Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ff62b087c |
@@ -160,22 +160,6 @@ jobs:
|
||||
key: gradle-${{ hashFiles('clients/android/**/*.gradle.kts', 'clients/android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-
|
||||
|
||||
# Clippy for the ANDROID target. Like the kit tests below, this was running NOWHERE: ci.yml
|
||||
# lints `--workspace` on the host, where `clients/android/native` and every
|
||||
# `#[cfg(target_os = "android")]` module elsewhere compile out, and this workflow only ever
|
||||
# built. Discovered in 2026-08 with five lints already resident — code no gate had ever read.
|
||||
#
|
||||
# Placed BEFORE assembleDebug deliberately: a lint failure should cost the ~10 s the lint
|
||||
# takes, not the full three-ABI build first. It shares sccache and the target dir with the
|
||||
# build that follows, so the compile is not paid twice.
|
||||
#
|
||||
# The task lints arm64-v8a AND armeabi-v7a, and reuses the build task's exact cargo-ndk
|
||||
# environment — see the long note on `registerCargoNdkClippy` in kit/build.gradle.kts for why
|
||||
# both pointer widths are load-bearing and why the environment must not be duplicated here.
|
||||
- name: Clippy (Android target, deny warnings)
|
||||
working-directory: clients/android
|
||||
run: ./gradlew :kit:cargoNdkClippy --stacktrace
|
||||
|
||||
# The kit's JVM unit tests — the pure parsers, migrations and feedback policies. They were
|
||||
# running nowhere: this workflow only assembled, and android-screenshots.yml runs the :app
|
||||
# module's tests, so nothing enforced :kit's. Cheap (a couple of seconds against an already
|
||||
@@ -184,26 +168,6 @@ jobs:
|
||||
working-directory: clients/android
|
||||
run: ./gradlew :kit:testDebugUnitTest --stacktrace
|
||||
|
||||
# The cross-client contract in `clients/shared/console-vectors.json` — the console palette
|
||||
# table, the settings section names and the screen-transition motion, each of which exists in
|
||||
# three hand-written copies (here, pf-console-ui, the Apple client). The other two check it
|
||||
# from their own suites; this is Android's side.
|
||||
#
|
||||
# FILTERED, not a plain `:app:testDebugUnitTest`: that task also runs the ~20 Roborazzi
|
||||
# screenshot scenes, which are a release-artifact job (android-screenshots.yml, gated to v*
|
||||
# tags) and have no business adding a minute to every push. The filter is what lets the
|
||||
# contract gate here without dragging the rest of the app suite in with it.
|
||||
- name: console parity vectors + app-module logic tests
|
||||
working-directory: clients/android
|
||||
run: >-
|
||||
./gradlew :app:testDebugUnitTest
|
||||
--tests 'io.unom.punktfunk.ConsoleVectorsTest'
|
||||
--tests 'io.unom.punktfunk.HomeTilesTest'
|
||||
--tests 'io.unom.punktfunk.GamepadSettingsLayoutTest'
|
||||
--tests 'io.unom.punktfunk.ConsoleSubScreenRowsTest'
|
||||
--tests 'io.unom.punktfunk.ConsoleSubScreenRoutesTest'
|
||||
--stacktrace
|
||||
|
||||
- name: assembleDebug (cargo-ndk → jniLibs → APK)
|
||||
working-directory: clients/android
|
||||
env:
|
||||
|
||||
@@ -48,26 +48,7 @@ on:
|
||||
# `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*']
|
||||
# REBUILDING A PUBLISHED RELEASE, because on a rolling distro the ground moves under one.
|
||||
# Arch went FFmpeg 8 -> 9 (every libav soname +1) four minutes before v0.25.0 was tagged, so
|
||||
# the release's punktfunk-host was linked in a builder image that still had 8 and shipped
|
||||
# `libavcodec.so=62-64`. No up-to-date Arch box can satisfy that — and pacman prepares the
|
||||
# whole transaction at once, so it did not merely block our package, it blocked those users'
|
||||
# entire `pacman -Syu`. The repair is a rebuild of the SAME upstream version at a HIGHER
|
||||
# pkgrel; nothing else reaches a box that already has the broken build recorded in its db.
|
||||
# The workflow file at the tag can never carry inputs added after it was tagged, so dispatch
|
||||
# this from `main`: it checks the tag's SOURCE out, publishes to the STABLE repo, and
|
||||
# replaces the release-page assets. Same lever for any future "the distro moved" rebuild.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: 'Rebuild this published release (e.g. v0.25.0) into the stable `punktfunk` repo. Empty = ordinary canary build of the dispatched ref.'
|
||||
required: false
|
||||
default: ''
|
||||
pkgrel:
|
||||
description: 'pkgrel for that rebuild — MUST be above the published one (2, 3, …); a same-pkgrel republish is invisible to pacman. Ignored without release_tag.'
|
||||
required: false
|
||||
default: '2'
|
||||
|
||||
env:
|
||||
REGISTRY: git.unom.io
|
||||
@@ -113,52 +94,7 @@ jobs:
|
||||
}
|
||||
bun --version
|
||||
|
||||
# THE BUILDER'S FFmpeg IS PART OF THE PACKAGE CONTRACT, not merely a build detail.
|
||||
# packaging/arch/PKGBUILD binds punktfunk-host to the exact libav sonames it linked
|
||||
# (`libavcodec.so=63-64` …), so a builder one FFmpeg major behind Arch emits a package
|
||||
# that NOBODY can install — and takes the user's whole `pacman -Syu` down with it, since
|
||||
# pacman prepares the transaction as a unit. That is exactly how v0.25.0 shipped: PR #108
|
||||
# re-keyed this image for FFmpeg 9, the release tag fired four minutes later, and the job
|
||||
# still got the FFmpeg-8 `:latest`. The image is a cache and is allowed to lag — but never
|
||||
# on this one axis. So heal it in-job and shout, instead of building a dead package.
|
||||
# (Runs BEFORE checkout: a stale image should be repaired before anything depends on it.)
|
||||
- name: FFmpeg soname parity with today's Arch (heals a stale builder image)
|
||||
run: |
|
||||
export LC_ALL=C # `Provides` is a localized field name
|
||||
# Piped (never a TTY here) pacman prints each field on ONE line, unwrapped.
|
||||
sonames() { sed -n 's/^Provides *: *//p' | tr ' ' '\n' | grep -E '^lib(av|sw)[a-z]*\.so=' | sort | tr '\n' ' '; }
|
||||
# A SEPARATE --dbpath: this refreshes only a throwaway view of the repos, so the
|
||||
# container's own db never enters the partial-upgrade state a bare `pacman -Sy` leaves.
|
||||
mkdir -p /tmp/pf-archsync
|
||||
if ! pacman -Sy --dbpath /tmp/pf-archsync --logfile /dev/null >/dev/null 2>&1; then
|
||||
echo "::warning::could not refresh the Arch db — skipping the FFmpeg parity check"
|
||||
exit 0
|
||||
fi
|
||||
HAVE="$(pacman -Qi ffmpeg | sonames)"
|
||||
WANT="$(pacman -Si --dbpath /tmp/pf-archsync ffmpeg | sonames)"
|
||||
echo "builder ffmpeg $(pacman -Q ffmpeg | cut -d' ' -f2): $HAVE"
|
||||
echo "arch ffmpeg $(pacman -Si --dbpath /tmp/pf-archsync ffmpeg | sed -n 's/^Version *: *//p'): $WANT"
|
||||
if [ "$HAVE" = "$WANT" ]; then
|
||||
echo "OK: the builder links the FFmpeg every up-to-date Arch box already has"
|
||||
exit 0
|
||||
fi
|
||||
echo "::warning::arch-ci is stale ACROSS AN FFMPEG SONAME BUMP — upgrading it for this run."
|
||||
echo "::warning::Bump the 'refreshed:' date in ci/arch-ci.Dockerfile so the IMAGE carries it."
|
||||
pacman -Syu --noconfirm || true
|
||||
HAVE="$(pacman -Qi ffmpeg | sonames)"
|
||||
if [ "$HAVE" != "$WANT" ]; then
|
||||
echo "::error::builder still links $HAVE while Arch ships $WANT."
|
||||
echo "::error::Building on would publish a package no Arch box can install."
|
||||
exit 1
|
||||
fi
|
||||
echo "healed: builder now links $HAVE"
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# A dispatched release rebuild takes its WORKFLOW from the ref you dispatch (the only
|
||||
# way it can carry inputs the tag predates) and its SOURCE from the tag. Empty string
|
||||
# = checkout's own default, i.e. the triggering ref, for every other trigger.
|
||||
ref: ${{ github.event.inputs.release_tag }}
|
||||
|
||||
# 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
|
||||
@@ -191,30 +127,12 @@ jobs:
|
||||
# Keep the leading `0.` — it is what sorts a canary BELOW the eventual `X.Y.Z-1` stable
|
||||
# release. (A pkgrel is digits+dots only, so `0.` is the only prefix available; raising
|
||||
# it to `1.` would sort canaries ABOVE the release and is not an option.)
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event.inputs.release_tag }}
|
||||
REBUILD_PKGREL: ${{ github.event.inputs.pkgrel }}
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of latest stable)
|
||||
if [ -n "${RELEASE_TAG:-}" ]; then
|
||||
# Dispatched rebuild of a published release (see the workflow_dispatch note at the
|
||||
# top): same upstream version, higher pkgrel, straight into the stable repo.
|
||||
# ⚠ Keep that pkgrel SINGLE-DIGIT. Gitea's Arch registry picks the version its .db
|
||||
# advertises by STRING order (the same trap the canary zero-padding below exists for),
|
||||
# so "0.25.0-10" sorts BELOW "0.25.0-2" and the rebuild would never be advertised.
|
||||
V="${RELEASE_TAG#v}"
|
||||
R="${REBUILD_PKGREL:-2}"
|
||||
REPO=punktfunk
|
||||
case "$R" in
|
||||
''|*[!0-9.]*) echo "::error::pkgrel '$R' is not digits+dots"; exit 1 ;;
|
||||
1) echo "::error::pkgrel 1 is the published build — a rebuild MUST go up (2, 3, …)"; exit 1 ;;
|
||||
esac
|
||||
else
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; REPO=punktfunk ;;
|
||||
*) V="$PF_BASE"; R="0.$(printf '%08d' "$GITHUB_RUN_NUMBER")"; REPO=punktfunk-canary ;;
|
||||
esac
|
||||
fi
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; REPO=punktfunk ;;
|
||||
*) V="$PF_BASE"; R="0.$(printf '%08d' "$GITHUB_RUN_NUMBER")"; REPO=punktfunk-canary ;;
|
||||
esac
|
||||
echo "PF_PKGVER=$V" >> "$GITHUB_ENV"
|
||||
echo "PF_PKGREL=$R" >> "$GITHUB_ENV"
|
||||
echo "REPO=$REPO" >> "$GITHUB_ENV"
|
||||
@@ -255,46 +173,6 @@ jobs:
|
||||
makepkg -f -d --holdver
|
||||
ls -lh "$GITHUB_WORKSPACE/dist"
|
||||
|
||||
# The host must ship a VERSIONED libav soname dep, and nothing else in this pipeline proves
|
||||
# it. packaging/arch/PKGBUILD lists bare `libavcodec.so` etc. and relies on makepkg rewriting
|
||||
# each into `libavcodec.so=<soname>-<arch>` from the built binary's DT_NEEDED; if that
|
||||
# rewrite ever stops happening — Arch dropping the soname `provides`, someone "tidying" the
|
||||
# entries out of `depends`, a makepkg change — the dep silently degrades to an unversioned
|
||||
# name that ANY ffmpeg satisfies. That is precisely the 2026-08-08 state in which `pacman
|
||||
# -Syu` walked every Arch/CachyOS install across the FFmpeg 8 -> 9 soname bump and left the
|
||||
# host unable to start (exit 127 before main(), restart loop). The failure is invisible in a
|
||||
# green build and only shows up as a bricked box weeks later, so assert it here.
|
||||
- name: Assert the host pins the FFmpeg soname
|
||||
run: |
|
||||
PKG="$(ls "$GITHUB_WORKSPACE"/dist/punktfunk-host-*.pkg.tar.zst | head -1)"
|
||||
DEPS="$(bsdtar -xOf "$PKG" .PKGINFO | sed -n 's/^depend = //p')"
|
||||
echo "$DEPS" | sed 's/^/ depend = /'
|
||||
for lib in libavcodec libavutil; do
|
||||
echo "$DEPS" | grep -qE "^$lib\.so=[0-9]+-[0-9]+$" || {
|
||||
echo "::error::punktfunk-host declares no VERSIONED $lib.so dependency."
|
||||
echo "::error::makepkg did not expand the bare soname from DT_NEEDED, so pacman can"
|
||||
echo "::error::upgrade FFmpeg across a soname break and brick the install."
|
||||
echo "::error::See the depends comment in packaging/arch/PKGBUILD."
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
echo "OK: $(echo "$DEPS" | grep -E '^libav|^libsw' | tr '\n' ' ')"
|
||||
|
||||
# 0.26.0-1 setcap'd `cap_sys_nice=ep` on the host from this package's .INSTALL scriptlet and
|
||||
# killed desktop streaming on every KDE box — with a green board, because nothing here ever
|
||||
# looked at what the built package would DO. The lesson recorded then was "verify the
|
||||
# PACKAGE, never the board"; this is that, and pacman is the channel where it matters most,
|
||||
# since capabilities live in the scriptlet rather than in package metadata.
|
||||
#
|
||||
# Host must carry NOTHING, the worker exactly cap_sys_nice=ep. `--self-test` runs first so a
|
||||
# guard that has quietly lost the ability to fail takes the job down rather than approving a
|
||||
# release. (Only the host package is checked: the client/web/scripting packages ship neither
|
||||
# binary and the script skips them by itself.)
|
||||
- name: Assert the capability matrix (Arch package)
|
||||
run: |
|
||||
bash scripts/ci/assert-cap-matrix.sh --self-test
|
||||
bash scripts/ci/assert-cap-matrix.sh "$GITHUB_WORKSPACE"/dist/punktfunk-host-*.pkg.tar.zst
|
||||
|
||||
# The optional HDR gamescope companion (packaging/gamescope) — a separate pkgbase with a
|
||||
# completely different dependency set, published into the same repo so `pacman -S
|
||||
# punktfunk-gamescope` is all an Arch/SteamOS box needs for 10-bit BT.2020 PQ.
|
||||
@@ -332,63 +210,6 @@ jobs:
|
||||
rm -rf dist-gamescope # never cache a failed build (an empty path is not saved)
|
||||
fi
|
||||
|
||||
# THE GATE THIS PIPELINE WAS MISSING. The soname assert above proves the libav dep is
|
||||
# VERSIONED; it cannot prove the version is one that EXISTS. v0.25.0 passed it and still
|
||||
# shipped `libavcodec.so=62-64` to a world that had moved to 63 — every affected user got
|
||||
# "unable to satisfy dependency … required by punktfunk-host", and because pacman prepares
|
||||
# one transaction, their whole system upgrade stopped there. So ask the only question that
|
||||
# matters before publishing: would a real, up-to-date Arch box install this?
|
||||
#
|
||||
# An empty --dbpath is what makes the answer honest. It means "nothing is installed", so
|
||||
# pacman must satisfy every dependency FROM THE REPOS exactly as a user's box does. Checking
|
||||
# against the builder's own installed set instead would let a stale ffmpeg satisfy the stale
|
||||
# bound and hide the break completely — the very illusion that shipped v0.25.0. `--print`
|
||||
# resolves and prints; it downloads nothing and installs nothing. Verified against the real
|
||||
# broken artifact on an ffmpeg-9 box: it reproduces the user-visible failure verbatim.
|
||||
- name: Assert every package installs on an up-to-date Arch box
|
||||
run: |
|
||||
export LC_ALL=C
|
||||
mkdir -p /tmp/pf-instcheck
|
||||
if ! pacman -Sy --dbpath /tmp/pf-instcheck --logfile /dev/null >/dev/null 2>&1; then
|
||||
echo "::error::could not sync the Arch db — cannot prove these packages install"
|
||||
exit 1
|
||||
fi
|
||||
check() { # check FILE -> 0 installable, 1 not (reason on stdout)
|
||||
pacman -U --print --noconfirm --dbpath /tmp/pf-instcheck --logfile /dev/null "$1" 2>&1
|
||||
}
|
||||
ls dist/*.pkg.tar.zst >/dev/null 2>&1 || { echo "::error::nothing in dist/ to check"; exit 1; }
|
||||
rc=0
|
||||
for pkg in dist/*.pkg.tar.zst; do
|
||||
if out="$(check "$pkg")"; then
|
||||
echo "OK $(basename "$pkg") ($(echo "$out" | wc -l) targets resolve)"
|
||||
else
|
||||
rc=1
|
||||
echo "::error::$(basename "$pkg") CANNOT be installed on an up-to-date Arch box:"
|
||||
echo "$out" | sed 's/^/ /'
|
||||
fi
|
||||
done
|
||||
# gamescope stays best-effort, exactly as its build step is: a companion that cannot
|
||||
# install is dropped from the upload with a warning, never a reason to withhold the
|
||||
# packages this workflow exists to publish. (It is also the one package that can be
|
||||
# restored from a cache older than the current Arch snapshot.)
|
||||
for pkg in dist-gamescope/*.pkg.tar.zst; do
|
||||
[ -e "$pkg" ] || continue
|
||||
if out="$(check "$pkg")"; then
|
||||
echo "OK $(basename "$pkg") ($(echo "$out" | wc -l) targets resolve)"
|
||||
else
|
||||
echo "::warning::$(basename "$pkg") is not installable on current Arch — NOT publishing it"
|
||||
echo "$out" | sed 's/^/ /'
|
||||
rm -f "$pkg"
|
||||
fi
|
||||
done
|
||||
if [ "$rc" != 0 ]; then
|
||||
echo "::error::refusing to publish: pacman would reject this on a current box, and a"
|
||||
echo "::error::rejected dependency blocks the user's ENTIRE upgrade, not just punktfunk."
|
||||
echo "::error::Usual cause: the arch-ci builder image lags Arch across a soname bump —"
|
||||
echo "::error::bump 'refreshed:' in ci/arch-ci.Dockerfile, let docker.yml republish it, re-run."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# NOTE deliberately NO sysext image is built or published here: a prebuilt HOST binary on
|
||||
# SteamOS breaks on the next A/B soname bump (and /var — where sysexts live — is
|
||||
# per-partition-set), which is the standing packaging verdict behind the on-device
|
||||
@@ -416,48 +237,14 @@ jobs:
|
||||
done
|
||||
echo "published to $OWNER/arch/$REPO"
|
||||
|
||||
# On a real release, also attach the packages to the unified Gitea Release. A dispatched
|
||||
# rebuild attaches to that SAME release object: the release page is a distribution surface
|
||||
# too, and leaving the superseded .pkg.tar.zst sitting on it is one click away from handing
|
||||
# someone the exact break the rebuild exists to fix.
|
||||
- name: Attach packages to the Gitea release (stable tags + release rebuilds)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v') || github.event.inputs.release_tag != ''
|
||||
# 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 }}
|
||||
RELEASE_TAG: ${{ github.event.inputs.release_tag }}
|
||||
run: |
|
||||
. scripts/ci/gitea-release.sh
|
||||
TAG="${RELEASE_TAG:-$GITHUB_REF_NAME}"
|
||||
RID=$(ensure_release "$TAG" "$TAG" auto)
|
||||
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||
for pkg in dist/*.pkg.tar.zst; do
|
||||
upsert_asset "$RID" "$pkg"
|
||||
done
|
||||
# A rebuild bumps pkgrel, so its FILENAMES differ from the ones already attached, and
|
||||
# upsert_asset only replaces by name — the superseded set would survive untouched.
|
||||
# Drop every pacman asset (and .sha256 sidecar) this upload did not just write.
|
||||
#
|
||||
# ⚠⚠ THIS MUST LIVE IN THE WORKFLOW, NOT IN scripts/ci/gitea-release.sh. The sourced
|
||||
# script comes from the CHECKED-OUT TREE, which on a release rebuild is the OLD TAG —
|
||||
# so it can only ever offer the helpers that existed when that tag was cut. A helper
|
||||
# added for this feature is therefore guaranteed ABSENT in the one code path that
|
||||
# calls it: the first attempt failed with `prune_release_assets: command not found`
|
||||
# after publishing perfectly. Only the workflow file itself is taken from the ref you
|
||||
# dispatch. Same reason a packaging fix made after a tag does NOT reach a rebuild of
|
||||
# that tag — the PKGBUILD is the tag's too.
|
||||
if [ -n "${RELEASE_TAG:-}" ]; then
|
||||
KEEP="$(cd dist && printf '%s ' *.pkg.tar.zst)"
|
||||
# An UNMATCHED glob would come through literally and match nothing in the keep set —
|
||||
# i.e. "delete every pacman asset on the release". Skip entirely instead.
|
||||
case "$KEEP" in *'*'*) KEEP="" ;; esac
|
||||
API="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY"
|
||||
if [ -n "$KEEP" ]; then
|
||||
curl -fsS "$API/releases/$RID/assets" -H "Authorization: token $GITEA_TOKEN" \
|
||||
| python3 -c "import json,sys;k=set(sys.argv[1].split());k|={n+'.sha256' for n in k};print('\n'.join('%s %s'%(a['id'],a['name']) for a in json.load(sys.stdin) if a.get('name','').endswith(('.pkg.tar.zst','.pkg.tar.zst.sha256')) and a['name'] not in k))" "$KEEP" \
|
||||
| while read -r id name; do
|
||||
[ -n "$id" ] || continue
|
||||
echo "dropping superseded release asset: $name"
|
||||
curl -fsS -o /dev/null -X DELETE "$API/releases/$RID/assets/$id" \
|
||||
-H "Authorization: token $GITEA_TOKEN" || true
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -91,27 +91,8 @@ jobs:
|
||||
# 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).
|
||||
#
|
||||
# web carries two ignores, the ONLY ones in a blocking tree — both image-size advisories
|
||||
# (GHSA-w3rx-r6r6-pgpr ICNS, GHSA-5p2g-fcmc-qvqq JXL/HEIF infinite-loop DoS). They are
|
||||
# unfixable AND unreachable:
|
||||
# * unfixable — the vulnerable range is `<= 2.0.2` and 2.0.2 IS latest; upstream has
|
||||
# published no patched release, so no override can clear them.
|
||||
# * unreachable — image-size rides in under `@unom/ui › @payloadcms/richtext-lexical ›
|
||||
# … › payload`, and @payloadcms/richtext-lexical is a PEER of @unom/ui that only its
|
||||
# `./richtext` export needs. The console imports section/toast/button/card/dialog/
|
||||
# form/*/material/tabs — never `./richtext` — so payload is auto-installed peer weight
|
||||
# that no bundle, and no request path, ever touches.
|
||||
# Drop these the moment image-size ships a fix, or @unom/ui marks that peer optional
|
||||
# (peerDependenciesMeta) and the chain leaves web/bun.lock entirely — either one makes the
|
||||
# bare `bun audit` green again. Scoped per-tree so sdk/plugin-kit stay strictly fail-on-any.
|
||||
- name: bun audit
|
||||
run: |
|
||||
if [ "${{ matrix.tree }}" = "web" ]; then
|
||||
bun audit --ignore=GHSA-w3rx-r6r6-pgpr --ignore=GHSA-5p2g-fcmc-qvqq
|
||||
else
|
||||
bun audit
|
||||
fi
|
||||
run: bun audit
|
||||
|
||||
# Kept OUT of the bun-audit matrix so this tree's known-advisory state can't normalize failure
|
||||
# in a shipping tree. Non-blocking via a step-level `||` (NOT job-level continue-on-error, which
|
||||
|
||||
@@ -114,21 +114,6 @@ jobs:
|
||||
- name: Clippy (deny warnings)
|
||||
run: cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
|
||||
# WP19 (rust-safety): the hardened NATIVE-ONLY host — no Moonlight-compat planes, no
|
||||
# `rusty_enet` (transpiled C ENet), no `rsa`. Kept compiling here so the cfg boundary can't
|
||||
# rot, and the dependency claim is ASSERTED, not assumed: `cargo tree -i` must find neither
|
||||
# crate in the native-only graph (it exits non-zero with "nothing depends on" — inverted).
|
||||
- name: Clippy + tree (native-only host, no gamestream feature)
|
||||
run: |
|
||||
cargo clippy -p punktfunk-host --no-default-features --features pyrowave \
|
||||
--all-targets --locked -- -D warnings
|
||||
if cargo tree -p punktfunk-host --no-default-features --features pyrowave \
|
||||
--locked -i rusty_enet 2>/dev/null | grep -q rusty_enet; then
|
||||
echo "native-only build still depends on rusty_enet"; exit 1; fi
|
||||
if cargo tree -p punktfunk-host --no-default-features --features pyrowave \
|
||||
--locked -i rsa 2>/dev/null | grep -q "^rsa"; then
|
||||
echo "native-only build still depends on rsa"; exit 1; fi
|
||||
|
||||
- name: Build
|
||||
run: cargo build --workspace --locked
|
||||
|
||||
|
||||
@@ -310,14 +310,8 @@ jobs:
|
||||
# with "there is no reactor running, must be called from the context of a Tokio 1.x runtime".
|
||||
# It WAS listed here, which is why only the .deb shipped a crashing tray while the RPM and
|
||||
# Arch packages — which already split it — were fine.
|
||||
#
|
||||
# punktfunk-encode-worker IS in this invocation: it is the capability-carrying PyroWave
|
||||
# encode worker that ships next to the host in /usr/bin, and build-deb.sh only builds it
|
||||
# if the artifact is missing — building it here keeps it on the same sccache pass as the
|
||||
# host. Unlike the tray it shares the host's dependency graph by design (v1 accepts that
|
||||
# the worker links the same FFmpeg), so feature unification here is harmless.
|
||||
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
|
||||
-p punktfunk-host -p punktfunk-encode-worker
|
||||
-p punktfunk-host
|
||||
|
||||
- name: Build host .deb (FFmpeg bundled)
|
||||
# BUNDLE_FFMPEG=1 copies the image's /opt/ffmpeg libav* into the package and repoints the
|
||||
@@ -326,93 +320,6 @@ jobs:
|
||||
run: |
|
||||
VERSION="$VERSION" BUNDLE_FFMPEG=1 bash packaging/debian/build-deb.sh
|
||||
|
||||
# Read the capability matrix out of the BUILT .deb before it is published. dpkg carries no
|
||||
# capability metadata — the postinst applies them — so this reads the postinst that will
|
||||
# actually run on a user's box, plus the payload. 0.26.0-1 granted the host cap_sys_nice=ep
|
||||
# from exactly that postinst and killed every KDE desktop session while every board stayed
|
||||
# green: host must carry NOTHING, worker exactly cap_sys_nice=ep. `--self-test` first so a
|
||||
# guard that can no longer fail takes the job down instead of waving the release through.
|
||||
- name: Assert the capability matrix (host .deb)
|
||||
run: |
|
||||
bash scripts/ci/assert-cap-matrix.sh --self-test
|
||||
bash scripts/ci/assert-cap-matrix.sh dist/punktfunk-host_*.deb
|
||||
|
||||
# punktfunk-gamescope for apt. Same reasoning as the RPM leg in rpm.yml: without a packaged
|
||||
# build, a Debian/Ubuntu box has no route to the patched gamescope except compiling it, and a
|
||||
# stock gamescope streams SDR, cursorless, and tells every game its display is 60 Hz.
|
||||
#
|
||||
# CACHED on packaging/gamescope/** alone — it depends on nothing else in this repo, so a
|
||||
# normal push restores a binary instead of spending ~10 minutes on someone else's tree.
|
||||
- uses: actions/cache@v4
|
||||
id: gamescope
|
||||
with:
|
||||
path: gs-cache
|
||||
key: punktfunk-gamescope-noble-${{ hashFiles('packaging/gamescope/**') }}
|
||||
|
||||
- name: Build the patched gamescope
|
||||
if: steps.gamescope.outputs.cache-hit != 'true'
|
||||
# Best-effort, exactly like rpm.yml: the host packages above are the primary delivery and
|
||||
# work without this binary, so a hiccup building an unrelated tree must not fail the job.
|
||||
# `build-dep gamescope` resolves the distro's much older packaged version, so it can come up
|
||||
# short — that is what the `|| true`s absorb, and the marker check downstream is what makes
|
||||
# a half-built result impossible to ship.
|
||||
run: |
|
||||
set -x
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends meson ninja-build glslc git || true
|
||||
apt-get build-dep -y gamescope || true
|
||||
# NOT best-effort. `build-dep gamescope` resolves the distro's much older packaged
|
||||
# gamescope — where noble has one at all — so it misses what the master tree needs, and
|
||||
# wayland-protocols is the gap that actually stops the build: meson dies in
|
||||
# protocol/meson.build with "Neither a subproject directory nor a wayland-protocols.wrap
|
||||
# file was found", because the tree has no wrap fallback for it. That is what happened on
|
||||
# the v0.26.0 tag: the step warned and skipped, the job stayed green, and the release
|
||||
# shipped with no gamescope .deb while the notes said it had one.
|
||||
apt-get install -y --no-install-recommends wayland-protocols
|
||||
# The remaining Arch makedepends the older packaged gamescope does not necessarily pull.
|
||||
# Best-effort: meson falls back or does without, and a name that moves between Ubuntu
|
||||
# releases should not fail the job. (No libstdc++ static package is needed here — g++
|
||||
# ships libstdc++.a, which is why only Fedora tripped the sanity check.)
|
||||
# `build-dep gamescope` gives noble almost nothing — the distro has no comparable package
|
||||
# — so the tree's real dependency set has to be named outright. One `apt-get` per name on
|
||||
# purpose: a single transaction aborts wholesale on one unknown package, which would
|
||||
# install NOTHING and hide the real gap behind a name typo. Best-effort per package, with
|
||||
# the missing one named; the end-of-job gate below is what actually decides.
|
||||
for p in libxdamage-dev libxcomposite-dev libxrender-dev libxext-dev libxxf86vm-dev \
|
||||
libxtst-dev libx11-dev libxres-dev libxmu-dev libxcursor-dev libxi-dev \
|
||||
libxfixes-dev libxkbcommon-dev libxkbcommon-x11-dev libcap-dev libdrm-dev \
|
||||
libinput-dev libudev-dev libpipewire-0.3-dev libseat-dev libsdl2-dev \
|
||||
libluajit-5.1-dev libavif-dev libdecor-0-dev hwdata libglm-dev libbenchmark-dev \
|
||||
glslang-tools libvulkan-dev libwayland-dev libxcb1-dev libxcb-composite0-dev \
|
||||
libxcb-xfixes0-dev libxcb-res0-dev libxcb-ewmh-dev libxcb-icccm4-dev \
|
||||
libxcb-errors-dev libpixman-1-dev libdisplay-info-dev libgbm-dev libegl-dev \
|
||||
cmake xwayland; do
|
||||
apt-get install -y --no-install-recommends "$p" \
|
||||
|| echo "::warning::no such noble package: $p (gamescope may still build without it)"
|
||||
done
|
||||
if bash packaging/gamescope/build-punktfunk-gamescope.sh \
|
||||
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)"; then
|
||||
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
|
||||
else
|
||||
# Warn only, even on a tag. The hard gate moved to the END of this job: failing HERE
|
||||
# skips the host .deb's own publish + release-attach steps below, which is how the
|
||||
# v0.26.0 release ended up still carrying the pre-CAP_SYS_NICE host .deb from an
|
||||
# earlier tag commit — a KDE-breaking artifact withheld from replacement by a gate
|
||||
# meant to protect the release. Never let a missing EXTRA stop a good artifact
|
||||
# shipping; go red afterwards instead.
|
||||
echo "::warning::punktfunk-gamescope failed to build on noble — no .deb this run (gamescope sessions stay SDR)"
|
||||
fi
|
||||
|
||||
- name: Build punktfunk-gamescope .deb
|
||||
# Picked up by the publish loop below, which globs dist/*.deb.
|
||||
run: |
|
||||
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
|
||||
bash packaging/debian/build-gamescope-deb.sh --binary gs-cache/punktfunk-gamescope
|
||||
else
|
||||
# Warn only — see the note on the build step. The gate is the last step of this job.
|
||||
echo "::warning::no usable punktfunk-gamescope — skipping its .deb"
|
||||
fi
|
||||
|
||||
- name: Publish to the Gitea apt registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
@@ -440,26 +347,6 @@ jobs:
|
||||
upsert_asset "$RID" "$DEB"
|
||||
done
|
||||
|
||||
# A release must not be able to make a claim its own CI silently dropped: v0.26.0's notes and
|
||||
# docs-site said the patched gamescope was apt-installable while no .deb had ever been built,
|
||||
# because every failure on this path was a `::warning::` that returned 0.
|
||||
#
|
||||
# ⚠ LAST step on purpose. The first version of this gate failed at the build step instead, and
|
||||
# that skipped the host .deb's own publish + attach below — so the release kept the PREVIOUS
|
||||
# tag commit's host .deb, which still carried the CAP_SYS_NICE postinst that breaks KDE. A
|
||||
# gate protecting the release withheld the fix for it. Everything good ships first; the job
|
||||
# goes red afterwards.
|
||||
- name: A stable tag must ship the gamescope .deb
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
built=(dist/punktfunk-gamescope_*.deb)
|
||||
if [ ${#built[@]} -eq 0 ]; then
|
||||
echo "::error::no punktfunk-gamescope .deb was built — a stable tag must not ship without it (the release notes and docs-site say it is apt-installable). Everything else in this job published normally; see the gamescope build step above for the meson error."
|
||||
exit 1
|
||||
fi
|
||||
echo "gamescope .deb present: ${built[*]}"
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# The aarch64 CLIENT .deb. Cross-compiled on the ordinary amd64 runner in the
|
||||
# punktfunk-rust-ci-arm64cross image (the rust-ci toolchain + an arm64 multiarch sysroot — see
|
||||
|
||||
@@ -7,24 +7,10 @@
|
||||
# Two tiers, because a full `nix flake check` builds the whole Rust workspace with crane and would
|
||||
# run for an hour on every push:
|
||||
#
|
||||
# * eval — `nix flake check --no-build`: instantiates every package, app, check and devShell
|
||||
# without building them. Catches the failures that actually happen to this flake — a
|
||||
# renamed file, a callPackage argument that no longer exists, a syntax error, a package
|
||||
# attribute dropped from packages.nix.
|
||||
#
|
||||
# ⚠ It does NOT, on its own, check the NixOS module. `nix flake check` handles
|
||||
# `nixosModules` by forcing the value and asserting it is a lambda taking an open
|
||||
# attribute set — nothing more (nix's own source: `// FIXME: if we have a 'nixpkgs'
|
||||
# input, use it to check the module.`). MEASURED: a module setting a nonexistent
|
||||
# OPTION, referencing a nonexistent `pkgs` attribute AND calling a nonexistent `lib`
|
||||
# function passes clean, printing `checking NixOS module ... all checks passed!`. This
|
||||
# header used to claim the module was covered here; it was not, for the module's whole
|
||||
# life. It is covered NOW because `checks.<system>.nixos-module`
|
||||
# (packaging/nix/module-check.nix) evaluates it against real nixpkgs and asserts on the
|
||||
# rendered systemd units — and because those assertions are pure Nix, INSTANTIATING
|
||||
# that check runs them, so `--no-build` is enough. Keep them pure: a shell script in
|
||||
# the derivation body would only run under a full `nix flake check`, which builds the
|
||||
# hour-long Rust packages.
|
||||
# * eval — `nix flake check --no-build`: instantiates every package, app, check, devShell and
|
||||
# the NixOS module without building them. Catches the failures that actually happen to
|
||||
# this flake — a renamed file, a callPackage argument that no longer exists, a syntax
|
||||
# error, a package attribute dropped from packages.nix.
|
||||
# * bun — actually BUILDS punktfunk-web + punktfunk-scripting. These are the two derivations
|
||||
# whose inputs churn constantly (every dependency bump moves a lockfile) and they cost
|
||||
# minutes, not hours, because neither compiles Rust. This is the end-to-end proof that
|
||||
@@ -36,12 +22,6 @@
|
||||
# They are the expensive ones and their inputs are already gated by the `rust` job in ci.yml; build
|
||||
# them by hand on a Nix box, or with the `build-rust` dispatch input below.
|
||||
#
|
||||
# ⚠ punktfunk-gamescope deserves the dispatch run more than it looks: `host.gamescopeHdr` DEFAULTS
|
||||
# TRUE, so it is on the critical path of every `services.punktfunk.host.enable = true` build, while
|
||||
# being the one package nothing here compiles. It patches whatever gamescope the pinned nixpkgs
|
||||
# carries, so a nixpkgs bump — not a change of ours — is what breaks it, and the first person to
|
||||
# find out would be an operator whose system rebuild fails. Run the dispatch after a flake.lock bump.
|
||||
#
|
||||
# ⚠ pull_request is deliberately present. flatpak.yml shipped with push-only triggers and manifest
|
||||
# breakage reached main invisibly for weeks — do not "simplify" this workflow by dropping it.
|
||||
# ⚠ The two path lists are duplicated on purpose: a YAML anchor would be tidier, but Gitea's
|
||||
@@ -86,10 +66,6 @@ on:
|
||||
description: "Also build punktfunk-host + punktfunk-client (slow: full Rust workspace)"
|
||||
type: boolean
|
||||
default: false
|
||||
build-gamescope:
|
||||
description: "Also build punktfunk-gamescope (patched gamescope from source; run after a flake.lock bump)"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
flake:
|
||||
@@ -189,13 +165,3 @@ jobs:
|
||||
if: ${{ github.event.inputs.build-rust == 'true' }}
|
||||
run: |
|
||||
"$NIX" build --print-build-logs .#punktfunk-host .#punktfunk-client
|
||||
|
||||
# The patched compositor. Separate from build-rust because its failure mode is different: it
|
||||
# tracks nixpkgs' gamescope, not our Rust, so it wants a run after a flake.lock bump rather
|
||||
# than after a code change. `gamescope.nix` fails loudly (an eval-time `throw` if nixpkgs no
|
||||
# longer exposes a patchable derivation, a `+pfhdr` grep in installCheckPhase) — but only if
|
||||
# something actually builds it.
|
||||
- name: Build the patched gamescope (dispatch opt-in)
|
||||
if: ${{ github.event.inputs.build-gamescope == 'true' }}
|
||||
run: |
|
||||
"$NIX" build --print-build-logs .#punktfunk-gamescope
|
||||
|
||||
@@ -66,13 +66,6 @@ jobs:
|
||||
test -f node_modules/@punktfunk/host/package.json
|
||||
test -f node_modules/@punktfunk/host/dist/index.d.ts
|
||||
|
||||
# The kit had no biome config and no lint step, while every plugin repo that consumes it does
|
||||
# — so its source drifted (unused imports, formatting) with nothing to catch it. Now gated
|
||||
# here, on the same config and pinned biome version the plugins use.
|
||||
- name: Lint & format
|
||||
working-directory: plugin-kit
|
||||
run: bun run check
|
||||
|
||||
- name: Typecheck
|
||||
working-directory: plugin-kit
|
||||
run: bun run typecheck
|
||||
|
||||
@@ -103,11 +103,7 @@ jobs:
|
||||
# gamescope`.) Matches packaging/rpm/punktfunk.spec, which dropped its BuildRequires too.
|
||||
dnf -y install gtk4-devel libadwaita-devel SDL3-devel
|
||||
# sysext build (packaging/bazzite/build-sysext.sh): squashfs + SELinux labeling.
|
||||
# libcap = setcap/getcap: the sysext is the ONLY place the image can acquire
|
||||
# cap_sys_nice=ep on punktfunk-encode-worker (a merged /usr is read-only squashfs and no
|
||||
# scriptlet ever runs), and it is also what the build's host-must-be-uncapped assertion
|
||||
# and the capability-matrix CI leg read with. Without it the image ships the lever inert.
|
||||
dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted libcap
|
||||
dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted
|
||||
# Fedora's own gamescope, for its RUNTIME libraries only — never shipped, never run. The
|
||||
# sysext folds in our punktfunk-gamescope and verifies it by executing `--version`, and
|
||||
# on a cache hit (the common case) nothing else in this job would have pulled libavif /
|
||||
@@ -159,20 +155,6 @@ jobs:
|
||||
RPM_GPG_PASSPHRASE: ${{ secrets.RPM_GPG_PASSPHRASE }}
|
||||
run: bash packaging/rpm/sign-rpms.sh
|
||||
|
||||
# Read the file-capability matrix out of the BUILT rpm, before anything is signed or
|
||||
# published. 0.26.0-1 shipped `%caps(cap_sys_nice=ep)` on the host through this very spec —
|
||||
# on Fedora and, via rpm-ostree layering, on Bazzite — and every board was green while every
|
||||
# KDE desktop session died in the field. The lesson recorded then was "verify the PACKAGE,
|
||||
# never the board"; this is that. Host must carry NOTHING; the worker must carry exactly
|
||||
# cap_sys_nice=ep. `--self-test` first, so a guard that has quietly stopped being able to
|
||||
# fail takes the job down instead of waving the release through.
|
||||
- name: Assert the capability matrix (rpm)
|
||||
run: |
|
||||
bash scripts/ci/assert-cap-matrix.sh --self-test
|
||||
# Only the main host package carries binaries; -debuginfo/-debugsource and the
|
||||
# client/web/scripting subpackages ship neither and are skipped by the script itself.
|
||||
bash scripts/ci/assert-cap-matrix.sh dist/punktfunk-[0-9]*.rpm
|
||||
|
||||
- name: Publish to the Gitea RPM registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
@@ -224,89 +206,13 @@ jobs:
|
||||
dnf -y install dnf-plugins-core meson ninja-build glslc || true
|
||||
dnf builddep -y gamescope || true
|
||||
dnf -y install xorg-x11-server-Xwayland-devel || true
|
||||
# NOT best-effort: build-punktfunk-gamescope.sh appends `-static-libstdc++` to LDFLAGS
|
||||
# (so the binary still starts on SteamOS's older libstdc++ — see its comment), and
|
||||
# without the static library meson's very FIRST sanity check dies with
|
||||
# "cannot find -lstdc++ / have you installed the static version", so nothing builds at
|
||||
# all. That is what happened on the v0.26.0 tag: both Fedora bases warned and skipped,
|
||||
# the job stayed green, and the release shipped with no gamescope RPM while the notes
|
||||
# said it had one. A rename here must be LOUD, hence no `|| true`.
|
||||
dnf -y install libstdc++-static
|
||||
# The rest of the Arch package's makedepends that Fedora's older packaged gamescope does
|
||||
# not necessarily pull. Best-effort: unlike the static runtime, meson finds fallbacks or
|
||||
# does without, and a name that moves between Fedora releases should not fail the job.
|
||||
dnf -y install wayland-protocols-devel glm-devel cmake libXcursor-devel || true
|
||||
if bash packaging/gamescope/build-punktfunk-gamescope.sh \
|
||||
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)"; then
|
||||
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
|
||||
else
|
||||
# Warn only, even on a tag — the hard gate is the LAST step of this job. Failing here
|
||||
# would skip the sysext build, the sysext feed, AND the release attach below, so a
|
||||
# missing gamescope would also withhold the punktfunk RPMs and the .raw images that
|
||||
# built perfectly well. deb.yml learned that the expensive way on v0.26.0.
|
||||
echo "::warning::punktfunk-gamescope failed to build for f${{ matrix.fedver }} — the sysext ships without it (gamescope sessions stay SDR)"
|
||||
fi
|
||||
|
||||
# The same binary, as an ordinary RPM. The sysext below is the Atomic/Bazzite delivery; this
|
||||
# is the one a traditional Fedora-family box (Nobara, plain Fedora) can actually install —
|
||||
# until it existed those users had no packaged route to the patched build at all, and a stock
|
||||
# gamescope tells every game its display is 60 Hz whatever the client negotiated.
|
||||
#
|
||||
# Same best-effort rule as the build above: no binary, no package, and the host stays on its
|
||||
# existing SDR/host-composited path. The spec re-checks the +pfhdr marker itself.
|
||||
- name: Package punktfunk-gamescope as an RPM
|
||||
run: |
|
||||
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
|
||||
bash packaging/gamescope/build-gamescope-rpm.sh \
|
||||
--binary gs-cache/punktfunk-gamescope \
|
||||
--release "$PF_RELEASE"
|
||||
else
|
||||
# Warn only — see the note on the build step. The gate is the last step of this job.
|
||||
echo "::warning::no usable punktfunk-gamescope for f${{ matrix.fedver }} — skipping its RPM"
|
||||
fi
|
||||
|
||||
# A SECOND signing pass, for this package only. The main "Sign RPMs" step ran back at build
|
||||
# time, long before this RPM existed — the gamescope build sits behind its own ~10-minute
|
||||
# cache and deliberately runs after the host RPMs are already published. So every
|
||||
# punktfunk-gamescope RPM went to the registry UNSIGNED, and the repo file we tell users to
|
||||
# install carries gpgcheck=1: `dnf install punktfunk-gamescope` failed with "The package is
|
||||
# not signed" on every Fedora and Nobara box. The package was in the channel the whole time
|
||||
# and could not be installed from it — which is worse than absent, because the release notes
|
||||
# and the docs-site both say it is there.
|
||||
#
|
||||
# Same fail-closed rule as the first pass: sign-rpms.sh hard-fails on refs/tags/v* if the org
|
||||
# secret is missing, rather than republishing something a user's dnf will reject.
|
||||
- name: Sign punktfunk-gamescope
|
||||
env:
|
||||
RPM_GPG_PRIVATE_KEY: ${{ secrets.RPM_GPG_PRIVATE_KEY }}
|
||||
RPM_GPG_PASSPHRASE: ${{ secrets.RPM_GPG_PASSPHRASE }}
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
rpms=(dist/punktfunk-gamescope-*.rpm)
|
||||
# No RPM here is the best-effort skip above, already warned about — not a signing failure.
|
||||
if [ "${#rpms[@]}" -eq 0 ]; then
|
||||
echo "no punktfunk-gamescope RPM to sign (see the packaging step above)"
|
||||
exit 0
|
||||
fi
|
||||
bash packaging/rpm/sign-rpms.sh "${rpms[@]}"
|
||||
|
||||
- name: Publish punktfunk-gamescope to the Gitea RPM registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
for rpm in dist/punktfunk-gamescope-*.rpm; do
|
||||
case "$rpm" in *debuginfo*|*debugsource*) continue;; esac
|
||||
NAME=$(rpm -qp --qf '%{NAME}' "$rpm" 2>/dev/null)
|
||||
VR=$(rpm -qp --qf '%{VERSION}-%{RELEASE}' "$rpm" 2>/dev/null)
|
||||
ARCH=$(rpm -qp --qf '%{ARCH}' "$rpm" 2>/dev/null)
|
||||
echo "uploading $rpm"
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
||||
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/package/$NAME/$VR/$ARCH" || true
|
||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$rpm" \
|
||||
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/upload"
|
||||
done
|
||||
|
||||
# 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.
|
||||
@@ -330,19 +236,6 @@ jobs:
|
||||
dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
|
||||
dist/punktfunk-scripting-"${PF_VERSION}-${PF_RELEASE}"*.rpm
|
||||
|
||||
# Read the capability matrix back OUT of the image that is about to be published — the one
|
||||
# channel where getting it wrong is unrepairable, because a merged sysext's /usr is read-only
|
||||
# squashfs and the only fix is a new image plus a feed republish. 0.26.0-1's Bazzite breakage
|
||||
# was confirmed exactly this way, after the fact, by mounting the published .raw and running
|
||||
# getcap on it. Doing it here means the .raw never reaches the feed.
|
||||
#
|
||||
# The script proves its own reader first (cap a file, squash it, unsquash it, read it back)
|
||||
# so a runner that cannot see file capabilities FAILS the leg instead of blessing the image.
|
||||
- name: Assert the capability matrix (sysext image)
|
||||
run: |
|
||||
bash scripts/ci/assert-cap-matrix.sh \
|
||||
"dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw"
|
||||
|
||||
# The feed's SHA256SUMS is OpenPGP-signed with the same packages@unom.io key as the RPMs, and
|
||||
# punktfunk-sysext(8) refuses a feed it can't verify — the checksums alone never proved
|
||||
# anything, sitting on the same registry as the images they describe.
|
||||
@@ -383,26 +276,3 @@ jobs:
|
||||
for raw in dist-sysext/*.raw; do
|
||||
upsert_asset "$RID" "$raw" "$(basename "$raw" .raw).f${{ matrix.fedver }}.raw"
|
||||
done
|
||||
|
||||
# A release must not be able to make a claim its own CI silently dropped — v0.26.0's notes
|
||||
# said the patched gamescope was dnf-installable while both Fedora bases had skipped it on a
|
||||
# `::warning::` (missing libstdc++-static, which the -static-libstdc++ link needs).
|
||||
#
|
||||
# ⚠ LAST step on purpose, matching deb.yml: failing at the build step instead would skip the
|
||||
# sysext image, the feed publish AND the attach above, withholding the punktfunk RPMs and
|
||||
# .raw images that built perfectly well. Everything good ships first; the job goes red after.
|
||||
- name: A stable tag must ship the gamescope RPM
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
built=(dist/punktfunk-gamescope-*.rpm)
|
||||
keep=()
|
||||
for r in "${built[@]}"; do
|
||||
case "$r" in *debuginfo*|*debugsource*) continue;; esac
|
||||
keep+=("$r")
|
||||
done
|
||||
if [ ${#keep[@]} -eq 0 ]; then
|
||||
echo "::error::no punktfunk-gamescope RPM was built for f${{ matrix.fedver }} — a stable tag must not ship without it (the release notes and docs-site say it is installable). Everything else in this job published normally; see the gamescope build step above for the meson error."
|
||||
exit 1
|
||||
fi
|
||||
echo "gamescope RPM present: ${keep[*]}"
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
#
|
||||
# What goes in: scripts/ci/gen-sbom.sh = syft over the checkout (every lockfile-pinned dep in
|
||||
# both Rust workspaces + the JS trees + Swift Package.resolved) merged with
|
||||
# compliance/sbom/manual-components.cdx.json (vendored C/C++, bundled DLLs, gamescope).
|
||||
# compliance/sbom/manual-components.cdx.json (vendored C/C++, bundled DLLs, VB-CABLE, gamescope).
|
||||
name: sbom
|
||||
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
|
||||
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
|
||||
|
||||
@@ -150,6 +150,13 @@ jobs:
|
||||
if (-not $env:FFMPEG_DIR) {
|
||||
"FFMPEG_DIR=C:\Users\Public\ffmpeg" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
}
|
||||
# VBCABLE_DIR: the pinned official VB-CABLE package (provisioned by
|
||||
# provision-windows-punktfunk-extras.ps1) -> pack-host-installer.ps1 bundles the
|
||||
# streaming virtual microphone. Same daemon-env-or-fallback pattern as FFMPEG_DIR
|
||||
# (the daemon env only refreshes on a runner-task restart).
|
||||
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', ''
|
||||
@@ -399,6 +406,7 @@ jobs:
|
||||
@{ n = 'bun runtime (BUN_EXE)'; p = $env:BUN_EXE; f = '' }
|
||||
@{ n = 'plugin runner (SCRIPTING_BUNDLE)';p = $env:SCRIPTING_BUNDLE; f = '' }
|
||||
@{ n = 'FFmpeg DLLs (FFMPEG_DIR\bin)'; p = $env:FFMPEG_DIR; f = 'bin' }
|
||||
@{ n = 'VB-CABLE (VBCABLE_DIR)'; p = $env:VBCABLE_DIR; f = 'VBCABLE_Setup_x64.exe' }
|
||||
)
|
||||
$missing = @()
|
||||
foreach ($x in $need) {
|
||||
|
||||
@@ -647,9 +647,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.4.1"
|
||||
version = "1.2.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9066c49992464636f92905fa096ec58baaa4d57ec19a5c096c68d3e25ef3d136"
|
||||
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
@@ -994,7 +994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cursor-probe"
|
||||
version = "0.27.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-capture",
|
||||
@@ -1114,7 +1114,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.27.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
@@ -1290,9 +1290,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ffmpeg-next"
|
||||
version = "9.0.0"
|
||||
version = "8.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6380599799e175191eb7ffe82c97f36a2a90a36cbc54c738a903e5287d7f516a"
|
||||
checksum = "f7c4bd5ab1ac61f29c634df1175d350ded29cf74c3c6d4f7030431a5ae3c7d5d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"ffmpeg-sys-next",
|
||||
@@ -1301,9 +1301,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ffmpeg-sys-next"
|
||||
version = "9.0.0"
|
||||
version = "8.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b939bf79dd5949412a4b81cfe21a07f48ea21b47fcbb5f57816c8c2de5ae30b"
|
||||
checksum = "a314bc0e022a33a99567ed4bd2576bd58ffd8fcff7891c29194cfecc26a62547"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cc",
|
||||
@@ -1341,9 +1341,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.10"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
@@ -2358,7 +2358,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.27.0"
|
||||
version = "0.24.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2463,7 +2463,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.27.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2498,7 +2498,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.27.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2988,7 +2988,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-bitstream"
|
||||
version = "0.27.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"tracing",
|
||||
@@ -2996,7 +2996,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.27.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3017,7 +3017,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.27.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3047,12 +3047,11 @@ dependencies = [
|
||||
"wasapi",
|
||||
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"winreg",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.27.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3070,7 +3069,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.27.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3078,7 +3077,6 @@ dependencies = [
|
||||
"pf-presenter",
|
||||
"punktfunk-core",
|
||||
"sdl3",
|
||||
"serde_json",
|
||||
"skia-safe",
|
||||
"tracing",
|
||||
]
|
||||
@@ -3092,7 +3090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-dxvadec"
|
||||
version = "0.27.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3102,7 +3100,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.27.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3119,8 +3117,6 @@ dependencies = [
|
||||
"pf-zerocopy",
|
||||
"punktfunk-core",
|
||||
"pyrowave-sys",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
@@ -3128,7 +3124,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.27.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -3140,7 +3136,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.27.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -3154,11 +3150,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.27.0"
|
||||
version = "0.24.0"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.27.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3187,20 +3183,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.27.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.27.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
"async-channel",
|
||||
"pf-client-core",
|
||||
"pf-vkdecode",
|
||||
"punktfunk-core",
|
||||
"sdl3",
|
||||
"tracing",
|
||||
@@ -3209,7 +3204,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update"
|
||||
version = "0.27.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3217,7 +3212,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update-check"
|
||||
version = "0.27.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -3229,7 +3224,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vaadec"
|
||||
version = "0.27.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3238,7 +3233,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.27.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3271,7 +3266,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vkdecode"
|
||||
version = "0.27.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"cros-codecs",
|
||||
@@ -3282,7 +3277,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.27.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-paths",
|
||||
@@ -3294,7 +3289,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.27.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3517,7 +3512,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.27.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3528,7 +3523,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.27.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3546,7 +3541,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.27.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3563,7 +3558,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.27.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3578,7 +3573,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.27.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"mdns-sd",
|
||||
@@ -3597,7 +3592,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.27.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3627,18 +3622,9 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-encode-worker"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"pf-encode",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.27.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3723,7 +3709,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.27.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3737,7 +3723,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.27.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3760,7 +3746,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.27.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
|
||||
@@ -4,9 +4,6 @@ members = [
|
||||
"crates/punktfunk-core",
|
||||
"crates/punktfunk-host",
|
||||
"crates/punktfunk-host/vendor/usbip-sim",
|
||||
# The capability-carrying PyroWave encode worker. A SEPARATE binary by design — never a
|
||||
# hardlink of, or a subcommand of, punktfunk-host (design/gpu-priority-capability-worker.md).
|
||||
"crates/punktfunk-encode-worker",
|
||||
"crates/punktfunk-tray",
|
||||
"crates/pf-bitstream",
|
||||
"crates/pf-bitstream/vendor/cros-codecs",
|
||||
@@ -49,11 +46,6 @@ members = [
|
||||
exclude = [
|
||||
"packaging/linux/steam-deck-gadget/usbip-poc",
|
||||
"clients/android/native/vendor/ndk",
|
||||
# Bring-your-own-hardware measurement tools. `hid-descriptor-dump` pulls `hidapi`, a C library
|
||||
# wanting libudev on Linux; `win-input-matrix` is Windows-only and asks the live input stacks
|
||||
# what they can see. Neither belongs in `cargo build --workspace` or on a CI leg with no pad.
|
||||
"tools/hid-descriptor-dump",
|
||||
"tools/win-input-matrix",
|
||||
]
|
||||
|
||||
# ndk 0.9.0 verbatim from crates.io plus ONE visibility change (and two warning fixes — an
|
||||
@@ -65,7 +57,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.27.0"
|
||||
version = "0.24.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -83,7 +83,7 @@ MANIFEST (crate version — SPDX license — source)
|
||||
cast 0.3.0 — MIT OR Apache-2.0 — https://github.com/japaric/cast.rs
|
||||
cbc 0.1.2 — MIT OR Apache-2.0 — https://github.com/RustCrypto/block-modes
|
||||
cbindgen 0.29.4 — MPL-2.0 — https://github.com/mozilla/cbindgen
|
||||
cc 1.4.1 — MIT OR Apache-2.0 — https://github.com/rust-lang/cc-rs
|
||||
cc 1.2.65 — MIT OR Apache-2.0 — https://github.com/rust-lang/cc-rs
|
||||
cesu8 1.1.0 — Apache-2.0/MIT — https://github.com/emk/cesu8-rs
|
||||
cexpr 0.6.0 — Apache-2.0/MIT — https://github.com/jethrogb/rust-cexpr
|
||||
cfg-expr 0.20.8 — MIT OR Apache-2.0 — https://github.com/EmbarkStudios/cfg-expr
|
||||
@@ -148,12 +148,12 @@ MANIFEST (crate version — SPDX license — source)
|
||||
fastbloom 0.14.1 — MIT OR Apache-2.0 — https://github.com/tomtomwombat/fastbloom/
|
||||
fastrand 2.4.1 — Apache-2.0 OR MIT — https://github.com/smol-rs/fastrand
|
||||
fdeflate 0.3.7 — MIT OR Apache-2.0 — https://github.com/image-rs/fdeflate
|
||||
ffmpeg-next 9.0.0 — WTFPL — https://github.com/zmwangx/rust-ffmpeg
|
||||
ffmpeg-sys-next 9.0.0 — WTFPL — https://github.com/zmwangx/rust-ffmpeg-sys
|
||||
ffmpeg-next 8.1.0 — WTFPL — https://github.com/zmwangx/rust-ffmpeg
|
||||
ffmpeg-sys-next 8.1.0 — WTFPL — https://github.com/zmwangx/rust-ffmpeg-sys
|
||||
fiat-crypto 0.2.9 — MIT OR Apache-2.0 OR BSD-1-Clause — https://github.com/mit-plv/fiat-crypto
|
||||
field-offset 0.3.6 — MIT OR Apache-2.0 — https://github.com/Diggsey/rust-field-offset
|
||||
filetime 0.2.29 — MIT/Apache-2.0 — https://github.com/alexcrichton/filetime
|
||||
find-msvc-tools 0.1.10 — MIT OR Apache-2.0 — https://github.com/rust-lang/cc-rs
|
||||
find-msvc-tools 0.1.9 — MIT OR Apache-2.0 — https://github.com/rust-lang/cc-rs
|
||||
fixedbitset 0.5.7 — MIT OR Apache-2.0 — https://github.com/petgraph/fixedbitset
|
||||
flate2 1.1.9 — MIT OR Apache-2.0 — https://github.com/rust-lang/flate2-rs
|
||||
flume 0.12.0 — Apache-2.0/MIT — https://github.com/zesterer/flume
|
||||
@@ -628,7 +628,7 @@ Crates whose package did not embed a license file (SPDX + source only)
|
||||
atomig-macro 0.4.0 — MIT/Apache-2.0 — https://github.com/LukasKalbertodt/atomig/
|
||||
cookie-factory 0.3.3 — MIT — https://github.com/rust-bakery/cookie-factory
|
||||
defmt-parser 1.0.0 — MIT OR Apache-2.0 — https://github.com/knurling-rs/defmt
|
||||
ffmpeg-sys-next 9.0.0 — WTFPL — https://github.com/zmwangx/rust-ffmpeg-sys
|
||||
ffmpeg-sys-next 8.1.0 — WTFPL — https://github.com/zmwangx/rust-ffmpeg-sys
|
||||
jni-sys-macros 0.4.1 — MIT OR Apache-2.0 — https://github.com/jni-rs/jni-sys
|
||||
ndk 0.9.0 — MIT OR Apache-2.0 — https://github.com/rust-mobile/ndk
|
||||
ndk-sys 0.6.0+11769913 — MIT OR Apache-2.0 — https://github.com/rust-mobile/ndk
|
||||
@@ -2266,7 +2266,7 @@ SOFTWARE.
|
||||
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
The following license (LICENSE-APACHE) applies to: asn1-rs 0.6.2, asn1-rs-derive 0.5.1, assert_matches 1.5.0, async-channel 2.5.0, async-executor 1.14.0, async-io 2.6.0, async-lock 3.4.2, async-process 2.5.0, async-recursion 1.1.1, async-signal 0.2.14, async-task 4.7.1, atomic-waker 1.1.2, autocfg 1.5.1, base64 0.22.1, bitflags 1.3.2, bitflags 2.13.0, blocking 1.6.2, bumpalo 3.20.3, cast 0.3.0, cc 1.4.1, cexpr 0.6.0, cfg-if 1.0.4, cmake 0.1.58, concurrent-queue 2.5.0, core-foundation 0.10.1, core-foundation-sys 0.8.7, criterion 0.5.1, criterion-plot 0.5.0, crossbeam-deque 0.8.6, crossbeam-epoch 0.9.20, crossbeam-utils 0.8.21, curve25519-dalek-derive 0.1.1, der-parser 9.0.0, displaydoc 0.2.6, either 1.16.0, equivalent 1.0.2, errno 0.3.14, event-listener 5.4.1, event-listener-strategy 0.5.4, fastrand 2.4.1, filetime 0.2.29, find-msvc-tools 0.1.10, fixedbitset 0.5.7, flate2 1.1.9, fnv 1.0.7, form_urlencoded 1.2.2, fs-err 3.3.0, futures-lite 2.6.1, gethostname 1.1.0, gif 0.14.2, glob 0.3.3, hashbrown 0.16.1, hashbrown 0.17.1, heck 0.5.0, hermit-abi 0.5.2, httparse 1.10.1, idna 1.1.0, idna_adapter 1.2.2, indexmap 2.14.0, itertools 0.10.5, itertools 0.13.0, jni 0.21.1, jobserver 0.1.34, js-sys 0.3.103, khronos-egl 6.0.0, lazy_static 1.5.0, linux-raw-sys 0.12.1, lock_api 0.4.14, log 0.4.33, mime 0.3.17, nasm-rs 0.3.2, num-bigint 0.4.6, num-bigint-dig 0.8.6, num-derive 0.4.2, num-integer 0.1.46, num-iter 0.1.45, num-traits 0.2.19, num_cpus 1.17.0, oid-registry 0.7.1, once_cell 1.21.4, openssl-probe 0.2.1, opus 0.3.1, ordered-stream 0.2.0, parking 2.2.1, parking_lot 0.12.5, parking_lot_core 0.9.12, percent-encoding 2.3.2, piper 0.2.5, pkg-config 0.3.33, png 0.18.1, polling 3.11.0, proptest 1.11.0, rayon 1.12.0, rayon-core 1.13.0, regex 1.12.4, regex-automata 0.4.14, regex-syntax 0.8.11, roxmltree 0.21.1, rsa 0.9.10, rustc_version 0.4.1, rusticata-macros 4.1.0, rustix 1.1.4, rustls 0.23.41, rustls-native-certs 0.8.4, rusty-fork 0.3.1, scopeguard 1.2.0, security-framework 3.7.0, security-framework-sys 2.17.0, signal-hook-registry 1.4.8, smallvec 1.15.2, socket2 0.6.4, stable_deref_trait 1.2.1, system-deps 7.0.8, tar 0.4.46, tempfile 3.27.0, thread_local 1.1.9, tinytemplate 1.2.1, unicode-segmentation 1.13.3, unicode-width 0.2.2, url 2.5.8, uuid 1.23.4, vcpkg 0.2.15, version_check 0.9.5, wait-timeout 0.2.1, wasi 0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wasm-bindgen 0.2.126, wasm-bindgen-macro 0.2.126, wasm-bindgen-macro-support 0.2.126, wasm-bindgen-shared 0.2.126, weezl 0.1.12, wit-bindgen 0.57.1, x509-parser 0.16.0, xattr 1.6.1
|
||||
The following license (LICENSE-APACHE) applies to: asn1-rs 0.6.2, asn1-rs-derive 0.5.1, assert_matches 1.5.0, async-channel 2.5.0, async-executor 1.14.0, async-io 2.6.0, async-lock 3.4.2, async-process 2.5.0, async-recursion 1.1.1, async-signal 0.2.14, async-task 4.7.1, atomic-waker 1.1.2, autocfg 1.5.1, base64 0.22.1, bitflags 1.3.2, bitflags 2.13.0, blocking 1.6.2, bumpalo 3.20.3, cast 0.3.0, cc 1.2.65, cexpr 0.6.0, cfg-if 1.0.4, cmake 0.1.58, concurrent-queue 2.5.0, core-foundation 0.10.1, core-foundation-sys 0.8.7, criterion 0.5.1, criterion-plot 0.5.0, crossbeam-deque 0.8.6, crossbeam-epoch 0.9.20, crossbeam-utils 0.8.21, curve25519-dalek-derive 0.1.1, der-parser 9.0.0, displaydoc 0.2.6, either 1.16.0, equivalent 1.0.2, errno 0.3.14, event-listener 5.4.1, event-listener-strategy 0.5.4, fastrand 2.4.1, filetime 0.2.29, find-msvc-tools 0.1.9, fixedbitset 0.5.7, flate2 1.1.9, fnv 1.0.7, form_urlencoded 1.2.2, fs-err 3.3.0, futures-lite 2.6.1, gethostname 1.1.0, gif 0.14.2, glob 0.3.3, hashbrown 0.16.1, hashbrown 0.17.1, heck 0.5.0, hermit-abi 0.5.2, httparse 1.10.1, idna 1.1.0, idna_adapter 1.2.2, indexmap 2.14.0, itertools 0.10.5, itertools 0.13.0, jni 0.21.1, jobserver 0.1.34, js-sys 0.3.103, khronos-egl 6.0.0, lazy_static 1.5.0, linux-raw-sys 0.12.1, lock_api 0.4.14, log 0.4.33, mime 0.3.17, nasm-rs 0.3.2, num-bigint 0.4.6, num-bigint-dig 0.8.6, num-derive 0.4.2, num-integer 0.1.46, num-iter 0.1.45, num-traits 0.2.19, num_cpus 1.17.0, oid-registry 0.7.1, once_cell 1.21.4, openssl-probe 0.2.1, opus 0.3.1, ordered-stream 0.2.0, parking 2.2.1, parking_lot 0.12.5, parking_lot_core 0.9.12, percent-encoding 2.3.2, piper 0.2.5, pkg-config 0.3.33, png 0.18.1, polling 3.11.0, proptest 1.11.0, rayon 1.12.0, rayon-core 1.13.0, regex 1.12.4, regex-automata 0.4.14, regex-syntax 0.8.11, roxmltree 0.21.1, rsa 0.9.10, rustc_version 0.4.1, rusticata-macros 4.1.0, rustix 1.1.4, rustls 0.23.41, rustls-native-certs 0.8.4, rusty-fork 0.3.1, scopeguard 1.2.0, security-framework 3.7.0, security-framework-sys 2.17.0, signal-hook-registry 1.4.8, smallvec 1.15.2, socket2 0.6.4, stable_deref_trait 1.2.1, system-deps 7.0.8, tar 0.4.46, tempfile 3.27.0, thread_local 1.1.9, tinytemplate 1.2.1, unicode-segmentation 1.13.3, unicode-width 0.2.2, url 2.5.8, uuid 1.23.4, vcpkg 0.2.15, version_check 0.9.5, wait-timeout 0.2.1, wasi 0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wasm-bindgen 0.2.126, wasm-bindgen-macro 0.2.126, wasm-bindgen-macro-support 0.2.126, wasm-bindgen-shared 0.2.126, weezl 0.1.12, wit-bindgen 0.57.1, x509-parser 0.16.0, xattr 1.6.1
|
||||
----------------------------------------------------------------------------
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
@@ -4011,7 +4011,7 @@ Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
The following license (LICENSE-MIT) applies to: cc 1.4.1, cfg-if 1.0.4, cmake 0.1.58, filetime 0.2.29, find-msvc-tools 0.1.10, jobserver 0.1.34, js-sys 0.3.103, openssl-probe 0.2.1, pkg-config 0.3.33, socket2 0.6.4, wait-timeout 0.2.1, wasm-bindgen 0.2.126, wasm-bindgen-macro 0.2.126, wasm-bindgen-macro-support 0.2.126, wasm-bindgen-shared 0.2.126
|
||||
The following license (LICENSE-MIT) applies to: cc 1.2.65, cfg-if 1.0.4, cmake 0.1.58, filetime 0.2.29, find-msvc-tools 0.1.9, jobserver 0.1.34, js-sys 0.3.103, openssl-probe 0.2.1, pkg-config 0.3.33, socket2 0.6.4, wait-timeout 0.2.1, wasm-bindgen 0.2.126, wasm-bindgen-macro 0.2.126, wasm-bindgen-macro-support 0.2.126, wasm-bindgen-shared 0.2.126
|
||||
----------------------------------------------------------------------------
|
||||
Copyright (c) 2014 Alex Crichton
|
||||
|
||||
@@ -6183,7 +6183,7 @@ DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
The following license (LICENSE) applies to: ffmpeg-next 9.0.0
|
||||
The following license (LICENSE) applies to: ffmpeg-next 8.1.0
|
||||
----------------------------------------------------------------------------
|
||||
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
||||
Version 2, December 2004
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.27.0"
|
||||
"version": "0.24.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -53,7 +53,7 @@
|
||||
"clients"
|
||||
],
|
||||
"summary": "Unpair a client",
|
||||
"description": "Removes the client's certificate from the pairing store (persisted — the removal survives a\nhost restart). Removing the last pairing also closes the GameStream ENet control port\n(UDP 47999), which is only bound while at least one pairing exists. Caveat: the nvhttp TLS\nlayer does not yet reject unlisted certificates (`gamestream/tls.rs` accepts any well-formed\nclient cert — a planned hardening step), so until that lands this removes the client\nfrom the listing without severing its ability to reconnect.",
|
||||
"description": "Removes the client's certificate from the pairing store. Caveat: the nvhttp TLS layer\ndoes not yet reject unlisted certificates (`gamestream/tls.rs` accepts any well-formed\nclient cert — a planned hardening step), so until that lands this removes the client\nfrom the listing without severing its ability to reconnect.",
|
||||
"operationId": "unpairClient",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -997,7 +997,7 @@
|
||||
"library"
|
||||
],
|
||||
"summary": "List the game library",
|
||||
"description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the\nentries a given external provider owns; `?platform=` to one platform (case-insensitive —\ninstalled-store titles are `PC`, custom/provider entries carry whatever was authored).\n\n**The operator's own lane additionally sees the titles they have HIDDEN**, each carrying\n`hidden: true`; every other lane gets them filtered out upstream and cannot tell they exist. The\nconsole needs them to offer \"un-hide\", and it is the only surface that does.",
|
||||
"description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the\nentries a given external provider owns; `?platform=` to one platform (case-insensitive —\ninstalled-store titles are `PC`, custom/provider entries carry whatever was authored).",
|
||||
"operationId": "getLibrary",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1021,13 +1021,13 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Unified library across all stores (the operator's lane also gets hidden entries, flagged)",
|
||||
"description": "Unified library across all stores",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/OperatorGameEntry"
|
||||
"$ref": "#/components/schemas/GameEntry"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1301,79 +1301,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/library/hidden/{id}": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"library"
|
||||
],
|
||||
"summary": "Hide or un-hide one library title",
|
||||
"description": "Curation, not access control: a hidden title disappears from every play surface — the console\ngrid on a client, native clients, the GameStream app list, and launch resolution — while nothing\nis deleted and un-hiding restores it immediately. The operator's own console still lists it\n(flagged `hidden`) so it can be brought back.\n\nKeyed by the entry's stable `<store>:<external_id>` id, which survives re-scans and reconciles by\nconstruction (D2). The id is **not** validated against the current library on purpose: a title\ncan be legitimately absent at this moment (launcher closed, plugin mid-sync, drive unmounted),\nand refusing the operator's choice in that window would be worse than storing an id that\ncurrently matches nothing. Emits `library.changed` (source = the store) only on a real change.",
|
||||
"operationId": "setLibraryEntryHidden",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"description": "The library entry id (e.g. `steam:70`)",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HiddenToggle"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Stored; the entry's visibility after the call",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HiddenState"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Empty entry id",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Could not persist the settings",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/library/provider/{provider}": {
|
||||
"put": {
|
||||
"tags": [
|
||||
@@ -4118,51 +4045,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"AudioWiring": {
|
||||
"type": "object",
|
||||
"description": "The Windows host's audio wiring verdict — which endpoint carries each role. The names are\nthe endpoints' friendly names as the Sound settings show them (on current hosts the minted\n\"Punktfunk\" instances of Steam's streaming drivers).",
|
||||
"required": [
|
||||
"readiness",
|
||||
"mic_withheld",
|
||||
"last_resort"
|
||||
],
|
||||
"properties": {
|
||||
"last_resort": {
|
||||
"type": "boolean",
|
||||
"description": "The loopback is the known-degraded last resort — desktop audio may be silent until the\nendpoint set changes."
|
||||
},
|
||||
"loopback": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Friendly name of the desktop-audio loopback source; absent = desktop audio unavailable."
|
||||
},
|
||||
"mic": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Friendly name of the virtual-mic write target; absent = mic passthrough unavailable."
|
||||
},
|
||||
"mic_withheld": {
|
||||
"type": "boolean",
|
||||
"description": "The mic was WITHHELD so game audio could keep the only working sink — mic passthrough\nneeds Steam installed (the host mints its own microphone) or a virtual cable."
|
||||
},
|
||||
"narrowing": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Why the chosen loopback endpoint NARROWS the desktop mix (rate/channels), when it does."
|
||||
},
|
||||
"readiness": {
|
||||
"type": "string",
|
||||
"description": "`full` | `audio_only` | `mic_only` | `none` — whether desktop audio and mic passthrough\neach have an endpoint at all.",
|
||||
"example": "full"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AvailableCompositor": {
|
||||
"type": "object",
|
||||
"description": "A compositor backend the host can drive a virtual output on, and whether it's usable now.",
|
||||
@@ -4489,13 +4371,6 @@
|
||||
],
|
||||
"description": "The provider's own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is."
|
||||
},
|
||||
"icon": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Which brand mark a client should draw for this entry — see [`GameEntry::icon`]. A token\n(`steam`, `heroic`), never bytes and never a URL."
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)."
|
||||
@@ -4562,13 +4437,6 @@
|
||||
"$ref": "#/components/schemas/DetectHint",
|
||||
"description": "How to recognize this title's process — see [`CustomEntry::detect`]."
|
||||
},
|
||||
"icon": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Which brand mark to draw — see [`GameEntry::icon`]. Hand-settable for the same reason `role`\nis: an operator's own \"Steam\" tile should be able to look like one."
|
||||
},
|
||||
"launch": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -4788,7 +4656,7 @@
|
||||
"version": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "Schema version (currently 1) — lets a future field addition migrate rather than reject. Read\nat load time ([`DisplayPolicyStore::load_from`] warns when a file claims a version this host\ndoes not know, then reads it best-effort) and pinned back to the current version on write.",
|
||||
"description": "Schema version (currently 1) — lets a future field addition migrate rather than reject.",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
@@ -4857,7 +4725,7 @@
|
||||
},
|
||||
"EffectivePolicy": {
|
||||
"type": "object",
|
||||
"description": "The six resolved fields after preset expansion — what the lifecycle/registry and the policy call\nsites read, and what the mgmt API echoes as the \"currently in force\" policy. Pure output of\n[`DisplayPolicy::effective`].\n\n**Every field is required on the wire, deliberately.** Unlike [`DisplayPolicy`] — which is only\never a *file* — this shape is also the `fields` member of [`CustomPresetInput`], i.e. the request\nbody of `POST /display/presets` and `PUT /display/presets/{id}`, and a *response* member three\ntimes over (`DisplaySettingsState.effective`, `PresetInfo.fields`, `CustomPreset.fields`).\n`#[serde(default)]` here would (a) turn `{\"name\":\"Kiosk\",\"fields\":{}}` — or any camelCase typo —\nfrom a serde rejection into a 201 storing a preset that expands to six axes nobody chose, and\n(b) make all six OPTIONAL in the generated OpenAPI schema, so every codegen'd client has to\nnull-check them. The *persisted* catalog's tolerance for an entry written before an axis existed\nis bought where it belongs, on the read path only: see [`StoredEffectivePolicy`].",
|
||||
"description": "The six resolved fields after preset expansion — what the lifecycle/registry and the Stage-0 call\nsites read, and what the mgmt API echoes as the \"currently in force\" policy. Pure output of\n[`DisplayPolicy::effective`].",
|
||||
"required": [
|
||||
"keep_alive",
|
||||
"topology",
|
||||
@@ -5364,14 +5232,6 @@
|
||||
"art": {
|
||||
"$ref": "#/components/schemas/Artwork"
|
||||
},
|
||||
"icon": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Which brand mark to draw for this entry, as a **token** — `steam`, `heroic`, `playnite` —\nnever image bytes and never a URL. See [`is_icon_token`].\n\nIt exists for launcher tiles, which by design ship no cover art: a launcher's own icon is\nsquare, every client cover-crops a 2:3 poster, and the crop turns a mark into a strip — so\nuntil now those tiles were the launcher's name on a flat accent face. The token lets a client\ndraw the real mark from art it already ships, at whatever size its tile happens to be.\n\nA token rather than art on the wire because the host's art proxy serves *raster* bytes only\n([`art::local_art_bytes`] sniffs the container and refuses anything else, SVG very much\nincluded — it is script-capable XML and the console renders art in a browser). Sending the\nname of a mark instead of the mark keeps that refusal intact, keeps the glyph vector at every\ntile size, and lets it take the tile's ink.\n\nOrdinary titles may carry one too — nothing here is launcher-specific — but nothing sets it\nfor them: a game has real cover art, which is strictly better than a brand mark.",
|
||||
"example": "steam"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Stable, store-qualified id: `steam:<appid>` or `custom:<id>`.",
|
||||
@@ -5648,37 +5508,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"HiddenState": {
|
||||
"type": "object",
|
||||
"description": "What `setLibraryEntryHidden` echoes back.",
|
||||
"required": [
|
||||
"id",
|
||||
"hidden"
|
||||
],
|
||||
"properties": {
|
||||
"hidden": {
|
||||
"type": "boolean",
|
||||
"description": "Its visibility after the call."
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "The entry id the call addressed."
|
||||
}
|
||||
}
|
||||
},
|
||||
"HiddenToggle": {
|
||||
"type": "object",
|
||||
"description": "Request body for `setLibraryEntryHidden`.",
|
||||
"required": [
|
||||
"hidden"
|
||||
],
|
||||
"properties": {
|
||||
"hidden": {
|
||||
"type": "boolean",
|
||||
"description": "Whether this title should be hidden from every play surface."
|
||||
}
|
||||
}
|
||||
},
|
||||
"HookEntry": {
|
||||
"type": "object",
|
||||
"description": "One hook: fire `run` and/or `webhook` when an event matching `on` (+ `filter`) occurs.",
|
||||
@@ -5915,7 +5744,7 @@
|
||||
},
|
||||
"Identity": {
|
||||
"type": "string",
|
||||
"description": "Stable display identity, so desktop environments persist per-display config (KDE scaling). The\nslot this resolves to is carried per backend: the Windows EDID serial + IddCx connector index,\nKWin's per-slot output name, and the host-persisted Mutter scale map.",
|
||||
"description": "Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored\nat Stage 0; carriers wired from the identity stage.",
|
||||
"enum": [
|
||||
"shared",
|
||||
"per-client",
|
||||
@@ -6132,14 +5961,14 @@
|
||||
"seconds": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "Linger window in seconds, clamped to `0..=86400` on write (see\n[`DisplayPolicy::sanitized`]): a window longer than a day is `forever` by any honest\nreading, and `u32` seconds is ~136 years — a deadline the reaper would never reach and a\nnonsense `expires_in_ms` in `/display/state`.",
|
||||
"description": "Linger window in seconds.",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"description": "Keep the display until host shutdown or an explicit release (the `Pinned` lifecycle state).\nHonored end-to-end: the registry resolves it to `Release::Pin`, so the display survives every\ndisconnect — free it with `POST /display/release` (which force-releases `Pinned` exactly like\na `Lingering` display). This is what the `gaming-rig` preset selects.",
|
||||
"description": "Keep the display until host shutdown or an explicit release (the `Pinned` lifecycle state).\n**Not honored until the display-lifecycle stage** — rejected by the mgmt PUT at Stage 0.",
|
||||
"required": [
|
||||
"mode"
|
||||
],
|
||||
@@ -6183,7 +6012,6 @@
|
||||
},
|
||||
"positions": {
|
||||
"type": "object",
|
||||
"description": "Keys are the **canonical decimal** identity-slot id (`\"1\"`..`\"15\"`) — the exact string\n`arrange` looks a member up by. [`DisplayPolicy::sanitized`] re-canonicalizes them on write\n(`\"01\"` → `\"1\"`) and drops anything that is not a slot id, because a key that never matches is\na pin the operator can see in the console and in `GET /display/settings` while every session\nsilently auto-rows past it.",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/components/schemas/Position"
|
||||
},
|
||||
@@ -6195,7 +6023,7 @@
|
||||
},
|
||||
"LayoutMode": {
|
||||
"type": "string",
|
||||
"description": "How group members are arranged in the desktop coordinate space, resolved by `layout::arrange` —\nwhich both the `/display/state` readout and (on Linux, KWin only) the per-backend position apply\nconsume, so the answer is computed in exactly one place.",
|
||||
"description": "How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage.",
|
||||
"enum": [
|
||||
"auto-row",
|
||||
"manual"
|
||||
@@ -6355,7 +6183,7 @@
|
||||
},
|
||||
"ModeConflict": {
|
||||
"type": "string",
|
||||
"description": "Admission when a *different* client connects while a display/session is already live and asks for\na different mode. Enforced by [`super::admission`] before the Welcome is sent, so a `reject` is a\nclean handshake error rather than a half-built session.",
|
||||
"description": "Admission when a *different* client connects while a display/session is already live and asks for\na different mode. Stored at Stage 0; enforced from the mode-conflict admission stage.",
|
||||
"enum": [
|
||||
"separate",
|
||||
"steal",
|
||||
@@ -6466,23 +6294,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"OperatorGameEntry": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/GameEntry"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"hidden": {
|
||||
"type": "boolean",
|
||||
"description": "The operator hid this title ([`set_entry_hidden`]) — omitted when false, so the shape only\ngrows for entries that actually are hidden."
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": "A library entry plus the operator's own view of it — today, whether they hid it.\n\nA separate type rather than a field on [`GameEntry`] for two reasons. It keeps the visibility\nanswer out of the providers entirely: a store parser has no opinion on what the operator hid, and\nadding `hidden: false` to all eight construction sites would imply it does. More importantly it\nmakes the lane rule a TYPE guarantee instead of a discipline — `GET /library` answers\n`Vec<GameEntry>` on every lane but the operator's, so a hidden entry cannot leak to a paired\nclient by someone forgetting a filter; there is no field there to leak.\n\n`flatten` keeps the wire shape identical to a plain entry with one extra key, so the console\nparses one model either way."
|
||||
},
|
||||
"PairedClient": {
|
||||
"type": "object",
|
||||
"description": "A paired (certificate-pinned) Moonlight client.",
|
||||
@@ -6892,13 +6703,6 @@
|
||||
"type": "string",
|
||||
"description": "The provider's stable id for this title (the reconcile diff key)."
|
||||
},
|
||||
"icon": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Which brand mark to draw — see [`GameEntry::icon`]. This is the field a library plugin sets\non its `launchers(cfg)` tiles, and the whole reason the token exists."
|
||||
},
|
||||
"launch": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -7001,17 +6805,6 @@
|
||||
"description": "Number of live streaming sessions across BOTH planes (GameStream + native punktfunk/1). The\nnative server admits concurrent sessions, so this can exceed 1; `session`/`stream` below\ndescribe a single representative session for the detail card.",
|
||||
"minimum": 0
|
||||
},
|
||||
"audio": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/AudioWiring",
|
||||
"description": "The audio wiring verdict (Windows hosts; absent on other platforms and before the first\nwiring pass). Present even while idle — the wiring exists for the host's lifetime."
|
||||
}
|
||||
]
|
||||
},
|
||||
"audio_streaming": {
|
||||
"type": "boolean",
|
||||
"description": "True while the audio stream thread is running."
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
Font Awesome Free — brand icons (steam, xbox in assets/launcher-icons/) are from
|
||||
Font Awesome Free.
|
||||
|
||||
Copyright (c) Fonticons, Inc. (https://fontawesome.com)
|
||||
|
||||
Font Awesome Free icons are licensed under the Creative Commons Attribution 4.0
|
||||
International license (CC BY 4.0), https://creativecommons.org/licenses/by/4.0/.
|
||||
The icons are redistributed here as monochrome SVG path data with no
|
||||
modifications beyond color normalization (fill="currentColor").
|
||||
|
||||
Per the Font Awesome Free license (https://fontawesome.com/license/free):
|
||||
"Font Awesome Free is free, open source, and GPL friendly. You can use it for
|
||||
commercial projects, open source projects, or really almost whatever you want.
|
||||
Attribution is required by MIT, SIL OFL, and CC BY licenses."
|
||||
|
||||
Brand icons are trademarks of their respective owners and are used for
|
||||
identification purposes only; their use does not imply endorsement.
|
||||
@@ -1,29 +0,0 @@
|
||||
Playnite — the `playnite` mark in assets/launcher-icons/ is the Playnite logo from the
|
||||
Playnite source repository (media/playnite-logo-black.svg).
|
||||
|
||||
Copyright (c) 2020 Josef Nemec (https://github.com/JosefNemec/Playnite)
|
||||
|
||||
Licensed under the MIT License:
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without limitation the rights to use, copy,
|
||||
modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
|
||||
and to permit persons to whom the Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
Modifications: the colour was normalized to fill="currentColor"; the original viewBox
|
||||
(0 0 1024 1024) and path geometry are unchanged.
|
||||
|
||||
Brand icons are trademarks of their respective owners and are used for identification
|
||||
purposes only; their use does not imply endorsement.
|
||||
@@ -1,13 +0,0 @@
|
||||
Simple Icons — brand icons (lutris, heroic, epic, gog in assets/launcher-icons/) are
|
||||
from Simple Icons
|
||||
(https://simpleicons.org, https://github.com/simple-icons/simple-icons).
|
||||
|
||||
Upstream slugs: lutris, heroicgameslauncher, epicgames, gogdotcom.
|
||||
|
||||
The Simple Icons SVG path data is released under CC0 1.0 Universal (public domain
|
||||
dedication), https://creativecommons.org/publicdomain/zero/1.0/ — no attribution
|
||||
required; this notice is provided for provenance.
|
||||
|
||||
Brand icons are trademarks of their respective owners and are used for
|
||||
identification purposes only; their use does not imply endorsement. See
|
||||
https://github.com/simple-icons/simple-icons/blob/develop/DISCLAIMER.md.
|
||||
@@ -1,62 +0,0 @@
|
||||
# Launcher icon masters
|
||||
|
||||
The brand marks a **launcher tile** draws — the entries a library plugin publishes with
|
||||
`role: "launcher"` (design D4), which open Steam Big Picture or Heroic or Playnite rather
|
||||
than a game. One file per **icon token**, the value a plugin puts in an entry's `icon`
|
||||
field and every client resolves against the set it ships.
|
||||
|
||||
| token | mark | emitted by | source |
|
||||
|---|---|---|---|
|
||||
| `steam` | Steam | punktfunk-plugin-steam (Big Picture + desktop) | Font Awesome Free brands (CC BY 4.0) |
|
||||
| `lutris` | Lutris | punktfunk-plugin-lutris | Simple Icons (CC0 1.0) |
|
||||
| `heroic` | Heroic Games Launcher | punktfunk-plugin-heroic | Simple Icons (CC0 1.0, slug `heroicgameslauncher`) |
|
||||
| `playnite` | Playnite | punktfunk-plugin-playnite | JosefNemec/Playnite (MIT) |
|
||||
| `epic` | Epic Games | punktfunk-plugin-epic — **dormant** | Simple Icons (CC0 1.0, slug `epicgames`) |
|
||||
| `gog` | GOG.com | punktfunk-plugin-gog — **dormant** | Simple Icons (CC0 1.0, slug `gogdotcom`) |
|
||||
| `xbox` | Xbox | punktfunk-plugin-xbox — **dormant** | Font Awesome Free brands (CC BY 4.0) |
|
||||
|
||||
The last three are **dormant on purpose**: those plugins carry a `launcher` config switch that
|
||||
is off by default and whose `launcherEntries` returns nothing, because the host has no verified
|
||||
`launcher_ui` activation for them yet — a tile would be a card that does nothing. Their marks
|
||||
ship anyway so that turning one on stays the one-line plugin change those plugins promise,
|
||||
instead of also needing a release of all six clients.
|
||||
|
||||
`steam` is the same mark as `assets/os-icons/steam.svg`, generated from that file rather than
|
||||
re-sourced, so the SteamOS host badge and the Steam launcher tile can never drift apart.
|
||||
|
||||
## Why a token and not the icon itself
|
||||
|
||||
A plugin sends the **name** of a mark, never its bytes, and never a URL.
|
||||
|
||||
The obvious alternative — a plugin ships its own `icon.svg` and the host's art proxy serves it —
|
||||
is closed by construction, and deliberately: `local_art_bytes` serves what the bytes *are*
|
||||
(`sniff_image_type`, `crates/punktfunk-host/src/library/art.rs`), and SVG is not on that list
|
||||
because it is script-capable XML and the web console renders library art in a browser. Widening
|
||||
that sniff to admit SVG would trade a rendering nicety for a stored-XSS surface.
|
||||
|
||||
Sending a token instead keeps that refusal intact and buys three things a proxied image could
|
||||
not have given us anyway: the glyph stays vector at every tile size a client picks, it takes the
|
||||
tile's own ink instead of arriving pre-coloured, and it costs no fetch, no cache and no bytes on
|
||||
a reconcile that is already body-limited.
|
||||
|
||||
The cost is that a **third-party** plugin cannot ship a mark no client bundles. Its tile falls
|
||||
back to the launcher's name on an accent face — exactly what every launcher tile looked like
|
||||
before this existed — and the fix is a pull request adding the master here.
|
||||
|
||||
All files are monochrome (`fill="currentColor"`), original per-icon viewBoxes preserved. Those
|
||||
viewBoxes are not all square (`0 0 24 24`, `0 0 496 512`, `0 0 1024 1024`), so **a client must
|
||||
letterbox rather than stretch** — a mark drawn to a square box is a squashed mark.
|
||||
|
||||
## Regenerating the per-client derivatives
|
||||
|
||||
`bash scripts/gen-launcher-icons.sh [token ...]` turns a master into the three baked forms (GTK
|
||||
symbolic SVG, Windows PNG, Apple template PDF) and prints the path data for the three clients
|
||||
that inline it (web console, Android, the in-session console UI). Adding a **new** token also
|
||||
means adding it to each client's shipped-token list — the script prints that checklist too.
|
||||
|
||||
## Licensing
|
||||
|
||||
Attribution notices live in `LICENSES/` and are folded into `THIRD-PARTY-NOTICES.txt` by
|
||||
`scripts/gen-third-party-notices.py`. The marks are trademarks of their respective owners; they
|
||||
are used here nominatively — to *identify* the launcher a tile opens, the standard practice in
|
||||
this ecosystem — and imply no affiliation or endorsement.
|
||||
@@ -1,2 +0,0 @@
|
||||
<!-- epic — from Simple Icons (CC0 1.0), slug `epicgames`. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M3.537 0C2.165 0 1.66.506 1.66 1.879V18.44a4.262 4.262 0 00.02.433c.031.3.037.59.316.92.027.033.311.245.311.245.153.075.258.13.43.2l8.335 3.491c.433.199.614.276.928.27h.002c.314.006.495-.071.928-.27l8.335-3.492c.172-.07.277-.124.43-.2 0 0 .284-.211.311-.243.28-.33.285-.621.316-.92a4.261 4.261 0 00.02-.434V1.879c0-1.373-.506-1.88-1.878-1.88zm13.366 3.11h.68c1.138 0 1.688.553 1.688 1.696v1.88h-1.374v-1.8c0-.369-.17-.54-.523-.54h-.235c-.367 0-.537.17-.537.539v5.81c0 .369.17.54.537.54h.262c.353 0 .523-.171.523-.54V8.619h1.373v2.143c0 1.144-.562 1.71-1.7 1.71h-.694c-1.138 0-1.7-.566-1.7-1.71V4.82c0-1.144.562-1.709 1.7-1.709zm-12.186.08h3.114v1.274H6.117v2.603h1.648v1.275H6.117v2.774h1.74v1.275h-3.14zm3.816 0h2.198c1.138 0 1.7.564 1.7 1.708v2.445c0 1.144-.562 1.71-1.7 1.71h-.799v3.338h-1.4zm4.53 0h1.4v9.201h-1.4zm-3.13 1.235v3.392h.575c.354 0 .523-.171.523-.54V4.965c0-.368-.17-.54-.523-.54zm-3.74 10.147a1.708 1.708 0 01.591.108 1.745 1.745 0 01.49.299l-.452.546a1.247 1.247 0 00-.308-.195.91.91 0 00-.363-.068.658.658 0 00-.28.06.703.703 0 00-.224.163.783.783 0 00-.151.243.799.799 0 00-.056.299v.008a.852.852 0 00.056.31.7.7 0 00.157.245.736.736 0 00.238.16.774.774 0 00.303.058.79.79 0 00.445-.116v-.339h-.548v-.565H7.37v1.255a2.019 2.019 0 01-.524.307 1.789 1.789 0 01-.683.123 1.642 1.642 0 01-.602-.107 1.46 1.46 0 01-.478-.3 1.371 1.371 0 01-.318-.455 1.438 1.438 0 01-.115-.58v-.008a1.426 1.426 0 01.113-.57 1.449 1.449 0 01.312-.46 1.418 1.418 0 01.474-.309 1.58 1.58 0 01.598-.111 1.708 1.708 0 01.045 0zm11.963.008a2.006 2.006 0 01.612.094 1.61 1.61 0 01.507.277l-.386.546a1.562 1.562 0 00-.39-.205 1.178 1.178 0 00-.388-.07.347.347 0 00-.208.052.154.154 0 00-.07.127v.008a.158.158 0 00.022.084.198.198 0 00.076.066.831.831 0 00.147.06c.062.02.14.04.236.061a3.389 3.389 0 01.43.122 1.292 1.292 0 01.328.17.678.678 0 01.207.24.739.739 0 01.071.337v.008a.865.865 0 01-.081.382.82.82 0 01-.229.285 1.032 1.032 0 01-.353.18 1.606 1.606 0 01-.46.061 2.16 2.16 0 01-.71-.116 1.718 1.718 0 01-.593-.346l.43-.514c.277.223.578.335.9.335a.457.457 0 00.236-.05.157.157 0 00.082-.142v-.008a.15.15 0 00-.02-.077.204.204 0 00-.073-.066.753.753 0 00-.143-.062 2.45 2.45 0 00-.233-.062 5.036 5.036 0 01-.413-.113 1.26 1.26 0 01-.331-.16.72.72 0 01-.222-.243.73.73 0 01-.082-.36v-.008a.863.863 0 01.074-.359.794.794 0 01.214-.283 1.007 1.007 0 01.34-.185 1.423 1.423 0 01.448-.066 2.006 2.006 0 01.025 0zm-9.358.025h.742l1.183 2.81h-.825l-.203-.499H8.623l-.198.498h-.81zm2.197.02h.814l.663 1.08.663-1.08h.814v2.79h-.766v-1.602l-.711 1.091h-.016l-.707-1.083v1.593h-.754zm3.469 0h2.235v.658h-1.473v.422h1.334v.61h-1.334v.442h1.493v.658h-2.255zm-5.3.897l-.315.793h.624zm-1.145 5.19h8.014l-4.09 1.348z"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.8 KiB |
@@ -1,2 +0,0 @@
|
||||
<!-- gog — from Simple Icons (CC0 1.0), slug `gogdotcom`. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M7.15 15.24H4.36a.4.4 0 0 0-.4.4v2c0 .21.18.4.4.4h2.8v1.32h-3.5c-.56 0-1.02-.46-1.02-1.03v-3.39c0-.56.46-1.02 1.03-1.02h3.48v1.32zM8.16 11.54c0 .58-.47 1.05-1.05 1.05H2.63v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4H4.39a.4.4 0 0 0-.41.4v2.02c0 .23.18.4.4.4H6v1.35H3.68c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04H7.1c.58 0 1.05.47 1.05 1.04v5.86zM21.36 19.36h-1.32v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.42c0-.56.46-1.02 1.03-1.02h5.61v5.44zM21.37 11.54c0 .58-.47 1.05-1.05 1.05h-4.48v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4h-2.03a.4.4 0 0 0-.4.4v2.02c0 .23.18.4.4.4h1.62v1.35H16.9c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04h3.43c.58 0 1.05.47 1.05 1.04v5.86zM13.72 4.64h-3.44c-.58 0-1.04.47-1.04 1.04v3.44c0 .58.46 1.04 1.04 1.04h3.44c.57 0 1.04-.46 1.04-1.04V5.68c0-.57-.47-1.04-1.04-1.04m-.3 1.75v2.02a.4.4 0 0 1-.4.4h-2.03a.4.4 0 0 1-.4-.4V6.4c0-.22.17-.4.4-.4H13c.23 0 .4.18.4.4zM12.63 13.92H9.24c-.57 0-1.03.46-1.03 1.02v3.39c0 .57.46 1.03 1.03 1.03h3.39c.57 0 1.03-.46 1.03-1.03v-3.39c0-.56-.46-1.02-1.03-1.02m-.3 1.72v2a.4.4 0 0 1-.4.4v-.01H9.94a.4.4 0 0 1-.4-.4v-1.99c0-.22.18-.4.4-.4h2c.22 0 .4.18.4.4zM23.49 1.1a1.74 1.74 0 0 0-1.24-.52H1.75A1.74 1.74 0 0 0 0 2.33v19.34a1.74 1.74 0 0 0 1.75 1.75h20.5A1.74 1.74 0 0 0 24 21.67V2.33c0-.48-.2-.92-.51-1.24m0 20.58a1.23 1.23 0 0 1-1.24 1.24H1.75A1.23 1.23 0 0 1 .5 21.67V2.33a1.23 1.23 0 0 1 1.24-1.24h20.5a1.24 1.24 0 0 1 1.24 1.24v19.34z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,2 +0,0 @@
|
||||
<!-- heroic — from Simple Icons (CC0 1.0), slug `heroicgameslauncher`. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M11.999 0 11.997 0a.891.891 0 0 0-.36.075C8.964 1.253 6.29 2.434 3.618 3.613A.893.893 0 0 0 3.1 4.619l3.146 14.646c.043.197.15.375.307.504l4.88 4.027a.895.895 0 0 0 1.131.006l5-4.031a.895.895 0 0 0 .315-.516L20.9 4.614a.895.895 0 0 0-.515-1L12.358.074A.892.892 0 0 0 12 0zm0 .35v.003c.114 0 .228.023.334.07l7.42 3.27a.827.827 0 0 1 .476.924l-2.793 13.535a.83.83 0 0 1-.289.478l-4.623 3.725a.826.826 0 0 1-1.045-.006l-4.513-3.723a.829.829 0 0 1-.281-.465L3.775 4.622a.83.83 0 0 1 .476-.931L11.665.42a.832.832 0 0 1 .334-.07zm-.045 1.954L10.28 5.202h-.002l1.211 11.301.512.409.512-.409 1.117-11.3zM9.003 16.261l-.584 1.068.584 1.07 2.295-.38.47-.69-.47-.671zm5.996 0-2.295.397-.47.671.47.69 2.295.38.584-1.07zm-2.998 1.488-.51.444-.281 2.168.789.55.793-.55-.295-2.168z"/></svg>
|
||||
|
Before Width: | Height: | Size: 957 B |
@@ -1,2 +0,0 @@
|
||||
<!-- lutris — from Simple Icons (CC0 1.0), slug `lutris`. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="m21.231 18.89.001-.002c-1.293 3.243-5.218 5.232-9.447 5.105C5.3 23.993 0 18.48 0 11.906S5.276.001 11.785.001c1.793 0 3.493.406 5.015 1.13.081-.177.271-.544.451-.557.238-.017.374.137.526.309.154.172.46.429.46.429s1.393-.481 2.955.377c1.563.858 1.783 1.116 2.09 1.716.152.301.195.829.2 1.282a.796.796 0 0 0-.07-.003c-.496 0-.96.455-.96 1.08 0 .263.082.496.215.678l-.01.007a1.505 1.505 0 0 0-.132.01 18.704 18.704 0 0 0-.389-.142 2.53 2.53 0 0 1-.82-.472 1.402 1.402 0 0 0-1.196-2.112c-.383 0-.73.156-.982.41-.472-.271-1.174-.482-2.527-.565l-.407-.011c-2.282.012-3.611.279-5.979 1.301-.603.283-1.206.615-1.785 1.001-.423.3-.639.67-.709 1.137a1.326 1.326 0 0 0 1.23 1.373h.042c1.27.06 2.039 1.99 2.063 2.497.004.05.004.023.003.08-.032.727-.37 1.267-1.088 1.246a1.231 1.231 0 0 1-.976-.494c-.063-.077-.103-.172-.159-.254-.666-1.081-1.732-1.36-2.771-1.523-.438-.068-1.073-.122-1.31.25a8.28 8.28 0 0 0-.577 3.063c-.02 5.036 4.041 9.118 9.026 9.118 2.575 0 5.349-.952 6.993-2.7l-.035.03c-1.772 1.473-4.66 1.941-6.027 1.941-4.302 0-7.818-3.232-7.818-7.578 0-1.276.288-2.396.814-3.36.495.183.947.483 1.28 1.022a.24.24 0 0 0 .013.021c.064.092.111.197.182.284.424.524.881.658 1.342.68h.01c.43.013.768-.12 1.024-.342.347-.3.55-.79.577-1.382v-.014c.002-.085 0-.053-.004-.112-.024-.376-.333-1.318-.906-2.027-.266-.331-.587-.607-.95-.774l.12-.074c.756-.457 2.364-.977 4.592-.638 1.13.173 2.055.419 3.483.879 1.657.534 2.579 1.279 3.854 1.427.15.017.301.018.45.003.41 1.129.634 2.35.634 3.621 0 2.068-.59 3.995-1.611 5.62zm1.947-12.274s-.115.201-.364.322c-.103.05-.282-.075-.45.1-.359.726.516 1.332.923 1.315.408-.017.73-.432.712-.793-.017-.558-.82-.944-.82-.944zm.234-1.432c.255 0 .462.26.462.58 0 .32-.207.58-.462.58-.254 0-.46-.26-.46-.58 0-.32.206-.58.46-.58zm-3.292-.951c.492 0 .89.403.89.9a.895.895 0 0 1-.89.898.895.895 0 0 1-.89-.899c0-.496.399-.899.89-.899z"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -1,2 +0,0 @@
|
||||
<!-- playnite — from JosefNemec/Playnite media/playnite-logo-black.svg (MIT). See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="currentColor"><path d="M966.686,623.899c-9.773-81.666-29.323-161.25-54.514-239.447c-13.759-42.709-30.419-84.189-56.091-121.452 c-31.701-46.014-74.789-72.958-130.812-78.579c-29.631-2.973-57.785,4.118-85.677,12.35 c-61.172,18.056-123.359,25.124-186.493,14.903c-30.919-5.006-61.308-13.526-91.743-21.225 c-76.445-19.338-145.323,4.995-191.165,69.261c-11.441,16.04-21.194,33.543-29.78,51.312 c-25.091,51.925-40.443,107.249-54.53,162.924c-18.822,74.393-33.019,149.491-33.664,226.571c0,7.184-0.342,14.386,0.061,21.547 c1.557,27.727,4.354,55.289,16.045,80.97c15.334,33.68,45.905,46.725,79.471,31.198c18.291-8.461,36.293-19.857,50.766-33.743 c24.597-23.598,46.616-49.934,69.125-75.64c17.934-20.481,39.086-35.301,66.115-40.203c15.779-2.862,31.802-6.006,47.736-6.118 c87.888-0.62,175.783-0.602,263.673-0.278c51.4,0.189,93.314,19.382,124.091,62.134c12.518,17.388,27.83,32.889,42.78,48.371 c18.598,19.259,38.974,36.431,64.412,46.39c32.967,12.907,62.547,1.677,77.882-30.198c3.965-8.242,6.963-17.122,9.155-26.017 C976.198,727.534,972.874,675.607,966.686,623.899z M315.471,527.643c-44.289,0.213-80.733-36.32-80.847-81.045 c-0.115-45.048,35.472-81.194,80.197-81.458c44.521-0.263,80.718,35.897,80.884,80.801 C395.871,490.671,359.773,527.429,315.471,527.643z M708.857,319.301c21.859,0.06,39.486,17.884,39.471,39.91 c-0.015,22.133-17.489,39.677-39.523,39.682c-22.045,0.005-39.456-17.53-39.444-39.724 C669.372,337.125,687.089,319.241,708.857,319.301z M622.269,486.36c-21.542,0.085-39.7-18.08-39.808-39.822 c-0.108-21.888,17.617-39.622,39.62-39.641c22.066-0.018,39.759,17.552,39.718,39.442 C661.758,468.205,643.909,486.275,622.269,486.36z M708.967,573.333c-21.823,0.096-39.537-17.668-39.611-39.721 c-0.074-22.079,17.523-39.992,39.338-40.044c21.715-0.052,39.597,17.908,39.645,39.816 C748.386,555.477,730.883,573.237,708.967,573.333z M795.752,486.362c-21.764,0.155-39.671-17.882-39.651-39.938 c0.021-22.15,17.628-39.639,39.793-39.525c22.091,0.114,39.527,17.993,39.155,40.152 C834.686,468.733,817.216,486.209,795.752,486.362z"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,2 +0,0 @@
|
||||
<!-- steam — from Font Awesome Free 5 brands (CC BY 4.0); the same mark as assets/os-icons/steam.svg. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512" fill="currentColor"><path d="M496 256c0 137-111.2 248-248.4 248-113.8 0-209.6-76.3-239-180.4l95.2 39.3c6.4 32.1 34.9 56.4 68.9 56.4 39.2 0 71.9-32.4 70.2-73.5l84.5-60.2c52.1 1.3 95.8-40.9 95.8-93.5 0-51.6-42-93.5-93.7-93.5s-93.7 42-93.7 93.5v1.2L176.6 279c-15.5-.9-30.7 3.4-43.5 12.1L0 236.1C10.2 108.4 117.1 8 247.6 8 384.8 8 496 119 496 256zM155.7 384.3l-30.5-12.6a52.79 52.79 0 0 0 27.2 25.8c26.9 11.2 57.8-1.6 69-28.4 5.4-13 5.5-27.3.1-40.3-5.4-13-15.5-23.2-28.5-28.6-12.9-5.4-26.7-5.2-38.9-.6l31.5 13c19.8 8.2 29.2 30.9 20.9 50.7-8.3 19.9-31 29.2-50.8 21zm173.8-129.9c-34.4 0-62.4-28-62.4-62.3s28-62.3 62.4-62.3 62.4 28 62.4 62.3-27.9 62.3-62.4 62.3zm.1-15.6c25.9 0 46.9-21 46.9-46.8 0-25.9-21-46.8-46.9-46.8s-46.9 21-46.9 46.8c.1 25.8 21.1 46.8 46.9 46.8z"/></svg>
|
||||
|
Before Width: | Height: | Size: 956 B |
@@ -1,2 +0,0 @@
|
||||
<!-- xbox — from Font Awesome Free 6 brands (CC BY 4.0), `fa-xbox`. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="currentColor"><path d="M369.9 318.2c44.3 54.3 64.7 98.8 54.4 118.7-7.9 15.1-56.7 44.6-92.6 55.9-29.6 9.3-68.4 13.3-100.4 10.2-38.2-3.7-76.9-17.4-110.1-39-27.9-18.2-34.2-25.7-34.2-40.6 0-29.9 32.9-82.3 89.2-142.1 32-33.9 76.5-73.7 81.4-72.6 9.4 2.1 84.3 75.1 112.3 109.5zM188.6 143.8c-29.7-26.9-58.1-53.9-86.4-63.4-15.2-5.1-16.3-4.8-28.7 8.1-29.2 30.4-53.5 79.7-60.3 122.4-5.4 34.2-6.1 43.8-4.2 60.5 5.6 50.5 17.3 85.4 40.5 120.9 9.5 14.6 12.1 17.3 9.3 9.9-4.2-11-.3-37.5 9.5-64 14.3-39 53.9-112.9 120.3-194.4zm311.6 63.5c-16.9-80-67.5-130.3-74.6-130.3-7.3 0-24.2 6.5-36 13.9-23.3 14.5-41 31.4-64.3 52.8 42.4 53.3 102.2 139.4 122.9 202.3 6.8 20.7 9.7 41.1 7.4 52.3-1.7 8.5-1.7 8.5 1.4 4.6 6.1-7.7 19.9-31.3 25.4-43.5 7.4-16.2 15-40.2 18.6-58.7 4.3-22.5 3.9-70.8-.8-93.4zM141.3 43c47.7-2.5 109.7 34.5 114.3 35.4 .7 .1 10.4-4.2 21.6-9.7 63.9-31.1 94-25.8 107.4-25.2-63.9-39.3-152.7-50-233.9-11.7-23.4 11.1-24 11.9-9.4 11.2z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -9,26 +9,7 @@
|
||||
# from the last image rebuild instead of a fresh -Syu per run. That is the same staleness
|
||||
# the gamescope cache already embraces ("a stale binary against newer system libs is the
|
||||
# same risk the distro's own package carries between rebuilds"), and any ci/ edit — or
|
||||
# bumping the date in this line (refreshed: 2026-08-08) — re-keys and re-snapshots it.
|
||||
#
|
||||
# ⚠ That staleness has a sharp edge, and 2026-08-08 is why the date above moved: this snapshot is
|
||||
# what decides which FFmpeg the HOST links, and arch.yml deliberately runs no -Syu, so the builder
|
||||
# stayed frozen on ffmpeg 8 (libavcodec 62) even after Arch shipped 2:9.0-5 (libavcodec 63) to
|
||||
# every user. A canary built from the old snapshot therefore CANNOT satisfy the soname dep that
|
||||
# packaging/arch/PKGBUILD now derives from the link (libavcodec.so=62-64 against a box that has
|
||||
# 63-64), so it would simply refuse to install rather than start. Re-keying this image is the step
|
||||
# that makes the ffmpeg-9 bump actually reach the package — a Cargo.toml bump alone does nothing
|
||||
# here. Whenever Arch moves to an FFmpeg major, bump the date in the same commit.
|
||||
#
|
||||
# ⚠ AND KNOW WHY THAT WAS NOT ENOUGH: bumping this date only helps once docker.yml has actually
|
||||
# republished the image, and nothing sequences the two workflows. v0.25.0 was tagged four minutes
|
||||
# after the ffmpeg-9 merge, so the release build still pulled the FFmpeg-8 `:latest` and published
|
||||
# a punktfunk-host that no up-to-date Arch box could install — which blocks the user's ENTIRE
|
||||
# `pacman -Syu`, not just our package. arch.yml therefore no longer trusts this image on that one
|
||||
# axis: it compares the builder's libav sonames against the repos before building (and `-Syu`s
|
||||
# itself if they differ), and refuses to publish anything a pristine-db `pacman -U --print` says
|
||||
# is unsatisfiable. This file staying current is still the CHEAP path — those guards are the
|
||||
# backstop, not the plan.
|
||||
# bumping the date in this line (refreshed: 2026-07-29) — re-keys and re-snapshots it.
|
||||
FROM docker.io/library/archlinux:base-devel
|
||||
|
||||
# One transaction: the main build/runtime deps (first list) + the gamescope companion's
|
||||
|
||||
@@ -45,14 +45,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# Sourced from the official FFmpeg GitHub mirror by release tag, NOT ffmpeg.org: the CI build network
|
||||
# can't reach ffmpeg.org (curl times out) but reaches github.com fine. The `nX.Y` tag pins the version
|
||||
# (n8.0 -> libavcodec 62); bump it to move FFmpeg. Immutable-tag clone, so no separate checksum needed.
|
||||
#
|
||||
# STAYING ON 8.0 THROUGH THE 2026-08-08 FFmpeg-9 BUMP IS DELIBERATE. `ffmpeg-next` moved to 9, but a
|
||||
# crate major is a CEILING (ffmpeg-sys-next 9 spans libavcodec 56..63), so an 8.0 tree still compiles
|
||||
# — and this .deb is the one package with NO exposure to the soname break that motivated the bump: it
|
||||
# BUNDLES these libs into /usr/lib/punktfunk-host behind an rpath and strips the libav* sonames from
|
||||
# its Depends, so nothing the user's apt does can move them underneath it. Bumping this tag would
|
||||
# re-qualify the encode stack for every Ubuntu user and buy none of them anything, so it is its own
|
||||
# change — and it drags NVHDR_TAG and the soname assertion below along with it.
|
||||
ARG FFMPEG_TAG=n8.0
|
||||
# nv-codec-headers must MATCH the FFmpeg version: its `master` is NVENC SDK 13, which renamed
|
||||
# NV_ENC_CLOCK_TIMESTAMP_SET.countingType -> countingTypeLSB and won't compile against FFmpeg 8.0's
|
||||
|
||||
@@ -13,9 +13,7 @@ ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# toolchain + bindgen; nodejs runs the JS actions (checkout/cache); unzip is for the bun installer
|
||||
build-essential clang libclang-dev pkg-config cmake git curl ca-certificates nodejs unzip \
|
||||
# ffmpeg-next 9, built against whatever libav* 26.04 ships (FFmpeg 8 / libavcodec 62 today).
|
||||
# The crate major is a CEILING — ffmpeg-sys-next 9 spans libavcodec 56..63 — so this image does
|
||||
# not need to move in lockstep with Arch's FFmpeg 9; it just links what the distro has.
|
||||
# ffmpeg-next 8 (system FFmpeg 8 / libavcodec 62 on 26.04)
|
||||
libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libavfilter-dev \
|
||||
libavdevice-dev \
|
||||
# capture / audio / display stacks (+xkbcommon for the wlr input backend)
|
||||
|
||||
@@ -144,10 +144,6 @@ dependencies {
|
||||
testImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest") // the ComponentActivity test host
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
// Real `org.json` for the shared-vectors test: the `org.json` inside `android.jar` is a stub
|
||||
// set whose every method throws "Stub!", so a plain JVM unit test cannot parse with it. Same
|
||||
// dependency, same reason, as the kit module's deeplink-vectors test.
|
||||
testImplementation("org.json:json:20250107")
|
||||
testImplementation("org.robolectric:robolectric:4.16.1")
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.64.0")
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.64.0")
|
||||
|
||||
@@ -49,7 +49,7 @@ MANIFEST (crate version — SPDX license — source)
|
||||
bytes 1.12.0 — MIT — https://github.com/tokio-rs/bytes
|
||||
cast 0.3.0 — MIT OR Apache-2.0 — https://github.com/japaric/cast.rs
|
||||
cbindgen 0.29.4 — MPL-2.0 — https://github.com/mozilla/cbindgen
|
||||
cc 1.4.1 — MIT OR Apache-2.0 — https://github.com/rust-lang/cc-rs
|
||||
cc 1.2.65 — MIT OR Apache-2.0 — https://github.com/rust-lang/cc-rs
|
||||
cesu8 1.1.0 — Apache-2.0/MIT — https://github.com/emk/cesu8-rs
|
||||
cfg-if 1.0.4 — MIT OR Apache-2.0 — https://github.com/rust-lang/cfg-if
|
||||
cfg_aliases 0.2.1 — MIT — https://github.com/katharostech/cfg_aliases
|
||||
@@ -88,7 +88,7 @@ MANIFEST (crate version — SPDX license — source)
|
||||
fastbloom 0.14.1 — MIT OR Apache-2.0 — https://github.com/tomtomwombat/fastbloom/
|
||||
fastrand 2.4.1 — Apache-2.0 OR MIT — https://github.com/smol-rs/fastrand
|
||||
fiat-crypto 0.2.9 — MIT OR Apache-2.0 OR BSD-1-Clause — https://github.com/mit-plv/fiat-crypto
|
||||
find-msvc-tools 0.1.10 — MIT OR Apache-2.0 — https://github.com/rust-lang/cc-rs
|
||||
find-msvc-tools 0.1.9 — MIT OR Apache-2.0 — https://github.com/rust-lang/cc-rs
|
||||
fixedbitset 0.5.7 — MIT OR Apache-2.0 — https://github.com/petgraph/fixedbitset
|
||||
flume 0.12.0 — Apache-2.0/MIT — https://github.com/zesterer/flume
|
||||
fnv 1.0.7 — Apache-2.0 / MIT — https://github.com/servo/rust-fnv
|
||||
@@ -1390,7 +1390,7 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
The following license (LICENSE-APACHE) applies to: autocfg 1.5.1, base64 0.22.1, bitflags 2.13.0, bumpalo 3.20.3, cast 0.3.0, cc 1.4.1, cfg-if 1.0.4, cmake 0.1.58, core-foundation 0.10.1, core-foundation-sys 0.8.7, criterion 0.5.1, criterion-plot 0.5.0, crossbeam-deque 0.8.6, crossbeam-epoch 0.9.20, crossbeam-utils 0.8.21, curve25519-dalek-derive 0.1.1, either 1.16.0, equivalent 1.0.2, errno 0.3.14, fastrand 2.4.1, find-msvc-tools 0.1.10, fixedbitset 0.5.7, fnv 1.0.7, hashbrown 0.17.1, heck 0.5.0, hermit-abi 0.5.2, indexmap 2.14.0, itertools 0.10.5, jni 0.21.1, jobserver 0.1.34, js-sys 0.3.103, linux-raw-sys 0.12.1, lock_api 0.4.14, log 0.4.33, num-traits 0.2.19, once_cell 1.21.4, openssl-probe 0.2.1, opus 0.3.1, parking_lot 0.12.5, parking_lot_core 0.9.12, pkg-config 0.3.33, proptest 1.11.0, rayon 1.12.0, rayon-core 1.13.0, regex 1.12.4, regex-automata 0.4.14, regex-syntax 0.8.11, rustc_version 0.4.1, rustix 1.1.4, rustls 0.23.41, rustls-native-certs 0.8.4, rusty-fork 0.3.1, scopeguard 1.2.0, security-framework 3.7.0, security-framework-sys 2.17.0, signal-hook-registry 1.4.8, smallvec 1.15.2, socket2 0.6.4, tempfile 3.27.0, tinytemplate 1.2.1, version_check 0.9.5, wait-timeout 0.2.1, wasi 0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wasm-bindgen 0.2.126, wasm-bindgen-macro 0.2.126, wasm-bindgen-macro-support 0.2.126, wasm-bindgen-shared 0.2.126, wit-bindgen 0.57.1
|
||||
The following license (LICENSE-APACHE) applies to: autocfg 1.5.1, base64 0.22.1, bitflags 2.13.0, bumpalo 3.20.3, cast 0.3.0, cc 1.2.65, cfg-if 1.0.4, cmake 0.1.58, core-foundation 0.10.1, core-foundation-sys 0.8.7, criterion 0.5.1, criterion-plot 0.5.0, crossbeam-deque 0.8.6, crossbeam-epoch 0.9.20, crossbeam-utils 0.8.21, curve25519-dalek-derive 0.1.1, either 1.16.0, equivalent 1.0.2, errno 0.3.14, fastrand 2.4.1, find-msvc-tools 0.1.9, fixedbitset 0.5.7, fnv 1.0.7, hashbrown 0.17.1, heck 0.5.0, hermit-abi 0.5.2, indexmap 2.14.0, itertools 0.10.5, jni 0.21.1, jobserver 0.1.34, js-sys 0.3.103, linux-raw-sys 0.12.1, lock_api 0.4.14, log 0.4.33, num-traits 0.2.19, once_cell 1.21.4, openssl-probe 0.2.1, opus 0.3.1, parking_lot 0.12.5, parking_lot_core 0.9.12, pkg-config 0.3.33, proptest 1.11.0, rayon 1.12.0, rayon-core 1.13.0, regex 1.12.4, regex-automata 0.4.14, regex-syntax 0.8.11, rustc_version 0.4.1, rustix 1.1.4, rustls 0.23.41, rustls-native-certs 0.8.4, rusty-fork 0.3.1, scopeguard 1.2.0, security-framework 3.7.0, security-framework-sys 2.17.0, signal-hook-registry 1.4.8, smallvec 1.15.2, socket2 0.6.4, tempfile 3.27.0, tinytemplate 1.2.1, version_check 0.9.5, wait-timeout 0.2.1, wasi 0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wasm-bindgen 0.2.126, wasm-bindgen-macro 0.2.126, wasm-bindgen-macro-support 0.2.126, wasm-bindgen-shared 0.2.126, wit-bindgen 0.57.1
|
||||
----------------------------------------------------------------------------
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
@@ -2435,7 +2435,7 @@ Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
The following license (LICENSE-MIT) applies to: cc 1.4.1, cfg-if 1.0.4, cmake 0.1.58, find-msvc-tools 0.1.10, jobserver 0.1.34, js-sys 0.3.103, openssl-probe 0.2.1, pkg-config 0.3.33, socket2 0.6.4, wait-timeout 0.2.1, wasm-bindgen 0.2.126, wasm-bindgen-macro 0.2.126, wasm-bindgen-macro-support 0.2.126, wasm-bindgen-shared 0.2.126
|
||||
The following license (LICENSE-MIT) applies to: cc 1.2.65, cfg-if 1.0.4, cmake 0.1.58, find-msvc-tools 0.1.9, jobserver 0.1.34, js-sys 0.3.103, openssl-probe 0.2.1, pkg-config 0.3.33, socket2 0.6.4, wait-timeout 0.2.1, wasm-bindgen 0.2.126, wasm-bindgen-macro 0.2.126, wasm-bindgen-macro-support 0.2.126, wasm-bindgen-shared 0.2.126
|
||||
----------------------------------------------------------------------------
|
||||
Copyright (c) 2014 Alex Crichton
|
||||
|
||||
|
||||
@@ -1,350 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import io.unom.punktfunk.models.PendingTrust
|
||||
|
||||
// The prompts that say the SAME thing in both interfaces.
|
||||
//
|
||||
// Every one of these existed twice — a Material `AlertDialog` in ConnectDialogs.kt and a console
|
||||
// glass card in GamepadDialogs.kt — with the two copies maintained by hand. Predictably they
|
||||
// drifted, and always in the direction of the console losing something: "Pair with PIN…" lost its
|
||||
// ellipsis, "if no prompt appears when you tap Allow" became "after Allow", and the speed test
|
||||
// stopped telling console users which layer Apply would write to at all.
|
||||
//
|
||||
// What is shared here is the DESCRIPTION of a prompt — a title, a list of [DialogAction]s and a
|
||||
// body — and what stays per-interface is only how that description is drawn. That split is the
|
||||
// whole point: a copy change now lands in both places because there is only one place.
|
||||
//
|
||||
// ⚠ Deliberately NOT unified, and they belong apart: the PIN ceremony (a numeric keyboard field
|
||||
// and an editable device name on touch; four D-pad digit slots on the console — different input
|
||||
// models, not different skins), Add/Edit Host (a bottom sheet and a full screen with its own
|
||||
// on-screen keyboard), and the host action list (an anchored dropdown vs a modal stack, and the
|
||||
// touch one grows a row per profile).
|
||||
|
||||
/**
|
||||
* One prompt, drawn as whichever interface is running.
|
||||
*
|
||||
* [actions] is ordered PRIMARY FIRST — the console stacks them in that order with the cursor on
|
||||
* the first, and the touch renderer lifts that same first action into `confirmButton` and lays the
|
||||
* rest out beside it. One order, two idioms, no per-dialog bookkeeping.
|
||||
*
|
||||
* The two renderers cannot be one tree: an `AlertDialog` composes into its own platform window
|
||||
* while [ConsoleModal] is a plain Box in the calling tree — which is also why the console one
|
||||
* needs a `BackHandler` and the caller's `navActive` gate while the touch one needs neither.
|
||||
*/
|
||||
@Composable
|
||||
fun PunktfunkDialog(
|
||||
gamepadUi: Boolean,
|
||||
title: String,
|
||||
onDismiss: () -> Unit,
|
||||
actions: List<DialogAction>,
|
||||
/**
|
||||
* False pins the prompt open against a stray tap outside it — for a dialog sitting over work
|
||||
* in flight, where a mis-tap would abandon it. Console-side there is no outside to tap, so
|
||||
* this only reaches the touch renderer.
|
||||
*/
|
||||
dismissOnOutsideTap: Boolean = true,
|
||||
body: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
if (gamepadUi) {
|
||||
GamepadDialog(title = title, onDismiss = onDismiss, actions = actions, body = body)
|
||||
return
|
||||
}
|
||||
val primary = actions.firstOrNull { it.primary } ?: actions.firstOrNull()
|
||||
val rest = actions.filter { it !== primary }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(dismissOnClickOutside = dismissOnOutsideTap),
|
||||
title = { Text(title) },
|
||||
text = { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { body() } },
|
||||
confirmButton = {
|
||||
primary?.let { a ->
|
||||
TextButton(onClick = a.onClick, enabled = a.enabled) { Text(a.label) }
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
if (rest.isNotEmpty()) {
|
||||
Row {
|
||||
rest.forEach { a ->
|
||||
TextButton(onClick = a.onClick, enabled = a.enabled) { Text(a.label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** A prompt's body paragraph, dimmed to sit under the title in either interface. */
|
||||
@Composable
|
||||
private fun PromptText(text: String, gamepadUi: Boolean) {
|
||||
val ink = LocalGamepadInk.current
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (gamepadUi) ink.fg(0.7f) else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
/** First connection to a host that advertised pair=optional: offer TOFU, but pitch PIN pairing. */
|
||||
@Composable
|
||||
fun TrustNewHostPrompt(
|
||||
gamepadUi: Boolean,
|
||||
pt: PendingTrust,
|
||||
onTrust: () -> Unit,
|
||||
onPairInstead: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
PunktfunkDialog(
|
||||
gamepadUi = gamepadUi,
|
||||
title = "Trust this host?",
|
||||
onDismiss = onDismiss,
|
||||
actions = listOf(
|
||||
DialogAction("Trust (TOFU)", primary = true, onClick = onTrust),
|
||||
DialogAction("Pair with PIN…", onClick = onPairInstead),
|
||||
DialogAction("Cancel", onClick = onDismiss),
|
||||
),
|
||||
) {
|
||||
PromptText("First connection to ${pt.host}:${pt.port}.", gamepadUi)
|
||||
pt.advertisedFp?.let { PromptText("Fingerprint ${it.take(16)}…", gamepadUi) }
|
||||
PromptText(
|
||||
"This host allows trust-on-first-use, but that can't tell an impostor from the real " +
|
||||
"host. Pairing with a PIN is stronger — it proves both sides.",
|
||||
gamepadUi,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The pinned fingerprint no longer matches — force re-pairing (never a silent re-trust). */
|
||||
@Composable
|
||||
fun FingerprintChangedPrompt(
|
||||
gamepadUi: Boolean,
|
||||
pt: PendingTrust,
|
||||
onRepair: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
PunktfunkDialog(
|
||||
gamepadUi = gamepadUi,
|
||||
title = "Host identity changed",
|
||||
onDismiss = onDismiss,
|
||||
actions = listOf(
|
||||
DialogAction("Re-pair", primary = true, onClick = onRepair),
|
||||
DialogAction("Cancel", onClick = onDismiss),
|
||||
),
|
||||
) {
|
||||
PromptText(
|
||||
"The pinned fingerprint for ${pt.host} no longer matches what it now advertises. " +
|
||||
"This can mean a host reinstall — or an impostor. Re-pair with the host's PIN to " +
|
||||
"continue.",
|
||||
gamepadUi,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh pair=required (or manual/unknown-policy) host: offer the two ways in. "Request access" is
|
||||
* the no-PIN path — connect and wait for the operator to click Approve in the host's console;
|
||||
* "Use a PIN…" switches to the SPAKE2 ceremony.
|
||||
*/
|
||||
@Composable
|
||||
fun RequestAccessPrompt(
|
||||
gamepadUi: Boolean,
|
||||
pt: PendingTrust,
|
||||
onRequestAccess: () -> Unit,
|
||||
onUsePin: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
PunktfunkDialog(
|
||||
gamepadUi = gamepadUi,
|
||||
title = "Pairing required",
|
||||
onDismiss = onDismiss,
|
||||
actions = listOf(
|
||||
DialogAction("Request access", primary = true, onClick = onRequestAccess),
|
||||
DialogAction("Use a PIN…", onClick = onUsePin),
|
||||
DialogAction("Cancel", onClick = onDismiss),
|
||||
),
|
||||
) {
|
||||
PromptText("${pt.host}:${pt.port} requires pairing before it will stream.", gamepadUi)
|
||||
PromptText(
|
||||
"Request access and approve this device in the host's console (or web UI) — no PIN " +
|
||||
"needed. Or pair with the 4-digit PIN the host displays.",
|
||||
gamepadUi,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The no-PIN "request access" wait: the connect is parked on the host until the operator approves
|
||||
* this device. Cancel returns the UI immediately — the caller trips the per-attempt flag so a late
|
||||
* approval is torn down silently (see ConnectScreen.requestAccess) and resumes discovery.
|
||||
*
|
||||
* Outside taps are ignored: a connect is parked on the host, and a stray tap beside the card is not
|
||||
* a decision to abandon it.
|
||||
*/
|
||||
@Composable
|
||||
fun AwaitingApprovalPrompt(gamepadUi: Boolean, hostLabel: String, onCancel: () -> Unit) {
|
||||
val ink = LocalGamepadInk.current
|
||||
PunktfunkDialog(
|
||||
gamepadUi = gamepadUi,
|
||||
title = "Waiting for approval",
|
||||
onDismiss = onCancel,
|
||||
actions = listOf(DialogAction("Cancel", primary = true, onClick = onCancel)),
|
||||
dismissOnOutsideTap = false,
|
||||
) {
|
||||
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 = if (gamepadUi) ink.fg else MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
"Approve this device on $hostLabel.",
|
||||
color = if (gamepadUi) ink.fg else MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
PromptText(
|
||||
"Open the host's console (or web UI) and approve “$deviceName”. It connects " +
|
||||
"automatically once you approve — no PIN needed.",
|
||||
gamepadUi,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
fun LocalNetworkPrompt(
|
||||
gamepadUi: Boolean,
|
||||
onAllow: () -> Unit,
|
||||
onSettings: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
PunktfunkDialog(
|
||||
gamepadUi = gamepadUi,
|
||||
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),
|
||||
),
|
||||
) {
|
||||
PromptText(
|
||||
"Android blocks Punktfunk from talking to devices on your network, so it can't find " +
|
||||
"or reach any host until you allow it.",
|
||||
gamepadUi,
|
||||
)
|
||||
PromptText(
|
||||
"If no prompt appears after you allow it, enable “Nearby devices” for Punktfunk in " +
|
||||
"system settings.",
|
||||
gamepadUi,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The link measurement and what to do with the result. A TV box on a powerline adapter is exactly
|
||||
* the machine whose link is worth measuring, so this belongs on the couch surface too — and so
|
||||
* does [speedTestTargetNote], which the console used to omit, leaving a console user to guess
|
||||
* which layer Apply would write to.
|
||||
*/
|
||||
@Composable
|
||||
fun SpeedTestPrompt(
|
||||
gamepadUi: Boolean,
|
||||
hostName: String,
|
||||
target: SpeedTestTarget,
|
||||
phase: SpeedTestPhase,
|
||||
onApply: (toProfile: Boolean) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val done = phase as? SpeedTestPhase.Done
|
||||
PunktfunkDialog(
|
||||
gamepadUi = gamepadUi,
|
||||
title = "Network speed test",
|
||||
onDismiss = onDismiss,
|
||||
// Measuring bursts traffic for two seconds; a tap outside must not abandon it midway.
|
||||
dismissOnOutsideTap = phase !is SpeedTestPhase.Measuring,
|
||||
actions = buildList {
|
||||
if (done != null) {
|
||||
add(
|
||||
DialogAction(
|
||||
when (target) {
|
||||
SpeedTestTarget.Global -> "Apply"
|
||||
is SpeedTestTarget.Profile -> "Apply to “${target.profile.name}”"
|
||||
is SpeedTestTarget.Ask -> "Set in “${target.profile.name}”"
|
||||
},
|
||||
primary = true,
|
||||
) { onApply(true) },
|
||||
)
|
||||
if (target is SpeedTestTarget.Ask) {
|
||||
add(DialogAction("Set as default") { onApply(false) })
|
||||
}
|
||||
}
|
||||
add(DialogAction("Close", primary = done == null, onClick = onDismiss))
|
||||
},
|
||||
) {
|
||||
PromptText(hostName, gamepadUi)
|
||||
when (phase) {
|
||||
SpeedTestPhase.Connecting -> PromptText("Connecting…", gamepadUi)
|
||||
SpeedTestPhase.Measuring ->
|
||||
PromptText(
|
||||
"Measuring — the host is bursting test traffic for two seconds.",
|
||||
gamepadUi,
|
||||
)
|
||||
is SpeedTestPhase.Failed -> Text(
|
||||
phase.message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (gamepadUi) ink.danger else MaterialTheme.colorScheme.error,
|
||||
)
|
||||
is SpeedTestPhase.Done -> {
|
||||
Text(
|
||||
"%.0f Mbit/s measured · %.1f %% loss".format(phase.measuredMbps, phase.lossPct),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = if (gamepadUi) ink.fg else MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
PromptText(
|
||||
"Recommended bitrate: %.0f Mbit/s".format(phase.recommendedMbps),
|
||||
gamepadUi,
|
||||
)
|
||||
PromptText(speedTestTargetNote(target), gamepadUi)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One line saying which layer an Apply will write to, and why that one. */
|
||||
private fun speedTestTargetNote(target: SpeedTestTarget): String = when (target) {
|
||||
SpeedTestTarget.Global ->
|
||||
"This host uses the default settings, so the bitrate goes there."
|
||||
is SpeedTestTarget.Profile ->
|
||||
"This host streams with “${target.profile.name}”, which sets its own bitrate — " +
|
||||
"that override is what it actually reads."
|
||||
is SpeedTestTarget.Ask ->
|
||||
"This host streams with “${target.profile.name}”, which currently inherits the default " +
|
||||
"bitrate. Setting it in the profile affects only this host's profile; setting it as " +
|
||||
"the default affects everything that inherits it."
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
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
|
||||
@@ -11,9 +14,6 @@ import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -21,17 +21,14 @@ 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.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
@@ -40,10 +37,8 @@ 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.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
@@ -56,10 +51,7 @@ import io.unom.punktfunk.kit.link.HostResolution
|
||||
import io.unom.punktfunk.kit.SessionEndReason
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
import io.unom.punktfunk.models.ActiveSession
|
||||
import io.unom.punktfunk.models.LibraryReturn
|
||||
import io.unom.punktfunk.models.Tab
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun App(forceGamepadUi: Boolean = false) {
|
||||
@@ -75,16 +67,13 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
// whose library the console shell should come back to. Held HERE because the shell's own
|
||||
// navigation state does not outlive the stream. Cleared once the shell has consumed it, so a
|
||||
// later manual Back out of the library is not undone by a stale value.
|
||||
var reopenLibrary by remember { mutableStateOf<LibraryReturn?>(null) }
|
||||
var reopenLibraryHostId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Console (gamepad) mode mirrors the Apple client: the setting AND (its mode says Always OR a
|
||||
// pad is attached OR this is a TV OR the dev force flag). Flips live as controllers
|
||||
// connect/disconnect — unless the mode is Always, where it simply stays.
|
||||
// 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, settings.gamepadUiMode, controllerConnected, tv, forceGamepadUi,
|
||||
)
|
||||
val gamepadUi = gamepadUiActive(settings.gamepadUiEnabled, controllerConnected, tv, forceGamepadUi)
|
||||
|
||||
// Publish the live session process-wide, so a `punktfunk://` link that arrives as a SECOND
|
||||
// activity instance (the normal case under `launchMode = standard`) can refuse it before that
|
||||
@@ -140,9 +129,9 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
// than all the way out to host selection. The console shell's own screen state does
|
||||
// not survive the stream (StreamScreen replaces it in the composition, discarding
|
||||
// its `remember`s), so the intent is hoisted here and handed back on the way in.
|
||||
reopenLibrary =
|
||||
reopenLibraryHostId =
|
||||
if (reason == SessionEndReason.GAME_EXITED && active.launchedFromLibrary) {
|
||||
active.hostId?.let { LibraryReturn(it, active.libraryProfileId) }
|
||||
active.hostId
|
||||
} else {
|
||||
null
|
||||
}
|
||||
@@ -155,8 +144,8 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
onConnected = { session = it },
|
||||
deepLink = pendingLink,
|
||||
onDeepLinkHandled = { activity?.pendingDeepLink = null },
|
||||
reopenLibrary = reopenLibrary,
|
||||
onReopenLibraryHandled = { reopenLibrary = null },
|
||||
reopenLibraryHostId = reopenLibraryHostId,
|
||||
onReopenLibraryHandled = { reopenLibraryHostId = null },
|
||||
)
|
||||
} else {
|
||||
// Adaptive nav: a bottom bar on phones; on tablets / large windows a side NavigationRail
|
||||
@@ -219,9 +208,8 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
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). Cutout included:
|
||||
// a tablet in landscape puts its punch on exactly this pane's leading edge.
|
||||
Box(Modifier.weight(1f).fillMaxHeight().consoleSafeArea()) { tabContent(true) }
|
||||
// don't, since they used to rely on the Scaffold's padding).
|
||||
Box(Modifier.weight(1f).fillMaxHeight().systemBarsPadding()) { tabContent(true) }
|
||||
}
|
||||
} else {
|
||||
Scaffold(
|
||||
@@ -254,21 +242,8 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
*/
|
||||
val LocalGamepadPalette = compositionLocalOf { GamepadPalette.named("violet") }
|
||||
|
||||
/**
|
||||
* Which console screen the gamepad shell is showing, and how deep it sits — Home is the root, and
|
||||
* everything reachable from it is one level in. The DEPTH is what decides whether a change is a
|
||||
* push or a pop, and therefore which way the screens travel.
|
||||
*/
|
||||
private enum class GamepadScreen(val depth: Int) {
|
||||
Home(0),
|
||||
Settings(1),
|
||||
Library(1),
|
||||
// Reached FROM Settings, not from Home, so they sit a level deeper again — which is precisely
|
||||
// what makes Settings → Controllers travel like a push and the way back like a pop. Give one of
|
||||
// these depth 1 and the transition would read as a sideways swap between two peers.
|
||||
Controllers(2),
|
||||
Licenses(2),
|
||||
}
|
||||
/** 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:
|
||||
@@ -283,44 +258,28 @@ fun GamepadShell(
|
||||
deepLink: String? = null,
|
||||
onDeepLinkHandled: () -> Unit = {},
|
||||
/**
|
||||
* Open this library shelf instead of Home on the way in — set when a game launched from it has
|
||||
* just exited. Null (the default) starts on Home exactly as before.
|
||||
* Open this saved host's library instead of Home on the way in — set when a game launched from
|
||||
* it has just exited. Null (the default) starts on Home exactly as before.
|
||||
*/
|
||||
reopenLibrary: LibraryReturn? = null,
|
||||
reopenLibraryHostId: String? = null,
|
||||
onReopenLibraryHandled: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var screen by remember { mutableStateOf(GamepadScreen.Home) }
|
||||
var libraryHost by remember { mutableStateOf<io.unom.punktfunk.kit.security.KnownHost?>(null) }
|
||||
// Which of that host's shelves is open: the pinned card's profile id, or null for the host's
|
||||
// own tile (design §5.2a). Held beside `libraryHost` because it is the same navigation fact —
|
||||
// a pinned card and its host are two tiles, and the library belongs to whichever you pressed.
|
||||
var libraryPinId by remember { mutableStateOf<String?>(null) }
|
||||
// Where the settings screen was when a sub-screen took over. The shell's AnimatedContent
|
||||
// discards a screen's `remember`s the moment it stops being the target, so a trip out to the
|
||||
// Controllers view and back would otherwise land on the Stream tab's first row — the couch
|
||||
// equivalent of a browser losing your scroll position on Back. Held here because this is the
|
||||
// only thing that outlives the screen.
|
||||
var settingsPlace by remember { mutableStateOf<GpSettingsPlace?>(null) }
|
||||
|
||||
// Consume the "come back to this library" intent once, on entry. Keyed on the id so a second
|
||||
// game exit re-fires it; the parent clears it immediately, so a manual Back stays backed out.
|
||||
// A host that has since been forgotten simply leaves us on Home rather than failing.
|
||||
LaunchedEffect(reopenLibrary) {
|
||||
val (id, pinId) = reopenLibrary ?: return@LaunchedEffect
|
||||
LaunchedEffect(reopenLibraryHostId) {
|
||||
val id = reopenLibraryHostId ?: return@LaunchedEffect
|
||||
// Navigate BEFORE acknowledging: acknowledging clears the parent's state, which re-keys
|
||||
// this effect and cancels the coroutine running it. Nothing suspends in between today, so
|
||||
// either order happens to work — but this one cannot be broken by a later edit that adds a
|
||||
// suspending call. A host that has since been forgotten just leaves us on Home.
|
||||
KnownHostStore(context).all()
|
||||
.firstOrNull { it.id == id }
|
||||
// A pin unpinned while the game was running is no longer a shelf: fall back to the
|
||||
// host's own, rather than a card that no longer exists.
|
||||
?.let { kh ->
|
||||
libraryHost = kh
|
||||
libraryPinId = pinId?.takeIf { it in kh.pinnedProfileIds }
|
||||
screen = GamepadScreen.Library
|
||||
}
|
||||
?.let { libraryHost = it; screen = GamepadScreen.Library }
|
||||
onReopenLibraryHandled()
|
||||
}
|
||||
|
||||
@@ -335,50 +294,12 @@ fun GamepadShell(
|
||||
val fitDensity = screenWidthPx / CONSOLE_TV_MIN_WIDTH_DP
|
||||
val consoleDensity = if (isTv && fitDensity < baseDensity.density) fitDensity else baseDensity.density
|
||||
|
||||
// The console's screen transition, and the desktop console's contract rather than a plain
|
||||
// cross-fade (see ConsoleMotion for the numbers and where they come from): a PUSH slides the
|
||||
// incoming screen up out of a fade while the outgoing one recedes; a POP runs it backwards, the
|
||||
// leaving screen sliding down and the revealed one growing back. Direction comes from the
|
||||
// screens' nav DEPTH, so Settings → Home pops even though nothing tracks a stack.
|
||||
//
|
||||
// Each slot's controller nav is gated on being the CURRENT target (`s == screen`), so mid-
|
||||
// transition only the incoming screen drives the pad. All screens pin their legend at the same
|
||||
// ConsoleLegendInset, so it reads as fixed while the content behind it moves.
|
||||
val animated = animationsEnabled()
|
||||
CompositionLocalProvider(LocalDensity provides Density(consoleDensity, baseDensity.fontScale)) {
|
||||
// Measured INSIDE the console's own density, not the device's: on a TV the console UI runs at a
|
||||
// reduced density to shrink the 10-foot layout, and a slide sized in device pixels would travel
|
||||
// further than every other dp in the same animation.
|
||||
val slidePx = with(LocalDensity.current) { ConsoleMotion.PUSH_SLIDE.toPx() }.roundToInt()
|
||||
AnimatedContent(
|
||||
targetState = screen,
|
||||
transitionSpec = {
|
||||
if (!animated) {
|
||||
// Reduce-motion: no travel, no scale — just a fast cross-fade, the same courtesy
|
||||
// the frozen backdrop pays.
|
||||
fadeIn(tween(ConsoleMotion.REDUCED_MS)) togetherWith
|
||||
fadeOut(tween(ConsoleMotion.REDUCED_MS))
|
||||
} else if (targetState.depth > initialState.depth) {
|
||||
(
|
||||
fadeIn(ConsoleMotion.ease()) +
|
||||
slideInVertically(ConsoleMotion.ease()) { slidePx } +
|
||||
scaleIn(ConsoleMotion.ease(), initialScale = ConsoleMotion.ENTER_SCALE)
|
||||
) togetherWith (
|
||||
fadeOut(ConsoleMotion.ease()) +
|
||||
scaleOut(ConsoleMotion.ease(), targetScale = ConsoleMotion.EXIT_SCALE)
|
||||
)
|
||||
} else {
|
||||
(
|
||||
fadeIn(ConsoleMotion.ease(), initialAlpha = ConsoleMotion.REVEAL_ALPHA) +
|
||||
scaleIn(ConsoleMotion.ease(), initialScale = ConsoleMotion.EXIT_SCALE)
|
||||
) togetherWith (
|
||||
fadeOut(ConsoleMotion.ease()) +
|
||||
slideOutVertically(ConsoleMotion.ease()) { slidePx }
|
||||
)
|
||||
}
|
||||
},
|
||||
label = "consoleScreen",
|
||||
) { s ->
|
||||
// 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,
|
||||
@@ -388,33 +309,13 @@ fun GamepadShell(
|
||||
onDeepLinkHandled = onDeepLinkHandled,
|
||||
gamepadUi = true,
|
||||
onOpenSettings = { screen = GamepadScreen.Settings },
|
||||
onOpenLibrary = { host, pinId ->
|
||||
libraryHost = host
|
||||
libraryPinId = pinId
|
||||
screen = GamepadScreen.Library
|
||||
},
|
||||
onOpenLibrary = { host -> libraryHost = host; screen = GamepadScreen.Library },
|
||||
navGate = s == screen,
|
||||
)
|
||||
GamepadScreen.Settings -> GamepadSettingsScreen(
|
||||
initial = settings,
|
||||
onChange = onSettingsChange,
|
||||
// Leaving for HOME forgets the place: coming back in from the carousel should start
|
||||
// at the top of the first section, exactly as it always has. Only a sub-screen's
|
||||
// Back is a return.
|
||||
onBack = { screen = GamepadScreen.Home; settingsPlace = null },
|
||||
navActive = s == screen,
|
||||
resume = settingsPlace,
|
||||
onPlace = { settingsPlace = it },
|
||||
onOpenControllers = { screen = GamepadScreen.Controllers },
|
||||
onOpenLicenses = { screen = GamepadScreen.Licenses },
|
||||
)
|
||||
GamepadScreen.Controllers -> ConsoleControllersScreen(
|
||||
gamepadSetting = settings.gamepad,
|
||||
onBack = { screen = GamepadScreen.Settings },
|
||||
navActive = s == screen,
|
||||
)
|
||||
GamepadScreen.Licenses -> ConsoleLicensesScreen(
|
||||
onBack = { screen = GamepadScreen.Settings },
|
||||
onBack = { screen = GamepadScreen.Home },
|
||||
navActive = s == screen,
|
||||
)
|
||||
GamepadScreen.Library -> libraryHost?.let { host ->
|
||||
@@ -422,9 +323,8 @@ fun GamepadShell(
|
||||
host = host,
|
||||
settings = settings,
|
||||
onLaunched = onConnected,
|
||||
onBack = { screen = GamepadScreen.Home; libraryHost = null; libraryPinId = null },
|
||||
onBack = { screen = GamepadScreen.Home; libraryHost = null },
|
||||
navActive = s == screen,
|
||||
pinnedProfileId = libraryPinId,
|
||||
)
|
||||
} ?: run { screen = GamepadScreen.Home }
|
||||
}
|
||||
@@ -434,128 +334,3 @@ fun GamepadShell(
|
||||
|
||||
/** Minimum effective dp width the console UI targets on a TV (bigger → the 10-foot UI shrinks). */
|
||||
private const val CONSOLE_TV_MIN_WIDTH_DP = 1180f
|
||||
|
||||
// --- Showing a TOUCH-written screen on the console's field -------------------------------------
|
||||
//
|
||||
// Two screens (Controllers, Licenses) exist once and are shown in both interfaces. They live beside
|
||||
// the shell rather than in `GamepadChrome.kt` because they are about the SHELL's job — putting a
|
||||
// screen that was written for one interface onto the other's field — rather than about the console's
|
||||
// own material.
|
||||
|
||||
/**
|
||||
* Re-inks a screen written against the TOUCH theme so it can be shown on the console's field.
|
||||
*
|
||||
* `ControllersScreen` alone pulls `MaterialTheme.colorScheme` at 27 explicit sites, plus implicitly
|
||||
* through every `OutlinedCard`, `Switch`, `OutlinedButton` and `LinearProgressIndicator` it draws.
|
||||
* Dropped into the shell those keep the touch palette — light-grey body text with no background of
|
||||
* its own, which over the six PALE console palettes (`GamepadPalette`, `light = true`) is grey on
|
||||
* pastel: technically painted, in practice unreadable. That is the same class of bug as the console
|
||||
* dialogs that spent a release rendering dark ink on a dark card.
|
||||
*
|
||||
* The fix is deliberately ONE derived colour scheme rather than 27 call-site branches:
|
||||
* * a call-site branch cannot reach the IMPLICIT pulls at all — a `Switch`'s track and an
|
||||
* `OutlinedCard`'s border are resolved inside Material, not here;
|
||||
* * two colours per site is exactly the shape that drifts, and it would leave the touch screen
|
||||
* carrying console vocabulary it has no use for.
|
||||
*
|
||||
* The alternative — give the console presentation an opaque backdrop and let the touch theme read on
|
||||
* its own ground — was rejected because it splits the screen's material in two: an opaque touch-grey
|
||||
* slab under a palette-inked header and legend, with a visible seam between them, on a field whose
|
||||
* whole point is that one look runs through it.
|
||||
*
|
||||
* The base scheme follows the field's lightness, so anything not overridden here (a container role
|
||||
* some Material component reaches for) still lands on the right side of the contrast line.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConsoleInkedTheme(content: @Composable () -> Unit) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val scheme = remember(ink) {
|
||||
val base = if (ink.isLight) lightColorScheme() else darkColorScheme()
|
||||
base.copy(
|
||||
primary = ink.accent,
|
||||
onPrimary = ink.onAccent,
|
||||
// A card becomes a PANE over the aurora rather than a slab on top of it: the console's
|
||||
// own glass fill, so an OutlinedCard here is cut from the material the settings rows are.
|
||||
surface = ink.glass,
|
||||
onSurface = ink.fg,
|
||||
surfaceVariant = ink.fg(0.12f),
|
||||
onSurfaceVariant = ink.fg(0.68f),
|
||||
outline = ink.fg(0.30f),
|
||||
outlineVariant = ink.fg(0.16f),
|
||||
// Nothing here paints a background — the aurora is the ground — but a component that
|
||||
// resolves `background` (or the content colour for it) must still land on the palette.
|
||||
background = Color.Transparent,
|
||||
onBackground = ink.fg,
|
||||
)
|
||||
}
|
||||
// The typography and shapes are the app's, not Material's defaults: this swaps the INK, not the
|
||||
// brand typeface. And `LocalContentColor` has to be provided by hand — outside a Surface or a
|
||||
// Scaffold it defaults to BLACK, which is how an unstyled `Text` would vanish into a dark field.
|
||||
MaterialTheme(
|
||||
colorScheme = scheme,
|
||||
typography = MaterialTheme.typography,
|
||||
shapes = MaterialTheme.shapes,
|
||||
) {
|
||||
CompositionLocalProvider(LocalContentColor provides ink.fg, content = content)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The console's scroll route for a screen that is a WALL of content rather than a list of focusable
|
||||
* rows.
|
||||
*
|
||||
* Compose only scrolls a container to keep a FOCUSED child visible, so a screen whose body holds no
|
||||
* focusable nodes (the licenses notices are one enormous `Text`) simply cannot be scrolled by a
|
||||
* controller: the D-pad has nothing to move to. These screens therefore drive the scroll state
|
||||
* directly — up/down steps, the shoulders page.
|
||||
*
|
||||
* Returned as a plain function so a screen's nav callbacks read `scroll(-1, page = false)` rather
|
||||
* than each screen minting its own coroutine + viewport arithmetic (which is how the two would end
|
||||
* up scrolling at different speeds).
|
||||
*/
|
||||
@Composable
|
||||
internal fun rememberConsoleScroller(scroll: ScrollState): (dir: Int, page: Boolean) -> Unit {
|
||||
val scope = rememberCoroutineScope()
|
||||
val animated = animationsEnabled()
|
||||
return remember(scroll, animated) {
|
||||
{ dir, page ->
|
||||
val delta = consoleScrollDelta(scroll.viewportSize.toFloat(), page, dir)
|
||||
if (delta != 0f) {
|
||||
scope.launch {
|
||||
// Auto-repeat fires every 150 ms while a direction is held, so each animation is
|
||||
// short enough to have landed (or nearly) before the next one cancels it —
|
||||
// otherwise a held D-pad crawls, each step restarting from where the last was
|
||||
// interrupted.
|
||||
if (animated) {
|
||||
scroll.animateScrollBy(
|
||||
delta,
|
||||
ConsoleMotion.ease(
|
||||
if (page) ConsoleMotion.TRANSITION_MS else ConsoleMotion.FOCUS_MS,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
scroll.scrollBy(delta)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How far one console scroll press travels: [dir] is -1 (up/left) or +1 (down/right), [page] picks
|
||||
* the shoulders' full page over a D-pad step. Zero while the viewport is unmeasured — a first press
|
||||
* that arrived before layout must do nothing rather than fling the content by zero-times-nothing.
|
||||
*/
|
||||
internal fun consoleScrollDelta(viewportPx: Float, page: Boolean, dir: Int): Float =
|
||||
if (viewportPx <= 0f) 0f else viewportPx * (if (page) CONSOLE_PAGE else CONSOLE_STEP) * dir
|
||||
|
||||
/**
|
||||
* A page keeps a band of what you were reading on screen rather than jumping a clean screenful — the
|
||||
* overlap every reader has used since the printed page, and the difference between "I moved down"
|
||||
* and "where was I".
|
||||
*/
|
||||
private const val CONSOLE_PAGE = 0.88f
|
||||
|
||||
/** A D-pad step is about a quarter screen, so holding the direction walks the wall rather than flicking it. */
|
||||
private const val CONSOLE_STEP = 0.28f
|
||||
|
||||
@@ -123,6 +123,130 @@ internal fun AddHostSheet(
|
||||
}
|
||||
}
|
||||
|
||||
/** First connection to a host that advertised pair=optional: offer TOFU, but pitch PIN pairing. */
|
||||
@Composable
|
||||
internal fun TrustNewHostDialog(
|
||||
pt: PendingTrust,
|
||||
onTrust: () -> Unit,
|
||||
onPairInstead: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Trust this host?") },
|
||||
text = {
|
||||
Column {
|
||||
Text("First connection to ${pt.host}:${pt.port}.")
|
||||
pt.advertisedFp?.let { Text("Fingerprint ${it.take(16)}…") }
|
||||
Text(
|
||||
"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.",
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onTrust) { Text("Trust (TOFU)") }
|
||||
},
|
||||
dismissButton = {
|
||||
Row {
|
||||
TextButton(onClick = onPairInstead) { Text("Pair with PIN…") }
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
pt: PendingTrust,
|
||||
onRepair: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Host identity changed") },
|
||||
text = {
|
||||
Text(
|
||||
"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.",
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onRepair) { Text("Re-pair") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh pair=required (or manual/unknown-policy) host: offer the two ways in. "Request access" is
|
||||
* the no-PIN path — connect and wait for the operator to click Approve in the host's console;
|
||||
* "Use a PIN…" switches to the SPAKE2 ceremony.
|
||||
*/
|
||||
@Composable
|
||||
internal fun RequestAccessDialog(
|
||||
pt: PendingTrust,
|
||||
onRequestAccess: () -> Unit,
|
||||
onUsePin: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Pairing required") },
|
||||
text = {
|
||||
Column {
|
||||
Text("${pt.host}:${pt.port} requires pairing before it will stream.")
|
||||
Text(
|
||||
"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.",
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onRequestAccess) { Text("Request access") }
|
||||
},
|
||||
dismissButton = {
|
||||
Row {
|
||||
TextButton(onClick = onUsePin) { Text("Use a PIN…") }
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The SPAKE2 PIN ceremony dialog. Runs [NativeBridge.nativePair] off the UI thread itself (the
|
||||
* pin/name/error state is dialog-local); on success hands the host's verified fingerprint to
|
||||
@@ -194,6 +318,41 @@ internal fun PairPinDialog(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The no-PIN "request access" wait: the connect is parked on the host until the operator approves
|
||||
* this device. Cancel returns the UI immediately — the caller trips the per-attempt flag so a late
|
||||
* approval is torn down silently (see ConnectScreen.requestAccess) and resumes discovery.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onCancel,
|
||||
title = { Text("Waiting for approval") },
|
||||
text = {
|
||||
val deviceName = Build.MODEL ?: "this device"
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
Text("Approve this device on $hostLabel.")
|
||||
}
|
||||
Text(
|
||||
"Open the host's console (or web UI) and approve “$deviceName”. It connects " +
|
||||
"automatically once you approve — no PIN needed.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onCancel) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit a saved host: name, address, port, the Wake-on-LAN MAC, and the per-host settings the record
|
||||
* owns — shared clipboard (a trust decision about THIS machine, so it was never really a global).
|
||||
@@ -308,3 +467,103 @@ internal fun EditHostDialog(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The network speed test, as a dialog: it narrates while it measures, then offers to apply the
|
||||
* recommendation to the layer the tested host actually reads bitrate from — see [SpeedTestTarget]
|
||||
* for why that is the interesting part. The apply buttons name their destination, so the write is
|
||||
* never a surprise.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SpeedTestDialog(
|
||||
hostName: String,
|
||||
target: SpeedTestTarget,
|
||||
phase: SpeedTestPhase,
|
||||
onApply: (toProfile: Boolean) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val done = phase as? SpeedTestPhase.Done
|
||||
AlertDialog(
|
||||
// Measuring can't be cancelled mid-burst (the host is already sending), so a stray tap
|
||||
// outside shouldn't look like it did something.
|
||||
onDismissRequest = { if (done != null || phase is SpeedTestPhase.Failed) onDismiss() },
|
||||
title = { Text("Network speed test") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(hostName, style = MaterialTheme.typography.titleMedium)
|
||||
when (phase) {
|
||||
SpeedTestPhase.Connecting, SpeedTestPhase.Measuring -> Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
Text(
|
||||
if (phase == SpeedTestPhase.Connecting) {
|
||||
"Connecting…"
|
||||
} else {
|
||||
"Measuring — the host is bursting test traffic for two seconds."
|
||||
},
|
||||
)
|
||||
}
|
||||
is SpeedTestPhase.Failed -> Text(
|
||||
phase.message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
is SpeedTestPhase.Done -> Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
"%.0f Mbit/s measured · %.1f %% loss".format(
|
||||
phase.measuredMbps,
|
||||
phase.lossPct,
|
||||
),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
"Recommended bitrate: %.0f Mbit/s".format(phase.recommendedMbps),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
speedTestTargetNote(target),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
if (done != null) {
|
||||
TextButton(onClick = { onApply(true) }) {
|
||||
Text(
|
||||
when (target) {
|
||||
SpeedTestTarget.Global -> "Apply"
|
||||
is SpeedTestTarget.Profile -> "Apply to “${target.profile.name}”"
|
||||
is SpeedTestTarget.Ask -> "Set in “${target.profile.name}”"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
Row {
|
||||
// The both-are-defensible case: the user picks the layer, we don't guess.
|
||||
if (done != null && target is SpeedTestTarget.Ask) {
|
||||
TextButton(onClick = { onApply(false) }) { Text("Set as default") }
|
||||
}
|
||||
TextButton(onClick = onDismiss) { Text("Close") }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** One line saying which layer an Apply will write to, and why that one. */
|
||||
private fun speedTestTargetNote(target: SpeedTestTarget): String = when (target) {
|
||||
SpeedTestTarget.Global ->
|
||||
"This host uses the default settings, so the bitrate goes there."
|
||||
is SpeedTestTarget.Profile ->
|
||||
"This host streams with “${target.profile.name}”, which sets its own bitrate — " +
|
||||
"that override is what it actually reads."
|
||||
is SpeedTestTarget.Ask ->
|
||||
"This host streams with “${target.profile.name}”, which currently inherits the default " +
|
||||
"bitrate. Setting it in the profile affects only this host's profile; setting it as " +
|
||||
"the default affects everything that inherits it."
|
||||
}
|
||||
|
||||
@@ -1,339 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
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.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.unom.punktfunk.components.EmptyHostsState
|
||||
import io.unom.punktfunk.components.HostCard
|
||||
import io.unom.punktfunk.components.HostMenuItem
|
||||
import io.unom.punktfunk.components.SectionLabel
|
||||
import io.unom.punktfunk.kit.discovery.DiscoveredHost
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.models.HostStatus
|
||||
|
||||
/**
|
||||
* The touch home: the saved/discovered host grid with the Add-host FAB over it — everything
|
||||
* `ConnectScreen` draws when the console UI is off, and the counterpart of [buildHomeTiles] +
|
||||
* `GamepadHome` when it is on.
|
||||
*
|
||||
* Pure display: every action arrives as a callback, because they all end in state the screen owns
|
||||
* (a dial in flight, the trust prompt, the host store). What this file DOES own is the arrangement
|
||||
* — which sections exist, in what order, and which actions a given card offers — and the two rules
|
||||
* that are easy to get wrong from the outside: a pinned card is a shortcut and so withholds the
|
||||
* host's destructive actions, and every card in a section reserves the profile chip's space as soon
|
||||
* as one of them needs it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConnectGrid(
|
||||
savedHosts: List<KnownHost>,
|
||||
/** Every live advert — the OS mark prefers it over the stored one, and "searching…" reads it. */
|
||||
discovered: List<DiscoveredHost>,
|
||||
/** Adverts with no saved record behind them, de-duped by the caller (it needs them too). */
|
||||
discoveredUnsaved: List<DiscoveredHost>,
|
||||
/** Saved hosts answering the QUIC probe, "address:port" — the routed half of "online". */
|
||||
reachable: Set<String>,
|
||||
profiles: List<StreamProfile>,
|
||||
pinsFor: (KnownHost) -> List<StreamProfile>,
|
||||
connecting: Boolean,
|
||||
/** A confirmation ("75 Mbit/s set in …"); [status] is the failure line. Never the same thing. */
|
||||
notice: String?,
|
||||
status: String?,
|
||||
lnpGranted: Boolean,
|
||||
/** Raise the local-network-permission prompt — the banner's "Allow…" and the wake guard. */
|
||||
onAskLocalNetwork: () -> Unit,
|
||||
/**
|
||||
* Dial a saved host. The second argument is `connect`'s one-off profile reference: null follows
|
||||
* the host's binding (a plain tap), a profile id forces that profile, and the empty string
|
||||
* forces the global defaults — a real, different action on a bound host, which is why it has to
|
||||
* survive as a value rather than collapsing into "unset".
|
||||
*/
|
||||
onConnect: (KnownHost, String?) -> Unit,
|
||||
onConnectDiscovered: (DiscoveredHost) -> Unit,
|
||||
onForget: (KnownHost) -> Unit,
|
||||
onEdit: (KnownHost) -> Unit,
|
||||
onWake: (KnownHost) -> Unit,
|
||||
onSpeedTest: (KnownHost) -> Unit,
|
||||
onCopyLink: (KnownHost, StreamProfile?) -> Unit,
|
||||
onTogglePin: (KnownHost, StreamProfile) -> Unit,
|
||||
onRescan: () -> Unit,
|
||||
onAddHost: () -> Unit,
|
||||
) {
|
||||
// The profile rows a card's overflow menu grows. With no profiles at all it stays empty — a
|
||||
// user who never wants this feature sees no new clutter anywhere but the settings scope chips.
|
||||
// "Connect with" is a ONE-OFF on every card: it never rebinds the host, which is why rebinding
|
||||
// lives in the Edit sheet instead.
|
||||
fun hostMenu(kh: KnownHost, pin: StreamProfile?): List<HostMenuItem> = buildList {
|
||||
if (pin == null) {
|
||||
add(HostMenuItem("Network speed test") { onSpeedTest(kh) })
|
||||
}
|
||||
add(HostMenuItem("Copy link") { onCopyLink(kh, pin) })
|
||||
if (profiles.isEmpty()) return@buildList
|
||||
if (pin != null) {
|
||||
add(HostMenuItem("Unpin card", startsSection = true) { onTogglePin(kh, pin) })
|
||||
}
|
||||
add(
|
||||
HostMenuItem("Connect with: Default settings", startsSection = true) {
|
||||
// The empty reference is "force the defaults", not "unset" — on a bound host that
|
||||
// is a real, different action from a plain tap.
|
||||
onConnect(kh, "")
|
||||
},
|
||||
)
|
||||
profiles.forEach { p ->
|
||||
add(HostMenuItem("Connect with: ${p.name}") { onConnect(kh, p.id) })
|
||||
}
|
||||
if (pin == null) {
|
||||
profiles.forEachIndexed { i, p ->
|
||||
val pinned = p.id in kh.pinnedProfileIds
|
||||
add(
|
||||
HostMenuItem(
|
||||
if (pinned) "Unpin card: ${p.name}" else "Pin as card: ${p.name}",
|
||||
startsSection = i == 0,
|
||||
) { onTogglePin(kh, p) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The saved-hosts grid: each host's own card, then one card per profile it has pinned, so a
|
||||
// pinned combination is a plain one-click connect instead of a trip through a menu.
|
||||
val savedCards = savedHosts.flatMap { kh ->
|
||||
listOf(HostCardEntry(kh, null)) + pinsFor(kh).map { HostCardEntry(kh, it) }
|
||||
}
|
||||
// Cards in one grid row must be the same height (the grid won't stretch them), so as soon as
|
||||
// ANY saved card carries a profile chip, they all reserve its space. Nobody who doesn't use
|
||||
// profiles ever sees the gap.
|
||||
val anyProfileChip = savedCards.any { it.pin != null || it.host.profileId != null }
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 160.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Punktfunk", style = MaterialTheme.typography.headlineLarge)
|
||||
Text(
|
||||
"stream a remote desktop",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
notice?.let {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
status?.let {
|
||||
// In-flight progress (connecting / waking) is the full-screen ConnectOverlay's
|
||||
// job now, so `status` only ever carries a result/error here — a filled error
|
||||
// container reads as a real failure banner, not just red text lost in the layout.
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 = onAskLocalNetwork) { Text("Allow…") }
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (savedHosts.isEmpty() && discoveredUnsaved.isEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
EmptyHostsState()
|
||||
}
|
||||
}
|
||||
|
||||
if (savedHosts.isNotEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
SectionLabel("Saved hosts")
|
||||
}
|
||||
items(savedCards, key = { it.key }) { entry ->
|
||||
val kh = entry.host
|
||||
val pin = entry.pin
|
||||
val bound = kh.profileId?.let { id -> profiles.firstOrNull { it.id == id } }
|
||||
HostCard(
|
||||
name = kh.name,
|
||||
address = "${kh.address}:${kh.port}",
|
||||
status = if (kh.paired) HostStatus.PAIRED else HostStatus.TOFU,
|
||||
online = kh.isOnline(discovered, reachable),
|
||||
// Live advert preferred (the store lags a discovery tick), else stored.
|
||||
os = discovered.firstOrNull { kh.matches(it) && it.os.isNotEmpty() }?.os
|
||||
?: kh.os,
|
||||
enabled = !connecting,
|
||||
// A pinned card connects with ITS profile; the host's own card follows the
|
||||
// binding, which is exactly what its chip says it will do.
|
||||
onConnect = { onConnect(kh, pin?.id) },
|
||||
// Edit / Forget / Wake live on the host's own card only: a pinned card is a
|
||||
// shortcut, not a second host, and offering destructive host actions on it
|
||||
// would blur exactly that.
|
||||
onForget = if (pin != null) null else ({ onForget(kh) }),
|
||||
onEdit = if (pin != null) null else ({ onEdit(kh) }),
|
||||
// Explicit wake-only: offered when the host is offline and we have a MAC. The
|
||||
// screen runs it 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 (pin == null && kh.mac.isNotEmpty() && !kh.isOnline(discovered, reachable)) {
|
||||
({ onWake(kh) })
|
||||
} else {
|
||||
null
|
||||
},
|
||||
profileLabel = pin?.name ?: bound?.name,
|
||||
profileProminent = pin != null,
|
||||
accent = accentColor(pin?.accent ?: bound?.accent),
|
||||
menuItems = hostMenu(kh, pin),
|
||||
reserveProfileSlot = anyProfileChip,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (discoveredUnsaved.isNotEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
SectionLabel("Discovered on the network")
|
||||
}
|
||||
items(discoveredUnsaved, key = { "disc-${it.host}-${it.port}" }) { dh ->
|
||||
HostCard(
|
||||
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
|
||||
os = dh.os,
|
||||
enabled = !connecting,
|
||||
onConnect = { onConnectDiscovered(dh) },
|
||||
onForget = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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. 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.
|
||||
// Scan again is offered whether or not anything turned up: the case that sends people
|
||||
// here is ONE expected host missing, not an empty list, and a browse that quietly went
|
||||
// deaf (blocked when it started, or backed off to its hour-long re-query) looks
|
||||
// exactly like a network without that host on it.
|
||||
if (lnpGranted && !connecting) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (discovered.isEmpty()) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"Searching the local network…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
TextButton(onClick = onRescan) { Text("Scan again") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Spacer(Modifier.height(96.dp))
|
||||
}
|
||||
}
|
||||
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = onAddHost,
|
||||
icon = { Icon(Icons.Filled.Add, contentDescription = null) },
|
||||
text = { Text("Add host") },
|
||||
expanded = !connecting,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
@@ -30,8 +29,8 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -205,10 +204,7 @@ internal fun ConnectTakeover(
|
||||
) {
|
||||
GamepadAuroraBackground(Modifier.fillMaxSize())
|
||||
Column(
|
||||
// The backdrop runs full-bleed; the COPY keeps clear of the bars and the cutout. In
|
||||
// landscape a hole punch is a side inset deeper than this 40 dp gutter, so centred text
|
||||
// would otherwise sit under the camera.
|
||||
Modifier.consoleSafeArea().padding(horizontal = 40.dp).widthIn(max = 460.dp),
|
||||
Modifier.padding(horizontal = 40.dp).widthIn(max = 460.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
@@ -243,19 +239,7 @@ internal fun ConnectTakeover(
|
||||
add(PadGlyph.hint('B', copy.cancelLabel, onClick = onCancel))
|
||||
if (timedOut) add(PadGlyph.hint('A', "Try Again", onClick = onRetry))
|
||||
}
|
||||
// The SAME bottom-start spot every console screen pins its legend at — this takeover sat
|
||||
// its pill at bottom-CENTRE, so pressing Connect made the one piece of chrome that is
|
||||
// supposed to read as fixed jump halfway across the screen (second on-glass verdict).
|
||||
val landscape =
|
||||
LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.consoleLegendInsets(landscape)
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(hints)
|
||||
}
|
||||
GamepadHintBar(hints, Modifier.align(Alignment.BottomCenter).padding(bottom = 28.dp))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import io.unom.punktfunk.kit.security.ClientIdentity
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.models.PendingTrust
|
||||
|
||||
/**
|
||||
* Everything `ConnectScreen` puts ON TOP of whichever home it drew — the trust and pairing
|
||||
* ceremony, the parked "Waiting for approval…", the console's host options, the speed test, the
|
||||
* edit form, the local-network rationale, and finally the connect takeover.
|
||||
*
|
||||
* They live together because their ORDER is the contract: this is a stack of siblings in one tree,
|
||||
* so the last one drawn is the one on top, and [ConnectOverlay] is last on purpose — a dial can
|
||||
* start from any of the prompts above it, and its takeover has to cover the prompt that started it.
|
||||
*
|
||||
* Only the state each prompt reads comes in; every action goes back out as a callback, because they
|
||||
* all end in the connect/pair engine or in the host store, which the screen owns. Nothing in here
|
||||
* decides anything — it decides only what is visible.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConnectPrompts(
|
||||
gamepadUi: Boolean,
|
||||
/** The client identity — the PIN ceremony needs it to run SPAKE2; null while it is still minting. */
|
||||
identity: ClientIdentity?,
|
||||
profiles: List<StreamProfile>,
|
||||
isOnline: (KnownHost) -> Boolean,
|
||||
// ---- trust / pairing --------------------------------------------------------------------
|
||||
pendingTrust: PendingTrust?,
|
||||
/** Dismiss (null) or re-aim the SAME decision at another kind — "Pair with PIN…" does that. */
|
||||
onPendingTrustChange: (PendingTrust?) -> Unit,
|
||||
/** Trust-on-first-use accepted: dial with no pin. Offered only for a `pair=optional` host. */
|
||||
onTrustNew: (PendingTrust) -> Unit,
|
||||
/** The PIN ceremony completed with this host fingerprint — save as paired, then dial. */
|
||||
onPaired: (PendingTrust, String) -> Unit,
|
||||
onRequestAccess: (PendingTrust) -> Unit,
|
||||
// ---- the parked no-PIN request ----------------------------------------------------------
|
||||
/** Non-null while a "request access" connect sits parked on the host awaiting approval. */
|
||||
awaitingHostName: String?,
|
||||
onCancelApproval: () -> Unit,
|
||||
// ---- console host options (Up on a saved carousel tile) ---------------------------------
|
||||
optionsTarget: HostCardEntry?,
|
||||
onDismissOptions: () -> Unit,
|
||||
libraryEnabled: Boolean,
|
||||
onOpenLibrary: (KnownHost, String?) -> Unit,
|
||||
onWake: (KnownHost) -> Unit,
|
||||
onSpeedTest: (KnownHost) -> Unit,
|
||||
onCopyLink: (KnownHost, StreamProfile?) -> Unit,
|
||||
onEditHost: (KnownHost) -> Unit,
|
||||
onForgetHost: (KnownHost) -> Unit,
|
||||
onTogglePin: (KnownHost, StreamProfile) -> Unit,
|
||||
// ---- speed test --------------------------------------------------------------------------
|
||||
speedTest: HostCardEntry?,
|
||||
/** Which layer Apply writes to. Resolved by the caller (it holds the store); set with [speedTest]. */
|
||||
speedTestTarget: SpeedTestTarget?,
|
||||
speedTestPhase: SpeedTestPhase,
|
||||
/** true = write the measured bitrate to the profile, false = to the global default. */
|
||||
onApplySpeedTest: (Boolean) -> Unit,
|
||||
onDismissSpeedTest: () -> Unit,
|
||||
// ---- edit host ---------------------------------------------------------------------------
|
||||
editTarget: KnownHost?,
|
||||
/** A MAC from the live advert, for a host whose own is not learned yet. */
|
||||
editSuggestedMacs: List<String>,
|
||||
onSaveHost: (KnownHost) -> Unit,
|
||||
onDismissEdit: () -> Unit,
|
||||
// ---- local network permission ------------------------------------------------------------
|
||||
lnpPrompt: Boolean,
|
||||
onAllowLocalNetwork: () -> Unit,
|
||||
onOpenSystemSettings: () -> Unit,
|
||||
onDismissLnpPrompt: () -> Unit,
|
||||
// ---- the connect takeover ----------------------------------------------------------------
|
||||
connectingHostName: String?,
|
||||
waker: WakeController,
|
||||
onCancelConnect: () -> Unit,
|
||||
) {
|
||||
pendingTrust?.let { pt ->
|
||||
// Same trust/pairing logic, console-styled + controller-navigable in gamepad mode.
|
||||
val onPair = { onPendingTrustChange(pt.copy(kind = PendingTrust.Kind.PAIR)) }
|
||||
// Three of the four say the same thing in both interfaces, so they are ONE prompt that
|
||||
// knows which one is running. Only the PIN ceremony genuinely differs — a keyboard field
|
||||
// against four D-pad digit slots is a different input model, not a different skin.
|
||||
when (pt.kind) {
|
||||
PendingTrust.Kind.TRUST_NEW -> TrustNewHostPrompt(
|
||||
gamepadUi, pt,
|
||||
onTrust = { onTrustNew(pt) },
|
||||
onPairInstead = onPair,
|
||||
onDismiss = { onPendingTrustChange(null) },
|
||||
)
|
||||
PendingTrust.Kind.FP_CHANGED ->
|
||||
FingerprintChangedPrompt(gamepadUi, pt, onPair) { onPendingTrustChange(null) }
|
||||
PendingTrust.Kind.REQUEST_ACCESS -> RequestAccessPrompt(
|
||||
gamepadUi, pt,
|
||||
onRequestAccess = { onRequestAccess(pt) },
|
||||
onUsePin = onPair,
|
||||
onDismiss = { onPendingTrustChange(null) },
|
||||
)
|
||||
PendingTrust.Kind.PAIR -> {
|
||||
val onSavePaired = { fp: String -> onPaired(pt, fp) }
|
||||
if (gamepadUi) {
|
||||
GamepadPairPinDialog(pt, identity, onSavePaired) { onPendingTrustChange(null) }
|
||||
} else {
|
||||
PairPinDialog(pt, identity, onSavePaired) { onPendingTrustChange(null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
awaitingHostName?.let { hostLabel ->
|
||||
AwaitingApprovalPrompt(gamepadUi, hostLabel = hostLabel, onCancel = onCancelApproval)
|
||||
}
|
||||
|
||||
// Console host options (Up on a saved carousel tile): Wake / Edit / Forget.
|
||||
optionsTarget?.let { entry ->
|
||||
val kh = entry.host
|
||||
val pin = entry.pin
|
||||
val offline = !isOnline(kh)
|
||||
GamepadHostOptionsDialog(
|
||||
hostName = kh.name,
|
||||
canWake = kh.mac.isNotEmpty() && offline,
|
||||
onWake = { onDismissOptions(); onWake(kh) },
|
||||
// A saved host always has a library (it's a knownHost) → offer it when the setting's on,
|
||||
// so a TV remote reaches the library here instead of via the Y face button. A PIN card
|
||||
// gets it too, opening its own shelf: unlike wake/edit/forget, the library is a way to
|
||||
// start the card, not a property of the host.
|
||||
onLibrary = if (libraryEnabled) {
|
||||
{ onDismissOptions(); onOpenLibrary(kh, pin?.id) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onSpeedTest = if (pin == null) {
|
||||
{ onDismissOptions(); onSpeedTest(kh) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onCopyLink = { onDismissOptions(); onCopyLink(kh, pin) },
|
||||
onEdit = { onDismissOptions(); onEditHost(kh) },
|
||||
onForget = { onForgetHost(kh); onDismissOptions() },
|
||||
onDismiss = onDismissOptions,
|
||||
// A pin's only action: unpinning touches neither the host nor the profile.
|
||||
onUnpin = pin?.let { p -> { onTogglePin(kh, p); onDismissOptions() } },
|
||||
profileName = pin?.name,
|
||||
)
|
||||
}
|
||||
|
||||
if (speedTest != null && speedTestTarget != null) {
|
||||
SpeedTestPrompt(
|
||||
gamepadUi, speedTest.host.name, speedTestTarget, speedTestPhase,
|
||||
onApplySpeedTest, onDismissSpeedTest,
|
||||
)
|
||||
}
|
||||
|
||||
editTarget?.let { kh ->
|
||||
if (gamepadUi) {
|
||||
// Console edit: the same field list + on-screen keyboard as Add-Host, seeded from the
|
||||
// host with an extra MAC row; the action SAVES instead of connecting.
|
||||
GamepadAddHostScreen(
|
||||
onAdd = { _, _, _ -> },
|
||||
onDismiss = onDismissEdit,
|
||||
editHost = kh,
|
||||
suggestedMacs = editSuggestedMacs,
|
||||
onSave = onSaveHost,
|
||||
// Shared clipboard and the profile binding — the two host decisions that used to
|
||||
// exist only in the touch edit sheet, which a TV box has no way to reach.
|
||||
profiles = profiles,
|
||||
)
|
||||
} else {
|
||||
EditHostDialog(
|
||||
target = kh,
|
||||
suggestedMacs = editSuggestedMacs,
|
||||
profiles = profiles,
|
||||
onSave = onSaveHost,
|
||||
onDismiss = onDismissEdit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (lnpPrompt) {
|
||||
// Android 17+ local-network-permission rationale: re-request (a permanently-denied request
|
||||
// returns instantly without a system prompt — hence the settings deep link alongside).
|
||||
LocalNetworkPrompt(
|
||||
gamepadUi,
|
||||
onAllow = onAllowLocalNetwork,
|
||||
onSettings = onOpenSystemSettings,
|
||||
onDismiss = onDismissLnpPrompt,
|
||||
)
|
||||
}
|
||||
|
||||
// Topmost: the full-screen connect takeover — instant "Connecting…" feedback on any dial, flowing
|
||||
// seamlessly into the "Waking…" wait if the host turns out to be asleep. Rides over both the touch
|
||||
// grid and the console home.
|
||||
ConnectOverlay(
|
||||
connectingHostName = connectingHostName,
|
||||
waker = waker,
|
||||
gamepadUi = gamepadUi,
|
||||
onCancelConnect = onCancelConnect,
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,31 @@ import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
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.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
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
|
||||
@@ -20,11 +45,19 @@ 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
|
||||
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.HostMenuItem
|
||||
import io.unom.punktfunk.components.SectionLabel
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.discovery.DiscoveredHost
|
||||
@@ -40,6 +73,7 @@ import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
import io.unom.punktfunk.kit.security.obtainIdentity
|
||||
import io.unom.punktfunk.models.ActiveSession
|
||||
import io.unom.punktfunk.models.HostStatus
|
||||
import io.unom.punktfunk.models.PendingTrust
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -74,20 +108,6 @@ private class ConnectAttempt(val hostName: String) {
|
||||
val cancelled = AtomicBoolean(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* The connect screen — discovery, trust and the dial itself, under either interface.
|
||||
*
|
||||
* What is left in this file is the STATE and the engine: the mDNS browse and the permission that
|
||||
* gates it, the identity, the host and profile stores, the trust decision, the dial and its wake
|
||||
* fallback, and the `punktfunk://` router. What was drawn from that state now lives beside it —
|
||||
* `buildHomeTiles` (the console carousel's contents), `ConnectGrid` (the touch home) and
|
||||
* `ConnectPrompts` (everything modal, plus the connect takeover). They hold no state of their own,
|
||||
* which is why they could leave: each one takes what it displays and hands back what was pressed.
|
||||
*
|
||||
* The engine did NOT leave, and shouldn't until it has somewhere to live: it closes over ~20 locals
|
||||
* that a dozen callbacks read and write, and hoisting it means inventing a state holder — a second
|
||||
* refactor, and a second thing to get wrong.
|
||||
*/
|
||||
@Composable
|
||||
fun ConnectScreen(
|
||||
settings: Settings,
|
||||
@@ -100,9 +120,7 @@ fun ConnectScreen(
|
||||
// gamepad shell owns (the touch UI reaches Settings via the bottom bar and has no library button).
|
||||
gamepadUi: Boolean = false,
|
||||
onOpenSettings: () -> Unit = {},
|
||||
// (host, pinned profile id) — a pinned host+profile card opens ITS shelf, and the id is the
|
||||
// one-off every launch off that shelf runs with (design §5.2a). Null = the host's own tile.
|
||||
onOpenLibrary: (KnownHost, String?) -> Unit = { _, _ -> },
|
||||
onOpenLibrary: (KnownHost) -> Unit = {},
|
||||
navGate: Boolean = true, // false while the console home is cross-fading out
|
||||
// A `punktfunk://` URL to route (design/client-deep-links.md §3). This screen owns it because
|
||||
// it owns the connect path — trust decisions, the local-network grant, wake-and-retry — and a
|
||||
@@ -633,6 +651,52 @@ fun ConnectScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// The profile rows a card's overflow menu grows. With no profiles at all it stays empty — a
|
||||
// user who never wants this feature sees no new clutter anywhere but the settings scope chips.
|
||||
// "Connect with" is a ONE-OFF on every card: it never rebinds the host, which is why rebinding
|
||||
// lives in the Edit sheet instead.
|
||||
fun hostMenu(kh: KnownHost, pin: StreamProfile?): List<HostMenuItem> = buildList {
|
||||
if (pin == null) {
|
||||
add(HostMenuItem("Network speed test") { startSpeedTest(HostCardEntry(kh, null)) })
|
||||
}
|
||||
add(HostMenuItem("Copy link") { copyLink(kh, pin) })
|
||||
if (profiles.isEmpty()) return@buildList
|
||||
if (pin != null) {
|
||||
add(HostMenuItem("Unpin card", startsSection = true) { togglePin(kh, pin) })
|
||||
}
|
||||
add(
|
||||
HostMenuItem("Connect with: Default settings", startsSection = true) {
|
||||
// The empty reference is "force the defaults", not "unset" — on a bound host that
|
||||
// is a real, different action from a plain tap.
|
||||
connect(kh.address, kh.port, oneOffProfile = "")
|
||||
},
|
||||
)
|
||||
profiles.forEach { p ->
|
||||
add(HostMenuItem("Connect with: ${p.name}") { connect(kh.address, kh.port, oneOffProfile = p.id) })
|
||||
}
|
||||
if (pin == null) {
|
||||
profiles.forEachIndexed { i, p ->
|
||||
val pinned = p.id in kh.pinnedProfileIds
|
||||
add(
|
||||
HostMenuItem(
|
||||
if (pinned) "Unpin card: ${p.name}" else "Pin as card: ${p.name}",
|
||||
startsSection = i == 0,
|
||||
) { togglePin(kh, p) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The saved-hosts grid: each host's own card, then one card per profile it has pinned, so a
|
||||
// pinned combination is a plain one-click connect instead of a trip through a menu.
|
||||
val savedCards = savedHosts.flatMap { kh ->
|
||||
listOf(HostCardEntry(kh, null)) + profileStore.pinsFor(kh).map { HostCardEntry(kh, it) }
|
||||
}
|
||||
// Cards in one grid row must be the same height (the grid won't stretch them), so as soon as
|
||||
// ANY saved card carries a profile chip, they all reserve its space. Nobody who doesn't use
|
||||
// profiles ever sees the gap.
|
||||
val anyProfileChip = savedCards.any { it.pin != null || it.host.profileId != null }
|
||||
|
||||
// ---- punktfunk:// routing (design/client-deep-links.md §3) --------------------------------
|
||||
//
|
||||
// The invariant: a URL may only ever do what a click on an existing card could do, MINUS trust
|
||||
@@ -718,63 +782,79 @@ fun ConnectScreen(
|
||||
|
||||
var showManualSheet by remember { mutableStateOf(false) }
|
||||
|
||||
// Wake a saved host on demand — the touch card's Wake item and the console options dialog run
|
||||
// the same action. Through the WakeController, so it shows the "Waking…" overlay and waits for
|
||||
// the host to come back rather than firing one silent packet at it.
|
||||
fun wakeHost(kh: KnownHost) {
|
||||
// The magic packet is UDP broadcast — LNP-blocked like everything else.
|
||||
if (!lnpGranted) {
|
||||
lnpPrompt = true
|
||||
return
|
||||
}
|
||||
waker.start(
|
||||
hostName = kh.name,
|
||||
connectsAfter = false,
|
||||
macs = kh.mac,
|
||||
lastIp = kh.address,
|
||||
// "Back up" is mDNS presence ONLY — narrower than the [isOnline] that decides whether to
|
||||
// OFFER Wake, which also counts a QUIC probe answer. Matched through `matches`, so a
|
||||
// cold boot onto a new DHCP address still ends the wait.
|
||||
isOnline = { discovered.any { kh.matches(it) } },
|
||||
onOnline = {},
|
||||
)
|
||||
}
|
||||
|
||||
fun forgetHost(kh: KnownHost) {
|
||||
knownHostStore.remove(kh)
|
||||
savedHosts = knownHostStore.all()
|
||||
}
|
||||
|
||||
if (gamepadUi) {
|
||||
// Console mode: the host carousel (saved → discovered → Add Host), driven by the pad. Shares
|
||||
// every action above; the trailing Add Host tile opens the same manual-entry sheet.
|
||||
val tiles = buildList {
|
||||
savedHosts.forEach { kh ->
|
||||
val bound = kh.profileId?.let { id -> profiles.firstOrNull { it.id == id } }
|
||||
add(
|
||||
HomeTile(
|
||||
id = "saved-${kh.id}",
|
||||
title = kh.name,
|
||||
// The binding is what a press will actually do, so the tile says so — the
|
||||
// console can't edit profiles, but it must never lie about which one it uses.
|
||||
subtitle = bound?.let { "${kh.address}:${kh.port} · ${it.name}" }
|
||||
?: "${kh.address}:${kh.port}",
|
||||
filled = true,
|
||||
online = kh.isOnline(discovered, reachable),
|
||||
paired = kh.paired,
|
||||
knownHost = kh,
|
||||
activate = { connect(kh.address, kh.port) },
|
||||
),
|
||||
)
|
||||
// Pinned host+profile combinations, right after their host: one focus-and-press
|
||||
// each, which is the affordance a controller surface does well (menus are not).
|
||||
profileStore.pinsFor(kh).forEach { p ->
|
||||
add(
|
||||
HomeTile(
|
||||
id = "pin-${kh.id}-${p.id}",
|
||||
title = kh.name,
|
||||
subtitle = p.name,
|
||||
filled = true,
|
||||
online = kh.isOnline(discovered, reachable),
|
||||
paired = kh.paired,
|
||||
knownHost = kh,
|
||||
pinnedProfileId = p.id,
|
||||
activate = { connect(kh.address, kh.port, oneOffProfile = p.id) },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
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 = buildHomeTiles(
|
||||
savedHosts = savedHosts,
|
||||
profiles = profiles,
|
||||
pinsFor = profileStore::pinsFor,
|
||||
discoveredUnsaved = discoveredUnsaved,
|
||||
isOnline = { it.isOnline(discovered, reachable) },
|
||||
onConnect = { kh, oneOff -> connect(kh.address, kh.port, oneOffProfile = oneOff) },
|
||||
onConnectDiscovered = { dh -> connect(dh.host, dh.port, dh) },
|
||||
onAddHost = { showManualSheet = true },
|
||||
),
|
||||
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.
|
||||
// ⚠ `speedTest` belongs in this list and was missing. It LOOKED covered by `!connecting`,
|
||||
// and is — right up until the measurement finishes: `startSpeedTest` clears `connecting`
|
||||
// before its Done/Failed card is dismissed, so from that moment the card AND the
|
||||
// carousel underneath both consumed the pad. One A then dismissed the card and started
|
||||
// a connect. Every other modal on this screen is named here for exactly this reason.
|
||||
navActive = navGate && !connecting && !showManualSheet && pendingTrust == null &&
|
||||
awaiting == null && editTarget == null && optionsTarget == null &&
|
||||
speedTest == null && waker.waking == null && !lnpPrompt,
|
||||
waker.waking == null && !lnpPrompt,
|
||||
onActivate = { it.activate() },
|
||||
onOpenLibrary = { tile -> tile.knownHost?.let { onOpenLibrary(it, tile.pinnedProfileId) } },
|
||||
onOpenLibrary = { it.knownHost?.let(onOpenLibrary) },
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOptions = { tile ->
|
||||
tile.knownHost?.let { kh ->
|
||||
@@ -783,35 +863,239 @@ fun ConnectScreen(
|
||||
},
|
||||
)
|
||||
} else {
|
||||
ConnectGrid(
|
||||
savedHosts = savedHosts,
|
||||
discovered = discovered,
|
||||
discoveredUnsaved = discoveredUnsaved,
|
||||
reachable = reachable,
|
||||
profiles = profiles,
|
||||
pinsFor = profileStore::pinsFor,
|
||||
connecting = connecting,
|
||||
notice = notice,
|
||||
status = status,
|
||||
lnpGranted = lnpGranted,
|
||||
onAskLocalNetwork = { lnpPrompt = true },
|
||||
onConnect = { kh, oneOff -> connect(kh.address, kh.port, oneOffProfile = oneOff) },
|
||||
onConnectDiscovered = { dh -> connect(dh.host, dh.port, dh) },
|
||||
onForget = { kh -> forgetHost(kh) },
|
||||
onEdit = { kh -> editTarget = kh },
|
||||
onWake = { kh -> wakeHost(kh) },
|
||||
onSpeedTest = { kh -> startSpeedTest(HostCardEntry(kh, null)) },
|
||||
onCopyLink = { kh, pin -> copyLink(kh, pin) },
|
||||
onTogglePin = { kh, p -> togglePin(kh, p) },
|
||||
onRescan = { discovery.restart() },
|
||||
onAddHost = { showManualSheet = true },
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 160.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Punktfunk", style = MaterialTheme.typography.headlineLarge)
|
||||
Text(
|
||||
"stream a remote desktop",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
notice?.let {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
status?.let {
|
||||
// In-flight progress (connecting / waking) is the full-screen ConnectOverlay's
|
||||
// job now, so `status` only ever carries a result/error here — a filled error
|
||||
// container reads as a real failure banner, not just red text lost in the layout.
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
if (savedHosts.isNotEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
SectionLabel("Saved hosts")
|
||||
}
|
||||
items(savedCards, key = { it.key }) { entry ->
|
||||
val kh = entry.host
|
||||
val pin = entry.pin
|
||||
val bound = kh.profileId?.let { id -> profiles.firstOrNull { it.id == id } }
|
||||
HostCard(
|
||||
name = kh.name,
|
||||
address = "${kh.address}:${kh.port}",
|
||||
status = if (kh.paired) HostStatus.PAIRED else HostStatus.TOFU,
|
||||
online = kh.isOnline(discovered, reachable),
|
||||
// Live advert preferred (the store lags a discovery tick), else stored.
|
||||
os = discovered.firstOrNull { kh.matches(it) && it.os.isNotEmpty() }?.os
|
||||
?: kh.os,
|
||||
enabled = !connecting,
|
||||
// A pinned card connects with ITS profile; the host's own card follows the
|
||||
// binding, which is exactly what its chip says it will do.
|
||||
onConnect = {
|
||||
if (pin != null) {
|
||||
connect(kh.address, kh.port, oneOffProfile = pin.id)
|
||||
} else {
|
||||
connect(kh.address, kh.port)
|
||||
}
|
||||
},
|
||||
// Edit / Forget / Wake live on the host's own card only: a pinned card is a
|
||||
// shortcut, not a second host, and offering destructive host actions on it
|
||||
// would blur exactly that.
|
||||
onForget = if (pin != null) {
|
||||
null
|
||||
} else {
|
||||
{
|
||||
knownHostStore.remove(kh)
|
||||
savedHosts = knownHostStore.all()
|
||||
}
|
||||
},
|
||||
onEdit = if (pin != null) null else ({ 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 (pin == null && 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
|
||||
},
|
||||
profileLabel = pin?.name ?: bound?.name,
|
||||
profileProminent = pin != null,
|
||||
accent = accentColor(pin?.accent ?: bound?.accent),
|
||||
menuItems = hostMenu(kh, pin),
|
||||
reserveProfileSlot = anyProfileChip,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (discoveredUnsaved.isNotEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
SectionLabel("Discovered on the network")
|
||||
}
|
||||
items(discoveredUnsaved, key = { "disc-${it.host}-${it.port}" }) { dh ->
|
||||
HostCard(
|
||||
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
|
||||
os = dh.os,
|
||||
enabled = !connecting,
|
||||
onConnect = { connect(dh.host, dh.port, dh) },
|
||||
onForget = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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. 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.
|
||||
// Scan again is offered whether or not anything turned up: the case that sends people
|
||||
// here is ONE expected host missing, not an empty list, and a browse that quietly went
|
||||
// deaf (blocked when it started, or backed off to its hour-long re-query) looks
|
||||
// exactly like a network without that host on it.
|
||||
if (lnpGranted && !connecting) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (discovered.isEmpty()) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"Searching the local network…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
TextButton(onClick = { discovery.restart() }) { Text("Scan again") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Spacer(Modifier.height(96.dp))
|
||||
}
|
||||
}
|
||||
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { showManualSheet = true },
|
||||
icon = { Icon(Icons.Filled.Add, contentDescription = null) },
|
||||
text = { Text("Add host") },
|
||||
expanded = !connecting,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Add Host stayed behind while the other modals moved into ConnectPrompts: its form fields are
|
||||
// remembered HERE, on purpose, so a half-typed address survives the sheet being dismissed and
|
||||
// reopened. Moving the block without moving that state would quietly change what a dismiss
|
||||
// costs; moving both is a separate decision from this one.
|
||||
if (showManualSheet) {
|
||||
if (gamepadUi) {
|
||||
// Console add-host: field list + on-screen controller keyboard. "Add" connects (which
|
||||
@@ -839,81 +1123,148 @@ fun ConnectScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Which layer a measurement would land in. Resolved here, not in the prompt: it is a question
|
||||
// for the profile store, and the Apply button and the caption above it must agree on the answer.
|
||||
val speedTestTarget = speedTest?.let { SpeedTestTarget.resolve(it.host, it.pin?.id, profileStore) }
|
||||
// Prefill a not-yet-learned MAC from the host's live advert, mirroring Apple's
|
||||
// `discovery.hosts.first { host.matches($0) }?.macAddresses`.
|
||||
val editSuggestedMacs =
|
||||
editTarget?.let { kh -> discovered.firstOrNull { kh.matches(it) }?.mac } ?: emptyList()
|
||||
|
||||
// Everything that floats above whichever home was drawn, in one place and in one order — see
|
||||
// ConnectPrompts.kt. It decides nothing: each action below lands right back in the engine above.
|
||||
ConnectPrompts(
|
||||
gamepadUi = gamepadUi,
|
||||
identity = identity,
|
||||
profiles = profiles,
|
||||
isOnline = { it.isOnline(discovered, reachable) },
|
||||
pendingTrust = pendingTrust,
|
||||
onPendingTrustChange = { pendingTrust = it },
|
||||
onTrustNew = { pt ->
|
||||
pendingTrust = null
|
||||
doConnect(pt.host, pt.port, pt.name, null, pt.profile, pt.launch)
|
||||
},
|
||||
onPaired = { pt, fp ->
|
||||
pendingTrust?.let { pt ->
|
||||
// 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.trust(pt.host, pt.port, pt.name, fp, paired = true)
|
||||
savedHosts = knownHostStore.all()
|
||||
pendingTrust = null
|
||||
doConnect(pt.host, pt.port, pt.name, fp, pt.profile, pt.launch)
|
||||
},
|
||||
onRequestAccess = { pt -> pendingTrust = null; requestAccess(pt) },
|
||||
awaitingHostName = awaiting?.target?.name,
|
||||
onCancelApproval = {
|
||||
awaiting?.cancelled?.set(true)
|
||||
}
|
||||
when (pt.kind) {
|
||||
PendingTrust.Kind.TRUST_NEW ->
|
||||
if (gamepadUi) GamepadTrustNewDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null, pt.profile, pt.launch) }, onPair, { pendingTrust = null })
|
||||
else TrustNewHostDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null, pt.profile, pt.launch) }, 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 ->
|
||||
val onCancel = {
|
||||
req.cancelled.set(true)
|
||||
awaiting = null
|
||||
connecting = false
|
||||
discovery.start() // the request may still be pending on the host; keep scanning
|
||||
},
|
||||
optionsTarget = optionsTarget,
|
||||
onDismissOptions = { optionsTarget = null },
|
||||
libraryEnabled = settings.libraryEnabled,
|
||||
onOpenLibrary = onOpenLibrary,
|
||||
onWake = { kh -> wakeHost(kh) },
|
||||
onSpeedTest = { kh -> startSpeedTest(HostCardEntry(kh, null)) },
|
||||
onCopyLink = { kh, pin -> copyLink(kh, pin) },
|
||||
onEditHost = { kh -> editTarget = kh },
|
||||
onForgetHost = { kh -> forgetHost(kh) },
|
||||
onTogglePin = { kh, p -> togglePin(kh, p) },
|
||||
speedTest = speedTest,
|
||||
speedTestTarget = speedTestTarget,
|
||||
speedTestPhase = speedTestPhase,
|
||||
onApplySpeedTest = { toProfile ->
|
||||
}
|
||||
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 { entry ->
|
||||
val kh = entry.host
|
||||
val pin = entry.pin
|
||||
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 && pin == null) {
|
||||
{ optionsTarget = null; onOpenLibrary(kh) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onSpeedTest = if (pin == null) {
|
||||
{ optionsTarget = null; startSpeedTest(HostCardEntry(kh, null)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onCopyLink = { optionsTarget = null; copyLink(kh, pin) },
|
||||
onEdit = { optionsTarget = null; editTarget = kh },
|
||||
onForget = {
|
||||
knownHostStore.remove(kh)
|
||||
savedHosts = knownHostStore.all()
|
||||
optionsTarget = null
|
||||
},
|
||||
onDismiss = { optionsTarget = null },
|
||||
// A pin's only action: unpinning touches neither the host nor the profile.
|
||||
onUnpin = pin?.let { p -> { togglePin(kh, p); optionsTarget = null } },
|
||||
profileName = pin?.name,
|
||||
)
|
||||
}
|
||||
|
||||
speedTest?.let { entry ->
|
||||
val target = SpeedTestTarget.resolve(entry.host, entry.pin?.id, profileStore)
|
||||
val dismiss = { speedTest = null }
|
||||
val apply: (Boolean) -> Unit = { toProfile ->
|
||||
val done = speedTestPhase as? SpeedTestPhase.Done
|
||||
if (done != null && speedTestTarget != null) {
|
||||
if (done != null) {
|
||||
val where = applySpeedTestResult(
|
||||
done.recommendedKbps, speedTestTarget, toProfile, profileStore, settings,
|
||||
onSettingsChange,
|
||||
done.recommendedKbps, target, toProfile, profileStore, settings, onSettingsChange,
|
||||
)
|
||||
profiles = profileStore.all()
|
||||
notice = "%.0f Mbit/s set in %s".format(done.recommendedMbps, where)
|
||||
}
|
||||
speedTest = null
|
||||
},
|
||||
onDismissSpeedTest = { speedTest = null },
|
||||
editTarget = editTarget,
|
||||
editSuggestedMacs = editSuggestedMacs,
|
||||
onSaveHost = { updated ->
|
||||
}
|
||||
if (gamepadUi) {
|
||||
GamepadSpeedTestDialog(entry.host.name, target, speedTestPhase, apply, dismiss)
|
||||
} else {
|
||||
SpeedTestDialog(entry.host.name, target, speedTestPhase, apply, dismiss)
|
||||
}
|
||||
}
|
||||
|
||||
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.save(updated)
|
||||
savedHosts = knownHostStore.all()
|
||||
editTarget = null
|
||||
},
|
||||
onDismissEdit = { editTarget = null },
|
||||
lnpPrompt = lnpPrompt,
|
||||
onAllowLocalNetwork = {
|
||||
}
|
||||
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,
|
||||
profiles = profiles,
|
||||
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)
|
||||
},
|
||||
onOpenSystemSettings = {
|
||||
}
|
||||
val onSettings = {
|
||||
lnpPrompt = false
|
||||
context.startActivity(
|
||||
Intent(
|
||||
@@ -921,10 +1272,21 @@ fun ConnectScreen(
|
||||
Uri.fromParts("package", context.packageName, null),
|
||||
),
|
||||
)
|
||||
},
|
||||
onDismissLnpPrompt = { lnpPrompt = false },
|
||||
}
|
||||
if (gamepadUi) {
|
||||
GamepadLocalNetworkDialog(onAllow = onAllow, onSettings = onSettings, onDismiss = { lnpPrompt = false })
|
||||
} else {
|
||||
LocalNetworkDialog(onAllow = onAllow, onSettings = onSettings, onDismiss = { lnpPrompt = false })
|
||||
}
|
||||
}
|
||||
|
||||
// Topmost: the full-screen connect takeover — instant "Connecting…" feedback on any dial, flowing
|
||||
// seamlessly into the "Waking…" wait if the host turns out to be asleep. Rides over both the touch
|
||||
// grid and the console home.
|
||||
ConnectOverlay(
|
||||
connectingHostName = attempt?.hostName,
|
||||
waker = waker,
|
||||
gamepadUi = gamepadUi,
|
||||
onCancelConnect = { cancelConnect() },
|
||||
)
|
||||
}
|
||||
@@ -933,11 +1295,8 @@ fun ConnectScreen(
|
||||
* One entry in the saved-hosts grid: a host's own card ([pin] null), or one of its pinned
|
||||
* host+profile cards. Pins are additive presentation state on the host record — never duplicated
|
||||
* host entries, which would fork pairing, trust and renames (design §5.2a).
|
||||
*
|
||||
* The console reuses it deliberately: its options dialog acts on a host-or-pin exactly as the touch
|
||||
* card's overflow menu does, and one currency for "which card is this" keeps the two from drifting.
|
||||
*/
|
||||
internal data class HostCardEntry(val host: KnownHost, val pin: StreamProfile?) {
|
||||
private data class HostCardEntry(val host: KnownHost, val pin: StreamProfile?) {
|
||||
val key: String get() = "card-${host.id}-${pin?.id ?: "primary"}"
|
||||
}
|
||||
|
||||
@@ -946,7 +1305,7 @@ internal data class HostCardEntry(val host: KnownHost, val pin: StreamProfile?)
|
||||
* as a multicast-reception hedge on OEMs that filter multicast without it, but discovery (raw mDNS via
|
||||
* the native core + MulticastLock) does not depend on it.
|
||||
*/
|
||||
internal fun hasNearbyPermission(context: Context): Boolean =
|
||||
fun hasNearbyPermission(context: Context): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.NEARBY_WIFI_DEVICES) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
@@ -958,7 +1317,7 @@ internal fun hasNearbyPermission(context: Context): Boolean =
|
||||
* 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.
|
||||
*/
|
||||
internal fun hasLocalNetworkPermission(context: Context): Boolean =
|
||||
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
|
||||
@@ -968,7 +1327,7 @@ internal fun hasLocalNetworkPermission(context: Context): Boolean =
|
||||
* fingerprint when both carry it (so it survives a DHCP address change), else by address:port.
|
||||
* Mirrors the Apple client's `StoredHost.matches`; de-dupes "Discovered" against "Saved hosts".
|
||||
*/
|
||||
internal fun KnownHost.matches(dh: DiscoveredHost): Boolean {
|
||||
private fun KnownHost.matches(dh: DiscoveredHost): Boolean {
|
||||
val advFp = dh.fingerprint?.lowercase()
|
||||
if (!advFp.isNullOrEmpty() && fpHex.isNotEmpty() && fpHex.lowercase() == advFp) return true
|
||||
return address == dh.host && port == dh.port
|
||||
@@ -978,9 +1337,6 @@ internal fun KnownHost.matches(dh: DiscoveredHost): Boolean {
|
||||
* 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".
|
||||
*
|
||||
* `internal`, not private: the touch grid draws the same pip in its own file now, and the console's
|
||||
* tile builder is handed this as a lambda so it never has to know what "reachable" is made of.
|
||||
*/
|
||||
internal fun KnownHost.isOnline(discovered: List<DiscoveredHost>, reachable: Set<String>): Boolean =
|
||||
private fun KnownHost.isOnline(discovered: List<DiscoveredHost>, reachable: Set<String>): Boolean =
|
||||
discovered.any { matches(it) } || reachable.contains("$address:$port")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.hardware.input.InputManager
|
||||
import android.os.Build
|
||||
import android.os.CombinedVibration
|
||||
@@ -12,15 +11,12 @@ import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.background
|
||||
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.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -43,15 +39,11 @@ import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import io.unom.punktfunk.kit.DsDevice
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.Sc2Capture
|
||||
@@ -63,145 +55,10 @@ import kotlinx.coroutines.delay
|
||||
* case where a pad "doesn't work" — adapters and BT-to-USB dongles often enumerate with a different
|
||||
* identity than the physical pad, or not as a gamepad at all, and punktfunk only forwards devices
|
||||
* Android classifies as gamepad/joystick. This screen makes that visible on the device itself.
|
||||
*
|
||||
* This is the TOUCH entry point; [ConsoleControllersScreen] shows the same body on the console's
|
||||
* field. Both drive [ControllersBody] — the screen exists once, and the support answer it gives has
|
||||
* to be the same one whichever interface asked.
|
||||
*/
|
||||
@Composable
|
||||
fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
||||
BackHandler(onBack = onBack)
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
ControllersBody(
|
||||
gamepadSetting = gamepadSetting,
|
||||
scroll = rememberScrollState(),
|
||||
testing = testing,
|
||||
onTestingChange = { testing = it },
|
||||
// The touch screen holds the probes for its whole life: events are OBSERVED (not consumed)
|
||||
// while the test is off, which is what keeps the "Last input" line live while browsing.
|
||||
// Nothing else here wants the pad, so there is no one to hand them to.
|
||||
observeInput = true,
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 24.dp),
|
||||
) {
|
||||
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same screen on the console's field — the couch route to it, which a TV box has no other way to
|
||||
* reach (there is no touch interface to fall back to there, which is exactly why this matters).
|
||||
*
|
||||
* Navigation, and how the pad is shared with the test:
|
||||
* * up/down scrolls, the shoulders page — the body is cards and prose with no focusable rows, and
|
||||
* Compose only scrolls to keep a FOCUSED child visible (see [rememberConsoleScroller]);
|
||||
* * A starts the input test, which is the one thing on this screen a controller can act on;
|
||||
* * while the test runs it OWNS the pad — that is the whole point of it — so this screen's nav
|
||||
* drops out of the probe slots and B is a HOLD (below). Everything reverts the moment it ends.
|
||||
*/
|
||||
@Composable
|
||||
fun ConsoleControllersScreen(gamepadSetting: Int, onBack: () -> Unit, navActive: Boolean = true) {
|
||||
BackHandler(onBack = onBack)
|
||||
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
val hazeState = remember { HazeState() }
|
||||
val scroll = rememberScrollState()
|
||||
val scrollBy = rememberConsoleScroller(scroll)
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
|
||||
|
||||
GamepadNavEffect2D(
|
||||
// Off while the test runs: both want the same single probe slot, and the test is the one
|
||||
// the user just asked for. The identity check in each teardown (here and in the body) is
|
||||
// what makes the handover safe in either direction.
|
||||
active = navActive && !testing,
|
||||
onDirection = { dir ->
|
||||
when (dir) {
|
||||
NavDir.UP -> scrollBy(-1, false)
|
||||
NavDir.DOWN -> scrollBy(1, false)
|
||||
// Nothing on this screen steps sideways; paging is the shoulders' job.
|
||||
NavDir.LEFT, NavDir.RIGHT -> {}
|
||||
}
|
||||
},
|
||||
onActivate = { testing = true },
|
||||
onShoulder = { delta -> scrollBy(delta, true) },
|
||||
)
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||
// The calm backdrop, full-bleed under the bars and the cutout: this is a screen to READ,
|
||||
// and the aurora is ambience. Only the content takes the safe area.
|
||||
GamepadFormBackground(Modifier.fillMaxSize())
|
||||
// The body is written against the touch theme; on the console field it has to be inked
|
||||
// from the palette or it is grey-on-pastel over the six pale palettes.
|
||||
ConsoleInkedTheme {
|
||||
Column(Modifier.fillMaxSize().consoleSafeArea()) {
|
||||
ControllersBody(
|
||||
gamepadSetting = gamepadSetting,
|
||||
scroll = scroll,
|
||||
testing = testing,
|
||||
onTestingChange = { testing = it },
|
||||
// Only while testing: the rest of the time the screen's own nav holds the
|
||||
// probes, so the "Last input" line is a test-time readout here rather than
|
||||
// an always-on one. A pad that reaches this screen at all has already
|
||||
// proved it is seen — by moving the cursor here.
|
||||
observeInput = testing,
|
||||
contentPadding = PaddingValues(
|
||||
start = ConsoleEdgeInset,
|
||||
end = ConsoleEdgeInset,
|
||||
// Clears the floating legend zone, like every other console list.
|
||||
bottom = ConsoleLegendClearance,
|
||||
),
|
||||
) {
|
||||
ConsoleHeader("Connected controllers", horizontalInset = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.consoleLegendInsets(landscape)
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(
|
||||
if (testing) {
|
||||
// The rule, stated at the moment it applies: while the test runs, B is a BUTTON
|
||||
// UNDER TEST like any other — it lights its own chip — so only a hold ends the
|
||||
// test, after which B is the universal Back again. Tappable as the touch hatch.
|
||||
listOf(PadGlyph.hint('B', "Hold to finish") { testing = false })
|
||||
} else {
|
||||
listOfNotNull(
|
||||
GamepadHint('↕', PadGlyph.Arrow, "Scroll"),
|
||||
// Advertised only where they exist — a TV remote has no shoulders, and
|
||||
// claiming otherwise is both a lie and the reason a narrow legend overflows.
|
||||
GamepadHint('⇄', PadGlyph.Arrow, "Page").takeIf { padIsGamepad },
|
||||
PadGlyph.hint('A', "Test inputs") { testing = true },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
},
|
||||
hazeState = hazeState,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The screen itself, shared by both interfaces. [contentPadding] and [heading] are where they
|
||||
* differ: the touch screen pads for a thumb and titles with the Material headline, the console pads
|
||||
* to the shared edge inset, clears its floating legend, and titles with [ConsoleHeader].
|
||||
*
|
||||
* [observeInput] decides whether this body installs the shared MainActivity probes at all — see the
|
||||
* two call sites, and [ConsoleControllersScreen] for why they cannot both be on at once.
|
||||
*/
|
||||
@Composable
|
||||
private fun ControllersBody(
|
||||
gamepadSetting: Int,
|
||||
scroll: ScrollState,
|
||||
testing: Boolean,
|
||||
onTestingChange: (Boolean) -> Unit,
|
||||
observeInput: Boolean,
|
||||
contentPadding: PaddingValues,
|
||||
heading: @Composable () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val activity = context as? MainActivity
|
||||
|
||||
@@ -227,31 +84,17 @@ private fun ControllersBody(
|
||||
|
||||
// Live input test. While `testing`, the MainActivity probes consume pad events (so they show up
|
||||
// here instead of driving focus navigation); holding B releases, since the pad can no longer
|
||||
// reach the Switch.
|
||||
// reach the Switch. Events are observed (not consumed) even when the test is off, so the
|
||||
// "last input" line works while browsing.
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
val held = remember { mutableStateMapOf<Int, Boolean>() }
|
||||
val axes = remember { mutableStateMapOf<String, Float>() }
|
||||
var lastInput by remember { mutableStateOf<String?>(null) }
|
||||
var bHeld by remember { mutableStateOf(false) }
|
||||
// The hold has lasted long enough; the test ends when B is let go (see the probe).
|
||||
var holdSatisfied by remember { mutableStateOf(false) }
|
||||
// The probes below are built ONCE per `observeInput` and then read these for the life of that
|
||||
// installation. `testing` and the callback arrive as parameters now, so capturing them plainly
|
||||
// would freeze the values they had when the probe was made — the test would consume nothing.
|
||||
val consuming by rememberUpdatedState(testing)
|
||||
// The console's refusal thud, on whatever actuator the driving pad or this device has.
|
||||
val haptics by rememberUpdatedState(rememberConsoleHaptics())
|
||||
|
||||
DisposableEffect(observeInput) {
|
||||
// Stable probe refs, and a teardown that releases the slot only if WE still hold it — the
|
||||
// rule GamepadNavEffect2D follows. Without it this screen's dispose nulls whatever is in the
|
||||
// slot: during the console shell's push/pop BOTH screens are briefly composed, so leaving
|
||||
// here would kill the pad navigation the arriving screen had just installed. The same
|
||||
// teardown also runs when this screen hands the pad to its own input test and back.
|
||||
val keyProbe: (KeyEvent) -> Boolean = probe@{ event ->
|
||||
DisposableEffect(Unit) {
|
||||
activity?.padKeyProbe = probe@{ event ->
|
||||
if (!Gamepad.isPad(event.device)) return@probe false
|
||||
// Read ONCE, up front: the test can end inside this very event, and the release that
|
||||
// ended it still has to be swallowed here — see the B branch below.
|
||||
val consume = consuming
|
||||
when (event.action) {
|
||||
KeyEvent.ACTION_DOWN -> {
|
||||
held[event.keyCode] = true
|
||||
@@ -259,34 +102,13 @@ private fun ControllersBody(
|
||||
}
|
||||
KeyEvent.ACTION_UP -> {
|
||||
held[event.keyCode] = false
|
||||
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) {
|
||||
bHeld = false
|
||||
if (consume) {
|
||||
if (event.eventTime - event.downTime >= HOLD_TO_FINISH_MS) {
|
||||
// The hold ends the test HERE, on the release, and NOT the moment
|
||||
// the 1.2 s elapsed: end it a moment earlier and this release falls
|
||||
// through unconsumed to the activity's B→BACK remap, which takes the
|
||||
// whole screen with it. Finishing the test and leaving the screen on
|
||||
// one press is not what "hold B to finish" says.
|
||||
onTestingChange(false)
|
||||
held.clear()
|
||||
} else {
|
||||
// A short B is not swallowed either. While the test owns the pad, B
|
||||
// is a BUTTON UNDER TEST — it lights its chip like every other — so
|
||||
// a tap can't also mean "leave", and in the console B is otherwise
|
||||
// the universal back. The press gets the boundary thud instead, the
|
||||
// same answer a refused step gets on the settings screen: heard, and
|
||||
// it means something else here.
|
||||
haptics.boundary()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) bHeld = false
|
||||
}
|
||||
}
|
||||
lastInput = "${event.device?.name}: ${KeyEvent.keyCodeToString(event.keyCode)}"
|
||||
consume
|
||||
testing
|
||||
}
|
||||
val motionProbe: (MotionEvent) -> Boolean = probe@{ event ->
|
||||
activity?.padMotionProbe = probe@{ event ->
|
||||
if (!Gamepad.isPad(event.device)) return@probe false
|
||||
axes["LX"] = event.getAxisValue(MotionEvent.AXIS_X)
|
||||
axes["LY"] = event.getAxisValue(MotionEvent.AXIS_Y)
|
||||
@@ -302,43 +124,31 @@ private fun ControllersBody(
|
||||
)
|
||||
axes["HX"] = event.getAxisValue(MotionEvent.AXIS_HAT_X)
|
||||
axes["HY"] = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
|
||||
consuming
|
||||
}
|
||||
if (observeInput) {
|
||||
activity?.padKeyProbe = keyProbe
|
||||
activity?.padMotionProbe = motionProbe
|
||||
testing
|
||||
}
|
||||
onDispose {
|
||||
activity?.let { a ->
|
||||
if (a.padKeyProbe === keyProbe) a.padKeyProbe = null
|
||||
if (a.padMotionProbe === motionProbe) a.padMotionProbe = null
|
||||
}
|
||||
activity?.padKeyProbe = null
|
||||
activity?.padMotionProbe = null
|
||||
}
|
||||
}
|
||||
// Hold-B-to-exit: with events consumed, the pad can't reach the Switch — a 1.2 s hold ends the
|
||||
// test instead (touch still works). This half only ANSWERS the hold once it is long enough; the
|
||||
// release is what ends the test (see the probe). Letting go early cancels the effect before the
|
||||
// delay fires, so nothing is announced.
|
||||
LaunchedEffect(bHeld, testing) {
|
||||
// test instead (touch still works). A short tap cancels the effect before the delay fires.
|
||||
LaunchedEffect(bHeld) {
|
||||
if (bHeld && testing) {
|
||||
delay(HOLD_TO_FINISH_MS)
|
||||
holdSatisfied = true
|
||||
// A hold with no answer at the moment it lands is a hold you keep holding. Say it in
|
||||
// both channels a couch user has: a pulse in the hands, a changed line on the screen.
|
||||
haptics.confirm()
|
||||
} else {
|
||||
holdSatisfied = false
|
||||
delay(1_200)
|
||||
testing = false
|
||||
held.clear()
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scroll)
|
||||
.padding(contentPadding),
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp, vertical = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
heading()
|
||||
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
|
||||
|
||||
// Capture-side detection, re-checked on USB hot-plug. The SC2 is never an InputDevice
|
||||
// (lizard mode is kb/mouse; the capture claims even those away) so it's enumerated from
|
||||
@@ -402,19 +212,13 @@ private fun ControllersBody(
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Test inputs", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
when {
|
||||
holdSatisfied -> "Release B to finish"
|
||||
testing -> "Controller input stays on this screen — hold B to finish"
|
||||
else -> "Show button presses and stick motion live"
|
||||
},
|
||||
if (testing) "Controller input stays on this screen — hold B to finish"
|
||||
else "Show button presses and stick motion live",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = testing,
|
||||
onCheckedChange = { on -> onTestingChange(on); if (!on) held.clear() },
|
||||
)
|
||||
Switch(checked = testing, onCheckedChange = { testing = it; if (!it) held.clear() })
|
||||
}
|
||||
if (testing) {
|
||||
ButtonGrid(held)
|
||||
@@ -876,11 +680,3 @@ private val TEST_BUTTONS = listOf(
|
||||
|
||||
/** Axis bars shown in the test view, in display order. */
|
||||
private val AXIS_LABELS = listOf("LX", "LY", "RX", "RY", "LT", "RT", "HX", "HY")
|
||||
|
||||
/**
|
||||
* How long B must be held to end the input test — and, below that, how long a press still counts as
|
||||
* a tap that gets answered rather than ignored. One constant, because a hold that ends at 1.2 s
|
||||
* while the "you tapped" answer stops at some other number leaves a window where a press does
|
||||
* nothing at all.
|
||||
*/
|
||||
private const val HOLD_TO_FINISH_MS = 1_200L
|
||||
|
||||
@@ -3,7 +3,6 @@ package io.unom.punktfunk
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -19,9 +18,10 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.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
|
||||
@@ -31,12 +31,6 @@ import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.toggleableState
|
||||
import androidx.compose.ui.state.ToggleableState
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
@@ -53,6 +47,7 @@ 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
|
||||
@@ -74,20 +69,6 @@ private const val KB_ROWS = 5
|
||||
|
||||
private class Field(val id: String, val label: String, val value: String, val placeholder: String)
|
||||
|
||||
/**
|
||||
* A non-text row of the EDIT form — a switch or a stepped choice, driven like a settings row rather
|
||||
* than opening the keyboard. Add-host mode has none: they all edit properties a host only has once
|
||||
* it is saved.
|
||||
*/
|
||||
private class ExtraRow(
|
||||
val label: String,
|
||||
val value: String,
|
||||
/** Non-null = draw a [ConsoleSwitch] instead of the value text. */
|
||||
val toggled: Boolean?,
|
||||
val adjust: (Int) -> Unit,
|
||||
val activate: () -> Unit,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun GamepadAddHostScreen(
|
||||
onAdd: (name: String, address: String, port: Int) -> Unit,
|
||||
@@ -97,11 +78,6 @@ fun GamepadAddHostScreen(
|
||||
editHost: KnownHost? = null,
|
||||
suggestedMacs: List<String> = emptyList(),
|
||||
onSave: ((KnownHost) -> Unit)? = null,
|
||||
/**
|
||||
* The profile catalog, for the edit form's binding row. Empty (the default) simply omits that
|
||||
* row — which is also what a device with no profiles yet gets.
|
||||
*/
|
||||
profiles: List<StreamProfile> = emptyList(),
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val context = LocalContext.current
|
||||
@@ -113,15 +89,6 @@ fun GamepadAddHostScreen(
|
||||
var address by remember { mutableStateOf(editHost?.address ?: "") }
|
||||
var port by remember { mutableStateOf(editHost?.port?.toString() ?: "9777") }
|
||||
var mac by remember { mutableStateOf(editHost?.mac?.ifEmpty { suggestedMacs }?.joinToString(", ") ?: "") }
|
||||
// The two host properties the console could not reach at all until now. `copy` preserved them,
|
||||
// so nothing was ever LOST — but a couch-only user (a TV box has no touch interface to fall
|
||||
// back to) could never decide either one, which the touch edit sheet has always offered.
|
||||
var clipboard by remember(editHost) { mutableStateOf(editHost?.clipboardSync ?: true) }
|
||||
// Filtered through the live catalog, so a binding to a since-deleted profile reads as unset
|
||||
// rather than as a name nothing can resolve — the same guard the touch sheet applies.
|
||||
var boundId by remember(editHost, profiles) {
|
||||
mutableStateOf(editHost?.profileId?.takeIf { id -> profiles.any { it.id == id } })
|
||||
}
|
||||
val canAdd = address.isNotBlank() && (port.toIntOrNull() ?: 0) > 0
|
||||
fun commit() {
|
||||
if (isEdit && editHost != null && onSave != null) {
|
||||
@@ -131,8 +98,6 @@ fun GamepadAddHostScreen(
|
||||
address = address.trim(),
|
||||
port = port.toIntOrNull() ?: editHost.port,
|
||||
mac = KnownHostStore.parseMacs(mac),
|
||||
clipboardSync = clipboard,
|
||||
profileId = boundId,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
@@ -170,43 +135,7 @@ fun GamepadAddHostScreen(
|
||||
add(Field("port", "Port", port, "9777"))
|
||||
if (isEdit) add(Field("mac", "Wake MAC", mac, "auto-filled when the host is seen"))
|
||||
}
|
||||
// The switch/choice rows, between the text fields and the action. Only in EDIT mode: both edit
|
||||
// properties a host only has once it has been saved.
|
||||
val extras = buildList {
|
||||
if (isEdit) {
|
||||
add(
|
||||
ExtraRow(
|
||||
label = "Shared clipboard",
|
||||
value = if (clipboard) "On" else "Off",
|
||||
toggled = clipboard,
|
||||
// Directional = state-targeted, so holding a direction can't oscillate — the
|
||||
// same rule the settings toggles and the pin picker use.
|
||||
adjust = { d -> clipboard = d > 0 },
|
||||
activate = { clipboard = !clipboard },
|
||||
),
|
||||
)
|
||||
if (profiles.isNotEmpty()) {
|
||||
// "Default settings" is the absence of a binding, not a profile, so it leads the
|
||||
// ring as a null rather than being faked as an entry in the catalog.
|
||||
val options = listOf<StreamProfile?>(null) + profiles
|
||||
val idx = options.indexOfFirst { it?.id == boundId }.coerceAtLeast(0)
|
||||
fun stepTo(delta: Int) {
|
||||
val n = ((idx + delta) % options.size + options.size) % options.size
|
||||
boundId = options[n]?.id
|
||||
}
|
||||
add(
|
||||
ExtraRow(
|
||||
label = "Profile",
|
||||
value = options[idx]?.name ?: "Default settings",
|
||||
toggled = null,
|
||||
adjust = { d -> stepTo(d) },
|
||||
activate = { stepTo(1) },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val actionIndex = fields.size + extras.size // the Save/Add action sits after everything
|
||||
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 }
|
||||
@@ -223,13 +152,11 @@ fun GamepadAddHostScreen(
|
||||
"address" -> c != ' '
|
||||
else -> true
|
||||
}
|
||||
/** The focused row's extra, or null when the cursor is on a text field or the action. */
|
||||
fun focusedExtra(): ExtraRow? = extras.getOrNull(focus - fields.size)
|
||||
fun activateField() {
|
||||
when {
|
||||
focus == actionIndex -> if (canAdd) commit() else { focus = 1; openKeyboard("address") }
|
||||
focus < fields.size -> openKeyboard(fields[focus].id)
|
||||
else -> focusedExtra()?.activate()
|
||||
if (focus == actionIndex) {
|
||||
if (canAdd) commit() else { focus = 1; openKeyboard("address") }
|
||||
} else {
|
||||
openKeyboard(fields[focus].id)
|
||||
}
|
||||
}
|
||||
fun pressKey() {
|
||||
@@ -252,11 +179,7 @@ fun GamepadAddHostScreen(
|
||||
when (dir) {
|
||||
NavDir.UP -> if (focus > 0) focus--
|
||||
NavDir.DOWN -> if (focus < actionIndex) focus++
|
||||
// Left/right step the switch and the profile ring, exactly as they step a
|
||||
// settings row. On a text field or the action they still do nothing — there is
|
||||
// no value there to walk.
|
||||
NavDir.LEFT -> focusedExtra()?.adjust(-1)
|
||||
NavDir.RIGHT -> focusedExtra()?.adjust(1)
|
||||
else -> {}
|
||||
}
|
||||
} else {
|
||||
when (dir) {
|
||||
@@ -291,17 +214,15 @@ fun GamepadAddHostScreen(
|
||||
// visible (stacked, the keyboard covered the whole short screen). The legend is NOT put
|
||||
// under the keyboard here — it floats at the same fixed bottom-left spot as everywhere.
|
||||
Row(
|
||||
Modifier.fillMaxSize().consoleSafeArea().padding(start = ConsoleEdgeInset, end = 20.dp, top = 8.dp, bottom = 8.dp),
|
||||
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().widthIn(max = 620.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
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) } }
|
||||
extras.forEachIndexed { i, e -> ExtraRowView(e, focused = false) { onFieldClick(fields.size + i) } }
|
||||
AddActionRow(actionLabel, enabled = canAdd, focused = false) { onAddClick() }
|
||||
Spacer(Modifier.height(64.dp)) // clear the floating legend at bottom-left
|
||||
}
|
||||
@@ -315,11 +236,9 @@ fun GamepadAddHostScreen(
|
||||
} else {
|
||||
// Portrait (or landscape not typing): the FORM SCROLLS so the Add button is never
|
||||
// compressed by the keyboard; the keyboard sits below it; the legend floats (fixed).
|
||||
Column(Modifier.fillMaxSize().consoleSafeArea().padding(horizontal = ConsoleEdgeInset)) {
|
||||
Column(Modifier.fillMaxSize().systemBarsPadding().padding(horizontal = ConsoleEdgeInset)) {
|
||||
Column(
|
||||
// Same 620 dp cap as the settings rows: a field stretched across a wide
|
||||
// landscape phone is a ribbon, not an input.
|
||||
Modifier.weight(1f).widthIn(max = 620.dp).verticalScroll(rememberScrollState()),
|
||||
Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ConsoleHeader(title, horizontalInset = false)
|
||||
@@ -332,11 +251,6 @@ fun GamepadAddHostScreen(
|
||||
)
|
||||
}
|
||||
fields.forEachIndexed { i, f -> FieldRow(f, focused = focus == i && editing == null, editing = editing == f.id) { onFieldClick(i) } }
|
||||
extras.forEachIndexed { i, e ->
|
||||
ExtraRowView(e, focused = focus == fields.size + i && editing == null) {
|
||||
onFieldClick(fields.size + i)
|
||||
}
|
||||
}
|
||||
AddActionRow(actionLabel, enabled = canAdd, focused = focus == actionIndex && editing == null) { onAddClick() }
|
||||
Spacer(Modifier.height(72.dp)) // last field clears the floating legend when scrolled
|
||||
}
|
||||
@@ -354,7 +268,7 @@ fun GamepadAddHostScreen(
|
||||
// open or not), so opening the keyboard never relocates it below the keys. Backdrop-blurred.
|
||||
Box(
|
||||
Modifier.align(Alignment.BottomStart)
|
||||
.consoleLegendInsets(landscape)
|
||||
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(
|
||||
@@ -401,7 +315,7 @@ private fun TvAddHostForm(
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.consoleSafeArea()
|
||||
.systemBarsPadding()
|
||||
.padding(horizontal = 56.dp, vertical = 36.dp)
|
||||
.widthIn(max = 720.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
@@ -452,18 +366,14 @@ private fun rowCols(row: Int): Int = if (row < KB_ACTIONS_ROW) KB_CHAR_ROWS[row]
|
||||
private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -> Unit) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val visuals = animateConsoleFocus(active = focused || editing, editing = editing)
|
||||
// The caret keeps its slot and only fades, like the settings rows' chevrons. Appending it on
|
||||
// `editing` shoved the whole value left the instant the keyboard opened — the same
|
||||
// layout-moves-under-focus bug the settings detail line had, one screen over.
|
||||
val caretAlpha by animateFloatAsState(
|
||||
if (editing) 1f else 0f,
|
||||
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
|
||||
label = "caret",
|
||||
)
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.consoleGlass(ConsoleShape.Row, visuals)
|
||||
.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,
|
||||
@@ -477,61 +387,7 @@ private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(" |", color = ink.accent, modifier = Modifier.graphicsLayer { alpha = caretAlpha })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A switch or stepped-choice row of the edit form. Deliberately the settings screen's row in
|
||||
* miniature — same glass, same end-aligned value slot, same `ConsoleSwitch` — because it IS a
|
||||
* settings row: it edits a stored property with left/right, and a user who has met one has met
|
||||
* both.
|
||||
*/
|
||||
@Composable
|
||||
private fun ExtraRowView(row: ExtraRow, focused: Boolean, onClick: () -> Unit) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val visuals = animateConsoleFocus(active = focused)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.consoleGlass(ConsoleShape.Row, visuals)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onClick,
|
||||
)
|
||||
.semantics(mergeDescendants = true) {
|
||||
role = if (row.toggled != null) Role.Switch else Role.Button
|
||||
contentDescription = "${row.label}, ${row.value}"
|
||||
row.toggled?.let {
|
||||
toggleableState = if (it) ToggleableState.On else ToggleableState.Off
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
row.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = ink.fg,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
if (row.toggled != null) {
|
||||
ConsoleSwitch(on = row.toggled, focused = focused)
|
||||
} else {
|
||||
Text(
|
||||
row.value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = ink.fg(if (focused) 1f else 0.6f),
|
||||
textAlign = TextAlign.End,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (editing) Text(" |", color = ink.accent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -539,15 +395,19 @@ private fun ExtraRowView(row: ExtraRow, focused: Boolean, onClick: () -> Unit) {
|
||||
private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onClick: () -> Unit) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val visuals = animateConsoleFocus(active = focused)
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
val labelColor by animateColorAsState(
|
||||
if (enabled) ink.accent else ink.fg(0.35f),
|
||||
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
|
||||
tween(160),
|
||||
label = "addLabel",
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.consoleGlass(ConsoleShape.Row, visuals)
|
||||
.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,
|
||||
@@ -570,16 +430,14 @@ private fun KeyboardGrid(
|
||||
onKey: (Int, Int) -> Unit,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val shape = ConsoleShape.Keyboard
|
||||
val shape = RoundedCornerShape(20.dp)
|
||||
val gap = if (compact) 5.dp else 7.dp
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 640.dp)
|
||||
.clip(shape)
|
||||
// Palette glass, lifted a touch above a row's: the keyboard is a slab the keys sit on,
|
||||
// and a hardcoded white wash was the one surface a pale palette couldn't recolour.
|
||||
.background(ink.glass.copy(alpha = (ink.glass.alpha * 1.5f).coerceAtMost(1f)))
|
||||
.background(Color(0x1FFFFFFF))
|
||||
.border(1.dp, ink.fg(0.12f), shape)
|
||||
.padding(start = 12.dp, end = 12.dp, top = if (compact) 8.dp else 12.dp, bottom = 12.dp + bottomInset),
|
||||
verticalArrangement = Arrangement.spacedBy(gap),
|
||||
@@ -609,13 +467,11 @@ private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier:
|
||||
tween(90),
|
||||
label = "keyBg",
|
||||
)
|
||||
// `onAccent`, not black: a pale palette's accent can be light enough that black-on-it is the
|
||||
// unreadable combination, and the palette already resolved which way that goes.
|
||||
val fg by animateColorAsState(if (focused) ink.onAccent else ink.fg, tween(90), label = "keyFg")
|
||||
val fg by animateColorAsState(if (focused) Color.Black else ink.fg, tween(90), label = "keyFg")
|
||||
Box(
|
||||
modifier = modifier
|
||||
.height(if (compact) 34.dp else 44.dp)
|
||||
.clip(ConsoleShape.Keycap)
|
||||
.clip(RoundedCornerShape(9.dp))
|
||||
.background(bg)
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
|
||||
@@ -1,348 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.graphics.RuntimeShader
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.withInfiniteAnimationFrameMillis
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ShaderBrush
|
||||
import java.util.Locale
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.max
|
||||
import kotlin.math.sin
|
||||
|
||||
// The living console backdrop, in two renderings of ONE design.
|
||||
//
|
||||
// On API 33+ this is the desktop console's actual field: `pf-console-ui`'s `mesh_sksl`
|
||||
// (library.rs) ported to AGSL — a 4×4 bicubic colour mesh warped by four drifting interior points,
|
||||
// swayed ±8° in hue, vignetted and scrimmed. AGSL is the SkSL subset Android 13 ships, so the
|
||||
// shader body is very nearly the same source, and `GamepadPalette.meshColors` is literally the same
|
||||
// 16-cell table the Rust samples. Below 33 (`RuntimeShader` is 33+) the field falls back to four
|
||||
// drifting radial blobs sampled from the same palette ramp — an approximation of the same look, and
|
||||
// the honest one: emulating a mesh gradient with bitmaps would cost more than it bought.
|
||||
//
|
||||
// Either way it is AMBIENCE, never content: it runs full-bleed under the cutout and the system bars,
|
||||
// and every console screen's chrome floats over it.
|
||||
|
||||
/**
|
||||
* The console backdrop. [calm] is what the FORM screens (settings, add-host) wear: the pools dim
|
||||
* onto the ground so the glass rows keep real colour and luminance without the launcher's contrast.
|
||||
* Motion is identical either way on purpose — only the contrast differs, so moving between screens
|
||||
* can't make the field jump.
|
||||
*
|
||||
* Honours the system's "remove animations" accessibility setting by freezing at a fixed phase, the
|
||||
* same courtesy the Apple client pays Reduce Motion — which doubles as the deterministic mode the
|
||||
* screenshot harness captures in, since the phase is just a uniform.
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadAuroraBackground(modifier: Modifier = Modifier, calm: Boolean = false) {
|
||||
val palette = LocalGamepadPalette.current
|
||||
val animated = animationsEnabled()
|
||||
// Compiled once per palette and cached process-wide: stepping the Background row recolours the
|
||||
// field under the very row being stepped, and a shader compile per D-pad press would be felt on
|
||||
// a TV box. A compile failure resolves null and takes the blob path — a vendor Skia that
|
||||
// rejects the source must not take the console UI down with it.
|
||||
val shader = if (Build.VERSION.SDK_INT >= 33) {
|
||||
remember(palette.id) { meshShaderFor(palette) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (shader != null) {
|
||||
MeshAurora(modifier, shader, calm, animated)
|
||||
} else {
|
||||
BlobAurora(modifier, palette, calm, animated)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The backdrop for the console FORM screens (settings, add-host) — the launcher's own living field
|
||||
* at `calm`, so no screen in the console UI is backed by a still image and the palette setting
|
||||
* reaches every one of them. Mirrors the Apple client's GamepadFormBackground and the desktop's
|
||||
* single `calm` uniform.
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadFormBackground(modifier: Modifier = Modifier) {
|
||||
GamepadAuroraBackground(modifier, calm = true)
|
||||
}
|
||||
|
||||
// --- The mesh field (API 33+) ---------------------------------------------------------------
|
||||
|
||||
/** The phase a frozen (reduce-motion / screenshot) field is drawn at — the desktop's t = 0. */
|
||||
private const val FROZEN_PHASE = 0f
|
||||
|
||||
@RequiresApi(33)
|
||||
@Composable
|
||||
private fun MeshAurora(
|
||||
modifier: Modifier,
|
||||
shader: RuntimeShader,
|
||||
calm: Boolean,
|
||||
animated: Boolean,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val palette = LocalGamepadPalette.current
|
||||
val brush = remember(shader) { ShaderBrush(shader) }
|
||||
// Real monotonic seconds, not a wrapping sweep: the four warp points and the hue sway run at
|
||||
// mutually irrational rates (periods ~90–130 s), so no loop length exists that would rejoin
|
||||
// them seamlessly — which is exactly why the desktop feeds its shader elapsed time too. Frozen
|
||||
// under reduce-motion, where it also makes the field deterministic for a screenshot.
|
||||
val time by produceState(FROZEN_PHASE, animated) {
|
||||
if (!animated) return@produceState
|
||||
while (true) {
|
||||
withInfiniteAnimationFrameMillis { value = it / 1000f }
|
||||
}
|
||||
}
|
||||
val (gr, gg, gb) = palette.ground
|
||||
Canvas(modifier) {
|
||||
// Uniforms are set per draw, not per recomposition: `time` is read HERE, inside the draw
|
||||
// scope, so a new frame invalidates the draw only — the composition never re-runs.
|
||||
shader.setFloatUniform("u_res", size.width, size.height)
|
||||
shader.setFloatUniform("u_tc", time, if (calm) 1f else 0f)
|
||||
// The calm lift: the palette's ground scaled to 0.4, what the field flattens toward.
|
||||
shader.setFloatUniform(
|
||||
"u_lift",
|
||||
(gr * 0.4).toFloat(), (gg * 0.4).toFloat(), (gb * 0.4).toFloat(), 0f,
|
||||
)
|
||||
// Where the vignette and scrims tend, and how hard — black at full strength on a dark
|
||||
// field, white at well under half on a pale one (mixing a pastel toward white at the dark
|
||||
// field's strength bleaches the chroma straight out of the gradient).
|
||||
shader.setFloatUniform(
|
||||
"u_scrim",
|
||||
ink.shade.red, ink.shade.green, ink.shade.blue, ink.shadeScale,
|
||||
)
|
||||
drawRect(brush)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiled mesh shaders by palette id — at most the 13 shipped palettes, so it is bounded by the
|
||||
* table rather than by use. Touched only from the composition (main) thread.
|
||||
*/
|
||||
private val meshShaders = HashMap<String, RuntimeShader?>()
|
||||
|
||||
@RequiresApi(33)
|
||||
private fun meshShaderFor(palette: GamepadPalette): RuntimeShader? =
|
||||
meshShaders.getOrPut(palette.id) {
|
||||
runCatching { RuntimeShader(meshAgsl(palette.meshColors)) }.getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a shader constant. `Locale.ROOT` is not optional: `String.format` on a German-locale
|
||||
* device emits `0,075`, which is a syntax error in the shader source and would take the whole
|
||||
* backdrop out on exactly the devices it was authored on. `%f` also keeps a very small ramp value
|
||||
* out of exponent notation, which SkSL would still parse but nobody would enjoy reading.
|
||||
*/
|
||||
private fun n(v: Double): String = String.format(Locale.ROOT, "%.6f", v)
|
||||
|
||||
/**
|
||||
* The mesh gradient as AGSL, the palette baked into the source and resolution/time/calm/scrim left
|
||||
* as uniforms — the direct port of `pf-console-ui`'s `mesh_sksl`, kept structurally line-for-line
|
||||
* with it so the two can be diffed. A smooth bicubic blend of the 16 colours (a separable
|
||||
* cubic-Bézier basis in x then y, the fragment-shader analogue of SwiftUI's
|
||||
* `MeshGradient(smoothsColors: true)`), four interior points driving a bounded domain warp, then
|
||||
* the ±8° hue sway, an elliptical vignette and the vertical legibility scrim.
|
||||
*/
|
||||
private fun meshAgsl(colors: List<Triple<Double, Double, Double>>): String {
|
||||
fun c(i: Int): String {
|
||||
val (r, g, b) = colors[i]
|
||||
return "float3(${n(r)}, ${n(g)}, ${n(b)})"
|
||||
}
|
||||
// The four interior-point domain-warp accumulators. SIG (0.30) sets how far each point's pull
|
||||
// reaches; the warp is the weight-normalised average displacement, so |warp| ≤ max|amp|.
|
||||
val warp = buildString {
|
||||
for (p in GamepadPalette.MESH_INTERIOR) {
|
||||
append(" q = uv - float2(${n(p.x)}, ${n(p.y)});\n")
|
||||
append(" ww = exp(-dot(q, q) / (2.0 * 0.30 * 0.30));\n")
|
||||
append(" d = float2(${n(p.amp)} * sin(tt * ${n(p.sx)} + ${n(p.phase)}),\n")
|
||||
append(" ${n(p.amp)} * cos(tt * ${n(p.sy)} + ${n(p.phase)} * 1.3));\n")
|
||||
append(" wsum += d * ww; wtot += ww;\n")
|
||||
}
|
||||
}
|
||||
return """
|
||||
uniform float2 u_res;
|
||||
// x = seconds since this field started, y = the calm mix (0 launcher, 1 form).
|
||||
uniform float2 u_tc;
|
||||
// rgb = the palette's corner colour scaled for the calm lift; a is unused.
|
||||
uniform float4 u_lift;
|
||||
// rgb = what the vignette and scrims tend toward, a = how hard.
|
||||
uniform float4 u_scrim;
|
||||
|
||||
// Cubic-Bézier basis over four control values — the smooth 4-point blend per axis.
|
||||
float bz(float t, float a, float b, float c, float d) {
|
||||
float u = 1.0 - t;
|
||||
return u*u*u*a + 3.0*u*u*t*b + 3.0*u*t*t*c + t*t*t*d;
|
||||
}
|
||||
float3 bz3(float t, float3 a, float3 b, float3 c, float3 d) {
|
||||
return float3(bz(t, a.r, b.r, c.r, d.r), bz(t, a.g, b.g, c.g, d.g), bz(t, a.b, b.b, c.b, d.b));
|
||||
}
|
||||
// Hue rotation about the grey axis (Rodrigues) — the ±8° warm/cool sway. The desktop's `cross(k,
|
||||
// col)` is written out here: with k = (c, c, c) it collapses to c·(b-g, r-b, g-r), which needs no
|
||||
// builtin at all — AGSL's function set is a subset of SkSL's and not worth betting the field on.
|
||||
float3 hue(float3 col, float a) {
|
||||
float c = 0.5773503;
|
||||
float cs = cos(a); float sn = sin(a);
|
||||
float3 kx = c * float3(col.b - col.g, col.r - col.b, col.g - col.r);
|
||||
return col*cs + kx*sn + float3(c) * dot(float3(c), col) * (1.0 - cs);
|
||||
}
|
||||
|
||||
half4 main(float2 xy) {
|
||||
float tt = u_tc.x; float calm = u_tc.y;
|
||||
float2 uv = xy / u_res;
|
||||
// Interior control points wander → bounded domain warp (pools follow them).
|
||||
float2 wsum = float2(0.0); float wtot = 0.0; float2 q; float ww; float2 d;
|
||||
$warp
|
||||
uv = clamp(uv - wsum / (wtot + 0.0001), 0.0, 1.0);
|
||||
|
||||
// Bicubic blend of the 16 mesh colours: cubic-Bézier in x per row, then in y.
|
||||
float3 r0 = bz3(uv.x, ${c(0)}, ${c(1)}, ${c(2)}, ${c(3)});
|
||||
float3 r1 = bz3(uv.x, ${c(4)}, ${c(5)}, ${c(6)}, ${c(7)});
|
||||
float3 r2 = bz3(uv.x, ${c(8)}, ${c(9)}, ${c(10)}, ${c(11)});
|
||||
float3 r3 = bz3(uv.x, ${c(12)}, ${c(13)}, ${c(14)}, ${c(15)});
|
||||
float3 col = bz3(uv.y, r0, r1, r2, r3);
|
||||
|
||||
col = hue(col, sin(tt * 0.021) * 0.1396263);
|
||||
|
||||
// Calm: flatten the field toward its own corner colour — the pools dim and the corners lift,
|
||||
// so a form screen keeps real colour under its glass rows while losing the launcher's
|
||||
// contrast. Motion is untouched.
|
||||
col = mix(col, col * 0.60 + u_lift.rgb, calm);
|
||||
|
||||
// Elliptical vignette: clear at r=0.25 → scrim·0.42 at r=1.15. Halved under calm — a
|
||||
// launcher's cards sit in the pooled centre, but a form screen's rows run out toward the
|
||||
// edges, where crushing them just eats the list.
|
||||
float2 e = (xy / u_res - 0.5) * 2.0;
|
||||
float vig = clamp((length(e) - 0.25) / 0.90, 0.0, 1.0) * mix(0.42, 0.21, calm) * u_scrim.a;
|
||||
col = mix(col, u_scrim.rgb, vig);
|
||||
|
||||
// Vertical legibility scrim for the pinned heading + the floating legend.
|
||||
float v = xy.y / u_res.y;
|
||||
float s = v < 0.32 ? mix(0.38, 0.06, v / 0.32)
|
||||
: v < 0.68 ? mix(0.06, 0.08, (v - 0.32) / 0.36)
|
||||
: mix(0.08, 0.40, (v - 0.68) / 0.32);
|
||||
col = mix(col, u_scrim.rgb, s * u_scrim.a);
|
||||
|
||||
return half4(half3(col), 1.0);
|
||||
}
|
||||
"""
|
||||
}
|
||||
|
||||
// --- The blob field (API 28–32 fallback) -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* One drifting blob of the fallback field: where it sits, how far it wanders, and how fast. Integer
|
||||
* [sx]/[sy] keep the loop seamless at wrap. The COLOUR is the palette's, taken from its ramp at
|
||||
* draw time, so the field always shows several of that palette's tones at once.
|
||||
*/
|
||||
private class AuroraBlob(
|
||||
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(0.30f, 0.26f, 0.16f, 0.10f, 1, 1, 0.0f, 0.62f, 0.55f),
|
||||
AuroraBlob(0.78f, 0.68f, 0.13f, 0.14f, 1, 2, 2.4f, 0.68f, 0.58f),
|
||||
AuroraBlob(0.16f, 0.82f, 0.12f, 0.09f, 2, 1, 4.1f, 0.52f, 0.42f),
|
||||
AuroraBlob(0.72f, 0.14f, 0.10f, 0.08f, 1, 3, 1.2f, 0.48f, 0.40f),
|
||||
)
|
||||
|
||||
/**
|
||||
* Soft blobs from the palette's ramp drifting over its ground on slow, seamless loops, finished
|
||||
* with a centre-pooling vignette and top/bottom legibility scrims. What API 28–32 sees in place of
|
||||
* the mesh: the same colour families, the same "ambience, never content" role, and the same
|
||||
* [GamepadPalette] setting recolours it.
|
||||
*/
|
||||
@Composable
|
||||
private fun BlobAurora(
|
||||
modifier: Modifier,
|
||||
palette: GamepadPalette,
|
||||
calm: Boolean,
|
||||
animated: Boolean,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
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 swept by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = (2 * PI).toFloat(),
|
||||
animationSpec = infiniteRepeatable(tween(96_000, easing = LinearEasing), RepeatMode.Restart),
|
||||
label = "angle",
|
||||
)
|
||||
val angle = if (animated) swept else 0f
|
||||
val tones = palette.blobColors
|
||||
val ground = palette.groundColor
|
||||
// Where the scrims tend, and how hard. Mixing a PALE field toward white at the dark field's
|
||||
// strength bleaches the chroma straight out of the gradient, so a pale palette gets under
|
||||
// half — the same scrim strength the desktop console's shader carries.
|
||||
val scrim = if (palette.light) ink.fg else Color.Black
|
||||
val strength = if (palette.light) 0.45f else 1f
|
||||
Canvas(modifier) {
|
||||
drawRect(ground)
|
||||
val span = max(size.width, size.height)
|
||||
for ((i, b) in auroraBlobs.withIndex()) {
|
||||
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
|
||||
// Calm scales each blob's contribution rather than dimming the whole canvas: the
|
||||
// ground stays put and only the pools come down to meet it, which is the same "lower
|
||||
// the contrast, keep the colour" the desktop console's `calm` uniform does.
|
||||
val alpha = if (calm) b.alpha * 0.62f else b.alpha
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(tones[i].copy(alpha = alpha), Color.Transparent),
|
||||
center = Offset(cx, cy),
|
||||
radius = r,
|
||||
),
|
||||
center = Offset(cx, cy),
|
||||
radius = r,
|
||||
// Additive only works over a DARK ground; over a pale one every blob
|
||||
// saturates to white and the field turns grey. Pale palettes tint instead.
|
||||
blendMode = if (palette.light) BlendMode.SrcOver else BlendMode.Plus,
|
||||
)
|
||||
}
|
||||
// Cinematic vignette: pool light centre, settle the corners toward the scrim. Halved under
|
||||
// calm: a launcher's cards sit in the pooled centre, but a form screen's rows run out
|
||||
// toward the edges, where crushing them just eats the list.
|
||||
drawRect(
|
||||
Brush.radialGradient(
|
||||
colors = listOf(
|
||||
Color.Transparent,
|
||||
scrim.copy(alpha = (if (calm) 0.22f else 0.44f) * strength),
|
||||
),
|
||||
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 scrim.copy(alpha = 0.40f * strength),
|
||||
0.30f to scrim.copy(alpha = 0.05f * strength),
|
||||
0.70f to scrim.copy(alpha = 0.06f * strength),
|
||||
1.0f to scrim.copy(alpha = 0.42f * strength),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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
|
||||
@@ -17,6 +18,7 @@ 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
|
||||
@@ -43,6 +45,7 @@ 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
|
||||
@@ -104,13 +107,18 @@ fun GamepadDialog(
|
||||
// the focused button pulls itself into view (see DialogButton), so D-pad navigation always shows
|
||||
// the current action even when the stack scrolls.
|
||||
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
|
||||
ConsoleModal {
|
||||
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)
|
||||
.consoleCard()
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(Color(0xF01A1730))
|
||||
.border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp))
|
||||
.padding(28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
@@ -142,11 +150,7 @@ private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enab
|
||||
// that's scrolled out of a short window, pull it into view (no-op when already visible).
|
||||
val intoView = remember { BringIntoViewRequester() }
|
||||
LaunchedEffect(focused) { if (focused) intoView.bringIntoView() }
|
||||
val focus by animateFloatAsState(
|
||||
if (focused) 1f else 0f,
|
||||
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
|
||||
label = "btnFocus",
|
||||
)
|
||||
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 {
|
||||
@@ -154,30 +158,32 @@ private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enab
|
||||
primary -> ink.accent(0.20f)
|
||||
else -> ink.glass
|
||||
},
|
||||
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
|
||||
tween(160),
|
||||
label = "btnBg",
|
||||
)
|
||||
val fg by animateColorAsState(
|
||||
when {
|
||||
!enabled -> ink.fg(0.35f)
|
||||
// On the accent, not on the field — a pale palette's accent decides this, not the ink.
|
||||
focused -> ink.onAccent
|
||||
focused -> ink.fg
|
||||
primary -> ink.accent
|
||||
else -> ink.fg(0.85f)
|
||||
},
|
||||
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
|
||||
tween(160),
|
||||
label = "btnFg",
|
||||
)
|
||||
val borderColor by animateColorAsState(
|
||||
ink.fg(if (focused) 0.3f else 0.08f),
|
||||
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
|
||||
Color.White.copy(alpha = if (focused) 0.3f else 0.08f),
|
||||
tween(160),
|
||||
label = "btnBorder",
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.bringIntoViewRequester(intoView)
|
||||
.consoleGlass(ConsoleShape.Row, ConsoleFocusVisuals(scale, bg, borderColor, focus))
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.clip(shape)
|
||||
.background(bg)
|
||||
.border(1.dp, borderColor, shape)
|
||||
.clickable(
|
||||
enabled = enabled,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
@@ -299,13 +305,18 @@ fun GamepadPinHostsDialog(
|
||||
},
|
||||
)
|
||||
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
|
||||
ConsoleModal {
|
||||
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)
|
||||
.consoleCard()
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(Color(0xF01A1730))
|
||||
.border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp))
|
||||
.padding(28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
@@ -357,11 +368,15 @@ private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: ()
|
||||
// landscape window pulls itself into view.
|
||||
val intoView = remember { BringIntoViewRequester() }
|
||||
LaunchedEffect(focused) { if (focused) intoView.bringIntoView() }
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.bringIntoViewRequester(intoView)
|
||||
.consoleGlass(ConsoleShape.Row, visuals)
|
||||
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
|
||||
.clip(shape)
|
||||
.background(visuals.background)
|
||||
.border(1.dp, visuals.border, shape)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
@@ -383,6 +398,157 @@ private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: ()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Console counterpart of [SpeedTestDialog]. Same measurement, same targeting rule — a TV box on a
|
||||
* powerline adapter is exactly the machine whose link is worth measuring, so this belongs on the
|
||||
* couch surface too, even though profile EDITING doesn't.
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadSpeedTestDialog(
|
||||
hostName: String,
|
||||
target: SpeedTestTarget,
|
||||
phase: SpeedTestPhase,
|
||||
onApply: (toProfile: Boolean) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val done = phase as? SpeedTestPhase.Done
|
||||
GamepadDialog(
|
||||
title = "Network speed test",
|
||||
onDismiss = onDismiss,
|
||||
actions = buildList {
|
||||
if (done != null) {
|
||||
add(
|
||||
DialogAction(
|
||||
when (target) {
|
||||
SpeedTestTarget.Global -> "Apply"
|
||||
is SpeedTestTarget.Profile -> "Apply to “${target.profile.name}”"
|
||||
is SpeedTestTarget.Ask -> "Set in “${target.profile.name}”"
|
||||
},
|
||||
primary = true,
|
||||
) { onApply(true) },
|
||||
)
|
||||
if (target is SpeedTestTarget.Ask) {
|
||||
add(DialogAction("Set as default") { onApply(false) })
|
||||
}
|
||||
}
|
||||
add(DialogAction("Close", primary = done == null, onClick = onDismiss))
|
||||
},
|
||||
) {
|
||||
DialogText(hostName)
|
||||
when (phase) {
|
||||
SpeedTestPhase.Connecting -> DialogText("Connecting…")
|
||||
SpeedTestPhase.Measuring ->
|
||||
DialogText("Measuring — the host is bursting test traffic for two seconds.")
|
||||
is SpeedTestPhase.Failed -> DialogText(phase.message)
|
||||
is SpeedTestPhase.Done -> {
|
||||
DialogText(
|
||||
"%.0f Mbit/s measured · %.1f %% loss".format(phase.measuredMbps, phase.lossPct),
|
||||
)
|
||||
DialogText("Recommended bitrate: %.0f Mbit/s".format(phase.recommendedMbps))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
val ink = LocalGamepadInk.current
|
||||
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 = ink.fg)
|
||||
Text("Approve this device on $hostLabel.", color = ink.fg)
|
||||
}
|
||||
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
|
||||
@@ -432,10 +598,11 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired:
|
||||
)
|
||||
|
||||
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
|
||||
ConsoleModal {
|
||||
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)
|
||||
.consoleCard()
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(Color(0xF01A1730)).border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp))
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(28.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
@@ -449,7 +616,7 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired:
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
repeat(4) { i -> PinSlot(digits[i], focused = slot == i && !pairing) }
|
||||
}
|
||||
err?.let { Text(it, color = ink.danger, style = MaterialTheme.typography.bodyMedium) }
|
||||
err?.let { Text(it, color = Color(0xFFE0736F), style = MaterialTheme.typography.bodyMedium) }
|
||||
DialogButton(
|
||||
label = if (pairing) "Pairing…" else "Pair",
|
||||
focused = slot == 4 && !pairing,
|
||||
@@ -471,12 +638,6 @@ private fun PinSlot(value: Int, focused: Boolean) {
|
||||
.border(if (focused) 2.dp else 1.dp, if (focused) ink.accent else ink.fg(0.1f), shape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
value.toString(),
|
||||
fontSize = 30.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = ink.fg,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
Text(value.toString(), fontSize = 30.sp, fontWeight = FontWeight.Bold, color = ink.fg, fontFamily = FontFamily.Monospace)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -19,11 +17,11 @@ 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.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
@@ -35,7 +33,6 @@ 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
|
||||
@@ -47,7 +44,6 @@ import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -59,7 +55,6 @@ import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.cos
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// The gamepad-driven home — the Android mirror of the Apple client's GamepadHomeView: a distinct,
|
||||
@@ -67,12 +62,6 @@ import kotlinx.coroutines.launch
|
||||
// active. A center-snapping carousel of hosts (saved first, then discovered, then a trailing Add
|
||||
// Host tile), driven from the couch: A connects, X opens Settings, Y opens a saved host's library.
|
||||
|
||||
/**
|
||||
* How far a fully off-centre card turns away from the viewer, in radians (~48°). Never rendered as
|
||||
* a rotation — see the projection note at the call site.
|
||||
*/
|
||||
private const val CARD_TURN_RAD = 0.838f
|
||||
|
||||
/** One navigable launcher tile — a saved host, a discovered-but-unsaved host, or the Add Host action. */
|
||||
class HomeTile(
|
||||
val id: String,
|
||||
@@ -86,27 +75,15 @@ class HomeTile(
|
||||
val knownHost: KnownHost? = null, // set for saved hosts → enables the library (Y)
|
||||
/**
|
||||
* Set when this tile is a PINNED host+profile combination rather than the host's own tile.
|
||||
* A pin is a shortcut, not a second host: the host-level actions (wake, edit, forget) belong
|
||||
* to the host's own tile, and this one offers only Unpin. The library is NOT one of those —
|
||||
* it is a way to start this card (a connect with a title picked first), so a pinned tile opens
|
||||
* its own shelf and every launch off it carries this profile.
|
||||
* A pin is a shortcut, not a second host: the host-level actions (wake, edit, forget, library)
|
||||
* belong to the host's own tile, and this one offers only Unpin.
|
||||
*/
|
||||
val pinnedProfileId: String? = null,
|
||||
/**
|
||||
* The profile a press will actually connect with — the host's binding, or the pin's own
|
||||
* profile. Rendered as a chip on the card rather than appended to the subtitle: on a PIN card
|
||||
* the profile is the entire reason the card exists, and a card that only whispers it in grey
|
||||
* body text can't say that. Matches the Apple client's tile.
|
||||
*/
|
||||
val profileName: String? = null,
|
||||
/** The profile's `#RRGGBB` chip colour, if it set one. */
|
||||
val profileAccent: Color? = null,
|
||||
val activate: () -> Unit,
|
||||
) {
|
||||
// Any SAVED host offers the library (matches Apple), pinned cards included — the fetch itself
|
||||
// returns a clear "pair first" message if the host hasn't authorized this device for its
|
||||
// management API.
|
||||
val hasLibrary: Boolean get() = knownHost != null
|
||||
// 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 && pinnedProfileId == null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,16 +118,6 @@ fun GamepadHome(
|
||||
LaunchedEffect(pagerState.settledPage) { navTarget = pagerState.settledPage }
|
||||
val current = tiles.getOrNull(navTarget)
|
||||
|
||||
// Bumped on every confirm — the centred card dips under the press and springs back, so A reads
|
||||
// as a button being pushed rather than as a screen simply changing.
|
||||
var pressToken by remember { mutableIntStateOf(0) }
|
||||
val press = remember { Animatable(1f) }
|
||||
LaunchedEffect(pressToken) {
|
||||
if (pressToken == 0) return@LaunchedEffect
|
||||
press.animateTo(0.97f, ConsoleMotion.ease(70))
|
||||
press.animateTo(1f, spring(dampingRatio = 0.45f, stiffness = Spring.StiffnessMedium))
|
||||
}
|
||||
|
||||
GamepadNavEffect(
|
||||
active = navActive && tiles.isNotEmpty(),
|
||||
onMove = { dir ->
|
||||
@@ -160,8 +127,7 @@ fun GamepadHome(
|
||||
scope.launch { pagerState.animateScrollToPage(target) }
|
||||
}
|
||||
},
|
||||
// A / D-pad-center → Connect
|
||||
onActivate = { pressToken++; tiles.getOrNull(navTarget)?.let(onActivate) },
|
||||
onActivate = { tiles.getOrNull(navTarget)?.let(onActivate) }, // A / D-pad-center → Connect
|
||||
onSecondary = { // Y (gamepad) → Library
|
||||
tiles.getOrNull(navTarget)?.takeIf { libraryEnabled && it.hasLibrary }?.let(onOpenLibrary)
|
||||
},
|
||||
@@ -179,9 +145,9 @@ fun GamepadHome(
|
||||
// way. Each hint is also TAPPABLE (touch hatch).
|
||||
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: false
|
||||
val connectLabel = if (current?.isAdd == true) "Add Host" else "Connect"
|
||||
val connectAction: () -> Unit = { pressToken++; tiles.getOrNull(navTarget)?.let(onActivate) }
|
||||
val connectAction: () -> Unit = { tiles.getOrNull(navTarget)?.let(onActivate) }
|
||||
val optionsAction: () -> Unit = { current?.let(onOptions) }
|
||||
val arrowTint = PadGlyph.Arrow
|
||||
val arrowTint = Color(0xFF9A93C7)
|
||||
val hints = buildList {
|
||||
if (padIsGamepad) {
|
||||
add(PadGlyph.hint('A', connectLabel, onClick = connectAction))
|
||||
@@ -211,12 +177,7 @@ fun GamepadHome(
|
||||
val cardWidth = (maxWidth * 0.82f).coerceAtMost(360.dp)
|
||||
val cardHeight = (maxHeight * 0.56f).coerceAtMost(216.dp)
|
||||
val sidePad = ((maxWidth - cardWidth) / 2).coerceAtLeast(0.dp)
|
||||
// The carousel deliberately IGNORES the safe area (first on-glass verdict): only the
|
||||
// CENTRED card matters, and it sits mid-screen; the fanned neighbours running under
|
||||
// the hole punch is ambience, while insetting the pager CLIPPED them at the cutout
|
||||
// edge — cards visibly cut off is worse than cards behind a camera. The title and the
|
||||
// legend keep their insets; they are content.
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().systemBarsPadding()) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
pageSize = PageSize.Fixed(cardWidth),
|
||||
@@ -228,35 +189,17 @@ fun GamepadHome(
|
||||
val tile = tiles[page]
|
||||
// Real distance-from-centered (page + fractional drag), so the pop tracks the
|
||||
// live scroll: centered tile at full scale/brightness, neighbours recede + blur.
|
||||
// Signed, because which SIDE a card fans to decides which edge it turns on.
|
||||
val signed = (page - pagerState.currentPage) - pagerState.currentPageOffsetFraction
|
||||
val offset = signed.absoluteValue.coerceIn(0f, 1f)
|
||||
val offset = ((pagerState.currentPage - page) + pagerState.currentPageOffsetFraction)
|
||||
.absoluteValue.coerceIn(0f, 1f)
|
||||
GamepadHostTile(
|
||||
tile = tile,
|
||||
centred = offset < 0.5f,
|
||||
modifier = Modifier
|
||||
.graphicsLayer {
|
||||
// The press dip applies to the CENTRED card only — it is the one
|
||||
// the button acted on, and a whole carousel flinching would read
|
||||
// as the screen moving rather than a card being pressed.
|
||||
val s = lerp(1f, 0.86f, offset) * lerp(press.value, 1f, offset)
|
||||
val s = lerp(1f, 0.86f, offset)
|
||||
scaleX = s
|
||||
scaleY = s
|
||||
alpha = lerp(1f, 0.5f, offset)
|
||||
}
|
||||
.graphicsLayer {
|
||||
// The neighbours TURN away, projected rather than rendered in 3D.
|
||||
// `cos(angle)` as a horizontal squeeze IS the orthographic
|
||||
// projection of a Y-axis rotation, and hinging it on the edge the
|
||||
// card fans from is what carries the direction the rotation's sign
|
||||
// would have. The Apple client arrived here the hard way (see
|
||||
// GamepadCarousel.swift): a real `rotation3DEffect` renders the
|
||||
// card through an offscreen pass and flashed as the strip settled.
|
||||
// Affine transforms don't.
|
||||
scaleX = cos(CARD_TURN_RAD * offset)
|
||||
transformOrigin =
|
||||
TransformOrigin(if (signed > 0f) 0f else 1f, 0.5f)
|
||||
}
|
||||
// Unbounded so the depth blur isn't hard-clipped at the card's rectangle
|
||||
// (the cut-off edge). No-op below API 31; a soft blur above.
|
||||
.blur(radius = (offset * 12f).dp, edgeTreatment = BlurredEdgeTreatment.Unbounded)
|
||||
@@ -266,7 +209,6 @@ fun GamepadHome(
|
||||
indication = null,
|
||||
) {
|
||||
if (page == navTarget) {
|
||||
pressToken++
|
||||
onActivate(tile)
|
||||
} else {
|
||||
navTarget = page
|
||||
@@ -281,28 +223,20 @@ fun GamepadHome(
|
||||
// Title floats over the top (out of the carousel's layout, so the cards stay centred). Uses
|
||||
// the shared ConsoleHeader so it lines up with every other screen's heading.
|
||||
Row(
|
||||
Modifier.align(Alignment.TopStart).fillMaxWidth().consoleSafeArea()
|
||||
Modifier.align(Alignment.TopStart).fillMaxWidth().systemBarsPadding()
|
||||
.padding(end = ConsoleEdgeInset),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
// The TITLE has priority (unweighted, so it is measured at its full width first) and the
|
||||
// chip takes what is left, ellipsizing its device name. The other way round — which is
|
||||
// what a weighted header gave — a talkative controller name ("Xbox Wireless Controller")
|
||||
// ate a 360 dp portrait phone's title down to "Selec…".
|
||||
ConsoleHeader("Select a Host")
|
||||
if (controllerName != null) {
|
||||
ControllerStatusChip(controllerName, Modifier.weight(1f, fill = false))
|
||||
}
|
||||
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 system bars (the nav-bar inset made the bottom gap look oversized) but never
|
||||
// the cutout — reverse-landscape parks the punch on this very corner.
|
||||
// it ignores the safe area (the nav-bar inset made the bottom gap look oversized).
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.consoleLegendInsets(landscape)
|
||||
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(hints, hazeState = hazeState)
|
||||
@@ -310,31 +244,22 @@ fun GamepadHome(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One glass landscape console tile — bigger and bolder than the touch grid's HostCard, and cut from
|
||||
* the same [Modifier.consoleGlass] every console surface is, so a card and a settings row catch the
|
||||
* light the same way. [centred] is the carousel's own focus: the tile the pad is pointing at, which
|
||||
* earns the lift and the accent bloom.
|
||||
*/
|
||||
/** One dark-glass landscape console tile — bigger and bolder than the touch grid's HostCard. */
|
||||
@Composable
|
||||
private fun GamepadHostTile(tile: HomeTile, centred: Boolean, modifier: Modifier = Modifier) {
|
||||
private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val visuals = animateConsoleFocus(active = centred)
|
||||
// A SAVED host wears the palette's accent; a discovered one (or the Add tile) stays neutral
|
||||
// glass, so "already yours" reads before you get to the label.
|
||||
val fill = if (tile.filled) ink.accent(0.20f) else ink.glass
|
||||
val shape = RoundedCornerShape(26.dp)
|
||||
val wash = if (tile.filled) {
|
||||
Brush.verticalGradient(listOf(ink.accent(0.20f), Color(0x14100C2A)))
|
||||
} else {
|
||||
Brush.verticalGradient(listOf(Color(0x1AFFFFFF), Color(0x0DFFFFFF)))
|
||||
}
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
// The carousel already drives its own scale; the glass must not fight it with a second.
|
||||
.consoleGlass(
|
||||
ConsoleShape.Tile,
|
||||
ConsoleFocusVisuals(1f, fill, ink.fg(0.16f), visuals.focus),
|
||||
// A DASHED edge on anything not yet saved — a host found on the network, and the
|
||||
// Add tile. It is the touch grid's own convention and the Apple client's, and it
|
||||
// says "not yours yet" before the subtitle has to.
|
||||
dashed = !tile.filled,
|
||||
)
|
||||
.clip(shape)
|
||||
.background(wash)
|
||||
.border(1.dp, ink.fg(0.16f), shape)
|
||||
.padding(22.dp),
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
|
||||
@@ -366,64 +291,12 @@ private fun GamepadHostTile(tile: HomeTile, centred: Boolean, modifier: Modifier
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (tile.profileName != null) {
|
||||
ConsoleProfileChip(
|
||||
name = tile.profileName,
|
||||
accent = tile.profileAccent,
|
||||
// On a PIN card the profile is why the card exists; on a bound host's own card it
|
||||
// is a note about what a press will do. Same chip, two weights.
|
||||
prominent = tile.pinnedProfileId != null,
|
||||
modifier = Modifier.padding(top = 5.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
tile.subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = ink.fg(0.55f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The profile a card connects with, worn as a tinted capsule. The console counterpart of the touch
|
||||
* grid's own chip (`HostComponents.kt`) — same shape and the same quiet/prominent split, but inked
|
||||
* from the console palette rather than `MaterialTheme`, since it sits on the aurora.
|
||||
*
|
||||
* A profile that set no accent falls back to the palette's, not to the touch theme's primary: on a
|
||||
* moss or copper field the brand violet would be the one foreign colour on the card.
|
||||
*/
|
||||
@Composable
|
||||
private fun ConsoleProfileChip(
|
||||
name: String,
|
||||
accent: Color?,
|
||||
prominent: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val tint = accent ?: ink.accent
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(ConsoleShape.Pill)
|
||||
.background(tint.copy(alpha = if (prominent) 0.24f else 0.12f))
|
||||
.padding(horizontal = 9.dp, vertical = 3.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(Modifier.size(7.dp).clip(CircleShape).background(tint))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
name,
|
||||
style = if (prominent) {
|
||||
MaterialTheme.typography.labelLarge
|
||||
} else {
|
||||
MaterialTheme.typography.labelMedium
|
||||
},
|
||||
fontWeight = if (prominent) FontWeight.Bold else FontWeight.SemiBold,
|
||||
color = tint,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -432,13 +305,10 @@ private fun ConsoleProfileChip(
|
||||
private fun MonogramBadge(tile: HomeTile) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val shape = RoundedCornerShape(15.dp)
|
||||
// Lit from the top like every other console surface — and the unsaved badge takes the palette's
|
||||
// own accent at low opacity rather than the brand violet, which on a copper or moss field was
|
||||
// the one square of the wrong hue on the screen.
|
||||
val fill = if (tile.filled) {
|
||||
Brush.verticalGradient(listOf(ink.accent.copy(alpha = 0.92f), ink.accent))
|
||||
Brush.verticalGradient(listOf(ink.accent, ink.accent))
|
||||
} else {
|
||||
Brush.verticalGradient(listOf(ink.accent(0.20f), ink.accent(0.14f)))
|
||||
Brush.verticalGradient(listOf(Color(0x296656F2), Color(0x296656F2)))
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.size(52.dp).clip(shape).background(fill),
|
||||
|
||||
@@ -32,31 +32,6 @@ class GamepadInk(
|
||||
val shadeScale: Float,
|
||||
/** True when the field is pale, for the few places that branch rather than blend. */
|
||||
val isLight: Boolean,
|
||||
/**
|
||||
* The near-opaque ground a MODAL card sits on. A dialog can't be glass: it has to occlude the
|
||||
* screen it covers, and it carries [fg] text — which is why this must follow the palette. It
|
||||
* was a hardcoded near-black indigo, so on a pale palette the card's dark ink landed on a dark
|
||||
* card and the dialogs were unreadable.
|
||||
*/
|
||||
val card: Color,
|
||||
/**
|
||||
* What dims the screen BEHIND a modal. Always dark, whatever the field: a scrim's job is to
|
||||
* push the backdrop down, and a pale field lit with more white doesn't recede — it glares. A
|
||||
* pale one needs less of it, because it has further to fall.
|
||||
*/
|
||||
val modalScrim: Color,
|
||||
/**
|
||||
* The light a glass surface catches along its top edge. White either way — a highlight is a
|
||||
* specular, not a tint — but a pale field's frost is already bright, so it takes MORE to read
|
||||
* as an edge against the pastel showing through it.
|
||||
*/
|
||||
val highlight: Color,
|
||||
/**
|
||||
* What a failure says itself in — the pairing error, and anything else the console has to
|
||||
* refuse in words. Follows the palette because it lands on [card], not on the field: the salmon
|
||||
* that reads on a dark modal is washed out on a near-white one.
|
||||
*/
|
||||
val danger: Color,
|
||||
) {
|
||||
/** The foreground at [alpha]. */
|
||||
fun fg(alpha: Float): Color = fg.copy(alpha = alpha)
|
||||
@@ -75,7 +50,6 @@ class GamepadInk(
|
||||
val accentLuma =
|
||||
0.2126 * p.accent.first + 0.7152 * p.accent.second + 0.0722 * p.accent.third
|
||||
val onAccent = if (accentLuma > 0.55) Color.Black else Color.White
|
||||
val (gr, gg, gb) = p.ground
|
||||
if (!p.light) {
|
||||
return GamepadInk(
|
||||
fg = Color.White,
|
||||
@@ -85,20 +59,9 @@ class GamepadInk(
|
||||
shade = Color.Black,
|
||||
shadeScale = 1f,
|
||||
isLight = false,
|
||||
// The palette's own ground, lifted just off it so the card reads as a surface
|
||||
// ABOVE the field rather than a hole in it. For the brand violet that lands on
|
||||
// the #1A1730 the dialogs were hardcoded to, which is where the number came from.
|
||||
card = Color(
|
||||
(gr + 0.030).toFloat().coerceAtMost(1f),
|
||||
(gg + 0.030).toFloat().coerceAtMost(1f),
|
||||
(gb + 0.040).toFloat().coerceAtMost(1f),
|
||||
0.94f,
|
||||
),
|
||||
modalScrim = Color.Black.copy(alpha = 0.62f),
|
||||
highlight = Color.White.copy(alpha = 0.30f),
|
||||
danger = Color(0xFFE0736F),
|
||||
)
|
||||
}
|
||||
val (gr, gg, gb) = p.ground
|
||||
return GamepadInk(
|
||||
// Tinted toward the palette's own ground so it doesn't read as a foreign grey.
|
||||
fg = Color((gr * 0.16).toFloat(), (gg * 0.14).toFloat(), (gb * 0.20).toFloat()),
|
||||
@@ -110,15 +73,6 @@ class GamepadInk(
|
||||
shade = Color.White,
|
||||
shadeScale = 0.45f,
|
||||
isLight = true,
|
||||
// Near-white rather than near-black: the card carries this palette's DARK ink.
|
||||
card = Color.White.copy(alpha = 0.94f),
|
||||
// Lighter than the dark field's: a pastel backdrop is closer to the card already,
|
||||
// so the same 0.62 would read as a bruise rather than a recession.
|
||||
modalScrim = Color.Black.copy(alpha = 0.38f),
|
||||
highlight = Color.White.copy(alpha = 0.55f),
|
||||
// Deepened for the near-white card the pale palettes' modals use — the dark
|
||||
// field's salmon has nothing like enough contrast against it.
|
||||
danger = Color(0xFFB3352F),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -65,10 +65,6 @@ fun GamepadNavEffect(
|
||||
) {
|
||||
val activity = LocalContext.current as? MainActivity ?: return
|
||||
val state = remember { NavInputState() }
|
||||
// Menu feel, inherited by every console screen that navigates through here rather than wired
|
||||
// per screen: a tick as the cursor steps, a pulse on confirm. Renders on the driving pad's own
|
||||
// motors, the phone body if it has none, and nothing at all on a TV.
|
||||
val haptics by rememberUpdatedState(rememberConsoleHaptics())
|
||||
// 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
|
||||
@@ -102,10 +98,7 @@ fun GamepadNavEffect(
|
||||
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) { haptics.confirm(); currentOnActivate() }
|
||||
true
|
||||
}
|
||||
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 }
|
||||
@@ -146,10 +139,8 @@ fun GamepadNavEffect(
|
||||
}
|
||||
when {
|
||||
dir == 0 -> committed = 0
|
||||
dir != committed -> {
|
||||
haptics.tick(); currentOnMove(dir); committed = dir; fireAt = now + INITIAL_DELAY_MS
|
||||
}
|
||||
now >= fireAt -> { haptics.tick(); currentOnMove(dir); fireAt = now + REPEAT_MS }
|
||||
dir != committed -> { currentOnMove(dir); committed = dir; fireAt = now + INITIAL_DELAY_MS }
|
||||
now >= fireAt -> { currentOnMove(dir); fireAt = now + REPEAT_MS }
|
||||
}
|
||||
delay(16)
|
||||
}
|
||||
@@ -176,9 +167,6 @@ fun GamepadNavEffect2D(
|
||||
) {
|
||||
val activity = LocalContext.current as? MainActivity ?: return
|
||||
val state = remember { NavInputState() }
|
||||
// See [GamepadNavEffect] — the same menu feel, so a form screen and a carousel answer a press
|
||||
// identically.
|
||||
val haptics by rememberUpdatedState(rememberConsoleHaptics())
|
||||
val currentOnDirection by rememberUpdatedState(onDirection)
|
||||
val currentOnActivate by rememberUpdatedState(onActivate)
|
||||
val currentOnTertiary by rememberUpdatedState(onTertiary)
|
||||
@@ -208,15 +196,12 @@ fun GamepadNavEffect2D(
|
||||
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) { haptics.confirm(); currentOnActivate() }
|
||||
true
|
||||
}
|
||||
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 }
|
||||
// Edge-only, no auto-repeat: a held shoulder shouldn't spin through the tabs.
|
||||
KeyEvent.KEYCODE_BUTTON_L1 -> { if (edge) { haptics.tick(); currentOnShoulder(-1) }; true }
|
||||
KeyEvent.KEYCODE_BUTTON_R1 -> { if (edge) { haptics.tick(); currentOnShoulder(1) }; true }
|
||||
KeyEvent.KEYCODE_BUTTON_L1 -> { if (edge) currentOnShoulder(-1); true }
|
||||
KeyEvent.KEYCODE_BUTTON_R1 -> { if (edge) currentOnShoulder(1); true }
|
||||
else -> false // B → MainActivity (remapped to BACK → BackHandler)
|
||||
}
|
||||
}
|
||||
@@ -244,10 +229,8 @@ fun GamepadNavEffect2D(
|
||||
when {
|
||||
raw == null && nearCentre -> committed = null
|
||||
raw == null -> { /* in the hysteresis band → hold, don't fire */ }
|
||||
raw != committed -> {
|
||||
haptics.tick(); currentOnDirection(raw); committed = raw; fireAt = now + INITIAL_DELAY_MS
|
||||
}
|
||||
now >= fireAt -> { haptics.tick(); currentOnDirection(raw); fireAt = now + REPEAT_MS }
|
||||
raw != committed -> { currentOnDirection(raw); committed = raw; fireAt = now + INITIAL_DELAY_MS }
|
||||
now >= fireAt -> { currentOnDirection(raw); fireAt = now + REPEAT_MS }
|
||||
}
|
||||
delay(16)
|
||||
}
|
||||
|
||||
@@ -17,21 +17,6 @@ import androidx.compose.ui.graphics.Color
|
||||
// on every client. Keep the three copies in step: a palette added here without the others is a
|
||||
// value the other clients silently render as Violet.
|
||||
|
||||
/**
|
||||
* One wandering interior control point of the mesh: [x]/[y] its resting place in unit UV, [amp] how
|
||||
* far it strays, [sx]/[sy] its per-axis rates in rad·s⁻¹ and [phase] its offset. Its live
|
||||
* displacement `(amp·sin(t·sx+ph), amp·cos(t·sy+ph·1.3))` drives a bounded domain warp, so the
|
||||
* bright colour pools drift with it.
|
||||
*/
|
||||
class MeshWarpPoint(
|
||||
val x: Double,
|
||||
val y: Double,
|
||||
val amp: Double,
|
||||
val sx: Double,
|
||||
val sy: Double,
|
||||
val phase: Double,
|
||||
)
|
||||
|
||||
/** One background colour family. */
|
||||
class GamepadPalette(
|
||||
/** The stored `ui_palette` value ([Settings.uiPalette]). */
|
||||
@@ -62,29 +47,11 @@ class GamepadPalette(
|
||||
/** The accent as a Compose colour. */
|
||||
val accentColor: Color by lazy { color(accent) }
|
||||
|
||||
/**
|
||||
* The 16 mesh colours this palette's field is woven from: the ramp sampled per cell (see
|
||||
* [CELL_RAMP]), or [MESH_COLORS] verbatim for the brand default — the exact rule
|
||||
* `pf-console-ui`'s `Palette::mesh_colors` follows, so one `ui_palette` value is one field on
|
||||
* every client. Consumed by the AGSL backdrop on API 33+; the blob field
|
||||
* ([blobColors]) approximates the same table below that.
|
||||
*/
|
||||
val meshColors: List<Triple<Double, Double, Double>> by lazy {
|
||||
if (stops.isEmpty()) {
|
||||
MESH_COLORS
|
||||
} else {
|
||||
(0..15).map { i ->
|
||||
ramp(stops, 0.5 * ((i % 4) / 3.0 + (i / 4) / 3.0) + CELL_RAMP[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Where each of the 16 mesh cells samples the ramp. The base is the diagonal
|
||||
* `0.5·(x + y)` — top-left the ramp's dark end, bottom-right its bright one — and the
|
||||
* per-cell nudges break the banding a pure diagonal would show. Mirrored from
|
||||
* `pf-console-ui`'s `CELL_RAMP`.
|
||||
* Where each of the 16 mesh cells samples the ramp on the clients that draw a mesh. Kept
|
||||
* here so the three ports stay one table even though this client approximates the field
|
||||
* with blobs.
|
||||
*/
|
||||
val CELL_RAMP = listOf(
|
||||
0.10, -0.06, 0.04, -0.12,
|
||||
@@ -93,34 +60,6 @@ class GamepadPalette(
|
||||
-0.10, 0.08, -0.06, 0.12,
|
||||
)
|
||||
|
||||
/**
|
||||
* The brand default's 16 mesh colours, used verbatim (rather than sampled from a ramp) so
|
||||
* `violet` stays bit-identical to what every install already sees. Mirrors
|
||||
* `pf-console-ui`'s `MESH_COLORS`.
|
||||
*/
|
||||
val MESH_COLORS = listOf(
|
||||
Triple(0.075, 0.060, 0.160), Triple(0.34, 0.27, 0.72),
|
||||
Triple(0.30, 0.26, 0.74), Triple(0.075, 0.060, 0.160),
|
||||
Triple(0.42, 0.20, 0.54), Triple(0.49, 0.39, 0.95),
|
||||
Triple(0.28, 0.31, 0.84), Triple(0.16, 0.26, 0.64),
|
||||
Triple(0.45, 0.23, 0.60), Triple(0.53, 0.31, 0.75),
|
||||
Triple(0.35, 0.35, 0.91), Triple(0.19, 0.28, 0.70),
|
||||
Triple(0.075, 0.060, 0.160), Triple(0.22, 0.18, 0.54),
|
||||
Triple(0.24, 0.20, 0.58), Triple(0.075, 0.060, 0.160),
|
||||
)
|
||||
|
||||
/**
|
||||
* The four interior points that wander; the 12 boundary points stay pinned to the frame (a
|
||||
* drifting edge point would shrink the field and expose the ground behind it). Periods
|
||||
* ~90–130 s, out of phase, so the field never visibly loops. Mirrors `MESH_INTERIOR`.
|
||||
*/
|
||||
val MESH_INTERIOR = listOf(
|
||||
MeshWarpPoint(0.333, 0.333, 0.11, 0.049, 0.063, 0.4),
|
||||
MeshWarpPoint(0.667, 0.333, 0.10, 0.055, 0.052, 2.1),
|
||||
MeshWarpPoint(0.333, 0.667, 0.10, 0.058, 0.049, 3.6),
|
||||
MeshWarpPoint(0.667, 0.667, 0.12, 0.047, 0.061, 5.0),
|
||||
)
|
||||
|
||||
/** The brand default's blob ramp — the colours the pre-palette field used. */
|
||||
private val VIOLET_BLOBS = listOf(
|
||||
Triple(0.53, 0.47, 0.96), Triple(0.24, 0.20, 0.72), Triple(0.62, 0.30, 0.80),
|
||||
@@ -128,7 +67,7 @@ class GamepadPalette(
|
||||
)
|
||||
|
||||
/**
|
||||
* The thirteen shipped palettes: the brand default, six more dark fields, then six pale
|
||||
* The twelve shipped palettes: the brand default, five more dark fields, then six pale
|
||||
* ones. Cycling order runs dark → light, so stepping the row walks the range one way.
|
||||
*/
|
||||
val ALL = listOf(
|
||||
@@ -138,26 +77,6 @@ class GamepadPalette(
|
||||
ground = Triple(0.075, 0.060, 0.160),
|
||||
accent = Triple(0.525, 0.471, 0.961), light = false,
|
||||
),
|
||||
GamepadPalette(
|
||||
// For OLED and AMOLED panels, where a black pixel is a pixel switched off — no
|
||||
// glow, no power. The first two stops are literally (0,0,0), so the shaded half
|
||||
// of the field is genuinely off rather than "very dark grey", and the ground is
|
||||
// pure black too: the calm mix on the form screens lifts toward nothing. What is
|
||||
// left is a faint indigo→violet ember in the bright corner. The accent stays the
|
||||
// brand violet — focus has to be findable on black.
|
||||
// Named for the look, not the panel technology — black with a thin violet corona
|
||||
// belongs beside Nebula and Abyss. ⚠ The ID stays "oled": it is the stored
|
||||
// `ui_palette` value and the cross-client key, so renaming it would orphan saved
|
||||
// choices and desync the clients.
|
||||
"oled", "Eclipse",
|
||||
listOf(
|
||||
Triple(0.000, 0.000, 0.000), Triple(0.000, 0.000, 0.000),
|
||||
Triple(0.010, 0.020, 0.100), Triple(0.045, 0.016, 0.115),
|
||||
Triple(0.120, 0.024, 0.130),
|
||||
),
|
||||
ground = Triple(0.0, 0.0, 0.0),
|
||||
accent = Triple(0.525, 0.471, 0.961), light = false,
|
||||
),
|
||||
GamepadPalette(
|
||||
// Deep indigo climbing through violet into a hot magenta.
|
||||
"nebula", "Nebula",
|
||||
|
||||
@@ -3,19 +3,20 @@ 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.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.spring
|
||||
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
|
||||
@@ -24,28 +25,15 @@ 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.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.displayCutout
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.union
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ChevronLeft
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -58,28 +46,17 @@ 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.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.disabled
|
||||
import androidx.compose.ui.semantics.hideFromAccessibility
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.toggleableState
|
||||
import androidx.compose.ui.state.ToggleableState
|
||||
import androidx.compose.ui.text.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.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import io.unom.punktfunk.kit.DeviceGyro
|
||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
@@ -125,19 +102,6 @@ internal class GpRow(
|
||||
val toggled: Boolean? = null, // non-null = a toggle row, drawn as a ConsoleSwitch (not text)
|
||||
val adjustable: Boolean = true, // false = the row navigates/acts instead of stepping — no chevrons
|
||||
val enabled: Boolean = true, // dimmed + inert when false (still focusable, for its detail)
|
||||
/**
|
||||
* What A does on a non-adjustable row, for the legend. It was the literal "Pin to hosts" in the
|
||||
* hint bar back when a profile row was the only kind of row that acted rather than stepped; a
|
||||
* row that opens the Controllers view was then advertised as pinning something.
|
||||
*/
|
||||
val actionHint: String = "Open",
|
||||
/**
|
||||
* A choice row's full option list + where [value] sits in it — what the [ConsoleOptionBand]
|
||||
* drum turns through. Null (with [selectedIndex] -1) on everything that is not a stepped
|
||||
* choice: toggles are a switch, and the flat rows keep the quiet text slip.
|
||||
*/
|
||||
val options: List<String>? = null,
|
||||
val selectedIndex: Int = -1,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -149,28 +113,12 @@ internal class GpRow(
|
||||
internal fun liveRow(rows: List<GpRow>, index: Int): GpRow? =
|
||||
rows.getOrNull(index)?.takeIf { it.enabled }
|
||||
|
||||
/**
|
||||
* Where the cursor was when a row opened a SUB-SCREEN. The shell holds it across the trip (this
|
||||
* screen's own state does not outlive it) and hands it back, so Back from the Controllers view lands
|
||||
* on the row that opened it rather than on the first row of the first section.
|
||||
*
|
||||
* The row is remembered by ID, not by index: a tab's length follows the hardware and the profile
|
||||
* catalog, and a remembered index is the stale-pointer bug the tab-switch clamp already exists for.
|
||||
*/
|
||||
data class GpSettingsPlace(val tab: GpTab, val rowId: String)
|
||||
|
||||
@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
|
||||
/** Open the connected-controllers view / the open-source notices — the shell pushes them. */
|
||||
onOpenControllers: () -> Unit = {},
|
||||
onOpenLicenses: () -> Unit = {},
|
||||
/** Where a return from one of those lands; null = a fresh entry, which starts at the top. */
|
||||
resume: GpSettingsPlace? = null,
|
||||
onPlace: (GpSettingsPlace) -> Unit = {},
|
||||
) {
|
||||
var s by remember { mutableStateOf(initial) }
|
||||
fun update(next: Settings) { s = next; onChange(next) }
|
||||
@@ -178,8 +126,6 @@ fun GamepadSettingsScreen(
|
||||
val context = LocalContext.current
|
||||
// Gates the "Rumble on this phone" row — a TV box has no body vibrator to mirror onto.
|
||||
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
|
||||
// Gates "Gyro from this phone" the same way — a TV box has no gyroscope to mirror from.
|
||||
val hasGyroscope = remember { DeviceGyro.available(context) }
|
||||
// Gates the AV1 codec row the same way the touch settings do (see `codecOptionsFor`).
|
||||
val av1Capable = remember { io.unom.punktfunk.kit.VideoDecoders.pickDecoder("video/av01") != null }
|
||||
|
||||
@@ -213,46 +159,24 @@ fun GamepadSettingsScreen(
|
||||
// path there is this screen's own Controller-optimized UI toggle, which swaps in the standard
|
||||
// interface remote-navigably. The strings branch on it.
|
||||
val tv = remember { isTvDevice(context) }
|
||||
// The installed version, for the About row — the console is the ONLY interface on a TV box, so
|
||||
// the identity the touch About page states has to be reachable from here too.
|
||||
val appVersion = remember {
|
||||
runCatching {
|
||||
@Suppress("DEPRECATION")
|
||||
context.packageManager.getPackageInfo(context.packageName, 0).versionName
|
||||
}.getOrNull().orEmpty()
|
||||
}
|
||||
val allRows = buildSettingsRows(
|
||||
s, hasBodyVibrator, hasGyroscope, av1Capable,
|
||||
appVersion = appVersion,
|
||||
openControllers = onOpenControllers,
|
||||
openLicenses = onOpenLicenses,
|
||||
update = ::update,
|
||||
) + buildProfileRows(profiles, savedHosts, tv) { pinProfile = it }
|
||||
val allRows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) +
|
||||
buildProfileRows(profiles, savedHosts, tv) { pinProfile = it }
|
||||
// Which section is showing, and where each one's focus was when it was last left — a detour
|
||||
// into another tab shouldn't lose your place.
|
||||
var tab by remember { mutableStateOf(resume?.tab ?: GpTab.STREAM) }
|
||||
var tab by remember { mutableStateOf(GpTab.STREAM) }
|
||||
// True while the STRIP holds the cursor rather than the list. Up from the first row moves
|
||||
// here and Down goes back — the only route to the sections on a D-pad remote, which has no
|
||||
// shoulder buttons at all (and is exactly what a TV box ships with).
|
||||
var tabFocused by remember { mutableStateOf(false) }
|
||||
val tabFocus = remember { mutableStateMapOf<GpTab, Int>() }
|
||||
val rows = allRows.filter { it.tab == tab }
|
||||
// Entry focus: the row a sub-screen was opened from, if we are coming back from one. Resolved
|
||||
// ONCE, against the first row list — after that the cursor belongs to this screen.
|
||||
var focus by remember {
|
||||
mutableIntStateOf(rows.indexOfFirst { it.id == resume?.rowId }.coerceAtLeast(0))
|
||||
}
|
||||
var focus by remember { mutableIntStateOf(0) }
|
||||
if (focus > rows.lastIndex) focus = rows.lastIndex.coerceAtLeast(0)
|
||||
|
||||
// Which way the section last moved (+1 forward / -1 back) — the row list slides in from that
|
||||
// side, so stepping sections reads as travelling along a strip rather than teleporting.
|
||||
var tabDir by remember { mutableIntStateOf(1) }
|
||||
|
||||
// L1/R1 — one section along, wrapping (the strip is a ring, like A's value cycle).
|
||||
fun selectTab(next: GpTab) {
|
||||
if (next == tab) return
|
||||
tabFocus[tab] = focus
|
||||
tabDir = if (next.ordinal > tab.ordinal) 1 else -1
|
||||
tab = next
|
||||
// Clamp: a tab's length follows the hardware and the catalog, so a remembered index can
|
||||
// outlive the row it pointed at.
|
||||
@@ -261,45 +185,15 @@ fun GamepadSettingsScreen(
|
||||
}
|
||||
fun stepTab(delta: Int) {
|
||||
val all = GpTab.entries
|
||||
val next = all[((all.indexOf(tab) + delta) % all.size + all.size) % all.size]
|
||||
selectTab(next)
|
||||
// A wrap (last → first) is still a step in the direction you pressed, whatever the ordinals
|
||||
// say — selectTab's ordinal compare would read it backwards.
|
||||
tabDir = delta
|
||||
selectTab(all[((all.indexOf(tab) + delta) % all.size + all.size) % all.size])
|
||||
}
|
||||
// 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) }
|
||||
// Bumped on every ACCEPTED step of the focused row (the chevron ticks) and every REFUSED one
|
||||
// (the value gives a little and springs back). A press always gets an answer, even "no".
|
||||
var stepToken by remember { mutableIntStateOf(0) }
|
||||
var refusalToken by remember { mutableIntStateOf(0) }
|
||||
val haptics = rememberConsoleHaptics()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
|
||||
// Act on a row, publishing where the cursor was FIRST. A row that opens a sub-screen unmounts
|
||||
// this one on the spot, so the place has to be out of here before its `activate` runs; every
|
||||
// activation route (pad, tap, the legend's own A cell) goes through this one door so none of
|
||||
// them can be the one that forgets.
|
||||
fun activate(row: GpRow) {
|
||||
onPlace(GpSettingsPlace(tab, row.id))
|
||||
adjustDir = 1
|
||||
row.activate()
|
||||
}
|
||||
|
||||
// Step the focused row's value, answering a refusal rather than swallowing it.
|
||||
fun step(delta: Int) {
|
||||
adjustDir = delta
|
||||
val row = liveRow(rows, focus)
|
||||
if (row != null && row.adjust(delta)) {
|
||||
stepToken++
|
||||
} else {
|
||||
refusalToken++
|
||||
haptics.boundary()
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(onBack = onBack)
|
||||
GamepadNavEffect2D(
|
||||
// The pin picker owns the pad while it's up (its own nav + BackHandler), so this screen
|
||||
@@ -312,233 +206,89 @@ fun GamepadSettingsScreen(
|
||||
// On the strip, left/right walks sections; on a row it steps the value. A disabled
|
||||
// row is INERT, not just dim — the step is refused instead of writing a setting
|
||||
// that has nothing to act on (see `liveRow`).
|
||||
NavDir.LEFT -> if (tabFocused) stepTab(-1) else step(-1)
|
||||
NavDir.RIGHT -> if (tabFocused) stepTab(1) else step(1)
|
||||
NavDir.LEFT ->
|
||||
if (tabFocused) stepTab(-1) else { adjustDir = -1; liveRow(rows, focus)?.adjust(-1) }
|
||||
NavDir.RIGHT ->
|
||||
if (tabFocused) stepTab(1) else { adjustDir = 1; liveRow(rows, focus)?.adjust(1) }
|
||||
}
|
||||
},
|
||||
// A on the strip drops into the section you picked, which is what "confirm" means there.
|
||||
onActivate = {
|
||||
if (tabFocused) tabFocused = false else liveRow(rows, focus)?.let { activate(it) }
|
||||
if (tabFocused) tabFocused = false else { adjustDir = 1; liveRow(rows, focus)?.activate() }
|
||||
},
|
||||
// The shoulders work from either place — a real pad never has to visit the strip.
|
||||
onShoulder = { delta -> stepTab(delta) },
|
||||
)
|
||||
val animated = animationsEnabled()
|
||||
val hazeState = remember { HazeState() }
|
||||
val ink = LocalGamepadInk.current
|
||||
// The list runs to the PHYSICAL bottom edge (see the column's insets below), so the legend
|
||||
// zone's clearance has to carry the bottom bar inset itself. Landscape's zone is only the
|
||||
// pill — its detail lives in the side pane — so it clears less.
|
||||
val bottomInset = with(LocalDensity.current) {
|
||||
WindowInsets.systemBars.getBottom(this).toDp()
|
||||
}
|
||||
val legendClearance = (if (landscape) 92.dp else ConsoleLegendClearance) + bottomInset
|
||||
val legendClearancePx = with(LocalDensity.current) { legendClearance.roundToPx() }
|
||||
// The drum's fixed stage: a portrait phone is the one place the full width starves the row's
|
||||
// label, so it alone narrows it — the Apple band makes the same single exception. 132, not the
|
||||
// 156 of the first cut: at 156 the LABELS truncated ("Resoluti…"), and a clipped label loses
|
||||
// meaning where a drum value only loses its tail into the edge fade.
|
||||
val bandWidth = if (landscape) 220.dp else 132.dp
|
||||
|
||||
/**
|
||||
* One section's rows as a scrolling pane. A composable local rather than inline because the
|
||||
* tab transition composes TWO of these at once (incoming and outgoing), and each needs its own
|
||||
* [LazyListState] — Compose refuses one state attached to two lists, which is why the previous
|
||||
* cut animated a single list's contents and read as the same fade in every direction.
|
||||
*/
|
||||
val tabPane: @Composable (GpTab, Modifier) -> Unit = { paneTab, paneModifier ->
|
||||
val paneRows = if (paneTab == tab) rows else allRows.filter { it.tab == paneTab }
|
||||
val paneFocus = if (paneTab == tab) focus else (tabFocus[paneTab] ?: 0)
|
||||
// Seeded at the restored cursor, so re-entering a section lands where it was left without
|
||||
// a visible catch-up scroll on the first frame.
|
||||
val paneListState = rememberLazyListState(
|
||||
initialFirstVisibleItemIndex = paneFocus.coerceIn(0, paneRows.lastIndex.coerceAtLeast(0)),
|
||||
)
|
||||
// Keep the focused row on screen, but only SCROLL when it's actually off-screen. Only the
|
||||
// LIVE pane tracks the cursor; the outgoing one is a photograph on its way out.
|
||||
if (paneTab == tab) {
|
||||
LaunchedEffect(focus) {
|
||||
runCatching {
|
||||
val info = paneListState.layoutInfo
|
||||
val item = info.visibleItemsInfo.firstOrNull { it.index == focus }
|
||||
val offScreen = item == null ||
|
||||
item.offset < info.viewportStartOffset ||
|
||||
// The SAME clearance the list pads its bottom with, rather than a literal
|
||||
// that has to be remembered when the legend zone grows.
|
||||
item.offset + item.size > info.viewportEndOffset - legendClearancePx
|
||||
if (offScreen) paneListState.animateScrollToItem(focus)
|
||||
}
|
||||
}
|
||||
// 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, tab) {
|
||||
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)
|
||||
}
|
||||
LazyColumn(
|
||||
state = paneListState,
|
||||
// Capped at the Apple client's 620 row width: a landscape phone is WIDER than it is
|
||||
// useful, and a settings row stretched across 900 dp reads as a ribbon, not a control.
|
||||
// Start-aligned (not centred) so the rows and the side detail pane split the screen
|
||||
// rather than both crowding the middle.
|
||||
modifier = paneModifier.widthIn(max = 620.dp + ConsoleEdgeInset * 2),
|
||||
contentPadding = PaddingValues(
|
||||
start = ConsoleEdgeInset,
|
||||
end = ConsoleEdgeInset,
|
||||
top = 8.dp,
|
||||
// Clears the whole floating legend ZONE, bottom bar included — the list itself
|
||||
// runs to the screen edge now.
|
||||
bottom = legendClearance,
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
itemsIndexed(paneRows, key = { _, r -> r.id }) { index, row ->
|
||||
val rowFocused = paneTab == tab && index == focus && !tabFocused
|
||||
}
|
||||
|
||||
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())
|
||||
Column(Modifier.fillMaxSize().systemBarsPadding()) {
|
||||
// The strip is PINNED while the rows scroll under it: it is this screen's primary
|
||||
// navigation now, and a switcher you have to scroll back up to find isn't one. The
|
||||
// title stays in the scrolling list (landscape has no height to spare, and the
|
||||
// selected pill already says which section you are in).
|
||||
ConsoleTabStrip(
|
||||
titles = GpTab.entries.map { it.title },
|
||||
selected = GpTab.entries.indexOf(tab),
|
||||
onSelect = { tabFocused = false; selectTab(GpTab.entries[it]) },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 2.dp),
|
||||
focused = tabFocused,
|
||||
)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 104.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
item(key = "__title") {
|
||||
// "Default settings", not "Settings": this screen edits the base layer only. The
|
||||
// console honours a host's profile but doesn't edit profiles (design §5.4), so a
|
||||
// bare "Settings" would quietly imply it changes whatever that host streams with.
|
||||
ConsoleHeader("Default settings", horizontalInset = false)
|
||||
}
|
||||
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
|
||||
SettingRowView(
|
||||
row,
|
||||
focused = rowFocused,
|
||||
focused = index == focus && !tabFocused,
|
||||
adjustDir = adjustDir,
|
||||
// Only the focused row can be stepped, so only it needs to answer one.
|
||||
stepToken = if (rowFocused) stepToken else 0,
|
||||
refusalToken = if (rowFocused) refusalToken else 0,
|
||||
bandWidth = bandWidth,
|
||||
onClick = {
|
||||
// Same inertness as the pad path above — tapping a dimmed row focuses it
|
||||
// (so its detail explains itself) but never flips it.
|
||||
tabFocused = false
|
||||
if (focus != index) focus = index
|
||||
else if (row.enabled) activate(row)
|
||||
else if (row.enabled) { adjustDir = 1; row.activate() }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The section switcher with its directional content — shared by both orientations below. */
|
||||
val tabbedContent: @Composable (Modifier) -> Unit = { contentModifier ->
|
||||
AnimatedContent(
|
||||
targetState = tab,
|
||||
modifier = contentModifier,
|
||||
transitionSpec = {
|
||||
if (!animated) {
|
||||
fadeIn(tween(ConsoleMotion.REDUCED_MS)) togetherWith
|
||||
fadeOut(tween(ConsoleMotion.REDUCED_MS))
|
||||
} else {
|
||||
// DIRECTION-driven, with a real exit: the incoming section slides in from the
|
||||
// side the press pointed at while the outgoing leaves the other way — paging
|
||||
// along a strip. The previous cut slid a single list's contents 24 dp under an
|
||||
// 85 % fade, which read as the same crossfade whichever shoulder was pressed.
|
||||
val dir = tabDir
|
||||
(
|
||||
slideInHorizontally(ConsoleMotion.ease(ConsoleMotion.TAB_MS)) { it / 6 * dir } +
|
||||
fadeIn(ConsoleMotion.ease(ConsoleMotion.TAB_MS))
|
||||
) togetherWith (
|
||||
slideOutHorizontally(ConsoleMotion.ease(ConsoleMotion.TAB_MS)) { -it / 6 * dir } +
|
||||
fadeOut(ConsoleMotion.ease(ConsoleMotion.TAB_MS))
|
||||
)
|
||||
}
|
||||
},
|
||||
label = "settingsTab",
|
||||
) { t ->
|
||||
tabPane(t, Modifier.fillMaxHeight())
|
||||
}
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||
// The backdrop stays full-bleed — it is ambience. The CHROME (strip, rows' start
|
||||
// edge) takes the safe area on the sides and top only: the LIST deliberately runs to
|
||||
// the physical bottom of the screen, with the bottom inset folded into its
|
||||
// contentPadding, so scrolled rows glide off the edge instead of being guillotined at
|
||||
// an invisible inset line 24 px above it (third on-glass verdict).
|
||||
GamepadFormBackground(Modifier.fillMaxSize())
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(
|
||||
WindowInsets.systemBars.union(WindowInsets.displayCutout)
|
||||
.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top),
|
||||
),
|
||||
) {
|
||||
// The strip is PINNED while the rows scroll under it: it is this screen's primary
|
||||
// navigation, and a switcher you have to scroll back up to find isn't one.
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
ConsoleTabStrip(
|
||||
titles = GpTab.entries.map { it.title },
|
||||
selected = GpTab.entries.indexOf(tab),
|
||||
onSelect = { tabFocused = false; selectTab(GpTab.entries[it]) },
|
||||
modifier = Modifier.weight(1f),
|
||||
focused = tabFocused,
|
||||
)
|
||||
// The base-layer marker, where a full "Default settings" heading used to eat a
|
||||
// headline row on EVERY tab. The honesty it carried stays: this screen edits
|
||||
// the defaults only — the console honours a host's profile but doesn't edit
|
||||
// profiles (design §5.4) — and this quiet chip at the strip's end says so
|
||||
// without a second heading repeating the tab pill's own word.
|
||||
Text(
|
||||
"Defaults",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = ink.fg(0.45f),
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(start = 10.dp, end = ConsoleEdgeInset),
|
||||
)
|
||||
}
|
||||
if (landscape) {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
tabbedContent(Modifier.weight(0.6f))
|
||||
// The focused row's description, in the width a wide phone wastes — beside
|
||||
// the rows instead of floating over the list's tail (the portrait band).
|
||||
// Presentation only: the row already merges this text into its own
|
||||
// announcement, so the pane is hidden from a screen reader like the band.
|
||||
val focusedRow = rows.getOrNull(focus)
|
||||
AnimatedContent(
|
||||
targetState = if (tabFocused) null else focusedRow,
|
||||
transitionSpec = {
|
||||
fadeIn(ConsoleMotion.ease(ConsoleMotion.FOCUS_MS)) togetherWith
|
||||
fadeOut(ConsoleMotion.ease(ConsoleMotion.FOCUS_MS))
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(0.4f)
|
||||
.semantics { hideFromAccessibility() },
|
||||
label = "sideDetail",
|
||||
) { r ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(start = 6.dp, end = ConsoleEdgeInset, top = 22.dp),
|
||||
) {
|
||||
if (r != null && r.detail.isNotBlank()) {
|
||||
Text(
|
||||
r.label,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = ink.fg(0.85f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
r.detail,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = ink.fg(0.6f),
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tabbedContent(Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The floating legend ZONE: the focused row's description above, the controls pill below,
|
||||
// both frosted over whatever scrolls behind them. It is an OVERLAY, so nothing in it can
|
||||
// ever displace the list — which is the whole reason the detail moved here out of the row.
|
||||
// In landscape it ignores the system bars so it hugs the corner instead of the nav-bar
|
||||
// inset, but it still takes the display cutout (reverse-landscape parks the punch here).
|
||||
// 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)
|
||||
.consoleLegendInsets(landscape)
|
||||
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
// The legend follows the focused row (the desktop console's hints() does the same):
|
||||
@@ -553,49 +303,31 @@ fun GamepadSettingsScreen(
|
||||
// Activity (preview/tests), like GamepadHintBar's own glyph choice.
|
||||
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
|
||||
val sections = listOfNotNull(
|
||||
GamepadHint('⇄', PadGlyph.Arrow, "Section", onClick = { stepTab(1) })
|
||||
GamepadHint('⇄', Color(0xFF9A93C7), "Section", onClick = { stepTab(1) })
|
||||
.takeIf { padIsGamepad },
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// Portrait only: landscape's description lives in the side pane, where the wide
|
||||
// aspect has room for it — a band AND a pane would say the same thing twice.
|
||||
if (!landscape) {
|
||||
ConsoleDetailBand(
|
||||
// On the strip there is no row to describe, and the pills already name the
|
||||
// sections — a stale row's description there would describe the wrong thing.
|
||||
text = if (tabFocused) "" else focused?.detail.orEmpty(),
|
||||
key = if (tabFocused) "__strip" else focused?.id,
|
||||
hazeState = hazeState,
|
||||
)
|
||||
}
|
||||
GamepadHintBar(
|
||||
if (tabFocused) listOf(
|
||||
GamepadHint('↔', PadGlyph.Arrow, "Section"),
|
||||
PadGlyph.hint('A', "Open") { tabFocused = false },
|
||||
GamepadHintBar(
|
||||
if (tabFocused) listOf(
|
||||
GamepadHint('↔', Color(0xFF9A93C7), "Section"),
|
||||
PadGlyph.hint('A', "Open") { tabFocused = false },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
) else sections + when {
|
||||
focused != null && !focused.enabled -> listOf(
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
) else sections + when {
|
||||
focused != null && !focused.enabled -> listOf(
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
// What A does here follows the ROW: it opens the pin picker on a profile,
|
||||
// the connected-controllers view on that one, the notices on the About row.
|
||||
// It was the literal "Pin to hosts" while profiles were the only such rows.
|
||||
focused != null && !focused.adjustable -> listOf(
|
||||
PadGlyph.hint('A', focused.actionHint) { activate(focused) },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
else -> listOf(
|
||||
GamepadHint('↔', PadGlyph.Arrow, "Adjust"),
|
||||
// Tappable too (touch hatch): Change cycles the focused row, Done leaves.
|
||||
PadGlyph.hint('A', "Change") {
|
||||
rows.getOrNull(focus)?.let { activate(it) }
|
||||
},
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
},
|
||||
hazeState = hazeState,
|
||||
)
|
||||
}
|
||||
)
|
||||
focused != null && !focused.adjustable -> listOf(
|
||||
PadGlyph.hint('A', "Pin to hosts") { focused.activate() },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
else -> listOf(
|
||||
GamepadHint('↔', Color(0xFF9A93C7), "Adjust"),
|
||||
// Tappable too (touch escape hatch): Change cycles the focused row, Done leaves.
|
||||
PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
},
|
||||
hazeState = hazeState,
|
||||
)
|
||||
}
|
||||
|
||||
// The pin-to-hosts picker for the activated profile row — the console counterpart of the
|
||||
@@ -612,64 +344,24 @@ fun GamepadSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One settings row. Its geometry NEVER changes with focus — that is the whole design of it.
|
||||
*
|
||||
* It used to unfold its description in place, which meant every D-pad step shrank one row and grew
|
||||
* another, shifting the entire list under the cursor and moving the keep-focus-visible scroll's
|
||||
* target out from under it mid-animation. The description now lives in the screen's floating
|
||||
* [ConsoleDetailBand], which is an overlay and cannot displace anything. Focus changes colour,
|
||||
* lift and bloom here; it does not change size.
|
||||
*
|
||||
* The value gets the same treatment sideways: a fixed minimum slot, end-aligned, with the size
|
||||
* transform snapped so the slot's WIDTH never animates. Stepping a choice used to widen and narrow
|
||||
* that slot on every press, walking the ‹ chevron back and forth. Tabular figures finish the job —
|
||||
* without them `1920 × 1080 → 2560 × 1440` changes width on the digits alone.
|
||||
*/
|
||||
@Composable
|
||||
private fun SettingRowView(
|
||||
row: GpRow,
|
||||
focused: Boolean,
|
||||
adjustDir: Int,
|
||||
stepToken: Int,
|
||||
refusalToken: Int,
|
||||
/** The option drum's fixed stage — sized by the SCREEN (orientation decides how much a row can spare). */
|
||||
bandWidth: Dp,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick: () -> Unit) {
|
||||
val ink = LocalGamepadInk.current
|
||||
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. A non-adjustable row (a profile row
|
||||
// navigates, the empty-catalog placeholder does nothing) never shows them at all.
|
||||
val chevronAlpha by animateFloatAsState(
|
||||
if (focused && row.adjustable) 0.6f else 0f,
|
||||
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
|
||||
tween(160),
|
||||
label = "chevrons",
|
||||
)
|
||||
val valueColor by animateColorAsState(
|
||||
ink.fg(if (focused) 1f else 0.6f),
|
||||
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
|
||||
tween(160),
|
||||
label = "valueColor",
|
||||
)
|
||||
// A press always gets an answer. Accepted: the chevron on the pressed side ticks outward and
|
||||
// springs back. Refused: the whole value slot gives 4 dp toward the press and springs back —
|
||||
// the "door is locked" motion, so a limit reads as a limit instead of as a dropped input.
|
||||
val chevronKick = remember { Animatable(0f) }
|
||||
LaunchedEffect(stepToken) {
|
||||
if (stepToken == 0) return@LaunchedEffect
|
||||
chevronKick.snapTo(2f * adjustDir)
|
||||
chevronKick.animateTo(0f, spring(dampingRatio = 0.45f, stiffness = 900f))
|
||||
}
|
||||
val refusal = remember { Animatable(0f) }
|
||||
LaunchedEffect(refusalToken) {
|
||||
if (refusalToken == 0) return@LaunchedEffect
|
||||
refusal.animateTo(
|
||||
ConsoleMotion.REFUSAL_NUDGE.value * adjustDir,
|
||||
ConsoleMotion.ease(ConsoleMotion.REFUSAL_MS / 2),
|
||||
)
|
||||
refusal.animateTo(0f, spring(dampingRatio = 0.5f, stiffness = Spring.StiffnessMedium))
|
||||
}
|
||||
Column {
|
||||
if (row.header != null) {
|
||||
Text(
|
||||
@@ -680,145 +372,86 @@ private fun SettingRowView(
|
||||
modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
Row(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.consoleGlass(ConsoleShape.Row, visuals)
|
||||
.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,
|
||||
)
|
||||
// ONE announcement per row, not five leaves read in layout order. Three things had
|
||||
// to be gathered to make it true:
|
||||
// * the VALUE of a toggle row existed nowhere in the tree — the switch replaces
|
||||
// the value text (see below), so `row.value` ("On"/"Off") was drawn by nothing;
|
||||
// * the DESCRIPTION lives in the floating band at the far bottom of the screen,
|
||||
// which is the right place to LOOK and the wrong place to be read — so it is
|
||||
// merged here, where the row it explains is;
|
||||
// * `enabled` was a colour and nothing else.
|
||||
// A row therefore announces "Refresh rate, 120 Hz, Frame rate the host renders and
|
||||
// streams at" — which is what the screen already means, said once.
|
||||
.semantics(mergeDescendants = true) {
|
||||
role = if (row.toggled != null) Role.Switch else Role.Button
|
||||
contentDescription = listOfNotNull(
|
||||
row.label,
|
||||
row.value.takeIf { it.isNotBlank() },
|
||||
row.detail.takeIf { it.isNotBlank() },
|
||||
).joinToString(", ")
|
||||
row.toggled?.let {
|
||||
toggleableState = if (it) ToggleableState.On else ToggleableState.Off
|
||||
}
|
||||
if (!row.enabled) disabled()
|
||||
}
|
||||
.padding(horizontal = 16.dp, vertical = 13.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
row.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
// A disabled row (the "No profiles yet" placeholder) dims but stays focusable, so
|
||||
// the detail band can still explain what would go here.
|
||||
color = ink.fg(if (row.enabled) 1f else 0.45f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
// Takes the slack rather than a Spacer doing it, so a long label ellipsizes into
|
||||
// the room it actually has instead of shoving the value slot off the row.
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.offset { IntOffset(refusal.value.dp.roundToPx(), 0) },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
row.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
// A disabled row (the "No profiles yet" placeholder) dims but stays focusable,
|
||||
// so its detail line can still explain what would go here.
|
||||
color = ink.fg(if (row.enabled) 1f else 0.45f),
|
||||
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 {
|
||||
Icon(
|
||||
Icons.Filled.ChevronLeft,
|
||||
// Decoration: it says "this value steps", which the row's Switch/Button
|
||||
// role already says. Left in the tree it is read out on every focused row.
|
||||
contentDescription = null,
|
||||
tint = ink.fg,
|
||||
modifier = Modifier
|
||||
.size(18.dp)
|
||||
.semantics { hideFromAccessibility() }
|
||||
.graphicsLayer { alpha = chevronAlpha }
|
||||
.offset { IntOffset(minOf(chevronKick.value, 0f).dp.roundToPx(), 0) },
|
||||
)
|
||||
if (row.options != null && row.selectedIndex in row.options.indices) {
|
||||
// The drum — see ConsoleOptionBand. Its width is FIXED by the row, so a
|
||||
// step can never reflow the chevrons, and the tabular-figures concern
|
||||
// dissolves with it: nothing about the row's layout depends on the label.
|
||||
ConsoleOptionBand(
|
||||
options = row.options,
|
||||
selection = row.selectedIndex,
|
||||
focused = focused,
|
||||
width = bandWidth,
|
||||
Text("‹ ", color = ink.fg, 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,
|
||||
)
|
||||
} else {
|
||||
// The flat rows (profile pin counts, the empty-catalog placeholder) keep
|
||||
// the quiet slip: the changed string slides in following the motion.
|
||||
AnimatedContent(
|
||||
targetState = row.value,
|
||||
transitionSpec = {
|
||||
val dir = adjustDir
|
||||
(
|
||||
slideInHorizontally(
|
||||
ConsoleMotion.ease(ConsoleMotion.VALUE_MS),
|
||||
) { w -> w / 2 * dir } +
|
||||
fadeIn(ConsoleMotion.ease(ConsoleMotion.VALUE_MS))
|
||||
) togetherWith (
|
||||
slideOutHorizontally(
|
||||
ConsoleMotion.ease(ConsoleMotion.VALUE_OUT_MS),
|
||||
) { w -> -w / 2 * dir } +
|
||||
fadeOut(ConsoleMotion.ease(100))
|
||||
) using SizeTransform(clip = false) { _, _ -> snap() }
|
||||
},
|
||||
label = "value",
|
||||
) { value ->
|
||||
Text(
|
||||
value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = valueColor,
|
||||
textAlign = TextAlign.End,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Icon(
|
||||
Icons.Filled.ChevronRight,
|
||||
contentDescription = null,
|
||||
tint = ink.fg,
|
||||
modifier = Modifier
|
||||
.size(18.dp)
|
||||
.semantics { hideFromAccessibility() }
|
||||
.graphicsLayer { alpha = chevronAlpha }
|
||||
.offset { IntOffset(maxOf(chevronKick.value, 0f).dp.roundToPx(), 0) },
|
||||
)
|
||||
Text(" ›", color = ink.fg, 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 = ink.fg(0.6f),
|
||||
maxLines = 2,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the console settings rows from the current [Settings], writing through [update].
|
||||
* [hasBodyVibrator] gates the "Rumble on this phone" row and [hasGyroscope] the "Gyro from this
|
||||
* phone" row (both absent on TVs); [av1Capable] gates the AV1 codec entry (see
|
||||
* `codecOptionsFor`). [appVersion] is the installed version the About row states, and
|
||||
* [openControllers] / [openLicenses] are the two rows that navigate rather than set anything.
|
||||
* Every row declares its [GpTab]; the screen shows one tab at a time. */
|
||||
* [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs); [av1Capable] gates the
|
||||
* AV1 codec entry (see `codecOptionsFor`). Every row declares its [GpTab]; the screen shows one
|
||||
* tab at a time. */
|
||||
internal fun buildSettingsRows(
|
||||
s: Settings,
|
||||
hasBodyVibrator: Boolean,
|
||||
hasGyroscope: Boolean,
|
||||
av1Capable: Boolean,
|
||||
appVersion: String = "",
|
||||
openControllers: () -> Unit = {},
|
||||
openLicenses: () -> Unit = {},
|
||||
update: (Settings) -> Unit,
|
||||
): List<GpRow> {
|
||||
fun <T> choice(
|
||||
@@ -843,8 +476,6 @@ internal fun buildSettingsRows(
|
||||
val i = if (idx < 0) 0 else (idx + 1) % options.size
|
||||
options.getOrNull(i)?.let { write(it.first) }
|
||||
},
|
||||
options = options.map { it.second },
|
||||
selectedIndex = idx,
|
||||
)
|
||||
}
|
||||
fun toggle(
|
||||
@@ -967,18 +598,6 @@ internal fun buildSettingsRows(
|
||||
} else {
|
||||
null
|
||||
},
|
||||
// The rumble mirror's sibling, data flowing the other way — needs a gyroscope to
|
||||
// mirror FROM, which a TV box lacks.
|
||||
if (hasGyroscope) {
|
||||
toggle(
|
||||
"phoneGyro", GpTab.CONTROLLER, null, "Gyro from this phone",
|
||||
"When the controller has no gyro of its own, send this phone's motion " +
|
||||
"sensors as controller 1's — for clip-on pads without one.",
|
||||
s.gyroOnPhone,
|
||||
) { update(s.copy(gyroOnPhone = it)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
) + listOf(
|
||||
// NOT gated on the vibrator (the bug A2 fixed in the touch settings): an SC2 capture has
|
||||
// nothing to do with this device's motor, and a TV box is where it matters most.
|
||||
@@ -998,28 +617,6 @@ internal fun buildSettingsRows(
|
||||
"triggers, lightbar and gyro.",
|
||||
s.dsCapture, enabled = s.gamepadForwarding,
|
||||
) { update(s.copy(dsCapture = it)) },
|
||||
// The diagnostics view — same screen the touch settings reach, same words for it. It was
|
||||
// reachable from touch ONLY, which on a TV box means not at all: there is no touch interface
|
||||
// to fall back to there, and "my controller does nothing" is the support case it answers.
|
||||
//
|
||||
// Deliberately NOT gated on the master forwarding switch its neighbours all follow: this is
|
||||
// the row you reach for when forwarding looks broken, and a diagnostic that dims itself when
|
||||
// the thing it diagnoses is off is worse than none.
|
||||
//
|
||||
// No value: this row navigates, it doesn't hold a setting (a count read here would be a
|
||||
// snapshot, and a stale "none detected" is worse than no number at all — the screen it opens
|
||||
// watches hot-plug live).
|
||||
GpRow(
|
||||
id = "controllers",
|
||||
tab = GpTab.CONTROLLER,
|
||||
header = "Diagnostics",
|
||||
label = "Connected controllers",
|
||||
value = "",
|
||||
detail = "What the app detects, with a live input test.",
|
||||
adjust = { false },
|
||||
activate = openControllers,
|
||||
adjustable = false,
|
||||
),
|
||||
|
||||
// The palette leads Interface: it is the one row whose effect you can see while you step
|
||||
// it (the backdrop behind this very list recolours), so it wants to be the first thing
|
||||
@@ -1034,7 +631,7 @@ internal fun buildSettingsRows(
|
||||
choice(
|
||||
"hud", GpTab.INTERFACE, null, "Statistics overlay",
|
||||
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
|
||||
"Select + X on a pad, or a 3-finger tap, cycles the tiers live.",
|
||||
"A 3-finger tap cycles the tiers live.",
|
||||
STATS_VERBOSITY_OPTIONS, s.statsVerbosity,
|
||||
) { update(s.copy(statsVerbosity = it)) },
|
||||
toggle(
|
||||
@@ -1052,37 +649,6 @@ internal fun buildSettingsRows(
|
||||
"Turn off to use the touch interface even with a controller connected.",
|
||||
s.gamepadUiEnabled,
|
||||
) { update(s.copy(gamepadUiEnabled = it)) },
|
||||
) + listOfNotNull(
|
||||
// WHEN the switch above takes over. Built only while it is ON: turn the switch off from
|
||||
// this very screen and the row under the cursor would otherwise be one deciding nothing,
|
||||
// on a screen that is itself about to disappear.
|
||||
if (s.gamepadUiEnabled) {
|
||||
choice(
|
||||
"gamepadUIMode", GpTab.INTERFACE, null, "Show it",
|
||||
"With a controller: the touch interface comes back when the last one " +
|
||||
"disconnects. Always keeps this layout either way — for a device that lives " +
|
||||
"docked to a TV. A TV itself is always in this mode regardless.",
|
||||
GAMEPAD_UI_MODE_OPTIONS, s.gamepadUiMode,
|
||||
) { update(s.copy(gamepadUiMode = it)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
) + listOf(
|
||||
// About closes the Interface section, the way the touch settings' last category does. The
|
||||
// notices are a licence obligation and were reachable from touch only — on a TV box that is
|
||||
// nowhere. The version rides in the VALUE slot rather than as a second, inert row: it is the
|
||||
// identity half of an About page, and the screen this opens states it again at the top.
|
||||
GpRow(
|
||||
id = "licenses",
|
||||
tab = GpTab.INTERFACE,
|
||||
header = "About",
|
||||
label = "Open-source licenses",
|
||||
value = appVersion,
|
||||
detail = "Third-party notices and credits.",
|
||||
adjust = { false },
|
||||
activate = openLicenses,
|
||||
adjustable = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1097,7 +663,7 @@ internal fun buildSettingsRows(
|
||||
* Controller-optimized UI toggle a few rows up, which swaps the standard interface in
|
||||
* (d-pad-navigable; the profile editor lives there on every device, unlike tvOS where none exists).
|
||||
*/
|
||||
internal fun buildProfileRows(
|
||||
private fun buildProfileRows(
|
||||
profiles: List<StreamProfile>,
|
||||
savedHosts: List<KnownHost>,
|
||||
tv: Boolean,
|
||||
@@ -1144,7 +710,6 @@ internal fun buildProfileRows(
|
||||
adjust = { false },
|
||||
activate = { openPinPicker(p) },
|
||||
adjustable = false,
|
||||
actionHint = "Pin to hosts",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,35 +16,15 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
|
||||
/**
|
||||
* [Settings.gamepadUiMode]: take over only while a controller is attached. The default, and what
|
||||
* the switch meant when it was a lone Boolean.
|
||||
*/
|
||||
const val GAMEPAD_UI_WHEN_CONNECTED = "connected"
|
||||
|
||||
/**
|
||||
* [Settings.gamepadUiMode]: take over whenever the switch is on, pad or no pad — for a phone or
|
||||
* tablet that lives docked to a TV, where the console layout is the one wanted and the pad is not
|
||||
* always awake.
|
||||
*/
|
||||
const val GAMEPAD_UI_ALWAYS = "always"
|
||||
|
||||
/**
|
||||
* 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 (the [mode] is [GAMEPAD_UI_ALWAYS] OR 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), which is why the
|
||||
* mode row means nothing there. An unrecognized [mode] waits for a controller, so a value a newer
|
||||
* client wrote can never strand this one in a layout it has no way back out of.
|
||||
* 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,
|
||||
mode: String,
|
||||
controllerConnected: Boolean,
|
||||
tv: Boolean,
|
||||
forced: Boolean,
|
||||
): Boolean = enabled && (mode == GAMEPAD_UI_ALWAYS || controllerConnected || tv || forced)
|
||||
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 {
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import io.unom.punktfunk.kit.discovery.DiscoveredHost
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
|
||||
/**
|
||||
* The console home's tiles, in carousel order: every saved host with its pinned host+profile cards
|
||||
* immediately behind it, then the hosts seen on the network but not yet saved, then Add Host.
|
||||
*
|
||||
* Pure, and deliberately not a composable. The half of this that can be WRONG is the ordering and
|
||||
* what a tile claims — a pin drifting away from the host it belongs to, a discovered host offered a
|
||||
* second time next to the saved record it already is, a chip naming a profile the press won't
|
||||
* actually use. None of that needs a display to be checked, and `HomeTilesTest` checks it without
|
||||
* one; the console home itself needs the live JNI core to compose at all.
|
||||
*
|
||||
* [isOnline] and [pinsFor] arrive as lambdas rather than as the discovery lists and the profile
|
||||
* store behind them: "online" means advertising on mDNS OR answering a QUIC probe (the routed
|
||||
* Tailscale/VPN case), which is a rule belonging to the screen that does the probing, not to a list
|
||||
* builder.
|
||||
*/
|
||||
internal fun buildHomeTiles(
|
||||
savedHosts: List<KnownHost>,
|
||||
/** The live catalog — resolves each host's binding into the name and colour its chip wears. */
|
||||
profiles: List<StreamProfile>,
|
||||
pinsFor: (KnownHost) -> List<StreamProfile>,
|
||||
/** Already de-duped against [savedHosts] by the caller: a saved host is not also "discovered". */
|
||||
discoveredUnsaved: List<DiscoveredHost>,
|
||||
isOnline: (KnownHost) -> Boolean,
|
||||
/**
|
||||
* Dial a saved host. The second argument is `connect`'s one-off profile reference: null on a
|
||||
* host's own tile (follow whatever the host is bound to), the pinned profile's id on a pin tile.
|
||||
*/
|
||||
onConnect: (KnownHost, String?) -> Unit,
|
||||
onConnectDiscovered: (DiscoveredHost) -> Unit,
|
||||
onAddHost: () -> Unit,
|
||||
): List<HomeTile> = buildList {
|
||||
savedHosts.forEach { kh ->
|
||||
val bound = kh.profileId?.let { id -> profiles.firstOrNull { it.id == id } }
|
||||
add(
|
||||
HomeTile(
|
||||
id = "saved-${kh.id}",
|
||||
title = kh.name,
|
||||
subtitle = "${kh.address}:${kh.port}",
|
||||
filled = true,
|
||||
online = isOnline(kh),
|
||||
paired = kh.paired,
|
||||
knownHost = kh,
|
||||
// The binding is what a press will actually do, so the tile says so — the console
|
||||
// can't edit profiles, but it must never lie about which one it uses. It rides in
|
||||
// the card's own chip now rather than as a "· Name" tail on the address, which is
|
||||
// where it read as an afterthought.
|
||||
profileName = bound?.name,
|
||||
profileAccent = accentColor(bound?.accent),
|
||||
activate = { onConnect(kh, null) },
|
||||
),
|
||||
)
|
||||
// Pinned host+profile combinations, right after their host: one focus-and-press each,
|
||||
// which is the affordance a controller surface does well (menus are not).
|
||||
pinsFor(kh).forEach { p ->
|
||||
add(
|
||||
HomeTile(
|
||||
id = "pin-${kh.id}-${p.id}",
|
||||
title = kh.name,
|
||||
// The address, like every other card — the PROFILE is what makes this card
|
||||
// different, and it now says so in the chip instead of standing in for the
|
||||
// subtitle, which left a pin card unable to say where it pointed.
|
||||
subtitle = "${kh.address}:${kh.port}",
|
||||
filled = true,
|
||||
online = isOnline(kh),
|
||||
paired = kh.paired,
|
||||
knownHost = kh,
|
||||
pinnedProfileId = p.id,
|
||||
profileName = p.name,
|
||||
profileAccent = accentColor(p.accent),
|
||||
activate = { onConnect(kh, p.id) },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
discoveredUnsaved.forEach { dh ->
|
||||
add(
|
||||
HomeTile(
|
||||
id = "disc-${dh.host}:${dh.port}",
|
||||
title = dh.name,
|
||||
subtitle = "${dh.host}:${dh.port}",
|
||||
online = true,
|
||||
activate = { onConnectDiscovered(dh) },
|
||||
),
|
||||
)
|
||||
}
|
||||
add(
|
||||
HomeTile(
|
||||
id = "add",
|
||||
title = "Add Host",
|
||||
subtitle = "Register a host by address",
|
||||
isAdd = true,
|
||||
activate = onAddHost,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -15,12 +15,13 @@ 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.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -41,11 +42,6 @@ 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.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.liveRegion
|
||||
import androidx.compose.ui.semantics.LiveRegionMode
|
||||
import androidx.compose.ui.semantics.selected
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.zIndex
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
@@ -58,7 +54,6 @@ import androidx.compose.ui.unit.sp
|
||||
import coil.ImageLoader
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import io.unom.punktfunk.components.launcherIcon
|
||||
import io.unom.punktfunk.kit.library.DEFAULT_MGMT_PORT
|
||||
import io.unom.punktfunk.kit.library.GameEntry
|
||||
import io.unom.punktfunk.kit.library.LibraryClient
|
||||
@@ -94,13 +89,6 @@ fun LibraryScreen(
|
||||
onLaunched: (ActiveSession) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
navActive: Boolean = true,
|
||||
/**
|
||||
* The profile this shelf launches with, when it was opened from a PINNED host+profile card
|
||||
* (design §5.2a) rather than the host's own tile: a one-off, exactly like the card's plain
|
||||
* connect. Null = the host's tile, and the host's binding decides — the same rule
|
||||
* [ProfileStore.resolveFor] applies to every other connect.
|
||||
*/
|
||||
pinnedProfileId: String? = null,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
BackHandler(onBack = onBack)
|
||||
@@ -111,14 +99,6 @@ fun LibraryScreen(
|
||||
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) }
|
||||
// The profile every launch off this shelf runs with, resolved ONCE per shelf by the same rule
|
||||
// the host-list connect uses: this card's pin as the one-off, else the host's binding, else the
|
||||
// globals. Resolved here rather than per launch so a profile edited mid-browse cannot make two
|
||||
// titles on one shelf stream differently.
|
||||
val profile = remember(host.id, pinnedProfileId) {
|
||||
ProfileStore(context).resolveFor(host, pinnedProfileId)
|
||||
}
|
||||
val streamSettings = remember(settings, profile) { settings.effectiveFor(profile) }
|
||||
|
||||
LaunchedEffect(host.address, host.port, host.fpHex) {
|
||||
state = LibState.Loading
|
||||
@@ -147,17 +127,8 @@ fun LibraryScreen(
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||
GamepadAuroraBackground(Modifier.fillMaxSize())
|
||||
Column(Modifier.fillMaxSize().consoleSafeArea()) {
|
||||
// A pinned card's shelf says so, in the card's own `host · profile` shape: what a
|
||||
// launch here will use is a property of the shelf, not something to remember from
|
||||
// the tile two screens back.
|
||||
ConsoleHeader(
|
||||
if (pinnedProfileId != null && profile != null) {
|
||||
"${host.name} · ${profile.name} — Library"
|
||||
} else {
|
||||
"${host.name} — Library"
|
||||
},
|
||||
)
|
||||
Column(Modifier.fillMaxSize().systemBarsPadding()) {
|
||||
ConsoleHeader("${host.name} — Library")
|
||||
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
when (val s = state) {
|
||||
is LibState.Loading -> LoadingState()
|
||||
@@ -169,7 +140,7 @@ fun LibraryScreen(
|
||||
// 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, streamSettings, s.identity,
|
||||
context, settings, s.identity,
|
||||
host.address, host.port, host.fpHex, launch = game.id,
|
||||
)
|
||||
launching = false
|
||||
@@ -177,14 +148,11 @@ fun LibraryScreen(
|
||||
onLaunched(
|
||||
ActiveSession(
|
||||
handle,
|
||||
streamSettings,
|
||||
settings,
|
||||
host.clipboardSync,
|
||||
profileName = profile?.name,
|
||||
hostId = host.id,
|
||||
// Where to come back to when this game exits —
|
||||
// this shelf, pin and all, not the host's default one.
|
||||
// Where to come back to when this game exits.
|
||||
launchedFromLibrary = true,
|
||||
libraryProfileId = pinnedProfileId,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -203,7 +171,7 @@ fun LibraryScreen(
|
||||
// Launching overlay — the connect + host-side game boot takes a moment; block the pad while it runs.
|
||||
if (launching) {
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(ink.modalScrim),
|
||||
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.6f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
@@ -219,7 +187,7 @@ fun LibraryScreen(
|
||||
// screen (ignore the safe area in landscape, where the bottom edge isn't a tap target).
|
||||
Box(
|
||||
Modifier.align(Alignment.BottomStart)
|
||||
.consoleLegendInsets(landscape)
|
||||
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(
|
||||
@@ -294,18 +262,10 @@ private fun Coverflow(
|
||||
Text(
|
||||
if (current?.isLauncher == true) "LAUNCHERS" else "GAMES",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
// The palette's ink, not white: on a pale field this heading was white on
|
||||
// near-white and simply wasn't there.
|
||||
color = ink.fg(0.45f),
|
||||
color = Color.White.copy(alpha = 0.45f),
|
||||
letterSpacing = 2.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
// A live region: this heading is the ONLY signal that the cursor has
|
||||
// crossed from the launchers into the games, and a coverflow gives a
|
||||
// reader no other way to notice — it is one strip, not two lists.
|
||||
.semantics { liveRegion = LiveRegionMode.Polite }
|
||||
.padding(bottom = 8.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
HorizontalPager(
|
||||
@@ -327,22 +287,10 @@ private fun Coverflow(
|
||||
.width(coverWidth)
|
||||
.height(coverHeight)
|
||||
// Touch: tap the centred cover to launch it; tap a neighbour to bring it centre.
|
||||
// The label says which of the two a press does, because from the poster
|
||||
// alone they are indistinguishable — and the CENTRED one is the only one A
|
||||
// acts on, which nothing else in the tree says.
|
||||
.clickable(
|
||||
onClickLabel = if (page == pagerState.currentPage) {
|
||||
"Launch ${games[page].title}"
|
||||
} else {
|
||||
"Bring ${games[page].title} to the centre"
|
||||
},
|
||||
) {
|
||||
.clickable {
|
||||
if (page == pagerState.currentPage) onLaunch(games[page])
|
||||
else scope.launch { pagerState.animateScrollToPage(page) }
|
||||
}
|
||||
.semantics {
|
||||
if (page == pagerState.currentPage) selected = true
|
||||
}
|
||||
.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 —
|
||||
@@ -401,14 +349,11 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo
|
||||
val ink = LocalGamepadInk.current
|
||||
val candidates = game.art.posterCandidates
|
||||
var idx by remember(game.id) { mutableStateOf(0) }
|
||||
val shape = ConsoleShape.Poster
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(shape)
|
||||
// The ground a cover sits on while its art loads (and the permanent one for a launcher
|
||||
// entry, which rarely has art). Palette-derived rather than a fixed indigo, so a poster
|
||||
// wall on a pale field isn't a grid of dark holes.
|
||||
.background(LocalGamepadPalette.current.groundColor)
|
||||
.background(Color(0xFF241F3D))
|
||||
.border(1.dp, ink.fg(0.12f), shape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
@@ -422,53 +367,27 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo
|
||||
onError = { idx++ }, // this candidate failed — try the next, or fall to the placeholder
|
||||
)
|
||||
} else {
|
||||
// A launcher ships no poster by design, so its brand mark IS the poster — drawn big and
|
||||
// centred, tinted like the text it replaces. Falling back to the launcher's name says
|
||||
// "opens Steam" for a mark we don't ship; the title would read as "a game whose cover
|
||||
// failed to load".
|
||||
val mark = launcherIcon(game.iconToken)
|
||||
if (mark != null) {
|
||||
Icon(
|
||||
imageVector = mark,
|
||||
contentDescription = game.title,
|
||||
tint = ink.fg(0.75f),
|
||||
modifier = Modifier.fillMaxSize(0.45f),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
if (game.isLauncher) game.storeLabel else game.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = ink.fg(0.75f),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
}
|
||||
// A launcher rarely has poster art. Naming the launcher says "opens Steam"; the title
|
||||
// would read as "a game whose cover failed to load".
|
||||
Text(
|
||||
if (game.isLauncher) game.storeLabel else game.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = ink.fg(0.75f),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
}
|
||||
// Store badge, top-start — brand-filled for a launcher entry (design D4).
|
||||
Box(Modifier.fillMaxSize().padding(8.dp), contentAlignment = Alignment.TopStart) {
|
||||
Text(
|
||||
game.storeLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
// A launcher's badge is brand-filled, so it reads on the ACCENT; a game's sits on
|
||||
// a plain dark wash over its own art.
|
||||
color = if (game.isLauncher) ink.onAccent else Color.White,
|
||||
color = ink.fg,
|
||||
modifier = Modifier
|
||||
// A bare store name read out after the title says nothing about WHY it is
|
||||
// there; the poster's own description already carries the title.
|
||||
.semantics {
|
||||
contentDescription = if (game.isLauncher) {
|
||||
"Opens ${game.storeLabel}"
|
||||
} else {
|
||||
"From ${game.storeLabel}"
|
||||
}
|
||||
}
|
||||
.clip(ConsoleShape.Pill)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(
|
||||
// The console's palette accent, not `MaterialTheme.colorScheme.primary` —
|
||||
// that is the TOUCH theme's colour (Material You, seeded from the user's
|
||||
// wallpaper), which had nothing to do with the field this poster sits on.
|
||||
if (game.isLauncher) ink.accent
|
||||
if (game.isLauncher) MaterialTheme.colorScheme.primary
|
||||
else Color.Black.copy(alpha = 0.5f),
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.ScrollState
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -23,130 +19,20 @@ 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.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
|
||||
/**
|
||||
* Open-source licenses: punktfunk's own license (MIT OR Apache-2.0) plus the third-party software
|
||||
* notices, read from the bundled `THIRD-PARTY-NOTICES.txt` asset (generated by
|
||||
* scripts/gen-third-party-notices.sh). Reached from [SettingsScreen]; Back returns there.
|
||||
*
|
||||
* This is the TOUCH entry point; [ConsoleLicensesScreen] shows the same notices on the console's
|
||||
* field, where they need a scroll route a controller can actually drive.
|
||||
*/
|
||||
@Composable
|
||||
fun LicensesScreen(onBack: () -> Unit) {
|
||||
BackHandler(onBack = onBack)
|
||||
|
||||
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)
|
||||
}
|
||||
LicensesBody(
|
||||
scroll = rememberScrollState(),
|
||||
contentPadding = PaddingValues(start = 20.dp, end = 20.dp, bottom = 24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The notices on the console's field. The reason this exists as its own screen rather than the touch
|
||||
* one dropped into the shell is the SCROLL: the body is a wall of text with exactly one focusable
|
||||
* node (the touch screen's back arrow), and Compose scrolls a container only to keep a FOCUSED child
|
||||
* visible — so a controller could reach the first screenful of `THIRD-PARTY-NOTICES.txt` and not one
|
||||
* line further. Here up/down steps and the shoulders page, driving the scroll state directly.
|
||||
*
|
||||
* B closes, as everywhere else; there is nothing on this screen to confirm, so A is not advertised.
|
||||
*/
|
||||
@Composable
|
||||
fun ConsoleLicensesScreen(onBack: () -> Unit, navActive: Boolean = true) {
|
||||
BackHandler(onBack = onBack)
|
||||
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
val hazeState = remember { HazeState() }
|
||||
val scroll = rememberScrollState()
|
||||
val scrollBy = rememberConsoleScroller(scroll)
|
||||
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
|
||||
|
||||
GamepadNavEffect2D(
|
||||
active = navActive,
|
||||
onDirection = { dir ->
|
||||
when (dir) {
|
||||
NavDir.UP -> scrollBy(-1, false)
|
||||
NavDir.DOWN -> scrollBy(1, false)
|
||||
// Left/right are deliberately inert: there is nothing beside this text, and paging
|
||||
// sideways off a D-pad would be a second, undocumented way to do the shoulders' job.
|
||||
NavDir.LEFT, NavDir.RIGHT -> {}
|
||||
}
|
||||
},
|
||||
onActivate = {},
|
||||
onShoulder = { delta -> scrollBy(delta, true) },
|
||||
)
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||
// Calm: this is a screen to read, and a drifting field behind small monospace text is
|
||||
// the one place the aurora would be actively unhelpful. Full-bleed under the cutout —
|
||||
// only the content takes the safe area.
|
||||
GamepadFormBackground(Modifier.fillMaxSize())
|
||||
// Inked from the palette: the notices carry no colour of their own, so outside a Surface
|
||||
// they would render in Material's default BLACK content colour over the aurora.
|
||||
ConsoleInkedTheme {
|
||||
Column(Modifier.fillMaxSize().consoleSafeArea()) {
|
||||
LicensesBody(
|
||||
scroll = scroll,
|
||||
contentPadding = PaddingValues(
|
||||
start = ConsoleEdgeInset,
|
||||
end = ConsoleEdgeInset,
|
||||
bottom = ConsoleLegendClearance,
|
||||
),
|
||||
) {
|
||||
ConsoleHeader("Open-source licenses", horizontalInset = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.consoleLegendInsets(landscape)
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(
|
||||
listOfNotNull(
|
||||
GamepadHint('↕', PadGlyph.Arrow, "Scroll"),
|
||||
// A TV remote has no shoulders — its route is the D-pad, one step at a time.
|
||||
GamepadHint('⇄', PadGlyph.Arrow, "Page").takeIf { padIsGamepad },
|
||||
PadGlyph.hint('B', "Close", onClick = onBack),
|
||||
),
|
||||
hazeState = hazeState,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The notices themselves, shared by both interfaces — the licenses are a legal obligation, so the
|
||||
* two routes must show the same text rather than two copies that can drift. [heading] is empty for
|
||||
* the touch screen, which pins its own title row above the scroll.
|
||||
*/
|
||||
@Composable
|
||||
private fun LicensesBody(
|
||||
scroll: ScrollState,
|
||||
contentPadding: PaddingValues,
|
||||
heading: @Composable () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
BackHandler(onBack = onBack)
|
||||
|
||||
val notices = remember {
|
||||
runCatching {
|
||||
context.assets.open("THIRD-PARTY-NOTICES.txt").bufferedReader().use { it.readText() }
|
||||
@@ -166,40 +52,52 @@ private fun LicensesBody(
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scroll)
|
||||
.padding(contentPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
heading()
|
||||
if (version != null) {
|
||||
Text(
|
||||
"Punktfunk $version",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
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)
|
||||
}
|
||||
Text(
|
||||
"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,
|
||||
)
|
||||
Text(
|
||||
notices,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
)
|
||||
if (fontLicense != null) {
|
||||
Text("Bundled font", style = MaterialTheme.typography.titleMedium)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp)
|
||||
.padding(bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
if (version != null) {
|
||||
Text(
|
||||
"Punktfunk $version",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"The Geist typeface is licensed under the SIL Open Font License 1.1.",
|
||||
"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,
|
||||
)
|
||||
Text(
|
||||
fontLicense,
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.material3.Surface
|
||||
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
|
||||
@@ -143,16 +142,6 @@ class MainActivity : ComponentActivity() {
|
||||
var lastPadStyle by mutableStateOf(Gamepad.PadStyle.GENERIC)
|
||||
private set
|
||||
|
||||
/**
|
||||
* The `InputDevice.id` of the controller driving the console UI, or 0 for none. Kept beside
|
||||
* [lastPadStyle] because the console's menu haptics render on the DRIVING pad's own motors when
|
||||
* it has any — a rumble that comes out of the device you are not holding is worse than none.
|
||||
* Falls back to the phone body (see `rememberConsoleHaptics`), and to silence on a TV, where
|
||||
* neither a remote nor the box has an actuator.
|
||||
*/
|
||||
var lastPadDeviceId by mutableIntStateOf(0)
|
||||
private set
|
||||
|
||||
/**
|
||||
* A `punktfunk://` URL waiting to be routed — set from the VIEW intent that started (or
|
||||
* re-entered) this activity, cleared by whoever handles it. Compose observes it.
|
||||
@@ -617,10 +606,7 @@ class MainActivity : ComponentActivity() {
|
||||
// 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)
|
||||
lastPadDeviceId = event.deviceId
|
||||
}
|
||||
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 }
|
||||
@@ -709,7 +695,6 @@ class MainActivity : ComponentActivity() {
|
||||
if (dir != 0) {
|
||||
lastPadIsGamepad = true // a stick/HAT push can only come from a real gamepad
|
||||
lastPadStyle = Gamepad.styleFor(event.device)
|
||||
lastPadDeviceId = event.deviceId
|
||||
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, dir))
|
||||
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, dir))
|
||||
return true
|
||||
|
||||
@@ -94,20 +94,11 @@ data class Settings(
|
||||
val touchMode: TouchMode = TouchMode.TRACKPAD,
|
||||
/**
|
||||
* Swap the whole home screen for the controller-optimized "console" UI (the host carousel +
|
||||
* gamepad chrome) — mirrors the Apple client's `gamepadUIEnabled`. On by default; turn it off
|
||||
* to keep the touch UI even with a pad attached. WHEN it takes over is [gamepadUiMode].
|
||||
* 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,
|
||||
/**
|
||||
* When [gamepadUiEnabled] actually takes over — the cross-client `gamepad_ui_mode` pair,
|
||||
* mirroring the Apple client's `gamepadUIMode`: `"connected"` (default, and what the switch
|
||||
* has always meant) waits for a controller; `"always"` keeps the console UI with no pad in
|
||||
* reach, for a phone or tablet that lives docked to a TV. Read only while [gamepadUiEnabled]
|
||||
* is on, which is why both settings screens hide the row when the switch is off. Anything
|
||||
* unrecognized resolves to `"connected"`. A TV ignores it — it is always in console mode.
|
||||
*/
|
||||
val gamepadUiMode: String = GAMEPAD_UI_WHEN_CONNECTED,
|
||||
/**
|
||||
* 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
|
||||
@@ -116,10 +107,9 @@ data class Settings(
|
||||
val libraryEnabled: Boolean = true,
|
||||
/**
|
||||
* Which colour family the console (gamepad) UI's living backdrop drifts through — the
|
||||
* cross-client `ui_palette` key: `"violet"` (the brand default), then `"oled"`, `"nebula"`,
|
||||
* `"abyss"`, `"ember"`, `"moss"`, `"graphite"`, then the six pale fields. See
|
||||
* [GamepadPalette], whose table and maths mirror the desktop console's and the Apple
|
||||
* client's under the same names. Presentation only: nothing
|
||||
* cross-client `ui_palette` key: `"violet"` (the brand default), `"tide"`, `"forest"`,
|
||||
* `"ember"`, `"rose"`, `"graphite"`. See [GamepadPalette], whose table and maths mirror the
|
||||
* desktop console's and the Apple client's under the same names. Presentation only: nothing
|
||||
* about a stream depends on it, so it is a device preference and never part of a profile.
|
||||
* An unknown value reads as the default rather than failing — a newer client may have shipped
|
||||
* a palette this build doesn't know.
|
||||
@@ -168,16 +158,6 @@ data class Settings(
|
||||
* toggle is hidden on devices without a vibrator (TVs), where this would be a silent no-op.
|
||||
*/
|
||||
val rumbleOnPhone: Boolean = false,
|
||||
/**
|
||||
* Opt-in: use this phone's own gyroscope as controller 1's motion when the forwarded pad has
|
||||
* none of its own — for clip-on gamepads without an IMU, where the phone body moves with the
|
||||
* player's hands. The rumble mirror's sibling, data flowing the other way. Off by default;
|
||||
* read once per session by StreamScreen (it starts a [io.unom.punktfunk.kit.DeviceGyro] only
|
||||
* when set), and the mirror stands down by itself whenever wire pad 0 is fed by a capture
|
||||
* link (USB DualSense / SC2 — pads with a real gyro). The toggle is hidden on devices
|
||||
* without a gyroscope (TVs), where this would be a silent no-op.
|
||||
*/
|
||||
val gyroOnPhone: Boolean = false,
|
||||
|
||||
/**
|
||||
* Capture a Steam Controller 2 (wired / Puck dongle over USB, or an already-paired BLE pad)
|
||||
@@ -313,8 +293,6 @@ class SettingsStore(context: Context) {
|
||||
// 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),
|
||||
gamepadUiMode = prefs.getString(K_GAMEPAD_UI_MODE, GAMEPAD_UI_WHEN_CONNECTED)
|
||||
?: GAMEPAD_UI_WHEN_CONNECTED,
|
||||
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
||||
uiPalette = prefs.getString(K_UI_PALETTE, "violet") ?: "violet",
|
||||
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
|
||||
@@ -322,7 +300,6 @@ class SettingsStore(context: Context) {
|
||||
smoothBuffer = prefs.getInt(K_SMOOTH_BUFFER, 0),
|
||||
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
|
||||
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
|
||||
gyroOnPhone = prefs.getBoolean(K_GYRO_ON_PHONE, false),
|
||||
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
|
||||
dsCapture = prefs.getBoolean(K_DS_CAPTURE, true),
|
||||
padHaptics = prefs.getBoolean(K_PAD_HAPTICS, true),
|
||||
@@ -356,7 +333,6 @@ class SettingsStore(context: Context) {
|
||||
.putString(K_STATS_VERBOSITY, s.statsVerbosity.name)
|
||||
.putString(K_TOUCH_MODE, s.touchMode.name)
|
||||
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
|
||||
.putString(K_GAMEPAD_UI_MODE, s.gamepadUiMode)
|
||||
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||
.putString(K_UI_PALETTE, s.uiPalette)
|
||||
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
|
||||
@@ -364,7 +340,6 @@ class SettingsStore(context: Context) {
|
||||
.putInt(K_SMOOTH_BUFFER, s.smoothBuffer)
|
||||
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
|
||||
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
|
||||
.putBoolean(K_GYRO_ON_PHONE, s.gyroOnPhone)
|
||||
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
|
||||
.putBoolean(K_DS_CAPTURE, s.dsCapture)
|
||||
.putBoolean(K_PAD_HAPTICS, s.padHaptics)
|
||||
@@ -397,7 +372,6 @@ class SettingsStore(context: Context) {
|
||||
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_GAMEPAD_UI_MODE = "gamepad_ui_mode"
|
||||
const val K_LIBRARY = "library_enabled"
|
||||
const val K_UI_PALETTE = "ui_palette"
|
||||
|
||||
@@ -416,7 +390,6 @@ class SettingsStore(context: Context) {
|
||||
const val K_SMOOTH_BUFFER = "smooth_buffer"
|
||||
const val K_AUTO_WAKE = "auto_wake_enabled"
|
||||
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
|
||||
const val K_GYRO_ON_PHONE = "gyro_on_phone"
|
||||
const val K_SC2_CAPTURE = "sc2_capture"
|
||||
const val K_DS_CAPTURE = "ds_capture"
|
||||
const val K_PAD_HAPTICS = "pad_haptics"
|
||||
@@ -792,13 +765,6 @@ fun smoothBufferOptions(hz: Int): List<Pair<Int, String>> {
|
||||
)
|
||||
}
|
||||
|
||||
/** (stored value, label) for when the console UI takes over — the Apple client's table verbatim.
|
||||
* Only offered while [Settings.gamepadUiEnabled] is on; a TV is in console mode either way. */
|
||||
val GAMEPAD_UI_MODE_OPTIONS = listOf(
|
||||
GAMEPAD_UI_WHEN_CONNECTED to "With a controller",
|
||||
GAMEPAD_UI_ALWAYS to "Always",
|
||||
)
|
||||
|
||||
/** (mode, label) for the touch-input model. */
|
||||
val TOUCH_MODE_OPTIONS = listOf(
|
||||
TouchMode.TRACKPAD to "Trackpad",
|
||||
|
||||
@@ -77,7 +77,6 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import io.unom.punktfunk.kit.DeviceGyro
|
||||
import io.unom.punktfunk.kit.VideoDecoders
|
||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
@@ -577,7 +576,7 @@ private fun GeneralSettings(s: Settings, update: (Settings) -> Unit) {
|
||||
selected = s.statsVerbosity,
|
||||
field = "stats_verbosity",
|
||||
caption = "Compact is one line; Detailed adds the decoder and latency breakdown. " +
|
||||
"A 3-finger tap, or Select + X on a pad, cycles the tiers in-stream.",
|
||||
"A 3-finger tap cycles the tiers in-stream.",
|
||||
) { v -> update(s.copy(statsVerbosity = v)) }
|
||||
}
|
||||
DeviceScopeOnly {
|
||||
@@ -592,24 +591,11 @@ private fun GeneralSettings(s: Settings, update: (Settings) -> Unit) {
|
||||
SettingsGroup("Interface") {
|
||||
ToggleRow(
|
||||
title = "Controller-optimized UI",
|
||||
subtitle = "Swap the touch home for the console home — the host carousel and " +
|
||||
"gamepad chrome. A TV always uses it.",
|
||||
subtitle = "Switch to the console home when a controller is connected. A TV " +
|
||||
"always uses it.",
|
||||
checked = s.gamepadUiEnabled,
|
||||
onCheckedChange = { on -> update(s.copy(gamepadUiEnabled = on)) },
|
||||
)
|
||||
// Only decides anything while the switch above is on, so it is HIDDEN rather than
|
||||
// dimmed when it isn't — a picker whose every option changes nothing is worse than
|
||||
// no picker, and this group is short enough that nothing jumps far.
|
||||
if (s.gamepadUiEnabled) {
|
||||
SettingDropdown(
|
||||
label = "Show it",
|
||||
options = GAMEPAD_UI_MODE_OPTIONS,
|
||||
selected = s.gamepadUiMode,
|
||||
caption = "With a controller: the touch home comes back when the last one " +
|
||||
"disconnects. Always keeps the console home either way — for a device " +
|
||||
"that lives docked to a TV.",
|
||||
) { v -> update(s.copy(gamepadUiMode = v)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -863,8 +849,7 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
|
||||
field = "gamepad",
|
||||
enabled = s.gamepadForwarding,
|
||||
caption = "The virtual pad the host creates. Automatic matches your controller; " +
|
||||
"every connected one is forwarded as its own player. An X-Box type has no " +
|
||||
"gyroscope, so pick a DualSense-class one if you want motion.",
|
||||
"every connected one is forwarded as its own player.",
|
||||
) { g -> update(s.copy(gamepad = g)) }
|
||||
SettingDropdown(
|
||||
label = "Guide button",
|
||||
@@ -903,18 +888,6 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
|
||||
onCheckedChange = { on -> update(s.copy(rumbleOnPhone = on)) },
|
||||
)
|
||||
}
|
||||
// The rumble mirror's sibling, data flowing the other way: needs a gyroscope to
|
||||
// mirror FROM — a TV box has none, so the row would be a silent no-op there.
|
||||
val hasGyroscope = remember { DeviceGyro.available(context) }
|
||||
if (hasGyroscope) {
|
||||
ToggleRow(
|
||||
title = "Gyro from this phone",
|
||||
subtitle = "When the controller has no gyro, send this phone's motion " +
|
||||
"sensors as controller 1's",
|
||||
checked = s.gyroOnPhone,
|
||||
onCheckedChange = { on -> update(s.copy(gyroOnPhone = on)) },
|
||||
)
|
||||
}
|
||||
// NOT gated on the vibrator: SC2 passthrough is a USB/BLE capture that has nothing to do
|
||||
// with rumbling this device's body, and the gate hid the toggle on exactly the machines
|
||||
// that most want it — TV boxes, where a Steam Controller 2 is the whole input story.
|
||||
|
||||
@@ -18,13 +18,12 @@ import kotlin.math.roundToInt
|
||||
* The live stats overlay — the unified HUD (`design/stats-unification.md`): headline is
|
||||
* `capture→displayed` tiled by `host+network` + `decode` + `display` when the platform delivered
|
||||
* OnFrameRendered render callbacks this window (`dispValid`), falling back to the v1
|
||||
* `capture→decoded` headline without the `display` term when it didn't. Reads the 35-double
|
||||
* `capture→decoded` headline without the `display` term when it didn't. Reads the 33-double
|
||||
* layout from [NativeBridge.nativeVideoStats] (that KDoc is the authoritative index list):
|
||||
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skew, w, h, hz, lostTotal, bitDepth, colorPrimaries,
|
||||
* colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms, netP50Ms, lost, skipped,
|
||||
* fec, frames, dispValid, displayP50Ms, e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms,
|
||||
* presentsWindow, presenterActive, feedP50Ms, codecP50Ms, skippedOverflowWindow, audioBufferMs,
|
||||
* audioAvOffsetMs]`. Every read
|
||||
* presentsWindow, presenterActive, feedP50Ms, codecP50Ms, skippedOverflowWindow]`. Every read
|
||||
* is length-guarded, so an older native lib simply omits the lines it can't feed.
|
||||
*
|
||||
* The shown `display` and `end-to-end` numbers EXCLUDE the OS present floor (see [osFloorMs]) at
|
||||
@@ -45,7 +44,7 @@ import kotlin.math.roundToInt
|
||||
* reliability counters (18–21) when nonzero.
|
||||
* - [StatsVerbosity.DETAILED] — also the decoder label, the video-feed descriptor (10–13), the
|
||||
* stage equation (14/15, split into `host + network` when the Phase-2 terms at 16/17 are nonzero),
|
||||
* the excluded-floor line when one was measured, and the audio plane's own latency (33/34).
|
||||
* and the excluded-floor line when one was measured.
|
||||
* [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).
|
||||
*/
|
||||
@@ -179,42 +178,10 @@ internal fun StatsOverlay(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (detailed) {
|
||||
audioLine(s)?.let { statLine(it, Color.White) }
|
||||
}
|
||||
counterLine(s, lost)?.let { statLine(it, Color(0xFFFFB0B0)) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The audio plane's own latency from the live gauges at 33/34 — `audio buffer 42 ms · a/v +18 ms`,
|
||||
* the same wording the desktop HUD uses. `buffer` is how much decoded audio is queued ahead of the
|
||||
* speaker; `a/v` is where that PUTS it relative to the picture (positive = audio behind). `null`
|
||||
* before any audio has been queued (buffer 0 — audio off, or the ring not yet primed) and on an
|
||||
* older native layout.
|
||||
*
|
||||
* Both terms, not just the depth: a deep ring on a jittery link is correct behaviour — the
|
||||
* underrun-driven floor earned that buffer — and only the offset distinguishes it from a ring that
|
||||
* is simply holding audio late. The offset term is dropped at zero, which is both "aligned" and
|
||||
* "no measurement yet"; the depth alone is still the triage number, and it is the one that did not
|
||||
* exist at all before (the plane published nothing any surface could render, so a "the audio delay
|
||||
* is way too high" report had no instrument behind it).
|
||||
*
|
||||
* NOT shaved by [osFloorMs], unlike every video figure above. That shave is a reporting policy —
|
||||
* metrics report what Punktfunk controls — but sound has to reach the ear when the light reaches
|
||||
* the eye, so the sync loop aligns against the RAW capture→displayed time (see the native
|
||||
* `DisplayTracker`) and this offset is stated in those same terms. Subtracting the floor here would
|
||||
* report an alignment the listener is not getting.
|
||||
*/
|
||||
private fun audioLine(s: DoubleArray): String? {
|
||||
if (s.size < 35) return null
|
||||
val bufferMs = s[33].roundToInt()
|
||||
if (bufferMs <= 0) return null
|
||||
val avOffset = s[34].roundToInt()
|
||||
val avTerm = if (avOffset != 0) " · a/v ${if (avOffset > 0) "+" else ""}$avOffset ms" else ""
|
||||
return "audio buffer $bufferMs ms$avTerm"
|
||||
}
|
||||
|
||||
/** One monospace HUD line — the shared type ramp so every tier's rows line up. */
|
||||
@Composable
|
||||
private fun statLine(text: String, color: Color) {
|
||||
|
||||
@@ -28,9 +28,6 @@ import android.view.inputmethod.InputConnection
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -56,7 +53,6 @@ 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.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
@@ -71,13 +67,11 @@ import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import io.unom.punktfunk.kit.DeviceGyro
|
||||
import io.unom.punktfunk.kit.DsCapture
|
||||
import io.unom.punktfunk.kit.GamepadFeedback
|
||||
import io.unom.punktfunk.kit.GamepadRouter
|
||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.PadSensors
|
||||
import io.unom.punktfunk.kit.Sc2Capture
|
||||
import io.unom.punktfunk.kit.SessionEndReason
|
||||
import io.unom.punktfunk.kit.VideoDecoders
|
||||
@@ -143,48 +137,6 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
micHint = null
|
||||
}
|
||||
}
|
||||
// A captured pad has a gyro this session's virtual controller cannot carry (see
|
||||
// GamepadRouter.onMotionUnreachable). Shown briefly, then gone: the failure is otherwise
|
||||
// completely silent — the gyro simply does nothing, which from the couch is indistinguishable
|
||||
// from a broken sensor — and the fix is a setting, so the notice has to name it.
|
||||
var motionHint by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(motionHint) {
|
||||
if (motionHint) {
|
||||
// Longer than the mic chord's 1.6 s: that one confirms something the user just did,
|
||||
// this one explains something they did not, in a sentence they have to read.
|
||||
delay(6000)
|
||||
motionHint = false
|
||||
}
|
||||
}
|
||||
// Whether this session has a controller — the start banner names pad chords only when there is
|
||||
// a pad to press them on. Seeded from the router the moment it is built (it opens a slot for
|
||||
// every already-connected controller) and latched true by a pad that arrives later; it never
|
||||
// goes back to false. A pad LEAVING inside the banner's six seconds is not worth the write:
|
||||
// teardown closes every slot, and poking Compose state from there is exactly what the nulled
|
||||
// callbacks in onDispose avoid. The latch is also what carries a pad through a USB capture
|
||||
// claiming it — its InputDevice slot closes and reopens as a capture-link one.
|
||||
var padPresent by remember(handle) { mutableStateOf(false) }
|
||||
// The start-of-stream banner: what this session's shortcuts ARE, said once. A stream takes the
|
||||
// whole screen and answers to none of the device's usual gestures, so it has to say how to get
|
||||
// back out — the desktop console draws the same pill for the same reason
|
||||
// (`pf-console-ui/src/skia_overlay.rs`, BANNER_S = 6 s with a BANNER_FADE_S = 0.6 s tail).
|
||||
// Two states because the fade and the removal are different moments: `bannerUp` composes the
|
||||
// pill at all, `bannerFading` runs its alpha down over the last 600 ms.
|
||||
var bannerUp by remember(handle) { mutableStateOf(true) }
|
||||
var bannerFading by remember(handle) { mutableStateOf(false) }
|
||||
val bannerAlpha by animateFloatAsState(
|
||||
targetValue = if (bannerFading) 0f else 1f,
|
||||
// Linear, like the desktop's (BANNER_S - age) / BANNER_FADE_S ramp — Compose's default
|
||||
// easing would hold near-opaque and then drop, which reads as a glitch rather than a fade.
|
||||
animationSpec = tween(600, easing = LinearEasing),
|
||||
label = "streamStartBanner",
|
||||
)
|
||||
LaunchedEffect(handle) {
|
||||
delay(5400) // 6 s − the 0.6 s tail: fully opaque until here, exactly as on the desktop
|
||||
bannerFading = true
|
||||
delay(600)
|
||||
bannerUp = false // stop composing it once it is invisible
|
||||
}
|
||||
// The one place mute is toggled — Compose state + the native flag, always together.
|
||||
val setMicMuted = { muted: Boolean ->
|
||||
micMuted = muted
|
||||
@@ -194,8 +146,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
// 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 — or the Select + X
|
||||
// pad chord, which is the only route a TV or a passthrough-touch session has — cycles the
|
||||
// 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
|
||||
@@ -218,11 +169,6 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
// 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) }
|
||||
// A screen with fingers on it — the start banner may only name the three-finger stats tap on a
|
||||
// device that can perform it. A TV box has no touchscreen at all, and its remote is not one.
|
||||
val hasTouch = remember {
|
||||
context.packageManager.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN)
|
||||
}
|
||||
LaunchedEffect(handle, statsOn) {
|
||||
NativeBridge.nativeSetVideoStatsEnabled(handle, statsOn)
|
||||
if (statsOn) {
|
||||
@@ -401,9 +347,6 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
initialSettings.systemButtonsForward(), initialSettings.guideGestureEnabled(),
|
||||
)
|
||||
activity?.gamepadRouter = router
|
||||
// Every controller that was already connected got a slot in the router's constructor, so
|
||||
// this is the session's pad answer at t=0 — what the start banner's words are chosen from.
|
||||
padPresent = router.forwardedDevices().isNotEmpty()
|
||||
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips
|
||||
// the keep-alive linger), unlike a host-ended / backgrounded drop. The router debounces it
|
||||
// (must be held ~1.5 s) and fires onExitChord on its main-thread timer, so leave the stream
|
||||
@@ -416,9 +359,6 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
// Select + Y toggles the mic — the couch reach for the on-screen mute button, which a
|
||||
// gamepad/TV user has no pointer for. Ignored when no capture is running (there is nothing
|
||||
// to mute, and claiming otherwise would be the lie the control exists to avoid).
|
||||
// A captured Sony pad whose motion this session cannot carry. Fires once per pad, at the
|
||||
// moment it is claimed, on the main thread.
|
||||
router.onMotionUnreachable = { motionHint = true }
|
||||
router.onMicChord = {
|
||||
if (micRunning) {
|
||||
val next = !micMuted
|
||||
@@ -426,11 +366,6 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
micHint = if (next) "Microphone muted" else "Microphone live"
|
||||
}
|
||||
}
|
||||
// Select + X steps the stats overlay one tier — the same live cycle the three-finger tap
|
||||
// performs, and the ONLY route to it on a TV or in a passthrough-touch session. Session-
|
||||
// local on purpose: this mirrors the tap exactly (`onCycleStats` below), and the settings
|
||||
// row calls it a live cycle — the stored default is what the next stream starts from.
|
||||
router.onStatsChord = { statsVerbosity = statsVerbosity.next() }
|
||||
// Physical mouse: uncaptured hover/click/wheel forwards as absolute pointing; captured
|
||||
// (setting or the Ctrl+Alt+Shift+Q chord) raw deltas forward as relative mouse-look.
|
||||
// The local cursor is hidden over the stream — the host's own cursor, composited into
|
||||
@@ -520,44 +455,9 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
router,
|
||||
deviceVibrator = if (initialSettings.rumbleOnPhone) deviceBodyVibrator(context) else null,
|
||||
).also { it.start() }
|
||||
// "Gyro from this phone" (opt-in): this device's IMU speaks for controller 1's motion
|
||||
// while wire pad 0 is a controller without a gyro of its own — the rumble mirror's
|
||||
// sibling, data flowing the other way. The mirror gates itself per sample (it stands
|
||||
// down whenever pad 0's controller has motion of its own — a capture link below, or a
|
||||
// pad whose own sensors PadSensors is reading), so it composes without coordination here.
|
||||
val phoneGyro = if (initialSettings.gyroOnPhone && initialSettings.gamepadForwarding) {
|
||||
DeviceGyro(context, handle, router).also { it.start() }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
// A Bluetooth controller's OWN gyro, through the platform sensor framework (API 31+):
|
||||
// a BT DualSense / DS4 / Switch Pro / 8BitDo is an ordinary InputDevice, so none of the
|
||||
// capture links below ever sees it and its motion used to go nowhere at all. No separate
|
||||
// setting — this is the pad's own IMU doing what the pad is for, and unlike the USB
|
||||
// captures it claims nothing; forwarding being off is the only thing that silences it.
|
||||
val padSensors = if (initialSettings.gamepadForwarding) {
|
||||
PadSensors(router).also { it.start() }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
// Free a disconnected controller's rumble/lights bindings promptly (else the open lights
|
||||
// session leaks until the session ends), and take its sensor listeners off with it — the
|
||||
// same callback also fires when a USB capture below CLAIMS the pad, which is what keeps
|
||||
// the claimed pad from being fed motion twice. The router owns hot-plug; the feedback owns
|
||||
// the binds. Assigned before the captures are constructed, so their claims land on it.
|
||||
router.onSlotClosed = { deviceId ->
|
||||
feedback.onDeviceRemoved(deviceId)
|
||||
padSensors?.onSlotClosed(deviceId)
|
||||
}
|
||||
// The other edge: a controller that arrives (or first speaks) mid-session gets its sensors
|
||||
// read too. The pads already connected were swept by PadSensors.start() above — both run
|
||||
// on the main thread with nothing between them, so no controller falls through the gap.
|
||||
router.onSlotOpened = { deviceId ->
|
||||
padSensors?.onSlotOpened(deviceId)
|
||||
// A pad that wakes up a second into the stream still deserves the chord banner — the
|
||||
// desktop rebuilds its banner text every frame for exactly this case.
|
||||
padPresent = true
|
||||
}
|
||||
// session leaks until the session ends). The router owns hot-plug; the feedback owns the binds.
|
||||
router.onSlotClosed = feedback::onDeviceRemoved
|
||||
// Steam Controller 2 as-is passthrough (opt-out): capture a wired/Puck USB pad — or an
|
||||
// already-paired BLE one — and forward its raw reports; the host mirrors a real
|
||||
// 28DE:1302 that its Steam drives directly, and Steam's rumble/settings writes come back
|
||||
@@ -687,18 +587,12 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
feedback.onHidRaw = null
|
||||
feedback.sink = null
|
||||
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
|
||||
phoneGyro?.stop() // join the sensor thread + park pad 0's rotation at zero, same ordering rule
|
||||
// After the mirror, so it cannot resume writing pad 0 in the gap when a pad's own
|
||||
// sensors let go of it; before the router is released, so the parks still find slots.
|
||||
padSensors?.stop()
|
||||
sc2UsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||
sc2?.stop() // release the USB/BLE link + free the wire slot (host tears the pad down)
|
||||
dsUsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||
ds?.stop() // rumble-stop on the physical pad + release the USB link + free the wire slot
|
||||
router.onExitArmed = null // don't poke Compose state from release()'s disarm while tearing down
|
||||
router.onMicChord = null // same: no mute toggle on buttons released during teardown
|
||||
router.onStatsChord = null // same: no tier cycle on buttons released during teardown
|
||||
router.onMotionUnreachable = null // same: no notice raised by a slot closing at teardown
|
||||
router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener
|
||||
activity?.gamepadRouter = null
|
||||
// Mouse/remote-pointer teardown: lift held buttons, drop the grab, restore the cursor.
|
||||
@@ -900,42 +794,6 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
if (remotePointerOn) {
|
||||
RemotePointerHint(Modifier.align(Alignment.TopCenter).padding(top = 16.dp))
|
||||
}
|
||||
// The start banner (desktop parity), naming ONLY the shortcuts this session actually has:
|
||||
// pad chords when a controller is here, the Back gesture and the three-finger tap when it
|
||||
// is not. Recomputed rather than captured, because both inputs change under it — a pad can
|
||||
// wake mid-banner, and `micRunning` only settles once the capture has actually opened.
|
||||
// Above the video and below the gesture layer: it teaches touches, it must never eat one.
|
||||
//
|
||||
// Bottom-centre is the desktop's placement and the only edge left — TopStart is the HUD,
|
||||
// TopEnd the mic badge, TopCentre the three transient cues — but MotionUnreachableHint
|
||||
// already owns it, and both of these can be up at t≈0. The banner YIELDS rather than
|
||||
// stacking or sliding off-centre: the notice reports something broken about THIS session
|
||||
// and names the setting that fixes it, while the banner repeats shortcuts that will be
|
||||
// there next stream too. Two pills sharing an edge for six seconds would cost the reader
|
||||
// both.
|
||||
if (bannerUp && !motionHint) {
|
||||
StreamStartBanner(
|
||||
text = buildList {
|
||||
if (padPresent) {
|
||||
add("Hold Select + Start + L1 + R1 to leave")
|
||||
// Only while a capture is actually running: the chord itself no-ops
|
||||
// without one, and offering a mute for a mic nobody has is the lie the
|
||||
// whole control exists to avoid.
|
||||
if (micRunning) add("Select + Y mic")
|
||||
add("Select + X stats")
|
||||
} else {
|
||||
// No pad: Back is the deliberate exit (gesture, key, or a TV remote's
|
||||
// button — all land on the same BackHandler).
|
||||
add("Back leaves the stream")
|
||||
// The tap lives in the pointer touch models only — passthrough gives every
|
||||
// finger to the host verbatim — and needs a screen to put three fingers on.
|
||||
if (hasTouch && touchMode != TouchMode.TOUCH) add("three-finger tap for stats")
|
||||
}
|
||||
}.joinToString(" · "),
|
||||
alpha = bannerAlpha,
|
||||
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 24.dp),
|
||||
)
|
||||
}
|
||||
// Invisible 1-px focus anchor for the host-typing soft keyboard (three-finger swipe up
|
||||
// in the mouse modes) AND the pointer-capture grab target — it never draws or takes
|
||||
// touches, it just owns IME focus and receives captured-pointer events.
|
||||
@@ -995,11 +853,6 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
}
|
||||
// Chord confirmation (gamepad/TV) — the counterpart to the button changing under a finger.
|
||||
micHint?.let { MicChordHint(it, Modifier.align(Alignment.TopCenter).padding(top = 16.dp)) }
|
||||
// Bottom, not top: this can coincide with a mic-chord confirmation or the exit cue, and a
|
||||
// notice landing on top of one of those would cost the user both.
|
||||
if (motionHint) {
|
||||
MotionUnreachableHint(Modifier.align(Alignment.BottomCenter).padding(bottom = 24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1086,28 +939,6 @@ private fun MicChordHint(text: String, modifier: Modifier = Modifier) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* "This pad's gyro can't reach the game" — shown briefly when a captured controller with motion
|
||||
* meets a session whose virtual pad has no motion plane (the X-Box classes have no gyro in their
|
||||
* HID contract, so every sample would be decoded and dropped host-side).
|
||||
*
|
||||
* It names the setting because that is the whole point: without it the player has a gyro that
|
||||
* silently does nothing and no way to tell that from a broken sensor. Not a control — the setting
|
||||
* applies from the next session, so offering to change it here would promise something this stream
|
||||
* cannot deliver. [GamepadRouter.onMotionUnreachable] raises it.
|
||||
*/
|
||||
@Composable
|
||||
private fun MotionUnreachableHint(modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
"Motion won't reach this session — set Controller type to DualSense",
|
||||
modifier = modifier
|
||||
.background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
color = Color.White,
|
||||
fontSize = 15.sp,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The "hold to quit" cue shown while the gamepad exit chord (Select + Start + L1 + R1) is held. The
|
||||
* chord no longer quits on a quick press — the router debounces it on a ~1 s hold — so this confirms
|
||||
@@ -1142,33 +973,6 @@ private fun RemotePointerHint(modifier: Modifier = Modifier) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The start-of-stream banner: the shortcuts this session actually has, in the same pill as every
|
||||
* other in-stream cue, shown once and then gone. The desktop console draws the identical thing
|
||||
* bottom-centre (`pf-console-ui/src/skia_overlay.rs` — six seconds with a 0.6 s fade), because a
|
||||
* stream owns the whole screen and answers to none of the device's usual gestures: without a line
|
||||
* saying how to get back out, the only discoverable exit is force-quitting the app.
|
||||
*
|
||||
* [text] and [alpha] are the caller's. Only it knows what this session HAS — a pad, a mic, a
|
||||
* touchscreen — and only it owns the timer, which is precisely what a screenshot wants to skip.
|
||||
* Purely visual: it sits below the gesture layer, takes no touches and is never clickable. Internal
|
||||
* so the screenshot scene can shoot the real pill instead of a copy of it that drifts.
|
||||
*/
|
||||
@Composable
|
||||
internal fun StreamStartBanner(text: String, alpha: Float, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text,
|
||||
// Alpha FIRST: the fade has to take the pill's backdrop with it, and everything after this
|
||||
// in the chain draws inside the layer it opens.
|
||||
modifier = modifier
|
||||
.alpha(alpha)
|
||||
.background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
color = Color.White,
|
||||
fontSize = 15.sp,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Invisible focus anchor for typing on the host: the three-finger swipe summons the device IME
|
||||
* onto this view. Two IME models, picked by the host's capabilities:
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
package io.unom.punktfunk.components
|
||||
|
||||
// GENERATED by scripts/gen_launcher_icon_tables.py from the assets/launcher-icons masters.
|
||||
// Do not edit by hand — re-run `bash scripts/gen-launcher-icons.sh` instead.
|
||||
// Per-mark provenance and licensing: assets/launcher-icons/README.md.
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.PathParser
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* The brand mark a `role: "launcher"` tile draws, resolved from the entry's `icon` token.
|
||||
* Material ships no brand icons, so this is a curated registry — the sibling of [OsIcons],
|
||||
* which does the equivalent job for the host cards.
|
||||
*
|
||||
* Held as raw SVG path strings rather than transcribed ImageVector DSL: [PathParser] builds
|
||||
* the vector once and [launcherIcon] caches it. Viewports are the masters' own and are NOT
|
||||
* all square, so the builder letterboxes — a mark forced into a square box is a squashed mark.
|
||||
*/
|
||||
private class LauncherGlyph(
|
||||
val viewportWidth: Float,
|
||||
val viewportHeight: Float,
|
||||
val d: String,
|
||||
)
|
||||
|
||||
private val GLYPHS: Map<String, LauncherGlyph> = mapOf(
|
||||
"steam" to LauncherGlyph(
|
||||
viewportWidth = 496f,
|
||||
viewportHeight = 512f,
|
||||
d = "M496 256c0 137-111.2 248-248.4 248-113.8 0-209.6-76.3-239-180.4l95.2 39.3c6.4 32.1 34.9 56.4 68.9 56.4 39.2 0 71.9-32.4 70.2-73.5l84.5-60.2c52.1 1.3 95.8-40.9 95.8-93.5 0-51.6-42-93.5-93.7-93.5s-93.7 42-93.7 93.5v1.2L176.6 279c-15.5-.9-30.7 3.4-43.5 12.1L0 236.1C10.2 108.4 117.1 8 247.6 8 384.8 8 496 119 496 256zM155.7 384.3l-30.5-12.6a52.79 52.79 0 0 0 27.2 25.8c26.9 11.2 57.8-1.6 69-28.4 5.4-13 5.5-27.3.1-40.3-5.4-13-15.5-23.2-28.5-28.6-12.9-5.4-26.7-5.2-38.9-.6l31.5 13c19.8 8.2 29.2 30.9 20.9 50.7-8.3 19.9-31 29.2-50.8 21zm173.8-129.9c-34.4 0-62.4-28-62.4-62.3s28-62.3 62.4-62.3 62.4 28 62.4 62.3-27.9 62.3-62.4 62.3zm.1-15.6c25.9 0 46.9-21 46.9-46.8 0-25.9-21-46.8-46.9-46.8s-46.9 21-46.9 46.8c.1 25.8 21.1 46.8 46.9 46.8z",
|
||||
),
|
||||
"lutris" to LauncherGlyph(
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
d = "m21.231 18.89.001-.002c-1.293 3.243-5.218 5.232-9.447 5.105C5.3 23.993 0 18.48 0 11.906S5.276.001 11.785.001c1.793 0 3.493.406 5.015 1.13.081-.177.271-.544.451-.557.238-.017.374.137.526.309.154.172.46.429.46.429s1.393-.481 2.955.377c1.563.858 1.783 1.116 2.09 1.716.152.301.195.829.2 1.282a.796.796 0 0 0-.07-.003c-.496 0-.96.455-.96 1.08 0 .263.082.496.215.678l-.01.007a1.505 1.505 0 0 0-.132.01 18.704 18.704 0 0 0-.389-.142 2.53 2.53 0 0 1-.82-.472 1.402 1.402 0 0 0-1.196-2.112c-.383 0-.73.156-.982.41-.472-.271-1.174-.482-2.527-.565l-.407-.011c-2.282.012-3.611.279-5.979 1.301-.603.283-1.206.615-1.785 1.001-.423.3-.639.67-.709 1.137a1.326 1.326 0 0 0 1.23 1.373h.042c1.27.06 2.039 1.99 2.063 2.497.004.05.004.023.003.08-.032.727-.37 1.267-1.088 1.246a1.231 1.231 0 0 1-.976-.494c-.063-.077-.103-.172-.159-.254-.666-1.081-1.732-1.36-2.771-1.523-.438-.068-1.073-.122-1.31.25a8.28 8.28 0 0 0-.577 3.063c-.02 5.036 4.041 9.118 9.026 9.118 2.575 0 5.349-.952 6.993-2.7l-.035.03c-1.772 1.473-4.66 1.941-6.027 1.941-4.302 0-7.818-3.232-7.818-7.578 0-1.276.288-2.396.814-3.36.495.183.947.483 1.28 1.022a.24.24 0 0 0 .013.021c.064.092.111.197.182.284.424.524.881.658 1.342.68h.01c.43.013.768-.12 1.024-.342.347-.3.55-.79.577-1.382v-.014c.002-.085 0-.053-.004-.112-.024-.376-.333-1.318-.906-2.027-.266-.331-.587-.607-.95-.774l.12-.074c.756-.457 2.364-.977 4.592-.638 1.13.173 2.055.419 3.483.879 1.657.534 2.579 1.279 3.854 1.427.15.017.301.018.45.003.41 1.129.634 2.35.634 3.621 0 2.068-.59 3.995-1.611 5.62zm1.947-12.274s-.115.201-.364.322c-.103.05-.282-.075-.45.1-.359.726.516 1.332.923 1.315.408-.017.73-.432.712-.793-.017-.558-.82-.944-.82-.944zm.234-1.432c.255 0 .462.26.462.58 0 .32-.207.58-.462.58-.254 0-.46-.26-.46-.58 0-.32.206-.58.46-.58zm-3.292-.951c.492 0 .89.403.89.9a.895.895 0 0 1-.89.898.895.895 0 0 1-.89-.899c0-.496.399-.899.89-.899z",
|
||||
),
|
||||
"heroic" to LauncherGlyph(
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
d = "M11.999 0 11.997 0a.891.891 0 0 0-.36.075C8.964 1.253 6.29 2.434 3.618 3.613A.893.893 0 0 0 3.1 4.619l3.146 14.646c.043.197.15.375.307.504l4.88 4.027a.895.895 0 0 0 1.131.006l5-4.031a.895.895 0 0 0 .315-.516L20.9 4.614a.895.895 0 0 0-.515-1L12.358.074A.892.892 0 0 0 12 0zm0 .35v.003c.114 0 .228.023.334.07l7.42 3.27a.827.827 0 0 1 .476.924l-2.793 13.535a.83.83 0 0 1-.289.478l-4.623 3.725a.826.826 0 0 1-1.045-.006l-4.513-3.723a.829.829 0 0 1-.281-.465L3.775 4.622a.83.83 0 0 1 .476-.931L11.665.42a.832.832 0 0 1 .334-.07zm-.045 1.954L10.28 5.202h-.002l1.211 11.301.512.409.512-.409 1.117-11.3zM9.003 16.261l-.584 1.068.584 1.07 2.295-.38.47-.69-.47-.671zm5.996 0-2.295.397-.47.671.47.69 2.295.38.584-1.07zm-2.998 1.488-.51.444-.281 2.168.789.55.793-.55-.295-2.168z",
|
||||
),
|
||||
"playnite" to LauncherGlyph(
|
||||
viewportWidth = 1024f,
|
||||
viewportHeight = 1024f,
|
||||
d = "M966.686,623.899c-9.773-81.666-29.323-161.25-54.514-239.447c-13.759-42.709-30.419-84.189-56.091-121.452 c-31.701-46.014-74.789-72.958-130.812-78.579c-29.631-2.973-57.785,4.118-85.677,12.35 c-61.172,18.056-123.359,25.124-186.493,14.903c-30.919-5.006-61.308-13.526-91.743-21.225 c-76.445-19.338-145.323,4.995-191.165,69.261c-11.441,16.04-21.194,33.543-29.78,51.312 c-25.091,51.925-40.443,107.249-54.53,162.924c-18.822,74.393-33.019,149.491-33.664,226.571c0,7.184-0.342,14.386,0.061,21.547 c1.557,27.727,4.354,55.289,16.045,80.97c15.334,33.68,45.905,46.725,79.471,31.198c18.291-8.461,36.293-19.857,50.766-33.743 c24.597-23.598,46.616-49.934,69.125-75.64c17.934-20.481,39.086-35.301,66.115-40.203c15.779-2.862,31.802-6.006,47.736-6.118 c87.888-0.62,175.783-0.602,263.673-0.278c51.4,0.189,93.314,19.382,124.091,62.134c12.518,17.388,27.83,32.889,42.78,48.371 c18.598,19.259,38.974,36.431,64.412,46.39c32.967,12.907,62.547,1.677,77.882-30.198c3.965-8.242,6.963-17.122,9.155-26.017 C976.198,727.534,972.874,675.607,966.686,623.899z M315.471,527.643c-44.289,0.213-80.733-36.32-80.847-81.045 c-0.115-45.048,35.472-81.194,80.197-81.458c44.521-0.263,80.718,35.897,80.884,80.801 C395.871,490.671,359.773,527.429,315.471,527.643z M708.857,319.301c21.859,0.06,39.486,17.884,39.471,39.91 c-0.015,22.133-17.489,39.677-39.523,39.682c-22.045,0.005-39.456-17.53-39.444-39.724 C669.372,337.125,687.089,319.241,708.857,319.301z M622.269,486.36c-21.542,0.085-39.7-18.08-39.808-39.822 c-0.108-21.888,17.617-39.622,39.62-39.641c22.066-0.018,39.759,17.552,39.718,39.442 C661.758,468.205,643.909,486.275,622.269,486.36z M708.967,573.333c-21.823,0.096-39.537-17.668-39.611-39.721 c-0.074-22.079,17.523-39.992,39.338-40.044c21.715-0.052,39.597,17.908,39.645,39.816 C748.386,555.477,730.883,573.237,708.967,573.333z M795.752,486.362c-21.764,0.155-39.671-17.882-39.651-39.938 c0.021-22.15,17.628-39.639,39.793-39.525c22.091,0.114,39.527,17.993,39.155,40.152 C834.686,468.733,817.216,486.209,795.752,486.362z",
|
||||
),
|
||||
"epic" to LauncherGlyph(
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
d = "M3.537 0C2.165 0 1.66.506 1.66 1.879V18.44a4.262 4.262 0 00.02.433c.031.3.037.59.316.92.027.033.311.245.311.245.153.075.258.13.43.2l8.335 3.491c.433.199.614.276.928.27h.002c.314.006.495-.071.928-.27l8.335-3.492c.172-.07.277-.124.43-.2 0 0 .284-.211.311-.243.28-.33.285-.621.316-.92a4.261 4.261 0 00.02-.434V1.879c0-1.373-.506-1.88-1.878-1.88zm13.366 3.11h.68c1.138 0 1.688.553 1.688 1.696v1.88h-1.374v-1.8c0-.369-.17-.54-.523-.54h-.235c-.367 0-.537.17-.537.539v5.81c0 .369.17.54.537.54h.262c.353 0 .523-.171.523-.54V8.619h1.373v2.143c0 1.144-.562 1.71-1.7 1.71h-.694c-1.138 0-1.7-.566-1.7-1.71V4.82c0-1.144.562-1.709 1.7-1.709zm-12.186.08h3.114v1.274H6.117v2.603h1.648v1.275H6.117v2.774h1.74v1.275h-3.14zm3.816 0h2.198c1.138 0 1.7.564 1.7 1.708v2.445c0 1.144-.562 1.71-1.7 1.71h-.799v3.338h-1.4zm4.53 0h1.4v9.201h-1.4zm-3.13 1.235v3.392h.575c.354 0 .523-.171.523-.54V4.965c0-.368-.17-.54-.523-.54zm-3.74 10.147a1.708 1.708 0 01.591.108 1.745 1.745 0 01.49.299l-.452.546a1.247 1.247 0 00-.308-.195.91.91 0 00-.363-.068.658.658 0 00-.28.06.703.703 0 00-.224.163.783.783 0 00-.151.243.799.799 0 00-.056.299v.008a.852.852 0 00.056.31.7.7 0 00.157.245.736.736 0 00.238.16.774.774 0 00.303.058.79.79 0 00.445-.116v-.339h-.548v-.565H7.37v1.255a2.019 2.019 0 01-.524.307 1.789 1.789 0 01-.683.123 1.642 1.642 0 01-.602-.107 1.46 1.46 0 01-.478-.3 1.371 1.371 0 01-.318-.455 1.438 1.438 0 01-.115-.58v-.008a1.426 1.426 0 01.113-.57 1.449 1.449 0 01.312-.46 1.418 1.418 0 01.474-.309 1.58 1.58 0 01.598-.111 1.708 1.708 0 01.045 0zm11.963.008a2.006 2.006 0 01.612.094 1.61 1.61 0 01.507.277l-.386.546a1.562 1.562 0 00-.39-.205 1.178 1.178 0 00-.388-.07.347.347 0 00-.208.052.154.154 0 00-.07.127v.008a.158.158 0 00.022.084.198.198 0 00.076.066.831.831 0 00.147.06c.062.02.14.04.236.061a3.389 3.389 0 01.43.122 1.292 1.292 0 01.328.17.678.678 0 01.207.24.739.739 0 01.071.337v.008a.865.865 0 01-.081.382.82.82 0 01-.229.285 1.032 1.032 0 01-.353.18 1.606 1.606 0 01-.46.061 2.16 2.16 0 01-.71-.116 1.718 1.718 0 01-.593-.346l.43-.514c.277.223.578.335.9.335a.457.457 0 00.236-.05.157.157 0 00.082-.142v-.008a.15.15 0 00-.02-.077.204.204 0 00-.073-.066.753.753 0 00-.143-.062 2.45 2.45 0 00-.233-.062 5.036 5.036 0 01-.413-.113 1.26 1.26 0 01-.331-.16.72.72 0 01-.222-.243.73.73 0 01-.082-.36v-.008a.863.863 0 01.074-.359.794.794 0 01.214-.283 1.007 1.007 0 01.34-.185 1.423 1.423 0 01.448-.066 2.006 2.006 0 01.025 0zm-9.358.025h.742l1.183 2.81h-.825l-.203-.499H8.623l-.198.498h-.81zm2.197.02h.814l.663 1.08.663-1.08h.814v2.79h-.766v-1.602l-.711 1.091h-.016l-.707-1.083v1.593h-.754zm3.469 0h2.235v.658h-1.473v.422h1.334v.61h-1.334v.442h1.493v.658h-2.255zm-5.3.897l-.315.793h.624zm-1.145 5.19h8.014l-4.09 1.348z",
|
||||
),
|
||||
"gog" to LauncherGlyph(
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
d = "M7.15 15.24H4.36a.4.4 0 0 0-.4.4v2c0 .21.18.4.4.4h2.8v1.32h-3.5c-.56 0-1.02-.46-1.02-1.03v-3.39c0-.56.46-1.02 1.03-1.02h3.48v1.32zM8.16 11.54c0 .58-.47 1.05-1.05 1.05H2.63v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4H4.39a.4.4 0 0 0-.41.4v2.02c0 .23.18.4.4.4H6v1.35H3.68c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04H7.1c.58 0 1.05.47 1.05 1.04v5.86zM21.36 19.36h-1.32v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.42c0-.56.46-1.02 1.03-1.02h5.61v5.44zM21.37 11.54c0 .58-.47 1.05-1.05 1.05h-4.48v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4h-2.03a.4.4 0 0 0-.4.4v2.02c0 .23.18.4.4.4h1.62v1.35H16.9c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04h3.43c.58 0 1.05.47 1.05 1.04v5.86zM13.72 4.64h-3.44c-.58 0-1.04.47-1.04 1.04v3.44c0 .58.46 1.04 1.04 1.04h3.44c.57 0 1.04-.46 1.04-1.04V5.68c0-.57-.47-1.04-1.04-1.04m-.3 1.75v2.02a.4.4 0 0 1-.4.4h-2.03a.4.4 0 0 1-.4-.4V6.4c0-.22.17-.4.4-.4H13c.23 0 .4.18.4.4zM12.63 13.92H9.24c-.57 0-1.03.46-1.03 1.02v3.39c0 .57.46 1.03 1.03 1.03h3.39c.57 0 1.03-.46 1.03-1.03v-3.39c0-.56-.46-1.02-1.03-1.02m-.3 1.72v2a.4.4 0 0 1-.4.4v-.01H9.94a.4.4 0 0 1-.4-.4v-1.99c0-.22.18-.4.4-.4h2c.22 0 .4.18.4.4zM23.49 1.1a1.74 1.74 0 0 0-1.24-.52H1.75A1.74 1.74 0 0 0 0 2.33v19.34a1.74 1.74 0 0 0 1.75 1.75h20.5A1.74 1.74 0 0 0 24 21.67V2.33c0-.48-.2-.92-.51-1.24m0 20.58a1.23 1.23 0 0 1-1.24 1.24H1.75A1.23 1.23 0 0 1 .5 21.67V2.33a1.23 1.23 0 0 1 1.24-1.24h20.5a1.24 1.24 0 0 1 1.24 1.24v19.34z",
|
||||
),
|
||||
"xbox" to LauncherGlyph(
|
||||
viewportWidth = 512f,
|
||||
viewportHeight = 512f,
|
||||
d = "M369.9 318.2c44.3 54.3 64.7 98.8 54.4 118.7-7.9 15.1-56.7 44.6-92.6 55.9-29.6 9.3-68.4 13.3-100.4 10.2-38.2-3.7-76.9-17.4-110.1-39-27.9-18.2-34.2-25.7-34.2-40.6 0-29.9 32.9-82.3 89.2-142.1 32-33.9 76.5-73.7 81.4-72.6 9.4 2.1 84.3 75.1 112.3 109.5zM188.6 143.8c-29.7-26.9-58.1-53.9-86.4-63.4-15.2-5.1-16.3-4.8-28.7 8.1-29.2 30.4-53.5 79.7-60.3 122.4-5.4 34.2-6.1 43.8-4.2 60.5 5.6 50.5 17.3 85.4 40.5 120.9 9.5 14.6 12.1 17.3 9.3 9.9-4.2-11-.3-37.5 9.5-64 14.3-39 53.9-112.9 120.3-194.4zm311.6 63.5c-16.9-80-67.5-130.3-74.6-130.3-7.3 0-24.2 6.5-36 13.9-23.3 14.5-41 31.4-64.3 52.8 42.4 53.3 102.2 139.4 122.9 202.3 6.8 20.7 9.7 41.1 7.4 52.3-1.7 8.5-1.7 8.5 1.4 4.6 6.1-7.7 19.9-31.3 25.4-43.5 7.4-16.2 15-40.2 18.6-58.7 4.3-22.5 3.9-70.8-.8-93.4zM141.3 43c47.7-2.5 109.7 34.5 114.3 35.4 .7 .1 10.4-4.2 21.6-9.7 63.9-31.1 94-25.8 107.4-25.2-63.9-39.3-152.7-50-233.9-11.7-23.4 11.1-24 11.9-9.4 11.2z",
|
||||
),
|
||||
)
|
||||
|
||||
private val CACHE = HashMap<String, ImageVector>()
|
||||
|
||||
/**
|
||||
* The [ImageVector] for an `icon` token, or null when the entry carries none or names a mark
|
||||
* this build ships no art for — the caller then falls back to naming the launcher, which is
|
||||
* what every launcher tile looked like before the token existed.
|
||||
*
|
||||
* Tinted by the caller via `tint`, so one mark serves every palette.
|
||||
*/
|
||||
fun launcherIcon(token: String?): ImageVector? {
|
||||
val glyph = GLYPHS[token ?: return null] ?: return null
|
||||
return CACHE.getOrPut(token) {
|
||||
// Square the box and centre the mark in it, so a wide or tall master keeps its aspect
|
||||
// ratio instead of being stretched to the tile.
|
||||
val side = max(glyph.viewportWidth, glyph.viewportHeight)
|
||||
val dx = (side - glyph.viewportWidth) / 2f
|
||||
val dy = (side - glyph.viewportHeight) / 2f
|
||||
ImageVector.Builder(
|
||||
name = "launcher_$token",
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = side,
|
||||
viewportHeight = side,
|
||||
).apply {
|
||||
addGroup(translationX = dx, translationY = dy)
|
||||
addPath(
|
||||
pathData = PathParser().parsePathString(glyph.d).toNodes(),
|
||||
fill = SolidColor(Color.White),
|
||||
)
|
||||
clearGroup()
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
@@ -71,24 +71,8 @@ data class ActiveSession(
|
||||
* [io.unom.punktfunk.kit.SessionEndReason.GAME_EXITED] ending.
|
||||
*/
|
||||
val launchedFromLibrary: Boolean = false,
|
||||
/**
|
||||
* Which of [hostId]'s shelves that library launch came off: the pinned host+profile card's
|
||||
* profile id (design §5.2a), or null for the host's own tile. Carried purely so the return
|
||||
* trip above lands back on the SAME shelf — a player who launched from a pinned card is still
|
||||
* on that card when the game exits, and coming back to the host's default shelf would silently
|
||||
* change what the next title streams with.
|
||||
*/
|
||||
val libraryProfileId: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* The library shelf a finished game launch should return to: the saved host's id, and the pinned
|
||||
* profile card it was opened from (null = the host's own tile). One value rather than two parallel
|
||||
* ones, because a hostId that arrives without its profile is not "the same shelf" — it is the
|
||||
* default one wearing the same name.
|
||||
*/
|
||||
data class LibraryReturn(val hostId: String, val profileId: String? = null)
|
||||
|
||||
/** Trust state of a host, shown as a colored pill on its card. */
|
||||
enum class HostStatus(val label: String) {
|
||||
PAIRED("Paired"),
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/**
|
||||
* The console route to the two sub-screens, driven through the REAL settings screen — the rows
|
||||
* themselves are pinned by `ConsoleSubScreenRowsTest`; what needs the Compose runtime is the trip:
|
||||
* that a press on the row reaches the shell, and that coming back lands where you left rather than
|
||||
* at the top of the first section (the shell's `AnimatedContent` discards a screen's state the
|
||||
* moment it stops being the target, so the place has to travel out and back).
|
||||
*
|
||||
* Rows are activated by TAP for the same reason `GamepadSettingsLayoutTest` does it: the pad path
|
||||
* needs a `MainActivity` for its probes, and both routes end in the same `activate`.
|
||||
*
|
||||
* `sdk = [36]` for the reason every Robolectric test here pins it: android-all jars stop at 36 while
|
||||
* the app compiles against 37.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [36], qualifiers = "w360dp-h800dp-xxhdpi")
|
||||
class ConsoleSubScreenRoutesTest {
|
||||
@get:Rule
|
||||
val compose = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun openingTheControllersRowNavigatesAndReportsWhereItWas() {
|
||||
var opened = 0
|
||||
var place: GpSettingsPlace? = null
|
||||
compose.setContent {
|
||||
GamepadSettingsScreen(
|
||||
initial = Settings(),
|
||||
onChange = {},
|
||||
onBack = {},
|
||||
onOpenControllers = { opened++ },
|
||||
// Entering as if we had just come back from it, which is also what puts the cursor
|
||||
// on the row — so a single tap ACTIVATES rather than merely focusing.
|
||||
resume = GpSettingsPlace(GpTab.CONTROLLER, "controllers"),
|
||||
onPlace = { place = it },
|
||||
)
|
||||
}
|
||||
compose.waitForIdle()
|
||||
|
||||
compose.onNodeWithText("Connected controllers").performClick()
|
||||
compose.waitForIdle()
|
||||
|
||||
assertEquals("the console never reached the diagnostics screen", 1, opened)
|
||||
assertEquals(
|
||||
"the place has to leave before the row does — this screen is gone the next frame",
|
||||
GpSettingsPlace(GpTab.CONTROLLER, "controllers"),
|
||||
place,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Back from a sub-screen lands on the section it was opened from, with the row on screen. The
|
||||
* cursor is restored by row ID rather than index, so it survives a section whose length follows
|
||||
* the hardware.
|
||||
*/
|
||||
@Test
|
||||
fun comingBackFromTheNoticesLandsOnTheRowThatOpenedThem() {
|
||||
compose.setContent {
|
||||
GamepadSettingsScreen(
|
||||
initial = Settings(),
|
||||
onChange = {},
|
||||
onBack = {},
|
||||
resume = GpSettingsPlace(GpTab.INTERFACE, "licenses"),
|
||||
)
|
||||
}
|
||||
compose.waitForIdle()
|
||||
|
||||
compose.onNodeWithText("Open-source licenses").assertIsDisplayed()
|
||||
// Not back at the top of the first section — "Resolution" leads the Stream tab, which is
|
||||
// where a screen that forgot its place would be.
|
||||
compose.onNodeWithText("Resolution").assertDoesNotExist()
|
||||
// And the legend describes THIS row's A. It said the literal "Pin to hosts" on every
|
||||
// non-adjustable row back when profiles were the only ones.
|
||||
compose.onNodeWithText("Open").assertIsDisplayed()
|
||||
compose.onNodeWithText("Pin to hosts").assertDoesNotExist()
|
||||
}
|
||||
|
||||
/**
|
||||
* The notices screen stands on its own on the console's field: no Scaffold or Surface above it
|
||||
* (the shell has neither), its own backdrop, and a legend that says how to leave. Composing it
|
||||
* is most of the assertion — a screen that only ever ran inside the touch Scaffold takes its
|
||||
* content colour from one.
|
||||
*/
|
||||
@Test
|
||||
fun theConsoleNoticesScreenStandsOnItsOwn() {
|
||||
compose.setContent { ConsoleLicensesScreen(onBack = {}) }
|
||||
compose.waitForIdle()
|
||||
|
||||
compose.onNodeWithText("Open-source licenses").assertIsDisplayed()
|
||||
compose.onNodeWithText("Scroll").assertIsDisplayed()
|
||||
compose.onNodeWithText("Close").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The console's route to the two screens that were reachable from touch only — the
|
||||
* connected-controllers diagnostics and the open-source notices.
|
||||
*
|
||||
* "Touch only" reads as a minor gap on a phone and is a dead end on a TV box, where the console IS
|
||||
* the interface: there is no touch UI to fall back to, so a screen with no console row could not be
|
||||
* opened at all. These pin the rows themselves; `ConsoleSubScreenRoutesTest` drives the real screen.
|
||||
*/
|
||||
class ConsoleSubScreenRowsTest {
|
||||
|
||||
private fun rows(
|
||||
forwarding: Boolean = true,
|
||||
version: String = "1.2.3",
|
||||
controllers: () -> Unit = {},
|
||||
licenses: () -> Unit = {},
|
||||
): List<GpRow> = buildSettingsRows(
|
||||
Settings(gamepadForwarding = forwarding),
|
||||
hasBodyVibrator = true,
|
||||
hasGyroscope = true,
|
||||
av1Capable = true,
|
||||
appVersion = version,
|
||||
openControllers = controllers,
|
||||
openLicenses = licenses,
|
||||
) {}
|
||||
|
||||
private fun row(rows: List<GpRow>, id: String): GpRow = rows.first { it.id == id }
|
||||
|
||||
@Test
|
||||
fun `the controllers row opens the diagnostics view from the controller section`() {
|
||||
var opened = 0
|
||||
val r = row(rows(controllers = { opened++ }), "controllers")
|
||||
assertEquals(GpTab.CONTROLLER, r.tab)
|
||||
assertEquals("Connected controllers", r.label)
|
||||
r.activate()
|
||||
assertEquals(1, opened)
|
||||
}
|
||||
|
||||
/**
|
||||
* It must NOT follow the master forwarding switch, unlike every other row in its section: the
|
||||
* screen it opens is what you reach for precisely when forwarding looks broken, and a diagnostic
|
||||
* that dims itself when the thing it diagnoses is off is worse than no diagnostic.
|
||||
*/
|
||||
@Test
|
||||
fun `the controllers row stays live with forwarding off`() {
|
||||
val off = rows(forwarding = false)
|
||||
assertTrue(row(off, "controllers").enabled)
|
||||
assertNotNull(liveRow(off, off.indexOfFirst { it.id == "controllers" }))
|
||||
// Its neighbours in the section still dim, so this is a deliberate exemption and not a
|
||||
// forgotten `enabled =`.
|
||||
assertFalse(row(off, "sc2").enabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the about row opens the notices and states the installed version`() {
|
||||
var opened = 0
|
||||
val r = row(rows(version = "0.27.0", licenses = { opened++ }), "licenses")
|
||||
assertEquals(GpTab.INTERFACE, r.tab)
|
||||
assertEquals("About", r.header)
|
||||
// The version rides in the value slot — on a TV this row is the whole About page.
|
||||
assertEquals("0.27.0", r.value)
|
||||
r.activate()
|
||||
assertEquals(1, opened)
|
||||
}
|
||||
|
||||
/** Both navigate; neither holds a value, so left/right must be refused rather than silently eaten. */
|
||||
@Test
|
||||
fun `neither row steps a value`() {
|
||||
val all = rows()
|
||||
for (id in listOf("controllers", "licenses")) {
|
||||
val r = row(all, id)
|
||||
assertFalse("$id should draw no chevrons", r.adjustable)
|
||||
assertFalse("$id must refuse a step", r.adjust(1))
|
||||
assertFalse("$id must refuse a step", r.adjust(-1))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The legend follows the ROW. It used to say the literal "Pin to hosts" on every non-adjustable
|
||||
* row, because a profile row was the only kind there was — so the moment another one existed,
|
||||
* A on it was advertised as pinning something.
|
||||
*/
|
||||
@Test
|
||||
fun `an action row advertises what A actually does`() {
|
||||
val all = rows()
|
||||
assertEquals("Open", row(all, "controllers").actionHint)
|
||||
assertEquals("Open", row(all, "licenses").actionHint)
|
||||
val profiles = buildProfileRows(listOf(newProfile("Work")), emptyList(), tv = false) {}
|
||||
assertEquals("Pin to hosts", profiles.first().actionHint)
|
||||
}
|
||||
|
||||
/**
|
||||
* The scroll geometry both console sub-screens share. A wall of text has no focusable rows for
|
||||
* Compose to keep visible, so these screens move the scroll state themselves — and how far one
|
||||
* press travels is the whole of their feel.
|
||||
*/
|
||||
@Test
|
||||
fun `a page overlaps what you were reading and a step is shorter still`() {
|
||||
val viewport = 1000f
|
||||
val page = consoleScrollDelta(viewport, page = true, dir = 1)
|
||||
val step = consoleScrollDelta(viewport, page = false, dir = 1)
|
||||
assertTrue("a page that skips a whole screenful loses your place", page < viewport)
|
||||
assertTrue("a page has to be worth pressing", page > viewport / 2f)
|
||||
assertTrue("a D-pad step must be shorter than a shoulder page", step > 0f && step < page)
|
||||
assertEquals("the other direction is the other way", -page, consoleScrollDelta(viewport, true, -1), 0.001f)
|
||||
// Before the first layout there is no viewport: a press then moves nothing, rather than
|
||||
// scrolling by a fraction of zero and reading as a dead button on the way in.
|
||||
assertEquals(0f, consoleScrollDelta(0f, page = true, dir = 1), 0f)
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import java.io.File
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The console UI's cross-client contract, against `clients/shared/console-vectors.json`.
|
||||
*
|
||||
* The background palettes, the settings section names and the screen-transition motion each exist
|
||||
* in three hand-written copies — this client, `pf-console-ui` (Rust) and the Apple client — and
|
||||
* until this file they were held together by nothing but a comment asking the next person to keep
|
||||
* them in step. Two of the three had already drifted.
|
||||
*
|
||||
* Read straight off disk with a relative path rather than copied into test resources, for the
|
||||
* reason the deeplink vectors state: a copy would be a fourth contract, free to go stale. Gradle
|
||||
* runs a unit test with the MODULE directory as its working directory, so `../../shared/` from
|
||||
* `clients/android/app` lands on `clients/shared`.
|
||||
*
|
||||
* What this pins that the older [GamepadPaletteTest] could not: the DERIVED 16-cell mesh and the
|
||||
* 4 blob colours per palette. Those are what actually reach the screen — the mesh through the AGSL
|
||||
* shader on API 33+, the blobs through the fallback field below it — and the existing tests only
|
||||
* ever measured the `stops` they are computed from.
|
||||
*/
|
||||
class ConsoleVectorsTest {
|
||||
private companion object {
|
||||
/** One step of Compose's 8-bit-per-component sRGB packing — see the blob comparison. */
|
||||
const val EIGHT_BIT_STEP = 1.0 / 255.0
|
||||
}
|
||||
|
||||
private val vectors: JSONObject by lazy {
|
||||
val file = File("../../shared/console-vectors.json")
|
||||
assertTrue(
|
||||
"the shared vector file must be reachable at ${file.absolutePath}",
|
||||
file.isFile,
|
||||
)
|
||||
JSONObject(file.readText())
|
||||
}
|
||||
|
||||
private fun JSONObject.doubles(key: String): List<Double> =
|
||||
getJSONArray(key).let { a -> (0 until a.length()).map { a.getDouble(it) } }
|
||||
|
||||
private fun close(what: String, got: Double, want: Double, tol: Double = 1e-6) {
|
||||
assertTrue(
|
||||
"$what: vectors say $want, this client computes $got",
|
||||
kotlin.math.abs(got - want) <= tol,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cellRampAndMeshInteriorMatch() {
|
||||
assertEquals("CELL_RAMP", vectors.doubles("cell_ramp"), GamepadPalette.CELL_RAMP)
|
||||
|
||||
val interior = vectors.getJSONArray("mesh_interior")
|
||||
assertEquals("mesh interior count", GamepadPalette.MESH_INTERIOR.size, interior.length())
|
||||
GamepadPalette.MESH_INTERIOR.forEachIndexed { i, p ->
|
||||
val w = interior.getJSONArray(i)
|
||||
val got = listOf(p.x, p.y, p.amp, p.sx, p.sy, p.phase)
|
||||
got.forEachIndexed { k, v -> close("mesh_interior[$i][$k]", v, w.getDouble(k)) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Every palette, field by field — and then the two tables derived from it. */
|
||||
@Test
|
||||
fun everyPaletteMatchesTheSharedVectors() {
|
||||
val want = vectors.getJSONArray("palettes")
|
||||
assertEquals("palette count", want.length(), GamepadPalette.ALL.size)
|
||||
GamepadPalette.ALL.forEachIndexed { i, p ->
|
||||
val w = want.getJSONObject(i)
|
||||
val id = w.getString("id")
|
||||
assertEquals("palette order", id, p.id)
|
||||
assertEquals("$id name", w.getString("name"), p.name)
|
||||
assertEquals("$id light", w.getBoolean("light"), p.light)
|
||||
|
||||
val stops = w.getJSONArray("stops")
|
||||
assertEquals("$id stop count", stops.length(), p.stops.size)
|
||||
p.stops.forEachIndexed { s, t ->
|
||||
val ws = stops.getJSONArray(s)
|
||||
close("$id stops[$s].r", t.first, ws.getDouble(0))
|
||||
close("$id stops[$s].g", t.second, ws.getDouble(1))
|
||||
close("$id stops[$s].b", t.third, ws.getDouble(2))
|
||||
}
|
||||
|
||||
val ground = w.doubles("ground")
|
||||
close("$id ground.r", p.ground.first, ground[0])
|
||||
close("$id ground.g", p.ground.second, ground[1])
|
||||
close("$id ground.b", p.ground.third, ground[2])
|
||||
val accent = w.doubles("accent")
|
||||
close("$id accent.r", p.accent.first, accent[0])
|
||||
close("$id accent.g", p.accent.second, accent[1])
|
||||
close("$id accent.b", p.accent.third, accent[2])
|
||||
|
||||
// The mesh the shader is built from — 16 cells, sampled off the ramp per CELL_RAMP.
|
||||
val mesh = w.getJSONArray("mesh")
|
||||
assertEquals("$id mesh cells", mesh.length(), p.meshColors.size)
|
||||
p.meshColors.forEachIndexed { c, t ->
|
||||
val wc = mesh.getJSONArray(c)
|
||||
close("$id mesh[$c].r", t.first, wc.getDouble(0))
|
||||
close("$id mesh[$c].g", t.second, wc.getDouble(1))
|
||||
close("$id mesh[$c].b", t.third, wc.getDouble(2))
|
||||
}
|
||||
|
||||
// The four blobs the API 28–32 fallback field drifts. These come back as Compose
|
||||
// `Color`s, which pack an sRGB colour at 8 bits per component — so the table
|
||||
// round-trips through 1/255 quantisation and the tolerance below IS that quantisation,
|
||||
// not slack. Anything the contract actually cares about (a mistyped stop, a shifted
|
||||
// sample point) moves these by far more than one 8-bit step.
|
||||
val blobs = w.getJSONArray("blobs")
|
||||
assertEquals("$id blob count", blobs.length(), p.blobColors.size)
|
||||
p.blobColors.forEachIndexed { b, colour ->
|
||||
val wb = blobs.getJSONArray(b)
|
||||
close("$id blob[$b].r", colour.red.toDouble(), wb.getDouble(0), EIGHT_BIT_STEP)
|
||||
close("$id blob[$b].g", colour.green.toDouble(), wb.getDouble(1), EIGHT_BIT_STEP)
|
||||
close("$id blob[$b].b", colour.blue.toDouble(), wb.getDouble(2), EIGHT_BIT_STEP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The section names, in order. The desktop console carries one tab this client does not —
|
||||
* Input, which holds touch mode, mouse, invert-scroll and shortcuts: desktop-host settings
|
||||
* with nothing to set on a phone or a TV. The vectors flag it `desktop_only` rather than
|
||||
* leaving it out, so neither side has to red the other to be right.
|
||||
*/
|
||||
@Test
|
||||
fun tabNamesMatchTheSharedVectors() {
|
||||
val tabs = vectors.getJSONArray("tabs")
|
||||
val want = (0 until tabs.length())
|
||||
.map { tabs.getJSONObject(it) }
|
||||
.filterNot { it.optBoolean("desktop_only", false) }
|
||||
.map { it.getString("name") }
|
||||
assertEquals("console settings tabs", want, GpTab.entries.map { it.title })
|
||||
}
|
||||
|
||||
/**
|
||||
* The screen-transition contract. The easing is sampled rather than compared as Bézier
|
||||
* control points: this client evaluates the desktop's analytic `1 − (1−t)³` directly, while
|
||||
* SwiftUI can only approximate it — samples with a tolerance are the one form all three can
|
||||
* meet. It is also the assertion that would have caught the curve this client shipped with
|
||||
* first, a "cubic-bezier(0.215, 0.61, 0.355, 1)" that is a full 0.08 slack at the midpoint.
|
||||
*/
|
||||
@Test
|
||||
fun motionMatchesTheSharedVectors() {
|
||||
val motion = vectors.getJSONObject("motion")
|
||||
close("transition", ConsoleMotion.TRANSITION_MS / 1000.0, motion.getDouble("transition_s"))
|
||||
close("push slide", ConsoleMotion.PUSH_SLIDE.value.toDouble(), motion.getDouble("push_slide_dp"))
|
||||
close("enter scale", ConsoleMotion.ENTER_SCALE.toDouble(), motion.getDouble("enter_scale"), 1e-5)
|
||||
close("exit scale", ConsoleMotion.EXIT_SCALE.toDouble(), motion.getDouble("exit_scale"), 1e-5)
|
||||
close("reveal alpha", ConsoleMotion.REVEAL_ALPHA.toDouble(), motion.getDouble("reveal_alpha"), 1e-5)
|
||||
|
||||
val curve = motion.getJSONObject("ease_out_cubic")
|
||||
val tol = curve.getDouble("tolerance")
|
||||
val samples = curve.getJSONArray("samples")
|
||||
assertTrue("the curve needs enough samples to pin it", samples.length() >= 5)
|
||||
for (i in 0 until samples.length()) {
|
||||
val s = samples.getJSONObject(i)
|
||||
val t = s.getDouble("t")
|
||||
close(
|
||||
"ease_out_cubic($t)",
|
||||
ConsoleMotion.EaseOutCubic.transform(t.toFloat()).toDouble(),
|
||||
s.getDouble("p"),
|
||||
tol,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,14 +33,14 @@ class GamepadPaletteTest {
|
||||
fun tableMatchesTheOtherClients() {
|
||||
assertEquals(
|
||||
listOf(
|
||||
"violet", "oled", "nebula", "abyss", "ember", "moss", "graphite",
|
||||
"violet", "nebula", "abyss", "ember", "moss", "graphite",
|
||||
"holo", "sunset", "bloom", "dawn", "mint", "opal",
|
||||
),
|
||||
GamepadPalette.ALL.map { it.id },
|
||||
)
|
||||
// Dark fields lead, pale ones follow, so stepping the row walks one direction.
|
||||
val firstLight = GamepadPalette.ALL.indexOfFirst { it.light }
|
||||
assertEquals(7, firstLight)
|
||||
assertEquals(6, firstLight)
|
||||
assertTrue(GamepadPalette.ALL.drop(firstLight).all { it.light })
|
||||
// An unknown name is a newer client's palette, not an error.
|
||||
assertEquals("violet", GamepadPalette.named("chartreuse").id)
|
||||
@@ -72,25 +72,6 @@ class GamepadPaletteTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OLED is the one palette whose selling point is measurable: it has to be genuinely black,
|
||||
* not merely the darkest of the dark fields. The blob field this client draws samples the
|
||||
* ramp at 0.15/0.40/0.65/0.90, so its darkest blob lands in the all-black head of the ramp.
|
||||
*/
|
||||
@Test
|
||||
fun oledIsActuallyBlack() {
|
||||
val oled = GamepadPalette.named("oled")
|
||||
assertEquals(Triple(0.0, 0.0, 0.0), oled.ground)
|
||||
assertEquals(0f, oled.blobColors[0].red, 1e-6f)
|
||||
assertEquals(0f, oled.blobColors[0].green, 1e-6f)
|
||||
assertEquals(0f, oled.blobColors[0].blue, 1e-6f)
|
||||
val mean = oled.stops.sumOf { luma(it) } / oled.stops.size
|
||||
val darkestOther = GamepadPalette.ALL
|
||||
.filter { it.id != "oled" && it.stops.isNotEmpty() }
|
||||
.minOf { p -> p.stops.sumOf { luma(it) } / p.stops.size }
|
||||
assertTrue("oled means $mean, barely under $darkestOther", mean < darkestOther / 2)
|
||||
}
|
||||
|
||||
/** A pale palette really is pale — its ink flips, so a mislabelled one is unreadable. */
|
||||
@Test
|
||||
fun palettesAreHonestAboutLightness() {
|
||||
@@ -142,9 +123,7 @@ class GamepadPaletteTest {
|
||||
*/
|
||||
@Test
|
||||
fun everySettingsRowHasATab() {
|
||||
val rows = buildSettingsRows(
|
||||
Settings(), hasBodyVibrator = true, hasGyroscope = true, av1Capable = true,
|
||||
) {}
|
||||
val rows = buildSettingsRows(Settings(), hasBodyVibrator = true, av1Capable = true) {}
|
||||
assertTrue(rows.isNotEmpty())
|
||||
assertEquals(rows.size, rows.map { it.id }.toSet().size)
|
||||
// Profiles is built separately (from the catalog), so no settings row claims it.
|
||||
@@ -158,9 +137,7 @@ class GamepadPaletteTest {
|
||||
@Test
|
||||
fun backgroundRowStepsTheSharedKey() {
|
||||
var s = Settings()
|
||||
fun rows() = buildSettingsRows(
|
||||
s, hasBodyVibrator = false, hasGyroscope = false, av1Capable = false,
|
||||
) { s = it }
|
||||
fun rows() = buildSettingsRows(s, hasBodyVibrator = false, av1Capable = false) { s = it }
|
||||
fun palette() = rows().first { it.id == "palette" }
|
||||
|
||||
assertEquals("violet", s.uiPalette)
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.getBoundsInRoot
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/**
|
||||
* The console settings list must not MOVE under the cursor. This is the regression net for the
|
||||
* layout instability the visual refresh fixed, and it needs the real Compose runtime because the
|
||||
* bug was entirely a layout one — every value the model held was correct throughout.
|
||||
*
|
||||
* What used to happen: the focused row unfolded its description in place
|
||||
* (`AnimatedVisibility` + `expandVertically`), so every step of the cursor shrank one row and grew
|
||||
* another and shifted every row below the focus point — on a list that is simultaneously being
|
||||
* scrolled to keep the focused row visible, whose target therefore moved mid-animation. The
|
||||
* description now renders in the screen's floating `ConsoleDetailBand`, which is an overlay and
|
||||
* cannot displace anything. Sideways, the value's `AnimatedContent` animated its own WIDTH on every
|
||||
* step, walking the ‹ chevron and the label's right edge back and forth.
|
||||
*
|
||||
* Focus is moved by TAP here rather than by pad: the pad path needs a `MainActivity` for its input
|
||||
* probes, and the screen routes both to the same `focus` state — the geometry under test is the
|
||||
* same either way.
|
||||
*
|
||||
* `sdk = [36]` for the reason every Robolectric test here pins it: android-all jars stop at 36
|
||||
* while the app compiles against 37.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [36], qualifiers = "w360dp-h800dp-xxhdpi")
|
||||
class GamepadSettingsLayoutTest {
|
||||
@get:Rule
|
||||
val compose = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private fun settings() {
|
||||
compose.setContent {
|
||||
GamepadSettingsScreen(initial = Settings(), onChange = {}, onBack = {})
|
||||
}
|
||||
}
|
||||
|
||||
/** A Dp compared at hairline tolerance — a rounding difference is not a layout shift. */
|
||||
private fun assertSame(what: String, expected: Dp, actual: Dp) {
|
||||
assertEquals(what, expected.value.toDouble(), actual.value.toDouble(), 0.5)
|
||||
}
|
||||
|
||||
/**
|
||||
* Moving the cursor down the list leaves every OTHER row exactly where it was. The rows below
|
||||
* the new focus are the ones the old in-row detail pushed around, so they are the assertion
|
||||
* that matters; the row above proves the shrink half.
|
||||
*/
|
||||
@Test
|
||||
fun focusingARowMovesNoOtherRow() {
|
||||
settings()
|
||||
// Entry focus is the first row (Resolution), so "Refresh rate" starts unfocused and
|
||||
// "Compositor" sits below both candidates.
|
||||
val refreshBefore = compose.onNodeWithText("Refresh rate").getBoundsInRoot()
|
||||
val compositorBefore = compose.onNodeWithText("Compositor").getBoundsInRoot()
|
||||
|
||||
// One tap on an unfocused row focuses it (a second would activate it — see the screen).
|
||||
compose.onNodeWithText("Bitrate").performClick()
|
||||
compose.waitForIdle()
|
||||
|
||||
val refreshAfter = compose.onNodeWithText("Refresh rate").getBoundsInRoot()
|
||||
val compositorAfter = compose.onNodeWithText("Compositor").getBoundsInRoot()
|
||||
assertSame("row above the cursor moved", refreshBefore.top, refreshAfter.top)
|
||||
assertSame("row below the cursor moved", compositorBefore.top, compositorAfter.top)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stepping a value leaves the row's own geometry alone. The label's right edge is the probe:
|
||||
* it is what the widening value slot used to shove, and it is stable for any value that fits
|
||||
* the slot (which every shipped Bitrate label does).
|
||||
*/
|
||||
@Test
|
||||
fun steppingAValueMovesNoLabel() {
|
||||
settings()
|
||||
compose.onNodeWithText("Bitrate").performClick() // focus it
|
||||
compose.waitForIdle()
|
||||
val labelBefore = compose.onNodeWithText("Bitrate").getBoundsInRoot()
|
||||
|
||||
compose.onNodeWithText("Bitrate").performClick() // now activates → cycles the value
|
||||
compose.waitForIdle()
|
||||
|
||||
val labelAfter = compose.onNodeWithText("Bitrate").getBoundsInRoot()
|
||||
assertSame("label moved sideways under a value step", labelBefore.left, labelAfter.left)
|
||||
assertSame("label moved sideways under a value step", labelBefore.right, labelAfter.right)
|
||||
assertSame("row changed height under a value step", labelBefore.top, labelAfter.top)
|
||||
}
|
||||
|
||||
/**
|
||||
* The focused row's description is on screen — in the floating band, not inside the row. Proves
|
||||
* the detail did not simply get dropped when it left the row: it is still what the cursor
|
||||
* explains itself with.
|
||||
*/
|
||||
@Test
|
||||
fun theFocusedRowsDetailIsShown() {
|
||||
settings()
|
||||
compose.onNodeWithText("Refresh rate").performClick()
|
||||
compose.waitForIdle()
|
||||
compose.onNodeWithText("Frame rate the host renders and streams at.").assertExists()
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,6 @@ class GamepadSettingsRowsTest {
|
||||
): List<GpRow> = buildSettingsRows(
|
||||
Settings(gamepadForwarding = forwarding),
|
||||
hasBodyVibrator = true,
|
||||
hasGyroscope = true,
|
||||
av1Capable = true,
|
||||
) { sink += it }
|
||||
|
||||
@@ -95,47 +94,4 @@ class GamepadSettingsRowsTest {
|
||||
// Drawn as a switch, and reading the persisted default.
|
||||
assertEquals(true, row(on, "dsCapture").toggled)
|
||||
}
|
||||
|
||||
/**
|
||||
* The activation-mode row is a sub-setting of the Controller-optimized UI switch, so it is
|
||||
* OFFERED only while that switch is on — hidden rather than dimmed, because with the switch
|
||||
* off this whole screen is about to be replaced by the touch UI and a dimmed row there would
|
||||
* be one last thing to step past on the way out.
|
||||
*/
|
||||
@Test
|
||||
fun `the activation-mode row follows the switch it belongs to`() {
|
||||
fun ids(enabled: Boolean) = buildSettingsRows(
|
||||
Settings(gamepadUiEnabled = enabled),
|
||||
hasBodyVibrator = false, hasGyroscope = false, av1Capable = false,
|
||||
) {}.map { it.id }
|
||||
|
||||
val on = ids(enabled = true)
|
||||
assertTrue("the mode row is missing", "gamepadUIMode" in on)
|
||||
assertEquals(
|
||||
"the mode belongs directly under the switch it qualifies",
|
||||
on.indexOf("gamepadUI") + 1,
|
||||
on.indexOf("gamepadUIMode"),
|
||||
)
|
||||
val off = ids(enabled = false)
|
||||
assertFalse("the mode row must not outlive its switch", "gamepadUIMode" in off)
|
||||
assertTrue("the switch itself stays, or it could never be turned back on", "gamepadUI" in off)
|
||||
}
|
||||
|
||||
/** Stepping the mode row writes the shared `gamepad_ui_mode` value, and wraps on A. */
|
||||
@Test
|
||||
fun `the activation-mode row steps the shared key`() {
|
||||
var s = Settings()
|
||||
fun mode() = buildSettingsRows(
|
||||
s, hasBodyVibrator = false, hasGyroscope = false, av1Capable = false,
|
||||
) { s = it }.first { it.id == "gamepadUIMode" }
|
||||
|
||||
assertEquals(GAMEPAD_UI_WHEN_CONNECTED, s.gamepadUiMode)
|
||||
assertEquals("With a controller", mode().value)
|
||||
assertFalse("already the first = thud", mode().adjust(-1))
|
||||
assertTrue(mode().adjust(1))
|
||||
assertEquals(GAMEPAD_UI_ALWAYS, s.gamepadUiMode)
|
||||
// A from the last entry wraps home.
|
||||
mode().activate()
|
||||
assertEquals(GAMEPAD_UI_WHEN_CONNECTED, s.gamepadUiMode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* [gamepadUiActive] is pure — table-tested over its inputs, and the mirror of the Apple client's
|
||||
* `GamepadUIEnvironmentTests`. The two clients share the stored `gamepad_ui_mode` values, so a
|
||||
* disagreement here is a device that behaves differently from the same setting.
|
||||
*/
|
||||
class GamepadUiTest {
|
||||
|
||||
/** The default mode is what the switch meant when it was a lone Boolean. */
|
||||
@Test
|
||||
fun whenConnectedWaitsForAPad() {
|
||||
assertTrue(gamepadUiActive(true, GAMEPAD_UI_WHEN_CONNECTED, true, tv = false, forced = false))
|
||||
assertFalse(gamepadUiActive(true, GAMEPAD_UI_WHEN_CONNECTED, false, tv = false, forced = false))
|
||||
assertFalse(gamepadUiActive(false, GAMEPAD_UI_WHEN_CONNECTED, true, tv = false, forced = false))
|
||||
assertFalse(gamepadUiActive(false, GAMEPAD_UI_WHEN_CONNECTED, false, tv = false, forced = false))
|
||||
// A TV is in console mode whatever the mode says — its remote is the only input.
|
||||
assertTrue(gamepadUiActive(true, GAMEPAD_UI_WHEN_CONNECTED, false, tv = true, forced = false))
|
||||
}
|
||||
|
||||
/** Always drops the controller from the decision — but never the switch, which is the one
|
||||
* way back to the touch UI. */
|
||||
@Test
|
||||
fun alwaysIgnoresThePadButNotTheSwitch() {
|
||||
assertTrue(gamepadUiActive(true, GAMEPAD_UI_ALWAYS, false, tv = false, forced = false))
|
||||
assertTrue(gamepadUiActive(true, GAMEPAD_UI_ALWAYS, true, tv = false, forced = false))
|
||||
assertFalse(gamepadUiActive(false, GAMEPAD_UI_ALWAYS, false, tv = false, forced = false))
|
||||
assertFalse(gamepadUiActive(false, GAMEPAD_UI_ALWAYS, true, tv = false, forced = false))
|
||||
}
|
||||
|
||||
/** A value a newer client wrote waits for a pad rather than stranding this build in a
|
||||
* layout it has no way back out of. */
|
||||
@Test
|
||||
fun anUnknownModeWaitsForAPad() {
|
||||
assertFalse(gamepadUiActive(true, "whenever-i-say-so", false, tv = false, forced = false))
|
||||
assertTrue(gamepadUiActive(true, "whenever-i-say-so", true, tv = false, forced = false))
|
||||
assertFalse(gamepadUiActive(true, "", false, tv = false, forced = false))
|
||||
}
|
||||
|
||||
/** The shipped default: the console UI still waits for a controller. */
|
||||
@Test
|
||||
fun theDefaultIsUnchangedBehaviour() {
|
||||
val s = Settings()
|
||||
assertTrue(s.gamepadUiEnabled)
|
||||
assertEquals(GAMEPAD_UI_WHEN_CONNECTED, s.gamepadUiMode)
|
||||
assertFalse(gamepadUiActive(s.gamepadUiEnabled, s.gamepadUiMode, false, tv = false, forced = false))
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import io.unom.punktfunk.kit.discovery.DiscoveredHost
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The console home's tile list ([buildHomeTiles]). Pure JVM — the carousel itself needs the live
|
||||
* JNI core to compose, so its ORDER and what each tile claims had no cover at all until now, and
|
||||
* both are exactly the kind of thing that survives a refactor looking fine and behaving wrong.
|
||||
*
|
||||
* Run: `./gradlew :app:testDebugUnitTest --tests 'io.unom.punktfunk.HomeTilesTest'`.
|
||||
*/
|
||||
class HomeTilesTest {
|
||||
private fun host(
|
||||
name: String,
|
||||
address: String,
|
||||
fp: String = "",
|
||||
profileId: String? = null,
|
||||
pins: List<String> = emptyList(),
|
||||
) = KnownHost(
|
||||
address = address,
|
||||
port = 9777,
|
||||
name = name,
|
||||
fpHex = fp,
|
||||
paired = true,
|
||||
id = "id-$name",
|
||||
profileId = profileId,
|
||||
pinnedProfileIds = pins,
|
||||
)
|
||||
|
||||
private fun advert(name: String, address: String, fp: String? = null) = DiscoveredHost(
|
||||
key = "$address:9777",
|
||||
name = name,
|
||||
host = address,
|
||||
port = 9777,
|
||||
fingerprint = fp,
|
||||
)
|
||||
|
||||
private val work = StreamProfile(id = "p-work", name = "Work", accent = "#3B82F6")
|
||||
private val travel = StreamProfile(id = "p-travel", name = "Travel")
|
||||
|
||||
/** The builder with nothing plugged in — every list empty, every callback a no-op. */
|
||||
private fun tiles(
|
||||
savedHosts: List<KnownHost> = emptyList(),
|
||||
profiles: List<StreamProfile> = emptyList(),
|
||||
pins: Map<String, List<StreamProfile>> = emptyMap(),
|
||||
discoveredUnsaved: List<DiscoveredHost> = emptyList(),
|
||||
online: Set<String> = emptySet(),
|
||||
onConnect: (KnownHost, String?) -> Unit = { _, _ -> },
|
||||
onConnectDiscovered: (DiscoveredHost) -> Unit = {},
|
||||
onAddHost: () -> Unit = {},
|
||||
) = buildHomeTiles(
|
||||
savedHosts = savedHosts,
|
||||
profiles = profiles,
|
||||
pinsFor = { kh -> pins[kh.id].orEmpty() },
|
||||
discoveredUnsaved = discoveredUnsaved,
|
||||
isOnline = { it.name in online },
|
||||
onConnect = onConnect,
|
||||
onConnectDiscovered = onConnectDiscovered,
|
||||
onAddHost = onAddHost,
|
||||
)
|
||||
|
||||
/**
|
||||
* A pin belongs to the host above it. Ordering is the whole affordance: on a controller a pin is
|
||||
* reached by walking one tile past its host, and a builder that grouped all the pins at the end
|
||||
* would still LOOK right in a screenshot of any single tile.
|
||||
*/
|
||||
@Test
|
||||
fun pinnedCardsFollowTheirOwnHost() {
|
||||
val living = host("living", "192.168.1.42", pins = listOf(work.id, travel.id))
|
||||
val studio = host("studio", "192.168.1.61", pins = listOf(work.id))
|
||||
val ids = tiles(
|
||||
savedHosts = listOf(living, studio),
|
||||
profiles = listOf(work, travel),
|
||||
pins = mapOf(living.id to listOf(work, travel), studio.id to listOf(work)),
|
||||
).map { it.id }
|
||||
assertEquals(
|
||||
listOf(
|
||||
"saved-id-living",
|
||||
"pin-id-living-p-work",
|
||||
"pin-id-living-p-travel",
|
||||
"saved-id-studio",
|
||||
"pin-id-studio-p-work",
|
||||
"add",
|
||||
),
|
||||
ids,
|
||||
)
|
||||
}
|
||||
|
||||
/** Add Host is the last tile, always — including on a device with nothing saved or seen. */
|
||||
@Test
|
||||
fun theAddTileIsAlwaysLast() {
|
||||
val empty = tiles()
|
||||
assertEquals(listOf("add"), empty.map { it.id })
|
||||
assertTrue(empty.single().isAdd)
|
||||
|
||||
val populated = tiles(
|
||||
savedHosts = listOf(host("living", "192.168.1.42")),
|
||||
discoveredUnsaved = listOf(advert("studio", "192.168.1.61")),
|
||||
)
|
||||
assertEquals(listOf("saved-id-living", "disc-192.168.1.61:9777", "add"), populated.map { it.id })
|
||||
assertTrue(populated.last().isAdd)
|
||||
// The Add tile is not a host: no library, no options menu, nothing to wake.
|
||||
assertNull(populated.last().knownHost)
|
||||
}
|
||||
|
||||
/**
|
||||
* A host that is both saved and advertising appears ONCE. The de-dupe is the caller's
|
||||
* ([KnownHost.matches], which the screen applies before handing the list over) — checked here
|
||||
* because the rule that matters is the fingerprint one: a host that came back on a new DHCP
|
||||
* address is the same machine, and matching on address alone would offer it a second time as a
|
||||
* stranger, next to the record that already holds its trust.
|
||||
*/
|
||||
@Test
|
||||
fun aSavedHostSeenOnTheNetworkIsNotListedTwice() {
|
||||
val fp = "ab12cd34"
|
||||
val living = host("living", "192.168.1.42", fp = fp)
|
||||
// Same host, new address after a cold boot, plus a genuine stranger.
|
||||
val adverts = listOf(advert("living", "192.168.1.77", fp = fp), advert("stranger", "192.168.1.99"))
|
||||
val unsaved = adverts.filter { dh -> listOf(living).none { it.matches(dh) } }
|
||||
val ids = tiles(savedHosts = listOf(living), discoveredUnsaved = unsaved).map { it.id }
|
||||
assertEquals(listOf("saved-id-living", "disc-192.168.1.99:9777", "add"), ids)
|
||||
}
|
||||
|
||||
/**
|
||||
* The chip says which profile a press will connect with — the host's binding on its own tile,
|
||||
* the pinned profile on a pin tile. The console cannot EDIT profiles, so this claim is the only
|
||||
* thing standing between a user and a stream with settings they didn't choose.
|
||||
*/
|
||||
@Test
|
||||
fun theChipNamesTheProfileThePressWillUse() {
|
||||
val living = host("living", "192.168.1.42", profileId = work.id, pins = listOf(travel.id))
|
||||
val result = tiles(
|
||||
savedHosts = listOf(living),
|
||||
profiles = listOf(work, travel),
|
||||
pins = mapOf(living.id to listOf(travel)),
|
||||
)
|
||||
val own = result[0]
|
||||
assertEquals("Work", own.profileName)
|
||||
assertEquals(Color(0xFF3B82F6), own.profileAccent)
|
||||
assertNull(own.pinnedProfileId)
|
||||
|
||||
val pin = result[1]
|
||||
assertEquals("Travel", pin.profileName)
|
||||
assertEquals(travel.id, pin.pinnedProfileId)
|
||||
// Travel set no accent: a chip with no colour, not a crash and not a stray default.
|
||||
assertNull(pin.profileAccent)
|
||||
|
||||
// A binding whose profile was deleted resolves to nothing — the tile stays silent rather
|
||||
// than naming an id that resolves to nobody.
|
||||
val dangling = tiles(savedHosts = listOf(host("ghost", "10.0.0.5", profileId = "p-gone")))
|
||||
assertNull(dangling[0].profileName)
|
||||
}
|
||||
|
||||
/** Both address and the subtitle: a pin card says where it points, like every other card. */
|
||||
@Test
|
||||
fun everySavedTileSaysWhereItPoints() {
|
||||
val living = host("living", "192.168.1.42", pins = listOf(work.id))
|
||||
val result = tiles(
|
||||
savedHosts = listOf(living),
|
||||
profiles = listOf(work),
|
||||
pins = mapOf(living.id to listOf(work)),
|
||||
online = setOf("living"),
|
||||
)
|
||||
result.take(2).forEach {
|
||||
assertEquals("192.168.1.42:9777", it.subtitle)
|
||||
assertEquals("living", it.title)
|
||||
assertTrue(it.filled)
|
||||
assertTrue(it.online)
|
||||
assertTrue(it.paired)
|
||||
assertNotNull(it.knownHost)
|
||||
}
|
||||
// Both tiles reach the library (Y): a pin card opens its OWN shelf, whose launches carry
|
||||
// the pinned profile — the library is a way to start a card, not a host-level action.
|
||||
assertTrue(result[0].hasLibrary)
|
||||
assertTrue(result[1].hasLibrary)
|
||||
}
|
||||
|
||||
/**
|
||||
* What a press DOES. A host's own tile dials with no one-off reference so the host's binding is
|
||||
* followed; a pin tile forces its own profile. Passing the pin's id as the binding (or the
|
||||
* other way round) is invisible until someone streams at the wrong bitrate.
|
||||
*/
|
||||
@Test
|
||||
fun activationCarriesTheRightProfileReference() {
|
||||
val living = host("living", "192.168.1.42", pins = listOf(work.id))
|
||||
val dialled = mutableListOf<Pair<String, String?>>()
|
||||
val discovered = mutableListOf<String>()
|
||||
var addOpened = false
|
||||
val result = tiles(
|
||||
savedHosts = listOf(living),
|
||||
profiles = listOf(work),
|
||||
pins = mapOf(living.id to listOf(work)),
|
||||
discoveredUnsaved = listOf(advert("stranger", "192.168.1.99")),
|
||||
onConnect = { kh, oneOff -> dialled += kh.name to oneOff },
|
||||
onConnectDiscovered = { dh -> discovered += dh.host },
|
||||
onAddHost = { addOpened = true },
|
||||
)
|
||||
result.forEach { it.activate() }
|
||||
assertEquals(listOf("living" to null, "living" to work.id), dialled)
|
||||
assertEquals(listOf("192.168.1.99"), discovered)
|
||||
assertTrue(addOpened)
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,6 @@ class ProfilesTest {
|
||||
|
||||
// Device-scope settings are not in the overlay at all, so no profile can move them.
|
||||
assertEquals(base.gamepadUiEnabled, out.gamepadUiEnabled)
|
||||
assertEquals(base.gamepadUiMode, out.gamepadUiMode)
|
||||
assertEquals(base.libraryEnabled, out.libraryEnabled)
|
||||
assertEquals(base.autoWakeEnabled, out.autoWakeEnabled)
|
||||
assertEquals(base.sc2Capture, out.sc2Capture)
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* The stats HUD's audio line — `audio buffer N ms · a/v ±N ms`, from the live gauges at indexes
|
||||
* 33/34 (`design/audio-latency-overhaul.md`).
|
||||
*
|
||||
* Worth pinning because the whole point of the overhaul's stats half is that the audio plane became
|
||||
* OBSERVABLE. Before it, ring depth and A/V offset existed only as a log line, and on a device
|
||||
* launched by a game launcher that goes to a pipe nobody can read — so the single number that
|
||||
* identifies a deep ring was unobtainable on the exact device reporting the latency, and a field
|
||||
* investigation ran to its conclusion without it. A measurement that never reaches a surface is
|
||||
* indistinguishable from no measurement, which is what this asserts.
|
||||
*
|
||||
* `sdk = [36]` for the same reason as the screenshot tests: Robolectric ships android-all jars only
|
||||
* up to API 36 while the app's compileSdk is 37.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [36])
|
||||
class StatsOverlayAudioTest {
|
||||
@get:Rule
|
||||
val compose = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
/**
|
||||
* A plausible 35-double window with the audio gauges dialled in. Everything before 33 is the
|
||||
* DETAILED-renderable shape the ShotScenes fixture uses; only the last two matter here.
|
||||
*/
|
||||
private fun stats(bufferMs: Double, avOffsetMs: Double, size: Int = 35): DoubleArray {
|
||||
val full = doubleArrayOf(
|
||||
238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 2.0,
|
||||
10.0, 9.0, 16.0, 1.0, 0.9, 0.4, 0.6, 0.3,
|
||||
2.0, 1.0, 5.0, 238.0,
|
||||
1.0, 0.5, 1.8, 2.6,
|
||||
0.2, 0.3, 236.0, 1.0,
|
||||
0.1, 0.3, 0.0,
|
||||
bufferMs, avOffsetMs,
|
||||
)
|
||||
return full.copyOf(size)
|
||||
}
|
||||
|
||||
private fun show(s: DoubleArray, verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
|
||||
compose.setContent { StatsOverlay(s, verbosity = verbosity) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun detailedShowsDepthAndOffset() {
|
||||
show(stats(bufferMs = 42.0, avOffsetMs = 18.0))
|
||||
// Positive = audio playing BEHIND the picture, and the sign is explicit so a glance tells
|
||||
// which way the loop still has to move.
|
||||
compose.onNodeWithText("audio buffer 42 ms · a/v +18 ms").assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun audioAheadOfThePictureReadsNegative() {
|
||||
show(stats(bufferMs = 42.0, avOffsetMs = -12.0))
|
||||
compose.onNodeWithText("audio buffer 42 ms · a/v -12 ms").assertExists()
|
||||
}
|
||||
|
||||
/** Aligned (or not yet measured) drops the offset term; the depth alone is still the triage number. */
|
||||
@Test
|
||||
fun alignedShowsDepthAlone() {
|
||||
show(stats(bufferMs = 42.0, avOffsetMs = 0.0))
|
||||
compose.onNodeWithText("audio buffer 42 ms").assertExists()
|
||||
}
|
||||
|
||||
/** Nothing queued (audio off, or the ring not yet primed) — the line has nothing to say. */
|
||||
@Test
|
||||
fun silentPlaneRendersNoLine() {
|
||||
show(stats(bufferMs = 0.0, avOffsetMs = 0.0))
|
||||
compose.onNodeWithText("audio buffer", substring = true).assertDoesNotExist()
|
||||
}
|
||||
|
||||
/** The line is DETAILED-only, like every other per-stage figure. */
|
||||
@Test
|
||||
fun normalTierOmitsTheLine() {
|
||||
show(stats(bufferMs = 42.0, avOffsetMs = 18.0), verbosity = StatsVerbosity.NORMAL)
|
||||
compose.onNodeWithText("audio buffer", substring = true).assertDoesNotExist()
|
||||
}
|
||||
|
||||
/** An older native lib emits 33 doubles; the overlay must omit the line, not index past the end. */
|
||||
@Test
|
||||
fun olderNativeLayoutOmitsTheLine() {
|
||||
show(stats(bufferMs = 42.0, avOffsetMs = 18.0, size = 33))
|
||||
compose.onNodeWithText("audio buffer", substring = true).assertDoesNotExist()
|
||||
}
|
||||
}
|
||||
@@ -83,16 +83,6 @@ class ScreenshotTest {
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun streamNormal() = shootRoot("stream-normal") { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) }
|
||||
|
||||
// Both banner texts, in the stream's own landscape geometry — it is bottom-centre, so the
|
||||
// aspect is load-bearing.
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun streamBannerPad() = shootRoot("stream-banner-pad") { StreamBannerScene(pad = true) }
|
||||
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun streamBannerTouch() = shootRoot("stream-banner-touch") { StreamBannerScene(pad = false) }
|
||||
|
||||
// The touch flow is a Material dialog over the host grid (a separate window → shootScreen).
|
||||
@Test
|
||||
fun connecting() = shootScreen("connecting") {
|
||||
@@ -124,59 +114,6 @@ class ScreenshotTest {
|
||||
fun consoleSettingsLight() =
|
||||
shootRoot("console-settings-light") { ConsoleSettingsScene(paletteId = "holo") }
|
||||
|
||||
/**
|
||||
* Landscape — the orientation the console actually runs in, and a DIFFERENT layout since the
|
||||
* on-glass review: rows capped and left-aligned, the focused row's description in a side pane
|
||||
* on the right instead of the floating band.
|
||||
*/
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun consoleSettingsLandscape() =
|
||||
shootRoot("console-settings-landscape") { ConsoleSettingsScene() }
|
||||
|
||||
// The console home, the screen the living backdrop is most of. The default sdk (36) draws the
|
||||
// real AGSL MESH field; the paired API-31 shot below draws the blob fallback, so the two
|
||||
// renderings of the same palette can be compared rather than assumed equivalent.
|
||||
@Test
|
||||
fun consoleHome() = shootRoot("console-home") { ConsoleHomeScene() }
|
||||
|
||||
@Test
|
||||
fun consoleHomeLight() = shootRoot("console-home-light") { ConsoleHomeScene(paletteId = "holo") }
|
||||
|
||||
/**
|
||||
* Landscape — the orientation the console UI actually runs in, and the only one wide enough to
|
||||
* show the carousel's NEIGHBOURS, which is where the projected turn (`CARD_TURN_RAD`) lives.
|
||||
*/
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun consoleHomeLandscape() = shootRoot("console-home-landscape") { ConsoleHomeScene() }
|
||||
|
||||
/**
|
||||
* The API 31/32 field. `RuntimeShader` is API 33+, so everything below it keeps the four
|
||||
* drifting blobs — an honest approximation rather than an emulation, and the thing this shot
|
||||
* exists to keep honest.
|
||||
*/
|
||||
@Test
|
||||
@Config(sdk = [31], qualifiers = "w360dp-h800dp-xxhdpi")
|
||||
fun consoleHomeBlobFallback() = shootRoot("console-home-blobs") { ConsoleHomeScene() }
|
||||
|
||||
// The two screens the console reached for the first time in WP8.3. Each is shot on a dark AND a
|
||||
// pale palette, because the console draws them through a ColorScheme derived from the palette's
|
||||
// ink — and the pale one is the only place a grey-on-pastel slip can show up.
|
||||
@Test
|
||||
fun consoleLicenses() = shootRoot("console-licenses") { ConsoleLicensesScene() }
|
||||
|
||||
@Test
|
||||
fun consoleLicensesLight() =
|
||||
shootRoot("console-licenses-light") { ConsoleLicensesScene(paletteId = "holo") }
|
||||
|
||||
@Test
|
||||
fun consoleControllers() = shootRoot("console-controllers") { ConsoleControllersScene() }
|
||||
|
||||
@Test
|
||||
fun consoleControllersLight() =
|
||||
shootRoot("console-controllers-light") { ConsoleControllersScene(paletteId = "holo") }
|
||||
|
||||
@Test
|
||||
fun trust() = shootScreen("trust") {
|
||||
HostsScene()
|
||||
|
||||
@@ -32,13 +32,9 @@ import io.unom.punktfunk.ConnectModal
|
||||
import io.unom.punktfunk.ConnectPhase
|
||||
import io.unom.punktfunk.ConnectTakeover
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import io.unom.punktfunk.GamepadHome
|
||||
import io.unom.punktfunk.GamepadInk
|
||||
import io.unom.punktfunk.GamepadPalette
|
||||
import io.unom.punktfunk.ConsoleControllersScreen
|
||||
import io.unom.punktfunk.ConsoleLicensesScreen
|
||||
import io.unom.punktfunk.GamepadSettingsScreen
|
||||
import io.unom.punktfunk.HomeTile
|
||||
import io.unom.punktfunk.LocalGamepadInk
|
||||
import io.unom.punktfunk.LocalGamepadPalette
|
||||
import io.unom.punktfunk.Settings
|
||||
@@ -47,11 +43,10 @@ import io.unom.punktfunk.SettingsCategory
|
||||
import io.unom.punktfunk.SettingsScreen
|
||||
import io.unom.punktfunk.StatsOverlay
|
||||
import io.unom.punktfunk.StatsVerbosity
|
||||
import io.unom.punktfunk.StreamStartBanner
|
||||
import io.unom.punktfunk.ProfileEditorFields
|
||||
import io.unom.punktfunk.ProfileStore
|
||||
import io.unom.punktfunk.SettingsOverlay
|
||||
import io.unom.punktfunk.SpeedTestPrompt
|
||||
import io.unom.punktfunk.SpeedTestDialog
|
||||
import io.unom.punktfunk.SpeedTestPhase
|
||||
import io.unom.punktfunk.SpeedTestTarget
|
||||
import io.unom.punktfunk.components.HostCard
|
||||
@@ -247,8 +242,7 @@ internal fun SettingsProfileScene() {
|
||||
*/
|
||||
@Composable
|
||||
internal fun SpeedTestScene() {
|
||||
SpeedTestPrompt(
|
||||
gamepadUi = false,
|
||||
SpeedTestDialog(
|
||||
hostName = "Living Room PC",
|
||||
target = SpeedTestTarget.Ask(newProfile("Game")),
|
||||
phase = SpeedTestPhase.Done(throughputKbps = 412_000, lossPct = 0.3, recommendedKbps = 288_400),
|
||||
@@ -361,12 +355,10 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
|
||||
Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))),
|
||||
),
|
||||
) {
|
||||
// The full 35-double unified layout — NativeBridge.nativeVideoStats' KDoc is the
|
||||
// authoritative index list: [fps, mbps, e2eP50, e2eP95, latValid, skew, w, h, hz,
|
||||
// lostTotal, bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50,
|
||||
// decodeP50, hostP50, netP50, lost, skipped, fec, frames, dispValid, displayP50,
|
||||
// e2eDispP50, e2eDispP95, paceP50, latchP50, presents, presenterActive, feedP50, codecP50,
|
||||
// skippedOverflow, audioBufferMs, audioAvOffsetMs].
|
||||
// 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, less the excluded OS present floor (the 0.3
|
||||
@@ -384,12 +376,6 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
|
||||
1.0, 0.5, 1.8, 2.6,
|
||||
// Timeline-presenter split: pace + latch tile the display term; presents ≈ fps.
|
||||
0.2, 0.3, 236.0, 1.0,
|
||||
// The decode term's own split (feed + codec = 0.4), and no overflow — the one
|
||||
// `skipped` above is benign newest-wins pacing, not a decoder falling behind.
|
||||
0.1, 0.3, 0.0,
|
||||
// The audio plane: a 28 ms ring placed 4 ms behind the picture — a converged sync
|
||||
// loop, i.e. inside the deadband it deliberately leaves alone.
|
||||
28.0, 4.0,
|
||||
),
|
||||
verbosity = verbosity,
|
||||
decoderLabel = "c2.qti.hevc.decoder · low-latency",
|
||||
@@ -433,111 +419,6 @@ internal fun ConnectConsoleScene() =
|
||||
* stand in for it: this is a different screen with different navigation, and the strip is the part
|
||||
* a layout regression would eat first.
|
||||
*/
|
||||
/**
|
||||
* The start-of-stream banner over the same synthetic "streamed frame" — the real
|
||||
* [StreamStartBanner] at full opacity, since the caller owns the 6 s timer and a shot must not race
|
||||
* it. Two variants because the WORDS are the point: the banner names pad chords or touch gestures
|
||||
* depending on what the session actually has, and a screenshot is the only place the two can be
|
||||
* compared side by side.
|
||||
*/
|
||||
@Composable
|
||||
internal fun StreamBannerScene(pad: Boolean) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B)),
|
||||
),
|
||||
),
|
||||
) {
|
||||
StreamStartBanner(
|
||||
text = if (pad) {
|
||||
"Hold Select + Start + L1 + R1 to leave · Select + Y mic · Select + X stats"
|
||||
} else {
|
||||
"Back leaves the stream · three-finger tap for stats"
|
||||
},
|
||||
alpha = 1f,
|
||||
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The console HOME — the host carousel over the living backdrop, which is the screen the aurora is
|
||||
* most of. Worth its own shot for exactly that reason: on API 33+ the field is the real bicubic
|
||||
* MESH (`GamepadAurora`'s AGSL port of the desktop console's shader) and below it the four-blob
|
||||
* fallback, and the two are only comparable side by side. The scene composes [GamepadHome]
|
||||
* directly with mock tiles — it needs no JNI core and no session, unlike the ConnectScreen that
|
||||
* normally feeds it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConsoleHomeScene(paletteId: String = "violet") {
|
||||
val palette = GamepadPalette.named(paletteId)
|
||||
val tiles = listOf(
|
||||
HomeTile(
|
||||
id = "living", title = "Living Room PC", subtitle = "192.168.1.42 · Paired",
|
||||
filled = true, online = true, paired = true, activate = {},
|
||||
),
|
||||
HomeTile(
|
||||
id = "studio", title = "studio-deck", subtitle = "192.168.1.61 · Discovered",
|
||||
online = true, activate = {},
|
||||
),
|
||||
HomeTile(id = "add", title = "Add Host", subtitle = "By address", isAdd = true, activate = {}),
|
||||
)
|
||||
CompositionLocalProvider(
|
||||
LocalGamepadPalette provides palette,
|
||||
LocalGamepadInk provides GamepadInk.of(palette),
|
||||
) {
|
||||
GamepadHome(
|
||||
tiles = tiles,
|
||||
libraryEnabled = true,
|
||||
controllerName = "Xbox Wireless Controller",
|
||||
navActive = false,
|
||||
onActivate = {},
|
||||
onOpenLibrary = {},
|
||||
onOpenSettings = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The two screens the console could not reach at all until WP8.3 — the open-source notices and the
|
||||
* connected-controllers view — in their console presentation.
|
||||
*
|
||||
* Worth a shot each, and worth a PALE one: both are ordinary Material screens underneath, and the
|
||||
* console shows them through a `ColorScheme` derived from the palette's ink. That derivation is the
|
||||
* whole risk. Their touch presentation is inked by the app theme, which is always dark, so nothing
|
||||
* before this could catch light-grey body text stranded on a pastel field.
|
||||
*
|
||||
* Robolectric enumerates no input devices, so the controllers scene renders its deterministic
|
||||
* "nothing connected" state.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConsoleLicensesScene(paletteId: String = "violet") =
|
||||
ConsolePalette(paletteId) { ConsoleLicensesScreen(onBack = {}, navActive = false) }
|
||||
|
||||
@Composable
|
||||
internal fun ConsoleControllersScene(paletteId: String = "violet") =
|
||||
ConsolePalette(paletteId) {
|
||||
ConsoleControllersScreen(gamepadSetting = 0, onBack = {}, navActive = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish the palette locals `App` would normally provide. A scene that calls a console screen
|
||||
* directly gets the DEFAULT dark ink without this, and a pale-palette shot would then silently
|
||||
* prove nothing at all.
|
||||
*/
|
||||
@Composable
|
||||
private fun ConsolePalette(paletteId: String, content: @Composable () -> Unit) {
|
||||
val palette = GamepadPalette.named(paletteId)
|
||||
CompositionLocalProvider(
|
||||
LocalGamepadPalette provides palette,
|
||||
LocalGamepadInk provides GamepadInk.of(palette),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ConsoleSettingsScene(paletteId: String = "violet") {
|
||||
// The scene calls the screen directly, so it has to publish the palette locals `App` would
|
||||
|
||||
@@ -67,37 +67,30 @@ fun androidSdkDir(): String {
|
||||
return "${System.getProperty("user.home")}/Library/Android/sdk"
|
||||
}
|
||||
|
||||
// Every cargo-ndk invocation needs the same discovery environment, and they must not drift apart:
|
||||
// a lint that ran against a different toolchain/sysroot than the build is a lint about a different
|
||||
// program. Applied by both `registerCargoNdk` (build) and `registerCargoNdkClippy` (lint).
|
||||
fun Exec.cargoNdkEnvironment() {
|
||||
val sdk = androidSdkDir()
|
||||
// A GUI Android Studio launch does not source the login shell, so make cargo, the NDK, and
|
||||
// cmake (libopus builds via the cmake crate) discoverable explicitly — same as a bare CLI.
|
||||
val cmakeBin = "$sdk/cmake/3.22.1/bin"
|
||||
environment(
|
||||
"PATH",
|
||||
cargoBin + File.pathSeparator + cmakeBin + File.pathSeparator + System.getenv("PATH"),
|
||||
)
|
||||
environment("ANDROID_HOME", sdk)
|
||||
environment("ANDROID_NDK_HOME", "$sdk/ndk/$ndkVer")
|
||||
// CMake's built-in Android support (used by the cmake crate for libopus) finds the NDK via
|
||||
// these, and uses Ninja (bundled next to the SDK cmake) since there's no `make`.
|
||||
environment("ANDROID_NDK_ROOT", "$sdk/ndk/$ndkVer")
|
||||
environment("ANDROID_NDK", "$sdk/ndk/$ndkVer")
|
||||
environment("CMAKE_GENERATOR", "Ninja")
|
||||
// audiopus_sys picks static-vs-dynamic by HOST not target — force the bundled static libopus
|
||||
// (pure C) so the android .so links it instead of looking for the host's libopus.so.
|
||||
environment("LIBOPUS_STATIC", "1")
|
||||
environment("LIBOPUS_NO_PKG", "1")
|
||||
}
|
||||
|
||||
fun registerCargoNdk(taskName: String, release: Boolean) =
|
||||
tasks.register<Exec>(taskName) {
|
||||
group = "rust"
|
||||
description = "cargo-ndk build of punktfunk-client-android (${if (release) "release" else "debug"})"
|
||||
workingDir = repoRoot
|
||||
cargoNdkEnvironment()
|
||||
val sdk = androidSdkDir()
|
||||
// A GUI Android Studio launch does not source the login shell, so make cargo, the NDK, and
|
||||
// cmake (libopus builds via the cmake crate) discoverable explicitly — same as a bare CLI.
|
||||
val cmakeBin = "$sdk/cmake/3.22.1/bin"
|
||||
environment(
|
||||
"PATH",
|
||||
cargoBin + File.pathSeparator + cmakeBin + File.pathSeparator + System.getenv("PATH"),
|
||||
)
|
||||
environment("ANDROID_HOME", sdk)
|
||||
environment("ANDROID_NDK_HOME", "$sdk/ndk/$ndkVer")
|
||||
// CMake's built-in Android support (used by the cmake crate for libopus) finds the NDK via
|
||||
// these, and uses Ninja (bundled next to the SDK cmake) since there's no `make`.
|
||||
environment("ANDROID_NDK_ROOT", "$sdk/ndk/$ndkVer")
|
||||
environment("ANDROID_NDK", "$sdk/ndk/$ndkVer")
|
||||
environment("CMAKE_GENERATOR", "Ninja")
|
||||
// audiopus_sys picks static-vs-dynamic by HOST not target — force the bundled static libopus
|
||||
// (pure C) so the android .so links it instead of looking for the host's libopus.so.
|
||||
environment("LIBOPUS_STATIC", "1")
|
||||
environment("LIBOPUS_NO_PKG", "1")
|
||||
// Resolve cargo by ABSOLUTE path: Gradle's Exec resolves command[0] via the JVM's
|
||||
// inherited PATH, NOT the environment("PATH", …) set above (that only reaches the spawned
|
||||
// child). A GUI Android Studio launch (and any daemon it started) has no ~/.cargo/bin on
|
||||
@@ -120,41 +113,6 @@ fun registerCargoNdk(taskName: String, release: Boolean) =
|
||||
commandLine(cmd)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
// Lint the ANDROID target. `punktfunk-client-android` and every `#[cfg(target_os = "android")]`
|
||||
// module elsewhere in the workspace were, until this task existed, **completely unlinted**: ci.yml
|
||||
// runs `cargo clippy --workspace` on the HOST, where all of that code is compiled out, and this
|
||||
// workflow only ever ran `build`. The gap was found in 2026-08 with five lints sitting in
|
||||
// clients/android/native (two of them `unnecessary_cast`, which is exactly the class that decides
|
||||
// whether a cast is redundant BY POINTER WIDTH).
|
||||
//
|
||||
// Both widths are linted, and that is the load-bearing part: arm64-v8a is 64-bit and armeabi-v7a is
|
||||
// 32-bit, so a cast that is redundant on one can be required on the other. Linting only the primary
|
||||
// ABI would license "fixes" that break the 32-bit build — the shipping ABI for the many 32-bit
|
||||
// Google TV / Android TV boxes this client targets. x86_64 is deliberately omitted: it is
|
||||
// emulator-only and shares its pointer width with arm64, so it costs a third of the job's lint time
|
||||
// for no signal these two do not already carry.
|
||||
//
|
||||
// `--all-targets` for the same reason ci.yml spells it out: without it the `#[cfg(test)]` modules
|
||||
// are never compiled, and un-compiled test code drifts silently.
|
||||
fun registerCargoNdkClippy(taskName: String) =
|
||||
tasks.register<Exec>(taskName) {
|
||||
group = "verification"
|
||||
description = "clippy (deny warnings) for punktfunk-client-android on both Android widths"
|
||||
workingDir = repoRoot
|
||||
cargoNdkEnvironment()
|
||||
commandLine(
|
||||
// Absolute cargo path for the same reason as the build task above.
|
||||
"$cargoBin/cargo", "ndk",
|
||||
"-t", "arm64-v8a", "-t", "armeabi-v7a",
|
||||
"--platform", "28",
|
||||
"clippy", "-p", "punktfunk-client-android", "--all-targets",
|
||||
"--", "-D", "warnings",
|
||||
)
|
||||
}
|
||||
|
||||
val cargoNdkClippy = registerCargoNdkClippy("cargoNdkClippy")
|
||||
|
||||
// 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
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.view.Display
|
||||
import android.view.Surface
|
||||
import android.view.WindowManager
|
||||
|
||||
/**
|
||||
* The opt-in phone-gyro mirror ("Gyro from this phone", off by default): while wire pad 0 is a
|
||||
* controller with no motion source of its own, THIS device's IMU speaks for it on the rich-input
|
||||
* motion plane — for clip-on and third-party pads that ship without a gyro, where the phone body
|
||||
* is rigidly attached to (or simply is) the thing in the player's hands. [GamepadFeedback]'s
|
||||
* rumble-on-phone mirror with the data flowing the other way.
|
||||
*
|
||||
* On Android the only motion sources are the capture links (USB DualSense / SC2 — pads with a
|
||||
* real IMU, claimed as [GamepadRouter.ExternalPad]s), so the stand-down rule is exactly
|
||||
* [GamepadRouter.padHasOwnMotion]: when a capture link holds pad 0, the mirror sends nothing —
|
||||
* two motion writers on one wire pad would fight. It also sends nothing while pad 0 has no slot
|
||||
* at all (motion never creates a host pad; a controller must have arrived first).
|
||||
*
|
||||
* Two properties this class enforces itself:
|
||||
* - samples ride a dedicated [HandlerThread] with batching disabled (`maxReportLatencyUs = 0`) —
|
||||
* sensor batching would trade the exact latency gyro aim exists to avoid;
|
||||
* - a stand-down edge (capture link claims pad 0, or [stop]) sends ONE zero-gyro sample, so the
|
||||
* host's virtual pad never keeps integrating an angular velocity this device stopped
|
||||
* producing (the gyro-sweep "stale angular velocity re-sent forever" failure mode).
|
||||
*
|
||||
* Units are the wire contract, converted by [Gamepad.motionGyroWire] / [Gamepad.motionAccelWire] —
|
||||
* the same two functions [PadSensors] uses, so a scale this client ever has to correct is corrected
|
||||
* once for every sender rather than once per sender that someone remembers. The one thing the
|
||||
* phone adds is a frame remap: sensors report in the device's natural-portrait frame, while
|
||||
* the wire wants the controller frame the player sees (x right, y up, z out of the screen), so
|
||||
* each sample is rotated by the current display rotation — a phone clipped landscape must yaw
|
||||
* when the player yaws, not roll. The matrix is derived and pinned by `DeviceGyroTest`;
|
||||
* correctable in one place if on-glass says otherwise.
|
||||
*/
|
||||
class DeviceGyro(
|
||||
context: Context,
|
||||
private val handle: Long,
|
||||
private val router: GamepadRouter,
|
||||
) : SensorEventListener {
|
||||
|
||||
private val sensorManager: SensorManager? =
|
||||
context.getSystemService(SensorManager::class.java)
|
||||
|
||||
/** For the live rotation; null on contexts without a display association (then portrait). */
|
||||
private val display: Display? = runCatching {
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
context.display
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
context.getSystemService(WindowManager::class.java)?.defaultDisplay
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
private val thread = HandlerThread("pf-phone-gyro")
|
||||
|
||||
/** Latest converted accel, paired with each gyro send (the wire fuses both per sample). */
|
||||
private val lastAccel = intArrayOf(0, Gamepad.MOTION_ACCEL_LSB_PER_G, 0)
|
||||
|
||||
/** Whether the last gyro event actually went to pad 0 — the stand-down zero-send edge. */
|
||||
private var wasWriting = false
|
||||
|
||||
/** Register the listeners; a device without a gyroscope makes this a no-op. */
|
||||
fun start() {
|
||||
val sm = sensorManager ?: return
|
||||
val gyro = sm.getDefaultSensor(Sensor.TYPE_GYROSCOPE) ?: return
|
||||
thread.start()
|
||||
val h = Handler(thread.looper)
|
||||
// ~200 Hz requested (the framework clamps to what the hardware offers), zero report
|
||||
// latency: batching is poison for gyro aim.
|
||||
sm.registerListener(this, gyro, SAMPLING_PERIOD_US, 0, h)
|
||||
sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)?.let {
|
||||
sm.registerListener(this, it, SAMPLING_PERIOD_US, 0, h)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister and join the sensor thread, then park the host pad's rotation at zero if this
|
||||
* mirror was the live writer. Call BEFORE the router is released / the handle freed —
|
||||
* teardown-ordered like the feedback threads.
|
||||
*/
|
||||
fun stop() {
|
||||
sensorManager?.unregisterListener(this)
|
||||
thread.quitSafely()
|
||||
runCatching { thread.join() }
|
||||
if (wasWriting) {
|
||||
wasWriting = false
|
||||
sendZero()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
val rotation = display?.rotation ?: Surface.ROTATION_0
|
||||
when (event.sensor.type) {
|
||||
Sensor.TYPE_ACCELEROMETER -> {
|
||||
val v = remap(rotation, event.values[0], event.values[1], event.values[2])
|
||||
for (i in 0..2) lastAccel[i] = Gamepad.motionAccelWire(v[i])
|
||||
}
|
||||
Sensor.TYPE_GYROSCOPE -> {
|
||||
// The write gate, per sample: pad 0 must exist (motion never creates a pad)
|
||||
// and must not be a capture link's (its own IMU is streaming).
|
||||
val write = router.padPresent(0) && !router.padHasOwnMotion(0)
|
||||
if (!write) {
|
||||
// Stand-down edge: never leave the last angular velocity latched host-side.
|
||||
if (wasWriting) {
|
||||
wasWriting = false
|
||||
sendZero()
|
||||
}
|
||||
return
|
||||
}
|
||||
wasWriting = true
|
||||
val v = remap(rotation, event.values[0], event.values[1], event.values[2])
|
||||
NativeBridge.nativeSendPadMotion(
|
||||
handle, 0,
|
||||
Gamepad.motionGyroWire(v[0]),
|
||||
Gamepad.motionGyroWire(v[1]),
|
||||
Gamepad.motionGyroWire(v[2]),
|
||||
lastAccel[0], lastAccel[1], lastAccel[2],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
|
||||
|
||||
/** Zero rotation, last-known accel — "at rest", not free-fall. */
|
||||
private fun sendZero() {
|
||||
NativeBridge.nativeSendPadMotion(
|
||||
handle, 0, 0, 0, 0, lastAccel[0], lastAccel[1], lastAccel[2],
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Whether this device can source motion at all — gates the settings rows (a TV box
|
||||
* without an IMU would make the toggle a silent no-op, the rumble mirror's rule). */
|
||||
fun available(context: Context): Boolean =
|
||||
context.getSystemService(SensorManager::class.java)
|
||||
?.getDefaultSensor(Sensor.TYPE_GYROSCOPE) != null
|
||||
|
||||
/**
|
||||
* ~200 Hz — between the sensor's usual FASTEST (~250-500 Hz) and GAME (~50 Hz), and also
|
||||
* the ceiling the framework grants an app without `HIGH_SAMPLING_RATE_SENSORS` (API 31+),
|
||||
* so asking for more would only be silently capped. Shared with [PadSensors].
|
||||
*/
|
||||
internal const val SAMPLING_PERIOD_US = 5000
|
||||
|
||||
/**
|
||||
* Rotate one device-frame vector (rotation rate or acceleration — both transform the
|
||||
* same way under an in-plane rotation) into the controller frame for [rotation]
|
||||
* ([Surface].ROTATION_*). Sensors report in the natural-portrait frame (+x right edge,
|
||||
* +y top, +z out of the screen); the controller frame keeps +z (the screen always faces
|
||||
* the player) and rotates x/y to mean "player's right" and "player's up". ROTATION_90 =
|
||||
* the device physically turned counter-clockwise, top to the player's LEFT.
|
||||
*/
|
||||
fun remap(rotation: Int, x: Float, y: Float, z: Float): FloatArray = when (rotation) {
|
||||
Surface.ROTATION_90 -> floatArrayOf(-y, x, z) // top left: right = bottom, up = +x
|
||||
Surface.ROTATION_270 -> floatArrayOf(y, -x, z) // top right: right = top, up = −x
|
||||
Surface.ROTATION_180 -> floatArrayOf(-x, -y, z)
|
||||
else -> floatArrayOf(x, y, z)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,11 +22,9 @@ import android.view.InputDevice
|
||||
*
|
||||
* Input: parse ([DsDevice.parseState]) → typed mirror on an [GamepadRouter.ExternalPad] (buttons
|
||||
* diffed, axes on-change — the exit chord participates like any pad) + the rich plane (touch
|
||||
* normalized to the wire's 0..65535 screen space on-change; motion forwarded per report, rescaled
|
||||
* into the wire's units by this pad's own calibration — read once per claim, off the claiming
|
||||
* thread, with the nominal scaling standing in for the millisecond that read is in flight rather
|
||||
* than the UI waiting on a control transfer). The wire slot is claimed when the capture engages,
|
||||
* with the first parsed report as the fallback for a claim that found no free index, and freed on
|
||||
* normalized to the wire's 0..65535 screen space on-change; motion forwarded per report in raw
|
||||
* device units, the wire's contract). The wire slot is claimed when the capture engages, with the
|
||||
* first parsed report as the fallback for a claim that found no free index, and freed on
|
||||
* unplug/[stop], so indices never leak.
|
||||
*
|
||||
* Feedback: implements [GamepadFeedback.PadFeedbackSink] — rumble / trigger / lightbar / player
|
||||
@@ -57,13 +55,6 @@ class DsCapture(
|
||||
@Volatile private var model: DsDevice.Model? = null
|
||||
@Volatile private var pad: GamepadRouter.ExternalPad? = null
|
||||
|
||||
/** This pad's factory motion scale, read once per capture on [calReader] and handed to the
|
||||
* link thread, which scales nominally until it lands — see [MotionCalHandoff]. */
|
||||
private val motionCal = MotionCalHandoff()
|
||||
|
||||
/** The thread doing the claim-time calibration read, kept for the teardown wait. */
|
||||
@Volatile private var calReader: Thread? = null
|
||||
|
||||
// Typed-mirror diff state (wire units) + rich-plane on-change mirrors. Link thread only.
|
||||
private val state = DsDevice.State()
|
||||
private var wireButtons = 0
|
||||
@@ -133,11 +124,6 @@ class DsCapture(
|
||||
if (model != null) return false
|
||||
val m = DsDevice.modelFor(dev.productId) ?: return false
|
||||
if (!usb.start(dev)) return false
|
||||
// Before `model`, which is what lets the link thread into the parse at all: opening the
|
||||
// claim forgets the last pad's calibration, so reports arriving while this pad's own read
|
||||
// (below, off this thread) is in flight fall back to the nominal scaling rather than to
|
||||
// another unit's factory numbers.
|
||||
val claim = motionCal.begin()
|
||||
model = m
|
||||
for (id in InputDevice.getDeviceIds()) {
|
||||
val d = InputDevice.getDevice(id) ?: continue
|
||||
@@ -149,88 +135,9 @@ class DsCapture(
|
||||
Log.i(TAG, "Sony pad captured over USB: PID=0x%04x model=%s".format(dev.productId, m))
|
||||
ensureSlot(m)
|
||||
onActiveChanged?.invoke(true)
|
||||
readMotionCalAsync(m, claim)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Start this claim's calibration read, on its own thread.
|
||||
*
|
||||
* Off the caller's thread because [startUsb] runs on the main one — stream setup, and the
|
||||
* USB-permission broadcast — and the read is a blocking EP0 control transfer: a pad that is
|
||||
* there answers in about a millisecond, but one that is stalling takes the link's whole write
|
||||
* timeout, and the interface must wait for neither. The pad is live throughout, its motion
|
||||
* nominally scaled until this lands ([onReport]), so even a pad that never answers costs
|
||||
* precision rather than the UI or the controller.
|
||||
*
|
||||
* One thread per claim, daemon and named, matching how [HidUsbLink] runs its reader; it is
|
||||
* awaited by [awaitCalRead] before the connection it reads from can be closed.
|
||||
*/
|
||||
private fun readMotionCalAsync(m: DsDevice.Model, claim: Int) {
|
||||
val t = Thread({
|
||||
// A read that throws would otherwise leave the capture on the nominal scaling with
|
||||
// nothing in the log to say why — the one outcome that looks identical to a pad whose
|
||||
// calibration is genuinely nominal. Publish the fallback explicitly, and say so.
|
||||
val cal = runCatching { readMotionCal(m) }.getOrElse {
|
||||
Log.w(TAG, "motion calibration read failed — nominal scaling", it)
|
||||
DsDevice.MotionCal.NOMINAL
|
||||
}
|
||||
// Discarded when the claim is already over (unplug, stop, or a re-claim beat us here):
|
||||
// scaling the NEXT pad by this one's factory numbers would be worse than not reading.
|
||||
if (!motionCal.publish(claim, cal)) {
|
||||
Log.i(TAG, "motion calibration arrived after the claim ended — discarded")
|
||||
}
|
||||
}, "pf-ds-cal")
|
||||
calReader = t
|
||||
t.isDaemon = true
|
||||
t.start()
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an in-flight calibration read to let go of the USB connection, before a teardown
|
||||
* closes it.
|
||||
*
|
||||
* Not politeness: the read is a control transfer on the very connection [HidUsbLink.stop] is
|
||||
* about to close, and closing a descriptor with a transfer in flight pulls it out from under
|
||||
* the kernel — the same rule the pad-audio borrow follows. Bounded, and in every case but a
|
||||
* pad that has stopped answering the thread is long gone, so this returns immediately. It can
|
||||
* never deadlock: the reading thread waits on nothing this one holds ([MotionCalHandoff] has
|
||||
* its own monitor, and the read itself takes no lock).
|
||||
*/
|
||||
private fun awaitCalRead() {
|
||||
val t = calReader ?: return
|
||||
calReader = null
|
||||
if (!t.isAlive) return
|
||||
runCatching { t.join(CAL_JOIN_MS) }
|
||||
if (t.isAlive) Log.w(TAG, "calibration read still in flight at teardown")
|
||||
}
|
||||
|
||||
/**
|
||||
* Read this pad's IMU calibration — the feature report that says how many raw counts this
|
||||
* individual unit puts on a °/s and on a g ([DsDevice.MotionCal]).
|
||||
*
|
||||
* Once, at claim time, and nowhere else: the calibration is fixed for the life of the
|
||||
* connection, so doing it per input report would buy nothing and cost the capture its latency.
|
||||
* A pad that refuses keeps the nominal scaling rather than losing motion altogether.
|
||||
*/
|
||||
private fun readMotionCal(m: DsDevice.Model): DsDevice.MotionCal {
|
||||
val blob = usb.getReport(HidUsbLink.REPORT_TYPE_FEATURE, m.calReportId, m.calReportLen)
|
||||
val cal = DsDevice.MotionCal.parse(blob, m.calReportId)
|
||||
// Worth a line either way: this is the number the owed on-glass check reads back — a pad
|
||||
// whose blob was read declares its own resolution, the fallback declares the wire's.
|
||||
if (cal === DsDevice.MotionCal.NOMINAL) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"motion calibration 0x%02x unreadable (%d/%d B) — nominal scaling (%s)".format(
|
||||
m.calReportId, blob?.size ?: 0, m.calReportLen, cal,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
Log.i(TAG, "motion calibration 0x%02x: %s".format(m.calReportId, cal))
|
||||
}
|
||||
return cal
|
||||
}
|
||||
|
||||
/** Stop the link and free the wire slot (host tears the virtual pad down). Idempotent. */
|
||||
fun stop() {
|
||||
// Before anything touches the link: the pad-audio renderer borrows this connection's
|
||||
@@ -250,10 +157,6 @@ class DsCapture(
|
||||
resetRichFeedback(m)
|
||||
}
|
||||
disarmBackstop()
|
||||
// End the claim before waiting on it: a calibration that lands after this publishes
|
||||
// nothing, and then the wait makes sure nothing is still reading the connection below.
|
||||
motionCal.end()
|
||||
awaitCalRead()
|
||||
usb.stop()
|
||||
val wasActive = model != null
|
||||
model = null
|
||||
@@ -265,10 +168,7 @@ class DsCapture(
|
||||
|
||||
private fun onReport(report: ByteArray, len: Int) {
|
||||
val m = model ?: return
|
||||
// Nominal scaling until this claim's calibration read lands (see MotionCalHandoff): for
|
||||
// that millisecond the pad behaves as it did before the read existed, which nobody can
|
||||
// feel — unlike a pad whose buttons wait on a control transfer.
|
||||
if (!DsDevice.parseState(m, report, len, state, motionCal.effective)) return
|
||||
if (!DsDevice.parseState(m, report, len, state)) return
|
||||
// Normally claimed already, at capture time; this is the retry for a capture that engaged
|
||||
// while every wire index was taken.
|
||||
val p = pad ?: ensureSlot(m) ?: return // all 16 taken — drop until one frees
|
||||
@@ -289,9 +189,7 @@ class DsCapture(
|
||||
@Synchronized
|
||||
private fun ensureSlot(m: DsDevice.Model): GamepadRouter.ExternalPad? {
|
||||
pad?.let { return it }
|
||||
// hasGyro: every pad this link captures is a Sony one with an IMU, and its motion goes out
|
||||
// on the rich plane — so a session that cannot carry it is worth saying out loud.
|
||||
val p = router.openExternal(m.pref, hasGyro = true) ?: return null
|
||||
val p = router.openExternal(m.pref) ?: return null
|
||||
pad = p
|
||||
Log.i(TAG, "captured $m → wire pad ${p.index}")
|
||||
// The wire index exists from here on, and the host addresses pad audio by it.
|
||||
@@ -381,10 +279,6 @@ class DsCapture(
|
||||
val wasActive = model != null
|
||||
model = null
|
||||
releaseSlot()
|
||||
// As in stop(): end the claim so a late calibration publishes nothing, then wait for the
|
||||
// read to let go of the connection the line below closes.
|
||||
motionCal.end()
|
||||
awaitCalRead()
|
||||
// Release the transport too: the link only *signals* the drop, so without this an unplug
|
||||
// left its connection open, its interfaces claimed and its detach receiver registered.
|
||||
usb.stop()
|
||||
@@ -416,8 +310,8 @@ class DsCapture(
|
||||
|
||||
/**
|
||||
* The rich plane: touch contacts normalized to the wire's 0..65535 screen space, forwarded
|
||||
* on change per slot; motion forwarded every report (already in wire units — the parse applies
|
||||
* this pad's calibration, and sensor noise makes per-report dedup pointless).
|
||||
* on change per slot; motion forwarded every report (raw device units — the wire is a unit
|
||||
* passthrough into the host's virtual pad, and sensor noise makes per-report dedup pointless).
|
||||
*/
|
||||
private fun mirrorRich(p: GamepadRouter.ExternalPad, m: DsDevice.Model) {
|
||||
for (f in 0 until 2) {
|
||||
@@ -589,9 +483,5 @@ class DsCapture(
|
||||
/** How soon to retry a rumble stop whose write was rejected. Short: the motors are running
|
||||
* and the host has already moved on, so nothing else is coming to silence them. */
|
||||
const val STOP_RETRY_MS = 100L
|
||||
|
||||
/** Teardown's budget for an in-flight calibration read. Comfortably past the link's own
|
||||
* EP0 timeout, so it only ever elapses for a pad that has stopped answering entirely. */
|
||||
const val CAL_JOIN_MS = 500L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Sony DualSense / DualSense Edge / DualShock 4 **USB** protocol constants: the input-report
|
||||
* parser and the output-report builders the capture link ([DsCapture]) needs. Unlike the SC2's
|
||||
@@ -30,168 +28,14 @@ object DsDevice {
|
||||
/**
|
||||
* One captured model: its `GamepadPref` wire byte (the virtual pad the host builds — matching
|
||||
* the physical one), its output-report size (the descriptor-declared size the firmware
|
||||
* expects: DS5 48 = id + 47, Edge 64 = id + 63, DS4 32 = id + 31), its touchpad extent
|
||||
* expects: DS5 48 = id + 47, Edge 64 = id + 63, DS4 32 = id + 31), and its touchpad extent
|
||||
* (`dualsense_proto::DS_TOUCH_W/H`, `dualshock4_proto::DS4_TOUCH_*`) for normalizing touches
|
||||
* onto the wire's 0..65535 space, and the IMU-calibration feature report it answers
|
||||
* ([MotionCal]): DS5/Edge `0x05` (id + 40 B), DS4 over USB `0x02` (id + 36 B).
|
||||
* onto the wire's 0..65535 space.
|
||||
*/
|
||||
enum class Model(
|
||||
val pref: Int,
|
||||
val outputSize: Int,
|
||||
val touchW: Int,
|
||||
val touchH: Int,
|
||||
val calReportId: Int,
|
||||
val calReportLen: Int,
|
||||
) {
|
||||
DUALSENSE(Gamepad.PREF_DUALSENSE, 48, 1920, 1080, 0x05, 41),
|
||||
DUALSENSE_EDGE(Gamepad.PREF_DUALSENSEEDGE, 64, 1920, 1080, 0x05, 41),
|
||||
DUALSHOCK4(Gamepad.PREF_DUALSHOCK4, 32, 1920, 942, 0x02, 37),
|
||||
}
|
||||
|
||||
/**
|
||||
* One pad's own IMU calibration: the factory scale factors that turn its raw motion counts
|
||||
* into the wire's fixed units (`punktfunk_core::input::gamepad` — 20 LSB per °/s, 10000 LSB
|
||||
* per g), read out of the calibration feature report the pad serves on EP0.
|
||||
*
|
||||
* **Why the pad's blob and not a constant.** Measured on glass 2026-08-07: a DualSense flat
|
||||
* and face up arrived as 0.811 g where 1.000 was owed, because this path forwarded the raw
|
||||
* i16s verbatim. The nominal ×10000/8192 rescale that first closed that gap ([NOMINAL]) still
|
||||
* leaves that unit's factory bias — about 1 % — on acceleration, and provably cannot fix gyro
|
||||
* at all: the same still-average showed this pad's gyro calibration is nowhere near identity,
|
||||
* and a near-identity one would mean 1024 LSB per °/s, i.e. ±32 °/s full scale, which no
|
||||
* controller has. The scale is per unit; only the pad knows it.
|
||||
*
|
||||
* The arithmetic is `hid-playstation`'s, and the host's contract test
|
||||
* (`crates/pf-inject/tests/motion_contract.rs`, `SonyImuCalibration`) is the same math read
|
||||
* from the other end — it applies it to the blobs our *virtual* pads declare and asserts they
|
||||
* land on the wire constants. Per axis: gyro `raw × speed_2x × 20 / (|plus − bias| +
|
||||
* |minus − bias|)`, accel `(raw − (plus − range/2)) × 20000 / range`, where `range = plus −
|
||||
* minus` spans 2 g.
|
||||
*/
|
||||
class MotionCal private constructor(
|
||||
/** Per axis: `speed_2x × 20`, over `|plus − bias| + |minus − bias|`. */
|
||||
private val gyroNumer: LongArray,
|
||||
private val gyroDenom: LongArray,
|
||||
/** Per axis: the raw count the pad reads at 0 g, and the raw span of 2 g. */
|
||||
private val accelBias: LongArray,
|
||||
private val accelRange: LongArray,
|
||||
) {
|
||||
/** Raw gyro count on [axis] (0 = pitch, 1 = yaw, 2 = roll) → the wire's 20 LSB per °/s. */
|
||||
fun gyroToWire(axis: Int, raw: Int): Int =
|
||||
clampWire(raw.toLong() * gyroNumer[axis] / gyroDenom[axis])
|
||||
|
||||
/** Raw acceleration count on [axis] → the wire's 10000 LSB per g, zero point removed. */
|
||||
fun accelToWire(axis: Int, raw: Int): Int =
|
||||
clampWire((raw - accelBias[axis]) * ACCEL_NUMER / accelRange[axis])
|
||||
|
||||
/**
|
||||
* The derived resolutions, for the capture's one-line claim log — the number that says
|
||||
* whether a pad's blob was actually read (a real DualSense declares ≈16 LSB/°·s and ≈8192
|
||||
* LSB/g; the [NOMINAL] fallback reads back as exactly 20 and 8192).
|
||||
*/
|
||||
override fun toString(): String = buildString {
|
||||
append("gyro ")
|
||||
for (i in 0 until 3) {
|
||||
if (i > 0) append('/')
|
||||
append(gyroDenom[i] * WIRE_GYRO_LSB_PER_DEG_S / gyroNumer[i])
|
||||
}
|
||||
append(" LSB/°·s, accel ")
|
||||
for (i in 0 until 3) {
|
||||
if (i > 0) append('/')
|
||||
append(accelRange[i] / 2)
|
||||
}
|
||||
append(" LSB/g at ")
|
||||
append(accelBias.joinToString("/"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Both conversions are a >1 multiplier on every pad measured so far, so a real ±4 g slam
|
||||
* or a fast flick near full scale would otherwise wrap the i16 and read as an impossible
|
||||
* motion in the opposite direction.
|
||||
*/
|
||||
private fun clampWire(v: Long): Int = v.coerceIn(-32768L, 32767L).toInt()
|
||||
|
||||
companion object {
|
||||
/** The pads' nominal acceleration resolution — `hid-playstation`'s `DS_ACC_RES_PER_G`. */
|
||||
private const val RAW_ACCEL_LSB_PER_G = 8192L
|
||||
/**
|
||||
* The wire's gyro scale, taken from [Gamepad] rather than restated. These were literal
|
||||
* `20L` / `10000L` until the sensor path hoisted the same numbers into one place; a
|
||||
* second copy of a unit constant is precisely the defect this whole program opened
|
||||
* with, and two of them in one module would be worse than the original.
|
||||
*
|
||||
* `val`, not `const val`, only because the widening to Long is not a compile-time
|
||||
* constant expression. Long here on purpose: the arithmetic below multiplies raw counts
|
||||
* by the calibration's speed term before dividing, which overflows an Int.
|
||||
*/
|
||||
private val WIRE_GYRO_LSB_PER_DEG_S = Gamepad.MOTION_GYRO_LSB_PER_DEG_S.toLong()
|
||||
/** `MOTION_ACCEL_LSB_PER_G`, doubled — the declared accel range spans 2 g, not 1. */
|
||||
private val ACCEL_NUMER = 2L * Gamepad.MOTION_ACCEL_LSB_PER_G
|
||||
/** Bytes the layout below reads; the reports themselves are longer (41 / 37). */
|
||||
private const val MIN_LEN = 35
|
||||
|
||||
/**
|
||||
* What an unreadable pad gets: gyro straight through and accel on the nominal 8192
|
||||
* LSB/g. Wrong by that unit's factory bias, and for gyro wrong by however far its
|
||||
* scale sits from the wire's 20 — but a pad whose calibration cannot be read is far
|
||||
* better off slightly mis-scaled than silent, so this never zeroes motion.
|
||||
*/
|
||||
val NOMINAL = MotionCal(
|
||||
LongArray(3) { 1 },
|
||||
LongArray(3) { 1 },
|
||||
LongArray(3),
|
||||
LongArray(3) { 2 * RAW_ACCEL_LSB_PER_G },
|
||||
)
|
||||
|
||||
/**
|
||||
* Parse a calibration feature report ([Model.calReportId]) — all little-endian i16:
|
||||
* `[0]` report id, `[1..7)` gyro bias (pitch, yaw, roll), `[7..19)` gyro plus/minus
|
||||
* INTERLEAVED (pitch+, pitch−, yaw+, yaw−, roll+, roll−), `[19..23)` the two speed
|
||||
* words, `[23..35)` accel plus/minus (x+, x−, y+, y−, z+, z−).
|
||||
*
|
||||
* ⚠ Interleaved is the **USB** order. A Bluetooth DualShock 4 groups the three plusses
|
||||
* before the three minuses and consumers switch layout on the transport — this path is
|
||||
* USB-only by construction (see the file header), so do not "generalise" it.
|
||||
*
|
||||
* Falls back to [NOMINAL] for a failed read (null), a truncated or foreign reply, and
|
||||
* per axis for a degenerate declaration — a clone or broken pad that declares zeroes
|
||||
* would otherwise divide by zero (`hid-playstation` guards the same case, for the same
|
||||
* reason).
|
||||
*/
|
||||
fun parse(blob: ByteArray?, reportId: Int): MotionCal {
|
||||
if (blob == null || blob.size < MIN_LEN) return NOMINAL
|
||||
if ((blob[0].toInt() and 0xFF) != reportId) return NOMINAL
|
||||
val w = { o: Int ->
|
||||
((blob[o + 1].toInt() shl 8) or (blob[o].toInt() and 0xFF)).toShort().toLong()
|
||||
}
|
||||
val speed2x = w(19) + w(21)
|
||||
val gyroNumer = LongArray(3)
|
||||
val gyroDenom = LongArray(3)
|
||||
val accelBias = LongArray(3)
|
||||
val accelRange = LongArray(3)
|
||||
for (i in 0 until 3) {
|
||||
val bias = w(1 + 2 * i)
|
||||
val denom = abs(w(7 + 4 * i) - bias) + abs(w(9 + 4 * i) - bias)
|
||||
if (speed2x > 0 && denom > 0) {
|
||||
gyroNumer[i] = speed2x * WIRE_GYRO_LSB_PER_DEG_S
|
||||
gyroDenom[i] = denom
|
||||
} else {
|
||||
gyroNumer[i] = 1 // passthrough, as before any calibration existed
|
||||
gyroDenom[i] = 1
|
||||
}
|
||||
val plus = w(23 + 4 * i)
|
||||
val range = plus - w(25 + 4 * i)
|
||||
if (range > 0) {
|
||||
accelBias[i] = plus - range / 2
|
||||
accelRange[i] = range
|
||||
} else {
|
||||
accelBias[i] = 0 // nominal, as NOMINAL above
|
||||
accelRange[i] = 2 * RAW_ACCEL_LSB_PER_G
|
||||
}
|
||||
}
|
||||
return MotionCal(gyroNumer, gyroDenom, accelBias, accelRange)
|
||||
}
|
||||
}
|
||||
enum class Model(val pref: Int, val outputSize: Int, val touchW: Int, val touchH: Int) {
|
||||
DUALSENSE(Gamepad.PREF_DUALSENSE, 48, 1920, 1080),
|
||||
DUALSENSE_EDGE(Gamepad.PREF_DUALSENSEEDGE, 64, 1920, 1080),
|
||||
DUALSHOCK4(Gamepad.PREF_DUALSHOCK4, 32, 1920, 942),
|
||||
}
|
||||
|
||||
/** The captured [Model] for a USB PID, or null for anything we don't capture. */
|
||||
@@ -206,9 +50,8 @@ object DsDevice {
|
||||
* The client-consumed fields of one input report. `buttons` is already the WIRE bitmask
|
||||
* (`Gamepad.BTN_*`) — the parse maps device bits straight to the wire, the exact inverse of
|
||||
* the host's `DsState::from_gamepad` (BTN_A ↔ cross, BTN_B ↔ circle, BTN_X ↔ square,
|
||||
* BTN_Y ↔ triangle; positional, not glyph-order). Gyro/accel arrive in WIRE units — the wire's
|
||||
* `Motion` is a unit passthrough into the virtual pad's report, so the pad's raw counts are
|
||||
* rescaled during the parse by the [MotionCal] handed to [parseState]. Touch coordinates stay
|
||||
* BTN_Y ↔ triangle; positional, not glyph-order). Gyro/accel stay in raw device units — the
|
||||
* wire's `Motion` is a unit passthrough into the virtual pad's report. Touch coordinates stay
|
||||
* device-raw here; [DsCapture] normalizes against the model's extent when forwarding.
|
||||
*/
|
||||
class State {
|
||||
@@ -216,8 +59,8 @@ object DsDevice {
|
||||
var lsX = 0; var lsY = 0 // wire i16, +y = up (device is +y down — inverted in the parse)
|
||||
var rsX = 0; var rsY = 0
|
||||
var lt = 0; var rt = 0 // 0..255
|
||||
val gyro = IntArray(3) // wire i16: 20 LSB per °/s (pitch/yaw/roll)
|
||||
val accel = IntArray(3) // wire i16: 10000 LSB per g
|
||||
val gyro = IntArray(3) // raw i16 units (pitch/yaw/roll)
|
||||
val accel = IntArray(3)
|
||||
val touchActive = BooleanArray(2)
|
||||
val touchX = IntArray(2) // raw device coords (0..touchW-1 / 0..touchH-1)
|
||||
val touchY = IntArray(2)
|
||||
@@ -265,25 +108,15 @@ object DsDevice {
|
||||
* short read (the pad also emits `0x09`-family getMAC responses etc. on EP0 — those never hit
|
||||
* the interrupt endpoint, but be defensive). Motion/touch fields update only when the report
|
||||
* is long enough to carry them (it always is on glass — 64-byte interrupt transfers).
|
||||
*
|
||||
* [cal] is this pad's own motion calibration, read once when the capture claims it; the
|
||||
* default is the nominal fallback, which is all a caller without a live pad (the tests) can
|
||||
* have.
|
||||
*/
|
||||
fun parseState(
|
||||
model: Model,
|
||||
report: ByteArray,
|
||||
len: Int,
|
||||
out: State,
|
||||
cal: MotionCal = MotionCal.NOMINAL,
|
||||
): Boolean =
|
||||
fun parseState(model: Model, report: ByteArray, len: Int, out: State): Boolean =
|
||||
if (model == Model.DUALSHOCK4) {
|
||||
parseDs4(report, len, out, cal)
|
||||
parseDs4(report, len, out)
|
||||
} else {
|
||||
parseDs5(model, report, len, out, cal)
|
||||
parseDs5(model, report, len, out)
|
||||
}
|
||||
|
||||
private fun parseDs5(model: Model, r: ByteArray, len: Int, out: State, cal: MotionCal): Boolean {
|
||||
private fun parseDs5(model: Model, r: ByteArray, len: Int, out: State): Boolean {
|
||||
if (len < 11 || (r[0].toInt() and 0xFF) != DS5_INPUT_ID) return false
|
||||
out.lsX = stickX(u8(r, 1))
|
||||
out.lsY = stickY(u8(r, 2))
|
||||
@@ -319,8 +152,8 @@ object DsDevice {
|
||||
}
|
||||
out.buttons = w
|
||||
if (len >= 28) {
|
||||
for (i in 0 until 3) out.gyro[i] = cal.gyroToWire(i, i16(r, 16 + 2 * i))
|
||||
for (i in 0 until 3) out.accel[i] = cal.accelToWire(i, i16(r, 22 + 2 * i))
|
||||
for (i in 0 until 3) out.gyro[i] = i16(r, 16 + 2 * i)
|
||||
for (i in 0 until 3) out.accel[i] = i16(r, 22 + 2 * i)
|
||||
}
|
||||
if (len >= 41) {
|
||||
unpackTouch(r, 33, out, 0)
|
||||
@@ -329,7 +162,7 @@ object DsDevice {
|
||||
return true
|
||||
}
|
||||
|
||||
private fun parseDs4(r: ByteArray, len: Int, out: State, cal: MotionCal): Boolean {
|
||||
private fun parseDs4(r: ByteArray, len: Int, out: State): Boolean {
|
||||
if (len < 10 || (r[0].toInt() and 0xFF) != DS5_INPUT_ID) return false // DS4 shares id 0x01
|
||||
out.lsX = stickX(u8(r, 1))
|
||||
out.lsY = stickY(u8(r, 2))
|
||||
@@ -355,8 +188,8 @@ object DsDevice {
|
||||
if (b7 and DS4_TOUCHPAD != 0) w = w or Gamepad.BTN_TOUCHPAD
|
||||
out.buttons = w
|
||||
if (len >= 25) {
|
||||
for (i in 0 until 3) out.gyro[i] = cal.gyroToWire(i, i16(r, 13 + 2 * i))
|
||||
for (i in 0 until 3) out.accel[i] = cal.accelToWire(i, i16(r, 19 + 2 * i))
|
||||
for (i in 0 until 3) out.gyro[i] = i16(r, 13 + 2 * i)
|
||||
for (i in 0 until 3) out.accel[i] = i16(r, 19 + 2 * i)
|
||||
}
|
||||
if (len >= 43) {
|
||||
unpackTouch(r, 35, out, 0)
|
||||
|
||||
@@ -3,7 +3,6 @@ package io.unom.punktfunk.kit
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Android gamepad capture → punktfunk/1 gamepad wire (the `input.rs::gamepad` contract; the host
|
||||
@@ -55,31 +54,6 @@ object Gamepad {
|
||||
const val AXIS_LT = 4
|
||||
const val AXIS_RT = 5
|
||||
|
||||
// Motion wire units — must equal punktfunk-core `input.rs::gamepad::MOTION_*`. Every motion
|
||||
// sender on this client goes through the two converters below, so a scale that ever has to
|
||||
// change changes in ONE place: the gyro program's first finding was a client sending 40× hot
|
||||
// because a second copy of the number had drifted.
|
||||
const val MOTION_GYRO_LSB_PER_DEG_S = 20
|
||||
const val MOTION_ACCEL_LSB_PER_G = 10_000
|
||||
|
||||
/** Standard gravity, `punktfunk-core`'s `G` — the divisor that turns m/s² into g. */
|
||||
const val GRAVITY = 9.80665f
|
||||
|
||||
/** [MOTION_GYRO_LSB_PER_DEG_S] restated for Android's rad/s sensors: 1 rad/s ⇒ ~1145.9 raw. */
|
||||
const val MOTION_GYRO_LSB_PER_RAD_S = MOTION_GYRO_LSB_PER_DEG_S * 180f / Math.PI.toFloat()
|
||||
|
||||
/** One angular-rate component, Android's rad/s → the wire's signed-16 raw units. */
|
||||
fun motionGyroWire(radPerSec: Float): Int =
|
||||
(radPerSec * MOTION_GYRO_LSB_PER_RAD_S).roundToInt().coerceIn(-32768, 32767)
|
||||
|
||||
/**
|
||||
* One acceleration component, Android's m/s² → the wire's signed-16 raw units. Android reports
|
||||
* specific force (the axis pointing up reads +1 g at rest), which is the DualSense report's own
|
||||
* convention — no sign flip, and a pad lying flat lands on the host's neutral +1 g exactly.
|
||||
*/
|
||||
fun motionAccelWire(mPerSecSq: Float): Int =
|
||||
(mPerSecSq / GRAVITY * MOTION_ACCEL_LSB_PER_G).roundToInt().coerceIn(-32768, 32767)
|
||||
|
||||
// GamepadPref wire bytes — must equal punktfunk-core `config.rs::GamepadPref::to_u8`.
|
||||
const val PREF_AUTO = 0
|
||||
const val PREF_XBOX360 = 1
|
||||
|
||||
@@ -7,7 +7,6 @@ import android.os.Looper
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
@@ -32,8 +31,7 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
*
|
||||
* Threading: slot mutation + dispatch run on the main thread (Android input dispatch and the
|
||||
* InputManager hot-plug callbacks both land there). [deviceForPad] is read from the feedback poll
|
||||
* threads, [padPresent]/[padHasOwnMotion] from the phone-gyro thread and [deviceMotion] from the
|
||||
* pad-sensor thread, so the slot table is a [ConcurrentHashMap].
|
||||
* threads, so the slot table is a [ConcurrentHashMap].
|
||||
*/
|
||||
class GamepadRouter(
|
||||
context: Context,
|
||||
@@ -46,8 +44,8 @@ class GamepadRouter(
|
||||
* as well would give the host two pads for one pair of hands.
|
||||
*
|
||||
* Off still opens slots and tracks held state; it only stops the wire sends. That is
|
||||
* deliberate: the exit, mic and stats chords are read off the same slots, and a couch that lost
|
||||
* its quit shortcut because a forwarding preference was off would be the worse bug. Nothing is
|
||||
* deliberate: the exit and mic chords are read off the same slots, and a couch that lost its
|
||||
* quit shortcut because a forwarding preference was off would be the worse bug. Nothing is
|
||||
* claimed by keeping a slot — the Android input stack shares controllers — unlike the USB
|
||||
* capture links, which `StreamScreen` does not start at all while this is off.
|
||||
*/
|
||||
@@ -71,18 +69,7 @@ class GamepadRouter(
|
||||
) {
|
||||
|
||||
/** One forwarded controller: its stable wire pad index, per-device axis state, and held buttons. */
|
||||
private class Slot(
|
||||
val index: Int,
|
||||
val mapper: Gamepad.AxisMapper,
|
||||
/**
|
||||
* Whether motion sent for this pad can reach the game at all, asked once at open off the
|
||||
* kind it declared ([NativeBridge.nativePadMotionReaches]). False means the host built it a
|
||||
* backend with no motion plane, so [deviceMotion] drops the sample here rather than paying
|
||||
* to send one the host will decode and discard — at a controller's full sensor rate, for
|
||||
* the whole session. The capture-link pads carry the same flag on [ExternalPad].
|
||||
*/
|
||||
val motionReaches: Boolean = true,
|
||||
) {
|
||||
private class Slot(val index: Int, val mapper: Gamepad.AxisMapper) {
|
||||
/** Forwarded button bits currently held (Gamepad.BTN_*) — for release-on-close + chord detection. */
|
||||
var held = 0
|
||||
|
||||
@@ -98,33 +85,12 @@ class GamepadRouter(
|
||||
private val slots = ConcurrentHashMap<Int, Slot>()
|
||||
|
||||
/**
|
||||
* deviceIds whose own gyro [PadSensors] is currently reading — see [setDeviceHasSensorMotion].
|
||||
* Written on the main thread, read from the phone-gyro thread, hence a concurrent set.
|
||||
*/
|
||||
private val sensorDevices: MutableSet<Int> =
|
||||
Collections.newSetFromMap(ConcurrentHashMap<Int, Boolean>())
|
||||
|
||||
/**
|
||||
* Invoked (main thread) with the deviceId whenever a slot closes — hot-unplug, a capture link's
|
||||
* [releaseDevice] claim, or session teardown. `StreamScreen` wires this to
|
||||
* `GamepadFeedback.onDeviceRemoved` so a disconnected pad's rumble / lights bindings are
|
||||
* released promptly instead of leaking until the feedback threads stop, and to
|
||||
* [PadSensors.onSlotClosed] so the controller's own sensor listeners come off with it.
|
||||
* Invoked (main thread) with the deviceId whenever a slot closes — hot-unplug or session teardown.
|
||||
* `StreamScreen` wires this to `GamepadFeedback.onDeviceRemoved` so a disconnected pad's rumble /
|
||||
* lights bindings are released promptly instead of leaking until the feedback threads stop.
|
||||
*/
|
||||
var onSlotClosed: ((deviceId: Int) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Invoked (main thread) with the deviceId whenever a slot opens for a REAL controller — the
|
||||
* hot-plug callback or the first input from a pad the session started without. Not fired for
|
||||
* [openExternal]: a capture link's pad has no [InputDevice] behind it and streams motion from
|
||||
* its own IMU already. `StreamScreen` wires this to [PadSensors.onSlotOpened].
|
||||
*
|
||||
* Slots opened in `init` (every controller already connected) predate any assignment here, so
|
||||
* a listener must sweep [forwardedDevices] once when it starts. Both happen on the main thread
|
||||
* inside one composition block, so nothing can slip between the sweep and the assignment.
|
||||
*/
|
||||
var onSlotOpened: ((deviceId: Int) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Invoked (main thread) when the emergency-exit chord has been HELD for [EXIT_HOLD_MS] — the caller
|
||||
* leaves the stream. `StreamScreen` wires this to the deliberate-quit exit.
|
||||
@@ -149,31 +115,6 @@ class GamepadRouter(
|
||||
*/
|
||||
var onMicChord: (() -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Invoked (main thread) each time the stats chord ([STATS_CHORD], Select + X) is COMPLETED on a
|
||||
* pad — one verbosity tier of the in-stream statistics overlay per completion. It exists
|
||||
* because a controller in both hands has no other way to the numbers: the three-finger tap
|
||||
* needs a touchscreen AND one of the pointer touch models, so a TV or a gamepad-only session
|
||||
* has none. `StreamScreen` wires it to the live tier cycle.
|
||||
*
|
||||
* Fires immediately and once per chord like [onMicChord], and like it the buttons still go to
|
||||
* the host — the chord adds a local meaning to them rather than swallowing them. The Apple
|
||||
* client's `GamepadCapture.statsChord` is the same two buttons; a shortcut that differs per
|
||||
* platform is worse than no shortcut.
|
||||
*/
|
||||
var onStatsChord: (() -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Invoked (main thread) once per pad when a captured controller WITH a gyro turns out to be in
|
||||
* a session whose virtual pad has no motion plane — its motion is not being sent, because every
|
||||
* sample would be decoded and dropped host-side.
|
||||
*
|
||||
* It exists because the failure is otherwise completely silent: the gyro just does nothing, and
|
||||
* from the couch that is indistinguishable from a broken sensor. The fix is the Controller type
|
||||
* setting, so whatever shows this has to name it. `StreamScreen` wires it to a brief notice.
|
||||
*/
|
||||
var onMotionUnreachable: (() -> Unit)? = null
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
/** The pending exit-chord hold timer, or null when the chord isn't currently armed. */
|
||||
private var pendingExit: Runnable? = null
|
||||
@@ -218,7 +159,7 @@ class GamepadRouter(
|
||||
/**
|
||||
* One button transition on [slot] — the shared body behind [onButton] and an [ExternalPad]'s
|
||||
* transitions: forward the wire event, track held state, arm/disarm the exit chord, and fire
|
||||
* the instant chords ([MIC_CHORD], [STATS_CHORD]).
|
||||
* the mic-mute chord ([MIC_CHORD]).
|
||||
*/
|
||||
private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
|
||||
// Raw system buttons stay local under the "local" policy — no wire send and no held
|
||||
@@ -246,11 +187,13 @@ class GamepadRouter(
|
||||
slot.held = slot.held or bit
|
||||
// Full chord now held on this pad → start the hold countdown (idempotent while held).
|
||||
if (slot.held and EXIT_CHORD == EXIT_CHORD) armExit()
|
||||
// Mic mute and the stats-tier cycle, each edge-triggered on the button that COMPLETES
|
||||
// its chord (see [completesChord]) — the two meanings this client gives Select plus a
|
||||
// face button. Both leave the press on the wire: the game still gets its buttons.
|
||||
if (completesChord(wasHeld, bit, MIC_CHORD)) onMicChord?.invoke()
|
||||
if (completesChord(wasHeld, bit, STATS_CHORD)) onStatsChord?.invoke()
|
||||
// Mic mute, edge-triggered on the button that COMPLETES the chord: a genuine press
|
||||
// (`wasHeld` lacks the bit, so an auto-repeat DOWN can't re-fire it) of a chord member
|
||||
// that leaves the whole chord held. Any other button pressed while Select + Y are down
|
||||
// fails the middle test, so the toggle happens once per chord, not once per press.
|
||||
if (wasHeld and bit == 0 && bit and MIC_CHORD != 0 && slot.held and MIC_CHORD == MIC_CHORD) {
|
||||
onMicChord?.invoke()
|
||||
}
|
||||
} else {
|
||||
val owned = guideGesture && bit == Gamepad.BTN_BACK && consumeSelectRelease(slot)
|
||||
if (!owned && send && forwarding) {
|
||||
@@ -377,82 +320,13 @@ class GamepadRouter(
|
||||
return null
|
||||
}
|
||||
|
||||
/** Whether ANY live slot currently holds wire pad [pad]. Read from the phone-gyro thread. */
|
||||
fun padPresent(pad: Int): Boolean = slots.values.any { it.index == pad }
|
||||
|
||||
/**
|
||||
* Whether wire pad [pad]'s motion already comes from the controller's OWN IMU — either a
|
||||
* capture-link slot ([ExternalPad] — USB DualSense / SC2; synthetic ids are negative
|
||||
* ([EXTERNAL_ID_BASE]), real [InputDevice] ids positive), or a real controller whose gyro
|
||||
* [PadSensors] is reading through the platform sensor framework (a Bluetooth DualSense /
|
||||
* Switch Pro / 8BitDo). The phone-gyro mirror stands down for both: two motion writers on one
|
||||
* wire pad would fight, and the pad's own IMU is the one attached to the player's hands.
|
||||
* Read from the phone-gyro thread (both tables are concurrent).
|
||||
*/
|
||||
fun padHasOwnMotion(pad: Int): Boolean =
|
||||
slots.any { (id, slot) -> slot.index == pad && (id < 0 || id in sensorDevices) }
|
||||
|
||||
/**
|
||||
* Declare (or withdraw) that real controller [deviceId] is sourcing its own rotation — see
|
||||
* [padHasOwnMotion]. Called by [PadSensors] as it registers and unregisters listeners, on the
|
||||
* main thread; read from the phone-gyro thread, hence the concurrent set. Keyed by device
|
||||
* rather than by pad index so a controller that changes wire index (a lower one freed up while
|
||||
* it was captured) carries the fact with it.
|
||||
*/
|
||||
fun setDeviceHasSensorMotion(deviceId: Int, has: Boolean) {
|
||||
if (has) sensorDevices.add(deviceId) else sensorDevices.remove(deviceId)
|
||||
// This is the first moment we know a Bluetooth pad actually HAS a gyro — `openSlot` only
|
||||
// knows what kind it declared. So it is the honest place to raise the notice when that
|
||||
// gyro has nowhere to go, and the only one that cannot nag about a pad that never had one.
|
||||
if (has && forwarding && slots[deviceId]?.motionReaches == false) {
|
||||
onMotionUnreachable?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One motion sample from real controller [deviceId]'s own sensors, on whatever wire index its
|
||||
* slot currently holds — [ExternalPad.motion] for pads the input stack still owns. Silently
|
||||
* drops when the slot is gone (unplugged, or claimed by a capture link between the sensor
|
||||
* callback and here) rather than writing to an index that may already belong to someone else.
|
||||
* Called from [PadSensors]' sensor thread.
|
||||
*/
|
||||
fun deviceMotion(deviceId: Int, gyro: IntArray, accel: IntArray) {
|
||||
val slot = slots[deviceId] ?: return
|
||||
if (!forwarding) return
|
||||
// The same gate the USB capture path takes: a backend with no motion plane decodes every
|
||||
// sample and discards it, so sending is pure cost. Notified once per pad by
|
||||
// [setDeviceHasSensorMotion], which is where we first know the controller HAS a gyro to
|
||||
// lose — a pad without one must not produce a warning about motion.
|
||||
if (!slot.motionReaches) return
|
||||
NativeBridge.nativeSendPadMotion(
|
||||
handle, slot.index,
|
||||
gyro[0], gyro[1], gyro[2],
|
||||
accel[0], accel[1], accel[2],
|
||||
)
|
||||
}
|
||||
|
||||
/** Snapshot of the REAL controllers currently forwarded, as deviceIds — the set [PadSensors]
|
||||
* sweeps at start for the pads that were already connected when the session opened. */
|
||||
fun forwardedDevices(): List<Int> = slots.keys.filter { it >= 0 }
|
||||
|
||||
/**
|
||||
* A capture-link pad occupying a wire slot without an Android [InputDevice] — the as-is Steam
|
||||
* Controller 2 passthrough (USB/BLE claimed directly, invisible to the input stack). Shares
|
||||
* the real slots' lifecycle: a stable lowest-free index, Arrival-before-input, held-state
|
||||
* flush + Remove on [close], and full participation in the emergency exit chord.
|
||||
*/
|
||||
inner class ExternalPad internal constructor(
|
||||
private val syntheticId: Int,
|
||||
val index: Int,
|
||||
/**
|
||||
* Whether this pad's motion can reach the game at all, asked once at open (see
|
||||
* [NativeBridge.nativePadMotionReaches]). False means the host built this pad a backend
|
||||
* without a motion plane, so [motion] drops the sample here instead of paying to send one
|
||||
* the host will decode and discard — at a controller's full report rate, for the whole
|
||||
* session.
|
||||
*/
|
||||
private val motionReaches: Boolean,
|
||||
) {
|
||||
inner class ExternalPad internal constructor(private val syntheticId: Int, val index: Int) {
|
||||
// Live lookup instead of a captured reference: after [close] (or a router release) the
|
||||
// slot is gone from the table and every entry point below degrades to a safe no-op.
|
||||
private val slot get() = slots[syntheticId]
|
||||
@@ -483,7 +357,7 @@ class GamepadRouter(
|
||||
/** One motion sample on the rich plane (gyro pitch/yaw/roll + accel, raw device i16
|
||||
* units — the host passes them straight into the virtual pad's report). Per report. */
|
||||
fun motion(gyro: IntArray, accel: IntArray) {
|
||||
if (slot != null && forwarding && motionReaches) {
|
||||
if (slot != null && forwarding) {
|
||||
NativeBridge.nativeSendPadMotion(
|
||||
handle, index,
|
||||
gyro[0], gyro[1], gyro[2],
|
||||
@@ -499,26 +373,15 @@ class GamepadRouter(
|
||||
/**
|
||||
* Open a slot for a capture-link pad, declaring [pref] as its kind; null when all 16 wire
|
||||
* indices are taken. Main thread (like the hot-plug callbacks).
|
||||
*
|
||||
* [hasGyro] says whether this link forwards motion on the RICH plane ([ExternalPad.motion]) —
|
||||
* true for the Sony pads, whose IMU is a headline feature, and false for the Steam Controller 2,
|
||||
* whose motion rides inside the opaque passthrough report that [ExternalPad.hidReport] carries
|
||||
* and which nothing here may second-guess. It gates only the notice: a pad that never sends
|
||||
* motion must not produce a warning about motion.
|
||||
*/
|
||||
fun openExternal(pref: Int, hasGyro: Boolean = false): ExternalPad? {
|
||||
fun openExternal(pref: Int): ExternalPad? {
|
||||
val index = lowestFreeIndex() ?: return null
|
||||
// Synthetic ids live below any real InputDevice id (those are positive), so they can't
|
||||
// collide and InputDevice.getDevice(id) resolves them to null for the feedback path.
|
||||
val syntheticId = EXTERNAL_ID_BASE - index
|
||||
if (forwarding) NativeBridge.nativeSendGamepadArrival(handle, pref, index)
|
||||
// Asked once, here, off the kind this pad just DECLARED — not off the session's resolved
|
||||
// backend, which under Automatic answers for whichever pad happened to be active at dial
|
||||
// time. Cheap enough to ask unconditionally; the answer holds for the pad's lifetime.
|
||||
val motionReaches = NativeBridge.nativePadMotionReaches(handle, pref)
|
||||
if (forwarding && hasGyro && !motionReaches) onMotionUnreachable?.invoke()
|
||||
slots[syntheticId] = Slot(index, Gamepad.AxisMapper(handle, index))
|
||||
return ExternalPad(syntheticId, index, motionReaches)
|
||||
return ExternalPad(syntheticId, index)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -574,18 +437,8 @@ class GamepadRouter(
|
||||
// to that type (a single global choice — matches the handshake's session-default pref).
|
||||
val pref = if (setting == Gamepad.PREF_AUTO) Gamepad.prefFor(dev) else setting
|
||||
if (forwarding) NativeBridge.nativeSendGamepadArrival(handle, pref, index)
|
||||
// Asked here, off the kind this pad just DECLARED — not off the session's resolved backend,
|
||||
// which under Automatic answers for whichever pad happened to be active at dial time. Held
|
||||
// for the slot's life; the sensor path reads it on every sample.
|
||||
val slot = Slot(
|
||||
index,
|
||||
Gamepad.AxisMapper(handle, index),
|
||||
NativeBridge.nativePadMotionReaches(handle, pref),
|
||||
)
|
||||
val slot = Slot(index, Gamepad.AxisMapper(handle, index))
|
||||
slots[dev.id] = slot
|
||||
// After the table holds the slot, so a listener that sends on this device the moment it is
|
||||
// told ([PadSensors]) finds an index to send on rather than dropping its first samples.
|
||||
onSlotOpened?.invoke(dev.id)
|
||||
return slot
|
||||
}
|
||||
|
||||
@@ -640,11 +493,7 @@ class GamepadRouter(
|
||||
return null
|
||||
}
|
||||
|
||||
// `internal` rather than private: the chord masks and [completesChord] are the only part of
|
||||
// this router a JVM unit test can reach — everything else needs an InputManager, a main Looper
|
||||
// and live InputDevices behind it — and until `GamepadChordTest` there was nothing pinning the
|
||||
// chords at all. Still invisible to :app, which is what private bought.
|
||||
internal companion object {
|
||||
private companion object {
|
||||
/** Mirror of `punktfunk-core::input::MAX_PADS` — wire pad indices 0..15. */
|
||||
const val MAX_PADS = 16
|
||||
|
||||
@@ -666,28 +515,6 @@ class GamepadRouter(
|
||||
*/
|
||||
const val MIC_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_Y
|
||||
|
||||
/**
|
||||
* Stats-overlay chord: Select + X, one verbosity tier per completion. X keeps both
|
||||
* properties [MIC_CHORD]'s Y has — it is none of [EXIT_CHORD]'s four buttons, so no way of
|
||||
* reaching the exit chord passes through this one on the way (and vice versa), and Select
|
||||
* is a menu button rather than a twitch action. Byte-for-byte the Apple client's
|
||||
* `GamepadCapture.statsChord`, which was modelled on [MIC_CHORD] in the first place and
|
||||
* leaves Y free for the mic chord to land there in turn — the two clients converge on one
|
||||
* pad vocabulary from both ends.
|
||||
*/
|
||||
const val STATS_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_X
|
||||
|
||||
/**
|
||||
* Whether pressing [bit] on a pad that held [wasHeld] beforehand COMPLETED [chord]: a
|
||||
* genuine press (`wasHeld` lacks the bit, so an auto-repeat DOWN can't re-fire it) of a
|
||||
* chord member that leaves the whole chord held (`wasHeld or bit` is the slot's held set
|
||||
* the instant after the press). Any other button pressed while the chord is already down
|
||||
* fails the middle test, so a chord fires once per chord, not once per press — and lifting
|
||||
* any member re-arms it, since the next press of that member is a fresh completion.
|
||||
*/
|
||||
internal fun completesChord(wasHeld: Int, bit: Int, chord: Int): Boolean =
|
||||
wasHeld and bit == 0 && bit and chord != 0 && (wasHeld or bit) and chord == chord
|
||||
|
||||
/** Synthetic slot-key base for [ExternalPad]s — below every real (positive) InputDevice id. */
|
||||
const val EXTERNAL_ID_BASE = -1000
|
||||
|
||||
|
||||
@@ -442,42 +442,6 @@ class HidUsbLink(
|
||||
return n >= 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one report back OUT of the device — HID `GET_REPORT`, the EP0 mirror of [sendReport].
|
||||
* [type] is [REPORT_TYPE_FEATURE] (or output), [id] the report number, [len] the report's full
|
||||
* declared size INCLUDING its leading id byte, which a numbered report echoes back in byte 0
|
||||
* (hidapi framing). Returns what arrived — truncated if the device answered short — or null
|
||||
* when the device refuses the request or the link is down.
|
||||
*
|
||||
* ⚠ **Once, at claim time; never per input report.** EP0 is independent of the interrupt
|
||||
* endpoints (see [sendReport]), so this is safe alongside the reader thread — but it BLOCKS the
|
||||
* calling thread for up to [WRITE_TIMEOUT_MS], and a blocking control transfer in the report
|
||||
* path would wreck capture latency. The one caller reads a Sony pad's fixed motion calibration
|
||||
* when the capture engages ([DsCapture]).
|
||||
*/
|
||||
fun getReport(type: Int, id: Int, len: Int): ByteArray? {
|
||||
if (len <= 0) return null
|
||||
val conn = connection ?: return null
|
||||
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return null
|
||||
val buf = ByteArray(len)
|
||||
val n = runCatching {
|
||||
conn.controlTransfer(
|
||||
0xA1, // device→host, class, interface
|
||||
0x01, // GET_REPORT
|
||||
(type shl 8) or id,
|
||||
ifId,
|
||||
buf,
|
||||
buf.size,
|
||||
WRITE_TIMEOUT_MS,
|
||||
)
|
||||
}.getOrDefault(-1)
|
||||
return when {
|
||||
n >= len -> buf
|
||||
n > 0 -> buf.copyOf(n)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed].
|
||||
*
|
||||
@@ -505,13 +469,12 @@ class HidUsbLink(
|
||||
device = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val READ_TIMEOUT_MS = 100L
|
||||
private const val WRITE_TIMEOUT_MS = 250
|
||||
private companion object {
|
||||
const val READ_TIMEOUT_MS = 100L
|
||||
const val WRITE_TIMEOUT_MS = 250
|
||||
/** Hard `requestWait` ERRORS (not timeouts) persisting this long = the fd is dead. */
|
||||
private const val ERROR_UNPLUG_MS = 2000L
|
||||
private const val REPORT_TYPE_OUTPUT = 0x02
|
||||
/** HID feature-report type — public for [getReport] callers ([writeRaw] takes a kind). */
|
||||
const val ERROR_UNPLUG_MS = 2000L
|
||||
const val REPORT_TYPE_OUTPUT = 0x02
|
||||
const val REPORT_TYPE_FEATURE = 0x03
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
/**
|
||||
* The hand-off of one claim's motion calibration, from the thread that reads it off the pad to the
|
||||
* link thread that scales every input report with it.
|
||||
*
|
||||
* [DsCapture] reads a captured Sony pad's calibration feature report **off** the claiming thread —
|
||||
* it is a blocking EP0 control transfer and the claim runs on the UI's thread — so the value lands
|
||||
* a moment after the capture goes live. Reports in that gap are scaled by
|
||||
* [DsDevice.MotionCal.NOMINAL] and forwarded like any other ([effective]): for about a millisecond
|
||||
* the pad behaves exactly as it did before the calibration read existed — acceleration a little
|
||||
* short, gyro unscaled — which nobody can feel, whereas a pad that ignores its buttons until an
|
||||
* EP0 read comes back is very obvious.
|
||||
*
|
||||
* What the hand-off is actually for is the two things that gap must NOT do, neither of which a
|
||||
* plain field gives:
|
||||
*
|
||||
* - **Fall back to the previous pad's numbers instead of the nominal ones.** Calibration is per
|
||||
* unit, so the last controller's scale factors are simply wrong for this one — more wrong, in
|
||||
* general, than the nominal constants. [begin] forgets them, which is what makes the gap
|
||||
* nominal rather than inherited.
|
||||
* - **Let a read that outlived its claim publish.** An unplug, a [DsCapture.stop] and a fast
|
||||
* re-claim can all land while a read is in flight; [publish] only accepts a value whose token is
|
||||
* still the live claim's, so a straggler can never scale a pad it never read.
|
||||
*
|
||||
* Thread-safe: claimed and ended by the claiming thread, published by the reading thread, read by
|
||||
* the link thread.
|
||||
*/
|
||||
internal class MotionCalHandoff {
|
||||
/** Handed out by [begin] and burned by [end] — never reused, so a straggler can't match. */
|
||||
private var token = 0
|
||||
|
||||
@Volatile private var cal: DsDevice.MotionCal? = null
|
||||
|
||||
/**
|
||||
* The calibration to scale the next report with: the live claim's own, or the nominal fallback
|
||||
* while its read is still in flight. Never null — a report is always forwarded, never held
|
||||
* back waiting for a control transfer.
|
||||
*/
|
||||
val effective: DsDevice.MotionCal get() = cal ?: DsDevice.MotionCal.NOMINAL
|
||||
|
||||
/** Open a claim: forget the previous pad's calibration, and take this claim's token. */
|
||||
@Synchronized
|
||||
fun begin(): Int {
|
||||
cal = null
|
||||
return ++token
|
||||
}
|
||||
|
||||
/** End the live claim. Nothing read under an older token can land after this. */
|
||||
@Synchronized
|
||||
fun end() {
|
||||
cal = null
|
||||
token++
|
||||
}
|
||||
|
||||
/** Publish [value] if [claim] is still the live claim; returns whether it landed. */
|
||||
@Synchronized
|
||||
fun publish(claim: Int, value: DsDevice.MotionCal): Boolean {
|
||||
if (claim != token) return false
|
||||
cal = value
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -264,12 +264,12 @@ object NativeBridge {
|
||||
|
||||
/**
|
||||
* Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs.
|
||||
* Returns 35 doubles (unified stats spec, `design/stats-unification.md`):
|
||||
* Returns 33 doubles (unified stats spec, `design/stats-unification.md`):
|
||||
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
|
||||
* bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
|
||||
* netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow, dispValid, displayP50Ms,
|
||||
* e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms, presentsWindow, presenterActive,
|
||||
* feedP50Ms, codecP50Ms, skippedOverflowWindow, audioBufferMs, audioAvOffsetMs]`
|
||||
* feedP50Ms, codecP50Ms, skippedOverflowWindow]`
|
||||
* (the flags are 1.0/0.0; indexes 2/3 are the end-to-end capture→decoded headline; 10–13
|
||||
* describe the negotiated video feed — bit depth 8/10, CICP primaries/transfer, and the HEVC
|
||||
* chroma_format_idc 1=4:2:0 / 3=4:4:4; 14/15 are the stage p50s tiling the headline —
|
||||
@@ -285,10 +285,7 @@ object NativeBridge {
|
||||
* the window's on-glass confirm count, and whether the presenter is active at all; 30/31
|
||||
* split `decode` (15) the same way — `feed` = received→queued (hand-off + input-slot wait),
|
||||
* `codec` = queued→decoded, the decoder's own time; 32 is the parked-AU overflow subset of
|
||||
* `skipped` (19), i.e. the decoder falling behind rather than benign newest-wins pacing;
|
||||
* 33/34 are the AUDIO plane — the playback ring's live depth in ms and the A/V sync loop's
|
||||
* smoothed offset in ms, positive meaning audio plays BEHIND the picture. Those two are live
|
||||
* gauges, not windowed samples, and the offset reads 0 until the loop has a video reference).
|
||||
* `skipped` (19), i.e. the decoder falling behind rather than benign newest-wins pacing).
|
||||
* Poll ~1 Hz; each call resets the measurement window.
|
||||
*/
|
||||
external fun nativeVideoStats(handle: Long): DoubleArray?
|
||||
@@ -519,23 +516,6 @@ object NativeBridge {
|
||||
/** Signal wire pad [pad] (0..15) was unplugged so the host tears its virtual device down. The core stamps the seq + re-sends. */
|
||||
external fun nativeSendGamepadRemove(handle: Long, pad: Int)
|
||||
|
||||
/**
|
||||
* Whether motion sent for a pad that declared [declaredPref] (the [Gamepad].PREF_* byte passed
|
||||
* to [nativeSendGamepadArrival]) can actually reach the game, or would be decoded and dropped
|
||||
* by a host backend without a motion plane — the X-Box classes have no gyro in their HID
|
||||
* contract.
|
||||
*
|
||||
* Answered natively, off `punktfunk_core::config::pad_motion_reaches`, rather than
|
||||
* reconstructed here from the session's requested/resolved prefs. The rule is subtler than it
|
||||
* looks (the host builds each pad from its OWN declaration and folds what it cannot build, so
|
||||
* neither the declaration nor the session echo answers it alone) and every way of getting it
|
||||
* wrong is silent, so it lives in one place with one set of tests.
|
||||
*
|
||||
* Ask ONCE when a pad opens, not per sample. `true` when the session handle is dead — "don't
|
||||
* suppress" is the safe answer whenever we cannot tell.
|
||||
*/
|
||||
external fun nativePadMotionReaches(handle: Long, declaredPref: Int): Boolean
|
||||
|
||||
/**
|
||||
* One raw HID input report from a client-captured controller (the as-is Steam Controller 2
|
||||
* passthrough), forwarded verbatim on the rich-input plane. [buf] is a DIRECT ByteBuffer whose
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.Log
|
||||
import android.view.InputDevice
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Motion from a controller the Android input stack owns — the Bluetooth pads.
|
||||
*
|
||||
* Before this, the only motion sources on Android were the capture links: [DsCapture] (a Sony pad
|
||||
* claimed over USB, raw HID) and [Sc2Capture] (Steam Controller 2 passthrough). A DualSense, a
|
||||
* DualShock 4, a Switch Pro or an 8BitDo paired over BLUETOOTH is neither — it arrives as an
|
||||
* ordinary [InputDevice], its buttons and sticks work, and its gyro was silently dead. That is a
|
||||
* whole class of controller with no motion at all.
|
||||
*
|
||||
* Android 12 (API 31) exposes those sensors: [InputDevice.getSensorManager] hands back a
|
||||
* [android.hardware.SensorManager] scoped to that one controller, carrying the usual
|
||||
* TYPE_GYROSCOPE / TYPE_ACCELEROMETER. This class registers a listener per forwarded controller
|
||||
* that has a gyroscope, converts each sample to wire units, and sends it on that pad's wire index
|
||||
* through [GamepadRouter.deviceMotion]. Below API 31 nothing is registered and the class is inert —
|
||||
* those pads keep working, minus motion, exactly as they did.
|
||||
*
|
||||
* It follows [DeviceGyro] (the phone-gyro mirror) wherever the two solve the same problem:
|
||||
* - samples ride ONE dedicated [HandlerThread] with batching disabled (`maxReportLatencyUs = 0`)
|
||||
* — sensor batching would trade away the exact latency gyro aim exists to avoid, and the main
|
||||
* thread is where Compose recomposition lives;
|
||||
* - a feed torn down while its wire pad is still alive parks the rotation at zero first, because
|
||||
* the host holds motion as STATE and re-emits it in every virtual-pad report: an angular
|
||||
* velocity left behind reads as a pad rotating forever (the gyro sweep's "stale rate re-sent
|
||||
* forever" finding).
|
||||
*
|
||||
* One writer per pad, three ways:
|
||||
* 1. A USB capture claims the physical device away from the input stack; [DsCapture.startUsb]
|
||||
* calls [GamepadRouter.releaseDevice] at claim time, which closes the slot, which fires
|
||||
* `onSlotClosed`, which lands on [onSlotClosed] here and unregisters. The claim also makes the
|
||||
* controller's [InputDevice] vanish outright, so even a reopened slot would find nothing to
|
||||
* register — but the explicit teardown is what makes the ordering deterministic instead of a
|
||||
* race against the platform's own removal callback.
|
||||
* 2. The phone-gyro mirror stands down: registering flips
|
||||
* [GamepadRouter.setDeviceHasSensorMotion], [GamepadRouter.padHasOwnMotion] reports it, and
|
||||
* [DeviceGyro] re-reads that gate on every sample (sending its own zero park on the edge).
|
||||
* 3. Exactly one feed exists per deviceId — [onSlotOpened] is idempotent, and it is the only
|
||||
* thing that ever constructs one.
|
||||
*
|
||||
* Frame: see [gyroToWire] — the mapping is straight through, and NOT yet verified on hardware.
|
||||
*/
|
||||
class PadSensors(private val router: GamepadRouter) {
|
||||
|
||||
/** One controller's live sensor feed: its listener state and the accel it pairs with each
|
||||
* rotation. Its arrays belong to the sensor thread; [stop] reads them only after the join. */
|
||||
private inner class Feed(private val deviceId: Int) : SensorEventListener {
|
||||
/** Latest converted accel, paired with each gyro send (the wire fuses both per sample).
|
||||
* Starts at the host's neutral — 1 g on the up axis, NOT [0,0,0], which is free fall. */
|
||||
private val accel = intArrayOf(0, Gamepad.MOTION_ACCEL_LSB_PER_G, 0)
|
||||
private val gyro = IntArray(3)
|
||||
|
||||
/** Whether any rotation has gone out on this pad — gates the park on teardown, so a pad
|
||||
* that never sent motion is not handed a sample it did not earn. */
|
||||
@Volatile
|
||||
var wroteMotion = false
|
||||
private set
|
||||
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
when (event.sensor.type) {
|
||||
Sensor.TYPE_ACCELEROMETER -> accelToWire(event.values, accel)
|
||||
Sensor.TYPE_GYROSCOPE -> {
|
||||
gyroToWire(event.values, gyro)
|
||||
// One line per controller per session, on the first sample that carries both
|
||||
// planes: it is the cheapest possible version of the frame measurement
|
||||
// [gyroToWire] asks for. Hold the pad flat and still while a stream starts and
|
||||
// the accel triple says which slot gravity lands on — the one thing that
|
||||
// settles whether the straight-through mapping is right.
|
||||
if (!wroteMotion) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"controller $deviceId first motion sample: " +
|
||||
"gyro ${gyro.joinToString()} accel ${accel.joinToString()}",
|
||||
)
|
||||
}
|
||||
wroteMotion = true
|
||||
router.deviceMotion(deviceId, gyro, accel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
|
||||
|
||||
/** Zero rotation, last-known accel — "at rest", not free fall. */
|
||||
fun park() {
|
||||
gyro.fill(0)
|
||||
router.deviceMotion(deviceId, gyro, accel)
|
||||
}
|
||||
}
|
||||
|
||||
/** deviceId → live feed. Concurrent: the main thread mutates it while the sensor thread is
|
||||
* running (hot-plug, a capture link's claim). */
|
||||
private val feeds = ConcurrentHashMap<Int, Feed>()
|
||||
|
||||
private val thread = HandlerThread("pf-pad-sensors")
|
||||
private var handler: Handler? = null
|
||||
|
||||
/**
|
||||
* Start the sensor thread and attach to every controller the router already forwards — the
|
||||
* pads connected before the session opened, which will never fire a hot-plug callback.
|
||||
* Everything after that arrives through [onSlotOpened]. Main thread.
|
||||
*/
|
||||
fun start() {
|
||||
if (!supported()) return
|
||||
thread.start()
|
||||
handler = Handler(thread.looper)
|
||||
for (deviceId in router.forwardedDevices()) onSlotOpened(deviceId)
|
||||
}
|
||||
|
||||
/**
|
||||
* A slot opened for real controller [deviceId] — attach if it has a gyroscope of its own.
|
||||
* Idempotent, and a no-op before [start] or on a platform without the API. Main thread, from
|
||||
* [GamepadRouter.onSlotOpened].
|
||||
*/
|
||||
fun onSlotOpened(deviceId: Int) {
|
||||
val h = handler ?: return
|
||||
if (feeds.containsKey(deviceId)) return
|
||||
// API 31+ only — getSensorManager does not exist below it. Re-checked here rather than
|
||||
// relying on start()'s gate, so the entry point is safe on its own terms.
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
|
||||
val dev = InputDevice.getDevice(deviceId) ?: return
|
||||
// Declared non-null: a controller with no sensors gets an empty manager, not a null one.
|
||||
val sm = dev.sensorManager
|
||||
// A gyroscope is the entry price; the accelerometer alone does not buy a feed. The rotation
|
||||
// is what gyro aim is for, and an accel-only feed would send gravity while pinning rotation
|
||||
// at zero on a pad the phone-gyro mirror is otherwise entitled to speak for — precisely the
|
||||
// two-writers-on-one-pad fight this program has spent its day unpicking. Such a pad stays
|
||||
// on the mirror's terms instead, where at least the accel agrees with the gyro beside it.
|
||||
// Nothing found here is not proof the pad has no IMU. A DualSense's motion arrives on its
|
||||
// own evdev node, and whether InputReader merges that node onto the gamepad InputDevice
|
||||
// (shared descriptor) or leaves it standing alone is the platform's business, not ours —
|
||||
// and a standalone one is exactly what GamepadRouter.isForwardable filters out, so this
|
||||
// would never see it. Android 12's own controller-sensor documentation cites the DualShock
|
||||
// 4 and DualSense, which says the merge happens; it is not something this code can assert.
|
||||
// If a Bluetooth Sony pad ever turns up here with no gyroscope, THAT is the thing to check.
|
||||
val gyroSensor = sm.getDefaultSensor(Sensor.TYPE_GYROSCOPE) ?: return
|
||||
val feed = Feed(deviceId)
|
||||
feeds[deviceId] = feed
|
||||
// ~200 Hz requested, zero report latency: batching is poison for gyro aim, and 200 Hz is
|
||||
// what the framework grants an app without HIGH_SAMPLING_RATE_SENSORS anyway.
|
||||
sm.registerListener(feed, gyroSensor, DeviceGyro.SAMPLING_PERIOD_US, 0, h)
|
||||
sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)?.let {
|
||||
sm.registerListener(feed, it, DeviceGyro.SAMPLING_PERIOD_US, 0, h)
|
||||
}
|
||||
// The pad sources its own rotation from here on → the phone-gyro mirror stands down for it.
|
||||
router.setDeviceHasSensorMotion(deviceId, true)
|
||||
Log.i(TAG, "controller $deviceId (${dev.name}) has a gyro — forwarding its motion")
|
||||
}
|
||||
|
||||
/**
|
||||
* The slot for [deviceId] closed — unplug, session teardown, or a capture link claiming the
|
||||
* device. Unregister and hand the pad back to the phone-gyro mirror. Main thread, from
|
||||
* [GamepadRouter.onSlotClosed].
|
||||
*
|
||||
* No park-at-zero here, on purpose: the router removed the slot BEFORE invoking the callback
|
||||
* and has already sent that pad's Remove, so the host tore the virtual pad down and there is no
|
||||
* latched rotation left to clear — while writing to a wire index that is free again would be
|
||||
* addressing whoever claims it next. [stop] is the case where the pad outlives the feed.
|
||||
*/
|
||||
fun onSlotClosed(deviceId: Int) {
|
||||
unregister(deviceId)
|
||||
router.setDeviceHasSensorMotion(deviceId, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister every listener, join the sensor thread, then park at zero each pad that was
|
||||
* rotating. Call BEFORE the router is released and the session handle freed — the same
|
||||
* teardown ordering rule as the feedback poll threads and [DeviceGyro.stop]. The parks come
|
||||
* AFTER the join for two reasons: a sample still in flight would re-latch the rotation just
|
||||
* cleared, and the join is what publishes the sensor thread's writes to this one.
|
||||
*/
|
||||
fun stop() {
|
||||
val parked = feeds.keys.toList().mapNotNull { id -> unregister(id)?.let { id to it } }
|
||||
for ((deviceId, _) in parked) router.setDeviceHasSensorMotion(deviceId, false)
|
||||
thread.quitSafely()
|
||||
runCatching { thread.join() }
|
||||
handler = null
|
||||
for ((_, feed) in parked) if (feed.wroteMotion) feed.park()
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop [deviceId]'s listeners, returning the feed that held them (null if there was none).
|
||||
* Safe for a controller that is already gone: the sensor manager is reached through the
|
||||
* [InputDevice], and a vanished device simply leaves nothing to unregister — the platform has
|
||||
* stopped calling the listener either way.
|
||||
*/
|
||||
private fun unregister(deviceId: Int): Feed? {
|
||||
val feed = feeds.remove(deviceId) ?: return null
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
InputDevice.getDevice(deviceId)?.sensorManager?.unregisterListener(feed)
|
||||
}
|
||||
return feed
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PadSensors"
|
||||
|
||||
/** Whether this platform can read a controller's own sensors at all (API 31+). */
|
||||
fun supported(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
|
||||
/**
|
||||
* One gyroscope sample (Android: rad/s) → the wire's three signed-16 components, in place.
|
||||
*
|
||||
* The axis frame is straight through, and that is now MEASURED rather than assumed.
|
||||
*
|
||||
* The wire is a unit passthrough into a virtual DualSense report, whose frame was measured
|
||||
* over raw HID on 2026-08-07: slot 0 = Right (pitch), slot 1 = Up (yaw), slot 2 = Backward
|
||||
* toward the player (roll), right-handed. Android hands a controller's own sensors over in
|
||||
* that same frame — which was the documented expectation, but the numbers pass through a
|
||||
* HID driver and InputFlinger's sensor mapper, either of which could have permuted or
|
||||
* negated without saying so.
|
||||
*
|
||||
* Verified 2026-08-07 end to end: a DualSense on Bluetooth to an Android phone, streaming
|
||||
* to a Linux host. This path's own first-sample log read `accel 0, 10000, 0` — exactly 1 g
|
||||
* on slot 1 — and at the far end `hid-playstation` published gravity as +0.991 g on ABS_Y
|
||||
* with every rotation driving its correctly-named axis (yaw→RY, pitch→RX, roll→RZ) and the
|
||||
* signs agreeing with gravity's independent witness on 95 of 100 rotating samples.
|
||||
*
|
||||
* So: no remap. If a future device disagrees, the remap belongs HERE with its own
|
||||
* expectations in `PadSensorsTest` — not spread across callers.
|
||||
*/
|
||||
fun gyroToWire(values: FloatArray, out: IntArray) {
|
||||
for (i in 0..2) out[i] = Gamepad.motionGyroWire(values.getOrElse(i) { 0f })
|
||||
}
|
||||
|
||||
/**
|
||||
* One accelerometer sample (Android: m/s², specific force) → the wire's three signed-16
|
||||
* components, in place. Same measured frame as [gyroToWire] and the same straight-through
|
||||
* mapping; the sign needs no flip, because Android and the DualSense report agree that the
|
||||
* axis pointing up reads +1 g at rest (see [Gamepad.motionAccelWire]) — which is precisely
|
||||
* what the on-glass run read back, `accel 0, 10000, 0` with the pad lying flat.
|
||||
*/
|
||||
fun accelToWire(values: FloatArray, out: IntArray) {
|
||||
for (i in 0..2) out[i] = Gamepad.motionAccelWire(values.getOrElse(i) { 0f })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,9 +44,6 @@ data class Artwork(val portrait: String?, val header: String?, val hero: String?
|
||||
* opens the launcher itself (Steam Big Picture, Heroic) rather than a title. Kept a plain nullable
|
||||
* String on purpose: the host owns the vocabulary, and an unknown future value must degrade to a
|
||||
* game rather than break the decode (design D4).
|
||||
*
|
||||
* [icon] is the token for the entry's brand mark (`"steam"`, `"heroic"`) — never art, never a URL.
|
||||
* Null on every older host and on every ordinary title.
|
||||
*/
|
||||
data class GameEntry(
|
||||
val id: String,
|
||||
@@ -54,25 +51,12 @@ data class GameEntry(
|
||||
val title: String,
|
||||
val art: Artwork,
|
||||
val role: String? = null,
|
||||
val icon: String? = null,
|
||||
) {
|
||||
val isCustom: Boolean get() = store == "custom"
|
||||
|
||||
/** Whether this entry opens a launcher rather than a game. */
|
||||
val isLauncher: Boolean get() = role == "launcher"
|
||||
|
||||
/**
|
||||
* The brand-icon token, re-validated rather than taken on trust.
|
||||
*
|
||||
* The host checks the shape on the way in, so this only fires for a host older than that
|
||||
* check or one that isn't ours. It costs a scan of a short string and means no consumer has
|
||||
* to wonder what it is about to look up.
|
||||
*/
|
||||
val iconToken: String? get() = icon?.takeIf { t ->
|
||||
t.isNotEmpty() && t.length <= 32 && t[0] in 'a'..'z' &&
|
||||
t.all { it in 'a'..'z' || it in '0'..'9' || it == '-' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Display name for the store badge — the same table the other clients use
|
||||
* (`pf-console-ui::library::store_label`). Before this the UI said "Steam" for every non-custom
|
||||
@@ -167,7 +151,6 @@ object LibraryClient {
|
||||
hero = resolveArt(str(art, "hero"), base),
|
||||
),
|
||||
role = str(o, "role"),
|
||||
icon = str(o, "icon"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.view.Surface
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins the phone-gyro mirror's device→controller frame remap and its wire-unit constants
|
||||
* ([DeviceGyro]). Pure JVM: [Surface]'s ROTATION_* are compile-time constants and remap is
|
||||
* plain math. The matrix is derived (like the wire scale constants) — if on-glass says an axis
|
||||
* is wrong, fix [DeviceGyro.remap] AND these expectations together.
|
||||
* Run: `./gradlew :kit:testDebugUnitTest`.
|
||||
*/
|
||||
class DeviceGyroTest {
|
||||
/** A distinct value per axis so a swapped or flipped component can't cancel out. */
|
||||
private fun remap(rotation: Int) = DeviceGyro.remap(rotation, 1f, 2f, 3f).toList()
|
||||
|
||||
@Test
|
||||
fun naturalPortraitIsIdentity() = assertEquals(listOf(1f, 2f, 3f), remap(Surface.ROTATION_0))
|
||||
|
||||
@Test
|
||||
fun upsideDownFlipsInPlane() = assertEquals(listOf(-1f, -2f, 3f), remap(Surface.ROTATION_180))
|
||||
|
||||
/** ROTATION_90 = device turned counter-clockwise, top to the player's LEFT:
|
||||
* player-right = device-bottom (−y), player-up = device-right (+x); z never changes. */
|
||||
@Test
|
||||
fun rotation90TopLeft() = assertEquals(listOf(-2f, 1f, 3f), remap(Surface.ROTATION_90))
|
||||
|
||||
/** ROTATION_270 = top to the player's RIGHT: player-right = +y, player-up = −x. */
|
||||
@Test
|
||||
fun rotation270TopRight() = assertEquals(listOf(2f, -1f, 3f), remap(Surface.ROTATION_270))
|
||||
|
||||
/** Every remap stays a proper (right-handed) rotation: x̂ × ŷ = ẑ after mapping. */
|
||||
@Test
|
||||
fun handednessPreserved() {
|
||||
for (r in listOf(
|
||||
Surface.ROTATION_0, Surface.ROTATION_90, Surface.ROTATION_180, Surface.ROTATION_270,
|
||||
)) {
|
||||
val x = DeviceGyro.remap(r, 1f, 0f, 0f)
|
||||
val y = DeviceGyro.remap(r, 0f, 1f, 0f)
|
||||
assertEquals("left-handed remap at rotation $r", 1f, x[0] * y[1] - x[1] * y[0], 0f)
|
||||
}
|
||||
}
|
||||
|
||||
/** The wire contract, shared with pf-client-core / the Swift client and now with every other
|
||||
* Android motion sender ([Gamepad.motionGyroWire]): 20 LSB/°·s means 1 rad/s ⇒ ~1145.9 raw;
|
||||
* 1 g ⇒ 10000 raw. */
|
||||
@Test
|
||||
fun wireUnitConstants() {
|
||||
assertEquals(20f * 180f / Math.PI.toFloat(), Gamepad.MOTION_GYRO_LSB_PER_RAD_S, 0f)
|
||||
assertEquals(1145.9156f, Gamepad.MOTION_GYRO_LSB_PER_RAD_S, 0.001f)
|
||||
assertEquals(10_000, Gamepad.MOTION_ACCEL_LSB_PER_G)
|
||||
}
|
||||
}
|
||||
@@ -151,185 +151,6 @@ class DsDeviceTest {
|
||||
assertFalse(DsDevice.parseState(DsDevice.Model.DUALSHOCK4, ds4Report(), 8, s))
|
||||
}
|
||||
|
||||
// ---- IMU calibration (the pad's own scale factors) ----
|
||||
|
||||
/**
|
||||
* A calibration feature report in the pads' USB layout: report id, three gyro bias words, six
|
||||
* INTERLEAVED gyro plus/minus words, the two speed words, six accel plus/minus words — all
|
||||
* little-endian i16, exactly what [DsDevice.MotionCal.parse] reads and what
|
||||
* `crates/pf-inject/tests/motion_contract.rs` writes from the other end.
|
||||
*/
|
||||
private fun calBlob(
|
||||
id: Int,
|
||||
gyroBias: IntArray,
|
||||
gyroPlus: IntArray,
|
||||
gyroMinus: IntArray,
|
||||
speed: Int,
|
||||
accelPlus: IntArray,
|
||||
accelMinus: IntArray,
|
||||
len: Int = 41,
|
||||
): ByteArray = ByteArray(len).also { b ->
|
||||
fun put(o: Int, v: Int) {
|
||||
b[o] = (v and 0xFF).toByte()
|
||||
b[o + 1] = ((v shr 8) and 0xFF).toByte()
|
||||
}
|
||||
b[0] = id.toByte()
|
||||
for (i in 0 until 3) {
|
||||
put(1 + 2 * i, gyroBias[i])
|
||||
put(7 + 4 * i, gyroPlus[i])
|
||||
put(9 + 4 * i, gyroMinus[i])
|
||||
put(23 + 4 * i, accelPlus[i])
|
||||
put(25 + 4 * i, accelMinus[i])
|
||||
}
|
||||
put(19, speed)
|
||||
put(21, speed)
|
||||
}
|
||||
|
||||
/**
|
||||
* A realistic DualSense blob: gyro measured at 512 °/s each way over ±8192 counts about a
|
||||
* small factory bias — 16384/1024 = 16 raw LSB per °/s, the ≈±2000 °/s full scale a real pad
|
||||
* has — and accel spanning about ±8192 counts (`DS_ACC_RES_PER_G`) about a per-axis zero point
|
||||
* that is NOT zero. Both are the shape a nominal constant cannot express.
|
||||
*/
|
||||
private fun realisticCal(): DsDevice.MotionCal = DsDevice.MotionCal.parse(
|
||||
calBlob(
|
||||
id = 0x05,
|
||||
gyroBias = intArrayOf(10, -6, 3),
|
||||
gyroPlus = intArrayOf(10 + 8192, -6 + 8192, 3 + 8192),
|
||||
gyroMinus = intArrayOf(10 - 8192, -6 - 8192, 3 - 8192),
|
||||
speed = 512, // speed_plus + speed_minus = 1024
|
||||
accelPlus = intArrayOf(8300, 8200, 8000),
|
||||
accelMinus = intArrayOf(-8100, -8192, -8384),
|
||||
),
|
||||
0x05,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun calibrationRescalesRawCountsOntoTheWireUnits() {
|
||||
val cal = realisticCal()
|
||||
// 100 °/s at this pad's 16 LSB per °/s = 1600 raw → the wire's 20 LSB per °/s = 2000.
|
||||
for (axis in 0 until 3) {
|
||||
assertEquals(2000, cal.gyroToWire(axis, 1600))
|
||||
assertEquals(-2000, cal.gyroToWire(axis, -1600))
|
||||
assertEquals(0, cal.gyroToWire(axis, 0))
|
||||
}
|
||||
// 1 g = the axis's zero point plus half its declared 2 g range → 10000 wire units.
|
||||
val zero = intArrayOf(100, 4, -192) // plus − range/2, per axis
|
||||
val oneG = intArrayOf(8300, 8200, 8000) // = accelPlus
|
||||
for (axis in 0 until 3) {
|
||||
assertEquals(10000, cal.accelToWire(axis, oneG[axis]))
|
||||
assertEquals(0, cal.accelToWire(axis, zero[axis]))
|
||||
assertEquals(-10000, cal.accelToWire(axis, zero[axis] - (oneG[axis] - zero[axis])))
|
||||
}
|
||||
// Both rescales are >1 here, so full-scale raw must clamp rather than wrap the i16.
|
||||
assertEquals(32767, cal.gyroToWire(0, 30000))
|
||||
assertEquals(-32768, cal.gyroToWire(0, -30000))
|
||||
assertEquals(32767, cal.accelToWire(0, 30000))
|
||||
// The capture logs this, and it is the discriminator the owed on-glass check reads: a pad
|
||||
// whose blob was read declares its own resolution, the fallback declares the wire's.
|
||||
assertTrue(cal.toString().startsWith("gyro 16/16/16 LSB/°·s"))
|
||||
assertTrue(DsDevice.MotionCal.NOMINAL.toString().startsWith("gyro 20/20/20 LSB/°·s"))
|
||||
}
|
||||
|
||||
/**
|
||||
* The host's own virtual pads declare `DS_FEATURE_CALIBRATION` (`dualsense_proto.rs`) — a blob
|
||||
* that states the wire's units exactly. Reading it back must therefore be a passthrough: if
|
||||
* this ever stops holding, the client and the host disagree about what a motion sample means.
|
||||
*/
|
||||
@Test
|
||||
fun theHostsOwnBlobIsAPassthrough() {
|
||||
val cal = DsDevice.MotionCal.parse(
|
||||
calBlob(
|
||||
id = 0x05,
|
||||
gyroBias = intArrayOf(0, 0, 0),
|
||||
gyroPlus = intArrayOf(10000, 10000, 10000),
|
||||
gyroMinus = intArrayOf(-10000, -10000, -10000),
|
||||
speed = 500,
|
||||
accelPlus = intArrayOf(10000, 10000, 10000),
|
||||
accelMinus = intArrayOf(-10000, -10000, -10000),
|
||||
),
|
||||
0x05,
|
||||
)
|
||||
for (axis in 0 until 3) {
|
||||
assertEquals(2000, cal.gyroToWire(axis, 2000)) // 100 °/s
|
||||
assertEquals(10000, cal.accelToWire(axis, 10000)) // 1 g
|
||||
assertEquals(-1234, cal.gyroToWire(axis, -1234))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Anything unusable keeps the pre-calibration behaviour — accel on the nominal 8192 LSB/g,
|
||||
* gyro straight through. A pad with no readable calibration is better off slightly mis-scaled
|
||||
* than silent, so nothing here may zero motion.
|
||||
*/
|
||||
@Test
|
||||
fun unusableCalibrationFallsBackInsteadOfZeroing() {
|
||||
val degenerate = calBlob(
|
||||
id = 0x02,
|
||||
gyroBias = intArrayOf(0, 0, 0),
|
||||
gyroPlus = intArrayOf(0, 0, 0),
|
||||
gyroMinus = intArrayOf(0, 0, 0),
|
||||
speed = 0,
|
||||
accelPlus = intArrayOf(0, 0, 0),
|
||||
accelMinus = intArrayOf(0, 0, 0),
|
||||
len = 37,
|
||||
)
|
||||
val cals = listOf(
|
||||
DsDevice.MotionCal.NOMINAL,
|
||||
DsDevice.MotionCal.parse(null, 0x05), // the GET_REPORT failed
|
||||
DsDevice.MotionCal.parse(ByteArray(8) { if (it == 0) 0x05 else 0 }, 0x05), // short reply
|
||||
DsDevice.MotionCal.parse(degenerate, 0x02), // a clone pad's zeroes
|
||||
DsDevice.MotionCal.parse(degenerate, 0x05), // someone else's report id
|
||||
)
|
||||
for (cal in cals) {
|
||||
for (axis in 0 until 3) {
|
||||
assertEquals(1234, cal.gyroToWire(axis, 1234)) // passthrough
|
||||
assertEquals(10000, cal.accelToWire(axis, 8192)) // 8192 raw LSB = 1 g
|
||||
assertEquals(-10000, cal.accelToWire(axis, -8192))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The parse applies the calibration at the motion offsets, per model, and defaults to nominal. */
|
||||
@Test
|
||||
fun parseStateAppliesTheCalibration() {
|
||||
val cal = realisticCal()
|
||||
// DS5: gyro at [16..22), accel at [22..28). Pitch = 1600 raw (100 °/s), accel z = 8000 (1 g).
|
||||
val ds5 = ds5Report {
|
||||
it[16] = 0x40; it[17] = 0x06 // 1600
|
||||
it[26] = 0x40; it[27] = 0x1F // 8000
|
||||
}
|
||||
val five = DsDevice.State()
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5, 64, five, cal))
|
||||
assertEquals(2000, five.gyro[0])
|
||||
assertEquals(10000, five.accel[2])
|
||||
// DS4: gyro at [13..19), accel at [19..25). Same numbers, same answers.
|
||||
val ds4 = ds4Report {
|
||||
it[13] = 0x40; it[14] = 0x06
|
||||
it[23] = 0x40; it[24] = 0x1F
|
||||
}
|
||||
val four = DsDevice.State()
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSHOCK4, ds4, 64, four, cal))
|
||||
assertEquals(2000, four.gyro[0])
|
||||
assertEquals(10000, four.accel[2])
|
||||
// No calibration argument = the nominal fallback: gyro through, accel ×10000/8192.
|
||||
val nominal = DsDevice.State()
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5, 64, nominal))
|
||||
assertEquals(1600, nominal.gyro[0])
|
||||
assertEquals(8000L * 10000 / 8192, nominal.accel[2].toLong())
|
||||
}
|
||||
|
||||
/** Each model asks for the feature report its firmware actually serves over USB. */
|
||||
@Test
|
||||
fun calibrationReportIdentityPerModel() {
|
||||
assertEquals(0x05, DsDevice.Model.DUALSENSE.calReportId)
|
||||
assertEquals(41, DsDevice.Model.DUALSENSE.calReportLen)
|
||||
assertEquals(0x05, DsDevice.Model.DUALSENSE_EDGE.calReportId)
|
||||
assertEquals(41, DsDevice.Model.DUALSENSE_EDGE.calReportLen)
|
||||
assertEquals(0x02, DsDevice.Model.DUALSHOCK4.calReportId)
|
||||
assertEquals(37, DsDevice.Model.DUALSHOCK4.calReportLen)
|
||||
}
|
||||
|
||||
// ---- output builders (offsets = the host parser's: `parse_ds_output` / `parse_ds4_output`) ----
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The router's instant button chords — mic mute (Select + Y) and the stats-tier cycle
|
||||
* (Select + X) — pinned at the one place a JVM test can reach them: [GamepadRouter.completesChord],
|
||||
* the shared edge rule both fire on. A [GamepadRouter] itself needs an InputManager, a main Looper
|
||||
* and live InputDevices behind it, so driving real KeyEvents through it is not a unit test; the
|
||||
* rule below is the whole of what those two `if`s decide.
|
||||
*
|
||||
* The Apple client pins the same chords from its own side (`GamepadStatsChordTests`), and the two
|
||||
* suites exist for the same reason: a chord that stops completing fails INVISIBLY — the buttons
|
||||
* still reach the game, nothing logs, and the couch simply finds that a shortcut it was told about
|
||||
* does nothing. On a TV the stats chord is the only route to the overlay at all.
|
||||
*/
|
||||
class GamepadChordTest {
|
||||
|
||||
/** The two chords `slotButton` tests on every press, in the order it tests them. */
|
||||
private val instantChords = listOf(GamepadRouter.MIC_CHORD, GamepadRouter.STATS_CHORD)
|
||||
|
||||
/**
|
||||
* One pad's held-button set, driven exactly the way `slotButton` drives a slot's: the chord
|
||||
* test reads the state from BEFORE the press, then the bit joins `held`. [press] returns the
|
||||
* chords that completed on it — an empty list means the press was silent.
|
||||
*/
|
||||
private inner class Pad {
|
||||
var held = 0
|
||||
private set
|
||||
|
||||
fun press(bit: Int): List<Int> {
|
||||
val wasHeld = held
|
||||
held = held or bit
|
||||
return instantChords.filter { GamepadRouter.completesChord(wasHeld, bit, it) }
|
||||
}
|
||||
|
||||
/** An auto-repeat DOWN: Android re-delivers a held button, so `wasHeld` already has it. */
|
||||
fun repeat(bit: Int): List<Int> = press(bit)
|
||||
|
||||
fun release(bit: Int) {
|
||||
held = held and bit.inv()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select + X, the same pair as the Apple client's `GamepadCapture.statsChord`
|
||||
* (`GamepadWire.back | GamepadWire.x`). A per-platform shortcut is worse than none, so the
|
||||
* literal bits are spelled out here rather than derived from the constant under test.
|
||||
*/
|
||||
@Test
|
||||
fun `the stats chord is Select plus X`() {
|
||||
assertEquals(0x0020 or 0x4000, GamepadRouter.STATS_CHORD)
|
||||
assertEquals(Gamepad.BTN_BACK or Gamepad.BTN_X, GamepadRouter.STATS_CHORD)
|
||||
assertEquals(Gamepad.BTN_BACK or Gamepad.BTN_Y, GamepadRouter.MIC_CHORD)
|
||||
}
|
||||
|
||||
/**
|
||||
* The three chords must not be reachable through one another: pressing toward the exit chord
|
||||
* may not cycle the overlay or mute the mic on the way, and neither instant chord may arm a
|
||||
* disconnect. Select is the one button they share by design — everything else is disjoint, and
|
||||
* no chord is a subset of another (a subset would complete whenever its superset did).
|
||||
*/
|
||||
@Test
|
||||
fun `the chords meet only on Select`() {
|
||||
val chords = mapOf(
|
||||
"exit" to GamepadRouter.EXIT_CHORD,
|
||||
"mic" to GamepadRouter.MIC_CHORD,
|
||||
"stats" to GamepadRouter.STATS_CHORD,
|
||||
)
|
||||
for ((aName, a) in chords) {
|
||||
for ((bName, b) in chords) {
|
||||
if (aName == bName) continue
|
||||
assertEquals("$aName and $bName share a button other than Select", Gamepad.BTN_BACK, a and b)
|
||||
assertNotEquals("$aName is a subset of $bName", a and b, a)
|
||||
assertNotEquals("$bName is a subset of $aName", a and b, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One cycle per chord, not one per press: the completing button fires it, an auto-repeat of
|
||||
* that same button does not, and a third button pressed on top of the held chord finds the mask
|
||||
* already complete.
|
||||
*/
|
||||
@Test
|
||||
fun `the chord fires once, on the button that completes it`() {
|
||||
val pad = Pad()
|
||||
assertEquals("Select alone is not a chord", emptyList<Int>(), pad.press(Gamepad.BTN_BACK))
|
||||
assertEquals(listOf(GamepadRouter.STATS_CHORD), pad.press(Gamepad.BTN_X))
|
||||
assertEquals("auto-repeat re-fired the chord", emptyList<Int>(), pad.repeat(Gamepad.BTN_X))
|
||||
assertEquals("a press on top re-fired the chord", emptyList<Int>(), pad.press(Gamepad.BTN_A))
|
||||
assertEquals(emptyList<Int>(), pad.press(Gamepad.BTN_B))
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifting either member re-arms the chord — pressing it again is a fresh completion. Both
|
||||
* directions matter: a couch user cycling tiers taps X with Select still down, and one who
|
||||
* lifted Select instead taps Select again with X still down.
|
||||
*/
|
||||
@Test
|
||||
fun `either member re-arms the chord when released`() {
|
||||
val pad = Pad()
|
||||
pad.press(Gamepad.BTN_BACK)
|
||||
assertEquals(listOf(GamepadRouter.STATS_CHORD), pad.press(Gamepad.BTN_X))
|
||||
pad.release(Gamepad.BTN_X)
|
||||
assertEquals(listOf(GamepadRouter.STATS_CHORD), pad.press(Gamepad.BTN_X))
|
||||
pad.release(Gamepad.BTN_BACK)
|
||||
assertEquals(listOf(GamepadRouter.STATS_CHORD), pad.press(Gamepad.BTN_BACK))
|
||||
}
|
||||
|
||||
/** A partial mask fires nothing — either member alone, or with a non-member alongside it. */
|
||||
@Test
|
||||
fun `a partial chord never fires`() {
|
||||
for (opening in listOf(Gamepad.BTN_BACK, Gamepad.BTN_X, Gamepad.BTN_Y)) {
|
||||
val pad = Pad()
|
||||
assertEquals(emptyList<Int>(), pad.press(opening))
|
||||
for (other in listOf(Gamepad.BTN_A, Gamepad.BTN_B, Gamepad.BTN_LB, Gamepad.BTN_DPAD_UP)) {
|
||||
assertEquals(emptyList<Int>(), pad.press(other))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walking into the exit chord (Select + Start + L1 + R1, in any order) must pass through
|
||||
* neither instant chord: the disconnect hold is the one gesture where a stray mute or a
|
||||
* changed overlay would land while the user is looking at the "hold to quit" hint.
|
||||
*/
|
||||
@Test
|
||||
fun `reaching the exit chord fires nothing on the way`() {
|
||||
val exit = listOf(Gamepad.BTN_BACK, Gamepad.BTN_START, Gamepad.BTN_LB, Gamepad.BTN_RB)
|
||||
for (order in exit.permutations()) {
|
||||
val pad = Pad()
|
||||
for (bit in order) {
|
||||
assertEquals("$order fired a chord at $bit", emptyList<Int>(), pad.press(bit))
|
||||
}
|
||||
assertEquals(GamepadRouter.EXIT_CHORD, pad.held)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* X and Y held, then Select: ONE press completes BOTH chords. That is the honest reading of
|
||||
* "the button that completes the mask", it is what the Apple client does too, and the
|
||||
* alternative — first match wins — would make the same press mean different things depending
|
||||
* on which chord the router happened to test first. Pinned so the behaviour is a decision
|
||||
* rather than a surprise; both outcomes are visible and reversible on screen.
|
||||
*/
|
||||
@Test
|
||||
fun `a shared Select can complete both chords at once`() {
|
||||
val pad = Pad()
|
||||
pad.press(Gamepad.BTN_X)
|
||||
pad.press(Gamepad.BTN_Y)
|
||||
assertEquals(instantChords, pad.press(Gamepad.BTN_BACK))
|
||||
}
|
||||
|
||||
/** The chord bits are the wire's, so they must stay inside the 32-bit button mask. */
|
||||
@Test
|
||||
fun `chord masks are wire button bits`() {
|
||||
for (chord in instantChords + GamepadRouter.EXIT_CHORD) {
|
||||
assertTrue("chord $chord has no bits", chord != 0)
|
||||
assertEquals("a chord bit is not a known BTN_*", chord, chord and ALL_BUTTONS)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Every button bit `Gamepad` defines — the universe a chord may draw from. */
|
||||
val ALL_BUTTONS = listOf(
|
||||
Gamepad.BTN_DPAD_UP, Gamepad.BTN_DPAD_DOWN, Gamepad.BTN_DPAD_LEFT, Gamepad.BTN_DPAD_RIGHT,
|
||||
Gamepad.BTN_START, Gamepad.BTN_BACK, Gamepad.BTN_LS_CLICK, Gamepad.BTN_RS_CLICK,
|
||||
Gamepad.BTN_LB, Gamepad.BTN_RB, Gamepad.BTN_GUIDE,
|
||||
Gamepad.BTN_A, Gamepad.BTN_B, Gamepad.BTN_X, Gamepad.BTN_Y,
|
||||
Gamepad.BTN_PADDLE1, Gamepad.BTN_PADDLE2, Gamepad.BTN_PADDLE3, Gamepad.BTN_PADDLE4,
|
||||
Gamepad.BTN_TOUCHPAD, Gamepad.BTN_MISC1,
|
||||
).fold(0) { acc, bit -> acc or bit }
|
||||
|
||||
/** Every ordering of a chord's buttons — presses arrive in whatever order the hands do. */
|
||||
fun <T> List<T>.permutations(): List<List<T>> =
|
||||
if (size <= 1) listOf(this)
|
||||
else flatMap { head -> (this - head).permutations().map { listOf(head) + it } }
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The claim/read hand-off that lets [DsCapture] read a pad's motion calibration off the claiming
|
||||
* thread. Two things are pinned here, and both are about the gap before the read comes back.
|
||||
*
|
||||
* What the gap DOES: the pad streams, scaled by the nominal calibration — the behaviour that
|
||||
* shipped before the read existed. What it must NOT do: inherit the previous pad's factory numbers
|
||||
* (calibration is per unit), or accept a read that outlived its claim, which an unplug, a stop, or
|
||||
* a re-claim can all cause.
|
||||
*/
|
||||
class MotionCalHandoffTest {
|
||||
/**
|
||||
* A calibration whose gyro reads [rawLsbPerDegS] raw LSB per °/s and whose accel sits at
|
||||
* [accelZero] raw counts at 0 g, so two of them are told apart by what they DO — identity
|
||||
* alone would let a regression that returns the wrong instance still look right.
|
||||
*/
|
||||
private fun cal(rawLsbPerDegS: Int, accelZero: Int = 0): DsDevice.MotionCal {
|
||||
val speed = 500 // speed_plus = speed_minus, so speed_2x = 1000
|
||||
val span = rawLsbPerDegS * 1000 // |plus − bias| + |minus − bias| = span
|
||||
val blob = ByteArray(41)
|
||||
fun put(o: Int, v: Int) {
|
||||
blob[o] = (v and 0xFF).toByte()
|
||||
blob[o + 1] = ((v shr 8) and 0xFF).toByte()
|
||||
}
|
||||
blob[0] = 0x05
|
||||
for (i in 0 until 3) {
|
||||
put(7 + 4 * i, span / 2) // gyro plus
|
||||
put(9 + 4 * i, -span / 2) // gyro minus
|
||||
put(23 + 4 * i, accelZero + 8192) // accel plus / minus: 8192 raw LSB per g
|
||||
put(25 + 4 * i, accelZero - 8192)
|
||||
}
|
||||
put(19, speed)
|
||||
put(21, speed)
|
||||
return DsDevice.MotionCal.parse(blob, 0x05)
|
||||
}
|
||||
|
||||
/** One DS5 input report: cross held, sticks centred, gyro pitch 1600 raw, accel z 8000 raw. */
|
||||
private fun report(): ByteArray = ByteArray(64).also {
|
||||
it[0] = 0x01
|
||||
it[1] = 0x80.toByte(); it[2] = 0x80.toByte(); it[3] = 0x80.toByte(); it[4] = 0x80.toByte()
|
||||
it[8] = (0x08 or 0x20).toByte() // hat neutral | cross
|
||||
it[16] = 0x40; it[17] = 0x06 // gyro pitch = 1600
|
||||
it[26] = 0x40; it[27] = 0x1F // accel z = 8000
|
||||
it[33] = 0x80.toByte(); it[37] = 0x80.toByte() // no touch contacts
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a claim scales nominally until its read lands`() {
|
||||
val h = MotionCalHandoff()
|
||||
assertSame(DsDevice.MotionCal.NOMINAL, h.effective)
|
||||
val claim = h.begin()
|
||||
assertSame("the read is in flight — scale nominally, do not wait", DsDevice.MotionCal.NOMINAL, h.effective)
|
||||
val read = cal(16)
|
||||
assertTrue(h.publish(claim, read))
|
||||
assertSame(read, h.effective)
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole point of scaling nominally instead of holding reports back: a pad answers its
|
||||
* buttons from the first report, and only its motion changes when the calibration arrives.
|
||||
*/
|
||||
@Test
|
||||
fun `a report in the gap is forwarded, nominally scaled, and rescales once the read lands`() {
|
||||
val h = MotionCalHandoff()
|
||||
val claim = h.begin()
|
||||
val r = report()
|
||||
|
||||
val gap = DsDevice.State()
|
||||
assertTrue(
|
||||
"a report must still be parsed while the read is in flight",
|
||||
DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, gap, h.effective),
|
||||
)
|
||||
assertEquals("buttons reach the wire immediately", Gamepad.BTN_A, gap.buttons)
|
||||
assertEquals("and so do sticks", 128, gap.lsX)
|
||||
assertEquals("nominal gyro is the raw count", 1600, gap.gyro[0])
|
||||
assertEquals("nominal accel is ×10000/8192", 8000L * 10000 / 8192, gap.accel[2].toLong())
|
||||
|
||||
assertTrue(h.publish(claim, cal(16, accelZero = 100)))
|
||||
val live = DsDevice.State()
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, live, h.effective))
|
||||
assertEquals("buttons do not depend on the calibration", gap.buttons, live.buttons)
|
||||
assertEquals("1600 raw at 16 LSB/°·s = 100 °/s = 2000 wire", 2000, live.gyro[0])
|
||||
assertNotEquals("the same raw report must convert differently now", gap.gyro[0], live.gyro[0])
|
||||
assertNotEquals(gap.accel[2], live.accel[2])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a read that outlived its claim publishes nothing`() {
|
||||
val h = MotionCalHandoff()
|
||||
val claim = h.begin()
|
||||
h.end() // unplug, or DsCapture.stop, while the read was in flight
|
||||
assertFalse("a straggler may not publish into a dead claim", h.publish(claim, cal(16)))
|
||||
assertSame(DsDevice.MotionCal.NOMINAL, h.effective)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a new claim scales nominally rather than inheriting the previous pad's calibration`() {
|
||||
val h = MotionCalHandoff()
|
||||
val first = h.begin()
|
||||
val hot = cal(4, accelZero = 400) // a pad reading 4 raw LSB per °/s, well off nominal
|
||||
assertTrue(h.publish(first, hot))
|
||||
assertSame(hot, h.effective)
|
||||
|
||||
// Re-claimed without an end() in between — the pad was swapped while a read was in flight.
|
||||
val second = h.begin()
|
||||
assertNotEquals(first, second)
|
||||
assertSame(
|
||||
"the next pad starts on the nominal scaling, NOT the last pad's factory numbers",
|
||||
DsDevice.MotionCal.NOMINAL,
|
||||
h.effective,
|
||||
)
|
||||
assertFalse("the first pad's read may not scale the second pad", h.publish(first, hot))
|
||||
assertSame(DsDevice.MotionCal.NOMINAL, h.effective)
|
||||
|
||||
// And that fallback is a real difference, not two names for the same numbers: the inherited
|
||||
// calibration would have turned this pad's motion into something else entirely.
|
||||
val r = report()
|
||||
val nominal = DsDevice.State()
|
||||
val inherited = DsDevice.State()
|
||||
DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, nominal, h.effective)
|
||||
DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, inherited, hot)
|
||||
assertNotEquals(inherited.gyro[0], nominal.gyro[0])
|
||||
assertNotEquals(inherited.accel[2], nominal.accel[2])
|
||||
|
||||
val slow = cal(32)
|
||||
assertTrue(h.publish(second, slow))
|
||||
assertSame(slow, h.effective)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ending a claim twice still refuses every outstanding token`() {
|
||||
val h = MotionCalHandoff()
|
||||
val claim = h.begin()
|
||||
h.end() // DsCapture.stop
|
||||
h.end() // …and the unplug that followed it
|
||||
assertFalse(h.publish(claim, cal(16)))
|
||||
assertSame(DsDevice.MotionCal.NOMINAL, h.effective)
|
||||
val next = h.begin()
|
||||
assertNotEquals(claim, next)
|
||||
val read = cal(16)
|
||||
assertTrue(h.publish(next, read))
|
||||
assertSame(read, h.effective)
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins the unit scaling and the axis mapping of the controller-sensor path ([PadSensors]) and the
|
||||
* shared converters it goes through ([Gamepad.motionGyroWire] / [Gamepad.motionAccelWire]). Pure
|
||||
* JVM — the two `*ToWire` functions take plain float arrays and touch no Android class.
|
||||
*
|
||||
* The scale is MEASURED FACT (`punktfunk_core::input::gamepad`: 20 LSB/°·s, 10000 LSB/g) and must
|
||||
* not drift. The axis mapping is straight through and NOT yet verified against hardware — see
|
||||
* [PadSensors.gyroToWire] for the measurement that would settle it. [straightThroughFrame] exists
|
||||
* to make a future remap a deliberate, visible edit rather than a quiet one.
|
||||
* Run: `./gradlew :kit:testDebugUnitTest`.
|
||||
*/
|
||||
class PadSensorsTest {
|
||||
private fun gyro(x: Float, y: Float, z: Float) =
|
||||
IntArray(3).also { PadSensors.gyroToWire(floatArrayOf(x, y, z), it) }
|
||||
|
||||
private fun accel(x: Float, y: Float, z: Float) =
|
||||
IntArray(3).also { PadSensors.accelToWire(floatArrayOf(x, y, z), it) }
|
||||
|
||||
/** 20 LSB/°·s from Android's rad/s: π rad/s is exactly 180 °/s, so exactly 3600 raw. */
|
||||
@Test
|
||||
fun gyroScaleFromRadiansPerSecond() {
|
||||
assertEquals(3600, gyro(Math.PI.toFloat(), 0f, 0f)[0])
|
||||
assertEquals(-3600, gyro(-Math.PI.toFloat(), 0f, 0f)[0])
|
||||
assertEquals(1146, gyro(1f, 0f, 0f)[0]) // 1 rad/s ⇒ 1145.9156, rounded
|
||||
assertEquals(0, gyro(0f, 0f, 0f)[0])
|
||||
}
|
||||
|
||||
/** 10000 LSB/g from Android's m/s²: standard gravity is exactly 1 g. Android reports specific
|
||||
* force, so a pad at rest reads +1 g on the axis pointing up — no sign flip anywhere. */
|
||||
@Test
|
||||
fun accelScaleFromMetresPerSecondSquared() {
|
||||
assertEquals(10_000, accel(0f, Gamepad.GRAVITY, 0f)[1])
|
||||
assertEquals(-10_000, accel(0f, -Gamepad.GRAVITY, 0f)[1])
|
||||
assertEquals(0, accel(0f, 0f, 0f)[1])
|
||||
}
|
||||
|
||||
/** A controller lying flat and still lands exactly on the host's neutral for a virtual
|
||||
* DualSense — 1 g on wire slot 1 (`punktfunk-core` `MOTION_NEUTRAL_ACCEL = [0, 10000, 0]`),
|
||||
* not the [0,0,0] that means free fall. */
|
||||
@Test
|
||||
fun restingPadIsTheHostNeutral() {
|
||||
assertArrayEquals(intArrayOf(0, 10_000, 0), accel(0f, Gamepad.GRAVITY, 0f))
|
||||
}
|
||||
|
||||
/**
|
||||
* The frame: component i of the sensor sample becomes component i of the wire triple, for both
|
||||
* planes, with no permutation and no negation. UNVERIFIED against hardware — if a Bluetooth
|
||||
* DualSense says otherwise, the remap goes into [PadSensors.gyroToWire] and this test changes
|
||||
* with it. Distinct magnitudes per axis so a swap or a flip cannot cancel out.
|
||||
*/
|
||||
@Test
|
||||
fun straightThroughFrame() {
|
||||
assertArrayEquals(intArrayOf(1146, 2292, 3438), gyro(1f, 2f, 3f))
|
||||
assertArrayEquals(
|
||||
intArrayOf(10_000, 20_000, -30_000),
|
||||
accel(Gamepad.GRAVITY, 2f * Gamepad.GRAVITY, -3f * Gamepad.GRAVITY),
|
||||
)
|
||||
}
|
||||
|
||||
/** Both planes clamp to signed 16 bits rather than wrapping — a flick past 1638 °/s or a knock
|
||||
* past 3.27 g saturates, where a wrap would send a full-speed rotation the other way. */
|
||||
@Test
|
||||
fun clampsToSigned16() {
|
||||
assertArrayEquals(intArrayOf(32767, -32768, 32767), gyro(100f, -100f, 1e9f))
|
||||
assertArrayEquals(intArrayOf(32767, -32768, 32767), accel(1000f, -1000f, 1e9f))
|
||||
}
|
||||
|
||||
/** Rounds to nearest rather than truncating: a truncating converter loses up to a whole LSB
|
||||
* off every sample, always toward zero, and a gyro whose every sample is biased the same way
|
||||
* is a gyro that drifts. */
|
||||
@Test
|
||||
fun roundsToNearestNotTowardZero() {
|
||||
assertEquals(1, gyro(0.0006f, 0f, 0f)[0]) // 0.688 raw — truncation would say 0
|
||||
assertEquals(-1, gyro(-0.0006f, 0f, 0f)[0])
|
||||
assertEquals(1, accel(0.0007f, 0f, 0f)[0]) // 0.714 raw
|
||||
}
|
||||
|
||||
/** A sensor that hands back fewer than three components (or none — the framework reuses one
|
||||
* array across types) contributes zero rather than throwing on the sensor thread. */
|
||||
@Test
|
||||
fun shortSampleIsZeroFilled() {
|
||||
val out = IntArray(3) { 7 }
|
||||
PadSensors.gyroToWire(floatArrayOf(Math.PI.toFloat()), out)
|
||||
assertArrayEquals(intArrayOf(3600, 0, 0), out)
|
||||
}
|
||||
}
|
||||
@@ -20,16 +20,6 @@
|
||||
//! (2) is now the SHARED `punktfunk_core::audio::JitterPolicy` at `JitterTuning::AAUDIO`, which also
|
||||
//! fixed what this ring was missing: it had a hard cap but nothing that walked the depth back down,
|
||||
//! so drift and arrival bursts raised latency permanently and Android settled on its ceiling.
|
||||
//!
|
||||
//! It is also **A/V synchronised** (`design/audio-latency-overhaul.md`): the decode thread reads the
|
||||
//! host capture `pts_ns` every `AudioPacket` has always carried, compares where this frame will
|
||||
//! actually play against where the picture it belongs with reached glass
|
||||
//! (`decode::DisplayTracker` publishes that), and asks the ring for a depth that closes the gap.
|
||||
//! Only ASKS — `JitterPolicy` clamps the request between its own underrun-driven floor and the hard
|
||||
//! cap, so continuity outranks sync and a link whose jitter genuinely needs more buffer than the
|
||||
//! picture is away keeps its buffer, with the residual reported on the HUD instead of taken out of
|
||||
//! the listener's stream. With no video reference (below API 33 there are no render callbacks, so
|
||||
//! nothing confirms a present) the target stays `None` and the ring behaves exactly as it did.
|
||||
|
||||
use ndk::audio::{
|
||||
AudioCallbackResult, AudioContentType, AudioDirection, AudioFormat, AudioPerformanceMode,
|
||||
@@ -44,14 +34,6 @@ use std::sync::mpsc::{sync_channel, Receiver, SyncSender, TrySendError};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// What one playback open attempt yields: the stream, plus both halves of the PCM hand-off — the
|
||||
/// sender the decode thread fills and the receiver that returns drained buffers for refill.
|
||||
///
|
||||
/// Named rather than written inline because the closure's return type trips
|
||||
/// `clippy::type_complexity`, which the Android target is now linted for (`:kit:cargoNdkClippy`)
|
||||
/// after years of nothing checking it.
|
||||
type OpenedPlayback = ndk::audio::Result<(AudioStream, SyncSender<Vec<f32>>, Receiver<Vec<f32>>)>;
|
||||
|
||||
const SAMPLE_RATE: i32 = 48_000;
|
||||
/// Decoded-chunk hand-off depth: 64 × 5 ms = 320 ms slack (matches the core's AUDIO_QUEUE).
|
||||
const RING_CHUNKS: usize = 64;
|
||||
@@ -112,45 +94,15 @@ impl AudioDec {
|
||||
|
||||
/// Diagnostics — written by the decode thread + the realtime callback, logged periodically. The
|
||||
/// audio analogue of the video `fed`/`rendered` counters (we can't "screenshot" sound).
|
||||
///
|
||||
/// The ring's DEPTH is not here: the A/V sync loop needs the same number in the same units, so it
|
||||
/// is published once through [`punktfunk_core::audio::AudioSyncCell`] and read from there by the
|
||||
/// log line below. One publisher, one reading — a second copy is a second thing to go stale.
|
||||
#[derive(Default)]
|
||||
struct Counters {
|
||||
opus_decoded: AtomicU64, // Opus packets decoded OK (~200/s at 5 ms frames)
|
||||
pcm_written: AtomicU64, // PCM frames copied out to AAudio (device clock is pulling)
|
||||
underruns: AtomicU64, // callbacks that emitted silence (ring not primed / drained)
|
||||
ring_depth: AtomicU64, // ring sample count at the last callback
|
||||
target_ms: AtomicU64, // the policy's LIVE target depth (it grows on this device's underruns)
|
||||
}
|
||||
|
||||
/// Whether the A/V sync loop runs this session. `false` leaves `JitterPolicy`'s sync target at
|
||||
/// `None`, which reproduces the pre-overhaul ring behaviour exactly — the point of the hatch.
|
||||
///
|
||||
/// Two levers because Android has neither of the other clients' launch surfaces. `PUNKTFUNK_NO_AV_SYNC`
|
||||
/// keeps the contract the desktop clients document (and works when the client is driven from a
|
||||
/// shell), but an app started from the launcher inherits no such environment, so the one a field
|
||||
/// tester can actually reach is the sysprop — `adb shell setprop debug.punktfunk.no_av_sync 1`,
|
||||
/// no rebuild, exactly like `debug.punktfunk.presenter`. A loop that steers PLAYBACK has to be
|
||||
/// bisectable on the device that reports the regression, not only on the bench.
|
||||
fn av_sync_enabled() -> bool {
|
||||
if matches!(
|
||||
std::env::var("PUNKTFUNK_NO_AV_SYNC").as_deref(),
|
||||
Ok("1") | Ok("true")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
let mut buf = [0u8; 92]; // PROP_VALUE_MAX
|
||||
// SAFETY: __system_property_get with a valid name + PROP_VALUE_MAX buffer is always safe.
|
||||
let n = unsafe {
|
||||
libc::__system_property_get(
|
||||
c"debug.punktfunk.no_av_sync".as_ptr(),
|
||||
buf.as_mut_ptr().cast(),
|
||||
)
|
||||
};
|
||||
!(n > 0 && matches!(&buf[..n as usize], b"1" | b"true"))
|
||||
}
|
||||
|
||||
/// Owned by [`crate::session::SessionHandle`]: the live AAudio stream + the decode thread.
|
||||
pub struct AudioPlayback {
|
||||
_stream: AudioStream, // dropping it stops + closes the AAudio stream
|
||||
@@ -175,15 +127,15 @@ impl AudioPlayback {
|
||||
// Worst transient the ring can hold before the policy trims it.
|
||||
let hard_cap_max = tuning.hard_cap_ms as usize * ms;
|
||||
let counters = Arc::new(Counters::default());
|
||||
// The A/V sync hand-off: the realtime callback owns the ring (so it publishes the depth and
|
||||
// consumes the target), the decode thread owns the timestamps (so it computes the target).
|
||||
// Two atomics, because the callback must not block on the thread that decodes Opus.
|
||||
let sync: Arc<punktfunk_core::audio::AudioSyncCell> = Arc::default();
|
||||
|
||||
// One open attempt at a given sharing mode. Everything the realtime callback captures
|
||||
// (channels, ring, prime state) is rebuilt per attempt — `open_stream` consumes the builder
|
||||
// AND the callback, so nothing survives a failed try to reuse.
|
||||
let try_open = |sharing: AudioSharingMode| -> OpenedPlayback {
|
||||
let try_open = |sharing: AudioSharingMode| -> ndk::audio::Result<(
|
||||
AudioStream,
|
||||
SyncSender<Vec<f32>>,
|
||||
Receiver<Vec<f32>>,
|
||||
)> {
|
||||
let (tx, rx) = sync_channel::<Vec<f32>>(RING_CHUNKS);
|
||||
// Recycle free-list: drained PCM buffers go BACK to the decode thread to be refilled, so
|
||||
// the realtime callback never frees heap (Android's Scudo allocator has unbounded free()
|
||||
@@ -194,7 +146,6 @@ impl AudioPlayback {
|
||||
// Realtime consumer state, owned by the callback (FnMut) — no lock: AAudio calls it from
|
||||
// a single high-priority thread, and the decode thread only touches `tx`/`free_rx`.
|
||||
let cb_counters = counters.clone();
|
||||
let cb_sync = sync.clone();
|
||||
// Pre-reserve the ring so `extend` never reallocates on the realtime thread. Worst
|
||||
// transient before the trim below = the hard cap plus one full channel of 5 ms (480-f32)
|
||||
// frames — the punktfunk protocol always sends 5 ms Opus frames (host `audio_thread`); a
|
||||
@@ -220,13 +171,6 @@ impl AudioPlayback {
|
||||
ring.extend(chunk.drain(..));
|
||||
let _ = free_tx.try_send(chunk);
|
||||
}
|
||||
// A/V sync: take whatever depth the decode thread's sync loop last asked for, and
|
||||
// publish where the ring actually is so it can measure the result. The policy
|
||||
// clamps the request between its own underrun floor and the hard cap — continuity
|
||||
// outranks sync, always (see `JitterPolicy::set_sync_target`). Read AFTER the
|
||||
// drain, so the depth is everything a frame queued right now must wait behind.
|
||||
policy.set_sync_target(cb_sync.target());
|
||||
cb_sync.publish_depth(ring.len());
|
||||
// Jitter buffer: the shared policy decides prime/silence, trims a burst, and —
|
||||
// new here — sheds ONE crossfaded 5 ms frame when the depth average has sat above
|
||||
// target long enough to be drift rather than jitter. Without that shed this ring
|
||||
@@ -257,6 +201,9 @@ impl AudioPlayback {
|
||||
// No-op while un-primed, so a deliberate priming silence is never counted as an
|
||||
// underrun (which would otherwise drive the adaptive floor up for no reason).
|
||||
policy.note_read(ran_short);
|
||||
cb_counters
|
||||
.ring_depth
|
||||
.store(ring.len() as u64, Ordering::Relaxed);
|
||||
cb_counters
|
||||
.target_ms
|
||||
.store(policy.target_ms() as u64, Ordering::Relaxed);
|
||||
@@ -356,7 +303,7 @@ impl AudioPlayback {
|
||||
let sd = shutdown.clone();
|
||||
let join = std::thread::Builder::new()
|
||||
.name("pf-audio".into())
|
||||
.spawn(move || decode_loop(client, tx, free_rx, sd, counters, channels, sync))
|
||||
.spawn(move || decode_loop(client, tx, free_rx, sd, counters, channels))
|
||||
.ok();
|
||||
|
||||
Some(AudioPlayback {
|
||||
@@ -387,7 +334,6 @@ fn decode_loop(
|
||||
shutdown: Arc<AtomicBool>,
|
||||
counters: Arc<Counters>,
|
||||
channels: usize,
|
||||
sync: Arc<punktfunk_core::audio::AudioSyncCell>,
|
||||
) {
|
||||
// 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
|
||||
@@ -408,44 +354,9 @@ fn decode_loop(
|
||||
let mut window_peak = 0f32; // loudest |sample| since the last log — tells a tone from silence
|
||||
let mut gaps = punktfunk_core::audio::AudioGapTracker::new();
|
||||
let mut frame_samples = 0usize; // per-channel samples of the last decoded frame — the PLC unit
|
||||
|
||||
// A/V sync (audio latency overhaul). This thread is the only place holding all three
|
||||
// ingredients at once: the packet's host capture `pts_ns`, the ring depth (via the sync cell)
|
||||
// and the video plane's end-to-end figure. `pts_ns` arrived in every `AudioPacket` and was
|
||||
// dropped on the floor here for the plane's whole existence, which is why audio ran at whatever
|
||||
// depth its jitter ring settled at with nothing ever placing it against the picture.
|
||||
let av_sync_enabled = av_sync_enabled();
|
||||
let mut av = punktfunk_core::audio::AvSync::new(channels as u8);
|
||||
let video_e2e = client.video_e2e_shared();
|
||||
let av_offset_out = client.audio_av_offset_shared();
|
||||
let buffer_ms_out = client.audio_buffer_ms_shared();
|
||||
if !av_sync_enabled {
|
||||
log::info!("audio: A/V sync disabled (PUNKTFUNK_NO_AV_SYNC / debug.punktfunk.no_av_sync)");
|
||||
}
|
||||
'pump: while !shutdown.load(Ordering::Relaxed) {
|
||||
match client.next_audio(Duration::from_millis(5)) {
|
||||
Ok(pkt) => {
|
||||
// Place this frame against the picture it belongs with, BEFORE it is queued:
|
||||
// `buffered_ahead` is everything that must still play first, so the depth read here
|
||||
// is exactly what delays it.
|
||||
let depth = sync.depth();
|
||||
// Published unconditionally — the ring's depth is worth seeing even with sync off,
|
||||
// and it is what makes a "the audio delay is way too high" report triageable at all.
|
||||
buffer_ms_out.store((depth / ms.max(1)) as u32, Ordering::Relaxed);
|
||||
if av_sync_enabled {
|
||||
let ve2e = video_e2e.load(Ordering::Relaxed);
|
||||
av.observe(punktfunk_core::audio::AvSyncObservation {
|
||||
pts_ns: pkt.pts_ns,
|
||||
now_local_ns: punktfunk_core::client::now_realtime_ns(),
|
||||
clock_offset_ns: client.clock_offset_now_ns(),
|
||||
buffered_ahead: depth,
|
||||
// 0 = nothing confirmed on the glass yet (no render callback below API 33,
|
||||
// or the stream has not presented a frame); no reference, no correction.
|
||||
video_e2e_ns: (ve2e > 0).then_some(ve2e),
|
||||
});
|
||||
sync.set_target(av.desired_depth(depth));
|
||||
av_offset_out.store(av.offset_ms() as i64, Ordering::Relaxed);
|
||||
}
|
||||
// Conceal lost packets (a seq gap) with libopus PLC before decoding the one that
|
||||
// arrived: empty input synthesizes `frame_samples` of interpolation per missing
|
||||
// packet — an inaudible fade instead of the click a hard gap makes in the ring.
|
||||
@@ -493,17 +404,12 @@ fn decode_loop(
|
||||
Err(TrySendError::Disconnected(_)) => break,
|
||||
}
|
||||
if count % 600 == 0 {
|
||||
// `av_ms` is the sync loop's smoothed placement error (+ = audio behind
|
||||
// the picture); 0 with sync off, or before it has a video reference.
|
||||
// Logged next to the depth because a deep ring on a jittery link is
|
||||
// correct and only the offset separates that from audio held late.
|
||||
log::info!(
|
||||
"audio: opus={count} pcm_frames={} underruns={} buffer_ms={} target_ms={} av_ms={} peak={window_peak:.3}",
|
||||
"audio: opus={count} pcm_frames={} underruns={} buffer_ms={} target_ms={} peak={window_peak:.3}",
|
||||
counters.pcm_written.load(Ordering::Relaxed),
|
||||
counters.underruns.load(Ordering::Relaxed),
|
||||
(depth / ms.max(1)) as u64,
|
||||
counters.ring_depth.load(Ordering::Relaxed) / ms.max(1) as u64,
|
||||
counters.target_ms.load(Ordering::Relaxed),
|
||||
av.offset_ms(),
|
||||
);
|
||||
window_peak = 0.0;
|
||||
}
|
||||
|
||||
@@ -204,15 +204,7 @@ pub(super) fn run_async(
|
||||
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
|
||||
// reclaimed after the codec is dropped below.
|
||||
let meter = Arc::new(PresentMeter::new());
|
||||
// The tracker also publishes each confirmed present's end-to-end into the shared cell the audio
|
||||
// plane steers its jitter ring by (`design/audio-latency-overhaul.md`) — video is the master,
|
||||
// and this is the only point that knows when a frame actually reached glass.
|
||||
let tracker = DisplayTracker::new(
|
||||
stats.clone(),
|
||||
clock_offset.clone(),
|
||||
client.video_e2e_shared(),
|
||||
meter.clone(),
|
||||
);
|
||||
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone(), meter.clone());
|
||||
let render_cb = install_render_callback(&codec, &tracker);
|
||||
|
||||
// The timeline presenter (see `presenter.rs`): newest-wins / smoothing store, one-in-flight
|
||||
|
||||
@@ -5,7 +5,7 @@ use ndk::media::media_codec::MediaCodec;
|
||||
use ndk::native_window::NativeWindow;
|
||||
use std::collections::VecDeque;
|
||||
use std::ffi::c_void;
|
||||
use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::latency::now_realtime_ns;
|
||||
@@ -35,16 +35,6 @@ pub(super) struct DisplayTracker {
|
||||
/// loaded per callback so mid-stream re-syncs apply. Holding the handle (not the client)
|
||||
/// keeps the leaked render-callback refcount from pinning the whole session alive.
|
||||
clock_offset: Arc<AtomicI64>,
|
||||
/// Where the AUDIO plane reads the video leg it has to land with (ns) — `displayed +
|
||||
/// clock_offset − pts`, published on every confirmed present. Written here, read by
|
||||
/// [`crate::audio`]'s sync loop; the two planes never touch each other directly (the presenter
|
||||
/// must not know about audio, and the audio thread cannot see the glass).
|
||||
///
|
||||
/// Published RAW. The HUD shaves the OS present floor off its shown display / end-to-end
|
||||
/// numbers (`StatsOverlay.osFloorMs` — metrics report what Punktfunk controls), but sound has
|
||||
/// to reach the ear when the light reaches the eye, and a floor-shaved reference would place
|
||||
/// audio a whole latch period early on every device. Presentation policy, not physics.
|
||||
video_e2e: Arc<AtomicU64>,
|
||||
/// Always-on latch/display accumulator for the presenter's 1 Hz `pf-present` line —
|
||||
/// independent of the HUD gate, so a HUD-off A/B stays measurable from logcat.
|
||||
meter: Arc<super::presenter::PresentMeter>,
|
||||
@@ -58,13 +48,11 @@ impl DisplayTracker {
|
||||
pub(super) fn new(
|
||||
stats: Arc<crate::stats::VideoStats>,
|
||||
clock_offset: Arc<AtomicI64>,
|
||||
video_e2e: Arc<AtomicU64>,
|
||||
meter: Arc<super::presenter::PresentMeter>,
|
||||
) -> Arc<DisplayTracker> {
|
||||
Arc::new(DisplayTracker {
|
||||
stats,
|
||||
clock_offset,
|
||||
video_e2e,
|
||||
meter,
|
||||
rendered: Mutex::new(VecDeque::new()),
|
||||
})
|
||||
@@ -117,14 +105,7 @@ pub(super) fn install_render_callback(
|
||||
}
|
||||
let sym = libc::dlsym(lib, c"AMediaCodec_setOnFrameRenderedCallback".as_ptr());
|
||||
if sym.is_null() {
|
||||
// No confirmed present ⇒ no `display` stage AND no reference for the audio plane's A/V
|
||||
// sync, which then stays inert and leaves the ring exactly as it was. The release
|
||||
// instant is NOT substituted: releases target a future vsync, so it runs a whole latch
|
||||
// period (8-21 ms measured) ahead of glass — well outside the loop's deadband, i.e. it
|
||||
// would place audio early on every frame while looking like it was working.
|
||||
log::info!(
|
||||
"decode: no render callback on this API level (<33) — no display stage, no A/V sync"
|
||||
);
|
||||
log::info!("decode: no render callback on this API level (<33) — no display stage");
|
||||
return None;
|
||||
}
|
||||
std::mem::transmute::<*mut c_void, SetOnFrameRenderedFn>(sym)
|
||||
@@ -164,10 +145,8 @@ pub(super) unsafe fn release_render_callback(ud: *const DisplayTracker) {
|
||||
/// between the frame rendering and the (batchable) callback delivery — to subtract against the
|
||||
/// receipt/decode stamps and the host capture pts. Records the HUD's `displayed` point:
|
||||
/// `end-to-end` = capture→displayed (skew-corrected) and `display` = decoded→displayed
|
||||
/// (single-clock local) — and publishes that end-to-end figure for the audio plane to align
|
||||
/// against, which is the only place in the client that knows when a frame truly reached glass.
|
||||
/// Panic-free by construction (poison-proof lock, saturating math) — an unwind out of an
|
||||
/// `extern "C"` fn would abort the process.
|
||||
/// (single-clock local). Panic-free by construction (poison-proof lock, saturating math) — an
|
||||
/// unwind out of an `extern "C"` fn would abort the process.
|
||||
unsafe extern "C" fn on_frame_rendered(
|
||||
_codec: *mut ndk_sys::AMediaCodec,
|
||||
userdata: *mut c_void,
|
||||
@@ -207,28 +186,13 @@ unsafe extern "C" fn on_frame_rendered(
|
||||
let latch_us = paired.and_then(|(_, r)| clamp(displayed_ns - r));
|
||||
// Always-on half: the presenter's pf-present line reads these with the HUD off.
|
||||
t.meter.note_latch(latch_us);
|
||||
// The glass-to-glass figure, computed ABOVE the HUD gate: the audio plane steers its ring by it
|
||||
// (see `video_e2e`), and a sync loop that only worked while the overlay was up would be off on
|
||||
// the exact devices that report latency — on a Deck-class report the overlay is precisely what
|
||||
// the field cannot reach. The cost is one relaxed load and some integer arithmetic per confirmed
|
||||
// present (≤ the panel rate); the stats LOCK stays behind the gate, which is what that
|
||||
// early-return was really protecting.
|
||||
if !t.stats.enabled() {
|
||||
return; // HUD hidden — skip the skew math + the stats lock
|
||||
}
|
||||
let e2e_ns =
|
||||
displayed_ns + t.clock_offset.load(Ordering::Relaxed) as i128 - pts_us as i128 * 1000;
|
||||
// Same (0, 10 s) clamp as every other e2e sample — a vendor's first render callbacks can carry
|
||||
// a garbage `system_nano`, and here that would step the audio ring rather than just a p95.
|
||||
let e2e_valid = e2e_ns > 0 && e2e_ns < 10_000_000_000;
|
||||
if e2e_valid {
|
||||
t.video_e2e.store(e2e_ns as u64, Ordering::Relaxed);
|
||||
}
|
||||
if !t.stats.enabled() {
|
||||
return; // HUD hidden — skip the stats lock
|
||||
}
|
||||
t.stats.note_displayed(
|
||||
e2e_valid.then_some((e2e_ns / 1000) as u64),
|
||||
display_us,
|
||||
latch_us,
|
||||
);
|
||||
let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64);
|
||||
t.stats.note_displayed(e2e_us, display_us, latch_us);
|
||||
}
|
||||
|
||||
/// React to an output-format change by signalling the stream's HDR dataspace on the Surface (SDR
|
||||
|
||||
@@ -185,12 +185,9 @@ pub(super) fn run_sync(
|
||||
// render = true are parked in the tracker; the OnFrameRendered callback pairs them with
|
||||
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
|
||||
// reclaimed after the codec is dropped below.
|
||||
// The `video_e2e` cell is the audio plane's alignment reference (see `DisplayTracker`): this
|
||||
// legacy loop feeds it too, so A/V sync works with "Low-latency mode" off as well.
|
||||
let tracker = DisplayTracker::new(
|
||||
stats.clone(),
|
||||
clock_offset.clone(),
|
||||
client.video_e2e_shared(),
|
||||
std::sync::Arc::new(super::presenter::PresentMeter::new()),
|
||||
);
|
||||
let render_cb = install_render_callback(&codec, &tracker);
|
||||
|
||||
@@ -33,21 +33,8 @@ pub(super) fn now_monotonic_ns() -> i64 {
|
||||
};
|
||||
// SAFETY: `clock_gettime` with a valid out-pointer is an always-safe syscall.
|
||||
unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) };
|
||||
// Explicit widening: `timespec`'s fields are 32-bit on armv7 (`time_t`/`c_long`) and 64-bit on
|
||||
// arm64, so these casts are REQUIRED on one shipping ABI and redundant on the other.
|
||||
//
|
||||
// `:kit:cargoNdkClippy` lints both widths, so it sees the redundant half and flags it; taking
|
||||
// its advice would break the 32-bit build, which is the ABI for the many 32-bit Google TV /
|
||||
// Android TV boxes this client targets. `i64::from`/`.into()` do not escape it either — they
|
||||
// just trade `unnecessary_cast` for `useless_conversion` on the 64-bit side. So the cast stays
|
||||
// and the lint is answered here rather than in whichever build breaks first.
|
||||
#[allow(
|
||||
clippy::unnecessary_cast,
|
||||
reason = "required on 32-bit ABIs; redundant only on 64-bit"
|
||||
)]
|
||||
{
|
||||
ts.tv_sec as i64 * 1_000_000_000 + ts.tv_nsec as i64
|
||||
}
|
||||
// Explicit widening: timespec's fields are 32-bit on armv7 (time_t/c_long).
|
||||
ts.tv_sec as i64 * 1_000_000_000 + ts.tv_nsec as i64
|
||||
}
|
||||
|
||||
/// One upcoming frame timeline (API 33+ payload): when SurfaceFlinger expects to present the
|
||||
|
||||
@@ -26,15 +26,6 @@ use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError, SyncSender, TryS
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// What one capture open attempt yields: the stream, plus both halves of the PCM hand-off — the
|
||||
/// receiver the encode worker drains and the sender that returns emptied buffers for reuse. Note
|
||||
/// the pair is the mirror image of [`crate::audio::OpenedPlayback`]'s: here the callback produces
|
||||
/// and the worker consumes.
|
||||
///
|
||||
/// Named rather than written inline for the same reason as that one — `clippy::type_complexity`,
|
||||
/// now that the Android target is actually linted (`:kit:cargoNdkClippy`).
|
||||
type OpenedCapture = ndk::audio::Result<(AudioStream, Receiver<Vec<f32>>, SyncSender<Vec<f32>>)>;
|
||||
|
||||
const CHANNELS: usize = 1;
|
||||
const SAMPLE_RATE: i32 = 48_000;
|
||||
/// 10 ms per channel @ 48 kHz — half the desktop clients' 20 ms frame, trading a little Opus
|
||||
@@ -93,7 +84,13 @@ impl MicCapture {
|
||||
|
||||
// One open attempt at a given sharing mode (same pattern as [`crate::audio`]: `open_stream`
|
||||
// consumes the builder AND the callback, so each try rebuilds the channels it captures).
|
||||
let try_open = |sharing: AudioSharingMode, voice: bool| -> OpenedCapture {
|
||||
let try_open = |sharing: AudioSharingMode,
|
||||
voice: bool|
|
||||
-> ndk::audio::Result<(
|
||||
AudioStream,
|
||||
Receiver<Vec<f32>>,
|
||||
SyncSender<Vec<f32>>,
|
||||
)> {
|
||||
let (tx, rx) = sync_channel::<Vec<f32>>(RING_CHUNKS);
|
||||
// Recycle free-list, mirroring the playback path: the realtime capture callback must
|
||||
// not touch the allocator (Android's Scudo has unbounded malloc/free tail latency — an
|
||||
|
||||
@@ -408,8 +408,8 @@ pub(crate) unsafe fn self_test(fd: i32, seconds: i32, hz: i32) -> i32 {
|
||||
frame.fill(0);
|
||||
// Channels 2 and 3 are the voice coils; the speaker pair stays silent so a pass is
|
||||
// unambiguously FELT rather than merely audible.
|
||||
for slot in frame.iter_mut().take(channels).skip(2) {
|
||||
*slot = sample;
|
||||
for c in 2..channels {
|
||||
frame[c] = sample;
|
||||
}
|
||||
}
|
||||
if let Err(e) = playback.write_interleaved(&chunk) {
|
||||
|
||||
@@ -361,40 +361,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
|
||||
);
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativePadMotionReaches(handle, declaredPref)` — whether motion sent for a pad that
|
||||
/// declared `declaredPref` (the `GamepadPref` wire byte it passed to `nativeSendGamepadArrival`) can
|
||||
/// actually reach the game, or would be decoded and dropped by a host backend with no motion plane.
|
||||
///
|
||||
/// The whole question is answered here rather than in Kotlin so the reasoning lives in exactly one
|
||||
/// place — [`punktfunk_core::config::pad_motion_reaches`], which carries the argument and the tests.
|
||||
/// A third transcription of it would be a third thing to get subtly wrong, and every way of getting
|
||||
/// it wrong is silent: too strict kills a working gyro, too lax keeps ~250 Hz of samples flowing
|
||||
/// into a host that drops every one.
|
||||
///
|
||||
/// A `0` handle answers `true` — "don't suppress" is the safe answer when we cannot tell, matching
|
||||
/// the `Auto` rule inside the predicate itself.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadMotionReaches(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
declared_pref: jint,
|
||||
) -> jboolean {
|
||||
if handle == 0 {
|
||||
return 1;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; both fields are plain Copy
|
||||
// values read behind `&self`.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let declared =
|
||||
punktfunk_core::config::GamepadPref::from_u8(declared_pref.clamp(0, u8::MAX as jint) as u8);
|
||||
u8::from(punktfunk_core::config::pad_motion_reaches(
|
||||
declared,
|
||||
h.client.requested_gamepad,
|
||||
h.client.resolved_gamepad,
|
||||
))
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendGamepadRemove(handle, pad)` — signal that wire pad index `pad` was
|
||||
/// unplugged so the host tears its virtual device down. `pad` (rides `flags`) is the only field; the
|
||||
/// core stamps the per-pad seq (in the snapshot seq space, so a reordered snapshot can't resurrect the
|
||||
|
||||
@@ -177,12 +177,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD
|
||||
/// (unified stats spec, `design/stats-unification.md`). Returns 35 doubles
|
||||
/// (unified stats spec, `design/stats-unification.md`). Returns 33 doubles
|
||||
/// `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
|
||||
/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
|
||||
/// netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow, dispValid, displayP50Ms,
|
||||
/// e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms, presentsWindow, presenterActive,
|
||||
/// feedP50Ms, codecP50Ms, skippedOverflowWindow, audioBufferMs, audioAvOffsetMs]`
|
||||
/// feedP50Ms, codecP50Ms, skippedOverflowWindow]`
|
||||
/// (the flags are 1.0/0.0; indexes 0–21 match the previous 22-double layout — 0–13 the original
|
||||
/// 14-double one with the latency pair re-based to the end-to-end capture→decoded headline, 14/15
|
||||
/// the stage p50s tiling it: `host+network` = capture→received, `decode` = received→decoded; 16/17
|
||||
@@ -203,10 +203,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
||||
/// received→queued (hand-off + input-slot wait) at 30 and `codec` = queued→decoded (codec-pure,
|
||||
/// from the AU's last piece) at 31, both 0.0 when no sample landed (sync loop); 32 is the
|
||||
/// parked-AU overflow subset of the window's `skipped` at 19 (decoder fell behind, vs benign
|
||||
/// newest-wins pacing); 33/34 are the AUDIO plane's latency — the playback ring's live depth in ms
|
||||
/// and the A/V sync loop's smoothed offset in ms (positive = audio behind the picture) — both live
|
||||
/// gauges rather than windowed samples, like the cumulative drop total at 9), or `null` when no
|
||||
/// decode thread is running.
|
||||
/// newest-wins pacing)), or `null` when no decode thread is running.
|
||||
/// Poll ~1 Hz from the UI; each call
|
||||
/// resets the measurement window. Not android-gated — pure `jni` + connector reads, so it links on
|
||||
/// the host build too (Kotlin only ever calls it on device).
|
||||
@@ -230,7 +227,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
.drain(h.client.frames_dropped(), h.client.fec_recovered_shards());
|
||||
let mode = h.client.mode();
|
||||
let color = h.client.color;
|
||||
let buf: [f64; 35] = [
|
||||
let buf: [f64; 33] = [
|
||||
snap.fps,
|
||||
snap.mbps,
|
||||
snap.e2e_p50_ms,
|
||||
@@ -284,15 +281,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
snap.feed_p50_ms,
|
||||
snap.codec_p50_ms,
|
||||
snap.skipped_overflow as f64,
|
||||
// The audio plane's own latency (`design/audio-latency-overhaul.md`): how much decoded
|
||||
// audio is queued ahead of the speaker, and where the A/V sync loop measures that
|
||||
// PUTS it relative to the picture (+ = audio behind). Both, because a deep ring on a
|
||||
// jittery link is correct behaviour and only the offset tells that apart from audio
|
||||
// simply held late. Live gauges written by the audio thread — before this the whole
|
||||
// plane published nothing any surface could render, so a "the audio delay is way too
|
||||
// high" report had no instrument behind it at all.
|
||||
h.client.audio_buffer_ms() as f64,
|
||||
h.client.audio_av_offset_ms() as f64,
|
||||
];
|
||||
let arr = match env.new_double_array(buf.len() as jsize) {
|
||||
Ok(a) => a,
|
||||
|
||||
@@ -19,14 +19,6 @@
|
||||
<array>
|
||||
<string>_punktfunk._udp</string>
|
||||
</array>
|
||||
<!-- NOTE: there is deliberately NO NSAppTransportSecurity dict here. ATS stays fully ON.
|
||||
The host is self-signed at a user-supplied address, which default ATS can never accept
|
||||
(it exempts only .local, unqualified names, and RFC1918/link-local literals — notably NOT
|
||||
Tailscale's 100.64/10 CGNAT range), so the management API talks over MgmtTransport
|
||||
(Network.framework), which is outside the URL loading system and pins the host by
|
||||
SHA-256 fingerprint instead. That leaves cover-art CDN fetches as the app's only
|
||||
URLSession traffic, and they keep the full ATS policy — which is the whole reason not to
|
||||
reach for NSAllowsArbitraryLoads here. See MgmtTransport.swift. -->
|
||||
<!-- Background keep-alive (opt-in, iOS/iPadOS): the ONLY sanctioned way to keep the long-lived
|
||||
QUIC socket + pump-thread set alive while backgrounded is the audio background mode, backed
|
||||
by the session's real, audible remote audio (AVAudioEngine keeps rendering). Video decode is
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "glur",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/joogps/Glur.git",
|
||||
"state" : {
|
||||
"revision" : "ba4f05d3c9a608ec773b9305f2af6089390de68a"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
}
|
||||
@@ -16,17 +16,6 @@ let package = Package(
|
||||
.library(name: "PunktfunkShared", targets: ["PunktfunkShared"]),
|
||||
.executable(name: "PunktfunkClient", targets: ["PunktfunkClient"]),
|
||||
],
|
||||
dependencies: [
|
||||
// Progressive (gradient) backdrop blur for the form screens' trays — a real blur with no
|
||||
// material tint stage (see GamepadTrayBlur). Pinned by REVISION, not `from:`: the
|
||||
// GlurBackdrop product exists only on main — no release carries it (the newest tag,
|
||||
// `1.1`, predates it, and is not three-component semver anyway, so version-based
|
||||
// resolution stops at 1.0.4). The revision is main's head at adoption time; a revision
|
||||
// pin stays reproducible when the branch moves.
|
||||
.package(
|
||||
url: "https://github.com/joogps/Glur.git",
|
||||
revision: "ba4f05d3c9a608ec773b9305f2af6089390de68a"),
|
||||
],
|
||||
targets: [
|
||||
.binaryTarget(name: "PunktfunkCore", path: "PunktfunkCore.xcframework"),
|
||||
// No dependencies by design — an extension process links this alone.
|
||||
@@ -50,10 +39,6 @@ let package = Package(
|
||||
// licensing in that README). `.process` compiles the catalog; loaded via
|
||||
// OsIcon.swift.
|
||||
.process("Resources/OsIcons.xcassets"),
|
||||
// The launcher tiles' brand marks (template vector imagesets generated from the
|
||||
// assets/launcher-icons masters by scripts/gen-launcher-icons.sh — per-mark
|
||||
// provenance and licensing in that README). Loaded via LauncherIcon.swift.
|
||||
.process("Resources/LauncherIcons.xcassets"),
|
||||
],
|
||||
linkerSettings: [
|
||||
// Rust staticlib system deps.
|
||||
@@ -66,12 +51,7 @@ let package = Package(
|
||||
// (The tvOS slide-transition package is referenced by the Xcode PROJECT only —
|
||||
// its manifest breaks SwiftPM whole-graph validation on macOS, and only the
|
||||
// Punktfunk-tvOS target links it; the #if os(tvOS) import never compiles here.)
|
||||
.executableTarget(
|
||||
name: "PunktfunkClient",
|
||||
dependencies: [
|
||||
"PunktfunkKit",
|
||||
.product(name: "GlurBackdrop", package: "Glur"),
|
||||
]),
|
||||
.executableTarget(name: "PunktfunkClient", dependencies: ["PunktfunkKit"]),
|
||||
// PunktfunkCore is a direct dep too so the wire tests can name the C ABI's
|
||||
// `PunktfunkInputEvent` / `PUNKTFUNK_INPUT_KIND_*` when asserting the gamepad byte layout.
|
||||
.testTarget(
|
||||
|
||||
@@ -11,12 +11,6 @@
|
||||
BB0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = BB0000000000000000000006 /* PunktfunkKit */; };
|
||||
CC0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = CC0000000000000000000006 /* PunktfunkKit */; };
|
||||
DD0000000000000000000003 /* SwiftUINavigationTransitions in Frameworks */ = {isa = PBXBuildFile; productRef = DD0000000000000000000002 /* SwiftUINavigationTransitions */; };
|
||||
EE0000000000000000000012 /* Glur in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000002 /* Glur */; };
|
||||
EE0000000000000000000013 /* GlurBackdrop in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000003 /* GlurBackdrop */; };
|
||||
EE0000000000000000000014 /* Glur in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000004 /* Glur */; };
|
||||
EE0000000000000000000015 /* GlurBackdrop in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000005 /* GlurBackdrop */; };
|
||||
EE0000000000000000000016 /* Glur in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000006 /* Glur */; };
|
||||
EE0000000000000000000017 /* GlurBackdrop in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000007 /* GlurBackdrop */; };
|
||||
E295569A300948B9009F939C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E2955699300948B9009F939C /* WidgetKit.framework */; };
|
||||
E295569C300948B9009F939C /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E295569B300948B9009F939C /* SwiftUI.framework */; };
|
||||
E29556A9300948BA009F939C /* PunktfunkWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
@@ -94,8 +88,6 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
AA0000000000000000000005 /* PunktfunkKit in Frameworks */,
|
||||
EE0000000000000000000012 /* Glur in Frameworks */,
|
||||
EE0000000000000000000013 /* GlurBackdrop in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -104,8 +96,6 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
BB0000000000000000000005 /* PunktfunkKit in Frameworks */,
|
||||
EE0000000000000000000014 /* Glur in Frameworks */,
|
||||
EE0000000000000000000015 /* GlurBackdrop in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -115,8 +105,6 @@
|
||||
files = (
|
||||
CC0000000000000000000005 /* PunktfunkKit in Frameworks */,
|
||||
DD0000000000000000000003 /* SwiftUINavigationTransitions in Frameworks */,
|
||||
EE0000000000000000000016 /* Glur in Frameworks */,
|
||||
EE0000000000000000000017 /* GlurBackdrop in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -187,8 +175,6 @@
|
||||
name = Punktfunk;
|
||||
packageProductDependencies = (
|
||||
AA0000000000000000000006 /* PunktfunkKit */,
|
||||
EE0000000000000000000002 /* Glur */,
|
||||
EE0000000000000000000003 /* GlurBackdrop */,
|
||||
);
|
||||
productName = Punktfunk;
|
||||
productReference = AA0000000000000000000001 /* Punktfunk.app */;
|
||||
@@ -215,8 +201,6 @@
|
||||
name = "Punktfunk-iOS";
|
||||
packageProductDependencies = (
|
||||
BB0000000000000000000006 /* PunktfunkKit */,
|
||||
EE0000000000000000000004 /* Glur */,
|
||||
EE0000000000000000000005 /* GlurBackdrop */,
|
||||
);
|
||||
productName = "Punktfunk-iOS";
|
||||
productReference = BB0000000000000000000001 /* Punktfunk-iOS.app */;
|
||||
@@ -242,8 +226,6 @@
|
||||
packageProductDependencies = (
|
||||
CC0000000000000000000006 /* PunktfunkKit */,
|
||||
DD0000000000000000000002 /* SwiftUINavigationTransitions */,
|
||||
EE0000000000000000000006 /* Glur */,
|
||||
EE0000000000000000000007 /* GlurBackdrop */,
|
||||
);
|
||||
productName = "Punktfunk-tvOS";
|
||||
productReference = CC0000000000000000000001 /* Punktfunk-tvOS.app */;
|
||||
@@ -301,7 +283,6 @@
|
||||
packageReferences = (
|
||||
AA000000000000000000000F /* XCLocalSwiftPackageReference "." */,
|
||||
DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */,
|
||||
EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = AA0000000000000000000008 /* Products */;
|
||||
@@ -867,14 +848,6 @@
|
||||
minimumVersion = 0.18.0;
|
||||
};
|
||||
};
|
||||
EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/joogps/Glur.git";
|
||||
requirement = {
|
||||
kind = revision;
|
||||
revision = ba4f05d3c9a608ec773b9305f2af6089390de68a;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
@@ -895,36 +868,6 @@
|
||||
package = DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */;
|
||||
productName = SwiftUINavigationTransitions;
|
||||
};
|
||||
EE0000000000000000000002 /* Glur */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
|
||||
productName = Glur;
|
||||
};
|
||||
EE0000000000000000000003 /* GlurBackdrop */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
|
||||
productName = GlurBackdrop;
|
||||
};
|
||||
EE0000000000000000000004 /* Glur */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
|
||||
productName = Glur;
|
||||
};
|
||||
EE0000000000000000000005 /* GlurBackdrop */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
|
||||
productName = GlurBackdrop;
|
||||
};
|
||||
EE0000000000000000000006 /* Glur */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
|
||||
productName = Glur;
|
||||
};
|
||||
EE0000000000000000000007 /* GlurBackdrop */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
|
||||
productName = GlurBackdrop;
|
||||
};
|
||||
E2CAFE000000000000000002 /* PunktfunkShared */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = PunktfunkShared;
|
||||
|
||||