Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bd8c18b4d | |||
| af3a7d8cd5 | |||
| 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 |
@@ -90,7 +90,11 @@ jobs:
|
|||||||
git config --global --add safe.directory "$PWD"
|
git config --global --add safe.directory "$PWD"
|
||||||
# punktfunk-client-session is the Vulkan/Skia streamer the shell execs for a connect —
|
# 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).
|
# 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.
|
||||||
|
cargo build --release --locked --features punktfunk-host/nvenc \
|
||||||
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session
|
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session
|
||||||
|
|
||||||
- name: Build + smoke-boot web console (bun preset)
|
- 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
|
image: fedora:43
|
||||||
options: --privileged
|
options: --privileged
|
||||||
steps:
|
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
|
# 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.
|
# executes via the container shell (no node needed), so install node BEFORE checkout.
|
||||||
- name: node for the JS actions
|
- name: node for the JS actions
|
||||||
@@ -72,11 +89,28 @@ jobs:
|
|||||||
# gnupg2/rsync/openssh-clients: sign the OSTree repo + rsync it to unom-1 (see the deploy step).
|
# 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 \
|
dnf -y install flatpak flatpak-builder git python3 python3-aiohttp python3-tomlkit curl jq \
|
||||||
gnupg2 rsync openssh-clients
|
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.
|
# Flathub provides the GNOME runtime/SDK + the rust-stable + ffmpeg-full extensions.
|
||||||
# retry.sh: the busy runner intermittently drops DNS lookups ("[6] Could not resolve
|
#
|
||||||
# hostname" seconds after dnf pulled 300+ packages fine) — never fail the job on a
|
# ROOT CAUSE (confirmed 2026-07-11 by watching a live run on home-runner-1): this is
|
||||||
# single-shot fetch. Same treatment for every network command below.
|
# NOT a deterministic nsswitch/DNS-config bug. gitea-runner-fleet on home-runner-1 is
|
||||||
bash scripts/ci/retry.sh 5 flatpak remote-add --user --if-not-exists flathub \
|
# 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
|
https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||||
git config --global --add safe.directory "$PWD"
|
git config --global --add safe.directory "$PWD"
|
||||||
|
|
||||||
@@ -171,10 +205,12 @@ jobs:
|
|||||||
# the .flatpak-builder state dir. Both are resumable/idempotent, so re-running
|
# the .flatpak-builder state dir. Both are resumable/idempotent, so re-running
|
||||||
# after a partial failure is safe and cheap.
|
# after a partial failure is safe and cheap.
|
||||||
# --disable-rofiles-fuse is the container-safe path (no FUSE).
|
# --disable-rofiles-fuse is the container-safe path (no FUSE).
|
||||||
bash scripts/ci/retry.sh 5 flatpak-builder --user --force-clean --disable-rofiles-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 \
|
--install-deps-from=flathub --install-deps-only \
|
||||||
"$PWD/build-dir" "$MANIFEST"
|
"$PWD/build-dir" "$MANIFEST"
|
||||||
bash scripts/ci/retry.sh 5 flatpak-builder --user --force-clean --disable-rofiles-fuse \
|
bash scripts/ci/retry.sh 10 flatpak-builder --user --force-clean --disable-rofiles-fuse \
|
||||||
--download-only \
|
--download-only \
|
||||||
"$PWD/build-dir" "$MANIFEST"
|
"$PWD/build-dir" "$MANIFEST"
|
||||||
|
|
||||||
|
|||||||
Generated
+14
-14
@@ -2154,7 +2154,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "latency-probe"
|
name = "latency-probe"
|
||||||
version = "0.9.1"
|
version = "0.9.2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
@@ -2286,7 +2286,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "loss-harness"
|
name = "loss-harness"
|
||||||
version = "0.9.1"
|
version = "0.9.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
]
|
]
|
||||||
@@ -2765,7 +2765,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-client-core"
|
name = "pf-client-core"
|
||||||
version = "0.9.1"
|
version = "0.9.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -2787,7 +2787,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-console-ui"
|
name = "pf-console-ui"
|
||||||
version = "0.9.1"
|
version = "0.9.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2808,7 +2808,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-ffvk"
|
name = "pf-ffvk"
|
||||||
version = "0.9.1"
|
version = "0.9.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ash",
|
"ash",
|
||||||
"bindgen",
|
"bindgen",
|
||||||
@@ -2817,7 +2817,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-presenter"
|
name = "pf-presenter"
|
||||||
version = "0.9.1"
|
version = "0.9.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -3001,7 +3001,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-android"
|
name = "punktfunk-client-android"
|
||||||
version = "0.9.1"
|
version = "0.9.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"jni",
|
"jni",
|
||||||
@@ -3017,7 +3017,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-linux"
|
name = "punktfunk-client-linux"
|
||||||
version = "0.9.1"
|
version = "0.9.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -3033,7 +3033,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-session"
|
name = "punktfunk-client-session"
|
||||||
version = "0.9.1"
|
version = "0.9.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pf-client-core",
|
"pf-client-core",
|
||||||
@@ -3048,7 +3048,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-windows"
|
name = "punktfunk-client-windows"
|
||||||
version = "0.9.1"
|
version = "0.9.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -3072,7 +3072,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-core"
|
name = "punktfunk-core"
|
||||||
version = "0.9.1"
|
version = "0.9.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -3103,7 +3103,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-host"
|
name = "punktfunk-host"
|
||||||
version = "0.9.1"
|
version = "0.9.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
@@ -3175,7 +3175,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-probe"
|
name = "punktfunk-probe"
|
||||||
version = "0.9.1"
|
version = "0.9.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
@@ -3189,7 +3189,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-tray"
|
name = "punktfunk-tray"
|
||||||
version = "0.9.1"
|
version = "0.9.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ksni",
|
"ksni",
|
||||||
|
|||||||
+1
-1
@@ -35,7 +35,7 @@ exclude = [
|
|||||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.9.1"
|
version = "0.9.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.82"
|
rust-version = "1.82"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
@@ -2517,6 +2517,10 @@
|
|||||||
"type": "object",
|
"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`].",
|
"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": {
|
"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": {
|
"game_session": {
|
||||||
"$ref": "#/components/schemas/GameSession",
|
"$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."
|
"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": {
|
"mode_conflict": {
|
||||||
"$ref": "#/components/schemas/ModeConflict"
|
"$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": {
|
"preset": {
|
||||||
"$ref": "#/components/schemas/Preset"
|
"$ref": "#/components/schemas/Preset"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)
|
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
|
@Composable
|
||||||
fun ConnectScreen(
|
fun ConnectScreen(
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
@@ -107,6 +117,9 @@ fun ConnectScreen(
|
|||||||
var port by remember { mutableStateOf("9777") }
|
var port by remember { mutableStateOf("9777") }
|
||||||
var connecting by remember { mutableStateOf(false) }
|
var connecting by remember { mutableStateOf(false) }
|
||||||
var status by remember { mutableStateOf<String?>(null) }
|
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.
|
// The host streams at exactly this mode; "Native" settings resolve from the device display.
|
||||||
val (w, h, hz) = settings.effectiveMode(context)
|
val (w, h, hz) = settings.effectiveMode(context)
|
||||||
|
|
||||||
@@ -267,11 +280,20 @@ fun ConnectScreen(
|
|||||||
status = "Identity not ready yet — try again in a moment"
|
status = "Identity not ready yet — try again in a moment"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
val thisAttempt = ConnectAttempt(name)
|
||||||
|
attempt = thisAttempt // shows the ConnectOverlay's "Connecting…" phase immediately
|
||||||
connecting = true
|
connecting = true
|
||||||
status = "Connecting to $targetHost:$targetPort…"
|
status = null
|
||||||
discovery.stop() // free the Wi-Fi radio before the stream session
|
discovery.stop() // free the Wi-Fi radio before the stream session
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS)
|
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
|
connecting = false
|
||||||
if (handle != 0L) {
|
if (handle != 0L) {
|
||||||
if (pinHex == null) { // TOFU: pin what we observed (unpaired)
|
if (pinHex == null) { // TOFU: pin what we observed (unpaired)
|
||||||
@@ -284,7 +306,9 @@ fun ConnectScreen(
|
|||||||
} else {
|
} else {
|
||||||
discovery.start()
|
discovery.start()
|
||||||
if (onFailure != null) {
|
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()
|
onFailure()
|
||||||
} else {
|
} else {
|
||||||
status = "Connection failed — check host/port, PIN, and logcat"
|
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
|
// 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
|
// 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 —
|
// 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))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
status?.let {
|
status?.let {
|
||||||
// While connecting it's progress (spinner, neutral); otherwise it's a
|
// In-flight progress (connecting / waking) is the full-screen ConnectOverlay's
|
||||||
// result/error (red). Previously every status showed in error-red, so a
|
// job now, so `status` only ever carries a result/error here — a filled error
|
||||||
// normal "Connecting…" looked like a failure.
|
// container reads as a real failure banner, not just red text lost in the layout.
|
||||||
if (connecting) {
|
Surface(
|
||||||
Row(
|
color = MaterialTheme.colorScheme.errorContainer,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
shape = MaterialTheme.shapes.medium,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
CircularProgressIndicator(
|
Text(
|
||||||
modifier = Modifier.size(16.dp),
|
it,
|
||||||
strokeWidth = 2.dp,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
Text(
|
textAlign = TextAlign.Center,
|
||||||
it,
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(16.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.
|
// Topmost: the full-screen connect takeover — instant "Connecting…" feedback on any dial, flowing
|
||||||
WakeOverlay(waker, gamepadUi)
|
// 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() },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import kotlinx.coroutines.launch
|
|||||||
* [isOnline]/[onOnline] callbacks all run on the main thread; only the blocking send is off-loaded.
|
* [isOnline]/[onOnline] callbacks all run on the main thread; only the blocking send is off-loaded.
|
||||||
*/
|
*/
|
||||||
class WakeController(private val scope: CoroutineScope) {
|
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(
|
data class Waking(
|
||||||
val hostName: String,
|
val hostName: String,
|
||||||
/** Whether coming online chains into a connect (Wake & Connect) vs. just stopping. */
|
/** 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")
|
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||||
fun streamNormal() = shootRoot("stream-normal") { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) }
|
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
|
@Test
|
||||||
fun trust() = shootScreen("trust") {
|
fun trust() = shootScreen("trust") {
|
||||||
HostsScene()
|
HostsScene()
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.unom.punktfunk.BrandDark
|
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.Settings
|
||||||
import io.unom.punktfunk.TouchMode
|
import io.unom.punktfunk.TouchMode
|
||||||
import io.unom.punktfunk.SettingsScreen
|
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 = {})
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ class GamepadFeedback(private val handle: Long) {
|
|||||||
const val TAG_LED: Byte = 0x01
|
const val TAG_LED: Byte = 0x01
|
||||||
const val TAG_PLAYER_LEDS: Byte = 0x02
|
const val TAG_PLAYER_LEDS: Byte = 0x02
|
||||||
const val TAG_TRIGGER: Byte = 0x03
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@Volatile private var running = false
|
@Volatile private var running = false
|
||||||
@@ -66,7 +69,17 @@ class GamepadFeedback(private val handle: Long) {
|
|||||||
while (running) {
|
while (running) {
|
||||||
val ev = NativeBridge.nativeNextRumble(handle)
|
val ev = NativeBridge.nativeNextRumble(handle)
|
||||||
if (ev < 0L) continue // timeout / closed
|
if (ev < 0L) continue // timeout / closed
|
||||||
renderRumble(((ev ushr 16) and 0xFFFF).toInt(), (ev and 0xFFFF).toInt())
|
// ev 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 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(
|
||||||
|
((ev ushr 16) and 0xFFFF).toInt(),
|
||||||
|
(ev and 0xFFFF).toInt(),
|
||||||
|
durationMs,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}, "pf-rumble").apply { isDaemon = true; start() }
|
}, "pf-rumble").apply { isDaemon = true; start() }
|
||||||
|
|
||||||
@@ -143,9 +156,14 @@ class GamepadFeedback(private val handle: Long) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes). */
|
/**
|
||||||
private fun renderRumble(low: Int, high: Int) {
|
* low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes).
|
||||||
Log.i(TAG, "rumble low=$low high=$high") // verification line — BEFORE any no-op return
|
* `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] (the prior fixed duration).
|
||||||
|
*/
|
||||||
|
private fun renderRumble(low: Int, high: Int, durationMs: Long) {
|
||||||
|
Log.i(TAG, "rumble low=$low high=$high ttlMs=$durationMs") // verification line — BEFORE any no-op return
|
||||||
val lo = toAmplitude(low)
|
val lo = toAmplitude(low)
|
||||||
val hi = toAmplitude(high)
|
val hi = toAmplitude(high)
|
||||||
val m = vm
|
val m = vm
|
||||||
@@ -157,12 +175,12 @@ class GamepadFeedback(private val handle: Long) {
|
|||||||
val combo = CombinedVibration.startParallel()
|
val combo = CombinedVibration.startParallel()
|
||||||
if (amplitudeControlled && vibratorIds.size >= 2) {
|
if (amplitudeControlled && vibratorIds.size >= 2) {
|
||||||
// ids[0] = light/right, ids[1] = heavy/left (XInput/Moonlight convention).
|
// ids[0] = light/right, ids[1] = heavy/left (XInput/Moonlight convention).
|
||||||
if (hi != 0) combo.addVibrator(vibratorIds[0], oneShot(hi))
|
if (hi != 0) combo.addVibrator(vibratorIds[0], oneShot(hi, durationMs))
|
||||||
if (lo != 0) combo.addVibrator(vibratorIds[1], oneShot(lo))
|
if (lo != 0) combo.addVibrator(vibratorIds[1], oneShot(lo, durationMs))
|
||||||
} else {
|
} else {
|
||||||
// Single motor or no amplitude control: blend both into one effect.
|
// Single motor or no amplitude control: blend both into one effect.
|
||||||
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
||||||
for (id in vibratorIds) combo.addVibrator(id, oneShot(a))
|
for (id in vibratorIds) combo.addVibrator(id, oneShot(a, durationMs))
|
||||||
}
|
}
|
||||||
runCatching { m.vibrate(combo.combine()) }
|
runCatching { m.vibrate(combo.combine()) }
|
||||||
return
|
return
|
||||||
@@ -175,7 +193,10 @@ class GamepadFeedback(private val handle: Long) {
|
|||||||
}
|
}
|
||||||
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
||||||
runCatching {
|
runCatching {
|
||||||
lv.vibrate(if (amplitudeControlled) oneShot(a) else oneShot(VibrationEffect.DEFAULT_AMPLITUDE))
|
lv.vibrate(
|
||||||
|
if (amplitudeControlled) oneShot(a, durationMs)
|
||||||
|
else oneShot(VibrationEffect.DEFAULT_AMPLITUDE, durationMs)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,8 +206,10 @@ class GamepadFeedback(private val handle: Long) {
|
|||||||
return if (v16 != 0 && a == 0) 1 else a
|
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.
|
// One-shot held for `durationMs` — the host's v2 TTL (renewed while the level holds), so it
|
||||||
private fun oneShot(amp: Int): VibrationEffect = VibrationEffect.createOneShot(60_000L, amp)
|
// self-terminates on a lost stop; cancel on zero.
|
||||||
|
private fun oneShot(amp: Int, durationMs: Long): VibrationEffect =
|
||||||
|
VibrationEffect.createOneShot(durationMs, amp)
|
||||||
|
|
||||||
// ---- HID output ----
|
// ---- HID output ----
|
||||||
|
|
||||||
|
|||||||
@@ -355,45 +355,70 @@ fn decode_loop(
|
|||||||
};
|
};
|
||||||
let mut pcm = vec![0f32; pcm_scratch];
|
let mut pcm = vec![0f32; pcm_scratch];
|
||||||
let mut window_peak = 0f32; // loudest |sample| since the last log — tells a tone from silence
|
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)) {
|
match client.next_audio(Duration::from_millis(5)) {
|
||||||
Ok(pkt) => match dec.decode_float(&pkt.data, &mut pcm, false) {
|
Ok(pkt) => {
|
||||||
Ok(samples) => {
|
// Conceal lost packets (a seq gap) with libopus PLC before decoding the one that
|
||||||
let n = samples * channels;
|
// arrived: empty input synthesizes `frame_samples` of interpolation per missing
|
||||||
for &s in &pcm[..n] {
|
// packet — an inaudible fade instead of the click a hard gap makes in the ring.
|
||||||
window_peak = window_peak.max(s.abs());
|
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)
|
if let Ok(samples) = dec.decode_float(&[], &mut pcm[..plc], false) {
|
||||||
// frames; a larger frame would force a one-time realloc on the RT thread. Catch a
|
let mut buf = free_rx
|
||||||
// future host frame-size change here in debug, not as a silent audio glitch.
|
.try_recv()
|
||||||
debug_assert!(
|
.unwrap_or_else(|_| Vec::with_capacity(pcm_scratch));
|
||||||
n <= 5 * ms,
|
buf.clear();
|
||||||
"audio frame {n} f32 exceeds the 5 ms ring reserve"
|
buf.extend_from_slice(&pcm[..samples * channels]);
|
||||||
);
|
match tx.try_send(buf) {
|
||||||
let count = counters.opus_decoded.fetch_add(1, Ordering::Relaxed) + 1;
|
Ok(()) | Err(TrySendError::Full(_)) => {}
|
||||||
// Reuse a recycled buffer if the callback handed one back; only allocate when the
|
Err(TrySendError::Disconnected(_)) => break 'pump,
|
||||||
// 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}"),
|
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(PunktfunkError::NoFrame) => {} // timeout
|
||||||
Err(_) => break, // session closed
|
Err(_) => break, // session closed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use punktfunk_core::error::PunktfunkError;
|
|||||||
use punktfunk_core::session::Frame;
|
use punktfunk_core::session::Frame;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::ffi::c_void;
|
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::sync::{mpsc, Arc, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
@@ -202,6 +202,8 @@ fn run_sync(
|
|||||||
let mut fed: u64 = 0;
|
let mut fed: u64 = 0;
|
||||||
let mut rendered: u64 = 0;
|
let mut rendered: u64 = 0;
|
||||||
let mut discarded: 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
|
// 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
|
// 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.
|
// round-trip) and we only pop the next one once it's queued.
|
||||||
@@ -213,12 +215,12 @@ fn run_sync(
|
|||||||
// Skew-corrected latency stats (spec: design/stats-unification.md) use the negotiated
|
// 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
|
// host-minus-client clock offset (0 if the host didn't answer the skew handshake — then the
|
||||||
// HUD flags it "(same-host clock)").
|
// 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
|
// Display stage (spec `display` + the capture→displayed headline): frames released with
|
||||||
// render = true are parked in the tracker; the OnFrameRendered callback pairs them 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,
|
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
|
||||||
// reclaimed after the codec is dropped below.
|
// 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);
|
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
|
// 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
|
// point (output-buffer dequeue — MediaCodec round-trips presentationTimeUs) can be paired back
|
||||||
@@ -241,6 +243,11 @@ fn run_sync(
|
|||||||
if pending.is_none() {
|
if pending.is_none() {
|
||||||
match client.next_frame(Duration::from_millis(5)) {
|
match client.next_frame(Duration::from_millis(5)) {
|
||||||
Ok(frame) => {
|
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 frames_dropped
|
||||||
|
// keyframe path below stays the backstop when the recovery frame itself is lost.
|
||||||
|
let _ = client.note_frame_index(frame.frame_index);
|
||||||
if fed == 0 {
|
if fed == 0 {
|
||||||
let p = &frame.data;
|
let p = &frame.data;
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -256,6 +263,7 @@ fn run_sync(
|
|||||||
// the output buffer) for the decoded-point pairing in `drain`.
|
// the output buffer) for the decoded-point pairing in `drain`.
|
||||||
if stats.enabled() {
|
if stats.enabled() {
|
||||||
let received_ns = now_realtime_ns();
|
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_ns = received_ns + clock_offset as i128 - frame.pts_ns as i128;
|
||||||
let lat_us = (lat_ns > 0 && lat_ns < 10_000_000_000)
|
let lat_us = (lat_ns > 0 && lat_ns < 10_000_000_000)
|
||||||
.then_some((lat_ns / 1000) as u64);
|
.then_some((lat_ns / 1000) as u64);
|
||||||
@@ -295,7 +303,13 @@ fn run_sync(
|
|||||||
// and excluded, so ADPF sees this thread's real per-frame CPU cost, not the poll timeout.
|
// and excluded, so ADPF sees this thread's real per-frame CPU cost, not the poll timeout.
|
||||||
let work_t0 = Instant::now();
|
let work_t0 = Instant::now();
|
||||||
if let Some(frame) = pending.take() {
|
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;
|
fed += 1;
|
||||||
if fed % 300 == 0 {
|
if fed % 300 == 0 {
|
||||||
log::info!("decode: fed={fed} rendered={rendered} discarded={discarded}");
|
log::info!("decode: fed={fed} rendered={rendered} discarded={discarded}");
|
||||||
@@ -320,7 +334,7 @@ fn run_sync(
|
|||||||
wait,
|
wait,
|
||||||
&stats,
|
&stats,
|
||||||
&mut in_flight,
|
&mut in_flight,
|
||||||
clock_offset,
|
clock_offset.load(Ordering::Relaxed),
|
||||||
&tracker,
|
&tracker,
|
||||||
);
|
);
|
||||||
rendered += r;
|
rendered += r;
|
||||||
@@ -418,8 +432,10 @@ fn now_monotonic_ns() -> i128 {
|
|||||||
/// endpoint whenever the platform delivers render callbacks).
|
/// endpoint whenever the platform delivers render callbacks).
|
||||||
struct DisplayTracker {
|
struct DisplayTracker {
|
||||||
stats: Arc<crate::stats::VideoStats>,
|
stats: Arc<crate::stats::VideoStats>,
|
||||||
/// Host-minus-client clock offset (ns) for the skew-corrected end-to-end sample.
|
/// Live host-minus-client clock offset (ns) for the skew-corrected end-to-end sample —
|
||||||
clock_offset: i64,
|
/// 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,
|
/// `(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
|
/// awaiting their callback. Pushes are HUD-gated by the caller, so this stays empty (and the
|
||||||
/// callback early-outs) while the overlay is hidden.
|
/// callback early-outs) while the overlay is hidden.
|
||||||
@@ -427,7 +443,10 @@ struct DisplayTracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
Arc::new(DisplayTracker {
|
||||||
stats,
|
stats,
|
||||||
clock_offset,
|
clock_offset,
|
||||||
@@ -554,7 +573,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 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);
|
let display_us = decoded_ns.map(|d| ((displayed_ns - d).max(0) / 1000) as u64);
|
||||||
t.stats.note_displayed(e2e_us, display_us);
|
t.stats.note_displayed(e2e_us, display_us);
|
||||||
@@ -827,13 +847,13 @@ fn run_async(
|
|||||||
// pts we queue) live in a shared map: the feeder writes them at receipt, this loop pairs decoded
|
// 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
|
// output back to them. Behind a `Mutex` since two threads touch it — only ever locked while the
|
||||||
// HUD is visible.
|
// 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()));
|
let in_flight = Arc::new(Mutex::new(VecDeque::<(u64, i128)>::new()));
|
||||||
// Display stage (spec `display` + the capture→displayed headline): the rendered frame is
|
// Display stage (spec `display` + the capture→displayed headline): the rendered frame is
|
||||||
// parked in the tracker at release; the OnFrameRendered callback pairs it with
|
// 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,
|
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
|
||||||
// reclaimed after the codec is dropped below.
|
// 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);
|
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
|
// Feeder thread: block on the network so this loop doesn't (an AU's arrival becomes an event that
|
||||||
@@ -842,19 +862,13 @@ fn run_async(
|
|||||||
let client = client.clone();
|
let client = client.clone();
|
||||||
let stats = stats.clone();
|
let stats = stats.clone();
|
||||||
let in_flight = in_flight.clone();
|
let in_flight = in_flight.clone();
|
||||||
|
let clock_offset = clock_offset.clone();
|
||||||
let shutdown = shutdown.clone();
|
let shutdown = shutdown.clone();
|
||||||
let ev_tx = ev_tx.clone();
|
let ev_tx = ev_tx.clone();
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("pf-decode-feed".into())
|
.name("pf-decode-feed".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
feeder_loop(
|
feeder_loop(client, stats, in_flight, clock_offset, shutdown, ev_tx);
|
||||||
client,
|
|
||||||
stats,
|
|
||||||
in_flight,
|
|
||||||
clock_offset as i128,
|
|
||||||
shutdown,
|
|
||||||
ev_tx,
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.ok()
|
.ok()
|
||||||
};
|
};
|
||||||
@@ -878,6 +892,8 @@ fn run_async(
|
|||||||
let mut fed: u64 = 0;
|
let mut fed: u64 = 0;
|
||||||
let mut rendered: u64 = 0;
|
let mut rendered: u64 = 0;
|
||||||
let mut discarded: 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;
|
||||||
let mut last_dropped = client.frames_dropped();
|
let mut last_dropped = client.frames_dropped();
|
||||||
let mut last_kf_req: Option<Instant> = None;
|
let mut last_kf_req: Option<Instant> = None;
|
||||||
// Productive (dispatch+feed+present) time between displayed frames; reported to ADPF once one is
|
// Productive (dispatch+feed+present) time between displayed frames; reported to ADPF once one is
|
||||||
@@ -922,14 +938,21 @@ fn run_async(
|
|||||||
if fmt_dirty {
|
if fmt_dirty {
|
||||||
apply_hdr_dataspace(&codec, &window, &mut applied_ds);
|
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();
|
let had_output = !ready.is_empty();
|
||||||
present_ready(
|
present_ready(
|
||||||
&codec,
|
&codec,
|
||||||
&mut ready,
|
&mut ready,
|
||||||
&stats,
|
&stats,
|
||||||
&in_flight,
|
&in_flight,
|
||||||
clock_offset,
|
clock_offset.load(Ordering::Relaxed),
|
||||||
&tracker,
|
&tracker,
|
||||||
&mut rendered,
|
&mut rendered,
|
||||||
&mut discarded,
|
&mut discarded,
|
||||||
@@ -999,7 +1022,7 @@ fn feeder_loop(
|
|||||||
client: Arc<NativeClient>,
|
client: Arc<NativeClient>,
|
||||||
stats: Arc<crate::stats::VideoStats>,
|
stats: Arc<crate::stats::VideoStats>,
|
||||||
in_flight: Arc<Mutex<VecDeque<(u64, i128)>>>,
|
in_flight: Arc<Mutex<VecDeque<(u64, i128)>>>,
|
||||||
clock_offset: i128,
|
clock_offset: Arc<AtomicI64>,
|
||||||
shutdown: Arc<AtomicBool>,
|
shutdown: Arc<AtomicBool>,
|
||||||
ev_tx: mpsc::Sender<DecodeEvent>,
|
ev_tx: mpsc::Sender<DecodeEvent>,
|
||||||
) {
|
) {
|
||||||
@@ -1008,8 +1031,13 @@ fn feeder_loop(
|
|||||||
while !shutdown.load(Ordering::Relaxed) {
|
while !shutdown.load(Ordering::Relaxed) {
|
||||||
match client.next_frame(Duration::from_millis(5)) {
|
match client.next_frame(Duration::from_millis(5)) {
|
||||||
Ok(frame) => {
|
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).
|
||||||
|
let _ = client.note_frame_index(frame.frame_index);
|
||||||
if stats.enabled() {
|
if stats.enabled() {
|
||||||
let received_ns = now_realtime_ns();
|
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_ns = received_ns + clock_offset - frame.pts_ns as i128;
|
||||||
let lat_us =
|
let lat_us =
|
||||||
(lat_ns > 0 && lat_ns < 10_000_000_000).then_some((lat_ns / 1000) as u64);
|
(lat_ns > 0 && lat_ns < 10_000_000_000).then_some((lat_ns / 1000) as u64);
|
||||||
@@ -1089,12 +1117,15 @@ fn dispatch_event(
|
|||||||
|
|
||||||
/// Queue as many parked AUs as there are free input buffer slots (async mode: the indices come from
|
/// 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
|
/// `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(
|
fn feed_ready(
|
||||||
codec: &MediaCodec,
|
codec: &MediaCodec,
|
||||||
|
client: &NativeClient,
|
||||||
pending_aus: &mut VecDeque<Frame>,
|
pending_aus: &mut VecDeque<Frame>,
|
||||||
free_inputs: &mut VecDeque<usize>,
|
free_inputs: &mut VecDeque<usize>,
|
||||||
fed: &mut u64,
|
fed: &mut u64,
|
||||||
|
oversized_dropped: &mut u64,
|
||||||
) {
|
) {
|
||||||
while !pending_aus.is_empty() && !free_inputs.is_empty() {
|
while !pending_aus.is_empty() && !free_inputs.is_empty() {
|
||||||
let idx = free_inputs.pop_front().unwrap();
|
let idx = free_inputs.pop_front().unwrap();
|
||||||
@@ -1105,14 +1136,20 @@ fn feed_ready(
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let au = &frame.data;
|
let au = &frame.data;
|
||||||
let n = au.len().min(dst.len());
|
if au.len() > dst.len() {
|
||||||
if n < au.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!(
|
log::warn!(
|
||||||
"decode: AU {} > input buffer {}, truncated",
|
"decode: AU {} > input buffer {} — dropped ({} so far), requesting keyframe",
|
||||||
au.len(),
|
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
|
// 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].
|
// for `n` bytes; `MaybeUninit<u8>` is layout-identical to `u8`, so this initializes dst[..n].
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -1298,27 +1335,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
|
/// 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
|
/// `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
|
/// 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).
|
/// parking it forever would wedge the loop on a broken codec). An AU larger than the input
|
||||||
fn feed(codec: &MediaCodec, au: &[u8], pts_us: u64) -> bool {
|
/// 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) {
|
match codec.dequeue_input_buffer(Duration::ZERO) {
|
||||||
Ok(DequeuedInputBufferResult::Buffer(mut buf)) => {
|
Ok(DequeuedInputBufferResult::Buffer(mut buf)) => {
|
||||||
let n = {
|
let n = {
|
||||||
let dst = buf.buffer_mut();
|
let dst = buf.buffer_mut();
|
||||||
let n = au.len().min(dst.len());
|
if au.len() > dst.len() {
|
||||||
if n < au.len() {
|
*oversized_dropped += 1;
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"decode: AU {} > input buffer {}, truncated",
|
"decode: AU {} > input buffer {} — dropped ({} so far), requesting keyframe",
|
||||||
au.len(),
|
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) {
|
if let Err(e) = codec.queue_input_buffer(buf, 0, n, pts_us, 0) {
|
||||||
log::warn!("decode: queue_input_buffer: {e}");
|
log::warn!("decode: queue_input_buffer: {e}");
|
||||||
|
|||||||
@@ -24,8 +24,12 @@ const TAG_PLAYER_LEDS: u8 = 0x02;
|
|||||||
const TAG_TRIGGER: u8 = 0x03;
|
const TAG_TRIGGER: u8 = 0x03;
|
||||||
|
|
||||||
/// `NativeBridge.nativeNextRumble(handle): Long` — block up to ~100 ms for the next rumble update.
|
/// `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.
|
/// Returns a packed positive long: bit 48 = "has a v2 lease", bits 32..47 = `ttl_ms`, bits 16..31 =
|
||||||
/// Pad index is dropped (single-pad model). Run from a dedicated Kotlin poll thread.
|
/// `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). Pad index is dropped (single-pad model). Run from a Kotlin poll thread.
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
||||||
_env: JNIEnv,
|
_env: JNIEnv,
|
||||||
@@ -37,12 +41,20 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
|||||||
if handle == 0 {
|
if handle == 0 {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble is &self on the
|
// SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble_ttl is &self on
|
||||||
// Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll
|
// the Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll
|
||||||
// threads (and joins them — unbounded) before nativeClose frees the handle.
|
// threads (and joins them — unbounded) before nativeClose frees the handle.
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||||
match h.client.next_rumble(PULL_TIMEOUT) {
|
match h.client.next_rumble_ttl(PULL_TIMEOUT) {
|
||||||
Ok((_pad, low, high)) => (jlong::from(low) << 16) | jlong::from(high),
|
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.
|
||||||
|
let (lease_flag, ttl_bits) = match ttl {
|
||||||
|
Some(ms) => (1i64 << 48, jlong::from(ms) << 32),
|
||||||
|
None => (0, 0),
|
||||||
|
};
|
||||||
|
lease_flag | ttl_bits | (jlong::from(low) << 16) | jlong::from(high)
|
||||||
|
}
|
||||||
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
|
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
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
|
launch, // a store-qualified library id to boot into a game, or None for the desktop
|
||||||
pin, // Some → Crypto on host-fp mismatch
|
pin, // Some → Crypto on host-fp mismatch
|
||||||
identity, // owned (cert, key) PEM, or None (anonymous)
|
identity, // owned (cert, key) PEM, or None (anonymous)
|
||||||
|
|||||||
@@ -14,19 +14,11 @@
|
|||||||
<!-- Wake-on-LAN needs to send a UDP broadcast magic packet (a sleeping host has no ARP
|
<!-- 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
|
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
|
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
|
be approved by Apple for the App ID and enabled in the provisioning profile. macOS is not
|
||||||
profile. macOS is not gated by this (its App Sandbox network.client/server cover it).
|
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
|
||||||
GATED pending Apple's approval of the request (form filed) — an unauthorized managed
|
is true on iOS/tvOS too. -->
|
||||||
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). -->
|
|
||||||
<!--
|
|
||||||
<key>com.apple.developer.networking.multicast</key>
|
<key>com.apple.developer.networking.multicast</key>
|
||||||
<true/>
|
<true/>
|
||||||
-->
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -376,7 +376,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.1;
|
MARKETING_VERSION = 0.9.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SUPPORTED_PLATFORMS = macosx;
|
SUPPORTED_PLATFORMS = macosx;
|
||||||
@@ -412,7 +412,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.1;
|
MARKETING_VERSION = 0.9.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SUPPORTED_PLATFORMS = macosx;
|
SUPPORTED_PLATFORMS = macosx;
|
||||||
@@ -449,7 +449,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.1;
|
MARKETING_VERSION = 0.9.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -490,7 +490,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.1;
|
MARKETING_VERSION = 0.9.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -522,7 +522,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.1;
|
MARKETING_VERSION = 0.9.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = appletvos;
|
SDKROOT = appletvos;
|
||||||
@@ -552,7 +552,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.1;
|
MARKETING_VERSION = 0.9.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = appletvos;
|
SDKROOT = appletvos;
|
||||||
|
|||||||
@@ -60,7 +60,8 @@ struct ContentView: View {
|
|||||||
@State private var speedTestTarget: StoredHost?
|
@State private var speedTestTarget: StoredHost?
|
||||||
@State private var libraryTarget: StoredHost?
|
@State private var libraryTarget: StoredHost?
|
||||||
/// Wakes a sleeping host and waits for it to come back online before connecting (drives the
|
/// 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()
|
@StateObject private var waker = HostWaker()
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
/// Whether the hosting window is native-fullscreen right now (reported by
|
/// Whether the hosting window is native-fullscreen right now (reported by
|
||||||
@@ -68,25 +69,32 @@ struct ContentView: View {
|
|||||||
/// edge-to-edge (behind the notch); windowed respects the top inset so the title bar
|
/// edge-to-edge (behind the notch); windowed respects the top inset so the title bar
|
||||||
/// never covers the video.
|
/// never covers the video.
|
||||||
@State private var isFullscreen = false
|
@State private var isFullscreen = false
|
||||||
|
#endif
|
||||||
|
#if os(macOS) || os(tvOS)
|
||||||
/// Shows the start-of-stream shortcut banner (the Windows client's discoverability
|
/// 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
|
/// 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
|
/// 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
|
@State private var showShortcutHint = false
|
||||||
#endif
|
#endif
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
@State private var showSettings = false
|
@State private var showSettings = false
|
||||||
#endif
|
#endif
|
||||||
#if os(iOS) || os(macOS)
|
|
||||||
// A connected controller (+ the Settings toggle) swaps the whole home screen for
|
// A connected controller (+ the Settings toggle) swaps the whole home screen for
|
||||||
// GamepadHomeView instead of retrofitting HomeView's touch/desktop UI — see `home` below.
|
// 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
|
@ObservedObject private var gamepadManager = GamepadManager.shared
|
||||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
@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 {
|
private var gamepadUIActive: Bool {
|
||||||
GamepadUIEnvironment.isActive(
|
GamepadUIEnvironment.isActive(
|
||||||
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
|
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
@@ -108,7 +116,7 @@ struct ContentView: View {
|
|||||||
.onChange(of: model.phase) { _, phase in
|
.onChange(of: model.phase) { _, phase in
|
||||||
switch phase {
|
switch phase {
|
||||||
case .streaming:
|
case .streaming:
|
||||||
#if os(macOS)
|
#if os(macOS) || os(tvOS)
|
||||||
showShortcutHint = true // the 6 s shortcut banner, per session start
|
showShortcutHint = true // the 6 s shortcut banner, per session start
|
||||||
#endif
|
#endif
|
||||||
// A session actually started — remember it on the card ("Connected … ago"
|
// A session actually started — remember it on the card ("Connected … ago"
|
||||||
@@ -256,9 +264,26 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var home: some View {
|
private var home: some View {
|
||||||
// The "Waking…" overlay rides over BOTH home UIs (and the pre-connect window is still
|
// The full-screen connect takeover rides over BOTH home UIs (and the pre-connect window is
|
||||||
// `home`, so it covers the whole wake→online→connect sequence).
|
// still `home`, so it covers the whole dial → wake → online → connect sequence): instant
|
||||||
homeBase.overlay { WakeOverlay(waker: waker) }
|
// "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 {
|
@ViewBuilder private var homeBase: some View {
|
||||||
@@ -278,13 +303,30 @@ struct ContentView: View {
|
|||||||
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
|
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#elseif os(iOS)
|
#else
|
||||||
Group {
|
Group {
|
||||||
if gamepadUIActive {
|
if gamepadUIActive {
|
||||||
GamepadHomeView(
|
GamepadHomeView(
|
||||||
store: store, model: model, discovery: discovery,
|
store: store, model: model, discovery: discovery,
|
||||||
libraryTarget: $libraryTarget, waker: waker,
|
libraryTarget: $libraryTarget, waker: waker,
|
||||||
connect: { connect($0) }, connectDiscovered: connectDiscovered)
|
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 {
|
} else {
|
||||||
HomeView(
|
HomeView(
|
||||||
store: store, model: model, discovery: discovery,
|
store: store, model: model, discovery: discovery,
|
||||||
@@ -295,14 +337,6 @@ struct ContentView: View {
|
|||||||
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
|
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
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,12 +349,25 @@ struct ContentView: View {
|
|||||||
}()
|
}()
|
||||||
return ZStack {
|
return ZStack {
|
||||||
stream(captureEnabled: pendingFingerprint == nil)
|
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 {
|
.overlay {
|
||||||
if pendingFingerprint != nil {
|
if pendingFingerprint != nil {
|
||||||
Color.black.opacity(0.45)
|
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 {
|
if let fp = pendingFingerprint {
|
||||||
TrustCardView(
|
TrustCardView(
|
||||||
fingerprint: fp,
|
fingerprint: fp,
|
||||||
@@ -362,11 +409,14 @@ struct ContentView: View {
|
|||||||
#else
|
#else
|
||||||
.background(Color.black)
|
.background(Color.black)
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
// Siri Remote MENU = disconnect (the idiomatic tvOS "back"). With no focusable
|
// SWALLOW Menu/B during a session — a game controller's B button ALSO surfaces as this
|
||||||
// disconnect control during play, the controller's buttons flow to the host instead of
|
// UIKit menu press, so the old instant-disconnect here ended the session on every B
|
||||||
// driving the focus engine. NOTE: a game controller's Menu is also forwarded to the
|
// press in gameplay. The button still reaches the host via GamepadCapture; the
|
||||||
// host as Start — the Siri Remote is the intended disconnect path.
|
// DELIBERATE exits are holding the remote's Back ≥ 1 s (SiriRemotePointer) and holding
|
||||||
.onExitCommand { model.disconnect() }
|
// 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
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,6 +445,16 @@ struct ContentView: View {
|
|||||||
onSessionEnd: { [weak model] in
|
onSessionEnd: { [weak model] in
|
||||||
Task { @MainActor in model?.sessionEnded() }
|
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,
|
endToEndMeter: model.endToEnd,
|
||||||
decodeMeter: model.decodeStage,
|
decodeMeter: model.decodeStage,
|
||||||
displayMeter: model.displayStage
|
displayMeter: model.displayStage
|
||||||
@@ -418,17 +478,18 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.animation(.smooth(duration: 0.28), value: statsVerbosity)
|
.animation(.smooth(duration: 0.28), value: statsVerbosity)
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
#if os(macOS) || os(tvOS)
|
||||||
// The start-of-stream shortcut banner (Windows-client parity): the full
|
// The start-of-stream shortcut banner (Windows-client parity): the platform's
|
||||||
// reserved key set on a glass pill, bottom-centre, for the first 6 seconds of
|
// 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
|
// 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
|
// 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) {
|
.overlay(alignment: .bottom) {
|
||||||
if captureEnabled && showShortcutHint {
|
if captureEnabled && showShortcutHint {
|
||||||
Text("Click the stream to capture · ⌃⌥⇧Q releases the mouse · "
|
Text(Self.shortcutHintText)
|
||||||
+ "⌃⌥⇧D disconnects · ⌃⌥⇧S stats")
|
.font(.geist(Self.shortcutHintFont, relativeTo: .caption))
|
||||||
.font(.geist(12, relativeTo: .caption))
|
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
@@ -472,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
|
// MARK: - Connect
|
||||||
|
|
||||||
private func connect(_ host: StoredHost, launchID: String? = nil, allowTofu: Bool? = nil) {
|
private func connect(_ host: StoredHost, launchID: String? = nil, allowTofu: Bool? = nil) {
|
||||||
@@ -517,7 +589,8 @@ struct ContentView: View {
|
|||||||
// packet up front, so a genuinely-asleep host is waking while the connect times out; only
|
// 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
|
// 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.
|
// 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
|
discovery.start() // so the wake-wait can observe it reappear
|
||||||
startSessionDirect(
|
startSessionDirect(
|
||||||
host, launchID: launchID, allowTofu: allowTofu,
|
host, launchID: launchID, allowTofu: allowTofu,
|
||||||
@@ -574,7 +647,9 @@ struct ContentView: View {
|
|||||||
private func prepareWake(for host: StoredHost) {
|
private func prepareWake(for host: StoredHost) {
|
||||||
if let live = discovery.hosts.first(where: { host.matches($0) }) {
|
if let live = discovery.hosts.first(where: { host.matches($0) }) {
|
||||||
store.updateMacs(host.id, macs: live.macAddresses) // learn — on every platform
|
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 macs = host.wakeMacs
|
||||||
let ip = host.address
|
let ip = host.address
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
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 /
|
// 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;
|
// 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
|
// 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
|
// 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.
|
// 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 PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
#if os(iOS) || os(macOS)
|
#if os(iOS) || os(macOS) || os(tvOS)
|
||||||
|
|
||||||
struct GamepadAddHostView: View {
|
struct GamepadAddHostView: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@@ -37,22 +39,22 @@ struct GamepadAddHostView: View {
|
|||||||
isActive: editing == nil
|
isActive: editing == nil
|
||||||
) { row, focused in
|
) { row, focused in
|
||||||
rowView(row, focused: focused)
|
rowView(row, focused: focused)
|
||||||
.frame(maxWidth: 620)
|
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.safeAreaInset(edge: .top, spacing: 0) {
|
.safeAreaInset(edge: .top, spacing: 0) {
|
||||||
VStack(spacing: 4) {
|
VStack(spacing: 4) {
|
||||||
Text("Add Host")
|
Text("Add Host")
|
||||||
.font(.geist(compact ? 20 : 30, .bold, relativeTo: .title))
|
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
if !compact {
|
if !compact {
|
||||||
Text("Hosts on this network appear automatically — add one by address "
|
Text("Hosts on this network appear automatically — add one by address "
|
||||||
+ "for everything else.")
|
+ "for everything else.")
|
||||||
.font(.geist(13, relativeTo: .caption))
|
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
|
||||||
.foregroundStyle(.white.opacity(0.55))
|
.foregroundStyle(.white.opacity(0.55))
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.frame(maxWidth: 440)
|
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||||
@@ -75,10 +77,38 @@ struct GamepadAddHostView: View {
|
|||||||
.onChange(of: port) { _, value in
|
.onChange(of: port) { _, value in
|
||||||
if value.count > 5 { port = String(value.prefix(5)) }
|
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 {
|
@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 {
|
if let editing {
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
GamepadKeyboard(
|
GamepadKeyboard(
|
||||||
@@ -104,6 +134,7 @@ struct GamepadAddHostView: View {
|
|||||||
])
|
])
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Touch/click fallback for closing — the controller path is B, a hardware keyboard's Esc
|
/// 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 {
|
private var closeButton: some View {
|
||||||
Button { dismiss() } label: {
|
Button { dismiss() } label: {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
.font(.system(size: 14, weight: .semibold))
|
.font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.frame(width: 34, height: 34)
|
.frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide)
|
||||||
.glassBackground(Circle(), interactive: true)
|
.glassBackground(Circle(), interactive: true)
|
||||||
.contentShape(Circle())
|
.contentShape(Circle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.keyboardShortcut(.cancelAction)
|
#if !os(tvOS)
|
||||||
|
.keyboardShortcut(.cancelAction) // unavailable on tvOS (Menu is the cancel there)
|
||||||
|
#endif
|
||||||
.accessibilityLabel("Cancel")
|
.accessibilityLabel("Cancel")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,19 +175,20 @@ struct GamepadAddHostView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func rowView(_ row: Row, focused: Bool) -> some View {
|
private func rowView(_ row: Row, focused: Bool) -> some View {
|
||||||
HStack(spacing: 14) {
|
let m = GamepadFormMetrics.self
|
||||||
|
return HStack(spacing: 14) {
|
||||||
if row.isAction {
|
if row.isAction {
|
||||||
Label("Add Host", systemImage: "plus.circle.fill")
|
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))
|
.foregroundStyle(canAdd ? Color.brand : .white.opacity(0.35))
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
} else {
|
} else {
|
||||||
Text(row.label)
|
Text(row.label)
|
||||||
.font(.geist(16, .semibold, relativeTo: .body))
|
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
Spacer(minLength: 12)
|
Spacer(minLength: 12)
|
||||||
Text(row.value.isEmpty ? row.placeholder : row.value)
|
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)
|
.foregroundStyle(row.value.isEmpty ? .white.opacity(0.35) : .white)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.head) // keep the end of a long address visible while typing
|
.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.
|
// The live-edit caret: this row is what the keyboard tray is typing into.
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(Color.brand)
|
.fill(Color.brand)
|
||||||
.frame(width: 2, height: 18)
|
.frame(width: 2, height: m.labelFont + 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, m.rowHPad)
|
||||||
.padding(.vertical, 13)
|
.padding(.vertical, m.rowVPad)
|
||||||
// Liquid Glass rows, matching the settings screen; the focused (or actively edited) row
|
// 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.
|
// takes the brand wash, and the edited row keeps its brand caret border.
|
||||||
.consoleGlass(
|
.consoleGlass(
|
||||||
RoundedRectangle(cornerRadius: 14, style: .continuous),
|
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
|
||||||
tint: (focused || editing == row.id) ? Color.brand.opacity(0.30) : nil,
|
tint: (focused || editing == row.id) ? Color.brand.opacity(0.30) : nil,
|
||||||
interactive: focused)
|
interactive: focused)
|
||||||
.overlay {
|
.overlay {
|
||||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
|
||||||
.strokeBorder(
|
.strokeBorder(
|
||||||
editing == row.id ? Color.brand.opacity(0.7) : .white.opacity(focused ? 0.28 : 0.06),
|
editing == row.id ? Color.brand.opacity(0.7) : .white.opacity(focused ? 0.28 : 0.06),
|
||||||
lineWidth: 1)
|
lineWidth: 1)
|
||||||
@@ -235,5 +269,41 @@ struct GamepadAddHostView: View {
|
|||||||
default: return nil
|
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
|
#endif
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
// The one piece of gamepad-menu machinery shared by the host launcher (GamepadHomeView) and the
|
// 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
|
// 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)`
|
// The scrolling is pure native SwiftUI — `.scrollTargetLayout()` + `.scrollTargetBehavior(.viewAligned)`
|
||||||
// snap exactly one item to center, and symmetric `.safeAreaPadding(.horizontal)` (sized off the live
|
// snap exactly one item to center, and symmetric `.safeAreaPadding(.horizontal)` (sized off the live
|
||||||
@@ -24,7 +29,7 @@
|
|||||||
|
|
||||||
import PunktfunkKit
|
import PunktfunkKit
|
||||||
import SwiftUI
|
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 {
|
struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hashable {
|
||||||
let items: [Item]
|
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 input = GamepadMenuInput(manager: .shared)
|
||||||
@State private var haptics = MenuHaptics(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
|
/// Authoritative gamepad cursor (index into `items`). Never assigned from scroll read-back
|
||||||
/// while the gamepad is driving — that's the whole desync fix.
|
/// while the gamepad is driving — that's the whole desync fix.
|
||||||
@State private var cursor = 0
|
@State private var cursor = 0
|
||||||
@@ -81,26 +91,72 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
let inset = max(0, (geo.size.width - itemWidth) / 2)
|
let inset = max(0, (geo.size.width - itemWidth) / 2)
|
||||||
ScrollView(.horizontal) {
|
ScrollViewReader { proxy in
|
||||||
HStack(spacing: spacing) {
|
ScrollView(.horizontal) {
|
||||||
ForEach(items) { item in
|
HStack(spacing: spacing) {
|
||||||
card(item)
|
ForEach(items) { item in
|
||||||
.frame(width: itemWidth)
|
#if os(tvOS)
|
||||||
.contentShape(Rectangle())
|
// A focusable Button per card: the focus engine does the navigating
|
||||||
.onTapGesture { tap(item) }
|
// (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
|
// The list changed under a stable focus (discovered hosts prepend tiles): the
|
||||||
.scrollTargetLayout()
|
// 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(.selection, trigger: cursor)
|
||||||
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
|
.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
|
// 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
|
// 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
|
// 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
|
.onChange(of: scrolledID) { _, newValue in
|
||||||
guard !isScrolling, Date().timeIntervalSince(lastNav) > navSettle else { return }
|
guard !isScrolling, Date().timeIntervalSince(lastNav) > navSettle else { return }
|
||||||
guard let idx = index(of: newValue), idx != cursor else { return }
|
guard let idx = index(of: newValue), idx != cursor else { return }
|
||||||
cursor = idx
|
cursor = idx
|
||||||
selection = newValue
|
selection = newValue
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
// Re-seed a dropped/changed selection AND re-wire the input callbacks so they capture the
|
// 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
|
// current `items` value (a plain array — unlike an observed object it would otherwise go
|
||||||
// stale in the closures stored on `input`).
|
// 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
|
// MARK: - Input wiring
|
||||||
|
|
||||||
private func wire() {
|
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.onMove = { move($0) }
|
||||||
input.onConfirm = { activate() }
|
input.onConfirm = { activate() }
|
||||||
input.onSecondary = onSecondary
|
input.onSecondary = onSecondary
|
||||||
input.onTertiary = onTertiary
|
input.onTertiary = onTertiary
|
||||||
input.onBack = onBack
|
input.onBack = onBack
|
||||||
input.onShoulder = shoulderJump > 0 ? { shoulder(right: $0) } : nil
|
input.onShoulder = shoulderJump > 0 ? { shoulder(right: $0) } : nil
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func move(_ direction: GamepadMenuInput.Direction) {
|
private func move(_ direction: GamepadMenuInput.Direction) {
|
||||||
@@ -212,9 +279,14 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
|||||||
|
|
||||||
private func activate() {
|
private func activate() {
|
||||||
guard cursor >= 0, cursor < items.count else { return }
|
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
|
activateTick &+= 1
|
||||||
haptics.confirm()
|
haptics.confirm()
|
||||||
onActivate(items[cursor])
|
onActivate(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Touch fallback matching the rest of the app: tapping the centered card activates it, tapping
|
/// 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
|
scrolledID = id
|
||||||
selection = 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) {
|
private func boundaryBump(forward: Bool) {
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
// GamepadAddHostView, LibraryCoverflowView): the full-bleed console backdrop, the
|
// GamepadAddHostView, LibraryCoverflowView): the full-bleed console backdrop, the
|
||||||
// controller-glyph hint bar, and the connected-controller status chip. One look across every
|
// 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.
|
// 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 PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
#if os(iOS) || os(macOS)
|
#if os(iOS) || os(macOS) || os(tvOS)
|
||||||
import GameController
|
import GameController
|
||||||
|
|
||||||
/// The active controller's real glyph for a button (Xbox "A", DualSense ✕, …) via
|
/// The active controller's real glyph for a button (Xbox "A", DualSense ✕, …) via
|
||||||
@@ -31,6 +32,51 @@ func gamepadTitleTopPadding(compact: Bool) -> CGFloat {
|
|||||||
#endif
|
#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.
|
/// One glyph + label cell in a hint bar.
|
||||||
struct GamepadHint: Identifiable {
|
struct GamepadHint: Identifiable {
|
||||||
let glyph: String
|
let glyph: String
|
||||||
@@ -45,21 +91,32 @@ struct GamepadHint: Identifiable {
|
|||||||
struct GamepadHintBar: View {
|
struct GamepadHintBar: View {
|
||||||
let hints: [GamepadHint]
|
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 {
|
var body: some View {
|
||||||
HStack(spacing: 18) {
|
HStack(spacing: 18) {
|
||||||
ForEach(hints) { hint in
|
ForEach(hints) { hint in
|
||||||
HStack(spacing: 7) {
|
HStack(spacing: 7) {
|
||||||
Image(systemName: hint.glyph)
|
Image(systemName: hint.glyph)
|
||||||
.font(.system(size: 19))
|
.font(.system(size: Self.glyphFont))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
Text(hint.text)
|
Text(hint.text)
|
||||||
}
|
}
|
||||||
.fixedSize() // keep glyph + label together; never truncate a hint mid-word
|
.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))
|
.foregroundStyle(.white.opacity(0.85))
|
||||||
.padding(13)
|
.padding(Self.pad)
|
||||||
.consoleGlass(Capsule())
|
.consoleGlass(Capsule())
|
||||||
.overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1))
|
.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
|
/// "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
|
/// chip in the launcher's top bar. Callers observe GamepadManager already, so this re-renders
|
||||||
/// when the pad or its battery state changes.
|
/// when the pad or its battery state changes.
|
||||||
struct ControllerStatusChip: View {
|
struct ControllerStatusChip: View {
|
||||||
let controller: GamepadManager.DiscoveredController
|
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 {
|
var body: some View {
|
||||||
HStack(spacing: 7) {
|
HStack(spacing: 7) {
|
||||||
Image(systemName: controller.hasTouchpadAndMotion
|
Image(systemName: controller.hasTouchpadAndMotion
|
||||||
? "playstation.logo" : "gamecontroller.fill")
|
? "playstation.logo" : "gamecontroller.fill")
|
||||||
.font(.system(size: 12))
|
.font(.system(size: Self.font))
|
||||||
Text(controller.name)
|
Text(controller.name)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
if let level = controller.batteryLevel {
|
if let level = controller.batteryLevel {
|
||||||
Image(systemName: batterySymbol(level))
|
Image(systemName: batterySymbol(level))
|
||||||
.font(.system(size: 12))
|
.font(.system(size: Self.font))
|
||||||
.foregroundStyle(level <= 0.2 && !controller.isCharging
|
.foregroundStyle(level <= 0.2 && !controller.isCharging
|
||||||
? AnyShapeStyle(.red) : AnyShapeStyle(.white.opacity(0.7)))
|
? 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))
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, Self.hPad)
|
||||||
.padding(.vertical, 7)
|
.padding(.vertical, Self.vPad)
|
||||||
.background(Capsule().fill(.white.opacity(0.08)))
|
.background(Capsule().fill(.white.opacity(0.08)))
|
||||||
.overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1))
|
.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
|
// 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
|
// 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
|
// 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
|
// `.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
|
// 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
|
// 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 PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
#if os(iOS) || os(macOS)
|
#if os(iOS) || os(macOS) || os(tvOS)
|
||||||
import GameController
|
import GameController
|
||||||
|
|
||||||
/// One navigable tile: a saved host, a discovered-but-unsaved one, or the trailing Add Host
|
/// 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 connect: (StoredHost) -> Void
|
||||||
let connectDiscovered: (DiscoveredHost) -> Void
|
let connectDiscovered: (DiscoveredHost) -> Void
|
||||||
|
|
||||||
/// Same experimental gate the touch grid's "Browse Library…" context-menu item uses.
|
/// Same gate the touch grid's "Browse Library…" context-menu item uses (default ON; the
|
||||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
/// 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)
|
#if os(iOS)
|
||||||
/// `.compact` in a landscape phone window — drives tighter chrome so everything still fits.
|
/// `.compact` in a landscape phone window — drives tighter chrome so everything still fits.
|
||||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||||
@@ -104,6 +110,12 @@ struct GamepadHomeView: View {
|
|||||||
try? await Task.sleep(for: .seconds(10))
|
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`
|
// 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
|
// 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.
|
// 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
|
// MARK: - Hero (carousel + detail), sized to fit the space between the pinned title and hints
|
||||||
|
|
||||||
@ViewBuilder private func hero(for size: CGSize) -> some View {
|
@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)
|
let cardWidth = min(340, size.width * 0.84)
|
||||||
// 48 ≈ the carousel's own vertical breathing (+40) plus a small margin; clamp so the strip
|
// 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
|
// 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.)
|
// 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))
|
let cardHeight = min(compact ? 176 : 224, max(118, size.height - 48))
|
||||||
|
#endif
|
||||||
VStack(spacing: compact ? 8 : 10) {
|
VStack(spacing: compact ? 8 : 10) {
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
carousel(cardWidth: cardWidth, cardHeight: cardHeight)
|
carousel(cardWidth: cardWidth, cardHeight: cardHeight)
|
||||||
@@ -145,7 +163,7 @@ struct GamepadHomeView: View {
|
|||||||
|
|
||||||
private var titleBar: some View {
|
private var titleBar: some View {
|
||||||
Text("Select a Host")
|
Text("Select a Host")
|
||||||
.font(.geist(compact ? 20 : 30, .bold, relativeTo: .title))
|
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.overlay(alignment: .trailing) {
|
.overlay(alignment: .trailing) {
|
||||||
@@ -158,6 +176,14 @@ struct GamepadHomeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var cardSpacing: CGFloat {
|
||||||
|
#if os(tvOS)
|
||||||
|
44
|
||||||
|
#else
|
||||||
|
30
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Carousel
|
// MARK: - Carousel
|
||||||
|
|
||||||
private func carousel(cardWidth: CGFloat, cardHeight: CGFloat) -> some View {
|
private func carousel(cardWidth: CGFloat, cardHeight: CGFloat) -> some View {
|
||||||
@@ -165,13 +191,16 @@ struct GamepadHomeView: View {
|
|||||||
items: tiles,
|
items: tiles,
|
||||||
selection: $selection,
|
selection: $selection,
|
||||||
itemWidth: cardWidth,
|
itemWidth: cardWidth,
|
||||||
spacing: 30,
|
spacing: cardSpacing,
|
||||||
onActivate: { $0.activate() },
|
onActivate: { $0.activate() },
|
||||||
onSecondary: { openLibraryForSelected() },
|
onSecondary: { openLibraryForSelected() },
|
||||||
onTertiary: { showSettings = true },
|
onTertiary: { showSettings = true },
|
||||||
// Stop consuming the controller while another screen (or the wake overlay) is on top —
|
// Stop consuming the controller while another screen (or the connect/wake takeover) is on
|
||||||
// otherwise the launcher navigates behind it (invisibly on iPhone, visibly on iPad).
|
// top — otherwise the launcher navigates behind it (invisibly on iPhone, visibly on iPad),
|
||||||
isActive: libraryTarget == nil && !showSettings && !showAddHost && waker.waking == nil
|
// 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
|
) { tile in
|
||||||
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight))
|
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight))
|
||||||
}
|
}
|
||||||
@@ -233,7 +262,7 @@ struct GamepadHomeView: View {
|
|||||||
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
||||||
filled: true,
|
filled: true,
|
||||||
hasLibrary: true,
|
hasLibrary: true,
|
||||||
canWake: PunktfunkConnection.wakeOnLANAvailable
|
canWake: autoWakeEnabled && PunktfunkConnection.wakeOnLANAvailable
|
||||||
&& !discovery.advertises(host) && !store.probedOnline.contains(host.id)
|
&& !discovery.advertises(host) && !store.probedOnline.contains(host.id)
|
||||||
&& !host.wakeMacs.isEmpty,
|
&& !host.wakeMacs.isEmpty,
|
||||||
activate: { connect(host) })
|
activate: { connect(host) })
|
||||||
@@ -272,6 +301,31 @@ private struct GamepadHostTile: View {
|
|||||||
let tile: HomeTile
|
let tile: HomeTile
|
||||||
let size: CGSize
|
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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
HStack(alignment: .top, spacing: 8) {
|
HStack(alignment: .top, spacing: 8) {
|
||||||
@@ -282,38 +336,38 @@ private struct GamepadHostTile: View {
|
|||||||
HStack(spacing: 7) {
|
HStack(spacing: 7) {
|
||||||
if tile.isPaired {
|
if tile.isPaired {
|
||||||
Image(systemName: "lock.fill")
|
Image(systemName: "lock.fill")
|
||||||
.font(.system(size: 11, weight: .semibold))
|
.font(.system(size: Self.statusFont, weight: .semibold))
|
||||||
.foregroundStyle(.white.opacity(0.5))
|
.foregroundStyle(.white.opacity(0.5))
|
||||||
}
|
}
|
||||||
if tile.isOnline {
|
if tile.isOnline {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(Color.green)
|
.fill(Color.green)
|
||||||
.frame(width: 9, height: 9)
|
.frame(width: Self.pipSide, height: Self.pipSide)
|
||||||
.shadow(color: .green.opacity(0.7), radius: 5)
|
.shadow(color: .green.opacity(0.7), radius: 5)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
Text(tile.title)
|
Text(tile.title)
|
||||||
.font(.geist(23, .bold, relativeTo: .title2))
|
.font(.geist(Self.titleFont, .bold, relativeTo: .title2))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(0.7)
|
.minimumScaleFactor(0.7)
|
||||||
Text(tile.subtitle)
|
Text(tile.subtitle)
|
||||||
.font(.geist(13, relativeTo: .caption))
|
.font(.geist(Self.subtitleFont, relativeTo: .caption))
|
||||||
.foregroundStyle(.white.opacity(0.55))
|
.foregroundStyle(.white.opacity(0.55))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.padding(.top, 2)
|
.padding(.top, 2)
|
||||||
}
|
}
|
||||||
.padding(20)
|
.padding(Self.pad)
|
||||||
.frame(width: size.width, height: size.height, alignment: .leading)
|
.frame(width: size.width, height: size.height, alignment: .leading)
|
||||||
// Liquid Glass console tile — a brand wash marks a saved host as primary; discovered /
|
// 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.
|
// Add-Host tiles stay neutral glass with a dashed edge. Glass clips to the shape itself.
|
||||||
.consoleGlass(
|
.consoleGlass(
|
||||||
RoundedRectangle(cornerRadius: 26, style: .continuous),
|
RoundedRectangle(cornerRadius: Self.corner, style: .continuous),
|
||||||
tint: tile.filled ? Color.brand.opacity(0.20) : nil)
|
tint: tile.filled ? Color.brand.opacity(0.20) : nil)
|
||||||
.overlay {
|
.overlay {
|
||||||
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
RoundedRectangle(cornerRadius: Self.corner, style: .continuous)
|
||||||
.strokeBorder(
|
.strokeBorder(
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
colors: [.white.opacity(0.22), .white.opacity(0.04)],
|
colors: [.white.opacity(0.22), .white.opacity(0.04)],
|
||||||
@@ -324,7 +378,7 @@ private struct GamepadHostTile: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var monogramBadge: some View {
|
private var monogramBadge: some View {
|
||||||
let shape = RoundedRectangle(cornerRadius: 15, style: .continuous)
|
let shape = RoundedRectangle(cornerRadius: Self.badgeCorner, style: .continuous)
|
||||||
return ZStack {
|
return ZStack {
|
||||||
shape.fill(tile.filled
|
shape.fill(tile.filled
|
||||||
? AnyShapeStyle(LinearGradient(
|
? AnyShapeStyle(LinearGradient(
|
||||||
@@ -335,15 +389,15 @@ private struct GamepadHostTile: View {
|
|||||||
ProgressView().tint(.white)
|
ProgressView().tint(.white)
|
||||||
} else if let icon = tile.icon {
|
} else if let icon = tile.icon {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 24, weight: .semibold))
|
.font(.system(size: Self.iconFont, weight: .semibold))
|
||||||
.foregroundStyle(Color.brand)
|
.foregroundStyle(Color.brand)
|
||||||
} else {
|
} else {
|
||||||
Text(monogram(tile.title))
|
Text(monogram(tile.title))
|
||||||
.font(.geistFixed(25, .bold))
|
.font(.geistFixed(Self.monogramFont, .bold))
|
||||||
.foregroundStyle(tile.filled ? .white : Color.brand)
|
.foregroundStyle(tile.filled ? .white : Color.brand)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: 52, height: 52)
|
.frame(width: Self.badgeSide, height: Self.badgeSide)
|
||||||
.overlay {
|
.overlay {
|
||||||
if !tile.filled {
|
if !tile.filled {
|
||||||
shape.strokeBorder(Color.brand.opacity(0.5), lineWidth: 1)
|
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 vertical sibling of GamepadCarousel (iOS/iPadOS/macOS/tvOS): a controller-driven focus list
|
||||||
// the gamepad UI's form-like screens (GamepadSettingsView, GamepadAddHostView). Up/down moves a
|
// for the gamepad UI's form-like screens (GamepadSettingsView, GamepadAddHostView). Up/down moves
|
||||||
// focus bar through the rows, left/right adjusts the focused row's value, A activates it, B backs
|
// a focus bar through the rows, left/right adjusts the focused row's value, A activates it, B
|
||||||
// out. The CALLER owns each row's look (it gets the focused flag); this component owns the focus
|
// backs out. The CALLER owns each row's look (it gets the focused flag); this component owns the
|
||||||
// cursor, controller polling, haptics, and keeping the focused row scrolled into view.
|
// 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
|
// 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
|
// cursor is plainly authoritative, the scroll view just chases it with `scrollTo`. Touch stays a
|
||||||
@@ -16,7 +22,7 @@
|
|||||||
|
|
||||||
import PunktfunkKit
|
import PunktfunkKit
|
||||||
import SwiftUI
|
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 {
|
struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hashable {
|
||||||
let items: [Item]
|
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 input = GamepadMenuInput(manager: .shared)
|
||||||
@State private var haptics = MenuHaptics(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`).
|
/// Authoritative focus cursor (index into `items`).
|
||||||
@State private var cursor = 0
|
@State private var cursor = 0
|
||||||
/// A short vertical recoil when a move is refused at a list end.
|
/// 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) {
|
ScrollView(.vertical) {
|
||||||
LazyVStack(spacing: 6) {
|
LazyVStack(spacing: 6) {
|
||||||
ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in
|
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)
|
row(item, idx == cursor && isActive)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { tap(idx) }
|
.onTapGesture { tap(idx) }
|
||||||
.id(item.id)
|
.id(item.id)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 10)
|
.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: cursor)
|
||||||
.sensoryFeedback(.selection, trigger: adjustTick)
|
.sensoryFeedback(.selection, trigger: adjustTick)
|
||||||
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
|
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
|
||||||
@@ -102,6 +144,22 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
|||||||
// MARK: - Input wiring
|
// MARK: - Input wiring
|
||||||
|
|
||||||
private func wire() {
|
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
|
input.onMove = { direction in
|
||||||
switch direction {
|
switch direction {
|
||||||
case .up: step(by: -1)
|
case .up: step(by: -1)
|
||||||
@@ -112,6 +170,7 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
|||||||
}
|
}
|
||||||
input.onConfirm = { activate() }
|
input.onConfirm = { activate() }
|
||||||
input.onBack = onBack
|
input.onBack = onBack
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func step(by delta: Int) {
|
private func step(by delta: Int) {
|
||||||
@@ -123,6 +182,7 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
|||||||
haptics.move()
|
haptics.move()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private func adjust(by delta: Int) {
|
private func adjust(by delta: Int) {
|
||||||
guard let onAdjust, cursor >= 0, cursor < items.count else { return }
|
guard let onAdjust, cursor >= 0, cursor < items.count else { return }
|
||||||
if onAdjust(items[cursor], delta) {
|
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)
|
cursor = min(max(cursor, 0), items.count - 1)
|
||||||
focusID = items[cursor].id
|
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) {
|
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
|
/// 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.
|
/// (the "Waking…" overlay), without connecting. Routed through ContentView's HostWaker.
|
||||||
let wake: (StoredHost) -> Void
|
let wake: (StoredHost) -> Void
|
||||||
/// Experimental game-library browser (gated) — the host-card "Browse Library…" action.
|
/// Game-library browser (default ON; the Settings toggle opts out) — the host-card
|
||||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
/// "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.
|
/// The host being edited (name / address / port / Wake-on-LAN MAC) — drives the edit sheet.
|
||||||
@State private var editTarget: StoredHost?
|
@State private var editTarget: StoredHost?
|
||||||
|
|
||||||
@@ -48,6 +49,13 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.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 {
|
if !discoveredUnsaved.isEmpty {
|
||||||
discoveredSection
|
discoveredSection
|
||||||
@@ -67,6 +75,14 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.top, 24)
|
.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
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,6 +214,10 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
.padding([.horizontal, .bottom])
|
.padding([.horizontal, .bottom])
|
||||||
.padding(.top, store.hosts.isEmpty ? 0 : 8)
|
.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
|
/// Discovered hosts not already saved (see `HostDiscovery.unsaved` — shared with the gamepad
|
||||||
@@ -259,7 +279,9 @@ struct HomeView: View {
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
[GridItem(.adaptive(minimum: 250, maximum: 320), spacing: 16)]
|
[GridItem(.adaptive(minimum: 250, maximum: 320), spacing: 16)]
|
||||||
#elseif os(tvOS)
|
#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
|
#else
|
||||||
[GridItem(.adaptive(minimum: 280), spacing: 16)]
|
[GridItem(.adaptive(minimum: 280), spacing: 16)]
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ private struct CardMetrics {
|
|||||||
CardMetrics(tile: 54, monogram: 26, name: 19, meta: 13, status: 11,
|
CardMetrics(tile: 54, monogram: 26, name: 19, meta: 13, status: 11,
|
||||||
padding: 16, spacing: 14, radius: 12)
|
padding: 16, spacing: 14, radius: 12)
|
||||||
#elseif os(tvOS)
|
#elseif os(tvOS)
|
||||||
CardMetrics(tile: 64, monogram: 32, name: 24, meta: 16, status: 14,
|
// 10-foot sizes — the 24pt-name tier read like a phone card from the couch.
|
||||||
padding: 18, spacing: 18, radius: 14)
|
CardMetrics(tile: 84, monogram: 42, name: 30, meta: 20, status: 17,
|
||||||
|
padding: 24, spacing: 22, radius: 18)
|
||||||
#else
|
#else
|
||||||
CardMetrics(tile: 44, monogram: 21, name: 15, meta: 12, status: 10.5,
|
CardMetrics(tile: 44, monogram: 21, name: 15, meta: 12, status: 10.5,
|
||||||
padding: 13, spacing: 12, radius: 10)
|
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
|
// `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
|
// 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
|
// (poster + the 3D recede treatment via `.scrollTransition`), the "now focused" detail panel, and
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
import PunktfunkKit
|
import PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
#if os(iOS) || os(macOS)
|
#if os(iOS) || os(macOS) || os(tvOS)
|
||||||
import GameController
|
import GameController
|
||||||
|
|
||||||
struct LibraryCoverflowView: View {
|
struct LibraryCoverflowView: View {
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ struct LibraryView: View {
|
|||||||
/// list fetch, reused across every poster in the grid). Built alongside `games` in `load()`;
|
/// 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.
|
/// torn down on disappear since it isn't one-shot like `LibraryClient.fetch`'s own session.
|
||||||
@State private var imageSession: URLSession?
|
@State private var imageSession: URLSession?
|
||||||
#if os(iOS) || os(macOS)
|
#if os(iOS) || os(macOS) || os(tvOS)
|
||||||
// Gamepad-driven browsing (iOS/iPadOS/macOS) — see ContentView's identical gate. tvOS keeps
|
// Gamepad-driven browsing — see ContentView's identical gate. With no controller (or the
|
||||||
// its existing plain-grid presentation of this same view unchanged.
|
// setting off) every platform keeps the plain-grid presentation of this same view.
|
||||||
@ObservedObject private var gamepadManager = GamepadManager.shared
|
@ObservedObject private var gamepadManager = GamepadManager.shared
|
||||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||||
private var gamepadUIActive: Bool {
|
private var gamepadUIActive: Bool {
|
||||||
@@ -69,7 +69,6 @@ struct LibraryView: View {
|
|||||||
} else if games.isEmpty {
|
} else if games.isEmpty {
|
||||||
emptyState
|
emptyState
|
||||||
} else {
|
} else {
|
||||||
#if os(iOS) || os(macOS)
|
|
||||||
if gamepadUIActive {
|
if gamepadUIActive {
|
||||||
LibraryCoverflowView(
|
LibraryCoverflowView(
|
||||||
games: games, imageSession: imageSession, onLaunch: onLaunch,
|
games: games, imageSession: imageSession, onLaunch: onLaunch,
|
||||||
@@ -77,9 +76,6 @@ struct LibraryView: View {
|
|||||||
} else {
|
} else {
|
||||||
grid
|
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()
|
ContentView()
|
||||||
#endif
|
#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)
|
.tint(.brand)
|
||||||
|
#endif
|
||||||
// Geist Sans is the app's typeface. This sets the default for unstyled text and the
|
// 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.
|
// 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))
|
.font(.geist(17, relativeTo: .body))
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
// The Stream menu (Release Mouse ⌃⌥⇧Q, Disconnect ⌃⌥⇧D, Show/Hide Statistics ⌃⌥⇧S —
|
// 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
|
// 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) {
|
ShotScene(name: "08-gamepad-addhost", orientation: .natural, colorScheme: .dark) {
|
||||||
AnyView(ShotGamepadAddHost())
|
AnyView(ShotGamepadAddHost())
|
||||||
},
|
},
|
||||||
ShotScene(name: "09-waking", orientation: .natural, colorScheme: .dark) {
|
ShotScene(name: "09-connecting", orientation: .natural, colorScheme: .dark) {
|
||||||
AnyView(ShotWaking())
|
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
|
#endif
|
||||||
@@ -137,23 +153,53 @@ private struct ShotGamepadAddHost: View {
|
|||||||
var body: some View { GamepadAddHostView(onAdd: { _ in }) }
|
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 store = ShotMock.hostStore()
|
||||||
@StateObject private var model = SessionModel()
|
@StateObject private var model = SessionModel()
|
||||||
@StateObject private var discovery = HostDiscovery()
|
@StateObject private var discovery = HostDiscovery()
|
||||||
@StateObject private var waker = HostWaker()
|
@StateObject private var waker = HostWaker()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GamepadHomeView(
|
backdrop
|
||||||
store: store, model: model, discovery: discovery,
|
.overlay {
|
||||||
libraryTarget: .constant(nil), waker: waker,
|
ConnectOverlay(
|
||||||
connect: { _ in }, connectDiscovered: { _ in }
|
connectingHostName: kind == .connecting ? "Battlestation" : nil,
|
||||||
)
|
waker: waker,
|
||||||
.overlay { WakeOverlay(waker: waker) }
|
gamepadUI: gamepadUI,
|
||||||
.onAppear {
|
onCancelConnect: {})
|
||||||
waker.debugSet(.init(
|
}
|
||||||
hostID: store.hosts.first?.id ?? UUID(),
|
.onAppear {
|
||||||
hostName: "Battlestation", connectsAfter: true, seconds: 14))
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ final class HostWaker: ObservableObject {
|
|||||||
var timedOut = false
|
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?
|
@Published private(set) var waking: Waking?
|
||||||
|
|
||||||
/// How long to wait for the host to reappear before giving up. Generous — a cold boot + service
|
/// 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.
|
// handshake phase, and the pump-thread → main-actor stats relay.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import os
|
||||||
import PunktfunkKit
|
import PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@@ -10,6 +11,15 @@ import SwiftUI
|
|||||||
#elseif canImport(UIKit)
|
#elseif canImport(UIKit)
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
#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
|
/// 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.
|
/// 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
|
/// Mirrors StreamView's capture state (it owns the input capture; this drives the
|
||||||
/// HUD's "click to capture" / "⌘⎋ releases" hint).
|
/// HUD's "click to capture" / "⌘⎋ releases" hint).
|
||||||
@Published var mouseCaptured = false
|
@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()
|
let meter = FrameMeter()
|
||||||
/// Capture→received (the host+network stage), fed per AU at receipt by the stream view's
|
/// 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 audio: SessionAudio?
|
||||||
private var gamepadCapture: GamepadCapture?
|
private var gamepadCapture: GamepadCapture?
|
||||||
private var gamepadFeedback: GamepadFeedback?
|
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 }
|
var isBusy: Bool { phase != .idle }
|
||||||
|
|
||||||
@@ -163,13 +189,25 @@ final class SessionModel: ObservableObject {
|
|||||||
let displayHDR: Bool = {
|
let displayHDR: Bool = {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
return (NSScreen.main?.maximumExtendedDynamicRangeColorComponentValue ?? 1.0) > 1.0
|
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
|
#else
|
||||||
return UIScreen.main.potentialEDRHeadroom > 1.0
|
return UIScreen.main.potentialEDRHeadroom > 1.0
|
||||||
#endif
|
#endif
|
||||||
}()
|
}()
|
||||||
let hdrCapable = hdrEnabled && displayHDR
|
let hdrCapable = hdrEnabled && displayHDR
|
||||||
// 4:4:4 opt-out (default on); the hardware-decode probe below is the real gate.
|
// 4:4:4 opt-IN (default off): full chroma is a per-client choice — a clear win for
|
||||||
let want444 = (UserDefaults.standard.object(forKey: DefaultsKey.enable444) as? Bool) ?? true
|
// 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) {
|
Task.detached(priority: .userInitiated) {
|
||||||
// PunktfunkConnection.init blocks on the QUIC handshake — keep it off the main
|
// PunktfunkConnection.init blocks on the QUIC handshake — keep it off the main
|
||||||
// actor. The persistent identity is presented on every connect so a paired
|
// 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.
|
// connection is still up); the feedback drain joins off-main like audio.
|
||||||
gamepadCapture?.stop()
|
gamepadCapture?.stop()
|
||||||
gamepadCapture = nil
|
gamepadCapture = nil
|
||||||
|
#if os(tvOS)
|
||||||
|
remotePointer?.stop() // releases any held click while the connection is still up
|
||||||
|
remotePointer = nil
|
||||||
|
#endif
|
||||||
let feedback = gamepadFeedback
|
let feedback = gamepadFeedback
|
||||||
gamepadFeedback = nil
|
gamepadFeedback = nil
|
||||||
if let conn = connection {
|
if let conn = connection {
|
||||||
@@ -332,6 +374,8 @@ final class SessionModel: ObservableObject {
|
|||||||
lostFrames = 0
|
lostFrames = 0
|
||||||
lostPct = 0
|
lostPct = 0
|
||||||
mouseCaptured = false
|
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.
|
/// 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)."
|
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() {
|
private func beginStreaming() {
|
||||||
guard let conn = connection else { return }
|
guard let conn = connection else { return }
|
||||||
// Input capture itself is owned by StreamView (engaged by the captureEnabled
|
// Input capture itself is owned by StreamView (engaged by the captureEnabled
|
||||||
@@ -363,11 +424,20 @@ final class SessionModel: ObservableObject {
|
|||||||
// session's virtual pad is a DualSense). Same trust gate as audio — nothing is
|
// session's virtual pad is a DualSense). Same trust gate as audio — nothing is
|
||||||
// forwarded during the trust prompt.
|
// forwarded during the trust prompt.
|
||||||
let capture = GamepadCapture(connection: conn, manager: .shared)
|
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()
|
capture.start()
|
||||||
gamepadCapture = capture
|
gamepadCapture = capture
|
||||||
let feedback = GamepadFeedback(connection: conn, manager: .shared)
|
let feedback = GamepadFeedback(connection: conn, manager: .shared)
|
||||||
feedback.start()
|
feedback.start()
|
||||||
gamepadFeedback = feedback
|
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() {
|
private func startStatsTimer() {
|
||||||
@@ -376,6 +446,11 @@ final class SessionModel: ObservableObject {
|
|||||||
let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
|
let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
Task { @MainActor in
|
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()
|
let (frames, bytes, total) = self.meter.drain()
|
||||||
self.fps = frames
|
self.fps = frames
|
||||||
self.mbps = Double(bytes) * 8 / 1_000_000
|
self.mbps = Double(bytes) * 8 / 1_000_000
|
||||||
@@ -429,12 +504,32 @@ final class SessionModel: ObservableObject {
|
|||||||
} else {
|
} else {
|
||||||
self.decodeValid = false
|
self.decodeValid = false
|
||||||
}
|
}
|
||||||
if let d = self.displayStage.drain() {
|
let displayWindow = self.displayStage.drain()
|
||||||
|
if let d = displayWindow {
|
||||||
self.displayP50Ms = d.p50Ms
|
self.displayP50Ms = d.p50Ms
|
||||||
self.displayValid = true
|
self.displayValid = true
|
||||||
} else {
|
} else {
|
||||||
self.displayValid = false
|
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.
|
// .common so the HUD keeps updating during window drags / menu tracking.
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ struct StreamHUDView: View {
|
|||||||
// this card — its frame (and, on iOS, its clamped corner) animate to the new size — rather
|
// 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.
|
// than cross-fading a whole new card in. Only the inner content switches per tier.
|
||||||
tierContent
|
tierContent
|
||||||
.padding(10)
|
.padding(cardPadding)
|
||||||
.glassBackground(cardShape)
|
.glassBackground(cardShape)
|
||||||
.padding(edgeInset)
|
.padding(edgeInset)
|
||||||
}
|
}
|
||||||
@@ -145,36 +145,43 @@ struct StreamHUDView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
#endif
|
#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)
|
// ⌃⌥⇧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,
|
// 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)
|
#if os(macOS)
|
||||||
Button("Disconnect (⌃⌥⇧D)") { model.disconnect() }
|
Button("Disconnect (⌃⌥⇧D)") { model.disconnect() }
|
||||||
.font(.geist(12, relativeTo: .caption))
|
.font(.geist(12, relativeTo: .caption))
|
||||||
#else
|
#elseif os(iOS)
|
||||||
Button("Disconnect") { model.disconnect() }
|
Button("Disconnect") { model.disconnect() }
|
||||||
.font(.geist(12, relativeTo: .caption))
|
.font(.geist(12, relativeTo: .caption))
|
||||||
#endif
|
#endif
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Card metrics
|
// MARK: - Card metrics
|
||||||
|
|
||||||
/// The OUTER gap between the card and the screen edge. (Inner content padding stays a fixed 10.)
|
/// The card's inner content padding. Roomier on tvOS — the stat text auto-scales for the
|
||||||
/// On iOS the card hugs a physically rounded display corner, so it sits a little further in and
|
/// couch (relative system styles), so the card's chrome must keep pace or it reads cramped.
|
||||||
/// pairs with a concentric corner radius (below); on macOS/tvOS windows the classic 10 reads fine.
|
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 {
|
private var edgeInset: CGFloat {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
return 14
|
return 14
|
||||||
|
#elseif os(tvOS)
|
||||||
|
return 24
|
||||||
#else
|
#else
|
||||||
return 10
|
return 10
|
||||||
#endif
|
#endif
|
||||||
@@ -187,6 +194,8 @@ struct StreamHUDView: View {
|
|||||||
private var cardCornerRadius: CGFloat {
|
private var cardCornerRadius: CGFloat {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
return max(12, DeviceMetrics.displayCornerRadius - edgeInset)
|
return max(12, DeviceMetrics.displayCornerRadius - edgeInset)
|
||||||
|
#elseif os(tvOS)
|
||||||
|
return 16 // scales with the roomier padding
|
||||||
#else
|
#else
|
||||||
return 10
|
return 10
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
import PunktfunkKit
|
import PunktfunkKit
|
||||||
import SwiftUI
|
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.
|
/// third-party software notices. Used as a pushed view on iOS/tvOS and a preferences tab on macOS.
|
||||||
struct AcknowledgementsView: View {
|
struct AcknowledgementsView: View {
|
||||||
private var version: String? {
|
private var version: String? {
|
||||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? 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 {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
// Top-level LazyVStack so the third-party-notices chunks (Licenses.thirdPartyNoticesChunks,
|
// 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.
|
// notice chunks visually continuous; the header block carries its own spacing + bottom pad.
|
||||||
LazyVStack(alignment: .leading, spacing: 0) {
|
LazyVStack(alignment: .leading, spacing: 0) {
|
||||||
VStack(alignment: .leading, spacing: 18) {
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
Text("punktfunk")
|
Text("Punktfunk")
|
||||||
.font(.geist(22, .bold, relativeTo: .title2))
|
.font(.geist(Self.titleFont, .bold, relativeTo: .title2))
|
||||||
if let version {
|
if let version {
|
||||||
Text("Version \(version)")
|
Text("Version \(version)")
|
||||||
.font(.geist(12, relativeTo: .caption))
|
.font(.geist(Self.captionFont, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Text(Licenses.appLicense)
|
LicenseWall(text: Licenses.appLicense)
|
||||||
.font(.caption.monospaced())
|
.font(.caption.monospaced())
|
||||||
.modifier(SelectableText())
|
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
Text("Bundled font")
|
Text("Bundled font")
|
||||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
.font(.geist(Self.headlineFont, .semibold, relativeTo: .headline))
|
||||||
Text("punktfunk ships the Geist typeface (Geist Sans), "
|
Text("Punktfunk ships the Geist typeface (Geist Sans), "
|
||||||
+ "© The Geist Project Authors / Vercel, used under the SIL Open Font "
|
+ "© The Geist Project Authors / Vercel, used under the SIL Open Font "
|
||||||
+ "License 1.1.")
|
+ "License 1.1.")
|
||||||
.font(.geist(12, relativeTo: .caption))
|
.font(.geist(Self.captionFont, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
if !Licenses.fontLicense.isEmpty {
|
if !Licenses.fontLicense.isEmpty {
|
||||||
Text(Licenses.fontLicense)
|
LicenseWall(text: Licenses.fontLicense)
|
||||||
.font(.caption2.monospaced())
|
.font(.caption2.monospaced())
|
||||||
.modifier(SelectableText())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
Text("Third-party software")
|
Text("Third-party software")
|
||||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
.font(.geist(Self.headlineFont, .semibold, relativeTo: .headline))
|
||||||
Text(
|
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+ "
|
+ "On some platforms FFmpeg is additionally bundled under the LGPL v2.1+ "
|
||||||
+ "(dynamically linked, replaceable)."
|
+ "(dynamically linked, replaceable)."
|
||||||
)
|
)
|
||||||
.font(.geist(12, relativeTo: .caption))
|
.font(.geist(Self.captionFont, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
@@ -62,6 +72,7 @@ struct AcknowledgementsView: View {
|
|||||||
.font(.caption2.monospaced())
|
.font(.caption2.monospaced())
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.modifier(SelectableText())
|
.modifier(SelectableText())
|
||||||
|
.modifier(TVFocusable())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: 900, alignment: .leading)
|
.frame(maxWidth: 900, alignment: .leading)
|
||||||
@@ -85,3 +96,40 @@ private struct SelectableText: ViewModifier {
|
|||||||
#endif
|
#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
|
// 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
|
// 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
|
// gamepad home launcher (X); the touch SettingsView remains the full-fidelity editor (custom
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
import PunktfunkKit
|
import PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
#if os(iOS) || os(macOS)
|
#if os(iOS) || os(macOS) || os(tvOS)
|
||||||
import GameController
|
import GameController
|
||||||
|
|
||||||
struct GamepadSettingsView: View {
|
struct GamepadSettingsView: View {
|
||||||
@@ -26,7 +26,7 @@ struct GamepadSettingsView: View {
|
|||||||
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
||||||
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
|
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
|
||||||
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
|
@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.codec) private var codec = "auto"
|
||||||
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
|
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
|
||||||
// The overlay tier's raw string (rows tag by rawValue); the absent-key default runs the
|
// The overlay tier's raw string (rows tag by rawValue); the absent-key default runs the
|
||||||
@@ -34,8 +34,10 @@ struct GamepadSettingsView: View {
|
|||||||
@AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw
|
@AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw
|
||||||
= StatsVerbosity.current.rawValue
|
= StatsVerbosity.current.rawValue
|
||||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.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.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
|
@ObservedObject private var gamepads = GamepadManager.shared
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -47,6 +49,9 @@ struct GamepadSettingsView: View {
|
|||||||
private let compact = false // no size classes on macOS; the sheet is sized generously
|
private let compact = false // no size classes on macOS; the sheet is sized generously
|
||||||
#endif
|
#endif
|
||||||
@State private var focusID: String?
|
@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 {
|
var body: some View {
|
||||||
GamepadMenuList(
|
GamepadMenuList(
|
||||||
@@ -57,13 +62,13 @@ struct GamepadSettingsView: View {
|
|||||||
onBack: { dismiss() }
|
onBack: { dismiss() }
|
||||||
) { row, focused in
|
) { row, focused in
|
||||||
rowView(row, focused: focused)
|
rowView(row, focused: focused)
|
||||||
.frame(maxWidth: 620)
|
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.safeAreaInset(edge: .top, spacing: 0) {
|
.safeAreaInset(edge: .top, spacing: 0) {
|
||||||
Text("Settings")
|
Text("Settings")
|
||||||
.font(.geist(compact ? 20 : 30, .bold, relativeTo: .title))
|
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||||
.padding(.bottom, compact ? 4 : 8)
|
.padding(.bottom, compact ? 4 : 8)
|
||||||
@@ -74,7 +79,7 @@ struct GamepadSettingsView: View {
|
|||||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text(focusedDetail)
|
Text(focusedDetail)
|
||||||
.font(.geist(13, relativeTo: .caption))
|
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
|
||||||
.foregroundStyle(.white.opacity(0.55))
|
.foregroundStyle(.white.opacity(0.55))
|
||||||
.lineLimit(2, reservesSpace: true)
|
.lineLimit(2, reservesSpace: true)
|
||||||
.animation(.smooth(duration: 0.2), value: focusID)
|
.animation(.smooth(duration: 0.2), value: focusID)
|
||||||
@@ -107,61 +112,78 @@ struct GamepadSettingsView: View {
|
|||||||
private var closeButton: some View {
|
private var closeButton: some View {
|
||||||
Button { dismiss() } label: {
|
Button { dismiss() } label: {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
.font(.system(size: 14, weight: .semibold))
|
.font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.frame(width: 34, height: 34)
|
.frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide)
|
||||||
.glassBackground(Circle(), interactive: true)
|
.glassBackground(Circle(), interactive: true)
|
||||||
.contentShape(Circle())
|
.contentShape(Circle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.keyboardShortcut(.cancelAction)
|
#if !os(tvOS)
|
||||||
|
.keyboardShortcut(.cancelAction) // unavailable on tvOS (Menu is the cancel there)
|
||||||
|
#endif
|
||||||
.accessibilityLabel("Close settings")
|
.accessibilityLabel("Close settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Row rendering
|
// MARK: - Row rendering
|
||||||
|
|
||||||
private func rowView(_ row: Row, focused: Bool) -> some View {
|
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 {
|
if let header = row.header {
|
||||||
Text(header)
|
Text(header)
|
||||||
.font(.geist(12, .semibold, relativeTo: .caption))
|
.font(.geist(m.headerFont, .semibold, relativeTo: .caption))
|
||||||
.tracking(1.4)
|
.tracking(1.4)
|
||||||
.foregroundStyle(.white.opacity(0.45))
|
.foregroundStyle(.white.opacity(0.45))
|
||||||
.padding(.leading, 16)
|
.padding(.leading, m.rowHPad)
|
||||||
.padding(.top, 14)
|
.padding(.top, 14)
|
||||||
}
|
}
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
Image(systemName: row.icon)
|
Image(systemName: row.icon)
|
||||||
.font(.system(size: 17))
|
.font(.system(size: m.iconFont))
|
||||||
.foregroundStyle(focused ? Color.brand : .white.opacity(0.55))
|
.foregroundStyle(focused ? Color.brand : .white.opacity(0.55))
|
||||||
.frame(width: 28)
|
.frame(width: m.iconWidth)
|
||||||
Text(row.label)
|
Text(row.label)
|
||||||
.font(.geist(16, .semibold, relativeTo: .body))
|
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Spacer(minLength: 12)
|
Spacer(minLength: 12)
|
||||||
HStack(spacing: 9) {
|
HStack(spacing: 9) {
|
||||||
Image(systemName: "chevron.left")
|
Image(systemName: "chevron.left")
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.system(size: m.chevronFont, weight: .semibold))
|
||||||
.foregroundStyle(.white.opacity(focused ? 0.6 : 0))
|
.foregroundStyle(.white.opacity(focused ? 0.6 : 0))
|
||||||
Text(row.value)
|
// Keyed by the value so a change slides the new option in instead of
|
||||||
.font(.geist(15, .medium, relativeTo: .callout))
|
// hard-swapping the string — a QUIET horizontal slip following the user's
|
||||||
.foregroundStyle(focused ? .white : .white.opacity(0.6))
|
// motion (a right-step enters from the right), crossfading over ~14 pt.
|
||||||
.lineLimit(1)
|
// 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")
|
Image(systemName: "chevron.right")
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.system(size: m.chevronFont, weight: .semibold))
|
||||||
.foregroundStyle(.white.opacity(focused ? 0.6 : 0))
|
.foregroundStyle(.white.opacity(focused ? 0.6 : 0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, m.rowHPad)
|
||||||
.padding(.vertical, 13)
|
.padding(.vertical, m.rowVPad)
|
||||||
// Every row is Liquid Glass; the focused one takes a brand wash and reacts to press.
|
// Every row is Liquid Glass; the focused one takes a brand wash and reacts to press.
|
||||||
.consoleGlass(
|
.consoleGlass(
|
||||||
RoundedRectangle(cornerRadius: 14, style: .continuous),
|
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
|
||||||
tint: focused ? Color.brand.opacity(0.30) : nil,
|
tint: focused ? Color.brand.opacity(0.30) : nil,
|
||||||
interactive: focused)
|
interactive: focused)
|
||||||
.overlay {
|
.overlay {
|
||||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
|
||||||
.strokeBorder(.white.opacity(focused ? 0.28 : 0.06), lineWidth: 1)
|
.strokeBorder(.white.opacity(focused ? 0.28 : 0.06), lineWidth: 1)
|
||||||
}
|
}
|
||||||
.scaleEffect(focused ? 1.0 : 0.98)
|
.scaleEffect(focused ? 1.0 : 0.98)
|
||||||
@@ -193,10 +215,12 @@ struct GamepadSettingsView: View {
|
|||||||
/// Dispatch by id so the focus list's stored input callbacks always act on freshly built rows
|
/// Dispatch by id so the focus list's stored input callbacks always act on freshly built rows
|
||||||
/// (never on state captured at wire time).
|
/// (never on state captured at wire time).
|
||||||
private func adjust(id: String, by delta: Int) -> Bool {
|
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) {
|
private func activate(id: String) {
|
||||||
|
lastAdjustDelta = 1 // A always cycles forward
|
||||||
rows.first { $0.id == id }?.activate()
|
rows.first { $0.id == id }?.activate()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +259,11 @@ struct GamepadSettingsView: View {
|
|||||||
+ "available on the host.",
|
+ "available on the host.",
|
||||||
options: SettingsOptions.compositors, current: compositor
|
options: SettingsOptions.compositors, current: compositor
|
||||||
) { compositor = $0 },
|
) { 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(
|
choiceRow(
|
||||||
id: "codec", header: "Video", icon: "film", label: "Video codec",
|
id: "codec", header: "Video", icon: "film", label: "Video codec",
|
||||||
@@ -252,6 +281,12 @@ struct GamepadSettingsView: View {
|
|||||||
detail: "Sharper text and UI at more bandwidth — needs host opt-in and "
|
detail: "Sharper text and UI at more bandwidth — needs host opt-in and "
|
||||||
+ "hardware decode.",
|
+ "hardware decode.",
|
||||||
value: $enable444),
|
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(
|
choiceRow(
|
||||||
id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels",
|
id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels",
|
||||||
@@ -287,8 +322,7 @@ struct GamepadSettingsView: View {
|
|||||||
) { hudPlacement = $0 },
|
) { hudPlacement = $0 },
|
||||||
toggleRow(
|
toggleRow(
|
||||||
id: "library", icon: "square.grid.2x2", label: "Game library",
|
id: "library", icon: "square.grid.2x2", label: "Game library",
|
||||||
detail: "Browse and launch the host's games with \(buttonName(\.buttonY, "Y")) "
|
detail: "Browse and launch the host's games with \(buttonName(\.buttonY, "Y")).",
|
||||||
+ "(experimental).",
|
|
||||||
value: $libraryEnabled),
|
value: $libraryEnabled),
|
||||||
toggleRow(
|
toggleRow(
|
||||||
id: "gamepadUI", icon: "hand.tap", label: "Controller-optimized UI",
|
id: "gamepadUI", icon: "hand.tap", label: "Controller-optimized UI",
|
||||||
|
|||||||
@@ -37,6 +37,30 @@ enum SettingsOptions {
|
|||||||
static let hudPlacements: [(label: String, tag: String)] =
|
static let hudPlacements: [(label: String, tag: String)] =
|
||||||
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
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.
|
/// Stats-overlay tiers (`DefaultsKey.statsVerbosity`) — the `tag` is the raw value.
|
||||||
static let statsVerbosities: [(label: String, tag: String)] =
|
static let statsVerbosities: [(label: String, tag: String)] =
|
||||||
StatsVerbosity.allCases.map { ($0.label, $0.rawValue) }
|
StatsVerbosity.allCases.map { ($0.label, $0.rawValue) }
|
||||||
@@ -105,8 +129,8 @@ enum SettingsOptions {
|
|||||||
return options
|
return options
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS) || os(macOS)
|
// MARK: - Stream mode (iOS/macOS pickers + the gamepad settings rows on all three; the
|
||||||
// MARK: - Stream mode (iOS + macOS pickers; tvOS builds its own preset list)
|
// touch/remote tvOS SettingsView builds its own preset list)
|
||||||
|
|
||||||
/// 16:9 then ultrawide presets; the device's native mode is prepended by `resolutionModes`.
|
/// 16:9 then ultrawide presets; the device's native mode is prepended by `resolutionModes`.
|
||||||
static let resolutionPresets: [(name: String, w: Int, h: Int)] = [
|
static let resolutionPresets: [(name: String, w: Int, h: Int)] = [
|
||||||
@@ -124,8 +148,8 @@ enum SettingsOptions {
|
|||||||
@MainActor
|
@MainActor
|
||||||
static func resolutionModes() -> [(name: String, w: Int, h: Int)] {
|
static func resolutionModes() -> [(name: String, w: Int, h: Int)] {
|
||||||
var native: [(name: String, w: Int, h: Int)] = []
|
var native: [(name: String, w: Int, h: Int)] = []
|
||||||
#if os(iOS)
|
#if os(iOS) || os(tvOS)
|
||||||
let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels
|
let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels (tvOS: the TV mode)
|
||||||
native = [("This device",
|
native = [("This device",
|
||||||
Int(max(bounds.width, bounds.height)),
|
Int(max(bounds.width, bounds.height)),
|
||||||
Int(min(bounds.width, bounds.height)))]
|
Int(min(bounds.width, bounds.height)))]
|
||||||
@@ -145,7 +169,7 @@ enum SettingsOptions {
|
|||||||
/// the screen can't show), plus any stored custom value so it stays selectable.
|
/// the screen can't show), plus any stored custom value so it stays selectable.
|
||||||
@MainActor
|
@MainActor
|
||||||
static func refreshRates(including current: Int) -> [Int] {
|
static func refreshRates(including current: Int) -> [Int] {
|
||||||
#if os(iOS)
|
#if os(iOS) || os(tvOS)
|
||||||
let maxHz = UIScreen.main.maximumFramesPerSecond
|
let maxHz = UIScreen.main.maximumFramesPerSecond
|
||||||
#else
|
#else
|
||||||
let maxHz = NSScreen.main?.maximumFramesPerSecond ?? 60
|
let maxHz = NSScreen.main?.maximumFramesPerSecond ?? 60
|
||||||
@@ -155,5 +179,4 @@ enum SettingsOptions {
|
|||||||
if !rates.contains(current) { rates.append(current) }
|
if !rates.contains(current) { rates.append(current) }
|
||||||
return rates.sorted()
|
return rates.sorted()
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ extension SettingsView {
|
|||||||
// failed exactly one slice: the iOS archive (macOS/tvOS never compile that branch).
|
// failed exactly one slice: the iOS archive (macOS/tvOS never compile that branch).
|
||||||
@ViewBuilder var streamModeSection: some View {
|
@ViewBuilder var streamModeSection: some View {
|
||||||
Section {
|
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)
|
#if os(iOS)
|
||||||
iosResolutionWheel
|
iosResolutionWheel
|
||||||
iosRefreshRows
|
iosRefreshRows
|
||||||
@@ -35,8 +40,12 @@ extension SettingsView {
|
|||||||
} header: {
|
} header: {
|
||||||
Text("Stream mode")
|
Text("Stream mode")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("The host creates a virtual output at exactly this mode — "
|
Text(matchWindow
|
||||||
+ "native resolution, no scaling. \(Self.bitrateFooter)")
|
? "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))
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.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 {
|
@ViewBuilder var windowSection: some View {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
Section {
|
Section {
|
||||||
@@ -380,8 +407,9 @@ extension SettingsView {
|
|||||||
Text("Codec is a preference; the host falls back if it can't encode your choice. "
|
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 "
|
+ "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 "
|
+ "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 "
|
+ "4:2:0 SDR. 4:4:4 (off by default) sharpens text and UI — best for desktop "
|
||||||
+ "next session.")
|
+ "work; for games the bits are better spent at 4:2:0. Applies from the next "
|
||||||
|
+ "session.")
|
||||||
.font(.geist(12, relativeTo: .caption))
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,10 +21,14 @@ struct SettingsView: View {
|
|||||||
@AppStorage(DefaultsKey.streamWidth) var width = 1920
|
@AppStorage(DefaultsKey.streamWidth) var width = 1920
|
||||||
@AppStorage(DefaultsKey.streamHeight) var height = 1080
|
@AppStorage(DefaultsKey.streamHeight) var height = 1080
|
||||||
@AppStorage(DefaultsKey.streamHz) var hz = 60
|
@AppStorage(DefaultsKey.streamHz) var hz = 60
|
||||||
|
// Default ON: a windowed session streams at the window's native pixels (1:1, no scaling) so it
|
||||||
|
// stays pixel-exact instead of the presenter resampling a fixed-mode frame into the window.
|
||||||
|
// Off falls back to the explicit mode below (fixed output, scaled to non-matching windows).
|
||||||
|
@AppStorage(DefaultsKey.matchWindow) var matchWindow = true
|
||||||
@AppStorage(DefaultsKey.compositor) var compositor = 0
|
@AppStorage(DefaultsKey.compositor) var compositor = 0
|
||||||
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
||||||
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
|
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
|
||||||
@AppStorage(DefaultsKey.presenter) var presenter = "stage2"
|
@AppStorage(DefaultsKey.presenter) var presenter = SettingsOptions.presenterDefault
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@AppStorage(DefaultsKey.vsync) var vsync = false
|
@AppStorage(DefaultsKey.vsync) var vsync = false
|
||||||
#endif
|
#endif
|
||||||
@@ -32,8 +36,8 @@ struct SettingsView: View {
|
|||||||
@AppStorage(DefaultsKey.allowVRR) var allowVRR = true
|
@AppStorage(DefaultsKey.allowVRR) var allowVRR = true
|
||||||
#endif
|
#endif
|
||||||
@AppStorage(DefaultsKey.hdrEnabled) var hdrEnabled = true
|
@AppStorage(DefaultsKey.hdrEnabled) var hdrEnabled = true
|
||||||
@AppStorage(DefaultsKey.enable444) var enable444 = true
|
@AppStorage(DefaultsKey.enable444) var enable444 = false
|
||||||
@AppStorage(DefaultsKey.libraryEnabled) var libraryEnabled = false
|
@AppStorage(DefaultsKey.libraryEnabled) var libraryEnabled = true
|
||||||
@AppStorage(DefaultsKey.fullscreenWhileStreaming) var fullscreenWhileStreaming = true
|
@AppStorage(DefaultsKey.fullscreenWhileStreaming) var fullscreenWhileStreaming = true
|
||||||
@AppStorage(DefaultsKey.micEnabled) var micEnabled = true
|
@AppStorage(DefaultsKey.micEnabled) var micEnabled = true
|
||||||
@AppStorage(DefaultsKey.audioChannels) var audioChannels = 2
|
@AppStorage(DefaultsKey.audioChannels) var audioChannels = 2
|
||||||
@@ -43,9 +47,8 @@ struct SettingsView: View {
|
|||||||
@AppStorage(DefaultsKey.statsVerbosity) var statsVerbosityRaw = StatsVerbosity.current.rawValue
|
@AppStorage(DefaultsKey.statsVerbosity) var statsVerbosityRaw = StatsVerbosity.current.rawValue
|
||||||
@AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue
|
@AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||||
@ObservedObject var gamepads = GamepadManager.shared
|
@ObservedObject var gamepads = GamepadManager.shared
|
||||||
#if !os(tvOS)
|
|
||||||
@AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true
|
@AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true
|
||||||
#endif
|
@AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true
|
||||||
#if DEBUG && !os(tvOS)
|
#if DEBUG && !os(tvOS)
|
||||||
@State var showControllerTest = false
|
@State var showControllerTest = false
|
||||||
#endif
|
#endif
|
||||||
@@ -107,6 +110,7 @@ struct SettingsView: View {
|
|||||||
Form {
|
Form {
|
||||||
streamModeSection
|
streamModeSection
|
||||||
compositorSection
|
compositorSection
|
||||||
|
wakeSection
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.tabItem { Label("General", systemImage: "gearshape") }
|
.tabItem { Label("General", systemImage: "gearshape") }
|
||||||
@@ -236,6 +240,7 @@ struct SettingsView: View {
|
|||||||
streamModeSection
|
streamModeSection
|
||||||
pointerSection
|
pointerSection
|
||||||
compositorSection
|
compositorSection
|
||||||
|
wakeSection
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.navigationTitle("General")
|
.navigationTitle("General")
|
||||||
@@ -284,19 +289,6 @@ struct SettingsView: View {
|
|||||||
("4K @ 60", "3840x2160x60"),
|
("4K @ 60", "3840x2160x60"),
|
||||||
]
|
]
|
||||||
|
|
||||||
/// Stage-2 vs stage-3 present pacing (see SettingsView+Sections' presenterSection for the
|
|
||||||
/// rationale); the freeze-prone stage-1 diagnostic only ships in DEBUG builds.
|
|
||||||
private static var presenterOptions: [(label: String, tag: String)] {
|
|
||||||
var options: [(label: String, tag: String)] = [
|
|
||||||
("Stage 2 (default)", "stage2"),
|
|
||||||
("Stage 3 (experimental)", "stage3"),
|
|
||||||
]
|
|
||||||
#if DEBUG
|
|
||||||
options.append(("Stage 1 (debug)", "stage1"))
|
|
||||||
#endif
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
|
|
||||||
private var modeTag: Binding<String> {
|
private var modeTag: Binding<String> {
|
||||||
Binding(
|
Binding(
|
||||||
get: { "\(width)x\(height)x\(hz)" },
|
get: { "\(width)x\(height)x\(hz)" },
|
||||||
@@ -313,6 +305,16 @@ struct SettingsView: View {
|
|||||||
Binding(get: { hdrEnabled ? "on" : "off" }, set: { hdrEnabled = $0 == "on" })
|
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 {
|
private var tvBody: some View {
|
||||||
let currentTag = "\(width)x\(height)x\(hz)"
|
let currentTag = "\(width)x\(height)x\(hz)"
|
||||||
let bounds = UIScreen.main.nativeBounds
|
let bounds = UIScreen.main.nativeBounds
|
||||||
@@ -338,7 +340,7 @@ struct SettingsView: View {
|
|||||||
selection: $audioChannels)
|
selection: $audioChannels)
|
||||||
if bitrateKbps > 1_000_000 {
|
if bitrateKbps > 1_000_000 {
|
||||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||||
.font(.geist(12, relativeTo: .caption))
|
.font(.geist(20, relativeTo: .caption)) // TV-legible caption size
|
||||||
.foregroundStyle(.orange)
|
.foregroundStyle(.orange)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
@@ -347,15 +349,19 @@ struct SettingsView: View {
|
|||||||
selection: $compositor)
|
selection: $compositor)
|
||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "Presenter",
|
title: "Presenter",
|
||||||
options: Self.presenterOptions,
|
options: SettingsOptions.presenters,
|
||||||
selection: $presenter)
|
selection: $presenter)
|
||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "10-bit HDR",
|
title: "10-bit HDR",
|
||||||
options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag)
|
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 "
|
Text("The host creates a virtual output at exactly this mode — native "
|
||||||
+ "resolution, no scaling. \(Self.bitrateFooter) A specific compositor "
|
+ "resolution, no scaling. \(Self.bitrateFooter) A specific compositor "
|
||||||
+ "is honored only if available on the host.")
|
+ "is honored only if available on the host. Auto-wake sends Wake-on-LAN to a "
|
||||||
.font(.geist(12, relativeTo: .caption))
|
+ "sleeping saved host and waits for it before streaming.")
|
||||||
|
.font(.geist(20, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
@@ -375,8 +381,11 @@ struct SettingsView: View {
|
|||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "Controller type", options: SettingsOptions.padTypes,
|
title: "Controller type", options: SettingsOptions.padTypes,
|
||||||
selection: $gamepadType)
|
selection: $gamepadType)
|
||||||
|
TVSelectionRow(
|
||||||
|
title: "Gamepad-optimized browsing",
|
||||||
|
options: [("On", "on"), ("Off", "off")], selection: gamepadUIEnabledTag)
|
||||||
Text(Self.controllersFooter)
|
Text(Self.controllersFooter)
|
||||||
.font(.geist(12, relativeTo: .caption))
|
.font(.geist(20, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
|
|||||||
@@ -82,20 +82,36 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
|
|||||||
var interactive = false
|
var interactive = false
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
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)
|
content.glassEffect(glass, in: shape)
|
||||||
} else {
|
} else {
|
||||||
content.background { shape.fill(.ultraThinMaterial).environment(\.colorScheme, .dark) }
|
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 {
|
private var glass: Glass {
|
||||||
var g: Glass = .regular
|
var g: Glass = .regular
|
||||||
if let tint { g = g.tint(tint) }
|
if let tint { g = g.tint(tint) }
|
||||||
if interactive { g = g.interactive() }
|
if interactive { g = g.interactive() }
|
||||||
return g
|
return g
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
extension View {
|
extension View {
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ struct PairSheet: View {
|
|||||||
+ "(http://<host>:3000 → Pairing). "
|
+ "(http://<host>:3000 → Pairing). "
|
||||||
+ "Pairing verifies both sides at once — no fingerprint comparison "
|
+ "Pairing verifies both sides at once — no fingerprint comparison "
|
||||||
+ "needed.")
|
+ "needed.")
|
||||||
.font(.geist(16, relativeTo: .callout))
|
.font(.geist(22, relativeTo: .callout)) // TV-legible (system callout is ~25 there)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
TVFieldRow(
|
TVFieldRow(
|
||||||
@@ -59,7 +59,7 @@ struct PairSheet: View {
|
|||||||
) { editing = .clientName }
|
) { editing = .clientName }
|
||||||
if let errorText {
|
if let errorText {
|
||||||
Text(errorText)
|
Text(errorText)
|
||||||
.font(.geist(16, relativeTo: .callout))
|
.font(.geist(22, relativeTo: .callout))
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
}
|
}
|
||||||
HStack(spacing: 32) {
|
HStack(spacing: 32) {
|
||||||
|
|||||||
@@ -263,19 +263,24 @@ public final class SessionAudio {
|
|||||||
defer { drainDone.signal() }
|
defer { drainDone.signal() }
|
||||||
// Decode happens IN-CORE (libopus multistream) — AudioToolbox's Opus path is
|
// 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.
|
// 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?
|
let pcm: PunktfunkConnection.AudioPCM?
|
||||||
do {
|
do {
|
||||||
pcm = try connection.nextAudioPcm(timeoutMs: 100)
|
pcm = try connection.nextAudioPcm(timeoutMs: 100)
|
||||||
} catch {
|
} 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
|
pcm.samples.withUnsafeBufferPointer { p in
|
||||||
if let base = p.baseAddress {
|
if let base = p.baseAddress {
|
||||||
ring.write(base, count: pcm.frameCount * pcm.channels)
|
ring.write(base, count: pcm.frameCount * pcm.channels)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
thread.name = "punktfunk-audio"
|
thread.name = "punktfunk-audio"
|
||||||
|
|||||||
@@ -70,19 +70,10 @@ func withOptionalCString<R>(_ s: String?, _ body: (UnsafePointer<CChar>?) -> R)
|
|||||||
public extension PunktfunkConnection {
|
public extension PunktfunkConnection {
|
||||||
/// Whether the Wake-on-LAN broadcast path is usable on this platform/build. macOS can always
|
/// 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
|
/// 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
|
/// `com.apple.developer.networking.multicast` entitlement — now approved and enabled (see
|
||||||
/// approval (see `Config/Punktfunk.entitlements`) — until it's granted, sending a broadcast is
|
/// `Config/Punktfunk.entitlements`), so wake is available on every platform. Kept as the single
|
||||||
/// blocked by the OS, so the wake path + its UI are gated off there to avoid a dead action.
|
/// switch every call site gates on, should a future build ever need to disable it.
|
||||||
/// The MAC-learning path stays active on every platform, so flipping this on once the
|
static var wakeOnLANAvailable: Bool { true }
|
||||||
/// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a Wake-on-LAN magic packet to wake a sleeping host. `macs` are the host's NIC MAC(s)
|
/// 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
|
/// (`aa:bb:cc:dd:ee:ff`, learned from its mDNS `mac` TXT while awake); malformed entries are
|
||||||
@@ -445,6 +436,20 @@ public final class PunktfunkConnection {
|
|||||||
_ = punktfunk_connection_request_keyframe(h)
|
_ = 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)
|
||||||
|
}
|
||||||
|
|
||||||
/// Cumulative access units the host→client reassembler dropped as unrecoverable (FEC couldn't
|
/// 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
|
/// 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
|
/// correct loss trigger under the host's infinite GOP, where unrecoverable loss yields
|
||||||
@@ -570,7 +575,8 @@ public final class PunktfunkConnection {
|
|||||||
|
|
||||||
/// Pull the next force-feedback update for the GCController haptics engine:
|
/// Pull the next force-feedback update for the GCController haptics engine:
|
||||||
/// `(pad, lowFrequency, highFrequency)` with 0...0xFFFF amplitudes, (0, 0) = stop.
|
/// `(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)? {
|
public func nextRumble(timeoutMs: UInt32 = 0) throws -> (pad: UInt16, low: UInt16, high: UInt16)? {
|
||||||
feedbackLock.lock()
|
feedbackLock.lock()
|
||||||
defer { feedbackLock.unlock() }
|
defer { feedbackLock.unlock() }
|
||||||
@@ -590,6 +596,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
|
/// One DualSense feedback event a game wrote to the host's virtual pad — replay it on
|
||||||
/// the real controller (GCDeviceLight, GCControllerPlayerIndex,
|
/// the real controller (GCDeviceLight, GCControllerPlayerIndex,
|
||||||
/// GCDualSenseAdaptiveTrigger). Only a `.dualSense` session emits these.
|
/// GCDualSenseAdaptiveTrigger). Only a `.dualSense` session emits these.
|
||||||
|
|||||||
@@ -48,6 +48,23 @@ public final class GamepadCapture {
|
|||||||
/// Motion forwarding floor: ≥ 4 ms between samples (≈ 250 Hz, the DualSense's own rate).
|
/// Motion forwarding floor: ≥ 4 ms between samples (≈ 250 Hz, the DualSense's own rate).
|
||||||
private static let motionIntervalNs: UInt64 = 4_000_000
|
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) {
|
public init(connection: PunktfunkConnection, manager: GamepadManager) {
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
self.manager = manager
|
self.manager = manager
|
||||||
@@ -165,6 +182,7 @@ public final class GamepadCapture {
|
|||||||
private func sync(_ g: GCExtendedGamepad) {
|
private func sync(_ g: GCExtendedGamepad) {
|
||||||
guard !suspended else { return }
|
guard !suspended else { return }
|
||||||
let newButtons = Self.buttonMask(g)
|
let newButtons = Self.buttonMask(g)
|
||||||
|
updateEscapeChord(newButtons)
|
||||||
let changed = newButtons ^ buttons
|
let changed = newButtons ^ buttons
|
||||||
if changed != 0 {
|
if changed != 0 {
|
||||||
for bit in GamepadWire.allButtons where changed & bit != 0 {
|
for bit in GamepadWire.allButtons where changed & bit != 0 {
|
||||||
@@ -297,7 +315,26 @@ public final class GamepadCapture {
|
|||||||
|
|
||||||
/// Unwind everything held on the wire: button-ups, neutral axes, lifted fingers. The
|
/// 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.
|
/// host's virtual pad returns to rest instead of running with the last state.
|
||||||
|
/// Arm the disconnect timer when the full chord lands, disarm the moment any of the four
|
||||||
|
/// releases. 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(_ newButtons: UInt32) {
|
||||||
|
let held = newButtons & 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func releaseAll() {
|
private func releaseAll() {
|
||||||
|
chordTimer?.invalidate()
|
||||||
|
chordTimer = nil
|
||||||
for bit in GamepadWire.allButtons where buttons & bit != 0 {
|
for bit in GamepadWire.allButtons where buttons & bit != 0 {
|
||||||
connection.send(.gamepadButton(bit, down: false, pad: 0))
|
connection.send(.gamepadButton(bit, down: false, pad: 0))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,11 @@ public final class GamepadFeedback {
|
|||||||
// session — a DualSense or a DualShock 4 (lightbar only). Block briefly on it there and
|
// 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 rumble own the wait elsewhere; on an Xbox session it stays nonblocking.
|
||||||
let thread = Thread { [connection, flag, drainDone, weak self] in
|
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 {
|
do {
|
||||||
// Poll the feedback planes NON-BLOCKING. A blocking poll (timeoutMs > 0) holds
|
// 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
|
// the connection's shared feedback lock for its whole wait; the video pump drains
|
||||||
@@ -88,15 +92,15 @@ public final class GamepadFeedback {
|
|||||||
// drain: levels rendered up to ~130 ms late through the core's 16-deep queue,
|
// 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
|
// 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.
|
// queued ahead of it — buzzing until the host's next 500 ms refresh.
|
||||||
var newest: (low: UInt16, high: UInt16)?
|
var newest: (low: UInt16, high: UInt16, ttl: UInt32)?
|
||||||
var rumbleBurst = 0
|
var rumbleBurst = 0
|
||||||
while rumbleBurst < 64, !flag.isStopped,
|
while rumbleBurst < 64, !flag.isStopped,
|
||||||
let r = try connection.nextRumble(timeoutMs: 0) {
|
let r = try connection.nextRumble2(timeoutMs: 0) {
|
||||||
if r.pad == 0 { newest = (r.low, r.high) }
|
if r.pad == 0 { newest = (r.low, r.high, r.ttlMs) }
|
||||||
rumbleBurst += 1
|
rumbleBurst += 1
|
||||||
}
|
}
|
||||||
if let n = newest {
|
if let n = newest {
|
||||||
self?.rumble.apply(low: n.low, high: n.high)
|
self?.rumble.apply(low: n.low, high: n.high, ttlMs: n.ttl)
|
||||||
}
|
}
|
||||||
// Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing
|
// 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.
|
// per-frame LED/trigger reports) can't spin here or block stop() past one cycle.
|
||||||
@@ -106,12 +110,14 @@ public final class GamepadFeedback {
|
|||||||
self?.render(ev)
|
self?.render(ev)
|
||||||
burst += 1
|
burst += 1
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
} catch {
|
} 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
|
// ~8 ms poll cadence (≈125 Hz), slept OUTSIDE the feedback lock — low rumble/HID
|
||||||
// latency without holding the lock the HDR-meta drain needs.
|
// 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()
|
drainDone.signal()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,10 +23,23 @@ enum RumbleTuning {
|
|||||||
/// the churn that lost stops inside CoreHaptics. Newest level wins when the window opens;
|
/// the churn that lost stops inside CoreHaptics. Newest level wins when the window opens;
|
||||||
/// zero is never throttled.
|
/// zero is never throttled.
|
||||||
static let minRebakeSeconds: TimeInterval = 0.025
|
static let minRebakeSeconds: TimeInterval = 0.025
|
||||||
/// Session watchdog: silence the motors when no wire command arrived for this long. The
|
/// Session watchdog: silence the motors when no wire command arrived for this long. This is
|
||||||
/// host re-sends the current rumble state every 500 ms as its loss heal, so this trips only
|
/// the **legacy-host fallback only** — an old host sends no self-termination lease, so its
|
||||||
/// after 3 consecutive refreshes vanished — i.e. the channel or host died while audible.
|
/// 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
|
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
|
/// Levels closer than this (≈0.4 % of full scale) are the same level — an identical host
|
||||||
/// refresh must never rebuild a player.
|
/// refresh must never rebuild a player.
|
||||||
static let levelEpsilon: Float = 1.0 / 256.0
|
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.
|
/// 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 target: (low: UInt16, high: UInt16) = (0, 0)
|
||||||
private var lastCommand = DispatchTime(uptimeNanoseconds: 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,
|
/// 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,
|
/// 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.
|
/// 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
|
/// Set the wire-truth target. Called with every 0xCA state the host sends — level changes AND
|
||||||
/// AND the 500 ms refreshes; refreshes stamp liveness for the watchdog and are otherwise
|
/// renewals (v2) / 500 ms refreshes (legacy); both stamp liveness and, for v2, refresh the
|
||||||
/// free (invariant 2).
|
/// self-termination deadline. `ttlMs` is the envelope lease in ms, or [`RumbleTuning.noTTL`]
|
||||||
func apply(low lowAmp: UInt16, high highAmp: UInt16) {
|
/// 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 {
|
queue.async {
|
||||||
self.lastCommand = .now()
|
self.lastCommand = .now()
|
||||||
let active = lowAmp != 0 || highAmp != 0
|
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 {
|
if active != self.wasActive {
|
||||||
self.wasActive = active
|
self.wasActive = active
|
||||||
log.debug(
|
log.debug(
|
||||||
@@ -236,6 +263,7 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
self.ticker?.cancel()
|
self.ticker?.cancel()
|
||||||
self.ticker = nil
|
self.ticker = nil
|
||||||
self.target = (0, 0)
|
self.target = (0, 0)
|
||||||
|
self.envelopeDeadline = nil
|
||||||
self.wasActive = false
|
self.wasActive = false
|
||||||
self.teardown()
|
self.teardown()
|
||||||
self.closeHID()
|
self.closeHID()
|
||||||
@@ -293,9 +321,18 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
|
|
||||||
/// Watchdog + housekeeping heartbeat while audible.
|
/// Watchdog + housekeeping heartbeat while audible.
|
||||||
private func tick() {
|
private func tick() {
|
||||||
if let after = policy.staleAfter, target != (0, 0), seconds(since: lastCommand) > after {
|
if let deadline = envelopeDeadline {
|
||||||
// The host refreshes rumble state every 500 ms; this much silence means the channel
|
// v2 host lease: silence the moment it lapses unrenewed. This firing in the wild is the
|
||||||
// (or host) died while a motor was on. A direct-connected pad would have been
|
// 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.
|
// stopped by its game long ago — force the same outcome.
|
||||||
log.warning(
|
log.warning(
|
||||||
"rumble: no wire refresh for \(after, format: .fixed(precision: 1), privacy: .public)s — auto-silencing")
|
"rumble: no wire refresh for \(after, format: .fixed(precision: 1), privacy: .public)s — auto-silencing")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -11,6 +11,15 @@ public enum DefaultsKey {
|
|||||||
public static let streamWidth = "punktfunk.width"
|
public static let streamWidth = "punktfunk.width"
|
||||||
public static let streamHeight = "punktfunk.height"
|
public static let streamHeight = "punktfunk.height"
|
||||||
public static let streamHz = "punktfunk.hz"
|
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 compositor = "punktfunk.compositor"
|
||||||
public static let gamepadType = "punktfunk.gamepadType"
|
public static let gamepadType = "punktfunk.gamepadType"
|
||||||
public static let gamepadID = "punktfunk.gamepadID"
|
public static let gamepadID = "punktfunk.gamepadID"
|
||||||
@@ -88,6 +97,12 @@ public enum DefaultsKey {
|
|||||||
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library)
|
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library)
|
||||||
/// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`.
|
/// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`.
|
||||||
public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled"
|
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 {
|
extension Notification.Name {
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ public enum Licenses {
|
|||||||
return text
|
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 {
|
public static var appLicense: String {
|
||||||
let mit = resource("LICENSE-MIT")
|
let mit = resource("LICENSE-MIT")
|
||||||
let apache = resource("LICENSE-APACHE")
|
let apache = resource("LICENSE-APACHE")
|
||||||
if mit.isEmpty && apache.isEmpty {
|
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"
|
||||||
+ mit
|
+ mit
|
||||||
+ "\n\n============================== Apache-2.0 ==============================\n\n"
|
+ "\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
|
/// 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";
|
/// 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.
|
/// the inter-chunk break is the `LazyVStack` row boundary, so no text is lost. Computed once.
|
||||||
public static let thirdPartyNoticesChunks: [String] = {
|
public static let thirdPartyNoticesChunks: [String] = chunked(thirdPartyNotices)
|
||||||
let lines = thirdPartyNotices.split(separator: "\n", omittingEmptySubsequences: false)
|
|
||||||
let chunkSize = 200
|
/// Lines per chunk: tvOS reads much smaller chunks — focus is how tvOS scrolls, and each
|
||||||
return stride(from: 0, to: lines.count, by: chunkSize).map { start in
|
/// chunk is one focus stop, so a 200-line chunk (~5 screens tall there) would skip most of
|
||||||
lines[start..<min(start + chunkSize, lines.count)].joined(separator: "\n")
|
/// 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,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.
|
/// dimmer. Matches the host's standard PQ reference white.
|
||||||
private let hdrReferenceWhiteNits: Float = 203.0
|
private let hdrReferenceWhiteNits: Float = 203.0
|
||||||
|
|
||||||
/// Runtime-compiled (no metallib build step needed in SwiftPM): a fullscreen triangle and BT.709 SDR
|
/// PUNKTFUNK_SDR_COLORSPACE=srgb — A/B hatch for the SDR layer's colour tag. Today the SDR layer
|
||||||
/// and BT.2020-PQ HDR Y′CbCr→RGB fragment shaders. uv.y is flipped (1 - p.y) so the top-left-origin
|
/// ships with `colorspace = nil`, which on macOS means NO colour matching: the BT.709/sRGB-encoded
|
||||||
/// texture presents upright (NDC y is up). The HDR shader outputs PQ-encoded R′G′B′ as-is — the
|
/// stream is displayed with the panel's native primaries — mild oversaturation on every P3 Mac.
|
||||||
/// CAMetalLayer's `itur_2100_PQ` colour space + `edrMetadata` tell the system compositor the samples
|
/// `srgb` tags the layer so CoreAnimation colour-matches it into the panel's gamut (the strictly
|
||||||
/// are PQ and how to tone-map them (no EOTF here, matching the host's BT.2020 PQ emission).
|
/// 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 = """
|
private let shaderSource = """
|
||||||
#include <metal_stdlib>
|
#include <metal_stdlib>
|
||||||
using namespace metal;
|
using namespace metal;
|
||||||
|
|
||||||
struct VOut { float4 pos [[position]]; float2 uv; };
|
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]]) {
|
vertex VOut pf_vtx(uint vid [[vertex_id]]) {
|
||||||
float2 p = float2(float((vid << 1) & 2), float(vid & 2));
|
float2 p = float2(float((vid << 1) & 2), float(vid & 2));
|
||||||
VOut o;
|
VOut o;
|
||||||
@@ -94,43 +116,80 @@ float2 chromaUV(texture2d<float> lumaTex, texture2d<float> chromaTex, float2 uv)
|
|||||||
return uv;
|
return uv;
|
||||||
}
|
}
|
||||||
|
|
||||||
// SDR: 8-bit NV12 / 4:4:4 (BT.709, limited/video range) → full-range RGB. Chroma is sampled at the
|
// The shared sample + row-multiply: Y′CbCr (bicubic luma, siting-corrected bilinear chroma) →
|
||||||
// (siting-corrected) luma UV, so a full-size 4:4:4 chroma plane needs no shader change vs 4:2:0.
|
// RGB via the per-frame rows. A full-size 4:4:4 chroma plane needs no change vs 4:2:0 (the siting
|
||||||
fragment float4 pf_frag(VOut in [[stage_in]],
|
// offset self-disables). What the result MEANS depends on the stream: an SDR frame's rows yield
|
||||||
texture2d<float> lumaTex [[texture(0)]],
|
// gamma-encoded RGB, an HDR frame's rows yield PQ-encoded R′G′B′ — the fragment variants below
|
||||||
texture2d<float> chromaTex [[texture(1)]]) {
|
// 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);
|
constexpr sampler s(filter::linear, address::clamp_to_edge);
|
||||||
float y = catmullRomLuma(lumaTex, s, in.uv);
|
float3 yuv = float3(catmullRomLuma(lumaTex, s, uv),
|
||||||
float2 c = chromaTex.sample(s, chromaUV(lumaTex, chromaTex, in.uv)).rg;
|
chromaTex.sample(s, chromaUV(lumaTex, chromaTex, uv)).rg);
|
||||||
// BT.709, 8-bit limited (video) range → full-range RGB.
|
return saturate(float3(dot(csc.r0.xyz, yuv) + csc.r0.w,
|
||||||
y = (y - 16.0/255.0) * (255.0/219.0);
|
dot(csc.r1.xyz, yuv) + csc.r1.w,
|
||||||
float u = (c.x - 128.0/255.0) * (255.0/224.0);
|
dot(csc.r2.xyz, yuv) + csc.r2.w));
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HDR: 10-bit P010 / 4:4:4 (BT.2020, limited range), Y′CbCr that is PQ-encoded. We apply the BT.2020
|
// SDR: 8-bit NV12 / 4:4:4 → full-range RGB, transfer left baked (shown as-is, the proven SDR
|
||||||
// matrix to get PQ-encoded R′G′B′ and output it as-is — the CAMetalLayer's itur_2100_PQ colour space
|
// layer config).
|
||||||
// + edrMetadata tell the compositor the samples are PQ, so it does the PQ→display tone-map. No EOTF
|
fragment float4 pf_frag(VOut in [[stage_in]],
|
||||||
// here. P010/x444 store the 10-bit code in the high bits of each 16-bit sample, so an .r16Unorm sample
|
texture2d<float> lumaTex [[texture(0)]],
|
||||||
// reads ~code/1023 (the /1024 vs /1023 error is < 0.1%).
|
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]],
|
fragment float4 pf_frag_hdr(VOut in [[stage_in]],
|
||||||
texture2d<float> lumaTex [[texture(0)]],
|
texture2d<float> lumaTex [[texture(0)]],
|
||||||
texture2d<float> chromaTex [[texture(1)]]) {
|
texture2d<float> chromaTex [[texture(1)]],
|
||||||
constexpr sampler s(filter::linear, address::clamp_to_edge);
|
constant CscUniform& csc [[buffer(0)]]) {
|
||||||
float y = catmullRomLuma(lumaTex, s, in.uv);
|
return float4(sampleRgb(lumaTex, chromaTex, in.uv, csc), 1.0);
|
||||||
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);
|
// HDR on tvOS when the display is composited WITHOUT HDR headroom (SDR output mode, or the user
|
||||||
float u = (c.x - 512.0/1023.0) * (1023.0/896.0);
|
// disabled Match Dynamic Range): no Metal EDR API exists there (CAEDRMetadata /
|
||||||
float v = (c.y - 512.0/1023.0) * (1023.0/896.0);
|
// wantsExtendedDynamicRangeContent are API_UNAVAILABLE(tvos)), and a bare PQ colour-space tag
|
||||||
float r = y + 1.4746 * v;
|
// composites UNtone-mapped — the CAMetalLayer header says so outright — which showed as a badly
|
||||||
float g = y - 0.16455 * u - 0.57135 * v;
|
// overblown picture on Apple TV. So this variant finishes the job in-shader: PQ EOTF → linear
|
||||||
float b = y + 1.8814 * u;
|
// light, 203-nit reference white (BT.2408) anchored at display white, extended-Reinhard highlight
|
||||||
return float4(saturate(float3(r, g, b)), 1.0);
|
// 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 +203,19 @@ public final class MetalVideoPresenter {
|
|||||||
/// frame in `render`; the layer is reconfigured to match when the session flips (HDR toggle).
|
/// frame in `render`; the layer is reconfigured to match when the session flips (HDR toggle).
|
||||||
private let pipelineSDR: MTLRenderPipelineState
|
private let pipelineSDR: MTLRenderPipelineState
|
||||||
private let pipelineHDR: 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?
|
private var textureCache: CVMetalTextureCache?
|
||||||
|
|
||||||
/// Current layer configuration — switched in `configure(hdr:)` when a frame's HDR-ness differs.
|
/// 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
|
/// 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).
|
/// `configure` call is ordered before the thread starts, so it doesn't race).
|
||||||
private var hdrActive = false
|
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
|
/// 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
|
/// 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
|
/// bare reference-white anchor (an out-of-order race otherwise: `setHdrMeta` and the flip both write
|
||||||
@@ -163,6 +229,11 @@ public final class MetalVideoPresenter {
|
|||||||
private let stagingLock = NSLock()
|
private let stagingLock = NSLock()
|
||||||
private var pendingHdrMeta: PunktfunkConnection.HdrMeta?
|
private var pendingHdrMeta: PunktfunkConnection.HdrMeta?
|
||||||
private var drawableTarget: CGSize = .zero
|
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
|
#if DEBUG
|
||||||
/// Last logged "decoded→drawable" signature, so the diagnostic logs only on a size/HDR change.
|
/// Last logged "decoded→drawable" signature, so the diagnostic logs only on a size/HDR change.
|
||||||
@@ -177,6 +248,7 @@ public final class MetalVideoPresenter {
|
|||||||
else { return nil }
|
else { return nil }
|
||||||
let pipelineSDR: MTLRenderPipelineState
|
let pipelineSDR: MTLRenderPipelineState
|
||||||
let pipelineHDR: MTLRenderPipelineState
|
let pipelineHDR: MTLRenderPipelineState
|
||||||
|
let pipelineHDRToneMap: MTLRenderPipelineState?
|
||||||
do {
|
do {
|
||||||
let library = try device.makeLibrary(source: shaderSource, options: nil)
|
let library = try device.makeLibrary(source: shaderSource, options: nil)
|
||||||
let vtx = library.makeFunction(name: "pf_vtx")
|
let vtx = library.makeFunction(name: "pf_vtx")
|
||||||
@@ -188,8 +260,20 @@ public final class MetalVideoPresenter {
|
|||||||
let hdr = MTLRenderPipelineDescriptor()
|
let hdr = MTLRenderPipelineDescriptor()
|
||||||
hdr.vertexFunction = vtx
|
hdr.vertexFunction = vtx
|
||||||
hdr.fragmentFunction = library.makeFunction(name: "pf_frag_hdr")
|
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)
|
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 {
|
} catch {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -229,17 +313,19 @@ public final class MetalVideoPresenter {
|
|||||||
|
|
||||||
return MetalVideoPresenter(
|
return MetalVideoPresenter(
|
||||||
device: device, queue: queue, pipelineSDR: pipelineSDR, pipelineHDR: pipelineHDR,
|
device: device, queue: queue, pipelineSDR: pipelineSDR, pipelineHDR: pipelineHDR,
|
||||||
textureCache: textureCache, layer: layer)
|
pipelineHDRToneMap: pipelineHDRToneMap, textureCache: textureCache, layer: layer)
|
||||||
}
|
}
|
||||||
|
|
||||||
private init(
|
private init(
|
||||||
device: MTLDevice, queue: MTLCommandQueue, pipelineSDR: MTLRenderPipelineState,
|
device: MTLDevice, queue: MTLCommandQueue, pipelineSDR: MTLRenderPipelineState,
|
||||||
pipelineHDR: MTLRenderPipelineState, textureCache: CVMetalTextureCache, layer: CAMetalLayer
|
pipelineHDR: MTLRenderPipelineState, pipelineHDRToneMap: MTLRenderPipelineState?,
|
||||||
|
textureCache: CVMetalTextureCache, layer: CAMetalLayer
|
||||||
) {
|
) {
|
||||||
self.device = device
|
self.device = device
|
||||||
self.queue = queue
|
self.queue = queue
|
||||||
self.pipelineSDR = pipelineSDR
|
self.pipelineSDR = pipelineSDR
|
||||||
self.pipelineHDR = pipelineHDR
|
self.pipelineHDR = pipelineHDR
|
||||||
|
self.pipelineHDRToneMap = pipelineHDRToneMap
|
||||||
self.textureCache = textureCache
|
self.textureCache = textureCache
|
||||||
self.layer = layer
|
self.layer = layer
|
||||||
}
|
}
|
||||||
@@ -251,30 +337,68 @@ public final class MetalVideoPresenter {
|
|||||||
/// an rgba16Float drawable + BT.2020 PQ colour space + EDR with a 203-nit reference-white anchor;
|
/// an rgba16Float drawable + BT.2020 PQ colour space + EDR with a 203-nit reference-white anchor;
|
||||||
/// SDR uses the plain 8-bit sRGB path.
|
/// SDR uses the plain 8-bit sRGB path.
|
||||||
public func configure(hdr: Bool) {
|
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 }
|
guard hdr != hdrActive else { return }
|
||||||
hdrActive = hdr
|
hdrActive = hdr
|
||||||
|
#endif
|
||||||
configureColor(hdr: hdr)
|
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
|
/// 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
|
/// 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
|
/// (`wantsExtendedDynamicRangeContent`/`edrMetadata`/`CAEDRMetadata` are all unavailable there) —
|
||||||
/// it gets the PQ pixel format + colour space only — the tvOS compositor tone-maps from those.
|
/// 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) {
|
private func configureColor(hdr: Bool) {
|
||||||
if hdr {
|
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.pixelFormat = .rgba16Float
|
||||||
layer.colorspace = CGColorSpace(name: CGColorSpace.itur_2100_PQ)
|
layer.colorspace = CGColorSpace(name: CGColorSpace.itur_2100_PQ)
|
||||||
#if !os(tvOS)
|
|
||||||
layer.wantsExtendedDynamicRangeContent = true
|
layer.wantsExtendedDynamicRangeContent = true
|
||||||
// Anchor reference white. Re-apply the real grade if one already arrived (0xCE before the
|
// 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.
|
// flip); otherwise the bare 203-nit anchor. Without this anchor the PQ signal is too bright.
|
||||||
layer.edrMetadata = makeEDR(lastHdrMeta)
|
layer.edrMetadata = makeEDR(lastHdrMeta)
|
||||||
#endif
|
#endif
|
||||||
} else {
|
} else {
|
||||||
// SDR: gamma-encoded BT.709 [0,1] in an 8-bit drawable; a nil colorspace tags it device/sRGB
|
// SDR: gamma-encoded BT.709 [0,1] in an 8-bit drawable. Default: nil colorspace = NO
|
||||||
// (the proven SDR path — never showed the "too bright" issue, which was HDR-only).
|
// 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.pixelFormat = .bgra8Unorm
|
||||||
layer.colorspace = nil
|
layer.colorspace = sdrColorspaceOverride
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
layer.wantsExtendedDynamicRangeContent = false
|
layer.wantsExtendedDynamicRangeContent = false
|
||||||
layer.edrMetadata = nil
|
layer.edrMetadata = nil
|
||||||
@@ -360,6 +484,11 @@ public final class MetalVideoPresenter {
|
|||||||
|| pf == kCVPixelFormatType_420YpCbCr10BiPlanarFullRange
|
|| pf == kCVPixelFormatType_420YpCbCr10BiPlanarFullRange
|
||||||
|| pf == kCVPixelFormatType_444YpCbCr10BiPlanarVideoRange
|
|| pf == kCVPixelFormatType_444YpCbCr10BiPlanarVideoRange
|
||||||
|| pf == kCVPixelFormatType_444YpCbCr10BiPlanarFullRange
|
|| 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,
|
guard let textureCache,
|
||||||
let luma = makeTexture(
|
let luma = makeTexture(
|
||||||
pixelBuffer, plane: 0, format: tenBit ? .r16Unorm : .r8Unorm, cache: textureCache),
|
pixelBuffer, plane: 0, format: tenBit ? .r16Unorm : .r8Unorm, cache: textureCache),
|
||||||
@@ -395,9 +524,17 @@ public final class MetalVideoPresenter {
|
|||||||
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else {
|
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else {
|
||||||
return false
|
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)
|
encoder.setRenderPipelineState(hdrActive ? pipelineHDR : pipelineSDR)
|
||||||
|
#endif
|
||||||
encoder.setFragmentTexture(CVMetalTextureGetTexture(luma), index: 0)
|
encoder.setFragmentTexture(CVMetalTextureGetTexture(luma), index: 0)
|
||||||
encoder.setFragmentTexture(CVMetalTextureGetTexture(chroma), index: 1)
|
encoder.setFragmentTexture(CVMetalTextureGetTexture(chroma), index: 1)
|
||||||
|
encoder.setFragmentBytes(&csc, length: MemoryLayout<CscUniform>.stride, index: 0)
|
||||||
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
|
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
|
||||||
encoder.endEncoding()
|
encoder.endEncoding()
|
||||||
if let onPresented {
|
if let onPresented {
|
||||||
|
|||||||
@@ -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 AVFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import QuartzCore
|
import QuartzCore
|
||||||
|
#if os(tvOS)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Weak-target wrapper for CADisplayLink. The link retains its target, so targeting a view or
|
/// 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
|
/// presenter directly makes a `owner → link → owner` cycle that only `invalidate()` breaks — if a
|
||||||
@@ -34,16 +37,31 @@ enum PresenterChoice: Equatable {
|
|||||||
|
|
||||||
/// Resolve from the `PUNKTFUNK_PRESENTER` env override (A/B without touching settings) first,
|
/// 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)
|
/// then the persisted `DefaultsKey.presenter` setting; anything unknown (or an empty env var)
|
||||||
/// falls back to stage-2. `allowStage1` is false in release builds, where a leftover DEBUG
|
/// falls back to the platform default. `allowStage1` is false in release builds, where a
|
||||||
/// "stage1" value silently maps to stage-2 rather than reviving the freeze-prone fallback.
|
/// 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 {
|
static func resolve(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice {
|
||||||
let raw = env.flatMap { $0.isEmpty ? nil : $0 } ?? setting
|
let raw = env.flatMap { $0.isEmpty ? nil : $0 } ?? setting
|
||||||
switch raw {
|
switch raw {
|
||||||
case "stage1": return allowStage1 ? .stage1 : .stage2
|
case "stage1": return allowStage1 ? .stage1 : platformDefault
|
||||||
|
case "stage2": return .stage2
|
||||||
case "stage3": return .stage3
|
case "stage3": return .stage3
|
||||||
default: return .stage2
|
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 {
|
final class SessionPresenter {
|
||||||
@@ -52,6 +70,15 @@ final class SessionPresenter {
|
|||||||
private var stage2Link: CADisplayLink?
|
private var stage2Link: CADisplayLink?
|
||||||
private var metalLayer: CAMetalLayer?
|
private var metalLayer: CAMetalLayer?
|
||||||
private var connection: PunktfunkConnection?
|
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:
|
/// 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
|
/// stage-1 enqueues into it; stage-2 leaves it idle and composites an opaque CAMetalLayer
|
||||||
@@ -67,7 +94,8 @@ final class SessionPresenter {
|
|||||||
displayMeter: LatencyMeter? = nil,
|
displayMeter: LatencyMeter? = nil,
|
||||||
makeDisplayLink: (AnyObject, Selector) -> CADisplayLink,
|
makeDisplayLink: (AnyObject, Selector) -> CADisplayLink,
|
||||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||||
onSessionEnd: (@Sendable () -> Void)?
|
onSessionEnd: (@Sendable () -> Void)?,
|
||||||
|
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil
|
||||||
) {
|
) {
|
||||||
stop()
|
stop()
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
@@ -110,12 +138,14 @@ final class SessionPresenter {
|
|||||||
link.add(to: .main, forMode: .common)
|
link.add(to: .main, forMode: .common)
|
||||||
stage2Link = link
|
stage2Link = link
|
||||||
syncFrameRate(hz: connection.currentMode().refreshHz)
|
syncFrameRate(hz: connection.currentMode().refreshHz)
|
||||||
pipeline.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
pipeline.start(
|
||||||
|
connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd,
|
||||||
|
onDecodedSize: onDecodedSize)
|
||||||
} else {
|
} else {
|
||||||
let pump = StreamPump()
|
let pump = StreamPump()
|
||||||
pump.start(
|
pump.start(
|
||||||
connection: connection, layer: baseLayer,
|
connection: connection, layer: baseLayer,
|
||||||
onFrame: onFrame, onSessionEnd: onSessionEnd)
|
onFrame: onFrame, onSessionEnd: onSessionEnd, onDecodedSize: onDecodedSize)
|
||||||
self.pump = pump
|
self.pump = pump
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,11 +193,18 @@ final class SessionPresenter {
|
|||||||
guard let metalLayer, let connection else { return }
|
guard let metalLayer, let connection else { return }
|
||||||
let mode = connection.currentMode()
|
let mode = connection.currentMode()
|
||||||
syncFrameRate(hz: mode.refreshHz) // track a mid-session Reconfigure's new refresh
|
syncFrameRate(hz: mode.refreshHz) // track a mid-session Reconfigure's new refresh
|
||||||
let fit: CGRect = (mode.width > 0 && mode.height > 0)
|
// Aspect source: the ACTUAL decoded dims when known (survives a lagging `currentMode()` and a
|
||||||
? AVMakeRect(
|
// host that delivered a different size than requested), else the negotiated mode. The shader
|
||||||
aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)),
|
// stretches the frame across the WHOLE drawable, so this rect's aspect is the only thing that
|
||||||
insideRect: bounds)
|
// keeps the picture undistorted — a stale aspect here is the post-resize black-bars+stretch.
|
||||||
: bounds
|
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
|
||||||
// No implicit resize animation; contentsScale tracks the view's backing/display scale.
|
// No implicit resize animation; contentsScale tracks the view's backing/display scale.
|
||||||
CATransaction.begin()
|
CATransaction.begin()
|
||||||
CATransaction.setDisableActions(true)
|
CATransaction.setDisableActions(true)
|
||||||
@@ -179,12 +216,29 @@ final class SessionPresenter {
|
|||||||
stage2?.setDrawableTarget(CGSize(
|
stage2?.setDrawableTarget(CGSize(
|
||||||
width: (fit.width * contentsScale).rounded(),
|
width: (fit.width * contentsScale).rounded(),
|
||||||
height: (fit.height * contentsScale).rounded()))
|
height: (fit.height * contentsScale).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
|
/// 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.
|
/// stage-2 layer + link. Does not close the connection — that stays with whoever owns it.
|
||||||
/// Idempotent.
|
/// Idempotent.
|
||||||
func stop() {
|
func stop() {
|
||||||
|
contentSize = nil // a new session re-derives it from its first frame
|
||||||
pump?.stop()
|
pump?.stop()
|
||||||
pump = nil
|
pump = nil
|
||||||
stage2Link?.invalidate()
|
stage2Link?.invalidate()
|
||||||
|
|||||||
@@ -329,7 +329,8 @@ public final class Stage2Pipeline {
|
|||||||
public func start(
|
public func start(
|
||||||
connection: PunktfunkConnection,
|
connection: PunktfunkConnection,
|
||||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||||
onSessionEnd: (@Sendable () -> Void)?
|
onSessionEnd: (@Sendable () -> Void)?,
|
||||||
|
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil
|
||||||
) {
|
) {
|
||||||
offsetNs = connection.clockOffsetNs
|
offsetNs = connection.clockOffsetNs
|
||||||
recovery.bind(connection) // arm host-keyframe recovery for this session
|
recovery.bind(connection) // arm host-keyframe recovery for this session
|
||||||
@@ -350,6 +351,9 @@ public final class Stage2Pipeline {
|
|||||||
let thread = Thread {
|
let thread = Thread {
|
||||||
defer { pumpStopped.signal() } // let stop() join the pump (bounded) before decoder.reset()
|
defer { pumpStopped.signal() } // let stop() join the pump (bounded) before decoder.reset()
|
||||||
var format: CMVideoFormatDescription?
|
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()
|
var lastFramesDropped = connection.framesDropped()
|
||||||
// Persistent recovery WANT, not a one-shot edge (see StreamPump for the full rationale):
|
// 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.
|
// keep asking until an IDR lands so a request swallowed by the throttle is re-sent.
|
||||||
@@ -358,7 +362,12 @@ public final class Stage2Pipeline {
|
|||||||
// decode 4:4:4 at the negotiated resolution (the HW probe clears the common case but not a
|
// 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.
|
// resolution-ceiling miss). End cleanly instead of looping on a black screen.
|
||||||
var decodeFailRun = 0
|
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 {
|
do {
|
||||||
// Loss recovery (the primary path). The reassembler drops unrecoverable AUs and the
|
// Loss recovery (the primary path). The reassembler drops unrecoverable AUs and the
|
||||||
// decoder conceals the reference-missing deltas — often WITHOUT an error callback —
|
// decoder conceals the reference-missing deltas — often WITHOUT an error callback —
|
||||||
@@ -378,13 +387,23 @@ public final class Stage2Pipeline {
|
|||||||
if let meta = try? connection.nextHdrMeta(timeoutMs: 0) {
|
if let meta = try? connection.nextHdrMeta(timeoutMs: 0) {
|
||||||
presenter.setHdrMeta(meta)
|
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 below stays the backstop for when the recovery frame itself is lost.
|
||||||
|
connection.noteFrameIndex(au.frameIndex)
|
||||||
onFrame?(au)
|
onFrame?(au)
|
||||||
if let f = connection.videoCodec.formatDescription(fromKeyframe: au.data) {
|
if let f = connection.videoCodec.formatDescription(fromKeyframe: au.data) {
|
||||||
format = f // refreshed on every IDR (mode changes included)
|
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
|
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) {
|
if decoder.decode(au: au, format: f) {
|
||||||
decodeFailRun = 0
|
decodeFailRun = 0
|
||||||
} else {
|
} else {
|
||||||
@@ -397,12 +416,14 @@ public final class Stage2Pipeline {
|
|||||||
// recovers within a GOP) ⇒ 4:4:4 isn't decodable here; end the session.
|
// recovers within a GOP) ⇒ 4:4:4 isn't decodable here; end the session.
|
||||||
if connection.isChroma444, decodeFailRun >= 180 {
|
if connection.isChroma444, decodeFailRun >= 180 {
|
||||||
if !token.isStopped { onSessionEnd?() }
|
if !token.isStopped { onSessionEnd?() }
|
||||||
break
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
if !token.isStopped { onSessionEnd?() }
|
if !token.isStopped { onSessionEnd?() }
|
||||||
break // session closed
|
return false // session closed
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -435,10 +456,14 @@ public final class Stage2Pipeline {
|
|||||||
let gate: PresentGate? = pacing == .glass ? PresentGate() : nil
|
let gate: PresentGate? = pacing == .glass ? PresentGate() : nil
|
||||||
let renderThread = Thread {
|
let renderThread = Thread {
|
||||||
defer { renderStopped.signal() }
|
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 {
|
if renderSignal.wait(timeout: .now() + .milliseconds(100)) == .timedOut {
|
||||||
debugStats?.flushIfDue(ring: ring, gate: gate)
|
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
// Stage-3: while a present is in flight, don't take from the ring at all — frames
|
// 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
|
// keep coalescing there (newest wins, the intended drop point) and the presented
|
||||||
@@ -447,13 +472,13 @@ public final class Stage2Pipeline {
|
|||||||
if let gate, !gate.tryAcquire(now: CACurrentMediaTime()) {
|
if let gate, !gate.tryAcquire(now: CACurrentMediaTime()) {
|
||||||
debugStats?.gatedWake()
|
debugStats?.gatedWake()
|
||||||
debugStats?.flushIfDue(ring: ring, gate: gate)
|
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
guard !token.isStopped, let frame = ring.take() else {
|
guard !token.isStopped, let frame = ring.take() else {
|
||||||
gate?.release() // armed but nothing to render — don't hold the gate stale
|
gate?.release() // armed but nothing to render — don't hold the gate stale
|
||||||
debugStats?.emptyWake()
|
debugStats?.emptyWake()
|
||||||
debugStats?.flushIfDue(ring: ring, gate: gate)
|
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
// V-Sync ON: flip on the next predicted vsync (< one period out, stale link ⇒
|
// 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.
|
// immediate — see VsyncClock). OFF: flip as soon as the GPU finishes.
|
||||||
@@ -488,7 +513,7 @@ public final class Stage2Pipeline {
|
|||||||
ring.putBack(frame)
|
ring.putBack(frame)
|
||||||
}
|
}
|
||||||
debugStats?.flushIfDue(ring: ring, gate: gate)
|
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||||
}
|
} }
|
||||||
}
|
}
|
||||||
renderThread.name = "punktfunk-stage2-render"
|
renderThread.name = "punktfunk-stage2-render"
|
||||||
renderThread.qualityOfService = .userInteractive
|
renderThread.qualityOfService = .userInteractive
|
||||||
@@ -512,6 +537,13 @@ public final class Stage2Pipeline {
|
|||||||
presenter.setDrawableTarget(size)
|
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
|
/// 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
|
/// THREAD; idempotent. Does not close the connection. A restart needs a fresh Stage2Pipeline
|
||||||
/// (the stop is permanent).
|
/// (the stop is permanent).
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ final class StreamPump {
|
|||||||
connection: PunktfunkConnection,
|
connection: PunktfunkConnection,
|
||||||
layer: AVSampleBufferDisplayLayer,
|
layer: AVSampleBufferDisplayLayer,
|
||||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||||
onSessionEnd: (@Sendable () -> Void)?
|
onSessionEnd: (@Sendable () -> Void)?,
|
||||||
|
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil
|
||||||
) {
|
) {
|
||||||
let token = token
|
let token = token
|
||||||
// Coalesced host keyframe requests (100 ms throttle — see KeyframeRecovery).
|
// Coalesced host keyframe requests (100 ms throttle — see KeyframeRecovery).
|
||||||
@@ -35,6 +36,9 @@ final class StreamPump {
|
|||||||
|
|
||||||
let thread = Thread {
|
let thread = Thread {
|
||||||
var format: CMVideoFormatDescription?
|
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()
|
var lastFramesDropped = connection.framesDropped()
|
||||||
// Recovery is a persistent WANT, not a one-shot edge: set it on detected loss (or a
|
// 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
|
// decoder reset), retry the throttled request EVERY iteration, and clear it only when a
|
||||||
@@ -47,7 +51,12 @@ final class StreamPump {
|
|||||||
var awaitingIDR = false
|
var awaitingIDR = false
|
||||||
var awaitingSince = Date.distantPast // when the current recovery began (for the resume log)
|
var awaitingSince = Date.distantPast // when the current recovery began (for the resume log)
|
||||||
var wasFailed = false
|
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 {
|
do {
|
||||||
// Loss recovery (the primary path). Under the host's infinite GOP the only
|
// Loss recovery (the primary path). Under the host's infinite GOP the only
|
||||||
// recovery keyframe is one we request. The reassembler drops unrecoverable AUs
|
// recovery keyframe is one we request. The reassembler drops unrecoverable AUs
|
||||||
@@ -69,11 +78,21 @@ final class StreamPump {
|
|||||||
}
|
}
|
||||||
if awaitingIDR { recovery.request() }
|
if awaitingIDR { 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.
|
||||||
|
connection.noteFrameIndex(au.frameIndex)
|
||||||
onFrame?(au)
|
onFrame?(au)
|
||||||
let idrFormat = connection.videoCodec.formatDescription(fromKeyframe: au.data)
|
let idrFormat = connection.videoCodec.formatDescription(fromKeyframe: au.data)
|
||||||
if let f = idrFormat {
|
if let f = idrFormat {
|
||||||
format = f // refreshed on every IDR (mode changes included)
|
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 {
|
if awaitingIDR {
|
||||||
let ms = Int(Date().timeIntervalSince(awaitingSince) * 1000)
|
let ms = Int(Date().timeIntervalSince(awaitingSince) * 1000)
|
||||||
pumpLog.notice("video: recovery IDR received — resumed after \(ms, privacy: .public) ms")
|
pumpLog.notice("video: recovery IDR received — resumed after \(ms, privacy: .public) ms")
|
||||||
@@ -97,13 +116,15 @@ final class StreamPump {
|
|||||||
guard let f = format,
|
guard let f = format,
|
||||||
let sample = connection.videoCodec.sampleBuffer(au: au, format: f),
|
let sample = connection.videoCodec.sampleBuffer(au: au, format: f),
|
||||||
!token.isStopped // don't enqueue a stale frame after a restart
|
!token.isStopped // don't enqueue a stale frame after a restart
|
||||||
else { continue }
|
else { return true }
|
||||||
layer.enqueue(sample)
|
layer.enqueue(sample)
|
||||||
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
if !token.isStopped {
|
if !token.isStopped {
|
||||||
onSessionEnd?()
|
onSessionEnd?()
|
||||||
}
|
}
|
||||||
break // session closed
|
return false // session closed
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ public struct StreamView: NSViewRepresentable {
|
|||||||
private let onDisconnectRequest: (() -> Void)?
|
private let onDisconnectRequest: (() -> Void)?
|
||||||
private let onFrame: (@Sendable (AccessUnit) -> Void)?
|
private let onFrame: (@Sendable (AccessUnit) -> Void)?
|
||||||
private let onSessionEnd: (@Sendable () -> 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 endToEndMeter: LatencyMeter?
|
||||||
private let decodeMeter: LatencyMeter?
|
private let decodeMeter: LatencyMeter?
|
||||||
private let displayMeter: LatencyMeter?
|
private let displayMeter: LatencyMeter?
|
||||||
@@ -108,6 +110,8 @@ public struct StreamView: NSViewRepresentable {
|
|||||||
onDisconnectRequest: (() -> Void)? = nil,
|
onDisconnectRequest: (() -> Void)? = nil,
|
||||||
onFrame: (@Sendable (AccessUnit) -> Void)? = nil,
|
onFrame: (@Sendable (AccessUnit) -> Void)? = nil,
|
||||||
onSessionEnd: (@Sendable () -> Void)? = nil,
|
onSessionEnd: (@Sendable () -> Void)? = nil,
|
||||||
|
onResizeTarget: ((UInt32, UInt32) -> Void)? = nil,
|
||||||
|
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
|
||||||
endToEndMeter: LatencyMeter? = nil,
|
endToEndMeter: LatencyMeter? = nil,
|
||||||
decodeMeter: LatencyMeter? = nil,
|
decodeMeter: LatencyMeter? = nil,
|
||||||
displayMeter: LatencyMeter? = nil
|
displayMeter: LatencyMeter? = nil
|
||||||
@@ -118,6 +122,8 @@ public struct StreamView: NSViewRepresentable {
|
|||||||
self.onDisconnectRequest = onDisconnectRequest
|
self.onDisconnectRequest = onDisconnectRequest
|
||||||
self.onFrame = onFrame
|
self.onFrame = onFrame
|
||||||
self.onSessionEnd = onSessionEnd
|
self.onSessionEnd = onSessionEnd
|
||||||
|
self.onResizeTarget = onResizeTarget
|
||||||
|
self.onDecodedSize = onDecodedSize
|
||||||
self.endToEndMeter = endToEndMeter
|
self.endToEndMeter = endToEndMeter
|
||||||
self.decodeMeter = decodeMeter
|
self.decodeMeter = decodeMeter
|
||||||
self.displayMeter = displayMeter
|
self.displayMeter = displayMeter
|
||||||
@@ -131,6 +137,8 @@ public struct StreamView: NSViewRepresentable {
|
|||||||
view.endToEndMeter = endToEndMeter
|
view.endToEndMeter = endToEndMeter
|
||||||
view.decodeMeter = decodeMeter
|
view.decodeMeter = decodeMeter
|
||||||
view.displayMeter = displayMeter
|
view.displayMeter = displayMeter
|
||||||
|
view.onResizeTarget = onResizeTarget
|
||||||
|
view.onDecodedSize = onDecodedSize
|
||||||
view.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
view.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
@@ -142,6 +150,8 @@ public struct StreamView: NSViewRepresentable {
|
|||||||
view.endToEndMeter = endToEndMeter
|
view.endToEndMeter = endToEndMeter
|
||||||
view.decodeMeter = decodeMeter
|
view.decodeMeter = decodeMeter
|
||||||
view.displayMeter = displayMeter
|
view.displayMeter = displayMeter
|
||||||
|
view.onResizeTarget = onResizeTarget
|
||||||
|
view.onDecodedSize = onDecodedSize
|
||||||
// SwiftUI reuses the NSView across state changes — repoint the pump only when the
|
// SwiftUI reuses the NSView across state changes — repoint the pump only when the
|
||||||
// connection identity actually changed.
|
// connection identity actually changed.
|
||||||
if view.connection !== connection {
|
if view.connection !== connection {
|
||||||
@@ -165,6 +175,15 @@ public final class StreamLayerView: NSView {
|
|||||||
/// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback.
|
/// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback.
|
||||||
private let presenter = SessionPresenter()
|
private let presenter = SessionPresenter()
|
||||||
public private(set) var connection: PunktfunkConnection?
|
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 let cursorCapture = CursorCapture()
|
||||||
private var inputCapture: InputCapture?
|
private var inputCapture: InputCapture?
|
||||||
private var appObservers: [NSObjectProtocol] = []
|
private var appObservers: [NSObjectProtocol] = []
|
||||||
@@ -201,6 +220,13 @@ public final class StreamLayerView: NSView {
|
|||||||
/// view can't do that itself (the connection's owner disconnects).
|
/// view can't do that itself (the connection's owner disconnects).
|
||||||
public var onDisconnectRequest: (() -> Void)?
|
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
|
/// Main-thread only. False = input capture disabled outright (UI layered over the
|
||||||
/// stream); flipping to true auto-engages once.
|
/// stream); flipping to true auto-engages once.
|
||||||
public var captureEnabled = true {
|
public var captureEnabled = true {
|
||||||
@@ -618,6 +644,10 @@ public final class StreamLayerView: NSView {
|
|||||||
// (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by
|
// (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by
|
||||||
// default, the stage-1 pump as the Metal-missing / DEBUG fallback. The link comes from
|
// 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.
|
// 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(
|
presenter.start(
|
||||||
connection: connection,
|
connection: connection,
|
||||||
baseLayer: displayLayer,
|
baseLayer: displayLayer,
|
||||||
@@ -626,15 +656,38 @@ public final class StreamLayerView: NSView {
|
|||||||
displayMeter: displayMeter,
|
displayMeter: displayMeter,
|
||||||
makeDisplayLink: { displayLink(target: $0, selector: $1) },
|
makeDisplayLink: { displayLink(target: $0, selector: $1) },
|
||||||
onFrame: onFrame,
|
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): follow the window's pixel size — DEFAULT ON, 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.
|
||||||
|
// `?? true` so an unset default matches the Settings toggle (which also defaults on).
|
||||||
|
let follower = MatchWindowFollower(
|
||||||
|
connection: connection,
|
||||||
|
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? true)
|
||||||
|
follower.onResizeTarget = onResizeTarget // resize overlay START signal (instant, on the follower)
|
||||||
|
matchFollower = follower
|
||||||
layoutPresenter()
|
layoutPresenter()
|
||||||
requestAutoCapture() // entering a session is the deliberate "capture me" moment
|
requestAutoCapture() // entering a session is the deliberate "capture me" moment
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Aspect-fit the stage-2 metal sublayer to the view; refresh contentsScale on a
|
/// 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() {
|
private func layoutPresenter() {
|
||||||
presenter.layout(in: bounds, contentsScale: window?.backingScaleFactor ?? 1)
|
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() {
|
public override func viewDidChangeBackingProperties() {
|
||||||
@@ -642,6 +695,18 @@ public final class StreamLayerView: NSView {
|
|||||||
layoutPresenter() // backing scale changed (e.g. moved to a non-retina display)
|
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
|
/// Stop pumping (≤ one poll timeout). Does not close the connection — that stays with
|
||||||
/// whoever owns it (PunktfunkConnection.close() is safe alongside a draining pump).
|
/// whoever owns it (PunktfunkConnection.close() is safe alongside a draining pump).
|
||||||
public func stop() {
|
public func stop() {
|
||||||
@@ -650,6 +715,8 @@ public final class StreamLayerView: NSView {
|
|||||||
inputCapture?.stop()
|
inputCapture?.stop()
|
||||||
inputCapture = nil
|
inputCapture = nil
|
||||||
presenter.stop()
|
presenter.stop()
|
||||||
|
matchFollower = nil
|
||||||
|
lastDecodedContentSize = nil // the next session re-derives it from its first frame
|
||||||
connection = nil
|
connection = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ import PunktfunkCore
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
import os
|
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
|
/// 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
|
/// resolved pointer-lock state each time capture engages, so the user can see whether the
|
||||||
@@ -50,6 +53,8 @@ public struct StreamView: UIViewControllerRepresentable {
|
|||||||
private let onCaptureChange: ((Bool) -> Void)?
|
private let onCaptureChange: ((Bool) -> Void)?
|
||||||
private let onFrame: (@Sendable (AccessUnit) -> Void)?
|
private let onFrame: (@Sendable (AccessUnit) -> Void)?
|
||||||
private let onSessionEnd: (@Sendable () -> 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 endToEndMeter: LatencyMeter?
|
||||||
private let decodeMeter: LatencyMeter?
|
private let decodeMeter: LatencyMeter?
|
||||||
private let displayMeter: LatencyMeter?
|
private let displayMeter: LatencyMeter?
|
||||||
@@ -65,6 +70,8 @@ public struct StreamView: UIViewControllerRepresentable {
|
|||||||
onDisconnectRequest: (() -> Void)? = nil,
|
onDisconnectRequest: (() -> Void)? = nil,
|
||||||
onFrame: (@Sendable (AccessUnit) -> Void)? = nil,
|
onFrame: (@Sendable (AccessUnit) -> Void)? = nil,
|
||||||
onSessionEnd: (@Sendable () -> Void)? = nil,
|
onSessionEnd: (@Sendable () -> Void)? = nil,
|
||||||
|
onResizeTarget: ((UInt32, UInt32) -> Void)? = nil,
|
||||||
|
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
|
||||||
endToEndMeter: LatencyMeter? = nil,
|
endToEndMeter: LatencyMeter? = nil,
|
||||||
decodeMeter: LatencyMeter? = nil,
|
decodeMeter: LatencyMeter? = nil,
|
||||||
displayMeter: LatencyMeter? = nil
|
displayMeter: LatencyMeter? = nil
|
||||||
@@ -74,6 +81,8 @@ public struct StreamView: UIViewControllerRepresentable {
|
|||||||
self.onCaptureChange = onCaptureChange
|
self.onCaptureChange = onCaptureChange
|
||||||
self.onFrame = onFrame
|
self.onFrame = onFrame
|
||||||
self.onSessionEnd = onSessionEnd
|
self.onSessionEnd = onSessionEnd
|
||||||
|
self.onResizeTarget = onResizeTarget
|
||||||
|
self.onDecodedSize = onDecodedSize
|
||||||
self.endToEndMeter = endToEndMeter
|
self.endToEndMeter = endToEndMeter
|
||||||
self.decodeMeter = decodeMeter
|
self.decodeMeter = decodeMeter
|
||||||
self.displayMeter = displayMeter
|
self.displayMeter = displayMeter
|
||||||
@@ -86,6 +95,8 @@ public struct StreamView: UIViewControllerRepresentable {
|
|||||||
controller.endToEndMeter = endToEndMeter
|
controller.endToEndMeter = endToEndMeter
|
||||||
controller.decodeMeter = decodeMeter
|
controller.decodeMeter = decodeMeter
|
||||||
controller.displayMeter = displayMeter
|
controller.displayMeter = displayMeter
|
||||||
|
controller.onResizeTarget = onResizeTarget
|
||||||
|
controller.onDecodedSize = onDecodedSize
|
||||||
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||||
return controller
|
return controller
|
||||||
}
|
}
|
||||||
@@ -96,6 +107,8 @@ public struct StreamView: UIViewControllerRepresentable {
|
|||||||
controller.endToEndMeter = endToEndMeter
|
controller.endToEndMeter = endToEndMeter
|
||||||
controller.decodeMeter = decodeMeter
|
controller.decodeMeter = decodeMeter
|
||||||
controller.displayMeter = displayMeter
|
controller.displayMeter = displayMeter
|
||||||
|
controller.onResizeTarget = onResizeTarget
|
||||||
|
controller.onDecodedSize = onDecodedSize
|
||||||
if controller.connection !== connection {
|
if controller.connection !== connection {
|
||||||
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||||
}
|
}
|
||||||
@@ -108,7 +121,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?
|
public private(set) var connection: PunktfunkConnection?
|
||||||
private var observers: [NSObjectProtocol] = []
|
private var observers: [NSObjectProtocol] = []
|
||||||
/// Record the unified latency stages (end-to-end / decode / display) when the stage-2
|
/// Record the unified latency stages (end-to-end / decode / display) when the stage-2
|
||||||
@@ -119,6 +145,11 @@ public final class StreamViewController: UIViewController {
|
|||||||
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
|
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
|
||||||
/// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback.
|
/// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback.
|
||||||
private let presenter = SessionPresenter()
|
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)
|
#if os(iOS)
|
||||||
private var inputCapture: InputCapture?
|
private var inputCapture: InputCapture?
|
||||||
fileprivate var captured = false
|
fileprivate var captured = false
|
||||||
@@ -126,6 +157,12 @@ public final class StreamViewController: UIViewController {
|
|||||||
/// Capture state at the last resign, restored on the next foreground — otherwise the
|
/// 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.
|
/// mouse/keyboard stay released after navigating out and nothing re-grabs them.
|
||||||
private var wasCapturedOnResign = false
|
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
|
#endif
|
||||||
|
|
||||||
/// Reads whether the scene's pointer is actually locked right now; nil = state
|
/// Reads whether the scene's pointer is actually locked right now; nil = state
|
||||||
@@ -140,6 +177,18 @@ public final class StreamViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var onCaptureChange: ((Bool) -> Void)?
|
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 {
|
var captureEnabled = true {
|
||||||
didSet {
|
didSet {
|
||||||
@@ -157,6 +206,12 @@ public final class StreamViewController: UIViewController {
|
|||||||
|
|
||||||
public override func loadView() {
|
public override func loadView() {
|
||||||
view = StreamLayerUIView()
|
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.
|
// 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
|
// 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
|
// viewDidChangeBackingProperties relayout. The handler takes the VC as its argument, so it
|
||||||
@@ -230,6 +285,18 @@ public final class StreamViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
#endif
|
#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(
|
func start(
|
||||||
connection: PunktfunkConnection,
|
connection: PunktfunkConnection,
|
||||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||||
@@ -288,11 +355,25 @@ public final class StreamViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
capture.start()
|
capture.start()
|
||||||
inputCapture = capture
|
inputCapture = capture
|
||||||
|
// Match-window (C3): follow the scene's pixel size — DEFAULT ON, 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).
|
||||||
|
// `?? true` so an unset default matches the Settings toggle (which also defaults on).
|
||||||
|
let follower = MatchWindowFollower(
|
||||||
|
connection: connection,
|
||||||
|
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? true)
|
||||||
|
follower.onResizeTarget = onResizeTarget
|
||||||
|
matchFollower = follower
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Presenter choice + lifecycle live in SessionPresenter (shared with macOS): stage-2
|
// Presenter choice + lifecycle live in SessionPresenter (shared with macOS): stage-2
|
||||||
// (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by
|
// (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by
|
||||||
// default, the stage-1 pump as the Metal-missing / DEBUG fallback.
|
// 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(
|
presenter.start(
|
||||||
connection: connection,
|
connection: connection,
|
||||||
baseLayer: streamView.displayLayer,
|
baseLayer: streamView.displayLayer,
|
||||||
@@ -301,7 +382,11 @@ public final class StreamViewController: UIViewController {
|
|||||||
displayMeter: displayMeter,
|
displayMeter: displayMeter,
|
||||||
makeDisplayLink: { CADisplayLink(target: $0, selector: $1) },
|
makeDisplayLink: { CADisplayLink(target: $0, selector: $1) },
|
||||||
onFrame: onFrame,
|
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()
|
layoutMetalLayer()
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -342,6 +427,19 @@ public final class StreamViewController: UIViewController {
|
|||||||
setCaptured(true) // entering a session is the deliberate "capture me" moment
|
setCaptured(true) // entering a session is the deliberate "capture me" moment
|
||||||
}
|
}
|
||||||
#endif
|
#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() {
|
func stop() {
|
||||||
@@ -359,16 +457,76 @@ public final class StreamViewController: UIViewController {
|
|||||||
streamView.onPointerButton = nil
|
streamView.onPointerButton = nil
|
||||||
streamView.onScroll = nil
|
streamView.onScroll = nil
|
||||||
streamView.currentHostMode = 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
|
#endif
|
||||||
presenter.stop()
|
presenter.stop()
|
||||||
|
lastDecodedContentSize = nil // the next session re-derives it from its first frame
|
||||||
connection = nil
|
connection = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
public override func viewDidLayoutSubviews() {
|
public override func viewDidLayoutSubviews() {
|
||||||
super.viewDidLayoutSubviews()
|
super.viewDidLayoutSubviews()
|
||||||
layoutMetalLayer()
|
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
|
/// 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;
|
/// 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
|
/// `view.contentScaleFactor` can read 1.0 before the view attaches to a window/screen, which
|
||||||
@@ -385,6 +543,18 @@ public final class StreamViewController: UIViewController {
|
|||||||
presenter.layout(in: streamView.bounds, contentsScale: renderScale)
|
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)
|
#if os(iOS)
|
||||||
private func setCaptured(_ on: Bool) {
|
private func setCaptured(_ on: Bool) {
|
||||||
if on {
|
if on {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,16 +88,19 @@ final class LoopbackIntegrationTests: XCTestCase {
|
|||||||
// one feedback burst on the host→client planes — drain both and verify, end to
|
// one feedback burst on the host→client planes — drain both and verify, end to
|
||||||
// end through the xcframework: rumble (0xCA) + the three hidout kinds (0xCD).
|
// end through the xcframework: rumble (0xCA) + the three hidout kinds (0xCD).
|
||||||
if ProcessInfo.processInfo.environment["PUNKTFUNK_TEST_FEEDBACK"] == "1" {
|
if ProcessInfo.processInfo.environment["PUNKTFUNK_TEST_FEEDBACK"] == "1" {
|
||||||
var rumble: (pad: UInt16, low: UInt16, high: UInt16)?
|
var rumble: (pad: UInt16, low: UInt16, high: UInt16, ttlMs: UInt32)?
|
||||||
var hidout: [PunktfunkConnection.HidOutputEvent] = []
|
var hidout: [PunktfunkConnection.HidOutputEvent] = []
|
||||||
let feedbackDeadline = Date().addingTimeInterval(10)
|
let feedbackDeadline = Date().addingTimeInterval(10)
|
||||||
while (rumble == nil || hidout.count < 3), Date() < feedbackDeadline {
|
while (rumble == nil || hidout.count < 3), Date() < feedbackDeadline {
|
||||||
if rumble == nil, let r = try conn.nextRumble(timeoutMs: 100) { rumble = r }
|
if rumble == nil, let r = try conn.nextRumble2(timeoutMs: 100) { rumble = r }
|
||||||
if let ev = try conn.nextHidOutput(timeoutMs: 100) { hidout.append(ev) }
|
if let ev = try conn.nextHidOutput(timeoutMs: 100) { hidout.append(ev) }
|
||||||
}
|
}
|
||||||
XCTAssertEqual(rumble?.pad, 0)
|
XCTAssertEqual(rumble?.pad, 0)
|
||||||
XCTAssertEqual(rumble?.low, 0x4000)
|
XCTAssertEqual(rumble?.low, 0x4000)
|
||||||
XCTAssertEqual(rumble?.high, 0x8000)
|
XCTAssertEqual(rumble?.high, 0x8000)
|
||||||
|
// The synthetic host emits a v2 envelope (400 ms TTL) — assert the self-terminating tail
|
||||||
|
// survived the full wire → C ABI → Swift path, not just the level.
|
||||||
|
XCTAssertEqual(rumble?.ttlMs, 400)
|
||||||
XCTAssertTrue(
|
XCTAssertTrue(
|
||||||
hidout.contains(.led(pad: 0, r: 10, g: 20, b: 30)),
|
hidout.contains(.led(pad: 0, r: 10, g: 20, b: 30)),
|
||||||
"missing the scripted lightbar event: \(hidout)")
|
"missing the scripted lightbar event: \(hidout)")
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// The Match-window trigger discipline (design/midstream-resolution-resize.md D2), as pure
|
||||||
|
// functions — the same rules the session binary's `resize_decision` unit-tests: physical pixels
|
||||||
|
// even-floored and clamped ≥ 320×200, skip a size equal to the live mode, and request each
|
||||||
|
// distinct size at most once (so a rejected size / a host rollback can't loop).
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
@testable import PunktfunkKit
|
||||||
|
|
||||||
|
final class MatchWindowTests: XCTestCase {
|
||||||
|
func testNormalizeEvenFloorsAndClamps() {
|
||||||
|
// Odd pixels floor to even (the host rejects odd dimensions).
|
||||||
|
let a = MatchWindow.normalize(widthPx: 1001, heightPx: 601)
|
||||||
|
XCTAssertEqual(a.width, 1000)
|
||||||
|
XCTAssertEqual(a.height, 600)
|
||||||
|
// Already-even sizes pass through.
|
||||||
|
let b = MatchWindow.normalize(widthPx: 2560, heightPx: 1440)
|
||||||
|
XCTAssertEqual(b.width, 2560)
|
||||||
|
XCTAssertEqual(b.height, 1440)
|
||||||
|
// Tiny / zero clamp to the host floor.
|
||||||
|
let c = MatchWindow.normalize(widthPx: 100, heightPx: 80)
|
||||||
|
XCTAssertEqual(c.width, 320)
|
||||||
|
XCTAssertEqual(c.height, 200)
|
||||||
|
let z = MatchWindow.normalize(widthPx: 0, heightPx: -4)
|
||||||
|
XCTAssertEqual(z.width, 320)
|
||||||
|
XCTAssertEqual(z.height, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRequestSkipsEqualAndAlreadyRequested() {
|
||||||
|
// A new size (different from the live mode, not yet requested) → request it.
|
||||||
|
let r = MatchWindow.request(
|
||||||
|
target: (1000, 600), current: (1280, 720), lastRequested: (800, 500))
|
||||||
|
XCTAssertEqual(r?.width, 1000)
|
||||||
|
XCTAssertEqual(r?.height, 600)
|
||||||
|
// Equal to the live mode → nothing to do.
|
||||||
|
XCTAssertNil(MatchWindow.request(
|
||||||
|
target: (1280, 720), current: (1280, 720), lastRequested: nil))
|
||||||
|
// Already requested once → don't re-ask (covers a rejected size AND a host rollback:
|
||||||
|
// accepted → rebuild failed → corrective ack restored the old mode must not loop).
|
||||||
|
XCTAssertNil(MatchWindow.request(
|
||||||
|
target: (1000, 600), current: (1280, 720), lastRequested: (1000, 600)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
@testable import PunktfunkKit
|
||||||
|
|
||||||
|
final class ResizeIndicatorTests: XCTestCase {
|
||||||
|
func testInactiveUntilSteered() {
|
||||||
|
var r = ResizeIndicator()
|
||||||
|
XCTAssertFalse(r.active)
|
||||||
|
// A decoded frame with nothing pending is a no-op (session start / steady state).
|
||||||
|
r.decoded(width: 1920, height: 1080)
|
||||||
|
XCTAssertFalse(r.active)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSteeringActivatesAndDecodedTargetClears() {
|
||||||
|
var r = ResizeIndicator()
|
||||||
|
r.steering(width: 2560, height: 1440, now: 0)
|
||||||
|
XCTAssertTrue(r.active)
|
||||||
|
// A frame at a DIFFERENT size (the old mode still draining) doesn't clear it.
|
||||||
|
r.decoded(width: 1920, height: 1080)
|
||||||
|
XCTAssertTrue(r.active)
|
||||||
|
// The target frame lands → clear.
|
||||||
|
r.decoded(width: 2560, height: 1440)
|
||||||
|
XCTAssertFalse(r.active)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTimeoutClearsWhenTargetNeverArrives() {
|
||||||
|
var r = ResizeIndicator(timeout: 2.5)
|
||||||
|
r.steering(width: 2560, height: 1440, now: 10)
|
||||||
|
r.tick(now: 12) // 2 s < timeout — still up
|
||||||
|
XCTAssertTrue(r.active)
|
||||||
|
r.tick(now: 12.6) // 2.6 s ≥ timeout — a rejected/capped switch clears
|
||||||
|
XCTAssertFalse(r.active)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDragReArmsTimeoutOnEachNewTarget() {
|
||||||
|
var r = ResizeIndicator(timeout: 2.5)
|
||||||
|
r.steering(width: 2000, height: 1200, now: 0)
|
||||||
|
r.steering(width: 2200, height: 1200, now: 2) // target changed → since re-armed to 2
|
||||||
|
r.tick(now: 4) // only 2 s since the last change — still up (drag isn't a timeout)
|
||||||
|
XCTAssertTrue(r.active)
|
||||||
|
r.tick(now: 4.6) // 2.6 s since the last change → clears
|
||||||
|
XCTAssertFalse(r.active)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSteadyDragDoesNotResetTimeout() {
|
||||||
|
var r = ResizeIndicator(timeout: 2.5)
|
||||||
|
r.steering(width: 2560, height: 1440, now: 0)
|
||||||
|
r.steering(width: 2560, height: 1440, now: 1) // SAME target → since stays 0
|
||||||
|
r.tick(now: 2.6) // 2.6 s since the ORIGINAL steer → clears (not reset by the repeat)
|
||||||
|
XCTAssertFalse(r.active)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,6 +75,40 @@ final class RumbleTuningTests: XCTestCase {
|
|||||||
renderer.stop()
|
renderer.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testLeaseSecondsInterpretsWireTTL() {
|
||||||
|
// The legacy no-lease sentinel → nil (fall back to the staleness watchdog).
|
||||||
|
XCTAssertNil(RumbleTuning.leaseSeconds(ttlMs: RumbleTuning.noTTL))
|
||||||
|
XCTAssertEqual(RumbleTuning.noTTL, UInt32.max)
|
||||||
|
// A real lease → its duration in seconds (non-nil for any ttl != noTTL).
|
||||||
|
XCTAssertEqual(RumbleTuning.leaseSeconds(ttlMs: 400) ?? .nan, 0.4, accuracy: 1e-9)
|
||||||
|
XCTAssertEqual(RumbleTuning.leaseSeconds(ttlMs: 0) ?? .nan, 0, accuracy: 1e-9)
|
||||||
|
XCTAssertEqual(RumbleTuning.leaseSeconds(ttlMs: 150) ?? .nan, 0.15, accuracy: 1e-9)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEnvelopeLeaseBoundsMotorLifeTighterThanTheLegacyWatchdog() {
|
||||||
|
// The whole point of v2: a host-supplied lease silences the motor faster than the
|
||||||
|
// legacy staleness watchdog ever could (which needs sessionStaleSeconds of silence). The
|
||||||
|
// default 400 ms TTL is well under that, on every platform.
|
||||||
|
let defaultTTL = RumbleTuning.leaseSeconds(ttlMs: 400)
|
||||||
|
XCTAssertNotNil(defaultTTL)
|
||||||
|
XCTAssertLessThan(defaultTTL!, RumbleTuning.sessionStaleSeconds)
|
||||||
|
// The ticker must be able to observe an expired lease promptly (well within one TTL).
|
||||||
|
XCTAssertLessThan(RumbleTuning.tickSeconds, defaultTTL!)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A v2 envelope with a short TTL, left unrenewed, must self-silence — the renderer's core
|
||||||
|
/// promise. Drive the real queue/ticker (no physical pad) and confirm it doesn't wedge.
|
||||||
|
func testEnvelopeExpiresWhenUnrenewed() {
|
||||||
|
let renderer = RumbleRenderer(policy: .session)
|
||||||
|
renderer.retarget(nil)
|
||||||
|
// A 100 ms lease, then no renewal — the ticker (50 ms) must silence it on its own.
|
||||||
|
renderer.apply(low: 0x8000, high: 0x8000, ttlMs: 100)
|
||||||
|
Thread.sleep(forTimeInterval: 0.3)
|
||||||
|
// No assertion on private state; this exercises the expiry path + serial-queue teardown
|
||||||
|
// without deadlock (the ticker fires on the same queue stop() sync-hops onto).
|
||||||
|
renderer.stop()
|
||||||
|
}
|
||||||
|
|
||||||
func testTuningRelationsTheDesignDependsOn() {
|
func testTuningRelationsTheDesignDependsOn() {
|
||||||
// The watchdog must tolerate a couple of lost 500 ms host refreshes (heals, not gaps)
|
// The watchdog must tolerate a couple of lost 500 ms host refreshes (heals, not gaps)
|
||||||
// but trip well before a stuck rumble reads as "still going".
|
// but trip well before a stuck rumble reads as "still going".
|
||||||
|
|||||||
@@ -496,7 +496,8 @@ impl AppModel {
|
|||||||
2, // audio_channels: stereo
|
2, // audio_channels: stereo
|
||||||
crate::video::decodable_codecs(), // codecs (unused by the probe, but honest)
|
crate::video::decodable_codecs(), // codecs (unused by the probe, but honest)
|
||||||
0, // preferred_codec: no preference
|
0, // preferred_codec: no preference
|
||||||
None, // launch: probe connect, no game
|
None, // display_hdr: probe connect, nothing presents
|
||||||
|
None, // launch: probe connect, no game
|
||||||
pin,
|
pin,
|
||||||
Some(identity),
|
Some(identity),
|
||||||
std::time::Duration::from_secs(15),
|
std::time::Duration::from_secs(15),
|
||||||
|
|||||||
@@ -264,21 +264,23 @@ pub fn show(
|
|||||||
let page = adw::PreferencesPage::new();
|
let page = adw::PreferencesPage::new();
|
||||||
|
|
||||||
let stream = adw::PreferencesGroup::builder().title("Stream").build();
|
let stream = adw::PreferencesGroup::builder().title("Stream").build();
|
||||||
let res_names: Vec<String> = RESOLUTIONS
|
// The D1 tri-state: Native, Match window (a virtual index 1, stored as the
|
||||||
.iter()
|
// `match_window` flag), then the explicit sizes.
|
||||||
.map(|&(w, h)| {
|
let res_names: Vec<String> = std::iter::once("Native display".to_string())
|
||||||
if w == 0 {
|
.chain(std::iter::once("Match window".to_string()))
|
||||||
"Native display".to_string()
|
.chain(
|
||||||
} else {
|
RESOLUTIONS
|
||||||
format!("{w} × {h}")
|
.iter()
|
||||||
}
|
.skip(1)
|
||||||
})
|
.map(|&(w, h)| format!("{w} × {h}")),
|
||||||
|
)
|
||||||
.collect();
|
.collect();
|
||||||
let res_row = ChoiceRow::new(
|
let res_row = ChoiceRow::new(
|
||||||
&dialog,
|
&dialog,
|
||||||
inline,
|
inline,
|
||||||
"Resolution",
|
"Resolution",
|
||||||
"The host creates a virtual output at exactly this size",
|
"The host creates a virtual output at exactly this size — Match window follows \
|
||||||
|
the stream window, including mid-stream resizes",
|
||||||
&res_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
&res_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||||
);
|
);
|
||||||
let hz_names: Vec<String> = REFRESH
|
let hz_names: Vec<String> = REFRESH
|
||||||
@@ -470,10 +472,15 @@ pub fn show(
|
|||||||
// Seed from the current settings.
|
// Seed from the current settings.
|
||||||
{
|
{
|
||||||
let s = settings.borrow();
|
let s = settings.borrow();
|
||||||
let res_i = RESOLUTIONS
|
let res_i = if s.match_window {
|
||||||
.iter()
|
1
|
||||||
.position(|&(w, h)| w == s.width && h == s.height)
|
} else {
|
||||||
.unwrap_or(0);
|
RESOLUTIONS
|
||||||
|
.iter()
|
||||||
|
.position(|&(w, h)| w == s.width && h == s.height)
|
||||||
|
.map(|i| if i == 0 { 0 } else { i + 1 })
|
||||||
|
.unwrap_or(0)
|
||||||
|
};
|
||||||
res_row.set_selected(res_i as u32);
|
res_row.set_selected(res_i as u32);
|
||||||
let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0);
|
let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0);
|
||||||
hz_row.set_selected(hz_i as u32);
|
hz_row.set_selected(hz_i as u32);
|
||||||
@@ -508,8 +515,14 @@ pub fn show(
|
|||||||
dialog.add(&page);
|
dialog.add(&page);
|
||||||
dialog.connect_closed(move |_| {
|
dialog.connect_closed(move |_| {
|
||||||
let mut s = settings.borrow_mut();
|
let mut s = settings.borrow_mut();
|
||||||
let (w, h) = RESOLUTIONS[(res_row.selected() as usize).min(RESOLUTIONS.len() - 1)];
|
// Index 1 is the virtual "Match window" option; 0 = Native, 2.. = explicit.
|
||||||
(s.width, s.height) = (w, h);
|
let res_i = (res_row.selected() as usize).min(RESOLUTIONS.len());
|
||||||
|
s.match_window = res_i == 1;
|
||||||
|
(s.width, s.height) = if res_i <= 1 {
|
||||||
|
(0, 0)
|
||||||
|
} else {
|
||||||
|
RESOLUTIONS[res_i - 1]
|
||||||
|
};
|
||||||
s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)];
|
s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)];
|
||||||
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
|
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
|
||||||
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
|
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
|
||||||
|
|||||||
@@ -111,6 +111,11 @@ struct Args {
|
|||||||
/// `--discover [SECS]` — browse the LAN for native (`_punktfunk._udp`) hosts for `SECS`
|
/// `--discover [SECS]` — browse the LAN for native (`_punktfunk._udp`) hosts for `SECS`
|
||||||
/// seconds (default 4), print what's found, and exit. No connection is made.
|
/// seconds (default 4), print what's found, and exit. No connection is made.
|
||||||
discover: Option<u64>,
|
discover: Option<u64>,
|
||||||
|
/// `--clock-resync` — after the connect-time skew handshake, immediately run a SECOND
|
||||||
|
/// handshake on the same control stream and assert both estimates are sane and consistent:
|
||||||
|
/// the headless validator for the host answering `ClockProbe` at any time (what the native
|
||||||
|
/// clients' mid-stream re-sync relies on). Aborts the session when the re-probe fails.
|
||||||
|
clock_resync: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_mode(m: &str) -> Option<Mode> {
|
fn parse_mode(m: &str) -> Option<Mode> {
|
||||||
@@ -274,6 +279,7 @@ fn parse_args() -> Args {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|a| a == "--discover")
|
.any(|a| a == "--discover")
|
||||||
.then(|| get("--discover").and_then(|s| s.parse().ok()).unwrap_or(4)),
|
.then(|| get("--discover").and_then(|s| s.parse().ok()).unwrap_or(4)),
|
||||||
|
clock_resync: argv.iter().any(|a| a == "--clock-resync"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,6 +491,10 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
| punktfunk_core::quic::CODEC_AV1,
|
| punktfunk_core::quic::CODEC_AV1,
|
||||||
// `--codec` soft preference (0 = auto). The host honors it when it can emit it.
|
// `--codec` soft preference (0 = auto). The host honors it when it can emit it.
|
||||||
preferred_codec: args.preferred_codec,
|
preferred_codec: args.preferred_codec,
|
||||||
|
// PUNKTFUNK_CLIENT_PEAK_NITS=<nits> advertises a synthetic display volume — the host
|
||||||
|
// writes it into the virtual display's EDID (CTA HDR block), so the EDID-forwarding
|
||||||
|
// path can be validated headlessly (check the host's monitor caps / ADD log line).
|
||||||
|
display_hdr: punktfunk_core::client::display_hdr_env_override(),
|
||||||
}
|
}
|
||||||
.encode(),
|
.encode(),
|
||||||
)
|
)
|
||||||
@@ -523,7 +533,8 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
// Wall-clock skew handshake on the still-private control stream (before --remode/--speed-test
|
// Wall-clock skew handshake on the still-private control stream (before --remode/--speed-test
|
||||||
// take it): align our clock to the host's so the per-frame capture→received latency is valid
|
// take it): align our clock to the host's so the per-frame capture→received latency is valid
|
||||||
// across machines. `None` ⇒ an old host that doesn't answer — fall back to a shared clock (0).
|
// across machines. `None` ⇒ an old host that doesn't answer — fall back to a shared clock (0).
|
||||||
let clock_offset_ns = match punktfunk_core::quic::clock_sync(&mut send, &mut recv).await {
|
let first_skew = punktfunk_core::quic::clock_sync(&mut send, &mut recv).await;
|
||||||
|
let clock_offset_ns = match &first_skew {
|
||||||
Some(skew) => {
|
Some(skew) => {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
offset_ns = skew.offset_ns,
|
offset_ns = skew.offset_ns,
|
||||||
@@ -536,6 +547,42 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// `--clock-resync`: prove the host answers `ClockProbe` mid-session, not just at connect —
|
||||||
|
// the contract the native clients' mid-stream re-sync rests on. Run a full second handshake
|
||||||
|
// and require a sane, consistent estimate: both batches measure the same physical skew, so
|
||||||
|
// they must agree to within RTT-scale error (the handshake's own uncertainty is ≈ RTT/2).
|
||||||
|
if args.clock_resync {
|
||||||
|
let first = first_skew.as_ref().ok_or_else(|| {
|
||||||
|
anyhow!("clock-resync: host never answered the connect-time handshake")
|
||||||
|
})?;
|
||||||
|
let second = punktfunk_core::quic::clock_sync(&mut send, &mut recv)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow!("clock-resync: host did not answer the re-probe"))?;
|
||||||
|
let disagree_ns = (second.offset_ns - first.offset_ns).unsigned_abs();
|
||||||
|
let bound_ns = (first.rtt_ns + second.rtt_ns).max(2_000_000);
|
||||||
|
tracing::info!(
|
||||||
|
first_offset_ns = first.offset_ns,
|
||||||
|
second_offset_ns = second.offset_ns,
|
||||||
|
disagree_us = disagree_ns / 1000,
|
||||||
|
bound_us = bound_ns / 1000,
|
||||||
|
second_rtt_us = second.rtt_ns / 1000,
|
||||||
|
rounds = second.rounds,
|
||||||
|
"clock re-probe answered"
|
||||||
|
);
|
||||||
|
if second.rounds < 8 || disagree_ns > bound_ns {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"clock-resync: re-probe unsound (rounds {}, disagreement {} µs > bound {} µs)",
|
||||||
|
second.rounds,
|
||||||
|
disagree_ns / 1000,
|
||||||
|
bound_ns / 1000
|
||||||
|
));
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"clock-resync OK: offsets {} / {} ns",
|
||||||
|
first.offset_ns, second.offset_ns
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Packet-level receive counters mirrored from `session.stats()` by the data-plane loop. The
|
// Packet-level receive counters mirrored from `session.stats()` by the data-plane loop. The
|
||||||
// speed test reads their delta over the burst window so throughput/loss reflect every delivered
|
// speed test reads their delta over the burst window so throughput/loss reflect every delivered
|
||||||
// wire packet (graceful past the FEC budget), not just fully-reassembled probe AUs.
|
// wire packet (graceful past the FEC budget), not just fully-reassembled probe AUs.
|
||||||
@@ -960,6 +1007,10 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
let audio_bytes = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
let audio_bytes = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||||
let rumble_pkts = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
let rumble_pkts = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||||
let hidout_pkts = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
let hidout_pkts = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||||
|
// Set when a self-terminating v2 rumble envelope (0xCA with the seq+ttl tail) arrives — the
|
||||||
|
// Rust-side contract check for `PUNKTFUNK_TEST_FEEDBACK` (asserted at report time). A legacy v1
|
||||||
|
// datagram leaves it false, so this only ever fails when a v2 tail we EXPECTED went missing.
|
||||||
|
let saw_v2_rumble = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||||
// Per-AU host timings (0xCF) → the stream loop, which matches them to received AUs by pts
|
// Per-AU host timings (0xCF) → the stream loop, which matches them to received AUs by pts
|
||||||
// and reports the host/network split. try_send: overflow drops samples, never blocks QUIC.
|
// and reports the host/network split. try_send: overflow drops samples, never blocks QUIC.
|
||||||
let (host_timing_tx, host_timing_rx) =
|
let (host_timing_tx, host_timing_rx) =
|
||||||
@@ -971,6 +1022,7 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
rumble_pkts.clone(),
|
rumble_pkts.clone(),
|
||||||
hidout_pkts.clone(),
|
hidout_pkts.clone(),
|
||||||
);
|
);
|
||||||
|
let saw_v2 = saw_v2_rumble.clone();
|
||||||
let ht_tx = host_timing_tx;
|
let ht_tx = host_timing_tx;
|
||||||
let conn2 = conn.clone();
|
let conn2 = conn.clone();
|
||||||
// Build a multistream decoder for the host-RESOLVED layout so the probe actually decodes
|
// Build a multistream decoder for the host-RESOLVED layout so the probe actually decodes
|
||||||
@@ -979,6 +1031,7 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
use std::sync::atomic::Ordering::Relaxed;
|
use std::sync::atomic::Ordering::Relaxed;
|
||||||
let mut hdr_logged = false;
|
let mut hdr_logged = false;
|
||||||
|
let mut rumble_logged = false;
|
||||||
let layout = punktfunk_core::audio::layout_for(audio_channels, false);
|
let layout = punktfunk_core::audio::layout_for(audio_channels, false);
|
||||||
let mut audio_dec =
|
let mut audio_dec =
|
||||||
opus::MSDecoder::new(48_000, layout.streams, layout.coupled, layout.mapping).ok();
|
opus::MSDecoder::new(48_000, layout.streams, layout.coupled, layout.mapping).ok();
|
||||||
@@ -1004,7 +1057,24 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
Err(e) => tracing::debug!(error = %e, "probe audio decode"),
|
Err(e) => tracing::debug!(error = %e, "probe audio decode"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if punktfunk_core::quic::decode_rumble_datagram(&d).is_some() {
|
} else if let Some(u) = punktfunk_core::quic::decode_rumble_envelope(&d) {
|
||||||
|
// Log the first rumble so a loopback test can see the self-terminating v2
|
||||||
|
// envelope tail (seq + TTL) arrived, not just the level.
|
||||||
|
if !rumble_logged {
|
||||||
|
rumble_logged = true;
|
||||||
|
tracing::info!(
|
||||||
|
pad = u.pad,
|
||||||
|
low = u.low,
|
||||||
|
high = u.high,
|
||||||
|
envelope = ?u.envelope,
|
||||||
|
"rumble (0xCA)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Record that a v2 tail was present — the Rust-side seq/ttl contract check for
|
||||||
|
// PUNKTFUNK_TEST_FEEDBACK (asserted at report time).
|
||||||
|
if u.envelope.is_some() {
|
||||||
|
saw_v2.store(true, Relaxed);
|
||||||
|
}
|
||||||
r.fetch_add(1, Relaxed);
|
r.fetch_add(1, Relaxed);
|
||||||
} else if let Some(meta) = punktfunk_core::quic::decode_hdr_meta_datagram(&d) {
|
} else if let Some(meta) = punktfunk_core::quic::decode_hdr_meta_datagram(&d) {
|
||||||
// HDR static metadata (0xCE). Log the first receipt so a loopback test can
|
// HDR static metadata (0xCE). Log the first receipt so a loopback test can
|
||||||
@@ -1245,6 +1315,23 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rust-side rumble-envelope contract check: when the host was told to script a feedback burst
|
||||||
|
// (PUNKTFUNK_TEST_FEEDBACK, shared by a loopback harness), fail if no self-terminating v2 tail
|
||||||
|
// (seq + TTL) arrived — a regression that reverted the host to v1 level datagrams increments the
|
||||||
|
// rumble counter identically and would otherwise pass silently. Only tightens an otherwise-OK run
|
||||||
|
// (a video failure stays the primary error). The level + hidout planes are asserted end-to-end by
|
||||||
|
// the Apple loopback; this covers the seq/ttl tail on the Rust/Linux path.
|
||||||
|
let result = if std::env::var("PUNKTFUNK_TEST_FEEDBACK").as_deref() == Ok("1")
|
||||||
|
&& result.is_ok()
|
||||||
|
&& !saw_v2_rumble.load(std::sync::atomic::Ordering::Relaxed)
|
||||||
|
{
|
||||||
|
Err(anyhow::anyhow!(
|
||||||
|
"PUNKTFUNK_TEST_FEEDBACK: expected a v2 rumble envelope (0xCA seq+ttl tail), received none"
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
result
|
||||||
|
};
|
||||||
|
|
||||||
// `--quit` closes with the deliberate-quit code so the host skips the keep-alive linger; a normal
|
// `--quit` closes with the deliberate-quit code so the host skips the keep-alive linger; a normal
|
||||||
// exit uses code 0 (an unwanted-disconnect close → the host lingers for a reconnect).
|
// exit uses code 0 (an unwanted-disconnect close → the host lingers for a reconnect).
|
||||||
let close_code = if args.quit {
|
let close_code = if args.quit {
|
||||||
|
|||||||
@@ -145,6 +145,10 @@ pub fn run(target: Option<&str>) -> u8 {
|
|||||||
trust::touch_last_used(&trust::hex(&fingerprint));
|
trust::touch_last_used(&trust::hex(&fingerprint));
|
||||||
})),
|
})),
|
||||||
overlay: Some(Box::new(overlay)),
|
overlay: Some(Box::new(overlay)),
|
||||||
|
window_size: crate::session_main::window_size(&settings_at_start),
|
||||||
|
// Latched at console start (like the stats tier above): toggling Match window in
|
||||||
|
// the console's settings screen applies from the next console launch.
|
||||||
|
match_window: crate::session_main::match_window(&settings_at_start),
|
||||||
};
|
};
|
||||||
|
|
||||||
let result =
|
let result =
|
||||||
|
|||||||
@@ -146,6 +146,10 @@ mod session_main {
|
|||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
},
|
},
|
||||||
|
// No portable Wayland/X11 display-volume query yet, so the host keeps its EDID
|
||||||
|
// defaults for Linux clients; `PUNKTFUNK_CLIENT_PEAK_NITS` (read in the session
|
||||||
|
// pump) pins one manually.
|
||||||
|
display_hdr: None,
|
||||||
mic_enabled: settings.mic_enabled,
|
mic_enabled: settings.mic_enabled,
|
||||||
// The Settings preference (auto → VAAPI where it exists; the presenter
|
// The Settings preference (auto → VAAPI where it exists; the presenter
|
||||||
// demotes to software on boxes whose Vulkan can't import the dmabufs).
|
// demotes to software on boxes whose Vulkan can't import the dmabufs).
|
||||||
@@ -160,6 +164,32 @@ mod session_main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The window's starting size under Match-window: the persisted last size, so the
|
||||||
|
/// first connect's mode already matches the glass; `None` (policy off / never
|
||||||
|
/// stored) = the 1280×720 default.
|
||||||
|
pub(crate) fn window_size(settings: &trust::Settings) -> Option<(u32, u32)> {
|
||||||
|
(settings.match_window && settings.last_window_w > 0 && settings.last_window_h > 0)
|
||||||
|
.then_some((settings.last_window_w, settings.last_window_h))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Match-window policy hook for the presenter loop
|
||||||
|
/// (design/midstream-resolution-resize.md D1/D2): `Some(persist)` turns the
|
||||||
|
/// debounced resize→`Reconfigure` machinery on; the callback stores each resize-end's
|
||||||
|
/// logical window size (load-modify-save, like the console settings screen) so the
|
||||||
|
/// next launch opens at it.
|
||||||
|
pub(crate) fn match_window(settings: &trust::Settings) -> Option<Box<dyn FnMut(u32, u32)>> {
|
||||||
|
settings.match_window.then(|| {
|
||||||
|
Box::new(|w: u32, h: u32| {
|
||||||
|
let mut s = trust::Settings::load();
|
||||||
|
if (s.last_window_w, s.last_window_h) != (w, h) {
|
||||||
|
s.last_window_w = w;
|
||||||
|
s.last_window_h = h;
|
||||||
|
s.save();
|
||||||
|
}
|
||||||
|
}) as Box<dyn FnMut(u32, u32)>
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// One JSON status line on stdout (the shell parses these; strings hand-escaped via
|
/// One JSON status line on stdout (the shell parses these; strings hand-escaped via
|
||||||
/// the minimal rules a reason string can need). `pub(crate)`: browse mode emits its
|
/// the minimal rules a reason string can need). `pub(crate)`: browse mode emits its
|
||||||
/// failure through the same contract when spawned with `--json-status`.
|
/// failure through the same contract when spawned with `--json-status`.
|
||||||
@@ -339,6 +369,8 @@ mod session_main {
|
|||||||
overlay: Some(Box::new(pf_console_ui::SkiaOverlay::new())),
|
overlay: Some(Box::new(pf_console_ui::SkiaOverlay::new())),
|
||||||
#[cfg(not(feature = "ui"))]
|
#[cfg(not(feature = "ui"))]
|
||||||
overlay: None,
|
overlay: None,
|
||||||
|
window_size: window_size(&settings),
|
||||||
|
match_window: match_window(&settings),
|
||||||
};
|
};
|
||||||
|
|
||||||
let outcome =
|
let outcome =
|
||||||
|
|||||||
@@ -189,14 +189,21 @@ fn status_row(online: Option<bool>, badge: &str, kind: Pill) -> Element {
|
|||||||
|
|
||||||
/// The in-tile rename editor (ContentDialog can't hold a text field): name box + save/cancel.
|
/// The in-tile rename editor (ContentDialog can't hold a text field): name box + save/cancel.
|
||||||
/// No tap-to-connect while editing — a click into the box would bubble `Tapped` to the region.
|
/// No tap-to-connect while editing — a click into the box would bubble `Tapped` to the region.
|
||||||
|
/// `initial` seeds the text box's displayed value and is CONSTANT for the life of the edit — the
|
||||||
|
/// field is uncontrolled, its live value kept in `live` (read at Save). Driving a *controlled* box
|
||||||
|
/// from an always-deferred `AsyncSetState` round-trip fights the caret on fast typing and can drop
|
||||||
|
/// the last char if Save is clicked before the write lands; an uncontrolled box + a ref sidesteps
|
||||||
|
/// both (and skips a full-page re-render per keystroke). See the seed block in `hosts_page`.
|
||||||
fn rename_editor(
|
fn rename_editor(
|
||||||
draft: &str,
|
initial: &str,
|
||||||
fp: String,
|
fp: String,
|
||||||
|
live: HookRef<String>,
|
||||||
set_rename: AsyncSetState<Option<(String, String)>>,
|
set_rename: AsyncSetState<Option<(String, String)>>,
|
||||||
) -> Element {
|
) -> Element {
|
||||||
let commit = {
|
let commit = {
|
||||||
let (fp, draft, sr) = (fp.clone(), draft.to_string(), set_rename.clone());
|
let (fp, live, sr) = (fp.clone(), live.clone(), set_rename.clone());
|
||||||
move || {
|
move || {
|
||||||
|
let draft = live.borrow();
|
||||||
let name = draft.trim();
|
let name = draft.trim();
|
||||||
if !name.is_empty() {
|
if !name.is_empty() {
|
||||||
let mut known = KnownHosts::load();
|
let mut known = KnownHosts::load();
|
||||||
@@ -209,12 +216,12 @@ fn rename_editor(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let on_changed = {
|
let on_changed = {
|
||||||
let sr = set_rename.clone();
|
let live = live.clone();
|
||||||
move |s: String| sr.call(Some((fp.clone(), s)))
|
move |s: String| live.set(s)
|
||||||
};
|
};
|
||||||
card(
|
card(
|
||||||
vstack((
|
vstack((
|
||||||
text_box(draft)
|
text_box(initial)
|
||||||
.placeholder_text("Host name")
|
.placeholder_text("Host name")
|
||||||
.on_text_changed(on_changed),
|
.on_text_changed(on_changed),
|
||||||
hstack((
|
hstack((
|
||||||
@@ -240,6 +247,14 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
let set_screen = &props.svc.set_screen;
|
let set_screen = &props.svc.set_screen;
|
||||||
let set_status = &props.svc.set_status;
|
let set_status = &props.svc.set_status;
|
||||||
let (manual, set_manual) = cx.use_state(String::new());
|
let (manual, set_manual) = cx.use_state(String::new());
|
||||||
|
// The Add-host field's live value, read by Connect at click time. This page's `use_state` is
|
||||||
|
// unreliable as the click's source of truth: while the modal is open the page usually has no
|
||||||
|
// reason to re-render (you open it precisely because the host ISN'T being discovered, so no
|
||||||
|
// discovery tick fires), and the top-down reconcile skips this unchanged-props subtree — so a
|
||||||
|
// sync `set_manual` write never re-renders the Connect button to re-capture the address, and it
|
||||||
|
// would connect to the empty mount-time value. Mirror every keystroke into this stable ref (the
|
||||||
|
// pair-screen PIN pattern). `manual` still drives the text box's displayed value.
|
||||||
|
let manual_live = cx.use_ref(String::new());
|
||||||
// "Add host" modal open state lives in ROOT (see `HostsProps`).
|
// "Add host" modal open state lives in ROOT (see `HostsProps`).
|
||||||
let show_add = props.show_add;
|
let show_add = props.show_add;
|
||||||
let set_show_add = &props.set_show_add;
|
let set_show_add = &props.set_show_add;
|
||||||
@@ -249,6 +264,18 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
let rename = props.rename.clone();
|
let rename = props.rename.clone();
|
||||||
let set_forget = &props.set_forget;
|
let set_forget = &props.set_forget;
|
||||||
let set_rename = &props.set_rename;
|
let set_rename = &props.set_rename;
|
||||||
|
// The live rename draft, read at Save time (see `rename_editor`). Root `rename` carries only the
|
||||||
|
// INITIAL name, so it no longer round-trips per keystroke. Seed the draft each time the rename
|
||||||
|
// TARGET changes (start, cancel, or a switch to another host).
|
||||||
|
let rename_draft = cx.use_ref(String::new());
|
||||||
|
let rename_seed = cx.use_ref(Option::<String>::None);
|
||||||
|
{
|
||||||
|
let active = rename.as_ref().map(|(fp, _)| fp.clone());
|
||||||
|
if *rename_seed.borrow() != active {
|
||||||
|
rename_draft.set(rename.as_ref().map(|(_, n)| n.clone()).unwrap_or_default());
|
||||||
|
rename_seed.set(active);
|
||||||
|
}
|
||||||
|
}
|
||||||
let hover = Hover {
|
let hover = Hover {
|
||||||
current: props.hover.clone(),
|
current: props.hover.clone(),
|
||||||
set: props.set_hover.clone(),
|
set: props.set_hover.clone(),
|
||||||
@@ -393,8 +420,13 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
for k in &known.hosts {
|
for k in &known.hosts {
|
||||||
// Rust 2021 (no let-chains): match the "this tile is being renamed" case explicitly.
|
// Rust 2021 (no let-chains): match the "this tile is being renamed" case explicitly.
|
||||||
if matches!(&rename, Some((fp, _)) if fp == &k.fp_hex) {
|
if matches!(&rename, Some((fp, _)) if fp == &k.fp_hex) {
|
||||||
let (fp, draft) = rename.clone().unwrap();
|
let (fp, initial) = rename.clone().unwrap();
|
||||||
tiles.push(rename_editor(&draft, fp, set_rename.clone()));
|
tiles.push(rename_editor(
|
||||||
|
&initial,
|
||||||
|
fp,
|
||||||
|
rename_draft.clone(),
|
||||||
|
set_rename.clone(),
|
||||||
|
));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let target = Target {
|
let target = Target {
|
||||||
@@ -595,14 +627,15 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
// field). The scrim border fills the cell and is hit-testable, so it blocks the page behind;
|
// field). The scrim border fills the cell and is hit-testable, so it blocks the page behind;
|
||||||
// it closes only via Cancel/Connect (a scrim tap would bubble `Tapped` up from the card too).
|
// it closes only via Cancel/Connect (a scrim tap would bubble `Tapped` up from the card too).
|
||||||
let connect_manual = {
|
let connect_manual = {
|
||||||
let (ctx2, ss, st, text, sa) = (
|
let (ctx2, ss, st, live, sa) = (
|
||||||
ctx.clone(),
|
ctx.clone(),
|
||||||
set_screen.clone(),
|
set_screen.clone(),
|
||||||
set_status.clone(),
|
set_status.clone(),
|
||||||
manual.clone(),
|
manual_live.clone(),
|
||||||
set_show_add.clone(),
|
set_show_add.clone(),
|
||||||
);
|
);
|
||||||
move || {
|
move || {
|
||||||
|
let text = live.borrow();
|
||||||
let text = text.trim();
|
let text = text.trim();
|
||||||
if text.is_empty() {
|
if text.is_empty() {
|
||||||
return;
|
return;
|
||||||
@@ -640,7 +673,13 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
text_box(manual)
|
text_box(manual)
|
||||||
.header("Address")
|
.header("Address")
|
||||||
.placeholder_text("192.168.1.20 or my-pc.local")
|
.placeholder_text("192.168.1.20 or my-pc.local")
|
||||||
.on_text_changed(move |s| set_manual.call(s))
|
.on_text_changed({
|
||||||
|
let live = manual_live.clone();
|
||||||
|
move |s: String| {
|
||||||
|
live.set(s.clone());
|
||||||
|
set_manual.call(s);
|
||||||
|
}
|
||||||
|
})
|
||||||
.margin(edges(0.0, 6.0, 0.0, 0.0)),
|
.margin(edges(0.0, 6.0, 0.0, 0.0)),
|
||||||
hstack((
|
hstack((
|
||||||
button("Connect")
|
button("Connect")
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ pub(crate) fn licenses_page(set_screen: &AsyncSetState<Screen>) -> Element {
|
|||||||
|
|
||||||
let app_card = card(
|
let app_card = card(
|
||||||
vstack((
|
vstack((
|
||||||
text_block("punktfunk").font_size(15.0).semibold(),
|
text_block("Punktfunk").font_size(15.0).semibold(),
|
||||||
text_block("Licensed under MIT OR Apache-2.0, at your option.")
|
text_block("Licensed under MIT OR Apache-2.0, at your option.")
|
||||||
.font_size(12.0)
|
.font_size(12.0)
|
||||||
.wrap()
|
.wrap()
|
||||||
|
|||||||
@@ -14,21 +14,28 @@ pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
|
|||||||
let set_screen = &props.set_screen;
|
let set_screen = &props.set_screen;
|
||||||
let set_status = &props.set_status;
|
let set_status = &props.set_status;
|
||||||
let (code, set_code) = cx.use_state(String::new());
|
let (code, set_code) = cx.use_state(String::new());
|
||||||
|
// The PIN's live value, read directly by the click handler. This page's props (`Svc`) never
|
||||||
|
// change, and root wraps every screen in an animated `border` that compares equal once the
|
||||||
|
// entrance tween settles — so the top-down reconcile `can_skip_update`s this subtree and never
|
||||||
|
// re-renders the pair component off its *local* `use_state`. A button rebuilt only at mount
|
||||||
|
// would forever capture the empty mount-time PIN (pairing then fails as a "wrong PIN"). Mirror
|
||||||
|
// every keystroke into this stable ref instead, so the click reads exactly what was typed.
|
||||||
|
let live_pin = cx.use_ref(String::new());
|
||||||
let target = ctx.shared.target.lock().unwrap().clone();
|
let target = ctx.shared.target.lock().unwrap().clone();
|
||||||
|
|
||||||
let pair_btn = {
|
let pair_btn = {
|
||||||
let (ctx2, ss, st, code2, target2) = (
|
let (ctx2, ss, st, live, target2) = (
|
||||||
ctx.clone(),
|
ctx.clone(),
|
||||||
set_screen.clone(),
|
set_screen.clone(),
|
||||||
set_status.clone(),
|
set_status.clone(),
|
||||||
code.clone(),
|
live_pin.clone(),
|
||||||
target.clone(),
|
target.clone(),
|
||||||
);
|
);
|
||||||
button("Pair & Connect")
|
button("Pair & Connect")
|
||||||
.accent()
|
.accent()
|
||||||
.icon(Symbol::Accept)
|
.icon(Symbol::Accept)
|
||||||
.on_click(move || {
|
.on_click(move || {
|
||||||
let pin = code2.trim().to_string();
|
let pin = live.borrow().trim().to_string();
|
||||||
let (ctx3, ss, st, target3) =
|
let (ctx3, ss, st, target3) =
|
||||||
(ctx2.clone(), ss.clone(), st.clone(), target2.clone());
|
(ctx2.clone(), ss.clone(), st.clone(), target2.clone());
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
@@ -109,7 +116,16 @@ pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
|
|||||||
text_box(code)
|
text_box(code)
|
||||||
.placeholder_text("PIN")
|
.placeholder_text("PIN")
|
||||||
.font_size(28.0)
|
.font_size(28.0)
|
||||||
.on_text_changed(move |s| set_code.call(s)),
|
.on_text_changed({
|
||||||
|
let live = live_pin.clone();
|
||||||
|
move |s: String| {
|
||||||
|
// Record the live value for the click handler (the source of truth for the
|
||||||
|
// PIN), and mirror it into `code` so the field stays correct if anything ever
|
||||||
|
// does re-render this page (theme/DPI change).
|
||||||
|
live.set(s.clone());
|
||||||
|
set_code.call(s);
|
||||||
|
}
|
||||||
|
}),
|
||||||
hstack((pair_btn, cancel_btn)).spacing(8.0),
|
hstack((pair_btn, cancel_btn)).spacing(8.0),
|
||||||
text_block(
|
text_block(
|
||||||
"Don\u{2019}t have a PIN? Request access instead and approve this device on the host \
|
"Don\u{2019}t have a PIN? Request access instead and approve this device on the host \
|
||||||
|
|||||||
@@ -136,29 +136,37 @@ pub(crate) fn settings_page(
|
|||||||
let s = ctx.settings.lock().unwrap().clone();
|
let s = ctx.settings.lock().unwrap().clone();
|
||||||
|
|
||||||
// --- Display ---------------------------------------------------------------------------
|
// --- Display ---------------------------------------------------------------------------
|
||||||
|
// The D1 tri-state: Native, Match window (a virtual index 1, stored as the
|
||||||
|
// `match_window` flag), then the explicit sizes.
|
||||||
let (res_names, res_i) = {
|
let (res_names, res_i) = {
|
||||||
let names: Vec<String> = RESOLUTIONS
|
let names: Vec<String> = std::iter::once("Native display".to_string())
|
||||||
.iter()
|
.chain(std::iter::once("Match window".to_string()))
|
||||||
.map(|&(w, h)| {
|
.chain(
|
||||||
if w == 0 {
|
RESOLUTIONS
|
||||||
"Native display".into()
|
.iter()
|
||||||
} else {
|
.skip(1)
|
||||||
format!("{w} \u{00D7} {h}")
|
.map(|&(w, h)| format!("{w} \u{00D7} {h}")),
|
||||||
}
|
)
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
let i = RESOLUTIONS
|
let i = if s.match_window {
|
||||||
.iter()
|
1
|
||||||
.position(|&(w, h)| w == s.width && h == s.height)
|
} else {
|
||||||
.unwrap_or(0);
|
RESOLUTIONS
|
||||||
|
.iter()
|
||||||
|
.position(|&(w, h)| w == s.width && h == s.height)
|
||||||
|
.map(|i| if i == 0 { 0 } else { i + 1 })
|
||||||
|
.unwrap_or(0)
|
||||||
|
};
|
||||||
(names, i)
|
(names, i)
|
||||||
};
|
};
|
||||||
let res_combo = setting_combo(ctx, "Resolution", res_names, res_i, |s, i| {
|
let res_combo = setting_combo(ctx, "Resolution", res_names, res_i, |s, i| {
|
||||||
(s.width, s.height) = RESOLUTIONS[i];
|
s.match_window = i == 1;
|
||||||
|
(s.width, s.height) = if i <= 1 { (0, 0) } else { RESOLUTIONS[i - 1] };
|
||||||
})
|
})
|
||||||
.tooltip(
|
.tooltip(
|
||||||
"The host creates a virtual display at exactly this size. \u{201C}Native display\u{201D} \
|
"The host creates a virtual display at exactly this size. \u{201C}Native display\u{201D} \
|
||||||
resolves to the monitor this window is on at connect.",
|
resolves to the monitor this window is on at connect; \u{201C}Match window\u{201D} \
|
||||||
|
follows the stream window, including mid-stream resizes.",
|
||||||
);
|
);
|
||||||
let (hz_names, hz_i) = {
|
let (hz_names, hz_i) = {
|
||||||
let names: Vec<String> = REFRESH
|
let names: Vec<String> = REFRESH
|
||||||
@@ -361,6 +369,16 @@ pub(crate) fn settings_page(
|
|||||||
"Adds \u{201C}Browse library\u{2026}\u{201D} to paired hosts \u{2014} pick a game and it \
|
"Adds \u{201C}Browse library\u{2026}\u{201D} to paired hosts \u{2014} pick a game and it \
|
||||||
launches in the stream. Mirrors the Apple client's toggle.",
|
launches in the stream. Mirrors the Apple client's toggle.",
|
||||||
);
|
);
|
||||||
|
// App identity + version at the top of the About card (the WinUI Settings convention; the About
|
||||||
|
// screen previously showed no version at all). CARGO_PKG_VERSION is the workspace version, baked
|
||||||
|
// in at compile time.
|
||||||
|
let about_identity = vstack((
|
||||||
|
text_block("Punktfunk").font_size(20.0).semibold(),
|
||||||
|
text_block(concat!("Version ", env!("CARGO_PKG_VERSION")))
|
||||||
|
.font_size(12.0)
|
||||||
|
.foreground(ThemeRef::SecondaryText),
|
||||||
|
))
|
||||||
|
.spacing(2.0);
|
||||||
|
|
||||||
// The selected section's content — per-control guidance lives on hover tooltips, so the
|
// The selected section's content — per-control guidance lives on hover tooltips, so the
|
||||||
// card is just the controls.
|
// card is just the controls.
|
||||||
@@ -395,7 +413,11 @@ pub(crate) fn settings_page(
|
|||||||
),
|
),
|
||||||
"about" => (
|
"about" => (
|
||||||
"About",
|
"About",
|
||||||
settings_card(vec![library_toggle.into(), licenses_button.into()]),
|
settings_card(vec![
|
||||||
|
about_identity.into(),
|
||||||
|
library_toggle.into(),
|
||||||
|
licenses_button.into(),
|
||||||
|
]),
|
||||||
),
|
),
|
||||||
_ => (
|
_ => (
|
||||||
"Display",
|
"Display",
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ impl PartialEq for StreamProps {
|
|||||||
thread_local! {
|
thread_local! {
|
||||||
/// Frames + host clock offset, stashed by the mount effect for `on_mounted` (which fires
|
/// Frames + host clock offset, stashed by the mount effect for `on_mounted` (which fires
|
||||||
/// later, once the native panel exists).
|
/// later, once the native panel exists).
|
||||||
static PENDING: RefCell<Option<(crate::session::FrameRx, i64)>> = const { RefCell::new(None) };
|
static PENDING: RefCell<Option<(crate::session::FrameRx, std::sync::Arc<std::sync::atomic::AtomicI64>)>> = const { RefCell::new(None) };
|
||||||
/// The live render thread; stopped + joined by the unmount cleanup (before panel teardown).
|
/// The live render thread; stopped + joined by the unmount cleanup (before panel teardown).
|
||||||
static RENDER: RefCell<Option<RenderThread>> = const { RefCell::new(None) };
|
static RENDER: RefCell<Option<RenderThread>> = const { RefCell::new(None) };
|
||||||
}
|
}
|
||||||
@@ -88,7 +88,7 @@ pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element {
|
|||||||
move || {
|
move || {
|
||||||
if let Some((connector, frames, stop)) = shared.handoff.lock().unwrap().take() {
|
if let Some((connector, frames, stop)) = shared.handoff.lock().unwrap().take() {
|
||||||
let mode = connector.mode();
|
let mode = connector.mode();
|
||||||
let clock_offset = connector.clock_offset_ns;
|
let clock_offset = connector.clock_offset_shared();
|
||||||
connector_ref.set(Some(connector.clone()));
|
connector_ref.set(Some(connector.clone()));
|
||||||
PENDING.with(|c| *c.borrow_mut() = Some((frames, clock_offset)));
|
PENDING.with(|c| *c.borrow_mut() = Some((frames, clock_offset)));
|
||||||
crate::input::install(connector, mode, inhibit, show_stats, stop);
|
crate::input::install(connector, mode, inhibit, show_stats, stop);
|
||||||
|
|||||||
@@ -607,18 +607,22 @@ fn run(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Feedback planes (this thread is their single consumer). The host re-sends rumble state
|
// Feedback planes (this thread is their single consumer). Rumble arrives as
|
||||||
// periodically, so a generous duration with refresh-on-update is safe — a dropped stop
|
// self-terminating v2 envelopes: the host renews an active level and lets an abandoned one
|
||||||
// heals within ~500 ms.
|
// lapse, so the SDL duration is the host's TTL — a lost stop (or a dead host) self-silences
|
||||||
|
// at the lease instead of droning. A legacy host (`ttl == None`) sends no lease → keep the
|
||||||
|
// proven 5 s duration and rely on its periodic re-send as before.
|
||||||
if let Some(connector) = w.attached.clone() {
|
if let Some(connector) = w.attached.clone() {
|
||||||
while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) {
|
while let Ok((pad, low, high, ttl)) = connector.next_rumble_ttl(Duration::ZERO) {
|
||||||
if pad == 0 {
|
if pad == 0 {
|
||||||
|
// Floor the lease so a jittered renewal can't gap the actuator between writes.
|
||||||
|
let dur_ms = ttl.map_or(5_000, |ms| (ms as u32).max(240));
|
||||||
if let Some(p) = w.active_id().and_then(|id| w.opened.get_mut(&id)) {
|
if let Some(p) = w.active_id().and_then(|id| w.opened.get_mut(&id)) {
|
||||||
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in
|
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in
|
||||||
// the right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The
|
// the right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The
|
||||||
// host logs the send side on 0xCA, so the two together pinpoint host-game vs
|
// host logs the send side on 0xCA, so the two together pinpoint host-game vs
|
||||||
// client-render.
|
// client-render.
|
||||||
if let Err(e) = p.set_rumble(low, high, 5_000) {
|
if let Err(e) = p.set_rumble(low, high, dur_ms) {
|
||||||
tracing::warn!(low, high, error = %e, "rumble: SDL set_rumble failed");
|
tracing::warn!(low, high, error = %e, "rumble: SDL set_rumble failed");
|
||||||
} else {
|
} else {
|
||||||
tracing::debug!(low, high, "rumble: rendered");
|
tracing::debug!(low, high, "rumble: rendered");
|
||||||
|
|||||||
+142
-85
@@ -4,7 +4,9 @@
|
|||||||
//! the dedicated render thread ([`crate::render`]) — presenting never touches (or is stalled by)
|
//! the dedicated render thread ([`crate::render`]) — presenting never touches (or is stalled by)
|
||||||
//! the XAML thread.
|
//! the XAML thread.
|
||||||
//!
|
//!
|
||||||
//! Two frame sources, one pair of YUV shaders (identical colour math for both):
|
//! Two frame sources, ONE Y′CbCr→RGB shader whose conversion rows arrive per frame in a constant
|
||||||
|
//! buffer (`pf_client_core::video::csc_rows` from the frame's CICP signaling — identical colour
|
||||||
|
//! math for both sources, and the stream's signaled matrix/range is honored, not assumed):
|
||||||
//!
|
//!
|
||||||
//! * **GPU (D3D11VA)** — [`crate::video::GpuFrame`] is a slice of the decoder-only NV12/P010
|
//! * **GPU (D3D11VA)** — [`crate::video::GpuFrame`] is a slice of the decoder-only NV12/P010
|
||||||
//! texture array. One `CopySubresourceRegion` with a display-size box moves the slice — **both
|
//! texture array. One `CopySubresourceRegion` with a display-size box moves the slice — **both
|
||||||
@@ -46,10 +48,14 @@ use windows::Win32::Graphics::Dxgi::Common::*;
|
|||||||
use windows::Win32::Graphics::Dxgi::*;
|
use windows::Win32::Graphics::Dxgi::*;
|
||||||
use windows::Win32::System::Threading::WaitForSingleObject;
|
use windows::Win32::System::Threading::WaitForSingleObject;
|
||||||
|
|
||||||
// One vertex shader (fullscreen triangle) + two pixel shaders, selected per frame colour space.
|
// One vertex shader (fullscreen triangle) + ONE pixel shader for every colour combination:
|
||||||
// tex0 is the luma plane, tex1 the chroma plane. The YUV→RGB matrices fold the limited→full range
|
// tex0 is the luma plane, tex1 the chroma plane, and the Y′CbCr→RGB conversion arrives as three
|
||||||
// scale into the coefficients; for P010 the R16 sample is rescaled (×65535/65472) to undo the
|
// constant-buffer rows precomputed on the CPU per frame (`pf_client_core::video::csc_rows` —
|
||||||
// 10-bits-in-the-high-bits packing, then converted with BT.2020 NCL, PQ preserved.
|
// bit-depth exact, range expansion + the P010 ×65535/65472 high-bit repack folded in). One shader
|
||||||
|
// honors whatever the stream signals (BT.601/709/2020, full/limited, 8/10-bit) instead of the old
|
||||||
|
// two hardcoded matrices — a BT.601-signaled stream (a Linux host's RGB-input NVENC) used to
|
||||||
|
// render with BT.709 coefficients, a constant hue error. A PQ stream's rows yield PQ-encoded
|
||||||
|
// R′G′B′ passed through as-is to the HDR10 swapchain, exactly as before.
|
||||||
const SHADER_HLSL: &str = r#"
|
const SHADER_HLSL: &str = r#"
|
||||||
struct VSOut { float4 pos : SV_Position; float2 uv : TEXCOORD0; };
|
struct VSOut { float4 pos : SV_Position; float2 uv : TEXCOORD0; };
|
||||||
VSOut vs_main(uint vid : SV_VertexID) {
|
VSOut vs_main(uint vid : SV_VertexID) {
|
||||||
@@ -62,47 +68,47 @@ VSOut vs_main(uint vid : SV_VertexID) {
|
|||||||
Texture2D tex0 : register(t0);
|
Texture2D tex0 : register(t0);
|
||||||
Texture2D tex1 : register(t1);
|
Texture2D tex1 : register(t1);
|
||||||
SamplerState smp : register(s0);
|
SamplerState smp : register(s0);
|
||||||
|
cbuffer Csc : register(b0) {
|
||||||
|
float4 r0; // rgb[i] = dot(ri.xyz, yuv) + ri.w
|
||||||
|
float4 r1;
|
||||||
|
float4 r2;
|
||||||
|
};
|
||||||
|
|
||||||
float4 ps_nv12(VSOut i) : SV_Target {
|
float4 ps_yuv(VSOut i) : SV_Target {
|
||||||
float y = tex0.Sample(smp, i.uv).r;
|
// 4:2:0 chroma is left-cosited (H.273 type 0 — the default inference when unsignaled, and
|
||||||
float2 uv = tex1.Sample(smp, i.uv).rg;
|
// what the hosts produce), but sampling the half-res plane at the luma UV assumes CENTER
|
||||||
float yy = (y - 0.0627451) * 1.164384; // (Y-16/255)*255/219
|
// siting — a ~0.5-luma-px rightward chroma shift on hard colored edges. Offset +0.25 chroma
|
||||||
float u = uv.x - 0.5;
|
// texels to re-align (the same correction the Apple client applies). Self-disables when the
|
||||||
float v = uv.y - 0.5; // BT.709 limited, chroma scale folded
|
// plane widths match (a full-size 4:4:4 chroma plane has no subsampling to correct).
|
||||||
float r = yy + 1.792741 * v;
|
float lw, lh, cw, ch;
|
||||||
float g = yy - 0.213249 * u - 0.532909 * v;
|
tex0.GetDimensions(lw, lh);
|
||||||
float b = yy + 2.112402 * u;
|
tex1.GetDimensions(cw, ch);
|
||||||
return float4(saturate(float3(r, g, b)), 1.0);
|
float2 cuv = i.uv;
|
||||||
}
|
if (cw < lw) { cuv.x += 0.25 / cw; }
|
||||||
|
float3 yuv = float3(tex0.Sample(smp, i.uv).r, tex1.Sample(smp, cuv).rg);
|
||||||
float4 ps_p010(VSOut i) : SV_Target {
|
float3 rgb = float3(dot(r0.xyz, yuv) + r0.w,
|
||||||
const float S = 65535.0 / 65472.0; // undo P010 high-bit packing → exact 10-bit / 1023
|
dot(r1.xyz, yuv) + r1.w,
|
||||||
float y = tex0.Sample(smp, i.uv).r * S;
|
dot(r2.xyz, yuv) + r2.w);
|
||||||
float2 uv = tex1.Sample(smp, i.uv).rg * S;
|
return float4(saturate(rgb), 1.0);
|
||||||
float yy = (y - 0.0625611) * 1.167808; // (Y-64/1023)*1023/876
|
|
||||||
float u = uv.x - 0.5;
|
|
||||||
float v = uv.y - 0.5; // BT.2020 NCL limited, chroma scale folded; PQ kept
|
|
||||||
float r = yy + 1.683611 * v;
|
|
||||||
float g = yy - 0.187877 * u - 0.652337 * v;
|
|
||||||
float b = yy + 2.148072 * u;
|
|
||||||
return float4(saturate(float3(r, g, b)), 1.0);
|
|
||||||
}
|
}
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
/// The currently bound frame: per-plane SRVs (over the GPU sample texture or the CPU plane
|
/// The currently bound frame: per-plane SRVs (over the GPU sample texture or the CPU plane
|
||||||
/// textures) + the colour space that picks the shader. Redraws (resize, letterbox) re-present it.
|
/// textures). Redraws (resize, letterbox) re-present it — the CSC constant buffer still holds
|
||||||
|
/// this frame's rows, and the swapchain mode was latched by `set_hdr` when the frame arrived.
|
||||||
struct Bound {
|
struct Bound {
|
||||||
y: ID3D11ShaderResourceView,
|
y: ID3D11ShaderResourceView,
|
||||||
c: ID3D11ShaderResourceView,
|
c: ID3D11ShaderResourceView,
|
||||||
hdr: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Presenter {
|
pub struct Presenter {
|
||||||
device: ID3D11Device,
|
device: ID3D11Device,
|
||||||
context: ID3D11DeviceContext,
|
context: ID3D11DeviceContext,
|
||||||
vs: ID3D11VertexShader,
|
vs: ID3D11VertexShader,
|
||||||
ps_nv12: ID3D11PixelShader,
|
ps_yuv: ID3D11PixelShader,
|
||||||
ps_p010: ID3D11PixelShader,
|
/// Dynamic constant buffer holding the bound frame's three CSC rows (`csc_rows`), rewritten
|
||||||
|
/// on every bind (colour signaling can flip in-band, e.g. the host's SDR→HDR re-init).
|
||||||
|
csc_buf: ID3D11Buffer,
|
||||||
sampler: ID3D11SamplerState,
|
sampler: ID3D11SamplerState,
|
||||||
swap: IDXGISwapChain1,
|
swap: IDXGISwapChain1,
|
||||||
/// Creation flags — MUST be re-passed to every `ResizeBuffers` or it fails.
|
/// Creation flags — MUST be re-passed to every `ResizeBuffers` or it fails.
|
||||||
@@ -157,7 +163,22 @@ impl Presenter {
|
|||||||
let shared = crate::gpu::shared().ok_or_else(|| anyhow!("no shared D3D11 device"))?;
|
let shared = crate::gpu::shared().ok_or_else(|| anyhow!("no shared D3D11 device"))?;
|
||||||
let device = shared.device.clone();
|
let device = shared.device.clone();
|
||||||
let context = shared.context.clone();
|
let context = shared.context.clone();
|
||||||
let (vs, ps_nv12, ps_p010, sampler) = build_pipeline(&device)?;
|
let (vs, ps_yuv, sampler) = build_pipeline(&device)?;
|
||||||
|
// The per-frame CSC rows (three float4s). Dynamic: rewritten with Map-discard on bind.
|
||||||
|
let csc_desc = D3D11_BUFFER_DESC {
|
||||||
|
ByteWidth: 48,
|
||||||
|
Usage: D3D11_USAGE_DYNAMIC,
|
||||||
|
BindFlags: D3D11_BIND_CONSTANT_BUFFER.0 as u32,
|
||||||
|
CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let csc_buf = unsafe {
|
||||||
|
let mut b = None;
|
||||||
|
device
|
||||||
|
.CreateBuffer(&csc_desc, None, Some(&mut b))
|
||||||
|
.context("CreateBuffer (CSC rows)")?;
|
||||||
|
b.ok_or_else(|| anyhow!("null CSC constant buffer"))?
|
||||||
|
};
|
||||||
let (swap, swap_flags) =
|
let (swap, swap_flags) =
|
||||||
create_composition_swapchain(&device, width.max(1), height.max(1))?;
|
create_composition_swapchain(&device, width.max(1), height.max(1))?;
|
||||||
// ≤1 queued present: the render thread blocks on the waitable, so a frame is only drawn
|
// ≤1 queued present: the render thread blocks on the waitable, so a frame is only drawn
|
||||||
@@ -175,8 +196,8 @@ impl Presenter {
|
|||||||
device,
|
device,
|
||||||
context,
|
context,
|
||||||
vs,
|
vs,
|
||||||
ps_nv12,
|
ps_yuv,
|
||||||
ps_p010,
|
csc_buf,
|
||||||
sampler,
|
sampler,
|
||||||
swap,
|
swap,
|
||||||
swap_flags,
|
swap_flags,
|
||||||
@@ -327,12 +348,10 @@ impl Presenter {
|
|||||||
let (fy, fc) = plane_formats(g.ten_bit);
|
let (fy, fc) = plane_formats(g.ten_bit);
|
||||||
let y = self.plane_srv(&dst, fy)?;
|
let y = self.plane_srv(&dst, fy)?;
|
||||||
let c = self.plane_srv(&dst, fc)?;
|
let c = self.plane_srv(&dst, fc)?;
|
||||||
if g.ten_bit != g.hdr {
|
self.write_csc_rows(g.color, g.ten_bit)?;
|
||||||
warn_bitdepth_mismatch_once(g.ten_bit, g.hdr);
|
|
||||||
}
|
|
||||||
self.src_w = g.width;
|
self.src_w = g.width;
|
||||||
self.src_h = g.height;
|
self.src_h = g.height;
|
||||||
self.bound = Some(Bound { y, c, hdr: g.hdr });
|
self.bound = Some(Bound { y, c });
|
||||||
// Hold the frame until the next bind: its decode surface stays out of the reuse pool
|
// Hold the frame until the next bind: its decode surface stays out of the reuse pool
|
||||||
// until this copy is queued ahead of any later decoder write (previous frame drops here).
|
// until this copy is queued ahead of any later decoder write (previous frame drops here).
|
||||||
self.gpu_frame = Some(g);
|
self.gpu_frame = Some(g);
|
||||||
@@ -428,12 +447,13 @@ impl Presenter {
|
|||||||
w.div_ceil(2) as usize * 2 * bytes,
|
w.div_ceil(2) as usize * 2 * bytes,
|
||||||
h.div_ceil(2) as usize,
|
h.div_ceil(2) as usize,
|
||||||
)?;
|
)?;
|
||||||
|
let (y_srv, uv_srv) = (y_srv.clone(), uv_srv.clone());
|
||||||
|
self.write_csc_rows(frame.color, frame.ten_bit)?;
|
||||||
self.src_w = w;
|
self.src_w = w;
|
||||||
self.src_h = h;
|
self.src_h = h;
|
||||||
self.bound = Some(Bound {
|
self.bound = Some(Bound {
|
||||||
y: y_srv.clone(),
|
y: y_srv,
|
||||||
c: uv_srv.clone(),
|
c: uv_srv,
|
||||||
hdr: frame.hdr,
|
|
||||||
});
|
});
|
||||||
self.gpu_frame = None; // drop any held GPU frame
|
self.gpu_frame = None; // drop any held GPU frame
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -464,6 +484,32 @@ impl Presenter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Recompute the bound frame's Y′CbCr→RGB rows from its CICP signaling and Map-discard them
|
||||||
|
/// into the CSC constant buffer. `ten_bit` selects the 10-bit code points AND the P010
|
||||||
|
/// high-bit repack (the plane SRVs are R16/R16G16 UNORM for 10-bit).
|
||||||
|
fn write_csc_rows(&self, color: pf_client_core::video::ColorDesc, ten_bit: bool) -> Result<()> {
|
||||||
|
let rows = pf_client_core::video::csc_rows(color, if ten_bit { 10 } else { 8 }, ten_bit);
|
||||||
|
unsafe {
|
||||||
|
let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
|
||||||
|
self.context
|
||||||
|
.Map(
|
||||||
|
&self.csc_buf,
|
||||||
|
0,
|
||||||
|
D3D11_MAP_WRITE_DISCARD,
|
||||||
|
0,
|
||||||
|
Some(&mut mapped),
|
||||||
|
)
|
||||||
|
.context("Map CSC constant buffer")?;
|
||||||
|
std::ptr::copy_nonoverlapping(
|
||||||
|
rows.as_ptr() as *const u8,
|
||||||
|
mapped.pData as *mut u8,
|
||||||
|
48, // [[f32; 4]; 3]
|
||||||
|
);
|
||||||
|
self.context.Unmap(&self.csc_buf, 0);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Map-discard `tex` and copy `rows` rows of `row_bytes` from `src` (stride `src_pitch`).
|
/// Map-discard `tex` and copy `rows` rows of `row_bytes` from `src` (stride `src_pitch`).
|
||||||
fn map_rows(
|
fn map_rows(
|
||||||
&self,
|
&self,
|
||||||
@@ -525,14 +571,8 @@ impl Presenter {
|
|||||||
c.IASetInputLayout(None);
|
c.IASetInputLayout(None);
|
||||||
c.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
|
c.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
|
||||||
c.VSSetShader(&self.vs, None);
|
c.VSSetShader(&self.vs, None);
|
||||||
c.PSSetShader(
|
c.PSSetShader(&self.ps_yuv, None);
|
||||||
if bound.hdr {
|
c.PSSetConstantBuffers(0, Some(&[Some(self.csc_buf.clone())]));
|
||||||
&self.ps_p010
|
|
||||||
} else {
|
|
||||||
&self.ps_nv12
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
c.PSSetShaderResources(0, Some(&[Some(bound.y.clone()), Some(bound.c.clone())]));
|
c.PSSetShaderResources(0, Some(&[Some(bound.y.clone()), Some(bound.c.clone())]));
|
||||||
c.PSSetSamplers(0, Some(&[Some(self.sampler.clone())]));
|
c.PSSetSamplers(0, Some(&[Some(self.sampler.clone())]));
|
||||||
c.Draw(3, 0);
|
c.Draw(3, 0);
|
||||||
@@ -645,20 +685,6 @@ fn plane_formats(ten_bit: bool) -> (DXGI_FORMAT, DXGI_FORMAT) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The host couples 10-bit ⟺ HDR today; a mismatch means the shader's transfer/matrix assumption
|
|
||||||
/// is off for this stream (rendered anyway — approximate colour beats no picture).
|
|
||||||
fn warn_bitdepth_mismatch_once(ten_bit: bool, hdr: bool) {
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
static ONCE: AtomicBool = AtomicBool::new(true);
|
|
||||||
if ONCE.swap(false, Ordering::Relaxed) {
|
|
||||||
tracing::warn!(
|
|
||||||
ten_bit,
|
|
||||||
hdr,
|
|
||||||
"bit depth / HDR mismatch — colour may be approximate"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A composition flip-model swapchain (no HWND) for binding to a XAML `SwapChainPanel`, with the
|
/// A composition flip-model swapchain (no HWND) for binding to a XAML `SwapChainPanel`, with the
|
||||||
/// frame-latency waitable when the driver allows it. Returns the swapchain + the flags it was
|
/// frame-latency waitable when the driver allows it. Returns the swapchain + the flags it was
|
||||||
/// created with (every `ResizeBuffers` must re-pass them).
|
/// created with (every `ResizeBuffers` must re-pass them).
|
||||||
@@ -708,28 +734,18 @@ fn create_composition_swapchain(
|
|||||||
|
|
||||||
fn build_pipeline(
|
fn build_pipeline(
|
||||||
device: &ID3D11Device,
|
device: &ID3D11Device,
|
||||||
) -> Result<(
|
) -> Result<(ID3D11VertexShader, ID3D11PixelShader, ID3D11SamplerState)> {
|
||||||
ID3D11VertexShader,
|
|
||||||
ID3D11PixelShader,
|
|
||||||
ID3D11PixelShader,
|
|
||||||
ID3D11SamplerState,
|
|
||||||
)> {
|
|
||||||
let vs_blob = compile(SHADER_HLSL, "vs_main", "vs_5_0")?;
|
let vs_blob = compile(SHADER_HLSL, "vs_main", "vs_5_0")?;
|
||||||
let nv12_blob = compile(SHADER_HLSL, "ps_nv12", "ps_5_0")?;
|
let yuv_blob = compile(SHADER_HLSL, "ps_yuv", "ps_5_0")?;
|
||||||
let p010_blob = compile(SHADER_HLSL, "ps_p010", "ps_5_0")?;
|
|
||||||
unsafe {
|
unsafe {
|
||||||
let mut vs = None;
|
let mut vs = None;
|
||||||
device
|
device
|
||||||
.CreateVertexShader(blob_bytes(&vs_blob), None, Some(&mut vs))
|
.CreateVertexShader(blob_bytes(&vs_blob), None, Some(&mut vs))
|
||||||
.context("CreateVertexShader")?;
|
.context("CreateVertexShader")?;
|
||||||
let mut ps_nv12 = None;
|
let mut ps_yuv = None;
|
||||||
device
|
device
|
||||||
.CreatePixelShader(blob_bytes(&nv12_blob), None, Some(&mut ps_nv12))
|
.CreatePixelShader(blob_bytes(&yuv_blob), None, Some(&mut ps_yuv))
|
||||||
.context("CreatePixelShader (nv12)")?;
|
.context("CreatePixelShader (yuv)")?;
|
||||||
let mut ps_p010 = None;
|
|
||||||
device
|
|
||||||
.CreatePixelShader(blob_bytes(&p010_blob), None, Some(&mut ps_p010))
|
|
||||||
.context("CreatePixelShader (p010)")?;
|
|
||||||
let sdesc = D3D11_SAMPLER_DESC {
|
let sdesc = D3D11_SAMPLER_DESC {
|
||||||
Filter: D3D11_FILTER_MIN_MAG_MIP_LINEAR,
|
Filter: D3D11_FILTER_MIN_MAG_MIP_LINEAR,
|
||||||
AddressU: D3D11_TEXTURE_ADDRESS_CLAMP,
|
AddressU: D3D11_TEXTURE_ADDRESS_CLAMP,
|
||||||
@@ -742,12 +758,7 @@ fn build_pipeline(
|
|||||||
device
|
device
|
||||||
.CreateSamplerState(&sdesc, Some(&mut sampler))
|
.CreateSamplerState(&sdesc, Some(&mut sampler))
|
||||||
.context("CreateSamplerState")?;
|
.context("CreateSamplerState")?;
|
||||||
Ok((
|
Ok((vs.unwrap(), ps_yuv.unwrap(), sampler.unwrap()))
|
||||||
vs.unwrap(),
|
|
||||||
ps_nv12.unwrap(),
|
|
||||||
ps_p010.unwrap(),
|
|
||||||
sampler.unwrap(),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -823,6 +834,52 @@ pub fn display_supports_hdr() -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The HDR display's colour volume from `IDXGIOutput6::GetDesc1` — the first output currently in
|
||||||
|
/// HDR (BT.2020 PQ) mode, as [`HdrMeta`](punktfunk_core::quic::HdrMeta) for `Hello::display_hdr`.
|
||||||
|
/// The host writes this volume into its virtual display's EDID, so host apps tone-map to THIS
|
||||||
|
/// panel and the PQ stream needs no client-side rescue. Chromaticities come as CIE xy floats
|
||||||
|
/// (×50000 → ST.2086 units, G/B/R order); luminances as nits floats (max ×10000 → 0.0001-cd/m²
|
||||||
|
/// units); `MaxFullFrameLuminance` → MaxFALL (whole nits); MaxCLL stays 0 (a display has no
|
||||||
|
/// content light level). Same ANY-output coarseness as [`display_supports_hdr`] — the session
|
||||||
|
/// gates on that check first, so both look at the same panel in the single-HDR-display case.
|
||||||
|
pub fn display_hdr_volume() -> Option<punktfunk_core::quic::HdrMeta> {
|
||||||
|
let to_2086 = |v: f32| (v * 50000.0).round().clamp(0.0, 65535.0) as u16;
|
||||||
|
unsafe {
|
||||||
|
let factory: IDXGIFactory1 = CreateDXGIFactory1().ok()?;
|
||||||
|
let mut ai = 0u32;
|
||||||
|
while let Ok(adapter) = factory.EnumAdapters1(ai) {
|
||||||
|
ai += 1;
|
||||||
|
let mut oi = 0u32;
|
||||||
|
while let Ok(output) = adapter.EnumOutputs(oi) {
|
||||||
|
oi += 1;
|
||||||
|
let Ok(o6) = output.cast::<IDXGIOutput6>() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(desc) = o6.GetDesc1() else { continue };
|
||||||
|
if desc.ColorSpace != DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return Some(punktfunk_core::quic::HdrMeta {
|
||||||
|
// ST.2086 order is G, B, R.
|
||||||
|
display_primaries: [
|
||||||
|
[to_2086(desc.GreenPrimary[0]), to_2086(desc.GreenPrimary[1])],
|
||||||
|
[to_2086(desc.BluePrimary[0]), to_2086(desc.BluePrimary[1])],
|
||||||
|
[to_2086(desc.RedPrimary[0]), to_2086(desc.RedPrimary[1])],
|
||||||
|
],
|
||||||
|
white_point: [to_2086(desc.WhitePoint[0]), to_2086(desc.WhitePoint[1])],
|
||||||
|
max_display_mastering_luminance: (desc.MaxLuminance.max(0.0) * 10_000.0).round()
|
||||||
|
as u32,
|
||||||
|
min_display_mastering_luminance: (desc.MinLuminance.max(0.0) * 10_000.0).round()
|
||||||
|
as u32,
|
||||||
|
max_cll: 0,
|
||||||
|
max_fall: desc.MaxFullFrameLuminance.max(0.0).round() as u16,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Generic HDR10 mastering metadata: BT.2020 primaries + D65 white, a 1000-nit mastering display,
|
/// Generic HDR10 mastering metadata: BT.2020 primaries + D65 white, a 1000-nit mastering display,
|
||||||
/// MaxCLL 1000 / MaxFALL 400. The fallback used only until the host's real `0xCE` metadata arrives.
|
/// MaxCLL 1000 / MaxFALL 400. The fallback used only until the host's real `0xCE` metadata arrives.
|
||||||
fn generic_hdr10_metadata() -> DXGI_HDR_METADATA_HDR10 {
|
fn generic_hdr10_metadata() -> DXGI_HDR_METADATA_HDR10 {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
use crate::present::Presenter;
|
use crate::present::Presenter;
|
||||||
use crate::session::{FrameRx, FrameTimes};
|
use crate::session::{FrameRx, FrameTimes};
|
||||||
use crossbeam_channel::RecvTimeoutError;
|
use crossbeam_channel::RecvTimeoutError;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
@@ -122,12 +122,13 @@ unsafe impl Send for SendPresenter {}
|
|||||||
|
|
||||||
/// Spawn the render thread. `frames` carries `(frame, FrameTimes)`; `clock_offset_ns` maps our
|
/// Spawn the render thread. `frames` carries `(frame, FrameTimes)`; `clock_offset_ns` maps our
|
||||||
/// wall clock onto the host's so the end-to-end (capture→on-glass) number is cross-machine valid
|
/// wall clock onto the host's so the end-to-end (capture→on-glass) number is cross-machine valid
|
||||||
/// (same math as the pump's host+network stage).
|
/// (same math as the pump's host+network stage). A live handle (loaded per present) so
|
||||||
|
/// mid-stream clock re-syncs keep the number honest after an NTP step / drift.
|
||||||
pub fn spawn(
|
pub fn spawn(
|
||||||
presenter: Presenter,
|
presenter: Presenter,
|
||||||
frames: FrameRx,
|
frames: FrameRx,
|
||||||
shared: Arc<RenderShared>,
|
shared: Arc<RenderShared>,
|
||||||
clock_offset_ns: i64,
|
clock_offset_ns: Arc<AtomicI64>,
|
||||||
) -> RenderThread {
|
) -> RenderThread {
|
||||||
let boxed = SendPresenter(presenter);
|
let boxed = SendPresenter(presenter);
|
||||||
let shared_w = shared.clone();
|
let shared_w = shared.clone();
|
||||||
@@ -162,7 +163,12 @@ fn poll_window_dpi() -> Option<u32> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run(presenter: SendPresenter, frames: FrameRx, shared: Arc<RenderShared>, clock_offset_ns: i64) {
|
fn run(
|
||||||
|
presenter: SendPresenter,
|
||||||
|
frames: FrameRx,
|
||||||
|
shared: Arc<RenderShared>,
|
||||||
|
clock_offset_ns: Arc<AtomicI64>,
|
||||||
|
) {
|
||||||
let mut p = presenter.0;
|
let mut p = presenter.0;
|
||||||
let mut applied = (0u32, 0u32, 0u32); // last (w, h, dpi) handed to the presenter
|
let mut applied = (0u32, 0u32, 0u32); // last (w, h, dpi) handed to the presenter
|
||||||
let mut presented = 0u32;
|
let mut presented = 0u32;
|
||||||
@@ -232,8 +238,9 @@ fn run(presenter: SendPresenter, frames: FrameRx, shared: Arc<RenderShared>, clo
|
|||||||
let displayed_ns = now_ns();
|
let displayed_ns = now_ns();
|
||||||
// End-to-end = capture → displayed, host-clock corrected, measured directly
|
// End-to-end = capture → displayed, host-clock corrected, measured directly
|
||||||
// (never the sum of stage percentiles). Clamped (0, 10 s).
|
// (never the sum of stage percentiles). Clamped (0, 10 s).
|
||||||
let e2e =
|
let e2e = (displayed_ns as i128 + clock_offset_ns.load(Ordering::Relaxed) as i128
|
||||||
(displayed_ns as i128 + clock_offset_ns as i128 - t.pts_ns as i128).max(0) as u64;
|
- t.pts_ns as i128)
|
||||||
|
.max(0) as u64;
|
||||||
if e2e > 0 && e2e < 10_000_000_000 {
|
if e2e > 0 && e2e < 10_000_000_000 {
|
||||||
e2e_us.push(e2e / 1000);
|
e2e_us.push(e2e / 1000);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ pub fn run_speed_probe(
|
|||||||
2, // audio_channels: stereo baseline
|
2, // audio_channels: stereo baseline
|
||||||
crate::video::decodable_codecs(),
|
crate::video::decodable_codecs(),
|
||||||
0, // preferred_codec: no preference
|
0, // preferred_codec: no preference
|
||||||
|
None, // display_hdr: probe connect, nothing presents
|
||||||
None, // launch: no game
|
None, // launch: no game
|
||||||
pin,
|
pin,
|
||||||
Some(identity),
|
Some(identity),
|
||||||
@@ -235,6 +236,34 @@ fn pump(
|
|||||||
frame_rx: FrameRx,
|
frame_rx: FrameRx,
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
) {
|
) {
|
||||||
|
// Advertise 10-bit + HDR10 only when the user enabled HDR AND a display is actually in HDR
|
||||||
|
// mode: the host then upgrades HDR content to a Main10/PQ stream (its own 10-bit gate still
|
||||||
|
// applies). On an SDR display we advertise `0` so the host sends a proper 8-bit BT.709 stream
|
||||||
|
// rather than PQ the panel would mis-tone-map (washed-out/dark). The presenter handles BT.2020
|
||||||
|
// PQ frames (P010 / X2BGR10).
|
||||||
|
let hdr_active = params.hdr_enabled && crate::present::display_supports_hdr();
|
||||||
|
if params.hdr_enabled && !hdr_active {
|
||||||
|
tracing::info!("HDR enabled in settings but no HDR display detected — requesting SDR");
|
||||||
|
}
|
||||||
|
// With HDR active, also report the panel's real colour volume (GetDesc1): the host writes it
|
||||||
|
// into its virtual display's EDID, so host apps tone-map to THIS panel and the PQ stream
|
||||||
|
// arrives already inside its volume — the client presents it untouched.
|
||||||
|
// PUNKTFUNK_CLIENT_PEAK_NITS pins a synthetic volume for A/B runs.
|
||||||
|
let display_hdr = if hdr_active {
|
||||||
|
let vol = punktfunk_core::client::display_hdr_env_override()
|
||||||
|
.or_else(crate::present::display_hdr_volume);
|
||||||
|
if let Some(m) = vol {
|
||||||
|
tracing::info!(
|
||||||
|
max_nits = m.max_display_mastering_luminance / 10_000,
|
||||||
|
min_millinits = m.min_display_mastering_luminance / 10,
|
||||||
|
max_fall = m.max_fall,
|
||||||
|
"advertising this display's HDR volume to the host"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
vol
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
let connector = match NativeClient::connect(
|
let connector = match NativeClient::connect(
|
||||||
¶ms.host,
|
¶ms.host,
|
||||||
params.port,
|
params.port,
|
||||||
@@ -242,25 +271,16 @@ fn pump(
|
|||||||
params.compositor,
|
params.compositor,
|
||||||
params.gamepad,
|
params.gamepad,
|
||||||
params.bitrate_kbps,
|
params.bitrate_kbps,
|
||||||
// Advertise 10-bit + HDR10 only when the user enabled HDR AND a display is actually in HDR
|
if hdr_active {
|
||||||
// mode: the host then upgrades HDR content to a Main10/PQ stream (its own 10-bit gate still
|
|
||||||
// applies). On an SDR display we advertise `0` so the host sends a proper 8-bit BT.709 stream
|
|
||||||
// rather than PQ the panel would mis-tone-map (washed-out/dark). An HDR display self-tone-maps
|
|
||||||
// from the mastering metadata we apply. The presenter handles BT.2020 PQ frames (P010 / X2BGR10).
|
|
||||||
if params.hdr_enabled && crate::present::display_supports_hdr() {
|
|
||||||
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
|
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
|
||||||
} else {
|
} else {
|
||||||
if params.hdr_enabled {
|
|
||||||
tracing::info!(
|
|
||||||
"HDR enabled in settings but no HDR display detected — requesting SDR"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
0
|
0
|
||||||
},
|
},
|
||||||
params.audio_channels,
|
params.audio_channels,
|
||||||
crate::video::decodable_codecs(), // codecs FFmpeg can decode (HEVC/H.264/AV1)
|
crate::video::decodable_codecs(), // codecs FFmpeg can decode (HEVC/H.264/AV1)
|
||||||
params.preferred_codec, // the user's soft codec preference (0 = auto)
|
params.preferred_codec, // the user's soft codec preference (0 = auto)
|
||||||
None, // launch: the Windows client has no library picker yet
|
display_hdr,
|
||||||
|
None, // launch: the Windows client has no library picker yet
|
||||||
params.pin,
|
params.pin,
|
||||||
Some(params.identity),
|
Some(params.identity),
|
||||||
params.connect_timeout,
|
params.connect_timeout,
|
||||||
@@ -330,7 +350,9 @@ fn pump(
|
|||||||
// "PPS id out of range" (a black screen) until one arrives.
|
// "PPS id out of range" (a black screen) until one arrives.
|
||||||
let _ = connector.request_keyframe();
|
let _ = connector.request_keyframe();
|
||||||
|
|
||||||
let clock_offset = connector.clock_offset_ns;
|
// Live host↔client clock offset: loaded per use (Relaxed) so mid-stream re-syncs (an NTP
|
||||||
|
// step, drift) keep the capture-clock latency stats honest — never cached at session start.
|
||||||
|
let clock_offset_live = connector.clock_offset_shared();
|
||||||
let mut total_frames = 0u64;
|
let mut total_frames = 0u64;
|
||||||
let session_start = Instant::now();
|
let session_start = Instant::now();
|
||||||
let mut window_start = Instant::now();
|
let mut window_start = Instant::now();
|
||||||
@@ -358,11 +380,17 @@ fn pump(
|
|||||||
Ok(frame) => {
|
Ok(frame) => {
|
||||||
// The `received` point: AU fully reassembled, handed to us, before decode.
|
// The `received` point: AU fully reassembled, handed to us, before decode.
|
||||||
let received_ns = now_ns();
|
let received_ns = now_ns();
|
||||||
|
// 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 frames_dropped keyframe path below is the
|
||||||
|
// backstop for when the recovery frame itself is lost.
|
||||||
|
let _ = connector.note_frame_index(frame.frame_index);
|
||||||
// fps = AUs received per second, Mb/s = received goodput (spec: counted at the
|
// fps = AUs received per second, Mb/s = received goodput (spec: counted at the
|
||||||
// received point, not the decoded one).
|
// received point, not the decoded one).
|
||||||
frames_n += 1;
|
frames_n += 1;
|
||||||
bytes_n += frame.data.len() as u64;
|
bytes_n += frame.data.len() as u64;
|
||||||
// `host+network` stage: capture → received, host-clock corrected. Clamped (0, 10 s).
|
// `host+network` stage: capture → received, host-clock corrected. Clamped (0, 10 s).
|
||||||
|
let clock_offset = clock_offset_live.load(Ordering::Relaxed);
|
||||||
let hostnet = (received_ns as i128 + clock_offset as i128 - frame.pts_ns as i128)
|
let hostnet = (received_ns as i128 + clock_offset as i128 - frame.pts_ns as i128)
|
||||||
.max(0) as u64;
|
.max(0) as u64;
|
||||||
if hostnet > 0 && hostnet < 10_000_000_000 {
|
if hostnet > 0 && hostnet < 10_000_000_000 {
|
||||||
@@ -500,7 +528,7 @@ fn pump(
|
|||||||
host_ms: host_p50 as f32 / 1000.0,
|
host_ms: host_p50 as f32 / 1000.0,
|
||||||
net_ms: net_p50 as f32 / 1000.0,
|
net_ms: net_p50 as f32 / 1000.0,
|
||||||
split,
|
split,
|
||||||
same_host: clock_offset == 0,
|
same_host: clock_offset_live.load(Ordering::Relaxed) == 0,
|
||||||
hardware,
|
hardware,
|
||||||
hdr,
|
hdr,
|
||||||
codec: connector.codec,
|
codec: connector.codec,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ use ffmpeg::format::Pixel;
|
|||||||
use ffmpeg::software::scaling;
|
use ffmpeg::software::scaling;
|
||||||
use ffmpeg::util::frame::Video as AvFrame;
|
use ffmpeg::util::frame::Video as AvFrame;
|
||||||
use ffmpeg_next as ffmpeg;
|
use ffmpeg_next as ffmpeg;
|
||||||
|
use pf_client_core::video::ColorDesc;
|
||||||
use std::ffi::c_void;
|
use std::ffi::c_void;
|
||||||
use std::ptr;
|
use std::ptr;
|
||||||
use windows::core::{Interface, GUID};
|
use windows::core::{Interface, GUID};
|
||||||
@@ -95,8 +96,12 @@ pub struct CpuFrame {
|
|||||||
pub uv_stride: usize,
|
pub uv_stride: usize,
|
||||||
/// P010 sample layout (10 bits in the high bits of 16) vs NV12. Selects texture/SRV formats.
|
/// P010 sample layout (10 bits in the high bits of 16) vs NV12. Selects texture/SRV formats.
|
||||||
pub ten_bit: bool,
|
pub ten_bit: bool,
|
||||||
/// BT.2020 PQ HDR10 vs ordinary BT.709 SDR. Selects shader + swapchain colour space.
|
/// BT.2020 PQ HDR10 vs ordinary BT.709 SDR. Selects the swapchain colour space.
|
||||||
pub hdr: bool,
|
pub hdr: bool,
|
||||||
|
/// The frame's CICP signaling (HEVC VUI → `AVFrame`), read per-frame — the presenter derives
|
||||||
|
/// its Y′CbCr→RGB constant buffer from it (`csc_rows`), so a BT.601-signaled stream (a Linux
|
||||||
|
/// host's RGB-input NVENC) no longer renders with BT.709 coefficients.
|
||||||
|
pub color: ColorDesc,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A decoded frame still on the GPU: a D3D11 texture **array** plus the slice index the decoder
|
/// A decoded frame still on the GPU: a D3D11 texture **array** plus the slice index the decoder
|
||||||
@@ -112,9 +117,11 @@ pub struct GpuFrame {
|
|||||||
/// `sw_format`. The presenter keys its copy-texture/SRV formats off this: they must match the
|
/// `sw_format`. The presenter keys its copy-texture/SRV formats off this: they must match the
|
||||||
/// source array exactly for `CopySubresourceRegion`.
|
/// source array exactly for `CopySubresourceRegion`.
|
||||||
pub ten_bit: bool,
|
pub ten_bit: bool,
|
||||||
/// BT.2020 PQ HDR10 (ST.2084 transfer) vs ordinary BT.709 SDR. Selects shader + swapchain
|
/// BT.2020 PQ HDR10 (ST.2084 transfer) vs ordinary BT.709 SDR. Selects the swapchain colour
|
||||||
/// colour space only (the host couples 10-bit ⟺ HDR today, but formats key off `ten_bit`).
|
/// space only (the host couples 10-bit ⟺ HDR today, but formats key off `ten_bit`).
|
||||||
pub hdr: bool,
|
pub hdr: bool,
|
||||||
|
/// Per-frame CICP signaling — see [`CpuFrame::color`].
|
||||||
|
pub color: ColorDesc,
|
||||||
guard: D3d11FrameGuard,
|
guard: D3d11FrameGuard,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,9 +336,10 @@ impl SoftwareDecoder {
|
|||||||
/// matrix/range/transfer handling all lives in the presenter's shaders, shared with the
|
/// matrix/range/transfer handling all lives in the presenter's shaders, shared with the
|
||||||
/// D3D11VA path, so software frames are bit-comparable with hardware ones.
|
/// D3D11VA path, so software frames are bit-comparable with hardware ones.
|
||||||
fn convert(&mut self, frame: &AvFrame) -> Result<CpuFrame> {
|
fn convert(&mut self, frame: &AvFrame) -> Result<CpuFrame> {
|
||||||
use ffmpeg::color::TransferCharacteristic;
|
|
||||||
let (fmt, w, h) = (frame.format(), frame.width(), frame.height());
|
let (fmt, w, h) = (frame.format(), frame.width(), frame.height());
|
||||||
let hdr = frame.color_transfer_characteristic() == TransferCharacteristic::SMPTE2084;
|
// SAFETY: `frame` wraps a live decoded AVFrame for the duration of this call.
|
||||||
|
let color = unsafe { ColorDesc::from_raw(frame.as_ptr()) };
|
||||||
|
let hdr = color.is_pq();
|
||||||
// Source bit depth from the pix-fmt descriptor (stable FFmpeg public API).
|
// Source bit depth from the pix-fmt descriptor (stable FFmpeg public API).
|
||||||
let ten_bit = unsafe {
|
let ten_bit = unsafe {
|
||||||
let desc = ffmpeg::ffi::av_pix_fmt_desc_get(fmt.into());
|
let desc = ffmpeg::ffi::av_pix_fmt_desc_get(fmt.into());
|
||||||
@@ -356,6 +364,7 @@ impl SoftwareDecoder {
|
|||||||
uv_stride: conv.stride(1),
|
uv_stride: conv.stride(1),
|
||||||
ten_bit,
|
ten_bit,
|
||||||
hdr,
|
hdr,
|
||||||
|
color,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -586,8 +595,9 @@ impl D3d11vaDecoder {
|
|||||||
if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_D3D11 as i32 {
|
if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_D3D11 as i32 {
|
||||||
bail!("decoder returned a software frame (no D3D11 surface)");
|
bail!("decoder returned a software frame (no D3D11 surface)");
|
||||||
}
|
}
|
||||||
let hdr =
|
// SAFETY: `self.frame` is the live decoded AVFrame for the duration of this call.
|
||||||
(*self.frame).color_trc == ffi::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084;
|
let color = ColorDesc::from_raw(self.frame);
|
||||||
|
let hdr = color.is_pq();
|
||||||
let ten_bit = {
|
let ten_bit = {
|
||||||
let hwfc = (*self.frame).hw_frames_ctx;
|
let hwfc = (*self.frame).hw_frames_ctx;
|
||||||
!hwfc.is_null()
|
!hwfc.is_null()
|
||||||
@@ -604,6 +614,7 @@ impl D3d11vaDecoder {
|
|||||||
index: (*self.frame).data[1] as usize as u32,
|
index: (*self.frame).data[1] as usize as u32,
|
||||||
ten_bit,
|
ten_bit,
|
||||||
hdr,
|
hdr,
|
||||||
|
color,
|
||||||
guard: D3d11FrameGuard(cloned),
|
guard: D3d11FrameGuard(cloned),
|
||||||
};
|
};
|
||||||
log_layout_once(frame.width, frame.height, frame.index, hdr, ten_bit);
|
log_layout_once(frame.width, frame.height, frame.index, hdr, ten_bit);
|
||||||
|
|||||||
@@ -61,6 +61,16 @@ const ESCAPE_CHORD: [u32; 4] = [wire::BTN_LB, wire::BTN_RB, wire::BTN_START, wir
|
|||||||
/// Hold the [`ESCAPE_CHORD`] at least this long to disconnect (escalates the leave-fullscreen press).
|
/// Hold the [`ESCAPE_CHORD`] at least this long to disconnect (escalates the leave-fullscreen press).
|
||||||
const DISCONNECT_HOLD: Duration = Duration::from_millis(1500);
|
const DISCONNECT_HOLD: Duration = Duration::from_millis(1500);
|
||||||
|
|
||||||
|
/// Steam Deck built-in haptic keep-alive interval. The Deck's actuator decays inside SDL's
|
||||||
|
/// ~2 s internal rumble resend (`SDL_RUMBLE_RESEND_MS`), and SDL short-circuits a repeated
|
||||||
|
/// identical `set_rumble` value to a no-op device write — so a STEADY host value (which the
|
||||||
|
/// host delivers only as unchanging 500 ms refreshes) never re-kicks the motor and is felt as
|
||||||
|
/// a periodic pulse. We re-issue below the decay so the bursts fuse into a continuous buzz;
|
||||||
|
/// 40 ms mirrors SDL's sibling Steam-Controller driver keep-alive. Deck-only (see
|
||||||
|
/// [`Worker::issue_rumble`]); every other pad sustains rumble at the hardware level and is
|
||||||
|
/// left untouched.
|
||||||
|
const DECK_RUMBLE_KEEPALIVE_MS: u64 = 40;
|
||||||
|
|
||||||
/// Stick deflection below this is ignored for menu navigation (0.5 of full scale — Apple
|
/// Stick deflection below this is ignored for menu navigation (0.5 of full scale — Apple
|
||||||
/// `GamepadMenuInput` parity; menus want deliberate flicks, not drift).
|
/// `GamepadMenuInput` parity; menus want deliberate flicks, not drift).
|
||||||
const MENU_DEADZONE: u16 = 16384;
|
const MENU_DEADZONE: u16 = 16384;
|
||||||
@@ -641,6 +651,21 @@ struct Worker {
|
|||||||
menu_mode: bool,
|
menu_mode: bool,
|
||||||
menu_nav: MenuNav,
|
menu_nav: MenuNav,
|
||||||
menu_tx: async_channel::Sender<MenuEvent>,
|
menu_tx: async_channel::Sender<MenuEvent>,
|
||||||
|
/// Last rumble value handed to the active pad (the logical host value, pre-jitter) and
|
||||||
|
/// when — drives the Steam Deck haptic keep-alive in [`Worker::render_feedback`].
|
||||||
|
rumble_last: (u16, u16),
|
||||||
|
rumble_last_at: Option<Instant>,
|
||||||
|
/// Toggles the 1-LSB low-motor nudge that forces SDL past its identical-value dedupe on a
|
||||||
|
/// Deck keep-alive re-issue (see [`Worker::issue_rumble`]).
|
||||||
|
rumble_jitter: bool,
|
||||||
|
/// The host lease from a v2 rumble envelope: last non-zero level expires at this instant
|
||||||
|
/// unless the host renews it. `None` outside a live rumble or against a legacy host (which
|
||||||
|
/// sends no lease — the pad then relies on SDL's own duration expiry as before).
|
||||||
|
rumble_deadline: Option<Instant>,
|
||||||
|
/// The host-supplied TTL (ms) of the current envelope, handed to SDL as the `set_rumble`
|
||||||
|
/// duration; `0` = legacy host (fall back to the proven 1.5 s duration). Read by
|
||||||
|
/// [`Worker::issue_rumble`].
|
||||||
|
rumble_ttl_ms: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Worker {
|
impl Worker {
|
||||||
@@ -1225,32 +1250,101 @@ impl Worker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hand a rumble value to SDL on the active pad, remembering it for the Deck keep-alive.
|
||||||
|
/// SDL short-circuits an identical `(low, high)` with NO device write (it only re-arms its
|
||||||
|
/// expiration), so on a Deck keep-alive re-issue of the same non-zero value we flip a single
|
||||||
|
/// low-motor LSB — an imperceptible amplitude nudge — to force the write through and keep the
|
||||||
|
/// actuator physically fed. The SDL duration is the host's envelope TTL (a lease continuously
|
||||||
|
/// refreshed by renewals, so a sustained rumble never dies mid-effect and an abandoned one
|
||||||
|
/// self-silences at the TTL); against a legacy host (`rumble_ttl_ms == 0`) it stays the proven
|
||||||
|
/// 1.5 s.
|
||||||
|
fn issue_rumble(&mut self, low: u16, high: u16, deck: bool) {
|
||||||
|
let dur_ms: u32 = if self.rumble_ttl_ms == 0 {
|
||||||
|
1_500 // legacy host: no lease — keep the proven duration
|
||||||
|
} else {
|
||||||
|
// Floor the lease so a jittered renewal (or the ~40 ms Deck re-kick) can never gap the
|
||||||
|
// actuator between SDL writes.
|
||||||
|
(self.rumble_ttl_ms as u32).max(DECK_RUMBLE_KEEPALIVE_MS as u32 * 4)
|
||||||
|
};
|
||||||
|
let (out_low, out_high) =
|
||||||
|
if deck && (low, high) == self.rumble_last && (low, high) != (0, 0) {
|
||||||
|
self.rumble_jitter = !self.rumble_jitter;
|
||||||
|
(low ^ self.rumble_jitter as u16, high)
|
||||||
|
} else {
|
||||||
|
(low, high)
|
||||||
|
};
|
||||||
|
match self
|
||||||
|
.open
|
||||||
|
.as_mut()
|
||||||
|
.map(|(_, p)| p.set_rumble(out_low, out_high, dur_ms))
|
||||||
|
{
|
||||||
|
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in the
|
||||||
|
// right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The host logs
|
||||||
|
// the send side on 0xCA, so the two together pinpoint host-game vs client-render.
|
||||||
|
Some(Err(e)) => tracing::warn!(low, high, error = %e, "rumble: SDL set_rumble failed"),
|
||||||
|
Some(Ok(())) => tracing::debug!(low, high, "rumble: rendered"),
|
||||||
|
None => tracing::debug!(low, high, "rumble: received but no active pad to render"),
|
||||||
|
}
|
||||||
|
self.rumble_last = (low, high);
|
||||||
|
self.rumble_last_at = Some(Instant::now());
|
||||||
|
}
|
||||||
|
|
||||||
/// Drain and render the feedback planes — rumble plus HID output (lightbar /
|
/// Drain and render the feedback planes — rumble plus HID output (lightbar /
|
||||||
/// player LEDs / adaptive triggers) — on the active pad; this thread is their single
|
/// player LEDs / adaptive triggers) — on the active pad; this thread is their single
|
||||||
/// consumer. The host re-sends rumble state every ~500 ms, so the SDL duration only
|
/// consumer. Rumble arrives as self-terminating v2 envelopes: each carries a TTL the host
|
||||||
/// needs to outlive a couple of refresh periods: long enough that one or two lost
|
/// renews while the level holds and lets expire when it stops, so the actuator's divergence
|
||||||
/// refreshes don't gap a genuine long rumble, short enough that a stale nonzero state
|
/// from the host's intent is bounded by the wire, not by a client guess. A legacy host
|
||||||
/// (a stop lost host-side, a session torn down mid-buzz) dies on its own instead of
|
/// (`ttl == None`) has no lease — the pad falls back to SDL's own 1.5 s duration expiry as
|
||||||
/// droning for seconds.
|
/// before.
|
||||||
fn render_feedback(&mut self) {
|
fn render_feedback(&mut self) {
|
||||||
let Some(connector) = self.attached.clone() else {
|
let Some(connector) = self.attached.clone() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) {
|
// The Steam Deck's built-in haptic actuator decays inside SDL's ~2 s internal rumble
|
||||||
|
// resend, and SDL dedupes an unchanged `set_rumble` value to a no-op device write — so a
|
||||||
|
// steady host value is felt as a periodic pulse rather than a continuous buzz. Detect the
|
||||||
|
// Deck pad here and keep it fed below the decay (`DECK_RUMBLE_KEEPALIVE_MS`) — an actuator
|
||||||
|
// limitation no wire lease can fix — but bound the re-kick by the host's TTL so it can no
|
||||||
|
// longer sustain a value the host has stopped renewing. Every other pad sustains (and
|
||||||
|
// expires) at the SDL/hardware level.
|
||||||
|
let deck = self
|
||||||
|
.open
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|(id, _)| self.pad_info(*id))
|
||||||
|
.is_some_and(|p| matches!(p.pref, GamepadPref::SteamDeck));
|
||||||
|
let mut fresh = false;
|
||||||
|
while let Ok((pad, low, high, ttl)) = connector.next_rumble_ttl(Duration::ZERO) {
|
||||||
if pad == 0 {
|
if pad == 0 {
|
||||||
if let Some((_, p)) = self.open.as_mut() {
|
fresh = true;
|
||||||
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in
|
self.rumble_ttl_ms = ttl.unwrap_or(0);
|
||||||
// the right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The
|
// A v2 lease sets an explicit client-side deadline; a legacy update clears it and
|
||||||
// host logs the send side on 0xCA, so the two together pinpoint host-game vs
|
// leans on SDL's own duration expiry (unchanged behaviour).
|
||||||
// client-render.
|
self.rumble_deadline = match ttl {
|
||||||
if let Err(e) = p.set_rumble(low, high, 1_500) {
|
Some(ms) if (low, high) != (0, 0) => {
|
||||||
tracing::warn!(low, high, error = %e, "rumble: SDL set_rumble failed");
|
Some(Instant::now() + Duration::from_millis(ms as u64))
|
||||||
} else {
|
|
||||||
tracing::debug!(low, high, "rumble: rendered");
|
|
||||||
}
|
}
|
||||||
} else {
|
_ => None,
|
||||||
tracing::debug!(low, high, "rumble: received but no active pad to render");
|
};
|
||||||
}
|
self.issue_rumble(low, high, deck);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Deck keep-alive: no fresh datagram this tick but a non-zero value is latched. If the
|
||||||
|
// host lease has expired, silence the actuator (the host stopped renewing — the stop
|
||||||
|
// datagram was lost, or the host died); otherwise re-kick it so its discrete haptic bursts
|
||||||
|
// fuse into a continuous buzz. A legacy update leaves `rumble_deadline` None, so the
|
||||||
|
// re-kick behaves exactly as before (SDL's duration is the only backstop). Non-Deck pads
|
||||||
|
// never enter here (`deck` is false).
|
||||||
|
if deck && !fresh && self.rumble_last != (0, 0) {
|
||||||
|
if self.rumble_deadline.is_some_and(|d| Instant::now() >= d) {
|
||||||
|
self.rumble_deadline = None;
|
||||||
|
self.rumble_ttl_ms = 0;
|
||||||
|
self.issue_rumble(0, 0, deck);
|
||||||
|
} else if self
|
||||||
|
.rumble_last_at
|
||||||
|
.is_none_or(|t| t.elapsed() >= Duration::from_millis(DECK_RUMBLE_KEEPALIVE_MS))
|
||||||
|
{
|
||||||
|
let (low, high) = self.rumble_last;
|
||||||
|
self.issue_rumble(low, high, deck);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
while let Ok(hid) = connector.next_hidout(Duration::ZERO) {
|
while let Ok(hid) = connector.next_hidout(Duration::ZERO) {
|
||||||
@@ -1318,6 +1412,11 @@ impl Worker {
|
|||||||
menu_mode: false,
|
menu_mode: false,
|
||||||
menu_nav: MenuNav::new(),
|
menu_nav: MenuNav::new(),
|
||||||
menu_tx,
|
menu_tx,
|
||||||
|
rumble_last: (0, 0),
|
||||||
|
rumble_last_at: None,
|
||||||
|
rumble_jitter: false,
|
||||||
|
rumble_deadline: None,
|
||||||
|
rumble_ttl_ms: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ pub struct SessionParams {
|
|||||||
/// the host still gates the upgrade behind its own PUNKTFUNK_10BIT policy) — `0`
|
/// the host still gates the upgrade behind its own PUNKTFUNK_10BIT policy) — `0`
|
||||||
/// when the user turned HDR off in Settings ("never send me 10-bit").
|
/// when the user turned HDR off in Settings ("never send me 10-bit").
|
||||||
pub video_caps: u8,
|
pub video_caps: u8,
|
||||||
|
/// This display's HDR colour volume (primaries/white/luminance), when the embedder can read
|
||||||
|
/// it from the OS. Rides `Hello::display_hdr` → the host's virtual-display EDID, so host apps
|
||||||
|
/// tone-map to THIS panel. `None` = unknown/SDR (host EDID defaults). Overridable for testing
|
||||||
|
/// via `PUNKTFUNK_CLIENT_PEAK_NITS` (synthesizes a BT.2020 volume at that peak).
|
||||||
|
pub display_hdr: Option<punktfunk_core::quic::HdrMeta>,
|
||||||
/// Stream the default microphone to the host's virtual mic source.
|
/// Stream the default microphone to the host's virtual mic source.
|
||||||
pub mic_enabled: bool,
|
pub mic_enabled: bool,
|
||||||
/// Video decoder preference (Settings; `PUNKTFUNK_DECODER` overrides — see
|
/// Video decoder preference (Settings; `PUNKTFUNK_DECODER` overrides — see
|
||||||
@@ -99,6 +104,81 @@ pub struct Stats {
|
|||||||
/// IDR (or a mid-GOP join) unfreezes almost immediately instead of never.
|
/// IDR (or a mid-GOP join) unfreezes almost immediately instead of never.
|
||||||
const NO_OUTPUT_KEYFRAME_STREAK: u32 = 3;
|
const NO_OUTPUT_KEYFRAME_STREAK: u32 = 3;
|
||||||
|
|
||||||
|
/// Longest the pump holds the last good frame waiting for a post-loss re-anchor keyframe before it
|
||||||
|
/// gives up and resumes display. After a reference loss the hardware decoder does not error — it
|
||||||
|
/// conceals the reference-missing deltas (on RADV, the DPB-and-output-COINCIDE path renders them as
|
||||||
|
/// a gray plate with the new frame's motion painted over it) and returns Ok, so displaying them is
|
||||||
|
/// the "gray frames mid-stream" artifact. We instead freeze on the last good picture until a fresh
|
||||||
|
/// IDR re-anchors decode — the behaviour NVIDIA already shows (its DISTINCT output image + different
|
||||||
|
/// concealment reads as a brief freeze, not gray). This cap only bounds the freeze when recovery
|
||||||
|
/// genuinely stalls (host ignores the request, or an RFI recovery that never emits a keyframe), so a
|
||||||
|
/// glitch can never become a permanent freeze. A recovery IDR round-trips well under this on any
|
||||||
|
/// live link.
|
||||||
|
const REANCHOR_FREEZE_MAX: Duration = Duration::from_millis(500);
|
||||||
|
|
||||||
|
/// How many host intra-refresh recovery marks ([`USER_FLAG_RECOVERY_POINT`]) must arrive since the
|
||||||
|
/// latest frame gap before the pump lifts its freeze on an IDR-free stream. TWO, not one: with a
|
||||||
|
/// continuous rolling wave the host marks phase-fixed wave boundaries, so the FIRST boundary after a
|
||||||
|
/// loss is only partially healed — stripes swept BEFORE the loss still reference the lost frame — and
|
||||||
|
/// lifting there would flash a partially-stale picture. The SECOND boundary guarantees a full wave
|
||||||
|
/// swept entirely after the loss, so the picture is clean. This stays correct under repeated loss
|
||||||
|
/// because every new gap resets the count. The cost is up to ~2 wave periods of holding the last good
|
||||||
|
/// frame — the deliberate "hold longer, never show garbage" trade.
|
||||||
|
///
|
||||||
|
/// [`USER_FLAG_RECOVERY_POINT`]: punktfunk_core::packet::USER_FLAG_RECOVERY_POINT
|
||||||
|
const REANCHOR_MARKS_TO_LIFT: u32 = 2;
|
||||||
|
|
||||||
|
/// Backstop patience while a host intra-refresh heal is visibly in progress. Each recovery mark
|
||||||
|
/// pushes the freeze deadline out by this much, so a live mark stream (the host actively healing via
|
||||||
|
/// its wave) keeps the client patiently holding the last good frame instead of tripping the IDR
|
||||||
|
/// floor mid-heal. Must exceed the inter-mark interval (one wave period, ~0.5 s) with margin; if the
|
||||||
|
/// marks STOP (heal stalled, or the host isn't running intra-refresh) the deadline lapses and the
|
||||||
|
/// normal recovery-IDR floor fires, so a real stall still recovers.
|
||||||
|
const RECOVERY_MARK_PATIENCE: Duration = Duration::from_millis(1500);
|
||||||
|
|
||||||
|
/// Frames skipped when `got` arrives while `expected` was the next index, or `None` if `got` is
|
||||||
|
/// contiguous (`== expected`) or a straggler we have already passed. Frame indices are u32 counters
|
||||||
|
/// that wrap, so the "ahead" test is a wrapping subtraction split at the half-space: a small
|
||||||
|
/// positive delta is a forward gap (missing frames whose dependents will decode against absent
|
||||||
|
/// references); a delta in the top half is an index behind us.
|
||||||
|
fn index_gap(expected: u32, got: u32) -> Option<u32> {
|
||||||
|
let ahead = got.wrapping_sub(expected);
|
||||||
|
(ahead != 0 && ahead < u32::MAX / 2).then_some(ahead)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold one decoded frame into the re-anchor state and decide whether it lifts the post-loss freeze.
|
||||||
|
///
|
||||||
|
/// `is_keyframe` — a real IDR (always a clean re-anchor). `has_anchor` — this AU carried
|
||||||
|
/// [`USER_FLAG_RECOVERY_ANCHOR`](punktfunk_core::packet::USER_FLAG_RECOVERY_ANCHOR), the host's
|
||||||
|
/// definitive single-frame re-anchor from an LTR-RFI recovery (a clean P-frame coded against a
|
||||||
|
/// known-good reference), so it lifts on the FIRST occurrence exactly like an IDR — no two-mark wait.
|
||||||
|
/// `has_mark` — this AU carried [`USER_FLAG_RECOVERY_POINT`](punktfunk_core::packet::USER_FLAG_RECOVERY_POINT),
|
||||||
|
/// a host-signalled intra-refresh wave boundary (only *half* a re-anchor). `marks` — recovery marks
|
||||||
|
/// seen since the latest gap.
|
||||||
|
///
|
||||||
|
/// Returns `(lift, new_marks)`: `lift` clears the freeze; `new_marks` is the running count (reset to 0
|
||||||
|
/// on a lift). The two-mark rule ([`REANCHOR_MARKS_TO_LIFT`]) lives here so it is unit-tested
|
||||||
|
/// independent of the pump's channel/decoder plumbing — the first wave boundary after a loss is only
|
||||||
|
/// partially healed, so a single mark must NOT lift. An anchor (or IDR) is a *whole* re-anchor and
|
||||||
|
/// lifts immediately.
|
||||||
|
fn reanchor_after_frame(
|
||||||
|
is_keyframe: bool,
|
||||||
|
has_anchor: bool,
|
||||||
|
has_mark: bool,
|
||||||
|
marks: u32,
|
||||||
|
) -> (bool, u32) {
|
||||||
|
let marks = if has_mark {
|
||||||
|
marks.saturating_add(1)
|
||||||
|
} else {
|
||||||
|
marks
|
||||||
|
};
|
||||||
|
if is_keyframe || has_anchor || marks >= REANCHOR_MARKS_TO_LIFT {
|
||||||
|
(true, 0)
|
||||||
|
} else {
|
||||||
|
(false, marks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Frames the pump keeps waiting for their 0xCF host timing (pts → capture→received µs).
|
/// Frames the pump keeps waiting for their 0xCF host timing (pts → capture→received µs).
|
||||||
/// ~2 s at 120 Hz — a timing arrives within a frame or two of its AU, and against an old
|
/// ~2 s at 120 Hz — a timing arrives within a frame or two of its AU, and against an old
|
||||||
/// host (no 0xCF at all) this just caps the dead-weight ring.
|
/// host (no 0xCF at all) this just caps the dead-weight ring.
|
||||||
@@ -221,6 +301,9 @@ fn pump(
|
|||||||
params.audio_channels,
|
params.audio_channels,
|
||||||
crate::video::decodable_codecs(), // codecs FFmpeg can decode (HEVC/H.264/AV1)
|
crate::video::decodable_codecs(), // codecs FFmpeg can decode (HEVC/H.264/AV1)
|
||||||
params.preferred_codec, // the user's soft codec preference (0 = auto)
|
params.preferred_codec, // the user's soft codec preference (0 = auto)
|
||||||
|
// This display's HDR volume → the host's virtual-display EDID. The env hatch wins so an
|
||||||
|
// A/B run can pin an exact peak (PUNKTFUNK_CLIENT_PEAK_NITS=600).
|
||||||
|
punktfunk_core::client::display_hdr_env_override().or(params.display_hdr),
|
||||||
params.launch.clone(),
|
params.launch.clone(),
|
||||||
params.pin,
|
params.pin,
|
||||||
Some(params.identity),
|
Some(params.identity),
|
||||||
@@ -279,7 +362,9 @@ fn pump(
|
|||||||
})
|
})
|
||||||
.flatten();
|
.flatten();
|
||||||
|
|
||||||
let clock_offset = connector.clock_offset_ns;
|
// Live host↔client clock offset: loaded per frame (Relaxed) so mid-stream re-syncs (an NTP
|
||||||
|
// step, drift) keep the capture-clock latency stats honest — never cached at session start.
|
||||||
|
let clock_offset_live = connector.clock_offset_shared();
|
||||||
let mut total_frames = 0u64;
|
let mut total_frames = 0u64;
|
||||||
let mut window_start = Instant::now();
|
let mut window_start = Instant::now();
|
||||||
let mut frames_n = 0u32;
|
let mut frames_n = 0u32;
|
||||||
@@ -309,6 +394,20 @@ fn pump(
|
|||||||
// never drops, so the drop-count trigger below stays silent and the stream freezes
|
// never drops, so the drop-count trigger below stays silent and the stream freezes
|
||||||
// on the last good frame. A short streak forces a fresh IDR to re-anchor.
|
// on the last good frame. A short streak forces a fresh IDR to re-anchor.
|
||||||
let mut no_output_streak = 0u32;
|
let mut no_output_streak = 0u32;
|
||||||
|
// Freeze-until-reanchor: armed the moment we request a recovery keyframe (loss, decode error, or
|
||||||
|
// a no-output streak), it withholds the decoder's concealed frames from the presenter — which
|
||||||
|
// then redraws the last good picture — until a fresh keyframe re-anchors decode. See
|
||||||
|
// [`REANCHOR_FREEZE_MAX`] for why this exists and its backstop deadline.
|
||||||
|
let mut awaiting_reanchor = false;
|
||||||
|
let mut reanchor_deadline: Option<Instant> = None;
|
||||||
|
// Host intra-refresh recovery marks seen since the latest gap (see [`REANCHOR_MARKS_TO_LIFT`]).
|
||||||
|
// Reset to 0 whenever the freeze is (re-)armed, so a fresh loss always waits out two fresh marks.
|
||||||
|
let mut recovery_marks: u32 = 0;
|
||||||
|
// The frame_index we expect next (the host numbers frames consecutively). A jump means a frame
|
||||||
|
// went missing — the earliest, most reliable signal that the decoder is about to conceal, ~120 ms
|
||||||
|
// ahead of `frames_dropped` (the reassembler only declares a straggler lost once it ages out of
|
||||||
|
// the loss window, by which point the concealment already reached the screen).
|
||||||
|
let mut next_expected_index: Option<u32> = None;
|
||||||
|
|
||||||
let end: Option<String> = loop {
|
let end: Option<String> = loop {
|
||||||
if stop.load(Ordering::SeqCst) {
|
if stop.load(Ordering::SeqCst) {
|
||||||
@@ -324,9 +423,103 @@ fn pump(
|
|||||||
// fps / goodput count every received AU (spec), decoded or not.
|
// fps / goodput count every received AU (spec), decoded or not.
|
||||||
frames_n += 1;
|
frames_n += 1;
|
||||||
bytes_n += frame.data.len() as u64;
|
bytes_n += frame.data.len() as u64;
|
||||||
|
// Reference-continuity gate: the host numbers frames consecutively, so a jump in
|
||||||
|
// frame_index means a frame is missing (lost, or an out-of-order straggler the
|
||||||
|
// reassembler emitted a newer frame ahead of) and this AU references a picture we
|
||||||
|
// never decoded. On RADV the decoder conceals that as a gray plate with the new
|
||||||
|
// motion on top — the reported artifact, and it shows most on high-motion frames (a
|
||||||
|
// full-screen pan bursts far more packets than a static desktop or a UFO-test's small
|
||||||
|
// moving sprite, so it is the frame that loses shards). Arm the freeze at the FIRST
|
||||||
|
// such frame — ~120 ms before `frames_dropped` would — so the gray never reaches the
|
||||||
|
// screen; recovery IDRs stay on the existing throttled path (see the arm below).
|
||||||
|
match next_expected_index {
|
||||||
|
Some(exp) if frame.frame_index == exp => {
|
||||||
|
next_expected_index = Some(exp.wrapping_add(1)); // contiguous
|
||||||
|
}
|
||||||
|
// A forward gap: hold the last good frame — but DO NOT ask for a keyframe here.
|
||||||
|
// Hiding the concealment is free (the presenter redraws the last picture); an IDR
|
||||||
|
// is not — at 4K120 it is a multi-megabyte frame and a visible stutter, and it can
|
||||||
|
// re-trigger the very burst loss that caused this. The existing loss recovery below
|
||||||
|
// (`frames_dropped`, host-coalesced + throttled) still requests it at exactly the
|
||||||
|
// cadence it did before this change, so we add zero IDR pressure per pan. A
|
||||||
|
// straggler behind us (`index_gap` → None) leaves the expectation put so the real
|
||||||
|
// gap still trips.
|
||||||
|
Some(exp) => {
|
||||||
|
if let Some(gap) = index_gap(exp, frame.frame_index) {
|
||||||
|
let now = Instant::now();
|
||||||
|
awaiting_reanchor = true;
|
||||||
|
recovery_marks = 0;
|
||||||
|
reanchor_deadline = Some(now + REANCHOR_FREEZE_MAX);
|
||||||
|
next_expected_index = Some(frame.frame_index.wrapping_add(1));
|
||||||
|
// The gap carries the PRECISE lost range — [first missing, newest
|
||||||
|
// received - 1] — so this is the one recovery signal that can drive true
|
||||||
|
// reference-frame invalidation. Prefer an RFI request over a keyframe: an
|
||||||
|
// RFI-capable host (AMD LTR / NVENC) re-references a known-good picture and
|
||||||
|
// emits a clean P-frame tagged USER_FLAG_RECOVERY_ANCHOR (the freeze lifts
|
||||||
|
// on ONE frame, no 20-40× IDR spike); an incapable/old host forces a
|
||||||
|
// host-coalesced IDR instead, or ignores it (then the frames_dropped /
|
||||||
|
// overdue keyframe paths below are the backstop). Throttled with those
|
||||||
|
// paths (one recovery ask per 100 ms) so a burst of gaps — a full-screen
|
||||||
|
// pan shedding shards — can't storm the control stream. This fires ~120 ms
|
||||||
|
// before frames_dropped would, so recovery also starts sooner.
|
||||||
|
//
|
||||||
|
// A gap wider than RFI_MAX_RANGE is beyond any encoder's reference
|
||||||
|
// history (a seconds-long outage — or a phantom index jump, e.g. the
|
||||||
|
// first real AU after an old host's speed-test burst consumed video
|
||||||
|
// indexes): RFI is hopeless there, so ask for the IDR resync directly.
|
||||||
|
if last_kf_req
|
||||||
|
.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
|
||||||
|
{
|
||||||
|
last_kf_req = Some(now);
|
||||||
|
if gap > punktfunk_core::packet::RFI_MAX_RANGE {
|
||||||
|
let _ = connector.request_keyframe();
|
||||||
|
} else {
|
||||||
|
let _ = connector
|
||||||
|
.request_rfi(exp, frame.frame_index.wrapping_sub(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracing::trace!(
|
||||||
|
gap,
|
||||||
|
"frame gap — RFI recovery, holding last frame until re-anchor"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => next_expected_index = Some(frame.frame_index.wrapping_add(1)),
|
||||||
|
}
|
||||||
match decoder.decode(&frame.data) {
|
match decoder.decode(&frame.data) {
|
||||||
Ok(Some(image)) => {
|
Ok(Some(image)) => {
|
||||||
no_output_streak = 0; // a decoded frame — the anchor holds
|
// A decoded frame — the anchor holds.
|
||||||
|
no_output_streak = 0;
|
||||||
|
// Host-signalled intra-refresh recovery mark: on an IDR-free intra-refresh
|
||||||
|
// stream this wave-boundary flag is the only clean point the client can honor
|
||||||
|
// (the decoder never flags the re-anchor — the coded frame stays `P`). A live
|
||||||
|
// mark stream also means the host is actively healing, so push the backstop out
|
||||||
|
// rather than trip a mid-heal IDR (see `RECOVERY_MARK_PATIENCE`).
|
||||||
|
let has_mark =
|
||||||
|
frame.flags & punktfunk_core::packet::USER_FLAG_RECOVERY_POINT != 0;
|
||||||
|
// The host's definitive single-frame re-anchor: an LTR-RFI recovery frame (a
|
||||||
|
// clean P-frame off a known-good reference), the AMD twin of an IDR re-anchor
|
||||||
|
// but without the spike. It lifts on the FIRST occurrence.
|
||||||
|
let has_anchor =
|
||||||
|
frame.flags & punktfunk_core::packet::USER_FLAG_RECOVERY_ANCHOR != 0;
|
||||||
|
if has_mark && awaiting_reanchor {
|
||||||
|
reanchor_deadline = Some(Instant::now() + RECOVERY_MARK_PATIENCE);
|
||||||
|
}
|
||||||
|
// A fresh clean re-anchor lifts the freeze and shows this frame: a real intra
|
||||||
|
// keyframe (IDR, always clean), an LTR-RFI recovery anchor (also whole), OR the
|
||||||
|
// second recovery mark since the gap (the first wave boundary is only
|
||||||
|
// half-healed — see `reanchor_after_frame`).
|
||||||
|
let (lift, marks) = reanchor_after_frame(
|
||||||
|
image.is_keyframe(),
|
||||||
|
has_anchor,
|
||||||
|
has_mark,
|
||||||
|
recovery_marks,
|
||||||
|
);
|
||||||
|
recovery_marks = marks;
|
||||||
|
if lift {
|
||||||
|
awaiting_reanchor = false;
|
||||||
|
reanchor_deadline = None;
|
||||||
|
}
|
||||||
total_frames += 1;
|
total_frames += 1;
|
||||||
dec_path = match &image {
|
dec_path = match &image {
|
||||||
DecodedImage::Cpu(_) => "software",
|
DecodedImage::Cpu(_) => "software",
|
||||||
@@ -352,6 +545,8 @@ fn pump(
|
|||||||
let decoded_ns = now_ns();
|
let decoded_ns = now_ns();
|
||||||
// `host+network` stage: received expressed in the host's capture
|
// `host+network` stage: received expressed in the host's capture
|
||||||
// clock, minus the host-stamped capture pts (clamped (0, 10 s)).
|
// clock, minus the host-stamped capture pts (clamped (0, 10 s)).
|
||||||
|
let clock_offset =
|
||||||
|
clock_offset_live.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
let hn = (received_ns as i128 + clock_offset as i128 - frame.pts_ns as i128)
|
let hn = (received_ns as i128 + clock_offset as i128 - frame.pts_ns as i128)
|
||||||
.max(0) as u64;
|
.max(0) as u64;
|
||||||
if hn > 0 && hn < 10_000_000_000 {
|
if hn > 0 && hn < 10_000_000_000 {
|
||||||
@@ -379,11 +574,20 @@ fn pump(
|
|||||||
DecodedImage::VkFrame(v) => Some((v.timeline_sem, v.decode_done_value)),
|
DecodedImage::VkFrame(v) => Some((v.timeline_sem, v.decode_done_value)),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
let _ = frame_tx.force_send(DecodedFrame {
|
if awaiting_reanchor {
|
||||||
pts_ns: frame.pts_ns,
|
// Post-loss concealment: withhold this frame (it references a lost/gray
|
||||||
decoded_ns,
|
// reference) so the presenter keeps redrawing the last good picture
|
||||||
image,
|
// rather than flashing the decoder's gray plate. Dropped here — the
|
||||||
});
|
// hw-decode stat below still samples via `hw_fence` (raw handle + value,
|
||||||
|
// valid past the guard). Cleared by the next keyframe or the backstop.
|
||||||
|
tracing::trace!("holding last frame — awaiting post-loss re-anchor");
|
||||||
|
} else {
|
||||||
|
let _ = frame_tx.force_send(DecodedFrame {
|
||||||
|
pts_ns: frame.pts_ns,
|
||||||
|
decoded_ns,
|
||||||
|
image,
|
||||||
|
});
|
||||||
|
}
|
||||||
// `decode` stage: received→decode COMPLETE, single clock.
|
// `decode` stage: received→decode COMPLETE, single clock.
|
||||||
match hw_fence {
|
match hw_fence {
|
||||||
Some((sem, value)) => {
|
Some((sem, value)) => {
|
||||||
@@ -412,6 +616,12 @@ fn pump(
|
|||||||
// trip before asking again instead of flooding.
|
// trip before asking again instead of flooding.
|
||||||
if no_output_streak >= NO_OUTPUT_KEYFRAME_STREAK {
|
if no_output_streak >= NO_OUTPUT_KEYFRAME_STREAK {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
|
// Wedged on missing references: hold the last good frame until re-anchor
|
||||||
|
// (armed even when the IDR request itself is throttled — the stream is broken
|
||||||
|
// regardless of whether we ask again this iteration).
|
||||||
|
awaiting_reanchor = true;
|
||||||
|
recovery_marks = 0;
|
||||||
|
reanchor_deadline = Some(now + REANCHOR_FREEZE_MAX);
|
||||||
if last_kf_req
|
if last_kf_req
|
||||||
.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
|
.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
|
||||||
{
|
{
|
||||||
@@ -439,6 +649,9 @@ fn pump(
|
|||||||
// through the same throttle as loss recovery below.
|
// through the same throttle as loss recovery below.
|
||||||
if decoder.take_keyframe_request() {
|
if decoder.take_keyframe_request() {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
|
awaiting_reanchor = true;
|
||||||
|
recovery_marks = 0;
|
||||||
|
reanchor_deadline = Some(now + REANCHOR_FREEZE_MAX);
|
||||||
if last_kf_req
|
if last_kf_req
|
||||||
.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
|
.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
|
||||||
{
|
{
|
||||||
@@ -475,12 +688,33 @@ fn pump(
|
|||||||
if dropped > last_dropped {
|
if dropped > last_dropped {
|
||||||
last_dropped = dropped;
|
last_dropped = dropped;
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
|
// A dropped AU means the frames after it reference a picture we never decoded — the
|
||||||
|
// decoder will conceal them (gray on RADV). Freeze on the last good frame until a fresh
|
||||||
|
// IDR re-anchors, so the concealment never reaches the screen.
|
||||||
|
awaiting_reanchor = true;
|
||||||
|
recovery_marks = 0;
|
||||||
|
reanchor_deadline = Some(now + REANCHOR_FREEZE_MAX);
|
||||||
if last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) {
|
if last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) {
|
||||||
last_kf_req = Some(now);
|
last_kf_req = Some(now);
|
||||||
let _ = connector.request_keyframe();
|
let _ = connector.request_keyframe();
|
||||||
tracing::debug!(dropped, "requested keyframe (loss recovery)");
|
tracing::debug!(dropped, "requested keyframe (loss recovery)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Re-anchor overdue: the freeze has held the whole window with no keyframe — a lost recovery
|
||||||
|
// IDR, or a benign reorder that produced no `frames_dropped` and so requested none. Do NOT
|
||||||
|
// resume to gray (the one thing worse than a freeze): keep holding the last good frame and
|
||||||
|
// (re-)request a keyframe, throttled + host-coalesced, so a CLEAN re-anchor is what un-freezes
|
||||||
|
// us. A genuinely dead stream — host gone, link collapsed — is caught by the QUIC idle-timeout
|
||||||
|
// watchdog (returns to the menu), never by painting the decoder's concealment.
|
||||||
|
if awaiting_reanchor && reanchor_deadline.is_some_and(|d| Instant::now() >= d) {
|
||||||
|
let now = Instant::now();
|
||||||
|
reanchor_deadline = Some(now + REANCHOR_FREEZE_MAX);
|
||||||
|
if last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) {
|
||||||
|
last_kf_req = Some(now);
|
||||||
|
let _ = connector.request_keyframe();
|
||||||
|
tracing::debug!("re-anchor overdue — still holding, re-requesting keyframe");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if window_start.elapsed() >= Duration::from_secs(1) {
|
if window_start.elapsed() >= Duration::from_secs(1) {
|
||||||
let secs = window_start.elapsed().as_secs_f32();
|
let secs = window_start.elapsed().as_secs_f32();
|
||||||
@@ -562,18 +796,37 @@ fn spawn_audio(
|
|||||||
.name("punktfunk-audio-rx".into())
|
.name("punktfunk-audio-rx".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
let mut pcm = vec![0f32; 5760 * channels as usize]; // scratch: max Opus frame (120 ms) × channels
|
let mut pcm = vec![0f32; 5760 * channels as usize]; // scratch: max Opus frame (120 ms) × channels
|
||||||
|
let mut gaps = punktfunk_core::audio::AudioGapTracker::new();
|
||||||
|
let mut frame_samples = 0usize; // per-channel samples of the last decoded frame — the PLC unit
|
||||||
while !stop.load(Ordering::SeqCst) {
|
while !stop.load(Ordering::SeqCst) {
|
||||||
match connector.next_audio(Duration::from_millis(100)) {
|
match connector.next_audio(Duration::from_millis(100)) {
|
||||||
Ok(pkt) => match dec.decode_float(&pkt.data, &mut pcm, false) {
|
Ok(pkt) => {
|
||||||
// `samples` is per-channel; the interleaved frame is `samples * channels`.
|
// Conceal lost packets (a seq gap) with libopus PLC before decoding the one
|
||||||
Ok(samples) => {
|
// that arrived: empty input synthesizes `frame_samples` of interpolation per
|
||||||
let n = samples * channels as usize;
|
// missing packet — an inaudible fade instead of the click a hard gap makes.
|
||||||
let mut buf = player.take_buffer();
|
for _ in 0..gaps.missing_before(pkt.seq) {
|
||||||
buf.extend_from_slice(&pcm[..n]);
|
let plc = frame_samples * channels as usize;
|
||||||
player.push(buf);
|
if plc == 0 {
|
||||||
|
break; // no decoded frame yet to size the concealment from
|
||||||
|
}
|
||||||
|
if let Ok(samples) = dec.decode_float(&[], &mut pcm[..plc], false) {
|
||||||
|
let mut buf = player.take_buffer();
|
||||||
|
buf.extend_from_slice(&pcm[..samples * channels as usize]);
|
||||||
|
player.push(buf);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => tracing::debug!(error = %e, "opus decode"),
|
match dec.decode_float(&pkt.data, &mut pcm, false) {
|
||||||
},
|
// `samples` is per-channel; the interleaved frame is `samples * channels`.
|
||||||
|
Ok(samples) => {
|
||||||
|
frame_samples = samples;
|
||||||
|
let n = samples * channels as usize;
|
||||||
|
let mut buf = player.take_buffer();
|
||||||
|
buf.extend_from_slice(&pcm[..n]);
|
||||||
|
player.push(buf);
|
||||||
|
}
|
||||||
|
Err(e) => tracing::debug!(error = %e, "opus decode"),
|
||||||
|
}
|
||||||
|
}
|
||||||
Err(PunktfunkError::NoFrame) => {}
|
Err(PunktfunkError::NoFrame) => {}
|
||||||
Err(_) => break, // plane closed — the session is ending
|
Err(_) => break, // plane closed — the session is ending
|
||||||
}
|
}
|
||||||
@@ -583,3 +836,111 @@ fn spawn_audio(
|
|||||||
.map_err(|e| tracing::warn!(error = %e, "audio thread failed to start — audio disabled"))
|
.map_err(|e| tracing::warn!(error = %e, "audio thread failed to start — audio disabled"))
|
||||||
.ok()
|
.ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{index_gap, reanchor_after_frame, REANCHOR_MARKS_TO_LIFT};
|
||||||
|
|
||||||
|
// Simulate the pump's re-anchor state across a sequence of decoded frames: each `(is_keyframe,
|
||||||
|
// has_mark)` pair is folded through `reanchor_after_frame`, returning the frame index (0-based)
|
||||||
|
// at which the freeze first lifts, or `None` if it never does. `gap_before` reset points model a
|
||||||
|
// fresh loss re-arming the freeze (the pump zeroes the count at every gap/arm site).
|
||||||
|
fn lift_at(frames: &[(bool, bool)]) -> Option<usize> {
|
||||||
|
let mut marks = 0u32;
|
||||||
|
for (i, &(is_kf, has_mark)) in frames.iter().enumerate() {
|
||||||
|
// The intra-refresh-mark model never carries an LTR-RFI anchor (that path is exercised
|
||||||
|
// by `an_rfi_anchor_lifts_immediately`), so `has_anchor` is always false here.
|
||||||
|
let (lift, m) = reanchor_after_frame(is_kf, false, has_mark, marks);
|
||||||
|
marks = m;
|
||||||
|
if lift {
|
||||||
|
return Some(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_single_recovery_mark_does_not_lift() {
|
||||||
|
// The first wave boundary after a loss is only half-healed — one mark must hold the freeze.
|
||||||
|
assert_eq!(REANCHOR_MARKS_TO_LIFT, 2);
|
||||||
|
assert_eq!(lift_at(&[(false, true)]), None);
|
||||||
|
assert_eq!(
|
||||||
|
lift_at(&[(false, false), (false, true), (false, false)]),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_second_recovery_mark_lifts() {
|
||||||
|
// Two marks = a full wave swept after the loss → clean re-anchor.
|
||||||
|
assert_eq!(lift_at(&[(false, true), (false, true)]), Some(1));
|
||||||
|
assert_eq!(
|
||||||
|
lift_at(&[(false, false), (false, true), (false, false), (false, true)]),
|
||||||
|
Some(3)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_real_keyframe_lifts_immediately() {
|
||||||
|
// An IDR is always a clean anchor — no marks needed.
|
||||||
|
assert_eq!(lift_at(&[(true, false)]), Some(0));
|
||||||
|
assert_eq!(lift_at(&[(false, true), (true, false)]), Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_fresh_gap_resets_the_mark_count() {
|
||||||
|
// The pump zeroes `recovery_marks` at each arm site, so one mark before a new gap plus one
|
||||||
|
// after must NOT lift — the model resets the running count to imitate that.
|
||||||
|
let mut marks = 0u32;
|
||||||
|
let (_, m) = reanchor_after_frame(false, false, true, marks); // mark #1 (pre-gap)
|
||||||
|
marks = m;
|
||||||
|
assert_eq!(marks, 1);
|
||||||
|
marks = 0; // a new gap re-arms the freeze → count reset
|
||||||
|
let (lift, m) = reanchor_after_frame(false, false, true, marks); // first mark of the new wave
|
||||||
|
assert!(!lift, "a single post-gap mark must not lift");
|
||||||
|
assert_eq!(m, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_rfi_anchor_lifts_immediately() {
|
||||||
|
// An LTR-RFI recovery anchor is a WHOLE re-anchor (a clean P-frame off a known-good
|
||||||
|
// reference), so — like an IDR — it lifts on the FIRST occurrence, no two-mark wait.
|
||||||
|
let (lift, marks) = reanchor_after_frame(false, true, false, 0);
|
||||||
|
assert!(lift, "an RFI anchor must lift the freeze immediately");
|
||||||
|
assert_eq!(marks, 0, "a lift resets the running mark count");
|
||||||
|
// Even with zero prior marks and no keyframe, the anchor alone is sufficient.
|
||||||
|
let (lift, _) = reanchor_after_frame(false, true, true, 1);
|
||||||
|
assert!(lift, "an anchor lifts regardless of the pending mark count");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contiguous_indices_are_not_a_gap() {
|
||||||
|
assert_eq!(index_gap(5, 5), None);
|
||||||
|
assert_eq!(index_gap(0, 0), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_forward_jump_reports_the_skip_count() {
|
||||||
|
assert_eq!(index_gap(5, 6), Some(1)); // one frame missing
|
||||||
|
assert_eq!(index_gap(5, 9), Some(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_straggler_behind_us_is_not_a_gap() {
|
||||||
|
// The reassembler emitted a newer frame first; the late one must not re-arm.
|
||||||
|
assert_eq!(index_gap(9, 5), None);
|
||||||
|
assert_eq!(index_gap(1, 0), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_index_counter_wraps_cleanly() {
|
||||||
|
// last frame = u32::MAX, so the next expected wraps to 0.
|
||||||
|
// Contiguous across the wrap.
|
||||||
|
assert_eq!(index_gap(0, 0), None);
|
||||||
|
// waiting on u32::MAX, frame 0 arrived → MAX was skipped.
|
||||||
|
assert_eq!(index_gap(u32::MAX, 0), Some(1));
|
||||||
|
assert_eq!(index_gap(u32::MAX, 2), Some(3));
|
||||||
|
// an old frame arriving just after the wrap is still a straggler.
|
||||||
|
assert_eq!(index_gap(0, u32::MAX), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -406,6 +406,19 @@ pub struct Settings {
|
|||||||
/// Experimental: the game-library browser ("Browse library…" on saved cards) —
|
/// Experimental: the game-library browser ("Browse library…" on saved cards) —
|
||||||
/// mirrors the Apple client's "Show game library" toggle, default off.
|
/// mirrors the Apple client's "Show game library" toggle, default off.
|
||||||
pub library_enabled: bool,
|
pub library_enabled: bool,
|
||||||
|
/// Match-window resolution policy (design/midstream-resolution-resize.md D1): the
|
||||||
|
/// stream mode follows the session window — the connect asks for the window's pixel
|
||||||
|
/// size and a mid-session resize renegotiates the host's virtual display + encoder
|
||||||
|
/// (`Reconfigure`), so windowed sessions stream native-resolution pixels instead of
|
||||||
|
/// scaling. Overrides `width`/`height` while on; on fullscreen it degenerates to the
|
||||||
|
/// display's native mode. Default off (Auto-native stays the shipped default until
|
||||||
|
/// the per-backend validation matrix is green).
|
||||||
|
pub match_window: bool,
|
||||||
|
/// The session window's last logical size under `match_window`: the next launch
|
||||||
|
/// opens its window at this size, so the first connect's mode already matches what
|
||||||
|
/// the user will be looking at. `0` = never stored → the 1280×720 default.
|
||||||
|
pub last_window_w: u32,
|
||||||
|
pub last_window_h: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_codec() -> String {
|
fn default_codec() -> String {
|
||||||
@@ -466,6 +479,9 @@ impl Default for Settings {
|
|||||||
stats_verbosity: None,
|
stats_verbosity: None,
|
||||||
fullscreen_on_stream: true,
|
fullscreen_on_stream: true,
|
||||||
library_enabled: false,
|
library_enabled: false,
|
||||||
|
match_window: false,
|
||||||
|
last_window_w: 0,
|
||||||
|
last_window_h: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ pub struct VkVideoFrame {
|
|||||||
pub width: u32,
|
pub width: u32,
|
||||||
pub height: u32,
|
pub height: u32,
|
||||||
pub color: ColorDesc,
|
pub color: ColorDesc,
|
||||||
|
/// Intra keyframe (IDR/I): the stream's re-anchor point. The pump resumes display on
|
||||||
|
/// one after suppressing the concealed frames a reference loss leaves in its wake (on
|
||||||
|
/// RADV a lost reference decodes to a gray plate with the new motion painted on top).
|
||||||
|
pub keyframe: bool,
|
||||||
/// Keeps the cloned AVFrame (and through it the VkImage + frames context) alive
|
/// Keeps the cloned AVFrame (and through it the VkImage + frames context) alive
|
||||||
/// until the presenter's fence proves the GPU reads done — same mechanism as the
|
/// until the presenter's fence proves the GPU reads done — same mechanism as the
|
||||||
/// VAAPI path's DRM guard.
|
/// VAAPI path's DRM guard.
|
||||||
@@ -119,11 +123,13 @@ pub struct ColorDesc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ColorDesc {
|
impl ColorDesc {
|
||||||
/// Read the CICP fields off a raw decoded frame.
|
/// Read the CICP fields off a raw decoded frame. Public: the Windows client's raw-FFI
|
||||||
|
/// D3D11VA/software decoders build their per-frame `ColorDesc` with it too (same
|
||||||
|
/// `ffmpeg-next` major, so the `AVFrame` type unifies across the workspace).
|
||||||
///
|
///
|
||||||
/// # Safety
|
/// # Safety
|
||||||
/// `frame` must point to a valid `AVFrame` (alive for the duration of the call).
|
/// `frame` must point to a valid `AVFrame` (alive for the duration of the call).
|
||||||
pub(crate) unsafe fn from_raw(frame: *const ffmpeg::ffi::AVFrame) -> ColorDesc {
|
pub unsafe fn from_raw(frame: *const ffmpeg::ffi::AVFrame) -> ColorDesc {
|
||||||
// SAFETY: caller guarantees a live AVFrame; these are plain enum field reads.
|
// SAFETY: caller guarantees a live AVFrame; these are plain enum field reads.
|
||||||
unsafe {
|
unsafe {
|
||||||
ColorDesc {
|
ColorDesc {
|
||||||
@@ -141,6 +147,109 @@ impl ColorDesc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True if the decoder tagged this frame as a full IDR keyframe — a guaranteed clean re-anchor
|
||||||
|
/// after which the picture is loss-free, so the pump can lift a post-loss display freeze here.
|
||||||
|
///
|
||||||
|
/// Keys off `AV_FRAME_FLAG_KEY` (with `pict_type == I` as a belt for decoders that fill pict_type
|
||||||
|
/// but not the flag). NOTE: FFmpeg's H.264/HEVC decode layer sets this flag **only for true IDR
|
||||||
|
/// frames**, never for an *intra-refresh recovery point*. H.264 flags key only when a picture's
|
||||||
|
/// `recovery_frame_cnt == 0` (a moving band uses `> 0`); HEVC clears the flag on every non-IRAP
|
||||||
|
/// frame regardless of the recovery-point SEI. So an intra-refresh host (NVENC/AMF/QSV) heals the
|
||||||
|
/// picture over N P-frames with no decoded frame ever flagged key — this function cannot detect
|
||||||
|
/// that clean point, and the pump would freeze until the `REANCHOR_FREEZE_MAX` backstop (in
|
||||||
|
/// `session.rs`) forces a real IDR. Detecting an intra-refresh re-anchor requires an out-of-band
|
||||||
|
/// host wire signal on the AU that completes the wave; that is not yet plumbed.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `frame` must point to a valid `AVFrame` alive for the duration of the call.
|
||||||
|
pub unsafe fn frame_is_keyframe(frame: *const ffmpeg::ffi::AVFrame) -> bool {
|
||||||
|
// SAFETY: caller guarantees a live AVFrame; plain field reads.
|
||||||
|
unsafe {
|
||||||
|
((*frame).flags & ffmpeg::ffi::AV_FRAME_FLAG_KEY) != 0
|
||||||
|
|| (*frame).pict_type == ffmpeg::ffi::AVPictureType::AV_PICTURE_TYPE_I
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DecodedImage {
|
||||||
|
/// Whether the frame is an intra keyframe — see [`frame_is_keyframe`]. The pump uses
|
||||||
|
/// this as the stream's re-anchor signal after a loss.
|
||||||
|
pub fn is_keyframe(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
DecodedImage::Cpu(f) => f.keyframe,
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
DecodedImage::Dmabuf(f) => f.keyframe,
|
||||||
|
DecodedImage::VkFrame(f) => f.keyframe,
|
||||||
|
#[cfg(windows)]
|
||||||
|
DecodedImage::D3d11(f) => f.keyframe,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The decoded image's pixel dimensions. The presenter's resize indicator uses these
|
||||||
|
/// as the mid-stream-resize END signal: a frame arriving at the target size means the
|
||||||
|
/// new-mode picture is on glass (the ack alone lands before the host's rebuild does).
|
||||||
|
pub fn dimensions(&self) -> (u32, u32) {
|
||||||
|
match self {
|
||||||
|
DecodedImage::Cpu(f) => (f.width, f.height),
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
DecodedImage::Dmabuf(f) => (f.width, f.height),
|
||||||
|
DecodedImage::VkFrame(f) => (f.width, f.height),
|
||||||
|
#[cfg(windows)]
|
||||||
|
DecodedImage::D3d11(f) => (f.width, f.height),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Y′CbCr→RGB conversion as three vec4 rows for a shader constant buffer / push-constant
|
||||||
|
/// block: `rgb[i] = dot(r[i].xyz, yuv) + r[i].w` — bit-depth exact. The ONE coefficient
|
||||||
|
/// implementation every presenter derives its CSC from (Vulkan push constants, the Windows
|
||||||
|
/// client's D3D11 constant buffer), so a stream's signaled matrix/range is honored identically
|
||||||
|
/// everywhere; the Apple client ports this function (and its tests) to Swift.
|
||||||
|
///
|
||||||
|
/// `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). `msb_packed` folds in the P010/X6 packing factor: 10 significant bits live in
|
||||||
|
/// the MSBs of 16, so a UNORM16 sample reads `code·64/65535` — multiplying by
|
||||||
|
/// `65535/65472` recovers exact `code/1023`.
|
||||||
|
pub fn csc_rows(desc: ColorDesc, depth: u8, msb_packed: bool) -> [[f32; 4]; 3] {
|
||||||
|
// BT.601 (5/6), BT.2020 (9/10); everything else — incl. unspecified — is the host's
|
||||||
|
// BT.709 SDR default (mirrors the software path's swscale coefficient choice).
|
||||||
|
let (kr, kb) = match desc.matrix {
|
||||||
|
5 | 6 => (0.299, 0.114),
|
||||||
|
9 | 10 => (0.2627, 0.0593),
|
||||||
|
_ => (0.2126, 0.0722),
|
||||||
|
};
|
||||||
|
let kg = 1.0 - kr - kb;
|
||||||
|
let max = f64::from((1u32 << depth) - 1); // 255 / 1023
|
||||||
|
let step = f64::from(1u32 << (depth - 8)); // code points per 8-bit step: 1 / 4
|
||||||
|
let pack = if msb_packed { 65535.0 / 65472.0 } else { 1.0 };
|
||||||
|
let (sy, oy, sc) = if desc.full_range {
|
||||||
|
(pack, 0.0f64, pack)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
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 = [
|
||||||
|
[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],
|
||||||
|
];
|
||||||
|
core::array::from_fn(|r| {
|
||||||
|
let w: f64 = (0..3).map(|c| m[r][c] * off[c]).sum();
|
||||||
|
[m[r][0] as f32, m[r][1] as f32, m[r][2] as f32, w as f32]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// RGBA pixels for `GdkMemoryTexture` (which takes a stride).
|
/// RGBA pixels for `GdkMemoryTexture` (which takes a stride).
|
||||||
pub struct CpuFrame {
|
pub struct CpuFrame {
|
||||||
pub width: u32,
|
pub width: u32,
|
||||||
@@ -152,6 +261,8 @@ pub struct CpuFrame {
|
|||||||
/// pixels are full-range RGB), but a PQ/BT.2020 stream keeps its transfer + primaries
|
/// pixels are full-range RGB), but a PQ/BT.2020 stream keeps its transfer + primaries
|
||||||
/// baked in — the presenter tags the texture so GTK tone-maps it.
|
/// baked in — the presenter tags the texture so GTK tone-maps it.
|
||||||
pub color: ColorDesc,
|
pub color: ColorDesc,
|
||||||
|
/// Intra keyframe (IDR/I) — the pump's post-loss re-anchor signal. See [`VkVideoFrame`].
|
||||||
|
pub keyframe: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A decoded frame still on the GPU: dmabuf fds + plane layout for
|
/// A decoded frame still on the GPU: dmabuf fds + plane layout for
|
||||||
@@ -169,6 +280,8 @@ pub struct DmabufFrame {
|
|||||||
/// Signaling of the source frame — drives the `GdkDmabufTexture` color state (BT.709
|
/// Signaling of the source frame — drives the `GdkDmabufTexture` color state (BT.709
|
||||||
/// narrow for SDR, BT.2020 PQ for an HDR stream).
|
/// narrow for SDR, BT.2020 PQ for an HDR stream).
|
||||||
pub color: ColorDesc,
|
pub color: ColorDesc,
|
||||||
|
/// Intra keyframe (IDR/I) — the pump's post-loss re-anchor signal. See [`VkVideoFrame`].
|
||||||
|
pub keyframe: bool,
|
||||||
pub guard: DrmFrameGuard,
|
pub guard: DrmFrameGuard,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,6 +704,9 @@ impl SoftwareDecoder {
|
|||||||
stride: dst_linesize[0] as usize,
|
stride: dst_linesize[0] as usize,
|
||||||
rgba,
|
rgba,
|
||||||
color,
|
color,
|
||||||
|
// `is_key()` reads the same intra flag `frame_is_keyframe` derives from pict_type
|
||||||
|
// for the hardware paths; ffmpeg-next handles the FFmpeg-version binding split.
|
||||||
|
keyframe: frame.is_key(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -791,6 +907,7 @@ impl VaapiDecoder {
|
|||||||
// SAFETY: `self.frame` is the live decoded AVFrame (unref'd only after
|
// SAFETY: `self.frame` is the live decoded AVFrame (unref'd only after
|
||||||
// this returns); plain CICP field reads.
|
// this returns); plain CICP field reads.
|
||||||
color: ColorDesc::from_raw(self.frame),
|
color: ColorDesc::from_raw(self.frame),
|
||||||
|
keyframe: frame_is_keyframe(self.frame),
|
||||||
guard,
|
guard,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1310,6 +1427,7 @@ impl VulkanDecoder {
|
|||||||
width: (*self.frame).width as u32,
|
width: (*self.frame).width as u32,
|
||||||
height: (*self.frame).height as u32,
|
height: (*self.frame).height as u32,
|
||||||
color: ColorDesc::from_raw(self.frame),
|
color: ColorDesc::from_raw(self.frame),
|
||||||
|
keyframe: frame_is_keyframe(self.frame),
|
||||||
guard: DrmFrameGuard(clone),
|
guard: DrmFrameGuard(clone),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1387,6 +1505,117 @@ unsafe extern "C" fn pick_vulkan(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn desc(matrix: u8, full_range: bool) -> ColorDesc {
|
||||||
|
ColorDesc {
|
||||||
|
primaries: 1,
|
||||||
|
transfer: 1,
|
||||||
|
matrix,
|
||||||
|
full_range,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply(rows: &[[f32; 4]; 3], yuv: [f32; 3]) -> [f32; 3] {
|
||||||
|
core::array::from_fn(|r| {
|
||||||
|
rows[r][0] * yuv[0] + rows[r][1] * yuv[1] + rows[r][2] * yuv[2] + rows[r][3]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 10-bit limited MSB-packed (P010/X6): reference white Y=940, black Y=64, neutral
|
||||||
|
/// chroma 512 — sampled as UNORM16 of `code << 6`.
|
||||||
|
#[test]
|
||||||
|
fn bt2020_10bit_limited_white_black() {
|
||||||
|
let rows = csc_rows(desc(9, false), 10, true);
|
||||||
|
let s = |code: u32| ((code << 6) as f32) / 65535.0;
|
||||||
|
let white = apply(&rows, [s(940), s(512), s(512)]);
|
||||||
|
let black = apply(&rows, [s(64), s(512), s(512)]);
|
||||||
|
for (w, b) in white.iter().zip(black) {
|
||||||
|
assert!((w - 1.0).abs() < 0.002, "white {white:?}");
|
||||||
|
assert!(b.abs() < 0.002, "black {black:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reference white (Y=235, U=V=128 limited) → RGB 1.0; reference black (Y=16) → 0.0
|
||||||
|
/// — the GL presenter's test, in row form.
|
||||||
|
#[test]
|
||||||
|
fn bt709_limited_white_black() {
|
||||||
|
let rows = csc_rows(desc(1, false), 8, false);
|
||||||
|
let white = apply(&rows, [235.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]);
|
||||||
|
let black = apply(&rows, [16.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]);
|
||||||
|
for (w, b) in white.iter().zip(black) {
|
||||||
|
assert!((w - 1.0).abs() < 0.005, "white {white:?}");
|
||||||
|
assert!(b.abs() < 0.005, "black {black:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full-range identity points + the 601-vs-709 red excursion (guards the
|
||||||
|
/// matrix-code dispatch), same as the GL presenter's test.
|
||||||
|
#[test]
|
||||||
|
fn full_range_and_red_excursion() {
|
||||||
|
let rows = csc_rows(desc(5, true), 8, false);
|
||||||
|
let white = apply(&rows, [1.0, 0.5, 0.5]);
|
||||||
|
assert!(white.iter().all(|v| (v - 1.0).abs() < 1e-5), "{white:?}");
|
||||||
|
let red = apply(&rows, [0.0, 0.5, 1.0]);
|
||||||
|
assert!((red[0] - 2.0 * (1.0 - 0.299) * 0.5).abs() < 1e-4, "{red:?}");
|
||||||
|
let rows709 = csc_rows(desc(1, true), 8, false);
|
||||||
|
let red709 = apply(&rows709, [0.0, 0.5, 1.0]);
|
||||||
|
assert!(
|
||||||
|
(red709[0] - 2.0 * (1.0 - 0.2126) * 0.5).abs() < 1e-4,
|
||||||
|
"{red709:?}"
|
||||||
|
);
|
||||||
|
assert!((red[0] - red709[0]).abs() > 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The row form must agree with the GL presenter's column-major `yuv_to_rgb` on a
|
||||||
|
/// grid of inputs — same math, different packing.
|
||||||
|
#[test]
|
||||||
|
fn rows_match_the_gl_matrix_form() {
|
||||||
|
for (matrix, full) in [(1u8, false), (1, true), (5, false), (9, false), (9, true)] {
|
||||||
|
let d = desc(matrix, full);
|
||||||
|
let rows = csc_rows(d, 8, false);
|
||||||
|
// Reimplementation of video_gl::yuv_to_rgb's application for comparison.
|
||||||
|
let (kr, kb) = match matrix {
|
||||||
|
5 | 6 => (0.299f32, 0.114f32),
|
||||||
|
9 | 10 => (0.2627, 0.0593),
|
||||||
|
_ => (0.2126, 0.0722),
|
||||||
|
};
|
||||||
|
let kg = 1.0 - kr - kb;
|
||||||
|
let (sy, oy, sc) = if full {
|
||||||
|
(1.0f32, 0.0f32, 1.0f32)
|
||||||
|
} else {
|
||||||
|
(255.0 / 219.0, -16.0 / 255.0, 255.0 / 224.0)
|
||||||
|
};
|
||||||
|
let mat = [
|
||||||
|
sy,
|
||||||
|
sy,
|
||||||
|
sy,
|
||||||
|
0.0,
|
||||||
|
-2.0 * (1.0 - kb) * kb / kg * sc,
|
||||||
|
2.0 * (1.0 - kb) * sc,
|
||||||
|
2.0 * (1.0 - kr) * sc,
|
||||||
|
-2.0 * (1.0 - kr) * kr / kg * sc,
|
||||||
|
0.0,
|
||||||
|
];
|
||||||
|
let off = [oy, -0.5, -0.5];
|
||||||
|
for yuv in [
|
||||||
|
[0.1f32, 0.3, 0.7],
|
||||||
|
[0.9, 0.5, 0.5],
|
||||||
|
[0.5, 0.2, 0.8],
|
||||||
|
[16.0 / 255.0, 0.5, 0.5],
|
||||||
|
] {
|
||||||
|
let v = [yuv[0] + off[0], yuv[1] + off[1], yuv[2] + off[2]];
|
||||||
|
let gl: [f32; 3] =
|
||||||
|
core::array::from_fn(|r| (0..3).map(|c| mat[c * 3 + r] * v[c]).sum());
|
||||||
|
let ours = apply(&rows, yuv);
|
||||||
|
for (a, b) in gl.iter().zip(ours) {
|
||||||
|
assert!(
|
||||||
|
(a - b).abs() < 1e-5,
|
||||||
|
"{matrix}/{full}: gl {gl:?} rows {ours:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Lock the DRM FourCC magic numbers against typos — these are the exact values
|
/// Lock the DRM FourCC magic numbers against typos — these are the exact values
|
||||||
/// `<drm_fourcc.h>` defines, and a wrong one is what painted the Steam Deck green.
|
/// `<drm_fourcc.h>` defines, and a wrong one is what painted the Steam Deck green.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1434,4 +1663,82 @@ mod tests {
|
|||||||
assert!(f.color.is_pq());
|
assert!(f.color.is_pq());
|
||||||
assert_eq!((f.width, f.height), (64, 64));
|
assert_eq!((f.width, f.height), (64, 64));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Golden colour fixtures: one 256×64 LOSSLESS x265 IDR of 8 fully-saturated colour bars per
|
||||||
|
/// signaling variant (generated offline with ffmpeg/libx265; the RGB→YUV conversion matched
|
||||||
|
/// to the VUI each fixture declares, so the original RGB is recoverable ±1 code). Decoding
|
||||||
|
/// through the real CPU path (`SoftwareDecoder` → per-frame `ColorDesc` → swscale with the
|
||||||
|
/// signaled matrix/range) must reproduce the bars — the end-to-end guard for the
|
||||||
|
/// signaling-driven CSC across BT.601/709 × limited/full. A hardcoded-709 regression fails
|
||||||
|
/// the 601 fixture by tens of code points; a range mix-up fails the full-range one.
|
||||||
|
#[test]
|
||||||
|
fn software_decode_reproduces_golden_bars() {
|
||||||
|
const BARS: [(u8, u8, u8); 8] = [
|
||||||
|
(255, 255, 255),
|
||||||
|
(255, 255, 0),
|
||||||
|
(0, 255, 255),
|
||||||
|
(0, 255, 0),
|
||||||
|
(255, 0, 255),
|
||||||
|
(255, 0, 0),
|
||||||
|
(0, 0, 255),
|
||||||
|
(0, 0, 0),
|
||||||
|
];
|
||||||
|
let fixtures: [(&str, &[u8], ColorDesc); 3] = [
|
||||||
|
(
|
||||||
|
"601-limited",
|
||||||
|
include_bytes!("../tests/bars-601-limited.h265"),
|
||||||
|
ColorDesc {
|
||||||
|
primaries: 1,
|
||||||
|
transfer: 1,
|
||||||
|
matrix: 5, // BT.470BG — what a Linux host's RGB-input NVENC signals
|
||||||
|
full_range: false,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"709-limited",
|
||||||
|
include_bytes!("../tests/bars-709-limited.h265"),
|
||||||
|
ColorDesc {
|
||||||
|
primaries: 1,
|
||||||
|
transfer: 1,
|
||||||
|
matrix: 1,
|
||||||
|
full_range: false,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"709-full",
|
||||||
|
include_bytes!("../tests/bars-709-full.h265"),
|
||||||
|
ColorDesc {
|
||||||
|
primaries: 1,
|
||||||
|
transfer: 1,
|
||||||
|
matrix: 1,
|
||||||
|
full_range: true, // the PUNKTFUNK_444_FULLRANGE experiment's signaling
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (name, au, want_color) in fixtures {
|
||||||
|
let mut dec = SoftwareDecoder::new(ffmpeg::codec::Id::HEVC).expect("hevc decoder");
|
||||||
|
let mut got = dec.decode(au).expect("decode");
|
||||||
|
if got.is_none() {
|
||||||
|
dec.decoder.send_eof().ok();
|
||||||
|
let mut frame = AvFrame::empty();
|
||||||
|
if dec.decoder.receive_frame(&mut frame).is_ok() {
|
||||||
|
got = Some(dec.convert_rgba(&frame).expect("convert"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let f = got.unwrap_or_else(|| panic!("{name}: no frame decoded"));
|
||||||
|
assert_eq!(f.color, want_color, "{name}: signaling");
|
||||||
|
assert_eq!((f.width, f.height), (256, 64), "{name}: dims");
|
||||||
|
for (i, (r, g, b)) in BARS.iter().enumerate() {
|
||||||
|
let (cx, cy) = (i * 32 + 16, 32usize);
|
||||||
|
let o = cy * f.stride + cx * 4;
|
||||||
|
let px = &f.rgba[o..o + 3];
|
||||||
|
for (got, want) in px.iter().zip([r, g, b]) {
|
||||||
|
assert!(
|
||||||
|
got.abs_diff(*want) <= 3,
|
||||||
|
"{name} bar {i}: got {px:?}, want ({r},{g},{b})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,10 +52,12 @@ use windows::Win32::Graphics::Direct3D11::{
|
|||||||
D3D11_VPOV_DIMENSION_TEXTURE2D,
|
D3D11_VPOV_DIMENSION_TEXTURE2D,
|
||||||
};
|
};
|
||||||
use windows::Win32::Graphics::Dxgi::Common::{
|
use windows::Win32::Graphics::Dxgi::Common::{
|
||||||
DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709, DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P709,
|
DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709, DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P2020,
|
||||||
|
DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P601, DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P709,
|
||||||
DXGI_COLOR_SPACE_YCBCR_STUDIO_G2084_LEFT_P2020, DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P2020,
|
DXGI_COLOR_SPACE_YCBCR_STUDIO_G2084_LEFT_P2020, DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P2020,
|
||||||
DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709, DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM,
|
DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P601, DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709,
|
||||||
DXGI_FORMAT_NV12, DXGI_FORMAT_P010, DXGI_RATIONAL, DXGI_SAMPLE_DESC,
|
DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_NV12, DXGI_FORMAT_P010, DXGI_RATIONAL,
|
||||||
|
DXGI_SAMPLE_DESC,
|
||||||
};
|
};
|
||||||
use windows::Win32::Graphics::Dxgi::{
|
use windows::Win32::Graphics::Dxgi::{
|
||||||
CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory1, IDXGIKeyedMutex, IDXGIResource1,
|
CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory1, IDXGIKeyedMutex, IDXGIResource1,
|
||||||
@@ -97,6 +99,9 @@ pub struct D3d11Frame {
|
|||||||
/// BT.709 full-range RGB — regardless of the stream's own CICP (a PQ stream was
|
/// BT.709 full-range RGB — regardless of the stream's own CICP (a PQ stream was
|
||||||
/// tone-mapped). The presenter keys SDR/HDR handling off this, so it always reads SDR.
|
/// tone-mapped). The presenter keys SDR/HDR handling off this, so it always reads SDR.
|
||||||
pub color: ColorDesc,
|
pub color: ColorDesc,
|
||||||
|
/// Intra keyframe (IDR/I) — the pump's post-loss re-anchor signal. See
|
||||||
|
/// `crate::video::VkVideoFrame`.
|
||||||
|
pub keyframe: bool,
|
||||||
/// The ring slot's NT shared handle (`IDXGIResource1::CreateSharedHandle`), stable for the
|
/// The ring slot's NT shared handle (`IDXGIResource1::CreateSharedHandle`), stable for the
|
||||||
/// ring's lifetime. Raw `isize` so the frame crosses the pump→presenter channel.
|
/// ring's lifetime. Raw `isize` so the frame crosses the pump→presenter channel.
|
||||||
pub handle: isize,
|
pub handle: isize,
|
||||||
@@ -629,9 +634,16 @@ impl D3d11vaDecoder {
|
|||||||
|
|
||||||
// Colour spaces per frame (the host flips PQ in-band): YCbCr in, sRGB out — a PQ
|
// Colour spaces per frame (the host flips PQ in-band): YCbCr in, sRGB out — a PQ
|
||||||
// stream is tone-mapped to SDR by the processor (module docs). CICP → DXGI enums.
|
// stream is tone-mapped to SDR by the processor (module docs). CICP → DXGI enums.
|
||||||
|
// BT.601 (5/6) matters in practice: a Linux host's RGB-input NVENC paths signal
|
||||||
|
// BT470BG limited (NVENC's fixed internal RGB→YUV is BT.601 — ffmpeg force-writes
|
||||||
|
// that VUI), and mapping it to P709 here was a constant hue error on those streams.
|
||||||
|
// DXGI has no full-range G2084 YCbCr enum, so PQ is studio regardless of range.
|
||||||
let in_cs = match (color.transfer, color.matrix, color.full_range) {
|
let in_cs = match (color.transfer, color.matrix, color.full_range) {
|
||||||
(16, _, _) => DXGI_COLOR_SPACE_YCBCR_STUDIO_G2084_LEFT_P2020,
|
(16, _, _) => DXGI_COLOR_SPACE_YCBCR_STUDIO_G2084_LEFT_P2020,
|
||||||
(_, 9, _) => DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P2020,
|
(_, 9 | 10, false) => DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P2020,
|
||||||
|
(_, 9 | 10, true) => DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P2020,
|
||||||
|
(_, 5 | 6, false) => DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P601,
|
||||||
|
(_, 5 | 6, true) => DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P601,
|
||||||
(_, _, true) => DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P709,
|
(_, _, true) => DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P709,
|
||||||
_ => DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709,
|
_ => DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709,
|
||||||
};
|
};
|
||||||
@@ -683,6 +695,8 @@ impl D3d11vaDecoder {
|
|||||||
matrix: 0, // identity — RGB
|
matrix: 0, // identity — RGB
|
||||||
full_range: true,
|
full_range: true,
|
||||||
},
|
},
|
||||||
|
// SAFETY: `self.frame` is the live decoded AVFrame for this call.
|
||||||
|
keyframe: crate::video::frame_is_keyframe(self.frame),
|
||||||
handle,
|
handle,
|
||||||
generation,
|
generation,
|
||||||
})
|
})
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -176,7 +176,9 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
|
|||||||
RowId::Resolution => (
|
RowId::Resolution => (
|
||||||
Some("Stream"),
|
Some("Stream"),
|
||||||
"Resolution",
|
"Resolution",
|
||||||
if s.width == 0 {
|
if s.match_window {
|
||||||
|
"Match window".into()
|
||||||
|
} else if s.width == 0 {
|
||||||
"Native".into()
|
"Native".into()
|
||||||
} else {
|
} else {
|
||||||
format!("{} × {}", s.width, s.height)
|
format!("{} × {}", s.width, s.height)
|
||||||
@@ -259,7 +261,8 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
|
|||||||
fn detail(id: RowId) -> &'static str {
|
fn detail(id: RowId) -> &'static str {
|
||||||
match id {
|
match id {
|
||||||
RowId::Resolution => {
|
RowId::Resolution => {
|
||||||
"The host creates a virtual display at exactly this size — no scaling."
|
"The host creates a virtual display at exactly this size — no scaling. \
|
||||||
|
Match window follows this window, including mid-stream resizes."
|
||||||
}
|
}
|
||||||
RowId::Refresh => "Native follows the display this window is on.",
|
RowId::Refresh => "Native follows the display this window is on.",
|
||||||
RowId::Bitrate => "Automatic uses the host's default (20 Mbps).",
|
RowId::Bitrate => "Automatic uses the host's default (20 Mbps).",
|
||||||
@@ -303,11 +306,20 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
|||||||
let s = &mut *ctx.settings;
|
let s = &mut *ctx.settings;
|
||||||
match id {
|
match id {
|
||||||
RowId::Resolution => {
|
RowId::Resolution => {
|
||||||
let cur = RESOLUTIONS
|
// The D1 tri-state as one picker: Native, Match window, then the explicit
|
||||||
.iter()
|
// sizes (RESOLUTIONS keeps its (0,0) = Native head; Match window is the
|
||||||
.position(|(w, h)| (*w, *h) == (s.width, s.height));
|
// virtual index 1, stored as the `match_window` flag with w/h cleared).
|
||||||
step_option(cur, RESOLUTIONS.len(), delta, wrap).map(|i| {
|
let cur = if s.match_window {
|
||||||
(s.width, s.height) = RESOLUTIONS[i];
|
Some(1)
|
||||||
|
} else {
|
||||||
|
RESOLUTIONS
|
||||||
|
.iter()
|
||||||
|
.position(|(w, h)| (*w, *h) == (s.width, s.height))
|
||||||
|
.map(|i| if i == 0 { 0 } else { i + 1 })
|
||||||
|
};
|
||||||
|
step_option(cur, RESOLUTIONS.len() + 1, delta, wrap).map(|i| {
|
||||||
|
s.match_window = i == 1;
|
||||||
|
(s.width, s.height) = if i <= 1 { (0, 0) } else { RESOLUTIONS[i - 1] };
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
RowId::Refresh => {
|
RowId::Refresh => {
|
||||||
@@ -401,14 +413,29 @@ mod tests {
|
|||||||
device_name: "t",
|
device_name: "t",
|
||||||
t: 0.0,
|
t: 0.0,
|
||||||
};
|
};
|
||||||
// Resolution starts at Native (index 0): left refuses, right steps.
|
// Resolution starts at Native (index 0): left refuses, right steps — first onto
|
||||||
|
// Match window (the D1 tri-state's middle option), then the explicit sizes.
|
||||||
assert!(!adjust(RowId::Resolution, -1, false, &mut ctx));
|
assert!(!adjust(RowId::Resolution, -1, false, &mut ctx));
|
||||||
assert!(adjust(RowId::Resolution, 1, false, &mut ctx));
|
assert!(adjust(RowId::Resolution, 1, false, &mut ctx));
|
||||||
|
assert!(ctx.settings.match_window, "Native → Match window");
|
||||||
|
assert_eq!((ctx.settings.width, ctx.settings.height), (0, 0));
|
||||||
|
assert!(adjust(RowId::Resolution, 1, false, &mut ctx));
|
||||||
|
assert!(
|
||||||
|
!ctx.settings.match_window,
|
||||||
|
"explicit size clears the policy"
|
||||||
|
);
|
||||||
assert_eq!((ctx.settings.width, ctx.settings.height), (1280, 720));
|
assert_eq!((ctx.settings.width, ctx.settings.height), (1280, 720));
|
||||||
|
// Stepping back from an explicit size returns to Match window, then Native.
|
||||||
|
assert!(adjust(RowId::Resolution, -1, false, &mut ctx));
|
||||||
|
assert!(ctx.settings.match_window);
|
||||||
|
assert!(adjust(RowId::Resolution, -1, false, &mut ctx));
|
||||||
|
assert!(!ctx.settings.match_window);
|
||||||
|
assert_eq!(ctx.settings.width, 0, "back to Native");
|
||||||
// Cycle from the last option wraps to the first.
|
// Cycle from the last option wraps to the first.
|
||||||
(ctx.settings.width, ctx.settings.height) = (3840, 2160);
|
(ctx.settings.width, ctx.settings.height) = (3840, 2160);
|
||||||
assert!(adjust(RowId::Resolution, 1, true, &mut ctx));
|
assert!(adjust(RowId::Resolution, 1, true, &mut ctx));
|
||||||
assert_eq!(ctx.settings.width, 0, "wrapped to Native");
|
assert_eq!(ctx.settings.width, 0, "wrapped to Native");
|
||||||
|
assert!(!ctx.settings.match_window);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+158
-130
@@ -19,7 +19,9 @@ use anyhow::{anyhow, Result};
|
|||||||
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse, PadInfo};
|
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse, PadInfo};
|
||||||
use pf_client_core::trust;
|
use pf_client_core::trust;
|
||||||
use pf_presenter::overlay::OverlayAction;
|
use pf_presenter::overlay::OverlayAction;
|
||||||
use skia_safe::{Canvas, Color4f, Data, Paint, Rect, RuntimeEffect};
|
use skia_safe::{
|
||||||
|
gradient_shader, Canvas, Color4f, Data, Paint, Point, Rect, RuntimeEffect, TileMode,
|
||||||
|
};
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
@@ -240,6 +242,11 @@ impl Shell {
|
|||||||
self.wake = None;
|
self.wake = None;
|
||||||
if let Some(Some(intent)) = intent {
|
if let Some(Some(intent)) = intent {
|
||||||
self.start_connect(intent);
|
self.start_connect(intent);
|
||||||
|
// The wake takeover was already full-screen; skip the connect fade-in so the
|
||||||
|
// Waking → Connecting handoff is seamless (no flash of the home behind).
|
||||||
|
if let Some(c) = &mut self.connecting {
|
||||||
|
c.appear = 1.0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -607,77 +614,68 @@ impl Shell {
|
|||||||
t: f64,
|
t: f64,
|
||||||
fonts: &Fonts,
|
fonts: &Fonts,
|
||||||
) {
|
) {
|
||||||
if let Some(c) = &mut self.connecting {
|
// Resolve the connect/wake takeover — the two phases of reaching a host — into one
|
||||||
c.appear = approach(c.appear, 1.0, dt, 0.07);
|
// full-screen shape (spinner, title, one detail line, its own hints). Connecting flows
|
||||||
let a = c.appear;
|
// straight out of a wake (see `sync`) so they share the same backdrop and never blink
|
||||||
canvas.draw_rect(
|
// between them. Mirrors the Android client's unified `ConnectOverlay`.
|
||||||
Rect::from_wh(w as f32, h as f32),
|
let takeover: Option<(f64, bool, String, String, Vec<Hint>)> =
|
||||||
&Paint::new(Color4f::new(0.0, 0.0, 0.0, (0.45 * a) as f32), None),
|
if let Some(c) = &mut self.connecting {
|
||||||
);
|
c.appear = approach(c.appear, 1.0, dt, 0.07);
|
||||||
let title = if c.canceling {
|
if c.canceling {
|
||||||
"Canceling…".to_string()
|
Some((
|
||||||
|
c.appear,
|
||||||
|
true,
|
||||||
|
"Canceling…".to_string(),
|
||||||
|
String::new(),
|
||||||
|
vec![],
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Some((
|
||||||
|
c.appear,
|
||||||
|
true,
|
||||||
|
format!("Connecting to {}…", c.title),
|
||||||
|
"Starting the stream in this window.".to_string(),
|
||||||
|
vec![Hint::new(HintKey::Back, "Cancel")],
|
||||||
|
))
|
||||||
|
}
|
||||||
|
} else if let Some(wk) = &self.wake {
|
||||||
|
// Service-driven, so it appears settled (no fade-in).
|
||||||
|
if wk.timed_out {
|
||||||
|
Some((
|
||||||
|
1.0,
|
||||||
|
false,
|
||||||
|
format!("{} didn't wake", wk.name),
|
||||||
|
"Check its power settings, or wake it manually and try again.".to_string(),
|
||||||
|
vec![
|
||||||
|
Hint::new(HintKey::Confirm, "Try Again"),
|
||||||
|
Hint::new(HintKey::Back, "Cancel"),
|
||||||
|
],
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Some((
|
||||||
|
1.0,
|
||||||
|
true,
|
||||||
|
format!("Waking {}…", wk.name),
|
||||||
|
format!("Waiting for it to come online · {} s", wk.seconds),
|
||||||
|
// A wake-only wait (no dial after) offers "Stop Waiting"; a wake-&-connect
|
||||||
|
// is a plain "Cancel".
|
||||||
|
vec![Hint::new(
|
||||||
|
HintKey::Back,
|
||||||
|
if wk.then_connect {
|
||||||
|
"Cancel"
|
||||||
|
} else {
|
||||||
|
"Stop Waiting"
|
||||||
|
},
|
||||||
|
)],
|
||||||
|
))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
format!("Connecting to {}…", c.title)
|
None
|
||||||
};
|
};
|
||||||
let hints = if c.canceling {
|
if let Some((appear, spinner, title, body, hints)) = takeover {
|
||||||
vec![]
|
self.draw_takeover(
|
||||||
} else {
|
canvas, w, h, k, appear, t, fonts, spinner, &title, &body, &hints,
|
||||||
vec![Hint::new(HintKey::Back, "Cancel")]
|
|
||||||
};
|
|
||||||
card(
|
|
||||||
canvas,
|
|
||||||
fonts,
|
|
||||||
w,
|
|
||||||
h,
|
|
||||||
k,
|
|
||||||
a,
|
|
||||||
t,
|
|
||||||
self.glyphs,
|
|
||||||
true,
|
|
||||||
&title,
|
|
||||||
"Starting the stream in this window.",
|
|
||||||
&hints,
|
|
||||||
);
|
);
|
||||||
} else if let Some(wk) = &self.wake {
|
|
||||||
let a = 1.0; // the wake card is service-driven; it appears settled
|
|
||||||
canvas.draw_rect(
|
|
||||||
Rect::from_wh(w as f32, h as f32),
|
|
||||||
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.45), None),
|
|
||||||
);
|
|
||||||
if wk.timed_out {
|
|
||||||
card(
|
|
||||||
canvas,
|
|
||||||
fonts,
|
|
||||||
w,
|
|
||||||
h,
|
|
||||||
k,
|
|
||||||
a,
|
|
||||||
t,
|
|
||||||
self.glyphs,
|
|
||||||
false,
|
|
||||||
&format!("{} didn't wake", wk.name),
|
|
||||||
"Check its power settings, or wake it manually and try again.",
|
|
||||||
&[
|
|
||||||
Hint::new(HintKey::Confirm, "Try Again"),
|
|
||||||
Hint::new(HintKey::Back, "Cancel"),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
card(
|
|
||||||
canvas,
|
|
||||||
fonts,
|
|
||||||
w,
|
|
||||||
h,
|
|
||||||
k,
|
|
||||||
a,
|
|
||||||
t,
|
|
||||||
self.glyphs,
|
|
||||||
true,
|
|
||||||
&format!("Waking {}…", wk.name),
|
|
||||||
&format!("Waiting for it to come online · {} s", wk.seconds),
|
|
||||||
&[Hint::new(HintKey::Back, "Cancel")],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The toast: a transient pill above the hint bar; slides in, fades out.
|
// The toast: a transient pill above the hint bar; slides in, fades out.
|
||||||
@@ -799,70 +797,91 @@ impl LayerEnv<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A centered modal card: spinner (or not), a title, one detail line, and its own hint
|
impl Shell {
|
||||||
/// row — the connect/wake overlays share this one shape.
|
/// A full-screen connect/wake takeover: a fresh aurora over everything (so the carousel and
|
||||||
#[allow(clippy::too_many_arguments)]
|
/// chrome fall away), a centered spinner (or none, when a wake has timed out), a title, one
|
||||||
fn card(
|
/// detail line, and its own bottom hint row. `appear` fades the whole thing in over the home;
|
||||||
canvas: &Canvas,
|
/// a wake that hands off to a connect passes 1.0 so the two never blink between them. The
|
||||||
fonts: &Fonts,
|
/// console counterpart of the Android/Apple `ConnectOverlay` — one full-screen shape, not a
|
||||||
w: f64,
|
/// centered modal card.
|
||||||
h: f64,
|
#[allow(clippy::too_many_arguments)]
|
||||||
k: f64,
|
fn draw_takeover(
|
||||||
appear: f64,
|
&self,
|
||||||
t: f64,
|
canvas: &Canvas,
|
||||||
glyphs: GlyphStyle,
|
w: f64,
|
||||||
spinner: bool,
|
h: f64,
|
||||||
title: &str,
|
k: f64,
|
||||||
body: &str,
|
appear: f64,
|
||||||
hints: &[Hint],
|
t: f64,
|
||||||
) {
|
fonts: &Fonts,
|
||||||
let cw = (440.0 * k).min(w * 0.86);
|
spinner: bool,
|
||||||
let ch = 190.0 * k;
|
title: &str,
|
||||||
let cx = w / 2.0;
|
body: &str,
|
||||||
let top = h / 2.0 - ch / 2.0 + (1.0 - appear) * 14.0 * k;
|
hints: &[Hint],
|
||||||
canvas.save_layer_alpha_f(None, appear as f32);
|
) {
|
||||||
let rect = Rect::from_xywh((cx - cw / 2.0) as f32, top as f32, cw as f32, ch as f32);
|
let cx = w / 2.0;
|
||||||
crate::theme::drop_shadow(canvas, rect, 22.0, k as f32, 0.5);
|
canvas.save_layer_alpha_f(None, appear as f32);
|
||||||
crate::theme::panel(
|
// Opaque aurora — the same living backdrop the home wears, so the takeover reads as the
|
||||||
canvas,
|
// console taking over rather than a card popping up.
|
||||||
rect,
|
self.draw_aurora(canvas, w, h, t);
|
||||||
22.0,
|
// A soft pool of shade under the centre seats the white text against a bright aurora.
|
||||||
Some(Color4f::new(0.07, 0.06, 0.12, 0.85)),
|
let mut vignette = Paint::default();
|
||||||
PanelStroke::Plain(0.14),
|
vignette.set_shader(gradient_shader::radial(
|
||||||
k as f32,
|
Point::new(cx as f32, (h / 2.0) as f32),
|
||||||
);
|
(w.max(h) * 0.42) as f32,
|
||||||
let mut y = top + 44.0 * k;
|
gradient_shader::GradientShaderColors::Colors(&[
|
||||||
if spinner {
|
Color4f::new(0.0, 0.0, 0.0, 0.5).to_color(),
|
||||||
crate::theme::spinner(canvas, cx, y, 14.0 * k, t);
|
Color4f::new(0.0, 0.0, 0.0, 0.0).to_color(),
|
||||||
y += 34.0 * k;
|
]),
|
||||||
} else {
|
None,
|
||||||
y += 6.0 * k;
|
TileMode::Clamp,
|
||||||
}
|
None,
|
||||||
fonts.centered(canvas, title, W::SemiBold, 19.0 * k, WHITE, cx, y, cw * 0.9);
|
None,
|
||||||
fonts.centered(
|
));
|
||||||
canvas,
|
canvas.draw_rect(Rect::from_wh(w as f32, h as f32), &vignette);
|
||||||
body,
|
|
||||||
W::Regular,
|
// Centre the spinner + title + detail as a group around the middle of the screen.
|
||||||
13.0 * k,
|
let title_y = h / 2.0 + if spinner { 14.0 * k } else { 0.0 };
|
||||||
DIM,
|
if spinner {
|
||||||
cx,
|
crate::theme::spinner(canvas, cx, title_y - 52.0 * k, 22.0 * k, t);
|
||||||
y + 30.0 * k,
|
}
|
||||||
cw * 0.86,
|
fonts.centered(
|
||||||
);
|
|
||||||
if !hints.is_empty() {
|
|
||||||
// Centered inside the card's bottom band.
|
|
||||||
let probe = hint_bar(canvas, fonts, hints, glyphs, -10_000.0, -10_000.0, k);
|
|
||||||
hint_bar(
|
|
||||||
canvas,
|
canvas,
|
||||||
fonts,
|
title,
|
||||||
hints,
|
W::SemiBold,
|
||||||
glyphs,
|
23.0 * k,
|
||||||
cx - probe.0 / 2.0,
|
WHITE,
|
||||||
top + ch - 16.0 * k,
|
cx,
|
||||||
k,
|
title_y,
|
||||||
|
w * 0.82,
|
||||||
);
|
);
|
||||||
|
if !body.is_empty() {
|
||||||
|
fonts.centered(
|
||||||
|
canvas,
|
||||||
|
body,
|
||||||
|
W::Regular,
|
||||||
|
14.0 * k,
|
||||||
|
DIM,
|
||||||
|
cx,
|
||||||
|
title_y + 32.0 * k,
|
||||||
|
w * 0.66,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !hints.is_empty() {
|
||||||
|
// Centered near the bottom, where every console screen's legend sits.
|
||||||
|
let probe = hint_bar(canvas, fonts, hints, self.glyphs, -10_000.0, -10_000.0, k);
|
||||||
|
hint_bar(
|
||||||
|
canvas,
|
||||||
|
fonts,
|
||||||
|
hints,
|
||||||
|
self.glyphs,
|
||||||
|
cx - probe.0 / 2.0,
|
||||||
|
h - 34.0 * k,
|
||||||
|
k,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
canvas.restore();
|
||||||
}
|
}
|
||||||
canvas.restore();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1154,6 +1173,15 @@ mod tests {
|
|||||||
then_connect: true,
|
then_connect: true,
|
||||||
}));
|
}));
|
||||||
dump(&mut s, 10, 8, "08-waking", true);
|
dump(&mut s, 10, 8, "08-waking", true);
|
||||||
|
console.set_wake(Some(WakeStatus {
|
||||||
|
key: "bb22".into(),
|
||||||
|
name: "Office Tower".into(),
|
||||||
|
seconds: 90,
|
||||||
|
timed_out: true,
|
||||||
|
online: false,
|
||||||
|
then_connect: true,
|
||||||
|
}));
|
||||||
|
dump(&mut s, 10, 8, "08b-wake-timed-out", true);
|
||||||
console.set_wake(None);
|
console.set_wake(None);
|
||||||
s.set_connecting(Some("Elden Ring".into()));
|
s.set_connecting(Some("Elden Ring".into()));
|
||||||
dump(&mut s, 10, 8, "09-connecting", true);
|
dump(&mut s, 10, 8, "09-connecting", true);
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ struct Drawn {
|
|||||||
hint: Option<String>,
|
hint: Option<String>,
|
||||||
/// The start banner's alpha, quantized — a fade step is a redraw, steady is not.
|
/// The start banner's alpha, quantized — a fade step is a redraw, steady is not.
|
||||||
banner_step: u8,
|
banner_step: u8,
|
||||||
|
/// The resize scrim's spinner phase, quantized — a nonzero, ever-changing step while a
|
||||||
|
/// mid-stream resize is in flight forces the per-frame redraw the spinner needs; `0`
|
||||||
|
/// when no resize is showing (so a still stream stays damage-free).
|
||||||
|
resize_step: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where the console starts (the session binary's `--browse` forms).
|
/// Where the console starts (the session binary's `--browse` forms).
|
||||||
@@ -85,6 +89,9 @@ pub struct SkiaOverlay {
|
|||||||
streaming_since: Option<Instant>,
|
streaming_since: Option<Instant>,
|
||||||
/// The banner's words (set per stream from the active-pad state).
|
/// The banner's words (set per stream from the active-pad state).
|
||||||
banner_text: Option<String>,
|
banner_text: Option<String>,
|
||||||
|
/// When the current mid-stream resize scrim began showing — drives its spinner phase.
|
||||||
|
/// `None` = no resize in flight (`FrameCtx::resizing` was false last frame).
|
||||||
|
resizing_since: Option<Instant>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Gpu {
|
struct Gpu {
|
||||||
@@ -114,6 +121,7 @@ impl SkiaOverlay {
|
|||||||
shell: None,
|
shell: None,
|
||||||
streaming_since: None,
|
streaming_since: None,
|
||||||
banner_text: None,
|
banner_text: None,
|
||||||
|
resizing_since: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,10 +348,15 @@ impl Overlay for SkiaOverlay {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Stream chrome: stats OSD + capture hint + the start banner ---------------
|
// --- Stream chrome: stats OSD + capture hint + start banner + resize scrim -----
|
||||||
let banner_alpha = self.banner_alpha(ctx);
|
let banner_alpha = self.banner_alpha(ctx);
|
||||||
let banner_step = (banner_alpha * 32.0).round() as u8;
|
let banner_step = (banner_alpha * 32.0).round() as u8;
|
||||||
if ctx.stats.is_none() && ctx.hint.is_none() && banner_step == 0 {
|
let resize_phase = self.resize_phase(ctx);
|
||||||
|
// 120 steps/s: every ~16 ms frame lands on a fresh step, so the spinner keeps
|
||||||
|
// spinning through the damage gate; `+ 1` keeps an active resize's step nonzero
|
||||||
|
// even on its first frame (phase 0) so the guard below doesn't skip it.
|
||||||
|
let resize_step = resize_phase.map_or(0, |p| (p * 120.0) as u16 + 1);
|
||||||
|
if ctx.stats.is_none() && ctx.hint.is_none() && banner_step == 0 && resize_step == 0 {
|
||||||
self.drawn = Drawn::default(); // forget content so re-show re-renders
|
self.drawn = Drawn::default(); // forget content so re-show re-renders
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
@@ -353,6 +366,7 @@ impl Overlay for SkiaOverlay {
|
|||||||
stats: ctx.stats.map(str::to_owned),
|
stats: ctx.stats.map(str::to_owned),
|
||||||
hint: ctx.hint.map(str::to_owned),
|
hint: ctx.hint.map(str::to_owned),
|
||||||
banner_step,
|
banner_step,
|
||||||
|
resize_step,
|
||||||
};
|
};
|
||||||
if want == self.drawn {
|
if want == self.drawn {
|
||||||
// Unchanged — hand the presenter the already-rendered image.
|
// Unchanged — hand the presenter the already-rendered image.
|
||||||
@@ -374,6 +388,10 @@ impl Overlay for SkiaOverlay {
|
|||||||
let canvas = slot.surface.canvas();
|
let canvas = slot.surface.canvas();
|
||||||
canvas.clear(Color4f::new(0.0, 0.0, 0.0, 0.0));
|
canvas.clear(Color4f::new(0.0, 0.0, 0.0, 0.0));
|
||||||
let font = self.font.as_ref().expect("init ran");
|
let font = self.font.as_ref().expect("init ran");
|
||||||
|
// The resize scrim sits UNDER the OSD/hint so those stay legible over it.
|
||||||
|
if let Some(phase) = resize_phase {
|
||||||
|
draw_resize_scrim(canvas, font, ctx.width, ctx.height, phase);
|
||||||
|
}
|
||||||
if let Some(stats) = &want.stats {
|
if let Some(stats) = &want.stats {
|
||||||
draw_osd_panel(canvas, font, stats, 12.0, 12.0);
|
draw_osd_panel(canvas, font, stats, 12.0, 12.0);
|
||||||
}
|
}
|
||||||
@@ -445,6 +463,23 @@ impl SkiaOverlay {
|
|||||||
((BANNER_S - age) / BANNER_FADE_S).min(1.0)
|
((BANNER_S - age) / BANNER_FADE_S).min(1.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The mid-stream-resize spinner's phase (elapsed seconds since the scrim came up), or
|
||||||
|
/// `None` when no resize is in flight. Latches the start on the first `resizing` frame
|
||||||
|
/// and clears it the moment the run loop drops the flag (the target frame landed or the
|
||||||
|
/// switch timed out), so the next resize starts its spinner from zero.
|
||||||
|
fn resize_phase(&mut self, ctx: &FrameCtx) -> Option<f64> {
|
||||||
|
if !ctx.resizing {
|
||||||
|
self.resizing_since = None;
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(
|
||||||
|
self.resizing_since
|
||||||
|
.get_or_insert_with(Instant::now)
|
||||||
|
.elapsed()
|
||||||
|
.as_secs_f64(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Make `slots[i]` a render target of exactly `width`×`height` (rebuilt on resize).
|
/// Make `slots[i]` a render target of exactly `width`×`height` (rebuilt on resize).
|
||||||
fn ensure_slot(&mut self, i: usize, width: u32, height: u32) -> Result<()> {
|
fn ensure_slot(&mut self, i: usize, width: u32, height: u32) -> Result<()> {
|
||||||
if self.slots[i]
|
if self.slots[i]
|
||||||
@@ -540,6 +575,33 @@ fn draw_osd_panel(canvas: &Canvas, font: &Font, text: &str, x: f32, y: f32) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The mid-stream-resize cover: a full-screen dark scrim, the shared rotating spinner, and
|
||||||
|
/// a "Resizing…" label centered over it — so the host's 0.3–2 s virtual-display + encoder
|
||||||
|
/// rebuild reads as a deliberate pause rather than the stream stretching to the changed
|
||||||
|
/// window. This is the presenter's analog of the Apple client's blur overlay: the overlay
|
||||||
|
/// composites its own RGBA quad and cannot sample the video to blur it, so an opaque scrim
|
||||||
|
/// hides the stretched in-between frame instead (same intent, one draw).
|
||||||
|
fn draw_resize_scrim(canvas: &Canvas, font: &Font, width: u32, height: u32, phase: f64) {
|
||||||
|
let (wf, hf) = (width as f32, height as f32);
|
||||||
|
canvas.draw_rect(
|
||||||
|
Rect::from_wh(wf, hf),
|
||||||
|
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.55), None),
|
||||||
|
);
|
||||||
|
// Spinner slightly above center; the label sits below it.
|
||||||
|
let (cx, cy) = (f64::from(width) / 2.0, f64::from(height) / 2.0);
|
||||||
|
let r = (f64::from(width.min(height)) * 0.045).clamp(16.0, 44.0);
|
||||||
|
crate::theme::spinner(canvas, cx, cy - r, r, phase);
|
||||||
|
let (_, metrics) = font.metrics();
|
||||||
|
let label = "Resizing\u{2026}";
|
||||||
|
let tw = font.measure_str(label, None).0;
|
||||||
|
canvas.draw_str(
|
||||||
|
label,
|
||||||
|
Point::new((wf - tw) / 2.0, (cy + r * 0.9) as f32 - metrics.ascent),
|
||||||
|
font,
|
||||||
|
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.9), None),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The capture hint / start banner: a centered pill near the bottom edge.
|
/// The capture hint / start banner: a centered pill near the bottom edge.
|
||||||
fn draw_hint_pill(canvas: &Canvas, font: &Font, text: &str, width: u32, height: u32, alpha: f32) {
|
fn draw_hint_pill(canvas: &Canvas, font: &Font, text: &str, width: u32, height: u32, alpha: f32) {
|
||||||
let (_, metrics) = font.metrics();
|
let (_, metrics) = font.metrics();
|
||||||
|
|||||||
@@ -53,7 +53,13 @@ pub const fn interface_guid_fields() -> (u32, u16, u16, [u8; 8]) {
|
|||||||
/// ([`control::IOCTL_SET_FRAME_CHANNEL`]), and [`control::AddReply`] grew `wudf_pid` (the duplication
|
/// ([`control::IOCTL_SET_FRAME_CHANNEL`]), and [`control::AddReply`] grew `wudf_pid` (the duplication
|
||||||
/// target). A v1 driver has no channel-delivery IOCTL and expects named objects, so the pairing is
|
/// target). A v1 driver has no channel-delivery IOCTL and expects named objects, so the pairing is
|
||||||
/// incompatible by design.
|
/// incompatible by design.
|
||||||
pub const PROTOCOL_VERSION: u32 = 2;
|
/// v3: ring↔monitor binding hardening for parallel displays
|
||||||
|
/// (`design/windows-parallel-virtual-displays.md` §3): [`frame::SharedHeader`] names its monitor
|
||||||
|
/// (`target_id`, the former `_pad` — same size, same offsets) and the driver's publisher refuses to
|
||||||
|
/// attach a ring naming a different monitor ([`frame::DRV_STATUS_BIND_FAIL`], the gamepad channel's
|
||||||
|
/// `pad_index` validation applied to frames). A v2 host never stamps the field, so a v3 driver
|
||||||
|
/// against a v2 host would refuse every attach — lockstep by the handshake, as ever.
|
||||||
|
pub const PROTOCOL_VERSION: u32 = 3;
|
||||||
|
|
||||||
/// `CTL_CODE(FILE_DEVICE_UNKNOWN = 0x22, func, METHOD_BUFFERED = 0, FILE_ANY_ACCESS = 0)`.
|
/// `CTL_CODE(FILE_DEVICE_UNKNOWN = 0x22, func, METHOD_BUFFERED = 0, FILE_ANY_ACCESS = 0)`.
|
||||||
pub const fn ctl_code(func: u32) -> u32 {
|
pub const fn ctl_code(func: u32) -> u32 {
|
||||||
@@ -89,6 +95,13 @@ pub mod control {
|
|||||||
/// `IOCTL_ADD` input. A monotonic `session_id` keys the monitor (the host's refcount manager owns
|
/// `IOCTL_ADD` input. A monotonic `session_id` keys the monitor (the host's refcount manager owns
|
||||||
/// collision safety — no more SudoVDA's 16-byte GUID + pid-mangling). The driver advertises this
|
/// collision safety — no more SudoVDA's 16-byte GUID + pid-mangling). The driver advertises this
|
||||||
/// mode as preferred; the host still CCD-forces the active mode (the OS activates IDDs at a default).
|
/// mode as preferred; the host still CCD-forces the active mode (the OS activates IDDs at a default).
|
||||||
|
///
|
||||||
|
/// **Size compatibility**: the client-HDR luminance tail (the three fields after
|
||||||
|
/// `preferred_monitor_id`) was appended without a protocol bump because BOTH directions degrade
|
||||||
|
/// cleanly: an un-upgraded driver reads the [`ADD_REQUEST_LEGACY_SIZE`]-byte prefix of a new
|
||||||
|
/// host's request (its `read_input` accepts a larger buffer) and keeps its built-in EDID
|
||||||
|
/// luminance; an upgraded driver accepts a legacy-size request and zero-fills the tail (`0` =
|
||||||
|
/// unknown → the built-in defaults). Any FURTHER field must follow the same discipline.
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
|
||||||
pub struct AddRequest {
|
pub struct AddRequest {
|
||||||
@@ -104,8 +117,26 @@ pub mod control {
|
|||||||
/// GameStream sessions). Byte-compatible with the old `_reserved` (offset 20): an un-upgraded
|
/// GameStream sessions). Byte-compatible with the old `_reserved` (offset 20): an un-upgraded
|
||||||
/// driver ignores it (→ auto), which the host detects via [`AddReply::resolved_monitor_id`].
|
/// driver ignores it (→ auto), which the host detects via [`AddReply::resolved_monitor_id`].
|
||||||
pub preferred_monitor_id: u32,
|
pub preferred_monitor_id: u32,
|
||||||
|
/// The CLIENT display's peak luminance in nits — written into this monitor's EDID CTA-861.3
|
||||||
|
/// HDR static-metadata block (Desired Content Max Luminance), so host apps and the OS
|
||||||
|
/// tone-map to the panel the stream actually lands on instead of the driver's built-in
|
||||||
|
/// ~1000-nit placeholder. `0` = unknown → the driver keeps its built-in default block.
|
||||||
|
pub max_luminance_nits: u32,
|
||||||
|
/// The client display's max frame-average luminance in nits (→ Desired Content Max
|
||||||
|
/// Frame-average Luminance). `0` = unknown/not indicated.
|
||||||
|
pub max_frame_avg_nits: u32,
|
||||||
|
/// The client display's min luminance in MILLI-nits (0.001 cd/m² — the CTA min-luminance
|
||||||
|
/// range lives well below 1 nit) → Desired Content Min Luminance. `0` = unknown.
|
||||||
|
pub min_luminance_millinits: u32,
|
||||||
|
/// Pads the `u64`-aligned struct to a multiple of 8 (Pod forbids implicit tail padding);
|
||||||
|
/// free expansion room for the next appended field.
|
||||||
|
pub _reserved: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`AddRequest`]'s size before the client-HDR luminance tail — the prefix an un-upgraded
|
||||||
|
/// driver reads and the whole request an un-upgraded host sends (see the struct docs).
|
||||||
|
pub const ADD_REQUEST_LEGACY_SIZE: usize = 24;
|
||||||
|
|
||||||
/// `IOCTL_ADD` reply: the OS target id + the adapter LUID the IDD landed on (split low/high to
|
/// `IOCTL_ADD` reply: the OS target id + the adapter LUID the IDD landed on (split low/high to
|
||||||
/// match `windows` `LUID { LowPart: u32, HighPart: i32 }`).
|
/// match `windows` `LUID { LowPart: u32, HighPart: i32 }`).
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
@@ -193,12 +224,16 @@ pub mod control {
|
|||||||
const _: () = {
|
const _: () = {
|
||||||
use core::mem::{offset_of, size_of};
|
use core::mem::{offset_of, size_of};
|
||||||
|
|
||||||
assert!(size_of::<AddRequest>() == 24);
|
assert!(size_of::<AddRequest>() == 40);
|
||||||
assert!(offset_of!(AddRequest, session_id) == 0);
|
assert!(offset_of!(AddRequest, session_id) == 0);
|
||||||
assert!(offset_of!(AddRequest, width) == 8);
|
assert!(offset_of!(AddRequest, width) == 8);
|
||||||
assert!(offset_of!(AddRequest, height) == 12);
|
assert!(offset_of!(AddRequest, height) == 12);
|
||||||
assert!(offset_of!(AddRequest, refresh_hz) == 16);
|
assert!(offset_of!(AddRequest, refresh_hz) == 16);
|
||||||
assert!(offset_of!(AddRequest, preferred_monitor_id) == 20);
|
assert!(offset_of!(AddRequest, preferred_monitor_id) == 20);
|
||||||
|
// The client-HDR luminance tail starts exactly at the legacy boundary (prefix-compat).
|
||||||
|
assert!(offset_of!(AddRequest, max_luminance_nits) == ADD_REQUEST_LEGACY_SIZE);
|
||||||
|
assert!(offset_of!(AddRequest, max_frame_avg_nits) == 28);
|
||||||
|
assert!(offset_of!(AddRequest, min_luminance_millinits) == 32);
|
||||||
|
|
||||||
assert!(size_of::<AddReply>() == 20);
|
assert!(size_of::<AddReply>() == 20);
|
||||||
assert!(offset_of!(AddReply, adapter_luid_low) == 0);
|
assert!(offset_of!(AddReply, adapter_luid_low) == 0);
|
||||||
@@ -228,6 +263,88 @@ pub mod control {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// CTA-861.3 "Desired Content Luminance" coding for the pf-vdisplay EDID's HDR Static Metadata
|
||||||
|
/// Data Block — the three bytes that tell Windows (and through it every host app) what luminance
|
||||||
|
/// volume the virtual display's panel has. The HOST fills [`control::AddRequest`]'s luminance
|
||||||
|
/// fields from the CLIENT's real display volume and the DRIVER codes them here, so games tone-map
|
||||||
|
/// to the panel the stream actually lands on.
|
||||||
|
///
|
||||||
|
/// Lives in this shared crate (not the driver) deliberately: the driver only builds under the WDK
|
||||||
|
/// on Windows, but this byte-level coding is exactly the fiddly part that wants unit tests on
|
||||||
|
/// every dev machine BEFORE a driver build/sign/deploy cycle. `no_std` + integer-only (fixed
|
||||||
|
/// point), so it drops into the driver unchanged.
|
||||||
|
pub mod edid {
|
||||||
|
/// `2^(k/32)` for `k = 0..32` in Q16 fixed point (`round(2^(k/32) * 65536)`) — the fractional
|
||||||
|
/// step table for the CTA-861.3 luminance exponent.
|
||||||
|
const POW2_Q16: [u32; 32] = [
|
||||||
|
65536, 66971, 68438, 69936, 71468, 73032, 74632, 76266, 77936, 79642, 81386, 83169, 84990,
|
||||||
|
86851, 88752, 90696, 92682, 94711, 96785, 98905, 101070, 103283, 105545, 107856, 110218,
|
||||||
|
112631, 115098, 117618, 120194, 122825, 125515, 128263,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Decode a CTA-861.3 max / frame-average luminance code to MILLI-nits:
|
||||||
|
/// `L = 50 * 2^(CV/32)` cd/m², so `L_millinits = 50_000 * 2^(CV/32)`.
|
||||||
|
/// (`CV = 255` ≈ 12_525 nits — comfortably inside u64 at Q16.)
|
||||||
|
pub const fn cta_max_millinits(code: u8) -> u64 {
|
||||||
|
let whole = code as u32 / 32;
|
||||||
|
let frac = code as u32 % 32;
|
||||||
|
((50_000u64 << whole) * POW2_Q16[frac as usize] as u64) >> 16
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Code a display's peak (or frame-average) luminance in nits as a CTA-861.3 luminance value:
|
||||||
|
/// the LARGEST code whose decoded luminance does not exceed the panel's — never advertise a
|
||||||
|
/// volume brighter than the glass, so a host app's tone map can't clip on the client. Clamped
|
||||||
|
/// to `1..=255`: `0` is "no data" on the wire, and callers gate on `nits > 0` themselves (a
|
||||||
|
/// sub-51-nit request — no real HDR panel — still codes as 1).
|
||||||
|
pub fn cta_max_luminance_code(nits: u32) -> u8 {
|
||||||
|
let target = nits as u64 * 1000;
|
||||||
|
let mut code = 1u8;
|
||||||
|
while code < 255 && cta_max_millinits(code + 1) <= target {
|
||||||
|
code += 1;
|
||||||
|
}
|
||||||
|
code
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Floor integer square root (Newton's method — `u64::isqrt` needs Rust 1.84, above this
|
||||||
|
/// crate's 1.82 MSRV). Converges in ≤ 6 iterations from the power-of-two seed.
|
||||||
|
fn isqrt_u64(x: u64) -> u64 {
|
||||||
|
if x == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// Seed strictly above sqrt(x): 2^(ceil(bits/2)).
|
||||||
|
let mut r = 1u64 << (64 - x.leading_zeros()).div_ceil(2);
|
||||||
|
loop {
|
||||||
|
let next = (r + x / r) / 2;
|
||||||
|
if next >= r {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
r = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Code a display's min luminance (MILLI-nits) as the CTA-861.3 min-luminance value, which is
|
||||||
|
/// relative to the block's coded max: `L_min = L_max * (CV/255)^2 / 100`, so
|
||||||
|
/// `CV = 255 * sqrt(100 * L_min / L_max)` — rounded to nearest. `max_code` is the byte
|
||||||
|
/// produced by [`cta_max_luminance_code`]; a result of `0` (a true-black panel, or
|
||||||
|
/// `millinits = 0` = unknown) is valid on the wire.
|
||||||
|
pub fn cta_min_luminance_code(millinits: u32, max_code: u8) -> u8 {
|
||||||
|
let max_millinits = cta_max_millinits(max_code);
|
||||||
|
if millinits == 0 || max_millinits == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// CV = sqrt(100 * 255^2 * L_min / L_max); round to nearest by comparing the two flanking
|
||||||
|
// squares (the integer sqrt floors).
|
||||||
|
let x = (100u64 * 255 * 255).saturating_mul(millinits as u64) / max_millinits;
|
||||||
|
let floor = isqrt_u64(x);
|
||||||
|
let cv = if (floor + 1) * (floor + 1) - x <= x - floor * floor {
|
||||||
|
floor + 1
|
||||||
|
} else {
|
||||||
|
floor
|
||||||
|
};
|
||||||
|
cv.min(255) as u8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The IDD-push frame transport: the host-created shared ring header, the publish token, and the
|
/// The IDD-push frame transport: the host-created shared ring header, the publish token, and the
|
||||||
/// driver-status codes. The texture ring itself is host-created **unnamed** D3D11 keyed-mutex textures;
|
/// driver-status codes. The texture ring itself is host-created **unnamed** D3D11 keyed-mutex textures;
|
||||||
/// the driver reaches them (and the header + event) only through handles the host duplicated into its
|
/// the driver reaches them (and the header + event) only through handles the host duplicated into its
|
||||||
@@ -255,6 +372,12 @@ pub mod frame {
|
|||||||
pub const DRV_STATUS_TEX_FAIL: u32 = 2;
|
pub const DRV_STATUS_TEX_FAIL: u32 = 2;
|
||||||
/// Driver has no `ID3D11Device1` to open shared resources.
|
/// Driver has no `ID3D11Device1` to open shared resources.
|
||||||
pub const DRV_STATUS_NO_DEVICE1: u32 = 3;
|
pub const DRV_STATUS_NO_DEVICE1: u32 = 3;
|
||||||
|
/// Driver refused the attach because the mapped ring names a DIFFERENT monitor
|
||||||
|
/// ([`SharedHeader::target_id`] != the monitor the delivery landed on) — a host stash cross-wire
|
||||||
|
/// or stale-delivery race that, with parallel displays, would carry one client's frames into
|
||||||
|
/// another client's stream. Fail-closed binding validation (v3, invariant #10 of
|
||||||
|
/// `design/idd-push-security.md`); `driver_status_detail` carries the target id the ring claims.
|
||||||
|
pub const DRV_STATUS_BIND_FAIL: u32 = 4;
|
||||||
|
|
||||||
/// The shared metadata header (host-created, mapped by both sides). Atomic fields (`magic`, `latest`,
|
/// The shared metadata header (host-created, mapped by both sides). Atomic fields (`magic`, `latest`,
|
||||||
/// `generation`) are accessed via each side's own atomic view over the mapping; this is the layout.
|
/// `generation`) are accessed via each side's own atomic view over the mapping; this is the layout.
|
||||||
@@ -272,7 +395,14 @@ pub mod frame {
|
|||||||
pub width: u32,
|
pub width: u32,
|
||||||
pub height: u32,
|
pub height: u32,
|
||||||
pub dxgi_format: u32,
|
pub dxgi_format: u32,
|
||||||
pub _pad: u32,
|
/// The OS target id of the monitor this ring belongs to (v3 — the former `_pad`, same
|
||||||
|
/// offset). Host-stamped at ring creation, BEFORE the magic (the magic-last publish ordering
|
||||||
|
/// guarantees the driver never reads it half-initialized) and never changed afterwards (a
|
||||||
|
/// mid-session recreate reuses the mapping, so the binding is stable for the ring's life).
|
||||||
|
/// The driver's publisher attaches only when it equals the monitor's own target id
|
||||||
|
/// ([`check_attach`]) — a mis-delivered ring fails closed ([`DRV_STATUS_BIND_FAIL`]) instead
|
||||||
|
/// of carrying another display's frames (invariant #10, `design/idd-push-security.md`).
|
||||||
|
pub target_id: u32,
|
||||||
/// Driver-written after each copy; host loads `Acquire`. See [`FrameToken`].
|
/// Driver-written after each copy; host loads `Acquire`. See [`FrameToken`].
|
||||||
pub latest: u64,
|
pub latest: u64,
|
||||||
pub qpc_pts: u64,
|
pub qpc_pts: u64,
|
||||||
@@ -285,6 +415,43 @@ pub mod frame {
|
|||||||
pub driver_status_detail: u32,
|
pub driver_status_detail: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Why the driver's publisher must NOT attach a delivered channel to its monitor's ring — the
|
||||||
|
/// two reject outcomes of [`check_attach`], each with different driver behavior.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum AttachReject {
|
||||||
|
/// The header isn't (or is no longer) the ring this delivery described: magic missing, or
|
||||||
|
/// the host recreated the ring again before the attach (a fresh delivery is on its way).
|
||||||
|
/// Benign — drop the delivery silently; no status is written.
|
||||||
|
Stale,
|
||||||
|
/// The ring names a DIFFERENT monitor (`SharedHeader::target_id` mismatch) — a host
|
||||||
|
/// stash/delivery cross-wire that, with parallel displays, would publish this monitor's
|
||||||
|
/// frames into another client's stream. Fail closed: refuse the attach and write
|
||||||
|
/// [`DRV_STATUS_BIND_FAIL`] so the host's wait-for-attach fails the open loudly.
|
||||||
|
BindMismatch,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The publisher's attach precondition (v3): given the mapped header's `magic`, `generation`
|
||||||
|
/// and `target_id` plus the delivery's generation and the monitor's own target id, decide
|
||||||
|
/// whether the attach may proceed. Staleness is checked FIRST — a superseded delivery's binding
|
||||||
|
/// is meaningless (the fresh delivery re-validates it), so it never false-alarms as a bind
|
||||||
|
/// failure. Pure and shared-crate-owned so the reject paths are unit-tested on every dev
|
||||||
|
/// machine (the driver workspace builds `panic = "abort"` and cannot host a test harness).
|
||||||
|
pub fn check_attach(
|
||||||
|
magic: u32,
|
||||||
|
header_generation: u32,
|
||||||
|
header_target_id: u32,
|
||||||
|
delivery_generation: u32,
|
||||||
|
monitor_target_id: u32,
|
||||||
|
) -> Result<(), AttachReject> {
|
||||||
|
if magic != MAGIC || header_generation != delivery_generation {
|
||||||
|
return Err(AttachReject::Stale);
|
||||||
|
}
|
||||||
|
if header_target_id != monitor_target_id {
|
||||||
|
return Err(AttachReject::BindMismatch);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// The `SharedHeader.latest` publish token: `(generation << 40) | (seq << 8) | slot`.
|
/// The `SharedHeader.latest` publish token: `(generation << 40) | (seq << 8) | slot`.
|
||||||
/// `generation` is 24-bit, `seq` 32-bit, `slot` 8-bit. The generation tag lets the host REJECT a
|
/// `generation` is 24-bit, `seq` 32-bit, `slot` 8-bit. The generation tag lets the host REJECT a
|
||||||
/// publish from a stale ring (an old-generation publisher racing a mid-session recreate) so it never
|
/// publish from a stale ring (an old-generation publisher racing a mid-session recreate) so it never
|
||||||
@@ -316,8 +483,9 @@ pub mod frame {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Size + per-field offsets are load-bearing: both sides access these via raw atomic views over the
|
// Size + per-field offsets are load-bearing: both sides access these via raw atomic views over the
|
||||||
// mapping, so a same-size field reorder would silently corrupt. Pin every offset. The `_pad` after
|
// mapping, so a same-size field reorder would silently corrupt. Pin every offset. `target_id`
|
||||||
// `dxgi_format` is what 8-aligns the `u64 latest` at offset 32 — assert that too.
|
// (v3, the former `_pad`) after `dxgi_format` is what 8-aligns the `u64 latest` at offset 32 —
|
||||||
|
// assert that too.
|
||||||
const _: () = {
|
const _: () = {
|
||||||
use core::mem::{offset_of, size_of};
|
use core::mem::{offset_of, size_of};
|
||||||
|
|
||||||
@@ -329,7 +497,7 @@ pub mod frame {
|
|||||||
assert!(offset_of!(SharedHeader, width) == 16);
|
assert!(offset_of!(SharedHeader, width) == 16);
|
||||||
assert!(offset_of!(SharedHeader, height) == 20);
|
assert!(offset_of!(SharedHeader, height) == 20);
|
||||||
assert!(offset_of!(SharedHeader, dxgi_format) == 24);
|
assert!(offset_of!(SharedHeader, dxgi_format) == 24);
|
||||||
assert!(offset_of!(SharedHeader, _pad) == 28);
|
assert!(offset_of!(SharedHeader, target_id) == 28);
|
||||||
assert!(offset_of!(SharedHeader, latest) == 32);
|
assert!(offset_of!(SharedHeader, latest) == 32);
|
||||||
assert!(offset_of!(SharedHeader, qpc_pts) == 40);
|
assert!(offset_of!(SharedHeader, qpc_pts) == 40);
|
||||||
assert!(offset_of!(SharedHeader, driver_render_luid_low) == 48);
|
assert!(offset_of!(SharedHeader, driver_render_luid_low) == 48);
|
||||||
@@ -588,12 +756,52 @@ mod tests {
|
|||||||
h.magic = frame::MAGIC;
|
h.magic = frame::MAGIC;
|
||||||
h.width = 5120;
|
h.width = 5120;
|
||||||
h.height = 1440;
|
h.height = 1440;
|
||||||
|
h.target_id = 262;
|
||||||
let bytes = bytemuck::bytes_of(&h);
|
let bytes = bytemuck::bytes_of(&h);
|
||||||
assert_eq!(bytes.len(), 64);
|
assert_eq!(bytes.len(), 64);
|
||||||
let back: frame::SharedHeader = *bytemuck::from_bytes(bytes);
|
let back: frame::SharedHeader = *bytemuck::from_bytes(bytes);
|
||||||
assert_eq!(back.magic, frame::MAGIC);
|
assert_eq!(back.magic, frame::MAGIC);
|
||||||
assert_eq!(back.width, 5120);
|
assert_eq!(back.width, 5120);
|
||||||
assert_eq!(back.height, 1440);
|
assert_eq!(back.height, 1440);
|
||||||
|
// v3: the monitor binding occupies the old `_pad` slot at offset 28 — byte-compatible (a v2
|
||||||
|
// host left it zero there).
|
||||||
|
assert_eq!(bytes[28..32], 262u32.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attach_check_binds_ring_to_monitor() {
|
||||||
|
use frame::{check_attach, AttachReject, MAGIC};
|
||||||
|
// The good path: magic + matching generation + matching monitor binding.
|
||||||
|
assert_eq!(check_attach(MAGIC, 7, 262, 7, 262), Ok(()));
|
||||||
|
// Missing magic / superseded generation → Stale (silent drop, re-delivery coming) — and
|
||||||
|
// staleness WINS over a binding mismatch, since a superseded delivery's binding is
|
||||||
|
// meaningless (the fresh one re-validates).
|
||||||
|
assert_eq!(
|
||||||
|
check_attach(0, 7, 262, 7, 262),
|
||||||
|
Err(AttachReject::Stale),
|
||||||
|
"no magic"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
check_attach(MAGIC, 8, 262, 7, 262),
|
||||||
|
Err(AttachReject::Stale),
|
||||||
|
"recreated ring"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
check_attach(0, 8, 999, 7, 262),
|
||||||
|
Err(AttachReject::Stale),
|
||||||
|
"stale outranks bind"
|
||||||
|
);
|
||||||
|
// The v3 hardening: a fresh, magic-valid ring naming a DIFFERENT monitor fails closed.
|
||||||
|
assert_eq!(
|
||||||
|
check_attach(MAGIC, 7, 999, 7, 262),
|
||||||
|
Err(AttachReject::BindMismatch)
|
||||||
|
);
|
||||||
|
// A v2-host header (never stamped, target_id = 0) also fails closed against a v3 driver —
|
||||||
|
// the GET_INFO handshake rejects that pairing first, but the channel must not rely on it.
|
||||||
|
assert_eq!(
|
||||||
|
check_attach(MAGIC, 7, 0, 7, 262),
|
||||||
|
Err(AttachReject::BindMismatch)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -604,12 +812,32 @@ mod tests {
|
|||||||
height: 2160,
|
height: 2160,
|
||||||
refresh_hz: 120,
|
refresh_hz: 120,
|
||||||
preferred_monitor_id: 7,
|
preferred_monitor_id: 7,
|
||||||
|
max_luminance_nits: 800,
|
||||||
|
max_frame_avg_nits: 400,
|
||||||
|
min_luminance_millinits: 50, // 0.05 nits
|
||||||
|
_reserved: 0,
|
||||||
};
|
};
|
||||||
let bytes = bytemuck::bytes_of(&req);
|
let bytes = bytemuck::bytes_of(&req);
|
||||||
assert_eq!(bytes.len(), 24);
|
assert_eq!(bytes.len(), 40);
|
||||||
assert_eq!(*bytemuck::from_bytes::<control::AddRequest>(bytes), req);
|
assert_eq!(*bytemuck::from_bytes::<control::AddRequest>(bytes), req);
|
||||||
// preferred_monitor_id occupies the old `_reserved` slot at offset 20 — byte-compatible.
|
// preferred_monitor_id occupies the old `_reserved` slot at offset 20 — byte-compatible.
|
||||||
assert_eq!(bytes[20..24], 7u32.to_le_bytes());
|
assert_eq!(bytes[20..24], 7u32.to_le_bytes());
|
||||||
|
// The client-HDR luminance tail rides after the legacy boundary; a zero-filled tail decodes
|
||||||
|
// as "unknown" (the un-upgraded-host form the driver's legacy read synthesizes).
|
||||||
|
assert_eq!(bytes[24..28], 800u32.to_le_bytes());
|
||||||
|
let mut legacy = [0u8; 40];
|
||||||
|
legacy[..control::ADD_REQUEST_LEGACY_SIZE]
|
||||||
|
.copy_from_slice(&bytes[..control::ADD_REQUEST_LEGACY_SIZE]);
|
||||||
|
let old = *bytemuck::from_bytes::<control::AddRequest>(&legacy);
|
||||||
|
assert_eq!(old.preferred_monitor_id, 7);
|
||||||
|
assert_eq!(
|
||||||
|
(
|
||||||
|
old.max_luminance_nits,
|
||||||
|
old.max_frame_avg_nits,
|
||||||
|
old.min_luminance_millinits
|
||||||
|
),
|
||||||
|
(0, 0, 0)
|
||||||
|
);
|
||||||
|
|
||||||
let reply = control::AddReply {
|
let reply = control::AddReply {
|
||||||
adapter_luid_low: 0x1234_5678,
|
adapter_luid_low: 0x1234_5678,
|
||||||
@@ -702,6 +930,48 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cta_luminance_codes_hit_the_reference_points() {
|
||||||
|
// The driver's historical built-in EDID block: 0x8A ≈ 993 nits, 0x60 = 400 nits (exact),
|
||||||
|
// 0x12 for a ~0.05-nit floor. Our coder must land on the same bytes for those volumes.
|
||||||
|
assert_eq!(edid::cta_max_millinits(0x60), 400_000); // 50·2^3 exactly
|
||||||
|
assert_eq!(edid::cta_max_millinits(0x8A) / 1000, 993);
|
||||||
|
assert_eq!(edid::cta_max_luminance_code(400), 0x60);
|
||||||
|
// 0x8A decodes to 993.481 nits, so 994 is the smallest whole-nit input that reaches it
|
||||||
|
// under the never-advertise-brighter floor.
|
||||||
|
assert_eq!(edid::cta_max_luminance_code(994), 0x8A);
|
||||||
|
assert_eq!(edid::cta_min_luminance_code(50, 0x8A), 0x12); // 0.05 nits @ a 993-nit max
|
||||||
|
// Floor semantics: never advertise brighter than the panel. 1000 nits sits between
|
||||||
|
// code 138 (993) and 139 (~1015) → 138.
|
||||||
|
assert_eq!(edid::cta_max_luminance_code(1000), 138);
|
||||||
|
assert!(edid::cta_max_millinits(edid::cta_max_luminance_code(1000)) <= 1_000_000);
|
||||||
|
// Every real code decodes below or at its input (round-down), within one step (~2.2%).
|
||||||
|
// (Starts above code 1's 51.094 nits — beneath that the documented clamp-to-1 wins.)
|
||||||
|
for nits in [52u32, 80, 120, 250, 400, 604, 800, 1_499, 4_000, 10_000] {
|
||||||
|
let c = edid::cta_max_luminance_code(nits);
|
||||||
|
let dec = edid::cta_max_millinits(c);
|
||||||
|
assert!(dec <= nits as u64 * 1000, "{nits} → {c} decoded {dec}");
|
||||||
|
assert!(
|
||||||
|
dec * 1023 / 1000 >= nits as u64 * 1000,
|
||||||
|
"{nits} → {c} more than a step low"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Clamps: 0/tiny stays a valid on-wire code (callers gate presence on nits > 0); the
|
||||||
|
// ceiling saturates at 255.
|
||||||
|
assert_eq!(edid::cta_max_luminance_code(0), 1);
|
||||||
|
assert_eq!(edid::cta_max_luminance_code(u32::MAX), 255);
|
||||||
|
// Min-luminance: 0 = unknown/true black stays 0; a floor brighter than the max clamps.
|
||||||
|
assert_eq!(edid::cta_min_luminance_code(0, 0x8A), 0);
|
||||||
|
assert_eq!(edid::cta_min_luminance_code(u32::MAX, 1), 255);
|
||||||
|
// Round-trip a typical HDR400 panel: max 400 nits / min 0.4 nits.
|
||||||
|
let max_c = edid::cta_max_luminance_code(400);
|
||||||
|
let min_c = edid::cta_min_luminance_code(400, max_c);
|
||||||
|
// decode: L_min = L_max·(cv/255)²/100 — must come back within ~10% of 0.4 nits.
|
||||||
|
let back =
|
||||||
|
edid::cta_max_millinits(max_c) * (min_c as u64 * min_c as u64) / (255 * 255) / 100;
|
||||||
|
assert!((360..=440).contains(&back), "min decoded {back} millinits");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn guid_is_not_sudovda() {
|
fn guid_is_not_sudovda() {
|
||||||
const SUDOVDA: u128 = 0xE5BC_C234_1E0C_418A_A0D4_EF8B_7501_414D;
|
const SUDOVDA: u128 = 0xE5BC_C234_1E0C_418A_A0D4_EF8B_7501_414D;
|
||||||
|
|||||||
@@ -62,7 +62,17 @@ vec3 srgb_oetf(vec3 c) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
vec3 yuv = vec3(texture(u_y, v_uv).r, texture(u_c, v_uv).rg);
|
// 4:2:0 chroma is left-cosited (H.273 type 0 — the default inference when unsignaled, and
|
||||||
|
// what the hosts produce), but sampling the half-res plane at the luma UV assumes CENTER
|
||||||
|
// siting — a ~0.5-luma-px rightward chroma shift on hard colored edges. Offset +0.25 chroma
|
||||||
|
// texels to re-align (the same correction the Apple/Windows clients apply). Self-disables
|
||||||
|
// when the plane widths match (a full-size 4:4:4 chroma plane needs no correction).
|
||||||
|
vec2 cuv = v_uv;
|
||||||
|
int cw = textureSize(u_c, 0).x;
|
||||||
|
if (cw < textureSize(u_y, 0).x) {
|
||||||
|
cuv.x += 0.25 / float(cw);
|
||||||
|
}
|
||||||
|
vec3 yuv = vec3(texture(u_y, v_uv).r, texture(u_c, cuv).rg);
|
||||||
vec3 rgb = vec3(
|
vec3 rgb = vec3(
|
||||||
dot(pc.r0.xyz, yuv) + pc.r0.w,
|
dot(pc.r0.xyz, yuv) + pc.r0.w,
|
||||||
dot(pc.r1.xyz, yuv) + pc.r1.w,
|
dot(pc.r1.xyz, yuv) + pc.r1.w,
|
||||||
|
|||||||
Binary file not shown.
@@ -9,55 +9,11 @@
|
|||||||
|
|
||||||
use anyhow::{Context as _, Result};
|
use anyhow::{Context as _, Result};
|
||||||
use ash::vk;
|
use ash::vk;
|
||||||
use pf_client_core::video::ColorDesc;
|
|
||||||
|
|
||||||
/// The push-constant block's matrix half: three vec4 rows,
|
// The coefficient math lives in pf-client-core next to `ColorDesc` (one tested
|
||||||
/// `rgb[i] = dot(r[i].xyz, yuv) + r[i].w` — bit-depth exact.
|
// implementation shared with the Windows client's D3D11 constant buffer and mirrored by the
|
||||||
///
|
// Apple client's Swift port); re-exported here so presenter callers keep their import path.
|
||||||
/// `depth` picks the limited-range code points (8-bit: 16/235/240 over 255; 10-bit:
|
pub use pf_client_core::video::csc_rows;
|
||||||
/// 64/940/960 over 1023 — NOT the same normalized values, the difference is ~half a
|
|
||||||
/// code). `msb_packed` folds in the P010/X6 packing factor: 10 significant bits live in
|
|
||||||
/// the MSBs of 16, so a UNORM16 sample reads `code·64/65535` — multiplying by
|
|
||||||
/// `65535/65472` recovers exact `code/1023`.
|
|
||||||
pub fn csc_rows(desc: ColorDesc, depth: u8, msb_packed: bool) -> [[f32; 4]; 3] {
|
|
||||||
// BT.601 (5/6), BT.2020 (9/10); everything else — incl. unspecified — is the host's
|
|
||||||
// BT.709 SDR default (mirrors the software path's swscale coefficient choice).
|
|
||||||
let (kr, kb) = match desc.matrix {
|
|
||||||
5 | 6 => (0.299, 0.114),
|
|
||||||
9 | 10 => (0.2627, 0.0593),
|
|
||||||
_ => (0.2126, 0.0722),
|
|
||||||
};
|
|
||||||
let kg = 1.0 - kr - kb;
|
|
||||||
let max = f64::from((1u32 << depth) - 1); // 255 / 1023
|
|
||||||
let step = f64::from(1u32 << (depth - 8)); // code points per 8-bit step: 1 / 4
|
|
||||||
let pack = if msb_packed { 65535.0 / 65472.0 } else { 1.0 };
|
|
||||||
let (sy, oy, sc) = if desc.full_range {
|
|
||||||
(pack, 0.0f64, pack)
|
|
||||||
} else {
|
|
||||||
(
|
|
||||||
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 = [
|
|
||||||
[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],
|
|
||||||
];
|
|
||||||
core::array::from_fn(|r| {
|
|
||||||
let w: f64 = (0..3).map(|c| m[r][c] * off[c]).sum();
|
|
||||||
[m[r][0] as f32, m[r][1] as f32, m[r][2] as f32, w as f32]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The pass objects (everything except the per-video-size framebuffer, which lives with
|
/// The pass objects (everything except the per-video-size framebuffer, which lives with
|
||||||
/// the video image). Destroyed explicitly via [`CscPass::destroy`] from the presenter's
|
/// the video image). Destroyed explicitly via [`CscPass::destroy`] from the presenter's
|
||||||
@@ -336,119 +292,3 @@ pub(crate) fn build_fullscreen_pipeline(
|
|||||||
}
|
}
|
||||||
Ok(pipeline?[0])
|
Ok(pipeline?[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn desc(matrix: u8, full_range: bool) -> ColorDesc {
|
|
||||||
ColorDesc {
|
|
||||||
primaries: 1,
|
|
||||||
transfer: 1,
|
|
||||||
matrix,
|
|
||||||
full_range,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply(rows: &[[f32; 4]; 3], yuv: [f32; 3]) -> [f32; 3] {
|
|
||||||
core::array::from_fn(|r| {
|
|
||||||
rows[r][0] * yuv[0] + rows[r][1] * yuv[1] + rows[r][2] * yuv[2] + rows[r][3]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 10-bit limited MSB-packed (P010/X6): reference white Y=940, black Y=64, neutral
|
|
||||||
/// chroma 512 — sampled as UNORM16 of `code << 6`.
|
|
||||||
#[test]
|
|
||||||
fn bt2020_10bit_limited_white_black() {
|
|
||||||
let rows = csc_rows(desc(9, false), 10, true);
|
|
||||||
let s = |code: u32| ((code << 6) as f32) / 65535.0;
|
|
||||||
let white = apply(&rows, [s(940), s(512), s(512)]);
|
|
||||||
let black = apply(&rows, [s(64), s(512), s(512)]);
|
|
||||||
for (w, b) in white.iter().zip(black) {
|
|
||||||
assert!((w - 1.0).abs() < 0.002, "white {white:?}");
|
|
||||||
assert!(b.abs() < 0.002, "black {black:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reference white (Y=235, U=V=128 limited) → RGB 1.0; reference black (Y=16) → 0.0
|
|
||||||
/// — the GL presenter's test, in row form.
|
|
||||||
#[test]
|
|
||||||
fn bt709_limited_white_black() {
|
|
||||||
let rows = csc_rows(desc(1, false), 8, false);
|
|
||||||
let white = apply(&rows, [235.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]);
|
|
||||||
let black = apply(&rows, [16.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]);
|
|
||||||
for (w, b) in white.iter().zip(black) {
|
|
||||||
assert!((w - 1.0).abs() < 0.005, "white {white:?}");
|
|
||||||
assert!(b.abs() < 0.005, "black {black:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Full-range identity points + the 601-vs-709 red excursion (guards the
|
|
||||||
/// matrix-code dispatch), same as the GL presenter's test.
|
|
||||||
#[test]
|
|
||||||
fn full_range_and_red_excursion() {
|
|
||||||
let rows = csc_rows(desc(5, true), 8, false);
|
|
||||||
let white = apply(&rows, [1.0, 0.5, 0.5]);
|
|
||||||
assert!(white.iter().all(|v| (v - 1.0).abs() < 1e-5), "{white:?}");
|
|
||||||
let red = apply(&rows, [0.0, 0.5, 1.0]);
|
|
||||||
assert!((red[0] - 2.0 * (1.0 - 0.299) * 0.5).abs() < 1e-4, "{red:?}");
|
|
||||||
let rows709 = csc_rows(desc(1, true), 8, false);
|
|
||||||
let red709 = apply(&rows709, [0.0, 0.5, 1.0]);
|
|
||||||
assert!(
|
|
||||||
(red709[0] - 2.0 * (1.0 - 0.2126) * 0.5).abs() < 1e-4,
|
|
||||||
"{red709:?}"
|
|
||||||
);
|
|
||||||
assert!((red[0] - red709[0]).abs() > 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The row form must agree with the GL presenter's column-major `yuv_to_rgb` on a
|
|
||||||
/// grid of inputs — same math, different packing.
|
|
||||||
#[test]
|
|
||||||
fn rows_match_the_gl_matrix_form() {
|
|
||||||
for (matrix, full) in [(1u8, false), (1, true), (5, false), (9, false), (9, true)] {
|
|
||||||
let d = desc(matrix, full);
|
|
||||||
let rows = csc_rows(d, 8, false);
|
|
||||||
// Reimplementation of video_gl::yuv_to_rgb's application for comparison.
|
|
||||||
let (kr, kb) = match matrix {
|
|
||||||
5 | 6 => (0.299f32, 0.114f32),
|
|
||||||
9 | 10 => (0.2627, 0.0593),
|
|
||||||
_ => (0.2126, 0.0722),
|
|
||||||
};
|
|
||||||
let kg = 1.0 - kr - kb;
|
|
||||||
let (sy, oy, sc) = if full {
|
|
||||||
(1.0f32, 0.0f32, 1.0f32)
|
|
||||||
} else {
|
|
||||||
(255.0 / 219.0, -16.0 / 255.0, 255.0 / 224.0)
|
|
||||||
};
|
|
||||||
let mat = [
|
|
||||||
sy,
|
|
||||||
sy,
|
|
||||||
sy,
|
|
||||||
0.0,
|
|
||||||
-2.0 * (1.0 - kb) * kb / kg * sc,
|
|
||||||
2.0 * (1.0 - kb) * sc,
|
|
||||||
2.0 * (1.0 - kr) * sc,
|
|
||||||
-2.0 * (1.0 - kr) * kr / kg * sc,
|
|
||||||
0.0,
|
|
||||||
];
|
|
||||||
let off = [oy, -0.5, -0.5];
|
|
||||||
for yuv in [
|
|
||||||
[0.1f32, 0.3, 0.7],
|
|
||||||
[0.9, 0.5, 0.5],
|
|
||||||
[0.5, 0.2, 0.8],
|
|
||||||
[16.0 / 255.0, 0.5, 0.5],
|
|
||||||
] {
|
|
||||||
let v = [yuv[0] + off[0], yuv[1] + off[1], yuv[2] + off[2]];
|
|
||||||
let gl: [f32; 3] =
|
|
||||||
core::array::from_fn(|r| (0..3).map(|c| mat[c * 3 + r] * v[c]).sum());
|
|
||||||
let ours = apply(&rows, yuv);
|
|
||||||
for (a, b) in gl.iter().zip(ours) {
|
|
||||||
assert!(
|
|
||||||
(a - b).abs() < 1e-5,
|
|
||||||
"{matrix}/{full}: gl {gl:?} rows {ours:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ pub struct FrameCtx<'a> {
|
|||||||
pub stats: Option<&'a str>,
|
pub stats: Option<&'a str>,
|
||||||
/// The capture hint (bottom-center pill, "click to capture…"); `None` = hidden.
|
/// The capture hint (bottom-center pill, "click to capture…"); `None` = hidden.
|
||||||
pub hint: Option<&'a str>,
|
pub hint: Option<&'a str>,
|
||||||
|
/// A mid-stream Match-window resize is in flight (design/midstream-resolution-resize.md,
|
||||||
|
/// client UX): draw a full-screen scrim + spinner so the host's 0.3–2 s virtual-display
|
||||||
|
/// and encoder rebuild reads as an intentional pause rather than the stream stretching to
|
||||||
|
/// the changed window. Cleared the instant the sharp new-resolution frame is on glass.
|
||||||
|
pub resizing: bool,
|
||||||
/// The active gamepad's name (the console library's controller chip).
|
/// The active gamepad's name (the console library's controller chip).
|
||||||
pub pad: Option<&'a str>,
|
pub pad: Option<&'a str>,
|
||||||
/// The active pad's resolved kind — drives the console UI's button glyphs
|
/// The active pad's resolved kind — drives the console UI's button glyphs
|
||||||
|
|||||||
+459
-13
@@ -52,6 +52,18 @@ pub struct SessionOpts {
|
|||||||
/// stay stdout-only). An overlay whose `init` fails degrades to `None` with a
|
/// stay stdout-only). An overlay whose `init` fails degrades to `None` with a
|
||||||
/// warning rather than killing the session. Browse mode requires one.
|
/// warning rather than killing the session. Browse mode requires one.
|
||||||
pub overlay: Option<Box<dyn Overlay>>,
|
pub overlay: Option<Box<dyn Overlay>>,
|
||||||
|
/// The window's starting logical size; `None` = the 1280×720 default. The binary
|
||||||
|
/// passes the persisted last-window size under the Match-window policy so the first
|
||||||
|
/// connect's mode already matches what the user will be looking at.
|
||||||
|
pub window_size: Option<(u32, u32)>,
|
||||||
|
/// Match-window resolution policy (design/midstream-resolution-resize.md D1/D2):
|
||||||
|
/// `Some` = the stream mode follows the window. At session start the params' mode
|
||||||
|
/// w/h are replaced by the window's physical pixel size; a mid-session resize sends
|
||||||
|
/// a debounced `Reconfigure` so the host's virtual display + encoder follow. The
|
||||||
|
/// callback receives the window's logical size at each resize-end — the binary
|
||||||
|
/// persists it for the next launch. `None` = never auto-resize (Auto-native /
|
||||||
|
/// Explicit keep today's behavior).
|
||||||
|
pub match_window: Option<Box<dyn FnMut(u32, u32)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum Outcome {
|
pub enum Outcome {
|
||||||
@@ -154,7 +166,9 @@ struct StreamState {
|
|||||||
canceled: bool,
|
canceled: bool,
|
||||||
ready_announced: bool,
|
ready_announced: bool,
|
||||||
mode_line: String,
|
mode_line: String,
|
||||||
clock_offset_ns: i64,
|
/// Live host↔client clock offset handle (None until Connected): loaded per present so
|
||||||
|
/// mid-stream re-syncs keep the end-to-end number honest after an NTP step / drift.
|
||||||
|
clock_offset: Option<Arc<std::sync::atomic::AtomicI64>>,
|
||||||
hdr: bool,
|
hdr: bool,
|
||||||
// Presenter-side 1 s window (design/stats-unification.md): end-to-end
|
// Presenter-side 1 s window (design/stats-unification.md): end-to-end
|
||||||
// capture→displayed (host-clock corrected) p50+p95, display = decoded→displayed p50.
|
// capture→displayed (host-clock corrected) p50+p95, display = decoded→displayed p50.
|
||||||
@@ -171,6 +185,25 @@ struct StreamState {
|
|||||||
/// The last pump window, kept so a Ctrl+Alt+Shift+S tier cycle can re-render the
|
/// The last pump window, kept so a Ctrl+Alt+Shift+S tier cycle can re-render the
|
||||||
/// OSD immediately instead of waiting up to 1 s for the next Stats event.
|
/// OSD immediately instead of waiting up to 1 s for the next Stats event.
|
||||||
last_stats: Option<Stats>,
|
last_stats: Option<Stats>,
|
||||||
|
/// Match-window (D2) debounce state: the last resize event's stamp. `Some` = a
|
||||||
|
/// resize is pending; the tick fires the request once ~400 ms pass with no further
|
||||||
|
/// size events (never per drag-frame — each accepted switch is a full host rebuild).
|
||||||
|
resize_pending: Option<Instant>,
|
||||||
|
/// When the last `Reconfigure` was sent — the ≥ 1 s spacing between requests (D2).
|
||||||
|
/// The accept ack round-trips in milliseconds (it precedes the host's rebuild), so
|
||||||
|
/// this spacing also serializes: at most ~one request is ever outstanding.
|
||||||
|
resize_sent_at: Option<Instant>,
|
||||||
|
/// The last size actually requested. Each distinct size is requested at most once:
|
||||||
|
/// this both implements "don't re-request a rejected size until it changes" (D2) and
|
||||||
|
/// keeps a host-side rollback (accept ack, rebuild failed, corrective ack restored
|
||||||
|
/// the old mode) from looping request → rollback → request forever.
|
||||||
|
resize_requested: Option<(u32, u32)>,
|
||||||
|
/// The connector mode last shown in the HUD/title — a change (an accepted switch's
|
||||||
|
/// ack, or a corrective rollback) refreshes both.
|
||||||
|
shown_mode: Option<Mode>,
|
||||||
|
/// Resize-in-progress overlay (scrim + spinner) — armed by [`resize_tick`] when it
|
||||||
|
/// requests a switch, cleared when a decoded frame reaches the target (or on timeout).
|
||||||
|
resize_overlay: ResizeIndicator,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StreamState {
|
impl StreamState {
|
||||||
@@ -205,7 +238,7 @@ impl StreamState {
|
|||||||
canceled: false,
|
canceled: false,
|
||||||
ready_announced: false,
|
ready_announced: false,
|
||||||
mode_line: String::new(),
|
mode_line: String::new(),
|
||||||
clock_offset_ns: 0,
|
clock_offset: None,
|
||||||
hdr: false,
|
hdr: false,
|
||||||
win_e2e_us: Vec::with_capacity(256),
|
win_e2e_us: Vec::with_capacity(256),
|
||||||
win_disp_us: Vec::with_capacity(256),
|
win_disp_us: Vec::with_capacity(256),
|
||||||
@@ -215,6 +248,11 @@ impl StreamState {
|
|||||||
hw_fails: 0,
|
hw_fails: 0,
|
||||||
osd_text: String::new(),
|
osd_text: String::new(),
|
||||||
last_stats: None,
|
last_stats: None,
|
||||||
|
resize_pending: None,
|
||||||
|
resize_sent_at: None,
|
||||||
|
resize_requested: None,
|
||||||
|
shown_mode: None,
|
||||||
|
resize_overlay: ResizeIndicator::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,7 +306,10 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
.register_custom_event::<FrameWake>()
|
.register_custom_event::<FrameWake>()
|
||||||
.map_err(|e| anyhow::anyhow!("register FrameWake event: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("register FrameWake event: {e}"))?;
|
||||||
let mut window = {
|
let mut window = {
|
||||||
let mut b = video.window(&opts.window_title, 1280, 720);
|
// Match-window (D1): open at the persisted last size, so the first connect's
|
||||||
|
// mode already matches the glass. 1280×720 stays the fallback/default.
|
||||||
|
let (ww, wh) = opts.window_size.unwrap_or((1280, 720));
|
||||||
|
let mut b = video.window(&opts.window_title, ww.max(320), wh.max(200));
|
||||||
match opts.window_pos {
|
match opts.window_pos {
|
||||||
Some((x, y)) => b.position(x, y),
|
Some((x, y)) => b.position(x, y),
|
||||||
None => b.position_centered(),
|
None => b.position_centered(),
|
||||||
@@ -338,12 +379,15 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
let mut stream: Option<StreamState> = match &mut mode {
|
let mut stream: Option<StreamState> = match &mut mode {
|
||||||
ModeCtl::Single(build) => {
|
ModeCtl::Single(build) => {
|
||||||
let force_software = Arc::new(AtomicBool::new(false));
|
let force_software = Arc::new(AtomicBool::new(false));
|
||||||
let params = build(
|
let mut params = build(
|
||||||
&gamepad,
|
&gamepad,
|
||||||
native,
|
native,
|
||||||
force_software.clone(),
|
force_software.clone(),
|
||||||
presenter.vulkan_decode(),
|
presenter.vulkan_decode(),
|
||||||
);
|
);
|
||||||
|
if opts.match_window.is_some() {
|
||||||
|
apply_match_window(&mut params, &window);
|
||||||
|
}
|
||||||
Some(StreamState::new(
|
Some(StreamState::new(
|
||||||
params,
|
params,
|
||||||
force_software,
|
force_software,
|
||||||
@@ -421,6 +465,14 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
WindowEvent::PixelSizeChanged(..) | WindowEvent::Resized(..) => {
|
WindowEvent::PixelSizeChanged(..) | WindowEvent::Resized(..) => {
|
||||||
presenter.recreate_swapchain(&window)?;
|
presenter.recreate_swapchain(&window)?;
|
||||||
presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?;
|
presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?;
|
||||||
|
// Match-window (D2): (re)stamp the debounce — the request fires
|
||||||
|
// once ~400 ms pass with no further size events, never per
|
||||||
|
// drag-frame (each accepted switch is a full host rebuild).
|
||||||
|
if opts.match_window.is_some() {
|
||||||
|
if let Some(st) = stream.as_mut() {
|
||||||
|
st.resize_pending = Some(Instant::now());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
WindowEvent::Exposed => {
|
WindowEvent::Exposed => {
|
||||||
presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?;
|
presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?;
|
||||||
@@ -614,7 +666,10 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
presenter.vulkan_decode(),
|
presenter.vulkan_decode(),
|
||||||
) {
|
) {
|
||||||
ActionOutcome::Handled => {}
|
ActionOutcome::Handled => {}
|
||||||
ActionOutcome::Start(params) => {
|
ActionOutcome::Start(mut params) => {
|
||||||
|
if opts.match_window.is_some() {
|
||||||
|
apply_match_window(&mut params, &window);
|
||||||
|
}
|
||||||
stream = Some(StreamState::new(
|
stream = Some(StreamState::new(
|
||||||
*params,
|
*params,
|
||||||
force_software,
|
force_software,
|
||||||
@@ -657,7 +712,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
.set_title(&format!("{} · {}", opts.window_title, st.mode_line))
|
.set_title(&format!("{} · {}", opts.window_title, st.mode_line))
|
||||||
.ok();
|
.ok();
|
||||||
gamepad.attach(c.clone());
|
gamepad.attach(c.clone());
|
||||||
st.clock_offset_ns = c.clock_offset_ns;
|
st.clock_offset = Some(c.clock_offset_shared());
|
||||||
let mut cap = Capture::new(c.clone());
|
let mut cap = Capture::new(c.clone());
|
||||||
cap.engage(); // capture engages when the stream starts (ui_stream parity)
|
cap.engage(); // capture engages when the stream starts (ui_stream parity)
|
||||||
apply_capture(&mut window, &mouse, true);
|
apply_capture(&mut window, &mouse, true);
|
||||||
@@ -751,6 +806,18 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Match-window (D2): debounced mode-follow + HUD/title refresh on a switch ----
|
||||||
|
if let Some(persist) = opts.match_window.as_mut() {
|
||||||
|
if let Some(st) = stream.as_mut() {
|
||||||
|
resize_tick(st, &mut window, &opts.window_title, persist.as_mut());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Resize overlay timeout: a switch the host rejected/capped never delivers the exact
|
||||||
|
// target frame — drop the scrim so it can't linger. A no-op unless one is showing.
|
||||||
|
if let Some(st) = stream.as_mut() {
|
||||||
|
st.resize_overlay.tick(Instant::now());
|
||||||
|
}
|
||||||
|
|
||||||
// --- Console UI: damage-driven overlay re-render for this iteration --------------
|
// --- Console UI: damage-driven overlay re-render for this iteration --------------
|
||||||
if let Some(o) = overlay.as_mut() {
|
if let Some(o) = overlay.as_mut() {
|
||||||
let (pw, ph) = window.size_in_pixels();
|
let (pw, ph) = window.size_in_pixels();
|
||||||
@@ -774,11 +841,15 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
};
|
};
|
||||||
let pad = gamepad.active();
|
let pad = gamepad.active();
|
||||||
let pads = gamepad.pads();
|
let pads = gamepad.pads();
|
||||||
|
let resizing = stream
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|st| st.connector.is_some() && st.resize_overlay.active());
|
||||||
let ctx = FrameCtx {
|
let ctx = FrameCtx {
|
||||||
width: pw,
|
width: pw,
|
||||||
height: ph,
|
height: ph,
|
||||||
stats,
|
stats,
|
||||||
hint,
|
hint,
|
||||||
|
resizing,
|
||||||
pad: pad.as_ref().map(|p| p.name.as_str()),
|
pad: pad.as_ref().map(|p| p.name.as_str()),
|
||||||
pad_pref: pad.as_ref().map(|p| p.pref),
|
pad_pref: pad.as_ref().map(|p| p.pref),
|
||||||
pads: &pads,
|
pads: &pads,
|
||||||
@@ -812,6 +883,10 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
newest = Some(f);
|
newest = Some(f);
|
||||||
}
|
}
|
||||||
if let Some(f) = newest {
|
if let Some(f) = newest {
|
||||||
|
// Resize END: a frame at the steered target size means the sharp new-mode
|
||||||
|
// picture is here — lift the scrim. A no-op unless a switch is in flight.
|
||||||
|
let (fw, fh) = f.image.dimensions();
|
||||||
|
st.resize_overlay.decoded(fw, fh);
|
||||||
let DecodedFrame {
|
let DecodedFrame {
|
||||||
pts_ns,
|
pts_ns,
|
||||||
decoded_ns,
|
decoded_ns,
|
||||||
@@ -960,7 +1035,11 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
println!("{{\"ready\":true}}");
|
println!("{{\"ready\":true}}");
|
||||||
}
|
}
|
||||||
// The `displayed` stamp (same clamp rules as the pump's windows).
|
// The `displayed` stamp (same clamp rules as the pump's windows).
|
||||||
let e2e = (displayed_ns as i128 + st.clock_offset_ns as i128 - pts_ns as i128)
|
let clock_offset_ns = st
|
||||||
|
.clock_offset
|
||||||
|
.as_ref()
|
||||||
|
.map_or(0, |o| o.load(Ordering::Relaxed));
|
||||||
|
let e2e = (displayed_ns as i128 + clock_offset_ns as i128 - pts_ns as i128)
|
||||||
.max(0) as u64;
|
.max(0) as u64;
|
||||||
if e2e > 0 && e2e < 10_000_000_000 {
|
if e2e > 0 && e2e < 10_000_000_000 {
|
||||||
st.win_e2e_us.push(e2e / 1000);
|
st.win_e2e_us.push(e2e / 1000);
|
||||||
@@ -985,12 +1064,15 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Browse with no video driving presents (library / connecting): composite the
|
// Composite the overlay every iteration when no video frame drove a present but
|
||||||
// overlay every iteration — FIFO vsync-throttles this to the display rate.
|
// something on-screen still animates: browse-idle (library / connecting), OR a
|
||||||
if matches!(mode, ModeCtl::Browse(_))
|
// mid-stream resize scrim + spinner (the host's virtual-display + encoder rebuild
|
||||||
&& !presented_video
|
// leaves a gap with no frames — without this the spinner would freeze). FIFO
|
||||||
&& stream.as_ref().is_none_or(|s| s.connector.is_none())
|
// vsync-throttles this to the display rate; the 15 ms wait keeps it smooth.
|
||||||
{
|
let resize_scrim = stream.as_ref().is_some_and(|s| s.resize_overlay.active());
|
||||||
|
let browse_idle = matches!(mode, ModeCtl::Browse(_))
|
||||||
|
&& stream.as_ref().is_none_or(|s| s.connector.is_none());
|
||||||
|
if !presented_video && (resize_scrim || browse_idle) {
|
||||||
presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?;
|
presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1007,6 +1089,195 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
Ok(outcome)
|
Ok(outcome)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Match-window (D1): replace the params' requested w/h with the window's physical pixel
|
||||||
|
/// size — even-floored (the host's `validate_dimensions` rejects odd) and clamped to a
|
||||||
|
/// sane minimum — keeping the resolved refresh. Under `--fullscreen` the window IS the
|
||||||
|
/// display, so this degenerates to the display's native mode.
|
||||||
|
fn apply_match_window(params: &mut SessionParams, window: &sdl3::video::Window) {
|
||||||
|
let (pw, ph) = window.size_in_pixels();
|
||||||
|
params.mode.width = (pw & !1).max(320);
|
||||||
|
params.mode.height = (ph & !1).max(200);
|
||||||
|
tracing::info!(
|
||||||
|
w = params.mode.width,
|
||||||
|
h = params.mode.height,
|
||||||
|
"match-window: requesting the window's pixel size"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Match-window (D2) per-iteration tick: refresh the HUD line + window title when the
|
||||||
|
/// live mode moves (an accepted switch's ack, or a corrective rollback), then fire the
|
||||||
|
/// debounced `Reconfigure` once ~400 ms pass with no further resize events. The shared
|
||||||
|
/// trigger discipline:
|
||||||
|
/// * physical pixels, even-floored, clamped ≥ 320×200; the current refresh is kept;
|
||||||
|
/// * ≥ 1 s between requests (the accept ack round-trips in milliseconds — it precedes
|
||||||
|
/// the host's rebuild — so the spacing also keeps at most ~one request outstanding);
|
||||||
|
/// * each distinct size is requested at most ONCE (`resize_requested`): a rejected
|
||||||
|
/// size isn't re-asked until the window changes, and a host-side rollback (accepted,
|
||||||
|
/// rebuild failed, corrective ack restored the old mode) can't loop.
|
||||||
|
fn resize_tick(
|
||||||
|
st: &mut StreamState,
|
||||||
|
window: &mut sdl3::video::Window,
|
||||||
|
title_base: &str,
|
||||||
|
persist: &mut dyn FnMut(u32, u32),
|
||||||
|
) {
|
||||||
|
let Some(c) = &st.connector else {
|
||||||
|
return; // not connected yet — the pending stamp survives until we are
|
||||||
|
};
|
||||||
|
// HUD/title follow the live mode slot (updated by any accepted ack).
|
||||||
|
let m = c.mode();
|
||||||
|
if st.shown_mode.is_some_and(|prev| prev != m) {
|
||||||
|
st.mode_line = format!("{}×{}@{}", m.width, m.height, m.refresh_hz);
|
||||||
|
tracing::info!(mode = %st.mode_line, "stream mode switched");
|
||||||
|
let _ = window.set_title(&format!("{title_base} · {}", st.mode_line));
|
||||||
|
}
|
||||||
|
st.shown_mode = Some(m);
|
||||||
|
|
||||||
|
match resize_decision(
|
||||||
|
Instant::now(),
|
||||||
|
&mut st.resize_pending,
|
||||||
|
st.resize_sent_at,
|
||||||
|
st.resize_requested,
|
||||||
|
(m.width, m.height),
|
||||||
|
window.size_in_pixels(),
|
||||||
|
) {
|
||||||
|
ResizeAction::Wait => {}
|
||||||
|
ResizeAction::Settled(target) => {
|
||||||
|
// The debounce settled: persist the window's LOGICAL size for the next
|
||||||
|
// launch (its window is created in logical units) even when no request goes
|
||||||
|
// out (e.g. resized back to the streamed size).
|
||||||
|
let (lw, lh) = window.size();
|
||||||
|
persist(lw, lh);
|
||||||
|
let Some((w, h)) = target else { return };
|
||||||
|
tracing::info!(w, h, "window resized — requesting mode switch");
|
||||||
|
if c.request_mode(Mode {
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
refresh_hz: m.refresh_hz,
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
tracing::warn!("mode-switch request dropped — control channel closed");
|
||||||
|
}
|
||||||
|
st.resize_requested = Some((w, h));
|
||||||
|
st.resize_sent_at = Some(Instant::now());
|
||||||
|
// Show the scrim + spinner until a frame at this size lands (or the timeout):
|
||||||
|
// the live drag itself stays sharp; only the host's rebuild gap is covered.
|
||||||
|
st.resize_overlay.steering(w, h, Instant::now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one [`resize_decision`] tick decided.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum ResizeAction {
|
||||||
|
/// Nothing to do yet (no resize pending, still debouncing, or spacing defers — the
|
||||||
|
/// pending stamp is kept so a later tick retries).
|
||||||
|
Wait,
|
||||||
|
/// The debounce settled (pending cleared, the caller persists the window size), with
|
||||||
|
/// the mode to request — `None` when the size needs no switch (equal to the streamed
|
||||||
|
/// mode, or this exact size was already requested once).
|
||||||
|
Settled(Option<(u32, u32)>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The D2 trigger discipline as a pure decision (unit-tested — CI can't open windows):
|
||||||
|
/// debounce to resize-end, ≥ 1 s between requests, physical pixels even-floored and
|
||||||
|
/// clamped ≥ 320×200, skip when equal to the streamed mode, and each distinct size
|
||||||
|
/// requested at most once (covers rejected sizes AND host-side rollbacks).
|
||||||
|
fn resize_decision(
|
||||||
|
now: Instant,
|
||||||
|
pending: &mut Option<Instant>,
|
||||||
|
sent_at: Option<Instant>,
|
||||||
|
requested: Option<(u32, u32)>,
|
||||||
|
current: (u32, u32),
|
||||||
|
pixel_size: (u32, u32),
|
||||||
|
) -> ResizeAction {
|
||||||
|
const DEBOUNCE: Duration = Duration::from_millis(400);
|
||||||
|
const SPACING: Duration = Duration::from_secs(1);
|
||||||
|
let Some(since) = *pending else {
|
||||||
|
return ResizeAction::Wait;
|
||||||
|
};
|
||||||
|
if now.duration_since(since) < DEBOUNCE {
|
||||||
|
return ResizeAction::Wait;
|
||||||
|
}
|
||||||
|
if sent_at.is_some_and(|at| now.duration_since(at) < SPACING) {
|
||||||
|
return ResizeAction::Wait; // keep the pending stamp — a later tick retries
|
||||||
|
}
|
||||||
|
*pending = None;
|
||||||
|
let target = ((pixel_size.0 & !1).max(320), (pixel_size.1 & !1).max(200));
|
||||||
|
if current == target || requested == Some(target) {
|
||||||
|
return ResizeAction::Settled(None);
|
||||||
|
}
|
||||||
|
ResizeAction::Settled(Some(target))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resize-in-progress overlay state (design/midstream-resolution-resize.md — client UX),
|
||||||
|
/// ported from the Apple client's `ResizeIndicator`. A mid-stream Match-window switch takes
|
||||||
|
/// the host 0.3–2 s to rebuild its virtual display + encoder, and the first new-mode frame
|
||||||
|
/// is an IDR the decoder re-inits on. Rather than let the stream stretch to the changed
|
||||||
|
/// window during that gap, the presenter EMBRACES the delay: a deliberate scrim + spinner
|
||||||
|
/// the instant a switch is requested, cleared the instant the sharp new-resolution frame is
|
||||||
|
/// on screen — so the wait reads as intentional, not as lag.
|
||||||
|
///
|
||||||
|
/// Driven entirely by signals the presenter already has (no new protocol):
|
||||||
|
/// * START — [`resize_tick`] reports the size it just requested (`steering`).
|
||||||
|
/// * END — the decode pipeline reports each frame's dimensions; when they reach the
|
||||||
|
/// target the new picture is here (`decoded`). The accepted-switch ack alone can't
|
||||||
|
/// end it: the ack round-trips in milliseconds, ahead of the host's rebuild.
|
||||||
|
/// * TIMEOUT — the safety net for a switch that never delivers the exact target (a
|
||||||
|
/// gamescope reject, an advertised-mode cap, or a corrective ack landing a different
|
||||||
|
/// size); `tick` clears it after [`ResizeIndicator::TIMEOUT`].
|
||||||
|
///
|
||||||
|
/// Pure + clock-injected so the transition logic is unit-tested without a live session.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ResizeIndicator {
|
||||||
|
/// The size the follower is steering toward — cleared once a decoded frame reaches it.
|
||||||
|
/// `Some` ⇔ the scrim + spinner should be shown.
|
||||||
|
target: Option<(u32, u32)>,
|
||||||
|
/// When the current active span began — the timeout is measured from here.
|
||||||
|
since: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResizeIndicator {
|
||||||
|
/// How long to keep the overlay up if the target frame never arrives.
|
||||||
|
const TIMEOUT: Duration = Duration::from_millis(2500);
|
||||||
|
|
||||||
|
/// Whether the scrim + spinner should be shown.
|
||||||
|
fn active(&self) -> bool {
|
||||||
|
self.target.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A switch to `w`×`h` was just requested — show the overlay now. The timeout re-arms
|
||||||
|
/// only when the target actually changes, so a drag that walks through several sizes
|
||||||
|
/// (each its own request) never trips the timeout mid-gesture.
|
||||||
|
fn steering(&mut self, w: u32, h: u32, now: Instant) {
|
||||||
|
if self.target != Some((w, h)) {
|
||||||
|
self.since = Some(now);
|
||||||
|
}
|
||||||
|
self.target = Some((w, h));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A decoded frame arrived at `w`×`h`. Clears the overlay once it matches the steered
|
||||||
|
/// target — the sharp new-resolution picture is on glass.
|
||||||
|
fn decoded(&mut self, w: u32, h: u32) {
|
||||||
|
if self.target == Some((w, h)) {
|
||||||
|
self.target = None;
|
||||||
|
self.since = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Timeout safety net: stop showing once [`TIMEOUT`](Self::TIMEOUT) has elapsed with no
|
||||||
|
/// matching frame (a rejected or host-capped switch never delivers the exact target).
|
||||||
|
fn tick(&mut self, now: Instant) {
|
||||||
|
if self
|
||||||
|
.since
|
||||||
|
.is_some_and(|s| now.duration_since(s) >= Self::TIMEOUT)
|
||||||
|
{
|
||||||
|
self.target = None;
|
||||||
|
self.since = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Apply the capture state to the window: pointer lock (relative mouse + hidden cursor)
|
/// Apply the capture state to the window: pointer lock (relative mouse + hidden cursor)
|
||||||
/// and — on Windows — a keyboard grab, so system chords (Alt+Tab, the Windows key) reach
|
/// and — on Windows — a keyboard grab, so system chords (Alt+Tab, the Windows key) reach
|
||||||
/// the host while captured instead of the local shell. SDL implements the grab there
|
/// the host while captured instead of the local shell. SDL implements the grab there
|
||||||
@@ -1109,6 +1380,181 @@ fn stats_text(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resize_decision_follows_the_d2_discipline() {
|
||||||
|
let t0 = Instant::now();
|
||||||
|
let ms = Duration::from_millis;
|
||||||
|
|
||||||
|
// No resize pending → nothing to do.
|
||||||
|
let mut pending = None;
|
||||||
|
assert_eq!(
|
||||||
|
resize_decision(t0, &mut pending, None, None, (1280, 720), (1000, 600)),
|
||||||
|
ResizeAction::Wait
|
||||||
|
);
|
||||||
|
|
||||||
|
// Still debouncing (a drag in progress) → wait, pending kept.
|
||||||
|
let mut pending = Some(t0);
|
||||||
|
assert_eq!(
|
||||||
|
resize_decision(
|
||||||
|
t0 + ms(399),
|
||||||
|
&mut pending,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
(1280, 720),
|
||||||
|
(1000, 600)
|
||||||
|
),
|
||||||
|
ResizeAction::Wait
|
||||||
|
);
|
||||||
|
assert!(pending.is_some(), "pending survives the wait");
|
||||||
|
|
||||||
|
// Debounce settled → request the even-floored, clamped pixel size.
|
||||||
|
assert_eq!(
|
||||||
|
resize_decision(
|
||||||
|
t0 + ms(400),
|
||||||
|
&mut pending,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
(1280, 720),
|
||||||
|
(1001, 601)
|
||||||
|
),
|
||||||
|
ResizeAction::Settled(Some((1000, 600))),
|
||||||
|
"odd pixels floor to even"
|
||||||
|
);
|
||||||
|
assert!(pending.is_none(), "pending consumed");
|
||||||
|
|
||||||
|
// Spacing: a request went out < 1 s ago → wait WITHOUT dropping the pending
|
||||||
|
// stamp, so a later tick retries.
|
||||||
|
let mut pending = Some(t0);
|
||||||
|
assert_eq!(
|
||||||
|
resize_decision(
|
||||||
|
t0 + ms(900),
|
||||||
|
&mut pending,
|
||||||
|
Some(t0),
|
||||||
|
Some((1000, 600)),
|
||||||
|
(1280, 720),
|
||||||
|
(800, 500)
|
||||||
|
),
|
||||||
|
ResizeAction::Wait
|
||||||
|
);
|
||||||
|
assert!(pending.is_some());
|
||||||
|
assert_eq!(
|
||||||
|
resize_decision(
|
||||||
|
t0 + ms(1000),
|
||||||
|
&mut pending,
|
||||||
|
Some(t0),
|
||||||
|
Some((1000, 600)),
|
||||||
|
(1280, 720),
|
||||||
|
(800, 500)
|
||||||
|
),
|
||||||
|
ResizeAction::Settled(Some((800, 500)))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Equal to the streamed mode → settle (persist) but no request.
|
||||||
|
let mut pending = Some(t0);
|
||||||
|
assert_eq!(
|
||||||
|
resize_decision(
|
||||||
|
t0 + ms(400),
|
||||||
|
&mut pending,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
(1280, 720),
|
||||||
|
(1280, 720)
|
||||||
|
),
|
||||||
|
ResizeAction::Settled(None)
|
||||||
|
);
|
||||||
|
|
||||||
|
// A size already requested once (rejected, or rolled back host-side) is never
|
||||||
|
// re-asked — no request → rollback → request loop.
|
||||||
|
let mut pending = Some(t0);
|
||||||
|
assert_eq!(
|
||||||
|
resize_decision(
|
||||||
|
t0 + ms(400),
|
||||||
|
&mut pending,
|
||||||
|
None,
|
||||||
|
Some((1000, 600)),
|
||||||
|
(1280, 720),
|
||||||
|
(1000, 600)
|
||||||
|
),
|
||||||
|
ResizeAction::Settled(None)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tiny windows clamp to the host's floor.
|
||||||
|
let mut pending = Some(t0);
|
||||||
|
assert_eq!(
|
||||||
|
resize_decision(
|
||||||
|
t0 + ms(400),
|
||||||
|
&mut pending,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
(1280, 720),
|
||||||
|
(100, 80)
|
||||||
|
),
|
||||||
|
ResizeAction::Settled(Some((320, 200)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resize_indicator_shows_until_the_target_frame_or_timeout() {
|
||||||
|
let t0 = Instant::now();
|
||||||
|
let ms = Duration::from_millis;
|
||||||
|
|
||||||
|
// Idle at rest.
|
||||||
|
let mut ind = ResizeIndicator::default();
|
||||||
|
assert!(!ind.active());
|
||||||
|
|
||||||
|
// A requested switch shows the overlay immediately.
|
||||||
|
ind.steering(1000, 600, t0);
|
||||||
|
assert!(ind.active());
|
||||||
|
|
||||||
|
// A frame at a DIFFERENT size (a stale old-mode frame still draining) doesn't lift it.
|
||||||
|
ind.decoded(1280, 720);
|
||||||
|
assert!(ind.active(), "an off-target frame keeps the scrim up");
|
||||||
|
|
||||||
|
// The sharp new-resolution frame arrives → cleared.
|
||||||
|
ind.decoded(1000, 600);
|
||||||
|
assert!(!ind.active(), "the target frame lifts the scrim");
|
||||||
|
ind.tick(t0 + ms(10_000)); // a late tick after clearing is inert
|
||||||
|
assert!(!ind.active());
|
||||||
|
|
||||||
|
// A switch whose target frame never arrives (rejected / host-capped) times out.
|
||||||
|
let mut ind = ResizeIndicator::default();
|
||||||
|
ind.steering(1000, 600, t0);
|
||||||
|
ind.tick(t0 + ResizeIndicator::TIMEOUT - ms(1));
|
||||||
|
assert!(ind.active(), "still within the timeout window");
|
||||||
|
ind.tick(t0 + ResizeIndicator::TIMEOUT);
|
||||||
|
assert!(!ind.active(), "timeout lifts a switch that never delivered");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resize_indicator_retargets_and_rearms_the_timeout_mid_drag() {
|
||||||
|
let t0 = Instant::now();
|
||||||
|
let ms = Duration::from_millis;
|
||||||
|
|
||||||
|
// A drag that walks through sizes (each a fresh request) re-arms the timeout, so a
|
||||||
|
// slow gesture never trips it: at t0 steer A, then near-timeout steer B, then a B
|
||||||
|
// frame lands well after A's timeout would have fired.
|
||||||
|
let mut ind = ResizeIndicator::default();
|
||||||
|
ind.steering(1000, 600, t0);
|
||||||
|
let near = t0 + ResizeIndicator::TIMEOUT - ms(1);
|
||||||
|
ind.steering(1200, 700, near); // new target → timeout re-armed from `near`
|
||||||
|
ind.tick(t0 + ResizeIndicator::TIMEOUT + ms(1)); // past A's window, within B's
|
||||||
|
assert!(
|
||||||
|
ind.active(),
|
||||||
|
"retarget re-armed the timeout — no mid-drag flicker"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-steering the SAME size does NOT re-arm (so a repeated identical request can't
|
||||||
|
// hold the scrim open forever).
|
||||||
|
let mut ind = ResizeIndicator::default();
|
||||||
|
ind.steering(1000, 600, t0);
|
||||||
|
ind.steering(1000, 600, t0 + ms(500)); // same target, later — `since` unchanged
|
||||||
|
ind.tick(t0 + ResizeIndicator::TIMEOUT);
|
||||||
|
assert!(
|
||||||
|
!ind.active(),
|
||||||
|
"an unchanged target keeps the original timeout"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn sample() -> (Stats, PresentedWindow) {
|
fn sample() -> (Stats, PresentedWindow) {
|
||||||
(
|
(
|
||||||
Stats {
|
Stats {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user