Compare commits
120 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49533ff90a | |||
| 1b890ae919 | |||
| f88d0ae4dc | |||
| 94802795e7 | |||
| 764b5d938b | |||
| 1af11cc64d | |||
| 2f214532d9 | |||
| 31bc863084 | |||
| 60af4de3ba | |||
| aedffc69dd | |||
| 26cac9ce20 | |||
| 48933dc405 | |||
| e5166c6e6e | |||
| 59fc820226 | |||
| d611645ffc | |||
| 17457cf4ba | |||
| 6263108e15 | |||
| 0c427cb3f1 | |||
| 5109a4c80a | |||
| 43e52437c0 | |||
| 2642ba6ad0 | |||
| 236c59754b | |||
| 68b9f108ab | |||
| 5cd66eca59 | |||
| a9dc6efe55 | |||
| 9822fc3b1c | |||
| cdb43f00fe | |||
| 644c035a34 | |||
| 3c16c1dd30 | |||
| c0fc2d8ee8 | |||
| ef5808254a | |||
| 8a18e130a2 | |||
| cd701a9594 | |||
| 05868ef634 | |||
| 2d37835545 | |||
| 9514a8c0e2 | |||
| 97c67b2692 | |||
| 0ad4e6eff7 | |||
| 76be4c3e12 | |||
| 84329205eb | |||
| e9d8f2bc04 | |||
| bbbb7f5723 | |||
| 5ab6daa694 | |||
| 76594f27c1 | |||
| e89b2f60eb | |||
| 63bc2bb10f | |||
| ad532b08a0 | |||
| 93093f3cf9 | |||
| fdda7144ed | |||
| 0dc414f197 | |||
| a95b518ef3 | |||
| f77fdee3e9 | |||
| a85be8e467 | |||
| 1dfcb0b2f6 | |||
| e87fd42cee | |||
| dd02e1f402 | |||
| 2271f67202 | |||
| 89aa6767f9 | |||
| 7cea893db5 | |||
| e55ff1bb28 | |||
| 890c7531d8 | |||
| e6fbcecdb9 | |||
| f01c5e210c | |||
| d294b3923c | |||
| 4c18bb80ca | |||
| 89ff326ebf | |||
| d0d9bd5bfb | |||
| 64b9d11ee6 | |||
| ca61477c3c | |||
| 68a1ec41c2 | |||
| 4873e8925d | |||
| 01d10f46ea | |||
| 73c911cae4 | |||
| 19e9828e8d | |||
| a1e79a0d69 | |||
| 313f194c63 | |||
| 55472495a4 | |||
| 9e6bd790a3 | |||
| 68b5376fb8 | |||
| 35d97ae6ac | |||
| 979e38523b | |||
| 9b0aac4b5c | |||
| 124a81e861 | |||
| 3078528b28 | |||
| 0dacb37088 | |||
| 5f687a7083 | |||
| d55cde61d3 | |||
| 3ba19f28a2 | |||
| 1fcf9e11ec | |||
| db49904c6d | |||
| a011aebef5 | |||
| d1770c3476 | |||
| baa04d2d24 | |||
| ddb93c533c | |||
| 9afcbcd307 | |||
| e9b2eacf87 | |||
| d4467a44e2 | |||
| 68a863866a | |||
| cdbdc078d6 | |||
| 204577c7ce | |||
| dd73ae2469 | |||
| 6fbab53d56 | |||
| 4a3b1ae2e3 | |||
| c7b8007ce7 | |||
| cca5008805 | |||
| f68f6bc590 | |||
| 7ab97bb1a3 | |||
| aaed4380e5 | |||
| 3d6c06bb06 | |||
| f5e5297a2a | |||
| 8fa12167af | |||
| 21be4fc620 | |||
| e707a962b6 | |||
| ef39050dbc | |||
| b7cb75a48a | |||
| 08694b4026 | |||
| 38b9f310e2 | |||
| 7b25868a19 | |||
| 47d22b6082 | |||
| c86da1a1ff |
@@ -16,8 +16,17 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
# SECURITY: builds/tests PULL-REQUEST code on the host-mode, persistent `macos-arm64` runner shared
|
||||
# with the release-signing job (release.yml, which loads the App Store Connect key). Untrusted PR
|
||||
# code could persist on it or harvest signing material. Definitive fix is server-side: enable Gitea's
|
||||
# "require approval for PRs from outside collaborators/forks", and/or isolate PR CI on ephemeral
|
||||
# runners. The `if:` is a fail-open backstop — it skips fork PRs where Gitea reports the fork flag and
|
||||
# still runs same-repo PRs (and where the flag is absent), so it never blocks internal PR CI.
|
||||
swift:
|
||||
runs-on: macos-arm64
|
||||
if: >-
|
||||
gitea.event_name != 'pull_request' ||
|
||||
gitea.event.pull_request.head.repo.fork != true
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -90,7 +90,14 @@ jobs:
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# punktfunk-client-session is the Vulkan/Skia streamer the shell execs for a connect —
|
||||
# both client binaries must ship (build-client-deb.sh installs both).
|
||||
cargo build --release --locked \
|
||||
# --features punktfunk-host/nvenc: the direct-SDK NVENC path (real RFI + recovery anchor on
|
||||
# Linux NVIDIA; design/linux-direct-nvenc.md). AMD/Intel-safe — NVENC/CUDA is dlopen'd at
|
||||
# runtime (no link-time dep; identical DT_NEEDED to a plain build), and the encoder is only
|
||||
# constructed for a CUDA capture frame + PUNKTFUNK_NVENC_DIRECT, never on VAAPI hosts.
|
||||
# --features punktfunk-host/vulkan-encode: the AMD/Intel twin — raw VK_KHR_video_encode_h265
|
||||
# with real RFI (design/linux-vulkan-video-encode.md). Pure Rust ash (no new lib / link dep);
|
||||
# default on for HEVC (PUNKTFUNK_VULKAN_ENCODE=0 → libav VAAPI), failed open falls back to VAAPI.
|
||||
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
|
||||
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session
|
||||
|
||||
- name: Build + smoke-boot web console (bun preset)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# Deploy-only: bring up the two unom-1 pieces that live in THIS repo but whose normal
|
||||
# deploys are coupled to heavy build workflows — docs to docker.yml's 5-image matrix,
|
||||
# the flatpak server to flatpak.yml's full flatpak-builder run. This workflow does
|
||||
# NEITHER build: it just (re)places the compose files and pulls the already-published
|
||||
# images, so unom/infra's deploy-all can bring a fresh unom-1 fully up in a single
|
||||
# dispatch without triggering those rebuilds.
|
||||
#
|
||||
# docs -> pulls git.unom.io/unom/punktfunk-docs:latest (built by docker.yml) and
|
||||
# brings it up on :3220.
|
||||
# flatpak -> brings up the caddy:2-alpine static server on :3230. The OSTree repo
|
||||
# CONTENT (./site) is NOT shipped here — it is regenerated by flatpak.yml
|
||||
# on the next client build, or restored from the unom-1 backup
|
||||
# (unom/infra scripts/restore-unom-1.sh, `files` tag). A fresh box serves
|
||||
# an empty repo until then; that is expected.
|
||||
#
|
||||
# Dispatched by unom/infra scripts/deploy-all.sh: `dispatch-and-wait.sh punktfunk
|
||||
# deploy-services.yml`. Uses the same secret set docker.yml/flatpak.yml already rely on:
|
||||
# DEPLOY_HOST/USER/PORT/SSH_KEY (the unom-ci-deploy key) + REGISTRY_TOKEN (docs pull).
|
||||
name: deploy-services
|
||||
run-name: ${{ gitea.actor }} deploy docs + flatpak server
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
deploy_host:
|
||||
description: "Box IP to deploy to; deploy-all passes the freshly-provisioned target IP. Blank (push) uses the DEPLOY_HOST secret."
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
docs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Sync compose file
|
||||
# SHA-pinned (receives DEPLOY_SSH_KEY): a moved tag would mean credential
|
||||
# exfiltration. v0.1.7 = 917f8b8. Bump the SHA + trailing version together.
|
||||
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
|
||||
with:
|
||||
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
port: ${{ secrets.DEPLOY_PORT }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
source: "compose.production.yml"
|
||||
target: "~/punktfunk-docs"
|
||||
overwrite: true
|
||||
|
||||
- name: Pull and start docs
|
||||
# SHA-pinned: receives DEPLOY_SSH_KEY + REGISTRY_TOKEN. v1.2.5 = 0ff4204.
|
||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
with:
|
||||
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
port: ${{ secrets.DEPLOY_PORT }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
# Token enters via env, never the script text (keeps it out of run logs).
|
||||
envs: REGISTRY_TOKEN
|
||||
script: |
|
||||
set -euo pipefail
|
||||
printf '%s' "$REGISTRY_TOKEN" | docker login git.unom.io -u enricobuehler --password-stdin
|
||||
cd ~/punktfunk-docs
|
||||
docker compose -f compose.production.yml pull docs
|
||||
docker compose -f compose.production.yml up -d --no-build docs
|
||||
|
||||
flatpak:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Sync flatpak server compose + Caddyfile
|
||||
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
|
||||
with:
|
||||
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
port: ${{ secrets.DEPLOY_PORT }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
# Land both files flat in ~/unom-flatpak/ (drop the packaging/flatpak/server/ prefix).
|
||||
source: "packaging/flatpak/server/compose.production.yml,packaging/flatpak/server/Caddyfile"
|
||||
target: "~/unom-flatpak"
|
||||
strip_components: 3
|
||||
overwrite: true
|
||||
|
||||
- name: Start flatpak static server
|
||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
with:
|
||||
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
port: ${{ secrets.DEPLOY_PORT }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
script: |
|
||||
set -euo pipefail
|
||||
# ./site (the OSTree repo) is NOT shipped by this workflow. Ensure the
|
||||
# bind-mount source exists so caddy starts; content is restored from the
|
||||
# unom-1 backup or regenerated by flatpak.yml's next publish.
|
||||
mkdir -p ~/unom-flatpak/site/repo
|
||||
cd ~/unom-flatpak
|
||||
docker compose -f compose.production.yml up -d
|
||||
@@ -59,6 +59,23 @@ jobs:
|
||||
image: fedora:43
|
||||
options: --privileged
|
||||
steps:
|
||||
# DNS fix — MUST run before any network step. fedora:43's nsswitch.conf is
|
||||
# `hosts: files myhostname resolve [!UNAVAIL=return] dns`: the `resolve`
|
||||
# module is nss-resolve (systemd-resolved), which isn't running in a CI
|
||||
# container. glibc getaddrinfo (git, curl, dnf) mostly falls through to the
|
||||
# `dns` module -> Docker's embedded 127.0.0.11 -> works. flatpak/ostree's
|
||||
# resolver does NOT fall through: the absent-daemon socket connect trips
|
||||
# `[!UNAVAIL=return]` and it reports "[6] Could not resolve hostname". This
|
||||
# was masked as an intermittent "busy runner drops DNS" and papered over
|
||||
# with retry.sh — but it's deterministic on a runner where the resolve
|
||||
# module tips that way (surfaced when jobs began landing on home-runner-2).
|
||||
# Drop the `resolve` entry so host lookups use plain `dns`. NOTE: this alone is not
|
||||
# sufficient — the Tooling step's dnf install pulls a systemd package upgrade whose RPM
|
||||
# trigger re-runs authselect and regenerates this file, undoing the fix. It's reapplied
|
||||
# there, right before the first `flatpak` network call.
|
||||
- name: Fix container DNS (drop nss-resolve — no systemd-resolved in CI)
|
||||
run: sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
|
||||
|
||||
# fedora:43 has no node, but actions/checkout (a JS action) needs it. A plain `run:` step
|
||||
# executes via the container shell (no node needed), so install node BEFORE checkout.
|
||||
- name: node for the JS actions
|
||||
@@ -72,8 +89,28 @@ jobs:
|
||||
# gnupg2/rsync/openssh-clients: sign the OSTree repo + rsync it to unom-1 (see the deploy step).
|
||||
dnf -y install flatpak flatpak-builder git python3 python3-aiohttp python3-tomlkit curl jq \
|
||||
gnupg2 rsync openssh-clients
|
||||
# Belt-and-suspenders: keep nsswitch on plain `dns` even if this dnf transaction pulled
|
||||
# in a fresh systemd-resolved (it does — flatpak recommends xdg-desktop-portal ->
|
||||
# pipewire/wireplumber -> systemd-networkd/-resolved). Verified on the real runner
|
||||
# (2026-07-11) this dnf install does NOT actually rewrite /etc/nsswitch.conf — no
|
||||
# authselect trigger fires — so this line alone was never the fix for the failures
|
||||
# below. See the retry.sh bump for the real cause.
|
||||
sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
|
||||
# Flathub provides the GNOME runtime/SDK + the rust-stable + ffmpeg-full extensions.
|
||||
flatpak remote-add --user --if-not-exists flathub \
|
||||
#
|
||||
# ROOT CAUSE (confirmed 2026-07-11 by watching a live run on home-runner-1): this is
|
||||
# NOT a deterministic nsswitch/DNS-config bug. gitea-runner-fleet on home-runner-1 is
|
||||
# a SHARED, resource-capped fleet (3 replicas, --cpus 5 --memory 7g each, on a 16c/24G
|
||||
# box) serving punktfunk AND several other orgs' repos (chatwoot, website, cms, data,
|
||||
# infra, tempblade, played...). A push to punktfunk's main fans out ~8 workflows at
|
||||
# once, and this runner box's Docker embedded DNS resolver (127.0.0.11) genuinely
|
||||
# drops UDP lookups under that concurrent load — exactly what retry.sh's own header
|
||||
# comment already documented. Reproduced: manually dispatching this job when the box
|
||||
# was idle (1 container running) succeeded immediately, with or without the sed above.
|
||||
# 5 attempts (~100s total) isn't always enough to outlast a synchronized multi-org
|
||||
# burst, so bump the bootstrap fetch's budget — never fail the job on a single-shot
|
||||
# fetch. Same treatment for every network command below.
|
||||
bash scripts/ci/retry.sh 10 flatpak remote-add --user --if-not-exists flathub \
|
||||
https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
git config --global --add safe.directory "$PWD"
|
||||
|
||||
@@ -108,7 +145,7 @@ jobs:
|
||||
# device" (see packaging/flatpak/prune-windows-lock.py). The committed Cargo.lock is
|
||||
# untouched; cargo --offline only needs sources for the crates it compiles.
|
||||
run: |
|
||||
curl -fsSL -o /tmp/flatpak-cargo-generator.py \
|
||||
curl -fsSL --retry 5 --retry-all-errors --retry-delay 5 -o /tmp/flatpak-cargo-generator.py \
|
||||
https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/flatpak-cargo-generator.py
|
||||
python3 packaging/flatpak/prune-windows-lock.py Cargo.lock /tmp/Cargo.flatpak.lock
|
||||
python3 /tmp/flatpak-cargo-generator.py /tmp/Cargo.flatpak.lock \
|
||||
@@ -142,18 +179,45 @@ jobs:
|
||||
DEST="${DEPLOY_USER}@${DEPLOY_HOST}"
|
||||
mkdir -p "$PWD/repo"
|
||||
# Pull the currently-published repo (all channels' objects + refs) into the repo the build
|
||||
# will extend. No --delete: the local repo starts empty, so this only ADDS. A missing
|
||||
# server repo (very first publish) is fine — we continue with a fresh repo.
|
||||
rsync -az --info=stats1 -e "$SSH" "$DEST:$DEPLOY_DIR/site/repo/" "$PWD/repo/" \
|
||||
|| echo "::warning::no published repo to seed (first publish?) — continuing fresh"
|
||||
# will extend. No --delete: the local repo starts empty, so this only ADDS.
|
||||
# Probe first (retried) whether a published repo exists at all: ONLY that case may
|
||||
# continue with a fresh repo. A transient network failure must FAIL the job instead —
|
||||
# a blanket `rsync || continue` here is exactly how a flaky link produces the
|
||||
# single-branch summary that clobbers the other channel (the bug described above).
|
||||
PRESENT=$(bash scripts/ci/retry.sh 5 $SSH "$DEST" \
|
||||
"[ -d $DEPLOY_DIR/site/repo/refs ] && echo present || echo absent")
|
||||
if [ "$PRESENT" = present ]; then
|
||||
bash scripts/ci/retry.sh 5 rsync -az --info=stats1 -e "$SSH" \
|
||||
"$DEST:$DEPLOY_DIR/site/repo/" "$PWD/repo/"
|
||||
else
|
||||
echo "::warning::no published repo on the server (first publish) — continuing fresh"
|
||||
fi
|
||||
echo "seeded refs:"; ls "$PWD/repo/refs/heads/app/$APP_ID/x86_64/" 2>/dev/null || echo " (none)"
|
||||
|
||||
- name: Build the flatpak (install deps from Flathub, offline build)
|
||||
- name: Prefetch deps + sources (retried — the network phase, split off the build)
|
||||
run: |
|
||||
# --install-deps-from=flathub pulls everything the manifest declares: the GNOME 50
|
||||
# runtime/SDK + the rust-stable (//25.08, rustc 1.96) and llvm20 SDK extensions, plus
|
||||
# the runtime's auto codecs-extra (HEVC libavcodec). --disable-rofiles-fuse is the
|
||||
# container-safe path (no FUSE).
|
||||
# All of the job's heavy network I/O happens HERE, retried, so a dropped DNS lookup
|
||||
# or TCP dial costs a backoff-retry instead of the whole (long) compile:
|
||||
# 1) --install-deps-only pulls everything the manifest declares from Flathub: the
|
||||
# GNOME 50 runtime/SDK + the rust-stable (//25.08, rustc 1.96) and llvm20 SDK
|
||||
# extensions, plus the runtime's auto codecs-extra (HEVC libavcodec).
|
||||
# 2) --download-only fetches every source (all crates in cargo-sources.json) into
|
||||
# the .flatpak-builder state dir. Both are resumable/idempotent, so re-running
|
||||
# after a partial failure is safe and cheap.
|
||||
# --disable-rofiles-fuse is the container-safe path (no FUSE).
|
||||
# 10 attempts (~9min budget), matching the remote-add bootstrap above — same shared,
|
||||
# load-sensitive runner, same flathub.org resolution path.
|
||||
bash scripts/ci/retry.sh 10 flatpak-builder --user --force-clean --disable-rofiles-fuse \
|
||||
--install-deps-from=flathub --install-deps-only \
|
||||
"$PWD/build-dir" "$MANIFEST"
|
||||
bash scripts/ci/retry.sh 10 flatpak-builder --user --force-clean --disable-rofiles-fuse \
|
||||
--download-only \
|
||||
"$PWD/build-dir" "$MANIFEST"
|
||||
|
||||
- name: Build the flatpak (offline — deps + sources prefetched above)
|
||||
run: |
|
||||
# Everything is already local (state dir warmed by the prefetch step), so this long
|
||||
# step needs no network; --install-deps-from stays as a no-op safety net.
|
||||
# --default-branch=$FLATPAK_BRANCH pins the ref to app/io.unom.Punktfunk/x86_64/<branch>
|
||||
# (canary or stable) so the matching hosted .flatpakref resolves deterministically
|
||||
# (manifest sets no branch).
|
||||
@@ -272,11 +336,13 @@ jobs:
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy; chmod 600 ~/.ssh/deploy
|
||||
SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=accept-new"
|
||||
DEST="${DEPLOY_USER}@${DEPLOY_HOST}"
|
||||
$SSH "$DEST" "mkdir -p ~/$DEPLOY_DIR/site/repo"
|
||||
rsync -az --info=stats1 -e "$SSH" repo/ "$DEST:$DEPLOY_DIR/site/repo/"
|
||||
rsync -az -e "$SSH" site/unom.flatpakrepo "site/${APP_ID}.flatpakref" "site/${APP_ID}.Canary.flatpakref" site/index.html "$DEST:$DEPLOY_DIR/site/"
|
||||
rsync -az -e "$SSH" packaging/flatpak/server/compose.production.yml packaging/flatpak/server/Caddyfile "$DEST:$DEPLOY_DIR/"
|
||||
$SSH "$DEST" "cd ~/$DEPLOY_DIR && docker compose -f compose.production.yml up -d"
|
||||
# All idempotent — retried because the runner's link to unom-1 drops TCP dials under
|
||||
# load (the same flake that hits docker.yml's deploy-docs with "dial tcp: i/o timeout").
|
||||
bash scripts/ci/retry.sh 5 $SSH "$DEST" "mkdir -p ~/$DEPLOY_DIR/site/repo"
|
||||
bash scripts/ci/retry.sh 5 rsync -az --info=stats1 -e "$SSH" repo/ "$DEST:$DEPLOY_DIR/site/repo/"
|
||||
bash scripts/ci/retry.sh 5 rsync -az -e "$SSH" site/unom.flatpakrepo "site/${APP_ID}.flatpakref" "site/${APP_ID}.Canary.flatpakref" site/index.html "$DEST:$DEPLOY_DIR/site/"
|
||||
bash scripts/ci/retry.sh 5 rsync -az -e "$SSH" packaging/flatpak/server/compose.production.yml packaging/flatpak/server/Caddyfile "$DEST:$DEPLOY_DIR/"
|
||||
bash scripts/ci/retry.sh 5 $SSH "$DEST" "cd ~/$DEPLOY_DIR && docker compose -f compose.production.yml up -d"
|
||||
echo "deployed → $REPO_URL/${APP_ID}.flatpakref"
|
||||
|
||||
- name: Attach bundle to the Gitea release (stable tags only)
|
||||
|
||||
@@ -30,8 +30,15 @@ on:
|
||||
# scripts/ci/ensure-windows-toolchain.ps1, a fast no-op once already present.
|
||||
|
||||
jobs:
|
||||
# SECURITY: builds PULL-REQUEST code on the host-mode, persistent `windows-amd64` runner shared with
|
||||
# the release-signing jobs (windows-host.yml / windows-msix.yml). See windows.yml for the full
|
||||
# rationale. Definitive fix is server-side (Gitea outside-collaborator approval + isolated PR
|
||||
# runners); the `if:` is a fail-open backstop that never blocks internal PR CI.
|
||||
probe-and-proto:
|
||||
runs-on: windows-amd64
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.fork != true
|
||||
timeout-minutes: 30
|
||||
defaults:
|
||||
run:
|
||||
@@ -108,6 +115,9 @@ jobs:
|
||||
# DLL's FORCE_INTEGRITY (/INTEGRITYCHECK) bit — the M0 self-signed-load question.
|
||||
driver-build:
|
||||
runs-on: windows-amd64
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.fork != true
|
||||
timeout-minutes: 45
|
||||
defaults:
|
||||
run:
|
||||
|
||||
@@ -68,8 +68,20 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
# SECURITY: this job builds PULL-REQUEST code (attacker-controllable build.rs / cargo build) on the
|
||||
# host-mode, persistent `windows-amd64` runner that the release-SIGNING jobs (windows-host.yml /
|
||||
# windows-msix.yml, which decrypt MSIX_CERT_PFX_B64 + REGISTRY_TOKEN to disk) also run on. Untrusted
|
||||
# PR code could therefore persist on that machine or harvest signing material a later job exposes.
|
||||
# The DEFINITIVE fix is operational and lives outside this file: enable Gitea's "require approval to
|
||||
# run workflows for PRs from outside collaborators/forks", and/or route PR CI to isolated ephemeral
|
||||
# runners. The `if:` below is only a backstop — it skips fork PRs where Gitea reports the fork flag,
|
||||
# and FAILS OPEN (still runs) for same-repo PRs and on Gitea versions that don't populate it, so it
|
||||
# never blocks internal PR CI.
|
||||
build:
|
||||
runs-on: windows-amd64
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.fork != true
|
||||
timeout-minutes: 90
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
+3
-1
@@ -40,4 +40,6 @@ Generated artifacts are checked in and CI fails on drift: `include/punktfunk_cor
|
||||
`api/openapi.json` (`cargo run -p punktfunk-host -- openapi`). Match the surrounding code's comment
|
||||
density and naming. Commit messages end with the `Co-Authored-By` trailer (see `git log`).
|
||||
|
||||
See [`CLAUDE.md`](CLAUDE.md) for the full build/test/run guide and design invariants.
|
||||
See the [README's Build & test section](README.md#build--test-from-source) and
|
||||
[Design invariants](README.md#design-invariants) for the full build/test/run guide, and the
|
||||
[docs site](https://docs.punktfunk.unom.io) for architecture and per-platform guides.
|
||||
|
||||
Generated
+14
-28
@@ -870,15 +870,6 @@ dependencies = [
|
||||
"itertools 0.10.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.6"
|
||||
@@ -2154,7 +2145,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.9.0"
|
||||
version = "0.10.1"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2286,7 +2277,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.9.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2765,7 +2756,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.9.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -2787,7 +2778,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.9.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2808,7 +2799,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.9.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
@@ -2817,7 +2808,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.9.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3001,7 +2992,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.9.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3017,7 +3008,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.9.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3033,7 +3024,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.9.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3048,22 +3039,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.9.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
"crossbeam-channel",
|
||||
"ffmpeg-next",
|
||||
"mdns-sd",
|
||||
"opus",
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
"sdl3",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"wasapi",
|
||||
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
"windows-reactor",
|
||||
"windows-reactor-setup",
|
||||
@@ -3072,7 +3058,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.9.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3103,7 +3089,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.9.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3175,7 +3161,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.9.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3189,7 +3175,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.9.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
|
||||
+3
-2
@@ -26,7 +26,8 @@ exclude = [
|
||||
"clients/android/native/vendor/ndk",
|
||||
]
|
||||
|
||||
# ndk 0.9.0 verbatim from crates.io plus ONE visibility change: `MediaCodec::as_ptr` made public
|
||||
# ndk 0.9.0 verbatim from crates.io plus ONE visibility change (and two warning fixes — an
|
||||
# unnecessary `std::` qualification and a feature-gated `Result` import): `MediaCodec::as_ptr` made public
|
||||
# (upstream keeps it private and exposes no frame-rendered binding), so the Android client can
|
||||
# call `AMediaCodec_setOnFrameRenderedCallback` via ndk-sys for the HUD's `display` stage
|
||||
# (design/stats-unification.md). Drop the patch when upstream exposes the pointer or the callback.
|
||||
@@ -34,7 +35,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.9.0"
|
||||
version = "0.10.1"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -22,7 +22,8 @@ punktfunk pairs a **virtual-display streaming host** with native clients on ever
|
||||
the existing **GameStream** protocol, so any [Moonlight](https://moonlight-stream.org/) client works
|
||||
day one — and adds its own faster **`punktfunk/1`** protocol that breaks the ~1 Gbps FEC wall with a
|
||||
**GF(2¹⁶) Leopard-RS** transport. A single shared **Rust core** (`punktfunk-core`) holds the
|
||||
protocol, FEC, and crypto, linked into the host and every client over a stable C ABI.
|
||||
protocol, FEC, and crypto, linked into the host and every native client — directly as a Rust crate
|
||||
on Linux and Windows, and over a stable C ABI from the Apple and Android apps.
|
||||
|
||||
## What makes it different
|
||||
|
||||
@@ -58,12 +59,16 @@ protocol, FEC, and crypto, linked into the host and every client over a stable C
|
||||
| **GameStream host** → stock Moonlight | ✅ Live end-to-end: pairing, RTSP, audio, per-client virtual output at native resolution, GPU zero-copy NVENC, gamepads |
|
||||
| **Native protocol** — `punktfunk/1` | ✅ Validated live: QUIC control + GF(2¹⁶) FEC/AES-GCM data plane, PIN pairing, mDNS discovery, mid-stream mode renegotiation |
|
||||
| **Windows host** (Windows 11 22H2+, x64) | 🟡 Implemented & shipping as a signed installer: its own all-Rust IddCx **virtual display** (secure-desktop capable) with a **sealed IDD-push** capture path — finished frames pushed straight into its own driver, not screen-scraped (no DDA/WGC) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel, software H.264 without a GPU) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green |
|
||||
| **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode, controllers incl. DualSense, discovery, pairing, speed test |
|
||||
| **Linux client** (`clients/linux`, GTK4) | ✅ Streaming live: FFmpeg + VAAPI zero-copy decode, PipeWire audio, SDL3 controllers; ships as Flatpak/apt/rpm/Arch |
|
||||
| **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode (HEVC, and AV1 on hardware that decodes it), controllers incl. DualSense, discovery, pairing, speed test |
|
||||
| **Linux client** (`clients/linux` + `clients/session`) | ✅ Streaming live: relm4/GTK4 launcher shell that spawns a Vulkan session binary — Vulkan Video / VAAPI / software decode, PipeWire audio, SDL3 controllers, Skia console UI; ships as Flatpak/apt/rpm/Arch |
|
||||
| **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, AAudio audio, controllers, discovery, pairing |
|
||||
| **Windows client** (`clients/windows`, WinUI 3) | ✅ Streaming live: D3D11VA hardware decode on all GPU vendors (NVIDIA + Intel validated on glass) with software fallback, WASAPI audio, SDL3 controllers, discovery, pairing; ships as signed MSIX (x64 + ARM64). HDR10 implemented, on-glass validation pending |
|
||||
| **Windows client** (`clients/windows`, WinUI 3) | ✅ Streaming live: WinUI 3 shell + Vulkan session presenter, hardware decode on all GPU vendors via Vulkan Video → D3D11VA → software (NVIDIA + Intel validated on glass), WASAPI audio, SDL3 controllers, discovery, pairing; ships as signed MSIX (x64 + ARM64). HDR10 implemented, on-glass validation pending |
|
||||
| **Web console + management API** (`web/`) | ✅ TanStack console over the OpenAPI mgmt API: host status, paired devices, on-demand PIN pairing, GPU selection, performance capture graphs, live host logs |
|
||||
|
||||
Every native client also ships a tiered **stats overlay** (Compact / Normal / Detailed) with a
|
||||
shared vocabulary across platforms, and the session client carries a full gamepad-driven **console
|
||||
shell** (`pf-console-ui`): host list, PIN pairing, settings, and an on-screen keyboard.
|
||||
|
||||
The **GameStream host works with a stock Moonlight client** — validated live on NVIDIA hardware
|
||||
(RTX 5070 Ti, RTX 4090): PIN pairing that persists across restarts, an app catalog, RTSP/ENet/audio,
|
||||
and **video at the client's exact resolution and refresh** via a per-session virtual output (KWin,
|
||||
@@ -117,7 +122,7 @@ Each client discovers hosts on the network automatically and does a one-time
|
||||
For development, or as an install fallback where no package is available:
|
||||
|
||||
```sh
|
||||
cargo build --workspace # the Rust core, host, Linux client, and probe (Linux & macOS)
|
||||
cargo build --workspace # core, host, tray, shared client crates, Linux shell + session client, probe (Linux & macOS)
|
||||
cargo test --workspace # unit + loopback + proptest + C ABI harness
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo fmt --all --check
|
||||
@@ -137,18 +142,27 @@ and the [docs site](https://docs.punktfunk.unom.io).
|
||||
crates/
|
||||
punktfunk-core/ protocol · FEC · pacing · crypto · QUIC control plane — the C ABI (lib + cdylib + staticlib)
|
||||
punktfunk-host/ the host (Linux + Windows): virtual displays · capture · encode · input · GameStream · punktfunk/1 · mgmt
|
||||
pf-client-core/ shared client plumbing (Linux + Windows): session pump · FFmpeg decode · audio · SDL3 gamepads · trust · discovery
|
||||
pf-presenter/ Vulkan session presenter: SDL3 window · ash swapchain · frame present · input capture
|
||||
pf-console-ui/ Skia console UI for the session client: gamepad shell · stats OSD · pairing · on-screen keyboard
|
||||
pf-ffvk/ FFmpeg Vulkan hwcontext bindings (AVVkFrame) for Vulkan Video decode on the presenter's device
|
||||
pf-driver-proto/ host ↔ pf-vdisplay driver contract: control IOCTLs + IDD-push frame transport (no_std)
|
||||
punktfunk-tray/ host tray icon (Windows notification area / Linux StatusNotifierItem)
|
||||
clients/
|
||||
apple/ macOS / iOS / tvOS app (Swift · VideoToolbox · Metal · GameController)
|
||||
linux/ Linux desktop app (Rust · GTK4/libadwaita · FFmpeg/VAAPI · PipeWire · SDL3)
|
||||
linux/ Linux launcher shell (Rust · relm4 / GTK4 / libadwaita) — spawns the session client to stream
|
||||
session/ punktfunk-session, the Vulkan streaming session (Rust · SDL3 · ash · Skia console UI) — also runs standalone (gamescope, Decky)
|
||||
windows/ Windows desktop app (Rust · WinUI 3 · D3D11 · WASAPI · SDL3)
|
||||
android/ Android phone + TV app (Kotlin · Rust JNI core · AMediaCodec · AAudio)
|
||||
probe/ headless reference / measurement client for punktfunk/1
|
||||
decky/ Steam Deck Decky plugin
|
||||
web/ web console (TanStack) over the management API — status · devices · pairing · GPUs · performance · logs
|
||||
api/openapi.json management-API OpenAPI spec (regenerated via `punktfunk-host openapi`, checked in)
|
||||
packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite bootc image
|
||||
docs-site/ public documentation site (Fumadocs) — https://docs.punktfunk.unom.io
|
||||
include/punktfunk_core.h cbindgen-generated C header (checked in)
|
||||
tools/ latency-probe · loss-harness (measurement)
|
||||
ci/ CI container images (rust-ci · fedora-rpm)
|
||||
```
|
||||
|
||||
## Design invariants
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ exposes other users before a fix exists.
|
||||
|
||||
The more of this you can give us, the faster we can act:
|
||||
|
||||
- The component and version (e.g. `punktfunk-host 0.6.0`, Windows or Linux, which client).
|
||||
- The component and version (e.g. `punktfunk-host 0.9.0`, Windows or Linux, which client).
|
||||
- The impact — what an attacker can do, and from what position (same LAN, a local service account,
|
||||
admin, a paired client, …).
|
||||
- Steps to reproduce, a proof-of-concept, or a crash/log if you have one.
|
||||
|
||||
+9
-1
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.9.0"
|
||||
"version": "0.9.1"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -2517,6 +2517,10 @@
|
||||
"type": "object",
|
||||
"description": "The user-facing display-management policy — what `display-settings.json` holds and what the mgmt\nAPI GETs/PUTs. When [`preset`](Self::preset) is not [`Preset::Custom`] the explicit fields are\nignored (the console writes one or the other); [`effective`](Self::effective) resolves both to a\nsingle [`EffectivePolicy`].",
|
||||
"properties": {
|
||||
"ddc_power_off": {
|
||||
"type": "boolean",
|
||||
"description": "EXPERIMENTAL (Windows): command physical monitors' panels off over DDC/CI (VCP 0xD6 →\nDPMS off) right before an `Exclusive` isolate deactivates them, and back on at restore.\nTargets the \"connected-but-dark head\" periodic-stutter class (monitor standby\nauto-input-scan / DP link churn while the virtual display is the sole active display) at\nthe monitor-firmware level. Best-effort — monitors without DDC/CI (or with it disabled in\nthe OSD) are skipped. Orthogonal to `preset` (like `game_session`): preserved across\npreset changes; `#[serde(default)]` = off so existing `display-settings.json` files are\nuntouched."
|
||||
},
|
||||
"game_session": {
|
||||
"$ref": "#/components/schemas/GameSession",
|
||||
"description": "How a game-launching session is served (`design/gamemode-and-dedicated-sessions.md` §5.2).\nOrthogonal to `preset`/lifecycle — preserved across preset changes; `#[serde(default)]` = `Auto`\nso existing `display-settings.json` files are untouched."
|
||||
@@ -2539,6 +2543,10 @@
|
||||
"mode_conflict": {
|
||||
"$ref": "#/components/schemas/ModeConflict"
|
||||
},
|
||||
"pnp_disable_monitors": {
|
||||
"type": "boolean",
|
||||
"description": "EXPERIMENTAL (Windows): after an `Exclusive` isolate deactivates the physical monitors,\nadditionally DISABLE their PnP device nodes (persistently, so a standby monitor/TV whose\nhot-plug events re-arrive stays disabled) and re-enable them at restore. Targets the same\n\"connected-but-dark head\" periodic-stutter class as [`Self::ddc_power_off`], but at the\nWindows-reaction level: a disabled devnode's wake events trigger no PnP arrival, no CCD\nre-evaluation, no DWM invalidation. A crash-recovery journal re-enables leftovers on host\nstartup. Orthogonal to `preset` (like `game_session`); `#[serde(default)]` = off."
|
||||
},
|
||||
"preset": {
|
||||
"$ref": "#/components/schemas/Preset"
|
||||
},
|
||||
|
||||
@@ -58,7 +58,7 @@ kit/ :kit — NativeBridge · native mDNS discovery · Gamepad · K
|
||||
`build-tools;37.0.0`, **`cmake;3.22.1`** (builds libopus); **JDK 21** (AGP 9.2 runs on JDK 17–21, not
|
||||
a newer default); Rust with `rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android` and
|
||||
`cargo install cargo-ndk`. Toolchain is pinned (AGP 9.2 · Gradle 9.4.1 · Kotlin 2.3.21 · Compose BOM
|
||||
2026.05.01 · compileSdk 37 · minSdk 31).
|
||||
2026.05.01 · compileSdk 37 · minSdk 28).
|
||||
|
||||
**Android Studio:** open `clients/android` — it uses its bundled JBR 21, and the `cargoNdk*` task
|
||||
builds the `.so` as part of the normal build.
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bedtime
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
|
||||
/**
|
||||
* Which phase of the connect flow to draw — the pure view model [ConnectOverlay] resolves from the
|
||||
* live dial/wake state, so [ConnectTakeover] / [ConnectModal] can render (and be screenshot-tested)
|
||||
* statelessly.
|
||||
*/
|
||||
internal sealed interface ConnectPhase {
|
||||
val hostName: String
|
||||
|
||||
/** The dial is in flight (shown the instant a host is picked). */
|
||||
data class Connecting(override val hostName: String) : ConnectPhase
|
||||
|
||||
/** A sleeping host is being Wake-on-LAN'd and we're waiting for it to advertise again. */
|
||||
data class Waking(override val hostName: String, val seconds: Int, val connectsAfter: Boolean) : ConnectPhase
|
||||
|
||||
/** The wake wait ran out — offer retry / cancel. */
|
||||
data class WakeTimedOut(override val hostName: String) : ConnectPhase
|
||||
}
|
||||
|
||||
/** Per-phase copy, shared by the console takeover and the touch modal so both read identically. */
|
||||
private data class ConnectCopy(
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
/** Monospace the subtitle so a ticking seconds counter doesn't jitter its width. */
|
||||
val monoSubtitle: Boolean,
|
||||
val cancelLabel: String,
|
||||
)
|
||||
|
||||
private fun connectCopy(phase: ConnectPhase): ConnectCopy = when (phase) {
|
||||
is ConnectPhase.Connecting -> ConnectCopy(
|
||||
"Connecting to ${phase.hostName}", "Establishing a secure connection…", false, "Cancel",
|
||||
)
|
||||
is ConnectPhase.Waking -> ConnectCopy(
|
||||
"Waking ${phase.hostName}…",
|
||||
"Waiting for it to come online · ${phase.seconds}s",
|
||||
true,
|
||||
// A wake-only wait (no dial after) says "Stop Waiting"; a wake that will connect says "Cancel".
|
||||
if (!phase.connectsAfter) "Stop Waiting" else "Cancel",
|
||||
)
|
||||
is ConnectPhase.WakeTimedOut -> ConnectCopy(
|
||||
"${phase.hostName} didn't wake",
|
||||
"It may still be booting, or it's powered off / off this network.",
|
||||
false,
|
||||
"Cancel",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The unified "getting you connected" feedback — one flow for BOTH phases of reaching a host, so the
|
||||
* user gets feedback the instant they pick one and it flows seamlessly into a wake if the host turns
|
||||
* out to be asleep:
|
||||
*
|
||||
* - **Connecting** ([connectingHostName] non-null): the dial is in flight. Shown immediately on tap,
|
||||
* so a host that takes a beat to answer no longer looks like nothing happened.
|
||||
* - **Waking** ([WakeController.waking] non-null): the dial failed on a sleeping host, so we're firing
|
||||
* Wake-on-LAN and waiting for it to advertise again, escalating to a retry/cancel prompt on timeout.
|
||||
*
|
||||
* Presentation is mode-aware (mirrors the Apple client): in the **console / gamepad** UI it's a
|
||||
* full-screen aurora [ConnectTakeover] — the same signature backdrop the console home uses, driven by
|
||||
* the pad (B cancels, A retries once timed out) with a hint bar. In the **default touch** UI it's a
|
||||
* Material [ConnectModal] over the host grid, matching the app's other dialogs — the aurora takeover
|
||||
* looked out of place there.
|
||||
*
|
||||
* The two phases hand off within a single Compose frame (see ConnectScreen's `doConnectDirect` →
|
||||
* `waker.start` → redial), so nothing blinks between them.
|
||||
*/
|
||||
@Composable
|
||||
fun ConnectOverlay(
|
||||
connectingHostName: String?,
|
||||
waker: WakeController,
|
||||
gamepadUi: Boolean,
|
||||
onCancelConnect: () -> Unit,
|
||||
) {
|
||||
val waking = waker.waking
|
||||
// Waking takes precedence (it only exists after a dial has failed) so a stray overlap can't strand
|
||||
// the "Connecting…" phase over a wake in progress.
|
||||
val phase = when {
|
||||
waking != null && waking.timedOut -> ConnectPhase.WakeTimedOut(waking.hostName)
|
||||
waking != null -> ConnectPhase.Waking(waking.hostName, waking.seconds, waking.connectsAfter)
|
||||
connectingHostName != null -> ConnectPhase.Connecting(connectingHostName)
|
||||
else -> return
|
||||
}
|
||||
|
||||
// System Back / pad B (remapped) cancels whatever's in flight — a plain dial or the wake wait.
|
||||
val cancel = { if (waking != null) waker.cancel() else onCancelConnect() }
|
||||
|
||||
if (gamepadUi) {
|
||||
BackHandler { cancel() }
|
||||
// A retries once a wake has timed out; B falls through to the BackHandler above.
|
||||
GamepadNavEffect2D(
|
||||
active = true,
|
||||
onDirection = {},
|
||||
onActivate = { if (phase is ConnectPhase.WakeTimedOut) waker.retry() },
|
||||
)
|
||||
ConnectTakeover(phase = phase, onCancel = cancel, onRetry = { waker.retry() })
|
||||
} else {
|
||||
// The AlertDialog owns its own scrim + system-Back handling (routed to cancel).
|
||||
ConnectModal(phase = phase, onCancel = cancel, onRetry = { waker.retry() })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The default-UI presentation: a Material dialog over the host grid, matching the app's other touch
|
||||
* dialogs. A spinner (or the sleep glyph once timed out) sits above the title; the scrim is inert so a
|
||||
* stray tap can't drop a connect in flight — only the buttons or system Back cancel.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConnectModal(
|
||||
phase: ConnectPhase,
|
||||
onCancel: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
val copy = connectCopy(phase)
|
||||
val timedOut = phase is ConnectPhase.WakeTimedOut
|
||||
AlertDialog(
|
||||
onDismissRequest = onCancel,
|
||||
properties = DialogProperties(dismissOnClickOutside = false),
|
||||
icon = {
|
||||
if (timedOut) {
|
||||
Icon(Icons.Filled.Bedtime, contentDescription = null)
|
||||
} else {
|
||||
CircularProgressIndicator(modifier = Modifier.size(28.dp), strokeWidth = 3.dp)
|
||||
}
|
||||
},
|
||||
title = { Text(copy.title, textAlign = TextAlign.Center) },
|
||||
text = {
|
||||
Text(
|
||||
copy.subtitle,
|
||||
textAlign = TextAlign.Center,
|
||||
fontFamily = if (copy.monoSubtitle) FontFamily.Monospace else FontFamily.Default,
|
||||
)
|
||||
},
|
||||
// No confirm action until the wake times out; then "Try Again" is the primary button.
|
||||
confirmButton = {
|
||||
if (timedOut) TextButton(onClick = onRetry) { Text("Try Again") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onCancel) { Text(copy.cancelLabel) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The console / gamepad presentation: an opaque aurora backdrop with a centred spinner/title/subtitle
|
||||
* for [phase], plus a bottom hint bar spelling out the pad actions (B cancels, A retries once timed
|
||||
* out) — glyph-driven like every other console screen. onClick keeps the hints tappable too, so a
|
||||
* user without a working pad can still get out.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConnectTakeover(
|
||||
phase: ConnectPhase,
|
||||
onCancel: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
val copy = connectCopy(phase)
|
||||
val timedOut = phase is ConnectPhase.WakeTimedOut
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
// Swallow taps so the screen behind can't be touched through the takeover.
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
GamepadAuroraBackground(Modifier.fillMaxSize())
|
||||
Column(
|
||||
Modifier.padding(horizontal = 40.dp).widthIn(max = 460.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
if (timedOut) {
|
||||
Box(Modifier.size(120.dp), contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
Icons.Filled.Bedtime,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.9f),
|
||||
modifier = Modifier.size(46.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
PulsingSpinner()
|
||||
}
|
||||
Text(
|
||||
copy.title,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 24.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
copy.subtitle,
|
||||
color = Color.White.copy(alpha = 0.65f),
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
fontFamily = if (copy.monoSubtitle) FontFamily.Monospace else FontFamily.Default,
|
||||
)
|
||||
}
|
||||
val hints = buildList {
|
||||
add(PadGlyph.hint('B', copy.cancelLabel, onClick = onCancel))
|
||||
if (timedOut) add(PadGlyph.hint('A', "Try Again", onClick = onRetry))
|
||||
}
|
||||
GamepadHintBar(hints, Modifier.align(Alignment.BottomCenter).padding(bottom = 28.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The connecting/waking indicator: a white progress ring inside two brand-violet halo rings that
|
||||
* expand and fade on a staggered loop — a small sign of life so the takeover reads as working, not
|
||||
* stalled.
|
||||
*/
|
||||
@Composable
|
||||
private fun PulsingSpinner() {
|
||||
val transition = rememberInfiniteTransition(label = "connectPulse")
|
||||
val pulse by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(tween(1600, easing = LinearEasing), RepeatMode.Restart),
|
||||
label = "pulse",
|
||||
)
|
||||
Box(Modifier.size(120.dp), contentAlignment = Alignment.Center) {
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
val maxR = size.minDimension / 2f
|
||||
for (i in 0..1) {
|
||||
val p = (pulse + i * 0.5f) % 1f
|
||||
drawCircle(
|
||||
color = Color(0xFF8678F5).copy(alpha = (1f - p) * 0.35f),
|
||||
radius = maxR * (0.42f + p * 0.58f),
|
||||
style = Stroke(width = 2.dp.toPx()),
|
||||
)
|
||||
}
|
||||
}
|
||||
CircularProgressIndicator(
|
||||
color = Color.White,
|
||||
strokeWidth = 3.dp,
|
||||
modifier = Modifier.size(54.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,16 @@ private class RequestAccessState(val target: PendingTrust) {
|
||||
val cancelled = AtomicBoolean(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* A plain dial in flight — [hostName] labels the unified [ConnectOverlay]'s "Connecting…" phase, and
|
||||
* [cancelled] lets its Cancel abort. The native connect is a blocking call with no abort, so Cancel
|
||||
* returns the UI immediately and a late-arriving handle is torn down silently rather than navigating
|
||||
* into a session the user already backed out of. Mirrors [RequestAccessState]'s late-result handling.
|
||||
*/
|
||||
private class ConnectAttempt(val hostName: String) {
|
||||
val cancelled = AtomicBoolean(false)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConnectScreen(
|
||||
settings: Settings,
|
||||
@@ -107,6 +117,9 @@ fun ConnectScreen(
|
||||
var port by remember { mutableStateOf("9777") }
|
||||
var connecting by remember { mutableStateOf(false) }
|
||||
var status by remember { mutableStateOf<String?>(null) }
|
||||
// A plain dial in flight (drives the "Connecting…" phase of the full-screen ConnectOverlay); null
|
||||
// when idle or when the request-access / wake flows own the screen instead.
|
||||
var attempt by remember { mutableStateOf<ConnectAttempt?>(null) }
|
||||
// The host streams at exactly this mode; "Native" settings resolve from the device display.
|
||||
val (w, h, hz) = settings.effectiveMode(context)
|
||||
|
||||
@@ -267,11 +280,20 @@ fun ConnectScreen(
|
||||
status = "Identity not ready yet — try again in a moment"
|
||||
return
|
||||
}
|
||||
val thisAttempt = ConnectAttempt(name)
|
||||
attempt = thisAttempt // shows the ConnectOverlay's "Connecting…" phase immediately
|
||||
connecting = true
|
||||
status = "Connecting to $targetHost:$targetPort…"
|
||||
status = null
|
||||
discovery.stop() // free the Wi-Fi radio before the stream session
|
||||
scope.launch {
|
||||
val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS)
|
||||
// Cancelled mid-dial: the UI's already been returned (and discovery restarted) by
|
||||
// cancelConnect — drop the just-opened session silently rather than navigating into it.
|
||||
if (thisAttempt.cancelled.get()) {
|
||||
if (handle != 0L) withContext(Dispatchers.IO) { NativeBridge.nativeClose(handle) }
|
||||
return@launch
|
||||
}
|
||||
attempt = null
|
||||
connecting = false
|
||||
if (handle != 0L) {
|
||||
if (pinHex == null) { // TOFU: pin what we observed (unpaired)
|
||||
@@ -284,7 +306,9 @@ fun ConnectScreen(
|
||||
} else {
|
||||
discovery.start()
|
||||
if (onFailure != null) {
|
||||
status = ""
|
||||
// Hand off to the wake-and-wait flow — clearing `attempt` above and setting
|
||||
// `waker.waking` here land in one recompose, so the overlay slides
|
||||
// Connecting → Waking without a blank frame.
|
||||
onFailure()
|
||||
} else {
|
||||
status = "Connection failed — check host/port, PIN, and logcat"
|
||||
@@ -293,6 +317,16 @@ fun ConnectScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel a plain dial in flight (the overlay's "Connecting…" phase, B / Cancel). The native
|
||||
// connect can't be aborted, so flag this attempt (a late handle is closed silently in
|
||||
// doConnectDirect) and return the UI now, resuming the discovery we paused for the dial.
|
||||
fun cancelConnect() {
|
||||
attempt?.cancelled?.set(true)
|
||||
attempt = null
|
||||
connecting = false
|
||||
discovery.start()
|
||||
}
|
||||
|
||||
// Wake-aware connect. If auto-wake is on (Settings.autoWakeEnabled) and the target is a saved
|
||||
// host with a learned MAC that ISN'T currently advertising, fire a wake packet and DIAL
|
||||
// IMMEDIATELY — mDNS absence does NOT mean unreachable (a host reached over a routed network —
|
||||
@@ -506,40 +540,21 @@ fun ConnectScreen(
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
status?.let {
|
||||
// While connecting it's progress (spinner, neutral); otherwise it's a
|
||||
// result/error (red). Previously every status showed in error-red, so a
|
||||
// normal "Connecting…" looked like a failure.
|
||||
if (connecting) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Result/error: a filled error container reads as a real failure banner,
|
||||
// not just red text lost in the layout.
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
// In-flight progress (connecting / waking) is the full-screen ConnectOverlay's
|
||||
// job now, so `status` only ever carries a result/error here — a filled error
|
||||
// container reads as a real failure banner, not just red text lost in the layout.
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
@@ -837,8 +852,15 @@ fun ConnectScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Topmost: the "Waking…" overlay rides over both the touch grid and the console home.
|
||||
WakeOverlay(waker, gamepadUi)
|
||||
// Topmost: the full-screen connect takeover — instant "Connecting…" feedback on any dial, flowing
|
||||
// seamlessly into the "Waking…" wait if the host turns out to be asleep. Rides over both the touch
|
||||
// grid and the console home.
|
||||
ConnectOverlay(
|
||||
connectingHostName = attempt?.hostName,
|
||||
waker = waker,
|
||||
gamepadUi = gamepadUi,
|
||||
onCancelConnect = { cancelConnect() },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -158,8 +158,11 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
pads.forEachIndexed { i, dev ->
|
||||
PadRow(dev, forwarded = i == 0, gamepadSetting = gamepadSetting)
|
||||
// Every real controller is forwarded now (Automatic forwards them all, each on its own
|
||||
// wire pad index) — not just the first. A joystick-only device Android doesn't classify as
|
||||
// a gamepad still can't be forwarded (the host wants a gamepad), so gate the badge on it.
|
||||
pads.forEach { dev ->
|
||||
PadRow(dev, forwarded = isForwarded(dev), gamepadSetting = gamepadSetting)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,8 +225,12 @@ private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(dev.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||
if (forwarded) {
|
||||
// Android's own controller number (1-based; 0 = unassigned), shown so a multi-pad
|
||||
// user can tell which physical pad is which. The stream's wire pad index is
|
||||
// assigned separately (lowest-free per device) once streaming starts.
|
||||
val number = dev.controllerNumber
|
||||
Text(
|
||||
"forwarded to host",
|
||||
if (number > 0) "forwarded · player $number" else "forwarded to host",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
@@ -319,6 +326,15 @@ private fun Group(title: String, content: @Composable ColumnScope.() -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this device is actually forwarded to the host — the same rule the stream's [GamepadRouter]
|
||||
* applies: a real, non-virtual controller whose source classes include GAMEPAD. A joystick-only node
|
||||
* (e.g. a DualSense motion-sensor sibling, or an adapter that enumerates as bare joystick) shows in
|
||||
* the list but isn't forwarded.
|
||||
*/
|
||||
private fun isForwarded(dev: InputDevice): Boolean =
|
||||
!dev.isVirtual && dev.sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD
|
||||
|
||||
/** Whether the controller reports a rumble motor — via VibratorManager (API 31+) or the legacy Vibrator. */
|
||||
private fun deviceHasVibrator(dev: InputDevice): Boolean =
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
|
||||
@@ -241,7 +241,10 @@ private fun resolveDir(s: NavInputState): NavDir? {
|
||||
if (s.hatY >= 0.5f) return NavDir.DOWN
|
||||
if (s.hatX <= -0.5f) return NavDir.LEFT
|
||||
if (s.hatX >= 0.5f) return NavDir.RIGHT
|
||||
return if (abs(s.stickY) >= abs(s.stickX)) {
|
||||
// Horizontal wins an exact |x| == |y| diagonal tie (Y must be strictly greater to take the
|
||||
// vertical branch), matching the SDL core and Apple nav so a perfect 45° push resolves the
|
||||
// same on every client.
|
||||
return if (abs(s.stickY) > abs(s.stickX)) {
|
||||
when {
|
||||
s.stickY <= -STICK_HIGH -> NavDir.UP
|
||||
s.stickY >= STICK_HIGH -> NavDir.DOWN
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.GamepadRouter
|
||||
import io.unom.punktfunk.kit.Keymap
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
|
||||
@@ -27,8 +28,12 @@ class MainActivity : ComponentActivity() {
|
||||
*/
|
||||
var streamHandle: Long = 0L
|
||||
|
||||
/** Joystick-axis state mapper for the active session (built/reset by StreamScreen). */
|
||||
var axisMapper: Gamepad.AxisMapper? = null
|
||||
/**
|
||||
* Multi-controller router for the active session (built/released by StreamScreen): assigns each
|
||||
* connected pad a stable wire index, threads it onto every event, declares/removes pads on
|
||||
* hot-plug, and routes rumble/HID feedback back by pad index. Null while not streaming.
|
||||
*/
|
||||
var gamepadRouter: GamepadRouter? = null
|
||||
|
||||
/**
|
||||
* Input observers for the Controllers debug screen (set while it is shown, like [streamHandle]).
|
||||
@@ -44,9 +49,6 @@ class MainActivity : ComponentActivity() {
|
||||
*/
|
||||
var requestStreamExit: (() -> Unit)? = null
|
||||
|
||||
/** Currently-held forwarded pad buttons (bitmask of `Gamepad.BTN_*`), for chord detection. */
|
||||
private var heldPadButtons = 0
|
||||
|
||||
/**
|
||||
* Whether the last console input came from a real gamepad (face buttons / stick) vs. a TV D-pad
|
||||
* remote (which has no A/B/X/Y). The console UI reads this to show glyphs the user recognises — pad
|
||||
@@ -125,23 +127,12 @@ class MainActivity : ComponentActivity() {
|
||||
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||
val bit = Gamepad.buttonBit(event.keyCode)
|
||||
if (bit != 0) {
|
||||
when (event.action) {
|
||||
// repeatCount guard: don't re-send a held button as auto-repeat.
|
||||
KeyEvent.ACTION_DOWN -> {
|
||||
if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true)
|
||||
heldPadButtons = heldPadButtons or bit
|
||||
// Emergency exit: Select + Start + L1 + R1 held together leaves the stream
|
||||
// (a couch user has no keyboard/Back). Fired once per full chord.
|
||||
if (heldPadButtons and STREAM_EXIT_CHORD == STREAM_EXIT_CHORD) {
|
||||
heldPadButtons = 0
|
||||
requestStreamExit?.let { exit -> window.decorView.post { exit() } }
|
||||
}
|
||||
}
|
||||
KeyEvent.ACTION_UP -> {
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, false)
|
||||
heldPadButtons = heldPadButtons and bit.inv()
|
||||
}
|
||||
}
|
||||
// The router forwards the bit on this device's own wire pad index and tracks held
|
||||
// state per pad. The emergency-exit chord (Select + Start + L1 + R1) is handled
|
||||
// inside the router: holding it for ~1.5 s fires router.onExitChord (wired in
|
||||
// StreamScreen), so a couch user with no keyboard/Back can still leave — but an
|
||||
// accidental brush of the four buttons no longer quits instantly.
|
||||
gamepadRouter?.onButton(event, bit)
|
||||
return true // consumed
|
||||
}
|
||||
}
|
||||
@@ -203,7 +194,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
|
||||
if (streamHandle != 0L) {
|
||||
if (axisMapper?.onMotion(event) == true) return true
|
||||
if (gamepadRouter?.onMotion(event) == true) return true
|
||||
return super.dispatchGenericMotionEvent(event)
|
||||
}
|
||||
// The Controllers debug screen sees pad motion before the stick→D-pad synthesis below.
|
||||
@@ -248,9 +239,4 @@ class MainActivity : ComponentActivity() {
|
||||
-> true
|
||||
else -> KeyEvent.isGamepadButton(kc)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Emergency stream-exit chord: Select + Start + L1 + R1 held together. */
|
||||
val STREAM_EXIT_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_START or Gamepad.BTN_LB or Gamepad.BTN_RB
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,8 +32,8 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.GamepadFeedback
|
||||
import io.unom.punktfunk.kit.GamepadRouter
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.VideoDecoders
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
@@ -174,18 +174,30 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
val priorOrientation = activity?.requestedOrientation
|
||||
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
activity?.streamHandle = handle // route hardware keys to this session
|
||||
activity?.axisMapper = Gamepad.AxisMapper(handle) // route joystick axes
|
||||
// Multi-controller router: a stable wire pad index per connected controller, per-device axis
|
||||
// state, Arrival/Remove on hot-plug, and feedback routed back by pad index. Forwards every
|
||||
// controller (Automatic). Built here, released on dispose.
|
||||
val router = GamepadRouter(context, handle, initialSettings.gamepad)
|
||||
activity?.gamepadRouter = router
|
||||
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips
|
||||
// the keep-alive linger), unlike a host-ended / backgrounded drop.
|
||||
// the 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() }
|
||||
router.onExitChord = { activity?.requestStreamExit?.invoke() }
|
||||
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
|
||||
// Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close.
|
||||
val feedback = GamepadFeedback(handle).also { it.start() }
|
||||
// Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad
|
||||
// index via the router; poll threads stopped + joined before the router is released and the
|
||||
// session closed.
|
||||
val feedback = GamepadFeedback(handle, router).also { it.start() }
|
||||
// Free a disconnected controller's rumble/lights bindings promptly (else the open lights
|
||||
// session leaks until the session ends). The router owns hot-plug; the feedback owns the binds.
|
||||
router.onSlotClosed = feedback::onDeviceRemoved
|
||||
onDispose {
|
||||
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it
|
||||
feedback.stop() // stop + join the poll threads BEFORE nativeClose frees the handle
|
||||
activity?.axisMapper?.reset() // release-all so nothing sticks on the host
|
||||
activity?.axisMapper = null
|
||||
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
|
||||
router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener
|
||||
activity?.gamepadRouter = null
|
||||
activity?.streamHandle = 0L
|
||||
activity?.requestStreamExit = null
|
||||
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
|
||||
|
||||
@@ -26,7 +26,7 @@ import kotlinx.coroutines.launch
|
||||
* [isOnline]/[onOnline] callbacks all run on the main thread; only the blocking send is off-loaded.
|
||||
*/
|
||||
class WakeController(private val scope: CoroutineScope) {
|
||||
/** null = idle; non-null drives [WakeOverlay]. */
|
||||
/** null = idle; non-null drives the "Waking…" phase of [ConnectOverlay]. */
|
||||
data class Waking(
|
||||
val hostName: String,
|
||||
/** Whether coming online chains into a connect (Wake & Connect) vs. just stopping. */
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bedtime
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* The "Waking <host>…" modal shown while [WakeController] brings a sleeping host back — a spinner + a
|
||||
* live elapsed counter, escalating to a retry/cancel prompt on timeout. The Android mirror of the
|
||||
* Apple client's `WakeOverlay`. Rendered over BOTH the touch grid and the console home; it swallows
|
||||
* input to the screen behind it, and in console mode the pad drives it (B cancels, A retries once
|
||||
* timed out) while the touch buttons work for a pointer.
|
||||
*/
|
||||
@Composable
|
||||
fun WakeOverlay(waker: WakeController, gamepadUi: Boolean) {
|
||||
val w = waker.waking ?: return
|
||||
|
||||
BackHandler { waker.cancel() } // system Back / pad B (remapped) cancels the wait
|
||||
if (gamepadUi) {
|
||||
// A retries once timed out; B falls through to the BackHandler above.
|
||||
GamepadNavEffect2D(
|
||||
active = true,
|
||||
onDirection = {},
|
||||
onActivate = { if (w.timedOut) waker.retry() },
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.6f))
|
||||
// Swallow taps so the home behind can't be touched while waking.
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.padding(40.dp)
|
||||
.widthIn(max = 380.dp)
|
||||
.clip(RoundedCornerShape(22.dp))
|
||||
.background(Color(0xF01A1730))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(22.dp))
|
||||
.padding(28.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
if (w.timedOut) {
|
||||
Icon(
|
||||
Icons.Filled.Bedtime,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.85f),
|
||||
modifier = Modifier.size(34.dp),
|
||||
)
|
||||
Text(
|
||||
"${w.hostName} didn't wake",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 19.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
"It may still be booting, or it's powered off / off this network.",
|
||||
color = Color.White.copy(alpha = 0.6f),
|
||||
fontSize = 13.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
) {
|
||||
OutlinedButton(onClick = { waker.cancel() }) { Text("Cancel") }
|
||||
Button(onClick = { waker.retry() }) { Text("Try Again") }
|
||||
}
|
||||
} else {
|
||||
CircularProgressIndicator(color = Color.White)
|
||||
Text(
|
||||
"Waking ${w.hostName}…",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 19.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
"Waiting for it to come online · ${w.seconds}s",
|
||||
color = Color.White.copy(alpha = 0.6f),
|
||||
fontSize = 13.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
OutlinedButton(onClick = { waker.cancel() }, modifier = Modifier.padding(top = 6.dp)) {
|
||||
Text(if (w.connectsAfter) "Cancel" else "Stop Waiting")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,29 @@ class ScreenshotTest {
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun streamNormal() = shootRoot("stream-normal") { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) }
|
||||
|
||||
// The touch flow is a Material dialog over the host grid (a separate window → shootScreen).
|
||||
@Test
|
||||
fun connecting() = shootScreen("connecting") {
|
||||
HostsScene()
|
||||
ConnectingScene()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun waking() = shootScreen("waking") {
|
||||
HostsScene()
|
||||
WakingScene()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wakeTimedOut() = shootScreen("wake-timed-out") {
|
||||
HostsScene()
|
||||
WakeTimedOutScene()
|
||||
}
|
||||
|
||||
// The console flow is the full-screen aurora takeover (a root capture).
|
||||
@Test
|
||||
fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() }
|
||||
|
||||
@Test
|
||||
fun trust() = shootScreen("trust") {
|
||||
HostsScene()
|
||||
|
||||
@@ -26,6 +26,9 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.unom.punktfunk.BrandDark
|
||||
import io.unom.punktfunk.ConnectModal
|
||||
import io.unom.punktfunk.ConnectPhase
|
||||
import io.unom.punktfunk.ConnectTakeover
|
||||
import io.unom.punktfunk.Settings
|
||||
import io.unom.punktfunk.TouchMode
|
||||
import io.unom.punktfunk.SettingsScreen
|
||||
@@ -215,3 +218,31 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The default-UI connect flow (the real [ConnectModal]) in each phase — instant "Connecting…"
|
||||
* feedback, the "Waking…" wait, and the wake-timed-out prompt. These render as a Material dialog over
|
||||
* the host grid, so the test composes [HostsScene] behind them and captures the whole screen.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConnectingScene() =
|
||||
ConnectModal(ConnectPhase.Connecting("Living Room PC"), onCancel = {}, onRetry = {})
|
||||
|
||||
@Composable
|
||||
internal fun WakingScene() =
|
||||
ConnectModal(
|
||||
ConnectPhase.Waking("Living Room PC", seconds = 12, connectsAfter = true),
|
||||
onCancel = {}, onRetry = {},
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun WakeTimedOutScene() =
|
||||
ConnectModal(ConnectPhase.WakeTimedOut("Living Room PC"), onCancel = {}, onRetry = {})
|
||||
|
||||
/**
|
||||
* The console / gamepad connect flow (the real full-screen [ConnectTakeover]) — the aurora backdrop
|
||||
* with a bottom hint bar, the same signature look the console home uses.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConnectConsoleScene() =
|
||||
ConnectTakeover(ConnectPhase.Connecting("Living Room PC"), onCancel = {}, onRetry = {})
|
||||
|
||||
@@ -91,9 +91,12 @@ fun registerCargoNdk(taskName: String, release: Boolean) =
|
||||
val cmd = mutableListOf(
|
||||
"$cargoBin/cargo", "ndk",
|
||||
"-t", "arm64-v8a", "-t", "armeabi-v7a", "-t", "x86_64",
|
||||
// Link against the minSdk-28 sysroot: libaaudio (API 26) is present, and building at the
|
||||
// floor makes the linker reject any accidental >28 hard import (the one API-30 call we
|
||||
// make, ANativeWindow_setFrameRate, is dlsym-resolved — see decode::try_set_frame_rate).
|
||||
// Link against the minSdk-28 sysroot (libaaudio, API 26, is present). NOTE: this does
|
||||
// NOT reject an accidental >28 hard import — a cdylib link permits undefined symbols,
|
||||
// which then fail at System.loadLibrary on every device below the symbol's API level
|
||||
// (the 0.9.0 Android-≤12 regression). The checkJniImports* task after this build is
|
||||
// what actually enforces the floor; >28 entry points must be dlsym-resolved (see
|
||||
// decode::try_set_frame_rate, decode::install_render_callback, adpf).
|
||||
"--platform", "28",
|
||||
"-o", file("src/main/jniLibs").absolutePath,
|
||||
"build", "-p", "punktfunk-client-android",
|
||||
@@ -102,8 +105,28 @@ fun registerCargoNdk(taskName: String, release: Boolean) =
|
||||
commandLine(cmd)
|
||||
}
|
||||
|
||||
// Post-link floor check: every undefined symbol in the built .so must exist in the API-28 stubs,
|
||||
// else System.loadLibrary fails on devices at the minSdk floor (see the script header for the
|
||||
// 0.9.0 incident this guards against). Runs right after its cargo-ndk task; the APK build depends
|
||||
// on this task (not the cargo one directly), so a violation fails the build, local and CI alike.
|
||||
fun registerCheckJniImports(taskName: String, cargoTask: TaskProvider<Exec>) =
|
||||
tasks.register<Exec>(taskName) {
|
||||
group = "rust"
|
||||
description = "verify libpunktfunk_android.so imports stay within the API-28 floor"
|
||||
dependsOn(cargoTask)
|
||||
workingDir = repoRoot
|
||||
commandLine(
|
||||
"sh", File(repoRoot, "scripts/ci/check-android-jni-imports.sh").absolutePath,
|
||||
"${androidSdkDir()}/ndk/$ndkVer",
|
||||
file("src/main/jniLibs").absolutePath,
|
||||
"28",
|
||||
)
|
||||
}
|
||||
|
||||
val cargoNdkDebug = registerCargoNdk("cargoNdkDebug", release = false)
|
||||
val cargoNdkRelease = registerCargoNdk("cargoNdkRelease", release = true)
|
||||
val checkJniImportsDebug = registerCheckJniImports("checkJniImportsDebug", cargoNdkDebug)
|
||||
val checkJniImportsRelease = registerCheckJniImports("checkJniImportsRelease", cargoNdkRelease)
|
||||
|
||||
afterEvaluate {
|
||||
// `-PskipRustBuild` skips the cargo-ndk native build — for JVM-only tasks (the Roborazzi
|
||||
@@ -120,8 +143,9 @@ afterEvaluate {
|
||||
// debug build); only the cargo profile changes. `-PrustDebug` restores a debug-profile native
|
||||
// build for the rare session that actually steps through Rust.
|
||||
if (!project.hasProperty("skipRustBuild")) {
|
||||
val debugRust = if (project.hasProperty("rustDebug")) cargoNdkDebug else cargoNdkRelease
|
||||
val debugRust =
|
||||
if (project.hasProperty("rustDebug")) checkJniImportsDebug else checkJniImportsRelease
|
||||
tasks.named("preDebugBuild").configure { dependsOn(debugRust) }
|
||||
tasks.named("preReleaseBuild").configure { dependsOn(cargoNdkRelease) }
|
||||
tasks.named("preReleaseBuild").configure { dependsOn(checkJniImportsRelease) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,49 +171,87 @@ object Gamepad {
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps joystick MotionEvents to axis (+ HAT→dpad) sends for one session, **on change only**.
|
||||
* Holds the previous axis/hat state so an unchanged frame emits nothing. One instance per
|
||||
* session; call [reset] on release-all (focus loss / disconnect / session stop) so nothing
|
||||
* sticks on the host (which has no client-side held-state knowledge).
|
||||
* Maps one controller's joystick MotionEvents to axis (+ HAT→dpad) sends on wire pad index [pad],
|
||||
* **on change only**. Holds the previous axis/hat state so an unchanged frame emits nothing. One
|
||||
* instance per forwarded controller (owned by [GamepadRouter], which routes each device's events
|
||||
* to its own mapper so a second pad can't clobber the first); call [reset] on that slot closing
|
||||
* (disconnect / session stop) so nothing sticks on the host (which has no client-side held-state
|
||||
* knowledge).
|
||||
*
|
||||
* The router only ever feeds this a qualifying event from the mapper's own device — a real
|
||||
* gamepad (its source classes include GAMEPAD), never a controller's joystick-classified sibling
|
||||
* node (DualSense/DS4 motion sensors), which reports every pad axis as 0. [onMotion] therefore
|
||||
* folds the event straight in without re-qualifying it.
|
||||
*/
|
||||
class AxisMapper(private val handle: Long) {
|
||||
class AxisMapper(private val handle: Long, private val pad: Int) {
|
||||
// Sentinel so the first real value (incl. 0) always sends once after attach (Linux parity).
|
||||
private val last = IntArray(6) { Int.MIN_VALUE }
|
||||
private var hatX = 0 // -1 / 0 / +1
|
||||
private var hatY = 0
|
||||
|
||||
/** Returns true if this was a joystick ACTION_MOVE we consumed. */
|
||||
fun onMotion(event: MotionEvent): Boolean {
|
||||
if (!event.isFromSource(InputDevice.SOURCE_JOYSTICK)) return false
|
||||
if (event.actionMasked != MotionEvent.ACTION_MOVE) return false
|
||||
|
||||
/** Fold one joystick ACTION_MOVE from this mapper's controller onto its pad index. */
|
||||
fun onMotion(event: MotionEvent) {
|
||||
// Sticks: Android floats −1..1, +y = down → ±32767, negate Y for the wire's +y = up.
|
||||
sendAxis(AXIS_LS_X, stick(event.getAxisValue(MotionEvent.AXIS_X)))
|
||||
sendAxis(AXIS_LS_Y, stick(-event.getAxisValue(MotionEvent.AXIS_Y)))
|
||||
sendAxis(AXIS_RS_X, stick(event.getAxisValue(MotionEvent.AXIS_Z)))
|
||||
sendAxis(AXIS_RS_Y, stick(-event.getAxisValue(MotionEvent.AXIS_RZ)))
|
||||
|
||||
// Triggers: LTRIGGER/RTRIGGER if present, else BRAKE/GAS; 0..1 float → 0..255.
|
||||
sendAxis(AXIS_LT, trigger(firstNonZero(event, MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_BRAKE)))
|
||||
sendAxis(AXIS_RT, trigger(firstNonZero(event, MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_GAS)))
|
||||
// Triggers: pads report LTRIGGER/RTRIGGER or BRAKE/GAS (some mirror both) — merge
|
||||
// with max, the same fold as the Controllers screen probe, so a pad that reports
|
||||
// only one pair and a pad that reports both behave identically; 0..1 → 0..255.
|
||||
sendAxis(
|
||||
AXIS_LT,
|
||||
trigger(
|
||||
maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_LTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_BRAKE),
|
||||
),
|
||||
),
|
||||
)
|
||||
sendAxis(
|
||||
AXIS_RT,
|
||||
trigger(
|
||||
maxOf(
|
||||
event.getAxisValue(MotionEvent.AXIS_RTRIGGER),
|
||||
event.getAxisValue(MotionEvent.AXIS_GAS),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// HAT → dpad button transitions (track previous, emit only the deltas).
|
||||
val hx = sign(event.getAxisValue(MotionEvent.AXIS_HAT_X))
|
||||
// HAT → dpad button transitions. Android BATCHES joystick ACTION_MOVEs, so a rapid d-pad
|
||||
// tap (press+release inside one batch window) lives only in the historical samples — the
|
||||
// final getAxisValue would show the HAT already back at rest and miss the tap entirely.
|
||||
// Feed every historical HAT sample (oldest→newest) through the same transition logic
|
||||
// before the current one, so each edge is emitted. (Sticks/triggers stay latest-wins:
|
||||
// only the final value matters for an analog axis.)
|
||||
for (h in 0 until event.historySize) {
|
||||
applyHat(
|
||||
sign(event.getHistoricalAxisValue(MotionEvent.AXIS_HAT_X, h)),
|
||||
sign(event.getHistoricalAxisValue(MotionEvent.AXIS_HAT_Y, h)),
|
||||
)
|
||||
}
|
||||
applyHat(
|
||||
sign(event.getAxisValue(MotionEvent.AXIS_HAT_X)),
|
||||
sign(event.getAxisValue(MotionEvent.AXIS_HAT_Y)),
|
||||
)
|
||||
}
|
||||
|
||||
/** Emit dpad button deltas for one HAT sample (`hx`/`hy` each −1/0/+1), tracking held state. */
|
||||
private fun applyHat(hx: Int, hy: Int) {
|
||||
if (hx != hatX) {
|
||||
if (hatX < 0) btn(BTN_DPAD_LEFT, false) else if (hatX > 0) btn(BTN_DPAD_RIGHT, false)
|
||||
if (hx < 0) btn(BTN_DPAD_LEFT, true) else if (hx > 0) btn(BTN_DPAD_RIGHT, true)
|
||||
hatX = hx
|
||||
}
|
||||
val hy = sign(event.getAxisValue(MotionEvent.AXIS_HAT_Y))
|
||||
if (hy != hatY) {
|
||||
if (hatY < 0) btn(BTN_DPAD_UP, false) else if (hatY > 0) btn(BTN_DPAD_DOWN, false)
|
||||
if (hy < 0) btn(BTN_DPAD_UP, true) else if (hy > 0) btn(BTN_DPAD_DOWN, true)
|
||||
hatY = hy
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Release-all: zero every axis and clear the held dpad. */
|
||||
/** Release-all: zero every axis and clear the held dpad (all on this mapper's pad index). */
|
||||
fun reset() {
|
||||
for (id in 0..5) sendAxis(id, 0)
|
||||
if (hatX < 0) btn(BTN_DPAD_LEFT, false) else if (hatX > 0) btn(BTN_DPAD_RIGHT, false)
|
||||
@@ -225,10 +263,10 @@ object Gamepad {
|
||||
private fun sendAxis(id: Int, v: Int) {
|
||||
if (last[id] == v) return
|
||||
last[id] = v
|
||||
NativeBridge.nativeSendGamepadAxis(handle, id, v)
|
||||
NativeBridge.nativeSendGamepadAxis(handle, id, v, pad)
|
||||
}
|
||||
|
||||
private fun btn(bit: Int, down: Boolean) = NativeBridge.nativeSendGamepadButton(handle, bit, down)
|
||||
private fun btn(bit: Int, down: Boolean) = NativeBridge.nativeSendGamepadButton(handle, bit, down, pad)
|
||||
|
||||
// −1..1 float → ±32767 i16 (matches the Apple client's 32767 scale).
|
||||
private fun stick(v: Float): Int = (v.coerceIn(-1f, 1f) * 32767f).toInt()
|
||||
@@ -237,10 +275,5 @@ object Gamepad {
|
||||
private fun trigger(v: Float): Int = (v.coerceIn(0f, 1f) * 255f).toInt()
|
||||
|
||||
private fun sign(v: Float): Int = if (v < -0.5f) -1 else if (v > 0.5f) 1 else 0
|
||||
|
||||
private fun firstNonZero(e: MotionEvent, a: Int, b: Int): Float {
|
||||
val va = e.getAxisValue(a)
|
||||
return if (va != 0f) va else e.getAxisValue(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,58 +15,88 @@ import android.view.InputDevice
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
/**
|
||||
* Host→client gamepad feedback for one session (single-pad model — pad 0 only). Two daemon poll
|
||||
* threads drain the blocking native pulls and render in Kotlin: rumble → the controller's
|
||||
* `VibratorManager` (API 31+) or its single legacy `Vibrator` on API 28–30; HID-output → lightbar /
|
||||
* player-LED via `LightsManager` (API 33+); adaptive
|
||||
* triggers are parse-validated and logged (Android has no public adaptive-trigger API).
|
||||
* Host→client gamepad feedback for one session, routed per controller by wire pad index. Two daemon
|
||||
* poll threads drain the blocking native pulls and render in Kotlin: rumble → the addressed
|
||||
* controller's `VibratorManager` (API 31+) or its single legacy `Vibrator` on API 28–30; HID-output
|
||||
* → that controller's lightbar / player-LED via `LightsManager` (API 33+); adaptive triggers are
|
||||
* parse-validated and logged (Android has no public adaptive-trigger API).
|
||||
*
|
||||
* Each pull carries the wire pad index it is addressed to; [GamepadRouter.deviceForPad] resolves it
|
||||
* to the physical controller currently holding that index — so a rumble the host aimed at pad 1
|
||||
* drives pad 1's motors, and an update for an index with no live controller (a pad that just
|
||||
* unplugged) is dropped. Per-controller rumble/light bindings are built lazily and cached by device
|
||||
* id (bounded — at most 16 pads).
|
||||
*
|
||||
* Mirrors `nativeStartAudio`'s lifecycle: [start]/[stop] driven by the StreamScreen. [stop] flips a
|
||||
* flag; the ~100 ms native pull timeout lets the threads exit, then they're joined (bounded) — and
|
||||
* this MUST run before `nativeClose` frees the session handle.
|
||||
* this MUST run before the router is released and `nativeClose` frees the session handle.
|
||||
*
|
||||
* The active pad is resolved from the connected input devices (first gamepad/joystick). With none
|
||||
* connected (emulator) rumble/lights become logged no-ops — exactly the verification path; the
|
||||
* `Log.i` receipt lines fire regardless of rendering hardware.
|
||||
* With no controller connected (emulator) rumble/lights become logged no-ops — exactly the
|
||||
* verification path; the `Log.i` receipt lines fire regardless of rendering hardware.
|
||||
*/
|
||||
class GamepadFeedback(private val handle: Long) {
|
||||
class GamepadFeedback(private val handle: Long, private val router: GamepadRouter?) {
|
||||
private companion object {
|
||||
const val TAG = "pf.feedback"
|
||||
const val TAG_LED: Byte = 0x01
|
||||
const val TAG_PLAYER_LEDS: Byte = 0x02
|
||||
const val TAG_TRIGGER: Byte = 0x03
|
||||
// Fallback one-shot duration against a legacy host (no v2 TTL lease): the prior fixed value.
|
||||
// A new host renews far below this, so it never actually holds this long there.
|
||||
const val LEGACY_RUMBLE_MS = 60_000L
|
||||
}
|
||||
|
||||
/** One controller's rumble binding — VibratorManager (API 31+) OR the legacy single Vibrator (API 28–30). */
|
||||
private class RumbleBind(
|
||||
val vm: VibratorManager?,
|
||||
val legacy: Vibrator?,
|
||||
val ids: IntArray,
|
||||
val amplitudeControlled: Boolean,
|
||||
)
|
||||
|
||||
/** One controller's lights binding (API 33+): its open session + the RGB / player-id lights it exposes. */
|
||||
private class LightBind(
|
||||
val session: LightsManager.LightsSession,
|
||||
val rgb: Light?,
|
||||
val player: Light?,
|
||||
)
|
||||
|
||||
@Volatile private var running = false
|
||||
private var rumbleThread: Thread? = null
|
||||
private var hidoutThread: Thread? = null
|
||||
|
||||
private var vm: VibratorManager? = null
|
||||
// API 28–30 fallback: the controller's single legacy Vibrator (no per-motor VibratorManager
|
||||
// until API 31). Exactly one of [vm] / [legacy] is bound; rumble degrades to one blended motor.
|
||||
private var legacy: Vibrator? = null
|
||||
private var vibratorIds: IntArray = IntArray(0)
|
||||
private var amplitudeControlled = false
|
||||
|
||||
private var lightsSession: LightsManager.LightsSession? = null
|
||||
private var rgbLight: Light? = null
|
||||
private var playerLight: Light? = null
|
||||
// Per-controller bindings, keyed by device id, built lazily. rumbleBinds is written by the rumble
|
||||
// thread and lightBinds by the hidout thread while running; [onDeviceRemoved] also evicts+closes
|
||||
// from the MAIN thread on a hot-unplug, and stop() clears both from the main thread after joining
|
||||
// the threads. That main-vs-poll concurrency is why every access goes through `bindsLock` (a plain
|
||||
// HashMap can corrupt under a concurrent structural write, and ConcurrentHashMap can't hold the
|
||||
// null value that caches "this controller has no vibrator / no controllable lights"). The lock
|
||||
// guards only the map ops — rendering runs on the returned reference outside it; a stale reference
|
||||
// is harmless (a closed LightsSession's requestLights and a cancelled Vibrator are runCatching'd
|
||||
// no-ops). A null value caches the negative result so a pad with no hardware isn't re-probed.
|
||||
private val bindsLock = Any()
|
||||
private val rumbleBinds = HashMap<Int, RumbleBind?>()
|
||||
private val lightBinds = HashMap<Int, LightBind?>()
|
||||
|
||||
fun start() {
|
||||
val dev = resolvePad()
|
||||
bindRumble(dev)
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
bindLights(dev)
|
||||
} else {
|
||||
Log.i(TAG, "lights need API 33 (have ${Build.VERSION.SDK_INT}) — lightbar/playerLed no-op")
|
||||
}
|
||||
|
||||
running = true
|
||||
rumbleThread = Thread({
|
||||
while (running) {
|
||||
val ev = NativeBridge.nativeNextRumble(handle)
|
||||
if (ev < 0L) continue // timeout / closed
|
||||
renderRumble(((ev ushr 16) and 0xFFFF).toInt(), (ev and 0xFFFF).toInt())
|
||||
// ev bits 49..52 = wire pad index; bit 48 = has a v2 lease; bits 32..47 = ttl_ms;
|
||||
// 16..31 = low; 0..15 = high. The lease flag is out-of-band, so any ttl_ms (incl.
|
||||
// 0xFFFF) is a real lease — no in-band sentinel. No lease (legacy host) → the prior
|
||||
// long one-shot.
|
||||
val pad = ((ev ushr 49) and 0xFL).toInt()
|
||||
val hasLease = ((ev ushr 48) and 0x1L) == 0x1L
|
||||
val ttl = ((ev ushr 32) and 0xFFFF).toInt()
|
||||
val durationMs = if (hasLease) ttl.toLong() else LEGACY_RUMBLE_MS
|
||||
renderRumble(
|
||||
pad,
|
||||
((ev ushr 16) and 0xFFFF).toInt(),
|
||||
(ev and 0xFFFF).toInt(),
|
||||
durationMs,
|
||||
)
|
||||
}
|
||||
}, "pf-rumble").apply { isDaemon = true; start() }
|
||||
|
||||
@@ -80,102 +110,139 @@ class GamepadFeedback(private val handle: Long) {
|
||||
}, "pf-hidout").apply { isDaemon = true; start() }
|
||||
}
|
||||
|
||||
/** Idempotent. Stops + joins the poll threads (must complete before the session handle is freed). */
|
||||
/** Idempotent. Stops + joins the poll threads (must complete before the router is released / handle freed). */
|
||||
fun stop() {
|
||||
running = false
|
||||
rumbleThread?.interrupt()
|
||||
hidoutThread?.interrupt()
|
||||
runCatching { vm?.cancel() } // drop any held rumble immediately
|
||||
runCatching { legacy?.cancel() }
|
||||
// Join WITHOUT a timeout. These poll threads dereference the native session handle on every
|
||||
// pull (nativeNextRumble/nativeNextHidout), so they MUST be dead before StreamScreen's
|
||||
// onDispose reaches nativeClose, which frees that handle. A *bounded* join that times out
|
||||
// would let a thread survive into the freed handle → use-after-free SIGSEGV (the
|
||||
// back-while-streaming crash, on the one path the main-thread `closed` guard can't cover).
|
||||
// Safe to block unbounded: the native pulls are internally time-bounded (PULL_TIMEOUT ~100 ms)
|
||||
// and rendering is a quick best-effort binder call, so each thread observes running=false and
|
||||
// exits within ~one timeout — the join returns promptly (well under any ANR threshold).
|
||||
// pull (nativeNextRumble/nativeNextHidout) and read the router, so they MUST be dead before
|
||||
// StreamScreen's onDispose reaches router.release() / nativeClose, which free that state. A
|
||||
// *bounded* join that times out would let a thread survive into the freed handle → use-after-
|
||||
// free SIGSEGV (the back-while-streaming crash, on the one path the main-thread `closed` guard
|
||||
// can't cover). Safe to block unbounded: the native pulls are internally time-bounded
|
||||
// (PULL_TIMEOUT ~100 ms) and rendering is a quick best-effort binder call, so each thread
|
||||
// observes running=false and exits within ~one timeout — the join returns promptly.
|
||||
runCatching { rumbleThread?.join() }
|
||||
runCatching { hidoutThread?.join() }
|
||||
rumbleThread = null
|
||||
hidoutThread = null
|
||||
runCatching { lightsSession?.close() }
|
||||
lightsSession = null
|
||||
rgbLight = null
|
||||
playerLight = null
|
||||
vm = null
|
||||
legacy = null
|
||||
vibratorIds = IntArray(0)
|
||||
// Threads are dead — drop any held rumble and close every lights session.
|
||||
synchronized(bindsLock) {
|
||||
for (b in rumbleBinds.values) b?.let {
|
||||
runCatching { it.vm?.cancel() }
|
||||
runCatching { it.legacy?.cancel() }
|
||||
}
|
||||
for (b in lightBinds.values) b?.let { runCatching { it.session.close() } }
|
||||
rumbleBinds.clear()
|
||||
lightBinds.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/** First connected gamepad/joystick InputDevice, or null (→ logged no-op on the emulator). */
|
||||
private fun resolvePad(): InputDevice? = Gamepad.firstPad()
|
||||
/**
|
||||
* Evict and release the bindings for a controller that just disconnected — invoked from
|
||||
* [GamepadRouter]'s slot-close on the main thread (routed via `StreamScreen`). Closes its
|
||||
* `LightsSession` and cancels any held rumble, so a hot-unplug mid-session frees the session
|
||||
* immediately instead of leaking it until [stop]. A no-op for a device with no cached binding.
|
||||
* The next feedback for that pad index rebinds against whatever controller now holds it.
|
||||
*/
|
||||
// Same runtime-guarded cleanup as [stop] (VIBRATE is app-declared; the light bind only exists
|
||||
// under the SDK 33 guard) — suppress the module-isolation lint false positives it re-triggers.
|
||||
@Suppress("MissingPermission", "NewApi")
|
||||
fun onDeviceRemoved(deviceId: Int) {
|
||||
synchronized(bindsLock) {
|
||||
rumbleBinds.remove(deviceId)?.let {
|
||||
runCatching { it.vm?.cancel() }
|
||||
runCatching { it.legacy?.cancel() }
|
||||
}
|
||||
lightBinds.remove(deviceId)?.let { runCatching { it.session.close() } }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Rumble ----
|
||||
|
||||
private fun bindRumble(dev: InputDevice?) {
|
||||
if (dev == null) {
|
||||
Log.i(TAG, "rumble: no controller connected — rumble no-op (emulator path)")
|
||||
return
|
||||
/** The rumble binding for the controller on wire pad [pad], or null (no live pad / no vibrator). Cached by device id. */
|
||||
private fun rumbleBindFor(pad: Int): RumbleBind? {
|
||||
val dev = router?.deviceForPad(pad) ?: return null
|
||||
synchronized(bindsLock) {
|
||||
if (rumbleBinds.containsKey(dev.id)) return rumbleBinds[dev.id]
|
||||
val bind = bindRumble(dev)
|
||||
rumbleBinds[dev.id] = bind
|
||||
return bind
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindRumble(dev: InputDevice): RumbleBind? {
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
val m = dev.vibratorManager
|
||||
val ids = m.vibratorIds
|
||||
if (ids.isEmpty()) {
|
||||
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrators — rumble no-op")
|
||||
return
|
||||
return null
|
||||
}
|
||||
vm = m
|
||||
vibratorIds = ids
|
||||
amplitudeControlled = ids.all { m.getVibrator(it).hasAmplitudeControl() }
|
||||
Log.i(TAG, "rumble: bound ${ids.size} vibrators amplitudeControl=$amplitudeControlled")
|
||||
} else {
|
||||
// API 28–30: no VibratorManager — fall back to the controller's single legacy Vibrator.
|
||||
@Suppress("DEPRECATION")
|
||||
val v = dev.vibrator
|
||||
if (!v.hasVibrator()) {
|
||||
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrator — rumble no-op")
|
||||
return
|
||||
}
|
||||
legacy = v
|
||||
amplitudeControlled = v.hasAmplitudeControl()
|
||||
Log.i(TAG, "rumble: bound legacy vibrator amplitudeControl=$amplitudeControlled")
|
||||
val amp = ids.all { m.getVibrator(it).hasAmplitudeControl() }
|
||||
Log.i(TAG, "rumble: bound ${ids.size} vibrators for '${dev.name}' amplitudeControl=$amp")
|
||||
return RumbleBind(m, null, ids, amp)
|
||||
}
|
||||
// API 28–30: no VibratorManager — fall back to the controller's single legacy Vibrator.
|
||||
@Suppress("DEPRECATION")
|
||||
val v = dev.vibrator
|
||||
if (!v.hasVibrator()) {
|
||||
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrator — rumble no-op")
|
||||
return null
|
||||
}
|
||||
Log.i(TAG, "rumble: bound legacy vibrator for '${dev.name}' amplitudeControl=${v.hasAmplitudeControl()}")
|
||||
return RumbleBind(null, v, IntArray(0), v.hasAmplitudeControl())
|
||||
}
|
||||
|
||||
/** low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes). */
|
||||
private fun renderRumble(low: Int, high: Int) {
|
||||
Log.i(TAG, "rumble low=$low high=$high") // verification line — BEFORE any no-op return
|
||||
/**
|
||||
* low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes),
|
||||
* addressed to wire pad [pad]. `durationMs` is the host's v2 envelope TTL — the one-shot self-
|
||||
* terminates after it unless the host renews, so a lost stop (or a dead host) silences at the
|
||||
* lease instead of the old fixed 60 s. Against a legacy host it is [LEGACY_RUMBLE_MS].
|
||||
*/
|
||||
private fun renderRumble(pad: Int, low: Int, high: Int, durationMs: Long) {
|
||||
Log.i(TAG, "rumble pad=$pad low=$low high=$high ttlMs=$durationMs") // verification line — BEFORE any no-op return
|
||||
val bind = rumbleBindFor(pad) ?: return
|
||||
val lo = toAmplitude(low)
|
||||
val hi = toAmplitude(high)
|
||||
val m = vm
|
||||
val m = bind.vm
|
||||
if (m != null) {
|
||||
if (lo == 0 && hi == 0) {
|
||||
m.cancel() // (0,0) = stop
|
||||
return
|
||||
}
|
||||
val combo = CombinedVibration.startParallel()
|
||||
if (amplitudeControlled && vibratorIds.size >= 2) {
|
||||
// ids[0] = light/right, ids[1] = heavy/left (XInput/Moonlight convention).
|
||||
if (hi != 0) combo.addVibrator(vibratorIds[0], oneShot(hi))
|
||||
if (lo != 0) combo.addVibrator(vibratorIds[1], oneShot(lo))
|
||||
if (bind.amplitudeControlled && bind.ids.size >= 2) {
|
||||
// Two-motor split — ASSUMPTION: ids[0] = light/right, ids[1] = heavy/left
|
||||
// (XInput/Moonlight convention). Android does not guarantee the order of
|
||||
// VibratorManager.getVibratorIds(), so a pad that enumerates heavy-first would
|
||||
// invert the feel: the stronger amplitude drives the physically-lighter motor.
|
||||
// Failure mode is tactile only — both motors still fire, nothing silences or
|
||||
// crashes — so this stays the default pending per-pad on-glass verification (G20).
|
||||
// ids beyond the first two (rare) are left alone here.
|
||||
if (hi != 0) combo.addVibrator(bind.ids[0], oneShot(hi, durationMs))
|
||||
if (lo != 0) combo.addVibrator(bind.ids[1], oneShot(lo, durationMs))
|
||||
} else {
|
||||
// Single motor or no amplitude control: blend both into one effect.
|
||||
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
||||
for (id in vibratorIds) combo.addVibrator(id, oneShot(a))
|
||||
for (id in bind.ids) combo.addVibrator(id, oneShot(a, durationMs))
|
||||
}
|
||||
runCatching { m.vibrate(combo.combine()) }
|
||||
return
|
||||
}
|
||||
// API 28–30 legacy single-motor path: blend both motors into one effect.
|
||||
val lv = legacy ?: return
|
||||
val lv = bind.legacy ?: return
|
||||
if (lo == 0 && hi == 0) {
|
||||
lv.cancel() // (0,0) = stop
|
||||
return
|
||||
}
|
||||
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
||||
runCatching {
|
||||
lv.vibrate(if (amplitudeControlled) oneShot(a) else oneShot(VibrationEffect.DEFAULT_AMPLITUDE))
|
||||
lv.vibrate(
|
||||
if (bind.amplitudeControlled) oneShot(a, durationMs)
|
||||
else oneShot(VibrationEffect.DEFAULT_AMPLITUDE, durationMs)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,35 +252,42 @@ class GamepadFeedback(private val handle: Long) {
|
||||
return if (v16 != 0 && a == 0) 1 else a
|
||||
}
|
||||
|
||||
// Long one-shot held until the next packet (the host re-sends ~periodically); cancel on zero.
|
||||
private fun oneShot(amp: Int): VibrationEffect = VibrationEffect.createOneShot(60_000L, amp)
|
||||
// One-shot held for `durationMs` — the host's v2 TTL (renewed while the level holds), so it
|
||||
// self-terminates on a lost stop; cancel on zero. Floor the duration at 1 ms: `createOneShot`
|
||||
// throws IllegalArgumentException on a non-positive duration, and a lease can carry ttl_ms==0
|
||||
// (e.g. the legacy-Deck ceiling) with a nonzero amplitude — which reaches here past the (0,0)
|
||||
// stop guard. On the VibratorManager path the effect is built OUTSIDE the vibrate() runCatching,
|
||||
// so an uncaught throw here would kill the whole rumble poll thread.
|
||||
private fun oneShot(amp: Int, durationMs: Long): VibrationEffect =
|
||||
VibrationEffect.createOneShot(durationMs.coerceAtLeast(1), amp)
|
||||
|
||||
// ---- HID output ----
|
||||
|
||||
private fun dispatchHidout(buf: ByteBuffer, n: Int) {
|
||||
buf.rewind()
|
||||
val pad = buf.get().toInt() and 0xFF // wire pad index the event is addressed to
|
||||
when (buf.get()) { // kind tag
|
||||
TAG_LED -> {
|
||||
val r = buf.get().toInt() and 0xFF
|
||||
val g = buf.get().toInt() and 0xFF
|
||||
val b = buf.get().toInt() and 0xFF
|
||||
Log.i(TAG, "hidout Led r=$r g=$g b=$b") // verification line
|
||||
if (Build.VERSION.SDK_INT >= 33) setLightbar(Color.rgb(r, g, b))
|
||||
Log.i(TAG, "hidout pad=$pad Led r=$r g=$g b=$b") // verification line
|
||||
if (Build.VERSION.SDK_INT >= 33) setLightbar(pad, Color.rgb(r, g, b))
|
||||
}
|
||||
TAG_PLAYER_LEDS -> {
|
||||
val bits = buf.get().toInt() and 0x1F
|
||||
val player = playerIndexForBits(bits)
|
||||
Log.i(TAG, "hidout PlayerLeds bits=$bits player=$player") // verification line
|
||||
if (Build.VERSION.SDK_INT >= 33) setPlayerId(player)
|
||||
Log.i(TAG, "hidout pad=$pad PlayerLeds bits=$bits player=$player") // verification line
|
||||
if (Build.VERSION.SDK_INT >= 33) setPlayerId(pad, player)
|
||||
}
|
||||
TAG_TRIGGER -> {
|
||||
val which = buf.get().toInt() and 0xFF // 0 = L2, 1 = R2
|
||||
val effLen = n - 2
|
||||
val effLen = n - 3 // [pad][kind][which] header, then the effect block
|
||||
val mode = if (effLen > 0) buf.get().toInt() and 0xFF else 0
|
||||
// No public adaptive-trigger API on Android — parse-validate the mode + log only.
|
||||
Log.i(
|
||||
TAG,
|
||||
"hidout Trigger which=$which effLen=$effLen mode=0x%02x (adaptive triggers unsupported on Android)".format(mode),
|
||||
"hidout pad=$pad Trigger which=$which effLen=$effLen mode=0x%02x (adaptive triggers unsupported on Android)".format(mode),
|
||||
)
|
||||
}
|
||||
else -> Log.d(TAG, "hidout: unknown kind, dropped")
|
||||
@@ -230,37 +304,48 @@ class GamepadFeedback(private val handle: Long) {
|
||||
else -> Integer.bitCount(bits and 0x1F).coerceIn(1, 4)
|
||||
}
|
||||
|
||||
private fun bindLights(dev: InputDevice?) {
|
||||
if (dev == null) {
|
||||
Log.i(TAG, "lights: no controller connected — lightbar/playerLed no-op (emulator path)")
|
||||
return
|
||||
/** The lights binding for the controller on wire pad [pad], or null (no live pad / no lights / < API 33). Cached by device id. */
|
||||
private fun lightBindFor(pad: Int): LightBind? {
|
||||
if (Build.VERSION.SDK_INT < 33) return null
|
||||
val dev = router?.deviceForPad(pad) ?: return null
|
||||
synchronized(bindsLock) {
|
||||
if (lightBinds.containsKey(dev.id)) return lightBinds[dev.id]
|
||||
val bind = bindLights(dev)
|
||||
lightBinds[dev.id] = bind
|
||||
return bind
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindLights(dev: InputDevice): LightBind? {
|
||||
val lm = dev.lightsManager
|
||||
var rgb: Light? = null
|
||||
var player: Light? = null
|
||||
for (l in lm.lights) {
|
||||
if (rgbLight == null && l.hasRgbControl()) rgbLight = l
|
||||
if (playerLight == null && l.type == Light.LIGHT_TYPE_PLAYER_ID) playerLight = l
|
||||
if (rgb == null && l.hasRgbControl()) rgb = l
|
||||
if (player == null && l.type == Light.LIGHT_TYPE_PLAYER_ID) player = l
|
||||
}
|
||||
if (rgbLight == null && playerLight == null) {
|
||||
if (rgb == null && player == null) {
|
||||
Log.i(TAG, "lights: controller '${dev.name}' exposes no controllable lights — no-op")
|
||||
return
|
||||
return null
|
||||
}
|
||||
lightsSession = lm.openSession()
|
||||
Log.i(TAG, "lights: bound rgb=${rgbLight != null} playerLed=${playerLight != null}")
|
||||
val session = lm.openSession()
|
||||
Log.i(TAG, "lights: bound rgb=${rgb != null} playerLed=${player != null} for '${dev.name}'")
|
||||
return LightBind(session, rgb, player)
|
||||
}
|
||||
|
||||
private fun setLightbar(argb: Int) {
|
||||
val s = lightsSession ?: return
|
||||
val l = rgbLight ?: return
|
||||
private fun setLightbar(pad: Int, argb: Int) {
|
||||
val bind = lightBindFor(pad) ?: return
|
||||
val l = bind.rgb ?: return
|
||||
runCatching {
|
||||
s.requestLights(LightsRequest.Builder().addLight(l, LightState.Builder().setColor(argb).build()).build())
|
||||
bind.session.requestLights(LightsRequest.Builder().addLight(l, LightState.Builder().setColor(argb).build()).build())
|
||||
}
|
||||
}
|
||||
|
||||
private fun setPlayerId(player: Int) {
|
||||
val s = lightsSession ?: return
|
||||
val l = playerLight ?: return
|
||||
private fun setPlayerId(pad: Int, player: Int) {
|
||||
val bind = lightBindFor(pad) ?: return
|
||||
val l = bind.player ?: return
|
||||
runCatching {
|
||||
s.requestLights(LightsRequest.Builder().addLight(l, LightState.Builder().setPlayerId(player).build()).build())
|
||||
bind.session.requestLights(LightsRequest.Builder().addLight(l, LightState.Builder().setPlayerId(player).build()).build())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.input.InputManager
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Multi-controller router for one stream session — the Android analogue of the Linux client's gamepad
|
||||
* `Worker`/`Slot` model (`pf-client-core/src/gamepad.rs`) over the shared native-plane wire contract
|
||||
* (`punktfunk-core/src/input.rs`). Each physical controller (Android `deviceId`) gets a STABLE
|
||||
* lowest-free wire pad index (0..15) held for its lifetime and freed only on disconnect, so a pad
|
||||
* dropping never renumbers the others (a game must not see its players shuffle). Every forwarded event
|
||||
* carries that pad index; a [NativeBridge.nativeSendGamepadArrival] declaring the pad's type is sent
|
||||
* once BEFORE its first input, a [NativeBridge.nativeSendGamepadRemove] on disconnect. Per-device axis
|
||||
* state lives in each slot's [Gamepad.AxisMapper] so a second controller can't clobber the first.
|
||||
* Feedback (rumble / HID) is routed BACK to the originating device by pad index via [deviceForPad].
|
||||
*
|
||||
* Selection: forward EVERY real controller (the Linux client's single-player pin has no Android UI
|
||||
* surface yet — Automatic is the only mode). Lifetime matches the session: constructed on stream
|
||||
* attach (opening a slot for every already-connected pad, so its Arrival lands before any input),
|
||||
* released on detach.
|
||||
*
|
||||
* A single controller lands on wire index 0, so its per-transition button/axis wire is byte-identical
|
||||
* to the old single-pad path (plus the Arrival/Remove declarations the contract requires — which an
|
||||
* older host simply ignores).
|
||||
*
|
||||
* Threading: slot mutation + dispatch run on the main thread (Android input dispatch and the
|
||||
* InputManager hot-plug callbacks both land there). [deviceForPad] is read from the feedback poll
|
||||
* threads, so the slot table is a [ConcurrentHashMap].
|
||||
*/
|
||||
class GamepadRouter(context: Context, private val handle: Long, private val setting: Int) {
|
||||
|
||||
/** One forwarded controller: its stable wire pad index, per-device axis state, and held buttons. */
|
||||
private class Slot(val index: Int, val mapper: Gamepad.AxisMapper) {
|
||||
/** Forwarded button bits currently held (Gamepad.BTN_*) — for release-on-close + chord detection. */
|
||||
var held = 0
|
||||
}
|
||||
|
||||
/** deviceId → slot. Concurrent: the feedback poll threads read it via [deviceForPad]. */
|
||||
private val slots = ConcurrentHashMap<Int, Slot>()
|
||||
|
||||
/**
|
||||
* Invoked (main thread) with the deviceId whenever a slot closes — hot-unplug or session teardown.
|
||||
* `StreamScreen` wires this to `GamepadFeedback.onDeviceRemoved` so a disconnected pad's rumble /
|
||||
* lights bindings are released promptly instead of leaking until the feedback threads stop.
|
||||
*/
|
||||
var onSlotClosed: ((deviceId: Int) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Invoked (main thread) when the emergency-exit chord has been HELD for [EXIT_HOLD_MS] — the caller
|
||||
* leaves the stream. `StreamScreen` wires this to the deliberate-quit exit.
|
||||
*/
|
||||
var onExitChord: (() -> Unit)? = null
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
/** The pending exit-chord hold timer, or null when the chord isn't currently armed. */
|
||||
private var pendingExit: Runnable? = null
|
||||
|
||||
private val inputManager = context.getSystemService(InputManager::class.java)
|
||||
private val listener = object : InputManager.InputDeviceListener {
|
||||
override fun onInputDeviceAdded(deviceId: Int) {
|
||||
InputDevice.getDevice(deviceId)?.let { if (isForwardable(it)) openSlot(it) }
|
||||
}
|
||||
|
||||
override fun onInputDeviceRemoved(deviceId: Int) = closeSlot(deviceId)
|
||||
override fun onInputDeviceChanged(deviceId: Int) {}
|
||||
}
|
||||
|
||||
init {
|
||||
inputManager?.registerInputDeviceListener(listener, mainHandler)
|
||||
// Open a slot for every controller already connected when the session starts — the pads that
|
||||
// will never fire onInputDeviceAdded during this session; their Arrival lands before any input.
|
||||
for (id in InputDevice.getDeviceIds()) {
|
||||
InputDevice.getDevice(id)?.let { if (isForwardable(it)) openSlot(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One gamepad button transition for the device that produced [event] (already resolved to BTN_*
|
||||
* bit [bit]). Opens the device's slot (declaring its type) if unseen, forwards the bit on the
|
||||
* slot's pad index, and tracks held state. Completing the emergency stream-exit chord (Select +
|
||||
* Start + L1 + R1) on any one pad ARMS a [EXIT_HOLD_MS] hold timer rather than leaving instantly;
|
||||
* [onExitChord] fires only if the chord is still held at expiry (a brief accidental brush is
|
||||
* ignored), matching `DISCONNECT_HOLD` on the SDL/Apple clients. Any controller can leave.
|
||||
*/
|
||||
fun onButton(event: KeyEvent, bit: Int) {
|
||||
val slot = slotFor(event.device) ?: return
|
||||
when (event.action) {
|
||||
KeyEvent.ACTION_DOWN -> {
|
||||
// repeatCount guard: don't re-send a held button as auto-repeat.
|
||||
if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
|
||||
slot.held = slot.held or bit
|
||||
// Full chord now held on this pad → start the hold countdown (idempotent while held).
|
||||
if (slot.held and EXIT_CHORD == EXIT_CHORD) armExit()
|
||||
}
|
||||
KeyEvent.ACTION_UP -> {
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
slot.held = slot.held and bit.inv()
|
||||
// A chord button lifted before the hold elapsed → cancel, unless another pad still
|
||||
// holds the full chord.
|
||||
if (bit and EXIT_CHORD != 0 && slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) {
|
||||
disarmExit()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Arm the exit-chord hold timer (once); on expiry, if the chord is still held, flush + leave. */
|
||||
private fun armExit() {
|
||||
if (pendingExit != null) return // already counting down
|
||||
val r = Runnable {
|
||||
pendingExit = null
|
||||
// Fire only if the chord survived the full hold on some pad.
|
||||
val held = slots.values.filter { it.held and EXIT_CHORD == EXIT_CHORD }
|
||||
if (held.isNotEmpty()) {
|
||||
// Release the held buttons + zero the axes on every triggering pad so nothing sticks
|
||||
// host-side once we leave, then signal the deliberate exit.
|
||||
for (s in held) releaseHeld(s)
|
||||
onExitChord?.invoke()
|
||||
}
|
||||
}
|
||||
pendingExit = r
|
||||
mainHandler.postDelayed(r, EXIT_HOLD_MS)
|
||||
}
|
||||
|
||||
/** Cancel a pending exit-chord hold timer. */
|
||||
private fun disarmExit() {
|
||||
pendingExit?.let { mainHandler.removeCallbacks(it) }
|
||||
pendingExit = null
|
||||
}
|
||||
|
||||
/**
|
||||
* One joystick MotionEvent — routed to the producing device's own [Gamepad.AxisMapper] (per-device
|
||||
* state). Returns true if consumed. Only a real gamepad drives a pad: a DualSense/DS4 motion-sensor
|
||||
* sibling node classifies as bare joystick (no GAMEPAD source class) and reports every pad axis as
|
||||
* 0, so [isForwardable] filters it out before it can open a slot or clobber axes.
|
||||
*/
|
||||
fun onMotion(event: MotionEvent): Boolean {
|
||||
if (!event.isFromSource(InputDevice.SOURCE_JOYSTICK)) return false
|
||||
if (event.actionMasked != MotionEvent.ACTION_MOVE) return false
|
||||
val dev = event.device ?: return false
|
||||
if (!isForwardable(dev)) return false
|
||||
val slot = slotFor(dev) ?: return false
|
||||
slot.mapper.onMotion(event)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* The controller currently mapped to wire pad [pad], for feedback routing; null if that index
|
||||
* holds no live slot (a pad that just unplugged — the update is then dropped). Read from the
|
||||
* feedback poll threads.
|
||||
*/
|
||||
fun deviceForPad(pad: Int): InputDevice? {
|
||||
for ((deviceId, slot) in slots) {
|
||||
if (slot.index == pad) return InputDevice.getDevice(deviceId)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush + drop every slot and unregister the hot-plug listener. Call on session teardown, AFTER
|
||||
* the feedback poll threads are joined (they read [deviceForPad]).
|
||||
*/
|
||||
fun release() {
|
||||
inputManager?.unregisterInputDeviceListener(listener)
|
||||
disarmExit() // drop any pending exit-chord timer so it can't fire after teardown
|
||||
// Snapshot the ids first — closeSlot mutates the map.
|
||||
for (id in slots.keys.toList()) closeSlot(id)
|
||||
}
|
||||
|
||||
// ---- slots ----
|
||||
|
||||
/** A real, non-virtual controller we forward — its source classes include GAMEPAD (excludes a pad's bare-joystick sensor node). */
|
||||
private fun isForwardable(dev: InputDevice): Boolean =
|
||||
!dev.isVirtual && dev.sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD
|
||||
|
||||
/**
|
||||
* The slot for [dev], opening one (and declaring the pad) if this device is unseen; null when [dev]
|
||||
* isn't a forwardable controller or every wire index is taken. The [isForwardable] gate lives here —
|
||||
* the single lazy-open chokepoint both [onButton] and [onMotion] funnel through — so no entry point
|
||||
* can open a phantom slot for a virtual/non-gamepad source (the hot-plug listener and init loop
|
||||
* pre-filter and call [openSlot] directly).
|
||||
*/
|
||||
private fun slotFor(dev: InputDevice?): Slot? {
|
||||
if (dev == null) return null
|
||||
slots[dev.id]?.let { return it }
|
||||
if (!isForwardable(dev)) return null
|
||||
return openSlot(dev)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a slot for [dev] on the lowest free wire index, declaring its kind ([NativeBridge.nativeSendGamepadArrival])
|
||||
* before any input so the host builds a matching virtual device (mixed types across pads).
|
||||
* Idempotent; null when all 16 wire indices are already forwarded.
|
||||
*/
|
||||
private fun openSlot(dev: InputDevice): Slot? {
|
||||
slots[dev.id]?.let { return it }
|
||||
val index = lowestFreeIndex() ?: return null // 16 pads already forwarded — drop this one
|
||||
// Automatic resolves the pad's type from its VID/PID; an explicit setting forces every pad
|
||||
// to that type (a single global choice — matches the handshake's session-default pref).
|
||||
val pref = if (setting == Gamepad.PREF_AUTO) Gamepad.prefFor(dev) else setting
|
||||
NativeBridge.nativeSendGamepadArrival(handle, pref, index)
|
||||
val slot = Slot(index, Gamepad.AxisMapper(handle, index))
|
||||
slots[dev.id] = slot
|
||||
return slot
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush a slot's held wire state (so nothing sticks host-side), signal the removal, and free its
|
||||
* index. Safe against an already-gone device — the flush emits wire events only, no device access.
|
||||
*/
|
||||
private fun closeSlot(deviceId: Int) {
|
||||
val slot = slots.remove(deviceId) ?: return
|
||||
releaseHeld(slot)
|
||||
NativeBridge.nativeSendGamepadRemove(handle, slot.index)
|
||||
// If this pad was mid-exit-chord, its removal may have left no pad holding it — drop the timer.
|
||||
if (slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) disarmExit()
|
||||
// Release this controller's feedback bindings (close its lights session / cancel rumble).
|
||||
onSlotClosed?.invoke(deviceId)
|
||||
}
|
||||
|
||||
/** Lift every held button + zero the axes/HAT dpad for [slot] (wire events only, all on its index). */
|
||||
private fun releaseHeld(slot: Slot) {
|
||||
var bits = slot.held
|
||||
while (bits != 0) {
|
||||
val bit = bits and -bits // lowest set bit
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
bits = bits and bit.inv()
|
||||
}
|
||||
slot.held = 0
|
||||
slot.mapper.reset() // zero sticks/triggers + release the HAT dpad
|
||||
}
|
||||
|
||||
/** Lowest wire index 0..[MAX_PADS) not held by a slot, or null when full — stable lowest-free keeps indices from shuffling on hot-plug. */
|
||||
private fun lowestFreeIndex(): Int? {
|
||||
val taken = slots.values.mapTo(HashSet()) { it.index }
|
||||
for (i in 0 until MAX_PADS) if (i !in taken) return i
|
||||
return null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Mirror of `punktfunk-core::input::MAX_PADS` — wire pad indices 0..15. */
|
||||
const val MAX_PADS = 16
|
||||
|
||||
/** Emergency stream-exit chord: Select + Start + L1 + R1 held together (matches the legacy single-pad chord). */
|
||||
const val EXIT_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_START or Gamepad.BTN_LB or Gamepad.BTN_RB
|
||||
|
||||
/** How long the exit chord must be held before the stream leaves — matches SDL/Apple `DISCONNECT_HOLD`. */
|
||||
const val EXIT_HOLD_MS = 1500L
|
||||
}
|
||||
}
|
||||
@@ -269,26 +269,43 @@ object NativeBridge {
|
||||
/** One key transition. vk: Windows VK (0 = dropped by Rust). mods: VK modifier mask (0 for now). */
|
||||
external fun nativeSendKey(handle: Long, vk: Int, down: Boolean, mods: Int)
|
||||
|
||||
// ---- Gamepad: one pad forwarded as pad 0 (Rust hardcodes flags=0) ----
|
||||
// ---- Gamepad: each controller forwarded on its own wire pad index (0..15, low byte of flags) ----
|
||||
// The pad index is assigned per Android device by GamepadRouter; a single controller lands on 0,
|
||||
// so its wire is byte-identical to the old single-pad path. The core folds the per-transition
|
||||
// events into seq'd GamepadState snapshots keyed on this index and owns the per-pad seq.
|
||||
|
||||
/** One gamepad button transition. bit: a [Gamepad].BTN_* bit. down: press/release. */
|
||||
external fun nativeSendGamepadButton(handle: Long, bit: Int, down: Boolean)
|
||||
/** One gamepad button transition on wire pad [pad] (0..15). bit: a [Gamepad].BTN_* bit. down: press/release. */
|
||||
external fun nativeSendGamepadButton(handle: Long, bit: Int, down: Boolean, pad: Int)
|
||||
|
||||
/** One gamepad axis update. axisId: [Gamepad].AXIS_* (0..5). value: stick i16 (+y=up) / trigger 0..255. */
|
||||
external fun nativeSendGamepadAxis(handle: Long, axisId: Int, value: Int)
|
||||
/** One gamepad axis update on wire pad [pad] (0..15). axisId: [Gamepad].AXIS_* (0..5). value: stick i16 (+y=up) / trigger 0..255. */
|
||||
external fun nativeSendGamepadAxis(handle: Long, axisId: Int, value: Int, pad: Int)
|
||||
|
||||
/**
|
||||
* Declare the controller KIND presented on wire pad [pad] (0..15) so the host builds a matching
|
||||
* virtual device (mixed types across pads). pref: a [Gamepad].PREF_* wire byte. Send ONCE when a
|
||||
* pad opens, BEFORE any of its input; an older host ignores it (that pad then uses the handshake's
|
||||
* session-default kind — the pre-existing single-pad behaviour on pad 0).
|
||||
*/
|
||||
external fun nativeSendGamepadArrival(handle: Long, pref: Int, pad: Int)
|
||||
|
||||
/** Signal wire pad [pad] (0..15) was unplugged so the host tears its virtual device down. The core stamps the seq + re-sends. */
|
||||
external fun nativeSendGamepadRemove(handle: Long, pad: Int)
|
||||
|
||||
// ---- Host→client gamepad feedback: Rust pulls block ~100ms, Kotlin renders (see GamepadFeedback) ----
|
||||
|
||||
/**
|
||||
* Block up to ~100 ms for the next rumble update. Returns `(low shl 16) or high` (each
|
||||
* 0..0xFFFF; 0 = stop), or -1 on timeout / session closed. Call from a dedicated poll thread.
|
||||
* Block up to ~100 ms for the next rumble update. Returns a packed positive long: bits 49..52 =
|
||||
* wire pad index (0..15), bit 48 = has a v2 lease, bits 32..47 = ttl_ms, bits 16..31 = low, bits
|
||||
* 0..15 = high (each amplitude 0..0xFFFF; 0/0 = stop), or -1 on timeout / session closed. Kotlin
|
||||
* routes the update to the controller holding that pad index. Call from a dedicated poll thread.
|
||||
*/
|
||||
external fun nativeNextRumble(handle: Long): Long
|
||||
|
||||
/**
|
||||
* Block up to ~100 ms for the next DualSense HID-output event, written into [buf] (a direct
|
||||
* ByteBuffer, capacity >= 64) as `[kind][fields…]`: Led=01 r g b, PlayerLeds=02 bits,
|
||||
* Trigger=03 which effect…. Returns the byte count, or -1 on timeout / session closed.
|
||||
* ByteBuffer, capacity >= 64) as `[pad][kind][fields…]` (leading pad = the wire pad index to
|
||||
* route to): Led=pad 01 r g b, PlayerLeds=pad 02 bits, Trigger=pad 03 which effect…. Returns the
|
||||
* byte count, or -1 on timeout / session closed.
|
||||
*/
|
||||
external fun nativeNextHidout(handle: Long, buf: java.nio.ByteBuffer): Int
|
||||
}
|
||||
|
||||
@@ -43,15 +43,20 @@ tracing = { version = "0.1", default-features = false, features = ["std", "log"]
|
||||
# Pure-Rust FFI to libmediandk/libnativewindow/libaaudio — no C++/libc++_shared to bundle. Decode +
|
||||
# audio run entirely in Rust on native threads (the "no async on the hot path" invariant).
|
||||
# api-level-28 matches the app's minSdk floor (Android 9). AAudio (26), AMediaCodec (21) and
|
||||
# ANativeWindow_setBuffersDataSpace (28) are all ≤28; the one API-30 call we make
|
||||
# (ANativeWindow_setFrameRate) is dlsym-resolved at runtime (see decode::try_set_frame_rate), not
|
||||
# linked, so the .so still loads on API 28/29.
|
||||
# ANativeWindow_setBuffersDataSpace (28) are all ≤28; every call above the floor —
|
||||
# ANativeWindow_setFrameRate (30), …WithChangeStrategy (31), AMediaCodec_setOnFrameRenderedCallback
|
||||
# (33), the ADPF hints — is dlsym-resolved at runtime (decode::try_set_frame_rate,
|
||||
# decode::install_render_callback, adpf), never linked, so the .so still loads on API 28+.
|
||||
ndk = { version = "0.9", features = ["media", "audio", "nativewindow", "api-level-28"] }
|
||||
# Raw FFI for the one AMediaCodec entry point the `ndk` wrapper lacks:
|
||||
# `AMediaCodec_setOnFrameRenderedCallback` (API 26, under the minSdk-28 floor ⇒ hard-linked) — the
|
||||
# per-frame render-timestamp callback behind the HUD's `display` stage (see decode::DisplayTracker).
|
||||
# Reaching it needs the codec's raw pointer, which is why the workspace pins `ndk` to the vendored
|
||||
# copy whose only patch makes `MediaCodec::as_ptr` public (vendor/ndk, wired in the root Cargo.toml).
|
||||
# Raw FFI *types* for the one AMediaCodec entry point the `ndk` wrapper lacks:
|
||||
# `AMediaCodec_setOnFrameRenderedCallback` — the per-frame render-timestamp callback behind the
|
||||
# HUD's `display` stage (see decode::DisplayTracker). The symbol is API 33 ("Available since
|
||||
# Android T"), ABOVE the minSdk-28 floor, so it is dlsym-resolved at runtime
|
||||
# (decode::install_render_callback), NEVER hard-linked: 0.9.0 hard-linked it and
|
||||
# `System.loadLibrary` failed on every pre-Android-13 device. Only ndk-sys's pointer/typedef
|
||||
# types are referenced, which creates no import. Reaching it needs the codec's raw pointer, which
|
||||
# is why the workspace pins `ndk` to the vendored copy whose only patch makes `MediaCodec::as_ptr`
|
||||
# public (vendor/ndk, wired in the root Cargo.toml).
|
||||
ndk-sys = { version = "0.6", features = ["media"] }
|
||||
# setpriority/gettid to raise the decode thread toward URGENT_DISPLAY (see decode::boost_thread_priority).
|
||||
libc = "0.2"
|
||||
|
||||
@@ -355,45 +355,70 @@ fn decode_loop(
|
||||
};
|
||||
let mut pcm = vec![0f32; pcm_scratch];
|
||||
let mut window_peak = 0f32; // loudest |sample| since the last log — tells a tone from silence
|
||||
while !shutdown.load(Ordering::Relaxed) {
|
||||
let mut gaps = punktfunk_core::audio::AudioGapTracker::new();
|
||||
let mut frame_samples = 0usize; // per-channel samples of the last decoded frame — the PLC unit
|
||||
'pump: while !shutdown.load(Ordering::Relaxed) {
|
||||
match client.next_audio(Duration::from_millis(5)) {
|
||||
Ok(pkt) => match dec.decode_float(&pkt.data, &mut pcm, false) {
|
||||
Ok(samples) => {
|
||||
let n = samples * channels;
|
||||
for &s in &pcm[..n] {
|
||||
window_peak = window_peak.max(s.abs());
|
||||
Ok(pkt) => {
|
||||
// Conceal lost packets (a seq gap) with libopus PLC before decoding the one that
|
||||
// arrived: empty input synthesizes `frame_samples` of interpolation per missing
|
||||
// packet — an inaudible fade instead of the click a hard gap makes in the ring.
|
||||
for _ in 0..gaps.missing_before(pkt.seq) {
|
||||
let plc = frame_samples * channels;
|
||||
if plc == 0 {
|
||||
break; // no decoded frame yet to size the concealment from
|
||||
}
|
||||
// The ring's pre-reservation in `start` assumes the protocol's 5 ms (≤480-f32/ch)
|
||||
// frames; a larger frame would force a one-time realloc on the RT thread. Catch a
|
||||
// future host frame-size change here in debug, not as a silent audio glitch.
|
||||
debug_assert!(
|
||||
n <= 5 * ms,
|
||||
"audio frame {n} f32 exceeds the 5 ms ring reserve"
|
||||
);
|
||||
let count = counters.opus_decoded.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
// Reuse a recycled buffer if the callback handed one back; only allocate when the
|
||||
// free-list is momentarily empty (startup / after a backpressure drop).
|
||||
let mut buf = free_rx
|
||||
.try_recv()
|
||||
.unwrap_or_else(|_| Vec::with_capacity(pcm_scratch));
|
||||
buf.clear();
|
||||
buf.extend_from_slice(&pcm[..n]);
|
||||
match tx.try_send(buf) {
|
||||
Ok(()) | Err(TrySendError::Full(_)) => {} // drop-newest under backpressure
|
||||
Err(TrySendError::Disconnected(_)) => break,
|
||||
}
|
||||
if count % 600 == 0 {
|
||||
log::info!(
|
||||
"audio: opus={count} pcm_frames={} underruns={} ring={} peak={window_peak:.3}",
|
||||
counters.pcm_written.load(Ordering::Relaxed),
|
||||
counters.underruns.load(Ordering::Relaxed),
|
||||
counters.ring_depth.load(Ordering::Relaxed),
|
||||
);
|
||||
window_peak = 0.0;
|
||||
if let Ok(samples) = dec.decode_float(&[], &mut pcm[..plc], false) {
|
||||
let mut buf = free_rx
|
||||
.try_recv()
|
||||
.unwrap_or_else(|_| Vec::with_capacity(pcm_scratch));
|
||||
buf.clear();
|
||||
buf.extend_from_slice(&pcm[..samples * channels]);
|
||||
match tx.try_send(buf) {
|
||||
Ok(()) | Err(TrySendError::Full(_)) => {}
|
||||
Err(TrySendError::Disconnected(_)) => break 'pump,
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => log::debug!("audio: opus decode: {e}"),
|
||||
},
|
||||
match dec.decode_float(&pkt.data, &mut pcm, false) {
|
||||
Ok(samples) => {
|
||||
frame_samples = samples;
|
||||
let n = samples * channels;
|
||||
for &s in &pcm[..n] {
|
||||
window_peak = window_peak.max(s.abs());
|
||||
}
|
||||
// The ring's pre-reservation in `start` assumes the protocol's 5 ms (≤480-f32/ch)
|
||||
// frames; a larger frame would force a one-time realloc on the RT thread. Catch a
|
||||
// future host frame-size change here in debug, not as a silent audio glitch.
|
||||
debug_assert!(
|
||||
n <= 5 * ms,
|
||||
"audio frame {n} f32 exceeds the 5 ms ring reserve"
|
||||
);
|
||||
let count = counters.opus_decoded.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
// Reuse a recycled buffer if the callback handed one back; only allocate when the
|
||||
// free-list is momentarily empty (startup / after a backpressure drop).
|
||||
let mut buf = free_rx
|
||||
.try_recv()
|
||||
.unwrap_or_else(|_| Vec::with_capacity(pcm_scratch));
|
||||
buf.clear();
|
||||
buf.extend_from_slice(&pcm[..n]);
|
||||
match tx.try_send(buf) {
|
||||
Ok(()) | Err(TrySendError::Full(_)) => {} // drop-newest under backpressure
|
||||
Err(TrySendError::Disconnected(_)) => break,
|
||||
}
|
||||
if count % 600 == 0 {
|
||||
log::info!(
|
||||
"audio: opus={count} pcm_frames={} underruns={} ring={} peak={window_peak:.3}",
|
||||
counters.pcm_written.load(Ordering::Relaxed),
|
||||
counters.underruns.load(Ordering::Relaxed),
|
||||
counters.ring_depth.load(Ordering::Relaxed),
|
||||
);
|
||||
window_peak = 0.0;
|
||||
}
|
||||
}
|
||||
Err(e) => log::debug!("audio: opus decode: {e}"),
|
||||
}
|
||||
}
|
||||
Err(PunktfunkError::NoFrame) => {} // timeout
|
||||
Err(_) => break, // session closed
|
||||
}
|
||||
|
||||
@@ -15,10 +15,11 @@ use ndk::media::media_format::MediaFormat;
|
||||
use ndk::native_window::NativeWindow;
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::error::PunktfunkError;
|
||||
use punktfunk_core::reanchor::{GateVerdict, ReanchorGate};
|
||||
use punktfunk_core::session::Frame;
|
||||
use std::collections::VecDeque;
|
||||
use std::ffi::c_void;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
|
||||
use std::sync::{mpsc, Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -202,23 +203,31 @@ fn run_sync(
|
||||
let mut fed: u64 = 0;
|
||||
let mut rendered: u64 = 0;
|
||||
let mut discarded: u64 = 0;
|
||||
// AUs larger than the codec input buffer, dropped whole (see `feed`/`feed_ready`).
|
||||
let mut oversized_dropped: u64 = 0;
|
||||
// The AU waiting for a free codec input buffer. `feed` is non-blocking; on transient input
|
||||
// pressure the AU stays parked here instead of being dropped (a drop forces a keyframe
|
||||
// round-trip) and we only pop the next one once it's queued.
|
||||
let mut pending: Option<Frame> = None;
|
||||
// Loss recovery: watch the host→client unrecoverable-drop count and ask for an IDR when it
|
||||
// climbs.
|
||||
let mut last_dropped = client.frames_dropped();
|
||||
// Freeze-until-reanchor: the shared post-loss gate ([`punktfunk_core::reanchor::ReanchorGate`]).
|
||||
// Armed on a frame-index gap or a dropped-count climb, it withholds the decoder's concealed output
|
||||
// (released WITHOUT rendering — the SurfaceView keeps the last rendered frame on glass) until a
|
||||
// proven clean re-anchor lifts it: an IDR (wire FLAG_SOF), an RFI anchor, or the 2nd recovery mark.
|
||||
// `last_kf_req` throttles the keyframe intents it emits; `recovery_flags` carries each AU's
|
||||
// user_flags from feed to present (keyed by the codec-echoed pts) so `on_decoded` reads the
|
||||
// re-anchor signalling the platform decoder doesn't expose.
|
||||
let mut gate = ReanchorGate::new(client.frames_dropped());
|
||||
let mut recovery_flags: VecDeque<(u64, u32)> = VecDeque::new();
|
||||
let mut last_kf_req: Option<Instant> = None;
|
||||
// Skew-corrected latency stats (spec: design/stats-unification.md) use the negotiated
|
||||
// host-minus-client clock offset (0 if the host didn't answer the skew handshake — then the
|
||||
// HUD flags it "(same-host clock)").
|
||||
let clock_offset = client.clock_offset_ns;
|
||||
let clock_offset = client.clock_offset_shared();
|
||||
// Display stage (spec `display` + the capture→displayed headline): frames released with
|
||||
// render = true are parked in the tracker; the OnFrameRendered callback pairs them with
|
||||
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
|
||||
// reclaimed after the codec is dropped below.
|
||||
let tracker = DisplayTracker::new(stats.clone(), clock_offset);
|
||||
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone());
|
||||
let render_cb = install_render_callback(&codec, &tracker);
|
||||
// HUD stage split: receipt timestamps keyed by the pts we queue into the codec, so the decoded
|
||||
// point (output-buffer dequeue — MediaCodec round-trips presentationTimeUs) can be paired back
|
||||
@@ -241,6 +250,20 @@ fn run_sync(
|
||||
if pending.is_none() {
|
||||
match client.next_frame(Duration::from_millis(5)) {
|
||||
Ok(frame) => {
|
||||
// Loss recovery (RFI): feed the frame index so a forward gap fires a throttled
|
||||
// reference-frame-invalidation request — an RFI-capable host (AMD LTR / NVENC)
|
||||
// recovers with a cheap clean P-frame instead of a full IDR. The same forward gap
|
||||
// arms the freeze gate so the decoder's concealment is held off the screen until the
|
||||
// recovery re-anchors. The frames_dropped keyframe path below stays the backstop.
|
||||
if client.note_frame_index(frame.frame_index) {
|
||||
gate.arm(Instant::now());
|
||||
}
|
||||
// Park this AU's re-anchor flags for the present side (keyed by the pts the codec
|
||||
// echoes on the output buffer) — unconditional, unlike the HUD's `in_flight` map.
|
||||
recovery_flags.push_back((frame.pts_ns / 1000, frame.flags));
|
||||
if recovery_flags.len() > IN_FLIGHT_CAP {
|
||||
recovery_flags.pop_front();
|
||||
}
|
||||
if fed == 0 {
|
||||
let p = &frame.data;
|
||||
log::info!(
|
||||
@@ -256,6 +279,7 @@ fn run_sync(
|
||||
// the output buffer) for the decoded-point pairing in `drain`.
|
||||
if stats.enabled() {
|
||||
let received_ns = now_realtime_ns();
|
||||
let clock_offset = clock_offset.load(Ordering::Relaxed);
|
||||
let lat_ns = received_ns + clock_offset as i128 - frame.pts_ns as i128;
|
||||
let lat_us = (lat_ns > 0 && lat_ns < 10_000_000_000)
|
||||
.then_some((lat_ns / 1000) as u64);
|
||||
@@ -295,7 +319,13 @@ fn run_sync(
|
||||
// and excluded, so ADPF sees this thread's real per-frame CPU cost, not the poll timeout.
|
||||
let work_t0 = Instant::now();
|
||||
if let Some(frame) = pending.take() {
|
||||
if feed(&codec, &frame.data, frame.pts_ns / 1000) {
|
||||
if feed(
|
||||
&codec,
|
||||
&client,
|
||||
&frame.data,
|
||||
frame.pts_ns / 1000,
|
||||
&mut oversized_dropped,
|
||||
) {
|
||||
fed += 1;
|
||||
if fed % 300 == 0 {
|
||||
log::info!("decode: fed={fed} rendered={rendered} discarded={discarded}");
|
||||
@@ -320,8 +350,10 @@ fn run_sync(
|
||||
wait,
|
||||
&stats,
|
||||
&mut in_flight,
|
||||
clock_offset,
|
||||
clock_offset.load(Ordering::Relaxed),
|
||||
&tracker,
|
||||
&mut gate,
|
||||
&mut recovery_flags,
|
||||
);
|
||||
rendered += r;
|
||||
discarded += d;
|
||||
@@ -361,21 +393,19 @@ fn run_sync(
|
||||
work_accum_ns = 0;
|
||||
}
|
||||
|
||||
// Loss recovery: under infinite GOP the only recovery keyframe is one we request. The
|
||||
// reassembler drops unrecoverable AUs (frames_dropped); the decoder then conceals the
|
||||
// reference-missing delta frames that follow and renders them without error, so keying off
|
||||
// a decode error rarely fires. Request an IDR when the drop count climbs, throttled — the
|
||||
// decode stays wedged for several frames until the IDR lands, so requesting every frame
|
||||
// would flood the control stream.
|
||||
let dropped = client.frames_dropped();
|
||||
if dropped > last_dropped {
|
||||
last_dropped = dropped;
|
||||
let now = Instant::now();
|
||||
if last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) {
|
||||
last_kf_req = Some(now);
|
||||
let _ = client.request_keyframe();
|
||||
log::debug!("decode: requested keyframe (loss recovery, dropped={dropped})");
|
||||
}
|
||||
// Loss recovery + overdue backstop, folded through the gate. Under infinite GOP the only
|
||||
// recovery keyframe is one we request; the reassembler drops unrecoverable AUs (frames_dropped)
|
||||
// and the decoder then conceals the reference-missing deltas and renders them without error, so
|
||||
// a decode-error trigger rarely fires — the gate arms the freeze on the drop-count climb
|
||||
// instead. An overdue freeze (held REANCHOR_FREEZE_MAX with no clean re-anchor) re-asks while it
|
||||
// keeps holding: never resume to gray — a dead stream is the QUIC idle-timeout watchdog's job.
|
||||
let now = Instant::now();
|
||||
if gate.poll(client.frames_dropped(), now)
|
||||
&& last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
|
||||
{
|
||||
last_kf_req = Some(now);
|
||||
let _ = client.request_keyframe();
|
||||
log::debug!("decode: requested keyframe (loss recovery / overdue re-anchor)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,8 +448,10 @@ fn now_monotonic_ns() -> i128 {
|
||||
/// endpoint whenever the platform delivers render callbacks).
|
||||
struct DisplayTracker {
|
||||
stats: Arc<crate::stats::VideoStats>,
|
||||
/// Host-minus-client clock offset (ns) for the skew-corrected end-to-end sample.
|
||||
clock_offset: i64,
|
||||
/// Live host-minus-client clock offset (ns) for the skew-corrected end-to-end sample —
|
||||
/// loaded per callback so mid-stream re-syncs apply. Holding the handle (not the client)
|
||||
/// keeps the leaked render-callback refcount from pinning the whole session alive.
|
||||
clock_offset: Arc<AtomicI64>,
|
||||
/// `(pts_us, decoded_real_ns)` of frames released with `render = true`, in release order,
|
||||
/// awaiting their callback. Pushes are HUD-gated by the caller, so this stays empty (and the
|
||||
/// callback early-outs) while the overlay is hidden.
|
||||
@@ -427,7 +459,10 @@ struct DisplayTracker {
|
||||
}
|
||||
|
||||
impl DisplayTracker {
|
||||
fn new(stats: Arc<crate::stats::VideoStats>, clock_offset: i64) -> Arc<DisplayTracker> {
|
||||
fn new(
|
||||
stats: Arc<crate::stats::VideoStats>,
|
||||
clock_offset: Arc<AtomicI64>,
|
||||
) -> Arc<DisplayTracker> {
|
||||
Arc::new(DisplayTracker {
|
||||
stats,
|
||||
clock_offset,
|
||||
@@ -449,26 +484,49 @@ impl DisplayTracker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Register [`on_frame_rendered`] on the codec (`AMediaCodec_setOnFrameRenderedCallback`, API 26 —
|
||||
/// under the minSdk-28 floor, so hard-linked via `ndk-sys`; the `ndk` wrapper has no binding, which
|
||||
/// is what the vendored crate's public `as_ptr` patch is for). Returns the userdata pointer holding
|
||||
/// a leaked `Arc<DisplayTracker>` refcount; the caller MUST reclaim it with
|
||||
/// [`release_render_callback`] AFTER dropping the codec (`AMediaCodec_delete` is what guarantees no
|
||||
/// further callback can fire). `None` (nothing to reclaim) if the platform refused — the HUD then
|
||||
/// simply has no `display` stage, exactly the pre-callback behaviour.
|
||||
/// Register [`on_frame_rendered`] on the codec (`AMediaCodec_setOnFrameRenderedCallback`,
|
||||
/// **API 33** — "Available since Android T" per the NDK header; only the *Java* listener dates
|
||||
/// back further). That sits above the API-28 floor, so the entry point is dlsym-resolved at
|
||||
/// runtime like [`try_set_frame_rate`] — hard-linking it (as 0.9.0 shipped) made
|
||||
/// `System.loadLibrary` fail on every pre-Android-13 device, taking down all of `NativeBridge`.
|
||||
/// The `ndk` wrapper has no binding and the call needs the raw codec pointer, which is what the
|
||||
/// vendored crate's public `as_ptr` patch is for. Returns the userdata pointer holding a leaked
|
||||
/// `Arc<DisplayTracker>` refcount; the caller MUST reclaim it with [`release_render_callback`]
|
||||
/// AFTER dropping the codec (`AMediaCodec_delete` is what guarantees no further callback can
|
||||
/// fire). `None` (nothing to reclaim) if the symbol is absent (API < 33) or the platform refused —
|
||||
/// the HUD then simply has no `display` stage, exactly the pre-callback behaviour.
|
||||
fn install_render_callback(
|
||||
codec: &MediaCodec,
|
||||
tracker: &Arc<DisplayTracker>,
|
||||
) -> Option<*const DisplayTracker> {
|
||||
// media_status_t AMediaCodec_setOnFrameRenderedCallback(
|
||||
// AMediaCodec*, AMediaCodecOnFrameRendered, void*) (API 33)
|
||||
type SetOnFrameRenderedFn = unsafe extern "C" fn(
|
||||
*mut ndk_sys::AMediaCodec,
|
||||
ndk_sys::AMediaCodecOnFrameRendered,
|
||||
*mut c_void,
|
||||
) -> ndk_sys::media_status_t;
|
||||
// SAFETY: `dlopen` of `libmediandk.so`, which the `ndk` media wrapper already links — always
|
||||
// mapped, so this only bumps its refcount (never closed — process-lifetime handle). `dlsym`
|
||||
// returns null when the symbol is absent (device below API 33), checked before transmuting the
|
||||
// non-null pointer to its fn-pointer type.
|
||||
let set_on_frame_rendered = unsafe {
|
||||
let lib = libc::dlopen(c"libmediandk.so".as_ptr(), libc::RTLD_NOW);
|
||||
if lib.is_null() {
|
||||
return None;
|
||||
}
|
||||
let sym = libc::dlsym(lib, c"AMediaCodec_setOnFrameRenderedCallback".as_ptr());
|
||||
if sym.is_null() {
|
||||
log::info!("decode: no render callback on this API level (<33) — no display stage");
|
||||
return None;
|
||||
}
|
||||
std::mem::transmute::<*mut c_void, SetOnFrameRenderedFn>(sym)
|
||||
};
|
||||
let ud = Arc::into_raw(tracker.clone());
|
||||
// SAFETY: `codec.as_ptr()` is the live codec this thread owns; `ud` outlives the registration
|
||||
// (reclaimed only after the codec is deleted, per this function's contract).
|
||||
let status = unsafe {
|
||||
ndk_sys::AMediaCodec_setOnFrameRenderedCallback(
|
||||
codec.as_ptr(),
|
||||
Some(on_frame_rendered),
|
||||
ud as *mut c_void,
|
||||
)
|
||||
set_on_frame_rendered(codec.as_ptr(), Some(on_frame_rendered), ud as *mut c_void)
|
||||
};
|
||||
if status == ndk_sys::media_status_t::AMEDIA_OK {
|
||||
Some(ud)
|
||||
@@ -531,7 +589,8 @@ unsafe extern "C" fn on_frame_rendered(
|
||||
}
|
||||
}
|
||||
}
|
||||
let e2e_ns = displayed_ns + t.clock_offset as i128 - pts_us as i128 * 1000;
|
||||
let e2e_ns =
|
||||
displayed_ns + t.clock_offset.load(Ordering::Relaxed) as i128 - pts_us as i128 * 1000;
|
||||
let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64);
|
||||
let display_us = decoded_ns.map(|d| ((displayed_ns - d).max(0) / 1000) as u64);
|
||||
t.stats.note_displayed(e2e_us, display_us);
|
||||
@@ -664,8 +723,10 @@ struct OutputReady {
|
||||
/// internal looper thread) push the codec ones; the feeder thread pushes `Au`. Each carries only
|
||||
/// owned/`Copy` data so the callback closures satisfy the `Send` bound and never touch the codec.
|
||||
enum DecodeEvent {
|
||||
/// A received access unit from the feeder, ready to queue into the decoder.
|
||||
Au(Frame),
|
||||
/// A received access unit from the feeder, ready to queue into the decoder. The `bool` is the
|
||||
/// feeder's [`NativeClient::note_frame_index`] verdict — `true` when this AU revealed a forward
|
||||
/// frame-index gap, so the loop arms the freeze gate (the feeder already fired the RFI request).
|
||||
Au(Frame, bool),
|
||||
/// An input buffer slot freed (index) — we can queue an AU into it.
|
||||
InputAvailable(usize),
|
||||
/// A decoded frame is ready (buffer index + echoed pts + the callback-time `decoded` stamp).
|
||||
@@ -804,13 +865,13 @@ fn run_async(
|
||||
// pts we queue) live in a shared map: the feeder writes them at receipt, this loop pairs decoded
|
||||
// output back to them. Behind a `Mutex` since two threads touch it — only ever locked while the
|
||||
// HUD is visible.
|
||||
let clock_offset = client.clock_offset_ns;
|
||||
let clock_offset = client.clock_offset_shared();
|
||||
let in_flight = Arc::new(Mutex::new(VecDeque::<(u64, i128)>::new()));
|
||||
// Display stage (spec `display` + the capture→displayed headline): the rendered frame is
|
||||
// parked in the tracker at release; the OnFrameRendered callback pairs it with
|
||||
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
|
||||
// reclaimed after the codec is dropped below.
|
||||
let tracker = DisplayTracker::new(stats.clone(), clock_offset);
|
||||
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone());
|
||||
let render_cb = install_render_callback(&codec, &tracker);
|
||||
|
||||
// Feeder thread: block on the network so this loop doesn't (an AU's arrival becomes an event that
|
||||
@@ -819,19 +880,13 @@ fn run_async(
|
||||
let client = client.clone();
|
||||
let stats = stats.clone();
|
||||
let in_flight = in_flight.clone();
|
||||
let clock_offset = clock_offset.clone();
|
||||
let shutdown = shutdown.clone();
|
||||
let ev_tx = ev_tx.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("pf-decode-feed".into())
|
||||
.spawn(move || {
|
||||
feeder_loop(
|
||||
client,
|
||||
stats,
|
||||
in_flight,
|
||||
clock_offset as i128,
|
||||
shutdown,
|
||||
ev_tx,
|
||||
);
|
||||
feeder_loop(client, stats, in_flight, clock_offset, shutdown, ev_tx);
|
||||
})
|
||||
.ok()
|
||||
};
|
||||
@@ -855,7 +910,14 @@ fn run_async(
|
||||
let mut fed: u64 = 0;
|
||||
let mut rendered: u64 = 0;
|
||||
let mut discarded: u64 = 0;
|
||||
let mut last_dropped = client.frames_dropped();
|
||||
// AUs larger than the codec input buffer, dropped whole (see `feed`/`feed_ready`).
|
||||
let mut oversized_dropped: u64 = 0;
|
||||
// Freeze-until-reanchor gate (see the sync loop for the rationale). Armed on a frame-index gap
|
||||
// (the feeder's Au verdict), a parked-AU overflow drop, a dropped-count climb, or a recoverable
|
||||
// codec error; `recovery_flags` carries each AU's user_flags from `dispatch_event` (feed) to
|
||||
// `present_ready` (present), keyed by the codec-echoed pts.
|
||||
let mut gate = ReanchorGate::new(client.frames_dropped());
|
||||
let mut recovery_flags: VecDeque<(u64, u32)> = VecDeque::new();
|
||||
let mut last_kf_req: Option<Instant> = None;
|
||||
// Productive (dispatch+feed+present) time between displayed frames; reported to ADPF once one is
|
||||
// presented. The blocking event wait is excluded (idle, not work) — same accounting as the sync loop.
|
||||
@@ -881,6 +943,8 @@ fn run_async(
|
||||
&mut ready,
|
||||
&mut fmt_dirty,
|
||||
&mut fatal,
|
||||
&mut gate,
|
||||
&mut recovery_flags,
|
||||
));
|
||||
}
|
||||
// Coalesce every other event already queued into this one work pass — correct newest-only
|
||||
@@ -893,23 +957,34 @@ fn run_async(
|
||||
&mut ready,
|
||||
&mut fmt_dirty,
|
||||
&mut fatal,
|
||||
&mut gate,
|
||||
&mut recovery_flags,
|
||||
));
|
||||
}
|
||||
stats.note_skipped(aus_dropped); // parked-AU overflow drops are client-side skips too
|
||||
if fmt_dirty {
|
||||
apply_hdr_dataspace(&codec, &window, &mut applied_ds);
|
||||
}
|
||||
feed_ready(&codec, &mut pending_aus, &mut free_inputs, &mut fed);
|
||||
feed_ready(
|
||||
&codec,
|
||||
&client,
|
||||
&mut pending_aus,
|
||||
&mut free_inputs,
|
||||
&mut fed,
|
||||
&mut oversized_dropped,
|
||||
);
|
||||
let had_output = !ready.is_empty();
|
||||
present_ready(
|
||||
&codec,
|
||||
&mut ready,
|
||||
&stats,
|
||||
&in_flight,
|
||||
clock_offset,
|
||||
clock_offset.load(Ordering::Relaxed),
|
||||
&tracker,
|
||||
&mut rendered,
|
||||
&mut discarded,
|
||||
&mut gate,
|
||||
&mut recovery_flags,
|
||||
);
|
||||
|
||||
work_accum_ns += work_t0.elapsed().as_nanos() as i64;
|
||||
@@ -941,17 +1016,19 @@ fn run_async(
|
||||
log::info!("decode: fed={fed} rendered={rendered} discarded={discarded}");
|
||||
}
|
||||
}
|
||||
// Loss recovery: request an IDR when the reassembler's unrecoverable-drop count climbs (or we
|
||||
// dropped a parked AU on overflow), throttled so a multi-frame recovery gap doesn't flood the
|
||||
// control stream.
|
||||
let dropped = client.frames_dropped();
|
||||
if dropped > last_dropped || aus_dropped > 0 {
|
||||
last_dropped = dropped;
|
||||
let now = Instant::now();
|
||||
if last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) {
|
||||
last_kf_req = Some(now);
|
||||
let _ = client.request_keyframe();
|
||||
}
|
||||
// Loss recovery + overdue backstop, folded through the gate. A parked-AU overflow drop is itself
|
||||
// a loss, so it arms the freeze directly; the gate's `poll` then arms on a dropped-count climb
|
||||
// and re-asks on an overdue freeze. All keyframe intents route through the shared 100 ms
|
||||
// throttle so a multi-frame recovery gap can't flood the control stream.
|
||||
let now = Instant::now();
|
||||
if aus_dropped > 0 {
|
||||
gate.arm(now);
|
||||
}
|
||||
if (gate.poll(client.frames_dropped(), now) || aus_dropped > 0)
|
||||
&& last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
|
||||
{
|
||||
last_kf_req = Some(now);
|
||||
let _ = client.request_keyframe();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -976,7 +1053,7 @@ fn feeder_loop(
|
||||
client: Arc<NativeClient>,
|
||||
stats: Arc<crate::stats::VideoStats>,
|
||||
in_flight: Arc<Mutex<VecDeque<(u64, i128)>>>,
|
||||
clock_offset: i128,
|
||||
clock_offset: Arc<AtomicI64>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
ev_tx: mpsc::Sender<DecodeEvent>,
|
||||
) {
|
||||
@@ -985,8 +1062,14 @@ fn feeder_loop(
|
||||
while !shutdown.load(Ordering::Relaxed) {
|
||||
match client.next_frame(Duration::from_millis(5)) {
|
||||
Ok(frame) => {
|
||||
// Loss recovery (RFI): a forward frame-index gap fires a throttled reference-frame-
|
||||
// invalidation request so an RFI-capable host recovers with a cheap clean P-frame
|
||||
// instead of a full IDR (the frames_dropped keyframe path is the backstop). The gap
|
||||
// verdict rides the Au event so the decode loop arms its freeze gate on the same signal.
|
||||
let gap = client.note_frame_index(frame.frame_index);
|
||||
if stats.enabled() {
|
||||
let received_ns = now_realtime_ns();
|
||||
let clock_offset = clock_offset.load(Ordering::Relaxed) as i128;
|
||||
let lat_ns = received_ns + clock_offset - frame.pts_ns as i128;
|
||||
let lat_us =
|
||||
(lat_ns > 0 && lat_ns < 10_000_000_000).then_some((lat_ns / 1000) as u64);
|
||||
@@ -1016,7 +1099,7 @@ fn feeder_loop(
|
||||
}
|
||||
}
|
||||
}
|
||||
if ev_tx.send(DecodeEvent::Au(frame)).is_err() {
|
||||
if ev_tx.send(DecodeEvent::Au(frame, gap)).is_err() {
|
||||
break; // the decode loop is gone
|
||||
}
|
||||
}
|
||||
@@ -1028,6 +1111,7 @@ fn feeder_loop(
|
||||
|
||||
/// Route one [`DecodeEvent`] into the loop's working sets. Returns `true` only when a parked AU was
|
||||
/// dropped on overflow (the caller then requests a keyframe).
|
||||
#[allow(clippy::too_many_arguments)] // two call sites; the freeze gate + flag map are threaded in
|
||||
fn dispatch_event(
|
||||
ev: DecodeEvent,
|
||||
pending_aus: &mut VecDeque<Frame>,
|
||||
@@ -1035,9 +1119,20 @@ fn dispatch_event(
|
||||
ready: &mut Vec<OutputReady>,
|
||||
fmt_dirty: &mut bool,
|
||||
fatal: &mut bool,
|
||||
gate: &mut ReanchorGate,
|
||||
recovery_flags: &mut VecDeque<(u64, u32)>,
|
||||
) -> bool {
|
||||
match ev {
|
||||
DecodeEvent::Au(f) => {
|
||||
DecodeEvent::Au(f, gap) => {
|
||||
// A forward frame-index gap arms the freeze; park this AU's flags for the present side to
|
||||
// fold `on_decoded` (keyed by the pts the codec will echo).
|
||||
if gap {
|
||||
gate.arm(Instant::now());
|
||||
}
|
||||
recovery_flags.push_back((f.pts_ns / 1000, f.flags));
|
||||
if recovery_flags.len() > IN_FLIGHT_CAP {
|
||||
recovery_flags.pop_front();
|
||||
}
|
||||
pending_aus.push_back(f);
|
||||
if pending_aus.len() > FRAME_PARK_CAP {
|
||||
pending_aus.pop_front(); // sustained overflow — drop oldest, signal a keyframe request
|
||||
@@ -1058,6 +1153,10 @@ fn dispatch_event(
|
||||
DecodeEvent::Error { fatal: f } => {
|
||||
if f {
|
||||
*fatal = true;
|
||||
} else {
|
||||
// A recoverable/transient codec error is a decode hiccup on a broken reference chain —
|
||||
// arm the freeze so the concealed output it recovers into is held off the screen.
|
||||
gate.arm(Instant::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1066,12 +1165,15 @@ fn dispatch_event(
|
||||
|
||||
/// Queue as many parked AUs as there are free input buffer slots (async mode: the indices come from
|
||||
/// `InputAvailable` callbacks, not a dequeue). Each AU is copied into its codec input buffer and
|
||||
/// submitted; a too-large AU is truncated (logged) rather than dropped.
|
||||
/// submitted; an AU larger than the buffer is DROPPED (+ a recovery keyframe requested) — a
|
||||
/// truncated AU is corrupt input the decoder chews on silently, poisoning the reference chain.
|
||||
fn feed_ready(
|
||||
codec: &MediaCodec,
|
||||
client: &NativeClient,
|
||||
pending_aus: &mut VecDeque<Frame>,
|
||||
free_inputs: &mut VecDeque<usize>,
|
||||
fed: &mut u64,
|
||||
oversized_dropped: &mut u64,
|
||||
) {
|
||||
while !pending_aus.is_empty() && !free_inputs.is_empty() {
|
||||
let idx = free_inputs.pop_front().unwrap();
|
||||
@@ -1082,14 +1184,20 @@ fn feed_ready(
|
||||
continue;
|
||||
};
|
||||
let au = &frame.data;
|
||||
let n = au.len().min(dst.len());
|
||||
if n < au.len() {
|
||||
if au.len() > dst.len() {
|
||||
// The slot was never queued, so it stays ours — recycle it for the next AU.
|
||||
free_inputs.push_front(idx);
|
||||
*oversized_dropped += 1;
|
||||
log::warn!(
|
||||
"decode: AU {} > input buffer {}, truncated",
|
||||
"decode: AU {} > input buffer {} — dropped ({} so far), requesting keyframe",
|
||||
au.len(),
|
||||
dst.len()
|
||||
dst.len(),
|
||||
*oversized_dropped
|
||||
);
|
||||
let _ = client.request_keyframe();
|
||||
continue;
|
||||
}
|
||||
let n = au.len();
|
||||
// SAFETY: `au` (wire AU) and `dst` (codec input buffer) are distinct allocations, both valid
|
||||
// for `n` bytes; `MaybeUninit<u8>` is layout-identical to `u8`, so this initializes dst[..n].
|
||||
unsafe {
|
||||
@@ -1120,6 +1228,8 @@ fn present_ready(
|
||||
tracker: &DisplayTracker,
|
||||
rendered: &mut u64,
|
||||
discarded: &mut u64,
|
||||
gate: &mut ReanchorGate,
|
||||
recovery_flags: &mut VecDeque<(u64, u32)>,
|
||||
) {
|
||||
if ready.is_empty() {
|
||||
return;
|
||||
@@ -1132,10 +1242,16 @@ fn present_ready(
|
||||
note_decoded_pts(stats, &mut g, clock_offset, o.pts_us, o.decoded_ns);
|
||||
}
|
||||
}
|
||||
// Fold EVERY output through the gate in pts (== decode) order — even the ones newest-wins discards —
|
||||
// so the two-mark re-anchor count stays correct; the newest's verdict decides whether it reaches
|
||||
// glass (`false` = withheld concealment; the SurfaceView keeps the last rendered frame frozen on).
|
||||
let now = Instant::now();
|
||||
let last = ready.len() - 1;
|
||||
let mut skipped: u64 = 0;
|
||||
for (i, o) in ready.drain(..).enumerate() {
|
||||
let render = i == last;
|
||||
let flags = take_flags(recovery_flags, o.pts_us);
|
||||
let present = gate.on_decoded(flags, false, now) == GateVerdict::Present;
|
||||
let render = i == last && present;
|
||||
match codec.release_output_buffer_by_index(o.index, render) {
|
||||
Ok(()) if render => {
|
||||
*rendered += 1;
|
||||
@@ -1155,7 +1271,7 @@ fn present_ready(
|
||||
}
|
||||
}
|
||||
}
|
||||
stats.note_skipped(skipped); // HUD `skipped` counter (newest-wins drops); no-op while hidden
|
||||
stats.note_skipped(skipped); // HUD `skipped` counter (newest-wins + held-off drops); no-op hidden
|
||||
}
|
||||
|
||||
/// React to an output-format change by signalling the stream's HDR dataspace on the Surface (SDR
|
||||
@@ -1275,27 +1391,44 @@ fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32, is_tv: bool) -> bo
|
||||
/// Try to copy one access unit into a codec input buffer and queue it, without blocking. Returns
|
||||
/// `false` only on `TryAgainLater` (no input buffer free) — the caller keeps the AU pending and
|
||||
/// retries; a hard dequeue/queue error counts as consumed (retrying can't salvage the AU, and
|
||||
/// parking it forever would wedge the loop on a broken codec).
|
||||
fn feed(codec: &MediaCodec, au: &[u8], pts_us: u64) -> bool {
|
||||
/// parking it forever would wedge the loop on a broken codec). An AU larger than the input
|
||||
/// buffer is DROPPED (+ a recovery keyframe requested), never truncated — a truncated AU is
|
||||
/// corrupt input the decoder chews on silently, poisoning the reference chain.
|
||||
fn feed(
|
||||
codec: &MediaCodec,
|
||||
client: &NativeClient,
|
||||
au: &[u8],
|
||||
pts_us: u64,
|
||||
oversized_dropped: &mut u64,
|
||||
) -> bool {
|
||||
match codec.dequeue_input_buffer(Duration::ZERO) {
|
||||
Ok(DequeuedInputBufferResult::Buffer(mut buf)) => {
|
||||
let n = {
|
||||
let dst = buf.buffer_mut();
|
||||
let n = au.len().min(dst.len());
|
||||
if n < au.len() {
|
||||
if au.len() > dst.len() {
|
||||
*oversized_dropped += 1;
|
||||
log::warn!(
|
||||
"decode: AU {} > input buffer {}, truncated",
|
||||
"decode: AU {} > input buffer {} — dropped ({} so far), requesting keyframe",
|
||||
au.len(),
|
||||
dst.len()
|
||||
dst.len(),
|
||||
*oversized_dropped
|
||||
);
|
||||
let _ = client.request_keyframe();
|
||||
0 // return the slot with zero valid bytes — a no-op input, not corrupt data
|
||||
} else {
|
||||
let n = au.len();
|
||||
// SAFETY: `au` and `dst` are distinct allocations (wire AU vs. codec buffer),
|
||||
// both valid for `n` bytes; `MaybeUninit<u8>` is layout-identical to `u8`, so
|
||||
// the cast write initializes exactly `dst[..n]`.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(
|
||||
au.as_ptr(),
|
||||
dst.as_mut_ptr().cast::<u8>(),
|
||||
n,
|
||||
);
|
||||
}
|
||||
n
|
||||
}
|
||||
// SAFETY: `au` and `dst` are distinct allocations (wire AU vs. codec buffer), both
|
||||
// valid for `n` bytes; `MaybeUninit<u8>` is layout-identical to `u8`, so the cast
|
||||
// write initializes exactly `dst[..n]`.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(au.as_ptr(), dst.as_mut_ptr().cast::<u8>(), n);
|
||||
}
|
||||
n
|
||||
};
|
||||
if let Err(e) = codec.queue_input_buffer(buf, 0, n, pts_us, 0) {
|
||||
log::warn!("decode: queue_input_buffer: {e}");
|
||||
@@ -1334,19 +1467,30 @@ fn drain(
|
||||
in_flight: &mut VecDeque<(u64, i128)>,
|
||||
clock_offset: i64,
|
||||
tracker: &DisplayTracker,
|
||||
gate: &mut ReanchorGate,
|
||||
recovery_flags: &mut VecDeque<(u64, u32)>,
|
||||
) -> (u64, u64) {
|
||||
// Newest ready buffer so far (presented after the loop) with its HUD metadata —
|
||||
// `Some((pts_us, decoded_ns))` only while the HUD is visible (the stamp read is gated).
|
||||
// `Some((pts_us, decoded_ns))` only while the HUD is visible. `held_present` is the freeze gate's
|
||||
// verdict for that newest buffer (`false` = a post-loss concealment to withhold).
|
||||
let mut held: Option<(OutputBuffer<'_>, Option<(u64, i128)>)> = None;
|
||||
let mut held_present = true;
|
||||
let mut discarded: u64 = 0;
|
||||
let mut wait = first_wait;
|
||||
loop {
|
||||
match codec.dequeue_output_buffer(wait) {
|
||||
Ok(DequeuedOutputBufferInfoResult::Buffer(buf)) => {
|
||||
wait = Duration::ZERO; // only the first dequeue may block
|
||||
// Only the first dequeue may block; later ones poll (wait == ZERO).
|
||||
wait = Duration::ZERO;
|
||||
// Fold every dequeued frame through the gate in pts (== decode) order — even the ones
|
||||
// the newest-wins policy discards — so the two-mark re-anchor count stays correct; the
|
||||
// verdict of the newest (last folded) buffer decides whether it reaches glass.
|
||||
let pts_us = buf.info().presentation_time_us().max(0) as u64;
|
||||
let flags = take_flags(recovery_flags, pts_us);
|
||||
held_present =
|
||||
gate.on_decoded(flags, false, Instant::now()) == GateVerdict::Present;
|
||||
let meta = if stats.enabled() {
|
||||
// The dequeue IS the sync loop's decoded-availability instant.
|
||||
let pts_us = buf.info().presentation_time_us().max(0) as u64;
|
||||
let decoded_ns = now_realtime_ns();
|
||||
note_decoded_pts(stats, in_flight, clock_offset, pts_us, decoded_ns);
|
||||
Some((pts_us, decoded_ns))
|
||||
@@ -1392,16 +1536,19 @@ fn drain(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Present the newest ready frame, if any, and park its metadata for the render callback.
|
||||
// Present the newest ready frame — UNLESS the gate is withholding it as a post-loss concealment,
|
||||
// in which case release it without rendering (the SurfaceView keeps the last rendered frame frozen
|
||||
// on glass) and count it as a discard rather than a display.
|
||||
let mut rendered = 0;
|
||||
if let Some((buf, meta)) = held {
|
||||
match codec.release_output_buffer(buf, true) {
|
||||
Ok(()) => {
|
||||
match codec.release_output_buffer(buf, held_present) {
|
||||
Ok(()) if held_present => {
|
||||
rendered = 1;
|
||||
if let Some((pts_us, decoded_ns)) = meta {
|
||||
tracker.note_rendered(pts_us, decoded_ns);
|
||||
}
|
||||
}
|
||||
Ok(()) => discarded += 1, // held off the screen — awaiting a clean re-anchor
|
||||
Err(e) => log::warn!("decode: release_output_buffer: {e}"),
|
||||
}
|
||||
}
|
||||
@@ -1443,6 +1590,25 @@ fn note_decoded_pts(
|
||||
stats.note_decoded(e2e_us, decode_us);
|
||||
}
|
||||
|
||||
/// The AU `user_flags` for a decoded output, keyed by the echoed `presentationTimeUs`. Recovery
|
||||
/// signalling (FLAG_SOF IDR marker / RECOVERY_ANCHOR / RECOVERY_POINT) rides the AU's flags, which are
|
||||
/// only in scope at feed time — so the feed side parks `(pts_us, flags)` here and the present side
|
||||
/// looks them up to fold [`ReanchorGate::on_decoded`]. Decode order == input order (low-latency, no
|
||||
/// B-frames), so this evicts entries older than `pts_us` as it goes; a miss (probe filler, or an entry
|
||||
/// aged past the cap) reads `0` — no recovery flags, decoded normally.
|
||||
fn take_flags(map: &mut VecDeque<(u64, u32)>, pts_us: u64) -> u32 {
|
||||
while let Some(&(p, f)) = map.front() {
|
||||
if p > pts_us {
|
||||
break; // future frame — leave it for its own output buffer
|
||||
}
|
||||
map.pop_front();
|
||||
if p == pts_us {
|
||||
return f;
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
/// Map the decoder's reported output colour to a BT.2020 HDR dataspace, or `None` for SDR. The
|
||||
/// integer values are the Android MediaFormat colour constants the NDK shares: COLOR_TRANSFER
|
||||
/// ST2084 = 6 (PQ/HDR10), HLG = 7; COLOR_RANGE FULL = 1, LIMITED = 2 (the host encodes limited).
|
||||
|
||||
@@ -24,8 +24,13 @@ const TAG_PLAYER_LEDS: u8 = 0x02;
|
||||
const TAG_TRIGGER: u8 = 0x03;
|
||||
|
||||
/// `NativeBridge.nativeNextRumble(handle): Long` — block up to ~100 ms for the next rumble update.
|
||||
/// Returns `(low << 16) | high` (each 0..=0xFFFF; `0` = stop), or `-1` on timeout / session closed.
|
||||
/// Pad index is dropped (single-pad model). Run from a dedicated Kotlin poll thread.
|
||||
/// Returns a packed positive long: bits 49..52 = wire `pad` index (0..15), bit 48 = "has a v2 lease",
|
||||
/// bits 32..47 = `ttl_ms`, bits 16..31 = `low`, bits 0..15 = `high` (`low`/`high` 0..=0xFFFF, `0/0` =
|
||||
/// stop). The lease flag is out-of-band so ANY 16-bit `ttl_ms` — including 0xFFFF — is unambiguous (no
|
||||
/// in-band sentinel to collide with a real 65535 ms lease). No lease (legacy host) → bit 48 clear, and
|
||||
/// Kotlin falls back to its long one-shot. `-1` on timeout / session closed (all packed values are
|
||||
/// positive, so `-1` stays unambiguous). Kotlin routes the update back to the controller holding that
|
||||
/// wire `pad` index (multi-pad rumble). Run from a Kotlin poll thread.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
||||
_env: JNIEnv,
|
||||
@@ -37,22 +42,37 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
||||
if handle == 0 {
|
||||
return -1;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble is &self on the
|
||||
// Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble_ttl is &self on
|
||||
// the Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll
|
||||
// threads (and joins them — unbounded) before nativeClose frees the handle.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
match h.client.next_rumble(PULL_TIMEOUT) {
|
||||
Ok((_pad, low, high)) => (jlong::from(low) << 16) | jlong::from(high),
|
||||
match h.client.next_rumble_ttl(PULL_TIMEOUT) {
|
||||
Ok((pad, low, high, ttl)) => {
|
||||
// The reorder gate already ran in the core, so this update is fresh. Encode the
|
||||
// Option out-of-band: a real lease sets bit 48 and carries ttl_ms verbatim. The pad
|
||||
// index rides above the lease flag (bits 49..52), keeping the whole word positive.
|
||||
let (lease_flag, ttl_bits) = match ttl {
|
||||
Some(ms) => (1i64 << 48, jlong::from(ms) << 32),
|
||||
None => (0, 0),
|
||||
};
|
||||
(jlong::from(pad & 0xF) << 49)
|
||||
| lease_flag
|
||||
| ttl_bits
|
||||
| (jlong::from(low) << 16)
|
||||
| jlong::from(high)
|
||||
}
|
||||
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeNextHidout(handle, buf): Int` — block up to ~100 ms for the next DualSense
|
||||
/// HID-output event, written into the caller's direct ByteBuffer as `[kind][fields…]`:
|
||||
/// Led → `[0x01][r][g][b]` (len 4)
|
||||
/// PlayerLeds → `[0x02][bits]` (len 2)
|
||||
/// Trigger → `[0x03][which][effect…]` (len 2 + effect.len())
|
||||
/// HID-output event, written into the caller's direct ByteBuffer as `[pad][kind][fields…]` (the
|
||||
/// leading `pad` is the wire pad index the event is addressed to, so Kotlin routes it to that
|
||||
/// controller — multi-pad HID feedback):
|
||||
/// Led → `[pad][0x01][r][g][b]` (len 5)
|
||||
/// PlayerLeds → `[pad][0x02][bits]` (len 3)
|
||||
/// Trigger → `[pad][0x03][which][effect…]` (len 3 + effect.len())
|
||||
/// Returns the byte count written, or `-1` on timeout / session closed / buffer too small.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
||||
@@ -85,33 +105,37 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
||||
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call.
|
||||
let out = unsafe { std::slice::from_raw_parts_mut(ptr, cap) };
|
||||
|
||||
// out[0] = wire pad index; out[1] = kind tag; the rest is the per-kind payload.
|
||||
let n = match ev {
|
||||
HidOutput::Led { r, g, b, .. } => {
|
||||
if cap < 4 {
|
||||
HidOutput::Led { pad, r, g, b } => {
|
||||
if cap < 5 {
|
||||
return -1;
|
||||
}
|
||||
out[0] = TAG_LED;
|
||||
out[1] = r;
|
||||
out[2] = g;
|
||||
out[3] = b;
|
||||
4
|
||||
out[0] = pad;
|
||||
out[1] = TAG_LED;
|
||||
out[2] = r;
|
||||
out[3] = g;
|
||||
out[4] = b;
|
||||
5
|
||||
}
|
||||
HidOutput::PlayerLeds { bits, .. } => {
|
||||
if cap < 2 {
|
||||
HidOutput::PlayerLeds { pad, bits } => {
|
||||
if cap < 3 {
|
||||
return -1;
|
||||
}
|
||||
out[0] = TAG_PLAYER_LEDS;
|
||||
out[1] = bits;
|
||||
2
|
||||
out[0] = pad;
|
||||
out[1] = TAG_PLAYER_LEDS;
|
||||
out[2] = bits;
|
||||
3
|
||||
}
|
||||
HidOutput::Trigger { which, effect, .. } => {
|
||||
let n = 2 + effect.len();
|
||||
HidOutput::Trigger { pad, which, effect } => {
|
||||
let n = 3 + effect.len();
|
||||
if cap < n {
|
||||
return -1; // the raw DS5 trigger block is ~11 bytes; Kotlin allocates 64
|
||||
}
|
||||
out[0] = TAG_TRIGGER;
|
||||
out[1] = which;
|
||||
out[2..n].copy_from_slice(&effect);
|
||||
out[0] = pad;
|
||||
out[1] = TAG_TRIGGER;
|
||||
out[2] = which;
|
||||
out[3..n].copy_from_slice(&effect);
|
||||
n
|
||||
}
|
||||
HidOutput::TrackpadHaptic { .. } => {
|
||||
|
||||
@@ -161,6 +161,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
}
|
||||
},
|
||||
preferred_codec.clamp(0, u8::MAX as jint) as u8,
|
||||
// No display-volume forwarding from Android yet (the panel tone-maps PQ itself via the
|
||||
// Surface dataspace + static metadata) — the host keeps its virtual-display EDID defaults.
|
||||
None,
|
||||
launch, // a store-qualified library id to boot into a game, or None for the desktop
|
||||
pin, // Some → Crypto on host-fp mismatch
|
||||
identity, // owned (cert, key) PEM, or None (anonymous)
|
||||
|
||||
@@ -145,13 +145,19 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendKey(
|
||||
}
|
||||
|
||||
// ---- Gamepad: Kotlin captures (KeyEvent/MotionEvent) → NativeClient::send_input ---------------
|
||||
// Single-pad model: exactly one controller, forwarded as pad 0 (flags = 0). Buttons carry the
|
||||
// gamepad::BTN_* bit in `code` and pressed/released in `x` (1/0); axes carry the gamepad::AXIS_* id
|
||||
// in `code` and the value in `x` (sticks i16 −32768..32767, +y = up; triggers 0..255). The host
|
||||
// accumulates the incremental events into its virtual xpad. Wire contract: input.rs::gamepad.
|
||||
// Multi-pad model: each physical controller is forwarded on its own wire pad index (0..15), carried
|
||||
// in the low byte of `flags` on every per-pad event — the Kotlin side (`GamepadRouter`) assigns a
|
||||
// stable lowest-free index per Android device and threads it here. Buttons carry the gamepad::BTN_*
|
||||
// bit in `code` and pressed/released in `x` (1/0); axes carry the gamepad::AXIS_* id in `code` and
|
||||
// the value in `x` (sticks i16 −32768..32767, +y = up; triggers 0..255). The host accumulates the
|
||||
// incremental events per pad into a matching virtual device. The core input task folds these into
|
||||
// the seq'd GamepadState snapshots (keyed on this same `flags` index) and owns the per-pad seq — so
|
||||
// the only thing this layer must get right is the index. Wire contract: input.rs::gamepad. A single
|
||||
// controller lands on index 0, so its wire is byte-identical to the old single-pad path.
|
||||
|
||||
/// `NativeBridge.nativeSendGamepadButton(handle, bit, down)` — one gamepad button transition.
|
||||
/// `bit`: a `gamepad::BTN_*` bit (e.g. BTN_A = 0x1000). `down`: 1=press, 0=release.
|
||||
/// `NativeBridge.nativeSendGamepadButton(handle, bit, down, pad)` — one gamepad button transition on
|
||||
/// wire pad index `pad`. `bit`: a `gamepad::BTN_*` bit (e.g. BTN_A = 0x1000). `down`: 1=press,
|
||||
/// 0=release. `pad`: wire pad index 0..15 (rides `flags`).
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadButton(
|
||||
_env: JNIEnv,
|
||||
@@ -159,21 +165,21 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
|
||||
handle: jlong,
|
||||
bit: jint,
|
||||
down: jboolean,
|
||||
pad: jint,
|
||||
) {
|
||||
// flags = 0: pad index 0 — single-pad model.
|
||||
send_event(
|
||||
handle,
|
||||
InputKind::GamepadButton,
|
||||
bit as u32,
|
||||
i32::from(down != 0),
|
||||
0,
|
||||
0,
|
||||
pad as u32,
|
||||
);
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendGamepadAxis(handle, axisId, value)` — one gamepad axis update.
|
||||
/// `axisId`: a `gamepad::AXIS_*` id (LS_X=0..RT=5). `value`: stick i16 (−32768..32767, +y=up) or
|
||||
/// trigger 0..255.
|
||||
/// `NativeBridge.nativeSendGamepadAxis(handle, axisId, value, pad)` — one gamepad axis update on wire
|
||||
/// pad index `pad`. `axisId`: a `gamepad::AXIS_*` id (LS_X=0..RT=5). `value`: stick i16
|
||||
/// (−32768..32767, +y=up) or trigger 0..255. `pad`: wire pad index 0..15 (rides `flags`).
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadAxis(
|
||||
_env: JNIEnv,
|
||||
@@ -181,7 +187,52 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
|
||||
handle: jlong,
|
||||
axis_id: jint,
|
||||
value: jint,
|
||||
pad: jint,
|
||||
) {
|
||||
// flags = 0: pad index 0 — single-pad model.
|
||||
send_event(handle, InputKind::GamepadAxis, axis_id as u32, value, 0, 0);
|
||||
send_event(
|
||||
handle,
|
||||
InputKind::GamepadAxis,
|
||||
axis_id as u32,
|
||||
value,
|
||||
0,
|
||||
pad as u32,
|
||||
);
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendGamepadArrival(handle, pref, pad)` — declare the controller KIND presented
|
||||
/// on wire pad index `pad` so the host builds a matching virtual device (mixed types — pad 0 a
|
||||
/// DualSense, pad 1 an Xbox pad). `pref`: the `GamepadPref` wire byte (rides `code`). `pad`: wire pad
|
||||
/// index 0..15 (rides `flags`). Sent ONCE when a pad opens, BEFORE any of its input; the core re-sends
|
||||
/// it a few times against datagram loss, and an older host ignores the unknown tag (that pad then uses
|
||||
/// the session-default kind from the handshake — the pre-existing single-pad behaviour on pad 0).
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadArrival(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pref: jint,
|
||||
pad: jint,
|
||||
) {
|
||||
send_event(
|
||||
handle,
|
||||
InputKind::GamepadArrival,
|
||||
pref as u32,
|
||||
0,
|
||||
0,
|
||||
pad as u32,
|
||||
);
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendGamepadRemove(handle, pad)` — signal that wire pad index `pad` was
|
||||
/// unplugged so the host tears its virtual device down. `pad` (rides `flags`) is the only field; the
|
||||
/// core stamps the per-pad seq (in the snapshot seq space, so a reordered snapshot can't resurrect the
|
||||
/// pad) and arms a re-send burst against datagram loss. An older host ignores the unknown tag.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadRemove(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jint,
|
||||
) {
|
||||
send_event(handle, InputKind::GamepadRemove, 0, 0, 0, pad as u32);
|
||||
}
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ in the workspace root.
|
||||
**The only change** is in `src/media/media_codec.rs`: `MediaCodec::as_ptr` is made
|
||||
`pub` (upstream keeps it private) so the Android client can register
|
||||
`AMediaCodec_setOnFrameRenderedCallback` through `ndk-sys` — the render-timestamp
|
||||
callback behind the HUD's `display` stage (`design/stats-unification.md`), which the
|
||||
callback behind the HUD's `display` stage (punktfunk-planning: `stats-unification.md`), which the
|
||||
wrapper doesn't expose. Grep for `punktfunk vendored patch` to find it.
|
||||
|
||||
Drop this vendor copy when upstream exposes the raw pointer or a frame-rendered
|
||||
|
||||
+1
-1
@@ -130,7 +130,7 @@ impl InputQueue {
|
||||
looper.ptr().as_ptr(),
|
||||
id,
|
||||
None,
|
||||
std::ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,11 @@ use std::{
|
||||
slice,
|
||||
};
|
||||
|
||||
use crate::media_error::{MediaError, Result};
|
||||
use crate::media_error::MediaError;
|
||||
// `Result` is only referenced by the api-level-29 methods below; an ungated import warns
|
||||
// (unused_imports) on every default-feature build.
|
||||
#[cfg(feature = "api-level-29")]
|
||||
use crate::media_error::Result;
|
||||
|
||||
/// A native [`AMediaFormat *`]
|
||||
///
|
||||
|
||||
@@ -2,24 +2,22 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Custom keys merged into the auto-generated Info.plist (GENERATE_INFOPLIST_FILE=YES
|
||||
supplies the rest). NSBonjourServices is required for NWBrowser to browse this
|
||||
service type on iOS/tvOS — without it the system blocks the browse and discovery
|
||||
returns nothing. Kept OUT of the synchronized App/ + Sources/ groups so it isn't
|
||||
auto-added as a bundle resource (which collides with Info.plist processing). -->
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>GCSupportedGameControllers</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>ProfileName</key>
|
||||
<string>ExtendedGamepad</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>ProfileName</key>
|
||||
<string>MicroGamepad</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_punktfunk._udp</string>
|
||||
</array>
|
||||
<!-- Standard-algorithm crypto only (AES-GCM via the Rust core) — exempt from export
|
||||
compliance, but the key must be declared or every TestFlight build stalls on the
|
||||
compliance question. -->
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<!-- Allow CADisplayLink above 60 Hz on ProMotion iPhones: without this key the system
|
||||
silently caps the link at 60 even when SessionPresenter asks for the stream's rate
|
||||
via preferredFrameRateRange, so a 120 fps stream would present at half rate. -->
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -14,19 +14,11 @@
|
||||
<!-- Wake-on-LAN needs to send a UDP broadcast magic packet (a sleeping host has no ARP
|
||||
entry, so unicast can't wake it). Since iOS 14 / tvOS 14 the OS blocks sending to
|
||||
broadcast/multicast addresses unless the app carries this managed entitlement — it must
|
||||
be requested from and approved by Apple for the App ID, then enabled in the provisioning
|
||||
profile. macOS is not gated by this (its App Sandbox network.client/server cover it).
|
||||
|
||||
GATED pending Apple's approval of the request (form filed) — an unauthorized managed
|
||||
entitlement breaks iOS/tvOS signing, so it's commented out to keep those apps releasable.
|
||||
ON APPROVAL: (1) uncomment the two lines below, and (2) flip
|
||||
PunktfunkConnection.wakeOnLANAvailable (PunktfunkConnection.swift) to enable the iOS/tvOS
|
||||
wake path + UI. Until then iOS/tvOS Wake-on-LAN is a clean no-op — MACs are still learned
|
||||
from mDNS so it works immediately once ungated. macOS is unaffected (separate entitlements
|
||||
file, no multicast entitlement needed). -->
|
||||
<!--
|
||||
be approved by Apple for the App ID and enabled in the provisioning profile. macOS is not
|
||||
gated by this (its App Sandbox network.client/server cover it), hence its separate file.
|
||||
Approved and provisioned, so it's enabled here and PunktfunkConnection.wakeOnLANAvailable
|
||||
is true on iOS/tvOS too. -->
|
||||
<key>com.apple.developer.networking.multicast</key>
|
||||
<true/>
|
||||
-->
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -40,6 +40,8 @@ let package = Package(
|
||||
// its manifest breaks SwiftPM whole-graph validation on macOS, and only the
|
||||
// Punktfunk-tvOS target links it; the #if os(tvOS) import never compiles here.)
|
||||
.executableTarget(name: "PunktfunkClient", dependencies: ["PunktfunkKit"]),
|
||||
.testTarget(name: "PunktfunkKitTests", dependencies: ["PunktfunkKit"]),
|
||||
// PunktfunkCore is a direct dep too so the wire tests can name the C ABI's
|
||||
// `PunktfunkInputEvent` / `PUNKTFUNK_INPUT_KIND_*` when asserting the gamepad byte layout.
|
||||
.testTarget(name: "PunktfunkKitTests", dependencies: ["PunktfunkKit", "PunktfunkCore"]),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -376,7 +376,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1;
|
||||
MARKETING_VERSION = 0.9.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = macosx;
|
||||
@@ -412,7 +412,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1;
|
||||
MARKETING_VERSION = 0.9.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = macosx;
|
||||
@@ -436,6 +436,7 @@
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
||||
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
|
||||
INFOPLIST_KEY_GCSupportsGameMode = YES;
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
||||
@@ -449,7 +450,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1;
|
||||
MARKETING_VERSION = 0.9.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -477,6 +478,7 @@
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
||||
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
|
||||
INFOPLIST_KEY_GCSupportsGameMode = YES;
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
||||
@@ -490,7 +492,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1;
|
||||
MARKETING_VERSION = 0.9.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -522,7 +524,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1;
|
||||
MARKETING_VERSION = 0.9.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = appletvos;
|
||||
@@ -552,7 +554,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1;
|
||||
MARKETING_VERSION = 0.9.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = appletvos;
|
||||
|
||||
@@ -49,6 +49,13 @@
|
||||
ReferencedContainer = "container:Punktfunk.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "PUNKTFUNK_BILINEAR_LUMA"
|
||||
value = "1"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
|
||||
@@ -122,7 +122,8 @@ PUNKTFUNK_AUTOCONNECT=<box-ip> PUNKTFUNK_MODE=1280x720x60 swift run PunktfunkCli
|
||||
- **App Store screenshots** are automated — `tools/screenshots.sh all` renders the real UI at the
|
||||
required pixel sizes via a DEBUG-only shot mode; the `apple` CI workflow captures the iOS sizes on
|
||||
every main push. See the script header for details.
|
||||
- Deeper design notes live in [`design/apple-stage2-presenter.md`](../../design/apple-stage2-presenter.md).
|
||||
- Deeper design notes live in the internal planning repo (punktfunk-planning:
|
||||
`apple-stage2-presenter.md`).
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -31,8 +31,15 @@ struct ContentView: View {
|
||||
@AppStorage(DefaultsKey.codec) private var codec = "auto"
|
||||
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
|
||||
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
|
||||
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
||||
// The raw string is what @AppStorage observes (so cycles from any surface re-render this
|
||||
// view); the absent-key default runs the legacy-hudEnabled migration once per init.
|
||||
@AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw
|
||||
= StatsVerbosity.current.rawValue
|
||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||
/// The persisted overlay tier (unknown raw falls back to .normal, like the migration).
|
||||
private var statsVerbosity: StatsVerbosity {
|
||||
StatsVerbosity(rawValue: statsVerbosityRaw) ?? .normal
|
||||
}
|
||||
/// The `codec` setting as a `PUNKTFUNK_CODEC_*` soft-preference byte (`0` = auto).
|
||||
private var preferredCodecByte: UInt8 {
|
||||
switch codec {
|
||||
@@ -53,7 +60,8 @@ struct ContentView: View {
|
||||
@State private var speedTestTarget: StoredHost?
|
||||
@State private var libraryTarget: StoredHost?
|
||||
/// Wakes a sleeping host and waits for it to come back online before connecting (drives the
|
||||
/// "Waking…" overlay). macOS-only in practice — WoL is gated off on iOS/tvOS.
|
||||
/// "Waking…" phase of the connect overlay). Available on every platform now that the iOS/tvOS
|
||||
/// multicast entitlement is granted (see PunktfunkConnection.wakeOnLANAvailable).
|
||||
@StateObject private var waker = HostWaker()
|
||||
#if os(macOS)
|
||||
/// Whether the hosting window is native-fullscreen right now (reported by
|
||||
@@ -61,25 +69,32 @@ struct ContentView: View {
|
||||
/// edge-to-edge (behind the notch); windowed respects the top inset so the title bar
|
||||
/// never covers the video.
|
||||
@State private var isFullscreen = false
|
||||
#endif
|
||||
#if os(macOS) || os(tvOS)
|
||||
/// Shows the start-of-stream shortcut banner (the Windows client's discoverability
|
||||
/// pattern): raised on every transition to `.streaming`, dropped by the banner's own
|
||||
/// 6-second task. Independent of the stats HUD so the keys are discoverable even with
|
||||
/// statistics off.
|
||||
/// statistics off. On tvOS it carries the ONLY exits (hold Back / the pad chord) plus
|
||||
/// the remote-as-pointer controls, so it must be seen at least once per session.
|
||||
@State private var showShortcutHint = false
|
||||
#endif
|
||||
#if !os(macOS)
|
||||
@State private var showSettings = false
|
||||
#endif
|
||||
#if os(iOS) || os(macOS)
|
||||
// A connected controller (+ the Settings toggle) swaps the whole home screen for
|
||||
// GamepadHomeView instead of retrofitting HomeView's touch/desktop UI — see `home` below.
|
||||
// On tvOS the same screens are focus-engine-driven, so the Siri Remote keeps working;
|
||||
// with no (extended) controller attached tvOS falls back to HomeView as before.
|
||||
@ObservedObject private var gamepadManager = GamepadManager.shared
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||
/// Auto-wake on connect (Settings → General). On (default): a dial to an offline saved host
|
||||
/// fires Wake-on-LAN up front and falls into the "Waking…" wait if the dial fails. Off: connects
|
||||
/// go straight through with no wake. The explicit "Wake Host" action is unaffected either way.
|
||||
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
||||
private var gamepadUIActive: Bool {
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
|
||||
}
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
@@ -101,7 +116,7 @@ struct ContentView: View {
|
||||
.onChange(of: model.phase) { _, phase in
|
||||
switch phase {
|
||||
case .streaming:
|
||||
#if os(macOS)
|
||||
#if os(macOS) || os(tvOS)
|
||||
showShortcutHint = true // the 6 s shortcut banner, per session start
|
||||
#endif
|
||||
// A session actually started — remember it on the card ("Connected … ago"
|
||||
@@ -249,9 +264,26 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
private var home: some View {
|
||||
// The "Waking…" overlay rides over BOTH home UIs (and the pre-connect window is still
|
||||
// `home`, so it covers the whole wake→online→connect sequence).
|
||||
homeBase.overlay { WakeOverlay(waker: waker) }
|
||||
// The full-screen connect takeover rides over BOTH home UIs (and the pre-connect window is
|
||||
// still `home`, so it covers the whole dial → wake → online → connect sequence): instant
|
||||
// "Connecting…" feedback on any dial, flowing seamlessly into the "Waking…" wait if the host
|
||||
// turns out to be asleep.
|
||||
homeBase.overlay {
|
||||
ConnectOverlay(
|
||||
connectingHostName: connectingOverlayName,
|
||||
waker: waker,
|
||||
gamepadUI: gamepadUIActive,
|
||||
onCancelConnect: { model.disconnect() })
|
||||
}
|
||||
}
|
||||
|
||||
/// The host label for the connect takeover's "Connecting…" phase — a plain dial in flight. Nil
|
||||
/// during the delegated-approval wait (that has its own "Waiting for approval" prompt, so the
|
||||
/// takeover must not stack over it) and, of course, when idle or streaming.
|
||||
private var connectingOverlayName: String? {
|
||||
guard awaitingApproval == nil, model.phase == .connecting, let host = model.activeHost
|
||||
else { return nil }
|
||||
return host.displayName
|
||||
}
|
||||
|
||||
@ViewBuilder private var homeBase: some View {
|
||||
@@ -271,13 +303,30 @@ struct ContentView: View {
|
||||
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
|
||||
}
|
||||
}
|
||||
#elseif os(iOS)
|
||||
#else
|
||||
Group {
|
||||
if gamepadUIActive {
|
||||
GamepadHomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: $libraryTarget, waker: waker,
|
||||
connect: { connect($0) }, connectDiscovered: connectDiscovered)
|
||||
// On tvOS pairing/library normally present from HomeView's navigationDestinations
|
||||
// — which aren't mounted while the gamepad launcher is up. Give the launcher its
|
||||
// own presenters (exactly one of the two homes is mounted at a time, so these can
|
||||
// never double-present against HomeView's routes). Menu closes a cover the same
|
||||
// way B backs out elsewhere; PairSheet's own onDisappear cancels a live ceremony.
|
||||
#if os(tvOS)
|
||||
.fullScreenCover(item: $pairingTarget) { host in
|
||||
PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) }
|
||||
.onExitCommand { pairingTarget = nil }
|
||||
}
|
||||
.fullScreenCover(item: $libraryTarget) { host in
|
||||
NavigationStack {
|
||||
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
|
||||
}
|
||||
.onExitCommand { libraryTarget = nil }
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
HomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
@@ -288,14 +337,6 @@ struct ContentView: View {
|
||||
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
|
||||
}
|
||||
}
|
||||
#else
|
||||
HomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
|
||||
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
|
||||
showSettings: $showSettings,
|
||||
connect: { connect($0) }, connectDiscovered: connectDiscovered,
|
||||
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -308,12 +349,25 @@ struct ContentView: View {
|
||||
}()
|
||||
return ZStack {
|
||||
stream(captureEnabled: pendingFingerprint == nil)
|
||||
.blur(radius: pendingFingerprint != nil ? 32 : 0)
|
||||
// Blur the live stream during the trust prompt (heavy) and during a resize (lighter
|
||||
// — the deliberate "hold on" while the host rebuilds its pipeline and the decoder
|
||||
// re-inits on the new-mode IDR). Only the resize blur animates; the trust blur snaps
|
||||
// as before (its own overlay handles the transition).
|
||||
.blur(radius: pendingFingerprint != nil ? 32 : (model.resizing ? 16 : 0))
|
||||
.animation(.easeInOut(duration: 0.22), value: model.resizing)
|
||||
.overlay {
|
||||
if pendingFingerprint != nil {
|
||||
Color.black.opacity(0.45)
|
||||
}
|
||||
}
|
||||
// The resize spinner rides over the (blurred) stream; suppressed under the trust
|
||||
// prompt, which owns the screen. It never hit-tests, so window-drag resizes keep
|
||||
// steering and the next click still reaches the stream.
|
||||
.overlay {
|
||||
if pendingFingerprint == nil {
|
||||
ResizeIndicatorView(active: model.resizing)
|
||||
}
|
||||
}
|
||||
if let fp = pendingFingerprint {
|
||||
TrustCardView(
|
||||
fingerprint: fp,
|
||||
@@ -355,11 +409,14 @@ struct ContentView: View {
|
||||
#else
|
||||
.background(Color.black)
|
||||
.ignoresSafeArea()
|
||||
// Siri Remote MENU = disconnect (the idiomatic tvOS "back"). With no focusable
|
||||
// disconnect control during play, the controller's buttons flow to the host instead of
|
||||
// driving the focus engine. NOTE: a game controller's Menu is also forwarded to the
|
||||
// host as Start — the Siri Remote is the intended disconnect path.
|
||||
.onExitCommand { model.disconnect() }
|
||||
// SWALLOW Menu/B during a session — a game controller's B button ALSO surfaces as this
|
||||
// UIKit menu press, so the old instant-disconnect here ended the session on every B
|
||||
// press in gameplay. The button still reaches the host via GamepadCapture; the
|
||||
// DELIBERATE exits are holding the remote's Back ≥ 1 s (SiriRemotePointer) and holding
|
||||
// L1+R1+Start+Select ≥ 1.5 s on a pad (GamepadCapture's escape chord), both surfaced by
|
||||
// the start-of-stream banner. The empty handler is what keeps the press from bubbling
|
||||
// out and suspending the app.
|
||||
.onExitCommand {}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -388,26 +445,51 @@ struct ContentView: View {
|
||||
onSessionEnd: { [weak model] in
|
||||
Task { @MainActor in model?.sessionEnded() }
|
||||
},
|
||||
// Resize overlay START — the follower is main-actor, so this drives the blur
|
||||
// + spinner synchronously the instant the window differs from the live mode.
|
||||
onResizeTarget: { [weak model] w, h in
|
||||
model?.resizeTargeted(width: w, height: h)
|
||||
},
|
||||
// Resize overlay END — the coded dims of each new-mode IDR, reported from the
|
||||
// decode pump thread; hop to the main actor to clear the overlay.
|
||||
onDecodedSize: { [weak model] w, h in
|
||||
Task { @MainActor in model?.resizeDecoded(width: w, height: h) }
|
||||
},
|
||||
endToEndMeter: model.endToEnd,
|
||||
decodeMeter: model.decodeStage,
|
||||
displayMeter: model.displayStage
|
||||
)
|
||||
.overlay(alignment: placement.alignment) {
|
||||
if captureEnabled && hudEnabled {
|
||||
StreamHUDView(model: model, connection: conn, placement: placement)
|
||||
// The stats overlay MORPHS between tiers and SCALES UP on enter. With no `.id`, a
|
||||
// verbosity change keeps the same StreamHUDView identity, so its one shared glass
|
||||
// card animates its frame/shape to the new tier (a morph) instead of cross-fading a
|
||||
// fresh card in. The `.transition` therefore fires only on the off↔on boundary — a
|
||||
// scale-up (0.8→1) from the HUD's own corner. The ZStack is the stable host the
|
||||
// `.animation` watches as the child enters/leaves and morphs.
|
||||
ZStack {
|
||||
if captureEnabled && statsVerbosity != .off {
|
||||
StreamHUDView(
|
||||
model: model, connection: conn, placement: placement,
|
||||
verbosity: statsVerbosity)
|
||||
.transition(
|
||||
.scale(scale: 0.8, anchor: placement.unitPoint)
|
||||
.combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.animation(.smooth(duration: 0.28), value: statsVerbosity)
|
||||
}
|
||||
#if os(macOS)
|
||||
// The start-of-stream shortcut banner (Windows-client parity): the full
|
||||
// reserved key set on a glass pill, bottom-centre, for the first 6 seconds of
|
||||
#if os(macOS) || os(tvOS)
|
||||
// The start-of-stream shortcut banner (Windows-client parity): the platform's
|
||||
// reserved controls on a glass pill, bottom-centre, for the first 6 seconds of
|
||||
// every session — independent of the stats HUD, so the keys are discoverable
|
||||
// even with statistics off. The banner's own task drops it (cancelled cleanly
|
||||
// if the session view goes away first).
|
||||
// if the session view goes away first). On tvOS it carries the ONLY exits —
|
||||
// Menu/B is swallowed during a session (the `.onExitCommand {}` in the tvOS
|
||||
// session branch), so the hold gestures must be told to the user.
|
||||
.overlay(alignment: .bottom) {
|
||||
if captureEnabled && showShortcutHint {
|
||||
Text("Click the stream to capture · ⌃⌥⇧Q releases the mouse · "
|
||||
+ "⌃⌥⇧D disconnects · ⌃⌥⇧S stats")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
Text(Self.shortcutHintText)
|
||||
.font(.geist(Self.shortcutHintFont, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
@@ -422,17 +504,18 @@ struct ContentView: View {
|
||||
}
|
||||
#endif
|
||||
#if os(iOS)
|
||||
// Touch users have no menu / ⌘D, so when the HUD (and its Disconnect button)
|
||||
// is hidden, keep a minimal always-reachable exit in a corner. It rides a
|
||||
// material disc (like the HUD) so the glyph stays legible over a bright frame
|
||||
// — this is the sole touch disconnect path when stats are off.
|
||||
// Touch users have no menu / ⌘D, so when the HUD's Disconnect button isn't on
|
||||
// screen — the overlay off, or the compact pill (which carries no button) —
|
||||
// keep a minimal always-reachable exit in a corner. It rides a material disc
|
||||
// (like the HUD) so the glyph stays legible over a bright frame — this is the
|
||||
// sole touch disconnect path in those tiers.
|
||||
.overlay(alignment: .topLeading) {
|
||||
if captureEnabled && !hudEnabled {
|
||||
if captureEnabled && (statsVerbosity == .off || statsVerbosity == .compact) {
|
||||
Button { model.disconnect() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.headline.weight(.semibold))
|
||||
.frame(width: 36, height: 36)
|
||||
// Sole touch exit when the HUD is off — a floating glass disc
|
||||
// Sole touch exit in the off/compact tiers — a floating glass disc
|
||||
// over the frame (26+, material fallback). interactive: the disc
|
||||
// IS the tap target, so the glass reacts to press.
|
||||
.glassBackground(Circle(), interactive: true)
|
||||
@@ -450,6 +533,17 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private static let shortcutHintText =
|
||||
"Click the stream to capture · ⌃⌥⇧Q releases the mouse · ⌃⌥⇧D disconnects · ⌃⌥⇧S stats"
|
||||
private static let shortcutHintFont: CGFloat = 12
|
||||
#elseif os(tvOS)
|
||||
private static let shortcutHintText =
|
||||
"Hold the remote's Back button — or L1+R1+Start+Select on a controller — to disconnect"
|
||||
+ " · Touch surface moves the pointer · press clicks · Play/Pause right-clicks"
|
||||
private static let shortcutHintFont: CGFloat = 22 // read from the couch
|
||||
#endif
|
||||
|
||||
// MARK: - Connect
|
||||
|
||||
private func connect(_ host: StoredHost, launchID: String? = nil, allowTofu: Bool? = nil) {
|
||||
@@ -495,7 +589,8 @@ struct ContentView: View {
|
||||
// packet up front, so a genuinely-asleep host is waking while the connect times out; only
|
||||
// when that dial FAILS do we fall into the visible "Waking…" wait — a cold box takes far
|
||||
// longer to boot than a connect will sit — and redial once it's back on mDNS.
|
||||
if PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty, !discovery.advertises(host) {
|
||||
if autoWakeEnabled, PunktfunkConnection.wakeOnLANAvailable,
|
||||
!host.wakeMacs.isEmpty, !discovery.advertises(host) {
|
||||
discovery.start() // so the wake-wait can observe it reappear
|
||||
startSessionDirect(
|
||||
host, launchID: launchID, allowTofu: allowTofu,
|
||||
@@ -552,7 +647,9 @@ struct ContentView: View {
|
||||
private func prepareWake(for host: StoredHost) {
|
||||
if let live = discovery.hosts.first(where: { host.matches($0) }) {
|
||||
store.updateMacs(host.id, macs: live.macAddresses) // learn — on every platform
|
||||
} else if PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty {
|
||||
} else if autoWakeEnabled, PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty {
|
||||
// Auto-wake only: fire the up-front packet so a genuinely-asleep host is booting while the
|
||||
// dial times out. With auto-wake off, connects go straight through (no packet).
|
||||
let macs = host.wakeMacs
|
||||
let ip = host.address
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// The unified "getting you connected" overlay — one look for BOTH phases of reaching a host, so the
|
||||
// user gets feedback the instant they pick one and it flows seamlessly into a wake if the host turns
|
||||
// out to be asleep. The Apple mirror of the Android client's `ConnectOverlay` and the shared console
|
||||
// UI's connect/wake takeover; it replaces the old centered-card `WakeOverlay`.
|
||||
//
|
||||
// - Connecting (`connectingHostName` non-nil): the dial is in flight. Shown immediately on activate
|
||||
// so a host that takes a beat to answer no longer looks like nothing happened.
|
||||
// - Waking (`waker.waking` non-nil): the dial failed on a sleeping host, so we're firing
|
||||
// Wake-on-LAN and waiting for it to advertise again, escalating to a retry/cancel prompt on
|
||||
// timeout.
|
||||
//
|
||||
// Presentation is mode-aware: the gamepad ("console") UI gets a full-screen aurora takeover — the
|
||||
// same living backdrop the console home wears, so it reads as a deliberate 10-foot moment; the
|
||||
// default touch/desktop UI gets a Liquid Glass modal over a dim scrim, which sits right at home among
|
||||
// the app's other floating surfaces (the trust card, the HUD) instead of a full-screen aurora that
|
||||
// looked out of place there.
|
||||
//
|
||||
// The two phases hand off within a single view update (HostWaker clears `waking` and starts the
|
||||
// connect in the same MainActor step), so the overlay never blinks between them. It swallows input to
|
||||
// the screen behind it, and on iOS/macOS the pad drives it (B cancels, A retries once timed out).
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
struct ConnectOverlay: View {
|
||||
/// Non-nil while a plain dial is in flight (the delegated-approval wait has its own prompt, so it
|
||||
/// passes nil here). Drives the "Connecting…" phase.
|
||||
let connectingHostName: String?
|
||||
@ObservedObject var waker: HostWaker
|
||||
/// The console launcher is up → full-screen aurora takeover; otherwise the default UI's Liquid
|
||||
/// Glass modal.
|
||||
var gamepadUI: Bool
|
||||
/// Cancel a dial in flight — tears down the (uncancelable) connect and returns the UI; the late
|
||||
/// result is discarded by SessionModel's connect guard.
|
||||
var onCancelConnect: () -> Void
|
||||
|
||||
private enum Phase {
|
||||
case connecting(name: String)
|
||||
case waking(HostWaker.Waking)
|
||||
}
|
||||
|
||||
/// Waking takes precedence — it only ever exists after a dial has already failed, so a stray
|
||||
/// overlap can't strand the "Connecting…" phase over a wake in progress.
|
||||
private var phase: Phase? {
|
||||
if let w = waker.waking { return .waking(w) }
|
||||
if let name = connectingHostName { return .connecting(name: name) }
|
||||
return nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if let phase {
|
||||
ZStack {
|
||||
if gamepadUI {
|
||||
// Console: an opaque, living aurora over everything.
|
||||
Color.black.ignoresSafeArea()
|
||||
GamepadScreenBackground().ignoresSafeArea()
|
||||
Color.clear.contentShape(Rectangle()).onTapGesture {}
|
||||
content(phase).padding(40).frame(maxWidth: 460)
|
||||
} else {
|
||||
// Default UI: a Liquid Glass modal over a dim scrim.
|
||||
Rectangle().fill(.black.opacity(0.5)).ignoresSafeArea()
|
||||
.contentShape(Rectangle()).onTapGesture {}
|
||||
content(phase)
|
||||
.padding(28)
|
||||
.frame(maxWidth: 380)
|
||||
.glassBackground(RoundedRectangle(cornerRadius: 26, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.12), lineWidth: 1))
|
||||
.padding(40)
|
||||
}
|
||||
}
|
||||
.environment(\.colorScheme, .dark)
|
||||
.transition(.opacity)
|
||||
#if os(iOS) || os(macOS)
|
||||
.background { ConnectControllerInput(waker: waker, onCancelConnect: onCancelConnect) }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func content(_ phase: Phase) -> some View {
|
||||
// The takeover carries larger type than the compact modal.
|
||||
let titleSize: CGFloat = gamepadUI ? 24 : 19
|
||||
let bodySize: CGFloat = gamepadUI ? 14 : 13
|
||||
VStack(spacing: gamepadUI ? 16 : 14) {
|
||||
switch phase {
|
||||
case .connecting(let name):
|
||||
ProgressView().controlSize(.large).tint(.white)
|
||||
Text("Connecting to \(name)")
|
||||
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white)
|
||||
.multilineTextAlignment(.center)
|
||||
Text("Establishing a secure connection…")
|
||||
.font(.geist(bodySize, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6))
|
||||
Button("Cancel") { onCancelConnect() }.buttonStyle(.bordered).padding(.top, 6)
|
||||
case .waking(let w) where w.timedOut:
|
||||
Image(systemName: "moon.zzz.fill")
|
||||
.font(.system(size: gamepadUI ? 40 : 34)).foregroundStyle(.white.opacity(0.9))
|
||||
Text("\(w.hostName) didn't wake")
|
||||
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white)
|
||||
.multilineTextAlignment(.center)
|
||||
Text("It may still be booting, or it's powered off / off this network.")
|
||||
.font(.geist(bodySize, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6))
|
||||
.multilineTextAlignment(.center)
|
||||
HStack(spacing: 12) {
|
||||
Button("Cancel") { waker.cancel() }.buttonStyle(.bordered)
|
||||
Button("Try Again") { waker.retry() }.glassProminentButtonStyle()
|
||||
}
|
||||
.padding(.top, 6)
|
||||
case .waking(let w):
|
||||
ProgressView().controlSize(.large).tint(.white)
|
||||
Text("Waking \(w.hostName)…")
|
||||
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white)
|
||||
.multilineTextAlignment(.center)
|
||||
Text("Waiting for it to come online · \(w.seconds)s")
|
||||
.font(.geistFixed(bodySize)).foregroundStyle(.white.opacity(0.6)).monospacedDigit()
|
||||
// A wake-only wait (no dial after) offers "Stop Waiting"; a wake-&-connect is "Cancel".
|
||||
Button(w.connectsAfter ? "Cancel" : "Stop Waiting") { waker.cancel() }
|
||||
.buttonStyle(.bordered).padding(.top, 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
/// Controller binding for the overlay: B cancels whatever's in flight (a dial or the wake wait); A
|
||||
/// retries once a wake has timed out. The closures read the live state on each press, so they stay
|
||||
/// correct across the Connecting ↔ Waking handoff without the view re-mounting. A zero-size backing
|
||||
/// view owning a `GamepadMenuInput` for the overlay's lifetime (the home is gated inactive while the
|
||||
/// overlay is up, so nothing else is consuming the pad).
|
||||
private struct ConnectControllerInput: View {
|
||||
@ObservedObject var waker: HostWaker
|
||||
var onCancelConnect: () -> Void
|
||||
@State private var input = GamepadMenuInput(manager: .shared)
|
||||
|
||||
var body: some View {
|
||||
Color.clear
|
||||
.onAppear {
|
||||
input.onBack = { if waker.waking != nil { waker.cancel() } else { onCancelConnect() } }
|
||||
input.onConfirm = { if waker.waking?.timedOut == true { waker.retry() } }
|
||||
input.start()
|
||||
}
|
||||
.onDisappear { input.stop() }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,13 +1,15 @@
|
||||
// The gamepad-driven "Add Host" screen (iOS/iPadOS/macOS) — the controller counterpart of
|
||||
// The gamepad-driven "Add Host" screen (iOS/iPadOS/macOS/tvOS) — the controller counterpart of
|
||||
// AddHostSheet, reached from the launcher's Add Host tile. Three field rows (name / address /
|
||||
// port) plus the Add action, navigated with the same vertical focus list as the gamepad settings;
|
||||
// A on a field opens GamepadKeyboard in a bottom tray, so a host can be registered end to end
|
||||
// without touching the screen. Field edits are live (the row shows every keystroke); B closes the
|
||||
// keyboard first, then cancels the screen — the same "back peels one layer" rule as a console UI.
|
||||
// tvOS swaps the custom keyboard tray for the SYSTEM fullscreen keyboard (TVTextEntry): unlike
|
||||
// iOS/macOS, tvOS HAS a first-class controller/remote-drivable text entry, so the native one wins.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS)
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
|
||||
struct GamepadAddHostView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@@ -37,22 +39,22 @@ struct GamepadAddHostView: View {
|
||||
isActive: editing == nil
|
||||
) { row, focused in
|
||||
rowView(row, focused: focused)
|
||||
.frame(maxWidth: 620)
|
||||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
VStack(spacing: 4) {
|
||||
Text("Add Host")
|
||||
.font(.geist(compact ? 20 : 30, .bold, relativeTo: .title))
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(.white)
|
||||
if !compact {
|
||||
Text("Hosts on this network appear automatically — add one by address "
|
||||
+ "for everything else.")
|
||||
.font(.geist(13, relativeTo: .caption))
|
||||
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 440)
|
||||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72)
|
||||
}
|
||||
}
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
@@ -75,10 +77,38 @@ struct GamepadAddHostView: View {
|
||||
.onChange(of: port) { _, value in
|
||||
if value.count > 5 { port = String(value.prefix(5)) }
|
||||
}
|
||||
#if os(tvOS)
|
||||
// tvOS types with the SYSTEM fullscreen keyboard (TVTextEntry) instead of the custom
|
||||
// tray — the remote and the pad both drive it natively. Same `editing` state as the
|
||||
// tray, just a different presentation; done (or Menu, edits-stick) commits and returns.
|
||||
.fullScreenCover(isPresented: Binding(
|
||||
get: { editing != nil },
|
||||
set: { if !$0 { editing = nil } })
|
||||
) {
|
||||
if let field = editing {
|
||||
TVTextEntry(
|
||||
title: fieldTitle(field),
|
||||
text: editingBinding(field).wrappedValue,
|
||||
keyboardType: keyboardType(field)
|
||||
) { value in
|
||||
commitEntry(field, value)
|
||||
editing = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// The keyboard tray while editing, the controls legend otherwise.
|
||||
/// The keyboard tray while editing, the controls legend otherwise. (tvOS never shows the
|
||||
/// tray — `editing` presents the system keyboard cover instead — so it's legend-only there.)
|
||||
@ViewBuilder private var bottomTray: some View {
|
||||
#if os(tvOS)
|
||||
GamepadHintBar(hints: [
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select"),
|
||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Cancel"),
|
||||
])
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
#else
|
||||
if let editing {
|
||||
VStack(spacing: 10) {
|
||||
GamepadKeyboard(
|
||||
@@ -104,6 +134,7 @@ struct GamepadAddHostView: View {
|
||||
])
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Touch/click fallback for closing — the controller path is B, a hardware keyboard's Esc
|
||||
@@ -111,14 +142,16 @@ struct GamepadAddHostView: View {
|
||||
private var closeButton: some View {
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 34, height: 34)
|
||||
.frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide)
|
||||
.glassBackground(Circle(), interactive: true)
|
||||
.contentShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.cancelAction) // unavailable on tvOS (Menu is the cancel there)
|
||||
#endif
|
||||
.accessibilityLabel("Cancel")
|
||||
}
|
||||
|
||||
@@ -142,19 +175,20 @@ struct GamepadAddHostView: View {
|
||||
}
|
||||
|
||||
private func rowView(_ row: Row, focused: Bool) -> some View {
|
||||
HStack(spacing: 14) {
|
||||
let m = GamepadFormMetrics.self
|
||||
return HStack(spacing: 14) {
|
||||
if row.isAction {
|
||||
Label("Add Host", systemImage: "plus.circle.fill")
|
||||
.font(.geist(16, .semibold, relativeTo: .body))
|
||||
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
|
||||
.foregroundStyle(canAdd ? Color.brand : .white.opacity(0.35))
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Text(row.label)
|
||||
.font(.geist(16, .semibold, relativeTo: .body))
|
||||
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
|
||||
.foregroundStyle(.white)
|
||||
Spacer(minLength: 12)
|
||||
Text(row.value.isEmpty ? row.placeholder : row.value)
|
||||
.font(.geistFixed(15, .medium))
|
||||
.font(.geistFixed(m.valueFont, .medium))
|
||||
.foregroundStyle(row.value.isEmpty ? .white.opacity(0.35) : .white)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.head) // keep the end of a long address visible while typing
|
||||
@@ -162,20 +196,20 @@ struct GamepadAddHostView: View {
|
||||
// The live-edit caret: this row is what the keyboard tray is typing into.
|
||||
Rectangle()
|
||||
.fill(Color.brand)
|
||||
.frame(width: 2, height: 18)
|
||||
.frame(width: 2, height: m.labelFont + 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 13)
|
||||
.padding(.horizontal, m.rowHPad)
|
||||
.padding(.vertical, m.rowVPad)
|
||||
// Liquid Glass rows, matching the settings screen; the focused (or actively edited) row
|
||||
// takes the brand wash, and the edited row keeps its brand caret border.
|
||||
.consoleGlass(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous),
|
||||
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
|
||||
tint: (focused || editing == row.id) ? Color.brand.opacity(0.30) : nil,
|
||||
interactive: focused)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
|
||||
.strokeBorder(
|
||||
editing == row.id ? Color.brand.opacity(0.7) : .white.opacity(focused ? 0.28 : 0.06),
|
||||
lineWidth: 1)
|
||||
@@ -235,5 +269,41 @@ struct GamepadAddHostView: View {
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
// MARK: - System keyboard plumbing (see the fullScreenCover on `body`)
|
||||
|
||||
private func fieldTitle(_ id: String) -> String {
|
||||
switch id {
|
||||
case "name": return "Name (optional)"
|
||||
case "port": return "Port"
|
||||
default: return "Address (IP or hostname)"
|
||||
}
|
||||
}
|
||||
|
||||
/// .URL for the address (dots on the primary layer, no autocapitalize) — the closest tvOS
|
||||
/// keyboard to "hostname or IP".
|
||||
private func keyboardType(_ id: String) -> UIKeyboardType {
|
||||
switch id {
|
||||
case "port": return .numberPad
|
||||
case "address": return .URL
|
||||
default: return .default
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a system-keyboard result, enforcing what `allowedCharacters` enforces per keystroke
|
||||
/// on the other platforms (the system keyboard will type anything).
|
||||
private func commitEntry(_ id: String, _ value: String) {
|
||||
switch id {
|
||||
case "port":
|
||||
editingBinding(id).wrappedValue = String(value.filter(\.isNumber).prefix(5))
|
||||
case "address":
|
||||
editingBinding(id).wrappedValue = value
|
||||
.replacingOccurrences(of: " ", with: "")
|
||||
default:
|
||||
editingBinding(id).wrappedValue = value
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
// The one piece of gamepad-menu machinery shared by the host launcher (GamepadHomeView) and the
|
||||
// library coverflow (LibraryCoverflowView): a horizontal, center-snapping carousel driven entirely
|
||||
// by a controller (iOS/iPadOS/macOS).
|
||||
// by a controller (iOS/iPadOS/macOS) — or, on tvOS, by the NATIVE FOCUS ENGINE: every card is a
|
||||
// focusable Button, so the Siri Remote and a game controller both navigate through the system
|
||||
// (dpad/swipe moves focus, select activates, Menu backs out at the presentation level), and the
|
||||
// cursor/scroll chase the focused card instead of the poll. The poll still runs on tvOS but
|
||||
// carries ONLY the Y/X actions (library/settings) — buttons the focus engine has no concept of.
|
||||
// The iOS/macOS poll-driven behavior is untouched by the tvOS mode.
|
||||
//
|
||||
// The scrolling is pure native SwiftUI — `.scrollTargetLayout()` + `.scrollTargetBehavior(.viewAligned)`
|
||||
// snap exactly one item to center, and symmetric `.safeAreaPadding(.horizontal)` (sized off the live
|
||||
@@ -24,7 +29,7 @@
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS)
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
|
||||
struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hashable {
|
||||
let items: [Item]
|
||||
@@ -54,6 +59,11 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
|
||||
@State private var input = GamepadMenuInput(manager: .shared)
|
||||
@State private var haptics = MenuHaptics(manager: .shared)
|
||||
#if os(tvOS)
|
||||
/// tvOS: the focus engine is the navigation authority — `cursor`/`scrolledID` chase this,
|
||||
/// never the other way around (mirroring the poll's cursor-first discipline).
|
||||
@FocusState private var focusedID: Item.ID?
|
||||
#endif
|
||||
/// Authoritative gamepad cursor (index into `items`). Never assigned from scroll read-back
|
||||
/// while the gamepad is driving — that's the whole desync fix.
|
||||
@State private var cursor = 0
|
||||
@@ -81,26 +91,72 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let inset = max(0, (geo.size.width - itemWidth) / 2)
|
||||
ScrollView(.horizontal) {
|
||||
HStack(spacing: spacing) {
|
||||
ForEach(items) { item in
|
||||
card(item)
|
||||
.frame(width: itemWidth)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { tap(item) }
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView(.horizontal) {
|
||||
HStack(spacing: spacing) {
|
||||
ForEach(items) { item in
|
||||
#if os(tvOS)
|
||||
// A focusable Button per card: the focus engine does the navigating
|
||||
// (remote swipes and pad dpad alike), select activates. The bare style
|
||||
// below keeps the tile's own look — the `.scrollTransition` center pop
|
||||
// is the focus treatment, since focus and center track each other.
|
||||
Button { activate(item) } label: {
|
||||
card(item)
|
||||
.frame(width: itemWidth)
|
||||
}
|
||||
.buttonStyle(ConsoleBareButtonStyle())
|
||||
.focused($focusedID, equals: item.id)
|
||||
.id(item.id)
|
||||
#else
|
||||
card(item)
|
||||
.frame(width: itemWidth)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { tap(item) }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.frame(height: geo.size.height) // fill so shorter cards center vertically
|
||||
.scrollTargetLayout()
|
||||
}
|
||||
// The two-way `.scrollPosition` + snap machinery serves the POLL/touch platforms.
|
||||
// Not on tvOS: that binding DROPS a write landing mid-animation (the very desync
|
||||
// the poll's cursor design exists to avoid — see the header), and on tvOS the
|
||||
// focus engine's own reveal-scrolls are always in flight, so drops were routine
|
||||
// ("navigation not reflected in the scroll view"). tvOS scrolls imperatively
|
||||
// below instead — scrollTo RE-TARGETS mid-animation (the GamepadMenuList pattern).
|
||||
#if !os(tvOS)
|
||||
.scrollPosition(id: $scrolledID)
|
||||
.scrollTargetBehavior(.viewAligned)
|
||||
#endif
|
||||
// .never, not .hidden — macOS's "always show scroll bars" setting overrides .hidden
|
||||
// and paints a scroller across the console strip.
|
||||
.scrollIndicators(.never)
|
||||
.scrollClipDisabled() // let the focused card scale up past the strip bounds
|
||||
.safeAreaPadding(.horizontal, inset)
|
||||
.offset(x: bumpOffset)
|
||||
#if os(tvOS)
|
||||
// Land initial focus on the first card (the launcher's first host / the coverflow's
|
||||
// first title) instead of wherever the engine guesses.
|
||||
.defaultFocus($focusedID, items.first?.id)
|
||||
// Focus moved (remote swipe / pad dpad) — chase it: cursor, detail selection,
|
||||
// controller detent, and an imperative center scroll.
|
||||
.onChange(of: focusedID) { _, newValue in
|
||||
guard let idx = index(of: newValue), idx != cursor else { return }
|
||||
cursor = idx
|
||||
lastNav = Date()
|
||||
haptics.move()
|
||||
selection = newValue
|
||||
withAnimation(.easeOut(duration: scrollAnim)) {
|
||||
proxy.scrollTo(newValue, anchor: .center)
|
||||
}
|
||||
}
|
||||
.frame(height: geo.size.height) // fill so shorter cards center vertically
|
||||
.scrollTargetLayout()
|
||||
// The list changed under a stable focus (discovered hosts prepend tiles): the
|
||||
// content shifted but no focus change fires above — re-center the focused card.
|
||||
.onChange(of: items.map(\.id)) { _, _ in
|
||||
if let id = focusedID { proxy.scrollTo(id, anchor: .center) }
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.scrollPosition(id: $scrolledID)
|
||||
.scrollTargetBehavior(.viewAligned)
|
||||
// .never, not .hidden — macOS's "always show scroll bars" setting overrides .hidden
|
||||
// and paints a scroller across the console strip.
|
||||
.scrollIndicators(.never)
|
||||
.scrollClipDisabled() // let the focused card scale up past the strip bounds
|
||||
.safeAreaPadding(.horizontal, inset)
|
||||
.offset(x: bumpOffset)
|
||||
}
|
||||
.sensoryFeedback(.selection, trigger: cursor)
|
||||
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
|
||||
@@ -128,13 +184,16 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
// A touch drag settles the scroll onto a new id: adopt it as the cursor. Ignored while a
|
||||
// programmatic scroll is animating (its own intermediate id write-backs would regress the
|
||||
// cursor) and briefly after a gamepad move (the same reason), so only a genuine touch drag
|
||||
// — which never sets `isScrolling` — moves the cursor here.
|
||||
// — which never sets `isScrolling` — moves the cursor here. Not on tvOS: there is no touch
|
||||
// drag, and the focus engine's own reveal-scrolls must never steal the cursor from focus.
|
||||
#if !os(tvOS)
|
||||
.onChange(of: scrolledID) { _, newValue in
|
||||
guard !isScrolling, Date().timeIntervalSince(lastNav) > navSettle else { return }
|
||||
guard let idx = index(of: newValue), idx != cursor else { return }
|
||||
cursor = idx
|
||||
selection = newValue
|
||||
}
|
||||
#endif
|
||||
// Re-seed a dropped/changed selection AND re-wire the input callbacks so they capture the
|
||||
// current `items` value (a plain array — unlike an observed object it would otherwise go
|
||||
// stale in the closures stored on `input`).
|
||||
@@ -147,12 +206,20 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
// MARK: - Input wiring
|
||||
|
||||
private func wire() {
|
||||
#if os(tvOS)
|
||||
// The focus engine owns move/confirm/back on tvOS (that's what keeps the Siri Remote
|
||||
// working on this screen — and what routes Menu through the system's back semantics).
|
||||
// The poll carries only the buttons focus has no concept of: Y/X, the screen actions.
|
||||
input.onSecondary = onSecondary
|
||||
input.onTertiary = onTertiary
|
||||
#else
|
||||
input.onMove = { move($0) }
|
||||
input.onConfirm = { activate() }
|
||||
input.onSecondary = onSecondary
|
||||
input.onTertiary = onTertiary
|
||||
input.onBack = onBack
|
||||
input.onShoulder = shoulderJump > 0 ? { shoulder(right: $0) } : nil
|
||||
#endif
|
||||
}
|
||||
|
||||
private func move(_ direction: GamepadMenuInput.Direction) {
|
||||
@@ -212,9 +279,14 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
|
||||
private func activate() {
|
||||
guard cursor >= 0, cursor < items.count else { return }
|
||||
activate(items[cursor])
|
||||
}
|
||||
|
||||
/// Shared confirm tail — the poll activates the cursor's item, a tvOS Button its own.
|
||||
private func activate(_ item: Item) {
|
||||
activateTick &+= 1
|
||||
haptics.confirm()
|
||||
onActivate(items[cursor])
|
||||
onActivate(item)
|
||||
}
|
||||
|
||||
/// Touch fallback matching the rest of the app: tapping the centered card activates it, tapping
|
||||
@@ -257,6 +329,13 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
scrolledID = id
|
||||
selection = id
|
||||
}
|
||||
#if os(tvOS)
|
||||
// Keep real focus on the reconciled item when its old target vanished from the list —
|
||||
// the engine would otherwise pick a neighbour by geometry and drag the cursor with it.
|
||||
if focusedID == nil || index(of: focusedID) == nil, cursor < items.count {
|
||||
focusedID = items[cursor].id
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func boundaryBump(forward: Bool) {
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
// GamepadAddHostView, LibraryCoverflowView): the full-bleed console backdrop, the
|
||||
// controller-glyph hint bar, and the connected-controller status chip. One look across every
|
||||
// screen is what makes the gamepad UI read as a coherent mode rather than a set of themed pages.
|
||||
// iOS/iPadOS and macOS (the couch Mac-mini case); tvOS keeps its native focus engine instead.
|
||||
// iOS/iPadOS, macOS (the couch Mac-mini case), and tvOS — where the same screens are driven by
|
||||
// the native focus engine instead of the controller poll (see GamepadCarousel/GamepadMenuList).
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS)
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
import GameController
|
||||
|
||||
/// The active controller's real glyph for a button (Xbox "A", DualSense ✕, …) via
|
||||
@@ -31,6 +32,51 @@ func gamepadTitleTopPadding(compact: Bool) -> CGFloat {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Point size for a gamepad screen's pinned title: TV-large on tvOS (read from the couch), the
|
||||
/// in-hand compact-aware sizes elsewhere.
|
||||
func gamepadTitleSize(compact: Bool) -> CGFloat {
|
||||
#if os(tvOS)
|
||||
44
|
||||
#else
|
||||
compact ? 20 : 30
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Metrics shared by the gamepad form screens' glass rows (GamepadSettingsView,
|
||||
/// GamepadAddHostView) — one set of numbers so the two screens read as the same surface,
|
||||
/// sized for the couch on tvOS and for the hand elsewhere.
|
||||
enum GamepadFormMetrics {
|
||||
#if os(tvOS)
|
||||
static let headerFont: CGFloat = 17
|
||||
static let labelFont: CGFloat = 23
|
||||
static let valueFont: CGFloat = 21
|
||||
static let iconFont: CGFloat = 24
|
||||
static let iconWidth: CGFloat = 40
|
||||
static let chevronFont: CGFloat = 16
|
||||
static let rowHPad: CGFloat = 24
|
||||
static let rowVPad: CGFloat = 19
|
||||
static let rowCorner: CGFloat = 18
|
||||
static let rowMaxWidth: CGFloat = 920
|
||||
static let detailFont: CGFloat = 19
|
||||
static let closeFont: CGFloat = 20
|
||||
static let closeSide: CGFloat = 48
|
||||
#else
|
||||
static let headerFont: CGFloat = 12
|
||||
static let labelFont: CGFloat = 16
|
||||
static let valueFont: CGFloat = 15
|
||||
static let iconFont: CGFloat = 17
|
||||
static let iconWidth: CGFloat = 28
|
||||
static let chevronFont: CGFloat = 12
|
||||
static let rowHPad: CGFloat = 16
|
||||
static let rowVPad: CGFloat = 13
|
||||
static let rowCorner: CGFloat = 14
|
||||
static let rowMaxWidth: CGFloat = 620
|
||||
static let detailFont: CGFloat = 13
|
||||
static let closeFont: CGFloat = 14
|
||||
static let closeSide: CGFloat = 34
|
||||
#endif
|
||||
}
|
||||
|
||||
/// One glyph + label cell in a hint bar.
|
||||
struct GamepadHint: Identifiable {
|
||||
let glyph: String
|
||||
@@ -45,21 +91,32 @@ struct GamepadHint: Identifiable {
|
||||
struct GamepadHintBar: View {
|
||||
let hints: [GamepadHint]
|
||||
|
||||
// 10-foot legend on tvOS, in-hand sizes elsewhere.
|
||||
#if os(tvOS)
|
||||
private static let glyphFont: CGFloat = 27
|
||||
private static let textFont: CGFloat = 20
|
||||
private static let pad: CGFloat = 18
|
||||
#else
|
||||
private static let glyphFont: CGFloat = 19
|
||||
private static let textFont: CGFloat = 14
|
||||
private static let pad: CGFloat = 13
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 18) {
|
||||
ForEach(hints) { hint in
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: hint.glyph)
|
||||
.font(.system(size: 19))
|
||||
.font(.system(size: Self.glyphFont))
|
||||
.foregroundStyle(.white)
|
||||
Text(hint.text)
|
||||
}
|
||||
.fixedSize() // keep glyph + label together; never truncate a hint mid-word
|
||||
}
|
||||
}
|
||||
.font(.geist(14, .semibold, relativeTo: .subheadline))
|
||||
.font(.geist(Self.textFont, .semibold, relativeTo: .subheadline))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
.padding(13)
|
||||
.padding(Self.pad)
|
||||
.consoleGlass(Capsule())
|
||||
.overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1))
|
||||
}
|
||||
@@ -297,30 +354,56 @@ struct GamepadFormBackground: View {
|
||||
}
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
/// Bare chrome for the focusable console Buttons (carousel cards, menu-list rows) on tvOS: the
|
||||
/// tile/row draws its own look and the screen's own focus treatment marks the focused element
|
||||
/// (the carousel's `.scrollTransition` center pop, the list row's `focused` styling), so the
|
||||
/// system's lift/halo would double up on it. Press feedback is a small dip, matching the
|
||||
/// interactive-glass feel elsewhere.
|
||||
struct ConsoleBareButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.97 : 1)
|
||||
.animation(.smooth(duration: 0.15), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// "Which pad is driving this UI" — the active controller's name and battery, worn as a quiet
|
||||
/// 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 {
|
||||
let controller: GamepadManager.DiscoveredController
|
||||
|
||||
// Legible from the couch on tvOS, quiet in hand elsewhere.
|
||||
#if os(tvOS)
|
||||
private static let font: CGFloat = 17
|
||||
private static let hPad: CGFloat = 16
|
||||
private static let vPad: CGFloat = 10
|
||||
#else
|
||||
private static let font: CGFloat = 12
|
||||
private static let hPad: CGFloat = 12
|
||||
private static let vPad: CGFloat = 7
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: controller.hasTouchpadAndMotion
|
||||
? "playstation.logo" : "gamecontroller.fill")
|
||||
.font(.system(size: 12))
|
||||
.font(.system(size: Self.font))
|
||||
Text(controller.name)
|
||||
.lineLimit(1)
|
||||
if let level = controller.batteryLevel {
|
||||
Image(systemName: batterySymbol(level))
|
||||
.font(.system(size: 12))
|
||||
.font(.system(size: Self.font))
|
||||
.foregroundStyle(level <= 0.2 && !controller.isCharging
|
||||
? AnyShapeStyle(.red) : AnyShapeStyle(.white.opacity(0.7)))
|
||||
}
|
||||
}
|
||||
.font(.geist(12, .medium, relativeTo: .caption))
|
||||
.font(.geist(Self.font, .medium, relativeTo: .caption))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 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))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// The gamepad-driven home screen (iOS/iPadOS only): a distinct, "10-foot" console-style host
|
||||
// The gamepad-driven home screen: a distinct, "10-foot" console-style host
|
||||
// launcher shown INSTEAD of HomeView while GamepadUIEnvironment is active — a separate screen built
|
||||
// around a center-snapping carousel of hosts, driven from the couch with a controller. No touch is
|
||||
// required anywhere: A connects, Y opens a saved host's library (when the flag is on), X opens the
|
||||
@@ -14,11 +14,13 @@
|
||||
// `.safeAreaInset` (top / bottom-leading) — guaranteed inside the safe area and out of the carousel's
|
||||
// vertical budget — and the card is sized off the remaining height. macOS mounts it too (the
|
||||
// couch Mac-mini case) — same screen, with the settings/add-host covers presented as sheets
|
||||
// (macOS has no fullScreenCover). tvOS never mounts this view (native focus engine instead).
|
||||
// (macOS has no fullScreenCover). tvOS mounts it as well, driven by the native focus engine
|
||||
// (see GamepadCarousel's tvOS mode) so the Siri Remote works alongside the pad; Play/Pause
|
||||
// mirrors X for Settings since the focus engine has no concept of that button.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS)
|
||||
#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
|
||||
@@ -60,8 +62,12 @@ struct GamepadHomeView: View {
|
||||
let connect: (StoredHost) -> Void
|
||||
let connectDiscovered: (DiscoveredHost) -> Void
|
||||
|
||||
/// Same experimental gate the touch grid's "Browse Library…" context-menu item uses.
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
||||
/// Same gate the touch grid's "Browse Library…" context-menu item uses (default ON; the
|
||||
/// Settings "Game library" toggle opts out).
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
||||
/// Auto-wake on connect (default ON) — when off, activating an offline host just dials (no wake),
|
||||
/// so the tile drops its "Wake & Connect" affordance for a plain "Connect".
|
||||
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
||||
#if os(iOS)
|
||||
/// `.compact` in a landscape phone window — drives tighter chrome so everything still fits.
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
@@ -104,6 +110,12 @@ struct GamepadHomeView: View {
|
||||
try? await Task.sleep(for: .seconds(10))
|
||||
}
|
||||
}
|
||||
// The remote's Play/Pause mirrors the pad's X (Settings): the focus engine never surfaces
|
||||
// X, and historically tvOS maps a pad's X to this same press — the poll and this command
|
||||
// double-firing just sets the same Bool twice.
|
||||
#if os(tvOS)
|
||||
.onPlayPauseCommand { showSettings = true }
|
||||
#endif
|
||||
// The settings / add-host screens take over the controller (the carousel's `isActive`
|
||||
// gate above). iOS presents them full screen — the immersive console feel; macOS has no
|
||||
// fullScreenCover, so they become generously sized sheets over the dimmed launcher.
|
||||
@@ -128,11 +140,17 @@ struct GamepadHomeView: View {
|
||||
// MARK: - Hero (carousel + detail), sized to fit the space between the pinned title and hints
|
||||
|
||||
@ViewBuilder private func hero(for size: CGSize) -> some View {
|
||||
#if os(tvOS)
|
||||
// 10-foot scale: the phone-sized card reads like a postage stamp from the couch.
|
||||
let cardWidth = min(560, size.width * 0.34)
|
||||
let cardHeight = min(350, max(240, size.height - 260))
|
||||
#else
|
||||
let cardWidth = min(340, size.width * 0.84)
|
||||
// 48 ≈ the carousel's own vertical breathing (+40) plus a small margin; clamp so the strip
|
||||
// always fits the region the pinned title / hints safe-area insets leave. (The old detail
|
||||
// line below the strip is gone — it only re-printed what the centered card already shows.)
|
||||
let cardHeight = min(compact ? 176 : 224, max(118, size.height - 48))
|
||||
#endif
|
||||
VStack(spacing: compact ? 8 : 10) {
|
||||
Spacer(minLength: 0)
|
||||
carousel(cardWidth: cardWidth, cardHeight: cardHeight)
|
||||
@@ -145,7 +163,7 @@ struct GamepadHomeView: View {
|
||||
|
||||
private var titleBar: some View {
|
||||
Text("Select a Host")
|
||||
.font(.geist(compact ? 20 : 30, .bold, relativeTo: .title))
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.overlay(alignment: .trailing) {
|
||||
@@ -158,6 +176,14 @@ struct GamepadHomeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var cardSpacing: CGFloat {
|
||||
#if os(tvOS)
|
||||
44
|
||||
#else
|
||||
30
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Carousel
|
||||
|
||||
private func carousel(cardWidth: CGFloat, cardHeight: CGFloat) -> some View {
|
||||
@@ -165,13 +191,16 @@ struct GamepadHomeView: View {
|
||||
items: tiles,
|
||||
selection: $selection,
|
||||
itemWidth: cardWidth,
|
||||
spacing: 30,
|
||||
spacing: cardSpacing,
|
||||
onActivate: { $0.activate() },
|
||||
onSecondary: { openLibraryForSelected() },
|
||||
onTertiary: { showSettings = true },
|
||||
// Stop consuming the controller while another screen (or the wake overlay) is on top —
|
||||
// otherwise the launcher navigates behind it (invisibly on iPhone, visibly on iPad).
|
||||
isActive: libraryTarget == nil && !showSettings && !showAddHost && waker.waking == nil
|
||||
// Stop consuming the controller while another screen (or the connect/wake takeover) is on
|
||||
// top — otherwise the launcher navigates behind it (invisibly on iPhone, visibly on iPad),
|
||||
// and a second A during a dial would launch a concurrent connect. `.connecting` covers the
|
||||
// takeover's Connecting phase; `waker.waking` covers its Waking phase.
|
||||
isActive: libraryTarget == nil && !showSettings && !showAddHost
|
||||
&& waker.waking == nil && model.phase != .connecting
|
||||
) { tile in
|
||||
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight))
|
||||
}
|
||||
@@ -233,7 +262,7 @@ struct GamepadHomeView: View {
|
||||
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
||||
filled: true,
|
||||
hasLibrary: true,
|
||||
canWake: PunktfunkConnection.wakeOnLANAvailable
|
||||
canWake: autoWakeEnabled && PunktfunkConnection.wakeOnLANAvailable
|
||||
&& !discovery.advertises(host) && !store.probedOnline.contains(host.id)
|
||||
&& !host.wakeMacs.isEmpty,
|
||||
activate: { connect(host) })
|
||||
@@ -272,6 +301,31 @@ private struct GamepadHostTile: View {
|
||||
let tile: HomeTile
|
||||
let size: CGSize
|
||||
|
||||
// 10-foot metrics on tvOS, in-hand metrics elsewhere — one tile, two viewing distances.
|
||||
#if os(tvOS)
|
||||
private static let titleFont: CGFloat = 33
|
||||
private static let subtitleFont: CGFloat = 19
|
||||
private static let statusFont: CGFloat = 15
|
||||
private static let pipSide: CGFloat = 12
|
||||
private static let badgeSide: CGFloat = 70
|
||||
private static let badgeCorner: CGFloat = 19
|
||||
private static let monogramFont: CGFloat = 34
|
||||
private static let iconFont: CGFloat = 32
|
||||
private static let pad: CGFloat = 28
|
||||
private static let corner: CGFloat = 30
|
||||
#else
|
||||
private static let titleFont: CGFloat = 23
|
||||
private static let subtitleFont: CGFloat = 13
|
||||
private static let statusFont: CGFloat = 11
|
||||
private static let pipSide: CGFloat = 9
|
||||
private static let badgeSide: CGFloat = 52
|
||||
private static let badgeCorner: CGFloat = 15
|
||||
private static let monogramFont: CGFloat = 25
|
||||
private static let iconFont: CGFloat = 24
|
||||
private static let pad: CGFloat = 20
|
||||
private static let corner: CGFloat = 26
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
@@ -282,38 +336,38 @@ private struct GamepadHostTile: View {
|
||||
HStack(spacing: 7) {
|
||||
if tile.isPaired {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.font(.system(size: Self.statusFont, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
}
|
||||
if tile.isOnline {
|
||||
Circle()
|
||||
.fill(Color.green)
|
||||
.frame(width: 9, height: 9)
|
||||
.frame(width: Self.pipSide, height: Self.pipSide)
|
||||
.shadow(color: .green.opacity(0.7), radius: 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
Text(tile.title)
|
||||
.font(.geist(23, .bold, relativeTo: .title2))
|
||||
.font(.geist(Self.titleFont, .bold, relativeTo: .title2))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
Text(tile.subtitle)
|
||||
.font(.geist(13, relativeTo: .caption))
|
||||
.font(.geist(Self.subtitleFont, relativeTo: .caption))
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
.lineLimit(1)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
.padding(20)
|
||||
.padding(Self.pad)
|
||||
.frame(width: size.width, height: size.height, alignment: .leading)
|
||||
// Liquid Glass console tile — a brand wash marks a saved host as primary; discovered /
|
||||
// Add-Host tiles stay neutral glass with a dashed edge. Glass clips to the shape itself.
|
||||
.consoleGlass(
|
||||
RoundedRectangle(cornerRadius: 26, style: .continuous),
|
||||
RoundedRectangle(cornerRadius: Self.corner, style: .continuous),
|
||||
tint: tile.filled ? Color.brand.opacity(0.20) : nil)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: Self.corner, style: .continuous)
|
||||
.strokeBorder(
|
||||
LinearGradient(
|
||||
colors: [.white.opacity(0.22), .white.opacity(0.04)],
|
||||
@@ -324,7 +378,7 @@ private struct GamepadHostTile: View {
|
||||
}
|
||||
|
||||
private var monogramBadge: some View {
|
||||
let shape = RoundedRectangle(cornerRadius: 15, style: .continuous)
|
||||
let shape = RoundedRectangle(cornerRadius: Self.badgeCorner, style: .continuous)
|
||||
return ZStack {
|
||||
shape.fill(tile.filled
|
||||
? AnyShapeStyle(LinearGradient(
|
||||
@@ -335,15 +389,15 @@ private struct GamepadHostTile: View {
|
||||
ProgressView().tint(.white)
|
||||
} else if let icon = tile.icon {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.font(.system(size: Self.iconFont, weight: .semibold))
|
||||
.foregroundStyle(Color.brand)
|
||||
} else {
|
||||
Text(monogram(tile.title))
|
||||
.font(.geistFixed(25, .bold))
|
||||
.font(.geistFixed(Self.monogramFont, .bold))
|
||||
.foregroundStyle(tile.filled ? .white : Color.brand)
|
||||
}
|
||||
}
|
||||
.frame(width: 52, height: 52)
|
||||
.frame(width: Self.badgeSide, height: Self.badgeSide)
|
||||
.overlay {
|
||||
if !tile.filled {
|
||||
shape.strokeBorder(Color.brand.opacity(0.5), lineWidth: 1)
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
// The vertical sibling of GamepadCarousel (iOS/iPadOS/macOS): a controller-driven focus list for
|
||||
// the gamepad UI's form-like screens (GamepadSettingsView, GamepadAddHostView). Up/down moves a
|
||||
// focus bar through the rows, left/right adjusts the focused row's value, A activates it, B backs
|
||||
// out. The CALLER owns each row's look (it gets the focused flag); this component owns the focus
|
||||
// cursor, controller polling, haptics, and keeping the focused row scrolled into view.
|
||||
// The vertical sibling of GamepadCarousel (iOS/iPadOS/macOS/tvOS): a controller-driven focus list
|
||||
// for the gamepad UI's form-like screens (GamepadSettingsView, GamepadAddHostView). Up/down moves
|
||||
// a focus bar through the rows, left/right adjusts the focused row's value, A activates it, B
|
||||
// backs out. The CALLER owns each row's look (it gets the focused flag); this component owns the
|
||||
// focus cursor, controller polling, haptics, and keeping the focused row scrolled into view.
|
||||
//
|
||||
// On tvOS the rows are focusable Buttons and the NATIVE FOCUS ENGINE replaces the poll entirely
|
||||
// (Siri Remote and pads both drive it: up/down moves focus, select activates, Menu — via
|
||||
// onExitCommand — backs out). Left/right value-adjust isn't wired there; select cycles a value
|
||||
// forward exactly like A does elsewhere, the standard tvOS settings interaction. The iOS/macOS
|
||||
// poll-driven behavior is untouched by the tvOS mode.
|
||||
//
|
||||
// Unlike the carousel there is no snapping and no `.scrollPosition` two-way binding to fight: the
|
||||
// cursor is plainly authoritative, the scroll view just chases it with `scrollTo`. Touch stays a
|
||||
@@ -16,7 +22,7 @@
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS)
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
|
||||
struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hashable {
|
||||
let items: [Item]
|
||||
@@ -36,6 +42,15 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
||||
|
||||
@State private var input = GamepadMenuInput(manager: .shared)
|
||||
@State private var haptics = MenuHaptics(manager: .shared)
|
||||
#if os(tvOS)
|
||||
/// tvOS: the focus engine is the navigation authority for UP/DOWN — `cursor` chases this, so
|
||||
/// the caller's `focused` row styling always matches real system focus. LEFT/RIGHT adjust
|
||||
/// comes from the POLL (see `wire`), never from `.onMoveCommand`: the command stream is
|
||||
/// 4-way with no axis data (diagonal scroll wobble buckets into left/right), and its
|
||||
/// interception of up/down proved INPUT-SOURCE-DEPENDENT on hardware — keyboard arrows were
|
||||
/// intercepted but a pad's dpad was not, so programmatic stepping double-moved every press.
|
||||
@FocusState private var focusedID: Item.ID?
|
||||
#endif
|
||||
/// Authoritative focus cursor (index into `items`).
|
||||
@State private var cursor = 0
|
||||
/// A short vertical recoil when a move is refused at a list end.
|
||||
@@ -51,10 +66,23 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
||||
ScrollView(.vertical) {
|
||||
LazyVStack(spacing: 6) {
|
||||
ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in
|
||||
#if os(tvOS)
|
||||
// A focusable Button per row: the engine moves between them, select
|
||||
// activates (`tap` keeps the cursor in step before firing). The row's
|
||||
// own `focused` styling is the focus treatment — the bare style adds
|
||||
// no system chrome on top of it.
|
||||
Button { tap(idx) } label: {
|
||||
row(item, focusedID == item.id)
|
||||
}
|
||||
.buttonStyle(ConsoleBareButtonStyle())
|
||||
.focused($focusedID, equals: item.id)
|
||||
.id(item.id)
|
||||
#else
|
||||
row(item, idx == cursor && isActive)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { tap(idx) }
|
||||
.id(item.id)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
@@ -69,6 +97,20 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
// Focus moved (remote swipe / pad dpad) — keep the cursor, the caller's focusID mirror,
|
||||
// and the controller detent in step. Menu = the list's back action (both tvOS callers
|
||||
// pass one; the screen behind would otherwise catch the press and peel too far).
|
||||
.onChange(of: focusedID) { _, newValue in
|
||||
guard let id = newValue, let idx = items.firstIndex(where: { $0.id == id }),
|
||||
idx != cursor else { return }
|
||||
cursor = idx
|
||||
focusID = id
|
||||
haptics.move()
|
||||
}
|
||||
.defaultFocus($focusedID, items.first?.id)
|
||||
.onExitCommand { onBack?() }
|
||||
#endif
|
||||
.sensoryFeedback(.selection, trigger: cursor)
|
||||
.sensoryFeedback(.selection, trigger: adjustTick)
|
||||
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
|
||||
@@ -102,6 +144,22 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
||||
// MARK: - Input wiring
|
||||
|
||||
private func wire() {
|
||||
#if os(tvOS)
|
||||
// The focus engine owns up/down and select (Button rows) and Menu (onExitCommand) — the
|
||||
// poll carries ONLY the horizontal axis, where its dominant-axis deadzone + hold-repeat
|
||||
// are exactly the adjust feel the other platforms have, and where the focus engine has
|
||||
// nothing to move to in a vertical list. Vertical poll directions are deliberately
|
||||
// dropped: acting on them would double the engine's own focus moves. (The Siri Remote
|
||||
// never reaches this poll — no extended profile — so remote users cycle values with
|
||||
// select instead, which `activate` already does.)
|
||||
input.onMove = { direction in
|
||||
switch direction {
|
||||
case .left: adjust(by: -1)
|
||||
case .right: adjust(by: 1)
|
||||
case .up, .down: break
|
||||
}
|
||||
}
|
||||
#else
|
||||
input.onMove = { direction in
|
||||
switch direction {
|
||||
case .up: step(by: -1)
|
||||
@@ -112,6 +170,7 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
||||
}
|
||||
input.onConfirm = { activate() }
|
||||
input.onBack = onBack
|
||||
#endif
|
||||
}
|
||||
|
||||
private func step(by delta: Int) {
|
||||
@@ -123,6 +182,7 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
||||
haptics.move()
|
||||
}
|
||||
|
||||
|
||||
private func adjust(by delta: Int) {
|
||||
guard let onAdjust, cursor >= 0, cursor < items.count else { return }
|
||||
if onAdjust(items[cursor], delta) {
|
||||
@@ -165,6 +225,12 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
||||
cursor = min(max(cursor, 0), items.count - 1)
|
||||
focusID = items[cursor].id
|
||||
}
|
||||
#if os(tvOS)
|
||||
// Keep real focus on the reconciled row when its old target vanished from the list.
|
||||
if focusedID == nil || !items.contains(where: { $0.id == focusedID }), cursor < items.count {
|
||||
focusedID = items[cursor].id
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func boundaryBump(forward: Bool) {
|
||||
|
||||
@@ -29,8 +29,9 @@ struct HomeView: View {
|
||||
/// Explicit Wake-on-LAN of an offline host — fires the packet and waits for it to come online
|
||||
/// (the "Waking…" overlay), without connecting. Routed through ContentView's HostWaker.
|
||||
let wake: (StoredHost) -> Void
|
||||
/// Experimental game-library browser (gated) — the host-card "Browse Library…" action.
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
||||
/// Game-library browser (default ON; the Settings toggle opts out) — the host-card
|
||||
/// "Browse Library…" action.
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
||||
/// The host being edited (name / address / port / Wake-on-LAN MAC) — drives the edit sheet.
|
||||
@State private var editTarget: StoredHost?
|
||||
|
||||
@@ -48,6 +49,13 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
// Mirror of the action row's focusSection below: an UPWARD move from
|
||||
// the centered buttons must land back in the grid even when no card
|
||||
// sits in the buttons' columns (a lone top-left card, say). The grid
|
||||
// spans the row, so the section catches every upward ray.
|
||||
#if os(tvOS)
|
||||
.focusSection()
|
||||
#endif
|
||||
}
|
||||
if !discoveredUnsaved.isEmpty {
|
||||
discoveredSection
|
||||
@@ -67,6 +75,14 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
.padding(.top, 24)
|
||||
// One FULL-WIDTH focus target for any downward move out of the grid.
|
||||
// focusSection alone is not enough: the engine tests the section's
|
||||
// FRAME, and a content-hugging centered HStack only overlaps the middle
|
||||
// columns — a swipe down from an outer card dead-ends and the actions
|
||||
// are unreachable by remote. Stretching the section across the row means
|
||||
// every column's downward ray hits it.
|
||||
.frame(maxWidth: .infinity)
|
||||
.focusSection()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -198,6 +214,10 @@ struct HomeView: View {
|
||||
}
|
||||
.padding([.horizontal, .bottom])
|
||||
.padding(.top, store.hosts.isEmpty ? 0 : 8)
|
||||
// Same reachability contract as the saved grid above — see its focusSection comment.
|
||||
#if os(tvOS)
|
||||
.focusSection()
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Discovered hosts not already saved (see `HostDiscovery.unsaved` — shared with the gamepad
|
||||
@@ -259,7 +279,9 @@ struct HomeView: View {
|
||||
#if os(macOS)
|
||||
[GridItem(.adaptive(minimum: 250, maximum: 320), spacing: 16)]
|
||||
#elseif os(tvOS)
|
||||
[GridItem(.adaptive(minimum: 320), spacing: 48)]
|
||||
// Tracks CardMetrics' 10-foot sizes — at the 30pt name a 320pt column truncates
|
||||
// every hostname longer than ~10 characters.
|
||||
[GridItem(.adaptive(minimum: 460), spacing: 48)]
|
||||
#else
|
||||
[GridItem(.adaptive(minimum: 280), spacing: 16)]
|
||||
#endif
|
||||
|
||||
@@ -22,8 +22,9 @@ private struct CardMetrics {
|
||||
CardMetrics(tile: 54, monogram: 26, name: 19, meta: 13, status: 11,
|
||||
padding: 16, spacing: 14, radius: 12)
|
||||
#elseif os(tvOS)
|
||||
CardMetrics(tile: 64, monogram: 32, name: 24, meta: 16, status: 14,
|
||||
padding: 18, spacing: 18, radius: 14)
|
||||
// 10-foot sizes — the 24pt-name tier read like a phone card from the couch.
|
||||
CardMetrics(tile: 84, monogram: 42, name: 30, meta: 20, status: 17,
|
||||
padding: 24, spacing: 22, radius: 18)
|
||||
#else
|
||||
CardMetrics(tile: 44, monogram: 21, name: 15, meta: 12, status: 10.5,
|
||||
padding: 13, spacing: 12, radius: 10)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// The gamepad-driven presentation of the game library (iOS/iPadOS/macOS — see LibraryView's
|
||||
// The gamepad-driven presentation of the game library (iOS/iPadOS/macOS/tvOS — see LibraryView's
|
||||
// `gamepadUIActive` branch): a classic coverflow instead of the touch grid. All the
|
||||
// scrolling/snapping/navigation/haptics live in GamepadCarousel; this file is the coverflow card
|
||||
// (poster + the 3D recede treatment via `.scrollTransition`), the "now focused" detail panel, and
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS)
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
import GameController
|
||||
|
||||
struct LibraryCoverflowView: View {
|
||||
|
||||
@@ -21,9 +21,9 @@ struct LibraryView: View {
|
||||
/// list fetch, reused across every poster in the grid). Built alongside `games` in `load()`;
|
||||
/// torn down on disappear since it isn't one-shot like `LibraryClient.fetch`'s own session.
|
||||
@State private var imageSession: URLSession?
|
||||
#if os(iOS) || os(macOS)
|
||||
// Gamepad-driven browsing (iOS/iPadOS/macOS) — see ContentView's identical gate. tvOS keeps
|
||||
// its existing plain-grid presentation of this same view unchanged.
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
// Gamepad-driven browsing — see ContentView's identical gate. With no controller (or the
|
||||
// setting off) every platform keeps the plain-grid presentation of this same view.
|
||||
@ObservedObject private var gamepadManager = GamepadManager.shared
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||
private var gamepadUIActive: Bool {
|
||||
@@ -69,7 +69,6 @@ struct LibraryView: View {
|
||||
} else if games.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
#if os(iOS) || os(macOS)
|
||||
if gamepadUIActive {
|
||||
LibraryCoverflowView(
|
||||
games: games, imageSession: imageSession, onLaunch: onLaunch,
|
||||
@@ -77,9 +76,6 @@ struct LibraryView: View {
|
||||
} else {
|
||||
grid
|
||||
}
|
||||
#else
|
||||
grid
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
// The "Waking <host>…" modal shown while HostWaker brings a sleeping host back — a spinner + a
|
||||
// live elapsed counter, escalating to a retry/cancel prompt on timeout. Presented over BOTH the
|
||||
// touch and gamepad home (a wake only ever starts on macOS today, where WoL is ungated), and it
|
||||
// drives from either a pointer (the buttons) or a controller (B cancels, A retries once timed out).
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
struct WakeOverlay: View {
|
||||
@ObservedObject var waker: HostWaker
|
||||
|
||||
var body: some View {
|
||||
if let w = waker.waking {
|
||||
ZStack {
|
||||
// Dim + swallow input to the home behind it.
|
||||
Rectangle().fill(.black.opacity(0.6)).ignoresSafeArea()
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {}
|
||||
card(w)
|
||||
.frame(maxWidth: 380)
|
||||
.padding(28)
|
||||
.consoleGlass(RoundedRectangle(cornerRadius: 22, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.12), lineWidth: 1))
|
||||
.padding(40)
|
||||
}
|
||||
.environment(\.colorScheme, .dark)
|
||||
.transition(.opacity)
|
||||
#if os(iOS) || os(macOS)
|
||||
.background { WakeControllerInput(waker: waker) }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func card(_ w: HostWaker.Waking) -> some View {
|
||||
VStack(spacing: 14) {
|
||||
if w.timedOut {
|
||||
Image(systemName: "moon.zzz.fill")
|
||||
.font(.system(size: 34)).foregroundStyle(.white.opacity(0.85))
|
||||
Text("\(w.hostName) didn't wake")
|
||||
.font(.geist(19, .bold, relativeTo: .title3)).foregroundStyle(.white)
|
||||
Text("It may still be booting, or it's powered off / off this network.")
|
||||
.font(.geist(13, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6))
|
||||
.multilineTextAlignment(.center)
|
||||
HStack(spacing: 12) {
|
||||
Button("Cancel") { waker.cancel() }.buttonStyle(.bordered)
|
||||
Button("Try Again") { waker.retry() }.glassProminentButtonStyle()
|
||||
}
|
||||
.padding(.top, 6)
|
||||
} else {
|
||||
ProgressView().controlSize(.large).tint(.white)
|
||||
Text("Waking \(w.hostName)…")
|
||||
.font(.geist(19, .bold, relativeTo: .title3)).foregroundStyle(.white)
|
||||
Text("Waiting for it to come online · \(w.seconds)s")
|
||||
.font(.geistFixed(13)).foregroundStyle(.white.opacity(0.6))
|
||||
.monospacedDigit()
|
||||
Button(w.connectsAfter ? "Cancel" : "Stop Waiting") { waker.cancel() }
|
||||
.buttonStyle(.bordered)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
/// Controller binding for the overlay: B cancels; A retries once it has timed out. A zero-size
|
||||
/// backing view owning a `GamepadMenuInput` for the overlay's lifetime (the home carousel/list is
|
||||
/// gated inactive while a wake is up, so nothing else is consuming the pad).
|
||||
private struct WakeControllerInput: View {
|
||||
@ObservedObject var waker: HostWaker
|
||||
@State private var input = GamepadMenuInput(manager: .shared)
|
||||
|
||||
var body: some View {
|
||||
Color.clear
|
||||
.onAppear {
|
||||
input.onBack = { waker.cancel() }
|
||||
input.onConfirm = { if waker.waking?.timedOut == true { waker.retry() } }
|
||||
input.start()
|
||||
}
|
||||
.onDisappear { input.stop() }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -38,10 +38,22 @@ struct PunktfunkClientApp: App {
|
||||
ContentView()
|
||||
#endif
|
||||
}
|
||||
// NOT on tvOS: under the tvOS 26 glass button style a tinted UNFOCUSED control fills
|
||||
// AND labels itself in the tint — every plain Button/TextField renders as a blank
|
||||
// brand-violet pill until focused. Untinted, tvOS keeps the system glass look
|
||||
// (visible labels, white focus lift); brand color stays on explicit Color.brand uses.
|
||||
#if !os(tvOS)
|
||||
.tint(.brand)
|
||||
#endif
|
||||
// Geist Sans is the app's typeface. This sets the default for unstyled text and the
|
||||
// form row labels; views that pick an explicit size/weight use `.geist(…)` directly.
|
||||
// tvOS reads from across the room: its system body is 29pt, so pinning the phone's
|
||||
// 17pt there shrank every unstyled control (rows, fields, buttons) to postage size.
|
||||
#if os(tvOS)
|
||||
.font(.geist(29, relativeTo: .body))
|
||||
#else
|
||||
.font(.geist(17, relativeTo: .body))
|
||||
#endif
|
||||
}
|
||||
// The Stream menu (Release Mouse ⌃⌥⇧Q, Disconnect ⌃⌥⇧D, Show/Hide Statistics ⌃⌥⇧S —
|
||||
// the cross-client Ctrl+Alt+Shift set) — a real menu bar on macOS, hardware-keyboard
|
||||
|
||||
@@ -49,8 +49,24 @@ enum ShotScenes {
|
||||
ShotScene(name: "08-gamepad-addhost", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotGamepadAddHost())
|
||||
},
|
||||
ShotScene(name: "09-waking", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotWaking())
|
||||
ShotScene(name: "09-connecting", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotConnect(kind: .connecting))
|
||||
},
|
||||
ShotScene(name: "09b-waking", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotConnect(kind: .waking))
|
||||
},
|
||||
ShotScene(name: "09c-wake-timed-out", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotConnect(kind: .timedOut))
|
||||
},
|
||||
// The default-UI presentation (Liquid Glass modal over the touch grid) of the same phases.
|
||||
ShotScene(name: "09d-connecting-modal", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotConnect(kind: .connecting, gamepadUI: false))
|
||||
},
|
||||
ShotScene(name: "09e-waking-modal", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotConnect(kind: .waking, gamepadUI: false))
|
||||
},
|
||||
ShotScene(name: "09f-wake-timed-out-modal", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotConnect(kind: .timedOut, gamepadUI: false))
|
||||
},
|
||||
]
|
||||
#endif
|
||||
@@ -137,23 +153,53 @@ private struct ShotGamepadAddHost: View {
|
||||
var body: some View { GamepadAddHostView(onAdd: { _ in }) }
|
||||
}
|
||||
|
||||
private struct ShotWaking: View {
|
||||
/// The unified connect overlay (the real `ConnectOverlay`) in each phase — instant "Connecting…"
|
||||
/// feedback, the "Waking…" wait, and the wake-timed-out prompt. `gamepadUI` picks the presentation:
|
||||
/// the console's full-screen aurora takeover over the gamepad home, or the default UI's Liquid Glass
|
||||
/// modal over the touch host grid.
|
||||
private struct ShotConnect: View {
|
||||
enum Kind { case connecting, waking, timedOut }
|
||||
let kind: Kind
|
||||
var gamepadUI = true
|
||||
|
||||
@StateObject private var store = ShotMock.hostStore()
|
||||
@StateObject private var model = SessionModel()
|
||||
@StateObject private var discovery = HostDiscovery()
|
||||
@StateObject private var waker = HostWaker()
|
||||
|
||||
var body: some View {
|
||||
GamepadHomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: .constant(nil), waker: waker,
|
||||
connect: { _ in }, connectDiscovered: { _ in }
|
||||
)
|
||||
.overlay { WakeOverlay(waker: waker) }
|
||||
.onAppear {
|
||||
waker.debugSet(.init(
|
||||
hostID: store.hosts.first?.id ?? UUID(),
|
||||
hostName: "Battlestation", connectsAfter: true, seconds: 14))
|
||||
backdrop
|
||||
.overlay {
|
||||
ConnectOverlay(
|
||||
connectingHostName: kind == .connecting ? "Battlestation" : nil,
|
||||
waker: waker,
|
||||
gamepadUI: gamepadUI,
|
||||
onCancelConnect: {})
|
||||
}
|
||||
.onAppear {
|
||||
switch kind {
|
||||
case .connecting:
|
||||
break
|
||||
case .waking:
|
||||
waker.debugSet(.init(
|
||||
hostID: store.hosts.first?.id ?? UUID(),
|
||||
hostName: "Battlestation", connectsAfter: true, seconds: 14))
|
||||
case .timedOut:
|
||||
waker.debugSet(.init(
|
||||
hostID: store.hosts.first?.id ?? UUID(),
|
||||
hostName: "Battlestation", connectsAfter: true, seconds: 90, timedOut: true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var backdrop: some View {
|
||||
if gamepadUI {
|
||||
GamepadHomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: .constant(nil), waker: waker,
|
||||
connect: { _ in }, connectDiscovered: { _ in })
|
||||
} else {
|
||||
ShotHome()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,17 @@ enum HUDPlacement: String, CaseIterable, Identifiable {
|
||||
/// The HUD's own stack hugs the screen edge it sits against, so its text aligns outward.
|
||||
var isTrailing: Bool { self == .topTrailing || self == .bottomTrailing }
|
||||
|
||||
/// The corner as a `UnitPoint`, so a scale transition grows/shrinks the HUD out of its own
|
||||
/// corner rather than its centre.
|
||||
var unitPoint: UnitPoint {
|
||||
switch self {
|
||||
case .topLeading: return .topLeading
|
||||
case .topTrailing: return .topTrailing
|
||||
case .bottomLeading: return .bottomLeading
|
||||
case .bottomTrailing: return .bottomTrailing
|
||||
}
|
||||
}
|
||||
|
||||
/// User-facing corner label.
|
||||
var label: String {
|
||||
switch self {
|
||||
|
||||
@@ -22,7 +22,7 @@ final class HostWaker: ObservableObject {
|
||||
var timedOut = false
|
||||
}
|
||||
|
||||
/// nil = idle; non-nil drives `WakeOverlay`.
|
||||
/// nil = idle; non-nil drives the "Waking…" phase of `ConnectOverlay`.
|
||||
@Published private(set) var waking: Waking?
|
||||
|
||||
/// How long to wait for the host to reappear before giving up. Generous — a cold boot + service
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// The resize overlay (design/midstream-resolution-resize.md — client resize UX). A Match-window
|
||||
// resize renegotiates the host's virtual display + encoder and re-inits the local VideoToolbox
|
||||
// decoder on the first new-mode IDR — an unavoidable sub-second gap where the last frame lingers,
|
||||
// briefly freezes, or the picture pops to the new geometry. Rather than let that read as a stutter,
|
||||
// we make it DELIBERATE: the caller blurs the live stream and this centered spinner + caption
|
||||
// acknowledges the transition. It clears the instant a frame at the requested size decodes (the
|
||||
// `onDecodedSize` END signal) or on the follower's safety timeout — see `SessionModel.resizing`.
|
||||
//
|
||||
// Floating overlay, never a hit-test target: input keeps flowing to the stream underneath so a
|
||||
// resize the user triggers by dragging the window never swallows their next click.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
struct ResizeIndicatorView: View {
|
||||
/// Mirrors `SessionModel.resizing`; the fade in/out is driven off this.
|
||||
let active: Bool
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if active {
|
||||
VStack(spacing: 12) {
|
||||
ProgressView().controlSize(.large).tint(.white)
|
||||
Text("Resizing…")
|
||||
.font(.geist(15, .medium, relativeTo: .callout))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
}
|
||||
.padding(.horizontal, 30)
|
||||
.padding(.vertical, 24)
|
||||
.glassBackground(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.12), lineWidth: 1))
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.92)))
|
||||
}
|
||||
}
|
||||
.environment(\.colorScheme, .dark) // the spinner + glass read over any frame
|
||||
.animation(.easeInOut(duration: 0.22), value: active)
|
||||
.allowsHitTesting(false) // the stream keeps receiving input the whole time
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// handshake phase, and the pump-thread → main-actor stats relay.
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
@@ -10,6 +11,15 @@ import SwiftUI
|
||||
#elseif canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
import AVFoundation // AVPlayer.eligibleForHDRPlayback — the TV-capability HDR gate
|
||||
#endif
|
||||
|
||||
/// 1 Hz latency-stage line mirrored to the unified log so the stages can be read WITHOUT the
|
||||
/// on-screen HUD (Console.app, wirelessly on an iPad/Apple TV). The HUD is not a neutral
|
||||
/// instrument: any visible overlay forces the metal layer through the compositor, which costs a
|
||||
/// refresh period on the vsync-latched platforms — this is how to measure with it off.
|
||||
private let statsLog = Logger(subsystem: "io.unom.punktfunk", category: "stats")
|
||||
|
||||
/// Pump-thread-side frame counters; a 1 Hz main-actor timer drains them into @Published
|
||||
/// values. NSLock instead of an actor — the writer is the (non-async) pump thread.
|
||||
@@ -99,6 +109,16 @@ final class SessionModel: ObservableObject {
|
||||
/// Mirrors StreamView's capture state (it owns the input capture; this drives the
|
||||
/// HUD's "click to capture" / "⌘⎋ releases" hint).
|
||||
@Published var mouseCaptured = false
|
||||
/// Resize overlay (design/midstream-resolution-resize.md — client resize UX): true from the
|
||||
/// instant a Match-window resize starts steering toward a new size until a frame at that size
|
||||
/// decodes (or a safety timeout). Drives the blur+spinner so the unavoidable host-rebuild delay
|
||||
/// reads as a deliberate, acknowledged transition instead of a stutter. Pure state lives in
|
||||
/// `ResizeIndicator`; this mirrors its `active` for SwiftUI.
|
||||
@Published private(set) var resizing = false
|
||||
/// START = follower steering (main actor), END = a new-mode IDR's coded dims (decode pump,
|
||||
/// hopped to main), TIMEOUT = safety net for a rejected/capped switch that never yields a
|
||||
/// differently-sized frame. Ticked from the 1 Hz stats timer.
|
||||
private var resizeIndicator = ResizeIndicator()
|
||||
|
||||
let meter = FrameMeter()
|
||||
/// Capture→received (the host+network stage), fed per AU at receipt by the stream view's
|
||||
@@ -119,6 +139,12 @@ final class SessionModel: ObservableObject {
|
||||
private var audio: SessionAudio?
|
||||
private var gamepadCapture: GamepadCapture?
|
||||
private var gamepadFeedback: GamepadFeedback?
|
||||
#if os(tvOS)
|
||||
/// Siri Remote → host pointer while streaming (touch surface moves, press = left click,
|
||||
/// Play/Pause = right click) + the remote's deliberate exit (hold Back ≥ 1 s). See
|
||||
/// SiriRemotePointer — same trust gate/lifecycle as the gamepad capture above.
|
||||
private var remotePointer: SiriRemotePointer?
|
||||
#endif
|
||||
|
||||
var isBusy: Bool { phase != .idle }
|
||||
|
||||
@@ -163,13 +189,25 @@ final class SessionModel: ObservableObject {
|
||||
let displayHDR: Bool = {
|
||||
#if os(macOS)
|
||||
return (NSScreen.main?.maximumExtendedDynamicRangeColorComponentValue ?? 1.0) > 1.0
|
||||
#elseif os(tvOS)
|
||||
// NOT the EDR headroom here: on tvOS that reflects the CURRENT output mode, and
|
||||
// Apple's recommended setup runs an SDR home screen with Match Content — an
|
||||
// HDR-capable TV would read 1.0 at connect time and never be advertised. The
|
||||
// session switches the display to HDR10 itself once streaming (AVDisplayManager —
|
||||
// see StreamViewIOS), so gate on the TV's mode-independent capability; if the
|
||||
// switch never lands, the presenter's in-shader tone-map keeps PQ safe anyway.
|
||||
return AVPlayer.eligibleForHDRPlayback
|
||||
#else
|
||||
return UIScreen.main.potentialEDRHeadroom > 1.0
|
||||
#endif
|
||||
}()
|
||||
let hdrCapable = hdrEnabled && displayHDR
|
||||
// 4:4:4 opt-out (default on); the hardware-decode probe below is the real gate.
|
||||
let want444 = (UserDefaults.standard.object(forKey: DefaultsKey.enable444) as? Bool) ?? true
|
||||
// 4:4:4 opt-IN (default off): full chroma is a per-client choice — a clear win for
|
||||
// desktop/text work, but at a fixed bitrate it spends bits on chroma that game content
|
||||
// doesn't visibly need, and the encode/decode pixel rate rises. The host allows it by
|
||||
// default (PUNKTFUNK_444, default on), so this toggle is the one real switch; the
|
||||
// hardware-decode probe below still gates what can actually be advertised.
|
||||
let want444 = (UserDefaults.standard.object(forKey: DefaultsKey.enable444) as? Bool) ?? false
|
||||
Task.detached(priority: .userInitiated) {
|
||||
// PunktfunkConnection.init blocks on the QUIC handshake — keep it off the main
|
||||
// actor. The persistent identity is presented on every connect so a paired
|
||||
@@ -300,6 +338,10 @@ final class SessionModel: ObservableObject {
|
||||
// connection is still up); the feedback drain joins off-main like audio.
|
||||
gamepadCapture?.stop()
|
||||
gamepadCapture = nil
|
||||
#if os(tvOS)
|
||||
remotePointer?.stop() // releases any held click while the connection is still up
|
||||
remotePointer = nil
|
||||
#endif
|
||||
let feedback = gamepadFeedback
|
||||
gamepadFeedback = nil
|
||||
if let conn = connection {
|
||||
@@ -332,6 +374,8 @@ final class SessionModel: ObservableObject {
|
||||
lostFrames = 0
|
||||
lostPct = 0
|
||||
mouseCaptured = false
|
||||
resizing = false
|
||||
resizeIndicator = ResizeIndicator() // no stale target/timer into the next session
|
||||
}
|
||||
|
||||
/// Called (via the main actor) when the pump hits end-of-session.
|
||||
@@ -342,6 +386,23 @@ final class SessionModel: ObservableObject {
|
||||
errorMessage = "Session ended by \(name)."
|
||||
}
|
||||
|
||||
/// Resize overlay START (main actor — from the Match-window follower's `onResizeTarget`): the
|
||||
/// window began differing from the live mode, so a `Reconfigure` toward `(width, height)` is
|
||||
/// imminent. Show the blur+spinner immediately, before the debounced request even leaves.
|
||||
func resizeTargeted(width: UInt32, height: UInt32) {
|
||||
resizeIndicator.steering(
|
||||
width: width, height: height, now: Date().timeIntervalSinceReferenceDate)
|
||||
resizing = resizeIndicator.active
|
||||
}
|
||||
|
||||
/// Resize overlay END (main actor — hopped from the decode pump's `onDecodedSize`): a new-mode
|
||||
/// IDR decoded at `(width, height)`. Clears the overlay only when that matches the size we're
|
||||
/// steering to (a same-size loss-recovery IDR, or the initial connect IDR, is a no-op).
|
||||
func resizeDecoded(width: Int, height: Int) {
|
||||
resizeIndicator.decoded(width: UInt32(max(width, 0)), height: UInt32(max(height, 0)))
|
||||
resizing = resizeIndicator.active
|
||||
}
|
||||
|
||||
private func beginStreaming() {
|
||||
guard let conn = connection else { return }
|
||||
// Input capture itself is owned by StreamView (engaged by the captureEnabled
|
||||
@@ -358,16 +419,26 @@ final class SessionModel: ObservableObject {
|
||||
micChannel: defaults.integer(forKey: DefaultsKey.micChannel),
|
||||
micEnabled: defaults.object(forKey: DefaultsKey.micEnabled) as? Bool ?? true)
|
||||
self.audio = audio
|
||||
// Gamepads: forward GamepadManager's active controller as pad 0 and render the
|
||||
// host's feedback (rumble always; lightbar/player-LEDs/adaptive-triggers when the
|
||||
// session's virtual pad is a DualSense). Same trust gate as audio — nothing is
|
||||
// Gamepads: forward every controller GamepadManager selected — each on its own wire pad
|
||||
// index (a pin forwards only one, Automatic forwards all) — and render the host's feedback
|
||||
// back to the pad it's addressed to (rumble always; lightbar/player-LEDs/adaptive-triggers
|
||||
// when a pad's virtual device is a DualSense). Same trust gate as audio — nothing is
|
||||
// forwarded during the trust prompt.
|
||||
let capture = GamepadCapture(connection: conn, manager: .shared)
|
||||
// The cross-client escape chord (hold L1+R1+Start+Select 1.5 s) — on tvOS the only
|
||||
// controller way out of a stream (B/Menu is swallowed during sessions; see ContentView).
|
||||
capture.onDisconnectRequest = { [weak self] in self?.disconnect() }
|
||||
capture.start()
|
||||
gamepadCapture = capture
|
||||
let feedback = GamepadFeedback(connection: conn, manager: .shared)
|
||||
feedback.start()
|
||||
gamepadFeedback = feedback
|
||||
#if os(tvOS)
|
||||
let pointer = SiriRemotePointer(connection: conn)
|
||||
pointer.onDisconnectRequest = { [weak self] in self?.disconnect() }
|
||||
pointer.start()
|
||||
remotePointer = pointer
|
||||
#endif
|
||||
}
|
||||
|
||||
private func startStatsTimer() {
|
||||
@@ -376,6 +447,11 @@ final class SessionModel: ObservableObject {
|
||||
let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
// Resize-overlay safety net: clear a stuck overlay when a targeted size never
|
||||
// decodes (a rejected/capped switch). The decoded-frame END clears it promptly on
|
||||
// success; this only fires after the timeout.
|
||||
self.resizeIndicator.tick(now: Date().timeIntervalSinceReferenceDate)
|
||||
self.resizing = self.resizeIndicator.active
|
||||
let (frames, bytes, total) = self.meter.drain()
|
||||
self.fps = frames
|
||||
self.mbps = Double(bytes) * 8 / 1_000_000
|
||||
@@ -429,12 +505,32 @@ final class SessionModel: ObservableObject {
|
||||
} else {
|
||||
self.decodeValid = false
|
||||
}
|
||||
if let d = self.displayStage.drain() {
|
||||
let displayWindow = self.displayStage.drain()
|
||||
if let d = displayWindow {
|
||||
self.displayP50Ms = d.p50Ms
|
||||
self.displayValid = true
|
||||
} else {
|
||||
self.displayValid = false
|
||||
}
|
||||
// Mirror the window to the unified log (see statsLog) — one line per second,
|
||||
// stages in ms, only while frames actually flowed. `fps` counts RECEIVED AUs;
|
||||
// `presents` counts frames that reached glass (the display meter's sample count)
|
||||
// — a presents≪fps gap is the presenter dropping/serializing, an fps deficit is
|
||||
// upstream (host capture/encode or the network).
|
||||
if frames > 0 {
|
||||
let line = String(
|
||||
format: "fps=%d presents=%d e2e_p50=%.1f e2e_p95=%.1f hostnet_p50=%.1f "
|
||||
+ "decode_p50=%.1f display_p50=%.1f lost=%d",
|
||||
frames,
|
||||
displayWindow?.count ?? 0,
|
||||
self.endToEndValid ? self.endToEndP50Ms : -1,
|
||||
self.endToEndValid ? self.endToEndP95Ms : -1,
|
||||
self.hostNetworkValid ? self.hostNetworkP50Ms : -1,
|
||||
self.decodeValid ? self.decodeP50Ms : -1,
|
||||
self.displayValid ? self.displayP50Ms : -1,
|
||||
lost)
|
||||
statsLog.info("\(line, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
// .common so the HUD keeps updating during window drags / menu tracking.
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
// (the Linux client has its GTK Shortcuts window, Windows its start-of-stream banner). While
|
||||
// input is CAPTURED these key equivalents never reach the menu (the stream view swallows
|
||||
// keys); InputCapture's monitor detects the same combos there and performs the same actions —
|
||||
// the menu covers the released state and discoverability. The stats toggle just flips the
|
||||
// shared `hudEnabled` setting; ContentView reads the same @AppStorage and reacts.
|
||||
// the menu covers the released state and discoverability. The stats item cycles the shared
|
||||
// `statsVerbosity` tier (off → compact → normal → detailed → off); ContentView reads the same
|
||||
// @AppStorage and reacts.
|
||||
//
|
||||
// tvOS has no menu bar / hardware-keyboard command surface (disconnect there is the Siri
|
||||
// Remote's Menu button, handled by ContentView's `.onExitCommand`), so this whole file is
|
||||
@@ -36,12 +37,16 @@ extension FocusedValues {
|
||||
|
||||
struct StreamCommands: Commands {
|
||||
@FocusedValue(\.sessionFocus) private var session
|
||||
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
||||
// The raw string so @AppStorage observes the shared key; the absent-key default runs the
|
||||
// legacy-hudEnabled migration (same pattern as ContentView/SettingsView).
|
||||
@AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw
|
||||
= StatsVerbosity.current.rawValue
|
||||
|
||||
var body: some Commands {
|
||||
CommandMenu("Stream") {
|
||||
Button(hudEnabled ? "Hide Statistics" : "Show Statistics") {
|
||||
hudEnabled.toggle()
|
||||
Button("Cycle Statistics") {
|
||||
let current = StatsVerbosity(rawValue: statsVerbosityRaw) ?? .normal
|
||||
statsVerbosityRaw = current.next().rawValue
|
||||
}
|
||||
.keyboardShortcut("s", modifiers: [.control, .option, .shift])
|
||||
// Reaches the key window's stream view via NotificationCenter — capture is view
|
||||
|
||||
@@ -1,17 +1,81 @@
|
||||
// The streaming overlay HUD: mode + fps/throughput, the unified latency lines
|
||||
// (design/stats-unification.md — end-to-end headline + the stage equation under stage-2, the
|
||||
// capture→received headline under the stage-1 fallback), the loss counter, the platform input
|
||||
// hint, and disconnect.
|
||||
// The streaming overlay HUD, tiered by StatsVerbosity (the Android client's 3-tier semantics):
|
||||
// * compact — one glass-pill line: fps · end-to-end p50 · throughput (+ loss when lossy);
|
||||
// * normal — mode + fps/throughput, the unified latency HEADLINE (design/stats-unification.md
|
||||
// — end-to-end under stage-2, capture→received under the stage-1 fallback), the loss
|
||||
// counter, a capture hint (shown until input is captured), and disconnect;
|
||||
// * detailed — everything normal has plus the stage equation line(s) under the headline.
|
||||
// `.off` never reaches this view (ContentView gates the overlay on the tier).
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
struct StreamHUDView: View {
|
||||
@ObservedObject var model: SessionModel
|
||||
let connection: PunktfunkConnection
|
||||
var placement: HUDPlacement = .topTrailing
|
||||
let verbosity: StatsVerbosity
|
||||
|
||||
var body: some View {
|
||||
// .off is gated upstream (ContentView only mounts the HUD when the tier is on) —
|
||||
// render nothing if it ever slips through.
|
||||
if verbosity != .off {
|
||||
// ONE shared glass card wraps the tier-dependent content, so a verbosity change MORPHS
|
||||
// this card — its frame (and, on iOS, its clamped corner) animate to the new size — rather
|
||||
// than cross-fading a whole new card in. Only the inner content switches per tier.
|
||||
tierContent
|
||||
.padding(cardPadding)
|
||||
.glassBackground(cardShape)
|
||||
.padding(edgeInset)
|
||||
}
|
||||
}
|
||||
|
||||
/// The tier-dependent content, unwrapped (the shared card in `body` supplies the padding +
|
||||
/// glass background). Compact is a one-line pill; normal/detailed the full stack.
|
||||
@ViewBuilder private var tierContent: some View {
|
||||
if verbosity == .compact {
|
||||
compactContent
|
||||
} else {
|
||||
fullContent
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Compact tier
|
||||
|
||||
/// One line: `{fps} fps · {e2e p50} ms · {mbps} Mb/s`. The ms segment is the best available
|
||||
/// latency headline (stage-2 end-to-end, else the stage-1 capture→received) and is omitted until
|
||||
/// either is valid. Loss appends in the same quiet styling the full HUD's lost line uses.
|
||||
private var compactContent: some View {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(Color.accentColor)
|
||||
.frame(width: 7, height: 7)
|
||||
Text(compactLine)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
if model.lostFrames > 0 {
|
||||
Text("· lost \(model.lostFrames)")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var compactLine: String {
|
||||
var parts = ["\(model.fps) fps"]
|
||||
if model.endToEndValid {
|
||||
parts.append(String(format: "%.1f ms", model.endToEndP50Ms))
|
||||
} else if model.hostNetworkValid {
|
||||
parts.append(String(format: "%.1f ms", model.hostNetworkP50Ms))
|
||||
}
|
||||
parts.append(String(format: "%.1f Mb/s", model.mbps))
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
// MARK: - Normal / detailed tiers
|
||||
|
||||
private var fullContent: some View {
|
||||
VStack(alignment: placement.isTrailing ? .trailing : .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
@@ -26,11 +90,11 @@ struct StreamHUDView: View {
|
||||
Text("end-to-end \(model.endToEndP50Ms, specifier: "%.1f") ms p50 · \(model.endToEndP95Ms, specifier: "%.1f") p95 · capture→on-glass\(model.endToEndSkewCorrected ? "" : " (same-host clock)")")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
// The equation: the stages tiling the headline interval (per-window p50s —
|
||||
// they only approximately sum to the directly-measured total). With a host
|
||||
// that reports per-AU timings (0xCF) the first term splits into host + network
|
||||
// (phase 2); an old host keeps the combined term.
|
||||
if model.hostNetworkValid && model.decodeValid && model.displayValid {
|
||||
// The equation (detailed tier only): the stages tiling the headline interval
|
||||
// (per-window p50s — they only approximately sum to the directly-measured
|
||||
// total). With a host that reports per-AU timings (0xCF) the first term splits
|
||||
// into host + network (phase 2); an old host keeps the combined term.
|
||||
if verbosity == .detailed && model.hostNetworkValid && model.decodeValid && model.displayValid {
|
||||
if model.splitValid {
|
||||
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayP50Ms, specifier: "%.1f")")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
@@ -45,11 +109,12 @@ struct StreamHUDView: View {
|
||||
// Stage-1 fallback presenter: the layer decodes + presents internally with no
|
||||
// per-frame stamp, so the honest headline ends at receipt. The host/network
|
||||
// split still applies there (receipt is presenter-independent) — it becomes the
|
||||
// only equation line; without it, host+network IS the whole measured interval.
|
||||
// only equation line (detailed tier); without it, host+network IS the whole
|
||||
// measured interval.
|
||||
Text("capture→received \(model.hostNetworkP50Ms, specifier: "%.1f") ms p50 · \(model.hostNetworkP95Ms, specifier: "%.1f") p95\(model.hostNetworkSkewCorrected ? "" : " (same-host clock)")")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
if model.splitValid {
|
||||
if verbosity == .detailed && model.splitValid {
|
||||
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f")")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -63,47 +128,104 @@ struct StreamHUDView: View {
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
// While captured the cursor is hidden+frozen, so the button is keyboard-only
|
||||
// (⌃⌥⇧Q — the cross-client Ctrl+Alt+Shift+Q — or ⌘⎋/Cmd+Tab release the cursor;
|
||||
// released, it's clickable again).
|
||||
// Capture hint, shown only until input is captured — how to grab it. The RELEASE
|
||||
// shortcut is intentionally not surfaced in the overlay (it lives on the Stream menu
|
||||
// and, on macOS, the start-of-stream banner), keeping the HUD uncluttered while playing.
|
||||
#if os(macOS)
|
||||
Text(model.mouseCaptured
|
||||
? "⌃⌥⇧Q releases the mouse"
|
||||
: "Click the stream to capture input")
|
||||
.font(.geist(11, relativeTo: .caption2))
|
||||
.foregroundStyle(.secondary)
|
||||
if !model.mouseCaptured {
|
||||
Text("Click the stream to capture input")
|
||||
.font(.geist(11, relativeTo: .caption2))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
#elseif os(iOS)
|
||||
// Touch always plays directly; ⌘⎋ (hardware keyboard) toggles kb/mouse.
|
||||
Text(model.mouseCaptured
|
||||
? "⌘⎋ releases keyboard & mouse"
|
||||
: "⌘⎋ captures keyboard & mouse")
|
||||
.font(.geist(11, relativeTo: .caption2))
|
||||
.foregroundStyle(.secondary)
|
||||
// Touch always plays directly; ⌘⎋ (hardware keyboard) captures kb/mouse.
|
||||
if !model.mouseCaptured {
|
||||
Text("⌘⎋ captures keyboard & mouse")
|
||||
.font(.geist(11, relativeTo: .caption2))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
// No focusable control during play: a focusable button steals the controller's
|
||||
// A press (the focus engine consumes it before the host sees it). Disconnect is
|
||||
// the Siri Remote's Menu button (.onExitCommand on the stream) — just hint it.
|
||||
Text("Press Menu to disconnect")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
#else
|
||||
// ⌃⌥⇧D lives on the app's Stream menu (so it still works when the HUD is hidden)
|
||||
// and in InputCapture's monitor while captured; this button is the in-overlay,
|
||||
// click-to-disconnect affordance.
|
||||
// click-to-disconnect affordance. tvOS deliberately gets NEITHER a button (a
|
||||
// focusable control would steal the controller's A press from the host) NOR a hint
|
||||
// line: the exits are the hold gestures the start-of-stream banner teaches (hold
|
||||
// the remote's Back; hold L1+R1+Start+Select on a pad).
|
||||
#if os(macOS)
|
||||
Button("Disconnect (⌃⌥⇧D)") { model.disconnect() }
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
#else
|
||||
#elseif os(iOS)
|
||||
Button("Disconnect") { model.disconnect() }
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
.padding(10)
|
||||
// Floating HUD over live video — the canonical Liquid-Glass overlay surface (26+);
|
||||
// falls back to .regularMaterial below 26 (see GlassStyle).
|
||||
.glassBackground(RoundedRectangle(cornerRadius: 10))
|
||||
.padding(10)
|
||||
}
|
||||
|
||||
// MARK: - Card metrics
|
||||
|
||||
/// The card's inner content padding. Roomier on tvOS — the stat text auto-scales for the
|
||||
/// couch (relative system styles), so the card's chrome must keep pace or it reads cramped.
|
||||
private var cardPadding: CGFloat {
|
||||
#if os(tvOS)
|
||||
return 16
|
||||
#else
|
||||
return 10
|
||||
#endif
|
||||
}
|
||||
|
||||
/// The OUTER gap between the card and the screen edge. On iOS the card hugs a physically
|
||||
/// rounded display corner, so it sits a little further in and pairs with a concentric corner
|
||||
/// radius (below); tvOS floats it well clear of the TV's overscan-ish edge; macOS windows
|
||||
/// keep the classic 10.
|
||||
private var edgeInset: CGFloat {
|
||||
#if os(iOS)
|
||||
return 14
|
||||
#elseif os(tvOS)
|
||||
return 24
|
||||
#else
|
||||
return 10
|
||||
#endif
|
||||
}
|
||||
|
||||
/// The card's corner radius. On iOS it's concentric with the physical display corner —
|
||||
/// `displayCornerRadius − edgeInset`, so the gap to the screen edge stays uniform right around the
|
||||
/// corner instead of a small-radius card cutting into the very rounded glass. Clamped so a
|
||||
/// flat-cornered device (or a hidden radius) still gets a sensibly rounded card.
|
||||
private var cardCornerRadius: CGFloat {
|
||||
#if os(iOS)
|
||||
return max(12, DeviceMetrics.displayCornerRadius - edgeInset)
|
||||
#elseif os(tvOS)
|
||||
return 16 // scales with the roomier padding
|
||||
#else
|
||||
return 10
|
||||
#endif
|
||||
}
|
||||
|
||||
/// The card background shape — a continuous (squircle) rounded rectangle, matching the curve
|
||||
/// Apple's hardware display corners use so the concentric inset actually reads as parallel.
|
||||
private var cardShape: RoundedRectangle {
|
||||
RoundedRectangle(cornerRadius: cardCornerRadius, style: .continuous)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// Device display geometry the overlay needs but UIKit doesn't expose publicly.
|
||||
enum DeviceMetrics {
|
||||
/// The physical display's corner radius. There's no public API for it, so read the private
|
||||
/// `_displayCornerRadius` via KVC on the active window scene's screen, guarded by a fallback that
|
||||
/// approximates a modern rounded device — a future OS that hides the key just yields a slightly
|
||||
/// less-perfect inset, never a crash. The key is assembled from parts so it isn't a plain literal
|
||||
/// in the binary; note the App Store private-API consideration regardless.
|
||||
static var displayCornerRadius: CGFloat {
|
||||
let key = ["_display", "Corner", "Radius"].joined()
|
||||
guard
|
||||
let screen = UIApplication.shared.connectedScenes
|
||||
.compactMap({ $0 as? UIWindowScene })
|
||||
.first?.screen,
|
||||
let radius = screen.value(forKey: key) as? NSNumber,
|
||||
radius.doubleValue > 0
|
||||
else { return 44 }
|
||||
return CGFloat(radius.doubleValue)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
/// Open-source acknowledgements: punktfunk's own license (MIT OR Apache-2.0) followed by the
|
||||
/// Open-source acknowledgements: Punktfunk's own license (MIT OR Apache-2.0) followed by the
|
||||
/// third-party software notices. Used as a pushed view on iOS/tvOS and a preferences tab on macOS.
|
||||
struct AcknowledgementsView: View {
|
||||
private var version: String? {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||
}
|
||||
|
||||
// TV-legible sizes for the explicitly-sized text; the in-hand sizes elsewhere. (The license
|
||||
// walls use relative system styles, which already scale per platform.)
|
||||
#if os(tvOS)
|
||||
private static let titleFont: CGFloat = 36
|
||||
private static let headlineFont: CGFloat = 26
|
||||
private static let captionFont: CGFloat = 20
|
||||
#else
|
||||
private static let titleFont: CGFloat = 22
|
||||
private static let headlineFont: CGFloat = 17
|
||||
private static let captionFont: CGFloat = 12
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
// Top-level LazyVStack so the third-party-notices chunks (Licenses.thirdPartyNoticesChunks,
|
||||
@@ -16,42 +28,40 @@ struct AcknowledgementsView: View {
|
||||
// notice chunks visually continuous; the header block carries its own spacing + bottom pad.
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Text("punktfunk")
|
||||
.font(.geist(22, .bold, relativeTo: .title2))
|
||||
Text("Punktfunk")
|
||||
.font(.geist(Self.titleFont, .bold, relativeTo: .title2))
|
||||
if let version {
|
||||
Text("Version \(version)")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.font(.geist(Self.captionFont, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(Licenses.appLicense)
|
||||
LicenseWall(text: Licenses.appLicense)
|
||||
.font(.caption.monospaced())
|
||||
.modifier(SelectableText())
|
||||
|
||||
Divider()
|
||||
|
||||
Text("Bundled font")
|
||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||
Text("punktfunk ships the Geist typeface (Geist Sans), "
|
||||
.font(.geist(Self.headlineFont, .semibold, relativeTo: .headline))
|
||||
Text("Punktfunk ships the Geist typeface (Geist Sans), "
|
||||
+ "© The Geist Project Authors / Vercel, used under the SIL Open Font "
|
||||
+ "License 1.1.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.font(.geist(Self.captionFont, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
if !Licenses.fontLicense.isEmpty {
|
||||
Text(Licenses.fontLicense)
|
||||
LicenseWall(text: Licenses.fontLicense)
|
||||
.font(.caption2.monospaced())
|
||||
.modifier(SelectableText())
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Text("Third-party software")
|
||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||
.font(.geist(Self.headlineFont, .semibold, relativeTo: .headline))
|
||||
Text(
|
||||
"punktfunk uses the open-source components below, each under its own license. "
|
||||
"Punktfunk uses the open-source components below, each under its own license. "
|
||||
+ "On some platforms FFmpeg is additionally bundled under the LGPL v2.1+ "
|
||||
+ "(dynamically linked, replaceable)."
|
||||
)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.font(.geist(Self.captionFont, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -62,6 +72,7 @@ struct AcknowledgementsView: View {
|
||||
.font(.caption2.monospaced())
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.modifier(SelectableText())
|
||||
.modifier(TVFocusable())
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 900, alignment: .leading)
|
||||
@@ -85,3 +96,40 @@ private struct SelectableText: ViewModifier {
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// Focus IS scrolling on tvOS: with nothing focusable in this pushed screen the license wall
|
||||
/// couldn't move at all, and a Menu press had nothing inside the NavigationStack to route
|
||||
/// through — it suspended the whole app instead of popping. Plain (non-interactive) focusability
|
||||
/// on every license/notice chunk fixes both; a chunk is sized to about two thirds of a screen
|
||||
/// (see Licenses.chunked), so each focus step reads as a page turn. The chunks must be SMALL
|
||||
/// focus stops all the way down — one tall focusable block would strand focus at its top and the
|
||||
/// next stop could sit past the LazyVStack's instantiation window.
|
||||
private struct TVFocusable: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
#if os(tvOS)
|
||||
content.focusable()
|
||||
#else
|
||||
content
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// One license wall: a single selectable Text on touch/desktop; on tvOS, focus-page-sized
|
||||
/// chunks (see TVFocusable). The caller's `.font` cascades into either form.
|
||||
private struct LicenseWall: View {
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
#if os(tvOS)
|
||||
let chunks = Licenses.chunked(text)
|
||||
ForEach(chunks.indices, id: \.self) { i in
|
||||
Text(chunks[i])
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.modifier(TVFocusable())
|
||||
}
|
||||
#else
|
||||
Text(text)
|
||||
.modifier(SelectableText())
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// The gamepad-driven settings screen (iOS/iPadOS/macOS): the couch-relevant subset of SettingsView,
|
||||
// The gamepad-driven settings screen (iOS/iPadOS/macOS/tvOS): the couch-relevant subset of SettingsView,
|
||||
// restyled as a console settings 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. Shown from the
|
||||
// gamepad home launcher (X); the touch SettingsView remains the full-fidelity editor (custom
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS)
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
import GameController
|
||||
|
||||
struct GamepadSettingsView: View {
|
||||
@@ -26,13 +26,18 @@ struct GamepadSettingsView: View {
|
||||
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
||||
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
|
||||
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
|
||||
@AppStorage(DefaultsKey.enable444) private var enable444 = true
|
||||
@AppStorage(DefaultsKey.enable444) private var enable444 = false
|
||||
@AppStorage(DefaultsKey.codec) private var codec = "auto"
|
||||
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
|
||||
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
||||
// The overlay tier's raw string (rows tag by rawValue); the absent-key default runs the
|
||||
// legacy-hudEnabled migration (same pattern as ContentView/SettingsView).
|
||||
@AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw
|
||||
= StatsVerbosity.current.rawValue
|
||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
||||
@AppStorage(DefaultsKey.presenter) private var presenter = SettingsOptions.presenterDefault
|
||||
@ObservedObject private var gamepads = GamepadManager.shared
|
||||
|
||||
#if os(iOS)
|
||||
@@ -44,6 +49,9 @@ struct GamepadSettingsView: View {
|
||||
private let compact = false // no size classes on macOS; the sheet is sized generously
|
||||
#endif
|
||||
@State private var focusID: String?
|
||||
/// The direction of the last value step (+1 right/forward, -1 left) — picks which edge the
|
||||
/// changed value slides in from, so the animation follows the user's motion.
|
||||
@State private var lastAdjustDelta = 1
|
||||
|
||||
var body: some View {
|
||||
GamepadMenuList(
|
||||
@@ -54,13 +62,13 @@ struct GamepadSettingsView: View {
|
||||
onBack: { dismiss() }
|
||||
) { row, focused in
|
||||
rowView(row, focused: focused)
|
||||
.frame(maxWidth: 620)
|
||||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
Text("Settings")
|
||||
.font(.geist(compact ? 20 : 30, .bold, relativeTo: .title))
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, compact ? 4 : 8)
|
||||
@@ -71,7 +79,7 @@ struct GamepadSettingsView: View {
|
||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(focusedDetail)
|
||||
.font(.geist(13, relativeTo: .caption))
|
||||
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
.lineLimit(2, reservesSpace: true)
|
||||
.animation(.smooth(duration: 0.2), value: focusID)
|
||||
@@ -104,61 +112,78 @@ struct GamepadSettingsView: View {
|
||||
private var closeButton: some View {
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 34, height: 34)
|
||||
.frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide)
|
||||
.glassBackground(Circle(), interactive: true)
|
||||
.contentShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.cancelAction) // unavailable on tvOS (Menu is the cancel there)
|
||||
#endif
|
||||
.accessibilityLabel("Close settings")
|
||||
}
|
||||
|
||||
// MARK: - Row rendering
|
||||
|
||||
private func rowView(_ row: Row, focused: Bool) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
let m = GamepadFormMetrics.self
|
||||
return VStack(alignment: .leading, spacing: 6) {
|
||||
if let header = row.header {
|
||||
Text(header)
|
||||
.font(.geist(12, .semibold, relativeTo: .caption))
|
||||
.font(.geist(m.headerFont, .semibold, relativeTo: .caption))
|
||||
.tracking(1.4)
|
||||
.foregroundStyle(.white.opacity(0.45))
|
||||
.padding(.leading, 16)
|
||||
.padding(.leading, m.rowHPad)
|
||||
.padding(.top, 14)
|
||||
}
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: row.icon)
|
||||
.font(.system(size: 17))
|
||||
.font(.system(size: m.iconFont))
|
||||
.foregroundStyle(focused ? Color.brand : .white.opacity(0.55))
|
||||
.frame(width: 28)
|
||||
.frame(width: m.iconWidth)
|
||||
Text(row.label)
|
||||
.font(.geist(16, .semibold, relativeTo: .body))
|
||||
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 12)
|
||||
HStack(spacing: 9) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(.system(size: m.chevronFont, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(focused ? 0.6 : 0))
|
||||
Text(row.value)
|
||||
.font(.geist(15, .medium, relativeTo: .callout))
|
||||
.foregroundStyle(focused ? .white : .white.opacity(0.6))
|
||||
.lineLimit(1)
|
||||
// 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.
|
||||
// Deliberately not `.push`: that travels the whole container width, loud
|
||||
// and visibly outside the row. The ZStack is the stable home the
|
||||
// removed/inserted texts transition within.
|
||||
let slide: CGFloat = lastAdjustDelta >= 0 ? 14 : -14
|
||||
ZStack {
|
||||
Text(row.value)
|
||||
.font(.geist(m.valueFont, .medium, relativeTo: .callout))
|
||||
.foregroundStyle(focused ? .white : .white.opacity(0.6))
|
||||
.lineLimit(1)
|
||||
.id(row.value)
|
||||
.transition(.asymmetric(
|
||||
insertion: .offset(x: slide).combined(with: .opacity),
|
||||
removal: .offset(x: -slide).combined(with: .opacity)))
|
||||
}
|
||||
.animation(.smooth(duration: 0.22), value: row.value)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(.system(size: m.chevronFont, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(focused ? 0.6 : 0))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 13)
|
||||
.padding(.horizontal, m.rowHPad)
|
||||
.padding(.vertical, m.rowVPad)
|
||||
// Every row is Liquid Glass; the focused one takes a brand wash and reacts to press.
|
||||
.consoleGlass(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous),
|
||||
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
|
||||
tint: focused ? Color.brand.opacity(0.30) : nil,
|
||||
interactive: focused)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
|
||||
.strokeBorder(.white.opacity(focused ? 0.28 : 0.06), lineWidth: 1)
|
||||
}
|
||||
.scaleEffect(focused ? 1.0 : 0.98)
|
||||
@@ -190,10 +215,12 @@ struct GamepadSettingsView: View {
|
||||
/// Dispatch by id so the focus list's stored input callbacks always act on freshly built rows
|
||||
/// (never on state captured at wire time).
|
||||
private func adjust(id: String, by delta: Int) -> Bool {
|
||||
rows.first { $0.id == id }?.adjust(delta) ?? false
|
||||
lastAdjustDelta = delta
|
||||
return rows.first { $0.id == id }?.adjust(delta) ?? false
|
||||
}
|
||||
|
||||
private func activate(id: String) {
|
||||
lastAdjustDelta = 1 // A always cycles forward
|
||||
rows.first { $0.id == id }?.activate()
|
||||
}
|
||||
|
||||
@@ -232,6 +259,11 @@ struct GamepadSettingsView: View {
|
||||
+ "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",
|
||||
@@ -249,6 +281,12 @@ struct GamepadSettingsView: View {
|
||||
detail: "Sharper text and UI at more bandwidth — needs host opt-in and "
|
||||
+ "hardware decode.",
|
||||
value: $enable444),
|
||||
choiceRow(
|
||||
id: "presenter", icon: "rectangle.stack", label: "Presenter",
|
||||
detail: "Stage 3 paces presents to the display — lowest display latency. "
|
||||
+ "Stage 2 shows each frame on arrival. Applies from the next session.",
|
||||
options: SettingsOptions.presenters, current: presenter
|
||||
) { presenter = $0 },
|
||||
|
||||
choiceRow(
|
||||
id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels",
|
||||
@@ -271,10 +309,12 @@ struct GamepadSettingsView: View {
|
||||
options: SettingsOptions.padTypes, current: gamepadType
|
||||
) { gamepadType = $0 },
|
||||
|
||||
toggleRow(
|
||||
choiceRow(
|
||||
id: "hud", header: "Interface", icon: "chart.bar", label: "Statistics overlay",
|
||||
detail: "Resolution, frame rate, throughput and latency while streaming.",
|
||||
value: $hudEnabled),
|
||||
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",
|
||||
detail: "Which corner the statistics overlay sits in.",
|
||||
@@ -282,8 +322,7 @@ struct GamepadSettingsView: View {
|
||||
) { hudPlacement = $0 },
|
||||
toggleRow(
|
||||
id: "library", icon: "square.grid.2x2", label: "Game library",
|
||||
detail: "Browse and launch the host's games with \(buttonName(\.buttonY, "Y")) "
|
||||
+ "(experimental).",
|
||||
detail: "Browse and launch the host's games with \(buttonName(\.buttonY, "Y")).",
|
||||
value: $libraryEnabled),
|
||||
toggleRow(
|
||||
id: "gamepadUI", icon: "hand.tap", label: "Controller-optimized UI",
|
||||
|
||||
@@ -37,6 +37,34 @@ enum SettingsOptions {
|
||||
static let hudPlacements: [(label: String, tag: String)] =
|
||||
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
||||
|
||||
/// Stage-2 vs stage-3 present pacing (`DefaultsKey.presenter` — see SessionPresenter's
|
||||
/// PresenterChoice); the freeze-prone stage-1 diagnostic only ships in DEBUG builds.
|
||||
static var presenters: [(label: String, tag: String)] {
|
||||
var options: [(label: String, tag: String)] = [
|
||||
("Stage 2", "stage2"),
|
||||
("Stage 3", "stage3"),
|
||||
]
|
||||
#if DEBUG
|
||||
options.append(("Stage 1 (debug)", "stage1"))
|
||||
#endif
|
||||
return options
|
||||
}
|
||||
|
||||
/// The platform's presenter default (mirrors SessionPresenter's platformDefault — tvOS runs
|
||||
/// glass pacing, everything else arrival). Views seed their @AppStorage display from this so
|
||||
/// an untouched picker shows what actually runs.
|
||||
static var presenterDefault: String {
|
||||
#if os(tvOS)
|
||||
"stage3"
|
||||
#else
|
||||
"stage2"
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Stats-overlay tiers (`DefaultsKey.statsVerbosity`) — the `tag` is the raw value.
|
||||
static let statsVerbosities: [(label: String, tag: String)] =
|
||||
StatsVerbosity.allCases.map { ($0.label, $0.rawValue) }
|
||||
|
||||
/// Video-codec preference (`DefaultsKey.codec`) — a soft preference the host falls back from.
|
||||
/// AV1 appears only on devices with an AV1 hardware decoder (the same
|
||||
/// `AV1.hardwareDecodeSupported` gate SessionModel advertises by) — elsewhere it would be a
|
||||
@@ -101,8 +129,8 @@ enum SettingsOptions {
|
||||
return options
|
||||
}
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
// MARK: - Stream mode (iOS + macOS pickers; tvOS builds its own preset list)
|
||||
// MARK: - Stream mode (iOS/macOS pickers + the gamepad settings rows on all three; the
|
||||
// touch/remote tvOS SettingsView builds its own preset list)
|
||||
|
||||
/// 16:9 then ultrawide presets; the device's native mode is prepended by `resolutionModes`.
|
||||
static let resolutionPresets: [(name: String, w: Int, h: Int)] = [
|
||||
@@ -120,8 +148,8 @@ enum SettingsOptions {
|
||||
@MainActor
|
||||
static func resolutionModes() -> [(name: String, w: Int, h: Int)] {
|
||||
var native: [(name: String, w: Int, h: Int)] = []
|
||||
#if os(iOS)
|
||||
let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels
|
||||
#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)))]
|
||||
@@ -141,7 +169,7 @@ enum SettingsOptions {
|
||||
/// the screen can't show), plus any stored custom value so it stays selectable.
|
||||
@MainActor
|
||||
static func refreshRates(including current: Int) -> [Int] {
|
||||
#if os(iOS)
|
||||
#if os(iOS) || os(tvOS)
|
||||
let maxHz = UIScreen.main.maximumFramesPerSecond
|
||||
#else
|
||||
let maxHz = NSScreen.main?.maximumFramesPerSecond ?? 60
|
||||
@@ -151,5 +179,4 @@ enum SettingsOptions {
|
||||
if !rates.contains(current) { rates.append(current) }
|
||||
return rates.sorted()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ extension SettingsView {
|
||||
// failed exactly one slice: the iOS archive (macOS/tvOS never compile that branch).
|
||||
@ViewBuilder var streamModeSection: some View {
|
||||
Section {
|
||||
#if os(iOS) || os(macOS)
|
||||
// Match-window (design/midstream-resolution-resize.md D1): follow the session
|
||||
// window/scene, renegotiating the host mode on a resize. Off → the explicit mode below.
|
||||
Toggle("Match window", isOn: $matchWindow)
|
||||
#endif
|
||||
#if os(iOS)
|
||||
iosResolutionWheel
|
||||
iosRefreshRows
|
||||
@@ -35,8 +40,12 @@ extension SettingsView {
|
||||
} header: {
|
||||
Text("Stream mode")
|
||||
} footer: {
|
||||
Text("The host creates a virtual output at exactly this mode — "
|
||||
+ "native resolution, no scaling. \(Self.bitrateFooter)")
|
||||
Text(matchWindow
|
||||
? "The stream follows this window — the host resizes its virtual output to match "
|
||||
+ "as you resize, so the picture stays pixel-exact (1:1) with no scaling. "
|
||||
+ "\(Self.bitrateFooter)"
|
||||
: "The host creates a virtual output at exactly this mode — native resolution, but "
|
||||
+ "a window that isn't this size is scaled to fit. \(Self.bitrateFooter)")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -286,6 +295,24 @@ extension SettingsView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-wake on connect — fire Wake-on-LAN + wait for a sleeping saved host to come back before
|
||||
/// giving up. Now available on every platform (the iOS/tvOS multicast entitlement is granted).
|
||||
@ViewBuilder var wakeSection: some View {
|
||||
Section {
|
||||
Toggle("Auto-wake on connect", isOn: $autoWakeEnabled)
|
||||
} header: {
|
||||
Text("Wake-on-LAN")
|
||||
} footer: {
|
||||
Text("Connecting to a saved host that isn't on the network yet sends a Wake-on-LAN "
|
||||
+ "packet and waits for it to come back before streaming. Turn off if a host that's "
|
||||
+ "already on just isn't visible here (e.g. over a VPN), so connects go straight "
|
||||
+ "through instead of waiting out the wake. A host's “Wake” action still works either "
|
||||
+ "way.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder var windowSection: some View {
|
||||
#if os(macOS)
|
||||
Section {
|
||||
@@ -337,28 +364,32 @@ extension SettingsView {
|
||||
#endif
|
||||
}
|
||||
|
||||
// Stage-2 (Metal/VTDecompressionSession) is the default and only user-visible presenter — it
|
||||
// recovers from a wedged decoder, where stage-1's AVSampleBufferDisplayLayer freezes hard on a
|
||||
// lost HEVC reference. Stage-1 is kept reachable as a DEBUG-only override for diagnostics, like
|
||||
// the controller test. Empty in release builds (no presenter UI; stage-2 always).
|
||||
// Stage-2 (Metal/VTDecompressionSession, present on frame arrival) is the proven default;
|
||||
// stage-3 is the same pipeline with glass-gated present pacing — a user-visible A/B while the
|
||||
// pacing work settles (see Stage2Pipeline's PresentPacing for the queue-saturation rationale).
|
||||
// Stage-1 (compressed video straight to the system layer) stays a DEBUG-only diagnostic — it
|
||||
// freezes hard on a lost HEVC reference.
|
||||
@ViewBuilder var presenterSection: some View {
|
||||
#if DEBUG
|
||||
Section {
|
||||
Picker("Presenter", selection: $presenter) {
|
||||
Text("Stage 2 (default)").tag("stage2")
|
||||
Text("Stage 3 (experimental)").tag("stage3")
|
||||
#if DEBUG
|
||||
Text("Stage 1 (debug)").tag("stage1")
|
||||
#endif
|
||||
}
|
||||
} header: {
|
||||
Text("Video presenter · debug")
|
||||
Text("Video presenter")
|
||||
} footer: {
|
||||
Text("Stage 2 (default): explicit decode + Metal present — full HUD latency "
|
||||
+ "breakdown and self-recovery from decode stalls. Stage 1: compressed video "
|
||||
+ "straight to the system layer; freezes on a lost HEVC reference, so it's a "
|
||||
+ "debug fallback only. Applies from the next session.")
|
||||
Text("Stage 2: each frame is shown the moment it's decoded — proven, but on displays "
|
||||
+ "running near the stream's frame rate, queued frames can add two to three "
|
||||
+ "refreshes of display latency that never drains. Stage 3: presents are paced "
|
||||
+ "to the display — at most one undisplayed frame in flight, always the freshest, "
|
||||
+ "dropping late frames instead of queueing them. Watch the statistics overlay's "
|
||||
+ "display time to compare. Applies from the next session.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder var hdrSection: some View {
|
||||
@@ -376,8 +407,9 @@ extension SettingsView {
|
||||
Text("Codec is a preference; the host falls back if it can't encode your choice. "
|
||||
+ "HDR (HDR10) and full chroma (4:4:4) are HEVC-only, and each engages only when "
|
||||
+ "both this device and the host support it — otherwise the stream stays 8-bit "
|
||||
+ "4:2:0 SDR. 4:4:4 sharpens text and UI for extra bandwidth. Applies from the "
|
||||
+ "next session.")
|
||||
+ "4:2:0 SDR. 4:4:4 (off by default) sharpens text and UI — best for desktop "
|
||||
+ "work; for games the bits are better spent at 4:2:0. Applies from the next "
|
||||
+ "session.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -385,13 +417,17 @@ extension SettingsView {
|
||||
|
||||
@ViewBuilder var statisticsSection: some View {
|
||||
Section {
|
||||
Toggle("Show statistics overlay", isOn: $hudEnabled)
|
||||
Picker("Statistics overlay", selection: $statsVerbosityRaw) {
|
||||
ForEach(StatsVerbosity.allCases, id: \.rawValue) { tier in
|
||||
Text(tier.label).tag(tier.rawValue)
|
||||
}
|
||||
}
|
||||
Picker("Position", selection: $hudPlacement) {
|
||||
ForEach(HUDPlacement.allCases) { placement in
|
||||
Text(placement.label).tag(placement.rawValue)
|
||||
}
|
||||
}
|
||||
.disabled(!hudEnabled)
|
||||
.disabled(statsVerbosityRaw == StatsVerbosity.off.rawValue)
|
||||
} header: {
|
||||
Text("Statistics")
|
||||
} footer: {
|
||||
|
||||
@@ -54,10 +54,14 @@ extension SettingsView {
|
||||
// MARK: - Statistics
|
||||
|
||||
static var statisticsFooter: String {
|
||||
let base = "Shows resolution, frame rate, throughput and latency in the chosen "
|
||||
+ "corner while streaming."
|
||||
#if os(macOS) || os(iOS)
|
||||
return base + " Toggle it any time with ⌃⌥⇧S."
|
||||
let base = "Shows streaming statistics in the chosen corner — Compact is a one-line "
|
||||
+ "pill, Normal adds resolution and latency, Detailed adds the latency stage "
|
||||
+ "breakdown."
|
||||
#if os(macOS)
|
||||
return base + " ⌃⌥⇧S cycles Off → Compact → Normal → Detailed any time."
|
||||
#elseif os(iOS)
|
||||
return base + " ⌃⌥⇧S or a three-finger tap cycles Off → Compact → Normal → Detailed "
|
||||
+ "any time."
|
||||
#else
|
||||
return base
|
||||
#endif
|
||||
@@ -129,8 +133,10 @@ extension SettingsView {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if gamepads.active?.id == controller.id {
|
||||
Text("In use")
|
||||
// Every forwarded controller is surfaced (not just the primary `active`) with its
|
||||
// wire pad index as a player number — a pin forwards only one, Automatic forwards all.
|
||||
if let pad = gamepads.padIndex(for: controller) {
|
||||
Text("Player \(pad + 1)")
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
|
||||
@@ -21,10 +21,14 @@ struct SettingsView: View {
|
||||
@AppStorage(DefaultsKey.streamWidth) var width = 1920
|
||||
@AppStorage(DefaultsKey.streamHeight) var height = 1080
|
||||
@AppStorage(DefaultsKey.streamHz) var hz = 60
|
||||
// Opt-in (default OFF): the explicit mode below is used and never auto-resized. When ON, a
|
||||
// windowed session instead streams at the window's native pixels (1:1, no scaling) so it stays
|
||||
// pixel-exact rather than the presenter resampling a fixed-mode frame into the window.
|
||||
@AppStorage(DefaultsKey.matchWindow) var matchWindow = false
|
||||
@AppStorage(DefaultsKey.compositor) var compositor = 0
|
||||
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
||||
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
|
||||
@AppStorage(DefaultsKey.presenter) var presenter = "stage2"
|
||||
@AppStorage(DefaultsKey.presenter) var presenter = SettingsOptions.presenterDefault
|
||||
#if os(macOS)
|
||||
@AppStorage(DefaultsKey.vsync) var vsync = false
|
||||
#endif
|
||||
@@ -32,18 +36,19 @@ struct SettingsView: View {
|
||||
@AppStorage(DefaultsKey.allowVRR) var allowVRR = true
|
||||
#endif
|
||||
@AppStorage(DefaultsKey.hdrEnabled) var hdrEnabled = true
|
||||
@AppStorage(DefaultsKey.enable444) var enable444 = true
|
||||
@AppStorage(DefaultsKey.libraryEnabled) var libraryEnabled = false
|
||||
@AppStorage(DefaultsKey.enable444) var enable444 = false
|
||||
@AppStorage(DefaultsKey.libraryEnabled) var libraryEnabled = true
|
||||
@AppStorage(DefaultsKey.fullscreenWhileStreaming) var fullscreenWhileStreaming = true
|
||||
@AppStorage(DefaultsKey.micEnabled) var micEnabled = true
|
||||
@AppStorage(DefaultsKey.audioChannels) var audioChannels = 2
|
||||
@AppStorage(DefaultsKey.codec) var codec = "auto"
|
||||
@AppStorage(DefaultsKey.hudEnabled) var hudEnabled = true
|
||||
// The overlay tier's raw string (the pickers tag by rawValue); the absent-key default runs
|
||||
// the legacy-hudEnabled migration (same pattern as ContentView/StreamCommands).
|
||||
@AppStorage(DefaultsKey.statsVerbosity) var statsVerbosityRaw = StatsVerbosity.current.rawValue
|
||||
@AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||
@ObservedObject var gamepads = GamepadManager.shared
|
||||
#if !os(tvOS)
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true
|
||||
#endif
|
||||
@AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true
|
||||
#if DEBUG && !os(tvOS)
|
||||
@State var showControllerTest = false
|
||||
#endif
|
||||
@@ -105,6 +110,7 @@ struct SettingsView: View {
|
||||
Form {
|
||||
streamModeSection
|
||||
compositorSection
|
||||
wakeSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.tabItem { Label("General", systemImage: "gearshape") }
|
||||
@@ -234,6 +240,7 @@ struct SettingsView: View {
|
||||
streamModeSection
|
||||
pointerSection
|
||||
compositorSection
|
||||
wakeSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("General")
|
||||
@@ -294,14 +301,20 @@ struct SettingsView: View {
|
||||
})
|
||||
}
|
||||
|
||||
private var hudEnabledTag: Binding<String> {
|
||||
Binding(get: { hudEnabled ? "on" : "off" }, set: { hudEnabled = $0 == "on" })
|
||||
}
|
||||
|
||||
private var hdrEnabledTag: Binding<String> {
|
||||
Binding(get: { hdrEnabled ? "on" : "off" }, set: { hdrEnabled = $0 == "on" })
|
||||
}
|
||||
|
||||
/// The gamepad-UI switch as an on/off row (same shape as HDR above) — the escape hatch back
|
||||
/// to this focus-engine home for someone who prefers it with a controller connected.
|
||||
private var gamepadUIEnabledTag: Binding<String> {
|
||||
Binding(get: { gamepadUIEnabled ? "on" : "off" }, set: { gamepadUIEnabled = $0 == "on" })
|
||||
}
|
||||
|
||||
private var autoWakeEnabledTag: Binding<String> {
|
||||
Binding(get: { autoWakeEnabled ? "on" : "off" }, set: { autoWakeEnabled = $0 == "on" })
|
||||
}
|
||||
|
||||
private var tvBody: some View {
|
||||
let currentTag = "\(width)x\(height)x\(hz)"
|
||||
let bounds = UIScreen.main.nativeBounds
|
||||
@@ -327,32 +340,34 @@ struct SettingsView: View {
|
||||
selection: $audioChannels)
|
||||
if bitrateKbps > 1_000_000 {
|
||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.font(.geist(20, relativeTo: .caption)) // TV-legible caption size
|
||||
.foregroundStyle(.orange)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
TVSelectionRow(
|
||||
title: "Compositor", options: SettingsOptions.compositors,
|
||||
selection: $compositor)
|
||||
#if DEBUG
|
||||
TVSelectionRow(
|
||||
title: "Presenter (debug)",
|
||||
options: [("Stage 2 (default)", "stage2"), ("Stage 1 (debug)", "stage1")],
|
||||
title: "Presenter",
|
||||
options: SettingsOptions.presenters,
|
||||
selection: $presenter)
|
||||
#endif
|
||||
TVSelectionRow(
|
||||
title: "10-bit HDR",
|
||||
options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag)
|
||||
TVSelectionRow(
|
||||
title: "Auto-wake on connect",
|
||||
options: [("On", "on"), ("Off", "off")], selection: autoWakeEnabledTag)
|
||||
Text("The host creates a virtual output at exactly this mode — native "
|
||||
+ "resolution, no scaling. \(Self.bitrateFooter) A specific compositor "
|
||||
+ "is honored only if available on the host.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
+ "is honored only if available on the host. Auto-wake sends Wake-on-LAN to a "
|
||||
+ "sleeping saved host and waits for it before streaming.")
|
||||
.font(.geist(20, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 8)
|
||||
TVSelectionRow(
|
||||
title: "Statistics overlay",
|
||||
options: [("On", "on"), ("Off", "off")], selection: hudEnabledTag)
|
||||
options: SettingsOptions.statsVerbosities, selection: $statsVerbosityRaw)
|
||||
TVSelectionRow(
|
||||
title: "Statistics position", options: SettingsOptions.hudPlacements,
|
||||
selection: $hudPlacement)
|
||||
@@ -366,8 +381,11 @@ struct SettingsView: View {
|
||||
TVSelectionRow(
|
||||
title: "Controller type", options: SettingsOptions.padTypes,
|
||||
selection: $gamepadType)
|
||||
TVSelectionRow(
|
||||
title: "Gamepad-optimized browsing",
|
||||
options: [("On", "on"), ("Off", "off")], selection: gamepadUIEnabledTag)
|
||||
Text(Self.controllersFooter)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.font(.geist(20, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 8)
|
||||
|
||||
@@ -82,20 +82,36 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
|
||||
var interactive = false
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 26, macOS 26, tvOS 26, *) {
|
||||
#if os(tvOS)
|
||||
// ALWAYS the material fallback on tvOS: the gamepad settings list is 15+ of these
|
||||
// surfaces, and live Liquid Glass per row made the whole screen visibly laggy on the
|
||||
// Apple TV's GPU (same class of call GlassProminentButton already makes — glass fights
|
||||
// the 10-foot platform). The tint rides an overlay so the focused row keeps its wash.
|
||||
content.background {
|
||||
shape.fill(.ultraThinMaterial)
|
||||
.environment(\.colorScheme, .dark)
|
||||
.overlay {
|
||||
if let tint { shape.fill(tint) }
|
||||
}
|
||||
}
|
||||
#else
|
||||
if #available(iOS 26, macOS 26, *) {
|
||||
content.glassEffect(glass, in: shape)
|
||||
} else {
|
||||
content.background { shape.fill(.ultraThinMaterial).environment(\.colorScheme, .dark) }
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@available(iOS 26, macOS 26, tvOS 26, *)
|
||||
#if !os(tvOS)
|
||||
@available(iOS 26, macOS 26, *)
|
||||
private var glass: Glass {
|
||||
var g: Glass = .regular
|
||||
if let tint { g = g.tint(tint) }
|
||||
if interactive { g = g.interactive() }
|
||||
return g
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
extension View {
|
||||
|
||||
@@ -48,7 +48,7 @@ struct PairSheet: View {
|
||||
+ "(http://<host>:3000 → Pairing). "
|
||||
+ "Pairing verifies both sides at once — no fingerprint comparison "
|
||||
+ "needed.")
|
||||
.font(.geist(16, relativeTo: .callout))
|
||||
.font(.geist(22, relativeTo: .callout)) // TV-legible (system callout is ~25 there)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
TVFieldRow(
|
||||
@@ -59,7 +59,7 @@ struct PairSheet: View {
|
||||
) { editing = .clientName }
|
||||
if let errorText {
|
||||
Text(errorText)
|
||||
.font(.geist(16, relativeTo: .callout))
|
||||
.font(.geist(22, relativeTo: .callout))
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
HStack(spacing: 32) {
|
||||
|
||||
@@ -263,19 +263,24 @@ public final class SessionAudio {
|
||||
defer { drainDone.signal() }
|
||||
// Decode happens IN-CORE (libopus multistream) — AudioToolbox's Opus path is
|
||||
// stereo-only — and is handed back as interleaved f32 PCM in wire channel order.
|
||||
while !flag.isStopped {
|
||||
// Per-iteration autorelease pool: no runloop on this thread (see Stage2Pipeline).
|
||||
var alive = true
|
||||
while alive, !flag.isStopped {
|
||||
alive = autoreleasepool { () -> Bool in
|
||||
let pcm: PunktfunkConnection.AudioPCM?
|
||||
do {
|
||||
pcm = try connection.nextAudioPcm(timeoutMs: 100)
|
||||
} catch {
|
||||
break // session closed
|
||||
return false // session closed
|
||||
}
|
||||
guard let pcm, pcm.frameCount > 0 else { continue }
|
||||
guard let pcm, pcm.frameCount > 0 else { return true }
|
||||
pcm.samples.withUnsafeBufferPointer { p in
|
||||
if let base = p.baseAddress {
|
||||
ring.write(base, count: pcm.frameCount * pcm.channels)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
thread.name = "punktfunk-audio"
|
||||
|
||||
@@ -59,6 +59,26 @@ public extension PunktfunkInputEvent {
|
||||
make(PUNKTFUNK_INPUT_KIND_GAMEPAD_AXIS.rawValue, code: axis, x: value, y: 0, flags: pad)
|
||||
}
|
||||
|
||||
/// Declare a pad's controller KIND (`InputKind::GamepadArrival`): `pref` is the
|
||||
/// `GamepadType` wire byte (Auto=0, Xbox360=1, DualSense=2, XboxOne=3, DualShock4=4,
|
||||
/// SteamController=5, SteamDeck=6), `pad` the wire index. Sent once when a controller slot
|
||||
/// opens — BEFORE that pad's first input — so the host builds a matching virtual device and a
|
||||
/// session can mix types (pad 0 a DualSense, pad 1 an Xbox pad). The core re-sends it a few
|
||||
/// times against datagram loss and folds per-pad state behind it; a host that predates the tag
|
||||
/// ignores it and uses the session-default kind from the handshake. Idempotent on the host.
|
||||
static func gamepadArrival(pref: UInt32, pad: UInt32) -> PunktfunkInputEvent {
|
||||
make(PUNKTFUNK_INPUT_KIND_GAMEPAD_ARRIVAL.rawValue, code: pref, x: 0, y: 0, flags: pad)
|
||||
}
|
||||
|
||||
/// A pad disconnected (`InputKind::GamepadRemove`): `flags` = pad index. The client sends the
|
||||
/// bare index; the core stamps the per-pad removal seq (`encode_gamepad_remove`) in the shared
|
||||
/// snapshot seq space and arms a loss-resistant re-send burst, so the host tears the pad's
|
||||
/// virtual device down and no reordered snapshot can resurrect it. A host that predates the tag
|
||||
/// ignores it (the pad then lingers until session end — the pre-existing behaviour).
|
||||
static func gamepadRemove(pad: UInt32) -> PunktfunkInputEvent {
|
||||
make(PUNKTFUNK_INPUT_KIND_GAMEPAD_REMOVE.rawValue, code: 0, x: 0, y: 0, flags: pad)
|
||||
}
|
||||
|
||||
// Touch (host-side: libei ei_touchscreen on the virtual output). `id` distinguishes
|
||||
// fingers and is reusable after touchUp; coordinates are absolute pixels on the
|
||||
// client's touch surface, whose size rides in `flags` so the host can rescale —
|
||||
|
||||
@@ -70,19 +70,10 @@ func withOptionalCString<R>(_ s: String?, _ body: (UnsafePointer<CChar>?) -> R)
|
||||
public extension PunktfunkConnection {
|
||||
/// Whether the Wake-on-LAN broadcast path is usable on this platform/build. macOS can always
|
||||
/// broadcast (its App Sandbox network entitlements cover it). iOS/tvOS need the managed
|
||||
/// `com.apple.developer.networking.multicast` entitlement, which is GATED pending Apple's
|
||||
/// approval (see `Config/Punktfunk.entitlements`) — until it's granted, sending a broadcast is
|
||||
/// blocked by the OS, so the wake path + its UI are gated off there to avoid a dead action.
|
||||
/// The MAC-learning path stays active on every platform, so flipping this on once the
|
||||
/// entitlement lands makes wake work immediately. ON APPROVAL: change `#if os(macOS)` below to
|
||||
/// `true` for iOS/tvOS too (and uncomment the entitlement).
|
||||
static var wakeOnLANAvailable: Bool {
|
||||
#if os(macOS)
|
||||
return true
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
/// `com.apple.developer.networking.multicast` entitlement — now approved and enabled (see
|
||||
/// `Config/Punktfunk.entitlements`), so wake is available on every platform. Kept as the single
|
||||
/// switch every call site gates on, should a future build ever need to disable it.
|
||||
static var wakeOnLANAvailable: Bool { true }
|
||||
|
||||
/// Send a Wake-on-LAN magic packet to wake a sleeping host. `macs` are the host's NIC MAC(s)
|
||||
/// (`aa:bb:cc:dd:ee:ff`, learned from its mDNS `mac` TXT while awake); malformed entries are
|
||||
@@ -445,6 +436,35 @@ public final class PunktfunkConnection {
|
||||
_ = punktfunk_connection_request_keyframe(h)
|
||||
}
|
||||
|
||||
/// Feed each received AU's `frameIndex` (in receive order) so the client recovers from loss with a
|
||||
/// cheap reference-frame invalidation instead of always paying for a full IDR. On a forward gap —
|
||||
/// a `frameIndex` jump means the intervening frames were lost and the following AUs reference a
|
||||
/// picture that never arrived — the core fires a THROTTLED RFI request for the lost range, and an
|
||||
/// RFI-capable host (AMD LTR / NVENC) recovers with a clean P-frame rather than a 20-40× IDR
|
||||
/// spike. Call it for every received AU; the `framesDropped`-driven `requestKeyframe()` path stays
|
||||
/// the backstop for when the recovery frame itself is lost. Cheap; silently dropped after close.
|
||||
public func noteFrameIndex(_ frameIndex: UInt32) {
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return }
|
||||
_ = punktfunk_connection_note_frame_index(h, frameIndex, nil)
|
||||
}
|
||||
|
||||
/// Like `noteFrameIndex`, but also reports whether the core saw a FORWARD frame-index gap — the
|
||||
/// signal that intervening frames were lost and the following AUs reference a picture that never
|
||||
/// arrived. The post-loss re-anchor gate arms its display freeze on a gap (the earliest, most
|
||||
/// precise loss trigger — ahead of the `framesDropped` climb). Same core side effect as
|
||||
/// `noteFrameIndex` (the throttled RFI request); call it for every received AU. Returns false
|
||||
/// after close.
|
||||
public func noteFrameIndexGap(_ frameIndex: UInt32) -> Bool {
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return false }
|
||||
var gap = false
|
||||
_ = punktfunk_connection_note_frame_index(h, frameIndex, &gap)
|
||||
return gap
|
||||
}
|
||||
|
||||
/// Cumulative access units the host→client reassembler dropped as unrecoverable (FEC couldn't
|
||||
/// rebuild them). The video pump polls this and calls `requestKeyframe()` when it climbs — the
|
||||
/// correct loss trigger under the host's infinite GOP, where unrecoverable loss yields
|
||||
@@ -570,7 +590,8 @@ public final class PunktfunkConnection {
|
||||
|
||||
/// Pull the next force-feedback update for the GCController haptics engine:
|
||||
/// `(pad, lowFrequency, highFrequency)` with 0...0xFFFF amplitudes, (0, 0) = stop.
|
||||
/// Drain from the (single) feedback thread, alongside `nextHidOutput`.
|
||||
/// Drain from the (single) feedback thread, alongside `nextHidOutput`. Drops the v2
|
||||
/// self-termination TTL — use `nextRumble2` to honor the host lease.
|
||||
public func nextRumble(timeoutMs: UInt32 = 0) throws -> (pad: UInt16, low: UInt16, high: UInt16)? {
|
||||
feedbackLock.lock()
|
||||
defer { feedbackLock.unlock() }
|
||||
@@ -590,6 +611,33 @@ public final class PunktfunkConnection {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the next force-feedback update *including its self-termination TTL* (v2 envelopes):
|
||||
/// `(pad, low, high, ttlMs)`. `ttlMs` is how long to render this level before silencing unless
|
||||
/// the host renews it; `RumbleTuning.noTTL` (`UInt32.max`) means "no lease" — a legacy host, so
|
||||
/// fall back to a client-side staleness timeout. The reorder gate (seq) already ran in the
|
||||
/// core, so a stale/reordered envelope never surfaces here. Drain from the (single) feedback
|
||||
/// thread, alongside `nextHidOutput`.
|
||||
public func nextRumble2(timeoutMs: UInt32 = 0) throws
|
||||
-> (pad: UInt16, low: UInt16, high: UInt16, ttlMs: UInt32)?
|
||||
{
|
||||
feedbackLock.lock()
|
||||
defer { feedbackLock.unlock() }
|
||||
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
|
||||
|
||||
var pad: UInt16 = 0, low: UInt16 = 0, high: UInt16 = 0, ttl: UInt32 = .max
|
||||
let rc = punktfunk_connection_next_rumble2(h, &pad, &low, &high, &ttl, timeoutMs)
|
||||
switch rc {
|
||||
case statusOK:
|
||||
return (pad, low, high, ttl)
|
||||
case statusNoFrame:
|
||||
return nil
|
||||
case statusClosed:
|
||||
throw PunktfunkClientError.closed
|
||||
default:
|
||||
throw PunktfunkClientError.status(rc)
|
||||
}
|
||||
}
|
||||
|
||||
/// One DualSense feedback event a game wrote to the host's virtual pad — replay it on
|
||||
/// the real controller (GCDeviceLight, GCControllerPlayerIndex,
|
||||
/// GCDualSenseAdaptiveTrigger). Only a `.dualSense` session emits these.
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
// Gamepad capture → punktfunk/1 datagrams. Forwards exactly ONE controller — whatever
|
||||
// GamepadManager selected — as pad 0, for the lifetime of a streaming session.
|
||||
// Gamepad capture → punktfunk/1 datagrams. Forwards EVERY controller GamepadManager selected —
|
||||
// each on its own stable wire pad index (pf-client-core's slot model) — for the lifetime of a
|
||||
// streaming session. One physical controller with no pin is player 0 (byte-identical to the old
|
||||
// single-pad path); a pin forwards only that one, also as pad 0.
|
||||
//
|
||||
// The wire is incremental (one button/axis transition per 18-byte event, accumulated
|
||||
// host-side into the virtual pad — see punktfunk_core::input::gamepad), so we snapshot the
|
||||
// full GCExtendedGamepad state on every valueChanged and diff against the previous
|
||||
// snapshot. Sticks are ±32767 with +y = up (GC already matches, no flip), triggers 0...255.
|
||||
// Each forwarded controller gets a `Slot`: its open GC handlers plus the wire state (buttons,
|
||||
// axes, touchpad fingers, motion throttle) for its pad index — isolated per device so two
|
||||
// controllers never clobber each other. On connect a slot opens (GamepadArrival declares its
|
||||
// kind, then input flows); on disconnect / pin change / stop it closes (held state flushed to
|
||||
// rest on the wire, then GamepadRemove tells the host to tear the pad's virtual device down).
|
||||
//
|
||||
// The wire is incremental (one button/axis transition per 18-byte event, accumulated host-side
|
||||
// into the virtual pad — see punktfunk_core::input::gamepad), so we snapshot the full
|
||||
// GCExtendedGamepad state on every valueChanged and diff against the previous snapshot. Sticks
|
||||
// are ±32767 with +y = up (GC already matches, no flip), triggers 0...255. The core folds these
|
||||
// per-pad transitions into idempotent, sequence-numbered snapshots keyed on the same pad index,
|
||||
// so all this layer must get right is the index — one controller per slot, one slot per index.
|
||||
//
|
||||
// PlayStation-pad extras ride the rich-input plane (0xCC): touchpad contacts normalized
|
||||
// 0...65535 (origin top-left, +y down — GC's ±1/+y-up is converted here) and motion
|
||||
// samples in raw DualSense sensor units (gyro 20 LSB per deg/s, accel 10000 LSB per g —
|
||||
// derived from the host's fixed calibration blob; the conversion lives in ONE place,
|
||||
// `Wire`, so a live sign/scale correction is a one-line change). The host ignores both
|
||||
// unless the session's virtual pad is a DualSense or DualShock 4 — both carry a touchpad
|
||||
// and motion, so the capture below covers either (`GCDualShockGamepad` exposes the same
|
||||
// `touchpad*` surface as `GCDualSenseGamepad`).
|
||||
// 0...65535 (origin top-left, +y down — GC's ±1/+y-up is converted here) and motion samples in
|
||||
// raw DualSense sensor units (gyro 20 LSB per deg/s, accel 10000 LSB per g — derived from the
|
||||
// host's fixed calibration blob; the conversion lives in ONE place, `Wire`, so a live sign/scale
|
||||
// correction is a one-line change). The host ignores both unless a pad's virtual device is a
|
||||
// DualSense or DualShock 4 — both carry a touchpad and motion, so the capture below covers either
|
||||
// (`GCDualShockGamepad` exposes the same `touchpad*` surface as `GCDualSenseGamepad`).
|
||||
//
|
||||
// Unlike mouse/keyboard capture, gamepad forwarding is NOT gated on the mouse-capture
|
||||
// toggle — a controller can't click local UI, so it always drives the host while the app
|
||||
// is active. On deactivation, controller switch, or stop, every held control is released
|
||||
// on the wire (the host pad would otherwise stay stuck on the last state).
|
||||
// Unlike mouse/keyboard capture, gamepad forwarding is NOT gated on the mouse-capture toggle — a
|
||||
// controller can't click local UI, so it always drives the host while the app is active. On
|
||||
// deactivation, controller switch, or stop, every held control is released on the wire (the host
|
||||
// pad would otherwise stay stuck on the last state).
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
@@ -33,31 +42,70 @@ import GameController
|
||||
public final class GamepadCapture {
|
||||
private let connection: PunktfunkConnection
|
||||
private let manager: GamepadManager
|
||||
private var activeSub: AnyCancellable?
|
||||
private var forwardedSub: AnyCancellable?
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
private var bound: GCController?
|
||||
/// App inactive → GC stops delivering; everything is released and stays silent.
|
||||
private var suspended = false
|
||||
|
||||
// Last wire state (the diff base — also what releaseAll() unwinds).
|
||||
private var buttons: UInt32 = 0
|
||||
private var axes: [Int32] = [0, 0, 0, 0, 0, 0]
|
||||
private var fingerActive: [Bool] = [false, false]
|
||||
private var lastMotionNs: UInt64 = 0
|
||||
/// One forwarded controller: the open device plus the last wire state for its pad index (the
|
||||
/// diff base — also what `flush` unwinds). Held per Slot so two controllers never clobber each
|
||||
/// other's held buttons/axes/fingers. Mirrors pf-client-core's `Slot`.
|
||||
private final class Slot {
|
||||
let controller: GCController
|
||||
/// Wire pad index (GamepadManager's stable lowest-free assignment), threaded onto every
|
||||
/// event this controller sends — the low byte of `flags`.
|
||||
let pad: UInt32
|
||||
/// The controller KIND declared to the host (GamepadArrival) when the slot opened.
|
||||
let pref: PunktfunkConnection.GamepadType
|
||||
var buttons: UInt32 = 0
|
||||
var axes: [Int32] = [0, 0, 0, 0, 0, 0]
|
||||
var fingerActive: [Bool] = [false, false]
|
||||
var lastMotionNs: UInt64 = 0
|
||||
init(controller: GCController, pad: UInt32, pref: PunktfunkConnection.GamepadType) {
|
||||
self.controller = controller
|
||||
self.pad = pad
|
||||
self.pref = pref
|
||||
}
|
||||
}
|
||||
|
||||
/// Open forwarded controllers, one Slot per physical pad on its own wire index. Reconciled
|
||||
/// against `manager.forwarded` (empty until a session's `start`, cleared by `stop`).
|
||||
private var slots: [Slot] = []
|
||||
|
||||
/// Motion forwarding floor: ≥ 4 ms between samples (≈ 250 Hz, the DualSense's own rate).
|
||||
private static let motionIntervalNs: UInt64 = 4_000_000
|
||||
|
||||
/// The cross-client controller escape chord (pf-client-core's `ESCAPE_CHORD`):
|
||||
/// L1+R1+Start+Select held together — four simultaneous buttons no game uses, so normal
|
||||
/// play can't trip it. Held for `disconnectHold` it ends the session via
|
||||
/// `onDisconnectRequest`; the chord keeps forwarding to the host meanwhile (the user is
|
||||
/// leaving anyway). The desktop clients' quick-press step (leave fullscreen / release
|
||||
/// capture) has no Apple equivalent worth wiring — macOS has ⌃⌥⇧Q/D, touch has the HUD.
|
||||
private static let escapeChord: UInt32 =
|
||||
GamepadWire.leftShoulder | GamepadWire.rightShoulder | GamepadWire.start | GamepadWire.back
|
||||
/// pf-client-core's `DISCONNECT_HOLD` — the same 1.5 s on every client.
|
||||
private static let disconnectHold: TimeInterval = 1.5
|
||||
private var chordTimer: Timer?
|
||||
/// Fired ON MAIN once the escape chord has been held `disconnectHold` — the session owner
|
||||
/// disconnects. On tvOS this (plus the Siri Remote's hold-Back) is the ONLY way out of a
|
||||
/// stream with a controller: B/Menu presses are deliberately swallowed during a session so
|
||||
/// gameplay can't end it (see ContentView's tvOS session branch).
|
||||
public var onDisconnectRequest: (() -> Void)?
|
||||
|
||||
public init(connection: PunktfunkConnection, manager: GamepadManager) {
|
||||
self.connection = connection
|
||||
self.manager = manager
|
||||
}
|
||||
|
||||
public func start() {
|
||||
// Fires immediately with the current selection, then on every change — a switch
|
||||
// releases the old controller's wire state before the new one takes over.
|
||||
activeSub = manager.$active.sink { [weak self] dc in
|
||||
MainActor.assumeIsolated { self?.rebind(to: dc?.controller) }
|
||||
// Session-scoped index assignment: a controller pinned before the session forwards as
|
||||
// pad 0 (pf-client-core assigns indices at slot-open time, not app-launch time).
|
||||
manager.resetForwardingAssignment()
|
||||
// Fires immediately with the current forwarded set, then on every change — a connect,
|
||||
// disconnect, or pin change reconciles the open slots against it (opening/closing devices
|
||||
// and flushing wire state so nothing sticks down).
|
||||
forwardedSub = manager.$forwarded.sink { [weak self] list in
|
||||
MainActor.assumeIsolated { self?.reconcile(list) }
|
||||
}
|
||||
#if os(macOS)
|
||||
let resign = NSApplication.willResignActiveNotification
|
||||
@@ -80,53 +128,56 @@ public final class GamepadCapture {
|
||||
MainActor.assumeIsolated {
|
||||
guard let self else { return }
|
||||
self.suspended = false
|
||||
if let ext = self.bound?.extendedGamepad { self.sync(ext) }
|
||||
// Re-send every open pad's current state (GC delivered nothing while inactive).
|
||||
for slot in self.slots {
|
||||
if let ext = slot.controller.extendedGamepad { self.sync(slot, ext) }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
releaseAll()
|
||||
rebind(to: nil)
|
||||
activeSub = nil
|
||||
closeAllSlots()
|
||||
forwardedSub = nil
|
||||
observers.forEach { NotificationCenter.default.removeObserver($0) }
|
||||
observers.removeAll()
|
||||
}
|
||||
|
||||
private func rebind(to controller: GCController?) {
|
||||
guard controller !== bound else { return }
|
||||
releaseAll()
|
||||
if let ext = bound?.extendedGamepad {
|
||||
ext.valueChangedHandler = nil
|
||||
let tp = Self.touchpad(ext)
|
||||
tp?.primary.valueChangedHandler = nil
|
||||
tp?.secondary.valueChangedHandler = nil
|
||||
/// Bring `slots` in line with the forwarded set: close any slot no longer wanted (flushing its
|
||||
/// held wire state and sending GamepadRemove first) and open any newly-forwarded controller into
|
||||
/// its assigned wire index. A controller that stays forwarded keeps its slot untouched, so a
|
||||
/// second pad connecting never disturbs the first. Mirrors pf-client-core's `reconcile_slots`.
|
||||
private func reconcile(_ forwarded: [GamepadManager.DiscoveredController]) {
|
||||
let wantIDs = Set(forwarded.map { ObjectIdentifier($0.controller) })
|
||||
for slot in slots where !wantIDs.contains(ObjectIdentifier(slot.controller)) {
|
||||
closeSlot(slot)
|
||||
}
|
||||
// Hand the system gestures back to the OS before letting the old pad go — outside a
|
||||
// stream the share button's screenshot and the Home overlay are the user's, not ours.
|
||||
if let old = bound {
|
||||
for element in old.physicalInputProfile.elements.values {
|
||||
element.preferredSystemGestureState = .enabled
|
||||
}
|
||||
for dc in forwarded where !slots.contains(where: { $0.controller === dc.controller }) {
|
||||
openSlot(dc)
|
||||
}
|
||||
if let motion = bound?.motion {
|
||||
motion.valueChangedHandler = nil
|
||||
// Power the sensors back down — left active they keep the pad streaming
|
||||
// gyro/accel over Bluetooth (battery drain) long after the session.
|
||||
if motion.sensorsRequireManualActivation { motion.sensorsActive = false }
|
||||
}
|
||||
bound = controller
|
||||
guard let c = controller, let ext = c.extendedGamepad else { return }
|
||||
// A chord-holding pad may have just unplugged — re-evaluate so a stale hold disarms.
|
||||
updateEscapeChord()
|
||||
}
|
||||
|
||||
ext.valueChangedHandler = { [weak self] g, _ in
|
||||
MainActor.assumeIsolated { self?.sync(g) }
|
||||
/// Open one forwarded controller on its assigned wire index: attach GC handlers, claim its
|
||||
/// system gestures, declare its kind (GamepadArrival — before any input), then wake the host
|
||||
/// pad and send its initial state. Skipped when the pad has no wire index (every slot taken)
|
||||
/// or exposes no extended profile.
|
||||
private func openSlot(_ dc: GamepadManager.DiscoveredController) {
|
||||
guard let pad = manager.padIndex(for: dc), let ext = dc.controller.extendedGamepad else { return }
|
||||
let c = dc.controller
|
||||
let slot = Slot(controller: c, pad: UInt32(pad), pref: dc.kind)
|
||||
slots.append(slot)
|
||||
|
||||
ext.valueChangedHandler = { [weak self, weak slot] g, _ in
|
||||
MainActor.assumeIsolated { if let self, let slot { self.sync(slot, g) } }
|
||||
}
|
||||
// Claim EVERY element's system gesture while this pad drives a stream. The OS attaches
|
||||
// gestures to several controller buttons — share/create → local screenshot/recording,
|
||||
// Home → Game Center overlay (iOS) / Launchpad's Games folder (macOS) — and with a
|
||||
// gesture attached the press is the system's, not the game's. During capture the remote
|
||||
// session IS the game: the share button must reach the host (e.g. Steam screenshots),
|
||||
// the PS button must open the host's Steam overlay. Restored to .enabled on unbind.
|
||||
// the PS button must open the host's Steam overlay. Restored to .enabled on close.
|
||||
for element in c.physicalInputProfile.elements.values {
|
||||
element.preferredSystemGestureState = .disabled
|
||||
}
|
||||
@@ -136,66 +187,114 @@ public final class GamepadCapture {
|
||||
// `extendedGamepad.buttonHome` is unreliable/often nil even when the physical element
|
||||
// exists. On tvOS the element is absent (reserved) → nil, the whole block no-ops.
|
||||
if let home = c.physicalInputProfile.buttons[GCInputButtonHome] {
|
||||
home.pressedChangedHandler = { [weak self] _, _, pressed in
|
||||
MainActor.assumeIsolated { self?.sendGuide(down: pressed) }
|
||||
home.pressedChangedHandler = { [weak self, weak slot] _, _, pressed in
|
||||
MainActor.assumeIsolated { if let self, let slot { self.sendGuide(slot, down: pressed) } }
|
||||
}
|
||||
}
|
||||
// Wake the host pad immediately (pads are created lazily from the first event;
|
||||
// a DualSense's UHID handshake + initial lightbar write only start then).
|
||||
connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: 0))
|
||||
sync(ext)
|
||||
// Declare this pad's controller KIND before any of its input, so the host builds a
|
||||
// matching virtual device (mixed types — pad 0 a DualSense, pad 1 an Xbox pad). The core
|
||||
// re-sends it a few times against datagram loss; an older host ignores it and uses the
|
||||
// session-default kind. Then wake the host pad (pads are created lazily from the first
|
||||
// event; a DualSense's UHID handshake + initial lightbar write only start then).
|
||||
connection.send(.gamepadArrival(pref: slot.pref.rawValue, pad: slot.pad))
|
||||
connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: slot.pad))
|
||||
sync(slot, ext)
|
||||
|
||||
if let tp = Self.touchpad(ext) {
|
||||
tp.primary.valueChangedHandler = { [weak self] _, x, y in
|
||||
MainActor.assumeIsolated { self?.touch(finger: 0, x: x, y: y) }
|
||||
tp.primary.valueChangedHandler = { [weak self, weak slot] _, x, y in
|
||||
MainActor.assumeIsolated { if let self, let slot { self.touch(slot, finger: 0, x: x, y: y) } }
|
||||
}
|
||||
tp.secondary.valueChangedHandler = { [weak self] _, x, y in
|
||||
MainActor.assumeIsolated { self?.touch(finger: 1, x: x, y: y) }
|
||||
tp.secondary.valueChangedHandler = { [weak self, weak slot] _, x, y in
|
||||
MainActor.assumeIsolated { if let self, let slot { self.touch(slot, finger: 1, x: x, y: y) } }
|
||||
}
|
||||
}
|
||||
if let motion = c.motion {
|
||||
if motion.sensorsRequireManualActivation { motion.sensorsActive = true }
|
||||
motion.valueChangedHandler = { [weak self] m in
|
||||
MainActor.assumeIsolated { self?.forwardMotion(m) }
|
||||
motion.valueChangedHandler = { [weak self, weak slot] m in
|
||||
MainActor.assumeIsolated { if let self, let slot { self.forwardMotion(slot, m) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot the profile into wire state and send every transition since the last one.
|
||||
private func sync(_ g: GCExtendedGamepad) {
|
||||
/// Flush a slot's held wire state (so nothing sticks down host-side) and signal the host to tear
|
||||
/// its virtual device down (GamepadRemove), then detach GC handlers, hand the system gestures
|
||||
/// back, and power the sensors down. Wire-only until the GC cleanup, so it is safe even when the
|
||||
/// device already physically unplugged. Mirrors pf-client-core's `close_slot_at`.
|
||||
private func closeSlot(_ slot: Slot) {
|
||||
flush(slot)
|
||||
// Sent after the flush so the core stamps it with a seq past the zeroing snapshots; the host
|
||||
// seq-gates it, so a reordered snapshot can't resurrect the removed pad.
|
||||
connection.send(.gamepadRemove(pad: slot.pad))
|
||||
let c = slot.controller
|
||||
if let ext = c.extendedGamepad {
|
||||
ext.valueChangedHandler = nil
|
||||
let tp = Self.touchpad(ext)
|
||||
tp?.primary.valueChangedHandler = nil
|
||||
tp?.secondary.valueChangedHandler = nil
|
||||
}
|
||||
c.physicalInputProfile.buttons[GCInputButtonHome]?.pressedChangedHandler = nil
|
||||
// Hand the system gestures back to the OS before letting the pad go — outside a stream the
|
||||
// share button's screenshot and the Home overlay are the user's, not ours.
|
||||
for element in c.physicalInputProfile.elements.values {
|
||||
element.preferredSystemGestureState = .enabled
|
||||
}
|
||||
if let motion = c.motion {
|
||||
motion.valueChangedHandler = nil
|
||||
// Power the sensors back down — left active they keep the pad streaming gyro/accel
|
||||
// over Bluetooth (battery drain) long after the session.
|
||||
if motion.sensorsRequireManualActivation { motion.sensorsActive = false }
|
||||
}
|
||||
slots.removeAll { $0 === slot }
|
||||
}
|
||||
|
||||
private func closeAllSlots() {
|
||||
while let slot = slots.first { closeSlot(slot) }
|
||||
chordTimer?.invalidate()
|
||||
chordTimer = nil
|
||||
}
|
||||
|
||||
/// Snapshot the profile into a slot's wire state and send every transition since the last one,
|
||||
/// tagged with the slot's wire pad index.
|
||||
private func sync(_ slot: Slot, _ g: GCExtendedGamepad) {
|
||||
guard !suspended else { return }
|
||||
let newButtons = Self.buttonMask(g)
|
||||
let changed = newButtons ^ buttons
|
||||
// guide is driven separately (`sendGuide`, off the Home handler) and deliberately kept out
|
||||
// of `buttonMask`. Preserve its current held state here so the XOR diff below never sees it
|
||||
// as "changed" — otherwise the first stick/button move after a guide press would emit a
|
||||
// spurious guide-UP while the button is still physically held (and drop the bit from
|
||||
// `slot.buttons`, swallowing the real release too). `flush`/`allButtons` still release it.
|
||||
let newButtons = Self.buttonMask(g) | (slot.buttons & GamepadWire.guide)
|
||||
let changed = newButtons ^ slot.buttons
|
||||
if changed != 0 {
|
||||
for bit in GamepadWire.allButtons where changed & bit != 0 {
|
||||
connection.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: 0))
|
||||
connection.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: slot.pad))
|
||||
}
|
||||
buttons = newButtons
|
||||
slot.buttons = newButtons
|
||||
}
|
||||
let newAxes: [Int32] = [
|
||||
Int32((g.leftThumbstick.xAxis.value * 32767).rounded()),
|
||||
Int32((g.leftThumbstick.yAxis.value * 32767).rounded()),
|
||||
Int32((g.rightThumbstick.xAxis.value * 32767).rounded()),
|
||||
Int32((g.rightThumbstick.yAxis.value * 32767).rounded()),
|
||||
Int32((g.leftTrigger.value * 255).rounded()),
|
||||
Int32((g.rightTrigger.value * 255).rounded()),
|
||||
Int32(g.leftThumbstick.xAxis.value * 32767),
|
||||
Int32(g.leftThumbstick.yAxis.value * 32767),
|
||||
Int32(g.rightThumbstick.xAxis.value * 32767),
|
||||
Int32(g.rightThumbstick.yAxis.value * 32767),
|
||||
Int32(g.leftTrigger.value * 255),
|
||||
Int32(g.rightTrigger.value * 255),
|
||||
]
|
||||
for (i, v) in newAxes.enumerated() where v != axes[i] {
|
||||
connection.send(.gamepadAxis(UInt32(i), value: v, pad: 0))
|
||||
axes[i] = v
|
||||
for (i, v) in newAxes.enumerated() where v != slot.axes[i] {
|
||||
connection.send(.gamepadAxis(UInt32(i), value: v, pad: slot.pad))
|
||||
slot.axes[i] = v
|
||||
}
|
||||
updateEscapeChord()
|
||||
}
|
||||
|
||||
/// Forward the guide (Home/PS) transition directly — it's kept out of `buttonMask` (the legacy
|
||||
/// `buttonHome` element is unreliable). Folds into `buttons` so a held PS button is released by
|
||||
/// `releaseAll` on focus loss just like the others.
|
||||
private func sendGuide(down: Bool) {
|
||||
/// `buttonHome` element is unreliable). Folds into the slot's `buttons` so a held PS button is
|
||||
/// released by `flush` on focus loss / close just like the others.
|
||||
private func sendGuide(_ slot: Slot, down: Bool) {
|
||||
guard !suspended else { return }
|
||||
let bit = GamepadWire.guide
|
||||
let now = down ? (buttons | bit) : (buttons & ~bit)
|
||||
guard now != buttons else { return }
|
||||
connection.send(.gamepadButton(bit, down: down, pad: 0))
|
||||
buttons = now
|
||||
let now = down ? (slot.buttons | bit) : (slot.buttons & ~bit)
|
||||
guard now != slot.buttons else { return }
|
||||
connection.send(.gamepadButton(bit, down: down, pad: slot.pad))
|
||||
slot.buttons = now
|
||||
}
|
||||
|
||||
private static func buttonMask(_ g: GCExtendedGamepad) -> UInt32 {
|
||||
@@ -206,17 +305,21 @@ public final class GamepadCapture {
|
||||
if g.dpad.right.isPressed { b |= GamepadWire.dpadRight }
|
||||
if g.buttonMenu.isPressed { b |= GamepadWire.start }
|
||||
if g.buttonOptions?.isPressed == true { b |= GamepadWire.back }
|
||||
// The share/create/capture element (Xbox Series share, a clone pad's screenshot button —
|
||||
// e.g. the GameSir G8's, below its d-pad) folds into back/select too. On pads that expose
|
||||
// the create button BOTH as buttonOptions and as the share element this OR is harmless —
|
||||
// same wire bit.
|
||||
if g.buttons[GCInputButtonShare]?.isPressed == true { b |= GamepadWire.back }
|
||||
// The dedicated share/create/capture element (Xbox-Series Share, DualSense Create, a clone
|
||||
// pad's screenshot button — e.g. the GameSir G8's, below its d-pad) → the wire's capture
|
||||
// bit, matching the Rust client's `Button::Misc1 => wire::BTN_MISC1`. On an Xbox-Series pad
|
||||
// this is a button physically DISTINCT from View (buttonOptions, above), so it must not
|
||||
// collapse onto back — the host reads MISC1 as its own control (DualSense mute / Steam
|
||||
// quick-access). Caveat: a pad that surfaces ONE physical button as both buttonOptions and
|
||||
// this share element now emits back+misc1 for it — harmless on a plain xpad session (no
|
||||
// misc button) and rare otherwise. NOTE: on-glass verify on a real Xbox-Series pad.
|
||||
if g.buttons[GCInputButtonShare]?.isPressed == true { b |= GamepadWire.misc1 }
|
||||
if g.leftThumbstickButton?.isPressed == true { b |= GamepadWire.leftStickClick }
|
||||
if g.rightThumbstickButton?.isPressed == true { b |= GamepadWire.rightStickClick }
|
||||
if g.leftShoulder.isPressed { b |= GamepadWire.leftShoulder }
|
||||
if g.rightShoulder.isPressed { b |= GamepadWire.rightShoulder }
|
||||
// guide (Home/PS) is NOT read here — it's forwarded directly by the Home button's
|
||||
// pressedChangedHandler (the legacy `buttonHome` element is unreliable). See `rebind`.
|
||||
// pressedChangedHandler (the legacy `buttonHome` element is unreliable). See `openSlot`.
|
||||
if g.buttonA.isPressed { b |= GamepadWire.a }
|
||||
if g.buttonB.isPressed { b |= GamepadWire.b }
|
||||
if g.buttonX.isPressed { b |= GamepadWire.x }
|
||||
@@ -244,29 +347,29 @@ public final class GamepadCapture {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// One touchpad finger moved. GC reports ±1 positions and snaps to exactly (0, 0) on
|
||||
/// lift — treated as the lift signal (a real finger landing on the precise center
|
||||
/// One touchpad finger moved on a slot's pad. GC reports ±1 positions and snaps to exactly
|
||||
/// (0, 0) on lift — treated as the lift signal (a real finger landing on the precise center
|
||||
/// momentarily reads as a lift; harmless for a 1-in-65k coincidence).
|
||||
private func touch(finger: Int, x: Float, y: Float) {
|
||||
private func touch(_ slot: Slot, finger: Int, x: Float, y: Float) {
|
||||
guard !suspended else { return }
|
||||
let lifted = x == 0 && y == 0
|
||||
if lifted {
|
||||
if fingerActive[finger] {
|
||||
fingerActive[finger] = false
|
||||
connection.sendTouchpad(finger: UInt8(finger), active: false, x: 0, y: 0)
|
||||
if slot.fingerActive[finger] {
|
||||
slot.fingerActive[finger] = false
|
||||
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: false, x: 0, y: 0)
|
||||
}
|
||||
return
|
||||
}
|
||||
fingerActive[finger] = true
|
||||
slot.fingerActive[finger] = true
|
||||
let w = GamepadWire.touchpad(x: x, y: y)
|
||||
connection.sendTouchpad(finger: UInt8(finger), active: true, x: w.x, y: w.y)
|
||||
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: true, x: w.x, y: w.y)
|
||||
}
|
||||
|
||||
private func forwardMotion(_ m: GCMotion) {
|
||||
private func forwardMotion(_ slot: Slot, _ m: GCMotion) {
|
||||
guard !suspended else { return }
|
||||
let now = DispatchTime.now().uptimeNanoseconds
|
||||
guard now &- lastMotionNs >= Self.motionIntervalNs else { return }
|
||||
lastMotionNs = now
|
||||
guard now &- slot.lastMotionNs >= Self.motionIntervalNs else { return }
|
||||
slot.lastMotionNs = now
|
||||
// Total acceleration in g: gravity + user when split, else the raw vector.
|
||||
let ax: Float
|
||||
let ay: Float
|
||||
@@ -283,6 +386,7 @@ public final class GamepadCapture {
|
||||
let gs = GamepadWire.gyroLSBPerRadS
|
||||
let as_ = GamepadWire.accelLSBPerG
|
||||
connection.sendMotion(
|
||||
pad: UInt8(slot.pad),
|
||||
gyro: (
|
||||
GamepadWire.motionRaw(Float(m.rotationRate.x), scale: gs),
|
||||
GamepadWire.motionRaw(Float(m.rotationRate.y), scale: gs),
|
||||
@@ -295,20 +399,49 @@ public final class GamepadCapture {
|
||||
))
|
||||
}
|
||||
|
||||
/// Unwind everything held on the wire: button-ups, neutral axes, lifted fingers. The
|
||||
/// host's virtual pad returns to rest instead of running with the last state.
|
||||
private func releaseAll() {
|
||||
for bit in GamepadWire.allButtons where buttons & bit != 0 {
|
||||
connection.send(.gamepadButton(bit, down: false, pad: 0))
|
||||
}
|
||||
buttons = 0
|
||||
for (i, v) in axes.enumerated() where v != 0 {
|
||||
connection.send(.gamepadAxis(UInt32(i), value: 0, pad: 0))
|
||||
axes[i] = 0
|
||||
}
|
||||
for (f, active) in fingerActive.enumerated() where active {
|
||||
connection.sendTouchpad(finger: UInt8(f), active: false, x: 0, y: 0)
|
||||
fingerActive[f] = false
|
||||
/// Arm the disconnect timer when ANY forwarded pad holds the full escape chord, disarm the
|
||||
/// moment none do — a release, or the holding pad unplugged (pf-client-core's `chord_held` is
|
||||
/// likewise any-slot). GC events only arrive on state CHANGES, so a held chord needs the timer:
|
||||
/// the handler won't fire again until something moves.
|
||||
private func updateEscapeChord() {
|
||||
let held = slots.contains { $0.buttons & Self.escapeChord == Self.escapeChord }
|
||||
if held, chordTimer == nil {
|
||||
let timer = Timer(timeInterval: Self.disconnectHold, repeats: false) { [weak self] _ in
|
||||
Task { @MainActor in self?.onDisconnectRequest?() }
|
||||
}
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
chordTimer = timer
|
||||
} else if !held, chordTimer != nil {
|
||||
chordTimer?.invalidate()
|
||||
chordTimer = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Unwind everything a slot holds on the wire: button-ups, neutral axes, lifted fingers. The
|
||||
/// host's virtual pad returns to rest instead of running with the last state. Wire events only
|
||||
/// (no GC calls) — safe against an already-removed device. Does NOT close the slot or send
|
||||
/// GamepadRemove (that's `closeSlot`).
|
||||
private func flush(_ slot: Slot) {
|
||||
for bit in GamepadWire.allButtons where slot.buttons & bit != 0 {
|
||||
connection.send(.gamepadButton(bit, down: false, pad: slot.pad))
|
||||
}
|
||||
slot.buttons = 0
|
||||
for (i, v) in slot.axes.enumerated() where v != 0 {
|
||||
connection.send(.gamepadAxis(UInt32(i), value: 0, pad: slot.pad))
|
||||
slot.axes[i] = 0
|
||||
}
|
||||
for (f, active) in slot.fingerActive.enumerated() where active {
|
||||
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(f), active: false, x: 0, y: 0)
|
||||
slot.fingerActive[f] = false
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush every open slot's held state (app deactivation) — keeps the slots open (GC just stops
|
||||
/// delivering; resume re-syncs), disarms the escape chord. Distinct from `closeAllSlots`, which
|
||||
/// also sends GamepadRemove and detaches handlers.
|
||||
private func releaseAll() {
|
||||
chordTimer?.invalidate()
|
||||
chordTimer = nil
|
||||
for slot in slots { flush(slot) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
// Host→client gamepad feedback rendering: one drain thread polls the rumble (0xCA) and
|
||||
// HID-output (0xCD) planes and replays them on the active physical controller —
|
||||
// HID-output (0xCD) planes and replays each update on the forwarded physical controller it is
|
||||
// ADDRESSED TO by wire pad index —
|
||||
//
|
||||
// rumble → CHHapticEngine players (per-handle localities when the pad has them,
|
||||
// one combined engine otherwise),
|
||||
// one combined engine otherwise), a RumbleRenderer per pad,
|
||||
// lightbar → GCDeviceLight,
|
||||
// player LEDs → GCController.playerIndex (the DS bit patterns map to player 1–4),
|
||||
// trigger FX → DualSenseTriggerEffect.parse → GCDualSenseAdaptiveTrigger.
|
||||
//
|
||||
// Only pad 0 is rendered (exactly one controller is forwarded). HID-output traffic exists
|
||||
// only on PlayStation-pad sessions (a DualSense, or a DualShock 4 = lightbar only) — the
|
||||
// drain always polls both planes with short timeouts and never spins, so an Xbox session
|
||||
// just renders rumble. GameController profile mutation
|
||||
// happens on main; CHHapticEngine work on its own serial queue; the drain thread itself
|
||||
// touches neither. When GamepadManager switches the active controller mid-session, the
|
||||
// old pad is reset (triggers off, player index unset) and the last known feedback state
|
||||
// is replayed onto the new one.
|
||||
// Every forwarded controller gets a per-pad feedback slot (its RumbleRenderer + last light /
|
||||
// player-LED / trigger state) keyed on the same wire index GamepadCapture streams it on, so a
|
||||
// rumble the host aimed at pad 1 drives pad 1's actuator and nothing else. An update for a pad
|
||||
// with no live slot (one that just closed) is dropped. HID-output traffic exists only on
|
||||
// PlayStation-pad sessions (a DualSense, or a DualShock 4 = lightbar only); the drain always
|
||||
// polls both planes with short timeouts and never spins, so an Xbox pad just renders rumble.
|
||||
// GameController profile mutation happens on main; CHHapticEngine work on the renderer's serial
|
||||
// queue; the drain thread itself touches neither (it routes rumble to the pad's renderer under a
|
||||
// lock and hops HID to main). When a controller leaves the forwarded set the old pad is reset
|
||||
// (triggers off, player index unset) and its renderer silenced.
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
@@ -22,26 +25,40 @@ import GameController
|
||||
|
||||
public final class GamepadFeedback {
|
||||
private let connection: PunktfunkConnection
|
||||
private let manager: GamepadManager
|
||||
private let flag = StopFlag()
|
||||
private let drainDone = DispatchSemaphore(value: 0)
|
||||
private var drainStarted = false
|
||||
private let rumble = RumbleRenderer(policy: .session)
|
||||
private var activeSub: AnyCancellable?
|
||||
private var forwardedSub: AnyCancellable?
|
||||
|
||||
// Last applied feedback (main-actor) — replayed when the active controller changes.
|
||||
@MainActor private var target: GCController?
|
||||
@MainActor private var lastLight: (r: UInt8, g: UInt8, b: UInt8)?
|
||||
@MainActor private var lastPlayerBits: UInt8?
|
||||
@MainActor private var lastTrigger: [DualSenseTriggerEffect?] = [nil, nil]
|
||||
/// One forwarded controller's non-rumble feedback state (main-actor) — the GC target plus the
|
||||
/// last applied lightbar / player-LED / trigger, replayed if the controller on this pad swaps.
|
||||
@MainActor private final class Slot {
|
||||
var controller: GCController?
|
||||
var lastLight: (r: UInt8, g: UInt8, b: UInt8)?
|
||||
var lastPlayerBits: UInt8?
|
||||
var lastTrigger: [DualSenseTriggerEffect?] = [nil, nil]
|
||||
init(controller: GCController?) { self.controller = controller }
|
||||
}
|
||||
/// HID / lightbar / player-LED slots, keyed by wire pad index. Main-actor only.
|
||||
@MainActor private var slots: [UInt8: Slot] = [:]
|
||||
|
||||
/// Rumble renderers keyed by wire pad index, guarded by `routingLock` so the background drain
|
||||
/// thread can route an incoming envelope to the right pad's renderer while the main actor
|
||||
/// reconciles the set. RumbleRenderer serializes on its own queue, so calling `apply` from the
|
||||
/// drain thread is safe — only the map lookup needs the lock.
|
||||
private let routingLock = NSLock()
|
||||
private var rumbleByPad: [UInt8: RumbleRenderer] = [:]
|
||||
|
||||
public init(connection: PunktfunkConnection, manager: GamepadManager) {
|
||||
self.connection = connection
|
||||
self.manager = manager
|
||||
// Capture self weakly in the hop too, so the inner sink's weak capture isn't shadowing
|
||||
// an implicit strong one — and the subscription (stored on self) never retain-cycles.
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
self.activeSub = manager.$active.sink { [weak self] dc in
|
||||
MainActor.assumeIsolated { self?.retarget(dc?.controller) }
|
||||
self.forwardedSub = manager.$forwarded.sink { [weak self] list in
|
||||
MainActor.assumeIsolated { self?.reconcile(list) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,6 +84,38 @@ public final class GamepadFeedback {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring the per-pad feedback slots in line with the forwarded set: drop pads no longer
|
||||
/// forwarded (silence + release their renderer, reset their controller), add a slot +
|
||||
/// renderer for each new pad, and retarget a pad whose controller changed (a re-plug into the
|
||||
/// same freed index) — replaying its cached feedback onto the new device.
|
||||
@MainActor
|
||||
private func reconcile(_ forwarded: [GamepadManager.DiscoveredController]) {
|
||||
var want: [UInt8: GCController] = [:]
|
||||
for dc in forwarded {
|
||||
if let pad = manager.padIndex(for: dc) { want[pad] = dc.controller }
|
||||
}
|
||||
for (pad, slot) in slots where want[pad] == nil {
|
||||
reset(slot.controller)
|
||||
slots[pad] = nil
|
||||
let renderer = withRouting { rumbleByPad.removeValue(forKey: pad) }
|
||||
renderer?.stop()
|
||||
}
|
||||
for (pad, controller) in want {
|
||||
if let slot = slots[pad] {
|
||||
guard slot.controller !== controller else { continue }
|
||||
reset(slot.controller)
|
||||
slot.controller = controller
|
||||
withRouting { rumbleByPad[pad]?.retarget(controller) }
|
||||
replay(slot)
|
||||
} else {
|
||||
slots[pad] = Slot(controller: controller)
|
||||
let renderer = RumbleRenderer(policy: .session)
|
||||
renderer.retarget(controller)
|
||||
withRouting { rumbleByPad[pad] = renderer }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func start() {
|
||||
guard !drainStarted else { return }
|
||||
drainStarted = true
|
||||
@@ -74,7 +123,11 @@ public final class GamepadFeedback {
|
||||
// session — a DualSense or a DualShock 4 (lightbar only). Block briefly on it there and
|
||||
// let rumble own the wait elsewhere; on an Xbox session it stays nonblocking.
|
||||
let thread = Thread { [connection, flag, drainDone, weak self] in
|
||||
while !flag.isStopped {
|
||||
// Per-iteration autorelease pool: no runloop on this thread, and the haptics/HID
|
||||
// rendering below autoreleases ObjC temporaries. `false` = session over.
|
||||
var alive = true
|
||||
while alive, !flag.isStopped {
|
||||
alive = autoreleasepool { () -> Bool in
|
||||
do {
|
||||
// Poll the feedback planes NON-BLOCKING. A blocking poll (timeoutMs > 0) holds
|
||||
// the connection's shared feedback lock for its whole wait; the video pump drains
|
||||
@@ -84,19 +137,19 @@ public final class GamepadFeedback {
|
||||
// rumble/HID latency low while leaving the lock free between polls.
|
||||
//
|
||||
// Rumble is idempotent state, so drain the plane DRY and apply only the newest
|
||||
// level. The old one-datagram-per-cycle shape let a burst outpace the ~125 Hz
|
||||
// drain: levels rendered up to ~130 ms late through the core's 16-deep queue,
|
||||
// and its drop-newest overflow could shed a stop while stale nonzero states
|
||||
// queued ahead of it — buzzing until the host's next 500 ms refresh.
|
||||
var newest: (low: UInt16, high: UInt16)?
|
||||
// level PER PAD. The old one-datagram-per-cycle shape let a burst outpace the
|
||||
// ~125 Hz drain: levels rendered up to ~130 ms late through the core's 16-deep
|
||||
// queue, and its drop-newest overflow could shed a stop while stale nonzero
|
||||
// states queued ahead of it — buzzing until the host's next 500 ms refresh.
|
||||
var newestByPad: [UInt8: (low: UInt16, high: UInt16, ttl: UInt32)] = [:]
|
||||
var rumbleBurst = 0
|
||||
while rumbleBurst < 64, !flag.isStopped,
|
||||
let r = try connection.nextRumble(timeoutMs: 0) {
|
||||
if r.pad == 0 { newest = (r.low, r.high) }
|
||||
let r = try connection.nextRumble2(timeoutMs: 0) {
|
||||
newestByPad[UInt8(truncatingIfNeeded: r.pad)] = (r.low, r.high, r.ttlMs)
|
||||
rumbleBurst += 1
|
||||
}
|
||||
if let n = newest {
|
||||
self?.rumble.apply(low: n.low, high: n.high)
|
||||
for (pad, n) in newestByPad {
|
||||
self?.routeRumble(pad: pad, low: n.low, high: n.high, ttlMs: n.ttl)
|
||||
}
|
||||
// Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing
|
||||
// per-frame LED/trigger reports) can't spin here or block stop() past one cycle.
|
||||
@@ -106,12 +159,14 @@ public final class GamepadFeedback {
|
||||
self?.render(ev)
|
||||
burst += 1
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
break // .closed (or fatal) — the session is over
|
||||
return false // .closed (or fatal) — the session is over
|
||||
}
|
||||
}
|
||||
// ~8 ms poll cadence (≈125 Hz), slept OUTSIDE the feedback lock — low rumble/HID
|
||||
// latency without holding the lock the HDR-meta drain needs.
|
||||
if !flag.isStopped { Thread.sleep(forTimeInterval: 0.008) }
|
||||
if alive, !flag.isStopped { Thread.sleep(forTimeInterval: 0.008) }
|
||||
}
|
||||
drainDone.signal()
|
||||
}
|
||||
@@ -120,7 +175,7 @@ public final class GamepadFeedback {
|
||||
thread.start()
|
||||
}
|
||||
|
||||
/// Stop the drain and silence the motors. Blocks until the drain thread exits (≤ one
|
||||
/// Stop the drain and silence every pad's motors. Blocks until the drain thread exits (≤ one
|
||||
/// poll cycle) — call off the main actor, before `connection.close()`.
|
||||
public func stop() {
|
||||
flag.stop()
|
||||
@@ -128,17 +183,32 @@ public final class GamepadFeedback {
|
||||
drainDone.wait()
|
||||
drainStarted = false
|
||||
}
|
||||
rumble.stop()
|
||||
// Drop the retarget subscription and the dead session's cached feedback — a
|
||||
// controller change after teardown must not replay this session's triggers/LEDs.
|
||||
Task { @MainActor in
|
||||
self.activeSub = nil
|
||||
self.lastLight = nil
|
||||
self.lastPlayerBits = nil
|
||||
self.lastTrigger = [nil, nil]
|
||||
self.reset(self.target)
|
||||
self.target = nil
|
||||
let renderers = withRouting { () -> [RumbleRenderer] in
|
||||
let r = Array(rumbleByPad.values)
|
||||
rumbleByPad.removeAll()
|
||||
return r
|
||||
}
|
||||
for r in renderers { r.stop() }
|
||||
// Drop the subscription and every dead pad's cached feedback — a controller change after
|
||||
// teardown must not replay this session's triggers/LEDs.
|
||||
Task { @MainActor in
|
||||
self.forwardedSub = nil
|
||||
for slot in self.slots.values { self.reset(slot.controller) }
|
||||
self.slots.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
/// Route one rumble envelope to its pad's renderer (drain thread). An update for a pad with no
|
||||
/// live renderer — one that just left the forwarded set — is dropped.
|
||||
private func routeRumble(pad: UInt8, low: UInt16, high: UInt16, ttlMs: UInt32) {
|
||||
let renderer = withRouting { rumbleByPad[pad] }
|
||||
renderer?.apply(low: low, high: high, ttlMs: ttlMs)
|
||||
}
|
||||
|
||||
private func withRouting<R>(_ body: () -> R) -> R {
|
||||
routingLock.lock()
|
||||
defer { routingLock.unlock() }
|
||||
return body()
|
||||
}
|
||||
|
||||
private func render(_ ev: PunktfunkConnection.HidOutputEvent) {
|
||||
@@ -151,40 +221,37 @@ public final class GamepadFeedback {
|
||||
private func apply(_ ev: PunktfunkConnection.HidOutputEvent) {
|
||||
switch ev {
|
||||
case let .led(pad, r, g, b):
|
||||
guard pad == 0 else { return }
|
||||
lastLight = (r, g, b)
|
||||
target?.light?.color = GCColor(
|
||||
guard let slot = slots[pad] else { return }
|
||||
slot.lastLight = (r, g, b)
|
||||
slot.controller?.light?.color = GCColor(
|
||||
red: Float(r) / 255, green: Float(g) / 255, blue: Float(b) / 255)
|
||||
case let .playerLEDs(pad, bits):
|
||||
guard pad == 0 else { return }
|
||||
lastPlayerBits = bits
|
||||
target?.playerIndex = Self.playerIndex(forBits: bits)
|
||||
guard let slot = slots[pad] else { return }
|
||||
slot.lastPlayerBits = bits
|
||||
slot.controller?.playerIndex = Self.playerIndex(forBits: bits)
|
||||
case let .triggerEffect(pad, which, effect):
|
||||
guard pad == 0, which < 2 else { return }
|
||||
guard which < 2, let slot = slots[pad] else { return }
|
||||
let parsed = DualSenseTriggerEffect.parse(effect)
|
||||
lastTrigger[Int(which)] = parsed
|
||||
if let trigger = adaptiveTrigger(which) {
|
||||
slot.lastTrigger[Int(which)] = parsed
|
||||
if let trigger = adaptiveTrigger(slot.controller, which) {
|
||||
parsed.apply(to: trigger)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Replay a pad's cached feedback onto its (swapped-in) controller so a re-plug looks the same.
|
||||
@MainActor
|
||||
private func retarget(_ controller: GCController?) {
|
||||
guard controller !== target else { return }
|
||||
reset(target)
|
||||
target = controller
|
||||
rumble.retarget(controller)
|
||||
// Replay the session's feedback state so a swapped-in controller looks the same.
|
||||
if let (r, g, b) = lastLight {
|
||||
controller?.light?.color = GCColor(
|
||||
private func replay(_ slot: Slot) {
|
||||
if let (r, g, b) = slot.lastLight {
|
||||
slot.controller?.light?.color = GCColor(
|
||||
red: Float(r) / 255, green: Float(g) / 255, blue: Float(b) / 255)
|
||||
}
|
||||
if let bits = lastPlayerBits {
|
||||
controller?.playerIndex = Self.playerIndex(forBits: bits)
|
||||
if let bits = slot.lastPlayerBits {
|
||||
slot.controller?.playerIndex = Self.playerIndex(forBits: bits)
|
||||
}
|
||||
for which in 0..<2 {
|
||||
if let effect = lastTrigger[which], let trigger = adaptiveTrigger(UInt8(which)) {
|
||||
if let effect = slot.lastTrigger[which],
|
||||
let trigger = adaptiveTrigger(slot.controller, UInt8(which)) {
|
||||
effect.apply(to: trigger)
|
||||
}
|
||||
}
|
||||
@@ -201,8 +268,8 @@ public final class GamepadFeedback {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func adaptiveTrigger(_ which: UInt8) -> GCDualSenseAdaptiveTrigger? {
|
||||
guard let ds = target?.extendedGamepad as? GCDualSenseGamepad else { return nil }
|
||||
private func adaptiveTrigger(_ controller: GCController?, _ which: UInt8) -> GCDualSenseAdaptiveTrigger? {
|
||||
guard let ds = controller?.extendedGamepad as? GCDualSenseGamepad else { return nil }
|
||||
return which == 0 ? ds.leftTrigger : ds.rightTrigger
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
// Controller discovery + selection, app-lifetime. One GamepadManager (`.shared`) watches
|
||||
// GCController connect/disconnect from launch, so the Settings page shows live controller
|
||||
// state without a session, and the session components (GamepadCapture / GamepadFeedback)
|
||||
// follow `active` — exactly ONE physical controller is forwarded to the host, as pad 0.
|
||||
// follow `forwarded` — every forwarded controller is streamed to the host, each on its own
|
||||
// wire pad index (pf-client-core parity; up to `GamepadWire.maxPads`).
|
||||
//
|
||||
// Selection: the user can pin a controller in Settings (persisted under
|
||||
// DefaultsKey.gamepadID); with no pin — or the pinned one absent — the most recently
|
||||
// connected extended gamepad wins. GCController has no stable hardware serial, so the pin
|
||||
// is a fingerprint of vendorName|productCategory (+ a connect-order suffix for twins);
|
||||
// identical twin controllers may swap a pin across reconnects, which the Settings footer
|
||||
// documents.
|
||||
// Selection (mirrors pf-client-core's `forwarded_ids` + slot model): with no pin, EVERY
|
||||
// extended controller is forwarded — each assigned a stable lowest-free pad index held for
|
||||
// its forwarded lifetime, so a disconnect frees only its own index and never renumbers the
|
||||
// others. A pin (Settings, persisted under DefaultsKey.gamepadID) forwards ONLY that one pad
|
||||
// — an explicit single-player choice. `active` stays the single "primary" pad (the pinned
|
||||
// one, else the most recently connected extended gamepad) that the Settings / launcher / menu
|
||||
// UI reads. GCController has no stable hardware serial, so the pin is a fingerprint of
|
||||
// vendorName|productCategory (+ a connect-order suffix for twins); identical twin controllers
|
||||
// may swap a pin across reconnects, which the Settings footer documents.
|
||||
//
|
||||
// A singleton (not a SwiftUI environment object) because macOS shows Settings in its own
|
||||
// `Settings{}` scene — there is no common ancestor view to inject from.
|
||||
@@ -60,9 +64,23 @@ public final class GamepadManager: ObservableObject {
|
||||
/// Every detected controller, in connect order (Settings lists these).
|
||||
@Published public private(set) var controllers: [DiscoveredController] = []
|
||||
|
||||
/// The one controller forwarded to the host (pad 0); nil when none qualifies.
|
||||
/// The single "primary" controller — the pinned one, else the most recently connected
|
||||
/// extended gamepad; nil when none qualifies. The Settings / launcher / menu UI and the
|
||||
/// connect-time `resolveType` read this; the streaming input path uses `forwarded`.
|
||||
@Published public private(set) var active: DiscoveredController?
|
||||
|
||||
/// The controllers forwarded to the host this session, in wire-pad-index preference order
|
||||
/// (pf-client-core's `forwarded_ids`): a pin forwards ONLY the pinned pad; Automatic forwards
|
||||
/// every extended controller. GamepadCapture opens a slot per entry and GamepadFeedback routes
|
||||
/// feedback back to it, each on the index from `padIndex(for:)`.
|
||||
@Published public private(set) var forwarded: [DiscoveredController] = []
|
||||
|
||||
/// Stable wire pad index (0..<`GamepadWire.maxPads`) per forwarded controller, keyed by
|
||||
/// GCController identity. Lowest-free, held while the controller stays forwarded — a
|
||||
/// disconnect frees only its own index so the others never renumber (pf-client-core's
|
||||
/// `lowest_free_index`). Recomputed by `assignPadIndices` whenever `forwarded` changes.
|
||||
private var padIndexByController: [ObjectIdentifier: UInt8] = [:]
|
||||
|
||||
/// The user's pinned controller fingerprint ("" = automatic). Persisted; updating it
|
||||
/// reselects immediately, so a Settings Picker can bind straight to this.
|
||||
@Published public var preferredID: String {
|
||||
@@ -159,7 +177,52 @@ public final class GamepadManager: ObservableObject {
|
||||
let candidates = controllers.filter(\.isExtended)
|
||||
// The pin wins when present; otherwise the most recently connected extended pad
|
||||
// (list is in connect order). A stale pin falls back to automatic.
|
||||
active = candidates.last { $0.id == preferredID } ?? candidates.last
|
||||
let pinned = candidates.last { $0.id == preferredID }
|
||||
active = pinned ?? candidates.last
|
||||
// Forwarded set (pf-client-core's `forwarded_ids`): a pin forwards ONLY the pinned pad
|
||||
// (explicit single-player); Automatic forwards every extended controller in connect order
|
||||
// (oldest→newest), so a game's player numbers are stable across hot-plug churn.
|
||||
let next = pinned.map { [$0] } ?? candidates
|
||||
// Update the pad-index assignment BEFORE publishing `forwarded`: @Published emits in
|
||||
// `willSet`, so GamepadCapture/GamepadFeedback reconcile against `padIndex(for:)` the
|
||||
// instant this assignment lands — a stale map here would skip a newly-forwarded pad.
|
||||
assignPadIndices(for: next)
|
||||
forwarded = next
|
||||
}
|
||||
|
||||
/// Assign each forwarded controller a stable wire pad index (lowest-free, held while it stays
|
||||
/// forwarded) — mirrors pf-client-core's slot model, where a disconnect frees only its own
|
||||
/// index and the others keep theirs. A controller already holding an index keeps it across the
|
||||
/// churn; a slot beyond `GamepadWire.maxPads` goes unassigned (that pad is not forwarded).
|
||||
private func assignPadIndices(for next: [DiscoveredController]) {
|
||||
let live = Set(next.map { ObjectIdentifier($0.controller) })
|
||||
padIndexByController = padIndexByController.filter { live.contains($0.key) }
|
||||
for dc in next {
|
||||
let key = ObjectIdentifier(dc.controller)
|
||||
guard padIndexByController[key] == nil,
|
||||
let free = Self.lowestFreeIndex(Set(padIndexByController.values)) else { continue }
|
||||
padIndexByController[key] = free
|
||||
}
|
||||
}
|
||||
|
||||
/// The lowest wire pad index not already taken, or nil when all `GamepadWire.maxPads` are in
|
||||
/// use (pf-client-core's `lowest_free_index`).
|
||||
private static func lowestFreeIndex(_ taken: Set<UInt8>) -> UInt8? {
|
||||
(0..<UInt8(GamepadWire.maxPads)).first { !taken.contains($0) }
|
||||
}
|
||||
|
||||
/// The wire pad index a forwarded controller streams on, or nil when it isn't forwarded.
|
||||
public func padIndex(for controller: DiscoveredController) -> UInt8? {
|
||||
padIndexByController[ObjectIdentifier(controller.controller)]
|
||||
}
|
||||
|
||||
/// Drop every pad-index assignment and recompute from the current forwarded set — called when
|
||||
/// a streaming session begins so the assignment starts fresh (a controller pinned before the
|
||||
/// session forwards as pad 0, not whatever index it held for the Settings list). pf-client-core
|
||||
/// assigns indices at slot-open time; this reproduces that session-scoped start.
|
||||
public func resetForwardingAssignment() {
|
||||
padIndexByController.removeAll()
|
||||
reselect()
|
||||
}
|
||||
|
||||
private static func describe(_ c: GCController, id: String) -> DiscoveredController {
|
||||
|
||||
@@ -140,7 +140,9 @@ public final class GamepadMenuInput {
|
||||
let stick = gamepad.leftThumbstick
|
||||
let x = stick.xAxis.value
|
||||
let y = stick.yAxis.value
|
||||
if abs(x) > abs(y), abs(x) > deadzone {
|
||||
// Horizontal wins an exact |x| == |y| diagonal tie (>=), matching the SDL core and Android
|
||||
// nav so a perfect 45° push resolves to the same direction on every client.
|
||||
if abs(x) >= abs(y), abs(x) > deadzone {
|
||||
return x > 0 ? .right : .left
|
||||
} else if abs(y) > deadzone {
|
||||
return y > 0 ? .up : .down
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
// The gamepad wire contract shared by capture (GamepadCapture), feedback (GamepadFeedback),
|
||||
// and the tests — button bits, axis ids, and the touchpad/motion unit conversions.
|
||||
// and the tests — the pad count, button bits, axis ids, and the touchpad/motion unit conversions.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The gamepad wire contract (mirrors `punktfunk_core::input::gamepad`).
|
||||
public enum GamepadWire {
|
||||
/// Gamepads addressable on the wire — the pad index rides the low byte of `flags` on every
|
||||
/// per-pad event, 0...15 (`punktfunk_core::input::MAX_PADS`).
|
||||
public static let maxPads: Int = 16
|
||||
|
||||
public static let dpadUp: UInt32 = 0x0001
|
||||
public static let dpadDown: UInt32 = 0x0002
|
||||
public static let dpadLeft: UInt32 = 0x0004
|
||||
@@ -22,11 +26,27 @@ public enum GamepadWire {
|
||||
public static let y: UInt32 = 0x8000
|
||||
/// DualSense touchpad click (Moonlight's extended-button bit position).
|
||||
public static let touchpadClick: UInt32 = 0x10_0000
|
||||
/// Misc / capture button — Xbox-Series Share, DualSense Create, Steam-Deck quick-access
|
||||
/// (Moonlight's extended-button namespace; `input::gamepad::BTN_MISC1`). The host routes it to
|
||||
/// the DualSense mute / Steam quick-access menu; a plain virtual xpad has no such button.
|
||||
public static let misc1: UInt32 = 0x0020_0000
|
||||
/// Back-grip paddles (Xbox Elite P1–P4 / DualSense Edge / Steam-Deck L4-L5-R4-R5), in
|
||||
/// Moonlight's extended-button namespace (`input::gamepad::BTN_PADDLE1..4`, R4/L4/R5/L5).
|
||||
/// Defined for wire completeness and pinned by the tests; `GamepadCapture.buttonMask` does not
|
||||
/// read them yet — the GameController `paddleButton1..4` ↔ BTN_PADDLE physical correspondence
|
||||
/// needs confirming on a real Elite pad first (see the gamepad-review-cleanup plan, G22), so
|
||||
/// they are intentionally absent from `allButtons` until that forwarding lands.
|
||||
public static let paddle1: UInt32 = 0x0001_0000
|
||||
public static let paddle2: UInt32 = 0x0002_0000
|
||||
public static let paddle3: UInt32 = 0x0004_0000
|
||||
public static let paddle4: UInt32 = 0x0008_0000
|
||||
|
||||
/// Every button `buttonMask`/`sendGuide` can set — walked by `sync`'s transition diff and by
|
||||
/// `flush` on release. Paddles are excluded until their capture lands (see above).
|
||||
public static let allButtons: [UInt32] = [
|
||||
dpadUp, dpadDown, dpadLeft, dpadRight, start, back,
|
||||
leftStickClick, rightStickClick, leftShoulder, rightShoulder, guide,
|
||||
a, b, x, y, touchpadClick,
|
||||
a, b, x, y, touchpadClick, misc1,
|
||||
]
|
||||
|
||||
public static let axisLSX: UInt32 = 0
|
||||
|
||||
@@ -23,10 +23,23 @@ enum RumbleTuning {
|
||||
/// the churn that lost stops inside CoreHaptics. Newest level wins when the window opens;
|
||||
/// zero is never throttled.
|
||||
static let minRebakeSeconds: TimeInterval = 0.025
|
||||
/// Session watchdog: silence the motors when no wire command arrived for this long. The
|
||||
/// host re-sends the current rumble state every 500 ms as its loss heal, so this trips only
|
||||
/// after 3 consecutive refreshes vanished — i.e. the channel or host died while audible.
|
||||
/// Session watchdog: silence the motors when no wire command arrived for this long. This is
|
||||
/// the **legacy-host fallback only** — an old host sends no self-termination lease, so its
|
||||
/// periodic re-send (every 500 ms) is the sole liveness signal and 3 vanished refreshes means
|
||||
/// the channel or host died while audible. A v2 host instead supplies a per-command TTL (see
|
||||
/// [`leaseSeconds`]); that deadline supersedes this watchdog.
|
||||
static let sessionStaleSeconds: TimeInterval = 1.6
|
||||
|
||||
/// The legacy no-lease sentinel a v2 `ttl_ms` carries for an old host (mirrors the C ABI's
|
||||
/// `PUNKTFUNK_RUMBLE_NO_TTL`). `UInt32.max` by construction.
|
||||
static let noTTL: UInt32 = .max
|
||||
|
||||
/// Interpret a wire TTL (ms) from a rumble update: `nil` for the legacy no-lease sentinel
|
||||
/// ([`noTTL`]) — the renderer falls back to [`sessionStaleSeconds`] — else the self-termination
|
||||
/// lease in seconds (render the level for at most this long unless the host renews it).
|
||||
static func leaseSeconds(ttlMs: UInt32) -> TimeInterval? {
|
||||
ttlMs == noTTL ? nil : TimeInterval(ttlMs) / 1000
|
||||
}
|
||||
/// Levels closer than this (≈0.4 % of full scale) are the same level — an identical host
|
||||
/// refresh must never rebuild a player.
|
||||
static let levelEpsilon: Float = 1.0 / 256.0
|
||||
@@ -139,6 +152,10 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
/// Wire-truth target (raw wire units) and when it was last confirmed by any command.
|
||||
private var target: (low: UInt16, high: UInt16) = (0, 0)
|
||||
private var lastCommand = DispatchTime(uptimeNanoseconds: 0)
|
||||
/// The v2 envelope lease: the active level is authorized until here unless the host renews it
|
||||
/// (`tick` silences at the deadline). `nil` against a legacy host (no lease — the
|
||||
/// `sessionStaleSeconds` watchdog is the backstop) and while silent.
|
||||
private var envelopeDeadline: DispatchTime?
|
||||
/// Runs while anything is (or should be) audible: staleness watchdog, segment re-arm,
|
||||
/// throttled-level catch-up, engine rebuild after a reset, HID keepalive. Nil while silent,
|
||||
/// so an idle controller costs no timer wakeups and no radio traffic.
|
||||
@@ -212,13 +229,23 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the wire-truth target. Called with every 0xCA state the host sends — level changes
|
||||
/// AND the 500 ms refreshes; refreshes stamp liveness for the watchdog and are otherwise
|
||||
/// free (invariant 2).
|
||||
func apply(low lowAmp: UInt16, high highAmp: UInt16) {
|
||||
/// Set the wire-truth target. Called with every 0xCA state the host sends — level changes AND
|
||||
/// renewals (v2) / 500 ms refreshes (legacy); both stamp liveness and, for v2, refresh the
|
||||
/// self-termination deadline. `ttlMs` is the envelope lease in ms, or [`RumbleTuning.noTTL`]
|
||||
/// against a legacy host (no lease → the staleness watchdog is the backstop). Renewals at an
|
||||
/// unchanged level extend the deadline before the idempotence guard, so a held rumble never
|
||||
/// lapses mid-effect.
|
||||
func apply(low lowAmp: UInt16, high highAmp: UInt16, ttlMs: UInt32 = RumbleTuning.noTTL) {
|
||||
queue.async {
|
||||
self.lastCommand = .now()
|
||||
let active = lowAmp != 0 || highAmp != 0
|
||||
// v2 lease: a nonzero level gets an explicit deadline; a stop or a legacy update clears
|
||||
// it. Set BEFORE the idempotence guard so an identical renewal still extends the lease.
|
||||
if let lease = RumbleTuning.leaseSeconds(ttlMs: ttlMs), active {
|
||||
self.envelopeDeadline = .now() + lease
|
||||
} else {
|
||||
self.envelopeDeadline = nil
|
||||
}
|
||||
if active != self.wasActive {
|
||||
self.wasActive = active
|
||||
log.debug(
|
||||
@@ -236,6 +263,7 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
self.ticker?.cancel()
|
||||
self.ticker = nil
|
||||
self.target = (0, 0)
|
||||
self.envelopeDeadline = nil
|
||||
self.wasActive = false
|
||||
self.teardown()
|
||||
self.closeHID()
|
||||
@@ -293,9 +321,18 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
|
||||
/// Watchdog + housekeeping heartbeat while audible.
|
||||
private func tick() {
|
||||
if let after = policy.staleAfter, target != (0, 0), seconds(since: lastCommand) > after {
|
||||
// The host refreshes rumble state every 500 ms; this much silence means the channel
|
||||
// (or host) died while a motor was on. A direct-connected pad would have been
|
||||
if let deadline = envelopeDeadline {
|
||||
// v2 host lease: silence the moment it lapses unrenewed. This firing in the wild is the
|
||||
// observable signature of a host that stopped renewing (a dropped stop, or a dead host)
|
||||
// — the whole point of the envelope model: the motor can't outlive the host's intent.
|
||||
if target != (0, 0), DispatchTime.now() >= deadline {
|
||||
log.warning("rumble: envelope expired unrenewed — silencing")
|
||||
target = (0, 0)
|
||||
envelopeDeadline = nil
|
||||
}
|
||||
} else if let after = policy.staleAfter, target != (0, 0), seconds(since: lastCommand) > after {
|
||||
// Legacy host (no lease): it re-sends state every 500 ms, so this much silence means the
|
||||
// channel (or host) died while a motor was on. A direct-connected pad would have been
|
||||
// stopped by its game long ago — force the same outcome.
|
||||
log.warning(
|
||||
"rumble: no wire refresh for \(after, format: .fixed(precision: 1), privacy: .public)s — auto-silencing")
|
||||
|
||||
@@ -85,6 +85,12 @@ public final class InputCapture {
|
||||
/// its Esc suppression need it in both states).
|
||||
private var cmdKeysDown: Set<UInt32> = []
|
||||
|
||||
/// Physical Control/Option/Shift keys currently held (Windows VKs, both L/R sides). iPad only:
|
||||
/// the ⌃⌥⇧Q release chord is recognized from the HID stream here (iOS has no NSEvent monitor,
|
||||
/// like the ⌘⎋ toggle), so it needs the live modifier state — tracked in both forwarding states,
|
||||
/// exactly like `cmdKeysDown`, and flushed by `releaseAll` when GC delivery stops.
|
||||
private var chordModifiersDown: Set<UInt32> = []
|
||||
|
||||
/// While true, mouse/keyboard flow to the host and key NSEvents are swallowed
|
||||
/// locally; while false the user is interacting with the local UI (dragging the
|
||||
/// window, clicking the HUD) and nothing is forwarded. Main-queue only.
|
||||
@@ -113,11 +119,26 @@ public final class InputCapture {
|
||||
/// keyDown monitor only WHILE FORWARDING — that's the state in which the app's menu (which
|
||||
/// carries the same key equivalents for discoverability) can't see them, so the monitor is the
|
||||
/// captured-state delivery path; released, the events pass through and the menu handles them.
|
||||
/// ⌃⌥⇧Q releases the captured mouse/keyboard; ⌃⌥⇧D disconnects; ⌃⌥⇧S toggles the stats
|
||||
/// overlay. Main queue.
|
||||
/// ⌃⌥⇧Q releases the captured mouse/keyboard; ⌃⌥⇧D disconnects; ⌃⌥⇧S cycles the stats
|
||||
/// overlay tier (off → compact → normal → detailed). Main queue.
|
||||
public var onReleaseCapture: (() -> Void)?
|
||||
public var onDisconnect: (() -> Void)?
|
||||
public var onToggleStats: (() -> Void)?
|
||||
public var onCycleStats: (() -> Void)?
|
||||
|
||||
#if os(iOS)
|
||||
/// Windows VKs of the three modifier classes in the ⌃⌥⇧Q release chord, both L/R sides:
|
||||
/// control (0xA2/0xA3), option (0xA4/0xA5), shift (0xA0/0xA1). Used to sift the HID key stream.
|
||||
private static let chordModifierVKs: Set<UInt32> = [0xA2, 0xA3, 0xA4, 0xA5, 0xA0, 0xA1]
|
||||
|
||||
/// Whether Control AND Option AND Shift are all currently held (either side of each counts) —
|
||||
/// the modifier precondition for the iPad ⌃⌥⇧Q release chord.
|
||||
private var hasReleaseChordModifiers: Bool {
|
||||
let m = chordModifiersDown
|
||||
return (m.contains(0xA2) || m.contains(0xA3)) // control
|
||||
&& (m.contains(0xA4) || m.contains(0xA5)) // option
|
||||
&& (m.contains(0xA0) || m.contains(0xA1)) // shift
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Fired when a newer InputCapture takes the process-global GC handler slots (the
|
||||
/// singletons hold ONE handler each): the preempted owner must drop its capture
|
||||
@@ -246,7 +267,7 @@ public final class InputCapture {
|
||||
return nil
|
||||
case 1 /* S */:
|
||||
self.suppressedVK = 0x53
|
||||
self.onToggleStats?()
|
||||
self.onCycleStats?()
|
||||
return nil
|
||||
default:
|
||||
break
|
||||
@@ -294,6 +315,7 @@ public final class InputCapture {
|
||||
/// in another app would otherwise stay "held" here forever — hijacking Esc).
|
||||
private func releaseAll() {
|
||||
cmdKeysDown.removeAll()
|
||||
chordModifiersDown.removeAll()
|
||||
suppressedVK = nil
|
||||
for vk in pressedVKs {
|
||||
connection.send(.key(vk, down: false))
|
||||
@@ -576,6 +598,13 @@ public final class InputCapture {
|
||||
self.cmdKeysDown.remove(vk)
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
// Track Control/Option/Shift for the ⌃⌥⇧Q release chord below — in both forwarding
|
||||
// states (like `cmdKeysDown`) so a modifier held before capture engaged still counts.
|
||||
if Self.chordModifierVKs.contains(vk) {
|
||||
if pressed { self.chordModifiersDown.insert(vk) } else { self.chordModifiersDown.remove(vk) }
|
||||
}
|
||||
#endif
|
||||
// The ⌘⎋ toggle's Esc — checked before the forwarding gate, because in the
|
||||
// engage direction forwarding is already true when this fires.
|
||||
if vk == self.suppressedVK {
|
||||
@@ -592,6 +621,18 @@ public final class InputCapture {
|
||||
}
|
||||
#endif
|
||||
guard self.forwarding else { return }
|
||||
#if os(iOS)
|
||||
// ⌃⌥⇧Q releases the captured mouse/keyboard (cross-client parity — the same combo the
|
||||
// macOS keyDown monitor handles). Recognized only while forwarding (nothing to release
|
||||
// otherwise). The Q is latched (`suppressedVK`) so its keyUp can't type into the host;
|
||||
// the ⌃⌥⇧ modifiers were forwarded as they went down and are flushed by the release
|
||||
// path (setCaptured(false) → releaseAll). VK 0x51 is layout-independent (physical Q).
|
||||
if pressed, vk == 0x51, self.hasReleaseChordModifiers {
|
||||
self.suppressedVK = 0x51
|
||||
self.onReleaseCapture?()
|
||||
return
|
||||
}
|
||||
#endif
|
||||
// Release direction of the toggle: GC's Esc-down can beat the NSEvent
|
||||
// monitor — never type Esc into the host while ⌘ is held (⌘⎋ is reserved).
|
||||
if vk == 0x1B, !self.cmdKeysDown.isEmpty {
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
// The Siri Remote as a pointing device during a tvOS streaming session — the remote's touch
|
||||
// surface drives the HOST cursor (relative deltas, like a laptop trackpad), a surface press
|
||||
// clicks (left button), and Play/Pause right-clicks. It also owns the remote's DELIBERATE
|
||||
// session exit: hold Back/Menu ≥ `disconnectHold`. A short Back press does nothing — the
|
||||
// UIKit menu press it also generates is swallowed by ContentView's session branch, so neither
|
||||
// a trackpad fumble nor a game-controller B press can end the session (the pad's exit is the
|
||||
// L1+R1+Start+Select chord in GamepadCapture).
|
||||
//
|
||||
// The remote is read through GameController as a GCMicroGamepad with
|
||||
// `reportsAbsoluteDpadValues = true`: the dpad axes then report the finger's ABSOLUTE position
|
||||
// on the surface (±1, +y up) while touched, and snap to exactly (0, 0) on lift. Successive
|
||||
// positions are differenced into relative mouse deltas; the exact-zero snap is treated as a
|
||||
// lift (a real touch at the mathematical centre is measure-zero, and one dropped delta there
|
||||
// is imperceptible). Handlers (not a poll) — the same in-session delivery GamepadCapture
|
||||
// relies on.
|
||||
//
|
||||
// Lifecycle mirrors GamepadCapture: started by SessionModel when streaming begins (never
|
||||
// during the trust prompt), stopped on disconnect; held buttons are released on stop so the
|
||||
// host never keeps a stuck click.
|
||||
|
||||
#if os(tvOS)
|
||||
import Foundation
|
||||
import GameController
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
public final class SiriRemotePointer {
|
||||
private let connection: PunktfunkConnection
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
private var bound: GCController?
|
||||
/// Finger position (±1 axes) at the last dpad callback while touched; nil = lifted.
|
||||
private var lastTouch: (x: Float, y: Float)?
|
||||
/// Wire buttons currently held (1 = left, 3 = right) — released on stop/unbind.
|
||||
private var heldButtons: Set<UInt32> = []
|
||||
/// When Back/Menu went down; a release after `disconnectHold` fires the exit.
|
||||
private var menuDownAt: Date?
|
||||
|
||||
/// Hold Back/Menu at least this long (then release) to end the session. Shorter than the
|
||||
/// controller chord's 1.5 s — the remote has no way to trip this during gameplay.
|
||||
private static let disconnectHold: TimeInterval = 1.0
|
||||
/// A full edge-to-edge swipe moves the host cursor about this many pixels. The surface is
|
||||
/// small; two comfortable swipes should cross a 1080p desktop.
|
||||
private static let pointerScale: Float = 1100
|
||||
/// Largest single-callback finger travel accepted as real motion (surface units; the axes
|
||||
/// span ±1, so 0.4 ≈ a fifth of the pad). On RELEASE the hardware slides the reported
|
||||
/// position back to (0, 0) through intermediate callbacks — naive differencing turns that
|
||||
/// tail into reverse deltas that RETRACE the whole swipe, so the cursor springs back to its
|
||||
/// anchor and the pointer feels absolute. Real finger motion arrives as many small steps
|
||||
/// (even a fast flick stays well under this per callback); the release tail arrives as one
|
||||
/// or two huge jumps — discard those (the anchor still follows, so nothing accumulates).
|
||||
private static let maxStep: Float = 0.4
|
||||
|
||||
/// Fired ON MAIN after Back/Menu was held ≥ `disconnectHold` and released.
|
||||
public var onDisconnectRequest: (() -> Void)?
|
||||
|
||||
public init(connection: PunktfunkConnection) {
|
||||
self.connection = connection
|
||||
}
|
||||
|
||||
public func start() {
|
||||
observers.append(NotificationCenter.default.addObserver(
|
||||
forName: .GCControllerDidConnect, object: nil, queue: .main
|
||||
) { [weak self] _ in
|
||||
MainActor.assumeIsolated { self?.rebind() }
|
||||
})
|
||||
observers.append(NotificationCenter.default.addObserver(
|
||||
forName: .GCControllerDidDisconnect, object: nil, queue: .main
|
||||
) { [weak self] _ in
|
||||
MainActor.assumeIsolated { self?.rebind() }
|
||||
})
|
||||
rebind()
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
observers.forEach(NotificationCenter.default.removeObserver(_:))
|
||||
observers.removeAll()
|
||||
bind(nil)
|
||||
}
|
||||
|
||||
/// The Siri Remote is the non-extended controller carrying a microGamepad — a full gamepad
|
||||
/// (which also EXPOSES a microGamepad view of itself) must never be captured here, its
|
||||
/// buttons belong to GamepadCapture.
|
||||
private func rebind() {
|
||||
let remote = GCController.controllers().first {
|
||||
$0.extendedGamepad == nil && $0.microGamepad != nil
|
||||
}
|
||||
bind(remote)
|
||||
}
|
||||
|
||||
private func bind(_ controller: GCController?) {
|
||||
guard controller !== bound else { return }
|
||||
if let old = bound?.microGamepad {
|
||||
old.dpad.valueChangedHandler = nil
|
||||
old.buttonA.pressedChangedHandler = nil
|
||||
old.buttonX.pressedChangedHandler = nil
|
||||
old.buttonMenu.pressedChangedHandler = nil
|
||||
}
|
||||
releaseHeld()
|
||||
lastTouch = nil
|
||||
menuDownAt = nil
|
||||
bound = controller
|
||||
guard let micro = controller?.microGamepad else { return }
|
||||
|
||||
// Absolute finger position instead of the emulated dpad — the raw surface is what a
|
||||
// trackpad needs. Rotation stays off: the remote's natural grip is the coordinate frame.
|
||||
micro.reportsAbsoluteDpadValues = true
|
||||
micro.allowsRotation = false
|
||||
|
||||
micro.dpad.valueChangedHandler = { [weak self] _, x, y in
|
||||
MainActor.assumeIsolated { self?.touchMoved(x: x, y: y) }
|
||||
}
|
||||
// Surface click = left button; Play/Pause = right (the remote's only spare face button).
|
||||
micro.buttonA.pressedChangedHandler = { [weak self] _, _, pressed in
|
||||
MainActor.assumeIsolated { self?.setButton(1, down: pressed) }
|
||||
}
|
||||
micro.buttonX.pressedChangedHandler = { [weak self] _, _, pressed in
|
||||
MainActor.assumeIsolated { self?.setButton(3, down: pressed) }
|
||||
}
|
||||
micro.buttonMenu.pressedChangedHandler = { [weak self] _, _, pressed in
|
||||
MainActor.assumeIsolated { self?.menuChanged(pressed: pressed) }
|
||||
}
|
||||
}
|
||||
|
||||
private func touchMoved(x: Float, y: Float) {
|
||||
// Exact (0, 0) is the lift snap — drop the anchor so the next touch starts a fresh
|
||||
// gesture instead of a jump-delta from the old position.
|
||||
guard x != 0 || y != 0 else {
|
||||
lastTouch = nil
|
||||
return
|
||||
}
|
||||
defer { lastTouch = (x, y) }
|
||||
guard let last = lastTouch else { return } // first contact anchors, moves nothing
|
||||
let stepX = x - last.x
|
||||
let stepY = y - last.y
|
||||
// The release tail (and any tracking glitch) shows up as a single impossible jump —
|
||||
// see `maxStep`. Skip the emission; the deferred anchor update above still follows the
|
||||
// reported position, so the gesture cleanly re-anchors instead of retracing.
|
||||
guard abs(stepX) < Self.maxStep, abs(stepY) < Self.maxStep else { return }
|
||||
let dx = stepX * Self.pointerScale / 2 // axes span ±1 → full swipe = 2.0
|
||||
let dy = -stepY * Self.pointerScale / 2 // GC +y is up; mouse +y is down
|
||||
let ix = Int32(dx.rounded())
|
||||
let iy = Int32(dy.rounded())
|
||||
guard ix != 0 || iy != 0 else { return }
|
||||
connection.send(.mouseMove(dx: ix, dy: iy))
|
||||
}
|
||||
|
||||
private func setButton(_ button: UInt32, down: Bool) {
|
||||
if down { heldButtons.insert(button) } else { heldButtons.remove(button) }
|
||||
connection.send(.mouseButton(button, down: down))
|
||||
}
|
||||
|
||||
private func menuChanged(pressed: Bool) {
|
||||
if pressed {
|
||||
menuDownAt = Date()
|
||||
return
|
||||
}
|
||||
let heldFor = menuDownAt.map { Date().timeIntervalSince($0) } ?? 0
|
||||
menuDownAt = nil
|
||||
if heldFor >= Self.disconnectHold {
|
||||
onDisconnectRequest?()
|
||||
}
|
||||
// A short press is deliberately nothing: the accompanying UIKit menu press is swallowed
|
||||
// in ContentView, and forwarding it as a host key would make trackpad fumbles type.
|
||||
}
|
||||
|
||||
private func releaseHeld() {
|
||||
for button in heldButtons {
|
||||
connection.send(.mouseButton(button, down: false))
|
||||
}
|
||||
heldButtons.removeAll()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -2,7 +2,8 @@
|
||||
// touch gesture model (clients/android .../TouchInput.kt) so the two touch clients feel
|
||||
// identical. Two mouse modes share one gesture vocabulary — tap = left click · two-finger
|
||||
// tap = right click · two-finger drag = scroll · tap-then-press-and-drag = held left drag
|
||||
// (text selection / window moves) · three-finger tap = stats-HUD toggle:
|
||||
// (text selection / window moves) · three-finger tap = cycles the stats overlay tiers
|
||||
// (off → compact → normal → detailed, matching Android):
|
||||
//
|
||||
// * trackpad (default): the cursor STAYS PUT on touch-down and moves by the finger's
|
||||
// relative delta with mild acceleration — swipe to nudge, lift and re-swipe to walk it
|
||||
@@ -164,7 +165,7 @@ final class TouchMouse {
|
||||
} else if !moved {
|
||||
switch maxFingers {
|
||||
case 3...:
|
||||
Self.toggleHUD() // in-stream stats-overlay toggle, same as Android
|
||||
Self.cycleStats() // in-stream stats-tier cycle, same as Android
|
||||
case 2: // two-finger tap → right click
|
||||
send?(.mouseButton(Button.right, down: true))
|
||||
send?(.mouseButton(Button.right, down: false))
|
||||
@@ -274,12 +275,11 @@ final class TouchMouse {
|
||||
}
|
||||
}
|
||||
|
||||
/// Three-finger tap toggles the stats overlay — through the shared `hudEnabled` default,
|
||||
/// which the app's HUD views observe via @AppStorage (so this needs no wiring to them).
|
||||
private static func toggleHUD() {
|
||||
let defaults = UserDefaults.standard
|
||||
let on = defaults.object(forKey: DefaultsKey.hudEnabled) as? Bool ?? true
|
||||
defaults.set(!on, forKey: DefaultsKey.hudEnabled)
|
||||
/// Three-finger tap cycles the stats overlay tiers (off → compact → normal → detailed) —
|
||||
/// through the shared `statsVerbosity` default, which the app's HUD views observe via
|
||||
/// @AppStorage (so this needs no wiring to them). Same cycle as Android's triple-tap.
|
||||
private static func cycleStats() {
|
||||
StatsVerbosity.store(StatsVerbosity.current.next())
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -11,6 +11,15 @@ public enum DefaultsKey {
|
||||
public static let streamWidth = "punktfunk.width"
|
||||
public static let streamHeight = "punktfunk.height"
|
||||
public static let streamHz = "punktfunk.hz"
|
||||
/// Match-window resolution policy (design/midstream-resolution-resize.md D1/D2): when on, the
|
||||
/// stream mode FOLLOWS the session view — the connect asks for the view's pixel size and a
|
||||
/// mid-session resize (a windowed macOS window, an iPad Stage Manager / Split View scene)
|
||||
/// renegotiates the host's virtual display + encoder (`PunktfunkConnection.requestMode`), so a
|
||||
/// windowed session streams native-resolution pixels instead of scaling. Off (default): the
|
||||
/// explicit `streamWidth`/`streamHeight` are used and never auto-resized (a fullscreen session
|
||||
/// is native either way, so this degenerates to Auto-native there). Read per session by the
|
||||
/// stream views' `MatchWindowFollower`.
|
||||
public static let matchWindow = "punktfunk.matchWindow"
|
||||
public static let compositor = "punktfunk.compositor"
|
||||
public static let gamepadType = "punktfunk.gamepadType"
|
||||
public static let gamepadID = "punktfunk.gamepadID"
|
||||
@@ -30,6 +39,11 @@ public enum DefaultsKey {
|
||||
/// discrete channel, and the default N→stereo downmix grabs channels 0/1 (silence when the mic
|
||||
/// is higher up), so we fold to mono ourselves. Only meaningful for multi-channel devices.
|
||||
public static let micChannel = "punktfunk.micChannel"
|
||||
/// Which presenter runs a session: "stage2" (default — explicit decode + Metal present on
|
||||
/// frame arrival), "stage3" (same pipeline, glass-gated present pacing — the experimental
|
||||
/// low-display-latency A/B; see Stage2Pipeline's PresentPacing), or "stage1" (DEBUG-only
|
||||
/// system-layer fallback). Resolved once per session by SessionPresenter;
|
||||
/// PUNKTFUNK_PRESENTER=stage1|stage2|stage3 overrides it for A/B.
|
||||
public static let presenter = "punktfunk.presenter"
|
||||
/// macOS: V-Sync the stream's presents — each decoded frame flips on the next display vsync
|
||||
/// (evenly paced, no tearing under direct scanout) instead of as soon as the GPU finishes
|
||||
@@ -67,9 +81,15 @@ public enum DefaultsKey {
|
||||
public static let libraryEnabled = "punktfunk.libraryEnabled"
|
||||
/// macOS: take the window fullscreen while streaming and restore it on the host list. On by default.
|
||||
public static let fullscreenWhileStreaming = "punktfunk.fullscreenWhileStreaming"
|
||||
/// Show the streaming statistics overlay (mode/fps/throughput/latency). On by default; toggle
|
||||
/// while streaming with ⌃⌥⇧S (the cross-client Ctrl+Alt+Shift+S; macOS / hardware keyboard).
|
||||
/// LEGACY (pre-tiered overlay): the old boolean stats-overlay toggle. Kept ONLY as the
|
||||
/// migration fallback `StatsVerbosity.current` reads when `statsVerbosity` was never
|
||||
/// written (absent-or-true → .normal, explicit false → .off). Never written anymore.
|
||||
public static let hudEnabled = "punktfunk.hudEnabled"
|
||||
/// The statistics overlay tier — a `StatsVerbosity` raw value ("off"/"compact"/"normal"/
|
||||
/// "detailed"). Absent → migrated from the legacy `hudEnabled` bool (see above). Cycle it
|
||||
/// while streaming with ⌃⌥⇧S (the cross-client Ctrl+Alt+Shift+S; macOS / hardware
|
||||
/// keyboard) or a three-finger tap (touch), matching the Android client.
|
||||
public static let statsVerbosity = "punktfunk.statsVerbosity"
|
||||
/// Which corner the statistics overlay sits in — a `HUDPlacement` raw value
|
||||
/// ("topLeading"/"topTrailing"/"bottomLeading"/"bottomTrailing"). Default top-trailing.
|
||||
public static let hudPlacement = "punktfunk.hudPlacement"
|
||||
@@ -77,6 +97,12 @@ 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"
|
||||
/// Auto-wake on connect: when connecting to a saved host that isn't advertising on mDNS, fire
|
||||
/// Wake-on-LAN and, if the dial fails, wait for it to come back before retrying (the "Waking…"
|
||||
/// overlay). On by default. Turn off if a host that's already on just isn't seen on mDNS (a
|
||||
/// routed/VPN host), so connects go straight through instead of waiting out the wake timeout.
|
||||
/// The explicit "Wake Host" action stays available regardless. Read by ContentView.startSession.
|
||||
public static let autoWake = "punktfunk.autoWake"
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
|
||||
@@ -13,14 +13,14 @@ public enum Licenses {
|
||||
return text
|
||||
}
|
||||
|
||||
/// punktfunk's own license — MIT OR Apache-2.0, at your option.
|
||||
/// Punktfunk's own license — MIT OR Apache-2.0, at your option.
|
||||
public static var appLicense: String {
|
||||
let mit = resource("LICENSE-MIT")
|
||||
let apache = resource("LICENSE-APACHE")
|
||||
if mit.isEmpty && apache.isEmpty {
|
||||
return "punktfunk is licensed under MIT OR Apache-2.0, at your option."
|
||||
return "Punktfunk is licensed under MIT OR Apache-2.0, at your option."
|
||||
}
|
||||
return "punktfunk is licensed under MIT OR Apache-2.0, at your option.\n\n"
|
||||
return "Punktfunk is licensed under MIT OR Apache-2.0, at your option.\n\n"
|
||||
+ "================================ MIT ================================\n\n"
|
||||
+ mit
|
||||
+ "\n\n============================== Apache-2.0 ==============================\n\n"
|
||||
@@ -51,11 +51,27 @@ public enum Licenses {
|
||||
/// Acknowledgements screen renders these chunks in a `LazyVStack` (only on-screen chunks lay
|
||||
/// out, and no chunk is tall enough to clip). Split at line boundaries and joined with "\n";
|
||||
/// the inter-chunk break is the `LazyVStack` row boundary, so no text is lost. Computed once.
|
||||
public static let thirdPartyNoticesChunks: [String] = {
|
||||
let lines = thirdPartyNotices.split(separator: "\n", omittingEmptySubsequences: false)
|
||||
let chunkSize = 200
|
||||
return stride(from: 0, to: lines.count, by: chunkSize).map { start in
|
||||
lines[start..<min(start + chunkSize, lines.count)].joined(separator: "\n")
|
||||
public static let thirdPartyNoticesChunks: [String] = chunked(thirdPartyNotices)
|
||||
|
||||
/// Lines per chunk: tvOS reads much smaller chunks — focus is how tvOS scrolls, and each
|
||||
/// chunk is one focus stop, so a 200-line chunk (~5 screens tall there) would skip most of
|
||||
/// itself per step; ~24 lines ≈ two thirds of a screen reads like a page turn. Elsewhere the
|
||||
/// only constraint is the text-render height limit, so chunks stay big.
|
||||
private static var chunkLines: Int {
|
||||
#if os(tvOS)
|
||||
24
|
||||
#else
|
||||
200
|
||||
#endif
|
||||
}
|
||||
|
||||
/// `text` split at line boundaries into render/focus-sized chunks (joined with "\n"; the
|
||||
/// inter-chunk break is the caller's stack-row boundary, so no text is lost). tvOS pages
|
||||
/// focus through these — every license wall on the Acknowledgements screen renders this way.
|
||||
public static func chunked(_ text: String) -> [String] {
|
||||
let lines = text.split(separator: "\n", omittingEmptySubsequences: false)
|
||||
return stride(from: 0, to: lines.count, by: chunkLines).map { start in
|
||||
lines[start..<min(start + chunkLines, lines.count)].joined(separator: "\n")
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// The stats overlay's verbosity tier — a port of the Android client's 3-tier overlay semantics
|
||||
// so the two clients feel identical: Off → Compact (one-line pill) → Normal (headline stats) →
|
||||
// Detailed (plus the latency stage equation). Persisted under `DefaultsKey.statsVerbosity`;
|
||||
// the in-stream cycle surfaces (⌃⌥⇧S, the three-finger tap) advance it with
|
||||
// `store(current.next())`, and every UI reader observes the same default via @AppStorage.
|
||||
//
|
||||
// Lives in PunktfunkKit (not the app) because the kit's input paths (TouchMouse's three-finger
|
||||
// tap, InputCapture's captured-state ⌃⌥⇧S) cycle it directly.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// How much of the streaming statistics overlay to show. The raw values are stable on disk —
|
||||
/// rename the cases freely, never the strings.
|
||||
public enum StatsVerbosity: String, CaseIterable, Sendable {
|
||||
case off, compact, normal, detailed
|
||||
|
||||
/// User-facing tier label (Settings pickers, the gamepad settings row).
|
||||
public var label: String {
|
||||
switch self {
|
||||
case .off: return "Off"
|
||||
case .compact: return "Compact"
|
||||
case .normal: return "Normal"
|
||||
case .detailed: return "Detailed"
|
||||
}
|
||||
}
|
||||
|
||||
/// The next tier in the cycle: off → compact → normal → detailed → off (wrapping) —
|
||||
/// the ⌃⌥⇧S / three-finger-tap order, same as Android.
|
||||
public func next() -> StatsVerbosity {
|
||||
switch self {
|
||||
case .off: return .compact
|
||||
case .compact: return .normal
|
||||
case .normal: return .detailed
|
||||
case .detailed: return .off
|
||||
}
|
||||
}
|
||||
|
||||
/// The persisted tier. When `statsVerbosity` has never been written, migrates from the
|
||||
/// legacy `hudEnabled` bool the pre-tiered clients stored: absent-or-true → `.normal`
|
||||
/// (the old "on" look minus the equation lines), explicit false → `.off`.
|
||||
public static var current: StatsVerbosity {
|
||||
let defaults = UserDefaults.standard
|
||||
if let raw = defaults.string(forKey: DefaultsKey.statsVerbosity),
|
||||
let tier = StatsVerbosity(rawValue: raw) {
|
||||
return tier
|
||||
}
|
||||
if let legacy = defaults.object(forKey: DefaultsKey.hudEnabled) as? Bool, !legacy {
|
||||
return .off
|
||||
}
|
||||
return .normal
|
||||
}
|
||||
|
||||
/// Persist a tier (the cycle surfaces write through here; the Settings pickers write the
|
||||
/// same key via @AppStorage).
|
||||
public static func store(_ tier: StatsVerbosity) {
|
||||
UserDefaults.standard.set(tier.rawValue, forKey: DefaultsKey.statsVerbosity)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// The Y′CbCr→RGB conversion as three shader rows, ported from pf-client-core's `csc_rows`
|
||||
// (crates/pf-client-core/src/video.rs) — the ONE coefficient implementation every punktfunk
|
||||
// presenter derives its CSC from. Keep the two in LOCKSTEP: both carry the same unit tests
|
||||
// (CscRowsTests.swift ↔ the Rust `csc_rows` tests), and a coefficient change lands in both or
|
||||
// neither.
|
||||
//
|
||||
// Why this exists: the stage-2 Metal shaders used to hardcode BT.709 (SDR) / BT.2020 (HDR)
|
||||
// matrices, silently ignoring the stream's signaled matrix. A Linux host's RGB-input NVENC paths
|
||||
// signal BT.601 limited (NVENC's fixed internal RGB→YUV conversion; ffmpeg force-writes that
|
||||
// VUI), so those streams rendered with the wrong coefficients — a constant hue error. The rows
|
||||
// are now computed per frame from the decoded buffer's actual signaling (VideoToolbox propagates
|
||||
// the HEVC VUI / AV1 colour config onto the CVPixelBuffer's attachments) and handed to the
|
||||
// fragment shaders as bytes.
|
||||
|
||||
import CoreVideo
|
||||
import simd
|
||||
|
||||
/// The fragment shaders' CSC constant block: `rgb[i] = dot(r[i].xyz, yuv) + r[i].w`.
|
||||
/// Layout matches the Metal-side `struct CscUniform { float4 r0; float4 r1; float4 r2; }`
|
||||
/// (three 16-byte-aligned float4s, stride 48) — passed via `setFragmentBytes`.
|
||||
public struct CscUniform: Equatable, Sendable {
|
||||
public var r0: SIMD4<Float>
|
||||
public var r1: SIMD4<Float>
|
||||
public var r2: SIMD4<Float>
|
||||
}
|
||||
|
||||
public enum CscRows {
|
||||
/// A decoded frame's Y′CbCr signaling: the H.273 matrix code (1 = BT.709, 5/6 = BT.601,
|
||||
/// 9/10 = BT.2020; 2 = unspecified → the BT.709 SDR default, mirroring `ColorDesc`) and
|
||||
/// whether the samples are full range.
|
||||
public struct Signal: Equatable, Sendable {
|
||||
public var matrix: UInt8
|
||||
public var fullRange: Bool
|
||||
|
||||
public init(matrix: UInt8, fullRange: Bool) {
|
||||
self.matrix = matrix
|
||||
self.fullRange = fullRange
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a decoded buffer's signaling: the matrix from the `CVImageBuffer` attachment
|
||||
/// (VideoToolbox propagates the bitstream's colour description there), the range from the
|
||||
/// pixel format itself (the video- vs full-range biplanar siblings), so a full-range stream
|
||||
/// expands correctly no matter which sibling VideoToolbox delivered.
|
||||
public static func signal(of buffer: CVPixelBuffer) -> Signal {
|
||||
var matrix: UInt8 = 2 // unspecified → BT.709 default in rows()
|
||||
if let att = CVBufferCopyAttachment(buffer, kCVImageBufferYCbCrMatrixKey, nil),
|
||||
CFGetTypeID(att) == CFStringGetTypeID() {
|
||||
let s = unsafeDowncast(att, to: CFString.self)
|
||||
if CFEqual(s, kCVImageBufferYCbCrMatrix_ITU_R_709_2) {
|
||||
matrix = 1
|
||||
} else if CFEqual(s, kCVImageBufferYCbCrMatrix_ITU_R_601_4) {
|
||||
matrix = 5
|
||||
} else if CFEqual(s, kCVImageBufferYCbCrMatrix_SMPTE_240M_1995) {
|
||||
matrix = 7
|
||||
} else if CFEqual(s, kCVImageBufferYCbCrMatrix_ITU_R_2020) {
|
||||
matrix = 9
|
||||
} else {
|
||||
// CICP codes CoreMedia has no named constant for arrive as the literal string
|
||||
// "YCbCrMatrix#<code>" — the suffix IS the H.273 code. BT.470BG (5) takes this
|
||||
// form (proven by the 601 golden fixture), and BT.470BG is exactly what a Linux
|
||||
// host's RGB-input NVENC signals, so missing it re-creates the hue bug the
|
||||
// per-frame signaling exists to fix.
|
||||
let str = s as String
|
||||
if str.hasPrefix("YCbCrMatrix#"), let code = UInt8(str.dropFirst(12)) {
|
||||
matrix = code
|
||||
}
|
||||
}
|
||||
}
|
||||
let pf = CVPixelBufferGetPixelFormatType(buffer)
|
||||
let fullRange = pf == kCVPixelFormatType_420YpCbCr8BiPlanarFullRange
|
||||
|| pf == kCVPixelFormatType_420YpCbCr10BiPlanarFullRange
|
||||
|| pf == kCVPixelFormatType_444YpCbCr8BiPlanarFullRange
|
||||
|| pf == kCVPixelFormatType_444YpCbCr10BiPlanarFullRange
|
||||
return Signal(matrix: matrix, fullRange: fullRange)
|
||||
}
|
||||
|
||||
/// Compute the three rows — bit-depth exact. `depth` picks the limited-range code points
|
||||
/// (8-bit: 16/235/240 over 255; 10-bit: 64/940/960 over 1023 — NOT the same normalized
|
||||
/// values, the difference is ~half a code). `msbPacked` folds in the P010/x444 packing
|
||||
/// factor: 10 significant bits live in the MSBs of 16, so an `.r16Unorm` sample reads
|
||||
/// `code·64/65535` — multiplying by `65535/65472` recovers exact `code/1023` (this replaces
|
||||
/// the shaders' old documented ~0.1% approximation).
|
||||
public static func rows(_ signal: Signal, depth: Int, msbPacked: Bool) -> CscUniform {
|
||||
// BT.601 (5/6), BT.2020 (9/10); everything else — incl. unspecified — is the host's
|
||||
// BT.709 SDR default (mirrors the Rust side's dispatch).
|
||||
let (kr, kb): (Double, Double)
|
||||
switch signal.matrix {
|
||||
case 5, 6: (kr, kb) = (0.299, 0.114)
|
||||
case 9, 10: (kr, kb) = (0.2627, 0.0593)
|
||||
default: (kr, kb) = (0.2126, 0.0722)
|
||||
}
|
||||
let kg = 1.0 - kr - kb
|
||||
let max = Double((1 << depth) - 1) // 255 / 1023
|
||||
let step = Double(1 << (depth - 8)) // code points per 8-bit step: 1 / 4
|
||||
let pack = msbPacked ? 65535.0 / 65472.0 : 1.0
|
||||
let (sy, oy, sc): (Double, Double, Double)
|
||||
if signal.fullRange {
|
||||
(sy, oy, sc) = (pack, 0.0, pack)
|
||||
} else {
|
||||
(sy, oy, sc) = (
|
||||
pack * max / (219.0 * step),
|
||||
-(16.0 * step) / max,
|
||||
pack * max / (224.0 * step)
|
||||
)
|
||||
}
|
||||
// rgb = M * (yuv + off) = M*yuv + M*off — rows of M with the offset dot folded into
|
||||
// w. `yuv` is the SAMPLED (packed) value, so the offsets divide by the packing
|
||||
// factor to land on the same scale.
|
||||
let off = [oy / pack, -0.5 / pack, -0.5 / pack]
|
||||
let m: [[Double]] = [
|
||||
[sy, 0.0, 2.0 * (1.0 - kr) * sc],
|
||||
[
|
||||
sy,
|
||||
-2.0 * (1.0 - kb) * kb / kg * sc,
|
||||
-2.0 * (1.0 - kr) * kr / kg * sc,
|
||||
],
|
||||
[sy, 2.0 * (1.0 - kb) * sc, 0.0],
|
||||
]
|
||||
func row(_ r: Int) -> SIMD4<Float> {
|
||||
let w = (0..<3).reduce(0.0) { $0 + m[r][$1] * off[$1] }
|
||||
return SIMD4(Float(m[r][0]), Float(m[r][1]), Float(m[r][2]), Float(w))
|
||||
}
|
||||
return CscUniform(r0: row(0), r1: row(1), r2: row(2))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// Match-window resize follower (design/midstream-resolution-resize.md D1/D2, client C3).
|
||||
//
|
||||
// The presenting view feeds this its PHYSICAL-PIXEL size on every layout; it debounces to
|
||||
// resize-end, spaces requests ≥ 1 s apart, and asks the connection to switch the host's virtual
|
||||
// display + encoder to match (`PunktfunkConnection.requestMode`) — so a windowed macOS session or
|
||||
// an iPad Stage Manager / Split View scene streams native-resolution pixels instead of scaling.
|
||||
// The decode/present side needs nothing: VideoToolbox recreates its session on the keyframe-derived
|
||||
// format-description change (the first new-mode AU is an IDR with fresh parameter sets).
|
||||
//
|
||||
// The trigger discipline is the shared cross-client one (mirrors the session binary's
|
||||
// `resize_decision`): physical pixels rounded DOWN to even (the host rejects odd dimensions) and
|
||||
// clamped ≥ 320×200; debounce to resize-end; ≥ 1 s between requests; skip a size equal to the live
|
||||
// mode; and request each distinct size at most once — which both stops re-asking a rejected size
|
||||
// and keeps a host-side rollback (accepted, rebuild failed, corrective ack restored the old mode)
|
||||
// from looping request → rollback → request.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The pure, side-effect-free core of the Match-window trigger — so the normalize/skip discipline
|
||||
/// is unit-tested without a live connection or a UI (`MatchWindowTests`).
|
||||
public enum MatchWindow {
|
||||
/// Even-floor + clamp a physical-pixel size to a host-valid mode dimension: the host's
|
||||
/// `validate_dimensions` rejects odd sizes, and we never ask below 320×200.
|
||||
public static func normalize(widthPx: Int, heightPx: Int) -> (width: UInt32, height: UInt32) {
|
||||
let evenClamp: (Int, UInt32) -> UInt32 = { px, minimum in
|
||||
let even = UInt32(max(px, 0)) / 2 * 2
|
||||
return max(even, minimum)
|
||||
}
|
||||
return (evenClamp(widthPx, 320), evenClamp(heightPx, 200))
|
||||
}
|
||||
|
||||
/// Whether to request `target` now (the debounce has already settled; spacing is the caller's
|
||||
/// timer): `nil` to skip — equal to the live mode, or already requested once (a rejected size /
|
||||
/// a host rollback must not loop). `target` is expected already-[normalize]d.
|
||||
public static func request(
|
||||
target: (width: UInt32, height: UInt32),
|
||||
current: (width: UInt32, height: UInt32),
|
||||
lastRequested: (width: UInt32, height: UInt32)?
|
||||
) -> (width: UInt32, height: UInt32)? {
|
||||
if target.width == current.width, target.height == current.height { return nil }
|
||||
if let lr = lastRequested, lr.width == target.width, lr.height == target.height { return nil }
|
||||
return target
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the debounce timer + serialization state and drives `PunktfunkConnection.requestMode` from
|
||||
/// the stream view's layout callbacks. Main-actor: the views feed it on the main thread and it reads
|
||||
/// the connection's live mode there. Enabled per session from the `matchWindow` setting.
|
||||
@MainActor
|
||||
public final class MatchWindowFollower {
|
||||
private weak var connection: PunktfunkConnection?
|
||||
private let debounce: TimeInterval
|
||||
private let minSpacing: TimeInterval
|
||||
private var enabled: Bool
|
||||
|
||||
private var work: DispatchWorkItem?
|
||||
private var pendingSize: (width: Int, height: Int)?
|
||||
private var lastRequested: (width: UInt32, height: UInt32)?
|
||||
private var lastRequestAt: Date?
|
||||
/// The last size we reported via [`onResizeTarget`] — dedups the per-layout stream of a drag so
|
||||
/// the UI is notified once per distinct target, and reset to `nil` when the window is back in
|
||||
/// sync with the live mode (so a later resize re-reports).
|
||||
private var lastSteered: (width: UInt32, height: UInt32)?
|
||||
|
||||
/// Fired (on the main actor) the instant the window starts differing from the live mode — i.e.
|
||||
/// a resize is under way and a `Reconfigure` for `(width, height)` is imminent. Drives the
|
||||
/// resize overlay's INSTANT feedback (blur + spinner) BEFORE the debounced request leaves; the
|
||||
/// overlay clears when a decoded frame reaches this size (or on a timeout). Deduped per target.
|
||||
public var onResizeTarget: ((_ width: UInt32, _ height: UInt32) -> Void)?
|
||||
|
||||
/// `debounce` = quiet time after the last size event before requesting (Win32 gets
|
||||
/// `WM_EXITSIZEMOVE` for free; we debounce). `minSpacing` = floor between accepted requests
|
||||
/// (a full host pipeline rebuild each). Defaults match the other clients.
|
||||
public init(
|
||||
connection: PunktfunkConnection,
|
||||
enabled: Bool,
|
||||
debounce: TimeInterval = 0.4,
|
||||
minSpacing: TimeInterval = 1.0
|
||||
) {
|
||||
self.connection = connection
|
||||
self.enabled = enabled
|
||||
self.debounce = debounce
|
||||
self.minSpacing = minSpacing
|
||||
}
|
||||
|
||||
/// Turn following on/off live (a mid-session settings change; off cancels a pending request).
|
||||
public func setEnabled(_ on: Bool) {
|
||||
enabled = on
|
||||
if !on {
|
||||
work?.cancel()
|
||||
work = nil
|
||||
pendingSize = nil
|
||||
lastSteered = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed the presenting view's current PHYSICAL-PIXEL size (its `bounds` × the backing/display
|
||||
/// scale). Called from every layout pass; coalesced by the debounce so a drag-resize sends one
|
||||
/// request at its end, never one per frame.
|
||||
public func noteSize(widthPx: Int, heightPx: Int) {
|
||||
guard enabled else { return }
|
||||
pendingSize = (widthPx, heightPx)
|
||||
schedule()
|
||||
reportSteering(widthPx: widthPx, heightPx: heightPx)
|
||||
}
|
||||
|
||||
/// Report the resize overlay's START signal (deduped): the moment the normalized window size
|
||||
/// differs from the live mode we're steering toward a new size. No connection / no negotiated
|
||||
/// mode yet → nothing to compare against, skip.
|
||||
private func reportSteering(widthPx: Int, heightPx: Int) {
|
||||
guard let connection else { return }
|
||||
let target = MatchWindow.normalize(widthPx: widthPx, heightPx: heightPx)
|
||||
let mode = connection.currentMode()
|
||||
guard mode.width > 0, mode.height > 0 else { return }
|
||||
if target.width == mode.width, target.height == mode.height {
|
||||
lastSteered = nil // back in sync — a later change re-reports
|
||||
return
|
||||
}
|
||||
if lastSteered?.width == target.width, lastSteered?.height == target.height { return }
|
||||
lastSteered = target
|
||||
onResizeTarget?(target.width, target.height)
|
||||
}
|
||||
|
||||
private func schedule() {
|
||||
work?.cancel()
|
||||
let item = DispatchWorkItem { [weak self] in self?.fire() }
|
||||
work = item
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + debounce, execute: item)
|
||||
}
|
||||
|
||||
private func fire() {
|
||||
guard enabled, let connection, let size = pendingSize else { return }
|
||||
// ≥ 1 s spacing: a request went out recently → re-arm the debounce and retry later rather
|
||||
// than fire early (keeps at most ~one request outstanding — the accept ack round-trips in
|
||||
// milliseconds, ahead of the host's rebuild).
|
||||
if let last = lastRequestAt, Date().timeIntervalSince(last) < minSpacing {
|
||||
schedule()
|
||||
return
|
||||
}
|
||||
let target = MatchWindow.normalize(widthPx: size.width, heightPx: size.height)
|
||||
let mode = connection.currentMode()
|
||||
pendingSize = nil
|
||||
guard let req = MatchWindow.request(
|
||||
target: target,
|
||||
current: (mode.width, mode.height),
|
||||
lastRequested: lastRequested
|
||||
) else { return }
|
||||
// Keep the current refresh — Match-window follows SIZE, not rate.
|
||||
connection.requestMode(width: req.width, height: req.height, refreshHz: mode.refreshHz)
|
||||
lastRequested = req
|
||||
lastRequestAt = Date()
|
||||
}
|
||||
}
|
||||
@@ -28,17 +28,39 @@ private let presenterLog = Logger(subsystem: "io.unom.punktfunk", category: "pre
|
||||
/// dimmer. Matches the host's standard PQ reference white.
|
||||
private let hdrReferenceWhiteNits: Float = 203.0
|
||||
|
||||
/// Runtime-compiled (no metallib build step needed in SwiftPM): a fullscreen triangle and BT.709 SDR
|
||||
/// and BT.2020-PQ HDR Y′CbCr→RGB fragment shaders. uv.y is flipped (1 - p.y) so the top-left-origin
|
||||
/// texture presents upright (NDC y is up). The HDR shader outputs PQ-encoded R′G′B′ as-is — the
|
||||
/// CAMetalLayer's `itur_2100_PQ` colour space + `edrMetadata` tell the system compositor the samples
|
||||
/// are PQ and how to tone-map them (no EOTF here, matching the host's BT.2020 PQ emission).
|
||||
/// PUNKTFUNK_SDR_COLORSPACE=srgb — A/B hatch for the SDR layer's colour tag. Today the SDR layer
|
||||
/// ships with `colorspace = nil`, which on macOS means NO colour matching: the BT.709/sRGB-encoded
|
||||
/// stream is displayed with the panel's native primaries — mild oversaturation on every P3 Mac.
|
||||
/// `srgb` tags the layer so CoreAnimation colour-matches it into the panel's gamut (the strictly
|
||||
/// correct rendering). Kept OFF by default until the on-glass A/B confirms it (the nil path is the
|
||||
/// long-proven look, and some users may prefer the vivid rendition); flip the default once verified.
|
||||
private let sdrColorspaceOverride: CGColorSpace? = {
|
||||
guard ProcessInfo.processInfo.environment["PUNKTFUNK_SDR_COLORSPACE"] == "srgb" else {
|
||||
return nil
|
||||
}
|
||||
return CGColorSpace(name: CGColorSpace.sRGB)
|
||||
}()
|
||||
|
||||
/// Runtime-compiled (no metallib build step needed in SwiftPM): a fullscreen triangle and Y′CbCr→RGB
|
||||
/// fragment shaders whose conversion arrives as three constant rows computed per frame on the CPU
|
||||
/// (`CscRows` — the Swift port of pf-client-core's `csc_rows`, from the decoded buffer's actual
|
||||
/// signaling). One set of coefficients honors BT.601/709/2020 × full/limited × 8/10-bit instead of
|
||||
/// the old hardcoded BT.709/BT.2020 matrices — a BT.601-signaled stream (a Linux host's RGB-input
|
||||
/// NVENC) used to render with BT.709 coefficients, a constant hue error. uv.y is flipped (1 - p.y)
|
||||
/// so the top-left-origin texture presents upright (NDC y is up). The HDR shader outputs PQ-encoded
|
||||
/// R′G′B′ as-is — the CAMetalLayer's `itur_2100_PQ` colour space + `edrMetadata` tell the system
|
||||
/// compositor the samples are PQ and how to tone-map them (no EOTF here, matching the host's
|
||||
/// BT.2020 PQ emission).
|
||||
private let shaderSource = """
|
||||
#include <metal_stdlib>
|
||||
using namespace metal;
|
||||
|
||||
struct VOut { float4 pos [[position]]; float2 uv; };
|
||||
|
||||
// The CPU-computed CSC rows (CscRows.swift, layout-matched): rgb[i] = dot(ri.xyz, yuv) + ri.w.
|
||||
// Range expansion, the matrix, and the 10-bit MSB-packing factor are all folded in.
|
||||
struct CscUniform { float4 r0; float4 r1; float4 r2; };
|
||||
|
||||
vertex VOut pf_vtx(uint vid [[vertex_id]]) {
|
||||
float2 p = float2(float((vid << 1) & 2), float(vid & 2));
|
||||
VOut o;
|
||||
@@ -94,43 +116,89 @@ float2 chromaUV(texture2d<float> lumaTex, texture2d<float> chromaTex, float2 uv)
|
||||
return uv;
|
||||
}
|
||||
|
||||
// SDR: 8-bit NV12 / 4:4:4 (BT.709, limited/video range) → full-range RGB. Chroma is sampled at the
|
||||
// (siting-corrected) luma UV, so a full-size 4:4:4 chroma plane needs no shader change vs 4:2:0.
|
||||
fragment float4 pf_frag(VOut in [[stage_in]],
|
||||
texture2d<float> lumaTex [[texture(0)]],
|
||||
texture2d<float> chromaTex [[texture(1)]]) {
|
||||
// The shared sample + row-multiply: Y′CbCr (bicubic luma, siting-corrected bilinear chroma) →
|
||||
// RGB via the per-frame rows. A full-size 4:4:4 chroma plane needs no change vs 4:2:0 (the siting
|
||||
// offset self-disables). What the result MEANS depends on the stream: an SDR frame's rows yield
|
||||
// gamma-encoded RGB, an HDR frame's rows yield PQ-encoded R′G′B′ — the fragment variants below
|
||||
// differ only in what they do next.
|
||||
float3 sampleRgb(texture2d<float> lumaTex, texture2d<float> chromaTex, float2 uv,
|
||||
constant CscUniform& csc) {
|
||||
constexpr sampler s(filter::linear, address::clamp_to_edge);
|
||||
float y = catmullRomLuma(lumaTex, s, in.uv);
|
||||
float2 c = chromaTex.sample(s, chromaUV(lumaTex, chromaTex, in.uv)).rg;
|
||||
// BT.709, 8-bit limited (video) range → full-range RGB.
|
||||
y = (y - 16.0/255.0) * (255.0/219.0);
|
||||
float u = (c.x - 128.0/255.0) * (255.0/224.0);
|
||||
float v = (c.y - 128.0/255.0) * (255.0/224.0);
|
||||
float r = y + 1.5748 * v;
|
||||
float g = y - 0.1873 * u - 0.4681 * v;
|
||||
float b = y + 1.8556 * u;
|
||||
return float4(saturate(float3(r, g, b)), 1.0);
|
||||
#ifdef PF_BILINEAR_LUMA
|
||||
// DEBUG (PUNKTFUNK_BILINEAR_LUMA=1): plain bilinear luma — Catmull-Rom OFF. A/B lever to see if
|
||||
// the bicubic overshoot contributes to edge fringing. NOTE: at a true 1:1 present both paths
|
||||
// reduce to the identity texel, so if this toggle VISIBLY changes the picture, the present is
|
||||
// NOT 1:1 (there's a resample); if it looks identical, the fringing is upstream (codec/source/OS).
|
||||
float lumaY = lumaTex.sample(s, uv).r;
|
||||
#else
|
||||
float lumaY = catmullRomLuma(lumaTex, s, uv);
|
||||
#endif
|
||||
float3 yuv = float3(lumaY,
|
||||
chromaTex.sample(s, chromaUV(lumaTex, chromaTex, uv)).rg);
|
||||
return saturate(float3(dot(csc.r0.xyz, yuv) + csc.r0.w,
|
||||
dot(csc.r1.xyz, yuv) + csc.r1.w,
|
||||
dot(csc.r2.xyz, yuv) + csc.r2.w));
|
||||
}
|
||||
|
||||
// HDR: 10-bit P010 / 4:4:4 (BT.2020, limited range), Y′CbCr that is PQ-encoded. We apply the BT.2020
|
||||
// matrix to get PQ-encoded R′G′B′ and output it as-is — the CAMetalLayer's itur_2100_PQ colour space
|
||||
// + edrMetadata tell the compositor the samples are PQ, so it does the PQ→display tone-map. No EOTF
|
||||
// here. P010/x444 store the 10-bit code in the high bits of each 16-bit sample, so an .r16Unorm sample
|
||||
// reads ~code/1023 (the /1024 vs /1023 error is < 0.1%).
|
||||
// SDR: 8-bit NV12 / 4:4:4 → full-range RGB, transfer left baked (shown as-is, the proven SDR
|
||||
// layer config).
|
||||
fragment float4 pf_frag(VOut in [[stage_in]],
|
||||
texture2d<float> lumaTex [[texture(0)]],
|
||||
texture2d<float> chromaTex [[texture(1)]],
|
||||
constant CscUniform& csc [[buffer(0)]]) {
|
||||
return float4(sampleRgb(lumaTex, chromaTex, in.uv, csc), 1.0);
|
||||
}
|
||||
|
||||
// HDR: 10-bit P010 / 4:4:4 (BT.2020, PQ-encoded Y′CbCr) → full-range PQ R′G′B′, output as-is —
|
||||
// the CAMetalLayer's itur_2100_PQ colour space + edrMetadata tell the compositor the samples are
|
||||
// PQ, so it does the PQ→display tone-map. No EOTF here. The rows fold in the exact 10-bit
|
||||
// MSB-packing factor (the old hardcoded shader carried a documented ~0.1% /1024-vs-/1023 error).
|
||||
fragment float4 pf_frag_hdr(VOut in [[stage_in]],
|
||||
texture2d<float> lumaTex [[texture(0)]],
|
||||
texture2d<float> chromaTex [[texture(1)]]) {
|
||||
constexpr sampler s(filter::linear, address::clamp_to_edge);
|
||||
float y = catmullRomLuma(lumaTex, s, in.uv);
|
||||
float2 c = chromaTex.sample(s, chromaUV(lumaTex, chromaTex, in.uv)).rg;
|
||||
// BT.2020 10-bit limited (video) range → full-range PQ R′G′B′.
|
||||
y = (y - 64.0/1023.0) * (1023.0/876.0);
|
||||
float u = (c.x - 512.0/1023.0) * (1023.0/896.0);
|
||||
float v = (c.y - 512.0/1023.0) * (1023.0/896.0);
|
||||
float r = y + 1.4746 * v;
|
||||
float g = y - 0.16455 * u - 0.57135 * v;
|
||||
float b = y + 1.8814 * u;
|
||||
return float4(saturate(float3(r, g, b)), 1.0);
|
||||
texture2d<float> chromaTex [[texture(1)]],
|
||||
constant CscUniform& csc [[buffer(0)]]) {
|
||||
return float4(sampleRgb(lumaTex, chromaTex, in.uv, csc), 1.0);
|
||||
}
|
||||
|
||||
// HDR on tvOS when the display is composited WITHOUT HDR headroom (SDR output mode, or the user
|
||||
// disabled Match Dynamic Range): no Metal EDR API exists there (CAEDRMetadata /
|
||||
// wantsExtendedDynamicRangeContent are API_UNAVAILABLE(tvos)), and a bare PQ colour-space tag
|
||||
// composites UNtone-mapped — the CAMetalLayer header says so outright — which showed as a badly
|
||||
// overblown picture on Apple TV. So this variant finishes the job in-shader: PQ EOTF → linear
|
||||
// light, 203-nit reference white (BT.2408) anchored at display white, extended-Reinhard highlight
|
||||
// rolloff with a 1000-nit knee, BT.2020→BT.709 primaries, BT.709 OETF — into the proven SDR layer
|
||||
// config. The 10-bit BT.2020 stream keeps its full decode depth; only the final presentation is
|
||||
// display-referred SDR. (When the display IS in an HDR mode — requested per session via
|
||||
// AVDisplayManager, see StreamViewIOS — tvOS presents pf_frag_hdr's PQ passthrough instead:
|
||||
// in a genuine HDR10 output, PQ passthrough is the correct emission and the TV tone-maps.)
|
||||
fragment float4 pf_frag_hdr_tv(VOut in [[stage_in]],
|
||||
texture2d<float> lumaTex [[texture(0)]],
|
||||
texture2d<float> chromaTex [[texture(1)]],
|
||||
constant CscUniform& csc [[buffer(0)]]) {
|
||||
// Y′CbCr → full-range PQ R′G′B′ via the per-frame rows (as pf_frag_hdr).
|
||||
float3 pq = sampleRgb(lumaTex, chromaTex, in.uv, csc);
|
||||
// ST 2084 EOTF: PQ code value → linear light, 1.0 = 10,000 nits.
|
||||
const float m1 = 2610.0/16384.0;
|
||||
const float m2 = 78.84375;
|
||||
const float c1 = 3424.0/4096.0;
|
||||
const float c2 = 18.8515625;
|
||||
const float c3 = 18.6875;
|
||||
float3 p = pow(pq, 1.0/m2);
|
||||
float3 lin = pow(max(p - c1, 0.0) / (c2 - c3 * p), 1.0/m1);
|
||||
// Scene-referred with diffuse white at 1.0 (the same 203-nit anchor the EDR path uses).
|
||||
float3 t = lin * (10000.0/203.0);
|
||||
// BT.2020 → BT.709 primaries while still linear; negatives are out-of-gamut, floor them.
|
||||
float3 t709 = float3(
|
||||
dot(t, float3( 1.6605, -0.5876, -0.0728)),
|
||||
dot(t, float3(-0.1246, 1.1329, -0.0083)),
|
||||
dot(t, float3(-0.0182, -0.1006, 1.1187)));
|
||||
t709 = max(t709, 0.0);
|
||||
// Extended Reinhard: 1.0 stays put, the 1000-nit knee lands at display white, above rolls off.
|
||||
const float w = 1000.0/203.0;
|
||||
float3 mapped = saturate(t709 * (1.0 + t709 / (w * w)) / (1.0 + t709));
|
||||
// BT.709 OETF — the same encoding the SDR stream arrives in, so both paths present alike.
|
||||
float3 e = select(1.099 * pow(mapped, 0.45) - 0.099, 4.5 * mapped, mapped < 0.018);
|
||||
return float4(e, 1.0);
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -144,12 +212,19 @@ public final class MetalVideoPresenter {
|
||||
/// frame in `render`; the layer is reconfigured to match when the session flips (HDR toggle).
|
||||
private let pipelineSDR: MTLRenderPipelineState
|
||||
private let pipelineHDR: MTLRenderPipelineState
|
||||
/// tvOS only: the in-shader PQ→SDR tone-map fallback (pf_frag_hdr_tv → bgra8), used whenever
|
||||
/// the display is composited without HDR headroom — see `setDisplayHeadroom`. nil elsewhere.
|
||||
private let pipelineHDRToneMap: MTLRenderPipelineState?
|
||||
private var textureCache: CVMetalTextureCache?
|
||||
|
||||
/// Current layer configuration — switched in `configure(hdr:)` when a frame's HDR-ness differs.
|
||||
/// Render-thread confined once the pipeline runs (Stage2Pipeline.start's one pre-thread
|
||||
/// `configure` call is ordered before the thread starts, so it doesn't race).
|
||||
private var hdrActive = false
|
||||
/// tvOS only: whether HDR frames currently present as PQ PASSTHROUGH (display has HDR headroom
|
||||
/// — its own tone-map applies) vs the in-shader tone-map fallback. Render-thread confined;
|
||||
/// derived from the staged display headroom at the top of every `render`.
|
||||
private var hdrPassthroughActive = false
|
||||
/// Last HDR mastering grade received via `setHdrMeta` (the host's 0xCE). Cached so a mid-session
|
||||
/// SDR→HDR flip's `configureColor` re-applies the real grade instead of clobbering it back to the
|
||||
/// bare reference-white anchor (an out-of-order race otherwise: `setHdrMeta` and the flip both write
|
||||
@@ -163,6 +238,11 @@ public final class MetalVideoPresenter {
|
||||
private let stagingLock = NSLock()
|
||||
private var pendingHdrMeta: PunktfunkConnection.HdrMeta?
|
||||
private var drawableTarget: CGSize = .zero
|
||||
/// tvOS: the display's current EDR headroom (UIScreen.currentEDRHeadroom), pushed from the
|
||||
/// main thread (SessionPresenter.layout + the mode-switch observers). > 1 ⇒ the display is
|
||||
/// composited with HDR headroom, so HDR frames present as PQ passthrough; otherwise the
|
||||
/// in-shader tone-map keeps the picture from blowing out. 1 (the default) is the safe start.
|
||||
private var stagedDisplayHeadroom: CGFloat = 1.0
|
||||
|
||||
#if DEBUG
|
||||
/// Last logged "decoded→drawable" signature, so the diagnostic logs only on a size/HDR change.
|
||||
@@ -177,8 +257,18 @@ public final class MetalVideoPresenter {
|
||||
else { return nil }
|
||||
let pipelineSDR: MTLRenderPipelineState
|
||||
let pipelineHDR: MTLRenderPipelineState
|
||||
let pipelineHDRToneMap: MTLRenderPipelineState?
|
||||
do {
|
||||
let library = try device.makeLibrary(source: shaderSource, options: nil)
|
||||
// DEBUG A/B lever: PUNKTFUNK_BILINEAR_LUMA=1 compiles the shader with Catmull-Rom OFF
|
||||
// (plain bilinear luma) by prepending a #define ahead of the source. Default (unset) is
|
||||
// the normal bicubic path. Read at presenter creation — set it in the environment and
|
||||
// relaunch to flip; the log line confirms which path built.
|
||||
let bilinearLuma = ProcessInfo.processInfo.environment["PUNKTFUNK_BILINEAR_LUMA"] == "1"
|
||||
let source = (bilinearLuma ? "#define PF_BILINEAR_LUMA 1\n" : "") + shaderSource
|
||||
if bilinearLuma {
|
||||
presenterLog.info("stage2: PUNKTFUNK_BILINEAR_LUMA=1 — Catmull-Rom luma DISABLED (bilinear)")
|
||||
}
|
||||
let library = try device.makeLibrary(source: source, options: nil)
|
||||
let vtx = library.makeFunction(name: "pf_vtx")
|
||||
let sdr = MTLRenderPipelineDescriptor()
|
||||
sdr.vertexFunction = vtx
|
||||
@@ -188,8 +278,20 @@ public final class MetalVideoPresenter {
|
||||
let hdr = MTLRenderPipelineDescriptor()
|
||||
hdr.vertexFunction = vtx
|
||||
hdr.fragmentFunction = library.makeFunction(name: "pf_frag_hdr")
|
||||
hdr.colorAttachments[0].pixelFormat = .rgba16Float // EDR-capable
|
||||
hdr.colorAttachments[0].pixelFormat = .rgba16Float // PQ passthrough
|
||||
pipelineHDR = try device.makeRenderPipelineState(descriptor: hdr)
|
||||
#if os(tvOS)
|
||||
// tvOS carries BOTH HDR pipelines: PQ passthrough when the display is composited
|
||||
// with HDR headroom, the in-shader tone-map (→ the 8-bit SDR config) when it isn't.
|
||||
// See setDisplayHeadroom / configureColor.
|
||||
let tm = MTLRenderPipelineDescriptor()
|
||||
tm.vertexFunction = vtx
|
||||
tm.fragmentFunction = library.makeFunction(name: "pf_frag_hdr_tv")
|
||||
tm.colorAttachments[0].pixelFormat = .bgra8Unorm
|
||||
pipelineHDRToneMap = try device.makeRenderPipelineState(descriptor: tm)
|
||||
#else
|
||||
pipelineHDRToneMap = nil
|
||||
#endif
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
@@ -229,17 +331,19 @@ public final class MetalVideoPresenter {
|
||||
|
||||
return MetalVideoPresenter(
|
||||
device: device, queue: queue, pipelineSDR: pipelineSDR, pipelineHDR: pipelineHDR,
|
||||
textureCache: textureCache, layer: layer)
|
||||
pipelineHDRToneMap: pipelineHDRToneMap, textureCache: textureCache, layer: layer)
|
||||
}
|
||||
|
||||
private init(
|
||||
device: MTLDevice, queue: MTLCommandQueue, pipelineSDR: MTLRenderPipelineState,
|
||||
pipelineHDR: MTLRenderPipelineState, textureCache: CVMetalTextureCache, layer: CAMetalLayer
|
||||
pipelineHDR: MTLRenderPipelineState, pipelineHDRToneMap: MTLRenderPipelineState?,
|
||||
textureCache: CVMetalTextureCache, layer: CAMetalLayer
|
||||
) {
|
||||
self.device = device
|
||||
self.queue = queue
|
||||
self.pipelineSDR = pipelineSDR
|
||||
self.pipelineHDR = pipelineHDR
|
||||
self.pipelineHDRToneMap = pipelineHDRToneMap
|
||||
self.textureCache = textureCache
|
||||
self.layer = layer
|
||||
}
|
||||
@@ -251,30 +355,68 @@ public final class MetalVideoPresenter {
|
||||
/// an rgba16Float drawable + BT.2020 PQ colour space + EDR with a 203-nit reference-white anchor;
|
||||
/// SDR uses the plain 8-bit sRGB path.
|
||||
public func configure(hdr: Bool) {
|
||||
#if os(tvOS)
|
||||
// Reconfigure on an HDR flip AND on a passthrough↔tone-map flip: the display's headroom
|
||||
// changes when the AVDisplayManager mode switch (requested at session start) completes —
|
||||
// typically a second or two into the session.
|
||||
stagingLock.lock()
|
||||
let passthrough = stagedDisplayHeadroom > 1.0
|
||||
stagingLock.unlock()
|
||||
guard hdr != hdrActive || (hdr && passthrough != hdrPassthroughActive) else { return }
|
||||
hdrActive = hdr
|
||||
hdrPassthroughActive = passthrough
|
||||
#else
|
||||
guard hdr != hdrActive else { return }
|
||||
hdrActive = hdr
|
||||
#endif
|
||||
configureColor(hdr: hdr)
|
||||
}
|
||||
|
||||
/// tvOS: park the display's current EDR headroom (a MAIN-thread `UIScreen` read — pushed by
|
||||
/// SessionPresenter.layout and the stream view's mode-switch observers). > 1 flips HDR frames
|
||||
/// to PQ passthrough (the display's own tone-map applies); ≤ 1 keeps the in-shader tone-map.
|
||||
/// Applied by the render thread on the next frame, like every other staged value here.
|
||||
public func setDisplayHeadroom(_ headroom: CGFloat) {
|
||||
stagingLock.lock()
|
||||
stagedDisplayHeadroom = headroom
|
||||
stagingLock.unlock()
|
||||
}
|
||||
|
||||
/// Set the layer's pixel format + colour config for SDR or HDR. MAIN THREAD ONLY. EDR is requested
|
||||
/// on macOS + iOS (the old `#if os(macOS)` guard left iOS EDR half-engaged). tvOS has NO EDR API
|
||||
/// (`wantsExtendedDynamicRangeContent`/`edrMetadata`/`CAEDRMetadata` are all unavailable there), so
|
||||
/// it gets the PQ pixel format + colour space only — the tvOS compositor tone-maps from those.
|
||||
/// (`wantsExtendedDynamicRangeContent`/`edrMetadata`/`CAEDRMetadata` are all unavailable there) —
|
||||
/// and a bare PQ colour-space tag composites UNtone-mapped (the "overblown HDR" Apple TV report),
|
||||
/// so tvOS instead tone-maps PQ→SDR in the shader (pf_frag_hdr_tv) and keeps the SDR layer config.
|
||||
private func configureColor(hdr: Bool) {
|
||||
if hdr {
|
||||
#if os(tvOS)
|
||||
if hdrPassthroughActive {
|
||||
// Display composited WITH HDR headroom (the session's AVDisplayManager request
|
||||
// landed): emit PQ passthrough — in a real HDR10 output that's the correct
|
||||
// emission, and the TV applies its own tone-map.
|
||||
layer.pixelFormat = .rgba16Float
|
||||
layer.colorspace = CGColorSpace(name: CGColorSpace.itur_2100_PQ)
|
||||
} else {
|
||||
// SDR-composited display: PQ would render untone-mapped (blown out) — the
|
||||
// pf_frag_hdr_tv shader tone-maps to SDR instead.
|
||||
layer.pixelFormat = .bgra8Unorm
|
||||
layer.colorspace = nil
|
||||
}
|
||||
#else
|
||||
layer.pixelFormat = .rgba16Float
|
||||
layer.colorspace = CGColorSpace(name: CGColorSpace.itur_2100_PQ)
|
||||
#if !os(tvOS)
|
||||
layer.wantsExtendedDynamicRangeContent = true
|
||||
// Anchor reference white. Re-apply the real grade if one already arrived (0xCE before the
|
||||
// flip); otherwise the bare 203-nit anchor. Without this anchor the PQ signal is too bright.
|
||||
layer.edrMetadata = makeEDR(lastHdrMeta)
|
||||
#endif
|
||||
} else {
|
||||
// SDR: gamma-encoded BT.709 [0,1] in an 8-bit drawable; a nil colorspace tags it device/sRGB
|
||||
// (the proven SDR path — never showed the "too bright" issue, which was HDR-only).
|
||||
// SDR: gamma-encoded BT.709 [0,1] in an 8-bit drawable. Default: nil colorspace = NO
|
||||
// colour matching on macOS (the panel's native primaries — the long-proven look,
|
||||
// slightly oversaturated on P3 panels); PUNKTFUNK_SDR_COLORSPACE=srgb tags the layer
|
||||
// for correct colour matching instead (A/B pending — see sdrColorspaceOverride).
|
||||
layer.pixelFormat = .bgra8Unorm
|
||||
layer.colorspace = nil
|
||||
layer.colorspace = sdrColorspaceOverride
|
||||
#if !os(tvOS)
|
||||
layer.wantsExtendedDynamicRangeContent = false
|
||||
layer.edrMetadata = nil
|
||||
@@ -360,6 +502,11 @@ public final class MetalVideoPresenter {
|
||||
|| pf == kCVPixelFormatType_420YpCbCr10BiPlanarFullRange
|
||||
|| pf == kCVPixelFormatType_444YpCbCr10BiPlanarVideoRange
|
||||
|| pf == kCVPixelFormatType_444YpCbCr10BiPlanarFullRange
|
||||
// The frame's Y′CbCr→RGB rows, from its ACTUAL signaling (buffer attachments + pixel
|
||||
// format) — a BT.601-signaled stream gets 601 coefficients, full-range gets full-range
|
||||
// expansion; recomputed per frame because the host can flip colour in-band (SDR↔HDR).
|
||||
var csc = CscRows.rows(
|
||||
CscRows.signal(of: pixelBuffer), depth: tenBit ? 10 : 8, msbPacked: tenBit)
|
||||
guard let textureCache,
|
||||
let luma = makeTexture(
|
||||
pixelBuffer, plane: 0, format: tenBit ? .r16Unorm : .r8Unorm, cache: textureCache),
|
||||
@@ -395,9 +542,17 @@ public final class MetalVideoPresenter {
|
||||
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else {
|
||||
return false
|
||||
}
|
||||
#if os(tvOS)
|
||||
// HDR splits by the display's headroom (kept in step with the layer by `configure` above):
|
||||
// PQ passthrough into an HDR-composited display, the tone-map shader otherwise.
|
||||
let hdrPipeline = hdrPassthroughActive ? pipelineHDR : (pipelineHDRToneMap ?? pipelineHDR)
|
||||
encoder.setRenderPipelineState(hdrActive ? hdrPipeline : pipelineSDR)
|
||||
#else
|
||||
encoder.setRenderPipelineState(hdrActive ? pipelineHDR : pipelineSDR)
|
||||
#endif
|
||||
encoder.setFragmentTexture(CVMetalTextureGetTexture(luma), index: 0)
|
||||
encoder.setFragmentTexture(CVMetalTextureGetTexture(chroma), index: 1)
|
||||
encoder.setFragmentBytes(&csc, length: MemoryLayout<CscUniform>.stride, index: 0)
|
||||
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
|
||||
encoder.endEncoding()
|
||||
if let onPresented {
|
||||
@@ -453,8 +608,17 @@ public final class MetalVideoPresenter {
|
||||
let sig = "\(Int(decoded.width))x\(Int(decoded.height))→\(Int(drawable.width))x\(Int(drawable.height))|hdr\(hdrActive ? 1 : 0)"
|
||||
if sig != lastSizeSig {
|
||||
lastSizeSig = sig
|
||||
// Explicit verdict: is the shader presenting 1:1 (decoded == drawable) or resampling? The
|
||||
// scale ratio makes a residual match-window mismatch obvious. If this says 1:1 but the
|
||||
// picture is still soft, the resample is downstream of us (macOS compositor — a scaled
|
||||
// display mode, or a fractional-pixel window position), not the shader.
|
||||
let sx = decoded.width > 0 ? drawable.width / decoded.width : 0
|
||||
let sy = decoded.height > 0 ? drawable.height / decoded.height : 0
|
||||
let verdict = decoded == drawable
|
||||
? "1:1 (no resample)"
|
||||
: String(format: "RESAMPLE scale=%.4fx%.4f", sx, sy)
|
||||
let msg =
|
||||
"stage2: decoded \(Int(decoded.width))x\(Int(decoded.height)) → drawable \(Int(drawable.width))x\(Int(drawable.height)) hdr=\(hdrActive)"
|
||||
"stage2: decoded \(Int(decoded.width))x\(Int(decoded.height)) → drawable \(Int(drawable.width))x\(Int(drawable.height)) [\(verdict)] hdr=\(hdrActive)"
|
||||
presenterLog.info("\(msg, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// Swift wrapper around the punktfunk-core C ABI's post-loss re-anchor gate
|
||||
// (`punktfunk_reanchor_gate_*`, ABI v6). The shared Rust gate (crates/punktfunk-core/src/reanchor.rs)
|
||||
// is what the Linux/Windows desktop pump and the Android client use directly; the Swift clients reach
|
||||
// it across the C ABI so the freeze-until-reanchor policy is defined ONCE for every platform.
|
||||
//
|
||||
// Why a freeze at all: after unrecoverable loss the host keeps sending delta frames that reference a
|
||||
// picture the client never got. Hardware decoders (VideoToolbox included) don't reliably error on
|
||||
// that — they CONCEAL, returning a gray/garbage frame with a success status. Presenting those is the
|
||||
// visible "gray flash with motion" of the loss reports. The gate withholds concealed frames and holds
|
||||
// the last good picture on glass until a PROVEN clean re-anchor lands — an IDR (wire `FLAG_SOF`), an
|
||||
// RFI recovery anchor (`USER_FLAG_RECOVERY_ANCHOR`), or the 2nd of two intra-refresh recovery marks
|
||||
// (`USER_FLAG_RECOVERY_POINT`) — with a bounded backstop so a lost re-anchor can never freeze forever.
|
||||
// See punktfunk-planning design/client-reanchor-freeze-parity.md.
|
||||
//
|
||||
// Threading: one gate per session. Its calls arrive from two threads — the pump thread (`arm` on a
|
||||
// frame-index gap / a submit failure, `poll` per iteration) and a VideoToolbox decode thread
|
||||
// (`onDecoded` per decoded frame, `onNoOutput` on a decode error). The raw Rust gate is a plain
|
||||
// struct behind an opaque pointer with no internal synchronization, so every call is serialized under
|
||||
// `lock` here — the calls are cheap field updates, so contention is negligible. `@unchecked Sendable`:
|
||||
// the lock enforces the contract.
|
||||
|
||||
import Foundation
|
||||
import PunktfunkCore
|
||||
|
||||
final class ReanchorGate: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
/// The opaque `ReanchorGate *`. `var` so `reseed` can swap it at session start. Never NULL
|
||||
/// (`punktfunk_reanchor_gate_new` never returns NULL).
|
||||
private var ptr: OpaquePointer
|
||||
|
||||
/// Seed the baseline with the connection's current `framesDropped` so the first `poll` doesn't
|
||||
/// read the session's starting drop count as a fresh loss.
|
||||
init(framesDropped: UInt64) {
|
||||
ptr = punktfunk_reanchor_gate_new(framesDropped)
|
||||
}
|
||||
|
||||
deinit { punktfunk_reanchor_gate_free(ptr) }
|
||||
|
||||
/// Re-anchor the drop-count baseline to `framesDropped` for a (re)started session. The gate is
|
||||
/// created in the pipeline's init (before a connection exists, seeded 0); `start` calls this once
|
||||
/// the live connection's count is known so a mid-life connection's non-zero baseline isn't
|
||||
/// mistaken for loss on the first poll.
|
||||
func reseed(framesDropped: UInt64) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
punktfunk_reanchor_gate_free(ptr)
|
||||
ptr = punktfunk_reanchor_gate_new(framesDropped)
|
||||
}
|
||||
|
||||
/// Arm the freeze: a loss was detected (a frame-index gap, or a decoder wedge). Zeroes the
|
||||
/// recovery-mark count and (re)sets the backstop deadline.
|
||||
func arm() {
|
||||
lock.lock()
|
||||
punktfunk_reanchor_gate_arm(ptr)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Fold one decoded frame. `flags` is the AU's wire `user_flags`. Returns true to PRESENT the
|
||||
/// frame, false to WITHHOLD it as a post-loss concealment (hold the last good picture). Pass
|
||||
/// `decoderKeyframe: false` — VideoToolbox doesn't flag IDRs, so the wire `FLAG_SOF` covers it.
|
||||
func onDecoded(flags: UInt32, decoderKeyframe: Bool = false) -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
var present = false
|
||||
_ = punktfunk_reanchor_gate_on_decoded(ptr, flags, decoderKeyframe, &present)
|
||||
return present
|
||||
}
|
||||
|
||||
/// A received AU produced no decoded frame (a VideoToolbox decode error). Returns true when the
|
||||
/// no-output streak has tripped (the gate armed the freeze) and the caller should — throttled —
|
||||
/// request a keyframe.
|
||||
func onNoOutput() -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
var requestKf = false
|
||||
_ = punktfunk_reanchor_gate_on_no_output(ptr, &requestKf)
|
||||
return requestKf
|
||||
}
|
||||
|
||||
/// Periodic fold of the session's `framesDropped` plus the overdue backstop. Returns true when the
|
||||
/// caller should — throttled — request a keyframe (a drop-count climb armed a fresh freeze, or the
|
||||
/// freeze is overdue and re-asks while it keeps holding).
|
||||
func poll(framesDropped: UInt64) -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
var requestKf = false
|
||||
_ = punktfunk_reanchor_gate_poll(ptr, framesDropped, &requestKf)
|
||||
return requestKf
|
||||
}
|
||||
|
||||
/// Whether the gate is currently withholding concealed frames (frozen on the last good picture).
|
||||
var isHolding: Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
var holding = false
|
||||
_ = punktfunk_reanchor_gate_is_holding(ptr, &holding)
|
||||
return holding
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Resize-in-progress indicator state (design/midstream-resolution-resize.md — client UX).
|
||||
//
|
||||
// A mid-stream resize takes the host 0.3–2 s to rebuild its virtual display + encoder, and the
|
||||
// first new-mode frame is an IDR that the decoder re-inits on. Rather than let the stream scale
|
||||
// (stretch/blur) to the changing window during that gap, the client EMBRACES the delay: it shows a
|
||||
// deliberate blur + spinner the instant a resize starts and clears it the instant the sharp
|
||||
// new-resolution frame is on screen — so the wait reads as intentional, not as lag.
|
||||
//
|
||||
// This is driven ENTIRELY by signals the client already has (no new protocol):
|
||||
// * START — the Match-window follower reports the size it is steering toward (instant, on the
|
||||
// first resize layout, before the debounced request even leaves).
|
||||
// * END — the decode pipeline reports each new-mode IDR's dimensions; when they reach the target
|
||||
// the new picture is here.
|
||||
// * TIMEOUT — the safety net for a switch that never delivers the exact target: the host rejected
|
||||
// it (gamescope), capped it to an advertised mode, or a corrective ack landed a different size.
|
||||
//
|
||||
// Pure + side-effect-free so the transition logic is unit-tested without a live session or UI
|
||||
// (`ResizeIndicatorTests`); `SessionModel` owns an instance and mirrors `active` into a @Published.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The pure state of the resize overlay. `now` is a monotonic time in seconds (the caller passes
|
||||
/// `ProcessInfo.processInfo.systemUptime` or a test clock).
|
||||
public struct ResizeIndicator {
|
||||
/// Whether the blur + spinner should be shown.
|
||||
public private(set) var active = false
|
||||
/// The size the follower is steering toward — cleared once a decoded frame reaches it.
|
||||
private var target: (width: UInt32, height: UInt32)?
|
||||
/// When the current `active` span began — the timeout is measured from here.
|
||||
private var since: TimeInterval?
|
||||
/// How long to keep the overlay up if the target frame never arrives (rejected / capped switch).
|
||||
public var timeout: TimeInterval
|
||||
|
||||
public init(timeout: TimeInterval = 2.5) { self.timeout = timeout }
|
||||
|
||||
/// The follower is steering toward `width`×`height` — a resize is under way. Show the overlay now
|
||||
/// (instant feedback). Called only for a genuine change (the follower skips a target equal to the
|
||||
/// live mode), possibly many times as a drag moves through sizes; the timeout re-arms whenever the
|
||||
/// target actually changes so a slow drag never trips it mid-gesture.
|
||||
public mutating func steering(width: UInt32, height: UInt32, now: TimeInterval) {
|
||||
if !active || target?.width != width || target?.height != height {
|
||||
since = now
|
||||
}
|
||||
target = (width, height)
|
||||
active = true
|
||||
}
|
||||
|
||||
/// A decoded frame arrived at `width`×`height` (a new-mode IDR). Clears the overlay once it
|
||||
/// matches the steered target — the sharp new-resolution picture is on glass.
|
||||
public mutating func decoded(width: UInt32, height: UInt32) {
|
||||
guard active, let t = target, t.width == width, t.height == height else { return }
|
||||
active = false
|
||||
since = nil
|
||||
}
|
||||
|
||||
/// Timeout safety net: stop showing the overlay once `timeout` has elapsed with no matching frame
|
||||
/// (a rejected or host-capped switch never delivers the exact target).
|
||||
public mutating func tick(now: TimeInterval) {
|
||||
guard active, let s = since, now - s >= timeout else { return }
|
||||
active = false
|
||||
since = nil
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,9 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import QuartzCore
|
||||
#if os(tvOS)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
/// Weak-target wrapper for CADisplayLink. The link retains its target, so targeting a view or
|
||||
/// presenter directly makes a `owner → link → owner` cycle that only `invalidate()` breaks — if a
|
||||
@@ -22,12 +25,60 @@ public final class DisplayLinkProxy: NSObject {
|
||||
@objc public func tick(_ link: CADisplayLink) { onTick(link) }
|
||||
}
|
||||
|
||||
/// Which presenter a session runs. Stage-2/stage-3 are the same Metal pipeline with arrival vs
|
||||
/// glass-gated present pacing (`PresentPacing` — see Stage2Pipeline for the tradeoff, and why
|
||||
/// stage-3 exists: stage-2's present-on-arrival saturates the layer's FIFO image queue on panels
|
||||
/// running near the stream rate). Stage-1 (compressed video straight to the system layer) is a
|
||||
/// DEBUG-only diagnostic. Internal (not private) for unit tests.
|
||||
enum PresenterChoice: Equatable {
|
||||
case stage1
|
||||
case stage2
|
||||
case stage3
|
||||
|
||||
/// Resolve from the `PUNKTFUNK_PRESENTER` env override (A/B without touching settings) first,
|
||||
/// then the persisted `DefaultsKey.presenter` setting; anything unknown (or an empty env var)
|
||||
/// falls back to the platform default. `allowStage1` is false in release builds, where a
|
||||
/// leftover DEBUG "stage1" value silently maps to the default rather than reviving the
|
||||
/// freeze-prone fallback.
|
||||
static func resolve(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice {
|
||||
let raw = env.flatMap { $0.isEmpty ? nil : $0 } ?? setting
|
||||
switch raw {
|
||||
case "stage1": return allowStage1 ? .stage1 : platformDefault
|
||||
case "stage2": return .stage2
|
||||
case "stage3": return .stage3
|
||||
default: return platformDefault
|
||||
}
|
||||
}
|
||||
|
||||
/// tvOS defaults to GLASS pacing: an Apple TV is the sticky-FIFO worst case by construction —
|
||||
/// a fixed 60 Hz panel fed a 60 fps stream, where arrival pacing pins the layer's image queue
|
||||
/// at ~3 drawables and every frame rides ~50 ms of queue (the measured display stage there).
|
||||
/// The Settings picker can still force stage-2 for an A/B. Everything else keeps stage-2 (the
|
||||
/// proven default; ProMotion/desktop panels out-tick the stream often enough to drain).
|
||||
static var platformDefault: PresenterChoice {
|
||||
#if os(tvOS)
|
||||
.stage3
|
||||
#else
|
||||
.stage2
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
final class SessionPresenter {
|
||||
private var pump: StreamPump?
|
||||
private var stage2: Stage2Pipeline?
|
||||
private var stage2Link: CADisplayLink?
|
||||
private var metalLayer: CAMetalLayer?
|
||||
private var connection: PunktfunkConnection?
|
||||
/// The decoded frame's REAL pixel dimensions (ground truth, pushed by the view from the pump's
|
||||
/// `onDecodedSize` new-mode-IDR callback). Used for the aspect-fit in `layout` in preference to
|
||||
/// `connection.currentMode()`, which (a) lags a mid-stream resize — it only updates on the
|
||||
/// `Reconfigured` ack, and a resize-END produces no bounds change to re-run `layout` afterward —
|
||||
/// and (b) can disagree with what the host actually DELIVERED (Windows corrective-ack falls back
|
||||
/// to an advertised mode). The pixels we're drawing are the only correct aspect source; a wrong
|
||||
/// one here is the "black bars + stretched" resize artifact. nil until the first frame → `layout`
|
||||
/// falls back to `currentMode()`. Main-thread only.
|
||||
private var contentSize: CGSize?
|
||||
|
||||
/// Start the presenter for `connection`. `baseLayer` is the view's AVSampleBufferDisplayLayer:
|
||||
/// stage-1 enqueues into it; stage-2 leaves it idle and composites an opaque CAMetalLayer
|
||||
@@ -43,25 +94,32 @@ final class SessionPresenter {
|
||||
displayMeter: LatencyMeter? = nil,
|
||||
makeDisplayLink: (AnyObject, Selector) -> CADisplayLink,
|
||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||
onSessionEnd: (@Sendable () -> Void)?
|
||||
onSessionEnd: (@Sendable () -> Void)?,
|
||||
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil
|
||||
) {
|
||||
stop()
|
||||
self.connection = connection
|
||||
|
||||
// Presenter choice — stage-2 is the DEFAULT (explicit VTDecompressionSession decode + a
|
||||
// CAMetalLayer/display-link present): it can detect + recover a wedged decoder where
|
||||
// stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference. Stage-1 is
|
||||
// reachable only via the DEBUG presenter toggle; release always takes stage-2 (the stage-1
|
||||
// pump below stays the automatic fallback if Metal is missing).
|
||||
// stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference. Stage-3 is
|
||||
// the same pipeline with glass-gated present pacing (the settings picker's live A/B — see
|
||||
// PresentPacing). Stage-1 is reachable only via the DEBUG presenter value; release maps it
|
||||
// back to stage-2 (the stage-1 pump below stays the automatic fallback if Metal is missing).
|
||||
#if DEBUG
|
||||
let forceStage1 = UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage1"
|
||||
let allowStage1 = true
|
||||
#else
|
||||
let forceStage1 = false
|
||||
let allowStage1 = false
|
||||
#endif
|
||||
if !forceStage1,
|
||||
let choice = PresenterChoice.resolve(
|
||||
setting: UserDefaults.standard.string(forKey: DefaultsKey.presenter),
|
||||
env: ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENTER"],
|
||||
allowStage1: allowStage1)
|
||||
if choice != .stage1,
|
||||
let pipeline = Stage2Pipeline(
|
||||
endToEndMeter: endToEndMeter, decodeMeter: decodeMeter,
|
||||
displayMeter: displayMeter) {
|
||||
displayMeter: displayMeter,
|
||||
pacing: choice == .stage3 ? .glass : .arrival) {
|
||||
let metal = pipeline.layer
|
||||
// The opaque metal layer composites OVER the AVSampleBufferDisplayLayer base, which
|
||||
// sits idle (un-enqueued) in stage-2. contentsScale + frame are set in layout().
|
||||
@@ -80,12 +138,14 @@ final class SessionPresenter {
|
||||
link.add(to: .main, forMode: .common)
|
||||
stage2Link = link
|
||||
syncFrameRate(hz: connection.currentMode().refreshHz)
|
||||
pipeline.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||
pipeline.start(
|
||||
connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd,
|
||||
onDecodedSize: onDecodedSize)
|
||||
} else {
|
||||
let pump = StreamPump()
|
||||
pump.start(
|
||||
connection: connection, layer: baseLayer,
|
||||
onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||
onFrame: onFrame, onSessionEnd: onSessionEnd, onDecodedSize: onDecodedSize)
|
||||
self.pump = pump
|
||||
}
|
||||
}
|
||||
@@ -133,28 +193,68 @@ final class SessionPresenter {
|
||||
guard let metalLayer, let connection else { return }
|
||||
let mode = connection.currentMode()
|
||||
syncFrameRate(hz: mode.refreshHz) // track a mid-session Reconfigure's new refresh
|
||||
let fit: CGRect = (mode.width > 0 && mode.height > 0)
|
||||
? AVMakeRect(
|
||||
aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)),
|
||||
insideRect: bounds)
|
||||
: bounds
|
||||
// Aspect source: the ACTUAL decoded dims when known (survives a lagging `currentMode()` and a
|
||||
// host that delivered a different size than requested), else the negotiated mode. The shader
|
||||
// stretches the frame across the WHOLE drawable, so this rect's aspect is the only thing that
|
||||
// keeps the picture undistorted — a stale aspect here is the post-resize black-bars+stretch.
|
||||
let aspect: CGSize? = {
|
||||
if let c = contentSize, c.width > 0, c.height > 0 { return c }
|
||||
if mode.width > 0, mode.height > 0 {
|
||||
return CGSize(width: Int(mode.width), height: Int(mode.height))
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
let fit: CGRect = aspect.map { AVMakeRect(aspectRatio: $0, insideRect: bounds) } ?? bounds
|
||||
// Snap the sublayer frame to the BACKING PIXEL GRID. AVMakeRect centers the aspect-fit rect,
|
||||
// so its origin/size are usually fractional points; a metal sublayer whose frame doesn't land
|
||||
// on whole device pixels is RESAMPLED by the macOS/UIKit compositor during composite — a
|
||||
// uniform "everything looks soft" blur — even when the drawable itself is pixel-exact 1:1
|
||||
// (verified via the stage2 "[1:1 (no resample)]" log while the picture was still soft). Round
|
||||
// origin AND size to device pixels so the composite is a true 1:1 blit. Idempotent when the
|
||||
// frame is already aligned (e.g. fullscreen fit == integer bounds), so it's a no-op there.
|
||||
let scale = contentsScale > 0 ? contentsScale : 1
|
||||
let snapped = CGRect(
|
||||
x: (fit.origin.x * scale).rounded() / scale,
|
||||
y: (fit.origin.y * scale).rounded() / scale,
|
||||
width: (fit.width * scale).rounded() / scale,
|
||||
height: (fit.height * scale).rounded() / scale)
|
||||
// No implicit resize animation; contentsScale tracks the view's backing/display scale.
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
metalLayer.contentsScale = contentsScale
|
||||
metalLayer.frame = fit
|
||||
metalLayer.frame = snapped
|
||||
CATransaction.commit()
|
||||
// Hand the resulting pixel size to the render thread (it must not read layer geometry
|
||||
// cross-thread) — this is what the presenter sizes its drawable to.
|
||||
// cross-thread) — this is what the presenter sizes its drawable to. Uses the SNAPPED size so
|
||||
// the drawable's texel count equals the on-screen device-pixel count exactly (1 texel ↔ 1
|
||||
// device pixel); with the frame snapped, this equals the pre-snap rounded value, so the
|
||||
// decoded↔drawable 1:1 the log confirmed is preserved.
|
||||
stage2?.setDrawableTarget(CGSize(
|
||||
width: (fit.width * contentsScale).rounded(),
|
||||
height: (fit.height * contentsScale).rounded()))
|
||||
width: (snapped.width * scale).rounded(),
|
||||
height: (snapped.height * scale).rounded()))
|
||||
#if os(tvOS)
|
||||
// Push the display's live EDR headroom alongside: > 1 means the TV is composited in an
|
||||
// HDR mode (the session's AVDisplayManager request landed — see StreamViewIOS), and HDR
|
||||
// frames flip to PQ passthrough. The stream view also re-layouts on mode-switch/screen-
|
||||
// mode notifications, so a mid-session switch reaches here without a bounds change.
|
||||
stage2?.setDisplayHeadroom(UIScreen.main.currentEDRHeadroom)
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Record the decoded frame's real dimensions (the view hops the pump's `onDecodedSize` to main
|
||||
/// and calls this) so `layout` aspect-fits to what's actually on screen instead of the possibly-
|
||||
/// stale `currentMode()`. Only stores — the caller re-runs `layout` right after, because a
|
||||
/// resize-END produces no bounds change to trigger one. No-op on a zero/unchanged size.
|
||||
func setContentSize(_ size: CGSize) {
|
||||
guard size.width > 0, size.height > 0, size != contentSize else { return }
|
||||
contentSize = size
|
||||
}
|
||||
|
||||
/// Stop the active pump/pipeline (≤ one poll timeout; stage-2 joins its pump) and detach the
|
||||
/// stage-2 layer + link. Does not close the connection — that stays with whoever owns it.
|
||||
/// Idempotent.
|
||||
func stop() {
|
||||
contentSize = nil // a new session re-derives it from its first frame
|
||||
pump?.stop()
|
||||
pump = nil
|
||||
stage2Link?.invalidate()
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
// – V-Sync ON: present(at: next vsync) predicted from the link's last phase/period, at most one
|
||||
// period ahead by construction, falling back to immediate when the link data is stale — a
|
||||
// schedule can never sit far in the future holding drawables hostage.
|
||||
// • Present PACING is the stage-2 vs stage-3 presenter split (`PresentPacing`, chosen per session
|
||||
// by SessionPresenter from the presenter setting / PUNKTFUNK_PRESENTER): stage-2 presents on
|
||||
// frame arrival; stage-3 additionally gates presents to ONE undisplayed drawable so the layer's
|
||||
// FIFO image queue can never saturate — see PresentPacing's doc for the full rationale.
|
||||
// • Rendering lives on its own thread so any `nextDrawable()` wait lands off-main (input, SwiftUI).
|
||||
//
|
||||
// The render thread also stamps the unified latency stages (end-to-end capture→on-glass + decode and
|
||||
@@ -98,6 +102,79 @@ private final class VsyncClock: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// When a ready frame is pushed to the layer — the stage-2 vs stage-3 presenter split. Same decode
|
||||
/// half, same newest-wins ring; only the present cadence differs.
|
||||
///
|
||||
/// - `arrival` (stage-2, the default): present the moment a frame is decoded. Lowest latency while
|
||||
/// the layer's image queue is shallow — but that queue is FIFO and consumed at one drawable per
|
||||
/// refresh (iOS always vsync-latches; the macOS 26 compositor latch-paces our out-of-band
|
||||
/// presents the same way when composited), so at stream rate ≈ refresh rate its depth is STICKY:
|
||||
/// one early burst (session start, a Wi-Fi clump) fills it to `maximumDrawableCount` and — with
|
||||
/// arrivals and latches then running at the same rate — it never drains. Every later frame rides
|
||||
/// ~2–3 refreshes of queue (the measured 29–30 ms display stage on 120 Hz ProMotion panels), and
|
||||
/// the full-queue regime is where host↔panel clock drift turns into periodic repeats/drops (the
|
||||
/// "fixed-interval" jitter reports).
|
||||
/// - `glass` (stage-3, experimental): at most ONE presented-but-undisplayed drawable in flight
|
||||
/// (`PresentGate`). The render thread presents only when the previous flip reached glass (the
|
||||
/// drawable's presented handler reopens the gate and re-signals); frames decoded meanwhile
|
||||
/// coalesce in the newest-wins ring. Freshness is preserved by DROPPING stale frames before
|
||||
/// present instead of queueing them behind the display — the hidden queue latency becomes
|
||||
/// explicit, correct frame drops.
|
||||
public enum PresentPacing: Sendable {
|
||||
case arrival
|
||||
case glass
|
||||
}
|
||||
|
||||
/// Stage-3's present gate: admits one in-flight (presented, not yet on glass) drawable. The render
|
||||
/// thread `tryAcquire`s before taking a frame; the drawable's presented handler `release`s and
|
||||
/// re-signals the render thread. `staleAfter` is insurance against a present whose handler never
|
||||
/// fires (the macOS "out-of-band presents aren't damage" hazard class — see MetalVideoPresenter's
|
||||
/// init post-mortem): rather than freezing the stream, a stuck gate force-opens after 100 ms, a
|
||||
/// visible ~10 fps degradation that PUNKTFUNK_PRESENT_DEBUG's `forced` counter exposes (it reads 0
|
||||
/// on healthy systems). Internal (not private) for unit tests. Sendable; lock-guarded — the
|
||||
/// releaser runs on a Metal callback thread.
|
||||
final class PresentGate: @unchecked Sendable {
|
||||
/// How long one pending present may hold the gate before it's presumed lost.
|
||||
static let staleAfter: CFTimeInterval = 0.1
|
||||
|
||||
private let lock = NSLock()
|
||||
private var pending = false
|
||||
private var armedAt: CFTimeInterval = 0
|
||||
private var forced = 0
|
||||
|
||||
/// Arm the gate for one present. False = a present is already in flight (and not stale) —
|
||||
/// leave the frame in the ring; the presented handler's release/re-signal (or the next
|
||||
/// display-link tick) retries with the freshest frame then.
|
||||
func tryAcquire(now: CFTimeInterval) -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if pending {
|
||||
guard now - armedAt > Self.staleAfter else { return false }
|
||||
forced += 1 // presumed-lost present — reopen rather than stall the stream
|
||||
}
|
||||
pending = true
|
||||
armedAt = now
|
||||
return true
|
||||
}
|
||||
|
||||
/// The in-flight present reached glass (or was dropped, or its render failed before a present
|
||||
/// was registered) — reopen. Idempotent: a late stale-path double-release is harmless.
|
||||
func release() {
|
||||
lock.lock()
|
||||
pending = false
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Take-and-reset the force-open count (PUNKTFUNK_PRESENT_DEBUG's `forced` stat).
|
||||
func drainForced() -> Int {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
let n = forced
|
||||
forced = 0
|
||||
return n
|
||||
}
|
||||
}
|
||||
|
||||
/// PUNKTFUNK_PRESENT_DEBUG=1 aggregation: one printed line per second from the render thread with
|
||||
/// the decode rate, render outcomes, the slowest render call (≈ nextDrawable wait) and the deltas
|
||||
/// between system-reported on-glass times (vsync-aligned presents show clean refresh-period
|
||||
@@ -105,22 +182,39 @@ private final class VsyncClock: @unchecked Sendable {
|
||||
private final class PresentDebugStats: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var last = CACurrentMediaTime()
|
||||
private var ok = 0, failed = 0, empty = 0, dropped = 0
|
||||
private var ok = 0, failed = 0, empty = 0, dropped = 0, gated = 0
|
||||
private var maxRenderMs = 0.0
|
||||
private var lastGlassNs: Int64 = 0
|
||||
private var glassDeltasMs: [Double] = []
|
||||
/// Presented-but-not-yet-on-glass drawables right now / the window's peak — the direct
|
||||
/// measurement of the layer image-queue depth the stage-3 gate exists to bound (stage-2 on a
|
||||
/// 120 Hz panel saturates this at ~maximumDrawableCount; stage-3 should peg it at 1).
|
||||
private var inFlight = 0
|
||||
private var maxInFlight = 0
|
||||
|
||||
func emptyWake() { lock.lock(); empty += 1; lock.unlock() }
|
||||
|
||||
/// A wake that found the stage-3 gate closed (a present still in flight) — the frame stays in
|
||||
/// the ring for the handler's re-signal. Includes display-link ticks while gated; a high count
|
||||
/// is normal, it just shows the gate working.
|
||||
func gatedWake() { lock.lock(); gated += 1; lock.unlock() }
|
||||
|
||||
func renderReturned(ok rendered: Bool, tookMs: Double) {
|
||||
lock.lock()
|
||||
if rendered { ok += 1 } else { failed += 1 }
|
||||
if rendered {
|
||||
ok += 1
|
||||
inFlight += 1
|
||||
maxInFlight = max(maxInFlight, inFlight)
|
||||
} else {
|
||||
failed += 1
|
||||
}
|
||||
maxRenderMs = max(maxRenderMs, tookMs)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func presented(atNs: Int64?) {
|
||||
lock.lock()
|
||||
inFlight = max(0, inFlight - 1) // clamp: the handler can beat renderReturned's increment
|
||||
if let atNs {
|
||||
if lastGlassNs > 0 { glassDeltasMs.append(Double(atNs - lastGlassNs) / 1e6) }
|
||||
lastGlassNs = atNs
|
||||
@@ -130,7 +224,7 @@ private final class PresentDebugStats: @unchecked Sendable {
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func flushIfDue(ring: ReadyRing) {
|
||||
func flushIfDue(ring: ReadyRing, gate: PresentGate?) {
|
||||
lock.lock()
|
||||
let now = CACurrentMediaTime()
|
||||
guard now - last >= 1 else { lock.unlock(); return }
|
||||
@@ -139,12 +233,15 @@ private final class PresentDebugStats: @unchecked Sendable {
|
||||
let deltas = glassDeltasMs.sorted()
|
||||
let p50 = deltas.isEmpty ? 0 : deltas[deltas.count / 2]
|
||||
let dMax = deltas.last ?? 0
|
||||
let inflightMax = maxInFlight
|
||||
let line = String(
|
||||
format: "pf-present decoded=%d ok=%d fail=%d empty=%d dropped=%d "
|
||||
+ "maxRenderMs=%.1f glassDeltaMs p50=%.2f max=%.2f n=%d",
|
||||
decoded, ok, failed, empty, dropped, maxRenderMs, p50, dMax, deltas.count)
|
||||
ok = 0; failed = 0; empty = 0; dropped = 0
|
||||
format: "pf-present decoded=%d ok=%d fail=%d empty=%d gated=%d dropped=%d "
|
||||
+ "maxRenderMs=%.1f inflightMax=%d forced=%d glassDeltaMs p50=%.2f max=%.2f n=%d",
|
||||
decoded, ok, failed, empty, gated, dropped, maxRenderMs, inflightMax,
|
||||
gate?.drainForced() ?? 0, p50, dMax, deltas.count)
|
||||
ok = 0; failed = 0; empty = 0; dropped = 0; gated = 0
|
||||
maxRenderMs = 0
|
||||
maxInFlight = inFlight // the window peak restarts from the live depth
|
||||
glassDeltasMs.removeAll(keepingCapacity: true)
|
||||
lock.unlock()
|
||||
print(line)
|
||||
@@ -156,9 +253,16 @@ public final class Stage2Pipeline {
|
||||
private let ring = ReadyRing()
|
||||
private let presenter: MetalVideoPresenter
|
||||
private let decoder: VideoDecoder
|
||||
/// Present cadence — `.arrival` (stage-2) or `.glass` (stage-3, the present gate). Fixed for
|
||||
/// the pipeline's lifetime; SessionPresenter resolves it per session (see PresentPacing).
|
||||
private let pacing: PresentPacing
|
||||
private let endToEndMeter: LatencyMeter?
|
||||
private let displayMeter: LatencyMeter?
|
||||
private let recovery = KeyframeRecovery()
|
||||
/// Post-loss freeze-until-reanchor gate (shared core policy via the C ABI). Created here seeded 0;
|
||||
/// `start` reseeds it to the live connection's drop count. Captured by the decoder callbacks
|
||||
/// (which withhold concealed frames) and driven by the pump (arm on a gap, poll per iteration).
|
||||
private let gate = ReanchorGate(framesDropped: 0)
|
||||
private var token = StopFlag()
|
||||
private var offsetNs: Int64 = 0
|
||||
/// Signalled when the pump thread exits, so `stop()` can join it (bounded) before `decoder.reset()`
|
||||
@@ -190,34 +294,45 @@ public final class Stage2Pipeline {
|
||||
/// (received→decoded); `displayMeter` the display stage (decoded→on-glass, the ring wait +
|
||||
/// render + vsync — the tail stage-2 exists to shorten). All optional: metering never gates
|
||||
/// the presenter choice. Returns nil if Metal can't be set up (headless / no GPU) — caller
|
||||
/// falls back to the stage-1 presenter.
|
||||
/// falls back to the stage-1 presenter. `pacing` selects the stage-2 (arrival) vs stage-3
|
||||
/// (glass-gated) present cadence — see PresentPacing.
|
||||
public init?(
|
||||
endToEndMeter: LatencyMeter?,
|
||||
decodeMeter: LatencyMeter? = nil,
|
||||
displayMeter: LatencyMeter? = nil
|
||||
displayMeter: LatencyMeter? = nil,
|
||||
pacing: PresentPacing = .arrival
|
||||
) {
|
||||
guard let presenter = MetalVideoPresenter.make() else { return nil }
|
||||
self.presenter = presenter
|
||||
self.pacing = pacing
|
||||
self.endToEndMeter = endToEndMeter
|
||||
self.displayMeter = displayMeter
|
||||
let ring = ring
|
||||
let recovery = recovery
|
||||
let renderSignal = renderSignal
|
||||
let gate = gate
|
||||
self.decoder = VideoDecoder(
|
||||
onDecoded: { frame in
|
||||
// Decode stage = received→decoded, both client CLOCK_REALTIME (offset 0 — no
|
||||
// skew applies). Stamped at decode completion, so it covers every decoded frame,
|
||||
// including ones the newest-wins ring drops before present.
|
||||
// including ones the re-anchor gate withholds or the newest-wins ring drops.
|
||||
decodeMeter?.record(
|
||||
ptsNs: UInt64(frame.receivedNs), atNs: frame.decodedNs, offsetNs: 0)
|
||||
// Freeze-until-reanchor: WITHHOLD a decoder-concealed post-loss frame (the gray/
|
||||
// garbage VideoToolbox returns Ok for a reference-missing delta) — don't submit it,
|
||||
// so the CAMetalLayer keeps its last good drawable on glass. The gate lifts (returns
|
||||
// present) on a proven clean re-anchor (IDR / RFI anchor / 2nd recovery mark) or the
|
||||
// bounded backstop. decoderKeyframe=false: VT doesn't flag IDRs, the wire FLAG_SOF does.
|
||||
guard gate.onDecoded(flags: frame.flags) else { return }
|
||||
ring.submit(frame)
|
||||
// FRAME ARRIVAL is the render trigger (never the display link — see the header).
|
||||
renderSignal.signal()
|
||||
},
|
||||
// Async decode failure (a bad P-frame referencing a lost/corrupt IDR): the pump resets to
|
||||
// re-gate on the next IDR, and we ask the host to send one now (infinite GOP — it wouldn't
|
||||
// Async decode failure (a bad P-frame referencing a lost/corrupt IDR): fold it into the
|
||||
// gate's no-output streak (which arms the freeze after a short run, matching the desktop),
|
||||
// and when that trips ask the host for a fresh IDR now (infinite GOP — it wouldn't
|
||||
// otherwise come soon). Throttled in KeyframeRecovery.
|
||||
onDecodeError: { _ in recovery.request() })
|
||||
onDecodeError: { _ in if gate.onNoOutput() { recovery.request() } })
|
||||
}
|
||||
|
||||
/// Start pulling AUs into the decoder. MAIN THREAD. `onFrame` fires per AU at receipt (the
|
||||
@@ -226,10 +341,12 @@ public final class Stage2Pipeline {
|
||||
public func start(
|
||||
connection: PunktfunkConnection,
|
||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||
onSessionEnd: (@Sendable () -> Void)?
|
||||
onSessionEnd: (@Sendable () -> Void)?,
|
||||
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil
|
||||
) {
|
||||
offsetNs = connection.clockOffsetNs
|
||||
recovery.bind(connection) // arm host-keyframe recovery for this session
|
||||
gate.reseed(framesDropped: connection.framesDropped()) // baseline the freeze to this session
|
||||
token = StopFlag() // fresh token per start — a stop is permanent (like StreamPump)
|
||||
|
||||
// Configure the decoder's chroma + the layer's initial colorimetry before the first frame. The
|
||||
@@ -244,9 +361,13 @@ public final class Stage2Pipeline {
|
||||
let recovery = recovery
|
||||
let presenter = presenter
|
||||
let pumpStopped = pumpStopped
|
||||
let reanchorGate = gate
|
||||
let thread = Thread {
|
||||
defer { pumpStopped.signal() } // let stop() join the pump (bounded) before decoder.reset()
|
||||
var format: CMVideoFormatDescription?
|
||||
// Report coded dims to the resize overlay only on a CHANGE (new-mode IDR), not per
|
||||
// loss-recovery IDR at the same size (see StreamPump).
|
||||
var lastDecodedDims: CMVideoDimensions?
|
||||
var lastFramesDropped = connection.framesDropped()
|
||||
// Persistent recovery WANT, not a one-shot edge (see StreamPump for the full rationale):
|
||||
// keep asking until an IDR lands so a request swallowed by the throttle is re-sent.
|
||||
@@ -255,7 +376,12 @@ public final class Stage2Pipeline {
|
||||
// decode 4:4:4 at the negotiated resolution (the HW probe clears the common case but not a
|
||||
// resolution-ceiling miss). End cleanly instead of looping on a black screen.
|
||||
var decodeFailRun = 0
|
||||
while !token.isStopped {
|
||||
// Every iteration drains its own autorelease pool: this thread has no runloop, so
|
||||
// autoreleased VT/CM temporaries would otherwise accumulate until session end.
|
||||
// `false` = session over — exit the loop (the closure can't `break` across itself).
|
||||
var alive = true
|
||||
while alive, !token.isStopped {
|
||||
alive = autoreleasepool { () -> Bool in
|
||||
do {
|
||||
// Loss recovery (the primary path). The reassembler drops unrecoverable AUs and the
|
||||
// decoder conceals the reference-missing deltas — often WITHOUT an error callback —
|
||||
@@ -267,6 +393,9 @@ public final class Stage2Pipeline {
|
||||
awaitingIDR = true
|
||||
}
|
||||
if awaitingIDR { recovery.request() }
|
||||
// Freeze backstop: a drop-count climb arms the gate (in case the frame-index gap
|
||||
// below was itself lost), and an overdue freeze re-asks for the re-anchor.
|
||||
if reanchorGate.poll(framesDropped: dropped) { recovery.request() }
|
||||
// Drain HDR mastering metadata (0xCE) and hand it to the PRESENTER (→ CAEDRMetadata).
|
||||
// Polled UNCONDITIONALLY (not gated on connection.isHDR, the fixed Welcome flag): the
|
||||
// host sends 0xCE only for HDR, INCLUDING a mid-session SDR→HDR transition (a game
|
||||
@@ -275,13 +404,25 @@ public final class Stage2Pipeline {
|
||||
if let meta = try? connection.nextHdrMeta(timeoutMs: 0) {
|
||||
presenter.setHdrMeta(meta)
|
||||
}
|
||||
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
||||
guard let au = try connection.nextAU(timeoutMs: 100) else { return true }
|
||||
// Loss recovery (RFI): a forward frame-index gap fires a throttled reference-
|
||||
// frame-invalidation request so an RFI-capable host (AMD LTR / NVENC) recovers
|
||||
// with a cheap clean P-frame instead of a full IDR. The framesDropped-driven
|
||||
// recovery above stays the backstop for when the recovery frame itself is lost.
|
||||
// The same gap is the earliest, most precise signal to ARM the display freeze —
|
||||
// the following concealed frames are withheld until a clean re-anchor.
|
||||
if connection.noteFrameIndexGap(au.frameIndex) { reanchorGate.arm() }
|
||||
onFrame?(au)
|
||||
if let f = connection.videoCodec.formatDescription(fromKeyframe: au.data) {
|
||||
format = f // refreshed on every IDR (mode changes included)
|
||||
let dims = CMVideoFormatDescriptionGetDimensions(f)
|
||||
if lastDecodedDims?.width != dims.width || lastDecodedDims?.height != dims.height {
|
||||
lastDecodedDims = dims
|
||||
onDecodedSize?(Int(dims.width), Int(dims.height))
|
||||
}
|
||||
awaitingIDR = false // a fresh IDR re-anchored decode — recovery complete
|
||||
}
|
||||
guard let f = format, !token.isStopped else { continue }
|
||||
guard let f = format, !token.isStopped else { return true }
|
||||
if decoder.decode(au: au, format: f) {
|
||||
decodeFailRun = 0
|
||||
} else {
|
||||
@@ -294,12 +435,14 @@ public final class Stage2Pipeline {
|
||||
// recovers within a GOP) ⇒ 4:4:4 isn't decodable here; end the session.
|
||||
if connection.isChroma444, decodeFailRun >= 180 {
|
||||
if !token.isStopped { onSessionEnd?() }
|
||||
break
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
if !token.isStopped { onSessionEnd?() }
|
||||
break // session closed
|
||||
return false // session closed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,17 +470,34 @@ public final class Stage2Pipeline {
|
||||
&& UserDefaults.standard.bool(forKey: DefaultsKey.vsync))
|
||||
let debugStats = presentDebug ? PresentDebugStats() : nil
|
||||
let vsyncClock = vsyncClock
|
||||
// Stage-3's one-in-flight present gate; nil = stage-2's present-on-arrival. A local (like
|
||||
// the ring) so neither the render thread nor the presented handlers capture `self`.
|
||||
let gate: PresentGate? = pacing == .glass ? PresentGate() : nil
|
||||
let renderThread = Thread {
|
||||
defer { renderStopped.signal() }
|
||||
while !token.isStopped {
|
||||
// Every iteration drains its own autorelease pool (`return` = the old `continue`):
|
||||
// this thread has no runloop, and `nextDrawable()` AUTORELEASES each CAMetalDrawable —
|
||||
// without a per-iteration pool every presented frame's drawable object (plus its
|
||||
// texture-descriptor/array retinue, ~2 MB/min at 120 fps) piles up until session end.
|
||||
while !token.isStopped { autoreleasepool {
|
||||
if renderSignal.wait(timeout: .now() + .milliseconds(100)) == .timedOut {
|
||||
debugStats?.flushIfDue(ring: ring)
|
||||
continue
|
||||
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||
return
|
||||
}
|
||||
// Stage-3: while a present is in flight, don't take from the ring at all — frames
|
||||
// keep coalescing there (newest wins, the intended drop point) and the presented
|
||||
// handler re-signals the moment the slot frees. Checked BEFORE the take so a gated
|
||||
// frame is never bounced through putBack.
|
||||
if let gate, !gate.tryAcquire(now: CACurrentMediaTime()) {
|
||||
debugStats?.gatedWake()
|
||||
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||
return
|
||||
}
|
||||
guard !token.isStopped, let frame = ring.take() else {
|
||||
gate?.release() // armed but nothing to render — don't hold the gate stale
|
||||
debugStats?.emptyWake()
|
||||
debugStats?.flushIfDue(ring: ring)
|
||||
continue
|
||||
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||
return
|
||||
}
|
||||
// V-Sync ON: flip on the next predicted vsync (< one period out, stale link ⇒
|
||||
// immediate — see VsyncClock). OFF: flip as soon as the GPU finishes.
|
||||
@@ -347,6 +507,12 @@ public final class Stage2Pipeline {
|
||||
let rendered = presenter.render(
|
||||
frame.pixelBuffer, isHDR: frame.isHDR, presentAtMediaTime: presentAt
|
||||
) { presentedNs in
|
||||
// Stage-3: the flip reached glass (or was dropped) — free the present slot,
|
||||
// then re-signal so the freshest waiting ring frame goes out immediately.
|
||||
if let gate {
|
||||
gate.release()
|
||||
renderSignal.signal()
|
||||
}
|
||||
// Fallback stamp for a dropped drawable (no system presentedTime): "now" on
|
||||
// the Metal callback, converted to the CLOCK_REALTIME the meters live in.
|
||||
let atNs = presentedNs
|
||||
@@ -361,9 +527,12 @@ public final class Stage2Pipeline {
|
||||
}
|
||||
debugStats?.renderReturned(
|
||||
ok: rendered, tookMs: (CACurrentMediaTime() - renderStarted) * 1000)
|
||||
if !rendered { ring.putBack(frame) }
|
||||
debugStats?.flushIfDue(ring: ring)
|
||||
}
|
||||
if !rendered {
|
||||
gate?.release() // no present registered — its handler will never fire
|
||||
ring.putBack(frame)
|
||||
}
|
||||
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||
} }
|
||||
}
|
||||
renderThread.name = "punktfunk-stage2-render"
|
||||
renderThread.qualityOfService = .userInteractive
|
||||
@@ -387,6 +556,13 @@ public final class Stage2Pipeline {
|
||||
presenter.setDrawableTarget(size)
|
||||
}
|
||||
|
||||
/// Forward the display's current EDR headroom to the presenter (MAIN thread — a `UIScreen`
|
||||
/// read). tvOS flips HDR presentation between PQ passthrough and the in-shader tone-map on
|
||||
/// it; see `MetalVideoPresenter.setDisplayHeadroom`.
|
||||
public func setDisplayHeadroom(_ headroom: CGFloat) {
|
||||
presenter.setDisplayHeadroom(headroom)
|
||||
}
|
||||
|
||||
/// Stop the pump + render thread (≤ one poll timeout each) and drop the decode session. MAIN
|
||||
/// THREAD; idempotent. Does not close the connection. A restart needs a fresh Stage2Pipeline
|
||||
/// (the stop is permanent).
|
||||
|
||||
@@ -21,12 +21,18 @@ final class StreamPump {
|
||||
connection: PunktfunkConnection,
|
||||
layer: AVSampleBufferDisplayLayer,
|
||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||
onSessionEnd: (@Sendable () -> Void)?
|
||||
onSessionEnd: (@Sendable () -> Void)?,
|
||||
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil
|
||||
) {
|
||||
let token = token
|
||||
// Coalesced host keyframe requests (100 ms throttle — see KeyframeRecovery).
|
||||
let recovery = KeyframeRecovery()
|
||||
recovery.bind(connection)
|
||||
// Post-loss freeze-until-reanchor (shared core policy via the C ABI). Stage-1 has no per-frame
|
||||
// decode callback, so the gate is folded at ENQUEUE (from the AU's wire flags): a withheld
|
||||
// frame is still enqueued but flagged DoNotDisplay so the layer's decoder keeps the reference
|
||||
// chain fed while the last GOOD picture stays on glass — until a clean re-anchor lifts it.
|
||||
let gate = ReanchorGate(framesDropped: connection.framesDropped())
|
||||
// The layer is non-Sendable but its enqueue/flush are documented thread-safe, and after
|
||||
// this point only the pump thread drives it — assert that so the @Sendable Thread closure
|
||||
// may capture it.
|
||||
@@ -35,6 +41,9 @@ final class StreamPump {
|
||||
|
||||
let thread = Thread {
|
||||
var format: CMVideoFormatDescription?
|
||||
// Report the coded dims to the resize overlay only when they CHANGE (a new-mode IDR),
|
||||
// not on every loss-recovery IDR at the same size — so it fires once per real switch.
|
||||
var lastDecodedDims: CMVideoDimensions?
|
||||
var lastFramesDropped = connection.framesDropped()
|
||||
// Recovery is a persistent WANT, not a one-shot edge: set it on detected loss (or a
|
||||
// decoder reset), retry the throttled request EVERY iteration, and clear it only when a
|
||||
@@ -47,7 +56,12 @@ final class StreamPump {
|
||||
var awaitingIDR = false
|
||||
var awaitingSince = Date.distantPast // when the current recovery began (for the resume log)
|
||||
var wasFailed = false
|
||||
while !token.isStopped {
|
||||
// Every iteration drains its own autorelease pool: this thread has no runloop, so
|
||||
// autoreleased CM/layer temporaries would otherwise accumulate until session end.
|
||||
// `false` = session over — exit the loop (the closure can't `break` across itself).
|
||||
var alive = true
|
||||
while alive, !token.isStopped {
|
||||
alive = autoreleasepool { () -> Bool in
|
||||
do {
|
||||
// Loss recovery (the primary path). Under the host's infinite GOP the only
|
||||
// recovery keyframe is one we request. The reassembler drops unrecoverable AUs
|
||||
@@ -68,12 +82,26 @@ final class StreamPump {
|
||||
awaitingIDR = true
|
||||
}
|
||||
if awaitingIDR { recovery.request() }
|
||||
// Freeze backstop: a drop-count climb arms the gate (should the frame-index gap
|
||||
// below be lost too), and an overdue freeze re-asks for the re-anchor.
|
||||
if gate.poll(framesDropped: dropped) { recovery.request() }
|
||||
|
||||
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
||||
guard let au = try connection.nextAU(timeoutMs: 100) else { return true }
|
||||
// Loss recovery (RFI): a forward frame-index gap fires a throttled reference-
|
||||
// frame-invalidation request so an RFI-capable host (AMD LTR / NVENC) recovers
|
||||
// with a cheap clean P-frame instead of a full IDR. The framesDropped-driven
|
||||
// recovery above stays the backstop for when the recovery frame itself is lost.
|
||||
// The same gap is the earliest, most precise signal to ARM the display freeze.
|
||||
if connection.noteFrameIndexGap(au.frameIndex) { gate.arm() }
|
||||
onFrame?(au)
|
||||
let idrFormat = connection.videoCodec.formatDescription(fromKeyframe: au.data)
|
||||
if let f = idrFormat {
|
||||
format = f // refreshed on every IDR (mode changes included)
|
||||
let dims = CMVideoFormatDescriptionGetDimensions(f)
|
||||
if lastDecodedDims?.width != dims.width || lastDecodedDims?.height != dims.height {
|
||||
lastDecodedDims = dims
|
||||
onDecodedSize?(Int(dims.width), Int(dims.height))
|
||||
}
|
||||
if awaitingIDR {
|
||||
let ms = Int(Date().timeIntervalSince(awaitingSince) * 1000)
|
||||
pumpLog.notice("video: recovery IDR received — resumed after \(ms, privacy: .public) ms")
|
||||
@@ -88,6 +116,7 @@ final class StreamPump {
|
||||
// delta into a failed layer can't recover it.
|
||||
if !wasFailed { pumpLog.warning("video: display layer .failed — flushing + re-anchoring") }
|
||||
layer.flush()
|
||||
gate.arm() // a wedged decoder is a loss — freeze until the re-anchor
|
||||
if idrFormat == nil {
|
||||
format = nil
|
||||
awaitingIDR = true
|
||||
@@ -97,13 +126,22 @@ final class StreamPump {
|
||||
guard let f = format,
|
||||
let sample = connection.videoCodec.sampleBuffer(au: au, format: f),
|
||||
!token.isStopped // don't enqueue a stale frame after a restart
|
||||
else { continue }
|
||||
else { return true }
|
||||
// Freeze-until-reanchor: while holding, WITHHOLD this concealed post-loss frame by
|
||||
// flagging it DoNotDisplay — the layer still decodes it (keeping the reference
|
||||
// chain fed) but shows the last GOOD picture until a clean re-anchor lifts the
|
||||
// gate. Folded from the AU's wire flags (stage-1 has no decode callback).
|
||||
if !gate.onDecoded(flags: au.flags) {
|
||||
StreamPump.setDoNotDisplay(sample)
|
||||
}
|
||||
layer.enqueue(sample)
|
||||
return true
|
||||
} catch {
|
||||
if !token.isStopped {
|
||||
onSessionEnd?()
|
||||
}
|
||||
break // session closed
|
||||
return false // session closed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,6 +150,21 @@ final class StreamPump {
|
||||
thread.start()
|
||||
}
|
||||
|
||||
/// Flag a sample decode-but-don't-display (`kCMSampleAttachmentKey_DoNotDisplay`). Used to
|
||||
/// withhold decoder-concealed post-loss frames while the re-anchor gate holds: the layer keeps
|
||||
/// its reference chain fed without flipping the frozen picture. No-op if the attachments array
|
||||
/// can't be materialized (then the frame just displays — the freeze degrades to the old behavior).
|
||||
private static func setDoNotDisplay(_ sample: CMSampleBuffer) {
|
||||
guard let attachments = CMSampleBufferGetSampleAttachmentsArray(
|
||||
sample, createIfNecessary: true), CFArrayGetCount(attachments) > 0
|
||||
else { return }
|
||||
let dict = unsafeBitCast(CFArrayGetValueAtIndex(attachments, 0), to: CFMutableDictionary.self)
|
||||
CFDictionarySetValue(
|
||||
dict,
|
||||
Unmanaged.passUnretained(kCMSampleAttachmentKey_DoNotDisplay).toOpaque(),
|
||||
Unmanaged.passUnretained(kCFBooleanTrue).toOpaque())
|
||||
}
|
||||
|
||||
/// Stop pumping (≤ one poll timeout). Does not close the connection.
|
||||
func stop() {
|
||||
token.stop()
|
||||
|
||||
@@ -27,19 +27,40 @@ public struct ReadyFrame: @unchecked Sendable {
|
||||
/// True when the stream is HDR (BT.2020 PQ): the buffer is 10-bit P010 and the presenter must
|
||||
/// configure EDR + BT.2020 PQ output. Derived from the decoded buffer's pixel format.
|
||||
public let isHDR: Bool
|
||||
/// The AU's wire `user_flags` (`AccessUnit.flags`), threaded through the decode via the frame
|
||||
/// context so the re-anchor gate can classify this decoded frame (IDR / RFI anchor / recovery
|
||||
/// mark) at present time — the async decode callback has no other access to it. 0 when unknown.
|
||||
public let flags: UInt32
|
||||
}
|
||||
|
||||
/// Per-frame context threaded through the VideoToolbox frame refcon: the AU's receipt instant (for
|
||||
/// the decode-stage meter) and its wire `user_flags` (for the re-anchor gate). Retained across the
|
||||
/// async decode and reclaimed exactly once — by the output callback for every frame VideoToolbox
|
||||
/// accepts, or by `decode`'s error branch for a frame `DecodeFrame` rejected outright (the callback
|
||||
/// then never fires). A tiny per-frame allocation, the price of smuggling two values (a 64-bit
|
||||
/// instant plus the flags) through the single `void*` a bit-pattern scalar can't hold.
|
||||
private final class FrameContext {
|
||||
let receivedNs: Int64
|
||||
let flags: UInt32
|
||||
init(receivedNs: Int64, flags: UInt32) {
|
||||
self.receivedNs = receivedNs
|
||||
self.flags = flags
|
||||
}
|
||||
}
|
||||
|
||||
/// The C output callback can't capture context, so VideoToolbox hands it the refcon we set at
|
||||
/// session creation — a pointer back to the owning `VideoDecoder`. The per-frame refcon carries
|
||||
/// the AU's `receivedNs` as a pointer bit pattern (a scalar smuggled through the C void*, never
|
||||
/// dereferenced) so the decode stage can be computed against decode-completion.
|
||||
/// session creation — a pointer back to the owning `VideoDecoder`. The per-frame refcon is the
|
||||
/// retained `FrameContext` set at submit; reclaim it here (balancing `passRetained`) and unpack the
|
||||
/// AU's receipt instant (for the decode stage) and wire flags (for the re-anchor gate).
|
||||
private let decoderOutputCallback: VTDecompressionOutputCallback = {
|
||||
refcon, frameRefcon, status, _, imageBuffer, pts, _ in
|
||||
guard let refcon else { return }
|
||||
let receivedNs = frameRefcon.map { Int64(Int(bitPattern: $0)) } ?? 0
|
||||
let ctx = frameRefcon.map { Unmanaged<FrameContext>.fromOpaque($0).takeRetainedValue() }
|
||||
Unmanaged<VideoDecoder>.fromOpaque(refcon)
|
||||
.takeUnretainedValue()
|
||||
.handleDecoded(status: status, imageBuffer: imageBuffer, pts: pts, receivedNs: receivedNs)
|
||||
.handleDecoded(
|
||||
status: status, imageBuffer: imageBuffer, pts: pts,
|
||||
receivedNs: ctx?.receivedNs ?? 0, flags: ctx?.flags ?? 0)
|
||||
}
|
||||
|
||||
/// Owns a `VTDecompressionSession` rebuilt whenever the format description changes (every IDR /
|
||||
@@ -117,16 +138,21 @@ public final class VideoDecoder: @unchecked Sendable {
|
||||
let sample = codec.sampleBuffer(au: au, format: newFormat)
|
||||
else { lock.unlock(); return false }
|
||||
var infoOut = VTDecodeInfoFlags()
|
||||
// The AU's receipt instant + wire flags ride through as a retained context; the output
|
||||
// callback reclaims it. Retain immediately before submit so no early return can leak it.
|
||||
let ctx = FrameContext(receivedNs: au.receivedNs, flags: au.flags)
|
||||
let refcon = Unmanaged.passRetained(ctx).toOpaque()
|
||||
let status = VTDecompressionSessionDecodeFrame(
|
||||
session,
|
||||
sampleBuffer: sample,
|
||||
flags: [._EnableAsynchronousDecompression],
|
||||
// The AU's receipt instant rides through as a bit pattern (nil for 0 — the output
|
||||
// callback maps that back to 0); the callback needs it to stamp the decode stage.
|
||||
frameRefcon: UnsafeMutableRawPointer(bitPattern: Int(au.receivedNs)),
|
||||
frameRefcon: refcon,
|
||||
infoFlagsOut: &infoOut)
|
||||
lock.unlock()
|
||||
if status != noErr {
|
||||
// DecodeFrame rejected the frame outright — the output callback will NOT fire, so
|
||||
// reclaim the context here (balancing passRetained) to avoid leaking it.
|
||||
Unmanaged<FrameContext>.fromOpaque(refcon).release()
|
||||
onDecodeError(status)
|
||||
return false
|
||||
}
|
||||
@@ -231,9 +257,10 @@ public final class VideoDecoder: @unchecked Sendable {
|
||||
}
|
||||
|
||||
/// VT thread. Stamp decode-completion and enqueue, or report the error. `receivedNs` is the
|
||||
/// AU's receipt instant threaded through the frame refcon (0 = unknown).
|
||||
/// AU's receipt instant and `flags` its wire `user_flags`, both threaded through the frame refcon
|
||||
/// (0 = unknown).
|
||||
fileprivate func handleDecoded(
|
||||
status: OSStatus, imageBuffer: CVImageBuffer?, pts: CMTime, receivedNs: Int64
|
||||
status: OSStatus, imageBuffer: CVImageBuffer?, pts: CMTime, receivedNs: Int64, flags: UInt32
|
||||
) {
|
||||
guard status == noErr, let imageBuffer else {
|
||||
onDecodeError(status)
|
||||
@@ -259,6 +286,6 @@ public final class VideoDecoder: @unchecked Sendable {
|
||||
onDecoded(
|
||||
ReadyFrame(
|
||||
ptsNs: ptsNs, receivedNs: receivedNs, decodedNs: decodedNs,
|
||||
pixelBuffer: imageBuffer, isHDR: isHDR))
|
||||
pixelBuffer: imageBuffer, isHDR: isHDR, flags: flags))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,8 @@ public struct StreamView: NSViewRepresentable {
|
||||
private let onDisconnectRequest: (() -> Void)?
|
||||
private let onFrame: (@Sendable (AccessUnit) -> Void)?
|
||||
private let onSessionEnd: (@Sendable () -> Void)?
|
||||
private let onResizeTarget: ((UInt32, UInt32) -> Void)?
|
||||
private let onDecodedSize: (@Sendable (Int, Int) -> Void)?
|
||||
private let endToEndMeter: LatencyMeter?
|
||||
private let decodeMeter: LatencyMeter?
|
||||
private let displayMeter: LatencyMeter?
|
||||
@@ -108,6 +110,8 @@ public struct StreamView: NSViewRepresentable {
|
||||
onDisconnectRequest: (() -> Void)? = nil,
|
||||
onFrame: (@Sendable (AccessUnit) -> Void)? = nil,
|
||||
onSessionEnd: (@Sendable () -> Void)? = nil,
|
||||
onResizeTarget: ((UInt32, UInt32) -> Void)? = nil,
|
||||
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
|
||||
endToEndMeter: LatencyMeter? = nil,
|
||||
decodeMeter: LatencyMeter? = nil,
|
||||
displayMeter: LatencyMeter? = nil
|
||||
@@ -118,6 +122,8 @@ public struct StreamView: NSViewRepresentable {
|
||||
self.onDisconnectRequest = onDisconnectRequest
|
||||
self.onFrame = onFrame
|
||||
self.onSessionEnd = onSessionEnd
|
||||
self.onResizeTarget = onResizeTarget
|
||||
self.onDecodedSize = onDecodedSize
|
||||
self.endToEndMeter = endToEndMeter
|
||||
self.decodeMeter = decodeMeter
|
||||
self.displayMeter = displayMeter
|
||||
@@ -131,6 +137,8 @@ public struct StreamView: NSViewRepresentable {
|
||||
view.endToEndMeter = endToEndMeter
|
||||
view.decodeMeter = decodeMeter
|
||||
view.displayMeter = displayMeter
|
||||
view.onResizeTarget = onResizeTarget
|
||||
view.onDecodedSize = onDecodedSize
|
||||
view.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||
return view
|
||||
}
|
||||
@@ -142,6 +150,8 @@ public struct StreamView: NSViewRepresentable {
|
||||
view.endToEndMeter = endToEndMeter
|
||||
view.decodeMeter = decodeMeter
|
||||
view.displayMeter = displayMeter
|
||||
view.onResizeTarget = onResizeTarget
|
||||
view.onDecodedSize = onDecodedSize
|
||||
// SwiftUI reuses the NSView across state changes — repoint the pump only when the
|
||||
// connection identity actually changed.
|
||||
if view.connection !== connection {
|
||||
@@ -165,6 +175,15 @@ public final class StreamLayerView: NSView {
|
||||
/// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback.
|
||||
private let presenter = SessionPresenter()
|
||||
public private(set) var connection: PunktfunkConnection?
|
||||
/// Match-window resize follower (C3) — non-nil while a session is active AND the `matchWindow`
|
||||
/// setting is on (DEFAULT on, for pixel-exact windowed streaming); fed the view's physical-pixel
|
||||
/// size on every relayout so the host mode tracks the window (1:1, no presenter resample).
|
||||
private var matchFollower: MatchWindowFollower?
|
||||
/// Last decoded frame size fed into the presenter's aspect-fit. A new-mode IDR after a resize
|
||||
/// re-fits the metal sublayer to the REAL content aspect here — `layout()` only re-runs on a
|
||||
/// bounds change and a resize-END has none, so without this the layer keeps its pre-resize aspect
|
||||
/// and the shader stretches the new frame into it (black bars + squish). Main-thread only.
|
||||
private var lastDecodedContentSize: CGSize?
|
||||
private let cursorCapture = CursorCapture()
|
||||
private var inputCapture: InputCapture?
|
||||
private var appObservers: [NSObjectProtocol] = []
|
||||
@@ -201,6 +220,13 @@ public final class StreamLayerView: NSView {
|
||||
/// view can't do that itself (the connection's owner disconnects).
|
||||
public var onDisconnectRequest: (() -> Void)?
|
||||
|
||||
/// Resize overlay signals (design/midstream-resolution-resize.md client UX): `onResizeTarget`
|
||||
/// (main thread, via the follower) fires the instant the window starts steering toward a new
|
||||
/// size; `onDecodedSize` (PUMP thread) fires when a new-mode IDR's dims land. The owner drives
|
||||
/// the blur+spinner from these — set before `start()`.
|
||||
public var onResizeTarget: ((UInt32, UInt32) -> Void)?
|
||||
public var onDecodedSize: (@Sendable (Int, Int) -> Void)?
|
||||
|
||||
/// Main-thread only. False = input capture disabled outright (UI layered over the
|
||||
/// stream); flipping to true auto-engages once.
|
||||
public var captureEnabled = true {
|
||||
@@ -594,13 +620,12 @@ public final class StreamLayerView: NSView {
|
||||
guard let self, self.window?.isKeyWindow == true else { return }
|
||||
self.onDisconnectRequest?()
|
||||
}
|
||||
capture.onToggleStats = { [weak self] in
|
||||
capture.onCycleStats = { [weak self] in
|
||||
guard self?.window?.isKeyWindow == true else { return }
|
||||
// Flip the shared setting directly — every @AppStorage reader (the HUD's visibility,
|
||||
// the menu item's title) observes UserDefaults, so this is the same as the menu path.
|
||||
let defaults = UserDefaults.standard
|
||||
let current = defaults.object(forKey: DefaultsKey.hudEnabled) as? Bool ?? true
|
||||
defaults.set(!current, forKey: DefaultsKey.hudEnabled)
|
||||
// Advance the shared tier setting directly — every @AppStorage reader (the HUD's
|
||||
// visibility/content, the Settings pickers) observes UserDefaults, so this is the
|
||||
// same as the menu path.
|
||||
StatsVerbosity.store(StatsVerbosity.current.next())
|
||||
}
|
||||
capture.start()
|
||||
inputCapture = capture
|
||||
@@ -619,6 +644,10 @@ public final class StreamLayerView: NSView {
|
||||
// (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by
|
||||
// default, the stage-1 pump as the Metal-missing / DEBUG fallback. The link comes from
|
||||
// NSView.displayLink so it tracks the display this view is on.
|
||||
// Intercept the pump's coded-dims callback: re-fit the metal sublayer to the real content
|
||||
// aspect (main thread) BEFORE forwarding to the owner's overlay END-signal. Fires only on a
|
||||
// size CHANGE (first frame + each resolved resize), so this is rare, not per-frame.
|
||||
let overlayDecodedSize = onDecodedSize
|
||||
presenter.start(
|
||||
connection: connection,
|
||||
baseLayer: displayLayer,
|
||||
@@ -627,15 +656,39 @@ public final class StreamLayerView: NSView {
|
||||
displayMeter: displayMeter,
|
||||
makeDisplayLink: { displayLink(target: $0, selector: $1) },
|
||||
onFrame: onFrame,
|
||||
onSessionEnd: onSessionEnd)
|
||||
onSessionEnd: onSessionEnd,
|
||||
onDecodedSize: { [weak self] w, h in // resize overlay END signal (new-mode IDR dims)
|
||||
DispatchQueue.main.async { self?.noteDecodedContentSize(width: w, height: h) }
|
||||
overlayDecodedSize?(w, h)
|
||||
})
|
||||
// Match-window (C3): when ON, follow the window's pixel size so a windowed session streams
|
||||
// 1:1 (pixel-exact) instead of the presenter resampling a fixed-mode frame into a
|
||||
// non-matching window. The first real `layout()` feeds the initial size, so the stream
|
||||
// converges to the window even though the connect used the explicit/display mode; entering
|
||||
// fullscreen reports the full-display px, restoring a native-res 1:1 present there too.
|
||||
// OPT-IN — `?? false` matches the Settings toggle (which also defaults off); an unset
|
||||
// default keeps the explicit mode.
|
||||
let follower = MatchWindowFollower(
|
||||
connection: connection,
|
||||
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false)
|
||||
follower.onResizeTarget = onResizeTarget // resize overlay START signal (instant, on the follower)
|
||||
matchFollower = follower
|
||||
layoutPresenter()
|
||||
requestAutoCapture() // entering a session is the deliberate "capture me" moment
|
||||
}
|
||||
|
||||
/// Aspect-fit the stage-2 metal sublayer to the view; refresh contentsScale on a
|
||||
/// retina↔non-retina move (see SessionPresenter.layout).
|
||||
/// retina↔non-retina move (see SessionPresenter.layout). Also feeds the Match-window follower
|
||||
/// the view's physical-pixel size (bounds → backing), so a window resize / retina move follows.
|
||||
private func layoutPresenter() {
|
||||
presenter.layout(in: bounds, contentsScale: window?.backingScaleFactor ?? 1)
|
||||
// Feed the follower only once in a window (backing scale is real then) and with real
|
||||
// bounds — a pre-window layout would report point-sized dimensions.
|
||||
if window != nil, bounds.width > 0, bounds.height > 0 {
|
||||
let px = convertToBacking(bounds).size
|
||||
matchFollower?.noteSize(
|
||||
widthPx: Int(px.width.rounded()), heightPx: Int(px.height.rounded()))
|
||||
}
|
||||
}
|
||||
|
||||
public override func viewDidChangeBackingProperties() {
|
||||
@@ -643,6 +696,18 @@ public final class StreamLayerView: NSView {
|
||||
layoutPresenter() // backing scale changed (e.g. moved to a non-retina display)
|
||||
}
|
||||
|
||||
/// A new decoded size landed (a new-mode IDR after a resize, or the session's first frame): push
|
||||
/// it to the presenter's aspect-fit and re-layout NOW. A resize-END triggers no `layout()`, so
|
||||
/// this is what makes the metal sublayer track the new content aspect instead of stretching the
|
||||
/// new frame into the pre-resize box. Deduped so a same-size repeat is a no-op. Main thread.
|
||||
private func noteDecodedContentSize(width: Int, height: Int) {
|
||||
let size = CGSize(width: width, height: height)
|
||||
guard size.width > 0, size.height > 0, size != lastDecodedContentSize else { return }
|
||||
lastDecodedContentSize = size
|
||||
presenter.setContentSize(size)
|
||||
layoutPresenter()
|
||||
}
|
||||
|
||||
/// Stop pumping (≤ one poll timeout). Does not close the connection — that stays with
|
||||
/// whoever owns it (PunktfunkConnection.close() is safe alongside a draining pump).
|
||||
public func stop() {
|
||||
@@ -651,6 +716,8 @@ public final class StreamLayerView: NSView {
|
||||
inputCapture?.stop()
|
||||
inputCapture = nil
|
||||
presenter.stop()
|
||||
matchFollower = nil
|
||||
lastDecodedContentSize = nil // the next session re-derives it from its first frame
|
||||
connection = nil
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,9 @@
|
||||
// (== locked): GCMouse forwards only WHILE locked, the UIKit indirect path (motion, buttons AND
|
||||
// scroll) only while NOT locked — so a pointer that emits both channels under lock can't double-send.
|
||||
// Hardware keyboard forwarding shares InputCapture with macOS — auto-engaged when streaming
|
||||
// starts, ⌘⎋ toggles (detected from the HID stream; there is no NSEvent monitor here).
|
||||
// starts, ⌘⎋ toggles and ⌃⌥⇧Q releases (both detected from the HID stream; there is no NSEvent
|
||||
// monitor here). ⌃⌥⇧Q is the cross-client Ctrl+Alt+Shift+Q — it un-captures so the Magic Keyboard
|
||||
// trackpad drives the local iPad UI again.
|
||||
//
|
||||
// The public type is named StreamView like its macOS twin (each is platform-gated), so
|
||||
// the SwiftUI app layer is identical on both platforms.
|
||||
@@ -36,6 +38,9 @@ import PunktfunkCore
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import os
|
||||
#if os(tvOS)
|
||||
import AVKit // AVDisplayManager — the per-session display-mode (HDR10/refresh) request
|
||||
#endif
|
||||
|
||||
/// Same diagnostic switch as InputCapture (PUNKTFUNK_INPUT_DEBUG=1): on iOS we log the
|
||||
/// resolved pointer-lock state each time capture engages, so the user can see whether the
|
||||
@@ -50,6 +55,8 @@ public struct StreamView: UIViewControllerRepresentable {
|
||||
private let onCaptureChange: ((Bool) -> Void)?
|
||||
private let onFrame: (@Sendable (AccessUnit) -> Void)?
|
||||
private let onSessionEnd: (@Sendable () -> Void)?
|
||||
private let onResizeTarget: ((UInt32, UInt32) -> Void)?
|
||||
private let onDecodedSize: (@Sendable (Int, Int) -> Void)?
|
||||
private let endToEndMeter: LatencyMeter?
|
||||
private let decodeMeter: LatencyMeter?
|
||||
private let displayMeter: LatencyMeter?
|
||||
@@ -65,6 +72,8 @@ public struct StreamView: UIViewControllerRepresentable {
|
||||
onDisconnectRequest: (() -> Void)? = nil,
|
||||
onFrame: (@Sendable (AccessUnit) -> Void)? = nil,
|
||||
onSessionEnd: (@Sendable () -> Void)? = nil,
|
||||
onResizeTarget: ((UInt32, UInt32) -> Void)? = nil,
|
||||
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
|
||||
endToEndMeter: LatencyMeter? = nil,
|
||||
decodeMeter: LatencyMeter? = nil,
|
||||
displayMeter: LatencyMeter? = nil
|
||||
@@ -74,6 +83,8 @@ public struct StreamView: UIViewControllerRepresentable {
|
||||
self.onCaptureChange = onCaptureChange
|
||||
self.onFrame = onFrame
|
||||
self.onSessionEnd = onSessionEnd
|
||||
self.onResizeTarget = onResizeTarget
|
||||
self.onDecodedSize = onDecodedSize
|
||||
self.endToEndMeter = endToEndMeter
|
||||
self.decodeMeter = decodeMeter
|
||||
self.displayMeter = displayMeter
|
||||
@@ -86,6 +97,8 @@ public struct StreamView: UIViewControllerRepresentable {
|
||||
controller.endToEndMeter = endToEndMeter
|
||||
controller.decodeMeter = decodeMeter
|
||||
controller.displayMeter = displayMeter
|
||||
controller.onResizeTarget = onResizeTarget
|
||||
controller.onDecodedSize = onDecodedSize
|
||||
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||
return controller
|
||||
}
|
||||
@@ -96,6 +109,8 @@ public struct StreamView: UIViewControllerRepresentable {
|
||||
controller.endToEndMeter = endToEndMeter
|
||||
controller.decodeMeter = decodeMeter
|
||||
controller.displayMeter = displayMeter
|
||||
controller.onResizeTarget = onResizeTarget
|
||||
controller.onDecodedSize = onDecodedSize
|
||||
if controller.connection !== connection {
|
||||
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||
}
|
||||
@@ -108,7 +123,20 @@ public struct StreamView: UIViewControllerRepresentable {
|
||||
}
|
||||
}
|
||||
|
||||
public final class StreamViewController: UIViewController {
|
||||
#if os(tvOS)
|
||||
/// tvOS: a GCEventViewController with `controllerUserInteractionEnabled = false` routes game-
|
||||
/// controller (and Siri Remote) input EXCLUSIVELY to the GameController framework while the
|
||||
/// stream is up. Without it a pad's B/Menu press doubles as a UIKit menu press — which ended
|
||||
/// the session (or suspended the whole app) from ordinary gameplay; a SwiftUI
|
||||
/// `.onExitCommand {}` swallow proved unreliable with nothing focusable on screen. Every
|
||||
/// in-session exit is GC-level by design: the pad's escape chord (GamepadCapture) and the
|
||||
/// remote's hold-Back (SiriRemotePointer).
|
||||
public typealias StreamViewControllerBase = GCEventViewController
|
||||
#else
|
||||
public typealias StreamViewControllerBase = UIViewController
|
||||
#endif
|
||||
|
||||
public final class StreamViewController: StreamViewControllerBase {
|
||||
public private(set) var connection: PunktfunkConnection?
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
/// Record the unified latency stages (end-to-end / decode / display) when the stage-2
|
||||
@@ -119,6 +147,11 @@ public final class StreamViewController: UIViewController {
|
||||
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
|
||||
/// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback.
|
||||
private let presenter = SessionPresenter()
|
||||
#if os(tvOS)
|
||||
/// The window's display manager the session's mode request was set on — held weakly so
|
||||
/// stop() can clear the request even after the view has left the window.
|
||||
private weak var sessionDisplayManager: AVDisplayManager?
|
||||
#endif
|
||||
#if os(iOS)
|
||||
private var inputCapture: InputCapture?
|
||||
fileprivate var captured = false
|
||||
@@ -126,6 +159,12 @@ public final class StreamViewController: UIViewController {
|
||||
/// Capture state at the last resign, restored on the next foreground — otherwise the
|
||||
/// mouse/keyboard stay released after navigating out and nothing re-grabs them.
|
||||
private var wasCapturedOnResign = false
|
||||
/// Match-window resize follower (C3) — non-nil while a session is active AND the `matchWindow`
|
||||
/// setting is on (DEFAULT on, for pixel-exact scene streaming); fed the view's physical-pixel
|
||||
/// size from `viewDidLayoutSubviews` so an iPad Stage Manager / Split View scene resize
|
||||
/// renegotiates the host mode (1:1, no presenter resample). iOS only (iPhone naturally no-ops
|
||||
/// its fixed full-screen scene; tvOS drives display modes via AVDisplayManager instead).
|
||||
private var matchFollower: MatchWindowFollower?
|
||||
#endif
|
||||
|
||||
/// Reads whether the scene's pointer is actually locked right now; nil = state
|
||||
@@ -140,6 +179,18 @@ public final class StreamViewController: UIViewController {
|
||||
}
|
||||
|
||||
var onCaptureChange: ((Bool) -> Void)?
|
||||
/// Resize-overlay START: forwarded to the Match-window follower so a scene resize drives the
|
||||
/// blur+spinner the instant the window differs from the live mode (iOS only — tvOS has no
|
||||
/// follower). See `MatchWindowFollower.onResizeTarget`.
|
||||
var onResizeTarget: ((UInt32, UInt32) -> Void)?
|
||||
/// Resize-overlay END: the presenter reports the coded dims of each new-mode IDR here, so the
|
||||
/// overlay clears when a frame at the requested size actually decodes.
|
||||
var onDecodedSize: (@Sendable (Int, Int) -> Void)?
|
||||
/// Last decoded size fed into the presenter's aspect-fit. A new-mode IDR (an iPad scene resize,
|
||||
/// or a tvOS AVDisplayManager mode switch) re-fits the metal sublayer to the REAL content aspect
|
||||
/// here — `viewDidLayoutSubviews` only re-runs on a bounds change, which a resize-END lacks, so
|
||||
/// without this the layer keeps its pre-resize aspect and stretches the new frame into it. Main.
|
||||
private var lastDecodedContentSize: CGSize?
|
||||
|
||||
var captureEnabled = true {
|
||||
didSet {
|
||||
@@ -157,6 +208,12 @@ public final class StreamViewController: UIViewController {
|
||||
|
||||
public override func loadView() {
|
||||
view = StreamLayerUIView()
|
||||
#if os(tvOS)
|
||||
// Kill the pad/remote → UIKit press path at the source for the whole session (see the
|
||||
// GCEventViewController typealias above). GC delivery is untouched: GamepadCapture
|
||||
// forwards the pad, SiriRemotePointer drives the pointer and owns the remote exit.
|
||||
controllerUserInteractionEnabled = false
|
||||
#endif
|
||||
// Re-size the stage-2 drawable if the display scale changes without a bounds change (e.g.
|
||||
// moving to an external display at a different scale) — the iOS analogue of macOS's
|
||||
// viewDidChangeBackingProperties relayout. The handler takes the VC as its argument, so it
|
||||
@@ -230,6 +287,18 @@ public final class StreamViewController: UIViewController {
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(tvOS)
|
||||
// The GCEventViewController's interaction flag applies to the deepest such controller
|
||||
// CONTAINING THE FIRST RESPONDER — inside SwiftUI's hosting-controller sandwich that is not
|
||||
// guaranteed to be us unless we anchor the responder chain here explicitly.
|
||||
public override var canBecomeFirstResponder: Bool { true }
|
||||
|
||||
public override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
becomeFirstResponder()
|
||||
}
|
||||
#endif
|
||||
|
||||
func start(
|
||||
connection: PunktfunkConnection,
|
||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||
@@ -270,7 +339,19 @@ public final class StreamViewController: UIViewController {
|
||||
x: p.x, y: p.y, surfaceWidth: p.w, surfaceHeight: p.h)
|
||||
}
|
||||
streamView.onPointerButton = { [weak self] button, down in
|
||||
guard let self, self.inputCapture?.gcMouseForwarding == false else { return }
|
||||
guard let self else { return }
|
||||
// Released → a trackpad/mouse click into the video RE-ENGAGES capture (the iPad
|
||||
// analogue of macOS's `mouseDown → engageCapture(fromClick:)`, and the click-mirror of
|
||||
// the ⌘⎋ / ⌃⌥⇧Q keyboard toggles). Only the button-DOWN engages; that click is the local
|
||||
// engage gesture, so it's suppressed toward the host (`fromClick`) and never forwarded —
|
||||
// its release is swallowed by InputCapture's suppress latch, whichever path delivers it.
|
||||
// (Finger taps are untouched: touch always plays directly, so only the indirect pointer
|
||||
// re-captures.) Captured already → the absolute path forwards the button as before.
|
||||
if !self.captured {
|
||||
if down, self.captureEnabled { self.setCaptured(true, fromClick: true) }
|
||||
return
|
||||
}
|
||||
guard self.inputCapture?.gcMouseForwarding == false else { return }
|
||||
self.inputCapture?.sendMouseButton(button, pressed: down)
|
||||
}
|
||||
streamView.onScroll = { [weak self] dx, dy in
|
||||
@@ -283,16 +364,38 @@ public final class StreamViewController: UIViewController {
|
||||
guard let self else { return }
|
||||
self.setCaptured(!self.captured)
|
||||
}
|
||||
// ⌃⌥⇧Q (cross-client parity with macOS/Windows/Linux) releases the captured pointer +
|
||||
// keyboard so the Magic Keyboard trackpad returns to driving the local iPad UI. Detected
|
||||
// from the HID stream in InputCapture (no NSEvent monitor on iOS); unlike the ⌘⎋ toggle it
|
||||
// only ever RELEASES — re-pressing it while already released is a no-op (setCaptured guards).
|
||||
capture.onReleaseCapture = { [weak self] in
|
||||
self?.setCaptured(false)
|
||||
}
|
||||
capture.onPreempted = { [weak self] in
|
||||
self?.setCaptured(false)
|
||||
}
|
||||
capture.start()
|
||||
inputCapture = capture
|
||||
// Match-window (C3): when ON, follow the scene's pixel size so a resizable iPad scene
|
||||
// streams 1:1 (pixel-exact) instead of the presenter resampling a fixed-mode frame into it.
|
||||
// `viewDidLayoutSubviews` feeds it — covers Stage Manager / Split View resizes and rotation.
|
||||
// iPhone is a fixed full-screen scene, so this naturally no-ops (reports the device mode).
|
||||
// OPT-IN — `?? false` matches the Settings toggle (which also defaults off); an unset
|
||||
// default keeps the explicit mode.
|
||||
let follower = MatchWindowFollower(
|
||||
connection: connection,
|
||||
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false)
|
||||
follower.onResizeTarget = onResizeTarget
|
||||
matchFollower = follower
|
||||
#endif
|
||||
|
||||
// Presenter choice + lifecycle live in SessionPresenter (shared with macOS): stage-2
|
||||
// (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by
|
||||
// default, the stage-1 pump as the Metal-missing / DEBUG fallback.
|
||||
// Intercept the pump's coded-dims callback: re-fit the metal sublayer to the real content
|
||||
// aspect (main thread) BEFORE forwarding to the owner's overlay END-signal. Fires only on a
|
||||
// size CHANGE (first frame + each resolved resize), so this is rare, not per-frame.
|
||||
let overlayDecodedSize = onDecodedSize
|
||||
presenter.start(
|
||||
connection: connection,
|
||||
baseLayer: streamView.displayLayer,
|
||||
@@ -301,7 +404,11 @@ public final class StreamViewController: UIViewController {
|
||||
displayMeter: displayMeter,
|
||||
makeDisplayLink: { CADisplayLink(target: $0, selector: $1) },
|
||||
onFrame: onFrame,
|
||||
onSessionEnd: onSessionEnd)
|
||||
onSessionEnd: onSessionEnd,
|
||||
onDecodedSize: { [weak self] w, h in
|
||||
DispatchQueue.main.async { self?.noteDecodedContentSize(width: w, height: h) }
|
||||
overlayDecodedSize?(w, h)
|
||||
})
|
||||
layoutMetalLayer()
|
||||
|
||||
#if os(iOS)
|
||||
@@ -337,11 +444,37 @@ public final class StreamViewController: UIViewController {
|
||||
) { [weak self] _ in
|
||||
self?.syncPointerLock()
|
||||
})
|
||||
// The Stream menu's "Release Mouse" (⌃⌥⇧Q) posts this — the discoverable menu surface for
|
||||
// the RELEASED state. While CAPTURED the combo is recognized from the HID stream in
|
||||
// InputCapture (onReleaseCapture) before the menu sees it, so in practice this fires as a
|
||||
// not-captured no-op (setCaptured guards it); wired for honesty + a non-GC fallback. Only the
|
||||
// foreground-active scene's stream acts — the iPad analogue of macOS's key-window guard, so a
|
||||
// second Stage Manager scene isn't released out from under the user.
|
||||
observers.append(NotificationCenter.default.addObserver(
|
||||
forName: .punktfunkReleaseCapture, object: nil, queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self,
|
||||
self.view.window?.windowScene?.activationState == .foregroundActive else { return }
|
||||
self.setCaptured(false)
|
||||
})
|
||||
|
||||
if captureEnabled {
|
||||
setCaptured(true) // entering a session is the deliberate "capture me" moment
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(tvOS)
|
||||
// The TV's mode switch (requested in applyDisplayCriteriaIfNeeded) completes
|
||||
// asynchronously, and a dynamic-range-only switch doesn't re-layout by itself —
|
||||
// re-layout on the switch/mode notifications so the presenter sees the new EDR
|
||||
// headroom immediately (layout pushes UIScreen.currentEDRHeadroom down).
|
||||
observers.append(NotificationCenter.default.addObserver(
|
||||
forName: .AVDisplayManagerModeSwitchEnd, object: nil, queue: .main
|
||||
) { [weak self] _ in self?.layoutMetalLayer() })
|
||||
observers.append(NotificationCenter.default.addObserver(
|
||||
forName: UIScreen.modeDidChangeNotification, object: nil, queue: .main
|
||||
) { [weak self] _ in self?.layoutMetalLayer() })
|
||||
#endif
|
||||
}
|
||||
|
||||
func stop() {
|
||||
@@ -359,16 +492,76 @@ public final class StreamViewController: UIViewController {
|
||||
streamView.onPointerButton = nil
|
||||
streamView.onScroll = nil
|
||||
streamView.currentHostMode = nil
|
||||
matchFollower = nil
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
// Return the TV to the user's preferred mode — the home screen must not stay in the
|
||||
// session's HDR10/refresh mode.
|
||||
sessionDisplayManager?.preferredDisplayCriteria = nil
|
||||
sessionDisplayManager = nil
|
||||
#endif
|
||||
presenter.stop()
|
||||
lastDecodedContentSize = nil // the next session re-derives it from its first frame
|
||||
connection = nil
|
||||
}
|
||||
|
||||
public override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
layoutMetalLayer()
|
||||
#if os(iOS)
|
||||
// Match-window (C3): feed the follower the view's physical-pixel size (points × scale).
|
||||
let b = streamView.bounds
|
||||
if b.width > 0, b.height > 0 {
|
||||
let scale = renderScale
|
||||
matchFollower?.noteSize(
|
||||
widthPx: Int((b.width * scale).rounded()),
|
||||
heightPx: Int((b.height * scale).rounded()))
|
||||
}
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
applyDisplayCriteriaIfNeeded()
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
/// Ask the TV for a display mode matching the session — HDR10 at the stream's refresh rate —
|
||||
/// via AVDisplayManager, the tvOS mechanism custom renderers use for HDR output (AVFoundation
|
||||
/// playback layers do this implicitly). Honored only when the user allows matching (tvOS
|
||||
/// Settings → Video and Audio → Match Content); the presenter reads the RESULT off UIScreen's
|
||||
/// EDR headroom (pushed in SessionPresenter.layout) and keeps the in-shader tone-map whenever
|
||||
/// the switch never lands, so an SDR-composited display can't show blown-out PQ either way.
|
||||
/// Applied once per session, as soon as the window and the negotiated mode both exist; the
|
||||
/// stop() teardown clears it.
|
||||
private func applyDisplayCriteriaIfNeeded() {
|
||||
guard let manager = view.window?.avDisplayManager, let connection,
|
||||
manager.preferredDisplayCriteria == nil,
|
||||
UserDefaults.standard.object(forKey: DefaultsKey.hdrEnabled) as? Bool ?? true
|
||||
else { return }
|
||||
let mode = connection.currentMode()
|
||||
guard mode.width > 0, mode.height > 0, mode.refreshHz > 0 else { return }
|
||||
// A synthetic HDR10-HEVC format description carrying the negotiated mode — what the
|
||||
// stream decodes to. AVDisplayCriteria(refreshRate:formatDescription:) matches the
|
||||
// display to it (tvOS 17+, our deployment floor).
|
||||
let ext: [CFString: Any] = [
|
||||
kCMFormatDescriptionExtension_ColorPrimaries:
|
||||
kCMFormatDescriptionColorPrimaries_ITU_R_2020,
|
||||
kCMFormatDescriptionExtension_TransferFunction:
|
||||
kCMFormatDescriptionTransferFunction_SMPTE_ST_2084_PQ,
|
||||
kCMFormatDescriptionExtension_YCbCrMatrix:
|
||||
kCMFormatDescriptionYCbCrMatrix_ITU_R_2020,
|
||||
]
|
||||
var desc: CMFormatDescription?
|
||||
CMVideoFormatDescriptionCreate(
|
||||
allocator: kCFAllocatorDefault, codecType: kCMVideoCodecType_HEVC,
|
||||
width: Int32(mode.width), height: Int32(mode.height),
|
||||
extensions: ext as CFDictionary, formatDescriptionOut: &desc)
|
||||
guard let desc else { return }
|
||||
manager.preferredDisplayCriteria = AVDisplayCriteria(
|
||||
refreshRate: Float(mode.refreshHz), formatDescription: desc)
|
||||
sessionDisplayManager = manager
|
||||
}
|
||||
#endif
|
||||
|
||||
/// The display scale to render the metal drawable at. `traitCollection.displayScale` is the
|
||||
/// canonical render scale and is reliable once the controller is in the hierarchy;
|
||||
/// `view.contentScaleFactor` can read 1.0 before the view attaches to a window/screen, which
|
||||
@@ -385,12 +578,28 @@ public final class StreamViewController: UIViewController {
|
||||
presenter.layout(in: streamView.bounds, contentsScale: renderScale)
|
||||
}
|
||||
|
||||
/// A new decoded size landed (a scene/mode resize's new IDR, or the first frame): push it to the
|
||||
/// presenter's aspect-fit and re-layout NOW. A resize-END triggers no `viewDidLayoutSubviews`, so
|
||||
/// this is what makes the metal sublayer track the new content aspect instead of stretching the
|
||||
/// new frame into the pre-resize box. Deduped so a same-size repeat is a no-op. Main thread.
|
||||
private func noteDecodedContentSize(width: Int, height: Int) {
|
||||
let size = CGSize(width: width, height: height)
|
||||
guard size.width > 0, size.height > 0, size != lastDecodedContentSize else { return }
|
||||
lastDecodedContentSize = size
|
||||
presenter.setContentSize(size)
|
||||
layoutMetalLayer()
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private func setCaptured(_ on: Bool) {
|
||||
/// `fromClick` marks a click-driven engage (the released-state pointer click that re-captures):
|
||||
/// that click's press/release are suppressed toward the host — it's the local engage gesture,
|
||||
/// not a host click — exactly as macOS's `engageCapture(fromClick:)` does. Keyboard-driven
|
||||
/// engages (⌘⎋) pass false so a normal click still reaches the host.
|
||||
private func setCaptured(_ on: Bool, fromClick: Bool = false) {
|
||||
if on {
|
||||
// `connection != nil` is the session-active gate (presenter internals are opaque here).
|
||||
guard captureEnabled, !captured, connection != nil else { return }
|
||||
inputCapture?.setForwarding(true)
|
||||
inputCapture?.setForwarding(true, suppressClick: fromClick)
|
||||
captured = true
|
||||
} else {
|
||||
guard captured else { return }
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import CoreMedia
|
||||
import CoreVideo
|
||||
import VideoToolbox
|
||||
import XCTest
|
||||
import simd
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
/// Golden end-to-end colour tests: decode the known-signaling bar fixtures through a real
|
||||
/// `VTDecompressionSession`, read the buffer's propagated signaling via `CscRows.signal(of:)`,
|
||||
/// convert sampled Y′CbCr through `CscRows.rows` — the exact math the Metal shaders run — and
|
||||
/// require the ORIGINAL RGB bars back. This is the proof of the two assumptions the stage-2
|
||||
/// colour fix rests on: (1) VideoToolbox propagates the bitstream's matrix onto the decoded
|
||||
/// CVPixelBuffer's attachments, and (2) signal+rows renders it correctly for BT.601/709 ×
|
||||
/// limited/full. A hardcoded-709 regression fails the 601 fixture by tens of code points.
|
||||
final class ColorBarDecodeTests: XCTestCase {
|
||||
private static let bars: [(r: Float, g: Float, b: Float)] = [
|
||||
(255, 255, 255), (255, 255, 0), (0, 255, 255), (0, 255, 0),
|
||||
(255, 0, 255), (255, 0, 0), (0, 0, 255), (0, 0, 0),
|
||||
]
|
||||
|
||||
/// Decode one fixture AU to a biplanar 4:2:0 buffer of the given range sibling.
|
||||
private func decode(_ au: [UInt8], pixelFormat: OSType) throws -> CVPixelBuffer {
|
||||
let data = Data(au)
|
||||
guard let format = AnnexB.formatDescription(fromIDR: data, codec: .hevc) else {
|
||||
throw XCTSkip("could not build a format description from the fixture")
|
||||
}
|
||||
let attrs: [CFString: Any] = [kCVPixelBufferPixelFormatTypeKey: pixelFormat]
|
||||
var session: VTDecompressionSession?
|
||||
let created = VTDecompressionSessionCreate(
|
||||
allocator: kCFAllocatorDefault, formatDescription: format,
|
||||
decoderSpecification: nil, imageBufferAttributes: attrs as CFDictionary,
|
||||
outputCallback: nil, decompressionSessionOut: &session)
|
||||
guard created == noErr, let session else {
|
||||
throw XCTSkip("VTDecompressionSessionCreate failed (\(created))")
|
||||
}
|
||||
defer { VTDecompressionSessionInvalidate(session) }
|
||||
let unit = AccessUnit(data: data, ptsNs: 0, frameIndex: 0, flags: 0, receivedNs: 0)
|
||||
guard let sample = AnnexB.sampleBuffer(au: unit, format: format, codec: .hevc) else {
|
||||
throw XCTSkip("could not build a sample buffer")
|
||||
}
|
||||
var produced: CVPixelBuffer?
|
||||
let status = VTDecompressionSessionDecodeFrame(
|
||||
session, sampleBuffer: sample, flags: [], infoFlagsOut: nil
|
||||
) { status, _, imageBuffer, _, _ in
|
||||
if status == noErr { produced = imageBuffer }
|
||||
}
|
||||
XCTAssertEqual(status, noErr, "decode submit")
|
||||
VTDecompressionSessionWaitForAsynchronousFrames(session)
|
||||
return try XCTUnwrap(produced, "no decoded frame")
|
||||
}
|
||||
|
||||
private func assertBars(
|
||||
_ name: String, au: [UInt8], pixelFormat: OSType,
|
||||
expected: CscRows.Signal
|
||||
) throws {
|
||||
let buffer = try decode(au, pixelFormat: pixelFormat)
|
||||
let signal = CscRows.signal(of: buffer)
|
||||
XCTAssertEqual(signal, expected, "\(name): VT must propagate the bitstream signaling")
|
||||
|
||||
let rows = CscRows.rows(signal, depth: 8, msbPacked: false)
|
||||
CVPixelBufferLockBaseAddress(buffer, .readOnly)
|
||||
defer { CVPixelBufferUnlockBaseAddress(buffer, .readOnly) }
|
||||
let yBase = try XCTUnwrap(CVPixelBufferGetBaseAddressOfPlane(buffer, 0))
|
||||
.assumingMemoryBound(to: UInt8.self)
|
||||
let yStride = CVPixelBufferGetBytesPerRowOfPlane(buffer, 0)
|
||||
let cBase = try XCTUnwrap(CVPixelBufferGetBaseAddressOfPlane(buffer, 1))
|
||||
.assumingMemoryBound(to: UInt8.self)
|
||||
let cStride = CVPixelBufferGetBytesPerRowOfPlane(buffer, 1)
|
||||
|
||||
for (i, bar) in Self.bars.enumerated() {
|
||||
let (cx, cy) = (i * 32 + 16, 32)
|
||||
let y = Float(yBase[cy * yStride + cx]) / 255.0
|
||||
let cb = Float(cBase[(cy / 2) * cStride + (cx / 2) * 2]) / 255.0
|
||||
let cr = Float(cBase[(cy / 2) * cStride + (cx / 2) * 2 + 1]) / 255.0
|
||||
let yuv = SIMD3<Float>(y, cb, cr)
|
||||
let rgb = SIMD3<Float>(
|
||||
simd_dot(SIMD3(rows.r0.x, rows.r0.y, rows.r0.z), yuv) + rows.r0.w,
|
||||
simd_dot(SIMD3(rows.r1.x, rows.r1.y, rows.r1.z), yuv) + rows.r1.w,
|
||||
simd_dot(SIMD3(rows.r2.x, rows.r2.y, rows.r2.z), yuv) + rows.r2.w)
|
||||
XCTAssertEqual(rgb.x * 255, bar.r, accuracy: 3, "\(name) bar \(i) R")
|
||||
XCTAssertEqual(rgb.y * 255, bar.g, accuracy: 3, "\(name) bar \(i) G")
|
||||
XCTAssertEqual(rgb.z * 255, bar.b, accuracy: 3, "\(name) bar \(i) B")
|
||||
}
|
||||
}
|
||||
|
||||
/// BT.601 (BT.470BG) limited — what a Linux host's RGB-input NVENC signals. The fixture that
|
||||
/// catches a hardcoded-BT.709 shader.
|
||||
func testGolden601LimitedBars() throws {
|
||||
try assertBars(
|
||||
"601-limited", au: ColorBarFixtures.bars601Limited,
|
||||
pixelFormat: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange,
|
||||
expected: .init(matrix: 5, fullRange: false))
|
||||
}
|
||||
|
||||
/// BT.709 limited — the hosts' explicit SDR signaling.
|
||||
func testGolden709LimitedBars() throws {
|
||||
try assertBars(
|
||||
"709-limited", au: ColorBarFixtures.bars709Limited,
|
||||
pixelFormat: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange,
|
||||
expected: .init(matrix: 1, fullRange: false))
|
||||
}
|
||||
|
||||
/// BT.709 full range — the PUNKTFUNK_444_FULLRANGE experiment's signaling (requesting the
|
||||
/// full-range sibling keeps VT from range-converting, so the full-range rows are exercised).
|
||||
func testGolden709FullBars() throws {
|
||||
try assertBars(
|
||||
"709-full", au: ColorBarFixtures.bars709Full,
|
||||
pixelFormat: kCVPixelFormatType_420YpCbCr8BiPlanarFullRange,
|
||||
expected: .init(matrix: 1, fullRange: true))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,864 @@
|
||||
// Golden colour-bar fixtures — the SAME bytes as crates/pf-client-core/tests/bars-*.h265
|
||||
// (one 256×64 LOSSLESS x265 IDR of 8 saturated bars per signaling variant; generated
|
||||
// offline with ffmpeg/libx265, RGB→YUV matched to the declared VUI so the original RGB
|
||||
// is recoverable ±1 code). Regenerate both together — the Rust and Swift golden tests
|
||||
// must chew identical streams. Test-target only; nothing here ships.
|
||||
|
||||
enum ColorBarFixtures {
|
||||
static let bars601Limited: [UInt8] = [
|
||||
0x00, 0x00, 0x00, 0x01, 0x40, 0x01, 0x0c, 0x01, 0xff, 0xff, 0x01, 0x60, 0x00, 0x00, 0x03, 0x00,
|
||||
0x90, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0xff, 0x95, 0x98, 0x09, 0x00, 0x00, 0x00, 0x01,
|
||||
0x42, 0x01, 0x01, 0x01, 0x60, 0x00, 0x00, 0x03, 0x00, 0x90, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03,
|
||||
0x00, 0xff, 0xa0, 0x08, 0x08, 0x10, 0x59, 0x65, 0x66, 0x92, 0x4c, 0xae, 0x6a, 0x02, 0x02, 0x0a,
|
||||
0x08, 0x00, 0x00, 0x03, 0x00, 0x08, 0x00, 0x00, 0x03, 0x00, 0xc8, 0x40, 0x00, 0x00, 0x00, 0x01,
|
||||
0x44, 0x01, 0xc1, 0x71, 0xa9, 0x12, 0x00, 0x00, 0x01, 0x4e, 0x01, 0x05, 0xff, 0xff, 0xff, 0xff,
|
||||
0xff, 0xff, 0xff, 0xff, 0xd1, 0x2c, 0xa2, 0xde, 0x09, 0xb5, 0x17, 0x47, 0xdb, 0xbb, 0x55, 0xa4,
|
||||
0xfe, 0x7f, 0xc2, 0xfc, 0x4e, 0x78, 0x32, 0x36, 0x35, 0x20, 0x28, 0x62, 0x75, 0x69, 0x6c, 0x64,
|
||||
0x20, 0x32, 0x31, 0x36, 0x29, 0x20, 0x2d, 0x20, 0x34, 0x2e, 0x32, 0x2b, 0x31, 0x2d, 0x65, 0x34,
|
||||
0x34, 0x34, 0x37, 0x34, 0x34, 0x3a, 0x5b, 0x4d, 0x61, 0x63, 0x20, 0x4f, 0x53, 0x20, 0x58, 0x5d,
|
||||
0x5b, 0x63, 0x6c, 0x61, 0x6e, 0x67, 0x20, 0x32, 0x31, 0x2e, 0x30, 0x2e, 0x30, 0x5d, 0x5b, 0x36,
|
||||
0x34, 0x20, 0x62, 0x69, 0x74, 0x5d, 0x20, 0x38, 0x62, 0x69, 0x74, 0x2b, 0x31, 0x30, 0x62, 0x69,
|
||||
0x74, 0x2b, 0x31, 0x32, 0x62, 0x69, 0x74, 0x20, 0x2d, 0x20, 0x48, 0x2e, 0x32, 0x36, 0x35, 0x2f,
|
||||
0x48, 0x45, 0x56, 0x43, 0x20, 0x63, 0x6f, 0x64, 0x65, 0x63, 0x20, 0x2d, 0x20, 0x43, 0x6f, 0x70,
|
||||
0x79, 0x72, 0x69, 0x67, 0x68, 0x74, 0x20, 0x32, 0x30, 0x31, 0x33, 0x2d, 0x32, 0x30, 0x31, 0x38,
|
||||
0x20, 0x28, 0x63, 0x29, 0x20, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x63, 0x6f, 0x72, 0x65, 0x77, 0x61,
|
||||
0x72, 0x65, 0x2c, 0x20, 0x49, 0x6e, 0x63, 0x20, 0x2d, 0x20, 0x68, 0x74, 0x74, 0x70, 0x3a, 0x2f,
|
||||
0x2f, 0x78, 0x32, 0x36, 0x35, 0x2e, 0x6f, 0x72, 0x67, 0x20, 0x2d, 0x20, 0x6f, 0x70, 0x74, 0x69,
|
||||
0x6f, 0x6e, 0x73, 0x3a, 0x20, 0x63, 0x70, 0x75, 0x69, 0x64, 0x3d, 0x39, 0x38, 0x20, 0x66, 0x72,
|
||||
0x61, 0x6d, 0x65, 0x2d, 0x74, 0x68, 0x72, 0x65, 0x61, 0x64, 0x73, 0x3d, 0x31, 0x20, 0x6e, 0x6f,
|
||||
0x2d, 0x77, 0x70, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x70, 0x6d, 0x6f, 0x64, 0x65, 0x20, 0x6e, 0x6f,
|
||||
0x2d, 0x70, 0x6d, 0x65, 0x20, 0x6e, 0x6f, 0x2d, 0x70, 0x73, 0x6e, 0x72, 0x20, 0x6e, 0x6f, 0x2d,
|
||||
0x73, 0x73, 0x69, 0x6d, 0x20, 0x6c, 0x6f, 0x67, 0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30,
|
||||
0x20, 0x62, 0x69, 0x74, 0x64, 0x65, 0x70, 0x74, 0x68, 0x3d, 0x38, 0x20, 0x69, 0x6e, 0x70, 0x75,
|
||||
0x74, 0x2d, 0x63, 0x73, 0x70, 0x3d, 0x31, 0x20, 0x66, 0x70, 0x73, 0x3d, 0x32, 0x35, 0x2f, 0x31,
|
||||
0x20, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x2d, 0x72, 0x65, 0x73, 0x3d, 0x32, 0x35, 0x36, 0x78, 0x36,
|
||||
0x34, 0x20, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6c, 0x61, 0x63, 0x65, 0x3d, 0x30, 0x20, 0x74, 0x6f,
|
||||
0x74, 0x61, 0x6c, 0x2d, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x73, 0x3d, 0x30, 0x20, 0x6c, 0x65, 0x76,
|
||||
0x65, 0x6c, 0x2d, 0x69, 0x64, 0x63, 0x3d, 0x30, 0x20, 0x68, 0x69, 0x67, 0x68, 0x2d, 0x74, 0x69,
|
||||
0x65, 0x72, 0x3d, 0x31, 0x20, 0x75, 0x68, 0x64, 0x2d, 0x62, 0x64, 0x3d, 0x30, 0x20, 0x72, 0x65,
|
||||
0x66, 0x3d, 0x33, 0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x2d, 0x6e, 0x6f, 0x6e,
|
||||
0x2d, 0x63, 0x6f, 0x6e, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x6e, 0x63, 0x65, 0x20, 0x72, 0x65, 0x70,
|
||||
0x65, 0x61, 0x74, 0x2d, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x20, 0x61, 0x6e, 0x6e, 0x65,
|
||||
0x78, 0x62, 0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x75, 0x64, 0x20, 0x6e, 0x6f, 0x2d, 0x65, 0x6f, 0x62,
|
||||
0x20, 0x6e, 0x6f, 0x2d, 0x65, 0x6f, 0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x72, 0x64, 0x20, 0x69,
|
||||
0x6e, 0x66, 0x6f, 0x20, 0x68, 0x61, 0x73, 0x68, 0x3d, 0x30, 0x20, 0x74, 0x65, 0x6d, 0x70, 0x6f,
|
||||
0x72, 0x61, 0x6c, 0x2d, 0x6c, 0x61, 0x79, 0x65, 0x72, 0x73, 0x3d, 0x30, 0x20, 0x6f, 0x70, 0x65,
|
||||
0x6e, 0x2d, 0x67, 0x6f, 0x70, 0x20, 0x6d, 0x69, 0x6e, 0x2d, 0x6b, 0x65, 0x79, 0x69, 0x6e, 0x74,
|
||||
0x3d, 0x32, 0x35, 0x20, 0x6b, 0x65, 0x79, 0x69, 0x6e, 0x74, 0x3d, 0x32, 0x35, 0x30, 0x20, 0x67,
|
||||
0x6f, 0x70, 0x2d, 0x6c, 0x6f, 0x6f, 0x6b, 0x61, 0x68, 0x65, 0x61, 0x64, 0x3d, 0x30, 0x20, 0x62,
|
||||
0x66, 0x72, 0x61, 0x6d, 0x65, 0x73, 0x3d, 0x34, 0x20, 0x62, 0x2d, 0x61, 0x64, 0x61, 0x70, 0x74,
|
||||
0x3d, 0x32, 0x20, 0x62, 0x2d, 0x70, 0x79, 0x72, 0x61, 0x6d, 0x69, 0x64, 0x20, 0x62, 0x66, 0x72,
|
||||
0x61, 0x6d, 0x65, 0x2d, 0x62, 0x69, 0x61, 0x73, 0x3d, 0x30, 0x20, 0x72, 0x63, 0x2d, 0x6c, 0x6f,
|
||||
0x6f, 0x6b, 0x61, 0x68, 0x65, 0x61, 0x64, 0x3d, 0x32, 0x30, 0x20, 0x6c, 0x6f, 0x6f, 0x6b, 0x61,
|
||||
0x68, 0x65, 0x61, 0x64, 0x2d, 0x73, 0x6c, 0x69, 0x63, 0x65, 0x73, 0x3d, 0x30, 0x20, 0x73, 0x63,
|
||||
0x65, 0x6e, 0x65, 0x63, 0x75, 0x74, 0x3d, 0x34, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x69, 0x73,
|
||||
0x74, 0x2d, 0x73, 0x63, 0x65, 0x6e, 0x65, 0x63, 0x75, 0x74, 0x20, 0x72, 0x61, 0x64, 0x6c, 0x3d,
|
||||
0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x70, 0x6c, 0x69, 0x63, 0x65, 0x20, 0x6e, 0x6f, 0x2d, 0x69,
|
||||
0x6e, 0x74, 0x72, 0x61, 0x2d, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x20, 0x63, 0x74, 0x75,
|
||||
0x3d, 0x36, 0x34, 0x20, 0x6d, 0x69, 0x6e, 0x2d, 0x63, 0x75, 0x2d, 0x73, 0x69, 0x7a, 0x65, 0x3d,
|
||||
0x38, 0x20, 0x6e, 0x6f, 0x2d, 0x72, 0x65, 0x63, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x6d, 0x70,
|
||||
0x20, 0x6d, 0x61, 0x78, 0x2d, 0x74, 0x75, 0x2d, 0x73, 0x69, 0x7a, 0x65, 0x3d, 0x33, 0x32, 0x20,
|
||||
0x74, 0x75, 0x2d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x2d, 0x64, 0x65, 0x70, 0x74, 0x68, 0x3d, 0x31,
|
||||
0x20, 0x74, 0x75, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x2d, 0x64, 0x65, 0x70, 0x74, 0x68, 0x3d,
|
||||
0x31, 0x20, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x74, 0x75, 0x3d, 0x30, 0x20, 0x72, 0x64, 0x6f,
|
||||
0x71, 0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30, 0x20, 0x64, 0x79, 0x6e, 0x61, 0x6d, 0x69,
|
||||
0x63, 0x2d, 0x72, 0x64, 0x3d, 0x30, 0x2e, 0x30, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x73, 0x69,
|
||||
0x6d, 0x2d, 0x72, 0x64, 0x20, 0x73, 0x69, 0x67, 0x6e, 0x68, 0x69, 0x64, 0x65, 0x20, 0x6e, 0x6f,
|
||||
0x2d, 0x74, 0x73, 0x6b, 0x69, 0x70, 0x20, 0x6e, 0x72, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x3d,
|
||||
0x30, 0x20, 0x6e, 0x72, 0x2d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d,
|
||||
0x63, 0x6f, 0x6e, 0x73, 0x74, 0x72, 0x61, 0x69, 0x6e, 0x65, 0x64, 0x2d, 0x69, 0x6e, 0x74, 0x72,
|
||||
0x61, 0x20, 0x73, 0x74, 0x72, 0x6f, 0x6e, 0x67, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x2d, 0x73,
|
||||
0x6d, 0x6f, 0x6f, 0x74, 0x68, 0x69, 0x6e, 0x67, 0x20, 0x6d, 0x61, 0x78, 0x2d, 0x6d, 0x65, 0x72,
|
||||
0x67, 0x65, 0x3d, 0x33, 0x20, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x72, 0x65, 0x66, 0x73, 0x3d,
|
||||
0x31, 0x20, 0x6e, 0x6f, 0x2d, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x6d, 0x6f, 0x64, 0x65, 0x73,
|
||||
0x20, 0x6d, 0x65, 0x3d, 0x31, 0x20, 0x73, 0x75, 0x62, 0x6d, 0x65, 0x3d, 0x32, 0x20, 0x6d, 0x65,
|
||||
0x72, 0x61, 0x6e, 0x67, 0x65, 0x3d, 0x35, 0x37, 0x20, 0x74, 0x65, 0x6d, 0x70, 0x6f, 0x72, 0x61,
|
||||
0x6c, 0x2d, 0x6d, 0x76, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x2d, 0x64,
|
||||
0x75, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x6d, 0x65, 0x20, 0x77, 0x65, 0x69, 0x67, 0x68, 0x74,
|
||||
0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x77, 0x65, 0x69, 0x67, 0x68, 0x74, 0x62, 0x20, 0x6e, 0x6f, 0x2d,
|
||||
0x61, 0x6e, 0x61, 0x6c, 0x79, 0x7a, 0x65, 0x2d, 0x73, 0x72, 0x63, 0x2d, 0x70, 0x69, 0x63, 0x73,
|
||||
0x20, 0x64, 0x65, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x3d, 0x30, 0x3a, 0x30, 0x20, 0x73, 0x61, 0x6f,
|
||||
0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x61, 0x6f, 0x2d, 0x6e, 0x6f, 0x6e, 0x2d, 0x64, 0x65, 0x62, 0x6c,
|
||||
0x6f, 0x63, 0x6b, 0x20, 0x72, 0x64, 0x3d, 0x33, 0x20, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x69,
|
||||
0x76, 0x65, 0x2d, 0x73, 0x61, 0x6f, 0x3d, 0x34, 0x20, 0x65, 0x61, 0x72, 0x6c, 0x79, 0x2d, 0x73,
|
||||
0x6b, 0x69, 0x70, 0x20, 0x72, 0x73, 0x6b, 0x69, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x61, 0x73,
|
||||
0x74, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x20, 0x6e, 0x6f, 0x2d, 0x74, 0x73, 0x6b, 0x69, 0x70,
|
||||
0x2d, 0x66, 0x61, 0x73, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x63, 0x75, 0x2d, 0x6c, 0x6f, 0x73, 0x73,
|
||||
0x6c, 0x65, 0x73, 0x73, 0x20, 0x62, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x20, 0x6e, 0x6f, 0x2d,
|
||||
0x73, 0x70, 0x6c, 0x69, 0x74, 0x72, 0x64, 0x2d, 0x73, 0x6b, 0x69, 0x70, 0x20, 0x72, 0x64, 0x70,
|
||||
0x65, 0x6e, 0x61, 0x6c, 0x74, 0x79, 0x3d, 0x30, 0x20, 0x70, 0x73, 0x79, 0x2d, 0x72, 0x64, 0x3d,
|
||||
0x32, 0x2e, 0x30, 0x30, 0x20, 0x70, 0x73, 0x79, 0x2d, 0x72, 0x64, 0x6f, 0x71, 0x3d, 0x30, 0x2e,
|
||||
0x30, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x72, 0x64, 0x2d, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x20,
|
||||
0x6c, 0x6f, 0x73, 0x73, 0x6c, 0x65, 0x73, 0x73, 0x20, 0x63, 0x62, 0x71, 0x70, 0x6f, 0x66, 0x66,
|
||||
0x73, 0x3d, 0x30, 0x20, 0x63, 0x72, 0x71, 0x70, 0x6f, 0x66, 0x66, 0x73, 0x3d, 0x30, 0x20, 0x72,
|
||||
0x63, 0x3d, 0x63, 0x71, 0x70, 0x20, 0x71, 0x70, 0x3d, 0x34, 0x20, 0x69, 0x70, 0x72, 0x61, 0x74,
|
||||
0x69, 0x6f, 0x3d, 0x31, 0x2e, 0x34, 0x30, 0x20, 0x70, 0x62, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x3d,
|
||||
0x31, 0x2e, 0x33, 0x30, 0x20, 0x61, 0x71, 0x2d, 0x6d, 0x6f, 0x64, 0x65, 0x3d, 0x30, 0x20, 0x61,
|
||||
0x71, 0x2d, 0x73, 0x74, 0x72, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x3d, 0x30, 0x2e, 0x30, 0x30, 0x20,
|
||||
0x6e, 0x6f, 0x2d, 0x63, 0x75, 0x74, 0x72, 0x65, 0x65, 0x20, 0x7a, 0x6f, 0x6e, 0x65, 0x2d, 0x63,
|
||||
0x6f, 0x75, 0x6e, 0x74, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x74, 0x72, 0x69, 0x63, 0x74,
|
||||
0x2d, 0x63, 0x62, 0x72, 0x20, 0x71, 0x67, 0x2d, 0x73, 0x69, 0x7a, 0x65, 0x3d, 0x36, 0x34, 0x20,
|
||||
0x6e, 0x6f, 0x2d, 0x72, 0x63, 0x2d, 0x67, 0x72, 0x61, 0x69, 0x6e, 0x20, 0x71, 0x70, 0x6d, 0x61,
|
||||
0x78, 0x3d, 0x36, 0x39, 0x20, 0x71, 0x70, 0x6d, 0x69, 0x6e, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d,
|
||||
0x63, 0x6f, 0x6e, 0x73, 0x74, 0x2d, 0x76, 0x62, 0x76, 0x20, 0x73, 0x61, 0x72, 0x3d, 0x30, 0x20,
|
||||
0x6f, 0x76, 0x65, 0x72, 0x73, 0x63, 0x61, 0x6e, 0x3d, 0x30, 0x20, 0x76, 0x69, 0x64, 0x65, 0x6f,
|
||||
0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x3d, 0x35, 0x20, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x3d, 0x30,
|
||||
0x20, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x70, 0x72, 0x69, 0x6d, 0x3d, 0x31, 0x20, 0x74, 0x72, 0x61,
|
||||
0x6e, 0x73, 0x66, 0x65, 0x72, 0x3d, 0x31, 0x20, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x6d, 0x61, 0x74,
|
||||
0x72, 0x69, 0x78, 0x3d, 0x35, 0x20, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x61, 0x6c, 0x6f, 0x63, 0x3d,
|
||||
0x30, 0x20, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x2d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77,
|
||||
0x3d, 0x30, 0x20, 0x63, 0x6c, 0x6c, 0x3d, 0x30, 0x2c, 0x30, 0x20, 0x6d, 0x69, 0x6e, 0x2d, 0x6c,
|
||||
0x75, 0x6d, 0x61, 0x3d, 0x30, 0x20, 0x6d, 0x61, 0x78, 0x2d, 0x6c, 0x75, 0x6d, 0x61, 0x3d, 0x32,
|
||||
0x35, 0x35, 0x20, 0x6c, 0x6f, 0x67, 0x32, 0x2d, 0x6d, 0x61, 0x78, 0x2d, 0x70, 0x6f, 0x63, 0x2d,
|
||||
0x6c, 0x73, 0x62, 0x3d, 0x38, 0x20, 0x76, 0x75, 0x69, 0x2d, 0x74, 0x69, 0x6d, 0x69, 0x6e, 0x67,
|
||||
0x2d, 0x69, 0x6e, 0x66, 0x6f, 0x20, 0x76, 0x75, 0x69, 0x2d, 0x68, 0x72, 0x64, 0x2d, 0x69, 0x6e,
|
||||
0x66, 0x6f, 0x20, 0x73, 0x6c, 0x69, 0x63, 0x65, 0x73, 0x3d, 0x31, 0x20, 0x6e, 0x6f, 0x2d, 0x6f,
|
||||
0x70, 0x74, 0x2d, 0x71, 0x70, 0x2d, 0x70, 0x70, 0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x6f, 0x70, 0x74,
|
||||
0x2d, 0x72, 0x65, 0x66, 0x2d, 0x6c, 0x69, 0x73, 0x74, 0x2d, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68,
|
||||
0x2d, 0x70, 0x70, 0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x2d, 0x70, 0x61,
|
||||
0x73, 0x73, 0x2d, 0x6f, 0x70, 0x74, 0x2d, 0x72, 0x70, 0x73, 0x20, 0x73, 0x63, 0x65, 0x6e, 0x65,
|
||||
0x63, 0x75, 0x74, 0x2d, 0x62, 0x69, 0x61, 0x73, 0x3d, 0x30, 0x2e, 0x30, 0x35, 0x20, 0x6e, 0x6f,
|
||||
0x2d, 0x6f, 0x70, 0x74, 0x2d, 0x63, 0x75, 0x2d, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x2d, 0x71, 0x70,
|
||||
0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x71, 0x2d, 0x6d, 0x6f, 0x74, 0x69, 0x6f, 0x6e, 0x20, 0x6e, 0x6f,
|
||||
0x2d, 0x68, 0x64, 0x72, 0x31, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x64, 0x72, 0x31, 0x30, 0x2d,
|
||||
0x6f, 0x70, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x64, 0x68, 0x64, 0x72, 0x31, 0x30, 0x2d, 0x6f, 0x70,
|
||||
0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x69, 0x64, 0x72, 0x2d, 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72,
|
||||
0x79, 0x2d, 0x73, 0x65, 0x69, 0x20, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2d, 0x72,
|
||||
0x65, 0x75, 0x73, 0x65, 0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30, 0x20, 0x61, 0x6e, 0x61,
|
||||
0x6c, 0x79, 0x73, 0x69, 0x73, 0x2d, 0x73, 0x61, 0x76, 0x65, 0x2d, 0x72, 0x65, 0x75, 0x73, 0x65,
|
||||
0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30, 0x20, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69,
|
||||
0x73, 0x2d, 0x6c, 0x6f, 0x61, 0x64, 0x2d, 0x72, 0x65, 0x75, 0x73, 0x65, 0x2d, 0x6c, 0x65, 0x76,
|
||||
0x65, 0x6c, 0x3d, 0x30, 0x20, 0x73, 0x63, 0x61, 0x6c, 0x65, 0x2d, 0x66, 0x61, 0x63, 0x74, 0x6f,
|
||||
0x72, 0x3d, 0x30, 0x20, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61,
|
||||
0x3d, 0x30, 0x20, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x2d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x3d,
|
||||
0x30, 0x20, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x2d, 0x6d, 0x76, 0x3d, 0x31, 0x20, 0x72, 0x65,
|
||||
0x66, 0x69, 0x6e, 0x65, 0x2d, 0x63, 0x74, 0x75, 0x2d, 0x64, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x74,
|
||||
0x69, 0x6f, 0x6e, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x73,
|
||||
0x61, 0x6f, 0x20, 0x63, 0x74, 0x75, 0x2d, 0x69, 0x6e, 0x66, 0x6f, 0x3d, 0x30, 0x20, 0x6e, 0x6f,
|
||||
0x2d, 0x6c, 0x6f, 0x77, 0x70, 0x61, 0x73, 0x73, 0x2d, 0x64, 0x63, 0x74, 0x20, 0x72, 0x65, 0x66,
|
||||
0x69, 0x6e, 0x65, 0x2d, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2d, 0x74, 0x79, 0x70,
|
||||
0x65, 0x3d, 0x30, 0x20, 0x63, 0x6f, 0x70, 0x79, 0x2d, 0x70, 0x69, 0x63, 0x3d, 0x31, 0x20, 0x6d,
|
||||
0x61, 0x78, 0x2d, 0x61, 0x75, 0x73, 0x69, 0x7a, 0x65, 0x2d, 0x66, 0x61, 0x63, 0x74, 0x6f, 0x72,
|
||||
0x3d, 0x31, 0x2e, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x64, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x2d,
|
||||
0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x69, 0x6e, 0x67, 0x6c, 0x65,
|
||||
0x2d, 0x73, 0x65, 0x69, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x65, 0x76, 0x63, 0x2d, 0x61, 0x71, 0x20,
|
||||
0x6e, 0x6f, 0x2d, 0x73, 0x76, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x20,
|
||||
0x71, 0x70, 0x2d, 0x61, 0x64, 0x61, 0x70, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2d, 0x72, 0x61,
|
||||
0x6e, 0x67, 0x65, 0x3d, 0x31, 0x2e, 0x30, 0x30, 0x20, 0x73, 0x63, 0x65, 0x6e, 0x65, 0x63, 0x75,
|
||||
0x74, 0x2d, 0x61, 0x77, 0x61, 0x72, 0x65, 0x2d, 0x71, 0x70, 0x3d, 0x30, 0x63, 0x6f, 0x6e, 0x66,
|
||||
0x6f, 0x72, 0x6d, 0x61, 0x6e, 0x63, 0x65, 0x2d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x2d, 0x6f,
|
||||
0x66, 0x66, 0x73, 0x65, 0x74, 0x73, 0x20, 0x72, 0x69, 0x67, 0x68, 0x74, 0x3d, 0x30, 0x20, 0x62,
|
||||
0x6f, 0x74, 0x74, 0x6f, 0x6d, 0x3d, 0x30, 0x20, 0x64, 0x65, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2d,
|
||||
0x6d, 0x61, 0x78, 0x2d, 0x72, 0x61, 0x74, 0x65, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x76, 0x62,
|
||||
0x76, 0x2d, 0x6c, 0x69, 0x76, 0x65, 0x2d, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x2d, 0x70, 0x61, 0x73,
|
||||
0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x6d, 0x63, 0x73, 0x74, 0x66, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x62,
|
||||
0x72, 0x63, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x2d, 0x72, 0x63, 0x80, 0x00,
|
||||
0x00, 0x01, 0x28, 0x01, 0xaf, 0x05, 0xb8, 0x10, 0x0c, 0x7f, 0x80, 0xd7, 0x00, 0xc4, 0xbb, 0x82,
|
||||
0x75, 0x79, 0xbd, 0x3c, 0x5f, 0x14, 0xb1, 0x4b, 0x14, 0xb1, 0x4b, 0x17, 0xc5, 0x7c, 0x57, 0xc5,
|
||||
0x7c, 0x57, 0xc5, 0x7c, 0x57, 0xc5, 0x7d, 0xb4, 0xcb, 0x51, 0xc8, 0xd6, 0xa0, 0x35, 0xd5, 0x55,
|
||||
0xa4, 0x40, 0xd9, 0x57, 0x3f, 0xff, 0xb5, 0xb6, 0x5d, 0x34, 0x79, 0xe7, 0x9e, 0x7f, 0x7d, 0xf7,
|
||||
0xdf, 0x7d, 0xf8, 0x18, 0x53, 0xff, 0xfe, 0xc3, 0xa1, 0x01, 0xf1, 0xbc, 0xa9, 0x03, 0x52, 0x4f,
|
||||
0xc2, 0x91, 0xef, 0xff, 0xf9, 0x13, 0xb5, 0xff, 0xff, 0xe0, 0xd3, 0xc0, 0xe8, 0xf7, 0xbe, 0xff,
|
||||
0x98, 0x39, 0x83, 0x98, 0x39, 0x83, 0x99, 0xb5, 0x9b, 0x59, 0xb5, 0x9b, 0x59, 0xb5, 0x9b, 0x59,
|
||||
0xb5, 0x88, 0xe7, 0xff, 0xb1, 0xee, 0x80, 0x1f, 0xaf, 0xd3, 0xc3, 0x0c, 0x30, 0xc3, 0x0c, 0x71,
|
||||
0xc7, 0x1c, 0x71, 0xc7, 0x16, 0x7a, 0x50, 0x01, 0xc9, 0xff, 0xf9, 0x43, 0x06, 0x41, 0x75, 0x47,
|
||||
0xb1, 0xe7, 0x71, 0x8e, 0x4e, 0x9d, 0x1f, 0xff, 0xce, 0x91, 0x28, 0x4d, 0xc5, 0x7a, 0x68, 0x84,
|
||||
0xdc, 0x76, 0x6c, 0xb4, 0xb3, 0x45, 0xc9, 0xef, 0xff, 0xcb, 0x9f, 0x2b, 0x6b, 0x50, 0x63, 0xb0,
|
||||
0x1f, 0xe0, 0x49, 0xe5, 0x74, 0x7f, 0xff, 0x3a, 0x44, 0xa1, 0x37, 0x15, 0xe9, 0xa2, 0x13, 0x71,
|
||||
0xd9, 0xaa, 0x79, 0xa3, 0xe3, 0xff, 0xf5, 0x23, 0x05, 0x46, 0x2f, 0x61, 0x97, 0x0a, 0x0e, 0x16,
|
||||
0x36, 0x94, 0x4f, 0xff, 0xb3, 0x7f, 0x1f, 0xe6, 0x13, 0x25, 0x0f, 0x78, 0x49, 0x00, 0x63, 0xf9,
|
||||
0x58, 0x64, 0x83, 0x85, 0x00, 0x07, 0x98, 0xce, 0xcb, 0x42, 0xb2, 0x35, 0x1d, 0xfa, 0xbf, 0xc6,
|
||||
0x85, 0xe6, 0x00, 0x48, 0xff, 0xfe, 0xed, 0x0e, 0xf8, 0x8b, 0xfc, 0x55, 0xed, 0x8d, 0xa0, 0xbd,
|
||||
0xf6, 0xdf, 0xff, 0xc7, 0xf9, 0xd8, 0x58, 0x24, 0x13, 0x43, 0x41, 0x3e, 0x1f, 0xc8, 0x40, 0x1c,
|
||||
0x5f, 0xff, 0x95, 0x21, 0x6f, 0x83, 0x90, 0x1c, 0x23, 0xc9, 0xe6, 0x5e, 0xdb, 0xd7, 0xff, 0xde,
|
||||
0xb1, 0x0b, 0xeb, 0x42, 0x9f, 0xe3, 0xae, 0xdb, 0x41, 0x26, 0x3b, 0x04, 0x56, 0x7f, 0xfe, 0xc9,
|
||||
0xf5, 0x58, 0x41, 0xd2, 0xa9, 0xeb, 0x7f, 0xd7, 0x3b, 0xa2, 0xef, 0xff, 0xde, 0x56, 0xa7, 0xa2,
|
||||
0xac, 0x43, 0x81, 0xaf, 0xce, 0x33, 0x5c, 0x41, 0xbf, 0x1f, 0xff, 0x5d, 0xee, 0xcb, 0x39, 0xae,
|
||||
0x1e, 0xa3, 0xd4, 0x47, 0xc7, 0xe9, 0x47, 0xff, 0xeb, 0x2a, 0xe3, 0x45, 0x58, 0xb5, 0x6a, 0x37,
|
||||
0xfd, 0xd2, 0xcf, 0xfa, 0x80, 0xa2, 0x8c, 0xff, 0xff, 0xf0, 0x0f, 0x7e, 0x56, 0xf1, 0x4e, 0x9d,
|
||||
0x3a, 0x74, 0xf2, 0x64, 0xc9, 0x93, 0x26, 0x4c, 0x98, 0x43, 0xff, 0xff, 0x77, 0x88, 0xb7, 0xf6,
|
||||
0x16, 0xba, 0x3e, 0xa1, 0xc4, 0xf8, 0x98, 0xc5, 0xc5, 0x67, 0xf6, 0xa6, 0x20, 0x0b, 0x3d, 0x62,
|
||||
0x9e, 0xaf, 0xe6, 0xaa, 0x45, 0x3e, 0x02, 0xd0, 0x2d, 0x02, 0xd0, 0x2d, 0x02, 0xfd, 0x2f, 0xd2,
|
||||
0xfd, 0x2f, 0xd2, 0xfd, 0x2f, 0xd2, 0xfd, 0x2d, 0x3d, 0xfe, 0x6b, 0xf7, 0xff, 0xff, 0x04, 0x5e,
|
||||
0x01, 0x17, 0x8a, 0x34, 0xae, 0x56, 0xe5, 0x6e, 0x56, 0xe5, 0x6e, 0x66, 0x26, 0x62, 0x66, 0x26,
|
||||
0x62, 0x66, 0x26, 0x62, 0x66, 0x25, 0xe8, 0x81, 0xb7, 0xff, 0xfc, 0x8c, 0xfc, 0x2d, 0xdd, 0xfc,
|
||||
0x78, 0xe0, 0x8f, 0x36, 0xc1, 0x09, 0xb5, 0x10, 0xff, 0xff, 0x05, 0x24, 0xbb, 0x93, 0x6d, 0x8b,
|
||||
0x11, 0x3f, 0xf5, 0x5f, 0x91, 0x4d, 0xe8, 0x34, 0x32, 0x13, 0xff, 0xf2, 0xd0, 0x8e, 0xa4, 0x10,
|
||||
0x3b, 0x17, 0x3d, 0x0d, 0xf0, 0x64, 0x88, 0x64, 0xd4, 0xff, 0xfd, 0xe4, 0x28, 0x23, 0x05, 0xf8,
|
||||
0x06, 0x68, 0xb4, 0xa5, 0x8c, 0x20, 0x1e, 0x54, 0x22, 0xf3, 0xff, 0xf6, 0x32, 0x77, 0x6d, 0x34,
|
||||
0xce, 0xe5, 0x7e, 0xdf, 0xcc, 0x43, 0x72, 0x2c, 0xdf, 0x7f, 0xfe, 0x7f, 0xb0, 0x15, 0x20, 0x52,
|
||||
0xd4, 0x06, 0xf8, 0x83, 0x2d, 0x08, 0x97, 0x2f, 0x08, 0x3d, 0x3d, 0x5f, 0xff, 0xe6, 0x0a, 0xa2,
|
||||
0x05, 0xf9, 0x24, 0x92, 0x4b, 0x18, 0x61, 0x86, 0x18, 0x61, 0x88, 0x6f, 0xff, 0xd4, 0xec, 0x35,
|
||||
0x68, 0x57, 0xf9, 0x4c, 0x73, 0xc7, 0x6e, 0x6d, 0x63, 0x89, 0x93, 0xff, 0xff, 0x6e, 0x4e, 0xd9,
|
||||
0x23, 0x2c, 0xf5, 0xed, 0xef, 0xde, 0xfd, 0xef, 0xde, 0xfd, 0xf6, 0xcf, 0x6c, 0xf6, 0xcf, 0x6c,
|
||||
0xf6, 0xcf, 0x6c, 0xf6, 0xcf, 0x01, 0x1b, 0x12, 0xc0, 0x18, 0x22, 0xee, 0x4d, 0x92, 0x49, 0x24,
|
||||
0x92, 0x7a, 0xaa, 0xaa, 0xaa, 0xaa, 0xa7, 0xd0, 0x1a, 0x5f, 0xff, 0x9c, 0xa9, 0xca, 0xe5, 0x6d,
|
||||
0x24, 0x4e, 0x60, 0x52, 0x2f, 0xa6, 0x7f, 0xff, 0xb0, 0xe8, 0x40, 0x7c, 0x6f, 0x2a, 0x40, 0xd4,
|
||||
0x93, 0xf0, 0xa6, 0xa6, 0x14, 0x55, 0xff, 0xf3, 0x76, 0x99, 0x75, 0xaa, 0xeb, 0x16, 0xc8, 0x7d,
|
||||
0x93, 0x68, 0x60, 0xff, 0xfd, 0x65, 0x5c, 0x68, 0xab, 0x16, 0xad, 0x46, 0xff, 0xba, 0x5a, 0x12,
|
||||
0x2c, 0x9f, 0xff, 0xe5, 0x39, 0x42, 0xf7, 0xb8, 0x97, 0xfb, 0xe6, 0xf7, 0xd3, 0x1e, 0x27, 0xff,
|
||||
0xf9, 0xc3, 0x14, 0x52, 0xf4, 0xf5, 0x3c, 0xbb, 0x6e, 0xb7, 0x2b, 0xbb, 0x80, 0x5e, 0xbf, 0xff,
|
||||
0x41, 0xd9, 0xcb, 0x9f, 0x5d, 0xc9, 0x28, 0x36, 0x1c, 0xcf, 0x18, 0xa2, 0xd9, 0xdf, 0xff, 0xa7,
|
||||
0xf3, 0x09, 0x84, 0xe6, 0x31, 0x94, 0xa5, 0x8d, 0xad, 0x93, 0x83, 0x8c, 0x01, 0x58, 0xff, 0xfd,
|
||||
0x4b, 0xa2, 0xd2, 0x82, 0x6b, 0xc7, 0x81, 0x87, 0x90, 0xc0, 0x16, 0x28, 0x4c, 0x7f, 0xfe, 0x95,
|
||||
0xc3, 0x60, 0xc7, 0x92, 0xea, 0x9a, 0xf4, 0xee, 0x9b, 0x68, 0x59, 0x04, 0x74, 0xec, 0x80, 0x0f,
|
||||
0x41, 0x5c, 0xee, 0x30, 0xf1, 0xc6, 0xe8, 0x40, 0x12, 0xed, 0xb5, 0x55, 0xff, 0xfc, 0x11, 0xc8,
|
||||
0x03, 0x4d, 0x33, 0x0f, 0x22, 0xec, 0x22, 0xdf, 0x56, 0x44, 0xb6, 0x7f, 0xff, 0x12, 0x30, 0x22,
|
||||
0x76, 0x41, 0xf5, 0x7f, 0xf8, 0x06, 0x4f, 0x55, 0x86, 0x31, 0x58, 0xa3, 0xff, 0xf4, 0xf1, 0xc2,
|
||||
0x65, 0x14, 0x4c, 0x4d, 0x40, 0x92, 0x12, 0x88, 0x96, 0xdb, 0xc4, 0xff, 0xfd, 0x33, 0x8d, 0xc5,
|
||||
0xcb, 0xf7, 0x51, 0xc8, 0xd1, 0x4a, 0x19, 0x22, 0x0f, 0x81, 0xee, 0x95, 0xff, 0xff, 0x9c, 0x31,
|
||||
0x45, 0x2f, 0x4f, 0x53, 0xcb, 0xb6, 0xeb, 0x72, 0xca, 0xaf, 0xff, 0x9b, 0x7c, 0x7c, 0x9e, 0x22,
|
||||
0x9b, 0x07, 0xb8, 0xd1, 0x57, 0x20, 0x17, 0x9d, 0xff, 0xf8, 0x24, 0xc8, 0xb0, 0xee, 0x78, 0x7b,
|
||||
0x95, 0x1d, 0x8d, 0x56, 0x89, 0xff, 0xf8, 0x15, 0xb7, 0xfa, 0x2a, 0xbe, 0x6b, 0x3f, 0xf0, 0xb5,
|
||||
0xb3, 0x63, 0x74, 0x55, 0xff, 0xfc, 0xcc, 0xbd, 0x0e, 0x72, 0xf9, 0x76, 0x87, 0x1d, 0x0e, 0xd9,
|
||||
0x12, 0x9f, 0xff, 0x32, 0x43, 0xc0, 0x1f, 0x1b, 0x10, 0xf4, 0xd3, 0x9f, 0x4b, 0xd2, 0x6b, 0x2f,
|
||||
0xff, 0xef, 0xcb, 0x14, 0x52, 0x91, 0x69, 0x76, 0xc8, 0x6d, 0x94, 0x00, 0x34, 0xff, 0xfb, 0xec,
|
||||
0xba, 0x8f, 0x8d, 0xeb, 0x14, 0xf5, 0x9e, 0xab, 0x96, 0xf0, 0xd9, 0x4f, 0x39, 0x0f, 0xff, 0xf8,
|
||||
0x50, 0x81, 0x7f, 0x73, 0xb2, 0xe5, 0xcb, 0x97, 0x32, 0x6c, 0xd9, 0xb3, 0x66, 0xcd, 0x9b, 0x61,
|
||||
0x7f, 0xff, 0x74, 0x86, 0x0d, 0xaf, 0x45, 0xff, 0x85, 0x5a, 0xa4, 0x21, 0x8d, 0xfd, 0x18, 0x3a,
|
||||
0x85, 0x00, 0x5d, 0x6a, 0x15, 0xf4, 0xff, 0x44, 0xd0, 0x78, 0xe5, 0xaf, 0x7a, 0xf7, 0xaf, 0x7a,
|
||||
0xf7, 0xb0, 0xf1, 0x0f, 0x10, 0xf1, 0x0f, 0x10, 0xf1, 0x0f, 0x10, 0xf0, 0xf8, 0x37, 0xae, 0xc0,
|
||||
0x38, 0xb4, 0xea, 0x7a, 0x6e, 0x22, 0x51, 0xbc, 0x31, 0xdd, 0x3b, 0x74, 0xed, 0xd3, 0xb7, 0x4e,
|
||||
0xdd, 0x74, 0x35, 0xd0, 0xd7, 0x43, 0x5d, 0x0d, 0x74, 0x35, 0xd0, 0xd7, 0x43, 0x56, 0xc8, 0x91,
|
||||
0x7f, 0xff, 0xa1, 0xad, 0x88, 0x55, 0x61, 0x10, 0x40, 0x65, 0xd0, 0x9b, 0x4b, 0xc3, 0x5b, 0x8f,
|
||||
0xff, 0xd0, 0xb6, 0xa8, 0x17, 0xbd, 0x41, 0xad, 0xe6, 0xc9, 0x9a, 0x1c, 0x1c, 0x1b, 0x5d, 0xa6,
|
||||
0x97, 0xff, 0xf4, 0x29, 0x51, 0x3f, 0xff, 0xfa, 0xae, 0x19, 0xa1, 0x7e, 0x73, 0xcd, 0xc7, 0xfa,
|
||||
0xff, 0xfd, 0x07, 0x66, 0xff, 0x1d, 0x6b, 0x4c, 0x94, 0xe8, 0xc3, 0x30, 0x89, 0x10, 0xd4, 0x81,
|
||||
0x17, 0xff, 0xe4, 0x00, 0x31, 0xf0, 0xc2, 0xff, 0x9b, 0xc2, 0xb6, 0xd5, 0xbc, 0x8d, 0x4a, 0xbf,
|
||||
0xff, 0xf8, 0xf9, 0xdb, 0xda, 0xc8, 0x96, 0xff, 0x59, 0x48, 0xb9, 0xdc, 0xca, 0x74, 0x56, 0x89,
|
||||
0x79, 0x8c, 0xff, 0xff, 0x11, 0xf1, 0x1f, 0x0b, 0x12, 0x49, 0x24, 0xa4, 0x30, 0xc3, 0x0c, 0x30,
|
||||
0xc4, 0x34, 0xff, 0xfd, 0xec, 0xf2, 0xd5, 0x8c, 0xa1, 0x40, 0x45, 0x93, 0x4f, 0x05, 0x8a, 0xdb,
|
||||
0xbf, 0xff, 0xfc, 0x63, 0xf8, 0xb1, 0xe1, 0x66, 0xf5, 0x2c, 0x92, 0xc9, 0x2c, 0x92, 0xc9, 0x2c,
|
||||
0xbe, 0xcb, 0xec, 0xbe, 0xcb, 0xec, 0xbe, 0xcb, 0xec, 0xbe, 0xc9, 0x46, 0xf9, 0x00, 0x24, 0xe2,
|
||||
0xac, 0x81, 0x0c, 0x30, 0xc3, 0x0c, 0x7c, 0x71, 0xc7, 0x1c, 0x71, 0xc3, 0x33, 0xbe, 0x7f, 0xff,
|
||||
0x09, 0x4e, 0x4f, 0x3a, 0xf9, 0x34, 0x8d, 0x41, 0xb5, 0x0d, 0x42, 0xbf, 0xfe, 0xf5, 0x88, 0x5f,
|
||||
0x5a, 0x14, 0xff, 0x1d, 0x76, 0xda, 0x09, 0x36, 0xa4, 0x5d, 0xaf, 0xff, 0xbd, 0x87, 0x69, 0x9c,
|
||||
0xd9, 0xf7, 0x0c, 0x48, 0x98, 0x9e, 0x17, 0x4b, 0xff, 0xef, 0x58, 0x85, 0xf5, 0xa1, 0x4f, 0xf1,
|
||||
0xd7, 0x6d, 0xa0, 0x93, 0x60, 0x15, 0xbf, 0xfe, 0xf6, 0x1d, 0xa6, 0x73, 0x67, 0xdc, 0x31, 0x22,
|
||||
0x62, 0x78, 0x5d, 0x2f, 0xff, 0xbd, 0x62, 0x17, 0xd6, 0x85, 0x3f, 0xc7, 0x5d, 0xb6, 0x82, 0x4d,
|
||||
0xcc, 0x83, 0xff, 0xff, 0xf1, 0xd3, 0x9b, 0xa2, 0x9d, 0xe7, 0x8c, 0x66, 0x72, 0xc0, 0x2f, 0xcf,
|
||||
0x65, 0x9b, 0xff, 0xf7, 0xc0, 0xca, 0x22, 0x04, 0xa8, 0x24, 0xf3, 0x1d, 0x63, 0x5d, 0x84, 0x8f,
|
||||
0xff, 0x44, 0x5f, 0xff, 0x9f, 0xeb, 0xff, 0x4c, 0x13, 0x36, 0x00, 0x3c, 0x22, 0xc9, 0xc2, 0xf5,
|
||||
0xf9, 0xef, 0xff, 0xcf, 0xf5, 0xff, 0xa6, 0x09, 0x9b, 0x00, 0x1e, 0x11, 0x64, 0xe1, 0x7a, 0xf0,
|
||||
0x2a, 0x80, 0xe0, 0x04, 0x5a, 0x21, 0x7a, 0xa9, 0x51, 0x5c, 0x9d, 0x9c, 0x8a, 0x61, 0xc3, 0xd2,
|
||||
0xff, 0xfd, 0xc8, 0xbf, 0xcc, 0xd3, 0x4c, 0x35, 0xeb, 0x66, 0x85, 0xe3, 0xe5, 0xaa, 0x5e, 0xbf,
|
||||
0xff, 0x72, 0x03, 0xdc, 0x34, 0x44, 0x7e, 0x97, 0x68, 0x3e, 0x1a, 0xca, 0x8d, 0xf6, 0xb9, 0xc9,
|
||||
0xff, 0xf9, 0x98, 0x62, 0x79, 0xa6, 0x97, 0x88, 0xdb, 0x12, 0xa0, 0xdb, 0x18, 0x4a, 0x52, 0x7f,
|
||||
0xfe, 0x65, 0x99, 0x2d, 0xca, 0xa3, 0x9b, 0x23, 0x17, 0x99, 0x47, 0x1b, 0x57, 0x6b, 0x24, 0xda,
|
||||
0x4f, 0xff, 0x9f, 0xb9, 0x30, 0x05, 0x2e, 0xef, 0x9f, 0xad, 0x6d, 0xfc, 0x3e, 0xcf, 0xff, 0x9f,
|
||||
0xb9, 0x30, 0x05, 0x2e, 0xef, 0x9f, 0xad, 0x6d, 0xfb, 0xed, 0x2b, 0xed, 0xff, 0xf5, 0xca, 0x2c,
|
||||
0x22, 0x59, 0x65, 0xee, 0x46, 0x1b, 0xdc, 0x14, 0xc5, 0xff, 0xf5, 0xca, 0x2c, 0x22, 0x59, 0x65,
|
||||
0xee, 0x46, 0x1b, 0xdc, 0x0c, 0x2d, 0x3c, 0xe4, 0xff, 0xf9, 0xfe, 0xf1, 0xf6, 0xb4, 0x7a, 0xc8,
|
||||
0x10, 0x48, 0x21, 0xb8, 0xfb, 0x3f, 0xfe, 0x7e, 0xe4, 0xc0, 0x14, 0xbb, 0xbe, 0x7e, 0xb5, 0xb7,
|
||||
0xef, 0xb4, 0xfa, 0x17, 0xff, 0xd7, 0x3f, 0x91, 0xb5, 0xa5, 0x4a, 0x1d, 0xb1, 0x7b, 0x6a, 0xd4,
|
||||
0xc5, 0xff, 0xf5, 0xca, 0x2c, 0x22, 0x59, 0x65, 0xee, 0x46, 0x1b, 0xdc, 0x0c, 0x28, 0xe4, 0xd2,
|
||||
0xe3, 0x5f, 0xff, 0xde, 0xd4, 0xf2, 0x31, 0x27, 0x12, 0xa5, 0x4a, 0x95, 0x3c, 0x28, 0x50, 0xa1,
|
||||
0x42, 0x85, 0x06, 0xda, 0xff, 0xfd, 0xc8, 0x0f, 0x94, 0x31, 0x1a, 0xd2, 0x66, 0x8a, 0xa8, 0x74,
|
||||
0x03, 0x52, 0xcf, 0x12, 0x00, 0x52, 0x6c, 0xd3, 0x36, 0x5f, 0x46, 0x35, 0xfb, 0xc6, 0xbf, 0x4b,
|
||||
0xf4, 0xbf, 0x4b, 0xf4, 0xc0, 0x96, 0x09, 0x60, 0x96, 0x09, 0x60, 0x96, 0x09, 0x60, 0x95, 0xf5,
|
||||
0xab, 0x2f, 0xff, 0xfe, 0xd2, 0xdd, 0x9c, 0x95, 0xe0, 0x25, 0xdd, 0x39, 0xd3, 0x9d, 0x39, 0xd3,
|
||||
0x9d, 0x4c, 0x14, 0xc1, 0x4c, 0x14, 0xc1, 0x4c, 0x14, 0xc1, 0x4c, 0x13, 0xab, 0xb0, 0x0b, 0xff,
|
||||
0xf7, 0x20, 0x3e, 0x50, 0xc4, 0x6b, 0x49, 0x9a, 0x2a, 0xa1, 0xd0, 0x0d, 0x52, 0xf5, 0xff, 0xfb,
|
||||
0x90, 0x1f, 0x28, 0x62, 0x35, 0xa4, 0xcd, 0x15, 0x50, 0xe8, 0x06, 0xa5, 0xb2, 0xce, 0xbf, 0x7f,
|
||||
0xff, 0x14, 0x7b, 0x0e, 0x08, 0x20, 0x18, 0x32, 0x3e, 0xeb, 0x9a, 0xc6, 0xee, 0x81, 0xdf, 0xff,
|
||||
0xc5, 0x0c, 0x78, 0xe1, 0x5d, 0x95, 0x29, 0x2c, 0x78, 0x61, 0xfa, 0x77, 0x12, 0x37, 0x16, 0xff,
|
||||
0xfd, 0xc8, 0xbf, 0xcc, 0xd3, 0x4c, 0x35, 0xeb, 0x66, 0x85, 0xe3, 0xe5, 0xaa, 0x5e, 0xbf, 0xff,
|
||||
0x72, 0x03, 0xe5, 0x0c, 0x46, 0xb4, 0x99, 0xa2, 0xaa, 0x1d, 0x00, 0xd4, 0xb6, 0xe7, 0x39, 0xf1,
|
||||
0xaf, 0xff, 0xe2, 0x23, 0x20, 0x60, 0x5e, 0x18, 0x61, 0x88, 0x57, 0x5d, 0x75, 0xd7, 0x5d, 0x5b,
|
||||
0x2f, 0xff, 0xbd, 0x62, 0x17, 0xd6, 0x85, 0x3f, 0xc7, 0x5d, 0xb6, 0x81, 0xde, 0x79, 0xff, 0xff,
|
||||
0xf5, 0x01, 0xe9, 0xc0, 0xa2, 0xd0, 0xd3, 0x64, 0xd6, 0x4d, 0x64, 0xd6, 0x4d, 0x65, 0x5c, 0x55,
|
||||
0xc5, 0x5c, 0x55, 0xc5, 0x5c, 0x55, 0xc5, 0x5c, 0x4d, 0xaa, 0x10, 0x05, 0xd9, 0x33, 0xd5, 0xc0,
|
||||
0x00, 0x00, 0x03, 0x00, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xfd, 0xc6, 0x42, 0xf7, 0xff, 0xd7, 0x28,
|
||||
0xb0, 0x89, 0x65, 0x97, 0xb9, 0x18, 0x6f, 0x70, 0x53, 0x17, 0xff, 0xd7, 0x28, 0xb0, 0x89, 0x65,
|
||||
0x97, 0xb9, 0x18, 0x6f, 0x70, 0x30, 0x56, 0xfd, 0x5b, 0xff, 0xf2, 0x33, 0x67, 0x90, 0x81, 0x60,
|
||||
0x4a, 0x3d, 0x34, 0x7d, 0x12, 0xe6, 0xff, 0xfc, 0x8a, 0xea, 0x0e, 0x95, 0x0c, 0xc0, 0x73, 0xf5,
|
||||
0x83, 0xaf, 0xfa, 0x2f, 0x2f, 0xff, 0xbd, 0x87, 0x69, 0x9c, 0xd9, 0xf7, 0x0c, 0x48, 0x98, 0x9e,
|
||||
0x07, 0x4b, 0xff, 0xef, 0x58, 0x85, 0xf5, 0xa1, 0x4f, 0xf1, 0xd7, 0x6d, 0xa0, 0x78, 0x35, 0x4a,
|
||||
0x6d, 0xff, 0xfc, 0x50, 0xc7, 0x8e, 0x15, 0xd9, 0x52, 0x92, 0xc7, 0x86, 0x1f, 0xa7, 0x74, 0x0e,
|
||||
0xff, 0xfe, 0x28, 0x63, 0xc7, 0x0a, 0xec, 0xa9, 0x49, 0x63, 0xc3, 0x0f, 0xd3, 0xb8, 0x92, 0x9f,
|
||||
0x57, 0xff, 0xee, 0x40, 0x7c, 0xa1, 0x88, 0xd6, 0x93, 0x34, 0x55, 0x43, 0xa0, 0x1a, 0xa5, 0xeb,
|
||||
0xff, 0xf7, 0x20, 0x3e, 0x50, 0xc4, 0x6b, 0x49, 0x9a, 0x2a, 0xa1, 0xd0, 0x0d, 0x4b, 0x13, 0x9a,
|
||||
0xfc, 0x01, 0x0a, 0xb8, 0xdc, 0xe9, 0xaa, 0x51, 0xa6, 0x2f, 0x33, 0x38, 0x70, 0x9f, 0x3f, 0xff,
|
||||
0x81, 0x38, 0x6f, 0x96, 0x59, 0x2c, 0x9d, 0xc5, 0x46, 0x2d, 0xbb, 0xb2, 0x86, 0x2f, 0xff, 0xde,
|
||||
0x02, 0x4a, 0x0e, 0x78, 0xf4, 0x81, 0xf4, 0x0e, 0xf1, 0xaf, 0x76, 0xc4, 0x68, 0x3a, 0x7f, 0xfe,
|
||||
0x7a, 0x23, 0xee, 0xeb, 0xae, 0x0b, 0xba, 0xa9, 0x83, 0xd2, 0x73, 0xc7, 0xd0, 0x9f, 0xff, 0x9e,
|
||||
0x6a, 0xb4, 0x7c, 0xad, 0x6d, 0xa6, 0x32, 0xbc, 0x60, 0xd2, 0xe3, 0x9c, 0x90, 0x95, 0xe3, 0xff,
|
||||
0xe9, 0x1b, 0x9a, 0x48, 0x96, 0x45, 0x2e, 0x92, 0xdc, 0x57, 0xac, 0xb3, 0xff, 0xe9, 0x1b, 0x9a,
|
||||
0x48, 0x96, 0x45, 0x2e, 0x92, 0xdc, 0x57, 0x99, 0x5e, 0x95, 0x7f, 0xfe, 0x01, 0xa9, 0xd0, 0xd9,
|
||||
0xc1, 0x16, 0xba, 0xb0, 0xf7, 0x82, 0xfd, 0x7f, 0xfe, 0x01, 0xa9, 0xd0, 0xd9, 0xc1, 0x16, 0xba,
|
||||
0xb0, 0xf7, 0x81, 0x46, 0x41, 0x3d, 0xbf, 0xff, 0x01, 0x63, 0xdb, 0x18, 0x93, 0x66, 0x4d, 0xce,
|
||||
0x9b, 0xce, 0x5f, 0xaf, 0xff, 0xc0, 0x35, 0x3a, 0x1b, 0x38, 0x22, 0xd7, 0x56, 0x1e, 0xf0, 0x28,
|
||||
0x50, 0x74, 0xff, 0xfa, 0x4a, 0x1b, 0x55, 0xac, 0x47, 0xb6, 0x24, 0xc4, 0x4a, 0xa2, 0xcb, 0x3f,
|
||||
0xfe, 0x91, 0xb9, 0xa4, 0x89, 0x64, 0x52, 0xe9, 0x2d, 0xc5, 0x79, 0x98, 0x82, 0x80,
|
||||
]
|
||||
|
||||
static let bars709Limited: [UInt8] = [
|
||||
0x00, 0x00, 0x00, 0x01, 0x40, 0x01, 0x0c, 0x01, 0xff, 0xff, 0x01, 0x60, 0x00, 0x00, 0x03, 0x00,
|
||||
0x90, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0xff, 0x95, 0x98, 0x09, 0x00, 0x00, 0x00, 0x01,
|
||||
0x42, 0x01, 0x01, 0x01, 0x60, 0x00, 0x00, 0x03, 0x00, 0x90, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03,
|
||||
0x00, 0xff, 0xa0, 0x08, 0x08, 0x10, 0x59, 0x65, 0x66, 0x92, 0x4c, 0xae, 0x6a, 0x02, 0x02, 0x02,
|
||||
0x08, 0x00, 0x00, 0x03, 0x00, 0x08, 0x00, 0x00, 0x03, 0x00, 0xc8, 0x40, 0x00, 0x00, 0x00, 0x01,
|
||||
0x44, 0x01, 0xc1, 0x71, 0xa9, 0x12, 0x00, 0x00, 0x01, 0x4e, 0x01, 0x05, 0xff, 0xff, 0xff, 0xff,
|
||||
0xff, 0xff, 0xff, 0xff, 0xd1, 0x2c, 0xa2, 0xde, 0x09, 0xb5, 0x17, 0x47, 0xdb, 0xbb, 0x55, 0xa4,
|
||||
0xfe, 0x7f, 0xc2, 0xfc, 0x4e, 0x78, 0x32, 0x36, 0x35, 0x20, 0x28, 0x62, 0x75, 0x69, 0x6c, 0x64,
|
||||
0x20, 0x32, 0x31, 0x36, 0x29, 0x20, 0x2d, 0x20, 0x34, 0x2e, 0x32, 0x2b, 0x31, 0x2d, 0x65, 0x34,
|
||||
0x34, 0x34, 0x37, 0x34, 0x34, 0x3a, 0x5b, 0x4d, 0x61, 0x63, 0x20, 0x4f, 0x53, 0x20, 0x58, 0x5d,
|
||||
0x5b, 0x63, 0x6c, 0x61, 0x6e, 0x67, 0x20, 0x32, 0x31, 0x2e, 0x30, 0x2e, 0x30, 0x5d, 0x5b, 0x36,
|
||||
0x34, 0x20, 0x62, 0x69, 0x74, 0x5d, 0x20, 0x38, 0x62, 0x69, 0x74, 0x2b, 0x31, 0x30, 0x62, 0x69,
|
||||
0x74, 0x2b, 0x31, 0x32, 0x62, 0x69, 0x74, 0x20, 0x2d, 0x20, 0x48, 0x2e, 0x32, 0x36, 0x35, 0x2f,
|
||||
0x48, 0x45, 0x56, 0x43, 0x20, 0x63, 0x6f, 0x64, 0x65, 0x63, 0x20, 0x2d, 0x20, 0x43, 0x6f, 0x70,
|
||||
0x79, 0x72, 0x69, 0x67, 0x68, 0x74, 0x20, 0x32, 0x30, 0x31, 0x33, 0x2d, 0x32, 0x30, 0x31, 0x38,
|
||||
0x20, 0x28, 0x63, 0x29, 0x20, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x63, 0x6f, 0x72, 0x65, 0x77, 0x61,
|
||||
0x72, 0x65, 0x2c, 0x20, 0x49, 0x6e, 0x63, 0x20, 0x2d, 0x20, 0x68, 0x74, 0x74, 0x70, 0x3a, 0x2f,
|
||||
0x2f, 0x78, 0x32, 0x36, 0x35, 0x2e, 0x6f, 0x72, 0x67, 0x20, 0x2d, 0x20, 0x6f, 0x70, 0x74, 0x69,
|
||||
0x6f, 0x6e, 0x73, 0x3a, 0x20, 0x63, 0x70, 0x75, 0x69, 0x64, 0x3d, 0x39, 0x38, 0x20, 0x66, 0x72,
|
||||
0x61, 0x6d, 0x65, 0x2d, 0x74, 0x68, 0x72, 0x65, 0x61, 0x64, 0x73, 0x3d, 0x31, 0x20, 0x6e, 0x6f,
|
||||
0x2d, 0x77, 0x70, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x70, 0x6d, 0x6f, 0x64, 0x65, 0x20, 0x6e, 0x6f,
|
||||
0x2d, 0x70, 0x6d, 0x65, 0x20, 0x6e, 0x6f, 0x2d, 0x70, 0x73, 0x6e, 0x72, 0x20, 0x6e, 0x6f, 0x2d,
|
||||
0x73, 0x73, 0x69, 0x6d, 0x20, 0x6c, 0x6f, 0x67, 0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30,
|
||||
0x20, 0x62, 0x69, 0x74, 0x64, 0x65, 0x70, 0x74, 0x68, 0x3d, 0x38, 0x20, 0x69, 0x6e, 0x70, 0x75,
|
||||
0x74, 0x2d, 0x63, 0x73, 0x70, 0x3d, 0x31, 0x20, 0x66, 0x70, 0x73, 0x3d, 0x32, 0x35, 0x2f, 0x31,
|
||||
0x20, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x2d, 0x72, 0x65, 0x73, 0x3d, 0x32, 0x35, 0x36, 0x78, 0x36,
|
||||
0x34, 0x20, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6c, 0x61, 0x63, 0x65, 0x3d, 0x30, 0x20, 0x74, 0x6f,
|
||||
0x74, 0x61, 0x6c, 0x2d, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x73, 0x3d, 0x30, 0x20, 0x6c, 0x65, 0x76,
|
||||
0x65, 0x6c, 0x2d, 0x69, 0x64, 0x63, 0x3d, 0x30, 0x20, 0x68, 0x69, 0x67, 0x68, 0x2d, 0x74, 0x69,
|
||||
0x65, 0x72, 0x3d, 0x31, 0x20, 0x75, 0x68, 0x64, 0x2d, 0x62, 0x64, 0x3d, 0x30, 0x20, 0x72, 0x65,
|
||||
0x66, 0x3d, 0x33, 0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x2d, 0x6e, 0x6f, 0x6e,
|
||||
0x2d, 0x63, 0x6f, 0x6e, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x6e, 0x63, 0x65, 0x20, 0x72, 0x65, 0x70,
|
||||
0x65, 0x61, 0x74, 0x2d, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x20, 0x61, 0x6e, 0x6e, 0x65,
|
||||
0x78, 0x62, 0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x75, 0x64, 0x20, 0x6e, 0x6f, 0x2d, 0x65, 0x6f, 0x62,
|
||||
0x20, 0x6e, 0x6f, 0x2d, 0x65, 0x6f, 0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x72, 0x64, 0x20, 0x69,
|
||||
0x6e, 0x66, 0x6f, 0x20, 0x68, 0x61, 0x73, 0x68, 0x3d, 0x30, 0x20, 0x74, 0x65, 0x6d, 0x70, 0x6f,
|
||||
0x72, 0x61, 0x6c, 0x2d, 0x6c, 0x61, 0x79, 0x65, 0x72, 0x73, 0x3d, 0x30, 0x20, 0x6f, 0x70, 0x65,
|
||||
0x6e, 0x2d, 0x67, 0x6f, 0x70, 0x20, 0x6d, 0x69, 0x6e, 0x2d, 0x6b, 0x65, 0x79, 0x69, 0x6e, 0x74,
|
||||
0x3d, 0x32, 0x35, 0x20, 0x6b, 0x65, 0x79, 0x69, 0x6e, 0x74, 0x3d, 0x32, 0x35, 0x30, 0x20, 0x67,
|
||||
0x6f, 0x70, 0x2d, 0x6c, 0x6f, 0x6f, 0x6b, 0x61, 0x68, 0x65, 0x61, 0x64, 0x3d, 0x30, 0x20, 0x62,
|
||||
0x66, 0x72, 0x61, 0x6d, 0x65, 0x73, 0x3d, 0x34, 0x20, 0x62, 0x2d, 0x61, 0x64, 0x61, 0x70, 0x74,
|
||||
0x3d, 0x32, 0x20, 0x62, 0x2d, 0x70, 0x79, 0x72, 0x61, 0x6d, 0x69, 0x64, 0x20, 0x62, 0x66, 0x72,
|
||||
0x61, 0x6d, 0x65, 0x2d, 0x62, 0x69, 0x61, 0x73, 0x3d, 0x30, 0x20, 0x72, 0x63, 0x2d, 0x6c, 0x6f,
|
||||
0x6f, 0x6b, 0x61, 0x68, 0x65, 0x61, 0x64, 0x3d, 0x32, 0x30, 0x20, 0x6c, 0x6f, 0x6f, 0x6b, 0x61,
|
||||
0x68, 0x65, 0x61, 0x64, 0x2d, 0x73, 0x6c, 0x69, 0x63, 0x65, 0x73, 0x3d, 0x30, 0x20, 0x73, 0x63,
|
||||
0x65, 0x6e, 0x65, 0x63, 0x75, 0x74, 0x3d, 0x34, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x69, 0x73,
|
||||
0x74, 0x2d, 0x73, 0x63, 0x65, 0x6e, 0x65, 0x63, 0x75, 0x74, 0x20, 0x72, 0x61, 0x64, 0x6c, 0x3d,
|
||||
0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x70, 0x6c, 0x69, 0x63, 0x65, 0x20, 0x6e, 0x6f, 0x2d, 0x69,
|
||||
0x6e, 0x74, 0x72, 0x61, 0x2d, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x20, 0x63, 0x74, 0x75,
|
||||
0x3d, 0x36, 0x34, 0x20, 0x6d, 0x69, 0x6e, 0x2d, 0x63, 0x75, 0x2d, 0x73, 0x69, 0x7a, 0x65, 0x3d,
|
||||
0x38, 0x20, 0x6e, 0x6f, 0x2d, 0x72, 0x65, 0x63, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x6d, 0x70,
|
||||
0x20, 0x6d, 0x61, 0x78, 0x2d, 0x74, 0x75, 0x2d, 0x73, 0x69, 0x7a, 0x65, 0x3d, 0x33, 0x32, 0x20,
|
||||
0x74, 0x75, 0x2d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x2d, 0x64, 0x65, 0x70, 0x74, 0x68, 0x3d, 0x31,
|
||||
0x20, 0x74, 0x75, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x2d, 0x64, 0x65, 0x70, 0x74, 0x68, 0x3d,
|
||||
0x31, 0x20, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x74, 0x75, 0x3d, 0x30, 0x20, 0x72, 0x64, 0x6f,
|
||||
0x71, 0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30, 0x20, 0x64, 0x79, 0x6e, 0x61, 0x6d, 0x69,
|
||||
0x63, 0x2d, 0x72, 0x64, 0x3d, 0x30, 0x2e, 0x30, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x73, 0x69,
|
||||
0x6d, 0x2d, 0x72, 0x64, 0x20, 0x73, 0x69, 0x67, 0x6e, 0x68, 0x69, 0x64, 0x65, 0x20, 0x6e, 0x6f,
|
||||
0x2d, 0x74, 0x73, 0x6b, 0x69, 0x70, 0x20, 0x6e, 0x72, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x3d,
|
||||
0x30, 0x20, 0x6e, 0x72, 0x2d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d,
|
||||
0x63, 0x6f, 0x6e, 0x73, 0x74, 0x72, 0x61, 0x69, 0x6e, 0x65, 0x64, 0x2d, 0x69, 0x6e, 0x74, 0x72,
|
||||
0x61, 0x20, 0x73, 0x74, 0x72, 0x6f, 0x6e, 0x67, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x2d, 0x73,
|
||||
0x6d, 0x6f, 0x6f, 0x74, 0x68, 0x69, 0x6e, 0x67, 0x20, 0x6d, 0x61, 0x78, 0x2d, 0x6d, 0x65, 0x72,
|
||||
0x67, 0x65, 0x3d, 0x33, 0x20, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x72, 0x65, 0x66, 0x73, 0x3d,
|
||||
0x31, 0x20, 0x6e, 0x6f, 0x2d, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x6d, 0x6f, 0x64, 0x65, 0x73,
|
||||
0x20, 0x6d, 0x65, 0x3d, 0x31, 0x20, 0x73, 0x75, 0x62, 0x6d, 0x65, 0x3d, 0x32, 0x20, 0x6d, 0x65,
|
||||
0x72, 0x61, 0x6e, 0x67, 0x65, 0x3d, 0x35, 0x37, 0x20, 0x74, 0x65, 0x6d, 0x70, 0x6f, 0x72, 0x61,
|
||||
0x6c, 0x2d, 0x6d, 0x76, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x2d, 0x64,
|
||||
0x75, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x6d, 0x65, 0x20, 0x77, 0x65, 0x69, 0x67, 0x68, 0x74,
|
||||
0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x77, 0x65, 0x69, 0x67, 0x68, 0x74, 0x62, 0x20, 0x6e, 0x6f, 0x2d,
|
||||
0x61, 0x6e, 0x61, 0x6c, 0x79, 0x7a, 0x65, 0x2d, 0x73, 0x72, 0x63, 0x2d, 0x70, 0x69, 0x63, 0x73,
|
||||
0x20, 0x64, 0x65, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x3d, 0x30, 0x3a, 0x30, 0x20, 0x73, 0x61, 0x6f,
|
||||
0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x61, 0x6f, 0x2d, 0x6e, 0x6f, 0x6e, 0x2d, 0x64, 0x65, 0x62, 0x6c,
|
||||
0x6f, 0x63, 0x6b, 0x20, 0x72, 0x64, 0x3d, 0x33, 0x20, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x69,
|
||||
0x76, 0x65, 0x2d, 0x73, 0x61, 0x6f, 0x3d, 0x34, 0x20, 0x65, 0x61, 0x72, 0x6c, 0x79, 0x2d, 0x73,
|
||||
0x6b, 0x69, 0x70, 0x20, 0x72, 0x73, 0x6b, 0x69, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x61, 0x73,
|
||||
0x74, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x20, 0x6e, 0x6f, 0x2d, 0x74, 0x73, 0x6b, 0x69, 0x70,
|
||||
0x2d, 0x66, 0x61, 0x73, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x63, 0x75, 0x2d, 0x6c, 0x6f, 0x73, 0x73,
|
||||
0x6c, 0x65, 0x73, 0x73, 0x20, 0x62, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x20, 0x6e, 0x6f, 0x2d,
|
||||
0x73, 0x70, 0x6c, 0x69, 0x74, 0x72, 0x64, 0x2d, 0x73, 0x6b, 0x69, 0x70, 0x20, 0x72, 0x64, 0x70,
|
||||
0x65, 0x6e, 0x61, 0x6c, 0x74, 0x79, 0x3d, 0x30, 0x20, 0x70, 0x73, 0x79, 0x2d, 0x72, 0x64, 0x3d,
|
||||
0x32, 0x2e, 0x30, 0x30, 0x20, 0x70, 0x73, 0x79, 0x2d, 0x72, 0x64, 0x6f, 0x71, 0x3d, 0x30, 0x2e,
|
||||
0x30, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x72, 0x64, 0x2d, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x20,
|
||||
0x6c, 0x6f, 0x73, 0x73, 0x6c, 0x65, 0x73, 0x73, 0x20, 0x63, 0x62, 0x71, 0x70, 0x6f, 0x66, 0x66,
|
||||
0x73, 0x3d, 0x30, 0x20, 0x63, 0x72, 0x71, 0x70, 0x6f, 0x66, 0x66, 0x73, 0x3d, 0x30, 0x20, 0x72,
|
||||
0x63, 0x3d, 0x63, 0x71, 0x70, 0x20, 0x71, 0x70, 0x3d, 0x34, 0x20, 0x69, 0x70, 0x72, 0x61, 0x74,
|
||||
0x69, 0x6f, 0x3d, 0x31, 0x2e, 0x34, 0x30, 0x20, 0x70, 0x62, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x3d,
|
||||
0x31, 0x2e, 0x33, 0x30, 0x20, 0x61, 0x71, 0x2d, 0x6d, 0x6f, 0x64, 0x65, 0x3d, 0x30, 0x20, 0x61,
|
||||
0x71, 0x2d, 0x73, 0x74, 0x72, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x3d, 0x30, 0x2e, 0x30, 0x30, 0x20,
|
||||
0x6e, 0x6f, 0x2d, 0x63, 0x75, 0x74, 0x72, 0x65, 0x65, 0x20, 0x7a, 0x6f, 0x6e, 0x65, 0x2d, 0x63,
|
||||
0x6f, 0x75, 0x6e, 0x74, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x74, 0x72, 0x69, 0x63, 0x74,
|
||||
0x2d, 0x63, 0x62, 0x72, 0x20, 0x71, 0x67, 0x2d, 0x73, 0x69, 0x7a, 0x65, 0x3d, 0x36, 0x34, 0x20,
|
||||
0x6e, 0x6f, 0x2d, 0x72, 0x63, 0x2d, 0x67, 0x72, 0x61, 0x69, 0x6e, 0x20, 0x71, 0x70, 0x6d, 0x61,
|
||||
0x78, 0x3d, 0x36, 0x39, 0x20, 0x71, 0x70, 0x6d, 0x69, 0x6e, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d,
|
||||
0x63, 0x6f, 0x6e, 0x73, 0x74, 0x2d, 0x76, 0x62, 0x76, 0x20, 0x73, 0x61, 0x72, 0x3d, 0x30, 0x20,
|
||||
0x6f, 0x76, 0x65, 0x72, 0x73, 0x63, 0x61, 0x6e, 0x3d, 0x30, 0x20, 0x76, 0x69, 0x64, 0x65, 0x6f,
|
||||
0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x3d, 0x35, 0x20, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x3d, 0x30,
|
||||
0x20, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x70, 0x72, 0x69, 0x6d, 0x3d, 0x31, 0x20, 0x74, 0x72, 0x61,
|
||||
0x6e, 0x73, 0x66, 0x65, 0x72, 0x3d, 0x31, 0x20, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x6d, 0x61, 0x74,
|
||||
0x72, 0x69, 0x78, 0x3d, 0x31, 0x20, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x61, 0x6c, 0x6f, 0x63, 0x3d,
|
||||
0x30, 0x20, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x2d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77,
|
||||
0x3d, 0x30, 0x20, 0x63, 0x6c, 0x6c, 0x3d, 0x30, 0x2c, 0x30, 0x20, 0x6d, 0x69, 0x6e, 0x2d, 0x6c,
|
||||
0x75, 0x6d, 0x61, 0x3d, 0x30, 0x20, 0x6d, 0x61, 0x78, 0x2d, 0x6c, 0x75, 0x6d, 0x61, 0x3d, 0x32,
|
||||
0x35, 0x35, 0x20, 0x6c, 0x6f, 0x67, 0x32, 0x2d, 0x6d, 0x61, 0x78, 0x2d, 0x70, 0x6f, 0x63, 0x2d,
|
||||
0x6c, 0x73, 0x62, 0x3d, 0x38, 0x20, 0x76, 0x75, 0x69, 0x2d, 0x74, 0x69, 0x6d, 0x69, 0x6e, 0x67,
|
||||
0x2d, 0x69, 0x6e, 0x66, 0x6f, 0x20, 0x76, 0x75, 0x69, 0x2d, 0x68, 0x72, 0x64, 0x2d, 0x69, 0x6e,
|
||||
0x66, 0x6f, 0x20, 0x73, 0x6c, 0x69, 0x63, 0x65, 0x73, 0x3d, 0x31, 0x20, 0x6e, 0x6f, 0x2d, 0x6f,
|
||||
0x70, 0x74, 0x2d, 0x71, 0x70, 0x2d, 0x70, 0x70, 0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x6f, 0x70, 0x74,
|
||||
0x2d, 0x72, 0x65, 0x66, 0x2d, 0x6c, 0x69, 0x73, 0x74, 0x2d, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68,
|
||||
0x2d, 0x70, 0x70, 0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x2d, 0x70, 0x61,
|
||||
0x73, 0x73, 0x2d, 0x6f, 0x70, 0x74, 0x2d, 0x72, 0x70, 0x73, 0x20, 0x73, 0x63, 0x65, 0x6e, 0x65,
|
||||
0x63, 0x75, 0x74, 0x2d, 0x62, 0x69, 0x61, 0x73, 0x3d, 0x30, 0x2e, 0x30, 0x35, 0x20, 0x6e, 0x6f,
|
||||
0x2d, 0x6f, 0x70, 0x74, 0x2d, 0x63, 0x75, 0x2d, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x2d, 0x71, 0x70,
|
||||
0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x71, 0x2d, 0x6d, 0x6f, 0x74, 0x69, 0x6f, 0x6e, 0x20, 0x6e, 0x6f,
|
||||
0x2d, 0x68, 0x64, 0x72, 0x31, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x64, 0x72, 0x31, 0x30, 0x2d,
|
||||
0x6f, 0x70, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x64, 0x68, 0x64, 0x72, 0x31, 0x30, 0x2d, 0x6f, 0x70,
|
||||
0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x69, 0x64, 0x72, 0x2d, 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72,
|
||||
0x79, 0x2d, 0x73, 0x65, 0x69, 0x20, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2d, 0x72,
|
||||
0x65, 0x75, 0x73, 0x65, 0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30, 0x20, 0x61, 0x6e, 0x61,
|
||||
0x6c, 0x79, 0x73, 0x69, 0x73, 0x2d, 0x73, 0x61, 0x76, 0x65, 0x2d, 0x72, 0x65, 0x75, 0x73, 0x65,
|
||||
0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30, 0x20, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69,
|
||||
0x73, 0x2d, 0x6c, 0x6f, 0x61, 0x64, 0x2d, 0x72, 0x65, 0x75, 0x73, 0x65, 0x2d, 0x6c, 0x65, 0x76,
|
||||
0x65, 0x6c, 0x3d, 0x30, 0x20, 0x73, 0x63, 0x61, 0x6c, 0x65, 0x2d, 0x66, 0x61, 0x63, 0x74, 0x6f,
|
||||
0x72, 0x3d, 0x30, 0x20, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61,
|
||||
0x3d, 0x30, 0x20, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x2d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x3d,
|
||||
0x30, 0x20, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x2d, 0x6d, 0x76, 0x3d, 0x31, 0x20, 0x72, 0x65,
|
||||
0x66, 0x69, 0x6e, 0x65, 0x2d, 0x63, 0x74, 0x75, 0x2d, 0x64, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x74,
|
||||
0x69, 0x6f, 0x6e, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x73,
|
||||
0x61, 0x6f, 0x20, 0x63, 0x74, 0x75, 0x2d, 0x69, 0x6e, 0x66, 0x6f, 0x3d, 0x30, 0x20, 0x6e, 0x6f,
|
||||
0x2d, 0x6c, 0x6f, 0x77, 0x70, 0x61, 0x73, 0x73, 0x2d, 0x64, 0x63, 0x74, 0x20, 0x72, 0x65, 0x66,
|
||||
0x69, 0x6e, 0x65, 0x2d, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2d, 0x74, 0x79, 0x70,
|
||||
0x65, 0x3d, 0x30, 0x20, 0x63, 0x6f, 0x70, 0x79, 0x2d, 0x70, 0x69, 0x63, 0x3d, 0x31, 0x20, 0x6d,
|
||||
0x61, 0x78, 0x2d, 0x61, 0x75, 0x73, 0x69, 0x7a, 0x65, 0x2d, 0x66, 0x61, 0x63, 0x74, 0x6f, 0x72,
|
||||
0x3d, 0x31, 0x2e, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x64, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x2d,
|
||||
0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x69, 0x6e, 0x67, 0x6c, 0x65,
|
||||
0x2d, 0x73, 0x65, 0x69, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x65, 0x76, 0x63, 0x2d, 0x61, 0x71, 0x20,
|
||||
0x6e, 0x6f, 0x2d, 0x73, 0x76, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x20,
|
||||
0x71, 0x70, 0x2d, 0x61, 0x64, 0x61, 0x70, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2d, 0x72, 0x61,
|
||||
0x6e, 0x67, 0x65, 0x3d, 0x31, 0x2e, 0x30, 0x30, 0x20, 0x73, 0x63, 0x65, 0x6e, 0x65, 0x63, 0x75,
|
||||
0x74, 0x2d, 0x61, 0x77, 0x61, 0x72, 0x65, 0x2d, 0x71, 0x70, 0x3d, 0x30, 0x63, 0x6f, 0x6e, 0x66,
|
||||
0x6f, 0x72, 0x6d, 0x61, 0x6e, 0x63, 0x65, 0x2d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x2d, 0x6f,
|
||||
0x66, 0x66, 0x73, 0x65, 0x74, 0x73, 0x20, 0x72, 0x69, 0x67, 0x68, 0x74, 0x3d, 0x30, 0x20, 0x62,
|
||||
0x6f, 0x74, 0x74, 0x6f, 0x6d, 0x3d, 0x30, 0x20, 0x64, 0x65, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2d,
|
||||
0x6d, 0x61, 0x78, 0x2d, 0x72, 0x61, 0x74, 0x65, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x76, 0x62,
|
||||
0x76, 0x2d, 0x6c, 0x69, 0x76, 0x65, 0x2d, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x2d, 0x70, 0x61, 0x73,
|
||||
0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x6d, 0x63, 0x73, 0x74, 0x66, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x62,
|
||||
0x72, 0x63, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x2d, 0x72, 0x63, 0x80, 0x00,
|
||||
0x00, 0x01, 0x28, 0x01, 0xaf, 0x05, 0xb8, 0x10, 0x0c, 0x7f, 0x80, 0xd7, 0x00, 0xc4, 0xbb, 0x82,
|
||||
0x75, 0x79, 0xbd, 0x3c, 0x5f, 0x14, 0xb1, 0x4b, 0x14, 0xb1, 0x4b, 0x17, 0xc5, 0x7c, 0x57, 0xc5,
|
||||
0x7c, 0x57, 0xc5, 0x7c, 0x57, 0xc5, 0x7d, 0xb4, 0xcb, 0x51, 0xc8, 0xd6, 0xa0, 0x35, 0xd5, 0x55,
|
||||
0xa4, 0x40, 0xd9, 0x57, 0x3f, 0xff, 0x7b, 0xb8, 0x0b, 0x9c, 0xe7, 0x3a, 0x29, 0x4a, 0x52, 0x94,
|
||||
0xa5, 0xc2, 0x9f, 0xff, 0xe8, 0xfd, 0xf0, 0x63, 0x71, 0xfc, 0x63, 0xec, 0x8f, 0x38, 0xf7, 0xff,
|
||||
0xfc, 0x89, 0xda, 0xff, 0xff, 0xf0, 0x69, 0xe0, 0x74, 0x7b, 0xdf, 0x7f, 0xcc, 0x1c, 0xc1, 0xcc,
|
||||
0x1c, 0xc1, 0xcc, 0xda, 0xcd, 0xac, 0xda, 0xcd, 0xac, 0xda, 0xcd, 0xac, 0xda, 0xc4, 0x73, 0xff,
|
||||
0xd8, 0xf7, 0x40, 0x0f, 0x9f, 0x18, 0xc6, 0x31, 0x8c, 0x67, 0x39, 0xce, 0x73, 0x9c, 0xb3, 0xd2,
|
||||
0x80, 0x0e, 0x4f, 0xff, 0x82, 0x8d, 0x24, 0xc8, 0xe1, 0x4d, 0xd7, 0xae, 0x61, 0xc6, 0x8f, 0xff,
|
||||
0xc6, 0x7a, 0xd8, 0xf8, 0x9c, 0xf6, 0xf9, 0xcf, 0x66, 0x3d, 0xa5, 0x9a, 0x2e, 0x4f, 0x7f, 0xfc,
|
||||
0x55, 0x8a, 0xa8, 0xed, 0xa7, 0x75, 0xbb, 0xe9, 0x49, 0x47, 0xff, 0xe3, 0x3d, 0x6c, 0x7c, 0x4e,
|
||||
0x7b, 0x7c, 0xe7, 0xb3, 0x16, 0x79, 0xa3, 0xe3, 0xff, 0xe7, 0xab, 0xaa, 0x9f, 0x21, 0x38, 0x65,
|
||||
0xc4, 0xb8, 0x18, 0x9f, 0xfe, 0x9b, 0xec, 0x4b, 0x58, 0x5d, 0xf8, 0xe5, 0xdf, 0x53, 0x6f, 0x2b,
|
||||
0x0c, 0x90, 0x70, 0xa0, 0x00, 0xee, 0xdd, 0x34, 0xd4, 0x1e, 0xb0, 0x10, 0x19, 0xe6, 0x85, 0xe6,
|
||||
0x00, 0x48, 0xff, 0xfd, 0x80, 0x28, 0x35, 0x78, 0x31, 0x21, 0xb6, 0x44, 0xdc, 0xed, 0xff, 0xf7,
|
||||
0xd9, 0xef, 0xcd, 0xe3, 0x09, 0x63, 0x63, 0xc8, 0x0d, 0x40, 0x1c, 0x5f, 0xff, 0x07, 0x4b, 0x3e,
|
||||
0xdd, 0x9f, 0x5f, 0x13, 0x8f, 0x5e, 0x4d, 0x7f, 0xfb, 0x27, 0xc1, 0xaa, 0x45, 0xe8, 0x00, 0x78,
|
||||
0x18, 0xd2, 0x7b, 0x04, 0x56, 0x7f, 0xfd, 0x49, 0x9f, 0xfe, 0xb6, 0xbb, 0x0b, 0x58, 0x86, 0x8b,
|
||||
0xdf, 0xff, 0x63, 0x53, 0x7d, 0x4f, 0xd7, 0x6d, 0x83, 0xdd, 0x24, 0x58, 0x37, 0xe3, 0xff, 0xd2,
|
||||
0xa0, 0xff, 0xea, 0x7c, 0x38, 0xbd, 0xc8, 0xcb, 0x41, 0xff, 0xf3, 0xe1, 0x33, 0x34, 0x62, 0xe7,
|
||||
0x3d, 0xc8, 0x0b, 0x72, 0xea, 0x02, 0x8a, 0x33, 0xff, 0xff, 0xa6, 0xb4, 0x4c, 0x89, 0x44, 0x92,
|
||||
0x49, 0x25, 0xa6, 0x9a, 0x69, 0xa6, 0x9a, 0x60, 0x3f, 0xff, 0xf3, 0x35, 0x41, 0x04, 0x41, 0xef,
|
||||
0xf0, 0x95, 0xfa, 0x4c, 0x21, 0x48, 0x56, 0x7f, 0x6a, 0x62, 0x00, 0xb3, 0xd4, 0x0d, 0xe9, 0xf0,
|
||||
0x69, 0x96, 0x4e, 0x10, 0x2d, 0x02, 0xd0, 0x2d, 0x02, 0xd0, 0x38, 0x43, 0x84, 0x38, 0x43, 0x84,
|
||||
0x38, 0x43, 0x84, 0x38, 0x42, 0xdf, 0x7f, 0x9a, 0xfd, 0xff, 0xff, 0xb9, 0x77, 0x71, 0x05, 0xa5,
|
||||
0x8b, 0x43, 0x77, 0x37, 0x73, 0x77, 0x37, 0x73, 0x7b, 0x07, 0xb0, 0x7b, 0x07, 0xb0, 0x7b, 0x07,
|
||||
0xb0, 0x7b, 0x07, 0x92, 0x20, 0x6d, 0xff, 0xfe, 0xb4, 0xe9, 0xde, 0x13, 0xd7, 0xc9, 0xbc, 0x56,
|
||||
0xf3, 0xee, 0xa8, 0xe4, 0x3f, 0xff, 0xa1, 0xf0, 0xfc, 0x2e, 0x5d, 0x4d, 0xac, 0xc7, 0x3f, 0x7b,
|
||||
0x12, 0xf6, 0x0d, 0x0c, 0x84, 0xff, 0xfb, 0x0e, 0x4f, 0x86, 0x62, 0xe4, 0x29, 0xcd, 0x16, 0x0d,
|
||||
0x1c, 0xd5, 0xf4, 0xff, 0xfc, 0xd6, 0x57, 0xd6, 0xef, 0x5c, 0x7f, 0xf4, 0x23, 0xc4, 0x3b, 0xff,
|
||||
0x44, 0x22, 0xf3, 0xff, 0xf1, 0x4c, 0x00, 0x90, 0x41, 0x74, 0x0e, 0xdb, 0x83, 0xdb, 0x98, 0x09,
|
||||
0xfd, 0xff, 0xf6, 0xfe, 0x6f, 0x2f, 0xb4, 0x11, 0xde, 0x8f, 0x3c, 0xed, 0x20, 0xdb, 0x5c, 0x20,
|
||||
0xf4, 0xf5, 0x7f, 0xff, 0x37, 0xb3, 0x9d, 0x94, 0xa5, 0x29, 0xc9, 0x4a, 0x52, 0x94, 0xa4, 0x4d,
|
||||
0xff, 0xf3, 0x75, 0xe0, 0xaa, 0xe3, 0x22, 0xfb, 0x9e, 0x89, 0xbf, 0x1c, 0x4c, 0x9f, 0xff, 0xfb,
|
||||
0x72, 0x76, 0xc9, 0x19, 0x67, 0xaf, 0x6f, 0x7e, 0xf7, 0xef, 0x7e, 0xf7, 0xef, 0xb6, 0x7b, 0x67,
|
||||
0xb6, 0x7b, 0x67, 0xb6, 0x7b, 0x67, 0xb6, 0x78, 0x08, 0xd8, 0x96, 0x00, 0xbe, 0x62, 0xfd, 0xef,
|
||||
0x7b, 0xdf, 0x2a, 0x10, 0x84, 0x21, 0x06, 0xa8, 0x0d, 0x2f, 0xff, 0x8c, 0x55, 0xb0, 0xd2, 0xf0,
|
||||
0x2a, 0xe6, 0xfa, 0xe9, 0xef, 0xff, 0xe8, 0xfa, 0x01, 0x39, 0xc2, 0x9c, 0x8f, 0x09, 0xe8, 0xf5,
|
||||
0x30, 0xa2, 0xaf, 0xff, 0x1f, 0x61, 0x54, 0xea, 0xd1, 0xd6, 0xde, 0xc5, 0x29, 0x4f, 0xff, 0x9e,
|
||||
0xf9, 0x05, 0x2a, 0x46, 0x6f, 0x36, 0x3c, 0x0c, 0xa8, 0xb2, 0x7f, 0xff, 0x10, 0x1f, 0xff, 0x8e,
|
||||
0x40, 0x9d, 0x84, 0xfb, 0x4f, 0x3f, 0xff, 0x8b, 0xc9, 0x5b, 0x85, 0x13, 0x47, 0x02, 0xc4, 0x4e,
|
||||
0xf7, 0x00, 0xbd, 0x7f, 0xfd, 0xc5, 0xa0, 0xda, 0x81, 0x9a, 0x24, 0xd8, 0x3d, 0x5c, 0x15, 0x53,
|
||||
0xfb, 0xbf, 0xfe, 0xf7, 0xe2, 0x20, 0x48, 0xd8, 0xf0, 0xa3, 0xa0, 0x20, 0xd8, 0x91, 0x6c, 0x02,
|
||||
0xb1, 0xff, 0xf7, 0xe3, 0x2f, 0x59, 0xc2, 0x49, 0x94, 0x57, 0x95, 0xcb, 0x93, 0x3a, 0xf8, 0xff,
|
||||
0xfb, 0xc1, 0x6f, 0x38, 0x3c, 0x77, 0x60, 0x88, 0x59, 0xdf, 0x60, 0xb2, 0x38, 0xe9, 0xd9, 0x00,
|
||||
0x1d, 0x78, 0xe9, 0xc8, 0x3a, 0x32, 0x69, 0x9c, 0x9d, 0xb6, 0xaa, 0xbf, 0xff, 0x43, 0x59, 0x62,
|
||||
0x04, 0x19, 0x30, 0xcb, 0xb7, 0xbb, 0xb8, 0xbf, 0x13, 0x3f, 0xff, 0x4d, 0xaa, 0x30, 0x0e, 0x2d,
|
||||
0xd4, 0xce, 0xc1, 0x74, 0x43, 0x75, 0xd4, 0xac, 0x51, 0xff, 0xf7, 0xb5, 0x7d, 0x74, 0x83, 0xc8,
|
||||
0xce, 0x4e, 0xcc, 0xce, 0xd8, 0x34, 0xc9, 0xff, 0xf7, 0x9a, 0xf2, 0xaa, 0xcb, 0x45, 0x75, 0xe2,
|
||||
0x6c, 0x41, 0xf5, 0xd9, 0xe3, 0xdd, 0x2b, 0xff, 0xfe, 0x2f, 0x25, 0x6e, 0x14, 0x4d, 0x1c, 0x0b,
|
||||
0x11, 0x43, 0x57, 0xff, 0x8a, 0xf6, 0xdb, 0x90, 0x47, 0xf1, 0x2c, 0x72, 0x5f, 0x12, 0xf3, 0xbf,
|
||||
0xfd, 0xb6, 0x8c, 0x1f, 0x2a, 0x79, 0x43, 0xec, 0x20, 0x9c, 0xcf, 0xff, 0x6c, 0x8a, 0x5d, 0x2e,
|
||||
0xe4, 0x89, 0x32, 0x9a, 0xe5, 0xa6, 0xe8, 0xab, 0xff, 0xf1, 0xaa, 0x55, 0x4e, 0x89, 0x31, 0xd5,
|
||||
0x8f, 0x95, 0xfa, 0x7f, 0xf8, 0x83, 0x93, 0x10, 0xaf, 0x77, 0xdb, 0x76, 0x14, 0x41, 0x6b, 0x2f,
|
||||
0xff, 0xdb, 0xb3, 0xff, 0xee, 0xc9, 0xc6, 0xfe, 0x3a, 0x41, 0x29, 0xff, 0xed, 0x04, 0xf6, 0x57,
|
||||
0xff, 0xad, 0x42, 0x1c, 0xbd, 0xb4, 0x6c, 0xa7, 0x9c, 0x87, 0xff, 0xfe, 0x68, 0xd4, 0xc9, 0x92,
|
||||
0xa5, 0x0d, 0x13, 0x6c, 0xdb, 0x36, 0xcd, 0xb3, 0xad, 0x6b, 0x5a, 0xd6, 0xb5, 0xad, 0x6b, 0x5a,
|
||||
0xd6, 0xa0, 0xbf, 0xff, 0xe3, 0xc2, 0xd8, 0x6d, 0xe4, 0xc2, 0xf4, 0x25, 0xcb, 0x59, 0x2d, 0x72,
|
||||
0x69, 0x07, 0x74, 0x08, 0x72, 0x03, 0xa8, 0x50, 0x05, 0xd6, 0xc4, 0x6f, 0x61, 0x7c, 0x5e, 0x8f,
|
||||
0xb1, 0x6c, 0x68, 0x31, 0xa0, 0xc6, 0x83, 0x1a, 0x0c, 0x6e, 0x09, 0xb8, 0x26, 0xe0, 0x9b, 0x82,
|
||||
0x6e, 0x09, 0xb8, 0x26, 0xe0, 0x9a, 0x13, 0x7a, 0xec, 0x03, 0x8b, 0x5c, 0xd7, 0xad, 0xfa, 0x2c,
|
||||
0x33, 0xd1, 0x4e, 0x0c, 0x78, 0x31, 0xe0, 0xc7, 0x83, 0x1e, 0x10, 0x04, 0x40, 0x11, 0x00, 0x44,
|
||||
0x01, 0x10, 0x04, 0x40, 0x11, 0x00, 0x43, 0x9c, 0x89, 0x17, 0xff, 0xfd, 0x9c, 0xe7, 0x03, 0xc3,
|
||||
0xae, 0xde, 0x4e, 0xb4, 0xa4, 0x8b, 0xab, 0x48, 0x6a, 0xfd, 0x60, 0x7c, 0x57, 0x1f, 0xff, 0xd9,
|
||||
0xb4, 0x6f, 0x2c, 0x0c, 0xc9, 0xc2, 0xcd, 0xf5, 0x60, 0x12, 0x59, 0xc5, 0xae, 0xf1, 0x27, 0x19,
|
||||
0x97, 0x69, 0xa5, 0xff, 0xfe, 0xcc, 0xd6, 0x37, 0x17, 0x92, 0xb3, 0x66, 0x3f, 0xdb, 0x68, 0x44,
|
||||
0x9e, 0x21, 0x12, 0x8a, 0x48, 0x6e, 0xbf, 0xff, 0xb3, 0x00, 0xda, 0x17, 0x61, 0x02, 0xfd, 0x26,
|
||||
0x97, 0x1d, 0x83, 0x48, 0x87, 0x5a, 0x4e, 0xcb, 0x8f, 0x24, 0x08, 0xbf, 0xff, 0xa5, 0x31, 0x1b,
|
||||
0x76, 0x35, 0x05, 0x4a, 0x6b, 0x42, 0x63, 0x65, 0x8e, 0x3d, 0x96, 0x3e, 0x36, 0xef, 0xff, 0xff,
|
||||
0xe9, 0x3f, 0x11, 0xd7, 0xa0, 0x62, 0x26, 0x55, 0xb4, 0x6c, 0xb0, 0x97, 0x1f, 0x37, 0xbb, 0x6c,
|
||||
0x1c, 0x56, 0x89, 0x79, 0x8c, 0xff, 0xfe, 0x34, 0xc7, 0x82, 0x42, 0x10, 0x85, 0x44, 0x21, 0x08,
|
||||
0x42, 0x17, 0x27, 0xff, 0xd9, 0x5a, 0x82, 0x0e, 0xab, 0x3d, 0xa6, 0xf5, 0x32, 0x85, 0xb7, 0x7f,
|
||||
0xff, 0xf8, 0xc7, 0xf1, 0x63, 0xc2, 0xcd, 0xea, 0x59, 0x25, 0x92, 0x59, 0x25, 0x92, 0x59, 0x7d,
|
||||
0x97, 0xd9, 0x7d, 0x97, 0xd9, 0x7d, 0x97, 0xd9, 0x7d, 0x92, 0x8d, 0xf2, 0x00, 0x48, 0xbc, 0xcb,
|
||||
0x5a, 0xd6, 0xb5, 0xc0, 0xa5, 0x29, 0x4a, 0x52, 0x06, 0x77, 0xcf, 0xff, 0xb8, 0x30, 0x59, 0x27,
|
||||
0xf7, 0x62, 0x38, 0x0c, 0xa1, 0x35, 0xff, 0xec, 0x9b, 0xb6, 0x5d, 0x9a, 0x02, 0x56, 0xf3, 0xc6,
|
||||
0x71, 0x48, 0xbb, 0x5f, 0xfe, 0xd6, 0x42, 0xaa, 0x1d, 0x7a, 0xde, 0x77, 0x06, 0xab, 0xaf, 0xff,
|
||||
0x64, 0xdd, 0xb2, 0xec, 0xd0, 0x12, 0xb7, 0x9e, 0x33, 0x80, 0x15, 0xbf, 0xfd, 0xac, 0x85, 0x54,
|
||||
0x3a, 0xf5, 0xbc, 0xee, 0x0d, 0x57, 0x5f, 0xfe, 0xc9, 0xbb, 0x65, 0xd9, 0xa0, 0x25, 0x6f, 0x3c,
|
||||
0x67, 0x26, 0x41, 0xff, 0xff, 0xfd, 0x21, 0x61, 0xf6, 0xe8, 0x83, 0x3c, 0x43, 0x61, 0x53, 0x6b,
|
||||
0xfc, 0x33, 0xa6, 0xbe, 0x35, 0x59, 0x89, 0xbf, 0xff, 0xca, 0x91, 0xd0, 0xc1, 0x31, 0xc1, 0xe7,
|
||||
0xbb, 0x89, 0xf6, 0x15, 0x87, 0x70, 0x29, 0xba, 0x64, 0xba, 0x7e, 0x88, 0xbf, 0xff, 0xb2, 0x30,
|
||||
0xd1, 0x95, 0xef, 0xe1, 0xec, 0x3b, 0xef, 0xd8, 0x40, 0x72, 0x7f, 0x53, 0x27, 0xc6, 0x4c, 0xfb,
|
||||
0xff, 0xfb, 0x23, 0x0d, 0x19, 0x5e, 0xfe, 0x1e, 0xc3, 0xbe, 0xfd, 0x84, 0x07, 0x27, 0xf5, 0x32,
|
||||
0x7c, 0x64, 0x69, 0x54, 0x07, 0x00, 0x21, 0xa1, 0xb8, 0x31, 0xe7, 0x92, 0x4d, 0x21, 0x98, 0xe1,
|
||||
0xe9, 0x7f, 0xff, 0x8d, 0x04, 0x56, 0x50, 0xf3, 0x87, 0x8e, 0xe9, 0x2c, 0x8b, 0x9a, 0x5d, 0x0e,
|
||||
0x69, 0x7a, 0xdf, 0xb3, 0x5f, 0xff, 0xe3, 0x40, 0xd3, 0x1c, 0xfe, 0x0e, 0x49, 0x93, 0x22, 0xcd,
|
||||
0xe3, 0xac, 0xa4, 0x02, 0xfb, 0xa5, 0x1b, 0xab, 0x9c, 0x9f, 0xff, 0xd6, 0x74, 0xad, 0x35, 0x38,
|
||||
0x54, 0x07, 0xfc, 0xf4, 0x47, 0x3e, 0xc8, 0x84, 0xfb, 0x24, 0x7a, 0xe0, 0x27, 0xff, 0xf5, 0x9d,
|
||||
0x13, 0x49, 0x92, 0x11, 0x5f, 0xc8, 0xd6, 0x12, 0xc1, 0xc0, 0x69, 0x64, 0x95, 0x44, 0x79, 0x86,
|
||||
0x49, 0xb4, 0x9f, 0xfe, 0x3f, 0x97, 0x6d, 0x34, 0x2f, 0xd0, 0xc9, 0x77, 0xb6, 0x27, 0xff, 0x8f,
|
||||
0xe5, 0xdb, 0x4d, 0x0b, 0xf4, 0x32, 0x5d, 0xe3, 0x55, 0x7d, 0xbf, 0xfd, 0x07, 0x21, 0x7d, 0xd6,
|
||||
0x5c, 0x66, 0xa7, 0xd9, 0x86, 0xaf, 0xff, 0x41, 0xc8, 0x5f, 0x75, 0x97, 0x19, 0xa9, 0xf6, 0x50,
|
||||
0x7a, 0x79, 0xc9, 0xff, 0xe5, 0x1b, 0x2a, 0x9d, 0xe9, 0xe9, 0x3d, 0x4b, 0x9a, 0x64, 0xff, 0xf1,
|
||||
0xfc, 0xbb, 0x69, 0xa1, 0x7e, 0x86, 0x4b, 0xbc, 0x6a, 0xfa, 0x17, 0xff, 0xa4, 0xbb, 0x55, 0x2a,
|
||||
0x0d, 0xeb, 0x17, 0x5e, 0x75, 0x2b, 0xff, 0xd0, 0x72, 0x17, 0xdd, 0x65, 0xc6, 0x6a, 0x7d, 0x94,
|
||||
0x1c, 0x72, 0x69, 0x71, 0xaf, 0xff, 0xe8, 0xc5, 0xf4, 0xd9, 0x5a, 0x12, 0x49, 0x24, 0xda, 0x28,
|
||||
0xa2, 0x8a, 0x28, 0x9d, 0x85, 0xff, 0xf9, 0x58, 0x68, 0xe1, 0xbe, 0x89, 0x87, 0x0a, 0x41, 0xbd,
|
||||
0x40, 0x5a, 0xbe, 0x24, 0x00, 0xa4, 0xd7, 0xb7, 0x6b, 0xc7, 0x0b, 0x74, 0x72, 0x39, 0x7e, 0x97,
|
||||
0xe9, 0x7e, 0x97, 0xe9, 0x88, 0xe8, 0x8e, 0x88, 0xe8, 0x8e, 0x88, 0xe8, 0x8e, 0x88, 0xe7, 0xf1,
|
||||
0x59, 0x7f, 0xff, 0xf5, 0x72, 0xea, 0x9c, 0xa5, 0xe0, 0xe5, 0xe5, 0x3e, 0x53, 0xe5, 0x3e, 0x53,
|
||||
0xe5, 0xd0, 0x5d, 0x05, 0xd0, 0x5d, 0x05, 0xd0, 0x5d, 0x05, 0xd0, 0x54, 0x5d, 0x80, 0x5f, 0xff,
|
||||
0x95, 0x86, 0x8e, 0x1b, 0xe8, 0x98, 0x70, 0xa4, 0x1b, 0xd4, 0x05, 0xe9, 0xaf, 0xff, 0xca, 0xc3,
|
||||
0x47, 0x0d, 0xf4, 0x4c, 0x38, 0x52, 0x0d, 0xea, 0x02, 0xd6, 0x96, 0x75, 0xfb, 0xff, 0xf4, 0xf5,
|
||||
0xfe, 0xc9, 0x8b, 0xed, 0xa0, 0xd4, 0x3f, 0xd4, 0x4e, 0xf3, 0xbb, 0xff, 0xf4, 0xf2, 0xb7, 0x3b,
|
||||
0x35, 0x34, 0x01, 0xbd, 0xcf, 0xc7, 0x6b, 0xc8, 0x86, 0xe2, 0xdf, 0xff, 0x95, 0xa6, 0x26, 0x6d,
|
||||
0x66, 0xef, 0x24, 0x4f, 0x9c, 0x50, 0x2e, 0x0b, 0x5f, 0xff, 0x95, 0x86, 0x8e, 0x1b, 0xe8, 0x98,
|
||||
0x70, 0xa4, 0x1b, 0xd4, 0x05, 0xad, 0x73, 0x9c, 0xf8, 0xd7, 0xff, 0xe5, 0x69, 0x89, 0x9b, 0x5a,
|
||||
0xd6, 0xc7, 0xef, 0x7b, 0xde, 0xf7, 0x16, 0xbf, 0xfd, 0x93, 0xe0, 0xd5, 0x22, 0xf4, 0x00, 0x3c,
|
||||
0x0c, 0x4d, 0xbe, 0x7f, 0xff, 0xfd, 0x40, 0x7a, 0x70, 0x28, 0xb4, 0x34, 0xd9, 0x35, 0x93, 0x59,
|
||||
0x35, 0x93, 0x59, 0x57, 0x15, 0x71, 0x57, 0x15, 0x71, 0x57, 0x15, 0x71, 0x57, 0x13, 0x6a, 0x84,
|
||||
0x01, 0x71, 0x0e, 0xa9, 0x4a, 0x52, 0x95, 0x06, 0xb5, 0xad, 0x6b, 0x51, 0x99, 0x0b, 0xdf, 0xfe,
|
||||
0x83, 0xd1, 0xc1, 0x15, 0xe1, 0x93, 0x97, 0xb2, 0x8b, 0xd5, 0xff, 0xe8, 0x3d, 0x1c, 0x11, 0x5e,
|
||||
0x19, 0x39, 0x7b, 0x28, 0x32, 0x5b, 0xf5, 0x6f, 0xff, 0x84, 0x61, 0x55, 0x1a, 0xb6, 0xa2, 0x05,
|
||||
0x17, 0xea, 0x77, 0xff, 0xbf, 0xa1, 0xda, 0x6b, 0x8b, 0x14, 0x60, 0xb1, 0x38, 0x81, 0x17, 0x97,
|
||||
0xff, 0xb5, 0x90, 0xaa, 0x87, 0x5e, 0xb7, 0x9d, 0xc1, 0x9a, 0xeb, 0xff, 0xd9, 0x3e, 0x0d, 0x52,
|
||||
0x2f, 0x40, 0x03, 0xc0, 0xc4, 0xe5, 0x54, 0xa6, 0xdf, 0xff, 0xa7, 0x95, 0xb9, 0xd9, 0xa9, 0xa0,
|
||||
0x0d, 0xee, 0x7e, 0x3b, 0x5e, 0x72, 0xef, 0xff, 0xd3, 0xca, 0xdc, 0xec, 0xd4, 0xd0, 0x06, 0xf7,
|
||||
0x3f, 0x1d, 0xaf, 0x22, 0x29, 0xf5, 0x7f, 0xfe, 0x56, 0x1a, 0x38, 0x6f, 0xa2, 0x61, 0xc2, 0x90,
|
||||
0x6f, 0x50, 0x17, 0xa6, 0xbf, 0xff, 0x2b, 0x0d, 0x1c, 0x37, 0xd1, 0x30, 0xe1, 0x48, 0x37, 0xa8,
|
||||
0x0b, 0x55, 0x39, 0xaf, 0xc0, 0x10, 0x61, 0x2d, 0x46, 0x41, 0x11, 0x62, 0x85, 0x8e, 0x38, 0x4f,
|
||||
0x9f, 0xff, 0xa0, 0xec, 0x48, 0x58, 0xc2, 0x3d, 0xd1, 0xc3, 0x19, 0xc3, 0x9c, 0x10, 0x5f, 0xff,
|
||||
0x9a, 0x0a, 0x59, 0x0b, 0x58, 0xda, 0x57, 0xf6, 0xb4, 0x6d, 0xdb, 0xd2, 0xd0, 0x74, 0xff, 0xfb,
|
||||
0x6e, 0x83, 0xdb, 0x08, 0x11, 0x73, 0xd9, 0x78, 0x19, 0x7e, 0x5b, 0xa4, 0xff, 0xfb, 0x6d, 0x28,
|
||||
0x6c, 0x1c, 0x47, 0xe7, 0xaa, 0x54, 0x14, 0x29, 0x10, 0x54, 0x84, 0xaf, 0x1f, 0xfe, 0x55, 0xcd,
|
||||
0xa1, 0xdc, 0xc5, 0x8c, 0x5f, 0x8b, 0xa9, 0x49, 0xff, 0xe5, 0x5c, 0xda, 0x1d, 0xcc, 0x58, 0xc5,
|
||||
0xf8, 0xb9, 0xf9, 0xf4, 0xab, 0xff, 0xda, 0xe2, 0xc5, 0x4b, 0x14, 0xd2, 0x8e, 0xe6, 0xbb, 0x98,
|
||||
0xbf, 0xfd, 0xae, 0x2c, 0x54, 0xb1, 0x4d, 0x28, 0xee, 0x6b, 0xab, 0xd2, 0x09, 0xed, 0xff, 0xee,
|
||||
0x2e, 0x2a, 0xa2, 0x37, 0x77, 0x7d, 0xbd, 0x0c, 0x42, 0xff, 0xf6, 0xb8, 0xb1, 0x52, 0xc5, 0x34,
|
||||
0xa3, 0xb9, 0xae, 0xae, 0xd0, 0x74, 0xff, 0xf3, 0x35, 0xea, 0xa4, 0x9a, 0x1c, 0x9b, 0xe5, 0xac,
|
||||
0xa2, 0x7f, 0xf9, 0x57, 0x36, 0x87, 0x73, 0x16, 0x31, 0x7e, 0x2e, 0x7e, 0xd0, 0x50,
|
||||
]
|
||||
|
||||
static let bars709Full: [UInt8] = [
|
||||
0x00, 0x00, 0x00, 0x01, 0x40, 0x01, 0x0c, 0x01, 0xff, 0xff, 0x01, 0x60, 0x00, 0x00, 0x03, 0x00,
|
||||
0x90, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0xff, 0x95, 0x98, 0x09, 0x00, 0x00, 0x00, 0x01,
|
||||
0x42, 0x01, 0x01, 0x01, 0x60, 0x00, 0x00, 0x03, 0x00, 0x90, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03,
|
||||
0x00, 0xff, 0xa0, 0x08, 0x08, 0x10, 0x59, 0x65, 0x66, 0x92, 0x4c, 0xae, 0x6e, 0x02, 0x02, 0x02,
|
||||
0x08, 0x00, 0x00, 0x03, 0x00, 0x08, 0x00, 0x00, 0x03, 0x00, 0xc8, 0x40, 0x00, 0x00, 0x00, 0x01,
|
||||
0x44, 0x01, 0xc1, 0x71, 0xa9, 0x12, 0x00, 0x00, 0x01, 0x4e, 0x01, 0x05, 0xff, 0xff, 0xff, 0xff,
|
||||
0xff, 0xff, 0xff, 0xff, 0xd1, 0x2c, 0xa2, 0xde, 0x09, 0xb5, 0x17, 0x47, 0xdb, 0xbb, 0x55, 0xa4,
|
||||
0xfe, 0x7f, 0xc2, 0xfc, 0x4e, 0x78, 0x32, 0x36, 0x35, 0x20, 0x28, 0x62, 0x75, 0x69, 0x6c, 0x64,
|
||||
0x20, 0x32, 0x31, 0x36, 0x29, 0x20, 0x2d, 0x20, 0x34, 0x2e, 0x32, 0x2b, 0x31, 0x2d, 0x65, 0x34,
|
||||
0x34, 0x34, 0x37, 0x34, 0x34, 0x3a, 0x5b, 0x4d, 0x61, 0x63, 0x20, 0x4f, 0x53, 0x20, 0x58, 0x5d,
|
||||
0x5b, 0x63, 0x6c, 0x61, 0x6e, 0x67, 0x20, 0x32, 0x31, 0x2e, 0x30, 0x2e, 0x30, 0x5d, 0x5b, 0x36,
|
||||
0x34, 0x20, 0x62, 0x69, 0x74, 0x5d, 0x20, 0x38, 0x62, 0x69, 0x74, 0x2b, 0x31, 0x30, 0x62, 0x69,
|
||||
0x74, 0x2b, 0x31, 0x32, 0x62, 0x69, 0x74, 0x20, 0x2d, 0x20, 0x48, 0x2e, 0x32, 0x36, 0x35, 0x2f,
|
||||
0x48, 0x45, 0x56, 0x43, 0x20, 0x63, 0x6f, 0x64, 0x65, 0x63, 0x20, 0x2d, 0x20, 0x43, 0x6f, 0x70,
|
||||
0x79, 0x72, 0x69, 0x67, 0x68, 0x74, 0x20, 0x32, 0x30, 0x31, 0x33, 0x2d, 0x32, 0x30, 0x31, 0x38,
|
||||
0x20, 0x28, 0x63, 0x29, 0x20, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x63, 0x6f, 0x72, 0x65, 0x77, 0x61,
|
||||
0x72, 0x65, 0x2c, 0x20, 0x49, 0x6e, 0x63, 0x20, 0x2d, 0x20, 0x68, 0x74, 0x74, 0x70, 0x3a, 0x2f,
|
||||
0x2f, 0x78, 0x32, 0x36, 0x35, 0x2e, 0x6f, 0x72, 0x67, 0x20, 0x2d, 0x20, 0x6f, 0x70, 0x74, 0x69,
|
||||
0x6f, 0x6e, 0x73, 0x3a, 0x20, 0x63, 0x70, 0x75, 0x69, 0x64, 0x3d, 0x39, 0x38, 0x20, 0x66, 0x72,
|
||||
0x61, 0x6d, 0x65, 0x2d, 0x74, 0x68, 0x72, 0x65, 0x61, 0x64, 0x73, 0x3d, 0x31, 0x20, 0x6e, 0x6f,
|
||||
0x2d, 0x77, 0x70, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x70, 0x6d, 0x6f, 0x64, 0x65, 0x20, 0x6e, 0x6f,
|
||||
0x2d, 0x70, 0x6d, 0x65, 0x20, 0x6e, 0x6f, 0x2d, 0x70, 0x73, 0x6e, 0x72, 0x20, 0x6e, 0x6f, 0x2d,
|
||||
0x73, 0x73, 0x69, 0x6d, 0x20, 0x6c, 0x6f, 0x67, 0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30,
|
||||
0x20, 0x62, 0x69, 0x74, 0x64, 0x65, 0x70, 0x74, 0x68, 0x3d, 0x38, 0x20, 0x69, 0x6e, 0x70, 0x75,
|
||||
0x74, 0x2d, 0x63, 0x73, 0x70, 0x3d, 0x31, 0x20, 0x66, 0x70, 0x73, 0x3d, 0x32, 0x35, 0x2f, 0x31,
|
||||
0x20, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x2d, 0x72, 0x65, 0x73, 0x3d, 0x32, 0x35, 0x36, 0x78, 0x36,
|
||||
0x34, 0x20, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6c, 0x61, 0x63, 0x65, 0x3d, 0x30, 0x20, 0x74, 0x6f,
|
||||
0x74, 0x61, 0x6c, 0x2d, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x73, 0x3d, 0x30, 0x20, 0x6c, 0x65, 0x76,
|
||||
0x65, 0x6c, 0x2d, 0x69, 0x64, 0x63, 0x3d, 0x30, 0x20, 0x68, 0x69, 0x67, 0x68, 0x2d, 0x74, 0x69,
|
||||
0x65, 0x72, 0x3d, 0x31, 0x20, 0x75, 0x68, 0x64, 0x2d, 0x62, 0x64, 0x3d, 0x30, 0x20, 0x72, 0x65,
|
||||
0x66, 0x3d, 0x33, 0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x2d, 0x6e, 0x6f, 0x6e,
|
||||
0x2d, 0x63, 0x6f, 0x6e, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x6e, 0x63, 0x65, 0x20, 0x72, 0x65, 0x70,
|
||||
0x65, 0x61, 0x74, 0x2d, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x20, 0x61, 0x6e, 0x6e, 0x65,
|
||||
0x78, 0x62, 0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x75, 0x64, 0x20, 0x6e, 0x6f, 0x2d, 0x65, 0x6f, 0x62,
|
||||
0x20, 0x6e, 0x6f, 0x2d, 0x65, 0x6f, 0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x72, 0x64, 0x20, 0x69,
|
||||
0x6e, 0x66, 0x6f, 0x20, 0x68, 0x61, 0x73, 0x68, 0x3d, 0x30, 0x20, 0x74, 0x65, 0x6d, 0x70, 0x6f,
|
||||
0x72, 0x61, 0x6c, 0x2d, 0x6c, 0x61, 0x79, 0x65, 0x72, 0x73, 0x3d, 0x30, 0x20, 0x6f, 0x70, 0x65,
|
||||
0x6e, 0x2d, 0x67, 0x6f, 0x70, 0x20, 0x6d, 0x69, 0x6e, 0x2d, 0x6b, 0x65, 0x79, 0x69, 0x6e, 0x74,
|
||||
0x3d, 0x32, 0x35, 0x20, 0x6b, 0x65, 0x79, 0x69, 0x6e, 0x74, 0x3d, 0x32, 0x35, 0x30, 0x20, 0x67,
|
||||
0x6f, 0x70, 0x2d, 0x6c, 0x6f, 0x6f, 0x6b, 0x61, 0x68, 0x65, 0x61, 0x64, 0x3d, 0x30, 0x20, 0x62,
|
||||
0x66, 0x72, 0x61, 0x6d, 0x65, 0x73, 0x3d, 0x34, 0x20, 0x62, 0x2d, 0x61, 0x64, 0x61, 0x70, 0x74,
|
||||
0x3d, 0x32, 0x20, 0x62, 0x2d, 0x70, 0x79, 0x72, 0x61, 0x6d, 0x69, 0x64, 0x20, 0x62, 0x66, 0x72,
|
||||
0x61, 0x6d, 0x65, 0x2d, 0x62, 0x69, 0x61, 0x73, 0x3d, 0x30, 0x20, 0x72, 0x63, 0x2d, 0x6c, 0x6f,
|
||||
0x6f, 0x6b, 0x61, 0x68, 0x65, 0x61, 0x64, 0x3d, 0x32, 0x30, 0x20, 0x6c, 0x6f, 0x6f, 0x6b, 0x61,
|
||||
0x68, 0x65, 0x61, 0x64, 0x2d, 0x73, 0x6c, 0x69, 0x63, 0x65, 0x73, 0x3d, 0x30, 0x20, 0x73, 0x63,
|
||||
0x65, 0x6e, 0x65, 0x63, 0x75, 0x74, 0x3d, 0x34, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x69, 0x73,
|
||||
0x74, 0x2d, 0x73, 0x63, 0x65, 0x6e, 0x65, 0x63, 0x75, 0x74, 0x20, 0x72, 0x61, 0x64, 0x6c, 0x3d,
|
||||
0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x70, 0x6c, 0x69, 0x63, 0x65, 0x20, 0x6e, 0x6f, 0x2d, 0x69,
|
||||
0x6e, 0x74, 0x72, 0x61, 0x2d, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x20, 0x63, 0x74, 0x75,
|
||||
0x3d, 0x36, 0x34, 0x20, 0x6d, 0x69, 0x6e, 0x2d, 0x63, 0x75, 0x2d, 0x73, 0x69, 0x7a, 0x65, 0x3d,
|
||||
0x38, 0x20, 0x6e, 0x6f, 0x2d, 0x72, 0x65, 0x63, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x6d, 0x70,
|
||||
0x20, 0x6d, 0x61, 0x78, 0x2d, 0x74, 0x75, 0x2d, 0x73, 0x69, 0x7a, 0x65, 0x3d, 0x33, 0x32, 0x20,
|
||||
0x74, 0x75, 0x2d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x2d, 0x64, 0x65, 0x70, 0x74, 0x68, 0x3d, 0x31,
|
||||
0x20, 0x74, 0x75, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x2d, 0x64, 0x65, 0x70, 0x74, 0x68, 0x3d,
|
||||
0x31, 0x20, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x74, 0x75, 0x3d, 0x30, 0x20, 0x72, 0x64, 0x6f,
|
||||
0x71, 0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30, 0x20, 0x64, 0x79, 0x6e, 0x61, 0x6d, 0x69,
|
||||
0x63, 0x2d, 0x72, 0x64, 0x3d, 0x30, 0x2e, 0x30, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x73, 0x69,
|
||||
0x6d, 0x2d, 0x72, 0x64, 0x20, 0x73, 0x69, 0x67, 0x6e, 0x68, 0x69, 0x64, 0x65, 0x20, 0x6e, 0x6f,
|
||||
0x2d, 0x74, 0x73, 0x6b, 0x69, 0x70, 0x20, 0x6e, 0x72, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x3d,
|
||||
0x30, 0x20, 0x6e, 0x72, 0x2d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d,
|
||||
0x63, 0x6f, 0x6e, 0x73, 0x74, 0x72, 0x61, 0x69, 0x6e, 0x65, 0x64, 0x2d, 0x69, 0x6e, 0x74, 0x72,
|
||||
0x61, 0x20, 0x73, 0x74, 0x72, 0x6f, 0x6e, 0x67, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x2d, 0x73,
|
||||
0x6d, 0x6f, 0x6f, 0x74, 0x68, 0x69, 0x6e, 0x67, 0x20, 0x6d, 0x61, 0x78, 0x2d, 0x6d, 0x65, 0x72,
|
||||
0x67, 0x65, 0x3d, 0x33, 0x20, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x72, 0x65, 0x66, 0x73, 0x3d,
|
||||
0x31, 0x20, 0x6e, 0x6f, 0x2d, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x6d, 0x6f, 0x64, 0x65, 0x73,
|
||||
0x20, 0x6d, 0x65, 0x3d, 0x31, 0x20, 0x73, 0x75, 0x62, 0x6d, 0x65, 0x3d, 0x32, 0x20, 0x6d, 0x65,
|
||||
0x72, 0x61, 0x6e, 0x67, 0x65, 0x3d, 0x35, 0x37, 0x20, 0x74, 0x65, 0x6d, 0x70, 0x6f, 0x72, 0x61,
|
||||
0x6c, 0x2d, 0x6d, 0x76, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x2d, 0x64,
|
||||
0x75, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x6d, 0x65, 0x20, 0x77, 0x65, 0x69, 0x67, 0x68, 0x74,
|
||||
0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x77, 0x65, 0x69, 0x67, 0x68, 0x74, 0x62, 0x20, 0x6e, 0x6f, 0x2d,
|
||||
0x61, 0x6e, 0x61, 0x6c, 0x79, 0x7a, 0x65, 0x2d, 0x73, 0x72, 0x63, 0x2d, 0x70, 0x69, 0x63, 0x73,
|
||||
0x20, 0x64, 0x65, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x3d, 0x30, 0x3a, 0x30, 0x20, 0x73, 0x61, 0x6f,
|
||||
0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x61, 0x6f, 0x2d, 0x6e, 0x6f, 0x6e, 0x2d, 0x64, 0x65, 0x62, 0x6c,
|
||||
0x6f, 0x63, 0x6b, 0x20, 0x72, 0x64, 0x3d, 0x33, 0x20, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x69,
|
||||
0x76, 0x65, 0x2d, 0x73, 0x61, 0x6f, 0x3d, 0x34, 0x20, 0x65, 0x61, 0x72, 0x6c, 0x79, 0x2d, 0x73,
|
||||
0x6b, 0x69, 0x70, 0x20, 0x72, 0x73, 0x6b, 0x69, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x61, 0x73,
|
||||
0x74, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x20, 0x6e, 0x6f, 0x2d, 0x74, 0x73, 0x6b, 0x69, 0x70,
|
||||
0x2d, 0x66, 0x61, 0x73, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x63, 0x75, 0x2d, 0x6c, 0x6f, 0x73, 0x73,
|
||||
0x6c, 0x65, 0x73, 0x73, 0x20, 0x62, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x20, 0x6e, 0x6f, 0x2d,
|
||||
0x73, 0x70, 0x6c, 0x69, 0x74, 0x72, 0x64, 0x2d, 0x73, 0x6b, 0x69, 0x70, 0x20, 0x72, 0x64, 0x70,
|
||||
0x65, 0x6e, 0x61, 0x6c, 0x74, 0x79, 0x3d, 0x30, 0x20, 0x70, 0x73, 0x79, 0x2d, 0x72, 0x64, 0x3d,
|
||||
0x32, 0x2e, 0x30, 0x30, 0x20, 0x70, 0x73, 0x79, 0x2d, 0x72, 0x64, 0x6f, 0x71, 0x3d, 0x30, 0x2e,
|
||||
0x30, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x72, 0x64, 0x2d, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x20,
|
||||
0x6c, 0x6f, 0x73, 0x73, 0x6c, 0x65, 0x73, 0x73, 0x20, 0x63, 0x62, 0x71, 0x70, 0x6f, 0x66, 0x66,
|
||||
0x73, 0x3d, 0x30, 0x20, 0x63, 0x72, 0x71, 0x70, 0x6f, 0x66, 0x66, 0x73, 0x3d, 0x30, 0x20, 0x72,
|
||||
0x63, 0x3d, 0x63, 0x71, 0x70, 0x20, 0x71, 0x70, 0x3d, 0x34, 0x20, 0x69, 0x70, 0x72, 0x61, 0x74,
|
||||
0x69, 0x6f, 0x3d, 0x31, 0x2e, 0x34, 0x30, 0x20, 0x70, 0x62, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x3d,
|
||||
0x31, 0x2e, 0x33, 0x30, 0x20, 0x61, 0x71, 0x2d, 0x6d, 0x6f, 0x64, 0x65, 0x3d, 0x30, 0x20, 0x61,
|
||||
0x71, 0x2d, 0x73, 0x74, 0x72, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x3d, 0x30, 0x2e, 0x30, 0x30, 0x20,
|
||||
0x6e, 0x6f, 0x2d, 0x63, 0x75, 0x74, 0x72, 0x65, 0x65, 0x20, 0x7a, 0x6f, 0x6e, 0x65, 0x2d, 0x63,
|
||||
0x6f, 0x75, 0x6e, 0x74, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x74, 0x72, 0x69, 0x63, 0x74,
|
||||
0x2d, 0x63, 0x62, 0x72, 0x20, 0x71, 0x67, 0x2d, 0x73, 0x69, 0x7a, 0x65, 0x3d, 0x36, 0x34, 0x20,
|
||||
0x6e, 0x6f, 0x2d, 0x72, 0x63, 0x2d, 0x67, 0x72, 0x61, 0x69, 0x6e, 0x20, 0x71, 0x70, 0x6d, 0x61,
|
||||
0x78, 0x3d, 0x36, 0x39, 0x20, 0x71, 0x70, 0x6d, 0x69, 0x6e, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d,
|
||||
0x63, 0x6f, 0x6e, 0x73, 0x74, 0x2d, 0x76, 0x62, 0x76, 0x20, 0x73, 0x61, 0x72, 0x3d, 0x30, 0x20,
|
||||
0x6f, 0x76, 0x65, 0x72, 0x73, 0x63, 0x61, 0x6e, 0x3d, 0x30, 0x20, 0x76, 0x69, 0x64, 0x65, 0x6f,
|
||||
0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x3d, 0x35, 0x20, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x3d, 0x31,
|
||||
0x20, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x70, 0x72, 0x69, 0x6d, 0x3d, 0x31, 0x20, 0x74, 0x72, 0x61,
|
||||
0x6e, 0x73, 0x66, 0x65, 0x72, 0x3d, 0x31, 0x20, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x6d, 0x61, 0x74,
|
||||
0x72, 0x69, 0x78, 0x3d, 0x31, 0x20, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x61, 0x6c, 0x6f, 0x63, 0x3d,
|
||||
0x30, 0x20, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x2d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77,
|
||||
0x3d, 0x30, 0x20, 0x63, 0x6c, 0x6c, 0x3d, 0x30, 0x2c, 0x30, 0x20, 0x6d, 0x69, 0x6e, 0x2d, 0x6c,
|
||||
0x75, 0x6d, 0x61, 0x3d, 0x30, 0x20, 0x6d, 0x61, 0x78, 0x2d, 0x6c, 0x75, 0x6d, 0x61, 0x3d, 0x32,
|
||||
0x35, 0x35, 0x20, 0x6c, 0x6f, 0x67, 0x32, 0x2d, 0x6d, 0x61, 0x78, 0x2d, 0x70, 0x6f, 0x63, 0x2d,
|
||||
0x6c, 0x73, 0x62, 0x3d, 0x38, 0x20, 0x76, 0x75, 0x69, 0x2d, 0x74, 0x69, 0x6d, 0x69, 0x6e, 0x67,
|
||||
0x2d, 0x69, 0x6e, 0x66, 0x6f, 0x20, 0x76, 0x75, 0x69, 0x2d, 0x68, 0x72, 0x64, 0x2d, 0x69, 0x6e,
|
||||
0x66, 0x6f, 0x20, 0x73, 0x6c, 0x69, 0x63, 0x65, 0x73, 0x3d, 0x31, 0x20, 0x6e, 0x6f, 0x2d, 0x6f,
|
||||
0x70, 0x74, 0x2d, 0x71, 0x70, 0x2d, 0x70, 0x70, 0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x6f, 0x70, 0x74,
|
||||
0x2d, 0x72, 0x65, 0x66, 0x2d, 0x6c, 0x69, 0x73, 0x74, 0x2d, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68,
|
||||
0x2d, 0x70, 0x70, 0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x2d, 0x70, 0x61,
|
||||
0x73, 0x73, 0x2d, 0x6f, 0x70, 0x74, 0x2d, 0x72, 0x70, 0x73, 0x20, 0x73, 0x63, 0x65, 0x6e, 0x65,
|
||||
0x63, 0x75, 0x74, 0x2d, 0x62, 0x69, 0x61, 0x73, 0x3d, 0x30, 0x2e, 0x30, 0x35, 0x20, 0x6e, 0x6f,
|
||||
0x2d, 0x6f, 0x70, 0x74, 0x2d, 0x63, 0x75, 0x2d, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x2d, 0x71, 0x70,
|
||||
0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x71, 0x2d, 0x6d, 0x6f, 0x74, 0x69, 0x6f, 0x6e, 0x20, 0x6e, 0x6f,
|
||||
0x2d, 0x68, 0x64, 0x72, 0x31, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x64, 0x72, 0x31, 0x30, 0x2d,
|
||||
0x6f, 0x70, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x64, 0x68, 0x64, 0x72, 0x31, 0x30, 0x2d, 0x6f, 0x70,
|
||||
0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x69, 0x64, 0x72, 0x2d, 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72,
|
||||
0x79, 0x2d, 0x73, 0x65, 0x69, 0x20, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2d, 0x72,
|
||||
0x65, 0x75, 0x73, 0x65, 0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30, 0x20, 0x61, 0x6e, 0x61,
|
||||
0x6c, 0x79, 0x73, 0x69, 0x73, 0x2d, 0x73, 0x61, 0x76, 0x65, 0x2d, 0x72, 0x65, 0x75, 0x73, 0x65,
|
||||
0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30, 0x20, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69,
|
||||
0x73, 0x2d, 0x6c, 0x6f, 0x61, 0x64, 0x2d, 0x72, 0x65, 0x75, 0x73, 0x65, 0x2d, 0x6c, 0x65, 0x76,
|
||||
0x65, 0x6c, 0x3d, 0x30, 0x20, 0x73, 0x63, 0x61, 0x6c, 0x65, 0x2d, 0x66, 0x61, 0x63, 0x74, 0x6f,
|
||||
0x72, 0x3d, 0x30, 0x20, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61,
|
||||
0x3d, 0x30, 0x20, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x2d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x3d,
|
||||
0x30, 0x20, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x2d, 0x6d, 0x76, 0x3d, 0x31, 0x20, 0x72, 0x65,
|
||||
0x66, 0x69, 0x6e, 0x65, 0x2d, 0x63, 0x74, 0x75, 0x2d, 0x64, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x74,
|
||||
0x69, 0x6f, 0x6e, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x73,
|
||||
0x61, 0x6f, 0x20, 0x63, 0x74, 0x75, 0x2d, 0x69, 0x6e, 0x66, 0x6f, 0x3d, 0x30, 0x20, 0x6e, 0x6f,
|
||||
0x2d, 0x6c, 0x6f, 0x77, 0x70, 0x61, 0x73, 0x73, 0x2d, 0x64, 0x63, 0x74, 0x20, 0x72, 0x65, 0x66,
|
||||
0x69, 0x6e, 0x65, 0x2d, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2d, 0x74, 0x79, 0x70,
|
||||
0x65, 0x3d, 0x30, 0x20, 0x63, 0x6f, 0x70, 0x79, 0x2d, 0x70, 0x69, 0x63, 0x3d, 0x31, 0x20, 0x6d,
|
||||
0x61, 0x78, 0x2d, 0x61, 0x75, 0x73, 0x69, 0x7a, 0x65, 0x2d, 0x66, 0x61, 0x63, 0x74, 0x6f, 0x72,
|
||||
0x3d, 0x31, 0x2e, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x64, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x2d,
|
||||
0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x69, 0x6e, 0x67, 0x6c, 0x65,
|
||||
0x2d, 0x73, 0x65, 0x69, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x65, 0x76, 0x63, 0x2d, 0x61, 0x71, 0x20,
|
||||
0x6e, 0x6f, 0x2d, 0x73, 0x76, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x20,
|
||||
0x71, 0x70, 0x2d, 0x61, 0x64, 0x61, 0x70, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2d, 0x72, 0x61,
|
||||
0x6e, 0x67, 0x65, 0x3d, 0x31, 0x2e, 0x30, 0x30, 0x20, 0x73, 0x63, 0x65, 0x6e, 0x65, 0x63, 0x75,
|
||||
0x74, 0x2d, 0x61, 0x77, 0x61, 0x72, 0x65, 0x2d, 0x71, 0x70, 0x3d, 0x30, 0x63, 0x6f, 0x6e, 0x66,
|
||||
0x6f, 0x72, 0x6d, 0x61, 0x6e, 0x63, 0x65, 0x2d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x2d, 0x6f,
|
||||
0x66, 0x66, 0x73, 0x65, 0x74, 0x73, 0x20, 0x72, 0x69, 0x67, 0x68, 0x74, 0x3d, 0x30, 0x20, 0x62,
|
||||
0x6f, 0x74, 0x74, 0x6f, 0x6d, 0x3d, 0x30, 0x20, 0x64, 0x65, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2d,
|
||||
0x6d, 0x61, 0x78, 0x2d, 0x72, 0x61, 0x74, 0x65, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x76, 0x62,
|
||||
0x76, 0x2d, 0x6c, 0x69, 0x76, 0x65, 0x2d, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x2d, 0x70, 0x61, 0x73,
|
||||
0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x6d, 0x63, 0x73, 0x74, 0x66, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x62,
|
||||
0x72, 0x63, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x2d, 0x72, 0x63, 0x80, 0x00,
|
||||
0x00, 0x01, 0x28, 0x01, 0xaf, 0x05, 0xb8, 0x10, 0x0c, 0x7f, 0x80, 0xd7, 0x00, 0xc4, 0xca, 0xe6,
|
||||
0x94, 0x42, 0x38, 0x60, 0x38, 0x52, 0x45, 0x24, 0x52, 0x45, 0x24, 0x55, 0x59, 0x55, 0x95, 0x59,
|
||||
0x55, 0x95, 0x59, 0x55, 0x95, 0x59, 0x56, 0xb4, 0xcb, 0x51, 0xc8, 0xd6, 0xa0, 0x35, 0xd5, 0x55,
|
||||
0xa4, 0x40, 0xd9, 0x57, 0x3f, 0xff, 0x91, 0xc3, 0x2c, 0x30, 0xc3, 0x0c, 0x31, 0x1c, 0x71, 0xc7,
|
||||
0x1c, 0x71, 0xc7, 0x85, 0x3f, 0xff, 0xda, 0x86, 0xda, 0x58, 0x75, 0x4a, 0x3b, 0xc1, 0xa2, 0x82,
|
||||
0x3d, 0xff, 0xff, 0x22, 0x76, 0xbf, 0xff, 0xfc, 0xd8, 0x79, 0x99, 0x24, 0xe8, 0x0f, 0x75, 0xff,
|
||||
0x5f, 0xf5, 0xff, 0x5f, 0xf6, 0x2e, 0xe2, 0xee, 0x2e, 0xe2, 0xee, 0x2e, 0xe2, 0xee, 0x2e, 0xe0,
|
||||
0x9c, 0xff, 0xf6, 0x3d, 0xd0, 0x03, 0xeb, 0xce, 0x73, 0x9c, 0xe7, 0x3a, 0xd6, 0xb5, 0xad, 0x6b,
|
||||
0x4c, 0xf4, 0xa0, 0x03, 0x93, 0xff, 0xe6, 0x75, 0xd0, 0x3d, 0xcf, 0xf0, 0x4e, 0xd2, 0x72, 0x8d,
|
||||
0x74, 0x7f, 0xfe, 0x89, 0x44, 0x87, 0x74, 0x94, 0xe6, 0x55, 0x90, 0x59, 0x45, 0xa5, 0x9a, 0x2e,
|
||||
0x4f, 0x7f, 0xfc, 0xeb, 0x9a, 0x21, 0xb2, 0xc8, 0x3c, 0x30, 0xf4, 0x24, 0x7a, 0x3f, 0xff, 0x44,
|
||||
0xa2, 0x43, 0xba, 0x4a, 0x73, 0x2a, 0xc8, 0x2c, 0x9a, 0x79, 0xa3, 0xe3, 0xff, 0xeb, 0x8f, 0x95,
|
||||
0x52, 0x72, 0x52, 0x34, 0xc8, 0xe9, 0x8b, 0x44, 0xff, 0xf6, 0xf0, 0x3a, 0x83, 0x7c, 0x5c, 0xce,
|
||||
0xd4, 0xba, 0x89, 0x3f, 0x2b, 0x0c, 0x90, 0x70, 0xa0, 0x00, 0xf0, 0x2c, 0x88, 0xe9, 0x8e, 0x1f,
|
||||
0xe0, 0x06, 0x79, 0xa1, 0x79, 0x80, 0x12, 0x3f, 0xff, 0x7d, 0xbc, 0x64, 0xb9, 0x97, 0xb8, 0xe8,
|
||||
0x1b, 0x03, 0xeb, 0x6f, 0xff, 0xca, 0xe9, 0xb5, 0x52, 0xa6, 0x29, 0x29, 0xfc, 0xfb, 0x8c, 0x40,
|
||||
0x1c, 0x5f, 0xff, 0x35, 0x77, 0x52, 0xf6, 0x79, 0xec, 0xa4, 0x7e, 0x2a, 0xdf, 0xaf, 0xff, 0x81,
|
||||
0xc0, 0x3b, 0x0b, 0x8b, 0x27, 0xcf, 0x6a, 0xd7, 0x4c, 0xec, 0x11, 0x59, 0xff, 0xf6, 0xe2, 0x9f,
|
||||
0xfe, 0xb6, 0x70, 0xc4, 0x83, 0x1c, 0x05, 0xbb, 0xff, 0xf0, 0x0d, 0x33, 0xc8, 0xb1, 0xeb, 0x05,
|
||||
0x9f, 0x5c, 0xc7, 0x20, 0xdf, 0x8f, 0xff, 0x67, 0x88, 0xff, 0xea, 0x77, 0x5e, 0x4b, 0x79, 0xd4,
|
||||
0xd2, 0x3f, 0xfe, 0xc4, 0x27, 0x79, 0x7b, 0xdc, 0x96, 0x40, 0xcd, 0xc7, 0x6f, 0xa8, 0x0a, 0x28,
|
||||
0xcf, 0xff, 0xfe, 0xef, 0xdb, 0xd4, 0xd0, 0x3c, 0x78, 0xf1, 0xe3, 0xd0, 0x20, 0x40, 0x81, 0x02,
|
||||
0x04, 0x07, 0x43, 0xff, 0xff, 0x55, 0x80, 0x26, 0x9f, 0xe0, 0xb3, 0x64, 0xa3, 0x87, 0x88, 0x9c,
|
||||
0xee, 0x95, 0x9f, 0xda, 0x98, 0x80, 0x2c, 0xf5, 0xd9, 0x3a, 0xe6, 0xfa, 0xd0, 0x75, 0x46, 0xd0,
|
||||
0x2d, 0x02, 0xd0, 0x2d, 0x02, 0xd0, 0xe1, 0x0e, 0x10, 0xe1, 0x0e, 0x10, 0xe1, 0x0e, 0x10, 0xe1,
|
||||
0x03, 0xb7, 0xf9, 0xaf, 0xdf, 0xff, 0xfc, 0x4a, 0xae, 0x23, 0x6e, 0x1b, 0xcb, 0xd4, 0x2e, 0x02,
|
||||
0xe0, 0x2e, 0x02, 0xe0, 0x2e, 0xf6, 0xef, 0x6e, 0xf6, 0xef, 0x6e, 0xf6, 0xef, 0x6e, 0xf6, 0xe7,
|
||||
0xc8, 0x1b, 0x7f, 0xff, 0xbb, 0x0c, 0x4b, 0xdb, 0x02, 0x66, 0xaa, 0x94, 0xf4, 0xed, 0xd6, 0x0d,
|
||||
0xd4, 0x3f, 0xff, 0xb1, 0xa5, 0x3e, 0xed, 0x3c, 0xe9, 0x07, 0xa9, 0x44, 0xdf, 0xb9, 0x10, 0x00,
|
||||
0x34, 0x32, 0x13, 0xff, 0xef, 0x84, 0xe7, 0xf8, 0x61, 0x80, 0x24, 0xdf, 0xcb, 0x21, 0xe5, 0xb1,
|
||||
0x52, 0x09, 0xff, 0xfa, 0xbb, 0x0c, 0x7c, 0xe1, 0xdb, 0x30, 0xf6, 0x95, 0x55, 0xf5, 0x15, 0xdf,
|
||||
0x61, 0x17, 0x9f, 0xff, 0x9d, 0xf9, 0xe9, 0x79, 0xe7, 0x7b, 0x14, 0x7a, 0x36, 0x0d, 0x1b, 0xc7,
|
||||
0x1c, 0xf7, 0xff, 0xe1, 0xfd, 0x7f, 0x44, 0xd8, 0x9b, 0xcf, 0x7a, 0x6e, 0x17, 0x38, 0xcc, 0x8c,
|
||||
0xc2, 0x0f, 0x4f, 0x57, 0xff, 0xf7, 0x55, 0x2b, 0x2a, 0xaa, 0xaa, 0xaa, 0xb2, 0x79, 0xe7, 0x9e,
|
||||
0x79, 0xe7, 0x59, 0xbf, 0xfe, 0xe7, 0x49, 0x8d, 0x06, 0x01, 0xfc, 0x41, 0x0b, 0xa7, 0x35, 0xc7,
|
||||
0x13, 0x27, 0xff, 0xff, 0x14, 0x1e, 0x21, 0x48, 0x15, 0xf9, 0xbc, 0xbd, 0xcb, 0xdc, 0xbd, 0xcb,
|
||||
0xdc, 0xcb, 0xac, 0xba, 0xcb, 0xac, 0xba, 0xcb, 0xac, 0xba, 0xcb, 0xab, 0xe2, 0x36, 0x25, 0x80,
|
||||
0x2f, 0xfb, 0x08, 0xc6, 0x31, 0x8c, 0x6f, 0xad, 0x6b, 0x5a, 0xd6, 0x36, 0x03, 0x4b, 0xff, 0xeb,
|
||||
0xc5, 0x63, 0x41, 0x65, 0x0a, 0x0c, 0x08, 0x18, 0xba, 0xd7, 0xff, 0xf7, 0xf2, 0x5f, 0x53, 0x0e,
|
||||
0xbe, 0xd7, 0xe6, 0x1a, 0xd5, 0x75, 0x30, 0xa2, 0xaf, 0xff, 0x5e, 0x5e, 0x52, 0x94, 0x4f, 0x21,
|
||||
0x29, 0x08, 0xa5, 0xbf, 0xbc, 0xff, 0xfb, 0xc1, 0xf6, 0xc9, 0x77, 0xc4, 0x88, 0x96, 0xee, 0x22,
|
||||
0xbd, 0x16, 0x4f, 0xff, 0xea, 0x6c, 0x84, 0x20, 0xfc, 0xc6, 0x13, 0x44, 0x4d, 0x48, 0x56, 0x7f,
|
||||
0xff, 0x5d, 0x66, 0xc1, 0x99, 0x29, 0xb7, 0xe2, 0x87, 0x73, 0x1f, 0xdc, 0x02, 0xf5, 0xff, 0xf8,
|
||||
0x93, 0x6e, 0xdb, 0xb9, 0xee, 0x66, 0x4b, 0x3c, 0xfd, 0x63, 0xc6, 0x4c, 0x7b, 0xff, 0xf2, 0x3f,
|
||||
0xb0, 0x4a, 0xa8, 0xc5, 0x0e, 0x87, 0x4e, 0x84, 0xf5, 0x99, 0x36, 0x80, 0xac, 0x7f, 0xfe, 0x4f,
|
||||
0x7b, 0xad, 0x46, 0x83, 0x6c, 0xb9, 0xa6, 0x5d, 0x76, 0xca, 0x57, 0x08, 0xff, 0xfc, 0x76, 0xd9,
|
||||
0x45, 0x85, 0x77, 0xf4, 0x9a, 0x09, 0xcb, 0xc2, 0x6e, 0x53, 0x03, 0xa7, 0x64, 0x00, 0x78, 0x92,
|
||||
0xba, 0xea, 0xdd, 0xb9, 0xae, 0xec, 0xc9, 0xdb, 0x6a, 0xab, 0xff, 0xf6, 0x2c, 0x94, 0x07, 0x9e,
|
||||
0x76, 0x5b, 0x4e, 0x17, 0x35, 0x0b, 0xad, 0xd6, 0x79, 0xff, 0xfb, 0x5b, 0x84, 0xd1, 0x00, 0x16,
|
||||
0xc4, 0x5b, 0xa1, 0x12, 0xa1, 0xaf, 0x57, 0xd5, 0x8a, 0x3f, 0xff, 0x22, 0xe5, 0xff, 0xe7, 0x9e,
|
||||
0x2a, 0x10, 0xbe, 0x98, 0xff, 0x4e, 0x32, 0x46, 0x9f, 0xff, 0x90, 0x1c, 0x19, 0x58, 0xeb, 0x75,
|
||||
0xba, 0x0b, 0x50, 0xf1, 0xa6, 0xfd, 0x2c, 0xf7, 0x4a, 0xff, 0xff, 0xae, 0xb3, 0x60, 0xcc, 0x94,
|
||||
0xdb, 0xf1, 0x43, 0xb9, 0x93, 0xab, 0xff, 0xd7, 0x10, 0x0f, 0x3b, 0x8a, 0xf4, 0xc9, 0x7c, 0x5d,
|
||||
0xbb, 0xf2, 0xf3, 0xbf, 0xfe, 0x66, 0x6f, 0x82, 0x36, 0xed, 0xe9, 0xbf, 0x6d, 0x54, 0xc7, 0x9f,
|
||||
0xff, 0x31, 0xaf, 0x10, 0x37, 0x79, 0xc3, 0xe4, 0x44, 0x04, 0xc7, 0x9b, 0xa2, 0xaf, 0xff, 0xd6,
|
||||
0xbd, 0x8c, 0x63, 0x02, 0xd0, 0x1b, 0xdc, 0x6f, 0xdb, 0x96, 0x9f, 0xfe, 0xb0, 0xd5, 0x05, 0x68,
|
||||
0x65, 0xb1, 0x62, 0xa5, 0xb2, 0x19, 0xb5, 0x97, 0xff, 0xf2, 0xf0, 0xd6, 0xb5, 0xa6, 0x77, 0xe3,
|
||||
0x3d, 0x8d, 0x17, 0xb5, 0x4f, 0xff, 0x95, 0xc6, 0x26, 0x53, 0xc2, 0x7f, 0xfb, 0x3c, 0xb7, 0x47,
|
||||
0xc6, 0xca, 0x79, 0xc8, 0x7f, 0xff, 0xea, 0x14, 0x53, 0xa7, 0x46, 0x89, 0xb3, 0x0b, 0x10, 0xb1,
|
||||
0x0b, 0x10, 0xb1, 0x0c, 0x12, 0xc1, 0x2c, 0x12, 0xc1, 0x2c, 0x12, 0xc1, 0x2c, 0x12, 0xbf, 0xcb,
|
||||
0xff, 0xfe, 0x78, 0xff, 0xb5, 0x5e, 0xcd, 0x7d, 0xb8, 0x9e, 0x9b, 0xf6, 0x77, 0x9c, 0xb6, 0x6b,
|
||||
0xb3, 0xd6, 0xbc, 0x5a, 0x3a, 0x85, 0x00, 0x5d, 0x6e, 0x8f, 0x57, 0x3b, 0xf7, 0x0d, 0x2d, 0x5f,
|
||||
0x2f, 0xa4, 0xbe, 0x92, 0xfa, 0x4b, 0xe9, 0x30, 0x02, 0x40, 0x09, 0x00, 0x24, 0x00, 0x90, 0x02,
|
||||
0x40, 0x09, 0x00, 0x23, 0xe9, 0x97, 0xae, 0xc0, 0x38, 0xb7, 0x4c, 0x8b, 0x9f, 0x2b, 0x82, 0xc6,
|
||||
0x93, 0xf6, 0xc3, 0xdb, 0x0f, 0x6c, 0x3d, 0xb0, 0xf6, 0xfc, 0x9b, 0xf2, 0x6f, 0xc9, 0xbf, 0x26,
|
||||
0xfc, 0x9b, 0xf2, 0x6f, 0xc9, 0xb8, 0xd8, 0x91, 0x7f, 0xff, 0xde, 0xf2, 0x9f, 0x72, 0x5f, 0xa4,
|
||||
0xdf, 0x42, 0xd1, 0x53, 0xe9, 0xe1, 0x81, 0x9b, 0x85, 0x88, 0x7b, 0xb1, 0xf1, 0xff, 0xfd, 0xed,
|
||||
0xc1, 0xe8, 0x25, 0x65, 0xdd, 0x23, 0xcd, 0xe2, 0x3c, 0xbd, 0xd5, 0x70, 0xef, 0xfc, 0x2b, 0x7e,
|
||||
0xd8, 0x76, 0x9a, 0x5f, 0xff, 0xef, 0x62, 0xf8, 0x1e, 0xc4, 0x28, 0xd5, 0xd5, 0x53, 0x4b, 0xb8,
|
||||
0x01, 0x3b, 0x94, 0x00, 0x9d, 0xe8, 0x5e, 0x76, 0xaf, 0xff, 0xef, 0x57, 0x8e, 0x51, 0x21, 0xe7,
|
||||
0xdc, 0x18, 0x7b, 0xe1, 0xc7, 0xea, 0x80, 0xfa, 0xfa, 0x1b, 0x98, 0x35, 0x31, 0x02, 0x2f, 0xff,
|
||||
0xec, 0x5a, 0x9f, 0x51, 0x14, 0xaa, 0x31, 0xb1, 0x17, 0x53, 0x71, 0xf9, 0x54, 0xac, 0xfc, 0xaa,
|
||||
0x7a, 0x67, 0x94, 0x7f, 0xff, 0xec, 0x4f, 0x2d, 0xf7, 0xe1, 0xd4, 0x9a, 0xcb, 0x35, 0xe5, 0xbc,
|
||||
0x5a, 0xc5, 0x0a, 0xf2, 0xf4, 0xb6, 0x9e, 0x56, 0x89, 0x79, 0x8c, 0xff, 0xfe, 0xc2, 0x0e, 0xc5,
|
||||
0x55, 0x55, 0x55, 0x56, 0x73, 0xcf, 0x3c, 0xf3, 0xcf, 0x44, 0x4f, 0xff, 0xc9, 0xed, 0xa4, 0x9b,
|
||||
0x63, 0x01, 0x60, 0x76, 0xb4, 0x22, 0x96, 0xdd, 0xff, 0xff, 0xe8, 0x9f, 0xd0, 0x8f, 0x37, 0x39,
|
||||
0x09, 0x7a, 0x97, 0xa9, 0x7a, 0x97, 0xa9, 0x7b, 0xf7, 0xbf, 0x7b, 0xf7, 0xbf, 0x7b, 0xf7, 0xbf,
|
||||
0x7b, 0xf7, 0xaa, 0x37, 0xc8, 0x01, 0x25, 0x4b, 0xfb, 0xde, 0xf7, 0xbe, 0x3c, 0x63, 0x18, 0xc6,
|
||||
0x2e, 0xb9, 0xdf, 0x3f, 0xff, 0x37, 0x0d, 0x7b, 0x55, 0x6f, 0xef, 0x54, 0x55, 0x48, 0x47, 0xaf,
|
||||
0xff, 0x93, 0x79, 0x1c, 0xfd, 0xc6, 0xb6, 0x82, 0x10, 0xbe, 0xf2, 0x94, 0x8b, 0xb5, 0xff, 0xf2,
|
||||
0x9c, 0x0c, 0x63, 0x11, 0xa5, 0x98, 0x4e, 0xe1, 0x5d, 0xe0, 0xaf, 0xff, 0x93, 0x79, 0x1c, 0xfd,
|
||||
0xc6, 0xb6, 0x82, 0x10, 0xbe, 0xf2, 0x80, 0x2b, 0x7f, 0xfc, 0xa7, 0x03, 0x18, 0xc4, 0x69, 0x66,
|
||||
0x13, 0xb8, 0x57, 0x78, 0x2b, 0xff, 0xe4, 0xde, 0x47, 0x3f, 0x71, 0xad, 0xa0, 0x84, 0x2f, 0xbc,
|
||||
0xa9, 0x90, 0x7f, 0xff, 0xff, 0x61, 0x11, 0x60, 0xbe, 0x7a, 0x34, 0x05, 0xfa, 0x7c, 0x2c, 0x02,
|
||||
0x93, 0x7f, 0x8f, 0x3b, 0x49, 0x78, 0xe9, 0x6f, 0xff, 0xf4, 0x70, 0xc4, 0xb3, 0x3c, 0x00, 0xcf,
|
||||
0x71, 0x94, 0xc8, 0x94, 0xdc, 0x24, 0xd1, 0x48, 0xe9, 0xf2, 0x10, 0xa7, 0xa2, 0x2f, 0xff, 0xef,
|
||||
0x2a, 0x8c, 0x71, 0x0f, 0x59, 0xc2, 0x0c, 0x95, 0x81, 0x8b, 0xe2, 0x2b, 0xe1, 0xee, 0x90, 0x10,
|
||||
0xb3, 0x86, 0xff, 0xfe, 0xf2, 0xa8, 0xc7, 0x10, 0xf5, 0x9c, 0x20, 0xc9, 0x58, 0x18, 0xbe, 0x22,
|
||||
0xbe, 0x1e, 0xe9, 0x01, 0x0b, 0x1e, 0xd5, 0x01, 0xc0, 0x08, 0x99, 0x6e, 0xc0, 0x1b, 0x0a, 0x14,
|
||||
0xed, 0x5f, 0x98, 0xe1, 0xe9, 0x7f, 0xff, 0x9c, 0x7e, 0xd2, 0x28, 0xb1, 0xf8, 0xa4, 0xa1, 0x96,
|
||||
0xc9, 0x93, 0x29, 0x3e, 0xe9, 0x94, 0xa0, 0x2a, 0xab, 0x01, 0x7f, 0xff, 0x9c, 0x7d, 0xda, 0x96,
|
||||
0xf9, 0xd2, 0x69, 0xb0, 0xa7, 0x3b, 0x45, 0x93, 0xda, 0xa0, 0x79, 0x82, 0x63, 0xd1, 0xee, 0x72,
|
||||
0x7f, 0xff, 0x70, 0x31, 0x67, 0xd0, 0x12, 0x65, 0x89, 0x85, 0x1f, 0x93, 0x7d, 0xd7, 0x33, 0x1e,
|
||||
0xeb, 0x9a, 0x96, 0x50, 0x7e, 0x7f, 0xff, 0x70, 0x30, 0x02, 0x04, 0xb5, 0xf0, 0xc5, 0xf9, 0xe0,
|
||||
0x80, 0x2d, 0x60, 0x15, 0xf9, 0x1c, 0x28, 0xfe, 0xa0, 0x24, 0x9b, 0x49, 0xff, 0xec, 0x64, 0xeb,
|
||||
0x1e, 0x03, 0xe5, 0xf2, 0x2e, 0xab, 0xf9, 0x94, 0xff, 0xf6, 0x32, 0x75, 0x8f, 0x01, 0xf2, 0xf9,
|
||||
0x17, 0x55, 0xfa, 0x3d, 0x5f, 0x6f, 0xff, 0x7a, 0xee, 0x0e, 0xbd, 0xf3, 0xa6, 0xca, 0xe6, 0x65,
|
||||
0xee, 0xd7, 0xff, 0xbd, 0x77, 0x07, 0x5e, 0xf9, 0xd3, 0x65, 0x73, 0x32, 0xe6, 0x3a, 0x79, 0xc9,
|
||||
0xff, 0xec, 0xa5, 0xd2, 0x94, 0x9a, 0xdc, 0x53, 0x86, 0xce, 0x4c, 0x7d, 0x4f, 0xff, 0x63, 0x27,
|
||||
0x58, 0xf0, 0x1f, 0x2f, 0x91, 0x75, 0x5f, 0xa3, 0xdf, 0x42, 0xff, 0xf7, 0xca, 0x69, 0x4a, 0x4e,
|
||||
0x36, 0x07, 0xe4, 0x5f, 0xa6, 0x64, 0xd7, 0xff, 0xbd, 0x77, 0x07, 0x5e, 0xf9, 0xd3, 0x65, 0x73,
|
||||
0x32, 0xe6, 0x31, 0xc9, 0xa5, 0xc6, 0xbf, 0xff, 0xb9, 0x36, 0x97, 0x97, 0x1b, 0x06, 0x0c, 0x18,
|
||||
0x32, 0x9b, 0x36, 0x6c, 0xd9, 0xb3, 0x66, 0x6f, 0x5f, 0xff, 0xa7, 0x4d, 0x7d, 0x81, 0x4a, 0x12,
|
||||
0x07, 0xa6, 0x03, 0x36, 0x99, 0x29, 0xfb, 0x89, 0x00, 0x29, 0x36, 0xb1, 0xcb, 0x53, 0xbb, 0x3f,
|
||||
0x16, 0x2b, 0x94, 0x29, 0x42, 0x94, 0x29, 0x42, 0x94, 0xce, 0x4c, 0xe4, 0xce, 0x4c, 0xe4, 0xce,
|
||||
0x4c, 0xe4, 0xce, 0x43, 0x05, 0x97, 0xff, 0xff, 0x71, 0xfd, 0xb8, 0xb5, 0xb7, 0x91, 0x60, 0x43,
|
||||
0xbc, 0x3b, 0xc3, 0xbc, 0x3b, 0xc3, 0xe0, 0xbe, 0x0b, 0xe0, 0xbe, 0x0b, 0xe0, 0xbe, 0x0b, 0xe0,
|
||||
0xbb, 0xdf, 0x60, 0x17, 0xff, 0xe9, 0xd3, 0x5f, 0x60, 0x52, 0x84, 0x81, 0xe9, 0x80, 0xcd, 0xa6,
|
||||
0x4a, 0xbc, 0xaf, 0xff, 0xd3, 0xa6, 0xbe, 0xc0, 0xa5, 0x09, 0x03, 0xd3, 0x01, 0x9b, 0x4c, 0x95,
|
||||
0x00, 0x59, 0xd7, 0xef, 0xff, 0xdb, 0x33, 0xb2, 0x06, 0x18, 0x54, 0x3c, 0x70, 0xf1, 0xf0, 0x79,
|
||||
0x40, 0x76, 0xf7, 0xff, 0xed, 0x96, 0x31, 0x59, 0x07, 0xe9, 0xf6, 0x47, 0xc7, 0x39, 0xb1, 0xe3,
|
||||
0x67, 0x37, 0x16, 0xff, 0xfd, 0x3a, 0xf8, 0xc4, 0xf3, 0xce, 0x3c, 0xec, 0x73, 0x29, 0xb9, 0x9a,
|
||||
0x4c, 0xa5, 0x7f, 0xfe, 0x9d, 0x35, 0xf6, 0x05, 0x28, 0x48, 0x1e, 0x98, 0x0c, 0xda, 0x64, 0xa8,
|
||||
0x07, 0x39, 0xcf, 0x8d, 0x7f, 0xfe, 0x9d, 0x7c, 0x62, 0x79, 0xe7, 0x9e, 0x7b, 0x07, 0x1c, 0x71,
|
||||
0xc7, 0x1c, 0x5a, 0x57, 0xff, 0xc0, 0xe0, 0x1d, 0x85, 0xc5, 0x93, 0xe7, 0xb5, 0x6b, 0x38, 0x79,
|
||||
0xff, 0xff, 0xf6, 0xf8, 0x6d, 0xad, 0xb2, 0x85, 0x51, 0x0c, 0xb0, 0xcb, 0x0c, 0xb0, 0xcb, 0x0d,
|
||||
0x36, 0xd3, 0x6d, 0x36, 0xd3, 0x6d, 0x36, 0xd3, 0x6d, 0x36, 0xcb, 0x4a, 0x10, 0x05, 0xd0, 0x23,
|
||||
0x08, 0x42, 0x10, 0x85, 0xa7, 0x39, 0xce, 0x73, 0x74, 0xe4, 0x2f, 0x7f, 0xfb, 0x29, 0x99, 0x54,
|
||||
0x5f, 0x24, 0x27, 0x86, 0x0b, 0x58, 0x02, 0xff, 0xf6, 0x53, 0x32, 0xa8, 0xbe, 0x48, 0x4f, 0x0c,
|
||||
0x16, 0xa7, 0x55, 0xbf, 0x56, 0xff, 0xf9, 0x82, 0x5a, 0xa9, 0xbf, 0xfa, 0xf8, 0x8b, 0xe9, 0x45,
|
||||
0x1b, 0xff, 0xe5, 0xca, 0x81, 0xda, 0xd2, 0x08, 0x7d, 0x9a, 0x79, 0xdc, 0xd1, 0x79, 0x7f, 0xfc,
|
||||
0x17, 0x7a, 0x21, 0x94, 0xd2, 0x7c, 0x99, 0xf6, 0xac, 0x65, 0xff, 0xf0, 0x38, 0x07, 0x61, 0x71,
|
||||
0x64, 0xf9, 0xed, 0x5a, 0xce, 0xb5, 0x4a, 0x6d, 0xff, 0xfb, 0x65, 0x8c, 0x56, 0x41, 0xfa, 0x7d,
|
||||
0x91, 0xf1, 0xce, 0x6c, 0x78, 0xe5, 0x7b, 0xff, 0xf6, 0xcb, 0x18, 0xac, 0x83, 0xf4, 0xfb, 0x23,
|
||||
0xe3, 0x9c, 0xd8, 0xf1, 0xb3, 0xa9, 0xf5, 0x7f, 0xfe, 0x9d, 0x35, 0xf6, 0x05, 0x28, 0x48, 0x1e,
|
||||
0x98, 0x0c, 0xda, 0x64, 0xab, 0xca, 0xff, 0xfd, 0x3a, 0x6b, 0xec, 0x0a, 0x50, 0x90, 0x3d, 0x30,
|
||||
0x19, 0xb4, 0xc9, 0x4f, 0xb3, 0x9a, 0xfc, 0x01, 0x07, 0x82, 0xbd, 0x99, 0xa4, 0x39, 0xc6, 0x16,
|
||||
0x38, 0xe1, 0x3e, 0x7f, 0xfe, 0xc3, 0x11, 0x1f, 0x0c, 0x30, 0x50, 0x9a, 0x8a, 0x51, 0x85, 0x2b,
|
||||
0x30, 0xc4, 0xbf, 0xff, 0x56, 0x20, 0x9e, 0xf3, 0xfe, 0xee, 0xf8, 0x08, 0x93, 0x45, 0x49, 0x85,
|
||||
0xce, 0x83, 0xa7, 0xff, 0xe1, 0x8b, 0x31, 0xd8, 0x61, 0x7b, 0x18, 0xf8, 0x2b, 0x60, 0x15, 0xeb,
|
||||
0xd2, 0xd3, 0xff, 0xf0, 0xc2, 0x92, 0x82, 0xe0, 0xf7, 0x90, 0x77, 0x52, 0xa4, 0xdb, 0xac, 0x6b,
|
||||
0xc8, 0x4a, 0xf1, 0xff, 0xea, 0x4e, 0xd2, 0xf7, 0x03, 0xe5, 0x41, 0xd4, 0x47, 0x86, 0x33, 0xff,
|
||||
0xd4, 0x9d, 0xa5, 0xee, 0x07, 0xca, 0x83, 0xa8, 0x8e, 0xe5, 0xbd, 0x2a, 0xff, 0xf8, 0x71, 0xaa,
|
||||
0xe2, 0x37, 0xa6, 0x65, 0x92, 0xa9, 0xb2, 0x35, 0xff, 0xf0, 0xe3, 0x55, 0xc4, 0x6f, 0x4c, 0xcb,
|
||||
0x25, 0x53, 0x56, 0xb2, 0x09, 0xed, 0xff, 0xf1, 0x07, 0x9b, 0xb9, 0x9e, 0x74, 0xf2, 0x13, 0xd8,
|
||||
0xa6, 0xd7, 0xff, 0xc3, 0x8d, 0x57, 0x11, 0xbd, 0x33, 0x2c, 0x95, 0x4d, 0x5a, 0x50, 0x74, 0xff,
|
||||
0xf5, 0x41, 0x72, 0x20, 0x9d, 0xa7, 0x0f, 0xfc, 0x4b, 0xab, 0x33, 0xff, 0xd4, 0x9d, 0xa5, 0xee,
|
||||
0x07, 0xca, 0x83, 0xa8, 0x8e, 0xe6, 0x10, 0x50,
|
||||
]
|
||||
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import CoreVideo
|
||||
import XCTest
|
||||
import simd
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
/// Mirrors pf-client-core's `csc_rows` tests (crates/pf-client-core/src/video.rs) — the Swift port
|
||||
/// must stay in LOCKSTEP with the Rust implementation, so these are the same fixtures with the
|
||||
/// same tolerances. A divergence here means the two sides would render the same stream
|
||||
/// differently.
|
||||
final class CscRowsTests: XCTestCase {
|
||||
private func apply(_ u: CscUniform, _ yuv: SIMD3<Float>) -> SIMD3<Float> {
|
||||
SIMD3(
|
||||
simd_dot(SIMD3(u.r0.x, u.r0.y, u.r0.z), yuv) + u.r0.w,
|
||||
simd_dot(SIMD3(u.r1.x, u.r1.y, u.r1.z), yuv) + u.r1.w,
|
||||
simd_dot(SIMD3(u.r2.x, u.r2.y, u.r2.z), yuv) + u.r2.w)
|
||||
}
|
||||
|
||||
/// 10-bit limited MSB-packed (P010/x444): reference white Y=940, black Y=64, neutral
|
||||
/// chroma 512 — sampled as UNORM16 of `code << 6`.
|
||||
func testBt2020TenBitLimitedWhiteBlack() {
|
||||
let rows = CscRows.rows(.init(matrix: 9, fullRange: false), depth: 10, msbPacked: true)
|
||||
func s(_ code: UInt32) -> Float { Float(code << 6) / 65535.0 }
|
||||
let white = apply(rows, SIMD3(s(940), s(512), s(512)))
|
||||
let black = apply(rows, SIMD3(s(64), s(512), s(512)))
|
||||
for i in 0..<3 {
|
||||
XCTAssertEqual(white[i], 1.0, accuracy: 0.002, "white \(white)")
|
||||
XCTAssertEqual(black[i], 0.0, accuracy: 0.002, "black \(black)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Reference white (Y=235, U=V=128 limited) → RGB 1.0; reference black (Y=16) → 0.0.
|
||||
func testBt709LimitedWhiteBlack() {
|
||||
let rows = CscRows.rows(.init(matrix: 1, fullRange: false), depth: 8, msbPacked: false)
|
||||
let white = apply(rows, SIMD3(235.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0))
|
||||
let black = apply(rows, SIMD3(16.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0))
|
||||
for i in 0..<3 {
|
||||
XCTAssertEqual(white[i], 1.0, accuracy: 0.005, "white \(white)")
|
||||
XCTAssertEqual(black[i], 0.0, accuracy: 0.005, "black \(black)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Full-range identity points + the 601-vs-709 red excursion (guards the matrix-code
|
||||
/// dispatch — the two matrices MUST differ measurably, that difference is the whole bug
|
||||
/// class this port fixes).
|
||||
func testFullRangeAndRedExcursion() {
|
||||
let rows601 = CscRows.rows(.init(matrix: 5, fullRange: true), depth: 8, msbPacked: false)
|
||||
let white = apply(rows601, SIMD3(1.0, 0.5, 0.5))
|
||||
for i in 0..<3 {
|
||||
XCTAssertEqual(white[i], 1.0, accuracy: 1e-5, "\(white)")
|
||||
}
|
||||
let red601 = apply(rows601, SIMD3(0.0, 0.5, 1.0))
|
||||
XCTAssertEqual(red601[0], 2.0 * (1.0 - 0.299) * 0.5, accuracy: 1e-4, "\(red601)")
|
||||
let rows709 = CscRows.rows(.init(matrix: 1, fullRange: true), depth: 8, msbPacked: false)
|
||||
let red709 = apply(rows709, SIMD3(0.0, 0.5, 1.0))
|
||||
XCTAssertEqual(red709[0], 2.0 * (1.0 - 0.2126) * 0.5, accuracy: 1e-4, "\(red709)")
|
||||
XCTAssertGreaterThan(abs(red601[0] - red709[0]), 0.05)
|
||||
}
|
||||
|
||||
/// Unspecified (2) and unknown matrix codes fall back to BT.709 — the same default as the
|
||||
/// Rust side and every punktfunk host's implicit SDR baseline.
|
||||
func testUnspecifiedFallsBackTo709() {
|
||||
let unspec = CscRows.rows(.init(matrix: 2, fullRange: false), depth: 8, msbPacked: false)
|
||||
let bt709 = CscRows.rows(.init(matrix: 1, fullRange: false), depth: 8, msbPacked: false)
|
||||
XCTAssertEqual(unspec, bt709)
|
||||
}
|
||||
|
||||
/// `signal(of:)` reads the matrix off the buffer's attachment (what VideoToolbox propagates
|
||||
/// from the VUI) and the range off the pixel format — a 601-tagged buffer must come back as
|
||||
/// matrix 5, an untagged one as unspecified (2), and a full-range sibling as fullRange.
|
||||
func testSignalReadsAttachmentAndRange() throws {
|
||||
func makeBuffer(_ format: OSType) throws -> CVPixelBuffer {
|
||||
var pb: CVPixelBuffer?
|
||||
let status = CVPixelBufferCreate(kCFAllocatorDefault, 64, 64, format, nil, &pb)
|
||||
guard status == kCVReturnSuccess, let pb else {
|
||||
throw XCTSkip("could not allocate a \(format) pixel buffer")
|
||||
}
|
||||
return pb
|
||||
}
|
||||
|
||||
let tagged = try makeBuffer(kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange)
|
||||
CVBufferSetAttachment(
|
||||
tagged, kCVImageBufferYCbCrMatrixKey, kCVImageBufferYCbCrMatrix_ITU_R_601_4,
|
||||
.shouldPropagate)
|
||||
XCTAssertEqual(CscRows.signal(of: tagged), CscRows.Signal(matrix: 5, fullRange: false))
|
||||
|
||||
let untagged = try makeBuffer(kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange)
|
||||
XCTAssertEqual(CscRows.signal(of: untagged), CscRows.Signal(matrix: 2, fullRange: false))
|
||||
|
||||
let full = try makeBuffer(kCVPixelFormatType_420YpCbCr8BiPlanarFullRange)
|
||||
CVBufferSetAttachment(
|
||||
full, kCVImageBufferYCbCrMatrixKey, kCVImageBufferYCbCrMatrix_ITU_R_2020,
|
||||
.shouldPropagate)
|
||||
XCTAssertEqual(CscRows.signal(of: full), CscRows.Signal(matrix: 9, fullRange: true))
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user