Compare commits
270 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aaed4380e5 | |||
| 3d6c06bb06 | |||
| f5e5297a2a | |||
| 8fa12167af | |||
| 21be4fc620 | |||
| e707a962b6 | |||
| ef39050dbc | |||
| b7cb75a48a | |||
| 08694b4026 | |||
| 38b9f310e2 | |||
| 7b25868a19 | |||
| 47d22b6082 | |||
| c86da1a1ff | |||
| d0faebf582 | |||
| 968a9c66c1 | |||
| 8743f63406 | |||
| f470858110 | |||
| 03a2fabff1 | |||
| 3952672873 | |||
| a1285dfa13 | |||
| c19c87c435 | |||
| 5d9f1ccb6d | |||
| c8fdef8bd1 | |||
| 4c99b78366 | |||
| f4c3a5d0c3 | |||
| 4839c0e6f6 | |||
| d0f68cbbcd | |||
| 13d1aa5738 | |||
| f508d3213f | |||
| a69a83b545 | |||
| d0fa8bd3ee | |||
| 8e6e8bb25c | |||
| 180ac3aa61 | |||
| c3fa6c1514 | |||
| bf9be59f0b | |||
| a3332aedae | |||
| 069fcdfca6 | |||
| f58d91ba19 | |||
| f4850625bd | |||
| f5f186b691 | |||
| 573b2af334 | |||
| 01428ced58 | |||
| 6198da3daf | |||
| c511462536 | |||
| d6647b9183 | |||
| 2003d8a75c | |||
| 838a1239cf | |||
| 0290bf7285 | |||
| e28160ebb3 | |||
| 0b7e4a00ee | |||
| ab6790ef6c | |||
| 52856d4b6b | |||
| 6bbd2a9ed6 | |||
| ade4266336 | |||
| dc834ea478 | |||
| 4516bd48e1 | |||
| 4c9c7e606e | |||
| b12c7d3deb | |||
| 8a4b0c7bae | |||
| d490397908 | |||
| a41f679516 | |||
| e6d9454251 | |||
| 9ca672d434 | |||
| b8fd652cb8 | |||
| 5042ffd935 | |||
| 8bef4e27f9 | |||
| b664ae373d | |||
| f97b5a3783 | |||
| 0052a6ae30 | |||
| 062a54e3a5 | |||
| 966758e757 | |||
| 07afa8ee26 | |||
| b2451e6aea | |||
| 8926d82a80 | |||
| 4543a3f529 | |||
| c299a41a67 | |||
| 349d16382e | |||
| a5bb5ec4d5 | |||
| 6cb97959a2 | |||
| c78ddc40cb | |||
| 58ccd530fc | |||
| 6dd2213a20 | |||
| cbcd7a5c40 | |||
| be09f9f345 | |||
| 021a2261f6 | |||
| 433a23da7a | |||
| a3d3d4738c | |||
| a02d0a2e9f | |||
| 89d45f2a55 | |||
| 0ab97b597c | |||
| bf07700c74 | |||
| 655ec31ef9 | |||
| 04302075b5 | |||
| a1b890ef42 | |||
| bdcbb2d3a7 | |||
| 1dc8dc7f0d | |||
| 7effdb4030 | |||
| f30a65d507 | |||
| 774988edd4 | |||
| 324da666e5 | |||
| efed8d5a20 | |||
| 636b9c1d1f | |||
| 92f078adaf | |||
| 17685ff73b | |||
| 08ab2b6bee | |||
| b271d0c816 | |||
| 83b7c7adf5 | |||
| eea23c5647 | |||
| 912d7de2e6 | |||
| e788d0de84 | |||
| 53c8eefa99 | |||
| da376b3122 | |||
| 35e16303e6 | |||
| b3c7ba5082 | |||
| cb7c54a14d | |||
| 4b88aa63c2 | |||
| 7b4132f74b | |||
| 959dd74dc8 | |||
| 3ed0ceef8f | |||
| 7f29796e81 | |||
| c966246e0c | |||
| 6f47abab8c | |||
| 19388f3412 | |||
| eafe76d2d5 | |||
| 91fadce900 | |||
| eaaf176adf | |||
| f47d417f37 | |||
| 76bc7fee97 | |||
| 167d590349 | |||
| 622c8bf701 | |||
| 6c1e6adbf2 | |||
| 9814368c8c | |||
| 7257bcb6a6 | |||
| 79c4f4cbc1 | |||
| 1d17524151 | |||
| 1992eb1c52 | |||
| 70e9570040 | |||
| 8c541ecf10 | |||
| f87a43f42d | |||
| d1c90ca24f | |||
| 5dc24a069f | |||
| 76791e53e9 | |||
| b28ddfc2d4 | |||
| 51595a7117 | |||
| 216619176c | |||
| 07c8b868e6 | |||
| 51c8dda585 | |||
| b19382af17 | |||
| 0001940cee | |||
| 7fb75e6922 | |||
| 60121f1ea9 | |||
| 3775a39e70 | |||
| 99a6baa960 | |||
| 6bebe238fc | |||
| a2df43e766 | |||
| 7122e2be5c | |||
| ac030bbda0 | |||
| 77871d6e23 | |||
| cef2f6780c | |||
| e8531a9eac | |||
| 956420d947 | |||
| 42ea12ad8f | |||
| 5b71a74da5 | |||
| 167b3ae251 | |||
| 458790e8f4 | |||
| 5c6ae43ed2 | |||
| 0c2808bb4c | |||
| 4c8b960048 | |||
| e77d7a6834 | |||
| 60b30fbc24 | |||
| 43e035de41 | |||
| a613f16b7d | |||
| aa2041c910 | |||
| 6ddce1049b | |||
| bc7cb04338 | |||
| 8fa475788f | |||
| c0f3ae59ab | |||
| 3acb039e29 | |||
| 091e91b36a | |||
| bfc7f91e19 | |||
| 85e8400028 | |||
| b0bc15af8e | |||
| a0281289c0 | |||
| 9d32cb58ee | |||
| ed0881a9b9 | |||
| f818f1ec3d | |||
| 8a06c7545b | |||
| f79d5c5b49 | |||
| bc07c75273 | |||
| b988053a31 | |||
| 21f3c1326f | |||
| 2a9b9d83c6 | |||
| 3c8a4b585f | |||
| 2e1e3d44b3 | |||
| dd4e2d4fa8 | |||
| a4ebacafd4 | |||
| 3993561c4d | |||
| 106eba34d6 | |||
| b622e53dc1 | |||
| f299aaeb3d | |||
| 081182f6a7 | |||
| ab2c2ae10c | |||
| c7f8ed2750 | |||
| e8db589289 | |||
| 7c230b97f8 | |||
| 780e1cf4cf | |||
| b37c48b32d | |||
| 3545febbeb | |||
| fb709d68ce | |||
| 3bf14a3df7 | |||
| bcf972e341 | |||
| 17a157761d | |||
| 24d53b7584 | |||
| db63647337 | |||
| f2cf65ef33 | |||
| 959ec30003 | |||
| c9ed49b4c2 | |||
| b1bc83f598 | |||
| d1af6304c7 | |||
| 2f7dae2404 | |||
| be83461cd2 | |||
| da3512d9c7 | |||
| 7eea9836b8 | |||
| c6367e74a1 | |||
| bea08b0416 | |||
| 5610f4f210 | |||
| e459109818 | |||
| e56470cf56 | |||
| 477adeaad8 | |||
| 547bc250b6 | |||
| 03a488f75d | |||
| d6dbce1dcc | |||
| 3122506836 | |||
| 99cb8dd888 | |||
| 83460cd2c0 | |||
| ea07ba09be | |||
| f87869db02 | |||
| d4aba755b6 | |||
| 7455404abb | |||
| ecbd36af1c | |||
| c33bdeab15 | |||
| eaacdfccc2 | |||
| c76a425ee1 | |||
| b0d169f7e6 | |||
| 495646b676 | |||
| 4f8b1b1832 | |||
| 341b31f7c1 | |||
| ea8e996989 | |||
| 5ee474c9e1 | |||
| f5713b90dc | |||
| aa6f2380b9 | |||
| 550b02c8ff | |||
| 23b7871648 | |||
| 8e8b536dae | |||
| be6c71aed6 | |||
| 796d381926 | |||
| 09afe48b17 | |||
| 82271d61dc | |||
| 1d3017fc51 | |||
| b5fe2a783d | |||
| 844372b203 | |||
| bc5f455be3 | |||
| 6dd0a0edda | |||
| 160a0ad015 | |||
| b292ce2c1f | |||
| ebada804b6 | |||
| 4482c97bd3 | |||
| 8b47be668f | |||
| 16a7991cea | |||
| 65d49f6430 |
@@ -0,0 +1,9 @@
|
||||
# Shown on the "new issue" chooser so security reports go to the private channel, not a public issue.
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: 🔒 Report a security vulnerability
|
||||
url: https://git.unom.io/unom/punktfunk/src/branch/main/SECURITY.md
|
||||
about: >-
|
||||
Found a security issue? Please report it privately by email to security@punktfunk.com — do not
|
||||
open a public issue, so other users aren't exposed before a fix ships. See SECURITY.md for the
|
||||
full policy.
|
||||
@@ -25,7 +25,8 @@ jobs:
|
||||
java-version: "21"
|
||||
|
||||
- name: Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
# SHA-pinned for parity with android.yml (third-party action). v3 = 9fc6c4e.
|
||||
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
|
||||
|
||||
# No NDK/CMake — the screenshot unit tests are pure JVM. compileSdk 37 auto-downloads via AGP
|
||||
# if the platform channel lacks it (same note as android.yml).
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Android client CI (Gitea Actions). Builds the Rust JNI core (clients/android/native) via
|
||||
# cargo-ndk for both shipping ABIs and assembles the debug APK (clients/android). Mirrors apple.yml
|
||||
# cargo-ndk for all three shipping ABIs and assembles the debug APK (clients/android). Mirrors apple.yml
|
||||
# but on a Linux runner — the NDK is cross-platform, so no self-hosted host is needed.
|
||||
#
|
||||
# Prereq: the runner needs ~6 GB free + internet (it pulls the Android SDK/NDK and the Gradle
|
||||
@@ -40,10 +40,12 @@ jobs:
|
||||
fi
|
||||
RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")"
|
||||
dirname "$RUSTUP" >> "$GITHUB_PATH"
|
||||
"$RUSTUP" target add aarch64-linux-android x86_64-linux-android
|
||||
"$RUSTUP" target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
|
||||
|
||||
- name: Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
# SHA-pinned: this workflow's release job carries the signing keystore + Play service-account
|
||||
# secrets, so a moved tag on a third-party action could exfiltrate them. v3 = 9fc6c4e.
|
||||
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
|
||||
|
||||
- name: NDK r30 + platform 36 + build-tools + CMake (libopus cross-build)
|
||||
# cmake;3.22.1 installs cmake + ninja under $ANDROID_SDK/cmake/3.22.1/bin — the exact path
|
||||
@@ -78,9 +80,10 @@ jobs:
|
||||
- name: Version + channel
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
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
|
||||
*) VN="0.5.0-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;;
|
||||
*) VN="${PF_BASE}-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;;
|
||||
esac
|
||||
echo "VERSION_NAME=$VN" >> "$GITHUB_ENV"
|
||||
echo "PLAY_TRACK=$TRACK" >> "$GITHUB_ENV"
|
||||
@@ -97,9 +100,26 @@ jobs:
|
||||
RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
|
||||
run: |
|
||||
echo "${{ secrets.RELEASE_KEYSTORE_BASE64 }}" | base64 -d > release.jks
|
||||
# AAB for Play; a universal APK (both ABIs) for direct sideload/testing — same upload key.
|
||||
# AAB for Play; a universal APK (all ABIs) for direct sideload/testing — same upload key.
|
||||
./gradlew :app:bundleRelease :app:assembleRelease --stacktrace
|
||||
|
||||
# Attach the built APK to the workflow run so it's a one-click sideload download straight from the
|
||||
# run page (no need to know the generic-registry URL) — on EVERY trigger, incl. a PR or a
|
||||
# workflow_dispatch. The debug APK is built on every run; the signed universal release APK exists
|
||||
# only on a main/tag push (its build step above is push-gated), so grab whichever were produced.
|
||||
- name: Attach APK(s) to the workflow run
|
||||
if: always()
|
||||
# v3, not v4: Gitea's artifact backend identifies as GHES, which upload-artifact@v4 refuses
|
||||
# (same reason as apple.yml / *-screenshots.yml). Download is a zip of the matched APK(s).
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: punktfunk-android-apk
|
||||
path: |
|
||||
clients/android/app/build/outputs/apk/release/*.apk
|
||||
clients/android/app/build/outputs/apk/debug/*.apk
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
|
||||
# Publish BEFORE the Play upload so artifacts land even while the Play step is still failing.
|
||||
# Generic registry is public for reads — matches windows-msix.yml / deb.yml (REGISTRY_TOKEN, user enricobuehler).
|
||||
# main = canary store + `canary/` sideload alias; a `vX.Y.Z` tag = `latest/` alias + attached
|
||||
|
||||
@@ -16,8 +16,17 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
# SECURITY: builds/tests PULL-REQUEST code on the host-mode, persistent `macos-arm64` runner shared
|
||||
# with the release-signing job (release.yml, which loads the App Store Connect key). Untrusted PR
|
||||
# code could persist on it or harvest signing material. Definitive fix is server-side: enable Gitea's
|
||||
# "require approval for PRs from outside collaborators/forks", and/or isolate PR CI on ephemeral
|
||||
# runners. The `if:` is a fail-open backstop — it skips fork PRs where Gitea reports the fork flag and
|
||||
# still runs same-repo PRs (and where the flag is absent), so it never blocks internal PR CI.
|
||||
swift:
|
||||
runs-on: macos-arm64
|
||||
if: >-
|
||||
gitea.event_name != 'pull_request' ||
|
||||
gitea.event.pull_request.head.repo.fork != true
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# Build the punktfunk-host / punktfunk-client / punktfunk-web pacman packages from
|
||||
# packaging/arch/PKGBUILD and publish them to Gitea's Arch package registry, so Arch boxes
|
||||
# get new builds via `pacman -Syu`. Counterpart to deb.yml (apt) and rpm.yml (dnf/rpm-ostree).
|
||||
# Arch is rolling, so the packages build against whatever the archlinux:base-devel container
|
||||
# resolves today — the same sonames an up-to-date Arch box runs.
|
||||
#
|
||||
# Registry (public, unom org) — box setup (once), see packaging/arch/README.md. The registry
|
||||
# SIGNS the DB + packages, so the box imports the registry key first (pacman-key --add +
|
||||
# --lsign-key), then no SigLevel line is needed (pacman's default Required verifies):
|
||||
# [punktfunk] # or [punktfunk-canary] for main-push builds
|
||||
# Server = https://git.unom.io/api/packages/unom/arch/$repo/$arch
|
||||
#
|
||||
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with docker.yml).
|
||||
# NOTE: this token + the registry-held private key are the trust root — a token holder can
|
||||
# publish a validly-signed package (the signature attests "via the registry", not "built by CI").
|
||||
name: arch
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# Single project version: a `vX.Y.Z` tag is THE release. main publishes to the
|
||||
# `punktfunk-canary` pacman repo as X.Y.Z-0.<run#> (sorts below the eventual X.Y.Z-1),
|
||||
# tags to `punktfunk` — separate repos, so neither channel can shadow the other.
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: git.unom.io
|
||||
OWNER: unom
|
||||
|
||||
jobs:
|
||||
build-publish:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: docker.io/library/archlinux:base-devel
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
CARGO_HOME: /usr/local/cargo
|
||||
steps:
|
||||
# git + nodejs must exist before actions/checkout — base-devel ships neither, and
|
||||
# act_runner runs the action's JS with the CONTAINER's node, it does not inject one.
|
||||
- name: Install build + runtime-dev deps
|
||||
run: |
|
||||
pacman -Syu --noconfirm --needed \
|
||||
git nodejs rust clang cmake nasm pkgconf python vulkan-headers \
|
||||
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
|
||||
mesa libglvnd unzip libarchive
|
||||
# bun builds the punktfunk-web console AND is vendored as its runtime (PF_WITH_WEB=1);
|
||||
# it's AUR-only on Arch, so bootstrap the official binary.
|
||||
command -v bun >/dev/null || {
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
install -m0755 "$HOME/.bun/bin/bun" /usr/local/bin/bun
|
||||
}
|
||||
bun --version
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Cache cargo's git dir too, not just the registry: the workspace includes
|
||||
# clients/windows, whose windows-reactor/windows deps are git-pinned — cargo must CLONE
|
||||
# them (windows-rs is huge) merely to resolve the workspace, even though nothing Windows
|
||||
# is ever compiled here. Cached, that cost is paid once per runner.
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
/usr/local/cargo/registry
|
||||
/usr/local/cargo/git
|
||||
key: cargo-home-arch-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-home-arch-
|
||||
|
||||
- name: Version + channel
|
||||
# vX.Y.Z tag -> X.Y.Z-1 in the `punktfunk` repo; main push -> <next-minor>-0.<run#> in
|
||||
# `punktfunk-canary` (pkgrel accepts only digits+dots — the run number carries the
|
||||
# monotonic ordering; the commit sha is stamped into the binary via the workflow log).
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of latest stable)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; REPO=punktfunk ;;
|
||||
*) V="$PF_BASE"; R="0.${GITHUB_RUN_NUMBER}"; REPO=punktfunk-canary ;;
|
||||
esac
|
||||
echo "PF_PKGVER=$V" >> "$GITHUB_ENV"
|
||||
echo "PF_PKGREL=$R" >> "$GITHUB_ENV"
|
||||
echo "REPO=$REPO" >> "$GITHUB_ENV"
|
||||
echo "pacman $V-$R -> repo '$REPO'"
|
||||
|
||||
- name: Build packages (makepkg)
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# libcuda link stub — same trick as packaging/rpm/build-rpm.sh: the zerocopy FFI
|
||||
# links -lcuda but the builder has no GPU; synthesize every cu* symbol the source
|
||||
# references so a newly-added call can't silently break the link.
|
||||
CU_SYMS="$(grep -rhoE '\bcu[A-Z][A-Za-z0-9_]*' crates/punktfunk-host/src/ | sort -u || true)"
|
||||
if [ -n "$CU_SYMS" ] && [ ! -e /usr/lib/libcuda.so ]; then
|
||||
STUB_C="$(mktemp --suffix=.c)"
|
||||
for s in $CU_SYMS; do printf 'int %s(void){return 0;}\n' "$s" >> "$STUB_C"; done
|
||||
gcc -shared -fPIC -Wl,-soname,libcuda.so.1 -o /usr/lib/libcuda.so.1 "$STUB_C"
|
||||
ln -sf libcuda.so.1 /usr/lib/libcuda.so
|
||||
rm -f "$STUB_C"; ldconfig
|
||||
echo "== libcuda stub: $(printf '%s\n' "$CU_SYMS" | wc -l) symbols =="
|
||||
fi
|
||||
# makepkg refuses to run as root; deps are already installed above (-d skips the
|
||||
# RPM-level check that can't see the script-installed bun anyway).
|
||||
useradd -m builder
|
||||
mkdir -p "$CARGO_HOME" # actions/cache doesn't create it on a cache miss
|
||||
chown -R builder: "$PWD" "$CARGO_HOME"
|
||||
sudo -u builder git config --global --add safe.directory "$PWD"
|
||||
mkdir -p dist && chown builder: dist
|
||||
cd packaging/arch
|
||||
sudo -u builder env PF_SRCDIR="$GITHUB_WORKSPACE" PF_WITH_WEB=1 \
|
||||
PF_PKGVER="$PF_PKGVER" PF_PKGREL="$PF_PKGREL" \
|
||||
CARGO_HOME="$CARGO_HOME" PKGDEST="$GITHUB_WORKSPACE/dist" \
|
||||
makepkg -f -d --holdver
|
||||
ls -lh "$GITHUB_WORKSPACE/dist"
|
||||
|
||||
- name: Publish to the Gitea Arch registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
for pkg in dist/*.pkg.tar.zst; do
|
||||
echo "uploading $pkg"
|
||||
NAME=$(bsdtar -xOf "$pkg" .PKGINFO | sed -n 's/^pkgname = //p')
|
||||
VER=$(bsdtar -xOf "$pkg" .PKGINFO | sed -n 's/^pkgver = //p')
|
||||
ARCH=$(bsdtar -xOf "$pkg" .PKGINFO | sed -n 's/^arch = //p')
|
||||
# A re-tagged release re-fires this workflow and the registry 409s on duplicate
|
||||
# package versions — delete any prior copy first (404 on the first publish is fine).
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
||||
"https://$REGISTRY/api/packages/$OWNER/arch/$REPO/$NAME/$VER/$ARCH" || true
|
||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$pkg" \
|
||||
"https://$REGISTRY/api/packages/$OWNER/arch/$REPO"
|
||||
done
|
||||
echo "published to $OWNER/arch/$REPO"
|
||||
|
||||
# On a real release, also attach the packages to the unified Gitea Release.
|
||||
- name: Attach packages to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
. scripts/ci/gitea-release.sh
|
||||
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||
for pkg in dist/*.pkg.tar.zst; do
|
||||
upsert_asset "$RID" "$pkg"
|
||||
done
|
||||
@@ -1,7 +1,10 @@
|
||||
# Supply-chain advisory scan for the (network-facing, crypto-heavy) Rust dependency tree.
|
||||
# Runs `cargo audit` against the RustSec advisory DB: weekly (catch newly-disclosed CVEs in
|
||||
# pinned deps), on every Cargo.lock change (catch a bad dep the moment it lands), and on demand.
|
||||
# To silence a known-unfixable advisory, add it to `.cargo/audit.toml` ([advisories] ignore = [...]).
|
||||
# Supply-chain advisory scan for BOTH dependency trees the project ships to users:
|
||||
# * cargo-audit → the (network-facing, crypto-heavy) Rust tree, against the RustSec advisory DB.
|
||||
# * bun audit → the web management console (Nitro/Bun BFF) — the component that holds the login
|
||||
# gate, session sealing, and the mgmt bearer token, so its deps matter too.
|
||||
# Triggers: weekly (catch newly-disclosed CVEs in pinned deps), on every lockfile change (catch a bad
|
||||
# dep the moment it lands), and on demand.
|
||||
# To silence a known-unfixable Rust advisory, add it to `.cargo/audit.toml` ([advisories] ignore=[…]).
|
||||
name: audit
|
||||
|
||||
on:
|
||||
@@ -9,7 +12,7 @@ on:
|
||||
- cron: '0 6 * * 1' # Mondays 06:00 UTC
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['Cargo.lock', '.gitea/workflows/audit.yml']
|
||||
paths: ['Cargo.lock', 'web/bun.lock', '.gitea/workflows/audit.yml']
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -31,3 +34,25 @@ jobs:
|
||||
git config --global --add safe.directory "$PWD"
|
||||
command -v cargo-audit >/dev/null 2>&1 || cargo install --locked cargo-audit
|
||||
cargo audit
|
||||
|
||||
bun-audit:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: oven/bun:1
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: web
|
||||
steps:
|
||||
# oven/bun's slim base lacks a CA bundle + git — actions/checkout's HTTPS fetch needs them
|
||||
# (same preamble as web-screenshots.yml / ci.yml's web job).
|
||||
- name: Install git + CA certs
|
||||
working-directory: /
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git
|
||||
- uses: actions/checkout@v4
|
||||
# `bun audit` queries the registry advisory DB for the versions pinned in web/bun.lock. No
|
||||
# install/build needed — it reads the manifest + lockfile. Fails the job on any advisory, the
|
||||
# same fail-on-vulnerability stance as cargo-audit above; triage a finding by bumping the dep
|
||||
# (or, if genuinely unfixable + inapplicable, pinning a resolution and noting why here).
|
||||
- name: bun audit
|
||||
run: bun audit
|
||||
|
||||
@@ -28,8 +28,8 @@ jobs:
|
||||
|
||||
# Best-effort caches (act_runner's built-in cache server). Keyed on Cargo.lock:
|
||||
# registry/git are download caches, target/ the incremental build. The target key
|
||||
# carries the rustc version — rust-toolchain.toml pins the floating "stable"
|
||||
# channel, so the file alone wouldn't invalidate stale incremental state.
|
||||
# carries the rustc version — resolved via `rustc --version` (below) rather than parsed
|
||||
# from rust-toolchain.toml, so a pin bump there invalidates stale incremental state too.
|
||||
- name: Cache keys
|
||||
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||
- uses: actions/cache@v4
|
||||
|
||||
@@ -36,16 +36,17 @@ jobs:
|
||||
|
||||
- name: Version + channel
|
||||
# vX.Y.Z tag -> X.Y.Z, published to the `stable` apt distribution (a real release).
|
||||
# A main push -> 0.5.0~ciN.g<sha>, published to the `canary` distribution: the '~' sorts
|
||||
# below the eventual 0.5.0 tag, it climbs monotonically by run number, and the canary base
|
||||
# stays one minor AHEAD of the latest stable so a stable->canary box re-point still moves
|
||||
# forward (see channels.md). Computed BEFORE the build so it's stamped into the binary
|
||||
# A main push -> <next-minor>~ciN.g<sha>, published to the `canary` distribution: the '~' sorts
|
||||
# below the eventual tag, it climbs monotonically by run number, and the canary base is
|
||||
# derived one minor AHEAD of the latest stable tag (scripts/ci/pf-version.sh) so a
|
||||
# stable->canary box re-point still moves forward (see channels.md). Computed BEFORE the build so it's stamped into the binary
|
||||
# (PUNKTFUNK_BUILD_VERSION -> build.rs -> --version).
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
|
||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; DIST=stable ;;
|
||||
*) V="0.5.0~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;;
|
||||
*) V="${PF_BASE}~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;;
|
||||
esac
|
||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
|
||||
@@ -59,8 +60,10 @@ jobs:
|
||||
run: |
|
||||
apt-get update
|
||||
# python3 is used by scripts/ci/gitea-release.sh for the stable-tag release attach.
|
||||
# libvulkan-dev: /usr/include/vulkan/vulkan.h for the client's pf-ffvk bindgen
|
||||
# (FFmpeg's hwcontext_vulkan.h includes it).
|
||||
apt-get install -y --no-install-recommends dpkg-dev python3 \
|
||||
libgtk-4-dev libadwaita-1-dev libsdl3-dev
|
||||
libgtk-4-dev libadwaita-1-dev libsdl3-dev libvulkan-dev
|
||||
|
||||
# Share ci.yml's cache keys so the release build reuses its registry + target artifacts.
|
||||
- name: Cache keys
|
||||
@@ -85,7 +88,10 @@ jobs:
|
||||
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binary (build.rs)
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
cargo build --release -p punktfunk-host -p punktfunk-client-linux --locked
|
||||
# punktfunk-client-session is the Vulkan/Skia streamer the shell execs for a connect —
|
||||
# both client binaries must ship (build-client-deb.sh installs both).
|
||||
cargo build --release --locked \
|
||||
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session
|
||||
|
||||
- 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
|
||||
|
||||
@@ -63,7 +63,8 @@ jobs:
|
||||
pnpm run build # rollup -> clients/decky/dist/index.js
|
||||
|
||||
- name: Version + channel + stamp
|
||||
# Tag vX.Y.Z -> X.Y.Z (stable `latest/` alias + Gitea Release); main push -> 0.3.<run>
|
||||
# Tag vX.Y.Z -> X.Y.Z (stable `latest/` alias + Gitea Release); main push -> <next-minor>.<run>
|
||||
# (base one minor ahead of the latest stable tag via scripts/ci/pf-version.sh)
|
||||
# (`canary/` alias). Decky reads a plugin's INSTALLED version from package.json (NOT
|
||||
# plugin.json), and the plugin's own update check (clients/decky/main.py check_update)
|
||||
# compares against it — so the build version is STAMPED into package.json here (mirrored
|
||||
@@ -72,9 +73,12 @@ jobs:
|
||||
# (ci10 < ci9), which would break update detection; the run number is monotonic.
|
||||
working-directory: ${{ gitea.workspace }}
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_MAJOR/PF_MINOR (base one minor ahead of latest stable)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; ALIAS=latest ;;
|
||||
*) V="0.3.${GITHUB_RUN_NUMBER}"; ALIAS=canary ;;
|
||||
# Canary MUST be a plain monotonic numeric semver (see the note above): <major>.<minor>.<run>,
|
||||
# where major.minor track one minor ahead of the latest stable and the run number climbs.
|
||||
*) V="${PF_MAJOR}.${PF_MINOR}.${GITHUB_RUN_NUMBER}"; ALIAS=canary ;;
|
||||
esac
|
||||
BASE="https://$REGISTRY/api/packages/$OWNER/generic/$PACKAGE"
|
||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||
|
||||
@@ -86,7 +86,10 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Sync compose file
|
||||
uses: appleboy/scp-action@v0.1.7
|
||||
# SHA-pinned (not tag-pinned): this action receives DEPLOY_SSH_KEY + host/user/port, so a
|
||||
# moved tag would mean credential exfiltration. v0.1.7 = 917f8b8. Bump both the SHA and the
|
||||
# trailing version together when upgrading.
|
||||
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
@@ -97,7 +100,8 @@ jobs:
|
||||
overwrite: true
|
||||
|
||||
- name: Pull and start docs
|
||||
uses: appleboy/ssh-action@v1.2.5
|
||||
# SHA-pinned: receives DEPLOY_SSH_KEY + REGISTRY_TOKEN (see the scp step above). v1.2.5 = 0ff4204.
|
||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
with:
|
||||
|
||||
@@ -25,9 +25,15 @@ on:
|
||||
branches: [main]
|
||||
# The flatpak is the CLIENT — only rebuild when the client/core/manifest change, not on every
|
||||
# design/host push (this is a heavy flatpak-builder run). Tags (v*, the client release) build too.
|
||||
# The bundle ships BOTH client binaries (shell + Vulkan session), so every crate in either
|
||||
# binary's dependency closure must be listed here.
|
||||
paths:
|
||||
- 'clients/linux/**'
|
||||
- 'clients/session/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'crates/pf-client-core/**'
|
||||
- 'crates/pf-presenter/**'
|
||||
- 'crates/pf-console-ui/**'
|
||||
- 'packaging/flatpak/**'
|
||||
- 'Cargo.lock'
|
||||
- '.gitea/workflows/flatpak.yml'
|
||||
@@ -67,21 +73,26 @@ jobs:
|
||||
dnf -y install flatpak flatpak-builder git python3 python3-aiohttp python3-tomlkit curl jq \
|
||||
gnupg2 rsync openssh-clients
|
||||
# Flathub provides the GNOME runtime/SDK + the rust-stable + ffmpeg-full extensions.
|
||||
flatpak remote-add --user --if-not-exists flathub \
|
||||
# retry.sh: the busy runner intermittently drops DNS lookups ("[6] Could not resolve
|
||||
# hostname" seconds after dnf pulled 300+ packages fine) — never fail the job on a
|
||||
# single-shot fetch. Same treatment for every network command below.
|
||||
bash scripts/ci/retry.sh 5 flatpak remote-add --user --if-not-exists flathub \
|
||||
https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
git config --global --add safe.directory "$PWD"
|
||||
|
||||
- name: Version + channel
|
||||
# Tag vX.Y.Z -> X.Y.Z on the OSTree `stable` branch (a real release); a main push ->
|
||||
# 0.5.0-ciN.g<sha> on the `canary` branch. The two branches live side-by-side in one repo
|
||||
# <next-minor>-ciN.g<sha> on the `canary` branch (base one minor ahead of the latest stable
|
||||
# tag via scripts/ci/pf-version.sh). The two branches live side-by-side in one repo
|
||||
# (rsync runs without --delete), each tracked by its own .flatpakref, so `flatpak update`
|
||||
# on a stable box never jumps to a canary build. The generic-registry version string allows
|
||||
# letters/dots/hyphens.
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
|
||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; BRANCH=stable; ALIAS=latest ;;
|
||||
*) V="0.5.0-ci${GITHUB_RUN_NUMBER}.g${SHORT}"; BRANCH=canary; ALIAS=canary ;;
|
||||
*) V="${PF_BASE}-ci${GITHUB_RUN_NUMBER}.g${SHORT}"; BRANCH=canary; ALIAS=canary ;;
|
||||
esac
|
||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "BUNDLE=punktfunk-client-${V}.flatpak" >> "$GITHUB_ENV"
|
||||
@@ -100,18 +111,77 @@ jobs:
|
||||
# device" (see packaging/flatpak/prune-windows-lock.py). The committed Cargo.lock is
|
||||
# untouched; cargo --offline only needs sources for the crates it compiles.
|
||||
run: |
|
||||
curl -fsSL -o /tmp/flatpak-cargo-generator.py \
|
||||
curl -fsSL --retry 5 --retry-all-errors --retry-delay 5 -o /tmp/flatpak-cargo-generator.py \
|
||||
https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/flatpak-cargo-generator.py
|
||||
python3 packaging/flatpak/prune-windows-lock.py Cargo.lock /tmp/Cargo.flatpak.lock
|
||||
python3 /tmp/flatpak-cargo-generator.py /tmp/Cargo.flatpak.lock \
|
||||
-o packaging/flatpak/cargo-sources.json
|
||||
|
||||
- name: Build the flatpak (install deps from Flathub, offline build)
|
||||
- name: Seed the local OSTree repo from the live server (keep BOTH channels in the summary)
|
||||
# Each CI run builds only ONE branch (canary on main, stable on a tag). The deploy step's
|
||||
# `flatpak build-update-repo` regenerates the repo SUMMARY from whatever refs are in the
|
||||
# LOCAL repo, and the rsync publishes it (without --delete). A fresh single-branch local
|
||||
# repo therefore produces a single-branch summary that CLOBBERS the other channel on the
|
||||
# server — the exact bug that made `app/io.unom.Punktfunk/x86_64/stable` unresolvable
|
||||
# ("No such ref") after a canary main-push overwrote the post-release summary, even though
|
||||
# the stable commit's objects were still on disk. Fix: mirror the published repo DOWN first,
|
||||
# so the local repo carries every existing branch; the build below then only ADDS this run's
|
||||
# commit and the regenerated+signed summary keeps both channels. No-op on a fresh repo (first
|
||||
# publish) or when the deploy secrets aren't set (the build still produces a valid bundle).
|
||||
env:
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
run: |
|
||||
# --install-deps-from=flathub pulls everything the manifest declares: the GNOME 50
|
||||
# runtime/SDK + the rust-stable (//25.08, rustc 1.96) and llvm20 SDK extensions, plus
|
||||
# the runtime's auto codecs-extra (HEVC libavcodec). --disable-rofiles-fuse is the
|
||||
# container-safe path (no FUSE).
|
||||
set -euo pipefail
|
||||
if [ -z "${DEPLOY_HOST:-}" ] || [ -z "${DEPLOY_SSH_KEY:-}" ]; then
|
||||
echo "::warning::DEPLOY_* not set — no seed; building a fresh single-branch repo."
|
||||
exit 0
|
||||
fi
|
||||
install -d -m700 ~/.ssh
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy; chmod 600 ~/.ssh/deploy
|
||||
SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=accept-new"
|
||||
DEST="${DEPLOY_USER}@${DEPLOY_HOST}"
|
||||
mkdir -p "$PWD/repo"
|
||||
# Pull the currently-published repo (all channels' objects + refs) into the repo the build
|
||||
# will extend. No --delete: the local repo starts empty, so this only ADDS.
|
||||
# Probe first (retried) whether a published repo exists at all: ONLY that case may
|
||||
# continue with a fresh repo. A transient network failure must FAIL the job instead —
|
||||
# a blanket `rsync || continue` here is exactly how a flaky link produces the
|
||||
# single-branch summary that clobbers the other channel (the bug described above).
|
||||
PRESENT=$(bash scripts/ci/retry.sh 5 $SSH "$DEST" \
|
||||
"[ -d $DEPLOY_DIR/site/repo/refs ] && echo present || echo absent")
|
||||
if [ "$PRESENT" = present ]; then
|
||||
bash scripts/ci/retry.sh 5 rsync -az --info=stats1 -e "$SSH" \
|
||||
"$DEST:$DEPLOY_DIR/site/repo/" "$PWD/repo/"
|
||||
else
|
||||
echo "::warning::no published repo on the server (first publish) — continuing fresh"
|
||||
fi
|
||||
echo "seeded refs:"; ls "$PWD/repo/refs/heads/app/$APP_ID/x86_64/" 2>/dev/null || echo " (none)"
|
||||
|
||||
- name: Prefetch deps + sources (retried — the network phase, split off the build)
|
||||
run: |
|
||||
# All of the job's heavy network I/O happens HERE, retried, so a dropped DNS lookup
|
||||
# or TCP dial costs a backoff-retry instead of the whole (long) compile:
|
||||
# 1) --install-deps-only pulls everything the manifest declares from Flathub: the
|
||||
# GNOME 50 runtime/SDK + the rust-stable (//25.08, rustc 1.96) and llvm20 SDK
|
||||
# extensions, plus the runtime's auto codecs-extra (HEVC libavcodec).
|
||||
# 2) --download-only fetches every source (all crates in cargo-sources.json) into
|
||||
# the .flatpak-builder state dir. Both are resumable/idempotent, so re-running
|
||||
# after a partial failure is safe and cheap.
|
||||
# --disable-rofiles-fuse is the container-safe path (no FUSE).
|
||||
bash scripts/ci/retry.sh 5 flatpak-builder --user --force-clean --disable-rofiles-fuse \
|
||||
--install-deps-from=flathub --install-deps-only \
|
||||
"$PWD/build-dir" "$MANIFEST"
|
||||
bash scripts/ci/retry.sh 5 flatpak-builder --user --force-clean --disable-rofiles-fuse \
|
||||
--download-only \
|
||||
"$PWD/build-dir" "$MANIFEST"
|
||||
|
||||
- name: Build the flatpak (offline — deps + sources prefetched above)
|
||||
run: |
|
||||
# Everything is already local (state dir warmed by the prefetch step), so this long
|
||||
# step needs no network; --install-deps-from stays as a no-op safety net.
|
||||
# --default-branch=$FLATPAK_BRANCH pins the ref to app/io.unom.Punktfunk/x86_64/<branch>
|
||||
# (canary or stable) so the matching hosted .flatpakref resolves deterministically
|
||||
# (manifest sets no branch).
|
||||
@@ -177,6 +247,10 @@ jobs:
|
||||
--gpg-sign="$KEYID" --gpg-homedir="$GNUPGHOME"
|
||||
flatpak build-update-repo --generate-static-deltas \
|
||||
--gpg-sign="$KEYID" --gpg-homedir="$GNUPGHOME" "$PWD/repo"
|
||||
# The regenerated summary advertises exactly these refs — must include EVERY channel that
|
||||
# has ever published (the seed step ensures the other channel's commit is present). If this
|
||||
# ever shows only one branch on a repo that had two, the seed didn't run — investigate.
|
||||
echo "published summary advertises:"; ls "$PWD/repo/refs/heads/app/$APP_ID/x86_64/" 2>/dev/null || echo " (none)"
|
||||
# 2) Build the install descriptors (GPGKey = the committed public key, base64).
|
||||
GPGKEY="$(base64 -w0 packaging/flatpak/unom-flatpak.gpg)"
|
||||
rm -rf site && mkdir -p site
|
||||
@@ -188,9 +262,12 @@ jobs:
|
||||
Comment=unom Flatpak applications
|
||||
GPGKey=$GPGKEY
|
||||
EOF
|
||||
# Two refs, one per channel — both regenerated every run and rsync'd without --delete, so
|
||||
# the server always offers both (the stable ref only resolves once a release has built the
|
||||
# `stable` branch). A box installs ONE; `flatpak update` then tracks that channel's branch.
|
||||
# Two refs, one per channel. Both descriptor files are regenerated every run and rsync'd
|
||||
# without --delete; the repo SUMMARY carries both branches because the build was seeded
|
||||
# from the live repo above (so build-update-repo below re-signs a summary listing every
|
||||
# published channel, not just this run's). The stable ref resolves for good once any
|
||||
# release has built the `stable` branch. A box installs ONE; `flatpak update` then tracks
|
||||
# that channel's branch.
|
||||
write_ref() { # <filename> <branch> <title>
|
||||
cat > "site/$1" <<EOF
|
||||
[Flatpak Ref]
|
||||
@@ -223,11 +300,13 @@ jobs:
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy; chmod 600 ~/.ssh/deploy
|
||||
SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=accept-new"
|
||||
DEST="${DEPLOY_USER}@${DEPLOY_HOST}"
|
||||
$SSH "$DEST" "mkdir -p ~/$DEPLOY_DIR/site/repo"
|
||||
rsync -az --info=stats1 -e "$SSH" repo/ "$DEST:$DEPLOY_DIR/site/repo/"
|
||||
rsync -az -e "$SSH" site/unom.flatpakrepo "site/${APP_ID}.flatpakref" "site/${APP_ID}.Canary.flatpakref" site/index.html "$DEST:$DEPLOY_DIR/site/"
|
||||
rsync -az -e "$SSH" packaging/flatpak/server/compose.production.yml packaging/flatpak/server/Caddyfile "$DEST:$DEPLOY_DIR/"
|
||||
$SSH "$DEST" "cd ~/$DEPLOY_DIR && docker compose -f compose.production.yml up -d"
|
||||
# All idempotent — retried because the runner's link to unom-1 drops TCP dials under
|
||||
# load (the same flake that hits docker.yml's deploy-docs with "dial tcp: i/o timeout").
|
||||
bash scripts/ci/retry.sh 5 $SSH "$DEST" "mkdir -p ~/$DEPLOY_DIR/site/repo"
|
||||
bash scripts/ci/retry.sh 5 rsync -az --info=stats1 -e "$SSH" repo/ "$DEST:$DEPLOY_DIR/site/repo/"
|
||||
bash scripts/ci/retry.sh 5 rsync -az -e "$SSH" site/unom.flatpakrepo "site/${APP_ID}.flatpakref" "site/${APP_ID}.Canary.flatpakref" site/index.html "$DEST:$DEPLOY_DIR/site/"
|
||||
bash scripts/ci/retry.sh 5 rsync -az -e "$SSH" packaging/flatpak/server/compose.production.yml packaging/flatpak/server/Caddyfile "$DEST:$DEPLOY_DIR/"
|
||||
bash scripts/ci/retry.sh 5 $SSH "$DEST" "cd ~/$DEPLOY_DIR && docker compose -f compose.production.yml up -d"
|
||||
echo "deployed → $REPO_URL/${APP_ID}.flatpakref"
|
||||
|
||||
- name: Attach bundle to the Gitea release (stable tags only)
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends \
|
||||
libgtk-4-dev libadwaita-1-dev libsdl3-dev \
|
||||
libgtk-4-dev libadwaita-1-dev libsdl3-dev libvulkan-dev \
|
||||
xvfb x11-utils imagemagick scrot \
|
||||
libgl1-mesa-dri mesa-vulkan-drivers \
|
||||
adwaita-icon-theme fonts-cantarell fonts-dejavu-core
|
||||
|
||||
@@ -14,8 +14,12 @@
|
||||
# The macOS app is App-SANDBOXED for both channels (Config/Punktfunk-macOS.entitlements —
|
||||
# app-sandbox + network client/server + audio-input + bluetooth/usb device access; the
|
||||
# shared Config/Punktfunk.entitlements stays iOS/tvOS-only, where app-sandbox is invalid).
|
||||
# The Developer ID DMG is codesigned with the SAME macOS entitlements, so what we test
|
||||
# locally equals what App Store users get.
|
||||
# The Developer ID DMG is codesigned with the SAME macOS entitlements as the App Store build,
|
||||
# BUT it must ALSO embed a Developer ID provisioning profile: keychain-access-groups is a
|
||||
# MANAGED entitlement that AMFI only honors when an embedded profile authorizes it. A DMG
|
||||
# without one is SIGKILLed at spawn ("Launchd job spawn failed", POSIX errno 163) even though
|
||||
# it is validly signed AND notarized. ⌘R hides this (Xcode embeds a development profile); the
|
||||
# raw Developer ID codesign path does NOT, so ⌘R is NOT equivalent to the shipped DMG here.
|
||||
#
|
||||
# macOS App Store prerequisites (one-time, Apple portal — NOT done by this workflow; the
|
||||
# step is continue-on-error until they exist):
|
||||
@@ -27,6 +31,15 @@
|
||||
# the runner's login keychain, in addition to "Apple Distribution" — the App Store
|
||||
# .pkg is installer-signed with it.
|
||||
#
|
||||
# macOS Developer ID (DMG) prerequisite (one-time, Apple portal — the DMG step embeds it):
|
||||
# * A "Punktfunk macOS Developer ID" provisioning profile (Distribution -> Developer ID,
|
||||
# App ID io.unom.punktfunk, with the Keychain Sharing capability) installed on the runner
|
||||
# under ~/Library/Developer/Xcode/UserData/Provisioning Profiles/. It authorizes the
|
||||
# managed keychain-access-groups entitlement; without it the DMG is SIGKILLed at launch
|
||||
# (errno 163). If it is missing the DMG step warns and strips that entitlement (the app
|
||||
# then uses ClientIdentityStore's legacy file-keychain fallback) so the build still ships
|
||||
# a launchable app.
|
||||
#
|
||||
# Signing setup (NOT secret-based anymore): the runner is a LaunchAgent in the user's
|
||||
# logged-in Aqua session, so it uses the **login keychain** directly. Install the signing
|
||||
# identities there once via Xcode (Settings -> Accounts -> Manage Certificates): Developer
|
||||
@@ -99,13 +112,14 @@ jobs:
|
||||
|
||||
- name: Version from tag
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE, PF_CHANNEL, PF_STABLE_TAG (single source of truth)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; V="${V%%-*}" ;; # App Store marketing version is numeric X.Y.Z (drop -rc)
|
||||
*) V="0.5.0" ;; # canary marketing version; the build number disambiguates
|
||||
*) V="$PF_BASE" ;; # canary marketing version = one minor ahead of the latest stable tag; the build number disambiguates
|
||||
esac
|
||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "BUILD_NUM=$GITHUB_RUN_NUMBER" >> "$GITHUB_ENV"
|
||||
echo "version $V build $GITHUB_RUN_NUMBER"
|
||||
echo "version $V build $GITHUB_RUN_NUMBER (channel $PF_CHANNEL, latest stable ${PF_STABLE_TAG})"
|
||||
|
||||
- name: Rust toolchain (mac + iOS + tvOS slices)
|
||||
run: |
|
||||
@@ -155,9 +169,8 @@ jobs:
|
||||
run: |
|
||||
# Archive UNSIGNED, then codesign with the Developer ID Application identity from the
|
||||
# login keychain. Unsigned archive sidesteps Xcode's keychain-access-groups
|
||||
# provisioning-profile gate; codesign just needs the (now valid) identity + the
|
||||
# team-prefixed entitlements, no profile (App Sandbox + the network/device
|
||||
# capabilities are self-asserted for Developer ID — no profile entry needed).
|
||||
# provisioning-profile gate at archive time; we re-assert that authorization below by
|
||||
# EMBEDDING a Developer ID profile before codesign (see the keychain note further down).
|
||||
# Bundle is a single static binary.
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||
-project "$PROJECT" -scheme Punktfunk \
|
||||
@@ -172,6 +185,35 @@ jobs:
|
||||
RESOLVED="$RUNNER_TEMP/macos.entitlements"
|
||||
sed "s/\$(AppIdentifierPrefix)/${TEAM_ID}./g" \
|
||||
clients/apple/Config/Punktfunk-macOS.entitlements > "$RESOLVED"
|
||||
|
||||
# keychain-access-groups is a MANAGED (restricted) entitlement: App Sandbox and the
|
||||
# network/device keys are self-asserted for Developer ID, but a keychain access group
|
||||
# must be AUTHORIZED by an embedded provisioning profile. Without one, AMFI refuses to
|
||||
# spawn the sandboxed process at launch — "Launchd job spawn failed" (POSIX errno 163),
|
||||
# SIGKILL before main() — even though the bundle is validly signed and notarized. Embed
|
||||
# a "Developer ID" distribution profile for io.unom.punktfunk (Keychain Sharing) so its
|
||||
# entitlements authorize the access group, exactly like the App Store build's profile
|
||||
# does. Located by profile Name among the profiles installed on the runner (see header).
|
||||
DEVID_PROFILE_NAME="Punktfunk macOS Developer ID"
|
||||
PROFILE_SRC=""
|
||||
for p in "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles/"*.provisionprofile \
|
||||
"$HOME/Library/MobileDevice/Provisioning Profiles/"*.provisionprofile; do
|
||||
[ -e "$p" ] || continue
|
||||
NAME=$(security cms -D -i "$p" 2>/dev/null | plutil -extract Name raw - 2>/dev/null || true)
|
||||
[ "$NAME" = "$DEVID_PROFILE_NAME" ] && PROFILE_SRC="$p" && break
|
||||
done
|
||||
if [ -n "$PROFILE_SRC" ]; then
|
||||
# Must land BEFORE codesign so it's sealed into the bundle.
|
||||
cp "$PROFILE_SRC" "$APP/Contents/embedded.provisionprofile"
|
||||
echo "embedded Developer ID profile: $PROFILE_SRC"
|
||||
else
|
||||
# Fallback so a missing/expired profile NEVER reships the errno-163 brick: drop the
|
||||
# managed entitlement and let ClientIdentityStore fall back to the legacy file keychain
|
||||
# (its errSecMissingEntitlement path). Degraded (one Keychain prompt) but launchable.
|
||||
echo "::warning::Developer ID profile '$DEVID_PROFILE_NAME' not installed on the runner — stripping keychain-access-groups so the DMG still launches (legacy file keychain). Create it in the Apple portal + install it on the runner to restore the no-prompt data-protection keychain."
|
||||
/usr/libexec/PlistBuddy -c "Delete :keychain-access-groups" "$RESOLVED" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
codesign --force --options runtime --timestamp \
|
||||
--entitlements "$RESOLVED" \
|
||||
--sign "Developer ID Application" "$APP"
|
||||
|
||||
@@ -35,8 +35,10 @@ jobs:
|
||||
include:
|
||||
- image: punktfunk-fedora-rpm # Fedora 43 == Bazzite base
|
||||
group: bazzite
|
||||
fedver: 43
|
||||
- image: punktfunk-fedora44-rpm # Fedora 44 == Fedora KDE spin
|
||||
group: fedora-44
|
||||
fedver: 44
|
||||
container:
|
||||
image: git.unom.io/unom/${{ matrix.image }}:latest
|
||||
timeout-minutes: 90
|
||||
@@ -52,7 +54,11 @@ jobs:
|
||||
- name: Prep
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
dnf -y install gtk4-devel libadwaita-devel SDL3-devel
|
||||
# vulkan-headers: the client's pf-ffvk crate runs bindgen over FFmpeg's
|
||||
# libavutil/hwcontext_vulkan.h (#include <vulkan/vulkan.h>).
|
||||
dnf -y install gtk4-devel libadwaita-devel SDL3-devel vulkan-headers
|
||||
# sysext build (packaging/bazzite/build-sysext.sh): squashfs + SELinux labeling.
|
||||
dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted
|
||||
# bun builds the punktfunk-web console (--with web). Baked into the image; install it
|
||||
# here too so the job stays green against the PREVIOUS image (docker.yml bootstrap note).
|
||||
command -v bun >/dev/null || {
|
||||
@@ -68,16 +74,17 @@ jobs:
|
||||
restore-keys: cargo-home-
|
||||
|
||||
- name: Version + channel
|
||||
# vX.Y.Z tag -> X.Y.Z-1 in the base group (a real release); main push -> 0.5.0-0.ciN.g<sha>
|
||||
# in the `<base>-canary` group, whose "0." release sorts below the eventual 0.5.0-1 yet
|
||||
# climbs by run number. The canary base stays one minor ahead of the latest stable so a
|
||||
# stable->canary box re-point still moves forward. The spec %build stamps
|
||||
# vX.Y.Z tag -> X.Y.Z-1 in the base group (a real release); main push -> <next-minor>-0.ciN.g<sha>
|
||||
# in the `<base>-canary` group, whose "0." release sorts below the eventual <next-minor>-1 yet
|
||||
# climbs by run number. The canary base is derived one minor ahead of the latest stable tag
|
||||
# (scripts/ci/pf-version.sh) so a stable->canary box re-point still moves forward. The spec %build stamps
|
||||
# PUNKTFUNK_BUILD_VERSION from these macros into the binary (--version provenance).
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
|
||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; GROUP="${{ matrix.group }}" ;;
|
||||
*) V="0.5.0"; R="0.ci${GITHUB_RUN_NUMBER}.g${SHORT}"; GROUP="${{ matrix.group }}-canary" ;;
|
||||
*) V="$PF_BASE"; R="0.ci${GITHUB_RUN_NUMBER}.g${SHORT}"; GROUP="${{ matrix.group }}-canary" ;;
|
||||
esac
|
||||
echo "PF_VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "PF_RELEASE=$R" >> "$GITHUB_ENV"
|
||||
@@ -116,6 +123,27 @@ jobs:
|
||||
done
|
||||
echo "published to $OWNER/rpm/$GROUP"
|
||||
|
||||
# The no-layering Bazzite path: wrap the just-built host + web RPMs into a systemd-sysext
|
||||
# image and publish it to the per-Fedora-major feed (punktfunk-sysext/f43[-canary], …) that
|
||||
# `punktfunk-sysext install|update` reads. Same RPMs, same channels — just no rpm-ostree.
|
||||
- name: Build the sysext image
|
||||
run: |
|
||||
bash packaging/bazzite/build-sysext.sh --version-id "${{ matrix.fedver }}" \
|
||||
--out "dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw" \
|
||||
dist/punktfunk-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
|
||||
dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm
|
||||
|
||||
- name: Publish the sysext feed
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
case "$GROUP" in
|
||||
*-canary) FEED="f${{ matrix.fedver }}-canary"; KEEP=6 ;; # rolling: bound the pile-up
|
||||
*) FEED="f${{ matrix.fedver }}"; KEEP=0 ;; # stable: keep every release
|
||||
esac
|
||||
KEEP=$KEEP bash packaging/bazzite/publish-sysext-feed.sh "$FEED" \
|
||||
"dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw"
|
||||
|
||||
# On a real release, also attach the .rpms to the unified Gitea Release. Both Fedora bases
|
||||
# (bazzite=F43, fedora-44) build the SAME filename, so suffix the asset with the base to keep
|
||||
# both on the release; canary builds live in the `*-canary` rpm groups (no release page).
|
||||
@@ -131,3 +159,6 @@ jobs:
|
||||
base="$(basename "$rpm" .rpm)"
|
||||
upsert_asset "$RID" "$rpm" "${base}.${{ matrix.group }}.rpm"
|
||||
done
|
||||
for raw in dist-sysext/*.raw; do
|
||||
upsert_asset "$RID" "$raw" "$(basename "$raw" .raw).f${{ matrix.fedver }}.raw"
|
||||
done
|
||||
|
||||
@@ -30,8 +30,15 @@ on:
|
||||
# scripts/ci/ensure-windows-toolchain.ps1, a fast no-op once already present.
|
||||
|
||||
jobs:
|
||||
# SECURITY: builds PULL-REQUEST code on the host-mode, persistent `windows-amd64` runner shared with
|
||||
# the release-signing jobs (windows-host.yml / windows-msix.yml). See windows.yml for the full
|
||||
# rationale. Definitive fix is server-side (Gitea outside-collaborator approval + isolated PR
|
||||
# runners); the `if:` is a fail-open backstop that never blocks internal PR CI.
|
||||
probe-and-proto:
|
||||
runs-on: windows-amd64
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.fork != true
|
||||
timeout-minutes: 30
|
||||
defaults:
|
||||
run:
|
||||
@@ -108,6 +115,9 @@ jobs:
|
||||
# DLL's FORCE_INTEGRITY (/INTEGRITYCHECK) bit — the M0 self-signed-load question.
|
||||
driver-build:
|
||||
runs-on: windows-amd64
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.fork != true
|
||||
timeout-minutes: 45
|
||||
defaults:
|
||||
run:
|
||||
@@ -131,11 +141,21 @@ jobs:
|
||||
# dispatched provisioning workflow landing on a different one. Path is relative to the job
|
||||
# working-directory (packaging/windows/drivers). Near-noop once the toolchain is present.
|
||||
run: ../../../scripts/ci/ensure-windows-toolchain.ps1
|
||||
- name: cargo build the driver workspace (wdk-probe + wdk-iddcx + pf-vdisplay)
|
||||
- name: cargo build the driver workspace (wdk-probe + wdk-iddcx + pf-vdisplay + gamepad drivers)
|
||||
# Whole workspace: wdk-probe (toolchain/surface-assert probe) + wdk-iddcx (DDI wrappers) +
|
||||
# pf-vdisplay (the real IddCx driver). pf-vdisplay linking proves the IddCx call sites resolve
|
||||
# against IddCxStub end-to-end (M1 step 2 gate).
|
||||
# pf-vdisplay (the real IddCx driver) + pf-umdf-util (the safe UMDF primitive layer) + the two
|
||||
# gamepad drivers. pf-vdisplay linking proves the IddCx call sites resolve against IddCxStub
|
||||
# end-to-end (M1 step 2 gate); the gamepad drivers prove pf-umdf-util's WDF dispatch links.
|
||||
run: cargo build -v
|
||||
- name: cargo clippy the shipped drivers (-D warnings — enforces the unsafe-audit gates)
|
||||
# The gamepad drivers' business logic is 100% safe (it moved onto pf-umdf-util, the audited
|
||||
# unsafe layer); pf-vdisplay + wdk-iddcx are inherently FFI-bound but every `unsafe {}` carries a
|
||||
# `// SAFETY:` proof. Both invariants are lint-gated (`unsafe_op_in_unsafe_fn` +
|
||||
# `undocumented_unsafe_blocks`); this step keeps them from regressing. (wdk-probe is a
|
||||
# toolchain-only probe crate and is excluded.)
|
||||
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-dualsense -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings
|
||||
- name: cargo fmt --check the safe-layer + gamepad drivers
|
||||
run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense --check
|
||||
- name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build
|
||||
run: |
|
||||
# explicit --target (.cargo/config.toml) -> output under the triple subdir.
|
||||
|
||||
@@ -16,15 +16,17 @@
|
||||
# Versioning (free-form; not MSIX's 4-part rule) — single project version:
|
||||
# vX.Y.Z tag -> X.Y.Z (THE release; published + stable `latest/` alias + attached to the
|
||||
# unified Gitea Release).
|
||||
# main push / dispatch -> 0.3.<run_number> (canary; `canary/` alias; climbs by run number).
|
||||
# main push / dispatch -> <next-minor>.<run_number> (canary; `canary/` alias; base one minor
|
||||
# ahead of the latest stable tag via scripts/ci/pf-version.ps1, run climbs).
|
||||
#
|
||||
# Signing reuses the client's MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD secrets (CN=unom). Without them
|
||||
# an ephemeral self-signed cert is generated and its public .cer published next to the installer
|
||||
# (import once to LocalMachine\TrustedPublisher). See packaging/windows/pack-host-installer.ps1.
|
||||
#
|
||||
# GPU backends: the host builds with --features nvenc,amf-qsv = all three vendors in one installer.
|
||||
# - NVENC (NVIDIA, direct SDK): the only link need is nvencodeapi.lib, synthesised from a 2-export
|
||||
# .def with llvm-dlltool (no GPU/SDK at build time).
|
||||
# - NVENC (NVIDIA, direct SDK): nothing needed at build time — the entry points are resolved at
|
||||
# RUNTIME from the driver's nvEncodeAPI64.dll (a link-time import would kill the binary on
|
||||
# AMD/Intel-only boxes before main).
|
||||
# - AMF/QSV (AMD/Intel, libavcodec): link-imports the FFmpeg libs from FFMPEG_DIR (the BtbN lgpl-shared
|
||||
# tree the client uses; includes the *_amf/*_qsv encoders) and bundles its DLLs into the installer.
|
||||
# lgpl-shared (not gpl-shared) keeps those bundled DLLs LGPL (we never use the GPL-only x264/x265).
|
||||
@@ -37,6 +39,7 @@ on:
|
||||
paths:
|
||||
- 'crates/punktfunk-host/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'crates/punktfunk-tray/**'
|
||||
- 'packaging/windows/**'
|
||||
- 'scripts/windows/**'
|
||||
- 'web/**'
|
||||
@@ -100,30 +103,33 @@ jobs:
|
||||
if (-not $env:VBCABLE_DIR) {
|
||||
"VBCABLE_DIR=C:\Users\Public\vbcable" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
}
|
||||
$pf = & "$env:GITHUB_WORKSPACE/scripts/ci/pf-version.ps1" # single source of truth: base is one minor ahead of the latest stable tag
|
||||
$v = if ($env:GITHUB_REF -like 'refs/tags/v*') {
|
||||
$env:GITHUB_REF_NAME -replace '^v', ''
|
||||
} else {
|
||||
"0.3.$($env:GITHUB_RUN_NUMBER)"
|
||||
# Canary: <major>.<minor>.<run> — major.minor track one minor ahead of stable, run climbs monotonically.
|
||||
"$($pf.PF_MAJOR).$($pf.PF_MINOR).$($env:GITHUB_RUN_NUMBER)"
|
||||
}
|
||||
"HOST_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"PUNKTFUNK_BUILD_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
Write-Output "host version $v"
|
||||
|
||||
- name: Generate NVENC import lib
|
||||
shell: pwsh
|
||||
run: |
|
||||
& packaging/windows/nvenc/gen-nvenc-importlib.ps1 -OutDir C:\t\nvenc
|
||||
"PUNKTFUNK_NVENC_LIB_DIR=C:\t\nvenc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
|
||||
- name: Build (release, nvenc + amf-qsv)
|
||||
shell: pwsh
|
||||
# All-vendor host: NVENC (NVIDIA, direct SDK) + AMF/QSV (AMD/Intel, libavcodec via FFMPEG_DIR).
|
||||
run: cargo build --release -p punktfunk-host --features nvenc,amf-qsv
|
||||
|
||||
- name: Clippy (host, Windows)
|
||||
- name: Build (release, status tray)
|
||||
shell: pwsh
|
||||
# The per-user notification-area companion the installer bundles (punktfunk-tray.exe).
|
||||
run: cargo build --release -p punktfunk-tray
|
||||
|
||||
- name: Clippy (host + tray, Windows)
|
||||
shell: pwsh
|
||||
# First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code).
|
||||
run: cargo clippy -p punktfunk-host --features nvenc,amf-qsv -- -D warnings
|
||||
run: |
|
||||
cargo clippy -p punktfunk-host --features nvenc,amf-qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
|
||||
cargo clippy -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
|
||||
|
||||
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
|
||||
shell: pwsh
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
# vX.Y.Z tag -> X.Y.Z.0 (THE release; any -rc/+meta pre-release suffix is dropped for MSIX).
|
||||
# Published to the generic registry + the stable `latest/` alias + attached to the
|
||||
# unified Gitea Release alongside every other platform's artifact.
|
||||
# main push / dispatch -> 0.3.<run_number>.0 (canary; climbs monotonically by run number).
|
||||
# main push / dispatch -> <next-minor>.<run_number>.0 (canary; base is one minor ahead of the
|
||||
# latest stable tag via scripts/ci/pf-version.ps1, run number climbs monotonically).
|
||||
# Published to the generic registry + the `canary/` alias.
|
||||
# Both arches share the version; artifacts are arch-suffixed (..._x64.msix / ..._arm64.msix).
|
||||
#
|
||||
@@ -32,7 +33,12 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'clients/windows/**'
|
||||
- 'clients/session/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'crates/pf-client-core/**'
|
||||
- 'crates/pf-presenter/**'
|
||||
- 'crates/pf-console-ui/**'
|
||||
- 'crates/pf-ffvk/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- '.gitea/workflows/windows-msix.yml'
|
||||
@@ -56,10 +62,15 @@ jobs:
|
||||
target: x86_64-pc-windows-msvc
|
||||
ffmpeg: C:\Users\Public\ffmpeg
|
||||
td: C:\t
|
||||
session_flags: ''
|
||||
- arch: arm64
|
||||
target: aarch64-pc-windows-msvc
|
||||
ffmpeg: C:\Users\Public\ffmpeg-arm64
|
||||
td: C:\t-a64
|
||||
# No skia-binaries prebuilt for aarch64-pc-windows-msvc: the session ships
|
||||
# without the Skia console UI on ARM64 (streaming unaffected) — flip when
|
||||
# rust-skia adds the target.
|
||||
session_flags: '--no-default-features'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -77,21 +88,27 @@ jobs:
|
||||
"CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"CARGO_TARGET_DIR=${{ matrix.td }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"FFMPEG_DIR=${{ matrix.ffmpeg }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
# pf-ffvk's bindgen needs Vulkan headers (arch-independent; provisioned alongside FFmpeg).
|
||||
"PF_FFVK_VULKAN_INCLUDE=C:\Users\Public\vulkan-headers\include" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
rustup target add ${{ matrix.target }}
|
||||
$pf = & "$env:GITHUB_WORKSPACE/scripts/ci/pf-version.ps1" # single source of truth: base is one minor ahead of the latest stable tag
|
||||
$parts = if ($env:GITHUB_REF -like 'refs/tags/v*') {
|
||||
# MSIX needs a purely-numeric 4-part version: drop any -rc/+meta pre-release suffix.
|
||||
(($env:GITHUB_REF_NAME -replace '^v', '') -replace '[-+].*$', '').Split('.')
|
||||
} else {
|
||||
@('0', '3', $env:GITHUB_RUN_NUMBER)
|
||||
# Canary: <major>.<minor>.<run>.0 — major.minor track one minor ahead of stable, run climbs monotonically.
|
||||
@($pf.PF_MAJOR, $pf.PF_MINOR, $env:GITHUB_RUN_NUMBER)
|
||||
}
|
||||
while ($parts.Count -lt 4) { $parts += '0' }
|
||||
$v = ($parts[0..3] -join '.')
|
||||
"MSIX_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
Write-Output "MSIX version $v arch ${{ matrix.arch }} target ${{ matrix.target }}"
|
||||
|
||||
# Both client binaries — the shell spawns punktfunk-session.exe (a package sibling)
|
||||
# for every stream. --no-default-features on ARM64 is a no-op for the shell.
|
||||
- name: Build (release)
|
||||
shell: pwsh
|
||||
run: cargo build --release -p punktfunk-client-windows --target ${{ matrix.target }}
|
||||
run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session ${{ matrix.session_flags }} --target ${{ matrix.target }}
|
||||
|
||||
- name: Pack + sign MSIX
|
||||
shell: pwsh
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
# Windows client CI — runs on a self-hosted windows-amd64 runner (host mode; the generic runner +
|
||||
# toolchain come from unom/infra's windows-runner/; punktfunk's own extras - FFmpeg, WDK, Inno
|
||||
# Setup, the ARM64 rustup target - self-provision via the "Ensure Windows toolchain" step below, a
|
||||
# fast no-op once already present, so any runner with that label works with no manual dispatch
|
||||
# step first). Build + clippy + fmt + test the WinUI 3 client
|
||||
# (windows-reactor + D3D11/SwapChainPanel + WASAPI + SDL3).
|
||||
# toolchain come from unom/infra's windows-runner/; punktfunk's own extras - FFmpeg,
|
||||
# Vulkan-Headers, WDK, Inno Setup, the ARM64 rustup target - self-provision via the "Ensure
|
||||
# Windows toolchain" step below, a fast no-op once already present, so any runner with that label
|
||||
# works with no manual dispatch step first). Build + clippy + fmt + test BOTH client binaries:
|
||||
# the WinUI 3 shell (windows-reactor + WASAPI + SDL3) and the punktfunk-session Vulkan client
|
||||
# (pf-presenter/pf-client-core/pf-console-ui/pf-ffvk — every stream runs in it, spawned by the
|
||||
# shell). ARM64 note: rust-skia publishes no aarch64-pc-windows-msvc prebuilt binaries, so the
|
||||
# session builds --no-default-features there (no Skia console UI; streaming is unaffected) —
|
||||
# flip when skia-binaries adds the target.
|
||||
#
|
||||
# Two architectures from ONE x64 runner: x86_64-pc-windows-msvc natively and
|
||||
# aarch64-pc-windows-msvc by cross-compiling. The x64 MSVC toolset ships an ARM64 cross compiler
|
||||
@@ -40,22 +44,44 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'clients/windows/**'
|
||||
- 'clients/session/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'crates/pf-client-core/**'
|
||||
- 'crates/pf-presenter/**'
|
||||
- 'crates/pf-console-ui/**'
|
||||
- 'crates/pf-ffvk/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- '.gitea/workflows/windows.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'clients/windows/**'
|
||||
- 'clients/session/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'crates/pf-client-core/**'
|
||||
- 'crates/pf-presenter/**'
|
||||
- 'crates/pf-console-ui/**'
|
||||
- 'crates/pf-ffvk/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- '.gitea/workflows/windows.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
# SECURITY: this job builds PULL-REQUEST code (attacker-controllable build.rs / cargo build) on the
|
||||
# host-mode, persistent `windows-amd64` runner that the release-SIGNING jobs (windows-host.yml /
|
||||
# windows-msix.yml, which decrypt MSIX_CERT_PFX_B64 + REGISTRY_TOKEN to disk) also run on. Untrusted
|
||||
# PR code could therefore persist on that machine or harvest signing material a later job exposes.
|
||||
# The DEFINITIVE fix is operational and lives outside this file: enable Gitea's "require approval to
|
||||
# run workflows for PRs from outside collaborators/forks", and/or route PR CI to isolated ephemeral
|
||||
# runners. The `if:` below is only a backstop — it skips fork PRs where Gitea reports the fork flag,
|
||||
# and FAILS OPEN (still runs) for same-repo PRs and on Gitea versions that don't populate it, so it
|
||||
# never blocks internal PR CI.
|
||||
build:
|
||||
runs-on: windows-amd64
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.fork != true
|
||||
timeout-minutes: 90
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -78,6 +104,8 @@ jobs:
|
||||
# Per-arch FFmpeg import libs (provision-windows-punktfunk-extras.ps1 fetches both).
|
||||
$ff = if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { 'C:\Users\Public\ffmpeg-arm64' } else { 'C:\Users\Public\ffmpeg' }
|
||||
"FFMPEG_DIR=$ff" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
# pf-ffvk's bindgen needs Vulkan headers (arch-independent; provisioned alongside FFmpeg).
|
||||
"PF_FFVK_VULKAN_INCLUDE=C:\Users\Public\vulkan-headers\include" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
# $ff\bin on PATH too (not just FFMPEG_DIR, which only satisfies the linker): the test
|
||||
# binary needs the actual DLLs to load at runtime. Set here rather than relying on the
|
||||
# daemon's own env (project-env.ps1) - on a freshly cloned/registered runner the daemon
|
||||
@@ -89,20 +117,29 @@ jobs:
|
||||
cargo --version
|
||||
Write-Output "target ${{ matrix.target }} target-dir $td ffmpeg $ff"
|
||||
|
||||
# Both client binaries. ARM64: no skia-binaries prebuilt for the target, so the session
|
||||
# drops its `ui` feature there (pf-console-ui excluded; --no-default-features is a no-op
|
||||
# for the shell, which has no features).
|
||||
- name: Build
|
||||
shell: pwsh
|
||||
run: cargo build -p punktfunk-client-windows --target ${{ matrix.target }}
|
||||
run: |
|
||||
$sf = @(); if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') }
|
||||
cargo build -p punktfunk-client-windows -p punktfunk-client-session @sf --target ${{ matrix.target }}
|
||||
|
||||
- name: Clippy (-D warnings)
|
||||
shell: pwsh
|
||||
run: cargo clippy -p punktfunk-client-windows --all-targets --target ${{ matrix.target }} -- -D warnings
|
||||
run: |
|
||||
$pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','pf-client-core','-p','pf-presenter','-p','pf-ffvk')
|
||||
$sf = @()
|
||||
if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') } else { $pkgs += @('-p','pf-console-ui') }
|
||||
cargo clippy @pkgs --all-targets @sf --target ${{ matrix.target }} -- -D warnings
|
||||
|
||||
- name: Rustfmt check
|
||||
if: matrix.target == 'x86_64-pc-windows-msvc'
|
||||
shell: pwsh
|
||||
run: cargo fmt -p punktfunk-client-windows -- --check
|
||||
run: cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk -- --check
|
||||
|
||||
- name: Test
|
||||
if: matrix.target == 'x86_64-pc-windows-msvc'
|
||||
shell: pwsh
|
||||
run: cargo test -p punktfunk-client-windows --target ${{ matrix.target }}
|
||||
run: cargo test -p punktfunk-client-windows -p punktfunk-client-session -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk --target ${{ matrix.target }}
|
||||
|
||||
@@ -31,3 +31,10 @@ xcuserdata/
|
||||
# Python bytecode (e.g. clients/android/ci tooling)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Claude Code project instructions — local to each dev box, not part of the repo.
|
||||
CLAUDE.md
|
||||
|
||||
# Local flatpak-builder output (build-flatpak.sh) — ostree repo + build dir at the repo root.
|
||||
.flatpak-repo/
|
||||
.flatpak-build/
|
||||
|
||||
@@ -1,541 +0,0 @@
|
||||
# CLAUDE.md — punktfunk
|
||||
|
||||
Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protocol core
|
||||
(`punktfunk-core`) exposed over a C ABI and native clients per platform. Full design:
|
||||
[`design/implementation-plan.md`](design/implementation-plan.md). Status table: `README.md`.
|
||||
|
||||
## Where the work stands
|
||||
|
||||
- **Core (`punktfunk-core` + C ABI): complete and hardened.** FEC recovery, loopback-under-loss,
|
||||
proptests, C ABI harness all green; 13 adversarial-review findings fixed +
|
||||
regression-tested (`a913042`).
|
||||
- **GameStream host: working end-to-end with a stock Moonlight client.** Validated live
|
||||
on this box: pairing (persists across restarts), serverinfo/applist (app catalog from
|
||||
`~/.config/punktfunk/apps.json` → each entry picks a compositor + nested command), RTSP, ENet
|
||||
control, audio, and video at the **client's native resolution and refresh** — the host
|
||||
creates a per-session virtual output via per-compositor `VirtualDisplay` backends:
|
||||
**KWin** (`zkde_screencast stream_virtual_output`, needs KWin ≥ 6.5.6 headless; >60 Hz via
|
||||
custom modes), **gamescope** (spawned headless at WxH@Hz, its PipeWire node captured, needs
|
||||
gamescope ≥ 3.16.22 — older deadlocks on PipeWire ≥ 1.6), **Mutter** (D-Bus
|
||||
`RecordVirtual` virtual monitor; validated live on headless GNOME Shell 50, zero-copy),
|
||||
**Sway/wlroots** (`swaymsg create_output` + custom mode, xdpw portal capture with a
|
||||
managed chooser config; validated live on sway 1.11, zero-copy).
|
||||
Performance work landed and measured: GPU **zero-copy** on all paths (tiled dmabuf →
|
||||
EGL/GL → CUDA; LINEAR dmabuf → **Vulkan bridge** → CUDA → NVENC), auto 2-way NVENC
|
||||
split-encode above ~1 Gpix/s (5K@240), infinite GOP + RFI keyframes (killed the periodic
|
||||
freeze), encode|send thread split with `sendmmsg` batching. Stable 240 fps at 5120×1440.
|
||||
Input: mouse/keyboard (libei via RemoteDesktop portal on KWin/GNOME, gamescope's own EIS
|
||||
socket, wlr protocols on Sway) and **gamepads** (uinput X-Box-360 pads + rumble
|
||||
back-channel; validated live — pad created/destroyed with the session). Management REST API +
|
||||
checked-in OpenAPI doc (`mgmt.rs`). **Web-console performance capture** (`stats_recorder.rs`,
|
||||
design: [`design/stats-capture-plan.md`](design/stats-capture-plan.md)): the operator arms stats
|
||||
recording from the web console, plays, stops, and reviews the run as graphs (per-stage latency
|
||||
breakdown · fps new/repeat · goodput · loss/FEC). A shared `Arc<StatsRecorder>` ring (the hot-path
|
||||
gate is a runtime `AtomicBool`, replacing the startup-only `PUNKTFUNK_PERF`) is fed by **both** the
|
||||
native `virtual_stream` and the GameStream encode loop at their existing ~2 s/~1 s aggregation
|
||||
boundary, and finished captures are saved as on-disk recordings
|
||||
(`~/.config/punktfunk/captures/*.json`) browsable/exportable from the console's **Performance** page
|
||||
(recharts). Endpoints `/api/v1/stats/*` (bearer-only). *Implemented; not yet on-glass validated.*
|
||||
**Web-console log view** (`log_capture.rs`): a `tracing` layer tees DEBUG-and-up (independent of
|
||||
`RUST_LOG`) into a 4096-entry in-memory ring, served cursor-paged at `GET /api/v1/logs`
|
||||
(bearer-only) → the console's **Logs** page (follow/pause · level filter · search). The Windows
|
||||
gamepad drivers now stamp attach/heartbeat marks into their shm sections and the host's
|
||||
`DriverAttach` watcher turns silence into a one-shot diagnosis WARN (driver-store check + CM
|
||||
devnode problem code) — failure-mode table: [`design/gamepad-driver-health.md`](design/gamepad-driver-health.md).
|
||||
The Android client gained Settings → **Connected controllers** (device list + VID/PID + resolved
|
||||
pad type + live input test) for the client end of the same chain. *Log view + driver health:
|
||||
Linux-tested; Windows/Android sides CI/device-validation pending.*
|
||||
- **Native protocol (`punktfunk/1`): full session planes, validated live.** QUIC
|
||||
control plane (`punktfunk-core` `quic` feature: Hello{mode}/Welcome{full Config}/Start), data
|
||||
plane = the hardened core `Session` over raw UDP with **GF(2¹⁶) Leopard FEC + AES-GCM**
|
||||
(inexpressible in GameStream), host creates the native virtual output at the client's
|
||||
requested mode. `punktfunk1-host` is a **persistent listener** (sessions back to back;
|
||||
`--max-sessions`). QUIC datagrams carry the side planes, demuxed by first byte: input
|
||||
0xC8 (incl. **gamepads** — incremental events accumulated into the uinput xpad), **Opus
|
||||
audio** 0xC9 (48 kHz stereo, 5 ms, host→client), **rumble** 0xCA (host→client). **Trust:**
|
||||
host serves its persistent identity (`~/.config/punktfunk/cert.pem`, shared with GameStream
|
||||
pairing) and logs the SHA-256 fingerprint; clients pin it, established by a **SPAKE2 PIN pairing
|
||||
ceremony** (host arms pairing and displays a 4-digit PIN; a PAKE binds both cert fingerprints so an
|
||||
attacker gets one online guess, no offline dictionary attack) — PIN pairing is the default for new
|
||||
hosts. **TOFU on first connect** (`endpoint::client_pinned`) stays as an explicit host opt-in
|
||||
(`punktfunk1-host --allow-tofu` / `serve --open`, advertised as `pair=optional`) for fully trusted LANs;
|
||||
clients only offer the TOFU "Trust" path for a host that advertised `pair=optional`, route every
|
||||
other new host straight to the PIN ceremony, and on a pinned-fingerprint change force re-pairing
|
||||
(no re-TOFU shortcut). Clients present persistent identities via QUIC client auth, the host stores
|
||||
paired fingerprints (`punktfunk1-paired.json`) and gates sessions with `--require-pairing` (the
|
||||
default; `--allow-tofu`/`--open` accept unpaired clients).
|
||||
**LAN auto-discovery**: both `serve` and `punktfunk1-host` advertise the native service over
|
||||
mDNS (`_punktfunk._udp`, `crate::discovery`) with TXT `proto`/`fp`(cert fingerprint to
|
||||
pin)/`pair`(required|optional)/`id`; `punktfunk-probe --discover` lists hosts, Apple clients
|
||||
browse the same service via NWBrowser (validated cross-LAN 2026-06-12).
|
||||
**Mid-stream mode renegotiation**: `Reconfigure` on the still-open control stream — the
|
||||
host rebuilds output+encoder at the new mode in ~90 ms while the data plane runs on
|
||||
(validated live: one .h265 with 720p and 1080p segments). Measured on-box at 720p120: 1680/1680 frames, **p50 0.83 ms**
|
||||
capture→…→reassembled; audio measured live (~200 pkts/s). A **wall-clock skew handshake**
|
||||
(`ClockProbe`/`ClockEcho`, 8 NTP rounds after `Start`, `clock_offset_ns`) aligns the client to the
|
||||
host clock, so that latency is now valid **cross-machine** (`skew_corrected=true`) — measured GNOME
|
||||
box → dev box over the LAN: **p50 1.30 ms** (the −1.57 ms inter-box clock offset removed).
|
||||
`punktfunk-probe` is the
|
||||
working reference client (`--pin`, datagram counters, `--input-test` incl. gamepad).
|
||||
The embeddable connector (`NativeClient`) exposes it all over the C ABI: `punktfunk_connect`
|
||||
(pin/TOFU) + `next_au`/`next_audio`/`next_rumble`/`next_hidout`/`send_input`/
|
||||
`send_rich_input`. **Client-negotiated virtual pad type**: the Hello carries a gamepad
|
||||
preference byte (same trailing-byte back-compat pattern as the compositor), the Welcome
|
||||
echoes the resolved backend — precedence: explicit client choice > `PUNKTFUNK_GAMEPAD`
|
||||
env > uinput Xbox 360. Backends: **Xbox 360** (uinput on Linux / the pf-xusb UMDF driver on
|
||||
Windows), **Xbox One/Series** (the same
|
||||
XInput backend with the One/Series USB identity for matching glyphs — no extra game-visible
|
||||
capability; impulse-trigger rumble is unreachable through a virtual pad), and the UHID
|
||||
`hid-playstation` pads — **DualSense** (adaptive triggers, lightbar, touchpad, motion) and
|
||||
**DualShock 4** (lightbar, touchpad, motion, rumble; DualSense minus adaptive triggers / player
|
||||
LEDs / mute). DualSense and DualShock 4 each have a Linux (UHID `hid-playstation`) **and a Windows
|
||||
(UMDF minidriver)** backend — `inject/windows/dualsense_windows.rs` + `inject/windows/dualshock4_windows.rs`, one
|
||||
driver serving either identity per a `device_type` byte the host stamps into shared memory (the DS4
|
||||
reuses the same SwDeviceCreate game-detection identity fix as the DualSense). One/Series stays
|
||||
Linux-only and folds into Xbox 360 off it. Clients auto-resolve the type from the physical controller
|
||||
(DS5→DualSense, DS4→DualShock 4, Xbox One→Xbox One). **Windows uses ZERO external gamepad
|
||||
dependencies — ViGEmBus is gone.** Xbox 360 (XInput) runs on a UMDF2 **XUSB companion** driver
|
||||
(`packaging/windows/drivers/pf-xusb/`, `inject/windows/gamepad_windows.rs`) that registers `GUID_DEVINTERFACE_XUSB`
|
||||
and answers the buffered XInput IOCTLs from a shared section, so classic `XInputGetState`/`SetState`
|
||||
work with no kernel bus driver (validated live: slot connected, state + rumble round-trip; Xbox One
|
||||
folds to this 360 path). All three UMDF drivers (DualSense/DS4 + XUSB) are built from source in CI
|
||||
(`packaging/windows/drivers/`) and installed by the Inno Setup installer via
|
||||
`punktfunk-host.exe driver install --gamepad`.
|
||||
**Multi-pad ready**: the host stamps each pad's index into the device Location (`pszDeviceLocation`),
|
||||
the driver reads it (`WdfDeviceAllocAndQueryProperty`) to map its own `*-shm-<index>`, and
|
||||
`UmdfHostProcessSharing=ProcessSharingDisabled` gives each pad its own host (per-pad statics) —
|
||||
validated live with 2 distinct XInput slots + 2 DualSense pads. (Client-side multi-pad forwarding is
|
||||
the remaining piece.)
|
||||
- **Windows host: implemented and shipping (all-vendor, x64-only).** `#[cfg(windows)]` backends
|
||||
behind the same traits as Linux — **IDD-push capture** straight into the in-house all-Rust IddCx
|
||||
**pf-vdisplay** virtual display (`capture/windows/idd_push.rs`, `vdisplay/windows/pf_vdisplay.rs`;
|
||||
DXGI Desktop Duplication / WGC as fallbacks, `capture/windows/dxgi.rs`), GPU encode (NVENC
|
||||
`--features nvenc`; AMD/Intel `--features amf-qsv`), SendInput + the in-house UMDF gamepad drivers
|
||||
(`inject/windows/`), WASAPI loopback + virtual mic (`audio/windows/wasapi_*`). **Keyboard wire
|
||||
convention: US-positional VKs** (every first-party client sends the physical key's US-layout VK;
|
||||
the Windows client derives it from the scancode, NOT the layout-resolved `vkCode`) — the Windows
|
||||
injector resolves them via a fixed table mirroring the Linux `vk_to_evdev` (never through a
|
||||
keyboard layout: the SYSTEM service thread's layout re-reads positions as characters — the
|
||||
German y↔z / ö→ü scramble), while GameStream/Moonlight VKs are layout-semantic
|
||||
(`KEY_FLAG_SEMANTIC_VK`, resolved under the foreground app's layout, Sunshine's model). Linux
|
||||
renders positions under the session compositor's layout (libei) or the virtual keyboard's
|
||||
uploaded keymap (Sway/wlroots — honors `XKB_DEFAULT_LAYOUT` et al., default US); the Android
|
||||
client reads `KeyEvent.scanCode` first so a user-selected physical-keyboard layout can't
|
||||
re-map keycodes semantically. Ships as a **signed
|
||||
Inno Setup installer** that registers a `LocalSystem` SCM service launching into the interactive
|
||||
session for secure-desktop (UAC/lock-screen) capture (`windows/service.rs`), bundles the
|
||||
pf-vdisplay driver + the FFmpeg DLLs (+ VB-CABLE for the virtual mic), and is published by
|
||||
`windows-host.yml`. **Encoder is GPU-aware** (`encode.rs` `open_video` + `windows_resolved_backend`):
|
||||
`PUNKTFUNK_ENCODER=auto` (the host.env default) reads the **selected render adapter's** vendor →
|
||||
**NVENC** (NVIDIA, direct SDK, `encode/windows/nvenc.rs`), **AMF** (AMD) / **QSV** (Intel) via libavcodec
|
||||
(`encode/windows/ffmpeg_win.rs`, the Windows analogue of the Linux VAAPI backend — `WinVendor{Amf,Qsv}`,
|
||||
system-memory NV12/P010 readback default + opt-in zero-copy D3D11 behind `PUNKTFUNK_ZEROCOPY` with a
|
||||
system fallback), or software H.264 (`encode/sw.rs`, GPU-less). GameStream codec advertisement is
|
||||
probed per-GPU on AMF/QSV (`windows_codec_support` → `serverinfo`, AV1 gated; cached per selected
|
||||
GPU). **Multi-GPU is first-class** (`gpu.rs`): GPU inventory + a persisted auto/manual preference
|
||||
(`<config>/gpu-settings.json`, stored by stable PCI identity — LUIDs are per-boot) exposed over
|
||||
`GET /api/v1/gpus` + `PUT /api/v1/gpus/preference` and a web-console GPU card (Host page: list,
|
||||
Automatic/Prefer, "In use · backend" badge). One selection — precedence **console preference >
|
||||
`PUNKTFUNK_RENDER_ADAPTER` > max VRAM**, graceful fallback when the preferred GPU is absent —
|
||||
feeds `win_adapter::resolve_render_adapter_luid` (capture ring + IddCx render pin), the encoder
|
||||
vendor auto-detect (previously DXGI adapter 0 — wrong on hybrid boxes like NVIDIA dGPU + Intel
|
||||
Arc iGPU), and the NVENC 4:4:4 probe; a preference change applies to the next session. On Linux a
|
||||
matched manual preference picks the VAAPI render node / NVENC-vs-VAAPI auto choice (auto mode
|
||||
unchanged). *Implemented + unit-tested; not yet on-glass validated on the hybrid box.* **HDR (10-bit)**: WGC
|
||||
captures the HDR desktop as FP16/Rgb10a2 (DDA FP16 for the secure desktop), the encoder forces HEVC
|
||||
Main10 + BT.2020 PQ (NVENC ABGR10/P010; AMF/QSV P010 + a swscale Rgb10a2→P010 fallback), the client
|
||||
auto-detects PQ from the HEVC VUI — gated by `PUNKTFUNK_10BIT` + client `VIDEO_CAP_10BIT`; **Windows
|
||||
host only** (the Linux host stays 8-bit, blocked upstream). **Vulkan-game HDR over the virtual
|
||||
display**: NVIDIA/AMD Vulkan ICDs refuse to *advertise* an HDR color space for a surface on an IddCx
|
||||
indirect display (so Vulkan games — Doom: The Dark Ages, id Tech, etc. — say "device does not support
|
||||
HDR"), even though the ICD happily *accepts + presents* a forced HDR swapchain there. A tiny always-on
|
||||
Vulkan **implicit layer** (`packaging/windows/pf-vkhdr-layer/`, `VK_LAYER_PUNKTFUNK_hdr_inject`)
|
||||
injects the `HDR10_ST2084`/scRGB surface formats into `vkGetPhysicalDeviceSurfaceFormats[2]KHR`,
|
||||
self-gated on the display's actual advanced-color state (no-op on SDR / real monitors); bundled +
|
||||
HKLM-registered by the installer. **Live-validated: Doom: The Dark Ages enables HDR over the virtual
|
||||
display.** **AMF/QSV is CI-green but not yet on-glass validated** (no AMD/Intel Windows box in the
|
||||
lab); NVENC is live-validated. Newer/less battle-tested than the Linux host. Packaging: `packaging/windows/`.
|
||||
|
||||
## What's left
|
||||
|
||||
1. **Native clients — decode + present: macOS stage 1 done, first light achieved
|
||||
(2026-06-10).** PunktfunkKit compiles and is tested on macOS (AnnexB → VideoToolbox →
|
||||
`AVSampleBufferDisplayLayer`, GCMouse/GCKeyboard capture, `PunktfunkClient` app shell);
|
||||
validated live Mac ↔ this box at 720p60 — vkcube on glass, input injected via gamescope
|
||||
EIS. The app speaks the full ABI v2 trust surface: Keychain-persisted client identity
|
||||
presented on every connect, SPAKE2 PIN pairing UI (host-card context menu + the trust
|
||||
prompt's "Pair with PIN instead…"), TOFU fingerprint prompt. **Gamepads (2026-06-11):**
|
||||
controller discovery + selection in Settings (`GamepadManager` — exactly one pad
|
||||
forwarded as pad 0, auto or pinned; pad TYPE auto-resolves from the physical
|
||||
controller, user-overridable), capture incl. DualSense touchpad/motion
|
||||
(`GamepadCapture`/`GamepadWire`; while streaming, EVERY element's
|
||||
`preferredSystemGestureState` is claimed `.disabled` — share/create reaches the host as
|
||||
select instead of screenshotting locally, PS/Home reaches the host as guide/`BTN_MODE` =
|
||||
the Steam-overlay button — restored `.enabled` on unbind), feedback rendering (rumble → CoreHaptics; lightbar /
|
||||
player LEDs / adaptive triggers → `GCDeviceLight`/`playerIndex`/
|
||||
`GCDualSenseAdaptiveTrigger` via the table-driven `DualSenseTriggerEffect` parser).
|
||||
Loopback-tested end to end (`PUNKTFUNK_TEST_FEEDBACK=1` scripted burst); DualSense
|
||||
motion sign/scale derived, not yet live-verified. **Rumble renderer rewritten
|
||||
(2026-07-02, `RumbleRenderer.swift`)** around "rumble is idempotent state, divergence
|
||||
must be bounded": the old per-datagram infinite-duration CoreHaptics players could leak
|
||||
one dropped async `stop` into a forever-buzzing motor (the stuck-rumble-after-menu bug)
|
||||
— now finite self-expiring segments with seamless engine-timeline re-arm, newest-wins
|
||||
dry drain of the 0xCA plane (was 1 datagram/8 ms), dedupe of the host's 500 ms state
|
||||
refreshes, zero-immediate/ramp-throttled rebakes, escalation to `engine.stop()` on a
|
||||
throwing player stop, and a 1.6 s staleness watchdog (`Policy.session`; the settings
|
||||
test panel uses `.manual` = hold). Controller engines use **plain `makePlayer` — never
|
||||
`makeAdvancedPlayer`**: the controller haptics server (gamecontrollerd) advertises
|
||||
`adv players: 0`, and iOS 27 beta 2 hard-drops advanced-player loads (XPC decode fault →
|
||||
CoreHaptics -4811/4097, rumble silently dead). Unit-tested (`RumbleTuningTests`);
|
||||
stuck-rumble repro on-glass revalidation pending. **Gamepad UI (iOS/iPadOS + macOS,
|
||||
2026-07-02 rework):** a connected pad swaps the home for a console-style launcher
|
||||
(`Home/Gamepad*` + `Settings/GamepadSettingsView`) — host carousel with a trailing Add
|
||||
Host tile (A connect · Y library · X settings · B back), a controller-navigable
|
||||
settings screen (vertical `GamepadMenuList`, left/right steps values), an add-host
|
||||
flow with an on-screen controller keyboard (`GamepadKeyboard` — no touch needed
|
||||
anywhere), and the coverflow library, all over an animated aurora backdrop
|
||||
(`GamepadScreenBackground`, TimelineView-driven drifting blobs — pure SwiftUI ON
|
||||
PURPOSE: a .metal lib only reliably bundles in one of the two build systems (SPM vs
|
||||
xcodeproj synced folders) these sources compile under). Input is the polled
|
||||
`GamepadMenuInput` (handlers don't fire outside a stream; on (re)start it SNAPSHOTS
|
||||
held buttons so a handoff press never double-fires), haptics dual-channel (device +
|
||||
`MenuHaptics` on the pad). macOS: same screens, settings/add-host as sheets (no
|
||||
fullScreenCover), NSScreen-based mode lists, scroll indicators `.never` (macOS
|
||||
"always show scroll bars" overrides `.hidden`); launcher/settings/add-host/keyboard
|
||||
render-verified live on this Mac via `PUNKTFUNK_FORCE_GAMEPAD_UI=1` (dev hook, forces
|
||||
the mode without a pad). Controller-in-hand on-glass validation still pending on all
|
||||
platforms. **Touch input (iOS/iPadOS, 2026-07-02):** a 3-way model in Settings —
|
||||
**Trackpad** (default; the Android client's gesture vocabulary ported 1:1 in
|
||||
`Input/TouchMouse.swift`: tap=click · two-finger tap=right-click · two-finger drag=scroll ·
|
||||
tap-then-drag=held drag · three-finger tap=HUD toggle, relative ballistics with the same
|
||||
px-based acceleration curve), **Direct pointer** (cursor jumps to the finger), **Touch
|
||||
passthrough** (the previous always-on behavior — real wire touches). Latched per gesture
|
||||
from `DefaultsKey.touchMode`; not yet on-glass validated. Tests: `swift test` in
|
||||
`clients/apple` (unit + real-codec round trip),
|
||||
`test-loopback.sh` (Swift client vs synthetic punktfunk1-hosts on loopback — runs on macOS;
|
||||
includes the pairing ceremony + `--require-pairing` gate),
|
||||
`RemoteFirstLightTests` (full pipeline over the LAN). See
|
||||
[`clients/apple/README.md`](clients/apple/README.md). **Stage 2 presenter is now the DEFAULT**
|
||||
(stage-1 is the Metal-unavailable / DEBUG fallback): explicit `VTDecompressionSession` decode →
|
||||
`CAMetalLayer`, presented from the hosting view's **main-runloop `CADisplayLink`** (`renderTick` pops
|
||||
the newest ready frame per vsync; macOS `displaySyncEnabled = false` is the real fullscreen-judder fix,
|
||||
~11 ms p50). *(An off-main `CAMetalDisplayLink` and an off-main blocking-render present thread were
|
||||
both tried and reverted — both measured slower on macOS and iPad.)* **HDR fixed**
|
||||
(`design/apple-stage2-presenter.md`): the "too bright" bug was a missing reference-white anchor — the
|
||||
fix keeps the PQ-passthrough shader and adds `CAEDRMetadata.hdr10(…, opticalOutputScale: 203)` +
|
||||
`wantsExtendedDynamicRangeContent` on the layer (on all platforms; the old `#if os(macOS)` guard left
|
||||
iOS/tvOS EDR half-engaged), routing the 0xCE mastering metadata to the layer (via `setHdrMeta`) instead
|
||||
of a never-composited source buffer. **Mid-session SDR↔HDR** is handled: `render` reconciles the layer
|
||||
per-frame from the decoded `frame.isHDR` (per-mode pixel format `bgra8`/`rgba16Float`), so a game
|
||||
entering HDR mid-stream just reconfigures (last 0xCE grade cached + re-applied; pump drains 0xCE
|
||||
unconditionally). **4:4:4 added**: decode format is a 2×2 `(chroma, HDR)` matrix
|
||||
(`420v/x420/444v/x444`, all biplanar so the shaders are unchanged), advertised (`VIDEO_CAP_444`) only
|
||||
behind a **hardware-required `VTDecompressionSession` probe** (`Stage444Probe`, validated on M3) with a
|
||||
Settings opt-out + a bounded pump backstop for an undecodable 4:4:4 session. *HDR brightness + 4:4:4
|
||||
still need on-glass validation (Windows-HDR / `PUNKTFUNK_444` host).* Next: glass-to-glass numbers via
|
||||
`tools/latency-probe`.
|
||||
**Linux stage 1 done, first light 2026-06-12** (`clients/linux`, binary
|
||||
`punktfunk-client`): GTK4/libadwaita shell linking `punktfunk-core` directly (no C ABI;
|
||||
`NativeClient` is now `Sync` — mutexed plane receivers), mDNS host list, TOFU + SPAKE2
|
||||
PIN dialogs (identity shared with client-rs), FFmpeg software HEVC decode (LOW_DELAY,
|
||||
slice threads) → `GtkGraphicsOffload`-wrapped picture, PipeWire playback (mic-player
|
||||
jitter ring inverted), SDL3 gamepad capture + rumble/lightbar feedback, keyboard via
|
||||
exact inverse of the host VK table, absolute mouse + 120-unit scroll. Validated live
|
||||
against `serve` on this box: 1080p60, steady 60 fps, capture→decoded p50
|
||||
≈6.4 ms (debug build). `--connect host[:port]` for scripting. **Swift-parity batch +
|
||||
stage 1.5 (2026-06-12 evening)**: capture state machine (click-to-capture,
|
||||
Ctrl+Alt+Shift+Q / focus-loss release, held-state flush), app-lifetime SDL gamepad
|
||||
service (pad pin UI, auto type from the physical pad, DualSense touchpad/motion 0xCC +
|
||||
raw-DS5-effects trigger/player-LED replay — needs a physical pad to live-verify), mic
|
||||
uplink (validated live), per-host speed test, compositor pref, native-display mode
|
||||
default, saved-hosts list, .deb + RPM-subpackage CI (deb.yml/rpm.yml). **VAAPI decode
|
||||
→ DRM-PRIME dmabuf → `GdkDmabufTexture`** (BT.709 color state; Tier-1 zero-copy on
|
||||
Intel/AMD, `PUNKTFUNK_DECODER=software|vaapi` override) with a proven fallback ladder —
|
||||
no VAAPI device (NVIDIA) or mid-session VAAPI error → software decode. **First AMD test
|
||||
(Steam Deck) hit a green-screen bug, fixed:** FFmpeg's VAAPI export uses
|
||||
`SEPARATE_LAYERS`, so NV12 arrives as two single-plane layers (R8 luma + GR88 chroma,
|
||||
one shared fd); the mapper took `layers[0]` only → GTK got a luma-only R8 texture, chroma
|
||||
read as 0 → green field / red whites. Fix derives the combined fourcc from the decoder
|
||||
`sw_format` (→ `DRM_FORMAT_NV12`) and flattens all planes across all layers (mpv's
|
||||
pattern); a first-frame descriptor dump logs the real layout. Awaiting Steam Deck
|
||||
reconfirm. Next: the stage-2 raw-Wayland
|
||||
presenter (wp_presentation feedback, tearing-control, Vulkan Video on NVIDIA) —
|
||||
**wgpu/winit rejected** (no dmabuf import / presentation feedback / shortcuts-inhibit).
|
||||
**Windows stage 1 done 2026-06-15** (`clients/windows`, binary
|
||||
`punktfunk-client`): pure-Rust **WinUI 3** UI via **windows-reactor** (a declarative React-like
|
||||
framework backed by WinUI; PR #4499 added the `SwapChainPanel` widget + `set_swap_chain`). The
|
||||
video is a **`SwapChainPanel`** bound to a **D3D11 composition swapchain**, presented from a
|
||||
**dedicated render thread** (`render.rs`, 2026-07-02 rewrite — presenting never touches or is
|
||||
stalled by the XAML thread): frame-latency-waitable swapchain + `SetMaximumFrameLatency(1)`
|
||||
(≤1 queued present, newest-wins drain after the wait, so a stream faster than the display drops
|
||||
backlog before any GPU work), **HiDPI-correct** (pixel-sized buffers + `SetMatrixTransform`
|
||||
96/DPI — DIP-sized buffers were blurry at 125/150 %), Contain-fit letterbox, WARP fallback.
|
||||
**FFmpeg decode with a D3D11VA hardware path on all vendors** (`gpu.rs` shares one D3D11 device
|
||||
between decoder + presenter, adapter picked by console pref `PUNKTFUNK_ADAPTER` > the window's
|
||||
monitor's adapter > default; `PUNKTFUNK_D3D_DEBUG=1` adds the debug layer): the decode pool is
|
||||
**decoder-only bind, sized/aligned by libavcodec itself** (get_format returns `AV_PIX_FMT_D3D11`
|
||||
and lets `hw_device_ctx` drive — three hand-built-frames-context strikes are why: NVIDIA rejects
|
||||
`DECODER|SHADER_RESOURCE` arrays, `BindFlags=0` fails texture creation, and Intel rejects
|
||||
non-128-aligned HEVC surfaces at the first `SubmitDecoderBuffers`), a DXVA **profile probe**
|
||||
before the hwdevice commits software-vs-hardware up front (no burned first IDR), and the
|
||||
presenter copies the decoded slice with ONE display-size-boxed `CopySubresourceRegion` (a planar
|
||||
slice is a single subresource in D3D11 — the old two-copy D3D12-style code silently no-opped =
|
||||
the black screen) into its sampleable NV12/P010 texture → per-plane SRVs + YUV→RGB shaders
|
||||
(NV12/BT.709, P010/BT.2020-PQ). **Software CPU decode is the fallback** (auto-selected,
|
||||
`DecoderPref` override, mid-session demotion + keyframe re-request) and now feeds the SAME
|
||||
shaders (swscale → NV12/P010 planes → two dynamic plane textures) so hw/sw colour math is
|
||||
identical. **HDR10**: the client advertises 10-bit/HDR (Settings toggle, gated on an HDR
|
||||
display), detects PQ in-band (`transfer == SMPTE2084`), and flips the swapchain to
|
||||
`R10G10B10A2` + ST.2084 with HDR10 metadata (0xCE mastering metadata plumbed). **WASAPI** render
|
||||
+ mic capture, **SDL3** gamepads (rumble/lightbar/DualSense), `mdns-sd` discovery, and the full
|
||||
trust surface — all **in-app**: a polished WinUI shell (host tiles w/ monogram + status pills,
|
||||
`InfoBar` errors/hints, `ToggleSwitch` settings, status-chip stream HUD showing GPU/CPU decode +
|
||||
HDR), host list (live mDNS + saved + manual), settings (resolution/refresh/decoder/bitrate/HDR/
|
||||
mic), SPAKE2 PIN pairing screen, TOFU, pinned-fp-mismatch re-pair. **Live-validated 2026-07-02
|
||||
on the hybrid laptop (Intel Arc Pro iGPU + RTX 3500 Ada) against the local Windows host**:
|
||||
D3D11VA hardware decode 60 fps on BOTH vendors (headless, `PUNKTFUNK_ADAPTER`-forced; NVIDIA
|
||||
0.2 ms decode, Intel 0.2 ms), software path, and the GUI on glass (real decoded desktop pixels,
|
||||
GPU-decode HUD chip, ~18 ms capture→decoded p50 over loopback — dominated by the host's 60 Hz
|
||||
virtual-display capture cadence). HDR-on-glass still pending. **Stream input** is Win32 low-level hooks (`WH_KEYBOARD_LL`/`WH_MOUSE_LL`) — reactor
|
||||
exposes no raw key/pointer events; native Windows VK + absolute mouse (client-rect Contain-fit) +
|
||||
wheel, Ctrl+Alt+Shift+Q capture toggle. `--headless`/`--discover` keep CLI paths. Builds + clippy
|
||||
+ fmt green on **`x86_64-pc-windows-msvc` and `aarch64-pc-windows-msvc`** — the latter
|
||||
**cross-compiled off the one x64 runner** (no ARM64 runner; the x64 MSVC toolset's ARM64 cross
|
||||
compiler + a per-arch `FFMPEG_DIR` ARM64 tree, SDL3/libopus build-from-source cross-compile
|
||||
cleanly), and both ship as signed MSIX (`windows-msix.yml` matrix → `..._x64.msix`/`..._arm64.msix`,
|
||||
verified: ARM64 binaries + manifest arch). **windows-reactor is unpublished** (git
|
||||
dep pinned to commit `a4f7b2cb`, bumped 2026-07-02 from `b4129fcc` for `on_pointer_entered`/
|
||||
`on_pointer_exited` hover events — mechanical renames only: `SymbolGlyph`→`Symbol`,
|
||||
`placeholder`→`placeholder_text`, TextBox `on_changed`→`on_text_changed`, ToggleSwitch
|
||||
`on_changed`→`on_toggled`, `on_menu_item_clicked`→`on_item_clicked`, SwapChainPanel
|
||||
`on_ready`→`on_mounted`; `windows` pinned to the SAME commit so `IDXGISwapChain1` unifies with
|
||||
`set_swap_chain`). New-model runtime staging: reactor has NO build.rs anymore — the app's own
|
||||
`build.rs` calls `windows_reactor_setup::as_framework_dependent()` (same-rev build-dep, stages
|
||||
the bootstrap DLL + resources.pri that pack-msix expects) and `main` calls
|
||||
`windows_reactor::bootstrap()` before `App` (packaged MSIX: a no-op, the manifest's
|
||||
`Microsoft.WindowsAppRuntime.2` dependency resolves the runtime). `CARGO_WORKSPACE_DIR` is no
|
||||
longer required (harmless where still set). Gotcha: `CARGO_HOME` must be an ASCII path
|
||||
— the `ü` in the dev box's username breaks SDL3's MSVC precompiled-header build. **Parity/cleanup
|
||||
batch (2026-07-02)**: `app.rs` split into per-screen `app/` modules (mod=root/router · hosts ·
|
||||
connect · pair · speed · settings · licenses · stream · style; thread-driven state lives in ROOT
|
||||
`use_async_state` and flows down as props — a child's own async-state write does NOT re-render it);
|
||||
"Native display" now resolves the real monitor mode at connect (`MonitorFromWindow` →
|
||||
`EnumDisplaySettingsW`, was hardcoded 1080p60); per-host **speed test** (saved-host card button +
|
||||
`--headless --speed-test`, probe burst → recommended ≈70 % bitrate applied in one tap; bitrate
|
||||
setting is now a free-form NumberBox); **forget host** (ContentDialog confirm →
|
||||
`KnownHosts::remove_by_fp`); settings gained forwarded-controller picker + gamepad type + host
|
||||
compositor + capture-system-shortcuts — the previously-dead `Settings.compositor`/
|
||||
`inhibit_shortcuts` are now honored (off = Alt+Tab/Alt+Esc/Ctrl+Esc/Win act locally);
|
||||
**click-to-recapture** after a Ctrl+Alt+Shift+Q release with the HUD hint tracking capture state;
|
||||
input hook caches lock geometry (no per-move `GetClientRect`), audio jitter-ring trims via
|
||||
`drain`. Validated on the bare-metal RTX box: `--discover` (3 live LAN hosts), synthetic-host
|
||||
loopback E2E (TOFU connect → clock skew → HEVC negotiate → shared-D3D11 + D3D11VA init → WASAPI →
|
||||
session end; synthetic payload isn't decodable so decode output stays unvalidated), speed-test
|
||||
E2E. The WinUI window itself CANNOT be launched from SSH (session-0 → WinAppSDK 0x80070005,
|
||||
pre-existing; needs the console session, e.g. PsExec -i 1). **UX batch (2026-07-02 evening,
|
||||
UIA-smoke-tested on the hybrid laptop)**: host tiles get the WinUI pointer-over fill
|
||||
(`on_pointer_entered`/`exited` → root hover state → `ControlFillSecondary`), Settings is a stock
|
||||
**NavigationView** sidebar (Windows-Settings pattern: Display/Video/Input/Audio/About panes,
|
||||
built-in back arrow, section in root state; the section card is **keyed by section** — an
|
||||
in-place diff across sections re-sets a reused ComboBox's items, clearing WinUI's selection,
|
||||
but skips `selected_index` when the values compare equal → blank selection; the key forces a
|
||||
remount — and the content column rides its own section-switch slide-up tween), new
|
||||
**"Show the stats overlay (HUD)"** toggle
|
||||
(`Settings::show_hud`, applies mid-stream via the 400 ms HUD re-render), the Add-host modal
|
||||
slides up + fades in (root margin/opacity tween, same pattern as screen navigation), and a
|
||||
self-initiated disconnect (Ctrl+Alt+Shift+D → `Ended(None)`) returns to the host list silently
|
||||
instead of raising the error banner.
|
||||
Next: **HDR on-glass validation** (Windows host with `PUNKTFUNK_10BIT` → the HDR laptop
|
||||
display), then RAWINPUT relative-mouse pointer-lock.
|
||||
**Android stage 1 done** (`clients/android`, Kotlin app + `native/` Rust JNI core linking
|
||||
`punktfunk-core`; phone + Android TV): NDK `AMediaCodec` hardware HEVC decode → `SurfaceView` incl.
|
||||
**HDR10** (Main10/BT.2020 PQ) with low-latency tuning + a live stats HUD (`decode.rs`/`stats.rs`),
|
||||
Opus/AAudio audio + mic uplink (`audio.rs`/`mic.rs`), gamepad input with rumble/HID feedback
|
||||
(`feedback.rs`), **native `mdns-sd` mDNS discovery** (`discovery.rs`, polled over JNI — the same
|
||||
browse the Linux/Windows clients use, replacing the flaky per-OEM `NsdManager`; Kotlin keeps only
|
||||
the `MulticastLock` + permission UX), SPAKE2 PIN pairing + TOFU (Keystore identity +
|
||||
known-host store), Compose UI (Connect/Settings/Stream) with D-pad/controller focus nav. Built for
|
||||
`arm64-v8a` + `x86_64`; published to Google Play (Internal Testing) via `android.yml`
|
||||
(`ci/play-upload.py`). Touch input is the same 3-way model as iOS (2026-07-02): the existing
|
||||
Trackpad/Direct mouse modes plus new **real multi-touch passthrough**
|
||||
(`streamTouchPassthrough` → `nativeSendTouch` → wire TouchDown/Move/Up), a `TouchMode`
|
||||
Settings dropdown replacing the old trackpad Boolean (migrated on load); not yet
|
||||
on-device validated. Next: real-device gamepad/HDR live-verify, presenter/latency polish.
|
||||
2. **Sub-frame pipelining**: overlap encode and transmit within a frame. Requires a direct
|
||||
NVENC SDK wrapper (libavcodec only emits whole AUs) — the next big latency lever (~2–4 ms
|
||||
at high res).
|
||||
3. **punktfunk/1 protocol growth.** **Done:** unified host (`serve --gamestream` runs GameStream + the
|
||||
punktfunk/1 QUIC host in one process; bare `serve` is the secure native-only default — GameStream is
|
||||
opt-in, trusted-LAN only, security-review #5/#9) with native pairing driven over the mgmt API /
|
||||
web console (`mod native_pairing`: arm-on-demand → display PIN, paired-device list).
|
||||
**Done:** PIN pairing is the default, host-gated — the host requires pairing and advertises
|
||||
`pair=required` unless opted out with `--allow-tofu`/`--open` (then `pair=optional`, accepts
|
||||
unpaired clients); clients render TOFU only for a `pair=optional` host and force re-pairing on a
|
||||
fingerprint change. **Done:** concurrent sessions — the accept loop spawns each session
|
||||
(`--max-concurrent`, default 4, an NVENC bound), each with its own virtual output + encoder, sharing
|
||||
the host-lifetime input/audio/mic services (shared-desktop multi-view on kwin/mutter/wlroots).
|
||||
**Done:** delegated pairing approval (§8b-1) — an unpaired device shows up as a pending request in
|
||||
the web console, one click approves + pins it. Next (see roadmap): gamescope multi-user isolation
|
||||
(per-session input/audio = independent desktops); §8b-2 peer-push approval from a paired device's
|
||||
own app.
|
||||
4. **GameStream host polish**: HDR/10-bit (needs HDR capture + metadata plumbing; `av1_nvenc
|
||||
-highbitdepth 1` already encodes Main10 from 8-bit input on this box),
|
||||
reconnect-at-new-mode robustness. AV1 negotiation and surround audio are implemented
|
||||
and unit/live-capture tested — both still need a live Moonlight confirmation (select
|
||||
AV1 in a stock client; a real 5.1/7.1 listen incl. FEC under loss).
|
||||
|
||||
Box one-time setup is complete: udev rule + `input` group (gamepads validated live),
|
||||
gamescope 3.16.22 installed system-wide (no PATH override), gnome-shell installed (Mutter
|
||||
backend validated live). All three compositor backends are live-validated.
|
||||
|
||||
## Build / test / run
|
||||
|
||||
```sh
|
||||
cargo build --workspace # green on Linux and macOS
|
||||
cargo test --workspace # unit + loopback + proptest + C ABI harness (~100 tests)
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo fmt --all --check
|
||||
|
||||
cargo run -p loss-harness # FEC loss-resilience sweep (no network needed)
|
||||
bash crates/punktfunk-core/tests/c/run.sh # standalone C-ABI link + round-trip proof
|
||||
```
|
||||
|
||||
Generated artifacts are **checked in** and CI fails on drift: `include/punktfunk_core.h`
|
||||
(cbindgen from `punktfunk-core/src/abi.rs`) and `api/openapi.json` (regenerate with
|
||||
`cargo run -p punktfunk-host -- openapi > api/openapi.json`; spec lives in `mgmt.rs`).
|
||||
|
||||
CI is Gitea Actions (`.gitea/workflows/`, guide: docs-site `ci.md`): `ci.yml` runs the
|
||||
workspace checks inside the `git.unom.io/unom/punktfunk-rust-ci` image plus web/docs-site
|
||||
build+typecheck; `docker.yml` builds+pushes the web/docs/rust-ci images (host and native
|
||||
clients are deliberately NOT containerized); `apple.yml` builds the xcframework and runs
|
||||
`swift build`/`swift test` on the `macos-arm64` host-mode runner (home-mac-mini-1,
|
||||
provisioned by `scripts/ci/setup-macos-runner.sh`). Per-client/host release workflows:
|
||||
`deb.yml`/`rpm.yml`/`flatpak.yml` (Linux client), `android.yml` (Google Play), `windows-msix.yml`
|
||||
(Windows client), `windows-host.yml` (Windows host installer), `release.yml` (Apple notarized DMG +
|
||||
TestFlight), `decky.yml` (Steam Deck plugin); Windows builds run on a self-hosted Windows runner
|
||||
(`home-windows-runner-1`, vmid 210, `windows-amd64:host` label). The runner is reproducible and
|
||||
**owned by `unom/infra`**, not this repo, since it's shared across unom Windows projects going
|
||||
forward: `unom/infra`'s `windows-runner/` Packer template bakes a generic Windows 11 template (OS
|
||||
install + OpenSSH Server + VS Build Tools/NASM/CMake/LLVM + the act_runner/Node/rustup base, no
|
||||
registration) on Proxmox once; `proxmox/windows-runner/` (Terraform, `bpg/proxmox`) full-clones it
|
||||
(agent-based IP discovery, no pre-provisioned DHCP reservation needed) and registers the instance
|
||||
over SSH remote-exec — the same bake-once/clone-fast split `proxmox/unom-1` uses for the Linux CI
|
||||
host, just without a native Windows cloud-init (registration goes over `remote-exec`/SSH instead of
|
||||
`initialization{}`; WinRM was tried first but is deprecated in OpenTofu, so this moved to SSH via
|
||||
Windows' in-box OpenSSH Server). punktfunk layers its own extras on top of that generic runner:
|
||||
`scripts/ci/provision-windows-wdk.ps1` (WDK + cargo-wdk for the UMDF drivers) and
|
||||
`scripts/ci/provision-windows-punktfunk-extras.ps1` (FFmpeg x64/ARM64 trees, Inno Setup, the
|
||||
`aarch64-pc-windows-msvc` rustup target) — both idempotent, and both run automatically at the start
|
||||
of every Windows CI job via the shared `scripts/ci/ensure-windows-toolchain.ps1` step (a fast no-op
|
||||
once already provisioned), rather than a separate manually-dispatched provisioning workflow — that
|
||||
avoided a real footgun once there could be more than one `windows-amd64` runner: a manually
|
||||
dispatched provisioning workflow has no way to target a *specific* runner instance, so it could
|
||||
land on an already-provisioned box instead of the one that actually needed it.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
crates/punktfunk-core/ protocol · FEC · crypto · quic (punktfunk/1 control plane, feature-gated)
|
||||
crates/punktfunk-host/
|
||||
gamestream/ Moonlight compat: nvhttp · pairing · rtsp · control · stream · gamepad · apps
|
||||
vdisplay/linux/{kwin,gamescope,mutter,wlroots}.rs per-compositor client-sized virtual outputs
|
||||
vdisplay/windows/{pf_vdisplay,manager,identity}.rs all-Rust IddCx virtual display (pf-vdisplay)
|
||||
linux/zerocopy/{egl,cuda,vulkan}.rs dmabuf → CUDA → NVENC (tiled via EGL/GL, LINEAR via Vulkan)
|
||||
inject/linux/{libei,wlr,gamepad,dualsense,dualshock4,steam_*}.rs Linux input (uinput xpad · UHID pads · virtual Deck)
|
||||
inject/windows/{sendinput,gamepad_windows,dualsense_windows,dualshock4_windows}.rs Windows input (UMDF shared-mem pads)
|
||||
encode/linux/{mod,vaapi}.rs · encode/windows/{nvenc,ffmpeg_win}.rs · encode/sw.rs per-GPU encoders (NVENC/CUDA · VAAPI · AMF/QSV) + GPU-less openh264
|
||||
capture/{linux/,windows/{dxgi,idd_push}}.rs · audio/{linux/,windows/wasapi_*}.rs
|
||||
windows/{service,install,interactive}.rs SCM service + in-binary driver/web install
|
||||
capture.rs · encode.rs · audio.rs · gpu.rs · spike.rs · punktfunk1.rs · mgmt.rs · native_pairing.rs · stats_recorder.rs · library.rs
|
||||
clients/probe/ punktfunk/1 reference/probe client (headless test/measurement tool)
|
||||
clients/linux/ native Linux client (GTK4/libadwaita · FFmpeg · PipeWire · SDL3)
|
||||
clients/windows/ native Windows client (WinUI 3 via windows-reactor · D3D11 · WASAPI · SDL3)
|
||||
clients/apple/ native macOS/iOS/tvOS client (Swift · VideoToolbox · GameController)
|
||||
clients/android/ native Android client (Kotlin app + native/ Rust JNI core over punktfunk-core)
|
||||
clients/decky/ Steam Deck Decky plugin
|
||||
packaging/windows/drivers/{pf-vdisplay,pf-dualsense,pf-xusb}/ in-house UMDF drivers (built from source in CI)
|
||||
web/ TanStack web console over the mgmt API (status · devices · pairing · GPU selection · performance graphs)
|
||||
packaging/ apt(deb) · RPM/COPR · Arch/sysext · Flatpak · Bazzite bootc · Windows host installer (per-dir READMEs)
|
||||
tools/{loss-harness,latency-probe}/ measurement (plan §10)
|
||||
scripts/ 60-punktfunk.rules · punktfunk-host.service · host.env.example · headless/
|
||||
include/punktfunk_core.h generated C header
|
||||
```
|
||||
|
||||
## Design invariants — do not regress
|
||||
|
||||
- **One core, linked everywhere.** Protocol/FEC/crypto live only in `punktfunk-core`, behind a
|
||||
stable, versioned C ABI. `tokio`/`quinn` exist only behind the `quic` feature (control
|
||||
plane); **no async on the per-frame path** — native threads only.
|
||||
- **Native client resolution, no scaling.** A session gets a virtual output at exactly the
|
||||
client's WxH@Hz via the `VirtualDisplay` trait (`create(mode) → VirtualOutput { node_id,
|
||||
remote_fd, preferred_mode, keepalive }`, RAII teardown). There is no cross-compositor
|
||||
protocol for this — each compositor keeps its own backend.
|
||||
- **FEC is the wall-breaker.** GF(2⁸) (≤255 shards/block, Moonlight-compatible) and GF(2¹⁶)
|
||||
Leopard (≤65535 shards/block) — punktfunk/1 negotiates the latter, removing the ~1 Gbps
|
||||
ceiling.
|
||||
- **Core security hardening stays intact**: reassembler bounds attacker-controlled fields
|
||||
before allocating (`ReassemblerLimits`); AES-GCM per-direction nonce salts + seq-as-AAD;
|
||||
ABI `struct_size` checks. Regression tests exist — keep them green.
|
||||
- **PipeWire consumer discipline**: our capture streams set `node.dont-reconnect` and tear
|
||||
down promptly on negotiation timeout — one wedged link head-blocks the daemon's shared
|
||||
work queue system-wide.
|
||||
|
||||
## Running on this box
|
||||
|
||||
Headless QEMU VM (Ubuntu 26.04, kernel 7.0), passthrough RTX 5070 Ti (driver 595 **open**
|
||||
module — a kernel update silently drops it; reinstall `nvidia-driver-595-open`), no KMS
|
||||
scanout → KWin `--drm` impossible; everything renders offscreen via `renderD128`.
|
||||
|
||||
```sh
|
||||
# compositor session (shell 1, or the systemd unit in scripts/): full headless Plasma.
|
||||
# The script sets XDG_MENU_PREFIX=plasma- & co. — without it plasmashell runs but the
|
||||
# launcher menu is EMPTY (no apps, no System Settings).
|
||||
bash scripts/headless/run-headless-kde.sh 1920x1080
|
||||
|
||||
# host (shell 2): bare `serve` is native-only (secure default); add --gamestream for Moonlight compat.
|
||||
WAYLAND_DISPLAY=wayland-kde XDG_CURRENT_DESKTOP=KDE PUNKTFUNK_VIDEO_SOURCE=virtual \
|
||||
PUNKTFUNK_ZEROCOPY=1 cargo run -rp punktfunk-host -- serve --gamestream
|
||||
|
||||
# punktfunk/1 native loopback test (no Moonlight needed; same env as serve, listener persists
|
||||
# across sessions — bound it with --max-sessions):
|
||||
cargo run -rp punktfunk-host -- punktfunk1-host --source virtual --seconds 10 --max-sessions 1
|
||||
cargo run -rp punktfunk-probe -- --mode 1280x720x120 --out /tmp/a.h265 --input-test # + --pin HEX
|
||||
```
|
||||
|
||||
Pinned crate facts: `ashpd` 0.13 + `pipewire` 0.9 (must match ashpd's) + `ffmpeg-next` 8.x
|
||||
(`ffmpeg-sys-next` auto-detects the system FFmpeg, so it builds against **FFmpeg 7.x/libavcodec 61
|
||||
or 8.x/libavcodec 62** — validated live on Ubuntu 26.04 (8) and Bazzite F43 (7.1); the zero-copy
|
||||
FFI also link-needs `libGL`/`libgbm`/`libcuda` at build time). Env knobs: `PUNKTFUNK_VIDEO_SOURCE=virtual|portal`,
|
||||
`PUNKTFUNK_COMPOSITOR=kwin|gamescope|mutter`, `PUNKTFUNK_ZEROCOPY=1|0` (Linux default: ON for
|
||||
VAAPI/AMD/Intel with a one-shot CPU downgrade if the dmabuf offer never negotiates, OFF/opt-in for
|
||||
NVENC), `PUNKTFUNK_VAAPI_LOW_POWER=1|0` (pin the VAAPI entrypoint; auto = full-feature then VDEnc
|
||||
fallback for modern Intel), `PUNKTFUNK_NV12=0` (opt OUT of the default GPU RGB→NV12 convert on the
|
||||
NVIDIA tiled zero-copy path), `PUNKTFUNK_INTRA_REFRESH=1` (opt-in NVENC intra-refresh loss recovery),
|
||||
`PUNKTFUNK_PIN_CLOCKS=1` (opt-in NVML GPU clock floor, root-gated), `PUNKTFUNK_GAMESCOPE_APP=...`,
|
||||
`PUNKTFUNK_INPUT_BACKEND=...`, `PUNKTFUNK_PERF=1` (per-stage timing), `PUNKTFUNK_VIDEO_DROP=N` (FEC
|
||||
test — injects N% wire-packet loss on BOTH the GameStream and native video paths, no netem needed), `PUNKTFUNK_FEC_PCT=N`, `PUNKTFUNK_DSCP=1` (opt-in DSCP/SO_PRIORITY media QoS on the data +
|
||||
GameStream video/audio sockets; no-op on the wire on Windows without a qWAVE policy),
|
||||
`PUNKTFUNK_444=1` (full-chroma HEVC 4:4:4, see below).
|
||||
|
||||
**HEVC 4:4:4 (full chroma, Range Extensions)**: opt-in via `PUNKTFUNK_444`, negotiated like 10-bit —
|
||||
the host emits 4:4:4 only when the client advertised `VIDEO_CAP_444` (wire bit `0x04` + ABI
|
||||
`PUNKTFUNK_VIDEO_CAP_444`), the codec is HEVC, the session is single-process, **and** a GPU probe
|
||||
(`encode::can_encode_444`, run before the Welcome) confirms support — else it resolves to 4:2:0 and
|
||||
`Welcome::chroma_format` reflects the real value (honest downgrade; the client reads it via
|
||||
`punktfunk_connection_chroma_format`). **punktfunk/1-native only** — GameStream/Moonlight stays 4:2:0
|
||||
(stock clients can't decode 4:4:4). **NVENC is the implemented path**: Linux `hevc_nvenc` feeds a
|
||||
swscale'd `yuv444p` (RGB-in is always 4:2:0 — verified on the RTX 5070 Ti — so the session forces CPU
|
||||
RGB capture for 4:4:4); Windows NVENC keeps ARGB input + FREXT profile + `chromaFormatIDC=3` and the
|
||||
DDA capturer delivers RGB. VAAPI / AMF / QSV **decline** (probe returns false — no validated 4:4:4
|
||||
hardware in the lab; they'd produce 4:2:0). Software (openh264) is 4:2:0-only. Test with
|
||||
`PUNKTFUNK_CLIENT_444=1 punktfunk-probe --out x.h265` then `ffprobe x.h265` (expect `pix_fmt yuv444p`).
|
||||
*Linux NVENC mechanism validated on the RTX 5070 Ti (ffmpeg CLI); Windows NVENC + 10-bit-4:4:4 not yet
|
||||
on-glass validated.*
|
||||
|
||||
## Conventions
|
||||
|
||||
- Rust 2021, `rustfmt` + `clippy -D warnings` clean before commit.
|
||||
- Match the surrounding code's comment density and naming.
|
||||
- Commit messages end with the Co-Authored-By trailer (see `git log`).
|
||||
- `pkill` caution on this box: match exact comm names (`pkill -x gamescope-wl`,
|
||||
`pkill -x punktfunk-host`) — `pkill -f` self-matches the invoking shell.
|
||||
+3
-1
@@ -40,4 +40,6 @@ Generated artifacts are checked in and CI fails on drift: `include/punktfunk_cor
|
||||
`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`).
|
||||
|
||||
See [`CLAUDE.md`](CLAUDE.md) for the full build/test/run guide and design invariants.
|
||||
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.
|
||||
|
||||
Generated
+498
-26
@@ -228,6 +228,67 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-executor"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
|
||||
dependencies = [
|
||||
"async-task",
|
||||
"concurrent-queue",
|
||||
"fastrand",
|
||||
"futures-lite",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-io"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"futures-io",
|
||||
"futures-lite",
|
||||
"parking",
|
||||
"polling",
|
||||
"rustix",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-lock"
|
||||
version = "3.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-process"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-signal",
|
||||
"async-task",
|
||||
"blocking",
|
||||
"cfg-if",
|
||||
"event-listener",
|
||||
"futures-lite",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-recursion"
|
||||
version = "1.1.1"
|
||||
@@ -239,6 +300,30 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-signal"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
|
||||
dependencies = [
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"atomic-waker",
|
||||
"cfg-if",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"rustix",
|
||||
"signal-hook-registry",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-task"
|
||||
version = "4.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
@@ -387,6 +472,8 @@ dependencies = [
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools 0.13.0",
|
||||
"log",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
@@ -434,6 +521,19 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blocking"
|
||||
version = "1.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-task",
|
||||
"futures-io",
|
||||
"futures-lite",
|
||||
"piper",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
@@ -791,9 +891,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.18"
|
||||
version = "0.9.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
@@ -1046,6 +1146,9 @@ name = "fastrand"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fec-rs"
|
||||
@@ -1095,6 +1198,16 @@ dependencies = [
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
@@ -1123,6 +1236,7 @@ version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"spin",
|
||||
@@ -1149,6 +1263,15 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fragile"
|
||||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8878864ba14bb86e818a412bfd6f18f9eabd4ec0f008a28e8f7eb61db532fcf9"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs-err"
|
||||
version = "3.3.0"
|
||||
@@ -1854,6 +1977,16 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "if-addrs"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "if-addrs"
|
||||
version = "0.15.0"
|
||||
@@ -2002,9 +2135,26 @@ dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ksni"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da9eeb3f510b6148ae68f963af2c1fbb0de4d9e4e05f82813cfb319837c3ad2b"
|
||||
dependencies = [
|
||||
"async-executor",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"futures-channel",
|
||||
"futures-lite",
|
||||
"futures-util",
|
||||
"pastey",
|
||||
"serde",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.5.1"
|
||||
version = "0.9.1"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2080,7 +2230,7 @@ dependencies = [
|
||||
"cookie-factory",
|
||||
"libc",
|
||||
"libspa-sys",
|
||||
"nix",
|
||||
"nix 0.30.1",
|
||||
"nom 8.0.0",
|
||||
"system-deps",
|
||||
]
|
||||
@@ -2136,7 +2286,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.5.1"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2147,6 +2297,16 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "mac_address"
|
||||
version = "1.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303"
|
||||
dependencies = [
|
||||
"nix 0.29.0",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
@@ -2170,7 +2330,7 @@ checksum = "fb75febbe5fa1837a52fdbd1c735e168286c5c645fc2ddd31526f65c49941c2e"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"flume",
|
||||
"if-addrs",
|
||||
"if-addrs 0.15.0",
|
||||
"log",
|
||||
"mio",
|
||||
"socket-pktinfo",
|
||||
@@ -2247,8 +2407,6 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"jni-sys 0.3.1",
|
||||
@@ -2268,6 +2426,19 @@ dependencies = [
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"memoffset",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.30.1"
|
||||
@@ -2561,6 +2732,12 @@ version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pastey"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.6"
|
||||
@@ -2586,6 +2763,42 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
"mdns-sd",
|
||||
"opus",
|
||||
"pf-ffvk",
|
||||
"pipewire",
|
||||
"punktfunk-core",
|
||||
"rustls",
|
||||
"sdl3",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tracing",
|
||||
"ureq",
|
||||
"wasapi",
|
||||
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
"pf-client-core",
|
||||
"pf-presenter",
|
||||
"punktfunk-core",
|
||||
"sdl3",
|
||||
"skia-safe",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-driver-proto"
|
||||
version = "0.0.1"
|
||||
@@ -2593,12 +2806,47 @@ dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
"async-channel",
|
||||
"pf-client-core",
|
||||
"pf-ffvk",
|
||||
"punktfunk-core",
|
||||
"sdl3",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "piper"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"fastrand",
|
||||
"futures-io",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pipewire"
|
||||
version = "0.9.2"
|
||||
@@ -2610,7 +2858,7 @@ dependencies = [
|
||||
"libc",
|
||||
"libspa",
|
||||
"libspa-sys",
|
||||
"nix",
|
||||
"nix 0.30.1",
|
||||
"once_cell",
|
||||
"pipewire-sys",
|
||||
"thiserror 2.0.18",
|
||||
@@ -2654,6 +2902,20 @@ version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||
|
||||
[[package]]
|
||||
name = "polling"
|
||||
version = "3.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"hermit-abi",
|
||||
"pin-project-lite",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
@@ -2690,13 +2952,23 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||
dependencies = [
|
||||
"toml_edit",
|
||||
"toml_edit 0.25.12+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2729,7 +3001,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.5.1"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -2737,35 +3009,46 @@ dependencies = [
|
||||
"log",
|
||||
"mdns-sd",
|
||||
"ndk",
|
||||
"ndk-sys",
|
||||
"opus",
|
||||
"punktfunk-core",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.5.1"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
"gtk4",
|
||||
"libadwaita",
|
||||
"mdns-sd",
|
||||
"opus",
|
||||
"pipewire",
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
"rustls",
|
||||
"sdl3",
|
||||
"serde",
|
||||
"relm4",
|
||||
"serde_json",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
"pf-console-ui",
|
||||
"pf-presenter",
|
||||
"punktfunk-core",
|
||||
"serde_json",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"winresource",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.5.1"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -2773,6 +3056,7 @@ dependencies = [
|
||||
"ffmpeg-next",
|
||||
"mdns-sd",
|
||||
"opus",
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
"sdl3",
|
||||
"serde",
|
||||
@@ -2788,7 +3072,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.5.1"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -2796,6 +3080,7 @@ dependencies = [
|
||||
"criterion",
|
||||
"fec-rs",
|
||||
"hmac",
|
||||
"if-addrs 0.13.4",
|
||||
"libc",
|
||||
"opus",
|
||||
"proptest",
|
||||
@@ -2818,7 +3103,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.5.1"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -2836,10 +3121,12 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"if-addrs 0.13.4",
|
||||
"khronos-egl",
|
||||
"libc",
|
||||
"libloading",
|
||||
"log",
|
||||
"mac_address",
|
||||
"mdns-sd",
|
||||
"nvidia-video-codec-sdk",
|
||||
"openh264",
|
||||
@@ -2881,13 +3168,14 @@ dependencies = [
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"windows-service",
|
||||
"winreg",
|
||||
"winresource",
|
||||
"x509-parser",
|
||||
"xkbcommon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.5.1"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -2899,6 +3187,23 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
"libc",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"ureq",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"windows-service",
|
||||
"winresource",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "1.2.3"
|
||||
@@ -3169,6 +3474,41 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "relm4"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6420f090f0545e9ec9656469d139a4e1b66ff9c30b808fe2247892724f71a198"
|
||||
dependencies = [
|
||||
"flume",
|
||||
"fragile",
|
||||
"futures",
|
||||
"gtk4",
|
||||
"libadwaita",
|
||||
"once_cell",
|
||||
"relm4-css",
|
||||
"relm4-macros",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "relm4-css"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3b81d263f784b103c815afa29124486b59741eca069ce7a5999efb14f13c368"
|
||||
|
||||
[[package]]
|
||||
name = "relm4-macros"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36c9dbf50a60c82375e66b61d522c936b187a11b25c0a42e91c516326ad24a4f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -3489,6 +3829,7 @@ version = "0.6.6+SDL-3.4.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04e7f134def04ed72e6f55187c6c29c72f7dab5d359c4be0dd49c9b97fef59c7"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"cc",
|
||||
"cmake",
|
||||
"pkg-config",
|
||||
@@ -3611,6 +3952,15 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.1"
|
||||
@@ -3696,6 +4046,35 @@ version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
|
||||
|
||||
[[package]]
|
||||
name = "skia-bindings"
|
||||
version = "0.87.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "704242769235d2ffe66a2a0a3002661262fc4af08d32807c362d7b0160ee703c"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cc",
|
||||
"flate2",
|
||||
"heck",
|
||||
"lazy_static",
|
||||
"pkg-config",
|
||||
"regex",
|
||||
"serde_json",
|
||||
"tar",
|
||||
"toml 0.8.23",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "skia-safe"
|
||||
version = "0.87.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f7d94f3e7537c71ad4cf132eb26e3be8c8a886ed3649c4525c089041fc312b2"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"lazy_static",
|
||||
"skia-bindings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@@ -3831,6 +4210,17 @@ dependencies = [
|
||||
"version-compare",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.13.5"
|
||||
@@ -4016,6 +4406,18 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_edit 0.22.27",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.9.12+spec-1.1.0"
|
||||
@@ -4024,7 +4426,7 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"serde_spanned 1.1.1",
|
||||
"toml_datetime 0.7.5+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
@@ -4039,13 +4441,22 @@ checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"serde_spanned 1.1.1",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow 1.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.7.5+spec-1.1.0"
|
||||
@@ -4064,6 +4475,20 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_write",
|
||||
"winnow 0.7.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.25.12+spec-1.1.0"
|
||||
@@ -4085,6 +4510,12 @@ dependencies = [
|
||||
"winnow 1.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_write"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
@@ -4601,6 +5032,22 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
@@ -4610,6 +5057,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.2"
|
||||
@@ -5106,6 +5559,9 @@ name = "winnow"
|
||||
version = "0.7.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
@@ -5165,6 +5621,16 @@ dependencies = [
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xkbcommon"
|
||||
version = "0.8.0"
|
||||
@@ -5221,8 +5687,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285"
|
||||
dependencies = [
|
||||
"async-broadcast",
|
||||
"async-executor",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-process",
|
||||
"async-recursion",
|
||||
"async-task",
|
||||
"async-trait",
|
||||
"blocking",
|
||||
"enumflags2",
|
||||
"event-listener",
|
||||
"futures-core",
|
||||
|
||||
+21
-2
@@ -4,19 +4,38 @@ members = [
|
||||
"crates/punktfunk-core",
|
||||
"crates/punktfunk-host",
|
||||
"crates/punktfunk-host/vendor/usbip-sim",
|
||||
"crates/punktfunk-tray",
|
||||
"crates/pf-client-core",
|
||||
"crates/pf-presenter",
|
||||
"crates/pf-console-ui",
|
||||
"crates/pf-ffvk",
|
||||
"crates/pf-driver-proto",
|
||||
"clients/probe",
|
||||
"clients/linux",
|
||||
"clients/session",
|
||||
"clients/windows",
|
||||
"clients/android/native",
|
||||
"tools/latency-probe",
|
||||
"tools/loss-harness",
|
||||
]
|
||||
# Standalone PoC (built on its own; pulls usbip/tokio/libusb we don't want in the workspace).
|
||||
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
|
||||
# The vendored `ndk` is a [patch.crates-io] source, not a member: it only compiles for the
|
||||
# `*-linux-android` targets, so workspace membership would break host `cargo build --workspace`.
|
||||
exclude = [
|
||||
"packaging/linux/steam-deck-gadget/usbip-poc",
|
||||
"clients/android/native/vendor/ndk",
|
||||
]
|
||||
|
||||
# ndk 0.9.0 verbatim from crates.io plus ONE visibility change (and two warning fixes — an
|
||||
# unnecessary `std::` qualification and a feature-gated `Result` import): `MediaCodec::as_ptr` made public
|
||||
# (upstream keeps it private and exposes no frame-rendered binding), so the Android client can
|
||||
# call `AMediaCodec_setOnFrameRenderedCallback` via ndk-sys for the HUD's `display` stage
|
||||
# (design/stats-unification.md). Drop the patch when upstream exposes the pointer or the callback.
|
||||
[patch.crates-io]
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.1"
|
||||
version = "0.9.1"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -15,17 +15,26 @@ your local network.
|
||||
💬 **Community: [Discord](https://discord.gg/kaPNvzMuGU)** — chat, support, and **Android beta
|
||||
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
|
||||
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
|
||||
protocol, FEC, and crypto, linked into the host and every client over a stable C ABI.
|
||||
protocol, FEC, and crypto, linked into the host and every native client — directly as a Rust crate
|
||||
on Linux and Windows, and over a stable C ABI from the Apple and Android apps.
|
||||
|
||||
## What makes it different
|
||||
|
||||
- **Your device's exact mode.** For each client that connects, the host spins up a virtual display
|
||||
sized to that device — 1080p60 to a laptop, 1440p120 to a desktop, 4K to a TV, all at once. No
|
||||
letterboxing, no scaling, no rearranging your real monitors.
|
||||
- **Displays you configure, not just create.** Keep a game's display (and the game) alive across
|
||||
disconnects so a reconnect drops straight back in; make the stream your sole desktop or extend
|
||||
alongside your monitors; let several devices become monitors of one desktop; keep each client's
|
||||
scaling. One-click presets in the console — a dedicated couch box, a shared desktop, a multi-monitor
|
||||
workstation. See [Virtual displays](docs-site/content/docs/virtual-displays.md).
|
||||
- **A real virtual display on Windows, too.** On Linux the host uses per-compositor virtual outputs;
|
||||
on Windows you get the same on-the-fly virtual display — at the client's exact mode, no physical
|
||||
monitor or dummy HDMI plug, even on the secure desktop (UAC / lock screen). It also has **its own
|
||||
@@ -49,19 +58,23 @@ protocol, FEC, and crypto, linked into the host and every client over a stable C
|
||||
| **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** (x64) | 🟡 Implemented & shipping as a signed installer: DXGI/WGC capture · its own all-Rust IddCx **virtual display** (secure-desktop capable) · 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, controllers incl. DualSense, discovery, pairing, speed test |
|
||||
| **Linux client** (`clients/linux`, GTK4) | ✅ Streaming live: FFmpeg + VAAPI zero-copy decode, PipeWire audio, SDL3 controllers; ships as Flatpak/apt/rpm/Arch |
|
||||
| **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 |
|
||||
| **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: D3D11VA hardware decode on all GPU vendors (NVIDIA + Intel validated on glass) with software fallback, WASAPI audio, SDL3 controllers, discovery, pairing; ships as signed MSIX (x64 + ARM64). HDR10 implemented, on-glass validation pending |
|
||||
| **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 |
|
||||
|
||||
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
|
||||
shell** (`pf-console-ui`): host list, PIN pairing, settings, and an on-screen keyboard.
|
||||
|
||||
The **GameStream host works with a stock Moonlight client** — validated live on NVIDIA hardware
|
||||
(RTX 5070 Ti, RTX 4090): PIN pairing that persists across restarts, an app catalog, RTSP/ENet/audio,
|
||||
and **video at the client's exact resolution and refresh** via a per-session virtual output (KWin,
|
||||
gamescope, Mutter, and Sway/wlroots backends), encoded with GPU **zero-copy** (dmabuf → CUDA/Vulkan →
|
||||
NVENC) up to 5120×1440@240. The native **`punktfunk/1`** protocol adds a QUIC control plane and a
|
||||
GF(2¹⁶) Leopard-FEC + AES-GCM data plane (p50 ~0.8 ms capture→reassembled at 720p120), with
|
||||
GF(2¹⁶) Leopard-FEC + AES-GCM data plane (p50 ~0.8 ms capture→received at 720p120), with
|
||||
mid-stream mode renegotiation and a wall-clock skew handshake so latency stays valid across machines.
|
||||
Both run from **one process**: bare `punktfunk-host serve` is the **secure native-only default**
|
||||
(`punktfunk/1` + the management API/web console), and `serve --gamestream` additionally enables the
|
||||
@@ -80,9 +93,10 @@ Windows host also 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) |
|
||||
| **Fedora / Bazzite** (rpm-ostree) | `rpm-ostree install punktfunk punktfunk-web` *(or the bootc image)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) · [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
|
||||
| **Arch / Steam Deck** (PKGBUILD / sysext) | `makepkg -si` *(Arch)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
|
||||
| **Windows** (x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) |
|
||||
| **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) |
|
||||
| **Windows** (11 22H2+, x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) |
|
||||
|
||||
`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;
|
||||
@@ -108,7 +122,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 # the Rust core, host, Linux client, and probe (Linux & macOS)
|
||||
cargo build --workspace # core, host, tray, shared client crates, Linux shell + session client, probe (Linux & macOS)
|
||||
cargo test --workspace # unit + loopback + proptest + C ABI harness
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo fmt --all --check
|
||||
@@ -128,19 +142,27 @@ and the [docs site](https://docs.punktfunk.unom.io).
|
||||
crates/
|
||||
punktfunk-core/ protocol · FEC · pacing · crypto · QUIC control plane — the C ABI (lib + cdylib + staticlib)
|
||||
punktfunk-host/ the host (Linux + Windows): virtual displays · capture · encode · input · GameStream · punktfunk/1 · mgmt
|
||||
pf-client-core/ shared client plumbing (Linux + Windows): session pump · FFmpeg decode · audio · SDL3 gamepads · trust · discovery
|
||||
pf-presenter/ Vulkan session presenter: SDL3 window · ash swapchain · frame present · input capture
|
||||
pf-console-ui/ Skia console UI for the session client: gamepad shell · stats OSD · pairing · on-screen keyboard
|
||||
pf-ffvk/ FFmpeg Vulkan hwcontext bindings (AVVkFrame) for Vulkan Video decode on the presenter's device
|
||||
pf-driver-proto/ host ↔ pf-vdisplay driver contract: control IOCTLs + IDD-push frame transport (no_std)
|
||||
punktfunk-tray/ host tray icon (Windows notification area / Linux StatusNotifierItem)
|
||||
clients/
|
||||
apple/ macOS / iOS / tvOS app (Swift · VideoToolbox · Metal · GameController)
|
||||
linux/ Linux desktop app (Rust · GTK4/libadwaita · FFmpeg/VAAPI · PipeWire · SDL3)
|
||||
linux/ Linux launcher shell (Rust · relm4 / GTK4 / libadwaita) — spawns the session client to stream
|
||||
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)
|
||||
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
|
||||
api/openapi.json management-API OpenAPI spec (regenerated via `punktfunk-host openapi`, checked in)
|
||||
packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite bootc image
|
||||
docs-site/ public documentation site (Fumadocs) — https://docs.punktfunk.unom.io
|
||||
design/ design notes & deep-dive plans (index: design/README.md)
|
||||
include/punktfunk_core.h cbindgen-generated C header (checked in)
|
||||
tools/ latency-probe · loss-harness (measurement)
|
||||
ci/ CI container images (rust-ci · fedora-rpm)
|
||||
```
|
||||
|
||||
## Design invariants
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
# Security Policy
|
||||
|
||||
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.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Please report security issues privately by email to security@punktfunk.com.**
|
||||
|
||||
Do **not** open a public issue, pull request, or chat/forum post for a suspected vulnerability — that
|
||||
exposes other users before a fix exists.
|
||||
|
||||
### What to include
|
||||
|
||||
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 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.
|
||||
- Any suggested fix or mitigation (optional).
|
||||
|
||||
## What to expect
|
||||
|
||||
We're a small team, so timelines are best-effort, but we commit to:
|
||||
|
||||
- **Acknowledge** your report within **3 business days**.
|
||||
- Give an **initial assessment** (severity + whether we can reproduce) within about **7 days**.
|
||||
- Keep you updated, and tell you when a fix ships.
|
||||
- **Credit** you in the advisory / release notes when the fix is public — unless you'd rather stay
|
||||
anonymous.
|
||||
|
||||
We practice **coordinated disclosure**: please give us reasonable time to release a fix before
|
||||
publishing details. We aim to resolve valid issues within **90 days** and will agree a disclosure
|
||||
date with you.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope — the code in this repository:
|
||||
|
||||
- The host (`punktfunk-host`), its Windows drivers, and the protocol/crypto core (`punktfunk-core`).
|
||||
- The native clients (Apple, Linux, Windows, Android), the web management console, and the management
|
||||
API.
|
||||
|
||||
Known limits — documented behavior, not vulnerabilities (see
|
||||
https://docs.punktfunk.unom.io/docs/security):
|
||||
|
||||
- **Admin/SYSTEM already on the host = out of scope.** An attacker who is already administrator or
|
||||
SYSTEM on the host owns the machine regardless of punktfunk.
|
||||
- **The virtual display is a real monitor** — any process already in the interactive desktop session
|
||||
can capture it via the normal OS screen-capture APIs, exactly as it could a physical monitor.
|
||||
- **GameStream/Moonlight compatibility** (`--gamestream`) uses legacy encryption and is documented as
|
||||
opt-in, trusted-LAN-only.
|
||||
- **Public-internet exposure is unsupported** — issues that only arise from exposing the host to the
|
||||
WAN are expected; keep the host on a trusted LAN or a VPN.
|
||||
|
||||
If you're unsure whether something is in scope, report it anyway — we'd rather hear about it.
|
||||
|
||||
## Safe harbor
|
||||
|
||||
We consider good-faith security research that follows this policy to be authorized, and we won't
|
||||
pursue legal action against researchers who:
|
||||
|
||||
- make a good-faith effort to avoid privacy violations, data loss, and service disruption,
|
||||
- only test systems they own or have explicit permission to test,
|
||||
- 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.
|
||||
+1049
-1
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,8 @@ RUN dnf -y install \
|
||||
mesa-libGL-devel mesa-libgbm-devel \
|
||||
# punktfunk-client link deps (GTK4 shell + SDL3 gamepads)
|
||||
gtk4-devel libadwaita-devel SDL3-devel \
|
||||
# pf-ffvk bindgen over libavutil/hwcontext_vulkan.h needs <vulkan/vulkan.h>
|
||||
vulkan-headers \
|
||||
&& dnf clean all
|
||||
|
||||
# bun — both the BUILD tool and the RUNTIME for the punktfunk-web console (`bun run build` -> the
|
||||
|
||||
@@ -22,6 +22,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libgl-dev libegl-dev libgbm-dev \
|
||||
# punktfunk-client-linux (GTK4/libadwaita shell, SDL3 gamepads)
|
||||
libgtk-4-dev libadwaita-1-dev libsdl3-dev \
|
||||
# pf-ffvk (bindgen over libavutil/hwcontext_vulkan.h needs <vulkan/vulkan.h>)
|
||||
libvulkan-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# bun — builds the punktfunk-web console in deb.yml (which runs the web build in THIS image).
|
||||
|
||||
@@ -16,7 +16,9 @@ couch (D-pad / gamepad focus navigation).
|
||||
pairing** (or TOFU on trusted LANs), then reconnects on a Keystore-wrapped, pinned identity.
|
||||
- **Compose UI** — Connect / Settings / Stream screens with Material You theming.
|
||||
|
||||
Built for `arm64-v8a` + `x86_64`.
|
||||
Built for `arm64-v8a` + `armeabi-v7a` + `x86_64` — the 32-bit `armeabi-v7a` slice is what keeps the
|
||||
app installable on the many 32-bit Google TV / Android TV streamers (Walmart onn. 4K, Chromecast with
|
||||
Google TV, budget Amlogic boxes) that otherwise reject a 64-bit-only build as "not compatible".
|
||||
|
||||
## Get it
|
||||
|
||||
@@ -54,9 +56,9 @@ kit/ :kit — NativeBridge · native mDNS discovery · Gamepad · K
|
||||
|
||||
**Prerequisites:** Android SDK + **NDK r30** (`30.0.14904198`), `platforms;android-37.0`,
|
||||
`build-tools;37.0.0`, **`cmake;3.22.1`** (builds libopus); **JDK 21** (AGP 9.2 runs on JDK 17–21, not
|
||||
a newer default); Rust with `rustup target add aarch64-linux-android x86_64-linux-android` and
|
||||
a newer default); Rust with `rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android` and
|
||||
`cargo install cargo-ndk`. Toolchain is pinned (AGP 9.2 · Gradle 9.4.1 · Kotlin 2.3.21 · Compose BOM
|
||||
2026.05.01 · compileSdk 37 · minSdk 31).
|
||||
2026.05.01 · compileSdk 37 · minSdk 28).
|
||||
|
||||
**Android Studio:** open `clients/android` — it uses its bundled JBR 21, and the `cargoNdk*` task
|
||||
builds the `.so` as part of the normal build.
|
||||
|
||||
@@ -11,7 +11,7 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "io.unom.punktfunk"
|
||||
compileSdk = 37 // Android 17 — required by androidx.core 1.19.0; targetSdk stays 36 for now.
|
||||
compileSdk = 37 // Android 17 — required by androidx.core 1.19.0.
|
||||
|
||||
defaultConfig {
|
||||
// Load from .env if it exists (local dev), otherwise from System.getenv (CI)
|
||||
@@ -22,14 +22,38 @@ android {
|
||||
}
|
||||
|
||||
applicationId = "io.unom.punktfunk"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
// Android 9. Reaches older Android TV boxes (e.g. Amlogic streamers still on Android 9–11);
|
||||
// the handful of API 31+ APIs we use are runtime-gated (Material You → brand palette, rumble
|
||||
// → legacy Vibrator, NEARBY_WIFI/lights/ADPF already gated), so nothing is lost above 28.
|
||||
minSdk = 28
|
||||
// Android 17: targeting 37 makes Local Network Protection MANDATORY — all LAN traffic (the
|
||||
// QUIC dial, mDNS, WoL, the library fetch) is blocked until the user grants the
|
||||
// ACCESS_LOCAL_NETWORK runtime permission. ConnectScreen owns that request/rationale flow;
|
||||
// don't bump past 37 without re-checking the next release's behavior changes.
|
||||
targetSdk = 37
|
||||
val vCode = (props.getProperty("VERSION_CODE") ?: System.getenv("VERSION_CODE"))
|
||||
versionCode = vCode?.toInt() ?: 1
|
||||
// versionName is the single project version, threaded from CI (a vX.Y.Z release or a
|
||||
// canary string). versionCode stays the monotonic run number (Play rejects regressions).
|
||||
versionName = (props.getProperty("VERSION_NAME") ?: System.getenv("VERSION_NAME")) ?: "0.0.2"
|
||||
ndk { abiFilters += listOf("arm64-v8a", "x86_64") }
|
||||
// Local dev (no VERSION_NAME) falls back to the workspace version from the root Cargo.toml —
|
||||
// the single source of truth — so an on-device build shows the real current version, not a
|
||||
// stale placeholder.
|
||||
val workspaceVersion = runCatching {
|
||||
project.rootProject.file("../../Cargo.toml").readLines()
|
||||
.dropWhile { !it.trim().startsWith("[workspace.package]") }
|
||||
.firstOrNull { it.trim().startsWith("version") }
|
||||
?.substringAfter('=')?.trim()?.trim('"')
|
||||
}.getOrNull()
|
||||
versionName = (props.getProperty("VERSION_NAME") ?: System.getenv("VERSION_NAME"))
|
||||
?: workspaceVersion ?: "0.0.0"
|
||||
// Ship 32-bit armeabi-v7a alongside 64-bit arm64-v8a: many Google TV / Android TV streamers
|
||||
// (Walmart onn. 4K, Chromecast with Google TV, budget Amlogic boxes) run a 32-bit Android
|
||||
// userspace, and because this app carries native code, Google Play (and a sideload installer)
|
||||
// filters it as "not compatible" on those devices unless an armeabi-v7a variant is present.
|
||||
// x86_64 stays for the emulator. Google keeps delivering to 32-bit TV devices (see the Aug
|
||||
// 2025 "64-bit app compatibility for Google TV and Android TV" post) — the 64-bit lib is the
|
||||
// required half; the 32-bit lib is what actually reaches the boxes people report failing.
|
||||
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") }
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
@@ -97,9 +121,18 @@ dependencies {
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.foundation:foundation")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-core") // bottom-bar tab icons
|
||||
implementation("androidx.compose.material:material-icons-core") // bottom-bar / rail tab icons
|
||||
implementation("androidx.compose.material:material-icons-extended") // settings-category icons
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
|
||||
// Cover-art loading for the game-library coverflow. Coil 2.x uses OkHttp under the hood, so we
|
||||
// feed it the same mTLS OkHttpClient the library fetch uses (reaching the host's own art proxy).
|
||||
implementation("io.coil-kt:coil-compose:2.7.0")
|
||||
|
||||
// Real backdrop blur for the floating console legends (RenderEffect on API 31+, a translucent
|
||||
// scrim below). The gamepad UI's frosted pills sample + blur whatever scrolls behind them.
|
||||
implementation("dev.chrisbanes.haze:haze:1.6.0")
|
||||
|
||||
// Android TV components (we target phone + TV) land in the TV-UI milestone:
|
||||
// implementation("androidx.tv:tv-material:1.1.0")
|
||||
// The manifest already declares leanback so the scaffold installs on TV.
|
||||
|
||||
@@ -13,8 +13,15 @@
|
||||
reception needs it (also an OEM Wi-Fi power-save hedge). -->
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<!-- Enforced from Android 17 (SDK 37) for ALL local-network traffic incl. the QUIC socket.
|
||||
Harmless to declare on earlier releases. -->
|
||||
<!-- WifiLock.acquire() ENFORCES this (a normal permission, granted at install). Without it the
|
||||
stream's Wi-Fi locks throw SecurityException and power save stays on: downlink delivery
|
||||
clumps at beacon intervals — hundreds of ms of latency mush + periodic whole-frame loss.
|
||||
Its absence went unnoticed for weeks because the acquire was wrapped in a silent
|
||||
runCatching (now logged). -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<!-- Enforced from Android 17 (SDK 37, our targetSdk) for ALL local-network traffic incl. the
|
||||
QUIC socket — a RUNTIME permission (NEARBY_DEVICES group): ConnectScreen requests it on
|
||||
entry and gates every dial/wake on the grant. Harmless to declare on earlier releases. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||
<!-- Mic uplink to the host's virtual microphone (requested at runtime). -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
@@ -27,14 +34,30 @@
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
|
||||
|
||||
<!-- appCategory="game": a game-streaming client IS a game as far as the SoC is concerned.
|
||||
On Snapdragon devices (and other OEMs with a Game Mode / Game Dashboard) this makes the app
|
||||
eligible for the vendor's game performance profile — the aggressive CPU/GPU governor and
|
||||
scheduler treatment games get — which, together with the ADPF hints in the native decode
|
||||
path, is what keeps clocks up for low, consistent decode latency. Also groups it correctly
|
||||
under Games in battery/data usage. Advisory: devices without Game Mode ignore it. -->
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:appCategory="game"
|
||||
android:banner="@drawable/tv_banner"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.PunktfunkAndroid">
|
||||
|
||||
<!-- Game Mode config (Android 13+): declare we support Performance mode and opt OUT of the
|
||||
OEM interventions that would fight the negotiated stream — resolution downscaling and
|
||||
FPS overrides. A game-streaming client renders exactly the host's mode; a platform
|
||||
downscale/FPS-cap corrupts that. Ignored below API 33. -->
|
||||
<meta-data
|
||||
android:name="android.game_mode_config"
|
||||
android:resource="@xml/game_mode_config" />
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -2,40 +2,62 @@ package io.unom.punktfunk
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.NavigationRail
|
||||
import androidx.compose.material3.NavigationRailItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.unom.punktfunk.models.Tab
|
||||
|
||||
@Composable
|
||||
fun App() {
|
||||
fun App(forceGamepadUi: Boolean = false) {
|
||||
val context = LocalContext.current
|
||||
val settingsStore = remember { SettingsStore(context) }
|
||||
var settings by remember { mutableStateOf(settingsStore.load()) }
|
||||
var streamHandle by remember { mutableLongStateOf(0L) } // 0 = not streaming
|
||||
var tab by remember { mutableStateOf(Tab.Connect) }
|
||||
|
||||
// Console (gamepad) mode mirrors the Apple client: the setting AND (a pad is attached OR this is
|
||||
// a TV OR the dev force flag). Flips live as controllers connect/disconnect.
|
||||
val tv = remember { isTvDevice(context) }
|
||||
val controllerConnected by rememberControllerConnected()
|
||||
val gamepadUi = gamepadUiActive(settings.gamepadUiEnabled, controllerConnected, tv, forceGamepadUi)
|
||||
|
||||
AnimatedContent(
|
||||
targetState = streamHandle != 0L,
|
||||
transitionSpec = {
|
||||
@@ -46,29 +68,34 @@ fun App() {
|
||||
if (isStreaming) {
|
||||
// Immersive: the stream takes the whole screen, no bottom bar.
|
||||
StreamScreen(streamHandle, micEnabled = settings.micEnabled, onDisconnect = { streamHandle = 0L })
|
||||
} else {
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
Tab.entries.forEach { t ->
|
||||
NavigationBarItem(
|
||||
selected = tab == t,
|
||||
onClick = { tab = t },
|
||||
icon = { Icon(t.icon, contentDescription = t.label) },
|
||||
label = { Text(t.label) },
|
||||
} else if (gamepadUi) {
|
||||
GamepadShell(
|
||||
settings = settings,
|
||||
onSettingsChange = { settings = it; settingsStore.save(it) },
|
||||
onConnected = { streamHandle = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
Box(Modifier.fillMaxSize().padding(innerPadding)) {
|
||||
} else {
|
||||
// Adaptive nav: a bottom bar on phones; on tablets / large windows a side NavigationRail
|
||||
// with its items centred vertically (the common Android tablet idiom, mirroring iPad's
|
||||
// side navigation). A short landscape phone keeps the bottom bar (rail needs height too).
|
||||
// Tabs slide along the axis the nav sits on: horizontally with the bottom bar (phone),
|
||||
// vertically with the side rail (tablet), so the motion tracks the direction you moved.
|
||||
val tabContent: @Composable (vertical: Boolean) -> Unit = { vertical ->
|
||||
AnimatedContent(
|
||||
targetState = tab,
|
||||
transitionSpec = {
|
||||
if (targetState.ordinal > initialState.ordinal) {
|
||||
val forward = targetState.ordinal > initialState.ordinal
|
||||
when {
|
||||
vertical && forward ->
|
||||
slideInVertically { it } + fadeIn() togetherWith
|
||||
slideOutVertically { -it } + fadeOut()
|
||||
vertical ->
|
||||
slideInVertically { -it } + fadeIn() togetherWith
|
||||
slideOutVertically { it } + fadeOut()
|
||||
forward ->
|
||||
slideInHorizontally { it } + fadeIn() togetherWith
|
||||
slideOutHorizontally { -it } + fadeOut()
|
||||
} else {
|
||||
else ->
|
||||
slideInHorizontally { -it } + fadeIn() togetherWith
|
||||
slideOutHorizontally { it } + fadeOut()
|
||||
}
|
||||
@@ -85,7 +112,112 @@ fun App() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
if (maxWidth >= 600.dp && maxHeight >= 480.dp) {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
NavigationRail(Modifier.fillMaxHeight()) {
|
||||
Spacer(Modifier.weight(1f)) // centre the rail items vertically
|
||||
Tab.entries.forEach { t ->
|
||||
NavigationRailItem(
|
||||
selected = tab == t,
|
||||
onClick = { tab = t },
|
||||
icon = { Icon(t.icon, contentDescription = t.label) },
|
||||
label = { Text(t.label) },
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
// The rail handles its own insets; the content pane insets itself (the screens
|
||||
// don't, since they used to rely on the Scaffold's padding).
|
||||
Box(Modifier.weight(1f).fillMaxHeight().systemBarsPadding()) { tabContent(true) }
|
||||
}
|
||||
} else {
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
Tab.entries.forEach { t ->
|
||||
NavigationBarItem(
|
||||
selected = tab == t,
|
||||
onClick = { tab = t },
|
||||
icon = { Icon(t.icon, contentDescription = t.label) },
|
||||
label = { Text(t.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
Box(Modifier.fillMaxSize().padding(innerPadding)) { tabContent(false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Which console screen the gamepad shell is showing. */
|
||||
private enum class GamepadScreen { Home, Settings, Library }
|
||||
|
||||
/**
|
||||
* The console (gamepad) shell — the Android mirror of the Apple client's ContentView gamepad branch:
|
||||
* a full-screen host carousel with X → Settings and Y → a saved host's library, all sharing
|
||||
* [ConnectScreen]'s connect logic. No bottom bar; navigation is button-driven.
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadShell(
|
||||
settings: Settings,
|
||||
onSettingsChange: (Settings) -> Unit,
|
||||
onConnected: (Long) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var screen by remember { mutableStateOf(GamepadScreen.Home) }
|
||||
var libraryHost by remember { mutableStateOf<io.unom.punktfunk.kit.security.KnownHost?>(null) }
|
||||
|
||||
// On a TV, shrink the 10-foot UI so its elements aren't oversized. Density-aware: expand the
|
||||
// effective dp footprint to at least CONSOLE_TV_MIN_WIDTH_DP (→ smaller elements) ONLY when the
|
||||
// panel reports fewer dp than that; a low-density TV that's already spacious, and every phone /
|
||||
// tablet, keep their real density unchanged. This is the "based on pixel density" scale the layout
|
||||
// wanted — one uniform factor across text, cards, spacing, and insets.
|
||||
val isTv = remember { isTvDevice(context) }
|
||||
val baseDensity = LocalDensity.current
|
||||
val screenWidthPx = LocalConfiguration.current.screenWidthDp * baseDensity.density
|
||||
val fitDensity = screenWidthPx / CONSOLE_TV_MIN_WIDTH_DP
|
||||
val consoleDensity = if (isTv && fitDensity < baseDensity.density) fitDensity else baseDensity.density
|
||||
|
||||
CompositionLocalProvider(LocalDensity provides Density(consoleDensity, baseDensity.fontScale)) {
|
||||
// Cross-fade between console screens so switches are smooth. Each slot's controller nav is gated
|
||||
// on being the CURRENT target (`s == screen`), so during the fade only the incoming screen drives
|
||||
// the pad. All screens pin their legend at the same ConsoleLegendInset, so it reads as fixed while
|
||||
// the content behind it fades.
|
||||
Crossfade(targetState = screen, animationSpec = tween(240), label = "consoleScreen") { s ->
|
||||
when (s) {
|
||||
GamepadScreen.Home -> ConnectScreen(
|
||||
settings = settings,
|
||||
onConnected = onConnected,
|
||||
gamepadUi = true,
|
||||
onOpenSettings = { screen = GamepadScreen.Settings },
|
||||
onOpenLibrary = { host -> libraryHost = host; screen = GamepadScreen.Library },
|
||||
navGate = s == screen,
|
||||
)
|
||||
GamepadScreen.Settings -> GamepadSettingsScreen(
|
||||
initial = settings,
|
||||
onChange = onSettingsChange,
|
||||
onBack = { screen = GamepadScreen.Home },
|
||||
navActive = s == screen,
|
||||
)
|
||||
GamepadScreen.Library -> libraryHost?.let { host ->
|
||||
LibraryScreen(
|
||||
host = host,
|
||||
settings = settings,
|
||||
onLaunched = onConnected,
|
||||
onBack = { screen = GamepadScreen.Home; libraryHost = null },
|
||||
navActive = s == screen,
|
||||
)
|
||||
} ?: run { screen = GamepadScreen.Home }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimum effective dp width the console UI targets on a TV (bigger → the 10-foot UI shrinks). */
|
||||
private const val CONSOLE_TV_MIN_WIDTH_DP = 1180f
|
||||
|
||||
@@ -9,7 +9,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -33,6 +35,7 @@ import androidx.compose.ui.unit.dp
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.security.ClientIdentity
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
import io.unom.punktfunk.models.PendingTrust
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -152,6 +155,35 @@ internal fun TrustNewHostDialog(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Android 17+ Local Network Protection rationale: ACCESS_LOCAL_NETWORK was denied, so discovery and
|
||||
* every connect are dead — offer the system prompt again and a settings deep link (a permanently-
|
||||
* denied request returns instantly without ever showing the prompt, so "Allow" alone isn't enough).
|
||||
*/
|
||||
@Composable
|
||||
internal fun LocalNetworkDialog(onAllow: () -> Unit, onSettings: () -> Unit, onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Allow local network access") },
|
||||
text = {
|
||||
Text(
|
||||
"Android blocks punktfunk from talking to devices on your network, so it can't " +
|
||||
"find or reach any host until you allow it. If no prompt appears when you tap " +
|
||||
"Allow, enable “Nearby devices” for punktfunk in system settings.",
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onAllow) { Text("Allow") }
|
||||
},
|
||||
dismissButton = {
|
||||
Row {
|
||||
TextButton(onClick = onSettings) { Text("Open settings") }
|
||||
TextButton(onClick = onDismiss) { Text("Not now") }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** The pinned fingerprint no longer matches — force re-pairing (never a silent re-trust). */
|
||||
@Composable
|
||||
internal fun FingerprintChangedDialog(
|
||||
@@ -320,32 +352,75 @@ internal fun AwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a saved host's label (discovered hosts are named by mDNS; this is how you give one a
|
||||
* friendly name like "Living Room" after pairing). Keyed by the host so reopening resets the field.
|
||||
* Edit a saved host: name, address, port, and the Wake-on-LAN MAC. The MAC is auto-learned from the
|
||||
* host's mDNS advert while it's online, but this is where you can enter or correct it (e.g. to wake a
|
||||
* host you've only ever reached by address). [suggestedMacs] prefills the field from the live advert
|
||||
* when nothing's been learned yet. Keyed by the host so reopening resets the fields. Mirrors the
|
||||
* Apple client's edit form.
|
||||
*/
|
||||
@Composable
|
||||
internal fun RenameHostDialog(
|
||||
internal fun EditHostDialog(
|
||||
target: KnownHost,
|
||||
onRename: (String) -> Unit,
|
||||
suggestedMacs: List<String>,
|
||||
onSave: (KnownHost) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var newName by remember(target) { mutableStateOf(target.name) }
|
||||
var name by remember(target) { mutableStateOf(target.name) }
|
||||
var address by remember(target) { mutableStateOf(target.address) }
|
||||
var port by remember(target) { mutableStateOf(target.port.toString()) }
|
||||
var mac by remember(target) {
|
||||
mutableStateOf(target.mac.ifEmpty { suggestedMacs }.joinToString(", "))
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Rename host") },
|
||||
title = { Text("Edit host") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = newName,
|
||||
onValueChange = { newName = it },
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Name") },
|
||||
placeholder = { Text(target.address) },
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = address,
|
||||
onValueChange = { address = it },
|
||||
label = { Text("Address") },
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = port,
|
||||
onValueChange = { v -> port = v.filter { it.isDigit() }.take(5) },
|
||||
label = { Text("Port") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = mac,
|
||||
onValueChange = { mac = it },
|
||||
label = { Text("Wake-on-LAN MAC") },
|
||||
placeholder = { Text("auto-filled when the host is seen") },
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = newName.isNotBlank(),
|
||||
onClick = { onRename(newName.trim()) },
|
||||
enabled = address.isNotBlank(),
|
||||
onClick = {
|
||||
onSave(
|
||||
target.copy(
|
||||
name = name.trim().ifEmpty { target.address },
|
||||
address = address.trim(),
|
||||
port = port.toIntOrNull() ?: target.port,
|
||||
mac = KnownHostStore.parseMacs(mac),
|
||||
),
|
||||
)
|
||||
},
|
||||
) { Text("Save") }
|
||||
},
|
||||
dismissButton = {
|
||||
|
||||
@@ -2,7 +2,9 @@ package io.unom.punktfunk
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
@@ -30,6 +32,7 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -37,6 +40,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -44,6 +48,9 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import io.unom.punktfunk.components.EmptyHostsState
|
||||
import io.unom.punktfunk.components.HostCard
|
||||
import io.unom.punktfunk.components.SectionLabel
|
||||
@@ -60,12 +67,10 @@ import io.unom.punktfunk.models.HostStatus
|
||||
import io.unom.punktfunk.models.PendingTrust
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** Handshake budget for a normal connect (the prior hardcoded value, now passed explicitly). */
|
||||
private const val CONNECT_TIMEOUT_MS = 10_000
|
||||
|
||||
/**
|
||||
* Handshake budget for the no-PIN "request access" connect. Must exceed the host's approval-park
|
||||
* window (~180 s) so a slow operator approval still lands on this same parked connection rather than
|
||||
@@ -84,7 +89,17 @@ private class RequestAccessState(val target: PendingTrust) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
fun ConnectScreen(
|
||||
settings: Settings,
|
||||
onConnected: (Long) -> Unit,
|
||||
// Console (gamepad) mode: render the host carousel instead of the touch grid, sharing all of this
|
||||
// screen's connect/trust/discovery logic. [onOpenSettings]/[onOpenLibrary] are the X/Y actions the
|
||||
// gamepad shell owns (the touch UI reaches Settings via the bottom bar and has no library button).
|
||||
gamepadUi: Boolean = false,
|
||||
onOpenSettings: () -> Unit = {},
|
||||
onOpenLibrary: (KnownHost) -> Unit = {},
|
||||
navGate: Boolean = true, // false while the console home is cross-fading out
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
var host by remember { mutableStateOf("") }
|
||||
@@ -104,14 +119,57 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
// denial used to leave discovery dead forever.
|
||||
val discovery = remember { HostDiscovery(context) }
|
||||
var discovered by remember { mutableStateOf<List<DiscoveredHost>>(emptyList()) }
|
||||
// Android 17 Local Network Protection: with targetSdk 37, EVERYTHING this screen does — the mDNS
|
||||
// browse, the QUIC dial (UDP 9777), Wake-on-LAN, the library fetch — is blocked until the user
|
||||
// grants ACCESS_LOCAL_NETWORK (a runtime permission in the NEARBY_DEVICES group). Blocked UDP
|
||||
// fails with EPERM, which quinn experiences as a silent handshake timeout — so without this gate
|
||||
// a denial looks exactly like a dead host. Unlike NEARBY_WIFI_DEVICES below, this one is
|
||||
// load-bearing: request it on entry, and surface a denial as an actionable dialog/banner (with a
|
||||
// system-settings deep link) instead of dead-ending on timeouts.
|
||||
var lnpGranted by remember { mutableStateOf(hasLocalNetworkPermission(context)) }
|
||||
var lnpPrompt by remember { mutableStateOf(false) }
|
||||
val localNetLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
lnpGranted = granted
|
||||
if (granted) {
|
||||
lnpPrompt = false
|
||||
// The browse started while blocked (its sockets failed or received nothing) — restart it
|
||||
// now that the grant makes them work.
|
||||
discovery.stop()
|
||||
discovery.start()
|
||||
} else {
|
||||
lnpPrompt = true // rationale + "Open settings" (a permanently-denied request returns instantly)
|
||||
}
|
||||
}
|
||||
val nearbyLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { _ -> /* best-effort hint; discovery runs regardless of the result */ }
|
||||
LaunchedEffect(Unit) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !hasNearbyPermission(context)) {
|
||||
if (!lnpGranted) {
|
||||
localNetLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !hasNearbyPermission(context)) {
|
||||
// The old opportunistic multicast hedge (some OEMs filter multicast without it). On API
|
||||
// 37+ it shares the NEARBY_DEVICES group with ACCESS_LOCAL_NETWORK, so once that is
|
||||
// granted this auto-grants without a second prompt.
|
||||
nearbyLauncher.launch(Manifest.permission.NEARBY_WIFI_DEVICES)
|
||||
}
|
||||
}
|
||||
// Re-check on resume: our dialog deep-links to system settings, and granting there doesn't kill
|
||||
// or otherwise notify the app — this observer is what turns the grant into a live discovery.
|
||||
DisposableEffect(Unit) {
|
||||
val lifecycle = (context as? LifecycleOwner)?.lifecycle
|
||||
val obs = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME && !lnpGranted && hasLocalNetworkPermission(context)) {
|
||||
lnpGranted = true
|
||||
lnpPrompt = false
|
||||
discovery.stop()
|
||||
discovery.start()
|
||||
}
|
||||
}
|
||||
lifecycle?.addObserver(obs)
|
||||
onDispose { lifecycle?.removeObserver(obs) }
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
discovery.onChange = { discovered = it }
|
||||
discovery.start()
|
||||
@@ -124,6 +182,52 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
val identityStore = remember { IdentityStore(context) }
|
||||
val knownHostStore = remember { KnownHostStore(context) }
|
||||
var savedHosts by remember { mutableStateOf(knownHostStore.all()) }
|
||||
// Wakes a sleeping saved host and waits for it to reappear on mDNS before dialing (its overlay
|
||||
// rides over both the touch and console home). Fire-and-forget WoL isn't enough — a cold boot can
|
||||
// take a minute-plus to advertise again.
|
||||
val waker = remember { WakeController(scope) }
|
||||
// Learn wake MAC(s) from live adverts for hosts we've saved (parity with the desktop clients),
|
||||
// so we can Wake-on-LAN them once they sleep. Runs only when the discovered set changes; the
|
||||
// prefs write is guarded (no-op when unchanged), and we refresh the saved list only if a MAC
|
||||
// was actually newly learned.
|
||||
LaunchedEffect(discovered) {
|
||||
val learned = withContext(Dispatchers.IO) {
|
||||
var any = false
|
||||
discovered.forEach { dh ->
|
||||
if (dh.mac.isNotEmpty() &&
|
||||
knownHostStore.get(dh.host, dh.port)?.let { it.mac != dh.mac } == true
|
||||
) {
|
||||
knownHostStore.learnMac(dh.host, dh.port, dh.mac)
|
||||
any = true
|
||||
}
|
||||
}
|
||||
any
|
||||
}
|
||||
if (learned) savedHosts = knownHostStore.all()
|
||||
}
|
||||
// Saved hosts proven reachable by a QUIC probe this cycle, keyed "address:port" — the
|
||||
// routed-network (Tailscale/VPN) counterpart to mDNS presence, since such hosts never
|
||||
// advertise. OR'd into `isOnline` below so their pips light up. Probe only saved hosts NOT
|
||||
// already seen on mDNS, off the main thread, every ~12 s; gated on LNP (blocked UDP would
|
||||
// just time out). `rememberUpdatedState` keeps the 1 Hz mDNS updates from restarting the loop.
|
||||
var reachable by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
val discoveredNow by rememberUpdatedState(discovered)
|
||||
LaunchedEffect(savedHosts, lnpGranted) {
|
||||
if (!lnpGranted) {
|
||||
reachable = emptySet()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
while (true) {
|
||||
val targets = savedHosts.filter { kh -> discoveredNow.none { kh.matches(it) } }
|
||||
reachable = withContext(Dispatchers.IO) {
|
||||
targets
|
||||
.filter { NativeBridge.nativeProbe(it.address, it.port, 3_000) }
|
||||
.map { "${it.address}:${it.port}" }
|
||||
.toSet()
|
||||
}
|
||||
delay(12_000)
|
||||
}
|
||||
}
|
||||
// Mint-once on genuine first run; an Unrecoverable store (decrypt failure) surfaces here and
|
||||
// refuses to connect — never silently shadow-minting a new identity (which would force re-pair).
|
||||
var identity by remember { mutableStateOf<ClientIdentity?>(null) }
|
||||
@@ -137,40 +241,29 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
var pendingTrust by remember { mutableStateOf<PendingTrust?>(null) }
|
||||
// A no-PIN "request access" connect in flight (the cancelable "Waiting for approval…" dialog).
|
||||
var awaiting by remember { mutableStateOf<RequestAccessState?>(null) }
|
||||
// A saved host whose label is being edited (the Rename dialog).
|
||||
var renameTarget by remember { mutableStateOf<KnownHost?>(null) }
|
||||
// A saved host being edited (name / address / port / MAC).
|
||||
var editTarget by remember { mutableStateOf<KnownHost?>(null) }
|
||||
// A saved host whose console options menu (Wake / Edit / Forget) is open — reached with Up on the
|
||||
// carousel (the console counterpart of the touch host card's overflow menu).
|
||||
var optionsTarget by remember { mutableStateOf<KnownHost?>(null) }
|
||||
|
||||
// Discovered hosts not already saved — a saved host (paired or TOFU) belongs in "Saved hosts",
|
||||
// not also in "Discovered", so we hide the overlap (matched by fingerprint when both carry it, so
|
||||
// it survives a DHCP address change; else by address:port). Mirrors the Apple client.
|
||||
val discoveredUnsaved = discovered.filter { dh -> savedHosts.none { it.matches(dh) } }
|
||||
|
||||
// The one place the full nativeConnect is issued (shared by the normal connect and the
|
||||
// request-access path), including the HDR/gamepad derivation both need.
|
||||
suspend fun connectNative(id: ClientIdentity, targetHost: String, targetPort: Int, pinHex: String, timeoutMs: Int): Long {
|
||||
// Advertise HDR only when the user enabled it AND this device's display can present it
|
||||
// (else the host sends a proper SDR stream rather than PQ the panel would mis-tone-map).
|
||||
val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context)
|
||||
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID
|
||||
// (Android exposes no controller-type enum) — parity with the Linux/Apple clients. An
|
||||
// explicit choice is passed through unchanged.
|
||||
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
|
||||
return withContext(Dispatchers.IO) {
|
||||
NativeBridge.nativeConnect(
|
||||
targetHost, targetPort, w, h, hz,
|
||||
id.certPem, id.privateKeyPem, pinHex,
|
||||
settings.bitrateKbps, settings.compositor, gamepadPref,
|
||||
hdrEnabled, settings.audioChannels, settings.preferredCodec(), timeoutMs,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Issue the native connect (shared by the normal connect and the request-access path). A plain
|
||||
// desktop connect (no library launch) — the library launcher calls [connectToHost] with an id.
|
||||
suspend fun connectNative(id: ClientIdentity, targetHost: String, targetPort: Int, pinHex: String, timeoutMs: Int): Long =
|
||||
connectToHost(context, settings, id, targetHost, targetPort, pinHex, launch = null, timeoutMs = timeoutMs)
|
||||
|
||||
// Issue the actual connect with identity + (optional) pin. On a TOFU connect (pinHex null),
|
||||
// pin the fingerprint the host presented (as an unpaired known host) so the next connect goes
|
||||
// straight through and it appears in the saved-hosts list.
|
||||
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
|
||||
val id = identity
|
||||
if (id == null) {
|
||||
// The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint
|
||||
// the host presented (as an unpaired known host) so the next connect goes straight through and it
|
||||
// appears in the saved-hosts list. [onFailure], when set, takes over a failed dial (the wake-wait
|
||||
// fallback) instead of the error status line — discovery is already restarted when it runs, so
|
||||
// the wait can observe the host reappear.
|
||||
fun doConnectDirect(targetHost: String, targetPort: Int, name: String, pinHex: String?, onFailure: (() -> Unit)? = null) {
|
||||
val id = identity ?: run {
|
||||
status = "Identity not ready yet — try again in a moment"
|
||||
return
|
||||
}
|
||||
@@ -189,11 +282,65 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
}
|
||||
onConnected(handle)
|
||||
} else {
|
||||
status = "Connection failed — check host/port, PIN, and logcat"
|
||||
discovery.start()
|
||||
if (onFailure != null) {
|
||||
status = ""
|
||||
onFailure()
|
||||
} else {
|
||||
status = "Connection failed — check host/port, PIN, and logcat"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wake-aware connect. If auto-wake is on (Settings.autoWakeEnabled) and the target is a saved
|
||||
// host with a learned MAC that ISN'T currently advertising, fire a wake packet and DIAL
|
||||
// IMMEDIATELY — mDNS absence does NOT mean unreachable (a host reached over a routed network —
|
||||
// Tailscale/VPN/another subnet — is mDNS-blind forever, and gating the dial on presence bricked
|
||||
// exactly those reconnects). A genuinely-asleep box is already booting while the dial times out;
|
||||
// only a FAILED dial falls into the wake-and-WAIT-for-mDNS flow (WakeController's "Waking…"
|
||||
// overlay), which redials once the host reappears. Otherwise (auto-wake off, no MAC, or already
|
||||
// seen live) dial straight through.
|
||||
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
|
||||
if (identity == null) {
|
||||
status = "Identity not ready yet — try again in a moment"
|
||||
return
|
||||
}
|
||||
val kh = knownHostStore.get(targetHost, targetPort)
|
||||
val macs = kh?.mac ?: emptyList()
|
||||
// "Up" = a live advert that is THIS host — matched by fingerprint first (so it survives a DHCP
|
||||
// address change on a cold boot), else by address:port. Returns the CURRENT advert so we can
|
||||
// dial its live address rather than the stale saved one.
|
||||
fun liveAdvert(): DiscoveredHost? =
|
||||
if (kh != null) discovered.firstOrNull { kh.matches(it) }
|
||||
else discovered.firstOrNull { it.host == targetHost && it.port == targetPort }
|
||||
if (settings.autoWakeEnabled && macs.isNotEmpty() && liveAdvert() == null) {
|
||||
// Fire-and-forget first packet (harmless if it's awake), then dial-first.
|
||||
scope.launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(macs.joinToString(","), targetHost) }
|
||||
doConnectDirect(targetHost, targetPort, name, pinHex, onFailure = {
|
||||
waker.start(
|
||||
hostName = name,
|
||||
connectsAfter = true,
|
||||
macs = macs,
|
||||
lastIp = targetHost,
|
||||
isOnline = { liveAdvert() != null },
|
||||
onOnline = {
|
||||
val live = liveAdvert()
|
||||
// Woke back on a new address? Re-key the saved record so it (and future
|
||||
// connects) point at the live one, then dial there (no fallback on this
|
||||
// redial — a second failure surfaces as the plain error).
|
||||
if (live != null && kh != null && (live.host != kh.address || live.port != kh.port)) {
|
||||
knownHostStore.update(kh.address, kh.port, kh.copy(address = live.host, port = live.port))
|
||||
savedHosts = knownHostStore.all()
|
||||
}
|
||||
doConnectDirect(live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex)
|
||||
},
|
||||
)
|
||||
})
|
||||
} else {
|
||||
doConnectDirect(targetHost, targetPort, name, pinHex)
|
||||
}
|
||||
}
|
||||
|
||||
// The no-PIN "request access" path (delegated approval): open a normal identified connect that
|
||||
// the host PARKS until the operator clicks Approve in its console/web UI, showing a cancelable
|
||||
@@ -252,6 +399,12 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
dh: DiscoveredHost? = null,
|
||||
manualName: String? = null,
|
||||
) {
|
||||
// Every dial/pair path funnels through here — with local network access denied the connect
|
||||
// can only EPERM its way to a 10 s timeout, so ask instead of pretending to try.
|
||||
if (!lnpGranted) {
|
||||
lnpPrompt = true
|
||||
return
|
||||
}
|
||||
val known = knownHostStore.get(targetHost, targetPort)
|
||||
val adv = dh?.fingerprint?.lowercase()
|
||||
// Label precedence: a saved host keeps its (possibly user-renamed) name; else the discovered
|
||||
@@ -277,6 +430,62 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
|
||||
var showManualSheet by remember { mutableStateOf(false) }
|
||||
|
||||
if (gamepadUi) {
|
||||
// Console mode: the host carousel (saved → discovered → Add Host), driven by the pad. Shares
|
||||
// every action above; the trailing Add Host tile opens the same manual-entry sheet.
|
||||
val tiles = buildList {
|
||||
savedHosts.forEach { kh ->
|
||||
add(
|
||||
HomeTile(
|
||||
id = "saved-${kh.address}:${kh.port}",
|
||||
title = kh.name,
|
||||
subtitle = "${kh.address}:${kh.port}",
|
||||
filled = true,
|
||||
online = kh.isOnline(discovered, reachable),
|
||||
paired = kh.paired,
|
||||
knownHost = kh,
|
||||
activate = { connect(kh.address, kh.port) },
|
||||
),
|
||||
)
|
||||
}
|
||||
discoveredUnsaved.forEach { dh ->
|
||||
add(
|
||||
HomeTile(
|
||||
id = "disc-${dh.host}:${dh.port}",
|
||||
title = dh.name,
|
||||
subtitle = "${dh.host}:${dh.port}",
|
||||
online = true,
|
||||
activate = { connect(dh.host, dh.port, dh) },
|
||||
),
|
||||
)
|
||||
}
|
||||
add(
|
||||
HomeTile(
|
||||
id = "add",
|
||||
title = "Add Host",
|
||||
subtitle = "Register a host by address",
|
||||
isAdd = true,
|
||||
activate = { showManualSheet = true },
|
||||
),
|
||||
)
|
||||
}
|
||||
GamepadHome(
|
||||
tiles = tiles,
|
||||
libraryEnabled = settings.libraryEnabled,
|
||||
controllerName = io.unom.punktfunk.kit.Gamepad.firstPad()?.name,
|
||||
// Stop the carousel from consuming the pad while a sheet/dialog/overlay owns the screen,
|
||||
// while a connect is in flight (else a second A launches a concurrent connect that leaks a
|
||||
// handle — the touch grid guards the same way with enabled=!connecting), or while the whole
|
||||
// console home is cross-fading out.
|
||||
navActive = navGate && !connecting && !showManualSheet && pendingTrust == null &&
|
||||
awaiting == null && editTarget == null && optionsTarget == null &&
|
||||
waker.waking == null && !lnpPrompt,
|
||||
onActivate = { it.activate() },
|
||||
onOpenLibrary = { it.knownHost?.let(onOpenLibrary) },
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOptions = { it.knownHost?.let { kh -> optionsTarget = kh } },
|
||||
)
|
||||
} else {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 160.dp),
|
||||
@@ -337,6 +546,38 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!lnpGranted) {
|
||||
// Local network access denied: discovery can't ever find anything and every connect
|
||||
// would time out — say so at the top, with the fix one tap away, instead of letting
|
||||
// the screen look idle/broken.
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
"Local network access is off",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Text(
|
||||
"Android blocks punktfunk from finding or reaching hosts until you allow it.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
TextButton(onClick = { lnpPrompt = true }) { Text("Allow…") }
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (savedHosts.isEmpty() && discoveredUnsaved.isEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
EmptyHostsState()
|
||||
@@ -352,13 +593,37 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
name = kh.name,
|
||||
address = "${kh.address}:${kh.port}",
|
||||
status = if (kh.paired) HostStatus.PAIRED else HostStatus.TOFU,
|
||||
online = kh.isOnline(discovered, reachable),
|
||||
enabled = !connecting,
|
||||
onConnect = { connect(kh.address, kh.port) },
|
||||
onForget = {
|
||||
knownHostStore.remove(kh.address, kh.port)
|
||||
savedHosts = knownHostStore.all()
|
||||
},
|
||||
onRename = { renameTarget = kh },
|
||||
onEdit = { editTarget = kh },
|
||||
// Explicit wake-only: offered when the host is offline and we have a MAC. Runs
|
||||
// through the WakeController so it shows the "Waking…" overlay and waits for
|
||||
// the host to come online (matched by fingerprint, so a new DHCP address on a
|
||||
// cold boot still counts as "up") rather than firing a single silent packet.
|
||||
onWake = if (kh.mac.isNotEmpty() && !kh.isOnline(discovered, reachable)) {
|
||||
{
|
||||
// The magic packet is UDP broadcast — LNP-blocked like everything else.
|
||||
if (!lnpGranted) {
|
||||
lnpPrompt = true
|
||||
} else {
|
||||
waker.start(
|
||||
hostName = kh.name,
|
||||
connectsAfter = false,
|
||||
macs = kh.mac,
|
||||
lastIp = kh.address,
|
||||
isOnline = { discovered.any { kh.matches(it) } },
|
||||
onOnline = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -373,6 +638,7 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
name = dh.name,
|
||||
address = "${dh.host}:${dh.port}",
|
||||
status = if (dh.pairingRequired) HostStatus.PAIRING else HostStatus.TOFU,
|
||||
online = true, // in the discovered list ⇒ live on mDNS right now
|
||||
enabled = !connecting,
|
||||
onConnect = { connect(dh.host, dh.port, dh) },
|
||||
onForget = null,
|
||||
@@ -382,8 +648,10 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
|
||||
// Active-discovery hint: discovery runs whenever this screen is up, so while it's
|
||||
// scanning but nothing's turned up yet (and we're not mid-connect), show it's working
|
||||
// rather than looking idle/empty.
|
||||
if (!connecting && discovered.isEmpty()) {
|
||||
// rather than looking idle/empty. Suppressed while local network access is denied —
|
||||
// a spinner would be a lie there (the browse can't receive anything); the banner above
|
||||
// owns that state.
|
||||
if (lnpGranted && !connecting && discovered.isEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
|
||||
@@ -416,8 +684,20 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
.padding(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showManualSheet) {
|
||||
if (gamepadUi) {
|
||||
// Console add-host: field list + on-screen controller keyboard. "Add" connects (which
|
||||
// saves the host on TOFU/pair), exactly like the touch sheet's Connect.
|
||||
GamepadAddHostScreen(
|
||||
onAdd = { n, addr, p ->
|
||||
showManualSheet = false
|
||||
connect(addr, p, manualName = n)
|
||||
},
|
||||
onDismiss = { showManualSheet = false },
|
||||
)
|
||||
} else {
|
||||
AddHostSheet(
|
||||
hostName = hostName,
|
||||
onHostNameChange = { hostName = it },
|
||||
@@ -431,66 +711,136 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
onConnect = { h2, p, n -> connect(h2, p, manualName = n) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pendingTrust?.let { pt ->
|
||||
when (pt.kind) {
|
||||
PendingTrust.Kind.TRUST_NEW -> TrustNewHostDialog(
|
||||
pt = pt,
|
||||
onTrust = { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) },
|
||||
onPairInstead = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) },
|
||||
onDismiss = { pendingTrust = null },
|
||||
)
|
||||
PendingTrust.Kind.FP_CHANGED -> FingerprintChangedDialog(
|
||||
pt = pt,
|
||||
onRepair = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) },
|
||||
onDismiss = { pendingTrust = null },
|
||||
)
|
||||
PendingTrust.Kind.REQUEST_ACCESS -> RequestAccessDialog(
|
||||
pt = pt,
|
||||
onRequestAccess = { pendingTrust = null; requestAccess(pt) },
|
||||
onUsePin = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) },
|
||||
onDismiss = { pendingTrust = null },
|
||||
)
|
||||
PendingTrust.Kind.PAIR -> PairPinDialog(
|
||||
pt = pt,
|
||||
identity = identity,
|
||||
onPaired = { fp ->
|
||||
// Verified host fp — save as a paired known host, then connect pinned.
|
||||
// Same trust/pairing logic, console-styled + controller-navigable in gamepad mode.
|
||||
val onPair = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }
|
||||
val onSavePaired = { fp: String ->
|
||||
knownHostStore.save(KnownHost(pt.host, pt.port, pt.name, fp, paired = true))
|
||||
savedHosts = knownHostStore.all()
|
||||
pendingTrust = null
|
||||
doConnect(pt.host, pt.port, pt.name, fp)
|
||||
},
|
||||
onDismiss = { pendingTrust = null },
|
||||
)
|
||||
}
|
||||
when (pt.kind) {
|
||||
PendingTrust.Kind.TRUST_NEW ->
|
||||
if (gamepadUi) GamepadTrustNewDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) }, onPair, { pendingTrust = null })
|
||||
else TrustNewHostDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) }, onPair, { pendingTrust = null })
|
||||
PendingTrust.Kind.FP_CHANGED ->
|
||||
if (gamepadUi) GamepadFingerprintChangedDialog(pt, onPair, { pendingTrust = null })
|
||||
else FingerprintChangedDialog(pt, onPair, { pendingTrust = null })
|
||||
PendingTrust.Kind.REQUEST_ACCESS ->
|
||||
if (gamepadUi) GamepadRequestAccessDialog(pt, { pendingTrust = null; requestAccess(pt) }, onPair, { pendingTrust = null })
|
||||
else RequestAccessDialog(pt, { pendingTrust = null; requestAccess(pt) }, onPair, { pendingTrust = null })
|
||||
PendingTrust.Kind.PAIR ->
|
||||
if (gamepadUi) GamepadPairPinDialog(pt, identity, onSavePaired, { pendingTrust = null })
|
||||
else PairPinDialog(pt, identity, onSavePaired, { pendingTrust = null })
|
||||
}
|
||||
}
|
||||
|
||||
awaiting?.let { req ->
|
||||
AwaitingApprovalDialog(
|
||||
hostLabel = req.target.name,
|
||||
onCancel = {
|
||||
val onCancel = {
|
||||
req.cancelled.set(true)
|
||||
awaiting = null
|
||||
connecting = false
|
||||
discovery.start() // the request may still be pending on the host; keep scanning
|
||||
}
|
||||
if (gamepadUi) GamepadAwaitingApprovalDialog(req.target.name, onCancel)
|
||||
else AwaitingApprovalDialog(hostLabel = req.target.name, onCancel = onCancel)
|
||||
}
|
||||
|
||||
// Console host options (Up on a saved carousel tile): Wake / Edit / Forget.
|
||||
optionsTarget?.let { kh ->
|
||||
val offline = !kh.isOnline(discovered, reachable)
|
||||
GamepadHostOptionsDialog(
|
||||
hostName = kh.name,
|
||||
canWake = kh.mac.isNotEmpty() && offline,
|
||||
onWake = {
|
||||
optionsTarget = null
|
||||
// The magic packet is UDP broadcast — LNP-blocked like everything else.
|
||||
if (!lnpGranted) {
|
||||
lnpPrompt = true
|
||||
} else {
|
||||
waker.start(
|
||||
hostName = kh.name, connectsAfter = false, macs = kh.mac, lastIp = kh.address,
|
||||
isOnline = { discovered.any { kh.matches(it) } },
|
||||
onOnline = {},
|
||||
)
|
||||
}
|
||||
},
|
||||
// A saved host always has a library (it's a knownHost) → offer it when the setting's on,
|
||||
// so a TV remote reaches the library here instead of via the Y face button.
|
||||
onLibrary = if (settings.libraryEnabled) {
|
||||
{ optionsTarget = null; onOpenLibrary(kh) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onEdit = { optionsTarget = null; editTarget = kh },
|
||||
onForget = {
|
||||
knownHostStore.remove(kh.address, kh.port)
|
||||
savedHosts = knownHostStore.all()
|
||||
optionsTarget = null
|
||||
},
|
||||
onDismiss = { optionsTarget = null },
|
||||
)
|
||||
}
|
||||
|
||||
renameTarget?.let { kh ->
|
||||
RenameHostDialog(
|
||||
target = kh,
|
||||
onRename = { newName ->
|
||||
knownHostStore.rename(kh.address, kh.port, newName)
|
||||
editTarget?.let { kh ->
|
||||
// Prefill a not-yet-learned MAC from the host's live advert, mirroring Apple's
|
||||
// `discovery.hosts.first { host.matches($0) }?.macAddresses`.
|
||||
val suggested = discovered.firstOrNull { kh.matches(it) }?.mac ?: emptyList()
|
||||
val onSaveHost: (KnownHost) -> Unit = { updated ->
|
||||
knownHostStore.update(kh.address, kh.port, updated)
|
||||
savedHosts = knownHostStore.all()
|
||||
renameTarget = null
|
||||
},
|
||||
onDismiss = { renameTarget = null },
|
||||
editTarget = null
|
||||
}
|
||||
if (gamepadUi) {
|
||||
// Console edit: the same field list + on-screen keyboard as Add-Host, seeded from the
|
||||
// host with an extra MAC row; the action SAVES instead of connecting.
|
||||
GamepadAddHostScreen(
|
||||
onAdd = { _, _, _ -> },
|
||||
onDismiss = { editTarget = null },
|
||||
editHost = kh,
|
||||
suggestedMacs = suggested,
|
||||
onSave = onSaveHost,
|
||||
)
|
||||
} else {
|
||||
EditHostDialog(
|
||||
target = kh,
|
||||
suggestedMacs = suggested,
|
||||
onSave = onSaveHost,
|
||||
onDismiss = { editTarget = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (lnpPrompt) {
|
||||
// Android 17+ local-network-permission rationale: re-request (a permanently-denied request
|
||||
// returns instantly without a system prompt — hence the settings deep link alongside).
|
||||
val onAllow = {
|
||||
lnpPrompt = false
|
||||
localNetLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
|
||||
}
|
||||
val onSettings = {
|
||||
lnpPrompt = false
|
||||
context.startActivity(
|
||||
Intent(
|
||||
android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.fromParts("package", context.packageName, null),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (gamepadUi) {
|
||||
GamepadLocalNetworkDialog(onAllow = onAllow, onSettings = onSettings, onDismiss = { lnpPrompt = false })
|
||||
} else {
|
||||
LocalNetworkDialog(onAllow = onAllow, onSettings = onSettings, onDismiss = { lnpPrompt = false })
|
||||
}
|
||||
}
|
||||
|
||||
// Topmost: the "Waking…" overlay rides over both the touch grid and the console home.
|
||||
WakeOverlay(waker, gamepadUi)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether NEARBY_WIFI_DEVICES is held (API 33+; not applicable below). We request it opportunistically
|
||||
* as a multicast-reception hedge on OEMs that filter multicast without it, but discovery (raw mDNS via
|
||||
@@ -501,6 +851,18 @@ fun hasNearbyPermission(context: Context): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.NEARBY_WIFI_DEVICES) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
/**
|
||||
* Whether ACCESS_LOCAL_NETWORK is held (API 37+; below, the permission doesn't exist and local
|
||||
* network access is implicit). Android 17's Local Network Protection blocks ALL local-network
|
||||
* traffic for apps targeting SDK 37 without this runtime grant: UDP sends fail with EPERM, so the
|
||||
* QUIC dial surfaces as a silent handshake timeout and the mDNS browse receives nothing. Unlike
|
||||
* [hasNearbyPermission] this is load-bearing — nothing on the connect screen works without it.
|
||||
*/
|
||||
fun hasLocalNetworkPermission(context: Context): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.CINNAMON_BUN ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_LOCAL_NETWORK) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
/**
|
||||
* True when a saved host and a discovered advert are the same machine — matched by certificate
|
||||
* fingerprint when both carry it (so it survives a DHCP address change), else by address:port.
|
||||
@@ -511,3 +873,11 @@ private fun KnownHost.matches(dh: DiscoveredHost): Boolean {
|
||||
if (!advFp.isNullOrEmpty() && fpHex.isNotEmpty() && fpHex.lowercase() == advFp) return true
|
||||
return address == dh.host && port == dh.port
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a saved host is reachable RIGHT NOW: advertising on mDNS OR answering the QUIC probe
|
||||
* (a host reached over a routed network — Tailscale/VPN — never advertises but is reachable). The
|
||||
* display-side companion to dial-first: presence no longer means "on this LAN".
|
||||
*/
|
||||
private fun KnownHost.isOnline(discovered: List<DiscoveredHost>, reachable: Set<String>): Boolean =
|
||||
discovered.any { matches(it) } || reachable.contains("$address:$port")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.hardware.input.InputManager
|
||||
import android.os.Build
|
||||
import android.os.CombinedVibration
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
@@ -244,7 +245,7 @@ private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
val canRumble = dev.vibratorManager.vibratorIds.isNotEmpty()
|
||||
val canRumble = deviceHasVibrator(dev)
|
||||
if (canRumble) {
|
||||
OutlinedButton(onClick = { testRumble(dev) }) { Text("Test rumble") }
|
||||
} else {
|
||||
@@ -318,11 +319,27 @@ private fun Group(title: String, content: @Composable ColumnScope.() -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the controller reports a rumble motor — via VibratorManager (API 31+) or the legacy Vibrator. */
|
||||
private fun deviceHasVibrator(dev: InputDevice): Boolean =
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
dev.vibratorManager.vibratorIds.isNotEmpty()
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
dev.vibrator.hasVibrator()
|
||||
}
|
||||
|
||||
private fun testRumble(dev: InputDevice) {
|
||||
runCatching {
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
val vm = dev.vibratorManager
|
||||
if (vm.vibratorIds.isEmpty()) return
|
||||
runCatching {
|
||||
vm.vibrate(CombinedVibration.createParallel(VibrationEffect.createOneShot(300, 200)))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
val v = dev.vibrator
|
||||
if (!v.hasVibrator()) return
|
||||
v.vibrate(VibrationEffect.createOneShot(300, 200))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
// The gamepad-driven "Add Host" screen — the Android mirror of the Apple client's GamepadAddHostView
|
||||
// + GamepadKeyboard: three field rows (name / address / port) plus an Add action, navigated with the
|
||||
// vertical focus list; A on a field opens the on-screen keyboard so a host can be registered end to
|
||||
// end from the couch. One GamepadNavEffect2D owns BOTH modes (list vs keyboard) so they never fight
|
||||
// over the shared input probes. B peels one layer: close the keyboard, then cancel the screen.
|
||||
|
||||
// Keyboard grid: digits, qwerty letters, hostname/address punctuation, then space / delete / done.
|
||||
private val KB_CHAR_ROWS = listOf("1234567890", "qwertyuiop", "asdfghjkl-", "zxcvbnm._:")
|
||||
private const val KB_ACTIONS_ROW = 4 // index of the [space, delete, done] row
|
||||
private const val KB_ROWS = 5
|
||||
|
||||
private class Field(val id: String, val label: String, val value: String, val placeholder: String)
|
||||
|
||||
@Composable
|
||||
fun GamepadAddHostScreen(
|
||||
onAdd: (name: String, address: String, port: Int) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
// Non-null → EDIT mode: fields seed from this host, a MAC row is added, and the action SAVES the
|
||||
// edited record via [onSave] instead of connecting. [suggestedMacs] prefills a not-yet-learned MAC.
|
||||
editHost: KnownHost? = null,
|
||||
suggestedMacs: List<String> = emptyList(),
|
||||
onSave: ((KnownHost) -> Unit)? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val isTv = remember { isTvDevice(context) }
|
||||
val isEdit = editHost != null
|
||||
val title = if (isEdit) "Edit Host" else "Add Host"
|
||||
val actionLabel = if (isEdit) "Save" else "Add Host"
|
||||
var name by remember { mutableStateOf(editHost?.name ?: "") }
|
||||
var address by remember { mutableStateOf(editHost?.address ?: "") }
|
||||
var port by remember { mutableStateOf(editHost?.port?.toString() ?: "9777") }
|
||||
var mac by remember { mutableStateOf(editHost?.mac?.ifEmpty { suggestedMacs }?.joinToString(", ") ?: "") }
|
||||
val canAdd = address.isNotBlank() && (port.toIntOrNull() ?: 0) > 0
|
||||
fun commit() {
|
||||
if (isEdit && editHost != null && onSave != null) {
|
||||
onSave(
|
||||
editHost.copy(
|
||||
name = name.trim().ifEmpty { editHost.address },
|
||||
address = address.trim(),
|
||||
port = port.toIntOrNull() ?: editHost.port,
|
||||
mac = KnownHostStore.parseMacs(mac),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
onAdd(name.trim(), address.trim(), port.toIntOrNull() ?: 9777)
|
||||
}
|
||||
}
|
||||
|
||||
// On a TV the OS provides a leanback on-screen keyboard for text fields, so use real (focusable)
|
||||
// text fields + the system IME there. Our controller keyboard is for a phone-with-controller,
|
||||
// where the phone's own soft keyboard needs a touch a pad can't provide.
|
||||
if (isTv) {
|
||||
TvAddHostForm(
|
||||
title = title, actionLabel = actionLabel,
|
||||
name = name, onName = { name = it },
|
||||
address = address, onAddress = { address = it },
|
||||
port = port, onPort = { port = it.filter(Char::isDigit).take(5) },
|
||||
mac = if (isEdit) mac else null, onMac = { mac = it },
|
||||
canAdd = canAdd,
|
||||
onAdd = { commit() },
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
var focus by remember { mutableIntStateOf(1) } // start on Address
|
||||
var editing by remember { mutableStateOf<String?>(null) } // field id being typed, or null
|
||||
var kbRow by remember { mutableIntStateOf(1) }
|
||||
var kbCol by remember { mutableIntStateOf(0) }
|
||||
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
val hazeState = remember { HazeState() }
|
||||
|
||||
val fields = buildList {
|
||||
add(Field("name", "Name", name, "Optional — e.g. Living Room"))
|
||||
add(Field("address", "Address", address, "IP or hostname"))
|
||||
add(Field("port", "Port", port, "9777"))
|
||||
if (isEdit) add(Field("mac", "Wake MAC", mac, "auto-filled when the host is seen"))
|
||||
}
|
||||
val actionIndex = fields.size // the Save/Add action sits just after the last field
|
||||
|
||||
fun openKeyboard(id: String) { editing = id; kbRow = 1; kbCol = 0 }
|
||||
fun closeKeyboard() { editing = null }
|
||||
fun editField(id: String, transform: (String) -> String) {
|
||||
when (id) {
|
||||
"name" -> name = transform(name)
|
||||
"address" -> address = transform(address)
|
||||
"port" -> port = transform(port).take(5)
|
||||
"mac" -> mac = transform(mac)
|
||||
}
|
||||
}
|
||||
fun allowed(id: String, c: Char): Boolean = when (id) {
|
||||
"port" -> c.isDigit()
|
||||
"address" -> c != ' '
|
||||
else -> true
|
||||
}
|
||||
fun activateField() {
|
||||
if (focus == actionIndex) {
|
||||
if (canAdd) commit() else { focus = 1; openKeyboard("address") }
|
||||
} else {
|
||||
openKeyboard(fields[focus].id)
|
||||
}
|
||||
}
|
||||
fun pressKey() {
|
||||
val id = editing ?: return
|
||||
if (kbRow < KB_ACTIONS_ROW) {
|
||||
val c = KB_CHAR_ROWS[kbRow][kbCol.coerceIn(0, KB_CHAR_ROWS[kbRow].lastIndex)]
|
||||
if (allowed(id, c)) editField(id) { it + c }
|
||||
} else when (kbCol) {
|
||||
0 -> if (allowed(id, ' ')) editField(id) { "$it " }
|
||||
1 -> editField(id) { it.dropLast(1) }
|
||||
else -> closeKeyboard()
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler { if (editing != null) closeKeyboard() else onDismiss() }
|
||||
GamepadNavEffect2D(
|
||||
active = true,
|
||||
onDirection = { dir ->
|
||||
if (editing == null) {
|
||||
when (dir) {
|
||||
NavDir.UP -> if (focus > 0) focus--
|
||||
NavDir.DOWN -> if (focus < actionIndex) focus++
|
||||
else -> {}
|
||||
}
|
||||
} else {
|
||||
when (dir) {
|
||||
NavDir.UP -> if (kbRow > 0) { kbRow--; kbCol = kbCol.coerceIn(0, rowCols(kbRow) - 1) }
|
||||
NavDir.DOWN -> if (kbRow < KB_ROWS - 1) { kbRow++; kbCol = kbCol.coerceIn(0, rowCols(kbRow) - 1) }
|
||||
NavDir.LEFT -> if (kbCol > 0) kbCol--
|
||||
NavDir.RIGHT -> if (kbCol < rowCols(kbRow) - 1) kbCol++
|
||||
}
|
||||
}
|
||||
},
|
||||
onActivate = { if (editing == null) activateField() else pressKey() },
|
||||
onTertiary = { if (editing != null) editField(editing!!) { it.dropLast(1) } },
|
||||
onSecondary = { if (editing != null) closeKeyboard() },
|
||||
)
|
||||
|
||||
val onFieldClick: (Int) -> Unit = { i -> if (focus == i) activateField() else focus = i }
|
||||
val onAddClick: () -> Unit = { if (focus == actionIndex) activateField() else focus = actionIndex }
|
||||
// Tappable (touch escape hatch): the legend doubles as buttons when there's no working controller.
|
||||
val typeHints = listOf(
|
||||
PadGlyph.hint('A', "Type") { pressKey() },
|
||||
PadGlyph.hint('X', "Delete") { editing?.let { id -> editField(id) { it.dropLast(1) } } },
|
||||
PadGlyph.hint('B', "Done") { closeKeyboard() },
|
||||
)
|
||||
val sideBySide = landscape && editing != null
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||
GamepadFormBackground(Modifier.fillMaxSize())
|
||||
|
||||
if (sideBySide) {
|
||||
// Landscape + typing: fields and keyboard SIDE BY SIDE so the field being edited stays
|
||||
// visible (stacked, the keyboard covered the whole short screen). The legend is NOT put
|
||||
// under the keyboard here — it floats at the same fixed bottom-left spot as everywhere.
|
||||
Row(
|
||||
Modifier.fillMaxSize().systemBarsPadding().padding(start = ConsoleEdgeInset, end = 20.dp, top = 8.dp, bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
Column(
|
||||
Modifier.weight(1f).fillMaxHeight().verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ConsoleHeader(title, horizontalInset = false)
|
||||
fields.forEachIndexed { i, f -> FieldRow(f, focused = false, editing = editing == f.id) { onFieldClick(i) } }
|
||||
AddActionRow(actionLabel, enabled = canAdd, focused = false) { onAddClick() }
|
||||
Spacer(Modifier.height(64.dp)) // clear the floating legend at bottom-left
|
||||
}
|
||||
Column(
|
||||
Modifier.weight(1.15f).fillMaxHeight().verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
KeyboardGrid(kbRow, kbCol, compact = true) { r, c -> kbRow = r; kbCol = c; pressKey() }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Portrait (or landscape not typing): the FORM SCROLLS so the Add button is never
|
||||
// compressed by the keyboard; the keyboard sits below it; the legend floats (fixed).
|
||||
Column(Modifier.fillMaxSize().systemBarsPadding().padding(horizontal = ConsoleEdgeInset)) {
|
||||
Column(
|
||||
Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ConsoleHeader(title, horizontalInset = false)
|
||||
if (editing == null && !landscape) {
|
||||
Text(
|
||||
"Hosts on this network appear automatically — add one by address for everything else.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White.copy(alpha = 0.55f),
|
||||
modifier = Modifier.widthIn(max = 520.dp).padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
fields.forEachIndexed { i, f -> FieldRow(f, focused = focus == i && editing == null, editing = editing == f.id) { onFieldClick(i) } }
|
||||
AddActionRow(actionLabel, enabled = canAdd, focused = focus == actionIndex && editing == null) { onAddClick() }
|
||||
Spacer(Modifier.height(72.dp)) // last field clears the floating legend when scrolled
|
||||
}
|
||||
if (editing != null) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
// The keyboard fills to the bottom; its bottom frame is padded so the fixed
|
||||
// legend sits OVER that frame (bottom-left corner) rather than in a gap below.
|
||||
KeyboardGrid(kbRow, kbCol, compact = false, bottomInset = 52.dp) { r, c -> kbRow = r; kbCol = c; pressKey() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Floating legend — ALWAYS at the same fixed bottom-start spot (portrait or landscape, keyboard
|
||||
// open or not), so opening the keyboard never relocates it below the keys. Backdrop-blurred.
|
||||
Box(
|
||||
Modifier.align(Alignment.BottomStart)
|
||||
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(
|
||||
if (editing != null) {
|
||||
typeHints
|
||||
} else {
|
||||
listOf(
|
||||
PadGlyph.hint('A', "Select") { activateField() },
|
||||
PadGlyph.hint('B', "Cancel", onClick = onDismiss),
|
||||
)
|
||||
},
|
||||
hazeState = hazeState,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add-Host on a TV: real focusable text fields + the system (leanback) IME, driven by the OS. No
|
||||
* custom keyboard or input probes — the native focus engine moves between fields and the Add button,
|
||||
* and focusing a field pops the OS keyboard. B backs out.
|
||||
*/
|
||||
@Composable
|
||||
private fun TvAddHostForm(
|
||||
title: String,
|
||||
actionLabel: String,
|
||||
name: String,
|
||||
onName: (String) -> Unit,
|
||||
address: String,
|
||||
onAddress: (String) -> Unit,
|
||||
port: String,
|
||||
onPort: (String) -> Unit,
|
||||
mac: String?, // non-null only in edit mode
|
||||
onMac: (String) -> Unit,
|
||||
canAdd: Boolean,
|
||||
onAdd: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
BackHandler(onBack = onDismiss)
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
GamepadFormBackground(Modifier.fillMaxSize())
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.systemBarsPadding()
|
||||
.padding(horizontal = 56.dp, vertical = 36.dp)
|
||||
.widthIn(max = 720.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
Text(
|
||||
"Hosts on this network appear automatically — add one by address for everything else.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White.copy(alpha = 0.55f),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = name, onValueChange = onName, singleLine = true,
|
||||
label = { Text("Name (optional)") },
|
||||
modifier = Modifier.fillMaxWidth().focusRequester(firstFocus),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = address, onValueChange = onAddress, singleLine = true,
|
||||
label = { Text("Address") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = port, onValueChange = onPort, singleLine = true,
|
||||
label = { Text("Port") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (mac != null) {
|
||||
OutlinedTextField(
|
||||
value = mac, onValueChange = onMac, singleLine = true,
|
||||
label = { Text("Wake-on-LAN MAC") },
|
||||
placeholder = { Text("auto-filled when the host is seen") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
Button(onClick = onAdd, enabled = canAdd, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(actionLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) { runCatching { firstFocus.requestFocus() } }
|
||||
}
|
||||
|
||||
private fun rowCols(row: Int): Int = if (row < KB_ACTIONS_ROW) KB_CHAR_ROWS[row].length else 3
|
||||
|
||||
@Composable
|
||||
private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -> Unit) {
|
||||
val visuals = animateConsoleFocus(active = focused || editing, editing = editing)
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.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 = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(f.label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(
|
||||
f.value.ifEmpty { f.placeholder },
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||
color = if (f.value.isEmpty()) Color.White.copy(alpha = 0.35f) else Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (editing) Text(" |", color = Color(0xFF8678F5))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onClick: () -> Unit) {
|
||||
val visuals = animateConsoleFocus(active = focused)
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
val labelColor by animateColorAsState(
|
||||
if (enabled) Color(0xFF8678F5) else Color.White.copy(alpha = 0.35f),
|
||||
tween(160),
|
||||
label = "addLabel",
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.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(vertical = 14.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = labelColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KeyboardGrid(
|
||||
cursorRow: Int,
|
||||
cursorCol: Int,
|
||||
compact: Boolean,
|
||||
bottomInset: Dp = 0.dp, // empty frame at the bottom of the glass for the floating legend to sit over
|
||||
onKey: (Int, Int) -> Unit,
|
||||
) {
|
||||
val shape = RoundedCornerShape(20.dp)
|
||||
val gap = if (compact) 5.dp else 7.dp
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 640.dp)
|
||||
.clip(shape)
|
||||
.background(Color(0x1FFFFFFF))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.12f), shape)
|
||||
.padding(start = 12.dp, end = 12.dp, top = if (compact) 8.dp else 12.dp, bottom = 12.dp + bottomInset),
|
||||
verticalArrangement = Arrangement.spacedBy(gap),
|
||||
) {
|
||||
KB_CHAR_ROWS.forEachIndexed { r, chars ->
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(gap)) {
|
||||
chars.forEachIndexed { c, ch ->
|
||||
Keycap(ch.toString(), focused = cursorRow == r && cursorCol == c, compact = compact, modifier = Modifier.weight(1f)) { onKey(r, c) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(gap)) {
|
||||
Keycap("space", focused = cursorRow == KB_ACTIONS_ROW && cursorCol == 0, compact = compact, modifier = Modifier.weight(2f)) { onKey(KB_ACTIONS_ROW, 0) }
|
||||
Keycap("⌫", focused = cursorRow == KB_ACTIONS_ROW && cursorCol == 1, compact = compact, modifier = Modifier.weight(1f)) { onKey(KB_ACTIONS_ROW, 1) }
|
||||
Keycap("Done", focused = cursorRow == KB_ACTIONS_ROW && cursorCol == 2, compact = compact, modifier = Modifier.weight(1.5f)) { onKey(KB_ACTIONS_ROW, 2) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
// Fast tweens: the keyboard cursor hops many keys per second under hold-to-repeat, so the
|
||||
// trailing key must have faded before the cursor is two keys away — quick, but no longer a snap.
|
||||
val bg by animateColorAsState(
|
||||
if (focused) Color(0xFF8678F5) else Color(0x14FFFFFF),
|
||||
tween(90),
|
||||
label = "keyBg",
|
||||
)
|
||||
val fg by animateColorAsState(if (focused) Color.Black else Color.White, tween(90), label = "keyFg")
|
||||
Box(
|
||||
modifier = modifier
|
||||
.height(if (compact) 34.dp else 44.dp)
|
||||
.clip(RoundedCornerShape(9.dp))
|
||||
.background(bg)
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = fg,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.SportsEsports
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
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.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sin
|
||||
|
||||
// The console chrome shared by the gamepad-driven screens — the Android mirror of the Apple client's
|
||||
// GamepadChrome.swift: a slow-drifting violet aurora backdrop, a bottom button-glyph hint bar, and a
|
||||
// connected-controller status chip. One look across every screen is what makes the console UI read
|
||||
// as a coherent mode rather than a set of themed pages.
|
||||
|
||||
/** One drifting colour blob of the aurora field. Integer [sx]/[sy] keep the loop seamless at wrap. */
|
||||
private class AuroraBlob(
|
||||
val color: Color,
|
||||
val baseX: Float,
|
||||
val baseY: Float,
|
||||
val driftX: Float,
|
||||
val driftY: Float,
|
||||
val sx: Int,
|
||||
val sy: Int,
|
||||
val phase: Float,
|
||||
val radiusFrac: Float,
|
||||
val alpha: Float,
|
||||
)
|
||||
|
||||
private val auroraBlobs = listOf(
|
||||
AuroraBlob(Color(0xFF877AF5), 0.30f, 0.26f, 0.16f, 0.10f, 1, 1, 0.0f, 0.62f, 0.55f), // brand violet
|
||||
AuroraBlob(Color(0xFF3E33B8), 0.78f, 0.68f, 0.13f, 0.14f, 1, 2, 2.4f, 0.68f, 0.58f), // deep indigo
|
||||
AuroraBlob(Color(0xFF9E4CCC), 0.16f, 0.82f, 0.12f, 0.09f, 2, 1, 4.1f, 0.52f, 0.42f), // plum
|
||||
AuroraBlob(Color(0xFF3862DB), 0.72f, 0.14f, 0.10f, 0.08f, 1, 3, 1.2f, 0.48f, 0.40f), // cool blue
|
||||
)
|
||||
|
||||
/**
|
||||
* The living console backdrop: soft violet-family blobs drifting over black on slow, seamless loops,
|
||||
* finished with a centre-pooling vignette and top/bottom legibility scrims. A Compose approximation
|
||||
* of the Apple client's MeshGradient aurora — same brand family, same "ambience, never content" role.
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadAuroraBackground(modifier: Modifier = Modifier) {
|
||||
val transition = rememberInfiniteTransition(label = "aurora")
|
||||
// A full 0..2π sweep over ~96 s; integer per-blob multipliers make sin/cos continuous at the wrap
|
||||
// so the field never visibly jumps when the animation restarts.
|
||||
val angle by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = (2 * PI).toFloat(),
|
||||
animationSpec = infiniteRepeatable(tween(96_000, easing = LinearEasing), RepeatMode.Restart),
|
||||
label = "angle",
|
||||
)
|
||||
Canvas(modifier) {
|
||||
drawRect(Color.Black)
|
||||
val span = max(size.width, size.height)
|
||||
for (b in auroraBlobs) {
|
||||
val cx = (b.baseX + b.driftX * sin(angle * b.sx + b.phase)) * size.width
|
||||
val cy = (b.baseY + b.driftY * cos(angle * b.sy + b.phase)) * size.height
|
||||
val r = span * b.radiusFrac
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(b.color.copy(alpha = b.alpha), Color.Transparent),
|
||||
center = Offset(cx, cy),
|
||||
radius = r,
|
||||
),
|
||||
center = Offset(cx, cy),
|
||||
radius = r,
|
||||
blendMode = BlendMode.Plus,
|
||||
)
|
||||
}
|
||||
// Cinematic vignette: pool light centre, sink the corners.
|
||||
drawRect(
|
||||
Brush.radialGradient(
|
||||
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.44f)),
|
||||
center = Offset(size.width / 2, size.height / 2),
|
||||
radius = span * 0.92f,
|
||||
),
|
||||
)
|
||||
// Top/bottom legibility scrim for the pinned title + hint bar.
|
||||
drawRect(
|
||||
Brush.verticalGradient(
|
||||
0.0f to Color.Black.copy(alpha = 0.40f),
|
||||
0.30f to Color.Black.copy(alpha = 0.05f),
|
||||
0.70f to Color.Black.copy(alpha = 0.06f),
|
||||
1.0f to Color.Black.copy(alpha = 0.42f),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The calm backdrop for the console FORM screens (settings, add-host) — deliberately still and quiet
|
||||
* (unlike the launcher's drifting aurora), a deep indigo base with two soft brand glows so the glass
|
||||
* rows have some colour + luminance to sit on. Mirrors the Apple client's GamepadFormBackground.
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadFormBackground(modifier: Modifier = Modifier) {
|
||||
Canvas(modifier) {
|
||||
val span = max(size.width, size.height)
|
||||
drawRect(Color(0xFF131126))
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(Color(0xE6635AAE), Color.Transparent),
|
||||
center = Offset(size.width * 0.24f, size.height * 0.12f),
|
||||
radius = span * 0.7f,
|
||||
),
|
||||
center = Offset(size.width * 0.24f, size.height * 0.12f),
|
||||
radius = span * 0.7f,
|
||||
)
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(Color(0xBF343E96), Color.Transparent),
|
||||
center = Offset(size.width * 0.82f, size.height * 0.9f),
|
||||
radius = span * 0.7f,
|
||||
),
|
||||
center = Offset(size.width * 0.82f, size.height * 0.9f),
|
||||
radius = span * 0.7f,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact inset every console screen places its floating legend at (bottom-start), so the legend
|
||||
* sits in the SAME spot across Home / Settings / Add-Host and appears pinned while the content behind
|
||||
* it cross-fades between screens.
|
||||
*/
|
||||
val ConsoleLegendInset = PaddingValues(start = 24.dp, bottom = 24.dp)
|
||||
|
||||
/** The shared horizontal inset for a console screen's heading (matches the legend's left edge). */
|
||||
val ConsoleEdgeInset = 24.dp
|
||||
|
||||
/**
|
||||
* The heading every console screen uses — one style, one inset, so titles line up across Home /
|
||||
* Settings / Add-Host / Library. Callers place it at the top of their content (or float it, on Home).
|
||||
*/
|
||||
@Composable
|
||||
fun ConsoleHeader(title: String, modifier: Modifier = Modifier, horizontalInset: Boolean = true) {
|
||||
// `horizontalInset = false` when the caller's container already pads to ConsoleEdgeInset (e.g. a
|
||||
// LazyColumn contentPadding) — so the heading lands at the SAME 24dp on every screen either way.
|
||||
val h = if (horizontalInset) ConsoleEdgeInset else 0.dp
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier.padding(start = h, end = h, top = 18.dp, bottom = 10.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One glyph + label cell of a hint bar. [glyph] is the SEMANTIC face letter (the Android
|
||||
* `KEYCODE_BUTTON_*` name — 'A' = confirm/south); [color] its Xbox-convention hue. How the pair is
|
||||
* actually DRAWN is the hint bar's decision, per the driving controller's [Gamepad.PadStyle] — a
|
||||
* DualSense renders 'A' as the ✕ shape, a Switch pad as a monochrome letter. [onClick], when set,
|
||||
* makes the cell tappable — a TOUCH escape hatch so a user without a working controller can still
|
||||
* drive the console UI (and reach Settings to switch it off).
|
||||
*/
|
||||
class GamepadHint(
|
||||
val glyph: Char,
|
||||
val color: Color,
|
||||
val text: String,
|
||||
val onClick: (() -> Unit)? = null,
|
||||
// Render as the D-pad-centre "select" button (a ring) instead of a lettered face-button disc —
|
||||
// for a TV remote, which has no A/B/X/Y.
|
||||
val select: Boolean = false,
|
||||
// Render as the pad's physical Select/View/Create/− button (per PadStyle) — the button that
|
||||
// delivers KEYCODE_BUTTON_SELECT.
|
||||
val viewButton: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Xbox-convention face-button colours, so the glyphs read at a glance across the room. These are
|
||||
* the DEFAULT (Xbox/generic) rendering; the hint bar swaps in PlayStation shapes or Nintendo
|
||||
* monochrome per the driving pad's [Gamepad.PadStyle] at draw time.
|
||||
*/
|
||||
object PadGlyph {
|
||||
val A = Color(0xFF6BBE45)
|
||||
val B = Color(0xFFD14B4B)
|
||||
val X = Color(0xFF4B7BD1)
|
||||
val Y = Color(0xFFE0B23C)
|
||||
fun hint(glyph: Char, text: String, onClick: (() -> Unit)? = null) = GamepadHint(
|
||||
glyph, when (glyph) { 'A' -> A; 'B' -> B; 'X' -> X; 'Y' -> Y; else -> Color(0xFF9A93C7) }, text, onClick,
|
||||
)
|
||||
}
|
||||
|
||||
/** The dark button-face fill shared by the PlayStation / Nintendo / select-button badges. */
|
||||
internal val PadButtonFace = Color(0xFF2A2740)
|
||||
|
||||
/** The animated focus visuals of one console row/field/button — see [animateConsoleFocus]. */
|
||||
class ConsoleFocusVisuals(val scale: Float, val background: Color, val border: Color)
|
||||
|
||||
/**
|
||||
* The focus visuals every console form element shares (settings rows, add-host fields, action
|
||||
* rows), ANIMATED: the background/border cross-fade instead of snapping between the focused and
|
||||
* resting looks, and the scale pops on a soft spring. [editing] draws the brighter violet border
|
||||
* of a field actively receiving keyboard input.
|
||||
*/
|
||||
@Composable
|
||||
fun animateConsoleFocus(active: Boolean, editing: Boolean = false): ConsoleFocusVisuals {
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (active) 1f else 0.98f,
|
||||
animationSpec = spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow),
|
||||
label = "consoleScale",
|
||||
)
|
||||
val background by animateColorAsState(
|
||||
if (active) Color(0x336656F2) else Color(0x14FFFFFF),
|
||||
tween(160),
|
||||
label = "consoleBg",
|
||||
)
|
||||
val border by animateColorAsState(
|
||||
when {
|
||||
editing -> Color(0xB38678F5)
|
||||
active -> Color.White.copy(alpha = 0.28f)
|
||||
else -> Color.White.copy(alpha = 0.06f)
|
||||
},
|
||||
tween(160),
|
||||
label = "consoleBorder",
|
||||
)
|
||||
return ConsoleFocusVisuals(scale, background, border)
|
||||
}
|
||||
|
||||
/**
|
||||
* The console-styled switch a toggle row renders in place of an "On"/"Off" value: a brand-violet
|
||||
* track that tints as it engages while the knob slides across on a spring — the state change reads
|
||||
* from across the room, and the motion confirms the press.
|
||||
*/
|
||||
@Composable
|
||||
fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier) {
|
||||
val travel by animateFloatAsState(
|
||||
targetValue = if (on) 1f else 0f,
|
||||
animationSpec = spring(dampingRatio = 0.8f, stiffness = 600f),
|
||||
label = "switchKnob",
|
||||
)
|
||||
val track by animateColorAsState(
|
||||
if (on) Color(0xFF6656F2) else Color(0x26FFFFFF),
|
||||
tween(200),
|
||||
label = "switchTrack",
|
||||
)
|
||||
val outline by animateColorAsState(
|
||||
Color.White.copy(alpha = if (focused) 0.45f else 0.15f),
|
||||
tween(160),
|
||||
label = "switchOutline",
|
||||
)
|
||||
val trackW = 44.dp
|
||||
val trackH = 24.dp
|
||||
val pad = 3.dp
|
||||
val knob = trackH - pad * 2
|
||||
Box(
|
||||
modifier
|
||||
.size(trackW, trackH)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(track)
|
||||
.border(1.dp, outline, RoundedCornerShape(50)),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(horizontal = pad)
|
||||
.offset { IntOffset(((trackW - knob - pad * 2).toPx() * travel).roundToInt(), 0) }
|
||||
.size(knob)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A round face-button badge: a coloured disc with the button letter, like a controller's face. */
|
||||
@Composable
|
||||
fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(size)
|
||||
.clip(CircleShape)
|
||||
.background(color),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
glyph.toString(),
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = (size.value * 0.52f).sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The D-pad-centre "select" button — a green (confirm) disc with a ring; the TV-remote glyph for A. */
|
||||
@Composable
|
||||
private fun SelectGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||
Box(
|
||||
modifier = Modifier.size(size).clip(CircleShape).background(PadGlyph.A),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(Modifier.size(size * 0.46f).clip(CircleShape).border(2.dp, Color.White, CircleShape))
|
||||
}
|
||||
}
|
||||
|
||||
/** The remote's "Back" button — a back-arrow disc; the TV-remote glyph for B (back / cancel / done). */
|
||||
@Composable
|
||||
private fun BackGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||
GamepadButtonGlyph('↩', PadGlyph.B, size)
|
||||
}
|
||||
|
||||
/**
|
||||
* A PlayStation face button: the dark button face with the coloured shape outline Sony prints on it.
|
||||
* Keyed by the SEMANTIC letter (Android keycode name): A = ✕ cross, B = ○ circle, X = □ square,
|
||||
* Y = △ triangle — exactly how a Sony pad's buttons map to `KEYCODE_BUTTON_*`, in the classic
|
||||
* DualShock colours.
|
||||
*/
|
||||
@Composable
|
||||
internal fun PsFaceGlyph(glyph: Char, size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||
val color = when (glyph) {
|
||||
'A' -> Color(0xFF7C9CE8) // cross — light blue
|
||||
'B' -> Color(0xFFE0736F) // circle — red
|
||||
'X' -> Color(0xFFD48FC7) // square — pink
|
||||
else -> Color(0xFF5FBFA5) // triangle — green
|
||||
}
|
||||
Box(
|
||||
Modifier.size(size).clip(CircleShape).background(PadButtonFace),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Canvas(Modifier.size(size * 0.46f)) {
|
||||
val w = this.size.minDimension
|
||||
val stroke = Stroke(width = w * 0.17f, cap = StrokeCap.Round, join = StrokeJoin.Round)
|
||||
when (glyph) {
|
||||
'A' -> { // ✕ — the two diagonals
|
||||
drawLine(color, Offset(0f, 0f), Offset(w, w), stroke.width, StrokeCap.Round)
|
||||
drawLine(color, Offset(w, 0f), Offset(0f, w), stroke.width, StrokeCap.Round)
|
||||
}
|
||||
'B' -> drawCircle(color, radius = (w - stroke.width) / 2f, style = stroke)
|
||||
'X' -> drawRect(
|
||||
color,
|
||||
topLeft = Offset(stroke.width / 2f, stroke.width / 2f),
|
||||
size = Size(w - stroke.width, w - stroke.width),
|
||||
style = stroke,
|
||||
)
|
||||
else -> { // △
|
||||
val p = Path().apply {
|
||||
moveTo(w / 2f, stroke.width / 2f)
|
||||
lineTo(w - stroke.width / 2f, w - stroke.width / 2f)
|
||||
lineTo(stroke.width / 2f, w - stroke.width / 2f)
|
||||
close()
|
||||
}
|
||||
drawPath(p, color, style = stroke)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pad's physical Select-family button — the one that delivers `KEYCODE_BUTTON_SELECT` and opens
|
||||
* Options — drawn per [Gamepad.PadStyle] as a badge with the button's real face: Xbox View (two
|
||||
* overlapping windows), PlayStation Create/Share (a slim capsule), Nintendo − (minus). The generic
|
||||
* fallback wears the capsule too (the near-universal select shape).
|
||||
*/
|
||||
@Composable
|
||||
internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||
Box(
|
||||
Modifier.size(size).clip(CircleShape).background(PadButtonFace),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
when (style) {
|
||||
Gamepad.PadStyle.XBOX -> Box(Modifier.size(size * 0.50f)) {
|
||||
// The View icon: two overlapping outlined windows; the front one is filled with the
|
||||
// button face so it visibly occludes the back one.
|
||||
val corner = RoundedCornerShape(2.dp)
|
||||
Box(
|
||||
Modifier.size(size * 0.32f).align(Alignment.TopEnd)
|
||||
.border(1.4.dp, Color.White.copy(alpha = 0.9f), corner),
|
||||
)
|
||||
Box(
|
||||
Modifier.size(size * 0.32f).align(Alignment.BottomStart)
|
||||
.clip(corner).background(PadButtonFace)
|
||||
.border(1.4.dp, Color.White.copy(alpha = 0.9f), corner),
|
||||
)
|
||||
}
|
||||
Gamepad.PadStyle.NINTENDO -> Text(
|
||||
"−",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = (size.value * 0.62f).sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
else -> Box(
|
||||
Modifier
|
||||
.size(width = size * 0.58f, height = size * 0.30f)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.border(1.6.dp, Color.White.copy(alpha = 0.9f), RoundedCornerShape(50)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pinned controls legend every gamepad screen shows along the bottom — worn as a self-contained
|
||||
* translucent pill so it floats over the aurora rather than dissolving into it.
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, hazeState: HazeState? = null) {
|
||||
// On a TV D-pad remote (no A/B/X/Y), auto-swap the two universal pad glyphs every screen uses:
|
||||
// A (confirm) → the select ring, B (back/cancel) → a back glyph. Screen-specific glyphs like the
|
||||
// home's Up/Down handle themselves. A real pad instead picks its glyph FAMILY (Xbox letters /
|
||||
// PlayStation shapes / Nintendo monochrome) from the controller that last drove the UI.
|
||||
// Defaults to the generic gamepad look off an Activity (preview/tests).
|
||||
val activity = LocalContext.current as? MainActivity
|
||||
val padIsGamepad = activity?.lastPadIsGamepad ?: true
|
||||
val padStyle = activity?.lastPadStyle ?: Gamepad.PadStyle.GENERIC
|
||||
val shape = RoundedCornerShape(50)
|
||||
// With a haze source, blur the content behind the pill (real backdrop blur, API 31+; a translucent
|
||||
// scrim below) + a light tint; otherwise fall back to a solid frosted fill.
|
||||
val frosted = if (hazeState != null) {
|
||||
modifier.clip(shape).hazeEffect(hazeState).background(Color(0x4014122A))
|
||||
} else {
|
||||
modifier.clip(shape).background(Color(0x8C14122A))
|
||||
}
|
||||
Row(
|
||||
modifier = frosted
|
||||
.border(1.dp, Color.White.copy(alpha = 0.14f), shape)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(11.dp),
|
||||
) {
|
||||
for (h in hints) {
|
||||
val cb = h.onClick
|
||||
val cell = if (cb != null) {
|
||||
Modifier.clip(RoundedCornerShape(50)).clickable(onClick = cb).padding(horizontal = 4.dp, vertical = 5.dp)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
Row(modifier = cell, verticalAlignment = Alignment.CenterVertically) {
|
||||
when {
|
||||
h.viewButton -> SelectButtonGlyph(padStyle)
|
||||
h.select || (!padIsGamepad && h.glyph == 'A') -> SelectGlyph()
|
||||
!padIsGamepad && h.glyph == 'B' -> BackGlyph()
|
||||
padStyle == Gamepad.PadStyle.PLAYSTATION && h.glyph in "ABXY" ->
|
||||
PsFaceGlyph(h.glyph)
|
||||
padStyle == Gamepad.PadStyle.NINTENDO && h.glyph in "ABXY" ->
|
||||
GamepadButtonGlyph(h.glyph, PadButtonFace)
|
||||
else -> GamepadButtonGlyph(h.glyph, h.color)
|
||||
}
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
h.text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = Color.White.copy(alpha = 0.9f),
|
||||
maxLines = 1,
|
||||
softWrap = false, // never char-wrap a label when several hints crowd a narrow pill
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Which pad is driving this UI" — a quiet chip in the console top bar with the controller's name. */
|
||||
@Composable
|
||||
fun ControllerStatusChip(name: String, modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(Color.White.copy(alpha = 0.08f))
|
||||
.padding(horizontal = 12.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.SportsEsports,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.75f),
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(Modifier.width(7.dp))
|
||||
Text(
|
||||
name,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color.White.copy(alpha = 0.75f),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.security.ClientIdentity
|
||||
import io.unom.punktfunk.models.PendingTrust
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
// Console-styled trust/pairing dialogs — the controller-navigable counterparts of the touch
|
||||
// AlertDialogs in ConnectDialogs.kt, shown while the gamepad UI is active. A dark glass card over a
|
||||
// scrim with focusable action buttons: D-pad left/right moves the focus, A activates it, B dismisses.
|
||||
|
||||
/** One dialog action button. */
|
||||
class DialogAction(
|
||||
val label: String,
|
||||
val primary: Boolean = false,
|
||||
val enabled: Boolean = true,
|
||||
val onClick: () -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* The shared console-dialog scaffold: scrim + glass card with a title, [body], and a row of focusable
|
||||
* [actions]. Owns its own controller nav (the presenting carousel drops its probes while a dialog is
|
||||
* up, via ConnectScreen's `navActive`). B → [onDismiss].
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadDialog(
|
||||
title: String,
|
||||
onDismiss: () -> Unit,
|
||||
actions: List<DialogAction>,
|
||||
body: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
// Focus the primary action; buttons are stacked full-width, navigated up/down (fits long labels
|
||||
// like "Request access" without the cramped-row wrapping a horizontal layout caused).
|
||||
var focus by remember { mutableIntStateOf(actions.indexOfFirst { it.primary }.coerceAtLeast(0)) }
|
||||
BackHandler(onBack = onDismiss)
|
||||
GamepadNavEffect2D(
|
||||
active = true,
|
||||
onDirection = { dir ->
|
||||
when (dir) {
|
||||
NavDir.UP -> if (focus > 0) focus--
|
||||
NavDir.DOWN -> if (focus < actions.lastIndex) focus++
|
||||
else -> {}
|
||||
}
|
||||
},
|
||||
onActivate = { actions.getOrNull(focus)?.takeIf { it.enabled }?.onClick?.invoke() },
|
||||
)
|
||||
// Cap the card to most of the screen and let body + BUTTONS scroll together — in a short
|
||||
// landscape window a 5-action stack (host options) exceeds the card even with an empty body, and
|
||||
// a pinned actions column can only compress/clip its last button. Only the title stays pinned;
|
||||
// the focused button pulls itself into view (see DialogButton), so D-pad navigation always shows
|
||||
// the current action even when the stack scrolls.
|
||||
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.padding(24.dp)
|
||||
.widthIn(max = 520.dp)
|
||||
.heightIn(max = maxCardHeight)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(Color(0xF01A1730))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp))
|
||||
.padding(28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
Column(
|
||||
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
body()
|
||||
Spacer(Modifier.size(4.dp))
|
||||
actions.forEachIndexed { i, a ->
|
||||
DialogButton(a.label, focused = i == focus, primary = a.primary, enabled = a.enabled, onClick = a.onClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enabled: Boolean, onClick: () -> Unit) {
|
||||
val scale by animateFloatAsState(
|
||||
if (focused) 1.02f else 1f,
|
||||
spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow),
|
||||
label = "btnScale",
|
||||
)
|
||||
// The action stack lives inside the dialog's scroll region: when D-pad focus moves to a button
|
||||
// that's scrolled out of a short window, pull it into view (no-op when already visible).
|
||||
val intoView = remember { BringIntoViewRequester() }
|
||||
LaunchedEffect(focused) { if (focused) intoView.bringIntoView() }
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
// Focus sweeps up/down the stack — cross-fade the fills so it glides instead of snapping.
|
||||
val bg by animateColorAsState(
|
||||
when {
|
||||
focused -> Color(0xFF6656F2)
|
||||
primary -> Color(0x336656F2)
|
||||
else -> Color(0x14FFFFFF)
|
||||
},
|
||||
tween(160),
|
||||
label = "btnBg",
|
||||
)
|
||||
val fg by animateColorAsState(
|
||||
when {
|
||||
!enabled -> Color.White.copy(alpha = 0.35f)
|
||||
focused -> Color.White
|
||||
primary -> Color(0xFF8678F5)
|
||||
else -> Color.White.copy(alpha = 0.85f)
|
||||
},
|
||||
tween(160),
|
||||
label = "btnFg",
|
||||
)
|
||||
val borderColor by animateColorAsState(
|
||||
Color.White.copy(alpha = if (focused) 0.3f else 0.08f),
|
||||
tween(160),
|
||||
label = "btnBorder",
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.bringIntoViewRequester(intoView)
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.clip(shape)
|
||||
.background(bg)
|
||||
.border(1.dp, borderColor, shape)
|
||||
.clickable(
|
||||
enabled = enabled,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onClick,
|
||||
)
|
||||
.padding(horizontal = 20.dp, vertical = 13.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, color = fg, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
/** Body text helper — a dimmed paragraph. */
|
||||
@Composable
|
||||
private fun DialogText(text: String) {
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.7f))
|
||||
}
|
||||
|
||||
/**
|
||||
* Console host options for a saved tile — Wake (offered only when offline + a MAC is known), Edit,
|
||||
* Forget. Reached by pressing Up on a focused saved host in the carousel; the console counterpart of
|
||||
* the touch host card's overflow menu.
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadHostOptionsDialog(
|
||||
hostName: String,
|
||||
canWake: Boolean,
|
||||
onWake: () -> Unit,
|
||||
onLibrary: (() -> Unit)?, // non-null when the game library is enabled → reachable without Y
|
||||
onEdit: () -> Unit,
|
||||
onForget: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
GamepadDialog(
|
||||
title = hostName,
|
||||
onDismiss = onDismiss,
|
||||
actions = buildList {
|
||||
if (onLibrary != null) add(DialogAction("Library", primary = true, onClick = onLibrary))
|
||||
if (canWake) add(DialogAction("Wake host", onClick = onWake))
|
||||
add(DialogAction("Edit…", primary = onLibrary == null, onClick = onEdit))
|
||||
add(DialogAction("Forget", onClick = onForget))
|
||||
add(DialogAction("Cancel", onClick = onDismiss))
|
||||
},
|
||||
) {
|
||||
DialogText("Manage this saved host.")
|
||||
}
|
||||
}
|
||||
|
||||
/** Console counterpart of [LocalNetworkDialog] — the Android 17+ ACCESS_LOCAL_NETWORK rationale. */
|
||||
@Composable
|
||||
fun GamepadLocalNetworkDialog(onAllow: () -> Unit, onSettings: () -> Unit, onDismiss: () -> Unit) {
|
||||
GamepadDialog(
|
||||
title = "Allow local network access",
|
||||
onDismiss = onDismiss,
|
||||
actions = listOf(
|
||||
DialogAction("Allow", primary = true, onClick = onAllow),
|
||||
DialogAction("Open settings", onClick = onSettings),
|
||||
DialogAction("Not now", onClick = onDismiss),
|
||||
),
|
||||
) {
|
||||
DialogText(
|
||||
"Android blocks punktfunk from talking to devices on your network, so it can't find " +
|
||||
"or reach any host until you allow it.",
|
||||
)
|
||||
DialogText(
|
||||
"If no prompt appears after Allow, enable “Nearby devices” for punktfunk in " +
|
||||
"system settings.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GamepadTrustNewDialog(pt: PendingTrust, onTrust: () -> Unit, onPairInstead: () -> Unit, onDismiss: () -> Unit) {
|
||||
GamepadDialog(
|
||||
title = "Trust this host?",
|
||||
onDismiss = onDismiss,
|
||||
actions = listOf(
|
||||
DialogAction("Cancel", onClick = onDismiss),
|
||||
DialogAction("Pair with PIN", onClick = onPairInstead),
|
||||
DialogAction("Trust (TOFU)", primary = true, onClick = onTrust),
|
||||
),
|
||||
) {
|
||||
DialogText("First connection to ${pt.host}:${pt.port}.")
|
||||
pt.advertisedFp?.let { DialogText("Fingerprint ${it.take(16)}…") }
|
||||
DialogText(
|
||||
"This host allows trust-on-first-use, but that can't tell an impostor from the real host. " +
|
||||
"Pairing with a PIN is stronger — it proves both sides.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GamepadFingerprintChangedDialog(pt: PendingTrust, onRepair: () -> Unit, onDismiss: () -> Unit) {
|
||||
GamepadDialog(
|
||||
title = "Host identity changed",
|
||||
onDismiss = onDismiss,
|
||||
actions = listOf(
|
||||
DialogAction("Cancel", onClick = onDismiss),
|
||||
DialogAction("Re-pair", primary = true, onClick = onRepair),
|
||||
),
|
||||
) {
|
||||
DialogText(
|
||||
"The pinned fingerprint for ${pt.host} no longer matches what it now advertises. This can " +
|
||||
"mean a host reinstall — or an impostor. Re-pair with the host's PIN to continue.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GamepadRequestAccessDialog(pt: PendingTrust, onRequestAccess: () -> Unit, onUsePin: () -> Unit, onDismiss: () -> Unit) {
|
||||
GamepadDialog(
|
||||
title = "Pairing required",
|
||||
onDismiss = onDismiss,
|
||||
actions = listOf(
|
||||
DialogAction("Cancel", onClick = onDismiss),
|
||||
DialogAction("Use a PIN", onClick = onUsePin),
|
||||
DialogAction("Request access", primary = true, onClick = onRequestAccess),
|
||||
),
|
||||
) {
|
||||
DialogText("${pt.host}:${pt.port} requires pairing before it will stream.")
|
||||
DialogText(
|
||||
"Request access and approve this device in the host's console (or web UI) — no PIN needed. " +
|
||||
"Or pair with the 4-digit PIN the host displays.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
|
||||
GamepadDialog(
|
||||
title = "Waiting for approval",
|
||||
onDismiss = onCancel,
|
||||
actions = listOf(DialogAction("Cancel", primary = true, onClick = onCancel)),
|
||||
) {
|
||||
val deviceName = Build.MODEL ?: "this device"
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = Color.White)
|
||||
Text("Approve this device on $hostLabel.", color = Color.White)
|
||||
}
|
||||
DialogText(
|
||||
"Open the host's console (or web UI) and approve “$deviceName”. It connects automatically " +
|
||||
"once you approve — no PIN needed.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Console PIN pairing: four digit slots set with the D-pad (left/right selects a slot, up/down changes
|
||||
* 0–9), then Pair. Runs [NativeBridge.nativePair] off the UI thread; on success hands the verified
|
||||
* fingerprint to [onPaired]. No text keyboard needed — a PIN is four digits.
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired: (String) -> Unit, onDismiss: () -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val digits = remember(pt) { mutableStateListOf(0, 0, 0, 0) }
|
||||
var slot by remember(pt) { mutableIntStateOf(0) } // 0..3 = digit slots, 4 = Pair button
|
||||
var pairing by remember(pt) { mutableStateOf(false) }
|
||||
var err by remember(pt) { mutableStateOf<String?>(null) }
|
||||
val name = remember { Build.MODEL ?: "Android" }
|
||||
|
||||
fun pair() {
|
||||
val id = identity ?: return
|
||||
pairing = true
|
||||
err = null
|
||||
val pin = digits.joinToString("")
|
||||
scope.launch {
|
||||
val fp = withContext(Dispatchers.IO) {
|
||||
NativeBridge.nativePair(pt.host, pt.port, id.certPem, id.privateKeyPem, pin, name)
|
||||
}
|
||||
pairing = false
|
||||
if (fp.isNotEmpty()) onPaired(fp) else err = "Pairing failed — wrong PIN, or the host isn't armed."
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(onBack = { if (!pairing) onDismiss() })
|
||||
GamepadNavEffect2D(
|
||||
active = !pairing,
|
||||
onDirection = { dir ->
|
||||
when (dir) {
|
||||
NavDir.LEFT -> if (slot > 0) slot--
|
||||
NavDir.RIGHT -> if (slot < 4) slot++
|
||||
NavDir.UP -> if (slot < 4) digits[slot] = (digits[slot] + 1) % 10
|
||||
NavDir.DOWN -> if (slot < 4) digits[slot] = (digits[slot] + 9) % 10
|
||||
}
|
||||
},
|
||||
onActivate = { if (slot == 4 && identity != null) pair() },
|
||||
)
|
||||
|
||||
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
|
||||
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)), contentAlignment = Alignment.Center) {
|
||||
Column(
|
||||
Modifier.padding(24.dp).widthIn(max = 460.dp).heightIn(max = maxCardHeight)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(Color(0xF01A1730)).border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp))
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(28.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
Text("Pair with PIN", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
Text(
|
||||
"Enter the 4-digit PIN shown on the host — D-pad ↑↓ sets a digit, ←→ moves.",
|
||||
style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.7f), textAlign = TextAlign.Center,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
repeat(4) { i -> PinSlot(digits[i], focused = slot == i && !pairing) }
|
||||
}
|
||||
err?.let { Text(it, color = Color(0xFFE0736F), style = MaterialTheme.typography.bodyMedium) }
|
||||
DialogButton(
|
||||
label = if (pairing) "Pairing…" else "Pair",
|
||||
focused = slot == 4 && !pairing,
|
||||
primary = true,
|
||||
enabled = !pairing && identity != null,
|
||||
onClick = { if (identity != null) pair() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinSlot(value: Int, focused: Boolean) {
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
Box(
|
||||
Modifier.size(54.dp, 66.dp).clip(shape)
|
||||
.background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF))
|
||||
.border(if (focused) 2.dp else 1.dp, if (focused) Color(0xFF8678F5) else Color.White.copy(alpha = 0.1f), shape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(value.toString(), fontSize = 30.sp, fontWeight = FontWeight.Bold, color = Color.White, fontFamily = FontFamily.Monospace)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PageSize
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.BlurredEdgeTreatment
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.lerp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// The gamepad-driven home — the Android mirror of the Apple client's GamepadHomeView: a distinct,
|
||||
// "10-foot" console-style host launcher shown INSTEAD of the touch grid while the console UI is
|
||||
// active. A center-snapping carousel of hosts (saved first, then discovered, then a trailing Add
|
||||
// Host tile), driven from the couch: A connects, X opens Settings, Y opens a saved host's library.
|
||||
|
||||
/** One navigable launcher tile — a saved host, a discovered-but-unsaved host, or the Add Host action. */
|
||||
class HomeTile(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val filled: Boolean = false, // saved (solid monogram) vs discovered / action (tinted outline)
|
||||
val online: Boolean = false, // advertising on the LAN right now
|
||||
val paired: Boolean = false, // pinned identity (shows a lock)
|
||||
val connecting: Boolean = false,
|
||||
val isAdd: Boolean = false, // the trailing Add Host tile (plus icon, not a monogram)
|
||||
val knownHost: KnownHost? = null, // set for saved hosts → enables the library (Y)
|
||||
val activate: () -> Unit,
|
||||
) {
|
||||
// Any SAVED host offers the library (matches Apple) — the fetch itself returns a clear "pair
|
||||
// first" message if the host hasn't authorized this device for its management API.
|
||||
val hasLibrary: Boolean get() = knownHost != null
|
||||
}
|
||||
|
||||
/**
|
||||
* The console home. [tiles] is rebuilt by the caller from the live host stores; [onActivate] runs a
|
||||
* tile's action, [onOpenLibrary]/[onOpenSettings] are the Y/X actions. Fully driven by D-pad / stick
|
||||
* / face buttons (MainActivity already maps a pad's A→center, B→back, sticks→D-pad) and by touch.
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadHome(
|
||||
tiles: List<HomeTile>,
|
||||
libraryEnabled: Boolean,
|
||||
controllerName: String?,
|
||||
// False while a sheet/dialog is on top → the carousel stops consuming the pad so the overlay
|
||||
// can be driven instead.
|
||||
navActive: Boolean,
|
||||
onActivate: (HomeTile) -> Unit,
|
||||
onOpenLibrary: (HomeTile) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
// Up on a saved host opens its options (Wake / Edit / Forget). Only saved tiles carry a knownHost.
|
||||
onOptions: (HomeTile) -> Unit = {},
|
||||
) {
|
||||
// Equal inset for the pinned title + hint bar, measured from the safe-area edges (so the legend
|
||||
// sits the same distance from the left and the bottom).
|
||||
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
|
||||
val pagerState = rememberPagerState(pageCount = { tiles.size })
|
||||
val scope = rememberCoroutineScope()
|
||||
// navTarget is the navigation authority — a controller move steps THIS, and the pager is pointed
|
||||
// at it, so a fast repeat coalesces to the latest target instead of reading a lagging currentPage
|
||||
// mid-animation (which is what let a flick overshoot by two).
|
||||
var navTarget by remember { mutableStateOf(0) }
|
||||
LaunchedEffect(pagerState.settledPage) { navTarget = pagerState.settledPage }
|
||||
val current = tiles.getOrNull(navTarget)
|
||||
|
||||
GamepadNavEffect(
|
||||
active = navActive && tiles.isNotEmpty(),
|
||||
onMove = { dir ->
|
||||
val target = (navTarget + dir).coerceIn(0, tiles.lastIndex)
|
||||
if (target != navTarget) {
|
||||
navTarget = target
|
||||
scope.launch { pagerState.animateScrollToPage(target) }
|
||||
}
|
||||
},
|
||||
onActivate = { tiles.getOrNull(navTarget)?.let(onActivate) }, // A / D-pad-center → Connect
|
||||
onSecondary = { // Y (gamepad) → Library
|
||||
tiles.getOrNull(navTarget)?.takeIf { libraryEnabled && it.hasLibrary }?.let(onOpenLibrary)
|
||||
},
|
||||
onTertiary = onOpenSettings, // X (gamepad) → Settings
|
||||
// A TV remote has no A/B/X/Y: Up → Settings, Down → a saved host's Options (Wake / Library /
|
||||
// Edit / Forget). A gamepad instead opens Options on its Select/View button.
|
||||
onUp = onOpenSettings,
|
||||
onDown = { tiles.getOrNull(navTarget)?.takeIf { it.knownHost != null }?.let(onOptions) },
|
||||
onOptions = { tiles.getOrNull(navTarget)?.takeIf { it.knownHost != null }?.let(onOptions) },
|
||||
)
|
||||
|
||||
// The legend follows the LAST-USED input: a real gamepad shows its A/X/Y face buttons + the
|
||||
// Select/View button for Options; a TV D-pad remote (no face buttons) shows a select ring + Up
|
||||
// (Settings) / Down (Options) arrows, with Library folded into Options. Input is universal either
|
||||
// way. Each hint is also TAPPABLE (touch hatch).
|
||||
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: false
|
||||
val connectLabel = if (current?.isAdd == true) "Add Host" else "Connect"
|
||||
val connectAction: () -> Unit = { tiles.getOrNull(navTarget)?.let(onActivate) }
|
||||
val optionsAction: () -> Unit = { current?.let(onOptions) }
|
||||
val arrowTint = Color(0xFF9A93C7)
|
||||
val hints = buildList {
|
||||
if (padIsGamepad) {
|
||||
add(PadGlyph.hint('A', connectLabel, onClick = connectAction))
|
||||
if (libraryEnabled && current?.hasLibrary == true) add(PadGlyph.hint('Y', "Library") {
|
||||
tiles.getOrNull(navTarget)?.takeIf { it.hasLibrary }?.let(onOpenLibrary)
|
||||
})
|
||||
add(PadGlyph.hint('X', "Settings", onClick = onOpenSettings))
|
||||
// The pad's Select/View button (drawn as its capsule glyph) opens host options.
|
||||
if (current?.knownHost != null) add(GamepadHint(' ', arrowTint, "Options", onClick = optionsAction, viewButton = true))
|
||||
} else {
|
||||
add(GamepadHint(' ', PadGlyph.A, connectLabel, onClick = connectAction, select = true))
|
||||
add(GamepadHint('↑', arrowTint, "Settings", onClick = { onOpenSettings() }))
|
||||
if (current?.knownHost != null) add(GamepadHint('↓', arrowTint, "Options", onClick = optionsAction))
|
||||
}
|
||||
}
|
||||
|
||||
val hazeState = remember { HazeState() }
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
// The whole backdrop (aurora + carousel) is the haze source, so the floating legend can blur
|
||||
// whatever scrolls under it.
|
||||
BoxWithConstraints(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||
GamepadAuroraBackground(Modifier.fillMaxSize())
|
||||
|
||||
// Carousel centred on the FULL screen — the title + legend FLOAT over it (below), so they
|
||||
// no longer push the cards below the true centre.
|
||||
val cardWidth = (maxWidth * 0.82f).coerceAtMost(360.dp)
|
||||
val cardHeight = (maxHeight * 0.56f).coerceAtMost(216.dp)
|
||||
val sidePad = ((maxWidth - cardWidth) / 2).coerceAtLeast(0.dp)
|
||||
Box(Modifier.fillMaxSize().systemBarsPadding()) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
pageSize = PageSize.Fixed(cardWidth),
|
||||
contentPadding = PaddingValues(horizontal = sidePad),
|
||||
pageSpacing = 22.dp,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) { page ->
|
||||
val tile = tiles[page]
|
||||
// Real distance-from-centered (page + fractional drag), so the pop tracks the
|
||||
// live scroll: centered tile at full scale/brightness, neighbours recede + blur.
|
||||
val offset = ((pagerState.currentPage - page) + pagerState.currentPageOffsetFraction)
|
||||
.absoluteValue.coerceIn(0f, 1f)
|
||||
GamepadHostTile(
|
||||
tile = tile,
|
||||
modifier = Modifier
|
||||
.graphicsLayer {
|
||||
val s = lerp(1f, 0.86f, offset)
|
||||
scaleX = s
|
||||
scaleY = s
|
||||
alpha = lerp(1f, 0.5f, offset)
|
||||
}
|
||||
// Unbounded so the depth blur isn't hard-clipped at the card's rectangle
|
||||
// (the cut-off edge). No-op below API 31; a soft blur above.
|
||||
.blur(radius = (offset * 12f).dp, edgeTreatment = BlurredEdgeTreatment.Unbounded)
|
||||
.height(cardHeight)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
) {
|
||||
if (page == navTarget) {
|
||||
onActivate(tile)
|
||||
} else {
|
||||
navTarget = page
|
||||
scope.launch { pagerState.animateScrollToPage(page) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Title floats over the top (out of the carousel's layout, so the cards stay centred). Uses
|
||||
// the shared ConsoleHeader so it lines up with every other screen's heading.
|
||||
Row(
|
||||
Modifier.align(Alignment.TopStart).fillMaxWidth().systemBarsPadding()
|
||||
.padding(end = ConsoleEdgeInset),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
ConsoleHeader("Select a Host", modifier = Modifier.weight(1f))
|
||||
if (controllerName != null) ControllerStatusChip(controllerName)
|
||||
}
|
||||
|
||||
// Legend floats bottom-start with a real backdrop blur of the content behind it. In LANDSCAPE
|
||||
// it ignores the safe area (the nav-bar inset made the bottom gap look oversized).
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(hints, hazeState = hazeState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One dark-glass landscape console tile — bigger and bolder than the touch grid's HostCard. */
|
||||
@Composable
|
||||
private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
|
||||
val shape = RoundedCornerShape(26.dp)
|
||||
val wash = if (tile.filled) {
|
||||
Brush.verticalGradient(listOf(Color(0x336656F2), Color(0x14100C2A)))
|
||||
} else {
|
||||
Brush.verticalGradient(listOf(Color(0x1AFFFFFF), Color(0x0DFFFFFF)))
|
||||
}
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(shape)
|
||||
.background(wash)
|
||||
.border(1.dp, Color.White.copy(alpha = 0.16f), shape)
|
||||
.padding(22.dp),
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
|
||||
MonogramBadge(tile)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (tile.paired) {
|
||||
Icon(
|
||||
Icons.Filled.Lock,
|
||||
contentDescription = "Paired",
|
||||
tint = Color.White.copy(alpha = 0.7f),
|
||||
modifier = Modifier.padding(end = 6.dp).size(15.dp),
|
||||
)
|
||||
}
|
||||
if (tile.online) {
|
||||
Box(
|
||||
Modifier.size(10.dp).clip(androidx.compose.foundation.shape.CircleShape)
|
||||
.background(Color(0xFF3CD070)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(
|
||||
tile.title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
tile.subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White.copy(alpha = 0.55f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonogramBadge(tile: HomeTile) {
|
||||
val shape = RoundedCornerShape(15.dp)
|
||||
val fill = if (tile.filled) {
|
||||
Brush.verticalGradient(listOf(Color(0xFF6656F2), Color(0xFF8678F5)))
|
||||
} else {
|
||||
Brush.verticalGradient(listOf(Color(0x296656F2), Color(0x296656F2)))
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.size(52.dp).clip(shape).background(fill),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
when {
|
||||
tile.connecting -> CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = Color.White,
|
||||
)
|
||||
tile.isAdd -> Icon(
|
||||
Icons.Filled.Add,
|
||||
contentDescription = null,
|
||||
tint = if (tile.filled) Color.White else Color(0xFF8678F5),
|
||||
)
|
||||
else -> Text(
|
||||
tile.title.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "•",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (tile.filled) Color.White else Color(0xFF8678F5),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.os.SystemClock
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import kotlin.math.abs
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
|
||||
// Controller navigation for the console carousels (host launcher + library coverflow). It taps the
|
||||
// SAME MainActivity input probes the Controllers debug screen uses (padMotionProbe / padKeyProbe) so
|
||||
// it sees the raw analog stick and consumes it BEFORE MainActivity's stick→D-pad focus synthesis —
|
||||
// which is what made carousel scrolling feel wrong: that path is edge-only (no hold-to-repeat, so a
|
||||
// held stick did nothing) and a flick could cross the threshold twice (double-move). Here the left
|
||||
// stick drives discrete moves with hysteresis (fire once when it crosses HIGH; re-arm only after it
|
||||
// falls back under LOW → a flick is exactly one move) and auto-repeat while held. The caller coalesces
|
||||
// the moves against a target index so a fast repeat walks smoothly instead of overshooting.
|
||||
|
||||
private const val STICK_HIGH = 0.6f // cross this to commit a move
|
||||
private const val STICK_LOW = 0.3f // fall back under this to re-arm (hysteresis)
|
||||
private const val INITIAL_DELAY_MS = 420L // hold this long before the first auto-repeat
|
||||
private const val REPEAT_MS = 150L // then repeat this often while held
|
||||
|
||||
private class NavInputState {
|
||||
@Volatile var stickX = 0f
|
||||
@Volatile var stickY = 0f
|
||||
@Volatile var hatX = 0f
|
||||
@Volatile var hatY = 0f
|
||||
@Volatile var dpadX = 0
|
||||
@Volatile var dpadY = 0
|
||||
fun reset() { stickX = 0f; stickY = 0f; hatX = 0f; hatY = 0f; dpadX = 0; dpadY = 0 }
|
||||
}
|
||||
|
||||
/** A committed navigation direction from the stick / D-pad / HAT. */
|
||||
enum class NavDir { UP, DOWN, LEFT, RIGHT }
|
||||
|
||||
/**
|
||||
* Installs controller navigation for a console screen while [active]. [onMove] gets -1 (left) / +1
|
||||
* (right) for each committed step; [onActivate] is A / D-pad-center / Enter, [onTertiary] is X,
|
||||
* [onSecondary] is Y. B and the shoulders fall through to MainActivity (B → its BACK remap → the
|
||||
* screen's BackHandler). [active] is set false while a sheet/dialog is on top so the carousel stops
|
||||
* consuming the pad and the overlay can be navigated.
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadNavEffect(
|
||||
active: Boolean,
|
||||
onMove: (Int) -> Unit,
|
||||
onActivate: () -> Unit,
|
||||
onSecondary: () -> Unit = {},
|
||||
onTertiary: () -> Unit = {},
|
||||
// D-pad Up (the carousel is horizontal) → e.g. Settings, since a TV remote has no X face button.
|
||||
onUp: () -> Unit = {},
|
||||
onDown: () -> Unit = {},
|
||||
// Context/options menu — fired by the gamepad Select/View button OR a long-press of the select/OK
|
||||
// button (the Android-TV context-menu convention). A short OK press is [onActivate].
|
||||
onOptions: () -> Unit = {},
|
||||
) {
|
||||
val activity = LocalContext.current as? MainActivity ?: return
|
||||
val state = remember { NavInputState() }
|
||||
// The effects below are keyed on `active` only (they must NOT restart on every recomposition), so
|
||||
// they'd otherwise capture the FIRST callbacks — closing over a stale `tiles` (fewer hosts than are
|
||||
// discovered later, which clamped navigation to that old count). rememberUpdatedState keeps the
|
||||
// long-lived coroutine/probes pointed at the CURRENT callbacks.
|
||||
val currentOnMove by rememberUpdatedState(onMove)
|
||||
val currentOnActivate by rememberUpdatedState(onActivate)
|
||||
val currentOnSecondary by rememberUpdatedState(onSecondary)
|
||||
val currentOnTertiary by rememberUpdatedState(onTertiary)
|
||||
val currentOnUp by rememberUpdatedState(onUp)
|
||||
val currentOnDown by rememberUpdatedState(onDown)
|
||||
val currentOnOptions by rememberUpdatedState(onOptions)
|
||||
|
||||
DisposableEffect(active) {
|
||||
// Stable probe refs (see GamepadNavEffect2D) so onDispose only releases the slot if we still
|
||||
// own it — a cross-fading-out screen mustn't null the incoming screen's probes.
|
||||
val motionProbe: (MotionEvent) -> Boolean = probe@{ ev ->
|
||||
if (ev.isFromSource(InputDevice.SOURCE_JOYSTICK) && ev.actionMasked == MotionEvent.ACTION_MOVE) {
|
||||
state.stickX = ev.getAxisValue(MotionEvent.AXIS_X)
|
||||
state.hatX = ev.getAxisValue(MotionEvent.AXIS_HAT_X)
|
||||
return@probe true // consume → MainActivity's stick→D-pad synthesis stays out of it
|
||||
}
|
||||
false
|
||||
}
|
||||
val keyProbe: (KeyEvent) -> Boolean = probe@{ ev ->
|
||||
val down = ev.action == KeyEvent.ACTION_DOWN
|
||||
val edge = down && ev.repeatCount == 0
|
||||
when (ev.keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> { state.dpadX = if (down) -1 else 0; true }
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> { state.dpadX = if (down) 1 else 0; true }
|
||||
// TV remote (no face buttons): Up → Settings, Down → a saved host's Options.
|
||||
KeyEvent.KEYCODE_DPAD_UP -> { if (edge) currentOnUp(); true }
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> { if (edge) currentOnDown(); true }
|
||||
KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_DPAD_CENTER,
|
||||
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { if (edge) currentOnActivate(); true }
|
||||
// The gamepad Select / View / Share button → context options (a remote uses Down).
|
||||
KeyEvent.KEYCODE_BUTTON_SELECT -> { if (edge) currentOnOptions(); true }
|
||||
KeyEvent.KEYCODE_BUTTON_X -> { if (edge) currentOnTertiary(); true }
|
||||
KeyEvent.KEYCODE_BUTTON_Y -> { if (edge) currentOnSecondary(); true }
|
||||
else -> false // B / shoulders / etc. → MainActivity handles (B remaps to BACK)
|
||||
}
|
||||
}
|
||||
if (active) {
|
||||
activity.padMotionProbe = motionProbe
|
||||
activity.padKeyProbe = keyProbe
|
||||
}
|
||||
onDispose {
|
||||
if (activity.padMotionProbe === motionProbe) activity.padMotionProbe = null
|
||||
if (activity.padKeyProbe === keyProbe) activity.padKeyProbe = null
|
||||
state.reset()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(active) {
|
||||
if (!active) return@LaunchedEffect
|
||||
var committed = 0 // the direction currently held (hysteresis + repeat authority)
|
||||
var fireAt = 0L // uptime at/after which the next auto-repeat may fire
|
||||
while (isActive) {
|
||||
val now = SystemClock.uptimeMillis()
|
||||
val hat = if (state.hatX <= -0.5f) -1 else if (state.hatX >= 0.5f) 1 else 0
|
||||
val dir = when {
|
||||
state.dpadX != 0 -> state.dpadX
|
||||
hat != 0 -> hat
|
||||
else -> {
|
||||
val x = state.stickX
|
||||
when {
|
||||
x >= STICK_HIGH -> 1
|
||||
x <= -STICK_HIGH -> -1
|
||||
abs(x) < STICK_LOW -> 0
|
||||
else -> committed // inside the hysteresis band → hold the committed value
|
||||
}
|
||||
}
|
||||
}
|
||||
when {
|
||||
dir == 0 -> committed = 0
|
||||
dir != committed -> { currentOnMove(dir); committed = dir; fireAt = now + INITIAL_DELAY_MS }
|
||||
now >= fireAt -> { currentOnMove(dir); fireAt = now + REPEAT_MS }
|
||||
}
|
||||
delay(16)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 2-D controller navigation for the console form screens (settings focus list, add-host, on-screen
|
||||
* keyboard). Same hysteresis + hold-to-repeat as [GamepadNavEffect] but on both axes — the dominant
|
||||
* stick axis (or the pressed D-pad/HAT) commits a [NavDir], and it re-arms only after the stick
|
||||
* returns near centre (so a flick is one step). [onActivate] is A / center, [onTertiary] is X,
|
||||
* [onSecondary] is Y. B is left to MainActivity's BACK remap → the screen's BackHandler (so B "peels
|
||||
* one layer": close the keyboard, then the screen).
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadNavEffect2D(
|
||||
active: Boolean,
|
||||
onDirection: (NavDir) -> Unit,
|
||||
onActivate: () -> Unit,
|
||||
onTertiary: () -> Unit = {},
|
||||
onSecondary: () -> Unit = {},
|
||||
) {
|
||||
val activity = LocalContext.current as? MainActivity ?: return
|
||||
val state = remember { NavInputState() }
|
||||
val currentOnDirection by rememberUpdatedState(onDirection)
|
||||
val currentOnActivate by rememberUpdatedState(onActivate)
|
||||
val currentOnTertiary by rememberUpdatedState(onTertiary)
|
||||
val currentOnSecondary by rememberUpdatedState(onSecondary)
|
||||
|
||||
DisposableEffect(active) {
|
||||
// Stable probe refs so onDispose only releases the slot if WE still own it — during a
|
||||
// cross-fade both the outgoing and incoming screen are briefly composed, and the outgoing's
|
||||
// teardown must not null out the incoming screen's just-installed probes.
|
||||
val motionProbe: (MotionEvent) -> Boolean = probe@{ ev ->
|
||||
if (ev.isFromSource(InputDevice.SOURCE_JOYSTICK) && ev.actionMasked == MotionEvent.ACTION_MOVE) {
|
||||
state.stickX = ev.getAxisValue(MotionEvent.AXIS_X)
|
||||
state.stickY = ev.getAxisValue(MotionEvent.AXIS_Y)
|
||||
state.hatX = ev.getAxisValue(MotionEvent.AXIS_HAT_X)
|
||||
state.hatY = ev.getAxisValue(MotionEvent.AXIS_HAT_Y)
|
||||
return@probe true
|
||||
}
|
||||
false
|
||||
}
|
||||
val keyProbe: (KeyEvent) -> Boolean = probe@{ ev ->
|
||||
val down = ev.action == KeyEvent.ACTION_DOWN
|
||||
val edge = down && ev.repeatCount == 0
|
||||
when (ev.keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> { state.dpadX = if (down) -1 else 0; true }
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> { state.dpadX = if (down) 1 else 0; true }
|
||||
KeyEvent.KEYCODE_DPAD_UP -> { state.dpadY = if (down) -1 else 0; true }
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> { state.dpadY = if (down) 1 else 0; true }
|
||||
KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_DPAD_CENTER,
|
||||
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { if (edge) currentOnActivate(); true }
|
||||
KeyEvent.KEYCODE_BUTTON_X -> { if (edge) currentOnTertiary(); true }
|
||||
KeyEvent.KEYCODE_BUTTON_Y -> { if (edge) currentOnSecondary(); true }
|
||||
else -> false // B / shoulders → MainActivity (B remaps to BACK → BackHandler)
|
||||
}
|
||||
}
|
||||
if (active) {
|
||||
activity.padMotionProbe = motionProbe
|
||||
activity.padKeyProbe = keyProbe
|
||||
}
|
||||
onDispose {
|
||||
if (activity.padMotionProbe === motionProbe) activity.padMotionProbe = null
|
||||
if (activity.padKeyProbe === keyProbe) activity.padKeyProbe = null
|
||||
state.reset()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(active) {
|
||||
if (!active) return@LaunchedEffect
|
||||
var committed: NavDir? = null
|
||||
var fireAt = 0L
|
||||
while (isActive) {
|
||||
val now = SystemClock.uptimeMillis()
|
||||
val raw = resolveDir(state)
|
||||
val nearCentre = state.dpadX == 0 && state.dpadY == 0 &&
|
||||
abs(state.hatX) < 0.5f && abs(state.hatY) < 0.5f &&
|
||||
abs(state.stickX) < STICK_LOW && abs(state.stickY) < STICK_LOW
|
||||
when {
|
||||
raw == null && nearCentre -> committed = null
|
||||
raw == null -> { /* in the hysteresis band → hold, don't fire */ }
|
||||
raw != committed -> { currentOnDirection(raw); committed = raw; fireAt = now + INITIAL_DELAY_MS }
|
||||
now >= fireAt -> { currentOnDirection(raw); fireAt = now + REPEAT_MS }
|
||||
}
|
||||
delay(16)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The direction currently past the commit threshold (D-pad/HAT first, then the dominant stick axis). */
|
||||
private fun resolveDir(s: NavInputState): NavDir? {
|
||||
if (s.dpadY < 0) return NavDir.UP
|
||||
if (s.dpadY > 0) return NavDir.DOWN
|
||||
if (s.dpadX < 0) return NavDir.LEFT
|
||||
if (s.dpadX > 0) return NavDir.RIGHT
|
||||
if (s.hatY <= -0.5f) return NavDir.UP
|
||||
if (s.hatY >= 0.5f) return NavDir.DOWN
|
||||
if (s.hatX <= -0.5f) return NavDir.LEFT
|
||||
if (s.hatX >= 0.5f) return NavDir.RIGHT
|
||||
return if (abs(s.stickY) >= abs(s.stickX)) {
|
||||
when {
|
||||
s.stickY <= -STICK_HIGH -> NavDir.UP
|
||||
s.stickY >= STICK_HIGH -> NavDir.DOWN
|
||||
else -> null
|
||||
}
|
||||
} else {
|
||||
when {
|
||||
s.stickX <= -STICK_HIGH -> NavDir.LEFT
|
||||
s.stickX >= STICK_HIGH -> NavDir.RIGHT
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.SizeTransform
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
|
||||
// The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView:
|
||||
// the couch-relevant subset of the touch settings restyled as a console page and fully navigable with
|
||||
// a controller: up/down moves the focus bar, left/right steps the focused value, A cycles/toggles it,
|
||||
// B closes. Both write the same SharedPreferences, so values round-trip with the touch settings.
|
||||
|
||||
private class GpRow(
|
||||
val id: String,
|
||||
val header: String?,
|
||||
val label: String,
|
||||
val value: String,
|
||||
val detail: String,
|
||||
val adjust: (Int) -> Boolean, // left/right; returns whether the value actually changed
|
||||
val activate: () -> Unit, // A → cycle forward (wrapping) / flip
|
||||
val toggled: Boolean? = null, // non-null = a toggle row, drawn as a ConsoleSwitch (not text)
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun GamepadSettingsScreen(
|
||||
initial: Settings,
|
||||
onChange: (Settings) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
navActive: Boolean = true, // false while this screen is cross-fading out, so it drops the pad
|
||||
) {
|
||||
var s by remember { mutableStateOf(initial) }
|
||||
fun update(next: Settings) { s = next; onChange(next) }
|
||||
|
||||
val rows = buildSettingsRows(s, ::update)
|
||||
var focus by remember { mutableIntStateOf(0) }
|
||||
if (focus > rows.lastIndex) focus = rows.lastIndex
|
||||
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
|
||||
// value text slides in its AnimatedContent, so the motion matches the button press.
|
||||
var adjustDir by remember { mutableIntStateOf(1) }
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
|
||||
BackHandler(onBack = onBack)
|
||||
GamepadNavEffect2D(
|
||||
active = navActive,
|
||||
onDirection = { dir ->
|
||||
when (dir) {
|
||||
NavDir.UP -> if (focus > 0) focus--
|
||||
NavDir.DOWN -> if (focus < rows.lastIndex) focus++
|
||||
NavDir.LEFT -> { adjustDir = -1; rows.getOrNull(focus)?.adjust(-1) }
|
||||
NavDir.RIGHT -> { adjustDir = 1; rows.getOrNull(focus)?.adjust(1) }
|
||||
}
|
||||
},
|
||||
onActivate = { adjustDir = 1; rows.getOrNull(focus)?.activate() },
|
||||
)
|
||||
// Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the
|
||||
// screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it.
|
||||
// +1 accounts for the heading being item 0.
|
||||
LaunchedEffect(focus) {
|
||||
runCatching {
|
||||
val itemIndex = focus + 1
|
||||
val info = listState.layoutInfo
|
||||
val item = info.visibleItemsInfo.firstOrNull { it.index == itemIndex }
|
||||
val offScreen = item == null ||
|
||||
item.offset < info.viewportStartOffset ||
|
||||
item.offset + item.size > info.viewportEndOffset - 96 // keep clear of the floating legend
|
||||
if (offScreen) listState.animateScrollToItem(itemIndex)
|
||||
}
|
||||
}
|
||||
|
||||
val hazeState = remember { HazeState() }
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
// Everything scrolls — including the heading — so nothing is pinned. Vital in landscape,
|
||||
// where a fixed title + a fixed detail/legend strip ate most of the (short) height.
|
||||
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||
GamepadFormBackground(Modifier.fillMaxSize())
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize().systemBarsPadding(),
|
||||
contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 104.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
item(key = "__title") {
|
||||
ConsoleHeader("Settings", horizontalInset = false)
|
||||
}
|
||||
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
|
||||
SettingRowView(row, focused = index == focus, adjustDir = adjustDir, onClick = {
|
||||
if (focus == index) { adjustDir = 1; row.activate() } else focus = index
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Floating frosted legend — a real backdrop blur of the rows scrolling behind it (no dedicated
|
||||
// strip). In landscape it ignores the safe area so it hugs the corner instead of the nav-bar inset.
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(
|
||||
listOf(
|
||||
GamepadHint('↔', Color(0xFF9A93C7), "Adjust"),
|
||||
// Tappable too (touch escape hatch): Change cycles the focused row, Done leaves.
|
||||
PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
),
|
||||
hazeState = hazeState,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick: () -> Unit) {
|
||||
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")
|
||||
val valueColor by animateColorAsState(
|
||||
Color.White.copy(alpha = if (focused) 1f else 0.6f),
|
||||
tween(160),
|
||||
label = "valueColor",
|
||||
)
|
||||
Column {
|
||||
if (row.header != null) {
|
||||
Text(
|
||||
row.header.uppercase(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color.White.copy(alpha = 0.45f),
|
||||
letterSpacing = 1.4.sp,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer { scaleX = 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),
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
row.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (row.toggled != null) {
|
||||
// A toggle is a switch, not text — the sliding knob + tinting track IS the value.
|
||||
ConsoleSwitch(on = row.toggled, focused = focused)
|
||||
} else {
|
||||
Text("‹ ", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
|
||||
// The value slides in the direction it was stepped and its width animates, so
|
||||
// cycling a choice reads as motion through a list rather than a text swap.
|
||||
AnimatedContent(
|
||||
targetState = row.value,
|
||||
transitionSpec = {
|
||||
val dir = adjustDir
|
||||
(slideInHorizontally(tween(180)) { w -> w / 2 * dir } + fadeIn(tween(180))) togetherWith
|
||||
(slideOutHorizontally(tween(140)) { w -> -w / 2 * dir } + fadeOut(tween(100))) using
|
||||
SizeTransform(clip = false)
|
||||
},
|
||||
label = "value",
|
||||
) { value ->
|
||||
Text(
|
||||
value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = valueColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(" ›", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
|
||||
}
|
||||
}
|
||||
// The focused row carries its own one-line description — no dedicated (space-eating)
|
||||
// detail strip. It unfolds right where you're looking, and the row grows to fit.
|
||||
AnimatedVisibility(
|
||||
visible = focused && row.detail.isNotBlank(),
|
||||
enter = fadeIn(tween(180, delayMillis = 60)) + expandVertically(tween(180)),
|
||||
exit = fadeOut(tween(90)) + shrinkVertically(tween(150)),
|
||||
) {
|
||||
Text(
|
||||
row.detail,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.White.copy(alpha = 0.6f),
|
||||
maxLines = 2,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the console settings rows from the current [Settings], writing through [update]. */
|
||||
private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpRow> {
|
||||
fun <T> choice(
|
||||
id: String, header: String?, label: String, detail: String,
|
||||
options: List<Pair<T, String>>, current: T, write: (T) -> Unit,
|
||||
): GpRow {
|
||||
val idx = options.indexOfFirst { it.first == current }
|
||||
return GpRow(
|
||||
id, header, label,
|
||||
value = options.getOrNull(idx)?.second ?: "—",
|
||||
detail = detail,
|
||||
adjust = { delta ->
|
||||
if (idx < 0) {
|
||||
options.firstOrNull()?.let { write(it.first) } != null
|
||||
} else {
|
||||
val t = idx + delta
|
||||
if (t in options.indices) { write(options[t].first); true } else false
|
||||
}
|
||||
},
|
||||
activate = {
|
||||
val i = if (idx < 0) 0 else (idx + 1) % options.size
|
||||
options.getOrNull(i)?.let { write(it.first) }
|
||||
},
|
||||
)
|
||||
}
|
||||
fun toggle(
|
||||
id: String, header: String?, label: String, detail: String,
|
||||
value: Boolean, write: (Boolean) -> Unit,
|
||||
): GpRow = GpRow(
|
||||
id, header, label,
|
||||
value = if (value) "On" else "Off",
|
||||
detail = detail,
|
||||
adjust = { delta -> val target = delta > 0; if (value != target) { write(target); true } else false },
|
||||
activate = { write(!value) },
|
||||
toggled = value,
|
||||
)
|
||||
|
||||
return listOf(
|
||||
choice(
|
||||
"resolution", "Stream", "Resolution",
|
||||
"The host creates a virtual display at exactly this size — no scaling. " +
|
||||
"Custom sizes are typed in the touch settings.",
|
||||
// A custom size (typed in the touch settings) leads the list so it stays visible and
|
||||
// selectable here instead of being silently snapped to Native — a pad can keep a
|
||||
// custom size, it just can't type one.
|
||||
(if (s.isCustomResolution()) {
|
||||
listOf((s.width to s.height) to "Custom · ${s.width} × ${s.height}")
|
||||
} else {
|
||||
emptyList()
|
||||
}) + RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to lbl },
|
||||
s.width to s.height,
|
||||
) { (w, h) -> update(s.copy(width = w, height = h)) },
|
||||
choice(
|
||||
"refresh", null, "Refresh rate", "Frame rate the host renders and streams at.",
|
||||
REFRESH_OPTIONS, s.hz,
|
||||
) { update(s.copy(hz = it)) },
|
||||
choice(
|
||||
"bitrate", null, "Bitrate",
|
||||
"Automatic uses the host's default. Run a speed test from the touch UI for an informed value.",
|
||||
BITRATE_OPTIONS, s.bitrateKbps,
|
||||
) { update(s.copy(bitrateKbps = it)) },
|
||||
choice(
|
||||
"compositor", null, "Compositor",
|
||||
"Which compositor drives the virtual output — honored only if available on the host.",
|
||||
COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.compositor,
|
||||
) { update(s.copy(compositor = it)) },
|
||||
|
||||
choice(
|
||||
"codec", "Video", "Video codec",
|
||||
"A preference — the host falls back if it can't encode this one.",
|
||||
CODEC_OPTIONS, s.codec,
|
||||
) { update(s.copy(codec = it)) },
|
||||
toggle(
|
||||
"hdr", null, "10-bit HDR",
|
||||
"HDR10 — engages when the host sends HDR content and this display supports it.",
|
||||
s.hdrEnabled,
|
||||
) { update(s.copy(hdrEnabled = it)) },
|
||||
toggle(
|
||||
"lowLatency", null, "Low-latency mode",
|
||||
"The fast pipeline (async decode + system tuning). On by default — turn off to fall back if the stream stutters or glitches.",
|
||||
s.lowLatencyMode,
|
||||
) { update(s.copy(lowLatencyMode = it)) },
|
||||
|
||||
choice(
|
||||
"audio", "Audio", "Audio channels", "The speaker layout requested from the host.",
|
||||
AUDIO_CHANNEL_OPTIONS, s.audioChannels,
|
||||
) { update(s.copy(audioChannels = it)) },
|
||||
toggle(
|
||||
"mic", null, "Microphone", "Send this device's microphone to the host's virtual mic.",
|
||||
s.micEnabled,
|
||||
) { update(s.copy(micEnabled = it)) },
|
||||
|
||||
choice(
|
||||
"padType", "Controller", "Controller type",
|
||||
"The virtual pad the host creates — Automatic matches this controller.",
|
||||
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad,
|
||||
) { update(s.copy(gamepad = it)) },
|
||||
|
||||
choice(
|
||||
"hud", "Interface", "Statistics overlay",
|
||||
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
|
||||
"A 3-finger tap cycles the tiers live.",
|
||||
STATS_VERBOSITY_OPTIONS, s.statsVerbosity,
|
||||
) { update(s.copy(statsVerbosity = it)) },
|
||||
toggle(
|
||||
"library", null, "Game library",
|
||||
"Browse a paired host's games with Y (experimental).",
|
||||
s.libraryEnabled,
|
||||
) { update(s.copy(libraryEnabled = it)) },
|
||||
toggle(
|
||||
"autoWake", null, "Auto-wake on connect",
|
||||
"Wake a saved host with Wake-on-LAN when it isn't seen on the network, then connect.",
|
||||
s.autoWakeEnabled,
|
||||
) { update(s.copy(autoWakeEnabled = it)) },
|
||||
toggle(
|
||||
"gamepadUI", null, "Controller-optimized UI",
|
||||
"Turn off to use the touch interface even with a controller connected.",
|
||||
s.gamepadUiEnabled,
|
||||
) { update(s.copy(gamepadUiEnabled = it)) },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.app.UiModeManager
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.hardware.input.InputManager
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
|
||||
/**
|
||||
* Whether the controller-optimized "console" home (the host carousel + gamepad chrome) should
|
||||
* replace the touch UI — the Android mirror of the Apple client's `GamepadUIEnvironment.isActive`:
|
||||
* the user's [enabled] setting AND (a controller is attached OR this is a TV OR the dev [forced]
|
||||
* flag). A TV counts unconditionally — its remote/gamepad is the only input, so it's always the
|
||||
* console UI (as long as the setting is on).
|
||||
*/
|
||||
fun gamepadUiActive(enabled: Boolean, controllerConnected: Boolean, tv: Boolean, forced: Boolean): Boolean =
|
||||
enabled && (controllerConnected || tv || forced)
|
||||
|
||||
/** True on a TV: the leanback/television feature or the TELEVISION ui-mode. */
|
||||
fun isTvDevice(context: Context): Boolean {
|
||||
val pm = context.packageManager
|
||||
if (pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK) ||
|
||||
pm.hasSystemFeature(PackageManager.FEATURE_TELEVISION)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
val uiMode = context.getSystemService(Context.UI_MODE_SERVICE) as? UiModeManager
|
||||
return uiMode?.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION
|
||||
}
|
||||
|
||||
/**
|
||||
* Live "is a game controller attached" state, updated as pads connect/disconnect via
|
||||
* [InputManager]'s device listener — so the home screen flips to the console UI the instant a pad is
|
||||
* plugged in or paired, and back to touch when it's removed. Mirrors the reactivity the Apple client
|
||||
* gets from observing `GamepadManager.shared`.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberControllerConnected(): State<Boolean> {
|
||||
val context = LocalContext.current
|
||||
val connected = remember { mutableStateOf(Gamepad.firstPad() != null) }
|
||||
DisposableEffect(Unit) {
|
||||
val im = context.getSystemService(Context.INPUT_SERVICE) as InputManager
|
||||
val listener = object : InputManager.InputDeviceListener {
|
||||
private fun refresh() { connected.value = Gamepad.firstPad() != null }
|
||||
override fun onInputDeviceAdded(deviceId: Int) = refresh()
|
||||
override fun onInputDeviceRemoved(deviceId: Int) = refresh()
|
||||
override fun onInputDeviceChanged(deviceId: Int) = refresh()
|
||||
}
|
||||
im.registerInputDeviceListener(listener, Handler(Looper.getMainLooper()))
|
||||
connected.value = Gamepad.firstPad() != null
|
||||
onDispose { im.unregisterInputDeviceListener(listener) }
|
||||
}
|
||||
return connected
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.Context
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.VideoDecoders
|
||||
import io.unom.punktfunk.kit.security.ClientIdentity
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** Handshake budget for a normal / library-launch connect (not the long request-access park). */
|
||||
const val CONNECT_TIMEOUT_MS = 10_000
|
||||
|
||||
/**
|
||||
* The one place [NativeBridge.nativeConnect] is assembled — shared by [ConnectScreen] and the library
|
||||
* launcher ([LibraryScreen]). Derives the mode / HDR / gamepad settings the host needs from
|
||||
* [settings]. [pinHex] is the pinned fingerprint (empty ⇒ TOFU). [launch] is a store-qualified library
|
||||
* id (`steam:<appid>` / `custom:<id>`) to boot straight into a game, or `null` for the desktop.
|
||||
* Returns the session handle, or `0` on failure. Call off the main thread.
|
||||
*/
|
||||
suspend fun connectToHost(
|
||||
context: Context,
|
||||
settings: Settings,
|
||||
identity: ClientIdentity,
|
||||
host: String,
|
||||
port: Int,
|
||||
pinHex: String,
|
||||
launch: String?,
|
||||
timeoutMs: Int = CONNECT_TIMEOUT_MS,
|
||||
): Long {
|
||||
// Advertise HDR only when the user enabled it AND this device's display can present it (else the
|
||||
// host sends a proper SDR stream rather than PQ the panel would mis-tone-map).
|
||||
val (w, h, hz) = settings.effectiveMode(context)
|
||||
val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context)
|
||||
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID.
|
||||
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
|
||||
return withContext(Dispatchers.IO) {
|
||||
// Transport-level half of "Low-latency mode (experimental)" (DSCP marking on the media
|
||||
// sockets) — must be applied before connect, since sockets are tagged at creation.
|
||||
NativeBridge.nativeSetLowLatencyMode(settings.lowLatencyMode)
|
||||
NativeBridge.nativeConnect(
|
||||
host, port, w, h, hz,
|
||||
identity.certPem, identity.privateKeyPem, pinHex,
|
||||
settings.bitrateKbps, settings.compositor, gamepadPref,
|
||||
hdrEnabled, 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,
|
||||
launch,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PageSize
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.zIndex
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.ImageLoader
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import io.unom.punktfunk.kit.library.DEFAULT_MGMT_PORT
|
||||
import io.unom.punktfunk.kit.library.GameEntry
|
||||
import io.unom.punktfunk.kit.library.LibraryClient
|
||||
import io.unom.punktfunk.kit.library.LibraryResult
|
||||
import io.unom.punktfunk.kit.library.mtlsHttpClient
|
||||
import io.unom.punktfunk.kit.security.ClientIdentity
|
||||
import io.unom.punktfunk.kit.security.IdentityStore
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.kit.security.obtainIdentity
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sign
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
// The host game-library browser — the Android mirror of the Apple client's LibraryCoverflowView:
|
||||
// a gamepad-driven poster coverflow (centered cover flat + prominent, neighbours receding on a 3D
|
||||
// Y-tilt) fetched from the host's management API over mTLS. Reached with Y from a saved host.
|
||||
|
||||
private sealed class LibState {
|
||||
object Loading : LibState()
|
||||
// Carries the client identity so a launch can dial the host over the same pinned mTLS trust.
|
||||
data class Ready(val games: List<GameEntry>, val loader: ImageLoader, val identity: ClientIdentity) : LibState()
|
||||
data class Message(val text: String) : LibState() // unauthorized / empty / error
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LibraryScreen(
|
||||
host: KnownHost,
|
||||
settings: Settings,
|
||||
onLaunched: (Long) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
navActive: Boolean = true,
|
||||
) {
|
||||
BackHandler(onBack = onBack)
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val hazeState = remember { HazeState() }
|
||||
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
var state by remember { mutableStateOf<LibState>(LibState.Loading) }
|
||||
// A launch (connect) in flight: shows an overlay + gates the pad so a second press can't dial twice.
|
||||
var launching by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(host.address, host.port, host.fpHex) {
|
||||
state = LibState.Loading
|
||||
state = withContext(Dispatchers.IO) {
|
||||
val id = runCatching { obtainIdentity(IdentityStore(context)) }.getOrNull()
|
||||
?: return@withContext LibState.Message("Identity unavailable — re-pair may be required.")
|
||||
when (val res = LibraryClient.fetch(
|
||||
address = host.address,
|
||||
mgmtPort = DEFAULT_MGMT_PORT,
|
||||
certPem = id.certPem,
|
||||
keyPem = id.privateKeyPem,
|
||||
fpHex = host.fpHex,
|
||||
)) {
|
||||
is LibraryResult.Ok -> if (res.games.isEmpty()) {
|
||||
LibState.Message("No games found on this host.")
|
||||
} else {
|
||||
val client = mtlsHttpClient(id.certPem, id.privateKeyPem, host.address, host.fpHex)
|
||||
LibState.Ready(res.games, ImageLoader.Builder(context).okHttpClient(client).build(), id)
|
||||
}
|
||||
is LibraryResult.Unauthorized -> LibState.Message(res.message)
|
||||
is LibraryResult.Error -> LibState.Message(res.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||
GamepadAuroraBackground(Modifier.fillMaxSize())
|
||||
Column(Modifier.fillMaxSize().systemBarsPadding()) {
|
||||
ConsoleHeader("${host.name} — Library")
|
||||
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
when (val s = state) {
|
||||
is LibState.Loading -> LoadingState()
|
||||
is LibState.Message -> MessageState(s.text)
|
||||
is LibState.Ready -> Coverflow(s.games, s.loader, navActive && !launching) { game ->
|
||||
if (!launching) {
|
||||
launching = true
|
||||
scope.launch {
|
||||
// Dial the host over the same pinned mTLS trust, booting straight
|
||||
// into this title (the host resolves `launch` = its library id).
|
||||
val handle = connectToHost(
|
||||
context, settings, s.identity,
|
||||
host.address, host.port, host.fpHex, launch = game.id,
|
||||
)
|
||||
launching = false
|
||||
if (handle != 0L) onLaunched(handle)
|
||||
else Toast.makeText(
|
||||
context,
|
||||
"Launch failed — check the host and try again.",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Launching overlay — the connect + host-side game boot takes a moment; block the pad while it runs.
|
||||
if (launching) {
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.6f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
CircularProgressIndicator(color = Color.White)
|
||||
Text("Launching…", color = Color.White, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Floating legend at the shared spot — same landscape-aware inset as every other console
|
||||
// screen (ignore the safe area in landscape, where the bottom edge isn't a tap target).
|
||||
Box(
|
||||
Modifier.align(Alignment.BottomStart)
|
||||
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(
|
||||
buildList {
|
||||
if (state is LibState.Ready) add(PadGlyph.hint('A', "Launch"))
|
||||
add(PadGlyph.hint('B', "Close", onClick = onBack))
|
||||
},
|
||||
hazeState = hazeState,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingState() {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
CircularProgressIndicator(color = Color.White)
|
||||
Text("Loading library…", color = Color.White.copy(alpha = 0.7f), style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageState(text: String) {
|
||||
Text(
|
||||
text,
|
||||
color = Color.White.copy(alpha = 0.75f),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Coverflow(
|
||||
games: List<GameEntry>,
|
||||
loader: ImageLoader,
|
||||
navActive: Boolean,
|
||||
onLaunch: (GameEntry) -> Unit,
|
||||
) {
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
// Fit a 2:3 poster into the height the detail line leaves; clamp so it never dwarfs the screen.
|
||||
val coverHeight = (maxHeight * 0.72f).coerceAtMost(360.dp)
|
||||
val coverWidth = coverHeight * 2f / 3f
|
||||
val sidePad = ((maxWidth - coverWidth) / 2).coerceAtLeast(0.dp)
|
||||
val pagerState = rememberPagerState(pageCount = { games.size })
|
||||
val scope = rememberCoroutineScope()
|
||||
var navTarget by remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(pagerState.settledPage) { navTarget = pagerState.settledPage }
|
||||
val current = games.getOrNull(navTarget)
|
||||
|
||||
// Controller nav: the pad drives the coverflow. Left/right steps a coalesced target the pager
|
||||
// chases; A launches the centred title; B closes via the screen's BackHandler.
|
||||
GamepadNavEffect(
|
||||
active = navActive && games.isNotEmpty(),
|
||||
onMove = { dir ->
|
||||
val t = (navTarget + dir).coerceIn(0, games.lastIndex)
|
||||
if (t != navTarget) { navTarget = t; scope.launch { pagerState.animateScrollToPage(t) } }
|
||||
},
|
||||
onActivate = { games.getOrNull(navTarget)?.let(onLaunch) },
|
||||
)
|
||||
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
pageSize = PageSize.Fixed(coverWidth),
|
||||
contentPadding = PaddingValues(horizontal = sidePad),
|
||||
pageSpacing = 0.dp, // translationX (below) does the spacing so covers sit closer
|
||||
beyondViewportPageCount = 3, // render more neighbours so a denser fan is visible
|
||||
modifier = Modifier.fillMaxWidth().height(coverHeight + 24.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) { page ->
|
||||
val signed = (pagerState.currentPage - page) + pagerState.currentPageOffsetFraction
|
||||
val d = signed.absoluteValue
|
||||
Poster(
|
||||
game = games[page],
|
||||
loader = loader,
|
||||
modifier = Modifier
|
||||
.zIndex(-d) // centred cover on top, neighbours stacked behind
|
||||
.width(coverWidth)
|
||||
.height(coverHeight)
|
||||
// Touch: tap the centred cover to launch it; tap a neighbour to bring it centre.
|
||||
.clickable {
|
||||
if (page == pagerState.currentPage) onLaunch(games[page])
|
||||
else scope.launch { pagerState.animateScrollToPage(page) }
|
||||
}
|
||||
.graphicsLayer {
|
||||
// Centre at full size; EVERY neighbour settles to one size, so an even pitch
|
||||
// yields even VISUAL gaps. (A progressive shrink made the outer gaps grow —
|
||||
// the "edges spread apart while the centre gets crowded" look.)
|
||||
val scale = 1f - 0.28f * d.coerceAtMost(1f)
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
alpha = (1f - 0.26f * d).coerceAtLeast(0.15f) // depth via fade, not size
|
||||
val rotDeg = signed.coerceIn(-2.5f, 2.5f) * 26f // tilt inward
|
||||
rotationY = rotDeg
|
||||
// Even neighbour pitch (0.8·cover) + a little extra outward push (ramped over
|
||||
// the first step so scrolling stays smooth) so the CENTRE card breathes.
|
||||
val base = signed * size.width * 0.2f - signed.coerceIn(-1f, 1f) * size.width * 0.14f
|
||||
// Counter-balance: a rotated card projects narrower (≈cos θ), which opens its
|
||||
// inner gap — pull it back toward centre by the half-width it loses so the
|
||||
// gaps stay even no matter the tilt.
|
||||
val halfW = size.width * scale * 0.5f
|
||||
val counter = sign(signed) * halfW * (1f - cos(rotDeg * (PI.toFloat() / 180f)))
|
||||
translationX = base + counter
|
||||
// Lower cameraDistance = stronger perspective (CSS `perspective`); the flat
|
||||
// 22 washed the tilt out. 9 makes the same angle read as real depth.
|
||||
cameraDistance = 9f * density
|
||||
transformOrigin = TransformOrigin(0.5f, 0.5f)
|
||||
},
|
||||
)
|
||||
}
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(top = 14.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
current?.title ?: " ",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (current != null) {
|
||||
Text(
|
||||
if (current.isCustom) "CUSTOM" else "STEAM",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color.White.copy(alpha = 0.5f),
|
||||
letterSpacing = 2.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One cover: walks the art candidates (portrait → header → hero) then a text placeholder. */
|
||||
@Composable
|
||||
private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Modifier) {
|
||||
val candidates = game.art.posterCandidates
|
||||
var idx by remember(game.id) { mutableStateOf(0) }
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(shape)
|
||||
.background(Color(0xFF241F3D))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.12f), shape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (idx < candidates.size) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current).data(candidates[idx]).build(),
|
||||
imageLoader = loader,
|
||||
contentDescription = game.title,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onError = { idx++ }, // this candidate failed — try the next, or fall to the placeholder
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
game.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White.copy(alpha = 0.75f),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
}
|
||||
// Store badge, top-start.
|
||||
Box(Modifier.fillMaxSize().padding(8.dp), contentAlignment = Alignment.TopStart) {
|
||||
Text(
|
||||
if (game.isCustom) "Custom" else "Steam",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(Color.Black.copy(alpha = 0.5f))
|
||||
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,21 @@ package io.unom.punktfunk
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
@@ -31,6 +38,13 @@ fun LicensesScreen(onBack: () -> Unit) {
|
||||
context.assets.open("THIRD-PARTY-NOTICES.txt").bufferedReader().use { it.readText() }
|
||||
}.getOrDefault("Third-party notices unavailable.")
|
||||
}
|
||||
// The bundled brand typeface (Geist Sans) ships under the SIL Open Font License 1.1. The OFL
|
||||
// requires the license travel with the font, so surface it here (mirrors the Apple client).
|
||||
val fontLicense = remember {
|
||||
runCatching {
|
||||
context.assets.open("GEIST-OFL.txt").bufferedReader().use { it.readText() }
|
||||
}.getOrNull()
|
||||
}
|
||||
val version = remember {
|
||||
runCatching {
|
||||
@Suppress("DEPRECATION")
|
||||
@@ -38,23 +52,34 @@ fun LicensesScreen(onBack: () -> Unit) {
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
// Pinned header with a visible Back affordance (Back-button/gesture still work via BackHandler).
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 12.dp, top = 8.dp, bottom = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
Text("Open-source licenses", style = MaterialTheme.typography.headlineSmall)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp, vertical = 24.dp),
|
||||
.padding(horizontal = 20.dp)
|
||||
.padding(bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text("Open-source licenses", style = MaterialTheme.typography.headlineMedium)
|
||||
if (version != null) {
|
||||
Text(
|
||||
"punktfunk $version",
|
||||
"Punktfunk $version",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"punktfunk is licensed under MIT OR Apache-2.0, at your option. It uses the open-source " +
|
||||
"Punktfunk is licensed under MIT OR Apache-2.0, at your option. It uses the open-source " +
|
||||
"components below, each under its own license.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
@@ -62,5 +87,17 @@ fun LicensesScreen(onBack: () -> Unit) {
|
||||
notices,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
)
|
||||
if (fontLicense != null) {
|
||||
Text("Bundled font", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"The Geist typeface is licensed under the SIL Open Font License 1.1.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
fontLicense,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
@@ -10,6 +11,9 @@ import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.Keymap
|
||||
@@ -34,8 +38,45 @@ class MainActivity : ComponentActivity() {
|
||||
var padKeyProbe: ((KeyEvent) -> Boolean)? = null
|
||||
var padMotionProbe: ((MotionEvent) -> Boolean)? = null
|
||||
|
||||
/**
|
||||
* Set by [StreamScreen] to its disconnect action. The emergency-exit chord (below) invokes it so a
|
||||
* couch user with no keyboard/Back can always leave a stream.
|
||||
*/
|
||||
var requestStreamExit: (() -> Unit)? = null
|
||||
|
||||
/** Currently-held forwarded pad buttons (bitmask of `Gamepad.BTN_*`), for chord detection. */
|
||||
private var heldPadButtons = 0
|
||||
|
||||
/**
|
||||
* Whether the last console input came from a real gamepad (face buttons / stick) vs. a TV D-pad
|
||||
* remote (which has no A/B/X/Y). The console UI reads this to show glyphs the user recognises — pad
|
||||
* face buttons, or a select glyph + arrows for a remote. Compose observes it (a snapshot state).
|
||||
* Defaults to the remote glyphs on a TV (its D-pad remote is the typical first input, and often the
|
||||
* only one) and to gamepad glyphs everywhere else (the console UI on a phone/tablet only activates
|
||||
* via a real controller, so a TV-remote glyph would be a wrong first impression there) — set from
|
||||
* [onCreate] once a [Context] is available, then kept live by real input.
|
||||
*/
|
||||
var lastPadIsGamepad by mutableStateOf(true)
|
||||
private set
|
||||
|
||||
/**
|
||||
* The glyph family of the controller driving the console UI (Xbox letters / PlayStation shapes /
|
||||
* Nintendo monochrome) — seeded from the first connected pad, then kept live by real input the
|
||||
* same way [lastPadIsGamepad] is. Compose observes it (a snapshot state); the hint bar picks its
|
||||
* button glyphs from it so a DualSense user isn't shown Xbox lettering.
|
||||
*/
|
||||
var lastPadStyle by mutableStateOf(Gamepad.PadStyle.GENERIC)
|
||||
private set
|
||||
|
||||
/** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */
|
||||
private var highRefreshModeId = 0
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
lastPadIsGamepad = !isTvDevice(this)
|
||||
lastPadStyle = Gamepad.styleFor(Gamepad.firstPad())
|
||||
resolveHighRefreshMode()
|
||||
setConsoleHighRefreshRate(true) // the console UI wants max refresh; streaming manages its own
|
||||
// Dark, transparent system bars regardless of the system theme — our UI is always dark, so
|
||||
// the status/nav bars blend with our surface and get light icons. (The no-arg edge-to-edge
|
||||
// picks the *system* light/dark, which left a black status bar over our dark background.)
|
||||
@@ -43,13 +84,39 @@ class MainActivity : ComponentActivity() {
|
||||
statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
|
||||
navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
|
||||
)
|
||||
// Dev escape hatch (mirrors the Apple client's PUNKTFUNK_FORCE_GAMEPAD_UI): force the console
|
||||
// UI without a physical pad — `adb shell am start -n io.unom.punktfunk/.MainActivity --ez
|
||||
// pf_force_gamepad_ui true`. Never set in normal use; real activation is a connected pad / TV.
|
||||
val forceGamepadUi = intent?.getBooleanExtra("pf_force_gamepad_ui", false) ?: false
|
||||
setContent {
|
||||
PunktfunkTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) { App() }
|
||||
Surface(modifier = Modifier.fillMaxSize()) { App(forceGamepadUi = forceGamepadUi) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the panel's highest-refresh mode (same resolution) once, for [setConsoleHighRefreshRate]. */
|
||||
private fun resolveHighRefreshMode() {
|
||||
@Suppress("DEPRECATION")
|
||||
val disp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) display else windowManager.defaultDisplay
|
||||
highRefreshModeId = disp?.supportedModes?.maxWithOrNull(
|
||||
compareBy({ it.refreshRate }, { it.physicalWidth * it.physicalHeight }),
|
||||
)?.modeId ?: 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Opt the CONSOLE UI into the panel's highest refresh mode. Some OEMs (Nothing OS among them) pin
|
||||
* third-party apps to 60Hz unless they explicitly ask for more, which halves the smoothness of the
|
||||
* UI's scrolling/animation on a 120/144Hz panel. [StreamScreen] turns this OFF while streaming so
|
||||
* its own `ANativeWindow_setFrameRate` (matched to the video) governs the panel instead.
|
||||
*/
|
||||
fun setConsoleHighRefreshRate(high: Boolean) {
|
||||
if (highRefreshModeId == 0) return
|
||||
window.attributes = window.attributes.apply {
|
||||
preferredDisplayModeId = if (high) highRefreshModeId else 0
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
val handle = streamHandle
|
||||
if (handle != 0L) {
|
||||
@@ -60,9 +127,20 @@ class MainActivity : ComponentActivity() {
|
||||
if (bit != 0) {
|
||||
when (event.action) {
|
||||
// repeatCount guard: don't re-send a held button as auto-repeat.
|
||||
KeyEvent.ACTION_DOWN ->
|
||||
KeyEvent.ACTION_DOWN -> {
|
||||
if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true)
|
||||
KeyEvent.ACTION_UP -> NativeBridge.nativeSendGamepadButton(handle, bit, false)
|
||||
heldPadButtons = heldPadButtons or bit
|
||||
// Emergency exit: Select + Start + L1 + R1 held together leaves the stream
|
||||
// (a couch user has no keyboard/Back). Fired once per full chord.
|
||||
if (heldPadButtons and STREAM_EXIT_CHORD == STREAM_EXIT_CHORD) {
|
||||
heldPadButtons = 0
|
||||
requestStreamExit?.let { exit -> window.decorView.post { exit() } }
|
||||
}
|
||||
}
|
||||
KeyEvent.ACTION_UP -> {
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, false)
|
||||
heldPadButtons = heldPadButtons and bit.inv()
|
||||
}
|
||||
}
|
||||
return true // consumed
|
||||
}
|
||||
@@ -90,18 +168,31 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Note which input the console UI is being driven by, so its glyphs match (a TV remote's
|
||||
// D-pad is not from SOURCE_GAMEPAD; a pad's face buttons / D-pad are) — and, for a real
|
||||
// pad, WHICH pad family, so the glyphs wear its lettering/shapes.
|
||||
if (event.action == KeyEvent.ACTION_DOWN && isConsoleNavKey(event.keyCode)) {
|
||||
lastPadIsGamepad = event.isFromSource(InputDevice.SOURCE_GAMEPAD)
|
||||
if (lastPadIsGamepad) lastPadStyle = Gamepad.styleFor(event.device)
|
||||
}
|
||||
// The Controllers debug screen sees pad events before the navigation remap below.
|
||||
padKeyProbe?.let { if (it(event)) return true }
|
||||
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||
// Not streaming: a game controller drives the Compose UI (TV + phone). Map the face
|
||||
// buttons to the navigation keys the focus system understands; D-pad *keys* already
|
||||
// move focus on their own, so they fall through to super untouched.
|
||||
val mapped = when (event.keyCode) {
|
||||
KeyEvent.KEYCODE_BUTTON_A -> KeyEvent.KEYCODE_DPAD_CENTER // activate focused element
|
||||
KeyEvent.KEYCODE_BUTTON_B -> KeyEvent.KEYCODE_BACK // back / dismiss
|
||||
else -> 0
|
||||
// buttons to the navigation the focus system / back stack understand; D-pad *keys*
|
||||
// already move focus on their own, so they fall through to super untouched.
|
||||
when (event.keyCode) {
|
||||
// B → back. Drive the OnBackPressedDispatcher directly rather than synthesising a
|
||||
// BACK KeyEvent: a synthetic event isn't "tracking", so the framework's default
|
||||
// onKeyUp(BACK) never calls onBackPressed() and Compose BackHandlers wouldn't fire.
|
||||
KeyEvent.KEYCODE_BUTTON_B -> {
|
||||
if (event.action == KeyEvent.ACTION_UP) onBackPressedDispatcher.onBackPressed()
|
||||
return true
|
||||
}
|
||||
// A → activate the focused element (the focus system understands DPAD_CENTER).
|
||||
KeyEvent.KEYCODE_BUTTON_A ->
|
||||
return super.dispatchKeyEvent(KeyEvent(event.action, KeyEvent.KEYCODE_DPAD_CENTER))
|
||||
}
|
||||
if (mapped != 0) return super.dispatchKeyEvent(KeyEvent(event.action, mapped))
|
||||
}
|
||||
}
|
||||
return super.dispatchKeyEvent(event)
|
||||
@@ -137,6 +228,8 @@ class MainActivity : ComponentActivity() {
|
||||
if (dir != lastNavDir) {
|
||||
lastNavDir = dir
|
||||
if (dir != 0) {
|
||||
lastPadIsGamepad = true // a stick/HAT push can only come from a real gamepad
|
||||
lastPadStyle = Gamepad.styleFor(event.device)
|
||||
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, dir))
|
||||
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, dir))
|
||||
return true
|
||||
@@ -147,4 +240,17 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
return super.dispatchGenericMotionEvent(event)
|
||||
}
|
||||
|
||||
/** Keys that drive the console UI — D-pad + face buttons; used to classify the last input source. */
|
||||
private fun isConsoleNavKey(kc: Int): Boolean = when (kc) {
|
||||
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_LEFT,
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER,
|
||||
-> true
|
||||
else -> KeyEvent.isGamepadButton(kc)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Emergency stream-exit chord: Select + Start + L1 + R1 held together. */
|
||||
val STREAM_EXIT_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_START or Gamepad.BTN_LB or Gamepad.BTN_RB
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,12 +26,18 @@ data class Settings(
|
||||
/** Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
|
||||
* can capture; the resolved count drives the decoder + AAudio layout. */
|
||||
val audioChannels: Int = 2,
|
||||
/** Preferred video codec: `"auto"` (host decides), `"hevc"`, or `"h264"`. A soft preference — the
|
||||
* host emits it when it can, else falls back. AMediaCodec decodes whichever the host resolves. */
|
||||
/** Preferred video codec: `"auto"` (host decides), `"hevc"`, `"h264"`, or `"av1"`. A soft
|
||||
* preference — the host emits it when it can, else falls back. AMediaCodec decodes whichever
|
||||
* the host resolves (AV1 is only advertised/offered when the device has a real AV1 decoder). */
|
||||
val codec: String = "auto",
|
||||
val micEnabled: Boolean = false,
|
||||
/** Show the live stats overlay (FPS / throughput / latency) during a stream. */
|
||||
val statsHudEnabled: 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
|
||||
* decoder/feed/equation HUD is [StatsVerbosity.DETAILED], and a single terse line is
|
||||
* [StatsVerbosity.COMPACT]. A 3-finger tap cycles through the tiers live.
|
||||
*/
|
||||
val statsVerbosity: StatsVerbosity = StatsVerbosity.NORMAL,
|
||||
/**
|
||||
* Touch input model — how touchscreen fingers drive the host. [TouchMode.TRACKPAD] (default):
|
||||
* the cursor stays put on touch-down and moves by the finger's relative delta (swipe to nudge,
|
||||
@@ -41,11 +47,67 @@ data class Settings(
|
||||
* understand touch. Mirrors the Apple client's TouchInputMode.
|
||||
*/
|
||||
val touchMode: TouchMode = TouchMode.TRACKPAD,
|
||||
/**
|
||||
* Swap the whole home screen for the controller-optimized "console" UI (the host carousel +
|
||||
* gamepad chrome) whenever a controller is connected — mirrors the Apple client's
|
||||
* `gamepadUIEnabled`. On by default; turn it off to keep the touch UI even with a pad attached.
|
||||
* A TV (leanback) is always in this mode regardless (its remote/pad is the only input).
|
||||
*/
|
||||
val gamepadUiEnabled: Boolean = true,
|
||||
/**
|
||||
* Show the experimental game-library browser (the coverflow reached with Y from a saved host).
|
||||
* Fetched from the host's management API over mTLS; needs a paired host. Mirrors the Apple
|
||||
* client's `libraryEnabled`.
|
||||
*/
|
||||
val libraryEnabled: Boolean = true,
|
||||
/**
|
||||
* "Low-latency mode" — the master switch over the latency pipeline: the async decode loop
|
||||
* (native; burst-feed + present-newest-per-vsync, the Apple client's discipline), decoder ranking
|
||||
* + per-SoC vendor keys, pipeline thread boosts + ADPF max-performance, game-tagged AAudio, DSCP
|
||||
* marking on the media sockets, HDMI ALLM, and the forced TV mode switch. (The Wi-Fi locks are NOT
|
||||
* part of this — both are always held while streaming; see StreamScreen.) On (default): the fast
|
||||
* pipeline. Off restores the original synchronous decode loop byte-for-byte, kept as a per-device
|
||||
* escape hatch. Promoted to default once the receive-side latency ratchet the overhaul interacted
|
||||
* badly with was fixed in the shared core — the pump now jumps to live on a standing backlog
|
||||
* instead of accumulating it (see `punktfunk-core` `FrameChannel`), so the async loop no longer
|
||||
* feeds a queue that only grows.
|
||||
*/
|
||||
val lowLatencyMode: Boolean = true,
|
||||
/**
|
||||
* 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
|
||||
* for it to reappear (see [WakeController]) before dialing. Off: always dial straight through,
|
||||
* skipping the mDNS-presence check entirely — for a host that's actually up but not visible on
|
||||
* mDNS (a flaky discovery path, a VLAN/subnet that blocks multicast, etc.), where auto-wake would
|
||||
* otherwise misfire and wait out its timeout despite the host already being reachable.
|
||||
*/
|
||||
val autoWakeEnabled: Boolean = true,
|
||||
)
|
||||
|
||||
/** [Settings.touchMode] values; persisted by name. */
|
||||
enum class TouchMode { TRACKPAD, POINTER, TOUCH }
|
||||
|
||||
/**
|
||||
* Stats-overlay detail tiers, in cycling order (persisted by name). Each tier is a strict superset
|
||||
* of the previous one, so toning down never hides a number a lower tier keeps:
|
||||
* - [OFF] — no overlay (and native sampling is gated off, one atomic load per frame).
|
||||
* - [COMPACT] — one line: `fps · end-to-end ms · Mb/s` (+ a loss flag when frames drop).
|
||||
* - [NORMAL] — adds the resolution/refresh line, the end-to-end p50/p95 headline, and the
|
||||
* reliability counters (lost / skipped / FEC) when nonzero. The default.
|
||||
* - [DETAILED] — the full HUD: also the decoder label, the video-feed descriptor, and the
|
||||
* `host+network + decode` stage equation.
|
||||
* A 3-finger tap in-stream cycles Off → Compact → Normal → Detailed → Off (see [next]).
|
||||
*/
|
||||
enum class StatsVerbosity(val label: String) {
|
||||
OFF("Off"),
|
||||
COMPACT("Compact"),
|
||||
NORMAL("Normal"),
|
||||
DETAILED("Detailed");
|
||||
|
||||
/** The next tier for the live 3-finger-tap cycle (wraps Detailed → Off). */
|
||||
fun next(): StatsVerbosity = entries[(ordinal + 1) % entries.size]
|
||||
}
|
||||
|
||||
/** Loads/saves [Settings] in the app-private `punktfunk_settings` prefs. */
|
||||
class SettingsStore(context: Context) {
|
||||
private val prefs =
|
||||
@@ -62,11 +124,24 @@ class SettingsStore(context: Context) {
|
||||
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
|
||||
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
|
||||
micEnabled = prefs.getBoolean(K_MIC, false),
|
||||
statsHudEnabled = prefs.getBoolean(K_HUD, 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;
|
||||
// everyone else (incl. fresh installs) lands on NORMAL — the old always-full HUD toned
|
||||
// down to the new default, which is the whole point of adding tiers.
|
||||
?: if (prefs.contains(K_HUD) && !prefs.getBoolean(K_HUD, true)) {
|
||||
StatsVerbosity.OFF
|
||||
} else {
|
||||
StatsVerbosity.NORMAL
|
||||
},
|
||||
touchMode = prefs.getString(K_TOUCH_MODE, null)
|
||||
?.let { name -> TouchMode.entries.firstOrNull { it.name == name } }
|
||||
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
|
||||
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
|
||||
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
|
||||
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
||||
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
|
||||
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
|
||||
)
|
||||
|
||||
fun save(s: Settings) {
|
||||
@@ -81,8 +156,12 @@ class SettingsStore(context: Context) {
|
||||
.putInt(K_AUDIO_CH, s.audioChannels)
|
||||
.putString(K_CODEC, s.codec)
|
||||
.putBoolean(K_MIC, s.micEnabled)
|
||||
.putBoolean(K_HUD, s.statsHudEnabled)
|
||||
.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)
|
||||
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
|
||||
.apply()
|
||||
}
|
||||
|
||||
@@ -97,8 +176,27 @@ class SettingsStore(context: Context) {
|
||||
const val K_AUDIO_CH = "audio_channels"
|
||||
const val K_CODEC = "codec"
|
||||
const val K_MIC = "mic_enabled"
|
||||
const val K_STATS_VERBOSITY = "stats_verbosity"
|
||||
|
||||
/** Pre-tier Boolean the [K_STATS_VERBOSITY] enum replaced — read once for migration, never
|
||||
* written. */
|
||||
const val K_HUD = "stats_hud_enabled"
|
||||
const val K_TOUCH_MODE = "touch_mode"
|
||||
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
|
||||
const val K_LIBRARY = "library_enabled"
|
||||
|
||||
/**
|
||||
* Bumped AGAIN to restart every install at the new default (ON). History: the original
|
||||
* `"low_latency_mode"` shipped default-ON; `"low_latency_mode_experimental"` restarted
|
||||
* everyone at OFF after the overhaul regressed on some phones. That regression was the
|
||||
* receive-side latency ratchet the async loop fed (a standing queue that only grew) — now
|
||||
* fixed in the shared core (`punktfunk-core` `FrameChannel`: the pump jumps to live on a
|
||||
* standing backlog instead of accumulating it), so the fast pipeline is the default again. A
|
||||
* fresh key re-defaults every install — including ones persisted OFF under the old key — to
|
||||
* 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_AUTO_WAKE = "auto_wake_enabled"
|
||||
|
||||
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
|
||||
const val K_TRACKPAD = "trackpad_mode"
|
||||
@@ -155,6 +253,12 @@ val RESOLUTION_OPTIONS = listOf(
|
||||
Triple(3840, 2160, "3840 × 2160"),
|
||||
)
|
||||
|
||||
/** True when the stored size is none of the [RESOLUTION_OPTIONS] presets — a custom resolution
|
||||
* typed in the touch settings. Detected from the size itself rather than a persisted flag, so it
|
||||
* can never disagree with what's actually stored (mirrors the Apple client). */
|
||||
fun Settings.isCustomResolution(): Boolean =
|
||||
RESOLUTION_OPTIONS.none { (w, h, _) -> w == width && h == height }
|
||||
|
||||
/** (hz, label). `0` = native refresh. */
|
||||
val REFRESH_OPTIONS = listOf(
|
||||
0 to "Native",
|
||||
@@ -174,14 +278,17 @@ val AUDIO_CHANNEL_OPTIONS = listOf(
|
||||
8 to "7.1 Surround",
|
||||
)
|
||||
|
||||
/** (stored value, label) for the preferred video codec. `"auto"` = host decides. */
|
||||
/** (stored value, label) for the preferred video codec. `"auto"` = host decides. The `"av1"` row
|
||||
* only makes sense on a device with a real AV1 decoder — SettingsScreen filters it out otherwise. */
|
||||
val CODEC_OPTIONS = listOf(
|
||||
"auto" to "Automatic",
|
||||
"hevc" to "HEVC (H.265)",
|
||||
"h264" to "H.264 (AVC)",
|
||||
"av1" to "AV1",
|
||||
)
|
||||
|
||||
/** The [Settings.codec] string as a `quic::CODEC_*` preference byte (`0` = auto). H264=1, HEVC=2. */
|
||||
/** The [Settings.codec] string as a `quic::CODEC_*` preference byte (`0` = auto). H264=1, HEVC=2,
|
||||
* AV1=4. */
|
||||
fun Settings.preferredCodec(): Int = when (codec) {
|
||||
"h264" -> 1
|
||||
"hevc" -> 2
|
||||
@@ -196,6 +303,10 @@ val BITRATE_OPTIONS = listOf(
|
||||
20_000 to "20 Mbps",
|
||||
50_000 to "50 Mbps",
|
||||
100_000 to "100 Mbps",
|
||||
150_000 to "150 Mbps",
|
||||
200_000 to "200 Mbps",
|
||||
300_000 to "300 Mbps",
|
||||
500_000 to "500 Mbps",
|
||||
)
|
||||
|
||||
/** index = CompositorPref wire byte. */
|
||||
@@ -207,6 +318,9 @@ val COMPOSITOR_OPTIONS = listOf(
|
||||
"gamescope",
|
||||
)
|
||||
|
||||
/** (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 }
|
||||
|
||||
/** (mode, label) for the touch-input model. */
|
||||
val TOUCH_MODE_OPTIONS = listOf(
|
||||
TouchMode.TRACKPAD to "Trackpad",
|
||||
|
||||
@@ -5,44 +5,83 @@ import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.SportsEsports
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material.icons.filled.Tv
|
||||
import androidx.compose.material.icons.filled.VolumeUp
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
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.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import io.unom.punktfunk.kit.VideoDecoders
|
||||
|
||||
/**
|
||||
* Stream settings, grouped into Display / Host / Audio / Overlay cards. Edits are persisted
|
||||
* immediately via [onChange]; [onBack] returns to the connect screen. Resolution/refresh "Native"
|
||||
* resolve from the device display at connect time.
|
||||
* Stream settings, organised as an iOS-Settings / Android-system-settings style list of category
|
||||
* subpages. On a phone the category list pushes to a full-screen detail; on a tablet / large screen
|
||||
* it becomes a two-pane list-detail (the list stays on the left, the detail on the right). Edits
|
||||
* persist immediately via [onChange]; [onBack] returns to the connect screen.
|
||||
*/
|
||||
@Composable
|
||||
fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -> Unit) {
|
||||
fun SettingsScreen(
|
||||
initial: Settings,
|
||||
onChange: (Settings) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
var s by remember { mutableStateOf(initial) }
|
||||
val context = LocalContext.current
|
||||
var showLicenses by remember { mutableStateOf(false) }
|
||||
@@ -52,13 +91,20 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
BackHandler(onBack = onBack)
|
||||
|
||||
// Mic uplink — turning it on requests RECORD_AUDIO; if denied, the toggle stays off.
|
||||
val micLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted -> update(s.copy(micEnabled = granted)) }
|
||||
val onMicChange: (Boolean) -> Unit = { on ->
|
||||
when {
|
||||
!on -> update(s.copy(micEnabled = false))
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED -> update(s.copy(micEnabled = true))
|
||||
else -> micLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
}
|
||||
|
||||
// Deep sub-screens replace the whole settings surface (they carry their own back).
|
||||
if (showLicenses) {
|
||||
LicensesScreen(onBack = { showLicenses = false })
|
||||
return
|
||||
@@ -68,25 +114,213 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
||||
return
|
||||
}
|
||||
|
||||
// Selected category persists across rotation (stored by name — null = the bare list on a phone).
|
||||
var selectedName by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val selected = selectedName?.let { n -> SettingsCategory.entries.firstOrNull { it.name == n } }
|
||||
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
val twoPane = maxWidth >= 640.dp
|
||||
// A two-column layout must never show an empty detail — land on the first category.
|
||||
LaunchedEffect(twoPane) {
|
||||
if (twoPane && selected == null) selectedName = SettingsCategory.Display.name
|
||||
}
|
||||
|
||||
val detail: @Composable (SettingsCategory, (() -> Unit)?) -> Unit = { cat, back ->
|
||||
CategoryDetail(
|
||||
category = cat,
|
||||
settings = s,
|
||||
onChange = ::update,
|
||||
context = context,
|
||||
onMicChange = onMicChange,
|
||||
onOpenControllers = { showControllers = true },
|
||||
onOpenLicenses = { showLicenses = true },
|
||||
onBack = back,
|
||||
)
|
||||
}
|
||||
|
||||
if (twoPane) {
|
||||
BackHandler(onBack = onBack)
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
CategoryList(
|
||||
selected = selected,
|
||||
twoPane = true,
|
||||
onSelect = { selectedName = it.name },
|
||||
modifier = Modifier.width(300.dp).fillMaxHeight(),
|
||||
)
|
||||
VerticalDivider()
|
||||
Box(Modifier.weight(1f).fillMaxHeight()) {
|
||||
// Cross-fade the detail pane as the selected category changes.
|
||||
AnimatedContent(
|
||||
targetState = selected ?: SettingsCategory.Display,
|
||||
transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) },
|
||||
label = "SettingsPane",
|
||||
) { cat -> detail(cat, null) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Compact: the category list pushes to a full-screen detail and back, like the iOS /
|
||||
// Android system settings — a horizontal slide that tracks the drill-in direction.
|
||||
BackHandler { if (selected != null) selectedName = null else onBack() }
|
||||
AnimatedContent(
|
||||
targetState = selected,
|
||||
transitionSpec = {
|
||||
if (targetState != null) {
|
||||
slideInHorizontally { it } + fadeIn() togetherWith
|
||||
slideOutHorizontally { -it } + fadeOut()
|
||||
} else {
|
||||
slideInHorizontally { -it } + fadeIn() togetherWith
|
||||
slideOutHorizontally { it } + fadeOut()
|
||||
}
|
||||
},
|
||||
label = "SettingsPush",
|
||||
) { sel ->
|
||||
if (sel == null) {
|
||||
CategoryList(
|
||||
selected = null,
|
||||
twoPane = false,
|
||||
onSelect = { selectedName = it.name },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else {
|
||||
detail(sel) { selectedName = null }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The top-level settings groups — each opens its own subpage (list on phone, split on tablet). */
|
||||
enum class SettingsCategory(val title: String, val icon: ImageVector) {
|
||||
Display("Display", Icons.Filled.Tv),
|
||||
Audio("Audio", Icons.Filled.VolumeUp),
|
||||
Controls("Controls", Icons.Filled.SportsEsports),
|
||||
Interface("Interface", Icons.Filled.Tune),
|
||||
About("About", Icons.Filled.Info),
|
||||
}
|
||||
|
||||
/** The category list — the settings root. Highlights the [selected] row when it drives a detail pane. */
|
||||
@Composable
|
||||
private fun CategoryList(
|
||||
selected: SettingsCategory?,
|
||||
twoPane: Boolean,
|
||||
onSelect: (SettingsCategory) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 12.dp, vertical = 20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
"Settings",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(start = 8.dp, bottom = 12.dp),
|
||||
)
|
||||
SettingsCategory.entries.forEach { cat ->
|
||||
val highlighted = twoPane && selected == cat
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(if (highlighted) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent)
|
||||
.clickable { onSelect(cat) }
|
||||
.padding(horizontal = 14.dp, vertical = 15.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
cat.icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(end = 16.dp),
|
||||
)
|
||||
Text(cat.title, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||
if (!twoPane) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One category's controls. [onBack] non-null (phone push) shows a back arrow; null (tablet pane) hides it. */
|
||||
@Composable
|
||||
private fun CategoryDetail(
|
||||
category: SettingsCategory,
|
||||
settings: Settings,
|
||||
onChange: (Settings) -> Unit,
|
||||
context: android.content.Context,
|
||||
onMicChange: (Boolean) -> Unit,
|
||||
onOpenControllers: () -> Unit,
|
||||
onOpenLicenses: () -> Unit,
|
||||
onBack: (() -> Unit)?,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp, vertical = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
Text("Settings", style = MaterialTheme.typography.headlineMedium)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (onBack != null) {
|
||||
IconButton(onClick = onBack, modifier = Modifier.padding(end = 4.dp)) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
Text(category.title, style = MaterialTheme.typography.headlineMedium)
|
||||
}
|
||||
when (category) {
|
||||
SettingsCategory.Display -> DisplaySettings(settings, onChange, context)
|
||||
SettingsCategory.Audio -> AudioSettings(settings, onChange, onMicChange)
|
||||
SettingsCategory.Controls -> ControlsSettings(settings, onChange, onOpenControllers)
|
||||
SettingsCategory.Interface -> InterfaceSettings(settings, onChange)
|
||||
SettingsCategory.About -> AboutSettings(onOpenLicenses)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: android.content.Context) {
|
||||
val (nw, nh, nhz) = nativeDisplayMode(context)
|
||||
|
||||
SettingsGroup("Display") {
|
||||
// "Custom…" picked while the stored size is still a preset — keeps the size fields visible
|
||||
// until an edit actually makes it custom (or a preset is re-picked). Custom itself is detected
|
||||
// from the stored size, never flagged (see [isCustomResolution]), so nothing new persists.
|
||||
var customPicked by remember { mutableStateOf(false) }
|
||||
val showCustom = customPicked || s.isCustomResolution()
|
||||
SettingsCard {
|
||||
SettingDropdown(
|
||||
label = "Resolution",
|
||||
options = RESOLUTION_OPTIONS.map { (w, h, lbl) ->
|
||||
(w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl)
|
||||
},
|
||||
selected = s.width to s.height,
|
||||
) { (w, h) -> update(s.copy(width = w, height = h)) }
|
||||
options = RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl) } +
|
||||
// The (-1, -1) sentinel can't collide with a real size; once a custom size is
|
||||
// stored its label carries the live value, like the native row carries ($nw × $nh).
|
||||
((-1 to -1) to if (s.isCustomResolution()) "Custom (${s.width} × ${s.height})" else "Custom…"),
|
||||
selected = if (showCustom) -1 to -1 else s.width to s.height,
|
||||
) { (w, h) ->
|
||||
if (w < 0) {
|
||||
// Seed from the current *effective* size so the fields start from something
|
||||
// sensible (the resolved native mode, not the 0 × 0 placeholder).
|
||||
customPicked = true
|
||||
update(s.copy(width = if (s.width > 0) s.width else nw, height = if (s.height > 0) s.height else nh))
|
||||
} else {
|
||||
customPicked = false
|
||||
update(s.copy(width = w, height = h))
|
||||
}
|
||||
}
|
||||
if (showCustom) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
ResolutionField(label = "Width", value = s.width, modifier = Modifier.weight(1f)) { w ->
|
||||
update(s.copy(width = w))
|
||||
}
|
||||
ResolutionField(label = "Height", value = s.height, modifier = Modifier.weight(1f)) { h ->
|
||||
update(s.copy(height = h))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingDropdown(
|
||||
label = "Refresh rate",
|
||||
@@ -94,21 +328,21 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
||||
selected = s.hz,
|
||||
) { hz -> update(s.copy(hz = hz)) }
|
||||
|
||||
SettingDropdown(
|
||||
label = "Bitrate",
|
||||
options = BITRATE_OPTIONS,
|
||||
selected = s.bitrateKbps,
|
||||
) { kbps -> update(s.copy(bitrateKbps = kbps)) }
|
||||
SettingDropdown(label = "Bitrate", options = BITRATE_OPTIONS, selected = s.bitrateKbps) { kbps ->
|
||||
update(s.copy(bitrateKbps = kbps))
|
||||
}
|
||||
|
||||
SettingDropdown(
|
||||
label = "Video codec",
|
||||
options = CODEC_OPTIONS,
|
||||
selected = s.codec,
|
||||
) { c -> update(s.copy(codec = c)) }
|
||||
// AV1 is only offered when the device has a real AV1 decoder (it's never advertised to the
|
||||
// host otherwise, so preferring it would be a dead setting). A stored "av1" from a capable
|
||||
// device stays visible so the selection is always representable.
|
||||
val av1Capable = remember { VideoDecoders.pickDecoder("video/av01") != null }
|
||||
val codecOptions = CODEC_OPTIONS.filter { (v, _) -> v != "av1" || av1Capable || s.codec == "av1" }
|
||||
SettingDropdown(label = "Video codec", options = codecOptions, selected = s.codec) { c ->
|
||||
update(s.copy(codec = c))
|
||||
}
|
||||
|
||||
// HDR is only meaningful on a panel that can present HDR10; on an SDR display the toggle
|
||||
// is disabled (and HDR is never advertised regardless) so the host doesn't send PQ the
|
||||
// panel would mis-tone-map. The capability is fixed for the device, so read it once.
|
||||
// HDR is only meaningful on a panel that can present HDR10; on an SDR display the toggle is
|
||||
// disabled (and HDR is never advertised) so the host doesn't send PQ the panel mis-tone-maps.
|
||||
val hdrCapable = remember { displaySupportsHdr(context) }
|
||||
ToggleRow(
|
||||
title = "HDR",
|
||||
@@ -121,97 +355,120 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
||||
enabled = hdrCapable,
|
||||
onCheckedChange = { on -> update(s.copy(hdrEnabled = on)) },
|
||||
)
|
||||
}
|
||||
|
||||
SettingsGroup("Host") {
|
||||
SettingDropdown(
|
||||
label = "Compositor",
|
||||
options = COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl },
|
||||
selected = s.compositor,
|
||||
) { c -> update(s.copy(compositor = c)) }
|
||||
|
||||
ToggleRow(
|
||||
title = "Low-latency mode",
|
||||
subtitle = "The fast pipeline (async decode, per-device decoder selection, HDMI game " +
|
||||
"mode). On by default — turn off to fall back to the plain decode path if the stream " +
|
||||
"stutters or glitches on this device.",
|
||||
checked = s.lowLatencyMode,
|
||||
onCheckedChange = { on -> update(s.copy(lowLatencyMode = on)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AudioSettings(s: Settings, update: (Settings) -> Unit, onMicChange: (Boolean) -> Unit) {
|
||||
SettingsCard {
|
||||
SettingDropdown(label = "Audio channels", options = AUDIO_CHANNEL_OPTIONS, selected = s.audioChannels) { ch ->
|
||||
update(s.copy(audioChannels = ch))
|
||||
}
|
||||
ToggleRow(
|
||||
title = "Microphone",
|
||||
subtitle = "Send your mic to the host's virtual microphone",
|
||||
checked = s.micEnabled,
|
||||
onCheckedChange = onMicChange,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ControlsSettings(s: Settings, update: (Settings) -> Unit, onOpenControllers: () -> Unit) {
|
||||
SettingsCard {
|
||||
SettingDropdown(label = "Touch input", options = TOUCH_MODE_OPTIONS, selected = s.touchMode) { mode ->
|
||||
update(s.copy(touchMode = mode))
|
||||
}
|
||||
Text(
|
||||
"Trackpad: relative cursor like a laptop touchpad — tap to click, two-finger tap " +
|
||||
"right-clicks, two fingers scroll, tap-then-drag holds the button. Direct pointer: " +
|
||||
"the cursor jumps to your finger. Touch passthrough: real multi-touch reaches the " +
|
||||
"host, for apps that understand touch.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
SettingsCard {
|
||||
SettingDropdown(
|
||||
label = "Controller type",
|
||||
options = GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl },
|
||||
selected = s.gamepad,
|
||||
) { g -> update(s.copy(gamepad = g)) }
|
||||
|
||||
ClickableRow(
|
||||
title = "Connected controllers",
|
||||
subtitle = "What the app detects, with a live input test",
|
||||
onClick = { showControllers = true },
|
||||
onClick = onOpenControllers,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsGroup("Audio") {
|
||||
SettingDropdown(
|
||||
label = "Audio channels",
|
||||
options = AUDIO_CHANNEL_OPTIONS,
|
||||
selected = s.audioChannels,
|
||||
) { ch -> update(s.copy(audioChannels = ch)) }
|
||||
|
||||
@Composable
|
||||
private fun InterfaceSettings(s: Settings, update: (Settings) -> Unit) {
|
||||
SettingsCard {
|
||||
ToggleRow(
|
||||
title = "Microphone",
|
||||
subtitle = "Send your mic to the host's virtual microphone",
|
||||
checked = s.micEnabled,
|
||||
onCheckedChange = { on ->
|
||||
when {
|
||||
!on -> update(s.copy(micEnabled = false))
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED -> update(s.copy(micEnabled = true))
|
||||
else -> micLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
},
|
||||
title = "Controller-optimized UI",
|
||||
subtitle = "Switch to the console home (host carousel) when a controller is connected",
|
||||
checked = s.gamepadUiEnabled,
|
||||
onCheckedChange = { on -> update(s.copy(gamepadUiEnabled = on)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Game library",
|
||||
subtitle = "Browse a paired host's game library (press Y on a saved host)",
|
||||
checked = s.libraryEnabled,
|
||||
onCheckedChange = { on -> update(s.copy(libraryEnabled = on)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Auto-wake on connect",
|
||||
subtitle = "Send Wake-on-LAN and wait for a saved host to reappear on mDNS before " +
|
||||
"connecting. Turn off if a host that's already on isn't seen on mDNS, so connects " +
|
||||
"go straight through instead of waiting out the wake timeout.",
|
||||
checked = s.autoWakeEnabled,
|
||||
onCheckedChange = { on -> update(s.copy(autoWakeEnabled = on)) },
|
||||
)
|
||||
}
|
||||
|
||||
SettingsGroup("Touch input") {
|
||||
SettingDropdown(
|
||||
label = "Touch input",
|
||||
options = TOUCH_MODE_OPTIONS,
|
||||
selected = s.touchMode,
|
||||
onSelect = { mode -> update(s.copy(touchMode = mode)) },
|
||||
)
|
||||
label = "Stats overlay",
|
||||
options = STATS_VERBOSITY_OPTIONS,
|
||||
selected = s.statsVerbosity,
|
||||
) { v -> update(s.copy(statsVerbosity = v)) }
|
||||
Text(
|
||||
"Trackpad: relative cursor like a laptop touchpad — tap to click, two-finger " +
|
||||
"tap right-clicks, two fingers scroll, tap-then-drag holds the button. " +
|
||||
"Direct pointer: the cursor jumps to your finger. Touch passthrough: real " +
|
||||
"multi-touch reaches the host, for apps that understand touch.",
|
||||
"How much the in-stream overlay shows: Compact is a single fps · latency · bitrate " +
|
||||
"line; Normal adds the resolution and reliability lines; Detailed adds the decoder, " +
|
||||
"colour and latency-breakdown lines. A 3-finger tap cycles the tiers live.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
SettingsGroup("Overlay") {
|
||||
ToggleRow(
|
||||
title = "Stats overlay",
|
||||
subtitle = "Show FPS, throughput and latency while streaming (3-finger tap toggles it live)",
|
||||
checked = s.statsHudEnabled,
|
||||
onCheckedChange = { on -> update(s.copy(statsHudEnabled = on)) },
|
||||
)
|
||||
}
|
||||
|
||||
SettingsGroup("About") {
|
||||
@Composable
|
||||
private fun AboutSettings(onOpenLicenses: () -> Unit) {
|
||||
SettingsCard {
|
||||
ClickableRow(
|
||||
title = "Open-source licenses",
|
||||
subtitle = "Third-party notices and credits",
|
||||
onClick = { showLicenses = true },
|
||||
onClick = onOpenLicenses,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A titled group of settings rendered inside an outlined card. */
|
||||
/** A group of settings rendered inside an outlined card. */
|
||||
@Composable
|
||||
private fun SettingsGroup(title: String, content: @Composable ColumnScope.() -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
)
|
||||
private fun SettingsCard(content: @Composable ColumnScope.() -> Unit) {
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
@@ -220,7 +477,6 @@ private fun SettingsGroup(title: String, content: @Composable ColumnScope.() ->
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A title + subtitle on the left, a Switch on the right. [enabled] greys out the whole row. */
|
||||
@Composable
|
||||
@@ -265,6 +521,12 @@ private fun ClickableRow(title: String, subtitle: String, onClick: () -> Unit) {
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,3 +566,30 @@ private fun <T> SettingDropdown(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One side of a custom resolution. Digits only; every usable keystroke commits — coerced even
|
||||
* (encoders reject odd dimensions) and capped at 8192, the HEVC/AV1 per-side ceiling (the host
|
||||
* clamps H.264's tighter 4096 itself) — while the field keeps the raw text so intermediate states
|
||||
* ("15" on the way to "1512") aren't rewritten mid-typing; it snaps to the committed value when
|
||||
* focus leaves. */
|
||||
@Composable
|
||||
private fun ResolutionField(
|
||||
label: String,
|
||||
value: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
onCommit: (Int) -> Unit,
|
||||
) {
|
||||
var text by remember { mutableStateOf(if (value > 0) value.toString() else "") }
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { raw ->
|
||||
text = raw.filter { it.isDigit() }.take(4)
|
||||
val v = (text.toIntOrNull() ?: 0).let { it - it % 2 }.coerceAtMost(8192)
|
||||
if (v > 0) onCommit(v)
|
||||
},
|
||||
label = { Text(label) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = modifier.onFocusChanged { if (!it.isFocused) text = if (value > 0) value.toString() else "" },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,58 +15,139 @@ import io.unom.punktfunk.kit.NativeBridge
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The live stats overlay — mirrors the Apple client's HUD. Reads the 14-double layout from
|
||||
* [NativeBridge.nativeVideoStats]:
|
||||
* `[fps, mbps, latP50Ms, latP95Ms, latValid, skew, w, h, hz, dropped, bitDepth, colorPrimaries,
|
||||
* colorTransfer, chromaFormatIdc]`. The trailing four (present on a current native lib) describe the
|
||||
* negotiated video feed and render as a codec/depth/colour/chroma line; older layouts just omit it.
|
||||
* 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]:
|
||||
* `[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]`.
|
||||
*
|
||||
* [verbosity] selects how many lines render (each tier a superset of the last — see
|
||||
* [StatsVerbosity]):
|
||||
* - [StatsVerbosity.COMPACT] — one line, `fps · end-to-end ms · Mb/s` (+ a loss flag).
|
||||
* - [StatsVerbosity.NORMAL] — the res/fps/Mb·s line, the end-to-end p50/p95 headline, and the
|
||||
* reliability counters (18–21) when nonzero.
|
||||
* - [StatsVerbosity.DETAILED] — also the decoder label, the video-feed descriptor (10–13), and the
|
||||
* stage equation (14/15, split into `host + network` when the Phase-2 terms at 16/17 are nonzero).
|
||||
* [StatsVerbosity.OFF] renders nothing. Older native layouts simply omit the lines they lack (the
|
||||
* counter line falls back to the cumulative `lostTotal` at index 9 on a pre-window lib).
|
||||
*/
|
||||
@Composable
|
||||
internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
|
||||
if (s.size < 10) return
|
||||
internal fun StatsOverlay(
|
||||
s: DoubleArray,
|
||||
verbosity: StatsVerbosity,
|
||||
decoderLabel: String = "",
|
||||
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 latValid = s[4] != 0.0
|
||||
val skew = s[5] != 0.0
|
||||
val dropped = s[9].toLong()
|
||||
val lost = s[9].toLong()
|
||||
val detailed = verbosity == StatsVerbosity.DETAILED
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s",
|
||||
color = Color.White,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
videoFeedLine(s)?.let { feed ->
|
||||
Text(
|
||||
feed,
|
||||
color = Color.White,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
// Compact: everything the glance-value needs on one line, nothing else.
|
||||
if (verbosity == StatsVerbosity.COMPACT) {
|
||||
statLine(compactLine(s, latValid), Color.White)
|
||||
return@Column
|
||||
}
|
||||
|
||||
statLine("$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s", Color.White)
|
||||
if (detailed && decoderLabel.isNotEmpty()) {
|
||||
statLine(decoderLabel, Color(0xFFB0D0FF))
|
||||
}
|
||||
if (detailed) {
|
||||
videoFeedLine(s)?.let { statLine(it, Color.White) }
|
||||
}
|
||||
if (latValid) {
|
||||
val tag = if (skew) "" else " (same-host)"
|
||||
Text(
|
||||
"capture→client ${"%.1f".format(s[2])}/${"%.1f".format(s[3])} ms p50/p95$tag",
|
||||
color = Color.White,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
// Display stage (s[22]–s[25], from OnFrameRendered): when a render timestamp landed
|
||||
// this window the headline is the directly-measured capture→displayed pair and the
|
||||
// equation gains its `display` term; otherwise (older lib / no callbacks) the endpoint
|
||||
// honestly stays capture→decoded — the equation always tiles the headline interval.
|
||||
val dispValid = s.size >= 26 && s[22] != 0.0
|
||||
val tag = if (skew) "" else " (same-host clock)"
|
||||
val (p50, p95, endpoint) = if (dispValid) {
|
||||
Triple(s[24], s[25], "capture→displayed")
|
||||
} else {
|
||||
Triple(s[2], s[3], "capture→decoded")
|
||||
}
|
||||
statLine(
|
||||
"end-to-end ${"%.1f".format(p50)} ms p50 · ${"%.1f".format(p95)} p95 · $endpoint$tag",
|
||||
Color.White,
|
||||
)
|
||||
if (detailed && s.size >= 16) {
|
||||
// Phase-2 split (s[16]/s[17]): render `host + network` separately when the host
|
||||
// reported its share this window; otherwise the combined term (old host / no
|
||||
// matched 0xCF timing).
|
||||
val hostTerms = if (s.size >= 18 && s[16] > 0) {
|
||||
"host ${"%.1f".format(s[16])} + network ${"%.1f".format(s[17])}"
|
||||
} else {
|
||||
"host+network ${"%.1f".format(s[14])}"
|
||||
}
|
||||
if (dropped > 0) {
|
||||
Text(
|
||||
"dropped $dropped",
|
||||
color = Color(0xFFFFB0B0),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
val displayTerm = if (dispValid) " + display ${"%.1f".format(s[23])}" else ""
|
||||
statLine("= $hostTerms + decode ${"%.1f".format(s[15])}$displayTerm", Color.White)
|
||||
}
|
||||
}
|
||||
counterLine(s, lost)?.let { statLine(it, Color(0xFFFFB0B0)) }
|
||||
}
|
||||
}
|
||||
|
||||
/** One monospace HUD line — the shared type ramp so every tier's rows line up. */
|
||||
@Composable
|
||||
private fun statLine(text: String, color: Color) {
|
||||
Text(text, color = color, fontFamily = FontFamily.Monospace, fontSize = 12.sp)
|
||||
}
|
||||
|
||||
/**
|
||||
* The single [StatsVerbosity.COMPACT] line: `238 fps · 1.3 ms · 921 Mb/s`. The end-to-end p50 term
|
||||
* is dropped when no in-range latency sample landed (`latValid` false), and a loss flag
|
||||
* `· ⚠ lost {n}` is appended when the window (or, on an old lib, the session) dropped frames — the
|
||||
* one reliability signal worth surfacing even at the tersest tier.
|
||||
*/
|
||||
private fun compactLine(s: DoubleArray, latValid: Boolean): String {
|
||||
// Prefer the capture→displayed end-to-end (s[24]) when a render timestamp landed this window.
|
||||
val e2eP50 = if (s.size >= 26 && s[22] != 0.0) s[24] else s[2]
|
||||
val parts = buildList {
|
||||
add("${s[0].roundToInt()} fps")
|
||||
if (latValid) add("${"%.1f".format(e2eP50)} ms")
|
||||
add("${s[1].roundToInt()} Mb/s")
|
||||
}
|
||||
val lostWindow = if (s.size >= 22) s[18].toLong() else s[9].toLong()
|
||||
val suffix = if (lostWindow > 0) " ⚠ lost $lostWindow" else ""
|
||||
return parts.joinToString(" · ") + suffix
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the spec's line-4 counters from the per-window doubles at 18–21 —
|
||||
* `lost {n} ({pct}%) · skipped {m} · FEC {k}`, each term only when nonzero, the whole line `null`
|
||||
* when all are zero (spec: "only rendered when any value is nonzero"). `pct = lost/(frames+lost)`
|
||||
* (the received count rides at index 21). A pre-window layout (< 22 doubles) falls back to the
|
||||
* session-cumulative `lostTotal` so an older native lib still reports loss.
|
||||
*/
|
||||
private fun counterLine(s: DoubleArray, lostTotal: Long): String? {
|
||||
if (s.size < 22) return if (lostTotal > 0) "lost $lostTotal" else null
|
||||
val lost = s[18].toLong()
|
||||
val skipped = s[19].toLong()
|
||||
val fec = s[20].toLong()
|
||||
val frames = s[21].toLong()
|
||||
if (lost == 0L && skipped == 0L && fec == 0L) return null
|
||||
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 (fec > 0) add("FEC $fec")
|
||||
}.joinToString(" · ")
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.WindowManager
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -30,6 +35,7 @@ import androidx.core.view.WindowInsetsControllerCompat
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.GamepadFeedback
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.VideoDecoders
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@@ -48,36 +54,114 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
Manifest.permission.RECORD_AUDIO,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
// Live decode stats for the HUD. `showStats` gates the whole pipeline: the native per-frame
|
||||
// sampling (nativeSetVideoStatsEnabled — hidden HUD costs one atomic load per frame) AND the
|
||||
// 1 s poll loop, which only runs while the overlay is visible. Enabling resets the native
|
||||
// window, so re-showing never renders stale data. A 3-finger tap toggles it live; the default
|
||||
// comes from Settings.
|
||||
// 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
|
||||
// the native window, so re-showing never renders stale data. A 3-finger tap cycles the
|
||||
// verbosity tier live (Off → Compact → Normal → Detailed → Off); the default comes from
|
||||
// Settings. The tier only changes how many lines `StatsOverlay` draws — switching between the
|
||||
// visible tiers keeps sampling running (the effect keys on `statsOn`, not the tier) so it never
|
||||
// blanks the numbers for a poll interval.
|
||||
val initialSettings = remember { SettingsStore(context).load() }
|
||||
var stats by remember { mutableStateOf<DoubleArray?>(null) }
|
||||
var showStats by remember { mutableStateOf(initialSettings.statsHudEnabled) }
|
||||
var decoderLabel by remember { mutableStateOf("") }
|
||||
var statsVerbosity by remember { mutableStateOf(initialSettings.statsVerbosity) }
|
||||
val statsOn = statsVerbosity != StatsVerbosity.OFF
|
||||
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
|
||||
val touchMode = initialSettings.touchMode
|
||||
LaunchedEffect(handle, showStats) {
|
||||
NativeBridge.nativeSetVideoStatsEnabled(handle, showStats)
|
||||
if (showStats) {
|
||||
// "Low-latency mode" master toggle, resolved once for the session. On (the default) enables the
|
||||
// fast pipeline — decoder ranking + vendor keys + async loop (native side), HDMI ALLM below,
|
||||
// game-tagged audio, and DSCP marking (applied earlier, at connect); off falls back to the
|
||||
// original synchronous decode pipeline as a per-device escape hatch.
|
||||
val lowLatencyMode = initialSettings.lowLatencyMode
|
||||
// TV form factor (leanback): the decoder actively switches the HDMI output mode to the stream
|
||||
// refresh; a phone/tablet gets the softer seamless frame-rate hint instead.
|
||||
val isTv = remember { context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) }
|
||||
LaunchedEffect(handle, statsOn) {
|
||||
NativeBridge.nativeSetVideoStatsEnabled(handle, statsOn)
|
||||
if (statsOn) {
|
||||
while (true) {
|
||||
delay(1000)
|
||||
stats = NativeBridge.nativeVideoStats(handle)
|
||||
// The decoder is fixed for the session; fetch its label once it's resolved.
|
||||
if (decoderLabel.isEmpty()) decoderLabel = NativeBridge.nativeVideoDecoderLabel(handle)
|
||||
}
|
||||
} else {
|
||||
stats = null // drop the last snapshot so a re-show never flashes stale numbers
|
||||
}
|
||||
}
|
||||
|
||||
// Host-gone watchdog. When the host suspends/sleeps (or crashes, or drops off the network) it
|
||||
// stops answering the QUIC keep-alive and the connection idle-times out (~8 s) — no more frames
|
||||
// arrive and the decoder would otherwise sit frozen on its last decoded frame until the user
|
||||
// manually backed out. Poll the native session-liveness flag (one atomic load, independent of the
|
||||
// stats HUD) and, the moment the session is dead, drop back to the menu so the user can
|
||||
// Wake-on-LAN the host instead of being stranded on a frozen picture. Mirrors the Apple client's
|
||||
// onSessionEnd → sessionEnded() → disconnect(). The 1 s cadence + the ~8 s idle timeout is a
|
||||
// deliberately generous window: the keep-alive holds a merely-quiet connection (a static desktop)
|
||||
// open, so this fires only on a genuinely dead peer, never a false positive. Keyed on `handle`, so
|
||||
// it stops the moment we navigate away (the handle is only freed later, in onDispose).
|
||||
LaunchedEffect(handle) {
|
||||
while (true) {
|
||||
delay(1000)
|
||||
if (NativeBridge.nativeSessionEnded(handle)) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Connection lost — the host may be asleep. Wake it to reconnect.",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
onDisconnect()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One-shot teardown guard. Both the SurfaceView callback and DisposableEffect tear down on the
|
||||
// way out, but `nativeClose` frees the handle — so once it's closed, NO path may touch the handle
|
||||
// again (use-after-free → SIGSEGV: the consistent back-while-streaming crash). Both run on the
|
||||
// main thread, so a plain flag is race-free; AtomicBoolean just makes the intent explicit.
|
||||
val closed = remember { AtomicBoolean(false) }
|
||||
|
||||
// Wi-Fi locks held for the stream's duration — BOTH of them, unconditionally (Moonlight does
|
||||
// the same). Without an effective lock, Wi-Fi power save batches downlink delivery into
|
||||
// beacon-interval clumps: hundreds of ms of latency mush, sawtoothing bitrate, and periodic
|
||||
// whole-frame loss when the AP's power-save buffer overflows (all observed live on a phone).
|
||||
// - FULL_LOW_LATENCY (API 29+) is the only lock that actually disables power save on modern
|
||||
// Android; it needs the app foreground + screen on, which a stream always is.
|
||||
// - FULL_HIGH_PERF covers older releases — it is deprecated AND a documented no-op on recent
|
||||
// Android, which is exactly why it can't be the only lock (a lesson learned: holding just
|
||||
// HIGH_PERF left power save fully active on Android 13+).
|
||||
// acquire() ENFORCES the WAKE_LOCK permission (manifest) — and a failed acquire MUST be loud:
|
||||
// a silent runCatching hid the missing permission for weeks (dumpsys wifi showed
|
||||
// low_latency_active_time_ms=0 across every "locked" stream). Non-reference-counted: one
|
||||
// explicit acquire/release each.
|
||||
val wifiLocks = remember(handle) {
|
||||
val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
|
||||
?: return@remember emptyList<WifiManager.WifiLock>()
|
||||
buildList {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
wm.createWifiLock(WifiManager.WIFI_MODE_FULL_LOW_LATENCY, "punktfunk:stream-ll")
|
||||
?.let(::add)
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "punktfunk:stream-hp")
|
||||
?.let(::add)
|
||||
}.onEach { it.setReferenceCounted(false) }
|
||||
}
|
||||
|
||||
DisposableEffect(handle) {
|
||||
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
wifiLocks.forEach { lock ->
|
||||
runCatching { lock.acquire() }.onFailure { e ->
|
||||
Log.w("punktfunk", "WifiLock acquire failed — power save stays ON: $lock", e)
|
||||
}
|
||||
}
|
||||
// HDMI Auto Low-Latency Mode: ask the display to drop its post-processing (game mode) —
|
||||
// the biggest panel-side latency win on the TV boxes. No-op where ALLM isn't supported. API
|
||||
// 30+. Part of the experimental low-latency stack.
|
||||
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
window?.setPreferMinimalPostProcessing(true)
|
||||
}
|
||||
controller?.let {
|
||||
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
it.hide(WindowInsetsCompat.Type.systemBars())
|
||||
@@ -91,6 +175,10 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
activity?.streamHandle = handle // route hardware keys to this session
|
||||
activity?.axisMapper = Gamepad.AxisMapper(handle) // route joystick axes
|
||||
// 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.
|
||||
activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
||||
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
|
||||
// Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close.
|
||||
val feedback = GamepadFeedback(handle).also { it.start() }
|
||||
onDispose {
|
||||
@@ -99,8 +187,14 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
activity?.axisMapper?.reset() // release-all so nothing sticks on the host
|
||||
activity?.axisMapper = null
|
||||
activity?.streamHandle = 0L
|
||||
activity?.requestStreamExit = null
|
||||
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
|
||||
controller?.show(WindowInsetsCompat.Type.systemBars())
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
window?.setPreferMinimalPostProcessing(false)
|
||||
}
|
||||
wifiLocks.forEach { runCatching { if (it.isHeld) it.release() } }
|
||||
// Release the landscape lock so the rest of the app follows the device/system again.
|
||||
activity?.requestedOrientation =
|
||||
priorOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
@@ -112,7 +206,8 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler { onDisconnect() }
|
||||
// Back gesture = a deliberate exit → signal the quit so the host tears down now (no linger).
|
||||
BackHandler { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AndroidView(
|
||||
@@ -121,8 +216,22 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
SurfaceView(ctx).apply {
|
||||
holder.addCallback(object : SurfaceHolder.Callback {
|
||||
override fun surfaceCreated(holder: SurfaceHolder) {
|
||||
NativeBridge.nativeStartVideo(handle, holder.surface)
|
||||
NativeBridge.nativeStartAudio(handle)
|
||||
// Low-latency mode: rank MediaCodecList decoders for the negotiated
|
||||
// MIME (framework-only API) and hand the chosen one to Rust, which
|
||||
// creates it by name and applies the per-SoC vendor low-latency keys.
|
||||
// Off ⇒ no ranking: the platform resolves its default decoder for the
|
||||
// MIME, exactly as before the overhaul.
|
||||
val mime = NativeBridge.nativeVideoMime(handle)
|
||||
val choice = if (lowLatencyMode) VideoDecoders.pickDecoder(mime) else null
|
||||
NativeBridge.nativeStartVideo(
|
||||
handle,
|
||||
holder.surface,
|
||||
choice?.name ?: "",
|
||||
lowLatencyMode,
|
||||
choice?.lowLatencyFeature ?: false,
|
||||
isTv,
|
||||
)
|
||||
NativeBridge.nativeStartAudio(handle, lowLatencyMode)
|
||||
if (micWanted) NativeBridge.nativeStartMic(handle)
|
||||
}
|
||||
|
||||
@@ -145,8 +254,10 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
)
|
||||
// Live stats HUD (FPS / throughput / capture→client latency), drawn over the video but
|
||||
// BEFORE the transparent gesture layer below, so it shows through and never eats touches.
|
||||
if (showStats) {
|
||||
stats?.let { StatsOverlay(it, Modifier.align(Alignment.TopStart).padding(12.dp)) }
|
||||
if (statsOn) {
|
||||
stats?.let {
|
||||
StatsOverlay(it, statsVerbosity, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp))
|
||||
}
|
||||
}
|
||||
// Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
|
||||
// vocabulary) or real multi-touch passthrough — see TouchInput.kt.
|
||||
@@ -157,7 +268,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
else -> streamTouchInput(
|
||||
handle,
|
||||
trackpad = touchMode == TouchMode.TRACKPAD,
|
||||
onToggleStats = { showStats = !showStats },
|
||||
onCycleStats = { statsVerbosity = statsVerbosity.next() },
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -41,5 +41,7 @@ fun PunktfunkTheme(content: @Composable () -> Unit) {
|
||||
} else {
|
||||
BrandDark
|
||||
}
|
||||
MaterialTheme(colorScheme = scheme, content = content)
|
||||
// Geist Sans across the whole type scale — the brand typeface the website and the Apple client
|
||||
// already ship (see Type.kt).
|
||||
MaterialTheme(colorScheme = scheme, typography = PunktfunkTypography, content = content)
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ private const val ACCEL_MAX = 3.0f
|
||||
*
|
||||
* Both share the same gesture vocabulary: tap = left click; two-finger tap = right click;
|
||||
* two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving
|
||||
* windows); three-finger tap = [onToggleStats] (the stats HUD).
|
||||
* windows); three-finger tap = [onCycleStats] (cycle the stats-HUD verbosity tier).
|
||||
*/
|
||||
/**
|
||||
* Real multi-touch passthrough ([TouchMode.TOUCH]): every finger forwards as a host touchscreen
|
||||
@@ -93,7 +93,7 @@ internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long) {
|
||||
internal suspend fun PointerInputScope.streamTouchInput(
|
||||
handle: Long,
|
||||
trackpad: Boolean,
|
||||
onToggleStats: () -> Unit,
|
||||
onCycleStats: () -> Unit,
|
||||
) {
|
||||
var lastTapUp = 0L
|
||||
var lastTapX = 0f
|
||||
@@ -127,6 +127,7 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
||||
var moved = false
|
||||
var maxFingers = 1
|
||||
var scrolling = false
|
||||
var scrollCount = 0 // pointer count the scroll centroid is anchored at
|
||||
var prevCx = startX
|
||||
var prevCy = startY
|
||||
var upTime = down.uptimeMillis
|
||||
@@ -149,11 +150,18 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
||||
if (pressed.size > maxFingers) maxFingers = pressed.size
|
||||
|
||||
if (pressed.size >= 2) {
|
||||
// Two fingers → scroll by the centroid delta; never move the cursor.
|
||||
// Two+ fingers → scroll by the centroid delta; never move the cursor.
|
||||
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
|
||||
val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat()
|
||||
if (!scrolling) {
|
||||
// (Re-)anchor whenever the finger COUNT changes, not just on scroll start: the
|
||||
// centroid of three fingers sits far from the centroid of two, and real fingers
|
||||
// never land (or lift) in the same input frame — so the 2→3 transition would
|
||||
// otherwise read as a scroll notch, sending a phantom wheel tick to the host AND
|
||||
// setting `moved`, which disqualified the tap classification below and made the
|
||||
// 3-finger stats tap unreachable on real hardware.
|
||||
if (!scrolling || pressed.size != scrollCount) {
|
||||
scrolling = true
|
||||
scrollCount = pressed.size
|
||||
prevCx = cx
|
||||
prevCy = cy
|
||||
}
|
||||
@@ -218,7 +226,7 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
||||
NativeBridge.nativeSendPointerButton(handle, 1, false) // end the drag
|
||||
} else if (!moved) {
|
||||
when {
|
||||
maxFingers >= 3 -> onToggleStats() // in-stream HUD toggle
|
||||
maxFingers >= 3 -> onCycleStats() // in-stream HUD verbosity cycle
|
||||
maxFingers == 2 -> { // two-finger tap → right click
|
||||
NativeBridge.nativeSendPointerButton(handle, 3, true)
|
||||
NativeBridge.nativeSendPointerButton(handle, 3, false)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
|
||||
// Geist — the punktfunk brand typeface (the same family the website and the Apple client ship).
|
||||
// Bundled as static OTF weights in res/font and applied to every Material 3 text style below, so the
|
||||
// Android UI carries the brand type identically to the other clients. Geist Sans only — Geist Mono
|
||||
// is intentionally not shipped (the licenses screen's technical block uses the platform monospace).
|
||||
//
|
||||
// Licensed under the SIL Open Font License 1.1 (see the Geist OFL entry in THIRD-PARTY-NOTICES.txt).
|
||||
val Geist = FontFamily(
|
||||
Font(R.font.geist_regular, FontWeight.Normal),
|
||||
Font(R.font.geist_medium, FontWeight.Medium),
|
||||
Font(R.font.geist_semibold, FontWeight.SemiBold),
|
||||
Font(R.font.geist_bold, FontWeight.Bold),
|
||||
)
|
||||
|
||||
/**
|
||||
* The default Material 3 type scale re-based on [Geist]. Material 3's [Typography] has no
|
||||
* `defaultFontFamily` shortcut (that was Material 2), so each of the 15 roles is re-emitted with the
|
||||
* Geist family while keeping Material's sizes, line heights, letter spacing and per-role weights.
|
||||
*/
|
||||
val PunktfunkTypography: Typography = Typography().run {
|
||||
Typography(
|
||||
displayLarge = displayLarge.copy(fontFamily = Geist),
|
||||
displayMedium = displayMedium.copy(fontFamily = Geist),
|
||||
displaySmall = displaySmall.copy(fontFamily = Geist),
|
||||
headlineLarge = headlineLarge.copy(fontFamily = Geist),
|
||||
headlineMedium = headlineMedium.copy(fontFamily = Geist),
|
||||
headlineSmall = headlineSmall.copy(fontFamily = Geist),
|
||||
titleLarge = titleLarge.copy(fontFamily = Geist),
|
||||
titleMedium = titleMedium.copy(fontFamily = Geist),
|
||||
titleSmall = titleSmall.copy(fontFamily = Geist),
|
||||
bodyLarge = bodyLarge.copy(fontFamily = Geist),
|
||||
bodyMedium = bodyMedium.copy(fontFamily = Geist),
|
||||
bodySmall = bodySmall.copy(fontFamily = Geist),
|
||||
labelLarge = labelLarge.copy(fontFamily = Geist),
|
||||
labelMedium = labelMedium.copy(fontFamily = Geist),
|
||||
labelSmall = labelSmall.copy(fontFamily = Geist),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Wake a sleeping host and WAIT for it to come back before proceeding — the Android mirror of the
|
||||
* Apple client's `HostWaker`.
|
||||
*
|
||||
* A magic packet is fire-and-forget, and a cold box can take 20–60 s to POST, boot, and start
|
||||
* advertising on mDNS again — far longer than a connect attempt will sit. So instead of firing one
|
||||
* packet and immediately dialing (which just fails on a genuinely-asleep host), this drives a visible
|
||||
* "Waking…" state: it (re-)sends the packet, polls the host's mDNS presence once a second via
|
||||
* [isOnline], and on success runs [onOnline] (the real connect for a Wake-&-Connect, or nothing for
|
||||
* a wake-only); on timeout it parks in a retry/cancel state. One wake at a time.
|
||||
*
|
||||
* [scope] is the composition's coroutine scope (main-dispatched), so [waking] mutations and the
|
||||
* [isOnline]/[onOnline] callbacks all run on the main thread; only the blocking send is off-loaded.
|
||||
*/
|
||||
class WakeController(private val scope: CoroutineScope) {
|
||||
/** null = idle; non-null drives [WakeOverlay]. */
|
||||
data class Waking(
|
||||
val hostName: String,
|
||||
/** Whether coming online chains into a connect (Wake & Connect) vs. just stopping. */
|
||||
val connectsAfter: Boolean,
|
||||
val seconds: Int = 0,
|
||||
val timedOut: Boolean = false,
|
||||
)
|
||||
|
||||
var waking by mutableStateOf<Waking?>(null)
|
||||
private set
|
||||
|
||||
private var loop: Job? = null
|
||||
|
||||
/** Captured so "Try Again" replays the exact same wait. */
|
||||
private var replay: (() -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Wake the host and wait for [isOnline] to go true, then run [onOnline]. [macs]/[lastIp] target
|
||||
* the magic packet. No-ops straight to [onOnline] when there's nothing to wake with or the host
|
||||
* is already up (a race between the caller's check and here).
|
||||
*/
|
||||
fun start(
|
||||
hostName: String,
|
||||
connectsAfter: Boolean,
|
||||
macs: List<String>,
|
||||
lastIp: String,
|
||||
isOnline: () -> Boolean,
|
||||
onOnline: () -> Unit,
|
||||
) {
|
||||
if (macs.isEmpty() || isOnline()) {
|
||||
cancel()
|
||||
onOnline()
|
||||
return
|
||||
}
|
||||
replay = { run(hostName, connectsAfter, macs, lastIp, isOnline, onOnline) }
|
||||
replay?.invoke()
|
||||
}
|
||||
|
||||
/** Stop waiting and dismiss the overlay (B / Cancel). */
|
||||
fun cancel() {
|
||||
loop?.cancel()
|
||||
loop = null
|
||||
replay = null
|
||||
waking = null
|
||||
}
|
||||
|
||||
/** Restart the wait after a timeout (A / Try Again). */
|
||||
fun retry() {
|
||||
replay?.invoke()
|
||||
}
|
||||
|
||||
private fun run(
|
||||
hostName: String,
|
||||
connectsAfter: Boolean,
|
||||
macs: List<String>,
|
||||
lastIp: String,
|
||||
isOnline: () -> Boolean,
|
||||
onOnline: () -> Unit,
|
||||
) {
|
||||
loop?.cancel()
|
||||
waking = Waking(hostName = hostName, connectsAfter = connectsAfter)
|
||||
loop = scope.launch {
|
||||
var elapsed = 0
|
||||
while (isActive) {
|
||||
// Re-send periodically: a single packet can be missed, and some NICs only wake on a
|
||||
// fresh packet after dropping into a deeper sleep state.
|
||||
if (elapsed % RESEND_EVERY_S == 0) {
|
||||
val csv = macs.joinToString(",")
|
||||
launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(csv, lastIp) }
|
||||
}
|
||||
if (isOnline()) {
|
||||
waking = null
|
||||
loop = null
|
||||
onOnline()
|
||||
return@launch
|
||||
}
|
||||
if (elapsed >= TIMEOUT_S) {
|
||||
waking = waking?.copy(timedOut = true)
|
||||
loop = null
|
||||
return@launch
|
||||
}
|
||||
delay(1000)
|
||||
elapsed++
|
||||
waking = waking?.copy(seconds = elapsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** How long to wait for the host to reappear before giving up (a cold boot can be a minute+). */
|
||||
const val TIMEOUT_S = 90
|
||||
|
||||
/** Re-send the magic packet this often. */
|
||||
const val RESEND_EVERY_S = 6
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bedtime
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* The "Waking <host>…" modal shown while [WakeController] brings a sleeping host back — a spinner + a
|
||||
* live elapsed counter, escalating to a retry/cancel prompt on timeout. The Android mirror of the
|
||||
* Apple client's `WakeOverlay`. Rendered over BOTH the touch grid and the console home; it swallows
|
||||
* input to the screen behind it, and in console mode the pad drives it (B cancels, A retries once
|
||||
* timed out) while the touch buttons work for a pointer.
|
||||
*/
|
||||
@Composable
|
||||
fun WakeOverlay(waker: WakeController, gamepadUi: Boolean) {
|
||||
val w = waker.waking ?: return
|
||||
|
||||
BackHandler { waker.cancel() } // system Back / pad B (remapped) cancels the wait
|
||||
if (gamepadUi) {
|
||||
// A retries once timed out; B falls through to the BackHandler above.
|
||||
GamepadNavEffect2D(
|
||||
active = true,
|
||||
onDirection = {},
|
||||
onActivate = { if (w.timedOut) waker.retry() },
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.6f))
|
||||
// Swallow taps so the home behind can't be touched while waking.
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.padding(40.dp)
|
||||
.widthIn(max = 380.dp)
|
||||
.clip(RoundedCornerShape(22.dp))
|
||||
.background(Color(0xF01A1730))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(22.dp))
|
||||
.padding(28.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
if (w.timedOut) {
|
||||
Icon(
|
||||
Icons.Filled.Bedtime,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.85f),
|
||||
modifier = Modifier.size(34.dp),
|
||||
)
|
||||
Text(
|
||||
"${w.hostName} didn't wake",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 19.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
"It may still be booting, or it's powered off / off this network.",
|
||||
color = Color.White.copy(alpha = 0.6f),
|
||||
fontSize = 13.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
) {
|
||||
OutlinedButton(onClick = { waker.cancel() }) { Text("Cancel") }
|
||||
Button(onClick = { waker.retry() }) { Text("Try Again") }
|
||||
}
|
||||
} else {
|
||||
CircularProgressIndicator(color = Color.White)
|
||||
Text(
|
||||
"Waking ${w.hostName}…",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 19.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
"Waiting for it to come online · ${w.seconds}s",
|
||||
color = Color.White.copy(alpha = 0.6f),
|
||||
fontSize = 13.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
OutlinedButton(onClick = { waker.cancel() }, modifier = Modifier.padding(top = 6.dp)) {
|
||||
Text(if (w.connectsAfter) "Cancel" else "Stop Waiting")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package io.unom.punktfunk.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -56,10 +57,12 @@ fun HostCard(
|
||||
name: String,
|
||||
address: String,
|
||||
status: HostStatus,
|
||||
online: Boolean = false,
|
||||
enabled: Boolean,
|
||||
onConnect: () -> Unit,
|
||||
onForget: (() -> Unit)?,
|
||||
onRename: (() -> Unit)? = null,
|
||||
onEdit: (() -> Unit)? = null,
|
||||
onWake: (() -> Unit)? = null,
|
||||
) {
|
||||
// D-pad / controller focus highlight: a clickable card is focusable, but the default state
|
||||
// layer is too subtle on a TV across a room — draw a clear primary-colour border when focused.
|
||||
@@ -104,10 +107,16 @@ fun HostCard(
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
PresencePill(online)
|
||||
StatusPill(status)
|
||||
}
|
||||
}
|
||||
|
||||
if (onForget != null || onRename != null) {
|
||||
if (onForget != null || onEdit != null || onWake != null) {
|
||||
var menu by remember { mutableStateOf(false) }
|
||||
Box(modifier = Modifier.align(Alignment.TopEnd)) {
|
||||
IconButton(enabled = enabled, onClick = { menu = true }) {
|
||||
@@ -119,12 +128,21 @@ fun HostCard(
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
||||
if (onRename != null) {
|
||||
if (onWake != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Rename") },
|
||||
text = { Text("Wake host") },
|
||||
onClick = {
|
||||
menu = false
|
||||
onRename()
|
||||
onWake()
|
||||
},
|
||||
)
|
||||
}
|
||||
if (onEdit != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Edit…") },
|
||||
onClick = {
|
||||
menu = false
|
||||
onEdit()
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -163,6 +181,27 @@ fun HostAvatar(name: String) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A small dot + label for live presence: green Online when the host advertises on mDNS OR answers
|
||||
* the reachability probe (so a routed/VPN host that never advertises still reads Online), dimmed
|
||||
* Offline otherwise.
|
||||
*/
|
||||
@Composable
|
||||
fun PresencePill(online: Boolean) {
|
||||
val color =
|
||||
if (online) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.size(8.dp).clip(CircleShape).background(color))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
if (online) "Online" else "Offline",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = color,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A small colored dot + label for the host's trust state. */
|
||||
@Composable
|
||||
fun StatusPill(status: HostStatus) {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.6 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Game Mode config (Android 13 / API 33+). We support the Performance game mode; and we opt OUT of
|
||||
the two OEM interventions that would corrupt a game-streaming session:
|
||||
- allowGameDownscaling=false: the client renders exactly the host's negotiated resolution; a
|
||||
platform downscale would blur the stream.
|
||||
- allowGameFpsOverride=false: the stream is paced to the host's frame rate; a platform FPS cap
|
||||
would drop frames / add judder.
|
||||
Ignored on releases below API 33.
|
||||
-->
|
||||
<game-mode-config xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:supportsPerformanceGameMode="true"
|
||||
android:allowGameDownscaling="false"
|
||||
android:allowGameFpsOverride="false" />
|
||||
@@ -58,7 +58,15 @@ class ScreenshotTest {
|
||||
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") // landscape — the stream is immersive
|
||||
fun stream() = shootRoot("stream") { StreamScene() }
|
||||
fun stream() = shootRoot("stream") { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) }
|
||||
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun streamCompact() = shootRoot("stream-compact") { StreamScene(io.unom.punktfunk.StatsVerbosity.COMPACT) }
|
||||
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun streamNormal() = shootRoot("stream-normal") { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) }
|
||||
|
||||
@Test
|
||||
fun trust() = shootScreen("trust") {
|
||||
|
||||
@@ -30,6 +30,7 @@ import io.unom.punktfunk.Settings
|
||||
import io.unom.punktfunk.TouchMode
|
||||
import io.unom.punktfunk.SettingsScreen
|
||||
import io.unom.punktfunk.StatsOverlay
|
||||
import io.unom.punktfunk.StatsVerbosity
|
||||
import io.unom.punktfunk.components.HostCard
|
||||
import io.unom.punktfunk.components.SectionLabel
|
||||
import io.unom.punktfunk.models.HostStatus
|
||||
@@ -83,7 +84,7 @@ internal fun HostsScene() {
|
||||
}
|
||||
item(span = { GridItemSpan(maxLineSpan) }) { SectionLabel("Saved hosts") }
|
||||
items(SAVED) { h ->
|
||||
HostCard(h.name, h.address, h.status, enabled = true, onConnect = {}, onForget = {}, onRename = {})
|
||||
HostCard(h.name, h.address, h.status, enabled = true, onConnect = {}, onForget = {}, onEdit = {})
|
||||
}
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
@@ -109,7 +110,7 @@ internal fun SettingsScene() {
|
||||
compositor = 1,
|
||||
gamepad = 2,
|
||||
micEnabled = true,
|
||||
statsHudEnabled = true,
|
||||
statsVerbosity = StatsVerbosity.DETAILED,
|
||||
touchMode = TouchMode.TRACKPAD,
|
||||
),
|
||||
onChange = {},
|
||||
@@ -177,9 +178,12 @@ internal fun PairDialog() {
|
||||
)
|
||||
}
|
||||
|
||||
/** The live stats HUD (the real StatsOverlay) over a synthetic "streamed frame" gradient. */
|
||||
/**
|
||||
* The live stats HUD (the real StatsOverlay) over a synthetic "streamed frame" gradient, at the
|
||||
* given [verbosity] tier — one scene per tier documents how far each tones the overlay down.
|
||||
*/
|
||||
@Composable
|
||||
internal fun StreamScene() {
|
||||
internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
@@ -187,12 +191,27 @@ internal fun StreamScene() {
|
||||
Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))),
|
||||
),
|
||||
) {
|
||||
// [fps, mbps, latP50, latP95, latValid, skew, w, h, hz, dropped,
|
||||
// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc] — the last four = a 10-bit
|
||||
// BT.2020 PQ (HDR) 4:2:0 feed, so the HUD renders its video-feed line.
|
||||
// The full 26-double unified layout (design/stats-unification.md): [fps, mbps, e2eP50,
|
||||
// e2eP95, latValid, skew, w, h, hz, lostTotal, bitDepth, colorPrimaries, colorTransfer,
|
||||
// chromaFormatIdc, hostNetP50, decodeP50, hostP50, netP50, lost, skipped, fec, frames,
|
||||
// dispValid, displayP50, e2eDispP50, e2eDispP95].
|
||||
// 10/9/16/1 = a 10-bit BT.2020 PQ (HDR) 4:2:0 feed so the DETAILED HUD renders its
|
||||
// video-feed line; the display stage is valid (dispValid 1) so the headline is the
|
||||
// directly-measured capture→displayed pair (1.8/2.6) and the Phase-2 stage terms
|
||||
// (host 0.6 + network 0.3 + decode 0.4 + display 0.5) tile it, rendering the full split
|
||||
// equation; the decoder label shows the ranked low-latency decoder. Light per-window loss
|
||||
// (lost 2 · skipped 1 · FEC 5 of 238) so the reliability line (NORMAL/DETAILED) and the
|
||||
// compact loss flag both render.
|
||||
StatsOverlay(
|
||||
doubleArrayOf(238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 0.0, 10.0, 9.0, 16.0, 1.0),
|
||||
Modifier.align(Alignment.TopStart).padding(12.dp),
|
||||
doubleArrayOf(
|
||||
238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 2.0,
|
||||
10.0, 9.0, 16.0, 1.0, 0.9, 0.4, 0.6, 0.3,
|
||||
2.0, 1.0, 5.0, 238.0,
|
||||
1.0, 0.5, 1.8, 2.6,
|
||||
),
|
||||
verbosity = verbosity,
|
||||
decoderLabel = "c2.qti.hevc.decoder · low-latency",
|
||||
modifier = Modifier.align(Alignment.TopStart).padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// here (version + apply false) so modules can apply it version-less; its version pins the build's
|
||||
// Kotlin (compose-compiler and Kotlin release in lockstep), keeping them matched.
|
||||
// Toolchain: AGP 9.2.0 · Gradle 9.4.1 · Kotlin/Compose-compiler 2.3.21 · JDK 21 · Compose BOM
|
||||
// 2026.05.01 · compileSdk 37 · targetSdk 36 · minSdk 31.
|
||||
// 2026.05.01 · compileSdk 37 · targetSdk 37 · minSdk 28.
|
||||
plugins {
|
||||
id("com.android.application") version "9.2.1" apply false
|
||||
id("com.android.library") version "9.2.1" apply false
|
||||
|
||||
@@ -37,13 +37,30 @@ def call(method, url, token=None, data=None, content_type=None, want_json=True):
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
if content_type:
|
||||
headers["Content-Type"] = content_type
|
||||
# Transient-fault retries: googleapis.com occasionally drops the TLS session ("EOF
|
||||
# occurred in violation of protocol" — failed two release uploads on 2026-07-02) or
|
||||
# answers 5xx. Retry those with backoff; 4xx raises immediately (a real API error).
|
||||
# The edits API is transactional until commit, so re-sending any of these is safe.
|
||||
last = None
|
||||
for attempt in range(4):
|
||||
if attempt:
|
||||
delay = 3**attempt
|
||||
print(f"transient Play API failure ({last}); retry {attempt}/3 in {delay}s")
|
||||
time.sleep(delay)
|
||||
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=300) as r:
|
||||
body = r.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
raise ApiError(e.code, method, url, e.read().decode("utf-8", "replace"))
|
||||
return json.loads(body) if (want_json and body) else body
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code >= 500:
|
||||
last = f"HTTP {e.code}"
|
||||
continue
|
||||
raise ApiError(e.code, method, url, e.read().decode("utf-8", "replace"))
|
||||
except urllib.error.URLError as e:
|
||||
last = str(getattr(e, "reason", e))
|
||||
continue
|
||||
sys.exit(f"ERROR: {method} {url} still failing after retries: {last}")
|
||||
|
||||
|
||||
def load_sa():
|
||||
|
||||
@@ -15,8 +15,10 @@ android {
|
||||
ndkVersion = ndkVer
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 31
|
||||
ndk { abiFilters += listOf("arm64-v8a", "x86_64") }
|
||||
minSdk = 28 // Android 9 — reaches older TV boxes; API 31+ features are runtime-gated.
|
||||
// Keep in lockstep with :app — 32-bit armeabi-v7a for the many 32-bit Google TV / Android TV
|
||||
// boxes, 64-bit arm64-v8a for phones + modern TV, x86_64 for the emulator.
|
||||
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") }
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
@@ -28,6 +30,9 @@ android {
|
||||
kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } }
|
||||
|
||||
dependencies {
|
||||
// mTLS HTTPS client for the host's management API (the game-library fetch + cover-art loads).
|
||||
// OkHttp lets us present the paired client cert and pin the host's self-signed cert by SHA-256.
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
testImplementation("junit:junit:4.13.2") // JVM unit test for the pure TXT parser
|
||||
}
|
||||
|
||||
@@ -85,9 +90,14 @@ fun registerCargoNdk(taskName: String, release: Boolean) =
|
||||
// find their subtools.
|
||||
val cmd = mutableListOf(
|
||||
"$cargoBin/cargo", "ndk",
|
||||
"-t", "arm64-v8a", "-t", "x86_64",
|
||||
// Link against the minSdk-31 sysroot so libaaudio (API 26+) is found.
|
||||
"--platform", "31",
|
||||
"-t", "arm64-v8a", "-t", "armeabi-v7a", "-t", "x86_64",
|
||||
// Link against the minSdk-28 sysroot (libaaudio, API 26, is present). NOTE: this does
|
||||
// NOT reject an accidental >28 hard import — a cdylib link permits undefined symbols,
|
||||
// which then fail at System.loadLibrary on every device below the symbol's API level
|
||||
// (the 0.9.0 Android-≤12 regression). The checkJniImports* task after this build is
|
||||
// what actually enforces the floor; >28 entry points must be dlsym-resolved (see
|
||||
// decode::try_set_frame_rate, decode::install_render_callback, adpf).
|
||||
"--platform", "28",
|
||||
"-o", file("src/main/jniLibs").absolutePath,
|
||||
"build", "-p", "punktfunk-client-android",
|
||||
)
|
||||
@@ -95,16 +105,47 @@ fun registerCargoNdk(taskName: String, release: Boolean) =
|
||||
commandLine(cmd)
|
||||
}
|
||||
|
||||
// Post-link floor check: every undefined symbol in the built .so must exist in the API-28 stubs,
|
||||
// else System.loadLibrary fails on devices at the minSdk floor (see the script header for the
|
||||
// 0.9.0 incident this guards against). Runs right after its cargo-ndk task; the APK build depends
|
||||
// on this task (not the cargo one directly), so a violation fails the build, local and CI alike.
|
||||
fun registerCheckJniImports(taskName: String, cargoTask: TaskProvider<Exec>) =
|
||||
tasks.register<Exec>(taskName) {
|
||||
group = "rust"
|
||||
description = "verify libpunktfunk_android.so imports stay within the API-28 floor"
|
||||
dependsOn(cargoTask)
|
||||
workingDir = repoRoot
|
||||
commandLine(
|
||||
"sh", File(repoRoot, "scripts/ci/check-android-jni-imports.sh").absolutePath,
|
||||
"${androidSdkDir()}/ndk/$ndkVer",
|
||||
file("src/main/jniLibs").absolutePath,
|
||||
"28",
|
||||
)
|
||||
}
|
||||
|
||||
val cargoNdkDebug = registerCargoNdk("cargoNdkDebug", release = false)
|
||||
val cargoNdkRelease = registerCargoNdk("cargoNdkRelease", release = true)
|
||||
val checkJniImportsDebug = registerCheckJniImports("checkJniImportsDebug", cargoNdkDebug)
|
||||
val checkJniImportsRelease = registerCheckJniImports("checkJniImportsRelease", cargoNdkRelease)
|
||||
|
||||
afterEvaluate {
|
||||
// `-PskipRustBuild` skips the cargo-ndk native build — for JVM-only tasks (the Roborazzi
|
||||
// screenshot unit tests render Compose on the JVM and never load libpunktfunk_android.so), so
|
||||
// CI/local screenshot runs don't need the Rust toolchain or NDK. The native build stays wired
|
||||
// for every normal APK/AAR build.
|
||||
//
|
||||
// DEBUG APKs SHIP RELEASE RUST. Cargo's debug profile is not "a bit slower" for this library —
|
||||
// it is unusable: the AES-GCM data-plane decrypt runs through generic-array iterator closures
|
||||
// with per-byte UB checks instead of ARMv8 hardware AES. Profiled live on a phone (simpleperf):
|
||||
// ~800 µs of user CPU per 1.4 KB packet, the receive pump pinned over a full core yet unable to
|
||||
// drain a 20 Mbps stream — every debug-APK on-device test was silently benchmarking unoptimized
|
||||
// crypto, not the streaming pipeline. Kotlin debuggability is untouched (the APK is still a
|
||||
// debug build); only the cargo profile changes. `-PrustDebug` restores a debug-profile native
|
||||
// build for the rare session that actually steps through Rust.
|
||||
if (!project.hasProperty("skipRustBuild")) {
|
||||
tasks.named("preDebugBuild").configure { dependsOn(cargoNdkDebug) }
|
||||
tasks.named("preReleaseBuild").configure { dependsOn(cargoNdkRelease) }
|
||||
val debugRust =
|
||||
if (project.hasProperty("rustDebug")) checkJniImportsDebug else checkJniImportsRelease
|
||||
tasks.named("preDebugBuild").configure { dependsOn(debugRust) }
|
||||
tasks.named("preReleaseBuild").configure { dependsOn(checkJniImportsRelease) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ object Gamepad {
|
||||
private const val VID_SONY = 0x054C
|
||||
private const val VID_MICROSOFT = 0x045E
|
||||
private const val VID_VALVE = 0x28DE
|
||||
private const val VID_NINTENDO = 0x057E
|
||||
|
||||
// Sony product ids. DualSense (PS5) and DualShock 4 (PS4) map to distinct host pad types.
|
||||
private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2)
|
||||
@@ -98,6 +99,28 @@ object Gamepad {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The glyph family a controller's physical buttons belong to, for the console UI's hint bar —
|
||||
* so a DualSense user sees ✕/○/□/△ shapes and a Switch pad its monochrome lettering instead of
|
||||
* Xbox's coloured letters. PURELY visual: the wire mapping ([buttonBit]) is unaffected.
|
||||
*/
|
||||
enum class PadStyle { GENERIC, XBOX, PLAYSTATION, NINTENDO }
|
||||
|
||||
/**
|
||||
* Resolve the [PadStyle] for a connected controller by USB vendor id. Vendor alone is enough —
|
||||
* every pad a vendor ships wears its family's glyphs (any Sony pad has the shapes, any Nintendo
|
||||
* pad the −/+ system buttons), so unlike [prefFor] no PID table is needed. Valve renders as
|
||||
* [PadStyle.XBOX]: Steam pads carry A/B/X/Y in Xbox positions. Unknown vendors (8BitDo & co.,
|
||||
* which near-universally clone the Xbox layout) fall back to [PadStyle.GENERIC], drawn with the
|
||||
* Xbox convention.
|
||||
*/
|
||||
fun styleFor(dev: InputDevice?): PadStyle = when (dev?.vendorId) {
|
||||
VID_SONY -> PadStyle.PLAYSTATION
|
||||
VID_MICROSOFT, VID_VALVE -> PadStyle.XBOX
|
||||
VID_NINTENDO -> PadStyle.NINTENDO
|
||||
else -> PadStyle.GENERIC
|
||||
}
|
||||
|
||||
/** True when [dev]'s source classes include gamepad or joystick. */
|
||||
fun isPad(dev: InputDevice?): Boolean {
|
||||
val s = dev?.sources ?: return false
|
||||
@@ -152,6 +175,12 @@ object Gamepad {
|
||||
* Holds the previous axis/hat state so an unchanged frame emits nothing. One instance per
|
||||
* session; call [reset] on release-all (focus loss / disconnect / session stop) so nothing
|
||||
* sticks on the host (which has no client-side held-state knowledge).
|
||||
*
|
||||
* Single-source: only ONE qualifying controller feeds pad 0. Events must come from a device
|
||||
* whose source classes include GAMEPAD (see [onMotion]) and the mapper pins itself to the
|
||||
* first such device — a controller's joystick-classified sibling nodes (DualSense/DS4 motion
|
||||
* sensors) and any second pad report every axis as 0, and folding them into the same state
|
||||
* flapped a held trigger/stick between its value and 0 on every event interleave.
|
||||
*/
|
||||
class AxisMapper(private val handle: Long) {
|
||||
// Sentinel so the first real value (incl. 0) always sends once after attach (Linux parity).
|
||||
@@ -159,10 +188,29 @@ object Gamepad {
|
||||
private var hatX = 0 // -1 / 0 / +1
|
||||
private var hatY = 0
|
||||
|
||||
/** deviceId of the controller pad 0 is pinned to; −1 until the first qualifying event. */
|
||||
private var deviceId = -1
|
||||
|
||||
/** Returns true if this was a joystick ACTION_MOVE we consumed. */
|
||||
fun onMotion(event: MotionEvent): Boolean {
|
||||
if (!event.isFromSource(InputDevice.SOURCE_JOYSTICK)) return false
|
||||
if (event.actionMasked != MotionEvent.ACTION_MOVE) return false
|
||||
// Only a true gamepad drives pad 0. A joystick ACTION_MOVE's own source is plain
|
||||
// JOYSTICK for every sender, so qualify by the DEVICE's source classes: a real pad
|
||||
// carries the GAMEPAD (button) class too, its sensor/touchpad sibling nodes and
|
||||
// joystick-class remotes don't — and those report every pad axis as 0 (see the
|
||||
// class doc for the held-trigger flap this caused).
|
||||
val dev = event.device ?: return false
|
||||
if (dev.sources and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD) return false
|
||||
// Single-pad model: pin to the first qualifying controller so a second pad (or its
|
||||
// stick drift) can't fight pad 0; re-adopt only once the pinned device is gone.
|
||||
if (deviceId != event.deviceId) {
|
||||
if (deviceId != -1) {
|
||||
if (InputDevice.getDevice(deviceId) != null) return false
|
||||
reset() // the pinned pad is gone — lift its held state before adopting
|
||||
}
|
||||
deviceId = event.deviceId
|
||||
}
|
||||
|
||||
// Sticks: Android floats −1..1, +y = down → ±32767, negate Y for the wire's +y = up.
|
||||
sendAxis(AXIS_LS_X, stick(event.getAxisValue(MotionEvent.AXIS_X)))
|
||||
@@ -170,9 +218,27 @@ object Gamepad {
|
||||
sendAxis(AXIS_RS_X, stick(event.getAxisValue(MotionEvent.AXIS_Z)))
|
||||
sendAxis(AXIS_RS_Y, stick(-event.getAxisValue(MotionEvent.AXIS_RZ)))
|
||||
|
||||
// Triggers: LTRIGGER/RTRIGGER if present, else BRAKE/GAS; 0..1 float → 0..255.
|
||||
sendAxis(AXIS_LT, trigger(firstNonZero(event, MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_BRAKE)))
|
||||
sendAxis(AXIS_RT, trigger(firstNonZero(event, MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_GAS)))
|
||||
// Triggers: pads report LTRIGGER/RTRIGGER or BRAKE/GAS (some mirror both) — merge
|
||||
// with max, the same fold as the Controllers screen probe, so a pad that reports
|
||||
// only one pair and a pad that reports both behave identically; 0..1 → 0..255.
|
||||
sendAxis(
|
||||
AXIS_LT,
|
||||
trigger(
|
||||
maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_LTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_BRAKE),
|
||||
),
|
||||
),
|
||||
)
|
||||
sendAxis(
|
||||
AXIS_RT,
|
||||
trigger(
|
||||
maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_RTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_GAS),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// HAT → dpad button transitions (track previous, emit only the deltas).
|
||||
val hx = sign(event.getAxisValue(MotionEvent.AXIS_HAT_X))
|
||||
@@ -214,10 +280,5 @@ object Gamepad {
|
||||
private fun trigger(v: Float): Int = (v.coerceIn(0f, 1f) * 255f).toInt()
|
||||
|
||||
private fun sign(v: Float): Int = if (v < -0.5f) -1 else if (v > 0.5f) 1 else 0
|
||||
|
||||
private fun firstNonZero(e: MotionEvent, a: Int, b: Int): Float {
|
||||
val va = e.getAxisValue(a)
|
||||
return if (va != 0f) va else e.getAxisValue(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.hardware.lights.LightsRequest
|
||||
import android.os.Build
|
||||
import android.os.CombinedVibration
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import android.util.Log
|
||||
import android.view.InputDevice
|
||||
@@ -16,7 +17,8 @@ import java.nio.ByteBuffer
|
||||
/**
|
||||
* Host→client gamepad feedback for one session (single-pad model — pad 0 only). Two daemon poll
|
||||
* threads drain the blocking native pulls and render in Kotlin: rumble → the controller's
|
||||
* `VibratorManager`; HID-output → lightbar / player-LED via `LightsManager` (API 33+); adaptive
|
||||
* `VibratorManager` (API 31+) or its single legacy `Vibrator` on API 28–30; HID-output → lightbar /
|
||||
* player-LED via `LightsManager` (API 33+); adaptive
|
||||
* triggers are parse-validated and logged (Android has no public adaptive-trigger API).
|
||||
*
|
||||
* Mirrors `nativeStartAudio`'s lifecycle: [start]/[stop] driven by the StreamScreen. [stop] flips a
|
||||
@@ -40,6 +42,9 @@ class GamepadFeedback(private val handle: Long) {
|
||||
private var hidoutThread: Thread? = null
|
||||
|
||||
private var vm: VibratorManager? = null
|
||||
// API 28–30 fallback: the controller's single legacy Vibrator (no per-motor VibratorManager
|
||||
// until API 31). Exactly one of [vm] / [legacy] is bound; rumble degrades to one blended motor.
|
||||
private var legacy: Vibrator? = null
|
||||
private var vibratorIds: IntArray = IntArray(0)
|
||||
private var amplitudeControlled = false
|
||||
|
||||
@@ -81,6 +86,7 @@ class GamepadFeedback(private val handle: Long) {
|
||||
rumbleThread?.interrupt()
|
||||
hidoutThread?.interrupt()
|
||||
runCatching { vm?.cancel() } // drop any held rumble immediately
|
||||
runCatching { legacy?.cancel() }
|
||||
// Join WITHOUT a timeout. These poll threads dereference the native session handle on every
|
||||
// pull (nativeNextRumble/nativeNextHidout), so they MUST be dead before StreamScreen's
|
||||
// onDispose reaches nativeClose, which frees that handle. A *bounded* join that times out
|
||||
@@ -98,6 +104,7 @@ class GamepadFeedback(private val handle: Long) {
|
||||
rgbLight = null
|
||||
playerLight = null
|
||||
vm = null
|
||||
legacy = null
|
||||
vibratorIds = IntArray(0)
|
||||
}
|
||||
|
||||
@@ -111,6 +118,7 @@ class GamepadFeedback(private val handle: Long) {
|
||||
Log.i(TAG, "rumble: no controller connected — rumble no-op (emulator path)")
|
||||
return
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
val m = dev.vibratorManager
|
||||
val ids = m.vibratorIds
|
||||
if (ids.isEmpty()) {
|
||||
@@ -121,14 +129,27 @@ class GamepadFeedback(private val handle: Long) {
|
||||
vibratorIds = ids
|
||||
amplitudeControlled = ids.all { m.getVibrator(it).hasAmplitudeControl() }
|
||||
Log.i(TAG, "rumble: bound ${ids.size} vibrators amplitudeControl=$amplitudeControlled")
|
||||
} else {
|
||||
// API 28–30: no VibratorManager — fall back to the controller's single legacy Vibrator.
|
||||
@Suppress("DEPRECATION")
|
||||
val v = dev.vibrator
|
||||
if (!v.hasVibrator()) {
|
||||
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrator — rumble no-op")
|
||||
return
|
||||
}
|
||||
legacy = v
|
||||
amplitudeControlled = v.hasAmplitudeControl()
|
||||
Log.i(TAG, "rumble: bound legacy vibrator amplitudeControl=$amplitudeControlled")
|
||||
}
|
||||
}
|
||||
|
||||
/** low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes). */
|
||||
private fun renderRumble(low: Int, high: Int) {
|
||||
Log.i(TAG, "rumble low=$low high=$high") // verification line — BEFORE any no-op return
|
||||
val m = vm ?: return
|
||||
val lo = toAmplitude(low)
|
||||
val hi = toAmplitude(high)
|
||||
val m = vm
|
||||
if (m != null) {
|
||||
if (lo == 0 && hi == 0) {
|
||||
m.cancel() // (0,0) = stop
|
||||
return
|
||||
@@ -144,6 +165,18 @@ class GamepadFeedback(private val handle: Long) {
|
||||
for (id in vibratorIds) combo.addVibrator(id, oneShot(a))
|
||||
}
|
||||
runCatching { m.vibrate(combo.combine()) }
|
||||
return
|
||||
}
|
||||
// API 28–30 legacy single-motor path: blend both motors into one effect.
|
||||
val lv = legacy ?: return
|
||||
if (lo == 0 && hi == 0) {
|
||||
lv.cancel() // (0,0) = stop
|
||||
return
|
||||
}
|
||||
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
||||
runCatching {
|
||||
lv.vibrate(if (amplitudeControlled) oneShot(a) else oneShot(VibrationEffect.DEFAULT_AMPLITUDE))
|
||||
}
|
||||
}
|
||||
|
||||
// 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0.
|
||||
|
||||
@@ -48,14 +48,29 @@ object NativeBridge {
|
||||
gamepadPref: Int,
|
||||
hdrEnabled: 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. */
|
||||
videoCodecs: Int,
|
||||
/** Preferred video codec as a `quic::CODEC_*` bit (`0` = auto). Soft — the host falls back. */
|
||||
preferredCodec: Int,
|
||||
timeoutMs: Int,
|
||||
/** Store-qualified library id (`steam:<appid>` / `custom:<id>`) to boot straight into a game,
|
||||
* or `null`/empty for a plain desktop connect. Rides the Hello as `launch`. */
|
||||
launch: String?,
|
||||
): Long
|
||||
|
||||
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
|
||||
external fun nativeHostFingerprint(handle: Long): String
|
||||
|
||||
/**
|
||||
* Has the underlying QUIC session ended? `true` once the connection closed — a host suspend /
|
||||
* crash / network drop idle-timed it out (~8 s), or the host closed it — from then on no frame
|
||||
* ever arrives and the video sits frozen on its last one. The stream watchdog polls this (~1 Hz)
|
||||
* to leave a dead stream and return to the menu, where the user can Wake-on-LAN the host, instead
|
||||
* of stranding them on a frozen frame. `false` on a `0` handle. Cheap (one atomic load); UI-safe.
|
||||
*/
|
||||
external fun nativeSessionEnded(handle: Long): Boolean
|
||||
|
||||
/**
|
||||
* Run the SPAKE2 PIN ceremony, presenting [certPem]/[keyPem]. Returns the host's verified
|
||||
* fingerprint (64-hex) to persist + pin, or `""` on failure (wrong PIN / MITM / unreachable).
|
||||
@@ -70,6 +85,14 @@ object NativeBridge {
|
||||
name: String,
|
||||
): String
|
||||
|
||||
/**
|
||||
* Signal a **deliberate** user disconnect on [handle] before [nativeClose]: the session closes
|
||||
* with `QUIT_CLOSE_CODE` so the host tears it down immediately instead of holding the keep-alive
|
||||
* linger for a reconnect. Call from an explicit disconnect gesture only — NOT from a
|
||||
* host-ended/network-drop end or an app-background (those keep the linger). No-op on `0`.
|
||||
*/
|
||||
external fun nativeDisconnectQuit(handle: Long)
|
||||
|
||||
/** Tear down a session handle returned by [nativeConnect]. No-op on `0`. */
|
||||
external fun nativeClose(handle: Long)
|
||||
|
||||
@@ -86,7 +109,7 @@ object NativeBridge {
|
||||
|
||||
/**
|
||||
* The current resolved-host snapshot for [handle]: newline-joined records, each
|
||||
* `key␟name␟addr␟port␟fp␟pair` (`␟` = U+001F). Empty string = no hosts / `0` handle. Poll ~1 Hz;
|
||||
* `key␟name␟addr␟port␟fp␟pair␟mac` (`␟` = U+001F). Empty string = no hosts / `0` handle. Poll ~1 Hz;
|
||||
* cheap (a lock + string build), safe to call on the main thread.
|
||||
*/
|
||||
external fun nativeDiscoveryPoll(handle: Long): String
|
||||
@@ -95,22 +118,88 @@ object NativeBridge {
|
||||
external fun nativeDiscoveryStop(handle: Long)
|
||||
|
||||
/**
|
||||
* Start the HEVC decode thread rendering onto [surface] (a SurfaceView's surface). Decode runs
|
||||
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. No-op if already started.
|
||||
* Send a Wake-on-LAN magic packet to wake a sleeping host. [macsCsv] is comma-separated MAC
|
||||
* addresses (`aa:bb:..,cc:dd:..`), learned from the host's mDNS `mac` TXT while it was online;
|
||||
* [lastIp] is the host's last-known IPv4 (or empty). Returns true if at least one datagram was
|
||||
* sent. No handle — callable without a live session. Do NOT call on the main thread (it does
|
||||
* blocking socket sends); run it on a background dispatcher.
|
||||
*/
|
||||
external fun nativeStartVideo(handle: Long, surface: android.view.Surface)
|
||||
external fun nativeWakeOnLan(macsCsv: String, lastIp: String): Boolean
|
||||
|
||||
/**
|
||||
* Bounded, trust-agnostic QUIC reachability probe to [host]:[port] (mDNS-independent): true if
|
||||
* the host completed the handshake within [timeoutMs]. No pin/identity presented. Lets a saved
|
||||
* host reached over a routed network (Tailscale/VPN/another subnet) — which never advertises on
|
||||
* mDNS — still show as online. Blocking (builds its own runtime) — run on a background
|
||||
* dispatcher, never the main thread.
|
||||
*/
|
||||
external fun nativeProbe(host: String, port: Int, timeoutMs: Int): Boolean
|
||||
|
||||
/**
|
||||
* Apply the user's "Low-latency mode (experimental)" toggle to the process-wide transport
|
||||
* defaults — today just DSCP/QoS marking on the media sockets. Must be called BEFORE
|
||||
* [nativeConnect] (the tag is applied at socket creation); `HostConnect.connectToHost` does.
|
||||
* The rest of the toggle rides explicit per-session parameters ([nativeStartVideo] /
|
||||
* [nativeStartAudio]). Cheap (one atomic store); UI-safe.
|
||||
*/
|
||||
external fun nativeSetLowLatencyMode(enabled: Boolean)
|
||||
|
||||
/**
|
||||
* The MediaCodec MIME the host resolved for this session (`"video/hevc"` / `"video/avc"` /
|
||||
* `"video/av01"`), or `""` on a `0` handle. Kotlin ranks `MediaCodecList` decoders for this
|
||||
* MIME (see [io.unom.punktfunk.kit.VideoDecoders]) before [nativeStartVideo]. Cheap; UI-safe.
|
||||
*/
|
||||
external fun nativeVideoMime(handle: Long): String
|
||||
|
||||
/**
|
||||
* Start the decode thread rendering onto [surface] (a SurfaceView's surface). Decode runs
|
||||
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. [decoderName] is the
|
||||
* 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
|
||||
* [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.
|
||||
*/
|
||||
external fun nativeStartVideo(
|
||||
handle: Long,
|
||||
surface: android.view.Surface,
|
||||
decoderName: String,
|
||||
lowLatencyMode: Boolean,
|
||||
lowLatencyFeature: Boolean,
|
||||
isTv: Boolean,
|
||||
)
|
||||
|
||||
/** Stop + join the decode thread without closing the session. No-op on `0`. */
|
||||
external fun nativeStopVideo(handle: Long)
|
||||
|
||||
/**
|
||||
* The resolved decoder identity for the HUD, e.g. `c2.qti.avc.decoder · low-latency`, or `""`
|
||||
* before the decode thread has resolved one. One-shot (fixed for the session); poll once after
|
||||
* the HUD appears.
|
||||
*/
|
||||
external fun nativeVideoDecoderLabel(handle: Long): String
|
||||
|
||||
/**
|
||||
* Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs.
|
||||
* Returns 14 doubles:
|
||||
* `[fps, mbps, latP50Ms, latP95Ms, latValid, skewCorrected, width, height, refreshHz, framesDropped,
|
||||
* bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`
|
||||
* (the two flags are 1.0/0.0; the trailing four 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). Poll ~1 Hz;
|
||||
* each call resets the measurement window.
|
||||
* Returns 26 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]`
|
||||
* (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 —
|
||||
* `host+network` = capture→received, `decode` = received→decoded; 16/17 split the
|
||||
* `host+network` term via the host's per-AU 0xCF timings — `host` = the host's capture→sent,
|
||||
* `network` = the remainder — both 0.0 when no timing matched this window, i.e. an old host;
|
||||
* 18–21 are the per-window reliability counters — lost/skipped/FEC/received; 22–25 are the
|
||||
* `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).
|
||||
* Poll ~1 Hz; each call resets the measurement window.
|
||||
*/
|
||||
external fun nativeVideoStats(handle: Long): DoubleArray?
|
||||
|
||||
@@ -123,10 +212,12 @@ object NativeBridge {
|
||||
external fun nativeSetVideoStatsEnabled(handle: Long, enabled: Boolean)
|
||||
|
||||
/**
|
||||
* Start host→client audio: Opus decode → jitter ring → AAudio (LowLatency), all in Rust. No-op
|
||||
* if already started. Best-effort — a failure leaves video streaming.
|
||||
* Start host→client audio: Opus decode → jitter ring → AAudio (LowLatency), all in Rust.
|
||||
* [lowLatencyMode] (the experimental toggle) additionally tags the stream usage=Game for the
|
||||
* HAL's game-audio routing. No-op if already started. Best-effort — a failure leaves video
|
||||
* streaming.
|
||||
*/
|
||||
external fun nativeStartAudio(handle: Long)
|
||||
external fun nativeStartAudio(handle: Long, lowLatencyMode: Boolean)
|
||||
|
||||
/** Stop + join the audio thread and close AAudio, without closing the session. No-op on `0`. */
|
||||
external fun nativeStopAudio(handle: Long)
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.media.MediaCodecInfo.CodecCapabilities
|
||||
import android.media.MediaCodecList
|
||||
import android.os.Build
|
||||
|
||||
/** The decoder Kotlin ranked for a MIME, handed to [NativeBridge.nativeStartVideo]. */
|
||||
data class DecoderChoice(val name: String, val lowLatencyFeature: Boolean)
|
||||
|
||||
/**
|
||||
* Rank the platform's `MediaCodecList` decoders for a video MIME and pick the best one for
|
||||
* low-latency streaming, the way Moonlight-Android does. There is no NDK `MediaCodecList`, so this
|
||||
* enumeration must live on the Kotlin (framework) side; Rust then creates the chosen decoder by
|
||||
* name (`AMediaCodec_createCodecByName`) and derives the per-SoC vendor low-latency keys from it.
|
||||
*
|
||||
* Ranking (best first): hardware over software; a real SoC-vendor decoder (Qualcomm/Amlogic/…) over
|
||||
* the generic AOSP software fallback; a decoder advertising `FEATURE_LowLatency` over one that
|
||||
* doesn't. Known-bad software decoders (`omx.google.*`, `c2.android.*`, Qualcomm/Samsung SW HEVC)
|
||||
* are dropped outright — matching Moonlight's blacklist.
|
||||
*/
|
||||
object VideoDecoders {
|
||||
/** Decoder-name prefixes/names we never want, mirroring Moonlight's blacklist. */
|
||||
private val BLOCKED_PREFIXES = listOf("omx.google.", "c2.android.", "avcdecoder", "omx.ffmpeg.")
|
||||
private val BLOCKED_EXACT = listOf("omx.qcom.video.decoder.hevcswvdec", "omx.sec.hevc.sw.dec")
|
||||
|
||||
/**
|
||||
* Real SoC-vendor decoder prefixes we prefer over the generic AOSP fallback, covering the common
|
||||
* targets: Qualcomm Snapdragon and MediaTek (most phones + many TV boxes), Samsung Exynos (+
|
||||
* Google Tensor, whose decoder is `c2.exynos.*`), NVIDIA Tegra (Shield TV), Amlogic / Rockchip /
|
||||
* Realtek (TV boxes & smart TVs), and HiSilicon Kirin (older Huawei).
|
||||
*/
|
||||
private val VENDOR_PREFIXES = listOf(
|
||||
"omx.qcom", "c2.qti",
|
||||
"omx.mtk", "c2.mtk",
|
||||
"omx.exynos", "c2.exynos",
|
||||
"omx.nvidia", "c2.nvidia",
|
||||
"omx.amlogic", "c2.amlogic",
|
||||
"omx.rk", "c2.rk",
|
||||
"omx.realtek", "c2.realtek",
|
||||
"omx.hisi", "c2.hisi",
|
||||
)
|
||||
|
||||
/**
|
||||
* Pick the best decoder for [mime] (`"video/hevc"` / `"video/avc"` / `"video/av01"`), or `null`
|
||||
* to let the platform resolve its default. Enumerates once — call at stream start.
|
||||
*/
|
||||
/**
|
||||
* The `quic::CODEC_*` bitfield of codecs this device can decode, advertised in the Hello so the
|
||||
* host never emits a codec the decode loop can't open: H.264 (1) and HEVC (2) always (universal
|
||||
* on Android hardware), plus AV1 (4) only when [pickDecoder] finds a real (hardware, non-blocked)
|
||||
* `video/av01` decoder. Enumerates `MediaCodecList` — call at connect time, not per frame.
|
||||
*/
|
||||
fun decodableCodecBits(): Int =
|
||||
1 or 2 or (if (pickDecoder("video/av01") != null) 4 else 0)
|
||||
|
||||
fun pickDecoder(mime: String): DecoderChoice? {
|
||||
if (mime.isEmpty()) return null
|
||||
val infos = runCatching { MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos }
|
||||
.getOrNull() ?: return null
|
||||
|
||||
var bestName: String? = null
|
||||
var bestLowLatency = false
|
||||
var bestScore = Int.MIN_VALUE
|
||||
for (info in infos) {
|
||||
if (info.isEncoder) continue
|
||||
val name = info.name
|
||||
val lower = name.lowercase()
|
||||
if (BLOCKED_PREFIXES.any { lower.startsWith(it) } || lower in BLOCKED_EXACT) continue
|
||||
// Never a secure decoder: `.secure` names are the DRM-pipeline twins of the real
|
||||
// decoder and require a secure surface — configuring one for a clear stream fails (or
|
||||
// renders black). The plain twin is also in the list, so drop rather than rank
|
||||
// (a `.secure` twin can otherwise OUT-score its plain sibling when only it advertises
|
||||
// FEATURE_LowLatency). Moonlight filters the same way.
|
||||
if (lower.endsWith(".secure")) continue
|
||||
val caps = runCatching { info.getCapabilitiesForType(mime) }.getOrNull() ?: continue
|
||||
val secureRequired = runCatching {
|
||||
caps.isFeatureRequired(CodecCapabilities.FEATURE_SecurePlayback)
|
||||
}.getOrDefault(false)
|
||||
if (secureRequired) continue
|
||||
|
||||
val hardware = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
info.isHardwareAccelerated
|
||||
} else {
|
||||
// Pre-Q heuristic: the software decoders are the ones we can name (already blocked
|
||||
// above), so anything surviving the blacklist is treated as hardware.
|
||||
true
|
||||
}
|
||||
val lowLatency = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
|
||||
runCatching { caps.isFeatureSupported(CodecCapabilities.FEATURE_LowLatency) }
|
||||
.getOrDefault(false)
|
||||
val vendor = VENDOR_PREFIXES.any { lower.startsWith(it) }
|
||||
|
||||
val score = (if (hardware) 100 else 0) +
|
||||
(if (vendor) 40 else 0) +
|
||||
(if (lowLatency) 20 else 0)
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
bestName = name
|
||||
bestLowLatency = lowLatency
|
||||
}
|
||||
}
|
||||
return bestName?.let { DecoderChoice(it, bestLowLatency) }
|
||||
}
|
||||
}
|
||||
+7
-3
@@ -17,15 +17,17 @@ data class DiscoveredHost(
|
||||
val port: Int,
|
||||
val fingerprint: String? = null, // TXT "fp" (host cert SHA-256, advisory — TOFU still verifies)
|
||||
val pairingRequired: Boolean = false,
|
||||
val mac: List<String> = emptyList(), // TXT "mac" (wake-capable NIC MAC(s), for Wake-on-LAN)
|
||||
)
|
||||
|
||||
/** Field separator the native browse uses inside one record (ASCII Unit Separator). */
|
||||
private const val FIELD_SEP = '\u001F'
|
||||
|
||||
/**
|
||||
* Parse one record from [NativeBridge.nativeDiscoveryPoll] (`key␟name␟addr␟port␟fp␟pair`), or null
|
||||
* if it's malformed. Pure — unit-tested without Android (see ParseRecordTest). The native side
|
||||
* already applied the protocol gate and address selection, so this is just field marshaling.
|
||||
* Parse one record from [NativeBridge.nativeDiscoveryPoll] (`key␟name␟addr␟port␟fp␟pair␟mac`), or
|
||||
* null if it's malformed. `mac` (7th field) is optional — an older host omits it. Pure —
|
||||
* unit-tested without Android (see ParseRecordTest). The native side already applied the protocol
|
||||
* gate and address selection, so this is just field marshaling.
|
||||
*/
|
||||
fun parseHostRecord(record: String): DiscoveredHost? {
|
||||
val f = record.split(FIELD_SEP)
|
||||
@@ -40,6 +42,8 @@ fun parseHostRecord(record: String): DiscoveredHost? {
|
||||
port = port,
|
||||
fingerprint = f[4].ifBlank { null },
|
||||
pairingRequired = f[5] == "required",
|
||||
mac = if (f.size > 6) f[6].split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||
else emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
package io.unom.punktfunk.kit.library
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.security.KeyFactory
|
||||
import java.security.KeyStore
|
||||
import java.security.MessageDigest
|
||||
import java.security.PrivateKey
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
import java.util.Base64
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.net.ssl.HostnameVerifier
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
import javax.net.ssl.KeyManagerFactory
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
// Android game-library client — the mirror of the Apple client's LibraryClient.swift. Fetches a
|
||||
// host's unified game library from its management REST API (`GET /api/v1/library`) over **mTLS**: the
|
||||
// paired client presents its persistent cert/key (the same identity the host paired over QUIC), and
|
||||
// the host's self-signed cert is pinned by SHA-256(DER). Read-only. Mirrors the GameEntry/Artwork
|
||||
// schema in crates/punktfunk-host/src/library.rs.
|
||||
|
||||
/** The management API's default port — matches `mgmt::DEFAULT_PORT` on the host and the Apple client. */
|
||||
const val DEFAULT_MGMT_PORT = 47990
|
||||
|
||||
/** Cover-art URLs. Steam art arrives as host-relative proxy paths, resolved to absolute by [LibraryClient]. */
|
||||
data class Artwork(val portrait: String?, val header: String?, val hero: String?) {
|
||||
/** Poster preference for a 2:3 tile: portrait capsule → header → hero (near-universal fallbacks). */
|
||||
val posterCandidates: List<String> get() = listOfNotNull(portrait, header, hero)
|
||||
}
|
||||
|
||||
/** One title in the unified library. [id] is store-qualified (`steam:<appid>` / `custom:<id>`). */
|
||||
data class GameEntry(val id: String, val store: String, val title: String, val art: Artwork) {
|
||||
val isCustom: Boolean get() = store == "custom"
|
||||
}
|
||||
|
||||
/** Fetch outcome — three states so the UI can guide setup (the common case is "not paired yet"). */
|
||||
sealed class LibraryResult {
|
||||
data class Ok(val games: List<GameEntry>) : LibraryResult()
|
||||
data class Unauthorized(val message: String) : LibraryResult()
|
||||
data class Error(val message: String) : LibraryResult()
|
||||
}
|
||||
|
||||
object LibraryClient {
|
||||
/**
|
||||
* `GET https://<address>:<mgmtPort>/api/v1/library`, authenticated by mTLS. [fpHex] is the pinned
|
||||
* host-cert SHA-256 (64 hex, from the paired [io.unom.punktfunk.kit.security.KnownHost]); a blank
|
||||
* value means the host was never connected/paired, so there's nothing authorized to browse.
|
||||
* BLOCKING — call from a background dispatcher.
|
||||
*/
|
||||
fun fetch(
|
||||
address: String,
|
||||
mgmtPort: Int = DEFAULT_MGMT_PORT,
|
||||
certPem: String,
|
||||
keyPem: String,
|
||||
fpHex: String,
|
||||
): LibraryResult {
|
||||
if (fpHex.isBlank()) {
|
||||
return LibraryResult.Unauthorized(
|
||||
"Connect to this host once first — the library uses the identity created on pairing to authenticate.",
|
||||
)
|
||||
}
|
||||
val client = try {
|
||||
mtlsHttpClient(certPem, keyPem, address, fpHex)
|
||||
} catch (e: Exception) {
|
||||
return LibraryResult.Error("Couldn't set up the secure connection: ${e.message}")
|
||||
}
|
||||
val base = "https://$address:$mgmtPort"
|
||||
val req = Request.Builder().url("$base/api/v1/library").build()
|
||||
return try {
|
||||
client.newCall(req).execute().use { resp ->
|
||||
when (resp.code) {
|
||||
200 -> LibraryResult.Ok(parse(resp.body?.string().orEmpty(), base))
|
||||
401 -> LibraryResult.Unauthorized(
|
||||
"The host didn't recognize this device. Pair with the host first — it authorizes paired clients by their certificate.",
|
||||
)
|
||||
else -> LibraryResult.Error("The management API returned HTTP ${resp.code}.")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LibraryResult.Error(
|
||||
"Couldn't reach the host's management API: ${e.message}. It binds the LAN by default, so check the host is updated and reachable.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parse(json: String, base: String): List<GameEntry> {
|
||||
val arr = JSONArray(json)
|
||||
val out = ArrayList<GameEntry>(arr.length())
|
||||
for (i in 0 until arr.length()) {
|
||||
val o = arr.getJSONObject(i)
|
||||
val art = o.optJSONObject("art") ?: JSONObject()
|
||||
out.add(
|
||||
GameEntry(
|
||||
id = o.optString("id"),
|
||||
store = o.optString("store"),
|
||||
title = o.optString("title"),
|
||||
art = Artwork(
|
||||
portrait = resolveArt(str(art, "portrait"), base),
|
||||
header = resolveArt(str(art, "header"), base),
|
||||
hero = resolveArt(str(art, "hero"), base),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** A present, non-null, non-blank JSON string field, else null. */
|
||||
private fun str(o: JSONObject, key: String): String? =
|
||||
if (o.has(key) && !o.isNull(key)) o.optString(key).ifBlank { null } else null
|
||||
|
||||
/** Host-relative art path (`/api/v1/library/art/...`) → absolute against the host; else unchanged. */
|
||||
private fun resolveArt(s: String?, base: String): String? =
|
||||
if (s != null && s.startsWith("/")) base + s else s
|
||||
}
|
||||
|
||||
/**
|
||||
* An OkHttpClient that presents the paired client cert and pins the host's self-signed cert by
|
||||
* SHA-256(DER) — reused for BOTH the library fetch and the cover-art loads (so a paired client
|
||||
* reaches the host's own art proxy). The pinning trust manager trusts the host by fingerprint and
|
||||
* defers to normal public trust for any other origin (an external CDN URL); the hostname verifier
|
||||
* accepts the pinned host (whose self-signed cert has no matching SAN) and defers otherwise.
|
||||
*/
|
||||
fun mtlsHttpClient(certPem: String, keyPem: String, host: String, fpHex: String): OkHttpClient {
|
||||
val clientCert = CertificateFactory.getInstance("X.509")
|
||||
.generateCertificate(ByteArrayInputStream(certPem.toByteArray())) as X509Certificate
|
||||
val privateKey = parsePrivateKey(keyPem)
|
||||
|
||||
val keyStore = KeyStore.getInstance("PKCS12").apply {
|
||||
load(null, null)
|
||||
setKeyEntry("client", privateKey, CharArray(0), arrayOf(clientCert))
|
||||
}
|
||||
val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
|
||||
kmf.init(keyStore, CharArray(0))
|
||||
|
||||
// System default trust manager, for non-host (external CDN) origins.
|
||||
val sysTmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
sysTmf.init(null as KeyStore?)
|
||||
val sysTm = sysTmf.trustManagers.filterIsInstance<X509TrustManager>().first()
|
||||
|
||||
val pinned = fpHex.lowercase()
|
||||
val trustManager = object : X509TrustManager {
|
||||
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {}
|
||||
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
|
||||
if (sha256Hex(chain[0].encoded) == pinned) return // the pinned host
|
||||
sysTm.checkServerTrusted(chain, authType) // external CDN — normal public trust
|
||||
}
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> = sysTm.acceptedIssuers
|
||||
}
|
||||
|
||||
val ssl = SSLContext.getInstance("TLS")
|
||||
ssl.init(kmf.keyManagers, arrayOf<TrustManager>(trustManager), null)
|
||||
|
||||
val defaultVerifier = HttpsURLConnection.getDefaultHostnameVerifier()
|
||||
val verifier = HostnameVerifier { hostname, session ->
|
||||
hostname == host || defaultVerifier.verify(hostname, session)
|
||||
}
|
||||
|
||||
return OkHttpClient.Builder()
|
||||
.sslSocketFactory(ssl.socketFactory, trustManager)
|
||||
.hostnameVerifier(verifier)
|
||||
.connectTimeout(8, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
/** Parse a PKCS#8 PEM private key (rcgen emits `-----BEGIN PRIVATE KEY-----`), trying EC then RSA/Ed25519. */
|
||||
private fun parsePrivateKey(pem: String): PrivateKey {
|
||||
val body = pem
|
||||
.replace(Regex("-----BEGIN [A-Z ]*PRIVATE KEY-----"), "")
|
||||
.replace(Regex("-----END [A-Z ]*PRIVATE KEY-----"), "")
|
||||
.replace(Regex("\\s"), "")
|
||||
val der = Base64.getDecoder().decode(body)
|
||||
val spec = PKCS8EncodedKeySpec(der)
|
||||
for (alg in listOf("EC", "RSA", "Ed25519")) {
|
||||
try {
|
||||
return KeyFactory.getInstance(alg).generatePrivate(spec)
|
||||
} catch (_: Exception) {
|
||||
// try the next algorithm
|
||||
}
|
||||
}
|
||||
throw IllegalArgumentException("unsupported private-key format (not EC/RSA/Ed25519 PKCS#8)")
|
||||
}
|
||||
|
||||
private fun sha256Hex(der: ByteArray): String =
|
||||
MessageDigest.getInstance("SHA-256").digest(der).joinToString("") { "%02x".format(it) }
|
||||
@@ -13,6 +13,11 @@ data class KnownHost(
|
||||
val name: String,
|
||||
val fpHex: String,
|
||||
val paired: Boolean,
|
||||
/**
|
||||
* Wake-on-LAN MAC(s) (`aa:bb:cc:dd:ee:ff`) learned from the host's mDNS `mac` TXT while it was
|
||||
* online, so the client can wake it once it sleeps. Empty until first learned.
|
||||
*/
|
||||
val mac: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -42,9 +47,22 @@ class KnownHostStore(context: Context) {
|
||||
.put("name", host.name)
|
||||
.put("fp", host.fpHex.lowercase())
|
||||
.put("paired", host.paired)
|
||||
.put("mac", host.mac.joinToString(","))
|
||||
prefs.edit().putString(key(host.address, host.port), json.toString()).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Learn/refresh a saved host's Wake-on-LAN MAC(s) from its live advert (called while online).
|
||||
* No-op when the host isn't saved, the list is empty, or it's unchanged — so it doesn't churn
|
||||
* prefs on every discovery tick.
|
||||
*/
|
||||
fun learnMac(address: String, port: Int, mac: List<String>) {
|
||||
if (mac.isEmpty()) return
|
||||
val h = get(address, port) ?: return
|
||||
if (h.mac == mac) return
|
||||
save(h.copy(mac = mac))
|
||||
}
|
||||
|
||||
/** Forget [address]:[port] (the next connect re-pairs / re-TOFUs). */
|
||||
fun remove(address: String, port: Int) {
|
||||
prefs.edit().remove(key(address, port)).apply()
|
||||
@@ -56,6 +74,16 @@ class KnownHostStore(context: Context) {
|
||||
save(h.copy(name = newName))
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit a saved host, RE-KEYING if the address or port changed (the pref key IS `address:port`, so
|
||||
* a plain [save] would otherwise leave a stale record under the old key). The caller passes an
|
||||
* [updated] copy that preserves `fpHex`/`paired` (and sets `mac` from the edit form).
|
||||
*/
|
||||
fun update(oldAddress: String, oldPort: Int, updated: KnownHost) {
|
||||
if (oldAddress != updated.address || oldPort != updated.port) remove(oldAddress, oldPort)
|
||||
save(updated)
|
||||
}
|
||||
|
||||
/** All trusted hosts, name-sorted — backs the saved-hosts list. */
|
||||
fun all(): List<KnownHost> =
|
||||
prefs.all.values.mapNotNull { (it as? String)?.let(::parse) }.sortedBy { it.name.lowercase() }
|
||||
@@ -68,6 +96,25 @@ class KnownHostStore(context: Context) {
|
||||
name = j.getString("name"),
|
||||
fpHex = j.getString("fp"),
|
||||
paired = j.optBoolean("paired", false),
|
||||
mac = j.optString("mac", "").split(",").map { it.trim() }.filter { it.isNotEmpty() },
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Parse a free-typed Wake-on-LAN field into normalized `aa:bb:cc:dd:ee:ff` entries (comma /
|
||||
* space / newline separated). Anything that isn't six colon-separated hex octets is dropped;
|
||||
* an empty result clears the host's MAC. Mirrors the Apple client's `AddHostSheet.parseMacs`.
|
||||
*/
|
||||
fun parseMacs(s: String): List<String> = s
|
||||
.split(',', ';', ' ', '\n', '\t')
|
||||
.map { it.trim().lowercase() }
|
||||
.filter { m ->
|
||||
// Exactly six octets, each two literal hex digits. (Not toIntOrNull(16) — that accepts
|
||||
// a leading +/- sign, so "aa:bb:cc:dd:ee:-1" would wrongly pass.)
|
||||
m.split(":").let { o ->
|
||||
o.size == 6 && o.all { it.length == 2 && it.all { c -> c in '0'..'9' || c in 'a'..'f' } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package io.unom.punktfunk.kit.security
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/** Unit tests for the pure MAC-parsing helper backing the host edit form. */
|
||||
class KnownHostStoreTest {
|
||||
@Test
|
||||
fun parsesAndNormalizesSingleMac() {
|
||||
assertEquals(listOf("aa:bb:cc:dd:ee:ff"), KnownHostStore.parseMacs("AA:BB:CC:DD:EE:FF"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesMultipleSeparators() {
|
||||
val expected = listOf("aa:bb:cc:dd:ee:ff", "11:22:33:44:55:66")
|
||||
assertEquals(expected, KnownHostStore.parseMacs("aa:bb:cc:dd:ee:ff, 11:22:33:44:55:66"))
|
||||
assertEquals(expected, KnownHostStore.parseMacs("aa:bb:cc:dd:ee:ff 11:22:33:44:55:66"))
|
||||
assertEquals(expected, KnownHostStore.parseMacs("aa:bb:cc:dd:ee:ff\n11:22:33:44:55:66"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dropsMalformedEntries() {
|
||||
// Not six octets / bad hex / wrong width are all dropped; an empty field clears the MAC.
|
||||
assertEquals(emptyList<String>(), KnownHostStore.parseMacs(""))
|
||||
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("not-a-mac"))
|
||||
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("aa:bb:cc:dd:ee")) // 5 octets
|
||||
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("gg:bb:cc:dd:ee:ff")) // non-hex
|
||||
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("aaa:bb:cc:dd:ee:ff")) // wrong width
|
||||
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("aa:bb:cc:dd:ee:-1")) // signed octet
|
||||
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("+a:-b:+c:-d:+e:-f")) // signed octets
|
||||
assertEquals(listOf("aa:bb:cc:dd:ee:ff"), KnownHostStore.parseMacs("junk, aa:bb:cc:dd:ee:ff"))
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ punktfunk-core = { path = "../../../crates/punktfunk-core", features = ["quic"]
|
||||
jni = "0.21"
|
||||
log = "0.4"
|
||||
# LAN host discovery: browse the host's `_punktfunk._udp` mDNS advert — the SAME crate + service the
|
||||
# Linux/Windows clients use (`clients/linux/src/discovery.rs`), replacing Android's per-OEM
|
||||
# Linux/Windows clients use (`crates/pf-client-core/src/discovery.rs`), replacing Android's per-OEM
|
||||
# `NsdManager` system daemon with one tested browse path. Pure Rust (socket2/if-addrs/mio), so it
|
||||
# cross-compiles to the Android targets AND builds on the host (the JNI seam links into
|
||||
# `cargo build --workspace`). Kotlin keeps only the Wi-Fi `MulticastLock` + permission UX.
|
||||
@@ -31,10 +31,33 @@ mdns-sd = "0.20"
|
||||
# via `ndk`, the Opus codec) is only pulled in for the real `*-linux-android` targets.
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
android_logger = "0.14"
|
||||
# Feature bridge, no code here: punktfunk-core logs through `tracing`, but this client only
|
||||
# installs `android_logger` (a `log` backend). Core transport warnings (e.g. "UDP socket buffer
|
||||
# capped well below target") reach logcat only via tracing's "log" feature, which forwards events
|
||||
# as `log` records when no tracing subscriber is set (always, here). Today that feature happens to
|
||||
# be enabled transitively — quinn's default `log` feature unifies `tracing/log` onto the whole
|
||||
# graph — but nothing about this client's logging should hinge on a QUIC crate's default feature
|
||||
# set, so declare it explicitly.
|
||||
tracing = { version = "0.1", default-features = false, features = ["std", "log"] }
|
||||
# NDK bindings. "media" = AMediaCodec/ANativeWindow (video); "audio" = AAudio (audio playback).
|
||||
# Pure-Rust FFI to libmediandk/libnativewindow/libaaudio — no C++/libc++_shared to bundle. Decode +
|
||||
# audio run entirely in Rust on native threads (the "no async on the hot path" invariant).
|
||||
ndk = { version = "0.9", features = ["media", "audio", "nativewindow", "api-level-31"] }
|
||||
# api-level-28 matches the app's minSdk floor (Android 9). AAudio (26), AMediaCodec (21) and
|
||||
# ANativeWindow_setBuffersDataSpace (28) are all ≤28; every call above the floor —
|
||||
# ANativeWindow_setFrameRate (30), …WithChangeStrategy (31), AMediaCodec_setOnFrameRenderedCallback
|
||||
# (33), the ADPF hints — is dlsym-resolved at runtime (decode::try_set_frame_rate,
|
||||
# decode::install_render_callback, adpf), never linked, so the .so still loads on API 28+.
|
||||
ndk = { version = "0.9", features = ["media", "audio", "nativewindow", "api-level-28"] }
|
||||
# Raw FFI *types* for the one AMediaCodec entry point the `ndk` wrapper lacks:
|
||||
# `AMediaCodec_setOnFrameRenderedCallback` — the per-frame render-timestamp callback behind the
|
||||
# HUD's `display` stage (see decode::DisplayTracker). The symbol is API 33 ("Available since
|
||||
# Android T"), ABOVE the minSdk-28 floor, so it is dlsym-resolved at runtime
|
||||
# (decode::install_render_callback), NEVER hard-linked: 0.9.0 hard-linked it and
|
||||
# `System.loadLibrary` failed on every pre-Android-13 device. Only ndk-sys's pointer/typedef
|
||||
# types are referenced, which creates no import. Reaching it needs the codec's raw pointer, which
|
||||
# is why the workspace pins `ndk` to the vendored copy whose only patch makes `MediaCodec::as_ptr`
|
||||
# public (vendor/ndk, wired in the root Cargo.toml).
|
||||
ndk-sys = { version = "0.6", features = ["media"] }
|
||||
# setpriority/gettid to raise the decode thread toward URGENT_DISPLAY (see decode::boost_thread_priority).
|
||||
libc = "0.2"
|
||||
# Opus decode for the host→client audio plane (0xC9: 48 kHz stereo, 5 ms frames). Same crate the
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Android Adaptive Performance Framework (ADPF) — CPU performance hints for the decode thread.
|
||||
//!
|
||||
//! ADPF lets a latency-critical app tell the platform "these threads run a repeating workload with
|
||||
//! this per-cycle deadline, and here's how long they *actually* took." The kernel's CPU governor
|
||||
//! (on Qualcomm Snapdragon in particular — its ADPF backend is among the most responsive) then keeps
|
||||
//! those threads on the fast cores at high clocks instead of migrating them to a little core or
|
||||
//! down-clocking between frames. For a stream client the win is on the in-process hot path we
|
||||
//! control — the `pf-decode` feed/drain/present loop — *not* the hardware codec itself (that decodes
|
||||
//! in the mediacodec service, a separate process we can't hint); keeping our loop from being
|
||||
//! scheduled late directly trims the jitter between "AU received" and "buffer released to the
|
||||
//! Surface." It complements the codec-side `operating-rate`/`priority` hints, which push the codec's
|
||||
//! own clocks.
|
||||
//!
|
||||
//! The `APerformanceHint_*` API arrived in NDK **API level 33**. minSdk is 31, so we CANNOT link the
|
||||
//! symbols directly: a `libpunktfunk_android.so` carrying an unresolved
|
||||
//! `APerformanceHint_createSession` import fails to load on API 31/32 devices
|
||||
//! (`System.loadLibrary` throws) even if the code path is never taken. Instead we resolve the
|
||||
//! entry points from `libandroid.so` with `dlsym` at runtime — absent on < 33 ⇒
|
||||
//! [`HintSession::create`] returns `None` and the decode loop simply runs without hints.
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::os::raw::c_int;
|
||||
|
||||
// `APerformanceHint_*` function-pointer types. The manager/session handles are opaque, so we treat
|
||||
// them as `*mut c_void`.
|
||||
type GetManagerFn = unsafe extern "C" fn() -> *mut c_void;
|
||||
type CreateSessionFn = unsafe extern "C" fn(*mut c_void, *const i32, usize, i64) -> *mut c_void;
|
||||
type ReportFn = unsafe extern "C" fn(*mut c_void, i64) -> c_int;
|
||||
type UpdateTargetFn = unsafe extern "C" fn(*mut c_void, i64) -> c_int;
|
||||
type CloseFn = unsafe extern "C" fn(*mut c_void);
|
||||
type SetPreferPowerEfficiencyFn = unsafe extern "C" fn(*mut c_void, bool) -> c_int;
|
||||
|
||||
/// The entry points we use, resolved once from `libandroid.so`, plus the process-wide manager.
|
||||
struct Api {
|
||||
create_session: CreateSessionFn,
|
||||
report: ReportFn,
|
||||
update_target: UpdateTargetFn,
|
||||
close: CloseFn,
|
||||
/// `APerformanceHint_setPreferPowerEfficiency` — NDK **API 35**, so `Option`al even when the
|
||||
/// rest of ADPF resolved (a 33/34 device has the session API but not this one).
|
||||
set_prefer_power_efficiency: Option<SetPreferPowerEfficiencyFn>,
|
||||
manager: *mut c_void,
|
||||
}
|
||||
|
||||
/// Resolve the ADPF entry points + the process manager, or `None` on API < 33 (symbols absent) or if
|
||||
/// the manager is unavailable.
|
||||
fn resolve_api() -> Option<Api> {
|
||||
// SAFETY: `dlopen` of an always-present system library with a NUL-terminated name; it returns
|
||||
// null on failure (checked below). `libandroid.so` is already mapped into every app process, so
|
||||
// this only bumps its refcount — we intentionally never `dlclose` (process-lifetime handle).
|
||||
let lib = unsafe { libc::dlopen(c"libandroid.so".as_ptr(), libc::RTLD_NOW) };
|
||||
if lib.is_null() {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: `dlsym` on the valid handle above with NUL-terminated symbol names; each returns null
|
||||
// when the symbol is absent (device API < 33), which we check before transmuting the non-null
|
||||
// pointer to its fn-pointer type (layout-compatible; a resolved symbol is a valid code address).
|
||||
unsafe {
|
||||
let get_manager = libc::dlsym(lib, c"APerformanceHint_getManager".as_ptr());
|
||||
let create_session = libc::dlsym(lib, c"APerformanceHint_createSession".as_ptr());
|
||||
let report = libc::dlsym(lib, c"APerformanceHint_reportActualWorkDuration".as_ptr());
|
||||
let update_target = libc::dlsym(lib, c"APerformanceHint_updateTargetWorkDuration".as_ptr());
|
||||
let close = libc::dlsym(lib, c"APerformanceHint_closeSession".as_ptr());
|
||||
if get_manager.is_null()
|
||||
|| create_session.is_null()
|
||||
|| report.is_null()
|
||||
|| update_target.is_null()
|
||||
|| close.is_null()
|
||||
{
|
||||
return None; // device API < 33 — no ADPF
|
||||
}
|
||||
let get_manager = std::mem::transmute::<*mut c_void, GetManagerFn>(get_manager);
|
||||
let manager = get_manager();
|
||||
if manager.is_null() {
|
||||
return None;
|
||||
}
|
||||
// Optional (API 35): resolve if present, else `None` — the session still works without it.
|
||||
let set_prefer_power_efficiency =
|
||||
libc::dlsym(lib, c"APerformanceHint_setPreferPowerEfficiency".as_ptr());
|
||||
let set_prefer_power_efficiency = (!set_prefer_power_efficiency.is_null()).then(|| {
|
||||
std::mem::transmute::<*mut c_void, SetPreferPowerEfficiencyFn>(
|
||||
set_prefer_power_efficiency,
|
||||
)
|
||||
});
|
||||
Some(Api {
|
||||
create_session: std::mem::transmute::<*mut c_void, CreateSessionFn>(create_session),
|
||||
report: std::mem::transmute::<*mut c_void, ReportFn>(report),
|
||||
update_target: std::mem::transmute::<*mut c_void, UpdateTargetFn>(update_target),
|
||||
close: std::mem::transmute::<*mut c_void, CloseFn>(close),
|
||||
set_prefer_power_efficiency,
|
||||
manager,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A live ADPF hint session bound to a set of thread ids. Dropping it closes the session. Holds raw
|
||||
/// handles, so it is `!Send`/`!Sync` — created and used only on the `pf-decode` thread.
|
||||
pub struct HintSession {
|
||||
api: Api,
|
||||
session: *mut c_void,
|
||||
}
|
||||
|
||||
impl HintSession {
|
||||
/// Open a session hinting `tids` with an initial per-frame target of `target_ns` nanoseconds.
|
||||
/// `None` when ADPF is unavailable (device API < 33) or the platform declines — the caller then
|
||||
/// runs unhinted (a no-op, not an error). `prefer_performance` (the experimental low-latency
|
||||
/// mode) additionally biases the governor away from power efficiency (API 35+); off, the
|
||||
/// session runs with the platform default, as it did before the overhaul.
|
||||
pub fn create(target_ns: i64, tids: &[i32], prefer_performance: bool) -> Option<Self> {
|
||||
if target_ns <= 0 || tids.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let api = resolve_api()?;
|
||||
// SAFETY: `api.manager` is the live process manager returned above; `tids` is a valid slice
|
||||
// of `len` i32s that `createSession` copies; it returns null on failure (checked).
|
||||
let session =
|
||||
unsafe { (api.create_session)(api.manager, tids.as_ptr(), tids.len(), target_ns) };
|
||||
if session.is_null() {
|
||||
return None;
|
||||
}
|
||||
// Tell the governor NOT to bias this session toward power efficiency (API 35+): our loop is
|
||||
// latency-critical, so we want it kept on fast cores at high clocks over battery savings.
|
||||
// Best-effort; absent below API 35.
|
||||
if prefer_performance {
|
||||
if let Some(f) = api.set_prefer_power_efficiency {
|
||||
// SAFETY: `session` is the live session just created; the fn takes it + a bool.
|
||||
unsafe { f(session, false) };
|
||||
}
|
||||
}
|
||||
Some(Self { api, session })
|
||||
}
|
||||
|
||||
/// Report the wall-clock time the hinted thread spent producing the last displayed frame. When
|
||||
/// it exceeds the session target the governor boosts the cores running the thread; when it
|
||||
/// stays under, clocks may relax. No-op on a non-positive duration (the API rejects it).
|
||||
pub fn report_actual(&self, actual_ns: i64) {
|
||||
if actual_ns <= 0 {
|
||||
return;
|
||||
}
|
||||
// SAFETY: `self.session` is a live session for `self`'s lifetime.
|
||||
unsafe { (self.api.report)(self.session, actual_ns) };
|
||||
}
|
||||
|
||||
/// Update the per-frame target (e.g. after a mid-session refresh-rate change). Unused today —
|
||||
/// the decode thread restarts on renegotiation — but kept for that path.
|
||||
#[allow(dead_code)]
|
||||
pub fn update_target(&self, target_ns: i64) {
|
||||
if target_ns <= 0 {
|
||||
return;
|
||||
}
|
||||
// SAFETY: `self.session` is a live session for `self`'s lifetime.
|
||||
unsafe { (self.api.update_target)(self.session, target_ns) };
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for HintSession {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.session` was created by `createSession` and is closed exactly once, here.
|
||||
unsafe { (self.api.close)(self.session) };
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,8 @@
|
||||
//! grown on XRuns (Google's anti-glitch technique).
|
||||
|
||||
use ndk::audio::{
|
||||
AudioCallbackResult, AudioDirection, AudioFormat, AudioPerformanceMode, AudioSharingMode,
|
||||
AudioStream, AudioStreamBuilder,
|
||||
AudioCallbackResult, AudioContentType, AudioDirection, AudioFormat, AudioPerformanceMode,
|
||||
AudioSharingMode, AudioStream, AudioStreamBuilder, AudioUsage,
|
||||
};
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::error::PunktfunkError;
|
||||
@@ -116,8 +116,10 @@ pub struct AudioPlayback {
|
||||
impl AudioPlayback {
|
||||
/// Open AAudio (LowLatency, 48 kHz/f32, the host-resolved channel layout) with a realtime
|
||||
/// callback draining a jitter ring, then spawn the Opus decode thread. `None` on failure (the
|
||||
/// caller leaves video streaming).
|
||||
pub fn start(client: Arc<NativeClient>) -> Option<AudioPlayback> {
|
||||
/// caller leaves video streaming). `game_audio` (the experimental low-latency mode) tags the
|
||||
/// stream usage=Game for the HAL's game-audio routing; off, the stream is untagged as it was
|
||||
/// before the overhaul.
|
||||
pub fn start(client: Arc<NativeClient>, game_audio: bool) -> Option<AudioPlayback> {
|
||||
// Build playback 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 = punktfunk_core::audio::normalize_channels(client.audio_channels) as usize;
|
||||
@@ -226,7 +228,7 @@ impl AudioPlayback {
|
||||
AudioCallbackResult::Continue
|
||||
};
|
||||
|
||||
let stream = AudioStreamBuilder::new()?
|
||||
let builder = AudioStreamBuilder::new()?
|
||||
.direction(AudioDirection::Output)
|
||||
.sample_rate(SAMPLE_RATE)
|
||||
// The wire order (FL FR FC LFE RL RR SL SR) is the standard AAudio/Android channel
|
||||
@@ -234,7 +236,19 @@ impl AudioPlayback {
|
||||
// from `channel_count` (the ndk crate's builder exposes no setChannelMask); the host
|
||||
// captures + Opus-encodes in exactly this order.
|
||||
.channel_count(channels as i32)
|
||||
.format(AudioFormat::PCM_Float)
|
||||
.format(AudioFormat::PCM_Float);
|
||||
// Tag the stream as game audio (usage=Game / content=Movie): the audio HAL applies
|
||||
// its low-latency game-audio routing/policy and it's grouped correctly with the
|
||||
// game-mode profile. Advisory — ignored where the device has no such policy. Part of
|
||||
// the experimental low-latency stack; off, the stream stays untagged.
|
||||
let builder = if game_audio {
|
||||
builder
|
||||
.usage(AudioUsage::Game)
|
||||
.content_type(AudioContentType::Movie)
|
||||
} else {
|
||||
builder
|
||||
};
|
||||
let stream = builder
|
||||
.performance_mode(AudioPerformanceMode::LowLatency)
|
||||
.sharing_mode(sharing)
|
||||
.data_callback(Box::new(callback))
|
||||
@@ -324,6 +338,10 @@ fn decode_loop(
|
||||
counters: Arc<Counters>,
|
||||
channels: usize,
|
||||
) {
|
||||
// Fold this Opus→AAudio thread into the client's hot-thread set so the ADPF session the decode
|
||||
// thread opens also keeps audio decode on a fast core (registered before the video pump's first
|
||||
// frame arrives, so it's captured when that session is created). No-op below API 33.
|
||||
client.register_hot_thread();
|
||||
// Interleaved f32 samples per millisecond at this layout — the ring's 5 ms reserve check below.
|
||||
let ms = (SAMPLE_RATE as usize / 1000) * channels;
|
||||
// Opus decode scratch: worst-case 120 ms frame (5760 samples/ch) × channels.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
//! LAN host discovery over mDNS, in Rust via `mdns-sd` — the same crate + service type the
|
||||
//! Linux/Windows clients use (`clients/linux/src/discovery.rs`), exposed to Kotlin over JNI.
|
||||
//! Linux/Windows clients use (`crates/pf-client-core/src/discovery.rs`), exposed to Kotlin over JNI.
|
||||
//!
|
||||
//! Why not `NsdManager`: that API delegates to a per-OEM system mDNS daemon whose reliability
|
||||
//! varies wildly (the Android client's discovery was "mostly broken"). Browsing in our own Rust
|
||||
@@ -31,7 +31,7 @@ const PROTO: &str = "punktfunk/1";
|
||||
/// Field separator inside one serialized record (ASCII Unit Separator — never in a field value).
|
||||
const FIELD_SEP: char = '\u{1f}';
|
||||
|
||||
/// One resolved host, serialized to Kotlin as `key␟name␟addr␟port␟fp␟pair` (`␟` = [`FIELD_SEP`]).
|
||||
/// One resolved host, serialized to Kotlin as `key␟name␟addr␟port␟fp␟pair␟mac` (`␟` = [`FIELD_SEP`]).
|
||||
/// Records are newline-joined in a poll snapshot; [`Host::encode`] strips the framing bytes from
|
||||
/// every field so no value can break it.
|
||||
#[derive(Clone, PartialEq)]
|
||||
@@ -42,6 +42,8 @@ struct Host {
|
||||
port: u16,
|
||||
fp: String,
|
||||
pair: String,
|
||||
/// Wake-on-LAN MAC(s) from the mDNS `mac` TXT (comma-separated), for later wake. Empty if absent.
|
||||
mac: String,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
@@ -54,13 +56,14 @@ impl Host {
|
||||
s.replace(['\n', '\r', FIELD_SEP], "")
|
||||
}
|
||||
format!(
|
||||
"{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}",
|
||||
"{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}",
|
||||
clean(&self.key),
|
||||
clean(&self.name),
|
||||
clean(&self.addr),
|
||||
self.port,
|
||||
clean(&self.fp),
|
||||
clean(&self.pair),
|
||||
clean(&self.mac),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -182,6 +185,7 @@ fn resolve(info: &ResolvedService) -> Option<Host> {
|
||||
port: info.get_port(),
|
||||
fp: val("fp"),
|
||||
pair: val("pair"),
|
||||
mac: val("mac"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -202,7 +206,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoverySt
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeDiscoveryPoll(handle): String` — the current resolved-host snapshot,
|
||||
/// newline-joined records of `key␟name␟addr␟port␟fp␟pair` (`␟` = U+001F). Empty string = no hosts /
|
||||
/// newline-joined records of `key␟name␟addr␟port␟fp␟pair␟mac` (`␟` = U+001F). Empty string = no hosts /
|
||||
/// `0` handle. Poll ~1 Hz from the UI thread (cheap: a mutex lock + string build).
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll<'local>(
|
||||
@@ -263,16 +267,18 @@ mod tests {
|
||||
port: 9777,
|
||||
fp: "ab".repeat(32),
|
||||
pair: "required".into(),
|
||||
mac: "aa:bb:cc:dd:ee:ff".into(),
|
||||
};
|
||||
let encoded = h.encode();
|
||||
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
|
||||
assert_eq!(fields.len(), 6);
|
||||
assert_eq!(fields.len(), 7);
|
||||
assert_eq!(fields[0], "host-123");
|
||||
assert_eq!(fields[1], "home-worker-2");
|
||||
assert_eq!(fields[2], "192.168.1.70");
|
||||
assert_eq!(fields[3], "9777");
|
||||
assert_eq!(fields[4], "ab".repeat(32));
|
||||
assert_eq!(fields[5], "required");
|
||||
assert_eq!(fields[6], "aa:bb:cc:dd:ee:ff");
|
||||
assert!(
|
||||
!encoded.contains('\n'),
|
||||
"a record must never contain the record separator"
|
||||
@@ -282,7 +288,7 @@ mod tests {
|
||||
#[test]
|
||||
fn encode_strips_injected_separators_from_a_hostile_advert() {
|
||||
// A rogue advert could carry framing bytes in its instance label / TXT; encode must strip
|
||||
// them so the snapshot stays exactly one record of exactly six fields.
|
||||
// them so the snapshot stays exactly one record of exactly seven fields.
|
||||
let h = Host {
|
||||
key: "k\u{1f}injected".into(),
|
||||
name: "evil\nhost\r".into(),
|
||||
@@ -290,9 +296,14 @@ mod tests {
|
||||
port: 9777,
|
||||
fp: "ab\u{1f}cd".into(),
|
||||
pair: "required\n".into(),
|
||||
mac: "aa:bb\u{1f}cc".into(),
|
||||
};
|
||||
let encoded = h.encode();
|
||||
assert_eq!(encoded.matches(FIELD_SEP).count(), 5, "exactly six fields");
|
||||
assert_eq!(
|
||||
encoded.matches(FIELD_SEP).count(),
|
||||
6,
|
||||
"exactly seven fields"
|
||||
);
|
||||
assert!(!encoded.contains('\n') && !encoded.contains('\r'));
|
||||
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
|
||||
assert_eq!(fields[0], "kinjected");
|
||||
|
||||
@@ -25,6 +25,8 @@ use jni::objects::JObject;
|
||||
use jni::sys::jint;
|
||||
use jni::JNIEnv;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
mod adpf;
|
||||
#[cfg(target_os = "android")]
|
||||
mod audio;
|
||||
#[cfg(target_os = "android")]
|
||||
@@ -37,9 +39,18 @@ mod feedback;
|
||||
mod mic;
|
||||
mod session;
|
||||
mod stats;
|
||||
// Ungated like `discovery`: pure `jni` + `punktfunk_core::wol` (no Android framework), so it links
|
||||
// into the host workspace build too. Kotlin only ever calls it on device.
|
||||
mod wol;
|
||||
// Ungated like `wol`: pure `jni` + `punktfunk_core::client` (the reachability probe). Kotlin calls
|
||||
// it off the main thread to light saved-host "online" pips independently of mDNS.
|
||||
mod probe;
|
||||
|
||||
/// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the
|
||||
/// `punktfunk` tag. Android-only — there is no JVM (and no logcat) on the host build.
|
||||
/// `punktfunk` tag. Core `tracing` events (transport warnings: socket-buffer clamp, QoS failures)
|
||||
/// arrive here too: tracing's "log" feature — declared explicitly in Cargo.toml rather than relied
|
||||
/// on via quinn's defaults — forwards them as `log` records since no tracing subscriber is ever
|
||||
/// installed. Android-only — there is no JVM (and no logcat) on the host build.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn JNI_OnLoad(
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
//! JNI seam for the reachability probe: a bounded, trust-agnostic QUIC handshake to a saved host
|
||||
//! (`punktfunk_core::client::NativeClient::probe`). Like [`crate::wol`] it takes no session handle
|
||||
//! and links into the host workspace build (pure `jni` + `punktfunk_core`). Kotlin calls it
|
||||
//! periodically, on a background dispatcher, to light the "online" pip for saved hosts that never
|
||||
//! advertise on mDNS (reached over Tailscale / VPN / another subnet) — the display-side companion
|
||||
//! to the dial-first connect fix.
|
||||
|
||||
use jni::objects::{JObject, JString};
|
||||
use jni::sys::{jboolean, jint};
|
||||
use jni::JNIEnv;
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use std::time::Duration;
|
||||
|
||||
/// `NativeBridge.nativeProbe(host, port, timeoutMs): Boolean` — true if `host:port` completed a
|
||||
/// QUIC handshake within `timeoutMs`. No pin/identity presented (trust-agnostic), mDNS-independent.
|
||||
/// Blocking (builds its own runtime) — Kotlin runs it on `Dispatchers.IO`, never the main thread.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeProbe<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
host: JString<'local>,
|
||||
port: jint,
|
||||
timeout_ms: jint,
|
||||
) -> jboolean {
|
||||
let host: String = match env.get_string(&host) {
|
||||
Ok(s) => s.into(),
|
||||
Err(_) => return 0,
|
||||
};
|
||||
let port = port.clamp(0, u16::MAX as jint) as u16;
|
||||
let timeout = Duration::from_millis(timeout_ms.max(0) as u64);
|
||||
if NativeClient::probe(&host, port, timeout) {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
@@ -32,8 +32,23 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIde
|
||||
}
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSetLowLatencyMode(enabled)` — apply the user's "Low-latency mode
|
||||
/// (experimental)" toggle to the process-wide transport defaults, today just DSCP/QoS marking on
|
||||
/// the media sockets. Must be called BEFORE `nativeConnect` (the tag is applied at socket
|
||||
/// creation); Kotlin's one connect choke point (`HostConnect.connectToHost`) does. The rest of the
|
||||
/// toggle rides explicit per-session parameters (`nativeStartVideo` / `nativeStartAudio`).
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetLowLatencyMode(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
enabled: jboolean,
|
||||
) {
|
||||
punktfunk_core::transport::set_dscp_default(enabled != 0);
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex, bitrateKbps,
|
||||
/// compositorPref, gamepadPref, hdrEnabled, audioChannels, preferredCodec, timeoutMs): Long`.
|
||||
/// compositorPref, gamepadPref, hdrEnabled, audioChannels, preferredCodec, timeoutMs, launch): Long`.
|
||||
/// `launch` (empty ⇒ none) is a store-qualified library id to boot straight into a game.
|
||||
/// `certPem`/`keyPem` empty = anonymous, else presented as the persistent identity. `pinHex` empty
|
||||
/// = TOFU (read `nativeHostFingerprint` after), else 64-hex SHA-256 to pin the host (mismatch → 0).
|
||||
/// `bitrateKbps` 0 = host default. `compositorPref`/`gamepadPref` are `CompositorPref`/`GamepadPref`
|
||||
@@ -61,8 +76,10 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
gamepad_pref: jint,
|
||||
hdr_enabled: jboolean,
|
||||
audio_channels: jint,
|
||||
video_codecs: jint,
|
||||
preferred_codec: jint,
|
||||
timeout_ms: jint,
|
||||
launch: JString<'local>,
|
||||
) -> jlong {
|
||||
let host: String = match env.get_string(&host) {
|
||||
Ok(s) => s.into(),
|
||||
@@ -74,6 +91,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
.unwrap_or_default();
|
||||
let key: String = env.get_string(&key_pem).map(Into::into).unwrap_or_default();
|
||||
let pin_hex: String = env.get_string(&pin_hex).map(Into::into).unwrap_or_default();
|
||||
// A store-qualified library id (`steam:<appid>` / `custom:<id>`) to boot straight into a game;
|
||||
// null / empty ⇒ None (a plain desktop connect). Rides the Hello as `launch`.
|
||||
let launch: Option<String> = env
|
||||
.get_string(&launch)
|
||||
.map(Into::into)
|
||||
.ok()
|
||||
.filter(|s: &String| !s.is_empty());
|
||||
|
||||
let identity: Option<(String, String)> = if cert.is_empty() || key.is_empty() {
|
||||
None
|
||||
@@ -119,12 +143,25 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
// decoder + AAudio layout (read in `crate::audio::AudioPlayback::start`). Anything else
|
||||
// normalizes to stereo here.
|
||||
punktfunk_core::audio::normalize_channels(audio_channels.clamp(0, u8::MAX as jint) as u8),
|
||||
// Codecs this device can decode — AMediaCodec decodes both HEVC and H.264 (AV1 isn't wired;
|
||||
// hosts don't emit it on the native path yet). The host resolves the emitted codec from these
|
||||
// + the soft `preferred_codec` and echoes it in `connector.codec`, which drives the mime below.
|
||||
punktfunk_core::quic::CODEC_H264 | punktfunk_core::quic::CODEC_HEVC,
|
||||
// Codecs this device can decode, ranked on the Kotlin side (`VideoDecoders.decodableCodecBits`:
|
||||
// H.264 + HEVC always, AV1 when a real `video/av01` decoder exists — AMediaCodec is
|
||||
// mime-driven, see `codec_mime`). Mask to the known bits and fall back to the pre-AV1
|
||||
// H.264|HEVC pair on 0 so a bogus value can't advertise nothing and kill the handshake.
|
||||
// The host resolves the emitted codec from these + the soft `preferred_codec` and echoes it
|
||||
// in `connector.codec`, which drives the mime below.
|
||||
{
|
||||
let bits = (video_codecs.clamp(0, u8::MAX as jint) as u8)
|
||||
& (punktfunk_core::quic::CODEC_H264
|
||||
| punktfunk_core::quic::CODEC_HEVC
|
||||
| punktfunk_core::quic::CODEC_AV1);
|
||||
if bits == 0 {
|
||||
punktfunk_core::quic::CODEC_H264 | punktfunk_core::quic::CODEC_HEVC
|
||||
} else {
|
||||
bits
|
||||
}
|
||||
},
|
||||
preferred_codec.clamp(0, u8::MAX as jint) as u8,
|
||||
None, // launch: default app
|
||||
launch, // a store-qualified library id to boot into a game, or None for the desktop
|
||||
pin, // Some → Crypto on host-fp mismatch
|
||||
identity, // owned (cert, key) PEM, or None (anonymous)
|
||||
// Handshake budget from Kotlin: ~10 s for a normal connect, ~185 s for "request access"
|
||||
@@ -170,6 +207,30 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose(
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeDisconnectQuit(handle)` — signal a DELIBERATE user quit before `nativeClose`,
|
||||
/// so the session closes with `QUIT_CLOSE_CODE` and the host tears it down immediately instead of
|
||||
/// holding the keep-alive linger for a reconnect. Call from an explicit disconnect action only (a
|
||||
/// plain drop / app-background keeps the linger). The handle is only BORROWED (not freed). No-op on `0`.
|
||||
///
|
||||
/// # Safety contract
|
||||
/// `handle` must be `0` or a live handle from [`Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect`],
|
||||
/// not freed / closed concurrently with this call (Kotlin still owns it and closes it via `nativeClose`).
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDisconnectQuit(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) {
|
||||
jni_guard((), || {
|
||||
if handle != 0 {
|
||||
// SAFETY: per the contract, `handle` is a live `Box<SessionHandle>` — we only borrow it
|
||||
// (no drop), so it stays owned by Kotlin for the later `nativeClose`.
|
||||
let sh = unsafe { &*(handle as *const SessionHandle) };
|
||||
sh.client.disconnect_quit();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeHostFingerprint(handle): String` — the SHA-256 (64-hex) of the cert the host
|
||||
/// presented on this connection. Valid after a successful `nativeConnect`; Kotlin pins it on a TOFU
|
||||
/// connect. `""` on a `0` handle.
|
||||
@@ -192,6 +253,28 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostFingerp
|
||||
}
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSessionEnded(handle): Boolean` — has the underlying QUIC session ended?
|
||||
/// `true` once the connection closed (a host suspend / crash / network drop idle-timed it out, or the
|
||||
/// host closed it) — from then on no more frames arrive and the video sits frozen on its last one.
|
||||
/// Kotlin's stream watchdog polls this (~1 Hz) to leave a dead stream and return to the menu (where
|
||||
/// the user can Wake-on-LAN the host) instead of stranding them on a frozen frame. `false` on a `0`
|
||||
/// handle. Cheap (one atomic load); safe on the UI thread.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSessionEnded(
|
||||
_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.client.is_session_ended())
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativePair(host, port, certPem, keyPem, pin, name): String` — run the SPAKE2 PIN
|
||||
/// ceremony, presenting our persistent identity. On success returns the host's verified fingerprint
|
||||
/// (64-hex) to persist + pin; on any failure (wrong PIN / MITM / host reject / unreachable) returns
|
||||
|
||||
@@ -2,20 +2,31 @@
|
||||
//! ~1 Hz decode-stats drain for the HUD.
|
||||
|
||||
use jni::objects::JObject;
|
||||
use jni::sys::{jboolean, jdoubleArray, jlong, jsize};
|
||||
// Used only by the android-gated `nativeStartVideo`; on the host build that fn is cfg'd out.
|
||||
#[cfg(target_os = "android")]
|
||||
use jni::objects::JString;
|
||||
use jni::sys::{jboolean, jdoubleArray, jlong, jsize, jstring};
|
||||
use jni::JNIEnv;
|
||||
|
||||
use super::{jni_guard, SessionHandle};
|
||||
|
||||
/// `NativeBridge.nativeStartVideo(handle, surface)` — wrap the SurfaceView's `Surface` as an
|
||||
/// `ANativeWindow` and start the HEVC decode thread rendering onto it. No-op if already started.
|
||||
/// `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).
|
||||
/// No-op if already started.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
env: JNIEnv,
|
||||
mut env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
surface: JObject,
|
||||
decoder_name: JString,
|
||||
low_latency_mode: jboolean,
|
||||
ll_feature: jboolean,
|
||||
is_tv: jboolean,
|
||||
) {
|
||||
use super::VideoThread;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
@@ -24,6 +35,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
if handle == 0 {
|
||||
return;
|
||||
}
|
||||
// The decoder name Kotlin picked (empty string / read failure ⇒ None ⇒ default resolver).
|
||||
let decoder = env
|
||||
.get_string(&decoder_name)
|
||||
.ok()
|
||||
.map(String::from)
|
||||
.filter(|s| !s.is_empty());
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let mut guard = h.video.lock().unwrap();
|
||||
@@ -48,13 +65,67 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
let client = h.client.clone();
|
||||
let sd = shutdown.clone();
|
||||
let st = h.stats.clone(); // session-lifetime stats (gate survives surface recreate)
|
||||
let opts = crate::decode::DecodeOptions {
|
||||
decoder_name: decoder,
|
||||
ll_feature: ll_feature != 0,
|
||||
low_latency_mode: low_latency_mode != 0,
|
||||
is_tv: is_tv != 0,
|
||||
};
|
||||
let join = std::thread::Builder::new()
|
||||
.name("pf-decode".into())
|
||||
.spawn(move || crate::decode::run(client, window, sd, st))
|
||||
.spawn(move || crate::decode::run(client, window, sd, st, opts))
|
||||
.ok();
|
||||
*guard = Some(VideoThread { shutdown, join });
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoMime(handle): String` — the MediaCodec MIME for the codec the host
|
||||
/// resolved (`"video/hevc"` / `"video/avc"` / `"video/av01"`), so Kotlin can rank `MediaCodecList`
|
||||
/// decoders for it before calling [`Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo`].
|
||||
/// Empty string on a `0` handle. Cheap; safe on the UI thread.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoMime<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
handle: jlong,
|
||||
) -> jstring {
|
||||
jni_guard(std::ptr::null_mut(), || {
|
||||
if handle == 0 {
|
||||
return std::ptr::null_mut();
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
match env.new_string(crate::decode::codec_mime(h.client.codec)) {
|
||||
Ok(s) => s.into_raw(),
|
||||
Err(_) => std::ptr::null_mut(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoDecoderLabel(handle): String` — the resolved decoder identity for the
|
||||
/// HUD, e.g. `c2.qti.avc.decoder · low-latency`, or `""` before the decode thread has resolved one.
|
||||
/// One-shot (the decoder is fixed for the session); poll once after the HUD appears. Not
|
||||
/// android-gated — pure `jni` + a lock, so it links on the host build too (Kotlin only calls it on
|
||||
/// device).
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoDecoderLabel<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
handle: jlong,
|
||||
) -> jstring {
|
||||
jni_guard(std::ptr::null_mut(), || {
|
||||
if handle == 0 {
|
||||
return std::ptr::null_mut();
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
match env.new_string(h.stats.decoder_label()) {
|
||||
Ok(s) => s.into_raw(),
|
||||
Err(_) => std::ptr::null_mut(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeStopVideo(handle)` — stop + join the decode thread (without closing the
|
||||
/// session). No-op on `0`.
|
||||
#[no_mangle]
|
||||
@@ -72,14 +143,28 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD.
|
||||
/// Returns 14 doubles
|
||||
/// `[fps, mbps, latP50Ms, latP95Ms, latValid, skewCorrected, width, height, refreshHz, framesDropped,
|
||||
/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`
|
||||
/// (the two flags are 1.0/0.0; the trailing four describe the negotiated video feed — see below), 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).
|
||||
/// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD
|
||||
/// (unified stats spec, `design/stats-unification.md`). Returns 26 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]`
|
||||
/// (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
|
||||
/// are the Phase-2 split of the `host+network` term from the per-AU 0xCF host timings — `host` =
|
||||
/// the host's capture→sent, `network` = the remainder — both 0.0 when no timing matched this
|
||||
/// window, i.e. an old host; 18–21 are the spec's per-window line-4 counters — `lost` =
|
||||
/// unrecoverable drops this window, `skipped` = client newest-wins/pacing drops, `fec` = shards
|
||||
/// recovered, `frames` = AUs received, so the HUD can compute `lost/(frames+lost)` — index 9 stays
|
||||
/// the cumulative session total for older readers; 22–25 are the `display` stage from the
|
||||
/// 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.
|
||||
/// 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).
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
env: JNIEnv,
|
||||
@@ -95,14 +180,16 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
if h.video.lock().unwrap().is_none() {
|
||||
return std::ptr::null_mut(); // not streaming → no stats
|
||||
}
|
||||
let snap = h.stats.drain();
|
||||
let snap = h
|
||||
.stats
|
||||
.drain(h.client.frames_dropped(), h.client.fec_recovered_shards());
|
||||
let mode = h.client.mode();
|
||||
let color = h.client.color;
|
||||
let buf: [f64; 14] = [
|
||||
let buf: [f64; 26] = [
|
||||
snap.fps,
|
||||
snap.mbps,
|
||||
snap.lat_p50_ms,
|
||||
snap.lat_p95_ms,
|
||||
snap.e2e_p50_ms,
|
||||
snap.e2e_p95_ms,
|
||||
if snap.lat_valid { 1.0 } else { 0.0 },
|
||||
if snap.skew_corrected { 1.0 } else { 0.0 },
|
||||
mode.width as f64,
|
||||
@@ -117,6 +204,28 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
color.primaries as f64,
|
||||
color.transfer as f64,
|
||||
h.client.chroma_format as f64,
|
||||
// Stage p50s tiling the end-to-end headline (appended to keep 0–13 index-compatible).
|
||||
snap.hostnet_p50_ms,
|
||||
snap.decode_p50_ms,
|
||||
// Phase-2 host/network split of the `host+network` stage (0xCF host timings): 0.0
|
||||
// when no timing matched this window (old host) — the HUD keeps the combined term.
|
||||
snap.host_p50_ms,
|
||||
snap.net_p50_ms,
|
||||
// Spec line-4 counters, per-window: lost (unrecoverable drops), skipped (client
|
||||
// newest-wins/pacing drops), FEC shards recovered, and the received-AU count so the
|
||||
// HUD computes the loss percentage `lost/(frames+lost)` exactly.
|
||||
snap.lost as f64,
|
||||
snap.skipped as f64,
|
||||
snap.fec as f64,
|
||||
snap.frames as f64,
|
||||
// `display` stage (OnFrameRendered render timestamps): validity flag, the
|
||||
// decoded→displayed stage p50, and the directly-measured capture→displayed headline
|
||||
// pair that supersedes 2/3 whenever the flag is set (spec: the equation always tiles
|
||||
// the headline interval, so endpoint and terms move together).
|
||||
if snap.disp_valid { 1.0 } else { 0.0 },
|
||||
snap.display_p50_ms,
|
||||
snap.e2e_disp_p50_ms,
|
||||
snap.e2e_disp_p95_ms,
|
||||
];
|
||||
let arr = match env.new_double_array(buf.len() as jsize) {
|
||||
Ok(a) => a,
|
||||
@@ -145,19 +254,28 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoSta
|
||||
if handle != 0 {
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.stats.set_enabled(enabled != 0);
|
||||
// The current cumulative counters seed the window baselines, so the first snapshot's
|
||||
// `lost`/`FEC` cover only time the HUD was actually up.
|
||||
h.stats.set_enabled(
|
||||
enabled != 0,
|
||||
h.client.frames_dropped(),
|
||||
h.client.fec_recovered_shards(),
|
||||
);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeStartAudio(handle)` — start the Opus→AAudio playback thread. No-op if already
|
||||
/// started or on a `0` handle. Best-effort: a failure leaves video streaming.
|
||||
/// `NativeBridge.nativeStartAudio(handle, lowLatencyMode)` — start the Opus→AAudio playback thread.
|
||||
/// `lowLatencyMode` (the experimental toggle) tags the stream usage=Game for the HAL's game-audio
|
||||
/// routing. No-op if already started or on a `0` handle. Best-effort: a failure leaves video
|
||||
/// streaming.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
low_latency_mode: jboolean,
|
||||
) {
|
||||
if handle == 0 {
|
||||
return;
|
||||
@@ -168,7 +286,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
|
||||
if guard.is_some() {
|
||||
return; // already playing
|
||||
}
|
||||
match crate::audio::AudioPlayback::start(h.client.clone()) {
|
||||
match crate::audio::AudioPlayback::start(h.client.clone(), low_latency_mode != 0) {
|
||||
Some(p) => *guard = Some(p),
|
||||
None => log::error!("nativeStartAudio: playback init failed (video unaffected)"),
|
||||
}
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
//! Live decode stats for the on-stream HUD (mirrors the Apple client's stats overlay): FPS,
|
||||
//! receive throughput, and capture→client-receipt latency (p50/p95). The decode thread is the sole
|
||||
//! writer (`note` per access unit); the JNI accessor `nativeVideoStats` drains a snapshot ~1 Hz and
|
||||
//! resets the window. Sampling is gated on the HUD actually being visible (`set_enabled`, driven by
|
||||
//! `nativeSetVideoStatsEnabled`) so the hidden steady state costs one relaxed atomic load per frame.
|
||||
//! Live decode stats for the on-stream HUD, following the unified stats spec
|
||||
//! (`design/stats-unification.md`): FPS, receive throughput, and the full stage split — headline
|
||||
//! `end-to-end` = capture→displayed (p50/p95, measured directly from the `OnFrameRendered` render
|
||||
//! timestamps — `note_displayed`) tiled by `host+network` = capture→received, `decode` =
|
||||
//! received→decoded, and `display` = decoded→displayed (stage p50s). When the platform delivers no
|
||||
//! render callbacks (allowed under load; `disp_valid` false), the HUD falls back to the v1
|
||||
//! capture→decoded headline without the `display` term. When the host emits per-AU 0xCF host
|
||||
//! timings, the `host+network` term further splits into `host` + `network` (Phase 2,
|
||||
//! `note_host_split`); an old host emits none and the combined term stands. The spec's line-4 counters are per-window too:
|
||||
//! `lost` / `FEC` are windowed here from the connector's cumulative counters (the caller passes the
|
||||
//! current totals into `set_enabled`/`drain`), `skipped` counts the client's own newest-wins drops
|
||||
//! (`note_skipped`). The decode thread is the sole writer
|
||||
//! (`note_received` per access unit at receipt, `note_decoded` per decoder output buffer); the JNI
|
||||
//! accessor `nativeVideoStats` drains a snapshot ~1 Hz and resets the window. Sampling is gated on
|
||||
//! the HUD actually being visible (`set_enabled`, driven by `nativeSetVideoStatsEnabled`) so the
|
||||
//! hidden steady state costs one relaxed atomic load per frame.
|
||||
//! Pure `std` so it compiles on the host build too (the decode thread is android-only, but
|
||||
//! `SessionHandle` holds the shared handle unconditionally).
|
||||
|
||||
@@ -13,50 +24,136 @@ use std::time::Instant;
|
||||
/// Rolling per-window accumulator. Rates are computed over the actual elapsed wall-time at drain
|
||||
/// (robust to poll jitter), so a poll that lands at 0.9 s or 1.1 s still reports the right FPS.
|
||||
pub struct VideoStats {
|
||||
/// HUD gate: `note` runs on the per-frame decode path, so while the overlay is hidden it (and
|
||||
/// the caller's latency computation — see `enabled`) early-outs on this flag alone. Off until
|
||||
/// Kotlin shows the HUD.
|
||||
/// HUD gate: the samplers run on the per-frame decode path, so while the overlay is hidden
|
||||
/// they (and the caller's latency computation — see `enabled`) early-out on this flag alone.
|
||||
/// Off until Kotlin shows the HUD.
|
||||
enabled: 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`.
|
||||
/// Separate from `inner` (never touched per-frame) so naming it costs nothing on the hot path.
|
||||
decoder: Mutex<Option<DecoderInfo>>,
|
||||
inner: Mutex<Inner>,
|
||||
}
|
||||
|
||||
/// The chosen decoder's identity, surfaced on the stats HUD so before/after latency comparisons
|
||||
/// name the codec that produced them.
|
||||
struct DecoderInfo {
|
||||
name: String,
|
||||
low_latency: bool,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
window_start: Instant,
|
||||
frames: u64,
|
||||
bytes: u64,
|
||||
/// capture→client-receipt latency samples for this window, in microseconds.
|
||||
lat_us: Vec<u64>,
|
||||
/// `end-to-end` = capture→decoded latency samples for this window, in microseconds
|
||||
/// (skew-corrected clock base).
|
||||
e2e_us: Vec<u64>,
|
||||
/// `host+network` stage = capture→received samples, in microseconds (skew-corrected).
|
||||
hostnet_us: Vec<u64>,
|
||||
/// Phase-2 split of `host+network` (design/stats-unification.md Phase 2), fed only when the
|
||||
/// host emits per-AU 0xCF timings: `host` = the host's own capture→sent duration, µs.
|
||||
host_us: Vec<u64>,
|
||||
/// The matching `network` term, µs: capture→received minus the host's capture→sent
|
||||
/// (wire + reassembly). Always pushed in lockstep with `host_us`.
|
||||
net_us: Vec<u64>,
|
||||
/// `decode` stage = received→decoded samples, in microseconds (client-local, single clock).
|
||||
decode_us: Vec<u64>,
|
||||
/// `display` stage = decoded→displayed samples, in microseconds (client-local, single clock),
|
||||
/// from the `OnFrameRendered` render timestamps. Empty when the platform delivers no render
|
||||
/// callbacks — the HUD then drops the term and the headline endpoint moves back to `decoded`.
|
||||
display_us: Vec<u64>,
|
||||
/// `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>,
|
||||
/// 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,
|
||||
/// 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).
|
||||
last_dropped_total: u64,
|
||||
last_fec_total: u64,
|
||||
/// Whether the host answered the clock-skew handshake (latency is cross-machine valid).
|
||||
skew_corrected: bool,
|
||||
}
|
||||
|
||||
/// A drained, computed view of one window. `lat_valid` is false when no in-range latency sample
|
||||
/// landed (then p50/p95 are 0 and the HUD hides the latency line, exactly like the Apple client).
|
||||
/// A drained, computed view of one window. `lat_valid` is false when no in-range end-to-end sample
|
||||
/// landed (then the latency figures are 0 and the HUD hides the latency lines, exactly like the
|
||||
/// Apple client).
|
||||
pub struct Snapshot {
|
||||
pub fps: f64,
|
||||
pub mbps: f64,
|
||||
pub lat_p50_ms: f64,
|
||||
pub lat_p95_ms: f64,
|
||||
/// Headline `end-to-end` (capture→decoded) percentiles, ms — the fallback headline when no
|
||||
/// render callback landed this window (`disp_valid` false).
|
||||
pub e2e_p50_ms: f64,
|
||||
pub e2e_p95_ms: f64,
|
||||
/// The full headline: `end-to-end` = capture→displayed percentiles, ms, measured directly from
|
||||
/// the render timestamps. Meaningful only when `disp_valid`.
|
||||
pub e2e_disp_p50_ms: f64,
|
||||
pub e2e_disp_p95_ms: f64,
|
||||
/// Stage p50s (ms): `host+network` (capture→received), `decode` (received→decoded), and
|
||||
/// `display` (decoded→displayed; 0.0 when `disp_valid` is false — the HUD drops the term).
|
||||
pub hostnet_p50_ms: f64,
|
||||
pub decode_p50_ms: f64,
|
||||
pub display_p50_ms: f64,
|
||||
/// 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,
|
||||
/// 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,
|
||||
pub net_p50_ms: f64,
|
||||
pub lat_valid: bool,
|
||||
pub skew_corrected: bool,
|
||||
/// Access units received this window (the count behind `fps`) — lets the HUD compute the
|
||||
/// spec's loss percentage `lost / (received + lost)` exactly.
|
||||
pub frames: u64,
|
||||
/// Unrecoverable network frame drops this window (spec `lost`, windowed from the
|
||||
/// session-cumulative connector counter).
|
||||
pub lost: u64,
|
||||
/// Client-side newest-wins/pacing drops this window (spec `skipped`).
|
||||
pub skipped: u64,
|
||||
/// FEC shards recovered this window (spec `FEC`, windowed from the cumulative counter).
|
||||
pub fec: u64,
|
||||
}
|
||||
|
||||
/// Percentile over a sorted-in-place µs sample vec, in ms. 0.0 when empty.
|
||||
fn pctl_ms(sorted_us: &[u64], p: f64) -> f64 {
|
||||
if sorted_us.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let n = sorted_us.len();
|
||||
sorted_us[((n as f64 * p) as usize).min(n - 1)] as f64 / 1000.0
|
||||
}
|
||||
|
||||
impl VideoStats {
|
||||
pub fn new() -> VideoStats {
|
||||
VideoStats {
|
||||
enabled: AtomicBool::new(false),
|
||||
decoder: Mutex::new(None),
|
||||
inner: Mutex::new(Inner {
|
||||
window_start: Instant::now(),
|
||||
frames: 0,
|
||||
bytes: 0,
|
||||
lat_us: Vec::with_capacity(256),
|
||||
e2e_us: Vec::with_capacity(256),
|
||||
hostnet_us: Vec::with_capacity(256),
|
||||
host_us: Vec::with_capacity(256),
|
||||
net_us: Vec::with_capacity(256),
|
||||
decode_us: Vec::with_capacity(256),
|
||||
display_us: Vec::with_capacity(256),
|
||||
e2e_disp_us: Vec::with_capacity(256),
|
||||
skipped: 0,
|
||||
last_dropped_total: 0,
|
||||
last_fec_total: 0,
|
||||
skew_corrected: false,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the HUD wants samples. The decode thread checks this BEFORE building a latency
|
||||
/// sample, so the per-frame wall-clock read is skipped too while hidden.
|
||||
/// sample, so the per-frame wall-clock reads are skipped too while hidden.
|
||||
// Read only by the android-only decode thread; unreferenced on the host build — expected.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub fn enabled(&self) -> bool {
|
||||
@@ -64,8 +161,10 @@ impl VideoStats {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn set_enabled(&self, on: bool) {
|
||||
/// 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
|
||||
/// windowing baselines so the first snapshot's `lost` / `FEC` cover only time the HUD was up.
|
||||
pub fn set_enabled(&self, on: bool, dropped_total: u64, fec_total: u64) {
|
||||
let was = self.enabled.swap(on, Ordering::Relaxed);
|
||||
if on && !was {
|
||||
let mut g = self
|
||||
@@ -75,18 +174,58 @@ impl VideoStats {
|
||||
g.window_start = Instant::now();
|
||||
g.frames = 0;
|
||||
g.bytes = 0;
|
||||
g.lat_us.clear();
|
||||
g.e2e_us.clear();
|
||||
g.hostnet_us.clear();
|
||||
g.host_us.clear();
|
||||
g.net_us.clear();
|
||||
g.decode_us.clear();
|
||||
g.display_us.clear();
|
||||
g.e2e_disp_us.clear();
|
||||
g.skipped = 0;
|
||||
g.last_dropped_total = dropped_total;
|
||||
g.last_fec_total = fec_total;
|
||||
}
|
||||
}
|
||||
|
||||
/// Record one decoded access unit: its wire size and (if in range) its capture→client latency.
|
||||
/// Record the resolved decoder identity for the HUD — the codec's real `AMediaCodec` name and
|
||||
/// whether it reported `FEATURE_LowLatency`. Called once from the decode thread right after the
|
||||
/// codec is created (before `configure`), overwriting any prior value on a surface recreate.
|
||||
// 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_decoder(&self, name: &str, low_latency: bool) {
|
||||
let mut g = self
|
||||
.decoder
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
*g = Some(DecoderInfo {
|
||||
name: name.to_owned(),
|
||||
low_latency,
|
||||
});
|
||||
}
|
||||
|
||||
/// The decoder label for the HUD, e.g. `c2.qti.avc.decoder · low-latency`, or `""` before the
|
||||
/// decode thread has resolved one. Cheap (a lock + a string build); safe on the UI thread.
|
||||
pub fn decoder_label(&self) -> String {
|
||||
let g = self
|
||||
.decoder
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
match &*g {
|
||||
Some(d) if d.low_latency => format!("{} · low-latency", d.name),
|
||||
Some(d) => d.name.clone(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record one received access unit: its wire size and (if in range) its capture→received
|
||||
/// `host+network` stage sample. Receipt is the fps/goodput counting point per the spec.
|
||||
// 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(&self, bytes: usize, lat_us: Option<u64>, skew_corrected: bool) {
|
||||
pub fn note_received(&self, bytes: usize, hostnet_us: Option<u64>, skew_corrected: bool) {
|
||||
if !self.enabled.load(Ordering::Relaxed) {
|
||||
return; // HUD hidden — skip the lock (the caller already skipped the clock read)
|
||||
}
|
||||
// Poison-proof: `note` runs per-frame on the decode thread, which has no catch_unwind —
|
||||
// Poison-proof: this runs per-frame on the decode thread, which has no catch_unwind —
|
||||
// a panic elsewhere must not turn every later lock into a second panic (the counters
|
||||
// stay consistent regardless).
|
||||
let mut g = self
|
||||
@@ -96,14 +235,98 @@ impl VideoStats {
|
||||
g.frames += 1;
|
||||
g.bytes += bytes as u64;
|
||||
g.skew_corrected = skew_corrected;
|
||||
if let Some(l) = lat_us {
|
||||
g.lat_us.push(l);
|
||||
if let Some(l) = hostnet_us {
|
||||
g.hostnet_us.push(l);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record one matched host/network split sample (Phase 2): the host's reported capture→sent
|
||||
/// duration and our capture→received minus it, both µs — one pair per AU whose 0xCF host
|
||||
/// timing arrived and matched by pts. An old host emits none, leaving the vecs empty and the
|
||||
/// snapshot p50s at 0 (HUD keeps the combined `host+network` term).
|
||||
// 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_host_split(&self, host_us: u64, net_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.host_us.push(host_us);
|
||||
g.net_us.push(net_us);
|
||||
}
|
||||
|
||||
/// Record client-side frame skips (spec `skipped`): decoded output buffers released without
|
||||
/// rendering under the newest-wins policy, or parked AUs dropped on queue overflow.
|
||||
// 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(&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;
|
||||
}
|
||||
|
||||
/// 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).
|
||||
// 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_decoded(&self, e2e_us: Option<u64>, decode_us: Option<u64>) {
|
||||
if !self.enabled.load(Ordering::Relaxed) {
|
||||
return; // HUD hidden — skip the lock (the caller already skipped the clock read)
|
||||
}
|
||||
// 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(l) = e2e_us {
|
||||
g.e2e_us.push(l);
|
||||
}
|
||||
if let Some(l) = decode_us {
|
||||
g.decode_us.push(l);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
// 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>) {
|
||||
if !self.enabled.load(Ordering::Relaxed) {
|
||||
return; // HUD hidden — skip the lock (the callback already skipped the clock reads)
|
||||
}
|
||||
// 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(l) = e2e_us {
|
||||
g.e2e_disp_us.push(l);
|
||||
}
|
||||
if let Some(l) = display_us {
|
||||
g.display_us.push(l);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the window's rates + latency percentiles, then reset for the next window.
|
||||
pub fn drain(&self) -> Snapshot {
|
||||
// Poison-proof for the same reason as `note` — a poisoned window still drains fine.
|
||||
/// `dropped_total` / `fec_total` are the connector's session-cumulative counters now; the
|
||||
/// snapshot's `lost` / `fec` are their deltas since the last drain (or the enabling show).
|
||||
pub fn drain(&self, dropped_total: u64, fec_total: u64) -> Snapshot {
|
||||
// Poison-proof for the same reason as `note_received` — a poisoned window still drains
|
||||
// fine.
|
||||
let mut g = self
|
||||
.inner
|
||||
.lock()
|
||||
@@ -111,26 +334,46 @@ impl VideoStats {
|
||||
let elapsed = g.window_start.elapsed().as_secs_f64().max(1e-3);
|
||||
let fps = g.frames as f64 / elapsed;
|
||||
let mbps = g.bytes as f64 * 8.0 / 1_000_000.0 / elapsed;
|
||||
let (p50, p95, valid) = if g.lat_us.is_empty() {
|
||||
(0.0, 0.0, false)
|
||||
} else {
|
||||
g.lat_us.sort_unstable();
|
||||
let n = g.lat_us.len();
|
||||
let at = |p: f64| g.lat_us[((n as f64 * p) as usize).min(n - 1)] as f64 / 1000.0;
|
||||
(at(0.50), at(0.95), true)
|
||||
g.e2e_us.sort_unstable();
|
||||
g.hostnet_us.sort_unstable();
|
||||
g.host_us.sort_unstable();
|
||||
g.net_us.sort_unstable();
|
||||
g.decode_us.sort_unstable();
|
||||
g.display_us.sort_unstable();
|
||||
g.e2e_disp_us.sort_unstable();
|
||||
let snap = Snapshot {
|
||||
fps,
|
||||
mbps,
|
||||
e2e_p50_ms: pctl_ms(&g.e2e_us, 0.50),
|
||||
e2e_p95_ms: pctl_ms(&g.e2e_us, 0.95),
|
||||
e2e_disp_p50_ms: pctl_ms(&g.e2e_disp_us, 0.50),
|
||||
e2e_disp_p95_ms: pctl_ms(&g.e2e_disp_us, 0.95),
|
||||
hostnet_p50_ms: pctl_ms(&g.hostnet_us, 0.50),
|
||||
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(),
|
||||
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(),
|
||||
skew_corrected: g.skew_corrected,
|
||||
frames: g.frames,
|
||||
lost: dropped_total.saturating_sub(g.last_dropped_total),
|
||||
skipped: g.skipped,
|
||||
fec: fec_total.saturating_sub(g.last_fec_total),
|
||||
};
|
||||
let skew = g.skew_corrected;
|
||||
g.window_start = Instant::now();
|
||||
g.frames = 0;
|
||||
g.bytes = 0;
|
||||
g.lat_us.clear();
|
||||
Snapshot {
|
||||
fps,
|
||||
mbps,
|
||||
lat_p50_ms: p50,
|
||||
lat_p95_ms: p95,
|
||||
lat_valid: valid,
|
||||
skew_corrected: skew,
|
||||
}
|
||||
g.e2e_us.clear();
|
||||
g.hostnet_us.clear();
|
||||
g.host_us.clear();
|
||||
g.net_us.clear();
|
||||
g.decode_us.clear();
|
||||
g.display_us.clear();
|
||||
g.e2e_disp_us.clear();
|
||||
g.skipped = 0;
|
||||
g.last_dropped_total = dropped_total;
|
||||
g.last_fec_total = fec_total;
|
||||
snap
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
//! JNI seam for Wake-on-LAN: parse the stored MAC strings and hand them to the shared core sender
|
||||
//! (`punktfunk_core::wol`). Like [`crate::discovery`], this takes no session handle — a sleeping
|
||||
//! host has no ARP entry, so the broadcast the core sends is what wakes it, and Kotlin calls this
|
||||
//! just before connecting to an offline saved host.
|
||||
|
||||
use jni::objects::{JObject, JString};
|
||||
use jni::JNIEnv;
|
||||
|
||||
/// `NativeBridge.nativeWakeOnLan(macsCsv: String, lastIp: String): Boolean` — send a Wake-on-LAN
|
||||
/// magic packet. `macsCsv` is comma-separated MACs (`aa:bb:..,cc:dd:..`, learned from the host's
|
||||
/// mDNS `mac` TXT while it was online); `lastIp` is the host's last-known IPv4 (or empty).
|
||||
/// Returns true if at least one datagram went out.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeWakeOnLan<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
macs_csv: JString<'local>,
|
||||
last_ip: JString<'local>,
|
||||
) -> jni::sys::jboolean {
|
||||
let macs_csv: String = match env.get_string(&macs_csv) {
|
||||
Ok(s) => s.into(),
|
||||
Err(_) => return 0,
|
||||
};
|
||||
let last_ip: String = env
|
||||
.get_string(&last_ip)
|
||||
.map(Into::<String>::into)
|
||||
.unwrap_or_default();
|
||||
let macs: Vec<[u8; 6]> = macs_csv
|
||||
.split(',')
|
||||
.filter_map(|s| punktfunk_core::wol::parse_mac(s.trim()))
|
||||
.collect();
|
||||
if macs.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let ip = last_ip.trim().parse::<std::net::Ipv4Addr>().ok();
|
||||
match punktfunk_core::wol::send_magic_packet(&macs, ip) {
|
||||
Ok(()) => 1,
|
||||
Err(_) => 0,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
/target
|
||||
**/*.rs.bk
|
||||
Cargo.lock
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
# Unreleased
|
||||
|
||||
# 0.9.0 (2024-04-26)
|
||||
|
||||
- Move `MediaFormat` from `media::media_codec` to its own `media::media_format` module. (#442)
|
||||
- media_format: Expose `MediaFormat::copy()` and `MediaFormat::clear()` from API level 29. (#449)
|
||||
- **Breaking:** media_format: Mark all `fn set_*()` and `fn str()` as taking `self` by `&mut`. (#452)
|
||||
- **Breaking:** Require all `dyn Fn*` types to implement `Send` when the FFI implementation invokes them on a separate thread: (#455)
|
||||
- `audio::AudioStreamDataCallback`;
|
||||
- `audio::AudioStreamErrorCallback`;
|
||||
- `media::image_reader::BufferRemovedListener`;
|
||||
- `media::image_reader::ImageListener`;
|
||||
- `media::media_codec::ErrorCallback`;
|
||||
- `media::media_codec::FormatChangedCallback`;
|
||||
- `media::media_codec::InputAvailableCallback`;
|
||||
- `media::media_codec::OutputAvailableCallback`.
|
||||
- Drop previous `Box`ed callbacks _after_ registering new ones, instead of before. (#455)
|
||||
- input_queue: Add `from_java()` constructor, available since API level 33. (#456)
|
||||
- event: Add `from_java()` constructors to `KeyEvent` and `MotionEvent`, available since API level 31. (#456)
|
||||
- **Breaking:** image_reader: Special-case return statuses in `Image`-acquire functions. (#457)
|
||||
- **Breaking:** image_reader: Mark `ImageReader::acquire_latest_image_async()` `unsafe` to match the safety requirements on `ImageReader::acquire_next_image_async()`. (#457)
|
||||
- event: Implement `SourceClass` `bitflag` and provide `Source::class()` getter. (#458)
|
||||
- Ensure all `bitflags` implementations consider all (including unknown) bits in negation and `all()`. (#458)
|
||||
- **Breaking:** Mark all enums as `non_exhaustive` and fix `repr` types. (#459)
|
||||
- **Breaking:** native_window: Remove redundant `TRANSFORM_` prefix from `NativeWindowTransform` variants. (#460)
|
||||
- **Breaking:** hardware_buffer: Convert `HardwareBufferUsage` to `bitflags`. (#461)
|
||||
- bitmap: Guard `BitmapCompressError` behind missing `api-level-30` feature. (#462)
|
||||
- native_window: Require linking against `libnativewindow` for most API >= 26 functions. (#465)
|
||||
- **Breaking:** audio: Merge `AudioResult` variant enum into `AudioError`. (#467)
|
||||
- data_space: Add missing `DataSpaceRange::Unspecified` variant. (#468)
|
||||
- **Breaking:** looper: Require `Send` marker when adding fd event callbacks on `ForeignLooper`. (#469)
|
||||
- **Breaking:** Upgrade to [`ndk-sys 0.6.0`](../ndk-sys/CHANGELOG.md#060-2024-04-26). (#472)
|
||||
|
||||
# 0.8.0 (2023-10-15)
|
||||
|
||||
- event: Add `tool_type` getter for `Pointer`. (#323)
|
||||
- input_queue: Allow any non-zero return code from `pre_dispatch()` again, as per documentation. (#325)
|
||||
- asset: Use entire asset length when mapping buffer. (#387)
|
||||
- Bump MSRV to 1.66 for `raw-window-handle 0.5.1`, `num_enum`'s `catch_all` with arbitrary enum discriminants. (#388, #431)
|
||||
- Bump optional `jni` dependency for doctest example from `0.19` to `0.21`. (#390)
|
||||
- **Breaking:** Upgrade to [`ndk-sys 0.5.0`](../ndk-sys/CHANGELOG.md#050-2023-10-15). (#370)
|
||||
- **Breaking:** Upgrade `bitflags` crate from `1` to `2`. (#394)
|
||||
- bitmap: Add `try_format()` to `AndroidBitmapInfo` to handle unexpected formats without panicking. (#395)
|
||||
- Add `Font` bindings. (#397)
|
||||
- **Breaking:** Upgrade `num_enum` crate from `0.5.1` to `0.7`. (#398, #419)
|
||||
- **Breaking:** Renamed, moved and flattened "`media`" error types and helpers to a new `media_error` module. (#399, #432)
|
||||
- **Breaking:** media_codec: Wrap common dequeued-buffer status codes in enum. (#401)
|
||||
- **Breaking:** media_codec: Return `MaybeUninit` bytes in `buffer_mut()`. (#403)
|
||||
- native_window: Add `lock()` to blit raw pixel data. (#404)
|
||||
- hardware_buffer_format: Add `YCbCr_P010` and `R8_UNORM` variants. (#405)
|
||||
- **Breaking:** hardware_buffer_format: Add catch-all variant. (#407)
|
||||
- asset: Add missing `is_allocated()` and `open_file_descriptor()` methods. (#409)
|
||||
- **Breaking:** media_codec: Add support for asynchronous notification callbacks. (#410)
|
||||
- Add panic guards to callbacks. (#412)
|
||||
- looper: Add `remove_fd()` to unregister events/callbacks for a file descriptor. (#416)
|
||||
- **Breaking:** Use `BorrowedFd` and `OwnedFd` to clarify possible ownership transitions. (#417)
|
||||
- **Breaking:** Upgrade to [`ndk-sys 0.5.0`](../ndk-sys/CHANGELOG.md#050-2023-10-15). (#420)
|
||||
- Add bindings for `sync.h`. (#423)
|
||||
- **Breaking:** bitmap: Provide detailed implementation for `AndroidBitmapInfoFlags`. (#424)
|
||||
- native_window: Add `set_buffers_transform()`, `try_allocate_buffers()` and `set_frame_rate*()`. (#425)
|
||||
- Add bindings for `ASharedMemory`. (#427)
|
||||
- hardware_buffer: Add `id()` to retrieve a system-wide unique identifier for a `HardwareBuffer`. (#428)
|
||||
- **Breaking:** bitmap: Strip `Android` prefix from structs and enums, and `Bitmap` from `Result`. (#430)
|
||||
- **Breaking:** `raw-window-handle 0.5` support is now behind an _optional_ `rwh_05` crate feature and `raw-window-handle` `0.4` and `0.6` support is provided via the new `rwh_04` and (default-enabled) `rwh_06` crate features. (#434)
|
||||
- **Breaking:** looper: Provide `event` value to file descriptor poll callback. (#435)
|
||||
- **Breaking:** `HardwareBufferFormat` is no longer exported from `hardware_buffer` and `native_window`, and can only be reached through the `hardware_buffer_format` module. (#436)
|
||||
- **Breaking:** `get_` prefixes have been removed from all public functions in light of the [C-GETTER](https://rust-lang.github.io/api-guidelines/naming.html#getter-names-follow-rust-convention-c-getter) convention. (#437)
|
||||
- Add `DataSpace` type and relevant functions on `Bitmap` and `NativeWindow`. (#438)
|
||||
- bitmap: Add `Bitmap::compress()` and `Bitmap::compress_raw()` functions. (#440)
|
||||
- **Breaking:** Turn `BitmapError` into a `non_exhaustive` `enum`. (#440)
|
||||
- **Breaking:** audio: Rename `AudioErrorResult` to `AudioResult` and turn into a `non_exhaustive` `enum`. (#441)
|
||||
|
||||
# 0.7.0 (2022-07-24)
|
||||
|
||||
- hardware_buffer: Make `HardwareBuffer::as_ptr()` public for interop with Vulkan. (#213)
|
||||
- **Breaking:** `Configuration::country()` now returns `None` when the country is unset (akin to `Configuration::language()`). (#220)
|
||||
- Add `MediaCodec` and `MediaFormat` bindings. (#216)
|
||||
- **Breaking:** Upgrade to [`ndk-sys 0.4.0`](../ndk-sys/CHANGELOG.md#040-2022-07-24) and use new `enum` newtype wrappers. (#245)
|
||||
- native_window: Use `release`/`acquire` for `Drop` and `Clone` respectively. (#207)
|
||||
- **Breaking:** audio: Rename from `aaudio` to `audio` and drop `A` prefix. (#273)
|
||||
- Implement `HasRawWindowHandle` directly on `NativeWindow`. (#274, #319)
|
||||
- **Breaking:** native_activity: Replace `CStr` return types with `Path`. (#279)
|
||||
- native_window: Add `format()` getter and `set_buffers_geometry()` setter. (#276)
|
||||
- native_activity: Add `set_window_format()` setter. (#277)
|
||||
- native_activity: Add `set_window_flags()` to change window behavior. (#278)
|
||||
- Add `SurfaceTexture` bindings. (#267)
|
||||
- Improve library and structure documentation, linking back to the NDK docs more rigorously. (#290)
|
||||
- **Breaking:** input_queue: `get_event()` now returns a `Result` with `std::io::Error`; `InputQueueError` has been removed. (#292)
|
||||
- **Breaking:** input_queue: `has_events()` now returns a `bool` directly without being wrapped in `Result`. (#294)
|
||||
- **Breaking:** hardware_buffer: `HardwareBufferError` has been removed and replaced with `std::io::Error` in return types. (#295)
|
||||
- Fixed `HardwareBuffer` leak on buffers returned from `AndroidBitmap::get_hardware_buffer()`. (#296)
|
||||
- Bump optional `jni` dependency for doctest example from `0.18` to `0.19`. (#300)
|
||||
- hardware_buffer: Made `HardwareBufferDesc` fields `pub`. (#313)
|
||||
- **Breaking:** Remove `hardware_buffer` and `trace` features in favour of using `api-level-26` or `api-level-23` directly. (#320)
|
||||
|
||||
# 0.6.0 (2022-01-05)
|
||||
|
||||
- **Breaking:** Upgrade to [`ndk-sys 0.3.0`](../ndk-sys/CHANGELOG.md#030-2022-01-05) and migrate to `jni-sys` types that it now directly uses in its bindings. (#209 / #214)
|
||||
|
||||
# 0.5.0 (2021-11-22)
|
||||
|
||||
- **Breaking:** Replace `add_fd_with_callback` `ident` with constant value `ALOOPER_POLL_CALLBACK`,
|
||||
as per <https://developer.android.com/ndk/reference/group/looper#alooper_addfd>.
|
||||
- **Breaking:** Accept unboxed closure in `add_fd_with_callback`.
|
||||
- aaudio: Replace "Added in" comments with missing `#[cfg(feature)]`.
|
||||
- aaudio: Add missing `fn get_allowed_capture_policy()`.
|
||||
- configuration: Add missing `api-level-30` feature to `fn screen_round()`.
|
||||
|
||||
# 0.4.0 (2021-08-02)
|
||||
|
||||
- **Breaking:** Model looper file descriptor events integer as `bitflags`.
|
||||
|
||||
# 0.3.0 (2021-01-30)
|
||||
|
||||
- **Breaking:** Looper `ident` not passed in `data` pointer anymore.
|
||||
`attach_looper` now only sets the `ident` field when attaching an
|
||||
`InputQueue` to a `ForeignLooper`.
|
||||
If you are relying on `Poll::Event::data` to tell event fd and
|
||||
input queue apart, please use `Poll::Event::ident` and the new
|
||||
constants introduced in `ndk-glue`!
|
||||
|
||||
# 0.2.1 (2020-10-15)
|
||||
|
||||
- Fix documentation build on docs.rs
|
||||
|
||||
# 0.2.0 (2020-09-15)
|
||||
|
||||
- **Breaking:** Updated to use [ndk-sys 0.2.0](../ndk-sys/CHANGELOG.md#020-2020-09-15)
|
||||
- Added `media` bindings
|
||||
- Added `bitmap` and `hardware_buffer` bindings
|
||||
- Added `aaudio` bindings
|
||||
- Fixed assets directory path to be relative to the manifest
|
||||
- Added `trace` feature for native tracing
|
||||
|
||||
# 0.1.0 (2020-04-22)
|
||||
|
||||
- Initial release! 🎉
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
|
||||
#
|
||||
# When uploading crates to the registry Cargo will automatically
|
||||
# "normalize" Cargo.toml files for maximal compatibility
|
||||
# with all versions of Cargo and also rewrite `path` dependencies
|
||||
# to registry (e.g., crates.io) dependencies.
|
||||
#
|
||||
# If you are reading this file be aware that the original Cargo.toml
|
||||
# will likely look very different (and much more reasonable).
|
||||
# See Cargo.toml.orig for the original contents.
|
||||
|
||||
[package]
|
||||
edition = "2021"
|
||||
rust-version = "1.66"
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
authors = ["The Rust Mobile contributors"]
|
||||
description = "Safe Rust bindings to the Android NDK"
|
||||
homepage = "https://github.com/rust-mobile/ndk"
|
||||
documentation = "https://docs.rs/ndk"
|
||||
readme = "README.md"
|
||||
keywords = [
|
||||
"android",
|
||||
"ndk",
|
||||
]
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/rust-mobile/ndk"
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
features = [
|
||||
"jni",
|
||||
"all",
|
||||
]
|
||||
rustdoc-args = [
|
||||
"--cfg",
|
||||
"docsrs",
|
||||
]
|
||||
targets = [
|
||||
"aarch64-linux-android",
|
||||
"armv7-linux-androideabi",
|
||||
"i686-linux-android",
|
||||
"x86_64-linux-android",
|
||||
]
|
||||
|
||||
[dependencies.bitflags]
|
||||
version = "2.4"
|
||||
|
||||
[dependencies.ffi]
|
||||
version = "0.6.0"
|
||||
package = "ndk-sys"
|
||||
|
||||
[dependencies.jni]
|
||||
version = "0.21"
|
||||
optional = true
|
||||
|
||||
[dependencies.jni-sys]
|
||||
version = "0.3"
|
||||
|
||||
[dependencies.log]
|
||||
version = "0.4.6"
|
||||
|
||||
[dependencies.num_enum]
|
||||
version = "0.7"
|
||||
|
||||
[dependencies.rwh_04]
|
||||
version = "0.4"
|
||||
optional = true
|
||||
package = "raw-window-handle"
|
||||
|
||||
[dependencies.rwh_05]
|
||||
version = "0.5"
|
||||
optional = true
|
||||
package = "raw-window-handle"
|
||||
|
||||
[dependencies.rwh_06]
|
||||
version = "0.6"
|
||||
optional = true
|
||||
package = "raw-window-handle"
|
||||
|
||||
[dependencies.thiserror]
|
||||
version = "1.0.23"
|
||||
|
||||
[dev-dependencies.libc]
|
||||
version = "0.2.3"
|
||||
|
||||
[features]
|
||||
all = [
|
||||
"audio",
|
||||
"bitmap",
|
||||
"media",
|
||||
"nativewindow",
|
||||
"sync",
|
||||
"api-level-33",
|
||||
"rwh_04",
|
||||
"rwh_05",
|
||||
"rwh_06",
|
||||
]
|
||||
api-level-23 = []
|
||||
api-level-24 = ["api-level-23"]
|
||||
api-level-25 = ["api-level-24"]
|
||||
api-level-26 = ["api-level-25"]
|
||||
api-level-27 = ["api-level-26"]
|
||||
api-level-28 = ["api-level-27"]
|
||||
api-level-29 = ["api-level-28"]
|
||||
api-level-30 = ["api-level-29"]
|
||||
api-level-31 = ["api-level-30"]
|
||||
api-level-32 = ["api-level-31"]
|
||||
api-level-33 = ["api-level-32"]
|
||||
audio = [
|
||||
"ffi/audio",
|
||||
"api-level-26",
|
||||
]
|
||||
bitmap = ["ffi/bitmap"]
|
||||
default = ["rwh_06"]
|
||||
media = ["ffi/media"]
|
||||
nativewindow = ["ffi/nativewindow"]
|
||||
sync = [
|
||||
"ffi/sync",
|
||||
"api-level-26",
|
||||
]
|
||||
test = [
|
||||
"ffi/test",
|
||||
"jni",
|
||||
"all",
|
||||
]
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
[](https://github.com/rust-mobile/ndk/actions/workflows/rust.yml)  
|
||||
|
||||
Rust bindings to the [Android NDK](https://developer.android.com/ndk)
|
||||
|
||||
Name | Description | Badges
|
||||
--- | --- | ---
|
||||
[`ndk-sys`](./ndk-sys) | Raw FFI bindings to the NDK | [](https://crates.io/crates/ndk-sys) [](https://docs.rs/ndk-sys) [](https://blog.rust-lang.org/2022/04/07/Rust-1.60.0.html)
|
||||
[`ndk`](./ndk) | Safe abstraction of the bindings | [](https://crates.io/crates/ndk) [](https://docs.rs/ndk) [](https://blog.rust-lang.org/2022/09/22/Rust-1.64.0.html)
|
||||
|
||||
See these [`ndk-examples`](https://github.com/rust-mobile/cargo-apk/tree/main/examples/examples) and these [`rust-android-examples`](https://github.com/rust-mobile/rust-android-examples) for examples using the NDK.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This repository was recently [modularized](https://github.com/rust-mobile/ndk/issues/372) and the following crates were split into separate repositories:
|
||||
>
|
||||
> Crate | New Location | Notes
|
||||
> ------|--------------|------
|
||||
> ndk-context | https://github.com/rust-mobile/ndk-context |
|
||||
> ndk-glue | https://github.com/rust-mobile/ndk-glue | ⛔ _deprecated_ - see [android-activity](https://github.com/rust-mobile/android-activity)
|
||||
> ndk-macro | https://github.com/rust-mobile/ndk-glue | ⛔ _deprecated_ - see [android-activity](https://github.com/rust-mobile/android-activity)
|
||||
> ndk-build | https://github.com/rust-mobile/cargo-apk | ⛔ _deprecated_ - see [xbuild](https://github.com/rust-mobile/xbuild)
|
||||
> cargo-apk | https://github.com/rust-mobile/cargo-apk | ⛔ _deprecated_ - see [xbuild](https://github.com/rust-mobile/xbuild)
|
||||
@@ -0,0 +1,14 @@
|
||||
# Vendored `ndk` 0.9.0 (patched)
|
||||
|
||||
Verbatim copy of the published `ndk` 0.9.0 crate (https://crates.io/crates/ndk,
|
||||
MIT OR Apache-2.0, © the rust-mobile contributors), wired in via `[patch.crates-io]`
|
||||
in the workspace root.
|
||||
|
||||
**The only change** is in `src/media/media_codec.rs`: `MediaCodec::as_ptr` is made
|
||||
`pub` (upstream keeps it private) so the Android client can register
|
||||
`AMediaCodec_setOnFrameRenderedCallback` through `ndk-sys` — the render-timestamp
|
||||
callback behind the HUD's `display` stage (punktfunk-planning: `stats-unification.md`), which the
|
||||
wrapper doesn't expose. Grep for `punktfunk vendored patch` to find it.
|
||||
|
||||
Drop this vendor copy when upstream exposes the raw pointer or a frame-rendered
|
||||
callback binding (tracked against https://github.com/rust-mobile/ndk).
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
//! Bindings for [`AAsset`], [`AAssetDir`] and [`AAssetManager`]
|
||||
//!
|
||||
//! [`AAsset`]: https://developer.android.com/ndk/reference/group/asset#aasset
|
||||
//! [`AAssetDir`]: https://developer.android.com/ndk/reference/group/asset#aassetdir
|
||||
//! [`AAssetManager`]: https://developer.android.com/ndk/reference/group/asset#aassetmanager
|
||||
|
||||
use std::{
|
||||
ffi::{CStr, CString},
|
||||
io,
|
||||
os::fd::{FromRawFd, OwnedFd},
|
||||
ptr::NonNull,
|
||||
};
|
||||
|
||||
/// A native [`AAssetManager *`]
|
||||
///
|
||||
/// [`AAssetManager *`]: https://developer.android.com/ndk/reference/group/asset#aassetmanager
|
||||
#[derive(Debug)]
|
||||
#[doc(alias = "AAssetManager")]
|
||||
pub struct AssetManager {
|
||||
ptr: NonNull<ffi::AAssetManager>,
|
||||
}
|
||||
|
||||
// AAssetManager is thread safe.
|
||||
// See https://developer.android.com/ndk/reference/group/asset#aassetmanager
|
||||
unsafe impl Send for AssetManager {}
|
||||
unsafe impl Sync for AssetManager {}
|
||||
|
||||
impl AssetManager {
|
||||
/// Create an `AssetManager` from a pointer
|
||||
///
|
||||
/// # Safety
|
||||
/// By calling this function, you assert that the pointer is a valid pointer to a native
|
||||
/// `AAssetManager`.
|
||||
pub unsafe fn from_ptr(ptr: NonNull<ffi::AAssetManager>) -> Self {
|
||||
Self { ptr }
|
||||
}
|
||||
|
||||
/// Returns the pointer to the native `AAssetManager`.
|
||||
pub fn ptr(&self) -> NonNull<ffi::AAssetManager> {
|
||||
self.ptr
|
||||
}
|
||||
|
||||
/// Open the asset. Returns [`None`] if opening the asset fails.
|
||||
///
|
||||
/// This currently always opens the asset in the streaming mode.
|
||||
#[doc(alias = "AAssetManager_open")]
|
||||
pub fn open(&self, filename: &CStr) -> Option<Asset> {
|
||||
unsafe {
|
||||
let ptr = ffi::AAssetManager_open(
|
||||
self.ptr.as_ptr(),
|
||||
filename.as_ptr(),
|
||||
ffi::AASSET_MODE_STREAMING as i32,
|
||||
);
|
||||
Some(Asset::from_ptr(NonNull::new(ptr)?))
|
||||
}
|
||||
}
|
||||
|
||||
/// Open an asset directory. Returns [`None`] if opening the directory fails.
|
||||
#[doc(alias = "AAssetManager_openDir")]
|
||||
pub fn open_dir(&self, filename: &CStr) -> Option<AssetDir> {
|
||||
unsafe {
|
||||
let ptr = ffi::AAssetManager_openDir(self.ptr.as_ptr(), filename.as_ptr());
|
||||
Some(AssetDir::from_ptr(NonNull::new(ptr)?))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A native [`AAssetDir *`]
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use std::ffi::CString;
|
||||
/// # use ndk::asset::AssetManager;
|
||||
/// # let asset_manager: AssetManager = unimplemented!();
|
||||
/// use std::io::Read;
|
||||
///
|
||||
/// let mut my_dir = asset_manager
|
||||
/// .open_dir(&CString::new("my_dir").unwrap())
|
||||
/// .expect("Could not open directory");
|
||||
///
|
||||
/// // Use it as an iterator
|
||||
/// let all_files = my_dir.collect::<Vec<CString>>();
|
||||
///
|
||||
/// // Reset the iterator
|
||||
/// my_dir.rewind();
|
||||
///
|
||||
/// // Use .with_next() to iterate without allocating `CString`s
|
||||
/// while let Some(asset) = my_dir.with_next(|cstr| asset_manager.open(cstr).unwrap()) {
|
||||
/// let mut text = String::new();
|
||||
/// asset.read_to_string(&mut text);
|
||||
/// // ...
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// [`AAssetDir *`]: https://developer.android.com/ndk/reference/group/asset#aassetdir
|
||||
#[derive(Debug)]
|
||||
#[doc(alias = "AAssetDir")]
|
||||
pub struct AssetDir {
|
||||
ptr: NonNull<ffi::AAssetDir>,
|
||||
}
|
||||
|
||||
// It's unclear if AAssetDir is thread safe.
|
||||
// However, AAsset is not, so there's a good chance that AAssetDir is not either.
|
||||
|
||||
impl Drop for AssetDir {
|
||||
#[doc(alias = "AAssetDir_close")]
|
||||
fn drop(&mut self) {
|
||||
unsafe { ffi::AAssetDir_close(self.ptr.as_ptr()) }
|
||||
}
|
||||
}
|
||||
|
||||
impl AssetDir {
|
||||
/// Construct an `AssetDir` from the native `AAssetDir *`. This gives ownership of the
|
||||
/// `AAssetDir *` to the `AssetDir`, which will handle closing the asset. Avoid using
|
||||
/// the pointer after calling this function.
|
||||
///
|
||||
/// # Safety
|
||||
/// By calling this function, you assert that it points to a valid native `AAssetDir`.
|
||||
pub unsafe fn from_ptr(ptr: NonNull<ffi::AAssetDir>) -> Self {
|
||||
Self { ptr }
|
||||
}
|
||||
|
||||
/// The corresponding native `AAssetDir *`
|
||||
pub fn ptr(&self) -> NonNull<ffi::AAssetDir> {
|
||||
self.ptr
|
||||
}
|
||||
|
||||
/// Get the next filename, if any, and process it. Like [`Iterator::next()`], but performs
|
||||
/// no additional allocation.
|
||||
///
|
||||
/// The filenames are in the correct format to be passed to [`AssetManager::open()`].
|
||||
#[doc(alias = "AAssetDir_getNextFileName")]
|
||||
pub fn with_next<T>(&mut self, f: impl for<'a> FnOnce(&'a CStr) -> T) -> Option<T> {
|
||||
unsafe {
|
||||
let next_name = ffi::AAssetDir_getNextFileName(self.ptr.as_ptr());
|
||||
if next_name.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(f(CStr::from_ptr(next_name)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset the iteration state
|
||||
#[doc(alias = "AAssetDir_rewind")]
|
||||
pub fn rewind(&mut self) {
|
||||
unsafe {
|
||||
ffi::AAssetDir_rewind(self.ptr.as_ptr());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for AssetDir {
|
||||
type Item = CString;
|
||||
|
||||
fn next(&mut self) -> Option<CString> {
|
||||
self.with_next(|cstr| cstr.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
/// A native [`AAsset *`], opened in streaming mode
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use std::ffi::CString;
|
||||
/// # use ndk::asset::AssetManager;
|
||||
/// # let asset_manager: AssetManager = unimplemented!();
|
||||
/// use std::io::Read;
|
||||
///
|
||||
/// let asset = asset_manager
|
||||
/// .open(&CString::new("path/to/asset").unwrap())
|
||||
/// .expect("Could not open asset");
|
||||
///
|
||||
/// let mut data = vec![];
|
||||
/// asset.read_to_end(&mut data);
|
||||
/// // ... use data ...
|
||||
/// ```
|
||||
///
|
||||
/// [`AAsset *`]: https://developer.android.com/ndk/reference/group/asset#aasset
|
||||
#[derive(Debug)]
|
||||
#[doc(alias = "AAsset")]
|
||||
pub struct Asset {
|
||||
ptr: NonNull<ffi::AAsset>,
|
||||
}
|
||||
|
||||
// AAsset is *not* thread safe.
|
||||
// See https://developer.android.com/ndk/reference/group/asset#aasset
|
||||
|
||||
impl Drop for Asset {
|
||||
#[doc(alias = "AAsset_close")]
|
||||
fn drop(&mut self) {
|
||||
unsafe { ffi::AAsset_close(self.ptr.as_ptr()) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Asset {
|
||||
/// Construct an `Asset` from the native `AAsset *`. This gives ownership of the `AAsset *` to
|
||||
/// the `Asset`, which will handle closing the asset. Avoid using the pointer after calling
|
||||
/// this function.
|
||||
///
|
||||
/// # Safety
|
||||
/// By calling this function, you assert that it points to a valid native `AAsset`, open
|
||||
/// in the streaming mode.
|
||||
pub unsafe fn from_ptr(ptr: NonNull<ffi::AAsset>) -> Self {
|
||||
Self { ptr }
|
||||
}
|
||||
|
||||
/// The corresponding native `AAsset *`
|
||||
pub fn ptr(&self) -> NonNull<ffi::AAsset> {
|
||||
self.ptr
|
||||
}
|
||||
|
||||
/// Returns the total length of the asset, in bytes
|
||||
#[doc(alias = "AAsset_getLength64")]
|
||||
pub fn length(&self) -> usize {
|
||||
unsafe { ffi::AAsset_getLength64(self.ptr.as_ptr()) as usize }
|
||||
}
|
||||
|
||||
/// Returns the remaining length of the asset, in bytes
|
||||
#[doc(alias = "AAsset_getRemainingLength64")]
|
||||
pub fn remaining_length(&self) -> usize {
|
||||
unsafe { ffi::AAsset_getRemainingLength64(self.ptr.as_ptr()) as usize }
|
||||
}
|
||||
|
||||
/// Maps all data into a buffer and returns it
|
||||
#[doc(alias = "AAsset_getBuffer")]
|
||||
pub fn buffer(&mut self) -> io::Result<&[u8]> {
|
||||
unsafe {
|
||||
let buf_ptr = ffi::AAsset_getBuffer(self.ptr.as_ptr());
|
||||
if buf_ptr.is_null() {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
"Android Asset error creating buffer",
|
||||
))
|
||||
} else {
|
||||
Ok(std::slice::from_raw_parts(
|
||||
buf_ptr as *const u8,
|
||||
self.length(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether this asset's internal buffer is allocated in ordinary RAM (i.e. not `mmap`ped).
|
||||
#[doc(alias = "AAsset_isAllocated")]
|
||||
pub fn is_allocated(&self) -> bool {
|
||||
unsafe { ffi::AAsset_isAllocated(self.ptr.as_ptr()) != 0 }
|
||||
}
|
||||
|
||||
/// Open a new file descriptor that can be used to read the asset data.
|
||||
///
|
||||
/// Returns an error if direct fd access is not possible (for example, if the asset is compressed).
|
||||
#[doc(alias = "AAsset_openFileDescriptor64")]
|
||||
pub fn open_file_descriptor(&self) -> io::Result<OpenedFileDescriptor> {
|
||||
let mut offset = 0;
|
||||
let mut size = 0;
|
||||
let res =
|
||||
unsafe { ffi::AAsset_openFileDescriptor64(self.ptr.as_ptr(), &mut offset, &mut size) };
|
||||
if res >= 0 {
|
||||
Ok(OpenedFileDescriptor {
|
||||
fd: unsafe { OwnedFd::from_raw_fd(res) },
|
||||
offset: offset as usize,
|
||||
size: size as usize,
|
||||
})
|
||||
} else {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
"Android Asset openFileDescriptor error",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl io::Read for Asset {
|
||||
#[doc(alias = "AAsset_read")]
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
unsafe {
|
||||
let res = ffi::AAsset_read(self.ptr.as_ptr(), buf.as_mut_ptr() as *mut _, buf.len());
|
||||
if res >= 0 {
|
||||
Ok(res as usize)
|
||||
} else {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
"Android Asset read error",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl io::Seek for Asset {
|
||||
#[doc(alias = "AAsset_seek64")]
|
||||
fn seek(&mut self, seek: io::SeekFrom) -> io::Result<u64> {
|
||||
unsafe {
|
||||
let res = match seek {
|
||||
io::SeekFrom::Start(x) => {
|
||||
ffi::AAsset_seek64(self.ptr.as_ptr(), x as i64, ffi::SEEK_SET as i32)
|
||||
}
|
||||
io::SeekFrom::Current(x) => {
|
||||
ffi::AAsset_seek64(self.ptr.as_ptr(), x, ffi::SEEK_CUR as i32)
|
||||
}
|
||||
io::SeekFrom::End(x) => {
|
||||
ffi::AAsset_seek64(self.ptr.as_ptr(), x, ffi::SEEK_END as i32)
|
||||
}
|
||||
};
|
||||
if res < 0 {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
"Android Asset seek error",
|
||||
))
|
||||
} else {
|
||||
Ok(res as u64)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Contains the opened file descriptor returned by [`Asset::open_file_descriptor()`], together
|
||||
/// with the offset and size of the given asset within that file descriptor.
|
||||
#[derive(Debug)]
|
||||
pub struct OpenedFileDescriptor {
|
||||
pub fd: OwnedFd,
|
||||
pub offset: usize,
|
||||
pub size: usize,
|
||||
}
|
||||
+1419
File diff suppressed because it is too large
Load Diff
+489
@@ -0,0 +1,489 @@
|
||||
//! Bindings for [`AndroidBitmap`] functions
|
||||
//!
|
||||
//! These functions operate directly on a JNI [`android.graphics.Bitmap`] instance.
|
||||
//!
|
||||
//! [`AndroidBitmap`]: https://developer.android.com/ndk/reference/group/bitmap
|
||||
//! [`android.graphics.Bitmap`]: https://developer.android.com/reference/android/graphics/Bitmap
|
||||
#![cfg(feature = "bitmap")]
|
||||
|
||||
use jni_sys::{jobject, JNIEnv};
|
||||
use num_enum::{FromPrimitive, IntoPrimitive};
|
||||
use std::{error, fmt, mem::MaybeUninit};
|
||||
|
||||
#[cfg(feature = "api-level-30")]
|
||||
use crate::data_space::DataSpace;
|
||||
#[cfg(feature = "api-level-30")]
|
||||
use crate::hardware_buffer::HardwareBufferRef;
|
||||
|
||||
#[repr(i32)]
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||
#[non_exhaustive]
|
||||
pub enum BitmapError {
|
||||
#[doc(alias = "ANDROID_BITMAP_RESULT_ALLOCATION_FAILED")]
|
||||
AllocationFailed = ffi::ANDROID_BITMAP_RESULT_ALLOCATION_FAILED,
|
||||
#[doc(alias = "ANDROID_BITMAP_RESULT_BAD_PARAMETER")]
|
||||
BadParameter = ffi::ANDROID_BITMAP_RESULT_BAD_PARAMETER,
|
||||
#[doc(alias = "ANDROID_BITMAP_RESULT_JNI_EXCEPTION")]
|
||||
JniException = ffi::ANDROID_BITMAP_RESULT_JNI_EXCEPTION,
|
||||
|
||||
// Use the SUCCESS discriminant, as no-one will be able to call `as i32` and only has access to
|
||||
// the constants via `From` provided by `IntoPrimitive` which reads the contained value.
|
||||
// An autogenerated `<previous variant> + 1` discriminant is normally fine, except that the
|
||||
// previous variant is negative and `+ 1` would match the variant before that.
|
||||
#[doc(hidden)]
|
||||
#[num_enum(catch_all)]
|
||||
__Unknown(i32) = ffi::ANDROID_BITMAP_RESULT_SUCCESS,
|
||||
}
|
||||
|
||||
impl fmt::Display for BitmapError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{:?}", self)
|
||||
}
|
||||
}
|
||||
|
||||
impl error::Error for BitmapError {}
|
||||
|
||||
pub type Result<T, E = BitmapError> = std::result::Result<T, E>;
|
||||
|
||||
impl BitmapError {
|
||||
pub(crate) fn from_status(status: i32) -> Result<()> {
|
||||
match status {
|
||||
ffi::ANDROID_BITMAP_RESULT_SUCCESS => Ok(()),
|
||||
x => Err(Self::from(x)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn construct<T>(with_ptr: impl FnOnce(*mut T) -> i32) -> Result<T> {
|
||||
let mut result = MaybeUninit::uninit();
|
||||
let status = with_ptr(result.as_mut_ptr());
|
||||
BitmapError::from_status(status).map(|()| unsafe { result.assume_init() })
|
||||
}
|
||||
|
||||
#[repr(i32)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, IntoPrimitive, FromPrimitive)]
|
||||
#[allow(non_camel_case_types)]
|
||||
#[doc(alias = "AndroidBitmapFormat")]
|
||||
#[non_exhaustive]
|
||||
pub enum BitmapFormat {
|
||||
#[doc(alias = "ANDROID_BITMAP_FORMAT_NONE")]
|
||||
NONE = ffi::AndroidBitmapFormat::ANDROID_BITMAP_FORMAT_NONE.0 as i32,
|
||||
#[doc(alias = "ANDROID_BITMAP_FORMAT_RGBA_8888")]
|
||||
RGBA_8888 = ffi::AndroidBitmapFormat::ANDROID_BITMAP_FORMAT_RGBA_8888.0 as i32,
|
||||
#[doc(alias = "ANDROID_BITMAP_FORMAT_RGB_565")]
|
||||
RGB_565 = ffi::AndroidBitmapFormat::ANDROID_BITMAP_FORMAT_RGB_565.0 as i32,
|
||||
#[deprecated = "Deprecated in API level 13. Because of the poor quality of this configuration, it is advised to use ARGB_8888 instead."]
|
||||
#[doc(alias = "ANDROID_BITMAP_FORMAT_RGBA_4444")]
|
||||
RGBA_4444 = ffi::AndroidBitmapFormat::ANDROID_BITMAP_FORMAT_RGBA_4444.0 as i32,
|
||||
#[doc(alias = "ANDROID_BITMAP_FORMAT_A_8")]
|
||||
A_8 = ffi::AndroidBitmapFormat::ANDROID_BITMAP_FORMAT_A_8.0 as i32,
|
||||
#[doc(alias = "ANDROID_BITMAP_FORMAT_RGBA_F16")]
|
||||
RGBA_F16 = ffi::AndroidBitmapFormat::ANDROID_BITMAP_FORMAT_RGBA_F16.0 as i32,
|
||||
#[doc(alias = "ANDROID_BITMAP_FORMAT_RGBA_1010102")]
|
||||
RGBA_1010102 = ffi::AndroidBitmapFormat::ANDROID_BITMAP_FORMAT_RGBA_1010102.0 as i32,
|
||||
|
||||
#[doc(hidden)]
|
||||
#[num_enum(catch_all)]
|
||||
__Unknown(i32),
|
||||
}
|
||||
|
||||
/// An immediate wrapper over [`android.graphics.Bitmap`]
|
||||
///
|
||||
/// [`android.graphics.Bitmap`]: https://developer.android.com/reference/android/graphics/Bitmap
|
||||
#[derive(Debug)]
|
||||
pub struct Bitmap {
|
||||
env: *mut JNIEnv,
|
||||
inner: jobject,
|
||||
}
|
||||
|
||||
impl Bitmap {
|
||||
/// Create a [`Bitmap`] wrapper from JNI pointers
|
||||
///
|
||||
/// # Safety
|
||||
/// This function should be called with a healthy JVM pointer and with a non-null
|
||||
/// [`android.graphics.Bitmap`], which must be kept alive on the Java/Kotlin side.
|
||||
///
|
||||
/// [`android.graphics.Bitmap`]: https://developer.android.com/reference/android/graphics/Bitmap
|
||||
pub unsafe fn from_jni(env: *mut JNIEnv, bitmap: jobject) -> Self {
|
||||
Self { env, inner: bitmap }
|
||||
}
|
||||
|
||||
/// Fills out and returns the [`BitmapInfo`] struct for the given Java bitmap object.
|
||||
#[doc(alias = "AndroidBitmap_getInfo")]
|
||||
pub fn info(&self) -> Result<BitmapInfo> {
|
||||
let inner =
|
||||
construct(|res| unsafe { ffi::AndroidBitmap_getInfo(self.env, self.inner, res) })?;
|
||||
|
||||
Ok(BitmapInfo { inner })
|
||||
}
|
||||
|
||||
/// Returns the [`DataSpace`] of this [`Bitmap`].
|
||||
///
|
||||
/// Note that [`DataSpace`] only exposes a few values. This may return [`DataSpace::Unknown`],
|
||||
/// even for Named ColorSpaces, if they have no corresponding [`DataSpace`].
|
||||
#[cfg(feature = "api-level-30")]
|
||||
#[doc(alias = "AndroidBitmap_getDataSpace")]
|
||||
pub fn data_space(&self) -> DataSpace {
|
||||
let value = unsafe { ffi::AndroidBitmap_getDataSpace(self.env, self.inner) };
|
||||
value.into()
|
||||
}
|
||||
|
||||
/// Attempt to lock the pixel address.
|
||||
///
|
||||
/// Locking will ensure that the memory for the pixels will not move until the
|
||||
/// [`Bitmap::unlock_pixels()`] call, and ensure that, if the pixels had been previously purged,
|
||||
/// they will have been restored.
|
||||
///
|
||||
/// If this call succeeds, it must be balanced by a call to [`Bitmap::unlock_pixels()`], after
|
||||
/// which time the address of the pixels should no longer be used.
|
||||
#[doc(alias = "AndroidBitmap_lockPixels")]
|
||||
pub fn lock_pixels(&self) -> Result<*mut std::os::raw::c_void> {
|
||||
construct(|res| unsafe { ffi::AndroidBitmap_lockPixels(self.env, self.inner, res) })
|
||||
}
|
||||
|
||||
/// Call this to balance a successful call to [`Bitmap::lock_pixels()`].
|
||||
#[doc(alias = "AndroidBitmap_unlockPixels")]
|
||||
pub fn unlock_pixels(&self) -> Result<()> {
|
||||
let status = unsafe { ffi::AndroidBitmap_unlockPixels(self.env, self.inner) };
|
||||
BitmapError::from_status(status)
|
||||
}
|
||||
|
||||
/// Retrieve the native object associated with an [`ffi::ANDROID_BITMAP_FLAGS_IS_HARDWARE`]
|
||||
/// [`Bitmap`] (requires [`BitmapInfoFlags::is_hardware()`] on [`BitmapInfo::flags()`] to return
|
||||
/// [`true`]).
|
||||
///
|
||||
/// Client must not modify it while a [`Bitmap`] is wrapping it.
|
||||
#[cfg(feature = "api-level-30")]
|
||||
#[doc(alias = "AndroidBitmap_getHardwareBuffer")]
|
||||
pub fn hardware_buffer(&self) -> Result<HardwareBufferRef> {
|
||||
unsafe {
|
||||
let result =
|
||||
construct(|res| ffi::AndroidBitmap_getHardwareBuffer(self.env, self.inner, res))?;
|
||||
let non_null = if cfg!(debug_assertions) {
|
||||
std::ptr::NonNull::new(result).expect("result should never be null")
|
||||
} else {
|
||||
std::ptr::NonNull::new_unchecked(result)
|
||||
};
|
||||
Ok(HardwareBufferRef::from_ptr(non_null))
|
||||
}
|
||||
}
|
||||
|
||||
/// [Lock] the pixels in `self` and compress them as described by [`info()`].
|
||||
///
|
||||
/// Unlike [`compress_raw()`] this requires a [`Bitmap`] object (as `self`) backed by a
|
||||
/// [`jobject`].
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `format`: [`BitmapCompressFormat`] to compress to.
|
||||
/// - `quality`: Hint to the compressor, `0-100`. The value is interpreted differently
|
||||
/// depending on [`BitmapCompressFormat`].
|
||||
/// - `compress_callback`: Closure that writes the compressed data. Will be called on the
|
||||
/// current thread, each time the compressor has compressed more data that is ready to be
|
||||
/// written. May be called more than once for each call to this method.
|
||||
///
|
||||
/// [Lock]: Self::lock_pixels()
|
||||
/// [`info()`]: Self::info()
|
||||
/// [`compress_raw()`]: Self::compress_raw()
|
||||
#[cfg(feature = "api-level-30")]
|
||||
#[doc(alias = "AndroidBitmap_compress")]
|
||||
pub fn compress<F: FnMut(&[u8]) -> Result<(), ()>>(
|
||||
&self,
|
||||
format: BitmapCompressFormat,
|
||||
quality: i32,
|
||||
compress_callback: F,
|
||||
) -> Result<(), BitmapCompressError> {
|
||||
let info = self.info()?;
|
||||
let data_space = self.data_space();
|
||||
let pixels = self.lock_pixels()?;
|
||||
// SAFETY: When lock_pixels() succeeds, assume it returns a valid pointer that stays
|
||||
// valid until we call unlock_pixels().
|
||||
let result = unsafe {
|
||||
Self::compress_raw(
|
||||
&info,
|
||||
data_space,
|
||||
pixels,
|
||||
format,
|
||||
quality,
|
||||
compress_callback,
|
||||
)
|
||||
};
|
||||
self.unlock_pixels()?;
|
||||
result
|
||||
}
|
||||
|
||||
/// Compress `pixels` as described by `info`.
|
||||
///
|
||||
/// Unlike [`compress()`] this takes a raw pointer to pixels and does not need a [`Bitmap`]
|
||||
/// object backed by a [`jobject`].
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `info`: Description of the pixels to compress.
|
||||
/// - `data_space`: [`DataSpace`] describing the color space of the pixels. Should _not_ be
|
||||
/// [`DataSpace::Unknown`] [^1].
|
||||
/// - `pixels`: Pointer to pixels to compress.
|
||||
/// - `format`: [`BitmapCompressFormat`] to compress to.
|
||||
/// - `quality`: Hint to the compressor, `0-100`. The value is interpreted differently
|
||||
/// depending on [`BitmapCompressFormat`].
|
||||
/// - `compress_callback`: Closure that writes the compressed data. Will be called on the
|
||||
/// current thread, each time the compressor has compressed more data that is ready to be
|
||||
/// written. May be called more than once for each call to this method.
|
||||
///
|
||||
/// # Safety
|
||||
/// `pixels` must point to a valid buffer that matches the size, stride and format in `info`.
|
||||
///
|
||||
/// [`compress()`]: Self::compress()
|
||||
/// [^1]: <https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/libs/hwui/apex/android_bitmap.cpp;l=275-279;drc=7ba5c2fb3d1e35eb37a9cc522b30ba51f49ea491>
|
||||
#[cfg(feature = "api-level-30")]
|
||||
#[doc(alias = "AndroidBitmap_compress")]
|
||||
pub unsafe fn compress_raw<F: FnMut(&[u8]) -> Result<(), ()>>(
|
||||
info: &BitmapInfo,
|
||||
data_space: DataSpace,
|
||||
pixels: *const std::ffi::c_void,
|
||||
format: BitmapCompressFormat,
|
||||
quality: i32,
|
||||
compress_callback: F,
|
||||
) -> Result<(), BitmapCompressError> {
|
||||
if data_space == DataSpace::Unknown {
|
||||
return Err(BitmapCompressError::DataSpaceUnknown);
|
||||
}
|
||||
|
||||
use std::{any::Any, ffi::c_void, panic::AssertUnwindSafe};
|
||||
struct CallbackState<F: FnMut(&[u8]) -> Result<(), ()>> {
|
||||
callback: F,
|
||||
panic: Option<Box<dyn Any + Send>>,
|
||||
}
|
||||
let mut cb_state = CallbackState::<F> {
|
||||
callback: compress_callback,
|
||||
panic: None,
|
||||
};
|
||||
|
||||
extern "C" fn compress_cb<F: FnMut(&[u8]) -> Result<(), ()>>(
|
||||
context: *mut c_void,
|
||||
data: *const c_void,
|
||||
size: usize,
|
||||
) -> bool {
|
||||
// SAFETY: This callback will only be called serially on a single thread. Both the
|
||||
// panic state and the FnMut context need to be available mutably.
|
||||
let cb_state = unsafe { context.cast::<CallbackState<F>>().as_mut() }.unwrap();
|
||||
let data = unsafe { std::slice::from_raw_parts(data.cast(), size) };
|
||||
let panic = std::panic::catch_unwind(AssertUnwindSafe(|| (cb_state.callback)(data)));
|
||||
match panic {
|
||||
Ok(r) => r.is_ok(),
|
||||
Err(e) => {
|
||||
cb_state.panic = Some(e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let status = unsafe {
|
||||
ffi::AndroidBitmap_compress(
|
||||
&info.inner,
|
||||
data_space.into(),
|
||||
pixels,
|
||||
format.into(),
|
||||
quality,
|
||||
<*mut _>::cast(&mut cb_state),
|
||||
Some(compress_cb::<F>),
|
||||
)
|
||||
};
|
||||
|
||||
if let Some(panic) = cb_state.panic {
|
||||
std::panic::resume_unwind(panic)
|
||||
}
|
||||
|
||||
Ok(BitmapError::from_status(status)?)
|
||||
}
|
||||
}
|
||||
|
||||
/// Possible values for [`ffi::ANDROID_BITMAP_FLAGS_ALPHA_MASK`] within [`BitmapInfoFlags`]
|
||||
#[repr(u32)]
|
||||
#[cfg(feature = "api-level-30")]
|
||||
#[derive(Clone, Copy, Debug, IntoPrimitive, FromPrimitive)]
|
||||
#[doc(alias = "ANDROID_BITMAP_FLAGS_ALPHA_MASK")]
|
||||
#[non_exhaustive]
|
||||
pub enum BitmapInfoFlagsAlpha {
|
||||
/// Pixel components are premultiplied by alpha.
|
||||
#[doc(alias = "ANDROID_BITMAP_FLAGS_ALPHA_PREMUL")]
|
||||
Premultiplied = ffi::ANDROID_BITMAP_FLAGS_ALPHA_PREMUL,
|
||||
/// Pixels are opaque.
|
||||
#[doc(alias = "ANDROID_BITMAP_FLAGS_ALPHA_OPAQUE")]
|
||||
Opaque = ffi::ANDROID_BITMAP_FLAGS_ALPHA_OPAQUE,
|
||||
/// Pixel components are independent of alpha.
|
||||
#[doc(alias = "ANDROID_BITMAP_FLAGS_ALPHA_UNPREMUL")]
|
||||
Unpremultiplied = ffi::ANDROID_BITMAP_FLAGS_ALPHA_UNPREMUL,
|
||||
|
||||
#[doc(hidden)]
|
||||
#[num_enum(catch_all)]
|
||||
__Unknown(u32),
|
||||
}
|
||||
|
||||
/// Bitfield containing information about the bitmap.
|
||||
#[cfg(feature = "api-level-30")]
|
||||
#[repr(transparent)]
|
||||
#[derive(Clone, Copy, Hash, PartialEq, Eq)]
|
||||
pub struct BitmapInfoFlags(u32);
|
||||
|
||||
#[cfg(feature = "api-level-30")]
|
||||
impl std::fmt::Debug for BitmapInfoFlags {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"BitmapInfoFlags({:#x}, alpha: {:?}, is_hardware: {})",
|
||||
self.0,
|
||||
self.alpha(),
|
||||
self.is_hardware()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "api-level-30")]
|
||||
impl BitmapInfoFlags {
|
||||
/// Returns the alpha value contained in the [`ffi::ANDROID_BITMAP_FLAGS_ALPHA_MASK`] bit range
|
||||
#[doc(alias = "ANDROID_BITMAP_FLAGS_ALPHA_MASK")]
|
||||
pub fn alpha(self) -> BitmapInfoFlagsAlpha {
|
||||
// Note that ffi::ANDROID_BITMAP_FLAGS_ALPHA_SHIFT is 0 and hence irrelevant.
|
||||
(self.0 & ffi::ANDROID_BITMAP_FLAGS_ALPHA_MASK).into()
|
||||
}
|
||||
|
||||
/// Returns [`true`] when [`ffi::ANDROID_BITMAP_FLAGS_IS_HARDWARE`] is set, meaning this
|
||||
/// [`Bitmap`] uses "HARDWARE Config" and its [`HardwareBufferRef`] can be retrieved via
|
||||
/// [`Bitmap::hardware_buffer()`].
|
||||
#[doc(alias = "ANDROID_BITMAP_FLAGS_IS_HARDWARE")]
|
||||
pub fn is_hardware(self) -> bool {
|
||||
// This constant is defined in a separate anonymous enum which bindgen treats as i32.
|
||||
(self.0 & ffi::ANDROID_BITMAP_FLAGS_IS_HARDWARE as u32) != 0
|
||||
}
|
||||
}
|
||||
|
||||
/// A native [`AndroidBitmapInfo`]
|
||||
///
|
||||
/// [`AndroidBitmapInfo`]: https://developer.android.com/ndk/reference/struct/android-bitmap-info#struct_android_bitmap_info
|
||||
#[derive(Clone, Copy)]
|
||||
#[doc(alias = "AndroidBitmapInfo")]
|
||||
pub struct BitmapInfo {
|
||||
inner: ffi::AndroidBitmapInfo,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for BitmapInfo {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let mut f = f.debug_struct("BitmapInfo");
|
||||
f.field("width", &self.width())
|
||||
.field("height", &self.height())
|
||||
.field("stride", &self.stride())
|
||||
.field("format", &self.format());
|
||||
|
||||
#[cfg(feature = "api-level-30")]
|
||||
f.field("flags", &self.flags());
|
||||
|
||||
f.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl BitmapInfo {
|
||||
pub fn new(width: u32, height: u32, stride: u32, format: BitmapFormat) -> Self {
|
||||
Self {
|
||||
inner: ffi::AndroidBitmapInfo {
|
||||
width,
|
||||
height,
|
||||
stride,
|
||||
format: format.into(),
|
||||
flags: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "api-level-30")]
|
||||
pub fn new_with_flags(
|
||||
width: u32,
|
||||
height: u32,
|
||||
stride: u32,
|
||||
format: BitmapFormat,
|
||||
flags: BitmapInfoFlags,
|
||||
) -> Self {
|
||||
Self {
|
||||
inner: ffi::AndroidBitmapInfo {
|
||||
flags: flags.0,
|
||||
..Self::new(width, height, stride, format).inner
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The bitmap width in pixels.
|
||||
pub fn width(&self) -> u32 {
|
||||
self.inner.width
|
||||
}
|
||||
|
||||
/// The bitmap height in pixels.
|
||||
pub fn height(&self) -> u32 {
|
||||
self.inner.height
|
||||
}
|
||||
|
||||
/// The number of byte per row.
|
||||
pub fn stride(&self) -> u32 {
|
||||
self.inner.stride
|
||||
}
|
||||
|
||||
/// Convert the internal, native [`ffi::AndroidBitmapInfo::format`] into a [`BitmapFormat`].
|
||||
pub fn format(&self) -> BitmapFormat {
|
||||
self.inner.format.into()
|
||||
}
|
||||
|
||||
/// Bitfield containing information about the bitmap.
|
||||
#[cfg(feature = "api-level-30")]
|
||||
pub fn flags(&self) -> BitmapInfoFlags {
|
||||
BitmapInfoFlags(self.inner.flags)
|
||||
}
|
||||
}
|
||||
|
||||
/// Specifies the formats that can be compressed to with [`Bitmap::compress()`] and
|
||||
/// [`Bitmap::compress_raw()`].
|
||||
#[cfg(feature = "api-level-30")]
|
||||
#[repr(i32)]
|
||||
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||
#[doc(alias = "AndroidBitmapCompressFormat")]
|
||||
#[non_exhaustive]
|
||||
pub enum BitmapCompressFormat {
|
||||
/// Compress to the JPEG format.
|
||||
///
|
||||
/// quality of `0` means compress for the smallest size. `100` means compress for max visual
|
||||
/// quality.
|
||||
#[doc(alias = "ANDROID_BITMAP_COMPRESS_FORMAT_JPEG")]
|
||||
Jpeg = ffi::AndroidBitmapCompressFormat::ANDROID_BITMAP_COMPRESS_FORMAT_JPEG.0 as i32,
|
||||
/// Compress to the PNG format.
|
||||
///
|
||||
/// PNG is lossless, so quality is ignored.
|
||||
#[doc(alias = "ANDROID_BITMAP_COMPRESS_FORMAT_PNG")]
|
||||
Png = ffi::AndroidBitmapCompressFormat::ANDROID_BITMAP_COMPRESS_FORMAT_PNG.0 as i32,
|
||||
/// Compress to the WEBP lossless format.
|
||||
///
|
||||
/// quality refers to how much effort to put into compression. A value of `0` means to
|
||||
/// compress quickly, resulting in a relatively large file size. `100` means to spend more time
|
||||
/// compressing, resulting in a smaller file.
|
||||
#[doc(alias = "ANDROID_BITMAP_COMPRESS_FORMAT_WEBP_LOSSY")]
|
||||
WebPLossy =
|
||||
ffi::AndroidBitmapCompressFormat::ANDROID_BITMAP_COMPRESS_FORMAT_WEBP_LOSSY.0 as i32,
|
||||
/// Compress to the WEBP lossy format.
|
||||
///
|
||||
/// quality of `0` means compress for the smallest size. `100` means compress for max visual quality.
|
||||
#[doc(alias = "ANDROID_BITMAP_COMPRESS_FORMAT_WEBP_LOSSLESS")]
|
||||
WebPLossless =
|
||||
ffi::AndroidBitmapCompressFormat::ANDROID_BITMAP_COMPRESS_FORMAT_WEBP_LOSSLESS.0 as i32,
|
||||
|
||||
#[doc(hidden)]
|
||||
#[num_enum(catch_all)]
|
||||
__Unknown(i32),
|
||||
}
|
||||
|
||||
/// Encapsulates possible errors returned by [`Bitmap::compress()`] or [`Bitmap::compress_raw()`].
|
||||
#[cfg(feature = "api-level-30")]
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum BitmapCompressError {
|
||||
#[error(transparent)]
|
||||
BitmapError(#[from] BitmapError),
|
||||
/// [`Bitmap`] compression requires a known [`DataSpace`]. [`DataSpace::Unknown`] is invalid
|
||||
/// even though it is typically treated as `sRGB`, for that [`DataSpace::Srgb`] has to be passed
|
||||
/// explicitly.
|
||||
#[error("The dataspace for this Bitmap is Unknown")]
|
||||
DataSpaceUnknown,
|
||||
}
|
||||
@@ -0,0 +1,600 @@
|
||||
//! Bindings for [`AConfiguration`]
|
||||
//!
|
||||
//! See also the [NDK docs](https://developer.android.com/ndk/reference/group/configuration) for
|
||||
//! [`AConfiguration`], as well as the [docs for providing
|
||||
//! resources](https://developer.android.com/guide/topics/resources/providing-resources.html),
|
||||
//! which explain many of the configuration values. The [`android.content.res.Configuration`
|
||||
//! javadoc](https://developer.android.com/reference/android/content/res/Configuration.html) may
|
||||
//! also have useful information.
|
||||
//!
|
||||
//! [`AConfiguration`]: https://developer.android.com/ndk/reference/group/configuration#aconfiguration
|
||||
|
||||
use crate::asset::AssetManager;
|
||||
use num_enum::{FromPrimitive, IntoPrimitive};
|
||||
use std::fmt;
|
||||
use std::ptr::NonNull;
|
||||
|
||||
/// A native [`AConfiguration *`]
|
||||
///
|
||||
/// [`Configuration`] is an opaque type used to get and set various subsystem configurations.
|
||||
///
|
||||
/// [`AConfiguration *`]: https://developer.android.com/ndk/reference/group/configuration#aconfiguration
|
||||
pub struct Configuration {
|
||||
ptr: NonNull<ffi::AConfiguration>,
|
||||
}
|
||||
|
||||
unsafe impl Send for Configuration {}
|
||||
unsafe impl Sync for Configuration {}
|
||||
|
||||
impl Drop for Configuration {
|
||||
fn drop(&mut self) {
|
||||
unsafe { ffi::AConfiguration_delete(self.ptr.as_ptr()) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for Configuration {
|
||||
fn clone(&self) -> Self {
|
||||
let mut new = Self::new();
|
||||
new.copy(self);
|
||||
new
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Configuration {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.diff(other).0 == 0
|
||||
}
|
||||
}
|
||||
impl Eq for Configuration {}
|
||||
|
||||
impl fmt::Debug for Configuration {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("Configuration")
|
||||
.field("mcc", &self.mcc())
|
||||
.field("mnc", &self.mnc())
|
||||
.field("lang", &self.language())
|
||||
.field("country", &self.country())
|
||||
.field("orientation", &self.orientation())
|
||||
.field("touchscreen", &self.touchscreen())
|
||||
.field("density", &self.density())
|
||||
.field("keyboard", &self.keyboard())
|
||||
.field("navigation", &self.navigation())
|
||||
.field("keys_hidden", &self.keys_hidden())
|
||||
.field("nav_hidden", &self.nav_hidden())
|
||||
.field("sdk_version", &self.sdk_version())
|
||||
.field("screen_size", &self.screen_size())
|
||||
.field("screen_long", &self.screen_long())
|
||||
.field("ui_mode_type", &self.ui_mode_type())
|
||||
.field("ui_mode_night", &self.ui_mode_night())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Configuration {
|
||||
/// Construct a `Configuration` from a pointer.
|
||||
///
|
||||
/// # Safety
|
||||
/// By calling this function, you assert that it is a valid pointer to a native
|
||||
/// `AConfiguration`, and give ownership of it to the `Configuration` instance.
|
||||
pub unsafe fn from_ptr(ptr: NonNull<ffi::AConfiguration>) -> Self {
|
||||
Self { ptr }
|
||||
}
|
||||
|
||||
/// Create a new `Configuration`, with the same contents as the `AConfiguration` referenced by
|
||||
/// the pointer.
|
||||
///
|
||||
/// This is useful if you have a pointer, but not ownership of it.
|
||||
///
|
||||
/// # Safety
|
||||
/// By calling this function, you assert that it is a valid pointer to a native
|
||||
/// `AConfiguration`.
|
||||
pub unsafe fn clone_from_ptr(ptr: NonNull<ffi::AConfiguration>) -> Self {
|
||||
let conf = Self::new();
|
||||
ffi::AConfiguration_copy(conf.ptr.as_ptr(), ptr.as_ptr());
|
||||
conf
|
||||
}
|
||||
|
||||
/// The pointer to the native `AConfiguration`. Keep in mind that the `Configuration` object
|
||||
/// still has ownership, and will free it when dropped.
|
||||
pub fn ptr(&self) -> NonNull<ffi::AConfiguration> {
|
||||
self.ptr
|
||||
}
|
||||
|
||||
pub fn from_asset_manager(am: &AssetManager) -> Self {
|
||||
let config = Self::new();
|
||||
unsafe {
|
||||
ffi::AConfiguration_fromAssetManager(config.ptr().as_mut(), am.ptr().as_mut());
|
||||
}
|
||||
config
|
||||
}
|
||||
|
||||
/// Create a new `Configuration`, with none of the values set.
|
||||
pub fn new() -> Self {
|
||||
unsafe {
|
||||
Self {
|
||||
ptr: NonNull::new(ffi::AConfiguration_new()).unwrap(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `dest.copy(&src)` copies the contents of `src` to `dest`
|
||||
pub fn copy(&mut self, other: &Self) {
|
||||
unsafe { ffi::AConfiguration_copy(self.ptr.as_ptr(), other.ptr.as_ptr()) }
|
||||
}
|
||||
|
||||
/// Information about what fields differ between the two configurations
|
||||
pub fn diff(&self, other: &Self) -> DiffResult {
|
||||
unsafe {
|
||||
DiffResult(ffi::AConfiguration_diff(self.ptr.as_ptr(), other.ptr.as_ptr()) as u32)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns false if anything in `self` conflicts with `requested`
|
||||
pub fn matches(&self, requested: &Self) -> bool {
|
||||
unsafe { ffi::AConfiguration_match(self.ptr.as_ptr(), requested.ptr.as_ptr()) != 0 }
|
||||
}
|
||||
|
||||
/// Returns the country code, as a [`String`] of two characters, if set
|
||||
pub fn country(&self) -> Option<String> {
|
||||
let mut chars = [0u8; 2];
|
||||
unsafe {
|
||||
ffi::AConfiguration_getCountry(self.ptr.as_ptr(), chars.as_mut_ptr().cast());
|
||||
}
|
||||
if chars[0] == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(std::str::from_utf8(chars.as_slice()).unwrap().to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the screen density in dpi.
|
||||
///
|
||||
/// On some devices it can return values outside of the density enum.
|
||||
pub fn density(&self) -> Option<u32> {
|
||||
let density = unsafe { ffi::AConfiguration_getDensity(self.ptr.as_ptr()) as u32 };
|
||||
match density {
|
||||
ffi::ACONFIGURATION_DENSITY_DEFAULT => Some(160),
|
||||
ffi::ACONFIGURATION_DENSITY_ANY => None,
|
||||
ffi::ACONFIGURATION_DENSITY_NONE => None,
|
||||
density => Some(density),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the keyboard type.
|
||||
pub fn keyboard(&self) -> Keyboard {
|
||||
unsafe { ffi::AConfiguration_getKeyboard(self.ptr.as_ptr()).into() }
|
||||
}
|
||||
|
||||
/// Returns keyboard visibility/availability.
|
||||
pub fn keys_hidden(&self) -> KeysHidden {
|
||||
unsafe { ffi::AConfiguration_getKeysHidden(self.ptr.as_ptr()).into() }
|
||||
}
|
||||
|
||||
/// Returns the language, as a [`String`] of two characters, if set
|
||||
pub fn language(&self) -> Option<String> {
|
||||
let mut chars = [0u8; 2];
|
||||
unsafe {
|
||||
ffi::AConfiguration_getLanguage(self.ptr.as_ptr(), chars.as_mut_ptr().cast());
|
||||
}
|
||||
if chars[0] == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(std::str::from_utf8(chars.as_slice()).unwrap().to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the layout direction
|
||||
pub fn layout_direction(&self) -> LayoutDir {
|
||||
unsafe { ffi::AConfiguration_getLayoutDirection(self.ptr.as_ptr()).into() }
|
||||
}
|
||||
|
||||
/// Returns the mobile country code.
|
||||
pub fn mcc(&self) -> i32 {
|
||||
unsafe { ffi::AConfiguration_getMcc(self.ptr.as_ptr()) }
|
||||
}
|
||||
|
||||
/// Returns the mobile network code, if one is defined
|
||||
pub fn mnc(&self) -> Option<i32> {
|
||||
unsafe {
|
||||
match ffi::AConfiguration_getMnc(self.ptr.as_ptr()) {
|
||||
0 => None,
|
||||
x if x == ffi::ACONFIGURATION_MNC_ZERO as i32 => Some(0),
|
||||
x => Some(x),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn nav_hidden(&self) -> NavHidden {
|
||||
unsafe { ffi::AConfiguration_getNavHidden(self.ptr.as_ptr()).into() }
|
||||
}
|
||||
|
||||
pub fn navigation(&self) -> Navigation {
|
||||
unsafe { ffi::AConfiguration_getNavigation(self.ptr.as_ptr()).into() }
|
||||
}
|
||||
|
||||
pub fn orientation(&self) -> Orientation {
|
||||
unsafe { ffi::AConfiguration_getOrientation(self.ptr.as_ptr()).into() }
|
||||
}
|
||||
|
||||
pub fn screen_height_dp(&self) -> Option<i32> {
|
||||
unsafe {
|
||||
let height = ffi::AConfiguration_getScreenHeightDp(self.ptr.as_ptr());
|
||||
if height == ffi::ACONFIGURATION_SCREEN_HEIGHT_DP_ANY as i32 {
|
||||
None
|
||||
} else {
|
||||
Some(height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn screen_width_dp(&self) -> Option<i32> {
|
||||
unsafe {
|
||||
let width = ffi::AConfiguration_getScreenWidthDp(self.ptr.as_ptr());
|
||||
if width == ffi::ACONFIGURATION_SCREEN_WIDTH_DP_ANY as i32 {
|
||||
None
|
||||
} else {
|
||||
Some(width)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn screen_long(&self) -> ScreenLong {
|
||||
unsafe { ffi::AConfiguration_getScreenLong(self.ptr.as_ptr()).into() }
|
||||
}
|
||||
|
||||
#[cfg(feature = "api-level-30")]
|
||||
pub fn screen_round(&self) -> ScreenRound {
|
||||
unsafe { ffi::AConfiguration_getScreenRound(self.ptr.as_ptr()).into() }
|
||||
}
|
||||
|
||||
pub fn screen_size(&self) -> ScreenSize {
|
||||
unsafe { ffi::AConfiguration_getScreenSize(self.ptr.as_ptr()).into() }
|
||||
}
|
||||
|
||||
pub fn sdk_version(&self) -> i32 {
|
||||
unsafe { ffi::AConfiguration_getSdkVersion(self.ptr.as_ptr()) }
|
||||
}
|
||||
|
||||
pub fn smallest_screen_width_dp(&self) -> Option<i32> {
|
||||
unsafe {
|
||||
let width = ffi::AConfiguration_getSmallestScreenWidthDp(self.ptr.as_ptr());
|
||||
if width == ffi::ACONFIGURATION_SMALLEST_SCREEN_WIDTH_DP_ANY as i32 {
|
||||
None
|
||||
} else {
|
||||
Some(width)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn touchscreen(&self) -> Touchscreen {
|
||||
unsafe { ffi::AConfiguration_getTouchscreen(self.ptr.as_ptr()).into() }
|
||||
}
|
||||
|
||||
pub fn ui_mode_night(&self) -> UiModeNight {
|
||||
unsafe { ffi::AConfiguration_getUiModeNight(self.ptr.as_ptr()).into() }
|
||||
}
|
||||
|
||||
pub fn ui_mode_type(&self) -> UiModeType {
|
||||
unsafe { ffi::AConfiguration_getUiModeType(self.ptr.as_ptr()).into() }
|
||||
}
|
||||
}
|
||||
|
||||
/// A bitfield representing the differences between two [`Configuration`]s
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct DiffResult(pub u32);
|
||||
|
||||
impl DiffResult {
|
||||
pub fn mcc(self) -> bool {
|
||||
self.0 & ffi::ACONFIGURATION_MCC != 0
|
||||
}
|
||||
pub fn mnc(self) -> bool {
|
||||
self.0 & ffi::ACONFIGURATION_MNC != 0
|
||||
}
|
||||
pub fn locale(self) -> bool {
|
||||
self.0 & ffi::ACONFIGURATION_LOCALE != 0
|
||||
}
|
||||
pub fn touchscreen(self) -> bool {
|
||||
self.0 & ffi::ACONFIGURATION_TOUCHSCREEN != 0
|
||||
}
|
||||
pub fn keyboard(self) -> bool {
|
||||
self.0 & ffi::ACONFIGURATION_KEYBOARD != 0
|
||||
}
|
||||
pub fn keyboard_hidden(self) -> bool {
|
||||
self.0 & ffi::ACONFIGURATION_KEYBOARD_HIDDEN != 0
|
||||
}
|
||||
pub fn navigation(self) -> bool {
|
||||
self.0 & ffi::ACONFIGURATION_NAVIGATION != 0
|
||||
}
|
||||
pub fn orientation(self) -> bool {
|
||||
self.0 & ffi::ACONFIGURATION_ORIENTATION != 0
|
||||
}
|
||||
pub fn density(self) -> bool {
|
||||
self.0 & ffi::ACONFIGURATION_DENSITY != 0
|
||||
}
|
||||
pub fn screen_size(self) -> bool {
|
||||
self.0 & ffi::ACONFIGURATION_SCREEN_SIZE != 0
|
||||
}
|
||||
pub fn version(self) -> bool {
|
||||
self.0 & ffi::ACONFIGURATION_VERSION != 0
|
||||
}
|
||||
pub fn screen_layout(self) -> bool {
|
||||
self.0 & ffi::ACONFIGURATION_SCREEN_LAYOUT != 0
|
||||
}
|
||||
pub fn ui_mode(self) -> bool {
|
||||
self.0 & ffi::ACONFIGURATION_UI_MODE != 0
|
||||
}
|
||||
pub fn smallest_screen_size(self) -> bool {
|
||||
self.0 & ffi::ACONFIGURATION_SMALLEST_SCREEN_SIZE != 0
|
||||
}
|
||||
pub fn layout_dir(self) -> bool {
|
||||
self.0 & ffi::ACONFIGURATION_LAYOUTDIR != 0
|
||||
}
|
||||
pub fn screen_round(self) -> bool {
|
||||
self.0 & ffi::ACONFIGURATION_SCREEN_ROUND != 0
|
||||
}
|
||||
pub fn color_mode(self) -> bool {
|
||||
self.0 & ffi::ACONFIGURATION_COLOR_MODE != 0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||
#[repr(i32)]
|
||||
#[non_exhaustive]
|
||||
pub enum Orientation {
|
||||
Any = ffi::ACONFIGURATION_ORIENTATION_ANY as i32,
|
||||
Port = ffi::ACONFIGURATION_ORIENTATION_PORT as i32,
|
||||
Land = ffi::ACONFIGURATION_ORIENTATION_LAND as i32,
|
||||
Square = ffi::ACONFIGURATION_ORIENTATION_SQUARE as i32,
|
||||
|
||||
#[doc(hidden)]
|
||||
#[num_enum(catch_all)]
|
||||
__Unknown(i32),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||
#[repr(i32)]
|
||||
#[non_exhaustive]
|
||||
pub enum Touchscreen {
|
||||
Any = ffi::ACONFIGURATION_TOUCHSCREEN_ANY as i32,
|
||||
NoTouch = ffi::ACONFIGURATION_TOUCHSCREEN_NOTOUCH as i32,
|
||||
Stylus = ffi::ACONFIGURATION_TOUCHSCREEN_STYLUS as i32,
|
||||
Finger = ffi::ACONFIGURATION_TOUCHSCREEN_FINGER as i32,
|
||||
|
||||
#[doc(hidden)]
|
||||
#[num_enum(catch_all)]
|
||||
__Unknown(i32),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||
#[repr(i32)]
|
||||
#[non_exhaustive]
|
||||
pub enum Density {
|
||||
Default = ffi::ACONFIGURATION_DENSITY_DEFAULT as i32,
|
||||
Low = ffi::ACONFIGURATION_DENSITY_LOW as i32,
|
||||
Medium = ffi::ACONFIGURATION_DENSITY_MEDIUM as i32,
|
||||
TV = ffi::ACONFIGURATION_DENSITY_TV as i32,
|
||||
High = ffi::ACONFIGURATION_DENSITY_HIGH as i32,
|
||||
XHigh = ffi::ACONFIGURATION_DENSITY_XHIGH as i32,
|
||||
XXHigh = ffi::ACONFIGURATION_DENSITY_XXHIGH as i32,
|
||||
XXXHigh = ffi::ACONFIGURATION_DENSITY_XXXHIGH as i32,
|
||||
Any = ffi::ACONFIGURATION_DENSITY_ANY as i32,
|
||||
None = ffi::ACONFIGURATION_DENSITY_NONE as i32,
|
||||
|
||||
#[doc(hidden)]
|
||||
#[num_enum(catch_all)]
|
||||
__Unknown(i32),
|
||||
}
|
||||
|
||||
impl Density {
|
||||
/// The DPI associated with the density class.
|
||||
/// See [the Android screen density
|
||||
/// docs](https://developer.android.com/training/multiscreen/screendensities#TaskProvideAltBmp)
|
||||
///
|
||||
/// There are some [`Density`] values that have no associated DPI; these values return [`None`].
|
||||
pub fn dpi(self) -> Option<u32> {
|
||||
match self {
|
||||
Self::Default => Some(160), // Or should it be None?
|
||||
Self::Low => Some(120),
|
||||
Self::Medium => Some(160),
|
||||
Self::High => Some(240),
|
||||
Self::XHigh => Some(320),
|
||||
Self::XXHigh => Some(480),
|
||||
Self::XXXHigh => Some(640),
|
||||
Self::TV => Some(213),
|
||||
Self::Any => None,
|
||||
Self::None => None,
|
||||
// TODO
|
||||
Self::__Unknown(v) => Some(v as u32),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Hi-DPI factor associated with the density class. This is the factor by which an
|
||||
/// image/resource should be scaled to match its size across devices. The baseline is a 160dpi
|
||||
/// screen (i.e., Hi-DPI factor = DPI / 160).
|
||||
/// See [the Android screen density
|
||||
/// docs](https://developer.android.com/training/multiscreen/screendensities#TaskProvideAltBmp)
|
||||
///
|
||||
/// There are some [`Density`] values that have no associated DPI; these values return [`None`].
|
||||
pub fn approx_hidpi_factor(self) -> Option<f64> {
|
||||
match self {
|
||||
Self::Default => Some(1.), // Or should it be None?
|
||||
Self::Low => Some(0.75),
|
||||
Self::Medium => Some(1.),
|
||||
Self::High => Some(1.5),
|
||||
Self::XHigh => Some(2.),
|
||||
Self::XXHigh => Some(3.),
|
||||
Self::XXXHigh => Some(4.),
|
||||
Self::TV => Some(4. / 3.),
|
||||
Self::Any => None,
|
||||
Self::None => None,
|
||||
Self::__Unknown(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||
#[repr(i32)]
|
||||
#[non_exhaustive]
|
||||
pub enum Keyboard {
|
||||
Any = ffi::ACONFIGURATION_KEYBOARD_ANY as i32,
|
||||
NoKeys = ffi::ACONFIGURATION_KEYBOARD_NOKEYS as i32,
|
||||
Qwerty = ffi::ACONFIGURATION_KEYBOARD_QWERTY as i32,
|
||||
TwelveKey = ffi::ACONFIGURATION_KEYBOARD_12KEY as i32,
|
||||
|
||||
#[doc(hidden)]
|
||||
#[num_enum(catch_all)]
|
||||
__Unknown(i32),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||
#[repr(i32)]
|
||||
#[non_exhaustive]
|
||||
pub enum Navigation {
|
||||
Any = ffi::ACONFIGURATION_NAVIGATION_ANY as i32,
|
||||
NoNav = ffi::ACONFIGURATION_NAVIGATION_NONAV as i32,
|
||||
DPad = ffi::ACONFIGURATION_NAVIGATION_DPAD as i32,
|
||||
Trackball = ffi::ACONFIGURATION_NAVIGATION_TRACKBALL as i32,
|
||||
Wheel = ffi::ACONFIGURATION_NAVIGATION_WHEEL as i32,
|
||||
|
||||
#[doc(hidden)]
|
||||
#[num_enum(catch_all)]
|
||||
__Unknown(i32),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||
#[repr(i32)]
|
||||
#[non_exhaustive]
|
||||
pub enum KeysHidden {
|
||||
Any = ffi::ACONFIGURATION_KEYSHIDDEN_ANY as i32,
|
||||
No = ffi::ACONFIGURATION_KEYSHIDDEN_NO as i32,
|
||||
Yes = ffi::ACONFIGURATION_KEYSHIDDEN_YES as i32,
|
||||
Soft = ffi::ACONFIGURATION_KEYSHIDDEN_SOFT as i32,
|
||||
|
||||
#[doc(hidden)]
|
||||
#[num_enum(catch_all)]
|
||||
__Unknown(i32),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||
#[repr(i32)]
|
||||
#[non_exhaustive]
|
||||
pub enum NavHidden {
|
||||
Any = ffi::ACONFIGURATION_NAVHIDDEN_ANY as i32,
|
||||
No = ffi::ACONFIGURATION_NAVHIDDEN_NO as i32,
|
||||
Yes = ffi::ACONFIGURATION_NAVHIDDEN_YES as i32,
|
||||
|
||||
#[doc(hidden)]
|
||||
#[num_enum(catch_all)]
|
||||
__Unknown(i32),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||
#[repr(i32)]
|
||||
#[non_exhaustive]
|
||||
pub enum ScreenSize {
|
||||
Any = ffi::ACONFIGURATION_SCREENSIZE_ANY as i32,
|
||||
Small = ffi::ACONFIGURATION_SCREENSIZE_SMALL as i32,
|
||||
Normal = ffi::ACONFIGURATION_SCREENSIZE_NORMAL as i32,
|
||||
Large = ffi::ACONFIGURATION_SCREENSIZE_LARGE as i32,
|
||||
XLarge = ffi::ACONFIGURATION_SCREENSIZE_XLARGE as i32,
|
||||
|
||||
#[doc(hidden)]
|
||||
#[num_enum(catch_all)]
|
||||
__Unknown(i32),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||
#[repr(i32)]
|
||||
#[non_exhaustive]
|
||||
pub enum ScreenLong {
|
||||
Any = ffi::ACONFIGURATION_SCREENLONG_ANY as i32,
|
||||
No = ffi::ACONFIGURATION_SCREENLONG_NO as i32,
|
||||
Yes = ffi::ACONFIGURATION_SCREENLONG_YES as i32,
|
||||
|
||||
#[doc(hidden)]
|
||||
#[num_enum(catch_all)]
|
||||
__Unknown(i32),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||
#[repr(i32)]
|
||||
#[non_exhaustive]
|
||||
pub enum ScreenRound {
|
||||
Any = ffi::ACONFIGURATION_SCREENROUND_ANY as i32,
|
||||
No = ffi::ACONFIGURATION_SCREENROUND_NO as i32,
|
||||
Yes = ffi::ACONFIGURATION_SCREENROUND_YES as i32,
|
||||
|
||||
#[doc(hidden)]
|
||||
#[num_enum(catch_all)]
|
||||
__Unknown(i32),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||
#[repr(i32)]
|
||||
#[non_exhaustive]
|
||||
pub enum WideColorGamut {
|
||||
Any = ffi::ACONFIGURATION_WIDE_COLOR_GAMUT_ANY as i32,
|
||||
No = ffi::ACONFIGURATION_WIDE_COLOR_GAMUT_NO as i32,
|
||||
Yes = ffi::ACONFIGURATION_WIDE_COLOR_GAMUT_YES as i32,
|
||||
|
||||
#[doc(hidden)]
|
||||
#[num_enum(catch_all)]
|
||||
__Unknown(i32),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||
#[repr(i32)]
|
||||
#[non_exhaustive]
|
||||
pub enum HDR {
|
||||
Any = ffi::ACONFIGURATION_HDR_ANY as i32,
|
||||
No = ffi::ACONFIGURATION_HDR_NO as i32,
|
||||
Yes = ffi::ACONFIGURATION_HDR_YES as i32,
|
||||
|
||||
#[doc(hidden)]
|
||||
#[num_enum(catch_all)]
|
||||
__Unknown(i32),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||
#[repr(i32)]
|
||||
#[non_exhaustive]
|
||||
pub enum LayoutDir {
|
||||
Any = ffi::ACONFIGURATION_LAYOUTDIR_ANY as i32,
|
||||
Ltr = ffi::ACONFIGURATION_LAYOUTDIR_LTR as i32,
|
||||
Rtl = ffi::ACONFIGURATION_LAYOUTDIR_RTL as i32,
|
||||
|
||||
#[doc(hidden)]
|
||||
#[num_enum(catch_all)]
|
||||
__Unknown(i32),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||
#[repr(i32)]
|
||||
#[non_exhaustive]
|
||||
pub enum UiModeType {
|
||||
Any = ffi::ACONFIGURATION_UI_MODE_TYPE_ANY as i32,
|
||||
Normal = ffi::ACONFIGURATION_UI_MODE_TYPE_NORMAL as i32,
|
||||
Desk = ffi::ACONFIGURATION_UI_MODE_TYPE_DESK as i32,
|
||||
Car = ffi::ACONFIGURATION_UI_MODE_TYPE_CAR as i32,
|
||||
Television = ffi::ACONFIGURATION_UI_MODE_TYPE_TELEVISION as i32,
|
||||
Applicance = ffi::ACONFIGURATION_UI_MODE_TYPE_APPLIANCE as i32,
|
||||
Watch = ffi::ACONFIGURATION_UI_MODE_TYPE_WATCH as i32,
|
||||
VrHeadset = ffi::ACONFIGURATION_UI_MODE_TYPE_VR_HEADSET as i32,
|
||||
|
||||
#[doc(hidden)]
|
||||
#[num_enum(catch_all)]
|
||||
__Unknown(i32),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||
#[repr(i32)]
|
||||
#[non_exhaustive]
|
||||
pub enum UiModeNight {
|
||||
Any = ffi::ACONFIGURATION_UI_MODE_NIGHT_ANY as i32,
|
||||
No = ffi::ACONFIGURATION_UI_MODE_NIGHT_NO as i32,
|
||||
Yes = ffi::ACONFIGURATION_UI_MODE_NIGHT_YES as i32,
|
||||
|
||||
#[doc(hidden)]
|
||||
#[num_enum(catch_all)]
|
||||
__Unknown(i32),
|
||||
}
|
||||
+648
@@ -0,0 +1,648 @@
|
||||
//! Bindings for [`ADataSpace`]
|
||||
//!
|
||||
//! [`ADataSpace`]: https://developer.android.com/ndk/reference/group/a-data-space#group___a_data_space_1ga2759ad19cae46646cc5f7002758c4a1c
|
||||
#![cfg(feature = "api-level-28")]
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use num_enum::{FromPrimitive, IntoPrimitive};
|
||||
|
||||
/// Describes how to interpret colors.
|
||||
///
|
||||
/// <https://developer.android.com/ndk/reference/group/a-data-space#group___a_data_space_1ga2759ad19cae46646cc5f7002758c4a1c>
|
||||
#[repr(i32)]
|
||||
#[derive(Clone, Copy, Hash, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||
#[doc(alias = "ADataSpace")]
|
||||
#[non_exhaustive]
|
||||
pub enum DataSpace {
|
||||
/// Default-assumption data space, when not explicitly specified.
|
||||
///
|
||||
/// It is safest to assume the buffer is an image with `sRGB` primaries and encoding ranges,
|
||||
/// but the consumer and/or the producer of the data may simply be using defaults. No automatic
|
||||
/// gamma transform should be expected, except for a possible display gamma transform when drawn
|
||||
/// to a screen.
|
||||
#[doc(alias = "ADATASPACE_UNKNOWN")]
|
||||
Unknown = ffi::ADataSpace::ADATASPACE_UNKNOWN.0,
|
||||
|
||||
/// Adobe RGB.
|
||||
///
|
||||
/// Uses [full range], [gamma `2.2` transfer] and [Adobe RGB standard].
|
||||
///
|
||||
/// Note: Application is responsible for gamma encoding the data as a `2.2` gamma encoding is
|
||||
/// not supported in HW.
|
||||
///
|
||||
/// [full range]: DataSpaceRange::Full
|
||||
/// [gamma `2.2` transfer]: DataSpaceTransfer::Gamma2_2
|
||||
/// [Adobe RGB standard]: DataSpaceStandard::AdobeRgb
|
||||
#[doc(alias = "ADATASPACE_ADOBE_RGB")]
|
||||
AdobeRgb = ffi::ADataSpace::ADATASPACE_ADOBE_RGB.0,
|
||||
/// ITU-R Recommendation 2020 (`BT.2020`).
|
||||
///
|
||||
/// Ultra High-definition television.
|
||||
///
|
||||
/// Uses [full range], [`SMPTE 170M` transfer] and [`BT2020` standard].
|
||||
///
|
||||
/// [full range]: DataSpaceRange::Full
|
||||
/// [`SMPTE 170M` transfer]: DataSpaceTransfer::Smpte170M
|
||||
/// [`BT2020` standard]: DataSpaceStandard::Bt2020
|
||||
#[doc(alias = "ADATASPACE_BT2020")]
|
||||
Bt2020 = ffi::ADataSpace::ADATASPACE_BT2020.0,
|
||||
/// Hybrid Log Gamma encoding.
|
||||
///
|
||||
/// Uses [full range], [hybrid log gamma transfer] and [`BT2020` standard].
|
||||
///
|
||||
/// [full range]: DataSpaceRange::Full
|
||||
/// [hybrid log gamma transfer]: DataSpaceTransfer::HLG
|
||||
/// [`BT2020` standard]: DataSpaceStandard::Bt2020
|
||||
#[doc(alias = "ADATASPACE_BT2020_HLG")]
|
||||
Bt2020Hlg = ffi::ADataSpace::ADATASPACE_BT2020_HLG.0,
|
||||
/// ITU Hybrid Log Gamma encoding.
|
||||
///
|
||||
/// Uses [limited range], [hybrid log gamma transfer] and [`BT2020` standard].
|
||||
///
|
||||
/// [limited range]: DataSpaceRange::Limited
|
||||
/// [hybrid log gamma transfer]: DataSpaceTransfer::HLG
|
||||
/// [`BT2020` standard]: DataSpaceStandard::Bt2020
|
||||
#[doc(alias = "ADATASPACE_BT2020_ITU_HLG")]
|
||||
Bt2020ItuHlg = ffi::ADataSpace::ADATASPACE_BT2020_ITU_HLG.0,
|
||||
/// ITU-R Recommendation 2020 (`BT.2020`).
|
||||
///
|
||||
/// Ultra High-definition television.
|
||||
///
|
||||
/// Uses [limited range], [`SMPTE 2084 (PQ)` transfer] and [`BT2020` standard].
|
||||
///
|
||||
/// [limited range]: DataSpaceRange::Limited
|
||||
/// [`SMPTE 2084 (PQ)` transfer]: DataSpaceTransfer::St2084
|
||||
/// [`BT2020` standard]: DataSpaceStandard::Bt2020
|
||||
#[doc(alias = "ADATASPACE_BT2020_ITU_PQ")]
|
||||
Bt2020ItuPq = ffi::ADataSpace::ADATASPACE_BT2020_ITU_PQ.0,
|
||||
/// ITU-R Recommendation 2020 (`BT.2020`).
|
||||
///
|
||||
/// Ultra High-definition television.
|
||||
///
|
||||
/// Uses [full range], [`SMPTE 2084 (PQ)` transfer] and [`BT2020` standard].
|
||||
///
|
||||
/// [full range]: DataSpaceRange::Full
|
||||
/// [`SMPTE 2084 (PQ)` transfer]: DataSpaceTransfer::St2084
|
||||
/// [`BT2020` standard]: DataSpaceStandard::Bt2020
|
||||
#[doc(alias = "ADATASPACE_BT2020_PQ")]
|
||||
Bt2020Pq = ffi::ADataSpace::ADATASPACE_BT2020_PQ.0,
|
||||
/// ITU-R Recommendation 601 (`BT.601`) - 525-line.
|
||||
///
|
||||
/// Standard-definition television, 525 Lines (NTSC).
|
||||
///
|
||||
/// Uses [limited range], [`SMPTE 170M` transfer] and [`BT.601_525` standard].
|
||||
///
|
||||
/// [limited range]: DataSpaceRange::Limited
|
||||
/// [`SMPTE 170M` transfer]: DataSpaceTransfer::Smpte170M
|
||||
/// [`BT.601_525` standard]: DataSpaceStandard::Bt601_525
|
||||
#[doc(alias = "ADATASPACE_BT601_525")]
|
||||
Bt601_525 = ffi::ADataSpace::ADATASPACE_BT601_525.0,
|
||||
/// ITU-R Recommendation 601 (`BT.601`) - 625-line.
|
||||
///
|
||||
/// Standard-definition television, 625 Lines (PAL).
|
||||
///
|
||||
/// Uses [limited range], [`SMPTE 170M` transfer] and [`BT.601_625` standard].
|
||||
///
|
||||
/// [limited range]: DataSpaceRange::Limited
|
||||
/// [`SMPTE 170M` transfer]: DataSpaceTransfer::Smpte170M
|
||||
/// [`BT.601_625` standard]: DataSpaceStandard::Bt601_625
|
||||
#[doc(alias = "ADATASPACE_BT601_625")]
|
||||
Bt601_625 = ffi::ADataSpace::ADATASPACE_BT601_625.0,
|
||||
/// ITU-R Recommendation 709 (`BT.709`).
|
||||
///
|
||||
/// High-definition television.
|
||||
///
|
||||
/// Uses [limited range], [`SMPTE 170M` transfer] and [`BT.709` standard].
|
||||
///
|
||||
/// [limited range]: DataSpaceRange::Limited
|
||||
/// [`SMPTE 170M` transfer]: DataSpaceTransfer::Smpte170M
|
||||
/// [`BT.709` standard]: DataSpaceStandard::Bt709
|
||||
#[doc(alias = "ADATASPACE_BT709")]
|
||||
Bt709 = ffi::ADataSpace::ADATASPACE_BT709.0,
|
||||
/// `SMPTE EG 432-1` and `SMPTE RP 431-2`.
|
||||
///
|
||||
/// Digital Cinema `DCI-P3`.
|
||||
///
|
||||
/// Uses [full range], [gamma `2.6` transfer] and [`D65` `DCI-P3` standard].
|
||||
///
|
||||
/// Note: Application is responsible for gamma encoding the data as a `2.6` gamma encoding is
|
||||
/// not supported in HW.
|
||||
///
|
||||
/// [full range]: DataSpaceRange::Full
|
||||
/// [gamma `2.6` transfer]: DataSpaceTransfer::Gamma2_6
|
||||
/// [`D65` `DCI-P3` standard]: DataSpaceStandard::DciP3
|
||||
#[doc(alias = "ADATASPACE_DCI_P3")]
|
||||
DciP3 = ffi::ADataSpace::ADATASPACE_DCI_P3.0,
|
||||
/// Display P3.
|
||||
///
|
||||
/// Uses [full range], [`sRGB` transfer] and [`D65` `DCI-P3` standard].
|
||||
///
|
||||
/// [full range]: DataSpaceRange::Full
|
||||
/// [`sRGB` transfer]: DataSpaceTransfer::Srgb
|
||||
/// [`D65` `DCI-P3` standard]: DataSpaceStandard::DciP3
|
||||
#[doc(alias = "ADATASPACE_DISPLAY_P3")]
|
||||
DisplayP3 = ffi::ADataSpace::ADATASPACE_DISPLAY_P3.0,
|
||||
/// JPEG File Interchange Format (`JFIF`).
|
||||
///
|
||||
/// Same model as `BT.601-625`, but all values (`Y`, `Cb`, `Cr`) range from `0` to `255`.
|
||||
///
|
||||
/// Uses [full range], [`SMPTE 170M` transfer] and [`BT.601_625` standard].
|
||||
///
|
||||
/// [full range]: DataSpaceRange::Full
|
||||
/// [`SMPTE 170M` transfer]: DataSpaceTransfer::Smpte170M
|
||||
/// [`BT.601_625` standard]: DataSpaceStandard::Bt601_625
|
||||
#[doc(alias = "ADATASPACE_JFIF")]
|
||||
Jfif = ffi::ADataSpace::ADATASPACE_JFIF.0,
|
||||
/// `scRGB`.
|
||||
///
|
||||
/// The `red`, `green`, and `blue` components are stored in [extended][extended range] `sRGB`
|
||||
/// space, and gamma- encoded using the [`sRGB` transfer] function.
|
||||
///
|
||||
/// The values are floating point. A pixel value of `1.0`, `1.0`, `1.0` corresponds to `sRGB`
|
||||
/// white (`D65`) at `80` nits. Values beyond the range `[0.0 - 1.0]` would correspond to other
|
||||
/// colors spaces and/or HDR content.
|
||||
///
|
||||
/// Uses [extended range], [`sRGB` transfer] and [`BT.709` standard].
|
||||
///
|
||||
/// [extended range]: DataSpaceRange::Extended
|
||||
/// [`sRGB` transfer]: DataSpaceTransfer::Srgb
|
||||
/// [`BT.709` standard]: DataSpaceStandard::Bt709
|
||||
#[doc(alias = "ADATASPACE_SCRGB")]
|
||||
Scrgb = ffi::ADataSpace::ADATASPACE_SCRGB.0,
|
||||
/// `scRGB` linear encoding
|
||||
///
|
||||
/// The `red`, `green`, and `blue` components are stored in [extended][extended range] `sRGB`
|
||||
/// space, but are linear, not gamma-encoded.
|
||||
///
|
||||
/// The values are floating point. A pixel value of `1.0`, `1.0`, `1.0` corresponds to `sRGB`
|
||||
/// white (`D65`) at `80` nits. Values beyond the range `[0.0 - 1.0]` would correspond to other
|
||||
/// colors spaces and/or HDR content.
|
||||
///
|
||||
/// Uses [extended range], [linear transfer] and [`BT.709` standard].
|
||||
///
|
||||
/// [extended range]: DataSpaceRange::Extended
|
||||
/// [linear transfer]: DataSpaceTransfer::Linear
|
||||
/// [`BT.709` standard]: DataSpaceStandard::Bt709
|
||||
#[doc(alias = "ADATASPACE_SCRGB_LINEAR")]
|
||||
ScrgbLinear = ffi::ADataSpace::ADATASPACE_SCRGB_LINEAR.0,
|
||||
/// `sRGB` gamma encoding.
|
||||
///
|
||||
/// The `red`, `green` and `blue` components are stored in `sRGB` space, and converted to linear
|
||||
/// space when read, using the [`sRGB` transfer] function for each of the `R`, `G` and `B`
|
||||
/// components. When written, the inverse transformation is performed.
|
||||
///
|
||||
/// The `alpha` component, if present, is always stored in linear space and is left unmodified
|
||||
/// when read or written.
|
||||
///
|
||||
/// Uses [full range], [`sRGB` transfer] and [`BT.709` standard].
|
||||
///
|
||||
/// [full range]: DataSpaceRange::Full
|
||||
/// [`sRGB` transfer]: DataSpaceTransfer::Srgb
|
||||
/// [`BT.709` standard]: DataSpaceStandard::Bt709
|
||||
#[doc(alias = "ADATASPACE_SRGB")]
|
||||
Srgb = ffi::ADataSpace::ADATASPACE_SRGB.0,
|
||||
/// `sRGB` linear encoding.
|
||||
///
|
||||
/// The `red`, `green`, and `blue` components are stored in `sRGB` space, but are linear, not
|
||||
/// gamma-encoded. The `RGB` primaries and the white point are the same as [`BT.709]`.
|
||||
///
|
||||
/// The values are encoded using the [full range] (`[0, 255]` for 8-bit) for all components.
|
||||
///
|
||||
/// Uses [full range], [linear transfer] and [`BT.709` standard].
|
||||
///
|
||||
/// [full range]: DataSpaceRange::Full
|
||||
/// [linear transfer]: DataSpaceTransfer::Linear
|
||||
/// [`BT.709` standard]: DataSpaceStandard::Bt709
|
||||
#[doc(alias = "ADATASPACE_SRGB_LINEAR")]
|
||||
SrgbLinear = ffi::ADataSpace::ADATASPACE_SRGB_LINEAR.0,
|
||||
|
||||
/// Depth.
|
||||
///
|
||||
/// This value is valid with formats [`HAL_PIXEL_FORMAT_Y16`] and [`HAL_PIXEL_FORMAT_BLOB`].
|
||||
///
|
||||
/// [`HAL_PIXEL_FORMAT_Y16`]: https://cs.android.com/android/platform/superproject/main/+/main:frameworks/native/libs/nativewindow/include/vndk/hardware_buffer.h;l=74-75;drc=45317f5c7c966fc816843217adc96a2ddea8bf29
|
||||
/// [`HAL_PIXEL_FORMAT_BLOB`]: super::hardware_buffer_format::HardwareBufferFormat::BLOB
|
||||
#[doc(alias = "ADATASPACE_DEPTH")]
|
||||
Depth = ffi::ADataSpace::ADATASPACE_DEPTH.0,
|
||||
/// ISO `16684-1:2011(E)` Dynamic Depth.
|
||||
///
|
||||
/// Embedded depth metadata following the dynamic depth specification.
|
||||
#[doc(alias = "ADATASPACE_DYNAMIC_DEPTH")]
|
||||
DynamicDepth = ffi::ADataSpace::ADATASPACE_DYNAMIC_DEPTH.0,
|
||||
|
||||
#[doc(hidden)]
|
||||
#[num_enum(catch_all)]
|
||||
__Unknown(i32),
|
||||
}
|
||||
|
||||
impl fmt::Display for DataSpace {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::Unknown => "Unknown",
|
||||
Self::AdobeRgb => "AdobeRgb",
|
||||
Self::Bt2020 => "Bt2020",
|
||||
Self::Bt2020Hlg => "Bt2020Hlg",
|
||||
Self::Bt2020ItuHlg => "Bt2020ItuHlg",
|
||||
Self::Bt2020ItuPq => "Bt2020ItuPq",
|
||||
Self::Bt2020Pq => "Bt2020Pq",
|
||||
Self::Bt601_525 => "Bt601_525",
|
||||
Self::Bt601_625 => "Bt601_625",
|
||||
Self::Bt709 => "Bt709",
|
||||
Self::DciP3 => "DciP3",
|
||||
Self::DisplayP3 => "DisplayP3",
|
||||
Self::Jfif => "Jfif",
|
||||
Self::Scrgb => "Scrgb",
|
||||
Self::ScrgbLinear => "ScrgbLinear",
|
||||
Self::Srgb => "Srgb",
|
||||
Self::SrgbLinear => "SrgbLinear",
|
||||
Self::Depth => "Depth",
|
||||
Self::DynamicDepth => "DynamicDepth",
|
||||
Self::__Unknown(u) => {
|
||||
return write!(
|
||||
f,
|
||||
"Unknown DataSpace({u:x?}, standard: {:?}, transfer: {:?}, range: {:?})",
|
||||
self.standard(),
|
||||
self.transfer(),
|
||||
self.range()
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for DataSpace {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"DataSpace({self}, standard: {:?}, transfer: {:?}, range: {:?})",
|
||||
self.standard(),
|
||||
self.transfer(),
|
||||
self.range(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl DataSpace {
|
||||
/// Construct a [`DataSpace`] from individual `standard`, `transfer` and `range` components.
|
||||
///
|
||||
/// Together these should correspond to a single format.
|
||||
pub fn from_parts(
|
||||
standard: DataSpaceStandard,
|
||||
transfer: DataSpaceTransfer,
|
||||
range: DataSpaceRange,
|
||||
) -> Self {
|
||||
Self::from(i32::from(standard) | i32::from(transfer) | i32::from(range))
|
||||
}
|
||||
|
||||
/// Extracts and returns the color-description aspect from this [`DataSpace`].
|
||||
#[doc(alias = "STANDARD_MASK")]
|
||||
pub fn standard(self) -> DataSpaceStandard {
|
||||
let standard = i32::from(self) & ffi::ADataSpace::STANDARD_MASK.0;
|
||||
standard.into()
|
||||
}
|
||||
|
||||
/// Extracts and returns the transfer aspect from this [`DataSpace`].
|
||||
#[doc(alias = "TRANSFER_MASK")]
|
||||
pub fn transfer(self) -> DataSpaceTransfer {
|
||||
let transfer = i32::from(self) & ffi::ADataSpace::TRANSFER_MASK.0;
|
||||
transfer.into()
|
||||
}
|
||||
|
||||
/// Extracts and returns the range aspect from this [`DataSpace`].
|
||||
#[doc(alias = "RANGE_MASK")]
|
||||
pub fn range(self) -> DataSpaceRange {
|
||||
let range = i32::from(self) & ffi::ADataSpace::RANGE_MASK.0;
|
||||
range.into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Color-description aspects.
|
||||
///
|
||||
/// The following aspects define various characteristics of the color specification. These represent
|
||||
/// bitfields, so that a data space value can specify each of them independently. Standard aspect
|
||||
/// defines the chromaticity coordinates of the source primaries in terms of the CIE 1931 definition
|
||||
/// of `x` and `y` specified in ISO 11664-1.
|
||||
#[repr(i32)]
|
||||
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||
#[doc(alias = "STANDARD_MASK")]
|
||||
#[non_exhaustive]
|
||||
pub enum DataSpaceStandard {
|
||||
/// Chromacity coordinates are unknown or are determined by the application. Implementations
|
||||
/// shall use the following suggested standards:
|
||||
///
|
||||
/// All `YCbCr` formats: [`BT.709`] if size is `720p` or larger (since most video content is
|
||||
/// letterboxed this corresponds to width is `1280` or greater, or height
|
||||
/// is 720 or greater). [`BT.601_625`] if size is smaller than `720p` or
|
||||
/// is `JPEG`.
|
||||
/// All `RGB` formats: [`BT.709`].
|
||||
///
|
||||
/// For all other formats the standard is undefined, and implementations should use an
|
||||
/// appropriate standard for the data represented.
|
||||
///
|
||||
/// [`BT.709`]: Self::Bt709
|
||||
/// [`BT.601_625`]: Self::Bt601_625
|
||||
#[doc(alias = "STANDARD_UNSPECIFIED")]
|
||||
Unspecified = ffi::ADataSpace::STANDARD_UNSPECIFIED.0,
|
||||
/// | Primaries | x | y |
|
||||
/// | ----------- | ------ | ------ |
|
||||
/// | green | 0.300 | 0.600 |
|
||||
/// | blue | 0.150 | 0.060 |
|
||||
/// | red | 0.640 | 0.330 |
|
||||
/// | white (D65) | 0.3127 | 0.3290 |
|
||||
///
|
||||
/// Use the unadjusted `KR = 0.2126`, `KB = 0.0722` luminance interpretation for `RGB`
|
||||
/// conversion.
|
||||
#[doc(alias = "STANDARD_BT709")]
|
||||
Bt709 = ffi::ADataSpace::STANDARD_BT709.0,
|
||||
/// | Primaries | x | y |
|
||||
/// | ----------- | ------ | ------ |
|
||||
/// | green | 0.290 | 0.600 |
|
||||
/// | blue | 0.150 | 0.060 |
|
||||
/// | red | 0.640 | 0.330 |
|
||||
/// | white (D65) | 0.3127 | 0.3290 |
|
||||
///
|
||||
/// `KR = 0.299`, `KB = 0.114`. This adjusts the luminance interpretation for `RGB` conversion
|
||||
/// from the one purely determined by the primaries to minimize the color shift into `RGB`
|
||||
/// space that uses [`BT.709`] primaries.
|
||||
///
|
||||
/// [`BT.709`]: Self::Bt709
|
||||
#[doc(alias = "STANDARD_BT601_625")]
|
||||
Bt601_625 = ffi::ADataSpace::STANDARD_BT601_625.0,
|
||||
/// | Primaries | x | y |
|
||||
/// | ----------- | ------ | ------ |
|
||||
/// | green | 0.290 | 0.600 |
|
||||
/// | blue | 0.150 | 0.060 |
|
||||
/// | red | 0.640 | 0.330 |
|
||||
/// | white (D65) | 0.3127 | 0.3290 |
|
||||
///
|
||||
/// Use the unadjusted `KR = 0.222`, `KB = 0.071` luminance interpretation for `RGB` conversion.
|
||||
#[doc(alias = "STANDARD_BT601_625_UNADJUSTED")]
|
||||
Bt601_625Unadjusted = ffi::ADataSpace::STANDARD_BT601_625_UNADJUSTED.0,
|
||||
/// | Primaries | x | y |
|
||||
/// | ----------- | ------ | ------ |
|
||||
/// | green | 0.310 | 0.595 |
|
||||
/// | blue | 0.155 | 0.070 |
|
||||
/// | red | 0.630 | 0.340 |
|
||||
/// | white (D65) | 0.3127 | 0.3290 |
|
||||
///
|
||||
/// `KR = 0.299`, `KB = 0.114`. This adjusts the luminance interpretation for `RGB` conversion
|
||||
/// from the one purely determined by the primaries to minimize the color shift into `RGB` space
|
||||
/// that uses [`BT.709`] primaries.
|
||||
///
|
||||
/// [`BT.709`]: Self::Bt709
|
||||
#[doc(alias = "STANDARD_BT601_525")]
|
||||
Bt601_525 = ffi::ADataSpace::STANDARD_BT601_525.0,
|
||||
/// | Primaries | x | y |
|
||||
/// | ----------- | ------ | ------ |
|
||||
/// | green | 0.310 | 0.595 |
|
||||
/// | blue | 0.155 | 0.070 |
|
||||
/// | red | 0.630 | 0.340 |
|
||||
/// | white (D65) | 0.3127 | 0.3290 |
|
||||
///
|
||||
/// Use the unadjusted `KR = 0.212`, `KB = 0.087` luminance interpretation
|
||||
/// for `RGB` conversion (as in `SMPTE 240M`).
|
||||
#[doc(alias = "STANDARD_BT601_525_UNADJUSTED")]
|
||||
Bt601_525Unadjusted = ffi::ADataSpace::STANDARD_BT601_525_UNADJUSTED.0,
|
||||
/// | Primaries | x | y |
|
||||
/// | ----------- | ------ | ------ |
|
||||
/// | green | 0.170 | 0.797 |
|
||||
/// | blue | 0.131 | 0.046 |
|
||||
/// | red | 0.708 | 0.292 |
|
||||
/// | white (D65) | 0.3127 | 0.3290 |
|
||||
///
|
||||
/// Use the unadjusted `KR = 0.2627`, `KB = 0.0593` luminance interpretation for `RGB`
|
||||
/// conversion.
|
||||
#[doc(alias = "STANDARD_BT2020")]
|
||||
Bt2020 = ffi::ADataSpace::STANDARD_BT2020.0,
|
||||
/// | Primaries | x | y |
|
||||
/// | ----------- | ------ | ------ |
|
||||
/// | green | 0.170 | 0.797 |
|
||||
/// | blue | 0.131 | 0.046 |
|
||||
/// | red | 0.708 | 0.292 |
|
||||
/// | white (D65) | 0.3127 | 0.3290 |
|
||||
///
|
||||
/// Use the unadjusted `KR = 0.2627`, `KB = 0.0593` luminance interpretation for `RGB`
|
||||
/// conversion using the linear domain.
|
||||
#[doc(alias = "STANDARD_BT2020_CONSTANT_LUMINANCE")]
|
||||
Bt2020ConstantLuminance = ffi::ADataSpace::STANDARD_BT2020_CONSTANT_LUMINANCE.0,
|
||||
/// | Primaries | x | y |
|
||||
/// | --------- | ----- | ---- |
|
||||
/// | green | 0.21 |0.71 |
|
||||
/// | blue | 0.14 |0.08 |
|
||||
/// | red | 0.67 |0.33 |
|
||||
/// | white (C) | 0.310 |0.316 |
|
||||
///
|
||||
/// Use the unadjusted `KR = 0.30`, `KB = 0.11` luminance interpretation for `RGB` conversion.
|
||||
#[doc(alias = "STANDARD_BT470M")]
|
||||
Bt470M = ffi::ADataSpace::STANDARD_BT470M.0,
|
||||
/// | Primaries | x | y |
|
||||
/// | --------- | ----- | ----- |
|
||||
/// | green | 0.243 | 0.692 |
|
||||
/// | blue | 0.145 | 0.049 |
|
||||
/// | red | 0.681 | 0.319 |
|
||||
/// | white (C) | 0.310 | 0.316 |
|
||||
///
|
||||
/// Use the unadjusted `KR = 0.254`, `KB = 0.068` luminance interpretation for `RGB` conversion.
|
||||
#[doc(alias = "STANDARD_FILM")]
|
||||
Film = ffi::ADataSpace::STANDARD_FILM.0,
|
||||
/// `SMPTE EG 432-1` and `SMPTE RP 431-2`. (`DCI-P3`)
|
||||
///
|
||||
/// | Primaries | x | y |
|
||||
/// | ----------- | ------ | ------ |
|
||||
/// | green | 0.265 | 0.690 |
|
||||
/// | blue | 0.150 | 0.060 |
|
||||
/// | red | 0.680 | 0.320 |
|
||||
/// | white (D65) | 0.3127 | 0.3290 |
|
||||
#[doc(alias = "STANDARD_DCI_P3")]
|
||||
DciP3 = ffi::ADataSpace::STANDARD_DCI_P3.0,
|
||||
/// Adobe RGB
|
||||
///
|
||||
/// | Primaries | x | y |
|
||||
/// | ----------- | ------ | ------ |
|
||||
/// | green | 0.210 | 0.710 |
|
||||
/// | blue | 0.150 | 0.060 |
|
||||
/// | red | 0.640 | 0.330 |
|
||||
/// | white (D65) | 0.3127 | 0.3290 |
|
||||
#[doc(alias = "STANDARD_ADOBE_RGB")]
|
||||
AdobeRgb = ffi::ADataSpace::STANDARD_ADOBE_RGB.0,
|
||||
|
||||
#[doc(hidden)]
|
||||
#[num_enum(catch_all)]
|
||||
__Unknown(i32),
|
||||
}
|
||||
|
||||
/// Transfer aspect.
|
||||
///
|
||||
/// Transfer characteristics are the opto-electronic transfer characteristic at the source as a
|
||||
///function of linear optical intensity (luminance).
|
||||
///
|
||||
/// For digital signals, `E` corresponds to the recorded value. Normally, the transfer function is
|
||||
/// applied in `RGB` space to each of the `R`, `G` and `B` components independently. This may result
|
||||
/// in color shift that can be minimized by applying the transfer function in `Lab` space only for
|
||||
/// the `L` component. Implementation may apply the transfer function in `RGB` space for all pixel
|
||||
/// formats if desired.
|
||||
#[repr(i32)]
|
||||
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||
#[doc(alias = "TRANSFER_MASK")]
|
||||
#[non_exhaustive]
|
||||
pub enum DataSpaceTransfer {
|
||||
/// Transfer characteristics are unknown or are determined by the application.
|
||||
///
|
||||
/// Implementations should use the following transfer functions:
|
||||
///
|
||||
/// - For `YCbCr` formats: use [`DataSpaceTransfer::Smpte170M`]
|
||||
/// - For `RGB` formats: use [`DataSpaceTransfer::Srgb`]
|
||||
///
|
||||
/// For all other formats the transfer function is undefined, and implementations should use an
|
||||
/// appropriate standard for the data represented.
|
||||
#[doc(alias = "TRANSFER_UNSPECIFIED")]
|
||||
Unspecified = ffi::ADataSpace::TRANSFER_UNSPECIFIED.0,
|
||||
|
||||
/// Linear transfer.
|
||||
///
|
||||
/// Transfer characteristic curve:
|
||||
/// ```ignore
|
||||
/// E = L
|
||||
/// ```
|
||||
/// - `L`: luminance of image `0 <= L <= 1` for conventional colorimetry
|
||||
/// - `E`: corresponding electrical signal
|
||||
#[doc(alias = "TRANSFER_LINEAR")]
|
||||
Linear = ffi::ADataSpace::TRANSFER_LINEAR.0,
|
||||
/// `sRGB` transfer.
|
||||
///
|
||||
/// Transfer characteristic curve:
|
||||
///
|
||||
/// ```ignore
|
||||
/// E = 1.055 * L^(1/2.4) - 0.055 for 0.0031308 <= L <= 1
|
||||
/// = 12.92 * L for 0 <= L < 0.0031308
|
||||
/// ```
|
||||
/// - `L`: luminance of image `0 <= L <= 1` for conventional colorimetry
|
||||
/// - `E`: corresponding electrical signal
|
||||
#[doc(alias = "TRANSFER_SRGB")]
|
||||
Srgb = ffi::ADataSpace::TRANSFER_SRGB.0,
|
||||
/// SMPTE 170M transfer.
|
||||
///
|
||||
/// Transfer characteristic curve:
|
||||
/// ```ignore
|
||||
/// E = 1.099 * L ^ 0.45 - 0.099 for 0.018 <= L <= 1
|
||||
/// = 4.500 * L for 0 <= L < 0.018
|
||||
/// ```
|
||||
/// - `L`: luminance of image `0 <= L <= 1` for conventional colorimetry
|
||||
/// - `E`: corresponding electrical signal
|
||||
#[doc(alias = "TRANSFER_SMPTE_170M")]
|
||||
Smpte170M = ffi::ADataSpace::TRANSFER_SMPTE_170M.0,
|
||||
/// Display gamma `2.2`.
|
||||
///
|
||||
/// Transfer characteristic curve:
|
||||
/// ```ignore
|
||||
/// E = L ^ (1/2.2)
|
||||
/// ```
|
||||
/// - `L`: luminance of image `0 <= L <= 1` for conventional colorimetry
|
||||
/// - `E`: corresponding electrical signal
|
||||
#[doc(alias = "TRANSFER_GAMMA2_2")]
|
||||
Gamma2_2 = ffi::ADataSpace::TRANSFER_GAMMA2_2.0,
|
||||
/// Display gamma `2.6`.
|
||||
///
|
||||
/// Transfer characteristic curve:
|
||||
/// ```ignore
|
||||
/// E = L ^ (1/2.6)
|
||||
/// ```
|
||||
/// - `L`: luminance of image `0 <= L <= 1` for conventional colorimetry
|
||||
/// - `E`: corresponding electrical signal
|
||||
#[doc(alias = "TRANSFER_GAMMA2_6")]
|
||||
Gamma2_6 = ffi::ADataSpace::TRANSFER_GAMMA2_6.0,
|
||||
/// Display gamma `2.8`.
|
||||
///
|
||||
/// Transfer characteristic curve:
|
||||
/// ```ignore
|
||||
/// E = L ^ (1/2.8)
|
||||
/// ```
|
||||
/// - `L`: luminance of image `0 <= L <= 1` for conventional colorimetry
|
||||
/// - `E`: corresponding electrical signal
|
||||
#[doc(alias = "TRANSFER_GAMMA2_8")]
|
||||
Gamma2_8 = ffi::ADataSpace::TRANSFER_GAMMA2_8.0,
|
||||
/// SMPTE ST 2084 (Dolby Perceptual Quantizer).
|
||||
///
|
||||
/// Transfer characteristic curve:
|
||||
/// ```ignore
|
||||
/// E = ((c1 + c2 * L^n) / (1 + c3 * L^n)) ^ m
|
||||
/// c1 = c3 - c2 + 1 = 3424 / 4096 = 0.8359375
|
||||
/// c2 = 32 * 2413 / 4096 = 18.8515625
|
||||
/// c3 = 32 * 2392 / 4096 = 18.6875
|
||||
/// m = 128 * 2523 / 4096 = 78.84375
|
||||
/// n = 0.25 * 2610 / 4096 = 0.1593017578125
|
||||
/// ```
|
||||
/// - `L`: luminance of image 0 <= L <= 1 for HDR colorimetry.
|
||||
/// `L = 1` corresponds to `10000 cd/m2`
|
||||
#[doc(alias = "TRANSFER_ST2084")]
|
||||
St2084 = ffi::ADataSpace::TRANSFER_ST2084.0,
|
||||
/// ARIB STD-B67 Hybrid Log Gamma.
|
||||
///
|
||||
/// Transfer characteristic curve:
|
||||
/// ```ignore
|
||||
/// E = r * L^0.5 for 0 <= L <= 1
|
||||
/// = a * ln(L - b) + c for 1 < L
|
||||
/// a = 0.17883277
|
||||
/// b = 0.28466892
|
||||
/// c = 0.55991073
|
||||
/// r = 0.5
|
||||
/// ```
|
||||
/// - `L`: luminance of image `0 <= L` for HDR colorimetry.
|
||||
/// `L = 1` corresponds to reference white level of `100 cd/m2`
|
||||
/// - `E`: corresponding electrical signal
|
||||
#[doc(alias = "TRANSFER_HLG")]
|
||||
HLG = ffi::ADataSpace::TRANSFER_HLG.0,
|
||||
|
||||
#[doc(hidden)]
|
||||
#[num_enum(catch_all)]
|
||||
__Unknown(i32),
|
||||
}
|
||||
|
||||
/// Range aspect.
|
||||
///
|
||||
/// Defines the range of values corresponding to the unit range of `0-1`. This is defined for
|
||||
/// `YCbCr` only, but can be expanded to `RGB` space.
|
||||
#[repr(i32)]
|
||||
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
|
||||
#[doc(alias = "RANGE_MASK")]
|
||||
#[non_exhaustive]
|
||||
pub enum DataSpaceRange {
|
||||
/// Range is unknown or are determined by the application. Implementations shall use the
|
||||
/// following suggested ranges:
|
||||
///
|
||||
/// - All YCbCr formats: limited range.
|
||||
/// - All RGB or RGBA formats (including RAW and Bayer): full range.
|
||||
/// - All Y formats: full range
|
||||
///
|
||||
/// For all other formats range is undefined, and implementations should use an appropriate
|
||||
/// range for the data represented.
|
||||
#[doc(alias = "RANGE_UNSPECIFIED")]
|
||||
Unspecified = ffi::ADataSpace::RANGE_UNSPECIFIED.0,
|
||||
/// Full range uses all values for `Y`, `Cb` and `Cr` from `0` to `2^b-1`, where `b` is the bit
|
||||
/// depth of the color format.
|
||||
#[doc(alias = "RANGE_FULL")]
|
||||
Full = ffi::ADataSpace::RANGE_FULL.0,
|
||||
/// Limited range uses values `16/256*2^b` to `235/256*2^b` for `Y`, and `1/16*2^b` to
|
||||
/// `15/16*2^b` for `Cb`, `Cr`, `R`, `G` and `B`, where `b` is the bit depth of the color
|
||||
/// format.
|
||||
///
|
||||
/// E.g. For 8-bit-depth formats: Luma (`Y`) samples should range from `16` to `235`, inclusive
|
||||
/// Chroma `(Cb, Cr)` samples should range from `16` to `240`, inclusive.
|
||||
///
|
||||
/// For 10-bit-depth formats: Luma (`Y`) samples should range from `64` to `940`, inclusive
|
||||
/// Chroma `(Cb, Cr)` samples should range from `64` to `960`, inclusive.
|
||||
#[doc(alias = "RANGE_LIMITED")]
|
||||
Limited = ffi::ADataSpace::RANGE_LIMITED.0,
|
||||
/// Extended range is used for `scRGB`.
|
||||
///
|
||||
/// Intended for use with floating point pixel formats. `[0.0 - 1.0]` is the standard `sRGB`
|
||||
/// space. Values outside the range `0.0 - 1.0` can encode color outside the `sRGB` gamut. Used
|
||||
/// to blend / merge multiple dataspaces on a single display.
|
||||
#[doc(alias = "RANGE_EXTENDED")]
|
||||
Extended = ffi::ADataSpace::RANGE_EXTENDED.0,
|
||||
|
||||
#[doc(hidden)]
|
||||
#[num_enum(catch_all)]
|
||||
__Unknown(i32),
|
||||
}
|
||||
+1583
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user