Compare commits
105
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f34f311c69 | ||
|
|
e6bcac8770 | ||
|
|
9c7409ea40 | ||
|
|
b0ec759fad | ||
|
|
185a69c806 | ||
|
|
50fa228bab | ||
|
|
f33e442b43 | ||
|
|
442558e503 | ||
|
|
4bcb3794c5 | ||
|
|
214e846984 | ||
|
|
e78906fb06 | ||
|
|
d167e90ea1 | ||
|
|
e9da37aaf6 | ||
|
|
453b9850fa | ||
|
|
eb4b33ca49 | ||
|
|
8e02f3a8e1 | ||
|
|
4a9a1c3ed4 | ||
|
|
166e158afe | ||
|
|
fb707b4956 | ||
|
|
65996621d8 | ||
|
|
c48e60fbb7 | ||
|
|
70684e5079 | ||
|
|
3f8a70dd45 | ||
|
|
2a67c02f7e | ||
|
|
79ee308a7a | ||
|
|
58ee74cb58 | ||
|
|
6ae2ea6708 | ||
|
|
00d4026054 | ||
|
|
dd20a17edb | ||
|
|
8ff2c2e1c6 | ||
|
|
883c317872 | ||
|
|
72777119fd | ||
|
|
81b4f76c4d | ||
|
|
ec44496285 | ||
|
|
d74639de70 | ||
|
|
d4dd5f7a3d | ||
|
|
ea762b849d | ||
|
|
76e8bd1b98 | ||
|
|
fbdad8d917 | ||
|
|
78ad675507 | ||
|
|
b25e6eda91 | ||
|
|
c79d9397fe | ||
|
|
3edb01f1b8 | ||
|
|
5a7f7f0fc5 | ||
|
|
25b08916b6 | ||
|
|
35ba64ca0f | ||
|
|
5f71aeb024 | ||
|
|
e1adc5d6d7 | ||
|
|
76a271b97a | ||
|
|
b53568c99f | ||
|
|
db0637928b | ||
|
|
22bc81238d | ||
|
|
de6b9e94ec | ||
|
|
5ebe840320 | ||
|
|
4b1ce6b905 | ||
|
|
a11c672bea | ||
|
|
cbd0e9664d | ||
|
|
66df1624b6 | ||
|
|
6f07bd94d3 | ||
|
|
d2085879da | ||
|
|
19f637ea6e | ||
|
|
4a0d0ce587 | ||
|
|
0d94ef0dbe | ||
|
|
a1b8627e70 | ||
|
|
91fa32fbb6 | ||
|
|
defdfbdb58 | ||
|
|
8103958169 | ||
|
|
ce8f3e9eaf | ||
|
|
bd383f1820 | ||
|
|
8728d90e01 | ||
|
|
3d4a659959 | ||
|
|
a418d2852a | ||
|
|
110ac9b663 | ||
|
|
1d6f4760f3 | ||
|
|
9dfbc2f895 | ||
|
|
56adb47026 | ||
|
|
52a9d02355 | ||
|
|
e5ca213339 | ||
|
|
e5416646f9 | ||
|
|
b79d90b463 | ||
|
|
8983ec04b9 | ||
|
|
d27e62f7c9 | ||
|
|
0a72959ef7 | ||
|
|
173be61213 | ||
|
|
2032c48ffa | ||
|
|
9a52c279f1 | ||
|
|
5be494f490 | ||
|
|
0d5e5b436b | ||
|
|
3a48cc2470 | ||
|
|
64a392634e | ||
|
|
35285afafc | ||
|
|
0d0e7e6861 | ||
|
|
143454590f | ||
|
|
9409d0a04c | ||
|
|
212bdc3b08 | ||
|
|
45cb525035 | ||
|
|
6fed1510ba | ||
|
|
4fd240deab | ||
|
|
e32bd30c85 | ||
|
|
2f1ef44191 | ||
|
|
8ee224e5db | ||
|
|
e8499e6131 | ||
|
|
a10bde39bb | ||
|
|
b5f91d50bb | ||
|
|
ed3d236ab8 |
Executable
+68
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
# Assert that a builder image's :latest is the SAME manifest as its content key, and
|
||||
# re-point it when it isn't.
|
||||
#
|
||||
# This is what we do instead of pinning consumers by @sha256: digest
|
||||
# (security-review-2026-08-05, H-6 — see the reasoning at the top of docker.yml). The
|
||||
# content key is a hash of the ci/ tree, so "which image should :latest be?" has an
|
||||
# answer derivable from the commit alone. Checking it on every run turns :latest from a
|
||||
# tag someone remembered to move into a function of the tree.
|
||||
#
|
||||
# Two different things make them diverge and neither is distinguishable from here:
|
||||
#
|
||||
# - Someone overwrote :latest out of band. Post-fix that needs the push credential,
|
||||
# but it is exactly the H-6 attack and it must not pass silently.
|
||||
# - ci/ was reverted. The older key is already a cache hit, so nothing rebuilds and
|
||||
# nothing re-points :latest — it stays on the newer build forever while every
|
||||
# consumer pulls a builder that does not match the tree it is building. That bug
|
||||
# predates this script.
|
||||
#
|
||||
# Both are repaired identically, so: repair, and shout. Failing the build instead would
|
||||
# turn a legitimate revert into a red main with no way forward.
|
||||
#
|
||||
# Reads go to the anonymous port, the single write to the authenticated one.
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE="${1:?usage: reconcile-latest.sh <image> <content-key>}"
|
||||
KEY="${2:?usage: reconcile-latest.sh <image> <content-key>}"
|
||||
: "${CI_REGISTRY:?CI_REGISTRY not set}"
|
||||
: "${CI_REGISTRY_PUSH:?CI_REGISTRY_PUSH not set}"
|
||||
: "${CI_REGISTRY_PASSWORD:?CI_REGISTRY_PASSWORD not set}"
|
||||
|
||||
ACCEPT='Accept: application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json'
|
||||
|
||||
# Digest of a tag, or empty if the tag does not exist. Never fails the script itself —
|
||||
# "missing" is a state this has to reason about, not an error to abort on.
|
||||
digest_of() {
|
||||
curl -sfI -H "$ACCEPT" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$1" 2>/dev/null \
|
||||
| tr -d '\r' | sed -n 's/^[Dd]ocker-[Cc]ontent-[Dd]igest: //p' || true
|
||||
}
|
||||
|
||||
key_digest=$(digest_of "$KEY")
|
||||
latest_digest=$(digest_of latest)
|
||||
|
||||
if [ -z "$key_digest" ]; then
|
||||
echo "::error::$IMAGE:$KEY has no manifest — the build or push above did not land"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$key_digest" = "$latest_digest" ]; then
|
||||
echo "$IMAGE:latest == :$KEY ($key_digest)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::warning::$IMAGE:latest did not match its content key :$KEY — re-pointing it. If ci/ was not just reverted, someone overwrote this tag out of band: check the registry access log on home-ci-core."
|
||||
echo " was: ${latest_digest:-<no :latest tag>}"
|
||||
echo " wanted: $key_digest (:$KEY)"
|
||||
|
||||
tmp=$(mktemp)
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
media_type=$(curl -sfI -H "$ACCEPT" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY" \
|
||||
| tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p')
|
||||
curl -sf -H "$ACCEPT" -o "$tmp" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY"
|
||||
curl -sf -u "ci:$CI_REGISTRY_PASSWORD" -X PUT -H "Content-Type: $media_type" \
|
||||
--data-binary @"$tmp" "http://$CI_REGISTRY_PUSH/v2/$IMAGE/manifests/latest"
|
||||
|
||||
now=$(digest_of latest)
|
||||
[ "$now" = "$key_digest" ] || { echo "::error::re-point failed: :latest is $now"; exit 1; }
|
||||
echo "$IMAGE:latest re-pointed to $key_digest"
|
||||
@@ -41,9 +41,23 @@ jobs:
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
UPDATE_MANIFEST_KEY: ${{ secrets.UPDATE_MANIFEST_KEY }}
|
||||
# Through the ENVIRONMENT, never interpolated into the script body. A `${{ }}` expansion
|
||||
# is a raw textual substitution performed BEFORE the shell sees the line, so a
|
||||
# workflow_dispatch input containing shell syntax executes as this step — and this is the
|
||||
# step holding UPDATE_MANIFEST_KEY, the Ed25519 key every host pins to decide whether an
|
||||
# update is real (2026-08-05 review H-6). As `$INPUT_TAG` it is only ever data.
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ inputs.tag }}"
|
||||
TAG="$INPUT_TAG"
|
||||
# Shape-check before the value reaches a URL or a filename: tags are `vX.Y.Z[-suffix]`.
|
||||
case "$TAG" in
|
||||
v[0-9]*) ;;
|
||||
*) echo "refusing to publish for a tag that is not vX.Y.Z: $TAG" >&2; exit 1 ;;
|
||||
esac
|
||||
case "$TAG" in
|
||||
*[!A-Za-z0-9.+_-]*) echo "tag has characters no release tag has: $TAG" >&2; exit 1 ;;
|
||||
esac
|
||||
case "$TAG" in
|
||||
*-*) echo "pre-release tag $TAG — not publishing to the stable update feed"; exit 0 ;;
|
||||
esac
|
||||
@@ -67,4 +81,7 @@ jobs:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
DISCORD_RELEASE_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
|
||||
ALLOW_PRERELEASE: ${{ inputs.allow_prerelease }}
|
||||
run: bash scripts/ci/discord-announce.sh "${{ inputs.tag }}"
|
||||
# Same reasoning as the publish step above: the input is data in the environment, never
|
||||
# text spliced into the command line.
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
run: bash scripts/ci/discord-announce.sh "$INPUT_TAG"
|
||||
|
||||
@@ -29,4 +29,9 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Tier-3 GPU stream benchmark
|
||||
run: bash scripts/bench/gpu-stream.sh "${{ inputs.mode || '1920x1080x120' }}" 12
|
||||
# Through the environment, not interpolated into the command line: a `${{ }}` expansion is
|
||||
# substituted before the shell parses the line, so an input carrying shell syntax would run
|
||||
# as this step (2026-08-05 review H-6).
|
||||
env:
|
||||
BENCH_MODE: ${{ inputs.mode || '1920x1080x120' }}
|
||||
run: bash scripts/bench/gpu-stream.sh "$BENCH_MODE" 12
|
||||
|
||||
@@ -252,6 +252,11 @@ jobs:
|
||||
run: bun run build
|
||||
- name: Typecheck
|
||||
run: bun run lint
|
||||
# Scoped to server/: the console's browser code has no test runner, but the gate that keeps a
|
||||
# plugin's origin apart from the console's does — and its failure mode is a well-formed header
|
||||
# that only a browser rejects, which nothing else here would catch.
|
||||
- name: Test
|
||||
run: bun run test
|
||||
|
||||
docs-site:
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -275,3 +280,31 @@ jobs:
|
||||
run: bun run build
|
||||
- name: Typecheck
|
||||
run: bun run lint
|
||||
|
||||
# web/bun.nix and sdk/bun.nix are GENERATED from their bun.lock (bun2nix) and committed; the Nix
|
||||
# build fetches node_modules from nothing else. They regenerate only on a local `bun install` that
|
||||
# runs lifecycle scripts — never under CI's `--ignore-scripts`, and never on a merge or rebase,
|
||||
# which happily carries a lockfile change past a bun.nix generated before it. That is not
|
||||
# theoretical: web/bun.nix sat stale on main for 553 commits (2026-07-27 → 2026-08-05) with
|
||||
# `nix build .#punktfunk-web` broken, and was repaired only by accident when an advisory bump
|
||||
# happened to rerun a real `bun install`.
|
||||
#
|
||||
# Deliberately UNFILTERED and in ci.yml rather than nix.yml: it needs no Nix, takes well under a
|
||||
# minute, and the whole point is that the drift arrives through commits that look unrelated to
|
||||
# Nix. The Nix-toolchain gates (flake eval + building the bun packages) live in nix.yml.
|
||||
bun-nix:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: oven/bun:1
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
# oven/bun ships neither git nor a real node, and the slim base has no CA bundle —
|
||||
# actions/checkout needs all three (see the web job).
|
||||
- name: Install git + node + CA certs
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
|
||||
- uses: actions/checkout@v4
|
||||
# Regenerates each bun.nix from its committed bun.lock and diffs, and checks that the
|
||||
# bun2nix version pin agrees across flake.nix and both package.json files (bun.nix has no
|
||||
# schema stability across bun2nix releases). Fix with: scripts/ci/check-bun-nix.sh --fix
|
||||
- name: bun.nix drift gate
|
||||
run: sh scripts/ci/check-bun-nix.sh
|
||||
|
||||
@@ -46,7 +46,10 @@ env:
|
||||
REGISTRY: git.unom.io
|
||||
OWNER: unom
|
||||
PACKAGE: punktfunk-decky # generic-registry package name
|
||||
PLUGIN: punktfunk # plugin.json "name" == zip top-level dir
|
||||
# The plugin's ON-DISK dir == the zip's top-level dir. Deliberately NOT plugin.json "name"
|
||||
# (that is the brand-cased label Decky lists, and it locates a plugin by matching it, not by
|
||||
# the folder) — see clients/decky/scripts/package.sh.
|
||||
PLUGIN: punktfunk
|
||||
|
||||
jobs:
|
||||
build-publish:
|
||||
|
||||
+107
-21
@@ -3,13 +3,18 @@
|
||||
# Two very different image families now:
|
||||
#
|
||||
# BUILDER images (punktfunk-rust-ci{,-noble,-arm64cross}, punktfunk-fedora{,44}-rpm)
|
||||
# live on the LAN registry (home-ci-core, 192.168.1.58:5010 — unom/infra
|
||||
# runners/ci-core/) and are CONTENT-KEYED: the tag is a hash of what they are built
|
||||
# from (the ci/ tree, + rust-toolchain.toml for the cross image), and a build only
|
||||
# happens when that key has no manifest yet. A push that doesn't touch ci/ costs one
|
||||
# curl per image (~seconds), pushes nothing over the WAN, and mints no per-SHA tag
|
||||
# debris on the runners — the failure mode that filled the fleet's disks. `:latest`
|
||||
# is re-pushed alongside every new key and is what the consuming workflows pin.
|
||||
# live on the LAN registry (home-ci-core — unom/infra runners/ci-core/) and are
|
||||
# CONTENT-KEYED: the tag is a hash of what they are built from (the ci/ tree, +
|
||||
# rust-toolchain.toml for the cross image), and a build only happens when that key
|
||||
# has no manifest yet. A push that doesn't touch ci/ costs one curl per image
|
||||
# (~seconds), pushes nothing over the WAN, and mints no per-SHA tag debris on the
|
||||
# runners — the failure mode that filled the fleet's disks. `:latest` is re-pushed
|
||||
# alongside every new key and is what the consuming workflows pin.
|
||||
#
|
||||
# READS come from :5010 and need no credential. WRITES go to :5011 and need
|
||||
# CI_REGISTRY_PASSWORD. Same store behind both — a registry keys by repository name,
|
||||
# not by the host:port the client used — so an image pushed to :5011 is the same
|
||||
# image every consumer pulls from :5010.
|
||||
#
|
||||
# APP images (punktfunk-web, punktfunk-docs) are deployables: they keep going to the
|
||||
# Gitea registry (git.unom.io) with :latest + :sha-<8> (+ :vX.Y.Z on tags), because
|
||||
@@ -17,8 +22,38 @@
|
||||
#
|
||||
# Host and clients are intentionally NOT containerized (see CLAUDE.md "What's left").
|
||||
#
|
||||
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (app images only —
|
||||
# the LAN registry is unauthenticated inside the LAN).
|
||||
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (app images).
|
||||
# CI_REGISTRY_PASSWORD: repo Actions secret, the LAN registry's push credential for user
|
||||
# `ci`. Generated on ci-core into /srv/ci/stack/registry-secret; rotate in both places.
|
||||
#
|
||||
# --- security-review-2026-08-05 H-6, FIXED 2026-08-05 -------------------------------
|
||||
# The registry used to accept anonymous pushes from any LAN peer, and every
|
||||
# secret-bearing job in this repo runs INSIDE an image pulled from it. Attacker
|
||||
# position #1 of the project's own threat model did not need to break any signing
|
||||
# logic: push one tag, and the next android.yml run executes their code in the same job
|
||||
# that does `echo "$RELEASE_KEYSTORE_BASE64" | base64 -d > release.jks`. Same shape for
|
||||
# rpm.yml (RPM_GPG_PRIVATE_KEY) and android-promote.yml (SERVICE_ACCOUNT_JSON).
|
||||
#
|
||||
# The infra half is done (unom/infra runners/ci-core/): :5010 serves GET/HEAD only and
|
||||
# refuses everything else with 405, :5011 demands basic auth on every request. The half
|
||||
# in this file is done below: pushes and release-tag manifest PUTs authenticate.
|
||||
#
|
||||
# ⚠ On the second half as the review originally worded it — "pin consumers by @sha256:
|
||||
# digest". We deliberately do something else, because after authentication the digest
|
||||
# pin no longer buys what it was meant to buy. The set of people who can overwrite a tag
|
||||
# is now exactly the set who can push to main and edit a pinned digest in this very
|
||||
# file: a pin defends against nobody it did not already trust, while costing a
|
||||
# two-commit dance on every ci/ change (~3x a month) during which consumers silently run
|
||||
# a builder image that predates the ci/ change they are testing.
|
||||
#
|
||||
# What actually closes the residual gap — a tag quietly overwritten out of band — is
|
||||
# making :latest a CHECKED function of the tree instead of a tag someone remembered to
|
||||
# move. The "Reconcile :latest" step below asserts on every run that :latest and
|
||||
# :ck-$KEY are the same digest, repairs it when they are not, and says so loudly. That
|
||||
# catches an out-of-band overwrite on the next push to main, needs no churn, and fixes
|
||||
# a real pre-existing bug on the side: reverting ci/ used to leave :latest pointing at
|
||||
# the newer build forever. Revisit inline digest pins if the push credential ever leaves
|
||||
# the maintainer trust set.
|
||||
#
|
||||
# Bootstrap note: consuming workflows pull <LAN>/punktfunk-rust-ci:latest, so the LAN
|
||||
# registry must hold a seeded :latest once (done 2026-07-29 from the last Gitea-registry
|
||||
@@ -42,7 +77,10 @@ on:
|
||||
env:
|
||||
REGISTRY: git.unom.io
|
||||
OWNER: unom
|
||||
# Read port (anonymous, GET/HEAD only) and write port (basic auth). Two doors onto
|
||||
# one store; see the header.
|
||||
CI_REGISTRY: 192.168.1.58:5010
|
||||
CI_REGISTRY_PUSH: 192.168.1.58:5011
|
||||
|
||||
jobs:
|
||||
builders:
|
||||
@@ -98,21 +136,40 @@ jobs:
|
||||
echo "hit=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Tagged for the WRITE port: :5010 refuses a push outright, so a tag that names it
|
||||
# can only fail. Consumers still pull the identical image from :5010.
|
||||
- name: Build
|
||||
if: steps.exists.outputs.hit == 'false'
|
||||
# --pull is cheap now: base images come through the ci-core pull-through mirror.
|
||||
run: |
|
||||
docker build --pull ${{ matrix.buildargs }} \
|
||||
-f "${{ matrix.dockerfile }}" \
|
||||
-t "$CI_REGISTRY/${{ matrix.image }}:$KEY" \
|
||||
-t "$CI_REGISTRY/${{ matrix.image }}:latest" \
|
||||
-t "$CI_REGISTRY_PUSH/${{ matrix.image }}:$KEY" \
|
||||
-t "$CI_REGISTRY_PUSH/${{ matrix.image }}:latest" \
|
||||
ci
|
||||
|
||||
- name: Log in to the LAN registry
|
||||
run: |
|
||||
echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY_PUSH" -u ci --password-stdin
|
||||
env:
|
||||
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Push
|
||||
if: steps.exists.outputs.hit == 'false'
|
||||
run: |
|
||||
docker push "$CI_REGISTRY/${{ matrix.image }}:$KEY"
|
||||
docker push "$CI_REGISTRY/${{ matrix.image }}:latest"
|
||||
docker push "$CI_REGISTRY_PUSH/${{ matrix.image }}:$KEY"
|
||||
docker push "$CI_REGISTRY_PUSH/${{ matrix.image }}:latest"
|
||||
|
||||
# :latest must be whatever ci/ says it is, on every run — not only on the runs that
|
||||
# happened to build. Two things break that: an out-of-band overwrite (the H-6
|
||||
# attack, now only reachable by someone holding the push credential), and a plain
|
||||
# revert of ci/, which leaves :latest on the newer build because the older key is
|
||||
# already a cache hit and nothing re-points it. Both look identical from here and
|
||||
# both are repaired the same way, so repair and shout rather than fail the build.
|
||||
- name: Reconcile :latest with the content key
|
||||
run: .gitea/scripts/reconcile-latest.sh "${{ matrix.image }}" "$KEY"
|
||||
env:
|
||||
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
|
||||
|
||||
# A release pins reproducible builder images without any rebuild: copy the key's
|
||||
# manifest to a vX.Y.Z tag via the registry API (no image bytes move).
|
||||
@@ -124,8 +181,19 @@ jobs:
|
||||
| tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p')
|
||||
curl -sf -H "$ACCEPT" -o /tmp/manifest.json \
|
||||
"http://$CI_REGISTRY/v2/${{ matrix.image }}/manifests/$KEY"
|
||||
curl -sf -X PUT -H "Content-Type: $MT" --data-binary @/tmp/manifest.json \
|
||||
"http://$CI_REGISTRY/v2/${{ matrix.image }}/manifests/$GITHUB_REF_NAME"
|
||||
curl -sf -u "ci:$CI_REGISTRY_PASSWORD" -X PUT -H "Content-Type: $MT" \
|
||||
--data-binary @/tmp/manifest.json \
|
||||
"http://$CI_REGISTRY_PUSH/v2/${{ matrix.image }}/manifests/$GITHUB_REF_NAME"
|
||||
env:
|
||||
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
|
||||
|
||||
# Today the job container is ephemeral (the ubuntu-24.04 label is a docker://
|
||||
# image), so the credential docker login wrote would die with it anyway. Don't
|
||||
# make that a load-bearing assumption about a runner label somebody may change to
|
||||
# a host runner later.
|
||||
- name: Log out of the LAN registry
|
||||
if: always()
|
||||
run: docker logout "$CI_REGISTRY_PUSH" || true
|
||||
|
||||
# The aarch64 CROSS builder — a SEPARATE job because it is `FROM punktfunk-rust-ci:latest`
|
||||
# (the LAN copy) and so must not race the matrix entry that publishes that base. Consumed
|
||||
@@ -164,15 +232,26 @@ jobs:
|
||||
run: |
|
||||
docker build --pull \
|
||||
-f ci/rust-ci-arm64cross.Dockerfile \
|
||||
-t "$CI_REGISTRY/$IMAGE:$KEY" \
|
||||
-t "$CI_REGISTRY/$IMAGE:latest" \
|
||||
-t "$CI_REGISTRY_PUSH/$IMAGE:$KEY" \
|
||||
-t "$CI_REGISTRY_PUSH/$IMAGE:latest" \
|
||||
.
|
||||
|
||||
- name: Log in to the LAN registry
|
||||
run: |
|
||||
echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY_PUSH" -u ci --password-stdin
|
||||
env:
|
||||
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Push
|
||||
if: steps.exists.outputs.hit == 'false'
|
||||
run: |
|
||||
docker push "$CI_REGISTRY/$IMAGE:$KEY"
|
||||
docker push "$CI_REGISTRY/$IMAGE:latest"
|
||||
docker push "$CI_REGISTRY_PUSH/$IMAGE:$KEY"
|
||||
docker push "$CI_REGISTRY_PUSH/$IMAGE:latest"
|
||||
|
||||
- name: Reconcile :latest with the content key
|
||||
run: .gitea/scripts/reconcile-latest.sh "$IMAGE" "$KEY"
|
||||
env:
|
||||
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Tag for release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
@@ -182,8 +261,15 @@ jobs:
|
||||
| tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p')
|
||||
curl -sf -H "$ACCEPT" -o /tmp/manifest.json \
|
||||
"http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY"
|
||||
curl -sf -X PUT -H "Content-Type: $MT" --data-binary @/tmp/manifest.json \
|
||||
"http://$CI_REGISTRY/v2/$IMAGE/manifests/$GITHUB_REF_NAME"
|
||||
curl -sf -u "ci:$CI_REGISTRY_PASSWORD" -X PUT -H "Content-Type: $MT" \
|
||||
--data-binary @/tmp/manifest.json \
|
||||
"http://$CI_REGISTRY_PUSH/v2/$IMAGE/manifests/$GITHUB_REF_NAME"
|
||||
env:
|
||||
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Log out of the LAN registry
|
||||
if: always()
|
||||
run: docker logout "$CI_REGISTRY_PUSH" || true
|
||||
|
||||
# Deployable app images — unchanged flow, Gitea registry, per-SHA + release tags.
|
||||
apps:
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
# Nix packaging gate. Until this existed, NOTHING in CI ever evaluated flake.nix: the word "nix"
|
||||
# appeared in exactly one workflow file, and only in a comment about bun2nix breaking a Windows
|
||||
# step. Every Nix regression therefore reached main invisibly and was found by hand on a Nix box —
|
||||
# `nix build .#punktfunk-web` was broken for 553 commits before anyone noticed (see the bun-nix job
|
||||
# in ci.yml for that story).
|
||||
#
|
||||
# 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, 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
|
||||
# the generated bun.nix really does materialise a working node_modules offline — it
|
||||
# covers what the ci.yml drift gate cannot, e.g. a tarball the registry no longer
|
||||
# serves, or the codegen going quietly message-less (see packages.nix's inlang note).
|
||||
#
|
||||
# The Rust packages (punktfunk-host, punktfunk-client) and punktfunk-gamescope are NOT built here.
|
||||
# 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.
|
||||
#
|
||||
# ⚠ 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
|
||||
# workflow parser is not a place to bet on anchor support. Keep them in step by hand.
|
||||
name: nix
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "packaging/nix/**"
|
||||
- "**/bun.lock"
|
||||
- "**/bun.nix"
|
||||
- "**/package.json"
|
||||
- "Cargo.lock"
|
||||
- "Cargo.toml"
|
||||
- "rust-toolchain.toml"
|
||||
- ".gitea/workflows/nix.yml"
|
||||
- "scripts/ci/check-bun-nix.sh"
|
||||
pull_request:
|
||||
paths:
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "packaging/nix/**"
|
||||
- "**/bun.lock"
|
||||
- "**/bun.nix"
|
||||
- "**/package.json"
|
||||
- "Cargo.lock"
|
||||
- "Cargo.toml"
|
||||
- "rust-toolchain.toml"
|
||||
- ".gitea/workflows/nix.yml"
|
||||
- "scripts/ci/check-bun-nix.sh"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build-rust:
|
||||
description: "Also build punktfunk-host + punktfunk-client (slow: full Rust workspace)"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
flake:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
# NOT nixos/nix. That image contains nix and essentially nothing else — in particular no
|
||||
# /bin/sleep, and Gitea's act_runner starts every job container with
|
||||
# `entrypoint=["/bin/sleep","10800"]`. The container therefore never starts:
|
||||
# failed to create shim task: OCI runtime create failed: unable to start container
|
||||
# process: exec: "/bin/sleep": stat /bin/sleep: no such file or directory
|
||||
# and — the part that makes this expensive to debug — every step is then reported as
|
||||
# `cancelled` rather than failed, which reads exactly like a superseded run.
|
||||
#
|
||||
# node:22-bookworm instead: a full Debian with coreutils (so the entrypoint exists) and a
|
||||
# real node (so actions/checkout works with no pre-checkout install dance), and audit.yml
|
||||
# already pulls it on this fleet, so it is proven to resolve here. Nix is installed below.
|
||||
image: node:22-bookworm
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
# The flake needs both experimental features. Also baked into the installer's --extra-conf
|
||||
# below; this covers any step that shells out before that config is read.
|
||||
NIX_CONFIG: "experimental-features = nix-command flakes"
|
||||
# Absolute path rather than $GITHUB_PATH: one less runner behaviour to assume.
|
||||
NIX: /nix/var/nix/profiles/default/bin/nix
|
||||
# `--init none` installs Nix with NO daemon running, but the installer still writes a profile
|
||||
# script that exports NIX_REMOTE=daemon. Anything that sources it (any `-l` login shell) then
|
||||
# dies on `cannot connect to socket at '/nix/var/nix/daemon-socket/socket'` — which is exactly
|
||||
# how the installer's own self-test fails during this step, harmlessly, and would be a
|
||||
# confusing first thing to read in the log. The steps below never source that profile, but pin
|
||||
# the empty value so a future step cannot reintroduce it. Empty = talk to the local store
|
||||
# directly, which works because the job runs as root (MEASURED: "Store URL: local, Trusted: 1",
|
||||
# and a real `nix build` of a trivial derivation succeeds).
|
||||
NIX_REMOTE: ""
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# The Determinate installer needs curl + xz; git so nix can read the flake from the checkout.
|
||||
# (node:22-bookworm is the full image and already has all three — this is belt-and-braces
|
||||
# against a future slim-image swap, and costs one cached apt call.)
|
||||
- name: Installer prerequisites
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl xz-utils git
|
||||
|
||||
# `--init none` is the container mode: no systemd, no daemon. Running as root, nix then talks
|
||||
# to the store directly. Determinate Nix is also what the Nix box (.21) runs, so CI and the
|
||||
# hand-verification box stay on the same distribution.
|
||||
- name: Install Nix
|
||||
run: |
|
||||
curl -fsSL https://install.determinate.systems/nix -o /tmp/nix-installer.sh
|
||||
sh /tmp/nix-installer.sh install linux --init none --no-confirm \
|
||||
--extra-conf "experimental-features = nix-command flakes"
|
||||
"$NIX" --version
|
||||
|
||||
# Nix reads the flake through libgit2 and refuses a checkout owned by another uid
|
||||
# ("detected dubious ownership"), which is the normal case for a container job.
|
||||
- name: Trust the checkout
|
||||
run: git config --global --add safe.directory "$PWD"
|
||||
|
||||
# Diagnostics. This fleet ran a runner out of disk on 2026-08-06 (the ci.yml `web` job died
|
||||
# with "no space left on device" mid-`bun install`), and a Nix build is the heaviest thing
|
||||
# here — so record the headroom, or a future failure is a guess.
|
||||
- name: Environment
|
||||
run: df -h / /nix /tmp || true
|
||||
|
||||
# Evaluates + instantiates every flake output without building any of it.
|
||||
- name: nix flake check (eval only)
|
||||
run: |
|
||||
"$NIX" flake check --no-build --show-trace
|
||||
|
||||
# The bun packages, built for real. This is the leg that would have caught the stale
|
||||
# web/bun.nix end to end: the derivation's offline `bun install` runs against a store cache
|
||||
# built strictly from bun.nix, so a lockfile that cache does not cover fails here.
|
||||
# Path-filtered, so it runs only when the packaging or a lockfile actually moves. If it ever
|
||||
# starts going red on runner disk rather than on real defects, demote it to the dispatch
|
||||
# opt-in below rather than leaving an infra-red gate on the board.
|
||||
- name: Build the bun packages
|
||||
run: |
|
||||
"$NIX" build --print-build-logs .#punktfunk-web .#punktfunk-scripting
|
||||
|
||||
# Both launchers exec pkgs.bun from the store; confirm they were produced and are real entry
|
||||
# points rather than dangling wrappers.
|
||||
- name: Smoke the built launchers
|
||||
run: |
|
||||
set -eu
|
||||
web=$("$NIX" path-info .#punktfunk-web)
|
||||
scripting=$("$NIX" path-info .#punktfunk-scripting)
|
||||
test -x "$web/bin/punktfunk-web-server" || { echo "no punktfunk-web-server in $web" >&2; exit 1; }
|
||||
test -x "$scripting/bin/punktfunk-scripting" || { echo "no punktfunk-scripting in $scripting" >&2; exit 1; }
|
||||
# The console must be the bun bundle, not a node one — the same assertion packages.nix
|
||||
# makes at build time, re-checked on the installed output.
|
||||
grep -q 'Bun\.serve' "$web/share/punktfunk-web/.output/server/index.mjs" \
|
||||
|| { echo "installed console is not a bun bundle" >&2; exit 1; }
|
||||
echo "bun packages OK: $web $scripting"
|
||||
|
||||
# Opt-in only: the full Rust workspace through crane, which is the hour-long leg.
|
||||
# `github.event.inputs.*` (string) rather than `inputs.*` — the portable spelling.
|
||||
- name: Build the Rust packages (dispatch opt-in)
|
||||
if: ${{ github.event.inputs.build-rust == 'true' }}
|
||||
run: |
|
||||
"$NIX" build --print-build-logs .#punktfunk-host .#punktfunk-client
|
||||
@@ -38,10 +38,18 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# Pinned syft (keep in sync with the version validated against this repo; bump deliberately).
|
||||
#
|
||||
# The BINARY version was pinned; the INSTALLER was not — it was fetched from `main` and piped
|
||||
# into a shell, so whatever that branch happened to say at job time ran here, with the job's
|
||||
# environment (2026-08-05 review H-6). Pinning the script to the same tag as the binary makes
|
||||
# the whole step reproducible: bump the tag in both places together.
|
||||
- name: Install syft
|
||||
env:
|
||||
SYFT_VERSION: v1.49.0
|
||||
run: |
|
||||
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \
|
||||
| sh -s -- -b /usr/local/bin v1.49.0
|
||||
set -euo pipefail
|
||||
curl -sSfL "https://raw.githubusercontent.com/anchore/syft/${SYFT_VERSION}/install.sh" \
|
||||
| sh -s -- -b /usr/local/bin "$SYFT_VERSION"
|
||||
- name: Generate SBOM
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
|
||||
Generated
+19
@@ -2893,6 +2893,7 @@ dependencies = [
|
||||
"ureq",
|
||||
"wasapi",
|
||||
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3346,6 +3347,8 @@ dependencies = [
|
||||
"opus",
|
||||
"punktfunk-core",
|
||||
"tracing",
|
||||
"uac-host",
|
||||
"usbfs-iso",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4985,6 +4988,14 @@ version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "uac-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/unom-io/usbfs-iso?rev=f3de1fd62cec271d07f45664dc464f23e423e721#f3de1fd62cec271d07f45664dc464f23e423e721"
|
||||
dependencies = [
|
||||
"usbfs-iso",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
@@ -5064,6 +5075,14 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "usbfs-iso"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/unom-io/usbfs-iso?rev=f3de1fd62cec271d07f45664dc464f23e423e721#f3de1fd62cec271d07f45664dc464f23e423e721"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "usbip-sim"
|
||||
version = "0.8.0"
|
||||
|
||||
+1
-1
@@ -186,7 +186,7 @@
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 unom
|
||||
Copyright 2026 unom - Enrico Bühler
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 unom
|
||||
Copyright (c) 2026 unom - Enrico Bühler
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
THIRD-PARTY SOFTWARE NOTICES
|
||||
============================================================================
|
||||
|
||||
punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0.
|
||||
Punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0.
|
||||
The binaries it ships statically/dynamically link the third-party Rust crates listed
|
||||
below. Each is distributed under its own permissive license; the full license texts
|
||||
follow the manifest. This file is generated by scripts/gen-third-party-notices.py
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
THIRD-PARTY SOFTWARE NOTICES
|
||||
============================================================================
|
||||
|
||||
punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0.
|
||||
Punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0.
|
||||
The binaries it ships statically/dynamically link the third-party Rust crates below.
|
||||
Each is distributed under its own permissive license; full texts follow.
|
||||
Generated by `cargo about generate about.hbs` (see about.toml) — do not edit by hand.
|
||||
|
||||
+157
-9
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.23.0"
|
||||
"version": "0.24.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -1052,7 +1052,7 @@
|
||||
"library"
|
||||
],
|
||||
"summary": "Fetch one cover-art image for a library entry",
|
||||
"description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. For a Steam title, the host's own local Steam cache is tried first (exact —\nit's what the user's Steam client already shows for it), the public Steam CDN's flat URL\nconvention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host\ncan't predict, in which case this 404s and the client falls through to its next art candidate).\nOnly Steam ids are backed today; any other store 404s.",
|
||||
"description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries,\nand a library plugin's claimed-store entries) serves its local art file. A Steam title falls back\nto the in-host scanner's resolver: the host's own local Steam cache first (exact — it's what the\nuser's Steam client already shows for it), the public Steam CDN's flat URL convention second\n(newer titles' CDN assets can live at a per-asset-hash path the host can't predict, in which case\nthis 404s and the client falls through to its next art candidate).",
|
||||
"operationId": "getLibraryArt",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1307,7 +1307,7 @@
|
||||
"library"
|
||||
],
|
||||
"summary": "Replace a provider's library entries (declarative reconcile)",
|
||||
"description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.",
|
||||
"description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.\n\n`?store=` additionally **claims** that store for the provider: its entries then surface with\ndeterministic `<store>:<external_id>` ids and the store's own badge, instead of opaque\n`custom:<id>` ones — which is what lets a library plugin reproduce the entries an in-host scanner\nused to produce, right down to the GameStream app ids and client-side art caches. One provider\nper store; a second claimant gets 409. While a claim is held the matching built-in scanner is\nsuppressed, so the two never double-list. The claim is released by `DELETE`, not by an empty\nreconcile (a store can legitimately have zero installed titles).",
|
||||
"operationId": "reconcileProviderEntries",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1318,6 +1318,15 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "store",
|
||||
"in": "query",
|
||||
"description": "Claim this store for the provider ([a-z0-9_-], `custom`/`manual` reserved)",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
@@ -1348,7 +1357,7 @@
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid provider id or payload",
|
||||
"description": "Invalid provider id, store id, or payload",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -1367,6 +1376,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "That store is already claimed by another provider",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Could not persist the catalog",
|
||||
"content": {
|
||||
@@ -4159,7 +4178,8 @@
|
||||
"tier",
|
||||
"platforms",
|
||||
"compatible",
|
||||
"update_available"
|
||||
"update_available",
|
||||
"categories"
|
||||
],
|
||||
"properties": {
|
||||
"author": {
|
||||
@@ -4172,6 +4192,13 @@
|
||||
],
|
||||
"description": "A revocation covering the catalogued version — do not offer this without shouting."
|
||||
},
|
||||
"categories": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "What kind of plugin this is — the console filters Browse by these, and the Game sources\nsurface's \"Add a source\" rail shows exactly the `library` ones (design D5/D6)."
|
||||
},
|
||||
"compatible": {
|
||||
"type": "boolean",
|
||||
"description": "Can this host install it?"
|
||||
@@ -4179,6 +4206,13 @@
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"detected": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
],
|
||||
"description": "Whether the launcher this plugin scans looks **installed on this host** (design D8), from the\nindex's own existence probes. `null` = the entry declares no probes for this platform, which\nthe console renders as \"unknown\" rather than \"not installed\"."
|
||||
},
|
||||
"homepage": {
|
||||
"type": [
|
||||
"string",
|
||||
@@ -4365,6 +4399,17 @@
|
||||
],
|
||||
"description": "The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous)."
|
||||
},
|
||||
"role": {
|
||||
"$ref": "#/components/schemas/GameRole",
|
||||
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]."
|
||||
},
|
||||
"store": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The **store this entry was claimed under** (D2), stamped by a `?store=`-qualified reconcile.\n`None` = an unclaimed provider entry or a manual one, both of which surface as `custom`.\n\nMaterialized onto the entry rather than looked up in [`Catalog::claims`] on every read so an\nentry is self-describing: its id and its `store` badge derive from the entry alone, and stay\ncorrect even while the claim map is being rewritten."
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -4409,6 +4454,10 @@
|
||||
},
|
||||
"description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config."
|
||||
},
|
||||
"role": {
|
||||
"$ref": "#/components/schemas/GameRole",
|
||||
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]. A hand-added launcher\nentry is legal (an operator may want a \"Steam\" tile without installing the steam plugin)."
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -4467,6 +4516,17 @@
|
||||
"type": "object",
|
||||
"description": "What an operator (or a provider plugin) can tell the host about recognizing a title — the wire\nhalf of [`DetectSpec`], and the only part of it that is ever accepted from outside.\n\nDeliberately a **subset**: the store-derived signals (a Steam appid, a launcher's environment\nmarker) are things the host discovers for itself and would be meaningless — or dangerous — to take\non someone's word. What is left is what a provider genuinely knows and the host cannot guess: where\nthe title is installed, which executable is the game, what the process is called. All three are\noptional; supplying none is the same as supplying no hint at all.\n\nNever returned by the catalog API — see the module docs on why detect data does not cross the wire\noutbound.",
|
||||
"properties": {
|
||||
"env_marker": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EnvMarker",
|
||||
"description": "A launcher-stamped environment marker (D3) — see [`EnvMarker`]."
|
||||
}
|
||||
]
|
||||
},
|
||||
"exe": {
|
||||
"type": [
|
||||
"string",
|
||||
@@ -4487,6 +4547,15 @@
|
||||
"null"
|
||||
],
|
||||
"description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]."
|
||||
},
|
||||
"steam_appid": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int32",
|
||||
"description": "The Steam appid, for a title Steam itself installed (D3). On Linux this is the **sharpest**\nsignal that exists — Steam wraps every launch, native or Proton, in\n`reaper SteamLaunch AppId=<appid>`, whose lifetime is exactly the game's — so without it a\nsteam plugin's lease tracking would degrade from reaper-exact to install-dir prefix matching.",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4715,6 +4784,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"EnvMarker": {
|
||||
"type": "object",
|
||||
"description": "An environment variable a launcher stamps onto the game's process, identifying it.\n\nSerializable because it is now half of the inbound [`DetectHint`] too (D3) — a library plugin\nthat knows its launcher's marker (Heroic's `HEROIC_APP_NAME`, load-bearing under Proton) has to\nbe able to say so, since after extraction the host no longer reads that launcher's files itself.",
|
||||
"required": [
|
||||
"key"
|
||||
],
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "The variable name (e.g. `HEROIC_GAME_ID`).",
|
||||
"example": "HEROIC_APP_NAME"
|
||||
},
|
||||
"value": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The exact value to require, when the launcher's value identifies *this* title. `None` matches\nthe key's mere presence — only safe for launchers that run one game at a time."
|
||||
}
|
||||
}
|
||||
},
|
||||
"EventKind": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -5165,6 +5255,10 @@
|
||||
],
|
||||
"description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it."
|
||||
},
|
||||
"role": {
|
||||
"$ref": "#/components/schemas/GameRole",
|
||||
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]."
|
||||
},
|
||||
"store": {
|
||||
"type": "string",
|
||||
"description": "Which store surfaced it: `\"steam\"` or `\"custom\"`.",
|
||||
@@ -5296,6 +5390,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"GameRole": {
|
||||
"type": "string",
|
||||
"description": "What a library entry *is* — an ordinary title, or the launcher application itself (Steam Big\nPicture, Heroic, Playnite fullscreen). Purely a presentation hint: a launcher entry launches,\nleases and lists exactly like a game (design D4), and clients that don't know the field render it\nas a plain tile. Serde-default `game` and skip-serialized when default, so the wire is unchanged\nfor every entry that doesn't opt in.",
|
||||
"enum": [
|
||||
"game",
|
||||
"launcher"
|
||||
]
|
||||
},
|
||||
"GameSession": {
|
||||
"type": "string",
|
||||
"description": "How a session that **launches a game** (a library id on the Hello / apps.json / Decky pin) is\nserved (`design/gamemode-and-dedicated-sessions.md` §5.2). Orthogonal to the preset/lifecycle axes\n— a top-level [`DisplayPolicy`] field, NOT part of [`EffectivePolicy`], so a preset never clobbers\nit. Linux-only in effect (a launching Windows session opens into the one desktop).",
|
||||
@@ -6334,6 +6436,13 @@
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "What KIND of plugin this is (`^[a-z][a-z0-9-]{0,31}$`), top-level rather than under `ui`\nbecause it describes the plugin, not its surface. The console knows one value today —\n`library` — which it filters **out of the nav**: six installed scanner plugins would otherwise\nflood the sidebar, and their real entry point is the Game sources surface (design D5). A\nlibrary plugin that genuinely wants its own page (rom-manager, which is much more than a\nscanner) simply omits the category."
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Human-readable title for the console nav entry (1–64 chars; control chars stripped)."
|
||||
@@ -6366,6 +6475,13 @@
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The plugin's kind — see [`PluginRegistration::category`]."
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -6604,6 +6720,10 @@
|
||||
},
|
||||
"description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config."
|
||||
},
|
||||
"role": {
|
||||
"$ref": "#/components/schemas/GameRole",
|
||||
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]. A library plugin\nemits its `launchers(cfg)` entries with `role: \"launcher\"`."
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -6780,26 +6900,46 @@
|
||||
},
|
||||
"ScannerInfo": {
|
||||
"type": "object",
|
||||
"description": "One installed-store scanner this host build supports, with its enable state — the unit the\nconsole renders a toggle for. The list is platform-gated at compile time (the scanners are),\nso the console never shows a toggle that cannot do anything on this host.",
|
||||
"description": "One **game source** on this host, with its enable state — the unit the console renders a toggle\nfor. A source is either a scanner compiled into this build or a plugin that reconciles entries in\n(WP2.6); the console treats them identically, which is what makes the extraction invisible.",
|
||||
"required": [
|
||||
"id",
|
||||
"label",
|
||||
"enabled"
|
||||
"enabled",
|
||||
"origin"
|
||||
],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether this host runs the scanner (default true)."
|
||||
"description": "Whether this host runs the source (default true)."
|
||||
},
|
||||
"entries": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"description": "How many entries this source currently contributes. `None` for a built-in scanner, whose\ncount would mean walking every launcher's files just to render a toggle.",
|
||||
"minimum": 0
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Stable scanner id — the same string the scanner's entries carry in their `store` field.",
|
||||
"description": "Stable source id — the same string this source's entries carry in their `store` field. For a\nplugin source it is also its provider id and its store claim: one string, by construction, so\na user's disabled state survives a built-in scanner being replaced by its plugin.",
|
||||
"example": "steam"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "Human-facing name for the console toggle.",
|
||||
"example": "Steam"
|
||||
},
|
||||
"origin": {
|
||||
"$ref": "#/components/schemas/SourceOrigin",
|
||||
"description": "Where the source comes from: `builtin` (a scanner in this host build) or `plugin`."
|
||||
},
|
||||
"provider": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The provider id backing a `plugin` source — absent for a built-in scanner."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -6962,6 +7102,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SourceOrigin": {
|
||||
"type": "string",
|
||||
"description": "Where a [`ScannerInfo`] comes from.",
|
||||
"enum": [
|
||||
"builtin",
|
||||
"plugin"
|
||||
]
|
||||
},
|
||||
"SourceView": {
|
||||
"type": "object",
|
||||
"description": "A configured catalog source and how its last refresh went.",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
THIRD-PARTY SOFTWARE NOTICES
|
||||
============================================================================
|
||||
|
||||
punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0.
|
||||
Punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0.
|
||||
The binaries it ships statically/dynamically link the third-party Rust crates listed
|
||||
below. Each is distributed under its own permissive license; the full license texts
|
||||
follow the manifest. This file is generated by scripts/gen-third-party-notices.py
|
||||
|
||||
@@ -31,6 +31,7 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -47,6 +48,7 @@ import android.widget.Toast
|
||||
import io.unom.punktfunk.kit.link.DeepLinkResult
|
||||
import io.unom.punktfunk.kit.link.DeepLinks
|
||||
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.Tab
|
||||
@@ -61,6 +63,11 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
// so the stream screen never re-reads the store behind its own connect's back.
|
||||
var session by remember { mutableStateOf<ActiveSession?>(null) }
|
||||
var tab by remember { mutableStateOf(Tab.Connect) }
|
||||
// Set when a session ends because its game exited and it began as a library launch: the host
|
||||
// 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 reopenLibraryHostId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// 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.
|
||||
@@ -98,6 +105,15 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
}
|
||||
}
|
||||
|
||||
// The console backdrop's colour family, published once from the live settings rather than
|
||||
// threaded through every screen that draws a backdrop. Because it is read from the SAME
|
||||
// `settings` state the gamepad settings screen writes, stepping the Background row recolours
|
||||
// the field behind that very row.
|
||||
val palette = GamepadPalette.named(settings.uiPalette)
|
||||
CompositionLocalProvider(
|
||||
LocalGamepadPalette provides palette,
|
||||
LocalGamepadInk provides GamepadInk.of(palette),
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = session,
|
||||
transitionSpec = {
|
||||
@@ -107,7 +123,20 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
) { active ->
|
||||
if (active != null) {
|
||||
// Immersive: the stream takes the whole screen, no bottom bar.
|
||||
StreamScreen(active, onDisconnect = { session = null })
|
||||
StreamScreen(active) { reason ->
|
||||
// A game launched from a library exiting is a normal finish, and the player is
|
||||
// almost certainly after the next title — so send them back to that library rather
|
||||
// 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.
|
||||
reopenLibraryHostId =
|
||||
if (reason == SessionEndReason.GAME_EXITED && active.launchedFromLibrary) {
|
||||
active.hostId
|
||||
} else {
|
||||
null
|
||||
}
|
||||
session = null
|
||||
}
|
||||
} else if (gamepadUi) {
|
||||
GamepadShell(
|
||||
settings = settings,
|
||||
@@ -115,6 +144,8 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
onConnected = { session = it },
|
||||
deepLink = pendingLink,
|
||||
onDeepLinkHandled = { activity?.pendingDeepLink = null },
|
||||
reopenLibraryHostId = reopenLibraryHostId,
|
||||
onReopenLibraryHandled = { reopenLibraryHostId = null },
|
||||
)
|
||||
} else {
|
||||
// Adaptive nav: a bottom bar on phones; on tablets / large windows a side NavigationRail
|
||||
@@ -201,8 +232,16 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The console backdrop's colour family for everything under [App] — provided from the live
|
||||
* settings so a change on the gamepad settings screen recolours every backdrop at once. Defaults
|
||||
* to the brand violet, which is also what a preview or a test composition gets.
|
||||
*/
|
||||
val LocalGamepadPalette = compositionLocalOf { GamepadPalette.named("violet") }
|
||||
|
||||
/** Which console screen the gamepad shell is showing. */
|
||||
private enum class GamepadScreen { Home, Settings, Library }
|
||||
|
||||
@@ -218,11 +257,32 @@ fun GamepadShell(
|
||||
onConnected: (ActiveSession) -> Unit,
|
||||
deepLink: String? = null,
|
||||
onDeepLinkHandled: () -> Unit = {},
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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) }
|
||||
|
||||
// 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(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 }
|
||||
?.let { libraryHost = it; screen = GamepadScreen.Library }
|
||||
onReopenLibraryHandled()
|
||||
}
|
||||
|
||||
// On a TV, shrink the 10-foot UI so its elements aren't oversized. Density-aware: expand the
|
||||
// effective dp footprint to at least CONSOLE_TV_MIN_WIDTH_DP (→ smaller elements) ONLY when the
|
||||
// panel reports fewer dp than that; a low-density TV that's already spacious, and every phone /
|
||||
|
||||
@@ -168,9 +168,9 @@ internal fun LocalNetworkDialog(onAllow: () -> Unit, onSettings: () -> Unit, onD
|
||||
title = { Text("Allow local network access") },
|
||||
text = {
|
||||
Text(
|
||||
"Android blocks punktfunk from talking to devices on your network, so it can't " +
|
||||
"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.",
|
||||
"Allow, enable “Nearby devices” for Punktfunk in system settings.",
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
|
||||
@@ -191,6 +191,7 @@ internal fun ConnectTakeover(
|
||||
onCancel: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val copy = connectCopy(phase)
|
||||
val timedOut = phase is ConnectPhase.WakeTimedOut
|
||||
|
||||
@@ -212,7 +213,7 @@ internal fun ConnectTakeover(
|
||||
Icon(
|
||||
Icons.Filled.Bedtime,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.9f),
|
||||
tint = ink.fg(0.9f),
|
||||
modifier = Modifier.size(46.dp),
|
||||
)
|
||||
}
|
||||
@@ -221,14 +222,14 @@ internal fun ConnectTakeover(
|
||||
}
|
||||
Text(
|
||||
copy.title,
|
||||
color = Color.White,
|
||||
color = ink.fg,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 24.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
copy.subtitle,
|
||||
color = Color.White.copy(alpha = 0.65f),
|
||||
color = ink.fg(0.65f),
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
fontFamily = if (copy.monoSubtitle) FontFamily.Monospace else FontFamily.Default,
|
||||
@@ -249,6 +250,7 @@ internal fun ConnectTakeover(
|
||||
*/
|
||||
@Composable
|
||||
private fun PulsingSpinner() {
|
||||
val ink = LocalGamepadInk.current
|
||||
val transition = rememberInfiniteTransition(label = "connectPulse")
|
||||
val pulse by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
@@ -262,14 +264,14 @@ private fun PulsingSpinner() {
|
||||
for (i in 0..1) {
|
||||
val p = (pulse + i * 0.5f) % 1f
|
||||
drawCircle(
|
||||
color = Color(0xFF8678F5).copy(alpha = (1f - p) * 0.35f),
|
||||
color = ink.accent.copy(alpha = (1f - p) * 0.35f),
|
||||
radius = maxR * (0.42f + p * 0.58f),
|
||||
style = Stroke(width = 2.dp.toPx()),
|
||||
)
|
||||
}
|
||||
}
|
||||
CircularProgressIndicator(
|
||||
color = Color.White,
|
||||
color = ink.fg,
|
||||
strokeWidth = 3.dp,
|
||||
modifier = Modifier.size(54.dp),
|
||||
)
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
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
|
||||
@@ -168,8 +171,7 @@ fun ConnectScreen(
|
||||
lnpPrompt = false
|
||||
// The browse started while blocked (its sockets failed or received nothing) — restart it
|
||||
// now that the grant makes them work.
|
||||
discovery.stop()
|
||||
discovery.start()
|
||||
discovery.restart()
|
||||
} else {
|
||||
lnpPrompt = true // rationale + "Open settings" (a permanently-denied request returns instantly)
|
||||
}
|
||||
@@ -191,12 +193,27 @@ fun ConnectScreen(
|
||||
// or otherwise notify the app — this observer is what turns the grant into a live discovery.
|
||||
DisposableEffect(Unit) {
|
||||
val lifecycle = (context as? LifecycleOwner)?.lifecycle
|
||||
// Whether we've actually been away. ON_RESUME also fires on first entry, right after the
|
||||
// effect below starts the browse — restarting it there would be pure churn.
|
||||
var wasPaused = false
|
||||
val obs = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME && !lnpGranted && hasLocalNetworkPermission(context)) {
|
||||
lnpGranted = true
|
||||
lnpPrompt = false
|
||||
discovery.stop()
|
||||
discovery.start()
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_PAUSE -> wasPaused = true
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
if (!lnpGranted && hasLocalNetworkPermission(context)) {
|
||||
lnpGranted = true
|
||||
lnpPrompt = false
|
||||
discovery.restart()
|
||||
} else if (wasPaused) {
|
||||
// Coming back from the background: the browse may have been sitting idle
|
||||
// (or had its multicast socket torn out from under it) while we were away,
|
||||
// and its own re-query interval has kept doubling. Re-arm and ask again,
|
||||
// so returning to the screen is enough — no app restart.
|
||||
discovery.restart()
|
||||
}
|
||||
wasPaused = false
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
lifecycle?.addObserver(obs)
|
||||
@@ -608,6 +625,32 @@ fun ConnectScreen(
|
||||
savedHosts = knownHostStore.all()
|
||||
}
|
||||
|
||||
// "Copy link" — the self-emitted form every other client already hands out
|
||||
// (design/client-deep-links.md §4): the host's STABLE id first, with `host=` and `fp=` alongside,
|
||||
// so a link written today still lands on the right box after the host changes address or this
|
||||
// client is reinstalled. A PINNED card copies its own profile with it, because that combination
|
||||
// is the thing being copied; a host card copies no profile at all and so keeps honouring the
|
||||
// host's binding, exactly like a tap on it does.
|
||||
fun copyLink(kh: KnownHost, pin: StreamProfile?) {
|
||||
val url = DeepLinks.forHost(kh, profile = pin?.id).toUrl()
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
|
||||
val copied = clipboard != null && runCatching {
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("Punktfunk link", url))
|
||||
}.isSuccess
|
||||
// Android 13 draws its own clipboard confirmation, and stacking a second one on top of it is
|
||||
// the platform's own documented anti-pattern. Below it nothing visible happens at all unless
|
||||
// we say so — a silent menu item reads as a broken one.
|
||||
if (copied && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return
|
||||
val message = if (copied) "Link copied." else "Couldn't copy the link to the clipboard."
|
||||
// The console home renders neither the notice nor the status banner, so there it has to be a
|
||||
// toast; the touch grid has both, and a success dressed as an error banner is a small lie.
|
||||
when {
|
||||
gamepadUi -> Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
copied -> notice = message
|
||||
else -> status = message
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -616,6 +659,7 @@ fun ConnectScreen(
|
||||
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) })
|
||||
@@ -897,7 +941,7 @@ fun ConnectScreen(
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Text(
|
||||
"Android blocks punktfunk from finding or reaching hosts until you allow it.",
|
||||
"Android blocks Punktfunk from finding or reaching hosts until you allow it.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -1009,20 +1053,28 @@ fun ConnectScreen(
|
||||
// rather than looking idle/empty. Suppressed while local network access is denied —
|
||||
// a spinner would be a lie there (the browse can't receive anything); the banner above
|
||||
// owns that state.
|
||||
if (lnpGranted && !connecting && discovered.isEmpty()) {
|
||||
// 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,
|
||||
) {
|
||||
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,
|
||||
)
|
||||
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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1140,6 +1192,7 @@ fun ConnectScreen(
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onCopyLink = { optionsTarget = null; copyLink(kh, pin) },
|
||||
onEdit = { optionsTarget = null; editTarget = kh },
|
||||
onForget = {
|
||||
knownHostStore.remove(kh)
|
||||
|
||||
@@ -191,7 +191,7 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
||||
dsUsb?.let { DsRow(it) }
|
||||
if (pads.isEmpty() && !sc2Present) {
|
||||
Text(
|
||||
"No controller detected. punktfunk can only forward devices Android " +
|
||||
"No controller detected. Punktfunk can only forward devices Android " +
|
||||
"classifies as a gamepad or joystick — a pad connected through an adapter " +
|
||||
"or hub may show up under \"Other input devices\" below with the adapter's " +
|
||||
"identity, or not at all.",
|
||||
@@ -410,17 +410,68 @@ private fun DsRow(usbDev: android.hardware.usb.UsbDevice) {
|
||||
Text("Grant USB access")
|
||||
}
|
||||
}
|
||||
else -> Text(
|
||||
if (model == DsDevice.Model.DUALSHOCK4) {
|
||||
"Ready — captured at stream start: rumble, lightbar and gyro are " +
|
||||
"driven directly."
|
||||
} else {
|
||||
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
|
||||
"and gyro are driven directly."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
else -> {
|
||||
Text(
|
||||
if (model == DsDevice.Model.DUALSHOCK4) {
|
||||
"Ready — captured at stream start: rumble, lightbar and gyro are " +
|
||||
"driven directly."
|
||||
} else {
|
||||
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
|
||||
"and gyro are driven directly."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// Pad-audio self test. Deliberately reachable WITHOUT a stream: it exists to
|
||||
// answer "can this phone drive this pad's audio endpoint at all", and gating
|
||||
// that behind a live session would make it depend on the very thing one wants
|
||||
// to rule out when a session misbehaves. DualSense only — the DS4 has no
|
||||
// 4-channel haptics device.
|
||||
if (model != DsDevice.Model.DUALSHOCK4) {
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
var result by remember { mutableStateOf<String?>(null) }
|
||||
result?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
OutlinedButton(
|
||||
enabled = !testing,
|
||||
onClick = {
|
||||
testing = true
|
||||
result = null
|
||||
Thread({
|
||||
// Its OWN connection: the renderer's descriptor must never be
|
||||
// shared with another transfer engine, and that applies to
|
||||
// this test as much as to the real path.
|
||||
val conn = runCatching { usbManager.openDevice(usbDev) }.getOrNull()
|
||||
val fd = conn?.fileDescriptor ?: -1
|
||||
val r = if (fd >= 0) {
|
||||
io.unom.punktfunk.kit.NativeBridge.nativePadAudioSelfTest(fd, 3, 60)
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
conn?.close()
|
||||
val msg = when {
|
||||
r > 0 -> "Haptics test passed — $r frames to the pad."
|
||||
r == -1 -> "Could not open the pad's audio interface. " +
|
||||
"Some kernels refuse it; the pad still works normally."
|
||||
r == -2 -> "The audio stream stopped part-way."
|
||||
else -> "The stream opened but no audio reached the pad."
|
||||
}
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||
result = msg
|
||||
testing = false
|
||||
}
|
||||
}, "pf-pad-selftest-ui").start()
|
||||
},
|
||||
) {
|
||||
Text(if (testing) "Testing…" else "Test haptics")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ fun GamepadAddHostScreen(
|
||||
suggestedMacs: List<String> = emptyList(),
|
||||
onSave: ((KnownHost) -> Unit)? = null,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val context = LocalContext.current
|
||||
val isTv = remember { isTvDevice(context) }
|
||||
val isEdit = editHost != null
|
||||
@@ -245,7 +246,7 @@ fun GamepadAddHostScreen(
|
||||
Text(
|
||||
"Hosts on this network appear automatically — add one by address for everything else.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White.copy(alpha = 0.55f),
|
||||
color = ink.fg(0.55f),
|
||||
modifier = Modifier.widthIn(max = 520.dp).padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
@@ -306,6 +307,7 @@ private fun TvAddHostForm(
|
||||
onAdd: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
BackHandler(onBack = onDismiss)
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
@@ -319,11 +321,11 @@ private fun TvAddHostForm(
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = ink.fg)
|
||||
Text(
|
||||
"Hosts on this network appear automatically — add one by address for everything else.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White.copy(alpha = 0.55f),
|
||||
color = ink.fg(0.55f),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = name, onValueChange = onName, singleLine = true,
|
||||
@@ -362,6 +364,7 @@ private fun rowCols(row: Int): Int = if (row < KB_ACTIONS_ROW) KB_CHAR_ROWS[row]
|
||||
|
||||
@Composable
|
||||
private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -> Unit) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val visuals = animateConsoleFocus(active = focused || editing, editing = editing)
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
Row(
|
||||
@@ -375,25 +378,26 @@ private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(f.label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||
Text(f.label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = ink.fg)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(
|
||||
f.value.ifEmpty { f.placeholder },
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||
color = if (f.value.isEmpty()) Color.White.copy(alpha = 0.35f) else Color.White,
|
||||
color = if (f.value.isEmpty()) ink.fg(0.35f) else ink.fg,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (editing) Text(" |", color = Color(0xFF8678F5))
|
||||
if (editing) Text(" |", color = ink.accent)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
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) Color(0xFF8678F5) else Color.White.copy(alpha = 0.35f),
|
||||
if (enabled) ink.accent else ink.fg(0.35f),
|
||||
tween(160),
|
||||
label = "addLabel",
|
||||
)
|
||||
@@ -425,6 +429,7 @@ private fun KeyboardGrid(
|
||||
bottomInset: Dp = 0.dp, // empty frame at the bottom of the glass for the floating legend to sit over
|
||||
onKey: (Int, Int) -> Unit,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val shape = RoundedCornerShape(20.dp)
|
||||
val gap = if (compact) 5.dp else 7.dp
|
||||
Column(
|
||||
@@ -433,7 +438,7 @@ private fun KeyboardGrid(
|
||||
.widthIn(max = 640.dp)
|
||||
.clip(shape)
|
||||
.background(Color(0x1FFFFFFF))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.12f), shape)
|
||||
.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),
|
||||
) {
|
||||
@@ -454,14 +459,15 @@ private fun KeyboardGrid(
|
||||
|
||||
@Composable
|
||||
private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
val ink = LocalGamepadInk.current
|
||||
// Fast tweens: the keyboard cursor hops many keys per second under hold-to-repeat, so the
|
||||
// trailing key must have faded before the cursor is two keys away — quick, but no longer a snap.
|
||||
val bg by animateColorAsState(
|
||||
if (focused) Color(0xFF8678F5) else Color(0x14FFFFFF),
|
||||
if (focused) ink.accent else ink.glass,
|
||||
tween(90),
|
||||
label = "keyBg",
|
||||
)
|
||||
val fg by animateColorAsState(if (focused) Color.Black else Color.White, 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)
|
||||
|
||||
@@ -14,6 +14,8 @@ import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -23,6 +25,9 @@ import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -31,7 +36,9 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -65,9 +72,12 @@ import kotlin.math.sin
|
||||
// connected-controller status chip. One look across every screen is what makes the console UI read
|
||||
// as a coherent mode rather than a set of themed pages.
|
||||
|
||||
/** One drifting colour blob of the aurora field. Integer [sx]/[sy] keep the loop seamless at wrap. */
|
||||
/**
|
||||
* One drifting blob of the aurora 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 color: Color,
|
||||
val baseX: Float,
|
||||
val baseY: Float,
|
||||
val driftX: Float,
|
||||
@@ -80,50 +90,80 @@ private class AuroraBlob(
|
||||
)
|
||||
|
||||
private val auroraBlobs = listOf(
|
||||
AuroraBlob(Color(0xFF877AF5), 0.30f, 0.26f, 0.16f, 0.10f, 1, 1, 0.0f, 0.62f, 0.55f), // brand violet
|
||||
AuroraBlob(Color(0xFF3E33B8), 0.78f, 0.68f, 0.13f, 0.14f, 1, 2, 2.4f, 0.68f, 0.58f), // deep indigo
|
||||
AuroraBlob(Color(0xFF9E4CCC), 0.16f, 0.82f, 0.12f, 0.09f, 2, 1, 4.1f, 0.52f, 0.42f), // plum
|
||||
AuroraBlob(Color(0xFF3862DB), 0.72f, 0.14f, 0.10f, 0.08f, 1, 3, 1.2f, 0.48f, 0.40f), // cool blue
|
||||
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),
|
||||
)
|
||||
|
||||
/**
|
||||
* The living console backdrop: soft violet-family blobs drifting over black on slow, seamless loops,
|
||||
* finished with a centre-pooling vignette and top/bottom legibility scrims. A Compose approximation
|
||||
* of the Apple client's MeshGradient aurora — same brand family, same "ambience, never content" role.
|
||||
* The living console backdrop: 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.
|
||||
* A Compose approximation of the Apple client's MeshGradient aurora — same colour families, same
|
||||
* "ambience, never content" role, and the same [GamepadPalette] setting recolours both.
|
||||
*
|
||||
* [calm] is what the FORM screens 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.
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadAuroraBackground(modifier: Modifier = Modifier) {
|
||||
fun GamepadAuroraBackground(modifier: Modifier = Modifier, calm: Boolean = false) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val palette = LocalGamepadPalette.current
|
||||
val animated = animationsEnabled()
|
||||
val transition = rememberInfiniteTransition(label = "aurora")
|
||||
// A full 0..2π sweep over ~96 s; integer per-blob multipliers make sin/cos continuous at the wrap
|
||||
// so the field never visibly jumps when the animation restarts.
|
||||
val angle by transition.animateFloat(
|
||||
// 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(Color.Black)
|
||||
drawRect(ground)
|
||||
val span = max(size.width, size.height)
|
||||
for (b in auroraBlobs) {
|
||||
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(b.color.copy(alpha = b.alpha), Color.Transparent),
|
||||
colors = listOf(tones[i].copy(alpha = alpha), Color.Transparent),
|
||||
center = Offset(cx, cy),
|
||||
radius = r,
|
||||
),
|
||||
center = Offset(cx, cy),
|
||||
radius = r,
|
||||
blendMode = BlendMode.Plus,
|
||||
// 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, sink the corners.
|
||||
// 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, Color.Black.copy(alpha = 0.44f)),
|
||||
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,
|
||||
),
|
||||
@@ -131,43 +171,108 @@ fun GamepadAuroraBackground(modifier: Modifier = Modifier) {
|
||||
// Top/bottom legibility scrim for the pinned title + hint bar.
|
||||
drawRect(
|
||||
Brush.verticalGradient(
|
||||
0.0f to Color.Black.copy(alpha = 0.40f),
|
||||
0.30f to Color.Black.copy(alpha = 0.05f),
|
||||
0.70f to Color.Black.copy(alpha = 0.06f),
|
||||
1.0f to Color.Black.copy(alpha = 0.42f),
|
||||
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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The calm backdrop for the console FORM screens (settings, add-host) — deliberately still and quiet
|
||||
* (unlike the launcher's drifting aurora), a deep indigo base with two soft brand glows so the glass
|
||||
* rows have some colour + luminance to sit on. Mirrors the Apple client's GamepadFormBackground.
|
||||
* `false` when the user has turned animations off system-wide (Developer options' animator duration
|
||||
* scale, or the accessibility "Remove animations" switch, which sets the same global). Read once
|
||||
* per composition — it needs a settings trip to the system, and it changes about never.
|
||||
*/
|
||||
@Composable
|
||||
private fun animationsEnabled(): Boolean {
|
||||
val context = LocalContext.current
|
||||
return remember {
|
||||
runCatching {
|
||||
android.provider.Settings.Global.getFloat(
|
||||
context.contentResolver,
|
||||
android.provider.Settings.Global.ANIMATOR_DURATION_SCALE,
|
||||
1f,
|
||||
) != 0f
|
||||
}.getOrDefault(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The backdrop for the console FORM screens (settings, add-host). It used to be a STILL deep-indigo
|
||||
* base with two soft glows; it is now the launcher's own living field at `calm`, which keeps that
|
||||
* colour and luminance under the glass rows, honours the palette setting on every screen rather
|
||||
* than only the launcher, and leaves nothing in the console UI backed by a static image. Mirrors
|
||||
* the Apple client's GamepadFormBackground, which made the same substitution.
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadFormBackground(modifier: Modifier = Modifier) {
|
||||
Canvas(modifier) {
|
||||
val span = max(size.width, size.height)
|
||||
drawRect(Color(0xFF131126))
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(Color(0xE6635AAE), Color.Transparent),
|
||||
center = Offset(size.width * 0.24f, size.height * 0.12f),
|
||||
radius = span * 0.7f,
|
||||
),
|
||||
center = Offset(size.width * 0.24f, size.height * 0.12f),
|
||||
radius = span * 0.7f,
|
||||
)
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(Color(0xBF343E96), Color.Transparent),
|
||||
center = Offset(size.width * 0.82f, size.height * 0.9f),
|
||||
radius = span * 0.7f,
|
||||
),
|
||||
center = Offset(size.width * 0.82f, size.height * 0.9f),
|
||||
radius = span * 0.7f,
|
||||
)
|
||||
GamepadAuroraBackground(modifier, calm = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* The horizontal section switcher above a console list. Purely presentational — the SCREEN owns
|
||||
* which tab is selected and what the shoulders do. Scrollable so a narrow phone in landscape never
|
||||
* has to squeeze the pills, and the selected one is always brought into view whether it was reached
|
||||
* by shoulder button or tap.
|
||||
*/
|
||||
@Composable
|
||||
fun ConsoleTabStrip(
|
||||
titles: List<String>,
|
||||
selected: Int,
|
||||
onSelect: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
/**
|
||||
* The strip itself holds the cursor (the caller moved focus UP out of its list). Draws a ring
|
||||
* on the selected pill so it's clear left/right now walks sections rather than values — the
|
||||
* route a D-pad remote, which has no shoulder buttons, needs.
|
||||
*/
|
||||
focused: Boolean = false,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val listState = rememberLazyListState()
|
||||
LaunchedEffect(selected) {
|
||||
runCatching { listState.animateScrollToItem(selected.coerceAtLeast(0)) }
|
||||
}
|
||||
LazyRow(
|
||||
state = listState,
|
||||
modifier = modifier,
|
||||
contentPadding = PaddingValues(horizontal = ConsoleEdgeInset),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
itemsIndexed(titles) { i, title ->
|
||||
val active = i == selected
|
||||
val background by animateColorAsState(
|
||||
if (active) ink.accent(0.85f) else ink.glass,
|
||||
tween(180),
|
||||
label = "tabBg",
|
||||
)
|
||||
// Not `ink` — that name is the palette's, and shadowing it here cost a compile.
|
||||
val labelColor by animateColorAsState(
|
||||
if (active) ink.onAccent else ink.fg(0.55f),
|
||||
tween(180),
|
||||
label = "tabInk",
|
||||
)
|
||||
val ring by animateColorAsState(
|
||||
ink.fg(if (active && focused) 0.85f else 0f),
|
||||
tween(180),
|
||||
label = "tabRing",
|
||||
)
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = labelColor,
|
||||
maxLines = 1,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(background)
|
||||
.border(1.5.dp, ring, RoundedCornerShape(50))
|
||||
.clickable { onSelect(i) }
|
||||
.padding(horizontal = 14.dp, vertical = 7.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +281,7 @@ fun GamepadFormBackground(modifier: Modifier = Modifier) {
|
||||
* sits in the SAME spot across Home / Settings / Add-Host and appears pinned while the content behind
|
||||
* it cross-fades between screens.
|
||||
*/
|
||||
val ConsoleLegendInset = PaddingValues(start = 24.dp, bottom = 24.dp)
|
||||
val ConsoleLegendInset = PaddingValues(start = 24.dp, end = 24.dp, bottom = 24.dp)
|
||||
|
||||
/** The shared horizontal inset for a console screen's heading (matches the legend's left edge). */
|
||||
val ConsoleEdgeInset = 24.dp
|
||||
@@ -187,6 +292,7 @@ val ConsoleEdgeInset = 24.dp
|
||||
*/
|
||||
@Composable
|
||||
fun ConsoleHeader(title: String, modifier: Modifier = Modifier, horizontalInset: Boolean = true) {
|
||||
val ink = LocalGamepadInk.current
|
||||
// `horizontalInset = false` when the caller's container already pads to ConsoleEdgeInset (e.g. a
|
||||
// LazyColumn contentPadding) — so the heading lands at the SAME 24dp on every screen either way.
|
||||
val h = if (horizontalInset) ConsoleEdgeInset else 0.dp
|
||||
@@ -194,7 +300,7 @@ fun ConsoleHeader(title: String, modifier: Modifier = Modifier, horizontalInset:
|
||||
title,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
color = ink.fg,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier.padding(start = h, end = h, top = 18.dp, bottom = 10.dp),
|
||||
@@ -251,21 +357,22 @@ class ConsoleFocusVisuals(val scale: Float, val background: Color, val border: C
|
||||
*/
|
||||
@Composable
|
||||
fun animateConsoleFocus(active: Boolean, editing: Boolean = false): ConsoleFocusVisuals {
|
||||
val ink = LocalGamepadInk.current
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (active) 1f else 0.98f,
|
||||
animationSpec = spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow),
|
||||
label = "consoleScale",
|
||||
)
|
||||
val background by animateColorAsState(
|
||||
if (active) Color(0x336656F2) else Color(0x14FFFFFF),
|
||||
if (active) ink.accent(0.20f) else ink.glass,
|
||||
tween(160),
|
||||
label = "consoleBg",
|
||||
)
|
||||
val border by animateColorAsState(
|
||||
when {
|
||||
editing -> Color(0xB38678F5)
|
||||
active -> Color.White.copy(alpha = 0.28f)
|
||||
else -> Color.White.copy(alpha = 0.06f)
|
||||
editing -> ink.accent(0.70f)
|
||||
active -> ink.fg(0.28f)
|
||||
else -> ink.fg(0.06f)
|
||||
},
|
||||
tween(160),
|
||||
label = "consoleBorder",
|
||||
@@ -280,18 +387,19 @@ fun animateConsoleFocus(active: Boolean, editing: Boolean = false): ConsoleFocus
|
||||
*/
|
||||
@Composable
|
||||
fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val travel by animateFloatAsState(
|
||||
targetValue = if (on) 1f else 0f,
|
||||
animationSpec = spring(dampingRatio = 0.8f, stiffness = 600f),
|
||||
label = "switchKnob",
|
||||
)
|
||||
val track by animateColorAsState(
|
||||
if (on) Color(0xFF6656F2) else Color(0x26FFFFFF),
|
||||
if (on) ink.accent else Color(0x26FFFFFF),
|
||||
tween(200),
|
||||
label = "switchTrack",
|
||||
)
|
||||
val outline by animateColorAsState(
|
||||
Color.White.copy(alpha = if (focused) 0.45f else 0.15f),
|
||||
ink.fg(if (focused) 0.45f else 0.15f),
|
||||
tween(160),
|
||||
label = "switchOutline",
|
||||
)
|
||||
@@ -313,7 +421,7 @@ fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier)
|
||||
.offset { IntOffset(((trackW - knob - pad * 2).toPx() * travel).roundToInt(), 0) }
|
||||
.size(knob)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White),
|
||||
.background(ink.fg),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -321,6 +429,7 @@ fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier)
|
||||
/** A round face-button badge: a coloured disc with the button letter, like a controller's face. */
|
||||
@Composable
|
||||
fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||
val ink = LocalGamepadInk.current
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(size)
|
||||
@@ -330,7 +439,7 @@ fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit
|
||||
) {
|
||||
Text(
|
||||
glyph.toString(),
|
||||
color = Color.White,
|
||||
color = ink.fg,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = (size.value * 0.52f).sp,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -341,11 +450,12 @@ fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit
|
||||
/** The D-pad-centre "select" button — a green (confirm) disc with a ring; the TV-remote glyph for A. */
|
||||
@Composable
|
||||
private fun SelectGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||
val ink = LocalGamepadInk.current
|
||||
Box(
|
||||
modifier = Modifier.size(size).clip(CircleShape).background(PadGlyph.A),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(Modifier.size(size * 0.46f).clip(CircleShape).border(2.dp, Color.White, CircleShape))
|
||||
Box(Modifier.size(size * 0.46f).clip(CircleShape).border(2.dp, ink.fg, CircleShape))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,6 +520,7 @@ internal fun PsFaceGlyph(glyph: Char, size: androidx.compose.ui.unit.Dp = 26.dp)
|
||||
*/
|
||||
@Composable
|
||||
internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.ui.unit.Dp = 26.dp) {
|
||||
val ink = LocalGamepadInk.current
|
||||
Box(
|
||||
Modifier.size(size).clip(CircleShape).background(PadButtonFace),
|
||||
contentAlignment = Alignment.Center,
|
||||
@@ -421,17 +532,17 @@ internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.u
|
||||
val corner = RoundedCornerShape(2.dp)
|
||||
Box(
|
||||
Modifier.size(size * 0.32f).align(Alignment.TopEnd)
|
||||
.border(1.4.dp, Color.White.copy(alpha = 0.9f), corner),
|
||||
.border(1.4.dp, ink.fg(0.9f), corner),
|
||||
)
|
||||
Box(
|
||||
Modifier.size(size * 0.32f).align(Alignment.BottomStart)
|
||||
.clip(corner).background(PadButtonFace)
|
||||
.border(1.4.dp, Color.White.copy(alpha = 0.9f), corner),
|
||||
.border(1.4.dp, ink.fg(0.9f), corner),
|
||||
)
|
||||
}
|
||||
Gamepad.PadStyle.NINTENDO -> Text(
|
||||
"−",
|
||||
color = Color.White,
|
||||
color = ink.fg,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = (size.value * 0.62f).sp,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -440,7 +551,7 @@ internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.u
|
||||
Modifier
|
||||
.size(width = size * 0.58f, height = size * 0.30f)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.border(1.6.dp, Color.White.copy(alpha = 0.9f), RoundedCornerShape(50)),
|
||||
.border(1.6.dp, ink.fg(0.9f), RoundedCornerShape(50)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -452,6 +563,7 @@ internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.u
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, hazeState: HazeState? = null) {
|
||||
val ink = LocalGamepadInk.current
|
||||
// On a TV D-pad remote (no A/B/X/Y), auto-swap the two universal pad glyphs every screen uses:
|
||||
// A (confirm) → the select ring, B (back/cancel) → a back glyph. Screen-specific glyphs like the
|
||||
// home's Up/Down handle themselves. A real pad instead picks its glyph FAMILY (Xbox letters /
|
||||
@@ -464,14 +576,19 @@ fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, haze
|
||||
// With a haze source, blur the content behind the pill (real backdrop blur, API 31+; a translucent
|
||||
// scrim below) + a light tint; otherwise fall back to a solid frosted fill.
|
||||
val frosted = if (hazeState != null) {
|
||||
modifier.clip(shape).hazeEffect(hazeState).background(Color(0x4014122A))
|
||||
modifier.clip(shape).hazeEffect(hazeState).background(ink.shade(0.25f))
|
||||
} else {
|
||||
modifier.clip(shape).background(Color(0x8C14122A))
|
||||
modifier.clip(shape).background(ink.shade(0.55f))
|
||||
}
|
||||
Row(
|
||||
modifier = frosted
|
||||
.border(1.dp, Color.White.copy(alpha = 0.14f), shape)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
.border(1.dp, ink.fg(0.14f), shape)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||
// The pill still hugs its content when it fits; when it doesn't (a narrow phone, or a
|
||||
// screen whose legend grew a cell) it scrolls rather than running off the edge and
|
||||
// silently eating the last hint — which is exactly what the settings screen's new
|
||||
// Section cell did on a 360 dp phone.
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(11.dp),
|
||||
) {
|
||||
@@ -497,7 +614,7 @@ fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, haze
|
||||
Text(
|
||||
h.text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = Color.White.copy(alpha = 0.9f),
|
||||
color = ink.fg(0.9f),
|
||||
maxLines = 1,
|
||||
softWrap = false, // never char-wrap a label when several hints crowd a narrow pill
|
||||
)
|
||||
@@ -509,24 +626,25 @@ fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, haze
|
||||
/** "Which pad is driving this UI" — a quiet chip in the console top bar with the controller's name. */
|
||||
@Composable
|
||||
fun ControllerStatusChip(name: String, modifier: Modifier = Modifier) {
|
||||
val ink = LocalGamepadInk.current
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(Color.White.copy(alpha = 0.08f))
|
||||
.background(ink.fg(0.08f))
|
||||
.padding(horizontal = 12.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.SportsEsports,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.75f),
|
||||
tint = ink.fg(0.75f),
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(Modifier.width(7.dp))
|
||||
Text(
|
||||
name,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color.White.copy(alpha = 0.75f),
|
||||
color = ink.fg(0.75f),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ fun GamepadDialog(
|
||||
actions: List<DialogAction>,
|
||||
body: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
// Focus the primary action; buttons are stacked full-width, navigated up/down (fits long labels
|
||||
// like "Request access" without the cramped-row wrapping a horizontal layout caused).
|
||||
var focus by remember { mutableIntStateOf(actions.indexOfFirst { it.primary }.coerceAtLeast(0)) }
|
||||
@@ -117,11 +118,11 @@ fun GamepadDialog(
|
||||
.heightIn(max = maxCardHeight)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(Color(0xF01A1730))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp))
|
||||
.border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp))
|
||||
.padding(28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = ink.fg)
|
||||
Column(
|
||||
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
@@ -139,6 +140,7 @@ fun GamepadDialog(
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enabled: Boolean, onClick: () -> Unit) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val scale by animateFloatAsState(
|
||||
if (focused) 1.02f else 1f,
|
||||
spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow),
|
||||
@@ -152,19 +154,19 @@ private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enab
|
||||
// Focus sweeps up/down the stack — cross-fade the fills so it glides instead of snapping.
|
||||
val bg by animateColorAsState(
|
||||
when {
|
||||
focused -> Color(0xFF6656F2)
|
||||
primary -> Color(0x336656F2)
|
||||
else -> Color(0x14FFFFFF)
|
||||
focused -> ink.accent
|
||||
primary -> ink.accent(0.20f)
|
||||
else -> ink.glass
|
||||
},
|
||||
tween(160),
|
||||
label = "btnBg",
|
||||
)
|
||||
val fg by animateColorAsState(
|
||||
when {
|
||||
!enabled -> Color.White.copy(alpha = 0.35f)
|
||||
focused -> Color.White
|
||||
primary -> Color(0xFF8678F5)
|
||||
else -> Color.White.copy(alpha = 0.85f)
|
||||
!enabled -> ink.fg(0.35f)
|
||||
focused -> ink.fg
|
||||
primary -> ink.accent
|
||||
else -> ink.fg(0.85f)
|
||||
},
|
||||
tween(160),
|
||||
label = "btnFg",
|
||||
@@ -198,13 +200,14 @@ private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enab
|
||||
/** Body text helper — a dimmed paragraph. */
|
||||
@Composable
|
||||
private fun DialogText(text: String) {
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.7f))
|
||||
val ink = LocalGamepadInk.current
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium, color = ink.fg(0.7f))
|
||||
}
|
||||
|
||||
/**
|
||||
* Console host options for a saved tile — Wake (offered only when offline + a MAC is known), Edit,
|
||||
* Forget. Reached by pressing Up on a focused saved host in the carousel; the console counterpart of
|
||||
* the touch host card's overflow menu.
|
||||
* Console host options for a saved tile — Wake (offered only when offline + a MAC is known), Copy
|
||||
* link, Edit, Forget. Reached by pressing Up on a focused saved host in the carousel; the console
|
||||
* counterpart of the touch host card's overflow menu.
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadHostOptionsDialog(
|
||||
@@ -214,6 +217,12 @@ fun GamepadHostOptionsDialog(
|
||||
onLibrary: (() -> Unit)?, // non-null when the game library is enabled → reachable without Y
|
||||
onEdit: () -> Unit,
|
||||
onForget: () -> Unit,
|
||||
/**
|
||||
* Copy this tile's `punktfunk://` link. Offered on a pinned tile too — unlike the host's other
|
||||
* actions it says nothing about the host, it hands out the shortcut this very tile already is
|
||||
* (profile included), which is exactly what a pin is for.
|
||||
*/
|
||||
onCopyLink: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
onSpeedTest: (() -> Unit)? = null,
|
||||
/**
|
||||
@@ -230,12 +239,14 @@ fun GamepadHostOptionsDialog(
|
||||
actions = buildList {
|
||||
if (onUnpin != null) {
|
||||
add(DialogAction("Unpin card", primary = true, onClick = onUnpin))
|
||||
add(DialogAction("Copy link", onClick = onCopyLink))
|
||||
add(DialogAction("Cancel", onClick = onDismiss))
|
||||
return@buildList
|
||||
}
|
||||
if (onLibrary != null) add(DialogAction("Library", primary = true, onClick = onLibrary))
|
||||
if (canWake) add(DialogAction("Wake host", onClick = onWake))
|
||||
if (onSpeedTest != null) add(DialogAction("Network speed test", onClick = onSpeedTest))
|
||||
add(DialogAction("Copy link", onClick = onCopyLink))
|
||||
add(DialogAction("Edit…", primary = onLibrary == null, onClick = onEdit))
|
||||
add(DialogAction("Forget", onClick = onForget))
|
||||
add(DialogAction("Cancel", onClick = onDismiss))
|
||||
@@ -271,6 +282,7 @@ fun GamepadPinHostsDialog(
|
||||
onToggle: (KnownHost) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
// 0..hosts.lastIndex = host rows, hosts.size = the Done button (with no hosts, index 0 IS
|
||||
// Done, so it starts focused).
|
||||
var focus by remember { mutableIntStateOf(0) }
|
||||
@@ -304,7 +316,7 @@ fun GamepadPinHostsDialog(
|
||||
.heightIn(max = maxCardHeight)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(Color(0xF01A1730))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp))
|
||||
.border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp))
|
||||
.padding(28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
@@ -312,7 +324,7 @@ fun GamepadPinHostsDialog(
|
||||
"Pin “$profileName”",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
color = ink.fg,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
@@ -350,6 +362,7 @@ fun GamepadPinHostsDialog(
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: () -> Unit) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val visuals = animateConsoleFocus(active = focused)
|
||||
// Inside the dialog's scroll region, like DialogButton: a focused row scrolled out of a short
|
||||
// landscape window pulls itself into view.
|
||||
@@ -376,7 +389,7 @@ private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: ()
|
||||
label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
color = ink.fg,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
@@ -450,11 +463,11 @@ fun GamepadLocalNetworkDialog(onAllow: () -> Unit, onSettings: () -> Unit, onDis
|
||||
),
|
||||
) {
|
||||
DialogText(
|
||||
"Android blocks punktfunk from talking to devices on your network, so it can't find " +
|
||||
"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 " +
|
||||
"If no prompt appears after Allow, enable “Nearby devices” for Punktfunk in " +
|
||||
"system settings.",
|
||||
)
|
||||
}
|
||||
@@ -518,6 +531,7 @@ fun GamepadRequestAccessDialog(pt: PendingTrust, onRequestAccess: () -> Unit, on
|
||||
|
||||
@Composable
|
||||
fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
|
||||
val ink = LocalGamepadInk.current
|
||||
GamepadDialog(
|
||||
title = "Waiting for approval",
|
||||
onDismiss = onCancel,
|
||||
@@ -525,8 +539,8 @@ fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
|
||||
) {
|
||||
val deviceName = Build.MODEL ?: "this device"
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = Color.White)
|
||||
Text("Approve this device on $hostLabel.", color = Color.White)
|
||||
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 " +
|
||||
@@ -542,6 +556,7 @@ fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired: (String) -> Unit, onDismiss: () -> Unit) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val digits = remember(pt) { mutableStateListOf(0, 0, 0, 0) }
|
||||
var slot by remember(pt) { mutableIntStateOf(0) } // 0..3 = digit slots, 4 = Pair button
|
||||
@@ -587,16 +602,16 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired:
|
||||
Column(
|
||||
Modifier.padding(24.dp).widthIn(max = 460.dp).heightIn(max = maxCardHeight)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(Color(0xF01A1730)).border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp))
|
||||
.background(Color(0xF01A1730)).border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp))
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(28.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
Text("Pair with PIN", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
Text("Pair with PIN", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = ink.fg)
|
||||
Text(
|
||||
"Enter the 4-digit PIN shown on the host — D-pad ↑↓ sets a digit, ←→ moves.",
|
||||
style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.7f), textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyMedium, color = ink.fg(0.7f), textAlign = TextAlign.Center,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
repeat(4) { i -> PinSlot(digits[i], focused = slot == i && !pairing) }
|
||||
@@ -615,13 +630,14 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired:
|
||||
|
||||
@Composable
|
||||
private fun PinSlot(value: Int, focused: Boolean) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
Box(
|
||||
Modifier.size(54.dp, 66.dp).clip(shape)
|
||||
.background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF))
|
||||
.border(if (focused) 2.dp else 1.dp, if (focused) Color(0xFF8678F5) else Color.White.copy(alpha = 0.1f), shape),
|
||||
.background(if (focused) ink.accent(0.20f) else ink.glass)
|
||||
.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 = Color.White, fontFamily = FontFamily.Monospace)
|
||||
Text(value.toString(), fontSize = 30.sp, fontWeight = FontWeight.Bold, color = ink.fg, fontFamily = FontFamily.Monospace)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,9 +247,10 @@ fun GamepadHome(
|
||||
/** One dark-glass landscape console tile — bigger and bolder than the touch grid's HostCard. */
|
||||
@Composable
|
||||
private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val shape = RoundedCornerShape(26.dp)
|
||||
val wash = if (tile.filled) {
|
||||
Brush.verticalGradient(listOf(Color(0x336656F2), Color(0x14100C2A)))
|
||||
Brush.verticalGradient(listOf(ink.accent(0.20f), Color(0x14100C2A)))
|
||||
} else {
|
||||
Brush.verticalGradient(listOf(Color(0x1AFFFFFF), Color(0x0DFFFFFF)))
|
||||
}
|
||||
@@ -258,7 +259,7 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
|
||||
.fillMaxWidth()
|
||||
.clip(shape)
|
||||
.background(wash)
|
||||
.border(1.dp, Color.White.copy(alpha = 0.16f), shape)
|
||||
.border(1.dp, ink.fg(0.16f), shape)
|
||||
.padding(22.dp),
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
|
||||
@@ -269,7 +270,7 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
|
||||
Icon(
|
||||
Icons.Filled.Lock,
|
||||
contentDescription = "Paired",
|
||||
tint = Color.White.copy(alpha = 0.7f),
|
||||
tint = ink.fg(0.7f),
|
||||
modifier = Modifier.padding(end = 6.dp).size(15.dp),
|
||||
)
|
||||
}
|
||||
@@ -286,14 +287,14 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
|
||||
tile.title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
color = ink.fg,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
tile.subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White.copy(alpha = 0.55f),
|
||||
color = ink.fg(0.55f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
@@ -302,9 +303,10 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
|
||||
|
||||
@Composable
|
||||
private fun MonogramBadge(tile: HomeTile) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val shape = RoundedCornerShape(15.dp)
|
||||
val fill = if (tile.filled) {
|
||||
Brush.verticalGradient(listOf(Color(0xFF6656F2), Color(0xFF8678F5)))
|
||||
Brush.verticalGradient(listOf(ink.accent, ink.accent))
|
||||
} else {
|
||||
Brush.verticalGradient(listOf(Color(0x296656F2), Color(0x296656F2)))
|
||||
}
|
||||
@@ -316,18 +318,18 @@ private fun MonogramBadge(tile: HomeTile) {
|
||||
tile.connecting -> CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = Color.White,
|
||||
color = ink.fg,
|
||||
)
|
||||
tile.isAdd -> Icon(
|
||||
Icons.Filled.Add,
|
||||
contentDescription = null,
|
||||
tint = if (tile.filled) Color.White else Color(0xFF8678F5),
|
||||
tint = if (tile.filled) ink.fg else ink.accent,
|
||||
)
|
||||
else -> Text(
|
||||
tile.title.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "•",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (tile.filled) Color.White else Color(0xFF8678F5),
|
||||
color = if (tile.filled) ink.fg else ink.accent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// The ink the console (gamepad) UI draws with under the chosen background palette.
|
||||
//
|
||||
// The console screens were white-on-dark throughout with the brand violet hardcoded as the accent.
|
||||
// Both had to become palette-derived at once: a pale field needs dark text or it is unreadable,
|
||||
// and a violet focus wash on a copper field is exactly the clash this exists to fix.
|
||||
//
|
||||
// Published as a CompositionLocal rather than passed down, so a leaf (a row, a hint pill, a card)
|
||||
// can ask for the right colour without every caller in between knowing about palettes. The Apple
|
||||
// client uses an environment value and `pf-console-ui` a thread-local for the same reason.
|
||||
|
||||
/** Everything about the console's look that follows the chosen palette. */
|
||||
class GamepadInk(
|
||||
/** Primary text/glyph colour. */
|
||||
val fg: Color,
|
||||
/** Focus wash, selected tab pill, switch track — the palette's own accent. */
|
||||
val accent: Color,
|
||||
/** What reads ON the accent (a filled pill's label, a switch knob). */
|
||||
val onAccent: Color,
|
||||
/** The base fill every glass surface starts from, at its resting opacity. */
|
||||
val glass: Color,
|
||||
/** What a wash laid UNDER text tends toward: black on a dark field, white on a pale one. */
|
||||
val shade: Color,
|
||||
/**
|
||||
* How hard those washes go. A pale field needs far less — mixing toward white at the dark
|
||||
* field's strength bleaches the chroma straight out of the gradient.
|
||||
*/
|
||||
val shadeScale: Float,
|
||||
/** True when the field is pale, for the few places that branch rather than blend. */
|
||||
val isLight: Boolean,
|
||||
) {
|
||||
/** The foreground at [alpha]. */
|
||||
fun fg(alpha: Float): Color = fg.copy(alpha = alpha)
|
||||
|
||||
/** The accent at [alpha]. */
|
||||
fun accent(alpha: Float): Color = accent.copy(alpha = alpha)
|
||||
|
||||
/** A wash under text: [alpha] is the dark-field strength, scaled for a pale one. */
|
||||
fun shade(alpha: Float): Color = shade.copy(alpha = alpha * shadeScale)
|
||||
|
||||
companion object {
|
||||
fun of(p: GamepadPalette): GamepadInk {
|
||||
val accent = p.accentColor
|
||||
// Chosen by luminance, not by `light`: an accent is picked for contrast against the
|
||||
// GLASS, not against the field.
|
||||
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
|
||||
if (!p.light) {
|
||||
return GamepadInk(
|
||||
fg = Color.White,
|
||||
accent = accent,
|
||||
onAccent = onAccent,
|
||||
glass = Color.White.copy(alpha = 0.08f),
|
||||
shade = Color.Black,
|
||||
shadeScale = 1f,
|
||||
isLight = false,
|
||||
)
|
||||
}
|
||||
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()),
|
||||
accent = accent,
|
||||
onAccent = onAccent,
|
||||
// More body than the dark glass carries: white frost over a bright gradient has
|
||||
// far less separating it from its backdrop than dark glass over a dark one.
|
||||
glass = Color.White.copy(alpha = 0.55f),
|
||||
shade = Color.White,
|
||||
shadeScale = 0.45f,
|
||||
isLight = true,
|
||||
)
|
||||
}
|
||||
|
||||
/** The shipped dark look — what a preview or a test composition gets. */
|
||||
val DARK = of(GamepadPalette.named("violet"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The ink of the palette currently drawing, for everything under [App]. Provided from the live
|
||||
* settings alongside [LocalGamepadPalette], so a change on the gamepad settings screen re-inks
|
||||
* every console surface at once.
|
||||
*/
|
||||
val LocalGamepadInk = compositionLocalOf { GamepadInk.DARK }
|
||||
@@ -152,8 +152,9 @@ fun GamepadNavEffect(
|
||||
* keyboard). Same hysteresis + hold-to-repeat as [GamepadNavEffect] but on both axes — the dominant
|
||||
* stick axis (or the pressed D-pad/HAT) commits a [NavDir], and it re-arms only after the stick
|
||||
* returns near centre (so a flick is one step). [onActivate] is A / center, [onTertiary] is X,
|
||||
* [onSecondary] is Y. B is left to MainActivity's BACK remap → the screen's BackHandler (so B "peels
|
||||
* one layer": close the keyboard, then the screen).
|
||||
* [onSecondary] is Y, and [onShoulder] is L1 (-1) / R1 (+1) — a step SIDEWAYS out of the list, which
|
||||
* the settings screen uses for its section tabs. B is left to MainActivity's BACK remap → the
|
||||
* screen's BackHandler (so B "peels one layer": close the keyboard, then the screen).
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadNavEffect2D(
|
||||
@@ -162,6 +163,7 @@ fun GamepadNavEffect2D(
|
||||
onActivate: () -> Unit,
|
||||
onTertiary: () -> Unit = {},
|
||||
onSecondary: () -> Unit = {},
|
||||
onShoulder: (Int) -> Unit = {},
|
||||
) {
|
||||
val activity = LocalContext.current as? MainActivity ?: return
|
||||
val state = remember { NavInputState() }
|
||||
@@ -169,6 +171,7 @@ fun GamepadNavEffect2D(
|
||||
val currentOnActivate by rememberUpdatedState(onActivate)
|
||||
val currentOnTertiary by rememberUpdatedState(onTertiary)
|
||||
val currentOnSecondary by rememberUpdatedState(onSecondary)
|
||||
val currentOnShoulder by rememberUpdatedState(onShoulder)
|
||||
|
||||
DisposableEffect(active) {
|
||||
// Stable probe refs so onDispose only releases the slot if WE still own it — during a
|
||||
@@ -196,7 +199,10 @@ fun GamepadNavEffect2D(
|
||||
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { if (edge) currentOnActivate(); true }
|
||||
KeyEvent.KEYCODE_BUTTON_X -> { if (edge) currentOnTertiary(); true }
|
||||
KeyEvent.KEYCODE_BUTTON_Y -> { if (edge) currentOnSecondary(); true }
|
||||
else -> false // B / shoulders → MainActivity (B remaps to BACK → BackHandler)
|
||||
// Edge-only, no auto-repeat: a held shoulder shouldn't spin through the tabs.
|
||||
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)
|
||||
}
|
||||
}
|
||||
if (active) {
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// The console (gamepad) UI's background colour families, and the ink each one calls for.
|
||||
//
|
||||
// A palette is a short ordered ramp of DISTINCT hues, not one hue at several brightnesses. The
|
||||
// field samples that ramp so several tones show at once and pool into each other, the way a real
|
||||
// gradient poster does. An earlier version rotated ONE field's hue per palette, which is why every
|
||||
// non-default palette read flat and monotone.
|
||||
//
|
||||
// A palette also owns the UI sitting on it: [accent] is the focus wash / selected pill / switch
|
||||
// colour, and [light] flips the ink so a pale field gets dark text instead of white.
|
||||
//
|
||||
// The table, [ramp] and [CELL_RAMP] are mirrored in `pf-console-ui`'s `library.rs` (Rust) and the
|
||||
// Apple client's `GamepadPalette.swift` under the same ids, so one `ui_palette` value is one look
|
||||
// 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 background colour family. */
|
||||
class GamepadPalette(
|
||||
/** The stored `ui_palette` value ([Settings.uiPalette]). */
|
||||
val id: String,
|
||||
/** What the settings row shows. */
|
||||
val name: String,
|
||||
/**
|
||||
* The colour ramp, dark end first. Empty = the brand default's explicit field, kept
|
||||
* bit-identical to what every install already sees.
|
||||
*/
|
||||
val stops: List<Triple<Double, Double, Double>>,
|
||||
/** The field's ground — what it settles onto and what the calm mix lifts toward. */
|
||||
val ground: Triple<Double, Double, Double>,
|
||||
/** The UI accent: focus wash, selected tab pill, switch track. */
|
||||
val accent: Triple<Double, Double, Double>,
|
||||
/** A pale field: the UI flips to dark ink and the legibility scrims go white. */
|
||||
val light: Boolean,
|
||||
) {
|
||||
/** Four drifting blob colours, spread across the ramp so the field shows several hues. */
|
||||
val blobColors: List<Color> by lazy {
|
||||
val s = stops.ifEmpty { VIOLET_BLOBS }
|
||||
(0..3).map { color(ramp(s, 0.15 + 0.25 * it)) }
|
||||
}
|
||||
|
||||
/** The field's ground as a Compose colour. */
|
||||
val groundColor: Color by lazy { color(ground) }
|
||||
|
||||
/** The accent as a Compose colour. */
|
||||
val accentColor: Color by lazy { color(accent) }
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* 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,
|
||||
-0.08, 0.14, -0.10, 0.06,
|
||||
0.06, -0.12, 0.16, -0.04,
|
||||
-0.10, 0.08, -0.06, 0.12,
|
||||
)
|
||||
|
||||
/** 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),
|
||||
Triple(0.22, 0.38, 0.86), Triple(0.53, 0.47, 0.96),
|
||||
)
|
||||
|
||||
/**
|
||||
* 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(
|
||||
// --- dark fields (white ink) ---
|
||||
GamepadPalette(
|
||||
"violet", "Violet", emptyList(),
|
||||
ground = Triple(0.075, 0.060, 0.160),
|
||||
accent = Triple(0.525, 0.471, 0.961), light = false,
|
||||
),
|
||||
GamepadPalette(
|
||||
// Deep indigo climbing through violet into a hot magenta.
|
||||
"nebula", "Nebula",
|
||||
listOf(
|
||||
Triple(0.07, 0.05, 0.20), Triple(0.26, 0.14, 0.54), Triple(0.52, 0.20, 0.72),
|
||||
Triple(0.82, 0.26, 0.62), Triple(0.98, 0.46, 0.68),
|
||||
),
|
||||
ground = Triple(0.055, 0.040, 0.135),
|
||||
accent = Triple(0.95, 0.42, 0.72), light = false,
|
||||
),
|
||||
GamepadPalette(
|
||||
// Ink-blue water: teal → cerulean → a violet undertow.
|
||||
"abyss", "Abyss",
|
||||
listOf(
|
||||
Triple(0.02, 0.10, 0.17), Triple(0.04, 0.28, 0.42), Triple(0.07, 0.46, 0.63),
|
||||
Triple(0.16, 0.38, 0.78), Triple(0.26, 0.22, 0.58),
|
||||
),
|
||||
ground = Triple(0.018, 0.070, 0.130),
|
||||
accent = Triple(0.26, 0.76, 0.92), light = false,
|
||||
),
|
||||
GamepadPalette(
|
||||
// Banked coals: plum embers → crimson → burnt orange → gold.
|
||||
"ember", "Ember",
|
||||
listOf(
|
||||
Triple(0.16, 0.03, 0.10), Triple(0.45, 0.06, 0.12), Triple(0.72, 0.18, 0.06),
|
||||
Triple(0.90, 0.42, 0.08), Triple(0.95, 0.68, 0.18),
|
||||
),
|
||||
ground = Triple(0.090, 0.035, 0.040),
|
||||
accent = Triple(0.98, 0.62, 0.26), light = false,
|
||||
),
|
||||
GamepadPalette(
|
||||
// Forest floor into moss and a lime break.
|
||||
"moss", "Moss",
|
||||
listOf(
|
||||
Triple(0.03, 0.11, 0.09), Triple(0.06, 0.27, 0.20), Triple(0.09, 0.45, 0.31),
|
||||
Triple(0.28, 0.61, 0.28), Triple(0.58, 0.77, 0.31),
|
||||
),
|
||||
ground = Triple(0.025, 0.085, 0.070),
|
||||
accent = Triple(0.48, 0.86, 0.46), light = false,
|
||||
),
|
||||
GamepadPalette(
|
||||
// Neutral, but never flat: barely-there saturation that still travels from a cool
|
||||
// charcoal to a warm stone.
|
||||
"graphite", "Graphite",
|
||||
listOf(
|
||||
Triple(0.06, 0.07, 0.11), Triple(0.15, 0.18, 0.25), Triple(0.30, 0.31, 0.35),
|
||||
Triple(0.45, 0.42, 0.38), Triple(0.60, 0.56, 0.49),
|
||||
),
|
||||
ground = Triple(0.055, 0.055, 0.070),
|
||||
accent = Triple(0.78, 0.80, 0.86), light = false,
|
||||
),
|
||||
// --- pale fields (dark ink) ---
|
||||
GamepadPalette(
|
||||
// The holographic foil: rose → lilac → periwinkle → aqua, with a white bloom.
|
||||
"holo", "Holo",
|
||||
listOf(
|
||||
Triple(0.99, 0.72, 0.90), Triple(0.80, 0.60, 0.98), Triple(0.58, 0.62, 0.99),
|
||||
Triple(0.55, 0.86, 0.98), Triple(0.94, 0.98, 1.00),
|
||||
),
|
||||
ground = Triple(0.96, 0.92, 0.99),
|
||||
accent = Triple(0.42, 0.28, 0.86), light = true,
|
||||
),
|
||||
GamepadPalette(
|
||||
// The poster sunset: periwinkle → magenta → scarlet → tangerine → gold.
|
||||
"sunset", "Sunset",
|
||||
listOf(
|
||||
Triple(0.55, 0.45, 0.92), Triple(0.86, 0.31, 0.66), Triple(0.97, 0.26, 0.34),
|
||||
Triple(0.99, 0.51, 0.18), Triple(1.00, 0.80, 0.22),
|
||||
),
|
||||
ground = Triple(0.98, 0.74, 0.34),
|
||||
accent = Triple(0.64, 0.13, 0.44), light = true,
|
||||
),
|
||||
GamepadPalette(
|
||||
// Peach into blush and lilac — the softest of the set.
|
||||
"bloom", "Bloom",
|
||||
listOf(
|
||||
Triple(1.00, 0.86, 0.72), Triple(0.99, 0.73, 0.79), Triple(0.95, 0.65, 0.89),
|
||||
Triple(0.82, 0.68, 0.96), Triple(0.73, 0.79, 0.99),
|
||||
),
|
||||
ground = Triple(0.99, 0.90, 0.89),
|
||||
accent = Triple(0.72, 0.24, 0.55), light = true,
|
||||
),
|
||||
GamepadPalette(
|
||||
// First light: pale gold → coral → lilac.
|
||||
"dawn", "Dawn",
|
||||
listOf(
|
||||
Triple(1.00, 0.92, 0.70), Triple(1.00, 0.80, 0.62), Triple(0.99, 0.66, 0.62),
|
||||
Triple(0.90, 0.62, 0.78), Triple(0.77, 0.69, 0.95),
|
||||
),
|
||||
ground = Triple(1.00, 0.93, 0.82),
|
||||
accent = Triple(0.82, 0.33, 0.28), light = true,
|
||||
),
|
||||
GamepadPalette(
|
||||
// Sea glass: mint → aqua → a pale sky.
|
||||
"mint", "Mint",
|
||||
listOf(
|
||||
Triple(0.82, 0.98, 0.90), Triple(0.62, 0.94, 0.88), Triple(0.55, 0.88, 0.95),
|
||||
Triple(0.63, 0.82, 0.99), Triple(0.82, 0.87, 1.00),
|
||||
),
|
||||
ground = Triple(0.90, 0.98, 0.96),
|
||||
accent = Triple(0.04, 0.42, 0.40), light = true,
|
||||
),
|
||||
GamepadPalette(
|
||||
// Near-white, but iridescent rather than flat — rose, sky, mint and cream in turn.
|
||||
"opal", "Opal",
|
||||
listOf(
|
||||
Triple(0.98, 0.92, 0.96), Triple(0.87, 0.93, 0.99), Triple(0.91, 0.99, 0.95),
|
||||
Triple(0.99, 0.96, 0.88), Triple(0.94, 0.90, 0.99),
|
||||
),
|
||||
ground = Triple(0.97, 0.96, 0.99),
|
||||
accent = Triple(0.36, 0.32, 0.44), light = true,
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* The palette stored under [id], falling back to the brand default — an unknown name is a
|
||||
* palette a newer client shipped, not a reason to draw nothing.
|
||||
*/
|
||||
fun named(id: String): GamepadPalette = ALL.firstOrNull { it.id == id } ?: ALL[0]
|
||||
|
||||
/** Sample an ordered colour ramp at [t] ∈ [0, 1] (linear between neighbouring stops). */
|
||||
fun ramp(
|
||||
stops: List<Triple<Double, Double, Double>>,
|
||||
t: Double,
|
||||
): Triple<Double, Double, Double> {
|
||||
if (stops.isEmpty()) return Triple(0.0, 0.0, 0.0)
|
||||
if (stops.size == 1) return stops[0]
|
||||
val x = t.coerceIn(0.0, 1.0) * (stops.size - 1)
|
||||
val i = x.toInt().coerceAtMost(stops.size - 2)
|
||||
val f = x - i
|
||||
val (ar, ag, ab) = stops[i]
|
||||
val (br, bg, bb) = stops[i + 1]
|
||||
return Triple(ar + (br - ar) * f, ag + (bg - ag) * f, ab + (bb - ab) * f)
|
||||
}
|
||||
|
||||
fun color(c: Triple<Double, Double, Double>): Color =
|
||||
Color(c.first.toFloat(), c.second.toFloat(), c.third.toFloat())
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -63,10 +64,35 @@ import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
// The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView:
|
||||
// the couch-relevant subset of the touch settings restyled as a console page and fully navigable with
|
||||
// a controller: up/down moves the focus bar, left/right steps the focused value, A cycles/toggles it,
|
||||
// B closes. Both write the same SharedPreferences, so values round-trip with the touch settings.
|
||||
// L1/R1 change SECTION, B closes. Both write the same SharedPreferences, so values round-trip with
|
||||
// the touch settings.
|
||||
//
|
||||
// The rows are split across SECTION TABS ([GpTab]) — a shoulder press on a pad, a tap on a phone.
|
||||
// They used to be one long scroll with inline `Group · Subgroup` headers, which on a TV meant
|
||||
// walking past Display and Audio to reach the controller settings. The tab names match the desktop
|
||||
// console's and the Apple client's, so a setting is found under the same word wherever you look.
|
||||
|
||||
/**
|
||||
* The settings screen's sections. Order IS the strip order and the L1/R1 cycle order; the names
|
||||
* match `pf-console-ui`'s `TABS` and the Apple client's `GpSettingsTab`.
|
||||
*/
|
||||
enum class GpTab(val title: String) {
|
||||
STREAM("Stream"),
|
||||
VIDEO("Video"),
|
||||
AUDIO("Audio"),
|
||||
CONTROLLER("Controller"),
|
||||
INTERFACE("Interface"),
|
||||
PROFILES("Profiles"),
|
||||
}
|
||||
|
||||
internal class GpRow(
|
||||
val id: String,
|
||||
val tab: GpTab,
|
||||
/**
|
||||
* A sub-heading above this row, for the few tabs that hold more than one group. Most rows have
|
||||
* none: the tab pill already names the section, and repeating it would be a second label
|
||||
* saying the same word.
|
||||
*/
|
||||
val header: String?,
|
||||
val label: String,
|
||||
val value: String,
|
||||
@@ -133,10 +159,34 @@ 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) }
|
||||
val rows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) +
|
||||
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(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 }
|
||||
var focus by remember { mutableIntStateOf(0) }
|
||||
if (focus > rows.lastIndex) focus = rows.lastIndex
|
||||
if (focus > rows.lastIndex) focus = rows.lastIndex.coerceAtLeast(0)
|
||||
|
||||
// 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
|
||||
tab = next
|
||||
// Clamp: a tab's length follows the hardware and the catalog, so a remembered index can
|
||||
// outlive the row it pointed at.
|
||||
focus = (tabFocus[next] ?: 0)
|
||||
.coerceIn(0, (allRows.count { it.tab == next } - 1).coerceAtLeast(0))
|
||||
}
|
||||
fun stepTab(delta: Int) {
|
||||
val all = GpTab.entries
|
||||
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) }
|
||||
@@ -151,20 +201,28 @@ fun GamepadSettingsScreen(
|
||||
active = navActive && pinProfile == null,
|
||||
onDirection = { dir ->
|
||||
when (dir) {
|
||||
NavDir.UP -> if (focus > 0) focus--
|
||||
NavDir.DOWN -> if (focus < rows.lastIndex) focus++
|
||||
// A disabled row is INERT, not just dim — the step is refused instead of writing a
|
||||
// setting that has nothing to act on (see `liveRow`).
|
||||
NavDir.LEFT -> { adjustDir = -1; liveRow(rows, focus)?.adjust(-1) }
|
||||
NavDir.RIGHT -> { adjustDir = 1; liveRow(rows, focus)?.adjust(1) }
|
||||
NavDir.UP -> if (focus > 0) focus-- else tabFocused = true
|
||||
NavDir.DOWN -> if (tabFocused) tabFocused = false else if (focus < rows.lastIndex) focus++
|
||||
// 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 { adjustDir = -1; liveRow(rows, focus)?.adjust(-1) }
|
||||
NavDir.RIGHT ->
|
||||
if (tabFocused) stepTab(1) else { adjustDir = 1; liveRow(rows, focus)?.adjust(1) }
|
||||
}
|
||||
},
|
||||
onActivate = { adjustDir = 1; liveRow(rows, focus)?.activate() },
|
||||
// A on the strip drops into the section you picked, which is what "confirm" means there.
|
||||
onActivate = {
|
||||
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) },
|
||||
)
|
||||
// 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) {
|
||||
LaunchedEffect(focus, tab) {
|
||||
runCatching {
|
||||
val itemIndex = focus + 1
|
||||
val info = listState.layoutInfo
|
||||
@@ -183,9 +241,21 @@ fun GamepadSettingsScreen(
|
||||
// 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().systemBarsPadding(),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 104.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
@@ -196,12 +266,19 @@ fun GamepadSettingsScreen(
|
||||
ConsoleHeader("Default settings", horizontalInset = false)
|
||||
}
|
||||
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
|
||||
SettingRowView(row, focused = index == focus, adjustDir = adjustDir, onClick = {
|
||||
// Same inertness as the pad path above — tapping a dimmed row focuses it (so
|
||||
// its detail explains itself) but never flips it.
|
||||
if (focus != index) focus = index
|
||||
else if (row.enabled) { adjustDir = 1; row.activate() }
|
||||
})
|
||||
SettingRowView(
|
||||
row,
|
||||
focused = index == focus && !tabFocused,
|
||||
adjustDir = adjustDir,
|
||||
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) { adjustDir = 1; row.activate() }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,8 +295,23 @@ fun GamepadSettingsScreen(
|
||||
// a profile row doesn't adjust, it opens the pin picker, and the "No profiles yet"
|
||||
// placeholder does nothing at all — advertising ↔/A on those would be a lie.
|
||||
val focused = rows.getOrNull(focus)
|
||||
// The shoulders always change section, so that cell leads on every row. Tappable too,
|
||||
// like the others — a user without a working pad can still reach every tab.
|
||||
// Advertise the shoulders only where they EXIST: a TV remote has none (its route is Up
|
||||
// into the strip) and a touch user taps a pill, so on those the cell would be both a
|
||||
// lie and the reason a 360 dp legend runs out of room. Defaults to the pad case off an
|
||||
// Activity (preview/tests), like GamepadHintBar's own glyph choice.
|
||||
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
|
||||
val sections = listOfNotNull(
|
||||
GamepadHint('⇄', Color(0xFF9A93C7), "Section", onClick = { stepTab(1) })
|
||||
.takeIf { padIsGamepad },
|
||||
)
|
||||
GamepadHintBar(
|
||||
when {
|
||||
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),
|
||||
)
|
||||
@@ -254,6 +346,7 @@ fun GamepadSettingsScreen(
|
||||
|
||||
@Composable
|
||||
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
|
||||
@@ -265,7 +358,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
label = "chevrons",
|
||||
)
|
||||
val valueColor by animateColorAsState(
|
||||
Color.White.copy(alpha = if (focused) 1f else 0.6f),
|
||||
ink.fg(if (focused) 1f else 0.6f),
|
||||
tween(160),
|
||||
label = "valueColor",
|
||||
)
|
||||
@@ -274,7 +367,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
Text(
|
||||
row.header.uppercase(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color.White.copy(alpha = 0.45f),
|
||||
color = ink.fg(0.45f),
|
||||
letterSpacing = 1.4.sp,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 4.dp),
|
||||
)
|
||||
@@ -300,7 +393,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
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 = Color.White.copy(alpha = if (row.enabled) 1f else 0.45f),
|
||||
color = ink.fg(if (row.enabled) 1f else 0.45f),
|
||||
maxLines = 1,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
@@ -308,7 +401,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
// A toggle is a switch, not text — the sliding knob + tinting track IS the value.
|
||||
ConsoleSwitch(on = row.toggled, focused = focused)
|
||||
} else {
|
||||
Text("‹ ", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
|
||||
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(
|
||||
@@ -329,7 +422,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(" ›", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
|
||||
Text(" ›", color = ink.fg, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
|
||||
}
|
||||
}
|
||||
// The focused row carries its own one-line description — no dedicated (space-eating)
|
||||
@@ -342,7 +435,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
Text(
|
||||
row.detail,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.White.copy(alpha = 0.6f),
|
||||
color = ink.fg(0.6f),
|
||||
maxLines = 2,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
@@ -353,7 +446,8 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
|
||||
/** Build the console settings rows from the current [Settings], writing through [update].
|
||||
* [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs); [av1Capable] gates the
|
||||
* AV1 codec entry (see `codecOptionsFor`). */
|
||||
* 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,
|
||||
@@ -361,12 +455,12 @@ internal fun buildSettingsRows(
|
||||
update: (Settings) -> Unit,
|
||||
): List<GpRow> {
|
||||
fun <T> choice(
|
||||
id: String, header: String?, label: String, detail: String,
|
||||
id: String, tab: GpTab, header: String?, label: String, detail: String,
|
||||
options: List<Pair<T, String>>, current: T, enabled: Boolean = true, write: (T) -> Unit,
|
||||
): GpRow {
|
||||
val idx = options.indexOfFirst { it.first == current }
|
||||
return GpRow(
|
||||
id, header, label,
|
||||
id, tab, header, label,
|
||||
value = options.getOrNull(idx)?.second ?: "—",
|
||||
detail = detail,
|
||||
enabled = enabled,
|
||||
@@ -385,10 +479,10 @@ internal fun buildSettingsRows(
|
||||
)
|
||||
}
|
||||
fun toggle(
|
||||
id: String, header: String?, label: String, detail: String,
|
||||
id: String, tab: GpTab, header: String?, label: String, detail: String,
|
||||
value: Boolean, enabled: Boolean = true, write: (Boolean) -> Unit,
|
||||
): GpRow = GpRow(
|
||||
id, header, label,
|
||||
id, tab, header, label,
|
||||
value = if (value) "On" else "Off",
|
||||
detail = detail,
|
||||
enabled = enabled,
|
||||
@@ -397,36 +491,13 @@ internal fun buildSettingsRows(
|
||||
toggled = value,
|
||||
)
|
||||
|
||||
// Grouped and ordered by the cross-client category map (General / Display / Audio /
|
||||
// Controllers), with the same sub-section names the touch settings and the desktop clients use,
|
||||
// so a setting sits in the same place whichever surface you found it on. The ROWS stay the
|
||||
// couch-relevant subset: a pad can't drive a touch-input picker, and adding one for the sake of
|
||||
// symmetry would be parity in name only.
|
||||
// Grouped by the cross-client tab map (Stream / Video / Audio / Controller / Interface /
|
||||
// Profiles), so a setting sits under the same word whichever client you found it on. The ROWS
|
||||
// stay the couch-relevant subset: a pad can't drive a touch-input picker, and adding one for
|
||||
// the sake of symmetry would be parity in name only.
|
||||
return listOf(
|
||||
choice(
|
||||
"hud", "General · Statistics", "Statistics overlay",
|
||||
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
|
||||
"A 3-finger tap cycles the tiers live.",
|
||||
STATS_VERBOSITY_OPTIONS, s.statsVerbosity,
|
||||
) { update(s.copy(statsVerbosity = it)) },
|
||||
toggle(
|
||||
"autoWake", "General · Session", "Auto-wake on connect",
|
||||
"Wake a saved host with Wake-on-LAN when it isn't seen on the network, then connect.",
|
||||
s.autoWakeEnabled,
|
||||
) { update(s.copy(autoWakeEnabled = it)) },
|
||||
toggle(
|
||||
"library", "General · Library", "Game library",
|
||||
"Browse a paired host's games with Y (experimental).",
|
||||
s.libraryEnabled,
|
||||
) { update(s.copy(libraryEnabled = it)) },
|
||||
toggle(
|
||||
"gamepadUI", "General · Interface", "Controller-optimized UI",
|
||||
"Turn off to use the touch interface even with a controller connected.",
|
||||
s.gamepadUiEnabled,
|
||||
) { update(s.copy(gamepadUiEnabled = it)) },
|
||||
|
||||
choice(
|
||||
"resolution", "Display · Resolution", "Resolution",
|
||||
"resolution", GpTab.STREAM, null, "Resolution",
|
||||
"The host creates a virtual display at exactly this size — no scaling. " +
|
||||
"Custom sizes are typed in the touch settings.",
|
||||
// A custom size (typed in the touch settings) leads the list so it stays visible and
|
||||
@@ -440,55 +511,56 @@ internal fun buildSettingsRows(
|
||||
s.width to s.height,
|
||||
) { (w, h) -> update(s.copy(width = w, height = h)) },
|
||||
choice(
|
||||
"refresh", null, "Refresh rate", "Frame rate the host renders and streams at.",
|
||||
"refresh", GpTab.STREAM, null, "Refresh rate",
|
||||
"Frame rate the host renders and streams at.",
|
||||
REFRESH_OPTIONS, s.hz,
|
||||
) { update(s.copy(hz = it)) },
|
||||
|
||||
choice(
|
||||
"bitrate", "Display · Quality", "Bitrate",
|
||||
"bitrate", GpTab.STREAM, null, "Bitrate",
|
||||
"Automatic uses the host's default. A host's options (Up on its tile) can measure the " +
|
||||
"link and set an informed value.",
|
||||
BITRATE_OPTIONS, s.bitrateKbps,
|
||||
) { update(s.copy(bitrateKbps = it)) },
|
||||
choice(
|
||||
"codec", null, "Video codec",
|
||||
"A preference — the host falls back if it can't encode this one.",
|
||||
codecOptionsFor(s.codec, av1Capable), s.codec,
|
||||
) { update(s.copy(codec = it)) },
|
||||
toggle(
|
||||
"hdr", null, "10-bit HDR",
|
||||
"HDR10 — engages when the host sends HDR content and this display supports it.",
|
||||
s.hdrEnabled,
|
||||
) { update(s.copy(hdrEnabled = it)) },
|
||||
|
||||
toggle(
|
||||
"lowLatency", "Display · Decoding", "Low-latency mode",
|
||||
"The fast pipeline (async decode + system tuning). On by default — turn off to fall back if the stream stutters or glitches.",
|
||||
s.lowLatencyMode,
|
||||
) { update(s.copy(lowLatencyMode = it)) },
|
||||
|
||||
choice(
|
||||
"compositor", "Display · Host output", "Compositor",
|
||||
"compositor", GpTab.STREAM, "Host output", "Compositor",
|
||||
"Which compositor drives the virtual output — honored only if available on the host.",
|
||||
COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.compositor,
|
||||
) { update(s.copy(compositor = it)) },
|
||||
|
||||
choice(
|
||||
"audio", "Audio", "Audio channels", "The speaker layout requested from the host.",
|
||||
"codec", GpTab.VIDEO, null, "Video codec",
|
||||
"A preference — the host falls back if it can't encode this one.",
|
||||
codecOptionsFor(s.codec, av1Capable), s.codec,
|
||||
) { update(s.copy(codec = it)) },
|
||||
toggle(
|
||||
"hdr", GpTab.VIDEO, null, "10-bit HDR",
|
||||
"HDR10 — engages when the host sends HDR content and this display supports it.",
|
||||
s.hdrEnabled,
|
||||
) { update(s.copy(hdrEnabled = it)) },
|
||||
toggle(
|
||||
"lowLatency", GpTab.VIDEO, "Decoding", "Low-latency mode",
|
||||
"The fast pipeline (async decode + system tuning). On by default — turn off to fall back if the stream stutters or glitches.",
|
||||
s.lowLatencyMode,
|
||||
) { update(s.copy(lowLatencyMode = it)) },
|
||||
|
||||
choice(
|
||||
"audio", GpTab.AUDIO, null, "Audio channels",
|
||||
"The speaker layout requested from the host.",
|
||||
AUDIO_CHANNEL_OPTIONS, s.audioChannels,
|
||||
) { update(s.copy(audioChannels = it)) },
|
||||
toggle(
|
||||
"mic", null, "Microphone", "Send this device's microphone to the host's virtual mic.",
|
||||
"mic", GpTab.AUDIO, null, "Microphone",
|
||||
"Send this device's microphone to the host's virtual mic.",
|
||||
s.micEnabled,
|
||||
) { update(s.copy(micEnabled = it)) },
|
||||
toggle(
|
||||
"echoCancel", null, "Echo cancellation",
|
||||
"echoCancel", GpTab.AUDIO, null, "Echo cancellation",
|
||||
"Filter the stream's own audio out of the mic pickup. Applies while the microphone is on.",
|
||||
s.echoCancel,
|
||||
) { update(s.copy(echoCancel = it)) },
|
||||
|
||||
toggle(
|
||||
"padForward", "Controllers", "Forward controllers",
|
||||
"padForward", GpTab.CONTROLLER, null, "Forward controllers",
|
||||
"Send this device's controllers to the host. Turn it off when your controller " +
|
||||
"already reaches the host another way — USB passthrough such as VirtualHere — " +
|
||||
"so games don't see two of them.",
|
||||
@@ -499,18 +571,18 @@ internal fun buildSettingsRows(
|
||||
// had the capability (`GpRow.enabled`) and used it only for the profiles placeholder, so
|
||||
// the pad rows kept stepping settings that had nothing to act on.
|
||||
choice(
|
||||
"padType", null, "Controller type",
|
||||
"padType", GpTab.CONTROLLER, null, "Controller type",
|
||||
"The virtual pad the host creates — Automatic matches this controller.",
|
||||
GAMEPAD_OPTIONS, s.gamepad, enabled = s.gamepadForwarding,
|
||||
) { update(s.copy(gamepad = it)) },
|
||||
choice(
|
||||
"systemButtons", null, "Guide button",
|
||||
"systemButtons", GpTab.CONTROLLER, null, "Guide button",
|
||||
"Where the guide (Xbox/PS) and share presses go while streaming — Automatic " +
|
||||
"sends them to the host whenever this device delivers them.",
|
||||
SYSTEM_BUTTON_OPTIONS, s.systemButtons, enabled = s.gamepadForwarding,
|
||||
) { update(s.copy(systemButtons = it)) },
|
||||
choice(
|
||||
"guideGesture", null, "Hold Select for guide",
|
||||
"guideGesture", GpTab.CONTROLLER, null, "Hold Select for guide",
|
||||
"Hold Select alone to press the host's guide button — keep holding for a " +
|
||||
"Gaming-Mode host's quick-access menu. A Select tap still goes through.",
|
||||
GUIDE_GESTURE_OPTIONS, s.guideGesture, enabled = s.gamepadForwarding,
|
||||
@@ -518,7 +590,7 @@ internal fun buildSettingsRows(
|
||||
) + listOfNotNull(
|
||||
if (hasBodyVibrator) {
|
||||
toggle(
|
||||
"phoneRumble", null, "Rumble on this phone",
|
||||
"phoneRumble", GpTab.CONTROLLER, null, "Rumble on this phone",
|
||||
"Also play controller 1's rumble on this phone's own vibration motor — " +
|
||||
"for clip-on pads without rumble motors.",
|
||||
s.rumbleOnPhone,
|
||||
@@ -530,7 +602,7 @@ internal fun buildSettingsRows(
|
||||
// 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.
|
||||
toggle(
|
||||
"sc2", null, "Steam Controller 2 passthrough",
|
||||
"sc2", GpTab.CONTROLLER, "Passthrough", "Steam Controller 2 passthrough",
|
||||
"Capture a Steam Controller 2 (wired, Puck dongle, or paired Bluetooth) and stream " +
|
||||
"it as-is — Steam on the host drives it like the physical pad.",
|
||||
s.sc2Capture, enabled = s.gamepadForwarding,
|
||||
@@ -540,20 +612,53 @@ internal fun buildSettingsRows(
|
||||
// back to — could turn on SC2 passthrough but not the Sony one. Same no-vibrator-gate
|
||||
// reasoning: this capture renders feedback on the CONTROLLER's motors, not this device's.
|
||||
toggle(
|
||||
"dsCapture", null, "DualSense / DualShock passthrough (USB)",
|
||||
"dsCapture", GpTab.CONTROLLER, null, "DualSense / DualShock passthrough (USB)",
|
||||
"Drive a USB-connected Sony pad directly — rumble on any phone, plus adaptive " +
|
||||
"triggers, lightbar and gyro.",
|
||||
s.dsCapture, enabled = s.gamepadForwarding,
|
||||
) { update(s.copy(dsCapture = it)) },
|
||||
|
||||
// The 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
|
||||
// found in the section.
|
||||
choice(
|
||||
"palette", GpTab.INTERFACE, null, "Background",
|
||||
"The colour family this backdrop drifts through — it changes as you step, so pick by " +
|
||||
"looking. Appearance only.",
|
||||
GamepadPalette.ALL.map { it.id to it.name },
|
||||
GamepadPalette.named(s.uiPalette).id,
|
||||
) { update(s.copy(uiPalette = it)) },
|
||||
choice(
|
||||
"hud", GpTab.INTERFACE, null, "Statistics overlay",
|
||||
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
|
||||
"A 3-finger tap cycles the tiers live.",
|
||||
STATS_VERBOSITY_OPTIONS, s.statsVerbosity,
|
||||
) { update(s.copy(statsVerbosity = it)) },
|
||||
toggle(
|
||||
"autoWake", GpTab.INTERFACE, null, "Auto-wake on connect",
|
||||
"Wake a saved host with Wake-on-LAN when it isn't seen on the network, then connect.",
|
||||
s.autoWakeEnabled,
|
||||
) { update(s.copy(autoWakeEnabled = it)) },
|
||||
toggle(
|
||||
"library", GpTab.INTERFACE, null, "Game library",
|
||||
"Browse a paired host's games with Y (experimental).",
|
||||
s.libraryEnabled,
|
||||
) { update(s.copy(libraryEnabled = it)) },
|
||||
toggle(
|
||||
"gamepadUI", GpTab.INTERFACE, null, "Controller-optimized UI",
|
||||
"Turn off to use the touch interface even with a controller connected.",
|
||||
s.gamepadUiEnabled,
|
||||
) { update(s.copy(gamepadUiEnabled = it)) },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The trailing Profiles section — the Android mirror of the desktop console's (design §5.2a, §5.4):
|
||||
* one row per catalog profile, valued with how many saved hosts pin it, activating into the
|
||||
* pin-to-hosts picker. Read-only beyond pinning: profiles are created and edited in the standard
|
||||
* interface, so an empty catalog shows one dimmed placeholder explaining where they come from
|
||||
* instead of a dead-looking empty header. On a TV that phrasing changes: "touch interface" points
|
||||
* instead of a dead-looking empty tab. On a TV that phrasing changes: "touch interface" points
|
||||
* nowhere useful on a touchless device, so the strings name the actual route — the
|
||||
* Controller-optimized UI toggle a few rows up, which swaps the standard interface in
|
||||
* (d-pad-navigable; the profile editor lives there on every device, unlike tvOS where none exists).
|
||||
@@ -574,7 +679,8 @@ private fun buildProfileRows(
|
||||
return listOf(
|
||||
GpRow(
|
||||
id = "noProfiles",
|
||||
header = "Profiles",
|
||||
tab = GpTab.PROFILES,
|
||||
header = null,
|
||||
label = "No profiles yet",
|
||||
value = "",
|
||||
detail = "Profiles bundle stream settings for different uses — pinned ones become " +
|
||||
@@ -586,12 +692,13 @@ private fun buildProfileRows(
|
||||
),
|
||||
)
|
||||
}
|
||||
return profiles.mapIndexed { i, p ->
|
||||
return profiles.map { p ->
|
||||
// Counted straight off the host records, so it agrees with what the carousel renders.
|
||||
val pins = savedHosts.count { p.id in it.pinnedProfileIds }
|
||||
GpRow(
|
||||
id = "profile:${p.id}",
|
||||
header = if (i == 0) "Profiles" else null,
|
||||
tab = GpTab.PROFILES,
|
||||
header = null,
|
||||
label = p.name,
|
||||
value = when (pins) {
|
||||
0 -> "Not pinned"
|
||||
|
||||
@@ -84,6 +84,9 @@ suspend fun connectToHost(
|
||||
// The host's approval-list / trust-store label for this device — the same
|
||||
// Build.MODEL convention the pairing dialogs use for nativePair.
|
||||
Build.MODEL ?: "Android",
|
||||
// Tier-A pad audio: ask for the 0xD1 plane only when a setting would render it, so a
|
||||
// user with it off does not make the host provision endpoints it will never feed.
|
||||
settings.padHaptics || settings.padSpeaker,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ fun LibraryScreen(
|
||||
onBack: () -> Unit,
|
||||
navActive: Boolean = true,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
BackHandler(onBack = onBack)
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -145,7 +146,14 @@ fun LibraryScreen(
|
||||
launching = false
|
||||
if (handle != 0L) {
|
||||
onLaunched(
|
||||
ActiveSession(handle, settings, host.clipboardSync),
|
||||
ActiveSession(
|
||||
handle,
|
||||
settings,
|
||||
host.clipboardSync,
|
||||
hostId = host.id,
|
||||
// Where to come back to when this game exits.
|
||||
launchedFromLibrary = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
else Toast.makeText(
|
||||
@@ -170,8 +178,8 @@ fun LibraryScreen(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
CircularProgressIndicator(color = Color.White)
|
||||
Text("Launching…", color = Color.White, style = MaterialTheme.typography.bodyLarge)
|
||||
CircularProgressIndicator(color = ink.fg)
|
||||
Text("Launching…", color = ink.fg, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,17 +203,19 @@ fun LibraryScreen(
|
||||
|
||||
@Composable
|
||||
private fun LoadingState() {
|
||||
val ink = LocalGamepadInk.current
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
CircularProgressIndicator(color = Color.White)
|
||||
Text("Loading library…", color = Color.White.copy(alpha = 0.7f), style = MaterialTheme.typography.bodyLarge)
|
||||
CircularProgressIndicator(color = ink.fg)
|
||||
Text("Loading library…", color = ink.fg(0.7f), style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageState(text: String) {
|
||||
val ink = LocalGamepadInk.current
|
||||
Text(
|
||||
text,
|
||||
color = Color.White.copy(alpha = 0.75f),
|
||||
color = ink.fg(0.75f),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
@@ -219,6 +229,7 @@ private fun Coverflow(
|
||||
navActive: Boolean,
|
||||
onLaunch: (GameEntry) -> Unit,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
// Fit a 2:3 poster into the height the detail line leaves; clamp so it never dwarfs the screen.
|
||||
val coverHeight = (maxHeight * 0.72f).coerceAtMost(360.dp)
|
||||
@@ -241,7 +252,22 @@ private fun Coverflow(
|
||||
onActivate = { games.getOrNull(navTarget)?.let(onLaunch) },
|
||||
)
|
||||
|
||||
// Design D4: the launcher entries lead the strip (the client groups them at parse time).
|
||||
// A coverflow is one-dimensional, so instead of a second focus rail the heading names the
|
||||
// group the cursor is in and changes as it crosses the boundary. Only drawn when the
|
||||
// library actually has both groups — otherwise the screen is exactly what it was.
|
||||
val bothGroups = games.any { it.isLauncher } && games.any { !it.isLauncher }
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) {
|
||||
if (bothGroups) {
|
||||
Text(
|
||||
if (current?.isLauncher == true) "LAUNCHERS" else "GAMES",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White.copy(alpha = 0.45f),
|
||||
letterSpacing = 2.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
pageSize = PageSize.Fixed(coverWidth),
|
||||
@@ -299,15 +325,16 @@ private fun Coverflow(
|
||||
current?.title ?: " ",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
color = ink.fg,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (current != null) {
|
||||
Text(
|
||||
if (current.isCustom) "CUSTOM" else "STEAM",
|
||||
if (current.isLauncher) "${current.storeLabel.uppercase()} \u00B7 LAUNCHER"
|
||||
else current.storeLabel.uppercase(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color.White.copy(alpha = 0.5f),
|
||||
color = ink.fg(0.5f),
|
||||
letterSpacing = 2.sp,
|
||||
)
|
||||
}
|
||||
@@ -319,6 +346,7 @@ private fun Coverflow(
|
||||
/** One cover: walks the art candidates (portrait → header → hero) then a text placeholder. */
|
||||
@Composable
|
||||
private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Modifier) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val candidates = game.art.posterCandidates
|
||||
var idx by remember(game.id) { mutableStateOf(0) }
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
@@ -326,7 +354,7 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo
|
||||
modifier = modifier
|
||||
.clip(shape)
|
||||
.background(Color(0xFF241F3D))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.12f), shape),
|
||||
.border(1.dp, ink.fg(0.12f), shape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (idx < candidates.size) {
|
||||
@@ -339,24 +367,29 @@ 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 rarely has poster art. Naming the launcher says "opens Steam"; the title
|
||||
// would read as "a game whose cover failed to load".
|
||||
Text(
|
||||
game.title,
|
||||
if (game.isLauncher) game.storeLabel else game.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White.copy(alpha = 0.75f),
|
||||
color = ink.fg(0.75f),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
}
|
||||
// Store badge, top-start.
|
||||
// Store badge, top-start — brand-filled for a launcher entry (design D4).
|
||||
Box(Modifier.fillMaxSize().padding(8.dp), contentAlignment = Alignment.TopStart) {
|
||||
Text(
|
||||
if (game.isCustom) "Custom" else "Steam",
|
||||
game.storeLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White,
|
||||
color = ink.fg,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(Color.Black.copy(alpha = 0.5f))
|
||||
.background(
|
||||
if (game.isLauncher) MaterialTheme.colorScheme.primary
|
||||
else Color.Black.copy(alpha = 0.5f),
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -105,6 +105,16 @@ data class Settings(
|
||||
* client's `libraryEnabled`.
|
||||
*/
|
||||
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), `"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.
|
||||
*/
|
||||
val uiPalette: String = "violet",
|
||||
/**
|
||||
* "Low-latency mode" — the master switch over the latency pipeline: the async decode loop
|
||||
* (native; burst-feed + present-newest-per-vsync, the Apple client's discipline), decoder ranking
|
||||
@@ -170,6 +180,26 @@ data class Settings(
|
||||
*/
|
||||
val dsCapture: Boolean = true,
|
||||
|
||||
/**
|
||||
* Render the host's DualSense **voice-coil haptics** on a captured USB pad (tier A).
|
||||
*
|
||||
* The pad's own 4-channel audio device carries them, driven directly over usbfs — Android's
|
||||
* audio framework denylists that device by VID/PID, so there is no supported route to it. The
|
||||
* two kinds are arbitrated rather than mixed, and on evidence: wire rumble is suppressed only
|
||||
* while haptics frames are actually arriving, so a title that drives classic rumble and sends
|
||||
* no haptics audio keeps rumbling. Off, or on an uncaptured/Bluetooth pad, the pad stays on
|
||||
* ordinary rumble (tier C), which on this client already drives the same actuators.
|
||||
*/
|
||||
val padHaptics: Boolean = true,
|
||||
|
||||
/**
|
||||
* Render the pad's **built-in speaker** on a captured USB pad. Independent of [padHaptics] —
|
||||
* the host sends the two as separate streams and either can play alone. Off by default: the
|
||||
* speaker is a small, easily-startling loudspeaker in the user's hands, and unlike haptics it
|
||||
* duplicates audio they are already hearing.
|
||||
*/
|
||||
val padSpeaker: Boolean = false,
|
||||
|
||||
/**
|
||||
* How a physical mouse drives the host — the cross-client mouse model (see [MouseMode]).
|
||||
* [MouseMode.DESKTOP] (default here) points absolutely; [MouseMode.CAPTURE] locks the pointer
|
||||
@@ -264,6 +294,7 @@ class SettingsStore(context: Context) {
|
||||
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
|
||||
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
|
||||
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
||||
uiPalette = prefs.getString(K_UI_PALETTE, "violet") ?: "violet",
|
||||
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
|
||||
presentPriority = prefs.getString(K_PRESENT_PRIORITY, "latency") ?: "latency",
|
||||
smoothBuffer = prefs.getInt(K_SMOOTH_BUFFER, 0),
|
||||
@@ -271,6 +302,8 @@ class SettingsStore(context: Context) {
|
||||
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
|
||||
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
|
||||
dsCapture = prefs.getBoolean(K_DS_CAPTURE, true),
|
||||
padHaptics = prefs.getBoolean(K_PAD_HAPTICS, true),
|
||||
padSpeaker = prefs.getBoolean(K_PAD_SPEAKER, false),
|
||||
mouseMode = prefs.getString(K_MOUSE_MODE, null)
|
||||
?.let { name -> MouseMode.entries.firstOrNull { it.storedName == name } }
|
||||
// Migration: the pre-enum Boolean "pointer_capture" (true = lock the pointer). Its
|
||||
@@ -301,6 +334,7 @@ class SettingsStore(context: Context) {
|
||||
.putString(K_TOUCH_MODE, s.touchMode.name)
|
||||
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
|
||||
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||
.putString(K_UI_PALETTE, s.uiPalette)
|
||||
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
|
||||
.putString(K_PRESENT_PRIORITY, s.presentPriority)
|
||||
.putInt(K_SMOOTH_BUFFER, s.smoothBuffer)
|
||||
@@ -308,6 +342,8 @@ class SettingsStore(context: Context) {
|
||||
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
|
||||
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
|
||||
.putBoolean(K_DS_CAPTURE, s.dsCapture)
|
||||
.putBoolean(K_PAD_HAPTICS, s.padHaptics)
|
||||
.putBoolean(K_PAD_SPEAKER, s.padSpeaker)
|
||||
.putString(K_MOUSE_MODE, s.mouseMode.storedName)
|
||||
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
|
||||
.apply()
|
||||
@@ -337,6 +373,7 @@ class SettingsStore(context: Context) {
|
||||
const val K_TOUCH_MODE = "touch_mode"
|
||||
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
|
||||
const val K_LIBRARY = "library_enabled"
|
||||
const val K_UI_PALETTE = "ui_palette"
|
||||
|
||||
/**
|
||||
* Bumped AGAIN to restart every install at the new default (ON). History: the original
|
||||
@@ -355,6 +392,8 @@ class SettingsStore(context: Context) {
|
||||
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
|
||||
const val K_SC2_CAPTURE = "sc2_capture"
|
||||
const val K_DS_CAPTURE = "ds_capture"
|
||||
const val K_PAD_HAPTICS = "pad_haptics"
|
||||
const val K_PAD_SPEAKER = "pad_speaker"
|
||||
const val K_MOUSE_MODE = "mouse_mode"
|
||||
|
||||
/** Legacy Boolean the [K_MOUSE_MODE] enum replaced — read once for migration, never written. */
|
||||
@@ -398,6 +437,96 @@ fun nativeDisplayMode(context: Context): Triple<Int, Int, Int> {
|
||||
return Triple(maxOf(w, h), minOf(w, h), hz)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sentinel [Settings.width]/[Settings.height] meaning "the native mode, narrowed so the picture
|
||||
* clears the display cutout and the rounded corners" — resolved at connect by [safeDisplayMode],
|
||||
* exactly as `0` is resolved by [nativeDisplayMode]. Negative, so it can never collide with a real
|
||||
* size; distinct from the UI's `-1` "Custom…" sentinel.
|
||||
*/
|
||||
const val SAFE_AREA_MODE = -2
|
||||
|
||||
/**
|
||||
* Safe-area stream geometry — the pure part, so it is unit-testable without a Display.
|
||||
*
|
||||
* The phone clips the picture in HARDWARE: the cutout (notch / punch-hole) and the four rounded
|
||||
* corners eat whatever the stream draws under them. [StreamScreen] deliberately draws edge-to-edge
|
||||
* (`LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS`) and centres the video at its own aspect ratio
|
||||
* (`Modifier.aspectRatio`), so which pixels survive is decided purely by the mode's aspect:
|
||||
*
|
||||
* * A 16:9 mode on a 20:9 phone pillarboxes, and those black bars land exactly on the unsafe
|
||||
* regions — which is why the presets have always "just worked".
|
||||
* * The NATIVE mode has the panel's own aspect, so it fills every pixel, cutout and corners
|
||||
* included. That is the mode that loses its corners.
|
||||
*
|
||||
* So asking the host for a mode narrower by the unsafe inset is the entire fix: the existing
|
||||
* aspect-fit centres it inside the safe region, and pointer mapping follows for free (MouseInput
|
||||
* derives the picture rect from the live video size, not from the window).
|
||||
*/
|
||||
object SafeArea {
|
||||
/** The host rejects odd dimensions and anything under 320 px wide (`validate_dimensions`). */
|
||||
const val MIN_WIDTH = 320
|
||||
|
||||
/**
|
||||
* [nativeWidth] reduced by [perSideInsetPx] on each side, even-floored and clamped to the
|
||||
* host's floor. Height is deliberately untouched: under aspect-fit only one axis can bind, and
|
||||
* on a landscape phone that axis is always the horizontal one — insetting height as well would
|
||||
* shrink the picture without uncovering anything.
|
||||
*/
|
||||
fun insetWidth(nativeWidth: Int, perSideInsetPx: Int): Int {
|
||||
val inset = perSideInsetPx.coerceAtLeast(0)
|
||||
return (nativeWidth - inset * 2).coerceAtLeast(MIN_WIDTH) / 2 * 2
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-side inset, in pixels, that the **landscape** stream must clear on this display.
|
||||
*
|
||||
* Two contributions, and the larger wins:
|
||||
* * **The cutout.** [DisplayCutout] is rotation-aware, so in landscape the housing shows up on
|
||||
* `left`/`right`. The settings screen may be portrait though, where the very same housing is
|
||||
* reported on `top`/`bottom` and the horizontal insets read zero — which would compute "no inset
|
||||
* needed" for exactly the devices that need one. The stream is always landscape, so a vertical
|
||||
* inset now becomes a horizontal one then: fall back to it.
|
||||
* * **The rounded corners.** These are NOT part of the cutout insets. For a FULL-HEIGHT picture the
|
||||
* horizontal clearance a corner of radius `r` needs is exactly `r`: at the topmost row the
|
||||
* display boundary sits at `x = r`, so anything left of that is clipped. Not conservative — it is
|
||||
* the precise requirement for a picture that spans the full height.
|
||||
*
|
||||
* `0` when the display has neither, which makes the safe mode identical to the native one.
|
||||
*/
|
||||
private fun displaySideInsetPx(context: Context): Int {
|
||||
val display = probeDisplay(context) ?: return 0
|
||||
var inset = 0
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
display.cutout?.let { cut ->
|
||||
val horizontal = maxOf(cut.safeInsetLeft, cut.safeInsetRight)
|
||||
val vertical = maxOf(cut.safeInsetTop, cut.safeInsetBottom)
|
||||
inset = maxOf(inset, if (horizontal > 0) horizontal else vertical)
|
||||
}
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
for (position in intArrayOf(
|
||||
android.view.RoundedCorner.POSITION_TOP_LEFT,
|
||||
android.view.RoundedCorner.POSITION_TOP_RIGHT,
|
||||
android.view.RoundedCorner.POSITION_BOTTOM_LEFT,
|
||||
android.view.RoundedCorner.POSITION_BOTTOM_RIGHT,
|
||||
)) {
|
||||
display.getRoundedCorner(position)?.let { inset = maxOf(inset, it.radius) }
|
||||
}
|
||||
}
|
||||
return inset
|
||||
}
|
||||
|
||||
/**
|
||||
* The native mode narrowed to clear the cutout and the rounded corners — the [SAFE_AREA_MODE]
|
||||
* resolution, as a landscape `(width, height, hz)`. Same height and refresh as [nativeDisplayMode];
|
||||
* only the width moves.
|
||||
*/
|
||||
fun safeDisplayMode(context: Context): Triple<Int, Int, Int> {
|
||||
val (w, h, hz) = nativeDisplayMode(context)
|
||||
return Triple(SafeArea.insetWidth(w, displaySideInsetPx(context)), h, hz)
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this device's display can actually present HDR10, so we should advertise HDR to the
|
||||
* host. On an SDR panel we advertise `0` instead — the host then sends a proper 8-bit BT.709 stream
|
||||
@@ -432,12 +561,21 @@ fun displaySupportsHdr(context: Context): Boolean {
|
||||
return supported
|
||||
}
|
||||
|
||||
/** Resolve [Settings] (with its 0=native placeholders) to the concrete mode to request. */
|
||||
/**
|
||||
* Resolve [Settings] (with its `0`=native and [SAFE_AREA_MODE] placeholders) to the concrete mode to
|
||||
* request. The safe-area sentinel is checked first because it resolves BOTH axes together — it is one
|
||||
* mode, not an independent width and height, and mixing half of it with a native height would ask
|
||||
* for a size neither sentinel means.
|
||||
*/
|
||||
fun Settings.effectiveMode(context: Context): Triple<Int, Int, Int> {
|
||||
val native = nativeDisplayMode(context)
|
||||
val w = if (width > 0) width else native.first
|
||||
val h = if (height > 0) height else native.second
|
||||
val hz = if (hz > 0) hz else native.third
|
||||
val base = if (width == SAFE_AREA_MODE && height == SAFE_AREA_MODE) {
|
||||
safeDisplayMode(context)
|
||||
} else {
|
||||
nativeDisplayMode(context)
|
||||
}
|
||||
val w = if (width > 0) width else base.first
|
||||
val h = if (height > 0) height else base.second
|
||||
val hz = if (hz > 0) hz else base.third
|
||||
return Triple(w, h, hz)
|
||||
}
|
||||
|
||||
@@ -491,9 +629,10 @@ val RENDER_SCALE_OPTIONS = RenderScale.PRESETS.map { it to RenderScale.label(it)
|
||||
|
||||
// ---- UI option tables (value, label). The first entry is always the "auto/native" default. ----
|
||||
|
||||
/** (width, height, label). `(0,0)` = native display. */
|
||||
/** (width, height, label). `(0,0)` = native display; [SAFE_AREA_MODE] = native minus the cutout. */
|
||||
val RESOLUTION_OPTIONS = listOf(
|
||||
Triple(0, 0, "Native display"),
|
||||
Triple(SAFE_AREA_MODE, SAFE_AREA_MODE, "Native display (safe area)"),
|
||||
Triple(1280, 720, "1280 × 720"),
|
||||
Triple(1920, 1080, "1920 × 1080"),
|
||||
Triple(2560, 1440, "2560 × 1440"),
|
||||
|
||||
@@ -603,6 +603,10 @@ private fun GeneralSettings(s: Settings, update: (Settings) -> Unit) {
|
||||
@Composable
|
||||
private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: android.content.Context) {
|
||||
val (nw, nh, nhz) = nativeDisplayMode(context)
|
||||
// The safe-area row carries its resolved size the same way the native row does. On a display with
|
||||
// no cutout and square corners this equals the native mode — the row stays, honestly showing that
|
||||
// it changes nothing here, rather than silently vanishing on some devices and not others.
|
||||
val (sw, sh, _) = safeDisplayMode(context)
|
||||
// "Custom…" picked while the stored size is still a preset — keeps the size fields visible
|
||||
// until an edit actually makes it custom (or a preset is re-picked). Custom itself is detected
|
||||
// from the stored size, never flagged (see [isCustomResolution]), so nothing new persists.
|
||||
@@ -611,7 +615,13 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
|
||||
SettingsGroup("Resolution") {
|
||||
SettingDropdown(
|
||||
label = "Resolution",
|
||||
options = RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl) } +
|
||||
options = RESOLUTION_OPTIONS.map { (w, h, lbl) ->
|
||||
(w to h) to when (w) {
|
||||
0 -> "$lbl ($nw × $nh)"
|
||||
SAFE_AREA_MODE -> "$lbl ($sw × $sh)"
|
||||
else -> lbl
|
||||
}
|
||||
} +
|
||||
// The (-1, -1) sentinel can't collide with a real size; once a custom size is
|
||||
// stored its label carries the live value, like the native row carries ($nw × $nh).
|
||||
((-1 to -1) to if (s.isCustomResolution()) "Custom (${s.width} × ${s.height})" else "Custom…"),
|
||||
@@ -620,7 +630,10 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
|
||||
caption = "The host makes a display exactly this size — no scaling. Native follows " +
|
||||
"this device's panel.",
|
||||
) { (w, h) ->
|
||||
if (w < 0) {
|
||||
// ONLY -1 is "Custom…". The other negative value is the safe-area sentinel, which is a
|
||||
// stored mode like any preset — a blanket `w < 0` here would open the custom fields for it
|
||||
// and overwrite it with a concrete size.
|
||||
if (w == -1) {
|
||||
// Seed from the current *effective* size so the fields start from something
|
||||
// sensible (the resolved native mode, not the 0 × 0 placeholder).
|
||||
customPicked = true
|
||||
@@ -896,6 +909,22 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
|
||||
enabled = s.gamepadForwarding,
|
||||
onCheckedChange = { on -> update(s.copy(dsCapture = on)) },
|
||||
)
|
||||
// Both only ever apply to a captured pad, so they follow that row and gate on it.
|
||||
ToggleRow(
|
||||
title = "Controller haptics",
|
||||
subtitle = "Play the host's fine-grained DualSense haptics on the pad itself — " +
|
||||
"the pad keeps ordinary rumble for games that don't send them",
|
||||
checked = s.padHaptics,
|
||||
enabled = s.gamepadForwarding && s.dsCapture,
|
||||
onCheckedChange = { on -> update(s.copy(padHaptics = on)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Controller speaker",
|
||||
subtitle = "Play audio the game sends to the controller's own speaker",
|
||||
checked = s.padSpeaker,
|
||||
enabled = s.gamepadForwarding && s.dsCapture,
|
||||
onCheckedChange = { on -> update(s.copy(padSpeaker = on)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,13 +26,25 @@ import kotlin.math.roundToInt
|
||||
* 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
|
||||
* every tier, and the detailed tier names what was excluded on its own line. The principle is the
|
||||
* Apple client's: metrics report what Punktfunk controls, so the compositor's own latch and scanout
|
||||
* — which no client can pace under — is reported rather than charged. It also stops the HUD reading
|
||||
* worse than it is: the usual Android streaming overlays stop measuring at decode-complete, so a
|
||||
* headline that carried the compositor's wait was compared against numbers that never contained it.
|
||||
*
|
||||
* The RAW figures are not lost — the native 1 Hz `pf.present` logcat line keeps `paceMs`, `latchMs`
|
||||
* and `e2eMs` unshaved, so a HUD-off A/B and any cross-session comparison still work off the
|
||||
* untouched numbers.
|
||||
*
|
||||
* [verbosity] selects how many lines render (each tier a superset of the last — see
|
||||
* [StatsVerbosity]):
|
||||
* - [StatsVerbosity.COMPACT] — one line, `fps · end-to-end ms · Mb/s` (+ a loss flag).
|
||||
* - [StatsVerbosity.NORMAL] — the res/fps/Mb·s line, the end-to-end p50/p95 headline, and the
|
||||
* reliability counters (18–21) when nonzero.
|
||||
* - [StatsVerbosity.DETAILED] — also the decoder label, the video-feed descriptor (10–13), and the
|
||||
* stage equation (14/15, split into `host + network` when the Phase-2 terms at 16/17 are nonzero).
|
||||
* - [StatsVerbosity.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),
|
||||
* 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).
|
||||
*/
|
||||
@@ -95,9 +107,15 @@ internal fun StatsOverlay(
|
||||
// equation gains its `display` term; otherwise (older lib / no callbacks) the endpoint
|
||||
// honestly stays capture→decoded — the equation always tiles the headline interval.
|
||||
val dispValid = s.size >= 26 && s[22] != 0.0
|
||||
// The OS present floor this window (see [osFloorMs]) is excluded from every shown
|
||||
// display / end-to-end number, at every tier — it is pipeline depth no client can pace
|
||||
// under, so charging it to Punktfunk made our HUD read worse than clients that simply
|
||||
// never measure it. 0.0 when unmeasured, which leaves the numbers exactly as raw as
|
||||
// they were.
|
||||
val floorMs = osFloorMs(s)
|
||||
val tag = if (skew) "" else " (same-host clock)"
|
||||
val (p50, p95, endpoint) = if (dispValid) {
|
||||
Triple(s[24], s[25], "capture→displayed")
|
||||
Triple(shave(s[24], floorMs), shave(s[25], floorMs), "capture→displayed")
|
||||
} else {
|
||||
Triple(s[2], s[3], "capture→decoded")
|
||||
}
|
||||
@@ -120,6 +138,11 @@ internal fun StatsOverlay(
|
||||
// dropping/serializing, an fps deficit is upstream.
|
||||
val split = s.size >= 30 && s[29] != 0.0 && (s[26] > 0 || s[27] > 0)
|
||||
val displayTerm = when {
|
||||
// Floor excluded: what remains of the `display` term is the half Punktfunk
|
||||
// owns (the presenter's pace wait), and the excluded line below carries the
|
||||
// latch — printing the split too would report the same milliseconds twice.
|
||||
dispValid && floorMs > 0 ->
|
||||
" + display ${"%.1f".format(shave(s[23], floorMs))}"
|
||||
dispValid && split ->
|
||||
" + display ${"%.1f".format(s[23])} " +
|
||||
"(pace ${"%.1f".format(s[26])} + latch ${"%.1f".format(s[27])})"
|
||||
@@ -143,16 +166,14 @@ internal fun StatsOverlay(
|
||||
"= $hostTerms + $decodeTerm$displayTerm$presents",
|
||||
Color.White,
|
||||
)
|
||||
// Metric fairness: the Apple client's HUD shaves ~2 refresh periods of OS
|
||||
// pipeline floor off its shown display/end-to-end; Android shows raw. This twin
|
||||
// applies the same shave so iPhone↔Android HUD numbers compare directly.
|
||||
if (dispValid && hz > 0) {
|
||||
val shave = 2000.0 / hz
|
||||
// What the numbers above leave out, named — the Apple client's
|
||||
// `os present +N excluded` line, same wording so the two HUDs read alike.
|
||||
// (This replaces the old "≈ Apple-HUD equiv" twin: both clients now shave, and
|
||||
// Android's shave is measured rather than assumed at 2 refresh periods.)
|
||||
if (floorMs > 0) {
|
||||
statLine(
|
||||
"≈ Apple-HUD equiv: end-to-end " +
|
||||
"${"%.1f".format((s[24] - shave).coerceAtLeast(0.0))} · display " +
|
||||
"${"%.1f".format((s[23] - shave).coerceAtLeast(0.0))} (−2 refresh)",
|
||||
Color(0xFFA8D8B8),
|
||||
"os present +${"%.1f".format(floorMs)} excluded (display pipeline minimum)",
|
||||
Color(0xFF9AA6B8),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -167,6 +188,37 @@ private fun statLine(text: String, color: Color) {
|
||||
Text(text, color = color, fontFamily = FontFamily.Monospace, fontSize = 12.sp)
|
||||
}
|
||||
|
||||
/**
|
||||
* The OS present floor to exclude from the shown `display` / `end-to-end` numbers, ms — the
|
||||
* measured `latch` p50 at index 27, i.e. release→`OnFrameRendered`: SurfaceFlinger's own latch and
|
||||
* scanout. That is compositor pipeline depth no client can pace under, so it is reported as
|
||||
* excluded rather than charged to Punktfunk — the Apple client's policy since its presentation
|
||||
* rebuild, where the same floor is measured from the display link's vend lead.
|
||||
*
|
||||
* Measured, not assumed: the previous Android treatment used a fixed `2000/hz` twin, but the latch
|
||||
* varies with panel rate, tunnelled playback and the vendor's low-latency mode (~21 ms p50 observed
|
||||
* where the ~2-interval model predicts less), and this term self-adapts to all three. It is also
|
||||
* available on every render path — the presenter's and both legacy release-immediately ones — since
|
||||
* the release stamp it starts from is parked on every render, so it does not depend on
|
||||
* `presenterActive` (29).
|
||||
*
|
||||
* `0.0` means unmeasured — no display stage this window (an older native lib, API < 33, or a
|
||||
* platform that refused the callback), or no latch sample paired — and every caller then leaves its
|
||||
* number raw, which is the honest fallback: we exclude only what we actually measured.
|
||||
*/
|
||||
private fun osFloorMs(s: DoubleArray): Double {
|
||||
val dispValid = s.size >= 26 && s[22] != 0.0
|
||||
if (!dispValid || s.size < 28) return 0.0
|
||||
return s[27].coerceAtLeast(0.0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtract the excluded [floorMs] from a shown latency [ms], clamped at zero — the percentiles are
|
||||
* drawn from different sample sets (a p50 latch against a p50/p95 end-to-end), so the difference can
|
||||
* legitimately go slightly negative on a well-paced window without anything being wrong.
|
||||
*/
|
||||
private fun shave(ms: Double, floorMs: Double): Double = (ms - floorMs).coerceAtLeast(0.0)
|
||||
|
||||
/**
|
||||
* The single [StatsVerbosity.COMPACT] line: `238 fps · 1.3 ms · 921 Mb/s`. The end-to-end p50 term
|
||||
* is dropped when no in-range latency sample landed (`latValid` false), and a loss flag
|
||||
@@ -174,8 +226,9 @@ private fun statLine(text: String, color: Color) {
|
||||
* one reliability signal worth surfacing even at the tersest tier.
|
||||
*/
|
||||
private fun compactLine(s: DoubleArray, latValid: Boolean): String {
|
||||
// Prefer the capture→displayed end-to-end (s[24]) when a render timestamp landed this window.
|
||||
val e2eP50 = if (s.size >= 26 && s[22] != 0.0) s[24] else s[2]
|
||||
// Prefer the capture→displayed end-to-end (s[24]) when a render timestamp landed this window,
|
||||
// less the excluded OS present floor — the same number the richer tiers headline.
|
||||
val e2eP50 = if (s.size >= 26 && s[22] != 0.0) shave(s[24], osFloorMs(s)) else s[2]
|
||||
val parts = buildList {
|
||||
add("${s[0].roundToInt()} fps")
|
||||
if (latValid) add("${"%.1f".format(e2eP50)} ms")
|
||||
|
||||
@@ -73,6 +73,7 @@ import io.unom.punktfunk.kit.GamepadRouter
|
||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.Sc2Capture
|
||||
import io.unom.punktfunk.kit.SessionEndReason
|
||||
import io.unom.punktfunk.kit.VideoDecoders
|
||||
import io.unom.punktfunk.models.ActiveSession
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
@@ -86,7 +87,7 @@ import kotlinx.coroutines.delay
|
||||
* the connect that produced this handle.
|
||||
*/
|
||||
@Composable
|
||||
fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> Unit) {
|
||||
val handle = session.handle
|
||||
val initialSettings = session.settings
|
||||
val micEnabled = initialSettings.micEnabled
|
||||
@@ -200,12 +201,32 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
while (true) {
|
||||
delay(1000)
|
||||
if (NativeBridge.nativeSessionEnded(handle)) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Connection lost — the host may be asleep. Wake it to reconnect.",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
onDisconnect()
|
||||
// WHY it ended decides what the user is told. This used to show the "host may be
|
||||
// asleep" line for EVERY ending — including a game the player had just quit and a
|
||||
// session the host ended on purpose — which reads as a failure report for
|
||||
// something nobody did wrong. Only a connection that actually died says that now.
|
||||
val reason = SessionEndReason.fromNative(NativeBridge.nativeEndReason(handle))
|
||||
when (reason) {
|
||||
SessionEndReason.LOST ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Connection lost — the host may be asleep. Wake it to reconnect.",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
SessionEndReason.HOST_ERROR ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
"The host ended the session with an error.",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
// Deliberate endings — the player quit the game, the host was stopped, or we
|
||||
// closed it. Leaving the stream IS the feedback; a toast would only add noise.
|
||||
SessionEndReason.GAME_EXITED,
|
||||
SessionEndReason.HOST_ENDED,
|
||||
SessionEndReason.LOCAL,
|
||||
SessionEndReason.NONE -> {}
|
||||
}
|
||||
onSessionEnded(reason)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
}
|
||||
@@ -330,7 +351,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
// the keep-alive linger), unlike a host-ended / backgrounded drop. The router debounces it
|
||||
// (must be held ~1.5 s) and fires onExitChord on its main-thread timer, so leave the stream
|
||||
// the same way the Back gesture does.
|
||||
activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
||||
activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onSessionEnded(SessionEndReason.LOCAL) }
|
||||
router.onExitChord = { activity?.requestStreamExit?.invoke() }
|
||||
// Show a "hold to quit" hint the moment the chord completes (the router debounces the actual
|
||||
// exit); it clears when the buttons release early or the hold elapses. Runs on the main thread.
|
||||
@@ -507,6 +528,28 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
var dsUsbReceiver: BroadcastReceiver? = null
|
||||
if (ds != null) {
|
||||
feedback.sink = ds
|
||||
// Tier-A pad audio: render the host's 0xD1 streams on the pad's own 4-channel USB
|
||||
// audio device. Bound here rather than inside DsCapture because the session handle
|
||||
// lives at this layer; DsCapture decides WHEN (it knows the wire index and the link
|
||||
// lifetime), this decides WHETHER.
|
||||
if (initialSettings.padHaptics || initialSettings.padSpeaker) {
|
||||
ds.padAudio = object : DsCapture.PadAudioHook {
|
||||
override fun start(pad: Int, fd: Int) {
|
||||
val ok = NativeBridge.nativeStartPadAudio(
|
||||
handle,
|
||||
pad,
|
||||
fd,
|
||||
initialSettings.padHaptics,
|
||||
initialSettings.padSpeaker,
|
||||
)
|
||||
Log.i("punktfunk", "pad audio on pad $pad: ${if (ok) "started" else "unavailable"}")
|
||||
}
|
||||
|
||||
// Returns only once the render thread is joined — DsCapture calls this before
|
||||
// closing the connection whose descriptor that thread borrows.
|
||||
override fun stop(pad: Int) = NativeBridge.nativeStopPadAudio(handle, pad)
|
||||
}
|
||||
}
|
||||
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
val usbDev = ds.findUsbDevice()
|
||||
when {
|
||||
@@ -595,7 +638,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
}
|
||||
|
||||
// Back gesture = a deliberate exit → signal the quit so the host tears down now (no linger).
|
||||
BackHandler { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
||||
BackHandler { NativeBridge.nativeDisconnectQuit(handle); onSessionEnded(SessionEndReason.LOCAL) }
|
||||
|
||||
// Leaving the app (Home, task switch, screen off) MUST end the session. Android does not
|
||||
// suspend a process for going to background, so without this the native worker kept running and
|
||||
@@ -603,14 +646,14 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
// host still saw a live client and held the session (and its display + encoder) open until the
|
||||
// OS eventually reclaimed the process, which on a TV box is effectively never.
|
||||
//
|
||||
// Route it through `onDisconnect()` so the composable's `onDispose` above runs the one real
|
||||
// Route it through `onSessionEnded()` so the composable's `onDispose` above runs the one real
|
||||
// teardown path. Deliberately NOT a `nativeDisconnectQuit`: backgrounding isn't a user "quit",
|
||||
// so the host should linger the display and make coming straight back a fast reconnect.
|
||||
DisposableEffect(handle) {
|
||||
val lifecycle = (context as? LifecycleOwner)?.lifecycle
|
||||
val obs = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_STOP) {
|
||||
onDisconnect()
|
||||
onSessionEnded(SessionEndReason.LOCAL)
|
||||
}
|
||||
}
|
||||
lifecycle?.addObserver(obs)
|
||||
|
||||
@@ -61,6 +61,16 @@ data class ActiveSession(
|
||||
* from "a different host" (a notice; a URL may never preempt a live session).
|
||||
*/
|
||||
val hostId: String? = null,
|
||||
/**
|
||||
* This session was started by launching a title from [hostId]'s library, rather than by
|
||||
* connecting to the host's desktop.
|
||||
*
|
||||
* Decides where the client goes when the session ENDS: a title launched out of a library
|
||||
* belongs back in that library when its game exits — one press from the next one — not on the
|
||||
* host-selection screen. Only meaningful together with a
|
||||
* [io.unom.punktfunk.kit.SessionEndReason.GAME_EXITED] ending.
|
||||
*/
|
||||
val launchedFromLibrary: Boolean = false,
|
||||
)
|
||||
|
||||
/** Trust state of a host, shown as a colored pill on its card. */
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
// The console UI's background palettes. These assertions are the CONTRACT the Rust
|
||||
// (`pf-console-ui::library`) and Swift (`GamepadPalette.swift`) ports reproduce — the same ids in
|
||||
// the same order, the same light/dark split, the same ramp — so one `ui_palette` value is one look
|
||||
// on every client.
|
||||
class GamepadPaletteTest {
|
||||
|
||||
private fun luma(c: Triple<Double, Double, Double>) =
|
||||
0.2126 * c.first + 0.7152 * c.second + 0.0722 * c.third
|
||||
|
||||
/** Hue angle in degrees, or null for something too grey to have one. */
|
||||
private fun hue(c: Triple<Double, Double, Double>): Double? {
|
||||
val (r, g, b) = c
|
||||
val max = maxOf(r, g, b)
|
||||
val min = minOf(r, g, b)
|
||||
val d = max - min
|
||||
if (d < 0.04) return null
|
||||
val h = when (max) {
|
||||
r -> 60.0 * (((g - b) / d) % 6.0)
|
||||
g -> 60.0 * ((b - r) / d + 2.0)
|
||||
else -> 60.0 * ((r - g) / d + 4.0)
|
||||
}
|
||||
return (h + 360.0) % 360.0
|
||||
}
|
||||
|
||||
/** Ids, order and the light/dark split are the cross-client contract. */
|
||||
@Test
|
||||
fun tableMatchesTheOtherClients() {
|
||||
assertEquals(
|
||||
listOf(
|
||||
"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(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)
|
||||
assertEquals("violet", GamepadPalette.named("").id)
|
||||
// The brand default keeps the shipped field rather than a generated ramp.
|
||||
assertTrue(GamepadPalette.named("violet").stops.isEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* A palette must read as SEVERAL hues, not one hue at several brightnesses — that was exactly
|
||||
* the complaint about the hue-rotation model this replaced.
|
||||
*/
|
||||
@Test
|
||||
fun everyPaletteIsMultiTone() {
|
||||
for (p in GamepadPalette.ALL) {
|
||||
val stops = p.stops.ifEmpty { continue }
|
||||
val hues = stops.mapNotNull { hue(it) }
|
||||
assertTrue("${p.id}: too few coloured stops", hues.size >= 3)
|
||||
var spread = 0.0
|
||||
for (a in hues) {
|
||||
for (b in hues) {
|
||||
val d = Math.abs(a - b) % 360.0
|
||||
spread = maxOf(spread, minOf(d, 360.0 - d))
|
||||
}
|
||||
}
|
||||
// Graphite and Opal are deliberately near-neutral; the rest must travel.
|
||||
val floor = if (p.id == "graphite" || p.id == "opal") 20.0 else 45.0
|
||||
assertTrue("${p.id} spans only $spread° of hue", spread >= floor)
|
||||
}
|
||||
}
|
||||
|
||||
/** A pale palette really is pale — its ink flips, so a mislabelled one is unreadable. */
|
||||
@Test
|
||||
fun palettesAreHonestAboutLightness() {
|
||||
for (p in GamepadPalette.ALL) {
|
||||
if (p.light) {
|
||||
assertTrue("${p.id}'s ground is dark", luma(p.ground) > 0.6)
|
||||
assertTrue("${p.id}'s accent is too pale", luma(p.accent) < 0.45)
|
||||
} else {
|
||||
assertTrue("${p.id}'s ground is light", luma(p.ground) < 0.2)
|
||||
assertTrue("${p.id}'s accent is too dark", luma(p.accent) > 0.25)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The ramp is the shared sampling rule the Rust and Swift ports reproduce. */
|
||||
@Test
|
||||
fun rampInterpolatesBetweenStops() {
|
||||
val stops = listOf(
|
||||
Triple(0.0, 0.0, 0.0), Triple(1.0, 0.0, 0.0), Triple(1.0, 1.0, 1.0),
|
||||
)
|
||||
assertEquals(Triple(0.0, 0.0, 0.0), GamepadPalette.ramp(stops, 0.0))
|
||||
assertEquals(Triple(1.0, 1.0, 1.0), GamepadPalette.ramp(stops, 1.0))
|
||||
assertEquals(Triple(1.0, 0.0, 0.0), GamepadPalette.ramp(stops, 0.5))
|
||||
assertEquals(0.5, GamepadPalette.ramp(stops, 0.25).first, 1e-9)
|
||||
// Out of range clamps rather than throwing.
|
||||
assertEquals(Triple(0.0, 0.0, 0.0), GamepadPalette.ramp(stops, -3.0))
|
||||
assertEquals(Triple(1.0, 1.0, 1.0), GamepadPalette.ramp(stops, 9.0))
|
||||
assertEquals(Triple(0.0, 0.0, 0.0), GamepadPalette.ramp(emptyList(), 0.5))
|
||||
}
|
||||
|
||||
/** The ink a palette calls for: white on a dark field, near-black on a pale one. */
|
||||
@Test
|
||||
fun inkFollowsTheField() {
|
||||
val dark = GamepadInk.of(GamepadPalette.named("violet"))
|
||||
assertTrue(!dark.isLight)
|
||||
assertEquals(1f, dark.fg.red, 1e-6f)
|
||||
assertEquals(1f, dark.shadeScale, 1e-6f)
|
||||
|
||||
val light = GamepadInk.of(GamepadPalette.named("holo"))
|
||||
assertTrue(light.isLight)
|
||||
assertTrue("pale fields need dark ink", light.fg.red < 0.3f)
|
||||
// A pale field's scrims must pull far less, or they bleach the gradient.
|
||||
assertTrue(light.shadeScale < 0.5f)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every settings row lands in exactly one tab — a row missing from the tab map is a setting
|
||||
* that became unreachable on a TV, which is precisely what this screen exists to prevent.
|
||||
*/
|
||||
@Test
|
||||
fun everySettingsRowHasATab() {
|
||||
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.
|
||||
assertTrue(rows.none { it.tab == GpTab.PROFILES })
|
||||
for (t in listOf(GpTab.STREAM, GpTab.VIDEO, GpTab.AUDIO, GpTab.CONTROLLER, GpTab.INTERFACE)) {
|
||||
assertTrue("$t is empty", rows.any { it.tab == t })
|
||||
}
|
||||
}
|
||||
|
||||
/** The Background row steps the shared `ui_palette` key and wraps on A, like every choice row. */
|
||||
@Test
|
||||
fun backgroundRowStepsTheSharedKey() {
|
||||
var s = Settings()
|
||||
fun rows() = buildSettingsRows(s, hasBodyVibrator = false, av1Capable = false) { s = it }
|
||||
fun palette() = rows().first { it.id == "palette" }
|
||||
|
||||
assertEquals("violet", s.uiPalette)
|
||||
assertEquals("Violet", palette().value)
|
||||
assertTrue("already the first = thud", !palette().adjust(-1))
|
||||
assertTrue(palette().adjust(1))
|
||||
assertEquals(GamepadPalette.ALL[1].id, s.uiPalette)
|
||||
|
||||
// A from the last entry wraps home.
|
||||
s = s.copy(uiPalette = GamepadPalette.ALL.last().id)
|
||||
palette().activate()
|
||||
assertEquals("violet", s.uiPalette)
|
||||
|
||||
// A store written by a newer client shows the palette that is actually drawing.
|
||||
s = s.copy(uiPalette = "chartreuse")
|
||||
assertEquals("Violet", palette().value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pure JVM test of the safe-area stream geometry ([SafeArea]) and the sentinel that selects it —
|
||||
* the width-only inset that keeps the picture clear of the cutout and the rounded corners.
|
||||
* Run: `./gradlew :app:testDebugUnitTest`.
|
||||
*/
|
||||
class SafeAreaTest {
|
||||
@Test
|
||||
fun insetsBothSidesAndStaysHostValid() {
|
||||
// A punch-hole phone: 2400 px wide, 96 px of unsafe edge per side → 2208.
|
||||
assertEquals(2400 - 96 * 2, SafeArea.insetWidth(2400, 96))
|
||||
// Odd results even-floor — the host rejects odd dimensions outright, and an inset
|
||||
// subtraction lands odd about half the time.
|
||||
assertEquals(0, SafeArea.insetWidth(2401, 95) % 2)
|
||||
// No cutout and square corners → the native width, unchanged.
|
||||
assertEquals(2400, SafeArea.insetWidth(2400, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun absurdInsetsCannotDriveTheModeUnderTheHostFloor() {
|
||||
assertEquals(SafeArea.MIN_WIDTH, SafeArea.insetWidth(1280, 5000))
|
||||
// A negative reading is treated as no inset rather than widening past the panel.
|
||||
assertEquals(1280, SafeArea.insetWidth(1280, -40))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun safeModeIsNarrowerThanNativeWheneverThereIsAnInset() {
|
||||
val native = 2556
|
||||
assertTrue(SafeArea.insetWidth(native, 60) < native)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theSentinelIsAPresetAndNeverReadsAsCustom() {
|
||||
// The safe-area mode is a stored preset, not a typed size: `isCustomResolution` must be
|
||||
// false for it, or the touch settings would open the custom width/height fields on it and
|
||||
// the gamepad screen would prepend a bogus "Custom · -2 × -2" row.
|
||||
val s = Settings(width = SAFE_AREA_MODE, height = SAFE_AREA_MODE)
|
||||
assertTrue(!s.isCustomResolution())
|
||||
// And it must be distinct from the UI's own "Custom…" sentinel (-1).
|
||||
assertTrue(SAFE_AREA_MODE != -1)
|
||||
assertTrue(RESOLUTION_OPTIONS.any { it.first == SAFE_AREA_MODE && it.second == SAFE_AREA_MODE })
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,14 @@ class ScreenshotTest {
|
||||
@Test
|
||||
fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() }
|
||||
|
||||
@Test
|
||||
fun consoleSettings() = shootRoot("console-settings") { ConsoleSettingsScene() }
|
||||
|
||||
/** A PALE palette: the whole UI flips to dark ink on white frost, which only a shot proves. */
|
||||
@Test
|
||||
fun consoleSettingsLight() =
|
||||
shootRoot("console-settings-light") { ConsoleSettingsScene(paletteId = "holo") }
|
||||
|
||||
@Test
|
||||
fun trust() = shootScreen("trust") {
|
||||
HostsScene()
|
||||
|
||||
@@ -31,6 +31,12 @@ import io.unom.punktfunk.BrandDark
|
||||
import io.unom.punktfunk.ConnectModal
|
||||
import io.unom.punktfunk.ConnectPhase
|
||||
import io.unom.punktfunk.ConnectTakeover
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import io.unom.punktfunk.GamepadInk
|
||||
import io.unom.punktfunk.GamepadPalette
|
||||
import io.unom.punktfunk.GamepadSettingsScreen
|
||||
import io.unom.punktfunk.LocalGamepadInk
|
||||
import io.unom.punktfunk.LocalGamepadPalette
|
||||
import io.unom.punktfunk.Settings
|
||||
import io.unom.punktfunk.TouchMode
|
||||
import io.unom.punktfunk.SettingsCategory
|
||||
@@ -355,9 +361,11 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
|
||||
// dispValid, displayP50, e2eDispP50, e2eDispP95].
|
||||
// 10/9/16/1 = a 10-bit BT.2020 PQ (HDR) 4:2:0 feed so the DETAILED HUD renders its
|
||||
// video-feed line; the display stage is valid (dispValid 1) so the headline is the
|
||||
// directly-measured capture→displayed pair (1.8/2.6) and the Phase-2 stage terms
|
||||
// (host 0.6 + network 0.3 + decode 0.4 + display 0.5) tile it, rendering the full split
|
||||
// equation; the decoder label shows the ranked low-latency decoder. Light per-window loss
|
||||
// directly-measured capture→displayed pair, less the excluded OS present floor (the 0.3
|
||||
// latch p50) — 1.5/2.3 shown from 1.8/2.6 raw — and the Phase-2 stage terms
|
||||
// (host 0.6 + network 0.3 + decode 0.4 + display 0.2) tile the shaved headline, with the
|
||||
// `os present +0.3 excluded` line naming what came off; the decoder label shows the ranked
|
||||
// low-latency decoder. Light per-window loss
|
||||
// (lost 2 · skipped 1 · FEC 5 of 238) so the reliability line (NORMAL/DETAILED) and the
|
||||
// compact loss flag both render.
|
||||
StatsOverlay(
|
||||
@@ -404,3 +412,25 @@ internal fun WakeTimedOutScene() =
|
||||
@Composable
|
||||
internal fun ConnectConsoleScene() =
|
||||
ConnectTakeover(ConnectPhase.Connecting("Living Room PC"), onCancel = {}, onRetry = {})
|
||||
|
||||
/**
|
||||
* The real console settings screen — the section tab strip, the glass rows, the focused row's
|
||||
* unfolded detail, and the living (calmed) backdrop behind them. The touch [SettingsScene] can't
|
||||
* stand in for it: this is a different screen with different navigation, and the strip is the part
|
||||
* a layout regression would eat first.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConsoleSettingsScene(paletteId: String = "violet") {
|
||||
// The scene calls the screen directly, so it has to publish the palette locals `App` would
|
||||
// normally provide — without them a light palette would render with the default DARK ink and
|
||||
// the shot would silently prove nothing.
|
||||
val palette = GamepadPalette.named(paletteId)
|
||||
CompositionLocalProvider(
|
||||
LocalGamepadPalette provides palette,
|
||||
LocalGamepadInk provides GamepadInk.of(palette),
|
||||
) {
|
||||
GamepadSettingsScreen(
|
||||
initial = SHOT_SETTINGS.copy(uiPalette = paletteId), onChange = {}, onBack = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,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 in raw
|
||||
* device units, the wire's contract). The wire slot is claimed lazily on the FIRST parsed report
|
||||
* and freed on unplug/[stop], so indices never leak.
|
||||
* device units, the wire's contract). The wire slot is claimed when the capture engages, with the
|
||||
* first parsed report as the fallback for a claim that found no free index, and freed on
|
||||
* unplug/[stop], so indices never leak.
|
||||
*
|
||||
* Feedback: implements [GamepadFeedback.PadFeedbackSink] — rumble / trigger / lightbar / player
|
||||
* LED events addressed to this pad's wire index become USB output reports on the physical pad
|
||||
@@ -78,6 +79,33 @@ class DsCapture(
|
||||
@Volatile
|
||||
var onActiveChanged: ((active: Boolean) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Tier-A pad audio, bound by the app layer (which owns the session handle).
|
||||
*
|
||||
* [start] is called once the router has assigned this pad a wire index, which the host uses to
|
||||
* address the `0xD1` stream. [stop] is called **before** the USB link closes — on [stop] and on
|
||||
* unplug alike — and must not return until nothing is still writing to the descriptor.
|
||||
*/
|
||||
interface PadAudioHook {
|
||||
fun start(pad: Int, fd: Int)
|
||||
fun stop(pad: Int)
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var padAudio: PadAudioHook? = null
|
||||
|
||||
/** True once [PadAudioHook.start] has run for the current capture, so it fires exactly once. */
|
||||
@Volatile private var padAudioStarted = false
|
||||
|
||||
/**
|
||||
* The renderer's OWN connection to the pad.
|
||||
*
|
||||
* It must not share [usb]'s descriptor: two transfer engines on one usbfs descriptor reap each
|
||||
* other's completions (see [HidUsbLink.openAuxConnection]), which strands both the HID reader
|
||||
* and the audio ring. Closed only after the hook's stop has returned.
|
||||
*/
|
||||
@Volatile private var padAudioConn: android.hardware.usb.UsbDeviceConnection? = null
|
||||
|
||||
val isActive: Boolean get() = model != null
|
||||
|
||||
/** First attached Sony USB pad, for the permission flow. Needs no permission to enumerate. */
|
||||
@@ -105,12 +133,17 @@ class DsCapture(
|
||||
// (the same init hid-playstation/SDL send on open).
|
||||
if (m != DsDevice.Model.DUALSHOCK4) usb.writeRaw(0, DsDevice.ds5InitReport(m))
|
||||
Log.i(TAG, "Sony pad captured over USB: PID=0x%04x model=%s".format(dev.productId, m))
|
||||
ensureSlot(m)
|
||||
onActiveChanged?.invoke(true)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Stop the link and free the wire slot (host tears the virtual pad down). Idempotent. */
|
||||
fun stop() {
|
||||
// Before anything touches the link: the pad-audio renderer borrows this connection's
|
||||
// descriptor, and `usb.stop()` closes it. The hook does not return until its thread is
|
||||
// joined, so ordering this first is what makes the borrow sound.
|
||||
stopPadAudio()
|
||||
val m = model
|
||||
if (m != null) {
|
||||
// The interfaces are about to release with the kernel driver still detached — a
|
||||
@@ -136,16 +169,112 @@ class DsCapture(
|
||||
private fun onReport(report: ByteArray, len: Int) {
|
||||
val m = model ?: return
|
||||
if (!DsDevice.parseState(m, report, len, state)) return
|
||||
val p = pad ?: router.openExternal(m.pref)?.also {
|
||||
pad = it
|
||||
Log.i(TAG, "captured $m → wire pad ${it.index}")
|
||||
} ?: return // all 16 wire indices taken — drop until one frees
|
||||
// Normally claimed already, at capture time; this is the retry for a capture that engaged
|
||||
// while every wire index was taken.
|
||||
val p = pad ?: ensureSlot(m) ?: return // all 16 taken — drop until one frees
|
||||
mirrorTyped(p)
|
||||
mirrorRich(p, m)
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim this capture's wire slot and start pad audio on it. Idempotent; null when all 16
|
||||
* indices are taken.
|
||||
*
|
||||
* Claimed when the capture engages rather than on the first report, because a pad that reports
|
||||
* nothing is still a pad: with the lazy claim, a captured-but-silent pad left the host with no
|
||||
* arrival, hence no virtual pad, no pad-audio capability and so no `0xD1` — a renderer sitting
|
||||
* at zero frames, indistinguishable from a broken pipeline (it took a physical replug to
|
||||
* clear). Callable from the main thread (capture start) and the link thread (the fallback).
|
||||
*/
|
||||
@Synchronized
|
||||
private fun ensureSlot(m: DsDevice.Model): GamepadRouter.ExternalPad? {
|
||||
pad?.let { return it }
|
||||
val p = router.openExternal(m.pref) ?: return null
|
||||
pad = p
|
||||
Log.i(TAG, "captured $m → wire pad ${p.index}")
|
||||
// The wire index exists from here on, and the host addresses pad audio by it.
|
||||
startPadAudio(p.index)
|
||||
return p
|
||||
}
|
||||
|
||||
/** Hand the renderer its own descriptor. Caller holds the monitor; fires once per capture. */
|
||||
private fun startPadAudio(index: Int) {
|
||||
val hook = padAudio ?: return
|
||||
if (padAudioStarted) return
|
||||
// A dedicated connection, NOT usb.fileDescriptor — see padAudioConn.
|
||||
val conn = usb.openAuxConnection()
|
||||
val fd = conn?.fileDescriptor ?: -1
|
||||
if (fd < 0) {
|
||||
conn?.close()
|
||||
Log.w(TAG, "pad audio: could not open a second USB connection")
|
||||
return
|
||||
}
|
||||
padAudioConn = conn
|
||||
padAudioStarted = true
|
||||
// Real-world self test, opt-in: `adb shell setprop debug.punktfunk.pad_audio_selftest 3`
|
||||
// drives the voice coils for N seconds through the actual client path before the renderer
|
||||
// takes over — the one check that proves the descriptor, the interface claim and the write
|
||||
// path all work on THIS device, without needing a host to be streaming. Same convention as
|
||||
// debug.punktfunk.force_parts.
|
||||
val secs = runCatching {
|
||||
Class.forName("android.os.SystemProperties")
|
||||
.getMethod("get", String::class.java, String::class.java)
|
||||
.invoke(null, "debug.punktfunk.pad_audio_selftest", "0") as String
|
||||
}.getOrNull()?.toIntOrNull() ?: 0
|
||||
if (secs > 0) {
|
||||
// Diagnostic mode: the self test OWNS this descriptor for the capture, and the renderer
|
||||
// must not also drive it — two engines on one usbfs descriptor reap each other's
|
||||
// completions, which is precisely the fault this test exists to expose.
|
||||
Thread({
|
||||
val r = NativeBridge.nativePadAudioSelfTest(fd, secs, 60)
|
||||
Log.i(TAG, "pad audio self-test → ${if (r > 0) "PASS ($r frames)" else "FAIL ($r)"}")
|
||||
}, "pf-pad-selftest").start()
|
||||
} else {
|
||||
// B6: hand the coils back before the first haptics frame. Any rumble earlier in this
|
||||
// session asserted HAPTICS_SELECT, which firmware-mutes them, and nothing else ever
|
||||
// clears it — so without this the stream renders into a muted actuator and looks for
|
||||
// all the world like the host is sending nothing.
|
||||
restoreAudioHaptics()
|
||||
hook.start(index, fd)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* B6: clear the rumble/haptics-select bits so the pad's voice coils answer the audio-haptics
|
||||
* path again. EP0-direct, like the other out-of-band writes here: this has to land even when
|
||||
* the interrupt-OUT queue is busy or draining, and it is idempotent.
|
||||
*/
|
||||
private fun restoreAudioHaptics() {
|
||||
val m = model ?: return
|
||||
if (m == DsDevice.Model.DUALSHOCK4) return // no voice coils, no audio-haptics path
|
||||
if (!usb.writeControl(DsDevice.ds5AudioHapticsReport(m))) {
|
||||
Log.w(TAG, "pad audio: could not hand the coils back to audio haptics")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the renderer, then close the connection whose descriptor it borrows — in that order.
|
||||
*
|
||||
* Runs on [stop] and on unplug alike. Skipping it on unplug left the render thread writing to a
|
||||
* descriptor whose device was gone, leaked the connection, and — because the started flag stayed
|
||||
* set and the native tier-A registry stayed armed for that index — cost the pad both its pad
|
||||
* audio and its wire rumble on the way back in.
|
||||
*/
|
||||
@Synchronized
|
||||
private fun stopPadAudio() {
|
||||
if (!padAudioStarted) return
|
||||
padAudioStarted = false
|
||||
// The hook's stop joins the render thread, so nothing is using the descriptor once it
|
||||
// returns — only then is it safe to close the connection that owns it.
|
||||
pad?.let { padAudio?.stop(it.index) }
|
||||
padAudioConn?.close()
|
||||
padAudioConn = null
|
||||
}
|
||||
|
||||
private fun onLinkClosed() {
|
||||
Log.i(TAG, "Sony USB link closed (unplug)")
|
||||
// Before releaseSlot(), which forgets the wire index the renderer is addressed by.
|
||||
stopPadAudio()
|
||||
disarmBackstop()
|
||||
val wasActive = model != null
|
||||
model = null
|
||||
@@ -238,6 +367,10 @@ class DsCapture(
|
||||
// write — as this used to — meant a discarded stop left the motors running with
|
||||
// nothing scheduled to try again; a USB pad holds its last level until told zero.
|
||||
if (sent) disarmBackstop() else armBackstop(STOP_RETRY_MS)
|
||||
// B6: the stop report just re-asserted HAPTICS_SELECT on its way past, so if a
|
||||
// haptics stream is live the coils it drives were muted by the very write that
|
||||
// silenced the motors. Give them back.
|
||||
if (sent && padAudioStarted) restoreAudioHaptics()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -276,6 +276,21 @@ object DsDevice {
|
||||
* the classic compat-vibration path AND `VIBRATION2` (firmware ≥ 2.24's full-range replot;
|
||||
* older firmware ignores the unknown flag2 bit) — the host parser accepts either.
|
||||
*/
|
||||
/**
|
||||
* B6: hand the voice coils back to the audio-haptics path.
|
||||
*
|
||||
* Every [ds5RumbleReport] asserts `HAPTICS_SELECT` (flag0 bit1), which is SDL's
|
||||
* "disable audio haptics" bit — the firmware mutes the coils the 0xD1 haptics stream drives.
|
||||
* Until now NOTHING ever cleared it again, so a single rumble anywhere in a session left tier-A
|
||||
* haptics silent for the rest of that pad's life, with no error and nothing in a log.
|
||||
*
|
||||
* The undo is a report whose flag0 has BOTH bits clear (SDL's own comment: "Leaving emulated
|
||||
* rumble bits off will restore audio haptics"). No other valid flag is set, so nothing else
|
||||
* about the pad's state is touched. Mirrors `Ds5Feedback::audio_haptics_packet` on the desktop
|
||||
* client, which is the same packet one transport over.
|
||||
*/
|
||||
fun ds5AudioHapticsReport(model: Model): ByteArray = newDs5(model)
|
||||
|
||||
fun ds5RumbleReport(model: Model, low: Int, high: Int): ByteArray = newDs5(model).also {
|
||||
it[1] = (DS5_FLAG0_COMPAT_VIBRATION or DS5_FLAG0_HAPTICS_SELECT).toByte()
|
||||
it[39] = DS5_FLAG2_VIBRATION2.toByte()
|
||||
|
||||
@@ -98,6 +98,40 @@ class HidUsbLink(
|
||||
/** First attached matching device, or null. Does not need USB permission to enumerate. */
|
||||
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull(config.deviceMatch)
|
||||
|
||||
/**
|
||||
* Open a SECOND connection to the same device, for a consumer that needs its own descriptor.
|
||||
*
|
||||
* **Not a convenience — a correctness requirement.** `UsbDeviceConnection.requestWait()`
|
||||
* returns *any* completed request on that connection, and the same is true of the usbfs reap
|
||||
* ioctl underneath it: two independent transfer engines sharing one descriptor steal each
|
||||
* other's completions. This link's reader owns its connection exclusively (see the note on
|
||||
* [outQueue]), so anything else driving transfers on this device — the isochronous audio
|
||||
* renderer — must open its own.
|
||||
*
|
||||
* usbfs allows the same device to be opened many times, and claims are per (descriptor,
|
||||
* interface), so a claim made on this connection does not conflict with one made on that.
|
||||
*
|
||||
* The caller owns the returned connection and must close it.
|
||||
*/
|
||||
fun openAuxConnection(): UsbDeviceConnection? {
|
||||
val dev = device ?: return null
|
||||
return usb.openDevice(dev)
|
||||
}
|
||||
|
||||
/**
|
||||
* The open connection's usbfs file descriptor, or -1 when the link is not running.
|
||||
*
|
||||
* Handed to native code that drives interfaces this link deliberately does NOT claim — the
|
||||
* pad's isochronous audio endpoint (see `pad_audio` on the native side), which Android's own
|
||||
* USB API cannot reach because `UsbRequest` rejects anything that is not bulk or interrupt.
|
||||
* usbfs claims are per interface, so a native claim of the audio interface leaves this link's
|
||||
* HID claim untouched.
|
||||
*
|
||||
* **The borrower must stop using it before [stop] runs**: closing the connection while a
|
||||
* transfer is in flight pulls the descriptor out from under the kernel.
|
||||
*/
|
||||
val fileDescriptor: Int get() = connection?.fileDescriptor ?: -1
|
||||
|
||||
/**
|
||||
* Claim [dev]'s controller interface(s) and start the read loop. The caller has already
|
||||
* obtained USB permission. Returns false when nothing could be claimed.
|
||||
|
||||
@@ -69,6 +69,10 @@ object NativeBridge {
|
||||
* list and trust store show for it, same convention as [nativePair]'s `name`. `null`/blank ⇒
|
||||
* the host falls back to a fingerprint-derived "device abcd1234" label. */
|
||||
deviceName: String?,
|
||||
/** Advertise `CLIENT_CAP_PAD_AUDIO` — the SESSION-level negotiation for the 0xD1 per-pad
|
||||
* DualSense plane. Without it the host never sets `HOST_CAP_PAD_AUDIO` and emits nothing,
|
||||
* so a captured pad's own render capabilities would have nothing to gate. */
|
||||
padAudioOk: Boolean,
|
||||
): Long
|
||||
|
||||
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
|
||||
@@ -83,6 +87,18 @@ object NativeBridge {
|
||||
*/
|
||||
external fun nativeSessionEnded(handle: Long): Boolean
|
||||
|
||||
/**
|
||||
* WHY the session ended, as a [SessionEndReason] ordinal — decode with
|
||||
* [SessionEndReason.fromNative]. `0` (NONE) before it ends, or on a `0` handle.
|
||||
*
|
||||
* The companion to [nativeSessionEnded], which only says THAT it ended. Both are needed: the
|
||||
* flag to leave a dead stream, this to decide what to tell the user. A player quitting their
|
||||
* game and a host falling off the network both end the session, and with no way to separate
|
||||
* them the watchdog said "the host may be asleep" for all of them — wrong for every deliberate
|
||||
* ending. Cheap (one atomic load); UI-safe.
|
||||
*/
|
||||
external fun nativeEndReason(handle: Long): Int
|
||||
|
||||
/**
|
||||
* Run the SPAKE2 PIN ceremony, presenting [certPem]/[keyPem]. Returns the host's verified
|
||||
* fingerprint (64-hex) to persist + pin, or `""` on failure (wrong PIN / MITM / unreachable).
|
||||
@@ -332,6 +348,46 @@ object NativeBridge {
|
||||
*/
|
||||
external fun nativeSetMicMuted(handle: Long, muted: Boolean)
|
||||
|
||||
/**
|
||||
* Start tier-A DualSense pad audio: render the host's `0xD1` streams on the pad's own
|
||||
* 4-channel USB audio device.
|
||||
*
|
||||
* [fd] is an open [android.hardware.usb.UsbDeviceConnection]'s file descriptor. Native code
|
||||
* **borrows** it — it claims the pad's audio interface through usbfs (which leaves any HID
|
||||
* claim on the same device alone) and never closes the descriptor. The caller must keep the
|
||||
* connection open until [nativeStopPadAudio] returns.
|
||||
*
|
||||
* This also declares the pad's render capability to the host; without it no `0xD1` is sent.
|
||||
*
|
||||
* Returns false when there is nothing to render. A kernel that refuses the interface claim is
|
||||
* NOT reported here — the renderer discovers that on its own thread and the session simply
|
||||
* carries on without tier A, because some OEM kernels refuse and no app-side fix exists.
|
||||
*/
|
||||
external fun nativeStartPadAudio(
|
||||
handle: Long,
|
||||
pad: Int,
|
||||
fd: Int,
|
||||
haptics: Boolean,
|
||||
speaker: Boolean,
|
||||
): Boolean
|
||||
|
||||
/**
|
||||
* Stop tier-A pad audio and join its render thread, and hand the pad back to wire rumble.
|
||||
*
|
||||
* Returns only once the thread is joined — so the `UsbDeviceConnection` may be closed as soon
|
||||
* as this returns, and not before.
|
||||
*/
|
||||
external fun nativeStopPadAudio(handle: Long, pad: Int)
|
||||
|
||||
/**
|
||||
* Drive the pad with a test tone through the real render path — no host, no session.
|
||||
*
|
||||
* [fd] must come from a connection **nothing else is driving transfers on**: two engines on
|
||||
* one usbfs descriptor reap each other's completions. Blocks for roughly [seconds]; run it off
|
||||
* the main thread. Returns sample frames written, or negative on failure.
|
||||
*/
|
||||
external fun nativePadAudioSelfTest(fd: Int, seconds: Int, hz: Int): Int
|
||||
|
||||
/**
|
||||
* Is a mic capture actually RUNNING — i.e. did [nativeStartMic] open a stream, and has
|
||||
* [nativeStopMic] not been called since? Offer the in-stream mute control on THIS rather than
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
/**
|
||||
* Why a stream session ended — the Kotlin mirror of `punktfunk_core::client::PunktfunkEndReason`,
|
||||
* read via [NativeBridge.nativeEndReason].
|
||||
*
|
||||
* The distinction that matters to a UI is **normal vs alarming**, and it is not a spectrum: a
|
||||
* player quitting their game and a host falling off the network both arrive as "the session
|
||||
* ended". With no way to tell them apart this client showed one message for all of them — and it
|
||||
* was the alarming one ("Connection lost — the host may be asleep"), in front of players who had
|
||||
* just quit their own game.
|
||||
*
|
||||
* Ordinals are an ABI contract with the Rust side: append only, never renumber.
|
||||
*/
|
||||
enum class SessionEndReason {
|
||||
/** Not ended, or ended before a reason could be observed. Also the fallback for an unknown value. */
|
||||
NONE,
|
||||
|
||||
/** This client closed the session — the user pressed back or stop. Nothing to report. */
|
||||
LOCAL,
|
||||
|
||||
/**
|
||||
* The host's launched game exited. A normal finish, and the one reason worth acting on: go back
|
||||
* to the library the title was launched from, so the next one is a tap away.
|
||||
*/
|
||||
GAME_EXITED,
|
||||
|
||||
/** The host ended the session deliberately (an operator "End", or it simply finished). Normal. */
|
||||
HOST_ENDED,
|
||||
|
||||
/** The host closed reporting a failure of its own. Worth showing; the host's log has the detail. */
|
||||
HOST_ERROR,
|
||||
|
||||
/**
|
||||
* The connection died rather than being closed: idle timeout, reset, the network going away.
|
||||
* This — and only this — is the "the host may be asleep, wake it" case.
|
||||
*/
|
||||
LOST;
|
||||
|
||||
/**
|
||||
* Is this an ordinary outcome rather than something to alarm the user about?
|
||||
*
|
||||
* The question nearly every caller actually asks. [LOCAL], [GAME_EXITED] and [HOST_ENDED] were
|
||||
* all meant to happen. [NONE] counts as normal — no evidence of trouble is not evidence of it.
|
||||
*/
|
||||
val isNormal: Boolean
|
||||
get() = this != HOST_ERROR && this != LOST
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Decode the JNI byte. An unrecognized value becomes [NONE] rather than throwing: this
|
||||
* crosses an ABI where the native side may be newer than this code.
|
||||
*/
|
||||
fun fromNative(v: Int): SessionEndReason = entries.getOrNull(v) ?: NONE
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,27 @@ class HostDiscovery(context: Context) {
|
||||
handler.post(poll)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear the browse down and start a fresh one. This is the manual rescan, and the recovery path
|
||||
* for a browse that started while blocked (permission not yet granted, multicast filtered) or
|
||||
* that never started at all ([start] gives up when `nativeDiscoveryStart` returns 0, and
|
||||
* nothing else would ever retry it).
|
||||
*
|
||||
* It also puts a query back on the wire: `mdns-sd` re-queries on a doubling backoff that caps
|
||||
* at an hour, so a long-lived browse is effectively passive — a host that appeared since, or
|
||||
* whose announcement was lost to multicast, may never be asked for again.
|
||||
*
|
||||
* The currently-shown host set is left alone across the swap (rather than blinking empty via
|
||||
* [stop]'s notification); the first poll of the new browse publishes the fresh set.
|
||||
*/
|
||||
fun restart() {
|
||||
val keep = onChange
|
||||
onChange = null
|
||||
stop()
|
||||
onChange = keep
|
||||
start()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (!running && nativeHandle == 0L) return
|
||||
running = false
|
||||
|
||||
@@ -37,9 +37,51 @@ data class Artwork(val portrait: String?, val header: String?, val hero: String?
|
||||
val posterCandidates: List<String> get() = listOfNotNull(portrait, header, hero)
|
||||
}
|
||||
|
||||
/** One title in the unified library. [id] is store-qualified (`steam:<appid>` / `custom:<id>`). */
|
||||
data class GameEntry(val id: String, val store: String, val title: String, val art: Artwork) {
|
||||
/**
|
||||
* One title in the unified library. [id] is store-qualified (`steam:<appid>` / `custom:<id>`).
|
||||
*
|
||||
* [role] is `"game"` (the default, and what an older host omits) or `"launcher"` — an entry that
|
||||
* 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).
|
||||
*/
|
||||
data class GameEntry(
|
||||
val id: String,
|
||||
val store: String,
|
||||
val title: String,
|
||||
val art: Artwork,
|
||||
val role: String? = null,
|
||||
) {
|
||||
val isCustom: Boolean get() = store == "custom"
|
||||
|
||||
/** Whether this entry opens a launcher rather than a game. */
|
||||
val isLauncher: Boolean get() = role == "launcher"
|
||||
|
||||
/**
|
||||
* 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
|
||||
* entry, which a Lutris or GOG title made a lie.
|
||||
*/
|
||||
val storeLabel: String get() = when (store) {
|
||||
"steam" -> "Steam"
|
||||
"custom" -> "Custom"
|
||||
"heroic" -> "Heroic"
|
||||
"lutris" -> "Lutris"
|
||||
"epic" -> "Epic"
|
||||
"gog" -> "GOG"
|
||||
"xbox" -> "Xbox"
|
||||
else -> "Game"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Design D4: launcher entries lead the shelf, keeping the host's title order within each group.
|
||||
* Applied once where the library is fetched, so no screen has to remember the rule — and a library
|
||||
* without launcher entries comes back untouched.
|
||||
*/
|
||||
fun List<GameEntry>.launchersFirst(): List<GameEntry> {
|
||||
val launchers = filter { it.isLauncher }
|
||||
return if (launchers.isEmpty()) this else launchers + filterNot { it.isLauncher }
|
||||
}
|
||||
|
||||
/** Fetch outcome — three states so the UI can guide setup (the common case is "not paired yet"). */
|
||||
@@ -108,10 +150,11 @@ object LibraryClient {
|
||||
header = resolveArt(str(art, "header"), base),
|
||||
hero = resolveArt(str(art, "hero"), base),
|
||||
),
|
||||
role = str(o, "role"),
|
||||
),
|
||||
)
|
||||
}
|
||||
return out
|
||||
return out.launchersFirst()
|
||||
}
|
||||
|
||||
/** A present, non-null, non-blank JSON string field, else null. */
|
||||
@@ -127,8 +170,14 @@ object LibraryClient {
|
||||
* An OkHttpClient that presents the paired client cert and pins the host's self-signed cert by
|
||||
* SHA-256(DER) — reused for BOTH the library fetch and the cover-art loads (so a paired client
|
||||
* reaches the host's own art proxy). The pinning trust manager trusts the host by fingerprint and
|
||||
* defers to normal public trust for any other origin (an external CDN URL); the hostname verifier
|
||||
* accepts the pinned host (whose self-signed cert has no matching SAN) and defers otherwise.
|
||||
* defers to normal public trust for any other origin (an external CDN URL).
|
||||
*
|
||||
* The two checks are only sound TOGETHER, and the composition is the point: the trust manager
|
||||
* cannot fail closed on its own (it has no hostname, so it must let a CDN chain through), so the
|
||||
* hostname verifier is what makes the pinned host pin-only. Loosen either and a publicly-trusted
|
||||
* certificate for any name is accepted for the host — which is exactly what 2026-08-05 review M-2
|
||||
* found. The host's own cert is self-signed with no matching SAN, so it can never satisfy the
|
||||
* default verifier; the pin is its only credential, on purpose.
|
||||
*/
|
||||
fun mtlsHttpClient(certPem: String, keyPem: String, host: String, fpHex: String): OkHttpClient {
|
||||
val clientCert = CertificateFactory.getInstance("X.509")
|
||||
@@ -162,7 +211,26 @@ fun mtlsHttpClient(certPem: String, keyPem: String, host: String, fpHex: String)
|
||||
|
||||
val defaultVerifier = HttpsURLConnection.getDefaultHostnameVerifier()
|
||||
val verifier = HostnameVerifier { hostname, session ->
|
||||
hostname == host || defaultVerifier.verify(hostname, session)
|
||||
if (hostname == host) {
|
||||
// The PINNED host fails closed: only the pinned leaf is acceptable for this name.
|
||||
//
|
||||
// This used to be a bare `hostname == host`, which composed with the trust manager's
|
||||
// system-CA fall-through into "any publicly-trusted certificate, for any name, is
|
||||
// accepted for the pinned host" — the pin was decorative (2026-08-05 review M-2). A
|
||||
// MITM with any free CA-issued cert intercepted the connection, received the client's
|
||||
// mTLS IDENTITY certificate, and served attacker-chosen library JSON and art URLs.
|
||||
// The Rust (`pf-client-core`) and Apple (`ClientTLS`) paths already fail closed here;
|
||||
// only Android did not.
|
||||
try {
|
||||
sha256Hex((session.peerCertificates.firstOrNull() as? X509Certificate)?.encoded ?: return@HostnameVerifier false) == pinned
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
// Any other origin (an external CDN art URL) is ordinary public trust: the system
|
||||
// trust manager validated the chain, and this checks the name against it.
|
||||
defaultVerifier.verify(hostname, session)
|
||||
}
|
||||
}
|
||||
|
||||
return OkHttpClient.Builder()
|
||||
|
||||
@@ -64,6 +64,14 @@ libc = "0.2"
|
||||
# host + Linux client use. audiopus_sys vendors libopus (pure C) and builds it static via cmake —
|
||||
# the cargo-ndk build sets LIBOPUS_STATIC=1/LIBOPUS_NO_PKG=1 so it links the bundled lib, not the host's.
|
||||
opus = "0.3"
|
||||
# Tier-A pad audio (WP9). Android's audio framework denylists the DualSense's output by VID/PID,
|
||||
# so the pad's isochronous endpoint is driven directly on the fd `UsbDeviceConnection` hands over.
|
||||
# Our own crates, developed openly because the hole they fill — isochronous USB in Rust — is an
|
||||
# ecosystem-wide one: https://github.com/unom-io/usbfs-iso
|
||||
# Pinned by revision rather than floating: this is a transport under a real-time deadline and it
|
||||
# should move when we choose to. Becomes a plain version dependency once the crates are published.
|
||||
uac-host = { git = "https://github.com/unom-io/usbfs-iso", rev = "f3de1fd62cec271d07f45664dc464f23e423e721" }
|
||||
usbfs-iso = { git = "https://github.com/unom-io/usbfs-iso", rev = "f3de1fd62cec271d07f45664dc464f23e423e721" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -77,6 +77,14 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
||||
// handle.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
match h.client.next_rumble_command(PULL_TIMEOUT) {
|
||||
// A pad whose coils are ACTIVELY being driven by the 0xD1 haptics stream must not see
|
||||
// wire rumble: `DsDevice` sets `valid_flag0` bit 1 (`HAPTICS_SELECT`) on every rumble
|
||||
// write, and that bit disables the audio-haptics path — so one replayed command would
|
||||
// mute the coils the stream is driving. Gating on *arrival of haptics frames* rather
|
||||
// than on "a stream is open" is what keeps a rumble-only title working: it renders no
|
||||
// haptics audio, so the host emits nothing on 0xD1 and the pad keeps its rumble.
|
||||
// Dropping it here rather than in Kotlin keeps the rule next to the reason.
|
||||
Ok(cmd) if crate::pad_audio::haptics_owns_coils((cmd.pad & 0xF) as u8) => -1,
|
||||
Ok(cmd) => pack_rumble(cmd.pad, cmd.low, cmd.high, cmd.backstop_ms),
|
||||
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
|
||||
}
|
||||
@@ -174,6 +182,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
||||
out[3..n].copy_from_slice(&data);
|
||||
n
|
||||
}
|
||||
HidOutput::AudioCtl { .. } => {
|
||||
// DS5 pad-audio routing/volumes — no Android replay path yet (the 0xD1 sample
|
||||
// plane isn't rendered here either); drop it like TrackpadHaptic.
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
n as jint
|
||||
})
|
||||
|
||||
@@ -37,6 +37,8 @@ mod discovery;
|
||||
mod feedback;
|
||||
#[cfg(target_os = "android")]
|
||||
mod mic;
|
||||
/// Tier-A DualSense pad audio: the 0xD1 plane rendered on the pad's own USB endpoint.
|
||||
mod pad_audio;
|
||||
mod session;
|
||||
mod stats;
|
||||
// Ungated like `discovery`: pure `jni` + `punktfunk_core::wol` (no Android framework), so it links
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -145,6 +145,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
timeout_ms: jint,
|
||||
launch: JString<'local>,
|
||||
device_name: JString<'local>,
|
||||
pad_audio_ok: jboolean,
|
||||
) -> jlong {
|
||||
let host: String = match env.get_string(&host) {
|
||||
Ok(s) => s.into(),
|
||||
@@ -268,7 +269,16 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
// CLIENT_CAP_PHASE_LOCK is honest: the async decode loop's presenter feeds
|
||||
// report_phase (advisory in v1 — the host arms on report receipt — but the Hello
|
||||
// should say what the client does).
|
||||
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK,
|
||||
// CLIENT_CAP_PAD_AUDIO is the SESSION-level negotiation, separate from the per-pad
|
||||
// arrival bits: without it the host never sets HOST_CAP_PAD_AUDIO and never emits 0xD1,
|
||||
// so declaring a pad's render caps later would have nothing to gate. Gated on the
|
||||
// settings so a user with pad audio off does not make the host provision endpoints.
|
||||
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK
|
||||
| if pad_audio_ok != 0 {
|
||||
punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO
|
||||
} else {
|
||||
0
|
||||
},
|
||||
// Slice-progressive delivery, by decoder truth (Kotlin probes FEATURE_PartialFrame on
|
||||
// every decoder this device would use; `debug.punktfunk.force_parts` overrides for the
|
||||
// on-glass experiment): AU prefixes then arrive as `Frame::part` pieces and the decode
|
||||
@@ -291,6 +301,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
audio: Mutex::new(None),
|
||||
#[cfg(target_os = "android")]
|
||||
mic: Mutex::new(None),
|
||||
#[cfg(target_os = "android")]
|
||||
pad_audio: Mutex::new(None),
|
||||
// A fresh session is never muted (mute is per-session UI state, not a setting).
|
||||
mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
};
|
||||
@@ -392,6 +404,31 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSessionEnde
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeEndReason(handle): Int` — WHY the session ended, as a
|
||||
/// `punktfunk_core::client::PunktfunkEndReason` byte (Kotlin mirrors it in `SessionEndReason`).
|
||||
///
|
||||
/// Companion to `nativeSessionEnded`, which only says THAT it ended. Kotlin's watchdog needs both:
|
||||
/// the flag to leave a dead stream, and this to decide what — if anything — to tell the user. A
|
||||
/// player quitting their game and a host dropping off the network both end the session, and until
|
||||
/// this existed the watchdog worded them identically ("the host may be asleep"), which is wrong for
|
||||
/// every deliberate ending. `0` (NONE) on a `0` handle or before the session ends. Cheap (one
|
||||
/// atomic load); safe on the UI thread.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeEndReason(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) -> jint {
|
||||
jni_guard(0, || {
|
||||
if handle == 0 {
|
||||
return 0;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.client.end_reason() as jint
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativePair(host, port, certPem, keyPem, pin, name): String` — run the SPAKE2 PIN
|
||||
/// ceremony, presenting our persistent identity. On success returns the host's verified fingerprint
|
||||
/// (64-hex) to persist + pin; on any failure (wrong PIN / MITM / host reject / unreachable) returns
|
||||
|
||||
@@ -61,6 +61,11 @@ pub(crate) struct SessionHandle {
|
||||
audio: Mutex<Option<crate::audio::AudioPlayback>>,
|
||||
#[cfg(target_os = "android")]
|
||||
mic: Mutex<Option<crate::mic::MicCapture>>,
|
||||
/// Tier-A DualSense pad audio (the 0xD1 plane), started by `nativeStartPadAudio` once Kotlin
|
||||
/// has claimed the pad's audio interface and handed its descriptor over. Session-lifetime and
|
||||
/// `Option` because a session may have no wired DualSense at all, which is the common case.
|
||||
#[cfg(target_os = "android")]
|
||||
pub(crate) pad_audio: Mutex<Option<crate::pad_audio::PadAudio>>,
|
||||
/// In-stream mic mute, set via `nativeSetMicMuted` and read per 10 ms frame by the mic's
|
||||
/// encode loop ([`crate::mic`]). Session-lifetime rather than per-[`crate::mic::MicCapture`]
|
||||
/// for the same reason the stats gate is: the mic stops and restarts across a surface
|
||||
@@ -99,6 +104,14 @@ impl SessionHandle {
|
||||
fn stop_mic(&self) {
|
||||
let _ = self.mic.lock().unwrap().take();
|
||||
}
|
||||
|
||||
/// Stop pad audio. Dropping the [`crate::pad_audio::PadAudio`] joins its render thread, which
|
||||
/// is what guarantees nothing is still writing to the descriptor when Kotlin closes the
|
||||
/// `UsbDeviceConnection`. Idempotent.
|
||||
#[cfg(target_os = "android")]
|
||||
pub(crate) fn stop_pad_audio(&self) {
|
||||
let _ = self.pad_audio.lock().unwrap().take();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SessionHandle {
|
||||
@@ -108,6 +121,8 @@ impl Drop for SessionHandle {
|
||||
self.stop_audio();
|
||||
#[cfg(target_os = "android")]
|
||||
self.stop_mic();
|
||||
#[cfg(target_os = "android")]
|
||||
self.stop_pad_audio();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -460,6 +460,111 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeStartPadAudio(handle, pad, fd, haptics, speaker): Boolean` — start tier-A
|
||||
/// DualSense pad audio on a descriptor Kotlin has already obtained.
|
||||
///
|
||||
/// `fd` comes from `UsbDeviceConnection.getFileDescriptor()` **after** claiming the pad's audio
|
||||
/// streaming interface. Kotlin owns that connection and **must keep it open until
|
||||
/// `nativeStopPadAudio` returns**: the renderer borrows the descriptor and never closes it, so
|
||||
/// closing early would pull it out from under an in-flight isochronous transfer.
|
||||
///
|
||||
/// Returns `false` when there is nothing to render (both kinds disabled) or the thread would not
|
||||
/// start. A kernel that refuses the interface claim is NOT reported here — the renderer discovers
|
||||
/// that on its own thread and degrades to tier C, because some OEM kernels refuse and there is no
|
||||
/// app-side fix worth blocking a session on.
|
||||
#[no_mangle]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAudio(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jni::sys::jint,
|
||||
fd: jni::sys::jint,
|
||||
haptics: jboolean,
|
||||
speaker: jboolean,
|
||||
) -> jboolean {
|
||||
jni_guard(0, || {
|
||||
if handle == 0 || fd < 0 || !(0..16).contains(&pad) {
|
||||
return 0;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
// Replace any previous renderer first: dropping it joins the old thread, so two of them
|
||||
// can never hold the same descriptor at once.
|
||||
h.stop_pad_audio();
|
||||
// The capability declaration and the rumble suppression are NOT done here: the renderer
|
||||
// makes both only once its USB stream actually opens (see `pad_audio::render`). Doing them
|
||||
// at spawn time would, on a kernel that refuses the interface claim, take the pad off wire
|
||||
// rumble and give it nothing in return — no haptics of any kind.
|
||||
match crate::pad_audio::start(
|
||||
std::sync::Arc::clone(&h.client),
|
||||
pad as u8,
|
||||
fd,
|
||||
haptics != 0,
|
||||
speaker != 0,
|
||||
) {
|
||||
Some(p) => {
|
||||
*h.pad_audio.lock().unwrap() = Some(p);
|
||||
1
|
||||
}
|
||||
None => 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativePadAudioSelfTest(fd, seconds, hz): Int` — drive the pad directly with a
|
||||
/// tone through the real client render path, with no host and no session involved.
|
||||
///
|
||||
/// The check a standalone harness cannot make: it owns its descriptor by construction, so it can
|
||||
/// never reveal that the client handed the renderer a descriptor something else was already
|
||||
/// driving. Returns sample frames written, or negative on failure (see `pad_audio::SelfTest`).
|
||||
#[no_mangle]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadAudioSelfTest(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
fd: jni::sys::jint,
|
||||
seconds: jni::sys::jint,
|
||||
hz: jni::sys::jint,
|
||||
) -> jni::sys::jint {
|
||||
jni_guard(-1, || {
|
||||
if fd < 0 {
|
||||
return -1;
|
||||
}
|
||||
// SAFETY: Kotlin holds the owning UsbDeviceConnection open across this call and drives no
|
||||
// other transfers on it (it opens a dedicated connection for exactly this).
|
||||
unsafe { crate::pad_audio::self_test(fd, seconds, hz) }
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeStopPadAudio(handle, pad)` — stop tier-A pad audio and join its thread.
|
||||
///
|
||||
/// Returns only once the render thread is joined, which is the point: Kotlin may close the
|
||||
/// `UsbDeviceConnection` as soon as this returns and not before.
|
||||
#[no_mangle]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudio(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jni::sys::jint,
|
||||
) {
|
||||
jni_guard((), || {
|
||||
if handle != 0 {
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.stop_pad_audio();
|
||||
if (0..16).contains(&pad) {
|
||||
// Withdraw the capability and hand the pad back to wire rumble, in that order:
|
||||
// the host stops sending 0xD1 before tier C resumes, so the two never overlap.
|
||||
h.client.set_pad_audio_caps(pad as u8, 0);
|
||||
crate::pad_audio::set_tier_a(pad as u8, false);
|
||||
crate::pad_audio::clear_haptics_liveness(pad as u8);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSetMicMuted(handle, muted)` — mute/unmute the mic uplink mid-stream.
|
||||
///
|
||||
/// Muting deliberately does NOT stop the capture: the AAudio input stream, the input-preset rung
|
||||
|
||||
@@ -206,6 +206,20 @@ struct ContentView: View {
|
||||
model.setStatsVerbosity(StatsVerbosity(rawValue: raw) ?? .normal)
|
||||
}
|
||||
#if os(iOS) || os(tvOS)
|
||||
// Coming back to the app re-arms the LAN browse. The home's `onAppear`/`onDisappear` do
|
||||
// NOT fire across background/foreground, and a browse the system suspended while we were
|
||||
// away does not resume on its own — so the host grid came back empty and stayed empty
|
||||
// until the app was relaunched. No-op unless the browse is already running (mid-session
|
||||
// the home has deliberately torn it down).
|
||||
//
|
||||
// Mobile only: macOS never suspends the process, and its `scenePhase` flips on every
|
||||
// window focus change — re-arming there would rebuild the browser each time you alt-tab.
|
||||
// A Mac browse that genuinely breaks is caught by `HostDiscovery`'s own sweep instead.
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
if phase == .active { discovery.refreshIfRunning() }
|
||||
}
|
||||
#endif
|
||||
#if os(iOS) || os(tvOS)
|
||||
// Backgrounding driver. Only .background/.active matter; .inactive (a transient peek) is
|
||||
// ignored so neither branch fires for a Control-Center pull.
|
||||
//
|
||||
@@ -335,6 +349,16 @@ struct ContentView: View {
|
||||
active: fullscreenForSession && model.connection != nil,
|
||||
isFullscreen: $isFullscreen))
|
||||
#endif
|
||||
// A game launched from the library just exited, so the session ended on purpose: put the
|
||||
// player back in that host's library rather than on host selection. Set on the outer Group
|
||||
// (like the sheets below) so it survives the streaming → home transition the disconnect
|
||||
// drives, and consumed here — the model hands the host over once and we clear it, so a
|
||||
// later manual dismiss of the library can't be undone by a stale value.
|
||||
.onChange(of: model.returnToLibrary) { _, host in
|
||||
guard let host else { return }
|
||||
model.returnToLibrary = nil
|
||||
libraryTarget = host
|
||||
}
|
||||
// On the outer Group so the sheet survives the trust-prompt → home transition
|
||||
// (the "Pair with PIN instead" path disconnects first — the host's accept loop
|
||||
// is sequential, a pairing connection would queue behind the live session).
|
||||
@@ -512,6 +536,9 @@ struct ContentView: View {
|
||||
waker: waker,
|
||||
gamepadUI: gamepadUIActive,
|
||||
onCancelConnect: { model.disconnect() })
|
||||
// The takeover mounts OUTSIDE the gamepad screens (it covers the whole home), so
|
||||
// it publishes the palette's ink itself rather than inheriting it.
|
||||
.gamepadPaletteInk()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,12 +47,16 @@ struct ConnectOverlay: View {
|
||||
return nil
|
||||
}
|
||||
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
|
||||
var body: some View {
|
||||
if let phase {
|
||||
ZStack {
|
||||
if gamepadUI {
|
||||
// Console: an opaque, living aurora over everything.
|
||||
Color.black.ignoresSafeArea()
|
||||
// Console: an opaque, living aurora over everything, in the chosen palette.
|
||||
// The takeover's own text rides `ink`, so a pale palette flips it here too —
|
||||
// without that this is the one console screen that stays white-on-white.
|
||||
ink.isLight ? Color.white.ignoresSafeArea() : Color.black.ignoresSafeArea()
|
||||
GamepadScreenBackground().ignoresSafeArea()
|
||||
Color.clear.contentShape(Rectangle()).onTapGesture {}
|
||||
content(phase).padding(40).frame(maxWidth: 460)
|
||||
@@ -70,7 +74,8 @@ struct ConnectOverlay: View {
|
||||
.padding(40)
|
||||
}
|
||||
}
|
||||
.environment(\.colorScheme, .dark)
|
||||
// The console takeover follows the palette; the default UI's modal stays dark.
|
||||
.environment(\.colorScheme, gamepadUI && ink.isLight ? .light : .dark)
|
||||
.transition(.opacity)
|
||||
#if os(iOS) || os(macOS)
|
||||
.background { ConnectControllerInput(waker: waker, onCancelConnect: onCancelConnect) }
|
||||
|
||||
@@ -12,6 +12,7 @@ import SwiftUI
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
|
||||
struct GamepadAddHostView: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let onAdd: (StoredHost) -> Void
|
||||
|
||||
@@ -47,12 +48,12 @@ struct GamepadAddHostView: View {
|
||||
VStack(spacing: 4) {
|
||||
Text("Add Host")
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(ink.fg)
|
||||
if !compact {
|
||||
Text("Hosts on this network appear automatically — add one by address "
|
||||
+ "for everything else.")
|
||||
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
.foregroundStyle(ink.fg(0.55))
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72)
|
||||
}
|
||||
@@ -73,6 +74,9 @@ struct GamepadAddHostView: View {
|
||||
}
|
||||
// No aurora — the same clean Liquid-Glass-over-dark base as the gamepad settings screen.
|
||||
.background { GamepadFormBackground() }
|
||||
// Publish the palette's ink to this screen (text, glass, accent, scrims) — a
|
||||
// pale palette flips all of them, and no leaf should have to read the setting.
|
||||
.gamepadPaletteInk()
|
||||
// A port can't exceed 5 digits — cap while typing so the row can't grow absurd.
|
||||
.onChange(of: port) { _, value in
|
||||
if value.count > 5 { port = String(value.prefix(5)) }
|
||||
@@ -143,7 +147,7 @@ struct GamepadAddHostView: View {
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(ink.fg)
|
||||
.frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide)
|
||||
.glassBackground(Circle(), interactive: true)
|
||||
.contentShape(Circle())
|
||||
@@ -180,22 +184,22 @@ struct GamepadAddHostView: View {
|
||||
if row.isAction {
|
||||
Label("Add Host", systemImage: "plus.circle.fill")
|
||||
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
|
||||
.foregroundStyle(canAdd ? Color.brand : .white.opacity(0.35))
|
||||
.foregroundStyle(canAdd ? ink.accent : ink.fg(0.35))
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Text(row.label)
|
||||
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(ink.fg)
|
||||
Spacer(minLength: 12)
|
||||
Text(row.value.isEmpty ? row.placeholder : row.value)
|
||||
.font(.geistFixed(m.valueFont, .medium))
|
||||
.foregroundStyle(row.value.isEmpty ? .white.opacity(0.35) : .white)
|
||||
.foregroundStyle(row.value.isEmpty ? ink.fg(0.35) : ink.fg)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.head) // keep the end of a long address visible while typing
|
||||
if editing == row.id {
|
||||
// The live-edit caret: this row is what the keyboard tray is typing into.
|
||||
Rectangle()
|
||||
.fill(Color.brand)
|
||||
.fill(ink.accent)
|
||||
.frame(width: 2, height: m.labelFont + 2)
|
||||
}
|
||||
}
|
||||
@@ -206,12 +210,12 @@ struct GamepadAddHostView: View {
|
||||
// takes the brand wash, and the edited row keeps its brand caret border.
|
||||
.consoleGlass(
|
||||
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
|
||||
tint: (focused || editing == row.id) ? Color.brand.opacity(0.30) : nil,
|
||||
tint: (focused || editing == row.id) ? ink.accent(0.30) : nil,
|
||||
interactive: focused)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
|
||||
.strokeBorder(
|
||||
editing == row.id ? Color.brand.opacity(0.7) : .white.opacity(focused ? 0.28 : 0.06),
|
||||
editing == row.id ? ink.accent(0.7) : ink.fg(focused ? 0.28 : 0.06),
|
||||
lineWidth: 1)
|
||||
}
|
||||
.scaleEffect(focused ? 1.0 : 0.98)
|
||||
|
||||
@@ -89,6 +89,7 @@ struct GamepadHint: Identifiable {
|
||||
/// worn as a self-contained Liquid Glass pill (like the top-bar controller chip) so it floats over
|
||||
/// the backdrop instead of dissolving into it.
|
||||
struct GamepadHintBar: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
let hints: [GamepadHint]
|
||||
|
||||
// 10-foot legend on tvOS, in-hand sizes elsewhere.
|
||||
@@ -108,26 +109,35 @@ struct GamepadHintBar: View {
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: hint.glyph)
|
||||
.font(.system(size: Self.glyphFont))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(ink.fg)
|
||||
Text(hint.text)
|
||||
}
|
||||
.fixedSize() // keep glyph + label together; never truncate a hint mid-word
|
||||
}
|
||||
}
|
||||
.font(.geist(Self.textFont, .semibold, relativeTo: .subheadline))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
.foregroundStyle(ink.fg(0.85))
|
||||
.padding(Self.pad)
|
||||
.consoleGlass(Capsule())
|
||||
.overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1))
|
||||
.overlay(Capsule().strokeBorder(ink.fg(0.12), lineWidth: 1))
|
||||
}
|
||||
}
|
||||
|
||||
/// The console backdrop: a living aurora in the brand's violet family, drifting slowly over black
|
||||
/// so it reads as ambience behind the cards, never as content. On iOS 18 / macOS 15+ it's an
|
||||
/// animated `MeshGradient` — a continuous silk of colour whose control points wander on slow,
|
||||
/// out-of-phase sinusoids — finished with an elliptical vignette (pools light in the centre, sinks
|
||||
/// the corners) and a top/bottom legibility scrim. Older OSes fall back to the original drifting
|
||||
/// radial-blob field, unchanged, so nothing regresses.
|
||||
/// The console backdrop: a living aurora drifting slowly over black so it reads as ambience behind
|
||||
/// the cards, never as content. On iOS 18 / macOS 15+ it's an animated `MeshGradient` — a continuous
|
||||
/// silk of colour whose control points wander on slow, out-of-phase sinusoids — finished with an
|
||||
/// elliptical vignette (pools light in the centre, sinks the corners) and a top/bottom legibility
|
||||
/// scrim. Older OSes fall back to the original drifting radial-blob field, unchanged, so nothing
|
||||
/// regresses.
|
||||
///
|
||||
/// `calm` is what the FORM screens (settings, add-host) wear: the same living field with its pools
|
||||
/// dimmed onto its own corner colour, so those screens keep real colour under their Liquid Glass
|
||||
/// rows without the launcher's contrast. They used to sit on a still gradient; nothing in the
|
||||
/// gamepad UI is backed by a static image now. Motion is identical in both modes on purpose — only
|
||||
/// the contrast differs, so a screen change can't make the field jump.
|
||||
///
|
||||
/// `GamepadPalette` recolours the whole thing (the shared `ui_palette` setting) by transforming the
|
||||
/// COLOURS, not by stacking a filter — see GamepadPalette.swift for why.
|
||||
///
|
||||
/// Deliberately pure SwiftUI, no `.metal`: these sources build under both SwiftPM (`swift run`/
|
||||
/// tests) and the Xcode project's synchronized folders, and a compiled metallib is only reliably
|
||||
@@ -136,77 +146,90 @@ struct GamepadHintBar: View {
|
||||
/// can't inflate the caller's layout past the safe area (see the layout note in GamepadHomeView's
|
||||
/// header). Honors Reduce Motion by freezing the field at a fixed phase.
|
||||
struct GamepadScreenBackground: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
/// Quiet the field for a form screen (see the type comment).
|
||||
var calm = false
|
||||
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
|
||||
var body: some View {
|
||||
let palette = GamepadPalette.named(paletteID)
|
||||
Group {
|
||||
if reduceMotion {
|
||||
composite(at: 0)
|
||||
composite(at: 0, palette: palette)
|
||||
} else {
|
||||
// 30 Hz is plenty for a field that drifts centimetres per minute, and halves the
|
||||
// redraw cost of a battery-fed couch device vs. the display's native rate.
|
||||
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in
|
||||
composite(at: context.date.timeIntervalSinceReferenceDate)
|
||||
composite(at: context.date.timeIntervalSinceReferenceDate, palette: palette)
|
||||
}
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
|
||||
/// The colour field under a very slow warm/cool hue sway, an elliptical vignette, and the
|
||||
/// title/hints legibility scrim.
|
||||
private func composite(at t: TimeInterval) -> some View {
|
||||
ZStack {
|
||||
Color.black
|
||||
colorField(at: t)
|
||||
/// The colour field under a very slow warm/cool hue sway, the calm flattening, an elliptical
|
||||
/// vignette, and the title/hints legibility scrim — in that order, matching the console
|
||||
/// shader's `composite` so the two platforms' backdrops stay the same picture.
|
||||
private func composite(at t: TimeInterval, palette: GamepadPalette) -> some View {
|
||||
// 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 `u_scrim.a` the console shader carries.
|
||||
let scrim: Color = palette.light ? ink.fg : .black
|
||||
let strength = palette.light ? 0.45 : 1.0
|
||||
return ZStack {
|
||||
Self.color(palette.ground)
|
||||
colorField(at: t, palette: palette)
|
||||
// ±8° over ~5 min — the whole field very slowly warms and cools.
|
||||
.hueRotation(.degrees(sin(t * 0.021) * 8))
|
||||
// Cinematic vignette: darker toward the edges so the cards sit in the pooled light.
|
||||
// Soft (extends past the frame) so the corners deepen rather than crush to black.
|
||||
// Calm = col·0.6 + ground·0.4: over the ground, `.opacity` IS the multiply…
|
||||
.opacity(calm ? 0.6 : 1)
|
||||
if calm {
|
||||
// …and a plusLighter wash of the palette's own ground IS the add. Chosen so the
|
||||
// ground lands exactly where it was and the bright pools come down to meet it.
|
||||
Self.color(palette.ground)
|
||||
.opacity(0.4)
|
||||
.blendMode(.plusLighter)
|
||||
}
|
||||
// Cinematic vignette: the edges settle toward the scrim so the cards sit in the
|
||||
// pooled light. Soft (extends past the frame) so the corners deepen rather than
|
||||
// crush. 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.
|
||||
EllipticalGradient(
|
||||
colors: [.clear, .black.opacity(0.42)],
|
||||
colors: [.clear, scrim.opacity((calm ? 0.21 : 0.42) * strength)],
|
||||
center: .center, startRadiusFraction: 0.25, endRadiusFraction: 1.15)
|
||||
// Legibility grounding for the pinned title (top) and hint pill (bottom). This one
|
||||
// darkens the aurora itself (it's the backdrop's bottom layer — nothing behind it to
|
||||
// blur), so it stays a gradient, just a light one now.
|
||||
// works on the field itself (it's the backdrop's bottom layer — nothing behind it to
|
||||
// blur), so it stays a gradient, just a light one.
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: .black.opacity(0.38), location: 0),
|
||||
.init(color: .black.opacity(0.06), location: 0.32),
|
||||
.init(color: .black.opacity(0.08), location: 0.68),
|
||||
.init(color: .black.opacity(0.40), location: 1),
|
||||
.init(color: scrim.opacity(0.38 * strength), location: 0),
|
||||
.init(color: scrim.opacity(0.06 * strength), location: 0.32),
|
||||
.init(color: scrim.opacity(0.08 * strength), location: 0.68),
|
||||
.init(color: scrim.opacity(0.40 * strength), location: 1),
|
||||
],
|
||||
startPoint: .top, endPoint: .bottom)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func colorField(at t: TimeInterval) -> some View {
|
||||
@ViewBuilder private func colorField(at t: TimeInterval, palette: GamepadPalette) -> some View {
|
||||
if #available(iOS 18, macOS 15, tvOS 18, *) {
|
||||
MeshGradient(
|
||||
width: 4, height: 4,
|
||||
points: Self.meshPoints(at: t),
|
||||
colors: Self.meshColors,
|
||||
colors: palette.meshColors.map(Self.color),
|
||||
smoothsColors: true)
|
||||
} else {
|
||||
LegacyBlobField(t: t)
|
||||
LegacyBlobField(t: t, palette: palette)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MeshGradient aurora (iOS 18 / macOS 15+)
|
||||
|
||||
/// Sixteen mesh colours (row-major, 4×4): dark-violet corners sink the frame, the edges carry
|
||||
/// mid-tone violets, and the four interior points hold the bright brand family — a violet and a
|
||||
/// blue-violet up top, a magenta-violet and a violet below — so warm pools on the left, cool on
|
||||
/// the right, and the silk shifts temperature as those interior points drift.
|
||||
private static let meshColors: [Color] = {
|
||||
let corner = Color(red: 0.075, green: 0.060, blue: 0.160)
|
||||
return [
|
||||
corner, Color(red: 0.34, green: 0.27, blue: 0.72), Color(red: 0.30, green: 0.26, blue: 0.74), corner,
|
||||
Color(red: 0.42, green: 0.20, blue: 0.54), Color(red: 0.49, green: 0.39, blue: 0.95), Color(red: 0.28, green: 0.31, blue: 0.84), Color(red: 0.16, green: 0.26, blue: 0.64),
|
||||
Color(red: 0.45, green: 0.23, blue: 0.60), Color(red: 0.53, green: 0.31, blue: 0.75), Color(red: 0.35, green: 0.35, blue: 0.91), Color(red: 0.19, green: 0.28, blue: 0.70),
|
||||
corner, Color(red: 0.22, green: 0.18, blue: 0.54), Color(red: 0.24, green: 0.20, blue: 0.58), corner,
|
||||
]
|
||||
}()
|
||||
static func color(_ c: SIMD3<Double>) -> Color {
|
||||
Color(red: c.x, green: c.y, blue: c.z)
|
||||
}
|
||||
|
||||
/// The 4×4 control points at time `t`: every boundary point is PINNED to the frame (so the mesh
|
||||
/// always fills edge-to-edge — a drifting edge point would shrink the mesh and expose the black
|
||||
@@ -233,15 +256,18 @@ struct GamepadScreenBackground: View {
|
||||
}
|
||||
|
||||
/// Pre-18/15 fallback for `GamepadScreenBackground`: the original drifting radial-blob field — four
|
||||
/// soft colour blobs on slow Lissajous paths, additively blended. Kept verbatim so older OSes see
|
||||
/// exactly the aurora they shipped with (the mesh path is the upgrade for OS 18/15+).
|
||||
/// soft colour blobs on slow Lissajous paths, additively blended. Geometry and motion are verbatim
|
||||
/// so older OSes see exactly the aurora they shipped with (the mesh path is the upgrade for OS
|
||||
/// 18/15+); only the blob COLOURS now pass through the palette, so an older device honours the
|
||||
/// setting too instead of being stuck on violet.
|
||||
private struct LegacyBlobField: View {
|
||||
let t: TimeInterval
|
||||
let palette: GamepadPalette
|
||||
|
||||
/// One drifting color blob: a base position + drift ellipse (unit coordinates), angular speeds
|
||||
/// (rad/s — periods of 30–90 s), and a radius that slowly breathes.
|
||||
/// (rad/s — periods of 30–90 s), and a radius that slowly breathes. The COLOUR comes from the
|
||||
/// palette's ramp at draw time (see `blobColors`), so an older OS honours the setting too.
|
||||
private struct Blob {
|
||||
let color: Color
|
||||
let center: CGPoint
|
||||
let drift: CGSize
|
||||
let speed: (x: Double, y: Double)
|
||||
@@ -252,20 +278,16 @@ private struct LegacyBlobField: View {
|
||||
}
|
||||
|
||||
private static let blobs: [Blob] = [
|
||||
Blob(color: Color(red: 0.53, green: 0.47, blue: 0.96), // brand violet
|
||||
center: CGPoint(x: 0.30, y: 0.24), drift: CGSize(width: 0.16, height: 0.10),
|
||||
Blob(center: CGPoint(x: 0.30, y: 0.24), drift: CGSize(width: 0.16, height: 0.10),
|
||||
speed: (0.111, 0.083), phase: (0.0, 1.9),
|
||||
radius: 0.52, breathe: (0.07, 0.061), opacity: 0.52),
|
||||
Blob(color: Color(red: 0.24, green: 0.20, blue: 0.72), // deep indigo
|
||||
center: CGPoint(x: 0.78, y: 0.66), drift: CGSize(width: 0.13, height: 0.14),
|
||||
Blob(center: CGPoint(x: 0.78, y: 0.66), drift: CGSize(width: 0.13, height: 0.14),
|
||||
speed: (0.071, 0.096), phase: (2.4, 0.7),
|
||||
radius: 0.58, breathe: (0.08, 0.049), opacity: 0.55),
|
||||
Blob(color: Color(red: 0.62, green: 0.30, blue: 0.80), // plum
|
||||
center: CGPoint(x: 0.16, y: 0.82), drift: CGSize(width: 0.12, height: 0.09),
|
||||
Blob(center: CGPoint(x: 0.16, y: 0.82), drift: CGSize(width: 0.12, height: 0.09),
|
||||
speed: (0.089, 0.067), phase: (4.1, 3.2),
|
||||
radius: 0.44, breathe: (0.09, 0.078), opacity: 0.42),
|
||||
Blob(color: Color(red: 0.22, green: 0.38, blue: 0.86), // cool blue
|
||||
center: CGPoint(x: 0.70, y: 0.12), drift: CGSize(width: 0.10, height: 0.08),
|
||||
Blob(center: CGPoint(x: 0.70, y: 0.12), drift: CGSize(width: 0.10, height: 0.08),
|
||||
speed: (0.059, 0.104), phase: (1.2, 5.0),
|
||||
radius: 0.40, breathe: (0.06, 0.055), opacity: 0.38),
|
||||
]
|
||||
@@ -275,26 +297,31 @@ private struct LegacyBlobField: View {
|
||||
let side = max(geo.size.width, geo.size.height)
|
||||
ZStack {
|
||||
ForEach(Self.blobs.indices, id: \.self) { i in
|
||||
blobView(Self.blobs[i], in: geo.size, side: side)
|
||||
blobView(Self.blobs[i], tone: palette.blobColors[i], in: geo.size, side: side)
|
||||
}
|
||||
}
|
||||
.drawingGroup()
|
||||
}
|
||||
}
|
||||
|
||||
private func blobView(_ blob: Blob, in size: CGSize, side: CGFloat) -> some View {
|
||||
private func blobView(
|
||||
_ blob: Blob, tone: SIMD3<Double>, in size: CGSize, side: CGFloat
|
||||
) -> some View {
|
||||
let x = blob.center.x + blob.drift.width * CGFloat(sin(t * blob.speed.x + blob.phase.x))
|
||||
let y = blob.center.y + blob.drift.height * CGFloat(cos(t * blob.speed.y + blob.phase.y))
|
||||
let r = side * blob.radius
|
||||
* (1 + blob.breathe.amount * CGFloat(sin(t * blob.breathe.speed + blob.phase.x)))
|
||||
let color = GamepadScreenBackground.color(tone)
|
||||
return Circle()
|
||||
.fill(RadialGradient(
|
||||
colors: [blob.color, blob.color.opacity(0)],
|
||||
colors: [color, color.opacity(0)],
|
||||
center: .center, startRadius: 0, endRadius: r / 2))
|
||||
.frame(width: r, height: r)
|
||||
.position(x: x * size.width, y: y * size.height)
|
||||
.opacity(blob.opacity)
|
||||
.blendMode(.plusLighter)
|
||||
// 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(palette.light ? .normal : .plusLighter)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,15 +331,17 @@ private struct LegacyBlobField: View {
|
||||
/// the tray's text sits on a softly blurred backdrop that dissolves into the rows.
|
||||
struct GamepadTrayScrim: View {
|
||||
let edge: VerticalEdge
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
|
||||
var body: some View {
|
||||
let fromEdge: UnitPoint = edge == .top ? .top : .bottom
|
||||
let toContent: UnitPoint = edge == .top ? .bottom : .top
|
||||
Rectangle()
|
||||
.fill(.ultraThinMaterial)
|
||||
// These trays always sit on the dark console UI; force dark so the material frosts dark
|
||||
// (white text stays legible) regardless of the system appearance.
|
||||
.environment(\.colorScheme, .dark)
|
||||
// Force the frost to match the PALETTE, not the system appearance: the tray exists
|
||||
// to keep the pinned title legible, so it has to frost dark under white ink and
|
||||
// light under dark ink.
|
||||
.environment(\.colorScheme, ink.isLight ? .light : .dark)
|
||||
// Fade the whole blur out toward the content so it dissolves rather than ending on a line.
|
||||
.mask {
|
||||
LinearGradient(
|
||||
@@ -330,27 +359,16 @@ struct GamepadTrayScrim: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The calm backdrop for the gamepad UI's form screens (settings, add-host) — NOT the launcher's
|
||||
/// drifting aurora (this stays still and quiet), but deliberately NOT near-black either: Liquid
|
||||
/// Glass refracts whatever sits behind it, so over black the rows turn invisible. A deep indigo
|
||||
/// base plus two soft, static violet/indigo glows give the glass real colour and luminance to lens,
|
||||
/// so the rows read as glass while the screen stays restful.
|
||||
/// The backdrop for the gamepad UI's form screens (settings, add-host). It used to be a STILL pair
|
||||
/// of glows over a deep indigo base — deliberately not near-black, because Liquid Glass refracts
|
||||
/// whatever sits behind it and over black the rows turn invisible. It is now the launcher's own
|
||||
/// living field at `calm`, which keeps that luminance under the glass, keeps the palette setting
|
||||
/// honoured on every screen rather than only the launcher, and leaves nothing in the gamepad UI
|
||||
/// backed by a static image. Kept as its own type because that is what the form screens ask for by
|
||||
/// name; the console (`pf-console-ui`) made the same substitution behind its `Bg::Form`.
|
||||
struct GamepadFormBackground: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(red: 0.075, green: 0.062, blue: 0.150)
|
||||
// Violet lift top-leading, cooler indigo bottom-trailing — resolution-independent
|
||||
// (fraction radii) so the glow scale tracks the window on any screen.
|
||||
EllipticalGradient(
|
||||
colors: [Color(red: 0.40, green: 0.31, blue: 0.68).opacity(0.9), .clear],
|
||||
center: UnitPoint(x: 0.26, y: 0.14),
|
||||
startRadiusFraction: 0, endRadiusFraction: 0.78)
|
||||
EllipticalGradient(
|
||||
colors: [Color(red: 0.20, green: 0.24, blue: 0.58).opacity(0.75), .clear],
|
||||
center: UnitPoint(x: 0.82, y: 0.9),
|
||||
startRadiusFraction: 0, endRadiusFraction: 0.78)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
GamepadScreenBackground(calm: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,6 +391,7 @@ struct ConsoleBareButtonStyle: ButtonStyle {
|
||||
/// chip in the launcher's top bar. Callers observe GamepadManager already, so this re-renders
|
||||
/// when the pad or its battery state changes.
|
||||
struct ControllerStatusChip: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
let controller: GamepadManager.DiscoveredController
|
||||
|
||||
// Legible from the couch on tvOS, quiet in hand elsewhere.
|
||||
@@ -397,15 +416,15 @@ struct ControllerStatusChip: View {
|
||||
Image(systemName: batterySymbol(level))
|
||||
.font(.system(size: Self.font))
|
||||
.foregroundStyle(level <= 0.2 && !controller.isCharging
|
||||
? AnyShapeStyle(.red) : AnyShapeStyle(.white.opacity(0.7)))
|
||||
? AnyShapeStyle(.red) : AnyShapeStyle(ink.fg(0.7)))
|
||||
}
|
||||
}
|
||||
.font(.geist(Self.font, .medium, relativeTo: .caption))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.foregroundStyle(ink.fg(0.7))
|
||||
.padding(.horizontal, Self.hPad)
|
||||
.padding(.vertical, Self.vPad)
|
||||
.background(Capsule().fill(.white.opacity(0.08)))
|
||||
.overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1))
|
||||
.background(Capsule().fill(ink.fg(0.08)))
|
||||
.overlay(Capsule().strokeBorder(ink.fg(0.12), lineWidth: 1))
|
||||
}
|
||||
|
||||
private func batterySymbol(_ level: Float) -> String {
|
||||
|
||||
@@ -23,14 +23,15 @@ import SwiftUI
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
import GameController
|
||||
|
||||
/// One navigable tile: a saved host, a discovered-but-unsaved one, or the trailing Add Host
|
||||
/// action. Hashable so it can be the carousel's scroll-position identity.
|
||||
/// One navigable tile: a saved host, a discovered-but-unsaved one, or one of the trailing
|
||||
/// actions. Hashable so it can be the carousel's scroll-position identity.
|
||||
private enum GamepadHomeTarget: Hashable {
|
||||
/// A saved host's own tile, or one of its pinned host+profile cards (§5.2a) — which on a
|
||||
/// controller-first surface are THE profile affordance: focus and press, no menus.
|
||||
case saved(UUID, profile: String?)
|
||||
case discovered(String)
|
||||
case addHost
|
||||
case rescan
|
||||
}
|
||||
|
||||
/// A fully-resolved launcher tile — display fields + the activate action, built fresh each render
|
||||
@@ -63,6 +64,7 @@ private struct HomeTile: Identifiable {
|
||||
}
|
||||
|
||||
struct GamepadHomeView: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
@ObservedObject var store: HostStore
|
||||
@ObservedObject var model: SessionModel
|
||||
@ObservedObject var discovery: HostDiscovery
|
||||
@@ -114,6 +116,9 @@ struct GamepadHomeView: View {
|
||||
.padding(.top, compact ? 4 : 8)
|
||||
}
|
||||
.background { GamepadScreenBackground() }
|
||||
// Publish the palette's ink to this screen (text, glass, accent, scrims) — a
|
||||
// pale palette flips all of them, and no leaf should have to read the setting.
|
||||
.gamepadPaletteInk()
|
||||
.onAppear { discovery.start() }
|
||||
.onDisappear { discovery.stop() }
|
||||
// Reachability sweep (mDNS-independent) so routed/VPN hosts that never advertise still show
|
||||
@@ -185,7 +190,7 @@ struct GamepadHomeView: View {
|
||||
statusChip(hidden: true)
|
||||
Text("Select a Host")
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(ink.fg)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -262,10 +267,14 @@ struct GamepadHomeView: View {
|
||||
|
||||
private var hints: [GamepadHint] {
|
||||
let selected = tiles.first { $0.id == selection }
|
||||
let action: String? = switch selected?.id {
|
||||
case .addHost: "Add Host"
|
||||
case .rescan: "Rescan"
|
||||
default: nil
|
||||
}
|
||||
var hints = [GamepadHint(
|
||||
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"),
|
||||
text: selected?.id == .addHost ? "Add Host"
|
||||
: (selected?.canWake == true ? "Wake & Connect" : "Connect"))]
|
||||
text: action ?? (selected?.canWake == true ? "Wake & Connect" : "Connect"))]
|
||||
if libraryEnabled, selected?.hasLibrary == true {
|
||||
hints.append(.init(glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library"))
|
||||
}
|
||||
@@ -325,7 +334,15 @@ struct GamepadHomeView: View {
|
||||
subtitle: "Register a host by address",
|
||||
icon: "plus",
|
||||
activate: { showAddHost = true })
|
||||
return saved + discovered + [add]
|
||||
// A controller surface has no toolbar and no pull-to-refresh, so the rescan the field
|
||||
// asked for is a tile like any other — one press from wherever the stick already is.
|
||||
let rescan = HomeTile(
|
||||
id: .rescan,
|
||||
title: "Rescan",
|
||||
subtitle: discovery.isScanning ? "Scanning…" : "Look for hosts on this network",
|
||||
icon: "arrow.clockwise",
|
||||
activate: { discovery.refresh() })
|
||||
return saved + discovered + [add, rescan]
|
||||
}
|
||||
|
||||
/// Only saved hosts have a library — matches the touch grid, where "Browse Library…" is a
|
||||
@@ -342,6 +359,7 @@ struct GamepadHomeView: View {
|
||||
/// touch grid's `HostCardView`. Renders only its base look; the centered-tile pop is layered on by
|
||||
/// the caller's `.scrollTransition` so it always tracks the real scroll position.
|
||||
private struct GamepadHostTile: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
let tile: HomeTile
|
||||
let size: CGSize
|
||||
|
||||
@@ -381,7 +399,7 @@ private struct GamepadHostTile: View {
|
||||
if tile.isPaired {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: Self.statusFont, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
.foregroundStyle(ink.fg(0.5))
|
||||
}
|
||||
if tile.isOnline {
|
||||
Circle()
|
||||
@@ -394,7 +412,7 @@ private struct GamepadHostTile: View {
|
||||
Spacer(minLength: 0)
|
||||
Text(tile.title)
|
||||
.font(.geist(Self.titleFont, .bold, relativeTo: .title2))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(ink.fg)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
if let profile = tile.profile {
|
||||
@@ -404,7 +422,7 @@ private struct GamepadHostTile: View {
|
||||
}
|
||||
Text(tile.subtitle)
|
||||
.font(.geist(Self.subtitleFont, relativeTo: .caption))
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
.foregroundStyle(ink.fg(0.55))
|
||||
.lineLimit(1)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
@@ -414,12 +432,12 @@ private struct GamepadHostTile: View {
|
||||
// Add-Host tiles stay neutral glass with a dashed edge. Glass clips to the shape itself.
|
||||
.consoleGlass(
|
||||
RoundedRectangle(cornerRadius: Self.corner, style: .continuous),
|
||||
tint: tile.filled ? Color.brand.opacity(0.20) : nil)
|
||||
tint: tile.filled ? ink.accent(0.20) : nil)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Self.corner, style: .continuous)
|
||||
.strokeBorder(
|
||||
LinearGradient(
|
||||
colors: [.white.opacity(0.22), .white.opacity(0.04)],
|
||||
colors: [ink.fg(0.22), ink.fg(0.04)],
|
||||
startPoint: .top, endPoint: .bottom),
|
||||
style: StrokeStyle(lineWidth: 1, dash: tile.filled ? [] : [6, 5]))
|
||||
}
|
||||
@@ -431,15 +449,15 @@ private struct GamepadHostTile: View {
|
||||
return ZStack {
|
||||
shape.fill(tile.filled
|
||||
? AnyShapeStyle(LinearGradient(
|
||||
colors: [Color.brand, Color.brand.opacity(0.68)],
|
||||
colors: [ink.accent, ink.accent(0.68)],
|
||||
startPoint: .top, endPoint: .bottom))
|
||||
: AnyShapeStyle(Color.brand.opacity(0.16)))
|
||||
: AnyShapeStyle(ink.accent(0.16)))
|
||||
if tile.isConnecting {
|
||||
ProgressView().tint(.white)
|
||||
ProgressView().tint(ink.fg)
|
||||
} else if let icon = tile.icon {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: Self.iconFont, weight: .semibold))
|
||||
.foregroundStyle(Color.brand)
|
||||
.foregroundStyle(ink.accent)
|
||||
} else if let mark = osIconImage(for: tile.osChain) {
|
||||
// The OS mark stands in for the initial (template asset — tints like the text it
|
||||
// replaces), and carries the label, since nothing else on the tile names the OS.
|
||||
@@ -447,18 +465,18 @@ private struct GamepadHostTile: View {
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: Self.monogramFont, height: Self.monogramFont)
|
||||
.foregroundStyle(tile.filled ? .white : Color.brand)
|
||||
.foregroundStyle(tile.filled ? ink.fg : ink.accent)
|
||||
.accessibilityLabel(tile.osChain ?? "")
|
||||
} else {
|
||||
Text(monogram(tile.title))
|
||||
.font(.geistFixed(Self.monogramFont, .bold))
|
||||
.foregroundStyle(tile.filled ? .white : Color.brand)
|
||||
.foregroundStyle(tile.filled ? ink.fg : ink.accent)
|
||||
}
|
||||
}
|
||||
.frame(width: Self.badgeSide, height: Self.badgeSide)
|
||||
.overlay {
|
||||
if !tile.filled {
|
||||
shape.strokeBorder(Color.brand.opacity(0.5), lineWidth: 1)
|
||||
shape.strokeBorder(ink.accent(0.5), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// The ink the gamepad UI draws with under the chosen background palette.
|
||||
//
|
||||
// The console screens were white-on-dark throughout with the brand violet hardcoded as the
|
||||
// accent. Both had to become palette-derived at once: a pale field needs dark text or it is
|
||||
// unreadable, and a violet focus wash on a copper field is exactly the clash this exists to fix.
|
||||
//
|
||||
// Handed down the view tree as an environment value rather than passed to each screen, so a
|
||||
// leaf (a row, a hint pill, a card) can ask for the right colour without every caller in between
|
||||
// knowing about palettes. `pf-console-ui` does the same thing with a thread-local `Ink`.
|
||||
|
||||
import PunktfunkShared
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
|
||||
struct GamepadInk: Equatable, Sendable {
|
||||
/// Primary text/glyph colour.
|
||||
let fg: Color
|
||||
/// Focus wash, selected tab pill, switch track, caret — the palette's own accent.
|
||||
let accent: Color
|
||||
/// What reads ON the accent (a filled pill's label).
|
||||
let onAccent: Color
|
||||
/// The base fill every glass surface starts from.
|
||||
let glass: Color
|
||||
/// What a wash laid UNDER text tends toward: black on a dark field, white on a pale one.
|
||||
let shade: Color
|
||||
/// How hard those washes go. A pale field needs far less — mixing toward white at the dark
|
||||
/// field's strength bleaches the chroma straight out of the gradient.
|
||||
let shadeScale: Double
|
||||
/// True when the field is pale, for the few places that need to branch rather than blend
|
||||
/// (a material's `colorScheme`, a shadow's presence).
|
||||
let isLight: Bool
|
||||
|
||||
/// The foreground at `alpha`.
|
||||
func fg(_ alpha: Double) -> Color { fg.opacity(alpha) }
|
||||
/// The accent at `alpha`.
|
||||
func accent(_ alpha: Double) -> Color { accent.opacity(alpha) }
|
||||
/// A wash under text: `alpha` is the dark-field strength, scaled for a pale one.
|
||||
func shade(_ alpha: Double) -> Color { shade.opacity(alpha * shadeScale) }
|
||||
|
||||
static func of(_ p: GamepadPalette) -> GamepadInk {
|
||||
let accent = Color(red: p.accent.x, green: p.accent.y, blue: p.accent.z)
|
||||
let accentLuma = 0.2126 * p.accent.x + 0.7152 * p.accent.y + 0.0722 * p.accent.z
|
||||
// Chosen by luminance, not by `light`: an accent is picked for contrast against the
|
||||
// GLASS, not against the field.
|
||||
let onAccent: Color = accentLuma > 0.55 ? .black : .white
|
||||
guard p.light else {
|
||||
return GamepadInk(
|
||||
fg: .white, accent: accent, onAccent: onAccent,
|
||||
glass: Color(red: 0.086, green: 0.086, blue: 0.125),
|
||||
shade: .black, shadeScale: 1, isLight: false)
|
||||
}
|
||||
return GamepadInk(
|
||||
// Tinted toward the palette's own ground so it doesn't read as a foreign grey.
|
||||
fg: Color(red: p.ground.x * 0.16, green: p.ground.y * 0.14, blue: p.ground.z * 0.20),
|
||||
accent: accent, onAccent: onAccent,
|
||||
glass: .white,
|
||||
shade: .white, shadeScale: 0.45, isLight: true)
|
||||
}
|
||||
|
||||
/// The shipped dark look — what a preview or a test composition gets.
|
||||
static let dark = GamepadInk.of(GamepadPalette.named("violet"))
|
||||
}
|
||||
|
||||
private struct GamepadInkKey: EnvironmentKey {
|
||||
static let defaultValue = GamepadInk.dark
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// The ink of the palette currently drawing. Set once, high up (see `GamepadInkModifier`).
|
||||
var gamepadInk: GamepadInk {
|
||||
get { self[GamepadInkKey.self] }
|
||||
set { self[GamepadInkKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Resolve the stored `ui_palette` and publish its ink to everything below. Applied by the
|
||||
/// gamepad screens' common root so no individual view has to read the setting.
|
||||
func gamepadPaletteInk() -> some View { modifier(GamepadInkModifier()) }
|
||||
}
|
||||
|
||||
private struct GamepadInkModifier: ViewModifier {
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.environment(\.gamepadInk, GamepadInk.of(GamepadPalette.named(paletteID)))
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -14,6 +14,7 @@ import SwiftUI
|
||||
#if os(iOS) || os(macOS)
|
||||
|
||||
struct GamepadKeyboard: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
@Binding var text: String
|
||||
/// Restricts typed characters (e.g. digits for a port field); backspace always works.
|
||||
var allowed: CharacterSet?
|
||||
@@ -79,7 +80,7 @@ struct GamepadKeyboard: View {
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.12), lineWidth: 1)
|
||||
.strokeBorder(ink.fg(0.12), lineWidth: 1)
|
||||
}
|
||||
.sensoryFeedback(.selection, trigger: cursor)
|
||||
.sensoryFeedback(.impact(weight: .light), trigger: pressTick)
|
||||
@@ -110,11 +111,11 @@ struct GamepadKeyboard: View {
|
||||
.font(.geist(15, .semibold, relativeTo: .callout))
|
||||
}
|
||||
}
|
||||
.foregroundStyle(focused ? Color.black : .white)
|
||||
.foregroundStyle(focused ? Color.black : ink.fg)
|
||||
.frame(maxWidth: .infinity, minHeight: compact ? 34 : 42)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 9, style: .continuous)
|
||||
.fill(focused ? AnyShapeStyle(Color.brand) : AnyShapeStyle(.white.opacity(0.08)))
|
||||
.fill(focused ? AnyShapeStyle(ink.accent) : AnyShapeStyle(ink.fg(0.08)))
|
||||
}
|
||||
.animation(.smooth(duration: 0.12), value: focused)
|
||||
.contentShape(Rectangle())
|
||||
|
||||
@@ -35,6 +35,10 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
||||
let onActivate: (Item) -> Void
|
||||
/// B → back/dismiss; nil disables it.
|
||||
var onBack: (() -> Void)?
|
||||
/// L1 (`-1`) / R1 (`+1`) — a step SIDEWAYS out of the list: the settings screen's section
|
||||
/// tabs. Wired on tvOS too, where the focus engine owns up/down but leaves the shoulders
|
||||
/// to the poll. nil ⇒ the shoulders do nothing.
|
||||
var onShoulder: ((Int) -> Void)?
|
||||
/// Whether this list currently owns controller input — same handoff contract as
|
||||
/// GamepadCarousel's `isActive` (a covered screen must stop polling the shared pad).
|
||||
var isActive: Bool = true
|
||||
@@ -159,6 +163,7 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
||||
case .up, .down: break
|
||||
}
|
||||
}
|
||||
input.onShoulder = { forward in onShoulder?(forward ? 1 : -1) }
|
||||
#else
|
||||
input.onMove = { direction in
|
||||
switch direction {
|
||||
@@ -170,6 +175,7 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
||||
}
|
||||
input.onConfirm = { activate() }
|
||||
input.onBack = onBack
|
||||
input.onShoulder = { forward in onShoulder?(forward ? 1 : -1) }
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,18 @@ struct HomeView: View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if store.hosts.isEmpty && discoveredUnsaved.isEmpty {
|
||||
emptyState
|
||||
#if os(tvOS)
|
||||
emptyState // no pull-to-refresh on a remote; the action row carries Refresh
|
||||
#else
|
||||
// Inside a ScrollView purely so the pull gesture works on the ONE screen
|
||||
// where a rescan matters most: the one that found nothing.
|
||||
ScrollView {
|
||||
emptyState
|
||||
.frame(maxWidth: .infinity)
|
||||
.containerRelativeFrame(.vertical)
|
||||
}
|
||||
.refreshable { await discovery.rescan() }
|
||||
#endif
|
||||
} else {
|
||||
ScrollView {
|
||||
if !store.hosts.isEmpty {
|
||||
@@ -94,6 +105,7 @@ struct HomeView: View {
|
||||
} label: {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
}
|
||||
refreshButton
|
||||
}
|
||||
.padding(.top, 24)
|
||||
// One FULL-WIDTH focus target for any downward move out of the grid.
|
||||
@@ -106,6 +118,9 @@ struct HomeView: View {
|
||||
.focusSection()
|
||||
#endif
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.refreshable { await discovery.rescan() }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.navigationTitle("Punktfunk")
|
||||
@@ -151,6 +166,7 @@ struct HomeView: View {
|
||||
if showsArrangeMenu {
|
||||
ToolbarItem(placement: .topBarTrailing) { arrangeMenu }
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) { refreshButton }
|
||||
ToolbarItem(placement: .topBarTrailing) { addHostButton }
|
||||
#else
|
||||
if showsArrangeMenu {
|
||||
@@ -159,6 +175,10 @@ struct HomeView: View {
|
||||
.help("Sort and group the host list")
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
refreshButton
|
||||
.help("Scan the network for hosts again")
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
addHostButton
|
||||
.help("Add a host")
|
||||
@@ -324,13 +344,20 @@ struct HomeView: View {
|
||||
ContentUnavailableView {
|
||||
Label("No Hosts", systemImage: "rectangle.connected.to.line.below")
|
||||
} description: {
|
||||
Text("Add your punktfunk host with the + button.")
|
||||
Text("Add your Punktfunk host with the + button, or scan the network again.")
|
||||
} actions: {
|
||||
Button("Add Host") { showAddHost = true }
|
||||
.glassProminentButtonStyle()
|
||||
#if os(iOS)
|
||||
.controlSize(.large)
|
||||
#endif
|
||||
// The screen a host SHOULD have appeared on is where a rescan is worth offering
|
||||
// outright rather than hiding behind a pull gesture.
|
||||
Button("Scan Again") { discovery.refresh() }
|
||||
.disabled(discovery.isScanning)
|
||||
#if os(iOS)
|
||||
.controlSize(.large)
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
Button("Settings") { showSettings = true }
|
||||
#endif
|
||||
@@ -345,6 +372,18 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-run mDNS discovery from scratch. Discovery heals itself now (`HostDiscovery`'s sweep),
|
||||
/// so this is the fallback the field asked for — and the fastest way past the iOS
|
||||
/// local-network permission gate, which only a NEW browser can clear.
|
||||
private var refreshButton: some View {
|
||||
Button {
|
||||
discovery.refresh()
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.disabled(discovery.isScanning)
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
/// One host has no order and nothing to divide, so the control stays out of the way until
|
||||
/// there is a list to arrange.
|
||||
|
||||
@@ -19,6 +19,7 @@ import SwiftUI
|
||||
import GameController
|
||||
|
||||
struct LibraryCoverflowView: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
let games: [GameEntry]
|
||||
let imageSession: URLSession?
|
||||
var onLaunch: ((String) -> Void)?
|
||||
@@ -46,18 +47,24 @@ struct LibraryCoverflowView: View {
|
||||
.padding(.vertical, compact ? 6 : 10)
|
||||
}
|
||||
.background { GamepadScreenBackground() }
|
||||
// Publish the palette's ink to this screen (text, glass, accent, scrims) — a
|
||||
// pale palette flips all of them, and no leaf should have to read the setting.
|
||||
.gamepadPaletteInk()
|
||||
}
|
||||
|
||||
@ViewBuilder private func content(for size: CGSize) -> some View {
|
||||
// Fit the tallest poster into the height the detail line + paddings leave (the hints are a
|
||||
// safe-area inset, already out of this budget) — capped so it never dwarfs a large iPad and
|
||||
// clamped by width on a narrow screen.
|
||||
let reserved: CGFloat = compact ? 72 : 96 // detail line + spacers
|
||||
let reserved: CGFloat = (compact ? 72 : 96) + (showsGroupHeading ? 26 : 0)
|
||||
let coverHeight = min(360, min(max(140, size.height - reserved), size.width * 0.9))
|
||||
let coverWidth = coverHeight * 2 / 3
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Spacer(minLength: 4)
|
||||
if showsGroupHeading {
|
||||
groupHeading.padding(.bottom, 6)
|
||||
}
|
||||
carousel(coverWidth: coverWidth, coverHeight: coverHeight)
|
||||
detailPanel
|
||||
.padding(.top, 12)
|
||||
@@ -89,10 +96,12 @@ struct LibraryCoverflowView: View {
|
||||
PosterImage(candidates: game.art.posterCandidates, title: game.title, session: imageSession)
|
||||
.frame(width: width, height: height)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.overlay(alignment: .topLeading) { StoreBadge(isCustom: game.isCustom) }
|
||||
.overlay(alignment: .topLeading) {
|
||||
StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher)
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.12), lineWidth: 1)
|
||||
.strokeBorder(ink.fg(0.12), lineWidth: 1)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.5), radius: 16, y: 12)
|
||||
.scrollTransition { content, phase in
|
||||
@@ -112,21 +121,42 @@ struct LibraryCoverflowView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Does this library have both groups? Only then does the heading earn its row — a
|
||||
/// launcher-less library gets exactly the layout it had before design D4.
|
||||
private var showsGroupHeading: Bool {
|
||||
games.contains(where: \.isLauncher) && games.contains { !$0.isLauncher }
|
||||
}
|
||||
|
||||
/// Which group the cursor is in. A coverflow is one-dimensional, so instead of a second focus
|
||||
/// rail (a whole new up/down nav model for two or three tiles) the heading names the group and
|
||||
/// changes as the selection crosses the boundary — the launcher entries lead the strip.
|
||||
private var groupHeading: some View {
|
||||
let selected = games.first { $0.id == selection }
|
||||
return Text(selected?.isLauncher == true ? "LAUNCHERS" : "GAMES")
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.tracking(1.4)
|
||||
.foregroundStyle(ink.fg(0.45))
|
||||
}
|
||||
|
||||
/// The centered title + store tag — empty (not hidden) so the layout doesn't jump.
|
||||
@ViewBuilder private var detailPanel: some View {
|
||||
let game = games.first { $0.id == selection }
|
||||
VStack(spacing: 6) {
|
||||
Text(game?.title ?? " ")
|
||||
.font(.geist(compact ? 22 : 25, .bold, relativeTo: .title))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(ink.fg)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
.multilineTextAlignment(.center)
|
||||
if let game {
|
||||
Text(game.isCustom ? "CUSTOM" : "STEAM")
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.tracking(1.2)
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
// main's richer store label, in the palette's ink.
|
||||
Text(
|
||||
game.isLauncher
|
||||
? "\(game.storeLabel.uppercased()) · LAUNCHER" : game.storeLabel.uppercased()
|
||||
)
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.tracking(1.2)
|
||||
.foregroundStyle(ink.fg(0.5))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -139,7 +169,10 @@ struct LibraryCoverflowView: View {
|
||||
private var hints: [GamepadHint] {
|
||||
var hints: [GamepadHint] = []
|
||||
if onLaunch != nil {
|
||||
hints.append(.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Launch"))
|
||||
// You *open* a launcher and *launch* a game — the hint follows the focused entry.
|
||||
let opens = games.first { $0.id == selection }?.isLauncher == true
|
||||
hints.append(
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: opens ? "Open" : "Launch"))
|
||||
}
|
||||
hints.append(.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Close"))
|
||||
return hints
|
||||
|
||||
@@ -80,21 +80,47 @@ struct LibraryView: View {
|
||||
}
|
||||
|
||||
private var grid: some View {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 18) {
|
||||
ForEach(games) { game in
|
||||
if let onLaunch {
|
||||
Button { onLaunch(game.id) } label: { GameCard(game: game, imageSession: imageSession) }
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
GameCard(game: game, imageSession: imageSession)
|
||||
}
|
||||
// Design D4: launcher entries get their own section above the titles, never interleaved.
|
||||
// Both headers appear only when both groups exist, so a library without launcher entries
|
||||
// renders exactly as it did before.
|
||||
let launchers = games.filter(\.isLauncher)
|
||||
let titles = games.filter { !$0.isLauncher }
|
||||
let both = !launchers.isEmpty && !titles.isEmpty
|
||||
return ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
if !launchers.isEmpty {
|
||||
if both { sectionHeader("Launchers") }
|
||||
tiles(launchers)
|
||||
}
|
||||
if !titles.isEmpty {
|
||||
if both { sectionHeader("Games") }
|
||||
tiles(titles)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private func tiles(_ entries: [GameEntry]) -> some View {
|
||||
LazyVGrid(columns: columns, spacing: 18) {
|
||||
ForEach(entries) { game in
|
||||
if let onLaunch {
|
||||
Button { onLaunch(game.id) } label: { GameCard(game: game, imageSession: imageSession) }
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
GameCard(game: game, imageSession: imageSession)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sectionHeader(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.geist(12, .semibold, relativeTo: .caption))
|
||||
.tracking(1.1)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
private var columns: [GridItem] {
|
||||
#if os(tvOS)
|
||||
let minW: CGFloat = 220
|
||||
@@ -152,12 +178,15 @@ struct LibraryView: View {
|
||||
return
|
||||
}
|
||||
do {
|
||||
// `launchersFirst` groups launcher entries ahead of titles once, here, so the grid and
|
||||
// the gamepad coverflow both inherit the D4 ordering.
|
||||
games = try await LibraryClient.fetch(
|
||||
address: current.address,
|
||||
port: current.effectiveMgmtPort,
|
||||
certPEM: identity.certPEM,
|
||||
keyPEM: identity.keyPEM,
|
||||
hostFingerprint: current.pinnedSHA256)
|
||||
hostFingerprint: current.pinnedSHA256
|
||||
).launchersFirst
|
||||
imageSession?.finishTasksAndInvalidate()
|
||||
imageSession = try LibraryImageLoader.session(
|
||||
address: current.address,
|
||||
@@ -185,7 +214,9 @@ private struct GameCard: View {
|
||||
.aspectRatio(2.0 / 3.0, contentMode: .fit)
|
||||
.frame(maxWidth: .infinity)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.overlay(alignment: .topLeading) { StoreBadge(isCustom: game.isCustom) }
|
||||
.overlay(alignment: .topLeading) {
|
||||
StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher)
|
||||
}
|
||||
Text(game.title)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.lineLimit(2)
|
||||
|
||||
@@ -12,14 +12,21 @@ import AppKit
|
||||
/// The store-provenance badge (Steam vs. a user-curated custom entry) overlaid on a poster —
|
||||
/// shared by the touch grid's `GameCard` and the gamepad coverflow's cover cell.
|
||||
struct StoreBadge: View {
|
||||
let isCustom: Bool
|
||||
/// Which store surfaced the entry, already resolved to a display name (`GameEntry.storeLabel`).
|
||||
let label: String
|
||||
/// A launcher entry (design D4) gets the brand fill, so "opens Steam" is legible at poster size
|
||||
/// without reading the title.
|
||||
var isLauncher: Bool = false
|
||||
|
||||
var body: some View {
|
||||
Text(isCustom ? "Custom" : "Steam")
|
||||
Text(label)
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.foregroundStyle(isLauncher ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.background(
|
||||
isLauncher ? AnyShapeStyle(Color.brand) : AnyShapeStyle(.ultraThinMaterial),
|
||||
in: Capsule())
|
||||
.padding(6)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,14 @@ final class SessionModel: ObservableObject {
|
||||
@Published private(set) var connection: PunktfunkConnection?
|
||||
/// The host this session is for (a value copy; identity = id).
|
||||
@Published private(set) var activeHost: StoredHost?
|
||||
/// The library entry this session was launched with (`connect(launchID:)`), or nil if the user
|
||||
/// just connected to the host's desktop. Kept because where the client should go when the
|
||||
/// session ends depends on where it came FROM: a title launched out of the library belongs back
|
||||
/// in that library when its game exits, not on the host-selection screen.
|
||||
private var launchedTitleID: String?
|
||||
/// Set when a session ended because its game exited and it began as a library launch: the host
|
||||
/// whose library to reopen. The view layer consumes it and sets it back to nil.
|
||||
@Published var returnToLibrary: StoredHost?
|
||||
/// The settings THIS session runs on — the globals with its profile overlaid, resolved once at
|
||||
/// connect (design/client-settings-profiles.md §4.2). Also mirrored into `SessionSettings` for
|
||||
/// the readers that live in PunktfunkKit and can't see this model.
|
||||
@@ -249,6 +257,7 @@ final class SessionModel: ObservableObject {
|
||||
guard phase == .idle else { return }
|
||||
phase = .connecting
|
||||
activeHost = host
|
||||
launchedTitleID = launchID
|
||||
errorMessage = nil
|
||||
settings = effective
|
||||
statsVerbosity = StatsVerbosity(rawValue: effective.statsVerbosity) ?? .normal
|
||||
@@ -607,6 +616,8 @@ final class SessionModel: ObservableObject {
|
||||
}
|
||||
connection = nil
|
||||
activeHost = nil
|
||||
// Read by `sessionEnded` BEFORE it calls us, so clearing here can't rob it of the answer.
|
||||
launchedTitleID = nil
|
||||
phase = .idle
|
||||
fps = 0
|
||||
mbps = 0
|
||||
@@ -626,10 +637,36 @@ final class SessionModel: ObservableObject {
|
||||
|
||||
/// Called (via the main actor) when the pump hits end-of-session.
|
||||
func sessionEnded() {
|
||||
guard connection != nil else { return }
|
||||
guard let conn = connection else { return }
|
||||
let name = activeHost?.displayName ?? "host"
|
||||
// WHY it ended, asked while the connection is still up — `disconnect` tears it down.
|
||||
let reason = conn.sessionEndReason
|
||||
// Where a game exit sends us: back into the library this title was launched from, so the
|
||||
// next one is a tap away. Only for a launch that CAME from the library — a game exiting in
|
||||
// a plain desktop session has no library to return to.
|
||||
let host = activeHost
|
||||
let cameFromLibrary = launchedTitleID != nil
|
||||
disconnect(deliberate: false) // host/network ended it — keep the linger for a reconnect
|
||||
errorMessage = "Session ended by \(name)."
|
||||
switch reason {
|
||||
case .gameExited:
|
||||
// The player quit their own game. Not a failure, and they are probably after the next
|
||||
// title — so no banner, and back to the library it came from.
|
||||
if cameFromLibrary, let host {
|
||||
returnToLibrary = host
|
||||
}
|
||||
case .hostEnded, .local:
|
||||
// Someone asked for this: an operator "End" on the host, or our own close racing in.
|
||||
// Say it plainly, without the error framing.
|
||||
errorMessage = "\(name) ended the session."
|
||||
case .hostError:
|
||||
errorMessage = "\(name) ended the session with an error."
|
||||
case .lost:
|
||||
errorMessage = "Lost the connection to \(name)."
|
||||
case .none:
|
||||
// No verdict (an older core, or the close raced the read): keep the wording this path
|
||||
// has always used rather than inventing one.
|
||||
errorMessage = "Session ended by \(name)."
|
||||
}
|
||||
}
|
||||
|
||||
/// Resize overlay START (main actor — from the Match-window follower's `onResizeTarget`): the
|
||||
|
||||
@@ -11,7 +11,13 @@
|
||||
// the thumb it's the last option); A always cycles forward, wrapping, so every option is reachable
|
||||
// with one button. Toggles read left = off, right = on — refusing a no-op with the same thud.
|
||||
//
|
||||
// The trailing Profiles section (design/client-settings-profiles.md §5.2a/§5.4) is the pin manager
|
||||
// The rows are split across SECTION TABS (`GpSettingsTab`) — L1/R1 on a pad, a tap elsewhere. They
|
||||
// used to be one long scroll with inline group headers, which meant thumbing past Video and Audio
|
||||
// to reach the controller settings; a tab is one shoulder press, and each tab remembers where its
|
||||
// focus was. The tab names match the desktop console's and the Android client's, so a setting is
|
||||
// found under the same word wherever you look for it.
|
||||
//
|
||||
// The trailing Profiles tab (design/client-settings-profiles.md §5.2a/§5.4) is the pin manager
|
||||
// for this controller-first surface: a row per catalog profile opens the pin-to-hosts picker — an
|
||||
// in-place swap of the row list (B peels back, the "one layer" rule GamepadAddHostView set) with
|
||||
// one toggle row per saved host, writing `StoredHost.pinnedProfileIDs` via HostStore.setPinned.
|
||||
@@ -27,7 +33,19 @@ import GameController
|
||||
import CoreHaptics
|
||||
#endif
|
||||
|
||||
/// The settings screen's sections. Order IS the strip order and the L1/R1 cycle order; the names
|
||||
/// match `pf-console-ui`'s `TABS` and the Android client's `GpTab`.
|
||||
enum GpSettingsTab: String, CaseIterable, Hashable {
|
||||
case stream = "Stream"
|
||||
case video = "Video"
|
||||
case audio = "Audio"
|
||||
case controller = "Controller"
|
||||
case interface = "Interface"
|
||||
case profiles = "Profiles"
|
||||
}
|
||||
|
||||
struct GamepadSettingsView: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
/// The saved-host store — the pin picker writes `setPinned` through it and the profile rows
|
||||
/// count pins from its live hosts. Threaded in from GamepadHomeView like the home screen
|
||||
@@ -55,6 +73,9 @@ struct GamepadSettingsView: View {
|
||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||
/// The gamepad UI's background colour family — the backdrop BEHIND this screen re-colours as
|
||||
/// the row steps, which is why the picker lives here and not in a sheet.
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
||||
@AppStorage(DefaultsKey.presentPriority) private var presentPriority =
|
||||
SettingsOptions.presentPriorityDefault
|
||||
@@ -74,12 +95,23 @@ struct GamepadSettingsView: View {
|
||||
#if os(iOS)
|
||||
/// `.compact` in a landscape phone window — tighter chrome so more rows fit.
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
/// `.regular` only on an iPad-class window — see `showsSectionHint`.
|
||||
@Environment(\.horizontalSizeClass) private var hSizeClass
|
||||
|
||||
private var compact: Bool { vSizeClass == .compact }
|
||||
#else
|
||||
private let compact = false // no size classes on macOS; the sheet is sized generously
|
||||
#endif
|
||||
@State private var focusID: String?
|
||||
/// The section showing. The pin picker ignores it — that layer replaces the whole list.
|
||||
@State private var tab: GpSettingsTab = .stream
|
||||
/// Where each tab's focus was when it was last left, so a detour doesn't lose your place.
|
||||
@State private var tabFocus: [GpSettingsTab: String] = [:]
|
||||
@Namespace private var tabHighlight
|
||||
#if os(tvOS)
|
||||
/// Real focus on the strip — the tvOS route to the sections (see `tabStrip`).
|
||||
@FocusState private var focusedTab: GpSettingsTab?
|
||||
#endif
|
||||
/// The pin-to-hosts picker's profile — non-nil swaps the row list for one toggle row per
|
||||
/// saved host (§5.2a); B (Menu on tvOS) peels back to the settings rows.
|
||||
@State private var pinTarget: StreamProfile?
|
||||
@@ -93,7 +125,8 @@ struct GamepadSettingsView: View {
|
||||
focusID: $focusID,
|
||||
onAdjust: { row, delta in adjust(id: row.id, by: delta) },
|
||||
onActivate: { activate(id: $0.id) },
|
||||
onBack: { back() }
|
||||
onBack: { back() },
|
||||
onShoulder: { step(tabBy: $0) }
|
||||
) { row, focused in
|
||||
rowView(row, focused: focused)
|
||||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
|
||||
@@ -101,20 +134,25 @@ struct GamepadSettingsView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
Text(title)
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, compact ? 4 : 8)
|
||||
.frame(maxWidth: .infinity)
|
||||
.overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) }
|
||||
.background { GamepadTrayScrim(edge: .top) }
|
||||
VStack(spacing: compact ? 4 : 8) {
|
||||
Text(title)
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(ink.fg)
|
||||
.frame(maxWidth: .infinity)
|
||||
.overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) }
|
||||
// The picker is one layer deeper — its rows aren't sections of anything, so the
|
||||
// strip would be a control that does nothing while it's up.
|
||||
if pinTarget == nil { tabStrip }
|
||||
}
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, compact ? 4 : 8)
|
||||
.background { GamepadTrayScrim(edge: .top) }
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(focusedDetail)
|
||||
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
.foregroundStyle(ink.fg(0.55))
|
||||
.lineLimit(2, reservesSpace: true)
|
||||
.animation(.smooth(duration: 0.2), value: focusID)
|
||||
GamepadHintBar(hints: hints)
|
||||
@@ -127,9 +165,13 @@ struct GamepadSettingsView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background { GamepadTrayScrim(edge: .bottom) }
|
||||
}
|
||||
// No aurora here — the settings read as clean Liquid Glass over a quiet dark base, so the
|
||||
// glass rows are the only material on the screen.
|
||||
// The launcher's living field, calmed (GamepadFormBackground) — the glass rows keep real
|
||||
// colour and luminance to lens without the launcher's contrast, and the palette setting
|
||||
// applies here too, so this screen previews the row you're stepping.
|
||||
.background { GamepadFormBackground() }
|
||||
// Publish the palette's ink to this screen (text, glass, accent, scrims) — a
|
||||
// pale palette flips all of them, and no leaf should have to read the setting.
|
||||
.gamepadPaletteInk()
|
||||
.onAppear {
|
||||
gamepads.refresh()
|
||||
gamepads.startDiscovery()
|
||||
@@ -137,13 +179,108 @@ struct GamepadSettingsView: View {
|
||||
.onDisappear { gamepads.stopDiscovery() }
|
||||
}
|
||||
|
||||
/// The section switcher. Horizontally scrollable so a narrow phone in landscape never has to
|
||||
/// squeeze six pills — the selected one is always scrolled into view, whether it was reached
|
||||
/// by shoulder button, tap, or (tvOS) the focus engine.
|
||||
private var tabStrip: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView(.horizontal) {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(GpSettingsTab.allCases, id: \.self) { t in
|
||||
#if os(tvOS)
|
||||
// Focusable, because L1/R1 is NOT a route here: a Siri Remote has no
|
||||
// extended gamepad profile, so it never reaches GamepadMenuList's poll.
|
||||
// As focusable Buttons the pills are simply above the rows, and moving
|
||||
// focus up onto one switches section — the standard tvOS tab bar.
|
||||
Button { select(tab: t) } label: { pill(t) }
|
||||
.buttonStyle(ConsoleBareButtonStyle())
|
||||
.focused($focusedTab, equals: t)
|
||||
.id(t)
|
||||
#else
|
||||
pill(t)
|
||||
.contentShape(Capsule())
|
||||
.onTapGesture { select(tab: t) }
|
||||
.id(t)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
.scrollIndicators(.never)
|
||||
.animation(.smooth(duration: 0.22), value: tab)
|
||||
.onChange(of: tab) { _, t in
|
||||
withAnimation(.easeOut(duration: 0.2)) { proxy.scrollTo(t) }
|
||||
}
|
||||
#if os(tvOS)
|
||||
.onChange(of: focusedTab) { _, t in
|
||||
// Focus IS selection on a tab bar; nil means focus dropped back into the rows.
|
||||
if let t { select(tab: t) }
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private func pill(_ t: GpSettingsTab) -> some View {
|
||||
let selected = t == tab
|
||||
return Text(t.rawValue)
|
||||
.font(.geist(compact ? 12 : 13, .semibold, relativeTo: .footnote))
|
||||
.foregroundStyle(selected ? ink.fg : ink.fg(0.55))
|
||||
.padding(.horizontal, 13)
|
||||
.padding(.vertical, 7)
|
||||
.background {
|
||||
// One shared capsule that MOVES between pills, rather than one per pill fading
|
||||
// in and out — the highlight travels the way the press did.
|
||||
if selected {
|
||||
Capsule()
|
||||
.fill(ink.accent(0.85))
|
||||
.matchedGeometryEffect(id: "tab", in: tabHighlight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the legend advertises the shoulder shortcut. Held back on an iPhone, whose legend
|
||||
/// is already at its width and would push "Done" off the edge — the strip is visible and
|
||||
/// tappable there anyway. Never on tvOS: a Siri Remote has no shoulders, and its route to the
|
||||
/// sections is the focus engine (see `tabStrip`).
|
||||
private var showsSectionHint: Bool {
|
||||
#if os(tvOS)
|
||||
false
|
||||
#elseif os(iOS)
|
||||
hSizeClass == .regular
|
||||
#else
|
||||
true
|
||||
#endif
|
||||
}
|
||||
|
||||
/// L1/R1 — one section along, wrapping (the strip is a ring, like A's value cycle).
|
||||
private func step(tabBy delta: Int) {
|
||||
guard pinTarget == nil else { return }
|
||||
let all = GpSettingsTab.allCases
|
||||
guard let i = all.firstIndex(of: tab) else { return }
|
||||
let n = all.count
|
||||
select(tab: all[((i + delta) % n + n) % n])
|
||||
}
|
||||
|
||||
private func select(tab next: GpSettingsTab) {
|
||||
guard next != tab else { return }
|
||||
tabFocus[tab] = focusID
|
||||
// Restore where this tab was, if that row is still in it (a row can come and go with the
|
||||
// hardware it depends on); otherwise the focus list seeds its first row. Resolved against
|
||||
// `allRows` rather than `rows` so it doesn't depend on `tab`'s write being visible yet.
|
||||
let landing = tabFocus[next].flatMap { id in
|
||||
allRows.contains { $0.tab == next && $0.id == id } ? id : nil
|
||||
}
|
||||
tab = next
|
||||
focusID = landing
|
||||
}
|
||||
|
||||
/// Touch/click fallback for closing — the controller path is B, a hardware keyboard's Esc
|
||||
/// rides the cancel action.
|
||||
private var closeButton: some View {
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(ink.fg)
|
||||
.frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide)
|
||||
.glassBackground(Circle(), interactive: true)
|
||||
.contentShape(Circle())
|
||||
@@ -166,12 +303,19 @@ struct GamepadSettingsView: View {
|
||||
/// layer" rule), and a hostless picker has nothing to pin, so only Back remains.
|
||||
private var hints: [GamepadHint] {
|
||||
guard pinTarget != nil else {
|
||||
// The shoulders change section, so that cell leads — where it fits and where the
|
||||
// shoulders exist at all (see `showsSectionHint`).
|
||||
let sections: [GamepadHint] = showsSectionHint
|
||||
? [.init(glyph: buttonGlyph(\.leftShoulder, fallback: "l1.rectangle.roundedbottom"),
|
||||
text: "Section")]
|
||||
: []
|
||||
// A dimmed row takes neither, so offering them would be the same lie the row itself
|
||||
// used to tell — only Done remains, and the detail line says what to turn on first.
|
||||
guard rows.first(where: { $0.id == focusID })?.enabled ?? true else {
|
||||
return [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done")]
|
||||
return sections
|
||||
+ [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done")]
|
||||
}
|
||||
return [
|
||||
return sections + [
|
||||
.init(glyph: "arrow.left.and.right", text: "Adjust"),
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"),
|
||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
|
||||
@@ -201,30 +345,24 @@ struct GamepadSettingsView: View {
|
||||
|
||||
private func rowView(_ row: Row, focused: Bool) -> some View {
|
||||
let m = GamepadFormMetrics.self
|
||||
// No section header: the tab strip names the section now, and repeating it above the
|
||||
// first row of every tab was just a second label saying the same word.
|
||||
return VStack(alignment: .leading, spacing: 6) {
|
||||
if let header = row.header {
|
||||
Text(header)
|
||||
.font(.geist(m.headerFont, .semibold, relativeTo: .caption))
|
||||
.tracking(1.4)
|
||||
.foregroundStyle(.white.opacity(0.45))
|
||||
.padding(.leading, m.rowHPad)
|
||||
.padding(.top, 14)
|
||||
}
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: row.icon)
|
||||
.font(.system(size: m.iconFont))
|
||||
.foregroundStyle(focused ? Color.brand : .white.opacity(0.55))
|
||||
.foregroundStyle(focused ? ink.accent : ink.fg(0.55))
|
||||
.frame(width: m.iconWidth)
|
||||
Text(row.label)
|
||||
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(ink.fg)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 12)
|
||||
HStack(spacing: 9) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: m.chevronFont, weight: .semibold))
|
||||
.foregroundStyle(
|
||||
.white.opacity(focused && row.adjustable && row.enabled ? 0.6 : 0))
|
||||
ink.fg(focused && row.adjustable && row.enabled ? 0.6 : 0))
|
||||
// Keyed by the value so a change slides the new option in instead of
|
||||
// hard-swapping the string — a QUIET horizontal slip following the user's
|
||||
// motion (a right-step enters from the right), crossfading over ~14 pt.
|
||||
@@ -235,7 +373,7 @@ struct GamepadSettingsView: View {
|
||||
ZStack {
|
||||
Text(row.value)
|
||||
.font(.geist(m.valueFont, .medium, relativeTo: .callout))
|
||||
.foregroundStyle(focused ? .white : .white.opacity(0.6))
|
||||
.foregroundStyle(focused ? ink.fg : ink.fg(0.6))
|
||||
.lineLimit(1)
|
||||
.id(row.value)
|
||||
.transition(.asymmetric(
|
||||
@@ -246,7 +384,7 @@ struct GamepadSettingsView: View {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: m.chevronFont, weight: .semibold))
|
||||
.foregroundStyle(
|
||||
.white.opacity(focused && row.adjustable && row.enabled ? 0.6 : 0))
|
||||
ink.fg(focused && row.adjustable && row.enabled ? 0.6 : 0))
|
||||
}
|
||||
}
|
||||
// Contents only — the glass and border below stay at full strength, so a dimmed row
|
||||
@@ -257,11 +395,11 @@ struct GamepadSettingsView: View {
|
||||
// Every row is Liquid Glass; the focused one takes a brand wash and reacts to press.
|
||||
.consoleGlass(
|
||||
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
|
||||
tint: focused ? Color.brand.opacity(0.30) : nil,
|
||||
tint: focused ? ink.accent(0.30) : nil,
|
||||
interactive: focused)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
|
||||
.strokeBorder(.white.opacity(focused ? 0.28 : 0.06), lineWidth: 1)
|
||||
.strokeBorder(ink.fg(focused ? 0.28 : 0.06), lineWidth: 1)
|
||||
}
|
||||
.scaleEffect(focused ? 1.0 : 0.98)
|
||||
.animation(.smooth(duration: 0.18), value: focused)
|
||||
@@ -276,8 +414,9 @@ struct GamepadSettingsView: View {
|
||||
|
||||
private struct Row: Identifiable {
|
||||
let id: String
|
||||
/// Section header drawn above this row (the first row of each group carries it).
|
||||
var header: String?
|
||||
/// Which section tab this row belongs to. Every row has exactly one, and `rows` shows
|
||||
/// only the current tab's — see `allRows`.
|
||||
var tab: GpSettingsTab = .stream
|
||||
let icon: String
|
||||
let label: String
|
||||
let value: String
|
||||
@@ -313,10 +452,17 @@ struct GamepadSettingsView: View {
|
||||
row.activate()
|
||||
}
|
||||
|
||||
/// What the focus list actually shows: the current tab's rows — or the pin picker's, which
|
||||
/// replaces the whole list while it's up (same screen, one layer deeper, so the focus list's
|
||||
/// controller wiring and the tvOS focus engine carry over as is).
|
||||
private var rows: [Row] {
|
||||
// The pin picker replaces the whole list while it's up — same screen, one layer deeper,
|
||||
// so the focus list's controller wiring (and the tvOS focus engine) carries over as is.
|
||||
if let profile = pinTarget { return pinRows(for: profile) }
|
||||
return allRows.filter { $0.tab == tab }
|
||||
}
|
||||
|
||||
/// Every row on the screen, tagged with its section. Built as one list (not per tab) so the
|
||||
/// platform-conditional insertions below can still place a row RELATIVE to another by id.
|
||||
private var allRows: [Row] {
|
||||
let resolution = resolutionOptions
|
||||
let refresh = SettingsOptions.refreshRates(including: hz)
|
||||
.map { (label: "\($0) Hz", tag: $0) }
|
||||
@@ -324,7 +470,7 @@ struct GamepadSettingsView: View {
|
||||
let controllers = SettingsOptions.controllerOptions(gamepads)
|
||||
var list: [Row] = [
|
||||
choiceRow(
|
||||
id: "resolution", header: "Stream", icon: "aspectratio",
|
||||
id: "resolution", tab: .stream, icon: "aspectratio",
|
||||
label: "Resolution",
|
||||
detail: "The host creates a virtual display at exactly this size — no scaling.",
|
||||
options: resolution, current: "\(width)x\(height)"
|
||||
@@ -335,53 +481,48 @@ struct GamepadSettingsView: View {
|
||||
height = parts[1]
|
||||
},
|
||||
choiceRow(
|
||||
id: "refresh", icon: "gauge.with.needle", label: "Refresh rate",
|
||||
id: "refresh", tab: .stream, icon: "gauge.with.needle", label: "Refresh rate",
|
||||
detail: "Rates this display can actually show.",
|
||||
options: refresh, current: hz
|
||||
) { hz = $0 },
|
||||
choiceRow(
|
||||
id: "bitrate", icon: "speedometer", label: "Bitrate",
|
||||
id: "bitrate", tab: .stream, icon: "speedometer", label: "Bitrate",
|
||||
detail: "Automatic uses the host's default (20 Mbps). "
|
||||
+ "Run a speed test from the touch UI for an informed value.",
|
||||
options: bitrate, current: bitrateKbps
|
||||
) { bitrateKbps = $0 },
|
||||
choiceRow(
|
||||
id: "compositor", icon: "macwindow", label: "Compositor",
|
||||
id: "compositor", tab: .stream, icon: "macwindow", label: "Compositor",
|
||||
detail: "Which compositor drives the virtual output — honored only if "
|
||||
+ "available on the host.",
|
||||
options: SettingsOptions.compositors, current: compositor
|
||||
) { compositor = $0 },
|
||||
toggleRow(
|
||||
id: "autoWake", icon: "power", label: "Auto-wake on connect",
|
||||
detail: "Send Wake-on-LAN to a sleeping saved host and wait for it before "
|
||||
+ "streaming. Off connects straight through.",
|
||||
value: $autoWakeEnabled),
|
||||
|
||||
choiceRow(
|
||||
id: "codec", header: "Video", icon: "film", label: "Video codec",
|
||||
id: "codec", tab: .video, icon: "film", label: "Video codec",
|
||||
detail: "A preference — the host falls back if it can't encode this one "
|
||||
+ "(10-bit and 4:4:4 are HEVC-only).",
|
||||
options: SettingsOptions.codecs, current: codec
|
||||
) { codec = $0 },
|
||||
toggleRow(
|
||||
id: "hdr", icon: "sun.max", label: "10-bit HDR",
|
||||
id: "hdr", tab: .video, icon: "sun.max", label: "10-bit HDR",
|
||||
detail: "HDR10 — engages when the host sends HDR content and this display "
|
||||
+ "supports it.",
|
||||
value: $hdrEnabled),
|
||||
toggleRow(
|
||||
id: "chroma", icon: "textformat", label: "Full chroma (4:4:4)",
|
||||
id: "chroma", tab: .video, icon: "textformat", label: "Full chroma (4:4:4)",
|
||||
detail: "Sharper text and UI at more bandwidth — needs host opt-in and "
|
||||
+ "hardware decode.",
|
||||
value: $enable444),
|
||||
choiceRow(
|
||||
id: "presentPriority", icon: "rectangle.stack", label: "Prioritize",
|
||||
id: "presentPriority", tab: .video, icon: "rectangle.stack", label: "Prioritize",
|
||||
detail: "Lowest latency shows each frame the moment the display can take it; "
|
||||
+ "Smoothness buffers a few frames to even out network hiccups. Applies "
|
||||
+ "from the next session.",
|
||||
options: SettingsOptions.presentPriorities, current: presentPriority
|
||||
) { presentPriority = $0 },
|
||||
choiceRow(
|
||||
id: "smoothBuffer", icon: "square.stack.3d.up", label: "Smoothness buffer",
|
||||
id: "smoothBuffer", tab: .video, icon: "square.stack.3d.up",
|
||||
label: "Smoothness buffer",
|
||||
detail: "How many frames Smoothness holds — each adds about a refresh of "
|
||||
+ "display latency and absorbs about a refresh of jitter. Only applies "
|
||||
+ "when prioritizing smoothness.",
|
||||
@@ -389,22 +530,22 @@ struct GamepadSettingsView: View {
|
||||
) { smoothBuffer = $0 },
|
||||
|
||||
choiceRow(
|
||||
id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels",
|
||||
id: "audio", tab: .audio, icon: "speaker.wave.2", label: "Audio channels",
|
||||
detail: "The speaker layout requested from the host.",
|
||||
options: SettingsOptions.audioChannels, current: audioChannels
|
||||
) { audioChannels = $0 },
|
||||
toggleRow(
|
||||
id: "mic", icon: "mic", label: "Microphone",
|
||||
id: "mic", tab: .audio, icon: "mic", label: "Microphone",
|
||||
detail: "Send this device's microphone to the host's virtual mic.",
|
||||
value: $micEnabled),
|
||||
toggleRow(
|
||||
id: "echoCancel", icon: "waveform", label: "Echo cancellation",
|
||||
id: "echoCancel", tab: .audio, icon: "waveform", label: "Echo cancellation",
|
||||
detail: "Cancel the audio this device plays out of the mic signal — stops "
|
||||
+ "speaker setups feeding the game back to the host.",
|
||||
value: $echoCancel),
|
||||
|
||||
toggleRow(
|
||||
id: "padForward", header: "Controller", icon: "gamecontroller",
|
||||
id: "padForward", tab: .controller, icon: "gamecontroller",
|
||||
label: "Forward controllers",
|
||||
detail: "Send this device's controllers to the host. Turn it off when your "
|
||||
+ "controller already reaches the host another way — USB passthrough such "
|
||||
@@ -415,26 +556,28 @@ struct GamepadSettingsView: View {
|
||||
// `.disabled(!effective.gamepadForwarding)`. This screen could not express it until
|
||||
// `Row.enabled` existed, so it alone left them live and steppable.
|
||||
choiceRow(
|
||||
id: "pad", icon: "gamecontroller", label: "Use controller",
|
||||
id: "pad", tab: .controller, icon: "gamecontroller", label: "Use controller",
|
||||
detail: "Which pad is forwarded to the host, as player 1.",
|
||||
options: controllers, current: gamepads.preferredID,
|
||||
enabled: gamepadForwarding
|
||||
) { gamepads.preferredID = $0 },
|
||||
choiceRow(
|
||||
id: "padType", icon: "dpad", label: "Controller type",
|
||||
id: "padType", tab: .controller, icon: "dpad", label: "Controller type",
|
||||
detail: "The virtual pad the host creates — Automatic matches this controller.",
|
||||
options: SettingsOptions.padTypes, current: gamepadType,
|
||||
enabled: gamepadForwarding
|
||||
) { gamepadType = $0 },
|
||||
choiceRow(
|
||||
id: "systemButtons", icon: "house.circle", label: "Guide button",
|
||||
id: "systemButtons", tab: .controller, icon: "house.circle",
|
||||
label: "Guide button",
|
||||
detail: "Where the guide (Xbox/PS) and share presses go while streaming — "
|
||||
+ "Automatic sends them to the host whenever this device delivers them.",
|
||||
options: SettingsOptions.systemButtons, current: systemButtons,
|
||||
enabled: gamepadForwarding
|
||||
) { systemButtons = $0 },
|
||||
choiceRow(
|
||||
id: "guideGesture", icon: "hand.point.up.left", label: "Hold Select for guide",
|
||||
id: "guideGesture", tab: .controller, icon: "hand.point.up.left",
|
||||
label: "Hold Select for guide",
|
||||
detail: "Hold Select alone to press the host's guide button — keep holding "
|
||||
+ "for a Gaming-Mode host's quick-access menu. A tap still goes through.",
|
||||
options: SettingsOptions.guideGestures, current: guideGesture,
|
||||
@@ -442,33 +585,47 @@ struct GamepadSettingsView: View {
|
||||
) { guideGesture = $0 },
|
||||
|
||||
choiceRow(
|
||||
id: "hud", header: "Interface", icon: "chart.bar", label: "Statistics overlay",
|
||||
id: "palette", tab: .interface, icon: "paintpalette", label: "Background",
|
||||
detail: "The colour family this backdrop drifts through — it changes as you "
|
||||
+ "step, so pick by looking. Appearance only.",
|
||||
options: GamepadPalette.all.map { (label: $0.name, tag: $0.id) },
|
||||
current: GamepadPalette.named(paletteID).id
|
||||
) { paletteID = $0 },
|
||||
toggleRow(
|
||||
id: "autoWake", tab: .interface, icon: "power", label: "Auto-wake on connect",
|
||||
detail: "Send Wake-on-LAN to a sleeping saved host and wait for it before "
|
||||
+ "streaming. Off connects straight through.",
|
||||
value: $autoWakeEnabled),
|
||||
choiceRow(
|
||||
id: "hud", tab: .interface, icon: "chart.bar", label: "Statistics overlay",
|
||||
detail: "How much to show while streaming — Compact is a one-line pill, "
|
||||
+ "Detailed adds the latency stage breakdown.",
|
||||
options: SettingsOptions.statsVerbosities, current: statsVerbosityRaw
|
||||
) { statsVerbosityRaw = $0 },
|
||||
choiceRow(
|
||||
id: "hudPlacement", icon: "rectangle.inset.topright.filled", label: "Overlay position",
|
||||
id: "hudPlacement", tab: .interface, icon: "rectangle.inset.topright.filled",
|
||||
label: "Overlay position",
|
||||
detail: "Which corner the statistics overlay sits in.",
|
||||
options: SettingsOptions.hudPlacements, current: hudPlacement
|
||||
) { hudPlacement = $0 },
|
||||
toggleRow(
|
||||
id: "library", icon: "square.grid.2x2", label: "Game library",
|
||||
id: "library", tab: .interface, icon: "square.grid.2x2", label: "Game library",
|
||||
detail: "Browse and launch the host's games with \(buttonName(\.buttonY, "Y")).",
|
||||
value: $libraryEnabled),
|
||||
toggleRow(
|
||||
id: "gamepadUI", icon: "hand.tap", label: "Controller-optimized UI",
|
||||
id: "gamepadUI", tab: .interface, icon: "hand.tap",
|
||||
label: "Controller-optimized UI",
|
||||
detail: "Turn off to use the touch interface even with a controller connected.",
|
||||
value: $gamepadUIEnabled),
|
||||
]
|
||||
#if os(macOS)
|
||||
// The windowed safe-present toggle slots in after "Smoothness buffer" (staying inside
|
||||
// the Video group) — macOS only, mirroring the touch SettingsView's Presentation row
|
||||
// the Video tab) — macOS only, mirroring the touch SettingsView's Presentation row
|
||||
// (the DCP swapID-panic mitigation; see DefaultsKey.windowedSafePresent).
|
||||
if let at = list.firstIndex(where: { $0.id == "smoothBuffer" }) {
|
||||
list.insert(
|
||||
toggleRow(
|
||||
id: "windowedSafePresent", icon: "macwindow.badge.plus",
|
||||
id: "windowedSafePresent", tab: .video, icon: "macwindow.badge.plus",
|
||||
label: "Safe windowed presentation",
|
||||
detail: "Windowed streams present in step with the compositor — avoids a "
|
||||
+ "macOS display-driver crash on high-refresh displays, at a small "
|
||||
@@ -478,14 +635,14 @@ struct GamepadSettingsView: View {
|
||||
}
|
||||
#endif
|
||||
#if os(iOS)
|
||||
// The device-rumble mirror slots in after "Controller type" (staying inside the
|
||||
// Controller group — the next row carries the "Interface" header). iPhone only in
|
||||
// practice: hidden where the device itself can't play haptics (iPad).
|
||||
// The device-rumble mirror slots in after "Controller type", inside the Controller tab.
|
||||
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
|
||||
if CHHapticEngine.capabilitiesForHardware().supportsHaptics,
|
||||
let at = list.firstIndex(where: { $0.id == "padType" }) {
|
||||
list.insert(
|
||||
toggleRow(
|
||||
id: "deviceRumble", icon: "iphone.radiowaves.left.and.right",
|
||||
id: "deviceRumble", tab: .controller,
|
||||
icon: "iphone.radiowaves.left.and.right",
|
||||
label: "Rumble on this iPhone",
|
||||
detail: "Also play player 1's rumble on the phone's own Taptic Engine — "
|
||||
+ "for clip-on pads without rumble motors.",
|
||||
@@ -505,17 +662,17 @@ struct GamepadSettingsView: View {
|
||||
private var profileRows: [Row] {
|
||||
guard !profiles.profiles.isEmpty else {
|
||||
return [Row(
|
||||
id: "noProfiles", header: "Profiles", icon: "slider.horizontal.3",
|
||||
id: "noProfiles", tab: .profiles, icon: "slider.horizontal.3",
|
||||
label: "No profiles yet", value: "",
|
||||
detail: emptyCatalogDetail,
|
||||
adjustable: false,
|
||||
adjust: { _ in false }, activate: {})]
|
||||
}
|
||||
return profiles.profiles.enumerated().map { i, profile in
|
||||
return profiles.profiles.map { profile in
|
||||
let pins = store.hosts
|
||||
.filter { ($0.pinnedProfileIDs ?? []).contains(profile.id) }.count
|
||||
return Row(
|
||||
id: "profile-\(profile.id)", header: i == 0 ? "Profiles" : nil,
|
||||
id: "profile-\(profile.id)", tab: .profiles,
|
||||
icon: "slider.horizontal.3", label: profile.name,
|
||||
value: pins == 0 ? "Not pinned" : "Pinned to \(pins) host\(pins == 1 ? "" : "s")",
|
||||
detail: profileDetail,
|
||||
@@ -537,7 +694,8 @@ struct GamepadSettingsView: View {
|
||||
private func pinRows(for profile: StreamProfile) -> [Row] {
|
||||
guard !store.hosts.isEmpty else {
|
||||
return [Row(
|
||||
id: "noHosts", icon: "desktopcomputer", label: "No saved hosts yet",
|
||||
id: "noHosts", tab: .profiles, icon: "desktopcomputer",
|
||||
label: "No saved hosts yet",
|
||||
value: "",
|
||||
detail: "Pair with a host first, then pin this profile to it.",
|
||||
adjustable: false,
|
||||
@@ -547,7 +705,7 @@ struct GamepadSettingsView: View {
|
||||
let hostID = host.id
|
||||
let pinned = (host.pinnedProfileIDs ?? []).contains(profile.id)
|
||||
return Row(
|
||||
id: "pinHost-\(hostID.uuidString)", icon: "desktopcomputer",
|
||||
id: "pinHost-\(hostID.uuidString)", tab: .profiles, icon: "desktopcomputer",
|
||||
label: host.displayName,
|
||||
value: pinned ? "Pinned" : "Off",
|
||||
detail: "A pinned profile appears as its own card on the host — one press "
|
||||
@@ -609,13 +767,13 @@ struct GamepadSettingsView: View {
|
||||
// MARK: - Row builders
|
||||
|
||||
private func choiceRow<T: Equatable>(
|
||||
id: String, header: String? = nil, icon: String, label: String, detail: String,
|
||||
id: String, tab: GpSettingsTab, icon: String, label: String, detail: String,
|
||||
options: [(label: String, tag: T)], current: T, enabled: Bool = true,
|
||||
write: @escaping (T) -> Void
|
||||
) -> Row {
|
||||
let index = options.firstIndex { $0.tag == current }
|
||||
return Row(
|
||||
id: id, header: header, icon: icon, label: label,
|
||||
id: id, tab: tab, icon: icon, label: label,
|
||||
value: index.map { options[$0].label } ?? "—",
|
||||
detail: detail,
|
||||
enabled: enabled,
|
||||
@@ -638,11 +796,11 @@ struct GamepadSettingsView: View {
|
||||
}
|
||||
|
||||
private func toggleRow(
|
||||
id: String, header: String? = nil, icon: String, label: String, detail: String,
|
||||
id: String, tab: GpSettingsTab, icon: String, label: String, detail: String,
|
||||
value: Binding<Bool>, enabled: Bool = true
|
||||
) -> Row {
|
||||
Row(
|
||||
id: id, header: header, icon: icon, label: label,
|
||||
id: id, tab: tab, icon: icon, label: label,
|
||||
value: value.wrappedValue ? "On" : "Off",
|
||||
detail: detail,
|
||||
enabled: enabled,
|
||||
|
||||
@@ -171,14 +171,26 @@ enum SettingsOptions {
|
||||
|
||||
/// This device's native mode first, then the presets, deduped by dimensions (native wins a
|
||||
/// tie).
|
||||
///
|
||||
/// On iOS the native row is followed by its **safe-area** variant, which is the same mode
|
||||
/// narrowed so the picture clears the sensor housing and the rounded corners — see
|
||||
/// [`SafeDisplay`] for why a narrower mode is the whole fix. It is emitted unconditionally and
|
||||
/// left to the dedup below: on a device with no housing the two modes are identical, the
|
||||
/// duplicate is dropped, and no pointless row appears.
|
||||
@MainActor
|
||||
static func resolutionModes() -> [(name: String, w: Int, h: Int)] {
|
||||
var native: [(name: String, w: Int, h: Int)] = []
|
||||
#if os(iOS) || os(tvOS)
|
||||
let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels (tvOS: the TV mode)
|
||||
native = [("This device",
|
||||
Int(max(bounds.width, bounds.height)),
|
||||
Int(min(bounds.width, bounds.height)))]
|
||||
let nativeW = Int(max(bounds.width, bounds.height))
|
||||
let nativeH = Int(min(bounds.width, bounds.height))
|
||||
native = [("This device", nativeW, nativeH)]
|
||||
#if os(iOS)
|
||||
let safe = SafeDisplay.mode(
|
||||
nativeWidth: nativeW, nativeHeight: nativeH,
|
||||
sideInsetPoints: mainWindowSideInset(), scale: UIScreen.main.nativeScale)
|
||||
native.append(("This device (safe area)", safe.width, safe.height))
|
||||
#endif
|
||||
#else
|
||||
if let screen = NSScreen.main {
|
||||
let scale = screen.backingScaleFactor
|
||||
@@ -191,6 +203,26 @@ enum SettingsOptions {
|
||||
return (native + resolutionPresets).filter { seen.insert("\($0.w)x\($0.h)").inserted }
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// The key window's per-side safe-area inset in points, resolved for the LANDSCAPE stream even
|
||||
/// when this settings screen is currently portrait (see `SafeDisplay.sideInsetPoints`).
|
||||
///
|
||||
/// Zero when no window is up yet — the safe mode then equals the native one and `resolutionModes`
|
||||
/// dedups the row away, which is the right answer for a device we can't measure.
|
||||
@MainActor
|
||||
private static func mainWindowSideInset() -> Double {
|
||||
let insets = UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.flatMap(\.windows)
|
||||
.first { $0.isKeyWindow }?
|
||||
.safeAreaInsets
|
||||
guard let insets else { return 0 }
|
||||
return SafeDisplay.sideInsetPoints(
|
||||
left: Double(insets.left), right: Double(insets.right), top: Double(insets.top),
|
||||
isPhone: UIDevice.current.userInterfaceIdiom == .phone)
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Refresh rates the device can actually display (no point asking the host to render frames
|
||||
/// the screen can't show), plus any stored custom value so it stays selectable.
|
||||
@MainActor
|
||||
|
||||
@@ -80,6 +80,12 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
|
||||
let shape: S
|
||||
var tint: Color?
|
||||
var interactive = false
|
||||
/// The console surface follows the background palette: a PALE field needs the material to
|
||||
/// frost light and the glass to read as white, or the dark ink on top of it disappears.
|
||||
/// Defaults to the dark ink, so every non-gamepad caller is unchanged.
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
|
||||
private var scheme: ColorScheme { ink.isLight ? .light : .dark }
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
#if os(tvOS)
|
||||
@@ -89,16 +95,16 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
|
||||
// the 10-foot platform). The tint rides an overlay so the focused row keeps its wash.
|
||||
content.background {
|
||||
shape.fill(.ultraThinMaterial)
|
||||
.environment(\.colorScheme, .dark)
|
||||
.environment(\.colorScheme, scheme)
|
||||
.overlay {
|
||||
if let tint { shape.fill(tint) }
|
||||
}
|
||||
}
|
||||
#else
|
||||
if #available(iOS 26, macOS 26, *) {
|
||||
content.glassEffect(glass, in: shape)
|
||||
content.glassEffect(glass, in: shape).environment(\.colorScheme, scheme)
|
||||
} else {
|
||||
content.background { shape.fill(.ultraThinMaterial).environment(\.colorScheme, .dark) }
|
||||
content.background { shape.fill(.ultraThinMaterial).environment(\.colorScheme, scheme) }
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -9,6 +9,25 @@
|
||||
//
|
||||
// iOS/tvOS gate Bonjour browsing on Info.plist `NSBonjourServices` listing `_punktfunk._udp`
|
||||
// (Config/Info.plist) — without it the system blocks the browse and nothing is returned.
|
||||
//
|
||||
// SELF-HEALING is what the bookkeeping below is for. Neither Network.framework primitive
|
||||
// recovers on its own, and all three failure modes read as "the host isn't there":
|
||||
//
|
||||
// - `browseResultsChangedHandler` fires only when the result SET changes. A service that is
|
||||
// found but whose resolve fails is never re-offered — from the browser's point of view
|
||||
// nothing changed — so one unlucky resolve hid that host for the life of the process.
|
||||
// - `NWConnection` has no timeout. A resolve that cannot complete (v6-only advert against our
|
||||
// IPv4 pin, Wi-Fi still associating, host mid-reboot) parks in `.preparing`/`.waiting`
|
||||
// forever instead of failing, so the retry path above was never even reached.
|
||||
// - `NWBrowser` parks in `.waiting` when the browse is blocked. On iOS that is where the LOCAL
|
||||
// NETWORK PRIVACY gate lands the first launch after install: the browse starts, the system
|
||||
// puts up its "find and connect to devices on your local network" prompt, and the browser
|
||||
// waits. Granting permission does NOT revive that browser — only a new one sees the grant.
|
||||
//
|
||||
// Every one of those presented as "restarting the app fixes it", which is what field reports
|
||||
// described. A 1 Hz `sweep` therefore times out stuck resolves, retries failed ones on a backoff
|
||||
// and re-arms a browser that stopped working; `refresh()` forces the same recovery immediately,
|
||||
// behind the UI's pull-to-refresh and Refresh button.
|
||||
|
||||
#if canImport(Network)
|
||||
import Foundation
|
||||
@@ -48,12 +67,50 @@ public struct DiscoveredHost: Identifiable, Sendable, Equatable {
|
||||
public final class HostDiscovery: ObservableObject {
|
||||
/// Currently-visible hosts, deduped by `id`, sorted by name. Main-actor.
|
||||
@Published public private(set) var hosts: [DiscoveredHost] = []
|
||||
/// True for a moment after a rescan is kicked off, so a Refresh control can show that it did
|
||||
/// something on the surfaces with no pull-to-refresh spinner of their own (macOS, tvOS).
|
||||
@Published public private(set) var isScanning = false
|
||||
|
||||
private var browser: NWBrowser?
|
||||
/// Keyed by the service endpoint's description (a stable, Sendable handle we can capture
|
||||
/// into the resolve callbacks without smuggling non-Sendable Network types across hops).
|
||||
private var resolved: [String: DiscoveredHost] = [:]
|
||||
/// Every service the browser currently reports, keyed by the endpoint's description (a stable,
|
||||
/// Sendable handle we can capture into the resolve callbacks without smuggling non-Sendable
|
||||
/// Network types across hops). Held — not just diffed — so a retry can re-resolve a service
|
||||
/// the browser will never report again (see the file header).
|
||||
private var services: [String: NWBrowser.Result] = [:]
|
||||
/// The transport address a completed resolve produced, per service key. The rest of a
|
||||
/// `DiscoveredHost` comes from the advert's TXT, which is re-read on every browse report.
|
||||
private var addresses: [String: (host: String, port: UInt16)] = [:]
|
||||
private var connections: [String: NWConnection] = [:]
|
||||
/// Deadline for each in-flight resolve — `NWConnection` has none of its own.
|
||||
private var deadlines: [String: Date] = [:]
|
||||
/// Consecutive failed resolves per service, and when the next attempt is allowed.
|
||||
private var failures: [String: Int] = [:]
|
||||
private var retryAt: [String: Date] = [:]
|
||||
/// Services whose address should be re-resolved even though we already have one — set by
|
||||
/// `refresh()`. The old address keeps showing until the new one lands, so a rescan never
|
||||
/// blinks the list empty; without this a manual Refresh silently skipped every host it had
|
||||
/// already resolved, which is exactly the host whose address may have moved.
|
||||
private var staleAddresses: Set<String> = []
|
||||
/// Consecutive non-ready browser states, and when to tear it down and re-arm. nil = healthy.
|
||||
private var browserFailures = 0
|
||||
private var browserRearmAt: Date?
|
||||
/// Bumped on every re-arm so callbacks from a superseded browser — and from the resolves it
|
||||
/// started — are ignored instead of clobbering the current generation's bookkeeping.
|
||||
private var generation = 0
|
||||
/// The 1 Hz maintenance tick. Nothing else re-drives a stuck resolve or a sick browser.
|
||||
private var sweep: Task<Void, Never>?
|
||||
private var scanningUntil: Date?
|
||||
|
||||
/// A LAN resolve answers in milliseconds; this only has to outlast a slow Wi-Fi wake.
|
||||
private static let resolveTimeout: TimeInterval = 6
|
||||
/// How long `isScanning` holds — and `rescan()` waits — after a manual refresh.
|
||||
private static let scanSettle: TimeInterval = 1.5
|
||||
/// 1s, 2s, 4s, 8s … capped at 30s, for the resolve retry and the browser re-arm alike. Long
|
||||
/// enough that a genuinely-down network doesn't spin the main queue, short enough that a host
|
||||
/// coming back is picked up while the user is still looking at the screen.
|
||||
private static func backoff(_ failures: Int) -> TimeInterval {
|
||||
min(pow(2, Double(max(0, failures - 1))), 30)
|
||||
}
|
||||
|
||||
public init() {}
|
||||
|
||||
@@ -63,34 +120,73 @@ public final class HostDiscovery: ObservableObject {
|
||||
guard !debugPinned else { return } // a seeded advert set outranks the live LAN
|
||||
#endif
|
||||
guard browser == nil else { return }
|
||||
let browser = NWBrowser(
|
||||
for: .bonjourWithTXTRecord(type: "_punktfunk._udp", domain: nil),
|
||||
using: NWParameters())
|
||||
browser.browseResultsChangedHandler = { results, _ in
|
||||
MainActor.assumeIsolated { [weak self] in self?.reconcile(results) }
|
||||
}
|
||||
browser.stateUpdateHandler = { state in
|
||||
// A failed browser never recovers on its own; tear down and re-arm so transient
|
||||
// network changes (Wi-Fi flip, VPN) don't leave discovery silently dead.
|
||||
MainActor.assumeIsolated { [weak self] in
|
||||
if case .failed = state { self?.restart() }
|
||||
}
|
||||
}
|
||||
self.browser = browser
|
||||
browser.start(queue: .main)
|
||||
armBrowser()
|
||||
startSweep()
|
||||
}
|
||||
|
||||
/// Stop browsing and drop all discovered state.
|
||||
public func stop() {
|
||||
sweep?.cancel()
|
||||
sweep = nil
|
||||
generation &+= 1
|
||||
browser?.cancel()
|
||||
browser = nil
|
||||
for conn in connections.values { conn.cancel() }
|
||||
connections.removeAll()
|
||||
resolved.removeAll()
|
||||
deadlines.removeAll()
|
||||
services.removeAll()
|
||||
addresses.removeAll()
|
||||
failures.removeAll()
|
||||
retryAt.removeAll()
|
||||
staleAddresses.removeAll()
|
||||
browserFailures = 0
|
||||
browserRearmAt = nil
|
||||
scanningUntil = nil
|
||||
if isScanning { isScanning = false }
|
||||
if !hosts.isEmpty { hosts = [] }
|
||||
}
|
||||
|
||||
/// Force a rescan now: re-arm the browser and retry every service whose resolve had failed,
|
||||
/// clearing the backoffs so nothing is left waiting. This is the manual escape hatch for the
|
||||
/// failure modes in the file header — and the only thing that clears the iOS local-network
|
||||
/// permission gate without an app restart, since only a NEW browser sees a permission the
|
||||
/// user granted after the old one started.
|
||||
///
|
||||
/// Also starts discovery if it wasn't running, so a Refresh button does the obvious thing.
|
||||
public func refresh() {
|
||||
#if DEBUG
|
||||
guard !debugPinned else { return } // as in `start()` — the harness's set is the truth
|
||||
#endif
|
||||
isScanning = true
|
||||
scanningUntil = Date().addingTimeInterval(Self.scanSettle)
|
||||
failures.removeAll()
|
||||
retryAt.removeAll()
|
||||
staleAddresses = Set(services.keys)
|
||||
browserFailures = 0
|
||||
armBrowser()
|
||||
startSweep()
|
||||
pump()
|
||||
}
|
||||
|
||||
/// `refresh()` for a `.refreshable` gesture: holds briefly so the control's spinner reflects a
|
||||
/// browse that had time to answer instead of blinking out instantly.
|
||||
public func rescan() async {
|
||||
refresh()
|
||||
try? await Task.sleep(nanoseconds: UInt64(Self.scanSettle * 1_000_000_000))
|
||||
}
|
||||
|
||||
/// `refresh()`, but only when discovery is already running — the app-foreground hook. iOS
|
||||
/// suspends a backgrounded process's browse and `onAppear`/`onDisappear` don't fire across
|
||||
/// background/foreground, so a browse that died while suspended stayed dead on return; this
|
||||
/// re-arms it without starting a browse on a screen that deliberately isn't browsing
|
||||
/// (mid-session, where the home tore discovery down).
|
||||
public func refreshIfRunning() {
|
||||
guard browser != nil else { return }
|
||||
refresh()
|
||||
}
|
||||
|
||||
deinit {
|
||||
sweep?.cancel()
|
||||
browser?.cancel()
|
||||
for conn in connections.values { conn.cancel() }
|
||||
}
|
||||
@@ -124,48 +220,103 @@ public final class HostDiscovery: ObservableObject {
|
||||
}
|
||||
#endif
|
||||
|
||||
private func restart() {
|
||||
stop()
|
||||
start()
|
||||
// MARK: - Browser
|
||||
|
||||
/// Build and start a fresh browser, retiring the previous one and every resolve it started.
|
||||
/// Those resolves' callbacks are gated on `generation`, so they must not be left holding map
|
||||
/// entries — `pump()` restarts them against the new generation.
|
||||
private func armBrowser() {
|
||||
generation &+= 1
|
||||
browser?.cancel()
|
||||
for conn in connections.values { conn.cancel() }
|
||||
connections.removeAll()
|
||||
deadlines.removeAll()
|
||||
browserRearmAt = nil
|
||||
|
||||
let generation = self.generation
|
||||
let browser = NWBrowser(
|
||||
for: .bonjourWithTXTRecord(type: "_punktfunk._udp", domain: nil),
|
||||
using: NWParameters())
|
||||
browser.browseResultsChangedHandler = { results, _ in
|
||||
MainActor.assumeIsolated { [weak self] in
|
||||
guard let self, generation == self.generation else { return }
|
||||
self.reconcile(results)
|
||||
}
|
||||
}
|
||||
browser.stateUpdateHandler = { state in
|
||||
MainActor.assumeIsolated { [weak self] in
|
||||
guard let self, generation == self.generation else { return }
|
||||
self.browserStateChanged(state)
|
||||
}
|
||||
}
|
||||
self.browser = browser
|
||||
browser.start(queue: .main)
|
||||
}
|
||||
|
||||
/// Diff the browser's current result set against what we're tracking: drop departed
|
||||
/// services, resolve newly-seen ones.
|
||||
private func reconcile(_ results: Set<NWBrowser.Result>) {
|
||||
let live = Set(results.map { Self.key($0) })
|
||||
for key in resolved.keys where !live.contains(key) { resolved[key] = nil }
|
||||
for key in connections.keys where !live.contains(key) {
|
||||
connections[key]?.cancel()
|
||||
connections[key] = nil
|
||||
/// A browser that stops working never recovers on its own, and it has two ways to stop:
|
||||
/// `.failed` (dead) and `.waiting` (blocked — a network change, or the iOS local-network
|
||||
/// permission gate described in the file header). Schedule a re-arm for both, on a backoff:
|
||||
/// re-arming synchronously on `.failed` alone both missed the permission case entirely and
|
||||
/// could spin the main queue on a browser that fails instantly every time.
|
||||
private func browserStateChanged(_ state: NWBrowser.State) {
|
||||
switch state {
|
||||
case .ready:
|
||||
browserFailures = 0
|
||||
browserRearmAt = nil
|
||||
case .failed, .waiting:
|
||||
guard browserRearmAt == nil else { return } // one re-arm already scheduled
|
||||
browserFailures += 1
|
||||
browserRearmAt = Date().addingTimeInterval(Self.backoff(browserFailures))
|
||||
default:
|
||||
break // .setup / .cancelled — nothing to heal
|
||||
}
|
||||
}
|
||||
|
||||
/// Diff the browser's current result set against what we're tracking: drop departed services,
|
||||
/// record the rest — re-reading the advert every time, so a host that re-keys, moves or flips
|
||||
/// its pairing policy republishes under the same name and the card follows it — then resolve
|
||||
/// whatever still needs an address.
|
||||
private func reconcile(_ results: Set<NWBrowser.Result>) {
|
||||
var live: Set<String> = []
|
||||
for result in results {
|
||||
let key = Self.key(result)
|
||||
if resolved[key] == nil, connections[key] == nil { resolve(result) }
|
||||
live.insert(key)
|
||||
services[key] = result
|
||||
}
|
||||
for key in Array(services.keys) where !live.contains(key) { forget(key) }
|
||||
publish()
|
||||
pump()
|
||||
}
|
||||
|
||||
private func forget(_ key: String) {
|
||||
connections[key]?.cancel()
|
||||
connections[key] = nil
|
||||
deadlines[key] = nil
|
||||
services[key] = nil
|
||||
addresses[key] = nil
|
||||
failures[key] = nil
|
||||
retryAt[key] = nil
|
||||
staleAddresses.remove(key)
|
||||
}
|
||||
|
||||
// MARK: - Resolve
|
||||
|
||||
/// Start the resolves that are due: every live service with no address yet, nothing in flight,
|
||||
/// and past its retry time.
|
||||
private func pump() {
|
||||
let now = Date()
|
||||
for (key, result) in services {
|
||||
guard addresses[key] == nil || staleAddresses.contains(key) else { continue }
|
||||
guard connections[key] == nil else { continue }
|
||||
if let at = retryAt[key], at > now { continue }
|
||||
resolve(key, result)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve one service to IP:port via a short UDP connection (it reaches `.ready` once the
|
||||
/// path is established — no data is sent), reading the TXT up front so the callback only
|
||||
/// captures Sendable values + the endpoint key.
|
||||
private func resolve(_ result: NWBrowser.Result) {
|
||||
let key = Self.key(result)
|
||||
let name = Self.instanceName(result.endpoint)
|
||||
var fp: String?
|
||||
var pair: String?
|
||||
var id: String?
|
||||
var macs: [String] = []
|
||||
var osChain = ""
|
||||
if case let .bonjour(txt) = result.metadata {
|
||||
fp = Self.entry(txt, "fp")
|
||||
pair = Self.entry(txt, "pair")
|
||||
id = Self.entry(txt, "id")
|
||||
macs = (Self.entry(txt, "mac") ?? "")
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.filter { !$0.isEmpty }
|
||||
osChain = sanitizeOsChain(Self.entry(txt, "os") ?? "")
|
||||
}
|
||||
/// path is established — no data is sent). The TXT is NOT read here: it comes from the browse
|
||||
/// result at publish time, so a re-advertised host doesn't need a fresh resolve to be re-read.
|
||||
private func resolve(_ key: String, _ result: NWBrowser.Result) {
|
||||
// Resolve over IPv4 only: Network.framework prefers IPv6 (RFC 6724), and the host's OS
|
||||
// mDNS responder often answers AAAA for its hostname even though the punktfunk host stack
|
||||
// (control QUIC + data UDP) binds IPv4 sockets exclusively — a v6-resolved address would
|
||||
@@ -177,51 +328,132 @@ public final class HostDiscovery: ObservableObject {
|
||||
}
|
||||
let conn = NWConnection(to: result.endpoint, using: params)
|
||||
connections[key] = conn
|
||||
deadlines[key] = Date().addingTimeInterval(Self.resolveTimeout)
|
||||
let generation = self.generation
|
||||
conn.stateUpdateHandler = { state in
|
||||
MainActor.assumeIsolated { [weak self] in
|
||||
guard let self, let conn = self.connections[key] else { return }
|
||||
// Look the connection back up rather than capturing it — capturing it here would
|
||||
// retain the connection through its own handler.
|
||||
guard let self, generation == self.generation,
|
||||
let conn = self.connections[key] else { return }
|
||||
switch state {
|
||||
case .ready:
|
||||
if case let .hostPort(host, port)? = conn.currentPath?.remoteEndpoint,
|
||||
let address = Self.hostString(host) {
|
||||
self.resolved[key] = DiscoveredHost(
|
||||
id: (id?.isEmpty == false) ? id! : name,
|
||||
name: name, host: address, port: port.rawValue,
|
||||
fingerprintHex: fp, requiresPairing: pair == "required",
|
||||
allowsTofu: pair == "optional", macAddresses: macs,
|
||||
osChain: osChain)
|
||||
self.publish()
|
||||
}
|
||||
conn.cancel()
|
||||
let endpoint = conn.currentPath?.remoteEndpoint
|
||||
self.connections[key] = nil
|
||||
self.deadlines[key] = nil
|
||||
conn.cancel()
|
||||
if case let .hostPort(host, port)? = endpoint,
|
||||
let address = Self.hostString(host) {
|
||||
self.addresses[key] = (address, port.rawValue)
|
||||
self.failures[key] = nil
|
||||
self.retryAt[key] = nil
|
||||
self.staleAddresses.remove(key)
|
||||
self.publish()
|
||||
} else {
|
||||
// Ready but no usable remote — a failed attempt, not a finished one.
|
||||
self.resolveFailed(key)
|
||||
}
|
||||
case .failed, .cancelled:
|
||||
self.connections[key] = nil
|
||||
self.deadlines[key] = nil
|
||||
self.resolveFailed(key)
|
||||
default:
|
||||
break
|
||||
break // .preparing / .waiting — the sweep's deadline is what ends these
|
||||
}
|
||||
}
|
||||
}
|
||||
conn.start(queue: .main)
|
||||
}
|
||||
|
||||
/// Publish the resolved set, deduped by `id` (a host on several interfaces / re-advertising
|
||||
/// collapses to one row), sorted by name.
|
||||
private func resolveFailed(_ key: String) {
|
||||
let count = (failures[key] ?? 0) + 1
|
||||
failures[key] = count
|
||||
retryAt[key] = Date().addingTimeInterval(Self.backoff(count))
|
||||
}
|
||||
|
||||
// MARK: - Sweep
|
||||
|
||||
private func startSweep() {
|
||||
sweep?.cancel()
|
||||
sweep = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
self.tick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func tick() {
|
||||
let now = Date()
|
||||
// Time out the resolves that parked. Without this they never end, and `pump()` skips a
|
||||
// service that has a connection in flight — so that host stayed invisible indefinitely.
|
||||
for key in deadlines.filter({ $0.value <= now }).keys {
|
||||
connections[key]?.cancel()
|
||||
connections[key] = nil
|
||||
deadlines[key] = nil
|
||||
resolveFailed(key)
|
||||
}
|
||||
if let at = browserRearmAt, at <= now { armBrowser() }
|
||||
pump()
|
||||
if let until = scanningUntil, until <= now {
|
||||
scanningUntil = nil
|
||||
isScanning = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Publish
|
||||
|
||||
/// Publish the live adverts that have an address, deduped by `id` (a host on several
|
||||
/// interfaces / re-advertising collapses to one row), sorted by name.
|
||||
private func publish() {
|
||||
var byID: [String: DiscoveredHost] = [:]
|
||||
for host in resolved.values { byID[host.id] = host }
|
||||
for key in services.keys.sorted() {
|
||||
guard let result = services[key], let address = addresses[key] else { continue }
|
||||
let host = Self.host(from: result, address: address.host, port: address.port)
|
||||
byID[host.id] = host
|
||||
}
|
||||
let next = byID.values.sorted {
|
||||
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
|
||||
}
|
||||
if next != hosts { hosts = next }
|
||||
}
|
||||
|
||||
/// Join a browse result's advert (instance name + TXT) to a resolved address.
|
||||
private static func host(
|
||||
from result: NWBrowser.Result, address: String, port: UInt16
|
||||
) -> DiscoveredHost {
|
||||
let name = instanceName(result.endpoint)
|
||||
var fp: String?
|
||||
var pair: String?
|
||||
var id: String?
|
||||
var macs: [String] = []
|
||||
var osChain = ""
|
||||
if case let .bonjour(txt) = result.metadata {
|
||||
fp = entry(txt, "fp")
|
||||
pair = entry(txt, "pair")
|
||||
id = entry(txt, "id")
|
||||
macs = (entry(txt, "mac") ?? "")
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.filter { !$0.isEmpty }
|
||||
osChain = sanitizeOsChain(entry(txt, "os") ?? "")
|
||||
}
|
||||
return DiscoveredHost(
|
||||
id: (id?.isEmpty == false) ? id! : name,
|
||||
name: name, host: address, port: port,
|
||||
fingerprintHex: fp, requiresPairing: pair == "required",
|
||||
allowsTofu: pair == "optional", macAddresses: macs,
|
||||
osChain: osChain)
|
||||
}
|
||||
|
||||
private static func key(_ result: NWBrowser.Result) -> String {
|
||||
"\(result.endpoint)"
|
||||
}
|
||||
|
||||
private static func instanceName(_ endpoint: NWEndpoint) -> String {
|
||||
if case let .service(name, _, _, _) = endpoint { return name }
|
||||
return "punktfunk host"
|
||||
return "Punktfunk host"
|
||||
}
|
||||
|
||||
private static func entry(_ txt: NWTXTRecord, _ field: String) -> String? {
|
||||
|
||||
@@ -38,12 +38,46 @@ public struct LaunchSpec: Codable, Hashable, Sendable {
|
||||
/// One title in the unified library. `id` is store-qualified: `steam:<appid>` / `custom:<id>`.
|
||||
public struct GameEntry: Codable, Hashable, Identifiable, Sendable {
|
||||
public var id: String
|
||||
public var store: String // "steam" | "custom"
|
||||
public var store: String // "steam" | "custom" | "lutris" | "heroic" | "epic" | "gog" | "xbox"
|
||||
public var title: String
|
||||
public var art: Artwork
|
||||
public var launch: LaunchSpec?
|
||||
/// `"game"` (the default, and what an older host omits) or `"launcher"` — an entry that opens
|
||||
/// the launcher itself (Steam Big Picture, Heroic) rather than a title. Deliberately a plain
|
||||
/// optional String: the host owns the vocabulary, and an unknown future value must never fail
|
||||
/// the whole library decode. Anything that isn't `"launcher"` is a game (design D4).
|
||||
public var role: String?
|
||||
|
||||
public var isCustom: Bool { store == "custom" }
|
||||
|
||||
/// Whether this entry opens a launcher rather than a game.
|
||||
public var isLauncher: Bool { role == "launcher" }
|
||||
|
||||
/// Display name for the store badge — the same table the Rust clients use
|
||||
/// (`pf-console-ui::library::store_label`). Before this existed the badge said "Steam" for
|
||||
/// every non-custom entry, which a Lutris or GOG title made a lie.
|
||||
public var storeLabel: String {
|
||||
switch store {
|
||||
case "steam": return "Steam"
|
||||
case "custom": return "Custom"
|
||||
case "heroic": return "Heroic"
|
||||
case "lutris": return "Lutris"
|
||||
case "epic": return "Epic"
|
||||
case "gog": return "GOG"
|
||||
case "xbox": return "Xbox"
|
||||
default: return "Game"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension Array where Element == GameEntry {
|
||||
/// Design D4: launcher entries lead the shelf, and the host's title order survives within each
|
||||
/// group. Applied once where the library is fetched, so no individual view has to remember
|
||||
/// the rule — and a library without launcher entries comes back untouched.
|
||||
var launchersFirst: [GameEntry] {
|
||||
let launchers = filter(\.isLauncher)
|
||||
return launchers.isEmpty ? self : launchers + filter { !$0.isLauncher }
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet").
|
||||
|
||||
@@ -1430,6 +1430,49 @@ public final class PunktfunkConnection {
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a stream session ended — the Swift mirror of `PunktfunkEndReason` (ABI v17).
|
||||
///
|
||||
/// The distinction that matters to a UI is normal vs alarming, and it is not a spectrum: a
|
||||
/// player quitting their game and a host falling off the network both arrive as "the session
|
||||
/// ended". Without this every client wrote one message for all of them, and every client chose
|
||||
/// an error.
|
||||
public enum SessionEndReason: UInt8, Sendable {
|
||||
/// Not ended, or ended before a reason could be observed. Also the fallback for an
|
||||
/// unrecognized value — the core may be newer than this code.
|
||||
case none = 0
|
||||
/// This client closed the session. Nothing to report: the UI initiated it.
|
||||
case local = 1
|
||||
/// The host's launched game exited. A normal finish, and the one reason worth acting on:
|
||||
/// go back to the library the title was launched from.
|
||||
case gameExited = 2
|
||||
/// The host ended the session deliberately (an operator "End", or it simply finished).
|
||||
case hostEnded = 3
|
||||
/// The host closed reporting a failure of its own.
|
||||
case hostError = 4
|
||||
/// The connection died rather than being closed: idle timeout, reset, network gone. This —
|
||||
/// and only this — is the "the host may be asleep" case.
|
||||
case lost = 5
|
||||
|
||||
/// Is this an ordinary outcome rather than something to alarm the user about? `.none`
|
||||
/// counts as normal: no evidence of trouble is not evidence of it.
|
||||
public var isNormal: Bool { self != .hostError && self != .lost }
|
||||
}
|
||||
|
||||
/// Why this session ended. Only meaningful once it HAS ended (a plane threw `.closed`, or
|
||||
/// `onSessionEnd` fired) — before that it is `.none`.
|
||||
///
|
||||
/// Read it before tearing the connection down: once `close()` has been requested this reports
|
||||
/// `.none`, which is the safe direction (the caller falls back to its normal handling).
|
||||
public var sessionEndReason: SessionEndReason {
|
||||
guard let h = liveHandle() else { return .none }
|
||||
var out: UInt8 = 0
|
||||
guard punktfunk_connection_end_reason(h, &out) == statusOK else { return .none }
|
||||
return SessionEndReason(rawValue: out) ?? .none
|
||||
}
|
||||
|
||||
/// Shorthand for the single most actionable reason: the host's launched game exited.
|
||||
public var endedBecauseGameExited: Bool { sessionEndReason == .gameExited }
|
||||
|
||||
deinit { close() }
|
||||
|
||||
/// Snapshot the handle unless close is pending (callers hold their plane lock).
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 unom
|
||||
Copyright 2026 unom - Enrico Bühler
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 unom
|
||||
Copyright (c) 2026 unom - Enrico Bühler
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
THIRD-PARTY SOFTWARE NOTICES
|
||||
============================================================================
|
||||
|
||||
punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0.
|
||||
Punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0.
|
||||
The binaries it ships statically/dynamically link the third-party Rust crates listed
|
||||
below. Each is distributed under its own permissive license; the full license texts
|
||||
follow the manifest. This file is generated by scripts/gen-third-party-notices.py
|
||||
|
||||
@@ -538,14 +538,25 @@ public final class StreamLayerView: NSView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell the host who renders the pointer (the §8 mid-stream render flip): we draw it only
|
||||
/// while the DESKTOP model is engaged (the local OS cursor wears the host shape); under
|
||||
/// the capture model — and while released — the host composites it into the video (full
|
||||
/// fidelity, the pre-channel look). One edge-detected reconciler, called from every
|
||||
/// Tell the host who renders the pointer (the §8 mid-stream render flip). The host may
|
||||
/// composite one into the video ONLY while we are holding a grabbed, hidden pointer — the
|
||||
/// capture model, engaged. That is the one state with no local cursor on screen.
|
||||
///
|
||||
/// Every other state leaves a normal OS cursor visible over the video: the desktop model
|
||||
/// draws it wearing the host's shape, and a RELEASED view shows the plain arrow. A
|
||||
/// host-composited pointer then appears *underneath* it as a second cursor — and, because a
|
||||
/// released view forwards no motion, one that never moves. On glass that reads as a frozen
|
||||
/// duplicate stuck wherever the host pointer was last left (verified: `client_draws=false
|
||||
/// blended=true live=(-1, 622)` — parked on the streamed output's left edge while the user
|
||||
/// moved their own cursor around freely).
|
||||
///
|
||||
/// So "released" counts as WE draw it: the host stops compositing, the client keeps
|
||||
/// receiving shape/state over the channel (the forwarder only ticks on this side of the
|
||||
/// flip), and re-engaging is seamless. One edge-detected reconciler, called from every
|
||||
/// transition (chord, engage/release, session start).
|
||||
private func reconcileCursorRender() {
|
||||
guard cursorChannelActive, let connection else { return }
|
||||
let clientDraws = captured && desktopMouse
|
||||
let clientDraws = !captured || desktopMouse
|
||||
guard sentClientDraws != clientDraws else { return }
|
||||
sentClientDraws = clientDraws
|
||||
connection.setCursorRender(clientDraws: clientDraws)
|
||||
|
||||
@@ -225,6 +225,15 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
/// How long an escalated attempt reports `prefersPointerLocked == false` before flipping back,
|
||||
/// so the system observes a real transition instead of coalescing the flip away.
|
||||
private static let pointerLockForcedOffHold: TimeInterval = 0.05
|
||||
/// Attempts spent in the QUIET tail (see `scheduleQuietRelock()`), reset with the burst.
|
||||
private var pointerRelockQuietAttempt = 0
|
||||
/// When the quiet tail re-asks, measured from the drop. The visible burst above spends its whole
|
||||
/// budget inside ~0.6 s — and the pointer-lock cooldown the platform applies right after its own
|
||||
/// Escape gesture is about a second, so every one of those attempts asks while the answer can
|
||||
/// only be no. These land AFTER it. They are "quiet" because unlike the burst they do not hide
|
||||
/// the cursor or mute motion: the pointer behaves exactly as it does today while they run, so
|
||||
/// stretching the recovery costs the user nothing if it also fails.
|
||||
private static let pointerRelockQuietDelays: [TimeInterval] = [1.2, 2.4]
|
||||
#endif
|
||||
|
||||
/// Reads whether the scene's pointer is actually locked right now; nil = state
|
||||
@@ -340,12 +349,80 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
// SwiftUI places us in the hierarchy AFTER start()'s setCaptured(true), and may reparent us
|
||||
// later — re-anchor the chain here so a lock requested before we had a parent still lands.
|
||||
updatePointerLockChain()
|
||||
anchorKeyResponder()
|
||||
}
|
||||
|
||||
public override func didMove(toParent parent: UIViewController?) {
|
||||
super.didMove(toParent: parent)
|
||||
updatePointerLockChain() // chain shape changed — re-anchor (or no-op if not yet in a window)
|
||||
}
|
||||
|
||||
/// Put THIS controller on the responder chain for hardware key presses.
|
||||
///
|
||||
/// Nothing of ours is otherwise a first responder during a normal stream: keys arrive on the
|
||||
/// GameController (`GCKeyboard`) path, which is a parallel HID feed that does not consume the
|
||||
/// UIKit event, and `StreamLayerUIView` only becomes first responder to summon the SOFT
|
||||
/// keyboard (it is `UIKeyInput`, so making it one for any other reason would raise the on-screen
|
||||
/// keyboard mid-game). With no responder of ours in the chain, every hardware key press reaches
|
||||
/// UIKit unclaimed — and an unclaimed press is what lets the system apply its own default for
|
||||
/// that key. `pressesBegan` below is where we claim Escape; this is what gets it delivered.
|
||||
///
|
||||
/// A controller is not `UIKeyInput`, so being first responder raises no keyboard. Deferred to
|
||||
/// the soft keyboard whenever the view has taken over, so the three-finger-swipe keyboard is
|
||||
/// unaffected.
|
||||
///
|
||||
/// Only while captured — the whole claim is scoped to "the stream owns the keyboard", and
|
||||
/// holding the chain outside that would sit in front of SwiftUI's focus for no reason. Safe to
|
||||
/// call from anywhere: `start()` engages capture BEFORE SwiftUI puts us in a window (where
|
||||
/// `becomeFirstResponder` cannot succeed), so `viewDidAppear` calls it again to catch up.
|
||||
private func anchorKeyResponder() {
|
||||
guard captured, !streamView.isFirstResponder, !isFirstResponder else { return }
|
||||
becomeFirstResponder()
|
||||
}
|
||||
|
||||
public override var canBecomeFirstResponder: Bool { true }
|
||||
|
||||
/// Claim Escape while the stream owns the keyboard, so the SYSTEM never gets to act on it.
|
||||
///
|
||||
/// This is the fix for "Escape hands the mouse back to iPadOS": the platform releases the
|
||||
/// scene's pointer lock on an Escape that nothing claimed — the same "let me out" the web
|
||||
/// Pointer Lock API mandates. Every recovery attempt before this one fought that release AFTER
|
||||
/// the fact (a re-lock burst, then a click), and the platform's post-Escape cooldown means the
|
||||
/// burst is refused by construction. Claiming the press means there is nothing to recover from.
|
||||
///
|
||||
/// Escape is forwarded to the host on the GCKeyboard path, which is untouched by this — that
|
||||
/// path never sees the UIKit responder chain, so the host still receives the keystroke and
|
||||
/// in-game menus still open. Only the system's own interpretation is suppressed.
|
||||
///
|
||||
/// Strictly scoped: only while `captured` (the stream owns input), and only Escape. Anything
|
||||
/// else — including every key while the pointer is released — goes to `super` untouched, so
|
||||
/// Escape still dismisses sheets, exits full screen and does everything else it should whenever
|
||||
/// we are not holding the keyboard. The deliberate ways out are unaffected: ⌘⎋ and ⌃⌥⇧Q are
|
||||
/// recognized on the GCKeyboard path and clear `captured` themselves.
|
||||
public override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
|
||||
let unclaimed = presses.filter { !claimsPress($0) }
|
||||
if !unclaimed.isEmpty || presses.isEmpty {
|
||||
super.pressesBegan(unclaimed, with: event)
|
||||
}
|
||||
}
|
||||
|
||||
public override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
|
||||
let unclaimed = presses.filter { !claimsPress($0) }
|
||||
if !unclaimed.isEmpty || presses.isEmpty {
|
||||
super.pressesEnded(unclaimed, with: event)
|
||||
}
|
||||
}
|
||||
|
||||
public override func pressesCancelled(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
|
||||
// Never swallowed: a cancelled press is the system taking the key away from us, and
|
||||
// dropping it here would strand UIKit's own bookkeeping for a press we did claim.
|
||||
super.pressesCancelled(presses, with: event)
|
||||
}
|
||||
|
||||
/// Is this press one the stream owns outright (Escape while captured)?
|
||||
private func claimsPress(_ press: UIPress) -> Bool {
|
||||
captured && press.key?.keyCode == .keyboardEscape
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(tvOS)
|
||||
@@ -478,6 +555,7 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
if !down, self.wantsPointerLock, self.pointerLockWasEngaged,
|
||||
!self.pointerRelockPending, self.pointerLockEngaged() != true {
|
||||
self.pointerRelockAttempt = 0
|
||||
self.pointerRelockQuietAttempt = 0 // a real gesture buys a fresh tail too
|
||||
self.updatePointerLockChain() // a reparent since the drop would break the walk to us
|
||||
self.requestPointerRelock()
|
||||
}
|
||||
@@ -749,10 +827,16 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
guard captureEnabled, !captured, connection != nil else { return }
|
||||
inputCapture?.setForwarding(true, suppressClick: fromClick)
|
||||
captured = true
|
||||
// Claim the responder chain for as long as we own the keyboard — `pressesBegan` has to
|
||||
// be delivered to us before it can keep Escape away from the system.
|
||||
anchorKeyResponder()
|
||||
} else {
|
||||
guard captured else { return }
|
||||
inputCapture?.setForwarding(false)
|
||||
captured = false
|
||||
// Hand the chain back: released means Escape is the system's again, and staying first
|
||||
// responder for a stream that no longer owns input would sit in front of SwiftUI focus.
|
||||
if isFirstResponder { resignFirstResponder() }
|
||||
}
|
||||
setNeedsUpdateOfPrefersPointerLocked()
|
||||
updatePointerLockChain() // (re)anchor the SwiftUI ancestors so the lock actually resolves
|
||||
@@ -782,6 +866,7 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
pointerLockWasEngaged = true
|
||||
pointerRelockPending = false
|
||||
pointerRelockAttempt = 0
|
||||
pointerRelockQuietAttempt = 0 // granted — any scheduled tail finds nothing to do
|
||||
} else if wantsPointerLock, pointerLockWasEngaged {
|
||||
requestPointerRelock()
|
||||
} else {
|
||||
@@ -790,6 +875,7 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
if !wantsPointerLock { pointerLockWasEngaged = false }
|
||||
pointerRelockPending = false
|
||||
pointerRelockAttempt = 0
|
||||
pointerRelockQuietAttempt = 0
|
||||
}
|
||||
let useGCMouse = captured && locked
|
||||
// Lock dropped (or capture ended) while the GCMouse path held a button down: once
|
||||
@@ -830,10 +916,12 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
pointerRelockAttempt = 0
|
||||
}
|
||||
guard pointerRelockAttempt < Self.pointerRelockAttemptLimit else {
|
||||
// Out of budget: fall back to exactly today's behavior — the iPadOS cursor comes back
|
||||
// and a click into the video re-captures. The caller invalidates the interaction, so
|
||||
// the cursor can never stay hidden on a lock the system won't grant.
|
||||
// Out of VISIBLE budget: give the cursor straight back (the caller invalidates the
|
||||
// interaction, so it can never stay hidden on a lock the system won't grant) and hand
|
||||
// off to the quiet tail, which keeps asking after the platform's post-Escape cooldown
|
||||
// without costing the user anything while it does.
|
||||
pointerRelockPending = false
|
||||
scheduleQuietRelock()
|
||||
return
|
||||
}
|
||||
pointerRelockAttempt += 1
|
||||
@@ -881,6 +969,43 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep asking for the lock after the visible burst has given up — past the cooldown the
|
||||
/// platform applies to its own Escape gesture, which is the window the burst spends entirely.
|
||||
///
|
||||
/// Deliberately NOT a longer burst. `pointerRelockPending` hides the cursor and mutes absolute
|
||||
/// motion, which is only tolerable for the couple of frames a fast re-grab takes; holding that
|
||||
/// for seconds would trade a released pointer for a frozen one. These attempts leave the
|
||||
/// pointer fully usable — if they all fail the user sees exactly today's behaviour, and a click
|
||||
/// is still the immediate way back.
|
||||
///
|
||||
/// Each attempt presents a real false→true transition (the same escalation the burst uses on
|
||||
/// its later tries) because re-asserting a value the system already holds is what didn't take.
|
||||
/// A grant arrives as a `didChange` → `syncPointerLock`, which resets the counters, so a
|
||||
/// successful attempt silently ends the tail.
|
||||
private func scheduleQuietRelock() {
|
||||
guard pointerRelockQuietAttempt < Self.pointerRelockQuietDelays.count else { return }
|
||||
let delay = Self.pointerRelockQuietDelays[pointerRelockQuietAttempt]
|
||||
pointerRelockQuietAttempt += 1
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
guard let self else { return }
|
||||
// Still wanted, still ours to want, and still not held — otherwise the tail is moot.
|
||||
guard self.wantsPointerLock, self.pointerLockWasEngaged,
|
||||
self.pointerLockEngaged() != true,
|
||||
self.view.window?.windowScene?.activationState == .foregroundActive
|
||||
else { return }
|
||||
self.pointerLockForcedOff = true
|
||||
self.setNeedsUpdateOfPrefersPointerLocked()
|
||||
self.updatePointerLockChain()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + Self.pointerLockForcedOffHold) {
|
||||
[weak self] in
|
||||
guard let self else { return }
|
||||
self.pointerLockForcedOff = false
|
||||
self.setNeedsUpdateOfPrefersPointerLocked()
|
||||
self.scheduleQuietRelock() // no-op once the delays are spent, or once granted
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
deinit {
|
||||
|
||||
@@ -179,6 +179,14 @@ public enum DefaultsKey {
|
||||
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library)
|
||||
/// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`.
|
||||
public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled"
|
||||
/// Which colour family the gamepad UI's living backdrop drifts through — a
|
||||
/// `GamepadPalette` id ("violet" = the brand default, then "tide"/"forest"/"ember"/
|
||||
/// "rose"/"graphite"). The cross-client `ui_palette` key: the desktop console and the
|
||||
/// Android client carry the same table under the same names. Presentation only, so it is
|
||||
/// a device preference and never part of a stream profile. An unknown value reads as the
|
||||
/// default rather than failing — a newer client may have shipped a palette this build
|
||||
/// doesn't know.
|
||||
public static let uiPalette = "punktfunk.uiPalette"
|
||||
/// iPhone: ALSO play the rumble the host addresses to controller 1 (wire pad 0) on this
|
||||
/// device's own Taptic Engine — for phone-clip pads that ship without rumble motors, where
|
||||
/// the phone body is the only actuator in the player's hands. Off by default (opt-in); read
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// The gamepad UI's background colour families, and the ink each one calls for.
|
||||
//
|
||||
// A palette is a short ordered ramp of DISTINCT hues, not one hue at several brightnesses. The
|
||||
// 4×4 mesh samples that ramp diagonally with a per-cell offset (`cellRamp`), so neighbouring
|
||||
// cells land on different parts of it and the colours pool and swirl the way a real gradient
|
||||
// poster does; the mesh's existing control-point drift then moves those pools around. An earlier
|
||||
// version rotated ONE field's hue per palette, which is why every non-default palette read flat.
|
||||
//
|
||||
// A palette also owns the UI sitting on it: `accent` is the focus wash / selected pill / switch
|
||||
// colour, and `light` flips the ink so a pale field gets dark text instead of white.
|
||||
//
|
||||
// The table, `ramp` and `cellRamp` are mirrored in `pf-console-ui`'s `library.rs` (Rust) and the
|
||||
// Android client's `GamepadPalette.kt` (Kotlin) under the same ids, so one `ui_palette` value is
|
||||
// one look 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.
|
||||
//
|
||||
// It lives in PunktfunkShared rather than next to the views because that is the target the tests
|
||||
// can reach — the arithmetic below is the part that has to agree across three languages.
|
||||
|
||||
import Foundation
|
||||
import simd
|
||||
|
||||
public struct GamepadPalette: Identifiable, Equatable, Sendable {
|
||||
/// The stored `ui_palette` value (`DefaultsKey.uiPalette`).
|
||||
public let id: String
|
||||
/// What the settings row shows.
|
||||
public let name: String
|
||||
/// The colour ramp, dark end first. Empty = use `violetMesh` verbatim (the brand default,
|
||||
/// kept bit-identical to what every install already sees).
|
||||
public let stops: [SIMD3<Double>]
|
||||
/// The field's ground — what the corners settle onto and what the calm mix lifts toward.
|
||||
public let ground: SIMD3<Double>
|
||||
/// The UI accent: focus wash, selected tab pill, switch track, caret.
|
||||
public let accent: SIMD3<Double>
|
||||
/// A pale field: the UI flips to dark ink and the legibility scrims go white.
|
||||
public let light: Bool
|
||||
|
||||
/// Where each of the 16 mesh cells samples the ramp. The base is the diagonal
|
||||
/// `0.5·(x + y)` — top-left is the ramp's dark end, bottom-right its bright one — and the
|
||||
/// per-cell nudges break the banding a pure diagonal would give, so hues pool instead of
|
||||
/// striping.
|
||||
static let cellRamp: [Double] = [
|
||||
0.10, -0.06, 0.04, -0.12,
|
||||
-0.08, 0.14, -0.10, 0.06,
|
||||
0.06, -0.12, 0.16, -0.04,
|
||||
-0.10, 0.08, -0.06, 0.12,
|
||||
]
|
||||
|
||||
/// The brand default's 16 mesh colours, row-major 4×4: dark-violet corners sink the frame,
|
||||
/// the edges carry mid-tone violets, and the interior holds the bright brand family.
|
||||
public static let violetMesh: [SIMD3<Double>] = {
|
||||
let corner = SIMD3(0.075, 0.060, 0.160)
|
||||
return [
|
||||
corner, SIMD3(0.34, 0.27, 0.72), SIMD3(0.30, 0.26, 0.74), corner,
|
||||
SIMD3(0.42, 0.20, 0.54), SIMD3(0.49, 0.39, 0.95), SIMD3(0.28, 0.31, 0.84), SIMD3(0.16, 0.26, 0.64),
|
||||
SIMD3(0.45, 0.23, 0.60), SIMD3(0.53, 0.31, 0.75), SIMD3(0.35, 0.35, 0.91), SIMD3(0.19, 0.28, 0.70),
|
||||
corner, SIMD3(0.22, 0.18, 0.54), SIMD3(0.24, 0.20, 0.58), corner,
|
||||
]
|
||||
}()
|
||||
|
||||
/// The brand default's blob ramp — the four colours the pre-18/15 legacy field used, kept so
|
||||
/// `violet` is unchanged on older OSes too.
|
||||
static let violetBlobs: [SIMD3<Double>] = [
|
||||
SIMD3(0.53, 0.47, 0.96), SIMD3(0.24, 0.20, 0.72), SIMD3(0.62, 0.30, 0.80),
|
||||
SIMD3(0.22, 0.38, 0.86), SIMD3(0.53, 0.47, 0.96),
|
||||
]
|
||||
|
||||
/// 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 whole range one way.
|
||||
public static let all: [GamepadPalette] = [
|
||||
// --- dark fields (white ink) ---
|
||||
GamepadPalette(
|
||||
id: "violet", name: "Violet", stops: [],
|
||||
ground: SIMD3(0.075, 0.060, 0.160), accent: SIMD3(0.525, 0.471, 0.961), light: false),
|
||||
GamepadPalette(
|
||||
// Deep indigo climbing through violet into a hot magenta.
|
||||
id: "nebula", name: "Nebula",
|
||||
stops: [SIMD3(0.07, 0.05, 0.20), SIMD3(0.26, 0.14, 0.54), SIMD3(0.52, 0.20, 0.72),
|
||||
SIMD3(0.82, 0.26, 0.62), SIMD3(0.98, 0.46, 0.68)],
|
||||
ground: SIMD3(0.055, 0.040, 0.135), accent: SIMD3(0.95, 0.42, 0.72), light: false),
|
||||
GamepadPalette(
|
||||
// Ink-blue water: teal → cerulean → a violet undertow.
|
||||
id: "abyss", name: "Abyss",
|
||||
stops: [SIMD3(0.02, 0.10, 0.17), SIMD3(0.04, 0.28, 0.42), SIMD3(0.07, 0.46, 0.63),
|
||||
SIMD3(0.16, 0.38, 0.78), SIMD3(0.26, 0.22, 0.58)],
|
||||
ground: SIMD3(0.018, 0.070, 0.130), accent: SIMD3(0.26, 0.76, 0.92), light: false),
|
||||
GamepadPalette(
|
||||
// Banked coals: plum embers → crimson → burnt orange → gold.
|
||||
id: "ember", name: "Ember",
|
||||
stops: [SIMD3(0.16, 0.03, 0.10), SIMD3(0.45, 0.06, 0.12), SIMD3(0.72, 0.18, 0.06),
|
||||
SIMD3(0.90, 0.42, 0.08), SIMD3(0.95, 0.68, 0.18)],
|
||||
ground: SIMD3(0.090, 0.035, 0.040), accent: SIMD3(0.98, 0.62, 0.26), light: false),
|
||||
GamepadPalette(
|
||||
// Forest floor into moss and a lime break.
|
||||
id: "moss", name: "Moss",
|
||||
stops: [SIMD3(0.03, 0.11, 0.09), SIMD3(0.06, 0.27, 0.20), SIMD3(0.09, 0.45, 0.31),
|
||||
SIMD3(0.28, 0.61, 0.28), SIMD3(0.58, 0.77, 0.31)],
|
||||
ground: SIMD3(0.025, 0.085, 0.070), accent: SIMD3(0.48, 0.86, 0.46), light: false),
|
||||
GamepadPalette(
|
||||
// Neutral, but never flat: barely-there saturation that still travels from a cool
|
||||
// charcoal to a warm stone.
|
||||
id: "graphite", name: "Graphite",
|
||||
stops: [SIMD3(0.06, 0.07, 0.11), SIMD3(0.15, 0.18, 0.25), SIMD3(0.30, 0.31, 0.35),
|
||||
SIMD3(0.45, 0.42, 0.38), SIMD3(0.60, 0.56, 0.49)],
|
||||
ground: SIMD3(0.055, 0.055, 0.070), accent: SIMD3(0.78, 0.80, 0.86), light: false),
|
||||
// --- pale fields (dark ink) ---
|
||||
GamepadPalette(
|
||||
// The holographic foil: rose → lilac → periwinkle → aqua, with a white bloom.
|
||||
id: "holo", name: "Holo",
|
||||
stops: [SIMD3(0.99, 0.72, 0.90), SIMD3(0.80, 0.60, 0.98), SIMD3(0.58, 0.62, 0.99),
|
||||
SIMD3(0.55, 0.86, 0.98), SIMD3(0.94, 0.98, 1.00)],
|
||||
ground: SIMD3(0.96, 0.92, 0.99), accent: SIMD3(0.42, 0.28, 0.86), light: true),
|
||||
GamepadPalette(
|
||||
// The poster sunset: periwinkle → magenta → scarlet → tangerine → gold.
|
||||
id: "sunset", name: "Sunset",
|
||||
stops: [SIMD3(0.55, 0.45, 0.92), SIMD3(0.86, 0.31, 0.66), SIMD3(0.97, 0.26, 0.34),
|
||||
SIMD3(0.99, 0.51, 0.18), SIMD3(1.00, 0.80, 0.22)],
|
||||
ground: SIMD3(0.98, 0.74, 0.34), accent: SIMD3(0.64, 0.13, 0.44), light: true),
|
||||
GamepadPalette(
|
||||
// Peach into blush and lilac — the softest of the set.
|
||||
id: "bloom", name: "Bloom",
|
||||
stops: [SIMD3(1.00, 0.86, 0.72), SIMD3(0.99, 0.73, 0.79), SIMD3(0.95, 0.65, 0.89),
|
||||
SIMD3(0.82, 0.68, 0.96), SIMD3(0.73, 0.79, 0.99)],
|
||||
ground: SIMD3(0.99, 0.90, 0.89), accent: SIMD3(0.72, 0.24, 0.55), light: true),
|
||||
GamepadPalette(
|
||||
// First light: pale gold → coral → lilac.
|
||||
id: "dawn", name: "Dawn",
|
||||
stops: [SIMD3(1.00, 0.92, 0.70), SIMD3(1.00, 0.80, 0.62), SIMD3(0.99, 0.66, 0.62),
|
||||
SIMD3(0.90, 0.62, 0.78), SIMD3(0.77, 0.69, 0.95)],
|
||||
ground: SIMD3(1.00, 0.93, 0.82), accent: SIMD3(0.82, 0.33, 0.28), light: true),
|
||||
GamepadPalette(
|
||||
// Sea glass: mint → aqua → a pale sky.
|
||||
id: "mint", name: "Mint",
|
||||
stops: [SIMD3(0.82, 0.98, 0.90), SIMD3(0.62, 0.94, 0.88), SIMD3(0.55, 0.88, 0.95),
|
||||
SIMD3(0.63, 0.82, 0.99), SIMD3(0.82, 0.87, 1.00)],
|
||||
ground: SIMD3(0.90, 0.98, 0.96), accent: SIMD3(0.04, 0.42, 0.40), light: true),
|
||||
GamepadPalette(
|
||||
// Near-white, but iridescent rather than flat — rose, sky, mint and cream in turn.
|
||||
id: "opal", name: "Opal",
|
||||
stops: [SIMD3(0.98, 0.92, 0.96), SIMD3(0.87, 0.93, 0.99), SIMD3(0.91, 0.99, 0.95),
|
||||
SIMD3(0.99, 0.96, 0.88), SIMD3(0.94, 0.90, 0.99)],
|
||||
ground: SIMD3(0.97, 0.96, 0.99), accent: SIMD3(0.36, 0.32, 0.44), light: true),
|
||||
]
|
||||
|
||||
/// The palette stored under `id`, falling back to the brand default — an unknown name is a
|
||||
/// palette a newer client shipped, not a reason to draw nothing.
|
||||
public static func named(_ id: String) -> GamepadPalette {
|
||||
all.first { $0.id == id } ?? all[0]
|
||||
}
|
||||
|
||||
/// Sample an ordered colour ramp at `t` ∈ [0, 1] (linear between neighbouring stops).
|
||||
public static func ramp(_ stops: [SIMD3<Double>], _ t: Double) -> SIMD3<Double> {
|
||||
guard let first = stops.first else { return SIMD3(0, 0, 0) }
|
||||
guard stops.count > 1 else { return first }
|
||||
let x = min(max(t, 0), 1) * Double(stops.count - 1)
|
||||
let i = min(Int(x.rounded(.down)), stops.count - 2)
|
||||
let f = x - Double(i)
|
||||
return stops[i] + (stops[i + 1] - stops[i]) * f
|
||||
}
|
||||
|
||||
/// The 16 mesh colours for this palette: the ramp sampled per cell, or `violetMesh` verbatim
|
||||
/// for the brand default.
|
||||
public var meshColors: [SIMD3<Double>] {
|
||||
guard !stops.isEmpty else { return Self.violetMesh }
|
||||
return (0..<16).map { i in
|
||||
let (x, y) = (Double(i % 4) / 3.0, Double(i / 4) / 3.0)
|
||||
return Self.ramp(stops, 0.5 * (x + y) + Self.cellRamp[i])
|
||||
}
|
||||
}
|
||||
|
||||
/// Four drifting blob colours for the pre-18/15 legacy field. Spread across the ramp so it
|
||||
/// still shows several hues at once.
|
||||
public var blobColors: [SIMD3<Double>] {
|
||||
let s = stops.isEmpty ? Self.violetBlobs : stops
|
||||
return (0..<4).map { Self.ramp(s, 0.15 + 0.25 * Double($0)) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Safe-area stream sizing — the pure geometry behind the "safe area" resolution row.
|
||||
//
|
||||
// An iPhone clips the picture in HARDWARE: the sensor housing (notch / Dynamic Island) and the four
|
||||
// rounded corners eat whatever the stream draws underneath them. The session view is deliberately
|
||||
// edge-to-edge (ContentView's `.ignoresSafeArea()` on iOS) and the presenter aspect-FITS the host
|
||||
// mode into it, so which pixels survive is decided entirely by the mode's aspect ratio:
|
||||
//
|
||||
// * A 16:9 mode on a 19.5:9 phone pillarboxes, and those black bars land exactly on the unsafe
|
||||
// regions. That is why 1080p has always "just worked" and never needed a setting.
|
||||
// * The device's NATIVE mode has the screen's own aspect ratio, so it fills every pixel —
|
||||
// including the ones behind the housing and under the corner radii. That is the mode that
|
||||
// loses its corners, and the reason this file exists.
|
||||
//
|
||||
// So the fix needs no layout change and no input change: ask the host for a mode that is narrower
|
||||
// by the safe-area insets, and the existing aspect-fit centres it inside the safe region. Pointer
|
||||
// input keeps mapping correctly for free, because `hostPoint(from:)` derives the video rect from
|
||||
// the live host mode (`AVMakeRect(aspectRatio:insideRect:)`) instead of assuming full-bleed.
|
||||
//
|
||||
// The formula is Moonlight's (its settings' resolution table carries the same row): full native
|
||||
// height, width reduced by the left+right safe-area insets. Width-only is not a simplification —
|
||||
// under aspect-fit only one axis can bind, and on a landscape phone that axis is always the
|
||||
// horizontal one. Insetting the height too would shrink the picture without uncovering anything.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum SafeDisplay {
|
||||
/// The host rejects odd dimensions and anything under 320×200 (`validate_dimensions` in
|
||||
/// `pf-encode`), so the computed mode is even-floored and clamped exactly like `RenderScale`.
|
||||
public static let minWidth = 320
|
||||
public static let minHeight = 200
|
||||
|
||||
/// A portrait top inset at or above this many points means a sensor housing rather than a
|
||||
/// status bar. Notched and Dynamic Island iPhones report 44–59 pt; a plain status bar (older
|
||||
/// iPhones, every iPad) reports 20–24 pt. Used only by [`sideInsetPoints`] and only when the
|
||||
/// horizontal insets are unavailable — see there for why that case exists at all.
|
||||
public static let housingTopInsetThreshold: Double = 40
|
||||
|
||||
/// The per-side inset, in points, that the **landscape** stream will be subject to — which is
|
||||
/// not necessarily the inset the caller can read right now.
|
||||
///
|
||||
/// The stream is always landscape, but the settings screen the resolution row is rendered in may
|
||||
/// be portrait, and `safeAreaInsets` only ever describes the CURRENT orientation. In portrait a
|
||||
/// notched iPhone reports its housing on `top` and reports `left`/`right` as zero, so reading
|
||||
/// the horizontal insets there would compute "no inset needed" for exactly the devices that
|
||||
/// need one.
|
||||
///
|
||||
/// - In landscape, `max(left, right)` is the answer directly. (iOS symmetrizes the two so
|
||||
/// content stays centred, so they normally agree; `max` is simply the safe reduction.)
|
||||
/// - In portrait, the housing's portrait TOP inset equals its landscape SIDE inset on every
|
||||
/// notched/Dynamic Island iPhone — the same physical intrusion, measured on the axis that
|
||||
/// happens to be vertical at the time — so `top` is the correct stand-in. It is accepted only
|
||||
/// on phones and only past [`housingTopInsetThreshold`], so an iPad's status bar (or an older
|
||||
/// iPhone's) never fabricates an inset for a device with nothing to avoid.
|
||||
///
|
||||
/// Returns 0 when there is no housing to route around, which makes the safe mode identical to
|
||||
/// the native one — and the caller's dedup then drops the duplicate row on its own.
|
||||
public static func sideInsetPoints(
|
||||
left: Double, right: Double, top: Double, isPhone: Bool
|
||||
) -> Double {
|
||||
let horizontal = max(left, right)
|
||||
if horizontal > 0 { return horizontal }
|
||||
if isPhone, top >= housingTopInsetThreshold { return top }
|
||||
return 0
|
||||
}
|
||||
|
||||
/// The landscape safe-area mode in PIXELS: full native height, width reduced by
|
||||
/// `sideInsetPoints` on each side.
|
||||
///
|
||||
/// `nativeWidth`/`nativeHeight` are the device's native landscape pixels (the long edge first —
|
||||
/// `UIScreen.main.nativeBounds` is portrait-oriented, so the caller swaps). `scale` converts the
|
||||
/// point-valued insets into those same pixels and must therefore be `nativeScale`, not `scale`:
|
||||
/// with Display Zoom on, the two differ and only the former matches `nativeBounds`.
|
||||
///
|
||||
/// Even-floored and clamped so the result is directly host-valid — an odd width is rejected
|
||||
/// outright by the encoder, and an inset subtraction lands odd about half the time.
|
||||
public static func mode(
|
||||
nativeWidth: Int, nativeHeight: Int, sideInsetPoints: Double, scale: Double
|
||||
) -> (width: Int, height: Int) {
|
||||
let insetPixels = max(0, sideInsetPoints) * max(scale, 1) * 2 // both sides
|
||||
let width = Double(nativeWidth) - insetPixels
|
||||
let evenFloor: (Double, Int) -> Int = { value, minimum in
|
||||
max(Int(value.rounded(.down)), minimum) / 2 * 2
|
||||
}
|
||||
return (evenFloor(width, minWidth), evenFloor(Double(nativeHeight), minHeight))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// The gamepad UI's background palettes. These assertions are the CONTRACT the Rust
|
||||
// (`pf-console-ui::library`) and Kotlin (`GamepadPalette.kt`) ports reproduce — the same ids in
|
||||
// the same order, the same light/dark split, the same ramp — so one `ui_palette` value is one look
|
||||
// on every client.
|
||||
|
||||
import XCTest
|
||||
import simd
|
||||
@testable import PunktfunkShared
|
||||
|
||||
final class GamepadPaletteTests: XCTestCase {
|
||||
private func luma(_ c: SIMD3<Double>) -> Double {
|
||||
0.2126 * c.x + 0.7152 * c.y + 0.0722 * c.z
|
||||
}
|
||||
|
||||
/// Hue angle in degrees, or nil for something too grey to have one.
|
||||
private func hue(_ c: SIMD3<Double>) -> Double? {
|
||||
let maxV = max(c.x, c.y, c.z)
|
||||
let minV = min(c.x, c.y, c.z)
|
||||
let d = maxV - minV
|
||||
guard d >= 0.04 else { return nil }
|
||||
let h: Double
|
||||
if maxV == c.x {
|
||||
h = 60 * (((c.y - c.z) / d).truncatingRemainder(dividingBy: 6))
|
||||
} else if maxV == c.y {
|
||||
h = 60 * ((c.z - c.x) / d + 2)
|
||||
} else {
|
||||
h = 60 * ((c.x - c.y) / d + 4)
|
||||
}
|
||||
return (h + 360).truncatingRemainder(dividingBy: 360)
|
||||
}
|
||||
|
||||
/// The brand default must still be the SHIPPED field, colour for colour. Every install
|
||||
/// already sees it, and a palette table that quietly restyled the default would be a
|
||||
/// regression dressed as a feature.
|
||||
func testVioletIsTheUntouchedShippedField() {
|
||||
let violet = GamepadPalette.named("violet")
|
||||
XCTAssertEqual(GamepadPalette.all.first?.id, "violet")
|
||||
XCTAssertTrue(violet.stops.isEmpty, "the default is the explicit grid")
|
||||
XCTAssertEqual(violet.meshColors, GamepadPalette.violetMesh)
|
||||
// An unknown name is a newer client's palette, not an error.
|
||||
XCTAssertEqual(GamepadPalette.named("chartreuse").id, "violet")
|
||||
XCTAssertEqual(GamepadPalette.named("").id, "violet")
|
||||
}
|
||||
|
||||
/// Ids, order and the light/dark split are the cross-client contract.
|
||||
func testTableMatchesTheOtherClients() {
|
||||
XCTAssertEqual(
|
||||
GamepadPalette.all.map(\.id),
|
||||
["violet", "nebula", "abyss", "ember", "moss", "graphite",
|
||||
"holo", "sunset", "bloom", "dawn", "mint", "opal"])
|
||||
// Dark fields lead, pale ones follow, so stepping the row walks one direction.
|
||||
let firstLight = GamepadPalette.all.firstIndex { $0.light }
|
||||
XCTAssertEqual(firstLight, 6)
|
||||
XCTAssertTrue(GamepadPalette.all.dropFirst(6).allSatisfy(\.light))
|
||||
}
|
||||
|
||||
/// A palette must read as SEVERAL hues, not one hue at several brightnesses — that was
|
||||
/// exactly the complaint about the hue-rotation model this replaced.
|
||||
func testEveryPaletteIsMultiTone() {
|
||||
for p in GamepadPalette.all {
|
||||
let hues = p.meshColors.compactMap(hue)
|
||||
XCTAssertGreaterThanOrEqual(hues.count, 8, "\(p.id): too few coloured cells")
|
||||
var spread = 0.0
|
||||
for a in hues {
|
||||
for b in hues {
|
||||
let d = abs(a - b).truncatingRemainder(dividingBy: 360)
|
||||
spread = max(spread, min(d, 360 - d))
|
||||
}
|
||||
}
|
||||
// Graphite and Opal are deliberately near-neutral; the rest must travel.
|
||||
let floor = (p.id == "graphite" || p.id == "opal") ? 20.0 : 45.0
|
||||
XCTAssertGreaterThanOrEqual(spread, floor, "\(p.id) spans only \(spread)° of hue")
|
||||
}
|
||||
}
|
||||
|
||||
/// Every colour stays in gamut, and a pale palette really is pale — its ink flips, so a
|
||||
/// mislabelled one would put dark text on a dark field.
|
||||
func testPalettesAreInGamutAndHonestAboutLightness() {
|
||||
for p in GamepadPalette.all {
|
||||
for c in p.meshColors + p.blobColors {
|
||||
for v in [c.x, c.y, c.z] {
|
||||
XCTAssertTrue((0...1).contains(v), "\(p.id) \(c)")
|
||||
}
|
||||
}
|
||||
let mean = p.meshColors.map(luma).reduce(0, +) / Double(p.meshColors.count)
|
||||
if p.light {
|
||||
XCTAssertGreaterThan(mean, 0.5, "\(p.id) is flagged light")
|
||||
XCTAssertGreaterThan(luma(p.ground), 0.6, "\(p.id)'s ground is dark")
|
||||
XCTAssertLessThan(luma(p.accent), 0.45, "\(p.id)'s accent is too pale")
|
||||
} else {
|
||||
XCTAssertLessThan(mean, 0.45, "\(p.id) is flagged dark")
|
||||
XCTAssertLessThan(luma(p.ground), 0.2, "\(p.id)'s ground is light")
|
||||
XCTAssertGreaterThan(luma(p.accent), 0.25, "\(p.id)'s accent is too dark")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The ramp is the shared sampling rule the Rust and Kotlin ports reproduce.
|
||||
func testRampInterpolatesBetweenStops() {
|
||||
let stops = [SIMD3(0.0, 0.0, 0.0), SIMD3(1.0, 0.0, 0.0), SIMD3(1.0, 1.0, 1.0)]
|
||||
XCTAssertEqual(GamepadPalette.ramp(stops, 0), SIMD3(0.0, 0.0, 0.0))
|
||||
XCTAssertEqual(GamepadPalette.ramp(stops, 1), SIMD3(1.0, 1.0, 1.0))
|
||||
XCTAssertEqual(GamepadPalette.ramp(stops, 0.5), SIMD3(1.0, 0.0, 0.0))
|
||||
XCTAssertEqual(GamepadPalette.ramp(stops, 0.25).x, 0.5, accuracy: 1e-9)
|
||||
// Out of range clamps rather than trapping.
|
||||
XCTAssertEqual(GamepadPalette.ramp(stops, -3), SIMD3(0.0, 0.0, 0.0))
|
||||
XCTAssertEqual(GamepadPalette.ramp(stops, 9), SIMD3(1.0, 1.0, 1.0))
|
||||
XCTAssertEqual(GamepadPalette.ramp([], 0.5), SIMD3(0.0, 0.0, 0.0))
|
||||
}
|
||||
}
|
||||
@@ -50,5 +50,21 @@ final class HostDiscoveryTests: XCTestCase {
|
||||
XCTAssertEqual(host.fingerprintHex, String(repeating: "ab", count: 32))
|
||||
XCTAssertFalse(host.host.isEmpty, "a resolved address is required to connect")
|
||||
XCTAssertGreaterThan(host.port, 0, "a resolved port is required to connect")
|
||||
|
||||
// A rescan tears the browser down and re-arms it (the only way past the iOS local-network
|
||||
// permission gate without relaunching). The host must come BACK — `refresh()` cancels every
|
||||
// in-flight resolve and invalidates the previous generation's callbacks, so a re-arm that
|
||||
// failed to re-drive them would leave the list permanently empty.
|
||||
await discovery.rescan()
|
||||
var reappeared = false
|
||||
let rescanDeadline = Date().addingTimeInterval(10)
|
||||
while Date() < rescanDeadline {
|
||||
if await discovery.hosts.contains(where: { $0.id == uniqueid }) {
|
||||
reappeared = true
|
||||
break
|
||||
}
|
||||
try await Task.sleep(nanoseconds: 200_000_000)
|
||||
}
|
||||
XCTAssertTrue(reappeared, "a rescan must re-find a host that is still advertising")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// The safe-area stream mode (SafeDisplay), as pure geometry: Moonlight's formula — full native
|
||||
// height, width reduced by the left+right safe insets — plus the host's dimension rules (even, and
|
||||
// never under 320×200) and the landscape-inset resolution that makes the row correct even when the
|
||||
// settings screen it is rendered on is currently portrait.
|
||||
|
||||
import XCTest
|
||||
|
||||
import PunktfunkShared
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class SafeDisplayTests: XCTestCase {
|
||||
func testLandscapeUsesTheHorizontalInsets() {
|
||||
// Landscape: the housing is on a side and iOS symmetrizes the two, so either one is the
|
||||
// per-side inset.
|
||||
XCTAssertEqual(
|
||||
SafeDisplay.sideInsetPoints(left: 59, right: 59, top: 0, isPhone: true), 59)
|
||||
// Asymmetric (or mid-rotation) readings reduce to the larger — never under-inset.
|
||||
XCTAssertEqual(
|
||||
SafeDisplay.sideInsetPoints(left: 0, right: 44, top: 0, isPhone: true), 44)
|
||||
}
|
||||
|
||||
func testPortraitFallsBackToTheHousingTopInset() {
|
||||
// Portrait on a notched phone: left/right are zero and the housing sits on `top`. Reading
|
||||
// the horizontal insets here would compute "no inset" for exactly the devices that need one,
|
||||
// so the portrait top inset stands in — it is the same physical intrusion.
|
||||
XCTAssertEqual(
|
||||
SafeDisplay.sideInsetPoints(left: 0, right: 0, top: 59, isPhone: true), 59)
|
||||
// A plain status bar is not a housing: an iPad (or a pre-notch iPhone) must not fabricate an
|
||||
// inset for a device with nothing to route around.
|
||||
XCTAssertEqual(
|
||||
SafeDisplay.sideInsetPoints(left: 0, right: 0, top: 24, isPhone: true), 0)
|
||||
XCTAssertEqual(
|
||||
SafeDisplay.sideInsetPoints(left: 0, right: 0, top: 59, isPhone: false), 0)
|
||||
}
|
||||
|
||||
func testModeInsetsWidthOnlyAndKeepsFullHeight() {
|
||||
// A Dynamic Island phone: 2556×1179 native, 59 pt per side at nativeScale 3 → 177 px per
|
||||
// side, 354 px total. Height is untouched — under aspect-fit only the horizontal axis binds.
|
||||
let m = SafeDisplay.mode(
|
||||
nativeWidth: 2556, nativeHeight: 1179, sideInsetPoints: 59, scale: 3)
|
||||
XCTAssertEqual(m.width, 2202, "2556 − 2×177")
|
||||
XCTAssertEqual(m.height, 1178, "odd native heights even-floor")
|
||||
// The safe mode must be NARROWER than native, or it would still fill the housing.
|
||||
XCTAssertLessThan(m.width, 2556)
|
||||
}
|
||||
|
||||
func testNoHousingYieldsTheNativeModeSoTheRowDedups() {
|
||||
// Zero inset ⇒ identical to native (bar the even-floor). `resolutionModes` dedups by
|
||||
// dimensions, so this is what makes the extra row vanish on a device that has no housing
|
||||
// rather than showing a pointless duplicate.
|
||||
let m = SafeDisplay.mode(
|
||||
nativeWidth: 2360, nativeHeight: 1640, sideInsetPoints: 0, scale: 2)
|
||||
XCTAssertEqual(m.width, 2360)
|
||||
XCTAssertEqual(m.height, 1640)
|
||||
}
|
||||
|
||||
func testResultIsAlwaysHostValid() {
|
||||
// Odd widths even-floor: `validate_dimensions` rejects odd outright, and an inset
|
||||
// subtraction lands odd about half the time.
|
||||
let odd = SafeDisplay.mode(
|
||||
nativeWidth: 2001, nativeHeight: 1001, sideInsetPoints: 0, scale: 1)
|
||||
XCTAssertEqual(odd.width % 2, 0)
|
||||
XCTAssertEqual(odd.height % 2, 0)
|
||||
// An absurd inset can't drive the mode under the host's floor.
|
||||
let tiny = SafeDisplay.mode(
|
||||
nativeWidth: 1280, nativeHeight: 720, sideInsetPoints: 5000, scale: 3)
|
||||
XCTAssertEqual(tiny.width, SafeDisplay.minWidth)
|
||||
XCTAssertEqual(tiny.height, 720)
|
||||
// A negative inset is treated as none rather than widening past the panel.
|
||||
let neg = SafeDisplay.mode(
|
||||
nativeWidth: 1280, nativeHeight: 720, sideInsetPoints: -40, scale: 3)
|
||||
XCTAssertEqual(neg.width, 1280)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 unom
|
||||
Copyright (c) 2026 unom - Enrico Bühler
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "punktfunk",
|
||||
"name": "Punktfunk",
|
||||
"author": "enrico",
|
||||
"flags": ["debug"],
|
||||
"api_version": 1,
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
set -euo pipefail
|
||||
HERE="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
DECK="${DECK:?set DECK=deck@<ip>}"
|
||||
NAME="$(python3 -c 'import json;print(json.load(open("'"$HERE"'/plugin.json"))["name"])')"
|
||||
# The on-disk plugin DIR (what scripts/package.sh staged into out/), not plugin.json "name" —
|
||||
# that field is the brand-cased label Decky shows in its plugin list. See package.sh's header.
|
||||
NAME=punktfunk
|
||||
STAGE_LOCAL="$HERE/out/$NAME"
|
||||
[ -d "$STAGE_LOCAL" ] || { echo "$STAGE_LOCAL missing — run scripts/package.sh first" >&2; exit 1; }
|
||||
|
||||
|
||||
@@ -5,9 +5,13 @@
|
||||
# package.json,decky.pyi,LICENSE,README.md}
|
||||
# out/punktfunk/ (the same tree, unzipped — rsync this with scripts/deploy.sh)
|
||||
#
|
||||
# Decky extracts the zip with --strip-components=1, so the single top-level dir MUST equal
|
||||
# plugin.json "name". Run after `pnpm build` (or use `pnpm run package`). Host-agnostic: needs
|
||||
# only bash, python3 and zip.
|
||||
# The single top-level dir is the plugin's ON-DISK folder name (Decky extracts the zip as-is,
|
||||
# so the dir in the zip becomes ~/homebrew/plugins/<dir>). It is deliberately NOT read from
|
||||
# plugin.json "name": that field is the user-visible label ("Punktfunk", brand-cased, shown in
|
||||
# Decky's plugin list) and Decky locates an installed plugin by MATCHING it, never by the folder
|
||||
# name. Keeping the folder lowercase means a rename of the label can't strand the old directory
|
||||
# next to a new one (which would show up as two plugins).
|
||||
# Run after `pnpm build` (or use `pnpm run package`). Host-agnostic: needs only bash, python3 and zip.
|
||||
set -euo pipefail
|
||||
HERE="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$HERE"
|
||||
@@ -15,7 +19,7 @@ cd "$HERE"
|
||||
[ -f dist/index.js ] || { echo "dist/index.js missing — run 'pnpm build' first" >&2; exit 1; }
|
||||
[ -f LICENSE ] || { echo "LICENSE missing (required by the Decky store)" >&2; exit 1; }
|
||||
|
||||
NAME="$(python3 -c 'import json;print(json.load(open("plugin.json"))["name"])')"
|
||||
NAME=punktfunk # the on-disk plugin dir (see the header) — NOT plugin.json "name"
|
||||
VER="$(python3 -c 'import json;print(json.load(open("package.json"))["version"])')"
|
||||
|
||||
STAGE="$(mktemp -d)"
|
||||
|
||||
@@ -122,6 +122,25 @@ function advertMatchesSaved(a: DiscoveredHost, s: SavedHost): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The label a saved row shows.
|
||||
*
|
||||
* A saved record whose name IS its own address is a PLACEHOLDER, not a choice: `hosts add`
|
||||
* falls back to the address when the pairing path had nothing better, so the row ends up
|
||||
* captioned with the same string it already prints underneath. When the box is on the air it
|
||||
* is advertising its actual hostname — prefer that, and the row reads "home-worker-5" instead
|
||||
* of "192.168.1.21".
|
||||
*
|
||||
* A real saved name always wins over the advert, even a stale one: it may be a name the user
|
||||
* chose, and a live advert must never quietly overwrite that. Compared against the SAVED
|
||||
* address, so a host that moved DHCP lease still recognises its old address as a placeholder.
|
||||
*/
|
||||
function hostLabel(s: SavedHost, advert?: DiscoveredHost): string {
|
||||
const placeholder = !s.name || s.name === s.addr || s.name === `${s.addr}:${s.port}`;
|
||||
if (!placeholder) return s.name;
|
||||
return advert?.name || s.name || s.addr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Join the saved store and the live browse into the rows the panel draws.
|
||||
*
|
||||
@@ -134,7 +153,7 @@ export function mergeHosts(saved: SavedHost[], discovered: DiscoveredHost[]): Ho
|
||||
// Prefer a live advert's address: the host may have moved since it was last saved.
|
||||
const advert = discovered.find((a) => advertMatchesSaved(a, s));
|
||||
return {
|
||||
name: s.name || s.addr,
|
||||
name: hostLabel(s, advert),
|
||||
addr: advert?.addr ?? s.addr,
|
||||
port: advert?.port ?? s.port,
|
||||
fp: s.fp_hex,
|
||||
@@ -387,7 +406,10 @@ export async function applyUpdate(
|
||||
// before any result could arrive — so never await it. Decky shows its own confirm prompt.
|
||||
void backend.callable("utilities/install_plugin")(
|
||||
info.artifact,
|
||||
"punktfunk",
|
||||
// The name Decky uninstalls before extracting the new zip — it locates the folder by
|
||||
// matching plugin.json "name", so this must equal THIS build's plugin.json name (the
|
||||
// brand-cased one), not the lowercase on-disk dir.
|
||||
"Punktfunk",
|
||||
info.latest,
|
||||
info.hash,
|
||||
INSTALL_TYPE_UPDATE,
|
||||
|
||||
@@ -337,9 +337,11 @@ export default definePlugin(() => {
|
||||
// controller config. Fire-and-forget: cosmetic library upkeep must never block plugin load.
|
||||
void ensureGamepadUiShortcut();
|
||||
return {
|
||||
// `name` is the plugin's INTERNAL id — it must stay in sync with plugin.json (the loader
|
||||
// keys plugins by it), so it stays lowercase; user-facing strings say "Punktfunk".
|
||||
name: "punktfunk",
|
||||
// `name` must stay in sync with plugin.json (the loader keys plugins by it) — and it is
|
||||
// USER-VISIBLE: Decky labels the entry in its plugin list with it, so it carries the brand
|
||||
// case. Decky finds an installed plugin by matching plugin.json "name" (never the folder
|
||||
// name), so this is independent of the on-disk dir, which stays lowercase `punktfunk`.
|
||||
name: "Punktfunk",
|
||||
// `staticClasses?.Title` is guarded so a future client that drops the export can't throw
|
||||
// at plugin-load time (an error boundary only catches render-time, not load-time, errors).
|
||||
titleView: <div className={staticClasses?.Title}>Punktfunk</div>,
|
||||
|
||||
@@ -70,9 +70,18 @@ declare const appStore:
|
||||
* entry from a false "missing". A confident null means the shortcut was deleted → recreate. */
|
||||
function shortcutStillExists(appId: number): boolean {
|
||||
try {
|
||||
const get = appStore?.GetAppOverviewByAppID;
|
||||
if (!get) return true; // no way to verify — preserve the reuse path
|
||||
return get(appId) != null;
|
||||
// Call it as a METHOD on appStore — NEVER as an extracted function. Its implementation
|
||||
// reads the store's own state (`this.m_mapApps`), so `const get = appStore.GetAppOverview…;
|
||||
// get(id)` throws on the lost `this`, and the catch below turns that into a permanent
|
||||
// "true". That is not a stale-data bug but a total one: the guard then answers "still
|
||||
// exists" for EVERY appId, so a dangling id is never dropped, the reuse path repoints a
|
||||
// dead shortcut (silent no-ops), and "recreate" reports success having done nothing.
|
||||
// `typeof` first: `appStore` is a Steam-injected global, and a bare reference to a missing
|
||||
// one is a ReferenceError that optional chaining does NOT prevent.
|
||||
if (typeof appStore === "undefined" || !appStore?.GetAppOverviewByAppID) {
|
||||
return true; // no way to verify — preserve the reuse path
|
||||
}
|
||||
return appStore.GetAppOverviewByAppID(appId) != null;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -61,6 +61,13 @@ const CSS: &str = "
|
||||
.pf-poster { border-radius: 10px; background: alpha(currentColor, 0.08); }
|
||||
.pf-poster-monogram { font-size: 2.4em; font-weight: bold; color: alpha(currentColor, 0.45); }
|
||||
.pf-store-badge { color: white; background: rgba(0, 0, 0, 0.55); }
|
||||
/* Launcher entries (design D4) open the launcher itself. They rarely have poster art, so an
|
||||
art-less one must not read as a game whose cover failed to load: accent face, the launcher
|
||||
named instead of a title monogram, and an accent badge. */
|
||||
.pf-poster.pf-launcher { background: alpha(@accent_color, 0.18); }
|
||||
.pf-poster-launcher-name { font-size: 1.15em; font-weight: bold; color: alpha(currentColor, 0.85); }
|
||||
.pf-store-badge.pf-launcher { color: white; background: @accent_color; }
|
||||
.pf-group-heading { font-size: 0.8em; font-weight: bold; color: alpha(currentColor, 0.55); }
|
||||
";
|
||||
|
||||
/// Everything the shell shares below the component tree.
|
||||
|
||||
@@ -204,10 +204,18 @@ pub fn headless_library(target: &str) -> glib::ExitCode {
|
||||
});
|
||||
match crate::library::fetch_games(&addr, port, &identity, pin) {
|
||||
Ok(games) => {
|
||||
// A fourth column, appended: `game` or `launcher` (design D4). Appended rather than
|
||||
// folded into an existing field so anything reading the first three columns is
|
||||
// untouched.
|
||||
for g in &games {
|
||||
println!("{}\t{}\t{}", g.id, g.store, g.title);
|
||||
let role = if g.is_launcher() { "launcher" } else { "game" };
|
||||
println!("{}\t{}\t{}\t{}", g.id, g.store, g.title, role);
|
||||
}
|
||||
let launchers = games.iter().filter(|g| g.is_launcher()).count();
|
||||
match launchers {
|
||||
0 => println!("{} game(s)", games.len()),
|
||||
n => println!("{} game(s), {} launcher(s)", games.len() - n, n),
|
||||
}
|
||||
println!("{} game(s)", games.len());
|
||||
glib::ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -773,6 +781,7 @@ fn mock_library() -> (
|
||||
title: title.to_string(),
|
||||
art: crate::library::Artwork::default(),
|
||||
platform: None,
|
||||
role: None,
|
||||
};
|
||||
let games = vec![
|
||||
game("steam:570", "steam", "Dota 2"),
|
||||
|
||||
@@ -674,6 +674,9 @@ pub struct HostsPage {
|
||||
saved: FactoryVecDeque<HostCard>,
|
||||
discovered: FactoryVecDeque<HostCard>,
|
||||
widgets: PageWidgets,
|
||||
/// Forces the mDNS browse to re-query (the header's Refresh button). `None` only if the
|
||||
/// browse never started — the button then just re-renders, which is what it did before.
|
||||
rescan: Option<discovery::Rescan>,
|
||||
}
|
||||
|
||||
struct PageWidgets {
|
||||
@@ -693,6 +696,10 @@ pub enum HostsMsg {
|
||||
},
|
||||
/// Reload the disk store and re-render (fresh pairings, renames, the library gate).
|
||||
Refresh,
|
||||
/// Re-query mDNS *and* re-render — the header's Refresh button. Distinct from [`Self::Refresh`],
|
||||
/// which only re-reads local state: after a while `mdns-sd` re-queries about once an hour, so a
|
||||
/// host that appeared since (or whose announcement was lost) needs an actual query to show up.
|
||||
Rescan,
|
||||
/// A completed reachability sweep: saved-host key → reachable. Merged into the online pips.
|
||||
Probed(HashMap<String, bool>),
|
||||
/// Mark the card matching `ConnectRequest::card_key` as connecting; `None` restores.
|
||||
@@ -841,6 +848,13 @@ impl SimpleComponent for HostsPage {
|
||||
add_host_btn.set_tooltip_text(Some("Add host"));
|
||||
add_host_btn.set_action_name(Some("win.add-host"));
|
||||
header.pack_start(&add_host_btn);
|
||||
let rescan_btn = gtk::Button::from_icon_name("view-refresh-symbolic");
|
||||
rescan_btn.set_tooltip_text(Some("Scan the network for hosts again"));
|
||||
{
|
||||
let sender = sender.clone();
|
||||
rescan_btn.connect_clicked(move |_| sender.input(HostsMsg::Rescan));
|
||||
}
|
||||
header.pack_start(&rescan_btn);
|
||||
let menu = gio::Menu::new();
|
||||
menu.append(Some("Preferences"), Some("win.preferences"));
|
||||
menu.append(Some("Keyboard Shortcuts"), Some("win.shortcuts"));
|
||||
@@ -867,8 +881,8 @@ impl SimpleComponent for HostsPage {
|
||||
}
|
||||
|
||||
// Stream mDNS adverts into the model; every add/remove re-evaluates both grids.
|
||||
let (rx, rescan) = discovery::browse();
|
||||
{
|
||||
let rx = discovery::browse();
|
||||
let sender = sender.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
while let Ok(event) = rx.recv().await {
|
||||
@@ -937,6 +951,7 @@ impl SimpleComponent for HostsPage {
|
||||
disc_heading,
|
||||
searching,
|
||||
},
|
||||
rescan: Some(rescan),
|
||||
};
|
||||
model.rebuild();
|
||||
|
||||
@@ -954,6 +969,14 @@ impl SimpleComponent for HostsPage {
|
||||
self.rebuild();
|
||||
}
|
||||
HostsMsg::Refresh => self.rebuild(),
|
||||
HostsMsg::Rescan => {
|
||||
if let Some(rescan) = &self.rescan {
|
||||
rescan.request();
|
||||
}
|
||||
// Adverts stream in as they answer; re-render now so the local half is current
|
||||
// either way.
|
||||
self.rebuild();
|
||||
}
|
||||
HostsMsg::Probed(map) => {
|
||||
self.probed = map;
|
||||
self.rebuild();
|
||||
|
||||
@@ -28,6 +28,12 @@ struct State {
|
||||
req: ConnectRequest,
|
||||
stack: gtk::Stack,
|
||||
flow: gtk::FlowBox,
|
||||
/// Launcher entries (design D4) get their own shelf above the games, so a handful of ways to
|
||||
/// open a launcher aren't buried in a 400-title grid. Hidden outright when there are none.
|
||||
launcher_flow: gtk::FlowBox,
|
||||
launchers_group: gtk::Box,
|
||||
/// The "Games" heading — only earns its space once a Launchers shelf is above it.
|
||||
games_heading: gtk::Label,
|
||||
error_page: adw::StatusPage,
|
||||
/// Per-page poster cache (entry id → texture) — a Retry re-renders without refetching.
|
||||
art: RefCell<HashMap<String, gdk::Texture>>,
|
||||
@@ -94,11 +100,44 @@ fn build(
|
||||
flow.connect_child_activated(|_, child| {
|
||||
child.activate();
|
||||
});
|
||||
// The launcher shelf: same tile geometry as the games grid, its own FlowBox so the two
|
||||
// groups never interleave and each wraps on its own.
|
||||
let launcher_flow = gtk::FlowBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.activate_on_single_click(true)
|
||||
.homogeneous(true)
|
||||
.min_children_per_line(2)
|
||||
.max_children_per_line(6)
|
||||
.column_spacing(12)
|
||||
.row_spacing(18)
|
||||
.valign(gtk::Align::Start)
|
||||
.build();
|
||||
launcher_flow.connect_child_activated(|_, child| {
|
||||
child.activate();
|
||||
});
|
||||
let launchers_heading = gtk::Label::new(Some("Launchers"));
|
||||
launchers_heading.add_css_class("pf-group-heading");
|
||||
launchers_heading.set_halign(gtk::Align::Start);
|
||||
launchers_heading.set_margin_bottom(8);
|
||||
let launchers_group = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
launchers_group.append(&launchers_heading);
|
||||
launchers_group.append(&launcher_flow);
|
||||
launchers_group.set_margin_bottom(24);
|
||||
launchers_group.set_visible(false);
|
||||
|
||||
let games_heading = gtk::Label::new(Some("Games"));
|
||||
games_heading.add_css_class("pf-group-heading");
|
||||
games_heading.set_halign(gtk::Align::Start);
|
||||
games_heading.set_margin_bottom(8);
|
||||
games_heading.set_visible(false);
|
||||
|
||||
let content = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
content.set_margin_top(24);
|
||||
content.set_margin_bottom(24);
|
||||
content.set_margin_start(12);
|
||||
content.set_margin_end(12);
|
||||
content.append(&launchers_group);
|
||||
content.append(&games_heading);
|
||||
content.append(&flow);
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(1100)
|
||||
@@ -166,6 +205,9 @@ fn build(
|
||||
req,
|
||||
stack,
|
||||
flow,
|
||||
launcher_flow,
|
||||
launchers_group,
|
||||
games_heading,
|
||||
error_page,
|
||||
art: RefCell::new(HashMap::new()),
|
||||
pics: RefCell::new(HashMap::new()),
|
||||
@@ -224,18 +266,41 @@ fn load(state: &Rc<State>) {
|
||||
/// immediately; the rest keep their monogram placeholder until `load_art` delivers.
|
||||
fn render(state: &Rc<State>, games: &[GameEntry]) {
|
||||
state.flow.remove_all();
|
||||
state.launcher_flow.remove_all();
|
||||
state.pics.borrow_mut().clear();
|
||||
for game in games {
|
||||
// Design D4: launchers never interleave with titles. The host already sorts by title, and
|
||||
// `partition` is stable, so each group keeps that order.
|
||||
let (launchers, titles): (Vec<&GameEntry>, Vec<&GameEntry>) =
|
||||
games.iter().partition(|g| g.is_launcher());
|
||||
for game in &launchers {
|
||||
state.launcher_flow.append(&game_card(state, game));
|
||||
}
|
||||
for game in &titles {
|
||||
state.flow.append(&game_card(state, game));
|
||||
}
|
||||
// A library with no launcher entries looks exactly as it did before this existed.
|
||||
state.launchers_group.set_visible(!launchers.is_empty());
|
||||
state
|
||||
.games_heading
|
||||
.set_visible(!launchers.is_empty() && !titles.is_empty());
|
||||
}
|
||||
|
||||
/// One poster tile: 2:3 art (~150×225 logical) over the title, with a store badge and a
|
||||
/// monogram placeholder underneath the async art. Activation starts a session launching
|
||||
/// this title (silent on a pinned host — the normal trust gate applies).
|
||||
fn game_card(state: &Rc<State>, game: &GameEntry) -> gtk::FlowBoxChild {
|
||||
let monogram = gtk::Label::new(Some(&initials(&game.title)));
|
||||
monogram.add_css_class("pf-poster-monogram");
|
||||
// A launcher usually ships no poster. Naming the launcher on an accent face says "opens
|
||||
// Steam"; a title monogram on the neutral face would say "a game whose cover didn't load".
|
||||
let launcher = game.is_launcher();
|
||||
let monogram = if launcher {
|
||||
let l = gtk::Label::new(Some(store_label(&game.store)));
|
||||
l.add_css_class("pf-poster-launcher-name");
|
||||
l
|
||||
} else {
|
||||
let l = gtk::Label::new(Some(&initials(&game.title)));
|
||||
l.add_css_class("pf-poster-monogram");
|
||||
l
|
||||
};
|
||||
monogram.set_halign(gtk::Align::Center);
|
||||
monogram.set_valign(gtk::Align::Center);
|
||||
let placeholder = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
@@ -252,6 +317,9 @@ fn game_card(state: &Rc<State>, game: &GameEntry) -> gtk::FlowBoxChild {
|
||||
let badge = gtk::Label::new(Some(store_label(&game.store)));
|
||||
badge.add_css_class("pf-pill");
|
||||
badge.add_css_class("pf-store-badge");
|
||||
if launcher {
|
||||
badge.add_css_class("pf-launcher");
|
||||
}
|
||||
badge.set_halign(gtk::Align::Start);
|
||||
badge.set_valign(gtk::Align::Start);
|
||||
badge.set_margin_start(6);
|
||||
@@ -262,6 +330,9 @@ fn game_card(state: &Rc<State>, game: &GameEntry) -> gtk::FlowBoxChild {
|
||||
poster.add_overlay(&pic);
|
||||
poster.add_overlay(&badge);
|
||||
poster.add_css_class("pf-poster");
|
||||
if launcher {
|
||||
poster.add_css_class("pf-launcher");
|
||||
}
|
||||
poster.set_overflow(gtk::Overflow::Hidden);
|
||||
poster.set_size_request(150, 225);
|
||||
poster.set_halign(gtk::Align::Center);
|
||||
|
||||
@@ -46,8 +46,13 @@ pub fn wake_and_connect(
|
||||
let sender = sender.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
use std::time::Duration;
|
||||
let events = crate::discovery::browse();
|
||||
let (events, rescan) = crate::discovery::browse();
|
||||
let mut wait = WakeWait::new();
|
||||
// A waking host starts advertising at a moment we can't predict, and `mdns-sd`'s own
|
||||
// re-query interval has doubled well past a minute by the time a boot finishes — so ask
|
||||
// again periodically instead of waiting to be told. Every 5th tick: often enough that a
|
||||
// host that came up is noticed promptly, rare enough not to hammer multicast.
|
||||
let mut ticks: u32 = 0;
|
||||
loop {
|
||||
if cancel.get() {
|
||||
waiting.close();
|
||||
@@ -100,6 +105,10 @@ pub fn wake_and_connect(
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
ticks += 1;
|
||||
if ticks % 5 == 0 {
|
||||
rescan.request();
|
||||
}
|
||||
glib::timeout_future(Duration::from_secs(1)).await;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -343,6 +343,7 @@ impl Service {
|
||||
probe_inflight: Arc::new(AtomicBool::new(false)),
|
||||
last_probe: Instant::now() - Duration::from_secs(60),
|
||||
wake_cancel: None,
|
||||
rescan: None,
|
||||
}
|
||||
.run(stop_w)
|
||||
})
|
||||
@@ -373,11 +374,14 @@ struct ServiceState {
|
||||
last_probe: Instant,
|
||||
/// Cancels the active wake thread (it owns the model's wake status).
|
||||
wake_cancel: Option<Arc<AtomicBool>>,
|
||||
/// Forces the mDNS browse to re-query. Installed by `run`; `None` before it starts.
|
||||
rescan: Option<discovery::Rescan>,
|
||||
}
|
||||
|
||||
impl ServiceState {
|
||||
fn run(mut self, stop: Arc<AtomicBool>) {
|
||||
let discovery_rx = discovery::browse();
|
||||
let (discovery_rx, rescan) = discovery::browse();
|
||||
self.rescan = Some(rescan);
|
||||
while !stop.load(Ordering::SeqCst) {
|
||||
// mDNS churn.
|
||||
while let Ok(ev) = discovery_rx.try_recv() {
|
||||
@@ -512,6 +516,14 @@ impl ServiceState {
|
||||
}
|
||||
ConsoleCmd::Probe => {
|
||||
self.last_probe = Instant::now() - Duration::from_secs(60);
|
||||
// "Refresh presence" means the mDNS half too, not just the QUIC sweep: the browse
|
||||
// runs for the process's lifetime and `mdns-sd` backs its re-query interval off to
|
||||
// as much as an hour, so a host that appeared since startup may never be asked
|
||||
// for again. (No console screen emits Probe yet — every face button on the home
|
||||
// screen is spoken for — but the plumbing is correct for when one does.)
|
||||
if let Some(r) = &self.rescan {
|
||||
r.request();
|
||||
}
|
||||
}
|
||||
ConsoleCmd::SetPin {
|
||||
key,
|
||||
@@ -791,6 +803,7 @@ fn spawn_fetch(
|
||||
id: g.id.clone(),
|
||||
title: g.title.clone(),
|
||||
store: g.store.clone(),
|
||||
launcher: g.is_launcher(),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
@@ -831,6 +844,7 @@ fn load_fake(shared: &LibraryShared, path: &str) {
|
||||
id: g.id.clone(),
|
||||
title: g.title.clone(),
|
||||
store: g.store.clone(),
|
||||
launcher: g.is_launcher(),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
|
||||
@@ -286,6 +286,12 @@ mod session_main {
|
||||
// Spawned at first params-build so it exists for --connect AND console launches.
|
||||
#[cfg(unix)]
|
||||
crate::ctl_socket::spawn(gamepad.clone());
|
||||
// Pad-audio prefs to OUR gamepad service (same reasoning as the pin above): tier-A
|
||||
// slots declare their render caps at open time, which happens on attach — after this.
|
||||
gamepad.set_pad_audio_prefs(
|
||||
settings.pad_haptics,
|
||||
pf_client_core::pad_audio::speaker_active(&settings.pad_speaker),
|
||||
);
|
||||
let mode = Mode {
|
||||
width: if settings.width == 0 {
|
||||
native.width
|
||||
@@ -389,6 +395,11 @@ mod session_main {
|
||||
cursor_forward: settings.mouse_mode() == trust::MouseMode::Desktop,
|
||||
mic_enabled: settings.mic_enabled,
|
||||
echo_cancel: settings.echo_cancel,
|
||||
// Pad audio (0xD1): the DualSense haptics/speaker render settings. The gamepad
|
||||
// service learns the same prefs below so tier-A slots declare their render caps
|
||||
// at open; the session pump gates CLIENT_CAP_PAD_AUDIO + the renderer on these.
|
||||
pad_haptics: settings.pad_haptics,
|
||||
pad_speaker: settings.pad_speaker.clone(),
|
||||
clipboard,
|
||||
// The Settings preference (auto → VAAPI where it exists; the presenter
|
||||
// demotes to software on boxes whose Vulkan can't import the dmabufs).
|
||||
|
||||
@@ -490,9 +490,13 @@ fn wake_and_connect(
|
||||
|
||||
let (ctx, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
|
||||
std::thread::spawn(move || {
|
||||
let rx = crate::discovery::browse();
|
||||
let (rx, rescan) = crate::discovery::browse();
|
||||
let mut seen: Vec<DiscoveredHost> = Vec::new();
|
||||
let mut wait = WakeWait::new();
|
||||
// A waking host starts advertising at a moment we can't predict, and `mdns-sd`'s own
|
||||
// re-query interval has doubled well past a minute by the time a boot finishes — so ask
|
||||
// again periodically instead of waiting to be told (matches the GTK client's wake wait).
|
||||
let mut ticks: u32 = 0;
|
||||
loop {
|
||||
// Cancel already returned the UI to the host list — stop re-sending and tear down.
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
@@ -555,6 +559,10 @@ fn wake_and_connect(
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
ticks += 1;
|
||||
if ticks.is_multiple_of(5) {
|
||||
rescan.request();
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -595,6 +595,22 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
move || sa.call(true)
|
||||
})
|
||||
.into()];
|
||||
// Re-query mDNS. The browse runs for the app's lifetime, and `mdns-sd` backs its
|
||||
// re-query interval off to as much as an hour — so a host that appeared since
|
||||
// startup, or whose announcement was lost to multicast, may need an actual ask.
|
||||
actions.push(
|
||||
icon_btn("Scan the network for hosts again", Symbol::Refresh)
|
||||
.on_click({
|
||||
let (c, st) = (ctx.clone(), set_status.clone());
|
||||
move || {
|
||||
if let Some(r) = c.shared.rescan.lock().unwrap().as_ref() {
|
||||
r.request();
|
||||
}
|
||||
st.call("Scanning the network\u{2026}".to_string());
|
||||
}
|
||||
})
|
||||
.into(),
|
||||
);
|
||||
// The couch UI's front door, beside the other page actions. Absent on ARM64,
|
||||
// where the session binary ships without its Skia console.
|
||||
if CONSOLE_UI_AVAILABLE {
|
||||
|
||||
@@ -39,6 +39,10 @@ pub(crate) struct Game {
|
||||
pub(crate) id: String,
|
||||
pub(crate) title: String,
|
||||
pub(crate) store: String,
|
||||
/// This entry opens the launcher itself (Steam Big Picture, Heroic) rather than a title —
|
||||
/// design D4. Reduced from the wire's `role` by `GameEntry::is_launcher`, so "anything that
|
||||
/// isn't `launcher` is a game" is decided in one place for every client.
|
||||
pub(crate) launcher: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Default)]
|
||||
@@ -135,6 +139,7 @@ pub(crate) fn start_fetch(ctx: &Arc<AppCtx>, set_library: &AsyncSetState<Library
|
||||
id: g.id.clone(),
|
||||
title: g.title.clone(),
|
||||
store: g.store.clone(),
|
||||
launcher: g.is_launcher(),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
@@ -215,6 +220,17 @@ fn initials(title: &str) -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A small group label above a tile grid ("Launchers" / "Games"). Only drawn when the page shows
|
||||
/// both groups — a single unlabelled grid is what every launcher-less library looked like before.
|
||||
fn group_heading(text: &str) -> Element {
|
||||
text_block(text)
|
||||
.font_size(12.0)
|
||||
.semibold()
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.margin(edges(2.0, 8.0, 2.0, 2.0))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// One poster tile: the artwork (or a monogram placeholder while it loads) with the store
|
||||
/// badge overlaid top-left, the title below, tap-to-launch across the whole tile.
|
||||
fn poster_tile(
|
||||
@@ -228,13 +244,20 @@ fn poster_tile(
|
||||
.stretch(Stretch::UniformToFill)
|
||||
.height(poster_h)
|
||||
.into(),
|
||||
// A launcher rarely has poster art, and an art-less launcher drawn like an art-less game
|
||||
// reads as "a game whose cover failed to load". So it names its launcher instead of
|
||||
// showing a title monogram, and the frame below picks up the accent stroke.
|
||||
None => border(
|
||||
text_block(initials(&game.title))
|
||||
.font_size(28.0)
|
||||
.semibold()
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.horizontal_alignment(HorizontalAlignment::Center)
|
||||
.vertical_alignment(VerticalAlignment::Center),
|
||||
text_block(if game.launcher {
|
||||
store_label(&game.store).to_string()
|
||||
} else {
|
||||
initials(&game.title)
|
||||
})
|
||||
.font_size(if game.launcher { 18.0 } else { 28.0 })
|
||||
.semibold()
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.horizontal_alignment(HorizontalAlignment::Center)
|
||||
.vertical_alignment(VerticalAlignment::Center),
|
||||
)
|
||||
.background(ThemeRef::SubtleFill)
|
||||
.height(poster_h)
|
||||
@@ -242,14 +265,27 @@ fn poster_tile(
|
||||
};
|
||||
let framed = border(grid(vec![
|
||||
poster,
|
||||
pill(store_label(&game.store), Pill::Neutral)
|
||||
.horizontal_alignment(HorizontalAlignment::Left)
|
||||
.vertical_alignment(VerticalAlignment::Top)
|
||||
.margin(uniform(6.0))
|
||||
.into(),
|
||||
// `Pill::Info` rather than a solid accent fill — `style.rs` is explicit that
|
||||
// white-on-bright is unreadable here.
|
||||
pill(
|
||||
store_label(&game.store),
|
||||
if game.launcher {
|
||||
Pill::Info
|
||||
} else {
|
||||
Pill::Neutral
|
||||
},
|
||||
)
|
||||
.horizontal_alignment(HorizontalAlignment::Left)
|
||||
.vertical_alignment(VerticalAlignment::Top)
|
||||
.margin(uniform(6.0))
|
||||
.into(),
|
||||
]))
|
||||
.corner_radius(8.0)
|
||||
.border_brush(ThemeRef::CardStroke)
|
||||
.border_brush(if game.launcher {
|
||||
ThemeRef::Accent
|
||||
} else {
|
||||
ThemeRef::CardStroke
|
||||
})
|
||||
.border_thickness(uniform(1.0));
|
||||
|
||||
border(
|
||||
@@ -332,22 +368,43 @@ pub(crate) fn library_page(props: &LibraryProps, cx: &mut RenderCx) -> Element {
|
||||
.into(),
|
||||
),
|
||||
LibraryPhase::Ready(games) => {
|
||||
let tiles: Vec<Element> = games
|
||||
.iter()
|
||||
.map(|g| {
|
||||
let (ctx2, ss, st) = (ctx.clone(), ss.clone(), st.clone());
|
||||
let (target, id) = (target.clone(), g.id.clone());
|
||||
poster_tile(
|
||||
g,
|
||||
props.state.art.get(&g.id).map(String::as_str),
|
||||
poster_h,
|
||||
Box::new(move || {
|
||||
initiate_launch(&ctx2, target.clone(), id.clone(), &ss, &st)
|
||||
}),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
body.push(tile_grid(tiles, cols, POSTER_GAP));
|
||||
let tile = |g: &Game| -> Element {
|
||||
let (ctx2, ss, st) = (ctx.clone(), ss.clone(), st.clone());
|
||||
let (target, id) = (target.clone(), g.id.clone());
|
||||
poster_tile(
|
||||
g,
|
||||
props.state.art.get(&g.id).map(String::as_str),
|
||||
poster_h,
|
||||
Box::new(move || initiate_launch(&ctx2, target.clone(), id.clone(), &ss, &st)),
|
||||
)
|
||||
};
|
||||
// Design D4: launcher entries get their own shelf above the titles, never
|
||||
// interleaved. `partition` is stable, so the host's title order survives in each
|
||||
// group. Headings appear only when both groups exist, so a library without launcher
|
||||
// entries renders exactly as it did before.
|
||||
let (launchers, titles): (Vec<&Game>, Vec<&Game>) =
|
||||
games.iter().partition(|g| g.launcher);
|
||||
let both = !launchers.is_empty() && !titles.is_empty();
|
||||
if !launchers.is_empty() {
|
||||
if both {
|
||||
body.push(group_heading("Launchers"));
|
||||
}
|
||||
body.push(tile_grid(
|
||||
launchers.iter().map(|g| tile(g)).collect(),
|
||||
cols,
|
||||
POSTER_GAP,
|
||||
));
|
||||
}
|
||||
if !titles.is_empty() {
|
||||
if both {
|
||||
body.push(group_heading("Games"));
|
||||
}
|
||||
body.push(tile_grid(
|
||||
titles.iter().map(|g| tile(g)).collect(),
|
||||
cols,
|
||||
POSTER_GAP,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user