forked from unom/punktfunk
Compare commits
307
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d27e62f7c9 | ||
|
|
0a72959ef7 | ||
|
|
2d223274fc | ||
|
|
92f617a989 | ||
|
|
2f071a9a93 | ||
|
|
62d35bc4b6 | ||
|
|
5d06ef26ac | ||
|
|
fcf4076eb7 | ||
|
|
53eb592c43 | ||
|
|
956d8dd8ef | ||
|
|
b2e716ad5f | ||
|
|
ec288d64d3 | ||
|
|
68353a5d57 | ||
|
|
ffd5a33598 | ||
|
|
4af8b02be1 | ||
|
|
42a0dd52be | ||
|
|
b31495bea5 | ||
|
|
9fb41affba | ||
|
|
ee0b179618 | ||
|
|
d7e22c3db2 | ||
|
|
c1231fa2e6 | ||
|
|
1db7058a5d | ||
|
|
83a12c7413 | ||
|
|
7b1554af4b | ||
|
|
8f35155c14 | ||
|
|
0d407a866d | ||
|
|
0890cf3244 | ||
|
|
bf2d8505cf | ||
|
|
414380fc9e | ||
|
|
6267dcdcd3 | ||
|
|
8042a2fd52 | ||
|
|
7e40098bc6 | ||
|
|
ac5299d4ce | ||
|
|
2d43275fcb | ||
|
|
77ddd05b13 | ||
|
|
017c37b78a | ||
|
|
2fd303e22f | ||
|
|
a9a514dea0 | ||
|
|
f84c5b8114 | ||
|
|
aec02b9d26 | ||
|
|
48bb1769b4 | ||
|
|
173be61213 | ||
|
|
454fa2e0cb | ||
|
|
6e001e54b4 | ||
|
|
9f1f23eb40 | ||
|
|
d1c4cb18dd | ||
|
|
91aa684f0d | ||
|
|
e629606e39 | ||
|
|
ff5602361f | ||
|
|
5e319f3b77 | ||
|
|
34ad3cc611 | ||
|
|
857d7d7b6b | ||
|
|
f3c0ee47d7 | ||
|
|
80b4eccff9 | ||
|
|
63a4f583b9 | ||
|
|
290d760ea4 | ||
|
|
31b5f90b12 | ||
|
|
8abdd74a62 | ||
|
|
69f1db5ea9 | ||
|
|
7331be0a40 | ||
|
|
4bc7eecf05 | ||
|
|
dbc12dedcc | ||
|
|
2dfb7791a2 | ||
|
|
66a28d5abb | ||
|
|
e2faecfd42 | ||
|
|
5e19a4611f | ||
|
|
6f54fcdd2d | ||
|
|
c6597cbeb5 | ||
|
|
2cfc82e96c | ||
|
|
e9a209ef61 | ||
|
|
a12f1f092c | ||
|
|
3055e29ebb | ||
|
|
76832a5b86 | ||
|
|
7077b0a0df | ||
|
|
e5453aebb7 | ||
|
|
ec4bf75a6e | ||
|
|
2c03290a5e | ||
|
|
b6a370a0fd | ||
|
|
7db83445b2 | ||
|
|
5582a6ea51 | ||
|
|
f7b85ec1fd | ||
|
|
327301e012 | ||
|
|
ab4cd06e86 | ||
|
|
3eab1e41df | ||
|
|
62573d2781 | ||
|
|
d383fa6103 | ||
|
|
2032c48ffa | ||
|
|
9a52c279f1 | ||
|
|
93608980ae | ||
|
|
1feeff3ca6 | ||
|
|
5be494f490 | ||
|
|
1ae8b4d4ca | ||
|
|
33ecd8e1a5 | ||
|
|
48565c4e9e | ||
|
|
e9a7373c76 | ||
|
|
f7a8c2013d | ||
|
|
926e2ccbdd | ||
|
|
b8b38d082e | ||
|
|
9979489b56 | ||
|
|
14502769e0 | ||
|
|
db1faef9fb | ||
|
|
442ea12b96 | ||
|
|
1e56705b86 | ||
|
|
365caa23be | ||
|
|
0d5e5b436b | ||
|
|
3a48cc2470 | ||
|
|
64a392634e | ||
|
|
35285afafc | ||
|
|
0d0e7e6861 | ||
|
|
143454590f | ||
|
|
9409d0a04c | ||
|
|
212bdc3b08 | ||
|
|
45cb525035 | ||
|
|
6fed1510ba | ||
|
|
4fd240deab | ||
|
|
f71bee917b | ||
|
|
e32bd30c85 | ||
|
|
2f1ef44191 | ||
|
|
6de78213ee | ||
|
|
aa3bcfd0d0 | ||
|
|
8ee224e5db | ||
|
|
6b3c582eb1 | ||
|
|
e08474d96d | ||
|
|
f422ae3e38 | ||
|
|
e38e3c44c9 | ||
|
|
b1ac4d02de | ||
|
|
5f55fa874a | ||
|
|
8af6e2dd02 | ||
|
|
e8499e6131 | ||
|
|
d839f4c2b6 | ||
|
|
a10bde39bb | ||
|
|
b5f91d50bb | ||
|
|
0de161e29b | ||
|
|
b297542c4d | ||
|
|
98e040fd01 | ||
|
|
5174a59832 | ||
|
|
c2a6d30d7b | ||
|
|
20de58a78a | ||
|
|
97b2c01ac1 | ||
|
|
29473d6280 | ||
|
|
b6acbd096e | ||
|
|
d63e913f52 | ||
|
|
362595b20f | ||
|
|
caa47e28e6 | ||
|
|
652abeb397 | ||
|
|
48511d1267 | ||
|
|
3e649d372e | ||
|
|
0d004c4680 | ||
|
|
8d7e273a96 | ||
|
|
ed3d236ab8 | ||
|
|
e726542f96 | ||
|
|
e0427a3bb6 | ||
|
|
02a5bdb965 | ||
|
|
09b9ee8f53 | ||
|
|
43e3c7b69f | ||
|
|
ea469162f9 | ||
|
|
213b353dad | ||
|
|
23ec0822d8 | ||
|
|
49bbdcf4ef | ||
|
|
3c509d48c9 | ||
|
|
b8d987b145 | ||
|
|
2d3f9f8690 | ||
|
|
951bcec650 | ||
|
|
badda070ef | ||
|
|
46bcfc3041 | ||
|
|
f3a39df7b3 | ||
|
|
f9c56eaf5c | ||
|
|
b69ef02f4d | ||
|
|
4d45a96ff9 | ||
|
|
849baea881 | ||
|
|
c767a904d2 | ||
|
|
0985726415 | ||
|
|
23f1debe69 | ||
|
|
ff190e9825 | ||
|
|
f66de3eba4 | ||
|
|
f2e1b9872c | ||
|
|
dc57aa653c | ||
|
|
b8f603c8a1 | ||
|
|
0751265105 | ||
|
|
b9b0df349d | ||
|
|
de17ceb8f8 | ||
|
|
9e505aba41 | ||
|
|
4a5d4b0a71 | ||
|
|
55e01c1460 | ||
|
|
e30d94573a | ||
|
|
4575134c21 | ||
|
|
10a1863cc7 | ||
|
|
6a4ffcb15c | ||
|
|
a7143a6510 | ||
|
|
43a631ea9c | ||
|
|
6af067da2d | ||
|
|
8e877ad25f | ||
|
|
0c4a543831 | ||
|
|
4c98e2f788 | ||
|
|
6be8e5d6fa | ||
|
|
7ec3107b4a | ||
|
|
9f72a3b6ad | ||
|
|
253e0bbe7c | ||
|
|
47eb8c9f6f | ||
|
|
766991cf6a | ||
|
|
41e7035441 | ||
|
|
27861b52b8 | ||
|
|
c846b165ae | ||
|
|
d3870294d0 | ||
|
|
e861565e27 | ||
|
|
79856d2c50 | ||
|
|
efac33fd33 | ||
|
|
2ce0bea830 | ||
|
|
fd75e66041 | ||
|
|
f3e122b0d0 | ||
|
|
e54258b8ac | ||
|
|
a681de7e5c | ||
|
|
6286f91f89 | ||
|
|
55e01b4dcb | ||
|
|
d17e942db0 | ||
|
|
a7d4213778 | ||
|
|
15392bd707 | ||
|
|
698925a036 | ||
|
|
74863c96b3 | ||
|
|
5926306a4c | ||
|
|
9c5af8d7e1 | ||
|
|
3594bc029e | ||
|
|
fd3c54bd43 | ||
|
|
020306b5ac | ||
|
|
5807d3a8a6 | ||
|
|
2a6bf3c421 | ||
|
|
957cf7781c | ||
|
|
7cf71dd218 | ||
|
|
86d4b36bfa | ||
|
|
c002ca8746 | ||
|
|
d383161723 | ||
|
|
49bc93cd20 | ||
|
|
780791ed81 | ||
|
|
4240b76182 | ||
|
|
de8430097e | ||
|
|
5520167958 | ||
|
|
f4f83202cb | ||
|
|
440cb8d1ec | ||
|
|
43868af1f5 | ||
|
|
19392918ff | ||
|
|
78926be4ac | ||
|
|
759aac255b | ||
|
|
4457356ee4 | ||
|
|
fe552a50bb | ||
|
|
1d31e4c565 | ||
|
|
a5896f0883 | ||
|
|
74ca42568a | ||
|
|
21787ca6cd | ||
|
|
b164b4c03e | ||
|
|
a87fbebc0a | ||
|
|
f6cfe382fd | ||
|
|
84c6938562 | ||
|
|
6b0bd59b8d | ||
|
|
15361b0091 | ||
|
|
ccf5c922ee | ||
|
|
fa822744ff | ||
|
|
f9faab780e | ||
|
|
a6ff0350e4 | ||
|
|
984f7be896 | ||
|
|
e08fd91cd1 | ||
|
|
581320df0c | ||
|
|
87b6fa8813 | ||
|
|
2748b84933 | ||
|
|
98147fb89c | ||
|
|
00c29f82f2 | ||
|
|
5742ec9548 | ||
|
|
1c15ba89c3 | ||
|
|
85dd75437b | ||
|
|
8140d3f8b3 | ||
|
|
789dafc95f | ||
|
|
ecb3d1ab22 | ||
|
|
e71f4696ee | ||
|
|
d014cea489 | ||
|
|
cd6ceb98e3 | ||
|
|
f87c1e6cec | ||
|
|
c04c5be224 | ||
|
|
ef2bb56251 | ||
|
|
d9912aa795 | ||
|
|
c77823d800 | ||
|
|
3bb30cb2f6 | ||
|
|
e90c5d5bcd | ||
|
|
ec675261fc | ||
|
|
1984ddb942 | ||
|
|
696386dee7 | ||
|
|
5790a3e334 | ||
|
|
3b1485e2a1 | ||
|
|
4a540bddc8 | ||
|
|
51d5f6cb29 | ||
|
|
3e21398c16 | ||
|
|
239c69fd71 | ||
|
|
06a249e49f | ||
|
|
bb1f93d90e | ||
|
|
bb48225414 | ||
|
|
73e1224345 | ||
|
|
1c836afc02 | ||
|
|
beefeaf9d7 | ||
|
|
5b6fe7882a | ||
|
|
c64ada5649 | ||
|
|
b275e6d34c | ||
|
|
c4c4f217bf | ||
|
|
cc01562631 | ||
|
|
940bd0b7ec | ||
|
|
77517bbe21 | ||
|
|
8d6241efae | ||
|
|
384a0adc83 | ||
|
|
553676282a | ||
|
|
02e9cc4691 |
@@ -0,0 +1,95 @@
|
||||
# Move a versionCode that is ALREADY on Google Play between tracks — no rebuild.
|
||||
#
|
||||
# Why this is separate from android.yml: promotion must not rebuild. A rebuild produces a fresh
|
||||
# versionCode (github.run_number) from possibly-newer sources, so it ships something nobody tested;
|
||||
# promoting assigns the byte-identical artifact the testers already ran. Bolting this onto
|
||||
# android.yml would mean an `if:` on all ten of its build steps.
|
||||
#
|
||||
# What it is for:
|
||||
# * promote a tested build up a track (alpha -> production)
|
||||
# * roll production back by re-pointing it at an older versionCode (to_track=production,
|
||||
# version_code=<the good one>, from_track blank)
|
||||
# * halt a rollout (status=halted)
|
||||
#
|
||||
# Defaults are deliberately the safe ones: dry_run starts TRUE, so a mis-typed versionCode
|
||||
# validates and deletes the edit instead of publishing. Flip it to false only when the dry run
|
||||
# printed what you meant.
|
||||
name: android-promote
|
||||
|
||||
# Two concurrent promotions would race on the same Play edit; the loser fails with a stale-edit
|
||||
# error. One at a time, and never cancel one mid-flight — a half-applied track change is worse
|
||||
# than a queued one.
|
||||
concurrency:
|
||||
group: android-promote
|
||||
cancel-in-progress: false
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version_code:
|
||||
description: 'versionCode already on Play (e.g. 10816)'
|
||||
required: true
|
||||
to_track:
|
||||
description: 'destination track'
|
||||
required: true
|
||||
default: 'production'
|
||||
from_track:
|
||||
description: 'track to verify it is on, then clear (blank = touch nothing else)'
|
||||
required: false
|
||||
default: 'alpha'
|
||||
notes_tag:
|
||||
description: "tag whose docs/releases/whatsnew/<tag>.txt to attach, e.g. v0.23.0 (blank = none)"
|
||||
required: false
|
||||
default: ''
|
||||
status:
|
||||
description: 'completed (100%) | inProgress (needs user_fraction) | halted | draft'
|
||||
required: true
|
||||
default: 'completed'
|
||||
user_fraction:
|
||||
description: 'staged rollout fraction for inProgress, e.g. 0.2 (blank otherwise)'
|
||||
required: false
|
||||
default: ''
|
||||
dry_run:
|
||||
description: 'validate only, publish nothing'
|
||||
required: true
|
||||
default: 'true'
|
||||
|
||||
jobs:
|
||||
promote:
|
||||
runs-on: ubuntu-24.04
|
||||
# Same image as android.yml purely for python3 + openssl (play-upload.py's only deps); it is
|
||||
# already warm on the runner. Nothing here builds.
|
||||
container:
|
||||
image: 192.168.1.58:5010/punktfunk-android-ci:latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Promote
|
||||
env:
|
||||
SERVICE_ACCOUNT_JSON: ${{ secrets.SERVICE_ACCOUNT_JSON }}
|
||||
VERSION_CODE: ${{ inputs.version_code }}
|
||||
TO_TRACK: ${{ inputs.to_track }}
|
||||
FROM_TRACK: ${{ inputs.from_track }}
|
||||
NOTES_TAG: ${{ inputs.notes_tag }}
|
||||
STATUS: ${{ inputs.status }}
|
||||
USER_FRACTION: ${{ inputs.user_fraction }}
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
run: |
|
||||
set -- --package io.unom.punktfunk \
|
||||
--promote "$VERSION_CODE" \
|
||||
--track "$TO_TRACK" --status "$STATUS"
|
||||
# Explicit `if`, not `[ ] && …`: under `sh -e` a false AND-OR list that ends up LAST in
|
||||
# the script aborts the step, and these get reordered.
|
||||
if [ -n "$FROM_TRACK" ]; then set -- "$@" --promote-from "$FROM_TRACK"; fi
|
||||
if [ -n "$USER_FRACTION" ]; then set -- "$@" --user-fraction "$USER_FRACTION"; fi
|
||||
if [ -n "$NOTES_TAG" ]; then
|
||||
NOTES="docs/releases/whatsnew/${NOTES_TAG}.txt"
|
||||
# Fail loudly rather than silently publishing with the PREVIOUS release's text still
|
||||
# showing on the store listing.
|
||||
[ -f "$NOTES" ] || { echo "ERROR: no such notes file: $NOTES"; exit 1; }
|
||||
set -- "$@" --release-notes-file "$NOTES"
|
||||
fi
|
||||
if [ "$DRY_RUN" = "true" ]; then set -- "$@" --no-commit; fi
|
||||
echo "promoting versionCode=$VERSION_CODE -> $TO_TRACK (dry_run=$DRY_RUN)"
|
||||
python3 clients/android/ci/play-upload.py "$@"
|
||||
@@ -34,9 +34,10 @@ on:
|
||||
- 'rust-toolchain.toml'
|
||||
- 'scripts/ci/**'
|
||||
- '.gitea/workflows/android.yml'
|
||||
# Single project version: a `vX.Y.Z` tag is THE release (uploads to Play's `alpha` closed
|
||||
# track for manual promotion + attaches the .aab/.apk to the unified Gitea Release). A main
|
||||
# push is canary (Play `internal`).
|
||||
# Single project version: a `vX.Y.Z` tag is THE release (publishes to Play `production` at
|
||||
# 100% + attaches the .aab/.apk to the unified Gitea Release). A main push is canary
|
||||
# (Play `internal`). Production access was granted 2026-08-01; before that a tag could only
|
||||
# reach `alpha` and someone had to promote it by hand in the Console.
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
paths:
|
||||
@@ -75,6 +76,56 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# FIRST, because it costs a second and everything after it costs ten minutes.
|
||||
#
|
||||
# A release tag MUST carry its own Play "What's new". If the file is absent Play does not
|
||||
# show nothing — it carries the PREVIOUS release's text onto this version, so production
|
||||
# users read notes for a build they are not getting. That is the same defect the v0.22.3
|
||||
# notes shipped (see docs/releases/README.md), and it is invisible until someone reads the
|
||||
# store listing. Failing here also means a missing file cannot leave a half-published
|
||||
# release: nothing is built, nothing is attached to the Gitea release, nothing reaches Play.
|
||||
#
|
||||
# Canary is exempt on purpose: it has no curated notes, and Play reusing text for internal
|
||||
# testers costs nothing.
|
||||
- name: Play release notes gate (tags only)
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
run: |
|
||||
NOTES="docs/releases/whatsnew/${GITHUB_REF_NAME}.txt"
|
||||
if [ ! -f "$NOTES" ]; then
|
||||
echo "ERROR: $NOTES does not exist."
|
||||
echo "A production release needs its own Play 'What's new' (<=500 chars, written for"
|
||||
echo "phone/TV users). Without it Play reuses the previous release's text."
|
||||
echo "See docs/releases/README.md; copy docs/releases/whatsnew/TEMPLATE.txt."
|
||||
exit 1
|
||||
fi
|
||||
# A verbatim copy of another release's file is the same bug wearing a hat: the store
|
||||
# listing still describes the wrong build. Cheap to check, and only ever trips on an
|
||||
# actual copy-paste that was never edited.
|
||||
for other in docs/releases/whatsnew/*.txt; do
|
||||
if [ "$other" != "$NOTES" ] && [ "$other" != "docs/releases/whatsnew/TEMPLATE.txt" ]; then
|
||||
if cmp -s "$NOTES" "$other"; then
|
||||
echo "ERROR: $NOTES is byte-identical to $other."
|
||||
echo "Write notes describing THIS release, not the one before it."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
# Length is checked here as well as in play-upload.py. Not redundant: the uploader is
|
||||
# the last line of defence (and the only one android-promote.yml gets), but it runs at
|
||||
# step 9 — this catches an unedited TEMPLATE copy at step 1 instead of after the build.
|
||||
# Must count CHARACTERS, not bytes: Play's cap is 500 chars and `•` is 3 bytes in UTF-8,
|
||||
# so `wc -c` would reject a file that is comfortably legal.
|
||||
python3 - "$NOTES" <<'PY'
|
||||
import sys
|
||||
path = sys.argv[1]
|
||||
text = open(path, encoding="utf-8").read().strip()
|
||||
if not text:
|
||||
sys.exit(f"ERROR: {path} is empty.")
|
||||
if len(text) > 500:
|
||||
sys.exit(f"ERROR: {path} is {len(text)} chars; Play allows 500. Trim it.")
|
||||
print(f"Play release notes OK: {path} ({len(text)}/500 chars)")
|
||||
PY
|
||||
|
||||
# Everything below the checkout used to be four download steps (JDK, SDK,
|
||||
# NDK+CMake, cargo-ndk — the flakiest, heaviest part of the job); it is all baked
|
||||
# into the image now. This guard only re-asserts the Android targets so a
|
||||
@@ -109,6 +160,14 @@ jobs:
|
||||
key: gradle-${{ hashFiles('clients/android/**/*.gradle.kts', 'clients/android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-
|
||||
|
||||
# The kit's JVM unit tests — the pure parsers, migrations and feedback policies. They were
|
||||
# running nowhere: this workflow only assembled, and android-screenshots.yml runs the :app
|
||||
# module's tests, so nothing enforced :kit's. Cheap (a couple of seconds against an already
|
||||
# built module) and it is the only automated cover those behaviours have.
|
||||
- name: kit unit tests
|
||||
working-directory: clients/android
|
||||
run: ./gradlew :kit:testDebugUnitTest --stacktrace
|
||||
|
||||
- name: assembleDebug (cargo-ndk → jniLibs → APK)
|
||||
working-directory: clients/android
|
||||
env:
|
||||
@@ -122,11 +181,20 @@ jobs:
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="alpha" ;; # alpha = built-in closed testing
|
||||
refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="production" ;;
|
||||
*) VN="${PF_BASE}-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;;
|
||||
esac
|
||||
echo "VERSION_NAME=$VN" >> "$GITHUB_ENV"
|
||||
echo "PLAY_TRACK=$TRACK" >> "$GITHUB_ENV"
|
||||
# Play's own "What's new" (500-char cap, its own file — the vX.Y.Z.md body is ~34 KB).
|
||||
# On a tag the gate step above already proved this exists, so the else branch is only
|
||||
# ever the canary path. See docs/releases/README.md.
|
||||
NOTES="docs/releases/whatsnew/${GITHUB_REF_NAME}.txt"
|
||||
if [ -f "$NOTES" ]; then
|
||||
echo "PLAY_NOTES=$NOTES" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "no Play release notes at $NOTES (canary — Play keeps the previous text)"
|
||||
fi
|
||||
echo "android version $VN -> Play track '$TRACK'"
|
||||
|
||||
- name: Build Release (signed AAB + universal APK)
|
||||
@@ -199,15 +267,21 @@ jobs:
|
||||
# Direct Publishing-API upload instead of r0adkll/upload-google-play — that action hides the
|
||||
# real API error behind "Unknown error occurred."; this prints it. stdlib + openssl only (no
|
||||
# pip), reuses SERVICE_ACCOUNT_JSON (raw JSON or base64), auto-handles changesNotSentForReview.
|
||||
# Track: canary main -> `internal`; a vX.Y.Z release -> `alpha` (closed testing) for manual
|
||||
# promotion to production in the Play console.
|
||||
# Track: canary main -> `internal`; a vX.Y.Z release -> `production` at 100% (`completed`).
|
||||
#
|
||||
# A tag therefore ships to real users with no further click. Two things keep that honest:
|
||||
# the tag is only pushed once every platform is green, and Play reviews each production
|
||||
# release before it reaches anyone. To ramp instead of going straight to 100%, this is
|
||||
# `--status inProgress --user-fraction 0.2`; to undo a bad one, halt or roll back from the
|
||||
# Console (or `android-promote.yml`, which can re-point production at an older versionCode).
|
||||
- name: Upload to Google Play
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
env:
|
||||
SERVICE_ACCOUNT_JSON: ${{ secrets.SERVICE_ACCOUNT_JSON }}
|
||||
run: |
|
||||
echo "uploading to Play track '$PLAY_TRACK'"
|
||||
python3 clients/android/ci/play-upload.py \
|
||||
--package io.unom.punktfunk \
|
||||
--aab clients/android/app/build/outputs/bundle/release/app-release.aab \
|
||||
--track "$PLAY_TRACK" --status completed
|
||||
set -- --package io.unom.punktfunk \
|
||||
--aab clients/android/app/build/outputs/bundle/release/app-release.aab \
|
||||
--track "$PLAY_TRACK" --status completed
|
||||
if [ -n "${PLAY_NOTES:-}" ]; then set -- "$@" --release-notes-file "$PLAY_NOTES"; fi
|
||||
python3 clients/android/ci/play-upload.py "$@"
|
||||
|
||||
@@ -31,6 +31,37 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Publish the SIGNED stable update manifest — the moment every host's update check learns
|
||||
# about this release (planning: host-update-from-web-console.md §3.3). Deliberately here in
|
||||
# announce, not on the tag: the manual "fleet is green, go" gate doubles as the gate for the
|
||||
# fleet-wide "update available". Fails the announce loudly if the key is missing (fail-closed)
|
||||
# or the installer's live bytes don't match their .sha256 sidecar. Pre-release tags are
|
||||
# ALWAYS skipped — an -rc must never enter the stable feed, even with allow_prerelease.
|
||||
- name: Publish the stable update manifest
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
UPDATE_MANIFEST_KEY: ${{ secrets.UPDATE_MANIFEST_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ inputs.tag }}"
|
||||
case "$TAG" in
|
||||
*-*) echo "pre-release tag $TAG — not publishing to the stable update feed"; exit 0 ;;
|
||||
esac
|
||||
VER="${TAG#v}"
|
||||
URL="https://git.unom.io/unom/punktfunk/releases/download/${TAG}/punktfunk-host-setup-${VER}.exe"
|
||||
# Re-download and re-hash the real bytes; the sidecar is a cross-check, never the truth.
|
||||
curl -fsSL "$URL" -o /tmp/installer.exe
|
||||
curl -fsSL "$URL.sha256" -o /tmp/installer.sha256
|
||||
SHA="$(sha256sum /tmp/installer.exe | awk '{print $1}')"
|
||||
grep -qi "$SHA" /tmp/installer.sha256 || {
|
||||
echo "ERROR: installer sha256 $SHA does not match the release's .sha256 sidecar" >&2
|
||||
exit 1
|
||||
}
|
||||
CHANNEL=stable VERSION="$VER" REQUIRE_KEY=1 \
|
||||
WINDOWS_URL="$URL" WINDOWS_SHA256="$SHA" \
|
||||
NOTES_URL="https://git.unom.io/unom/punktfunk/releases/tag/${TAG}" \
|
||||
bash scripts/ci/publish-update-manifest.sh
|
||||
|
||||
- name: Post release announcement to Discord
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
@@ -210,6 +210,13 @@ jobs:
|
||||
rm -rf dist-gamescope # never cache a failed build (an empty path is not saved)
|
||||
fi
|
||||
|
||||
# NOTE deliberately NO sysext image is built or published here: a prebuilt HOST binary on
|
||||
# SteamOS breaks on the next A/B soname bump (and /var — where sysexts live — is
|
||||
# per-partition-set), which is the standing packaging verdict behind the on-device
|
||||
# distrobox build (scripts/steamdeck/, see scripts/steamdeck/README.md). That flow builds
|
||||
# its own HDR gamescope too. packaging/arch/build-sysext.sh remains a by-hand tool for the
|
||||
# Deck CLIENT image and for operators who accept the prebuilt-host trade-off.
|
||||
|
||||
- name: Publish to the Gitea Arch registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
@@ -147,15 +147,16 @@ jobs:
|
||||
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binaries (build.rs)
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# THREE binaries ship in the client .deb, so all three are built here: the GTK shell,
|
||||
# punktfunk-client-session (the Vulkan/Skia streamer the shell execs for a connect), and
|
||||
# punktfunk-cli (the headless `punktfunk` front-end). build-client-deb.sh installs all
|
||||
# three; leaving punktfunk-cli out here made it fall over on `install: No such file or
|
||||
# FOUR binaries ship in the client .deb, so all four are built here: the GTK shell,
|
||||
# punktfunk-client-session (the Vulkan/Skia streamer the shell execs for a connect),
|
||||
# punktfunk-cli (the headless `punktfunk` front-end), and pf-update (the root helper
|
||||
# behind `punktfunk-client --apply-update`). build-client-deb.sh installs all four;
|
||||
# leaving punktfunk-cli out here made it fall over on `install: No such file or
|
||||
# directory`, because its build-if-missing guard only tested the first two and so decided
|
||||
# everything was already built. The HOST is built separately in the build-publish-host
|
||||
# job (Ubuntu 24.04 image + bundled FFmpeg 8).
|
||||
cargo build --release --locked \
|
||||
-p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-cli
|
||||
-p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-cli -p pf-update
|
||||
|
||||
- name: Build + smoke-boot web console (bun preset)
|
||||
# Gate the .deb on a real bun boot: the punktfunk-web .deb runs the Nitro `bun` preset
|
||||
|
||||
@@ -348,6 +348,21 @@ jobs:
|
||||
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
|
||||
Write-Output "web console smoke (bun): /login -> $code"
|
||||
if ($code -ne 200) { throw "web console failed to boot under bun" }
|
||||
|
||||
# WEB_OUTPUT_DIR has to be exported whether or not the step above ran. It used to be that step's
|
||||
# last line, so a CACHE HIT skipped it and left the variable unset — and pack-host-installer.ps1
|
||||
# treats an unset WEB_OUTPUT_DIR as "don't bundle the console", silently ("installer built
|
||||
# WITHOUT the web console"). That shipped in 0.22.1 and 0.22.2: no {app}\web, so no web-run.cmd,
|
||||
# so `web setup` bails, so no PunktfunkWeb task and no console at all. It also removed the only
|
||||
# thing that stopped bun before the copy (StopBunRuntimes was #ifdef WithWeb), while bun.exe kept
|
||||
# shipping under WithScripting — which is the "DeleteFile failed; code 5" modal on bun.exe.
|
||||
# The throw is the point: never silently ship a console-less installer again.
|
||||
- name: Export the console output dir (cache hit or fresh build)
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (-not (Test-Path 'web\.output\server\index.mjs')) {
|
||||
throw "web\.output is missing - neither the cache restore nor the build produced it, and the installer must not ship without the console"
|
||||
}
|
||||
"WEB_OUTPUT_DIR=$((Resolve-Path 'web\.output').Path)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
|
||||
- name: Build plugin/script runner bundle (bun)
|
||||
@@ -376,6 +391,33 @@ jobs:
|
||||
# error 3)" and took the whole job with it. ~1 min of rebuild is the correct price; the
|
||||
# same rotation is why the other Windows jobs use a fixed C:\t instead of a cached
|
||||
# workspace-relative target.
|
||||
# Every payload this job is SUPPOSED to bundle, asserted before packing. The packer treats each
|
||||
# one as optional — correct for a local debug pack, and the reason 0.22.1/0.22.2 shipped with no
|
||||
# web console: an unset WEB_OUTPUT_DIR omitted it behind a single Write-Host. CI knows it bundles
|
||||
# all of these, so here a missing input is a build failure rather than a quietly smaller
|
||||
# installer. (pack-host-installer.ps1 already does this for VB-CABLE, for the same reason.)
|
||||
- name: Verify every installer payload is present
|
||||
shell: pwsh
|
||||
run: |
|
||||
$need = @(
|
||||
@{ n = 'web console (WEB_OUTPUT_DIR)'; p = $env:WEB_OUTPUT_DIR; f = 'server\index.mjs' }
|
||||
@{ n = 'bun runtime (BUN_EXE)'; p = $env:BUN_EXE; f = '' }
|
||||
@{ n = 'plugin runner (SCRIPTING_BUNDLE)';p = $env:SCRIPTING_BUNDLE; f = '' }
|
||||
@{ n = 'FFmpeg DLLs (FFMPEG_DIR\bin)'; p = $env:FFMPEG_DIR; f = 'bin' }
|
||||
@{ n = 'VB-CABLE (VBCABLE_DIR)'; p = $env:VBCABLE_DIR; f = 'VBCABLE_Setup_x64.exe' }
|
||||
)
|
||||
$missing = @()
|
||||
foreach ($x in $need) {
|
||||
if (-not $x.p) { $missing += "$($x.n): env var not set"; continue }
|
||||
$full = if ($x.f) { Join-Path $x.p $x.f } else { $x.p }
|
||||
if (-not (Test-Path $full)) { $missing += "$($x.n): missing $full" }
|
||||
else { Write-Output "payload OK - $($x.n) -> $full" }
|
||||
}
|
||||
if ($missing.Count) {
|
||||
$missing | ForEach-Object { Write-Output "MISSING PAYLOAD - $_" }
|
||||
throw "$($missing.Count) installer payload(s) missing - refusing to ship an incomplete installer"
|
||||
}
|
||||
|
||||
- name: Pack + sign installer
|
||||
shell: pwsh
|
||||
env:
|
||||
@@ -460,6 +502,36 @@ jobs:
|
||||
# A separate Linux job, not another step in `package`: the deploy actions are Docker-based and do
|
||||
# not run on a Windows runner. `needs: package` also gives the ordering that matters — build-data
|
||||
# reads the manifests from the release, so it must not run before they are attached.
|
||||
# Publish the SIGNED canary update manifest after the canary installer lands (planning:
|
||||
# host-update-from-web-console.md §3.3 — canary rides this workflow because the installer is
|
||||
# the only artifact the manifest references by URL; other canary channels may trail by minutes,
|
||||
# which the per-PM apply path tolerates). A Linux job: the signer is bash+openssl. Skips (with
|
||||
# a warning) when UPDATE_MANIFEST_KEY is absent — a canary build must not fail over it.
|
||||
canary-manifest:
|
||||
needs: package
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Publish the canary update manifest
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
UPDATE_MANIFEST_KEY: ${{ secrets.UPDATE_MANIFEST_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Same derivation the package job used: canary = <next-minor base>'s major.minor + run#.
|
||||
eval "$(bash scripts/ci/pf-version.sh)"
|
||||
VER="${PF_MAJOR}.${PF_MINOR}.${GITHUB_RUN_NUMBER}"
|
||||
URL="https://${REGISTRY}/api/packages/${OWNER}/generic/${PKG}/${VER}/punktfunk-host-setup-${VER}.exe"
|
||||
curl -fsSL "$URL" -o /tmp/installer.exe
|
||||
SHA="$(sha256sum /tmp/installer.exe | awk '{print $1}')"
|
||||
CHANNEL=canary VERSION="$VER" CI_RUN="${GITHUB_RUN_NUMBER}" \
|
||||
WINDOWS_URL="$URL" WINDOWS_SHA256="$SHA" \
|
||||
NOTES_URL="https://git.unom.io/unom/punktfunk/releases" \
|
||||
bash scripts/ci/publish-update-manifest.sh
|
||||
|
||||
winget-source:
|
||||
needs: package
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
|
||||
+55
-11
@@ -1,10 +1,10 @@
|
||||
# Contributing to punktfunk
|
||||
# Contributing to Punktfunk
|
||||
|
||||
Thanks for your interest in contributing!
|
||||
|
||||
## Licensing of contributions (inbound = outbound)
|
||||
|
||||
punktfunk is dual-licensed under **MIT OR Apache-2.0**.
|
||||
Punktfunk is dual-licensed under **MIT OR Apache-2.0**.
|
||||
|
||||
> Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in
|
||||
> the work by you, as defined in the Apache-2.0 license, shall be dual licensed as **MIT OR
|
||||
@@ -28,6 +28,28 @@ If you add a new third-party dependency, it must be permissive (MIT / Apache-2.0
|
||||
Unicode-3.0 / etc.). `about.toml` holds the accepted-license allow-list; regenerate the attribution
|
||||
file with `scripts/gen-third-party-notices.sh` when the dependency tree changes.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
The Rust toolchain is **pinned exactly** in `rust-toolchain.toml`; rustup installs it for you the
|
||||
first time you build, so don't override it — a different rustc reformats files nobody touched.
|
||||
|
||||
The workspace links real system libraries, so a bare `cargo build --workspace` fails on a stock
|
||||
machine. The authoritative list is what CI installs, in `ci/rust-ci.Dockerfile` — on **Ubuntu 26.04**,
|
||||
which is what gets you FFmpeg 8:
|
||||
|
||||
```sh
|
||||
sudo apt install build-essential clang libclang-dev pkg-config cmake \
|
||||
libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libavfilter-dev libavdevice-dev \
|
||||
libpipewire-0.3-dev libopus-dev libwayland-dev libxkbcommon-dev \
|
||||
libgl-dev libegl-dev libgbm-dev \
|
||||
libgtk-4-dev libadwaita-1-dev libsdl3-dev \
|
||||
libvulkan-dev
|
||||
```
|
||||
|
||||
(The last two groups are the Linux client shell and `pf-ffvk`; skip them only if you never build
|
||||
those crates. `scripts/bootstrap-ubuntu.sh` sets up an Ubuntu **capture-test host** — NVIDIA, Sway,
|
||||
PipeWire — and is not a substitute for the list above.)
|
||||
|
||||
## Before you push
|
||||
|
||||
Enable the repo git hooks once per clone — they run the exact rustfmt gates CI runs (main
|
||||
@@ -38,18 +60,40 @@ on formatting alone:
|
||||
git config core.hooksPath scripts/git-hooks
|
||||
```
|
||||
|
||||
Then the usual full pass:
|
||||
Then the usual full pass. Use `--locked` as CI does — otherwise a silent `Cargo.lock` update can pass
|
||||
locally and fail CI:
|
||||
|
||||
```sh
|
||||
cargo fmt --all --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
cargo test --workspace --locked
|
||||
```
|
||||
|
||||
Generated artifacts are checked in and CI fails on drift: `include/punktfunk_core.h` (cbindgen) and
|
||||
`api/openapi.json` (`cargo run -p punktfunk-host -- openapi`). Match the surrounding code's comment
|
||||
density and naming. Commit messages end with the `Co-Authored-By` trailer (see `git log`).
|
||||
Two more gates that only apply to some changes:
|
||||
|
||||
See the [README's Build & test section](README.md#build--test-from-source) and
|
||||
[Design invariants](README.md#design-invariants) for the full build/test/run guide, and the
|
||||
[docs site](https://docs.punktfunk.unom.io) for architecture and per-platform guides.
|
||||
- **Touched `web/` or `docs-site/`?** CI builds and typechecks both. Run, in that directory:
|
||||
```sh
|
||||
bun install && bun run build && bun run lint
|
||||
```
|
||||
Build first — it generates the API client / MDX typegen that the typecheck imports.
|
||||
- **Touched Windows- or Linux-gated code from another OS?** `scripts/xcheck.sh windows` (or
|
||||
`linux`) type-checks and lints that platform's `#[cfg(target_os = …)]` code in about a second,
|
||||
instead of waiting for the CI job that compiles it.
|
||||
|
||||
Generated artifacts are checked in. `include/punktfunk_core.h` (cbindgen) is regenerated by the build
|
||||
and CI fails if the committed copy drifts. `api/openapi.json` is **not** gated — nothing in CI
|
||||
regenerates or diffs it, so regenerate and commit it yourself whenever you touch the management API,
|
||||
and copy the snapshot the docs site serves:
|
||||
|
||||
```sh
|
||||
cargo run -p punktfunk-host -- openapi > api/openapi.json
|
||||
cp api/openapi.json docs-site/public/openapi.json
|
||||
```
|
||||
|
||||
Match the surrounding code's comment density and naming. Commit messages end with the
|
||||
`Co-Authored-By` trailer (see `git log`).
|
||||
|
||||
See the [README's Build & test section](README.md#build--test-from-source) for the extra dev
|
||||
commands (the FEC loss harness, the standalone C-ABI proof) and
|
||||
[Design invariants](README.md#design-invariants) for the rules a change is expected to hold to, and
|
||||
the [docs site](https://docs.punktfunk.unom.io) for architecture and per-platform guides.
|
||||
|
||||
Generated
+71
-30
@@ -947,7 +947,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cursor-probe"
|
||||
version = "0.22.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-capture",
|
||||
@@ -1036,7 +1036,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.22.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
@@ -2221,7 +2221,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.22.1"
|
||||
version = "0.24.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2326,7 +2326,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.22.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2361,7 +2361,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.22.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2850,7 +2850,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.22.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2871,7 +2871,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.22.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2880,6 +2880,7 @@ dependencies = [
|
||||
"mdns-sd",
|
||||
"opus",
|
||||
"pf-ffvk",
|
||||
"pf-update-check",
|
||||
"pipewire",
|
||||
"punktfunk-core",
|
||||
"pyrowave-sys",
|
||||
@@ -2892,11 +2893,12 @@ dependencies = [
|
||||
"ureq",
|
||||
"wasapi",
|
||||
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.22.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2914,7 +2916,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.22.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2935,7 +2937,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.22.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2959,7 +2961,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.22.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
@@ -2968,7 +2970,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.22.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -2980,7 +2982,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.22.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -2994,11 +2996,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.22.1"
|
||||
version = "0.24.0"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.22.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3027,14 +3029,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.22.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.22.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3047,9 +3049,29 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-update"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-update-check"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"ring",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.22.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3082,7 +3104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.22.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-paths",
|
||||
@@ -3094,7 +3116,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.22.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3302,7 +3324,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.22.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3313,7 +3335,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.22.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3325,11 +3347,13 @@ dependencies = [
|
||||
"opus",
|
||||
"punktfunk-core",
|
||||
"tracing",
|
||||
"uac-host",
|
||||
"usbfs-iso",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.22.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3346,7 +3370,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.22.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3361,7 +3385,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.22.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
@@ -3381,7 +3405,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.22.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3413,7 +3437,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.22.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3449,6 +3473,7 @@ dependencies = [
|
||||
"pf-host-config",
|
||||
"pf-inject",
|
||||
"pf-paths",
|
||||
"pf-update-check",
|
||||
"pf-vdisplay",
|
||||
"pf-win-display",
|
||||
"pf-zerocopy",
|
||||
@@ -3497,7 +3522,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.22.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3511,7 +3536,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.22.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3534,7 +3559,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.22.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -4963,6 +4988,14 @@ version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "uac-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/unom-io/usbfs-iso?rev=f3de1fd62cec271d07f45664dc464f23e423e721#f3de1fd62cec271d07f45664dc464f23e423e721"
|
||||
dependencies = [
|
||||
"usbfs-iso",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
@@ -5042,6 +5075,14 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "usbfs-iso"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/unom-io/usbfs-iso?rev=f3de1fd62cec271d07f45664dc464f23e423e721#f3de1fd62cec271d07f45664dc464f23e423e721"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "usbip-sim"
|
||||
version = "0.8.0"
|
||||
|
||||
+3
-1
@@ -12,6 +12,8 @@ members = [
|
||||
"crates/pf-ffvk",
|
||||
"crates/pf-driver-proto",
|
||||
"crates/pf-paths",
|
||||
"crates/pf-update",
|
||||
"crates/pf-update-check",
|
||||
"crates/pf-host-config",
|
||||
"crates/pf-gpu",
|
||||
"crates/pf-zerocopy",
|
||||
@@ -51,7 +53,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.22.1"
|
||||
version = "0.24.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="assets/punktfunk-logo.svg" alt="punktfunk" width="320" />
|
||||
<img src="assets/punktfunk-logo.svg" alt="Punktfunk" width="320" />
|
||||
</p>
|
||||
|
||||
<p align="center"><b>Low-latency desktop and game streaming with first-class Linux and Windows hosts.</b></p>
|
||||
@@ -18,7 +18,7 @@ access** · **[r/Punktfunk](https://www.reddit.com/r/Punktfunk/)**.
|
||||
🔒 **Security:** found a vulnerability? Report it privately to **security@punktfunk.com** — see
|
||||
[SECURITY.md](SECURITY.md). Please don't open a public issue.
|
||||
|
||||
punktfunk pairs a **virtual-display streaming host** with native clients on every platform. It speaks
|
||||
Punktfunk pairs a **virtual-display streaming host** with native clients on every platform. It speaks
|
||||
the existing **GameStream** protocol, so any [Moonlight](https://moonlight-stream.org/) client works
|
||||
day one — and adds its own faster **`punktfunk/1`** protocol that breaks the ~1 Gbps FEC wall with a
|
||||
**GF(2¹⁶) Leopard-RS** transport. A single shared **Rust core** (`punktfunk-core`) holds the
|
||||
@@ -43,8 +43,13 @@ on Linux and Windows, and over a stable C ABI from the Apple and Android apps.
|
||||
- **Low latency, GPU end to end.** Frames go straight from the compositor to the NVENC encoder with
|
||||
zero CPU copies (dmabuf → CUDA/Vulkan → NVENC), over a transport tuned for responsiveness rather
|
||||
than throughput. Stable 240 fps at 5120×1440; sub-millisecond capture-to-reassembly on-box,
|
||||
~1.3 ms cross-machine on a LAN. (AMD/Intel encode via VAAPI, and a GPU-less software H.264
|
||||
encoder exists as a fallback.)
|
||||
~1.3 ms cross-machine on a LAN. (On Linux AMD/Intel, Vulkan Video for HEVC and AV1 with VAAPI for
|
||||
H.264 and as the fallback; a GPU-less software H.264 encoder exists as a last resort.)
|
||||
- **A library that fills itself.** Steam and non-Steam titles show up as a grid on every client, and
|
||||
plugins add their own sources — ROM Manager (your ROM collection, matched to installed emulators),
|
||||
Playnite, VirtualHere. Install them from the console's **Plugins** page or with
|
||||
`punktfunk-host plugins add`. See
|
||||
[Plugins](https://docs.punktfunk.unom.io/docs/plugins).
|
||||
- **Works with what you already have.** Any Moonlight/Artemis client connects over GameStream — and
|
||||
native apps for macOS, Linux, Windows, and Android use the lower-latency `punktfunk/1` protocol.
|
||||
- **Secure by default.** Hosts require a one-time SPAKE2 **PIN pairing**; after that, devices
|
||||
@@ -58,12 +63,12 @@ on Linux and Windows, and over a stable C ABI from the Apple and Android apps.
|
||||
| **Core** — `punktfunk-core` + C ABI (protocol · FEC · crypto · QUIC) | ✅ Complete & hardened |
|
||||
| **GameStream host** → stock Moonlight | ✅ Live end-to-end: pairing, RTSP, audio, per-client virtual output at native resolution, GPU zero-copy NVENC, gamepads |
|
||||
| **Native protocol** — `punktfunk/1` | ✅ Validated live: QUIC control + GF(2¹⁶) FEC/AES-GCM data plane, PIN pairing, mDNS discovery, mid-stream mode renegotiation |
|
||||
| **Windows host** (Windows 11 22H2+, x64) | 🟡 Implemented & shipping as a signed installer: its own all-Rust IddCx **virtual display** (secure-desktop capable) with a **sealed IDD-push** capture path — finished frames pushed straight into its own driver, not screen-scraped (no DDA/WGC) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel, software H.264 without a GPU) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green |
|
||||
| **Windows host** (Windows 11 22H2+, x64) | ✅ Beta — shipping as a signed installer: its own all-Rust IddCx **virtual display** (secure-desktop capable) with a **sealed IDD-push** capture path — finished frames pushed straight into its own driver, not screen-scraped (no DDA/WGC) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel, software H.264 without a GPU) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green |
|
||||
| **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode (HEVC, and AV1 on hardware that decodes it), controllers incl. DualSense, discovery, pairing, speed test |
|
||||
| **Linux client** (`clients/linux` + `clients/session`) | ✅ Streaming live: relm4/GTK4 launcher shell that spawns a Vulkan session binary — Vulkan Video / VAAPI / software decode, PipeWire audio, SDL3 controllers, Skia console UI; ships as Flatpak/apt/rpm/Arch |
|
||||
| **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, AAudio audio, controllers, discovery, pairing |
|
||||
| **Windows client** (`clients/windows`, WinUI 3) | ✅ Streaming live: WinUI 3 shell + Vulkan session presenter, hardware decode on all GPU vendors via Vulkan Video → D3D11VA → software (NVIDIA + Intel validated on glass), WASAPI audio, SDL3 controllers, discovery, pairing; ships as signed MSIX (x64 + ARM64). HDR10 implemented, on-glass validation pending |
|
||||
| **Web console + management API** (`web/`) | ✅ TanStack console over the OpenAPI mgmt API: host status, paired devices, on-demand PIN pairing, GPU selection, performance capture graphs, live host logs |
|
||||
| **Windows client** (`clients/windows`, WinUI 3) | ✅ Streaming live: WinUI 3 shell + Vulkan session presenter, hardware decode on all GPU vendors via Vulkan Video → D3D11VA → software (NVIDIA + Intel validated on glass), WASAPI audio, SDL3 controllers, discovery, pairing; ships as signed MSIX (x64 + ARM64). Hardware decode and HDR10 present validated on glass on NVIDIA and Intel, including HDR pass-through on the Intel D3D11VA path |
|
||||
| **Web console + management API** (`web/`) | ✅ TanStack console over the OpenAPI mgmt API: host status, paired devices, on-demand PIN pairing, game library, virtual-display presets, plugin store, GPU selection, performance capture graphs, live host logs, host updates |
|
||||
|
||||
Every native client also ships a tiered **stats overlay** (Compact / Normal / Detailed) with a
|
||||
shared vocabulary across platforms, and the session client carries a full gamepad-driven **console
|
||||
@@ -81,36 +86,76 @@ Both run from **one process**: bare `punktfunk-host serve` is the **secure nativ
|
||||
GameStream/Moonlight-compat planes (opt-in, trusted-LAN only — GameStream has inherent on-path
|
||||
weaknesses). The host is managed through a REST API and web console. Builds against FFmpeg 7 or 8.
|
||||
|
||||
Full milestone status: **[docs.punktfunk.unom.io/docs/status](https://docs.punktfunk.unom.io/docs/status)** ·
|
||||
roadmap: **[/docs/roadmap](https://docs.punktfunk.unom.io/docs/roadmap)**.
|
||||
What works where: **[the support matrix](https://docs.punktfunk.unom.io/docs/support-matrix)** ·
|
||||
where it's heading: **[the roadmap](https://docs.punktfunk.unom.io/docs/roadmap)**.
|
||||
|
||||
## Install the host
|
||||
|
||||
Pick your platform and install from its package registry — the per-platform guide covers adding the
|
||||
repo, first run, and the web console. The Linux host is the primary, most battle-tested path; a
|
||||
Windows host also ships as a signed installer (all-vendor: NVIDIA, AMD, Intel).
|
||||
repo, first run, and the web console. The Linux host is the primary, most battle-tested path; on
|
||||
SteamOS the host is built on-device by a script instead, and a Windows host ships as a signed
|
||||
installer (all-vendor: NVIDIA, AMD, Intel).
|
||||
|
||||
| Platform | Install | Guide |
|
||||
|--------|---------|-------|
|
||||
| **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu — GNOME](https://docs.punktfunk.unom.io/docs/ubuntu-gnome) · [KDE](https://docs.punktfunk.unom.io/docs/ubuntu-kde) |
|
||||
| **Bazzite / Fedora Atomic** (systemd-sysext) | `sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
|
||||
| **Fedora** (dnf) | `dnf install punktfunk punktfunk-web` *(after adding the repo)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) |
|
||||
| **Arch / Steam Deck** (pacman / sysext) | `pacman -Sy punktfunk-host` *(binary repo)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
|
||||
| **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu / Debian](https://docs.punktfunk.unom.io/docs/ubuntu) · [packaging/debian](packaging/debian/README.md) |
|
||||
| **Bazzite / Fedora Atomic** (systemd-sysext) | `curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh && sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
|
||||
| **Fedora** (dnf) | `sudo dnf install punktfunk` *(after adding the repo; the console comes with it)* | [Fedora](https://docs.punktfunk.unom.io/docs/fedora) · [packaging/rpm](packaging/rpm/README.md) |
|
||||
| **Arch / CachyOS** (pacman) | `sudo pacman -Syu punktfunk-host` *(binary repo — always a full `-Syu`)* | [Arch Linux](https://docs.punktfunk.unom.io/docs/arch) · [packaging/arch](packaging/arch/README.md) |
|
||||
| **SteamOS / Steam Deck** (on-device build) | `bash ~/punktfunk/scripts/steamdeck/install.sh` *(after cloning this repo to `~/punktfunk`)* | [SteamOS (Host)](https://docs.punktfunk.unom.io/docs/steamos-host) |
|
||||
| **Windows** (11 22H2+, x64) | `winget install unom.PunktfunkHost` *(after `winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest`)* · or the signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) · [packaging/winget](packaging/winget/README.md) |
|
||||
|
||||
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
|
||||
After install, run `punktfunk-host serve` inside your desktop session (the secure native default;
|
||||
add `--gamestream` on a trusted LAN if you also want stock Moonlight clients), then pair from the web
|
||||
console. Full instructions: **[docs.punktfunk.unom.io/docs/install](https://docs.punktfunk.unom.io/docs/install)**.
|
||||
|
||||
**Linux:** every package ships systemd **user** units, so you don't launch the host by hand. The
|
||||
host unit won't start until `~/.config/punktfunk/host.env` exists, so copy the template your package
|
||||
installed first:
|
||||
|
||||
```sh
|
||||
mkdir -p ~/.config/punktfunk
|
||||
# /usr/share/punktfunk/ on Fedora/Arch/Bazzite, /usr/share/punktfunk-host/ on Debian/Ubuntu
|
||||
# (on Bazzite take host.env.bazzite instead)
|
||||
cp /usr/share/punktfunk/host.env.example ~/.config/punktfunk/host.env
|
||||
|
||||
systemctl --user enable --now punktfunk-host # the streaming host
|
||||
systemctl --user enable --now punktfunk-web # the web console (Arch: install punktfunk-web first)
|
||||
```
|
||||
|
||||
The shipped host unit runs `serve --gamestream` — the native `punktfunk/1` plane **plus** the
|
||||
GameStream/Moonlight-compat planes, which belong on a trusted LAN only; for a native-only host drop
|
||||
the flag with a `systemctl --user edit punktfunk-host` drop-in (which needs an empty `ExecStart=`
|
||||
line before the replacement — the install guide has the snippet). Then open
|
||||
`https://<host-ip>:47992` and pair.
|
||||
|
||||
How the virtual display and input are wired up depends on your desktop — see
|
||||
[KDE](https://docs.punktfunk.unom.io/docs/kde) · [GNOME](https://docs.punktfunk.unom.io/docs/gnome) ·
|
||||
[Steam / gamescope](https://docs.punktfunk.unom.io/docs/gamescope) ·
|
||||
[Sway](https://docs.punktfunk.unom.io/docs/sway).
|
||||
|
||||
**Windows:** the installer registers and starts the host as a `LocalSystem` service, so there is
|
||||
nothing to run by hand — open the web console and pair. Use
|
||||
`punktfunk-host service start|stop|restart|status` if you need to control it. Upgrades happen in
|
||||
place — the console's **Updates** card, `winget upgrade unom.PunktfunkHost`, or the newer
|
||||
`setup.exe` over the old install; uninstall from Add/Remove Programs.
|
||||
|
||||
Full instructions: **[docs.punktfunk.unom.io/docs/install](https://docs.punktfunk.unom.io/docs/install)**.
|
||||
|
||||
The console's **Host** page also shows when a newer host is out, along with the exact command for
|
||||
how *this* box was installed (or a one-click **Update now** on Windows) — see
|
||||
[Updating the host](https://docs.punktfunk.unom.io/docs/updating). To remove it again, or to go back
|
||||
to an earlier version, see [Uninstalling](https://docs.punktfunk.unom.io/docs/uninstall) and
|
||||
[Release Channels](https://docs.punktfunk.unom.io/docs/channels#pin-a-version-or-roll-back).
|
||||
|
||||
## Connect a client
|
||||
|
||||
| Streaming to… | Use |
|
||||
|---|---|
|
||||
| Mac, iPhone, iPad, Apple TV | The **Apple app** (`clients/apple`) — also on TestFlight |
|
||||
| Linux desktop / laptop, Steam Deck | **`punktfunk-client`** (Flatpak / apt / rpm / Arch) |
|
||||
| Linux desktop / laptop | **`punktfunk-client`** (Flatpak / apt / rpm / Arch) |
|
||||
| Steam Deck | The **Decky plugin** in Gaming Mode — it launches the client for you ([Steam Deck](https://docs.punktfunk.unom.io/docs/steam-deck)); in Desktop Mode, the Flatpak directly |
|
||||
| Android phone or TV | The **Android app** (`clients/android`) |
|
||||
| Windows | Native **`punktfunk-client`** (signed MSIX) or **Moonlight** |
|
||||
| Scripts, automation, another launcher | **`punktfunk`** — the headless CLI shipped in the Linux client packages (`punktfunk pair`, `punktfunk hosts list --json`, `punktfunk launch <host>`) |
|
||||
| Anything else (browser, old phone, smart TV) | **Moonlight** over GameStream |
|
||||
|
||||
Each client discovers hosts on the network automatically and does a one-time
|
||||
@@ -122,7 +167,7 @@ Each client discovers hosts on the network automatically and does a one-time
|
||||
For development, or as an install fallback where no package is available:
|
||||
|
||||
```sh
|
||||
cargo build --workspace # core, host, tray, shared client crates, Linux shell + session client, probe (Linux & macOS)
|
||||
cargo build --workspace # core, host, tray, shared client crates, Linux shell + session client, the `punktfunk` CLI, probe (Linux & macOS)
|
||||
cargo test --workspace # unit + loopback + proptest + C ABI harness
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo fmt --all --check
|
||||
@@ -154,11 +199,14 @@ clients/
|
||||
session/ punktfunk-session, the Vulkan streaming session (Rust · SDL3 · ash · Skia console UI) — also runs standalone (gamescope, Decky)
|
||||
windows/ Windows desktop app (Rust · WinUI 3 · D3D11 · WASAPI · SDL3)
|
||||
android/ Android phone + TV app (Kotlin · Rust JNI core · AMediaCodec · AAudio)
|
||||
cli/ punktfunk, the headless client CLI — pair · hosts · wake · library · launch · punktfunk:// links
|
||||
probe/ headless reference / measurement client for punktfunk/1
|
||||
decky/ Steam Deck Decky plugin
|
||||
web/ web console (TanStack) over the management API — status · devices · pairing · GPUs · performance · logs
|
||||
web/ web console (TanStack) over the management API — status · devices · pairing · library · displays · plugins · GPUs · performance · logs · updates
|
||||
api/openapi.json management-API OpenAPI spec (regenerated via `punktfunk-host openapi`, checked in)
|
||||
packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite bootc image
|
||||
sdk/ `@punktfunk/host` — TypeScript management-API client + event stream (Effect)
|
||||
plugin-kit/ `@punktfunk/plugin-kit` — the plugin authoring kit (bun / TypeScript)
|
||||
packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite sysext + bootc · Windows installer + drivers · winget · Nix · gamescope
|
||||
docs-site/ public documentation site (Fumadocs) — https://docs.punktfunk.unom.io
|
||||
include/punktfunk_core.h cbindgen-generated C header (checked in)
|
||||
tools/ latency-probe · loss-harness (measurement)
|
||||
@@ -195,7 +243,7 @@ additional terms or conditions. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
### Third-party components
|
||||
|
||||
punktfunk's own source is MIT/Apache-2.0. Shipped binaries additionally link third-party components
|
||||
Punktfunk's own source is MIT/Apache-2.0. Shipped binaries additionally link third-party components
|
||||
under their own (permissive) licenses — see [`THIRD-PARTY-NOTICES.txt`](THIRD-PARTY-NOTICES.txt)
|
||||
(regenerate with `scripts/gen-third-party-notices.sh`). The Windows host and client builds also
|
||||
bundle FFmpeg under the **LGPL v2.1+** (dynamically linked, replaceable DLLs; the license text and
|
||||
@@ -203,7 +251,7 @@ notice ship in the installed `licenses/` folder).
|
||||
|
||||
### Trademarks
|
||||
|
||||
punktfunk is an independent project and is **not affiliated with, endorsed by, or sponsored by**
|
||||
Punktfunk is an independent project and is **not affiliated with, endorsed by, or sponsored by**
|
||||
NVIDIA, Microsoft, Sony, Valve, or the Moonlight project. "GameStream", "Moonlight", "Xbox",
|
||||
"DualSense", "DualShock", and "PlayStation" are trademarks of their respective owners and are used
|
||||
here only to describe interoperability.
|
||||
|
||||
+12
-3
@@ -1,8 +1,17 @@
|
||||
# Security Policy
|
||||
|
||||
punktfunk is a low-latency desktop/game streaming stack. A host is effectively remote control of a
|
||||
Punktfunk is a low-latency desktop/game streaming stack. A host is effectively remote control of a
|
||||
machine, so we take security reports seriously and appreciate responsible disclosure.
|
||||
|
||||
## Supported versions
|
||||
|
||||
Punktfunk ships on two tracks — **stable** (a `vX.Y.Z` tag; the current line is **0.22.x**) and
|
||||
**canary** (built from `main`). Fixes ship as a new release on those tracks; in practice
|
||||
we don't backport to older minor versions, so the supported versions are the latest stable release
|
||||
and the current canary build. If you're on an older build, please check that the issue still
|
||||
reproduces on the latest stable before reporting it. See
|
||||
[Release Channels](https://docs.punktfunk.unom.io/docs/channels).
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Please report security issues privately by email to security@punktfunk.com.**
|
||||
@@ -14,7 +23,7 @@ exposes other users before a fix exists.
|
||||
|
||||
The more of this you can give us, the faster we can act:
|
||||
|
||||
- The component and version (e.g. `punktfunk-host 0.9.0`, Windows or Linux, which client).
|
||||
- The component and version (e.g. `punktfunk-host 0.22.3`, Windows or Linux, which client).
|
||||
- The impact — what an attacker can do, and from what position (same LAN, a local service account,
|
||||
admin, a paired client, …).
|
||||
- Steps to reproduce, a proof-of-concept, or a crash/log if you have one.
|
||||
@@ -98,4 +107,4 @@ pursue legal action against researchers who:
|
||||
- give us reasonable time to remediate before public disclosure,
|
||||
- don't exfiltrate more data than needed to demonstrate the issue.
|
||||
|
||||
Thank you for helping keep punktfunk and its users safe.
|
||||
Thank you for helping keep Punktfunk and its users safe.
|
||||
|
||||
+25
-4
@@ -18,6 +18,7 @@ VENDORED THIRD-PARTY SOURCE (inside first-party crates)
|
||||
Vulkan-Headers (vendored, crates/pyrowave-sys) — https://github.com/KhronosGroup/Vulkan-Headers
|
||||
Font Awesome Free brand icons (vendored, assets/os-icons) — https://fontawesome.com
|
||||
Simple Icons (vendored, assets/os-icons) — https://simpleicons.org
|
||||
Bazzite logo (vendored, assets/os-icons) — https://github.com/ublue-os/bazzite
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
MANIFEST (crate version — SPDX license — source)
|
||||
@@ -2930,6 +2931,25 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
The following license (bazzite.txt) applies to: Bazzite logo (vendored, assets/os-icons)
|
||||
----------------------------------------------------------------------------
|
||||
Bazzite — the `bazzite` mark in assets/os-icons/ is derived from the Bazzite logo in
|
||||
the Bazzite source repository (repo_content/Bazzite.svg).
|
||||
|
||||
Copyright (c) Universal Blue (https://github.com/ublue-os/bazzite)
|
||||
|
||||
Licensed under the Apache License, Version 2.0,
|
||||
https://www.apache.org/licenses/LICENSE-2.0.
|
||||
|
||||
Modifications: the logo's "b" letterform was lifted out of the surrounding badge, the
|
||||
gradient and decorative overlays were dropped, and the path was translated and scaled
|
||||
into a 24x24 box with a monochrome fill (fill="currentColor").
|
||||
|
||||
Brand icons are trademarks of their respective owners and are used for identification
|
||||
purposes only; their use does not imply endorsement.
|
||||
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
The following license (LICENSE) applies to: bindgen 0.72.1
|
||||
----------------------------------------------------------------------------
|
||||
@@ -6283,8 +6303,8 @@ the following restrictions:
|
||||
----------------------------------------------------------------------------
|
||||
The following license (font-awesome-brands.txt) applies to: Font Awesome Free brand icons (vendored, assets/os-icons)
|
||||
----------------------------------------------------------------------------
|
||||
Font Awesome Free — brand icons (windows, apple, linux, steam, ubuntu, fedora,
|
||||
opensuse in assets/os-icons/) are from Font Awesome Free.
|
||||
Font Awesome Free — brand icons (apple, linux, steam, ubuntu, fedora, opensuse in
|
||||
assets/os-icons/) are from Font Awesome Free.
|
||||
|
||||
Copyright (c) Fonticons, Inc. (https://fontawesome.com)
|
||||
|
||||
@@ -12997,8 +13017,9 @@ SOFTWARE.
|
||||
----------------------------------------------------------------------------
|
||||
The following license (simple-icons.txt) applies to: Simple Icons (vendored, assets/os-icons)
|
||||
----------------------------------------------------------------------------
|
||||
Simple Icons — brand icons (arch, nixos, debian in assets/os-icons/) are from
|
||||
Simple Icons (https://simpleicons.org, https://github.com/simple-icons/simple-icons).
|
||||
Simple Icons — brand icons (arch, nixos, debian, cachyos, nobara in assets/os-icons/)
|
||||
are from Simple Icons
|
||||
(https://simpleicons.org, https://github.com/simple-icons/simple-icons).
|
||||
|
||||
The Simple Icons SVG path data is released under CC0 1.0 Universal (public domain
|
||||
dedication), https://creativecommons.org/publicdomain/zero/1.0/ — no attribution
|
||||
|
||||
+519
-1
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.21.0"
|
||||
"version": "0.23.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -2170,6 +2170,51 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/plugins/logs": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"plugins"
|
||||
],
|
||||
"summary": "Ingest runner log lines",
|
||||
"description": "The plugin/script runner ships its output here so the console's **Logs** page can show it.\n\nPlugins are not host child processes — the runner is a separate `bun` process that `import()`s\neach plugin in-process — so nothing a plugin logs passes through the host's own `tracing`, and\nbefore this endpoint the console's log page could not show a single plugin line. On Linux the\nfallback was `journalctl --user -u punktfunk-scripting`; on Windows the runner task writes no\nlog file at all, so a failing plugin was diagnosable only by stopping the scheduled task and\nre-running the runner by hand. Both are shell access on the host box, which is exactly what the\nconsole exists to avoid.\n\nLines land in the same ring as the host's own, sharing one `seq` cursor, targeted\n`plugin:<source>` — so `GET /logs` needs no second cursor and the console needs no second poll.",
|
||||
"operationId": "ingestPluginLogs",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PluginLogBatch"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Lines ingested"
|
||||
},
|
||||
"400": {
|
||||
"description": "Batch too large",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/plugins/{id}": {
|
||||
"put": {
|
||||
"tags": [
|
||||
@@ -3432,6 +3477,142 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/update/apply": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"update"
|
||||
],
|
||||
"summary": "Apply the available update",
|
||||
"description": "Starts the one-click apply for install kinds that support it (Windows installer). The\nrequest carries no version or URL — the host installs exactly what its verified manifest\nannounced. Progress is polled via `GET /update/status` (`job`); the host restarts as part\nof the apply, and the outcome lands in `last_result` after it comes back.",
|
||||
"operationId": "applyUpdate",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApplyRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"202": {
|
||||
"description": "Apply started — poll `GET /update/status`",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateStatus"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Refused: unsupported install kind, apply disabled (PUNKTFUNK_UPDATE_APPLY=0), a job already running, an active streaming session without `force`, or nothing newer to apply",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/update/check": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"update"
|
||||
],
|
||||
"summary": "Check for updates now",
|
||||
"description": "Forces a manifest fetch + verification and returns the refreshed state. Rate-limited to\none forced check per 30 s.",
|
||||
"operationId": "forceUpdateCheck",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Refreshed update-check state (`last_error` carries a failed check; `not_published` an empty channel, which is not one)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateStatus"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Update checks are disabled on this host",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "A forced check ran less than 30 s ago",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/update/status": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"update"
|
||||
],
|
||||
"summary": "Update-check status",
|
||||
"description": "How this host was installed, which channel it follows, whether a newer release is known,\nand how to update. Reading this may kick a background refresh when the cached check is\nolder than 6 h; the response never blocks on the network.",
|
||||
"operationId": "getUpdateStatus",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Current update-check state",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateStatus"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -3764,6 +3945,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApplyRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"force": {
|
||||
"type": "boolean",
|
||||
"description": "Proceed even while a streaming session is live (the stream will drop when the host\nrestarts — the console warns before sending this)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApprovePending": {
|
||||
"type": "object",
|
||||
"description": "Approve-pending-device request body. Send `{}` to keep the device's own name.",
|
||||
@@ -4799,6 +4989,59 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"description": "A verified update manifest announced a release newer than the running host. Emitted\nonce per discovered version (a steady-state \"newer exists\" doesn't re-fire on every\nrefresh).",
|
||||
"required": [
|
||||
"version",
|
||||
"channel",
|
||||
"install_kind",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"channel": {
|
||||
"type": "string",
|
||||
"description": "The channel it was announced on (`stable` | `canary`)."
|
||||
},
|
||||
"install_kind": {
|
||||
"type": "string",
|
||||
"description": "This host's install kind (`apt`, `windows-installer`, …) — lets a hook or the\ntray render the right \"how to update\" hint without a second call."
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"update.available"
|
||||
]
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "The newer release's version string."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"description": "A host update completed: emitted by boot-time reconciliation, i.e. by the NEW binary's\nfirst start after a successful apply.",
|
||||
"required": [
|
||||
"from",
|
||||
"to",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"from": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"update.applied"
|
||||
]
|
||||
},
|
||||
"to": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -6040,6 +6283,50 @@
|
||||
"gamestream"
|
||||
]
|
||||
},
|
||||
"PluginLogBatch": {
|
||||
"type": "object",
|
||||
"description": "A batch of runner log lines.",
|
||||
"required": [
|
||||
"entries"
|
||||
],
|
||||
"properties": {
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PluginLogLine"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"PluginLogLine": {
|
||||
"type": "object",
|
||||
"description": "One log line produced by the runner or a plugin inside it (`POST /plugins/logs`).",
|
||||
"required": [
|
||||
"ts_ms",
|
||||
"level",
|
||||
"source",
|
||||
"msg"
|
||||
],
|
||||
"properties": {
|
||||
"level": {
|
||||
"type": "string",
|
||||
"description": "`ERROR` | `WARN` | `INFO` | `DEBUG` | `TRACE`. Anything else is coerced to `INFO`."
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "Which unit emitted it — a plugin's `definePlugin` name, a package name, or `runner`.\nSurfaced in the console's target column as `plugin:<source>`."
|
||||
},
|
||||
"ts_ms": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "When the line was produced, unix milliseconds. Kept verbatim — see\n[`crate::log_capture::LogRing::push_remote`].",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"PluginRegistration": {
|
||||
"type": "object",
|
||||
"description": "Register/renew body for `PUT /plugins/{id}`.",
|
||||
@@ -7040,6 +7327,233 @@
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdateJobInfo": {
|
||||
"type": "object",
|
||||
"description": "A running apply job (or a spawned installer that hasn't resolved yet).",
|
||||
"required": [
|
||||
"target_version",
|
||||
"stage",
|
||||
"received_bytes",
|
||||
"started_unix"
|
||||
],
|
||||
"properties": {
|
||||
"received_bytes": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0
|
||||
},
|
||||
"stage": {
|
||||
"type": "string",
|
||||
"description": "`downloading` | `verifying` | `applying` | `restarting`."
|
||||
},
|
||||
"started_unix": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0
|
||||
},
|
||||
"target_version": {
|
||||
"type": "string",
|
||||
"description": "The version being installed."
|
||||
},
|
||||
"total_bytes": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdateManifestInfo": {
|
||||
"type": "object",
|
||||
"description": "One channel's manifest facts, as much as the console renders.",
|
||||
"required": [
|
||||
"version",
|
||||
"serial",
|
||||
"published_at",
|
||||
"notes_url",
|
||||
"stale"
|
||||
],
|
||||
"properties": {
|
||||
"notes_url": {
|
||||
"type": "string",
|
||||
"description": "Release-notes link (pinned to our forge by the manifest validator)."
|
||||
},
|
||||
"published_at": {
|
||||
"type": "string",
|
||||
"description": "RFC-3339 publish time (display only)."
|
||||
},
|
||||
"serial": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Publish serial (unix seconds) — monotonic per channel.",
|
||||
"minimum": 0
|
||||
},
|
||||
"stale": {
|
||||
"type": "boolean",
|
||||
"description": "The last verified manifest is suspiciously old (>45 days) — the freeze/stale hint."
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "The released version this manifest announces."
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdateResultInfo": {
|
||||
"type": "object",
|
||||
"description": "Durable outcome of the most recent apply attempt (survives the host's own restart).",
|
||||
"required": [
|
||||
"ok",
|
||||
"from",
|
||||
"to",
|
||||
"finished_unix"
|
||||
],
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"finished_unix": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0
|
||||
},
|
||||
"from": {
|
||||
"type": "string"
|
||||
},
|
||||
"log_path": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The installer's own log file on this host, for diagnosis."
|
||||
},
|
||||
"ok": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"stage": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The stage that failed; absent on success."
|
||||
},
|
||||
"staged": {
|
||||
"type": "boolean",
|
||||
"description": "Applied but activates on the next reboot (rpm-ostree)."
|
||||
},
|
||||
"to": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdateStatus": {
|
||||
"type": "object",
|
||||
"description": "The full update-check state for this host.",
|
||||
"required": [
|
||||
"install_kind",
|
||||
"channel",
|
||||
"current_version",
|
||||
"apply",
|
||||
"channel_hint",
|
||||
"check_disabled",
|
||||
"available",
|
||||
"not_published"
|
||||
],
|
||||
"properties": {
|
||||
"apply": {
|
||||
"type": "string",
|
||||
"description": "What the console may offer for this install: `notify` (show the command) — later\nphases add `full` (one-click apply) and `staged` (apply + reboot to finish)."
|
||||
},
|
||||
"available": {
|
||||
"type": "boolean",
|
||||
"description": "A newer release than `current_version` exists for this channel (definitive\ncomparisons only — an unparseable version pair never flags)."
|
||||
},
|
||||
"channel": {
|
||||
"type": "string",
|
||||
"description": "Release channel this install follows: `stable` | `canary`."
|
||||
},
|
||||
"channel_hint": {
|
||||
"type": "string",
|
||||
"description": "The copy-pastable update command for this install kind."
|
||||
},
|
||||
"check_disabled": {
|
||||
"type": "boolean",
|
||||
"description": "Update checks are disabled on this host (`PUNKTFUNK_UPDATE_CHECK=0`)."
|
||||
},
|
||||
"current_version": {
|
||||
"type": "string",
|
||||
"description": "The running host version."
|
||||
},
|
||||
"install_kind": {
|
||||
"type": "string",
|
||||
"description": "How this host was installed: `windows-installer` | `sysext` | `rpm-ostree` | `apt` |\n`dnf` | `pacman` | `steamos-source` | `nix` | `source`."
|
||||
},
|
||||
"job": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/UpdateJobInfo",
|
||||
"description": "The apply in flight, if any."
|
||||
}
|
||||
]
|
||||
},
|
||||
"last_checked_unix": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "When the last successful check happened (unix seconds).",
|
||||
"minimum": 0
|
||||
},
|
||||
"last_error": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Why the last check failed, verbatim, if it did."
|
||||
},
|
||||
"last_result": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/UpdateResultInfo",
|
||||
"description": "Outcome of the most recent apply attempt."
|
||||
}
|
||||
]
|
||||
},
|
||||
"manifest": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/UpdateManifestInfo",
|
||||
"description": "The last verified manifest, if any check has succeeded."
|
||||
}
|
||||
]
|
||||
},
|
||||
"not_published": {
|
||||
"type": "boolean",
|
||||
"description": "The check reached the feed and found this channel has **no release published yet** —\nan expected state (a channel nobody has announced to answers with a 404), not a\nfailure. Mutually exclusive with `last_error`, so a UI can say \"nothing published yet\"\ninstead of painting an empty feed as a broken host. Never set once a manifest has been\nseen for this channel: a feed that loses a document it used to serve stays an error."
|
||||
},
|
||||
"opt_in_hint": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "This install could one-click apply, but the operator hasn't opted in yet — the\ncommand to run (Linux: join the `punktfunk-update` group)."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"securitySchemes": {
|
||||
@@ -7110,6 +7624,10 @@
|
||||
{
|
||||
"name": "store",
|
||||
"description": "Plugin store: browse signed catalogs (verified first-party entries, attributed third-party sources), install/uninstall as tracked jobs, and switch the plugin runner on"
|
||||
},
|
||||
{
|
||||
"name": "update",
|
||||
"description": "Host update check: install kind + channel, the last verified release manifest, and whether a newer host exists (admin lane only)"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
Bazzite — the `bazzite` mark in assets/os-icons/ is derived from the Bazzite logo in
|
||||
the Bazzite source repository (repo_content/Bazzite.svg).
|
||||
|
||||
Copyright (c) Universal Blue (https://github.com/ublue-os/bazzite)
|
||||
|
||||
Licensed under the Apache License, Version 2.0,
|
||||
https://www.apache.org/licenses/LICENSE-2.0.
|
||||
|
||||
Modifications: the logo's "b" letterform was lifted out of the surrounding badge, the
|
||||
gradient and decorative overlays were dropped, and the path was translated and scaled
|
||||
into a 24x24 box with a monochrome fill (fill="currentColor").
|
||||
|
||||
Brand icons are trademarks of their respective owners and are used for identification
|
||||
purposes only; their use does not imply endorsement.
|
||||
@@ -1,5 +1,5 @@
|
||||
Font Awesome Free — brand icons (windows, apple, linux, steam, ubuntu, fedora,
|
||||
opensuse in assets/os-icons/) are from Font Awesome Free.
|
||||
Font Awesome Free — brand icons (apple, linux, steam, ubuntu, fedora, opensuse in
|
||||
assets/os-icons/) are from Font Awesome Free.
|
||||
|
||||
Copyright (c) Fonticons, Inc. (https://fontawesome.com)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
Simple Icons — brand icons (arch, nixos, debian in assets/os-icons/) are from
|
||||
Simple Icons (https://simpleicons.org, https://github.com/simple-icons/simple-icons).
|
||||
Simple Icons — brand icons (arch, nixos, debian, cachyos, nobara in assets/os-icons/)
|
||||
are from Simple Icons
|
||||
(https://simpleicons.org, https://github.com/simple-icons/simple-icons).
|
||||
|
||||
The Simple Icons SVG path data is released under CC0 1.0 Universal (public domain
|
||||
dedication), https://creativecommons.org/publicdomain/zero/1.0/ — no attribution
|
||||
|
||||
+32
-10
@@ -7,7 +7,7 @@ Android `ImageVector`s). One file per **icon token** of the host's OS-identity c
|
||||
|
||||
| token | mark | source |
|
||||
|---|---|---|
|
||||
| `windows` | Windows | Font Awesome Free brands (CC BY 4.0) |
|
||||
| `windows` | Windows (the current four-pane mark, no perspective skew) | own geometry |
|
||||
| `apple` | Apple (also `macos` via alias) | Font Awesome Free brands (CC BY 4.0) |
|
||||
| `linux` | Tux | Font Awesome Free brands (CC BY 4.0) |
|
||||
| `steam` | Steam (also `steamos` via alias) | Font Awesome Free brands (CC BY 4.0) |
|
||||
@@ -17,14 +17,36 @@ Android `ImageVector`s). One file per **icon token** of the host's OS-identity c
|
||||
| `arch` | Arch Linux | Simple Icons (CC0 1.0) |
|
||||
| `nixos` | NixOS | Simple Icons (CC0 1.0) |
|
||||
| `debian` | Debian | Simple Icons (CC0 1.0) |
|
||||
| `bazzite` | Bazzite | ublue-os/bazzite (Apache-2.0) |
|
||||
| `cachyos` | CachyOS | Simple Icons (CC0 1.0) |
|
||||
| `nobara` | Nobara | Simple Icons (CC0 1.0, slug `nobaralinux`) |
|
||||
|
||||
Distros with no file here (Bazzite, CachyOS, Nobara, Pop!_OS, Mint, …) are intentional:
|
||||
the host advertises the full chain (`linux/fedora/bazzite`), and clients walk it
|
||||
most-specific-first, so they degrade to the family mark and finally to Tux.
|
||||
The last three are **distro leaves, not families**: a chain walks most-specific-first, so
|
||||
`linux/fedora/bazzite` would otherwise draw the Fedora mark. They earn their own art because
|
||||
"a Bazzite box" and "a Fedora box" are different machines to the person reading the card, and
|
||||
they are what this project's hosts actually run. Every other distro with no file here (Pop!_OS,
|
||||
Mint, …) still degrades to its family's mark and finally to Tux — that fallback is the design,
|
||||
not a gap.
|
||||
|
||||
All files are monochrome (`fill="currentColor"`), original per-icon viewBoxes preserved.
|
||||
Licensing: attribution notices live in `LICENSES/` and are folded into
|
||||
`THIRD-PARTY-NOTICES.txt` by `scripts/gen-third-party-notices.py`. The marks are
|
||||
trademarks of their respective owners; they are used here nominatively — to *identify*
|
||||
the operating system a host runs, the standard practice in this ecosystem — and imply no
|
||||
affiliation or endorsement.
|
||||
Windows is the one mark drawn here rather than sourced: every icon set that ships a "Windows"
|
||||
brand glyph still carries the **Windows 8/10 flag with the perspective skew**, which reads as
|
||||
dated next to the flat four-pane mark Microsoft has used since Windows 11. Four equal squares
|
||||
(11.377 + 1.246 gap) is the current proportion.
|
||||
|
||||
All files are monochrome (`fill="currentColor"`), original per-icon viewBoxes preserved. Because
|
||||
those viewBoxes are not all square, a client must letterbox rather than stretch — see the aspect
|
||||
note in `clients/android/.../components/OsIcons.kt`.
|
||||
|
||||
## Regenerating the per-client derivatives
|
||||
|
||||
`bash scripts/gen-os-icons.sh [token ...]` turns a master into the three baked forms (GTK
|
||||
symbolic SVG, Windows PNG, Apple template PDF) and prints the path data for the three clients
|
||||
that inline it (web console, Decky plugin, Android). Adding a **new** token also means adding it
|
||||
to each client's shipped-token list — the script prints that checklist too.
|
||||
|
||||
## Licensing
|
||||
|
||||
Attribution notices live in `LICENSES/` and are folded into `THIRD-PARTY-NOTICES.txt` by
|
||||
`scripts/gen-third-party-notices.py`. The marks are trademarks of their respective owners; they
|
||||
are used here nominatively — to *identify* the operating system a host runs, the standard
|
||||
practice in this ecosystem — and imply no affiliation or endorsement.
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
<!-- bazzite — the Bazzite "b", from ublue-os/bazzite repo_content/Bazzite.svg (Apache-2.0), lifted out of the badge and normalized to a 24x24 box. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M7.178 0h3.589v7.178h7.524c3.153 0 5.709 2.556 5.709 5.709 0 6.138-4.976 11.113-11.113 11.113-3.153 0-5.709-2.556-5.709-5.709V10.766H0v-3.589h7.178zm3.589 10.766v7.524c0 1.171.949 2.12 2.12 2.12 4.156 0 7.524-3.369 7.524-7.524 0-1.171-.949-2.12-2.12-2.12z"/></svg>
|
||||
|
After Width: | Height: | Size: 523 B |
@@ -0,0 +1,2 @@
|
||||
<!-- cachyos — from Simple Icons (CC0 1.0). See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M5.301 2.646 0 11.771l5.541 9.583h11.486l2.904-5.017H8.102l-2.56-4.429L8.067 7.54h6.063l2.83-4.893ZM20.058 4.12a.748.748 0 0 0 0 1.496.748.748 0 0 0 0-1.496m-1.983 4.303a1.45 1.45 0 0 0 0 2.9 1.45 1.45 0 0 0 0-2.9m4.02 3.98a1.904 1.904 0 0 0 0 3.809 1.904 1.904 0 0 0 0-3.81"/></svg>
|
||||
|
After Width: | Height: | Size: 438 B |
@@ -0,0 +1,2 @@
|
||||
<!-- nobara — from Simple Icons (CC0 1.0), slug "nobaralinux". See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M23.808 11.808v8.281a3.542 3.542 0 0 1-3.542 3.527h-.46a3.543 3.543 0 0 1-3.083-3.513v-7.282l3.543-1.013-3.66-1.045a4.724 4.724 0 0 0-9.33 1.045v2.362a2.362 2.362 0 0 0 2.362 2.362 3.543 3.543 0 0 1 3.543 3.542V24a3.539 3.539 0 0 0-3.542-3.542 3.537 3.537 0 0 0-3.063 1.76 3.54 3.54 0 0 1-2.382 1.398h-.46A3.542 3.542 0 0 1 .192 20.09V3.543a3.542 3.542 0 0 1 6.323-2.194A11.756 11.756 0 0 1 12 0c6.521 0 11.808 5.287 11.808 11.808zm-9.446 0A2.359 2.359 0 0 1 12 14.17a2.362 2.362 0 1 1 2.362-2.362z"/></svg>
|
||||
|
After Width: | Height: | Size: 681 B |
@@ -1,2 +1,2 @@
|
||||
<!-- windows — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaWindows. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" fill="currentColor"><path d="M0 93.7l183.6-25.3v177.4H0V93.7zm0 324.6l183.6 25.3V268.4H0v149.9zm203.8 28L448 480V268.4H203.8v177.9zm0-380.6v180.1H448V32L203.8 65.7z"/></svg>
|
||||
<!-- windows — the modern (Windows 11) four-pane mark: four equal squares, no perspective skew. Own geometry, see README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M0 0h11.377v11.377H0zm12.623 0H24v11.377H12.623zM0 12.623h11.377V24H0zm12.623 0H24V24H12.623z"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 344 B After Width: | Height: | Size: 323 B |
File diff suppressed because it is too large
Load Diff
@@ -44,6 +44,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.unom.punktfunk.kit.DsDevice
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.Sc2Capture
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -149,13 +150,14 @@ 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) }
|
||||
// Capture-side detection, re-checked on USB hot-plug. The SC2 is never an InputDevice
|
||||
// (lizard mode is kb/mouse; the capture claims even those away) so it's enumerated from
|
||||
// the USB device list + bonded BLE; a Sony pad IS an InputDevice until claimed, so its
|
||||
// row supplements the PadRow below with the capture status + the USB grant.
|
||||
var usbGeneration by remember { mutableIntStateOf(0) }
|
||||
DisposableEffect(Unit) {
|
||||
val receiver = object : android.content.BroadcastReceiver() {
|
||||
override fun onReceive(c: Context?, i: android.content.Intent?) { sc2Generation++ }
|
||||
override fun onReceive(c: Context?, i: android.content.Intent?) { usbGeneration++ }
|
||||
}
|
||||
val filter = android.content.IntentFilter().apply {
|
||||
addAction(android.hardware.usb.UsbManager.ACTION_USB_DEVICE_ATTACHED)
|
||||
@@ -170,16 +172,23 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
||||
onDispose { runCatching { context.unregisterReceiver(receiver) } }
|
||||
}
|
||||
val sc2Probe = remember { Sc2Capture(context) }
|
||||
val sc2Usb = remember(sc2Generation) { sc2Probe.findUsbDevice() }
|
||||
val sc2Ble = remember(sc2Generation) {
|
||||
val sc2Usb = remember(usbGeneration) { sc2Probe.findUsbDevice() }
|
||||
val sc2Ble = remember(usbGeneration) {
|
||||
if (context.checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) ==
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) sc2Probe.pairedBleAddress() else null
|
||||
}
|
||||
val sc2Present = sc2Usb != null || sc2Ble != null
|
||||
val dsUsb = remember(usbGeneration) {
|
||||
(context.getSystemService(Context.USB_SERVICE) as android.hardware.usb.UsbManager)
|
||||
.deviceList.values.firstOrNull {
|
||||
it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS
|
||||
}
|
||||
}
|
||||
|
||||
Group("Gamepads") {
|
||||
if (sc2Present) Sc2Row(sc2Usb, activity)
|
||||
dsUsb?.let { DsRow(it) }
|
||||
if (pads.isEmpty() && !sc2Present) {
|
||||
Text(
|
||||
"No controller detected. punktfunk can only forward devices Android " +
|
||||
@@ -319,6 +328,155 @@ private fun Sc2Row(usbDev: android.hardware.usb.UsbDevice?, activity: MainActivi
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast action for the Sony-pad USB grants — fired by both the menu-time auto-ask
|
||||
* ([MainActivity.maybeAskDsPermission]) and [DsRow]'s explicit button, so an open card
|
||||
* refreshes whichever dialog was answered.
|
||||
*/
|
||||
internal const val DS_USB_PERMISSION_ACTION = "io.unom.punktfunk.DS_CONTROLLERS_USB_PERMISSION"
|
||||
|
||||
/**
|
||||
* The Sony USB pad card — capture status + the USB grant. The grant normally arrives via the
|
||||
* menu-time auto-ask the moment the pad attaches ([MainActivity.maybeAskDsPermission]); the
|
||||
* button here is the recovery path after a deny (the auto-ask fires once per attach). Shown
|
||||
* ALONGSIDE the pad's ordinary [PadRow] (unclaimed it is still an InputDevice); the capture
|
||||
* itself only runs inside a stream, so at menu time this card is pure status.
|
||||
*/
|
||||
@Composable
|
||||
private fun DsRow(usbDev: android.hardware.usb.UsbDevice) {
|
||||
val context = LocalContext.current
|
||||
val settingOn = remember { SettingsStore(context).load().dsCapture }
|
||||
val usbManager = context.getSystemService(Context.USB_SERVICE) as android.hardware.usb.UsbManager
|
||||
var permitted by remember(usbDev) { mutableStateOf(usbManager.hasPermission(usbDev)) }
|
||||
val model = DsDevice.modelFor(usbDev.productId)
|
||||
val label = when (model) {
|
||||
DsDevice.Model.DUALSENSE -> "DualSense"
|
||||
DsDevice.Model.DUALSENSE_EDGE -> "DualSense Edge"
|
||||
DsDevice.Model.DUALSHOCK4 -> "DualShock 4"
|
||||
null -> return
|
||||
}
|
||||
// Refresh `permitted` when the grant dialog answers (the grant itself is system-recorded;
|
||||
// this receiver only updates the card).
|
||||
val action = DS_USB_PERMISSION_ACTION
|
||||
DisposableEffect(usbDev) {
|
||||
val receiver = object : android.content.BroadcastReceiver() {
|
||||
override fun onReceive(c: Context?, i: android.content.Intent?) {
|
||||
if (i?.action == action) permitted = usbManager.hasPermission(usbDev)
|
||||
}
|
||||
}
|
||||
androidx.core.content.ContextCompat.registerReceiver(
|
||||
context,
|
||||
receiver,
|
||||
android.content.IntentFilter(action),
|
||||
androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
onDispose { runCatching { context.unregisterReceiver(receiver) } }
|
||||
}
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text("$label passthrough", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
"Wired (USB)",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
when {
|
||||
!settingOn -> Text(
|
||||
"Passthrough is disabled in Settings — enable \"DualSense / DualShock " +
|
||||
"passthrough (USB)\" to capture it.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
!permitted -> {
|
||||
Text(
|
||||
"Needs USB access — grant it now and streams capture the pad silently.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
OutlinedButton(onClick = {
|
||||
usbManager.requestPermission(
|
||||
usbDev,
|
||||
android.app.PendingIntent.getBroadcast(
|
||||
context, 3, // requestCode 3 — 0/1/2 are the SC2/stream grants
|
||||
android.content.Intent(action).setPackage(context.packageName),
|
||||
// MUTABLE: the USB stack appends the grant extras to this intent.
|
||||
android.app.PendingIntent.FLAG_MUTABLE,
|
||||
),
|
||||
)
|
||||
}) {
|
||||
Text("Grant USB access")
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Text(
|
||||
if (model == DsDevice.Model.DUALSHOCK4) {
|
||||
"Ready — captured at stream start: rumble, lightbar and gyro are " +
|
||||
"driven directly."
|
||||
} else {
|
||||
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
|
||||
"and gyro are driven directly."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// Pad-audio self test. Deliberately reachable WITHOUT a stream: it exists to
|
||||
// answer "can this phone drive this pad's audio endpoint at all", and gating
|
||||
// that behind a live session would make it depend on the very thing one wants
|
||||
// to rule out when a session misbehaves. DualSense only — the DS4 has no
|
||||
// 4-channel haptics device.
|
||||
if (model != DsDevice.Model.DUALSHOCK4) {
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
var result by remember { mutableStateOf<String?>(null) }
|
||||
result?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
OutlinedButton(
|
||||
enabled = !testing,
|
||||
onClick = {
|
||||
testing = true
|
||||
result = null
|
||||
Thread({
|
||||
// Its OWN connection: the renderer's descriptor must never be
|
||||
// shared with another transfer engine, and that applies to
|
||||
// this test as much as to the real path.
|
||||
val conn = runCatching { usbManager.openDevice(usbDev) }.getOrNull()
|
||||
val fd = conn?.fileDescriptor ?: -1
|
||||
val r = if (fd >= 0) {
|
||||
io.unom.punktfunk.kit.NativeBridge.nativePadAudioSelfTest(fd, 3, 60)
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
conn?.close()
|
||||
val msg = when {
|
||||
r > 0 -> "Haptics test passed — $r frames to the pad."
|
||||
r == -1 -> "Could not open the pad's audio interface. " +
|
||||
"Some kernels refuse it; the pad still works normally."
|
||||
r == -2 -> "The audio stream stopped part-way."
|
||||
else -> "The stream opened but no audio reached the pad."
|
||||
}
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||
result = msg
|
||||
testing = false
|
||||
}
|
||||
}, "pf-pad-selftest-ui").start()
|
||||
},
|
||||
) {
|
||||
Text(if (testing) "Testing…" else "Test haptics")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One detected gamepad: identity, what it streams as, and a rumble test. */
|
||||
@Composable
|
||||
private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
|
||||
|
||||
@@ -50,10 +50,12 @@ import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.security.ClientIdentity
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.models.PendingTrust
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -250,6 +252,139 @@ fun GamepadHostOptionsDialog(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pin-to-hosts picker the settings screen's Profiles section opens — the Android mirror of the
|
||||
* desktop console's PinHostsScreen (design §5.2a): one toggle row per SAVED host, D-pad up/down
|
||||
* moves, A flips the focused pin, left/right unpins/pins (the settings-toggle semantics), B closes.
|
||||
* A toggle is presentation only: it edits the host's pinned cards through the same store write the
|
||||
* carousel's unpin uses, never the profile itself and never the host's default binding.
|
||||
*
|
||||
* Pin state is read live from [pinned] (backed by the host records), so what a switch shows is
|
||||
* always what the store holds — the row can't disagree with the carousel it feeds.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun GamepadPinHostsDialog(
|
||||
profileName: String,
|
||||
hosts: List<KnownHost>,
|
||||
pinned: (KnownHost) -> Boolean,
|
||||
onToggle: (KnownHost) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// 0..hosts.lastIndex = host rows, hosts.size = the Done button (with no hosts, index 0 IS
|
||||
// Done, so it starts focused).
|
||||
var focus by remember { mutableIntStateOf(0) }
|
||||
BackHandler(onBack = onDismiss)
|
||||
GamepadNavEffect2D(
|
||||
active = true,
|
||||
onDirection = { dir ->
|
||||
when (dir) {
|
||||
NavDir.UP -> if (focus > 0) focus--
|
||||
NavDir.DOWN -> if (focus < hosts.size) focus++
|
||||
// Directional = state-targeted (left → unpinned, right → pinned), so holding a
|
||||
// direction can't oscillate; asking for the state it's already in is a no-op.
|
||||
NavDir.LEFT -> hosts.getOrNull(focus)?.let { if (pinned(it)) onToggle(it) }
|
||||
NavDir.RIGHT -> hosts.getOrNull(focus)?.let { if (!pinned(it)) onToggle(it) }
|
||||
}
|
||||
},
|
||||
onActivate = {
|
||||
val kh = hosts.getOrNull(focus)
|
||||
if (kh != null) onToggle(kh) else onDismiss()
|
||||
},
|
||||
)
|
||||
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.padding(24.dp)
|
||||
.widthIn(max = 520.dp)
|
||||
.heightIn(max = maxCardHeight)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(Color(0xF01A1730))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp))
|
||||
.padding(28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Text(
|
||||
"Pin “$profileName”",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Column(
|
||||
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (hosts.isEmpty()) {
|
||||
DialogText("No saved hosts yet — pair with a host first, then pin this profile to it.")
|
||||
} else {
|
||||
DialogText("A pinned profile appears as its own card on the host — one press connects with it.")
|
||||
hosts.forEachIndexed { i, kh ->
|
||||
PinHostRow(
|
||||
label = kh.name,
|
||||
on = pinned(kh),
|
||||
focused = i == focus,
|
||||
onClick = { onToggle(kh) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.size(4.dp))
|
||||
DialogButton(
|
||||
"Done",
|
||||
focused = focus == hosts.size,
|
||||
primary = true,
|
||||
enabled = true,
|
||||
onClick = onDismiss,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One host's pin toggle: name + a [ConsoleSwitch], with the shared console focus visuals. */
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: () -> Unit) {
|
||||
val visuals = animateConsoleFocus(active = focused)
|
||||
// Inside the dialog's scroll region, like DialogButton: a focused row scrolled out of a short
|
||||
// landscape window pulls itself into view.
|
||||
val intoView = remember { BringIntoViewRequester() }
|
||||
LaunchedEffect(focused) { if (focused) intoView.bringIntoView() }
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.bringIntoViewRequester(intoView)
|
||||
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
|
||||
.clip(shape)
|
||||
.background(visuals.background)
|
||||
.border(1.dp, visuals.border, shape)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onClick,
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 13.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
ConsoleSwitch(on = on, focused = focused)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Console counterpart of [SpeedTestDialog]. Same measurement, same targeting rule — a TV box on a
|
||||
* powerline adapter is exactly the machine whose link is worth measuring, so this belongs on the
|
||||
|
||||
@@ -57,13 +57,15 @@ import androidx.compose.ui.unit.sp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
|
||||
// The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView:
|
||||
// the couch-relevant subset of the touch settings restyled as a console page and fully navigable with
|
||||
// a controller: up/down moves the focus bar, left/right steps the focused value, A cycles/toggles it,
|
||||
// B closes. Both write the same SharedPreferences, so values round-trip with the touch settings.
|
||||
|
||||
private class GpRow(
|
||||
internal class GpRow(
|
||||
val id: String,
|
||||
val header: String?,
|
||||
val label: String,
|
||||
@@ -72,8 +74,19 @@ private class GpRow(
|
||||
val adjust: (Int) -> Boolean, // left/right; returns whether the value actually changed
|
||||
val activate: () -> Unit, // A → cycle forward (wrapping) / flip
|
||||
val toggled: Boolean? = null, // non-null = a toggle row, drawn as a ConsoleSwitch (not text)
|
||||
val adjustable: Boolean = true, // false = the row navigates/acts instead of stepping — no chevrons
|
||||
val enabled: Boolean = true, // dimmed + inert when false (still focusable, for its detail)
|
||||
)
|
||||
|
||||
/**
|
||||
* The row at [index], or null when it is dimmed. The single place the "disabled ⇒ inert" half of
|
||||
* [GpRow.enabled] is enforced, so the three input paths (pad left/right, A, and a tap on the
|
||||
* already-focused row) cannot drift apart — before this, `enabled` dimmed the label and nothing
|
||||
* else, and every dimmed row still stepped its setting.
|
||||
*/
|
||||
internal fun liveRow(rows: List<GpRow>, index: Int): GpRow? =
|
||||
rows.getOrNull(index)?.takeIf { it.enabled }
|
||||
|
||||
@Composable
|
||||
fun GamepadSettingsScreen(
|
||||
initial: Settings,
|
||||
@@ -89,7 +102,39 @@ fun GamepadSettingsScreen(
|
||||
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
|
||||
// Gates the AV1 codec row the same way the touch settings do (see `codecOptionsFor`).
|
||||
val av1Capable = remember { io.unom.punktfunk.kit.VideoDecoders.pickDecoder("video/av01") != null }
|
||||
val rows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update)
|
||||
|
||||
// The Profiles section's stores, constructed here the way ConnectScreen constructs its own.
|
||||
// The catalog is read once per screen entry: this screen can't create or edit profiles
|
||||
// (design §5.4 — the touch interface does), so the list is stable for its lifetime. The saved
|
||||
// hosts DO change under it — every pin toggle writes one — so they live in state and refresh
|
||||
// on each toggle, keeping the "Pinned to N hosts" counts honest.
|
||||
val knownHostStore = remember { KnownHostStore(context) }
|
||||
val profileStore = remember { ProfileStore(context) }
|
||||
val profiles = remember { profileStore.all() }
|
||||
var savedHosts by remember { mutableStateOf(knownHostStore.all()) }
|
||||
// The profile whose pin-to-hosts picker is up, or null. While it's showing, it owns the pad
|
||||
// (this screen's nav gates on it, the ConnectScreen-dialog pattern).
|
||||
var pinProfile by remember { mutableStateOf<StreamProfile?>(null) }
|
||||
|
||||
// Toggle a host+profile pin — the same store write ConnectScreen's togglePin does. Presentation
|
||||
// only: pin appends at the end (card order), unpin removes, and the host's default binding
|
||||
// (profileId) is never touched.
|
||||
fun togglePin(kh: KnownHost, profile: StreamProfile) {
|
||||
val pins = if (profile.id in kh.pinnedProfileIds) {
|
||||
kh.pinnedProfileIds - profile.id
|
||||
} else {
|
||||
kh.pinnedProfileIds + profile.id
|
||||
}
|
||||
knownHostStore.save(kh.copy(pinnedProfileIds = pins))
|
||||
savedHosts = knownHostStore.all()
|
||||
}
|
||||
|
||||
// On a TV "the touch interface" is confusing advice (no touch to reach it with) — the honest
|
||||
// path there is this screen's own Controller-optimized UI toggle, which swaps in the standard
|
||||
// interface remote-navigably. The strings branch on it.
|
||||
val tv = remember { isTvDevice(context) }
|
||||
val rows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) +
|
||||
buildProfileRows(profiles, savedHosts, tv) { pinProfile = it }
|
||||
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
|
||||
@@ -101,16 +146,20 @@ fun GamepadSettingsScreen(
|
||||
|
||||
BackHandler(onBack = onBack)
|
||||
GamepadNavEffect2D(
|
||||
active = navActive,
|
||||
// The pin picker owns the pad while it's up (its own nav + BackHandler), so this screen
|
||||
// drops its probes — the pattern ConnectScreen's dialogs use.
|
||||
active = navActive && pinProfile == null,
|
||||
onDirection = { dir ->
|
||||
when (dir) {
|
||||
NavDir.UP -> if (focus > 0) focus--
|
||||
NavDir.DOWN -> if (focus < rows.lastIndex) focus++
|
||||
NavDir.LEFT -> { adjustDir = -1; rows.getOrNull(focus)?.adjust(-1) }
|
||||
NavDir.RIGHT -> { adjustDir = 1; rows.getOrNull(focus)?.adjust(1) }
|
||||
// A disabled row is INERT, not just dim — the step is refused instead of writing a
|
||||
// setting that has nothing to act on (see `liveRow`).
|
||||
NavDir.LEFT -> { adjustDir = -1; liveRow(rows, focus)?.adjust(-1) }
|
||||
NavDir.RIGHT -> { adjustDir = 1; liveRow(rows, focus)?.adjust(1) }
|
||||
}
|
||||
},
|
||||
onActivate = { adjustDir = 1; rows.getOrNull(focus)?.activate() },
|
||||
onActivate = { adjustDir = 1; liveRow(rows, focus)?.activate() },
|
||||
)
|
||||
// Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the
|
||||
// screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it.
|
||||
@@ -148,7 +197,10 @@ fun GamepadSettingsScreen(
|
||||
}
|
||||
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
|
||||
SettingRowView(row, focused = index == focus, adjustDir = adjustDir, onClick = {
|
||||
if (focus == index) { adjustDir = 1; row.activate() } else focus = index
|
||||
// Same inertness as the pad path above — tapping a dimmed row focuses it (so
|
||||
// its detail explains itself) but never flips it.
|
||||
if (focus != index) focus = index
|
||||
else if (row.enabled) { adjustDir = 1; row.activate() }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -162,16 +214,41 @@ fun GamepadSettingsScreen(
|
||||
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
// The legend follows the focused row (the desktop console's hints() does the same):
|
||||
// a profile row doesn't adjust, it opens the pin picker, and the "No profiles yet"
|
||||
// placeholder does nothing at all — advertising ↔/A on those would be a lie.
|
||||
val focused = rows.getOrNull(focus)
|
||||
GamepadHintBar(
|
||||
listOf(
|
||||
GamepadHint('↔', Color(0xFF9A93C7), "Adjust"),
|
||||
// Tappable too (touch escape hatch): Change cycles the focused row, Done leaves.
|
||||
PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
),
|
||||
when {
|
||||
focused != null && !focused.enabled -> listOf(
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
focused != null && !focused.adjustable -> listOf(
|
||||
PadGlyph.hint('A', "Pin to hosts") { focused.activate() },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
else -> listOf(
|
||||
GamepadHint('↔', Color(0xFF9A93C7), "Adjust"),
|
||||
// Tappable too (touch escape hatch): Change cycles the focused row, Done leaves.
|
||||
PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
},
|
||||
hazeState = hazeState,
|
||||
)
|
||||
}
|
||||
|
||||
// The pin-to-hosts picker for the activated profile row — the console counterpart of the
|
||||
// touch UI's per-profile pin toggles in the host edit sheet.
|
||||
pinProfile?.let { p ->
|
||||
GamepadPinHostsDialog(
|
||||
profileName = p.name,
|
||||
hosts = savedHosts,
|
||||
pinned = { kh -> p.id in kh.pinnedProfileIds },
|
||||
onToggle = { kh -> togglePin(kh, p) },
|
||||
onDismiss = { pinProfile = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,8 +257,13 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
val visuals = animateConsoleFocus(active = focused)
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
// The chevrons keep their layout slot and only fade, so the value never jumps sideways when
|
||||
// focus arrives; the value colour cross-fades with them.
|
||||
val chevronAlpha by animateFloatAsState(if (focused) 0.6f else 0f, tween(160), label = "chevrons")
|
||||
// focus arrives; the value colour cross-fades with them. A non-adjustable row (a profile row
|
||||
// navigates, the empty-catalog placeholder does nothing) never shows them at all.
|
||||
val chevronAlpha by animateFloatAsState(
|
||||
if (focused && row.adjustable) 0.6f else 0f,
|
||||
tween(160),
|
||||
label = "chevrons",
|
||||
)
|
||||
val valueColor by animateColorAsState(
|
||||
Color.White.copy(alpha = if (focused) 1f else 0.6f),
|
||||
tween(160),
|
||||
@@ -216,7 +298,9 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
row.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
// A disabled row (the "No profiles yet" placeholder) dims but stays focusable,
|
||||
// so its detail line can still explain what would go here.
|
||||
color = Color.White.copy(alpha = if (row.enabled) 1f else 0.45f),
|
||||
maxLines = 1,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
@@ -270,7 +354,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
/** Build the console settings rows from the current [Settings], writing through [update].
|
||||
* [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs); [av1Capable] gates the
|
||||
* AV1 codec entry (see `codecOptionsFor`). */
|
||||
private fun buildSettingsRows(
|
||||
internal fun buildSettingsRows(
|
||||
s: Settings,
|
||||
hasBodyVibrator: Boolean,
|
||||
av1Capable: Boolean,
|
||||
@@ -278,13 +362,14 @@ private fun buildSettingsRows(
|
||||
): List<GpRow> {
|
||||
fun <T> choice(
|
||||
id: String, header: String?, label: String, detail: String,
|
||||
options: List<Pair<T, String>>, current: T, write: (T) -> Unit,
|
||||
options: List<Pair<T, String>>, current: T, enabled: Boolean = true, write: (T) -> Unit,
|
||||
): GpRow {
|
||||
val idx = options.indexOfFirst { it.first == current }
|
||||
return GpRow(
|
||||
id, header, label,
|
||||
value = options.getOrNull(idx)?.second ?: "—",
|
||||
detail = detail,
|
||||
enabled = enabled,
|
||||
adjust = { delta ->
|
||||
if (idx < 0) {
|
||||
options.firstOrNull()?.let { write(it.first) } != null
|
||||
@@ -301,11 +386,12 @@ private fun buildSettingsRows(
|
||||
}
|
||||
fun toggle(
|
||||
id: String, header: String?, label: String, detail: String,
|
||||
value: Boolean, write: (Boolean) -> Unit,
|
||||
value: Boolean, enabled: Boolean = true, write: (Boolean) -> Unit,
|
||||
): GpRow = GpRow(
|
||||
id, header, label,
|
||||
value = if (value) "On" else "Off",
|
||||
detail = detail,
|
||||
enabled = enabled,
|
||||
adjust = { delta -> val target = delta > 0; if (value != target) { write(target); true } else false },
|
||||
activate = { write(!value) },
|
||||
toggled = value,
|
||||
@@ -395,12 +481,40 @@ private fun buildSettingsRows(
|
||||
"mic", null, "Microphone", "Send this device's microphone to the host's virtual mic.",
|
||||
s.micEnabled,
|
||||
) { update(s.copy(micEnabled = it)) },
|
||||
toggle(
|
||||
"echoCancel", null, "Echo cancellation",
|
||||
"Filter the stream's own audio out of the mic pickup. Applies while the microphone is on.",
|
||||
s.echoCancel,
|
||||
) { update(s.copy(echoCancel = it)) },
|
||||
|
||||
toggle(
|
||||
"padForward", "Controllers", "Forward controllers",
|
||||
"Send this device's controllers to the host. Turn it off when your controller " +
|
||||
"already reaches the host another way — USB passthrough such as VirtualHere — " +
|
||||
"so games don't see two of them.",
|
||||
s.gamepadForwarding,
|
||||
) { update(s.copy(gamepadForwarding = it)) },
|
||||
// Everything below the master switch follows it — dim and inert while nothing is being
|
||||
// forwarded, the same relationship the touch settings draw with `enabled =`. This screen
|
||||
// had the capability (`GpRow.enabled`) and used it only for the profiles placeholder, so
|
||||
// the pad rows kept stepping settings that had nothing to act on.
|
||||
choice(
|
||||
"padType", "Controllers", "Controller type",
|
||||
"padType", null, "Controller type",
|
||||
"The virtual pad the host creates — Automatic matches this controller.",
|
||||
GAMEPAD_OPTIONS, s.gamepad,
|
||||
GAMEPAD_OPTIONS, s.gamepad, enabled = s.gamepadForwarding,
|
||||
) { update(s.copy(gamepad = it)) },
|
||||
choice(
|
||||
"systemButtons", null, "Guide button",
|
||||
"Where the guide (Xbox/PS) and share presses go while streaming — Automatic " +
|
||||
"sends them to the host whenever this device delivers them.",
|
||||
SYSTEM_BUTTON_OPTIONS, s.systemButtons, enabled = s.gamepadForwarding,
|
||||
) { update(s.copy(systemButtons = it)) },
|
||||
choice(
|
||||
"guideGesture", null, "Hold Select for guide",
|
||||
"Hold Select alone to press the host's guide button — keep holding for a " +
|
||||
"Gaming-Mode host's quick-access menu. A Select tap still goes through.",
|
||||
GUIDE_GESTURE_OPTIONS, s.guideGesture, enabled = s.gamepadForwarding,
|
||||
) { update(s.copy(guideGesture = it)) },
|
||||
) + listOfNotNull(
|
||||
if (hasBodyVibrator) {
|
||||
toggle(
|
||||
@@ -419,7 +533,76 @@ private fun buildSettingsRows(
|
||||
"sc2", null, "Steam Controller 2 passthrough",
|
||||
"Capture a Steam Controller 2 (wired, Puck dongle, or paired Bluetooth) and stream " +
|
||||
"it as-is — Steam on the host drives it like the physical pad.",
|
||||
s.sc2Capture,
|
||||
s.sc2Capture, enabled = s.gamepadForwarding,
|
||||
) { update(s.copy(sc2Capture = it)) },
|
||||
// The SC2 row's twin, and missing here until now: the touch settings have carried both
|
||||
// side by side, so a couch user on a TV box — where there IS no touch interface to fall
|
||||
// back to — could turn on SC2 passthrough but not the Sony one. Same no-vibrator-gate
|
||||
// reasoning: this capture renders feedback on the CONTROLLER's motors, not this device's.
|
||||
toggle(
|
||||
"dsCapture", null, "DualSense / DualShock passthrough (USB)",
|
||||
"Drive a USB-connected Sony pad directly — rumble on any phone, plus adaptive " +
|
||||
"triggers, lightbar and gyro.",
|
||||
s.dsCapture, enabled = s.gamepadForwarding,
|
||||
) { update(s.copy(dsCapture = it)) },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The trailing Profiles section — the Android mirror of the desktop console's (design §5.2a, §5.4):
|
||||
* one row per catalog profile, valued with how many saved hosts pin it, activating into the
|
||||
* pin-to-hosts picker. Read-only beyond pinning: profiles are created and edited in the standard
|
||||
* interface, so an empty catalog shows one dimmed placeholder explaining where they come from
|
||||
* instead of a dead-looking empty header. On a TV that phrasing changes: "touch interface" points
|
||||
* nowhere useful on a touchless device, so the strings name the actual route — the
|
||||
* Controller-optimized UI toggle a few rows up, which swaps the standard interface in
|
||||
* (d-pad-navigable; the profile editor lives there on every device, unlike tvOS where none exists).
|
||||
*/
|
||||
private fun buildProfileRows(
|
||||
profiles: List<StreamProfile>,
|
||||
savedHosts: List<KnownHost>,
|
||||
tv: Boolean,
|
||||
openPinPicker: (StreamProfile) -> Unit,
|
||||
): List<GpRow> {
|
||||
val createHint = if (tv) {
|
||||
"To create or edit profiles on this device, turn off Controller-optimized UI above " +
|
||||
"and use the standard interface."
|
||||
} else {
|
||||
"Profiles are created and edited in the touch interface."
|
||||
}
|
||||
if (profiles.isEmpty()) {
|
||||
return listOf(
|
||||
GpRow(
|
||||
id = "noProfiles",
|
||||
header = "Profiles",
|
||||
label = "No profiles yet",
|
||||
value = "",
|
||||
detail = "Profiles bundle stream settings for different uses — pinned ones become " +
|
||||
"one-press connect cards here. " + createHint,
|
||||
adjust = { false },
|
||||
activate = {},
|
||||
adjustable = false,
|
||||
enabled = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
return profiles.mapIndexed { i, p ->
|
||||
// Counted straight off the host records, so it agrees with what the carousel renders.
|
||||
val pins = savedHosts.count { p.id in it.pinnedProfileIds }
|
||||
GpRow(
|
||||
id = "profile:${p.id}",
|
||||
header = if (i == 0) "Profiles" else null,
|
||||
label = p.name,
|
||||
value = when (pins) {
|
||||
0 -> "Not pinned"
|
||||
1 -> "Pinned to 1 host"
|
||||
else -> "Pinned to $pins hosts"
|
||||
},
|
||||
detail = "Pin this profile to a host and it appears as its own card — one press " +
|
||||
"connects with it. " + createHint,
|
||||
adjust = { false },
|
||||
activate = { openPinPicker(p) },
|
||||
adjustable = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package io.unom.punktfunk
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.VideoDecoders
|
||||
@@ -45,18 +46,47 @@ suspend fun connectToHost(
|
||||
// Transport-level half of "Low-latency mode (experimental)" (DSCP marking on the media
|
||||
// sockets) — must be applied before connect, since sockets are tagged at creation.
|
||||
NativeBridge.nativeSetLowLatencyMode(settings.lowLatencyMode)
|
||||
val multiSlice = VideoDecoders.multiSliceTolerant()
|
||||
val partialFrame = VideoDecoders.partialFrameCapable()
|
||||
// Slice-progressive delivery: decoder truth AND the async decode loop — the legacy
|
||||
// sync loop feeds whole AUs only, so parts must never arrive when it is selected.
|
||||
val frameParts = settings.lowLatencyMode && partialFrame
|
||||
val codecBits = VideoDecoders.decodableCodecBits()
|
||||
// Automatic codec (P5, measured NP3 ↔ RTX 4090): AV1 beat HEVC by ~1.2 ms end-to-end at
|
||||
// identical conditions, so under "Automatic" this device prefers AV1 when it hardware-
|
||||
// decodes it (the AV1 bit is only ever set for a real, non-blocked hardware decoder) AND
|
||||
// it lacks FEATURE_PartialFrame — a partial-frame device keeps HEVC, whose slice overlap
|
||||
// AV1 cannot ride (AV1 has no slices; the host's chunked poll never arms). The host
|
||||
// honors the preference only inside the probed shared codec set, so an AV1-less encoder
|
||||
// still resolves HEVC. An explicit user choice always wins unchanged.
|
||||
val preferredCodec = settings.preferredCodec().takeIf { it != 0 }
|
||||
?: if (codecBits and 4 != 0 && !partialFrame) 4 else 0
|
||||
// The connect-time capability readout (`adb logcat -s pf.caps`): the P2 slice pipeline
|
||||
// is client-inert unless BOTH probes pass — this line says which decoder failed one.
|
||||
Log.i(
|
||||
"pf.caps",
|
||||
VideoDecoders.capsReport() +
|
||||
" → multiSlice=$multiSlice parts=$frameParts prefer=$preferredCodec" +
|
||||
" (lowLatency=${settings.lowLatencyMode})",
|
||||
)
|
||||
NativeBridge.nativeConnect(
|
||||
host, port, w, h, hz,
|
||||
identity.certPem, identity.privateKeyPem, pinHex,
|
||||
settings.bitrateKbps, settings.compositor, gamepadPref,
|
||||
hdrEnabled, settings.audioChannels,
|
||||
hdrEnabled, multiSlice,
|
||||
frameParts,
|
||||
settings.audioChannels,
|
||||
// What this device can decode (H.264|HEVC always, AV1 when a real decoder exists) +
|
||||
// the user's soft codec preference — the host resolves the emitted codec from both.
|
||||
VideoDecoders.decodableCodecBits(), settings.preferredCodec(), timeoutMs,
|
||||
// the soft codec preference (user choice, or the Automatic AV1 rule above) — the
|
||||
// host resolves the emitted codec from both.
|
||||
codecBits, preferredCodec, timeoutMs,
|
||||
launch,
|
||||
// The host's approval-list / trust-store label for this device — the same
|
||||
// Build.MODEL convention the pairing dialogs use for nativePair.
|
||||
Build.MODEL ?: "Android",
|
||||
// Tier-A pad audio: ask for the 0xD1 plane only when a setting would render it, so a
|
||||
// user with it off does not make the host provision endpoints it will never feed.
|
||||
settings.padHaptics || settings.padSpeaker,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import io.unom.punktfunk.kit.DsDevice
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.GamepadRouter
|
||||
import io.unom.punktfunk.kit.Keymap
|
||||
@@ -169,6 +170,10 @@ class MainActivity : ComponentActivity() {
|
||||
private var sc2Receiver: BroadcastReceiver? = null
|
||||
private var sc2PermissionAsked = false
|
||||
|
||||
/** Sony-pad USB grant asked this attach — a deny doesn't re-nag until a fresh attach (or the
|
||||
* Controllers screen's explicit button). */
|
||||
private var dsPermissionAsked = 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
|
||||
@@ -225,6 +230,8 @@ class MainActivity : ComponentActivity() {
|
||||
UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
|
||||
sc2PermissionAsked = false // a fresh attach may ask once again
|
||||
startSc2MenuNav()
|
||||
dsPermissionAsked = false
|
||||
maybeAskDsPermission()
|
||||
}
|
||||
SC2_MENU_PERMISSION -> {
|
||||
if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
|
||||
@@ -281,6 +288,7 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
startSc2MenuNav()
|
||||
maybeAskDsPermission()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
@@ -341,6 +349,37 @@ class MainActivity : ComponentActivity() {
|
||||
sc2MenuActive = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for USB access to an attached Sony pad the moment it appears — a fresh attach while
|
||||
* the app is open, or the app coming to the foreground with one already plugged in — at most
|
||||
* once per attach, so the stream-mode capture ([io.unom.punktfunk.kit.DsCapture]) engages
|
||||
* silently instead of interrupting stream start with the dialog. Unlike the SC2's menu flow
|
||||
* there is nothing to START on the grant: an uncaptured Sony pad is an ordinary InputDevice
|
||||
* at menu time, so the grant is simply recorded (Android keeps it while the pad stays
|
||||
* attached). The broadcast only refreshes the Controllers screen's card if it happens to be
|
||||
* open; a deny leaves that card's explicit button as the re-ask.
|
||||
*/
|
||||
private fun maybeAskDsPermission() {
|
||||
if (streamHandle != 0L) return // StreamScreen owns its own permission flow while streaming
|
||||
if (dsPermissionAsked) return
|
||||
if (!SettingsStore(this).load().dsCapture) return
|
||||
val usbManager = getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
val dev = usbManager.deviceList.values.firstOrNull {
|
||||
it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS
|
||||
} ?: return
|
||||
if (usbManager.hasPermission(dev)) return
|
||||
dsPermissionAsked = true
|
||||
usbManager.requestPermission(
|
||||
dev,
|
||||
PendingIntent.getBroadcast(
|
||||
this, 4, // requestCode 4 — 0..3 are the SC2 stream/menu + DS stream/card grants
|
||||
Intent(DS_USB_PERMISSION_ACTION).setPackage(packageName),
|
||||
// MUTABLE: the USB stack appends the grant extras to this intent.
|
||||
PendingIntent.FLAG_MUTABLE,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
@@ -405,8 +444,8 @@ class MainActivity : ComponentActivity() {
|
||||
/**
|
||||
* Opt the CONSOLE UI into the panel's highest refresh mode. Some OEMs (Nothing OS among them) pin
|
||||
* third-party apps to 60Hz unless they explicitly ask for more, which halves the smoothness of the
|
||||
* UI's scrolling/animation on a 120/144Hz panel. [StreamScreen] turns this OFF while streaming so
|
||||
* its own `ANativeWindow_setFrameRate` (matched to the video) governs the panel instead.
|
||||
* UI's scrolling/animation on a 120/144Hz panel. [StreamScreen] replaces this with
|
||||
* [setStreamDisplayMode] while streaming (matched to the video, not to the panel maximum).
|
||||
*/
|
||||
fun setConsoleHighRefreshRate(high: Boolean) {
|
||||
if (highRefreshModeId == 0) return
|
||||
@@ -415,6 +454,64 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin the panel to a display mode matching the STREAM's refresh for the session's duration —
|
||||
* exact rate first, else the smallest integer multiple (120 for a 60 stream: judder-free 2:1
|
||||
* pulldown), else the highest available. Same-resolution modes only.
|
||||
*
|
||||
* The window-level mode pin is the belt to the decoder's `ANativeWindow_setFrameRate` braces:
|
||||
* the surface hint alone is advisory, and several OEM refresh governors (Nothing OS's LTPO
|
||||
* logic among them) ignore it entirely for third-party apps — leaving a 120 Hz session
|
||||
* presenting on a 60/90 Hz panel, which reads as judder + a refresh of extra latency. The
|
||||
* preferredDisplayModeId is the one signal they all honor. [hz] ≤ 0 falls back to releasing
|
||||
* the pin (the pre-pin behaviour).
|
||||
*/
|
||||
fun setStreamDisplayMode(hz: Int) {
|
||||
if (hz <= 0) {
|
||||
setConsoleHighRefreshRate(false)
|
||||
return
|
||||
}
|
||||
val target = streamModeFor(hz) ?: return
|
||||
window.attributes = window.attributes.apply { preferredDisplayModeId = target.modeId }
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel refresh rate a [hz] stream runs against — [streamModeFor]'s pick, from the mode
|
||||
* TABLE rather than `display.refreshRate`. The distinction matters: under a per-uid frame
|
||||
* rate override (games get a 60 fps default on Android 15+) `refreshRate` reports the
|
||||
* override, not the panel — observed on-glass as a 120 Hz panel reading back as 60. The
|
||||
* supported-modes list is not override-filtered. `0` when unresolvable.
|
||||
*/
|
||||
fun streamPanelFps(hz: Int): Int =
|
||||
streamModeFor(hz)?.refreshRate?.let { kotlin.math.round(it).toInt() } ?: 0
|
||||
|
||||
/** The same-resolution display mode [setStreamDisplayMode] pins for a [hz] stream. */
|
||||
private fun streamModeFor(hz: Int): android.view.Display.Mode? {
|
||||
if (hz <= 0) return null
|
||||
@Suppress("DEPRECATION")
|
||||
val disp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) display else windowManager.defaultDisplay
|
||||
val current = disp?.mode ?: return null
|
||||
val sameRes = disp.supportedModes.filter {
|
||||
it.physicalWidth == current.physicalWidth && it.physicalHeight == current.physicalHeight
|
||||
}
|
||||
fun multiple(rate: Float): Int {
|
||||
val k = (rate / hz).toInt()
|
||||
return if (k >= 2 && kotlin.math.abs(rate - hz * k) < 1f) k else 0
|
||||
}
|
||||
return sameRes.minWithOrNull(
|
||||
compareBy(
|
||||
{
|
||||
when {
|
||||
kotlin.math.abs(it.refreshRate - hz) < 1f -> 0 // exact
|
||||
multiple(it.refreshRate) > 0 -> 1 // integer multiple — prefer smallest
|
||||
else -> 2 // no relation — prefer highest so at least nothing is halved
|
||||
}
|
||||
},
|
||||
{ if (multiple(it.refreshRate) > 0) it.refreshRate else -it.refreshRate },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
val handle = streamHandle
|
||||
if (handle != 0L) {
|
||||
|
||||
@@ -38,16 +38,23 @@ data class SettingsOverlay(
|
||||
val compositor: Int? = null,
|
||||
val audioChannels: Int? = null,
|
||||
val micEnabled: Boolean? = null,
|
||||
val echoCancel: Boolean? = null,
|
||||
val touchMode: TouchMode? = null,
|
||||
val mouseMode: MouseMode? = null,
|
||||
val invertScroll: Boolean? = null,
|
||||
val gamepad: Int? = null,
|
||||
val gamepadForwarding: Boolean? = null,
|
||||
val systemButtons: String? = null,
|
||||
val guideGesture: String? = null,
|
||||
val statsVerbosity: StatsVerbosity? = null,
|
||||
/**
|
||||
* Android-only tier-P addition (design §3): the decode pipeline is a device fact everywhere
|
||||
* else, but here it is the one knob a marginal link wants turned off per host.
|
||||
*/
|
||||
val lowLatencyMode: Boolean? = null,
|
||||
/** The timeline presenter's intent pair — cross-client keys, see [Settings.presentPriority]. */
|
||||
val presentPriority: String? = null,
|
||||
val smoothBuffer: Int? = null,
|
||||
/**
|
||||
* Overlay keys a newer build wrote and this one doesn't model — carried through a load→save
|
||||
* round-trip untouched. The don't-clobber rule: opening and saving a profile on an older client
|
||||
@@ -67,12 +74,18 @@ data class SettingsOverlay(
|
||||
compositor = compositor ?: base.compositor,
|
||||
audioChannels = audioChannels ?: base.audioChannels,
|
||||
micEnabled = micEnabled ?: base.micEnabled,
|
||||
echoCancel = echoCancel ?: base.echoCancel,
|
||||
touchMode = touchMode ?: base.touchMode,
|
||||
mouseMode = mouseMode ?: base.mouseMode,
|
||||
invertScroll = invertScroll ?: base.invertScroll,
|
||||
gamepad = gamepad ?: base.gamepad,
|
||||
gamepadForwarding = gamepadForwarding ?: base.gamepadForwarding,
|
||||
systemButtons = systemButtons ?: base.systemButtons,
|
||||
guideGesture = guideGesture ?: base.guideGesture,
|
||||
statsVerbosity = statsVerbosity ?: base.statsVerbosity,
|
||||
lowLatencyMode = lowLatencyMode ?: base.lowLatencyMode,
|
||||
presentPriority = presentPriority ?: base.presentPriority,
|
||||
smoothBuffer = smoothBuffer ?: base.smoothBuffer,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -98,12 +111,20 @@ data class SettingsOverlay(
|
||||
compositor = if (after.compositor != before.compositor) after.compositor else compositor,
|
||||
audioChannels = if (after.audioChannels != before.audioChannels) after.audioChannels else audioChannels,
|
||||
micEnabled = if (after.micEnabled != before.micEnabled) after.micEnabled else micEnabled,
|
||||
echoCancel = if (after.echoCancel != before.echoCancel) after.echoCancel else echoCancel,
|
||||
touchMode = if (after.touchMode != before.touchMode) after.touchMode else touchMode,
|
||||
mouseMode = if (after.mouseMode != before.mouseMode) after.mouseMode else mouseMode,
|
||||
invertScroll = if (after.invertScroll != before.invertScroll) after.invertScroll else invertScroll,
|
||||
gamepad = if (after.gamepad != before.gamepad) after.gamepad else gamepad,
|
||||
gamepadForwarding =
|
||||
if (after.gamepadForwarding != before.gamepadForwarding) after.gamepadForwarding
|
||||
else gamepadForwarding,
|
||||
systemButtons = if (after.systemButtons != before.systemButtons) after.systemButtons else systemButtons,
|
||||
guideGesture = if (after.guideGesture != before.guideGesture) after.guideGesture else guideGesture,
|
||||
statsVerbosity = if (after.statsVerbosity != before.statsVerbosity) after.statsVerbosity else statsVerbosity,
|
||||
lowLatencyMode = if (after.lowLatencyMode != before.lowLatencyMode) after.lowLatencyMode else lowLatencyMode,
|
||||
presentPriority = if (after.presentPriority != before.presentPriority) after.presentPriority else presentPriority,
|
||||
smoothBuffer = if (after.smoothBuffer != before.smoothBuffer) after.smoothBuffer else smoothBuffer,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -121,12 +142,18 @@ data class SettingsOverlay(
|
||||
"compositor" -> copy(compositor = null)
|
||||
"audio_channels" -> copy(audioChannels = null)
|
||||
"mic_enabled" -> copy(micEnabled = null)
|
||||
"echo_cancel" -> copy(echoCancel = null)
|
||||
"touch_mode" -> copy(touchMode = null)
|
||||
"mouse_mode" -> copy(mouseMode = null)
|
||||
"invert_scroll" -> copy(invertScroll = null)
|
||||
"gamepad" -> copy(gamepad = null)
|
||||
"gamepad_forwarding" -> copy(gamepadForwarding = null)
|
||||
"system_buttons" -> copy(systemButtons = null)
|
||||
"guide_gesture" -> copy(guideGesture = null)
|
||||
"stats_verbosity" -> copy(statsVerbosity = null)
|
||||
"low_latency_mode" -> copy(lowLatencyMode = null)
|
||||
"present_priority" -> copy(presentPriority = null)
|
||||
"smooth_buffer" -> copy(smoothBuffer = null)
|
||||
else -> this
|
||||
}
|
||||
|
||||
@@ -141,12 +168,18 @@ data class SettingsOverlay(
|
||||
if (compositor != null) add("compositor")
|
||||
if (audioChannels != null) add("audio_channels")
|
||||
if (micEnabled != null) add("mic_enabled")
|
||||
if (echoCancel != null) add("echo_cancel")
|
||||
if (touchMode != null) add("touch_mode")
|
||||
if (mouseMode != null) add("mouse_mode")
|
||||
if (invertScroll != null) add("invert_scroll")
|
||||
if (gamepad != null) add("gamepad")
|
||||
if (gamepadForwarding != null) add("gamepad_forwarding")
|
||||
if (systemButtons != null) add("system_buttons")
|
||||
if (guideGesture != null) add("guide_gesture")
|
||||
if (statsVerbosity != null) add("stats_verbosity")
|
||||
if (lowLatencyMode != null) add("low_latency_mode")
|
||||
if (presentPriority != null) add("present_priority")
|
||||
if (smoothBuffer != null) add("smooth_buffer")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -169,12 +202,18 @@ data class SettingsOverlay(
|
||||
compositor?.let { j.put("compositor", it) }
|
||||
audioChannels?.let { j.put("audio_channels", it) }
|
||||
micEnabled?.let { j.put("mic_enabled", it) }
|
||||
echoCancel?.let { j.put("echo_cancel", it) }
|
||||
touchMode?.let { j.put("touch_mode", it.name) }
|
||||
mouseMode?.let { j.put("mouse_mode", it.storedName) }
|
||||
invertScroll?.let { j.put("invert_scroll", it) }
|
||||
gamepad?.let { j.put("gamepad", it) }
|
||||
gamepadForwarding?.let { j.put("gamepad_forwarding", it) }
|
||||
systemButtons?.let { j.put("system_buttons", it) }
|
||||
guideGesture?.let { j.put("guide_gesture", it) }
|
||||
statsVerbosity?.let { j.put("stats_verbosity", it.name) }
|
||||
lowLatencyMode?.let { j.put("low_latency_mode", it) }
|
||||
presentPriority?.let { j.put("present_priority", it) }
|
||||
smoothBuffer?.let { j.put("smooth_buffer", it) }
|
||||
return j
|
||||
}
|
||||
|
||||
@@ -185,8 +224,11 @@ data class SettingsOverlay(
|
||||
/** Keys this build models; everything else in a stored overlay is carried through. */
|
||||
private val KNOWN = setOf(
|
||||
"width", "height", "refresh_hz", "bitrate_kbps", "render_scale", "codec",
|
||||
"hdr_enabled", "compositor", "audio_channels", "mic_enabled", "touch_mode",
|
||||
"mouse_mode", "invert_scroll", "gamepad", "stats_verbosity", "low_latency_mode",
|
||||
"hdr_enabled", "compositor", "audio_channels", "mic_enabled", "echo_cancel",
|
||||
"touch_mode", "mouse_mode", "invert_scroll", "gamepad", "gamepad_forwarding",
|
||||
"system_buttons", "guide_gesture",
|
||||
"stats_verbosity",
|
||||
"low_latency_mode", "present_priority", "smooth_buffer",
|
||||
)
|
||||
|
||||
internal fun fromJson(j: JSONObject): SettingsOverlay = SettingsOverlay(
|
||||
@@ -200,15 +242,21 @@ data class SettingsOverlay(
|
||||
compositor = j.optIntOrNull("compositor"),
|
||||
audioChannels = j.optIntOrNull("audio_channels"),
|
||||
micEnabled = j.optBooleanOrNull("mic_enabled"),
|
||||
echoCancel = j.optBooleanOrNull("echo_cancel"),
|
||||
touchMode = j.optStringOrNull("touch_mode")
|
||||
?.let { n -> TouchMode.entries.firstOrNull { it.name == n } },
|
||||
mouseMode = j.optStringOrNull("mouse_mode")
|
||||
?.let { n -> MouseMode.entries.firstOrNull { it.storedName == n } },
|
||||
invertScroll = j.optBooleanOrNull("invert_scroll"),
|
||||
gamepad = j.optIntOrNull("gamepad"),
|
||||
gamepadForwarding = j.optBooleanOrNull("gamepad_forwarding"),
|
||||
systemButtons = j.optStringOrNull("system_buttons"),
|
||||
guideGesture = j.optStringOrNull("guide_gesture"),
|
||||
statsVerbosity = j.optStringOrNull("stats_verbosity")
|
||||
?.let { n -> StatsVerbosity.entries.firstOrNull { it.name == n } },
|
||||
lowLatencyMode = j.optBooleanOrNull("low_latency_mode"),
|
||||
presentPriority = j.optStringOrNull("present_priority"),
|
||||
smoothBuffer = j.optIntOrNull("smooth_buffer"),
|
||||
extra = j.keys().asSequence().filter { it !in KNOWN }.associateWith { j.get(it) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.view.Display
|
||||
@@ -33,6 +34,31 @@ data class Settings(
|
||||
val hdrEnabled: Boolean = true,
|
||||
val compositor: Int = 0,
|
||||
val gamepad: Int = 0,
|
||||
/**
|
||||
* Forward this device's controllers to the host at all. Default on — that was the
|
||||
* unconditional behaviour before this became a setting.
|
||||
*
|
||||
* Off is for a couch whose controller reaches the host another way: a USB passthrough tool
|
||||
* (VirtualHere and friends), or a pad simply plugged into the host itself. Leaving it on
|
||||
* there gives the host two controllers for one pair of hands, and games read both. It also
|
||||
* stops this device CLAIMING the pad — a device held open is one a passthrough tool can't
|
||||
* bind — which is why it gates the USB capture paths, not just the wire sends.
|
||||
*/
|
||||
val gamepadForwarding: Boolean = true,
|
||||
/**
|
||||
* Where the guide (Xbox/PS) and misc/share presses land while streaming — the
|
||||
* cross-client `system_buttons` key: `"auto"` (forward on Android — the press reaches
|
||||
* the app on most devices) | `"forward"` | `"local"`.
|
||||
*/
|
||||
val systemButtons: String = "auto",
|
||||
/**
|
||||
* The hold-Select guide gesture — the cross-client `guide_gesture` key: `"auto"` (off
|
||||
* on Android) | `"on"` | `"off"`. On: holding Select alone ≥350 ms sends the HOST's
|
||||
* guide, down until release (long hold = the host's long-press → a Gaming-Mode host's
|
||||
* QAM); a Select tap is delivered on release, slightly delayed. For devices whose
|
||||
* shell intercepts the physical guide button.
|
||||
*/
|
||||
val guideGesture: String = "auto",
|
||||
/** Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
|
||||
* can capture; the resolved count drives the decoder + AAudio layout. */
|
||||
val audioChannels: Int = 2,
|
||||
@@ -41,6 +67,15 @@ data class Settings(
|
||||
* the host resolves (AV1 is only advertised/offered when the device has a real AV1 decoder). */
|
||||
val codec: String = "auto",
|
||||
val micEnabled: Boolean = false,
|
||||
/**
|
||||
* Cancel acoustic echo on the mic uplink (plus noise suppression): the capture opens under
|
||||
* the VoiceCommunication preset so the HAL's own AEC/NS process it, with the Java effects
|
||||
* attached as a backstop where available. On by default — a phone/tablet plays the game audio
|
||||
* out of the same device its mic hears, so without this the host hears its own stream back.
|
||||
* Turn off for a headset-only setup where the untouched full-band capture sounds better.
|
||||
* Only meaningful while [micEnabled] is on.
|
||||
*/
|
||||
val echoCancel: Boolean = true,
|
||||
/**
|
||||
* How much the in-stream stats overlay shows — see [StatsVerbosity]. Defaults to
|
||||
* [StatsVerbosity.NORMAL] (the res/fps line + latency headline + reliability counters); the full
|
||||
@@ -83,6 +118,19 @@ data class Settings(
|
||||
* feeds a queue that only grows.
|
||||
*/
|
||||
val lowLatencyMode: Boolean = true,
|
||||
/**
|
||||
* The timeline presenter's intent — the cross-client `present_priority` pair (the Apple
|
||||
* client's "Prioritize" picker, same stored values): `"latency"` (default) = newest-wins,
|
||||
* a frame reaches glass the instant the glass budget opens; `"smooth"` = a small FIFO
|
||||
* drained one frame per vsync, absorbing network/decode jitter at one refresh of added
|
||||
* display latency per buffered frame. Anything unrecognized resolves to latency.
|
||||
*/
|
||||
val presentPriority: String = "latency",
|
||||
/**
|
||||
* The smoothness buffer depth (`smooth_buffer`): 0 = Automatic (2 frames), else 1..3.
|
||||
* Only meaningful when [presentPriority] is `"smooth"`.
|
||||
*/
|
||||
val smoothBuffer: Int = 0,
|
||||
/**
|
||||
* Wake-on-LAN a saved host before connecting when it isn't currently seen on mDNS. On (default):
|
||||
* a connect to a host with a learned MAC that isn't advertising sends a magic packet and waits
|
||||
@@ -110,6 +158,38 @@ data class Settings(
|
||||
*/
|
||||
val sc2Capture: Boolean = true,
|
||||
|
||||
/**
|
||||
* Capture a USB-connected Sony controller (DualSense / DualSense Edge / DualShock 4) and
|
||||
* drive it directly: the app claims the pad's HID interface and renders the host's feedback
|
||||
* by writing USB output reports — rumble works on every phone (no kernel force-feedback
|
||||
* driver needed), and adaptive triggers + lightbar + player LEDs work at all (Android has no
|
||||
* platform API for any of them). ON by default — it engages only when such a pad is attached
|
||||
* over USB at stream start; uncaptured (toggle off / no permission / Bluetooth) the pad stays
|
||||
* on the ordinary InputDevice path. USB only: Android exposes no raw path to a Bluetooth
|
||||
* Classic pad, which is also why Sony's own Remote Play has no Android trigger support.
|
||||
*/
|
||||
val dsCapture: Boolean = true,
|
||||
|
||||
/**
|
||||
* Render the host's DualSense **voice-coil haptics** on a captured USB pad (tier A).
|
||||
*
|
||||
* The pad's own 4-channel audio device carries them, driven directly over usbfs — Android's
|
||||
* audio framework denylists that device by VID/PID, so there is no supported route to it. The
|
||||
* two kinds are arbitrated rather than mixed, and on evidence: wire rumble is suppressed only
|
||||
* while haptics frames are actually arriving, so a title that drives classic rumble and sends
|
||||
* no haptics audio keeps rumbling. Off, or on an uncaptured/Bluetooth pad, the pad stays on
|
||||
* ordinary rumble (tier C), which on this client already drives the same actuators.
|
||||
*/
|
||||
val padHaptics: Boolean = true,
|
||||
|
||||
/**
|
||||
* Render the pad's **built-in speaker** on a captured USB pad. Independent of [padHaptics] —
|
||||
* the host sends the two as separate streams and either can play alone. Off by default: the
|
||||
* speaker is a small, easily-startling loudspeaker in the user's hands, and unlike haptics it
|
||||
* duplicates audio they are already hearing.
|
||||
*/
|
||||
val padSpeaker: Boolean = false,
|
||||
|
||||
/**
|
||||
* How a physical mouse drives the host — the cross-client mouse model (see [MouseMode]).
|
||||
* [MouseMode.DESKTOP] (default here) points absolutely; [MouseMode.CAPTURE] locks the pointer
|
||||
@@ -181,9 +261,13 @@ class SettingsStore(context: Context) {
|
||||
hdrEnabled = prefs.getBoolean(K_HDR, true),
|
||||
compositor = prefs.getInt(K_COMPOSITOR, 0),
|
||||
gamepad = prefs.getInt(K_GAMEPAD, 0),
|
||||
gamepadForwarding = prefs.getBoolean(K_GAMEPAD_FORWARDING, true),
|
||||
systemButtons = prefs.getString(K_SYSTEM_BUTTONS, "auto") ?: "auto",
|
||||
guideGesture = prefs.getString(K_GUIDE_GESTURE, "auto") ?: "auto",
|
||||
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
|
||||
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
|
||||
micEnabled = prefs.getBoolean(K_MIC, false),
|
||||
echoCancel = prefs.getBoolean(K_ECHO_CANCEL, true),
|
||||
statsVerbosity = prefs.getString(K_STATS_VERBOSITY, null)
|
||||
?.let { name -> StatsVerbosity.entries.firstOrNull { it.name == name } }
|
||||
// Migration from the pre-tier Boolean "stats_hud_enabled": an explicit OFF stays off;
|
||||
@@ -201,9 +285,14 @@ class SettingsStore(context: Context) {
|
||||
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
|
||||
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
||||
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
|
||||
presentPriority = prefs.getString(K_PRESENT_PRIORITY, "latency") ?: "latency",
|
||||
smoothBuffer = prefs.getInt(K_SMOOTH_BUFFER, 0),
|
||||
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
|
||||
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
|
||||
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
|
||||
dsCapture = prefs.getBoolean(K_DS_CAPTURE, true),
|
||||
padHaptics = prefs.getBoolean(K_PAD_HAPTICS, true),
|
||||
padSpeaker = prefs.getBoolean(K_PAD_SPEAKER, false),
|
||||
mouseMode = prefs.getString(K_MOUSE_MODE, null)
|
||||
?.let { name -> MouseMode.entries.firstOrNull { it.storedName == name } }
|
||||
// Migration: the pre-enum Boolean "pointer_capture" (true = lock the pointer). Its
|
||||
@@ -223,17 +312,26 @@ class SettingsStore(context: Context) {
|
||||
.putBoolean(K_HDR, s.hdrEnabled)
|
||||
.putInt(K_COMPOSITOR, s.compositor)
|
||||
.putInt(K_GAMEPAD, s.gamepad)
|
||||
.putBoolean(K_GAMEPAD_FORWARDING, s.gamepadForwarding)
|
||||
.putString(K_SYSTEM_BUTTONS, s.systemButtons)
|
||||
.putString(K_GUIDE_GESTURE, s.guideGesture)
|
||||
.putInt(K_AUDIO_CH, s.audioChannels)
|
||||
.putString(K_CODEC, s.codec)
|
||||
.putBoolean(K_MIC, s.micEnabled)
|
||||
.putBoolean(K_ECHO_CANCEL, s.echoCancel)
|
||||
.putString(K_STATS_VERBOSITY, s.statsVerbosity.name)
|
||||
.putString(K_TOUCH_MODE, s.touchMode.name)
|
||||
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
|
||||
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
|
||||
.putString(K_PRESENT_PRIORITY, s.presentPriority)
|
||||
.putInt(K_SMOOTH_BUFFER, s.smoothBuffer)
|
||||
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
|
||||
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
|
||||
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
|
||||
.putBoolean(K_DS_CAPTURE, s.dsCapture)
|
||||
.putBoolean(K_PAD_HAPTICS, s.padHaptics)
|
||||
.putBoolean(K_PAD_SPEAKER, s.padSpeaker)
|
||||
.putString(K_MOUSE_MODE, s.mouseMode.storedName)
|
||||
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
|
||||
.apply()
|
||||
@@ -248,9 +346,13 @@ class SettingsStore(context: Context) {
|
||||
const val K_HDR = "hdr_enabled"
|
||||
const val K_COMPOSITOR = "compositor"
|
||||
const val K_GAMEPAD = "gamepad"
|
||||
const val K_GAMEPAD_FORWARDING = "gamepad_forwarding"
|
||||
const val K_SYSTEM_BUTTONS = "system_buttons"
|
||||
const val K_GUIDE_GESTURE = "guide_gesture"
|
||||
const val K_AUDIO_CH = "audio_channels"
|
||||
const val K_CODEC = "codec"
|
||||
const val K_MIC = "mic_enabled"
|
||||
const val K_ECHO_CANCEL = "echo_cancel"
|
||||
const val K_STATS_VERBOSITY = "stats_verbosity"
|
||||
|
||||
/** Pre-tier Boolean the [K_STATS_VERBOSITY] enum replaced — read once for migration, never
|
||||
@@ -271,9 +373,14 @@ class SettingsStore(context: Context) {
|
||||
* on; both stale keys are abandoned unread. The toggle stays as a per-device escape hatch.
|
||||
*/
|
||||
const val K_LOW_LATENCY = "low_latency_mode_v2"
|
||||
const val K_PRESENT_PRIORITY = "present_priority"
|
||||
const val K_SMOOTH_BUFFER = "smooth_buffer"
|
||||
const val K_AUTO_WAKE = "auto_wake_enabled"
|
||||
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
|
||||
const val K_SC2_CAPTURE = "sc2_capture"
|
||||
const val K_DS_CAPTURE = "ds_capture"
|
||||
const val K_PAD_HAPTICS = "pad_haptics"
|
||||
const val K_PAD_SPEAKER = "pad_speaker"
|
||||
const val K_MOUSE_MODE = "mouse_mode"
|
||||
|
||||
/** Legacy Boolean the [K_MOUSE_MODE] enum replaced — read once for migration, never written. */
|
||||
@@ -285,14 +392,31 @@ class SettingsStore(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The display to probe for capability/mode queries: the context's own display when it is already
|
||||
* associated with one, else the DEFAULT display via [DisplayManager]. A `punktfunk://` deep-link
|
||||
* COLD start can reach the connect before the activity is attached to its display —
|
||||
* `context.display` then throws, and the old `false`/1080p60 fallbacks silently downgraded the
|
||||
* whole session (no HDR advertised / non-native mode) with nothing in the log. The default
|
||||
* display IS the panel on phones and TVs; the activity-display distinction only matters on
|
||||
* multi-display setups, where the attached path still wins whenever it is available.
|
||||
*/
|
||||
private fun probeDisplay(context: Context): Display? =
|
||||
runCatching { context.display }.getOrNull()
|
||||
?: runCatching {
|
||||
context.getSystemService(DisplayManager::class.java)
|
||||
?.getDisplay(Display.DEFAULT_DISPLAY)
|
||||
}.getOrNull().also {
|
||||
if (it != null) Log.i("punktfunk", "display probe: context unattached — using DEFAULT_DISPLAY")
|
||||
}
|
||||
|
||||
/**
|
||||
* The device's native display mode as a landscape `(width, height, hz)` — the long edge is the
|
||||
* width, since we stream a desktop. Falls back to 1920×1080@60 if the display can't be read.
|
||||
* [context] must be a visual (Activity) context.
|
||||
* width, since we stream a desktop. Falls back to 1920×1080@60 if no display can be read at all
|
||||
* (see [probeDisplay] for the cold-start fallback that makes that a last resort).
|
||||
*/
|
||||
fun nativeDisplayMode(context: Context): Triple<Int, Int, Int> {
|
||||
// getDisplay() throws on a non-visual context rather than returning null — guard it.
|
||||
val display = runCatching { context.display }.getOrNull() ?: return Triple(1920, 1080, 60)
|
||||
val display = probeDisplay(context) ?: return Triple(1920, 1080, 60)
|
||||
val mode = display.mode
|
||||
val w = mode.physicalWidth
|
||||
val h = mode.physicalHeight
|
||||
@@ -307,7 +431,12 @@ fun nativeDisplayMode(context: Context): Triple<Int, Int, Int> {
|
||||
* capability gate the Apple/Windows clients apply.
|
||||
*/
|
||||
fun displaySupportsHdr(context: Context): Boolean {
|
||||
val display = runCatching { context.display }.getOrNull() ?: return false
|
||||
val display = probeDisplay(context)
|
||||
if (display == null) {
|
||||
// Distinguishable from a real SDR verdict — a silent `false` here cost an HDR session.
|
||||
Log.w("punktfunk", "display HDR probe: no display reachable — advertising SDR")
|
||||
return false
|
||||
}
|
||||
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
|
||||
@@ -456,6 +585,15 @@ fun codecOptionsFor(stored: String, av1Capable: Boolean): List<Pair<String, Stri
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolved [Settings.systemButtons]: forward the raw guide/misc presses? Auto = forward on
|
||||
* Android — the press reaches the app on most devices, and where the shell shows its own UI
|
||||
* for it that's the shell's business. */
|
||||
fun Settings.systemButtonsForward(): Boolean = systemButtons != "local"
|
||||
|
||||
/** Resolved [Settings.guideGesture]: auto = OFF on Android (the raw press already reaches the
|
||||
* host); "on" is for devices whose shell intercepts the physical guide button. */
|
||||
fun Settings.guideGestureEnabled(): Boolean = guideGesture == "on"
|
||||
|
||||
/** The [Settings.codec] string as a `quic::CODEC_*` preference byte (`0` = auto). H264=1, HEVC=2,
|
||||
* AV1=4, PyroWave=8 (never decodable here, but the byte is the shared contract). */
|
||||
fun Settings.preferredCodec(): Int = when (codec) {
|
||||
@@ -491,6 +629,29 @@ val COMPOSITOR_OPTIONS = listOf(
|
||||
/** (verbosity, label) for the stats-overlay detail picker. Order = the live 3-finger-tap cycle. */
|
||||
val STATS_VERBOSITY_OPTIONS = StatsVerbosity.entries.map { it to it.label }
|
||||
|
||||
/** [Settings.presentPriority] as the wire int `nativeStartVideo` takes (0 = latency, 1 = smooth).
|
||||
* Unrecognized values resolve to latency — same rule as the Apple client. */
|
||||
fun Settings.presentPriorityWire(): Int = if (presentPriority == "smooth") 1 else 0
|
||||
|
||||
/** (stored value, label) for the presenter-intent picker — the Apple client's table verbatim. */
|
||||
val PRESENT_PRIORITY_OPTIONS = listOf(
|
||||
"latency" to "Lowest latency",
|
||||
"smooth" to "Smoothness",
|
||||
)
|
||||
|
||||
/** (frames, label) for the smoothness-buffer picker; each buffered frame ≈ one refresh interval
|
||||
* of jitter absorbed for one interval of added display latency ([hz] labels the cost). */
|
||||
fun smoothBufferOptions(hz: Int): List<Pair<Int, String>> {
|
||||
val periodMs = 1000.0 / maxOf(24, hz)
|
||||
fun cost(frames: Int) = "+%.0f ms".format(periodMs * frames)
|
||||
return listOf(
|
||||
0 to "Automatic",
|
||||
1 to "1 frame (${cost(1)})",
|
||||
2 to "2 frames (${cost(2)})",
|
||||
3 to "3 frames (${cost(3)})",
|
||||
)
|
||||
}
|
||||
|
||||
/** (mode, label) for the touch-input model. */
|
||||
val TOUCH_MODE_OPTIONS = listOf(
|
||||
TouchMode.TRACKPAD to "Trackpad",
|
||||
@@ -515,3 +676,17 @@ val GAMEPAD_OPTIONS = listOf(
|
||||
io.unom.punktfunk.kit.Gamepad.PREF_DUALSHOCK4 to "DualShock 4",
|
||||
io.unom.punktfunk.kit.Gamepad.PREF_STEAMDECK to "Steam Deck",
|
||||
)
|
||||
|
||||
/** (stored `system_buttons` value, label) — where the guide/share presses land while streaming. */
|
||||
val SYSTEM_BUTTON_OPTIONS = listOf(
|
||||
"auto" to "Automatic",
|
||||
"forward" to "Send to host",
|
||||
"local" to "This device",
|
||||
)
|
||||
|
||||
/** (stored `guide_gesture` value, label) — the hold-Select guide gesture. */
|
||||
val GUIDE_GESTURE_OPTIONS = listOf(
|
||||
"auto" to "Automatic",
|
||||
"on" to "On",
|
||||
"off" to "Off",
|
||||
)
|
||||
|
||||
@@ -673,12 +673,22 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
|
||||
// Only codecs this device can actually decode are offered — a preference the client never
|
||||
// advertises would be a dead setting (see [codecOptionsFor]).
|
||||
val av1Capable = remember { VideoDecoders.pickDecoder("video/av01") != null }
|
||||
// Mirror the Automatic AV1 rule in HostConnect (hardware AV1 AND no partial-frame
|
||||
// support) so the picker says what "Automatic" actually does on THIS device.
|
||||
val autoPrefersAv1 = remember {
|
||||
VideoDecoders.decodableCodecBits() and 4 != 0 && !VideoDecoders.partialFrameCapable()
|
||||
}
|
||||
SettingDropdown(
|
||||
label = "Video codec",
|
||||
options = codecOptionsFor(s.codec, av1Capable),
|
||||
selected = s.codec,
|
||||
field = "codec",
|
||||
caption = "A preference — the host falls back if it can't encode this one.",
|
||||
caption = if (autoPrefersAv1) {
|
||||
"A preference — the host falls back if it can't encode this one. " +
|
||||
"Automatic prefers AV1 on this device."
|
||||
} else {
|
||||
"A preference — the host falls back if it can't encode this one."
|
||||
},
|
||||
) { c -> update(s.copy(codec = c)) }
|
||||
|
||||
// HDR is only meaningful on a panel that can present HDR10; on an SDR display the toggle is
|
||||
@@ -711,6 +721,26 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
|
||||
field = "low_latency_mode",
|
||||
onCheckedChange = { on -> update(s.copy(lowLatencyMode = on)) },
|
||||
)
|
||||
// The timeline presenter's intent — the Apple client's "Prioritize" pair, same stored
|
||||
// values, so a profile written on one platform means the same thing here.
|
||||
SettingDropdown(
|
||||
label = "Prioritize",
|
||||
options = PRESENT_PRIORITY_OPTIONS,
|
||||
selected = if (s.presentPriority == "smooth") "smooth" else "latency",
|
||||
field = "present_priority",
|
||||
caption = "Lowest latency shows each frame the moment it can reach the panel; " +
|
||||
"Smoothness buffers a little to absorb network jitter.",
|
||||
) { v -> update(s.copy(presentPriority = v)) }
|
||||
if (s.presentPriority == "smooth") {
|
||||
SettingDropdown(
|
||||
label = "Smoothness buffer",
|
||||
options = smoothBufferOptions(if (s.hz > 0) s.hz else nhz),
|
||||
selected = if (s.smoothBuffer in 1..3) s.smoothBuffer else 0,
|
||||
field = "smooth_buffer",
|
||||
caption = "Each buffered frame absorbs one refresh of jitter and adds one of " +
|
||||
"display latency — the cost shown is at the session's refresh rate.",
|
||||
) { v -> update(s.copy(smoothBuffer = v)) }
|
||||
}
|
||||
}
|
||||
|
||||
SettingsGroup("Host output", footer = "Display changes apply from the next session.") {
|
||||
@@ -774,20 +804,59 @@ private fun AudioSettings(s: Settings, update: (Settings) -> Unit, onMicChange:
|
||||
field = "mic_enabled",
|
||||
onCheckedChange = onMicChange,
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Echo cancellation",
|
||||
subtitle = "Filters the stream's own audio out of the mic pickup",
|
||||
checked = s.echoCancel,
|
||||
enabled = s.micEnabled,
|
||||
field = "echo_cancel",
|
||||
onCheckedChange = { on -> update(s.copy(echoCancel = on)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenControllers: () -> Unit) {
|
||||
SettingsGroup(footer = "Applies from the next session.") {
|
||||
// The master switch, above everything it governs. Profileable, so it shows in both
|
||||
// scopes: a "Work" profile can decline to forward what "Game" forwards.
|
||||
ToggleRow(
|
||||
title = "Forward controllers",
|
||||
subtitle = "Send this device's controllers to the host. Turn it off when your " +
|
||||
"controller already reaches the host another way — USB passthrough such as " +
|
||||
"VirtualHere, or a pad plugged into the host — so games don't see two of them",
|
||||
checked = s.gamepadForwarding,
|
||||
field = "gamepad_forwarding",
|
||||
onCheckedChange = { on -> update(s.copy(gamepadForwarding = on)) },
|
||||
)
|
||||
SettingDropdown(
|
||||
label = "Controller type",
|
||||
options = GAMEPAD_OPTIONS,
|
||||
selected = s.gamepad,
|
||||
field = "gamepad",
|
||||
enabled = s.gamepadForwarding,
|
||||
caption = "The virtual pad the host creates. Automatic matches your controller; " +
|
||||
"every connected one is forwarded as its own player.",
|
||||
) { g -> update(s.copy(gamepad = g)) }
|
||||
SettingDropdown(
|
||||
label = "Guide button",
|
||||
options = SYSTEM_BUTTON_OPTIONS,
|
||||
selected = s.systemButtons,
|
||||
field = "system_buttons",
|
||||
enabled = s.gamepadForwarding,
|
||||
caption = "Where the guide (Xbox/PS) and share presses go while streaming. " +
|
||||
"Automatic sends them to the host whenever this device delivers them.",
|
||||
) { v -> update(s.copy(systemButtons = v)) }
|
||||
SettingDropdown(
|
||||
label = "Hold Select for guide",
|
||||
options = GUIDE_GESTURE_OPTIONS,
|
||||
selected = s.guideGesture,
|
||||
field = "guide_gesture",
|
||||
enabled = s.gamepadForwarding,
|
||||
caption = "Hold Select alone to press the host's guide button — keep holding for a " +
|
||||
"Gaming-Mode host's quick-access menu. A Select tap still goes through, " +
|
||||
"slightly delayed. For devices that intercept the real guide button.",
|
||||
) { v -> update(s.copy(guideGesture = v)) }
|
||||
DeviceScopeOnly {
|
||||
ClickableRow(
|
||||
title = "Connected controllers",
|
||||
@@ -814,8 +883,35 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
|
||||
subtitle = "Stream a Steam Controller 2 as-is — Steam on the host drives its " +
|
||||
"trackpads, gyro and haptics directly",
|
||||
checked = s.sc2Capture,
|
||||
enabled = s.gamepadForwarding,
|
||||
onCheckedChange = { on -> update(s.copy(sc2Capture = on)) },
|
||||
)
|
||||
// Same no-vibrator-gate reasoning as the SC2 row: this capture renders feedback on
|
||||
// the CONTROLLER's own motors/LEDs, not this device's.
|
||||
ToggleRow(
|
||||
title = "DualSense / DualShock passthrough (USB)",
|
||||
subtitle = "Drive a USB-connected Sony pad directly — rumble on any phone, " +
|
||||
"plus adaptive triggers, lightbar and gyro",
|
||||
checked = s.dsCapture,
|
||||
enabled = s.gamepadForwarding,
|
||||
onCheckedChange = { on -> update(s.copy(dsCapture = on)) },
|
||||
)
|
||||
// Both only ever apply to a captured pad, so they follow that row and gate on it.
|
||||
ToggleRow(
|
||||
title = "Controller haptics",
|
||||
subtitle = "Play the host's fine-grained DualSense haptics on the pad itself — " +
|
||||
"the pad keeps ordinary rumble for games that don't send them",
|
||||
checked = s.padHaptics,
|
||||
enabled = s.gamepadForwarding && s.dsCapture,
|
||||
onCheckedChange = { on -> update(s.copy(padHaptics = on)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Controller speaker",
|
||||
subtitle = "Play audio the game sends to the controller's own speaker",
|
||||
checked = s.padSpeaker,
|
||||
enabled = s.gamepadForwarding && s.dsCapture,
|
||||
onCheckedChange = { on -> update(s.copy(padSpeaker = on)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -966,6 +1062,7 @@ private fun <T> SettingDropdown(
|
||||
selected: T,
|
||||
field: String? = null,
|
||||
caption: String? = null,
|
||||
enabled: Boolean = true,
|
||||
onSelect: (T) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
@@ -973,18 +1070,25 @@ private fun <T> SettingDropdown(
|
||||
?: options.firstOrNull()?.second.orEmpty()
|
||||
Column {
|
||||
OverrideBadge(field)
|
||||
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded && enabled,
|
||||
onExpandedChange = { if (enabled) expanded = it },
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = selectedLabel,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
enabled = enabled,
|
||||
label = { Text(label) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded && enabled,
|
||||
onDismissRequest = { expanded = false },
|
||||
) {
|
||||
options.forEach { (value, lbl) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(lbl) },
|
||||
|
||||
@@ -18,11 +18,13 @@ import kotlin.math.roundToInt
|
||||
* The live stats overlay — the unified HUD (`design/stats-unification.md`): headline is
|
||||
* `capture→displayed` tiled by `host+network` + `decode` + `display` when the platform delivered
|
||||
* OnFrameRendered render callbacks this window (`dispValid`), falling back to the v1
|
||||
* `capture→decoded` headline without the `display` term when it didn't. Reads the 26-double
|
||||
* layout from [NativeBridge.nativeVideoStats]:
|
||||
* `capture→decoded` headline without the `display` term when it didn't. Reads the 33-double
|
||||
* layout from [NativeBridge.nativeVideoStats] (that KDoc is the authoritative index list):
|
||||
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skew, w, h, hz, lostTotal, bitDepth, colorPrimaries,
|
||||
* colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms, netP50Ms, lost, skipped,
|
||||
* fec, frames, dispValid, displayP50Ms, e2eDispP50Ms, e2eDispP95Ms]`.
|
||||
* fec, frames, dispValid, displayP50Ms, e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms,
|
||||
* presentsWindow, presenterActive, feedP50Ms, codecP50Ms, skippedOverflowWindow]`. Every read
|
||||
* is length-guarded, so an older native lib simply omits the lines it can't feed.
|
||||
*
|
||||
* [verbosity] selects how many lines render (each tier a superset of the last — see
|
||||
* [StatsVerbosity]):
|
||||
@@ -46,12 +48,20 @@ internal fun StatsOverlay(
|
||||
* common case: no profile) the line is exactly what it always was.
|
||||
*/
|
||||
profileName: String? = null,
|
||||
/**
|
||||
* The panel's live refresh rate (0 = unknown). Shown as a warning on the first line whenever
|
||||
* it sits below the stream rate — the "an OEM governor ignored the mode pin" tell, which
|
||||
* otherwise reads as inexplicable judder and an extra refresh of latency.
|
||||
*/
|
||||
panelHz: Float = 0f,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (verbosity == StatsVerbosity.OFF || s.size < 10) return
|
||||
val w = s[6].toInt()
|
||||
val h = s[7].toInt()
|
||||
val hz = s[8].toInt()
|
||||
val panelBelowStream = panelHz > 0f && hz > 0 && panelHz + 1f < hz.toFloat()
|
||||
val panelTag = if (panelBelowStream) " ⚠ panel ${panelHz.roundToInt()} Hz" else ""
|
||||
val latValid = s[4] != 0.0
|
||||
val skew = s[5] != 0.0
|
||||
val lost = s[9].toLong()
|
||||
@@ -65,12 +75,12 @@ internal fun StatsOverlay(
|
||||
val profileTag = profileName?.let { " · $it" }.orEmpty()
|
||||
// Compact: everything the glance-value needs on one line, nothing else.
|
||||
if (verbosity == StatsVerbosity.COMPACT) {
|
||||
statLine(compactLine(s, latValid) + profileTag, Color.White)
|
||||
statLine(compactLine(s, latValid) + profileTag + panelTag, Color.White)
|
||||
return@Column
|
||||
}
|
||||
|
||||
statLine(
|
||||
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s$profileTag",
|
||||
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s$profileTag$panelTag",
|
||||
Color.White,
|
||||
)
|
||||
if (detailed && decoderLabel.isNotEmpty()) {
|
||||
@@ -104,8 +114,47 @@ internal fun StatsOverlay(
|
||||
} else {
|
||||
"host+network ${"%.1f".format(s[14])}"
|
||||
}
|
||||
val displayTerm = if (dispValid) " + display ${"%.1f".format(s[23])}" else ""
|
||||
statLine("= $hostTerms + decode ${"%.1f".format(s[15])}$displayTerm", Color.White)
|
||||
// Timeline-presenter split (s[26]/s[27], when s[29] flags it active): the display
|
||||
// term decomposes into pace (store + glass budget) + latch (SurfaceFlinger), and
|
||||
// s[28] is the on-glass confirm count — presents ≪ fps means the presenter is
|
||||
// dropping/serializing, an fps deficit is upstream.
|
||||
val split = s.size >= 30 && s[29] != 0.0 && (s[26] > 0 || s[27] > 0)
|
||||
val displayTerm = when {
|
||||
dispValid && split ->
|
||||
" + display ${"%.1f".format(s[23])} " +
|
||||
"(pace ${"%.1f".format(s[26])} + latch ${"%.1f".format(s[27])})"
|
||||
dispValid -> " + display ${"%.1f".format(s[23])}"
|
||||
else -> ""
|
||||
}
|
||||
val presents = if (s.size >= 30 && s[29] != 0.0) {
|
||||
" · presents ${s[28].toInt()}"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
// P3 decode split (s[30]/s[31]): `feed` = received→queued (hand-off + input-slot
|
||||
// wait) + `codec` = queued→decoded (codec-pure) — rendered when a sample landed.
|
||||
val decodeTerm = if (s.size >= 33 && (s[30] > 0 || s[31] > 0)) {
|
||||
"decode ${"%.1f".format(s[15])} " +
|
||||
"(feed ${"%.1f".format(s[30])} + codec ${"%.1f".format(s[31])})"
|
||||
} else {
|
||||
"decode ${"%.1f".format(s[15])}"
|
||||
}
|
||||
statLine(
|
||||
"= $hostTerms + $decodeTerm$displayTerm$presents",
|
||||
Color.White,
|
||||
)
|
||||
// Metric fairness: the Apple client's HUD shaves ~2 refresh periods of OS
|
||||
// pipeline floor off its shown display/end-to-end; Android shows raw. This twin
|
||||
// applies the same shave so iPhone↔Android HUD numbers compare directly.
|
||||
if (dispValid && hz > 0) {
|
||||
val shave = 2000.0 / hz
|
||||
statLine(
|
||||
"≈ Apple-HUD equiv: end-to-end " +
|
||||
"${"%.1f".format((s[24] - shave).coerceAtLeast(0.0))} · display " +
|
||||
"${"%.1f".format((s[23] - shave).coerceAtLeast(0.0))} (−2 refresh)",
|
||||
Color(0xFFA8D8B8),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
counterLine(s, lost)?.let { statLine(it, Color(0xFFFFB0B0)) }
|
||||
@@ -151,12 +200,17 @@ private fun counterLine(s: DoubleArray, lostTotal: Long): String? {
|
||||
val fec = s[20].toLong()
|
||||
val frames = s[21].toLong()
|
||||
if (lost == 0L && skipped == 0L && fec == 0L) return null
|
||||
// The overflow subset of `skipped` (s[32]): whole AUs dropped before feeding — the decoder
|
||||
// fell behind. Absent (0 / old layout) the plain count keeps meaning benign pacing drops.
|
||||
val overflow = if (s.size >= 33) s[32].toLong() else 0L
|
||||
return buildList {
|
||||
if (lost > 0) {
|
||||
val pct = 100.0 * lost / (frames + lost).coerceAtLeast(1)
|
||||
add("lost $lost (${"%.1f".format(pct)}%)")
|
||||
}
|
||||
if (skipped > 0) add("skipped $skipped")
|
||||
if (skipped > 0) {
|
||||
add(if (overflow > 0) "skipped $skipped (⚠ $overflow overflow)" else "skipped $skipped")
|
||||
}
|
||||
if (fec > 0) add("FEC $fec")
|
||||
}.joinToString(" · ")
|
||||
}
|
||||
|
||||
@@ -9,11 +9,15 @@ import android.content.IntentFilter
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.media.audiofx.AcousticEchoCanceler
|
||||
import android.media.audiofx.AudioEffect
|
||||
import android.media.audiofx.NoiseSuppressor
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.text.InputType
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import android.view.Surface
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.View
|
||||
@@ -25,12 +29,20 @@ import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.MicOff
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
@@ -41,6 +53,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -54,6 +67,7 @@ import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import io.unom.punktfunk.kit.DsCapture
|
||||
import io.unom.punktfunk.kit.GamepadFeedback
|
||||
import io.unom.punktfunk.kit.GamepadRouter
|
||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
@@ -62,6 +76,7 @@ import io.unom.punktfunk.kit.Sc2Capture
|
||||
import io.unom.punktfunk.kit.VideoDecoders
|
||||
import io.unom.punktfunk.models.ActiveSession
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
@@ -77,7 +92,14 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
val micEnabled = initialSettings.micEnabled
|
||||
val context = LocalContext.current
|
||||
val activity = context as? MainActivity
|
||||
// The View hosting this composition — the one that receives the stream's touch/pointer events
|
||||
// (the gesture Box below is a Compose node inside it), so it is where unbuffered dispatch is
|
||||
// requested.
|
||||
val composeView = androidx.compose.ui.platform.LocalView.current
|
||||
val window = activity?.window
|
||||
// The negotiated stream refresh, known from the handshake (0 = unknown / older native lib) —
|
||||
// drives the panel mode pin, the render-rate vote, and the presenter's latch grid.
|
||||
val streamHz = remember(handle) { NativeBridge.nativeVideoSize(handle)?.getOrNull(2) ?: 0 }
|
||||
val controller = remember(window) {
|
||||
window?.let { WindowCompat.getInsetsController(it, it.decorView) }
|
||||
}
|
||||
@@ -88,6 +110,38 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
Manifest.permission.RECORD_AUDIO,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
// The Java AEC/NS pair backstopping the native VoiceCommunication capture preset, hung off the
|
||||
// audio session id `nativeStartMic` returns. Attached in surfaceCreated (where the mic starts)
|
||||
// and released on every path that stops the mic — the surface teardown AND the final dispose —
|
||||
// so a surface recreate re-attaches to the fresh stream instead of leaking effect engines.
|
||||
// All three touch points run on the main thread; a plain list is race-free.
|
||||
val micEffects = remember { mutableListOf<AudioEffect>() }
|
||||
|
||||
// In-stream mic mute. Per SESSION and never persisted (no setting backs it): a new stream
|
||||
// always starts unmuted. The authoritative flag lives on the native handle, which is why a mute
|
||||
// survives the mic stop/start a surface recreate performs — this state is the UI's mirror of
|
||||
// it, and survives the same recreate because the composition outlives the surface.
|
||||
var micMuted by remember(handle) { mutableStateOf(false) }
|
||||
// Whether a capture is actually RUNNING, not merely wanted — set from surfaceCreated on what
|
||||
// nativeMicActive reports. A device that refused every AAudio input rung gets no mute control
|
||||
// rather than one that lies about a mic being heard.
|
||||
var micRunning by remember(handle) { mutableStateOf(false) }
|
||||
// Transient confirmation of a mic-chord toggle (null = nothing showing). Only the gamepad path
|
||||
// needs it: the touch button confirms itself by changing under the finger, but a chord has no
|
||||
// on-screen state of its own, and "did that register?" is exactly the doubt to answer.
|
||||
var micHint by remember { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(micHint) {
|
||||
if (micHint != null) {
|
||||
delay(1600)
|
||||
micHint = null
|
||||
}
|
||||
}
|
||||
// The one place mute is toggled — Compose state + the native flag, always together.
|
||||
val setMicMuted = { muted: Boolean ->
|
||||
micMuted = muted
|
||||
NativeBridge.nativeSetMicMuted(handle, muted)
|
||||
}
|
||||
|
||||
// Live decode stats for the HUD. `statsOn` (verbosity != OFF) gates the whole native pipeline:
|
||||
// the per-frame sampling (nativeSetVideoStatsEnabled — a hidden HUD costs one atomic load per
|
||||
// frame) AND the 1 s poll loop, which only runs while the overlay is visible. Enabling resets
|
||||
@@ -99,6 +153,9 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
var stats by remember { mutableStateOf<DoubleArray?>(null) }
|
||||
var decoderLabel by remember { mutableStateOf("") }
|
||||
var codecLabel by remember { mutableStateOf("") }
|
||||
// The panel's LIVE refresh rate, re-read each poll — the HUD flags a session whose panel sits
|
||||
// below the stream rate (an OEM governor that ignored both the mode pin and the surface hint).
|
||||
var panelHz by remember { mutableStateOf(0f) }
|
||||
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).
|
||||
@@ -120,6 +177,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
while (true) {
|
||||
delay(1000)
|
||||
stats = NativeBridge.nativeVideoStats(handle)
|
||||
panelHz = runCatching { context.display }.getOrNull()?.refreshRate ?: 0f
|
||||
// The decoder is fixed for the session; fetch its label once it's resolved.
|
||||
if (decoderLabel.isEmpty()) decoderLabel = NativeBridge.nativeVideoDecoderLabel(handle)
|
||||
}
|
||||
@@ -226,18 +284,47 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
val priorSoftInput = window?.attributes?.softInputMode
|
||||
?: WindowManager.LayoutParams.SOFT_INPUT_ADJUST_UNSPECIFIED
|
||||
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||
// Draw under the display cutout, explicitly. Android 15's SDK-35 edge-to-edge enforcement
|
||||
// makes ALWAYS the immersive default, but pre-15 devices letterbox the notch as a dead
|
||||
// black bar unless asked — and the stream's own letterbox is black anyway, so the cutout
|
||||
// region can never show anything wrong. Captured + restored like the rest of the window
|
||||
// state so the menus keep their platform-default behaviour.
|
||||
val priorCutout = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
window?.attributes?.layoutInDisplayCutoutMode
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
window?.let { w ->
|
||||
w.attributes = w.attributes.apply {
|
||||
layoutInDisplayCutoutMode =
|
||||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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
|
||||
// recreating the activity (no stream restart). On TV (fixed landscape) it's a harmless no-op.
|
||||
// The prior request is captured and restored on the way out.
|
||||
//
|
||||
// COMPACT devices only (sw < 600 dp): on tablets/foldables/desktop windows the lock is a
|
||||
// large-display anti-pattern (Play flags it; Android 16+ ignores it there outright), and the
|
||||
// stream doesn't need it — the aspect-ratio letterbox renders correctly in any orientation,
|
||||
// the lock is purely a phone-ergonomics choice.
|
||||
val compactDevice = context.resources.configuration.smallestScreenWidthDp < 600
|
||||
val priorOrientation = activity?.requestedOrientation
|
||||
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
if (compactDevice) {
|
||||
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
}
|
||||
activity?.streamHandle = handle // route hardware keys to this session
|
||||
// Multi-controller router: a stable wire pad index per connected controller, per-device axis
|
||||
// state, Arrival/Remove on hot-plug, and feedback routed back by pad index. Forwards every
|
||||
// controller (Automatic). Built here, released on dispose.
|
||||
val router = GamepadRouter(context, handle, initialSettings.gamepad)
|
||||
val router = GamepadRouter(
|
||||
context, handle, initialSettings.gamepad, initialSettings.gamepadForwarding,
|
||||
initialSettings.systemButtonsForward(), initialSettings.guideGestureEnabled(),
|
||||
)
|
||||
activity?.gamepadRouter = router
|
||||
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips
|
||||
// the keep-alive linger), unlike a host-ended / backgrounded drop. The router debounces it
|
||||
@@ -248,6 +335,16 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
// 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 }
|
||||
// Select + Y toggles the mic — the couch reach for the on-screen mute button, which a
|
||||
// gamepad/TV user has no pointer for. Ignored when no capture is running (there is nothing
|
||||
// to mute, and claiming otherwise would be the lie the control exists to avoid).
|
||||
router.onMicChord = {
|
||||
if (micRunning) {
|
||||
val next = !micMuted
|
||||
setMicMuted(next)
|
||||
micHint = if (next) "Microphone muted" else "Microphone live"
|
||||
}
|
||||
}
|
||||
// Physical mouse: uncaptured hover/click/wheel forwards as absolute pointing; captured
|
||||
// (setting or the Ctrl+Alt+Shift+Q chord) raw deltas forward as relative mouse-look.
|
||||
// The local cursor is hidden over the stream — the host's own cursor, composited into
|
||||
@@ -304,7 +401,30 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
} else {
|
||||
null
|
||||
}
|
||||
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
|
||||
// Pin the panel to the stream's refresh (exact / multiple) for the session. The decoder's
|
||||
// own ANativeWindow_setFrameRate hint still aligns vsync, but it is advisory — some OEM
|
||||
// refresh governors ignore it outright and would leave a 120 Hz session on a 60/90 Hz
|
||||
// panel. TV boxes skip the pin: the native side actively drives the HDMI mode there.
|
||||
if (isTv) {
|
||||
activity?.setConsoleHighRefreshRate(false) // the decoder's HDMI mode switch governs
|
||||
} else {
|
||||
activity?.setStreamDisplayMode(streamHz)
|
||||
}
|
||||
// Touch/pointer events are vsync-batched by default — up to a frame of input latency the
|
||||
// stream shouldn't pay. Unbuffered dispatch delivers them the moment the kernel does.
|
||||
// Undone by passing 0 on the way out (API 30+).
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
composeView.requestUnbufferedDispatch(android.view.InputDevice.SOURCE_CLASS_POINTER)
|
||||
}
|
||||
// Vote the app's RENDER rate up to the stream's (API 35+). The mode pin above governs the
|
||||
// panel, but the platform separately down-rates a quiet app's choreographer stream
|
||||
// (frame-rate categories: a non-animating UI reads as "normal" = 60) — observed on-glass
|
||||
// as 16.6 ms vsync callbacks on a 120 Hz panel, which would pace the presenter at half
|
||||
// rate. The native side also subdivides onto the panel grid, so this vote is the belt to
|
||||
// that braces. Reset to no-preference on the way out.
|
||||
if (Build.VERSION.SDK_INT >= 35 && streamHz > 0) {
|
||||
composeView.requestedFrameRate = streamHz.toFloat()
|
||||
}
|
||||
// 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. "Rumble on this phone" (opt-in) additionally mirrors controller 1's
|
||||
@@ -325,7 +445,11 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
// 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
|
||||
val sc2 = if (initialSettings.sc2Capture && initialSettings.gamepadForwarding) {
|
||||
Sc2Capture(context, router)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
var sc2UsbReceiver: BroadcastReceiver? = null
|
||||
if (sc2 != null) {
|
||||
feedback.onHidRaw = sc2::onHidRaw
|
||||
@@ -367,14 +491,87 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sony pad capture (DualSense / Edge / DualShock 4, opt-out): claim a USB-connected
|
||||
// pad's HID interface and drive it directly — rumble without a kernel force-feedback
|
||||
// driver, plus adaptive triggers, lightbar, player LEDs and gyro/touchpad, none of which
|
||||
// the InputDevice path can render (no platform API for any of them). Uncaptured (toggle
|
||||
// off / permission denied / Bluetooth) the pad stays on the ordinary InputDevice path —
|
||||
// the automatic fallback. Host feedback routes back through feedback.sink; the claim
|
||||
// frees the pad's InputDevice slot itself (see DsCapture.startUsb), so the wire index
|
||||
// hands over deterministically.
|
||||
val ds = if (initialSettings.dsCapture && initialSettings.gamepadForwarding) {
|
||||
DsCapture(context, router)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
var dsUsbReceiver: BroadcastReceiver? = null
|
||||
if (ds != null) {
|
||||
feedback.sink = ds
|
||||
// Tier-A pad audio: render the host's 0xD1 streams on the pad's own 4-channel USB
|
||||
// audio device. Bound here rather than inside DsCapture because the session handle
|
||||
// lives at this layer; DsCapture decides WHEN (it knows the wire index and the link
|
||||
// lifetime), this decides WHETHER.
|
||||
if (initialSettings.padHaptics || initialSettings.padSpeaker) {
|
||||
ds.padAudio = object : DsCapture.PadAudioHook {
|
||||
override fun start(pad: Int, fd: Int) {
|
||||
val ok = NativeBridge.nativeStartPadAudio(
|
||||
handle,
|
||||
pad,
|
||||
fd,
|
||||
initialSettings.padHaptics,
|
||||
initialSettings.padSpeaker,
|
||||
)
|
||||
Log.i("punktfunk", "pad audio on pad $pad: ${if (ok) "started" else "unavailable"}")
|
||||
}
|
||||
|
||||
// Returns only once the render thread is joined — DsCapture calls this before
|
||||
// closing the connection whose descriptor that thread borrows.
|
||||
override fun stop(pad: Int) = NativeBridge.nativeStopPadAudio(handle, pad)
|
||||
}
|
||||
}
|
||||
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
val usbDev = ds.findUsbDevice()
|
||||
when {
|
||||
usbDev != null && usbManager.hasPermission(usbDev) -> ds.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.DS_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) ds.startUsb(usbDev) else Log.i("punktfunk", "Sony pad USB permission denied")
|
||||
}
|
||||
}
|
||||
dsUsbReceiver = receiver
|
||||
ContextCompat.registerReceiver(
|
||||
context, receiver, IntentFilter(action), ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
usbManager.requestPermission(
|
||||
usbDev,
|
||||
PendingIntent.getBroadcast(
|
||||
context, 2, // requestCode 2 — 0/1 are the SC2 stream/menu grants
|
||||
Intent(action).setPackage(context.packageName),
|
||||
// MUTABLE: the USB stack appends the grant extras to this intent.
|
||||
PendingIntent.FLAG_MUTABLE,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
onDispose {
|
||||
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it
|
||||
clip?.stop() // stop + join the clipboard poll thread BEFORE the handle is freed
|
||||
feedback.onHidRaw = null
|
||||
feedback.sink = 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)
|
||||
dsUsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||
ds?.stop() // rumble-stop on the physical pad + release the USB link + free the wire slot
|
||||
router.onExitArmed = null // don't poke Compose state from release()'s disarm while tearing down
|
||||
router.onMicChord = null // same: no mute toggle on buttons released during teardown
|
||||
router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener
|
||||
activity?.gamepadRouter = null
|
||||
// Mouse/remote-pointer teardown: lift held buttons, drop the grab, restore the cursor.
|
||||
@@ -388,8 +585,19 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
// 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
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
composeView.requestUnbufferedDispatch(0) // back to ordinary batched dispatch
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 35) {
|
||||
composeView.requestedFrameRate = View.REQUESTED_FRAME_RATE_CATEGORY_DEFAULT
|
||||
}
|
||||
controller?.hide(WindowInsetsCompat.Type.ime()) // drop any keyboard left showing
|
||||
window?.setSoftInputMode(priorSoftInput)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && priorCutout != null) {
|
||||
window?.let { w ->
|
||||
w.attributes = w.attributes.apply { layoutInDisplayCutoutMode = priorCutout }
|
||||
}
|
||||
}
|
||||
controller?.show(WindowInsetsCompat.Type.systemBars())
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
@@ -400,6 +608,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
activity?.requestedOrientation =
|
||||
priorOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
// Leaving the stream: stop the mic + audio + decode threads and tear down the session.
|
||||
releaseMicEffects(micEffects)
|
||||
NativeBridge.nativeStopMic(handle)
|
||||
NativeBridge.nativeStopAudio(handle)
|
||||
NativeBridge.nativeStopVideo(handle)
|
||||
@@ -480,12 +689,48 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
lowLatencyMode,
|
||||
choice?.lowLatencyFeature ?: false,
|
||||
isTv,
|
||||
initialSettings.presentPriorityWire(),
|
||||
initialSettings.smoothBuffer,
|
||||
// The panel's own refresh — from the mode TABLE (streamPanelFps),
|
||||
// because display.refreshRate reports a per-uid override, not the
|
||||
// panel. Fallback: the (possibly lying) live rate.
|
||||
activity?.streamPanelFps(streamHz)?.takeIf { it > 0 }
|
||||
?: (runCatching { context.display }.getOrNull()?.refreshRate ?: 0f)
|
||||
.roundToInt(),
|
||||
)
|
||||
NativeBridge.nativeStartAudio(handle, lowLatencyMode)
|
||||
if (micWanted) NativeBridge.nativeStartMic(handle)
|
||||
if (micWanted) {
|
||||
val sessionId =
|
||||
NativeBridge.nativeStartMic(handle, initialSettings.echoCancel)
|
||||
if (initialSettings.echoCancel) {
|
||||
attachMicEffects(sessionId, micEffects)
|
||||
}
|
||||
// Did a capture actually open? That — not the setting — is what
|
||||
// puts the mute control on screen. A restart after a surface
|
||||
// recreate comes back already muted if the user muted: the flag
|
||||
// lives on the session handle, so nothing has to be re-applied.
|
||||
micRunning = NativeBridge.nativeMicActive(handle)
|
||||
}
|
||||
}
|
||||
|
||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {}
|
||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
|
||||
// Re-assert the frame-rate vote: a buffer-geometry change can reset
|
||||
// the surface's frame-rate setting on some OEM builds, silently
|
||||
// dropping the 120 Hz pin mid-stream. Mirrors the native hint's
|
||||
// policy (FIXED_SOURCE; ALWAYS only on the TV low-latency path —
|
||||
// phones stay seamless so a re-hint can never force a mode flicker).
|
||||
if (streamHz > 0) runCatching {
|
||||
holder.surface.setFrameRate(
|
||||
streamHz.toFloat(),
|
||||
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
|
||||
if (isTv && lowLatencyMode) {
|
||||
Surface.CHANGE_FRAME_RATE_ALWAYS
|
||||
} else {
|
||||
Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||
// Surface gone (backgrounding, or on the way out). Stop the threads that
|
||||
@@ -493,7 +738,12 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
// DisposableEffect has closed it, the handle is freed; dereferencing it
|
||||
// here is the use-after-free that crashed on back-navigation.
|
||||
if (!closed.get()) {
|
||||
releaseMicEffects(micEffects)
|
||||
NativeBridge.nativeStopMic(handle)
|
||||
// No capture, no control — but the MUTE state is deliberately left
|
||||
// standing (native keeps it on the handle), so the restart in
|
||||
// surfaceCreated brings the user's choice back with it.
|
||||
micRunning = false
|
||||
NativeBridge.nativeStopAudio(handle)
|
||||
NativeBridge.nativeStopVideo(handle)
|
||||
}
|
||||
@@ -508,6 +758,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
stats?.let {
|
||||
StatsOverlay(
|
||||
it, statsVerbosity, decoderLabel, codecLabel, session.profileName,
|
||||
panelHz,
|
||||
Modifier.align(Alignment.TopStart).padding(12.dp),
|
||||
)
|
||||
}
|
||||
@@ -567,9 +818,106 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
}
|
||||
},
|
||||
)
|
||||
// Mic mute, LAST in the stack — the one in-stream control, so unlike the purely visual
|
||||
// overlays above it has to sit on top of the gesture layer to receive its own taps (it
|
||||
// costs the stream that small corner of touch area, which is why it exists only while a
|
||||
// capture actually runs). On TV it is the indicator alone: the Select + Y chord is the
|
||||
// control there, and a focusable button would fight the game for the D-pad.
|
||||
if (micRunning && (micMuted || !isTv)) {
|
||||
MicMuteControl(
|
||||
muted = micMuted,
|
||||
onToggle = if (isTv) null else ({ setMicMuted(!micMuted) }),
|
||||
modifier = Modifier.align(Alignment.TopEnd).padding(12.dp),
|
||||
)
|
||||
}
|
||||
// Chord confirmation (gamepad/TV) — the counterpart to the button changing under a finger.
|
||||
micHint?.let { MicChordHint(it, Modifier.align(Alignment.TopCenter).padding(top = 16.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the Java echo-canceller + noise-suppressor pair to the mic stream's audio session — the
|
||||
* backstop for HALs whose VoiceCommunication capture path doesn't cancel on its own (the native
|
||||
* side already opened the stream under that preset). [sessionId] `<= 0` means native allocated no
|
||||
* session (echo cancellation off, or the preset fell back to the plain open), so there is nothing
|
||||
* to hang an effect on. Created effects land in [into] for [releaseMicEffects]; `create()`
|
||||
* returning null (unsupported / claimed) is quietly nothing — the HAL preset still does its part.
|
||||
* Needs no extra permission: the effect APIs attach to our own recording session.
|
||||
*/
|
||||
private fun attachMicEffects(sessionId: Int, into: MutableList<AudioEffect>) {
|
||||
if (sessionId <= 0) return
|
||||
if (AcousticEchoCanceler.isAvailable()) {
|
||||
AcousticEchoCanceler.create(sessionId)?.let { it.setEnabled(true); into.add(it) }
|
||||
}
|
||||
if (NoiseSuppressor.isAvailable()) {
|
||||
NoiseSuppressor.create(sessionId)?.let { it.setEnabled(true); into.add(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Release every attached mic effect engine. Idempotent — the list is cleared, and both stop
|
||||
* paths (surface teardown, final dispose) may call it in either order. */
|
||||
private fun releaseMicEffects(effects: MutableList<AudioEffect>) {
|
||||
effects.forEach { runCatching { it.release() } }
|
||||
effects.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* The in-stream mic control and its muted indicator, in one element: a dim mic glyph while the
|
||||
* uplink is live, a red **Muted** badge while it isn't — so the state that matters is the loud one,
|
||||
* readable at couch distance and impossible to mistake for the stream's own picture.
|
||||
*
|
||||
* [onToggle] `null` makes it a pure indicator (the TV/gamepad surface, where the Select + Y chord
|
||||
* is the control); non-null makes the badge itself the touch target. Rendering it at all is the
|
||||
* caller's decision — it means a capture is genuinely running.
|
||||
*/
|
||||
@Composable
|
||||
private fun MicMuteControl(muted: Boolean, onToggle: (() -> Unit)?, modifier: Modifier = Modifier) {
|
||||
val shape = RoundedCornerShape(10.dp)
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(shape)
|
||||
.background(if (muted) Color(0xE0B3261E) else Color.Black.copy(alpha = 0.45f))
|
||||
.then(if (onToggle != null) Modifier.clickable(onClick = onToggle) else Modifier)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (muted) Icons.Filled.MicOff else Icons.Filled.Mic,
|
||||
// Spoken state first, then the action — a talkback user needs to know they are muted
|
||||
// before they need to know how to stop being muted.
|
||||
contentDescription = if (muted) {
|
||||
"Microphone muted. Activate to unmute."
|
||||
} else {
|
||||
"Microphone live. Activate to mute."
|
||||
},
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
if (muted) {
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Muted", color = Color.White, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transient confirmation that the mic chord (Select + Y) registered. The badge above already says
|
||||
* *muted*, but nothing on screen says *un*muted — and "did that press do anything?" is the whole
|
||||
* doubt a chord with no button under the finger creates. Same pill vocabulary as the other
|
||||
* in-stream cues; the caller clears it after a beat.
|
||||
*/
|
||||
@Composable
|
||||
private fun MicChordHint(text: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text,
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -14,7 +14,11 @@ private const val PEN_TOUCHING = 2f
|
||||
private const val PEN_BARREL1 = 4f
|
||||
private const val PEN_BARREL2 = 8f
|
||||
private const val STRIDE = 10
|
||||
private const val MAX_SAMPLES = 8
|
||||
// Ceiling on samples per emit, NOT the wire batch size: the JNI layer splits an over-8 run into
|
||||
// consecutive wire batches (never truncates — a long historical run means the UI thread hitched,
|
||||
// which is exactly when dropping its head would notch the stroke). 64 samples ≈ >250 ms of
|
||||
// 240 Hz history; anything past that clamp is a pathological stall, not stroke geometry.
|
||||
private const val MAX_SAMPLES = 64
|
||||
|
||||
/**
|
||||
* Android stylus → the state-full pen plane (design/pen-tablet-input.md §7): pressure, tilt
|
||||
@@ -117,7 +121,8 @@ internal class StylusStream(private val handle: Long) {
|
||||
NativeBridge.nativeSendPen(handle, last, 1)
|
||||
}
|
||||
|
||||
/** Historical (coalesced) samples oldest-first, then the current one — a single batch. */
|
||||
/** Historical (coalesced) samples oldest-first, then the current one — one emit; the JNI
|
||||
* layer splits runs longer than the wire's 8-sample batch cap into consecutive sends. */
|
||||
private fun emitSamples(me: MotionEvent, idx: Int, size: IntSize) {
|
||||
val history = minOf(me.historySize, MAX_SAMPLES - 1)
|
||||
var count = 0
|
||||
|
||||
@@ -105,8 +105,20 @@ internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long, styl
|
||||
NativeBridge.nativeSendTouch(handle, it, 2, 0, 0, sw, sh)
|
||||
}
|
||||
c.positionChanged() ->
|
||||
ids[c.id]?.let {
|
||||
NativeBridge.nativeSendTouch(handle, it, 1, x, y, sw, sh)
|
||||
ids[c.id]?.let { id ->
|
||||
// Batched MotionEvents coalesce intermediate points into the
|
||||
// historical list — forward them in order so a fast swipe keeps
|
||||
// its real curvature on the host (usually empty during a stream:
|
||||
// unbuffered dispatch is requested, so this costs nothing).
|
||||
for (hs in c.historical) {
|
||||
NativeBridge.nativeSendTouch(
|
||||
handle, id, 1,
|
||||
hs.position.x.roundToInt().coerceIn(0, sw - 1),
|
||||
hs.position.y.roundToInt().coerceIn(0, sh - 1),
|
||||
sw, sh,
|
||||
)
|
||||
}
|
||||
NativeBridge.nativeSendTouch(handle, id, 1, x, y, sw, sh)
|
||||
}
|
||||
}
|
||||
c.consume()
|
||||
@@ -289,7 +301,10 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
||||
accY -= outY
|
||||
}
|
||||
} else {
|
||||
moveAbs(p.position.x, p.position.y) // direct: cursor follows the finger
|
||||
// Direct: cursor follows the finger — historical points first (batched
|
||||
// MotionEvent samples), so the host cursor traces the finger's real path.
|
||||
for (hs in p.historical) moveAbs(hs.position.x, hs.position.y)
|
||||
moveAbs(p.position.x, p.position.y)
|
||||
}
|
||||
}
|
||||
ev.changes.forEach { it.consume() }
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.PathParser
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.unom.punktfunk.kit.discovery.osIconTokens
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* The host card's OS marks, resolved from the host's OS-identity chain (mDNS `os` TXT,
|
||||
@@ -14,18 +15,19 @@ import io.unom.punktfunk.kit.discovery.osIconTokens
|
||||
* ship, so an unknown distro degrades to its family's mark and finally to Tux; null means
|
||||
* "no icon", rendering the card exactly as before the field existed.
|
||||
*
|
||||
* Path data is vendored from the assets/os-icons masters (Font Awesome Free brands
|
||||
* CC BY 4.0 + Simple Icons CC0 — provenance in that directory's README); Material ships
|
||||
* no brand icons. Hand-kept as raw SVG path strings (one line each) rather than
|
||||
* transcribed ImageVector DSL — [PathParser] builds the vector once, then it's cached.
|
||||
* Path data is vendored from the assets/os-icons masters (per-mark provenance and licensing
|
||||
* in that directory's README; `bash scripts/gen-os-icons.sh <token>` prints a master's
|
||||
* viewport + path ready to paste); Material ships no brand icons. Hand-kept as raw SVG path
|
||||
* strings (one line each) rather than transcribed ImageVector DSL — [PathParser] builds the
|
||||
* vector once, then it's cached.
|
||||
*/
|
||||
private class OsGlyph(val viewportWidth: Float, val viewportHeight: Float, val d: String)
|
||||
|
||||
private val GLYPHS: Map<String, OsGlyph> = mapOf(
|
||||
"windows" to OsGlyph(
|
||||
viewportWidth = 448f,
|
||||
viewportHeight = 512f,
|
||||
d = "M0 93.7l183.6-25.3v177.4H0V93.7zm0 324.6l183.6 25.3V268.4H0v149.9zm203.8 28L448 480V268.4H203.8v177.9zm0-380.6v180.1H448V32L203.8 65.7z",
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
d = "M0 0h11.377v11.377H0zm12.623 0H24v11.377H12.623zM0 12.623h11.377V24H0zm12.623 0H24V24H12.623z",
|
||||
),
|
||||
"apple" to OsGlyph(
|
||||
viewportWidth = 384f,
|
||||
@@ -72,8 +74,28 @@ private val GLYPHS: Map<String, OsGlyph> = mapOf(
|
||||
viewportHeight = 512f,
|
||||
d = "M471.08 102.66s-.3 18.3-.3 20.3c-9.1-3-74.4-24.1-135.7-26.3-51.9-1.8-122.8-4.3-223 57.3-19.4 12.4-73.9 46.1-99.6 109.7C7 277-.12 307 7 335.06a111 111 0 0 0 16.5 35.7c17.4 25 46.6 41.6 78.1 44.4 44.4 3.9 78.1-16 90-53.3 8.2-25.8 0-63.6-31.5-82.9-25.6-15.7-53.3-12.1-69.2-1.6-13.9 9.2-21.8 23.5-21.6 39.2.3 27.8 24.3 42.6 41.5 42.6a49 49 0 0 0 15.8-2.7c6.5-1.8 13.3-6.5 13.3-14.9 0-12.1-11.6-14.8-16.8-13.9-2.9.5-4.5 2-11.8 2.4-2-.2-12-3.1-12-14V316c.2-12.3 13.2-18 25.5-16.9 32.3 2.8 47.7 40.7 28.5 65.7-18.3 23.7-76.6 23.2-99.7-20.4-26-49.2 12.7-111.2 87-98.4 33.2 5.7 83.6 35.5 102.4 104.3h45.9c-5.7-17.6-8.9-68.3 42.7-68.3 56.7 0 63.9 39.9 79.8 68.3H460c-12.8-18.3-21.7-38.7-18.9-55.8 5.6-33.8 39.7-18.4 82.4-17.4 66.5.4 102.1-27 103.1-28 3.7-3.1 6.5-15.8 7-17.7 1.3-5.1-3.2-2.4-3.2-2.4-8.7 5.2-30.5 15.2-50.9 15.6-25.3.5-76.2-25.4-81.6-28.2-.3-.4.1 1.2-11-25.5 88.4 58.3 118.3 40.5 145.2 21.7.8-.6 4.3-2.9 3.6-5.7-13.8-48.1-22.4-62.7-34.5-69.6-37-21.6-125-34.7-129.2-35.3.1-.1-.9-.3-.9.7zm60.4 72.8a37.54 37.54 0 0 1 38.9-36.3c33.4 1.2 48.8 42.3 24.4 65.2-24.2 22.7-64.4 4.6-63.3-28.9zm38.6-25.3a26.27 26.27 0 1 0 25.4 27.2 26.19 26.19 0 0 0-25.4-27.2zm4.3 28.8c-15.4 0-15.4-15.6 0-15.6s15.4 15.64 0 15.64z",
|
||||
),
|
||||
// The gaming distros get their own mark rather than their family's: "a Bazzite box" and
|
||||
// "a Fedora box" are different machines to the person reading the card.
|
||||
"bazzite" to OsGlyph(
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
d = "M7.178 0h3.589v7.178h7.524c3.153 0 5.709 2.556 5.709 5.709 0 6.138-4.976 11.113-11.113 11.113-3.153 0-5.709-2.556-5.709-5.709V10.766H0v-3.589h7.178zm3.589 10.766v7.524c0 1.171.949 2.12 2.12 2.12 4.156 0 7.524-3.369 7.524-7.524 0-1.171-.949-2.12-2.12-2.12z",
|
||||
),
|
||||
"cachyos" to OsGlyph(
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
d = "M5.301 2.646 0 11.771l5.541 9.583h11.486l2.904-5.017H8.102l-2.56-4.429L8.067 7.54h6.063l2.83-4.893ZM20.058 4.12a.748.748 0 0 0 0 1.496.748.748 0 0 0 0-1.496m-1.983 4.303a1.45 1.45 0 0 0 0 2.9 1.45 1.45 0 0 0 0-2.9m4.02 3.98a1.904 1.904 0 0 0 0 3.809 1.904 1.904 0 0 0 0-3.81",
|
||||
),
|
||||
"nobara" to OsGlyph(
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
d = "M23.808 11.808v8.281a3.542 3.542 0 0 1-3.542 3.527h-.46a3.543 3.543 0 0 1-3.083-3.513v-7.282l3.543-1.013-3.66-1.045a4.724 4.724 0 0 0-9.33 1.045v2.362a2.362 2.362 0 0 0 2.362 2.362 3.543 3.543 0 0 1 3.543 3.542V24a3.539 3.539 0 0 0-3.542-3.542 3.537 3.537 0 0 0-3.063 1.76 3.54 3.54 0 0 1-2.382 1.398h-.46A3.542 3.542 0 0 1 .192 20.09V3.543a3.542 3.542 0 0 1 6.323-2.194A11.756 11.756 0 0 1 12 0c6.521 0 11.808 5.287 11.808 11.808zm-9.446 0A2.359 2.359 0 0 1 12 14.17a2.362 2.362 0 1 1 2.362-2.362z",
|
||||
),
|
||||
)
|
||||
|
||||
/** Longest edge of a built mark, in dp — the box callers size the [Icon] to. */
|
||||
private const val GLYPH_DP = 24f
|
||||
|
||||
private val built = mutableMapOf<String, ImageVector>()
|
||||
|
||||
/** The mark for a chain, or null (no icon). Vectors build lazily and cache per token. */
|
||||
@@ -82,11 +104,18 @@ fun resolveOsIcon(chain: String): ImageVector? =
|
||||
GLYPHS[token]?.let { glyph -> built.getOrPut(token) { glyph.build(token) } }
|
||||
}
|
||||
|
||||
private fun OsGlyph.build(token: String): ImageVector =
|
||||
ImageVector.Builder(
|
||||
private fun OsGlyph.build(token: String): ImageVector {
|
||||
// The intrinsic size has to carry the VIEWPORT'S ASPECT RATIO, not a fixed square:
|
||||
// a VectorPainter maps the viewport onto the default size with independent x and y
|
||||
// scales, so declaring a 448x512 mark as 24x24 dp stretches it horizontally — which is
|
||||
// exactly how Tux and the Apple mark used to come out on a phone. Scaling the longest
|
||||
// edge to GLYPH_DP instead keeps the ratio, and Icon() paints with ContentScale.Fit, so
|
||||
// the mark letterboxes inside whatever box the caller sized us to.
|
||||
val longest = max(viewportWidth, viewportHeight)
|
||||
return ImageVector.Builder(
|
||||
name = "OsIcon.$token",
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
defaultWidth = (GLYPH_DP * viewportWidth / longest).dp,
|
||||
defaultHeight = (GLYPH_DP * viewportHeight / longest).dp,
|
||||
viewportWidth = viewportWidth,
|
||||
viewportHeight = viewportHeight,
|
||||
).apply {
|
||||
@@ -96,3 +125,4 @@ private fun OsGlyph.build(token: String): ImageVector =
|
||||
fill = SolidColor(Color.Black),
|
||||
)
|
||||
}.build()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The controller-navigable settings rows: what the master forwarding switch governs, and that a
|
||||
* governed row is inert rather than merely dim.
|
||||
*
|
||||
* The touch settings and the desktop console have carried this relationship for a while (`enabled =
|
||||
* s.gamepadForwarding` / `RowSpec.enabled`); this screen dimmed nothing and stepped everything, so
|
||||
* these tests pin both halves — the flag AND the refusal to write.
|
||||
*/
|
||||
class GamepadSettingsRowsTest {
|
||||
|
||||
/** Rows for a given forwarding state, capturing whatever a row writes back. */
|
||||
private fun rows(
|
||||
forwarding: Boolean,
|
||||
sink: MutableList<Settings> = mutableListOf(),
|
||||
): List<GpRow> = buildSettingsRows(
|
||||
Settings(gamepadForwarding = forwarding),
|
||||
hasBodyVibrator = true,
|
||||
av1Capable = true,
|
||||
) { sink += it }
|
||||
|
||||
private fun row(rows: List<GpRow>, id: String): GpRow =
|
||||
rows.first { it.id == id }
|
||||
|
||||
/** Every row that only means something while a controller is actually being forwarded. */
|
||||
private val governed = listOf("padType", "systemButtons", "guideGesture", "sc2", "dsCapture")
|
||||
|
||||
@Test
|
||||
fun `forwarding off dims every row that depends on it`() {
|
||||
val off = rows(forwarding = false)
|
||||
for (id in governed) {
|
||||
assertFalse("$id should be dimmed with forwarding off", row(off, id).enabled)
|
||||
}
|
||||
// The master switch itself stays live — otherwise it could never be turned back on.
|
||||
assertTrue(row(off, "padForward").enabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `forwarding on leaves them all live`() {
|
||||
val on = rows(forwarding = true)
|
||||
for (id in governed) {
|
||||
assertTrue("$id should be live with forwarding on", row(on, id).enabled)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a dimmed row is inert - liveRow withholds it and nothing is written`() {
|
||||
val writes = mutableListOf<Settings>()
|
||||
val off = rows(forwarding = false, sink = writes)
|
||||
for (id in governed) {
|
||||
val i = off.indexOfFirst { it.id == id }
|
||||
assertNull("$id must not be reachable while dimmed", liveRow(off, i))
|
||||
// What the screen actually does on left/right/A — the whole point is that it no-ops.
|
||||
liveRow(off, i)?.adjust(1)
|
||||
liveRow(off, i)?.adjust(-1)
|
||||
liveRow(off, i)?.activate()
|
||||
}
|
||||
assertEquals("a dimmed row wrote a setting", emptyList<Settings>(), writes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same rows do write once forwarding is on`() {
|
||||
val writes = mutableListOf<Settings>()
|
||||
val on = rows(forwarding = true, sink = writes)
|
||||
val i = on.indexOfFirst { it.id == "sc2" }
|
||||
assertNotNull(liveRow(on, i))
|
||||
liveRow(on, i)?.activate()
|
||||
assertEquals(1, writes.size)
|
||||
assertFalse("activate flips the toggle", writes[0].sc2Capture)
|
||||
}
|
||||
|
||||
/**
|
||||
* R18: the Sony passthrough toggle the touch settings have always had. It matters most exactly
|
||||
* where this screen is the only one reachable — a TV box has no touch interface to fall back to.
|
||||
*/
|
||||
@Test
|
||||
fun `the DualSense passthrough toggle is present, next to its SC2 twin`() {
|
||||
val on = rows(forwarding = true)
|
||||
val ids = on.map { it.id }
|
||||
assertTrue("dsCapture row is missing", "dsCapture" in ids)
|
||||
assertEquals(
|
||||
"the two passthrough rows belong side by side",
|
||||
ids.indexOf("sc2") + 1,
|
||||
ids.indexOf("dsCapture"),
|
||||
)
|
||||
// Drawn as a switch, and reading the persisted default.
|
||||
assertEquals(true, row(on, "dsCapture").toggled)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import io.unom.punktfunk.components.resolveOsIcon
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pure JVM test of the host card's OS marks (`components/OsIcons.kt`). Run:
|
||||
* `./gradlew :app:testDebugUnitTest`.
|
||||
*
|
||||
* The aspect assertions are the point: a [androidx.compose.ui.graphics.vector.VectorPainter] maps
|
||||
* the viewport onto the vector's default size with independent x and y scales, so a mark whose
|
||||
* default size does not carry its viewport's ratio renders STRETCHED — silently, with no crash and
|
||||
* no warning. That is exactly how Tux and the Apple mark used to look on a phone.
|
||||
*/
|
||||
class OsIconsTest {
|
||||
/** `ImageVector.name` is "OsIcon.<token>" — the only handle on which mark got resolved. */
|
||||
private fun markOf(chain: String) = resolveOsIcon(chain)?.name?.removePrefix("OsIcon.")
|
||||
|
||||
@Test
|
||||
fun defaultSizeCarriesTheViewportAspect() {
|
||||
for (chain in listOf("windows", "linux", "opensuse", "steam", "apple", "bazzite")) {
|
||||
val v = resolveOsIcon(chain)
|
||||
assertNotNull("no mark for $chain", v)
|
||||
v!!
|
||||
assertEquals(
|
||||
"$chain default size must keep the viewport ratio",
|
||||
v.viewportWidth / v.viewportHeight,
|
||||
v.defaultWidth.value / v.defaultHeight.value,
|
||||
0.001f,
|
||||
)
|
||||
assertEquals(
|
||||
"$chain longest edge must be the 24dp box",
|
||||
24f,
|
||||
maxOf(v.defaultWidth.value, v.defaultHeight.value),
|
||||
0.001f,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tallMarkIsNarrowerThanItsBox() {
|
||||
// Tux is 448x512, so a correct build is 21x24dp — 24x24 would be the stretched bug.
|
||||
val tux = resolveOsIcon("linux")!!
|
||||
assertEquals(21f, tux.defaultWidth.value, 0.001f)
|
||||
assertEquals(24f, tux.defaultHeight.value, 0.001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gamingDistrosResolveToTheirOwnMark() {
|
||||
// The whole reason these three ship art: without it they'd draw their family's mark.
|
||||
assertEquals("bazzite", markOf("linux/fedora/bazzite"))
|
||||
assertEquals("cachyos", markOf("linux/arch/cachyos"))
|
||||
assertEquals("nobara", markOf("linux/rhel/nobara"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownDistroStillDegradesThroughItsFamily() {
|
||||
assertEquals("fedora", markOf("linux/fedora/somethingnew"))
|
||||
assertEquals("linux", markOf("linux/frontier/chimera"))
|
||||
assertEquals("steam", markOf("linux/arch/steamos")) // brand alias
|
||||
assertEquals("apple", markOf("macos"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noChainMeansNoMark() {
|
||||
assertNull(resolveOsIcon(""))
|
||||
assertNull(resolveOsIcon("!!!"))
|
||||
}
|
||||
}
|
||||
@@ -366,6 +366,8 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
|
||||
10.0, 9.0, 16.0, 1.0, 0.9, 0.4, 0.6, 0.3,
|
||||
2.0, 1.0, 5.0, 238.0,
|
||||
1.0, 0.5, 1.8, 2.6,
|
||||
// Timeline-presenter split: pace + latch tile the display term; presents ≈ fps.
|
||||
0.2, 0.3, 236.0, 1.0,
|
||||
),
|
||||
verbosity = verbosity,
|
||||
decoderLabel = "c2.qti.hevc.decoder · low-latency",
|
||||
|
||||
@@ -6,13 +6,23 @@ Why hand-rolled: stdlib + `openssl` only (no pip on the runner), and it prints G
|
||||
error at the stage it fails instead of a catch-all. Reuses the SERVICE_ACCOUNT_JSON secret and
|
||||
tolerates it being raw JSON *or* base64-encoded JSON.
|
||||
|
||||
Usage:
|
||||
Usage (upload a new build):
|
||||
SERVICE_ACCOUNT_JSON='<raw-or-base64 SA key>' \
|
||||
python3 play-upload.py --package io.unom.punktfunk \
|
||||
--aab path/to/app-release.aab --track internal --status completed [--no-commit]
|
||||
|
||||
--no-commit: do insert -> upload -> track-update -> validate, then delete the edit (publishes
|
||||
nothing). Use it to dry-run the credentials/AAB without touching the live track.
|
||||
Usage (promote a build that is already on Play, no rebuild):
|
||||
python3 play-upload.py --package io.unom.punktfunk \
|
||||
--promote 10816 --promote-from alpha --track production
|
||||
|
||||
Promotion assigns an EXISTING versionCode to another track, so what ships to production is the
|
||||
byte-identical artifact the testers ran — rebuilding would burn a fresh versionCode and ship
|
||||
something nobody has tested. --promote-from additionally asserts the code really is on that track
|
||||
(catches a typo'd versionCode before it reaches production) and empties it in the SAME edit, so
|
||||
the move is atomic: testers are never left pinned to a code that production also serves.
|
||||
|
||||
--no-commit: do insert -> upload/assign -> track-update -> validate, then delete the edit
|
||||
(publishes nothing). Use it to dry-run the credentials/AAB/notes without touching the live track.
|
||||
"""
|
||||
import argparse, base64, json, os, subprocess, sys, tempfile, time
|
||||
import urllib.request, urllib.parse, urllib.error
|
||||
@@ -104,18 +114,79 @@ def access_token(sa) -> str:
|
||||
return tok["access_token"]
|
||||
|
||||
|
||||
# Play's "What's new" is capped at 500 characters per language. The cap lives in the Console
|
||||
# (the REST reference does not state it) and the API rejects longer text at commit — i.e. AFTER
|
||||
# the AAB has uploaded — so check it up front and print the actual count.
|
||||
NOTES_MAX = 500
|
||||
|
||||
|
||||
def load_release_notes(path, language):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
text = f.read().strip()
|
||||
if not text:
|
||||
sys.exit(f"ERROR: release-notes file is empty: {path}")
|
||||
if len(text) > NOTES_MAX:
|
||||
sys.exit(f"ERROR: release notes are {len(text)} chars, Play allows {NOTES_MAX}: {path}")
|
||||
print(f"release notes: {len(text)}/{NOTES_MAX} chars ({language})")
|
||||
return [{"language": language, "text": text}]
|
||||
|
||||
|
||||
def put_track(app, edit, tok, track, version_codes, status, user_fraction=None, notes=None):
|
||||
"""PUT one track. An empty version_codes list clears the track (what promotion does to the
|
||||
track it promoted OUT of)."""
|
||||
release = {"status": status, "versionCodes": [str(v) for v in version_codes]}
|
||||
if user_fraction is not None:
|
||||
release["userFraction"] = user_fraction
|
||||
if notes:
|
||||
release["releaseNotes"] = notes
|
||||
# Clearing a track means "no active releases", not "an empty release".
|
||||
body = {"track": track, "releases": [release] if version_codes else []}
|
||||
call("PUT", f"{app}/edits/{edit}/tracks/{track}", token=tok,
|
||||
data=json.dumps(body).encode(), content_type="application/json")
|
||||
|
||||
|
||||
def assert_on_track(app, edit, tok, track, vc):
|
||||
"""Fail before anything is written if --promote names a versionCode that is not actually on
|
||||
the track we claim to be promoting out of."""
|
||||
got = call("GET", f"{app}/edits/{edit}/tracks/{track}", token=tok)
|
||||
live = [c for r in got.get("releases", []) for c in r.get("versionCodes", [])]
|
||||
if str(vc) not in live:
|
||||
sys.exit(f"ERROR: versionCode {vc} is not on track '{track}' (it has: {live or 'nothing'})")
|
||||
print(f"verified versionCode={vc} is live on '{track}'")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--package", required=True)
|
||||
ap.add_argument("--aab", required=True)
|
||||
ap.add_argument("--aab", help="upload this bundle (mutually exclusive with --promote)")
|
||||
ap.add_argument("--promote", type=int, metavar="VERSIONCODE",
|
||||
help="assign an already-uploaded versionCode instead of uploading")
|
||||
ap.add_argument("--promote-from", metavar="TRACK",
|
||||
help="with --promote: assert the code is on TRACK, then clear TRACK")
|
||||
ap.add_argument("--track", default="internal")
|
||||
ap.add_argument("--status", default="completed")
|
||||
ap.add_argument("--user-fraction", type=float,
|
||||
help="staged rollout fraction, 0<f<1; required by --status inProgress")
|
||||
ap.add_argument("--release-notes-file", help="Play 'What's new' text (<=500 chars)")
|
||||
ap.add_argument("--release-notes-language", default="en-US")
|
||||
ap.add_argument("--no-commit", action="store_true")
|
||||
a = ap.parse_args()
|
||||
|
||||
if not os.path.isfile(a.aab):
|
||||
if bool(a.aab) == bool(a.promote):
|
||||
sys.exit("ERROR: pass exactly one of --aab (upload) or --promote (assign an existing code)")
|
||||
if a.promote_from and not a.promote:
|
||||
sys.exit("ERROR: --promote-from only applies to --promote")
|
||||
# inProgress without a fraction is an API error; halted/completed with one is also rejected.
|
||||
if a.status == "inProgress" and a.user_fraction is None:
|
||||
sys.exit("ERROR: --status inProgress requires --user-fraction")
|
||||
if a.user_fraction is not None and not (0 < a.user_fraction < 1):
|
||||
sys.exit(f"ERROR: --user-fraction must be strictly between 0 and 1 (got {a.user_fraction})")
|
||||
if a.aab and not os.path.isfile(a.aab):
|
||||
sys.exit(f"ERROR: AAB not found: {a.aab}")
|
||||
|
||||
notes = load_release_notes(a.release_notes_file, a.release_notes_language) \
|
||||
if a.release_notes_file else None
|
||||
|
||||
sa = load_sa()
|
||||
tok = access_token(sa)
|
||||
print(f"authenticated as {sa['client_email']} (project {sa.get('project_id')})")
|
||||
@@ -123,17 +194,25 @@ def main():
|
||||
|
||||
try:
|
||||
edit = call("POST", f"{app}/edits", token=tok)["id"]
|
||||
with open(a.aab, "rb") as f:
|
||||
blob = f.read()
|
||||
print(f"uploading {a.aab} ({len(blob)} bytes) ...")
|
||||
vc = call("POST", f"{UPLOAD}/{a.package}/edits/{edit}/bundles?uploadType=media",
|
||||
token=tok, data=blob, content_type="application/octet-stream")["versionCode"]
|
||||
print(f"uploaded versionCode={vc}")
|
||||
call("PUT", f"{app}/edits/{edit}/tracks/{a.track}", token=tok,
|
||||
data=json.dumps({"track": a.track,
|
||||
"releases": [{"status": a.status, "versionCodes": [str(vc)]}]}).encode(),
|
||||
content_type="application/json")
|
||||
print(f"assigned versionCode={vc} -> track={a.track} status={a.status}")
|
||||
if a.promote:
|
||||
vc = a.promote
|
||||
if a.promote_from:
|
||||
assert_on_track(app, edit, tok, a.promote_from, vc)
|
||||
else:
|
||||
with open(a.aab, "rb") as f:
|
||||
blob = f.read()
|
||||
print(f"uploading {a.aab} ({len(blob)} bytes) ...")
|
||||
vc = call("POST", f"{UPLOAD}/{a.package}/edits/{edit}/bundles?uploadType=media",
|
||||
token=tok, data=blob, content_type="application/octet-stream")["versionCode"]
|
||||
print(f"uploaded versionCode={vc}")
|
||||
|
||||
put_track(app, edit, tok, a.track, [vc], a.status, a.user_fraction, notes)
|
||||
print(f"assigned versionCode={vc} -> track={a.track} status={a.status}"
|
||||
+ (f" userFraction={a.user_fraction}" if a.user_fraction is not None else ""))
|
||||
# Same edit as the assignment above, so the code is never active on both tracks at once.
|
||||
if a.promote_from:
|
||||
put_track(app, edit, tok, a.promote_from, [], a.status)
|
||||
print(f"cleared track '{a.promote_from}'")
|
||||
|
||||
if a.no_commit:
|
||||
call("POST", f"{app}/edits/{edit}:validate", token=tok)
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.InputDevice
|
||||
|
||||
/**
|
||||
* One captured Sony pad (DualSense / DualSense Edge / DualShock 4) over USB — stream mode only.
|
||||
* The capture exists to fix what the InputDevice path structurally can't: rumble depends on the
|
||||
* phone's kernel exposing force feedback (many don't), and adaptive triggers / lightbar / player
|
||||
* LEDs have NO platform API at all. Claiming the pad's HID interface makes all of it work on any
|
||||
* phone, plus gyro + touchpad the standard path never captured.
|
||||
*
|
||||
* Unlike [Sc2Capture] there is no raw passthrough — the host's DualSense/DS4 backends consume
|
||||
* only typed events — and no UI mode: an UNcaptured Sony pad is a perfectly good InputDevice, so
|
||||
* outside a stream the ordinary path drives the console UI and this class isn't constructed.
|
||||
* That also makes the InputDevice path the automatic fallback whenever the capture doesn't
|
||||
* engage (toggle off, permission denied, Bluetooth).
|
||||
*
|
||||
* Input: parse ([DsDevice.parseState]) → typed mirror on an [GamepadRouter.ExternalPad] (buttons
|
||||
* diffed, axes on-change — the exit chord participates like any pad) + the rich plane (touch
|
||||
* normalized to the wire's 0..65535 screen space on-change; motion forwarded per report in raw
|
||||
* device units, the wire's contract). The wire slot is claimed when the capture engages, with the
|
||||
* first parsed report as the fallback for a claim that found no free index, and freed on
|
||||
* unplug/[stop], so indices never leak.
|
||||
*
|
||||
* Feedback: implements [GamepadFeedback.PadFeedbackSink] — rumble / trigger / lightbar / player
|
||||
* LED events addressed to this pad's wire index become USB output reports on the physical pad
|
||||
* ([DsDevice] builders). Rendering runs on the feedback poll threads; [HidUsbLink.writeRaw] is
|
||||
* thread-safe (bounded newest-wins queue, submitted by the reader thread). A USB pad holds its
|
||||
* rumble level until written zero, so a backstop timer re-arms per command and writes the stop
|
||||
* itself if the poll thread stalls — the engine's explicit zeros remain the real stop mechanism.
|
||||
*/
|
||||
class DsCapture(
|
||||
context: Context,
|
||||
private val router: GamepadRouter,
|
||||
) : GamepadFeedback.PadFeedbackSink {
|
||||
private val usb = HidUsbLink(
|
||||
context,
|
||||
HidUsbLink.Config(
|
||||
tag = TAG,
|
||||
threadName = "pf-ds-usb",
|
||||
deviceMatch = { it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS },
|
||||
// No ifaceFilter: the pad's audio interfaces are not HID class, so the link's built-in
|
||||
// class check already leaves them (and the pad's headset routing) to Android; the
|
||||
// single HID interface is the only claim.
|
||||
),
|
||||
::onReport,
|
||||
::onLinkClosed,
|
||||
)
|
||||
|
||||
@Volatile private var model: DsDevice.Model? = null
|
||||
@Volatile private var pad: GamepadRouter.ExternalPad? = null
|
||||
|
||||
// Typed-mirror diff state (wire units) + rich-plane on-change mirrors. Link thread only.
|
||||
private val state = DsDevice.State()
|
||||
private var wireButtons = 0
|
||||
private val lastAxis = IntArray(6) { Int.MIN_VALUE }
|
||||
private val lastTouchActive = BooleanArray(2)
|
||||
private val lastTouchX = IntArray(2) { -1 }
|
||||
private val lastTouchY = IntArray(2) { -1 }
|
||||
|
||||
// DS4 composed feedback (its writes are full-state — see DsDevice.ds4Report). Feedback threads.
|
||||
// The lightbar starts at hid-sony's player-1 blue so the first composed write (usually a
|
||||
// rumble, before any host Led lands) doesn't black the bar out.
|
||||
@Volatile private var ds4Low = 0
|
||||
@Volatile private var ds4High = 0
|
||||
@Volatile private var ds4Rgb = 0x000040
|
||||
|
||||
// Rumble backstop: a USB pad holds its level until told zero, so a stalled poll thread would
|
||||
// leave the motors running — re-armed per command, cancelled by an explicit (0,0).
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
@Volatile private var backstop: Runnable? = null
|
||||
|
||||
/** Fired (link thread) when the capture engages or drops — the Controllers screen's status. */
|
||||
@Volatile
|
||||
var onActiveChanged: ((active: Boolean) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Tier-A pad audio, bound by the app layer (which owns the session handle).
|
||||
*
|
||||
* [start] is called once the router has assigned this pad a wire index, which the host uses to
|
||||
* address the `0xD1` stream. [stop] is called **before** the USB link closes — on [stop] and on
|
||||
* unplug alike — and must not return until nothing is still writing to the descriptor.
|
||||
*/
|
||||
interface PadAudioHook {
|
||||
fun start(pad: Int, fd: Int)
|
||||
fun stop(pad: Int)
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var padAudio: PadAudioHook? = null
|
||||
|
||||
/** True once [PadAudioHook.start] has run for the current capture, so it fires exactly once. */
|
||||
@Volatile private var padAudioStarted = false
|
||||
|
||||
/**
|
||||
* The renderer's OWN connection to the pad.
|
||||
*
|
||||
* It must not share [usb]'s descriptor: two transfer engines on one usbfs descriptor reap each
|
||||
* other's completions (see [HidUsbLink.openAuxConnection]), which strands both the HID reader
|
||||
* and the audio ring. Closed only after the hook's stop has returned.
|
||||
*/
|
||||
@Volatile private var padAudioConn: android.hardware.usb.UsbDeviceConnection? = null
|
||||
|
||||
val isActive: Boolean get() = model != null
|
||||
|
||||
/** First attached Sony USB pad, for the permission flow. Needs no permission to enumerate. */
|
||||
fun findUsbDevice(): UsbDevice? = usb.findDevice()
|
||||
|
||||
/**
|
||||
* Start capturing [dev] (permission already granted). Claims the HID interface — the kernel
|
||||
* driver detaches and the pad's InputDevice node vanishes; its router slot (if the router
|
||||
* already opened one from the pre-claim InputDevice) is released HERE, at claim time, rather
|
||||
* than waiting for the system's removal callback — so the freed wire index is deterministic
|
||||
* for this capture's ExternalPad instead of racing the first report against the callback. A
|
||||
* released sibling that still exists as an InputDevice (a same-model Bluetooth pad) lazily
|
||||
* reopens a slot on its next input event, so over-matching self-heals.
|
||||
*/
|
||||
fun startUsb(dev: UsbDevice): Boolean {
|
||||
if (model != null) return false
|
||||
val m = DsDevice.modelFor(dev.productId) ?: return false
|
||||
if (!usb.start(dev)) return false
|
||||
model = m
|
||||
for (id in InputDevice.getDeviceIds()) {
|
||||
val d = InputDevice.getDevice(id) ?: continue
|
||||
if (d.vendorId == dev.vendorId && d.productId == dev.productId) router.releaseDevice(id)
|
||||
}
|
||||
// Release the firmware's lightbar animation once so host lightbar writes take effect
|
||||
// (the same init hid-playstation/SDL send on open).
|
||||
if (m != DsDevice.Model.DUALSHOCK4) usb.writeRaw(0, DsDevice.ds5InitReport(m))
|
||||
Log.i(TAG, "Sony pad captured over USB: PID=0x%04x model=%s".format(dev.productId, m))
|
||||
ensureSlot(m)
|
||||
onActiveChanged?.invoke(true)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Stop the link and free the wire slot (host tears the virtual pad down). Idempotent. */
|
||||
fun stop() {
|
||||
// Before anything touches the link: the pad-audio renderer borrows this connection's
|
||||
// descriptor, and `usb.stop()` closes it. The hook does not return until its thread is
|
||||
// joined, so ordering this first is what makes the borrow sound.
|
||||
stopPadAudio()
|
||||
val m = model
|
||||
if (m != null) {
|
||||
// The interfaces are about to release with the kernel driver still detached — a
|
||||
// mid-rumble teardown would leave the motors running with nobody to stop them.
|
||||
// EP0-direct (the reader thread is stopping; the queue would never drain).
|
||||
// Nothing can retry after this point, so a failure is worth saying out loud: it is
|
||||
// the difference between a quiet pad and one that buzzes until it is unplugged.
|
||||
if (!usb.writeControl(stopReport(m))) Log.w(TAG, "teardown rumble stop was not written")
|
||||
// Motors silenced above; this hands back the lightbar, player LEDs and adaptive
|
||||
// triggers the game was holding, which outlive the link just as stubbornly.
|
||||
resetRichFeedback(m)
|
||||
}
|
||||
disarmBackstop()
|
||||
usb.stop()
|
||||
val wasActive = model != null
|
||||
model = null
|
||||
releaseSlot()
|
||||
if (wasActive) onActiveChanged?.invoke(false)
|
||||
}
|
||||
|
||||
// ---- link callbacks (link thread) ----
|
||||
|
||||
private fun onReport(report: ByteArray, len: Int) {
|
||||
val m = model ?: return
|
||||
if (!DsDevice.parseState(m, report, len, state)) return
|
||||
// Normally claimed already, at capture time; this is the retry for a capture that engaged
|
||||
// while every wire index was taken.
|
||||
val p = pad ?: ensureSlot(m) ?: return // all 16 taken — drop until one frees
|
||||
mirrorTyped(p)
|
||||
mirrorRich(p, m)
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim this capture's wire slot and start pad audio on it. Idempotent; null when all 16
|
||||
* indices are taken.
|
||||
*
|
||||
* Claimed when the capture engages rather than on the first report, because a pad that reports
|
||||
* nothing is still a pad: with the lazy claim, a captured-but-silent pad left the host with no
|
||||
* arrival, hence no virtual pad, no pad-audio capability and so no `0xD1` — a renderer sitting
|
||||
* at zero frames, indistinguishable from a broken pipeline (it took a physical replug to
|
||||
* clear). Callable from the main thread (capture start) and the link thread (the fallback).
|
||||
*/
|
||||
@Synchronized
|
||||
private fun ensureSlot(m: DsDevice.Model): GamepadRouter.ExternalPad? {
|
||||
pad?.let { return it }
|
||||
val p = router.openExternal(m.pref) ?: return null
|
||||
pad = p
|
||||
Log.i(TAG, "captured $m → wire pad ${p.index}")
|
||||
// The wire index exists from here on, and the host addresses pad audio by it.
|
||||
startPadAudio(p.index)
|
||||
return p
|
||||
}
|
||||
|
||||
/** Hand the renderer its own descriptor. Caller holds the monitor; fires once per capture. */
|
||||
private fun startPadAudio(index: Int) {
|
||||
val hook = padAudio ?: return
|
||||
if (padAudioStarted) return
|
||||
// A dedicated connection, NOT usb.fileDescriptor — see padAudioConn.
|
||||
val conn = usb.openAuxConnection()
|
||||
val fd = conn?.fileDescriptor ?: -1
|
||||
if (fd < 0) {
|
||||
conn?.close()
|
||||
Log.w(TAG, "pad audio: could not open a second USB connection")
|
||||
return
|
||||
}
|
||||
padAudioConn = conn
|
||||
padAudioStarted = true
|
||||
// Real-world self test, opt-in: `adb shell setprop debug.punktfunk.pad_audio_selftest 3`
|
||||
// drives the voice coils for N seconds through the actual client path before the renderer
|
||||
// takes over — the one check that proves the descriptor, the interface claim and the write
|
||||
// path all work on THIS device, without needing a host to be streaming. Same convention as
|
||||
// debug.punktfunk.force_parts.
|
||||
val secs = runCatching {
|
||||
Class.forName("android.os.SystemProperties")
|
||||
.getMethod("get", String::class.java, String::class.java)
|
||||
.invoke(null, "debug.punktfunk.pad_audio_selftest", "0") as String
|
||||
}.getOrNull()?.toIntOrNull() ?: 0
|
||||
if (secs > 0) {
|
||||
// Diagnostic mode: the self test OWNS this descriptor for the capture, and the renderer
|
||||
// must not also drive it — two engines on one usbfs descriptor reap each other's
|
||||
// completions, which is precisely the fault this test exists to expose.
|
||||
Thread({
|
||||
val r = NativeBridge.nativePadAudioSelfTest(fd, secs, 60)
|
||||
Log.i(TAG, "pad audio self-test → ${if (r > 0) "PASS ($r frames)" else "FAIL ($r)"}")
|
||||
}, "pf-pad-selftest").start()
|
||||
} else {
|
||||
// B6: hand the coils back before the first haptics frame. Any rumble earlier in this
|
||||
// session asserted HAPTICS_SELECT, which firmware-mutes them, and nothing else ever
|
||||
// clears it — so without this the stream renders into a muted actuator and looks for
|
||||
// all the world like the host is sending nothing.
|
||||
restoreAudioHaptics()
|
||||
hook.start(index, fd)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* B6: clear the rumble/haptics-select bits so the pad's voice coils answer the audio-haptics
|
||||
* path again. EP0-direct, like the other out-of-band writes here: this has to land even when
|
||||
* the interrupt-OUT queue is busy or draining, and it is idempotent.
|
||||
*/
|
||||
private fun restoreAudioHaptics() {
|
||||
val m = model ?: return
|
||||
if (m == DsDevice.Model.DUALSHOCK4) return // no voice coils, no audio-haptics path
|
||||
if (!usb.writeControl(DsDevice.ds5AudioHapticsReport(m))) {
|
||||
Log.w(TAG, "pad audio: could not hand the coils back to audio haptics")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the renderer, then close the connection whose descriptor it borrows — in that order.
|
||||
*
|
||||
* Runs on [stop] and on unplug alike. Skipping it on unplug left the render thread writing to a
|
||||
* descriptor whose device was gone, leaked the connection, and — because the started flag stayed
|
||||
* set and the native tier-A registry stayed armed for that index — cost the pad both its pad
|
||||
* audio and its wire rumble on the way back in.
|
||||
*/
|
||||
@Synchronized
|
||||
private fun stopPadAudio() {
|
||||
if (!padAudioStarted) return
|
||||
padAudioStarted = false
|
||||
// The hook's stop joins the render thread, so nothing is using the descriptor once it
|
||||
// returns — only then is it safe to close the connection that owns it.
|
||||
pad?.let { padAudio?.stop(it.index) }
|
||||
padAudioConn?.close()
|
||||
padAudioConn = null
|
||||
}
|
||||
|
||||
private fun onLinkClosed() {
|
||||
Log.i(TAG, "Sony USB link closed (unplug)")
|
||||
// Before releaseSlot(), which forgets the wire index the renderer is addressed by.
|
||||
stopPadAudio()
|
||||
disarmBackstop()
|
||||
val wasActive = model != null
|
||||
model = null
|
||||
releaseSlot()
|
||||
// Release the transport too: the link only *signals* the drop, so without this an unplug
|
||||
// left its connection open, its interfaces claimed and its detach receiver registered.
|
||||
usb.stop()
|
||||
if (wasActive) onActiveChanged?.invoke(false)
|
||||
}
|
||||
|
||||
/** Diff the parsed state onto the per-transition plane (buttons + axes, on change only). */
|
||||
private fun mirrorTyped(p: GamepadRouter.ExternalPad) {
|
||||
var changed = state.buttons xor wireButtons
|
||||
while (changed != 0) {
|
||||
val bit = changed and -changed // lowest changed bit
|
||||
p.button(bit, state.buttons and bit != 0)
|
||||
changed = changed and bit.inv()
|
||||
}
|
||||
wireButtons = state.buttons
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* The rich plane: touch contacts normalized to the wire's 0..65535 screen space, forwarded
|
||||
* on change per slot; motion forwarded every report (raw device units — the wire is a unit
|
||||
* passthrough into the host's virtual pad, and sensor noise makes per-report dedup pointless).
|
||||
*/
|
||||
private fun mirrorRich(p: GamepadRouter.ExternalPad, m: DsDevice.Model) {
|
||||
for (f in 0 until 2) {
|
||||
if (state.touchActive[f]) {
|
||||
val x = (state.touchX[f].coerceIn(0, m.touchW - 1) * 65535) / (m.touchW - 1)
|
||||
val y = (state.touchY[f].coerceIn(0, m.touchH - 1) * 65535) / (m.touchH - 1)
|
||||
if (!lastTouchActive[f] || x != lastTouchX[f] || y != lastTouchY[f]) {
|
||||
p.touch(f, true, x, y)
|
||||
lastTouchActive[f] = true
|
||||
lastTouchX[f] = x
|
||||
lastTouchY[f] = y
|
||||
}
|
||||
} else if (lastTouchActive[f]) {
|
||||
p.touch(f, false, lastTouchX[f], lastTouchY[f])
|
||||
lastTouchActive[f] = false
|
||||
}
|
||||
}
|
||||
p.motion(state.gyro, state.accel)
|
||||
}
|
||||
|
||||
private fun releaseSlot() {
|
||||
// Lift any still-touching finger so the host's virtual touchpad doesn't hold a contact.
|
||||
val p = pad
|
||||
if (p != null) {
|
||||
for (f in 0 until 2) if (lastTouchActive[f]) p.touch(f, false, lastTouchX[f], lastTouchY[f])
|
||||
}
|
||||
p?.close()
|
||||
pad = null
|
||||
wireButtons = 0
|
||||
lastAxis.fill(Int.MIN_VALUE)
|
||||
lastTouchActive.fill(false)
|
||||
lastTouchX.fill(-1)
|
||||
lastTouchY.fill(-1)
|
||||
}
|
||||
|
||||
// ---- PadFeedbackSink (feedback poll threads) ----
|
||||
|
||||
override fun ownsPad(pad: Int): Boolean = pad == this.pad?.index
|
||||
|
||||
override fun rumble(pad: Int, low: Int, high: Int, backstopMs: Long) {
|
||||
val m = model ?: return
|
||||
val stop = low == 0 && high == 0
|
||||
if (!stop) armBackstop(backstopMs)
|
||||
val sent = if (m == DsDevice.Model.DUALSHOCK4) {
|
||||
ds4Low = low
|
||||
ds4High = high
|
||||
writeDs4()
|
||||
} else {
|
||||
usb.writeRaw(0, DsDevice.ds5RumbleReport(m, low, high), OutReportQueue.KEY_RUMBLE)
|
||||
}
|
||||
if (stop) {
|
||||
// Disarm only once the stop is actually on its way. Dropping the net *before* the
|
||||
// write — as this used to — meant a discarded stop left the motors running with
|
||||
// nothing scheduled to try again; a USB pad holds its last level until told zero.
|
||||
if (sent) disarmBackstop() else armBackstop(STOP_RETRY_MS)
|
||||
// B6: the stop report just re-asserted HAPTICS_SELECT on its way past, so if a
|
||||
// haptics stream is live the coils it drives were muted by the very write that
|
||||
// silenced the motors. Give them back.
|
||||
if (sent && padAudioStarted) restoreAudioHaptics()
|
||||
}
|
||||
}
|
||||
|
||||
override fun led(pad: Int, r: Int, g: Int, b: Int) {
|
||||
val m = model ?: return
|
||||
if (m == DsDevice.Model.DUALSHOCK4) {
|
||||
ds4Rgb = (r shl 16) or (g shl 8) or b
|
||||
writeDs4()
|
||||
} else {
|
||||
usb.writeRaw(0, DsDevice.ds5LightbarReport(m, r, g, b))
|
||||
}
|
||||
}
|
||||
|
||||
override fun playerLeds(pad: Int, bits: Int) {
|
||||
val m = model ?: return
|
||||
if (m == DsDevice.Model.DUALSHOCK4) return // no player LEDs on a DS4 (host never sends any)
|
||||
usb.writeRaw(0, DsDevice.ds5PlayerLedsReport(m, bits))
|
||||
}
|
||||
|
||||
override fun trigger(pad: Int, which: Int, effect: ByteArray) {
|
||||
val m = model ?: return
|
||||
if (m == DsDevice.Model.DUALSHOCK4) return // no adaptive triggers on a DS4
|
||||
usb.writeRaw(0, DsDevice.ds5TriggerReport(m, which, effect))
|
||||
}
|
||||
|
||||
// Coalescable: the DS4's write is full-state (motors AND lightbar, rebuilt from the current
|
||||
// fields on every call), so a newer one supersedes an older one wholesale — nothing is lost by
|
||||
// collapsing a backlog of them down to the last.
|
||||
private fun writeDs4() = usb.writeRaw(
|
||||
0,
|
||||
DsDevice.ds4Report(
|
||||
ds4Low,
|
||||
ds4High,
|
||||
(ds4Rgb shr 16) and 0xFF,
|
||||
(ds4Rgb shr 8) and 0xFF,
|
||||
ds4Rgb and 0xFF,
|
||||
),
|
||||
OutReportQueue.KEY_RUMBLE,
|
||||
)
|
||||
|
||||
/**
|
||||
* Hand the pad back neutral: adaptive triggers released, lightbar dark, player LEDs clear.
|
||||
*
|
||||
* Rumble stops the moment nothing renews it, but these are LATCHED in the controller's
|
||||
* firmware — they outlive the stream, the app, and being unplugged. Ending a session while a
|
||||
* game held a weapon's trigger resistance left the physical trigger stiff afterwards, with
|
||||
* nothing to release it but another game that happens to set one.
|
||||
*
|
||||
* EP0-direct like the rumble stop above: the reader thread is stopping, so the interrupt-OUT
|
||||
* queue would never drain. Writes are best-effort — the pad may already be gone.
|
||||
*/
|
||||
private fun resetRichFeedback(m: DsDevice.Model) {
|
||||
if (m == DsDevice.Model.DUALSHOCK4) {
|
||||
// No adaptive triggers or player LEDs on a DS4, and its write is full-state, so
|
||||
// blacking the lightbar is a single composed report.
|
||||
ds4Rgb = 0
|
||||
usb.writeControl(DsDevice.ds4Report(0, 0, 0, 0, 0))
|
||||
return
|
||||
}
|
||||
// An all-zero effect block is mode 0x00 — no effect — which is what releases the trigger.
|
||||
for (which in 0..1) {
|
||||
usb.writeControl(
|
||||
DsDevice.ds5TriggerReport(m, which, ByteArray(DsDevice.TRIGGER_EFFECT_LEN)),
|
||||
)
|
||||
}
|
||||
usb.writeControl(DsDevice.ds5LightbarReport(m, 0, 0, 0))
|
||||
usb.writeControl(DsDevice.ds5PlayerLedsReport(m, 0))
|
||||
}
|
||||
|
||||
/** The report that stops the motors. The DS4's is a full-state write, so it zeroes the
|
||||
* composed motor state and carries the current lightbar rather than blacking it out. */
|
||||
private fun stopReport(m: DsDevice.Model): ByteArray = if (m == DsDevice.Model.DUALSHOCK4) {
|
||||
ds4Low = 0
|
||||
ds4High = 0
|
||||
DsDevice.ds4Report(
|
||||
0,
|
||||
0,
|
||||
(ds4Rgb shr 16) and 0xFF,
|
||||
(ds4Rgb shr 8) and 0xFF,
|
||||
ds4Rgb and 0xFF,
|
||||
)
|
||||
} else {
|
||||
DsDevice.ds5RumbleReport(m, 0, 0)
|
||||
}
|
||||
|
||||
/** (Re)arm the stalled-poll-thread net: write a rumble stop at the command's backstop. */
|
||||
private fun armBackstop(ms: Long) {
|
||||
backstop?.let { mainHandler.removeCallbacks(it) }
|
||||
val r = Runnable {
|
||||
backstop = null
|
||||
val m = model ?: return@Runnable
|
||||
// The net itself can be refused (a full queue, a connection going away). Re-arm rather
|
||||
// than give up: this is the last thing between a stalled poll thread and a pad that
|
||||
// buzzes until it is unplugged. It stops re-arming as soon as the link closes, which
|
||||
// clears `model` and disarms.
|
||||
if (!usb.writeRaw(0, stopReport(m), OutReportQueue.KEY_RUMBLE)) armBackstop(STOP_RETRY_MS)
|
||||
}
|
||||
backstop = r
|
||||
mainHandler.postDelayed(r, ms.coerceAtLeast(1))
|
||||
}
|
||||
|
||||
private fun disarmBackstop() {
|
||||
backstop?.let { mainHandler.removeCallbacks(it) }
|
||||
backstop = null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "DsCapture"
|
||||
|
||||
/** How soon to retry a rumble stop whose write was rejected. Short: the motors are running
|
||||
* and the host has already moved on, so nothing else is coming to silence them. */
|
||||
const val STOP_RETRY_MS = 100L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
/**
|
||||
* Sony DualSense / DualSense Edge / DualShock 4 **USB** protocol constants: the input-report
|
||||
* parser and the output-report builders the capture link ([DsCapture]) needs. Unlike the SC2's
|
||||
* as-is passthrough, nothing rides the wire raw here — the host's DualSense/DS4 backends consume
|
||||
* only typed events (`dualsense_proto.rs` discards `RichInput::HidReport`), so the client parses
|
||||
* the pad's input reports into the ordinary button/axis wire + the rich touch/motion plane, and
|
||||
* renders the host's feedback (rumble / adaptive triggers / lightbar / player LEDs) by composing
|
||||
* USB output reports itself.
|
||||
*
|
||||
* Protocol ground truth: the Linux kernel's `hid-playstation` / `hid-sony` structs, SDL's
|
||||
* `SDL_hidapi_ps5.c` / `SDL_hidapi_ps4.c`, mirrored host-side in `punktfunk-host`'s
|
||||
* `dualsense_proto.rs` / `dualshock4_proto.rs` — this file is the byte-exact inverse of those
|
||||
* serializers (offsets cross-referenced below). USB only: over Bluetooth the reports shift
|
||||
* (`0x31` + CRC32) AND Android exposes no raw path to a Classic pad anyway, so the BT case never
|
||||
* reaches this code — an uncaptured pad stays on the ordinary InputDevice path.
|
||||
*/
|
||||
object DsDevice {
|
||||
const val VID_SONY = 0x054C
|
||||
const val PID_DUALSENSE = 0x0CE6
|
||||
const val PID_DUALSENSE_EDGE = 0x0DF2
|
||||
const val PID_DUALSHOCK4_V1 = 0x05C4
|
||||
const val PID_DUALSHOCK4_V2 = 0x09CC
|
||||
|
||||
val USB_PIDS = setOf(PID_DUALSENSE, PID_DUALSENSE_EDGE, PID_DUALSHOCK4_V1, PID_DUALSHOCK4_V2)
|
||||
|
||||
/**
|
||||
* One captured model: its `GamepadPref` wire byte (the virtual pad the host builds — matching
|
||||
* the physical one), its output-report size (the descriptor-declared size the firmware
|
||||
* expects: DS5 48 = id + 47, Edge 64 = id + 63, DS4 32 = id + 31), and its touchpad extent
|
||||
* (`dualsense_proto::DS_TOUCH_W/H`, `dualshock4_proto::DS4_TOUCH_*`) for normalizing touches
|
||||
* onto the wire's 0..65535 space.
|
||||
*/
|
||||
enum class Model(val pref: Int, val outputSize: Int, val touchW: Int, val touchH: Int) {
|
||||
DUALSENSE(Gamepad.PREF_DUALSENSE, 48, 1920, 1080),
|
||||
DUALSENSE_EDGE(Gamepad.PREF_DUALSENSEEDGE, 64, 1920, 1080),
|
||||
DUALSHOCK4(Gamepad.PREF_DUALSHOCK4, 32, 1920, 942),
|
||||
}
|
||||
|
||||
/** The captured [Model] for a USB PID, or null for anything we don't capture. */
|
||||
fun modelFor(pid: Int): Model? = when (pid) {
|
||||
PID_DUALSENSE -> Model.DUALSENSE
|
||||
PID_DUALSENSE_EDGE -> Model.DUALSENSE_EDGE
|
||||
PID_DUALSHOCK4_V1, PID_DUALSHOCK4_V2 -> Model.DUALSHOCK4
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* The client-consumed fields of one input report. `buttons` is already the WIRE bitmask
|
||||
* (`Gamepad.BTN_*`) — the parse maps device bits straight to the wire, the exact inverse of
|
||||
* the host's `DsState::from_gamepad` (BTN_A ↔ cross, BTN_B ↔ circle, BTN_X ↔ square,
|
||||
* BTN_Y ↔ triangle; positional, not glyph-order). Gyro/accel stay in raw device units — the
|
||||
* wire's `Motion` is a unit passthrough into the virtual pad's report. Touch coordinates stay
|
||||
* device-raw here; [DsCapture] normalizes against the model's extent when forwarding.
|
||||
*/
|
||||
class State {
|
||||
var buttons = 0
|
||||
var lsX = 0; var lsY = 0 // wire i16, +y = up (device is +y down — inverted in the parse)
|
||||
var rsX = 0; var rsY = 0
|
||||
var lt = 0; var rt = 0 // 0..255
|
||||
val gyro = IntArray(3) // raw i16 units (pitch/yaw/roll)
|
||||
val accel = IntArray(3)
|
||||
val touchActive = BooleanArray(2)
|
||||
val touchX = IntArray(2) // raw device coords (0..touchW-1 / 0..touchH-1)
|
||||
val touchY = IntArray(2)
|
||||
}
|
||||
|
||||
// DS5 USB input report 0x01 (64 B) — offsets mirror the host serializer
|
||||
// (`dualsense_proto.rs::serialize_state`): [1..7) sticks + triggers, [8] hat|face,
|
||||
// [9]/[10] buttons, [16..28) gyro+accel, [33..41) two 4-byte touch points.
|
||||
private const val DS5_INPUT_ID = 0x01
|
||||
// report[8] high nibble (`dualsense_proto::btn0`).
|
||||
private const val DS5_SQUARE = 0x10
|
||||
private const val DS5_CROSS = 0x20
|
||||
private const val DS5_CIRCLE = 0x40
|
||||
private const val DS5_TRIANGLE = 0x80
|
||||
// report[9] (`btn1`).
|
||||
private const val DS5_L1 = 0x01
|
||||
private const val DS5_R1 = 0x02
|
||||
private const val DS5_CREATE = 0x10
|
||||
private const val DS5_OPTIONS = 0x20
|
||||
private const val DS5_L3 = 0x40
|
||||
private const val DS5_R3 = 0x80
|
||||
// report[10] (`btn2`); the FN/BACK bits exist only on the Edge.
|
||||
private const val DS5_PS = 0x01
|
||||
private const val DS5_TOUCHPAD = 0x02
|
||||
private const val DS5_MUTE = 0x04
|
||||
private const val EDGE_FN_LEFT = 0x10
|
||||
private const val EDGE_FN_RIGHT = 0x20
|
||||
private const val EDGE_BACK_LEFT = 0x40
|
||||
private const val EDGE_BACK_RIGHT = 0x80
|
||||
|
||||
// DS4 USB input report 0x01 (64 B) — offsets mirror `dualshock4_proto.rs::serialize_state`:
|
||||
// [1..5) sticks, [5] hat|face, [6]/[7] buttons, [8]/[9] triggers, [13..25) gyro+accel,
|
||||
// [35..43) two touch points (same 4-byte packing as the DS5).
|
||||
private const val DS4_L1 = 0x01
|
||||
private const val DS4_R1 = 0x02
|
||||
private const val DS4_SHARE = 0x10
|
||||
private const val DS4_OPTIONS = 0x20
|
||||
private const val DS4_L3 = 0x40
|
||||
private const val DS4_R3 = 0x80
|
||||
private const val DS4_PS = 0x01
|
||||
private const val DS4_TOUCHPAD = 0x02
|
||||
|
||||
/**
|
||||
* Parse one USB input report (`0x01`) into [out]. Returns false for any other report id or a
|
||||
* short read (the pad also emits `0x09`-family getMAC responses etc. on EP0 — those never hit
|
||||
* the interrupt endpoint, but be defensive). Motion/touch fields update only when the report
|
||||
* is long enough to carry them (it always is on glass — 64-byte interrupt transfers).
|
||||
*/
|
||||
fun parseState(model: Model, report: ByteArray, len: Int, out: State): Boolean =
|
||||
if (model == Model.DUALSHOCK4) {
|
||||
parseDs4(report, len, out)
|
||||
} else {
|
||||
parseDs5(model, report, len, out)
|
||||
}
|
||||
|
||||
private fun parseDs5(model: Model, r: ByteArray, len: Int, out: State): Boolean {
|
||||
if (len < 11 || (r[0].toInt() and 0xFF) != DS5_INPUT_ID) return false
|
||||
out.lsX = stickX(u8(r, 1))
|
||||
out.lsY = stickY(u8(r, 2))
|
||||
out.rsX = stickX(u8(r, 3))
|
||||
out.rsY = stickY(u8(r, 4))
|
||||
out.lt = u8(r, 5)
|
||||
out.rt = u8(r, 6)
|
||||
val b8 = u8(r, 8)
|
||||
val b9 = u8(r, 9)
|
||||
val b10 = u8(r, 10)
|
||||
var w = hatBits(b8 and 0x0F)
|
||||
if (b8 and DS5_CROSS != 0) w = w or Gamepad.BTN_A
|
||||
if (b8 and DS5_CIRCLE != 0) w = w or Gamepad.BTN_B
|
||||
if (b8 and DS5_SQUARE != 0) w = w or Gamepad.BTN_X
|
||||
if (b8 and DS5_TRIANGLE != 0) w = w or Gamepad.BTN_Y
|
||||
if (b9 and DS5_L1 != 0) w = w or Gamepad.BTN_LB
|
||||
if (b9 and DS5_R1 != 0) w = w or Gamepad.BTN_RB
|
||||
// L2/R2 digital bits ride the analog axes instead (wire convention).
|
||||
if (b9 and DS5_CREATE != 0) w = w or Gamepad.BTN_BACK
|
||||
if (b9 and DS5_OPTIONS != 0) w = w or Gamepad.BTN_START
|
||||
if (b9 and DS5_L3 != 0) w = w or Gamepad.BTN_LS_CLICK
|
||||
if (b9 and DS5_R3 != 0) w = w or Gamepad.BTN_RS_CLICK
|
||||
if (b10 and DS5_PS != 0) w = w or Gamepad.BTN_GUIDE
|
||||
if (b10 and DS5_TOUCHPAD != 0) w = w or Gamepad.BTN_TOUCHPAD
|
||||
if (b10 and DS5_MUTE != 0) w = w or Gamepad.BTN_MISC1
|
||||
if (model == Model.DUALSENSE_EDGE) {
|
||||
// Wire paddle order matches the host's `edge_paddle_bits` inverse: PADDLE1/2 =
|
||||
// right/left BACK (the primary pair, Steam R4/L4 convention), PADDLE3/4 = right/left Fn.
|
||||
if (b10 and EDGE_BACK_RIGHT != 0) w = w or Gamepad.BTN_PADDLE1
|
||||
if (b10 and EDGE_BACK_LEFT != 0) w = w or Gamepad.BTN_PADDLE2
|
||||
if (b10 and EDGE_FN_RIGHT != 0) w = w or Gamepad.BTN_PADDLE3
|
||||
if (b10 and EDGE_FN_LEFT != 0) w = w or Gamepad.BTN_PADDLE4
|
||||
}
|
||||
out.buttons = w
|
||||
if (len >= 28) {
|
||||
for (i in 0 until 3) out.gyro[i] = i16(r, 16 + 2 * i)
|
||||
for (i in 0 until 3) out.accel[i] = i16(r, 22 + 2 * i)
|
||||
}
|
||||
if (len >= 41) {
|
||||
unpackTouch(r, 33, out, 0)
|
||||
unpackTouch(r, 37, out, 1)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun parseDs4(r: ByteArray, len: Int, out: State): Boolean {
|
||||
if (len < 10 || (r[0].toInt() and 0xFF) != DS5_INPUT_ID) return false // DS4 shares id 0x01
|
||||
out.lsX = stickX(u8(r, 1))
|
||||
out.lsY = stickY(u8(r, 2))
|
||||
out.rsX = stickX(u8(r, 3))
|
||||
out.rsY = stickY(u8(r, 4))
|
||||
val b5 = u8(r, 5)
|
||||
val b6 = u8(r, 6)
|
||||
val b7 = u8(r, 7)
|
||||
out.lt = u8(r, 8)
|
||||
out.rt = u8(r, 9)
|
||||
var w = hatBits(b5 and 0x0F)
|
||||
if (b5 and DS5_CROSS != 0) w = w or Gamepad.BTN_A
|
||||
if (b5 and DS5_CIRCLE != 0) w = w or Gamepad.BTN_B
|
||||
if (b5 and DS5_SQUARE != 0) w = w or Gamepad.BTN_X
|
||||
if (b5 and DS5_TRIANGLE != 0) w = w or Gamepad.BTN_Y
|
||||
if (b6 and DS4_L1 != 0) w = w or Gamepad.BTN_LB
|
||||
if (b6 and DS4_R1 != 0) w = w or Gamepad.BTN_RB
|
||||
if (b6 and DS4_SHARE != 0) w = w or Gamepad.BTN_BACK
|
||||
if (b6 and DS4_OPTIONS != 0) w = w or Gamepad.BTN_START
|
||||
if (b6 and DS4_L3 != 0) w = w or Gamepad.BTN_LS_CLICK
|
||||
if (b6 and DS4_R3 != 0) w = w or Gamepad.BTN_RS_CLICK
|
||||
if (b7 and DS4_PS != 0) w = w or Gamepad.BTN_GUIDE
|
||||
if (b7 and DS4_TOUCHPAD != 0) w = w or Gamepad.BTN_TOUCHPAD
|
||||
out.buttons = w
|
||||
if (len >= 25) {
|
||||
for (i in 0 until 3) out.gyro[i] = i16(r, 13 + 2 * i)
|
||||
for (i in 0 until 3) out.accel[i] = i16(r, 19 + 2 * i)
|
||||
}
|
||||
if (len >= 43) {
|
||||
unpackTouch(r, 35, out, 0)
|
||||
unpackTouch(r, 39, out, 1)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** hat nibble (0=N … 7=NW, 8+=neutral) → wire dpad bits — inverse of the host's `hat()`. */
|
||||
private fun hatBits(h: Int): Int = when (h) {
|
||||
0 -> Gamepad.BTN_DPAD_UP
|
||||
1 -> Gamepad.BTN_DPAD_UP or Gamepad.BTN_DPAD_RIGHT
|
||||
2 -> Gamepad.BTN_DPAD_RIGHT
|
||||
3 -> Gamepad.BTN_DPAD_DOWN or Gamepad.BTN_DPAD_RIGHT
|
||||
4 -> Gamepad.BTN_DPAD_DOWN
|
||||
5 -> Gamepad.BTN_DPAD_DOWN or Gamepad.BTN_DPAD_LEFT
|
||||
6 -> Gamepad.BTN_DPAD_LEFT
|
||||
7 -> Gamepad.BTN_DPAD_UP or Gamepad.BTN_DPAD_LEFT
|
||||
else -> 0
|
||||
}
|
||||
|
||||
/**
|
||||
* One 4-byte touch point (shared DS5/DS4 packing — `dualsense_proto::pack_touch`): byte0
|
||||
* bit7 = NOT active + contact id in bits 0..6; 12-bit x/y split across bytes 1..3.
|
||||
*/
|
||||
private fun unpackTouch(r: ByteArray, o: Int, out: State, slot: Int) {
|
||||
val b0 = u8(r, o)
|
||||
out.touchActive[slot] = b0 and 0x80 == 0
|
||||
out.touchX[slot] = u8(r, o + 1) or ((u8(r, o + 2) and 0x0F) shl 8)
|
||||
out.touchY[slot] = (u8(r, o + 2) shr 4) or (u8(r, o + 3) shl 4)
|
||||
}
|
||||
|
||||
private fun u8(r: ByteArray, o: Int): Int = r[o].toInt() and 0xFF
|
||||
|
||||
private fun i16(r: ByteArray, o: Int): Int =
|
||||
((r[o + 1].toInt() shl 8) or (r[o].toInt() and 0xFF)).toShort().toInt()
|
||||
|
||||
// Device stick byte (0..255, centre 0x80, +y down) → wire i16 (+y up) — the exact inverse of
|
||||
// the host's `to_u8` mapping (`lx = to_u8(x)`, `ly = 255 - to_u8(y)`).
|
||||
private fun stickX(raw: Int): Int = raw * 257 - 32768
|
||||
|
||||
private fun stickY(raw: Int): Int = (255 - raw) * 257 - 32768
|
||||
|
||||
// ---- Output reports ----
|
||||
//
|
||||
// Every write is valid-flag-selective: only the flagged channel applies, the firmware keeps
|
||||
// the rest (the same contract the host's `parse_ds_output` mirrors — an unflagged parse would
|
||||
// turn every rumble into a lightbar-off). The DS4 is the exception: its builder writes the
|
||||
// full composed motors+LED state each time with both flags, SDL's proven-on-hardware shape.
|
||||
|
||||
// DS5 output report 0x02, report-relative offsets (`dualsense_proto::parse_ds_output`):
|
||||
// [1] valid_flag0 (bit0 compat vibration, bit1 haptics select, bit2 R2 block, bit3 L2 block),
|
||||
// [2] valid_flag1 (bit2 lightbar, bit4 player LEDs), [3]/[4] motors, [11..22) R2 effect,
|
||||
// [22..33) L2 effect, [39] valid_flag2 (bit1 lightbar-setup enable, bit2 vibration2),
|
||||
// [42] lightbar_setup, [44] player LEDs, [45..48) RGB.
|
||||
private const val DS5_FLAG0_COMPAT_VIBRATION = 0x01
|
||||
private const val DS5_FLAG0_HAPTICS_SELECT = 0x02
|
||||
private const val DS5_FLAG0_R2_EFFECT = 0x04
|
||||
private const val DS5_FLAG0_L2_EFFECT = 0x08
|
||||
private const val DS5_FLAG1_LIGHTBAR = 0x04
|
||||
private const val DS5_FLAG1_PLAYER_LEDS = 0x10
|
||||
private const val DS5_FLAG2_LIGHTBAR_SETUP = 0x02
|
||||
private const val DS5_FLAG2_VIBRATION2 = 0x04
|
||||
private const val DS5_LIGHTBAR_SETUP_LIGHT_OUT = 0x02
|
||||
|
||||
/** The 11-byte adaptive-trigger effect block length (mode byte + 10 parameters). */
|
||||
const val TRIGGER_EFFECT_LEN = 11
|
||||
|
||||
private fun newDs5(model: Model): ByteArray = ByteArray(model.outputSize).also { it[0] = 0x02 }
|
||||
|
||||
/**
|
||||
* One-time capture-start report (DS5/Edge): release the firmware's lightbar animation
|
||||
* (`LIGHTBAR_SETUP_LIGHT_OUT`) so subsequent host lightbar writes take effect — the same
|
||||
* init both hid-playstation and SDL send on open. No-op fields otherwise.
|
||||
*/
|
||||
fun ds5InitReport(model: Model): ByteArray = newDs5(model).also {
|
||||
it[39] = DS5_FLAG2_LIGHTBAR_SETUP.toByte()
|
||||
it[42] = DS5_LIGHTBAR_SETUP_LIGHT_OUT.toByte()
|
||||
}
|
||||
|
||||
/**
|
||||
* DS5/Edge rumble at the wire's u16 amplitudes ([low] = heavy/left motor, [high] =
|
||||
* light/right — the host parses `[3]` as high and `[4]` as low, mirrored here). Flags both
|
||||
* the classic compat-vibration path AND `VIBRATION2` (firmware ≥ 2.24's full-range replot;
|
||||
* older firmware ignores the unknown flag2 bit) — the host parser accepts either.
|
||||
*/
|
||||
/**
|
||||
* B6: hand the voice coils back to the audio-haptics path.
|
||||
*
|
||||
* Every [ds5RumbleReport] asserts `HAPTICS_SELECT` (flag0 bit1), which is SDL's
|
||||
* "disable audio haptics" bit — the firmware mutes the coils the 0xD1 haptics stream drives.
|
||||
* Until now NOTHING ever cleared it again, so a single rumble anywhere in a session left tier-A
|
||||
* haptics silent for the rest of that pad's life, with no error and nothing in a log.
|
||||
*
|
||||
* The undo is a report whose flag0 has BOTH bits clear (SDL's own comment: "Leaving emulated
|
||||
* rumble bits off will restore audio haptics"). No other valid flag is set, so nothing else
|
||||
* about the pad's state is touched. Mirrors `Ds5Feedback::audio_haptics_packet` on the desktop
|
||||
* client, which is the same packet one transport over.
|
||||
*/
|
||||
fun ds5AudioHapticsReport(model: Model): ByteArray = newDs5(model)
|
||||
|
||||
fun ds5RumbleReport(model: Model, low: Int, high: Int): ByteArray = newDs5(model).also {
|
||||
it[1] = (DS5_FLAG0_COMPAT_VIBRATION or DS5_FLAG0_HAPTICS_SELECT).toByte()
|
||||
it[39] = DS5_FLAG2_VIBRATION2.toByte()
|
||||
it[3] = wireAmplitudeToByte(high).toByte()
|
||||
it[4] = wireAmplitudeToByte(low).toByte()
|
||||
}
|
||||
|
||||
/**
|
||||
* DS5/Edge adaptive-trigger effect: [which] 0 = L2, 1 = R2; [effect] is the raw 11-byte
|
||||
* trigger block from the wire (`HidOutput::Trigger` — the game's bytes verbatim), copied to
|
||||
* the same offsets the host parsed it from ([11..22) R2 / [22..33) L2).
|
||||
*/
|
||||
fun ds5TriggerReport(model: Model, which: Int, effect: ByteArray): ByteArray = newDs5(model).also {
|
||||
val at = if (which == 1) 11 else 22
|
||||
it[1] = (if (which == 1) DS5_FLAG0_R2_EFFECT else DS5_FLAG0_L2_EFFECT).toByte()
|
||||
val n = effect.size.coerceAtMost(TRIGGER_EFFECT_LEN)
|
||||
System.arraycopy(effect, 0, it, at, n)
|
||||
}
|
||||
|
||||
/** DS5/Edge lightbar RGB. */
|
||||
fun ds5LightbarReport(model: Model, r: Int, g: Int, b: Int): ByteArray = newDs5(model).also {
|
||||
it[2] = DS5_FLAG1_LIGHTBAR.toByte()
|
||||
it[45] = r.toByte()
|
||||
it[46] = g.toByte()
|
||||
it[47] = b.toByte()
|
||||
}
|
||||
|
||||
/** DS5/Edge player-indicator LEDs (low 5 bits, hid-playstation pattern). */
|
||||
fun ds5PlayerLedsReport(model: Model, bits: Int): ByteArray = newDs5(model).also {
|
||||
it[2] = DS5_FLAG1_PLAYER_LEDS.toByte()
|
||||
it[44] = (bits and 0x1F).toByte()
|
||||
}
|
||||
|
||||
// DS4 output report 0x05 (32 B), report-relative (`dualshock4_proto::parse_ds4_output`):
|
||||
// [1] valid_flag0 (bit0 motors, bit1 LED, bit2 blink), [4] weak/right motor, [5] strong/left,
|
||||
// [6..9) RGB, [9]/[10] blink on/off.
|
||||
private const val DS4_FLAG0_MOTORS = 0x01
|
||||
private const val DS4_FLAG0_LED = 0x02
|
||||
|
||||
/**
|
||||
* One full-state DS4 write: motors + lightbar together, both flags set — the composed-state
|
||||
* shape SDL uses against real hardware (per-channel selective writes are unproven on DS4
|
||||
* firmware, unlike the DS5's). [DsCapture] holds the composition. Blink stays untouched.
|
||||
*/
|
||||
fun ds4Report(low: Int, high: Int, r: Int, g: Int, b: Int): ByteArray =
|
||||
ByteArray(Model.DUALSHOCK4.outputSize).also {
|
||||
it[0] = 0x05
|
||||
it[1] = (DS4_FLAG0_MOTORS or DS4_FLAG0_LED).toByte()
|
||||
it[4] = wireAmplitudeToByte(high).toByte()
|
||||
it[5] = wireAmplitudeToByte(low).toByte()
|
||||
it[6] = r.toByte()
|
||||
it[7] = g.toByte()
|
||||
it[8] = b.toByte()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -46,12 +46,51 @@ class GamepadFeedback(
|
||||
private val router: GamepadRouter?,
|
||||
private val deviceVibrator: Vibrator? = null,
|
||||
) {
|
||||
/**
|
||||
* A capture link's feedback renderer for the wire pads it owns, consulted BEFORE the
|
||||
* InputDevice vibrator/lights paths. A captured controller has no [android.view.InputDevice]
|
||||
* (its slot is an [GamepadRouter.ExternalPad] on a synthetic id, so [GamepadRouter.deviceForPad]
|
||||
* resolves null and the platform paths no-op) — the link renders instead, by composing USB
|
||||
* output reports on the physical pad. This is also the ONLY route to adaptive triggers:
|
||||
* Android has no platform API for them, so without a sink a Trigger event is log-and-drop.
|
||||
* Invoked on the feedback poll threads; implementations must be thread-safe.
|
||||
*/
|
||||
interface PadFeedbackSink {
|
||||
/** True when this sink renders feedback for wire pad [pad]; the render methods are only
|
||||
* invoked while true. Racing a pad close is fine — a late render is a harmless no-op. */
|
||||
fun ownsPad(pad: Int): Boolean
|
||||
|
||||
/** One effective rumble command (`(0,0)` = stop now; else a one-shot at this level with
|
||||
* [backstopMs] as the self-termination net — see [GamepadFeedback.renderRumble]). */
|
||||
fun rumble(pad: Int, low: Int, high: Int, backstopMs: Long)
|
||||
|
||||
/** Lightbar RGB. */
|
||||
fun led(pad: Int, r: Int, g: Int, b: Int)
|
||||
|
||||
/** Player-indicator LED bitmask (low 5 bits, hid-playstation layout). */
|
||||
fun playerLeds(pad: Int, bits: Int)
|
||||
|
||||
/** One adaptive-trigger effect: [which] 0 = L2, 1 = R2; [effect] = the raw DS5 trigger
|
||||
* block (mode byte + parameters) exactly as the game wrote it host-side. */
|
||||
fun trigger(pad: Int, which: Int, effect: ByteArray)
|
||||
}
|
||||
|
||||
/**
|
||||
* The active capture link's sink (a [DsCapture]), or null. Wired by StreamScreen alongside
|
||||
* [onHidRaw]; cleared before the poll threads stop.
|
||||
*/
|
||||
@Volatile
|
||||
var sink: PadFeedbackSink? = 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
|
||||
|
||||
/** Sparse-log cadence for swallowed render failures — see [noteRenderFailure]. */
|
||||
const val LOG_EVERY = 128L
|
||||
}
|
||||
|
||||
/** One controller's rumble binding — VibratorManager (API 31+) OR the legacy single Vibrator (API 28–30). */
|
||||
@@ -89,37 +128,51 @@ class GamepadFeedback(
|
||||
fun start() {
|
||||
running = true
|
||||
rumbleThread = Thread({
|
||||
var failures = 0L
|
||||
while (running) {
|
||||
val ev = NativeBridge.nativeNextRumble(handle)
|
||||
if (ev < 0L) continue // timeout / closed
|
||||
// ev bits 49..52 = wire pad index; bits 32..47 = backstop duration (ms);
|
||||
// 16..31 = low; 0..15 = high. These are EFFECTIVE commands from the core's shared
|
||||
// rumble policy engine — it owns every lease/staleness/close decision (uniform
|
||||
// across all clients; the old 60 s legacy-host exposure is gone) and emits
|
||||
// explicit zeros, so apply verbatim: (0, 0) = cancel, non-zero = one-shot for
|
||||
// the backstop (the hardware net under a stalled poll thread).
|
||||
val pad = ((ev ushr 49) and 0xFL).toInt()
|
||||
val backstopMs = ((ev ushr 32) and 0xFFFF)
|
||||
renderRumble(
|
||||
pad,
|
||||
((ev ushr 16) and 0xFFFF).toInt(),
|
||||
(ev and 0xFFFF).toInt(),
|
||||
backstopMs,
|
||||
)
|
||||
// Layout + semantics live in `unpackRumbleEvent` (RumbleWire.kt), tested there
|
||||
// against the Rust packer.
|
||||
val cmd = unpackRumbleEvent(ev) ?: continue // timeout / closed
|
||||
// Rendering is binder calls into the vibrator service, and every one of them can
|
||||
// throw unchecked — DeadSystemRuntimeException when system_server goes down, and
|
||||
// the ordinary RuntimeException a dying service wraps its RemoteException in.
|
||||
// Unguarded, ONE of those killed this thread outright: `running` stayed true, so
|
||||
// nothing noticed and nothing restarted it, and rumble was gone for the rest of
|
||||
// the session. Losing a single command is recoverable; losing the loop is not.
|
||||
runCatching {
|
||||
renderRumble(cmd.pad, cmd.low, cmd.high, cmd.backstopMs)
|
||||
}.onFailure { failures = noteRenderFailure("rumble", it, failures) }
|
||||
}
|
||||
}, "pf-rumble").apply { isDaemon = true; start() }
|
||||
|
||||
hidoutThread = Thread({
|
||||
// 128: the raw as-is passthrough events are [pad][kind tag][report kind][≤64 bytes].
|
||||
val buf = ByteBuffer.allocateDirect(128)
|
||||
var failures = 0L
|
||||
while (running) {
|
||||
val n = NativeBridge.nativeNextHidout(handle, buf)
|
||||
if (n < 0) continue // timeout / closed
|
||||
dispatchHidout(buf, n)
|
||||
// Same hazard as the rumble loop above: lights/trigger rendering is binder and USB
|
||||
// calls, and an unchecked throw here would silently end the rich-feedback plane.
|
||||
runCatching { dispatchHidout(buf, n) }
|
||||
.onFailure { failures = noteRenderFailure("hidout", it, failures) }
|
||||
}
|
||||
}, "pf-hidout").apply { isDaemon = true; start() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a render failure the poll loop swallowed, and return the updated count. Logged on the
|
||||
* first occurrence and sparsely after: a genuinely dead vibrator service fails on *every*
|
||||
* command, which at a rumble plane's rate would bury the log.
|
||||
*/
|
||||
private fun noteRenderFailure(plane: String, t: Throwable, seen: Long): Long {
|
||||
if (seen == 0L || seen % LOG_EVERY == 0L) {
|
||||
Log.w(TAG, "$plane render failed (#${seen + 1}) — command dropped, poll loop alive", t)
|
||||
}
|
||||
return seen + 1
|
||||
}
|
||||
|
||||
/** Idempotent. Stops + joins the poll threads (must complete before the router is released / handle freed). */
|
||||
fun stop() {
|
||||
running = false
|
||||
@@ -221,13 +274,19 @@ class GamepadFeedback(
|
||||
// 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)
|
||||
// A captured pad's link renders on the physical controller itself (its slot has no
|
||||
// InputDevice, so the vibrator bind below would resolve null and drop the command).
|
||||
sink?.takeIf { it.ownsPad(pad) }?.let {
|
||||
it.rumble(pad, low, high, durationMs)
|
||||
return
|
||||
}
|
||||
val bind = rumbleBindFor(pad) ?: return
|
||||
val lo = toAmplitude(low)
|
||||
val hi = toAmplitude(high)
|
||||
val lo = wireAmplitudeToByte(low)
|
||||
val hi = wireAmplitudeToByte(high)
|
||||
val m = bind.vm
|
||||
if (m != null) {
|
||||
if (lo == 0 && hi == 0) {
|
||||
m.cancel() // (0,0) = stop
|
||||
runCatching { m.cancel() } // (0,0) = stop
|
||||
return
|
||||
}
|
||||
val combo = CombinedVibration.startParallel()
|
||||
@@ -252,7 +311,7 @@ class GamepadFeedback(
|
||||
// API 28–30 legacy single-motor path: blend both motors into one effect.
|
||||
val lv = bind.legacy ?: return
|
||||
if (lo == 0 && hi == 0) {
|
||||
lv.cancel() // (0,0) = stop
|
||||
runCatching { lv.cancel() } // (0,0) = stop
|
||||
return
|
||||
}
|
||||
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
||||
@@ -272,8 +331,8 @@ class GamepadFeedback(
|
||||
*/
|
||||
private fun renderDeviceRumble(low: Int, high: Int, durationMs: Long) {
|
||||
val v = deviceVibrator ?: return
|
||||
val lo = toAmplitude(low)
|
||||
val hi = toAmplitude(high)
|
||||
val lo = wireAmplitudeToByte(low)
|
||||
val hi = wireAmplitudeToByte(high)
|
||||
if (lo == 0 && hi == 0) {
|
||||
runCatching { v.cancel() } // (0,0) = stop
|
||||
return
|
||||
@@ -287,12 +346,6 @@ class GamepadFeedback(
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
return if (v16 != 0 && a == 0) 1 else a
|
||||
}
|
||||
|
||||
// One-shot held for `durationMs` — the host's v2 TTL (renewed while the level holds), so it
|
||||
// self-terminates on a lost stop; cancel on zero. Floor the duration at 1 ms: `createOneShot`
|
||||
// throws IllegalArgumentException on a non-positive duration, and a lease can carry ttl_ms==0
|
||||
@@ -313,23 +366,36 @@ class GamepadFeedback(
|
||||
val g = buf.get().toInt() and 0xFF
|
||||
val b = buf.get().toInt() and 0xFF
|
||||
Log.i(TAG, "hidout pad=$pad Led r=$r g=$g b=$b") // verification line
|
||||
if (Build.VERSION.SDK_INT >= 33) setLightbar(pad, Color.rgb(r, g, b))
|
||||
val s = sink?.takeIf { it.ownsPad(pad) }
|
||||
if (s != null) s.led(pad, r, g, b)
|
||||
else if (Build.VERSION.SDK_INT >= 33) setLightbar(pad, Color.rgb(r, g, b))
|
||||
}
|
||||
TAG_PLAYER_LEDS -> {
|
||||
val bits = buf.get().toInt() and 0x1F
|
||||
val player = playerIndexForBits(bits)
|
||||
Log.i(TAG, "hidout pad=$pad PlayerLeds bits=$bits player=$player") // verification line
|
||||
if (Build.VERSION.SDK_INT >= 33) setPlayerId(pad, player)
|
||||
val s = sink?.takeIf { it.ownsPad(pad) }
|
||||
if (s != null) s.playerLeds(pad, bits)
|
||||
else if (Build.VERSION.SDK_INT >= 33) setPlayerId(pad, player)
|
||||
}
|
||||
TAG_TRIGGER -> {
|
||||
val which = buf.get().toInt() and 0xFF // 0 = L2, 1 = R2
|
||||
val effLen = n - 3 // [pad][kind][which] header, then the effect block
|
||||
val mode = if (effLen > 0) buf.get().toInt() and 0xFF else 0
|
||||
// No public adaptive-trigger API on Android — parse-validate the mode + log only.
|
||||
Log.i(
|
||||
TAG,
|
||||
"hidout pad=$pad Trigger which=$which effLen=$effLen mode=0x%02x (adaptive triggers unsupported on Android)".format(mode),
|
||||
)
|
||||
val s = sink?.takeIf { it.ownsPad(pad) }
|
||||
if (s != null && effLen > 0) {
|
||||
// A captured DualSense: the raw trigger block replays onto the physical pad.
|
||||
val effect = ByteArray(effLen)
|
||||
buf.get(effect)
|
||||
Log.i(TAG, "hidout pad=$pad Trigger which=$which effLen=$effLen → captured pad") // verification line
|
||||
s.trigger(pad, which, effect)
|
||||
} else {
|
||||
val mode = if (effLen > 0) buf.get().toInt() and 0xFF else 0
|
||||
// No platform adaptive-trigger API — parse-validate the mode + log only.
|
||||
Log.i(
|
||||
TAG,
|
||||
"hidout pad=$pad Trigger which=$which effLen=$effLen mode=0x%02x (no adaptive-trigger renderer for this pad)".format(mode),
|
||||
)
|
||||
}
|
||||
}
|
||||
TAG_HID_RAW -> {
|
||||
// As-is SC2 passthrough: a raw report the host's Steam wrote to the virtual pad —
|
||||
|
||||
@@ -33,12 +33,52 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
* InputManager hot-plug callbacks both land there). [deviceForPad] is read from the feedback poll
|
||||
* threads, so the slot table is a [ConcurrentHashMap].
|
||||
*/
|
||||
class GamepadRouter(context: Context, private val handle: Long, private val setting: Int) {
|
||||
class GamepadRouter(
|
||||
context: Context,
|
||||
private val handle: Long,
|
||||
private val setting: Int,
|
||||
/**
|
||||
* Forward this device's controllers to the host at all (`Settings.gamepadForwarding`,
|
||||
* default true). Off is for a couch whose controller reaches the host another way — USB
|
||||
* passthrough such as VirtualHere, or a pad plugged into the host itself — where forwarding
|
||||
* as well would give the host two pads for one pair of hands.
|
||||
*
|
||||
* Off still opens slots and tracks held state; it only stops the wire sends. That is
|
||||
* deliberate: the exit and mic chords are read off the same slots, and a couch that lost its
|
||||
* quit shortcut because a forwarding preference was off would be the worse bug. Nothing is
|
||||
* claimed by keeping a slot — the Android input stack shares controllers — unlike the USB
|
||||
* capture links, which `StreamScreen` does not start at all while this is off.
|
||||
*/
|
||||
private val forwarding: Boolean = true,
|
||||
/**
|
||||
* Forward raw guide/QAM presses (`Settings.systemButtons` resolved — auto = forward on
|
||||
* Android, where the press reaches the app on most devices; `local` exists for
|
||||
* cross-client profile parity with the Gaming-Mode clients). Off keeps them entirely
|
||||
* with this device.
|
||||
*/
|
||||
private val systemForward: Boolean = true,
|
||||
/**
|
||||
* The hold-Select guide gesture (`Settings.guideGesture` resolved — auto = off on
|
||||
* Android): holding Select ALONE ≥ [GUIDE_HOLD_MS] sends the HOST's guide button, down
|
||||
* until release — so a long hold is the host's long-press, a Gaming-Mode host's QAM. A
|
||||
* Select tap is delivered on release (delayed by up to the threshold); a Select pressed
|
||||
* while other buttons are down passes through untouched, so the exit/mic chords keep
|
||||
* working. pf-client-core's `SelectGesture`, on the main-thread handler.
|
||||
*/
|
||||
private val guideGesture: Boolean = false,
|
||||
) {
|
||||
|
||||
/** One forwarded controller: its stable wire pad index, per-device axis state, and held buttons. */
|
||||
private class Slot(val index: Int, val mapper: Gamepad.AxisMapper) {
|
||||
/** Forwarded button bits currently held (Gamepad.BTN_*) — for release-on-close + chord detection. */
|
||||
var held = 0
|
||||
|
||||
// Hold-Select→guide gesture state ([guideGesture]): the pending Select's hold
|
||||
// timer / a delivered tap's owed release (both on the main handler), and whether
|
||||
// the held Select was transformed into a synthetic guide.
|
||||
var pendingGuide: Runnable? = null
|
||||
var pendingTapUp: Runnable? = null
|
||||
var selectAsGuide = false
|
||||
}
|
||||
|
||||
/** deviceId → slot. Concurrent: the feedback poll threads read it via [deviceForPad]. */
|
||||
@@ -65,6 +105,16 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
*/
|
||||
var onExitArmed: ((armed: Boolean) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Invoked (main thread) each time the mic-mute chord ([MIC_CHORD], Select + Y) is COMPLETED on
|
||||
* a pad — the couch equivalent of the stream's on-screen mute button, which a gamepad user
|
||||
* cannot reach. `StreamScreen` wires it to the mute toggle. Unlike the exit chord this fires
|
||||
* immediately: muting is the kind of thing you want to have already happened, and the on-screen
|
||||
* indicator makes an accidental toggle self-evident. The buttons still go to the host — the
|
||||
* chord adds a meaning to them rather than swallowing them, exactly as the exit chord does.
|
||||
*/
|
||||
var onMicChord: (() -> 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
|
||||
@@ -108,16 +158,47 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* transitions: forward the wire event, track held state, arm/disarm the exit chord, and fire
|
||||
* the mic-mute chord ([MIC_CHORD]).
|
||||
*/
|
||||
private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
|
||||
// Raw system buttons stay local under the "local" policy — no wire send and no held
|
||||
// tracking, symmetric on both edges so nothing leaks into the chords either.
|
||||
if (!systemForward && (bit == Gamepad.BTN_GUIDE || bit == Gamepad.BTN_MISC1)) return
|
||||
if (down) {
|
||||
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
|
||||
if (guideGesture && send) {
|
||||
// A Select pressed ALONE is held back until it resolves: a tap (delivered
|
||||
// on release), a combo member (the next button flushes it as a real
|
||||
// press), or — past GUIDE_HOLD_MS — a synthetic guide. Held state records
|
||||
// it either way, so the exit/mic chords read as if the gesture didn't
|
||||
// exist (Select+Y still fires the mic toggle: the flush sends Select's
|
||||
// down before Y's).
|
||||
if (bit == Gamepad.BTN_BACK && slot.held == 0) {
|
||||
slot.held = slot.held or bit
|
||||
armGuide(slot)
|
||||
return
|
||||
}
|
||||
flushPendingSelect(slot)
|
||||
}
|
||||
if (send && forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
|
||||
}
|
||||
val wasHeld = slot.held
|
||||
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()
|
||||
// Mic mute, edge-triggered on the button that COMPLETES the chord: a genuine press
|
||||
// (`wasHeld` lacks the bit, so an auto-repeat DOWN can't re-fire it) of a chord member
|
||||
// that leaves the whole chord held. Any other button pressed while Select + Y are down
|
||||
// fails the middle test, so the toggle happens once per chord, not once per press.
|
||||
if (wasHeld and bit == 0 && bit and MIC_CHORD != 0 && slot.held and MIC_CHORD == MIC_CHORD) {
|
||||
onMicChord?.invoke()
|
||||
}
|
||||
} else {
|
||||
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
val owned = guideGesture && bit == Gamepad.BTN_BACK && consumeSelectRelease(slot)
|
||||
if (!owned && send && forwarding) {
|
||||
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.
|
||||
@@ -127,6 +208,61 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
}
|
||||
}
|
||||
|
||||
/** Start a pending Select's hold countdown ([GUIDE_HOLD_MS] → a synthetic guide, down until release). */
|
||||
private fun armGuide(slot: Slot) {
|
||||
val r = Runnable {
|
||||
slot.pendingGuide = null
|
||||
slot.selectAsGuide = true
|
||||
if (forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, true, slot.index)
|
||||
}
|
||||
}
|
||||
slot.pendingGuide = r
|
||||
mainHandler.postDelayed(r, GUIDE_HOLD_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* A second button joined while Select was pending — it was a real Select after all; its
|
||||
* deferred down goes out before the caller sends the new button's, preserving chronology.
|
||||
*/
|
||||
private fun flushPendingSelect(slot: Slot) {
|
||||
val r = slot.pendingGuide ?: return
|
||||
mainHandler.removeCallbacks(r)
|
||||
slot.pendingGuide = null
|
||||
if (forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, true, slot.index)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select released with gesture state outstanding — true when the gesture owned the
|
||||
* release. A transformed hold lifts the synthetic guide; a pending tap delivers its
|
||||
* held-back press now, with the release [TAP_PRESS_MS] behind it (a back-to-back pair
|
||||
* can fold into nothing in the host's per-pad input fold).
|
||||
*/
|
||||
private fun consumeSelectRelease(slot: Slot): Boolean {
|
||||
if (slot.selectAsGuide) {
|
||||
slot.selectAsGuide = false
|
||||
if (forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, false, slot.index)
|
||||
}
|
||||
return true
|
||||
}
|
||||
val r = slot.pendingGuide ?: return false
|
||||
mainHandler.removeCallbacks(r)
|
||||
slot.pendingGuide = null
|
||||
if (forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, true, slot.index)
|
||||
val up = Runnable {
|
||||
slot.pendingTapUp = null
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, false, slot.index)
|
||||
}
|
||||
slot.pendingTapUp = up
|
||||
mainHandler.postDelayed(up, TAP_PRESS_MS)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Arm the exit-chord hold timer (once); on expiry, if the chord is still held, flush + leave. */
|
||||
private fun armExit() {
|
||||
if (pendingExit != null) return // already counting down
|
||||
@@ -167,7 +303,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
val dev = event.device ?: return false
|
||||
if (!isForwardable(dev)) return false
|
||||
val slot = slotFor(dev) ?: return false
|
||||
slot.mapper.onMotion(event)
|
||||
if (forwarding) slot.mapper.onMotion(event)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -202,12 +338,32 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
|
||||
/** 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)
|
||||
if (slot != null && forwarding) 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)
|
||||
if (slot != null && forwarding) NativeBridge.nativeSendPadHidReport(handle, index, buf, len)
|
||||
}
|
||||
|
||||
/** One touchpad contact on the rich plane: [finger] 0/1, x/y normalized 0..65535 in
|
||||
* SCREEN convention (+y down); `active = false` lifts the finger. On-change only. */
|
||||
fun touch(finger: Int, active: Boolean, x: Int, y: Int) {
|
||||
if (slot != null && forwarding) {
|
||||
NativeBridge.nativeSendPadTouch(handle, index, finger, active, x, y)
|
||||
}
|
||||
}
|
||||
|
||||
/** One motion sample on the rich plane (gyro pitch/yaw/roll + accel, raw device i16
|
||||
* units — the host passes them straight into the virtual pad's report). Per report. */
|
||||
fun motion(gyro: IntArray, accel: IntArray) {
|
||||
if (slot != null && forwarding) {
|
||||
NativeBridge.nativeSendPadMotion(
|
||||
handle, index,
|
||||
gyro[0], gyro[1], gyro[2],
|
||||
accel[0], accel[1], accel[2],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Flush held state, signal the removal, and free the wire index. Idempotent. */
|
||||
@@ -223,11 +379,21 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
// 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)
|
||||
if (forwarding) NativeBridge.nativeSendGamepadArrival(handle, pref, index)
|
||||
slots[syntheticId] = Slot(index, Gamepad.AxisMapper(handle, index))
|
||||
return ExternalPad(syntheticId, index)
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the slot (if any) for a physical controller a capture link just claimed. The claim
|
||||
* detaches the kernel driver, so the system's own removal callback would close it moments
|
||||
* later anyway — doing it at claim time makes the freed wire index deterministic for the
|
||||
* link's [ExternalPad] instead of racing the link's first report against that callback. Safe
|
||||
* to over-match (a same-VID/PID sibling that still exists as an InputDevice lazily reopens a
|
||||
* slot on its next input event). Main thread, like the hot-plug callbacks.
|
||||
*/
|
||||
fun releaseDevice(deviceId: Int) = closeSlot(deviceId)
|
||||
|
||||
/**
|
||||
* Flush + drop every slot and unregister the hot-plug listener. Call on session teardown, AFTER
|
||||
* the feedback poll threads are joined (they read [deviceForPad]).
|
||||
@@ -270,7 +436,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
// Automatic resolves the pad's type from its VID/PID; an explicit setting forces every pad
|
||||
// to that type (a single global choice — matches the handshake's session-default pref).
|
||||
val pref = if (setting == Gamepad.PREF_AUTO) Gamepad.prefFor(dev) else setting
|
||||
NativeBridge.nativeSendGamepadArrival(handle, pref, index)
|
||||
if (forwarding) NativeBridge.nativeSendGamepadArrival(handle, pref, index)
|
||||
val slot = Slot(index, Gamepad.AxisMapper(handle, index))
|
||||
slots[dev.id] = slot
|
||||
return slot
|
||||
@@ -283,7 +449,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
private fun closeSlot(deviceId: Int) {
|
||||
val slot = slots.remove(deviceId) ?: return
|
||||
releaseHeld(slot)
|
||||
NativeBridge.nativeSendGamepadRemove(handle, slot.index)
|
||||
if (forwarding) NativeBridge.nativeSendGamepadRemove(handle, slot.index)
|
||||
// If this pad was mid-exit-chord, its removal may have left no pad holding it — drop the timer.
|
||||
if (slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) disarmExit()
|
||||
// Release this controller's feedback bindings (close its lights session / cancel rumble).
|
||||
@@ -292,14 +458,32 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
|
||||
/** Lift every held button + zero the axes/HAT dpad for [slot] (wire events only, all on its index). */
|
||||
private fun releaseHeld(slot: Slot) {
|
||||
// Gesture first: a pending (never-sent) Select just drops its timer; an owed tap
|
||||
// release goes out NOW (its down is already on the wire and the handle may not
|
||||
// outlive this slot); a transformed guide — which is not in `held` — is lifted.
|
||||
slot.pendingGuide?.let { mainHandler.removeCallbacks(it) }
|
||||
slot.pendingGuide = null
|
||||
slot.pendingTapUp?.let {
|
||||
mainHandler.removeCallbacks(it)
|
||||
slot.pendingTapUp = null
|
||||
if (forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, false, slot.index)
|
||||
}
|
||||
}
|
||||
if (slot.selectAsGuide) {
|
||||
slot.selectAsGuide = false
|
||||
if (forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, false, slot.index)
|
||||
}
|
||||
}
|
||||
var bits = slot.held
|
||||
while (bits != 0) {
|
||||
val bit = bits and -bits // lowest set bit
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
if (forwarding) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
bits = bits and bit.inv()
|
||||
}
|
||||
slot.held = 0
|
||||
slot.mapper.reset() // zero sticks/triggers + release the HAT dpad
|
||||
if (forwarding) slot.mapper.reset() // zero sticks/triggers + release the HAT dpad
|
||||
}
|
||||
|
||||
/** Lowest wire index 0..[MAX_PADS) not held by a slot, or null when full — stable lowest-free keeps indices from shuffling on hot-plug. */
|
||||
@@ -323,7 +507,24 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
*/
|
||||
const val EXIT_HOLD_MS = 1000L
|
||||
|
||||
/**
|
||||
* Mic-mute chord: Select + Y. Y is deliberately NOT one of [EXIT_CHORD]'s buttons, so no
|
||||
* way of reaching the exit chord can pass through this one on the way (and vice versa) —
|
||||
* and Select is a menu button rather than a twitch action, which makes the pair unlikely
|
||||
* to occur inside real play.
|
||||
*/
|
||||
const val MIC_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_Y
|
||||
|
||||
/** Synthetic slot-key base for [ExternalPad]s — below every real (positive) InputDevice id. */
|
||||
const val EXTERNAL_ID_BASE = -1000
|
||||
|
||||
/** pf-client-core's `GUIDE_HOLD`: hold Select alone this long → the host's guide goes down. */
|
||||
const val GUIDE_HOLD_MS = 350L
|
||||
|
||||
/**
|
||||
* pf-client-core's `TAP_PRESS`: a held-back Select tap's release trails its press by
|
||||
* this much, so the pair can't coalesce into no press at all.
|
||||
*/
|
||||
const val TAP_PRESS_MS = 50L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
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.TimeoutException
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Generic USB transport for a client-captured HID controller — the device-agnostic half of what
|
||||
* [Sc2UsbLink] pioneered, now shared with the Sony capture ([DsCapture]). Claims the controller
|
||||
* interface(s) — `force = true` detaches the kernel/OS driver, so a captured pad can't
|
||||
* double-drive the ordinary InputDevice path — runs a multiplexed [UsbRequest] read loop, and
|
||||
* writes the host/capture's reports back to the device (interrupt-OUT when the interface has one,
|
||||
* else EP0 `SET_REPORT`).
|
||||
*
|
||||
* Everything device-specific is [Config]: which attached device to pick, which of its interfaces
|
||||
* to claim, and an optional keep-alive (feature reports re-sent on a firmware-watchdog cadence —
|
||||
* the SC2's lizard-mode refresh; a DualSense needs none).
|
||||
*
|
||||
* **Unplug is signalled, never inferred from silence:** a quiet controller is not a missing one
|
||||
* (an SC2 on-glass round tripped exactly this — a 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 HidUsbLink(
|
||||
private val context: Context,
|
||||
private val config: Config,
|
||||
private val onReport: (report: ByteArray, len: Int) -> Unit,
|
||||
private val onClosed: () -> Unit,
|
||||
) {
|
||||
/**
|
||||
* The per-device knowledge this transport is parameterized by. [ifaceFilter] narrows WHICH
|
||||
* HID/vendor-class interfaces get claimed (the class check itself is built in) — e.g. the SC2
|
||||
* Puck's controller slots, or the DualSense's single HID interface among its audio siblings.
|
||||
* [keepAliveFeatures] are full feature reports (id byte first) re-sent to the streaming
|
||||
* interface every [keepAliveMs] AND once at claim time; empty = no keep-alive.
|
||||
*/
|
||||
class Config(
|
||||
val tag: String,
|
||||
val threadName: String,
|
||||
val deviceMatch: (UsbDevice) -> Boolean,
|
||||
val ifaceFilter: (UsbDevice, UsbInterface) -> Boolean = { _, _ -> true },
|
||||
val keepAliveFeatures: List<ByteArray> = emptyList(),
|
||||
val keepAliveMs: Long = 0,
|
||||
)
|
||||
|
||||
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 output/feature writes go.
|
||||
* Written by the reader thread, read by the feedback thread (feature control transfers). */
|
||||
@Volatile private var activeClaim: Claim? = null
|
||||
|
||||
/** Pending OUT reports, 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). See [OutReportQueue] for
|
||||
* what gets discarded when it fills, and why that is not simply "the oldest". */
|
||||
private val outQueue = OutReportQueue()
|
||||
|
||||
private var reader: Thread? = null
|
||||
private var detachReceiver: BroadcastReceiver? = null
|
||||
|
||||
@Volatile private var running = false
|
||||
|
||||
/** Latches on the first "this link is down" signal so [onClosed] fires exactly once, however
|
||||
* many of the racing detectors (detach broadcast, reader error streak, failed re-queue) see
|
||||
* it. Reset by [start]. */
|
||||
private val down = AtomicBoolean(false)
|
||||
|
||||
/** First attached matching device, or null. Does not need USB permission to enumerate. */
|
||||
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull(config.deviceMatch)
|
||||
|
||||
/**
|
||||
* Open a SECOND connection to the same device, for a consumer that needs its own descriptor.
|
||||
*
|
||||
* **Not a convenience — a correctness requirement.** `UsbDeviceConnection.requestWait()`
|
||||
* returns *any* completed request on that connection, and the same is true of the usbfs reap
|
||||
* ioctl underneath it: two independent transfer engines sharing one descriptor steal each
|
||||
* other's completions. This link's reader owns its connection exclusively (see the note on
|
||||
* [outQueue]), so anything else driving transfers on this device — the isochronous audio
|
||||
* renderer — must open its own.
|
||||
*
|
||||
* usbfs allows the same device to be opened many times, and claims are per (descriptor,
|
||||
* interface), so a claim made on this connection does not conflict with one made on that.
|
||||
*
|
||||
* The caller owns the returned connection and must close it.
|
||||
*/
|
||||
fun openAuxConnection(): UsbDeviceConnection? {
|
||||
val dev = device ?: return null
|
||||
return usb.openDevice(dev)
|
||||
}
|
||||
|
||||
/**
|
||||
* The open connection's usbfs file descriptor, or -1 when the link is not running.
|
||||
*
|
||||
* Handed to native code that drives interfaces this link deliberately does NOT claim — the
|
||||
* pad's isochronous audio endpoint (see `pad_audio` on the native side), which Android's own
|
||||
* USB API cannot reach because `UsbRequest` rejects anything that is not bulk or interrupt.
|
||||
* usbfs claims are per interface, so a native claim of the audio interface leaves this link's
|
||||
* HID claim untouched.
|
||||
*
|
||||
* **The borrower must stop using it before [stop] runs**: closing the connection while a
|
||||
* transfer is in flight pulls the descriptor out from under the kernel.
|
||||
*/
|
||||
val fileDescriptor: Int get() = connection?.fileDescriptor ?: -1
|
||||
|
||||
/**
|
||||
* 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(config.tag, "no USB permission for ${dev.deviceName}")
|
||||
return false
|
||||
}
|
||||
val conn = usb.openDevice(dev) ?: run {
|
||||
Log.e(config.tag, "openDevice failed for ${dev.deviceName}")
|
||||
return false
|
||||
}
|
||||
val claimed = claimControllerInterfaces(dev, conn)
|
||||
if (claimed.isEmpty()) {
|
||||
Log.e(config.tag, "no claimable interface on ${dev.deviceName} (PID=0x%04x)".format(dev.productId))
|
||||
conn.close()
|
||||
return false
|
||||
}
|
||||
connection = conn
|
||||
device = dev
|
||||
claims = claimed
|
||||
down.set(false)
|
||||
running = true
|
||||
Log.i(
|
||||
config.tag,
|
||||
"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(config.tag, "USB detached (${dev.deviceName})")
|
||||
linkDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
if (config.keepAliveFeatures.isNotEmpty()) {
|
||||
claimed.forEach { sendKeepAlive(conn, it.iface.id) }
|
||||
}
|
||||
reader = Thread({ readLoop(conn, claimed) }, config.threadName).apply {
|
||||
isDaemon = true
|
||||
start()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim every candidate controller interface: HID (or vendor-class) interfaces that pass the
|
||||
* config's [Config.ifaceFilter], with an INT/BULK IN endpoint (OUT optional — the fallback is
|
||||
* EP0 `SET_REPORT`). `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 out = mutableListOf<Claim>()
|
||||
for (i in 0 until dev.interfaceCount) {
|
||||
val iface = dev.getInterface(i)
|
||||
if (!config.ifaceFilter(dev, iface)) 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(config.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(config.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(config.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(config.tag, "no IN request could be queued")
|
||||
finishReader(claims)
|
||||
// `start` already returned true, so without this the owner would sit waiting on a
|
||||
// capture that never streams and never reports itself dead.
|
||||
linkDown()
|
||||
return
|
||||
}
|
||||
val scratch = ByteArray(64)
|
||||
var lastKeepAlive = 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 (config.keepAliveFeatures.isNotEmpty() && config.keepAliveMs > 0 &&
|
||||
now - lastKeepAlive >= config.keepAliveMs
|
||||
) {
|
||||
// Refresh the firmware settings on the streaming interface (else every live
|
||||
// one, before a streaming interface is known) — replaying also repairs state
|
||||
// some other consumer changed after capture started.
|
||||
val target = activeClaim
|
||||
if (target != null) sendKeepAlive(conn, target.iface.id)
|
||||
else live.forEach { sendKeepAlive(conn, it.iface.id) }
|
||||
lastKeepAlive = 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(config.tag, "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(
|
||||
config.tag,
|
||||
"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(config.tag, "re-queue(IN, iface ${claim.iface.id}) failed — treating as unplug")
|
||||
break
|
||||
}
|
||||
} else if (done === claim.outReq) {
|
||||
claim.outBusy = false
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
finishReader(claims)
|
||||
}
|
||||
linkDown()
|
||||
}
|
||||
|
||||
/**
|
||||
* Report the link down, exactly once, from whichever detector noticed first — the detach
|
||||
* broadcast (main thread) or the reader thread on its way out.
|
||||
*
|
||||
* This only *signals*; releasing the connection and the interfaces stays the owner's job, via
|
||||
* the [stop] its `onClosed` handler calls. Previously nothing released them on this path: the
|
||||
* detach receiver flipped a flag and fired the callback, so an unplug left the connection open,
|
||||
* the interfaces claimed (the pad could not return to Android's own input stack) and the
|
||||
* receiver still registered — and a re-plug overwrote the field holding it, leaking a receiver
|
||||
* that stayed live for the process's lifetime.
|
||||
*/
|
||||
private fun linkDown() {
|
||||
running = false
|
||||
if (down.compareAndSet(false, true)) 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one raw report to the device: kind 0 = output report (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, hidapi framing.
|
||||
*
|
||||
* [coalesce] tells the pending-OUT queue whether a newer report of the same kind may replace
|
||||
* this one — [OutReportQueue.KEY_RUMBLE] for motor levels, the default [OutReportQueue.NO_COALESCE]
|
||||
* for one-shots (lightbar, player LEDs, trigger effects) the sender will not repeat.
|
||||
*
|
||||
* Returns whether the report reached the device or is queued for it. A caller that is writing
|
||||
* a **stop** needs this: a discarded stop has nothing behind it, so it must not be mistaken
|
||||
* for one that landed.
|
||||
*/
|
||||
fun writeRaw(kind: Int, data: ByteArray, coalesce: Int = OutReportQueue.NO_COALESCE): Boolean {
|
||||
if (data.isEmpty()) return false
|
||||
return when (kind) {
|
||||
0 -> {
|
||||
if ((activeClaim ?: claims.firstOrNull())?.outReq != null) {
|
||||
// Interrupt-OUT rides UsbRequests submitted by the reader thread.
|
||||
outQueue.offer(data, coalesce)
|
||||
} else {
|
||||
setReport(REPORT_TYPE_OUTPUT, data)
|
||||
}
|
||||
}
|
||||
1 -> setReport(REPORT_TYPE_FEATURE, data)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun setReport(type: Int, data: ByteArray): Boolean {
|
||||
val conn = connection ?: return false
|
||||
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return false
|
||||
return sendReport(conn, ifId, type, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one output report EP0-direct (`SET_REPORT(Output)`), bypassing the interrupt-OUT
|
||||
* queue — for a teardown write that must land while the reader thread is stopping and the
|
||||
* queue would never drain (e.g. a rumble stop before the interfaces release). Safe from any
|
||||
* thread: EP0 control transfers are independent of the reader's `requestWait`.
|
||||
*/
|
||||
fun writeControl(data: ByteArray): Boolean =
|
||||
data.isNotEmpty() && setReport(REPORT_TYPE_OUTPUT, data)
|
||||
|
||||
private fun sendKeepAlive(conn: UsbDeviceConnection, ifaceId: Int) {
|
||||
for (f in config.keepAliveFeatures) sendReport(conn, ifaceId, REPORT_TYPE_FEATURE, f)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
): Boolean {
|
||||
val id = data[0].toInt() and 0xFF
|
||||
val payload = if (id == 0) data.copyOfRange(1, data.size) else data
|
||||
// controlTransfer returns the byte count, or a negative value on failure — a failed write
|
||||
// must be reported as such, not swallowed (a dropped rumble stop has nothing behind it).
|
||||
val n = runCatching {
|
||||
conn.controlTransfer(
|
||||
0x21, // host→device, class, interface
|
||||
0x09, // SET_REPORT
|
||||
(type shl 8) or id,
|
||||
ifaceId,
|
||||
payload,
|
||||
payload.size,
|
||||
WRITE_TIMEOUT_MS,
|
||||
)
|
||||
}.getOrDefault(-1)
|
||||
return n >= 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed].
|
||||
*
|
||||
* Safe to call from the `onClosed` handler itself — that is how an unplug now gets cleaned up,
|
||||
* and it arrives on the reader thread, which must not try to join itself.
|
||||
*/
|
||||
fun stop() {
|
||||
running = false
|
||||
// Claim the down-latch so the reader's own exit does not report a close the owner asked for.
|
||||
down.set(true)
|
||||
detachReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||
detachReceiver = null
|
||||
if (reader !== Thread.currentThread()) {
|
||||
runCatching { reader?.join(1000) }
|
||||
// Only forget the thread once it is actually gone: clearing it while it still runs
|
||||
// would let a later stop() skip the join and free the connection under it.
|
||||
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 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
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,14 @@ object NativeBridge {
|
||||
compositorPref: Int,
|
||||
gamepadPref: Int,
|
||||
hdrEnabled: Boolean,
|
||||
/** Every decoder this device would use tolerates multi-slice AUs
|
||||
* ([VideoDecoders.multiSliceTolerant]) — advertises `VIDEO_CAP_MULTI_SLICE`; false keeps
|
||||
* the host at single-slice frames (the safe pre-0.17 wire shape). */
|
||||
multiSliceOk: Boolean,
|
||||
/** Every decoder this device would use accepts partial-frame input
|
||||
* ([VideoDecoders.partialFrameCapable]) — opts into slice-progressive delivery (the
|
||||
* decode loop then feeds slices with `BUFFER_FLAG_PARTIAL_FRAME` as they arrive). */
|
||||
framePartsOk: Boolean,
|
||||
audioChannels: Int,
|
||||
/** `quic::CODEC_*` bitfield of codecs this device decodes ([VideoDecoders.decodableCodecBits]);
|
||||
* `0` falls back to H.264|HEVC. The host resolves the emitted codec from this ∩ its GPU. */
|
||||
@@ -61,6 +69,10 @@ object NativeBridge {
|
||||
* list and trust store show for it, same convention as [nativePair]'s `name`. `null`/blank ⇒
|
||||
* the host falls back to a fingerprint-derived "device abcd1234" label. */
|
||||
deviceName: String?,
|
||||
/** Advertise `CLIENT_CAP_PAD_AUDIO` — the SESSION-level negotiation for the 0xD1 per-pad
|
||||
* DualSense plane. Without it the host never sets `HOST_CAP_PAD_AUDIO` and emits nothing,
|
||||
* so a captured pad's own render capabilities would have nothing to gate. */
|
||||
padAudioOk: Boolean,
|
||||
): Long
|
||||
|
||||
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
|
||||
@@ -184,10 +196,12 @@ object NativeBridge {
|
||||
external fun nativeVideoMime(handle: Long): String
|
||||
|
||||
/**
|
||||
* The negotiated video mode as `[width, height]`, or `null` on a `0` handle. Resolved at the
|
||||
* handshake, so it is known before the first frame — the stream view sizes itself to THIS
|
||||
* aspect rather than stretching the picture to the panel's. Fixed for the session; read once.
|
||||
* Cheap; UI-safe.
|
||||
* The negotiated video mode as `[width, height, refreshHz]`, or `null` on a `0` handle.
|
||||
* Resolved at the handshake, so it is known before the first frame — the stream view sizes
|
||||
* itself to THIS aspect rather than stretching the picture to the panel's, and pins the
|
||||
* panel's display mode to the stream refresh. The trailing `refreshHz` was appended later
|
||||
* (an older native lib returns only `[width, height]` — index defensively). Fixed for the
|
||||
* session; read once. Cheap; UI-safe.
|
||||
*/
|
||||
external fun nativeVideoSize(handle: Long): IntArray?
|
||||
|
||||
@@ -204,11 +218,13 @@ object NativeBridge {
|
||||
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. [decoderName] is the
|
||||
* decoder Kotlin ranked from `MediaCodecList` (`""` = let the platform resolve the default for
|
||||
* the MIME — what the pre-overhaul client always did); [lowLatencyMode] is the user's
|
||||
* "Low-latency mode (experimental)" toggle (off, the default, runs the original decode
|
||||
* pipeline; on, the aggressive per-SoC tuning + async loop); [lowLatencyFeature] is whether
|
||||
* "Low-latency mode" master toggle (ON by default: async loop + per-SoC tuning; off runs the
|
||||
* original synchronous pipeline as the per-device escape hatch); [lowLatencyFeature] is whether
|
||||
* [decoderName] advertised `FEATURE_LowLatency` (HUD label only). [isTv] drives an active HDMI
|
||||
* mode switch to the stream refresh on TV boxes when the toggle is on (vs. the softer seamless
|
||||
* hint otherwise). No-op if already started.
|
||||
* hint otherwise). [presentPriority]/[smoothBuffer] are the timeline presenter's intent
|
||||
* (0 = lowest latency / 1 = smoothness; buffer 0 = automatic, else 1..3 frames) — the Apple
|
||||
* client's `present_priority`/`smooth_buffer` pair. No-op if already started.
|
||||
*/
|
||||
external fun nativeStartVideo(
|
||||
handle: Long,
|
||||
@@ -217,6 +233,11 @@ object NativeBridge {
|
||||
lowLatencyMode: Boolean,
|
||||
lowLatencyFeature: Boolean,
|
||||
isTv: Boolean,
|
||||
presentPriority: Int,
|
||||
smoothBuffer: Int,
|
||||
/** The display mode's own refresh rate (0 = unknown) — the latch grid the presenter
|
||||
* subdivides onto when the platform down-rates the app's choreographer stream. */
|
||||
panelFps: Int,
|
||||
)
|
||||
|
||||
/** Stop + join the decode thread without closing the session. No-op on `0`. */
|
||||
@@ -231,11 +252,12 @@ object NativeBridge {
|
||||
|
||||
/**
|
||||
* Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs.
|
||||
* Returns 26 doubles (unified stats spec, `design/stats-unification.md`):
|
||||
* Returns 33 doubles (unified stats spec, `design/stats-unification.md`):
|
||||
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
|
||||
* bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
|
||||
* netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow, dispValid, displayP50Ms,
|
||||
* e2eDispP50Ms, e2eDispP95Ms]`
|
||||
* e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms, presentsWindow, presenterActive,
|
||||
* feedP50Ms, codecP50Ms, skippedOverflowWindow]`
|
||||
* (the flags are 1.0/0.0; indexes 2/3 are the end-to-end capture→decoded headline; 10–13
|
||||
* describe the negotiated video feed — bit depth 8/10, CICP primaries/transfer, and the HEVC
|
||||
* chroma_format_idc 1=4:2:0 / 3=4:4:4; 14/15 are the stage p50s tiling the headline —
|
||||
@@ -246,7 +268,12 @@ object NativeBridge {
|
||||
* `display` stage from the OnFrameRendered render timestamps — when `dispValid` is 1.0 the
|
||||
* headline becomes the directly-measured capture→displayed pair at 24/25, tiled by
|
||||
* `host+network` + `decode` + `display` (23), and when 0.0 the HUD falls back to the
|
||||
* capture→decoded headline at 2/3 without the `display` term).
|
||||
* capture→decoded headline at 2/3 without the `display` term; 26–29 split the `display`
|
||||
* term the timeline presenter owns — `pace` = decoded→release, `latch` = release→displayed,
|
||||
* the window's on-glass confirm count, and whether the presenter is active at all; 30/31
|
||||
* split `decode` (15) the same way — `feed` = received→queued (hand-off + input-slot wait),
|
||||
* `codec` = queued→decoded, the decoder's own time; 32 is the parked-AU overflow subset of
|
||||
* `skipped` (19), i.e. the decoder falling behind rather than benign newest-wins pacing).
|
||||
* Poll ~1 Hz; each call resets the measurement window.
|
||||
*/
|
||||
external fun nativeVideoStats(handle: Long): DoubleArray?
|
||||
@@ -271,15 +298,93 @@ object NativeBridge {
|
||||
external fun nativeStopAudio(handle: Long)
|
||||
|
||||
/**
|
||||
* Start mic uplink: AAudio input → Opus (48 kHz stereo, 20 ms) → host (`send_mic` / 0xCB), all in
|
||||
* Rust. No-op if already running. The caller MUST hold RECORD_AUDIO; otherwise the AAudio input
|
||||
* stream fails to open and the rest of the session keeps streaming.
|
||||
* Start mic uplink: AAudio input → Opus (48 kHz mono, 10 ms) → host (`send_mic` / 0xCB), all in
|
||||
* Rust. [echoCancel] opens the capture under the VoiceCommunication preset (the HAL's own echo
|
||||
* canceller / noise suppressor) and allocates an audio session id; the return value is that id
|
||||
* (`> 0`) so the caller can attach the Java [android.media.audiofx.AcousticEchoCanceler] /
|
||||
* [android.media.audiofx.NoiseSuppressor] as a backstop — `0` when none was allocated
|
||||
* (echoCancel off, the device refused the preset and the open fell back to the plain path, or
|
||||
* the mic failed entirely). No-op if already running (returns the running capture's id). The
|
||||
* caller MUST hold RECORD_AUDIO; otherwise the AAudio input stream fails to open and the rest
|
||||
* of the session keeps streaming.
|
||||
*/
|
||||
external fun nativeStartMic(handle: Long)
|
||||
external fun nativeStartMic(handle: Long, echoCancel: Boolean): Int
|
||||
|
||||
/** Stop + join the mic thread and close the AAudio input stream. No-op on `0`. */
|
||||
/**
|
||||
* Stop + join the mic thread and close the AAudio input stream. No-op on `0`. Leaves the
|
||||
* session's mute state ([nativeSetMicMuted]) alone — a surface recreate stops and restarts the
|
||||
* mic, and a user who muted must stay muted through it.
|
||||
*/
|
||||
external fun nativeStopMic(handle: Long)
|
||||
|
||||
/**
|
||||
* Mute/unmute the mic uplink mid-stream. Muting does NOT stop the capture: the AAudio input
|
||||
* stream, the input preset it settled on and its primed buffers stay as they are, and the
|
||||
* encode loop drops each 10 ms frame instead of encoding + sending it — so room audio is never
|
||||
* encoded and nothing goes on the wire, while a toggle costs an atomic store and takes effect
|
||||
* on the next 10 ms boundary (a stop/start would re-run the preset fallback ladder and re-prime
|
||||
* buffers every time).
|
||||
*
|
||||
* Sticky for the SESSION — the flag lives on the handle, not on the capture — so the mic
|
||||
* restart a surface recreate performs comes back muted, with no window for an unmuted frame to
|
||||
* escape; a fresh session always starts unmuted. Nothing here is persisted. No-op on `0`.
|
||||
* Cheap (one atomic store); UI-safe.
|
||||
*
|
||||
* One honest consequence of keeping the stream open: the platform's own recording indicator
|
||||
* stays lit while muted, because the mic really is still open. What stops is the encode and the
|
||||
* send — no captured audio leaves the process.
|
||||
*/
|
||||
external fun nativeSetMicMuted(handle: Long, muted: Boolean)
|
||||
|
||||
/**
|
||||
* Start tier-A DualSense pad audio: render the host's `0xD1` streams on the pad's own
|
||||
* 4-channel USB audio device.
|
||||
*
|
||||
* [fd] is an open [android.hardware.usb.UsbDeviceConnection]'s file descriptor. Native code
|
||||
* **borrows** it — it claims the pad's audio interface through usbfs (which leaves any HID
|
||||
* claim on the same device alone) and never closes the descriptor. The caller must keep the
|
||||
* connection open until [nativeStopPadAudio] returns.
|
||||
*
|
||||
* This also declares the pad's render capability to the host; without it no `0xD1` is sent.
|
||||
*
|
||||
* Returns false when there is nothing to render. A kernel that refuses the interface claim is
|
||||
* NOT reported here — the renderer discovers that on its own thread and the session simply
|
||||
* carries on without tier A, because some OEM kernels refuse and no app-side fix exists.
|
||||
*/
|
||||
external fun nativeStartPadAudio(
|
||||
handle: Long,
|
||||
pad: Int,
|
||||
fd: Int,
|
||||
haptics: Boolean,
|
||||
speaker: Boolean,
|
||||
): Boolean
|
||||
|
||||
/**
|
||||
* Stop tier-A pad audio and join its render thread, and hand the pad back to wire rumble.
|
||||
*
|
||||
* Returns only once the thread is joined — so the `UsbDeviceConnection` may be closed as soon
|
||||
* as this returns, and not before.
|
||||
*/
|
||||
external fun nativeStopPadAudio(handle: Long, pad: Int)
|
||||
|
||||
/**
|
||||
* Drive the pad with a test tone through the real render path — no host, no session.
|
||||
*
|
||||
* [fd] must come from a connection **nothing else is driving transfers on**: two engines on
|
||||
* one usbfs descriptor reap each other's completions. Blocks for roughly [seconds]; run it off
|
||||
* the main thread. Returns sample frames written, or negative on failure.
|
||||
*/
|
||||
external fun nativePadAudioSelfTest(fd: Int, seconds: Int, hz: Int): Int
|
||||
|
||||
/**
|
||||
* Is a mic capture actually RUNNING — i.e. did [nativeStartMic] open a stream, and has
|
||||
* [nativeStopMic] not been called since? Offer the in-stream mute control on THIS rather than
|
||||
* on the user's setting: a device that refused every AAudio input rung (or a missing
|
||||
* RECORD_AUDIO grant) then shows no control instead of a lie about a mic being heard. `false`
|
||||
* on a `0` handle. Cheap; UI-safe.
|
||||
*/
|
||||
external fun nativeMicActive(handle: Long): Boolean
|
||||
|
||||
// ---- Input: Kotlin captures, Rust forwards to the host (send_input) ----
|
||||
|
||||
/** Relative mouse move; dx/dy are device-pixel deltas (screen +y down). */
|
||||
@@ -407,6 +512,30 @@ object NativeBridge {
|
||||
*/
|
||||
external fun nativeSendPadHidReport(handle: Long, pad: Int, buf: java.nio.ByteBuffer, len: Int)
|
||||
|
||||
/**
|
||||
* One touchpad contact from a client-captured controller (the Sony USB capture), forwarded on
|
||||
* the rich-input plane (`RichInput::Touchpad`). [finger] is the contact slot (0/1); [x]/[y]
|
||||
* are normalized 0..65535 in SCREEN convention (+y down — the wire's fixed meaning); active
|
||||
* false lifts the finger. Send on change only — the host holds per-slot state.
|
||||
*/
|
||||
external fun nativeSendPadTouch(handle: Long, pad: Int, finger: Int, active: Boolean, x: Int, y: Int)
|
||||
|
||||
/**
|
||||
* One motion-sensor sample from a client-captured controller (`RichInput::Motion`): gyro
|
||||
* pitch/yaw/roll + accel, each a raw signed-16 value in the pad's own units — the host passes
|
||||
* them straight into the virtual DualSense report. Called at the pad's report rate.
|
||||
*/
|
||||
external fun nativeSendPadMotion(
|
||||
handle: Long,
|
||||
pad: Int,
|
||||
gyroPitch: Int,
|
||||
gyroYaw: Int,
|
||||
gyroRoll: Int,
|
||||
accelX: Int,
|
||||
accelY: Int,
|
||||
accelZ: Int,
|
||||
)
|
||||
|
||||
// ---- Host→client gamepad feedback: Rust pulls block ~100ms, Kotlin renders (see GamepadFeedback) ----
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
/**
|
||||
* The pending interrupt-OUT reports for a captured controller: a bounded FIFO whose overflow
|
||||
* policy knows which reports may be thrown away and which may not.
|
||||
*
|
||||
* The queue exists because only one thread may drive a connection's `UsbRequest`s, so writes from
|
||||
* the feedback threads are handed to the reader thread rather than submitted directly. It has to
|
||||
* be bounded — a stalled or unplugged device would otherwise grow it without limit — and the
|
||||
* question is what to discard when it fills.
|
||||
*
|
||||
* The old policy was "newest wins": drop from the head until there is room. That is right for
|
||||
* rumble, which is *level-styled* — the host re-sends it continuously, so a dropped frame is
|
||||
* replaced milliseconds later and nothing is permanently lost. It is wrong for everything else.
|
||||
* A lightbar colour, a player-LED mask and an adaptive-trigger effect are **one-shots**: the host
|
||||
* sends them on change and never repeats them. Dropping one leaves the pad wrong until the next
|
||||
* time that value happens to change, which may be never.
|
||||
*
|
||||
* So eviction is driven by an explicit [key] supplied by the caller, not by inspecting the bytes.
|
||||
* That distinction cannot be recovered from the report itself: every DualSense output report
|
||||
* carries the *same* report id and differs only in its `valid_flag` bytes, so an id-keyed policy
|
||||
* would happily let a rumble supersede a lightbar — the very bug this replaces, relocated.
|
||||
*
|
||||
* Two rules:
|
||||
* - A report offered with a coalescing key **replaces** the pending report with that key, in
|
||||
* place. A burst of rumble collapses to its latest value and never displaces anything else.
|
||||
* - Only when the queue is full does anything get dropped, and then the oldest *coalescable*
|
||||
* report goes first. A one-shot is discarded only if the queue is full of nothing but
|
||||
* one-shots — which needs [cap] distinct one-shots outstanding, far beyond what a real pad
|
||||
* produces.
|
||||
*
|
||||
* Thread-safe: offered by the feedback threads, drained by the reader thread.
|
||||
*/
|
||||
internal class OutReportQueue(private val cap: Int = CAP) {
|
||||
private class Entry(val key: Int, val data: ByteArray)
|
||||
|
||||
private val items = ArrayDeque<Entry>()
|
||||
|
||||
/**
|
||||
* Queue [data] for submission. [key] is [NO_COALESCE] for a one-shot, or a caller-chosen
|
||||
* constant identifying a level-styled stream whose newer values supersede older ones.
|
||||
*
|
||||
* Returns false only if the report had to be dropped outright — the caller can then treat the
|
||||
* write as failed rather than assuming it is on its way.
|
||||
*/
|
||||
fun offer(data: ByteArray, key: Int = NO_COALESCE): Boolean = synchronized(items) {
|
||||
if (key != NO_COALESCE) {
|
||||
val at = items.indexOfFirst { it.key == key }
|
||||
if (at >= 0) {
|
||||
// Supersede in place: keeping the queue position stops a fast rumble stream from
|
||||
// repeatedly jumping the one-shots queued ahead of it.
|
||||
items[at] = Entry(key, data)
|
||||
return true
|
||||
}
|
||||
}
|
||||
if (items.size >= cap) {
|
||||
val victim = items.indexOfFirst { it.key != NO_COALESCE }
|
||||
if (victim >= 0) {
|
||||
items.removeAt(victim)
|
||||
} else if (key != NO_COALESCE) {
|
||||
// Nothing coalescable to sacrifice and this report is itself replaceable — drop it
|
||||
// rather than a one-shot that will never come again.
|
||||
return false
|
||||
} else {
|
||||
items.removeFirst()
|
||||
}
|
||||
}
|
||||
items.addLast(Entry(key, data))
|
||||
return true
|
||||
}
|
||||
|
||||
/** The next report to submit, or null when nothing is pending. */
|
||||
fun poll(): ByteArray? = synchronized(items) { items.removeFirstOrNull()?.data }
|
||||
|
||||
fun clear() = synchronized(items) { items.clear() }
|
||||
|
||||
val size: Int get() = synchronized(items) { items.size }
|
||||
|
||||
companion object {
|
||||
/** This report is a one-shot: never superseded, evicted only as a last resort. */
|
||||
const val NO_COALESCE = 0
|
||||
|
||||
/** Motor levels — re-sent continuously, so only the newest is worth keeping. */
|
||||
const val KEY_RUMBLE = 1
|
||||
|
||||
/** Deep enough to absorb a burst, small enough that a stalled device cannot bloat us. */
|
||||
const val CAP = 32
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
/**
|
||||
* The two conversions every rumble path in this module needs, in one place.
|
||||
*
|
||||
* Both used to be transcribed per call site: [wireAmplitudeToByte] existed twice, byte-identical,
|
||||
* in `GamepadFeedback` and `DsDevice`; [unpackRumbleEvent] was inline bit-shifting in the poll loop
|
||||
* with no test on either side of the JNI boundary. Neither is complicated — which is exactly why a
|
||||
* silent divergence between copies would have been hard to notice.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Wire amplitude (`0..0xFFFF`) → an 8-bit motor/vibrator level.
|
||||
*
|
||||
* The high byte, except that a **nonzero command never collapses to zero**: anything below 0x0100
|
||||
* would otherwise round to silence, turning a weak-but-real rumble into no rumble at all. 1 is
|
||||
* imperceptibly light, but it moves.
|
||||
*/
|
||||
internal fun wireAmplitudeToByte(v16: Int): Int {
|
||||
val a = (v16 ushr 8) and 0xFF
|
||||
return if (v16 != 0 && a == 0) 1 else a
|
||||
}
|
||||
|
||||
/** One effective rumble command, as packed by the native side's `nativeNextRumble`. */
|
||||
internal data class RumbleCmd(val pad: Int, val low: Int, val high: Int, val backstopMs: Long)
|
||||
|
||||
/**
|
||||
* Unpack `NativeBridge.nativeNextRumble`'s `jlong`, or null for the timeout/closed sentinel.
|
||||
*
|
||||
* Layout, mirroring `clients/android/native/src/feedback.rs::pack_rumble`:
|
||||
* bits 49..52 = wire pad index, 32..47 = backstop duration (ms), 16..31 = low, 0..15 = high.
|
||||
* The pad field is 4 bits because `punktfunk_core::input::MAX_PADS` is 16 — the Rust side has a
|
||||
* compile-time assertion tying the two together, so this can't silently start truncating.
|
||||
*
|
||||
* These are EFFECTIVE commands from the core's shared rumble policy engine: it owns every
|
||||
* lease/staleness/close decision and emits explicit zeros, so apply them verbatim —
|
||||
* `(0, 0)` = cancel, non-zero = one-shot for the backstop.
|
||||
*/
|
||||
internal fun unpackRumbleEvent(ev: Long): RumbleCmd? {
|
||||
if (ev < 0L) return null // timeout / closed
|
||||
return RumbleCmd(
|
||||
pad = ((ev ushr 49) and 0xFL).toInt(),
|
||||
low = ((ev ushr 16) and 0xFFFF).toInt(),
|
||||
high = (ev and 0xFFFF).toInt(),
|
||||
backstopMs = (ev ushr 32) and 0xFFFF,
|
||||
)
|
||||
}
|
||||
@@ -273,10 +273,20 @@ class Sc2Capture(
|
||||
|
||||
private fun onLinkClosed() {
|
||||
Log.i(TAG, "SC2 link closed (unplug / power-off)")
|
||||
// Both transports share this callback, so read which one was live BEFORE clearing it —
|
||||
// releasing the other would tear down a link that never dropped.
|
||||
val dropped = activeLink
|
||||
activeLink = LINK_NONE
|
||||
dongleLink = false
|
||||
releaseSlot()
|
||||
releaseUiKeys()
|
||||
// Release the transport too — see the note in DsCapture.onLinkClosed. The Puck makes this
|
||||
// worse than a single leak: it is the pad that gets power-cycled, so the same process can
|
||||
// round-trip a link many times in one session.
|
||||
when (dropped) {
|
||||
LINK_USB -> usb.stop()
|
||||
LINK_BLE -> ble.stop()
|
||||
}
|
||||
onActiveChanged?.invoke(false)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +1,13 @@
|
||||
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.
|
||||
* dongle (`1304`/`1305`). The SC2 specialization of the shared [HidUsbLink] transport (which owns
|
||||
* the claim, read loop, write queue, and unplug handling); this class contributes only what is
|
||||
* SC2-specific:
|
||||
*
|
||||
* **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
|
||||
@@ -30,350 +15,50 @@ import java.util.concurrent.TimeoutException
|
||||
* 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).
|
||||
* **Lizard keep-alive:** the firmware watchdog re-enables lizard mode (built-in kb/mouse
|
||||
* emulation) after a few seconds of silence, so [Sc2Device.DISABLE_LIZARD] +
|
||||
* [Sc2Device.NORMALIZE_JOYSTICKS] are re-sent on SDL's cadence — the generic link's keep-alive.
|
||||
*/
|
||||
class Sc2UsbLink(
|
||||
private val context: Context,
|
||||
private val onReport: (report: ByteArray, len: Int) -> Unit,
|
||||
private val onClosed: () -> Unit,
|
||||
context: Context,
|
||||
onReport: (report: ByteArray, len: Int) -> Unit,
|
||||
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
|
||||
private val link = HidUsbLink(
|
||||
context,
|
||||
HidUsbLink.Config(
|
||||
tag = "Sc2UsbLink",
|
||||
threadName = "pf-sc2-usb",
|
||||
deviceMatch = {
|
||||
it.vendorId == Sc2Device.VID_VALVE && it.productId in Sc2Device.USB_PIDS
|
||||
},
|
||||
// Wired: every HID/vendor interface; dongle: only the controller slots 2..5.
|
||||
ifaceFilter = { dev, iface ->
|
||||
dev.productId == Sc2Device.PID_WIRED || iface.id in Sc2Device.DONGLE_IFACES
|
||||
},
|
||||
keepAliveFeatures = listOf(Sc2Device.DISABLE_LIZARD, Sc2Device.NORMALIZE_JOYSTICKS),
|
||||
keepAliveMs = Sc2Device.LIZARD_REFRESH_MS,
|
||||
),
|
||||
onReport,
|
||||
onClosed,
|
||||
)
|
||||
|
||||
/** 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
|
||||
}
|
||||
fun findDevice(): UsbDevice? = link.findDevice()
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
fun start(dev: UsbDevice): Boolean = link.start(dev)
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* rumble & friends), kind 1 = feature report. [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)
|
||||
}
|
||||
}
|
||||
fun writeRaw(kind: Int, data: ByteArray) = link.writeRaw(kind, 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
|
||||
}
|
||||
/** Stop the read loop and release the interfaces. Idempotent; does not fire the closed callback. */
|
||||
fun stop() = link.stop()
|
||||
}
|
||||
|
||||
@@ -53,6 +53,81 @@ object VideoDecoders {
|
||||
fun decodableCodecBits(): Int =
|
||||
1 or 2 or (if (pickDecoder("video/av01") != null) 4 else 0)
|
||||
|
||||
/**
|
||||
* Whether EVERY decoder this device would use tolerates multi-slice access units — the
|
||||
* `VIDEO_CAP_MULTI_SLICE` advertisement (decoder truth, per the 0.17.0 field regression:
|
||||
* Amlogic HEVC decoders wedge the whole DEVICE on multi-slice frames — Chromecast with
|
||||
* Google TV, onn 4K — which is why Moonlight requests single-slice for every hardware
|
||||
* decoder). We advertise per-family instead: tolerant unless the pick for ANY advertised
|
||||
* codec is an Amlogic decoder or can't be inspected (a null pick = the platform default —
|
||||
* uninspectable, so conservatively single-slice). Probed once at connect time; the host
|
||||
* defaults to >1 slice only toward clients that set the bit.
|
||||
*/
|
||||
fun multiSliceTolerant(): Boolean {
|
||||
val mimes = buildList {
|
||||
add("video/avc")
|
||||
add("video/hevc")
|
||||
if (decodableCodecBits() and 4 != 0) add("video/av01")
|
||||
}
|
||||
return mimes.all { mime ->
|
||||
val name = pickDecoder(mime)?.name?.lowercase() ?: return@all false
|
||||
!name.startsWith("omx.amlogic") && !name.startsWith("c2.amlogic")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when EVERY decoder this device would stream with supports partial-frame input
|
||||
* (MediaCodec `FEATURE_PartialFrame`): the client may then opt into slice-progressive
|
||||
* delivery and feed slices with `BUFFER_FLAG_PARTIAL_FRAME` ahead of the AU's tail.
|
||||
* Codec selection happens after connect, so all advertised mimes must qualify — the same
|
||||
* conservative shape as [multiSliceTolerant] (an unprobeable pick disqualifies).
|
||||
*/
|
||||
fun partialFrameCapable(): Boolean {
|
||||
val mimes = buildList {
|
||||
add("video/avc")
|
||||
add("video/hevc")
|
||||
if (decodableCodecBits() and 4 != 0) add("video/av01")
|
||||
}
|
||||
val infos = runCatching { MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos }
|
||||
.getOrNull() ?: return false
|
||||
return mimes.all { mime ->
|
||||
val pick = pickDecoder(mime)?.name ?: return@all false
|
||||
val info = infos.firstOrNull { it.name == pick } ?: return@all false
|
||||
runCatching {
|
||||
info.getCapabilitiesForType(mime)
|
||||
.isFeatureSupported(CodecCapabilities.FEATURE_PartialFrame)
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line per-mime probe readout for the connect log (`adb logcat -s pf.caps`): which
|
||||
* decoder each advertised mime resolves to and whether it declares `FEATURE_PartialFrame` —
|
||||
* the P2 slice-pipeline gate that is otherwise invisible until a stream behaves differently.
|
||||
*/
|
||||
fun capsReport(): String {
|
||||
val mimes = buildList {
|
||||
add("video/avc")
|
||||
add("video/hevc")
|
||||
if (decodableCodecBits() and 4 != 0) add("video/av01")
|
||||
}
|
||||
val infos = runCatching { MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos }
|
||||
.getOrNull() ?: return "codec list unavailable"
|
||||
return mimes.joinToString(" ") { mime ->
|
||||
val pick = pickDecoder(mime)?.name
|
||||
val partial = pick?.let { p ->
|
||||
infos.firstOrNull { it.name == p }?.let { info ->
|
||||
runCatching {
|
||||
info.getCapabilitiesForType(mime)
|
||||
.isFeatureSupported(CodecCapabilities.FEATURE_PartialFrame)
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
"${mime.removePrefix("video/")}=${pick ?: "platform-default"}" +
|
||||
" partialFrame=${partial ?: "?"}"
|
||||
}
|
||||
}
|
||||
|
||||
fun pickDecoder(mime: String): DecoderChoice? {
|
||||
if (mime.isEmpty()) return null
|
||||
val infos = runCatching { MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos }
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pure JVM tests of the Sony USB report codec ([DsDevice]) — the byte-exact inverse of the
|
||||
* host's `dualsense_proto.rs` / `dualshock4_proto.rs` serializers (offsets cross-checked against
|
||||
* those files' own tests). No Android runtime types ([Gamepad]'s BTN_* are compile-time ints).
|
||||
* Run: `./gradlew :kit:testDebugUnitTest`.
|
||||
*/
|
||||
class DsDeviceTest {
|
||||
private fun ds5Report(mutate: (ByteArray) -> Unit = {}): ByteArray =
|
||||
ByteArray(64).also {
|
||||
it[0] = 0x01
|
||||
// Sticks centred, hat neutral (8).
|
||||
it[1] = 0x80.toByte(); it[2] = 0x80.toByte(); it[3] = 0x80.toByte(); it[4] = 0x80.toByte()
|
||||
it[8] = 0x08
|
||||
// Touch points inactive (bit7 set).
|
||||
it[33] = 0x80.toByte(); it[37] = 0x80.toByte()
|
||||
mutate(it)
|
||||
}
|
||||
|
||||
private fun ds4Report(mutate: (ByteArray) -> Unit = {}): ByteArray =
|
||||
ByteArray(64).also {
|
||||
it[0] = 0x01
|
||||
it[1] = 0x80.toByte(); it[2] = 0x80.toByte(); it[3] = 0x80.toByte(); it[4] = 0x80.toByte()
|
||||
it[5] = 0x08
|
||||
it[35] = 0x80.toByte(); it[39] = 0x80.toByte()
|
||||
mutate(it)
|
||||
}
|
||||
|
||||
// ---- input parse ----
|
||||
|
||||
@Test
|
||||
fun ds5ButtonsMapPositionally() {
|
||||
val s = DsDevice.State()
|
||||
// cross+triangle, hat NE, L1+create+L3, PS+touchpad+mute.
|
||||
val r = ds5Report {
|
||||
it[8] = (0x20 or 0x80 or 0x01).toByte() // cross | triangle | hat=1 (NE)
|
||||
it[9] = (0x01 or 0x10 or 0x40).toByte() // L1 | create | L3
|
||||
it[10] = (0x01 or 0x02 or 0x04).toByte() // PS | touchpad | mute
|
||||
}
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, s))
|
||||
val expected = Gamepad.BTN_A or Gamepad.BTN_Y or
|
||||
Gamepad.BTN_DPAD_UP or Gamepad.BTN_DPAD_RIGHT or
|
||||
Gamepad.BTN_LB or Gamepad.BTN_BACK or Gamepad.BTN_LS_CLICK or
|
||||
Gamepad.BTN_GUIDE or Gamepad.BTN_TOUCHPAD or Gamepad.BTN_MISC1
|
||||
assertEquals(expected, s.buttons)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ds5SticksInvertYAndCoverTheFullRange() {
|
||||
val s = DsDevice.State()
|
||||
// Device +y down; wire +y up. Left stick fully up-left, right stick fully down-right.
|
||||
val r = ds5Report {
|
||||
it[1] = 0x00; it[2] = 0x00 // lx min, ly min (up)
|
||||
it[3] = 0xFF.toByte(); it[4] = 0xFF.toByte() // rx max, ry max (down)
|
||||
it[5] = 0x40; it[6] = 0xFF.toByte()
|
||||
}
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, s))
|
||||
assertEquals(-32768, s.lsX)
|
||||
assertEquals(32767, s.lsY) // device up → wire +32767
|
||||
assertEquals(32767, s.rsX)
|
||||
assertEquals(-32768, s.rsY) // device down → wire −32768
|
||||
assertEquals(0x40, s.lt)
|
||||
assertEquals(0xFF, s.rt)
|
||||
// Centre stays (near) centre: 0x80 → 128 wire units of bias, the u8 grid's own offset.
|
||||
val c = DsDevice.State()
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5Report(), 64, c))
|
||||
assertEquals(128, c.lsX)
|
||||
assertEquals(-129, c.lsY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ds5MotionAndTouchUnpack() {
|
||||
val s = DsDevice.State()
|
||||
val r = ds5Report {
|
||||
// gyro pitch = 0x0102, accel z = -2 (LE i16s at 16.. / 22..).
|
||||
it[16] = 0x02; it[17] = 0x01
|
||||
it[26] = 0xFE.toByte(); it[27] = 0xFF.toByte()
|
||||
// Touch 0 active, id 5, x=1919 (0x77F), y=1079 (0x437):
|
||||
// b0=0x05, b1=0x7F, b2=(x>>8)|((y&0xF)<<4)=0x77, y>>4=0x43.
|
||||
it[33] = 0x05
|
||||
it[34] = 0x7F
|
||||
it[35] = (0x07 or (0x07 shl 4)).toByte()
|
||||
it[36] = 0x43
|
||||
}
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, s))
|
||||
assertEquals(0x0102, s.gyro[0])
|
||||
assertEquals(-2, s.accel[2])
|
||||
assertTrue(s.touchActive[0])
|
||||
assertEquals(1919, s.touchX[0])
|
||||
assertEquals(1079, s.touchY[0])
|
||||
assertFalse(s.touchActive[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun edgePaddlesParseOnlyOnTheEdge() {
|
||||
val r = ds5Report { it[10] = 0xF0.toByte() } // all four FN/BACK bits
|
||||
val edge = DsDevice.State()
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE_EDGE, r, 64, edge))
|
||||
// Host inverse (`edge_paddle_bits`): PADDLE1/2 = right/left BACK, PADDLE3/4 = right/left Fn.
|
||||
assertEquals(
|
||||
Gamepad.BTN_PADDLE1 or Gamepad.BTN_PADDLE2 or Gamepad.BTN_PADDLE3 or Gamepad.BTN_PADDLE4,
|
||||
edge.buttons,
|
||||
)
|
||||
val plain = DsDevice.State()
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, plain))
|
||||
assertEquals(0, plain.buttons) // a non-Edge never reports phantom paddles
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ds4LayoutDiffersWhereItShould() {
|
||||
val s = DsDevice.State()
|
||||
val r = ds4Report {
|
||||
it[5] = (0x10 or 0x04).toByte() // square | hat=4 (down)
|
||||
it[6] = (0x10 or 0x20).toByte() // share | options
|
||||
it[7] = 0x03 // PS | touchpad click
|
||||
it[8] = 0x11 // L2 analog
|
||||
it[9] = 0x99.toByte() // R2 analog
|
||||
// gyro yaw at 15.. (second i16 of 13..19).
|
||||
it[15] = 0x34; it[16] = 0x12
|
||||
// Touch 0 active id 3 at x=100 (0x064), y=941 (0x3AD): b1=0x64, b2=0xD0, b3=0x3A.
|
||||
it[35] = 0x03
|
||||
it[36] = 0x64
|
||||
it[37] = 0xD0.toByte()
|
||||
it[38] = 0x3A
|
||||
}
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSHOCK4, r, 64, s))
|
||||
assertEquals(
|
||||
Gamepad.BTN_X or Gamepad.BTN_DPAD_DOWN or Gamepad.BTN_BACK or Gamepad.BTN_START or
|
||||
Gamepad.BTN_GUIDE or Gamepad.BTN_TOUCHPAD,
|
||||
s.buttons,
|
||||
)
|
||||
assertEquals(0x11, s.lt)
|
||||
assertEquals(0x99, s.rt)
|
||||
assertEquals(0x1234, s.gyro[1])
|
||||
assertTrue(s.touchActive[0])
|
||||
assertEquals(100, s.touchX[0])
|
||||
assertEquals(941, s.touchY[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsForeignAndShortReports() {
|
||||
val s = DsDevice.State()
|
||||
assertFalse(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5Report { it[0] = 0x31 }, 64, s))
|
||||
assertFalse(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5Report(), 8, s))
|
||||
assertFalse(DsDevice.parseState(DsDevice.Model.DUALSHOCK4, ds4Report(), 8, s))
|
||||
}
|
||||
|
||||
// ---- output builders (offsets = the host parser's: `parse_ds_output` / `parse_ds4_output`) ----
|
||||
|
||||
@Test
|
||||
fun ds5RumbleReportFlagsAndMotors() {
|
||||
val r = DsDevice.ds5RumbleReport(DsDevice.Model.DUALSENSE, low = 0xFF00, high = 0x1200)
|
||||
assertEquals(48, r.size)
|
||||
assertEquals(0x02, r[0].toInt())
|
||||
assertEquals(0x03, r[1].toInt()) // compat vibration | haptics select
|
||||
assertEquals(0x04, r[39].toInt()) // VIBRATION2 (fw ≥ 2.24)
|
||||
assertEquals(0x12, r[3].toInt() and 0xFF) // high = right/small at [3]
|
||||
assertEquals(0xFF, r[4].toInt() and 0xFF) // low = left/big at [4]
|
||||
// A nonzero amplitude never collapses to motor 0.
|
||||
assertEquals(1, DsDevice.ds5RumbleReport(DsDevice.Model.DUALSENSE, 0x00FF, 0)[4].toInt())
|
||||
// The Edge's output report is the 64-byte variant.
|
||||
assertEquals(64, DsDevice.ds5RumbleReport(DsDevice.Model.DUALSENSE_EDGE, 0, 0).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ds5TriggerReportPlacesTheBlockPerSide() {
|
||||
val effect = ByteArray(11) { (it + 1).toByte() }
|
||||
val r2 = DsDevice.ds5TriggerReport(DsDevice.Model.DUALSENSE, which = 1, effect = effect)
|
||||
assertEquals(0x04, r2[1].toInt()) // R2 valid flag
|
||||
assertEquals(1, r2[11].toInt()) // block at [11..22)
|
||||
assertEquals(11, r2[21].toInt())
|
||||
assertEquals(0, r2[22].toInt())
|
||||
val l2 = DsDevice.ds5TriggerReport(DsDevice.Model.DUALSENSE, which = 0, effect = effect)
|
||||
assertEquals(0x08, l2[1].toInt()) // L2 valid flag
|
||||
assertEquals(1, l2[22].toInt()) // block at [22..33)
|
||||
assertEquals(11, l2[32].toInt())
|
||||
// Oversized wire effects clamp to the 11-byte hardware block.
|
||||
val big = DsDevice.ds5TriggerReport(DsDevice.Model.DUALSENSE, 1, ByteArray(20) { 0x7F })
|
||||
assertEquals(0, big[22].toInt())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ds5LightbarPlayerLedsAndInit() {
|
||||
val led = DsDevice.ds5LightbarReport(DsDevice.Model.DUALSENSE, 1, 2, 3)
|
||||
assertEquals(0x04, led[2].toInt()) // lightbar valid flag
|
||||
assertEquals(1, led[45].toInt()); assertEquals(2, led[46].toInt()); assertEquals(3, led[47].toInt())
|
||||
val pl = DsDevice.ds5PlayerLedsReport(DsDevice.Model.DUALSENSE, 0xFF)
|
||||
assertEquals(0x10, pl[2].toInt()) // player-LED valid flag
|
||||
assertEquals(0x1F, pl[44].toInt()) // masked to the 5 LEDs
|
||||
val init = DsDevice.ds5InitReport(DsDevice.Model.DUALSENSE)
|
||||
assertEquals(0x02, init[39].toInt()) // lightbar-setup enable
|
||||
assertEquals(0x02, init[42].toInt()) // LIGHT_OUT — releases the firmware animation
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ds4ReportIsAFullStateWrite() {
|
||||
val r = DsDevice.ds4Report(low = 0xAB00, high = 0x0100, r = 9, g = 8, b = 7)
|
||||
assertEquals(32, r.size)
|
||||
assertEquals(0x05, r[0].toInt())
|
||||
assertEquals(0x03, r[1].toInt()) // motors | LED, both — composed state
|
||||
assertEquals(0x01, r[4].toInt()) // high = weak/right at [4]
|
||||
assertEquals(0xAB, r[5].toInt() and 0xFF) // low = strong/left at [5]
|
||||
assertEquals(9, r[6].toInt()); assertEquals(8, r[7].toInt()); assertEquals(7, r[8].toInt())
|
||||
assertEquals(0, r[9].toInt()) // blink untouched
|
||||
}
|
||||
|
||||
@Test
|
||||
fun modelResolution() {
|
||||
assertEquals(DsDevice.Model.DUALSENSE, DsDevice.modelFor(0x0CE6))
|
||||
assertEquals(DsDevice.Model.DUALSENSE_EDGE, DsDevice.modelFor(0x0DF2))
|
||||
assertEquals(DsDevice.Model.DUALSHOCK4, DsDevice.modelFor(0x05C4))
|
||||
assertEquals(DsDevice.Model.DUALSHOCK4, DsDevice.modelFor(0x09CC))
|
||||
assertEquals(null, DsDevice.modelFor(0x1234))
|
||||
assertEquals(Gamepad.PREF_DUALSENSE, DsDevice.Model.DUALSENSE.pref)
|
||||
assertEquals(Gamepad.PREF_DUALSENSEEDGE, DsDevice.Model.DUALSENSE_EDGE.pref)
|
||||
assertEquals(Gamepad.PREF_DUALSHOCK4, DsDevice.Model.DUALSHOCK4.pref)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The pending-OUT queue's overflow policy. What is being pinned here is the distinction the old
|
||||
* "drop from the head until there is room" policy did not make: rumble is re-sent continuously and
|
||||
* may be thrown away, while a lightbar/player-LED/trigger report is sent once and never repeated.
|
||||
*/
|
||||
class OutReportQueueTest {
|
||||
/** A report carrying a 0..255 marker so a test can tell which one came back out. */
|
||||
private fun report(marker: Int) = byteArrayOf(0x02, marker.toByte())
|
||||
|
||||
// Masked: the marker rides in a Byte, and Byte.toInt() sign-extends.
|
||||
private fun drain(q: OutReportQueue): List<Int> =
|
||||
generateSequence { q.poll() }.map { it[1].toInt() and 0xFF }.toList()
|
||||
|
||||
@Test
|
||||
fun `rumble supersedes the pending rumble instead of queueing another`() {
|
||||
val q = OutReportQueue()
|
||||
assertTrue(q.offer(report(1), OutReportQueue.KEY_RUMBLE))
|
||||
assertTrue(q.offer(report(2), OutReportQueue.KEY_RUMBLE))
|
||||
assertTrue(q.offer(report(3), OutReportQueue.KEY_RUMBLE))
|
||||
assertEquals("a rumble burst must collapse to one entry", 1, q.size)
|
||||
assertArrayEquals(report(3), q.poll())
|
||||
assertNull(q.poll())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `superseding keeps the queue position so a rumble stream cannot jump one-shots`() {
|
||||
val q = OutReportQueue()
|
||||
q.offer(report(1), OutReportQueue.KEY_RUMBLE)
|
||||
q.offer(report(10)) // a one-shot queued behind it
|
||||
q.offer(report(2), OutReportQueue.KEY_RUMBLE)
|
||||
// The newer rumble takes the OLD rumble's slot, so the one-shot does not get starved
|
||||
// behind an endlessly-renewed entry.
|
||||
assertEquals(listOf(2, 10), drain(q))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a full queue sacrifices rumble, never a one-shot`() {
|
||||
val q = OutReportQueue(cap = 4)
|
||||
q.offer(report(1), OutReportQueue.KEY_RUMBLE)
|
||||
q.offer(report(10))
|
||||
q.offer(report(11))
|
||||
q.offer(report(12))
|
||||
assertEquals(4, q.size)
|
||||
// Full. The old policy dropped the head — here that is a rumble, but only by luck of
|
||||
// ordering; what matters is that the one-shots all survive.
|
||||
assertTrue(q.offer(report(13)))
|
||||
assertEquals(listOf(10, 11, 12, 13), drain(q))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the one-shot the host never repeats survives a rumble storm`() {
|
||||
val q = OutReportQueue(cap = 4)
|
||||
// The exact regression: a lightbar colour queued once, then a flood of rumble. Under the
|
||||
// old newest-wins eviction the colour was dropped from the head and never came back,
|
||||
// leaving the pad lit wrong until the value next happened to change.
|
||||
q.offer(report(200)) // lightbar
|
||||
repeat(50) { q.offer(report(it), OutReportQueue.KEY_RUMBLE) }
|
||||
val out = drain(q)
|
||||
assertTrue("the lightbar report must still be queued, got $out", out.contains(200))
|
||||
assertEquals("rumble must not have accumulated", listOf(200, 49), out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a queue full of one-shots refuses a rumble rather than dropping one`() {
|
||||
val q = OutReportQueue(cap = 2)
|
||||
q.offer(report(10))
|
||||
q.offer(report(11))
|
||||
assertFalse(
|
||||
"with nothing coalescable to sacrifice, the replaceable report yields",
|
||||
q.offer(report(1), OutReportQueue.KEY_RUMBLE),
|
||||
)
|
||||
assertEquals(listOf(10, 11), drain(q))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only a queue of nothing but one-shots drops one, and it is the oldest`() {
|
||||
val q = OutReportQueue(cap = 2)
|
||||
q.offer(report(10))
|
||||
q.offer(report(11))
|
||||
assertTrue(q.offer(report(12)))
|
||||
assertEquals(listOf(11, 12), drain(q))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear empties the queue`() {
|
||||
val q = OutReportQueue()
|
||||
q.offer(report(1), OutReportQueue.KEY_RUMBLE)
|
||||
q.offer(report(10))
|
||||
q.clear()
|
||||
assertEquals(0, q.size)
|
||||
assertNull(q.poll())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The Kotlin half of the rumble JNI boundary. The Rust half is pinned by `pack_rumble_tests` in
|
||||
* `clients/android/native/src/feedback.rs`; the two suites describe the same layout from opposite
|
||||
* sides, which is the only thing that catches one of them drifting.
|
||||
*/
|
||||
class RumbleWireTest {
|
||||
|
||||
/** `pack_rumble` from the native side, transcribed — the packer these tests unpack. */
|
||||
private fun pack(pad: Int, low: Int, high: Int, backstopMs: Int): Long =
|
||||
((pad and 0xF).toLong() shl 49) or
|
||||
((backstopMs.coerceAtMost(0xFFFF)).toLong() shl 32) or
|
||||
(low.toLong() shl 16) or
|
||||
high.toLong()
|
||||
|
||||
@Test
|
||||
fun `every field round-trips at its extremes`() {
|
||||
val cases = listOf(
|
||||
listOf(0, 0, 0, 0),
|
||||
listOf(15, 0xFFFF, 0xFFFF, 0xFFFF),
|
||||
listOf(1, 0x1234, 0x5678, 500),
|
||||
listOf(7, 0, 0xFFFF, 2000),
|
||||
)
|
||||
for ((pad, low, high, backstop) in cases) {
|
||||
val cmd = unpackRumbleEvent(pack(pad, low, high, backstop))!!
|
||||
assertEquals("pad", pad, cmd.pad)
|
||||
assertEquals("low", low, cmd.low)
|
||||
assertEquals("high", high, cmd.high)
|
||||
assertEquals("backstop", backstop.toLong(), cmd.backstopMs)
|
||||
}
|
||||
}
|
||||
|
||||
/** MAX_PADS is 16, so all 16 indices must survive the 4-bit field without aliasing. */
|
||||
@Test
|
||||
fun `all sixteen pad indices are distinct`() {
|
||||
val seen = (0 until 16).map { unpackRumbleEvent(pack(it, 1, 2, 3))!!.pad }
|
||||
assertEquals((0 until 16).toList(), seen)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the negative sentinel is not a command`() {
|
||||
assertNull(unpackRumbleEvent(-1L))
|
||||
assertNull(unpackRumbleEvent(Long.MIN_VALUE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stop is distinguishable from a hold`() {
|
||||
val stop = unpackRumbleEvent(pack(2, 0, 0, 0))!!
|
||||
val hold = unpackRumbleEvent(pack(2, 0x8000, 0x8000, 500))!!
|
||||
assertEquals(0, stop.low)
|
||||
assertEquals(0, stop.high)
|
||||
assertNotEquals(stop, hold)
|
||||
}
|
||||
|
||||
// --- wireAmplitudeToByte (was two byte-identical private copies) ---
|
||||
|
||||
@Test
|
||||
fun `amplitude takes the high byte`() {
|
||||
assertEquals(0xFF, wireAmplitudeToByte(0xFFFF))
|
||||
assertEquals(0x80, wireAmplitudeToByte(0x8000))
|
||||
assertEquals(0x12, wireAmplitudeToByte(0x1234))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zero stays silent but a weak nonzero never does`() {
|
||||
assertEquals("only a real zero may render as silence", 0, wireAmplitudeToByte(0))
|
||||
// Everything below 0x0100 has a zero high byte — without the floor these all vanish.
|
||||
for (v in listOf(1, 0x0042, 0x00FF)) {
|
||||
assertEquals("wire $v collapsed to silence", 1, wireAmplitudeToByte(v))
|
||||
}
|
||||
assertEquals(1, wireAmplitudeToByte(0x0100)) // first value that reaches 1 on its own
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,14 @@ libc = "0.2"
|
||||
# host + Linux client use. audiopus_sys vendors libopus (pure C) and builds it static via cmake —
|
||||
# the cargo-ndk build sets LIBOPUS_STATIC=1/LIBOPUS_NO_PKG=1 so it links the bundled lib, not the host's.
|
||||
opus = "0.3"
|
||||
# Tier-A pad audio (WP9). Android's audio framework denylists the DualSense's output by VID/PID,
|
||||
# so the pad's isochronous endpoint is driven directly on the fd `UsbDeviceConnection` hands over.
|
||||
# Our own crates, developed openly because the hole they fill — isochronous USB in Rust — is an
|
||||
# ecosystem-wide one: https://github.com/unom-io/usbfs-iso
|
||||
# Pinned by revision rather than floating: this is a transport under a real-time deadline and it
|
||||
# should move when we choose to. Becomes a plain version dependency once the crates are published.
|
||||
uac-host = { git = "https://github.com/unom-io/usbfs-iso", rev = "f3de1fd62cec271d07f45664dc464f23e423e721" }
|
||||
usbfs-iso = { git = "https://github.com/unom-io/usbfs-iso", rev = "f3de1fd62cec271d07f45664dc464f23e423e721" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -12,10 +12,14 @@
|
||||
//! realtime callback and makes us own the buffer. So this client diverges deliberately to stop the
|
||||
//! Android-only crackle: (1) the callback is allocation/free-free — decoded buffers are recycled to
|
||||
//! the producer via a free-list instead of being freed on the audio thread (Android's Scudo `free`
|
||||
//! has unbounded tail latency); (2) the jitter ring is deeper (~40 ms prime / ~150 ms hard cap) and
|
||||
//! decoupled from the tiny LowLatency burst size, with de-prime hysteresis so a transient drain
|
||||
//! doesn't manufacture a silence; (3) the AAudio HW buffer is primed above its 2-burst default and
|
||||
//! grown on XRuns (Google's anti-glitch technique).
|
||||
//! has unbounded tail latency); (2) the jitter ring is deeper than the other clients' and decoupled
|
||||
//! from the tiny LowLatency burst size, with de-prime hysteresis so a transient drain doesn't
|
||||
//! manufacture a silence; (3) the AAudio HW buffer is primed above its 2-burst default and grown on
|
||||
//! XRuns (Google's anti-glitch technique).
|
||||
//!
|
||||
//! (2) is now the SHARED `punktfunk_core::audio::JitterPolicy` at `JitterTuning::AAUDIO`, which also
|
||||
//! fixed what this ring was missing: it had a hard cap but nothing that walked the depth back down,
|
||||
//! so drift and arrival bursts raised latency permanently and Android settled on its ceiling.
|
||||
|
||||
use ndk::audio::{
|
||||
AudioCallbackResult, AudioContentType, AudioDirection, AudioFormat, AudioPerformanceMode,
|
||||
@@ -34,26 +38,18 @@ const SAMPLE_RATE: i32 = 48_000;
|
||||
/// Decoded-chunk hand-off depth: 64 × 5 ms = 320 ms slack (matches the core's AUDIO_QUEUE).
|
||||
const RING_CHUNKS: usize = 64;
|
||||
|
||||
// --- Jitter-ring depths, in MILLISECONDS (scaled to interleaved-f32 samples at runtime). --------
|
||||
// The channel count is negotiated, not a compile-time const, so these are kept in ms and multiplied
|
||||
// by `ms` (interleaved-f32 samples per millisecond at the resolved layout) inside `start`.
|
||||
// Unlike the Linux client (PipeWire adaptively rate-matches the stream to the graph clock, masking
|
||||
// host↔DAC drift + a shallow ring), AAudio hands us a raw callback and we own the buffer: drift and
|
||||
// WiFi power-save bunching land as underruns/overflows = crackle. So Android runs a deliberately
|
||||
// deeper, smoothly-managed ring than Linux — keep the two clients' depths intentionally divergent.
|
||||
/// Prime/target floor: fill to ~40 ms before playing (and after a sustained drain). Deep enough to
|
||||
/// ride out WiFi arrival jitter + clock drift; the dominant Android-only anti-crackle lever.
|
||||
const PRIME_FLOOR_MS: usize = 40;
|
||||
/// Ceiling for the burst-scaled target (so a large quantum can't push the prime depth too high).
|
||||
const PRIME_CEIL_MS: usize = 80;
|
||||
/// Drop-oldest headroom above the target before trimming — a ~80 ms band swallows an arrival burst
|
||||
/// without overflowing.
|
||||
const JITTER_HEADROOM_MS: usize = 80;
|
||||
/// Hard latency bound: never let the ring exceed ~150 ms (the only thing that caps added latency).
|
||||
const HARD_CAP_MS: usize = 150;
|
||||
/// Re-prime (go silent to refill) only after this many CONSECUTIVE empty callbacks, so one transient
|
||||
/// drain doesn't manufacture a fresh 40 ms silence (the old `if ring.is_empty()` re-primed instantly).
|
||||
const DEPRIME_AFTER_CALLBACKS: u32 = 5;
|
||||
// --- Jitter-ring depths now come from the SHARED policy (`punktfunk_core::audio::JitterTuning`). --
|
||||
// They used to be four Android-only constants here. The rationale for Android being DEEPER than the
|
||||
// other clients still holds and is preserved in `JitterTuning::AAUDIO`: unlike PipeWire, which
|
||||
// adaptively rate-matches the stream to the graph clock and masks host↔DAC drift, AAudio hands us a
|
||||
// raw callback and we own the buffer, so drift and Wi-Fi power-save bunching land as
|
||||
// underruns/overflows = crackle.
|
||||
//
|
||||
// Two things changed with the move. The prime floor drops 40 ms → 25 ms, because the policy GROWS
|
||||
// the target on the devices that actually underrun instead of every device pre-paying for the worst
|
||||
// one. And the ring finally sheds: it had a hard cap but nothing that walked the depth back down, so
|
||||
// any drift or burst raised latency permanently and Android converged on its 120 ms ceiling and
|
||||
// stayed there — the "audio latency is too high" report.
|
||||
/// Throttle the AAudio XRun-driven HW-buffer grow check (cheap, but no need to poll every quantum).
|
||||
const XRUN_CHECK_EVERY: u32 = 128;
|
||||
|
||||
@@ -104,6 +100,7 @@ struct Counters {
|
||||
pcm_written: AtomicU64, // PCM frames copied out to AAudio (device clock is pulling)
|
||||
underruns: AtomicU64, // callbacks that emitted silence (ring not primed / drained)
|
||||
ring_depth: AtomicU64, // ring sample count at the last callback
|
||||
target_ms: AtomicU64, // the policy's LIVE target depth (it grows on this device's underruns)
|
||||
}
|
||||
|
||||
/// Owned by [`crate::session::SessionHandle`]: the live AAudio stream + the decode thread.
|
||||
@@ -126,10 +123,9 @@ impl AudioPlayback {
|
||||
// Interleaved f32 samples per millisecond at this layout (48 kHz × channels); the ms-
|
||||
// denominated jitter-ring depths scale by it.
|
||||
let ms = (SAMPLE_RATE as usize / 1000) * channels;
|
||||
let prime_floor = PRIME_FLOOR_MS * ms;
|
||||
let prime_ceil = PRIME_CEIL_MS * ms;
|
||||
let jitter_headroom = JITTER_HEADROOM_MS * ms;
|
||||
let hard_cap_max = HARD_CAP_MS * ms;
|
||||
let tuning = punktfunk_core::audio::JitterTuning::AAUDIO;
|
||||
// Worst transient the ring can hold before the policy trims it.
|
||||
let hard_cap_max = tuning.hard_cap_ms as usize * ms;
|
||||
let counters = Arc::new(Counters::default());
|
||||
|
||||
// One open attempt at a given sharing mode. Everything the realtime callback captures
|
||||
@@ -157,8 +153,10 @@ impl AudioPlayback {
|
||||
// `decode_loop`.
|
||||
let mut ring: VecDeque<f32> =
|
||||
VecDeque::with_capacity(hard_cap_max + RING_CHUNKS * 5 * ms);
|
||||
let mut primed = false;
|
||||
let mut empties: u32 = 0; // consecutive empty callbacks (de-prime hysteresis)
|
||||
// Shared de-jitter policy — prime depth, drift correction, de-prime hysteresis. The
|
||||
// hysteresis this replaces was Android-only; Linux and Windows carried the instant
|
||||
// `if ring.is_empty()` re-prime until now.
|
||||
let mut policy = punktfunk_core::audio::JitterPolicy::new(tuning, channels as u8);
|
||||
let mut cb_count: u32 = 0; // callbacks since open (throttles the XRun grow check)
|
||||
let mut last_xrun: i32 = 0; // last AAudio XRun count we grew the buffer for
|
||||
let callback = move |s: &AudioStream, data: *mut c_void, num_frames: i32| {
|
||||
@@ -173,21 +171,25 @@ impl AudioPlayback {
|
||||
ring.extend(chunk.drain(..));
|
||||
let _ = free_tx.try_send(chunk);
|
||||
}
|
||||
// Jitter buffer: prime to ~40 ms (prime_floor) before playing and after a sustained
|
||||
// drain; drop-oldest only above a wide ~120 ms band. Decoupled from the AAudio burst
|
||||
// `want` (tiny on the LowLatency MMAP path) so the depth doesn't collapse to a single
|
||||
// quantum.
|
||||
let target = (3 * want).clamp(prime_floor, prime_ceil);
|
||||
let hard_cap = (target + jitter_headroom).min(hard_cap_max);
|
||||
while ring.len() > hard_cap {
|
||||
ring.pop_front();
|
||||
// Jitter buffer: the shared policy decides prime/silence, trims a burst, and —
|
||||
// new here — sheds ONE crossfaded 5 ms frame when the depth average has sat above
|
||||
// target long enough to be drift rather than jitter. Without that shed this ring
|
||||
// had no way back down: it clamped at 120 ms and stayed pinned there.
|
||||
let step = policy.step(ring.len(), want);
|
||||
if step.drop_front > 0 {
|
||||
punktfunk_core::audio::crossfade_drop(
|
||||
&mut ring,
|
||||
step.drop_front,
|
||||
step.crossfade,
|
||||
);
|
||||
}
|
||||
if !primed && ring.len() >= target {
|
||||
primed = true;
|
||||
}
|
||||
if primed {
|
||||
let mut ran_short = false;
|
||||
if !step.silence {
|
||||
for slot in out.iter_mut() {
|
||||
*slot = ring.pop_front().unwrap_or(0.0);
|
||||
*slot = ring.pop_front().unwrap_or_else(|| {
|
||||
ran_short = true;
|
||||
0.0
|
||||
});
|
||||
}
|
||||
cb_counters
|
||||
.pcm_written
|
||||
@@ -196,20 +198,15 @@ impl AudioPlayback {
|
||||
out.fill(0.0);
|
||||
cb_counters.underruns.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
// Re-prime only after a RUN of empty callbacks, not a single transient one —
|
||||
// otherwise every momentary drain costs a fresh 40 ms silence (the old behaviour,
|
||||
// self-inflicted crackle on any jitter spike).
|
||||
if ring.is_empty() {
|
||||
empties += 1;
|
||||
if empties >= DEPRIME_AFTER_CALLBACKS {
|
||||
primed = false;
|
||||
}
|
||||
} else {
|
||||
empties = 0;
|
||||
}
|
||||
// No-op while un-primed, so a deliberate priming silence is never counted as an
|
||||
// underrun (which would otherwise drive the adaptive floor up for no reason).
|
||||
policy.note_read(ran_short);
|
||||
cb_counters
|
||||
.ring_depth
|
||||
.store(ring.len() as u64, Ordering::Relaxed);
|
||||
cb_counters
|
||||
.target_ms
|
||||
.store(policy.target_ms() as u64, Ordering::Relaxed);
|
||||
// Google's AAudio anti-glitch technique: when the device reports new XRuns, grow the
|
||||
// HW buffer by one burst (up to capacity). getXRunCount + setBufferSizeInFrames are
|
||||
// both callback-safe / non-blocking, and set clamps to capacity so it self-limits.
|
||||
@@ -408,10 +405,11 @@ fn decode_loop(
|
||||
}
|
||||
if count % 600 == 0 {
|
||||
log::info!(
|
||||
"audio: opus={count} pcm_frames={} underruns={} ring={} peak={window_peak:.3}",
|
||||
"audio: opus={count} pcm_frames={} underruns={} buffer_ms={} target_ms={} peak={window_peak:.3}",
|
||||
counters.pcm_written.load(Ordering::Relaxed),
|
||||
counters.underruns.load(Ordering::Relaxed),
|
||||
counters.ring_depth.load(Ordering::Relaxed),
|
||||
counters.ring_depth.load(Ordering::Relaxed) / ms.max(1) as u64,
|
||||
counters.target_ms.load(Ordering::Relaxed),
|
||||
);
|
||||
window_peak = 0.0;
|
||||
}
|
||||
|
||||
@@ -16,11 +16,13 @@ use std::time::{Duration, Instant};
|
||||
use super::display::{
|
||||
apply_hdr_dataspace, install_render_callback, release_render_callback, DisplayTracker,
|
||||
};
|
||||
use super::latency::{note_decoded_pts, now_realtime_ns, take_flags};
|
||||
use super::latency::{note_decoded_pts, now_realtime_ns, take_flags, take_stamp};
|
||||
use super::presenter::{presenter_disabled_by_sysprop, PresentMeter, PresentPriority, Presenter};
|
||||
use super::setup::{
|
||||
android_hdr_static_info, boost_hot_threads, boost_thread_priority, codec_mime,
|
||||
configure_low_latency, create_codec, try_set_frame_rate,
|
||||
};
|
||||
use super::vsync::{now_monotonic_ns, VsyncClock};
|
||||
use super::{
|
||||
DecodeOptions, FRAME_PARK_CAP, IN_FLIGHT_CAP, NO_OUTPUT_PATIENCE, NO_VIDEO_PATIENCE,
|
||||
NO_VIDEO_RETRY, PENDING_SPLIT_CAP,
|
||||
@@ -55,6 +57,8 @@ enum DecodeEvent {
|
||||
},
|
||||
/// The output format changed — re-check the stream's colour signalling (HDR DataSpace).
|
||||
FormatChanged,
|
||||
/// A panel vsync (from the [`VsyncClock`] thread) — the presenter's retry/pacing tick.
|
||||
Vsync,
|
||||
/// The codec reported an error; `fatal` when neither recoverable nor transient.
|
||||
Error { fatal: bool },
|
||||
}
|
||||
@@ -78,6 +82,9 @@ pub(super) fn run_async(
|
||||
ll_feature,
|
||||
low_latency_mode,
|
||||
is_tv,
|
||||
present_priority,
|
||||
smooth_buffer,
|
||||
panel_hz,
|
||||
} = opts;
|
||||
boost_thread_priority();
|
||||
let mode = client.mode();
|
||||
@@ -196,9 +203,33 @@ pub(super) fn run_async(
|
||||
// parked in the tracker at release; the OnFrameRendered callback pairs it with
|
||||
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
|
||||
// reclaimed after the codec is dropped below.
|
||||
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone());
|
||||
let meter = Arc::new(PresentMeter::new());
|
||||
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone(), meter.clone());
|
||||
let render_cb = install_render_callback(&codec, &tracker);
|
||||
|
||||
// The timeline presenter (see `presenter.rs`): newest-wins / smoothing store, one-in-flight
|
||||
// glass budget, timeline-timed release. `debug.punktfunk.presenter = arrival` selects the
|
||||
// legacy release-immediately path for a rebuild-free on-device A/B.
|
||||
let mut presenter = if presenter_disabled_by_sysprop() {
|
||||
log::info!("decode: presenter = arrival (sysprop) — legacy immediate release");
|
||||
None
|
||||
} else {
|
||||
let priority = PresentPriority::resolve(present_priority, smooth_buffer);
|
||||
log::info!(
|
||||
"decode: presenter = timeline ({})",
|
||||
match priority {
|
||||
PresentPriority::Latency => "lowest latency".to_string(),
|
||||
PresentPriority::Smooth { buffer } => format!("smoothness, buffer {buffer}"),
|
||||
}
|
||||
);
|
||||
Some(Presenter::new(priority))
|
||||
};
|
||||
stats.set_presenter_active(presenter.is_some());
|
||||
// The vsync clock, started LAZILY on the first decoded frame (see `vsync.rs`); its ticks ride
|
||||
// the same event channel. The Sender parks here until that moment.
|
||||
let mut vsync: Option<VsyncClock> = None;
|
||||
let mut vsync_tx = presenter.is_some().then(|| ev_tx.clone());
|
||||
|
||||
// Feeder thread: block on the network so this loop doesn't (an AU's arrival becomes an event that
|
||||
// wakes us immediately, with no input-side poll latency). It also records the `received` HUD stat.
|
||||
let feeder = {
|
||||
@@ -238,6 +269,8 @@ pub(super) fn run_async(
|
||||
|
||||
let mut free_inputs: VecDeque<usize> = VecDeque::new();
|
||||
let mut pending_aus: VecDeque<Frame> = VecDeque::new();
|
||||
// Phase-lock v3: per-AU arrival stamps for the circular arrival-lead report (drained 1 Hz).
|
||||
let mut arrival_stamps: Vec<i128> = Vec::new();
|
||||
let mut ready: Vec<OutputReady> = Vec::new();
|
||||
let mut applied_ds: Option<DataSpace> = None;
|
||||
let mut fed: u64 = 0;
|
||||
@@ -245,6 +278,12 @@ pub(super) fn run_async(
|
||||
let mut discarded: u64 = 0;
|
||||
// AUs larger than the codec input buffer, dropped whole (see `feed`/`feed_ready`).
|
||||
let mut oversized_dropped: u64 = 0;
|
||||
// Slice-progressive continuity ledger (see `PartFeed`).
|
||||
let mut part_open: Option<PartFeed> = None;
|
||||
// Queued-instant stamps (pts → realtime ns at the AU's LAST piece entering the codec) — the
|
||||
// P3 decode-split ledger: `feed` = received→queued, `codec` = queued→decoded. Always on
|
||||
// (one vDSO clock read per AU); consumed by `present_ready`.
|
||||
let mut queued_stamps: VecDeque<(u64, i128)> = VecDeque::new();
|
||||
// Freeze-until-reanchor gate (see the sync loop for the rationale). Armed on a frame-index gap
|
||||
// (the feeder's Au verdict), a parked-AU overflow drop, a dropped-count climb, or a recoverable
|
||||
// codec error; `recovery_flags` carries each AU's user_flags from `dispatch_event` (feed) to
|
||||
@@ -277,6 +316,7 @@ pub(super) fn run_async(
|
||||
};
|
||||
let work_t0 = Instant::now();
|
||||
let mut fmt_dirty = false;
|
||||
let mut vsync_tick = false;
|
||||
let mut aus_dropped: u64 = 0;
|
||||
if let Some(ev) = ev0 {
|
||||
aus_dropped += u64::from(dispatch_event(
|
||||
@@ -285,9 +325,11 @@ pub(super) fn run_async(
|
||||
&mut free_inputs,
|
||||
&mut ready,
|
||||
&mut fmt_dirty,
|
||||
&mut vsync_tick,
|
||||
&mut fatal,
|
||||
&mut gate,
|
||||
&mut recovery_flags,
|
||||
&mut arrival_stamps,
|
||||
));
|
||||
}
|
||||
// Coalesce every other event already queued into this one work pass — correct newest-only
|
||||
@@ -299,12 +341,19 @@ pub(super) fn run_async(
|
||||
&mut free_inputs,
|
||||
&mut ready,
|
||||
&mut fmt_dirty,
|
||||
&mut vsync_tick,
|
||||
&mut fatal,
|
||||
&mut gate,
|
||||
&mut recovery_flags,
|
||||
&mut arrival_stamps,
|
||||
));
|
||||
}
|
||||
stats.note_skipped(aus_dropped); // parked-AU overflow drops are client-side skips too
|
||||
if vsync_tick {
|
||||
if let Some(p) = presenter.as_mut() {
|
||||
p.on_vsync();
|
||||
}
|
||||
}
|
||||
stats.note_skipped_overflow(aus_dropped); // parked-AU overflow: skips, flagged as such
|
||||
if fmt_dirty {
|
||||
apply_hdr_dataspace(&codec, &window, &mut applied_ds);
|
||||
}
|
||||
@@ -315,8 +364,12 @@ pub(super) fn run_async(
|
||||
&mut free_inputs,
|
||||
&mut fed,
|
||||
&mut oversized_dropped,
|
||||
&mut part_open,
|
||||
&mut queued_stamps,
|
||||
&mut gate,
|
||||
);
|
||||
let had_output = !ready.is_empty();
|
||||
let rendered_before = rendered;
|
||||
present_ready(
|
||||
&codec,
|
||||
&client,
|
||||
@@ -324,16 +377,92 @@ pub(super) fn run_async(
|
||||
&mut ready,
|
||||
&stats,
|
||||
&in_flight,
|
||||
&mut queued_stamps,
|
||||
&meter,
|
||||
clock_offset.load(Ordering::Relaxed),
|
||||
&tracker,
|
||||
&mut presenter,
|
||||
&mut rendered,
|
||||
&mut discarded,
|
||||
&mut gate,
|
||||
&mut recovery_flags,
|
||||
);
|
||||
// The presenter's decision point runs EVERY pass — frame arrivals, vsync ticks and the
|
||||
// 5 ms housekeeping wake all land here, which is what reopens the glass budget on time
|
||||
// even when the choreographer clock is absent.
|
||||
if let Some(p) = presenter.as_mut() {
|
||||
let clock = vsync.as_ref().map(|v| v.shared().as_ref());
|
||||
if p.pump(&codec, clock, &tracker, &meter, &stats, now_monotonic_ns()) {
|
||||
rendered += 1;
|
||||
}
|
||||
// The 1 Hz window flush doubles as the phase-lock report tick. v3 sensor: the
|
||||
// CIRCULAR mean + coherence of the ARRIVAL lead — each AU's reassembly stamp
|
||||
// against the panel's latch grid — because arrival is the phase the host actually
|
||||
// controls; the v2 latch statistic measured downstream of the decoder pipeline,
|
||||
// which absorbed the actuation (on-glass 2026-07-31). Timestamps convert
|
||||
// monotonic→realtime→host — the skew offset lives client-side.
|
||||
if let (Some(_), Some(c)) = (p.flush_log(&meter, clock), clock) {
|
||||
let period = c.panel_period_ns().max(c.period_ns());
|
||||
if period > 0 {
|
||||
if let Some(t) = c.next_target(now_monotonic_ns(), 0) {
|
||||
let mono_now = now_monotonic_ns();
|
||||
let real_now = now_realtime_ns();
|
||||
let leads_us: Vec<u64> = arrival_stamps
|
||||
.iter()
|
||||
.map(|&r_ns| {
|
||||
let arrival_mono = mono_now as i128 - (real_now - r_ns);
|
||||
((t.expected_present_ns as i128 - arrival_mono)
|
||||
.rem_euclid(period as i128)
|
||||
/ 1000) as u64
|
||||
})
|
||||
.collect();
|
||||
arrival_stamps.clear();
|
||||
if let Some((lead_mean_ns, coherence)) =
|
||||
punktfunk_core::phase::circular_latch(&leads_us, period)
|
||||
{
|
||||
log::info!(
|
||||
target: "pf.phase",
|
||||
"arrival lead circ={:.2}ms coh={}",
|
||||
lead_mean_ns as f64 / 1e6,
|
||||
coherence
|
||||
);
|
||||
let latch_real_ns =
|
||||
real_now + (t.expected_present_ns - mono_now) as i128;
|
||||
let latch_host_ns = (latch_real_ns
|
||||
+ clock_offset.load(Ordering::Relaxed) as i128)
|
||||
.max(0) as u64;
|
||||
client.report_phase(
|
||||
latch_host_ns,
|
||||
period.clamp(0, u32::MAX as i64) as u32,
|
||||
1_000_000, // skew residual — conservative 1 ms
|
||||
lead_mean_ns.min(u32::MAX as u64) as u32,
|
||||
coherence,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let presented_now = rendered > rendered_before;
|
||||
// Start the vsync clock LAZILY on the first decoded output (eager, it ticks the panel
|
||||
// rate into a session that has no frame yet — the Apple deadline presenter's bootstrap
|
||||
// lesson). A `None` from start (no choreographer surface) simply leaves ASAP targets.
|
||||
if had_output && vsync.is_none() {
|
||||
if let Some(tx) = vsync_tx.take() {
|
||||
vsync = VsyncClock::start(
|
||||
panel_hz,
|
||||
Box::new(move || {
|
||||
let _ = tx.send(DecodeEvent::Vsync);
|
||||
}),
|
||||
);
|
||||
if vsync.is_none() {
|
||||
log::info!("decode: no choreographer clock — presenter uses ASAP targets");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
work_accum_ns += work_t0.elapsed().as_nanos() as i64;
|
||||
if had_output {
|
||||
if presented_now {
|
||||
if !hint_tried {
|
||||
hint_tried = true;
|
||||
let tids = client.hot_thread_ids();
|
||||
@@ -420,6 +549,10 @@ pub(super) fn run_async(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(p) = presenter.as_mut() {
|
||||
p.release_all(&codec); // hand every held output buffer back before the codec stops
|
||||
}
|
||||
drop(vsync); // stop + join the choreographer thread; its channel sends are harmless after
|
||||
let _ = codec.stop();
|
||||
shutdown.store(true, Ordering::SeqCst); // ensure the feeder wakes and exits, then join it
|
||||
if let Some(j) = feeder {
|
||||
@@ -448,6 +581,9 @@ fn feeder_loop(
|
||||
) {
|
||||
// Received AUs awaiting their 0xCF host timing (Phase-2 split), as (pts_ns, capture→received µs).
|
||||
let mut pending_split: VecDeque<(u64, u64)> = VecDeque::new();
|
||||
// Last logged phase-lock ACK (the host's applied capture hold, from the 0xCF tail) — logged
|
||||
// on change so `adb logcat -s pf.phase` shows the closed loop working (or not) at a glance.
|
||||
let mut last_phase_ack: Option<i32> = None;
|
||||
while !shutdown.load(Ordering::Relaxed) {
|
||||
match client.next_frame(Duration::from_millis(5)) {
|
||||
Ok(frame) => {
|
||||
@@ -455,11 +591,15 @@ fn feeder_loop(
|
||||
// invalidation request so an RFI-capable host recovers with a cheap clean P-frame
|
||||
// instead of a full IDR (the frames_dropped keyframe path is the backstop). The gap
|
||||
// verdict rides the Au event so the decode loop arms its freeze gate on the same signal.
|
||||
let gap = client.note_frame_index(frame.frame_index);
|
||||
// Slice-progressive parts repeat their AU's index — note it once, on the
|
||||
// AU's first piece (or a whole delivery), so the RFI gap detector keeps
|
||||
// counting AUs.
|
||||
let au_first = frame.part.is_none_or(|p| p.first);
|
||||
let gap = au_first && client.note_frame_index(frame.frame_index);
|
||||
// Park the receipt stamp (keyed by the pts the codec echoes) whenever the `decode`
|
||||
// stage is consumed: the HUD, or the ABR decode signal (`measure_decode`). The
|
||||
// HUD-only `received` point + host/network split stay gated on the overlay.
|
||||
if stats.enabled() || measure_decode {
|
||||
if (stats.enabled() || measure_decode) && frame.complete {
|
||||
// Core reassembly-completion stamp (ABI v9), NOT the pull instant: stamping
|
||||
// here would fold the hand-off queue wait into the network latency figure
|
||||
// (a client-side standing backlog masquerading as network). 0 = older core.
|
||||
@@ -482,7 +622,10 @@ fn feeder_loop(
|
||||
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);
|
||||
// On a parts stream the completing delivery carries only the AU's
|
||||
// suffix — its offset restores the full AU byte count for bitrate.
|
||||
let au_len = frame.part.map_or(0, |p| p.offset as usize) + frame.data.len();
|
||||
stats.note_received(au_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 {
|
||||
@@ -490,6 +633,17 @@ fn feeder_loop(
|
||||
}
|
||||
}
|
||||
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
|
||||
// Phase-lock closed-loop readout: the host's applied hold rides the
|
||||
// 0xCF tail; log transitions (~1 Hz worst case — the host updates it
|
||||
// once a second). None = a host without the tail (pre-phase-lock).
|
||||
if t.applied_phase_ns != last_phase_ack {
|
||||
log::info!(
|
||||
target: "pf.phase",
|
||||
"host applied_phase={:?}us",
|
||||
t.applied_phase_ns.map(|n| n / 1000)
|
||||
);
|
||||
last_phase_ack = t.applied_phase_ns;
|
||||
}
|
||||
if let Some(i) = pending_split.iter().position(|&(p, _)| p == t.pts_ns)
|
||||
{
|
||||
let (_, hostnet_us) = pending_split.remove(i).unwrap();
|
||||
@@ -520,9 +674,11 @@ fn dispatch_event(
|
||||
free_inputs: &mut VecDeque<usize>,
|
||||
ready: &mut Vec<OutputReady>,
|
||||
fmt_dirty: &mut bool,
|
||||
vsync_tick: &mut bool,
|
||||
fatal: &mut bool,
|
||||
gate: &mut ReanchorGate,
|
||||
recovery_flags: &mut VecDeque<(u64, u32)>,
|
||||
arrival_stamps: &mut Vec<i128>,
|
||||
) -> bool {
|
||||
match ev {
|
||||
DecodeEvent::Au(f, gap) => {
|
||||
@@ -531,9 +687,27 @@ fn dispatch_event(
|
||||
if gap {
|
||||
gate.arm(Instant::now());
|
||||
}
|
||||
recovery_flags.push_back((f.pts_ns / 1000, f.flags));
|
||||
if recovery_flags.len() > IN_FLIGHT_CAP {
|
||||
recovery_flags.pop_front();
|
||||
// One entry per AU (parts share the pts): the completing delivery carries it.
|
||||
if f.complete {
|
||||
recovery_flags.push_back((f.pts_ns / 1000, f.flags));
|
||||
if recovery_flags.len() > IN_FLIGHT_CAP {
|
||||
recovery_flags.pop_front();
|
||||
}
|
||||
}
|
||||
// Phase-lock v3 sensor: the ARRIVAL stamp (reassembly completion, realtime) — the
|
||||
// phase the host actually controls. The latch-based v2 sensor measured downstream
|
||||
// of the decoder pipeline, which absorbed the host's actuation (on-glass 07-31).
|
||||
// Phase sensor: AU completion is the arrival the host's hold actually moves —
|
||||
// prefix parts would smear the phase toward the first slice's landing.
|
||||
if f.complete {
|
||||
arrival_stamps.push(if f.received_ns > 0 {
|
||||
f.received_ns as i128
|
||||
} else {
|
||||
now_realtime_ns()
|
||||
});
|
||||
if arrival_stamps.len() > 256 {
|
||||
arrival_stamps.remove(0);
|
||||
}
|
||||
}
|
||||
pending_aus.push_back(f);
|
||||
if pending_aus.len() > FRAME_PARK_CAP {
|
||||
@@ -552,6 +726,7 @@ fn dispatch_event(
|
||||
decoded_ns,
|
||||
}),
|
||||
DecodeEvent::FormatChanged => *fmt_dirty = true,
|
||||
DecodeEvent::Vsync => *vsync_tick = true,
|
||||
DecodeEvent::Error { fatal: f } => {
|
||||
if f {
|
||||
*fatal = true;
|
||||
@@ -565,10 +740,36 @@ fn dispatch_event(
|
||||
false
|
||||
}
|
||||
|
||||
/// `AMEDIACODEC_BUFFER_FLAG_PARTIAL_FRAME` (NDK ≥ 26, gated by the Kotlin
|
||||
/// `FEATURE_PartialFrame` probe): this input buffer is a PIECE of an AU — the codec assembles
|
||||
/// pieces until a buffer WITHOUT the flag closes the AU.
|
||||
const BUFFER_FLAG_PARTIAL_FRAME: u32 = 8;
|
||||
|
||||
/// The slice-progressive feed's open access unit: parts already queued into the codec under
|
||||
/// [`BUFFER_FLAG_PARTIAL_FRAME`], awaiting the rest. Loop-local — a codec rebuild tears the
|
||||
/// whole loop down, so the state can never outlive the codec instance it fed.
|
||||
pub(super) struct PartFeed {
|
||||
index: u32,
|
||||
/// The AU byte offset the next part must carry — a mismatch means the hand-off dropped a
|
||||
/// piece (memory cap / jump-to-live clear) and the AU is unrecoverable.
|
||||
expected: usize,
|
||||
/// The dead-close pts: an abandoned AU is CLOSED with an empty non-PARTIAL buffer at its
|
||||
/// own pts — the codec then emits (concealed garbage) at that pts, which the reanchor
|
||||
/// freeze gate withholds from glass while the keyframe request recovers the chain. No
|
||||
/// mid-stream codec flush needed.
|
||||
pts_us: u64,
|
||||
}
|
||||
|
||||
/// Queue as many parked AUs as there are free input buffer slots (async mode: the indices come from
|
||||
/// `InputAvailable` callbacks, not a dequeue). Each AU is copied into its codec input buffer and
|
||||
/// submitted; an AU larger than the buffer is DROPPED (+ a recovery keyframe requested) — a
|
||||
/// truncated AU is corrupt input the decoder chews on silently, poisoning the reference chain.
|
||||
///
|
||||
/// Slice-progressive deliveries ([`Frame::part`]) feed as they arrive: every piece rides
|
||||
/// [`BUFFER_FLAG_PARTIAL_FRAME`] except the AU's last, all at the AU's pts. `part_open` is the
|
||||
/// continuity ledger — any break (gap, orphan, oversize) abandons the AU per [`PartFeed::pts_us`]'s
|
||||
/// close contract and re-syncs at the next `first`.
|
||||
#[allow(clippy::too_many_arguments)] // one call site; the split ledger threads through like the gate
|
||||
fn feed_ready(
|
||||
codec: &MediaCodec,
|
||||
client: &NativeClient,
|
||||
@@ -576,14 +777,66 @@ fn feed_ready(
|
||||
free_inputs: &mut VecDeque<usize>,
|
||||
fed: &mut u64,
|
||||
oversized_dropped: &mut u64,
|
||||
part_open: &mut Option<PartFeed>,
|
||||
queued_stamps: &mut VecDeque<(u64, i128)>,
|
||||
gate: &mut ReanchorGate,
|
||||
) {
|
||||
while !pending_aus.is_empty() && !free_inputs.is_empty() {
|
||||
let idx = free_inputs.pop_front().unwrap();
|
||||
let frame = pending_aus.pop_front().unwrap();
|
||||
let pts_us = frame.pts_ns / 1000;
|
||||
let (first, last, offset) = match frame.part {
|
||||
None => (true, true, 0usize),
|
||||
Some(p) => (p.first, p.last, p.offset as usize),
|
||||
};
|
||||
// Continuity ledger. `continues` = this piece extends the open AU exactly;
|
||||
// anything else with an AU open means that AU died mid-flight and must be closed
|
||||
// (empty non-PARTIAL buffer at ITS pts) before this frame may touch the codec.
|
||||
let continues = part_open
|
||||
.as_ref()
|
||||
.is_some_and(|o| frame.frame_index == o.index && offset == o.expected && !first);
|
||||
if !continues {
|
||||
if let Some(o) = part_open.take() {
|
||||
// Spend THIS slot on the close; the current frame re-queues for the next one.
|
||||
if let Err(e) = codec.queue_input_buffer_by_index(idx, 0, 0, o.pts_us, 0) {
|
||||
log::warn!("decode: close of abandoned partial AU {}: {e}", o.index);
|
||||
}
|
||||
log::warn!(
|
||||
"decode: partial AU {} abandoned mid-feed — closed empty, requesting keyframe",
|
||||
o.index
|
||||
);
|
||||
// The close makes the codec emit concealed garbage at the dead pts — freeze it
|
||||
// off the glass until the recovery keyframe re-anchors.
|
||||
gate.arm(Instant::now());
|
||||
let _ = client.request_keyframe();
|
||||
pending_aus.push_front(frame);
|
||||
continue;
|
||||
}
|
||||
// No AU open: an orphan non-first piece lost its head upstream — discard and
|
||||
// re-sync at the next `first` (the recovery request rides the same loss).
|
||||
if !first {
|
||||
free_inputs.push_front(idx);
|
||||
gate.arm(Instant::now());
|
||||
let _ = client.request_keyframe();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let Some(dst) = codec.input_buffer(idx) else {
|
||||
log::warn!("decode: input_buffer({idx}) returned None — dropping AU");
|
||||
continue;
|
||||
// Nothing was written and nothing was queued, so BOTH stay ours. Dropping the slot
|
||||
// here leaked one of the codec's input buffers per occurrence — we forget it and the
|
||||
// codec never frees what it never received, so the pipeline quietly runs out of input
|
||||
// slots, `pending_aus` overflows, and the resulting drop storm reads as a decode
|
||||
// fault. Dropping the AU on top of that punched a hole in the reference chain with no
|
||||
// keyframe request behind it, unlike every sibling path here.
|
||||
//
|
||||
// `break`, not `continue`: a codec that cannot hand out an input buffer it just
|
||||
// advertised is in no state to be fed the rest of the parked queue this pass, and
|
||||
// retrying the same index against every parked AU would burn the whole backlog. The
|
||||
// loop re-runs within the housekeeping wake (≤ 5 ms) if it was transient.
|
||||
log::warn!("decode: input_buffer({idx}) returned None — retrying next pass");
|
||||
free_inputs.push_front(idx);
|
||||
pending_aus.push_front(frame);
|
||||
break;
|
||||
};
|
||||
let au = &frame.data;
|
||||
if au.len() > dst.len() {
|
||||
@@ -597,6 +850,16 @@ fn feed_ready(
|
||||
*oversized_dropped
|
||||
);
|
||||
let _ = client.request_keyframe();
|
||||
if frame.part.is_some() {
|
||||
gate.arm(Instant::now());
|
||||
// Pieces already queued can't be unqueued: poison the ledger so the next
|
||||
// delivery mismatches and takes the close-empty path above.
|
||||
*part_open = Some(PartFeed {
|
||||
index: frame.frame_index,
|
||||
expected: usize::MAX,
|
||||
pts_us,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let n = au.len();
|
||||
@@ -605,21 +868,51 @@ fn feed_ready(
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(au.as_ptr(), dst.as_mut_ptr().cast::<u8>(), n);
|
||||
}
|
||||
if let Err(e) = codec.queue_input_buffer_by_index(idx, 0, n, pts_us, 0) {
|
||||
let flags = if last { 0 } else { BUFFER_FLAG_PARTIAL_FRAME };
|
||||
if let Err(e) = codec.queue_input_buffer_by_index(idx, 0, n, pts_us, flags) {
|
||||
log::warn!("decode: queue_input_buffer_by_index: {e}");
|
||||
if frame.part.is_some() && !last {
|
||||
// The piece never reached the codec — same unrecoverable-AU shape as oversize.
|
||||
*part_open = Some(PartFeed {
|
||||
index: frame.frame_index,
|
||||
expected: usize::MAX,
|
||||
pts_us,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
*fed += 1;
|
||||
// `fed` counts ACCESS UNITS toward the HUD's fed/decoded balance — the closing
|
||||
// piece (or a whole AU) bumps it. The queued stamp marks the same instant (the AU
|
||||
// is fully in the codec's hands): the P3 decode split measures `codec` from here,
|
||||
// so a slice-progressive head start shows up as codec-pure shrink.
|
||||
if last {
|
||||
*fed += 1;
|
||||
queued_stamps.push_back((pts_us, now_realtime_ns()));
|
||||
if queued_stamps.len() > IN_FLIGHT_CAP {
|
||||
queued_stamps.pop_front(); // stale — codec never echoed it back
|
||||
}
|
||||
}
|
||||
*part_open = if last {
|
||||
None
|
||||
} else {
|
||||
Some(PartFeed {
|
||||
index: frame.frame_index,
|
||||
expected: offset + n,
|
||||
pts_us,
|
||||
})
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Present only the NEWEST ready output (render = true) and release the rest without rendering — a
|
||||
/// burst of stale frames on glass is worse than skipping to the freshest (the sync loop's newest-ready
|
||||
/// policy, callback-driven). Every dequeued buffer, rendered or not, is the HUD's `decoded`
|
||||
/// measurement point (it finished decoding either way); samples are recorded in pts order so the
|
||||
/// receipt-map eviction stays monotonic. The presented frame's `(pts, decoded stamp)` is parked in
|
||||
/// `tracker` for the OnFrameRendered callback — the `display` stage's other endpoint. `ready` is
|
||||
/// drained.
|
||||
/// Route the ready outputs toward glass, recording each one's decode-split + e2e first. With the
|
||||
/// timeline presenter (default): fold each output
|
||||
/// through the re-anchor gate in pts order, hand the approved ones to the presenter's store
|
||||
/// (newest-wins / smoothing FIFO — the actual release happens in `Presenter::pump`, budgeted and
|
||||
/// timeline-timed), and release withheld concealment unrendered. Legacy (`arrival` sysprop):
|
||||
/// present only the NEWEST ready output immediately and release the rest unrendered — the
|
||||
/// original policy. Every dequeued buffer, rendered or not, is the HUD's `decoded` measurement
|
||||
/// point (it finished decoding either way); samples are recorded in pts order so the receipt-map
|
||||
/// eviction stays monotonic. `ready` is drained.
|
||||
#[allow(clippy::too_many_arguments)] // one call site; mirrors the sync loop's drain
|
||||
fn present_ready(
|
||||
codec: &MediaCodec,
|
||||
@@ -628,8 +921,11 @@ fn present_ready(
|
||||
ready: &mut Vec<OutputReady>,
|
||||
stats: &crate::stats::VideoStats,
|
||||
in_flight: &Mutex<VecDeque<(u64, i128)>>,
|
||||
queued_stamps: &mut VecDeque<(u64, i128)>,
|
||||
meter: &PresentMeter,
|
||||
clock_offset: i64,
|
||||
tracker: &DisplayTracker,
|
||||
presenter: &mut Option<Presenter>,
|
||||
rendered: &mut u64,
|
||||
discarded: &mut u64,
|
||||
gate: &mut ReanchorGate,
|
||||
@@ -638,50 +934,85 @@ fn present_ready(
|
||||
if ready.is_empty() {
|
||||
return;
|
||||
}
|
||||
// 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 {
|
||||
// Pair each output's decode stage (the ABR decode signal + the HUD histogram consume the
|
||||
// receipt map; the P3 split's codec-pure half needs only the queued stamp, so it records
|
||||
// even with both off — that keeps the 1 Hz pf.present mirror HUD-off readable).
|
||||
{
|
||||
let want_stage = stats.enabled() || measure_decode;
|
||||
let mut g = in_flight
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
for o in ready.iter() {
|
||||
note_decoded_pts(
|
||||
client,
|
||||
measure_decode,
|
||||
stats,
|
||||
&mut g,
|
||||
clock_offset,
|
||||
o.pts_us,
|
||||
o.decoded_ns,
|
||||
);
|
||||
let received_ns = if want_stage {
|
||||
note_decoded_pts(
|
||||
client,
|
||||
measure_decode,
|
||||
stats,
|
||||
&mut g,
|
||||
clock_offset,
|
||||
o.pts_us,
|
||||
o.decoded_ns,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let queued = take_stamp(queued_stamps, o.pts_us);
|
||||
let codec_us = queued.map(|q| ((o.decoded_ns - q).max(0) / 1000) as u64);
|
||||
let feed_us = match (queued, received_ns) {
|
||||
(Some(q), Some(r)) => Some(((q - r).max(0) / 1000) as u64),
|
||||
_ => None,
|
||||
};
|
||||
// Always-on e2e for the 1 Hz pf.present mirror (same formula + clamp as the HUD's
|
||||
// capture→decoded headline in `note_decoded_pts`).
|
||||
let e2e_ns = o.decoded_ns + clock_offset as i128 - o.pts_us as i128 * 1000;
|
||||
let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64);
|
||||
meter.note_decode(feed_us, codec_us, e2e_us);
|
||||
if let Some(c) = codec_us {
|
||||
stats.note_decode_split(feed_us, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fold EVERY output through the gate in pts (== decode) order — even the ones newest-wins discards —
|
||||
// so the two-mark re-anchor count stays correct; the newest's verdict decides whether it reaches
|
||||
// glass (`false` = withheld concealment; the SurfaceView keeps the last rendered frame frozen on).
|
||||
// so the two-mark re-anchor count stays correct; a `false` verdict is withheld concealment (the
|
||||
// SurfaceView keeps the last rendered frame frozen on).
|
||||
let now = Instant::now();
|
||||
let last = ready.len() - 1;
|
||||
let mut skipped: u64 = 0;
|
||||
for (i, o) in ready.drain(..).enumerate() {
|
||||
let flags = take_flags(recovery_flags, o.pts_us);
|
||||
let present = gate.on_decoded(flags, false, now) == GateVerdict::Present;
|
||||
let render = i == last && present;
|
||||
match codec.release_output_buffer_by_index(o.index, render) {
|
||||
Ok(()) if render => {
|
||||
*rendered += 1;
|
||||
if stats.enabled() {
|
||||
tracker.note_rendered(o.pts_us, o.decoded_ns);
|
||||
if let Some(p) = presenter.as_mut() {
|
||||
for o in ready.drain(..) {
|
||||
let flags = take_flags(recovery_flags, o.pts_us);
|
||||
if gate.on_decoded(flags, false, now) == GateVerdict::Present {
|
||||
let dropped = p.submit(codec, o.index, o.pts_us, o.decoded_ns);
|
||||
skipped += dropped;
|
||||
*discarded += dropped;
|
||||
} else {
|
||||
if let Err(e) = codec.release_output_buffer_by_index(o.index, false) {
|
||||
log::warn!("decode: release_output_buffer_by_index({}): {e}", o.index);
|
||||
}
|
||||
}
|
||||
Ok(()) => {
|
||||
*discarded += 1;
|
||||
skipped += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"decode: release_output_buffer_by_index({}, {render}): {e}",
|
||||
o.index
|
||||
)
|
||||
}
|
||||
} else {
|
||||
let last = ready.len() - 1;
|
||||
for (i, o) in ready.drain(..).enumerate() {
|
||||
let flags = take_flags(recovery_flags, o.pts_us);
|
||||
let present = gate.on_decoded(flags, false, now) == GateVerdict::Present;
|
||||
let render = i == last && present;
|
||||
match codec.release_output_buffer_by_index(o.index, render) {
|
||||
Ok(()) if render => {
|
||||
*rendered += 1;
|
||||
tracker.note_rendered(o.pts_us, o.decoded_ns, now_realtime_ns());
|
||||
}
|
||||
Ok(()) => {
|
||||
*discarded += 1;
|
||||
skipped += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"decode: release_output_buffer_by_index({}, {render}): {e}",
|
||||
o.index
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,32 +35,37 @@ pub(super) struct DisplayTracker {
|
||||
/// loaded per callback so mid-stream re-syncs apply. Holding the handle (not the client)
|
||||
/// keeps the leaked render-callback refcount from pinning the whole session alive.
|
||||
clock_offset: Arc<AtomicI64>,
|
||||
/// `(pts_us, decoded_real_ns)` of frames released with `render = true`, in release order,
|
||||
/// awaiting their callback. Pushes are HUD-gated by the caller, so this stays empty (and the
|
||||
/// callback early-outs) while the overlay is hidden.
|
||||
rendered: Mutex<VecDeque<(u64, i128)>>,
|
||||
/// Always-on latch/display accumulator for the presenter's 1 Hz `pf-present` line —
|
||||
/// independent of the HUD gate, so a HUD-off A/B stays measurable from logcat.
|
||||
meter: Arc<super::presenter::PresentMeter>,
|
||||
/// `(pts_us, decoded_real_ns, released_real_ns)` of frames released with `render = true`, in
|
||||
/// release order, awaiting their callback. Pushed on EVERY render (no HUD gate — the ring is
|
||||
/// a 64-tuple bound and the latch metric wants to exist when nobody is watching).
|
||||
rendered: Mutex<VecDeque<(u64, i128, i128)>>,
|
||||
}
|
||||
|
||||
impl DisplayTracker {
|
||||
pub(super) fn new(
|
||||
stats: Arc<crate::stats::VideoStats>,
|
||||
clock_offset: Arc<AtomicI64>,
|
||||
meter: Arc<super::presenter::PresentMeter>,
|
||||
) -> Arc<DisplayTracker> {
|
||||
Arc::new(DisplayTracker {
|
||||
stats,
|
||||
clock_offset,
|
||||
meter,
|
||||
rendered: Mutex::new(VecDeque::new()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Park one just-rendered frame's `(pts, decoded stamp)` for the render callback to pair.
|
||||
/// Caller gates on the HUD being visible.
|
||||
pub(super) fn note_rendered(&self, pts_us: u64, decoded_ns: i128) {
|
||||
/// Park one just-rendered frame's `(pts, decoded stamp, release stamp)` for the render
|
||||
/// callback to pair — the release stamp is the latch metric's start (release→displayed).
|
||||
pub(super) fn note_rendered(&self, pts_us: u64, decoded_ns: i128, released_ns: i128) {
|
||||
let mut g = self
|
||||
.rendered
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
g.push_back((pts_us, decoded_ns));
|
||||
g.push_back((pts_us, decoded_ns, released_ns));
|
||||
if g.len() > RENDERED_CAP {
|
||||
g.pop_front(); // render callbacks stopped coming (allowed under load) — evict
|
||||
}
|
||||
@@ -152,36 +157,42 @@ unsafe extern "C" fn on_frame_rendered(
|
||||
// `Arc::into_raw` pointer from `install_render_callback`, whose refcount is held for as long as
|
||||
// the codec exists, and the codec is what delivers this call.
|
||||
let t = unsafe { &*(userdata as *const DisplayTracker) };
|
||||
if !t.stats.enabled() {
|
||||
return; // HUD hidden — the ring is empty too (pushes are caller-gated)
|
||||
}
|
||||
let displayed_ns = now_realtime_ns() - (now_monotonic_ns() - system_nano as i128);
|
||||
let pts_us = media_time_us.max(0) as u64;
|
||||
// Pair the frame back to its release record, evicting older entries (their callbacks were
|
||||
// dropped by the platform, or the entry predates a HUD toggle) — same monotonic-eviction
|
||||
// discipline as `note_decoded_pts`.
|
||||
let mut decoded_ns = None;
|
||||
// dropped by the platform) — same monotonic-eviction discipline as `note_decoded_pts`.
|
||||
let mut paired = None;
|
||||
{
|
||||
let mut g = t
|
||||
.rendered
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
while let Some(&(p, d)) = g.front() {
|
||||
while let Some(&(p, d, r)) = g.front() {
|
||||
if p > pts_us {
|
||||
break; // future frame — leave it for its own callback
|
||||
}
|
||||
g.pop_front();
|
||||
if p == pts_us {
|
||||
decoded_ns = Some(d);
|
||||
paired = Some((d, r));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clamped to (0, 10 s) like the e2e sample: a vendor's first render callbacks can carry a
|
||||
// garbage `system_nano` (observed on-glass: an epoch-sized latch max on the session's first
|
||||
// window), and one such sample would poison every max/percentile it lands in.
|
||||
let clamp = |v: i128| (v > 0 && v < 10_000_000_000).then_some((v / 1000) as u64);
|
||||
let display_us = paired.and_then(|(d, _)| clamp(displayed_ns - d));
|
||||
let latch_us = paired.and_then(|(_, r)| clamp(displayed_ns - r));
|
||||
// Always-on half: the presenter's pf-present line reads these with the HUD off.
|
||||
t.meter.note_latch(latch_us);
|
||||
if !t.stats.enabled() {
|
||||
return; // HUD hidden — skip the skew math + the stats lock
|
||||
}
|
||||
let e2e_ns =
|
||||
displayed_ns + t.clock_offset.load(Ordering::Relaxed) 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 display_us = decoded_ns.map(|d| ((displayed_ns - d).max(0) / 1000) as u64);
|
||||
t.stats.note_displayed(e2e_us, display_us);
|
||||
t.stats.note_displayed(e2e_us, display_us, latch_us);
|
||||
}
|
||||
|
||||
/// React to an output-format change by signalling the stream's HDR dataspace on the Surface (SDR
|
||||
|
||||
@@ -20,7 +20,8 @@ pub(super) fn now_realtime_ns() -> i128 {
|
||||
/// entries older than it are evicted (decode order == input order here — low-latency, no
|
||||
/// B-frames — so anything before it was dropped inside the codec or stamped before a flush).
|
||||
/// `decoded_ns` is the availability instant: the dequeue (sync loop) or the output callback's
|
||||
/// stamp (async loop).
|
||||
/// stamp (async loop). Returns the receipt stamp it paired (if any) so the caller can split the
|
||||
/// `decode` stage further (feed wait vs codec-pure) without re-walking the map.
|
||||
pub(super) fn note_decoded_pts(
|
||||
client: &NativeClient,
|
||||
measure_decode: bool,
|
||||
@@ -29,7 +30,7 @@ pub(super) fn note_decoded_pts(
|
||||
clock_offset: i64,
|
||||
pts_us: u64,
|
||||
decoded_ns: i128,
|
||||
) {
|
||||
) -> Option<i128> {
|
||||
// Pair the echoed pts back to its receipt stamp, evicting stale (older) entries as we go.
|
||||
let mut received_ns = None;
|
||||
while let Some(&(p, r)) = in_flight.front() {
|
||||
@@ -61,6 +62,24 @@ pub(super) fn note_decoded_pts(
|
||||
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);
|
||||
}
|
||||
received_ns
|
||||
}
|
||||
|
||||
/// The queued-instant stamp for a decoded output, keyed by the echoed `presentationTimeUs` — the
|
||||
/// same monotonic evict-as-you-go pairing as [`take_flags`], over an `(pts_us, realtime_ns)` map
|
||||
/// (the feed side stamps each AU as its last piece enters the codec). A miss returns `None` —
|
||||
/// the split is simply not recorded for that frame.
|
||||
pub(super) fn take_stamp(map: &mut VecDeque<(u64, i128)>, pts_us: u64) -> Option<i128> {
|
||||
while let Some(&(p, t)) = map.front() {
|
||||
if p > pts_us {
|
||||
break; // future frame — leave it for its own output buffer
|
||||
}
|
||||
map.pop_front();
|
||||
if p == pts_us {
|
||||
return Some(t);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The AU `user_flags` for a decoded output, keyed by the echoed `presentationTimeUs`. Recovery
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
mod async_loop;
|
||||
mod display;
|
||||
mod latency;
|
||||
mod presenter;
|
||||
mod setup;
|
||||
mod sync_loop;
|
||||
mod vsync;
|
||||
|
||||
use async_loop::run_async;
|
||||
pub(crate) use setup::{codec_label, codec_mime};
|
||||
@@ -106,6 +108,22 @@ pub(crate) struct DecodeOptions {
|
||||
/// TV form factor (Kotlin's `UiModeManager`): actively drive the HDMI output into the stream's
|
||||
/// refresh mode, vs. the softer seamless hint on a phone/tablet.
|
||||
pub is_tv: bool,
|
||||
/// The user's presentation intent (`present_priority` setting): 0 = lowest latency
|
||||
/// (newest-wins), 1 = smoothness (a small FIFO). Resolved by
|
||||
/// [`presenter::PresentPriority::resolve`]; anything else = latency.
|
||||
pub present_priority: i32,
|
||||
/// The smoothness buffer depth (`smooth_buffer` setting): 0 = automatic (2), else 1..=3.
|
||||
/// Only meaningful with `present_priority` = smooth.
|
||||
pub smooth_buffer: i32,
|
||||
/// SEED for the panel's refresh period — the latch grid the presenter subdivides onto when
|
||||
/// the app's choreographer stream is down-rated below the panel (see `vsync.rs`). Kotlin
|
||||
/// resolves it from the display mode TABLE (`MainActivity.streamPanelFps`), not
|
||||
/// `display.refreshRate`, which reports a per-uid override rather than the panel. 0 = unknown.
|
||||
///
|
||||
/// ⚠ Only a seed: `preferredDisplayModeId` is a REQUEST the system may refuse, so the mode
|
||||
/// named here is not necessarily the one the panel ends up in. The measured timeline spacing
|
||||
/// corrects it in both directions ([`punktfunk_core::phase::PanelGrid`]).
|
||||
pub panel_hz: i32,
|
||||
}
|
||||
|
||||
/// The decode entry point on the `pf-decode` thread: dispatches to the async or synchronous loop.
|
||||
|
||||
@@ -0,0 +1,673 @@
|
||||
//! The timeline presenter — Android's port of the Apple client's stage-4 deadline discipline
|
||||
//! (`clients/apple/.../Stage2Pipeline.swift`, the `.deadline` pacing):
|
||||
//!
|
||||
//! * a **newest-wins slot** (or a small smoothing FIFO, by user intent) between decode and
|
||||
//! release, so a burst coalesces in the app — as an explicit, counted drop — instead of
|
||||
//! queueing behind the display;
|
||||
//! * a **glass budget of one**: at most one undisplayed release in flight to SurfaceFlinger,
|
||||
//! reopened on the clock-predicted latch (with a 100 ms stale force-open as the liveness
|
||||
//! backstop, mirroring Apple's `PresentGate.staleAfter`), and bounded underneath by what
|
||||
//! `OnFrameRendered` actually confirmed reached glass ([`UNDISPLAYED_CAP`]) — because the
|
||||
//! prediction is only as good as the panel grid behind it, and 0.23.0 shipped a grid that
|
||||
//! could be wrong in one direction forever;
|
||||
//! * a **timed release**: `AMediaCodec_releaseOutputBufferAtTime` targeting the platform's own
|
||||
//! frame timeline (API 33+, via [`super::vsync`]), so the latch phase is deterministic instead
|
||||
//! of inheriting network + decode jitter. On the 31/32 fallback the release is ASAP —
|
||||
//! identical to the legacy path — and only the budget prediction uses the measured period.
|
||||
//!
|
||||
//! The legacy behaviour (release the newest ready buffer immediately, unbudgeted) remains
|
||||
//! selectable at runtime: `adb shell setprop debug.punktfunk.presenter arrival` — the on-device
|
||||
//! A/B needs no rebuild. The user-facing escape hatch stays the "Low-latency mode" master toggle
|
||||
//! (off = the synchronous pre-overhaul loop, no presenter at all).
|
||||
|
||||
use ndk::media::media_codec::MediaCodec;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Instant;
|
||||
|
||||
use super::display::DisplayTracker;
|
||||
use super::latency::now_realtime_ns;
|
||||
use super::vsync::VsyncShared;
|
||||
|
||||
/// Submit-margin ahead of a timeline's EXPECTED PRESENT — SurfaceFlinger's own latch lead: the
|
||||
/// released buffer must be in the BufferQueue by SF's wakeup for that vsync (a few ms before
|
||||
/// present). A present closer than this is treated as missed and the next one is targeted. This
|
||||
/// is deliberately NOT the timeline's `deadline` (which budgets for GPU rendering a video
|
||||
/// buffer doesn't do — see `VsyncShared::next_target`); a too-tight gamble here presents one
|
||||
/// vsync later, the exact cost the deadline gate paid on every frame.
|
||||
///
|
||||
/// 2.5 ms: SF's latch runs ~1-2 ms before present on modern devices (its `sfOffset`), and the
|
||||
/// release itself is a binder call well under a ms. 4 ms measured latch p50 8-10; each ms cut
|
||||
/// here is a ms off every frame's display stage. A device that misses at the live margin shows it
|
||||
/// as a measured latch beyond one panel period (see the adaptation in
|
||||
/// [`Presenter::flush_log`]) — that, not a drop counter, is the signal to widen.
|
||||
const LATCH_MARGIN_NS: i64 = 2_500_000;
|
||||
|
||||
/// `debug.punktfunk.latch_margin_us` (0..=8000 µs): PIN the submit margin for a sweep —
|
||||
/// setprop + stream restart, no rebuild. Unset/invalid = `None` = the adaptive default.
|
||||
fn latch_margin_ns() -> Option<i64> {
|
||||
let mut buf = [0u8; 92]; // PROP_VALUE_MAX
|
||||
// SAFETY: __system_property_get with a valid name + PROP_VALUE_MAX buffer is always safe.
|
||||
let n = unsafe {
|
||||
libc::__system_property_get(
|
||||
c"debug.punktfunk.latch_margin_us".as_ptr(),
|
||||
buf.as_mut_ptr().cast(),
|
||||
)
|
||||
};
|
||||
if n > 0 {
|
||||
if let Ok(us) = std::str::from_utf8(&buf[..n as usize])
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.parse::<i64>()
|
||||
{
|
||||
if (0..=8_000).contains(&us) {
|
||||
return Some(us * 1_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The budget's liveness backstop: a release whose predicted latch never seems to arrive
|
||||
/// (clock glitch, mode switch) force-reopens the budget this long after the release, counted in
|
||||
/// `forced` — reads 0 on healthy systems (Apple's `PresentGate.staleAfter`, same value).
|
||||
const STALE_REOPEN_NS: i64 = 100_000_000;
|
||||
|
||||
/// Releases still unconfirmed by `OnFrameRendered` at which the presenter stops handing
|
||||
/// SurfaceFlinger more work.
|
||||
///
|
||||
/// The reopen above is a PREDICTION off the learned panel grid. A grid finer than the panel
|
||||
/// (0.23.0 could pin one permanently — see [`punktfunk_core::phase::PanelGrid`]) reopens the
|
||||
/// budget before the display has consumed anything, and the presenter then releases faster than
|
||||
/// the panel scans: the BufferQueue fills, MediaCodec runs out of output buffers, the decoder
|
||||
/// stalls, and the no-output backstop starts begging for keyframes. The render callback is the
|
||||
/// ground truth about what actually reached glass, so it bounds the prediction.
|
||||
///
|
||||
/// Six, not one: the platform is explicitly allowed to deliver these callbacks BATCHED, and this
|
||||
/// module's own `RENDERED_CAP` note records them trailing a release by a vsync or two — so a
|
||||
/// healthy device sits at 1-3 outstanding and a tight cap would throttle it for nothing (a held
|
||||
/// frame in the newest-wins slot is a DROPPED frame the moment a fresher one decodes). This is
|
||||
/// not a pacing knob; it is the "something is structurally wrong" rail, and a presenter genuinely
|
||||
/// out-running its display climbs past any fixed cap within a second. If a device's BufferQueue
|
||||
/// is shallower than this the rail simply never engages and the no-output backstop handles it,
|
||||
/// exactly as before — best-effort, never worse than not having it.
|
||||
const UNDISPLAYED_CAP: i32 = 6;
|
||||
|
||||
/// Fallback latch-prediction period while the vsync clock is unmeasured/absent: one 120 Hz frame.
|
||||
const FALLBACK_PERIOD_NS: i64 = 8_333_333;
|
||||
|
||||
/// The user's presentation intent — the Apple client's `PresentPriority`, same resolution rules:
|
||||
/// anything but an explicit "smooth" is latency; a smooth buffer outside 1..=3 becomes 2.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub(crate) enum PresentPriority {
|
||||
/// Newest-wins, release the instant the budget opens. The default.
|
||||
Latency,
|
||||
/// A small FIFO (1..=3 frames) drained one per vsync: jitter absorbed at one refresh of
|
||||
/// added display latency per slot, which the metrics show rather than hide.
|
||||
Smooth { buffer: usize },
|
||||
}
|
||||
|
||||
impl PresentPriority {
|
||||
/// From the JNI ints (`presentPriority` 0 = latency / 1 = smooth; `smoothBuffer` 0 = auto).
|
||||
pub(crate) fn resolve(priority: i32, buffer: i32) -> PresentPriority {
|
||||
if priority != 1 {
|
||||
return PresentPriority::Latency;
|
||||
}
|
||||
let b = if (1..=3).contains(&buffer) {
|
||||
buffer as usize
|
||||
} else {
|
||||
2
|
||||
};
|
||||
PresentPriority::Smooth { buffer: b }
|
||||
}
|
||||
}
|
||||
|
||||
/// One decoded output buffer held for presentation.
|
||||
struct HeldFrame {
|
||||
index: usize,
|
||||
pts_us: u64,
|
||||
/// The output callback's `CLOCK_REALTIME` stamp — the pace metric's start (decoded→release).
|
||||
decoded_ns: i128,
|
||||
}
|
||||
|
||||
/// The one-in-flight glass budget.
|
||||
struct InFlight {
|
||||
/// Monotonic instant the budget reopens: the release target's expected present (clock), or
|
||||
/// `release + period` on the fallback path.
|
||||
reopen_at_ns: i64,
|
||||
released_at_ns: i64,
|
||||
}
|
||||
|
||||
/// Latch samples + display confirms recorded by the `OnFrameRendered` callback thread, drained by
|
||||
/// the presenter's 1 Hz `pf-present` line. Always on (independent of the HUD) — this is what makes
|
||||
/// a HUD-off wireless A/B readable from logcat.
|
||||
pub(super) struct PresentMeter {
|
||||
inner: Mutex<PresentMeterInner>,
|
||||
/// Frames released to SurfaceFlinger that `OnFrameRendered` has not yet confirmed reached
|
||||
/// glass. The presenter's structural rail (see [`UNDISPLAYED_CAP`]) and the pf-present line's
|
||||
/// queue-depth readout. Lock-free because the release side runs on the decode loop and the
|
||||
/// confirm side on the codec's callback thread, once per frame each.
|
||||
undisplayed: AtomicI32,
|
||||
/// This device delivers render callbacks at all (API ≥ 33 and the platform accepted the
|
||||
/// registration). Until one arrives, `undisplayed` is meaningless and the rail stays down.
|
||||
confirms: AtomicBool,
|
||||
}
|
||||
|
||||
struct PresentMeterInner {
|
||||
latch_us: Vec<u64>,
|
||||
displays: u64,
|
||||
/// The `decode` stage's feed split, received→queued µs (P3 science: hand-off + input-slot
|
||||
/// wait). Empty when no receipt stamp matched (HUD off and ABR not measuring decode).
|
||||
feed_us: Vec<u64>,
|
||||
/// The codec-pure half, queued→decoded µs, measured from the AU's LAST piece — always on,
|
||||
/// so a HUD-off logcat A/B still reads the decoder's own time.
|
||||
codec_us: Vec<u64>,
|
||||
/// Capture→decoded end-to-end µs (skew-corrected, clamped) — always on for the same reason:
|
||||
/// the wireless A/B's headline without having to reach the on-screen HUD.
|
||||
e2e_us: Vec<u64>,
|
||||
}
|
||||
|
||||
impl PresentMeter {
|
||||
pub(super) fn new() -> PresentMeter {
|
||||
PresentMeter {
|
||||
inner: Mutex::new(PresentMeterInner {
|
||||
latch_us: Vec::with_capacity(256),
|
||||
displays: 0,
|
||||
feed_us: Vec::with_capacity(256),
|
||||
codec_us: Vec::with_capacity(256),
|
||||
e2e_us: Vec::with_capacity(256),
|
||||
}),
|
||||
undisplayed: AtomicI32::new(0),
|
||||
confirms: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// One displayed frame's release→displayed latch, µs. Callback thread; poison-proof.
|
||||
///
|
||||
/// Also the glass budget's CONFIRM: this frame left the BufferQueue, so one outstanding
|
||||
/// release is settled. Clamped at zero — the legacy `arrival` path renders without going
|
||||
/// through [`Presenter::pump`], so confirms can outnumber counted releases.
|
||||
pub(super) fn note_latch(&self, latch_us: Option<u64>) {
|
||||
self.confirms.store(true, Ordering::Relaxed);
|
||||
let _ = self
|
||||
.undisplayed
|
||||
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
|
||||
Some((v - 1).max(0))
|
||||
});
|
||||
let mut g = self
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
g.displays += 1;
|
||||
if let Some(l) = latch_us {
|
||||
if g.latch_us.len() < 4096 {
|
||||
g.latch_us.push(l);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame handed to SurfaceFlinger, awaiting its confirm. Decode thread.
|
||||
fn note_released(&self) {
|
||||
self.undisplayed.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Releases still unconfirmed, and whether confirms happen on this device at all.
|
||||
fn outstanding(&self) -> (i32, bool) {
|
||||
(
|
||||
self.undisplayed.load(Ordering::Relaxed),
|
||||
self.confirms.load(Ordering::Relaxed),
|
||||
)
|
||||
}
|
||||
|
||||
/// Write off the outstanding releases: the platform stopped confirming (it is allowed to
|
||||
/// drop callbacks under load) or SurfaceFlinger discarded the buffers without presenting
|
||||
/// them. Never stall the stream on a ledger we cannot audit.
|
||||
fn forgive_outstanding(&self) {
|
||||
self.undisplayed.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// One decoded frame's always-on measurements: the `decode`-stage split (feed =
|
||||
/// received→queued when a receipt stamp matched; codec = queued→decoded when the queued
|
||||
/// stamp did) and the capture→decoded end-to-end, µs. Decode thread; poison-proof.
|
||||
pub(super) fn note_decode(
|
||||
&self,
|
||||
feed_us: Option<u64>,
|
||||
codec_us: Option<u64>,
|
||||
e2e_us: Option<u64>,
|
||||
) {
|
||||
let mut g = self
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(f) = feed_us {
|
||||
if g.feed_us.len() < 4096 {
|
||||
g.feed_us.push(f);
|
||||
}
|
||||
}
|
||||
if let Some(c) = codec_us {
|
||||
if g.codec_us.len() < 4096 {
|
||||
g.codec_us.push(c);
|
||||
}
|
||||
}
|
||||
if let Some(e) = e2e_us {
|
||||
if g.e2e_us.len() < 4096 {
|
||||
g.e2e_us.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)] // one caller unpacks it in place; a struct would be noise
|
||||
fn drain(&self) -> (Vec<u64>, u64, Vec<u64>, Vec<u64>, Vec<u64>) {
|
||||
let mut g = self
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let displays = g.displays;
|
||||
g.displays = 0;
|
||||
(
|
||||
std::mem::take(&mut g.latch_us),
|
||||
displays,
|
||||
std::mem::take(&mut g.feed_us),
|
||||
std::mem::take(&mut g.codec_us),
|
||||
std::mem::take(&mut g.e2e_us),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// p50/max of an unsorted µs sample vec, in ms. (0, 0) when empty.
|
||||
fn p50_max_ms(mut v: Vec<u64>) -> (f64, f64) {
|
||||
if v.is_empty() {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
v.sort_unstable();
|
||||
let p50 = v[v.len() / 2] as f64 / 1000.0;
|
||||
let max = *v.last().unwrap() as f64 / 1000.0;
|
||||
(p50, max)
|
||||
}
|
||||
|
||||
pub(super) struct Presenter {
|
||||
/// 0 = newest-wins; 1..=3 = smoothing FIFO capacity.
|
||||
fifo_capacity: usize,
|
||||
frames: VecDeque<HeldFrame>,
|
||||
/// FIFO preroll: `take` withholds until the buffer filled to capacity once, re-armed on a dry
|
||||
/// run — the Apple `FrameStore` semantics (headroom never builds without it).
|
||||
prerolled: bool,
|
||||
inflight: Option<InFlight>,
|
||||
/// A vsync arrived since the last release — the FIFO's one-per-refresh drain pace.
|
||||
vsync_tick: bool,
|
||||
// -- 1 Hz pf-present window, always on --
|
||||
released: u64,
|
||||
paced_drops: u64,
|
||||
no_budget: u64,
|
||||
forced: u64,
|
||||
dry: u64,
|
||||
/// Pump passes that held a frame back because too many earlier releases were still
|
||||
/// unconfirmed ([`UNDISPLAYED_CAP`]) — reads 0 on a healthy device, and a climbing value is
|
||||
/// the signature of a presenter out-running its display.
|
||||
queue_waits: u64,
|
||||
/// When the unconfirmed-release rail first engaged, so it can be forgiven if the confirms
|
||||
/// simply stopped coming. `None` while the rail is down.
|
||||
backed_up_since: Option<i64>,
|
||||
pace_us: Vec<u64>,
|
||||
last_flush: Instant,
|
||||
/// The live submit margin. Starts at 0 (P2e on-glass: SurfaceFlinger latched every
|
||||
/// release with NO lead on the NP3 — the fixed 2.5 ms was pure display latency) and
|
||||
/// widens by measurement: `paced` misses in a 1 Hz window push it +500 µs toward
|
||||
/// [`LATCH_MARGIN_NS`], the pre-sweep known-safe ceiling. A sysprop override PINS it.
|
||||
margin_ns: i64,
|
||||
/// `debug.punktfunk.latch_margin_us` was set — the margin is pinned, never adapted.
|
||||
margin_pinned: bool,
|
||||
}
|
||||
|
||||
impl Presenter {
|
||||
pub(super) fn new(priority: PresentPriority) -> Presenter {
|
||||
let pinned = latch_margin_ns();
|
||||
let (margin_ns, margin_pinned) = match pinned {
|
||||
Some(ns) => (ns, true),
|
||||
None => (0, false),
|
||||
};
|
||||
log::info!(
|
||||
"presenter: latch margin {}us{}",
|
||||
margin_ns / 1_000,
|
||||
if margin_pinned {
|
||||
" (debug.punktfunk.latch_margin_us pin)"
|
||||
} else {
|
||||
" (adaptive — widens on latch misses)"
|
||||
}
|
||||
);
|
||||
Presenter {
|
||||
fifo_capacity: match priority {
|
||||
PresentPriority::Latency => 0,
|
||||
PresentPriority::Smooth { buffer } => buffer,
|
||||
},
|
||||
frames: VecDeque::new(),
|
||||
prerolled: false,
|
||||
inflight: None,
|
||||
vsync_tick: false,
|
||||
released: 0,
|
||||
paced_drops: 0,
|
||||
no_budget: 0,
|
||||
forced: 0,
|
||||
dry: 0,
|
||||
queue_waits: 0,
|
||||
backed_up_since: None,
|
||||
pace_us: Vec::with_capacity(256),
|
||||
last_flush: Instant::now(),
|
||||
margin_ns,
|
||||
margin_pinned,
|
||||
}
|
||||
}
|
||||
|
||||
/// A vsync pulse from the clock thread's event — the retry tick for a parked frame and the
|
||||
/// FIFO's drain pace.
|
||||
pub(super) fn on_vsync(&mut self) {
|
||||
self.vsync_tick = true;
|
||||
}
|
||||
|
||||
/// Accept one decoded, gate-approved output buffer. Newest-wins evicts everything older
|
||||
/// (released unrendered — the explicit, counted drop); the FIFO evicts its oldest past
|
||||
/// capacity. Returns how many frames were dropped by the policy (the HUD's `skipped`).
|
||||
pub(super) fn submit(
|
||||
&mut self,
|
||||
codec: &MediaCodec,
|
||||
index: usize,
|
||||
pts_us: u64,
|
||||
decoded_ns: i128,
|
||||
) -> u64 {
|
||||
let mut dropped = 0u64;
|
||||
if self.fifo_capacity == 0 {
|
||||
while let Some(stale) = self.frames.pop_front() {
|
||||
release_unrendered(codec, stale.index);
|
||||
dropped += 1;
|
||||
}
|
||||
}
|
||||
self.frames.push_back(HeldFrame {
|
||||
index,
|
||||
pts_us,
|
||||
decoded_ns,
|
||||
});
|
||||
if self.fifo_capacity > 0 && self.frames.len() > self.fifo_capacity {
|
||||
if let Some(stale) = self.frames.pop_front() {
|
||||
release_unrendered(codec, stale.index);
|
||||
dropped += 1;
|
||||
}
|
||||
}
|
||||
self.paced_drops += dropped;
|
||||
dropped
|
||||
}
|
||||
|
||||
/// The present decision point — run on every loop pass (frame arrivals, vsync ticks, and the
|
||||
/// 5 ms housekeeping wake all land here). Releases AT MOST one frame (the budget). Returns
|
||||
/// `true` when a frame was released to glass this call.
|
||||
#[allow(clippy::too_many_arguments)] // one call site; the seams are the point
|
||||
pub(super) fn pump(
|
||||
&mut self,
|
||||
codec: &MediaCodec,
|
||||
clock: Option<&VsyncShared>,
|
||||
tracker: &DisplayTracker,
|
||||
meter: &PresentMeter,
|
||||
stats: &crate::stats::VideoStats,
|
||||
now_mono_ns: i64,
|
||||
) -> bool {
|
||||
// Budget bookkeeping first: reopen on the predicted latch, force-open on the backstop.
|
||||
if let Some(f) = &self.inflight {
|
||||
if now_mono_ns >= f.reopen_at_ns {
|
||||
self.inflight = None;
|
||||
} else if now_mono_ns - f.released_at_ns > STALE_REOPEN_NS {
|
||||
self.forced += 1;
|
||||
self.inflight = None;
|
||||
}
|
||||
}
|
||||
// The measured rail beneath that prediction (see `UNDISPLAYED_CAP`). Evaluated on every
|
||||
// pass — frame waiting or not — so its forgiveness timer measures real elapsed time
|
||||
// rather than how often a frame happened to be ready.
|
||||
let backlogged = self.unconfirmed_backlog(meter, now_mono_ns);
|
||||
// Pick the frame this pump may release.
|
||||
let frame = if self.fifo_capacity == 0 {
|
||||
self.frames.pop_back() // submit() kept it a single slot; back == the newest
|
||||
} else {
|
||||
// FIFO: drain exactly one frame per vsync tick, after preroll; a drain tick that
|
||||
// finds the buffer dry re-arms preroll (the Apple `FrameStore` underflow semantics —
|
||||
// the previous frame persists on glass, a repeat by omission, while headroom
|
||||
// rebuilds). Everything is gated on the tick so an idle stream neither counts
|
||||
// underflows nor churns the preroll flag 200×/s.
|
||||
if !self.vsync_tick {
|
||||
return false;
|
||||
}
|
||||
if !self.prerolled {
|
||||
if self.frames.len() < self.fifo_capacity {
|
||||
return false;
|
||||
}
|
||||
self.prerolled = true;
|
||||
}
|
||||
if self.frames.is_empty() {
|
||||
self.prerolled = false;
|
||||
self.dry += 1;
|
||||
self.vsync_tick = false; // this tick's drain ran (and found nothing)
|
||||
return false;
|
||||
}
|
||||
self.frames.pop_front()
|
||||
};
|
||||
let Some(frame) = frame else { return false };
|
||||
if self.inflight.is_some() || backlogged {
|
||||
// Budget closed — park it back; a fresher submit replaces it (newest-wins), the next
|
||||
// vsync tick / loop pass retries the pairing.
|
||||
if backlogged {
|
||||
self.queue_waits += 1;
|
||||
}
|
||||
self.no_budget += 1;
|
||||
match self.fifo_capacity {
|
||||
0 => self.frames.push_back(frame),
|
||||
_ => self.frames.push_front(frame),
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Release: timeline-timed when the clock has one, ASAP otherwise.
|
||||
let target = clock.and_then(|c| c.next_target(now_mono_ns, self.margin_ns));
|
||||
let released = match target {
|
||||
Some(t) => codec
|
||||
.release_output_buffer_at_time_by_index(frame.index, t.expected_present_ns)
|
||||
.map_err(|e| log::warn!("presenter: release_at_time({}): {e}", frame.index)),
|
||||
None => codec
|
||||
.release_output_buffer_by_index(frame.index, true)
|
||||
.map_err(|e| log::warn!("presenter: release({}): {e}", frame.index)),
|
||||
};
|
||||
self.vsync_tick = false;
|
||||
if released.is_err() {
|
||||
return false; // the buffer is gone either way; nothing to book-keep
|
||||
}
|
||||
let period = clock.map(|c| c.period_ns()).filter(|&p| p > 0);
|
||||
// Reopen at SurfaceFlinger's LATCH for the targeted vsync (expected present minus the
|
||||
// latch lead) — the instant SF consumes the queued buffer and the slot frees, so the
|
||||
// next release can target the NEXT refresh. Not the platform `deadline` (with the
|
||||
// aggressive present gate it can already be in the past — an instant reopen would let
|
||||
// two releases pile onto the same vsync) and not the present time itself (a period too
|
||||
// late — it would cap the sustainable rate at roughly half the panel).
|
||||
let reopen_at_ns = target
|
||||
.map(|t| t.expected_present_ns - self.margin_ns)
|
||||
.unwrap_or(now_mono_ns + period.unwrap_or(FALLBACK_PERIOD_NS));
|
||||
self.inflight = Some(InFlight {
|
||||
reopen_at_ns,
|
||||
released_at_ns: now_mono_ns,
|
||||
});
|
||||
self.released += 1;
|
||||
meter.note_released();
|
||||
let release_real_ns = now_realtime_ns();
|
||||
let pace_us = ((release_real_ns - frame.decoded_ns).max(0) / 1000) as u64;
|
||||
if self.pace_us.len() < 4096 {
|
||||
self.pace_us.push(pace_us);
|
||||
}
|
||||
stats.note_release(pace_us);
|
||||
tracker.note_rendered(frame.pts_us, frame.decoded_ns, release_real_ns);
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether SurfaceFlinger is sitting on too many unconfirmed releases to be handed another.
|
||||
///
|
||||
/// The predicted reopen is only as good as the panel grid behind it; this is the measured
|
||||
/// rail underneath it (see [`UNDISPLAYED_CAP`]). It self-clears two ways — the confirms catch
|
||||
/// up, or [`STALE_REOPEN_NS`] passes with the backlog stuck, which means the ledger itself is
|
||||
/// unreliable (callbacks dropped under load, or SF discarded the buffers) and is written off
|
||||
/// rather than allowed to wedge the stream.
|
||||
fn unconfirmed_backlog(&mut self, meter: &PresentMeter, now_ns: i64) -> bool {
|
||||
let (outstanding, confirms_live) = meter.outstanding();
|
||||
if !confirms_live || outstanding < UNDISPLAYED_CAP {
|
||||
self.backed_up_since = None;
|
||||
return false;
|
||||
}
|
||||
match self.backed_up_since {
|
||||
Some(t) if now_ns - t > STALE_REOPEN_NS => {
|
||||
meter.forgive_outstanding();
|
||||
self.backed_up_since = None;
|
||||
self.forced += 1;
|
||||
false
|
||||
}
|
||||
_ => {
|
||||
self.backed_up_since.get_or_insert(now_ns);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Release every held buffer unrendered — the teardown path, BEFORE `codec.stop()`.
|
||||
pub(super) fn release_all(&mut self, codec: &MediaCodec) {
|
||||
while let Some(f) = self.frames.pop_front() {
|
||||
release_unrendered(codec, f.index);
|
||||
}
|
||||
self.inflight = None;
|
||||
}
|
||||
|
||||
/// The 1 Hz `pf-present` logcat mirror (target `pf.present`) — the Apple client's Console
|
||||
/// `pf-present` line, so a HUD-off on-device A/B is readable wirelessly:
|
||||
/// `released` (to glass) / `displays` (OnFrameRendered confirms) / `paced` (policy drops) /
|
||||
/// `noBudget` (waits on the closed budget) / `forced` (stale force-opens — 0 when healthy) /
|
||||
/// `qDry` (FIFO underflows) / `qWait` (pumps held back by unconfirmed releases — 0 when
|
||||
/// healthy) / `unconfirmed` (releases OnFrameRendered hasn't settled) /
|
||||
/// `pace` (decoded→release) / `latch` (release→displayed) /
|
||||
/// `feed`+`codec` (the decode stage split: received→queued hand-off/slot wait + the
|
||||
/// codec-pure queued→decoded time) / `e2e` (capture→decoded, skew-corrected — the wireless
|
||||
/// A/B headline) / `vsync` (the measured panel period).
|
||||
///
|
||||
/// Returns this window's CIRCULAR latch statistics `(vector-mean latch ns mod panel period,
|
||||
/// coherence ‰)` when a window actually flushed — the phase-lock reporter's v2 error signal
|
||||
/// (design/phase-locked-capture.md §6; the v1 median was immovable under jitter).
|
||||
pub(super) fn flush_log(
|
||||
&mut self,
|
||||
meter: &PresentMeter,
|
||||
clock: Option<&VsyncShared>,
|
||||
) -> Option<(u64, u16)> {
|
||||
if self.last_flush.elapsed() < std::time::Duration::from_secs(1) {
|
||||
return None;
|
||||
}
|
||||
self.last_flush = Instant::now();
|
||||
let (latch, displays, feed, codec, e2e) = meter.drain();
|
||||
if self.released == 0 && displays == 0 {
|
||||
return None; // idle stream — nothing worth a line
|
||||
}
|
||||
let (pace_p50, pace_max) = p50_max_ms(std::mem::take(&mut self.pace_us));
|
||||
let (feed_p50, feed_max) = p50_max_ms(feed);
|
||||
let (codec_p50, codec_max) = p50_max_ms(codec);
|
||||
let (e2e_p50, e2e_max) = p50_max_ms(e2e);
|
||||
let circ = clock.and_then(|c| {
|
||||
punktfunk_core::phase::circular_latch(&latch, c.panel_period_ns().max(c.period_ns()))
|
||||
});
|
||||
let latch_samples = latch.len();
|
||||
let (latch_p50, latch_max) = p50_max_ms(latch);
|
||||
let period_ms = clock.map(|c| c.period_ns() as f64 / 1e6).unwrap_or(0.0);
|
||||
let panel_ns = clock.map(|c| c.panel_period_ns()).unwrap_or(0);
|
||||
let (outstanding, _) = meter.outstanding();
|
||||
log::info!(
|
||||
target: "pf.present",
|
||||
"released={} displays={} paced={} noBudget={} forced={} qDry={} \
|
||||
qWait={} unconfirmed={} \
|
||||
paceMs p50={:.2} max={:.2} latchMs p50={:.2} max={:.2} \
|
||||
feedMs p50={:.2} max={:.2} codecMs p50={:.2} max={:.2} \
|
||||
e2eMs p50={:.2} max={:.2} circ={:.2}ms coh={} \
|
||||
vsyncMs={:.2} panelMs={:.2}",
|
||||
self.released,
|
||||
displays,
|
||||
self.paced_drops,
|
||||
self.no_budget,
|
||||
self.forced,
|
||||
self.dry,
|
||||
self.queue_waits,
|
||||
outstanding,
|
||||
pace_p50,
|
||||
pace_max,
|
||||
latch_p50,
|
||||
latch_max,
|
||||
feed_p50,
|
||||
feed_max,
|
||||
codec_p50,
|
||||
codec_max,
|
||||
e2e_p50,
|
||||
e2e_max,
|
||||
circ.map(|(m, _)| m as f64 / 1e6).unwrap_or(0.0),
|
||||
circ.map(|(_, c)| c).unwrap_or(0),
|
||||
period_ms,
|
||||
panel_ns as f64 / 1e6,
|
||||
);
|
||||
self.released = 0;
|
||||
// Margin adaptation, off the MEASURED latch. A release targets the first grid point past
|
||||
// `now + margin`, so a frame that makes its vsync is on glass within one panel period of
|
||||
// that margin; beyond it, SurfaceFlinger wanted more lead and the frame waited out an
|
||||
// extra refresh. Widen toward the pre-sweep ceiling. One-way by design: a margin that
|
||||
// once proved necessary is never re-gambled mid-stream (the next stream restarts at 0).
|
||||
//
|
||||
// ⚠ NOT `paced_drops`, which 0.23.0 used: those are the newest-wins store's own policy
|
||||
// evictions — a second frame decoding while one is held — which happen whenever the
|
||||
// stream out-runs the panel and say nothing at all about SF's latch lead. Driving the
|
||||
// margin from them widened it to the ceiling on healthy devices, re-imposing the 2.5 ms
|
||||
// of pure display latency the P2e sweep had just measured away.
|
||||
let latch_p50_ns = (latch_p50 * 1e6) as i64;
|
||||
if !self.margin_pinned
|
||||
&& self.margin_ns < LATCH_MARGIN_NS
|
||||
&& panel_ns > 0
|
||||
&& latch_samples >= 8
|
||||
&& latch_p50_ns > panel_ns + self.margin_ns
|
||||
{
|
||||
self.margin_ns = (self.margin_ns + 500_000).min(LATCH_MARGIN_NS);
|
||||
log::warn!(
|
||||
"presenter: latch p50 {:.2}ms over the {:.2}ms panel period — margin widened to {}us",
|
||||
latch_p50,
|
||||
panel_ns as f64 / 1e6,
|
||||
self.margin_ns / 1_000
|
||||
);
|
||||
}
|
||||
if self.queue_waits > 0 {
|
||||
log::warn!(
|
||||
"presenter: {} pump(s) held back — {} release(s) still unconfirmed by \
|
||||
OnFrameRendered (the display is not keeping up with the release rate)",
|
||||
self.queue_waits,
|
||||
outstanding
|
||||
);
|
||||
}
|
||||
self.paced_drops = 0;
|
||||
self.no_budget = 0;
|
||||
self.forced = 0;
|
||||
self.dry = 0;
|
||||
self.queue_waits = 0;
|
||||
circ
|
||||
}
|
||||
}
|
||||
|
||||
fn release_unrendered(codec: &MediaCodec, index: usize) {
|
||||
if let Err(e) = codec.release_output_buffer_by_index(index, false) {
|
||||
log::warn!("presenter: release_output_buffer({index}, false): {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `debug.punktfunk.presenter` sysprop: `arrival` = the legacy release-immediately path,
|
||||
/// anything else / unset = the timeline presenter. The rebuild-free on-device A/B lever.
|
||||
pub(super) fn presenter_disabled_by_sysprop() -> bool {
|
||||
let mut buf = [0u8; 92]; // PROP_VALUE_MAX
|
||||
// SAFETY: __system_property_get with a valid name + PROP_VALUE_MAX buffer is always safe.
|
||||
let n = unsafe {
|
||||
libc::__system_property_get(
|
||||
c"debug.punktfunk.presenter".as_ptr(),
|
||||
buf.as_mut_ptr().cast(),
|
||||
)
|
||||
};
|
||||
n > 0 && &buf[..n as usize] == b"arrival"
|
||||
}
|
||||
@@ -180,8 +180,14 @@ pub(super) fn boost_thread_priority() {
|
||||
/// mode (e.g. 60↔120) instead of leaving the panel at its default and judder-matching. The
|
||||
/// forced switch may blank the panel briefly — acceptable once at stream start, not wanted on a
|
||||
/// phone. Falls through to the 2-arg hint on API 30.
|
||||
/// - Otherwise: `ANativeWindow_setFrameRate` (**API 30**) with `compatibility = DEFAULT` — the
|
||||
/// softer, seamless-preferred hint for phones/tablets and the universal fallback.
|
||||
/// - Otherwise: `ANativeWindow_setFrameRate` (**API 30**) — the seamless-preferred hint for
|
||||
/// phones/tablets and the universal fallback.
|
||||
///
|
||||
/// Both paths pass `compatibility = FIXED_SOURCE` (1): the stream is fixed-rate video content the
|
||||
/// client cannot re-pace, which is exactly what that value declares — `DEFAULT` (0) told the
|
||||
/// platform the app could adapt to whatever rate it picked, an invitation some OEM refresh
|
||||
/// governors accepted by simply not switching. (The window-level `preferredDisplayModeId` pin in
|
||||
/// `MainActivity.setStreamDisplayMode` is the phone-side belt to this braces.)
|
||||
///
|
||||
/// Returns `true` when the platform accepted a hint; `false` on API < 30 (symbols absent) or a
|
||||
/// decline.
|
||||
@@ -200,8 +206,10 @@ pub(super) fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32, is_tv:
|
||||
if lib.is_null() {
|
||||
return false;
|
||||
}
|
||||
// TV: prefer the API-31 change-strategy form to force the mode switch (strategy 1 = ALWAYS,
|
||||
// compatibility 0 = DEFAULT). Absent on API 30 ⇒ fall through to the 2-arg hint below.
|
||||
// ANATIVEWINDOW_FRAME_RATE_COMPATIBILITY_FIXED_SOURCE — fixed-rate video content.
|
||||
const FIXED_SOURCE: i8 = 1;
|
||||
// TV: prefer the API-31 change-strategy form to force the mode switch (strategy 1 =
|
||||
// ALWAYS). Absent on API 30 ⇒ fall through to the 2-arg hint below.
|
||||
if is_tv {
|
||||
let sym = libc::dlsym(
|
||||
lib,
|
||||
@@ -209,7 +217,7 @@ pub(super) fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32, is_tv:
|
||||
);
|
||||
if !sym.is_null() {
|
||||
let set = std::mem::transmute::<*mut c_void, SetFrameRateStrategyFn>(sym);
|
||||
return set(window.ptr().as_ptr().cast(), frame_rate, 0, 1) == 0;
|
||||
return set(window.ptr().as_ptr().cast(), frame_rate, FIXED_SOURCE, 1) == 0;
|
||||
}
|
||||
}
|
||||
let sym = libc::dlsym(lib, c"ANativeWindow_setFrameRate".as_ptr());
|
||||
@@ -217,7 +225,7 @@ pub(super) fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32, is_tv:
|
||||
return false; // device API < 30 — no per-surface frame-rate hint
|
||||
}
|
||||
let set_frame_rate = std::mem::transmute::<*mut c_void, SetFrameRateFn>(sym);
|
||||
set_frame_rate(window.ptr().as_ptr().cast(), frame_rate, 0) == 0
|
||||
set_frame_rate(window.ptr().as_ptr().cast(), frame_rate, FIXED_SOURCE) == 0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,10 @@ pub(super) fn run_sync(
|
||||
ll_feature,
|
||||
low_latency_mode,
|
||||
is_tv,
|
||||
// The timeline presenter lives in the async loop only; this loop IS the escape hatch.
|
||||
present_priority: _,
|
||||
smooth_buffer: _,
|
||||
panel_hz: _,
|
||||
} = opts;
|
||||
boost_thread_priority();
|
||||
let mode = client.mode();
|
||||
@@ -181,7 +185,11 @@ pub(super) fn run_sync(
|
||||
// render = true are parked in the tracker; the OnFrameRendered callback pairs them with
|
||||
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
|
||||
// reclaimed after the codec is dropped below.
|
||||
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone());
|
||||
let tracker = DisplayTracker::new(
|
||||
stats.clone(),
|
||||
clock_offset.clone(),
|
||||
std::sync::Arc::new(super::presenter::PresentMeter::new()),
|
||||
);
|
||||
let render_cb = install_render_callback(&codec, &tracker);
|
||||
// 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
|
||||
@@ -598,7 +606,7 @@ fn drain(
|
||||
Ok(()) if held_present => {
|
||||
rendered = 1;
|
||||
if let Some((pts_us, decoded_ns)) = meta {
|
||||
tracker.note_rendered(pts_us, decoded_ns);
|
||||
tracker.note_rendered(pts_us, decoded_ns, super::latency::now_realtime_ns());
|
||||
}
|
||||
}
|
||||
Ok(()) => discarded += 1, // held off the screen — awaiting a clean re-anchor
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
//! The vsync clock behind the timeline presenter: an `AChoreographer` thread publishing the
|
||||
//! panel's vsync grid + upcoming frame timelines, and pulsing the decode loop's event channel so
|
||||
//! a frame parked on a closed glass budget gets its retry tick.
|
||||
//!
|
||||
//! On API 33+ the thread rides `AChoreographer_postVsyncCallback`, whose callback payload carries
|
||||
//! the platform's FRAME TIMELINES — for each upcoming refresh, when SurfaceFlinger expects to
|
||||
//! present and the deadline by which a frame must be submitted to make it. That pair is exactly
|
||||
//! what `AMediaCodec_releaseOutputBufferAtTime` wants as its target. On 31/32 the older
|
||||
//! `postFrameCallback64` supplies only the vsync instant; the presenter then releases ASAP
|
||||
//! (identical to the legacy path) and uses the measured period purely to predict the latch for
|
||||
//! its glass budget.
|
||||
//!
|
||||
//! Every `AChoreographer_*` symbol is dlsym-resolved from `libandroid.so` (mirrors
|
||||
//! [`super::setup::try_set_frame_rate`]): several sit above the crate's API floor, and one hard
|
||||
//! import of a too-new symbol fails `System.loadLibrary` on every older device.
|
||||
//!
|
||||
//! Started LAZILY on the first decoded frame (the Apple deadline presenter's bootstrap lesson:
|
||||
//! an eagerly started clock ticks uselessly for the whole connect window), stopped + joined via
|
||||
//! [`VsyncClock`]'s `Drop`.
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
/// `CLOCK_MONOTONIC` now in nanoseconds — the clock AChoreographer stamps its timelines on and
|
||||
/// the one `AMediaCodec_releaseOutputBufferAtTime` compares against (`System.nanoTime` basis).
|
||||
/// Distinct from the stats path's `CLOCK_REALTIME`: presenter scheduling stays monotonic.
|
||||
pub(super) fn now_monotonic_ns() -> i64 {
|
||||
let mut ts = libc::timespec {
|
||||
tv_sec: 0,
|
||||
tv_nsec: 0,
|
||||
};
|
||||
// SAFETY: `clock_gettime` with a valid out-pointer is an always-safe syscall.
|
||||
unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) };
|
||||
// Explicit widening: timespec's fields are 32-bit on armv7 (time_t/c_long).
|
||||
ts.tv_sec as i64 * 1_000_000_000 + ts.tv_nsec as i64
|
||||
}
|
||||
|
||||
/// One upcoming frame timeline (API 33+ payload): when SurfaceFlinger expects to present the
|
||||
/// frame, and the last instant it can be submitted to make that present. Monotonic ns.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct FrameTimeline {
|
||||
pub expected_present_ns: i64,
|
||||
pub deadline_ns: i64,
|
||||
}
|
||||
|
||||
/// State the choreographer thread publishes and the decode loop reads. All monotonic ns.
|
||||
pub(super) struct VsyncShared {
|
||||
stop: AtomicBool,
|
||||
/// The latest vsync callback's frame time (0 = no callback yet).
|
||||
last_vsync_ns: AtomicI64,
|
||||
/// Estimated vsync period (EMA over callback deltas / timeline spacing; 0 = unmeasured).
|
||||
///
|
||||
/// ⚠ This is the APP's render rate, not necessarily the panel's: Android down-rates a
|
||||
/// process's vsync stream (frame-rate categories / per-uid overrides), so a quiet UI can be
|
||||
/// served 60 Hz callbacks while the panel scans at 120 (observed on-glass, A024). Pacing
|
||||
/// video to THIS rate would cap the stream — hence `panel_period_ns` + the subdivision in
|
||||
/// [`Self::next_target`].
|
||||
period_ns: AtomicI64,
|
||||
/// The panel's own refresh period — the grid SurfaceFlinger actually latches on (0 = unknown).
|
||||
/// Seeded from the display mode Kotlin resolved at stream start and then corrected by
|
||||
/// measurement; the learner itself is [`punktfunk_core::phase::PanelGrid`], owned by the
|
||||
/// choreographer thread (see [`CallbackCtx::panel`]) and published here for the decode loop.
|
||||
panel_period_ns: AtomicI64,
|
||||
/// Callback count, for the one-shot cadence diagnostic log.
|
||||
ticks: std::sync::atomic::AtomicU32,
|
||||
/// The latest callback's upcoming timelines, soonest first. Empty on the 31/32 fallback.
|
||||
timelines: Mutex<Vec<FrameTimeline>>,
|
||||
}
|
||||
|
||||
impl VsyncShared {
|
||||
/// The measured vsync period, or 0 while unmeasured.
|
||||
pub(super) fn period_ns(&self) -> i64 {
|
||||
self.period_ns.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The panel's own refresh period (0 = unknown) — for the pf-present line's decomposition.
|
||||
pub(super) fn panel_period_ns(&self) -> i64 {
|
||||
self.panel_period_ns.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The release target for a frame submitted at `now`: the earliest stored timeline whose
|
||||
/// EXPECTED PRESENT is still `margin` away, extrapolated forward by whole periods once the
|
||||
/// stored set has aged out (timelines refresh once per vsync callback; a frame can decode
|
||||
/// anywhere inside that window). `None` on the 31/32 fallback — the caller releases ASAP.
|
||||
///
|
||||
/// Gated on `expected_present`, NOT the timeline's `deadline`, on purpose: the deadline
|
||||
/// budgets for GPU rendering the app has yet to submit (`presDeadline` — 11.3 ms on the
|
||||
/// A024, more than a full 120 Hz period), but a video buffer is already fully rendered —
|
||||
/// the only real constraint is SurfaceFlinger's own latch lead, which is what the caller's
|
||||
/// `margin` represents. Targeting by deadline cost every frame an extra refresh of waiting
|
||||
/// (measured: latch p50 ~21 ms vs the ~2-interval floor); a mis-gamble here just means the
|
||||
/// frame presents one vsync later — exactly what the conservative gate always paid.
|
||||
///
|
||||
/// The picked target is then SUBDIVIDED onto the panel grid: the platform reports timelines
|
||||
/// at the app's assigned render rate, but the panel latches at its own — when the app is
|
||||
/// down-rated (60 Hz callbacks on a 120 Hz panel) the reported timelines are a whole panel
|
||||
/// period apart or more, and pacing to them would cap the video. Pulling the target earlier
|
||||
/// by whole panel periods (while its present still clears the margin) restores the true
|
||||
/// grid; when callbacks run at the panel rate the pull condition is never true and this is
|
||||
/// a no-op.
|
||||
pub(super) fn next_target(&self, now_ns: i64, margin_ns: i64) -> Option<FrameTimeline> {
|
||||
let mut t = {
|
||||
let g = self
|
||||
.timelines
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let found = g
|
||||
.iter()
|
||||
.find(|t| t.expected_present_ns > now_ns + margin_ns)
|
||||
.copied();
|
||||
match found {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
let last = g.last().copied()?;
|
||||
let period = self.period_ns();
|
||||
if period <= 0 {
|
||||
return None;
|
||||
}
|
||||
// All stored timelines have passed — step the last one forward whole
|
||||
// periods until its present clears `now + margin` again.
|
||||
let behind = (now_ns + margin_ns).saturating_sub(last.expected_present_ns);
|
||||
let k = behind / period + 1;
|
||||
FrameTimeline {
|
||||
expected_present_ns: last.expected_present_ns + k * period,
|
||||
deadline_ns: last.deadline_ns + k * period,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let panel = self.panel_period_ns.load(Ordering::Relaxed);
|
||||
if panel > 0 {
|
||||
while t.expected_present_ns - panel > now_ns + margin_ns {
|
||||
t.deadline_ns -= panel;
|
||||
t.expected_present_ns -= panel;
|
||||
}
|
||||
}
|
||||
Some(t)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- dlsym'd AChoreographer surface ----
|
||||
|
||||
type PostFrameCallback64 =
|
||||
unsafe extern "C" fn(*mut c_void, unsafe extern "C" fn(i64, *mut c_void), *mut c_void);
|
||||
type PostVsyncCallback = unsafe extern "C" fn(
|
||||
*mut c_void,
|
||||
unsafe extern "C" fn(*const c_void, *mut c_void),
|
||||
*mut c_void,
|
||||
);
|
||||
|
||||
struct ChoreoApi {
|
||||
get_instance: unsafe extern "C" fn() -> *mut c_void,
|
||||
/// API 33: vsync callback with frame-timeline payload. Preferred.
|
||||
post_vsync: Option<PostVsyncCallback>,
|
||||
/// API 29 fallback: frame callback with only the vsync instant.
|
||||
post_frame64: Option<PostFrameCallback64>,
|
||||
// AChoreographerFrameCallbackData accessors (API 33; present iff `post_vsync` is).
|
||||
fcd_frame_time: Option<unsafe extern "C" fn(*const c_void) -> i64>,
|
||||
fcd_timelines_len: Option<unsafe extern "C" fn(*const c_void) -> usize>,
|
||||
fcd_preferred_index: Option<unsafe extern "C" fn(*const c_void) -> usize>,
|
||||
fcd_expected_present: Option<unsafe extern "C" fn(*const c_void, usize) -> i64>,
|
||||
fcd_deadline: Option<unsafe extern "C" fn(*const c_void, usize) -> i64>,
|
||||
}
|
||||
|
||||
impl ChoreoApi {
|
||||
/// Resolve from `libandroid.so`. `None` when even the baseline symbols are missing.
|
||||
fn resolve() -> Option<ChoreoApi> {
|
||||
// SAFETY: dlopen of the always-mapped libandroid.so (refcount bump, never closed); each
|
||||
// dlsym is null-checked before the transmute to its fn-pointer type.
|
||||
unsafe {
|
||||
let lib = libc::dlopen(c"libandroid.so".as_ptr(), libc::RTLD_NOW);
|
||||
if lib.is_null() {
|
||||
return None;
|
||||
}
|
||||
let sym = |name: &std::ffi::CStr| {
|
||||
let p = libc::dlsym(lib, name.as_ptr());
|
||||
(!p.is_null()).then_some(p)
|
||||
};
|
||||
let get_instance = sym(c"AChoreographer_getInstance")?;
|
||||
let post_vsync = sym(c"AChoreographer_postVsyncCallback");
|
||||
let post_frame64 = sym(c"AChoreographer_postFrameCallback64");
|
||||
post_vsync.or(post_frame64)?; // neither post entry point — no clock on this device
|
||||
Some(ChoreoApi {
|
||||
get_instance: std::mem::transmute::<
|
||||
*mut c_void,
|
||||
unsafe extern "C" fn() -> *mut c_void,
|
||||
>(get_instance),
|
||||
post_vsync: post_vsync.map(|p| std::mem::transmute::<*mut c_void, PostVsyncCallback>(p)),
|
||||
post_frame64: post_frame64
|
||||
.map(|p| std::mem::transmute::<*mut c_void, PostFrameCallback64>(p)),
|
||||
fcd_frame_time: sym(c"AChoreographerFrameCallbackData_getFrameTimeNanos").map(|p| {
|
||||
std::mem::transmute::<*mut c_void, unsafe extern "C" fn(*const c_void) -> i64>(p)
|
||||
}),
|
||||
fcd_timelines_len: sym(c"AChoreographerFrameCallbackData_getFrameTimelinesLength")
|
||||
.map(|p| {
|
||||
std::mem::transmute::<*mut c_void, unsafe extern "C" fn(*const c_void) -> usize>(
|
||||
p,
|
||||
)
|
||||
}),
|
||||
fcd_preferred_index: sym(
|
||||
c"AChoreographerFrameCallbackData_getPreferredFrameTimelineIndex",
|
||||
)
|
||||
.map(|p| {
|
||||
std::mem::transmute::<*mut c_void, unsafe extern "C" fn(*const c_void) -> usize>(p)
|
||||
}),
|
||||
fcd_expected_present: sym(
|
||||
c"AChoreographerFrameCallbackData_getFrameTimelineExpectedPresentationTimeNanos",
|
||||
)
|
||||
.map(|p| {
|
||||
std::mem::transmute::<
|
||||
*mut c_void,
|
||||
unsafe extern "C" fn(*const c_void, usize) -> i64,
|
||||
>(p)
|
||||
}),
|
||||
fcd_deadline: sym(c"AChoreographerFrameCallbackData_getFrameTimelineDeadlineNanos")
|
||||
.map(|p| {
|
||||
std::mem::transmute::<
|
||||
*mut c_void,
|
||||
unsafe extern "C" fn(*const c_void, usize) -> i64,
|
||||
>(p)
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything a callback invocation needs. Owned by the choreographer thread's stack; callbacks
|
||||
/// only ever fire inside that thread's looper poll, so the borrow can't outlive the thread.
|
||||
struct CallbackCtx {
|
||||
api: ChoreoApi,
|
||||
choreographer: *mut c_void,
|
||||
shared: Arc<VsyncShared>,
|
||||
on_tick: Box<dyn Fn() + Send>,
|
||||
/// The panel-period learner. `Cell` rather than an atomic because it is touched from exactly
|
||||
/// one thread — callbacks only ever fire inside this thread's looper poll (see the struct
|
||||
/// doc) — and its streak state is nobody else's business; only the settled period is
|
||||
/// published, to `shared.panel_period_ns`.
|
||||
panel: std::cell::Cell<punktfunk_core::phase::PanelGrid>,
|
||||
}
|
||||
|
||||
impl CallbackCtx {
|
||||
/// Common tail of both callback flavours: update the grid estimate, publish, pulse, re-arm.
|
||||
fn tick(&self, frame_time_ns: i64, timelines: Vec<FrameTimeline>) {
|
||||
let prev = self
|
||||
.shared
|
||||
.last_vsync_ns
|
||||
.swap(frame_time_ns, Ordering::Relaxed);
|
||||
// Panel-grid learner: timeline spacing is SurfaceFlinger's own grid, and therefore the
|
||||
// only honest witness to what the panel is doing — the configured mode is not (under a
|
||||
// per-uid frame-rate override `Display.getRefreshRate` REPORTS THE OVERRIDE, observed
|
||||
// on-glass: a 120 Hz panel read back as 60 while its timelines ran at 8.28 ms), and
|
||||
// neither is the mode Kotlin *requested* (`preferredDisplayModeId` is a hint the system
|
||||
// may refuse). Both directions matter and the asymmetry lives in `PanelGrid`.
|
||||
if timelines.len() >= 2 {
|
||||
let spacing = timelines[1].expected_present_ns - timelines[0].expected_present_ns;
|
||||
let mut grid = self.panel.get();
|
||||
if grid.observe(spacing) {
|
||||
self.shared
|
||||
.panel_period_ns
|
||||
.store(grid.period_ns(), Ordering::Relaxed);
|
||||
log::info!(
|
||||
"vsync: panel grid now {:.2}ms",
|
||||
grid.period_ns() as f64 / 1e6
|
||||
);
|
||||
}
|
||||
self.panel.set(grid);
|
||||
}
|
||||
// One-shot cadence diagnostic (3rd tick, once deltas exist): the callback cadence vs the
|
||||
// panel period is exactly the down-rating question, and this line answers it on-glass.
|
||||
if self.shared.ticks.fetch_add(1, Ordering::Relaxed) == 2 {
|
||||
let spacing = if timelines.len() >= 2 {
|
||||
timelines[1].expected_present_ns - timelines[0].expected_present_ns
|
||||
} else {
|
||||
0
|
||||
};
|
||||
log::info!(
|
||||
"vsync: cadence Δ={:.2}ms timelines={} spacing={:.2}ms panel={:.2}ms",
|
||||
if prev > 0 {
|
||||
(frame_time_ns - prev) as f64 / 1e6
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
timelines.len(),
|
||||
spacing as f64 / 1e6,
|
||||
self.shared.panel_period_ns.load(Ordering::Relaxed) as f64 / 1e6,
|
||||
);
|
||||
}
|
||||
// Period: prefer timeline spacing (exact, straight from the platform), else the delta of
|
||||
// successive callbacks (jittery — EMA'd), clamped to sane panel rates (24..500 Hz).
|
||||
let mut period = 0i64;
|
||||
if timelines.len() >= 2 {
|
||||
period = timelines[1].expected_present_ns - timelines[0].expected_present_ns;
|
||||
} else if prev > 0 {
|
||||
period = frame_time_ns - prev;
|
||||
}
|
||||
if (2_000_000..=42_000_000).contains(&period) {
|
||||
let old = self.shared.period_ns.load(Ordering::Relaxed);
|
||||
let smoothed = if old > 0 {
|
||||
(old * 7 + period) / 8
|
||||
} else {
|
||||
period
|
||||
};
|
||||
self.shared.period_ns.store(smoothed, Ordering::Relaxed);
|
||||
}
|
||||
if !timelines.is_empty() {
|
||||
let mut g = self
|
||||
.shared
|
||||
.timelines
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
*g = timelines;
|
||||
}
|
||||
(self.on_tick)();
|
||||
if !self.shared.stop.load(Ordering::Relaxed) {
|
||||
self.repost();
|
||||
}
|
||||
}
|
||||
|
||||
fn repost(&self) {
|
||||
// SAFETY: `choreographer` is this thread's instance; the ctx pointer stays valid for the
|
||||
// thread's life and callbacks only fire on this thread (see the struct doc).
|
||||
unsafe {
|
||||
let ud = self as *const CallbackCtx as *mut c_void;
|
||||
if let Some(post) = self.api.post_vsync {
|
||||
post(self.choreographer, on_vsync, ud);
|
||||
} else if let Some(post) = self.api.post_frame64 {
|
||||
post(self.choreographer, on_frame64, ud);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// API 33+ trampoline: harvest the frame timelines, then the common tick. Panic-free (an unwind
|
||||
/// out of an `extern "C"` fn aborts).
|
||||
unsafe extern "C" fn on_vsync(data: *const c_void, ud: *mut c_void) {
|
||||
// SAFETY: `ud` is the thread's `CallbackCtx`, alive for the whole poll loop (see struct doc).
|
||||
let ctx = unsafe { &*(ud as *const CallbackCtx) };
|
||||
let api = &ctx.api;
|
||||
let (mut frame_time, mut timelines) = (now_monotonic_ns(), Vec::new());
|
||||
// SAFETY: `data` is the platform's callback payload, valid for this invocation; the accessors
|
||||
// were resolved together with `post_vsync` (same API level) and are only called when present.
|
||||
unsafe {
|
||||
if let Some(f) = api.fcd_frame_time {
|
||||
frame_time = f(data);
|
||||
}
|
||||
if let (Some(len_f), Some(pref_f), Some(exp_f), Some(dl_f)) = (
|
||||
api.fcd_timelines_len,
|
||||
api.fcd_preferred_index,
|
||||
api.fcd_expected_present,
|
||||
api.fcd_deadline,
|
||||
) {
|
||||
let len = len_f(data).min(8);
|
||||
// From the PREFERRED index on: earlier timelines are ones the platform already
|
||||
// considers missed for a frame starting now.
|
||||
let start = pref_f(data).min(len);
|
||||
timelines = (start..len)
|
||||
.map(|i| FrameTimeline {
|
||||
expected_present_ns: exp_f(data, i),
|
||||
deadline_ns: dl_f(data, i),
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
ctx.tick(frame_time, timelines);
|
||||
}
|
||||
|
||||
/// API 29 fallback trampoline: vsync instant only.
|
||||
unsafe extern "C" fn on_frame64(frame_time_ns: i64, ud: *mut c_void) {
|
||||
// SAFETY: `ud` is the thread's `CallbackCtx` (see `on_vsync`).
|
||||
let ctx = unsafe { &*(ud as *const CallbackCtx) };
|
||||
ctx.tick(frame_time_ns, Vec::new());
|
||||
}
|
||||
|
||||
/// The clock: a dedicated looper thread the choreographer calls back on. Dropping stops + joins.
|
||||
pub(super) struct VsyncClock {
|
||||
shared: Arc<VsyncShared>,
|
||||
join: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl VsyncClock {
|
||||
/// Spawn the choreographer thread. `on_tick` fires once per vsync ON THAT THREAD — it must
|
||||
/// only do something cheap and `Send` (the decode loop passes an event-channel send).
|
||||
/// `panel_hz` SEEDS the panel-grid learner (0 = unknown) — the latch grid that
|
||||
/// [`VsyncShared::next_target`] subdivides onto. A seed, not a fact: it names the display
|
||||
/// mode Kotlin *requested*, and the observed timeline spacing is what settles it. `None` when the platform surface is missing
|
||||
/// (very old device) — the presenter then runs clock-less (ASAP targets, predicted-latch
|
||||
/// budget).
|
||||
pub(super) fn start(panel_hz: i32, on_tick: Box<dyn Fn() + Send>) -> Option<VsyncClock> {
|
||||
let api = ChoreoApi::resolve()?;
|
||||
let timelines_live = api.post_vsync.is_some();
|
||||
let shared = Arc::new(VsyncShared {
|
||||
stop: AtomicBool::new(false),
|
||||
last_vsync_ns: AtomicI64::new(0),
|
||||
period_ns: AtomicI64::new(0),
|
||||
panel_period_ns: AtomicI64::new(
|
||||
punktfunk_core::phase::PanelGrid::seeded(panel_hz).period_ns(),
|
||||
),
|
||||
ticks: std::sync::atomic::AtomicU32::new(0),
|
||||
timelines: Mutex::new(Vec::new()),
|
||||
});
|
||||
let thread_shared = shared.clone();
|
||||
let join = std::thread::Builder::new()
|
||||
.name("pf-vsync".into())
|
||||
.spawn(move || {
|
||||
let looper = ndk::looper::ThreadLooper::prepare();
|
||||
// SAFETY: getInstance on a thread with a prepared looper returns this thread's
|
||||
// choreographer (never null once a looper exists).
|
||||
let choreographer = unsafe { (api.get_instance)() };
|
||||
if choreographer.is_null() {
|
||||
log::warn!("vsync: AChoreographer_getInstance returned null — no clock");
|
||||
return;
|
||||
}
|
||||
let ctx = CallbackCtx {
|
||||
api,
|
||||
choreographer,
|
||||
shared: thread_shared,
|
||||
on_tick,
|
||||
panel: std::cell::Cell::new(punktfunk_core::phase::PanelGrid::seeded(panel_hz)),
|
||||
};
|
||||
ctx.repost();
|
||||
// The bounded poll doubles as the stop check: no cross-thread wake needed, worst
|
||||
// case teardown waits one timeout out. Callbacks fire inside poll_once_timeout.
|
||||
while !ctx.shared.stop.load(Ordering::Relaxed) {
|
||||
let _ = looper.poll_once_timeout(Duration::from_millis(250));
|
||||
}
|
||||
// `ctx` drops here — after the loop, so no queued callback can outlive it (they
|
||||
// only ever fire inside this thread's poll).
|
||||
})
|
||||
.ok()?;
|
||||
log::info!(
|
||||
"vsync: choreographer clock started ({})",
|
||||
if timelines_live {
|
||||
"frame timelines"
|
||||
} else {
|
||||
"frame callback fallback"
|
||||
}
|
||||
);
|
||||
Some(VsyncClock {
|
||||
shared,
|
||||
join: Some(join),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn shared(&self) -> &Arc<VsyncShared> {
|
||||
&self.shared
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for VsyncClock {
|
||||
fn drop(&mut self) {
|
||||
self.shared.stop.store(true, Ordering::Relaxed);
|
||||
if let Some(j) = self.join.take() {
|
||||
let _ = j.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,29 @@ use std::time::Duration;
|
||||
/// observes its `running=false` flag promptly on teardown.
|
||||
const PULL_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Width of the packed `pad` field in [`pack_rumble`] — 4 bits, i.e. indices 0..15.
|
||||
const PAD_BITS: u32 = 4;
|
||||
/// The packing is only lossless while every representable pad index fits in [`PAD_BITS`]. This was
|
||||
/// a comment before; growing `MAX_PADS` past 16 would have silently aliased pad 16 onto pad 0
|
||||
/// rather than failing the build.
|
||||
const _: () = assert!(
|
||||
punktfunk_core::input::MAX_PADS <= 1usize << PAD_BITS,
|
||||
"MAX_PADS no longer fits the 4-bit pad field in the packed rumble long"
|
||||
);
|
||||
|
||||
/// Pack one effective rumble command into the `jlong` `nativeNextRumble` returns.
|
||||
///
|
||||
/// Layout — mirrored by `unpackRumbleEvent` in `RumbleWire.kt`: bits 49..52 `pad`, 32..47
|
||||
/// `backstop_ms`, 16..31 `low`, 0..15 `high`. Always non-negative, so the `-1` timeout/closed
|
||||
/// sentinel stays unambiguous. Split out from the JNI entry point purely so it can be tested
|
||||
/// without a live session handle — the shift arithmetic is the part worth pinning.
|
||||
fn pack_rumble(pad: u16, low: u16, high: u16, backstop_ms: u32) -> jlong {
|
||||
(jlong::from(pad & ((1 << PAD_BITS) - 1)) << 49)
|
||||
| (jlong::from(backstop_ms.min(0xFFFF) as u16) << 32)
|
||||
| (jlong::from(low) << 16)
|
||||
| jlong::from(high)
|
||||
}
|
||||
|
||||
// HID-output kind tags written into the returned ByteBuffer (Kotlin reads them back).
|
||||
const TAG_LED: u8 = 0x01;
|
||||
const TAG_PLAYER_LEDS: u8 = 0x02;
|
||||
@@ -54,12 +77,15 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
||||
// handle.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
match h.client.next_rumble_command(PULL_TIMEOUT) {
|
||||
Ok(cmd) => {
|
||||
(jlong::from(cmd.pad & 0xF) << 49)
|
||||
| (jlong::from(cmd.backstop_ms.min(0xFFFF) as u16) << 32)
|
||||
| (jlong::from(cmd.low) << 16)
|
||||
| jlong::from(cmd.high)
|
||||
}
|
||||
// A pad whose coils are ACTIVELY being driven by the 0xD1 haptics stream must not see
|
||||
// wire rumble: `DsDevice` sets `valid_flag0` bit 1 (`HAPTICS_SELECT`) on every rumble
|
||||
// write, and that bit disables the audio-haptics path — so one replayed command would
|
||||
// mute the coils the stream is driving. Gating on *arrival of haptics frames* rather
|
||||
// than on "a stream is open" is what keeps a rumble-only title working: it renders no
|
||||
// haptics audio, so the host emits nothing on 0xD1 and the pad keeps its rumble.
|
||||
// Dropping it here rather than in Kotlin keeps the rule next to the reason.
|
||||
Ok(cmd) if crate::pad_audio::haptics_owns_coils((cmd.pad & 0xF) as u8) => -1,
|
||||
Ok(cmd) => pack_rumble(cmd.pad, cmd.low, cmd.high, cmd.backstop_ms),
|
||||
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
|
||||
}
|
||||
})
|
||||
@@ -156,7 +182,74 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
||||
out[3..n].copy_from_slice(&data);
|
||||
n
|
||||
}
|
||||
HidOutput::AudioCtl { .. } => {
|
||||
// DS5 pad-audio routing/volumes — no Android replay path yet (the 0xD1 sample
|
||||
// plane isn't rendered here either); drop it like TrackpadHaptic.
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
n as jint
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pack_rumble_tests {
|
||||
use super::*;
|
||||
use punktfunk_core::input::MAX_PADS;
|
||||
|
||||
/// Kotlin's `unpackRumbleEvent`, transcribed — if these two ever disagree the boundary is
|
||||
/// broken, and nothing else in the build would say so.
|
||||
fn unpack(ev: jlong) -> (u16, u16, u16, u32) {
|
||||
let pad = ((ev >> 49) & 0xF) as u16;
|
||||
let backstop = ((ev >> 32) & 0xFFFF) as u32;
|
||||
let low = ((ev >> 16) & 0xFFFF) as u16;
|
||||
let high = (ev & 0xFFFF) as u16;
|
||||
(pad, low, high, backstop)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_every_field_at_its_extremes() {
|
||||
for &(pad, low, high, backstop) in &[
|
||||
(0u16, 0u16, 0u16, 0u32),
|
||||
(15, 0xFFFF, 0xFFFF, 0xFFFF),
|
||||
(1, 0x1234, 0x5678, 500),
|
||||
(7, 0, 0xFFFF, 2000),
|
||||
] {
|
||||
let ev = pack_rumble(pad, low, high, backstop);
|
||||
assert_eq!(unpack(ev), (pad, low, high, backstop), "pad {pad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_representable_pad_survives_the_four_bit_field() {
|
||||
for pad in 0..MAX_PADS as u16 {
|
||||
let (got, ..) = unpack(pack_rumble(pad, 1, 2, 3));
|
||||
assert_eq!(got, pad, "pad {pad} aliased in the packed long");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_packed_command_is_never_negative() {
|
||||
// `-1` is the timeout/closed sentinel; any packed value colliding with it would read as
|
||||
// "no command" and the rumble would simply vanish.
|
||||
assert!(pack_rumble(15, 0xFFFF, 0xFFFF, 0xFFFF) >= 0);
|
||||
assert!(pack_rumble(0, 0, 0, 0) >= 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_oversized_backstop_saturates_instead_of_corrupting_the_pad_field() {
|
||||
let ev = pack_rumble(3, 0, 0, u32::MAX);
|
||||
let (pad, _, _, backstop) = unpack(ev);
|
||||
assert_eq!(pad, 3, "a huge backstop must not bleed into the pad bits");
|
||||
assert_eq!(backstop, 0xFFFF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stop_is_distinguishable_from_a_hold() {
|
||||
let stop = pack_rumble(2, 0, 0, 0);
|
||||
let hold = pack_rumble(2, 0x8000, 0x8000, 500);
|
||||
assert_ne!(stop, hold);
|
||||
assert_eq!(unpack(stop).1, 0);
|
||||
assert_eq!(unpack(stop).2, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ mod discovery;
|
||||
mod feedback;
|
||||
#[cfg(target_os = "android")]
|
||||
mod mic;
|
||||
/// Tier-A DualSense pad audio: the 0xD1 plane rendered on the pad's own USB endpoint.
|
||||
mod pad_audio;
|
||||
mod session;
|
||||
mod stats;
|
||||
// Ungated like `discovery`: pure `jni` + `punktfunk_core::wol` (no Android framework), so it links
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
//! Android microphone uplink (android-only): capture mic PCM via AAudio (LowLatency **input**),
|
||||
//! Opus-encode 20 ms stereo frames, and push them to the host over the connector's mic plane
|
||||
//! Opus-encode 10 ms mono frames, and push them to the host over the connector's mic plane
|
||||
//! (`send_mic` → 0xCB datagram). The mirror of [`crate::audio`] in reverse: AAudio's realtime input
|
||||
//! callback hands captured interleaved f32 to a channel; a worker thread we own does the Opus
|
||||
//! encode + send (encoding is too heavy for the realtime callback, exactly as decode is on the
|
||||
//! playback side). Like the playback path, the realtime callback is allocation-free: captured
|
||||
//! bursts are copied into pre-allocated buffers from a recycle free-list (pool empty = drop the
|
||||
//! chunk, never allocate on the capture thread). Format matches the host decoder + the Linux
|
||||
//! client: 48 kHz **stereo**, 20 ms, Opus VOIP.
|
||||
//! callback hands captured f32 to a channel; a worker thread we own does the Opus encode + send
|
||||
//! (encoding is too heavy for the realtime callback, exactly as decode is on the playback side).
|
||||
//! Like the playback path, the realtime callback is allocation-free: captured bursts are copied
|
||||
//! into pre-allocated buffers from a recycle free-list (pool empty = drop the chunk, never
|
||||
//! allocate on the capture thread). Format: 48 kHz **mono**, 10 ms, Opus VOIP with in-band FEC —
|
||||
//! the host decodes any Opus frame ≤ 120 ms with its stereo decoder (mono packets upmix), so this
|
||||
//! needs no protocol change; speech gains nothing from stereo, and the shorter frame shaves a
|
||||
//! buffering interval off the uplink.
|
||||
//!
|
||||
//! **Mute** is a flag the encode loop reads per 10 ms frame, never a stream teardown: the AAudio
|
||||
//! input stream, the input-preset ladder it settled on and its primed buffers all survive a
|
||||
//! mute/unmute untouched, so toggling costs an atomic load and nothing else.
|
||||
|
||||
use ndk::audio::{
|
||||
AudioCallbackResult, AudioDirection, AudioFormat, AudioPerformanceMode, AudioSharingMode,
|
||||
AudioStream, AudioStreamBuilder,
|
||||
AudioCallbackResult, AudioDirection, AudioFormat, AudioInputPreset, AudioPerformanceMode,
|
||||
AudioSharingMode, AudioStream, AudioStreamBuilder, SessionId,
|
||||
};
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use std::collections::VecDeque;
|
||||
@@ -20,31 +26,57 @@ use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError, SyncSender, TryS
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
const CHANNELS: usize = 2;
|
||||
const CHANNELS: usize = 1;
|
||||
const SAMPLE_RATE: i32 = 48_000;
|
||||
/// 20 ms per channel @ 48 kHz — the Linux client's frame; the host accepts ≤ 120 ms.
|
||||
const FRAME_SAMPLES: usize = 960;
|
||||
/// 10 ms per channel @ 48 kHz — half the desktop clients' 20 ms frame, trading a little Opus
|
||||
/// header overhead for one less buffered interval; the host accepts ≤ 120 ms.
|
||||
const FRAME_SAMPLES: usize = 480;
|
||||
/// Captured-chunk hand-off depth (each ~ one burst); drops on overflow (best-effort uplink).
|
||||
/// Bursts are sized in frames, so the wall-time depth is unchanged by the stereo→mono move.
|
||||
const RING_CHUNKS: usize = 64;
|
||||
/// Free-list buffer capacity, in interleaved f32 samples: comfortably above a LowLatency input
|
||||
/// burst (typically ≤ ~480 frames). A device with larger bursts costs each buffer a one-time grow
|
||||
/// on the capture thread, after which the steady state is allocation-free again.
|
||||
const CHUNK_CAP_SAMPLES: usize = 1920; // 20 ms stereo
|
||||
/// Opus VOIP target bitrate (speech; tunable).
|
||||
const MIC_BITRATE: i32 = 64_000;
|
||||
/// burst (typically ≤ ~480 frames — mono, so samples = frames). A device with larger bursts costs
|
||||
/// each buffer a one-time grow on the capture thread, after which the steady state is
|
||||
/// allocation-free again.
|
||||
const CHUNK_CAP_SAMPLES: usize = 960; // 20 ms mono — the same wall-time as the old stereo value
|
||||
/// Opus VOIP target bitrate (mono speech; tunable).
|
||||
const MIC_BITRATE: i32 = 48_000;
|
||||
/// Encode-side self-heal threshold, in queued 10 ms frames (~60 ms): waking to more than this
|
||||
/// means the uplink stalled — and because the capture callback drops the NEWEST chunk when the
|
||||
/// channel is full, a stall otherwise converts to standing mic delay that never drains (real-time
|
||||
/// playback host-side never makes time back up). Skip to the newest few frames instead.
|
||||
const BACKLOG_MAX_FRAMES: usize = 6;
|
||||
/// What a self-heal keeps: ~20 ms of the freshest audio (one audible blip, live again).
|
||||
const BACKLOG_KEEP_FRAMES: usize = 2;
|
||||
|
||||
/// Owned by [`crate::session::SessionHandle`]: the live AAudio input stream + the encode thread.
|
||||
pub struct MicCapture {
|
||||
_stream: AudioStream, // dropping it stops + closes the AAudio input stream
|
||||
/// The audio-session id AAudio allocated (`> 0`) when echo cancellation asked for one — the
|
||||
/// hook Kotlin hangs the Java `AcousticEchoCanceler`/`NoiseSuppressor` on. `0` = none.
|
||||
session_id: i32,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
join: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl MicCapture {
|
||||
/// Open AAudio (LowLatency, 48 kHz/stereo/f32) for **input** with a realtime callback that
|
||||
/// forwards captured PCM to a channel, then spawn the Opus encode + uplink thread. `None` on
|
||||
/// failure (the caller leaves the rest of the session streaming).
|
||||
pub fn start(client: Arc<NativeClient>) -> Option<MicCapture> {
|
||||
/// Open AAudio (LowLatency, 48 kHz/mono/f32) for **input** with a realtime callback that
|
||||
/// forwards captured PCM to a channel, then spawn the Opus encode + uplink thread. With
|
||||
/// `echo_cancel` the stream opens under the `VoiceCommunication` input preset — the HAL's own
|
||||
/// echo canceller / noise suppressor on the capture path (the default `VoiceRecognition`
|
||||
/// preset deliberately bypasses them, which is why the host used to hear its own stream back
|
||||
/// from a speaker-playing phone) — and allocates an audio session id for Kotlin's Java-effect
|
||||
/// backstop. `None` on failure (the caller leaves the rest of the session streaming).
|
||||
///
|
||||
/// `muted` is the SESSION's live mic-mute flag (owned by `SessionHandle`, not by this capture),
|
||||
/// honoured per frame by [`encode_loop`]. Sharing it rather than owning it is what makes mute
|
||||
/// survive the mic stop/start a surface recreate performs — and means a capture started while
|
||||
/// muted never encodes its first frame, so there is no window for one to escape.
|
||||
pub fn start(
|
||||
client: Arc<NativeClient>,
|
||||
echo_cancel: bool,
|
||||
muted: Arc<AtomicBool>,
|
||||
) -> Option<MicCapture> {
|
||||
let captured = Arc::new(AtomicU64::new(0));
|
||||
// Chunks discarded on the capture thread (free-list empty / encoder lagging); logged
|
||||
// throttled from the encode worker.
|
||||
@@ -52,7 +84,9 @@ impl MicCapture {
|
||||
|
||||
// One open attempt at a given sharing mode (same pattern as [`crate::audio`]: `open_stream`
|
||||
// consumes the builder AND the callback, so each try rebuilds the channels it captures).
|
||||
let try_open = |sharing: AudioSharingMode| -> ndk::audio::Result<(
|
||||
let try_open = |sharing: AudioSharingMode,
|
||||
voice: bool|
|
||||
-> ndk::audio::Result<(
|
||||
AudioStream,
|
||||
Receiver<Vec<f32>>,
|
||||
SyncSender<Vec<f32>>,
|
||||
@@ -99,13 +133,25 @@ impl MicCapture {
|
||||
AudioCallbackResult::Continue
|
||||
};
|
||||
|
||||
let stream = AudioStreamBuilder::new()?
|
||||
// NOTE: no `.frames_per_data_callback(...)`: AAudio's own docs call leaving it unset
|
||||
// the lowest-latency path (the callback then runs at the device's optimal burst,
|
||||
// while pinning a size inserts an adaptation buffer), and the encode side re-chunks
|
||||
// to 10 ms frames regardless of how the bursts arrive.
|
||||
let mut builder = AudioStreamBuilder::new()?
|
||||
.direction(AudioDirection::Input)
|
||||
.sample_rate(SAMPLE_RATE)
|
||||
.channel_count(CHANNELS as i32)
|
||||
.format(AudioFormat::PCM_Float)
|
||||
.performance_mode(AudioPerformanceMode::LowLatency)
|
||||
.sharing_mode(sharing)
|
||||
.sharing_mode(sharing);
|
||||
if voice {
|
||||
// VoiceCommunication routes the capture through the HAL's AEC/NS; the allocated
|
||||
// session id (`None` = allocate) is what Kotlin attaches the Java effects to.
|
||||
builder = builder
|
||||
.input_preset(AudioInputPreset::VoiceCommunication)
|
||||
.session_id(None);
|
||||
}
|
||||
let stream = builder
|
||||
.data_callback(Box::new(callback))
|
||||
.error_callback(Box::new(|_s, e| {
|
||||
log::warn!("mic: AAudio error (device reroute/disconnect?): {e:?}");
|
||||
@@ -114,21 +160,52 @@ impl MicCapture {
|
||||
Ok((stream, rx, free_tx))
|
||||
};
|
||||
|
||||
// Exclusive first — MMAP-exclusive is AAudio's lowest-latency path — falling back to Shared
|
||||
// when the device refuses (no MMAP, mic claimed, …). The started-log below prints the mode
|
||||
// the device actually GRANTED (`share=`).
|
||||
let (stream, rx, free_tx) = match try_open(AudioSharingMode::Exclusive) {
|
||||
Ok(opened) => opened,
|
||||
Err(e) => {
|
||||
log::info!("mic: Exclusive open failed ({e}) — retrying Shared");
|
||||
match try_open(AudioSharingMode::Shared) {
|
||||
Ok(opened) => opened,
|
||||
Err(e) => {
|
||||
log::error!("mic: open_stream (RECORD_AUDIO granted?): {e}");
|
||||
return None;
|
||||
}
|
||||
// Exclusive first — MMAP-exclusive is AAudio's lowest-latency path — falling back to
|
||||
// Shared when the device refuses (no MMAP, mic claimed, …); and each sharing mode with
|
||||
// the voice preset before without it, because some HALs reject VoiceCommunication (or a
|
||||
// session id) outright and a mic without echo cancellation still beats no mic. The
|
||||
// ladder's last rungs are exactly the preset-less open this always did. The started-log
|
||||
// below prints what the device actually GRANTED (`share=`/`session=`).
|
||||
let attempts: &[(AudioSharingMode, bool)] = if echo_cancel {
|
||||
&[
|
||||
(AudioSharingMode::Exclusive, true),
|
||||
(AudioSharingMode::Shared, true),
|
||||
(AudioSharingMode::Exclusive, false),
|
||||
(AudioSharingMode::Shared, false),
|
||||
]
|
||||
} else {
|
||||
&[
|
||||
(AudioSharingMode::Exclusive, false),
|
||||
(AudioSharingMode::Shared, false),
|
||||
]
|
||||
};
|
||||
let mut opened = None;
|
||||
for &(sharing, voice) in attempts {
|
||||
match try_open(sharing, voice) {
|
||||
Ok(o) => {
|
||||
opened = Some(o);
|
||||
break;
|
||||
}
|
||||
Err(e) => log::info!(
|
||||
"mic: open {sharing:?}{} failed ({e}) — trying the next fallback",
|
||||
if voice { "+VoiceCommunication" } else { "" },
|
||||
),
|
||||
}
|
||||
}
|
||||
let (stream, rx, free_tx) = match opened {
|
||||
Some(o) => o,
|
||||
None => {
|
||||
log::error!("mic: open_stream (RECORD_AUDIO granted?): every mode refused");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// The session id AAudio actually allocated (only a voice rung asks for one): `> 0` is the
|
||||
// handle Kotlin hangs the Java AcousticEchoCanceler/NoiseSuppressor off as the HAL
|
||||
// preset's backstop; `0` = none, nothing to attach.
|
||||
let session_id = match stream.session_id() {
|
||||
SessionId::Allocated(id) => id.get(),
|
||||
SessionId::None => 0,
|
||||
};
|
||||
|
||||
if let Err(e) = stream.request_start() {
|
||||
@@ -136,7 +213,7 @@ impl MicCapture {
|
||||
return None;
|
||||
}
|
||||
log::info!(
|
||||
"mic: AAudio input started rate={} ch={} fmt={:?} share={:?}",
|
||||
"mic: AAudio input started rate={} ch={} fmt={:?} share={:?} session={session_id}",
|
||||
stream.sample_rate(),
|
||||
stream.channel_count(),
|
||||
stream.format(),
|
||||
@@ -147,15 +224,21 @@ impl MicCapture {
|
||||
let sd = shutdown.clone();
|
||||
let join = std::thread::Builder::new()
|
||||
.name("pf-mic".into())
|
||||
.spawn(move || encode_loop(client, rx, free_tx, sd, captured, dropped))
|
||||
.spawn(move || encode_loop(client, rx, free_tx, sd, muted, captured, dropped))
|
||||
.ok();
|
||||
|
||||
Some(MicCapture {
|
||||
_stream: stream,
|
||||
session_id,
|
||||
shutdown,
|
||||
join,
|
||||
})
|
||||
}
|
||||
|
||||
/// The audio-session id AAudio allocated (`> 0`; see [`MicCapture::start`]), `0` = none.
|
||||
pub fn session_id(&self) -> i32 {
|
||||
self.session_id
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MicCapture {
|
||||
@@ -168,20 +251,29 @@ impl Drop for MicCapture {
|
||||
}
|
||||
}
|
||||
|
||||
/// Consumer: drain captured f32 → accumulate → Opus `encode_float` 20 ms stereo frames → `send_mic`.
|
||||
/// Consumer: drain captured f32 → accumulate → Opus `encode_float` 10 ms mono frames → `send_mic`.
|
||||
/// Drained chunk buffers go back to the callback's free-list; the encode scratch is reused across
|
||||
/// frames (only the packet Vec handed to `send_mic` is allocated per frame — it's sent away owned).
|
||||
///
|
||||
/// While `muted` is set a formed frame is dropped instead of encoded (see the frame loop) — the
|
||||
/// capture side keeps running exactly as it does unmuted, so nothing about the stream, its ring or
|
||||
/// its backlog behaviour changes across a toggle.
|
||||
fn encode_loop(
|
||||
client: Arc<NativeClient>,
|
||||
rx: Receiver<Vec<f32>>,
|
||||
free_tx: SyncSender<Vec<f32>>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
muted: Arc<AtomicBool>,
|
||||
captured: Arc<AtomicU64>,
|
||||
dropped: Arc<AtomicU64>,
|
||||
) {
|
||||
// Fold this Opus-encode/uplink thread into the client's hot-thread set so the ADPF session the
|
||||
// decode thread opens keeps mic encode on a fast core too (the playback side's decode_loop
|
||||
// does the same). No-op below API 33.
|
||||
client.register_hot_thread();
|
||||
let mut enc = match opus::Encoder::new(
|
||||
SAMPLE_RATE as u32,
|
||||
opus::Channels::Stereo,
|
||||
opus::Channels::Mono,
|
||||
opus::Application::Voip,
|
||||
) {
|
||||
Ok(e) => e,
|
||||
@@ -191,13 +283,21 @@ fn encode_loop(
|
||||
}
|
||||
};
|
||||
let _ = enc.set_bitrate(opus::Bitrate::Bits(MIC_BITRATE));
|
||||
// Speech tuning: complexity 5 roughly halves encode cost for no audible loss at this rate,
|
||||
// and in-band FEC at an assumed 10% loss lets the host's decoder reconstruct a dropped
|
||||
// datagram from its successor instead of playing a hole (the uplink is fire-and-forget).
|
||||
let _ = enc.set_complexity(5);
|
||||
let _ = enc.set_inband_fec(true);
|
||||
let _ = enc.set_packet_loss_perc(10);
|
||||
|
||||
let frame = FRAME_SAMPLES * CHANNELS;
|
||||
let mut ring: VecDeque<f32> = VecDeque::with_capacity(frame * 4);
|
||||
let mut pcm = vec![0f32; frame]; // reusable encode scratch (one 20 ms frame)
|
||||
let mut out = vec![0u8; 4000]; // max Opus packet for a 20 ms frame fits easily
|
||||
let mut pcm = vec![0f32; frame]; // reusable encode scratch (one 10 ms frame)
|
||||
let mut out = vec![0u8; 4000]; // max Opus packet for a 10 ms frame fits easily
|
||||
let mut seq: u32 = 0;
|
||||
let mut sent: u64 = 0;
|
||||
let mut stale: u64 = 0; // frames shed by the backlog self-heal (see BACKLOG_MAX_FRAMES)
|
||||
let mut muted_frames: u64 = 0; // frames dropped unencoded because the user muted
|
||||
let mut peak = 0f32; // loudest |sample| since the last log — tells speech from silence
|
||||
|
||||
while !shutdown.load(Ordering::Relaxed) {
|
||||
@@ -207,11 +307,41 @@ fn encode_loop(
|
||||
// callback's free-list (dropped only if the pool is momentarily full).
|
||||
ring.extend(chunk.drain(..));
|
||||
let _ = free_tx.try_send(chunk);
|
||||
// Drain whatever else queued while we were away, so a post-stall backlog lands as
|
||||
// ONE lump the self-heal below can size up — chunk-at-a-time it would be encoded
|
||||
// (and inflicted on the host as standing delay) before it ever looked deep.
|
||||
while let Ok(mut chunk) = rx.try_recv() {
|
||||
ring.extend(chunk.drain(..));
|
||||
let _ = free_tx.try_send(chunk);
|
||||
}
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout) => continue, // wake to re-check shutdown
|
||||
Err(RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
// Self-heal the latency ratchet: a stall (scheduler hiccup, a slow send) queues stale
|
||||
// audio, and every ms of it would ride the stream as mic delay for the rest of the
|
||||
// session. Jump to the newest ~20 ms (one audible blip), counting the shed.
|
||||
if ring.len() > BACKLOG_MAX_FRAMES * frame {
|
||||
let excess = ring.len() - BACKLOG_KEEP_FRAMES * frame;
|
||||
ring.drain(..excess);
|
||||
stale += (excess / frame) as u64;
|
||||
}
|
||||
while ring.len() >= frame {
|
||||
// Muted: drop the frame at the last point before it would become an Opus packet —
|
||||
// room audio is never encoded and nothing goes on the wire. `seq` does NOT advance:
|
||||
// it numbers the datagrams the host de-jitters, and that side reads a seq jump as
|
||||
// loss (conceal + a counted gap) where a mute is a pause. Freezing it means the
|
||||
// frame after an unmute continues the chain, which is what the host's own
|
||||
// `reset_stream` doc calls for and what the desktop uplink does. (Encoding silence
|
||||
// instead would keep a pointless uplink and a host-side ring alive for the whole
|
||||
// mute.) `peak` is the loudest sample the UPLINK carried since the last log, so a
|
||||
// dropped frame resets rather than raises it.
|
||||
if muted.load(Ordering::Relaxed) {
|
||||
ring.drain(..frame);
|
||||
muted_frames += 1;
|
||||
peak = 0.0;
|
||||
continue;
|
||||
}
|
||||
for (dst, src) in pcm.iter_mut().zip(ring.drain(..frame)) {
|
||||
*dst = src;
|
||||
}
|
||||
@@ -227,9 +357,10 @@ fn encode_loop(
|
||||
let _ = client.send_mic(seq, pts, out[..len].to_vec());
|
||||
seq = seq.wrapping_add(1);
|
||||
sent += 1;
|
||||
if sent % 250 == 0 {
|
||||
if sent % 500 == 0 {
|
||||
log::info!(
|
||||
"mic: sent={sent} captured_frames={} dropped_chunks={} peak={peak:.3}",
|
||||
"mic: sent={sent} captured_frames={} dropped_chunks={} \
|
||||
stale_frames={stale} muted_frames={muted_frames} peak={peak:.3}",
|
||||
captured.load(Ordering::Relaxed),
|
||||
dropped.load(Ordering::Relaxed),
|
||||
);
|
||||
@@ -241,7 +372,8 @@ fn encode_loop(
|
||||
}
|
||||
}
|
||||
log::info!(
|
||||
"mic: stopped (sent={sent} captured_frames={} dropped_chunks={})",
|
||||
"mic: stopped (sent={sent} captured_frames={} dropped_chunks={} stale_frames={stale} \
|
||||
muted_frames={muted_frames})",
|
||||
captured.load(Ordering::Relaxed),
|
||||
dropped.load(Ordering::Relaxed),
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -83,6 +83,28 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetLowLaten
|
||||
punktfunk_core::transport::set_dscp_default(enabled != 0);
|
||||
}
|
||||
|
||||
/// `debug.punktfunk.force_parts` = 1: arm slice-progressive parts delivery even when the
|
||||
/// Kotlin `FEATURE_PartialFrame` probe said no — the rebuild-free on-glass experiment for a
|
||||
/// decoder that may accept `BUFFER_FLAG_PARTIAL_FRAME` without declaring the feature (the NP3's
|
||||
/// c2.qti decoders declare nothing). Android-only; everywhere else the probe verdict stands.
|
||||
#[cfg(target_os = "android")]
|
||||
fn force_parts_sysprop() -> bool {
|
||||
let mut buf = [0u8; 92]; // PROP_VALUE_MAX
|
||||
// SAFETY: __system_property_get with a valid name + PROP_VALUE_MAX buffer is always safe.
|
||||
let n = unsafe {
|
||||
libc::__system_property_get(
|
||||
c"debug.punktfunk.force_parts".as_ptr(),
|
||||
buf.as_mut_ptr().cast(),
|
||||
)
|
||||
};
|
||||
n > 0 && std::str::from_utf8(&buf[..n as usize]).unwrap_or("").trim() == "1"
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn force_parts_sysprop() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex, bitrateKbps,
|
||||
/// compositorPref, gamepadPref, hdrEnabled, audioChannels, preferredCodec, timeoutMs, launch,
|
||||
/// deviceName): Long`.
|
||||
@@ -115,12 +137,15 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
compositor_pref: jint,
|
||||
gamepad_pref: jint,
|
||||
hdr_enabled: jboolean,
|
||||
multi_slice_ok: jboolean,
|
||||
frame_parts_ok: jboolean,
|
||||
audio_channels: jint,
|
||||
video_codecs: jint,
|
||||
preferred_codec: jint,
|
||||
timeout_ms: jint,
|
||||
launch: JString<'local>,
|
||||
device_name: JString<'local>,
|
||||
pad_audio_ok: jboolean,
|
||||
) -> jlong {
|
||||
let host: String = match env.get_string(&host) {
|
||||
Ok(s) => s.into(),
|
||||
@@ -153,6 +178,24 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
} else {
|
||||
Some((cert, key))
|
||||
};
|
||||
// Slice-progressive parts, by decoder truth (Kotlin's FEATURE_PartialFrame probe) — with a
|
||||
// sysprop escape hatch for the on-glass science question the probe can't answer: does the
|
||||
// decoder ACTUALLY choke on BUFFER_FLAG_PARTIAL_FRAME input, or does it merely not declare
|
||||
// the feature? (`adb shell setprop debug.punktfunk.force_parts 1` + stream restart; a codec
|
||||
// that can't take parts errors recoverably and the reanchor gate + keyframe path recovers.)
|
||||
let force_parts = force_parts_sysprop();
|
||||
let frame_parts = frame_parts_ok != 0 || force_parts;
|
||||
// The connect-time capability readout (`adb logcat -s pf.caps`): the P2 slice pipeline is
|
||||
// inert client-side unless BOTH probes pass — this line is the one place that says which.
|
||||
log::info!(
|
||||
target: "pf.caps",
|
||||
"decoder caps: multi_slice={} partial_frame={}{} hdr={} codec_bits={:#x}",
|
||||
multi_slice_ok != 0,
|
||||
frame_parts_ok != 0,
|
||||
if force_parts { " (FORCED by sysprop)" } else { "" },
|
||||
hdr_enabled != 0,
|
||||
video_codecs,
|
||||
);
|
||||
let pin: Option<[u8; 32]> = if pin_hex.is_empty() {
|
||||
None
|
||||
} else {
|
||||
@@ -182,11 +225,19 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
// sends a proper 8-bit BT.709 stream rather than PQ the panel would mis-tone-map. AMediaCodec
|
||||
// decodes Main10 from the SPS and the decode loop signals the Surface HDR dataspace + static
|
||||
// metadata (see crate::decode).
|
||||
if hdr_enabled != 0 {
|
||||
// 10-bit/HDR by panel truth (above) + multi-slice by DECODER truth: Kotlin probes every
|
||||
// decoder this device would use (`VideoDecoders.multiSliceTolerant` — Amlogic wedges the
|
||||
// whole device on multi-slice AUs, the 0.17.0 field regression) and only then may the
|
||||
// host default to >1 slice per frame (its sub-frame readback / the P2 slice pipeline).
|
||||
(if hdr_enabled != 0 {
|
||||
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
|
||||
} else {
|
||||
0
|
||||
},
|
||||
}) | (if multi_slice_ok != 0 {
|
||||
punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE
|
||||
} else {
|
||||
0
|
||||
}),
|
||||
// Requested surround layout (2 = stereo / 6 = 5.1 / 8 = 7.1). The host clamps to what it can
|
||||
// capture and echoes the resolved count in `connector.audio_channels`, which drives the
|
||||
// decoder + AAudio layout (read in `crate::audio::AudioPlayback::start`). Anything else
|
||||
@@ -213,9 +264,26 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
// No display-volume forwarding from Android yet (the panel tone-maps PQ itself via the
|
||||
// Surface dataspace + static metadata) — the host keeps its virtual-display EDID defaults.
|
||||
None,
|
||||
// No non-video caps: this client does not render the host cursor locally (no shape/state
|
||||
// planes in the jni surface), so advertising CLIENT_CAP_CURSOR would stream cursor-less.
|
||||
0,
|
||||
// No CLIENT_CAP_CURSOR: this client does not render the host cursor locally (no
|
||||
// shape/state planes in the jni surface) — advertising it would stream cursor-less.
|
||||
// CLIENT_CAP_PHASE_LOCK is honest: the async decode loop's presenter feeds
|
||||
// report_phase (advisory in v1 — the host arms on report receipt — but the Hello
|
||||
// should say what the client does).
|
||||
// CLIENT_CAP_PAD_AUDIO is the SESSION-level negotiation, separate from the per-pad
|
||||
// arrival bits: without it the host never sets HOST_CAP_PAD_AUDIO and never emits 0xD1,
|
||||
// so declaring a pad's render caps later would have nothing to gate. Gated on the
|
||||
// settings so a user with pad audio off does not make the host provision endpoints.
|
||||
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK
|
||||
| if pad_audio_ok != 0 {
|
||||
punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO
|
||||
} else {
|
||||
0
|
||||
},
|
||||
// Slice-progressive delivery, by decoder truth (Kotlin probes FEATURE_PartialFrame on
|
||||
// every decoder this device would use; `debug.punktfunk.force_parts` overrides for the
|
||||
// on-glass experiment): AU prefixes then arrive as `Frame::part` pieces and the decode
|
||||
// loop feeds them with BUFFER_FLAG_PARTIAL_FRAME.
|
||||
frame_parts,
|
||||
launch, // a store-qualified library id to boot into a game, or None for the desktop
|
||||
device_name, // Kotlin's Build.MODEL — the host's approval-list / trust-store label
|
||||
pin, // Some → Crypto on host-fp mismatch
|
||||
@@ -233,6 +301,10 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
audio: Mutex::new(None),
|
||||
#[cfg(target_os = "android")]
|
||||
mic: Mutex::new(None),
|
||||
#[cfg(target_os = "android")]
|
||||
pad_audio: Mutex::new(None),
|
||||
// A fresh session is never muted (mute is per-session UI state, not a setting).
|
||||
mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
};
|
||||
Box::into_raw(Box::new(handle)) as jlong
|
||||
}
|
||||
|
||||
@@ -185,7 +185,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostSupport
|
||||
/// Floats per sample in the `nativeSendPen` flat array.
|
||||
const PEN_JNI_STRIDE: usize = 10;
|
||||
|
||||
/// `NativeBridge.nativeSendPen(handle, samples, count)` — one stylus batch of STATE-FULL
|
||||
/// Sample ceiling per `nativeSendPen` call: over-cap runs are SPLIT into consecutive ≤8-sample
|
||||
/// `send_pen` batches (the send_pen contract — never truncated), so this only bounds the stack
|
||||
/// buffer. 64 samples ≈ >250 ms of 240 Hz history = a pathological UI-thread stall.
|
||||
const PEN_JNI_MAX_SAMPLES: usize = PEN_BATCH_MAX * 8;
|
||||
|
||||
/// `NativeBridge.nativeSendPen(handle, samples, count)` — one stylus emit of STATE-FULL
|
||||
/// samples, `count` × [`PEN_JNI_STRIDE`] floats, oldest first:
|
||||
/// `[state, tool, x, y, pressure, distance, tilt_deg, azimuth_deg, roll_deg, dt_us]`.
|
||||
/// `state` = the wire `PEN_*` bits; `tool` 0=pen 1=eraser; `x`/`y`/`pressure`/`distance`
|
||||
@@ -203,53 +208,56 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPen(
|
||||
if handle == 0 || count <= 0 {
|
||||
return;
|
||||
}
|
||||
let count = (count as usize).min(PEN_BATCH_MAX);
|
||||
let mut buf = [0f32; PEN_BATCH_MAX * PEN_JNI_STRIDE];
|
||||
let count = (count as usize).min(PEN_JNI_MAX_SAMPLES);
|
||||
let mut buf = [0f32; PEN_JNI_MAX_SAMPLES * PEN_JNI_STRIDE];
|
||||
let flat = &mut buf[..count * PEN_JNI_STRIDE];
|
||||
if env.get_float_array_region(&samples, 0, flat).is_err() {
|
||||
return; // short array — a bridge bug, never worth a crash on the input path
|
||||
}
|
||||
let mut batch = [PenSample::default(); PEN_BATCH_MAX];
|
||||
for (slot, s) in batch.iter_mut().zip(flat.chunks_exact(PEN_JNI_STRIDE)) {
|
||||
if !s[2].is_finite() || !s[3].is_finite() {
|
||||
return; // never forward a NaN coordinate
|
||||
}
|
||||
*slot = PenSample {
|
||||
state: s[0] as u8,
|
||||
tool: if s[1] as u8 == 1 {
|
||||
PenTool::Eraser
|
||||
} else {
|
||||
PenTool::Pen
|
||||
},
|
||||
x: s[2].clamp(0.0, 1.0),
|
||||
y: s[3].clamp(0.0, 1.0),
|
||||
pressure: (s[4].clamp(0.0, 1.0) * 65535.0) as u16,
|
||||
distance: if s[5] < 0.0 {
|
||||
PEN_DISTANCE_UNKNOWN
|
||||
} else {
|
||||
(s[5].clamp(0.0, 1.0) * 65534.0) as u16
|
||||
},
|
||||
tilt_deg: if s[6] < 0.0 {
|
||||
PEN_TILT_UNKNOWN
|
||||
} else {
|
||||
(s[6].clamp(0.0, 90.0)) as u8
|
||||
},
|
||||
azimuth_deg: if s[7] < 0.0 {
|
||||
PEN_ANGLE_UNKNOWN
|
||||
} else {
|
||||
(s[7] as u16) % 360
|
||||
},
|
||||
roll_deg: if s[8] < 0.0 {
|
||||
PEN_ANGLE_UNKNOWN
|
||||
} else {
|
||||
(s[8] as u16) % 360
|
||||
},
|
||||
dt_us: s[9].clamp(0.0, 65535.0) as u16,
|
||||
};
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_pen is &self.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let _ = h.client.send_pen(&batch[..count]);
|
||||
let mut batch = [PenSample::default(); PEN_BATCH_MAX];
|
||||
for run in flat.chunks(PEN_BATCH_MAX * PEN_JNI_STRIDE) {
|
||||
let n = run.len() / PEN_JNI_STRIDE;
|
||||
for (slot, s) in batch.iter_mut().zip(run.chunks_exact(PEN_JNI_STRIDE)) {
|
||||
if !s[2].is_finite() || !s[3].is_finite() {
|
||||
return; // never forward a NaN coordinate
|
||||
}
|
||||
*slot = PenSample {
|
||||
state: s[0] as u8,
|
||||
tool: if s[1] as u8 == 1 {
|
||||
PenTool::Eraser
|
||||
} else {
|
||||
PenTool::Pen
|
||||
},
|
||||
x: s[2].clamp(0.0, 1.0),
|
||||
y: s[3].clamp(0.0, 1.0),
|
||||
pressure: (s[4].clamp(0.0, 1.0) * 65535.0) as u16,
|
||||
distance: if s[5] < 0.0 {
|
||||
PEN_DISTANCE_UNKNOWN
|
||||
} else {
|
||||
(s[5].clamp(0.0, 1.0) * 65534.0) as u16
|
||||
},
|
||||
tilt_deg: if s[6] < 0.0 {
|
||||
PEN_TILT_UNKNOWN
|
||||
} else {
|
||||
(s[6].clamp(0.0, 90.0)) as u8
|
||||
},
|
||||
azimuth_deg: if s[7] < 0.0 {
|
||||
PEN_ANGLE_UNKNOWN
|
||||
} else {
|
||||
(s[7] as u16) % 360
|
||||
},
|
||||
roll_deg: if s[8] < 0.0 {
|
||||
PEN_ANGLE_UNKNOWN
|
||||
} else {
|
||||
(s[8] as u16) % 360
|
||||
},
|
||||
dt_us: s[9].clamp(0.0, 65535.0) as u16,
|
||||
};
|
||||
}
|
||||
let _ = h.client.send_pen(&batch[..n]);
|
||||
}
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendText(handle, text)` — committed IME text, one `TextInput` event per
|
||||
@@ -406,3 +414,66 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadHidR
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendPadTouch(handle, pad, finger, active, x, y)` — one touchpad contact
|
||||
/// from a client-captured controller (the Sony USB capture), forwarded on the rich-input plane
|
||||
/// (`RichInput::Touchpad`, 0xCC). `finger`: contact slot 0/1; `x`/`y`: normalized 0..=65535 in
|
||||
/// SCREEN convention (+y down — the wire's fixed meaning); `active` 0 lifts the finger. The
|
||||
/// host's DualSense-family backends scale onto the virtual pad's touch surface. On-change only —
|
||||
/// the capture diffs, the host holds per-slot state.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadTouch(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jint,
|
||||
finger: jint,
|
||||
active: jboolean,
|
||||
x: jint,
|
||||
y: jint,
|
||||
) {
|
||||
if handle == 0 {
|
||||
return;
|
||||
}
|
||||
// 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::Touchpad {
|
||||
pad: (pad as u32 & 0xF) as u8,
|
||||
finger: (finger as u32 & 0x1) as u8,
|
||||
active: active != 0,
|
||||
x: (x as i64).clamp(0, 65535) as u16,
|
||||
y: (y as i64).clamp(0, 65535) as u16,
|
||||
});
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendPadMotion(handle, pad, gp, gy, gr, ax, ay, az)` — one motion sample
|
||||
/// from a client-captured controller (`RichInput::Motion`, 0xCC): gyro pitch/yaw/roll + accel,
|
||||
/// raw signed-16 values in the pad's own units, passed straight into the host's virtual
|
||||
/// DualSense report (the wire is a unit passthrough). Called from the capture thread at the
|
||||
/// controller's report rate.
|
||||
#[no_mangle]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadMotion(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jint,
|
||||
gyro_pitch: jint,
|
||||
gyro_yaw: jint,
|
||||
gyro_roll: jint,
|
||||
accel_x: jint,
|
||||
accel_y: jint,
|
||||
accel_z: jint,
|
||||
) {
|
||||
if handle == 0 {
|
||||
return;
|
||||
}
|
||||
let c = |v: jint| (v as i64).clamp(i64::from(i16::MIN), i64::from(i16::MAX)) as i16;
|
||||
// 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::Motion {
|
||||
pad: (pad as u32 & 0xF) as u8,
|
||||
gyro: [c(gyro_pitch), c(gyro_yaw), c(gyro_roll)],
|
||||
accel: [c(accel_x), c(accel_y), c(accel_z)],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -61,6 +61,18 @@ pub(crate) struct SessionHandle {
|
||||
audio: Mutex<Option<crate::audio::AudioPlayback>>,
|
||||
#[cfg(target_os = "android")]
|
||||
mic: Mutex<Option<crate::mic::MicCapture>>,
|
||||
/// Tier-A DualSense pad audio (the 0xD1 plane), started by `nativeStartPadAudio` once Kotlin
|
||||
/// has claimed the pad's audio interface and handed its descriptor over. Session-lifetime and
|
||||
/// `Option` because a session may have no wired DualSense at all, which is the common case.
|
||||
#[cfg(target_os = "android")]
|
||||
pub(crate) pad_audio: Mutex<Option<crate::pad_audio::PadAudio>>,
|
||||
/// In-stream mic mute, set via `nativeSetMicMuted` and read per 10 ms frame by the mic's
|
||||
/// encode loop ([`crate::mic`]). Session-lifetime rather than per-[`crate::mic::MicCapture`]
|
||||
/// for the same reason the stats gate is: the mic stops and restarts across a surface
|
||||
/// recreate, and a mute the user set must come back with it — with no window in which the
|
||||
/// fresh capture could send an unmuted frame. Per session and never persisted: a new session
|
||||
/// starts unmuted.
|
||||
pub mic_muted: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
struct VideoThread {
|
||||
@@ -92,6 +104,14 @@ impl SessionHandle {
|
||||
fn stop_mic(&self) {
|
||||
let _ = self.mic.lock().unwrap().take();
|
||||
}
|
||||
|
||||
/// Stop pad audio. Dropping the [`crate::pad_audio::PadAudio`] joins its render thread, which
|
||||
/// is what guarantees nothing is still writing to the descriptor when Kotlin closes the
|
||||
/// `UsbDeviceConnection`. Idempotent.
|
||||
#[cfg(target_os = "android")]
|
||||
pub(crate) fn stop_pad_audio(&self) {
|
||||
let _ = self.pad_audio.lock().unwrap().take();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SessionHandle {
|
||||
@@ -101,6 +121,8 @@ impl Drop for SessionHandle {
|
||||
self.stop_audio();
|
||||
#[cfg(target_os = "android")]
|
||||
self.stop_mic();
|
||||
#[cfg(target_os = "android")]
|
||||
self.stop_pad_audio();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,11 +10,13 @@ use jni::JNIEnv;
|
||||
|
||||
use super::{jni_guard, SessionHandle};
|
||||
|
||||
/// `NativeBridge.nativeStartVideo(handle, surface, decoderName, lowLatencyMode, lowLatencyFeature)`
|
||||
/// — wrap the SurfaceView's `Surface` as an `ANativeWindow` and start the decode thread rendering
|
||||
/// onto it. `decoderName` is the codec Kotlin ranked from `MediaCodecList` (`""` = let the platform
|
||||
/// resolve the default for the MIME); `lowLatencyMode` is the user's master toggle;
|
||||
/// `lowLatencyFeature` is whether that decoder advertised `FEATURE_LowLatency` (HUD label only).
|
||||
/// `NativeBridge.nativeStartVideo(handle, surface, decoderName, lowLatencyMode, lowLatencyFeature,
|
||||
/// isTv, presentPriority, smoothBuffer)` — wrap the SurfaceView's `Surface` as an `ANativeWindow`
|
||||
/// and start the decode thread rendering onto it. `decoderName` is the codec Kotlin ranked from
|
||||
/// `MediaCodecList` (`""` = let the platform resolve the default for the MIME); `lowLatencyMode`
|
||||
/// is the user's master toggle; `lowLatencyFeature` is whether that decoder advertised
|
||||
/// `FEATURE_LowLatency` (HUD label only); `presentPriority`/`smoothBuffer` are the timeline
|
||||
/// presenter's intent (0 = lowest latency / 1 = smoothness; buffer 0 = auto, 1..=3 frames).
|
||||
/// No-op if already started.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
@@ -27,6 +29,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
low_latency_mode: jboolean,
|
||||
ll_feature: jboolean,
|
||||
is_tv: jboolean,
|
||||
present_priority: jni::sys::jint,
|
||||
smooth_buffer: jni::sys::jint,
|
||||
panel_fps: jni::sys::jint,
|
||||
) {
|
||||
use super::VideoThread;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
@@ -70,6 +75,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
ll_feature: ll_feature != 0,
|
||||
low_latency_mode: low_latency_mode != 0,
|
||||
is_tv: is_tv != 0,
|
||||
present_priority,
|
||||
smooth_buffer,
|
||||
panel_hz: panel_fps,
|
||||
};
|
||||
let join = std::thread::Builder::new()
|
||||
.name("pf-decode".into())
|
||||
@@ -169,11 +177,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD
|
||||
/// (unified stats spec, `design/stats-unification.md`). Returns 26 doubles
|
||||
/// (unified stats spec, `design/stats-unification.md`). Returns 33 doubles
|
||||
/// `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
|
||||
/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
|
||||
/// netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow, dispValid, displayP50Ms,
|
||||
/// e2eDispP50Ms, e2eDispP95Ms]`
|
||||
/// e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms, presentsWindow, presenterActive,
|
||||
/// feedP50Ms, codecP50Ms, skippedOverflowWindow]`
|
||||
/// (the flags are 1.0/0.0; indexes 0–21 match the previous 22-double layout — 0–13 the original
|
||||
/// 14-double one with the latency pair re-based to the end-to-end capture→decoded headline, 14/15
|
||||
/// the stage p50s tiling it: `host+network` = capture→received, `decode` = received→decoded; 16/17
|
||||
@@ -186,7 +195,15 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
||||
/// OnFrameRendered render timestamps — when `dispValid` is 1.0 the HUD headline becomes the
|
||||
/// directly-measured capture→displayed pair at 24/25 with `display` = decoded→displayed p50 at 23
|
||||
/// closing the equation, and when 0.0 — no render callback landed this window — it falls back to
|
||||
/// the capture→decoded headline at 2/3), or `null` when no decode thread is running.
|
||||
/// the capture→decoded headline at 2/3; 26–29 are the timeline presenter's split of the `display`
|
||||
/// term — `pace` = decoded→release (store + glass budget) p50 at 26, `latch` =
|
||||
/// release→displayed (SurfaceFlinger) p50 at 27, the window's on-glass confirm count at 28
|
||||
/// (`presents` vs `fps` is the presenter-health pair), and 29 = 1.0 while the timeline presenter
|
||||
/// is active this session; 30/31 are the `decode` stage's split p50s — `feed` =
|
||||
/// received→queued (hand-off + input-slot wait) at 30 and `codec` = queued→decoded (codec-pure,
|
||||
/// from the AU's last piece) at 31, both 0.0 when no sample landed (sync loop); 32 is the
|
||||
/// parked-AU overflow subset of the window's `skipped` at 19 (decoder fell behind, vs benign
|
||||
/// newest-wins pacing)), or `null` when no decode thread is running.
|
||||
/// Poll ~1 Hz from the UI; each call
|
||||
/// resets the measurement window. Not android-gated — pure `jni` + connector reads, so it links on
|
||||
/// the host build too (Kotlin only ever calls it on device).
|
||||
@@ -210,7 +227,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
.drain(h.client.frames_dropped(), h.client.fec_recovered_shards());
|
||||
let mode = h.client.mode();
|
||||
let color = h.client.color;
|
||||
let buf: [f64; 26] = [
|
||||
let buf: [f64; 33] = [
|
||||
snap.fps,
|
||||
snap.mbps,
|
||||
snap.e2e_p50_ms,
|
||||
@@ -251,6 +268,19 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
snap.display_p50_ms,
|
||||
snap.e2e_disp_p50_ms,
|
||||
snap.e2e_disp_p95_ms,
|
||||
// Timeline-presenter split of the `display` term (pace = decoded→release, latch =
|
||||
// release→displayed), the window's on-glass confirm count, and whether the presenter
|
||||
// is active at all (0.0 = legacy release-immediately path — split reads 0 too).
|
||||
snap.pace_p50_ms,
|
||||
snap.latch_p50_ms,
|
||||
snap.presents as f64,
|
||||
if h.stats.presenter_active() { 1.0 } else { 0.0 },
|
||||
// The `decode` stage's split (P3 science): feed = received→queued (hand-off +
|
||||
// input-slot wait), codec = queued→decoded (codec-pure) — and the parked-AU
|
||||
// overflow subset of `skipped` (decoder-health vs benign pacing drops).
|
||||
snap.feed_p50_ms,
|
||||
snap.codec_p50_ms,
|
||||
snap.skipped_overflow as f64,
|
||||
];
|
||||
let arr = match env.new_double_array(buf.len() as jsize) {
|
||||
Ok(a) => a,
|
||||
@@ -264,10 +294,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoSize(handle): IntArray?` — the negotiated video mode as
|
||||
/// `[width, height]`. Resolved at the handshake (Welcome), so it is known before a single frame
|
||||
/// arrives: the UI sizes the video surface to the STREAM's aspect rather than stretching it to the
|
||||
/// panel's. `null` on a `0` handle. Not android-gated — pure `jni` + a connector read, so it links
|
||||
/// on the host build too. Cheap; safe on the UI thread.
|
||||
/// `[width, height, refreshHz]`. Resolved at the handshake (Welcome), so it is known before a
|
||||
/// single frame arrives: the UI sizes the video surface to the STREAM's aspect rather than
|
||||
/// stretching it to the panel's, and pins the panel's display mode to the stream refresh. The
|
||||
/// trailing `refreshHz` was appended later — old readers index only 0/1 and never see it. `null`
|
||||
/// on a `0` handle. Not android-gated — pure `jni` + a connector read, so it links on the host
|
||||
/// build too. Cheap; safe on the UI thread.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
|
||||
env: JNIEnv,
|
||||
@@ -281,7 +313,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let mode = h.client.mode();
|
||||
let buf: [i32; 2] = [mode.width as i32, mode.height as i32];
|
||||
let buf: [i32; 3] = [
|
||||
mode.width as i32,
|
||||
mode.height as i32,
|
||||
mode.refresh_hz as i32,
|
||||
];
|
||||
let arr = match env.new_int_array(buf.len() as jsize) {
|
||||
Ok(a) => a,
|
||||
Err(_) => return std::ptr::null_mut(),
|
||||
@@ -365,33 +401,49 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopAudio(
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeStartMic(handle)` — start mic capture (AAudio input → Opus → host `send_mic`).
|
||||
/// No-op if already running or on a `0` handle. Caller MUST hold RECORD_AUDIO; a failure (e.g. no
|
||||
/// permission) leaves the rest of the session streaming.
|
||||
/// `NativeBridge.nativeStartMic(handle, echoCancel): Int` — start mic capture (AAudio input →
|
||||
/// Opus → host `send_mic`). `echoCancel` opens the capture under the `VoiceCommunication` preset
|
||||
/// (the HAL's echo canceller / noise suppressor) and allocates an audio session id; the return
|
||||
/// value is that id (`> 0`), so Kotlin can attach the Java `AcousticEchoCanceler`/`NoiseSuppressor`
|
||||
/// as a backstop — `0` when none was allocated (echoCancel off, the preset fell back to the plain
|
||||
/// open, a `0` handle, or the mic failed entirely). Already running (a surface recreate) returns
|
||||
/// the running capture's id. Caller MUST hold RECORD_AUDIO; a failure (e.g. no permission) leaves
|
||||
/// the rest of the session streaming.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) {
|
||||
echo_cancel: jboolean,
|
||||
) -> jni::sys::jint {
|
||||
if handle == 0 {
|
||||
return;
|
||||
return 0;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let mut guard = h.mic.lock().unwrap();
|
||||
if guard.is_some() {
|
||||
return; // already capturing
|
||||
if let Some(m) = guard.as_ref() {
|
||||
return m.session_id(); // already capturing — same stream, same session
|
||||
}
|
||||
match crate::mic::MicCapture::start(h.client.clone()) {
|
||||
Some(m) => *guard = Some(m),
|
||||
None => log::error!("nativeStartMic: mic init failed (RECORD_AUDIO? — session unaffected)"),
|
||||
// The capture SHARES the session's mute flag, so one started while muted stays muted (and
|
||||
// sends nothing) from its very first frame — see `SessionHandle::mic_muted`.
|
||||
match crate::mic::MicCapture::start(h.client.clone(), echo_cancel != 0, h.mic_muted.clone()) {
|
||||
Some(m) => {
|
||||
let session_id = m.session_id();
|
||||
*guard = Some(m);
|
||||
session_id
|
||||
}
|
||||
None => {
|
||||
log::error!("nativeStartMic: mic init failed (RECORD_AUDIO? — session unaffected)");
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeStopMic(handle)` — stop + join the mic thread and close the AAudio input
|
||||
/// stream (without closing the session). No-op on `0`.
|
||||
/// stream (without closing the session). No-op on `0`. Leaves the session's mute state alone: a
|
||||
/// surface recreate stops and restarts the mic, and a user who muted must stay muted through it.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
|
||||
@@ -407,3 +459,164 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeStartPadAudio(handle, pad, fd, haptics, speaker): Boolean` — start tier-A
|
||||
/// DualSense pad audio on a descriptor Kotlin has already obtained.
|
||||
///
|
||||
/// `fd` comes from `UsbDeviceConnection.getFileDescriptor()` **after** claiming the pad's audio
|
||||
/// streaming interface. Kotlin owns that connection and **must keep it open until
|
||||
/// `nativeStopPadAudio` returns**: the renderer borrows the descriptor and never closes it, so
|
||||
/// closing early would pull it out from under an in-flight isochronous transfer.
|
||||
///
|
||||
/// Returns `false` when there is nothing to render (both kinds disabled) or the thread would not
|
||||
/// start. A kernel that refuses the interface claim is NOT reported here — the renderer discovers
|
||||
/// that on its own thread and degrades to tier C, because some OEM kernels refuse and there is no
|
||||
/// app-side fix worth blocking a session on.
|
||||
#[no_mangle]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAudio(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jni::sys::jint,
|
||||
fd: jni::sys::jint,
|
||||
haptics: jboolean,
|
||||
speaker: jboolean,
|
||||
) -> jboolean {
|
||||
jni_guard(0, || {
|
||||
if handle == 0 || fd < 0 || !(0..16).contains(&pad) {
|
||||
return 0;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
// Replace any previous renderer first: dropping it joins the old thread, so two of them
|
||||
// can never hold the same descriptor at once.
|
||||
h.stop_pad_audio();
|
||||
// The capability declaration and the rumble suppression are NOT done here: the renderer
|
||||
// makes both only once its USB stream actually opens (see `pad_audio::render`). Doing them
|
||||
// at spawn time would, on a kernel that refuses the interface claim, take the pad off wire
|
||||
// rumble and give it nothing in return — no haptics of any kind.
|
||||
match crate::pad_audio::start(
|
||||
std::sync::Arc::clone(&h.client),
|
||||
pad as u8,
|
||||
fd,
|
||||
haptics != 0,
|
||||
speaker != 0,
|
||||
) {
|
||||
Some(p) => {
|
||||
*h.pad_audio.lock().unwrap() = Some(p);
|
||||
1
|
||||
}
|
||||
None => 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativePadAudioSelfTest(fd, seconds, hz): Int` — drive the pad directly with a
|
||||
/// tone through the real client render path, with no host and no session involved.
|
||||
///
|
||||
/// The check a standalone harness cannot make: it owns its descriptor by construction, so it can
|
||||
/// never reveal that the client handed the renderer a descriptor something else was already
|
||||
/// driving. Returns sample frames written, or negative on failure (see `pad_audio::SelfTest`).
|
||||
#[no_mangle]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadAudioSelfTest(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
fd: jni::sys::jint,
|
||||
seconds: jni::sys::jint,
|
||||
hz: jni::sys::jint,
|
||||
) -> jni::sys::jint {
|
||||
jni_guard(-1, || {
|
||||
if fd < 0 {
|
||||
return -1;
|
||||
}
|
||||
// SAFETY: Kotlin holds the owning UsbDeviceConnection open across this call and drives no
|
||||
// other transfers on it (it opens a dedicated connection for exactly this).
|
||||
unsafe { crate::pad_audio::self_test(fd, seconds, hz) }
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeStopPadAudio(handle, pad)` — stop tier-A pad audio and join its thread.
|
||||
///
|
||||
/// Returns only once the render thread is joined, which is the point: Kotlin may close the
|
||||
/// `UsbDeviceConnection` as soon as this returns and not before.
|
||||
#[no_mangle]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudio(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jni::sys::jint,
|
||||
) {
|
||||
jni_guard((), || {
|
||||
if handle != 0 {
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.stop_pad_audio();
|
||||
if (0..16).contains(&pad) {
|
||||
// Withdraw the capability and hand the pad back to wire rumble, in that order:
|
||||
// the host stops sending 0xD1 before tier C resumes, so the two never overlap.
|
||||
h.client.set_pad_audio_caps(pad as u8, 0);
|
||||
crate::pad_audio::set_tier_a(pad as u8, false);
|
||||
crate::pad_audio::clear_haptics_liveness(pad as u8);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSetMicMuted(handle, muted)` — mute/unmute the mic uplink mid-stream.
|
||||
///
|
||||
/// Muting deliberately does NOT stop the capture: the AAudio input stream, the input-preset rung
|
||||
/// it settled on and its primed buffers all stay exactly as they are, and the encode loop simply
|
||||
/// drops each 10 ms frame instead of encoding + sending it. A stop/start would re-run the preset
|
||||
/// fallback ladder and re-prime buffers on every toggle — hundreds of ms, and possibly a different
|
||||
/// rung (echo cancellation silently lost). This way a toggle costs one atomic store here and one
|
||||
/// relaxed load per frame there, and takes effect on the very next 10 ms boundary.
|
||||
///
|
||||
/// Sticky for the SESSION (the flag lives on the handle, not on the capture), so the mic restart a
|
||||
/// surface recreate performs comes back muted with no window for an unmuted frame to escape; a
|
||||
/// fresh session always starts unmuted. No-op on `0`. Not android-gated — pure `jni` + an atomic
|
||||
/// store, so it links on the host build too.
|
||||
///
|
||||
/// One honest consequence of keeping the stream open: the platform's own recording indicator stays
|
||||
/// lit while muted, because the mic really is still open. What stops is the encode and the send —
|
||||
/// no captured audio leaves the process.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetMicMuted(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
muted: jboolean,
|
||||
) {
|
||||
jni_guard((), || {
|
||||
if handle != 0 {
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.mic_muted
|
||||
.store(muted != 0, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeMicActive(handle): Boolean` — is a mic capture actually RUNNING? `true` only
|
||||
/// between a `nativeStartMic` that opened a stream and the matching `nativeStopMic`. The in-stream
|
||||
/// mute control is offered on this evidence rather than on the user's setting, so a device that
|
||||
/// refused every AAudio input rung (or a missing RECORD_AUDIO grant) shows no control instead of a
|
||||
/// lie about a mic that is being heard. `false` on a `0` handle. Cheap (one uncontended lock).
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeMicActive(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) -> jboolean {
|
||||
jni_guard(0, || {
|
||||
if handle == 0 {
|
||||
return 0;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
jboolean::from(h.mic.lock().unwrap().is_some())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@ pub struct VideoStats {
|
||||
/// they (and the caller's latency computation — see `enabled`) early-out on this flag alone.
|
||||
/// Off until Kotlin shows the HUD.
|
||||
enabled: AtomicBool,
|
||||
/// Whether the timeline presenter is active this session (async loop, not sysprop-disabled) —
|
||||
/// stats index 29, so the HUD can label the display split it is (or isn't) getting.
|
||||
presenter_active: AtomicBool,
|
||||
/// The resolved decoder identity for the HUD: the codec's actual `AMediaCodec` name (e.g.
|
||||
/// `c2.qti.avc.decoder`) and whether it advertised `FEATURE_LowLatency`. Set once when the
|
||||
/// decode thread creates the codec (`set_decoder`), read one-shot by `nativeVideoDecoderLabel`.
|
||||
@@ -67,9 +70,29 @@ struct Inner {
|
||||
/// `end-to-end` = capture→displayed samples, µs (skew-corrected) — the spec's headline,
|
||||
/// measured directly (not summed from stages). Empty under the same fallback as `display_us`.
|
||||
e2e_disp_us: Vec<u64>,
|
||||
/// The `display` stage's presenter split, decoded→release (pace wait: store + glass budget),
|
||||
/// µs. Empty on the legacy release-immediately path.
|
||||
pace_us: Vec<u64>,
|
||||
/// The other half of the split, release→displayed (SurfaceFlinger's latch + scanout), µs —
|
||||
/// from the `OnFrameRendered` render timestamps. `pace + latch ≈ display` per frame.
|
||||
latch_us: Vec<u64>,
|
||||
/// The `decode` stage's feed split, received→queued (hand-off + input-slot wait), µs. Empty
|
||||
/// when no receipt stamp matched (HUD off and ABR not measuring decode).
|
||||
feed_us: Vec<u64>,
|
||||
/// The other half, queued→decoded (codec-pure: the decoder's own time on the AU, measured
|
||||
/// from its LAST piece so a slice-progressive head start shows up as a shrink here), µs.
|
||||
codec_us: Vec<u64>,
|
||||
/// Frames confirmed on glass this window (`OnFrameRendered` callbacks) — the `presents`-vs-
|
||||
/// `fps` health pair: presents ≪ fps means the presenter is dropping/serializing; an fps
|
||||
/// deficit is upstream.
|
||||
presents: u64,
|
||||
/// Client-side newest-wins/pacing drops this window (decoded frames released without
|
||||
/// rendering, or parked AUs dropped on overflow) — the spec's `skipped` counter.
|
||||
skipped: u64,
|
||||
/// The subset of `skipped` that was parked-AU OVERFLOW (the decoder fell behind and whole
|
||||
/// AUs were dropped before feeding) — a decoder-health signal, vs the benign newest-wins
|
||||
/// pacing majority. Always ≤ `skipped`.
|
||||
skipped_overflow: u64,
|
||||
/// Baselines for windowing the session-cumulative connector counters: the unrecoverable-drop
|
||||
/// and FEC-recovered totals as of the last drain (or the enable that opened the window), so
|
||||
/// each snapshot reports only THIS window's `lost` / `FEC` (spec line 4).
|
||||
@@ -101,6 +124,18 @@ pub struct Snapshot {
|
||||
/// Whether any capture→displayed sample landed this window — gates the HUD's headline endpoint
|
||||
/// (`capture→displayed` vs the capture→decoded fallback) and the equation's `display` term.
|
||||
pub disp_valid: bool,
|
||||
/// The `display` stage's presenter split p50s (ms): `pace` = decoded→release (store + glass
|
||||
/// budget), `latch` = release→displayed (SurfaceFlinger). 0.0 when no sample landed (legacy
|
||||
/// path / no render callbacks).
|
||||
pub pace_p50_ms: f64,
|
||||
pub latch_p50_ms: f64,
|
||||
/// The `decode` stage's split p50s (ms): `feed` = received→queued (hand-off + input-slot
|
||||
/// wait), `codec` = queued→decoded (codec-pure, from the AU's last piece). 0.0 when no
|
||||
/// sample landed (sync loop / no receipt stamps).
|
||||
pub feed_p50_ms: f64,
|
||||
pub codec_p50_ms: f64,
|
||||
/// Frames confirmed on glass this window (`OnFrameRendered` callbacks).
|
||||
pub presents: u64,
|
||||
/// Phase-2 `host` / `network` split p50s (ms) — 0.0 when no 0xCF timing matched this window
|
||||
/// (old host / no samples yet), in which case the HUD keeps the combined `host+network` term.
|
||||
pub host_p50_ms: f64,
|
||||
@@ -115,6 +150,8 @@ pub struct Snapshot {
|
||||
pub lost: u64,
|
||||
/// Client-side newest-wins/pacing drops this window (spec `skipped`).
|
||||
pub skipped: u64,
|
||||
/// The parked-AU overflow subset of `skipped` (decoder fell behind; ≤ `skipped`).
|
||||
pub skipped_overflow: u64,
|
||||
/// FEC shards recovered this window (spec `FEC`, windowed from the cumulative counter).
|
||||
pub fec: u64,
|
||||
}
|
||||
@@ -132,6 +169,7 @@ impl VideoStats {
|
||||
pub fn new() -> VideoStats {
|
||||
VideoStats {
|
||||
enabled: AtomicBool::new(false),
|
||||
presenter_active: AtomicBool::new(false),
|
||||
decoder: Mutex::new(None),
|
||||
inner: Mutex::new(Inner {
|
||||
window_start: Instant::now(),
|
||||
@@ -144,7 +182,13 @@ impl VideoStats {
|
||||
decode_us: Vec::with_capacity(256),
|
||||
display_us: Vec::with_capacity(256),
|
||||
e2e_disp_us: Vec::with_capacity(256),
|
||||
pace_us: Vec::with_capacity(256),
|
||||
latch_us: Vec::with_capacity(256),
|
||||
feed_us: Vec::with_capacity(256),
|
||||
codec_us: Vec::with_capacity(256),
|
||||
presents: 0,
|
||||
skipped: 0,
|
||||
skipped_overflow: 0,
|
||||
last_dropped_total: 0,
|
||||
last_fec_total: 0,
|
||||
skew_corrected: false,
|
||||
@@ -160,6 +204,18 @@ impl VideoStats {
|
||||
self.enabled.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Record whether the timeline presenter runs this session (decode thread, once at start).
|
||||
// Set only by the android-only decode thread; unreferenced on the host build — expected.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub fn set_presenter_active(&self, on: bool) {
|
||||
self.presenter_active.store(on, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Whether the timeline presenter is active (stats index 29).
|
||||
pub fn presenter_active(&self) -> bool {
|
||||
self.presenter_active.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Toggle sampling. Enabling resets the window, so the first HUD poll after a show never mixes
|
||||
/// in counters (or a window start) from before the overlay was visible. `dropped_total` /
|
||||
/// `fec_total` are the connector's session-cumulative counters at this instant — they seed the
|
||||
@@ -181,7 +237,13 @@ impl VideoStats {
|
||||
g.decode_us.clear();
|
||||
g.display_us.clear();
|
||||
g.e2e_disp_us.clear();
|
||||
g.pace_us.clear();
|
||||
g.latch_us.clear();
|
||||
g.feed_us.clear();
|
||||
g.codec_us.clear();
|
||||
g.presents = 0;
|
||||
g.skipped = 0;
|
||||
g.skipped_overflow = 0;
|
||||
g.last_dropped_total = dropped_total;
|
||||
g.last_fec_total = fec_total;
|
||||
}
|
||||
@@ -275,6 +337,45 @@ impl VideoStats {
|
||||
g.skipped += n;
|
||||
}
|
||||
|
||||
/// Record parked-AU OVERFLOW drops (whole AUs dropped before feeding — the decoder fell
|
||||
/// behind). Counts into `skipped` too, plus the overflow-only counter, so the HUD can tell
|
||||
/// benign newest-wins pacing from a decoder that can't keep up.
|
||||
// Driven only by the android-only decode thread; unreferenced on the host build — expected.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub fn note_skipped_overflow(&self, n: u64) {
|
||||
if n == 0 || !self.enabled.load(Ordering::Relaxed) {
|
||||
return; // HUD hidden — skip the lock
|
||||
}
|
||||
// Poison-proof for the same reason as `note_received`.
|
||||
let mut g = self
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
g.skipped += n;
|
||||
g.skipped_overflow += n;
|
||||
}
|
||||
|
||||
/// Record one decoded frame's `decode`-stage split: `feed` = received→queued (hand-off +
|
||||
/// input-slot wait; absent when no receipt stamp matched) and `codec` = queued→decoded
|
||||
/// (codec-pure, measured from the AU's LAST piece — a slice-progressive head start shows
|
||||
/// as a shrink here), both µs.
|
||||
// Driven only by the android-only decode thread; unreferenced on the host build — expected.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub fn note_decode_split(&self, feed_us: Option<u64>, codec_us: u64) {
|
||||
if !self.enabled.load(Ordering::Relaxed) {
|
||||
return; // HUD hidden — skip the lock
|
||||
}
|
||||
// Poison-proof for the same reason as `note_received`.
|
||||
let mut g = self
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(f) = feed_us {
|
||||
g.feed_us.push(f);
|
||||
}
|
||||
g.codec_us.push(codec_us);
|
||||
}
|
||||
|
||||
/// Record one decoded output frame: its capture→decoded `end-to-end` sample and its
|
||||
/// received→decoded `decode` stage sample (either may be absent — e.g. the receipt stamp for
|
||||
/// this pts predates the HUD being shown).
|
||||
@@ -298,13 +399,19 @@ impl VideoStats {
|
||||
}
|
||||
|
||||
/// Record one displayed frame (the `OnFrameRendered` render timestamp, re-based to the
|
||||
/// realtime clock): its capture→displayed `end-to-end` sample and its decoded→displayed
|
||||
/// `display` stage sample (either may be absent — the e2e clamp rejected an out-of-range
|
||||
/// value, or the decoded stamp for this pts was already evicted/pre-HUD). Fired from the
|
||||
/// codec's render-callback thread, not the decode thread — the lock makes that safe.
|
||||
/// realtime clock): its capture→displayed `end-to-end` sample, its decoded→displayed
|
||||
/// `display` stage sample, and the presenter split's `latch` half (release→displayed) — any
|
||||
/// may be absent (the e2e clamp rejected an out-of-range value, or the release record for
|
||||
/// this pts was already evicted). Fired from the codec's render-callback thread, not the
|
||||
/// decode thread — the lock makes that safe.
|
||||
// Driven only by the android-only decode path; unreferenced on the host build — expected.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub fn note_displayed(&self, e2e_us: Option<u64>, display_us: Option<u64>) {
|
||||
pub fn note_displayed(
|
||||
&self,
|
||||
e2e_us: Option<u64>,
|
||||
display_us: Option<u64>,
|
||||
latch_us: Option<u64>,
|
||||
) {
|
||||
if !self.enabled.load(Ordering::Relaxed) {
|
||||
return; // HUD hidden — skip the lock (the callback already skipped the clock reads)
|
||||
}
|
||||
@@ -313,12 +420,32 @@ impl VideoStats {
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
g.presents += 1;
|
||||
if let Some(l) = e2e_us {
|
||||
g.e2e_disp_us.push(l);
|
||||
}
|
||||
if let Some(l) = display_us {
|
||||
g.display_us.push(l);
|
||||
}
|
||||
if let Some(l) = latch_us {
|
||||
g.latch_us.push(l);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record one released frame's pace-wait (decoded→release: the presenter's store + glass
|
||||
/// budget), µs — the `display` stage's other half. Decode-thread only.
|
||||
// Driven only by the android-only decode thread; unreferenced on the host build — expected.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub fn note_release(&self, pace_us: u64) {
|
||||
if !self.enabled.load(Ordering::Relaxed) {
|
||||
return; // HUD hidden — skip the lock
|
||||
}
|
||||
// Poison-proof for the same reason as `note_received`.
|
||||
let mut g = self
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
g.pace_us.push(pace_us);
|
||||
}
|
||||
|
||||
/// Compute the window's rates + latency percentiles, then reset for the next window.
|
||||
@@ -341,6 +468,10 @@ impl VideoStats {
|
||||
g.decode_us.sort_unstable();
|
||||
g.display_us.sort_unstable();
|
||||
g.e2e_disp_us.sort_unstable();
|
||||
g.pace_us.sort_unstable();
|
||||
g.latch_us.sort_unstable();
|
||||
g.feed_us.sort_unstable();
|
||||
g.codec_us.sort_unstable();
|
||||
let snap = Snapshot {
|
||||
fps,
|
||||
mbps,
|
||||
@@ -352,6 +483,11 @@ impl VideoStats {
|
||||
decode_p50_ms: pctl_ms(&g.decode_us, 0.50),
|
||||
display_p50_ms: pctl_ms(&g.display_us, 0.50),
|
||||
disp_valid: !g.e2e_disp_us.is_empty(),
|
||||
pace_p50_ms: pctl_ms(&g.pace_us, 0.50),
|
||||
latch_p50_ms: pctl_ms(&g.latch_us, 0.50),
|
||||
feed_p50_ms: pctl_ms(&g.feed_us, 0.50),
|
||||
codec_p50_ms: pctl_ms(&g.codec_us, 0.50),
|
||||
presents: g.presents,
|
||||
host_p50_ms: pctl_ms(&g.host_us, 0.50),
|
||||
net_p50_ms: pctl_ms(&g.net_us, 0.50),
|
||||
lat_valid: !g.e2e_us.is_empty(),
|
||||
@@ -359,6 +495,7 @@ impl VideoStats {
|
||||
frames: g.frames,
|
||||
lost: dropped_total.saturating_sub(g.last_dropped_total),
|
||||
skipped: g.skipped,
|
||||
skipped_overflow: g.skipped_overflow,
|
||||
fec: fec_total.saturating_sub(g.last_fec_total),
|
||||
};
|
||||
g.window_start = Instant::now();
|
||||
@@ -371,7 +508,13 @@ impl VideoStats {
|
||||
g.decode_us.clear();
|
||||
g.display_us.clear();
|
||||
g.e2e_disp_us.clear();
|
||||
g.pace_us.clear();
|
||||
g.latch_us.clear();
|
||||
g.feed_us.clear();
|
||||
g.codec_us.clear();
|
||||
g.presents = 0;
|
||||
g.skipped = 0;
|
||||
g.skipped_overflow = 0;
|
||||
g.last_dropped_total = dropped_total;
|
||||
g.last_fec_total = fec_total;
|
||||
snap
|
||||
|
||||
@@ -34,9 +34,10 @@ let package = Package(
|
||||
// Geist (SIL OFL 1.1) — the brand typeface, shared with punktfunk-website.
|
||||
// Registered with Core Text at first use; see BrandFont.swift.
|
||||
.copy("Resources/Fonts"),
|
||||
// The host cards' OS marks (template vector imagesets derived from the
|
||||
// assets/os-icons masters — FA brands CC BY 4.0 + Simple Icons CC0, see that
|
||||
// README). `.process` compiles the catalog; loaded via OsIcon.swift.
|
||||
// The host cards' OS marks (template vector imagesets generated from the
|
||||
// assets/os-icons masters by scripts/gen-os-icons.sh — per-mark provenance and
|
||||
// licensing in that README). `.process` compiles the catalog; loaded via
|
||||
// OsIcon.swift.
|
||||
.process("Resources/OsIcons.xcassets"),
|
||||
],
|
||||
linkerSettings: [
|
||||
|
||||
@@ -121,7 +121,11 @@ PUNKTFUNK_AUTOCONNECT=<box-ip> PUNKTFUNK_MODE=1280x720x60 swift run PunktfunkCli
|
||||
host's virtual pad.
|
||||
- **App Store screenshots** are automated — `tools/screenshots.sh all` renders the real UI at the
|
||||
required pixel sizes via a DEBUG-only shot mode; the `apple` CI workflow captures the iOS sizes on
|
||||
every main push. See the script header for details.
|
||||
every main push. See the script header for details. The script's `SCENES` array is the listing
|
||||
set, in listing order; override it (`SCENES="06-gamepad-home 10-edithost" tools/screenshots.sh ios`)
|
||||
to capture any of the other scenes in `ShotScenes.all`. Mock data — hosts, adverts, profiles — is
|
||||
seeded in `ShotMock` so a capture is byte-for-byte deterministic and never browses the real LAN
|
||||
(a stranger's hostname reached the live listing that way once).
|
||||
- Deeper design notes live in the internal planning repo (punktfunk-planning:
|
||||
`apple-stage2-presenter.md`).
|
||||
|
||||
|
||||
@@ -315,7 +315,15 @@ struct ContentView: View {
|
||||
clipboardAvailable: model.connection?.hostSupportsClipboard == true,
|
||||
clipboardOn: model.clipboardEnabled,
|
||||
toggleClipboard: { model.toggleClipboardSync() },
|
||||
micAvailable: model.micAvailable,
|
||||
micMuted: model.micMuted,
|
||||
toggleMicMute: { model.toggleMicMute() },
|
||||
disconnect: { model.disconnect() }))
|
||||
// ⌃⌥⇧A fired while input was CAPTURED (InputCapture's chord path posts it — the menu's
|
||||
// identical equivalent can't reach a captured stream). Same toggle either way.
|
||||
.onReceive(NotificationCenter.default.publisher(for: .punktfunkToggleMicMute)) { _ in
|
||||
model.toggleMicMute()
|
||||
}
|
||||
#endif
|
||||
#if os(macOS)
|
||||
// Fullscreen only while a session is up (incl. the trust prompt over the blurred stream),
|
||||
@@ -724,31 +732,48 @@ struct ContentView: View {
|
||||
}
|
||||
.animation(.smooth(duration: 0.28), value: statsVerbosity)
|
||||
}
|
||||
#if os(macOS) || os(tvOS)
|
||||
// The start-of-stream shortcut banner (Windows-client parity): the platform's
|
||||
// reserved controls on a glass pill, bottom-centre, for the first 6 seconds of
|
||||
// every session — independent of the stats HUD, so the keys are discoverable
|
||||
// even with statistics off. The banner's own task drops it (cancelled cleanly
|
||||
// if the session view goes away first). On tvOS it carries the ONLY exits —
|
||||
// Menu/B is swallowed during a session (the `.onExitCommand {}` in the tvOS
|
||||
// session branch), so the hold gestures must be told to the user.
|
||||
// The bottom-centre stack: the muted-microphone badge over the start-of-stream
|
||||
// shortcut banner. ONE overlay for both, so the two can never land on top of each
|
||||
// other in the seconds where they overlap.
|
||||
.overlay(alignment: .bottom) {
|
||||
if captureEnabled && showShortcutHint {
|
||||
Text(Self.shortcutHintText)
|
||||
.font(.geist(Self.shortcutHintFont, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.glassBackground(Capsule())
|
||||
.padding(.bottom, 24)
|
||||
.transition(.opacity)
|
||||
.task {
|
||||
try? await Task.sleep(for: .seconds(6))
|
||||
withAnimation(.easeOut(duration: 0.6)) { showShortcutHint = false }
|
||||
}
|
||||
VStack(spacing: 8) {
|
||||
#if !os(tvOS)
|
||||
// Shown for as long as the mic is muted, at every stats tier and with the
|
||||
// overlay off — see MicMutedBadge. tvOS has no microphone to mute.
|
||||
if captureEnabled && model.micMuted {
|
||||
MicMutedBadge { model.setMicMuted(false) }
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
||||
}
|
||||
#endif
|
||||
#if os(macOS) || os(tvOS)
|
||||
// The start-of-stream shortcut banner (Windows-client parity): the
|
||||
// platform's reserved controls on a glass pill for the first 6 seconds of
|
||||
// every session — independent of the stats HUD, so the keys are
|
||||
// discoverable even with statistics off. The banner's own task drops it
|
||||
// (cancelled cleanly if the session view goes away first). On tvOS it
|
||||
// carries the ONLY exits — Menu/B is swallowed during a session (the
|
||||
// `.onExitCommand {}` in the tvOS session branch), so the hold gestures
|
||||
// must be told to the user.
|
||||
if captureEnabled && showShortcutHint {
|
||||
Text(shortcutHintText)
|
||||
.font(.geist(Self.shortcutHintFont, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.glassBackground(Capsule())
|
||||
.transition(.opacity)
|
||||
.task {
|
||||
try? await Task.sleep(for: .seconds(6))
|
||||
withAnimation(.easeOut(duration: 0.6)) {
|
||||
showShortcutHint = false
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.padding(.bottom, 24)
|
||||
.animation(.easeOut(duration: 0.2), value: model.micMuted)
|
||||
}
|
||||
#endif
|
||||
#if os(iOS)
|
||||
// Touch users have no menu / ⌘D, so when the HUD's Disconnect button isn't on
|
||||
// screen — the overlay off, or the compact pill (which carries no button) —
|
||||
@@ -766,21 +791,24 @@ struct ContentView: View {
|
||||
.overlay(alignment: .topLeading) {
|
||||
if captureEnabled,
|
||||
statsVerbosity == .compact || (statsVerbosity == .off && showTouchExit) {
|
||||
Button { model.disconnect() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.headline.weight(.semibold))
|
||||
.frame(width: 36, height: 36)
|
||||
// Floating glass disc over the frame (26+, material fallback).
|
||||
// interactive: the disc IS the tap target, so the glass reacts
|
||||
// to press.
|
||||
.glassBackground(Circle(), interactive: true)
|
||||
// Match the hit region to the visible disc so every tap also
|
||||
// triggers the interactive-glass press highlight.
|
||||
.contentShape(Circle())
|
||||
HStack(spacing: 10) {
|
||||
Button { model.disconnect() } label: { touchDisc("xmark") }
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Disconnect")
|
||||
// The mic toggle rides the same discs, for the same reason: in these
|
||||
// tiers the HUD carries no buttons (compact is a stat pill, off is
|
||||
// nothing), so this is a touch-only user's ONLY way to mute. Absent —
|
||||
// not greyed — when the session sends no microphone at all.
|
||||
if model.micAvailable {
|
||||
Button { model.toggleMicMute() } label: {
|
||||
touchDisc(model.micMuted ? "mic.slash.fill" : "mic.fill")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
model.micMuted ? "Unmute microphone" : "Mute microphone")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(12)
|
||||
.accessibilityLabel("Disconnect")
|
||||
.transition(.opacity)
|
||||
.task {
|
||||
guard statsVerbosity == .off else { return }
|
||||
@@ -794,14 +822,34 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// One touch-control disc: an SF Symbol on a floating glass disc over the frame (26+,
|
||||
/// material fallback), sized as a comfortable tap target. `interactive`: the disc IS the tap
|
||||
/// target, so the glass reacts to press, and the hit region is matched to the visible disc so
|
||||
/// every tap triggers that press highlight.
|
||||
private func touchDisc(_ symbol: String) -> some View {
|
||||
Image(systemName: symbol)
|
||||
.font(.headline.weight(.semibold))
|
||||
.frame(width: 36, height: 36)
|
||||
.glassBackground(Circle(), interactive: true)
|
||||
.contentShape(Circle())
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
private static let shortcutHintText =
|
||||
"Click the stream to capture · ⌃⌥⇧Q releases the mouse · ⌃⌥⇧D disconnects · ⌃⌥⇧S stats"
|
||||
/// The reserved combos, told once per session. The mute segment appears only when the session
|
||||
/// actually sends a microphone — teaching a shortcut for a mic that isn't on would be a lie.
|
||||
private var shortcutHintText: String {
|
||||
let base =
|
||||
"Click the stream to capture · ⌃⌥⇧Q releases the mouse · ⌃⌥⇧D disconnects · ⌃⌥⇧S stats"
|
||||
return model.micAvailable ? base + " · ⌃⌥⇧A mutes the mic" : base
|
||||
}
|
||||
private static let shortcutHintFont: CGFloat = 12
|
||||
#elseif os(tvOS)
|
||||
private static let shortcutHintText =
|
||||
private var shortcutHintText: String {
|
||||
"Hold the remote's Back button — or L1+R1+Start+Select on a controller — to disconnect"
|
||||
+ " · Touch surface moves the pointer · press clicks · Play/Pause right-clicks"
|
||||
}
|
||||
private static let shortcutHintFont: CGFloat = 22 // read from the couch
|
||||
#endif
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ struct GamepadHomeView: View {
|
||||
// fullScreenCover, so they become generously sized sheets over the dimmed launcher.
|
||||
#if os(macOS)
|
||||
.sheet(isPresented: $showSettings) {
|
||||
GamepadSettingsView()
|
||||
GamepadSettingsView(store: store)
|
||||
.frame(width: 720, height: 640)
|
||||
}
|
||||
.sheet(isPresented: $showAddHost) {
|
||||
@@ -144,7 +144,7 @@ struct GamepadHomeView: View {
|
||||
}
|
||||
.frame(minWidth: 640, minHeight: 420)
|
||||
#else
|
||||
.fullScreenCover(isPresented: $showSettings) { GamepadSettingsView() }
|
||||
.fullScreenCover(isPresented: $showSettings) { GamepadSettingsView(store: store) }
|
||||
.fullScreenCover(isPresented: $showAddHost) {
|
||||
GamepadAddHostView { store.add($0) }
|
||||
}
|
||||
@@ -176,18 +176,33 @@ struct GamepadHomeView: View {
|
||||
// MARK: - Chrome
|
||||
|
||||
private var titleBar: some View {
|
||||
Text("Select a Host")
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.overlay(alignment: .trailing) {
|
||||
// Which pad is driving this UI (name + battery) — quiet, and only where there's
|
||||
// room; a compact-height phone gives the pixels to the carousel instead.
|
||||
if !compact, let active = gamepads.active {
|
||||
ControllerStatusChip(controller: active)
|
||||
.padding(.trailing, 20)
|
||||
}
|
||||
}
|
||||
// The chip used to be a trailing `.overlay`, which reserves no width: on a portrait phone
|
||||
// it sat directly on top of the centred title ("Select a Host" ran straight into the pad
|
||||
// name). Laying it out as a row with a hidden mirror on the leading side keeps the title
|
||||
// optically centred AND clear of the chip at every width; the title shrinks a little
|
||||
// before it would ever truncate.
|
||||
HStack(spacing: 12) {
|
||||
statusChip(hidden: true)
|
||||
Text("Select a Host")
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
.frame(maxWidth: .infinity)
|
||||
statusChip(hidden: false)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
|
||||
/// Which pad is driving this UI (name + battery) — quiet, and only where there's room; a
|
||||
/// compact-height phone gives the pixels to the carousel instead. `hidden` renders the same
|
||||
/// chip purely as a width reserve.
|
||||
@ViewBuilder private func statusChip(hidden: Bool) -> some View {
|
||||
if !compact, let active = gamepads.active {
|
||||
ControllerStatusChip(controller: active)
|
||||
.opacity(hidden ? 0 : 1)
|
||||
.accessibilityHidden(hidden)
|
||||
}
|
||||
}
|
||||
|
||||
private var cardSpacing: CGFloat {
|
||||
|
||||
@@ -24,6 +24,13 @@ import ImageIO
|
||||
|
||||
@MainActor
|
||||
enum ScreenshotMode {
|
||||
/// This process was launched to capture a screenshot. Cheap enough to consult from the
|
||||
/// stores' persistence paths (`HostStore` / `ProfileStore`), which must NOT write their
|
||||
/// mock contents back into a real user's App Group when the harness runs on a dev Mac.
|
||||
static var isActive: Bool {
|
||||
!(ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_SCENE"] ?? "").isEmpty
|
||||
}
|
||||
|
||||
/// The scene requested via PUNKTFUNK_SHOT_SCENE, or nil for a normal launch.
|
||||
static var requestedScene: ShotScene? {
|
||||
let name = ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_SCENE"] ?? ""
|
||||
@@ -41,8 +48,11 @@ struct ScreenshotHostView: View {
|
||||
scene.make()
|
||||
.environment(\.colorScheme, scene.colorScheme)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.black)
|
||||
.ignoresSafeArea()
|
||||
// Black fills the display, but the SCENE keeps its safe area. Ignoring it wholesale
|
||||
// here pushed the stream hero's HUD under the Dynamic Island (the resolution/bitrate
|
||||
// line was unreadable in every 6.9" capture); scenes that genuinely want full bleed —
|
||||
// the streamed frame itself — ignore it themselves.
|
||||
.background(Color.black.ignoresSafeArea())
|
||||
#if os(macOS)
|
||||
.background(MacShotWindowConfigurator(scene: scene))
|
||||
#elseif os(iOS)
|
||||
@@ -129,18 +139,64 @@ enum MacSelfCapture {
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
/// Best-effort orientation lock for the requested scene (landscape for the stream hero, portrait
|
||||
/// for chrome). Requires the app to allow those orientations in Info.plist.
|
||||
/// Orientation lock for the requested scene (landscape for the stream hero, portrait for chrome).
|
||||
/// Requires the app to allow those orientations in Info.plist — it does, for both.
|
||||
private struct IOSOrientationConfigurator: UIViewControllerRepresentable {
|
||||
let orientation: ShotOrientation
|
||||
|
||||
func makeUIViewController(context: Context) -> UIViewController { UIViewController() }
|
||||
func makeUIViewController(context: Context) -> ShotOrientationController {
|
||||
ShotOrientationController(mask: mask)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ vc: UIViewController, context: Context) {
|
||||
guard let scene = vc.view.window?.windowScene else { return }
|
||||
let mask: UIInterfaceOrientationMask = orientation == .landscape ? .landscapeRight : .portrait
|
||||
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask))
|
||||
vc.setNeedsUpdateOfSupportedInterfaceOrientations()
|
||||
func updateUIViewController(_ vc: ShotOrientationController, context: Context) {
|
||||
vc.mask = mask
|
||||
vc.applyGeometry()
|
||||
}
|
||||
|
||||
private var mask: UIInterfaceOrientationMask {
|
||||
orientation == .landscape ? .landscapeRight : .portrait
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks the window scene to rotate, from a place where there IS a window.
|
||||
///
|
||||
/// The previous version made the request inside `updateUIViewController`, where `view.window` is
|
||||
/// still nil: SwiftUI makes exactly one update pass for a representable mounted as a `.background`,
|
||||
/// before the hierarchy is in a window, so the `guard` fell through and nothing ever asked again.
|
||||
/// Every scene declared `.landscape` — the stream hero and the trust card — was therefore captured
|
||||
/// in PORTRAIT at the portrait App Store size. Overriding `supportedInterfaceOrientations` as well
|
||||
/// keeps the scene from rotating back if the simulator reports a device orientation change.
|
||||
final class ShotOrientationController: UIViewController {
|
||||
var mask: UIInterfaceOrientationMask
|
||||
|
||||
init(mask: UIInterfaceOrientationMask) {
|
||||
self.mask = mask
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("not from a nib") }
|
||||
|
||||
override var supportedInterfaceOrientations: UIInterfaceOrientationMask { mask }
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
applyGeometry()
|
||||
}
|
||||
|
||||
func applyGeometry() {
|
||||
// `view.window` once mounted; the connected-scene lookup covers the first update pass,
|
||||
// which still runs before this controller is in a window.
|
||||
let scene = view.window?.windowScene
|
||||
?? UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
|
||||
guard let scene else { return }
|
||||
// Report a refusal instead of silently shipping the wrong orientation — that is exactly
|
||||
// how every landscape scene went out as a portrait PNG for as long as it did.
|
||||
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { error in
|
||||
print("PF_SHOT_ORIENTATION_REFUSED \(error.localizedDescription)")
|
||||
fflush(stdout)
|
||||
}
|
||||
setNeedsUpdateOfSupportedInterfaceOrientations()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -81,24 +81,126 @@ enum ShotScenes {
|
||||
|
||||
@MainActor
|
||||
enum ShotMock {
|
||||
/// A populated saved-host grid: a pinned recent host, a couple more, mixed online state.
|
||||
// Stable ids so the store, the adverts and the profile bindings all point at the same things
|
||||
// across every scene and every run.
|
||||
static let battlestationID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000001")!
|
||||
static let livingRoomID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000002")!
|
||||
static let workshopID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000003")!
|
||||
static let officeID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000004")!
|
||||
static let editingID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000005")!
|
||||
static let bedroomID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000006")!
|
||||
|
||||
static let hdrProfileID = "a71c4e0d9f22"
|
||||
static let couchProfileID = "3e88b107c4da"
|
||||
|
||||
/// The catalog the host cards read their chips and pinned cards from. Seeded once, on the
|
||||
/// first store build — `ProfileStore` is a singleton, and in shot mode its write-back is
|
||||
/// suppressed, so this never reaches a real user's catalog.
|
||||
static func installProfiles() {
|
||||
guard !profilesInstalled else { return }
|
||||
profilesInstalled = true
|
||||
ProfileStore.shared.debugSet([
|
||||
StreamProfile(name: "4K HDR", id: hdrProfileID, accent: "#8B7BF7"),
|
||||
StreamProfile(name: "Couch 1080p", id: couchProfileID, accent: "#4FD1A5"),
|
||||
])
|
||||
}
|
||||
|
||||
private static var profilesInstalled = false
|
||||
|
||||
/// A populated saved-host grid: the most-recent host bound to a profile (its chip), a second
|
||||
/// paired machine, and one asleep box we hold a MAC for (so its card offers Wake-on-LAN). OS
|
||||
/// chains give every tile its real vendor mark instead of a letter monogram.
|
||||
///
|
||||
/// No PINNED host+profile card: it renders a second tile for the SAME host, which is the
|
||||
/// feature working as designed but reads as a duplicate to anyone meeting the app in a store
|
||||
/// listing. The binding chip carries the profile story on its own.
|
||||
static func hostStore() -> HostStore {
|
||||
installProfiles()
|
||||
let store = HostStore()
|
||||
store.hosts = [
|
||||
StoredHost(name: "Battlestation", address: "192.168.1.20", port: 9777,
|
||||
pinnedSHA256: fingerprint, lastConnected: Date().addingTimeInterval(-420)),
|
||||
StoredHost(name: "Living Room PC", address: "192.168.1.41", port: 9777,
|
||||
pinnedSHA256: fingerprint),
|
||||
StoredHost(name: "Workshop", address: "10.0.0.7", port: 9777),
|
||||
StoredHost(
|
||||
id: battlestationID, name: "Battlestation", address: "192.168.1.20", port: 9777,
|
||||
pinnedSHA256: fingerprint, lastConnected: Date().addingTimeInterval(-420),
|
||||
macAddresses: ["a4:b1:c2:d3:e4:f5"], profileID: hdrProfileID,
|
||||
osChain: "windows/11"),
|
||||
StoredHost(
|
||||
id: livingRoomID, name: "Living Room PC", address: "192.168.1.41", port: 9777,
|
||||
pinnedSHA256: hostFingerprint(1), lastConnected: Date().addingTimeInterval(-86_400),
|
||||
macAddresses: ["b8:27:eb:11:22:33"], osChain: "linux/fedora/bazzite"),
|
||||
StoredHost(
|
||||
id: officeID, name: "Office NUC", address: "192.168.1.33", port: 9777,
|
||||
pinnedSHA256: hostFingerprint(4), lastConnected: Date().addingTimeInterval(-259_200),
|
||||
profileID: couchProfileID, osChain: "linux/ubuntu"),
|
||||
StoredHost(
|
||||
id: workshopID, name: "Workshop", address: "10.0.0.7", port: 9777,
|
||||
pinnedSHA256: hostFingerprint(2), macAddresses: ["de:ad:be:ef:00:07"],
|
||||
osChain: "linux/arch"),
|
||||
StoredHost(
|
||||
id: editingID, name: "Editing Rig", address: "192.168.1.62", port: 9777,
|
||||
pinnedSHA256: hostFingerprint(5), lastConnected: Date().addingTimeInterval(-604_800),
|
||||
osChain: "linux/nobara"),
|
||||
StoredHost(
|
||||
id: bedroomID, name: "Bedroom Mini", address: "192.168.1.77", port: 9777,
|
||||
pinnedSHA256: hostFingerprint(6), macAddresses: ["00:1a:2b:3c:4d:5e"],
|
||||
osChain: "windows/11"),
|
||||
]
|
||||
return store
|
||||
}
|
||||
|
||||
static let host = StoredHost(name: "Battlestation", address: "192.168.1.20", port: 9777,
|
||||
pinnedSHA256: fingerprint)
|
||||
/// Discovery, seeded rather than live. Two saved hosts advertise (so their cards read ONLINE
|
||||
/// through the real `advertises` path, and the reachability probe skips them — no network from
|
||||
/// a capture), "Workshop" stays quiet so the grid shows an asleep machine, and one genuinely
|
||||
/// new host populates the "On this network" section.
|
||||
///
|
||||
/// A live browse made the shot non-deterministic AND leaked whatever was on the capturing
|
||||
/// machine's LAN into the App Store listing.
|
||||
static func discovery() -> HostDiscovery {
|
||||
let discovery = HostDiscovery()
|
||||
discovery.debugSet([
|
||||
HostDiscovery.debugAdvert(
|
||||
id: "battlestation", name: "Battlestation", host: "192.168.1.20",
|
||||
fingerprintHex: fingerprint.hexLower, macAddresses: ["a4:b1:c2:d3:e4:f5"],
|
||||
osChain: "windows/11"),
|
||||
HostDiscovery.debugAdvert(
|
||||
id: "living-room", name: "Living Room PC", host: "192.168.1.41",
|
||||
fingerprintHex: hostFingerprint(1).hexLower, macAddresses: ["b8:27:eb:11:22:33"],
|
||||
osChain: "linux/fedora/bazzite"),
|
||||
HostDiscovery.debugAdvert(
|
||||
id: "office-nuc", name: "Office NUC", host: "192.168.1.33",
|
||||
fingerprintHex: hostFingerprint(4).hexLower, osChain: "linux/ubuntu"),
|
||||
HostDiscovery.debugAdvert(
|
||||
id: "studio", name: "Studio PC", host: "192.168.1.58",
|
||||
fingerprintHex: hostFingerprint(3).hexLower, requiresPairing: true, allowsTofu: false,
|
||||
osChain: "windows/11"),
|
||||
])
|
||||
return discovery
|
||||
}
|
||||
|
||||
static let host = StoredHost(
|
||||
id: battlestationID, name: "Battlestation", address: "192.168.1.20", port: 9777,
|
||||
pinnedSHA256: fingerprint, osChain: "windows/11")
|
||||
|
||||
/// What the pairing sheet calls THIS device. Taken from the platform, not from
|
||||
/// `UIDevice.current.name` — on a capture simulator that is the harness's own throwaway name
|
||||
/// (`pf-shot-iphone-6.9` went out on the store listing that way).
|
||||
static var clientDeviceName: String {
|
||||
#if os(tvOS)
|
||||
"Apple TV"
|
||||
#elseif os(macOS)
|
||||
"MacBook Pro"
|
||||
#else
|
||||
UIDevice.current.userInterfaceIdiom == .pad ? "iPad Pro" : "iPhone"
|
||||
#endif
|
||||
}
|
||||
|
||||
/// A plausible-looking 32-byte SHA-256 for the trust card / pin lock glyphs.
|
||||
static let fingerprint = Data((0..<32).map { UInt8(($0 &* 37 &+ 0x1d) & 0xff) })
|
||||
static let fingerprint = hostFingerprint(0)
|
||||
|
||||
/// Distinct per host — `StoredHost.matches` prefers a fingerprint comparison, so sharing one
|
||||
/// across the mock grid made a single advert light up every card.
|
||||
static func hostFingerprint(_ seed: Int) -> Data {
|
||||
Data((0..<32).map { UInt8((($0 &* 37) &+ 0x1d &+ (seed &* 91)) & 0xff) })
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Home
|
||||
@@ -106,7 +208,7 @@ enum ShotMock {
|
||||
private struct ShotHome: View {
|
||||
@StateObject private var store = ShotMock.hostStore()
|
||||
@StateObject private var model = SessionModel()
|
||||
@StateObject private var discovery = HostDiscovery()
|
||||
@StateObject private var discovery = ShotMock.discovery()
|
||||
|
||||
var body: some View {
|
||||
#if os(macOS)
|
||||
@@ -134,7 +236,7 @@ private struct ShotHome: View {
|
||||
private struct ShotGamepadHome: View {
|
||||
@StateObject private var store = ShotMock.hostStore()
|
||||
@StateObject private var model = SessionModel()
|
||||
@StateObject private var discovery = HostDiscovery()
|
||||
@StateObject private var discovery = ShotMock.discovery()
|
||||
@StateObject private var waker = HostWaker()
|
||||
|
||||
var body: some View {
|
||||
@@ -146,7 +248,9 @@ private struct ShotGamepadHome: View {
|
||||
}
|
||||
|
||||
private struct ShotGamepadSettings: View {
|
||||
var body: some View { GamepadSettingsView() }
|
||||
@StateObject private var store = ShotMock.hostStore()
|
||||
|
||||
var body: some View { GamepadSettingsView(store: store) }
|
||||
}
|
||||
|
||||
private struct ShotGamepadAddHost: View {
|
||||
@@ -164,7 +268,7 @@ private struct ShotConnect: View {
|
||||
|
||||
@StateObject private var store = ShotMock.hostStore()
|
||||
@StateObject private var model = SessionModel()
|
||||
@StateObject private var discovery = HostDiscovery()
|
||||
@StateObject private var discovery = ShotMock.discovery()
|
||||
@StateObject private var waker = HostWaker()
|
||||
|
||||
var body: some View {
|
||||
@@ -241,9 +345,9 @@ private struct ShotSettings: View {
|
||||
#elseif os(iOS)
|
||||
// SettingsView owns its NavigationSplitView (sidebar + detail) and Done button, so it is
|
||||
// rendered directly — a wrapping NavigationStack would nest a split view in a stack. Open
|
||||
// on General so the shot lands on real controls (iPad: sidebar + General detail; iPhone:
|
||||
// the General page) instead of the bare category list.
|
||||
SettingsView(initialCategory: .general)
|
||||
// on Display rather than the bare category list: resolution, frame rate, bitrate, HDR and
|
||||
// codec are what someone reads a streaming app's settings shot to find out.
|
||||
SettingsView(initialCategory: .display)
|
||||
#else
|
||||
NavigationStack { SettingsView() }
|
||||
#endif
|
||||
@@ -253,16 +357,44 @@ private struct ShotSettings: View {
|
||||
// MARK: - Pair (PIN ceremony)
|
||||
|
||||
private struct ShotPair: View {
|
||||
/// The PIN as the host's web console shows it, and a device name that doesn't depend on what
|
||||
/// the capture simulator happens to be called.
|
||||
private var sheet: some View {
|
||||
PairSheet(
|
||||
host: ShotMock.host, shotPIN: "418 306",
|
||||
shotClientName: ShotMock.clientDeviceName, onPaired: { _ in })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
#if os(iOS)
|
||||
// PRESENT it, don't rebuild it. `PairSheet` is a bottom sheet on iOS — it carries its own
|
||||
// `.presentationDetents([.medium, .large])` and the system's Liquid Glass background, both
|
||||
// of which only exist inside a real `.sheet`. Composed into a ZStack instead (what this
|
||||
// scene used to do), the detents were inert, the grouped Form stretched to the full height
|
||||
// of the screen, and the capture was a thin strip of content over a huge black void.
|
||||
ShotHome()
|
||||
.sheet(isPresented: .constant(true)) {
|
||||
// Pinned to one detent. The sheet ships `[.medium, .large]` so it can grow over
|
||||
// the keyboard, and the resting height leaves a wide empty band between the form
|
||||
// and the button row; a capture wants the snug version.
|
||||
sheet.presentationDetents([.fraction(0.52)])
|
||||
}
|
||||
#elseif os(tvOS)
|
||||
// tvOS pushes the ceremony as a full screen (HomeView's `navigationDestination`).
|
||||
NavigationStack { sheet }
|
||||
#else
|
||||
// macOS: a fixed-width panel (`.frame(width: 400).fixedSize()`) that hugs its content, so
|
||||
// floating it over the dimmed grid matches how the window-modal sheet reads. `screencapture
|
||||
// -l<windowID>` grabs one window, and an AppKit sheet is a child window — a real `.sheet`
|
||||
// would fall outside the capture.
|
||||
ZStack {
|
||||
ShotHome().blur(radius: 28).overlay(Color.black.opacity(0.5))
|
||||
PairSheet(host: ShotMock.host, onPaired: { _ in })
|
||||
.frame(maxWidth: 460)
|
||||
sheet
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18))
|
||||
.shadow(radius: 40, y: 16)
|
||||
.padding(40)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Session state for the app shell: owns the connection, the input capture, the trust
|
||||
// handshake phase, and the pump-thread → main-actor stats relay.
|
||||
|
||||
// AVFoundation: AVCaptureDevice.authorizationStatus (the mic TCC grant behind `micAvailable`)
|
||||
// and, on tvOS, AVPlayer.eligibleForHDRPlayback (the TV-capability HDR gate).
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import os
|
||||
import PunktfunkKit
|
||||
@@ -11,9 +14,6 @@ import SwiftUI
|
||||
#elseif canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
import AVFoundation // AVPlayer.eligibleForHDRPlayback — the TV-capability HDR gate
|
||||
#endif
|
||||
|
||||
/// 1 Hz latency-stage line mirrored to the unified log so the stages can be read WITHOUT the
|
||||
/// on-screen HUD (Console.app, wirelessly on an iPad/Apple TV). The HUD is not a neutral
|
||||
@@ -137,6 +137,14 @@ final class SessionModel: ObservableObject {
|
||||
/// Mirrors StreamView's capture state (it owns the input capture; this drives the
|
||||
/// HUD's "click to capture" / "⌘⎋ releases" hint).
|
||||
@Published var mouseCaptured = false
|
||||
/// The USER's in-stream mic mute (the HUD button, the Stream menu's ⌃⌥⇧A, the captured-state
|
||||
/// chord, the iOS mic disc) — session state, deliberately NOT persisted: a mute is for the
|
||||
/// people in the room right now, so every new session starts live if the mic is on at all.
|
||||
/// One of the two inputs to the effective mute; `isBackgrounded` is the other, and
|
||||
/// `applyMicMute` composes them — a user mute survives a trip through the background, and the
|
||||
/// background's privacy mute never clears the user's choice. Local and instant: it gates
|
||||
/// capture on this device, nothing is sent to the host.
|
||||
@Published private(set) var micMuted = false
|
||||
/// Resize overlay (design/midstream-resolution-resize.md — client resize UX): true from the
|
||||
/// instant a Match-window resize starts steering toward a new size until a frame at that size
|
||||
/// decodes (or a safety timeout). Drives the blur+spinner so the unavoidable host-rebuild delay
|
||||
@@ -338,7 +346,10 @@ final class SessionModel: ObservableObject {
|
||||
let clientCaps: UInt8 =
|
||||
(MouseInputMode(rawValue: effective.mouseMode) ?? .capture) == .desktop ? 0x01 : 0
|
||||
#else
|
||||
let clientCaps: UInt8 = 0
|
||||
// iOS/tvOS run the stage-4 deadline presenter, whose link thread feeds
|
||||
// reportPhase — advertise the vsync-aware presenter (0x02, CLIENT_CAP_PHASE_LOCK).
|
||||
// macOS stays without it: the stage-2 arrival presenter has no latch grid.
|
||||
let clientCaps: UInt8 = 0x02
|
||||
#endif
|
||||
let result = Result { try PunktfunkConnection(
|
||||
host: host.address, port: host.port,
|
||||
@@ -437,7 +448,7 @@ final class SessionModel: ObservableObject {
|
||||
guard phase == .streaming, let conn = connection, !isBackgrounded else { return }
|
||||
isBackgrounded = true
|
||||
conn.setVideoDropped(true)
|
||||
audio?.setMicMuted(true)
|
||||
applyMicMute() // now muted for privacy — on top of the user's own mute, not instead of it
|
||||
// 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)
|
||||
@@ -462,13 +473,57 @@ final class SessionModel: ObservableObject {
|
||||
backgroundDeadline = nil
|
||||
backgroundTimer?.cancel()
|
||||
backgroundTimer = nil
|
||||
audio?.setMicMuted(false)
|
||||
applyMicMute() // back to the user's own choice — which may well still be "muted"
|
||||
if let conn = connection {
|
||||
conn.setVideoDropped(false)
|
||||
conn.requestKeyframe()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Microphone mute (in-stream, per session)
|
||||
|
||||
/// Whether this session has a mic uplink there is any point in muting: the mic must be on in
|
||||
/// the session's RESOLVED settings (a profile can turn it on or off), the platform must have
|
||||
/// an app-accessible input at all, and the OS must not have refused us one. Drives whether the
|
||||
/// mute control is offered — a live-looking mute button over a session that sends no
|
||||
/// microphone would be a lie. Same three conditions `SessionAudio` starts an uplink on
|
||||
/// (`.notDetermined` counts: the prompt is pending and a grant starts the uplink mid-session).
|
||||
var micAvailable: Bool {
|
||||
#if os(tvOS)
|
||||
return false // no app-accessible microphone — SessionAudio never opens an uplink either
|
||||
#else
|
||||
guard settings.micEnabled else { return false }
|
||||
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
||||
case .authorized, .notDetermined: return true
|
||||
default: return false // denied / restricted — there is no uplink to mute
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Flip the user's mute. The in-stream surfaces (HUD button, Stream menu, ⌃⌥⇧A while
|
||||
/// captured, the iOS mic disc) all land here.
|
||||
func toggleMicMute() {
|
||||
setMicMuted(!micMuted)
|
||||
}
|
||||
|
||||
/// Set the user's mute directly (the badge's tap-to-unmute). Ignored when the session has no
|
||||
/// microphone, so a stale surface can't leave a phantom "muted" badge over a session that was
|
||||
/// never sending anything.
|
||||
func setMicMuted(_ muted: Bool) {
|
||||
guard micAvailable, micMuted != muted else { return }
|
||||
micMuted = muted
|
||||
applyMicMute()
|
||||
}
|
||||
|
||||
/// Push the EFFECTIVE mute — the user's choice OR the background keep-alive's privacy mute —
|
||||
/// onto the audio engine. The two reasons are composed here and nowhere else: whichever one
|
||||
/// changed, the other still holds, so returning from the background can't un-mute a user who
|
||||
/// muted mid-stream, and a user unmuting while backgrounded (Live Activity, another window)
|
||||
/// doesn't open the mic behind their back.
|
||||
private func applyMicMute() {
|
||||
audio?.setMicMuted(micMuted || isBackgrounded)
|
||||
}
|
||||
|
||||
/// Follow a live stats-overlay cycle (⌃⌥⇧S, the three-finger tap, the Stream menu). Those
|
||||
/// surfaces write the GLOBAL setting as they always have; this moves the session's own tier
|
||||
/// with it, so cycling still works in a session a profile put on a different tier.
|
||||
@@ -506,6 +561,9 @@ final class SessionModel: ObservableObject {
|
||||
backgroundTimer = nil
|
||||
isBackgrounded = false
|
||||
backgroundDeadline = nil
|
||||
// The mic mute is per-session and never persisted: the next stream starts live (if the
|
||||
// mic is enabled), rather than silently carrying a mute nobody remembers making.
|
||||
micMuted = false
|
||||
let audio = self.audio
|
||||
self.audio = nil
|
||||
// Gamepad capture is main-actor (releases held buttons on the wire while the
|
||||
@@ -606,14 +664,24 @@ final class SessionModel: ObservableObject {
|
||||
speakerUID: settings.speakerUID,
|
||||
micUID: settings.micUID,
|
||||
micChannel: settings.micChannel,
|
||||
micEnabled: settings.micEnabled)
|
||||
micEnabled: settings.micEnabled,
|
||||
echoCancel: settings.echoCancel)
|
||||
self.audio = audio
|
||||
// Gamepads: forward every controller GamepadManager selected — each on its own wire pad
|
||||
// index (a pin forwards only one, Automatic forwards all) — and render the host's feedback
|
||||
// back to the pad it's addressed to (rumble always; lightbar/player-LEDs/adaptive-triggers
|
||||
// when a pad's virtual device is a DualSense). Same trust gate as audio — nothing is
|
||||
// forwarded during the trust prompt.
|
||||
let capture = GamepadCapture(connection: conn, manager: .shared)
|
||||
// `gamepadForwarding` off means the host gets this device's pads from somewhere else
|
||||
// (USB passthrough, or a pad plugged into the host) — capture still runs, and still
|
||||
// watches for the escape chord, but puts nothing on the wire.
|
||||
// System-button routing: whether raw guide/share presses ride the wire, and whether
|
||||
// hold-Select arms as the alternate guide route (auto = on everywhere but macOS —
|
||||
// iOS reserves the physical Home press, tvOS never delivers it).
|
||||
let capture = GamepadCapture(
|
||||
connection: conn, manager: .shared, forwarding: settings.gamepadForwarding,
|
||||
systemForward: settings.systemButtonsForward,
|
||||
guideGesture: settings.guideGestureEnabled)
|
||||
// The cross-client escape chord (hold L1+R1+Start+Select 1.5 s) — on tvOS the only
|
||||
// controller way out of a stream (B/Menu is swallowed during sessions; see ContentView).
|
||||
capture.onDisconnectRequest = { [weak self] in self?.disconnect() }
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// The app's "Stream" menu (macOS menu bar + iPad hardware-keyboard shortcuts). These live at
|
||||
// the Scene level so they keep working when the HUD overlay is hidden. The shortcuts are the
|
||||
// CROSS-CLIENT set every punktfunk client reserves — Ctrl+Alt+Shift+Q (release the captured
|
||||
// mouse) / +D (disconnect) / +S (stats) — and the menu is their discoverable surface on macOS
|
||||
// mouse) / +D (disconnect) / +S (stats), plus +A (mute the microphone), the Apple clients'
|
||||
// addition to it — and the menu is their discoverable surface on macOS
|
||||
// (the Linux client has its GTK Shortcuts window, Windows its start-of-stream banner). While
|
||||
// input is CAPTURED these key equivalents never reach the menu (the stream view swallows
|
||||
// keys); InputCapture's monitor detects the same combos there and performs the same actions —
|
||||
@@ -27,6 +28,12 @@ struct SessionFocus {
|
||||
/// Clipboard sync is live (host-acked) — drives the item's Stop/Share title.
|
||||
var clipboardOn: Bool
|
||||
var toggleClipboard: () -> Void
|
||||
/// The session has a mic uplink at all (its resolved `micEnabled`) — gates the mute item, so
|
||||
/// it is never an enabled control over a session that sends no microphone.
|
||||
var micAvailable: Bool
|
||||
/// The user's mic mute is engaged — drives the item's Mute/Unmute title.
|
||||
var micMuted: Bool
|
||||
var toggleMicMute: () -> Void
|
||||
var disconnect: () -> Void
|
||||
}
|
||||
|
||||
@@ -60,6 +67,17 @@ struct StreamCommands: Commands {
|
||||
}
|
||||
.keyboardShortcut("q", modifiers: [.control, .option, .shift])
|
||||
.disabled(session?.isStreaming != true)
|
||||
// Mic mute, local and instant (it gates capture on this device — the host is never
|
||||
// asked). Per SESSION: it starts off every time, so this item is a live toggle, not a
|
||||
// setting. Greyed when the session sends no microphone at all (Settings → mic off, or
|
||||
// a profile that turns it off) rather than pretending there is something to mute.
|
||||
// Captured, the combo is handled by InputCapture's chord path before menus see it;
|
||||
// this item is the released-state path and the shortcut's documentation.
|
||||
Button(session?.micMuted == true ? "Unmute Microphone" : "Mute Microphone") {
|
||||
session?.toggleMicMute()
|
||||
}
|
||||
.keyboardShortcut("a", modifiers: [.control, .option, .shift])
|
||||
.disabled(session?.isStreaming != true || session?.micAvailable != true)
|
||||
#if os(macOS)
|
||||
// Mid-session clipboard flip (design/clipboard-and-file-transfer.md §5.3). Greyed
|
||||
// when the host doesn't advertise the cap (older host / operator policy off).
|
||||
|
||||
@@ -179,6 +179,18 @@ struct StreamHUDView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
#endif
|
||||
// Mic mute — the in-stream toggle, on the same card as the other in-overlay action.
|
||||
// Absent (not greyed) when the session sends no microphone: the HUD is a status card,
|
||||
// and a dead control on it would read as "there is a mic, and it is on". The muted
|
||||
// STATE is not this button's job — the badge over the stream says that at every tier
|
||||
// and with the overlay off entirely. tvOS gets no control: no microphone, and a
|
||||
// focusable one would steal the controller's A press from the host.
|
||||
#if !os(tvOS)
|
||||
if model.micAvailable {
|
||||
Button(micButtonTitle) { model.toggleMicMute() }
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
}
|
||||
#endif
|
||||
// ⌃⌥⇧D lives on the app's Stream menu (so it still works when the HUD is hidden)
|
||||
// and in InputCapture's monitor while captured; this button is the in-overlay,
|
||||
// click-to-disconnect affordance. tvOS deliberately gets NEITHER a button (a
|
||||
@@ -195,6 +207,19 @@ struct StreamHUDView: View {
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
/// The mute button's wording. macOS names the chord, exactly as its Disconnect button does;
|
||||
/// iOS/iPadOS spells the action out (the HUD's buttons there carry no shortcuts, even where a
|
||||
/// hardware keyboard could fire one — the Stream menu is that keyboard's surface).
|
||||
private var micButtonTitle: String {
|
||||
#if os(macOS)
|
||||
return model.micMuted ? "Unmute Mic (⌃⌥⇧A)" : "Mute Mic (⌃⌥⇧A)"
|
||||
#else
|
||||
return model.micMuted ? "Unmute Microphone" : "Mute Microphone"
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Card metrics
|
||||
|
||||
/// The card's inner content padding. Roomier on tvOS — the stat text auto-scales for the
|
||||
@@ -242,6 +267,42 @@ struct StreamHUDView: View {
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
/// The muted-microphone badge — the mute STATE, as opposed to the buttons that flip it. It rides
|
||||
/// over the stream whenever the mic is muted, INDEPENDENT of the stats overlay (which the user
|
||||
/// may have cycled off, and which the compact tier reduces to a stat line): "am I muted?" is not a
|
||||
/// statistic, and a mute you can't see is how people talk to nobody for a minute. Same glass
|
||||
/// language as the HUD, sized like the start-of-stream banner it shares the bottom edge with.
|
||||
///
|
||||
/// It is also a control: tapping it unmutes. That is the guaranteed way back for a touch user who
|
||||
/// muted with the overlay off, and it costs the badge nothing (it is on screen either way).
|
||||
struct MicMutedBadge: View {
|
||||
let onUnmute: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onUnmute) {
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: "mic.slash.fill")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.red)
|
||||
Text("Microphone muted")
|
||||
.font(.geist(12, .medium, relativeTo: .caption))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
// interactive: the badge IS the tap target, so the glass reacts to press.
|
||||
.glassBackground(Capsule(), interactive: true)
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.environment(\.colorScheme, .dark) // reads over any frame, like the resize overlay
|
||||
.accessibilityLabel("Microphone muted")
|
||||
.accessibilityHint("Unmutes the microphone")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
/// Device display geometry the overlay needs but UIKit doesn't expose publicly.
|
||||
enum DeviceMetrics {
|
||||
|
||||
@@ -10,6 +10,14 @@
|
||||
// on stale captured state. Left/right CLAMPS at a choice list's ends (the dull boundary thud tells
|
||||
// the thumb it's the last option); A always cycles forward, wrapping, so every option is reachable
|
||||
// with one button. Toggles read left = off, right = on — refusing a no-op with the same thud.
|
||||
//
|
||||
// The trailing Profiles section (design/client-settings-profiles.md §5.2a/§5.4) is the pin manager
|
||||
// for this controller-first surface: a row per catalog profile opens the pin-to-hosts picker — an
|
||||
// in-place swap of the row list (B peels back, the "one layer" rule GamepadAddHostView set) with
|
||||
// one toggle row per saved host, writing `StoredHost.pinnedProfileIDs` via HostStore.setPinned.
|
||||
// Pins are presentation only: never the host's default binding, never the profile itself —
|
||||
// profiles are created and edited in the standard interface (and can't be on tvOS, whose
|
||||
// per-device catalog the detail strings are honest about).
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
@@ -21,17 +29,25 @@ import CoreHaptics
|
||||
|
||||
struct GamepadSettingsView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
/// The saved-host store — the pin picker writes `setPinned` through it and the profile rows
|
||||
/// count pins from its live hosts. Threaded in from GamepadHomeView like the home screen
|
||||
/// itself (ContentView owns the instance).
|
||||
@ObservedObject var store: HostStore
|
||||
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
|
||||
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
|
||||
@AppStorage(DefaultsKey.streamHz) private var hz = 60
|
||||
@AppStorage(DefaultsKey.compositor) private var compositor = 0
|
||||
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
||||
@AppStorage(DefaultsKey.gamepadForwarding) private var gamepadForwarding = true
|
||||
@AppStorage(DefaultsKey.systemButtons) private var systemButtons = "auto"
|
||||
@AppStorage(DefaultsKey.guideGesture) private var guideGesture = "auto"
|
||||
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
||||
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
|
||||
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
|
||||
@AppStorage(DefaultsKey.enable444) private var enable444 = false
|
||||
@AppStorage(DefaultsKey.codec) private var codec = "auto"
|
||||
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
|
||||
@AppStorage(DefaultsKey.echoCancel) private var echoCancel = true
|
||||
// The overlay tier's raw string (rows tag by rawValue); the absent-key default runs the
|
||||
// legacy-hudEnabled migration (same pattern as ContentView/SettingsView).
|
||||
@AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw
|
||||
@@ -50,6 +66,10 @@ struct GamepadSettingsView: View {
|
||||
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
|
||||
#endif
|
||||
@ObservedObject private var gamepads = GamepadManager.shared
|
||||
/// The profile catalog (ProfileStore.shared, like every other surface that reads it) — the
|
||||
/// Profiles rows re-derive from it each render, so a rename/delete made in the standard
|
||||
/// interface shows up live.
|
||||
@ObservedObject private var profiles = ProfileStore.shared
|
||||
|
||||
#if os(iOS)
|
||||
/// `.compact` in a landscape phone window — tighter chrome so more rows fit.
|
||||
@@ -60,6 +80,9 @@ struct GamepadSettingsView: View {
|
||||
private let compact = false // no size classes on macOS; the sheet is sized generously
|
||||
#endif
|
||||
@State private var focusID: String?
|
||||
/// The pin-to-hosts picker's profile — non-nil swaps the row list for one toggle row per
|
||||
/// saved host (§5.2a); B (Menu on tvOS) peels back to the settings rows.
|
||||
@State private var pinTarget: StreamProfile?
|
||||
/// The direction of the last value step (+1 right/forward, -1 left) — picks which edge the
|
||||
/// changed value slides in from, so the animation follows the user's motion.
|
||||
@State private var lastAdjustDelta = 1
|
||||
@@ -70,7 +93,7 @@ struct GamepadSettingsView: View {
|
||||
focusID: $focusID,
|
||||
onAdjust: { row, delta in adjust(id: row.id, by: delta) },
|
||||
onActivate: { activate(id: $0.id) },
|
||||
onBack: { dismiss() }
|
||||
onBack: { back() }
|
||||
) { row, focused in
|
||||
rowView(row, focused: focused)
|
||||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
|
||||
@@ -78,7 +101,7 @@ struct GamepadSettingsView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
Text("Settings")
|
||||
Text(title)
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
@@ -94,11 +117,7 @@ struct GamepadSettingsView: View {
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
.lineLimit(2, reservesSpace: true)
|
||||
.animation(.smooth(duration: 0.2), value: focusID)
|
||||
GamepadHintBar(hints: [
|
||||
.init(glyph: "arrow.left.and.right", text: "Adjust"),
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"),
|
||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
|
||||
])
|
||||
GamepadHintBar(hints: hints)
|
||||
}
|
||||
// Equal distance from the left and bottom edges for the legend pill (see GamepadHomeView).
|
||||
.padding(.leading, compact ? 12 : 18)
|
||||
@@ -136,6 +155,48 @@ struct GamepadSettingsView: View {
|
||||
.accessibilityLabel("Close settings")
|
||||
}
|
||||
|
||||
/// "Settings", or "Pin “Work”" while the pin picker is up — the title is what says which
|
||||
/// layer the row list currently is.
|
||||
private var title: String {
|
||||
pinTarget.map { "Pin “\($0.name)”" } ?? "Settings"
|
||||
}
|
||||
|
||||
/// The legend follows the layer: value-editing hints on the settings rows, pin/unpin on the
|
||||
/// picker — where B reads "Back" (it peels to the settings rows, GamepadAddHostView's "one
|
||||
/// layer" rule), and a hostless picker has nothing to pin, so only Back remains.
|
||||
private var hints: [GamepadHint] {
|
||||
guard pinTarget != nil else {
|
||||
// A dimmed row takes neither, so offering them would be the same lie the row itself
|
||||
// used to tell — only Done remains, and the detail line says what to turn on first.
|
||||
guard rows.first(where: { $0.id == focusID })?.enabled ?? true else {
|
||||
return [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done")]
|
||||
}
|
||||
return [
|
||||
.init(glyph: "arrow.left.and.right", text: "Adjust"),
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"),
|
||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
|
||||
]
|
||||
}
|
||||
guard !store.hosts.isEmpty else {
|
||||
return [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back")]
|
||||
}
|
||||
return [
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Pin / Unpin"),
|
||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back"),
|
||||
]
|
||||
}
|
||||
|
||||
/// B peels one layer: the pin picker back to the settings rows — focus returning to the
|
||||
/// profile row it came from — then the screen itself.
|
||||
private func back() {
|
||||
if let profile = pinTarget {
|
||||
pinTarget = nil
|
||||
focusID = "profile-\(profile.id)"
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Row rendering
|
||||
|
||||
private func rowView(_ row: Row, focused: Bool) -> some View {
|
||||
@@ -162,7 +223,8 @@ struct GamepadSettingsView: View {
|
||||
HStack(spacing: 9) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: m.chevronFont, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(focused ? 0.6 : 0))
|
||||
.foregroundStyle(
|
||||
.white.opacity(focused && row.adjustable && row.enabled ? 0.6 : 0))
|
||||
// Keyed by the value so a change slides the new option in instead of
|
||||
// hard-swapping the string — a QUIET horizontal slip following the user's
|
||||
// motion (a right-step enters from the right), crossfading over ~14 pt.
|
||||
@@ -183,9 +245,13 @@ struct GamepadSettingsView: View {
|
||||
.animation(.smooth(duration: 0.22), value: row.value)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: m.chevronFont, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(focused ? 0.6 : 0))
|
||||
.foregroundStyle(
|
||||
.white.opacity(focused && row.adjustable && row.enabled ? 0.6 : 0))
|
||||
}
|
||||
}
|
||||
// Contents only — the glass and border below stay at full strength, so a dimmed row
|
||||
// still reads as a row you can sit on (which you can: its detail is the point).
|
||||
.opacity(row.enabled ? 1 : 0.45)
|
||||
.padding(.horizontal, m.rowHPad)
|
||||
.padding(.vertical, m.rowVPad)
|
||||
// Every row is Liquid Glass; the focused one takes a brand wash and reacts to press.
|
||||
@@ -217,6 +283,16 @@ struct GamepadSettingsView: View {
|
||||
let value: String
|
||||
/// One-line explanation shown near the hint bar while this row is focused.
|
||||
let detail: String
|
||||
/// Whether left/right means anything here — false hides the value's chevrons (the
|
||||
/// Profiles rows navigate, and the placeholder rows do nothing at all).
|
||||
var adjustable = true
|
||||
/// Dimmed and inert when false: a row whose meaning depends on another setting that is
|
||||
/// currently off. It stays in the list and stays FOCUSABLE — its `detail` is how the
|
||||
/// user learns which switch to flip first, and a row that vanished mid-list would
|
||||
/// shift everything under the cursor. Enforced centrally in `adjust(id:by:)` /
|
||||
/// `activate(id:)`, not per closure, so no row builder can forget it.
|
||||
/// (Android's `GpRow.enabled` and `pf-console-ui`'s `RowSpec.enabled` are the twins.)
|
||||
var enabled = true
|
||||
/// Left/right step; returns whether the value actually changed (false ⇒ boundary thud).
|
||||
let adjust: (Int) -> Bool
|
||||
/// A — cycle forward (wrapping) / flip.
|
||||
@@ -227,15 +303,20 @@ struct GamepadSettingsView: View {
|
||||
/// (never on state captured at wire time).
|
||||
private func adjust(id: String, by delta: Int) -> Bool {
|
||||
lastAdjustDelta = delta
|
||||
return rows.first { $0.id == id }?.adjust(delta) ?? false
|
||||
guard let row = rows.first(where: { $0.id == id }), row.enabled else { return false }
|
||||
return row.adjust(delta)
|
||||
}
|
||||
|
||||
private func activate(id: String) {
|
||||
lastAdjustDelta = 1 // A always cycles forward
|
||||
rows.first { $0.id == id }?.activate()
|
||||
guard let row = rows.first(where: { $0.id == id }), row.enabled else { return }
|
||||
row.activate()
|
||||
}
|
||||
|
||||
private var rows: [Row] {
|
||||
// The pin picker replaces the whole list while it's up — same screen, one layer deeper,
|
||||
// so the focus list's controller wiring (and the tvOS focus engine) carries over as is.
|
||||
if let profile = pinTarget { return pinRows(for: profile) }
|
||||
let resolution = resolutionOptions
|
||||
let refresh = SettingsOptions.refreshRates(including: hz)
|
||||
.map { (label: "\($0) Hz", tag: $0) }
|
||||
@@ -316,17 +397,49 @@ struct GamepadSettingsView: View {
|
||||
id: "mic", icon: "mic", label: "Microphone",
|
||||
detail: "Send this device's microphone to the host's virtual mic.",
|
||||
value: $micEnabled),
|
||||
toggleRow(
|
||||
id: "echoCancel", icon: "waveform", label: "Echo cancellation",
|
||||
detail: "Cancel the audio this device plays out of the mic signal — stops "
|
||||
+ "speaker setups feeding the game back to the host.",
|
||||
value: $echoCancel),
|
||||
|
||||
toggleRow(
|
||||
id: "padForward", header: "Controller", icon: "gamecontroller",
|
||||
label: "Forward controllers",
|
||||
detail: "Send this device's controllers to the host. Turn it off when your "
|
||||
+ "controller already reaches the host another way — USB passthrough such "
|
||||
+ "as VirtualHere — so games don't see two of them.",
|
||||
value: $gamepadForwarding),
|
||||
// The four rows below only mean something while something is being forwarded, so
|
||||
// they follow the switch above — the same relationship the touch settings draw with
|
||||
// `.disabled(!effective.gamepadForwarding)`. This screen could not express it until
|
||||
// `Row.enabled` existed, so it alone left them live and steppable.
|
||||
choiceRow(
|
||||
id: "pad", header: "Controller", icon: "gamecontroller", label: "Use controller",
|
||||
id: "pad", icon: "gamecontroller", label: "Use controller",
|
||||
detail: "Which pad is forwarded to the host, as player 1.",
|
||||
options: controllers, current: gamepads.preferredID
|
||||
options: controllers, current: gamepads.preferredID,
|
||||
enabled: gamepadForwarding
|
||||
) { gamepads.preferredID = $0 },
|
||||
choiceRow(
|
||||
id: "padType", icon: "dpad", label: "Controller type",
|
||||
detail: "The virtual pad the host creates — Automatic matches this controller.",
|
||||
options: SettingsOptions.padTypes, current: gamepadType
|
||||
options: SettingsOptions.padTypes, current: gamepadType,
|
||||
enabled: gamepadForwarding
|
||||
) { gamepadType = $0 },
|
||||
choiceRow(
|
||||
id: "systemButtons", icon: "house.circle", label: "Guide button",
|
||||
detail: "Where the guide (Xbox/PS) and share presses go while streaming — "
|
||||
+ "Automatic sends them to the host whenever this device delivers them.",
|
||||
options: SettingsOptions.systemButtons, current: systemButtons,
|
||||
enabled: gamepadForwarding
|
||||
) { systemButtons = $0 },
|
||||
choiceRow(
|
||||
id: "guideGesture", icon: "hand.point.up.left", label: "Hold Select for guide",
|
||||
detail: "Hold Select alone to press the host's guide button — keep holding "
|
||||
+ "for a Gaming-Mode host's quick-access menu. A tap still goes through.",
|
||||
options: SettingsOptions.guideGestures, current: guideGesture,
|
||||
enabled: gamepadForwarding
|
||||
) { guideGesture = $0 },
|
||||
|
||||
choiceRow(
|
||||
id: "hud", header: "Interface", icon: "chart.bar", label: "Statistics overlay",
|
||||
@@ -380,7 +493,98 @@ struct GamepadSettingsView: View {
|
||||
at: at + 1)
|
||||
}
|
||||
#endif
|
||||
return list
|
||||
return list + profileRows
|
||||
}
|
||||
|
||||
// MARK: - Profiles (§5.2a)
|
||||
|
||||
/// The trailing Profiles section: one row per catalog profile, its value how many saved
|
||||
/// hosts pin it, A opening the pin-to-hosts picker. Read-only beyond that — this surface
|
||||
/// pins and unpins, but profiles are created and edited elsewhere (design §5.4), so
|
||||
/// left/right is a boundary thud, not an editor.
|
||||
private var profileRows: [Row] {
|
||||
guard !profiles.profiles.isEmpty else {
|
||||
return [Row(
|
||||
id: "noProfiles", header: "Profiles", icon: "slider.horizontal.3",
|
||||
label: "No profiles yet", value: "",
|
||||
detail: emptyCatalogDetail,
|
||||
adjustable: false,
|
||||
adjust: { _ in false }, activate: {})]
|
||||
}
|
||||
return profiles.profiles.enumerated().map { i, profile in
|
||||
let pins = store.hosts
|
||||
.filter { ($0.pinnedProfileIDs ?? []).contains(profile.id) }.count
|
||||
return Row(
|
||||
id: "profile-\(profile.id)", header: i == 0 ? "Profiles" : nil,
|
||||
icon: "slider.horizontal.3", label: profile.name,
|
||||
value: pins == 0 ? "Not pinned" : "Pinned to \(pins) host\(pins == 1 ? "" : "s")",
|
||||
detail: profileDetail,
|
||||
adjustable: false,
|
||||
adjust: { _ in false },
|
||||
activate: {
|
||||
// Focus lands on the picker's first row — the focus list's reconcile
|
||||
// follows this id when the row set swaps underneath it.
|
||||
focusID = store.hosts.first.map { "pinHost-\($0.id.uuidString)" } ?? "noHosts"
|
||||
pinTarget = profile
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The pin-to-hosts picker: one toggle row per SAVED host, sharing the settings rows'
|
||||
/// toggle semantics (left = unpin, right = pin, A flips; asking for the state it's in is a
|
||||
/// boundary thud). Writes ride `HostStore.setPinned` — pin appends, unpin removes — and
|
||||
/// NEVER the host's default binding (`profileID`): a pin is presentation only (§5.2a).
|
||||
private func pinRows(for profile: StreamProfile) -> [Row] {
|
||||
guard !store.hosts.isEmpty else {
|
||||
return [Row(
|
||||
id: "noHosts", icon: "desktopcomputer", label: "No saved hosts yet",
|
||||
value: "",
|
||||
detail: "Pair with a host first, then pin this profile to it.",
|
||||
adjustable: false,
|
||||
adjust: { _ in false }, activate: {})]
|
||||
}
|
||||
return store.hosts.map { host in
|
||||
let hostID = host.id
|
||||
let pinned = (host.pinnedProfileIDs ?? []).contains(profile.id)
|
||||
return Row(
|
||||
id: "pinHost-\(hostID.uuidString)", icon: "desktopcomputer",
|
||||
label: host.displayName,
|
||||
value: pinned ? "Pinned" : "Off",
|
||||
detail: "A pinned profile appears as its own card on the host — one press "
|
||||
+ "connects with it.",
|
||||
adjust: { delta in
|
||||
let target = delta > 0
|
||||
guard pinned != target else { return false }
|
||||
store.setPinned(hostID, profileID: profile.id, pinned: target)
|
||||
return true
|
||||
},
|
||||
activate: { store.setPinned(hostID, profileID: profile.id, pinned: !pinned) })
|
||||
}
|
||||
}
|
||||
|
||||
/// The profile rows' explainer. tvOS gets its own: the catalog is per-device (the App Group
|
||||
/// suite — nothing syncs it) and tvOS has no profile editor at all (§5.4), so pointing a TV
|
||||
/// user at a "standard interface" would promise profiles that can never arrive there.
|
||||
private var profileDetail: String {
|
||||
#if os(tvOS)
|
||||
return "Pin this profile to a host and it appears as its own card on the home screen — "
|
||||
+ "one press connects with it."
|
||||
#else
|
||||
return "Pin this profile to a host and it appears as its own card — one press connects "
|
||||
+ "with it. Profiles are created and edited in Punktfunk's standard interface."
|
||||
#endif
|
||||
}
|
||||
|
||||
/// What the empty catalog's placeholder explains — again honest on tvOS, where profiles
|
||||
/// cannot be created (on the device or anywhere that would reach its per-device catalog).
|
||||
private var emptyCatalogDetail: String {
|
||||
#if os(tvOS)
|
||||
return "Profiles bundle stream settings for different uses. Creating them isn't "
|
||||
+ "available on Apple TV yet."
|
||||
#else
|
||||
return "Profiles bundle stream settings for different uses. Create them in Punktfunk's "
|
||||
+ "standard interface, then pin them here as one-press connect cards."
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Resolution choices as "WxH" tags — the current size is inserted when it's a custom mode
|
||||
@@ -406,13 +610,15 @@ struct GamepadSettingsView: View {
|
||||
|
||||
private func choiceRow<T: Equatable>(
|
||||
id: String, header: String? = nil, icon: String, label: String, detail: String,
|
||||
options: [(label: String, tag: T)], current: T, write: @escaping (T) -> Void
|
||||
options: [(label: String, tag: T)], current: T, enabled: Bool = true,
|
||||
write: @escaping (T) -> Void
|
||||
) -> Row {
|
||||
let index = options.firstIndex { $0.tag == current }
|
||||
return Row(
|
||||
id: id, header: header, icon: icon, label: label,
|
||||
value: index.map { options[$0].label } ?? "—",
|
||||
detail: detail,
|
||||
enabled: enabled,
|
||||
adjust: { delta in
|
||||
// Unknown current value: snap to the first option on any step.
|
||||
guard let index else {
|
||||
@@ -433,12 +639,13 @@ struct GamepadSettingsView: View {
|
||||
|
||||
private func toggleRow(
|
||||
id: String, header: String? = nil, icon: String, label: String, detail: String,
|
||||
value: Binding<Bool>
|
||||
value: Binding<Bool>, enabled: Bool = true
|
||||
) -> Row {
|
||||
Row(
|
||||
id: id, header: header, icon: icon, label: label,
|
||||
value: value.wrappedValue ? "On" : "Off",
|
||||
detail: detail,
|
||||
enabled: enabled,
|
||||
adjust: { delta in
|
||||
// Directional semantics: left = off, right = on; a no-op reads as a boundary.
|
||||
let target = delta > 0
|
||||
|
||||
@@ -34,6 +34,22 @@ enum SettingsOptions {
|
||||
("DualShock 4", 4),
|
||||
]
|
||||
|
||||
/// System-button routing (the cross-client `system_buttons` key): where the guide
|
||||
/// (Xbox/PS) and share presses land while streaming. Auto = forward on Apple.
|
||||
static let systemButtons: [(label: String, tag: String)] = [
|
||||
("Automatic", "auto"),
|
||||
("Send to host", "forward"),
|
||||
("This device", "local"),
|
||||
]
|
||||
|
||||
/// The hold-Select guide gesture (the cross-client `guide_gesture` key). Auto = on
|
||||
/// everywhere but macOS.
|
||||
static let guideGestures: [(label: String, tag: String)] = [
|
||||
("Automatic", "auto"),
|
||||
("On", "on"),
|
||||
("Off", "off"),
|
||||
]
|
||||
|
||||
static let hudPlacements: [(label: String, tag: String)] =
|
||||
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
||||
|
||||
|
||||
@@ -98,6 +98,10 @@ enum SettingsFields {
|
||||
.init(name: "mic_enabled", key: DefaultsKey.micEnabled,
|
||||
overlay: \.micEnabled, effective: \.micEnabled)
|
||||
}
|
||||
static var echoCancel: SettingsField<Bool> {
|
||||
.init(name: "echo_cancel", key: DefaultsKey.echoCancel,
|
||||
overlay: \.echoCancel, effective: \.echoCancel)
|
||||
}
|
||||
static var touchMode: SettingsField<String> {
|
||||
.init(name: "touch_mode", key: DefaultsKey.touchMode,
|
||||
overlay: \.touchMode, effective: \.touchMode)
|
||||
@@ -118,6 +122,18 @@ enum SettingsFields {
|
||||
.init(name: "gamepad", key: DefaultsKey.gamepadType,
|
||||
overlay: \.gamepadType, effective: \.gamepadType)
|
||||
}
|
||||
static var gamepadForwarding: SettingsField<Bool> {
|
||||
.init(name: "gamepad_forwarding", key: DefaultsKey.gamepadForwarding,
|
||||
overlay: \.gamepadForwarding, effective: \.gamepadForwarding)
|
||||
}
|
||||
static var systemButtons: SettingsField<String> {
|
||||
.init(name: "system_buttons", key: DefaultsKey.systemButtons,
|
||||
overlay: \.systemButtons, effective: \.systemButtons)
|
||||
}
|
||||
static var guideGesture: SettingsField<String> {
|
||||
.init(name: "guide_gesture", key: DefaultsKey.guideGesture,
|
||||
overlay: \.guideGesture, effective: \.guideGesture)
|
||||
}
|
||||
static var statsVerbosity: SettingsField<String> {
|
||||
.init(name: "stats_verbosity", key: DefaultsKey.statsVerbosity,
|
||||
overlay: \.statsVerbosity, effective: \.statsVerbosity)
|
||||
@@ -175,7 +191,9 @@ extension SettingsView {
|
||||
base.compositor = compositor
|
||||
base.audioChannels = audioChannels
|
||||
base.micEnabled = micEnabled
|
||||
base.echoCancel = echoCancel
|
||||
base.gamepadType = gamepadType
|
||||
base.gamepadForwarding = gamepadForwarding
|
||||
base.statsVerbosity = statsVerbosityRaw
|
||||
base.fullscreenWhileStreaming = fullscreenWhileStreaming
|
||||
base.presentPriority = presentPriority
|
||||
|
||||
@@ -581,6 +581,10 @@ extension SettingsView {
|
||||
field: "mic_enabled") {
|
||||
Toggle("Send microphone to the host", isOn: scoped(SettingsFields.micEnabled))
|
||||
}
|
||||
described(echoCancelCaption, field: "echo_cancel") {
|
||||
Toggle("Echo cancellation", isOn: scoped(SettingsFields.echoCancel))
|
||||
.disabled(!effective.micEnabled)
|
||||
}
|
||||
#if os(macOS)
|
||||
if !inProfileScope {
|
||||
Picker("Microphone", selection: $micUID) {
|
||||
@@ -619,10 +623,33 @@ extension SettingsView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Honest about the macOS escape hatch: the voice processor only follows the system
|
||||
/// default devices, so hand-picked endpoints silently keep the raw path (see
|
||||
/// SessionAudio's topology note) — better said here than discovered mid-call.
|
||||
private var echoCancelCaption: String {
|
||||
let base = "Voice processing cancels the audio this device plays out of the mic "
|
||||
+ "signal, so a speaker setup doesn't feed the game back to the host."
|
||||
#if os(macOS)
|
||||
return base + " Follows the system default devices — a hand-picked speaker, "
|
||||
+ "microphone or input channel streams the raw mic instead."
|
||||
#else
|
||||
return base
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Controllers
|
||||
|
||||
@ViewBuilder var controllersSection: some View {
|
||||
Section {
|
||||
// The master switch, above everything it governs. Profileable, so it renders in
|
||||
// both scopes: a "Work" profile can decline to forward what "Game" forwards.
|
||||
described("Sends controllers connected to this device to the host. Turn it off when "
|
||||
+ "your controller already reaches the host another way — USB passthrough such "
|
||||
+ "as VirtualHere, or a pad plugged into the host itself — so games don't see "
|
||||
+ "two of them.",
|
||||
field: "gamepad_forwarding") {
|
||||
Toggle("Forward controllers", isOn: scoped(SettingsFields.gamepadForwarding))
|
||||
}
|
||||
// Which physical pad this device forwards, and what its own haptics do, are facts
|
||||
// about THIS device (tier G) — only the virtual pad the host creates is profileable.
|
||||
if !inProfileScope {
|
||||
@@ -641,6 +668,7 @@ extension SettingsView {
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
}
|
||||
.disabled(!effective.gamepadForwarding)
|
||||
}
|
||||
}
|
||||
described("The virtual pad created on the host. Automatic matches your controller "
|
||||
@@ -651,6 +679,30 @@ extension SettingsView {
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
}
|
||||
.disabled(!effective.gamepadForwarding)
|
||||
}
|
||||
described("Where the guide (Xbox/PS) and share presses go while streaming. "
|
||||
+ "Automatic sends them to the host whenever this device delivers them "
|
||||
+ "— the hold-Select gesture below reaches the host regardless.",
|
||||
field: "system_buttons") {
|
||||
Picker("Guide button", selection: scoped(SettingsFields.systemButtons)) {
|
||||
Text("Automatic").tag("auto")
|
||||
Text("Send to host").tag("forward")
|
||||
Text("This device").tag("local")
|
||||
}
|
||||
.disabled(!effective.gamepadForwarding)
|
||||
}
|
||||
described("Hold Select on its own to press the host's guide button — keep "
|
||||
+ "holding for a Gaming-Mode host's quick-access menu. A Select tap still "
|
||||
+ "goes through, slightly delayed. Automatic arms it wherever the real "
|
||||
+ "button can't reach the host (this device reserves it).",
|
||||
field: "guide_gesture") {
|
||||
Picker("Hold Select for guide", selection: scoped(SettingsFields.guideGesture)) {
|
||||
Text("Automatic").tag("auto")
|
||||
Text("On").tag("on")
|
||||
Text("Off").tag("off")
|
||||
}
|
||||
.disabled(!effective.gamepadForwarding)
|
||||
}
|
||||
#if os(iOS)
|
||||
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
|
||||
|
||||
@@ -49,6 +49,7 @@ struct SettingsView: View {
|
||||
@AppStorage(DefaultsKey.renderScale) var renderScale = 1.0
|
||||
@AppStorage(DefaultsKey.compositor) var compositor = 0
|
||||
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
||||
@AppStorage(DefaultsKey.gamepadForwarding) var gamepadForwarding = true
|
||||
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
|
||||
@AppStorage(DefaultsKey.presentPriority) var presentPriority =
|
||||
SettingsOptions.presentPriorityDefault
|
||||
@@ -65,6 +66,7 @@ struct SettingsView: View {
|
||||
@AppStorage(DefaultsKey.libraryEnabled) var libraryEnabled = true
|
||||
@AppStorage(DefaultsKey.fullscreenWhileStreaming) var fullscreenWhileStreaming = true
|
||||
@AppStorage(DefaultsKey.micEnabled) var micEnabled = true
|
||||
@AppStorage(DefaultsKey.echoCancel) var echoCancel = true
|
||||
@AppStorage(DefaultsKey.audioChannels) var audioChannels = 2
|
||||
@AppStorage(DefaultsKey.codec) var codec = "auto"
|
||||
// The overlay tier's raw string (the pickers tag by rawValue); the absent-key default runs
|
||||
|
||||
@@ -191,6 +191,12 @@ final class HostStore: ObservableObject {
|
||||
|
||||
|
||||
private func persist() {
|
||||
#if DEBUG
|
||||
// The screenshot harness fills a store with mock hosts (ShotMock) purely to render a
|
||||
// scene. On a dev Mac that store is the SAME App-Group suite the real app reads, so
|
||||
// persisting would replace the tester's saved hosts with "Battlestation" & co.
|
||||
if ScreenshotMode.isActive { return }
|
||||
#endif
|
||||
if let data = try? JSONEncoder().encode(hosts) {
|
||||
defaults.set(data, forKey: Self.key)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,14 @@ final class ProfileStore: ObservableObject {
|
||||
static let shared = ProfileStore()
|
||||
|
||||
@Published private(set) var catalog: ProfileCatalog {
|
||||
didSet { catalog.save() }
|
||||
didSet {
|
||||
#if DEBUG
|
||||
// Shot mode seeds this SINGLETON with mock profiles to populate the host cards.
|
||||
// Saving would write them into the tester's real catalog — see HostStore.persist().
|
||||
if ScreenshotMode.isActive { return }
|
||||
#endif
|
||||
catalog.save()
|
||||
}
|
||||
}
|
||||
|
||||
var profiles: [StreamProfile] { catalog.profiles }
|
||||
@@ -33,6 +40,14 @@ final class ProfileStore: ObservableObject {
|
||||
id.flatMap { catalog.profile(id: $0) }
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// Shot-mode seed: replace the catalog outright so a capture shows a known set of profiles
|
||||
/// rather than the tester's. Safe because `didSet` suppresses the write-back in shot mode.
|
||||
func debugSet(_ profiles: [StreamProfile]) {
|
||||
catalog = ProfileCatalog(profiles: profiles)
|
||||
}
|
||||
#endif
|
||||
|
||||
/// This host's default profile, dangling ids dropped — a deleted profile resolves as "Default
|
||||
/// settings", never an error (§4.4).
|
||||
func binding(for host: StoredHost) -> StreamProfile? { catalog.binding(for: host) }
|
||||
|
||||
@@ -109,7 +109,7 @@ struct PairSheet: View {
|
||||
#endif
|
||||
TextField(
|
||||
"Client name", text: $clientName,
|
||||
prompt: Text("How the host lists this Mac"))
|
||||
prompt: Text(Self.clientNamePrompt))
|
||||
#if os(tvOS)
|
||||
.labelsHidden() // prefilled → tvOS floats the label off-center
|
||||
#endif
|
||||
@@ -184,6 +184,16 @@ struct PairSheet: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// The field prompt names the device you are actually on — it said "this Mac" on every
|
||||
/// platform, which on an iPhone is simply wrong.
|
||||
private static var clientNamePrompt: String {
|
||||
#if os(macOS)
|
||||
"How the host lists this Mac"
|
||||
#else
|
||||
"How the host lists this device"
|
||||
#endif
|
||||
}
|
||||
|
||||
private func runCeremony() {
|
||||
busy = true
|
||||
errorText = nil
|
||||
@@ -229,3 +239,24 @@ struct PairSheet: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension PairSheet {
|
||||
/// Screenshot-harness seed (`ShotScenes`). A capture of the untouched sheet shows an empty PIN
|
||||
/// field, a DISABLED "Pair & Connect", and — because the client name defaults to the device's
|
||||
/// own — whatever the capture simulator happens to be called (`pf-shot-iphone-6.9` reached App
|
||||
/// Store Connect that way). Seeding both fields captures the ceremony as a user meets it,
|
||||
/// mid-entry, with a live primary button.
|
||||
///
|
||||
/// An extension so `PairSheet` keeps its memberwise initialiser, and THIS file so it can reach
|
||||
/// the private state.
|
||||
init(
|
||||
host: StoredHost, shotPIN: String, shotClientName: String,
|
||||
onPaired: @escaping (Data) -> Void
|
||||
) {
|
||||
self.init(host: host, onPaired: onPaired)
|
||||
_pin = State(initialValue: shotPIN)
|
||||
_clientName = State(initialValue: shotClientName)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -3,28 +3,66 @@ import os
|
||||
|
||||
/// SPSC-ish jitter ring (interleaved float, `channels` per frame), drain thread → render
|
||||
/// callback. The unfair lock is held for microseconds; fine at render-callback rates. Priming:
|
||||
/// reads return silence until enough is buffered (at least `prefill`, and at least one
|
||||
/// reads return silence until enough is buffered (at least the target, and at least one
|
||||
/// packet more than the device's render quantum — large-buffer devices would otherwise
|
||||
/// chronically out-demand the prefill and oscillate prime → dropout → re-prime), and an
|
||||
/// underrun re-primes, concealing jitter as one short dip instead of sustained crackle.
|
||||
/// chronically out-demand the prefill and oscillate prime → dropout → re-prime).
|
||||
/// All counts stay whole frames (multiples of `channels`), so the interleave can never slip.
|
||||
///
|
||||
/// **Drift correction.** Both ends run at 48 kHz but on different crystals, so backlog from a
|
||||
/// network stall or plain host-vs-DAC skew never drains on its own: without correction one 300 ms
|
||||
/// hiccup leaves audio 300 ms behind video for the rest of the session. This used to be handled by
|
||||
/// a `highWater` shed that dropped a whole `2 × prefill` at once — its own comment called that "one
|
||||
/// audible blip". It is now the same two-stage scheme the Rust clients share
|
||||
/// (`punktfunk_core::audio::JitterPolicy`): a slow depth average that sits above target for a
|
||||
/// sustained window sheds ONE 5 ms frame with a crossfade, and the hard cap is only a backstop.
|
||||
/// Keep the constants here in step with `JitterTuning.COREAUDIO`.
|
||||
final class AudioRing: @unchecked Sendable {
|
||||
/// Mirrors `JitterTuning::COREAUDIO` — see that type for the rationale.
|
||||
private static let targetMS = 20
|
||||
private static let headroomMS = 30
|
||||
private static let hardCapMS = 90
|
||||
private static let deprimeAfter = 4
|
||||
/// The protocol's frame: the shed unit, and the slack added over a large device quantum.
|
||||
private static let frameMS = 5
|
||||
/// Depth average must exceed target by this before drift correction fires — the middle of the
|
||||
/// headroom band, so the smooth shed always gets its chance BEFORE the hard cap trims.
|
||||
private static let shedExcessMS = 15
|
||||
/// …and must stay there for this much consumed audio. Long, because a shed is the only thing
|
||||
/// here a listener could notice; it must never fire on a transient.
|
||||
private static let shedSustainMS = 2_000
|
||||
private static let crossfadeMS = 2
|
||||
/// Time constant of the depth average.
|
||||
private static let ewmaTauMS = 1_000
|
||||
|
||||
private var buf: [Float]
|
||||
private var readIdx = 0
|
||||
private var writeIdx = 0
|
||||
private var primed = false
|
||||
private var renderQuantum = 0
|
||||
private let prefill: Int
|
||||
private let highWater: Int
|
||||
private var emptyReads = 0
|
||||
private var depthAvg: Double = 0
|
||||
private var overRun = 0
|
||||
/// Reported, not acted on: short reads that actually starved the callback, and smooth drift
|
||||
/// corrections. A rising underrun count means the ring is being starved (network or CPU),
|
||||
/// which is a different problem from the depth being wrong.
|
||||
private var underrunCount = 0
|
||||
private var shedCount = 0
|
||||
private let channels: Int
|
||||
private let perMS: Int
|
||||
private let lock = OSAllocatedUnfairLock()
|
||||
|
||||
/// `capacity`/`prefill` in samples (interleaved — `channels` per frame, both whole frames).
|
||||
init(capacity: Int, prefill: Int, channels: Int) {
|
||||
/// `capacity` in samples (interleaved — `channels` per frame, a whole number of frames).
|
||||
/// The de-jitter depth is the ring's own business (`targetMS`), not a caller's prefill.
|
||||
init(capacity: Int, channels: Int) {
|
||||
buf = [Float](repeating: 0, count: capacity)
|
||||
self.prefill = prefill
|
||||
self.channels = channels
|
||||
highWater = prefill * 4
|
||||
perMS = 48 * channels
|
||||
}
|
||||
|
||||
/// Live target depth in interleaved samples, lifted so it can always serve one device quantum
|
||||
/// plus a packet (a large-buffer device cannot sustain a target below its own quantum).
|
||||
private var target: Int {
|
||||
max(Self.targetMS * perMS, renderQuantum + Self.frameMS * perMS)
|
||||
}
|
||||
|
||||
func write(_ samples: UnsafePointer<Float>, count: Int) {
|
||||
@@ -42,12 +80,12 @@ final class AudioRing: @unchecked Sendable {
|
||||
buf[(writeIdx + i) % capacity] = samples[i]
|
||||
}
|
||||
writeIdx += count
|
||||
// Latency clamp: both ends run at 48 kHz, so backlog from a network stall (or
|
||||
// creeping host-vs-DAC clock skew) never drains on its own — without this, one
|
||||
// 300 ms hiccup leaves audio 300 ms behind video for the rest of the session.
|
||||
// Shedding down to 2× prefill costs one audible blip instead.
|
||||
if writeIdx - readIdx > highWater {
|
||||
readIdx = writeIdx - prefill * 2
|
||||
// Backstop only: the smooth shed in `read` is what normally holds the depth down.
|
||||
let cap = min(target + Self.headroomMS * perMS, Self.hardCapMS * perMS)
|
||||
if writeIdx - readIdx > cap {
|
||||
readIdx = writeIdx - cap
|
||||
depthAvg = Double(cap)
|
||||
overRun = 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,16 +95,37 @@ final class AudioRing: @unchecked Sendable {
|
||||
defer { lock.unlock() }
|
||||
renderQuantum = max(renderQuantum, count)
|
||||
let available = writeIdx - readIdx
|
||||
|
||||
// Depth average, weighted by the callback size so its time constant is independent of the
|
||||
// device quantum.
|
||||
let alpha = min(1.0, Double(count) / Double(Self.ewmaTauMS * perMS))
|
||||
depthAvg += (Double(available) - depthAvg) * alpha
|
||||
|
||||
if !primed {
|
||||
// One 5 ms host packet (240 frames × channels) of slack beyond the device's demand.
|
||||
if available >= max(prefill, renderQuantum + 240 * channels) {
|
||||
if available >= target {
|
||||
primed = true
|
||||
emptyReads = 0
|
||||
} else {
|
||||
for i in 0..<count { out[i] = 0 }
|
||||
return
|
||||
}
|
||||
}
|
||||
let n = min(available, count)
|
||||
|
||||
// Drift correction: shed exactly one frame, crossfaded, once the AVERAGE has sat above
|
||||
// the threshold for the sustain window. Anything shorter is jitter and must be left alone.
|
||||
if depthAvg > Double(target + Self.shedExcessMS * perMS) {
|
||||
overRun += count
|
||||
if overRun >= Self.shedSustainMS * perMS {
|
||||
overRun = 0
|
||||
shedOneFrame()
|
||||
shedCount += 1
|
||||
depthAvg = Double(writeIdx - readIdx)
|
||||
}
|
||||
} else {
|
||||
overRun = 0
|
||||
}
|
||||
|
||||
let n = min(writeIdx - readIdx, count)
|
||||
let capacity = buf.count
|
||||
for i in 0..<n {
|
||||
out[i] = buf[(readIdx + i) % capacity]
|
||||
@@ -74,9 +133,63 @@ final class AudioRing: @unchecked Sendable {
|
||||
readIdx += n
|
||||
if n < count {
|
||||
for i in n..<count { out[i] = 0 }
|
||||
primed = false // underrun — re-prime before resuming
|
||||
// De-prime only after a RUN of short reads: a single transient drain must not
|
||||
// manufacture a whole target's worth of fresh silence.
|
||||
emptyReads += 1
|
||||
underrunCount += 1
|
||||
if emptyReads >= Self.deprimeAfter { primed = false }
|
||||
} else {
|
||||
emptyReads = 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop one protocol frame from the front, linearly crossfading the seam so the correction is
|
||||
/// inaudible rather than a click. Mirrors `punktfunk_core::audio::crossfade_drop`; caller holds
|
||||
/// the lock.
|
||||
private func shedOneFrame() {
|
||||
let drop = Self.frameMS * perMS
|
||||
let available = writeIdx - readIdx
|
||||
guard available > drop else { return }
|
||||
let fade = min(Self.crossfadeMS * perMS, min(drop, available - drop))
|
||||
let capacity = buf.count
|
||||
if fade > 0 {
|
||||
// The tail of what we discard fades out into the head of what survives.
|
||||
for i in 0..<fade {
|
||||
let old = buf[(readIdx + drop - fade + i) % capacity]
|
||||
let new = buf[(readIdx + drop + i) % capacity]
|
||||
let t = Float(i + 1) / Float(fade + 1)
|
||||
buf[(readIdx + drop + i) % capacity] = old * (1 - t) + new * t
|
||||
}
|
||||
}
|
||||
readIdx += drop
|
||||
}
|
||||
|
||||
/// Current buffered depth in milliseconds — for the stats overlay and the drain thread's
|
||||
/// periodic log.
|
||||
var bufferedMS: Int {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return (writeIdx - readIdx) / max(perMS, 1)
|
||||
}
|
||||
|
||||
/// One consistent snapshot of the ring's vitals, taken under a single lock so the numbers in
|
||||
/// a log line describe the same instant. Mirrors what the three Rust clients report.
|
||||
struct Stats {
|
||||
let bufferedMS: Int
|
||||
let targetMS: Int
|
||||
let underruns: Int
|
||||
let sheds: Int
|
||||
}
|
||||
|
||||
var stats: Stats {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return Stats(
|
||||
bufferedMS: (writeIdx - readIdx) / max(perMS, 1),
|
||||
targetMS: target / max(perMS, 1),
|
||||
underruns: underrunCount,
|
||||
sheds: shedCount)
|
||||
}
|
||||
}
|
||||
|
||||
/// CoreAudio channel layout for the canonical wire order FL FR FC LFE RL RR [SL SR]. nil for
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// Opus ⇄ PCM through CoreAudio's built-in codec (kAudioFormatOpus, macOS 10.13+ / iOS
|
||||
// 11+) — no bundled libopus. The host's audio plane is raw Opus packets (48 kHz stereo,
|
||||
// one frame per packet); AVAudioConverter handles them as single-packet
|
||||
// AVAudioCompressedBuffers with explicit packet descriptions.
|
||||
// one frame per packet); the mic uplink is 48 kHz MONO packets (one microphone bus —
|
||||
// the host's decoder upmixes, so duplicating it into a second channel only cost bits).
|
||||
// AVAudioConverter handles both as single-packet AVAudioCompressedBuffers with explicit
|
||||
// packet descriptions.
|
||||
//
|
||||
// Both classes are single-threaded by contract (one per direction, owned by their
|
||||
// drain/capture pipelines).
|
||||
@@ -14,16 +16,16 @@ enum OpusCodecError: Error {
|
||||
case convertFailed(String)
|
||||
}
|
||||
|
||||
/// 48 kHz stereo float32 interleaved — the PCM side of both converters and the layout
|
||||
/// of the playback ring buffer.
|
||||
/// 48 kHz stereo float32 interleaved — the decoder's PCM side (the host plane's shape).
|
||||
func opusPCMFormat() -> AVAudioFormat? {
|
||||
AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32, sampleRate: 48_000, channels: 2, interleaved: true)
|
||||
}
|
||||
|
||||
/// The compressed side: raw Opus, `framesPerPacket` nominal samples per packet at 48 kHz
|
||||
/// (240 = the host's 5 ms audio plane; 960 = the 20 ms packets the encoder emits).
|
||||
private func opusFormat(framesPerPacket: UInt32) -> AVAudioFormat? {
|
||||
/// (240 = the host's 5 ms audio plane; 480 = the 10 ms packets the encoder emits) and
|
||||
/// `channels` (2 = the host plane, 1 = the mic uplink).
|
||||
private func opusFormat(framesPerPacket: UInt32, channels: UInt32) -> AVAudioFormat? {
|
||||
var desc = AudioStreamBasicDescription(
|
||||
mSampleRate: 48_000,
|
||||
mFormatID: kAudioFormatOpus,
|
||||
@@ -31,7 +33,7 @@ private func opusFormat(framesPerPacket: UInt32) -> AVAudioFormat? {
|
||||
mBytesPerPacket: 0,
|
||||
mFramesPerPacket: framesPerPacket,
|
||||
mBytesPerFrame: 0,
|
||||
mChannelsPerFrame: 2,
|
||||
mChannelsPerFrame: channels,
|
||||
mBitsPerChannel: 0,
|
||||
mReserved: 0)
|
||||
return AVAudioFormat(streamDescription: &desc)
|
||||
@@ -45,7 +47,8 @@ final class OpusDecoder {
|
||||
|
||||
/// `framesPerPacket`: the sender's packet duration in samples (host audio = 240).
|
||||
init(framesPerPacket: UInt32) throws {
|
||||
guard let pcm = opusPCMFormat(), let opus = opusFormat(framesPerPacket: framesPerPacket),
|
||||
guard let pcm = opusPCMFormat(),
|
||||
let opus = opusFormat(framesPerPacket: framesPerPacket, channels: 2),
|
||||
let converter = AVAudioConverter(from: opus, to: pcm)
|
||||
else { throw OpusCodecError.unavailable }
|
||||
self.converter = converter
|
||||
@@ -90,24 +93,43 @@ final class OpusDecoder {
|
||||
}
|
||||
|
||||
final class OpusEncoder {
|
||||
/// The encoder's packet duration: 960 samples = 20 ms, CoreAudio's default Opus
|
||||
/// framing. The host's mic service decodes any Opus frame size up to 120 ms.
|
||||
static let framesPerPacket: AVAudioFrameCount = 960
|
||||
/// The encoder's packet duration in samples: 480 = 10 ms, halving the packetization
|
||||
/// latency of the old 20 ms framing. CoreAudio honors it — mFramesPerPacket 480/mono
|
||||
/// creates a converter that truly emits 10 ms CELT packets (TOC config 30), one per
|
||||
/// 480-frame chunk, verified by inspection of the emitted TOC bytes and per-packet
|
||||
/// frame accounting. 960 stays as the fallback should an older codec refuse 480.
|
||||
/// The host's mic service decodes any Opus frame size up to 120 ms, so either is
|
||||
/// wire-compatible.
|
||||
let framesPerPacket: AVAudioFrameCount
|
||||
|
||||
/// 48 kHz MONO float32 interleaved — the uplink carries one microphone bus.
|
||||
let pcmFormat: AVAudioFormat
|
||||
|
||||
private let converter: AVAudioConverter
|
||||
private let outBuf: AVAudioCompressedBuffer
|
||||
let pcmFormat: AVAudioFormat
|
||||
|
||||
init() throws {
|
||||
guard let pcm = opusPCMFormat(),
|
||||
let opus = opusFormat(framesPerPacket: UInt32(Self.framesPerPacket)),
|
||||
let converter = AVAudioConverter(from: pcm, to: opus)
|
||||
guard let pcm = AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32, sampleRate: 48_000, channels: 1,
|
||||
interleaved: true)
|
||||
else { throw OpusCodecError.unavailable }
|
||||
converter.bitRate = 96_000
|
||||
self.converter = converter
|
||||
self.pcmFormat = pcm
|
||||
var made: (converter: AVAudioConverter, fpp: AVAudioFrameCount)?
|
||||
for fpp: AVAudioFrameCount in [480, 960] {
|
||||
if let opus = opusFormat(framesPerPacket: UInt32(fpp), channels: 1),
|
||||
let converter = AVAudioConverter(from: pcm, to: opus) {
|
||||
made = (converter, fpp)
|
||||
break
|
||||
}
|
||||
}
|
||||
guard let made else { throw OpusCodecError.unavailable }
|
||||
// 48 kbps: transparent for mono voice — the old 96 kbps budget was sized for
|
||||
// the duplicated-stereo framing this encoder no longer emits.
|
||||
made.converter.bitRate = 48_000
|
||||
converter = made.converter
|
||||
framesPerPacket = made.fpp
|
||||
pcmFormat = pcm
|
||||
outBuf = AVAudioCompressedBuffer(
|
||||
format: opus, packetCapacity: 4, maximumPacketSize: 1500)
|
||||
format: made.converter.outputFormat, packetCapacity: 4, maximumPacketSize: 1500)
|
||||
}
|
||||
|
||||
/// Encode exactly `framesPerPacket` frames of `pcmFormat` audio; returns the encoded
|
||||
|
||||
@@ -5,15 +5,22 @@
|
||||
// AVAudioSourceNode pulls from the ring (silence on underrun with re-priming, so a
|
||||
// network gap costs one dip, not permanent crackle).
|
||||
//
|
||||
// mic → host: a second AVAudioEngine taps the input device, folds it to one mono bus (the
|
||||
// chosen channel of a multi-channel interface, or a sum of all channels), resamples to 48 kHz
|
||||
// stereo, slices 20 ms chunks, Opus-encodes, and sendMic()s each packet — the host feeds them
|
||||
// into a virtual PipeWire source.
|
||||
// mic → host: a tap on the input node folds the capture to one mono bus (the chosen channel
|
||||
// of a multi-channel interface, or a sum of all channels), resamples to 48 kHz mono, slices
|
||||
// 10 ms chunks, Opus-encodes, and sendMic()s each packet — the host feeds them into a
|
||||
// virtual PipeWire source.
|
||||
//
|
||||
// Engine topology. With the mic enabled and echo cancellation on (both defaults), BOTH
|
||||
// directions run on ONE AVAudioEngine with the system voice processor engaged
|
||||
// (`setVoiceProcessingEnabled`) — AEC needs render and capture on the same unit so it can
|
||||
// subtract what the speaker is playing from what the mic hears; without it, a loudspeaker
|
||||
// client feeds the host's own game audio straight back to it (the primary reported echo
|
||||
// source). The voice processor can only follow the system DEFAULT devices, so explicit
|
||||
// endpoint choices fall back to the old two-engine topology — see `wantsCombined` for the
|
||||
// exact decision, and `startCapture` for why two engines handle arbitrary device pairs.
|
||||
//
|
||||
// Devices are chosen by UID ("" = system default: the engine is then never pinned to a
|
||||
// concrete device and follows default-device changes). Two engines, not one — a single
|
||||
// AVAudioEngine ties input+output to one aggregate clock, separate engines keep
|
||||
// arbitrary mic/speaker combinations trivial.
|
||||
// concrete device and follows default-device changes).
|
||||
|
||||
import AVFoundation
|
||||
import os
|
||||
@@ -40,7 +47,21 @@ public final class SessionAudio {
|
||||
private let stateLock = NSLock()
|
||||
private var playbackEngine: AVAudioEngine?
|
||||
private var captureEngine: AVAudioEngine?
|
||||
/// The one engine running BOTH directions when the voice processor is engaged;
|
||||
/// `playbackEngine`/`captureEngine` stay nil while this is set.
|
||||
private var combinedEngine: AVAudioEngine?
|
||||
private var drainStarted = false
|
||||
/// The mute LATCH: the effective mute the owner last asked for (see `setMicMuted`). Held
|
||||
/// because the uplink engine can appear LATER than the request — the mic permission prompt
|
||||
/// is answered at the user's leisure, and the engine is built on the grant — so a mute set
|
||||
/// in the meantime must be waiting for it. Applied by whichever start path wins the race.
|
||||
/// Guarded by `stateLock`, like the engines it applies to.
|
||||
private var micMuted = false
|
||||
/// The playback jitter ring — created by whichever engine starts playback first and KEPT
|
||||
/// across an engine rebuild (the permission-grant upgrade in `startEngines` swaps engines,
|
||||
/// not the ring, so the drain thread never has to be re-pointed). Main-thread confined,
|
||||
/// like every start path.
|
||||
private var ring: AudioRing?
|
||||
#if !os(macOS)
|
||||
/// AVAudioSession `setCategory`/`setActive` are synchronous and block on the audio server, so
|
||||
/// they must not run on the main thread (UI stall — AVFoundation warns about it). PROCESS-WIDE
|
||||
@@ -69,11 +90,15 @@ public final class SessionAudio {
|
||||
/// ASYNCHRONOUS: it activates the AVAudioSession off the main thread, then starts the engines on
|
||||
/// a later main-queue hop (gated by `!flag.isStopped`) — so playback is live shortly after, not
|
||||
/// on return. The mic may start later still if the permission prompt is pending.
|
||||
public func start(speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool) {
|
||||
/// `echoCancel` picks the engine topology — see the header note and `wantsCombined`.
|
||||
public func start(
|
||||
speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool, echoCancel: Bool
|
||||
) {
|
||||
#if os(macOS)
|
||||
// No AVAudioSession on macOS — start the engines directly (caller's thread, as before).
|
||||
startEngines(
|
||||
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel, micEnabled: micEnabled)
|
||||
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
|
||||
micEnabled: micEnabled, echoCancel: echoCancel)
|
||||
#else
|
||||
// Configure + activate the session OFF the main thread (it blocks on the audio server),
|
||||
// then start the engines back on the main thread once it's active — engine routing/format
|
||||
@@ -85,7 +110,7 @@ public final class SessionAudio {
|
||||
guard let self, !self.flag.isStopped else { return }
|
||||
self.startEngines(
|
||||
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
|
||||
micEnabled: micEnabled)
|
||||
micEnabled: micEnabled, echoCancel: echoCancel)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -104,6 +129,12 @@ public final class SessionAudio {
|
||||
try session.setCategory(
|
||||
.playAndRecord, mode: .default,
|
||||
options: [.allowBluetoothA2DP, .defaultToSpeaker])
|
||||
// Uplink latency: ask for 5 ms IO quanta at the wire rate (the default ~10-23 ms
|
||||
// quantum is most of the mic path's burst latency). Best-effort — the hardware
|
||||
// has the final word (a Bluetooth route will ignore both), and whatever quantum
|
||||
// is actually granted, the capture tap handles the buffers it gets.
|
||||
try? session.setPreferredIOBufferDuration(0.005)
|
||||
try? session.setPreferredSampleRate(48_000)
|
||||
} else {
|
||||
try session.setCategory(.playback, mode: .default)
|
||||
}
|
||||
@@ -117,32 +148,80 @@ public final class SessionAudio {
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Build + start the playback engine (and the mic uplink when enabled + authorized). Main
|
||||
/// thread (engine setup); on iOS/tvOS the session is already active by the time this runs.
|
||||
/// Build + start the engines — combined (voice-processed) or split, per `wantsCombined` —
|
||||
/// with the mic uplink only when enabled + authorized. Main thread (engine setup); on
|
||||
/// iOS/tvOS the session is already active by the time this runs.
|
||||
private func startEngines(
|
||||
speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool
|
||||
speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool, echoCancel: Bool
|
||||
) {
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
#if os(tvOS)
|
||||
// No app-accessible microphone input on tvOS — playback only.
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
#else
|
||||
guard micEnabled else { return }
|
||||
guard micEnabled else {
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
return
|
||||
}
|
||||
let combined = wantsCombined(
|
||||
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
|
||||
echoCancel: echoCancel)
|
||||
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
||||
case .authorized:
|
||||
startCapture(micUID: micUID, micChannel: micChannel)
|
||||
if combined {
|
||||
startCombined(speakerUID: speakerUID, micUID: micUID, micChannel: micChannel)
|
||||
} else {
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
startCapture(micUID: micUID, micChannel: micChannel)
|
||||
}
|
||||
case .notDetermined:
|
||||
// Playback must not wait out the permission prompt (the user answers at their
|
||||
// leisure) — start it now, and on a grant either bolt the capture engine on
|
||||
// (split) or swap the playback engine for the combined one (the ring and its
|
||||
// drain thread carry over — see `makePlaybackChain`).
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
|
||||
DispatchQueue.main.async {
|
||||
guard let self, granted, !self.flag.isStopped else { return }
|
||||
self.startCapture(micUID: micUID, micChannel: micChannel)
|
||||
if combined {
|
||||
self.stateLock.lock()
|
||||
let playback = self.playbackEngine
|
||||
self.playbackEngine = nil
|
||||
self.stateLock.unlock()
|
||||
playback?.stop()
|
||||
self.startCombined(
|
||||
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel)
|
||||
} else {
|
||||
self.startCapture(micUID: micUID, micChannel: micChannel)
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
log.warning("microphone access denied — mic uplink disabled (System Settings → Privacy)")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
/// One engine or two: the voice processor requires render + capture on one unit, and that
|
||||
/// unit can only follow the system DEFAULT devices — so echo cancellation gets the combined
|
||||
/// engine only while nothing is explicitly pinned. On macOS a chosen speaker/mic UID or a
|
||||
/// picked input channel (the voice processor's capture side is its own mono mix — a
|
||||
/// per-channel pick can't survive it) keeps today's two-engine path, AEC-less but honoring
|
||||
/// the exact endpoints the user named. On iOS routes are session-managed and the UIDs are
|
||||
/// ignored, so the toggle alone decides.
|
||||
private func wantsCombined(
|
||||
speakerUID: String, micUID: String, micChannel: Int, echoCancel: Bool
|
||||
) -> Bool {
|
||||
guard echoCancel else { return false }
|
||||
#if os(macOS)
|
||||
return speakerUID.isEmpty && micUID.isEmpty && micChannel == 0
|
||||
#else
|
||||
return true
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Stop both directions. Safe from any thread; waits the drain thread out (≤ its
|
||||
/// poll timeout) so the caller can close the connection right after.
|
||||
public func stop() {
|
||||
@@ -152,6 +231,8 @@ public final class SessionAudio {
|
||||
captureEngine = nil
|
||||
let playback = playbackEngine
|
||||
playbackEngine = nil
|
||||
let combined = combinedEngine
|
||||
combinedEngine = nil
|
||||
let wasDraining = drainStarted
|
||||
drainStarted = false
|
||||
stateLock.unlock()
|
||||
@@ -160,6 +241,10 @@ public final class SessionAudio {
|
||||
capture.stop()
|
||||
}
|
||||
playback?.stop()
|
||||
if let combined {
|
||||
combined.inputNode.removeTap(onBus: 0)
|
||||
combined.stop()
|
||||
}
|
||||
#if !os(macOS)
|
||||
// Release the session so audio we interrupted (Music, podcasts) gets its resume cue. Like
|
||||
// activation, setActive is synchronous/blocking — run it on the shared serial session queue
|
||||
@@ -180,15 +265,38 @@ 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.
|
||||
/// Silence the mic uplink (no room audio leaves the device) or restore it. THE one muting
|
||||
/// mechanism: the owner composes its reasons — the user's in-stream mute and the background
|
||||
/// keep-alive's privacy mute — into one effective state and passes that here, so neither can
|
||||
/// clear the other (see `SessionModel.applyMicMute`).
|
||||
///
|
||||
/// Two-engine sessions pause/resume the capture engine; a combined session instead mutes the
|
||||
/// voice processor's input (playback shares that engine and must keep running, so the engine
|
||||
/// itself never pauses — the mute zeroes the mic at the IO unit, and the tap encodes silence).
|
||||
/// Local and instant either way: nothing is negotiated with the host, and the packets that do
|
||||
/// leave carry silence. A no-op when there's no uplink (playback-only / tvOS / mic disabled),
|
||||
/// except that the state is LATCHED for an uplink that starts later. The audio SESSION stays
|
||||
/// active for background playback, so iOS may keep showing the recording indicator until a
|
||||
/// full reconfigure — either path stops room audio leaving the device, which is the
|
||||
/// privacy-relevant part. Main thread.
|
||||
public func setMicMuted(_ muted: Bool) {
|
||||
stateLock.lock()
|
||||
micMuted = muted
|
||||
let capture = captureEngine
|
||||
let combined = combinedEngine
|
||||
stateLock.unlock()
|
||||
apply(micMuted: muted, capture: capture, combined: combined)
|
||||
}
|
||||
|
||||
/// Push the latched mute onto whichever engine carries the uplink. Split out from
|
||||
/// `setMicMuted` because the start paths call it too, with the engine they just started —
|
||||
/// that's how a mute requested before the permission grant lands on the engine the grant
|
||||
/// creates. Never resumes a stopped session's engine.
|
||||
private func apply(micMuted muted: Bool, capture: AVAudioEngine?, combined: AVAudioEngine?) {
|
||||
if let combined {
|
||||
combined.inputNode.isVoiceProcessingInputMuted = muted
|
||||
return
|
||||
}
|
||||
guard let capture else { return }
|
||||
if muted {
|
||||
capture.pause()
|
||||
@@ -199,28 +307,21 @@ public final class SessionAudio {
|
||||
|
||||
// MARK: - Playback (host → speaker)
|
||||
|
||||
private func startPlayback(speakerUID: String) {
|
||||
/// The playback jitter ring + the source node draining it — shared by the plain playback
|
||||
/// engine and the combined voice-processing engine, and REUSED across an engine rebuild
|
||||
/// (same session, same ring: the drain thread keeps writing right through the swap). nil
|
||||
/// when the host's channel layout can't be expressed (already logged). Main thread.
|
||||
private func makePlaybackChain()
|
||||
-> (ring: AudioRing, source: AVAudioSourceNode, format: AVAudioFormat)?
|
||||
{
|
||||
// Build the playback layout from the host-RESOLVED channel count (never the request):
|
||||
// 2 = stereo / 6 = 5.1 / 8 = 7.1, canonical wire order FL FR FC LFE RL RR SL SR.
|
||||
let channels = Int(connection.resolvedAudioChannels)
|
||||
// 1 s interleaved capacity, ~20 ms prefill (four 5 ms host packets of jitter absorption
|
||||
// before the first sample plays), both scaled by the channel count.
|
||||
let ring = AudioRing(
|
||||
capacity: 48_000 * channels, prefill: 960 * channels, channels: channels)
|
||||
|
||||
let engine = AVAudioEngine()
|
||||
#if os(macOS)
|
||||
if !speakerUID.isEmpty {
|
||||
if let dev = AudioDevices.deviceID(forUID: speakerUID),
|
||||
let unit = engine.outputNode.audioUnit {
|
||||
if !Self.setDevice(dev, on: unit) {
|
||||
log.error("could not select speaker \(speakerUID) — using default")
|
||||
}
|
||||
} else {
|
||||
log.warning("speaker \(speakerUID) not present — using default")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
// 1 s interleaved capacity, scaled by the channel count. The de-jitter depth itself is
|
||||
// the ring's own business now (`AudioRing.targetMS`, mirroring `JitterTuning::COREAUDIO`)
|
||||
// rather than a prefill passed in here.
|
||||
let ring = self.ring ?? AudioRing(capacity: 48_000 * channels, channels: channels)
|
||||
self.ring = ring
|
||||
|
||||
// Engine-native deinterleaved float; the render block deinterleaves from the ring. Surround
|
||||
// uses an explicit wire-order channel layout; the mixer downmixes to the output device when
|
||||
@@ -234,7 +335,7 @@ public final class SessionAudio {
|
||||
}
|
||||
guard let format else {
|
||||
log.error("could not build \(channels)-channel audio format — audio disabled")
|
||||
return
|
||||
return nil
|
||||
}
|
||||
let scratch = ScratchBuffer() // block-owned; freed with the closure
|
||||
let source = AVAudioSourceNode(format: format) { _, _, frameCount, abl -> OSStatus in
|
||||
@@ -252,6 +353,24 @@ public final class SessionAudio {
|
||||
}
|
||||
return noErr
|
||||
}
|
||||
return (ring, source, format)
|
||||
}
|
||||
|
||||
private func startPlayback(speakerUID: String) {
|
||||
guard let (ring, source, format) = makePlaybackChain() else { return }
|
||||
let engine = AVAudioEngine()
|
||||
#if os(macOS)
|
||||
if !speakerUID.isEmpty {
|
||||
if let dev = AudioDevices.deviceID(forUID: speakerUID),
|
||||
let unit = engine.outputNode.audioUnit {
|
||||
if !Self.setDevice(dev, on: unit) {
|
||||
log.error("could not select speaker \(speakerUID) — using default")
|
||||
}
|
||||
} else {
|
||||
log.warning("speaker \(speakerUID) not present — using default")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
engine.attach(source)
|
||||
engine.connect(source, to: engine.mainMixerNode, format: format)
|
||||
engine.prepare()
|
||||
@@ -272,12 +391,19 @@ public final class SessionAudio {
|
||||
startDrain(into: ring)
|
||||
}
|
||||
|
||||
/// Idempotent — the permission-grant engine swap reaches here a second time with the
|
||||
/// drain thread already feeding the (carried-over) ring.
|
||||
private func startDrain(into ring: AudioRing) {
|
||||
stateLock.lock()
|
||||
if drainStarted {
|
||||
stateLock.unlock()
|
||||
return
|
||||
}
|
||||
drainStarted = true
|
||||
stateLock.unlock()
|
||||
let thread = Thread { [connection, flag, drainDone] in
|
||||
defer { drainDone.signal() }
|
||||
var drained = 0
|
||||
// Decode happens IN-CORE (libopus multistream) — AudioToolbox's Opus path is
|
||||
// stereo-only — and is handed back as interleaved f32 PCM in wire channel order.
|
||||
// Per-iteration autorelease pool: no runloop on this thread (see Stage2Pipeline).
|
||||
@@ -296,6 +422,17 @@ public final class SessionAudio {
|
||||
ring.write(base, count: pcm.frameCount * pcm.channels)
|
||||
}
|
||||
}
|
||||
// Periodic vitals (~10 s at the protocol's 5 ms frames). The other three clients
|
||||
// log buffer depth and underruns; without this an Apple audio report — latency or
|
||||
// dropout — arrives with no numbers at all, which is the position every platform
|
||||
// was in before the 2026-08 audio work.
|
||||
drained += 1
|
||||
if drained % 2_000 == 0 {
|
||||
let s = ring.stats
|
||||
log.info(
|
||||
"audio: buffer_ms=\(s.bufferedMS) target_ms=\(s.targetMS) underruns=\(s.underruns) drift_sheds=\(s.sheds)"
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -308,6 +445,80 @@ public final class SessionAudio {
|
||||
// MARK: - Mic (mic → host)
|
||||
|
||||
#if !os(tvOS)
|
||||
/// One engine, both directions: engage the system voice processor on the shared IO unit
|
||||
/// (AEC + noise suppression + AGC), hang the playback source off its render side and the
|
||||
/// mic tap off its capture side. Every failure falls back to a WORKING configuration —
|
||||
/// the split path (no AEC) when the voice processor won't engage, plain playback when the
|
||||
/// mic chain can't be built — a session never loses audio to the echo-cancel feature.
|
||||
private func startCombined(speakerUID: String, micUID: String, micChannel: Int) {
|
||||
let engine = AVAudioEngine()
|
||||
let input = engine.inputNode
|
||||
do {
|
||||
// Before anything reads the input's format: the voice processor changes it (often
|
||||
// to its own mono mix, sometimes at a lower rate) — installMicTap reads the format
|
||||
// AFTER this, so the converter chain adapts to whatever the processor emits.
|
||||
try input.setVoiceProcessingEnabled(true)
|
||||
} catch {
|
||||
log.warning("""
|
||||
voice processing unavailable (\(error.localizedDescription)) — separate \
|
||||
engines, no echo cancellation
|
||||
""")
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
startCapture(micUID: micUID, micChannel: micChannel)
|
||||
return
|
||||
}
|
||||
// Symmetric enable for the render side; with both directions on one engine the
|
||||
// input-node enable already covers it, so a refusal here is not a failure.
|
||||
try? engine.outputNode.setVoiceProcessingEnabled(true)
|
||||
// This is a game stream, not a call: never duck the host's audio under the outgoing
|
||||
// voice. .min is the closest to "off" the API offers, and advanced (selective)
|
||||
// ducking stays off with it.
|
||||
input.voiceProcessingOtherAudioDuckingConfiguration = .init(
|
||||
enableAdvancedDucking: false, duckingLevel: .min)
|
||||
|
||||
guard let (ring, source, format) = makePlaybackChain() else {
|
||||
// Playback impossible (logged) — keep the uplink alive, as the split path would.
|
||||
startCapture(micUID: micUID, micChannel: micChannel)
|
||||
return
|
||||
}
|
||||
engine.attach(source)
|
||||
engine.connect(source, to: engine.mainMixerNode, format: format)
|
||||
guard installMicTap(on: input, micUID: micUID, micChannel: micChannel) else {
|
||||
// Mic chain unavailable (logged) — keep the session audible on the plain playback
|
||||
// engine rather than playing through an idle voice processor.
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
return
|
||||
}
|
||||
engine.prepare()
|
||||
do {
|
||||
try engine.start()
|
||||
} catch {
|
||||
log.error("combined engine failed to start: \(error.localizedDescription)")
|
||||
input.removeTap(onBus: 0)
|
||||
startPlayback(speakerUID: speakerUID) // no echo cancellation beats no audio
|
||||
return
|
||||
}
|
||||
stateLock.lock()
|
||||
if flag.isStopped {
|
||||
stateLock.unlock()
|
||||
input.removeTap(onBus: 0)
|
||||
engine.stop() // stop() already ran — don't strand a started engine (or a hot mic)
|
||||
return
|
||||
}
|
||||
combinedEngine = engine
|
||||
let muted = micMuted // latched before this engine existed (a mute during the prompt)
|
||||
stateLock.unlock()
|
||||
apply(micMuted: muted, capture: nil, combined: engine)
|
||||
startDrain(into: ring)
|
||||
log.info("audio engines joined — voice processing (echo cancellation) active")
|
||||
}
|
||||
|
||||
/// The split path: capture on its OWN engine, playback on another — the pre-echo-cancel
|
||||
/// topology, kept verbatim. Two engines, not one — a single AVAudioEngine ties
|
||||
/// input+output to one aggregate clock, separate engines keep arbitrary mic/speaker
|
||||
/// combinations trivial. That freedom is exactly why the voice processor can't ride this
|
||||
/// path (AEC needs both directions on one unit) and why explicitly pinned endpoints land
|
||||
/// here — see `wantsCombined`.
|
||||
private func startCapture(micUID: String, micChannel: Int) {
|
||||
let engine = AVAudioEngine()
|
||||
let input = engine.inputNode
|
||||
@@ -322,11 +533,44 @@ public final class SessionAudio {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
guard installMicTap(on: input, micUID: micUID, micChannel: micChannel) else { return }
|
||||
engine.prepare()
|
||||
do {
|
||||
try engine.start()
|
||||
} catch {
|
||||
log.error("capture engine failed to start: \(error.localizedDescription)")
|
||||
input.removeTap(onBus: 0)
|
||||
return
|
||||
}
|
||||
stateLock.lock()
|
||||
if flag.isStopped {
|
||||
// stop() ran while we were starting (the permission prompt resolves at the
|
||||
// user's leisure) — tear the engine down ourselves, nobody else owns it now.
|
||||
stateLock.unlock()
|
||||
input.removeTap(onBus: 0)
|
||||
engine.stop()
|
||||
return
|
||||
}
|
||||
captureEngine = engine
|
||||
let muted = micMuted // latched before this engine existed (a mute during the prompt)
|
||||
stateLock.unlock()
|
||||
apply(micMuted: muted, capture: engine, combined: nil)
|
||||
log.info("mic uplink started (\(micUID.isEmpty ? "default input" : micUID))")
|
||||
}
|
||||
|
||||
/// Resolve the input's live format + fold plan, build the mono→Opus chain, and install the
|
||||
/// capture tap on `input` — everything mic except engine ownership, shared verbatim by the
|
||||
/// combined and split topologies. Reads `input.outputFormat(forBus:)` at call time, so the
|
||||
/// chain follows whatever the node emits: the raw device format, or the voice processor's
|
||||
/// own mix when that's enabled. False (logged) when no input is usable or the encoder
|
||||
/// can't be built; the tap is installed on true.
|
||||
private func installMicTap(
|
||||
on input: AVAudioInputNode, micUID: String, micChannel: Int
|
||||
) -> Bool {
|
||||
let inFormat = input.outputFormat(forBus: 0)
|
||||
guard inFormat.sampleRate > 0, inFormat.channelCount > 0 else {
|
||||
log.error("no usable input device — mic uplink disabled")
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
// Multi-channel-interface handling. A pro interface exposes N discrete inputs with the mic
|
||||
@@ -378,22 +622,38 @@ public final class SessionAudio {
|
||||
#endif
|
||||
|
||||
// Encode a single mono bus (folded from `inFormat` in the tap): the resampler goes
|
||||
// mono@inputSR → the encoder's 48 kHz stereo, so it handles both the rate change and the
|
||||
// mono→stereo duplication, and the wrong-channel downmix never happens.
|
||||
// mono@inputSR → the encoder's 48 kHz mono, so it handles the rate change and the
|
||||
// wrong-channel downmix never happens. Mono end to end — the host's decoder upmixes,
|
||||
// so the old duplicate-into-stereo step only cost bits and cycles.
|
||||
//
|
||||
// `mono`/`staging` are the per-callback scratch buffers, preallocated HERE (grown only
|
||||
// if a larger-than-expected device quantum ever arrives) — the steady-state tap path
|
||||
// allocates nothing.
|
||||
let scratchFrames: AVAudioFrameCount = 8192
|
||||
let stagingCapacity = { (frames: AVAudioFrameCount) -> AVAudioFrameCount in
|
||||
AVAudioFrameCount(
|
||||
(Double(frames) * 48_000 / inFormat.sampleRate).rounded(.up)) + 64
|
||||
}
|
||||
guard let monoFormat = AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32, sampleRate: inFormat.sampleRate,
|
||||
channels: 1, interleaved: false),
|
||||
let encoder = try? OpusEncoder(),
|
||||
let resampler = AVAudioConverter(from: monoFormat, to: encoder.pcmFormat),
|
||||
let chunk = AVAudioPCMBuffer(
|
||||
pcmFormat: encoder.pcmFormat, frameCapacity: OpusEncoder.framesPerPacket)
|
||||
pcmFormat: encoder.pcmFormat, frameCapacity: encoder.framesPerPacket),
|
||||
let monoScratch = AVAudioPCMBuffer(
|
||||
pcmFormat: monoFormat, frameCapacity: scratchFrames),
|
||||
let stagingScratch = AVAudioPCMBuffer(
|
||||
pcmFormat: encoder.pcmFormat, frameCapacity: stagingCapacity(scratchFrames))
|
||||
else {
|
||||
log.error("Opus encoder unavailable — mic uplink disabled")
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
// Tap-thread-confined state: resample into `staging`, accumulate in `fifo`,
|
||||
// slice 960-frame chunks for the encoder.
|
||||
// Tap-thread-confined state: fold into `mono`, resample into `staging`, accumulate in
|
||||
// `fifo`, slice `framesPerPacket` (10 ms) chunks for the encoder.
|
||||
var mono = monoScratch
|
||||
var staging = stagingScratch
|
||||
var fifo: [Float] = []
|
||||
fifo.reserveCapacity(48_000)
|
||||
var seq: UInt32 = 0
|
||||
@@ -412,14 +672,26 @@ public final class SessionAudio {
|
||||
var inputPeak: Float = 0
|
||||
var levelReported = false
|
||||
|
||||
input.installTap(onBus: 0, bufferSize: 2048, format: inFormat) { buffer, _ in
|
||||
// 480 frames = 10 ms, matching the packet duration. Advisory — CoreAudio delivers the
|
||||
// device quantum whatever we ask (the old 2048 request came back as 42.7 ms bursts, most
|
||||
// of the uplink's latency) — but where the system honors it, the tap fires per-packet.
|
||||
input.installTap(onBus: 0, bufferSize: 480, format: inFormat) { buffer, _ in
|
||||
if flag.isStopped { return }
|
||||
let frames = Int(buffer.frameLength)
|
||||
guard frames > 0, let src = buffer.floatChannelData,
|
||||
let mono = AVAudioPCMBuffer(
|
||||
pcmFormat: monoFormat, frameCapacity: buffer.frameLength),
|
||||
let dst = mono.floatChannelData?[0]
|
||||
else { return }
|
||||
guard frames > 0, let src = buffer.floatChannelData else { return }
|
||||
if frames > Int(mono.frameCapacity) {
|
||||
// A quantum larger than the scratch (bufferSize is advisory both ways) — regrow
|
||||
// once to the new high-water mark; the steady state stays allocation-free.
|
||||
guard let biggerMono = AVAudioPCMBuffer(
|
||||
pcmFormat: monoFormat, frameCapacity: buffer.frameLength),
|
||||
let biggerStaging = AVAudioPCMBuffer(
|
||||
pcmFormat: encoder.pcmFormat,
|
||||
frameCapacity: stagingCapacity(buffer.frameLength))
|
||||
else { return }
|
||||
mono = biggerMono
|
||||
staging = biggerStaging
|
||||
}
|
||||
guard let dst = mono.floatChannelData?[0] else { return }
|
||||
mono.frameLength = buffer.frameLength
|
||||
|
||||
// Fold the multi-channel input down to the one mono bus we encode.
|
||||
@@ -451,11 +723,6 @@ public final class SessionAudio {
|
||||
}
|
||||
}
|
||||
|
||||
let ratio = 48_000 / inFormat.sampleRate
|
||||
let outCapacity = AVAudioFrameCount((Double(frames) * ratio).rounded(.up) + 64)
|
||||
guard let staging = AVAudioPCMBuffer(
|
||||
pcmFormat: encoder.pcmFormat, frameCapacity: outCapacity)
|
||||
else { return }
|
||||
var fed = false
|
||||
var convError: NSError?
|
||||
let status = resampler.convert(to: staging, error: &convError) { _, outStatus in
|
||||
@@ -469,16 +736,20 @@ public final class SessionAudio {
|
||||
}
|
||||
guard status != .error, let p = staging.floatChannelData?[0] else { return }
|
||||
fifo.append(contentsOf: UnsafeBufferPointer(
|
||||
start: p, count: Int(staging.frameLength) * 2))
|
||||
start: p, count: Int(staging.frameLength)))
|
||||
|
||||
let samplesPerChunk = Int(OpusEncoder.framesPerPacket) * 2
|
||||
while fifo.count >= samplesPerChunk {
|
||||
chunk.frameLength = OpusEncoder.framesPerPacket
|
||||
// Consume whole chunks through a head index, then drop the eaten prefix in ONE
|
||||
// move of the sub-chunk remainder. The old per-chunk removeFirst memmoved the
|
||||
// entire backlog for every packet — O(n) on the render-adjacent tap thread.
|
||||
let samplesPerChunk = Int(encoder.framesPerPacket)
|
||||
var head = 0
|
||||
while fifo.count - head >= samplesPerChunk {
|
||||
chunk.frameLength = encoder.framesPerPacket
|
||||
fifo.withUnsafeBufferPointer { src in
|
||||
chunk.floatChannelData![0].update(
|
||||
from: src.baseAddress!, count: samplesPerChunk)
|
||||
from: src.baseAddress! + head, count: samplesPerChunk)
|
||||
}
|
||||
fifo.removeFirst(samplesPerChunk)
|
||||
head += samplesPerChunk
|
||||
guard let packets = try? encoder.encode(chunk) else { continue }
|
||||
for packet in packets {
|
||||
connection.sendMic(
|
||||
@@ -486,28 +757,9 @@ public final class SessionAudio {
|
||||
seq &+= 1
|
||||
}
|
||||
}
|
||||
if head > 0 { fifo.removeFirst(head) } // keeps capacity — no realloc
|
||||
}
|
||||
|
||||
engine.prepare()
|
||||
do {
|
||||
try engine.start()
|
||||
} catch {
|
||||
log.error("capture engine failed to start: \(error.localizedDescription)")
|
||||
input.removeTap(onBus: 0)
|
||||
return
|
||||
}
|
||||
stateLock.lock()
|
||||
if flag.isStopped {
|
||||
// stop() ran while we were starting (the permission prompt resolves at the
|
||||
// user's leisure) — tear the engine down ourselves, nobody else owns it now.
|
||||
stateLock.unlock()
|
||||
input.removeTap(onBus: 0)
|
||||
engine.stop()
|
||||
return
|
||||
}
|
||||
captureEngine = engine
|
||||
stateLock.unlock()
|
||||
log.info("mic uplink started (\(micUID.isEmpty ? "default input" : micUID))")
|
||||
return true
|
||||
}
|
||||
|
||||
/// Fold `channels` of input (`floatChannelData` layout: `interleaved` → one buffer strided by
|
||||
|
||||
@@ -59,6 +59,9 @@ public final class HostDiscovery: ObservableObject {
|
||||
|
||||
/// Start browsing `_punktfunk._udp`. Idempotent — a second call while live is a no-op.
|
||||
public func start() {
|
||||
#if DEBUG
|
||||
guard !debugPinned else { return } // a seeded advert set outranks the live LAN
|
||||
#endif
|
||||
guard browser == nil else { return }
|
||||
let browser = NWBrowser(
|
||||
for: .bonjourWithTXTRecord(type: "_punktfunk._udp", domain: nil),
|
||||
@@ -92,6 +95,35 @@ public final class HostDiscovery: ObservableObject {
|
||||
for conn in connections.values { conn.cancel() }
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// A seeded advert set is in force — `start()` must not replace it with the live browse.
|
||||
private var debugPinned = false
|
||||
|
||||
/// Screenshot/preview seam, the discovery counterpart to `HostWaker.debugSet`: publish a FIXED
|
||||
/// set of adverts and keep browsing off. Without it a capture shows whatever happens to be on
|
||||
/// the machine's LAN — the App Store screenshots shipped a stranger's hostname more than once —
|
||||
/// and every mock host reads Offline because nothing advertises it.
|
||||
public func debugSet(_ adverts: [DiscoveredHost]) {
|
||||
stop()
|
||||
debugPinned = true
|
||||
hosts = adverts
|
||||
}
|
||||
|
||||
/// Builds one advert. `DiscoveredHost`'s memberwise init is internal (a public struct's is), and
|
||||
/// making it public would expose a wire-shaped model's construction to every consumer just to
|
||||
/// serve the harness.
|
||||
public static func debugAdvert(
|
||||
id: String, name: String, host: String, port: UInt16 = 9777,
|
||||
fingerprintHex: String? = nil, requiresPairing: Bool = false, allowsTofu: Bool = true,
|
||||
macAddresses: [String] = [], osChain: String = ""
|
||||
) -> DiscoveredHost {
|
||||
DiscoveredHost(
|
||||
id: id, name: name, host: host, port: port, fingerprintHex: fingerprintHex,
|
||||
requiresPairing: requiresPairing, allowsTofu: allowsTofu,
|
||||
macAddresses: macAddresses, osChain: osChain)
|
||||
}
|
||||
#endif
|
||||
|
||||
private func restart() {
|
||||
stop()
|
||||
start()
|
||||
|
||||
@@ -761,6 +761,21 @@ public final class PunktfunkConnection {
|
||||
return out
|
||||
}
|
||||
|
||||
/// Report the display-latch grid + circular arrival-phase statistic so the host can
|
||||
/// phase-lock its capture tick (design/phase-locked-capture.md). Fire-and-forget; call
|
||||
/// ~1 Hz from a vsync-aware presenter. `nextLatchHostNs` must already be HOST clock —
|
||||
/// convert with `clockOffsetNs` (host − client). No-op toward a host that never armed.
|
||||
public func reportPhase(
|
||||
nextLatchHostNs: UInt64, latchPeriodNs: UInt32, uncertaintyNs: UInt32,
|
||||
arrivalLeadNs: UInt32, coherenceMilli: UInt16
|
||||
) {
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return }
|
||||
_ = punktfunk_connection_report_phase(
|
||||
h, nextLatchHostNs, latchPeriodNs, uncertaintyNs, arrivalLeadNs, coherenceMilli)
|
||||
}
|
||||
|
||||
/// The currently active session mode (updated by accepted `requestMode` switches).
|
||||
public func currentMode() -> (width: UInt32, height: UInt32, refreshHz: UInt32) {
|
||||
abiLock.lock()
|
||||
|
||||
@@ -12,7 +12,7 @@ import GameController
|
||||
public final class ControllerTester: ObservableObject {
|
||||
// `.manual`: the panel's toggles hold a level until changed — no session wire refreshes
|
||||
// exist here to keep the renderer's staleness watchdog fed.
|
||||
private let renderer = RumbleRenderer(policy: .manual)
|
||||
private let renderer = RumbleRenderer()
|
||||
private weak var controller: GCController?
|
||||
|
||||
/// The rumble backend now in use — "DualSense HID · USB/Bluetooth", "CoreHaptics", or "—" —
|
||||
|
||||
@@ -21,8 +21,12 @@ import os
|
||||
|
||||
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
|
||||
|
||||
/// Opens the first connected Sony DualSense and forwards motor rumble to it over raw HID.
|
||||
/// Single-pad model (we forward exactly one controller), so the first match is the right one.
|
||||
/// Opens one connected Sony DualSense and forwards motor rumble to it over raw HID.
|
||||
///
|
||||
/// A caller that owns a particular pad passes the location id it wants (see
|
||||
/// `open(preferringLocationID:)`); the renderer takes that from the `GCController` it is bound to,
|
||||
/// so with two DualSenses attached each renderer drives its own device. Without a preference the
|
||||
/// lowest location id wins — an arbitrary but *stable* choice, where `Set.first` was neither.
|
||||
final class DualSenseHID {
|
||||
private let manager: IOHIDManager
|
||||
private var device: IOHIDDevice?
|
||||
@@ -43,9 +47,57 @@ final class DualSenseHID {
|
||||
|
||||
deinit { close() }
|
||||
|
||||
/// Find and open the first connected DualSense. Returns false if none is present or it can't
|
||||
/// be opened (caller then falls back to CoreHaptics).
|
||||
func open() -> Bool {
|
||||
/// The IOKit location id of the device this instance opened — the handle a caller correlates
|
||||
/// with its `GCController`. `nil` until a successful `open`.
|
||||
private(set) var locationID: UInt32?
|
||||
|
||||
/// A device's location id, or `nil` if IOKit does not report one.
|
||||
static func locationID(of dev: IOHIDDevice) -> UInt32? {
|
||||
IOHIDDeviceGetProperty(dev, kIOHIDLocationIDKey as CFString) as? UInt32
|
||||
}
|
||||
|
||||
/// Every connected DualSense/Edge, by location id — what a caller pairs against its controllers.
|
||||
static func attachedLocationIDs() -> [UInt32] {
|
||||
let mgr = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
|
||||
let matches = productIDs.map { pid in
|
||||
[kIOHIDVendorIDKey: vendorSony, kIOHIDProductIDKey: pid] as CFDictionary
|
||||
}
|
||||
IOHIDManagerSetDeviceMatchingMultiple(mgr, matches as CFArray)
|
||||
guard IOHIDManagerOpen(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) == kIOReturnSuccess else {
|
||||
return []
|
||||
}
|
||||
defer { IOHIDManagerClose(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) }
|
||||
let devices = IOHIDManagerCopyDevices(mgr) as? Set<IOHIDDevice> ?? []
|
||||
return devices.compactMap(locationID(of:)).sorted()
|
||||
}
|
||||
|
||||
/// Which attached device to drive, as an index into `ids` — the whole selection rule, pure so
|
||||
/// it can be tested without an `IOHIDDevice` (which cannot be constructed).
|
||||
///
|
||||
/// `IOHIDManagerCopyDevices` returns an unordered `Set`, so the previous `Set.first` was not
|
||||
/// merely arbitrary — it can differ between two calls in one process. With two DualSenses that
|
||||
/// made each renderer's pad→device binding a coin flip: both could land on the same device
|
||||
/// (one pad's rumble coming out of the other, and the two per-instance write dedupes fighting
|
||||
/// over it) or split by luck. An explicit location id makes the binding deterministic; the
|
||||
/// lowest-id fallback at least makes it stable. `nil` ids sort last so a device IOKit cannot
|
||||
/// place never displaces one it can.
|
||||
static func preferredIndex(among ids: [UInt32?], preferring wanted: UInt32?) -> Int? {
|
||||
if let wanted, let hit = ids.firstIndex(where: { $0 == wanted }) { return hit }
|
||||
return ids.indices.min { (ids[$0] ?? .max) < (ids[$1] ?? .max) }
|
||||
}
|
||||
|
||||
/// Pick the device to drive from everything attached (see [`preferredIndex`]).
|
||||
static func pick(_ devices: Set<IOHIDDevice>, preferring wanted: UInt32?) -> IOHIDDevice? {
|
||||
let ordered = Array(devices)
|
||||
guard let i = preferredIndex(among: ordered.map(locationID(of:)), preferring: wanted) else {
|
||||
return nil
|
||||
}
|
||||
return ordered[i]
|
||||
}
|
||||
|
||||
/// Find and open a connected DualSense, preferring the one at `preferredLocationID`. Returns
|
||||
/// false if none is present or it can't be opened (caller then falls back to CoreHaptics).
|
||||
func open(preferringLocationID preferred: UInt32? = nil) -> Bool {
|
||||
let matches = Self.productIDs.map { pid in
|
||||
[kIOHIDVendorIDKey: Self.vendorSony, kIOHIDProductIDKey: pid] as CFDictionary
|
||||
}
|
||||
@@ -55,13 +107,21 @@ final class DualSenseHID {
|
||||
return false
|
||||
}
|
||||
guard let devices = IOHIDManagerCopyDevices(manager) as? Set<IOHIDDevice>,
|
||||
let dev = devices.first
|
||||
let dev = Self.pick(devices, preferring: preferred)
|
||||
else {
|
||||
log.info("rumble: no DualSense HID device found — falling back to CoreHaptics")
|
||||
IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone))
|
||||
return false
|
||||
}
|
||||
device = dev
|
||||
locationID = Self.locationID(of: dev)
|
||||
if let preferred, locationID != preferred {
|
||||
// Not fatal — one pad still gets rumble — but with two pads attached it means this
|
||||
// renderer is driving the wrong one, and it is invisible without the log line.
|
||||
log.error(
|
||||
"rumble: wanted DualSense at location \(preferred, privacy: .public) but opened \(self.locationID.map(String.init) ?? "unknown", privacy: .public)"
|
||||
)
|
||||
}
|
||||
let transport = IOHIDDeviceGetProperty(dev, kIOHIDTransportKey as CFString) as? String
|
||||
bluetooth = transport?.lowercased().contains("bluetooth") ?? false
|
||||
log.info("rumble: DualSense raw-HID rumble active (transport=\(self.transport, privacy: .public))")
|
||||
@@ -70,8 +130,16 @@ final class DualSenseHID {
|
||||
|
||||
/// Drive the motors. `low` = left/heavy (low-frequency), `high` = right/light (high-frequency),
|
||||
/// each 0...255. (0, 0) stops.
|
||||
func rumble(low: UInt8, high: UInt8) {
|
||||
guard let dev = device else { return }
|
||||
///
|
||||
/// Returns whether the write reached the device. The caller needs this: it used to be logged
|
||||
/// and swallowed, so a failed write still counted as a successful render. That matters most
|
||||
/// for a **stop**, which has nothing behind it — the renderer stamps its write clock even on
|
||||
/// failure, the keepalive re-write only fires for non-zero levels, and the ticker is cancelled
|
||||
/// once the target is `(0, 0)`. On USB there is no firmware timeout either, so a swallowed
|
||||
/// stop left the motors running with nothing scheduled to try again.
|
||||
@discardableResult
|
||||
func rumble(low: UInt8, high: UInt8) -> Bool {
|
||||
guard let dev = device else { return false }
|
||||
let report = bluetooth
|
||||
? Self.bluetoothReport(low: low, high: high)
|
||||
: Self.usbReport(low: low, high: high)
|
||||
@@ -81,7 +149,9 @@ final class DualSenseHID {
|
||||
}
|
||||
if rc != kIOReturnSuccess {
|
||||
log.error("rumble: IOHIDDeviceSetReport failed (0x\(String(format: "%08x", rc), privacy: .public))")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func close() {
|
||||
|
||||
@@ -67,6 +67,17 @@ public final class GamepadCapture {
|
||||
var axes: [Int32] = [0, 0, 0, 0, 0, 0]
|
||||
var fingerActive: [Bool] = [false, false]
|
||||
var lastMotionNs: UInt64 = 0
|
||||
// Hold-Select→guide gesture state (pf-client-core's `SelectGesture`, adapted to
|
||||
// this class's mask-diff model): a Select pressed ALONE is held out of the mask
|
||||
// until it resolves into a tap (delivered on release) or — past `guideHold` — a
|
||||
// synthetic guide, down until release.
|
||||
var selectPending = false
|
||||
var selectAsGuide = false
|
||||
/// A delivered tap's release is owed (`tapTimer` scheduled) — its down went out
|
||||
/// outside `buttons`, so `flush` must know to lift it.
|
||||
var tapReleaseOwed = false
|
||||
var gestureTimer: Timer?
|
||||
var tapTimer: Timer?
|
||||
init(controller: GCController, pad: UInt32, pref: PunktfunkConnection.GamepadType) {
|
||||
self.controller = controller
|
||||
self.pad = pad
|
||||
@@ -87,10 +98,29 @@ public final class GamepadCapture {
|
||||
/// `onDisconnectRequest`; the chord keeps forwarding to the host meanwhile (the user is
|
||||
/// leaving anyway). The desktop clients' quick-press step (leave fullscreen / release
|
||||
/// capture) has no Apple equivalent worth wiring — macOS has ⌃⌥⇧Q/D, touch has the HUD.
|
||||
private static let escapeChord: UInt32 =
|
||||
/// Internal rather than private only so `GamepadEscapeChordTests` can pin it against
|
||||
/// `escapeChordElements` below — the two must not drift.
|
||||
static let escapeChord: UInt32 =
|
||||
GamepadWire.leftShoulder | GamepadWire.rightShoulder | GamepadWire.start | GamepadWire.back
|
||||
/// `escapeChord`'s four elements by GameController alias — the ONLY system gestures claimed
|
||||
/// while forwarding is off (see `openSlot`). Kept beside the mask it mirrors: change one and
|
||||
/// change the other, or the chord silently stops reaching us on tvOS. A test asserts the two
|
||||
/// agree, because the failure is invisible until someone is stuck in a stream on an Apple TV.
|
||||
static let escapeChordElements = [
|
||||
GCInputLeftShoulder, GCInputRightShoulder, GCInputButtonMenu, GCInputButtonOptions,
|
||||
]
|
||||
/// pf-client-core's `DISCONNECT_HOLD` — the same 1.5 s on every client.
|
||||
private static let disconnectHold: TimeInterval = 1.5
|
||||
/// pf-client-core's `GUIDE_HOLD`: hold Select alone this long → the HOST's guide goes
|
||||
/// down (until release, so a long hold is the host's long-press — a Gaming-Mode
|
||||
/// host's QAM). The gesture exists because iOS reserves the physical Home press (the
|
||||
/// Game Overlay; sanctioned opt-out only via the user's iOS 27+ Home-button setting)
|
||||
/// and tvOS never delivers it at all.
|
||||
private static let guideHold: TimeInterval = 0.35
|
||||
/// pf-client-core's `TAP_PRESS`: a held-back Select tap is delivered as a press with
|
||||
/// its release this far behind — back-to-back transitions can fold into nothing in
|
||||
/// the host's per-pad input fold.
|
||||
private static let tapPress: TimeInterval = 0.05
|
||||
private var chordTimer: Timer?
|
||||
/// Fired ON MAIN once the escape chord has been held `disconnectHold` — the session owner
|
||||
/// disconnects. On tvOS this (plus the Siri Remote's hold-Back) is the ONLY way out of a
|
||||
@@ -98,9 +128,40 @@ public final class GamepadCapture {
|
||||
/// gameplay can't end it (see ContentView's tvOS session branch).
|
||||
public var onDisconnectRequest: (() -> Void)?
|
||||
|
||||
public init(connection: PunktfunkConnection, manager: GamepadManager) {
|
||||
/// Forward this device's controllers to the host at all (`Settings.gamepadForwarding`,
|
||||
/// default true). Off is for a couch whose controller reaches the host another way — USB
|
||||
/// passthrough such as VirtualHere, or a pad plugged into the host itself — where
|
||||
/// forwarding as well would give the host two pads for one pair of hands.
|
||||
///
|
||||
/// Off still opens slots and tracks button state; it just sends nothing (see `wire`). That
|
||||
/// is deliberate, not laziness: the escape chord is read off the same slots, and on tvOS it
|
||||
/// is the ONLY controller way out of a stream — a session that silently lost its exit
|
||||
/// because a forwarding preference was off would be a worse bug than the one this fixes.
|
||||
/// Unlike pf-client-core's slots, GameController claims nothing exclusive, so holding one
|
||||
/// open costs the host nothing and blocks no passthrough tool.
|
||||
public let forwarding: Bool
|
||||
|
||||
/// The connection, or nil while forwarding is off — every wire send goes through this, so
|
||||
/// "don't forward" is one fact in one place rather than a condition at twelve call sites.
|
||||
private var wire: PunktfunkConnection? { forwarding ? connection : nil }
|
||||
|
||||
/// Forward the raw guide + share/QAM presses (`EffectiveSettings.systemButtonsForward`,
|
||||
/// default true on Apple — where the OS shows its own overlay for them, that's the OS's
|
||||
/// business; local mode exists for profile parity with the Gaming-Mode clients).
|
||||
public let systemForward: Bool
|
||||
/// The hold-Select guide gesture (`EffectiveSettings.guideGestureEnabled` — auto = on
|
||||
/// everywhere but macOS). See `guideHold`.
|
||||
public let guideGesture: Bool
|
||||
|
||||
public init(
|
||||
connection: PunktfunkConnection, manager: GamepadManager, forwarding: Bool = true,
|
||||
systemForward: Bool = true, guideGesture: Bool = false
|
||||
) {
|
||||
self.connection = connection
|
||||
self.manager = manager
|
||||
self.forwarding = forwarding
|
||||
self.systemForward = systemForward
|
||||
self.guideGesture = guideGesture
|
||||
}
|
||||
|
||||
public func start() {
|
||||
@@ -184,11 +245,28 @@ public final class GamepadCapture {
|
||||
// gesture attached the press is the system's, not the game's. During capture the remote
|
||||
// session IS the game: the share button must reach the host (e.g. Steam screenshots),
|
||||
// the PS button must open the host's Steam overlay. Restored to .enabled on close.
|
||||
for element in c.physicalInputProfile.elements.values {
|
||||
//
|
||||
// With forwarding OFF none of that applies — no press reaches the host, so taking the
|
||||
// user's screenshot gesture away buys nothing. NARROWED, not skipped: the escape chord
|
||||
// is still read off this slot, and on tvOS it is the only controller way out of a
|
||||
// stream, so the chord's own four elements keep their claim. (Menu especially: leave
|
||||
// its gesture attached on tvOS and the press is the system's — the chord would never
|
||||
// complete and the session would have no controller exit at all.)
|
||||
let claimed = forwarding
|
||||
? Array(c.physicalInputProfile.elements.values)
|
||||
: Self.escapeChordElements.compactMap { c.physicalInputProfile.elements[$0] }
|
||||
for element in claimed {
|
||||
element.preferredSystemGestureState = .disabled
|
||||
}
|
||||
// The Home/PS button (→ guide; the host maps it to the DualSense PS / Xbox guide bit,
|
||||
// BTN_MODE on the virtual xpad — the Steam-overlay button). Driven DIRECTLY from this
|
||||
// BTN_MODE on the virtual xpad — the Steam-overlay button). On iOS 26 the OS opens its
|
||||
// Game Overlay for this press regardless of the gesture claim below (the app is
|
||||
// LSApplicationCategoryType=games, which enrolls it); the sanctioned per-controller
|
||||
// opt-out is the USER's iOS 27+ Home-button setting. TODO(iOS 27 SDK): read
|
||||
// `GCControllerHomeButtonSettingsManager` and surface a one-time
|
||||
// `openControllerHomeButtonSettings(for:)` deep-link so users can hand the button to
|
||||
// the stream — the class is Swift-only and 27.0+, so it needs the Xcode 27 SDK to
|
||||
// even compile. Until then hold-Select is the reliable route. Driven DIRECTLY from this
|
||||
// handler's pressed value (not via buttonMask), because the legacy
|
||||
// `extendedGamepad.buttonHome` is unreliable/often nil even when the physical element
|
||||
// exists. On tvOS the element is absent (reserved) → nil, the whole block no-ops.
|
||||
@@ -205,8 +283,8 @@ public final class GamepadCapture {
|
||||
// core re-sends it a few times against datagram loss; an older host ignores it and uses
|
||||
// the session-default kind. Then wake the host pad (pads are created lazily from the first
|
||||
// event; a DualSense's UHID handshake + initial lightbar write only start then).
|
||||
connection.send(.gamepadArrival(pref: slot.pref.rawValue, pad: slot.pad))
|
||||
connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: slot.pad))
|
||||
wire?.send(.gamepadArrival(pref: slot.pref.rawValue, pad: slot.pad))
|
||||
wire?.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: slot.pad))
|
||||
sync(slot, ext)
|
||||
|
||||
if let tp = Self.touchpad(ext) {
|
||||
@@ -217,7 +295,11 @@ public final class GamepadCapture {
|
||||
MainActor.assumeIsolated { if let self, let slot { self.touch(slot, finger: 1, x: x, y: y) } }
|
||||
}
|
||||
}
|
||||
if let motion = c.motion {
|
||||
// Motion is wire-only — `forwardMotion` has nothing to do with forwarding off, and no
|
||||
// local feature reads it. Powering the IMU anyway costs the pad real battery (it streams
|
||||
// gyro + accel continuously over Bluetooth, which is why `closeSlot` is careful to power
|
||||
// it back down), so with nothing to forward we simply never turn it on.
|
||||
if forwarding, let motion = c.motion {
|
||||
if motion.sensorsRequireManualActivation { motion.sensorsActive = true }
|
||||
motion.valueChangedHandler = { [weak self, weak slot] m in
|
||||
MainActor.assumeIsolated { if let self, let slot { self.forwardMotion(slot, m) } }
|
||||
@@ -233,7 +315,7 @@ public final class GamepadCapture {
|
||||
flush(slot)
|
||||
// Sent after the flush so the core stamps it with a seq past the zeroing snapshots; the host
|
||||
// seq-gates it, so a reordered snapshot can't resurrect the removed pad.
|
||||
connection.send(.gamepadRemove(pad: slot.pad))
|
||||
wire?.send(.gamepadRemove(pad: slot.pad))
|
||||
let c = slot.controller
|
||||
if let ext = c.extendedGamepad {
|
||||
ext.valueChangedHandler = nil
|
||||
@@ -271,11 +353,18 @@ public final class GamepadCapture {
|
||||
// as "changed" — otherwise the first stick/button move after a guide press would emit a
|
||||
// spurious guide-UP while the button is still physically held (and drop the bit from
|
||||
// `slot.buttons`, swallowing the real release too). `flush`/`allButtons` still release it.
|
||||
let newButtons = Self.buttonMask(g) | (slot.buttons & GamepadWire.guide)
|
||||
var raw = Self.buttonMask(g)
|
||||
// Raw system buttons stay local when passthrough is off: misc1 (share/QAM) is
|
||||
// masked here, guide is gated at its own handler.
|
||||
if !systemForward { raw &= ~GamepadWire.misc1 }
|
||||
// The hold-Select gesture rewrites the mask: a Select pressed alone is held out
|
||||
// until it resolves (tap on release / synthetic guide past the threshold).
|
||||
if guideGesture { raw = gestureFiltered(slot, raw) }
|
||||
let newButtons = raw | (slot.buttons & GamepadWire.guide)
|
||||
let changed = newButtons ^ slot.buttons
|
||||
if changed != 0 {
|
||||
for bit in GamepadWire.allButtons where changed & bit != 0 {
|
||||
connection.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: slot.pad))
|
||||
wire?.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: slot.pad))
|
||||
}
|
||||
slot.buttons = newButtons
|
||||
}
|
||||
@@ -288,21 +377,117 @@ public final class GamepadCapture {
|
||||
Int32(g.rightTrigger.value * 255),
|
||||
]
|
||||
for (i, v) in newAxes.enumerated() where v != slot.axes[i] {
|
||||
connection.send(.gamepadAxis(UInt32(i), value: v, pad: slot.pad))
|
||||
wire?.send(.gamepadAxis(UInt32(i), value: v, pad: slot.pad))
|
||||
slot.axes[i] = v
|
||||
}
|
||||
updateEscapeChord()
|
||||
}
|
||||
|
||||
/// The hold-Select→guide state machine over one sync's raw mask (pf-client-core's
|
||||
/// `SelectGesture` rules): Select pressed ALONE is suppressed while pending; another
|
||||
/// button joining makes it real (unsuppressed — the diff sends its down); released
|
||||
/// inside `guideHold` it's a tap, delivered out-of-band on release with the release
|
||||
/// `tapPress` behind; past the threshold `gestureHoldFired` turned it into a synthetic
|
||||
/// guide, lifted here when Select physically releases.
|
||||
///
|
||||
/// One deliberate divergence from the Rust worker: while transformed into a guide the
|
||||
/// Select stays OUT of `slot.buttons`, so the escape chord doesn't complete on top of
|
||||
/// an in-flight guide-hold — release Select and press the chord plainly instead (the
|
||||
/// chord's four-at-once press never lingers in pending long enough to be affected).
|
||||
private func gestureFiltered(_ slot: Slot, _ raw: UInt32) -> UInt32 {
|
||||
let back = GamepadWire.back
|
||||
let backDown = raw & back != 0
|
||||
let othersDown = raw & ~back != 0
|
||||
if slot.selectAsGuide {
|
||||
if backDown { return raw & ~back }
|
||||
slot.selectAsGuide = false
|
||||
sendGuide(slot, down: false, raw: false)
|
||||
return raw
|
||||
}
|
||||
if slot.selectPending {
|
||||
if !backDown {
|
||||
endPending(slot)
|
||||
deliverTap(slot)
|
||||
return raw
|
||||
}
|
||||
if othersDown {
|
||||
// A combo after all — Select unsuppresses and the diff sends its down.
|
||||
endPending(slot)
|
||||
return raw
|
||||
}
|
||||
return raw & ~back
|
||||
}
|
||||
if backDown, !othersDown, slot.buttons & back == 0 {
|
||||
// Newly pressed, alone: hold it back. An owed tap release goes out first so
|
||||
// the host never sees two downs in a row.
|
||||
if slot.tapReleaseOwed { finishTap(slot) }
|
||||
slot.selectPending = true
|
||||
let timer = Timer(timeInterval: Self.guideHold, repeats: false) { [weak self, weak slot] _ in
|
||||
Task { @MainActor in
|
||||
if let self, let slot { self.gestureHoldFired(slot) }
|
||||
}
|
||||
}
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
slot.gestureTimer?.invalidate()
|
||||
slot.gestureTimer = timer
|
||||
return raw & ~back
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
/// The hold threshold passed with Select still pending → it IS the guide now, down
|
||||
/// until the physical release (`gestureFiltered`'s `selectAsGuide` branch lifts it).
|
||||
private func gestureHoldFired(_ slot: Slot) {
|
||||
guard slot.selectPending else { return }
|
||||
slot.selectPending = false
|
||||
slot.gestureTimer = nil
|
||||
slot.selectAsGuide = true
|
||||
sendGuide(slot, down: true, raw: false)
|
||||
}
|
||||
|
||||
private func endPending(_ slot: Slot) {
|
||||
slot.selectPending = false
|
||||
slot.gestureTimer?.invalidate()
|
||||
slot.gestureTimer = nil
|
||||
}
|
||||
|
||||
/// Deliver a held-back Select tap: the press now, its release `tapPress` behind. Both
|
||||
/// sends bypass `slot.buttons` (the raw mask no longer carries Select, so the diff
|
||||
/// stays consistent); `tapReleaseOwed` is what `flush` checks so the press can't
|
||||
/// outlive the slot.
|
||||
private func deliverTap(_ slot: Slot) {
|
||||
wire?.send(.gamepadButton(GamepadWire.back, down: true, pad: slot.pad))
|
||||
slot.tapReleaseOwed = true
|
||||
let timer = Timer(timeInterval: Self.tapPress, repeats: false) { [weak self, weak slot] _ in
|
||||
Task { @MainActor in
|
||||
if let self, let slot { self.finishTap(slot) }
|
||||
}
|
||||
}
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
slot.tapTimer?.invalidate()
|
||||
slot.tapTimer = timer
|
||||
}
|
||||
|
||||
private func finishTap(_ slot: Slot) {
|
||||
guard slot.tapReleaseOwed else { return }
|
||||
slot.tapReleaseOwed = false
|
||||
slot.tapTimer?.invalidate()
|
||||
slot.tapTimer = nil
|
||||
wire?.send(.gamepadButton(GamepadWire.back, down: false, pad: slot.pad))
|
||||
}
|
||||
|
||||
/// Forward the guide (Home/PS) transition directly — it's kept out of `buttonMask` (the legacy
|
||||
/// `buttonHome` element is unreliable). Folds into the slot's `buttons` so a held PS button is
|
||||
/// released by `flush` on focus loss / close just like the others.
|
||||
private func sendGuide(_ slot: Slot, down: Bool) {
|
||||
/// released by `flush` on focus loss / close just like the others. `raw: true` marks the
|
||||
/// physical Home handler's calls, which the system-buttons policy can keep local; the
|
||||
/// gesture's synthetic transitions pass `raw: false` and always go out.
|
||||
private func sendGuide(_ slot: Slot, down: Bool, raw: Bool = true) {
|
||||
if raw, !systemForward { return }
|
||||
guard !suspended else { return }
|
||||
let bit = GamepadWire.guide
|
||||
let now = down ? (slot.buttons | bit) : (slot.buttons & ~bit)
|
||||
guard now != slot.buttons else { return }
|
||||
connection.send(.gamepadButton(bit, down: down, pad: slot.pad))
|
||||
wire?.send(.gamepadButton(bit, down: down, pad: slot.pad))
|
||||
slot.buttons = now
|
||||
}
|
||||
|
||||
@@ -365,13 +550,13 @@ public final class GamepadCapture {
|
||||
if lifted {
|
||||
if slot.fingerActive[finger] {
|
||||
slot.fingerActive[finger] = false
|
||||
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: false, x: 0, y: 0)
|
||||
wire?.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: false, x: 0, y: 0)
|
||||
}
|
||||
return
|
||||
}
|
||||
slot.fingerActive[finger] = true
|
||||
let w = GamepadWire.touchpad(x: x, y: y)
|
||||
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: true, x: w.x, y: w.y)
|
||||
wire?.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: true, x: w.x, y: w.y)
|
||||
}
|
||||
|
||||
private func forwardMotion(_ slot: Slot, _ m: GCMotion) {
|
||||
@@ -394,7 +579,7 @@ public final class GamepadCapture {
|
||||
}
|
||||
let gs = GamepadWire.gyroLSBPerRadS
|
||||
let as_ = GamepadWire.accelLSBPerG
|
||||
connection.sendMotion(
|
||||
wire?.sendMotion(
|
||||
pad: UInt8(slot.pad),
|
||||
gyro: (
|
||||
GamepadWire.motionRaw(Float(m.rotationRate.x), scale: gs),
|
||||
@@ -431,16 +616,22 @@ public final class GamepadCapture {
|
||||
/// (no GC calls) — safe against an already-removed device. Does NOT close the slot or send
|
||||
/// GamepadRemove (that's `closeSlot`).
|
||||
private func flush(_ slot: Slot) {
|
||||
// Gesture first: a pending (never-sent) Select just drops, an owed tap release
|
||||
// goes out, and a transformed guide's bit — folded into `buttons` by `sendGuide`
|
||||
// — is lifted by the loop below like any held button.
|
||||
endPending(slot)
|
||||
slot.selectAsGuide = false
|
||||
if slot.tapReleaseOwed { finishTap(slot) }
|
||||
for bit in GamepadWire.allButtons where slot.buttons & bit != 0 {
|
||||
connection.send(.gamepadButton(bit, down: false, pad: slot.pad))
|
||||
wire?.send(.gamepadButton(bit, down: false, pad: slot.pad))
|
||||
}
|
||||
slot.buttons = 0
|
||||
for (i, v) in slot.axes.enumerated() where v != 0 {
|
||||
connection.send(.gamepadAxis(UInt32(i), value: 0, pad: slot.pad))
|
||||
wire?.send(.gamepadAxis(UInt32(i), value: 0, pad: slot.pad))
|
||||
slot.axes[i] = 0
|
||||
}
|
||||
for (f, active) in slot.fingerActive.enumerated() where active {
|
||||
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(f), active: false, x: 0, y: 0)
|
||||
wire?.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(f), active: false, x: 0, y: 0)
|
||||
slot.fingerActive[f] = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ public final class GamepadFeedback {
|
||||
#if os(iOS)
|
||||
if UserDefaults.standard.bool(forKey: DefaultsKey.rumbleOnDevice),
|
||||
CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
||||
deviceRumble = RumbleRenderer(policy: .session, actuator: .device)
|
||||
deviceRumble = RumbleRenderer(actuator: .device)
|
||||
} else {
|
||||
deviceRumble = nil
|
||||
}
|
||||
@@ -117,7 +117,15 @@ public final class GamepadFeedback {
|
||||
reset(slot.controller)
|
||||
slots[pad] = nil
|
||||
let renderer = withRouting { rumbleByPad.removeValue(forKey: pad) }
|
||||
renderer?.stop()
|
||||
// OFF the main actor. `RumbleRenderer.stop()` is a `queue.sync`, and its body is a
|
||||
// per-motor `CHHapticEngine.stop()` — an XPC round trip to gamecontrollerd, which the
|
||||
// renderer's own notes record as able to hang — plus `DualSenseHID.close()`, whose
|
||||
// blocking `IOHIDDeviceSetReport` goes to a device that has just departed. It also
|
||||
// queues behind any in-flight `setup()`. This runs on every unplug and every pin
|
||||
// change, and the main thread is what drives the presenter's CADisplayLink, so
|
||||
// blocking here hitches the picture mid-stream. The renderer is already detached from
|
||||
// routing above, so nothing observes it after this point.
|
||||
if let renderer { Task.detached { renderer.stop() } }
|
||||
}
|
||||
for (pad, controller) in want {
|
||||
if let slot = slots[pad] {
|
||||
@@ -128,7 +136,7 @@ public final class GamepadFeedback {
|
||||
replay(slot)
|
||||
} else {
|
||||
slots[pad] = Slot(controller: controller)
|
||||
let renderer = RumbleRenderer(policy: .session)
|
||||
let renderer = RumbleRenderer()
|
||||
renderer.retarget(controller)
|
||||
withRouting { rumbleByPad[pad] = renderer }
|
||||
}
|
||||
@@ -282,6 +290,12 @@ public final class GamepadFeedback {
|
||||
private func reset(_ controller: GCController?) {
|
||||
guard let c = controller else { return }
|
||||
c.playerIndex = .indexUnset
|
||||
// Put the lightbar out too. This class is what turned it on (see the `Led` and
|
||||
// `PlayerLeds` arms), and every DS write is valid-flag-selective, so a colour the game
|
||||
// set stays lit in firmware after the stream ends — back at the launcher, or for a pad
|
||||
// that merely left the forwarded set. A DS4 is cleared incidentally because its player
|
||||
// indicator IS the lightbar; a DualSense is not.
|
||||
c.light?.color = GCColor(red: 0, green: 0, blue: 0)
|
||||
if let ds = c.extendedGamepad as? GCDualSenseGamepad {
|
||||
ds.leftTrigger.setModeOff()
|
||||
ds.rightTrigger.setModeOff()
|
||||
|
||||
@@ -43,8 +43,14 @@ enum RumbleTuning {
|
||||
|
||||
/// Wire amplitude (0...0xFFFF) → CoreHaptics intensity (0...1).
|
||||
static func amplitude(_ wire: UInt16) -> Float { Float(wire) / 65535 }
|
||||
/// Wire amplitude → DualSense HID motor byte.
|
||||
static func hidByte(_ wire: UInt16) -> UInt8 { UInt8(wire >> 8) }
|
||||
/// Wire amplitude → DualSense HID motor byte. A nonzero command never collapses to silence:
|
||||
/// the top byte of anything below 0x0100 is 0, so a weak-but-real rumble used to render as
|
||||
/// nothing at all on this path. Floored at 1 — imperceptibly light, but moving. (Android's
|
||||
/// `toAmplitude` has always done this; this was the odd one out.)
|
||||
static func hidByte(_ wire: UInt16) -> UInt8 {
|
||||
let b = UInt8(wire >> 8)
|
||||
return wire != 0 && b == 0 ? 1 : b
|
||||
}
|
||||
/// Single-actuator pads render whichever motor is stronger.
|
||||
static func combined(low: UInt16, high: UInt16) -> UInt16 { max(low, high) }
|
||||
/// Are two baked levels the same (skip the rebuild)?
|
||||
@@ -81,10 +87,11 @@ enum RumbleTuning {
|
||||
/// 4. **Escalating stop.** A throwing `player.stop` means the engine's state is unknown — the
|
||||
/// whole engine is stopped (silencing every player it hosts) and lazily rebuilt behind the
|
||||
/// exponential backoff.
|
||||
/// 5. **Staleness watchdog** (`Policy.session`): audible with no wire command for
|
||||
/// `sessionStaleSeconds` → force silence. A lost stop can outlive the host's 500 ms heal
|
||||
/// only if the channel itself died, and then the pad must not buzz forever. `Policy.manual`
|
||||
/// (the settings test panel) instead holds a level until it is changed.
|
||||
/// 5. **No staleness watchdog here.** There was one, keyed off a `Policy` type and a
|
||||
/// `sessionStaleSeconds`; both are gone. Every liveness decision — lease expiry, legacy-host
|
||||
/// staleness, session close — now belongs to punktfunk-core's shared policy engine
|
||||
/// (`client/rumble.rs`), which emits explicit zero commands, so this renderer applies what it
|
||||
/// is told and never decides on its own when a level should end.
|
||||
///
|
||||
/// Engines are created lazily on the first nonzero amplitude and torn down on retarget;
|
||||
/// failures (pads without haptics, engine resets) downgrade to silence — rumble is best-effort
|
||||
@@ -93,17 +100,6 @@ enum RumbleTuning {
|
||||
/// `@unchecked Sendable` is sound because every property is read and written only inside
|
||||
/// `queue` closures — the serial queue is the synchronization.
|
||||
final class RumbleRenderer: @unchecked Sendable {
|
||||
/// Who ends an un-refreshed nonzero target. Session mode applies the core policy engine's
|
||||
/// commands verbatim — the engine (punktfunk-core `client/rumble.rs`) owns every lease,
|
||||
/// staleness, and close decision and emits explicit zeros, so the renderer keeps NO
|
||||
/// staleness policy of its own anymore. The controller test panel (`manual`) holds a slider
|
||||
/// level indefinitely; both are identical renderer-side today, the distinction is kept for
|
||||
/// the call sites' intent.
|
||||
struct Policy {
|
||||
static let session = Policy()
|
||||
static let manual = Policy()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -115,7 +111,6 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -190,8 +185,7 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
((0, 0), DispatchTime(uptimeNanoseconds: 0))
|
||||
#endif
|
||||
|
||||
init(policy: Policy = .session, actuator: Actuator = .controller) {
|
||||
self.policy = policy
|
||||
init(actuator: Actuator = .controller) {
|
||||
self.actuator = actuator
|
||||
}
|
||||
|
||||
@@ -459,6 +453,18 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
if split {
|
||||
low = makeMotor(haptics, .leftHandle, sharpness: RumbleTuning.sharpnessLow)
|
||||
high = makeMotor(haptics, .rightHandle, sharpness: RumbleTuning.sharpnessHigh)
|
||||
// HALF a split is worse than none, and it used to pass silently: only the all-nil case
|
||||
// below counts as failure, so one surviving handle left `ok` true and `reportHealth(nil)`
|
||||
// announced HEALTHY. What actually rendered was wrong in a direction that depends on
|
||||
// which handle died — lose `high` and `render` falls to the combined branch (selected
|
||||
// purely by `high != nil`), playing max(low, high) on the LEFT handle at the combined
|
||||
// sharpness; lose `low` and the split branch's reconcile no-ops on the nil slot, so the
|
||||
// heavy motor is discarded outright. Tear the survivor down and take the combined path,
|
||||
// which at least renders both motors somewhere.
|
||||
if low == nil || high == nil {
|
||||
log.warning("rumble: only one split-handle engine came up — falling back to combined")
|
||||
teardown() // disarms handlers, stops the survivor's players + engine, nils both
|
||||
}
|
||||
} else {
|
||||
low = makeMotor(haptics, .default, sharpness: RumbleTuning.sharpnessCombined)
|
||||
}
|
||||
@@ -587,7 +593,9 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
#if os(macOS)
|
||||
guard let c, c.extendedGamepad is GCDualSenseGamepad else { return false }
|
||||
let hid = DualSenseHID()
|
||||
guard hid.open() else { return false }
|
||||
// Ask for the device this renderer's controller actually is, so two attached DualSenses
|
||||
// do not both get driven through whichever one an unordered Set happened to yield first.
|
||||
guard hid.open(preferringLocationID: Self.hidLocationID(for: c)) else { return false }
|
||||
dualSenseHID = hid
|
||||
return true
|
||||
#else
|
||||
@@ -595,6 +603,24 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// Correlate a `GCController` with an IOKit location id.
|
||||
///
|
||||
/// GameController exposes no location id, so there is no direct mapping. What it does expose is
|
||||
/// a stable per-controller ordering, and IOKit's location ids are stable per port: pairing the
|
||||
/// two by rank makes each renderer pick a *distinct* device, which is the property that was
|
||||
/// missing. With one pad attached this is the same device it always was.
|
||||
static func hidLocationID(for c: GCController) -> UInt32? {
|
||||
let ids = DualSenseHID.attachedLocationIDs()
|
||||
guard ids.count > 1 else { return ids.first }
|
||||
let peers = GCController.controllers().filter { $0.extendedGamepad is GCDualSenseGamepad }
|
||||
guard let rank = peers.firstIndex(where: { $0 === c }), rank < ids.count else {
|
||||
return ids.first
|
||||
}
|
||||
return ids[rank]
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Write the target to the DualSense over HID if that's the active backend; false → not a
|
||||
/// HID pad, so the caller renders via CoreHaptics. Deduped on the pad's 0...255 resolution,
|
||||
/// with a periodic keepalive re-write while nonzero (the ticker calls back in here).
|
||||
@@ -605,8 +631,20 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
let keepalive = levels != (0, 0)
|
||||
&& seconds(since: lastHidWrite.at) > RumbleTuning.hidKeepaliveSeconds
|
||||
if levels != lastHidWrite.levels || keepalive {
|
||||
hid.rumble(low: levels.0, high: levels.1)
|
||||
lastHidWrite = (levels, .now())
|
||||
if hid.rumble(low: levels.0, high: levels.1) {
|
||||
lastHidWrite = (levels, .now())
|
||||
} else {
|
||||
// The write did not reach the device. Do NOT stamp the clock — that would claim a
|
||||
// render that never happened, and for a stop there is nothing behind it: the
|
||||
// keepalive only re-writes non-zero levels and the ticker is cancelled once the
|
||||
// target is (0, 0), so the motors would keep running with nothing scheduled.
|
||||
// Drop the handle instead: the pad reverts to CoreHaptics, and a reconnect
|
||||
// rebuilds it. Health is reported so the state is visible rather than silent.
|
||||
log.error("rumble: HID write failed — dropping the handle, falling back")
|
||||
closeHID()
|
||||
reportHealth("Lost the direct connection to this DualSense; using the system path.")
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
#else
|
||||
|
||||
@@ -128,11 +128,19 @@ public final class InputCapture {
|
||||
/// carries the same key equivalents for discoverability) can't see them, so the monitor is the
|
||||
/// captured-state delivery path; released, the events pass through and the menu handles them.
|
||||
/// ⌃⌥⇧Q releases the captured mouse/keyboard; ⌃⌥⇧D disconnects; ⌃⌥⇧S cycles the stats
|
||||
/// overlay tier (off → compact → normal → detailed). Main queue.
|
||||
/// overlay tier (off → compact → normal → detailed). ⌃⌥⇧A (`onToggleMicMute`, below) rides
|
||||
/// the same path. Main queue.
|
||||
public var onReleaseCapture: (() -> Void)?
|
||||
public var onDisconnect: (() -> Void)?
|
||||
public var onCycleStats: (() -> Void)?
|
||||
|
||||
/// Fired on ⌃⌥⇧A — mute/unmute the microphone uplink, the one in-stream control a captured
|
||||
/// session can't otherwise reach (the HUD's button is behind a grabbed cursor). Same delivery
|
||||
/// rule as the combos above: only WHILE FORWARDING, because that's when the menu's identical
|
||||
/// key equivalent can't fire. ⌃⌥⇧M — the obvious letter — is long since the mouse-model flip
|
||||
/// (cross-client), so A ("audio in") is the mic's. Main queue.
|
||||
public var onToggleMicMute: (() -> Void)?
|
||||
|
||||
/// Fired on ⌃⌘F (macOS) — toggle the streaming window in/out of fullscreen. Detected in the
|
||||
/// monitor only WHILE FORWARDING, for the same reason as the ⌃⌥⇧ combos: a captured stream view
|
||||
/// swallows keys, so the Stream menu's identical ⌃⌘F equivalent never reaches it; released, the
|
||||
@@ -140,13 +148,13 @@ public final class InputCapture {
|
||||
public var onToggleFullscreen: (() -> Void)?
|
||||
|
||||
#if os(iOS)
|
||||
/// Windows VKs of the three modifier classes in the ⌃⌥⇧Q release chord, both L/R sides:
|
||||
/// Windows VKs of the three modifier classes in the ⌃⌥⇧ chords, both L/R sides:
|
||||
/// control (0xA2/0xA3), option (0xA4/0xA5), shift (0xA0/0xA1). Used to sift the HID key stream.
|
||||
private static let chordModifierVKs: Set<UInt32> = [0xA2, 0xA3, 0xA4, 0xA5, 0xA0, 0xA1]
|
||||
|
||||
/// Whether Control AND Option AND Shift are all currently held (either side of each counts) —
|
||||
/// the modifier precondition for the iPad ⌃⌥⇧Q release chord.
|
||||
private var hasReleaseChordModifiers: Bool {
|
||||
/// the modifier precondition for the iPad ⌃⌥⇧ chords (Q releases capture, A mutes the mic).
|
||||
private var hasChordModifiers: Bool {
|
||||
let m = chordModifiersDown
|
||||
return (m.contains(0xA2) || m.contains(0xA3)) // control
|
||||
&& (m.contains(0xA4) || m.contains(0xA5)) // option
|
||||
@@ -284,6 +292,10 @@ public final class InputCapture {
|
||||
self.suppressedVK = 0x53
|
||||
self.onCycleStats?()
|
||||
return nil
|
||||
case 0 /* A */:
|
||||
self.suppressedVK = 0x41
|
||||
self.onToggleMicMute?()
|
||||
return nil
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -704,7 +716,7 @@ public final class InputCapture {
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
// Track Control/Option/Shift for the ⌃⌥⇧Q release chord below — in both forwarding
|
||||
// Track Control/Option/Shift for the ⌃⌥⇧ chords below — in both forwarding
|
||||
// states (like `cmdKeysDown`) so a modifier held before capture engaged still counts.
|
||||
if Self.chordModifierVKs.contains(vk) {
|
||||
if pressed { self.chordModifiersDown.insert(vk) } else { self.chordModifiersDown.remove(vk) }
|
||||
@@ -732,11 +744,19 @@ public final class InputCapture {
|
||||
// otherwise). The Q is latched (`suppressedVK`) so its keyUp can't type into the host;
|
||||
// the ⌃⌥⇧ modifiers were forwarded as they went down and are flushed by the release
|
||||
// path (setCaptured(false) → releaseAll). VK 0x51 is layout-independent (physical Q).
|
||||
if pressed, vk == 0x51, self.hasReleaseChordModifiers {
|
||||
if pressed, vk == 0x51, self.hasChordModifiers {
|
||||
self.suppressedVK = 0x51
|
||||
self.onReleaseCapture?()
|
||||
return
|
||||
}
|
||||
// ⌃⌥⇧A mutes/unmutes the mic uplink — same detection, same latching, and needed here
|
||||
// for the same reason as on macOS: a captured iPad swallows the Stream menu's
|
||||
// identical key equivalent. VK 0x41 is layout-independent (physical A).
|
||||
if pressed, vk == 0x41, self.hasChordModifiers {
|
||||
self.suppressedVK = 0x41
|
||||
self.onToggleMicMute?()
|
||||
return
|
||||
}
|
||||
#endif
|
||||
// Release direction of the toggle: GC's Esc-down can beat the NSEvent
|
||||
// monitor — never type Esc into the host while ⌘ is held (⌘⎋ is reserved).
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user