98 Commits

Author SHA1 Message Date
enricobuehler d0faebf582 chore(release): bump workspace version to 0.9.0
audit / bun-audit (push) Successful in 11s
ci / web (push) Successful in 51s
apple / swift (push) Successful in 1m10s
ci / docs-site (push) Successful in 1m9s
audit / cargo-audit (push) Successful in 2m36s
ci / bench (push) Successful in 5m53s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m8s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m2s
android-screenshots / screenshots (push) Successful in 2m58s
decky / build-publish (push) Successful in 18s
ci / rust (push) Successful in 17m43s
release / apple (push) Successful in 9m55s
apple / screenshots (push) Successful in 5m56s
arch / build-publish (push) Successful in 14m2s
android / android (push) Successful in 15m44s
deb / build-publish (push) Successful in 15m15s
web-screenshots / screenshots (push) Successful in 2m45s
linux-client-screenshots / screenshots (push) Successful in 8m20s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m58s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m57s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 42s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9m30s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11m34s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 13s
flatpak / build-publish (push) Successful in 7m14s
windows-host / package (push) Successful in 8m1s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 54s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m17s
docker / deploy-docs (push) Successful in 14s
Release 0.9.0 — the Windows client grows up, bitrate goes adaptive, and the
console shell arrives.

The headline: Windows now runs the same Vulkan session client as Linux
(session-always), with a single-window handoff from the shell, a shared
settings store, a game library page, and D3D11VA hardware decode slotted into
the chain (vulkan → d3d11va → software) — plus polish like hiding the local
cursor while captured and a Shortcuts help screen. Alongside it, adaptive
bitrate lands end to end: set to Automatic, the client's controller re-targets
the host encoder mid-stream (rebuild-in-place, the fresh encoder opens on an
IDR) instead of riding a fixed rate into congestion. And the console UI grows
into a full gamepad shell — host list, PIN pairing, settings, on-screen
keyboard, screen transitions.

Apple clients gain AV1 decode (hardware-gated advertisement), user-configurable
VRR, Game Mode across platforms, cross-client shortcuts, an opt-in V-Sync, and
a presenter rework; a stream error now returns to windowed like a disconnect
does. Codec selection is real everywhere: clients advertise what their GPU
actually decodes and the host picks accordingly — and AV1 encode on Windows
NVENC actually opens now: the init stamped HEVC HIGH tier and (on 10-bit
sessions) the HEVC Main10 profile GUID onto the AV1 config, so every AV1
session died at the encoder with INVALID_PARAM. AV1 now rides its preset
defaults where they are the only accepted values (Main tier, autoselect
level — 0 means Level 2.0 on AV1, not autoselect), sets its bit depths on
its own config, and a rejected split-encode mode falls back cleanly from
AUTO too. Verified live: 10-bit AV1 macOS → RTX 4090. Android's stats HUD headline
becomes true capture→displayed via OnFrameRendered, custom resolutions land in
both Android UIs (typed W×H in touch settings, kept selectable on the pad —
the Apple pattern), and the 3-finger stats tap actually fires now (the
two-finger scroll centroid read the third finger's landing as a scroll notch
and disqualified the tap). The console shell gates input the instant A starts
a Wake-on-LAN connect — the "Waking…" card shows immediately instead of the
cursor drifting ungated until the stream abruptly appeared. The web console
gets a favicon, Punktfunk-cased branding, a save toast for the auto-saved
display policy, and a stop-session button that only goes red when there is a
session to stop.

Reliability work on the host closes out a long field investigation into
periodic virtual-display stutter: the IDD capture path no longer runs CCD
display queries on the capture thread (a dedicated poller samples them,
measures them, and warns when something is holding the Windows display-config
lock), transient descriptor blips can no longer tear the ring down (last-
known-good + a two-strikes debounce), and a metronomic keyframe-recovery
cycle now names itself in the host log instead of hiding in "nothing in the
logs". The GPU inventory filters out IddCx ghost adapters — every host used
to list its render GPU twice (pf-vdisplay's adapter mirrors the GPU's whole
DXGI identity), with the picker offering a dead twin. Also: per-client display
scaling on GNOME hosts (Mutter virtual monitors), the shared-VkQueue race +
swapchain recreate-in-use fix behind the intermittent device-lost stream
killer, per-family MTU shard sizing (1388 over IPv6 — the v6 blackhole gate),
mDNS discovery pinned to IPv4 on every client, reachability-probed presence
with shareable Decky host management, --no-mdns/--pairing-pin for headless
testing, and an offline-flatpak build fix.

The [workspace.package] version (inherited by every crate via
version.workspace) is the release being cut; the 14 workspace entries in
Cargo.lock and the api/openapi.json info.version are refreshed to match (CI
builds --locked). Canary derives from the tag as minor+1 of the latest stable
(scripts/ci/pf-version.sh), so cutting 0.9.0 advances canary to 0.10.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 23:02:37 +02:00
enricobuehler 968a9c66c1 fix(host): NVENC AV1 sessions actually open — stop stamping HEVC config onto the AV1 union
Every AV1 session died at initialize_encoder with NV_ENC_ERR_INVALID_PARAM
("rejected even at the floor bitrate") while negotiation and the session plan
looked healthy. Live-bisected on the RTX 4090 box against a 10-bit macOS AV1
session; three independent poisons, all HEVC-isms applied across the codec
union:

- Unconditional `hevcConfig.tier = 1`: NVENC's AV1 encoder is Main-tier only,
  and the same write scribbled HEVC offsets into h264Config too. Tier/level
  are now per-codec; AV1 deliberately writes NEITHER — its preset defaults are
  the only accepted values, and `level = 0` is NOT autoselect on AV1 (that's
  Level 2.0 in the AV1 enum — it pins the stream to the smallest level and
  rejects any real mode). Explicit `idrPeriod = INFINITE` is likewise rejected;
  with PTD enabled the driver follows the infinite gopLength, matching the
  proven HEVC pattern of never touching idrPeriod.
- The 10-bit branch was gated on bit depth only, never codec: it stamped
  NV_ENC_HEVC_PROFILE_MAIN10_GUID + hevcConfig bitfields onto AV1 (the killer
  for HDR/10-bit clients). AV1's Main profile covers 10-bit natively; it now
  sets output/input depth on its own config.
- The split-encode fallback only retried FORCED modes, so an AUTO-mode
  rejection masqueraded as a bitrate cap and failed the whole clamp search.
  Any non-disabled mode now retries once with split disabled. (Also corrects
  the split-mode log legend: NV_ENC_SPLIT_DISABLE_MODE is 15, not 0.)

The AV1 RFI DPB write (maxNumRefFramesInDPB = 5) survives the bisect and
stays. Verified end to end: 10-bit AV1, macOS client → RTX 4090 host.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 23:02:37 +02:00
enricobuehler 8743f63406 polish(web): favicon, Punktfunk casing, display-save toast, idle stop button
- Favicon: the brand-mark lens SVG at /favicon.svg, linked from the root head.
- Branding: user-facing "punktfunk" → "Punktfunk" (tab title, app_name,
  pairing prose, the logo aria-labels/SVG titles) — protocol identifiers
  (punktfunk/1, _punktfunk._udp, binary/module names) stay lowercase.
- The display policy auto-saves on every preset pick / field edit with no
  signal; mount @unom/ui's sonner Toaster in the root and fire a success toast
  from the save funnel's onSuccess (new i18n key, en + de). First toast usage
  in the console — errors stay inline as before.
- The stop-session button rendered destructive-red even with nothing to stop:
  variant switches to the muted `secondary` while no session is active.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 22:18:37 +02:00
enricobuehler f470858110 fix(console-ui): gate input the instant A starts a wake — optimistic Waking card
Pressing A on an offline-but-wakeable host only queued ConsoleCmd::Wake; the
service thread round-trips its first WakeStatus ~100 ms–1 s later, and until
then the shell had no modal state — navigation kept flowing, the cursor
drifted (e.g. onto Add Host), the arriving status then froze the UI wherever
it sat, and for a fast wake the "Waking…" card was never rendered before the
stream abruptly appeared. Every other input-initiated async path already gates
synchronously (connect via `connecting`, pair via `busy`, screen pushes via
`Motion`); the wake branch was the unique offender.

`apply` now raises an optimistic WakeStatus placeholder in the same call that
forwards the command — the Waking card shows immediately and the wake modal
gate engages on the very next event. `sync` keeps the placeholder alive until
the service's first real status supersedes it (tracked via `wake_optimistic`),
and B-cancel clears both. Regression test replays the exact A-then-navigate
sequence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 22:18:37 +02:00
enricobuehler 03a2fabff1 feat(clients/android): custom resolutions in both settings UIs
The Settings model always carried arbitrary width/height (0 = native) end to
end — only the two UIs' preset lists gated it. Mirror the Apple client's
pattern: the touch Resolution dropdown gains a "Custom…" row revealing numeric
W×H fields (seeded from the current effective size, coerced even on commit,
capped at 8192 — the host's encode.rs stays the validation authority), and the
gamepad screen surfaces a stored custom size as a leading "Custom · W × H"
choice instead of silently snapping it to Native — a pad can keep a custom
size, it just can't type one. Custom-ness is detected from the stored size
itself (no persisted flag), so it can never disagree with what's saved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 22:18:21 +02:00
enricobuehler 3952672873 fix(clients/android): 3-finger stats tap — re-anchor the scroll centroid on pointer-count changes
The two-or-more-finger scroll path measured its centroid across a VARYING
pointer count: real fingers never land (or lift) in the same input frame, so
the 2→3 transition moved the centroid far past SCROLL_DIV, emitted a phantom
wheel tick to the host, and set `moved` — which disqualified the not-moved tap
classification and made the advertised 3-finger stats-cycle tap unreachable on
real hardware (two-finger right-click survived only because its anchor never
crossed a count change). Re-anchor whenever the finger count changes; genuine
two-finger scrolling is measured between same-count frames exactly as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 22:18:21 +02:00
enricobuehler a1285dfa13 fix(host): IDD capture immune to display-config stalls/flaps + metronomic-recovery self-diagnosis
ci / docs-site (push) Successful in 1m7s
ci / web (push) Successful in 1m8s
decky / build-publish (push) Successful in 32s
android / android (push) Failing after 2m38s
windows-host / package (push) Failing after 11m13s
ci / rust (push) Successful in 22m2s
ci / bench (push) Successful in 5m36s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10m9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12m15s
deb / build-publish (push) Successful in 15m40s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 12m41s
apple / swift (push) Successful in 1m12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 21s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
arch / build-publish (push) Successful in 14m26s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 12m11s
apple / screenshots (push) Successful in 5m30s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12m52s
docker / deploy-docs (push) Failing after 6m14s
Field report (0.8.2→0.8.4): metronomic stutter every ~3.7-4 s, each event a
double-jolt, on ALL clients — and the same box stuttered on Apollo (virtual
display) but not Sunshine (physical). The disturbance class is host-side
display-topology events / display-poller software (SteelSeries-GG class,
Apollo #480/#358/#368) hitting the virtual-display path. Three changes make
the IDD path resilient to it and self-diagnosing:

- DescriptorPoller: the display-descriptor CCD queries (QueryDisplayConfig
  twice per sample) serialize on the session-global display-config lock,
  which topology churn and display-poller software can hold for tens-to-
  hundreds of ms. Polled inline, that stall landed ON the capture/encode
  thread 4x/s — a periodic frame hitch, invisible in any log. A dedicated
  thread now samples every 250 ms and publishes a snapshot; the capture loop
  pays one uncontended mutex read, and a sample slower than 50 ms logs a
  rate-limited warn — the stall is measured instead of streamed.

- Flap immunity: advanced_color_enabled returns Option (None on a failed
  query or the target briefly missing from the active paths during a
  re-probe) and the poller keeps last-known-good per field — a transient no
  longer reads as "HDR off", which used to cost an HDR session TWO spurious
  ring recreates (false, then true again a poll later). On top, a two-strikes
  debounce: only two consecutive samples agreeing on the same new descriptor
  trigger the recreate; a real "Use HDR" flip or game mode-set still lands
  in ~1/2 s.

- RecoveryCadence: served recovery IDRs within 1.5 s coalesce into one event
  (a double-jolt's paired IDRs count once); four consecutive events evenly
  spaced (±20%) warn "client keyframe recoveries are METRONOMIC — a periodic
  host/display disturbance, not random network loss" with the measured
  period (re-warn ≤ every 30 s) — so an affected host self-diagnoses in its
  own log / web console instead of reporting "nothing in the logs". Unit
  tests include the field report's exact double-jolt-pair schedule.

Verified: host tests + clippy clean on Linux and on the RTX box (211/211,
--features nvenc).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:42:58 +02:00
enricobuehler c19c87c435 fix(host): filter IddCx ghost adapters out of the Windows GPU inventory
pf-vdisplay's IddCx adapter enumerates as a perfect DXGI twin of the GPU it
renders on — same Description, VendorId/DeviceId/SubSysId, even
DedicatedVideoMemory; only the LUID differs (verified on the RTX box: the
twins were byte-identical in DXGI). Every host with the driver installed
therefore listed each render GPU twice: the console picker offered a dead
twin (display-only — D3D11 device creation on it fails), and auto-select's
max-VRAM tie between the twins was settled by enumeration order — the real
GPU won by luck, not construction.

The kernel adapter type is the only field that separates the twins, so
enumerate() now queries KMTQAITYPE_ADAPTERTYPE per adapter (raw gdi32 FFI —
the windows crate's Wdk bindings aren't enabled, and one 3-call query
doesn't justify them) and drops indirect-display, display-only, and software
adapters. Fail-open: a failed query keeps the adapter — better a listed twin
than a hidden real GPU. Incidentally also filters other vendors' virtual
displays (Parsec-VDD class) from user machines' pickers.

Existing manual preferences heal via find_preferred's same-model fallback
when occurrence indices shift — a ghost accidentally pinned as -0 now
resolves to the real GPU. The regression test pins the adapter-type words
captured from real hardware; a Windows-gated smoke test runs enumerate()
against the live machine (on the RTX box it actively exercises ghost
exclusion: the inventory went from twin 4090s to exactly one).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:42:42 +02:00
enricobuehler 5d9f1ccb6d feat(host): --no-mdns/PUNKTFUNK_MDNS gate + punktfunk1-host --pairing-pin — docker-testing enablers
ci / docs-site (push) Successful in 54s
ci / web (push) Successful in 57s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
apple / swift (push) Successful in 1m6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
decky / build-publish (push) Successful in 24s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 46s
ci / bench (push) Successful in 6m13s
apple / screenshots (push) Successful in 5m33s
windows-host / package (push) Successful in 9m42s
docker / deploy-docs (push) Successful in 5m59s
android / android (push) Successful in 13m48s
arch / build-publish (push) Successful in 13m59s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 12m52s
deb / build-publish (push) Successful in 15m5s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m7s
ci / rust (push) Successful in 18m35s
mDNS advertisement (native _punktfunk._udp AND GameStream _nvstream) can now
be disabled with --no-mdns on serve/punktfunk1-host or PUNKTFUNK_MDNS=0
(the PUNKTFUNK_ZEROCOPY off-grammar) — for multicast-dead environments
(bridged Docker, CI netns) where the advert reaches nobody or fails outright
and aborts the GameStream plane; clients dial a manually-added host instead
(mDNS-blind host-add works since the 0.8.4 dial-first fix). Unit tests no
longer advertise on the LAN (mdns: false).

punktfunk1-host gains --pairing-pin <PIN> (the already-plumbed fixed-PIN
test seam, now CLI-exposed; empty refused like --mgmt-token) so a CI harness
runs a deterministic SPAKE2 ceremony instead of scraping the logged PIN.

Verified live: --no-mdns + PUNKTFUNK_MDNS=0 skip the advert, --pairing-pin
arms with the fixed PIN, and a punktfunk1-host --source synthetic +
punktfunk-probe loopback run passes verification (60/60 frames, 0 mismatch).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 18:43:15 +02:00
enricobuehler c8fdef8bd1 fix(host): per-client display scaling on GNOME — host-persisted scale for Mutter virtual monitors
ci / web (push) Successful in 53s
decky / build-publish (push) Successful in 12s
apple / swift (push) Successful in 1m7s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
ci / docs-site (push) Successful in 1m24s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
ci / bench (push) Successful in 6m15s
apple / screenshots (push) Successful in 5m29s
windows-host / package (push) Successful in 8m43s
arch / build-publish (push) Successful in 11m29s
android / android (push) Successful in 13m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 12m47s
deb / build-publish (push) Successful in 16m13s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m12s
ci / rust (push) Successful in 22m39s
docker / deploy-docs (push) Successful in 17s
The identity policy promised per-client scaling, but on GNOME it could never
work through the DE: Mutter mints a fresh EDID serial (0x%.6x, a per-shell
counter) for every RecordVirtual monitor and offers no way to pass a stable
identity, so the monitors.xml entry GNOME writes when the user sets a scale
never rematches on reconnect — and our own topology ApplyMonitorsConfig then
clobbered whatever was left back to a hardcoded scale 1.0.

The host now remembers the scale itself (<config>/display-scale.json, keyed by
the identity key — fp / fp@WxH / "shared" per the policy): reapplied at connect
via the RecordVirtual mode's preferred-scale (Mutter pre-validates it; older
Mutter ignores the key) and preserved — not forced — by the topology apply,
with an integral-logical-size snap + retry-at-derived fallback (GetCurrentState
reports no supported-scales for virtual monitors to snap to). The user's
mid-session changes are polled from DisplayConfig every 5 s and written back,
so a host crash loses at most a few seconds; an unconfirmed "Keep Changes"
revert is tracked faithfully too. Mutter also now resolves the identity slot
(set_client_identity/last_identity_slot) like KWin, keying the registry's
group arrangement and /display/state.

Verified live on the GNOME 50 box: set 1.5 mid-stream → persisted under the
client fp; full teardown (fresh serial 0x000018); reconnect → monitor up at
1.5, "reapplying the client's saved display scale" logged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 14:58:45 +02:00
enricobuehler 4c99b78366 fix(flatpak): prune pf-client-core's windows-rs git dep from the sandbox manifest — offline build broken since a69a83b5
apple / swift (push) Successful in 1m8s
apple / screenshots (push) Successful in 5m26s
android / android (push) Successful in 9m51s
arch / build-publish (push) Successful in 9m53s
deb / build-publish (push) Successful in 51s
decky / build-publish (push) Successful in 26s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 34s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 55s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m42s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m34s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m43s
flatpak / build-publish (push) Successful in 6m59s
docker / deploy-docs (push) Successful in 26s
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 1m2s
ci / bench (push) Successful in 5m16s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 12m21s
ci / rust (push) Successful in 16m57s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 12m59s
The flatpak's offline cargo build has failed on every run since the D3D11VA push:
pf-client-core now declares the same git-pinned `windows` dependency as the Windows
client (cfg(windows)-gated, never compiled on Linux), and `cargo --offline` needs
every DECLARED dependency's source just to build the unit graph — but windows-rs is
deliberately not vendored into cargo-sources.json (flatpak-builder would full-clone
the multi-GB repo; the reason prune-windows-lock.py exists). Removing the workspace
member alone no longer covers it.

New packaging/flatpak/prune-windows-toml.py (dependency-free, like its lock sibling)
strips windows-rs git entries from a manifest in place — single- or multi-line —
and the flatpak manifest runs it on crates/pf-client-core/Cargo.toml right after the
existing clients/windows member sed. Registry deps in the same cfg(windows) table
(wasapi, sdl3) are kept; they vendor normally.

Verified in a scratch worktree with both prunes applied: the TOML stays valid and
`cargo metadata` resolves ZERO windows-rs packages and zero git-source declarations
— nothing left for --offline to fetch. End-to-end proof is the flatpak CI run on
this push.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 12:52:26 +02:00
enricobuehler f4c3a5d0c3 fix(clients): shared-VkQueue race + swapchain recreate destroy-in-use — the intermittent device-lost stream killer
apple / swift (push) Successful in 1m7s
ci / web (push) Successful in 1m9s
ci / docs-site (push) Successful in 1m18s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m41s
decky / build-publish (push) Successful in 21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m43s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m11s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m34s
ci / bench (push) Successful in 6m12s
apple / screenshots (push) Successful in 5m18s
docker / deploy-docs (push) Successful in 27s
flatpak / build-publish (push) Failing after 5m25s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m50s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m35s
arch / build-publish (push) Successful in 19m55s
deb / build-publish (push) Successful in 18m59s
ci / rust (push) Successful in 20m9s
android / android (push) Successful in 20m11s
Live-diagnosed on the RTX box during the adaptive-bitrate A/B: 2 of 3 streams died
with VK_ERROR_DEVICE_LOST at stream start or at a mid-stream encoder rebuild, then
zombied — the demote-to-software path rebuilt the decoder against the same dead
device, FFmpeg wedged inside the rebuild, and the pump flushed a never-draining
backlog every 2 s forever. No OS TDR: the client's own Vulkan misuse. Two causes:

1. The presenter creates ONE graphics-family queue and hands FFmpeg's
   AVVulkanDeviceContext the same family (nb_graphics_queues=1 ⇒ queue 0) for its
   transfer/compute prep — so the pump thread and the presenter thread submitted to
   the SAME VkQueue with no shared lock, violating vkQueueSubmit's external-sync
   rule exactly when FFmpeg puts work on the graphics queue (decoder open /
   frames-context rebuild = stream start and every ABR encoder re-target). New
   guard-less QueueLock (FFmpeg's lock_queue/unlock_queue callbacks are a raw pair)
   shared by all four queue users: FFmpeg (callbacks installed via user_opaque),
   the presenter's submit/present/wait-idle, and the Skia overlay's flushes.

2. Swapchain recreation destroyed the old swapchain + render semaphores after ONE
   fence cycle — the fence proves our submit, not the presentation engine's
   semaphore consumption (VUID-vkDestroySemaphore-05149 +
   VUID-vkDestroySwapchainKHR-01282 on every recreate). Recreate now drains the
   queue (vkQueueWaitIdle under the shared lock — safe now that FFmpeg honours it)
   and destroys immediately; the deferred DisplayGarbage machinery is gone.

Resilience: VK_ERROR_DEVICE_LOST anywhere in a present error chain is now fatal —
the run loop fails the session loudly instead of demoting to software on a dead
device (the zombie path).

Verified on the RTX box (RTX 4090 → host .21) under VK_LAYER_KHRONOS_validation:
3/3 stream start/stop cycles clean, then 8 mid-stream encoder rebuilds in one
session (4 ABR down-steps under 10% induced loss + 4 clean-link recovery up-steps
— the exact scenarios that previously killed the device): 0 device losses, 0
wedges, both recreate VUIDs gone (previously fired on every path). Remaining
validation messages are FFmpeg's own video-session VUIDs, untouched by this
change. Linux: clippy -D warnings + tests green (home-worker-2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 12:42:11 +02:00
enricobuehler 4839c0e6f6 fix(ci): windows clippy + rustfmt debt from the D3D11VA push
apple / swift (push) Successful in 1m16s
windows-host / package (push) Successful in 8m34s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m49s
release / apple (push) Successful in 9m12s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m45s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m22s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m49s
apple / screenshots (push) Successful in 5m42s
android / android (push) Successful in 4m53s
arch / build-publish (push) Successful in 7m9s
ci / web (push) Successful in 1m9s
ci / docs-site (push) Successful in 1m12s
ci / rust (push) Successful in 5m28s
deb / build-publish (push) Successful in 4m9s
decky / build-publish (push) Successful in 15s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 6m13s
flatpak / build-publish (push) Failing after 5m1s
docker / deploy-docs (push) Successful in 16s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 12m30s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 12m36s
The windows workflow has been red since a69a83b5: clippy 1.96 rejects the two
field-reassign-with-default view-desc initializers in video_d3d11.rs (now struct
literals), and with clippy failing first, the rustfmt step never ran — cargo fmt
--all had genuine misses queued up in video_d3d11.rs / pf-presenter d3d11.rs +
vk.rs / core abr.rs + client.rs (plus this session's config.rs). Formatting only
beyond the two initializers; no behaviour change.

Verified: clippy -p pf-client-core --all-targets -D warnings clean on the RTX
Windows box, cargo fmt --all --check clean, core lib tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 11:47:06 +02:00
enricobuehler d0f68cbbcd feat(core,host): per-family MTU shard sizing — the IPv6 gating item
apple / swift (push) Successful in 1m10s
windows-host / package (push) Successful in 8m41s
release / apple (push) Successful in 8m47s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m52s
arch / build-publish (push) Successful in 10m51s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m46s
android / android (push) Successful in 12m52s
audit / cargo-audit (push) Successful in 2m3s
audit / bun-audit (push) Successful in 15s
ci / rust (push) Failing after 25s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 1m11s
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m12s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 1m24s
apple / screenshots (push) Successful in 5m37s
ci / bench (push) Successful in 7m12s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 48s
deb / build-publish (push) Successful in 11m48s
flatpak / build-publish (push) Failing after 4m13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 12m48s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m6s
docker / deploy-docs (push) Successful in 29s
Phase 1 of the dual-stack plan (design/client-parity-and-network-resilience.md, plan 5):
the host sizes each session's shard_payload from the QUIC remote's address family instead
of assuming IPv4 — 1408 over v4 (unchanged), 1388 over v6 (40-byte header). Rides the
existing Welcome::shard_payload negotiation, so there is zero wire change and old clients
simply follow.

This has to land before any v6 data path exists: the v4-maximal 1408 makes every sealed
video datagram overshoot a 1500-MTU IPv6 hop, and v6 routers never fragment — that's a
blackhole (every datagram dropped), not the graceful-ish degradation of the b5c30df v4
fragmentation saga. IPv4-mapped v6 remotes (::ffff:a.b.c.d, what a dual-stack [::] socket
reports for a v4 client) correctly keep the v4 size — they ride IPv4 on the wire.

New mtu1500_shard_payload_v6()/mtu1500_shard_payload_for() in core config with the same
pinned never-fragments/maximality tests as the v4 constant, plus a family-selection test.
Verified: 82 core lib tests + loopback/c_abi green and host check/clippy clean on Linux
(home-worker-2); core tests green on macOS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 11:35:28 +02:00
enricobuehler 13d1aa5738 feat(clients/android): OnFrameRendered display stage — HUD headline becomes capture→displayed
The long-deferred Android display stage (design/stats-unification.md; plan 4.1 of
design/client-parity-and-network-resilience.md): AMediaCodec_setOnFrameRenderedCallback
(API 26, under the minSdk-28 floor ⇒ hard-linked via ndk-sys) reports SurfaceFlinger's
per-frame render timestamp, giving the HUD the spec's `display` = decoded→displayed term
and the directly-measured capture→displayed end-to-end headline on both decode loops.
Falls back per spec to the v1 capture→decoded endpoint on any window without render
callbacks (the platform may drop them under load), and to it permanently if registration
is refused.

- The render timestamp arrives on CLOCK_MONOTONIC; it's re-based onto CLOCK_REALTIME
  against monotonic-now at callback time, which also cancels the (batchable) callback
  delivery lag.
- The `ndk` crate exposes neither the callback nor the codec pointer needed to bind it
  raw, so the workspace pins `ndk` 0.9.0 to a vendored copy (clients/android/native/
  vendor/ndk) whose ONLY change makes MediaCodec::as_ptr public — the "as_ptr patch".
  Workspace-excluded so host builds never compile it; drop when upstream exposes either.
- nativeVideoStats grows to 26 doubles (22–25: dispValid, displayP50, e2eDispP50/P95;
  0–21 unchanged for older readers); StatsOverlay moves headline endpoint + equation
  together so the equation always tiles the headline interval.

Verified: host cargo check/test/clippy, aarch64-linux-android check/clippy, Kotlin
app+kit+tests compile, roborazzi HUD render shows the full 4-term equation. Device
verification rides plan 4.2's phone A/B.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 11:35:16 +02:00
enricobuehler f508d3213f polish(clients/windows): name the session exe "Punktfunk Session" in its version info
apple / swift (push) Successful in 1m5s
android / android (push) Successful in 4m23s
audit / cargo-audit (push) Successful in 42s
audit / bun-audit (push) Successful in 19s
arch / build-publish (push) Successful in 5m38s
ci / rust (push) Failing after 44s
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 1m3s
windows-host / package (push) Successful in 8m41s
release / apple (push) Successful in 8m45s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m49s
ci / bench (push) Successful in 4m51s
decky / build-publish (push) Successful in 13s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m44s
deb / build-publish (push) Successful in 5m31s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 34s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 1m16s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m36s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 1m23s
apple / screenshots (push) Successful in 5m44s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m23s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 52s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4m39s
flatpak / build-publish (push) Failing after 3m13s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 11m24s
docker / deploy-docs (push) Successful in 29s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 11m27s
UAC prompts, Task Manager and Properties→Details show FileDescription; without one the exe
appeared as its raw filename.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 10:57:41 +02:00
enricobuehler a69a83b545 feat(clients/windows): D3D11VA hardware decode in the session client — Vulkan chain becomes vulkan → d3d11va → software
The vendor-agnostic DXVA path for GPUs without Vulkan Video (Intel's Windows driver foremost,
which previously landed on CPU decode). Ported from the retired WinUI presenter's decoder with
its Intel-safe discipline intact (decode pool stays libavcodec-derived — a hand-built pool
broke Intel at the first SubmitDecoderBuffers), on a decode device LUID-matched to the
presenter's adapter.

Hand-off is a ring of shareable BGRA8 textures (SHARED_NTHANDLE | SHARED_KEYEDMUTEX) filled by
the fixed-function ID3D11VideoProcessor (NV12/P010 → BGRA8, colour spaces from the per-frame
CICP; PQ is tone-mapped to SDR by the processor — HDR-first boxes take Vulkan Video). BGRA is
deliberate: importing a multiplanar NV12 D3D11 texture device-losts on NVIDIA however it is
consumed (plane-view sampling and DMA copy both validation-clean, both TDR — bisected), while
single-plane RGBA D3D11↔Vulkan interop is the path Chromium/ANGLE exercise on every driver.
The presenter imports a slot's NT handle per frame (VK_KHR_external_memory_win32, gated on the
spec-required external-format probe) and blits it into the video image — no CSC pass; the DXGI
keyed mutex (key 0 both sides, drop-tolerant) is the cross-API lock and visibility barrier.

Verified live vs a real host at 5120x1440@240 HEVC on an RTX 4090: 240 fps, e2e 2.7/3.0 ms
p50/p95 under the Khronos validation layer — parity with Vulkan Video (2.6 ms); auto still
resolves vulkan on NVIDIA. PUNKTFUNK_DECODER=d3d11va forces it; import/present failures demote
to software on the existing streak contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 10:57:41 +02:00
enricobuehler d0fa8bd3ee fix(clients): pin mDNS discovery to IPv4 on every client
Host machines' OS mDNS responders answer AAAA for hostname.local even though the punktfunk
stack binds IPv4 sockets exclusively, so a v6-resolved address rendered a host card whose
connect always failed. Apple's Network.framework prefers IPv6 (RFC 6724) — force the resolve
connection to v4; pf-client-core (Linux/Windows/Deck shells) now picks get_addresses_v4()
instead of an arbitrary first address. Android already did exactly this, for the same reason.
Lift all three when the stack actually speaks IPv6 (per-family shard sizing is the gating
item — the IPv6 header costs 20 bytes of the MTU budget shard_payload is maximal against).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 10:57:41 +02:00
enricobuehler 8e6e8bb25c feat(core,host): adaptive bitrate — mid-stream encoder re-targeting when set to Automatic
New SetBitrate (0x05) / BitrateChanged (0x06) control messages: the host clamps like the
Hello request, acks the resolved rate, and rebuilds the ENCODER ONLY in place (same mode,
first new-rate frame is an IDR — the proven mode-switch resync, minus the pipeline churn).
The client side is an AIMD controller (core abr.rs) in the data-plane pump, armed only when
the user's bitrate is Automatic (Hello bitrate_kbps == 0): ×0.7 after two bad 750 ms windows
(FEC-unrecoverable frames, ≥2% loss, one-way-delay rise above its rolling baseline — the
pre-loss bufferbloat signal off the clock-skew handshake — or a jump-to-live flush), ~+6%
after ~10 s clean, ceiling = the session's starting rate, 3 s cooldown, self-disables against
a host that never acks (older build). Division of labour: adaptive FEC keeps answering fast
random loss; bitrate now answers persistent congestion, closing the FEC death-spiral gap.
The web-console sample reports the live rate. Also: join_host_port() brackets bare IPv6
literals before SocketAddr parsing (parse-side IPv6 groundwork, pairs with the next commit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 10:57:41 +02:00
enricobuehler 180ac3aa61 feat(console): full gamepad shell — hosts, PIN pairing, settings, OSK, screen transitions
apple / swift (push) Successful in 1m6s
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
audit / bun-audit (push) Has been cancelled
audit / cargo-audit (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
release / apple (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
windows-host / package (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
decky / build-publish (push) Successful in 25s
flatpak / build-publish (push) Successful in 4m31s
The Skia console UI grows from the single library coverflow into a complete
couch shell (Apple gamepad-UI parity), so a Deck/gamescope session is
self-sufficient end to end:

- Home: host carousel (saved + discovered + Add Host tile) with presence
  pips, paired locks, wake & connect; A/Y/X/B per the Apple launcher.
- In-console SPAKE2 PIN pairing (previously needed the Decky plugin or a
  desktop), add-host with a controller keyboard, couch settings over the
  shared Settings store, wake-on-LAN overlay with retry, cancelable dials.
- Shell chrome: screen stack with push/pop entrance/exit choreography
  (slide + fade + recede; aurora↔form backdrop crossfade), per-pad button
  glyphs (PS shapes vs ABXY, keycaps with no pad), menu haptics, toasts,
  embedded Geist typography, in-stream start banner.
- Steam Deck: our OSK never draws — fields start SDL text input (the new
  Overlay::text_input_active hook) and Steam's keyboard types; a hint chip
  points at STEAM + X. Other Linux gets the full gamepad keyboard tray.
- punktfunk-session: bare --browse opens Home; --browse host opens that
  host's library with Home behind it (Decky launches keep working, B now
  pops instead of quitting). Service threads run discovery, 10 s probes,
  pairing, wake loops, fetches, and known-hosts persistence.
- Presenter contract: Launch actions carry the host, CancelConnect never
  engages a won race, pad kind/list ride FrameCtx, menu events flow while
  dialing so B can cancel.

Every screen renders to PNG on CPU raster for review
(PF_CONSOLE_DUMP=<dir> cargo test -p pf-console-ui -- --ignored dump).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 08:46:49 +02:00
enricobuehler c3fa6c1514 feat(clients/apple): AV1 decode support — OBU plumbing, hardware-gated advertisement
apple / swift (push) Successful in 1m15s
release / apple (push) Successful in 15m35s
apple / screenshots (push) Successful in 4m35s
android / android (push) Successful in 4m17s
arch / build-publish (push) Successful in 5m51s
ci / web (push) Successful in 1m5s
ci / docs-site (push) Successful in 1m22s
ci / rust (push) Successful in 5m14s
deb / build-publish (push) Successful in 3m31s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
ci / bench (push) Successful in 5m0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 11m12s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 11m20s
docker / deploy-docs (push) Successful in 6s
The Apple client was HEVC/H.264-only: the receive path spoke Annex-B NALs
exclusively, so AV1 was never advertised and the codec picker hid it. Add
the OBU flavor of the same plumbing (AV1.swift, sibling of AnnexB.swift):
a zero-copy OBU walker, a full spec-5.5.1 sequence-header parser, an av1C
CMVideoFormatDescription with colorimetry extensions (so isHDRFormat and
the presenter stay codec-agnostic), and an ISOBMFF 'av01' sample repack
(temporal delimiter stripped, everything size-fielded, one copy per AU).

VideoCodec gains .av1 (wire 0x04); both pumps and VideoDecoder route
through dispatching formatDescription(fromKeyframe:)/sampleBuffer(au:) —
keyframe gating keys on the in-band sequence header exactly as the NAL
codecs key on in-band parameter sets, so loss recovery and mid-session
reconfigure work unchanged. AV1 sessions require a hardware decoder
(VideoToolbox has no software AV1; same fail-fast policy as 4:4:4), and
both the Hello advertisement and the Settings picker are gated on
VTIsHardwareDecodeSupported — AV1 only appears on devices that can
actually decode it (M3-class Macs, A17 Pro-class iPhones; no Apple TV).

Tests: real SVT-AV1 blobs (generation recipe in the file) cover the walk,
the parse against an independent reference, av1C bytes, delta-TU gating,
repack byte-exactness, and — on AV1 hardware — a real
VTDecompressionSession decode through VideoDecoder. Host precedence stays
HEVC > AV1 > H.264, so AV1 engages only when explicitly picked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 01:22:56 +02:00
enricobuehler bf9be59f0b feat(clients/windows): rename the Help button to Shortcuts
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m39s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m37s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m15s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m30s
apple / swift (push) Successful in 1m12s
apple / screenshots (push) Successful in 5m48s
android / android (push) Successful in 4m30s
arch / build-publish (push) Successful in 5m47s
ci / web (push) Successful in 1m0s
ci / docs-site (push) Successful in 1m19s
ci / rust (push) Successful in 5m10s
deb / build-publish (push) Successful in 3m30s
ci / bench (push) Successful in 5m5s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
decky / build-publish (push) Successful in 13s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 11m25s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 11m33s
docker / deploy-docs (push) Successful in 12s
"Shortcuts" says what the page actually is — the in-stream keyboard/controller
reference — and the keyboard glyph reads better than the generic help icon
(user feedback from the live-test round). Page title follows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 01:07:03 +02:00
enricobuehler a3332aedae fix(clients/apple): return to windowed after a stream error, not just an active disconnect
apple / swift (push) Successful in 1m12s
release / apple (push) Successful in 8m48s
apple / screenshots (push) Successful in 5m48s
android / android (push) Successful in 4m6s
arch / build-publish (push) Successful in 7m3s
ci / rust (push) Successful in 7m1s
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 1m0s
deb / build-publish (push) Successful in 3m40s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
ci / bench (push) Successful in 5m4s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 11m5s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m51s
docker / deploy-docs (push) Failing after 18s
A session error tears down the same as a deliberate disconnect (connection
→ nil flips FullscreenController's `active` off), but ALSO sets
`errorMessage`, raising the "Connection failed" alert. On macOS that alert
is a window sheet, and AppKit drops `-toggleFullScreen:` while a sheet is
attached — so the exit was swallowed and the window stayed fullscreen on
the home screen (an active disconnect sets no error, so it never stuck).

Defer the alert while a forced-fullscreen exit is pending: no sheet
attaches during the exit, the window returns to windowed, then the alert
presents over it. Not gated when fullscreen is the user's own manual
choice (opt-out setting), where nothing is auto-exiting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 00:47:15 +02:00
enricobuehler 069fcdfca6 feat(clients/apple): user-configurable Allow VRR (macOS + iOS)
Add an "Allow VRR" toggle (Settings → Display; default on, non-tvOS) that
hands the presenter's display link a wide frame-rate RANGE — preferred =
the stream rate — so a ProMotion / adaptive-sync display can vary its
physical refresh to match the stream. `SessionPresenter.syncFrameRate`
now runs on macOS too: on it requests min 24 / max display / preferred
stream Hz; off restores the prior behavior (macOS free-runs at the native
rate, iOS keeps its 30 Hz floor). A no-op on fixed-refresh displays.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 00:47:15 +02:00
enricobuehler f58d91ba19 polish(clients/apple): tighten settings copy, fix stale ⌃⌥⇧S shortcut
The Statistics footer still advertised the old ⌘⇧S toggle after the
cross-client shortcut migration — corrected to ⌃⌥⇧S (StreamCommands).
Split the Audio footer into a platform-aware string (the "System default
follows device changes" note only applies to macOS, where the device
pickers live). Trim the overloaded Video-quality, Controllers, gamepad-UI,
touch/pointer, presenter and library footers to the load-bearing facts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 00:47:15 +02:00
enricobuehler f4850625bd feat(clients/windows): game library page + session window placement + help polish
apple / swift (push) Successful in 1m10s
android / android (push) Successful in 4m56s
arch / build-publish (push) Successful in 5m56s
audit / bun-audit (push) Successful in 13s
audit / cargo-audit (push) Successful in 1m34s
ci / web (push) Successful in 1m11s
windows-host / package (push) Successful in 7m57s
ci / docs-site (push) Successful in 1m27s
release / apple (push) Successful in 8m39s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m26s
ci / bench (push) Successful in 5m0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m16s
apple / screenshots (push) Successful in 5m35s
ci / rust (push) Successful in 10m32s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m47s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m36s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 6m38s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m39s
flatpak / build-publish (push) Successful in 5m32s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 11m0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m33s
docker / deploy-docs (push) Successful in 18s
UX polish batch 2 (plan workstreams D/E5/E6):

- D: a mouse/keyboard game library page over pf-client-core::library (the GTK
  ui_library.rs counterpart): responsive 2-6 column poster grid (2:3 portraits,
  store badge, monogram placeholder while art streams in), loading / error+retry
  / empty / grid states, tap-to-play via a normal spawn carrying --launch id.
  Poster bytes land in a disk cache (%LOCALAPPDATA%\punktfunk\art-cache) so the
  Image widget loads file:/// URIs (reactor has no from-bytes source) and
  revisits render instantly. Reached from a paired host's "Browse library..."
  menu item behind the new "Show game library (experimental)" Settings toggle
  (the shared library_enabled field, Apple/GTK parity). A Shared::library_gen
  generation guard keeps a superseded fetch from publishing (speed-test pattern).

- E5: the session window opens at the shell's own top-left (--window-pos, new
  SessionOpts::window_pos) instead of centered on the primary display - streams
  land on the monitor the shell is on, and the hide/restore handoff reads as one
  window changing content. Fullscreen follows that display.

- E6: the Help shortcuts add Alt+Enter and the controller escape chord
  (LB+RB+Start+Back, hold to disconnect).

Also: rustfmt normalization of the merged probe helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 00:43:46 +02:00
enricobuehler f5f186b691 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	clients/windows/src/app/mod.rs
2026-07-09 00:28:38 +02:00
enricobuehler 573b2af334 feat(clients/windows): single-window handoff, shared settings store, console-UI surfacing
UX polish batch 1 (clients/windows/ui-polish-plan.md, workstreams W0/A1/B/C/E):

- W0: the shell's duplicated trust/settings structs are gone — src/trust.rs
  re-exports pf-client-core's, so the shell and the spawned session binary share
  ONE Settings shape over client-windows-settings.json. Fixes real bugs: the
  shell's saves no longer drop session-side fields; the stats-overlay toggle
  (show_hud -> show_stats, serde alias migrates old files) and the forwarded-
  controller pick now actually reach the spawned session. The "Streaming engine"
  Settings combo is gone (PUNKTFUNK_BUILTIN_STREAM=1 stays as the dev A/B knob).

- Forwarded-controller pinning by stable key (vid:pid:name, pf-client-core's
  format): persisted as forward_pad, applied by the shell's own service at
  startup AND by the session binary in session_params (both OSes — the session
  never applied the pin before).

- A1 single window: the shell hides itself when the spawned session window
  presents its first frame ({"ready":true}) and restores + foregrounds on the
  child's exit — exactly one visible Punktfunk window at any time. Restore runs
  before the request-access cancel gate so a Ready/Cancel race can't strand the
  shell hidden.

- C console UI: punktfunk-session --browse gains --json-status (ready when the
  library window presents; error line on a failed start), and the shell
  surfaces it — "Open console UI" on paired hosts' menus, plus a controller-
  detected hint card targeting the most recent paired host (x64 only; aarch64
  ships no skia ui feature). Browse spawns hide/restore the shell like streams.

- B responsive: minimum window size (420x360); the hosts header collapses to
  icon-only buttons below 700 px; the session status card shrinks instead of
  clipping (max_width); busy pages, help actions, licenses and long labels wrap.

- E polish: session exe gets the app icon (winresource + WM_SETICON via the new
  pf-presenter win32.rs); both exes share an explicit AppUserModelID on
  unpackaged runs (packaged identity wins) so the windows group as one taskbar
  app across the visibility handoff; "Start streams fullscreen" toggle passes
  --fullscreen (GTK parity); connects stamp KnownHost::last_used; the connect
  target is stashed for every route so "Connecting to X" always names the host.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 00:25:02 +02:00
enricobuehler 01428ced58 feat(clients): reachability-probed presence + shareable Decky host management
apple / swift (push) Failing after 27s
release / apple (push) Failing after 26s
apple / screenshots (push) Has been skipped
windows-host / package (push) Has been cancelled
arch / build-publish (push) Has been cancelled
android / android (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
Add a bounded, trust-agnostic, mDNS-INDEPENDENT QUIC reachability probe and
surface it everywhere saved-host presence is shown, so a host reached over a
routed network (Tailscale/VPN/multicast-filtering LAN) no longer reads Offline
just because it isn't advertising — the display-side companion to the 0.8.4
dial-first connect fix.

Core:
- punktfunk-core: NativeClient::probe (bounded handshake; a real host answers even
  on trust mismatch, a wrong/closed/TCP-only port fails) + punktfunk_probe C ABI
  (ABI_VERSION 3->4, header regenerated).
- pf-client-core: trust::probe_reachable_many (parallel per-host sweep).

Presence pips now read `advertising OR probed-reachable`, refreshed by a ~10-12s
background sweep off the UI thread:
- Linux (relm4): ui_hosts probed map + HostsMsg::Probed sweep.
- Windows (windows-reactor): pf-probe worker -> HostsProps.probed.
- Apple (SwiftUI): HostStore.refreshReachability, driven by HomeView + GamepadHomeView .task.
- Android (Compose): nativeProbe JNI seam + periodic LaunchedEffect (LNP-gated),
  online dot added to the touch HostCard.
- Decky already probes via --list-hosts --probe.

Decky client: make the flatpak client's known-hosts store the single source of
truth via new headless CLI modes (--list-hosts / --add-host / --set-host /
--forget-host / --reset / --reachable). The plugin can now add a host by address,
edit/forget hosts, reset all state (keeping the client identity), and shows
probe-backed online pips — state is shared with the desktop client, not duplicated.

Also lands in-progress Android 17 LNP groundwork (targetSdk 37 +
ACCESS_LOCAL_NETWORK runtime flow, permission dialogs) that was already present in
the working tree.

Verified: cargo check + clippy clean (punktfunk-core, pf-client-core, linux,
android native); android assembleDebug BUILD SUCCESSFUL; decky typecheck + rollup
build clean; probe true/false-positive behaviour exercised against a live host.
Windows and Apple were not compiled locally (no MSVC/Xcode on this Linux box).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 00:14:56 +02:00
enricobuehler 6198da3daf feat(clients/apple): cross-client shortcuts + start banner, opt-in V-Sync, presenter rework
apple / swift (push) Failing after 28s
release / apple (push) Failing after 22s
apple / screenshots (push) Has been skipped
android / android (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
Align the macOS/iPad Stream menu on the cross-client Ctrl+Alt+Shift set
the Windows and Linux clients reserve — Release Mouse (⌃⌥⇧Q), Disconnect
(⌃⌥⇧D), HUD toggle (⌃⌥⇧S) — with ⌘⎋ kept as the macOS/iPad capture
toggle, and surface them on a 6-second banner at stream start.

Add an opt-in V-Sync present mode (punktfunk.vsync, default OFF =
lowest-latency immediate present; PUNKTFUNK_PRESENT_MODE overrides for
A/B), with the presenter reworked to a frame-arrival-triggered render
thread across Stage2Pipeline / MetalVideoPresenter / SessionPresenter,
plus the windowed title-bar safe-area handling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 00:12:02 +02:00
enricobuehler c511462536 feat(clients/apple): enable Game Mode across Apple platforms
Opt macOS and iOS into system Game Mode (GCSupportsGameMode=YES) and
unify the App Store category to Games across macOS/iOS/tvOS (tvOS omits
the key — no Game Mode there). Game Mode engages automatically when the
stream is native-fullscreen (already the default), giving GPU/CPU
priority and doubling controller/AirPods Bluetooth polling for lower
input/audio latency — parity with the Android client's appCategory=game.

Verified: macOS/iOS/tvOS Debug builds succeed and the produced bundle
Info.plists carry the expected keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 00:11:54 +02:00
enricobuehler d6647b9183 feat(clients/windows): port the Vulkan session client to Windows — session-always
The punktfunk-session Vulkan client (clients/linux-session, now clients/session)
builds and runs on Windows; the WinUI shell spawns it for every stream. Verified
live: 10-bit HEVC via Vulkan Video on both AMD (iGPU) and NVIDIA, 5120x1440 at
130 fps / 8 ms end-to-end on the RTX 4090.

- pf-ffvk: Windows bindgen branch (FFMPEG_DIR + PF_FFVK_VULKAN_INCLUDE, no
  pkg-config); provisioning fetches Vulkan-Headers (pinned v1.4.309).
- pf-client-core: builds on Windows — WASAPI audio (audio_wasapi.rs, cfg-swapped
  via #[path], same surface as the PipeWire twin), VAAPI/dmabuf gated inline
  (chain = vulkan -> software), trust reads the WinUI shell's %APPDATA% stores
  (parity tests pin both serialized shapes), Settings gains adapter/hdr_enabled
  (serde-defaulted; Linux stores unaffected).
- pf-presenter: builds on Windows — dmabuf module Linux-gated; SDL keyboard grab
  while captured (Alt+Tab/Win reach the host); pick_device ranks discrete over
  integrated (device 0 was the iGPU on hybrid boxes — the silent footgun) and
  honors PUNKTFUNK_VK_ADAPTER (the Settings GPU pick, exported by the session).
- run loop: block in one SDL wait woken by input AND decoded frames (a per-
  session forwarder pushes a FrameWake user event) instead of a 1 ms poll —
  measured 111%% -> 5%% of a core (NVIDIA), 86%% -> 3.5%% (AMD), stats unchanged.
  The pump's decode-fence wait became once-per-window sampling (no per-frame
  pipeline stall; the stat now shows true backlog).
- pf-console-ui: builds on Windows (skia-safe msvc prebuilts); font lookup falls
  through fontconfig aliases to concrete DirectWrite families (Consolas/Segoe UI)
  — browse/coverflow works, verified against a live host.
- WinUI shell: session-always via new src/spawn.rs (GTK spawn.rs port —
  CREATE_NO_WINDOW, stdout contract, kill handle); the Stream screen is a status
  card (chips + stage lines from the child's stats). The legacy in-process
  D3D11VA path stays behind Settings "Streaming engine" / PUNKTFUNK_BUILTIN_
  STREAM=1 as the A/B baseline until Phase 8 deletes it. SessionParams.video_caps
  makes the HDR toggle real.
- clients/linux-session renamed to clients/session (builds for both OSes).
- CI/MSIX: both workflows build/test both bins with widened path filters; the
  MSIX ships punktfunk-session.exe. ARM64 session builds --no-default-features
  (rust-skia has no aarch64-pc-windows-msvc prebuilts; flip when it does).

A/B on this box (5120x1440 HEVC vs home-worker-5): NVIDIA Vulkan 130 fps / 8 ms
e2e / 1.6 ms decode — clearly better than the built-in path. The AMD iGPU VCN
saturates at ~52 fps where its own D3D11VA does ~70 — Adrenalin Vulkan decode is
slower on APU silicon; discrete RDNA validation gates Phase 8.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 23:21:36 +02:00
enricobuehler 2003d8a75c style(host): rustfmt the bit_depth ternary in serve_session
apple / swift (push) Successful in 1m13s
apple / screenshots (push) Successful in 3m26s
android / android (push) Successful in 4m58s
arch / build-publish (push) Successful in 5m53s
ci / web (push) Successful in 1m4s
windows-host / package (push) Successful in 7m46s
ci / docs-site (push) Successful in 1m22s
ci / rust (push) Successful in 4m59s
ci / bench (push) Successful in 5m7s
decky / build-publish (push) Successful in 25s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
deb / build-publish (push) Successful in 4m41s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m18s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 10m57s
docker / deploy-docs (push) Successful in 20s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m24s
Collapse the manually-wrapped if/else to rustfmt's canonical form; fixes
the cargo fmt --all --check CI failure at punktfunk1.rs:895.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 22:07:54 +02:00
enricobuehler 838a1239cf feat(codec): make client codec selection real — GPU-aware native advertisement
apple / swift (push) Successful in 1m7s
release / apple (push) Successful in 8m27s
windows-host / package (push) Successful in 7m59s
apple / screenshots (push) Failing after 2m38s
android / android (push) Successful in 4m44s
ci / rust (push) Failing after 46s
arch / build-publish (push) Successful in 5m32s
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 1m2s
deb / build-publish (push) Successful in 4m44s
ci / bench (push) Successful in 5m7s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
decky / build-publish (push) Successful in 13s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 11m9s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 11m14s
docker / deploy-docs (push) Successful in 13s
The clients' codec pickers sent a preference the host threw away:
host_wire_caps() hardcoded HEVC for every GPU backend, so the native
path never emitted H.264/AV1 regardless of what the user chose.

Host: advertise what the backend actually encodes, mirroring the
GameStream serverinfo logic — software openh264 emits H.264; probed
backends (Linux VAAPI, Windows AMF/QSV) advertise their per-GPU
CodecSupport via the new wire_mask(); NVENC keeps the
Moonlight-validated H.264|HEVC|AV1 static superset; an empty probe
falls back to the superset so auto clients still resolve HEVC. Gate
10-bit to HEVC (like the 4:4:4 gate) now that a client can steer the
codec — Main10 is the only 10-bit encode path. Fix the web-console
stats label hardcoded to "hevc" (new Codec::label(), shared with the
GameStream register_session mapping).

Android: replace the hardcoded H264|HEVC Hello advertisement with a
videoCodecs param fed by VideoDecoders.decodableCodecBits() (AV1 bit
only when a real hardware video/av01 decoder exists — the decode loop
was already mime-driven); offer AV1 in the codec picker on capable
devices.

Decky: add the missing "Video codec" dropdown (auto/hevc/h264/av1) to
the plugin settings, round-tripping the same codec key the flatpak
client reads.

Apple: unchanged by design (AnnexB.swift is NAL-only, AV1 is never
advertised); refresh the stale "hosts don't emit AV1 on the native
path yet" comments here and host-side.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 16:45:37 +02:00
enricobuehler 0290bf7285 fix(clients/windows): hide the local cursor while captured
apple / swift (push) Successful in 1m11s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m42s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m25s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 58s
android / android (push) Successful in 4m21s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m4s
arch / build-publish (push) Successful in 5m57s
ci / web (push) Successful in 1m14s
ci / docs-site (push) Successful in 1m19s
ci / rust (push) Successful in 5m4s
ci / bench (push) Successful in 5m4s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
deb / build-publish (push) Successful in 4m45s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
apple / screenshots (push) Successful in 6m26s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 11m29s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 11m32s
docker / deploy-docs (push) Failing after 18s
set_locked() hid the OS cursor with a one-shot ShowCursor(false), but the
WinUI content island answers WM_SETCURSOR and re-asserts the arrow on every
pointer move, so the cursor reappeared the instant the mouse moved while the
pointer lock was engaged. A low-level mouse hook never sees WM_SETCURSOR, so
it couldn't suppress it.

Subclass the WinUI window and all its descendants (the video is a composition
SwapChainPanel, so the pointer actually sits over WinUI's internal input-site
child, which is the window that receives WM_SETCURSOR) and, while the lock
wants the cursor hidden, answer WM_SETCURSOR ourselves with SetCursor(None)
and return TRUE — halting WinUI's arrow re-assertion. ShowCursor(false) stays
as the coarse first-frame hide. The subclass is removed on teardown so the
reused app window (host list) is left pristine.

Adds the Win32_UI_Shell feature for the subclassing APIs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 16:45:20 +02:00
enricobuehler e28160ebb3 chore(release): bump workspace version to 0.8.4
apple / swift (push) Successful in 1m12s
audit / cargo-audit (push) Successful in 27s
audit / bun-audit (push) Successful in 15s
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 1m16s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 49s
ci / bench (push) Successful in 4m45s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 55s
apple / screenshots (push) Successful in 5m55s
ci / rust (push) Successful in 11m23s
android-screenshots / screenshots (push) Successful in 2m37s
windows-host / package (push) Successful in 7m37s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m15s
release / apple (push) Successful in 9m2s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m12s
android / android (push) Successful in 10m53s
arch / build-publish (push) Successful in 9m4s
decky / build-publish (push) Successful in 26s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m26s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
flatpak / build-publish (push) Successful in 4m42s
deb / build-publish (push) Successful in 10m29s
linux-client-screenshots / screenshots (push) Successful in 6m36s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 10m41s
web-screenshots / screenshots (push) Successful in 2m44s
docker / deploy-docs (push) Successful in 20s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m2s
Release 0.8.4 — reconnect reliability and streaming polish over 0.8.3.

The headline fix: a reconnect to a saved host that had stopped advertising on
mDNS (but still had a learned Wake-on-LAN MAC) was routed into a wait-for-mDNS
wake flow instead of being dialed — so a host reached over a routed network
(Tailscale/VPN/another subnet) could never be reconnected, reading as "Offline"
with no connection attempt in the host log. All four full clients now dial
first and only fall into the visible "Waking…" wait when the dial actually
fails. On the host side, the same investigation surfaced Windows-only gaps: a
deliberate quit (⌘D) now tears the pf-vdisplay virtual monitor down immediately
(no 10 s linger churning the reconnect's REMOVE→ADD), while keep_alive=forever
(the gaming-rig preset) correctly outranks the quit on both platforms so a
pinned screen stays alive. Silent deaths are no longer invisible: a panic hook
tees panics into the web console's Logs, a Windows SEH filter names the faulting
module on a native GPU-driver crash, and a watchdog flags a wedged monitor
teardown.

Also in this release: the presenter switches to MAILBOX present, pushes HDR10
mastering metadata, and tags HDR honestly; DualSense rumble accepts the v2
vibration valid-flag and bounds a stale buzz; the core caps the QUIC datagram
send buffer so audio/rumble go latest-wins; the host folds a Steam Deck to a
DualSense on Windows (split-pad touch + pad clicks) and applies input motion on
arrival rather than after the 4 ms tick; Android gains a tiered stats HUD with
windowed lost/skipped/FEC counters; and Windows surfaces a Help/shortcuts
screen and exits fullscreen when the stream stops.

The [workspace.package] version (inherited by every crate via version.workspace)
is the release being cut; the 14 workspace entries in Cargo.lock and the
api/openapi.json info.version are refreshed to match (CI builds --locked).
Canary derives from the tag as minor+1 of the latest stable
(scripts/ci/pf-version.sh), so cutting 0.8.4 keeps canary at 0.9.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:17:01 +02:00
enricobuehler 0b7e4a00ee fix(host): crash visibility + Windows deliberate-quit teardown; pin outranks quit
Two strands from the "Windows host went Offline after reconnect, zero errors in
the logs" investigation.

Crash visibility — the field reports kept arriving with nothing in the Logs
tab because every way the host can die silently was unlogged:
- A panic hook tees each panic (thread, location, backtrace) through `tracing`
  into the in-memory ring + host.log before the default hook runs — a panicking
  thread otherwise only hit stderr, absent from the web console and gone when
  stderr is detached.
- A last-resort Windows SEH filter (windows/crash.rs) logs an unhandled native
  exception with its code, faulting address, and faulting MODULE — the smoking
  gun that separates our bug from a GPU runtime/driver DLL (amfrt64, the UMD,
  d3d11) crashing under us.
- A 10 s watchdog around the vdisplay monitor teardown logs an ERROR when the
  driver REMOVE/CCD-restore looks wedged (it runs under the manager state lock,
  so a hang there silently blocks every future acquire).

Deliberate-quit teardown (Windows pf-vdisplay) — the Windows manager never
wired the linger skip the Linux registry has: on ⌘D (the QUIT close code) the
monitor lingered 10 s, so a quick reconnect hit the Lingering-preempt's
back-to-back REMOVE→ADD churn. A per-lease quit flag (VirtualDisplay::
set_quit_flag) now tears the monitor down immediately on a deliberate quit, so
the reconnect finds the manager Idle and does a clean fresh ADD.

Pin outranks quit (both platforms) — keep_alive=forever (the gaming-rig preset)
promises "the screen stays alive", so a deliberate quit must skip only the
linger WINDOW, never the pin. Windows release() checks keep_alive_forever()
before the quit; the Linux registry had the inverse bug (force_immediate tore
down even a pinned display) — fixed via effective_linger() with a unit test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:17:01 +02:00
enricobuehler ab6790ef6c fix(clients): dial-first reconnect — don't gate the connect on mDNS presence
A saved host that stopped advertising on mDNS but has a learned Wake-on-LAN
MAC was routed into a wake-and-WAIT-for-mDNS flow instead of being dialed. For
a host reached over a routed network (Tailscale/VPN/another subnet) mDNS never
sees it, so the wait could never succeed and reconnects were impossible — the
host log showed no connection attempt at all. The tile's Offline pip and this
connect gate shared the same LAN-only `advertises`/`liveAdvert` predicate, so a
perfectly reachable host read as unreachable. The first connect worked only
because the MAC hadn't been learned yet (a direct dial); once learned, every
reconnect wedged in the waker.

All four full clients carried this pattern (deliberate mirrors of the Apple
`HostWaker`). Each now DIALS FIRST: fire the magic packet up front
(fire-and-forget — harmless if the host is awake, and a genuinely-asleep box is
booting while the dial times out) and attempt the connection unconditionally.
Only a FAILED dial falls into the visible "Waking…" wait, whose redial carries
no fallback so it can't loop. A fingerprint-mismatch/trust-rejection failure
means the host ANSWERED, so it never takes the wake path.

- apple: SessionModel.connect gains `onUnreachable`; startSession dials first.
- linux (relm4): AppMsg::WakeConnect wakes+dials; a per-request wake_fallback
  is armed and consumed in SessionExited, matched by fingerprint/address.
- windows: initiate_waking + ConnectOpts.wake_on_fail; the worker's Failed arm
  escalates to wake_and_connect only on a non-trust failure.
- android: doConnectDirect gains onFailure; doConnect wakes+dials first.

Decky was already correct (always launches `--connect`; WoL fire-and-forget).
Explicit "Wake host" menu actions are unchanged — waiting on mDNS is right when
the user explicitly asked to wake a box.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:17:01 +02:00
enricobuehler 52856d4b6b chore(web): refresh bun.lock
android / android (push) Successful in 4m34s
arch / build-publish (push) Successful in 5m39s
audit / bun-audit (push) Successful in 12s
audit / cargo-audit (push) Successful in 1m21s
ci / web (push) Successful in 49s
apple / screenshots (push) Successful in 5m49s
apple / swift (push) Successful in 1m12s
windows-host / package (push) Successful in 7m39s
ci / docs-site (push) Successful in 1m14s
ci / rust (push) Successful in 3m31s
ci / bench (push) Successful in 4m54s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
deb / build-publish (push) Successful in 4m0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 34s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 11m17s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 11m12s
docker / deploy-docs (push) Successful in 18s
Dependency bumps within the existing package.json semver ranges; no
manifest changes.
2026-07-08 13:12:01 +02:00
enricobuehler 6bbd2a9ed6 feat(windows): add deploy-all.ps1 wrapper; fix web console health check
deploy-all.ps1 chains deploy-host.ps1 + build-web.ps1 so a full redeploy is
one command; stops before the web step if the host build/start fails.
build-web.ps1's post-restart check was probing http:// while web-run.cmd
serves HTTPS-only, so it always reported a false failure - probe https via
curl.exe (no -SkipCertificateCheck on the Windows PowerShell 5.1 this runs
under) with a short retry loop for the task's cold start instead.
2026-07-08 13:11:59 +02:00
enricobuehler ade4266336 fix(presenter): MAILBOX present; push HDR10 metadata; honest HDR tag
apple / swift (push) Successful in 1m10s
android / android (push) Successful in 4m43s
arch / build-publish (push) Successful in 5m33s
ci / web (push) Successful in 59s
ci / docs-site (push) Successful in 1m9s
windows-host / package (push) Successful in 7m54s
ci / rust (push) Successful in 3m33s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m12s
release / apple (push) Successful in 8m28s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m10s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 45s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 51s
deb / build-publish (push) Successful in 3m59s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
ci / bench (push) Successful in 5m1s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
apple / screenshots (push) Successful in 5m52s
flatpak / build-publish (push) Successful in 4m55s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 10m52s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m50s
Three Deck-test findings in the Vulkan presenter:

MAILBOX over FIFO when the surface offers it (PUNKTFUNK_PRESENT_MODE=
fifo restores the old default): both are tear-free, but an arrival-
paced presenter blocking in FIFO's present queue eats most of a vblank
whenever gamescope holds images for its composite pass or cadence
drifts against refresh — the display stat flipping 1 ms → 11-13 ms.

VK_EXT_hdr_metadata wired up: the host's 0xCE mastering metadata (or
the Windows presenter's generic HDR10 baseline until it arrives) is
pushed via vkSetHdrMetadataEXT whenever an HDR10 swapchain is (re)-
created. Compositors key their HDR-app signaling on this — colorspace
alone leaves gamescope treating the app as SDR (no SteamOS badge).

And the OSD's HDR tag now reports the display path, not the stream: a
PQ stream tone-mapped onto an SDR surface shows HDR→SDR, so the next
test distinguishes "badge missing" from "HDR never engaged".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 12:29:56 +02:00
enricobuehler dc834ea478 fix(rumble): accept the v2 vibration valid-flag; bound stale buzz to 1.5 s
Two hardenings against runaway vibration (one Deck session buzzed on a
~2 s cadence): the DualSense output-report parse now also treats
valid_flag2 COMPATIBLE_VIBRATION2 (data[39] BIT2) as rumble-carrying —
a writer hardcoding the ≥2.24-firmware convention previously had its
rumble INCLUDING stops silently ignored, and a missed stop re-sends
stale nonzero state forever via the host's 500 ms refresh. And the
client's SDL rumble duration drops 5 s → 1.5 s: long enough that a
couple of lost 500 ms refreshes don't gap genuine rumble, short enough
that stale state dies on its own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 12:29:56 +02:00
enricobuehler 4516bd48e1 perf(core): cap the QUIC datagram send buffer — audio/rumble go latest-wins
quinn's default 1 MiB datagram FIFO holds tens of seconds of Opus; on a
congested link the audio/rumble/input planes built a standing delay that
never drained while video (own latest-wins UDP path) stayed live —
observed on the Deck as audio and rumble lagging together by the same
experience-destroying amount. quinn sheds oldest-first at the cap, so a
4 KiB bound makes the plane latest-wins at the source: ~200 ms of stereo
Opus worst case, and sustained congestion costs concealable drops
instead of lag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 12:29:56 +02:00
enricobuehler 4c9c7e606e feat(android): tiered stats HUD + windowed lost/skipped/FEC counters
Stats overlay verbosity tiers (Off/Compact/Normal/Detailed, 3-finger tap
cycles live, old Boolean pref migrated) and the unified-spec line-4
reliability counters: lost/FEC windowed from the connector's cumulative
totals, skipped from the client's own newest-wins drops. Adds the
fec_recovered_shards accessor to NativeClient, mirrored from the
data-plane pump like frames_dropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 12:29:56 +02:00
enricobuehler b12c7d3deb style(windows): satisfy rustfmt on the uninstall() teardown comments
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m7s
apple / swift (push) Successful in 1m18s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m9s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 39s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m0s
android / android (push) Successful in 5m34s
apple / screenshots (push) Successful in 6m8s
arch / build-publish (push) Successful in 6m57s
ci / web (push) Successful in 1m36s
ci / rust (push) Successful in 3m58s
ci / docs-site (push) Successful in 1m4s
deb / build-publish (push) Successful in 3m57s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 5m3s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 11m35s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 11m24s
docker / deploy-docs (push) Successful in 29s
rustfmt 1.9.0 aligns standalone // lines out to the column of a preceding
trailing comment, so the two-line fullscreen note placed right after
`set_captured(...); // ...` failed `cargo fmt --check`. Make the first note a
leading comment so no standalone comment follows a trailing one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 11:24:42 +02:00
enricobuehler 8a4b0c7bae feat(windows): surface shortcuts on a Help screen; exit fullscreen when the stream stops
apple / swift (push) Successful in 1m10s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m13s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m13s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 44s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 41s
android / android (push) Successful in 4m11s
ci / rust (push) Failing after 1m21s
ci / web (push) Successful in 55s
apple / screenshots (push) Successful in 5m52s
ci / docs-site (push) Successful in 1m2s
arch / build-publish (push) Has been cancelled
ci / bench (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
The in-stream keyboard shortcuts were only reachable deep in Settings > Input.
Move them to a dedicated Help screen reached from a Help button on the host
list (mirrors the Licenses screen — a hook-free page routed from root), and drop
the reference card out of Settings so there's one home for it.

Fullscreen (F11) was a window-style toggle that stream stop never undid, so
disconnecting while fullscreen left the GUI borderless-fullscreen. Split
toggle_fullscreen into is_fullscreen/enter_fullscreen/exit_fullscreen sharing a
module-level SAVED_PLACEMENT, and have input::uninstall() call exit_fullscreen()
on teardown. exit_fullscreen is a no-op when nothing was saved, so it's safe on
every stream stop and idempotent with F11 — fullscreen is now streaming-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 11:17:19 +02:00
enricobuehler d490397908 chore: gitignore the local flatpak-builder repo/build dirs
apple / swift (push) Successful in 1m12s
android / android (push) Successful in 4m29s
arch / build-publish (push) Successful in 5m23s
ci / web (push) Successful in 1m0s
ci / docs-site (push) Successful in 1m8s
ci / rust (push) Successful in 3m35s
windows-host / package (push) Successful in 8m53s
release / apple (push) Successful in 8m52s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m15s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m16s
deb / build-publish (push) Successful in 4m1s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
ci / bench (push) Successful in 4m58s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m1s
apple / screenshots (push) Successful in 5m55s
flatpak / build-publish (push) Successful in 4m31s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m48s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 11m4s
docker / deploy-docs (push) Successful in 19s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m18s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 10:51:26 +02:00
enricobuehler a41f679516 perf(host): one input channel — motion applies on arrival, not after the 4 ms tick
Rich input (gyro at the pad's report rate, trackpad contacts) rode a second
mpsc channel that the input thread only drained after its main channel's
4 ms recv timeout — so during pure-gyro aiming (no button/pointer traffic,
the common case) every motion sample ate up to 4 ms of added latency and
quantization before reaching the virtual pad. At the Deck's 250 Hz sensor
cadence that is a full sample period of jitter.

Both planes now share one ClientInput channel: the thread wakes the moment
either arrives and applies rich input immediately. The 4 ms timeout stays
as the feedback-pump tick.

Also adds a debug-level motion-cadence metric (inter-arrival p50/p95/max
per 5 s window) so gyro delivery is measurable end-to-end instead of
'feels floaty'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 10:51:04 +02:00
enricobuehler e6d9454251 feat(host): fold Steam Deck to DualSense on Windows; split-pad touch + pad clicks
A Deck client asking for 'steamdeck' on a Windows host resolved to Xbox360,
whose apply_rich is a no-op — gyro and both trackpads silently discarded.
Windows already ships a full DualSense backend (UMDF driver, touchpad +
motion, wire units 1:1), so pick_gamepad now folds SteamDeck -> DualSense
there.

The three near-identical DualSense-family appliers (Linux UHID, Windows
DualSense, Windows DS4) are hoisted into one shared
dualsense_proto::DsState::apply_rich, with two mapping upgrades for Steam
dual-pad clients everywhere:
 * the Deck's two pads SPLIT the single DualSense touchpad — left pad ->
   contact 0 on the left half, right pad -> contact 1 on the right half —
   mirroring the physical thumb layout and the split-pad zones games and
   Steam Input already use (the left pad was previously dropped outright)
 * TouchpadEx pad clicks now press the touchpad-click button (persisted
   in DsState::touch_click, OR-ed in by both serializers; previously
   dropped by every DualSense-family backend, Linux included)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 10:50:53 +02:00
enricobuehler 9ca672d434 fix(host): trackpad Y rides the wire in screen convention, flips at the Deck boundary
Both Deck trackpads were inverted on the virtual pad (first live Deck-to-Deck
session): the client sends SDL's screen-convention coordinates (+y down)
onto the wire, but the Deck's raw report fields are stick convention (+y up)
— and Steam Input parses our report as real Deck hardware — while the host
applier passed the value straight through.

Pin the wire meaning down (quic.rs: TouchpadEx/Touchpad are screen
convention, +x right +y down — what every client capture API produces) and
translate to device-raw in the host applier, the layer that knows the
quirk: steam_proto's apply_rich now negates y (saturating: -32768 has no
i16 negation) for both TouchpadEx surfaces and the DualSense-standin
Touchpad arm, which carried the same latent inversion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 10:50:30 +02:00
enricobuehler b8fd652cb8 fix(packaging): autoload vhci-hcd and open its attach files for the usbip Deck pad
Steam Input only adopts the virtual Steam Deck controller when it arrives as
a real USB device (raw_gadget or usbip/vhci transports); the UHID fallback
has no USB interface and Steam ignores it. On a stock host neither
precondition for usbip held: vhci-hcd isn't loaded, and its sysfs
attach/detach files are root-only while the host runs as a user service —
so every session silently fell back to UHID and 'no controller appears on
the host' (live-debugged 2026-07-08: client events all arrived, the pad
existed, Steam just refused it).

Ship both preconditions with the host packages:
 * modules-load.d/punktfunk.conf loads vhci-hcd at boot (rpm/deb/arch;
   Bazzite gets it via the RPM payload + a modprobe in the sysext
   post-merge hook)
 * a udev rule in 60-punktfunk.rules grants the input group write on
   vhci_hcd's attach/detach whenever the device appears

Verified end-to-end on a live host: usbip in-process attach, hid-steam
binds all three interfaces, 'Steam Deck' + motion evdev appear, and Steam
adopts the pad (client-mode grab + its own virtual X360 emission).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 10:50:19 +02:00
enricobuehler 5042ffd935 fix(presenter): order the vk-frame layout transition after the decode semaphore
The submit waits the Vulkan-Video frame's decode-complete timeline semaphore
at FRAGMENT_SHADER, but the acquire barrier ran its layout transition
(VIDEO_DECODE_DST/DPB -> SHADER_READ_ONLY) from srcStage TOP_OF_PIPE — which
the wait does not cover, so no dependency chain formed and the transition
could execute while the decode queue was still writing the image. The race
window is real: the pump ships each frame to the presenter BEFORE its
decode-complete wait (that wait is stats-only).

On RADV the transition physically touches the image (metadata /
decompression), so the race rendered green/yellow block corruption exactly
at freshly-decoded regions — the Steam Deck 'artifacts around the moving
cursor' bug (the cursor is the only damage on an idle desktop). NVIDIA
treats the transition as a no-op, which is why the identical code looked
clean on the dev box.

srcStage is now FRAGMENT_SHADER, matching the semaphore's wait mask — the
standard acquire pattern (same as swapchain acquires) that chains the
transition after the decode signal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 10:50:06 +02:00
enricobuehler 8bef4e27f9 fix(client-linux): opt into RADV Vulkan Video decode at session start
Mesa gates RADV's video-decode support — the VK_KHR_video_decode_* device
extensions AND the decode-capable queue family — behind
RADV_PERFTEST=video_decode (Steam Deck / VANGOGH included). Without it the
presenter's device advertises no decode queue, Decoder::new's auto chain
skips Vulkan Video, and the session silently lands on VAAPI.

punktfunk-session now appends the token to RADV_PERFTEST before the driver
loads (all streams run through this binary, so every launch path and package
is covered). RADV-only knob: other drivers ignore it, and a Mesa where
decode is already default just no-ops. Verified on the Deck: decode_qf=2,
H264/H265/AV1 caps, first frame path="vulkan-video", 90 fps at 1.9 ms decode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 10:49:51 +02:00
enricobuehler b664ae373d fix(screenshots): give PF_SHOT_READY headroom for the cold first scene
apple / swift (push) Successful in 1m14s
android / android (push) Successful in 4m17s
arch / build-publish (push) Successful in 5m37s
ci / web (push) Successful in 1m4s
apple / screenshots (push) Successful in 6m11s
ci / docs-site (push) Successful in 1m5s
ci / rust (push) Successful in 3m45s
deb / build-publish (push) Successful in 4m1s
decky / build-publish (push) Successful in 25s
ci / bench (push) Successful in 5m4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m58s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m42s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 56s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m26s
flatpak / build-publish (push) Successful in 4m23s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 10m39s
docker / deploy-docs (push) Successful in 22s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m2s
The linux-client-screenshots job red-lit on a single scene — the first one
(`hosts`) — with "client never signalled PF_SHOT_READY", while the other six
captured fine (~1-2s each). Root cause: the first client launch pays a large
one-time cold-start under software rendering (llvmpipe GL-shader + fontconfig
cache build, plus first-run client-identity generation into the shared scratch
HOME), ~25s on the CI runner; the relm4 shell rebuilt in 0.8.3 tipped it just
over the 20s PF_SHOT_READY cap. Every scene rendered correctly — the cap was
simply too tight for cold start. Raise it to ~60s (warm scenes still break out
in ~1-2s, so the happy path is unchanged). Best-effort job; it never blocked the
release, but now it goes green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 08:30:58 +02:00
enricobuehler f97b5a3783 fix(rpm): drop the moved design/implementation-plan.md from %doc
apple / swift (push) Successful in 1m11s
apple / screenshots (push) Successful in 5m46s
ci / web (push) Successful in 56s
ci / rust (push) Successful in 3m40s
ci / docs-site (push) Successful in 59s
ci / bench (push) Successful in 4m59s
android-screenshots / screenshots (push) Successful in 49s
android / android (push) Successful in 4m25s
arch / build-publish (push) Successful in 5m47s
decky / build-publish (push) Successful in 15s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
deb / build-publish (push) Successful in 4m3s
flatpak / build-publish (push) Successful in 5m3s
release / apple (push) Successful in 17m20s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 10m47s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m41s
docker / deploy-docs (push) Successful in 17s
web-screenshots / screenshots (push) Successful in 2m38s
windows-host / package (push) Successful in 16m17s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m7s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m58s
linux-client-screenshots / screenshots (push) Successful in 2m7s
The RPM %files %doc referenced design/implementation-plan.md, but the design/
docs live in the separate planning repo now — the file isn't in the public tree,
so rpmbuild failed at %files ("File not found: .../implementation-plan.md") once
the build got that far. Ship the two docs that actually exist (README.md +
packaging/README.md). Pre-existing spec rot, surfaced by the 0.8.3 release run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 00:44:28 +02:00
enricobuehler 0052a6ae30 fix(packaging): vulkan-headers build dep + ship punktfunk-session across desktop packages
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Failing after 13s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 8s
apple / swift (push) Successful in 1m13s
apple / screenshots (push) Successful in 5m57s
android / android (push) Successful in 3m58s
ci / rust (push) Successful in 4m10s
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 1m0s
arch / build-publish (push) Successful in 9m35s
deb / build-publish (push) Successful in 3m59s
decky / build-publish (push) Successful in 15s
ci / bench (push) Successful in 4m51s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m38s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m31s
flatpak / build-publish (push) Successful in 4m24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 9m13s
docker / deploy-docs (push) Successful in 6s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
windows-host / package (push) Successful in 23m50s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m15s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m56s
The re-architecture split the Linux client into two binaries (shell + Vulkan
session streamer) and added the pf-ffvk crate, whose build.rs runs bindgen over
FFmpeg's libavutil/hwcontext_vulkan.h (#include <vulkan/vulkan.h>). Two release
regressions fell out of that on the desktop-package legs:

1. Missing build dep. The Arch build (archlinux:base-devel + its own pacman list)
   fatal-errored at `vulkan/vulkan.h file not found`. Add the Vulkan dev headers
   wherever the client compiles from a self-managed dep set: arch (PKGBUILD
   makedepends + arch.yml), rpm (spec BuildRequires + rpm.yml + fedora image),
   deb.yml (belt-and-suspenders; the rust-ci image already bakes libvulkan-dev),
   and the screenshots job. The flatpak builds offline against the GNOME SDK, so
   install Vulkan-Headers into /app as a pinned module and point pf-ffvk's bindgen
   at it via PF_FFVK_VULKAN_INCLUDE.

2. Only the shell shipped. arch/deb/rpm built and installed just punktfunk-client;
   the shell execs its sibling punktfunk-session for a connect, so desktop
   streaming would break exactly as Decky's did. Build and install BOTH binaries
   in all three, and declare the runtime Vulkan loader the session binary dlopens
   (vulkan-icd-loader / vulkan-loader / libvulkan1 — not a DT_NEEDED, so
   shlibdeps/auto-requires can't see it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 23:41:10 +02:00
enricobuehler 062a54e3a5 style: cargo fmt --all (rustfmt 1.96.0 drift across the re-arch branch)
`cargo fmt --all --check` (ci.yml) was red on main: the client re-architecture
commits and origin's windows-shortcut commit landed with rustfmt violations
(e.g. a 104-char .with_context line in hyprland.rs, an unsorted mod block in
vdisplay.rs, the input.rs `{`-placement CI flagged). Reformat the tree so the
fmt gate passes; no functional changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 23:33:40 +02:00
enricobuehler 966758e757 chore(release): bump workspace version to 0.8.3
apple / swift (push) Successful in 1m11s
audit / cargo-audit (push) Successful in 27s
audit / bun-audit (push) Successful in 17s
ci / web (push) Successful in 53s
ci / docs-site (push) Successful in 1m4s
ci / bench (push) Successful in 5m1s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 55s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 26s
windows-host / package (push) Failing after 9s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 9s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Failing after 8s
release / apple (push) Successful in 9m37s
apple / screenshots (push) Successful in 6m2s
android-screenshots / screenshots (push) Successful in 2m31s
arch / build-publish (push) Failing after 5m15s
deb / build-publish (push) Successful in 4m10s
android / android (push) Successful in 10m24s
decky / build-publish (push) Successful in 23s
linux-client-screenshots / screenshots (push) Successful in 2m17s
flatpak / build-publish (push) Successful in 4m19s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 8m51s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 8m34s
web-screenshots / screenshots (push) Successful in 2m40s
docker / deploy-docs (push) Successful in 20s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
ci / rust (push) Failing after 1m0s
Release 0.8.3: the native Linux client is re-architected — the GTK4 shell + GL
presenter give way to a two-binary design (phases 0–6). A relm4/libadwaita
desktop shell (host discovery, pairing, library) now hands desktop connects to a
dedicated Vulkan session binary, punktfunk-session: an SDL3 window + ash
presenter carrying a Skia "console" UI — stats OSD, capture HUD, and a
gamepad-navigable coverflow library with --browse. The split lands the streaming
wins the GL path couldn't: VAAPI dmabuf → Vulkan zero-copy import + CSC (no GL
round-trip), Vulkan Video decode on the presenter's own device for NVIDIA
hardware decode (via a new pf-ffvk bindgen shim over FFmpeg's Vulkan hwcontext),
and HDR10/P010 end to end. The console background is now an animated mesh gradient
matching the Apple client's MeshGradient. Along the way: a resize crash fixed
(never vkDeviceWaitIdle while the pump decodes), keyframe recovery when the
decoder produces no output, decode-stage stats that measure GPU completion, an
Alt+Enter fullscreen alias, pointer-lock + per-iteration motion-flush input
fixes, and the host-card hover highlight made concentric.

Packaging + Decky: the flatpak now builds and ships BOTH client binaries (shell +
session) so the Deck's stream/browse paths survive the split — the session
binary's Skia is fetched offline from a pinned, sha256-verified archive; the
mgmt/library port 47990 is opened on the LAN firewall profiles; and the plugin's
install-from-URL uses the unom.io/pf-decky short link.

Other clients + core: Windows gains the two missing stream shortcuts
(Ctrl+Alt+Shift+S stats, F11 fullscreen) plus an in-stream shortcut reference on
both clients; Android promotes the low-latency pipeline to default and attaches
its APK to CI; the host coalesces keyframe-request storms into one IDR per
cooldown; the core jumps to live on a standing receive backlog instead of
ratcheting latency; and two security bumps clear the audits (crossbeam-epoch
RUSTSEC-2026-0204, vulnerable web transitive deps).

The [workspace.package] version (inherited by every crate via version.workspace)
is the release being cut; refresh the 14 workspace entries in Cargo.lock to match
(CI builds --locked). Canary derives from the tag as minor+1 of the latest stable
(scripts/ci/pf-version.sh), so with 0.8.3 the newest stable it stays at 0.9.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 23:18:12 +02:00
enricobuehler 07afa8ee26 feat(console-ui): mesh-gradient background (Apple MeshGradient parity)
The console UI's animated background was a port of the Apple client's LEGACY
fallback — four drifting radial blobs. Apple's shipping look is a 4×4
MeshGradient. Replace the blob field with an SkSL shader that reproduces it: a
smooth bicubic (separable cubic-Bézier) blend of the same 16 sRGB mesh colours,
the four interior control points driving a bounded (weight-normalised) domain
warp so the bright brand-violet pools drift while the frame edges stay pinned —
then the same ±8°/~5-min hue sway, elliptical vignette, and vertical legibility
scrim as the Swift composite(). Same [u_res, u_t] uniforms, so the draw path is
untouched beyond the rename (draw_aurora → draw_background, aurora → mesh).

Verified: the SkSL compiles on Skia's CPU frontend (unit test), and CPU-raster
renders at several time points show the dark-cornered field, the centred bright
pool, warm-left/cool-right temperature split, and the slow interior drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 23:14:08 +02:00
enricobuehler b2451e6aea fix(flatpak): ship punktfunk-session so Decky streaming survives the two-binary split
The re-architected Linux client is now two binaries: the punktfunk-client shell
execs its sibling punktfunk-session (ash/Vulkan presenter + Skia console UI) for
--connect/--browse. The Decky plugin's stream and browse paths launch the shell
with exactly those flags, but the flatpak built and installed only the shell, so
streaming and the gamepad library from the Deck failed at exec with
"punktfunk-session: No such file" (pair/wake/library still worked — the shell
handles them in-process).

Build and install both binaries. The session binary pulls in Skia (skia-safe),
whose build script downloads a prebuilt libskia — dead in the offline sandbox —
so point skia-bindings at a pinned, vendored archive via SKIA_BINARIES_URL=file://
(read directly, no curl); the tarball rides along as a sha256-pinned flatpak
source. Widen the flatpak CI path filters to the session binary's crates
(linux-session, pf-presenter, pf-console-ui, pf-client-core) and fix the moved
library.rs path in the Decky error-classifier comment.

All other plugin↔client contracts (flags, pairing/library output, config files,
env vars, exit codes, the 47990 mgmt port) already match — no changes needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 23:14:08 +02:00
enricobuehler 8926d82a80 fix(shell): concentric hover-highlight radius on host cards
The FlowBoxChild draws the hover/selection highlight AROUND the card
(it wraps it with its own padding ring), so its default corner radius
ran visibly tighter than the card's 12px inside it. The host grids now
scope a 15px radius onto their children — card radius + the ring — so
the highlight's corners run concentric with the card's.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 23:14:08 +02:00
enricobuehler 4543a3f529 feat(video): HDR10/P010 end to end (phase 6)
The client now advertises VIDEO_CAP_10BIT|HDR and carries the result all
the way to glass:

- csc_rows is bit-depth exact (10-bit limited code points differ from
  8-bit by ~half a code) and folds in the P010/X6 MSB-packing factor;
  new 10-bit white/black tests.
- The CSC shader grows a params block: mode 0 passes the transfer
  through (SDR as-is, or PQ onto an HDR10 swapchain); mode 1 tonemaps
  PQ→SDR in-shader (ST.2084 EOTF, 203-nit reference white exposure,
  BT.2020→709, soft maxRGB rolloff, sRGB encode) for desktops without
  an HDR surface. PUNKTFUNK_TONEMAP_PEAK tunes the rolloff.
- The presenter probes VK_EXT_swapchain_colorspace + an HDR10/ST.2084
  10-bit surface format and flips modes in-band with the stream's PQ
  signaling: fence-quiesce, then CSC pass + video image (10-bit
  A2B10G10R10 intermediate — PQ in 8 bits bands) + overlay pipe +
  swapchain rebuild through the deferred-destroy rules.
- P010 decodes through all three paths: Vulkan Video (X6 multiplanar
  pool, R10X6 plane views), VAAPI dmabuf (R16/RG1616 plane imports),
  software (swscale as before).
- session pump advertises the caps; the host still gates Main10 behind
  its PUNKTFUNK_10BIT policy.

Probed on glass hardware: the KDE/NVIDIA surface exposes
A2B10G10R10+HDR10_ST2084, so true PQ passthrough is available there.
Known v1 gaps: software-decode PQ shows untonemapped (8-bit RGBA
carries the transfer baked); the SDR overlay composites unscaled onto
an HDR10 surface (dim OSD); no vkSetHdrMetadataEXT yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 23:14:08 +02:00
enricobuehler c299a41a67 fix(presenter): resize crash — never vkDeviceWaitIdle while the pump decodes
vkDeviceWaitIdle's external-sync rule claims EVERY queue on the device;
with Vulkan Video the session pump concurrently submits FFmpeg decode
work to the shared device's video queue, so the resize path's wait-idle
(and the video-image/staging rebuilds') raced it — observed as a crash
on window resize mid-stream (software/VAAPI never touched the device
from the pump, which is why this only appeared now).

Mid-session quiescing is now fence-only ( waits the single
in-flight fence, which covers every command buffer WE submitted), and
the replaced swapchain + its per-image semaphores/overlay targets are
parked in a retire list — the presentation engine may still hold the old
swapchain's final semaphore wait, which no fence covers — and destroyed
after the next present on the successor completes a fence cycle. The one
legitimate device-wide idle left is teardown, and the run loop now JOINS
the pump thread first (SessionHandle carries the JoinHandle; quick — the
pump notices stop within its 20 ms receive timeout).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 23:14:08 +02:00
enricobuehler 349d16382e fix(stats): decode stage measures GPU completion; Alt+Enter fullscreen alias
The Vulkan path's receive_frame returns at SUBMISSION (~0.1 ms) — the
hardware decodes asynchronously, so the decode stat was truthful but
measured the wrong boundary. The pump now ships the frame to the
presenter FIRST, then waits the frame's timeline fence (vkWaitSemaphores
resolved through the shared device's proc chain) and stamps
received→decode-COMPLETE — true NVDEC time at zero pipeline cost, since
the presenter's own GPU wait is what actually gates sampling. Software/
VAAPI keep their synchronous stamps.

Also: Alt+Enter joins F11 as the fullscreen toggle (some keyboards' Fn
layer sends a media key for plain F11 — observed on glass as
'F11 only works with shift'); the shell's shortcuts panel lists both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 23:14:08 +02:00
enricobuehler a5bb5ec4d5 fix(input): pointer lock + the missing per-iteration motion flush
Two capture bugs compounding into 'sluggish and not captured':

1. flush_motion was never called from the run loop — the GTK client's
   frame-clock tick flushed pending motion every frame, and the port
   lost it. Pure mouse movement only reached the host when a click/key/
   scroll happened to flush it; the host cursor simply didn't follow.
   Now one coalesced MouseMove per loop iteration.

2. Capture forwarded ABSOLUTE letterboxed coordinates with no pointer
   confinement (GTK parity, the plan's deferred 'stage-2' item): the
   visible local cursor outran the host cursor by the full e2e and
   escaped the window. Capture is now real pointer lock — SDL relative
   mouse mode (hidden, confined, raw deltas) with relative MouseMove on
   the wire, so the host cursor is the only cursor.

Also: focus-gain re-engages after an auto-release (Alt-Tab undoes
itself; the startup focus race can no longer strand the session
uncaptured) while a chord release stays released until the user opts
back in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 23:14:08 +02:00
enricobuehler 6cb97959a2 docs(video): three-backend reality + log software-by-preference
The silent settings-driven software path cost a debugging round on the
first Vulkan Video glass test (stale decoder=software from the VAAPI-
broken-on-NVIDIA era) — now it says so. Module docs updated: auto is
vulkan → vaapi → software.

Validated on glass (RTX 5070 Ti, HEVC 4K@144): decode 11 ms → 0.1 ms,
e2e p50 ~115 ms → 8.6 ms (the old number was software-decode queueing,
not clock skew), 144 fps locked, 2686 frames, zero errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 23:14:08 +02:00
enricobuehler c78ddc40cb feat(video): Vulkan Video decode on the presenter's device (NVIDIA hw decode)
FFmpeg's Vulkan Video decoder now runs on the PRESENTER's own VkDevice —
the decoded VkImage feeds the existing CICP CSC pass directly: zero
copy, no interop, and NVIDIA gets hardware decode for the first time
(its VAAPI is unusable by design). One decode architecture for every
vendor going forward; VAAPI-dmabuf and software remain the fallbacks
(auto: vulkan → vaapi → software; PUNKTFUNK_DECODER=vulkan pins it).

Presenter: instance 1.3; probes VK_KHR_video_queue/decode_queue + codec
extensions, a VIDEO_DECODE queue family (+ its codec caps via
QueueFamilyVideoPropertiesKHR), and the samplerYcbcrConversion/
timelineSemaphore/synchronization2 features — all enabled at device
creation when present, exported as a VulkanDecodeDevice handle bundle.

Decoder: AVVulkanDeviceContext built over those handles via pf-ffvk
(features chain, extension lists, deprecated queue indices + the qf[]
map); get_format supplies OUR frames context with MUTABLE_FORMAT so the
presenter's per-plane views are legal; output is DecodedImage::VkFrame
carrying AVVkFrame/frames-ctx pointers plus the lock fns.

Present: R8+R8G8 plane views over the multiplanar image, the live sync
state read under the AVVulkanFramesContext lock, a timeline-semaphore
wait(sem_value)/signal(sem_value+1) folded into the submit, layout/
queue-family/sem_value written back per FFmpeg's contract, and the frame
guard parked in the retire queue until the fence. CSC pass + video
framebuffer are now unconditional (NVIDIA has no dmabuf-import path).

Verified on the RTX 5070 Ti: device creates with decode_qf=3,
caps=DECODE_H264|H265|AV1|VP9; swapchain unaffected. Live stream
validation next.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 23:14:08 +02:00
enricobuehler 58ccd530fc feat(ffvk): bindgen shim for FFmpeg's Vulkan hwcontext (pf-ffvk)
ffmpeg-sys-next binds a curated header list that omits every
hwcontext_*.h, so AVVulkanDeviceContext/AVVulkanFramesContext/AVVkFrame
— the surface that lets FFmpeg's Vulkan Video decoder run on the
presenter's own VkDevice and hand the decoded VkImages back — had no
Rust bindings at all. pf-ffvk generates them at build time from the
SYSTEM headers, which is the only ABI-safe option: the struct layout
depends on FF_API_* deprecation gates resolved in libavutil/version.h
(confirmed: FFmpeg 8.1 still carries the FIXED_QUEUES fields). Ships
ash<->vulkan.h handle conversions; opaque AVHW*/AVFrame types keep
ffmpeg-sys-next the owner of the core surface (pointer casts across).
Needs vulkan-headers (Arch) / libvulkan-dev (CI, added);
PF_FFVK_VULKAN_INCLUDE overrides for cross builds. Verified: bindgen
layout tests pass, av_vk_frame_alloc links and zero-initializes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 23:14:08 +02:00
enricobuehler 6dd2213a20 fix(session): keyframe recovery when the decoder produces no output
Under infinite GOP the pump only re-requested an IDR when the
reassembler's drop count climbed. A lost initial IDR (or a mid-GOP join)
delivers complete-but-undecodable delta frames instead — the reassembler
never drops, so recovery never fired and the stream froze on the last
good frame while libavcodec flooded stderr with missing-reference
errors. Reproduced at 4K@144 (large IDRs, higher loss); lower modes hid
it. Now a 3-frame no-output streak (~50 ms) forces a fresh IDR,
throttled and re-armed across the request→IDR round trip. Verified on
glass: 4K@144 recovers and holds. Also quiets libavcodec's raw stderr
(it bypassed tracing) to fatal-only, PUNKTFUNK_FFMPEG_LOG restores it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 23:14:08 +02:00
enricobuehler cbcd7a5c40 refactor(client): relm4 desktop shell; delete legacy presenter + coverflow (phase 5)
The GTK client becomes a relm4 component tree: AppModel owns the window,
trust gate (rules 1-3), and the spawned session child's lifecycle as
typed messages; the hosts page is a child component with a
FactoryVecDeque of host cards — the HostsCallbacks Rc<dyn Fn> bag, the
busy Cell, and the Rc<RefCell<HostsUi>> cross-page pokes are gone.
Trust/settings/library dialogs stay plain GTK, invoked from update with
a ComponentSender.

Deleted with the in-process presenter: ui_stream.rs, video_gl.rs,
ui_gamepad_library.rs, launch.rs, the khronos-egl dep, and the
PUNKTFUNK_LEGACY_PRESENTER hatch. Every stream (and the console library)
now runs in punktfunk-session; the shell spawns it for card connects and
exec's it for --connect/--browse so the Decky wrapper keeps working. The
request-access flow gains --connect-timeout + a CancelHandle that kills
the child. Screenshot scenes re-pointed onto the components (verified:
hosts + library self-capture; the dialog scenes present correctly and
capture under a GL renderer); the gamepad-library scene is gone with the
page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 23:14:08 +02:00
enricobuehler be09f9f345 feat(session): console game library — Skia coverflow + SkSL aurora, --browse (phase 4b)
The run loop grows a browse mode: the console library idles between
streams in ONE window (no gamescope handoff), overlay actions launch
sessions via run_browse's callback, session end returns to the library.
The Overlay contract gains menu routing (MenuEvent → haptic pulse),
action draining, and session-phase edges.

pf-console-ui ports the GTK launcher wholesale: the coverflow's springs,
cursor arithmetic and recede/tilt constants move verbatim with their
tests (plus new projection tests — focused-card centering, the inner-
edge-recedes corridor); paint order is draw order (the gtk::Fixed
restack hack is gone); the aurora renders as an SkSL runtime shader at
full rate on every box (the 30 Hz CPU-upscale path and its frozen-on-
Deck fallback are deleted — the generated SkSL is compile-tested);
titles/scenes shape through textlayout (CJK-safe). Poster art streams
in as encoded bytes through the shared model and decodes renderer-side.

The session binary wires --browse host[:port] [--mgmt PORT]: KnownHosts
lookup (unpaired renders the pair-first scene), library + art fetch on
threads, PUNKTFUNK_FAKE_LIBRARY dev hook, and a session_params helper
shared with --connect. The minimal build refuses --browse cleanly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 23:13:16 +02:00
enricobuehler 021a2261f6 feat(session): Skia console UI — overlay contract + stats OSD/capture HUD (phase 4a)
The §6.1 presenter↔console-UI contract lands: pf-presenter exposes its
device (SharedDevice) and composites at most one premultiplied-alpha
quad per frame (new overlay.frag + LOAD render pass over the swapchain;
zero cost while the overlay returns None). pf-console-ui implements it
with skia-safe on the shared VkDevice: DirectContext via the ash
dispatch chain, a ring of two offscreen render targets (one-frame-in-
flight safe), damage-driven redraws — the OSD re-renders at 1 Hz, the
hint on capture toggles, nothing per-frame. Skia never touches the
swapchain. The session binary carries it behind the default ui feature:
4.9 MB stripped without, 10 MB with (measured).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 23:13:16 +02:00
enricobuehler 433a23da7a test(presenter): PUNKTFUNK_HW_FAULT=import demotion hook (phase 2 acceptance)
Faults every dmabuf import so the failure-streak → force_software →
software-decode recovery is exercisable on healthy hardware — the plan's
§8 phase-2 acceptance requires demoting via a deliberately faulted
import rather than waiting for a broken driver.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 23:13:16 +02:00
enricobuehler a3d3d4738c feat(client): shell hands desktop connects to punktfunk-session (phase 3)
start_session_with routes desktop windowed connects to the spawned
Vulkan session binary (--connect --fp, --launch, --fullscreen from the
stream setting); spawn.rs bridges its stdout contract into the shell —
spinner until {"ready":true}, banner from the error/ended JSON line,
exit 3 + trust_rejected routed to the re-pair PIN ceremony, TOFU pins
the advert fingerprint only once the child proves it on a real connect.
The in-process GTK presenter stays for PUNKTFUNK_LEGACY_PRESENTER=1,
Gaming-Mode/--fullscreen and --browse launches (no second toplevel under
gamescope until phase 4 moves the console UI), the request-access flow,
and any spawn failure (silent fallback).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 23:13:16 +02:00
enricobuehler a02d0a2e9f feat(presenter): VAAPI dmabuf → Vulkan zero-copy import + CSC pass (phase 2)
The decoder's NV12 dmabuf imports per-plane (R8 + GR88, explicit DRM
format modifier, dedicated dup'd-fd import, FOREIGN→graphics acquire)
and a fullscreen-triangle render pass converts it into the presenter's
video image with the CICP-driven coefficients ported from video_gl.rs
(same tests, plus a rows-vs-matrix agreement check). SPIR-V is committed
(shaders/build.sh regenerates) so builds and CI need no toolchain. The
import extension set is probed at device creation; unsupported boxes and
3-failure streaks demote the decoder to software via the existing
force_software contract. The session binary now honors the Settings
decoder preference instead of forcing software.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 23:13:16 +02:00
enricobuehler 89d45f2a55 feat(client): Vulkan session binary — SDL3 + ash presenter MVP (phase 1)
punktfunk-session streams one --connect session in an SDL3 window: ash
swapchain with a transfer-only letterboxed blit of the software-decode
path (no graphics pipeline until the phase-2 dmabuf/CSC pass), the
ui_stream input-capture state machine on SDL events (scancode→VK table
cross-checked against the evdev one), gamepads via a new caller-pumped
GamepadService mode (SDL video owns the main thread here), and the
shell↔session stdout contract: {"ready":true}, per-window stats:
lines, JSON error + exit codes 0/2/3/4. Strict trust — no pin, no
connect. Design: punktfunk-planning linux-client-rearchitecture.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 23:13:16 +02:00
enricobuehler 0ab97b597c refactor(client): extract UI-agnostic plumbing into pf-client-core (phase 0)
Session pump, FFmpeg decode, PipeWire audio, SDL3 gamepads, keymap, trust
store, mDNS discovery, library client and Wake-on-LAN move verbatim from
clients/linux into crates/pf-client-core, shared with the upcoming Vulkan
session binary (punktfunk-planning: linux-client-rearchitecture.md).
The GTK client re-exports them at the crate root so every existing
crate::-path keeps resolving; its manifest drops the moved-only deps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 23:13:16 +02:00
enricobuehler bf07700c74 feat(clients): windows shortcut parity (Ctrl+Alt+Shift+S, F11) + surface stream shortcuts
apple / swift (push) Successful in 1m7s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m39s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m19s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 54s
android / android (push) Successful in 4m45s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 57s
arch / build-publish (push) Successful in 5m31s
ci / rust (push) Failing after 1m11s
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m0s
apple / screenshots (push) Successful in 5m56s
ci / bench (push) Successful in 5m17s
deb / build-publish (push) Successful in 4m53s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
decky / build-publish (push) Successful in 26s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m35s
flatpak / build-publish (push) Successful in 4m42s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 8m48s
docker / deploy-docs (push) Successful in 21s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 7m54s
Windows was missing two of the four stream shortcuts the GTK client has:
Ctrl+Alt+Shift+S (toggle the stats overlay live) and F11 (toggle fullscreen).
Add both to the low-level keyboard hook — S flips a HUD_VISIBLE atomic seeded
from Settings::show_hud at install (Settings is the default, the key overrides
it for the session, matching GTK), F11 drives a borderless-fullscreen toggle on
the window HWND and re-locks the pointer geometry for the new client rect. Both
are consumed locally, never sent on the wire.

Surface the full key set in two places, on both clients:
- in the UI: a read-only "In-stream keyboard shortcuts" reference card in the
  Windows Settings > Input section (the counterpart of the GTK Shortcuts
  window), plus the expanded HUD hint; the Linux keyboard hint gains F11.
- at stream start: a bottom-centre banner listing the shortcuts for the first
  few seconds of every session, independent of the HUD setting. Linux gets the
  matching start-flash of its capture hint (capture engages on map and hid it,
  so the keys were never shown until the first release).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 22:55:45 +02:00
enricobuehler 655ec31ef9 ci(android): attach the built APK to the workflow run
apple / swift (push) Successful in 1m9s
android / android (push) Successful in 4m39s
arch / build-publish (push) Successful in 5m25s
ci / web (push) Successful in 53s
apple / screenshots (push) Successful in 5m32s
ci / rust (push) Successful in 2m11s
ci / docs-site (push) Successful in 1m0s
ci / bench (push) Successful in 5m9s
deb / build-publish (push) Successful in 4m59s
decky / build-publish (push) Successful in 18s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 8m52s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 8m27s
The APK was only reachable via the generic registry (or attached to a Gitea
Release on a vX.Y.Z tag) — a main-push canary or a PR/dispatch run surfaced no
downloadable APK on the run page itself. Add an upload-artifact step (v3, per
Gitea's GHES-identifying artifact backend, like apple.yml) that grabs whichever
APKs were built — the signed universal release APK on a main/tag push, else the
debug APK — so any run is a one-click sideload download.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 17:01:02 +00:00
enricobuehler 04302075b5 feat(android): promote the low-latency pipeline to default
apple / swift (push) Successful in 1m6s
android / android (push) Successful in 4m58s
arch / build-publish (push) Successful in 5m59s
ci / web (push) Successful in 51s
ci / rust (push) Successful in 2m29s
ci / docs-site (push) Successful in 1m0s
windows-host / package (push) Successful in 8m15s
release / apple (push) Successful in 8m19s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m24s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m17s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 54s
ci / bench (push) Successful in 5m14s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m1s
deb / build-publish (push) Successful in 5m0s
decky / build-publish (push) Successful in 15s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 54s
apple / screenshots (push) Successful in 5m49s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m23s
flatpak / build-publish (push) Successful in 4m39s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 8m48s
docker / deploy-docs (push) Successful in 19s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 8m12s
The async decode loop (burst-feed + present-newest-per-vsync, the Apple client's
discipline) plus the per-SoC tuning was gated behind an experimental,
default-off toggle after the 5dc24a0 overhaul regressed on some phones. That
regression was the receive-side latency ratchet the async loop fed — a standing
queue that only grew — now fixed in the shared core (FrameChannel jumps to live
instead of accumulating it), so the fast pipeline is the default again.

Default the master toggle on via a fresh pref key (low_latency_mode_v2),
mirroring the migration da376b31 used to flip it off: a new key re-defaults every
install — including ones persisted off under the old key — to on, so the
promotion reaches users who had saved settings, not just fresh installs. Both
stale keys are abandoned unread. Toggle-off still restores the original
synchronous decode pipeline byte-for-byte as a per-device escape hatch.

Drop "(experimental)" from the settings labels and fix the now-stale default-off
wording in the native + Kotlin docs. No decode-path routing change — run_async is
reached simply because the toggle now defaults on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 16:18:40 +00:00
enricobuehler a1b890ef42 fix(host): coalesce keyframe-request storms into one IDR per cooldown
Clients request a keyframe on every FEC-unrecoverable frame and keep asking
until the IDR actually arrives and decodes — a full round-trip on a link that is
already behind. The host answered every request with a full IDR and only
rate-limited under intra-refresh (opt-in, off by default), so on the default
path a Wi-Fi loss burst produced a 20-40x bitrate spike storm that deepened the
very loss it was recovering from — the second half of the periodic double-jolt.

Coalesce a request storm into at most one forced IDR per cooldown ALWAYS: serve
the first immediately (a genuinely wedged decoder still recovers at once), then
suppress for the window (2 s under intra-refresh's healing wave, 750 ms for a
full-IDR recovery — long enough to swallow one recovery event's round-trip echo,
short enough to re-issue a lost IDR promptly). Seed the cooldown at session open
and stamp it on both rebuild paths so the cold-open / post-rebuild storm
coalesces into the IDR the fresh encoder already emits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 16:18:30 +00:00
enricobuehler bdcbb2d3a7 fix(core): jump to live on a standing receive backlog instead of ratcheting latency
The embedder-facing frame queue was a 16-deep sync_channel whose try_send
dropped the NEWEST access unit on overflow — backwards for a live stream (keeps
stale, discards fresh), a ~266 ms floor that could not self-drain (producer and
consumer both run at frame rate, so any depth a burst injects is conserved
forever — the latency ratchet), and a silent reference-chain break the loss
counters never saw. The clock-based flush meant to catch it was gated on the
skew handshake and never even drained that queue.

Replace it with a purpose-built FrameChannel (VecDeque + Condvar) exposing
depth() and clear(). Pre-decode AUs are reference-chained under the host's
infinite GOP, so they are never dropped mid-stream; instead, when the embedder
falls persistently behind, the pump JUMPS TO LIVE — flush_backlog() + clear the
queued AUs + request a keyframe — so decode re-anchors cleanly at an IDR.

Two cooldown-gated detectors, both suspended during a speed test:
- clock-based (existing): > FLUSH_LATENCY behind the skew-corrected clock for
  FLUSH_AFTER_FRAMES straight; also catches kernel/reassembler backlog.
- clock-free (new): the hand-off queue sat >= QUEUE_HIGH without draining to
  QUEUE_LOW for STANDING_FRAMES straight. Works on same-clock / no-handshake
  sessions where the clock path is disarmed — the direct "the embedder can't
  keep up" signal. A transient Wi-Fi clump drains in a few frames and never
  trips it.

Bounded (90-frame hard cap, drop-oldest memory backstop) and diagnosable (each
jump logs queue_depth / flushed_datagrams / dropped_frames). next_frame's
external Timeout/Closed contract is unchanged, so every native client inherits
the fix. Adds 5 FrameChannel unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 16:18:18 +00:00
enricobuehler 1dc8dc7f0d fix(packaging): open mgmt/library port 47990 on the LAN firewall profiles
apple / swift (push) Successful in 1m11s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
decky / build-publish (push) Has been cancelled
deb / build-publish (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
android / android (push) Successful in 3m49s
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
The mgmt REST API has bound 0.0.0.0:47990 by default since ae51276 so paired
clients can browse the game library over mTLS, but every packaged firewall
opener still excluded 47990 and the docs still claimed it was loopback-only.
On any host with an active firewall (ufw/firewalld) the LAN game-library
feature was silently broken.

Add 47990/tcp to the native firewall profiles (punktfunk.ufw [punktfunk-native]
+ punktfunk-native.xml) and correct the stale "loopback-only by default" text
across the debian/arch/bazzite READMEs and the docs site (incl. the factually
wrong --mgmt-bind default in host-cli.md, 127.0.0.1 -> 0.0.0.0). Opening the
port adds no admin exposure: off-loopback mgmt::require_auth serves only the
read-only status/library allowlist to a paired client cert; the bearer-token
admin surface stays loopback-only regardless of the bind.

Windows was already sound (shared parse_serve binds 0.0.0.0; service.rs already
firewall-opens 47990) — add a clarifying comment so the rule isn't mistaken for
accidental over-exposure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 12:38:57 +00:00
enricobuehler 7effdb4030 docs(decky): use the unom.io/pf-decky short link for install-from-URL
The full git.unom.io package URL is painful to read/type on a Steam
Deck's on-screen keyboard; unom.io/pf-decky now redirects to it
(added in unom/infra's Caddyfile). Canary/pinned links stay long-form
since only /latest gets a short link.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 11:57:51 +00:00
enricobuehler f30a65d507 fix(web): override vulnerable transitive deps, clear bun audit
Force patched undici/dompurify/postcss/esbuild/js-yaml pulled in
transitively via @unom/ui's payload/vite/storybook chains, and bump
vite 7.3.5 -> 7.3.6 so esbuild resolves consistently to 0.28.1
instead of splitting across 0.27.7/0.28.0. Closes all 26 bun audit
findings (3 high, 17 moderate, 6 low).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 11:50:41 +00:00
enricobuehler 774988edd4 fix(deps): bump crossbeam-epoch 0.9.18 -> 0.9.20 (RUSTSEC-2026-0204)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:50:08 +00:00
enricobuehler 324da666e5 chore(release): bump workspace version to 0.8.2
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows-host / package (push) Has been cancelled
web-screenshots / screenshots (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
release / apple (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
linux-client-screenshots / screenshots (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
decky / build-publish (push) Has been cancelled
deb / build-publish (push) Has been cancelled
arch / build-publish (push) Has been cancelled
android / android (push) Has been cancelled
android-screenshots / screenshots (push) Has been cancelled
Release 0.8.2: native AMF encoder reconnect reliability on Windows AMD — a
second connection no longer comes up black (the encoder now Flushes before
Terminate so a reconnect's overlapping teardown can't strand AMD's VCN encode
session, self-heals with a full context rebuild when a reconnect still wedges,
and logs a per-context bring-up number + first-AU line so a silent wedge is
visible); a native data-plane hardening pass that keeps the stream alive across
real Wi-Fi links; Android streaming wake/Wi-Fi locks that actually engage plus a
console-UI polish pass (per-controller glyphs, scrollable dialogs, animated
forms); and gamescope-takeover survival on Bazzite's SDDM session supervisor.
Also a Linux-client crash fix — the FlowBox activation cycle that stack-
overflowed on every host-card click.

The [workspace.package] version (inherited by every crate via version.workspace)
is the release being cut; refresh the 9 workspace entries in Cargo.lock to match
(CI builds --locked). Canary derives from the tag (scripts/ci/pf-version.sh), so
cutting v0.8.2 auto-advances canary to 0.9.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 11:16:19 +02:00
enricobuehler efed8d5a20 fix(host): AMF encoder reconnect reliability on Windows AMD
A second connection to a Windows AMD host came up black with nothing in the
logs. The native AMF encoder's teardown never Flushed before Terminate, so a
reconnect whose teardown overlapped the new session (a client may not signal an
explicit exit, so session 1 tears down late — on the reconnect preempt grace or
the QUIC idle timeout) left AMD's limited VCN encode-session slot occupied. The
new session's Init then opened onto a wedged session that returns AMF_OK but
never emits an AU. NVENC has no equivalent per-session cap, so NVIDIA never
showed it. Recovery couldn't help either: the stall watchdog re-Init'd the SAME
context, which can't clear a context/VCN-level fault, so it looped a dead
context until MAX_ENCODER_RESETS ended the session.

Reliability:
- Component::drop now Flushes before Terminate (mirrors reset() and the design
  doc), releasing the VCN session cleanly so the next session's Init gets a free
  slot.
- reset() escalates to a FULL context teardown once an in-place re-Init has run
  without producing an AU (resets_without_output >= 2), so a wedged reconnect
  self-heals via a fresh CreateContext+InitDX11 within the reset budget instead
  of re-initing a dead context in a loop.

Logging (the failure was silent):
- Per-context bring-up sequence number (context #N) — distinguishes a first
  connection from a reconnect's fresh context.
- A one-shot "AMF produced its first AU on this context" line; its absence after
  a context #N bring-up is the smoking gun for a silent VCN wedge.
- Terminate result logged on drop for both the component and the context.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 11:09:59 +02:00
enricobuehler 636b9c1d1f Merge remote-tracking branch 'origin/main' 2026-07-07 06:15:21 +00:00
enricobuehler 92f078adaf fix(host): survive Bazzite's SDDM session supervisor in the gamescope takeover
Diagnosed live on the .181 Bazzite F44 box (couldn't connect at all; field
reports of streams dying after 30 s-5 min):

Bazzite autologs into game mode via SDDM with Relogin=true, so the moment the
managed takeover stops gamescope-session-plus@<client>, SDDM logs back in and
restarts it within the same second. The resurrected autologin session then
fights our transient session-plus over the Steam single instance and the GPU
for the whole stream: its wrapper relaunches gamescope every ~7 s (each one
missing the wrapper's hard 5 s readiness window on a slow NVIDIA init), the
churn SIGSEGVs gamescopes, and eventually the streaming gamescope dies with it.
Meanwhile a client that gave up left the pipeline-rebuild retry loop SIGKILLing
and relaunching the box's Steam session for up to ~6 more minutes.

- stop_autologin_sessions: runtime-mask each autologin unit before the SIGKILL
  stop, so no supervisor can restart it underneath the stream; match every
  loaded instance (the unit flaps through activating/failed mid-churn). Every
  restore path unmasks unconditionally (including the desktop-active early
  return), and --runtime keeps the mask in tmpfs so a reboot clears it.
- launch_session: supervise the transient unit while polling for the node —
  the session-plus wrapper kill -9s a gamescope that missed its 5 s readiness
  handshake and exits 1, so relaunch it (after a short driver-settle cooldown)
  instead of waiting the rest of the 45 s on a corpse.
- build_pipeline_with_retry: abort between attempts once the session's QUIC
  connection is closed — no more minutes of Steam churn for a departed client.

Validated live on .181: cold-boot connect streams 2059 frames/45 s (p50
5.1 ms), zero SDDM resurrections while masked, TV session restored+unmasked on
disconnect, warm same-mode reconnect reuses the session (866 frames/15 s).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 06:15:12 +00:00
enricobuehler 17685ff73b build(android): debug APKs ship release-profile rust
Cargo's debug profile is not "slower" for this library — it is unusable, and
it invalidated every on-device performance test to date: RustCrypto's AES-GCM
compiles to generic-array iterator closures with per-byte precondition checks
instead of ARMv8 hardware AES. Profiled live on a phone (simpleperf, 62k
samples): ~800 µs of user CPU per 1.4 KB packet — the receive pump pinned
above a full core yet only draining ~1,400 pkt/s of a 1,775 pkt/s (20 Mbps)
stream, 2.3 MB standing in the kernel socket buffer, the latency-bound flush
firing every 2 s forever. With release rust in the same debug APK: pump at
~12 % of a core, socket queue zero, no flushes, 2800x1260@120 streaming clean.

preDebugBuild now depends on cargoNdkRelease; `-PrustDebug` opts back into a
debug-profile native build for sessions that actually step through Rust.
Kotlin debuggability is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 08:07:42 +02:00
enricobuehler 08ab2b6bee fix(android): declare WAKE_LOCK — the stream's Wi-Fi locks never actually engaged
WifiLock.acquire() enforces the WAKE_LOCK permission, which the manifest never
declared — every acquisition since the first Wi-Fi lock shipped threw
SecurityException, silently swallowed by a bare runCatching. The phone's own
accounting proved it (dumpsys wifi: high_perf/low_latency active_time_ms = 0
across weeks of streams): every on-device session ran with Wi-Fi power save
fully active, whatever the code intended. Verified live after the fix: both
locks registered in WifiLockManager, mPowerSaveDisableRequests=2, ping RTT to
the streaming phone 3.8 ms avg. A failed acquire now logs loudly — this class
of failure must never be invisible again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 08:07:42 +02:00
enricobuehler b271d0c816 fix(android): hold BOTH Wi-Fi locks while streaming — HIGH_PERF alone is a no-op
The baseline stream held only WIFI_MODE_FULL_HIGH_PERF, which is deprecated
AND non-functional on recent Android — so with the low-latency toggle off (the
default) Wi-Fi power save stayed fully active: downlink delivery clumped at
beacon intervals (a few hundred ms of latency mush, sawtoothing bitrate) and
the AP's power-save buffer periodically overflowed, killing whole frames every
few seconds (the host log's alternating loss_ppm=0/50000). Now every stream
holds FULL_LOW_LATENCY (API 29+, the only effective power-save disable;
foreground + screen-on, which a stream always is) AND FULL_HIGH_PERF (covers
older releases) — the same pair Moonlight holds. The experimental toggle no
longer selects the lock mode.

Also: declare tracing's "log" feature explicitly in the native crate (core
transport warnings → logcat must not hinge on quinn's default features), and
align the low-latency toggle's copy with its actual scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 07:35:08 +02:00
enricobuehler 83b7c7adf5 feat(android): console UI — per-controller glyphs, dialog scrolling, animated forms
- Hint-bar glyphs now wear the driving controller's family (kit
  Gamepad.styleFor by USB vendor id → MainActivity.lastPadStyle, kept live by
  real input like lastPadIsGamepad): PlayStation pads get Canvas-drawn
  cross/circle/square/triangle shapes in the classic colours, Nintendo pads
  monochrome lettering, Xbox/Valve/unknown the coloured letter discs. Hint
  chars stay semantic (KEYCODE_BUTTON names); only the rendering changes.
- The Options legend renders the pad's real Select-family button
  (SelectButtonGlyph): Xbox View windows, PlayStation Create capsule,
  Nintendo minus — instead of a bare capsule outline.
- GamepadDialog: body + action stack scroll together (title pinned) with
  BringIntoViewRequester keeping the focused button visible — a 5-action host
  options dialog compressed/clipped its last button in short landscape
  windows because the pinned stack could not scroll.
- Console form polish: shared animateConsoleFocus (bg/border cross-fade +
  spring scale) across settings rows / add-host fields / action rows;
  ConsoleSwitch (spring knob, tinting track) replaces On/Off text on toggle
  rows; choice values slide in the direction they were stepped
  (AnimatedContent + SizeTransform) with chevrons that fade in place; the
  focused row's detail unfolds via AnimatedVisibility; dialog buttons and
  keyboard keycaps cross-fade (keycaps at 90 ms for hold-to-repeat).
- Console settings gain the "Low-latency mode" (Video) and "Auto-wake on
  connect" (Interface) rows, round-tripping with the touch settings.
- Screenshot scene: StatsOverlay call updated to the 18-double layout + the
  new decoderLabel parameter (fixes the android-screenshots CI compile).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 07:35:08 +02:00
enricobuehler eea23c5647 fix(core,host): make the native data plane survive real Wi-Fi links
Root-caused live on a phone at 100 Mbps (stream stuck seconds behind, then
oscillating): a stack of transport defects, each amplifying the next.

- MTU-safe shards: shard_payload 1452 overshot the IPv4/1500 budget (the old
  math forgot the 40 B header + 24 B crypto ride inside the UDP payload and
  counted IP+UDP as 8 B) — the kernel silently split EVERY video datagram into
  two IP fragments, doubling per-datagram loss on Wi-Fi. New
  config::mtu1500_shard_payload() = 1408 (1472 sealed = the exact ceiling),
  negotiated in the Welcome, pinned by a unit test.

- Android batched I/O: recv/send batching was cfg(linux); Android is
  target_os="android" and silently fell back to a syscall per datagram. The
  libc crate binds neither recvmmsg/sendmmsg nor mmsghdr for Android, so a
  local bionic extern binding provides them (API 21+, floor is 28); cbindgen
  excludes them from the C header. The pump/runtime threads also get the
  Apple-QoS analogue on Android: nice −8 (below the decode thread's −10).

- Latency-bounded receive: packets are consumed strictly in order at exactly
  the arrival rate, so a standing queue (Wi-Fi stall, power-save clumping)
  NEVER drains — observed as a stream permanently 6-7 s behind with both 32 MB
  socket buffers full. The pump now flushes the entire backlog
  (Session::flush_backlog: discard ring + kernel queue at memcpy speed, reset
  the reassembler) and requests a keyframe when frames keep completing > 400 ms
  behind the skew-corrected capture clock (30 consecutive, 2 s cooldown,
  logged).

- Time-based loss window: the reassembler declared an incomplete frame lost a
  fixed 4 INDICES behind the newest — 33 ms at 120 fps, inside normal Wi-Fi
  retry/reorder timescales, so merely-late frames were pruned every few
  seconds, each costing a recovery-IDR burst + an inflated loss report.
  Now 120 ms of capture time (LOSS_WINDOW_NS), same fuse at every refresh
  rate, with a 64-index hard cap bounding memory against hostile pts.

- Adaptive-FEC hysteresis: the controller was memoryless — one clean 750 ms
  report dropped FEC from 8 % straight back to the 1 % floor, so periodic burst
  loss (Wi-Fi scan / BT coexistence beats) always hit an unprotected stream and
  ping-ponged 1↔8 % with a frozen frame per cycle (observed in the host log as
  alternating loss_ppm=0/50000). Attack stays instant; decay is now one point
  per clean report.

Verified: full core suite (incl. new flush + time-window tests) on macOS +
Linux, host release build, arm64 cargo-ndk build, and a 30 s wired probe run
at 2800x1260@120 — 3559/3559 frames, zero loss, capture→received p50 5.3 ms
(host 5.1 + network 0.3).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 07:35:08 +02:00
enricobuehler 912d7de2e6 style(linux): rustfmt drift from the last two commits
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 00:22:46 +02:00
enricobuehler e788d0de84 feat(client-linux): log the release/disconnect keyboard chords
The Ctrl+Alt+Shift+Q/D handlers had no tracing, so a report of "the
disconnect shortcut doesn't work" was unverifiable from logs alone —
live tracing (added temporarily, then trimmed to these two lines)
showed the chord, `disconnect_quit()`, and the session teardown all
firing correctly and instantly every time; the confusion traced back
to the (now-fixed) FlowBox click crash having kept everyone from ever
reaching a live session to test the shortcut with in the first place.

Keep the two low-noise, deliberate-action log lines for the next time
this comes up; drop the per-keystroke debug trace used to diagnose it,
which would otherwise fire on every key during a stream.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 00:21:26 +02:00
enricobuehler 53c8eefa99 fix(client-linux): break the FlowBox activation signal cycle — stack overflow on every host-card click
`child-activated` (fired by a pointer click) was bridged to `child.activate()`
so each card's own connect handler (wired on the child's `activate` signal)
would run. But `child.activate()` runs `GtkFlowBoxChild`'s default handler,
which re-emits `child-activated` on the FlowBox — bouncing straight back into
the same closure. Unguarded, that ping-pong recursed forever, overflowing the
stack on every single host-card click or Enter-key activation (confirmed live
via coredump/gdb: 43k+ stack frames of gobject signal emission, and the
`fatal runtime error: stack overflow, aborting` in the crash log).

A re-entrancy flag breaks the cycle after the one real activation. Added a
regression test that wires the identical FlowBox/FlowBoxChild signal cycle
against a real display and asserts it returns instead of recursing — it
reproduces the exact stack overflow against the old code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 23:59:04 +02:00
299 changed files with 40063 additions and 7453 deletions
+17
View File
@@ -103,6 +103,23 @@ jobs:
# AAB for Play; a universal APK (all ABIs) for direct sideload/testing — same upload key.
./gradlew :app:bundleRelease :app:assembleRelease --stacktrace
# Attach the built APK to the workflow run so it's a one-click sideload download straight from the
# run page (no need to know the generic-registry URL) — on EVERY trigger, incl. a PR or a
# workflow_dispatch. The debug APK is built on every run; the signed universal release APK exists
# only on a main/tag push (its build step above is push-gated), so grab whichever were produced.
- name: Attach APK(s) to the workflow run
if: always()
# v3, not v4: Gitea's artifact backend identifies as GHES, which upload-artifact@v4 refuses
# (same reason as apple.yml / *-screenshots.yml). Download is a zip of the matched APK(s).
uses: actions/upload-artifact@v3
with:
name: punktfunk-android-apk
path: |
clients/android/app/build/outputs/apk/release/*.apk
clients/android/app/build/outputs/apk/debug/*.apk
if-no-files-found: warn
retention-days: 30
# Publish BEFORE the Play upload so artifacts land even while the Play step is still failing.
# Generic registry is public for reads — matches windows-msix.yml / deb.yml (REGISTRY_TOKEN, user enricobuehler).
# main = canary store + `canary/` sideload alias; a `vX.Y.Z` tag = `latest/` alias + attached
+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
- name: Install build + runtime-dev deps
run: |
pacman -Syu --noconfirm --needed \
git nodejs rust clang cmake nasm pkgconf python \
git nodejs rust clang cmake nasm pkgconf python vulkan-headers \
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
mesa libglvnd unzip libarchive
# bun builds the punktfunk-web console AND is vendored as its runtime (PF_WITH_WEB=1);
+7 -2
View File
@@ -60,8 +60,10 @@ jobs:
run: |
apt-get update
# python3 is used by scripts/ci/gitea-release.sh for the stable-tag release attach.
# libvulkan-dev: /usr/include/vulkan/vulkan.h for the client's pf-ffvk bindgen
# (FFmpeg's hwcontext_vulkan.h includes it).
apt-get install -y --no-install-recommends dpkg-dev python3 \
libgtk-4-dev libadwaita-1-dev libsdl3-dev
libgtk-4-dev libadwaita-1-dev libsdl3-dev libvulkan-dev
# Share ci.yml's cache keys so the release build reuses its registry + target artifacts.
- name: Cache keys
@@ -86,7 +88,10 @@ jobs:
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binary (build.rs)
run: |
git config --global --add safe.directory "$PWD"
cargo build --release -p punktfunk-host -p punktfunk-client-linux --locked
# punktfunk-client-session is the Vulkan/Skia streamer the shell execs for a connect —
# both client binaries must ship (build-client-deb.sh installs both).
cargo build --release --locked \
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session
- name: Build + smoke-boot web console (bun preset)
# Gate the .deb on a real bun boot: the punktfunk-web .deb runs the Nitro `bun` preset
+6
View File
@@ -25,9 +25,15 @@ on:
branches: [main]
# The flatpak is the CLIENT — only rebuild when the client/core/manifest change, not on every
# design/host push (this is a heavy flatpak-builder run). Tags (v*, the client release) build too.
# The bundle ships BOTH client binaries (shell + Vulkan session), so every crate in either
# binary's dependency closure must be listed here.
paths:
- 'clients/linux/**'
- 'clients/session/**'
- 'crates/punktfunk-core/**'
- 'crates/pf-client-core/**'
- 'crates/pf-presenter/**'
- 'crates/pf-console-ui/**'
- 'packaging/flatpak/**'
- 'Cargo.lock'
- '.gitea/workflows/flatpak.yml'
@@ -30,7 +30,7 @@ jobs:
run: |
apt-get update
apt-get install -y --no-install-recommends \
libgtk-4-dev libadwaita-1-dev libsdl3-dev \
libgtk-4-dev libadwaita-1-dev libsdl3-dev libvulkan-dev \
xvfb x11-utils imagemagick scrot \
libgl1-mesa-dri mesa-vulkan-drivers \
adwaita-icon-theme fonts-cantarell fonts-dejavu-core
+3 -1
View File
@@ -54,7 +54,9 @@ jobs:
- name: Prep
run: |
git config --global --add safe.directory "$PWD"
dnf -y install gtk4-devel libadwaita-devel SDL3-devel
# vulkan-headers: the client's pf-ffvk crate runs bindgen over FFmpeg's
# libavutil/hwcontext_vulkan.h (#include <vulkan/vulkan.h>).
dnf -y install gtk4-devel libadwaita-devel SDL3-devel vulkan-headers
# sysext build (packaging/bazzite/build-sysext.sh): squashfs + SELinux labeling.
dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted
# bun builds the punktfunk-web console (--with web). Baked into the image; install it
+15 -1
View File
@@ -33,7 +33,12 @@ on:
branches: [main]
paths:
- 'clients/windows/**'
- 'clients/session/**'
- 'crates/punktfunk-core/**'
- 'crates/pf-client-core/**'
- 'crates/pf-presenter/**'
- 'crates/pf-console-ui/**'
- 'crates/pf-ffvk/**'
- 'Cargo.lock'
- 'Cargo.toml'
- '.gitea/workflows/windows-msix.yml'
@@ -57,10 +62,15 @@ jobs:
target: x86_64-pc-windows-msvc
ffmpeg: C:\Users\Public\ffmpeg
td: C:\t
session_flags: ''
- arch: arm64
target: aarch64-pc-windows-msvc
ffmpeg: C:\Users\Public\ffmpeg-arm64
td: C:\t-a64
# No skia-binaries prebuilt for aarch64-pc-windows-msvc: the session ships
# without the Skia console UI on ARM64 (streaming unaffected) — flip when
# rust-skia adds the target.
session_flags: '--no-default-features'
steps:
- uses: actions/checkout@v4
@@ -78,6 +88,8 @@ jobs:
"CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"CARGO_TARGET_DIR=${{ matrix.td }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"FFMPEG_DIR=${{ matrix.ffmpeg }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# pf-ffvk's bindgen needs Vulkan headers (arch-independent; provisioned alongside FFmpeg).
"PF_FFVK_VULKAN_INCLUDE=C:\Users\Public\vulkan-headers\include" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
rustup target add ${{ matrix.target }}
$pf = & "$env:GITHUB_WORKSPACE/scripts/ci/pf-version.ps1" # single source of truth: base is one minor ahead of the latest stable tag
$parts = if ($env:GITHUB_REF -like 'refs/tags/v*') {
@@ -92,9 +104,11 @@ jobs:
"MSIX_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
Write-Output "MSIX version $v arch ${{ matrix.arch }} target ${{ matrix.target }}"
# Both client binaries — the shell spawns punktfunk-session.exe (a package sibling)
# for every stream. --no-default-features on ARM64 is a no-op for the shell.
- name: Build (release)
shell: pwsh
run: cargo build --release -p punktfunk-client-windows --target ${{ matrix.target }}
run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session ${{ matrix.session_flags }} --target ${{ matrix.target }}
- name: Pack + sign MSIX
shell: pwsh
+34 -9
View File
@@ -1,9 +1,13 @@
# Windows client CI — runs on a self-hosted windows-amd64 runner (host mode; the generic runner +
# toolchain come from unom/infra's windows-runner/; punktfunk's own extras - FFmpeg, WDK, Inno
# Setup, the ARM64 rustup target - self-provision via the "Ensure Windows toolchain" step below, a
# fast no-op once already present, so any runner with that label works with no manual dispatch
# step first). Build + clippy + fmt + test the WinUI 3 client
# (windows-reactor + D3D11/SwapChainPanel + WASAPI + SDL3).
# toolchain come from unom/infra's windows-runner/; punktfunk's own extras - FFmpeg,
# Vulkan-Headers, WDK, Inno Setup, the ARM64 rustup target - self-provision via the "Ensure
# Windows toolchain" step below, a fast no-op once already present, so any runner with that label
# works with no manual dispatch step first). Build + clippy + fmt + test BOTH client binaries:
# the WinUI 3 shell (windows-reactor + WASAPI + SDL3) and the punktfunk-session Vulkan client
# (pf-presenter/pf-client-core/pf-console-ui/pf-ffvk — every stream runs in it, spawned by the
# shell). ARM64 note: rust-skia publishes no aarch64-pc-windows-msvc prebuilt binaries, so the
# session builds --no-default-features there (no Skia console UI; streaming is unaffected) —
# flip when skia-binaries adds the target.
#
# Two architectures from ONE x64 runner: x86_64-pc-windows-msvc natively and
# aarch64-pc-windows-msvc by cross-compiling. The x64 MSVC toolset ships an ARM64 cross compiler
@@ -40,14 +44,24 @@ on:
branches: [main]
paths:
- 'clients/windows/**'
- 'clients/session/**'
- 'crates/punktfunk-core/**'
- 'crates/pf-client-core/**'
- 'crates/pf-presenter/**'
- 'crates/pf-console-ui/**'
- 'crates/pf-ffvk/**'
- 'Cargo.lock'
- 'Cargo.toml'
- '.gitea/workflows/windows.yml'
pull_request:
paths:
- 'clients/windows/**'
- 'clients/session/**'
- 'crates/punktfunk-core/**'
- 'crates/pf-client-core/**'
- 'crates/pf-presenter/**'
- 'crates/pf-console-ui/**'
- 'crates/pf-ffvk/**'
- 'Cargo.lock'
- 'Cargo.toml'
- '.gitea/workflows/windows.yml'
@@ -78,6 +92,8 @@ jobs:
# Per-arch FFmpeg import libs (provision-windows-punktfunk-extras.ps1 fetches both).
$ff = if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { 'C:\Users\Public\ffmpeg-arm64' } else { 'C:\Users\Public\ffmpeg' }
"FFMPEG_DIR=$ff" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# pf-ffvk's bindgen needs Vulkan headers (arch-independent; provisioned alongside FFmpeg).
"PF_FFVK_VULKAN_INCLUDE=C:\Users\Public\vulkan-headers\include" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# $ff\bin on PATH too (not just FFMPEG_DIR, which only satisfies the linker): the test
# binary needs the actual DLLs to load at runtime. Set here rather than relying on the
# daemon's own env (project-env.ps1) - on a freshly cloned/registered runner the daemon
@@ -89,20 +105,29 @@ jobs:
cargo --version
Write-Output "target ${{ matrix.target }} target-dir $td ffmpeg $ff"
# Both client binaries. ARM64: no skia-binaries prebuilt for the target, so the session
# drops its `ui` feature there (pf-console-ui excluded; --no-default-features is a no-op
# for the shell, which has no features).
- name: Build
shell: pwsh
run: cargo build -p punktfunk-client-windows --target ${{ matrix.target }}
run: |
$sf = @(); if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') }
cargo build -p punktfunk-client-windows -p punktfunk-client-session @sf --target ${{ matrix.target }}
- name: Clippy (-D warnings)
shell: pwsh
run: cargo clippy -p punktfunk-client-windows --all-targets --target ${{ matrix.target }} -- -D warnings
run: |
$pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','pf-client-core','-p','pf-presenter','-p','pf-ffvk')
$sf = @()
if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') } else { $pkgs += @('-p','pf-console-ui') }
cargo clippy @pkgs --all-targets @sf --target ${{ matrix.target }} -- -D warnings
- name: Rustfmt check
if: matrix.target == 'x86_64-pc-windows-msvc'
shell: pwsh
run: cargo fmt -p punktfunk-client-windows -- --check
run: cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk -- --check
- name: Test
if: matrix.target == 'x86_64-pc-windows-msvc'
shell: pwsh
run: cargo test -p punktfunk-client-windows --target ${{ matrix.target }}
run: cargo test -p punktfunk-client-windows -p punktfunk-client-session -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk --target ${{ matrix.target }}
+4
View File
@@ -34,3 +34,7 @@ __pycache__/
# Claude Code project instructions — local to each dev box, not part of the repo.
CLAUDE.md
# Local flatpak-builder output (build-flatpak.sh) — ostree repo + build dir at the repo root.
.flatpak-repo/
.flatpak-build/
Generated
+268 -25
View File
@@ -472,6 +472,8 @@ dependencies = [
"cexpr",
"clang-sys",
"itertools 0.13.0",
"log",
"prettyplease",
"proc-macro2",
"quote",
"regex",
@@ -889,9 +891,9 @@ dependencies = [
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
@@ -1144,6 +1146,9 @@ name = "fastrand"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "fec-rs"
@@ -1193,6 +1198,16 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -1221,6 +1236,7 @@ version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be"
dependencies = [
"fastrand",
"futures-core",
"futures-sink",
"spin",
@@ -1247,6 +1263,15 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fragile"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8878864ba14bb86e818a412bfd6f18f9eabd4ec0f008a28e8f7eb61db532fcf9"
dependencies = [
"futures-core",
]
[[package]]
name = "fs-err"
version = "3.3.0"
@@ -2129,7 +2154,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.8.1"
version = "0.9.0"
[[package]]
name = "lazy_static"
@@ -2261,7 +2286,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.8.1"
version = "0.9.0"
dependencies = [
"punktfunk-core",
]
@@ -2382,8 +2407,6 @@ dependencies = [
[[package]]
name = "ndk"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
dependencies = [
"bitflags",
"jni-sys 0.3.1",
@@ -2740,6 +2763,42 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-client-core"
version = "0.9.0"
dependencies = [
"anyhow",
"async-channel",
"ffmpeg-next",
"mdns-sd",
"opus",
"pf-ffvk",
"pipewire",
"punktfunk-core",
"rustls",
"sdl3",
"serde",
"serde_json",
"tracing",
"ureq",
"wasapi",
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
]
[[package]]
name = "pf-console-ui"
version = "0.9.0"
dependencies = [
"anyhow",
"ash",
"pf-client-core",
"pf-presenter",
"punktfunk-core",
"sdl3",
"skia-safe",
"tracing",
]
[[package]]
name = "pf-driver-proto"
version = "0.0.1"
@@ -2747,6 +2806,30 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "pf-ffvk"
version = "0.9.0"
dependencies = [
"ash",
"bindgen",
"pkg-config",
]
[[package]]
name = "pf-presenter"
version = "0.9.0"
dependencies = [
"anyhow",
"ash",
"async-channel",
"pf-client-core",
"pf-ffvk",
"punktfunk-core",
"sdl3",
"tracing",
"windows-sys 0.61.2",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -2869,13 +2952,23 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit",
"toml_edit 0.25.12+spec-1.1.0",
]
[[package]]
@@ -2908,7 +3001,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.8.1"
version = "0.9.0"
dependencies = [
"android_logger",
"jni",
@@ -2916,36 +3009,46 @@ dependencies = [
"log",
"mdns-sd",
"ndk",
"ndk-sys",
"opus",
"punktfunk-core",
"tracing",
]
[[package]]
name = "punktfunk-client-linux"
version = "0.8.1"
version = "0.9.0"
dependencies = [
"anyhow",
"async-channel",
"ffmpeg-next",
"gtk4",
"khronos-egl",
"libadwaita",
"mdns-sd",
"opus",
"pipewire",
"pf-client-core",
"punktfunk-core",
"rustls",
"sdl3",
"serde",
"relm4",
"serde_json",
"tracing",
"tracing-subscriber",
"ureq",
]
[[package]]
name = "punktfunk-client-session"
version = "0.9.0"
dependencies = [
"anyhow",
"pf-client-core",
"pf-console-ui",
"pf-presenter",
"punktfunk-core",
"serde_json",
"tracing",
"tracing-subscriber",
"winresource",
]
[[package]]
name = "punktfunk-client-windows"
version = "0.8.1"
version = "0.9.0"
dependencies = [
"anyhow",
"async-channel",
@@ -2953,6 +3056,7 @@ dependencies = [
"ffmpeg-next",
"mdns-sd",
"opus",
"pf-client-core",
"punktfunk-core",
"sdl3",
"serde",
@@ -2968,7 +3072,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.8.1"
version = "0.9.0"
dependencies = [
"aes-gcm",
"bytes",
@@ -2999,7 +3103,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.8.1"
version = "0.9.0"
dependencies = [
"aes",
"aes-gcm",
@@ -3071,7 +3175,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.8.1"
version = "0.9.0"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3085,7 +3189,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.8.1"
version = "0.9.0"
dependencies = [
"anyhow",
"ksni",
@@ -3370,6 +3474,41 @@ dependencies = [
"tokio",
]
[[package]]
name = "relm4"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6420f090f0545e9ec9656469d139a4e1b66ff9c30b808fe2247892724f71a198"
dependencies = [
"flume",
"fragile",
"futures",
"gtk4",
"libadwaita",
"once_cell",
"relm4-css",
"relm4-macros",
"tokio",
"tracing",
]
[[package]]
name = "relm4-css"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3b81d263f784b103c815afa29124486b59741eca069ce7a5999efb14f13c368"
[[package]]
name = "relm4-macros"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36c9dbf50a60c82375e66b61d522c936b187a11b25c0a42e91c516326ad24a4f"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -3690,6 +3829,7 @@ version = "0.6.6+SDL-3.4.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04e7f134def04ed72e6f55187c6c29c72f7dab5d359c4be0dd49c9b97fef59c7"
dependencies = [
"ash",
"cc",
"cmake",
"pkg-config",
@@ -3812,6 +3952,15 @@ dependencies = [
"syn",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
@@ -3897,6 +4046,35 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]]
name = "skia-bindings"
version = "0.87.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "704242769235d2ffe66a2a0a3002661262fc4af08d32807c362d7b0160ee703c"
dependencies = [
"bindgen",
"cc",
"flate2",
"heck",
"lazy_static",
"pkg-config",
"regex",
"serde_json",
"tar",
"toml 0.8.23",
]
[[package]]
name = "skia-safe"
version = "0.87.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f7d94f3e7537c71ad4cf132eb26e3be8c8a886ed3649c4525c089041fc312b2"
dependencies = [
"bitflags",
"lazy_static",
"skia-bindings",
]
[[package]]
name = "slab"
version = "0.4.12"
@@ -4032,6 +4210,17 @@ dependencies = [
"version-compare",
]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "target-lexicon"
version = "0.13.5"
@@ -4217,6 +4406,18 @@ dependencies = [
"tokio",
]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_edit 0.22.27",
]
[[package]]
name = "toml"
version = "0.9.12+spec-1.1.0"
@@ -4225,7 +4426,7 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"serde_spanned 1.1.1",
"toml_datetime 0.7.5+spec-1.1.0",
"toml_parser",
"toml_writer",
@@ -4240,13 +4441,22 @@ checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"serde_spanned 1.1.1",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 1.0.3",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
@@ -4265,6 +4475,20 @@ dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_write",
"winnow 0.7.15",
]
[[package]]
name = "toml_edit"
version = "0.25.12+spec-1.1.0"
@@ -4286,6 +4510,12 @@ dependencies = [
"winnow 1.0.3",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "toml_writer"
version = "1.1.1+spec-1.1.0"
@@ -5329,6 +5559,9 @@ name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]]
name = "winnow"
@@ -5388,6 +5621,16 @@ dependencies = [
"time",
]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "xkbcommon"
version = "0.8.0"
+19 -2
View File
@@ -5,19 +5,36 @@ members = [
"crates/punktfunk-host",
"crates/punktfunk-host/vendor/usbip-sim",
"crates/punktfunk-tray",
"crates/pf-client-core",
"crates/pf-presenter",
"crates/pf-console-ui",
"crates/pf-ffvk",
"crates/pf-driver-proto",
"clients/probe",
"clients/linux",
"clients/session",
"clients/windows",
"clients/android/native",
"tools/latency-probe",
"tools/loss-harness",
]
# Standalone PoC (built on its own; pulls usbip/tokio/libusb we don't want in the workspace).
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
# The vendored `ndk` is a [patch.crates-io] source, not a member: it only compiles for the
# `*-linux-android` targets, so workspace membership would break host `cargo build --workspace`.
exclude = [
"packaging/linux/steam-deck-gadget/usbip-poc",
"clients/android/native/vendor/ndk",
]
# ndk 0.9.0 verbatim from crates.io plus ONE visibility change: `MediaCodec::as_ptr` made public
# (upstream keeps it private and exposes no frame-rendered binding), so the Android client can
# call `AMediaCodec_setOnFrameRenderedCallback` via ndk-sys for the HUD's `display` stage
# (design/stats-unification.md). Drop the patch when upstream exposes the pointer or the callback.
[patch.crates-io]
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.8.1"
version = "0.9.0"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
+1 -1
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.8.1"
"version": "0.9.0"
},
"paths": {
"/api/v1/clients": {
+2
View File
@@ -27,6 +27,8 @@ RUN dnf -y install \
mesa-libGL-devel mesa-libgbm-devel \
# punktfunk-client link deps (GTK4 shell + SDL3 gamepads)
gtk4-devel libadwaita-devel SDL3-devel \
# pf-ffvk bindgen over libavutil/hwcontext_vulkan.h needs <vulkan/vulkan.h>
vulkan-headers \
&& dnf clean all
# bun — both the BUILD tool and the RUNTIME for the punktfunk-web console (`bun run build` -> the
+2
View File
@@ -22,6 +22,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libgl-dev libegl-dev libgbm-dev \
# punktfunk-client-linux (GTK4/libadwaita shell, SDL3 gamepads)
libgtk-4-dev libadwaita-1-dev libsdl3-dev \
# pf-ffvk (bindgen over libavutil/hwcontext_vulkan.h needs <vulkan/vulkan.h>)
libvulkan-dev \
&& rm -rf /var/lib/apt/lists/*
# bun — builds the punktfunk-web console in deb.yml (which runs the web build in THIS image).
+6 -2
View File
@@ -11,7 +11,7 @@ plugins {
android {
namespace = "io.unom.punktfunk"
compileSdk = 37 // Android 17 — required by androidx.core 1.19.0; targetSdk stays 36 for now.
compileSdk = 37 // Android 17 — required by androidx.core 1.19.0.
defaultConfig {
// Load from .env if it exists (local dev), otherwise from System.getenv (CI)
@@ -26,7 +26,11 @@ android {
// the handful of API 31+ APIs we use are runtime-gated (Material You → brand palette, rumble
// → legacy Vibrator, NEARBY_WIFI/lights/ADPF already gated), so nothing is lost above 28.
minSdk = 28
targetSdk = 36
// Android 17: targeting 37 makes Local Network Protection MANDATORY — all LAN traffic (the
// QUIC dial, mDNS, WoL, the library fetch) is blocked until the user grants the
// ACCESS_LOCAL_NETWORK runtime permission. ConnectScreen owns that request/rationale flow;
// don't bump past 37 without re-checking the next release's behavior changes.
targetSdk = 37
val vCode = (props.getProperty("VERSION_CODE") ?: System.getenv("VERSION_CODE"))
versionCode = vCode?.toInt() ?: 1
// versionName is the single project version, threaded from CI (a vX.Y.Z release or a
@@ -13,8 +13,15 @@
reception needs it (also an OEM Wi-Fi power-save hedge). -->
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- Enforced from Android 17 (SDK 37) for ALL local-network traffic incl. the QUIC socket.
Harmless to declare on earlier releases. -->
<!-- WifiLock.acquire() ENFORCES this (a normal permission, granted at install). Without it the
stream's Wi-Fi locks throw SecurityException and power save stays on: downlink delivery
clumps at beacon intervals — hundreds of ms of latency mush + periodic whole-frame loss.
Its absence went unnoticed for weeks because the acquire was wrapped in a silent
runCatching (now logged). -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Enforced from Android 17 (SDK 37, our targetSdk) for ALL local-network traffic incl. the
QUIC socket — a RUNTIME permission (NEARBY_DEVICES group): ConnectScreen requests it on
entry and gates every dial/wake on the grant. Harmless to declare on earlier releases. -->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<!-- Mic uplink to the host's virtual microphone (requested at runtime). -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
@@ -155,6 +155,35 @@ internal fun TrustNewHostDialog(
)
}
/**
* Android 17+ Local Network Protection rationale: ACCESS_LOCAL_NETWORK was denied, so discovery and
* every connect are dead — offer the system prompt again and a settings deep link (a permanently-
* denied request returns instantly without ever showing the prompt, so "Allow" alone isn't enough).
*/
@Composable
internal fun LocalNetworkDialog(onAllow: () -> Unit, onSettings: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Allow local network access") },
text = {
Text(
"Android blocks punktfunk from talking to devices on your network, so it can't " +
"find or reach any host until you allow it. If no prompt appears when you tap " +
"Allow, enable “Nearby devices” for punktfunk in system settings.",
)
},
confirmButton = {
TextButton(onClick = onAllow) { Text("Allow") }
},
dismissButton = {
Row {
TextButton(onClick = onSettings) { Text("Open settings") }
TextButton(onClick = onDismiss) { Text("Not now") }
}
},
)
}
/** The pinned fingerprint no longer matches — force re-pairing (never a silent re-trust). */
@Composable
internal fun FingerprintChangedDialog(
@@ -2,7 +2,9 @@ package io.unom.punktfunk
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -30,6 +32,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -37,6 +40,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -44,6 +48,9 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import io.unom.punktfunk.components.EmptyHostsState
import io.unom.punktfunk.components.HostCard
import io.unom.punktfunk.components.SectionLabel
@@ -60,6 +67,7 @@ import io.unom.punktfunk.models.HostStatus
import io.unom.punktfunk.models.PendingTrust
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -111,14 +119,57 @@ fun ConnectScreen(
// denial used to leave discovery dead forever.
val discovery = remember { HostDiscovery(context) }
var discovered by remember { mutableStateOf<List<DiscoveredHost>>(emptyList()) }
// Android 17 Local Network Protection: with targetSdk 37, EVERYTHING this screen does — the mDNS
// browse, the QUIC dial (UDP 9777), Wake-on-LAN, the library fetch — is blocked until the user
// grants ACCESS_LOCAL_NETWORK (a runtime permission in the NEARBY_DEVICES group). Blocked UDP
// fails with EPERM, which quinn experiences as a silent handshake timeout — so without this gate
// a denial looks exactly like a dead host. Unlike NEARBY_WIFI_DEVICES below, this one is
// load-bearing: request it on entry, and surface a denial as an actionable dialog/banner (with a
// system-settings deep link) instead of dead-ending on timeouts.
var lnpGranted by remember { mutableStateOf(hasLocalNetworkPermission(context)) }
var lnpPrompt by remember { mutableStateOf(false) }
val localNetLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
lnpGranted = granted
if (granted) {
lnpPrompt = false
// The browse started while blocked (its sockets failed or received nothing) — restart it
// now that the grant makes them work.
discovery.stop()
discovery.start()
} else {
lnpPrompt = true // rationale + "Open settings" (a permanently-denied request returns instantly)
}
}
val nearbyLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { _ -> /* best-effort hint; discovery runs regardless of the result */ }
LaunchedEffect(Unit) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !hasNearbyPermission(context)) {
if (!lnpGranted) {
localNetLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !hasNearbyPermission(context)) {
// The old opportunistic multicast hedge (some OEMs filter multicast without it). On API
// 37+ it shares the NEARBY_DEVICES group with ACCESS_LOCAL_NETWORK, so once that is
// granted this auto-grants without a second prompt.
nearbyLauncher.launch(Manifest.permission.NEARBY_WIFI_DEVICES)
}
}
// Re-check on resume: our dialog deep-links to system settings, and granting there doesn't kill
// or otherwise notify the app — this observer is what turns the grant into a live discovery.
DisposableEffect(Unit) {
val lifecycle = (context as? LifecycleOwner)?.lifecycle
val obs = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME && !lnpGranted && hasLocalNetworkPermission(context)) {
lnpGranted = true
lnpPrompt = false
discovery.stop()
discovery.start()
}
}
lifecycle?.addObserver(obs)
onDispose { lifecycle?.removeObserver(obs) }
}
DisposableEffect(Unit) {
discovery.onChange = { discovered = it }
discovery.start()
@@ -154,6 +205,29 @@ fun ConnectScreen(
}
if (learned) savedHosts = knownHostStore.all()
}
// Saved hosts proven reachable by a QUIC probe this cycle, keyed "address:port" — the
// routed-network (Tailscale/VPN) counterpart to mDNS presence, since such hosts never
// advertise. OR'd into `isOnline` below so their pips light up. Probe only saved hosts NOT
// already seen on mDNS, off the main thread, every ~12 s; gated on LNP (blocked UDP would
// just time out). `rememberUpdatedState` keeps the 1 Hz mDNS updates from restarting the loop.
var reachable by remember { mutableStateOf<Set<String>>(emptySet()) }
val discoveredNow by rememberUpdatedState(discovered)
LaunchedEffect(savedHosts, lnpGranted) {
if (!lnpGranted) {
reachable = emptySet()
return@LaunchedEffect
}
while (true) {
val targets = savedHosts.filter { kh -> discoveredNow.none { kh.matches(it) } }
reachable = withContext(Dispatchers.IO) {
targets
.filter { NativeBridge.nativeProbe(it.address, it.port, 3_000) }
.map { "${it.address}:${it.port}" }
.toSet()
}
delay(12_000)
}
}
// Mint-once on genuine first run; an Unrecoverable store (decrypt failure) surfaces here and
// refuses to connect — never silently shadow-minting a new identity (which would force re-pair).
var identity by remember { mutableStateOf<ClientIdentity?>(null) }
@@ -185,8 +259,10 @@ fun ConnectScreen(
// The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint
// the host presented (as an unpaired known host) so the next connect goes straight through and it
// appears in the saved-hosts list.
fun doConnectDirect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
// appears in the saved-hosts list. [onFailure], when set, takes over a failed dial (the wake-wait
// fallback) instead of the error status line — discovery is already restarted when it runs, so
// the wait can observe the host reappear.
fun doConnectDirect(targetHost: String, targetPort: Int, name: String, pinHex: String?, onFailure: (() -> Unit)? = null) {
val id = identity ?: run {
status = "Identity not ready yet — try again in a moment"
return
@@ -206,18 +282,25 @@ fun ConnectScreen(
}
onConnected(handle)
} else {
status = "Connection failed — check host/port, PIN, and logcat"
discovery.start()
if (onFailure != null) {
status = ""
onFailure()
} else {
status = "Connection failed — check host/port, PIN, and logcat"
}
}
}
}
// Wake-aware connect. If auto-wake is on (Settings.autoWakeEnabled) and the target is a saved
// host with a learned MAC that ISN'T currently advertising (asleep/off, or just missing from
// mDNS), wake it and WAIT for it to reappear on mDNS (WakeController shows the "Waking…" overlay)
// before dialing — discovery stays running meanwhile so we can see it come back. A fire-and-forget
// packet + the connect timeout wasn't enough for a cold boot. Otherwise (auto-wake off, no MAC, or
// already seen live) dial straight through.
// host with a learned MAC that ISN'T currently advertising, fire a wake packet and DIAL
// IMMEDIATELY — mDNS absence does NOT mean unreachable (a host reached over a routed network —
// Tailscale/VPN/another subnet — is mDNS-blind forever, and gating the dial on presence bricked
// exactly those reconnects). A genuinely-asleep box is already booting while the dial times out;
// only a FAILED dial falls into the wake-and-WAIT-for-mDNS flow (WakeController's "Waking…"
// overlay), which redials once the host reappears. Otherwise (auto-wake off, no MAC, or already
// seen live) dial straight through.
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
if (identity == null) {
status = "Identity not ready yet — try again in a moment"
@@ -232,23 +315,28 @@ fun ConnectScreen(
if (kh != null) discovered.firstOrNull { kh.matches(it) }
else discovered.firstOrNull { it.host == targetHost && it.port == targetPort }
if (settings.autoWakeEnabled && macs.isNotEmpty() && liveAdvert() == null) {
waker.start(
hostName = name,
connectsAfter = true,
macs = macs,
lastIp = targetHost,
isOnline = { liveAdvert() != null },
onOnline = {
val live = liveAdvert()
// Woke back on a new address? Re-key the saved record so it (and future connects)
// point at the live one, then dial there.
if (live != null && kh != null && (live.host != kh.address || live.port != kh.port)) {
knownHostStore.update(kh.address, kh.port, kh.copy(address = live.host, port = live.port))
savedHosts = knownHostStore.all()
}
doConnectDirect(live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex)
},
)
// Fire-and-forget first packet (harmless if it's awake), then dial-first.
scope.launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(macs.joinToString(","), targetHost) }
doConnectDirect(targetHost, targetPort, name, pinHex, onFailure = {
waker.start(
hostName = name,
connectsAfter = true,
macs = macs,
lastIp = targetHost,
isOnline = { liveAdvert() != null },
onOnline = {
val live = liveAdvert()
// Woke back on a new address? Re-key the saved record so it (and future
// connects) point at the live one, then dial there (no fallback on this
// redial — a second failure surfaces as the plain error).
if (live != null && kh != null && (live.host != kh.address || live.port != kh.port)) {
knownHostStore.update(kh.address, kh.port, kh.copy(address = live.host, port = live.port))
savedHosts = knownHostStore.all()
}
doConnectDirect(live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex)
},
)
})
} else {
doConnectDirect(targetHost, targetPort, name, pinHex)
}
@@ -311,6 +399,12 @@ fun ConnectScreen(
dh: DiscoveredHost? = null,
manualName: String? = null,
) {
// Every dial/pair path funnels through here — with local network access denied the connect
// can only EPERM its way to a 10 s timeout, so ask instead of pretending to try.
if (!lnpGranted) {
lnpPrompt = true
return
}
val known = knownHostStore.get(targetHost, targetPort)
val adv = dh?.fingerprint?.lowercase()
// Label precedence: a saved host keeps its (possibly user-renamed) name; else the discovered
@@ -347,7 +441,7 @@ fun ConnectScreen(
title = kh.name,
subtitle = "${kh.address}:${kh.port}",
filled = true,
online = discovered.any { it.host == kh.address && it.port == kh.port },
online = kh.isOnline(discovered, reachable),
paired = kh.paired,
knownHost = kh,
activate = { connect(kh.address, kh.port) },
@@ -384,7 +478,8 @@ fun ConnectScreen(
// handle — the touch grid guards the same way with enabled=!connecting), or while the whole
// console home is cross-fading out.
navActive = navGate && !connecting && !showManualSheet && pendingTrust == null &&
awaiting == null && editTarget == null && optionsTarget == null && waker.waking == null,
awaiting == null && editTarget == null && optionsTarget == null &&
waker.waking == null && !lnpPrompt,
onActivate = { it.activate() },
onOpenLibrary = { it.knownHost?.let(onOpenLibrary) },
onOpenSettings = onOpenSettings,
@@ -451,6 +546,38 @@ fun ConnectScreen(
}
}
if (!lnpGranted) {
// Local network access denied: discovery can't ever find anything and every connect
// would time out — say so at the top, with the fix one tap away, instead of letting
// the screen look idle/broken.
item(span = { GridItemSpan(maxLineSpan) }) {
Surface(
color = MaterialTheme.colorScheme.errorContainer,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
) {
Column(
Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"Local network access is off",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Text(
"Android blocks punktfunk from finding or reaching hosts until you allow it.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
textAlign = TextAlign.Center,
)
TextButton(onClick = { lnpPrompt = true }) { Text("Allow…") }
}
}
Spacer(Modifier.height(12.dp))
}
}
if (savedHosts.isEmpty() && discoveredUnsaved.isEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
EmptyHostsState()
@@ -466,6 +593,7 @@ fun ConnectScreen(
name = kh.name,
address = "${kh.address}:${kh.port}",
status = if (kh.paired) HostStatus.PAIRED else HostStatus.TOFU,
online = kh.isOnline(discovered, reachable),
enabled = !connecting,
onConnect = { connect(kh.address, kh.port) },
onForget = {
@@ -477,16 +605,21 @@ fun ConnectScreen(
// through the WakeController so it shows the "Waking…" overlay and waits for
// the host to come online (matched by fingerprint, so a new DHCP address on a
// cold boot still counts as "up") rather than firing a single silent packet.
onWake = if (kh.mac.isNotEmpty() && discovered.none { kh.matches(it) }) {
onWake = if (kh.mac.isNotEmpty() && !kh.isOnline(discovered, reachable)) {
{
waker.start(
hostName = kh.name,
connectsAfter = false,
macs = kh.mac,
lastIp = kh.address,
isOnline = { discovered.any { kh.matches(it) } },
onOnline = {},
)
// The magic packet is UDP broadcast — LNP-blocked like everything else.
if (!lnpGranted) {
lnpPrompt = true
} else {
waker.start(
hostName = kh.name,
connectsAfter = false,
macs = kh.mac,
lastIp = kh.address,
isOnline = { discovered.any { kh.matches(it) } },
onOnline = {},
)
}
}
} else {
null
@@ -505,6 +638,7 @@ fun ConnectScreen(
name = dh.name,
address = "${dh.host}:${dh.port}",
status = if (dh.pairingRequired) HostStatus.PAIRING else HostStatus.TOFU,
online = true, // in the discovered list ⇒ live on mDNS right now
enabled = !connecting,
onConnect = { connect(dh.host, dh.port, dh) },
onForget = null,
@@ -514,8 +648,10 @@ fun ConnectScreen(
// Active-discovery hint: discovery runs whenever this screen is up, so while it's
// scanning but nothing's turned up yet (and we're not mid-connect), show it's working
// rather than looking idle/empty.
if (!connecting && discovered.isEmpty()) {
// rather than looking idle/empty. Suppressed while local network access is denied —
// a spinner would be a lie there (the browse can't receive anything); the banner above
// owns that state.
if (lnpGranted && !connecting && discovered.isEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
@@ -615,17 +751,22 @@ fun ConnectScreen(
// Console host options (Up on a saved carousel tile): Wake / Edit / Forget.
optionsTarget?.let { kh ->
val offline = discovered.none { kh.matches(it) }
val offline = !kh.isOnline(discovered, reachable)
GamepadHostOptionsDialog(
hostName = kh.name,
canWake = kh.mac.isNotEmpty() && offline,
onWake = {
optionsTarget = null
waker.start(
hostName = kh.name, connectsAfter = false, macs = kh.mac, lastIp = kh.address,
isOnline = { discovered.any { kh.matches(it) } },
onOnline = {},
)
// The magic packet is UDP broadcast — LNP-blocked like everything else.
if (!lnpGranted) {
lnpPrompt = true
} else {
waker.start(
hostName = kh.name, connectsAfter = false, macs = kh.mac, lastIp = kh.address,
isOnline = { discovered.any { kh.matches(it) } },
onOnline = {},
)
}
},
// A saved host always has a library (it's a knownHost) → offer it when the setting's on,
// so a TV remote reaches the library here instead of via the Y face button.
@@ -673,6 +814,29 @@ fun ConnectScreen(
}
}
if (lnpPrompt) {
// Android 17+ local-network-permission rationale: re-request (a permanently-denied request
// returns instantly without a system prompt — hence the settings deep link alongside).
val onAllow = {
lnpPrompt = false
localNetLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
}
val onSettings = {
lnpPrompt = false
context.startActivity(
Intent(
android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null),
),
)
}
if (gamepadUi) {
GamepadLocalNetworkDialog(onAllow = onAllow, onSettings = onSettings, onDismiss = { lnpPrompt = false })
} else {
LocalNetworkDialog(onAllow = onAllow, onSettings = onSettings, onDismiss = { lnpPrompt = false })
}
}
// Topmost: the "Waking…" overlay rides over both the touch grid and the console home.
WakeOverlay(waker, gamepadUi)
}
@@ -687,6 +851,18 @@ fun hasNearbyPermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.NEARBY_WIFI_DEVICES) ==
PackageManager.PERMISSION_GRANTED
/**
* Whether ACCESS_LOCAL_NETWORK is held (API 37+; below, the permission doesn't exist and local
* network access is implicit). Android 17's Local Network Protection blocks ALL local-network
* traffic for apps targeting SDK 37 without this runtime grant: UDP sends fail with EPERM, so the
* QUIC dial surfaces as a silent handshake timeout and the mDNS browse receives nothing. Unlike
* [hasNearbyPermission] this is load-bearing — nothing on the connect screen works without it.
*/
fun hasLocalNetworkPermission(context: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.CINNAMON_BUN ||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_LOCAL_NETWORK) ==
PackageManager.PERMISSION_GRANTED
/**
* True when a saved host and a discovered advert are the same machine — matched by certificate
* fingerprint when both carry it (so it survives a DHCP address change), else by address:port.
@@ -697,3 +873,11 @@ private fun KnownHost.matches(dh: DiscoveredHost): Boolean {
if (!advFp.isNullOrEmpty() && fpHex.isNotEmpty() && fpHex.lowercase() == advFp) return true
return address == dh.host && port == dh.port
}
/**
* True when a saved host is reachable RIGHT NOW: advertising on mDNS OR answering the QUIC probe
* (a host reached over a routed network — Tailscale/VPN — never advertises but is reachable). The
* display-side companion to dial-first: presence no longer means "on this LAN".
*/
private fun KnownHost.isOnline(discovered: List<DiscoveredHost>, reachable: Set<String>): Boolean =
discovered.any { matches(it) } || reachable.contains("$address:$port")
@@ -2,7 +2,8 @@ package io.unom.punktfunk
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -361,15 +362,15 @@ private fun rowCols(row: Int): Int = if (row < KB_ACTIONS_ROW) KB_CHAR_ROWS[row]
@Composable
private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -> Unit) {
val scale by animateFloatAsState(if (focused || editing) 1f else 0.98f, label = "fieldScale")
val visuals = animateConsoleFocus(active = focused || editing, editing = editing)
val shape = RoundedCornerShape(14.dp)
Row(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer { scaleX = scale; scaleY = scale }
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
.clip(shape)
.background(if (focused || editing) Color(0x336656F2) else Color(0x14FFFFFF))
.border(1.dp, if (editing) Color(0xB38678F5) else Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape)
.background(visuals.background)
.border(1.dp, visuals.border, shape)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -389,15 +390,20 @@ private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -
@Composable
private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onClick: () -> Unit) {
val scale by animateFloatAsState(if (focused) 1f else 0.98f, label = "addScale")
val visuals = animateConsoleFocus(active = focused)
val shape = RoundedCornerShape(14.dp)
val labelColor by animateColorAsState(
if (enabled) Color(0xFF8678F5) else Color.White.copy(alpha = 0.35f),
tween(160),
label = "addLabel",
)
Box(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer { scaleX = scale; scaleY = scale }
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
.clip(shape)
.background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF))
.border(1.dp, Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape)
.background(visuals.background)
.border(1.dp, visuals.border, shape)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
.padding(vertical = 14.dp),
contentAlignment = Alignment.Center,
@@ -406,7 +412,7 @@ private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onCl
label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
color = if (enabled) Color(0xFF8678F5) else Color.White.copy(alpha = 0.35f),
color = labelColor,
)
}
}
@@ -448,11 +454,19 @@ private fun KeyboardGrid(
@Composable
private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
// Fast tweens: the keyboard cursor hops many keys per second under hold-to-repeat, so the
// trailing key must have faded before the cursor is two keys away — quick, but no longer a snap.
val bg by animateColorAsState(
if (focused) Color(0xFF8678F5) else Color(0x14FFFFFF),
tween(90),
label = "keyBg",
)
val fg by animateColorAsState(if (focused) Color.Black else Color.White, tween(90), label = "keyFg")
Box(
modifier = modifier
.height(if (compact) 34.dp else 44.dp)
.clip(RoundedCornerShape(9.dp))
.background(if (focused) Color(0xFF8678F5) else Color(0x14FFFFFF))
.background(bg)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick),
contentAlignment = Alignment.Center,
) {
@@ -460,7 +474,7 @@ private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier:
label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = if (focused) Color.Black else Color.White,
color = fg,
textAlign = TextAlign.Center,
)
}
@@ -1,10 +1,14 @@
package io.unom.punktfunk
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
@@ -15,6 +19,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -31,20 +36,28 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import io.unom.punktfunk.kit.Gamepad
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.max
import kotlin.math.roundToInt
import kotlin.math.sin
// The console chrome shared by the gamepad-driven screens — the Android mirror of the Apple client's
@@ -189,9 +202,12 @@ fun ConsoleHeader(title: String, modifier: Modifier = Modifier, horizontalInset:
}
/**
* One glyph + label cell of a hint bar. [glyph] is the face letter; [color] its Xbox-convention hue.
* [onClick], when set, makes the cell tappable — a TOUCH escape hatch so a user without a working
* controller can still drive the console UI (and reach Settings to switch it off).
* One glyph + label cell of a hint bar. [glyph] is the SEMANTIC face letter (the Android
* `KEYCODE_BUTTON_*` name — 'A' = confirm/south); [color] its Xbox-convention hue. How the pair is
* actually DRAWN is the hint bar's decision, per the driving controller's [Gamepad.PadStyle] — a
* DualSense renders 'A' as the ✕ shape, a Switch pad as a monochrome letter. [onClick], when set,
* makes the cell tappable — a TOUCH escape hatch so a user without a working controller can still
* drive the console UI (and reach Settings to switch it off).
*/
class GamepadHint(
val glyph: Char,
@@ -201,11 +217,16 @@ class GamepadHint(
// Render as the D-pad-centre "select" button (a ring) instead of a lettered face-button disc —
// for a TV remote, which has no A/B/X/Y.
val select: Boolean = false,
// Render as the gamepad Select/View button (a small capsule).
// Render as the pad's physical Select/View/Create/ button (per PadStyle) — the button that
// delivers KEYCODE_BUTTON_SELECT.
val viewButton: Boolean = false,
)
/** Xbox-convention face-button colours, so the glyphs read at a glance across the room. */
/**
* Xbox-convention face-button colours, so the glyphs read at a glance across the room. These are
* the DEFAULT (Xbox/generic) rendering; the hint bar swaps in PlayStation shapes or Nintendo
* monochrome per the driving pad's [Gamepad.PadStyle] at draw time.
*/
object PadGlyph {
val A = Color(0xFF6BBE45)
val B = Color(0xFFD14B4B)
@@ -216,6 +237,87 @@ object PadGlyph {
)
}
/** The dark button-face fill shared by the PlayStation / Nintendo / select-button badges. */
internal val PadButtonFace = Color(0xFF2A2740)
/** The animated focus visuals of one console row/field/button — see [animateConsoleFocus]. */
class ConsoleFocusVisuals(val scale: Float, val background: Color, val border: Color)
/**
* The focus visuals every console form element shares (settings rows, add-host fields, action
* rows), ANIMATED: the background/border cross-fade instead of snapping between the focused and
* resting looks, and the scale pops on a soft spring. [editing] draws the brighter violet border
* of a field actively receiving keyboard input.
*/
@Composable
fun animateConsoleFocus(active: Boolean, editing: Boolean = false): ConsoleFocusVisuals {
val scale by animateFloatAsState(
targetValue = if (active) 1f else 0.98f,
animationSpec = spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow),
label = "consoleScale",
)
val background by animateColorAsState(
if (active) Color(0x336656F2) else Color(0x14FFFFFF),
tween(160),
label = "consoleBg",
)
val border by animateColorAsState(
when {
editing -> Color(0xB38678F5)
active -> Color.White.copy(alpha = 0.28f)
else -> Color.White.copy(alpha = 0.06f)
},
tween(160),
label = "consoleBorder",
)
return ConsoleFocusVisuals(scale, background, border)
}
/**
* The console-styled switch a toggle row renders in place of an "On"/"Off" value: a brand-violet
* track that tints as it engages while the knob slides across on a spring — the state change reads
* from across the room, and the motion confirms the press.
*/
@Composable
fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier) {
val travel by animateFloatAsState(
targetValue = if (on) 1f else 0f,
animationSpec = spring(dampingRatio = 0.8f, stiffness = 600f),
label = "switchKnob",
)
val track by animateColorAsState(
if (on) Color(0xFF6656F2) else Color(0x26FFFFFF),
tween(200),
label = "switchTrack",
)
val outline by animateColorAsState(
Color.White.copy(alpha = if (focused) 0.45f else 0.15f),
tween(160),
label = "switchOutline",
)
val trackW = 44.dp
val trackH = 24.dp
val pad = 3.dp
val knob = trackH - pad * 2
Box(
modifier
.size(trackW, trackH)
.clip(RoundedCornerShape(50))
.background(track)
.border(1.dp, outline, RoundedCornerShape(50)),
contentAlignment = Alignment.CenterStart,
) {
Box(
Modifier
.padding(horizontal = pad)
.offset { IntOffset(((trackW - knob - pad * 2).toPx() * travel).roundToInt(), 0) }
.size(knob)
.clip(CircleShape)
.background(Color.White),
)
}
}
/** A round face-button badge: a coloured disc with the button letter, like a controller's face. */
@Composable
fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit.Dp = 26.dp) {
@@ -253,16 +355,94 @@ private fun BackGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
GamepadButtonGlyph('↩', PadGlyph.B, size)
}
/** The gamepad "Select / View" button — a small capsule outline, matching its physical shape. */
/**
* A PlayStation face button: the dark button face with the coloured shape outline Sony prints on it.
* Keyed by the SEMANTIC letter (Android keycode name): A = ✕ cross, B = ○ circle, X = □ square,
* Y = △ triangle — exactly how a Sony pad's buttons map to `KEYCODE_BUTTON_*`, in the classic
* DualShock colours.
*/
@Composable
private fun ViewButtonGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
Box(Modifier.size(size), contentAlignment = Alignment.Center) {
Box(
Modifier
.size(width = size * 0.74f, height = size * 0.46f)
.clip(RoundedCornerShape(50))
.border(1.6.dp, Color.White.copy(alpha = 0.85f), RoundedCornerShape(50)),
)
internal fun PsFaceGlyph(glyph: Char, size: androidx.compose.ui.unit.Dp = 26.dp) {
val color = when (glyph) {
'A' -> Color(0xFF7C9CE8) // cross — light blue
'B' -> Color(0xFFE0736F) // circle — red
'X' -> Color(0xFFD48FC7) // square — pink
else -> Color(0xFF5FBFA5) // triangle — green
}
Box(
Modifier.size(size).clip(CircleShape).background(PadButtonFace),
contentAlignment = Alignment.Center,
) {
Canvas(Modifier.size(size * 0.46f)) {
val w = this.size.minDimension
val stroke = Stroke(width = w * 0.17f, cap = StrokeCap.Round, join = StrokeJoin.Round)
when (glyph) {
'A' -> { // ✕ — the two diagonals
drawLine(color, Offset(0f, 0f), Offset(w, w), stroke.width, StrokeCap.Round)
drawLine(color, Offset(w, 0f), Offset(0f, w), stroke.width, StrokeCap.Round)
}
'B' -> drawCircle(color, radius = (w - stroke.width) / 2f, style = stroke)
'X' -> drawRect(
color,
topLeft = Offset(stroke.width / 2f, stroke.width / 2f),
size = Size(w - stroke.width, w - stroke.width),
style = stroke,
)
else -> { // △
val p = Path().apply {
moveTo(w / 2f, stroke.width / 2f)
lineTo(w - stroke.width / 2f, w - stroke.width / 2f)
lineTo(stroke.width / 2f, w - stroke.width / 2f)
close()
}
drawPath(p, color, style = stroke)
}
}
}
}
}
/**
* The pad's physical Select-family button — the one that delivers `KEYCODE_BUTTON_SELECT` and opens
* Options — drawn per [Gamepad.PadStyle] as a badge with the button's real face: Xbox View (two
* overlapping windows), PlayStation Create/Share (a slim capsule), Nintendo (minus). The generic
* fallback wears the capsule too (the near-universal select shape).
*/
@Composable
internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.ui.unit.Dp = 26.dp) {
Box(
Modifier.size(size).clip(CircleShape).background(PadButtonFace),
contentAlignment = Alignment.Center,
) {
when (style) {
Gamepad.PadStyle.XBOX -> Box(Modifier.size(size * 0.50f)) {
// The View icon: two overlapping outlined windows; the front one is filled with the
// button face so it visibly occludes the back one.
val corner = RoundedCornerShape(2.dp)
Box(
Modifier.size(size * 0.32f).align(Alignment.TopEnd)
.border(1.4.dp, Color.White.copy(alpha = 0.9f), corner),
)
Box(
Modifier.size(size * 0.32f).align(Alignment.BottomStart)
.clip(corner).background(PadButtonFace)
.border(1.4.dp, Color.White.copy(alpha = 0.9f), corner),
)
}
Gamepad.PadStyle.NINTENDO -> Text(
"",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = (size.value * 0.62f).sp,
textAlign = TextAlign.Center,
)
else -> Box(
Modifier
.size(width = size * 0.58f, height = size * 0.30f)
.clip(RoundedCornerShape(50))
.border(1.6.dp, Color.White.copy(alpha = 0.9f), RoundedCornerShape(50)),
)
}
}
}
@@ -274,8 +454,12 @@ private fun ViewButtonGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, hazeState: HazeState? = null) {
// On a TV D-pad remote (no A/B/X/Y), auto-swap the two universal pad glyphs every screen uses:
// A (confirm) → the select ring, B (back/cancel) → a back glyph. Screen-specific glyphs like the
// home's Up/Down handle themselves. Defaults to the gamepad look off an Activity (preview/tests).
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
// home's Up/Down handle themselves. A real pad instead picks its glyph FAMILY (Xbox letters /
// PlayStation shapes / Nintendo monochrome) from the controller that last drove the UI.
// Defaults to the generic gamepad look off an Activity (preview/tests).
val activity = LocalContext.current as? MainActivity
val padIsGamepad = activity?.lastPadIsGamepad ?: true
val padStyle = activity?.lastPadStyle ?: Gamepad.PadStyle.GENERIC
val shape = RoundedCornerShape(50)
// With a haze source, blur the content behind the pill (real backdrop blur, API 31+; a translucent
// scrim below) + a light tint; otherwise fall back to a solid frosted fill.
@@ -300,9 +484,13 @@ fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, haze
}
Row(modifier = cell, verticalAlignment = Alignment.CenterVertically) {
when {
h.viewButton -> ViewButtonGlyph()
h.viewButton -> SelectButtonGlyph(padStyle)
h.select || (!padIsGamepad && h.glyph == 'A') -> SelectGlyph()
!padIsGamepad && h.glyph == 'B' -> BackGlyph()
padStyle == Gamepad.PadStyle.PLAYSTATION && h.glyph in "ABXY" ->
PsFaceGlyph(h.glyph)
padStyle == Gamepad.PadStyle.NINTENDO && h.glyph in "ABXY" ->
GamepadButtonGlyph(h.glyph, PadButtonFace)
else -> GamepadButtonGlyph(h.glyph, h.color)
}
Spacer(Modifier.width(6.dp))
@@ -2,7 +2,12 @@ package io.unom.punktfunk
import android.os.Build
import androidx.activity.compose.BackHandler
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -19,6 +24,8 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
@@ -26,6 +33,7 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
@@ -90,8 +98,11 @@ fun GamepadDialog(
},
onActivate = { actions.getOrNull(focus)?.takeIf { it.enabled }?.onClick?.invoke() },
)
// Cap the card to most of the screen and let the BODY scroll — in a short landscape window the
// title + body + buttons would otherwise overflow and compress/clip the bottom button.
// Cap the card to most of the screen and let body + BUTTONS scroll together — in a short
// landscape window a 5-action stack (host options) exceeds the card even with an empty body, and
// a pinned actions column can only compress/clip its last button. Only the title stays pinned;
// the focused button pulls itself into view (see DialogButton), so D-pad navigation always shows
// the current action even when the stack scrolls.
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)),
@@ -109,43 +120,66 @@ fun GamepadDialog(
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White)
// The body scrolls; the title above and the buttons below stay pinned + always visible.
Column(
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
body()
}
Spacer(Modifier.size(4.dp))
actions.forEachIndexed { i, a ->
DialogButton(a.label, focused = i == focus, primary = a.primary, enabled = a.enabled, onClick = a.onClick)
Spacer(Modifier.size(4.dp))
actions.forEachIndexed { i, a ->
DialogButton(a.label, focused = i == focus, primary = a.primary, enabled = a.enabled, onClick = a.onClick)
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enabled: Boolean, onClick: () -> Unit) {
val scale by animateFloatAsState(if (focused) 1.02f else 1f, label = "btnScale")
val scale by animateFloatAsState(
if (focused) 1.02f else 1f,
spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow),
label = "btnScale",
)
// The action stack lives inside the dialog's scroll region: when D-pad focus moves to a button
// that's scrolled out of a short window, pull it into view (no-op when already visible).
val intoView = remember { BringIntoViewRequester() }
LaunchedEffect(focused) { if (focused) intoView.bringIntoView() }
val shape = RoundedCornerShape(14.dp)
val bg = when {
focused -> Color(0xFF6656F2)
primary -> Color(0x336656F2)
else -> Color(0x14FFFFFF)
}
val fg = when {
!enabled -> Color.White.copy(alpha = 0.35f)
focused -> Color.White
primary -> Color(0xFF8678F5)
else -> Color.White.copy(alpha = 0.85f)
}
// Focus sweeps up/down the stack — cross-fade the fills so it glides instead of snapping.
val bg by animateColorAsState(
when {
focused -> Color(0xFF6656F2)
primary -> Color(0x336656F2)
else -> Color(0x14FFFFFF)
},
tween(160),
label = "btnBg",
)
val fg by animateColorAsState(
when {
!enabled -> Color.White.copy(alpha = 0.35f)
focused -> Color.White
primary -> Color(0xFF8678F5)
else -> Color.White.copy(alpha = 0.85f)
},
tween(160),
label = "btnFg",
)
val borderColor by animateColorAsState(
Color.White.copy(alpha = if (focused) 0.3f else 0.08f),
tween(160),
label = "btnBorder",
)
Box(
modifier = Modifier
.fillMaxWidth()
.bringIntoViewRequester(intoView)
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(shape)
.background(bg)
.border(1.dp, Color.White.copy(alpha = if (focused) 0.3f else 0.08f), shape)
.border(1.dp, borderColor, shape)
.clickable(
enabled = enabled,
interactionSource = remember { MutableInteractionSource() },
@@ -195,6 +229,29 @@ fun GamepadHostOptionsDialog(
}
}
/** Console counterpart of [LocalNetworkDialog] — the Android 17+ ACCESS_LOCAL_NETWORK rationale. */
@Composable
fun GamepadLocalNetworkDialog(onAllow: () -> Unit, onSettings: () -> Unit, onDismiss: () -> Unit) {
GamepadDialog(
title = "Allow local network access",
onDismiss = onDismiss,
actions = listOf(
DialogAction("Allow", primary = true, onClick = onAllow),
DialogAction("Open settings", onClick = onSettings),
DialogAction("Not now", onClick = onDismiss),
),
) {
DialogText(
"Android blocks punktfunk from talking to devices on your network, so it can't find " +
"or reach any host until you allow it.",
)
DialogText(
"If no prompt appears after Allow, enable “Nearby devices” for punktfunk in " +
"system settings.",
)
}
}
@Composable
fun GamepadTrustNewDialog(pt: PendingTrust, onTrust: () -> Unit, onPairInstead: () -> Unit, onDismiss: () -> Unit) {
GamepadDialog(
@@ -2,7 +2,19 @@ package io.unom.punktfunk
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -57,6 +69,7 @@ private class GpRow(
val detail: String,
val adjust: (Int) -> Boolean, // left/right; returns whether the value actually changed
val activate: () -> Unit, // A → cycle forward (wrapping) / flip
val toggled: Boolean? = null, // non-null = a toggle row, drawn as a ConsoleSwitch (not text)
)
@Composable
@@ -72,6 +85,9 @@ fun GamepadSettingsScreen(
val rows = buildSettingsRows(s, ::update)
var focus by remember { mutableIntStateOf(0) }
if (focus > rows.lastIndex) focus = rows.lastIndex
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
// value text slides in its AnimatedContent, so the motion matches the button press.
var adjustDir by remember { mutableIntStateOf(1) }
val listState = rememberLazyListState()
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
@@ -83,11 +99,11 @@ fun GamepadSettingsScreen(
when (dir) {
NavDir.UP -> if (focus > 0) focus--
NavDir.DOWN -> if (focus < rows.lastIndex) focus++
NavDir.LEFT -> rows.getOrNull(focus)?.adjust(-1)
NavDir.RIGHT -> rows.getOrNull(focus)?.adjust(1)
NavDir.LEFT -> { adjustDir = -1; rows.getOrNull(focus)?.adjust(-1) }
NavDir.RIGHT -> { adjustDir = 1; rows.getOrNull(focus)?.adjust(1) }
}
},
onActivate = { rows.getOrNull(focus)?.activate() },
onActivate = { adjustDir = 1; rows.getOrNull(focus)?.activate() },
)
// Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the
// screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it.
@@ -121,8 +137,8 @@ fun GamepadSettingsScreen(
ConsoleHeader("Settings", horizontalInset = false)
}
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
SettingRowView(row, focused = index == focus, onClick = {
if (focus == index) row.activate() else focus = index
SettingRowView(row, focused = index == focus, adjustDir = adjustDir, onClick = {
if (focus == index) { adjustDir = 1; row.activate() } else focus = index
})
}
}
@@ -150,9 +166,17 @@ fun GamepadSettingsScreen(
}
@Composable
private fun SettingRowView(row: GpRow, focused: Boolean, onClick: () -> Unit) {
val scale by animateFloatAsState(if (focused) 1f else 0.98f, label = "rowScale")
private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick: () -> Unit) {
val visuals = animateConsoleFocus(active = focused)
val shape = RoundedCornerShape(14.dp)
// The chevrons keep their layout slot and only fade, so the value never jumps sideways when
// focus arrives; the value colour cross-fades with them.
val chevronAlpha by animateFloatAsState(if (focused) 0.6f else 0f, tween(160), label = "chevrons")
val valueColor by animateColorAsState(
Color.White.copy(alpha = if (focused) 1f else 0.6f),
tween(160),
label = "valueColor",
)
Column {
if (row.header != null) {
Text(
@@ -166,10 +190,10 @@ private fun SettingRowView(row: GpRow, focused: Boolean, onClick: () -> Unit) {
Column(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer { scaleX = scale; scaleY = scale }
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
.clip(shape)
.background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF))
.border(1.dp, Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape)
.background(visuals.background)
.border(1.dp, visuals.border, shape)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
@@ -186,19 +210,41 @@ private fun SettingRowView(row: GpRow, focused: Boolean, onClick: () -> Unit) {
maxLines = 1,
)
Spacer(Modifier.weight(1f))
if (focused) Text(" ", color = Color.White.copy(alpha = 0.6f))
Text(
row.value,
style = MaterialTheme.typography.bodyMedium,
color = if (focused) Color.White else Color.White.copy(alpha = 0.6f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (focused) Text(" ", color = Color.White.copy(alpha = 0.6f))
if (row.toggled != null) {
// A toggle is a switch, not text — the sliding knob + tinting track IS the value.
ConsoleSwitch(on = row.toggled, focused = focused)
} else {
Text(" ", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
// The value slides in the direction it was stepped and its width animates, so
// cycling a choice reads as motion through a list rather than a text swap.
AnimatedContent(
targetState = row.value,
transitionSpec = {
val dir = adjustDir
(slideInHorizontally(tween(180)) { w -> w / 2 * dir } + fadeIn(tween(180))) togetherWith
(slideOutHorizontally(tween(140)) { w -> -w / 2 * dir } + fadeOut(tween(100))) using
SizeTransform(clip = false)
},
label = "value",
) { value ->
Text(
value,
style = MaterialTheme.typography.bodyMedium,
color = valueColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(" ", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
}
}
// The focused row carries its own one-line description — no dedicated (space-eating)
// detail strip. It appears right where you're looking, and the row grows to fit.
if (focused && row.detail.isNotBlank()) {
// detail strip. It unfolds right where you're looking, and the row grows to fit.
AnimatedVisibility(
visible = focused && row.detail.isNotBlank(),
enter = fadeIn(tween(180, delayMillis = 60)) + expandVertically(tween(180)),
exit = fadeOut(tween(90)) + shrinkVertically(tween(150)),
) {
Text(
row.detail,
style = MaterialTheme.typography.bodySmall,
@@ -245,13 +291,23 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpR
detail = detail,
adjust = { delta -> val target = delta > 0; if (value != target) { write(target); true } else false },
activate = { write(!value) },
toggled = value,
)
return listOf(
choice(
"resolution", "Stream", "Resolution",
"The host creates a virtual display at exactly this size — no scaling.",
RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to lbl }, s.width to s.height,
"The host creates a virtual display at exactly this size — no scaling. " +
"Custom sizes are typed in the touch settings.",
// A custom size (typed in the touch settings) leads the list so it stays visible and
// selectable here instead of being silently snapped to Native — a pad can keep a
// custom size, it just can't type one.
(if (s.isCustomResolution()) {
listOf((s.width to s.height) to "Custom · ${s.width} × ${s.height}")
} else {
emptyList()
}) + RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to lbl },
s.width to s.height,
) { (w, h) -> update(s.copy(width = w, height = h)) },
choice(
"refresh", null, "Refresh rate", "Frame rate the host renders and streams at.",
@@ -278,6 +334,11 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpR
"HDR10 — engages when the host sends HDR content and this display supports it.",
s.hdrEnabled,
) { update(s.copy(hdrEnabled = it)) },
toggle(
"lowLatency", null, "Low-latency mode",
"The fast pipeline (async decode + system tuning). On by default — turn off to fall back if the stream stutters or glitches.",
s.lowLatencyMode,
) { update(s.copy(lowLatencyMode = it)) },
choice(
"audio", "Audio", "Audio channels", "The speaker layout requested from the host.",
@@ -294,16 +355,22 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpR
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad,
) { update(s.copy(gamepad = it)) },
toggle(
choice(
"hud", "Interface", "Statistics overlay",
"Show FPS, throughput and latency while streaming.",
s.statsHudEnabled,
) { update(s.copy(statsHudEnabled = it)) },
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
"A 3-finger tap cycles the tiers live.",
STATS_VERBOSITY_OPTIONS, s.statsVerbosity,
) { update(s.copy(statsVerbosity = it)) },
toggle(
"library", null, "Game library",
"Browse a paired host's games with Y (experimental).",
s.libraryEnabled,
) { update(s.copy(libraryEnabled = it)) },
toggle(
"autoWake", null, "Auto-wake on connect",
"Wake a saved host with Wake-on-LAN when it isn't seen on the network, then connect.",
s.autoWakeEnabled,
) { update(s.copy(autoWakeEnabled = it)) },
toggle(
"gamepadUI", null, "Controller-optimized UI",
"Turn off to use the touch interface even with a controller connected.",
@@ -3,6 +3,7 @@ package io.unom.punktfunk
import android.content.Context
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.VideoDecoders
import io.unom.punktfunk.kit.security.ClientIdentity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -41,7 +42,10 @@ suspend fun connectToHost(
host, port, w, h, hz,
identity.certPem, identity.privateKeyPem, pinHex,
settings.bitrateKbps, settings.compositor, gamepadPref,
hdrEnabled, settings.audioChannels, settings.preferredCodec(), timeoutMs,
hdrEnabled, settings.audioChannels,
// What this device can decode (H.264|HEVC always, AV1 when a real decoder exists) +
// the user's soft codec preference — the host resolves the emitted codec from both.
VideoDecoders.decodableCodecBits(), settings.preferredCodec(), timeoutMs,
launch,
)
}
@@ -59,12 +59,22 @@ class MainActivity : ComponentActivity() {
var lastPadIsGamepad by mutableStateOf(true)
private set
/**
* The glyph family of the controller driving the console UI (Xbox letters / PlayStation shapes /
* Nintendo monochrome) — seeded from the first connected pad, then kept live by real input the
* same way [lastPadIsGamepad] is. Compose observes it (a snapshot state); the hint bar picks its
* button glyphs from it so a DualSense user isn't shown Xbox lettering.
*/
var lastPadStyle by mutableStateOf(Gamepad.PadStyle.GENERIC)
private set
/** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */
private var highRefreshModeId = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lastPadIsGamepad = !isTvDevice(this)
lastPadStyle = Gamepad.styleFor(Gamepad.firstPad())
resolveHighRefreshMode()
setConsoleHighRefreshRate(true) // the console UI wants max refresh; streaming manages its own
// Dark, transparent system bars regardless of the system theme — our UI is always dark, so
@@ -159,9 +169,11 @@ class MainActivity : ComponentActivity() {
}
} else {
// Note which input the console UI is being driven by, so its glyphs match (a TV remote's
// D-pad is not from SOURCE_GAMEPAD; a pad's face buttons / D-pad are).
// D-pad is not from SOURCE_GAMEPAD; a pad's face buttons / D-pad are) — and, for a real
// pad, WHICH pad family, so the glyphs wear its lettering/shapes.
if (event.action == KeyEvent.ACTION_DOWN && isConsoleNavKey(event.keyCode)) {
lastPadIsGamepad = event.isFromSource(InputDevice.SOURCE_GAMEPAD)
if (lastPadIsGamepad) lastPadStyle = Gamepad.styleFor(event.device)
}
// The Controllers debug screen sees pad events before the navigation remap below.
padKeyProbe?.let { if (it(event)) return true }
@@ -217,6 +229,7 @@ class MainActivity : ComponentActivity() {
lastNavDir = dir
if (dir != 0) {
lastPadIsGamepad = true // a stick/HAT push can only come from a real gamepad
lastPadStyle = Gamepad.styleFor(event.device)
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, dir))
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, dir))
return true
@@ -26,12 +26,18 @@ data class Settings(
/** Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
* can capture; the resolved count drives the decoder + AAudio layout. */
val audioChannels: Int = 2,
/** Preferred video codec: `"auto"` (host decides), `"hevc"`, or `"h264"`. A soft preference — the
* host emits it when it can, else falls back. AMediaCodec decodes whichever the host resolves. */
/** Preferred video codec: `"auto"` (host decides), `"hevc"`, `"h264"`, or `"av1"`. A soft
* preference — the host emits it when it can, else falls back. AMediaCodec decodes whichever
* the host resolves (AV1 is only advertised/offered when the device has a real AV1 decoder). */
val codec: String = "auto",
val micEnabled: Boolean = false,
/** Show the live stats overlay (FPS / throughput / latency) during a stream. */
val statsHudEnabled: Boolean = true,
/**
* How much the in-stream stats overlay shows — see [StatsVerbosity]. Defaults to
* [StatsVerbosity.NORMAL] (the res/fps line + latency headline + reliability counters); the full
* decoder/feed/equation HUD is [StatsVerbosity.DETAILED], and a single terse line is
* [StatsVerbosity.COMPACT]. A 3-finger tap cycles through the tiers live.
*/
val statsVerbosity: StatsVerbosity = StatsVerbosity.NORMAL,
/**
* Touch input model — how touchscreen fingers drive the host. [TouchMode.TRACKPAD] (default):
* the cursor stays put on touch-down and moves by the finger's relative delta (swipe to nudge,
@@ -55,14 +61,18 @@ data class Settings(
*/
val libraryEnabled: Boolean = true,
/**
* "Low-latency mode (experimental)" — the master switch over the whole latency overhaul: decoder
* ranking + per-SoC vendor keys + the async decode loop (native), pipeline thread boosts + ADPF
* max-performance, game-tagged AAudio, DSCP marking on the media sockets, the Wi-Fi low-latency
* lock, HDMI ALLM, and the forced TV mode switch. Off (default): the original pre-overhaul
* pipeline, kept byte-for-byte as the known-good baseline — the overhaul regressed badly on some
* phones, so it's opt-in until it's proven per-device.
* "Low-latency mode" — the master switch over the latency pipeline: the async decode loop
* (native; burst-feed + present-newest-per-vsync, the Apple client's discipline), decoder ranking
* + per-SoC vendor keys, pipeline thread boosts + ADPF max-performance, game-tagged AAudio, DSCP
* marking on the media sockets, HDMI ALLM, and the forced TV mode switch. (The Wi-Fi locks are NOT
* part of this — both are always held while streaming; see StreamScreen.) On (default): the fast
* pipeline. Off restores the original synchronous decode loop byte-for-byte, kept as a per-device
* escape hatch. Promoted to default once the receive-side latency ratchet the overhaul interacted
* badly with was fixed in the shared core — the pump now jumps to live on a standing backlog
* instead of accumulating it (see `punktfunk-core` `FrameChannel`), so the async loop no longer
* feeds a queue that only grows.
*/
val lowLatencyMode: Boolean = false,
val lowLatencyMode: Boolean = true,
/**
* Wake-on-LAN a saved host before connecting when it isn't currently seen on mDNS. On (default):
* a connect to a host with a learned MAC that isn't advertising sends a magic packet and waits
@@ -77,6 +87,27 @@ data class Settings(
/** [Settings.touchMode] values; persisted by name. */
enum class TouchMode { TRACKPAD, POINTER, TOUCH }
/**
* Stats-overlay detail tiers, in cycling order (persisted by name). Each tier is a strict superset
* of the previous one, so toning down never hides a number a lower tier keeps:
* - [OFF] — no overlay (and native sampling is gated off, one atomic load per frame).
* - [COMPACT] — one line: `fps · end-to-end ms · Mb/s` (+ a loss flag when frames drop).
* - [NORMAL] — adds the resolution/refresh line, the end-to-end p50/p95 headline, and the
* reliability counters (lost / skipped / FEC) when nonzero. The default.
* - [DETAILED] — the full HUD: also the decoder label, the video-feed descriptor, and the
* `host+network + decode` stage equation.
* A 3-finger tap in-stream cycles Off → Compact → Normal → Detailed → Off (see [next]).
*/
enum class StatsVerbosity(val label: String) {
OFF("Off"),
COMPACT("Compact"),
NORMAL("Normal"),
DETAILED("Detailed");
/** The next tier for the live 3-finger-tap cycle (wraps Detailed → Off). */
fun next(): StatsVerbosity = entries[(ordinal + 1) % entries.size]
}
/** Loads/saves [Settings] in the app-private `punktfunk_settings` prefs. */
class SettingsStore(context: Context) {
private val prefs =
@@ -93,14 +124,23 @@ class SettingsStore(context: Context) {
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
micEnabled = prefs.getBoolean(K_MIC, false),
statsHudEnabled = prefs.getBoolean(K_HUD, true),
statsVerbosity = prefs.getString(K_STATS_VERBOSITY, null)
?.let { name -> StatsVerbosity.entries.firstOrNull { it.name == name } }
// Migration from the pre-tier Boolean "stats_hud_enabled": an explicit OFF stays off;
// everyone else (incl. fresh installs) lands on NORMAL — the old always-full HUD toned
// down to the new default, which is the whole point of adding tiers.
?: if (prefs.contains(K_HUD) && !prefs.getBoolean(K_HUD, true)) {
StatsVerbosity.OFF
} else {
StatsVerbosity.NORMAL
},
touchMode = prefs.getString(K_TOUCH_MODE, null)
?.let { name -> TouchMode.entries.firstOrNull { it.name == name } }
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, false),
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
)
@@ -116,7 +156,7 @@ class SettingsStore(context: Context) {
.putInt(K_AUDIO_CH, s.audioChannels)
.putString(K_CODEC, s.codec)
.putBoolean(K_MIC, s.micEnabled)
.putBoolean(K_HUD, s.statsHudEnabled)
.putString(K_STATS_VERBOSITY, s.statsVerbosity.name)
.putString(K_TOUCH_MODE, s.touchMode.name)
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
.putBoolean(K_LIBRARY, s.libraryEnabled)
@@ -136,18 +176,26 @@ class SettingsStore(context: Context) {
const val K_AUDIO_CH = "audio_channels"
const val K_CODEC = "codec"
const val K_MIC = "mic_enabled"
const val K_STATS_VERBOSITY = "stats_verbosity"
/** Pre-tier Boolean the [K_STATS_VERBOSITY] enum replaced — read once for migration, never
* written. */
const val K_HUD = "stats_hud_enabled"
const val K_TOUCH_MODE = "touch_mode"
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
const val K_LIBRARY = "library_enabled"
/**
* Deliberately NOT the original `"low_latency_mode"` key: that one shipped default-ON, so
* any install that ever saved settings persisted `true` — under the old key, flipping the
* default to off would leave exactly the regressed devices stuck on the overhaul. The fresh
* key restarts everyone at the safe default; the stale one is abandoned unread.
* Bumped AGAIN to restart every install at the new default (ON). History: the original
* `"low_latency_mode"` shipped default-ON; `"low_latency_mode_experimental"` restarted
* everyone at OFF after the overhaul regressed on some phones. That regression was the
* receive-side latency ratchet the async loop fed (a standing queue that only grew) — now
* fixed in the shared core (`punktfunk-core` `FrameChannel`: the pump jumps to live on a
* standing backlog instead of accumulating it), so the fast pipeline is the default again. A
* fresh key re-defaults every install — including ones persisted OFF under the old key — to
* on; both stale keys are abandoned unread. The toggle stays as a per-device escape hatch.
*/
const val K_LOW_LATENCY = "low_latency_mode_experimental"
const val K_LOW_LATENCY = "low_latency_mode_v2"
const val K_AUTO_WAKE = "auto_wake_enabled"
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
@@ -205,6 +253,12 @@ val RESOLUTION_OPTIONS = listOf(
Triple(3840, 2160, "3840 × 2160"),
)
/** True when the stored size is none of the [RESOLUTION_OPTIONS] presets — a custom resolution
* typed in the touch settings. Detected from the size itself rather than a persisted flag, so it
* can never disagree with what's actually stored (mirrors the Apple client). */
fun Settings.isCustomResolution(): Boolean =
RESOLUTION_OPTIONS.none { (w, h, _) -> w == width && h == height }
/** (hz, label). `0` = native refresh. */
val REFRESH_OPTIONS = listOf(
0 to "Native",
@@ -224,14 +278,17 @@ val AUDIO_CHANNEL_OPTIONS = listOf(
8 to "7.1 Surround",
)
/** (stored value, label) for the preferred video codec. `"auto"` = host decides. */
/** (stored value, label) for the preferred video codec. `"auto"` = host decides. The `"av1"` row
* only makes sense on a device with a real AV1 decoder — SettingsScreen filters it out otherwise. */
val CODEC_OPTIONS = listOf(
"auto" to "Automatic",
"hevc" to "HEVC (H.265)",
"h264" to "H.264 (AVC)",
"av1" to "AV1",
)
/** The [Settings.codec] string as a `quic::CODEC_*` preference byte (`0` = auto). H264=1, HEVC=2. */
/** The [Settings.codec] string as a `quic::CODEC_*` preference byte (`0` = auto). H264=1, HEVC=2,
* AV1=4. */
fun Settings.preferredCodec(): Int = when (codec) {
"h264" -> 1
"hevc" -> 2
@@ -261,6 +318,9 @@ val COMPOSITOR_OPTIONS = listOf(
"gamescope",
)
/** (verbosity, label) for the stats-overlay detail picker. Order = the live 3-finger-tap cycle. */
val STATS_VERBOSITY_OPTIONS = StatsVerbosity.entries.map { it to it.label }
/** (mode, label) for the touch-input model. */
val TOUCH_MODE_OPTIONS = listOf(
TouchMode.TRACKPAD to "Trackpad",
@@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -60,11 +61,14 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import io.unom.punktfunk.kit.VideoDecoders
/**
* Stream settings, organised as an iOS-Settings / Android-system-settings style list of category
@@ -283,12 +287,40 @@ private fun CategoryDetail(
@Composable
private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: android.content.Context) {
val (nw, nh, nhz) = nativeDisplayMode(context)
// "Custom…" picked while the stored size is still a preset — keeps the size fields visible
// until an edit actually makes it custom (or a preset is re-picked). Custom itself is detected
// from the stored size, never flagged (see [isCustomResolution]), so nothing new persists.
var customPicked by remember { mutableStateOf(false) }
val showCustom = customPicked || s.isCustomResolution()
SettingsCard {
SettingDropdown(
label = "Resolution",
options = RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl) },
selected = s.width to s.height,
) { (w, h) -> update(s.copy(width = w, height = h)) }
options = RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl) } +
// The (-1, -1) sentinel can't collide with a real size; once a custom size is
// stored its label carries the live value, like the native row carries ($nw × $nh).
((-1 to -1) to if (s.isCustomResolution()) "Custom (${s.width} × ${s.height})" else "Custom…"),
selected = if (showCustom) -1 to -1 else s.width to s.height,
) { (w, h) ->
if (w < 0) {
// Seed from the current *effective* size so the fields start from something
// sensible (the resolved native mode, not the 0 × 0 placeholder).
customPicked = true
update(s.copy(width = if (s.width > 0) s.width else nw, height = if (s.height > 0) s.height else nh))
} else {
customPicked = false
update(s.copy(width = w, height = h))
}
}
if (showCustom) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
ResolutionField(label = "Width", value = s.width, modifier = Modifier.weight(1f)) { w ->
update(s.copy(width = w))
}
ResolutionField(label = "Height", value = s.height, modifier = Modifier.weight(1f)) { h ->
update(s.copy(height = h))
}
}
}
SettingDropdown(
label = "Refresh rate",
@@ -300,7 +332,12 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
update(s.copy(bitrateKbps = kbps))
}
SettingDropdown(label = "Video codec", options = CODEC_OPTIONS, selected = s.codec) { c ->
// AV1 is only offered when the device has a real AV1 decoder (it's never advertised to the
// host otherwise, so preferring it would be a dead setting). A stored "av1" from a capable
// device stays visible so the selection is always representable.
val av1Capable = remember { VideoDecoders.pickDecoder("video/av01") != null }
val codecOptions = CODEC_OPTIONS.filter { (v, _) -> v != "av1" || av1Capable || s.codec == "av1" }
SettingDropdown(label = "Video codec", options = codecOptions, selected = s.codec) { c ->
update(s.copy(codec = c))
}
@@ -326,10 +363,10 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
) { c -> update(s.copy(compositor = c)) }
ToggleRow(
title = "Low-latency mode (experimental)",
subtitle = "Aggressive decoder and system tuning (per-device decoder selection, async " +
"decode, Wi-Fi and HDMI hints). Can lower latency, but may stutter or glitch on " +
"some devices — turn off if the stream misbehaves.",
title = "Low-latency mode",
subtitle = "The fast pipeline (async decode, per-device decoder selection, HDMI game " +
"mode). On by default — turn off to fall back to the plain decode path if the stream " +
"stutters or glitches on this device.",
checked = s.lowLatencyMode,
onCheckedChange = { on -> update(s.copy(lowLatencyMode = on)) },
)
@@ -403,11 +440,17 @@ private fun InterfaceSettings(s: Settings, update: (Settings) -> Unit) {
checked = s.autoWakeEnabled,
onCheckedChange = { on -> update(s.copy(autoWakeEnabled = on)) },
)
ToggleRow(
title = "Stats overlay",
subtitle = "Show FPS, throughput and latency while streaming (3-finger tap toggles it live)",
checked = s.statsHudEnabled,
onCheckedChange = { on -> update(s.copy(statsHudEnabled = on)) },
SettingDropdown(
label = "Stats overlay",
options = STATS_VERBOSITY_OPTIONS,
selected = s.statsVerbosity,
) { v -> update(s.copy(statsVerbosity = v)) }
Text(
"How much the in-stream overlay shows: Compact is a single fps · latency · bitrate " +
"line; Normal adds the resolution and reliability lines; Detailed adds the decoder, " +
"colour and latency-breakdown lines. A 3-finger tap cycles the tiers live.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -523,3 +566,30 @@ private fun <T> SettingDropdown(
}
}
}
/** One side of a custom resolution. Digits only; every usable keystroke commits — coerced even
* (encoders reject odd dimensions) and capped at 8192, the HEVC/AV1 per-side ceiling (the host
* clamps H.264's tighter 4096 itself) — while the field keeps the raw text so intermediate states
* ("15" on the way to "1512") aren't rewritten mid-typing; it snaps to the committed value when
* focus leaves. */
@Composable
private fun ResolutionField(
label: String,
value: Int,
modifier: Modifier = Modifier,
onCommit: (Int) -> Unit,
) {
var text by remember { mutableStateOf(if (value > 0) value.toString() else "") }
OutlinedTextField(
value = text,
onValueChange = { raw ->
text = raw.filter { it.isDigit() }.take(4)
val v = (text.toIntOrNull() ?: 0).let { it - it % 2 }.coerceAtMost(8192)
if (v > 0) onCommit(v)
},
label = { Text(label) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = modifier.onFocusChanged { if (!it.isFocused) text = if (value > 0) value.toString() else "" },
)
}
@@ -15,89 +15,141 @@ import io.unom.punktfunk.kit.NativeBridge
import kotlin.math.roundToInt
/**
* The live stats overlay — the unified HUD (`design/stats-unification.md`, Android v1: headline is
* `capture→decoded`, tiled by `host+network` + `decode`). Reads the 18-double layout from
* [NativeBridge.nativeVideoStats]:
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skew, w, h, hz, lost, bitDepth, colorPrimaries,
* colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms, netP50Ms]`. Indexes 1013
* (present on a current native lib) describe the negotiated video feed and render as a
* codec/depth/colour/chroma line; 14/15 render as the stage equation — split into
* `host + network + decode` when the Phase-2 terms at 16/17 are nonzero (a current host sends
* per-AU 0xCF timings; an old host leaves them 0 and the combined `host+network` term stands);
* older layouts just omit those lines.
* The live stats overlay — the unified HUD (`design/stats-unification.md`): headline is
* `capture→displayed` tiled by `host+network` + `decode` + `display` when the platform delivered
* OnFrameRendered render callbacks this window (`dispValid`), falling back to the v1
* `capture→decoded` headline without the `display` term when it didn't. Reads the 26-double
* layout from [NativeBridge.nativeVideoStats]:
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skew, w, h, hz, lostTotal, bitDepth, colorPrimaries,
* colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms, netP50Ms, lost, skipped,
* fec, frames, dispValid, displayP50Ms, e2eDispP50Ms, e2eDispP95Ms]`.
*
* [verbosity] selects how many lines render (each tier a superset of the last — see
* [StatsVerbosity]):
* - [StatsVerbosity.COMPACT] — one line, `fps · end-to-end ms · Mb/s` (+ a loss flag).
* - [StatsVerbosity.NORMAL] — the res/fps/Mb·s line, the end-to-end p50/p95 headline, and the
* reliability counters (1821) when nonzero.
* - [StatsVerbosity.DETAILED] — also the decoder label, the video-feed descriptor (1013), and the
* stage equation (14/15, split into `host + network` when the Phase-2 terms at 16/17 are nonzero).
* [StatsVerbosity.OFF] renders nothing. Older native layouts simply omit the lines they lack (the
* counter line falls back to the cumulative `lostTotal` at index 9 on a pre-window lib).
*/
@Composable
internal fun StatsOverlay(s: DoubleArray, decoderLabel: String = "", modifier: Modifier = Modifier) {
if (s.size < 10) return
internal fun StatsOverlay(
s: DoubleArray,
verbosity: StatsVerbosity,
decoderLabel: String = "",
modifier: Modifier = Modifier,
) {
if (verbosity == StatsVerbosity.OFF || s.size < 10) return
val w = s[6].toInt()
val h = s[7].toInt()
val hz = s[8].toInt()
val latValid = s[4] != 0.0
val skew = s[5] != 0.0
val lost = s[9].toLong()
val detailed = verbosity == StatsVerbosity.DETAILED
Column(
modifier = modifier
.background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp))
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Text(
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s",
color = Color.White,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
)
if (decoderLabel.isNotEmpty()) {
Text(
decoderLabel,
color = Color(0xFFB0D0FF),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
)
// Compact: everything the glance-value needs on one line, nothing else.
if (verbosity == StatsVerbosity.COMPACT) {
statLine(compactLine(s, latValid), Color.White)
return@Column
}
videoFeedLine(s)?.let { feed ->
Text(
feed,
color = Color.White,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
)
statLine("$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s", Color.White)
if (detailed && decoderLabel.isNotEmpty()) {
statLine(decoderLabel, Color(0xFFB0D0FF))
}
if (detailed) {
videoFeedLine(s)?.let { statLine(it, Color.White) }
}
if (latValid) {
// Display stage (s[22]s[25], from OnFrameRendered): when a render timestamp landed
// this window the headline is the directly-measured capture→displayed pair and the
// equation gains its `display` term; otherwise (older lib / no callbacks) the endpoint
// honestly stays capture→decoded — the equation always tiles the headline interval.
val dispValid = s.size >= 26 && s[22] != 0.0
val tag = if (skew) "" else " (same-host clock)"
Text(
"end-to-end ${"%.1f".format(s[2])} ms p50 · ${"%.1f".format(s[3])} p95 · capture→decoded$tag",
color = Color.White,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
val (p50, p95, endpoint) = if (dispValid) {
Triple(s[24], s[25], "capture→displayed")
} else {
Triple(s[2], s[3], "capture→decoded")
}
statLine(
"end-to-end ${"%.1f".format(p50)} ms p50 · ${"%.1f".format(p95)} p95 · $endpoint$tag",
Color.White,
)
if (s.size >= 16) {
if (detailed && s.size >= 16) {
// Phase-2 split (s[16]/s[17]): render `host + network` separately when the host
// reported its share this window; otherwise the combined term (old host / no
// matched 0xCF timing).
val equation = if (s.size >= 18 && s[16] > 0) {
"= host ${"%.1f".format(s[16])} + network ${"%.1f".format(s[17])} + decode ${"%.1f".format(s[15])}"
val hostTerms = if (s.size >= 18 && s[16] > 0) {
"host ${"%.1f".format(s[16])} + network ${"%.1f".format(s[17])}"
} else {
"= host+network ${"%.1f".format(s[14])} + decode ${"%.1f".format(s[15])}"
"host+network ${"%.1f".format(s[14])}"
}
Text(
equation,
color = Color.White,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
)
val displayTerm = if (dispValid) " + display ${"%.1f".format(s[23])}" else ""
statLine("= $hostTerms + decode ${"%.1f".format(s[15])}$displayTerm", Color.White)
}
}
if (lost > 0) {
Text(
"lost $lost",
color = Color(0xFFFFB0B0),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
)
}
counterLine(s, lost)?.let { statLine(it, Color(0xFFFFB0B0)) }
}
}
/** One monospace HUD line — the shared type ramp so every tier's rows line up. */
@Composable
private fun statLine(text: String, color: Color) {
Text(text, color = color, fontFamily = FontFamily.Monospace, fontSize = 12.sp)
}
/**
* The single [StatsVerbosity.COMPACT] line: `238 fps · 1.3 ms · 921 Mb/s`. The end-to-end p50 term
* is dropped when no in-range latency sample landed (`latValid` false), and a loss flag
* `· ⚠ lost {n}` is appended when the window (or, on an old lib, the session) dropped frames — the
* one reliability signal worth surfacing even at the tersest tier.
*/
private fun compactLine(s: DoubleArray, latValid: Boolean): String {
// Prefer the capture→displayed end-to-end (s[24]) when a render timestamp landed this window.
val e2eP50 = if (s.size >= 26 && s[22] != 0.0) s[24] else s[2]
val parts = buildList {
add("${s[0].roundToInt()} fps")
if (latValid) add("${"%.1f".format(e2eP50)} ms")
add("${s[1].roundToInt()} Mb/s")
}
val lostWindow = if (s.size >= 22) s[18].toLong() else s[9].toLong()
val suffix = if (lostWindow > 0) " ⚠ lost $lostWindow" else ""
return parts.joinToString(" · ") + suffix
}
/**
* Format the spec's line-4 counters from the per-window doubles at 1821 —
* `lost {n} ({pct}%) · skipped {m} · FEC {k}`, each term only when nonzero, the whole line `null`
* when all are zero (spec: "only rendered when any value is nonzero"). `pct = lost/(frames+lost)`
* (the received count rides at index 21). A pre-window layout (< 22 doubles) falls back to the
* session-cumulative `lostTotal` so an older native lib still reports loss.
*/
private fun counterLine(s: DoubleArray, lostTotal: Long): String? {
if (s.size < 22) return if (lostTotal > 0) "lost $lostTotal" else null
val lost = s[18].toLong()
val skipped = s[19].toLong()
val fec = s[20].toLong()
val frames = s[21].toLong()
if (lost == 0L && skipped == 0L && fec == 0L) return null
return buildList {
if (lost > 0) {
val pct = 100.0 * lost / (frames + lost).coerceAtLeast(1)
add("lost $lost (${"%.1f".format(pct)}%)")
}
if (skipped > 0) add("skipped $skipped")
if (fec > 0) add("FEC $fec")
}.joinToString(" · ")
}
/**
* Format the negotiated video-feed descriptor from the trailing four stats doubles
* `[bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`, e.g.
@@ -6,6 +6,7 @@ import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.net.wifi.WifiManager
import android.os.Build
import android.util.Log
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.WindowManager
@@ -53,28 +54,32 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
Manifest.permission.RECORD_AUDIO,
) == PackageManager.PERMISSION_GRANTED
// Live decode stats for the HUD. `showStats` gates the whole pipeline: the native per-frame
// sampling (nativeSetVideoStatsEnabled — hidden HUD costs one atomic load per frame) AND the
// 1 s poll loop, which only runs while the overlay is visible. Enabling resets the native
// window, so re-showing never renders stale data. A 3-finger tap toggles it live; the default
// comes from Settings.
// Live decode stats for the HUD. `statsOn` (verbosity != OFF) gates the whole native pipeline:
// the per-frame sampling (nativeSetVideoStatsEnabled — a hidden HUD costs one atomic load per
// frame) AND the 1 s poll loop, which only runs while the overlay is visible. Enabling resets
// the native window, so re-showing never renders stale data. A 3-finger tap cycles the
// verbosity tier live (Off → Compact → Normal → Detailed → Off); the default comes from
// Settings. The tier only changes how many lines `StatsOverlay` draws — switching between the
// visible tiers keeps sampling running (the effect keys on `statsOn`, not the tier) so it never
// blanks the numbers for a poll interval.
val initialSettings = remember { SettingsStore(context).load() }
var stats by remember { mutableStateOf<DoubleArray?>(null) }
var decoderLabel by remember { mutableStateOf("") }
var showStats by remember { mutableStateOf(initialSettings.statsHudEnabled) }
var statsVerbosity by remember { mutableStateOf(initialSettings.statsVerbosity) }
val statsOn = statsVerbosity != StatsVerbosity.OFF
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
val touchMode = initialSettings.touchMode
// "Low-latency mode (experimental)" master toggle, resolved once for the session. Off (the
// default) runs the original pre-overhaul pipeline; on enables the whole aggressive stack —
// decoder ranking + vendor keys + async loop (native side), the Wi-Fi low-latency lock and
// HDMI ALLM below, game-tagged audio, and DSCP marking (applied earlier, at connect).
// "Low-latency mode" master toggle, resolved once for the session. On (the default) enables the
// fast pipeline — decoder ranking + vendor keys + async loop (native side), HDMI ALLM below,
// game-tagged audio, and DSCP marking (applied earlier, at connect); off falls back to the
// original synchronous decode pipeline as a per-device escape hatch.
val lowLatencyMode = initialSettings.lowLatencyMode
// TV form factor (leanback): the decoder actively switches the HDMI output mode to the stream
// refresh; a phone/tablet gets the softer seamless frame-rate hint instead.
val isTv = remember { context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) }
LaunchedEffect(handle, showStats) {
NativeBridge.nativeSetVideoStatsEnabled(handle, showStats)
if (showStats) {
LaunchedEffect(handle, statsOn) {
NativeBridge.nativeSetVideoStatsEnabled(handle, statsOn)
if (statsOn) {
while (true) {
delay(1000)
stats = NativeBridge.nativeVideoStats(handle)
@@ -117,26 +122,40 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
// main thread, so a plain flag is race-free; AtomicBoolean just makes the intent explicit.
val closed = remember { AtomicBoolean(false) }
// A Wi-Fi low-latency lock held for the stream's duration: asks the Wi-Fi firmware to drop its
// power-save polling (a common source of tens-of-ms jitter). WIFI_MODE_FULL_LOW_LATENCY (API
// 29+) is the strongest; older releases fall back to FULL_HIGH_PERF. Needs no extra permission
// beyond ACCESS_WIFI_STATE (already declared). Non-reference-counted: one explicit acquire/release.
// Part of the experimental low-latency stack — not created at all when the toggle is off.
val wifiLock = remember(handle) {
if (!lowLatencyMode) return@remember null
// Wi-Fi locks held for the stream's duration — BOTH of them, unconditionally (Moonlight does
// the same). Without an effective lock, Wi-Fi power save batches downlink delivery into
// beacon-interval clumps: hundreds of ms of latency mush, sawtoothing bitrate, and periodic
// whole-frame loss when the AP's power-save buffer overflows (all observed live on a phone).
// - FULL_LOW_LATENCY (API 29+) is the only lock that actually disables power save on modern
// Android; it needs the app foreground + screen on, which a stream always is.
// - FULL_HIGH_PERF covers older releases — it is deprecated AND a documented no-op on recent
// Android, which is exactly why it can't be the only lock (a lesson learned: holding just
// HIGH_PERF left power save fully active on Android 13+).
// acquire() ENFORCES the WAKE_LOCK permission (manifest) — and a failed acquire MUST be loud:
// a silent runCatching hid the missing permission for weeks (dumpsys wifi showed
// low_latency_active_time_ms=0 across every "locked" stream). Non-reference-counted: one
// explicit acquire/release each.
val wifiLocks = remember(handle) {
val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
WifiManager.WIFI_MODE_FULL_LOW_LATENCY
} else {
?: return@remember emptyList<WifiManager.WifiLock>()
buildList {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
wm.createWifiLock(WifiManager.WIFI_MODE_FULL_LOW_LATENCY, "punktfunk:stream-ll")
?.let(::add)
}
@Suppress("DEPRECATION")
WifiManager.WIFI_MODE_FULL_HIGH_PERF
}
wm?.createWifiLock(mode, "punktfunk:stream")?.apply { setReferenceCounted(false) }
wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "punktfunk:stream-hp")
?.let(::add)
}.onEach { it.setReferenceCounted(false) }
}
DisposableEffect(handle) {
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
runCatching { wifiLock?.acquire() }
wifiLocks.forEach { lock ->
runCatching { lock.acquire() }.onFailure { e ->
Log.w("punktfunk", "WifiLock acquire failed — power save stays ON: $lock", e)
}
}
// HDMI Auto Low-Latency Mode: ask the display to drop its post-processing (game mode) —
// the biggest panel-side latency win on the TV boxes. No-op where ALLM isn't supported. API
// 30+. Part of the experimental low-latency stack.
@@ -175,7 +194,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window?.setPreferMinimalPostProcessing(false)
}
runCatching { if (wifiLock?.isHeld == true) wifiLock.release() }
wifiLocks.forEach { runCatching { if (it.isHeld) it.release() } }
// Release the landscape lock so the rest of the app follows the device/system again.
activity?.requestedOrientation =
priorOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
@@ -235,8 +254,10 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
)
// Live stats HUD (FPS / throughput / capture→client latency), drawn over the video but
// BEFORE the transparent gesture layer below, so it shows through and never eats touches.
if (showStats) {
stats?.let { StatsOverlay(it, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp)) }
if (statsOn) {
stats?.let {
StatsOverlay(it, statsVerbosity, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp))
}
}
// Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
// vocabulary) or real multi-touch passthrough — see TouchInput.kt.
@@ -247,7 +268,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
else -> streamTouchInput(
handle,
trackpad = touchMode == TouchMode.TRACKPAD,
onToggleStats = { showStats = !showStats },
onCycleStats = { statsVerbosity = statsVerbosity.next() },
)
}
},
@@ -40,7 +40,7 @@ private const val ACCEL_MAX = 3.0f
*
* Both share the same gesture vocabulary: tap = left click; two-finger tap = right click;
* two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving
* windows); three-finger tap = [onToggleStats] (the stats HUD).
* windows); three-finger tap = [onCycleStats] (cycle the stats-HUD verbosity tier).
*/
/**
* Real multi-touch passthrough ([TouchMode.TOUCH]): every finger forwards as a host touchscreen
@@ -93,7 +93,7 @@ internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long) {
internal suspend fun PointerInputScope.streamTouchInput(
handle: Long,
trackpad: Boolean,
onToggleStats: () -> Unit,
onCycleStats: () -> Unit,
) {
var lastTapUp = 0L
var lastTapX = 0f
@@ -127,6 +127,7 @@ internal suspend fun PointerInputScope.streamTouchInput(
var moved = false
var maxFingers = 1
var scrolling = false
var scrollCount = 0 // pointer count the scroll centroid is anchored at
var prevCx = startX
var prevCy = startY
var upTime = down.uptimeMillis
@@ -149,11 +150,18 @@ internal suspend fun PointerInputScope.streamTouchInput(
if (pressed.size > maxFingers) maxFingers = pressed.size
if (pressed.size >= 2) {
// Two fingers → scroll by the centroid delta; never move the cursor.
// Two+ fingers → scroll by the centroid delta; never move the cursor.
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat()
if (!scrolling) {
// (Re-)anchor whenever the finger COUNT changes, not just on scroll start: the
// centroid of three fingers sits far from the centroid of two, and real fingers
// never land (or lift) in the same input frame — so the 2→3 transition would
// otherwise read as a scroll notch, sending a phantom wheel tick to the host AND
// setting `moved`, which disqualified the tap classification below and made the
// 3-finger stats tap unreachable on real hardware.
if (!scrolling || pressed.size != scrollCount) {
scrolling = true
scrollCount = pressed.size
prevCx = cx
prevCy = cy
}
@@ -218,7 +226,7 @@ internal suspend fun PointerInputScope.streamTouchInput(
NativeBridge.nativeSendPointerButton(handle, 1, false) // end the drag
} else if (!moved) {
when {
maxFingers >= 3 -> onToggleStats() // in-stream HUD toggle
maxFingers >= 3 -> onCycleStats() // in-stream HUD verbosity cycle
maxFingers == 2 -> { // two-finger tap → right click
NativeBridge.nativeSendPointerButton(handle, 3, true)
NativeBridge.nativeSendPointerButton(handle, 3, false)
@@ -2,6 +2,7 @@ package io.unom.punktfunk.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -56,6 +57,7 @@ fun HostCard(
name: String,
address: String,
status: HostStatus,
online: Boolean = false,
enabled: Boolean,
onConnect: () -> Unit,
onForget: (() -> Unit)?,
@@ -105,7 +107,13 @@ fun HostCard(
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
StatusPill(status)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
PresencePill(online)
StatusPill(status)
}
}
if (onForget != null || onEdit != null || onWake != null) {
@@ -173,6 +181,27 @@ fun HostAvatar(name: String) {
}
}
/**
* A small dot + label for live presence: green Online when the host advertises on mDNS OR answers
* the reachability probe (so a routed/VPN host that never advertises still reads Online), dimmed
* Offline otherwise.
*/
@Composable
fun PresencePill(online: Boolean) {
val color =
if (online) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(8.dp).clip(CircleShape).background(color))
Spacer(Modifier.width(6.dp))
Text(
if (online) "Online" else "Offline",
style = MaterialTheme.typography.labelMedium,
color = color,
)
}
}
/** A small colored dot + label for the host's trust state. */
@Composable
fun StatusPill(status: HostStatus) {
@@ -58,7 +58,15 @@ class ScreenshotTest {
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") // landscape — the stream is immersive
fun stream() = shootRoot("stream") { StreamScene() }
fun stream() = shootRoot("stream") { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) }
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun streamCompact() = shootRoot("stream-compact") { StreamScene(io.unom.punktfunk.StatsVerbosity.COMPACT) }
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun streamNormal() = shootRoot("stream-normal") { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) }
@Test
fun trust() = shootScreen("trust") {
@@ -30,6 +30,7 @@ import io.unom.punktfunk.Settings
import io.unom.punktfunk.TouchMode
import io.unom.punktfunk.SettingsScreen
import io.unom.punktfunk.StatsOverlay
import io.unom.punktfunk.StatsVerbosity
import io.unom.punktfunk.components.HostCard
import io.unom.punktfunk.components.SectionLabel
import io.unom.punktfunk.models.HostStatus
@@ -109,7 +110,7 @@ internal fun SettingsScene() {
compositor = 1,
gamepad = 2,
micEnabled = true,
statsHudEnabled = true,
statsVerbosity = StatsVerbosity.DETAILED,
touchMode = TouchMode.TRACKPAD,
),
onChange = {},
@@ -177,9 +178,12 @@ internal fun PairDialog() {
)
}
/** The live stats HUD (the real StatsOverlay) over a synthetic "streamed frame" gradient. */
/**
* The live stats HUD (the real StatsOverlay) over a synthetic "streamed frame" gradient, at the
* given [verbosity] tier — one scene per tier documents how far each tones the overlay down.
*/
@Composable
internal fun StreamScene() {
internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
Box(
Modifier
.fillMaxSize()
@@ -187,12 +191,27 @@ internal fun StreamScene() {
Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))),
),
) {
// [fps, mbps, latP50, latP95, latValid, skew, w, h, hz, dropped,
// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc] — the last four = a 10-bit
// BT.2020 PQ (HDR) 4:2:0 feed, so the HUD renders its video-feed line.
// The full 26-double unified layout (design/stats-unification.md): [fps, mbps, e2eP50,
// e2eP95, latValid, skew, w, h, hz, lostTotal, bitDepth, colorPrimaries, colorTransfer,
// chromaFormatIdc, hostNetP50, decodeP50, hostP50, netP50, lost, skipped, fec, frames,
// dispValid, displayP50, e2eDispP50, e2eDispP95].
// 10/9/16/1 = a 10-bit BT.2020 PQ (HDR) 4:2:0 feed so the DETAILED HUD renders its
// video-feed line; the display stage is valid (dispValid 1) so the headline is the
// directly-measured capture→displayed pair (1.8/2.6) and the Phase-2 stage terms
// (host 0.6 + network 0.3 + decode 0.4 + display 0.5) tile it, rendering the full split
// equation; the decoder label shows the ranked low-latency decoder. Light per-window loss
// (lost 2 · skipped 1 · FEC 5 of 238) so the reliability line (NORMAL/DETAILED) and the
// compact loss flag both render.
StatsOverlay(
doubleArrayOf(238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 0.0, 10.0, 9.0, 16.0, 1.0),
Modifier.align(Alignment.TopStart).padding(12.dp),
doubleArrayOf(
238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 2.0,
10.0, 9.0, 16.0, 1.0, 0.9, 0.4, 0.6, 0.3,
2.0, 1.0, 5.0, 238.0,
1.0, 0.5, 1.8, 2.6,
),
verbosity = verbosity,
decoderLabel = "c2.qti.hevc.decoder · low-latency",
modifier = Modifier.align(Alignment.TopStart).padding(12.dp),
)
}
}
+1 -1
View File
@@ -3,7 +3,7 @@
// here (version + apply false) so modules can apply it version-less; its version pins the build's
// Kotlin (compose-compiler and Kotlin release in lockstep), keeping them matched.
// Toolchain: AGP 9.2.0 · Gradle 9.4.1 · Kotlin/Compose-compiler 2.3.21 · JDK 21 · Compose BOM
// 2026.05.01 · compileSdk 37 · targetSdk 36 · minSdk 31.
// 2026.05.01 · compileSdk 37 · targetSdk 37 · minSdk 28.
plugins {
id("com.android.application") version "9.2.1" apply false
id("com.android.library") version "9.2.1" apply false
+11 -1
View File
@@ -110,8 +110,18 @@ afterEvaluate {
// screenshot unit tests render Compose on the JVM and never load libpunktfunk_android.so), so
// CI/local screenshot runs don't need the Rust toolchain or NDK. The native build stays wired
// for every normal APK/AAR build.
//
// DEBUG APKs SHIP RELEASE RUST. Cargo's debug profile is not "a bit slower" for this library —
// it is unusable: the AES-GCM data-plane decrypt runs through generic-array iterator closures
// with per-byte UB checks instead of ARMv8 hardware AES. Profiled live on a phone (simpleperf):
// ~800 µs of user CPU per 1.4 KB packet, the receive pump pinned over a full core yet unable to
// drain a 20 Mbps stream — every debug-APK on-device test was silently benchmarking unoptimized
// crypto, not the streaming pipeline. Kotlin debuggability is untouched (the APK is still a
// debug build); only the cargo profile changes. `-PrustDebug` restores a debug-profile native
// build for the rare session that actually steps through Rust.
if (!project.hasProperty("skipRustBuild")) {
tasks.named("preDebugBuild").configure { dependsOn(cargoNdkDebug) }
val debugRust = if (project.hasProperty("rustDebug")) cargoNdkDebug else cargoNdkRelease
tasks.named("preDebugBuild").configure { dependsOn(debugRust) }
tasks.named("preReleaseBuild").configure { dependsOn(cargoNdkRelease) }
}
}
@@ -57,6 +57,7 @@ object Gamepad {
private const val VID_SONY = 0x054C
private const val VID_MICROSOFT = 0x045E
private const val VID_VALVE = 0x28DE
private const val VID_NINTENDO = 0x057E
// Sony product ids. DualSense (PS5) and DualShock 4 (PS4) map to distinct host pad types.
private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2)
@@ -98,6 +99,28 @@ object Gamepad {
}
}
/**
* The glyph family a controller's physical buttons belong to, for the console UI's hint bar —
* so a DualSense user sees ✕/○/□/△ shapes and a Switch pad its monochrome lettering instead of
* Xbox's coloured letters. PURELY visual: the wire mapping ([buttonBit]) is unaffected.
*/
enum class PadStyle { GENERIC, XBOX, PLAYSTATION, NINTENDO }
/**
* Resolve the [PadStyle] for a connected controller by USB vendor id. Vendor alone is enough —
* every pad a vendor ships wears its family's glyphs (any Sony pad has the shapes, any Nintendo
* pad the /+ system buttons), so unlike [prefFor] no PID table is needed. Valve renders as
* [PadStyle.XBOX]: Steam pads carry A/B/X/Y in Xbox positions. Unknown vendors (8BitDo & co.,
* which near-universally clone the Xbox layout) fall back to [PadStyle.GENERIC], drawn with the
* Xbox convention.
*/
fun styleFor(dev: InputDevice?): PadStyle = when (dev?.vendorId) {
VID_SONY -> PadStyle.PLAYSTATION
VID_MICROSOFT, VID_VALVE -> PadStyle.XBOX
VID_NINTENDO -> PadStyle.NINTENDO
else -> PadStyle.GENERIC
}
/** True when [dev]'s source classes include gamepad or joystick. */
fun isPad(dev: InputDevice?): Boolean {
val s = dev?.sources ?: return false
@@ -48,6 +48,9 @@ object NativeBridge {
gamepadPref: Int,
hdrEnabled: Boolean,
audioChannels: Int,
/** `quic::CODEC_*` bitfield of codecs this device decodes ([VideoDecoders.decodableCodecBits]);
* `0` falls back to H.264|HEVC. The host resolves the emitted codec from this ∩ its GPU. */
videoCodecs: Int,
/** Preferred video codec as a `quic::CODEC_*` bit (`0` = auto). Soft — the host falls back. */
preferredCodec: Int,
timeoutMs: Int,
@@ -123,6 +126,15 @@ object NativeBridge {
*/
external fun nativeWakeOnLan(macsCsv: String, lastIp: String): Boolean
/**
* Bounded, trust-agnostic QUIC reachability probe to [host]:[port] (mDNS-independent): true if
* the host completed the handshake within [timeoutMs]. No pin/identity presented. Lets a saved
* host reached over a routed network (Tailscale/VPN/another subnet) — which never advertises on
* mDNS — still show as online. Blocking (builds its own runtime) — run on a background
* dispatcher, never the main thread.
*/
external fun nativeProbe(host: String, port: Int, timeoutMs: Int): Boolean
/**
* Apply the user's "Low-latency mode (experimental)" toggle to the process-wide transport
* defaults — today just DSCP/QoS marking on the media sockets. Must be called BEFORE
@@ -171,16 +183,22 @@ object NativeBridge {
/**
* Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs.
* Returns 18 doubles (unified stats spec, `design/stats-unification.md`):
* Returns 26 doubles (unified stats spec, `design/stats-unification.md`):
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
* bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
* netP50Ms]`
* (the two flags are 1.0/0.0; indexes 2/3 are the end-to-end capture→decoded headline; 1013
* netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow, dispValid, displayP50Ms,
* e2eDispP50Ms, e2eDispP95Ms]`
* (the flags are 1.0/0.0; indexes 2/3 are the end-to-end capture→decoded headline; 1013
* describe the negotiated video feed — bit depth 8/10, CICP primaries/transfer, and the HEVC
* chroma_format_idc 1=4:2:0 / 3=4:4:4; 14/15 are the stage p50s tiling the headline —
* `host+network` = capture→received, `decode` = received→decoded; 16/17 split the
* `host+network` term via the host's per-AU 0xCF timings — `host` = the host's capture→sent,
* `network` = the remainder — both 0.0 when no timing matched this window, i.e. an old host).
* `network` = the remainder — both 0.0 when no timing matched this window, i.e. an old host;
* 1821 are the per-window reliability counters — lost/skipped/FEC/received; 2225 are the
* `display` stage from the OnFrameRendered render timestamps — when `dispValid` is 1.0 the
* headline becomes the directly-measured capture→displayed pair at 24/25, tiled by
* `host+network` + `decode` + `display` (23), and when 0.0 the HUD falls back to the
* capture→decoded headline at 2/3 without the `display` term).
* Poll ~1 Hz; each call resets the measurement window.
*/
external fun nativeVideoStats(handle: Long): DoubleArray?
@@ -44,6 +44,15 @@ object VideoDecoders {
* Pick the best decoder for [mime] (`"video/hevc"` / `"video/avc"` / `"video/av01"`), or `null`
* to let the platform resolve its default. Enumerates once — call at stream start.
*/
/**
* The `quic::CODEC_*` bitfield of codecs this device can decode, advertised in the Hello so the
* host never emits a codec the decode loop can't open: H.264 (1) and HEVC (2) always (universal
* on Android hardware), plus AV1 (4) only when [pickDecoder] finds a real (hardware, non-blocked)
* `video/av01` decoder. Enumerates `MediaCodecList` — call at connect time, not per frame.
*/
fun decodableCodecBits(): Int =
1 or 2 or (if (pickDecoder("video/av01") != null) 4 else 0)
fun pickDecoder(mime: String): DecoderChoice? {
if (mime.isEmpty()) return null
val infos = runCatching { MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos }
+15 -1
View File
@@ -20,7 +20,7 @@ punktfunk-core = { path = "../../../crates/punktfunk-core", features = ["quic"]
jni = "0.21"
log = "0.4"
# LAN host discovery: browse the host's `_punktfunk._udp` mDNS advert — the SAME crate + service the
# Linux/Windows clients use (`clients/linux/src/discovery.rs`), replacing Android's per-OEM
# Linux/Windows clients use (`crates/pf-client-core/src/discovery.rs`), replacing Android's per-OEM
# `NsdManager` system daemon with one tested browse path. Pure Rust (socket2/if-addrs/mio), so it
# cross-compiles to the Android targets AND builds on the host (the JNI seam links into
# `cargo build --workspace`). Kotlin keeps only the Wi-Fi `MulticastLock` + permission UX.
@@ -31,6 +31,14 @@ mdns-sd = "0.20"
# via `ndk`, the Opus codec) is only pulled in for the real `*-linux-android` targets.
[target.'cfg(target_os = "android")'.dependencies]
android_logger = "0.14"
# Feature bridge, no code here: punktfunk-core logs through `tracing`, but this client only
# installs `android_logger` (a `log` backend). Core transport warnings (e.g. "UDP socket buffer
# capped well below target") reach logcat only via tracing's "log" feature, which forwards events
# as `log` records when no tracing subscriber is set (always, here). Today that feature happens to
# be enabled transitively — quinn's default `log` feature unifies `tracing/log` onto the whole
# graph — but nothing about this client's logging should hinge on a QUIC crate's default feature
# set, so declare it explicitly.
tracing = { version = "0.1", default-features = false, features = ["std", "log"] }
# NDK bindings. "media" = AMediaCodec/ANativeWindow (video); "audio" = AAudio (audio playback).
# Pure-Rust FFI to libmediandk/libnativewindow/libaaudio — no C++/libc++_shared to bundle. Decode +
# audio run entirely in Rust on native threads (the "no async on the hot path" invariant).
@@ -39,6 +47,12 @@ android_logger = "0.14"
# (ANativeWindow_setFrameRate) is dlsym-resolved at runtime (see decode::try_set_frame_rate), not
# linked, so the .so still loads on API 28/29.
ndk = { version = "0.9", features = ["media", "audio", "nativewindow", "api-level-28"] }
# Raw FFI for the one AMediaCodec entry point the `ndk` wrapper lacks:
# `AMediaCodec_setOnFrameRenderedCallback` (API 26, under the minSdk-28 floor ⇒ hard-linked) — the
# per-frame render-timestamp callback behind the HUD's `display` stage (see decode::DisplayTracker).
# Reaching it needs the codec's raw pointer, which is why the workspace pins `ndk` to the vendored
# copy whose only patch makes `MediaCodec::as_ptr` public (vendor/ndk, wired in the root Cargo.toml).
ndk-sys = { version = "0.6", features = ["media"] }
# setpriority/gettid to raise the decode thread toward URGENT_DISPLAY (see decode::boost_thread_priority).
libc = "0.2"
# Opus decode for the host→client audio plane (0xC9: 48 kHz stereo, 5 ms frames). Same crate the
+262 -54
View File
@@ -36,11 +36,17 @@ const IN_FLIGHT_CAP: usize = 64;
/// this deep is a lost datagram (or an old host that never sends any) and gets evicted.
const PENDING_SPLIT_CAP: usize = 256;
/// Cap on rendered frames parked in [`DisplayTracker`] awaiting their `OnFrameRendered` render
/// timestamp: the callback trails its release by at most a vsync or two, so anything this deep
/// means the platform stopped delivering render callbacks (allowed under load, per the docs) and
/// gets evicted.
const RENDERED_CAP: usize = 64;
/// Whether low-latency mode uses the event-driven async decode loop (default) or the synchronous
/// poll loop. Flip to `false` to A/B the two on the HUD (`design/…`); the async loop presents a
/// decoded frame the instant it's ready instead of waiting out a poll interval. Only consulted when
/// the user's "Low-latency mode (experimental)" toggle is ON — off, the sync loop always runs (the
/// original pipeline).
/// the user's "Low-latency mode" toggle is ON (now the default) — off, the sync loop always runs (the
/// original pipeline, kept as the per-device escape hatch).
const USE_ASYNC_DECODE: bool = true;
/// Per-session decode configuration, resolved by the JNI layer (`nativeStartVideo`) and passed to
@@ -52,10 +58,10 @@ pub(crate) struct DecodeOptions {
/// Whether Kotlin found the chosen decoder advertises `FEATURE_LowLatency` (queryable only via
/// the Java `CodecCapabilities` API) — surfaced on the HUD next to the decoder name.
pub ll_feature: bool,
/// The user's "Low-latency mode (experimental)" master toggle. On ⇒ the full overhaul: async
/// The user's "Low-latency mode" master toggle. On (default) ⇒ the full fast pipeline: async
/// decode loop, per-SoC vendor keys, pipeline thread boosts, ADPF max-performance, forced TV
/// mode switch. Off (default) ⇒ the original pre-overhaul pipeline, kept as the known-good
/// baseline while the overhaul is experimental.
/// mode switch. Off ⇒ the original synchronous pre-overhaul pipeline, kept as the per-device
/// escape hatch.
pub low_latency_mode: bool,
/// TV form factor (Kotlin's `UiModeManager`): actively drive the HDMI output into the stream's
/// refresh mode, vs. the softer seamless hint on a phone/tablet.
@@ -208,6 +214,12 @@ fn run_sync(
// host-minus-client clock offset (0 if the host didn't answer the skew handshake — then the
// HUD flags it "(same-host clock)").
let clock_offset = client.clock_offset_ns;
// Display stage (spec `display` + the capture→displayed headline): frames released with
// render = true are parked in the tracker; the OnFrameRendered callback pairs them with
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
// reclaimed after the codec is dropped below.
let tracker = DisplayTracker::new(stats.clone(), clock_offset);
let render_cb = install_render_callback(&codec, &tracker);
// HUD stage split: receipt timestamps keyed by the pts we queue into the codec, so the decoded
// point (output-buffer dequeue — MediaCodec round-trips presentationTimeUs) can be paired back
// to its receipt for the `decode` stage. Only fed while the HUD is visible.
@@ -309,6 +321,7 @@ fn run_sync(
&stats,
&mut in_flight,
clock_offset,
&tracker,
);
rendered += r;
discarded += d;
@@ -367,6 +380,11 @@ fn run_sync(
}
let _ = codec.stop();
drop(codec); // AMediaCodec_delete — after this no render callback can fire
if let Some(ud) = render_cb {
// SAFETY: the codec was dropped above; this registration's single reclaim.
unsafe { release_render_callback(ud) };
}
log::info!("decode: stopped (fed={fed} rendered={rendered} discarded={discarded})");
}
@@ -380,6 +398,145 @@ fn now_realtime_ns() -> i128 {
.unwrap_or(0)
}
/// `CLOCK_MONOTONIC` now in nanoseconds — the base of the `systemNano` render timestamp the
/// `OnFrameRendered` callback reports (Android's `System.nanoTime`), read only to re-base that
/// stamp onto `CLOCK_REALTIME` (see [`on_frame_rendered`]).
fn now_monotonic_ns() -> i128 {
let mut ts = libc::timespec {
tv_sec: 0,
tv_nsec: 0,
};
// SAFETY: `clock_gettime` with a valid out-pointer is an always-safe syscall.
unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) };
ts.tv_sec as i128 * 1_000_000_000 + ts.tv_nsec as i128
}
/// State shared between the decode loop and the `AMediaCodec` `OnFrameRendered` callback (which
/// fires on a codec-internal thread): rendered frames awaiting their render timestamp, so the HUD
/// gets the spec's `display` stage (decoded→displayed) and the `capture→displayed` end-to-end
/// headline (`design/stats-unification.md` — this replaces Android's v1 `capture→decoded`
/// endpoint whenever the platform delivers render callbacks).
struct DisplayTracker {
stats: Arc<crate::stats::VideoStats>,
/// Host-minus-client clock offset (ns) for the skew-corrected end-to-end sample.
clock_offset: i64,
/// `(pts_us, decoded_real_ns)` of frames released with `render = true`, in release order,
/// awaiting their callback. Pushes are HUD-gated by the caller, so this stays empty (and the
/// callback early-outs) while the overlay is hidden.
rendered: Mutex<VecDeque<(u64, i128)>>,
}
impl DisplayTracker {
fn new(stats: Arc<crate::stats::VideoStats>, clock_offset: i64) -> Arc<DisplayTracker> {
Arc::new(DisplayTracker {
stats,
clock_offset,
rendered: Mutex::new(VecDeque::new()),
})
}
/// Park one just-rendered frame's `(pts, decoded stamp)` for the render callback to pair.
/// Caller gates on the HUD being visible.
fn note_rendered(&self, pts_us: u64, decoded_ns: i128) {
let mut g = self
.rendered
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
g.push_back((pts_us, decoded_ns));
if g.len() > RENDERED_CAP {
g.pop_front(); // render callbacks stopped coming (allowed under load) — evict
}
}
}
/// Register [`on_frame_rendered`] on the codec (`AMediaCodec_setOnFrameRenderedCallback`, API 26 —
/// under the minSdk-28 floor, so hard-linked via `ndk-sys`; the `ndk` wrapper has no binding, which
/// is what the vendored crate's public `as_ptr` patch is for). Returns the userdata pointer holding
/// a leaked `Arc<DisplayTracker>` refcount; the caller MUST reclaim it with
/// [`release_render_callback`] AFTER dropping the codec (`AMediaCodec_delete` is what guarantees no
/// further callback can fire). `None` (nothing to reclaim) if the platform refused — the HUD then
/// simply has no `display` stage, exactly the pre-callback behaviour.
fn install_render_callback(
codec: &MediaCodec,
tracker: &Arc<DisplayTracker>,
) -> Option<*const DisplayTracker> {
let ud = Arc::into_raw(tracker.clone());
// SAFETY: `codec.as_ptr()` is the live codec this thread owns; `ud` outlives the registration
// (reclaimed only after the codec is deleted, per this function's contract).
let status = unsafe {
ndk_sys::AMediaCodec_setOnFrameRenderedCallback(
codec.as_ptr(),
Some(on_frame_rendered),
ud as *mut c_void,
)
};
if status == ndk_sys::media_status_t::AMEDIA_OK {
Some(ud)
} else {
log::warn!("decode: setOnFrameRenderedCallback failed ({status:?}) — no display stage");
// SAFETY: registration failed, so the codec never took the reference — reclaim it now.
unsafe { drop(Arc::from_raw(ud)) };
None
}
}
/// Reclaim [`install_render_callback`]'s leaked `Arc` refcount.
///
/// # Safety
/// Call exactly once, and only after the codec the callback was registered on has been dropped —
/// deleting the codec stops its internal threads, so no callback can still be running (or run
/// later) against this pointer.
unsafe fn release_render_callback(ud: *const DisplayTracker) {
drop(Arc::from_raw(ud));
}
/// The `AMediaCodecOnFrameRendered` trampoline: fires (possibly batched) on a codec-internal
/// thread once per output frame actually placed on the output surface, with SurfaceFlinger's
/// render timestamp. That timestamp (`system_nano`) is on `CLOCK_MONOTONIC`, so it is re-based
/// onto `CLOCK_REALTIME` here — against monotonic-now at callback time, which also cancels any lag
/// between the frame rendering and the (batchable) callback delivery — to subtract against the
/// receipt/decode stamps and the host capture pts. Records the HUD's `displayed` point:
/// `end-to-end` = capture→displayed (skew-corrected) and `display` = decoded→displayed
/// (single-clock local). Panic-free by construction (poison-proof lock, saturating math) — an
/// unwind out of an `extern "C"` fn would abort the process.
unsafe extern "C" fn on_frame_rendered(
_codec: *mut ndk_sys::AMediaCodec,
userdata: *mut c_void,
media_time_us: i64,
system_nano: i64,
) {
let t = &*(userdata as *const DisplayTracker);
if !t.stats.enabled() {
return; // HUD hidden — the ring is empty too (pushes are caller-gated)
}
let displayed_ns = now_realtime_ns() - (now_monotonic_ns() - system_nano as i128);
let pts_us = media_time_us.max(0) as u64;
// Pair the frame back to its release record, evicting older entries (their callbacks were
// dropped by the platform, or the entry predates a HUD toggle) — same monotonic-eviction
// discipline as `note_decoded_pts`.
let mut decoded_ns = None;
{
let mut g = t
.rendered
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
while let Some(&(p, d)) = g.front() {
if p > pts_us {
break; // future frame — leave it for its own callback
}
g.pop_front();
if p == pts_us {
decoded_ns = Some(d);
break;
}
}
}
let e2e_ns = displayed_ns + t.clock_offset as i128 - pts_us as i128 * 1000;
let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64);
let display_us = decoded_ns.map(|d| ((displayed_ns - d).max(0) / 1000) as u64);
t.stats.note_displayed(e2e_us, display_us);
}
/// The MediaCodec MIME for the codec the host resolved (`Welcome.codec`). Shared by the decode
/// thread and `nativeVideoMime` (which tells Kotlin what to rank decoders for). AV1 uses the
/// AOSP `video/av01` type; anything not H.264/AV1 is treated as HEVC (every pre-negotiation host
@@ -409,10 +566,10 @@ fn create_codec(mime: &str, preferred: Option<&str>) -> Option<MediaCodec> {
/// Apply the low-latency MediaFormat keys for `codec_name`.
///
/// `aggressive` = the "Low-latency mode (experimental)" master toggle. **Off** (default) ⇒ the
/// pre-overhaul key set, byte-for-byte — the standard `low-latency` key, the blind Qualcomm vendor
/// twin, `priority = 0` AND `operating-rate = MAX` set together — kept as the known-good baseline
/// (the profile every device streamed with before the overhaul). **On** ⇒ the Moonlight-parity
/// `aggressive` = the "Low-latency mode" master toggle. **Off** ⇒ the pre-overhaul key set,
/// byte-for-byte — the standard `low-latency` key, the blind Qualcomm vendor twin, `priority = 0` AND
/// `operating-rate = MAX` set together — kept as the per-device escape hatch (the profile every device
/// streamed with before the overhaul). **On** (default) ⇒ the Moonlight-parity
/// profile: MediaTek's `vdec-lowlatency` (unconditionally — ignored off MediaTek), the per-SoC
/// vendor extension keys (gated on the decoder-name prefix the way Moonlight-Android does, since a
/// key one vendor honours is meaningless on another), and one *mutually exclusive* clock hint.
@@ -493,10 +650,14 @@ fn decoder_supports_max_operating_rate(name_lower: &str) -> bool {
}
/// One decoded output buffer ready to release: its codec buffer index + the pts the codec echoed
/// (from the output callback's `BufferInfo`), used to pair the `decode` HUD stat.
/// (from the output callback's `BufferInfo`), used to pair the `decode` HUD stat, and the
/// wall-clock instant the output callback fired — the spec's `decoded` point ("decoder output
/// frame available"), stamped at the callback so the event-channel hop + coalescing wait in the
/// loop never inflates the decode stage.
struct OutputReady {
index: usize,
pts_us: u64,
decoded_ns: i128,
}
/// Events the async decode loop reacts to. The codec's async-notify callbacks (which run on its
@@ -507,8 +668,12 @@ enum DecodeEvent {
Au(Frame),
/// An input buffer slot freed (index) — we can queue an AU into it.
InputAvailable(usize),
/// A decoded frame is ready (buffer index + echoed pts).
OutputAvailable { index: usize, pts_us: u64 },
/// A decoded frame is ready (buffer index + echoed pts + the callback-time `decoded` stamp).
OutputAvailable {
index: usize,
pts_us: u64,
decoded_ns: i128,
},
/// The output format changed — re-check the stream's colour signalling (HDR DataSpace).
FormatChanged,
/// The codec reported an error; `fatal` when neither recoverable nor transient.
@@ -569,6 +734,10 @@ fn run_async(
let _ = out_tx.send(DecodeEvent::OutputAvailable {
index: idx,
pts_us: info.presentation_time_us().max(0) as u64,
// The `decoded` HUD point: stamp HERE, on the codec's looper thread, so the
// decode stage ends when the frame actually became available — not after the
// channel hop + whatever work the loop coalesces in front of presenting it.
decoded_ns: now_realtime_ns(),
});
})),
on_format_changed: Some(Box::new(move |_fmt| {
@@ -637,6 +806,12 @@ fn run_async(
// HUD is visible.
let clock_offset = client.clock_offset_ns;
let in_flight = Arc::new(Mutex::new(VecDeque::<(u64, i128)>::new()));
// Display stage (spec `display` + the capture→displayed headline): the rendered frame is
// parked in the tracker at release; the OnFrameRendered callback pairs it with
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
// reclaimed after the codec is dropped below.
let tracker = DisplayTracker::new(stats.clone(), clock_offset);
let 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
// wakes us immediately, with no input-side poll latency). It also records the `received` HUD stat.
@@ -697,29 +872,30 @@ fn run_async(
};
let work_t0 = Instant::now();
let mut fmt_dirty = false;
let mut au_dropped = false;
let mut aus_dropped: u64 = 0;
if let Some(ev) = ev0 {
au_dropped |= dispatch_event(
aus_dropped += u64::from(dispatch_event(
ev,
&mut pending_aus,
&mut free_inputs,
&mut ready,
&mut fmt_dirty,
&mut fatal,
);
));
}
// Coalesce every other event already queued into this one work pass — correct newest-only
// presentation across a decode burst, and batched feeding.
while let Ok(ev) = ev_rx.try_recv() {
au_dropped |= dispatch_event(
aus_dropped += u64::from(dispatch_event(
ev,
&mut pending_aus,
&mut free_inputs,
&mut ready,
&mut fmt_dirty,
&mut fatal,
);
));
}
stats.note_skipped(aus_dropped); // parked-AU overflow drops are client-side skips too
if fmt_dirty {
apply_hdr_dataspace(&codec, &window, &mut applied_ds);
}
@@ -731,6 +907,7 @@ fn run_async(
&stats,
&in_flight,
clock_offset,
&tracker,
&mut rendered,
&mut discarded,
);
@@ -768,7 +945,7 @@ fn run_async(
// dropped a parked AU on overflow), throttled so a multi-frame recovery gap doesn't flood the
// control stream.
let dropped = client.frames_dropped();
if dropped > last_dropped || au_dropped {
if dropped > last_dropped || aus_dropped > 0 {
last_dropped = dropped;
let now = Instant::now();
if last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) {
@@ -783,6 +960,11 @@ fn run_async(
if let Some(j) = feeder {
let _ = j.join();
}
drop(codec); // AMediaCodec_delete — after this no render callback can fire
if let Some(ud) = render_cb {
// SAFETY: the codec was dropped above; this registration's single reclaim.
unsafe { release_render_callback(ud) };
}
log::info!("decode: stopped (async, fed={fed} rendered={rendered} discarded={discarded})");
}
@@ -863,7 +1045,15 @@ fn dispatch_event(
}
}
DecodeEvent::InputAvailable(i) => free_inputs.push_back(i),
DecodeEvent::OutputAvailable { index, pts_us } => ready.push(OutputReady { index, pts_us }),
DecodeEvent::OutputAvailable {
index,
pts_us,
decoded_ns,
} => ready.push(OutputReady {
index,
pts_us,
decoded_ns,
}),
DecodeEvent::FormatChanged => *fmt_dirty = true,
DecodeEvent::Error { fatal: f } => {
if f {
@@ -917,13 +1107,17 @@ fn feed_ready(
/// burst of stale frames on glass is worse than skipping to the freshest (the sync loop's newest-ready
/// policy, callback-driven). Every dequeued buffer, rendered or not, is the HUD's `decoded`
/// measurement point (it finished decoding either way); samples are recorded in pts order so the
/// receipt-map eviction stays monotonic. `ready` is drained.
/// receipt-map eviction stays monotonic. The presented frame's `(pts, decoded stamp)` is parked in
/// `tracker` for the OnFrameRendered callback — the `display` stage's other endpoint. `ready` is
/// drained.
#[allow(clippy::too_many_arguments)] // one call site; mirrors the sync loop's drain
fn present_ready(
codec: &MediaCodec,
ready: &mut Vec<OutputReady>,
stats: &crate::stats::VideoStats,
in_flight: &Mutex<VecDeque<(u64, i128)>>,
clock_offset: i64,
tracker: &DisplayTracker,
rendered: &mut u64,
discarded: &mut u64,
) {
@@ -935,15 +1129,24 @@ fn present_ready(
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
for o in ready.iter() {
note_decoded_pts(stats, &mut g, clock_offset, o.pts_us);
note_decoded_pts(stats, &mut g, clock_offset, o.pts_us, o.decoded_ns);
}
}
let last = ready.len() - 1;
let mut skipped: u64 = 0;
for (i, o) in ready.drain(..).enumerate() {
let render = i == last;
match codec.release_output_buffer_by_index(o.index, render) {
Ok(()) if render => *rendered += 1,
Ok(()) => *discarded += 1,
Ok(()) if render => {
*rendered += 1;
if stats.enabled() {
tracker.note_rendered(o.pts_us, o.decoded_ns);
}
}
Ok(()) => {
*discarded += 1;
skipped += 1;
}
Err(e) => {
log::warn!(
"decode: release_output_buffer_by_index({}, {render}): {e}",
@@ -952,6 +1155,7 @@ fn present_ready(
}
}
}
stats.note_skipped(skipped); // HUD `skipped` counter (newest-wins drops); no-op while hidden
}
/// React to an output-format change by signalling the stream's HDR dataspace on the Surface (SDR
@@ -1117,7 +1321,10 @@ fn feed(codec: &MediaCodec, au: &[u8], pts_us: u64) -> bool {
/// Each dequeued buffer is also the HUD's `decoded` measurement point (rendered or not — the frame
/// finished decoding either way): end-to-end = decoded + clock_offset capture pts, and the
/// `decode` stage pairs the buffer's echoed presentationTimeUs back to the receipt stamp in
/// `in_flight` (single-clock local difference, no skew involved).
/// `in_flight` (single-clock local difference, no skew involved). The presented frame's
/// `(pts, decoded stamp)` is additionally parked in `tracker` for the OnFrameRendered callback —
/// the `display` stage's other endpoint.
#[allow(clippy::too_many_arguments)] // one call site; mirrors the async loop's present_ready
fn drain(
codec: &MediaCodec,
window: &NativeWindow,
@@ -1126,23 +1333,33 @@ fn drain(
stats: &crate::stats::VideoStats,
in_flight: &mut VecDeque<(u64, i128)>,
clock_offset: i64,
tracker: &DisplayTracker,
) -> (u64, u64) {
let mut held = None; // newest ready buffer so far, presented after the loop
// Newest ready buffer so far (presented after the loop) with its HUD metadata —
// `Some((pts_us, decoded_ns))` only while the HUD is visible (the stamp read is gated).
let mut held: Option<(OutputBuffer<'_>, Option<(u64, i128)>)> = None;
let mut discarded: u64 = 0;
let mut wait = first_wait;
loop {
match codec.dequeue_output_buffer(wait) {
Ok(DequeuedOutputBufferInfoResult::Buffer(buf)) => {
wait = Duration::ZERO; // only the first dequeue may block
if stats.enabled() {
note_decoded(stats, in_flight, clock_offset, &buf);
}
if let Some(stale) = held.replace(buf) {
let meta = if stats.enabled() {
// The dequeue IS the sync loop's decoded-availability instant.
let pts_us = buf.info().presentation_time_us().max(0) as u64;
let decoded_ns = now_realtime_ns();
note_decoded_pts(stats, in_flight, clock_offset, pts_us, decoded_ns);
Some((pts_us, decoded_ns))
} else {
None
};
if let Some((stale, _)) = held.replace((buf, meta)) {
// A newer frame is ready — drop the held one without rendering.
if let Err(e) = codec.release_output_buffer(stale, false) {
log::warn!("decode: release_output_buffer(discard): {e}");
}
discarded += 1;
stats.note_skipped(1); // HUD `skipped` counter; no-op while hidden
}
}
Ok(DequeuedOutputBufferInfoResult::OutputFormatChanged) => {
@@ -1175,46 +1392,37 @@ fn drain(
}
}
}
// Present the newest ready frame, if any.
// Present the newest ready frame, if any, and park its metadata for the render callback.
let mut rendered = 0;
if let Some(buf) = held {
if let Some((buf, meta)) = held {
match codec.release_output_buffer(buf, true) {
Ok(()) => rendered = 1,
Ok(()) => {
rendered = 1;
if let Some((pts_us, decoded_ns)) = meta {
tracker.note_rendered(pts_us, decoded_ns);
}
}
Err(e) => log::warn!("decode: release_output_buffer: {e}"),
}
}
(rendered, discarded)
}
/// HUD `decoded` point for one dequeued output buffer: build the end-to-end (capture→decoded,
/// skew-corrected, clamped to (0, 10 s)) and `decode` (received→decoded, single-clock local, ≥ 0)
/// samples and hand them to [`crate::stats::VideoStats::note_decoded`]. The codec echoes the input
/// `presentationTimeUs` on the output buffer, which keys the receipt stamp in `in_flight`; entries
/// older than the echoed pts are evicted (decode order == input order here — low-latency, no
/// HUD `decoded` point for one dequeued output frame, keyed by the echoed `presentationTimeUs`:
/// build the end-to-end (capture→decoded, skew-corrected, clamped to (0, 10 s)) and `decode`
/// (received→decoded, single-clock local, ≥ 0) samples and hand them to
/// [`crate::stats::VideoStats::note_decoded`]. The pts keys the receipt stamp in `in_flight`;
/// entries older than it are evicted (decode order == input order here — low-latency, no
/// B-frames — so anything before it was dropped inside the codec or stamped before a flush).
fn note_decoded(
stats: &crate::stats::VideoStats,
in_flight: &mut VecDeque<(u64, i128)>,
clock_offset: i64,
buf: &OutputBuffer<'_>,
) {
note_decoded_pts(
stats,
in_flight,
clock_offset,
buf.info().presentation_time_us().max(0) as u64,
);
}
/// The [`note_decoded`] body keyed by the echoed `presentationTimeUs` directly — the async loop has
/// the pts (from the output callback's `BufferInfo`) but no borrowed `OutputBuffer`, so it calls this.
/// `decoded_ns` is the availability instant: the dequeue (sync loop) or the output callback's
/// stamp (async loop).
fn note_decoded_pts(
stats: &crate::stats::VideoStats,
in_flight: &mut VecDeque<(u64, i128)>,
clock_offset: i64,
pts_us: u64,
decoded_ns: i128,
) {
let decoded_ns = now_realtime_ns();
// Pair the echoed pts back to its receipt stamp, evicting stale (older) entries as we go.
let mut received_ns = None;
while let Some(&(p, r)) = in_flight.front() {
+1 -1
View File
@@ -1,5 +1,5 @@
//! LAN host discovery over mDNS, in Rust via `mdns-sd` — the same crate + service type the
//! Linux/Windows clients use (`clients/linux/src/discovery.rs`), exposed to Kotlin over JNI.
//! Linux/Windows clients use (`crates/pf-client-core/src/discovery.rs`), exposed to Kotlin over JNI.
//!
//! Why not `NsdManager`: that API delegates to a per-OEM system mDNS daemon whose reliability
//! varies wildly (the Android client's discovery was "mostly broken"). Browsing in our own Rust
+7 -1
View File
@@ -42,9 +42,15 @@ mod stats;
// Ungated like `discovery`: pure `jni` + `punktfunk_core::wol` (no Android framework), so it links
// into the host workspace build too. Kotlin only ever calls it on device.
mod wol;
// Ungated like `wol`: pure `jni` + `punktfunk_core::client` (the reachability probe). Kotlin calls
// it off the main thread to light saved-host "online" pips independently of mDNS.
mod probe;
/// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the
/// `punktfunk` tag. Android-only — there is no JVM (and no logcat) on the host build.
/// `punktfunk` tag. Core `tracing` events (transport warnings: socket-buffer clamp, QoS failures)
/// arrive here too: tracing's "log" feature — declared explicitly in Cargo.toml rather than relied
/// on via quinn's defaults — forwards them as `log` records since no tracing subscriber is ever
/// installed. Android-only — there is no JVM (and no logcat) on the host build.
#[cfg(target_os = "android")]
#[no_mangle]
pub extern "system" fn JNI_OnLoad(
+36
View File
@@ -0,0 +1,36 @@
//! JNI seam for the reachability probe: a bounded, trust-agnostic QUIC handshake to a saved host
//! (`punktfunk_core::client::NativeClient::probe`). Like [`crate::wol`] it takes no session handle
//! and links into the host workspace build (pure `jni` + `punktfunk_core`). Kotlin calls it
//! periodically, on a background dispatcher, to light the "online" pip for saved hosts that never
//! advertise on mDNS (reached over Tailscale / VPN / another subnet) — the display-side companion
//! to the dial-first connect fix.
use jni::objects::{JObject, JString};
use jni::sys::{jboolean, jint};
use jni::JNIEnv;
use punktfunk_core::client::NativeClient;
use std::time::Duration;
/// `NativeBridge.nativeProbe(host, port, timeoutMs): Boolean` — true if `host:port` completed a
/// QUIC handshake within `timeoutMs`. No pin/identity presented (trust-agnostic), mDNS-independent.
/// Blocking (builds its own runtime) — Kotlin runs it on `Dispatchers.IO`, never the main thread.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeProbe<'local>(
mut env: JNIEnv<'local>,
_this: JObject<'local>,
host: JString<'local>,
port: jint,
timeout_ms: jint,
) -> jboolean {
let host: String = match env.get_string(&host) {
Ok(s) => s.into(),
Err(_) => return 0,
};
let port = port.clamp(0, u16::MAX as jint) as u16;
let timeout = Duration::from_millis(timeout_ms.max(0) as u64);
if NativeClient::probe(&host, port, timeout) {
1
} else {
0
}
}
+18 -4
View File
@@ -76,6 +76,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
gamepad_pref: jint,
hdr_enabled: jboolean,
audio_channels: jint,
video_codecs: jint,
preferred_codec: jint,
timeout_ms: jint,
launch: JString<'local>,
@@ -142,10 +143,23 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
// decoder + AAudio layout (read in `crate::audio::AudioPlayback::start`). Anything else
// normalizes to stereo here.
punktfunk_core::audio::normalize_channels(audio_channels.clamp(0, u8::MAX as jint) as u8),
// Codecs this device can decode — AMediaCodec decodes both HEVC and H.264 (AV1 isn't wired;
// hosts don't emit it on the native path yet). The host resolves the emitted codec from these
// + the soft `preferred_codec` and echoes it in `connector.codec`, which drives the mime below.
punktfunk_core::quic::CODEC_H264 | punktfunk_core::quic::CODEC_HEVC,
// Codecs this device can decode, ranked on the Kotlin side (`VideoDecoders.decodableCodecBits`:
// H.264 + HEVC always, AV1 when a real `video/av01` decoder exists — AMediaCodec is
// mime-driven, see `codec_mime`). Mask to the known bits and fall back to the pre-AV1
// H.264|HEVC pair on 0 so a bogus value can't advertise nothing and kill the handshake.
// The host resolves the emitted codec from these + the soft `preferred_codec` and echoes it
// in `connector.codec`, which drives the mime below.
{
let bits = (video_codecs.clamp(0, u8::MAX as jint) as u8)
& (punktfunk_core::quic::CODEC_H264
| punktfunk_core::quic::CODEC_HEVC
| punktfunk_core::quic::CODEC_AV1);
if bits == 0 {
punktfunk_core::quic::CODEC_H264 | punktfunk_core::quic::CODEC_HEVC
} else {
bits
}
},
preferred_codec.clamp(0, u8::MAX as jint) as u8,
launch, // a store-qualified library id to boot into a game, or None for the desktop
pin, // Some → Crypto on host-fp mismatch
+39 -7
View File
@@ -144,16 +144,25 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
}
/// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD
/// (unified stats spec, `design/stats-unification.md`). Returns 18 doubles
/// (unified stats spec, `design/stats-unification.md`). Returns 26 doubles
/// `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
/// netP50Ms]`
/// (the two flags are 1.0/0.0; indexes 015 match the previous 16-double layout — 013 the original
/// netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow, dispValid, displayP50Ms,
/// e2eDispP50Ms, e2eDispP95Ms]`
/// (the flags are 1.0/0.0; indexes 021 match the previous 22-double layout — 013 the original
/// 14-double one with the latency pair re-based to the end-to-end capture→decoded headline, 14/15
/// the stage p50s tiling it: `host+network` = capture→received, `decode` = received→decoded; 16/17
/// are the Phase-2 split of the `host+network` term from the per-AU 0xCF host timings — `host` =
/// the host's capture→sent, `network` = the remainder — both 0.0 when no timing matched this
/// window, i.e. an old host), or `null` when no decode thread is running. Poll ~1 Hz from the UI; each call
/// window, i.e. an old host; 1821 are the spec's per-window line-4 counters — `lost` =
/// unrecoverable drops this window, `skipped` = client newest-wins/pacing drops, `fec` = shards
/// recovered, `frames` = AUs received, so the HUD can compute `lost/(frames+lost)` — index 9 stays
/// the cumulative session total for older readers; 2225 are the `display` stage from the
/// OnFrameRendered render timestamps — when `dispValid` is 1.0 the HUD headline becomes the
/// directly-measured capture→displayed pair at 24/25 with `display` = decoded→displayed p50 at 23
/// closing the equation, and when 0.0 — no render callback landed this window — it falls back to
/// the capture→decoded headline at 2/3), or `null` when no decode thread is running.
/// Poll ~1 Hz from the UI; each call
/// resets the measurement window. Not android-gated — pure `jni` + connector reads, so it links on
/// the host build too (Kotlin only ever calls it on device).
#[no_mangle]
@@ -171,10 +180,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
if h.video.lock().unwrap().is_none() {
return std::ptr::null_mut(); // not streaming → no stats
}
let snap = h.stats.drain();
let snap = h
.stats
.drain(h.client.frames_dropped(), h.client.fec_recovered_shards());
let mode = h.client.mode();
let color = h.client.color;
let buf: [f64; 18] = [
let buf: [f64; 26] = [
snap.fps,
snap.mbps,
snap.e2e_p50_ms,
@@ -200,6 +211,21 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
// when no timing matched this window (old host) — the HUD keeps the combined term.
snap.host_p50_ms,
snap.net_p50_ms,
// Spec line-4 counters, per-window: lost (unrecoverable drops), skipped (client
// newest-wins/pacing drops), FEC shards recovered, and the received-AU count so the
// HUD computes the loss percentage `lost/(frames+lost)` exactly.
snap.lost as f64,
snap.skipped as f64,
snap.fec as f64,
snap.frames as f64,
// `display` stage (OnFrameRendered render timestamps): validity flag, the
// decoded→displayed stage p50, and the directly-measured capture→displayed headline
// pair that supersedes 2/3 whenever the flag is set (spec: the equation always tiles
// the headline interval, so endpoint and terms move together).
if snap.disp_valid { 1.0 } else { 0.0 },
snap.display_p50_ms,
snap.e2e_disp_p50_ms,
snap.e2e_disp_p95_ms,
];
let arr = match env.new_double_array(buf.len() as jsize) {
Ok(a) => a,
@@ -228,7 +254,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoSta
if handle != 0 {
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
h.stats.set_enabled(enabled != 0);
// The current cumulative counters seed the window baselines, so the first snapshot's
// `lost`/`FEC` cover only time the HUD was actually up.
h.stats.set_enabled(
enabled != 0,
h.client.frames_dropped(),
h.client.fec_recovered_shards(),
);
}
})
}
+120 -10
View File
@@ -1,9 +1,15 @@
//! Live decode stats for the on-stream HUD, following the unified stats spec
//! (`design/stats-unification.md`): FPS, receive throughput, and the Android v1 stage split —
//! headline `end-to-end` = capture→decoded (p50/p95) tiled by `host+network` = capture→received
//! and `decode` = received→decoded (stage p50s). When the host emits per-AU 0xCF host timings, the
//! `host+network` term further splits into `host` + `network` (Phase 2, `note_host_split`); an old
//! host emits none and the combined term stands. The decode thread is the sole writer
//! (`design/stats-unification.md`): FPS, receive throughput, and the full stage split — headline
//! `end-to-end` = capture→displayed (p50/p95, measured directly from the `OnFrameRendered` render
//! timestamps — `note_displayed`) tiled by `host+network` = capture→received, `decode` =
//! received→decoded, and `display` = decoded→displayed (stage p50s). When the platform delivers no
//! render callbacks (allowed under load; `disp_valid` false), the HUD falls back to the v1
//! capture→decoded headline without the `display` term. When the host emits per-AU 0xCF host
//! timings, the `host+network` term further splits into `host` + `network` (Phase 2,
//! `note_host_split`); an old host emits none and the combined term stands. The spec's line-4 counters are per-window too:
//! `lost` / `FEC` are windowed here from the connector's cumulative counters (the caller passes the
//! current totals into `set_enabled`/`drain`), `skipped` counts the client's own newest-wins drops
//! (`note_skipped`). The decode thread is the sole writer
//! (`note_received` per access unit at receipt, `note_decoded` per decoder output buffer); the JNI
//! accessor `nativeVideoStats` drains a snapshot ~1 Hz and resets the window. Sampling is gated on
//! the HUD actually being visible (`set_enabled`, driven by `nativeSetVideoStatsEnabled`) so the
@@ -54,6 +60,21 @@ struct Inner {
net_us: Vec<u64>,
/// `decode` stage = received→decoded samples, in microseconds (client-local, single clock).
decode_us: Vec<u64>,
/// `display` stage = decoded→displayed samples, in microseconds (client-local, single clock),
/// from the `OnFrameRendered` render timestamps. Empty when the platform delivers no render
/// callbacks — the HUD then drops the term and the headline endpoint moves back to `decoded`.
display_us: Vec<u64>,
/// `end-to-end` = capture→displayed samples, µs (skew-corrected) — the spec's headline,
/// measured directly (not summed from stages). Empty under the same fallback as `display_us`.
e2e_disp_us: Vec<u64>,
/// Client-side newest-wins/pacing drops this window (decoded frames released without
/// rendering, or parked AUs dropped on overflow) — the spec's `skipped` counter.
skipped: u64,
/// Baselines for windowing the session-cumulative connector counters: the unrecoverable-drop
/// and FEC-recovered totals as of the last drain (or the enable that opened the window), so
/// each snapshot reports only THIS window's `lost` / `FEC` (spec line 4).
last_dropped_total: u64,
last_fec_total: u64,
/// Whether the host answered the clock-skew handshake (latency is cross-machine valid).
skew_corrected: bool,
}
@@ -64,18 +85,38 @@ struct Inner {
pub struct Snapshot {
pub fps: f64,
pub mbps: f64,
/// Headline `end-to-end` (capture→decoded) percentiles, ms.
/// Headline `end-to-end` (capture→decoded) percentiles, ms — the fallback headline when no
/// render callback landed this window (`disp_valid` false).
pub e2e_p50_ms: f64,
pub e2e_p95_ms: f64,
/// Stage p50s (ms): `host+network` (capture→received) and `decode` (received→decoded).
/// The full headline: `end-to-end` = capture→displayed percentiles, ms, measured directly from
/// the render timestamps. Meaningful only when `disp_valid`.
pub e2e_disp_p50_ms: f64,
pub e2e_disp_p95_ms: f64,
/// Stage p50s (ms): `host+network` (capture→received), `decode` (received→decoded), and
/// `display` (decoded→displayed; 0.0 when `disp_valid` is false — the HUD drops the term).
pub hostnet_p50_ms: f64,
pub decode_p50_ms: f64,
pub display_p50_ms: f64,
/// Whether any capture→displayed sample landed this window — gates the HUD's headline endpoint
/// (`capture→displayed` vs the capture→decoded fallback) and the equation's `display` term.
pub disp_valid: bool,
/// Phase-2 `host` / `network` split p50s (ms) — 0.0 when no 0xCF timing matched this window
/// (old host / no samples yet), in which case the HUD keeps the combined `host+network` term.
pub host_p50_ms: f64,
pub net_p50_ms: f64,
pub lat_valid: bool,
pub skew_corrected: bool,
/// Access units received this window (the count behind `fps`) — lets the HUD compute the
/// spec's loss percentage `lost / (received + lost)` exactly.
pub frames: u64,
/// Unrecoverable network frame drops this window (spec `lost`, windowed from the
/// session-cumulative connector counter).
pub lost: u64,
/// Client-side newest-wins/pacing drops this window (spec `skipped`).
pub skipped: u64,
/// FEC shards recovered this window (spec `FEC`, windowed from the cumulative counter).
pub fec: u64,
}
/// Percentile over a sorted-in-place µs sample vec, in ms. 0.0 when empty.
@@ -101,6 +142,11 @@ impl VideoStats {
host_us: Vec::with_capacity(256),
net_us: Vec::with_capacity(256),
decode_us: Vec::with_capacity(256),
display_us: Vec::with_capacity(256),
e2e_disp_us: Vec::with_capacity(256),
skipped: 0,
last_dropped_total: 0,
last_fec_total: 0,
skew_corrected: false,
}),
}
@@ -115,8 +161,10 @@ impl VideoStats {
}
/// Toggle sampling. Enabling resets the window, so the first HUD poll after a show never mixes
/// in counters (or a window start) from before the overlay was visible.
pub fn set_enabled(&self, on: bool) {
/// in counters (or a window start) from before the overlay was visible. `dropped_total` /
/// `fec_total` are the connector's session-cumulative counters at this instant — they seed the
/// windowing baselines so the first snapshot's `lost` / `FEC` cover only time the HUD was up.
pub fn set_enabled(&self, on: bool, dropped_total: u64, fec_total: u64) {
let was = self.enabled.swap(on, Ordering::Relaxed);
if on && !was {
let mut g = self
@@ -131,6 +179,11 @@ impl VideoStats {
g.host_us.clear();
g.net_us.clear();
g.decode_us.clear();
g.display_us.clear();
g.e2e_disp_us.clear();
g.skipped = 0;
g.last_dropped_total = dropped_total;
g.last_fec_total = fec_total;
}
}
@@ -206,6 +259,22 @@ impl VideoStats {
g.net_us.push(net_us);
}
/// Record client-side frame skips (spec `skipped`): decoded output buffers released without
/// rendering under the newest-wins policy, or parked AUs dropped on queue overflow.
// Driven only by the android-only decode thread; unreferenced on the host build — expected.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub fn note_skipped(&self, n: u64) {
if n == 0 || !self.enabled.load(Ordering::Relaxed) {
return; // HUD hidden — skip the lock
}
// Poison-proof for the same reason as `note_received`.
let mut g = self
.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
g.skipped += n;
}
/// Record one decoded output frame: its capture→decoded `end-to-end` sample and its
/// received→decoded `decode` stage sample (either may be absent — e.g. the receipt stamp for
/// this pts predates the HUD being shown).
@@ -228,8 +297,34 @@ impl VideoStats {
}
}
/// Record one displayed frame (the `OnFrameRendered` render timestamp, re-based to the
/// realtime clock): its capture→displayed `end-to-end` sample and its decoded→displayed
/// `display` stage sample (either may be absent — the e2e clamp rejected an out-of-range
/// value, or the decoded stamp for this pts was already evicted/pre-HUD). Fired from the
/// codec's render-callback thread, not the decode thread — the lock makes that safe.
// Driven only by the android-only decode path; unreferenced on the host build — expected.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub fn note_displayed(&self, e2e_us: Option<u64>, display_us: Option<u64>) {
if !self.enabled.load(Ordering::Relaxed) {
return; // HUD hidden — skip the lock (the callback already skipped the clock reads)
}
// Poison-proof for the same reason as `note_received`.
let mut g = self
.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(l) = e2e_us {
g.e2e_disp_us.push(l);
}
if let Some(l) = display_us {
g.display_us.push(l);
}
}
/// Compute the window's rates + latency percentiles, then reset for the next window.
pub fn drain(&self) -> Snapshot {
/// `dropped_total` / `fec_total` are the connector's session-cumulative counters now; the
/// snapshot's `lost` / `fec` are their deltas since the last drain (or the enabling show).
pub fn drain(&self, dropped_total: u64, fec_total: u64) -> Snapshot {
// Poison-proof for the same reason as `note_received` — a poisoned window still drains
// fine.
let mut g = self
@@ -244,17 +339,27 @@ impl VideoStats {
g.host_us.sort_unstable();
g.net_us.sort_unstable();
g.decode_us.sort_unstable();
g.display_us.sort_unstable();
g.e2e_disp_us.sort_unstable();
let snap = Snapshot {
fps,
mbps,
e2e_p50_ms: pctl_ms(&g.e2e_us, 0.50),
e2e_p95_ms: pctl_ms(&g.e2e_us, 0.95),
e2e_disp_p50_ms: pctl_ms(&g.e2e_disp_us, 0.50),
e2e_disp_p95_ms: pctl_ms(&g.e2e_disp_us, 0.95),
hostnet_p50_ms: pctl_ms(&g.hostnet_us, 0.50),
decode_p50_ms: pctl_ms(&g.decode_us, 0.50),
display_p50_ms: pctl_ms(&g.display_us, 0.50),
disp_valid: !g.e2e_disp_us.is_empty(),
host_p50_ms: pctl_ms(&g.host_us, 0.50),
net_p50_ms: pctl_ms(&g.net_us, 0.50),
lat_valid: !g.e2e_us.is_empty(),
skew_corrected: g.skew_corrected,
frames: g.frames,
lost: dropped_total.saturating_sub(g.last_dropped_total),
skipped: g.skipped,
fec: fec_total.saturating_sub(g.last_fec_total),
};
g.window_start = Instant::now();
g.frames = 0;
@@ -264,6 +369,11 @@ impl VideoStats {
g.host_us.clear();
g.net_us.clear();
g.decode_us.clear();
g.display_us.clear();
g.e2e_disp_us.clear();
g.skipped = 0;
g.last_dropped_total = dropped_total;
g.last_fec_total = fec_total;
snap
}
}
@@ -0,0 +1,3 @@
/target
**/*.rs.bk
Cargo.lock
+137
View File
@@ -0,0 +1,137 @@
# Unreleased
# 0.9.0 (2024-04-26)
- Move `MediaFormat` from `media::media_codec` to its own `media::media_format` module. (#442)
- media_format: Expose `MediaFormat::copy()` and `MediaFormat::clear()` from API level 29. (#449)
- **Breaking:** media_format: Mark all `fn set_*()` and `fn str()` as taking `self` by `&mut`. (#452)
- **Breaking:** Require all `dyn Fn*` types to implement `Send` when the FFI implementation invokes them on a separate thread: (#455)
- `audio::AudioStreamDataCallback`;
- `audio::AudioStreamErrorCallback`;
- `media::image_reader::BufferRemovedListener`;
- `media::image_reader::ImageListener`;
- `media::media_codec::ErrorCallback`;
- `media::media_codec::FormatChangedCallback`;
- `media::media_codec::InputAvailableCallback`;
- `media::media_codec::OutputAvailableCallback`.
- Drop previous `Box`ed callbacks _after_ registering new ones, instead of before. (#455)
- input_queue: Add `from_java()` constructor, available since API level 33. (#456)
- event: Add `from_java()` constructors to `KeyEvent` and `MotionEvent`, available since API level 31. (#456)
- **Breaking:** image_reader: Special-case return statuses in `Image`-acquire functions. (#457)
- **Breaking:** image_reader: Mark `ImageReader::acquire_latest_image_async()` `unsafe` to match the safety requirements on `ImageReader::acquire_next_image_async()`. (#457)
- event: Implement `SourceClass` `bitflag` and provide `Source::class()` getter. (#458)
- Ensure all `bitflags` implementations consider all (including unknown) bits in negation and `all()`. (#458)
- **Breaking:** Mark all enums as `non_exhaustive` and fix `repr` types. (#459)
- **Breaking:** native_window: Remove redundant `TRANSFORM_` prefix from `NativeWindowTransform` variants. (#460)
- **Breaking:** hardware_buffer: Convert `HardwareBufferUsage` to `bitflags`. (#461)
- bitmap: Guard `BitmapCompressError` behind missing `api-level-30` feature. (#462)
- native_window: Require linking against `libnativewindow` for most API >= 26 functions. (#465)
- **Breaking:** audio: Merge `AudioResult` variant enum into `AudioError`. (#467)
- data_space: Add missing `DataSpaceRange::Unspecified` variant. (#468)
- **Breaking:** looper: Require `Send` marker when adding fd event callbacks on `ForeignLooper`. (#469)
- **Breaking:** Upgrade to [`ndk-sys 0.6.0`](../ndk-sys/CHANGELOG.md#060-2024-04-26). (#472)
# 0.8.0 (2023-10-15)
- event: Add `tool_type` getter for `Pointer`. (#323)
- input_queue: Allow any non-zero return code from `pre_dispatch()` again, as per documentation. (#325)
- asset: Use entire asset length when mapping buffer. (#387)
- Bump MSRV to 1.66 for `raw-window-handle 0.5.1`, `num_enum`'s `catch_all` with arbitrary enum discriminants. (#388, #431)
- Bump optional `jni` dependency for doctest example from `0.19` to `0.21`. (#390)
- **Breaking:** Upgrade to [`ndk-sys 0.5.0`](../ndk-sys/CHANGELOG.md#050-2023-10-15). (#370)
- **Breaking:** Upgrade `bitflags` crate from `1` to `2`. (#394)
- bitmap: Add `try_format()` to `AndroidBitmapInfo` to handle unexpected formats without panicking. (#395)
- Add `Font` bindings. (#397)
- **Breaking:** Upgrade `num_enum` crate from `0.5.1` to `0.7`. (#398, #419)
- **Breaking:** Renamed, moved and flattened "`media`" error types and helpers to a new `media_error` module. (#399, #432)
- **Breaking:** media_codec: Wrap common dequeued-buffer status codes in enum. (#401)
- **Breaking:** media_codec: Return `MaybeUninit` bytes in `buffer_mut()`. (#403)
- native_window: Add `lock()` to blit raw pixel data. (#404)
- hardware_buffer_format: Add `YCbCr_P010` and `R8_UNORM` variants. (#405)
- **Breaking:** hardware_buffer_format: Add catch-all variant. (#407)
- asset: Add missing `is_allocated()` and `open_file_descriptor()` methods. (#409)
- **Breaking:** media_codec: Add support for asynchronous notification callbacks. (#410)
- Add panic guards to callbacks. (#412)
- looper: Add `remove_fd()` to unregister events/callbacks for a file descriptor. (#416)
- **Breaking:** Use `BorrowedFd` and `OwnedFd` to clarify possible ownership transitions. (#417)
- **Breaking:** Upgrade to [`ndk-sys 0.5.0`](../ndk-sys/CHANGELOG.md#050-2023-10-15). (#420)
- Add bindings for `sync.h`. (#423)
- **Breaking:** bitmap: Provide detailed implementation for `AndroidBitmapInfoFlags`. (#424)
- native_window: Add `set_buffers_transform()`, `try_allocate_buffers()` and `set_frame_rate*()`. (#425)
- Add bindings for `ASharedMemory`. (#427)
- hardware_buffer: Add `id()` to retrieve a system-wide unique identifier for a `HardwareBuffer`. (#428)
- **Breaking:** bitmap: Strip `Android` prefix from structs and enums, and `Bitmap` from `Result`. (#430)
- **Breaking:** `raw-window-handle 0.5` support is now behind an _optional_ `rwh_05` crate feature and `raw-window-handle` `0.4` and `0.6` support is provided via the new `rwh_04` and (default-enabled) `rwh_06` crate features. (#434)
- **Breaking:** looper: Provide `event` value to file descriptor poll callback. (#435)
- **Breaking:** `HardwareBufferFormat` is no longer exported from `hardware_buffer` and `native_window`, and can only be reached through the `hardware_buffer_format` module. (#436)
- **Breaking:** `get_` prefixes have been removed from all public functions in light of the [C-GETTER](https://rust-lang.github.io/api-guidelines/naming.html#getter-names-follow-rust-convention-c-getter) convention. (#437)
- Add `DataSpace` type and relevant functions on `Bitmap` and `NativeWindow`. (#438)
- bitmap: Add `Bitmap::compress()` and `Bitmap::compress_raw()` functions. (#440)
- **Breaking:** Turn `BitmapError` into a `non_exhaustive` `enum`. (#440)
- **Breaking:** audio: Rename `AudioErrorResult` to `AudioResult` and turn into a `non_exhaustive` `enum`. (#441)
# 0.7.0 (2022-07-24)
- hardware_buffer: Make `HardwareBuffer::as_ptr()` public for interop with Vulkan. (#213)
- **Breaking:** `Configuration::country()` now returns `None` when the country is unset (akin to `Configuration::language()`). (#220)
- Add `MediaCodec` and `MediaFormat` bindings. (#216)
- **Breaking:** Upgrade to [`ndk-sys 0.4.0`](../ndk-sys/CHANGELOG.md#040-2022-07-24) and use new `enum` newtype wrappers. (#245)
- native_window: Use `release`/`acquire` for `Drop` and `Clone` respectively. (#207)
- **Breaking:** audio: Rename from `aaudio` to `audio` and drop `A` prefix. (#273)
- Implement `HasRawWindowHandle` directly on `NativeWindow`. (#274, #319)
- **Breaking:** native_activity: Replace `CStr` return types with `Path`. (#279)
- native_window: Add `format()` getter and `set_buffers_geometry()` setter. (#276)
- native_activity: Add `set_window_format()` setter. (#277)
- native_activity: Add `set_window_flags()` to change window behavior. (#278)
- Add `SurfaceTexture` bindings. (#267)
- Improve library and structure documentation, linking back to the NDK docs more rigorously. (#290)
- **Breaking:** input_queue: `get_event()` now returns a `Result` with `std::io::Error`; `InputQueueError` has been removed. (#292)
- **Breaking:** input_queue: `has_events()` now returns a `bool` directly without being wrapped in `Result`. (#294)
- **Breaking:** hardware_buffer: `HardwareBufferError` has been removed and replaced with `std::io::Error` in return types. (#295)
- Fixed `HardwareBuffer` leak on buffers returned from `AndroidBitmap::get_hardware_buffer()`. (#296)
- Bump optional `jni` dependency for doctest example from `0.18` to `0.19`. (#300)
- hardware_buffer: Made `HardwareBufferDesc` fields `pub`. (#313)
- **Breaking:** Remove `hardware_buffer` and `trace` features in favour of using `api-level-26` or `api-level-23` directly. (#320)
# 0.6.0 (2022-01-05)
- **Breaking:** Upgrade to [`ndk-sys 0.3.0`](../ndk-sys/CHANGELOG.md#030-2022-01-05) and migrate to `jni-sys` types that it now directly uses in its bindings. (#209 / #214)
# 0.5.0 (2021-11-22)
- **Breaking:** Replace `add_fd_with_callback` `ident` with constant value `ALOOPER_POLL_CALLBACK`,
as per <https://developer.android.com/ndk/reference/group/looper#alooper_addfd>.
- **Breaking:** Accept unboxed closure in `add_fd_with_callback`.
- aaudio: Replace "Added in" comments with missing `#[cfg(feature)]`.
- aaudio: Add missing `fn get_allowed_capture_policy()`.
- configuration: Add missing `api-level-30` feature to `fn screen_round()`.
# 0.4.0 (2021-08-02)
- **Breaking:** Model looper file descriptor events integer as `bitflags`.
# 0.3.0 (2021-01-30)
- **Breaking:** Looper `ident` not passed in `data` pointer anymore.
`attach_looper` now only sets the `ident` field when attaching an
`InputQueue` to a `ForeignLooper`.
If you are relying on `Poll::Event::data` to tell event fd and
input queue apart, please use `Poll::Event::ident` and the new
constants introduced in `ndk-glue`!
# 0.2.1 (2020-10-15)
- Fix documentation build on docs.rs
# 0.2.0 (2020-09-15)
- **Breaking:** Updated to use [ndk-sys 0.2.0](../ndk-sys/CHANGELOG.md#020-2020-09-15)
- Added `media` bindings
- Added `bitmap` and `hardware_buffer` bindings
- Added `aaudio` bindings
- Fixed assets directory path to be relative to the manifest
- Added `trace` feature for native tracing
# 0.1.0 (2020-04-22)
- Initial release! 🎉
+125
View File
@@ -0,0 +1,125 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2021"
rust-version = "1.66"
name = "ndk"
version = "0.9.0"
authors = ["The Rust Mobile contributors"]
description = "Safe Rust bindings to the Android NDK"
homepage = "https://github.com/rust-mobile/ndk"
documentation = "https://docs.rs/ndk"
readme = "README.md"
keywords = [
"android",
"ndk",
]
license = "MIT OR Apache-2.0"
repository = "https://github.com/rust-mobile/ndk"
[package.metadata.docs.rs]
features = [
"jni",
"all",
]
rustdoc-args = [
"--cfg",
"docsrs",
]
targets = [
"aarch64-linux-android",
"armv7-linux-androideabi",
"i686-linux-android",
"x86_64-linux-android",
]
[dependencies.bitflags]
version = "2.4"
[dependencies.ffi]
version = "0.6.0"
package = "ndk-sys"
[dependencies.jni]
version = "0.21"
optional = true
[dependencies.jni-sys]
version = "0.3"
[dependencies.log]
version = "0.4.6"
[dependencies.num_enum]
version = "0.7"
[dependencies.rwh_04]
version = "0.4"
optional = true
package = "raw-window-handle"
[dependencies.rwh_05]
version = "0.5"
optional = true
package = "raw-window-handle"
[dependencies.rwh_06]
version = "0.6"
optional = true
package = "raw-window-handle"
[dependencies.thiserror]
version = "1.0.23"
[dev-dependencies.libc]
version = "0.2.3"
[features]
all = [
"audio",
"bitmap",
"media",
"nativewindow",
"sync",
"api-level-33",
"rwh_04",
"rwh_05",
"rwh_06",
]
api-level-23 = []
api-level-24 = ["api-level-23"]
api-level-25 = ["api-level-24"]
api-level-26 = ["api-level-25"]
api-level-27 = ["api-level-26"]
api-level-28 = ["api-level-27"]
api-level-29 = ["api-level-28"]
api-level-30 = ["api-level-29"]
api-level-31 = ["api-level-30"]
api-level-32 = ["api-level-31"]
api-level-33 = ["api-level-32"]
audio = [
"ffi/audio",
"api-level-26",
]
bitmap = ["ffi/bitmap"]
default = ["rwh_06"]
media = ["ffi/media"]
nativewindow = ["ffi/nativewindow"]
sync = [
"ffi/sync",
"api-level-26",
]
test = [
"ffi/test",
"jni",
"all",
]
+21
View File
@@ -0,0 +1,21 @@
[![ci](https://github.com/rust-mobile/ndk/actions/workflows/rust.yml/badge.svg)](https://github.com/rust-mobile/ndk/actions/workflows/rust.yml) ![MIT license](https://img.shields.io/badge/License-MIT-green.svg) ![APACHE2 license](https://img.shields.io/badge/License-APACHE2-green.svg)
Rust bindings to the [Android NDK](https://developer.android.com/ndk)
Name | Description | Badges
--- | --- | ---
[`ndk-sys`](./ndk-sys) | Raw FFI bindings to the NDK | [![crates.io](https://img.shields.io/crates/v/ndk-sys.svg)](https://crates.io/crates/ndk-sys) [![Docs](https://docs.rs/ndk-sys/badge.svg)](https://docs.rs/ndk-sys) [![MSRV](https://img.shields.io/badge/rustc-1.60.0+-ab6000.svg)](https://blog.rust-lang.org/2022/04/07/Rust-1.60.0.html)
[`ndk`](./ndk) | Safe abstraction of the bindings | [![crates.io](https://img.shields.io/crates/v/ndk.svg)](https://crates.io/crates/ndk) [![Docs](https://docs.rs/ndk/badge.svg)](https://docs.rs/ndk) [![MSRV](https://img.shields.io/badge/rustc-1.64.0+-ab6000.svg)](https://blog.rust-lang.org/2022/09/22/Rust-1.64.0.html)
See these [`ndk-examples`](https://github.com/rust-mobile/cargo-apk/tree/main/examples/examples) and these [`rust-android-examples`](https://github.com/rust-mobile/rust-android-examples) for examples using the NDK.
> [!IMPORTANT]
> This repository was recently [modularized](https://github.com/rust-mobile/ndk/issues/372) and the following crates were split into separate repositories:
>
> Crate | New Location | Notes
> ------|--------------|------
> ndk-context | https://github.com/rust-mobile/ndk-context |
> ndk-glue | https://github.com/rust-mobile/ndk-glue | ⛔ _deprecated_ - see [android-activity](https://github.com/rust-mobile/android-activity)
> ndk-macro | https://github.com/rust-mobile/ndk-glue | ⛔ _deprecated_ - see [android-activity](https://github.com/rust-mobile/android-activity)
> ndk-build | https://github.com/rust-mobile/cargo-apk | ⛔ _deprecated_ - see [xbuild](https://github.com/rust-mobile/xbuild)
> cargo-apk | https://github.com/rust-mobile/cargo-apk | ⛔ _deprecated_ - see [xbuild](https://github.com/rust-mobile/xbuild)
+14
View File
@@ -0,0 +1,14 @@
# Vendored `ndk` 0.9.0 (patched)
Verbatim copy of the published `ndk` 0.9.0 crate (https://crates.io/crates/ndk,
MIT OR Apache-2.0, © the rust-mobile contributors), wired in via `[patch.crates-io]`
in the workspace root.
**The only change** is in `src/media/media_codec.rs`: `MediaCodec::as_ptr` is made
`pub` (upstream keeps it private) so the Android client can register
`AMediaCodec_setOnFrameRenderedCallback` through `ndk-sys` — the render-timestamp
callback behind the HUD's `display` stage (`design/stats-unification.md`), which the
wrapper doesn't expose. Grep for `punktfunk vendored patch` to find it.
Drop this vendor copy when upstream exposes the raw pointer or a frame-rendered
callback binding (tracked against https://github.com/rust-mobile/ndk).
+323
View File
@@ -0,0 +1,323 @@
//! Bindings for [`AAsset`], [`AAssetDir`] and [`AAssetManager`]
//!
//! [`AAsset`]: https://developer.android.com/ndk/reference/group/asset#aasset
//! [`AAssetDir`]: https://developer.android.com/ndk/reference/group/asset#aassetdir
//! [`AAssetManager`]: https://developer.android.com/ndk/reference/group/asset#aassetmanager
use std::{
ffi::{CStr, CString},
io,
os::fd::{FromRawFd, OwnedFd},
ptr::NonNull,
};
/// A native [`AAssetManager *`]
///
/// [`AAssetManager *`]: https://developer.android.com/ndk/reference/group/asset#aassetmanager
#[derive(Debug)]
#[doc(alias = "AAssetManager")]
pub struct AssetManager {
ptr: NonNull<ffi::AAssetManager>,
}
// AAssetManager is thread safe.
// See https://developer.android.com/ndk/reference/group/asset#aassetmanager
unsafe impl Send for AssetManager {}
unsafe impl Sync for AssetManager {}
impl AssetManager {
/// Create an `AssetManager` from a pointer
///
/// # Safety
/// By calling this function, you assert that the pointer is a valid pointer to a native
/// `AAssetManager`.
pub unsafe fn from_ptr(ptr: NonNull<ffi::AAssetManager>) -> Self {
Self { ptr }
}
/// Returns the pointer to the native `AAssetManager`.
pub fn ptr(&self) -> NonNull<ffi::AAssetManager> {
self.ptr
}
/// Open the asset. Returns [`None`] if opening the asset fails.
///
/// This currently always opens the asset in the streaming mode.
#[doc(alias = "AAssetManager_open")]
pub fn open(&self, filename: &CStr) -> Option<Asset> {
unsafe {
let ptr = ffi::AAssetManager_open(
self.ptr.as_ptr(),
filename.as_ptr(),
ffi::AASSET_MODE_STREAMING as i32,
);
Some(Asset::from_ptr(NonNull::new(ptr)?))
}
}
/// Open an asset directory. Returns [`None`] if opening the directory fails.
#[doc(alias = "AAssetManager_openDir")]
pub fn open_dir(&self, filename: &CStr) -> Option<AssetDir> {
unsafe {
let ptr = ffi::AAssetManager_openDir(self.ptr.as_ptr(), filename.as_ptr());
Some(AssetDir::from_ptr(NonNull::new(ptr)?))
}
}
}
/// A native [`AAssetDir *`]
///
/// ```no_run
/// # use std::ffi::CString;
/// # use ndk::asset::AssetManager;
/// # let asset_manager: AssetManager = unimplemented!();
/// use std::io::Read;
///
/// let mut my_dir = asset_manager
/// .open_dir(&CString::new("my_dir").unwrap())
/// .expect("Could not open directory");
///
/// // Use it as an iterator
/// let all_files = my_dir.collect::<Vec<CString>>();
///
/// // Reset the iterator
/// my_dir.rewind();
///
/// // Use .with_next() to iterate without allocating `CString`s
/// while let Some(asset) = my_dir.with_next(|cstr| asset_manager.open(cstr).unwrap()) {
/// let mut text = String::new();
/// asset.read_to_string(&mut text);
/// // ...
/// }
/// ```
///
/// [`AAssetDir *`]: https://developer.android.com/ndk/reference/group/asset#aassetdir
#[derive(Debug)]
#[doc(alias = "AAssetDir")]
pub struct AssetDir {
ptr: NonNull<ffi::AAssetDir>,
}
// It's unclear if AAssetDir is thread safe.
// However, AAsset is not, so there's a good chance that AAssetDir is not either.
impl Drop for AssetDir {
#[doc(alias = "AAssetDir_close")]
fn drop(&mut self) {
unsafe { ffi::AAssetDir_close(self.ptr.as_ptr()) }
}
}
impl AssetDir {
/// Construct an `AssetDir` from the native `AAssetDir *`. This gives ownership of the
/// `AAssetDir *` to the `AssetDir`, which will handle closing the asset. Avoid using
/// the pointer after calling this function.
///
/// # Safety
/// By calling this function, you assert that it points to a valid native `AAssetDir`.
pub unsafe fn from_ptr(ptr: NonNull<ffi::AAssetDir>) -> Self {
Self { ptr }
}
/// The corresponding native `AAssetDir *`
pub fn ptr(&self) -> NonNull<ffi::AAssetDir> {
self.ptr
}
/// Get the next filename, if any, and process it. Like [`Iterator::next()`], but performs
/// no additional allocation.
///
/// The filenames are in the correct format to be passed to [`AssetManager::open()`].
#[doc(alias = "AAssetDir_getNextFileName")]
pub fn with_next<T>(&mut self, f: impl for<'a> FnOnce(&'a CStr) -> T) -> Option<T> {
unsafe {
let next_name = ffi::AAssetDir_getNextFileName(self.ptr.as_ptr());
if next_name.is_null() {
None
} else {
Some(f(CStr::from_ptr(next_name)))
}
}
}
/// Reset the iteration state
#[doc(alias = "AAssetDir_rewind")]
pub fn rewind(&mut self) {
unsafe {
ffi::AAssetDir_rewind(self.ptr.as_ptr());
}
}
}
impl Iterator for AssetDir {
type Item = CString;
fn next(&mut self) -> Option<CString> {
self.with_next(|cstr| cstr.to_owned())
}
}
/// A native [`AAsset *`], opened in streaming mode
///
/// ```no_run
/// # use std::ffi::CString;
/// # use ndk::asset::AssetManager;
/// # let asset_manager: AssetManager = unimplemented!();
/// use std::io::Read;
///
/// let asset = asset_manager
/// .open(&CString::new("path/to/asset").unwrap())
/// .expect("Could not open asset");
///
/// let mut data = vec![];
/// asset.read_to_end(&mut data);
/// // ... use data ...
/// ```
///
/// [`AAsset *`]: https://developer.android.com/ndk/reference/group/asset#aasset
#[derive(Debug)]
#[doc(alias = "AAsset")]
pub struct Asset {
ptr: NonNull<ffi::AAsset>,
}
// AAsset is *not* thread safe.
// See https://developer.android.com/ndk/reference/group/asset#aasset
impl Drop for Asset {
#[doc(alias = "AAsset_close")]
fn drop(&mut self) {
unsafe { ffi::AAsset_close(self.ptr.as_ptr()) }
}
}
impl Asset {
/// Construct an `Asset` from the native `AAsset *`. This gives ownership of the `AAsset *` to
/// the `Asset`, which will handle closing the asset. Avoid using the pointer after calling
/// this function.
///
/// # Safety
/// By calling this function, you assert that it points to a valid native `AAsset`, open
/// in the streaming mode.
pub unsafe fn from_ptr(ptr: NonNull<ffi::AAsset>) -> Self {
Self { ptr }
}
/// The corresponding native `AAsset *`
pub fn ptr(&self) -> NonNull<ffi::AAsset> {
self.ptr
}
/// Returns the total length of the asset, in bytes
#[doc(alias = "AAsset_getLength64")]
pub fn length(&self) -> usize {
unsafe { ffi::AAsset_getLength64(self.ptr.as_ptr()) as usize }
}
/// Returns the remaining length of the asset, in bytes
#[doc(alias = "AAsset_getRemainingLength64")]
pub fn remaining_length(&self) -> usize {
unsafe { ffi::AAsset_getRemainingLength64(self.ptr.as_ptr()) as usize }
}
/// Maps all data into a buffer and returns it
#[doc(alias = "AAsset_getBuffer")]
pub fn buffer(&mut self) -> io::Result<&[u8]> {
unsafe {
let buf_ptr = ffi::AAsset_getBuffer(self.ptr.as_ptr());
if buf_ptr.is_null() {
Err(io::Error::new(
io::ErrorKind::Other,
"Android Asset error creating buffer",
))
} else {
Ok(std::slice::from_raw_parts(
buf_ptr as *const u8,
self.length(),
))
}
}
}
/// Returns whether this asset's internal buffer is allocated in ordinary RAM (i.e. not `mmap`ped).
#[doc(alias = "AAsset_isAllocated")]
pub fn is_allocated(&self) -> bool {
unsafe { ffi::AAsset_isAllocated(self.ptr.as_ptr()) != 0 }
}
/// Open a new file descriptor that can be used to read the asset data.
///
/// Returns an error if direct fd access is not possible (for example, if the asset is compressed).
#[doc(alias = "AAsset_openFileDescriptor64")]
pub fn open_file_descriptor(&self) -> io::Result<OpenedFileDescriptor> {
let mut offset = 0;
let mut size = 0;
let res =
unsafe { ffi::AAsset_openFileDescriptor64(self.ptr.as_ptr(), &mut offset, &mut size) };
if res >= 0 {
Ok(OpenedFileDescriptor {
fd: unsafe { OwnedFd::from_raw_fd(res) },
offset: offset as usize,
size: size as usize,
})
} else {
Err(io::Error::new(
io::ErrorKind::Other,
"Android Asset openFileDescriptor error",
))
}
}
}
impl io::Read for Asset {
#[doc(alias = "AAsset_read")]
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
unsafe {
let res = ffi::AAsset_read(self.ptr.as_ptr(), buf.as_mut_ptr() as *mut _, buf.len());
if res >= 0 {
Ok(res as usize)
} else {
Err(io::Error::new(
io::ErrorKind::Other,
"Android Asset read error",
))
}
}
}
}
impl io::Seek for Asset {
#[doc(alias = "AAsset_seek64")]
fn seek(&mut self, seek: io::SeekFrom) -> io::Result<u64> {
unsafe {
let res = match seek {
io::SeekFrom::Start(x) => {
ffi::AAsset_seek64(self.ptr.as_ptr(), x as i64, ffi::SEEK_SET as i32)
}
io::SeekFrom::Current(x) => {
ffi::AAsset_seek64(self.ptr.as_ptr(), x, ffi::SEEK_CUR as i32)
}
io::SeekFrom::End(x) => {
ffi::AAsset_seek64(self.ptr.as_ptr(), x, ffi::SEEK_END as i32)
}
};
if res < 0 {
Err(io::Error::new(
io::ErrorKind::Other,
"Android Asset seek error",
))
} else {
Ok(res as u64)
}
}
}
}
/// Contains the opened file descriptor returned by [`Asset::open_file_descriptor()`], together
/// with the offset and size of the given asset within that file descriptor.
#[derive(Debug)]
pub struct OpenedFileDescriptor {
pub fd: OwnedFd,
pub offset: usize,
pub size: usize,
}
File diff suppressed because it is too large Load Diff
+489
View File
@@ -0,0 +1,489 @@
//! Bindings for [`AndroidBitmap`] functions
//!
//! These functions operate directly on a JNI [`android.graphics.Bitmap`] instance.
//!
//! [`AndroidBitmap`]: https://developer.android.com/ndk/reference/group/bitmap
//! [`android.graphics.Bitmap`]: https://developer.android.com/reference/android/graphics/Bitmap
#![cfg(feature = "bitmap")]
use jni_sys::{jobject, JNIEnv};
use num_enum::{FromPrimitive, IntoPrimitive};
use std::{error, fmt, mem::MaybeUninit};
#[cfg(feature = "api-level-30")]
use crate::data_space::DataSpace;
#[cfg(feature = "api-level-30")]
use crate::hardware_buffer::HardwareBufferRef;
#[repr(i32)]
#[derive(Copy, Clone, Debug, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
#[non_exhaustive]
pub enum BitmapError {
#[doc(alias = "ANDROID_BITMAP_RESULT_ALLOCATION_FAILED")]
AllocationFailed = ffi::ANDROID_BITMAP_RESULT_ALLOCATION_FAILED,
#[doc(alias = "ANDROID_BITMAP_RESULT_BAD_PARAMETER")]
BadParameter = ffi::ANDROID_BITMAP_RESULT_BAD_PARAMETER,
#[doc(alias = "ANDROID_BITMAP_RESULT_JNI_EXCEPTION")]
JniException = ffi::ANDROID_BITMAP_RESULT_JNI_EXCEPTION,
// Use the SUCCESS discriminant, as no-one will be able to call `as i32` and only has access to
// the constants via `From` provided by `IntoPrimitive` which reads the contained value.
// An autogenerated `<previous variant> + 1` discriminant is normally fine, except that the
// previous variant is negative and `+ 1` would match the variant before that.
#[doc(hidden)]
#[num_enum(catch_all)]
__Unknown(i32) = ffi::ANDROID_BITMAP_RESULT_SUCCESS,
}
impl fmt::Display for BitmapError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:?}", self)
}
}
impl error::Error for BitmapError {}
pub type Result<T, E = BitmapError> = std::result::Result<T, E>;
impl BitmapError {
pub(crate) fn from_status(status: i32) -> Result<()> {
match status {
ffi::ANDROID_BITMAP_RESULT_SUCCESS => Ok(()),
x => Err(Self::from(x)),
}
}
}
fn construct<T>(with_ptr: impl FnOnce(*mut T) -> i32) -> Result<T> {
let mut result = MaybeUninit::uninit();
let status = with_ptr(result.as_mut_ptr());
BitmapError::from_status(status).map(|()| unsafe { result.assume_init() })
}
#[repr(i32)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, IntoPrimitive, FromPrimitive)]
#[allow(non_camel_case_types)]
#[doc(alias = "AndroidBitmapFormat")]
#[non_exhaustive]
pub enum BitmapFormat {
#[doc(alias = "ANDROID_BITMAP_FORMAT_NONE")]
NONE = ffi::AndroidBitmapFormat::ANDROID_BITMAP_FORMAT_NONE.0 as i32,
#[doc(alias = "ANDROID_BITMAP_FORMAT_RGBA_8888")]
RGBA_8888 = ffi::AndroidBitmapFormat::ANDROID_BITMAP_FORMAT_RGBA_8888.0 as i32,
#[doc(alias = "ANDROID_BITMAP_FORMAT_RGB_565")]
RGB_565 = ffi::AndroidBitmapFormat::ANDROID_BITMAP_FORMAT_RGB_565.0 as i32,
#[deprecated = "Deprecated in API level 13. Because of the poor quality of this configuration, it is advised to use ARGB_8888 instead."]
#[doc(alias = "ANDROID_BITMAP_FORMAT_RGBA_4444")]
RGBA_4444 = ffi::AndroidBitmapFormat::ANDROID_BITMAP_FORMAT_RGBA_4444.0 as i32,
#[doc(alias = "ANDROID_BITMAP_FORMAT_A_8")]
A_8 = ffi::AndroidBitmapFormat::ANDROID_BITMAP_FORMAT_A_8.0 as i32,
#[doc(alias = "ANDROID_BITMAP_FORMAT_RGBA_F16")]
RGBA_F16 = ffi::AndroidBitmapFormat::ANDROID_BITMAP_FORMAT_RGBA_F16.0 as i32,
#[doc(alias = "ANDROID_BITMAP_FORMAT_RGBA_1010102")]
RGBA_1010102 = ffi::AndroidBitmapFormat::ANDROID_BITMAP_FORMAT_RGBA_1010102.0 as i32,
#[doc(hidden)]
#[num_enum(catch_all)]
__Unknown(i32),
}
/// An immediate wrapper over [`android.graphics.Bitmap`]
///
/// [`android.graphics.Bitmap`]: https://developer.android.com/reference/android/graphics/Bitmap
#[derive(Debug)]
pub struct Bitmap {
env: *mut JNIEnv,
inner: jobject,
}
impl Bitmap {
/// Create a [`Bitmap`] wrapper from JNI pointers
///
/// # Safety
/// This function should be called with a healthy JVM pointer and with a non-null
/// [`android.graphics.Bitmap`], which must be kept alive on the Java/Kotlin side.
///
/// [`android.graphics.Bitmap`]: https://developer.android.com/reference/android/graphics/Bitmap
pub unsafe fn from_jni(env: *mut JNIEnv, bitmap: jobject) -> Self {
Self { env, inner: bitmap }
}
/// Fills out and returns the [`BitmapInfo`] struct for the given Java bitmap object.
#[doc(alias = "AndroidBitmap_getInfo")]
pub fn info(&self) -> Result<BitmapInfo> {
let inner =
construct(|res| unsafe { ffi::AndroidBitmap_getInfo(self.env, self.inner, res) })?;
Ok(BitmapInfo { inner })
}
/// Returns the [`DataSpace`] of this [`Bitmap`].
///
/// Note that [`DataSpace`] only exposes a few values. This may return [`DataSpace::Unknown`],
/// even for Named ColorSpaces, if they have no corresponding [`DataSpace`].
#[cfg(feature = "api-level-30")]
#[doc(alias = "AndroidBitmap_getDataSpace")]
pub fn data_space(&self) -> DataSpace {
let value = unsafe { ffi::AndroidBitmap_getDataSpace(self.env, self.inner) };
value.into()
}
/// Attempt to lock the pixel address.
///
/// Locking will ensure that the memory for the pixels will not move until the
/// [`Bitmap::unlock_pixels()`] call, and ensure that, if the pixels had been previously purged,
/// they will have been restored.
///
/// If this call succeeds, it must be balanced by a call to [`Bitmap::unlock_pixels()`], after
/// which time the address of the pixels should no longer be used.
#[doc(alias = "AndroidBitmap_lockPixels")]
pub fn lock_pixels(&self) -> Result<*mut std::os::raw::c_void> {
construct(|res| unsafe { ffi::AndroidBitmap_lockPixels(self.env, self.inner, res) })
}
/// Call this to balance a successful call to [`Bitmap::lock_pixels()`].
#[doc(alias = "AndroidBitmap_unlockPixels")]
pub fn unlock_pixels(&self) -> Result<()> {
let status = unsafe { ffi::AndroidBitmap_unlockPixels(self.env, self.inner) };
BitmapError::from_status(status)
}
/// Retrieve the native object associated with an [`ffi::ANDROID_BITMAP_FLAGS_IS_HARDWARE`]
/// [`Bitmap`] (requires [`BitmapInfoFlags::is_hardware()`] on [`BitmapInfo::flags()`] to return
/// [`true`]).
///
/// Client must not modify it while a [`Bitmap`] is wrapping it.
#[cfg(feature = "api-level-30")]
#[doc(alias = "AndroidBitmap_getHardwareBuffer")]
pub fn hardware_buffer(&self) -> Result<HardwareBufferRef> {
unsafe {
let result =
construct(|res| ffi::AndroidBitmap_getHardwareBuffer(self.env, self.inner, res))?;
let non_null = if cfg!(debug_assertions) {
std::ptr::NonNull::new(result).expect("result should never be null")
} else {
std::ptr::NonNull::new_unchecked(result)
};
Ok(HardwareBufferRef::from_ptr(non_null))
}
}
/// [Lock] the pixels in `self` and compress them as described by [`info()`].
///
/// Unlike [`compress_raw()`] this requires a [`Bitmap`] object (as `self`) backed by a
/// [`jobject`].
///
/// # Parameters
/// - `format`: [`BitmapCompressFormat`] to compress to.
/// - `quality`: Hint to the compressor, `0-100`. The value is interpreted differently
/// depending on [`BitmapCompressFormat`].
/// - `compress_callback`: Closure that writes the compressed data. Will be called on the
/// current thread, each time the compressor has compressed more data that is ready to be
/// written. May be called more than once for each call to this method.
///
/// [Lock]: Self::lock_pixels()
/// [`info()`]: Self::info()
/// [`compress_raw()`]: Self::compress_raw()
#[cfg(feature = "api-level-30")]
#[doc(alias = "AndroidBitmap_compress")]
pub fn compress<F: FnMut(&[u8]) -> Result<(), ()>>(
&self,
format: BitmapCompressFormat,
quality: i32,
compress_callback: F,
) -> Result<(), BitmapCompressError> {
let info = self.info()?;
let data_space = self.data_space();
let pixels = self.lock_pixels()?;
// SAFETY: When lock_pixels() succeeds, assume it returns a valid pointer that stays
// valid until we call unlock_pixels().
let result = unsafe {
Self::compress_raw(
&info,
data_space,
pixels,
format,
quality,
compress_callback,
)
};
self.unlock_pixels()?;
result
}
/// Compress `pixels` as described by `info`.
///
/// Unlike [`compress()`] this takes a raw pointer to pixels and does not need a [`Bitmap`]
/// object backed by a [`jobject`].
///
/// # Parameters
/// - `info`: Description of the pixels to compress.
/// - `data_space`: [`DataSpace`] describing the color space of the pixels. Should _not_ be
/// [`DataSpace::Unknown`] [^1].
/// - `pixels`: Pointer to pixels to compress.
/// - `format`: [`BitmapCompressFormat`] to compress to.
/// - `quality`: Hint to the compressor, `0-100`. The value is interpreted differently
/// depending on [`BitmapCompressFormat`].
/// - `compress_callback`: Closure that writes the compressed data. Will be called on the
/// current thread, each time the compressor has compressed more data that is ready to be
/// written. May be called more than once for each call to this method.
///
/// # Safety
/// `pixels` must point to a valid buffer that matches the size, stride and format in `info`.
///
/// [`compress()`]: Self::compress()
/// [^1]: <https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/libs/hwui/apex/android_bitmap.cpp;l=275-279;drc=7ba5c2fb3d1e35eb37a9cc522b30ba51f49ea491>
#[cfg(feature = "api-level-30")]
#[doc(alias = "AndroidBitmap_compress")]
pub unsafe fn compress_raw<F: FnMut(&[u8]) -> Result<(), ()>>(
info: &BitmapInfo,
data_space: DataSpace,
pixels: *const std::ffi::c_void,
format: BitmapCompressFormat,
quality: i32,
compress_callback: F,
) -> Result<(), BitmapCompressError> {
if data_space == DataSpace::Unknown {
return Err(BitmapCompressError::DataSpaceUnknown);
}
use std::{any::Any, ffi::c_void, panic::AssertUnwindSafe};
struct CallbackState<F: FnMut(&[u8]) -> Result<(), ()>> {
callback: F,
panic: Option<Box<dyn Any + Send>>,
}
let mut cb_state = CallbackState::<F> {
callback: compress_callback,
panic: None,
};
extern "C" fn compress_cb<F: FnMut(&[u8]) -> Result<(), ()>>(
context: *mut c_void,
data: *const c_void,
size: usize,
) -> bool {
// SAFETY: This callback will only be called serially on a single thread. Both the
// panic state and the FnMut context need to be available mutably.
let cb_state = unsafe { context.cast::<CallbackState<F>>().as_mut() }.unwrap();
let data = unsafe { std::slice::from_raw_parts(data.cast(), size) };
let panic = std::panic::catch_unwind(AssertUnwindSafe(|| (cb_state.callback)(data)));
match panic {
Ok(r) => r.is_ok(),
Err(e) => {
cb_state.panic = Some(e);
false
}
}
}
let status = unsafe {
ffi::AndroidBitmap_compress(
&info.inner,
data_space.into(),
pixels,
format.into(),
quality,
<*mut _>::cast(&mut cb_state),
Some(compress_cb::<F>),
)
};
if let Some(panic) = cb_state.panic {
std::panic::resume_unwind(panic)
}
Ok(BitmapError::from_status(status)?)
}
}
/// Possible values for [`ffi::ANDROID_BITMAP_FLAGS_ALPHA_MASK`] within [`BitmapInfoFlags`]
#[repr(u32)]
#[cfg(feature = "api-level-30")]
#[derive(Clone, Copy, Debug, IntoPrimitive, FromPrimitive)]
#[doc(alias = "ANDROID_BITMAP_FLAGS_ALPHA_MASK")]
#[non_exhaustive]
pub enum BitmapInfoFlagsAlpha {
/// Pixel components are premultiplied by alpha.
#[doc(alias = "ANDROID_BITMAP_FLAGS_ALPHA_PREMUL")]
Premultiplied = ffi::ANDROID_BITMAP_FLAGS_ALPHA_PREMUL,
/// Pixels are opaque.
#[doc(alias = "ANDROID_BITMAP_FLAGS_ALPHA_OPAQUE")]
Opaque = ffi::ANDROID_BITMAP_FLAGS_ALPHA_OPAQUE,
/// Pixel components are independent of alpha.
#[doc(alias = "ANDROID_BITMAP_FLAGS_ALPHA_UNPREMUL")]
Unpremultiplied = ffi::ANDROID_BITMAP_FLAGS_ALPHA_UNPREMUL,
#[doc(hidden)]
#[num_enum(catch_all)]
__Unknown(u32),
}
/// Bitfield containing information about the bitmap.
#[cfg(feature = "api-level-30")]
#[repr(transparent)]
#[derive(Clone, Copy, Hash, PartialEq, Eq)]
pub struct BitmapInfoFlags(u32);
#[cfg(feature = "api-level-30")]
impl std::fmt::Debug for BitmapInfoFlags {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"BitmapInfoFlags({:#x}, alpha: {:?}, is_hardware: {})",
self.0,
self.alpha(),
self.is_hardware()
)
}
}
#[cfg(feature = "api-level-30")]
impl BitmapInfoFlags {
/// Returns the alpha value contained in the [`ffi::ANDROID_BITMAP_FLAGS_ALPHA_MASK`] bit range
#[doc(alias = "ANDROID_BITMAP_FLAGS_ALPHA_MASK")]
pub fn alpha(self) -> BitmapInfoFlagsAlpha {
// Note that ffi::ANDROID_BITMAP_FLAGS_ALPHA_SHIFT is 0 and hence irrelevant.
(self.0 & ffi::ANDROID_BITMAP_FLAGS_ALPHA_MASK).into()
}
/// Returns [`true`] when [`ffi::ANDROID_BITMAP_FLAGS_IS_HARDWARE`] is set, meaning this
/// [`Bitmap`] uses "HARDWARE Config" and its [`HardwareBufferRef`] can be retrieved via
/// [`Bitmap::hardware_buffer()`].
#[doc(alias = "ANDROID_BITMAP_FLAGS_IS_HARDWARE")]
pub fn is_hardware(self) -> bool {
// This constant is defined in a separate anonymous enum which bindgen treats as i32.
(self.0 & ffi::ANDROID_BITMAP_FLAGS_IS_HARDWARE as u32) != 0
}
}
/// A native [`AndroidBitmapInfo`]
///
/// [`AndroidBitmapInfo`]: https://developer.android.com/ndk/reference/struct/android-bitmap-info#struct_android_bitmap_info
#[derive(Clone, Copy)]
#[doc(alias = "AndroidBitmapInfo")]
pub struct BitmapInfo {
inner: ffi::AndroidBitmapInfo,
}
impl std::fmt::Debug for BitmapInfo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut f = f.debug_struct("BitmapInfo");
f.field("width", &self.width())
.field("height", &self.height())
.field("stride", &self.stride())
.field("format", &self.format());
#[cfg(feature = "api-level-30")]
f.field("flags", &self.flags());
f.finish()
}
}
impl BitmapInfo {
pub fn new(width: u32, height: u32, stride: u32, format: BitmapFormat) -> Self {
Self {
inner: ffi::AndroidBitmapInfo {
width,
height,
stride,
format: format.into(),
flags: 0,
},
}
}
#[cfg(feature = "api-level-30")]
pub fn new_with_flags(
width: u32,
height: u32,
stride: u32,
format: BitmapFormat,
flags: BitmapInfoFlags,
) -> Self {
Self {
inner: ffi::AndroidBitmapInfo {
flags: flags.0,
..Self::new(width, height, stride, format).inner
},
}
}
/// The bitmap width in pixels.
pub fn width(&self) -> u32 {
self.inner.width
}
/// The bitmap height in pixels.
pub fn height(&self) -> u32 {
self.inner.height
}
/// The number of byte per row.
pub fn stride(&self) -> u32 {
self.inner.stride
}
/// Convert the internal, native [`ffi::AndroidBitmapInfo::format`] into a [`BitmapFormat`].
pub fn format(&self) -> BitmapFormat {
self.inner.format.into()
}
/// Bitfield containing information about the bitmap.
#[cfg(feature = "api-level-30")]
pub fn flags(&self) -> BitmapInfoFlags {
BitmapInfoFlags(self.inner.flags)
}
}
/// Specifies the formats that can be compressed to with [`Bitmap::compress()`] and
/// [`Bitmap::compress_raw()`].
#[cfg(feature = "api-level-30")]
#[repr(i32)]
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
#[doc(alias = "AndroidBitmapCompressFormat")]
#[non_exhaustive]
pub enum BitmapCompressFormat {
/// Compress to the JPEG format.
///
/// quality of `0` means compress for the smallest size. `100` means compress for max visual
/// quality.
#[doc(alias = "ANDROID_BITMAP_COMPRESS_FORMAT_JPEG")]
Jpeg = ffi::AndroidBitmapCompressFormat::ANDROID_BITMAP_COMPRESS_FORMAT_JPEG.0 as i32,
/// Compress to the PNG format.
///
/// PNG is lossless, so quality is ignored.
#[doc(alias = "ANDROID_BITMAP_COMPRESS_FORMAT_PNG")]
Png = ffi::AndroidBitmapCompressFormat::ANDROID_BITMAP_COMPRESS_FORMAT_PNG.0 as i32,
/// Compress to the WEBP lossless format.
///
/// quality refers to how much effort to put into compression. A value of `0` means to
/// compress quickly, resulting in a relatively large file size. `100` means to spend more time
/// compressing, resulting in a smaller file.
#[doc(alias = "ANDROID_BITMAP_COMPRESS_FORMAT_WEBP_LOSSY")]
WebPLossy =
ffi::AndroidBitmapCompressFormat::ANDROID_BITMAP_COMPRESS_FORMAT_WEBP_LOSSY.0 as i32,
/// Compress to the WEBP lossy format.
///
/// quality of `0` means compress for the smallest size. `100` means compress for max visual quality.
#[doc(alias = "ANDROID_BITMAP_COMPRESS_FORMAT_WEBP_LOSSLESS")]
WebPLossless =
ffi::AndroidBitmapCompressFormat::ANDROID_BITMAP_COMPRESS_FORMAT_WEBP_LOSSLESS.0 as i32,
#[doc(hidden)]
#[num_enum(catch_all)]
__Unknown(i32),
}
/// Encapsulates possible errors returned by [`Bitmap::compress()`] or [`Bitmap::compress_raw()`].
#[cfg(feature = "api-level-30")]
#[derive(Debug, thiserror::Error)]
pub enum BitmapCompressError {
#[error(transparent)]
BitmapError(#[from] BitmapError),
/// [`Bitmap`] compression requires a known [`DataSpace`]. [`DataSpace::Unknown`] is invalid
/// even though it is typically treated as `sRGB`, for that [`DataSpace::Srgb`] has to be passed
/// explicitly.
#[error("The dataspace for this Bitmap is Unknown")]
DataSpaceUnknown,
}
+600
View File
@@ -0,0 +1,600 @@
//! Bindings for [`AConfiguration`]
//!
//! See also the [NDK docs](https://developer.android.com/ndk/reference/group/configuration) for
//! [`AConfiguration`], as well as the [docs for providing
//! resources](https://developer.android.com/guide/topics/resources/providing-resources.html),
//! which explain many of the configuration values. The [`android.content.res.Configuration`
//! javadoc](https://developer.android.com/reference/android/content/res/Configuration.html) may
//! also have useful information.
//!
//! [`AConfiguration`]: https://developer.android.com/ndk/reference/group/configuration#aconfiguration
use crate::asset::AssetManager;
use num_enum::{FromPrimitive, IntoPrimitive};
use std::fmt;
use std::ptr::NonNull;
/// A native [`AConfiguration *`]
///
/// [`Configuration`] is an opaque type used to get and set various subsystem configurations.
///
/// [`AConfiguration *`]: https://developer.android.com/ndk/reference/group/configuration#aconfiguration
pub struct Configuration {
ptr: NonNull<ffi::AConfiguration>,
}
unsafe impl Send for Configuration {}
unsafe impl Sync for Configuration {}
impl Drop for Configuration {
fn drop(&mut self) {
unsafe { ffi::AConfiguration_delete(self.ptr.as_ptr()) }
}
}
impl Clone for Configuration {
fn clone(&self) -> Self {
let mut new = Self::new();
new.copy(self);
new
}
}
impl PartialEq for Configuration {
fn eq(&self, other: &Self) -> bool {
self.diff(other).0 == 0
}
}
impl Eq for Configuration {}
impl fmt::Debug for Configuration {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Configuration")
.field("mcc", &self.mcc())
.field("mnc", &self.mnc())
.field("lang", &self.language())
.field("country", &self.country())
.field("orientation", &self.orientation())
.field("touchscreen", &self.touchscreen())
.field("density", &self.density())
.field("keyboard", &self.keyboard())
.field("navigation", &self.navigation())
.field("keys_hidden", &self.keys_hidden())
.field("nav_hidden", &self.nav_hidden())
.field("sdk_version", &self.sdk_version())
.field("screen_size", &self.screen_size())
.field("screen_long", &self.screen_long())
.field("ui_mode_type", &self.ui_mode_type())
.field("ui_mode_night", &self.ui_mode_night())
.finish()
}
}
impl Configuration {
/// Construct a `Configuration` from a pointer.
///
/// # Safety
/// By calling this function, you assert that it is a valid pointer to a native
/// `AConfiguration`, and give ownership of it to the `Configuration` instance.
pub unsafe fn from_ptr(ptr: NonNull<ffi::AConfiguration>) -> Self {
Self { ptr }
}
/// Create a new `Configuration`, with the same contents as the `AConfiguration` referenced by
/// the pointer.
///
/// This is useful if you have a pointer, but not ownership of it.
///
/// # Safety
/// By calling this function, you assert that it is a valid pointer to a native
/// `AConfiguration`.
pub unsafe fn clone_from_ptr(ptr: NonNull<ffi::AConfiguration>) -> Self {
let conf = Self::new();
ffi::AConfiguration_copy(conf.ptr.as_ptr(), ptr.as_ptr());
conf
}
/// The pointer to the native `AConfiguration`. Keep in mind that the `Configuration` object
/// still has ownership, and will free it when dropped.
pub fn ptr(&self) -> NonNull<ffi::AConfiguration> {
self.ptr
}
pub fn from_asset_manager(am: &AssetManager) -> Self {
let config = Self::new();
unsafe {
ffi::AConfiguration_fromAssetManager(config.ptr().as_mut(), am.ptr().as_mut());
}
config
}
/// Create a new `Configuration`, with none of the values set.
pub fn new() -> Self {
unsafe {
Self {
ptr: NonNull::new(ffi::AConfiguration_new()).unwrap(),
}
}
}
/// `dest.copy(&src)` copies the contents of `src` to `dest`
pub fn copy(&mut self, other: &Self) {
unsafe { ffi::AConfiguration_copy(self.ptr.as_ptr(), other.ptr.as_ptr()) }
}
/// Information about what fields differ between the two configurations
pub fn diff(&self, other: &Self) -> DiffResult {
unsafe {
DiffResult(ffi::AConfiguration_diff(self.ptr.as_ptr(), other.ptr.as_ptr()) as u32)
}
}
/// Returns false if anything in `self` conflicts with `requested`
pub fn matches(&self, requested: &Self) -> bool {
unsafe { ffi::AConfiguration_match(self.ptr.as_ptr(), requested.ptr.as_ptr()) != 0 }
}
/// Returns the country code, as a [`String`] of two characters, if set
pub fn country(&self) -> Option<String> {
let mut chars = [0u8; 2];
unsafe {
ffi::AConfiguration_getCountry(self.ptr.as_ptr(), chars.as_mut_ptr().cast());
}
if chars[0] == 0 {
None
} else {
Some(std::str::from_utf8(chars.as_slice()).unwrap().to_owned())
}
}
/// Returns the screen density in dpi.
///
/// On some devices it can return values outside of the density enum.
pub fn density(&self) -> Option<u32> {
let density = unsafe { ffi::AConfiguration_getDensity(self.ptr.as_ptr()) as u32 };
match density {
ffi::ACONFIGURATION_DENSITY_DEFAULT => Some(160),
ffi::ACONFIGURATION_DENSITY_ANY => None,
ffi::ACONFIGURATION_DENSITY_NONE => None,
density => Some(density),
}
}
/// Returns the keyboard type.
pub fn keyboard(&self) -> Keyboard {
unsafe { ffi::AConfiguration_getKeyboard(self.ptr.as_ptr()).into() }
}
/// Returns keyboard visibility/availability.
pub fn keys_hidden(&self) -> KeysHidden {
unsafe { ffi::AConfiguration_getKeysHidden(self.ptr.as_ptr()).into() }
}
/// Returns the language, as a [`String`] of two characters, if set
pub fn language(&self) -> Option<String> {
let mut chars = [0u8; 2];
unsafe {
ffi::AConfiguration_getLanguage(self.ptr.as_ptr(), chars.as_mut_ptr().cast());
}
if chars[0] == 0 {
None
} else {
Some(std::str::from_utf8(chars.as_slice()).unwrap().to_owned())
}
}
/// Returns the layout direction
pub fn layout_direction(&self) -> LayoutDir {
unsafe { ffi::AConfiguration_getLayoutDirection(self.ptr.as_ptr()).into() }
}
/// Returns the mobile country code.
pub fn mcc(&self) -> i32 {
unsafe { ffi::AConfiguration_getMcc(self.ptr.as_ptr()) }
}
/// Returns the mobile network code, if one is defined
pub fn mnc(&self) -> Option<i32> {
unsafe {
match ffi::AConfiguration_getMnc(self.ptr.as_ptr()) {
0 => None,
x if x == ffi::ACONFIGURATION_MNC_ZERO as i32 => Some(0),
x => Some(x),
}
}
}
pub fn nav_hidden(&self) -> NavHidden {
unsafe { ffi::AConfiguration_getNavHidden(self.ptr.as_ptr()).into() }
}
pub fn navigation(&self) -> Navigation {
unsafe { ffi::AConfiguration_getNavigation(self.ptr.as_ptr()).into() }
}
pub fn orientation(&self) -> Orientation {
unsafe { ffi::AConfiguration_getOrientation(self.ptr.as_ptr()).into() }
}
pub fn screen_height_dp(&self) -> Option<i32> {
unsafe {
let height = ffi::AConfiguration_getScreenHeightDp(self.ptr.as_ptr());
if height == ffi::ACONFIGURATION_SCREEN_HEIGHT_DP_ANY as i32 {
None
} else {
Some(height)
}
}
}
pub fn screen_width_dp(&self) -> Option<i32> {
unsafe {
let width = ffi::AConfiguration_getScreenWidthDp(self.ptr.as_ptr());
if width == ffi::ACONFIGURATION_SCREEN_WIDTH_DP_ANY as i32 {
None
} else {
Some(width)
}
}
}
pub fn screen_long(&self) -> ScreenLong {
unsafe { ffi::AConfiguration_getScreenLong(self.ptr.as_ptr()).into() }
}
#[cfg(feature = "api-level-30")]
pub fn screen_round(&self) -> ScreenRound {
unsafe { ffi::AConfiguration_getScreenRound(self.ptr.as_ptr()).into() }
}
pub fn screen_size(&self) -> ScreenSize {
unsafe { ffi::AConfiguration_getScreenSize(self.ptr.as_ptr()).into() }
}
pub fn sdk_version(&self) -> i32 {
unsafe { ffi::AConfiguration_getSdkVersion(self.ptr.as_ptr()) }
}
pub fn smallest_screen_width_dp(&self) -> Option<i32> {
unsafe {
let width = ffi::AConfiguration_getSmallestScreenWidthDp(self.ptr.as_ptr());
if width == ffi::ACONFIGURATION_SMALLEST_SCREEN_WIDTH_DP_ANY as i32 {
None
} else {
Some(width)
}
}
}
pub fn touchscreen(&self) -> Touchscreen {
unsafe { ffi::AConfiguration_getTouchscreen(self.ptr.as_ptr()).into() }
}
pub fn ui_mode_night(&self) -> UiModeNight {
unsafe { ffi::AConfiguration_getUiModeNight(self.ptr.as_ptr()).into() }
}
pub fn ui_mode_type(&self) -> UiModeType {
unsafe { ffi::AConfiguration_getUiModeType(self.ptr.as_ptr()).into() }
}
}
/// A bitfield representing the differences between two [`Configuration`]s
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct DiffResult(pub u32);
impl DiffResult {
pub fn mcc(self) -> bool {
self.0 & ffi::ACONFIGURATION_MCC != 0
}
pub fn mnc(self) -> bool {
self.0 & ffi::ACONFIGURATION_MNC != 0
}
pub fn locale(self) -> bool {
self.0 & ffi::ACONFIGURATION_LOCALE != 0
}
pub fn touchscreen(self) -> bool {
self.0 & ffi::ACONFIGURATION_TOUCHSCREEN != 0
}
pub fn keyboard(self) -> bool {
self.0 & ffi::ACONFIGURATION_KEYBOARD != 0
}
pub fn keyboard_hidden(self) -> bool {
self.0 & ffi::ACONFIGURATION_KEYBOARD_HIDDEN != 0
}
pub fn navigation(self) -> bool {
self.0 & ffi::ACONFIGURATION_NAVIGATION != 0
}
pub fn orientation(self) -> bool {
self.0 & ffi::ACONFIGURATION_ORIENTATION != 0
}
pub fn density(self) -> bool {
self.0 & ffi::ACONFIGURATION_DENSITY != 0
}
pub fn screen_size(self) -> bool {
self.0 & ffi::ACONFIGURATION_SCREEN_SIZE != 0
}
pub fn version(self) -> bool {
self.0 & ffi::ACONFIGURATION_VERSION != 0
}
pub fn screen_layout(self) -> bool {
self.0 & ffi::ACONFIGURATION_SCREEN_LAYOUT != 0
}
pub fn ui_mode(self) -> bool {
self.0 & ffi::ACONFIGURATION_UI_MODE != 0
}
pub fn smallest_screen_size(self) -> bool {
self.0 & ffi::ACONFIGURATION_SMALLEST_SCREEN_SIZE != 0
}
pub fn layout_dir(self) -> bool {
self.0 & ffi::ACONFIGURATION_LAYOUTDIR != 0
}
pub fn screen_round(self) -> bool {
self.0 & ffi::ACONFIGURATION_SCREEN_ROUND != 0
}
pub fn color_mode(self) -> bool {
self.0 & ffi::ACONFIGURATION_COLOR_MODE != 0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
#[repr(i32)]
#[non_exhaustive]
pub enum Orientation {
Any = ffi::ACONFIGURATION_ORIENTATION_ANY as i32,
Port = ffi::ACONFIGURATION_ORIENTATION_PORT as i32,
Land = ffi::ACONFIGURATION_ORIENTATION_LAND as i32,
Square = ffi::ACONFIGURATION_ORIENTATION_SQUARE as i32,
#[doc(hidden)]
#[num_enum(catch_all)]
__Unknown(i32),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
#[repr(i32)]
#[non_exhaustive]
pub enum Touchscreen {
Any = ffi::ACONFIGURATION_TOUCHSCREEN_ANY as i32,
NoTouch = ffi::ACONFIGURATION_TOUCHSCREEN_NOTOUCH as i32,
Stylus = ffi::ACONFIGURATION_TOUCHSCREEN_STYLUS as i32,
Finger = ffi::ACONFIGURATION_TOUCHSCREEN_FINGER as i32,
#[doc(hidden)]
#[num_enum(catch_all)]
__Unknown(i32),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
#[repr(i32)]
#[non_exhaustive]
pub enum Density {
Default = ffi::ACONFIGURATION_DENSITY_DEFAULT as i32,
Low = ffi::ACONFIGURATION_DENSITY_LOW as i32,
Medium = ffi::ACONFIGURATION_DENSITY_MEDIUM as i32,
TV = ffi::ACONFIGURATION_DENSITY_TV as i32,
High = ffi::ACONFIGURATION_DENSITY_HIGH as i32,
XHigh = ffi::ACONFIGURATION_DENSITY_XHIGH as i32,
XXHigh = ffi::ACONFIGURATION_DENSITY_XXHIGH as i32,
XXXHigh = ffi::ACONFIGURATION_DENSITY_XXXHIGH as i32,
Any = ffi::ACONFIGURATION_DENSITY_ANY as i32,
None = ffi::ACONFIGURATION_DENSITY_NONE as i32,
#[doc(hidden)]
#[num_enum(catch_all)]
__Unknown(i32),
}
impl Density {
/// The DPI associated with the density class.
/// See [the Android screen density
/// docs](https://developer.android.com/training/multiscreen/screendensities#TaskProvideAltBmp)
///
/// There are some [`Density`] values that have no associated DPI; these values return [`None`].
pub fn dpi(self) -> Option<u32> {
match self {
Self::Default => Some(160), // Or should it be None?
Self::Low => Some(120),
Self::Medium => Some(160),
Self::High => Some(240),
Self::XHigh => Some(320),
Self::XXHigh => Some(480),
Self::XXXHigh => Some(640),
Self::TV => Some(213),
Self::Any => None,
Self::None => None,
// TODO
Self::__Unknown(v) => Some(v as u32),
}
}
/// The Hi-DPI factor associated with the density class. This is the factor by which an
/// image/resource should be scaled to match its size across devices. The baseline is a 160dpi
/// screen (i.e., Hi-DPI factor = DPI / 160).
/// See [the Android screen density
/// docs](https://developer.android.com/training/multiscreen/screendensities#TaskProvideAltBmp)
///
/// There are some [`Density`] values that have no associated DPI; these values return [`None`].
pub fn approx_hidpi_factor(self) -> Option<f64> {
match self {
Self::Default => Some(1.), // Or should it be None?
Self::Low => Some(0.75),
Self::Medium => Some(1.),
Self::High => Some(1.5),
Self::XHigh => Some(2.),
Self::XXHigh => Some(3.),
Self::XXXHigh => Some(4.),
Self::TV => Some(4. / 3.),
Self::Any => None,
Self::None => None,
Self::__Unknown(_) => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
#[repr(i32)]
#[non_exhaustive]
pub enum Keyboard {
Any = ffi::ACONFIGURATION_KEYBOARD_ANY as i32,
NoKeys = ffi::ACONFIGURATION_KEYBOARD_NOKEYS as i32,
Qwerty = ffi::ACONFIGURATION_KEYBOARD_QWERTY as i32,
TwelveKey = ffi::ACONFIGURATION_KEYBOARD_12KEY as i32,
#[doc(hidden)]
#[num_enum(catch_all)]
__Unknown(i32),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
#[repr(i32)]
#[non_exhaustive]
pub enum Navigation {
Any = ffi::ACONFIGURATION_NAVIGATION_ANY as i32,
NoNav = ffi::ACONFIGURATION_NAVIGATION_NONAV as i32,
DPad = ffi::ACONFIGURATION_NAVIGATION_DPAD as i32,
Trackball = ffi::ACONFIGURATION_NAVIGATION_TRACKBALL as i32,
Wheel = ffi::ACONFIGURATION_NAVIGATION_WHEEL as i32,
#[doc(hidden)]
#[num_enum(catch_all)]
__Unknown(i32),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
#[repr(i32)]
#[non_exhaustive]
pub enum KeysHidden {
Any = ffi::ACONFIGURATION_KEYSHIDDEN_ANY as i32,
No = ffi::ACONFIGURATION_KEYSHIDDEN_NO as i32,
Yes = ffi::ACONFIGURATION_KEYSHIDDEN_YES as i32,
Soft = ffi::ACONFIGURATION_KEYSHIDDEN_SOFT as i32,
#[doc(hidden)]
#[num_enum(catch_all)]
__Unknown(i32),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
#[repr(i32)]
#[non_exhaustive]
pub enum NavHidden {
Any = ffi::ACONFIGURATION_NAVHIDDEN_ANY as i32,
No = ffi::ACONFIGURATION_NAVHIDDEN_NO as i32,
Yes = ffi::ACONFIGURATION_NAVHIDDEN_YES as i32,
#[doc(hidden)]
#[num_enum(catch_all)]
__Unknown(i32),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
#[repr(i32)]
#[non_exhaustive]
pub enum ScreenSize {
Any = ffi::ACONFIGURATION_SCREENSIZE_ANY as i32,
Small = ffi::ACONFIGURATION_SCREENSIZE_SMALL as i32,
Normal = ffi::ACONFIGURATION_SCREENSIZE_NORMAL as i32,
Large = ffi::ACONFIGURATION_SCREENSIZE_LARGE as i32,
XLarge = ffi::ACONFIGURATION_SCREENSIZE_XLARGE as i32,
#[doc(hidden)]
#[num_enum(catch_all)]
__Unknown(i32),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
#[repr(i32)]
#[non_exhaustive]
pub enum ScreenLong {
Any = ffi::ACONFIGURATION_SCREENLONG_ANY as i32,
No = ffi::ACONFIGURATION_SCREENLONG_NO as i32,
Yes = ffi::ACONFIGURATION_SCREENLONG_YES as i32,
#[doc(hidden)]
#[num_enum(catch_all)]
__Unknown(i32),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
#[repr(i32)]
#[non_exhaustive]
pub enum ScreenRound {
Any = ffi::ACONFIGURATION_SCREENROUND_ANY as i32,
No = ffi::ACONFIGURATION_SCREENROUND_NO as i32,
Yes = ffi::ACONFIGURATION_SCREENROUND_YES as i32,
#[doc(hidden)]
#[num_enum(catch_all)]
__Unknown(i32),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
#[repr(i32)]
#[non_exhaustive]
pub enum WideColorGamut {
Any = ffi::ACONFIGURATION_WIDE_COLOR_GAMUT_ANY as i32,
No = ffi::ACONFIGURATION_WIDE_COLOR_GAMUT_NO as i32,
Yes = ffi::ACONFIGURATION_WIDE_COLOR_GAMUT_YES as i32,
#[doc(hidden)]
#[num_enum(catch_all)]
__Unknown(i32),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
#[repr(i32)]
#[non_exhaustive]
pub enum HDR {
Any = ffi::ACONFIGURATION_HDR_ANY as i32,
No = ffi::ACONFIGURATION_HDR_NO as i32,
Yes = ffi::ACONFIGURATION_HDR_YES as i32,
#[doc(hidden)]
#[num_enum(catch_all)]
__Unknown(i32),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
#[repr(i32)]
#[non_exhaustive]
pub enum LayoutDir {
Any = ffi::ACONFIGURATION_LAYOUTDIR_ANY as i32,
Ltr = ffi::ACONFIGURATION_LAYOUTDIR_LTR as i32,
Rtl = ffi::ACONFIGURATION_LAYOUTDIR_RTL as i32,
#[doc(hidden)]
#[num_enum(catch_all)]
__Unknown(i32),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
#[repr(i32)]
#[non_exhaustive]
pub enum UiModeType {
Any = ffi::ACONFIGURATION_UI_MODE_TYPE_ANY as i32,
Normal = ffi::ACONFIGURATION_UI_MODE_TYPE_NORMAL as i32,
Desk = ffi::ACONFIGURATION_UI_MODE_TYPE_DESK as i32,
Car = ffi::ACONFIGURATION_UI_MODE_TYPE_CAR as i32,
Television = ffi::ACONFIGURATION_UI_MODE_TYPE_TELEVISION as i32,
Applicance = ffi::ACONFIGURATION_UI_MODE_TYPE_APPLIANCE as i32,
Watch = ffi::ACONFIGURATION_UI_MODE_TYPE_WATCH as i32,
VrHeadset = ffi::ACONFIGURATION_UI_MODE_TYPE_VR_HEADSET as i32,
#[doc(hidden)]
#[num_enum(catch_all)]
__Unknown(i32),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
#[repr(i32)]
#[non_exhaustive]
pub enum UiModeNight {
Any = ffi::ACONFIGURATION_UI_MODE_NIGHT_ANY as i32,
No = ffi::ACONFIGURATION_UI_MODE_NIGHT_NO as i32,
Yes = ffi::ACONFIGURATION_UI_MODE_NIGHT_YES as i32,
#[doc(hidden)]
#[num_enum(catch_all)]
__Unknown(i32),
}
+648
View File
@@ -0,0 +1,648 @@
//! Bindings for [`ADataSpace`]
//!
//! [`ADataSpace`]: https://developer.android.com/ndk/reference/group/a-data-space#group___a_data_space_1ga2759ad19cae46646cc5f7002758c4a1c
#![cfg(feature = "api-level-28")]
use std::fmt;
use num_enum::{FromPrimitive, IntoPrimitive};
/// Describes how to interpret colors.
///
/// <https://developer.android.com/ndk/reference/group/a-data-space#group___a_data_space_1ga2759ad19cae46646cc5f7002758c4a1c>
#[repr(i32)]
#[derive(Clone, Copy, Hash, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
#[doc(alias = "ADataSpace")]
#[non_exhaustive]
pub enum DataSpace {
/// Default-assumption data space, when not explicitly specified.
///
/// It is safest to assume the buffer is an image with `sRGB` primaries and encoding ranges,
/// but the consumer and/or the producer of the data may simply be using defaults. No automatic
/// gamma transform should be expected, except for a possible display gamma transform when drawn
/// to a screen.
#[doc(alias = "ADATASPACE_UNKNOWN")]
Unknown = ffi::ADataSpace::ADATASPACE_UNKNOWN.0,
/// Adobe RGB.
///
/// Uses [full range], [gamma `2.2` transfer] and [Adobe RGB standard].
///
/// Note: Application is responsible for gamma encoding the data as a `2.2` gamma encoding is
/// not supported in HW.
///
/// [full range]: DataSpaceRange::Full
/// [gamma `2.2` transfer]: DataSpaceTransfer::Gamma2_2
/// [Adobe RGB standard]: DataSpaceStandard::AdobeRgb
#[doc(alias = "ADATASPACE_ADOBE_RGB")]
AdobeRgb = ffi::ADataSpace::ADATASPACE_ADOBE_RGB.0,
/// ITU-R Recommendation 2020 (`BT.2020`).
///
/// Ultra High-definition television.
///
/// Uses [full range], [`SMPTE 170M` transfer] and [`BT2020` standard].
///
/// [full range]: DataSpaceRange::Full
/// [`SMPTE 170M` transfer]: DataSpaceTransfer::Smpte170M
/// [`BT2020` standard]: DataSpaceStandard::Bt2020
#[doc(alias = "ADATASPACE_BT2020")]
Bt2020 = ffi::ADataSpace::ADATASPACE_BT2020.0,
/// Hybrid Log Gamma encoding.
///
/// Uses [full range], [hybrid log gamma transfer] and [`BT2020` standard].
///
/// [full range]: DataSpaceRange::Full
/// [hybrid log gamma transfer]: DataSpaceTransfer::HLG
/// [`BT2020` standard]: DataSpaceStandard::Bt2020
#[doc(alias = "ADATASPACE_BT2020_HLG")]
Bt2020Hlg = ffi::ADataSpace::ADATASPACE_BT2020_HLG.0,
/// ITU Hybrid Log Gamma encoding.
///
/// Uses [limited range], [hybrid log gamma transfer] and [`BT2020` standard].
///
/// [limited range]: DataSpaceRange::Limited
/// [hybrid log gamma transfer]: DataSpaceTransfer::HLG
/// [`BT2020` standard]: DataSpaceStandard::Bt2020
#[doc(alias = "ADATASPACE_BT2020_ITU_HLG")]
Bt2020ItuHlg = ffi::ADataSpace::ADATASPACE_BT2020_ITU_HLG.0,
/// ITU-R Recommendation 2020 (`BT.2020`).
///
/// Ultra High-definition television.
///
/// Uses [limited range], [`SMPTE 2084 (PQ)` transfer] and [`BT2020` standard].
///
/// [limited range]: DataSpaceRange::Limited
/// [`SMPTE 2084 (PQ)` transfer]: DataSpaceTransfer::St2084
/// [`BT2020` standard]: DataSpaceStandard::Bt2020
#[doc(alias = "ADATASPACE_BT2020_ITU_PQ")]
Bt2020ItuPq = ffi::ADataSpace::ADATASPACE_BT2020_ITU_PQ.0,
/// ITU-R Recommendation 2020 (`BT.2020`).
///
/// Ultra High-definition television.
///
/// Uses [full range], [`SMPTE 2084 (PQ)` transfer] and [`BT2020` standard].
///
/// [full range]: DataSpaceRange::Full
/// [`SMPTE 2084 (PQ)` transfer]: DataSpaceTransfer::St2084
/// [`BT2020` standard]: DataSpaceStandard::Bt2020
#[doc(alias = "ADATASPACE_BT2020_PQ")]
Bt2020Pq = ffi::ADataSpace::ADATASPACE_BT2020_PQ.0,
/// ITU-R Recommendation 601 (`BT.601`) - 525-line.
///
/// Standard-definition television, 525 Lines (NTSC).
///
/// Uses [limited range], [`SMPTE 170M` transfer] and [`BT.601_525` standard].
///
/// [limited range]: DataSpaceRange::Limited
/// [`SMPTE 170M` transfer]: DataSpaceTransfer::Smpte170M
/// [`BT.601_525` standard]: DataSpaceStandard::Bt601_525
#[doc(alias = "ADATASPACE_BT601_525")]
Bt601_525 = ffi::ADataSpace::ADATASPACE_BT601_525.0,
/// ITU-R Recommendation 601 (`BT.601`) - 625-line.
///
/// Standard-definition television, 625 Lines (PAL).
///
/// Uses [limited range], [`SMPTE 170M` transfer] and [`BT.601_625` standard].
///
/// [limited range]: DataSpaceRange::Limited
/// [`SMPTE 170M` transfer]: DataSpaceTransfer::Smpte170M
/// [`BT.601_625` standard]: DataSpaceStandard::Bt601_625
#[doc(alias = "ADATASPACE_BT601_625")]
Bt601_625 = ffi::ADataSpace::ADATASPACE_BT601_625.0,
/// ITU-R Recommendation 709 (`BT.709`).
///
/// High-definition television.
///
/// Uses [limited range], [`SMPTE 170M` transfer] and [`BT.709` standard].
///
/// [limited range]: DataSpaceRange::Limited
/// [`SMPTE 170M` transfer]: DataSpaceTransfer::Smpte170M
/// [`BT.709` standard]: DataSpaceStandard::Bt709
#[doc(alias = "ADATASPACE_BT709")]
Bt709 = ffi::ADataSpace::ADATASPACE_BT709.0,
/// `SMPTE EG 432-1` and `SMPTE RP 431-2`.
///
/// Digital Cinema `DCI-P3`.
///
/// Uses [full range], [gamma `2.6` transfer] and [`D65` `DCI-P3` standard].
///
/// Note: Application is responsible for gamma encoding the data as a `2.6` gamma encoding is
/// not supported in HW.
///
/// [full range]: DataSpaceRange::Full
/// [gamma `2.6` transfer]: DataSpaceTransfer::Gamma2_6
/// [`D65` `DCI-P3` standard]: DataSpaceStandard::DciP3
#[doc(alias = "ADATASPACE_DCI_P3")]
DciP3 = ffi::ADataSpace::ADATASPACE_DCI_P3.0,
/// Display P3.
///
/// Uses [full range], [`sRGB` transfer] and [`D65` `DCI-P3` standard].
///
/// [full range]: DataSpaceRange::Full
/// [`sRGB` transfer]: DataSpaceTransfer::Srgb
/// [`D65` `DCI-P3` standard]: DataSpaceStandard::DciP3
#[doc(alias = "ADATASPACE_DISPLAY_P3")]
DisplayP3 = ffi::ADataSpace::ADATASPACE_DISPLAY_P3.0,
/// JPEG File Interchange Format (`JFIF`).
///
/// Same model as `BT.601-625`, but all values (`Y`, `Cb`, `Cr`) range from `0` to `255`.
///
/// Uses [full range], [`SMPTE 170M` transfer] and [`BT.601_625` standard].
///
/// [full range]: DataSpaceRange::Full
/// [`SMPTE 170M` transfer]: DataSpaceTransfer::Smpte170M
/// [`BT.601_625` standard]: DataSpaceStandard::Bt601_625
#[doc(alias = "ADATASPACE_JFIF")]
Jfif = ffi::ADataSpace::ADATASPACE_JFIF.0,
/// `scRGB`.
///
/// The `red`, `green`, and `blue` components are stored in [extended][extended range] `sRGB`
/// space, and gamma- encoded using the [`sRGB` transfer] function.
///
/// The values are floating point. A pixel value of `1.0`, `1.0`, `1.0` corresponds to `sRGB`
/// white (`D65`) at `80` nits. Values beyond the range `[0.0 - 1.0]` would correspond to other
/// colors spaces and/or HDR content.
///
/// Uses [extended range], [`sRGB` transfer] and [`BT.709` standard].
///
/// [extended range]: DataSpaceRange::Extended
/// [`sRGB` transfer]: DataSpaceTransfer::Srgb
/// [`BT.709` standard]: DataSpaceStandard::Bt709
#[doc(alias = "ADATASPACE_SCRGB")]
Scrgb = ffi::ADataSpace::ADATASPACE_SCRGB.0,
/// `scRGB` linear encoding
///
/// The `red`, `green`, and `blue` components are stored in [extended][extended range] `sRGB`
/// space, but are linear, not gamma-encoded.
///
/// The values are floating point. A pixel value of `1.0`, `1.0`, `1.0` corresponds to `sRGB`
/// white (`D65`) at `80` nits. Values beyond the range `[0.0 - 1.0]` would correspond to other
/// colors spaces and/or HDR content.
///
/// Uses [extended range], [linear transfer] and [`BT.709` standard].
///
/// [extended range]: DataSpaceRange::Extended
/// [linear transfer]: DataSpaceTransfer::Linear
/// [`BT.709` standard]: DataSpaceStandard::Bt709
#[doc(alias = "ADATASPACE_SCRGB_LINEAR")]
ScrgbLinear = ffi::ADataSpace::ADATASPACE_SCRGB_LINEAR.0,
/// `sRGB` gamma encoding.
///
/// The `red`, `green` and `blue` components are stored in `sRGB` space, and converted to linear
/// space when read, using the [`sRGB` transfer] function for each of the `R`, `G` and `B`
/// components. When written, the inverse transformation is performed.
///
/// The `alpha` component, if present, is always stored in linear space and is left unmodified
/// when read or written.
///
/// Uses [full range], [`sRGB` transfer] and [`BT.709` standard].
///
/// [full range]: DataSpaceRange::Full
/// [`sRGB` transfer]: DataSpaceTransfer::Srgb
/// [`BT.709` standard]: DataSpaceStandard::Bt709
#[doc(alias = "ADATASPACE_SRGB")]
Srgb = ffi::ADataSpace::ADATASPACE_SRGB.0,
/// `sRGB` linear encoding.
///
/// The `red`, `green`, and `blue` components are stored in `sRGB` space, but are linear, not
/// gamma-encoded. The `RGB` primaries and the white point are the same as [`BT.709]`.
///
/// The values are encoded using the [full range] (`[0, 255]` for 8-bit) for all components.
///
/// Uses [full range], [linear transfer] and [`BT.709` standard].
///
/// [full range]: DataSpaceRange::Full
/// [linear transfer]: DataSpaceTransfer::Linear
/// [`BT.709` standard]: DataSpaceStandard::Bt709
#[doc(alias = "ADATASPACE_SRGB_LINEAR")]
SrgbLinear = ffi::ADataSpace::ADATASPACE_SRGB_LINEAR.0,
/// Depth.
///
/// This value is valid with formats [`HAL_PIXEL_FORMAT_Y16`] and [`HAL_PIXEL_FORMAT_BLOB`].
///
/// [`HAL_PIXEL_FORMAT_Y16`]: https://cs.android.com/android/platform/superproject/main/+/main:frameworks/native/libs/nativewindow/include/vndk/hardware_buffer.h;l=74-75;drc=45317f5c7c966fc816843217adc96a2ddea8bf29
/// [`HAL_PIXEL_FORMAT_BLOB`]: super::hardware_buffer_format::HardwareBufferFormat::BLOB
#[doc(alias = "ADATASPACE_DEPTH")]
Depth = ffi::ADataSpace::ADATASPACE_DEPTH.0,
/// ISO `16684-1:2011(E)` Dynamic Depth.
///
/// Embedded depth metadata following the dynamic depth specification.
#[doc(alias = "ADATASPACE_DYNAMIC_DEPTH")]
DynamicDepth = ffi::ADataSpace::ADATASPACE_DYNAMIC_DEPTH.0,
#[doc(hidden)]
#[num_enum(catch_all)]
__Unknown(i32),
}
impl fmt::Display for DataSpace {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Unknown => "Unknown",
Self::AdobeRgb => "AdobeRgb",
Self::Bt2020 => "Bt2020",
Self::Bt2020Hlg => "Bt2020Hlg",
Self::Bt2020ItuHlg => "Bt2020ItuHlg",
Self::Bt2020ItuPq => "Bt2020ItuPq",
Self::Bt2020Pq => "Bt2020Pq",
Self::Bt601_525 => "Bt601_525",
Self::Bt601_625 => "Bt601_625",
Self::Bt709 => "Bt709",
Self::DciP3 => "DciP3",
Self::DisplayP3 => "DisplayP3",
Self::Jfif => "Jfif",
Self::Scrgb => "Scrgb",
Self::ScrgbLinear => "ScrgbLinear",
Self::Srgb => "Srgb",
Self::SrgbLinear => "SrgbLinear",
Self::Depth => "Depth",
Self::DynamicDepth => "DynamicDepth",
Self::__Unknown(u) => {
return write!(
f,
"Unknown DataSpace({u:x?}, standard: {:?}, transfer: {:?}, range: {:?})",
self.standard(),
self.transfer(),
self.range()
)
}
})
}
}
impl fmt::Debug for DataSpace {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"DataSpace({self}, standard: {:?}, transfer: {:?}, range: {:?})",
self.standard(),
self.transfer(),
self.range(),
)
}
}
impl DataSpace {
/// Construct a [`DataSpace`] from individual `standard`, `transfer` and `range` components.
///
/// Together these should correspond to a single format.
pub fn from_parts(
standard: DataSpaceStandard,
transfer: DataSpaceTransfer,
range: DataSpaceRange,
) -> Self {
Self::from(i32::from(standard) | i32::from(transfer) | i32::from(range))
}
/// Extracts and returns the color-description aspect from this [`DataSpace`].
#[doc(alias = "STANDARD_MASK")]
pub fn standard(self) -> DataSpaceStandard {
let standard = i32::from(self) & ffi::ADataSpace::STANDARD_MASK.0;
standard.into()
}
/// Extracts and returns the transfer aspect from this [`DataSpace`].
#[doc(alias = "TRANSFER_MASK")]
pub fn transfer(self) -> DataSpaceTransfer {
let transfer = i32::from(self) & ffi::ADataSpace::TRANSFER_MASK.0;
transfer.into()
}
/// Extracts and returns the range aspect from this [`DataSpace`].
#[doc(alias = "RANGE_MASK")]
pub fn range(self) -> DataSpaceRange {
let range = i32::from(self) & ffi::ADataSpace::RANGE_MASK.0;
range.into()
}
}
/// Color-description aspects.
///
/// The following aspects define various characteristics of the color specification. These represent
/// bitfields, so that a data space value can specify each of them independently. Standard aspect
/// defines the chromaticity coordinates of the source primaries in terms of the CIE 1931 definition
/// of `x` and `y` specified in ISO 11664-1.
#[repr(i32)]
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
#[doc(alias = "STANDARD_MASK")]
#[non_exhaustive]
pub enum DataSpaceStandard {
/// Chromacity coordinates are unknown or are determined by the application. Implementations
/// shall use the following suggested standards:
///
/// All `YCbCr` formats: [`BT.709`] if size is `720p` or larger (since most video content is
/// letterboxed this corresponds to width is `1280` or greater, or height
/// is 720 or greater). [`BT.601_625`] if size is smaller than `720p` or
/// is `JPEG`.
/// All `RGB` formats: [`BT.709`].
///
/// For all other formats the standard is undefined, and implementations should use an
/// appropriate standard for the data represented.
///
/// [`BT.709`]: Self::Bt709
/// [`BT.601_625`]: Self::Bt601_625
#[doc(alias = "STANDARD_UNSPECIFIED")]
Unspecified = ffi::ADataSpace::STANDARD_UNSPECIFIED.0,
/// | Primaries | x | y |
/// | ----------- | ------ | ------ |
/// | green | 0.300 | 0.600 |
/// | blue | 0.150 | 0.060 |
/// | red | 0.640 | 0.330 |
/// | white (D65) | 0.3127 | 0.3290 |
///
/// Use the unadjusted `KR = 0.2126`, `KB = 0.0722` luminance interpretation for `RGB`
/// conversion.
#[doc(alias = "STANDARD_BT709")]
Bt709 = ffi::ADataSpace::STANDARD_BT709.0,
/// | Primaries | x | y |
/// | ----------- | ------ | ------ |
/// | green | 0.290 | 0.600 |
/// | blue | 0.150 | 0.060 |
/// | red | 0.640 | 0.330 |
/// | white (D65) | 0.3127 | 0.3290 |
///
/// `KR = 0.299`, `KB = 0.114`. This adjusts the luminance interpretation for `RGB` conversion
/// from the one purely determined by the primaries to minimize the color shift into `RGB`
/// space that uses [`BT.709`] primaries.
///
/// [`BT.709`]: Self::Bt709
#[doc(alias = "STANDARD_BT601_625")]
Bt601_625 = ffi::ADataSpace::STANDARD_BT601_625.0,
/// | Primaries | x | y |
/// | ----------- | ------ | ------ |
/// | green | 0.290 | 0.600 |
/// | blue | 0.150 | 0.060 |
/// | red | 0.640 | 0.330 |
/// | white (D65) | 0.3127 | 0.3290 |
///
/// Use the unadjusted `KR = 0.222`, `KB = 0.071` luminance interpretation for `RGB` conversion.
#[doc(alias = "STANDARD_BT601_625_UNADJUSTED")]
Bt601_625Unadjusted = ffi::ADataSpace::STANDARD_BT601_625_UNADJUSTED.0,
/// | Primaries | x | y |
/// | ----------- | ------ | ------ |
/// | green | 0.310 | 0.595 |
/// | blue | 0.155 | 0.070 |
/// | red | 0.630 | 0.340 |
/// | white (D65) | 0.3127 | 0.3290 |
///
/// `KR = 0.299`, `KB = 0.114`. This adjusts the luminance interpretation for `RGB` conversion
/// from the one purely determined by the primaries to minimize the color shift into `RGB` space
/// that uses [`BT.709`] primaries.
///
/// [`BT.709`]: Self::Bt709
#[doc(alias = "STANDARD_BT601_525")]
Bt601_525 = ffi::ADataSpace::STANDARD_BT601_525.0,
/// | Primaries | x | y |
/// | ----------- | ------ | ------ |
/// | green | 0.310 | 0.595 |
/// | blue | 0.155 | 0.070 |
/// | red | 0.630 | 0.340 |
/// | white (D65) | 0.3127 | 0.3290 |
///
/// Use the unadjusted `KR = 0.212`, `KB = 0.087` luminance interpretation
/// for `RGB` conversion (as in `SMPTE 240M`).
#[doc(alias = "STANDARD_BT601_525_UNADJUSTED")]
Bt601_525Unadjusted = ffi::ADataSpace::STANDARD_BT601_525_UNADJUSTED.0,
/// | Primaries | x | y |
/// | ----------- | ------ | ------ |
/// | green | 0.170 | 0.797 |
/// | blue | 0.131 | 0.046 |
/// | red | 0.708 | 0.292 |
/// | white (D65) | 0.3127 | 0.3290 |
///
/// Use the unadjusted `KR = 0.2627`, `KB = 0.0593` luminance interpretation for `RGB`
/// conversion.
#[doc(alias = "STANDARD_BT2020")]
Bt2020 = ffi::ADataSpace::STANDARD_BT2020.0,
/// | Primaries | x | y |
/// | ----------- | ------ | ------ |
/// | green | 0.170 | 0.797 |
/// | blue | 0.131 | 0.046 |
/// | red | 0.708 | 0.292 |
/// | white (D65) | 0.3127 | 0.3290 |
///
/// Use the unadjusted `KR = 0.2627`, `KB = 0.0593` luminance interpretation for `RGB`
/// conversion using the linear domain.
#[doc(alias = "STANDARD_BT2020_CONSTANT_LUMINANCE")]
Bt2020ConstantLuminance = ffi::ADataSpace::STANDARD_BT2020_CONSTANT_LUMINANCE.0,
/// | Primaries | x | y |
/// | --------- | ----- | ---- |
/// | green | 0.21 |0.71 |
/// | blue | 0.14 |0.08 |
/// | red | 0.67 |0.33 |
/// | white (C) | 0.310 |0.316 |
///
/// Use the unadjusted `KR = 0.30`, `KB = 0.11` luminance interpretation for `RGB` conversion.
#[doc(alias = "STANDARD_BT470M")]
Bt470M = ffi::ADataSpace::STANDARD_BT470M.0,
/// | Primaries | x | y |
/// | --------- | ----- | ----- |
/// | green | 0.243 | 0.692 |
/// | blue | 0.145 | 0.049 |
/// | red | 0.681 | 0.319 |
/// | white (C) | 0.310 | 0.316 |
///
/// Use the unadjusted `KR = 0.254`, `KB = 0.068` luminance interpretation for `RGB` conversion.
#[doc(alias = "STANDARD_FILM")]
Film = ffi::ADataSpace::STANDARD_FILM.0,
/// `SMPTE EG 432-1` and `SMPTE RP 431-2`. (`DCI-P3`)
///
/// | Primaries | x | y |
/// | ----------- | ------ | ------ |
/// | green | 0.265 | 0.690 |
/// | blue | 0.150 | 0.060 |
/// | red | 0.680 | 0.320 |
/// | white (D65) | 0.3127 | 0.3290 |
#[doc(alias = "STANDARD_DCI_P3")]
DciP3 = ffi::ADataSpace::STANDARD_DCI_P3.0,
/// Adobe RGB
///
/// | Primaries | x | y |
/// | ----------- | ------ | ------ |
/// | green | 0.210 | 0.710 |
/// | blue | 0.150 | 0.060 |
/// | red | 0.640 | 0.330 |
/// | white (D65) | 0.3127 | 0.3290 |
#[doc(alias = "STANDARD_ADOBE_RGB")]
AdobeRgb = ffi::ADataSpace::STANDARD_ADOBE_RGB.0,
#[doc(hidden)]
#[num_enum(catch_all)]
__Unknown(i32),
}
/// Transfer aspect.
///
/// Transfer characteristics are the opto-electronic transfer characteristic at the source as a
///function of linear optical intensity (luminance).
///
/// For digital signals, `E` corresponds to the recorded value. Normally, the transfer function is
/// applied in `RGB` space to each of the `R`, `G` and `B` components independently. This may result
/// in color shift that can be minimized by applying the transfer function in `Lab` space only for
/// the `L` component. Implementation may apply the transfer function in `RGB` space for all pixel
/// formats if desired.
#[repr(i32)]
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
#[doc(alias = "TRANSFER_MASK")]
#[non_exhaustive]
pub enum DataSpaceTransfer {
/// Transfer characteristics are unknown or are determined by the application.
///
/// Implementations should use the following transfer functions:
///
/// - For `YCbCr` formats: use [`DataSpaceTransfer::Smpte170M`]
/// - For `RGB` formats: use [`DataSpaceTransfer::Srgb`]
///
/// For all other formats the transfer function is undefined, and implementations should use an
/// appropriate standard for the data represented.
#[doc(alias = "TRANSFER_UNSPECIFIED")]
Unspecified = ffi::ADataSpace::TRANSFER_UNSPECIFIED.0,
/// Linear transfer.
///
/// Transfer characteristic curve:
/// ```ignore
/// E = L
/// ```
/// - `L`: luminance of image `0 <= L <= 1` for conventional colorimetry
/// - `E`: corresponding electrical signal
#[doc(alias = "TRANSFER_LINEAR")]
Linear = ffi::ADataSpace::TRANSFER_LINEAR.0,
/// `sRGB` transfer.
///
/// Transfer characteristic curve:
///
/// ```ignore
/// E = 1.055 * L^(1/2.4) - 0.055 for 0.0031308 <= L <= 1
/// = 12.92 * L for 0 <= L < 0.0031308
/// ```
/// - `L`: luminance of image `0 <= L <= 1` for conventional colorimetry
/// - `E`: corresponding electrical signal
#[doc(alias = "TRANSFER_SRGB")]
Srgb = ffi::ADataSpace::TRANSFER_SRGB.0,
/// SMPTE 170M transfer.
///
/// Transfer characteristic curve:
/// ```ignore
/// E = 1.099 * L ^ 0.45 - 0.099 for 0.018 <= L <= 1
/// = 4.500 * L for 0 <= L < 0.018
/// ```
/// - `L`: luminance of image `0 <= L <= 1` for conventional colorimetry
/// - `E`: corresponding electrical signal
#[doc(alias = "TRANSFER_SMPTE_170M")]
Smpte170M = ffi::ADataSpace::TRANSFER_SMPTE_170M.0,
/// Display gamma `2.2`.
///
/// Transfer characteristic curve:
/// ```ignore
/// E = L ^ (1/2.2)
/// ```
/// - `L`: luminance of image `0 <= L <= 1` for conventional colorimetry
/// - `E`: corresponding electrical signal
#[doc(alias = "TRANSFER_GAMMA2_2")]
Gamma2_2 = ffi::ADataSpace::TRANSFER_GAMMA2_2.0,
/// Display gamma `2.6`.
///
/// Transfer characteristic curve:
/// ```ignore
/// E = L ^ (1/2.6)
/// ```
/// - `L`: luminance of image `0 <= L <= 1` for conventional colorimetry
/// - `E`: corresponding electrical signal
#[doc(alias = "TRANSFER_GAMMA2_6")]
Gamma2_6 = ffi::ADataSpace::TRANSFER_GAMMA2_6.0,
/// Display gamma `2.8`.
///
/// Transfer characteristic curve:
/// ```ignore
/// E = L ^ (1/2.8)
/// ```
/// - `L`: luminance of image `0 <= L <= 1` for conventional colorimetry
/// - `E`: corresponding electrical signal
#[doc(alias = "TRANSFER_GAMMA2_8")]
Gamma2_8 = ffi::ADataSpace::TRANSFER_GAMMA2_8.0,
/// SMPTE ST 2084 (Dolby Perceptual Quantizer).
///
/// Transfer characteristic curve:
/// ```ignore
/// E = ((c1 + c2 * L^n) / (1 + c3 * L^n)) ^ m
/// c1 = c3 - c2 + 1 = 3424 / 4096 = 0.8359375
/// c2 = 32 * 2413 / 4096 = 18.8515625
/// c3 = 32 * 2392 / 4096 = 18.6875
/// m = 128 * 2523 / 4096 = 78.84375
/// n = 0.25 * 2610 / 4096 = 0.1593017578125
/// ```
/// - `L`: luminance of image 0 <= L <= 1 for HDR colorimetry.
/// `L = 1` corresponds to `10000 cd/m2`
#[doc(alias = "TRANSFER_ST2084")]
St2084 = ffi::ADataSpace::TRANSFER_ST2084.0,
/// ARIB STD-B67 Hybrid Log Gamma.
///
/// Transfer characteristic curve:
/// ```ignore
/// E = r * L^0.5 for 0 <= L <= 1
/// = a * ln(L - b) + c for 1 < L
/// a = 0.17883277
/// b = 0.28466892
/// c = 0.55991073
/// r = 0.5
/// ```
/// - `L`: luminance of image `0 <= L` for HDR colorimetry.
/// `L = 1` corresponds to reference white level of `100 cd/m2`
/// - `E`: corresponding electrical signal
#[doc(alias = "TRANSFER_HLG")]
HLG = ffi::ADataSpace::TRANSFER_HLG.0,
#[doc(hidden)]
#[num_enum(catch_all)]
__Unknown(i32),
}
/// Range aspect.
///
/// Defines the range of values corresponding to the unit range of `0-1`. This is defined for
/// `YCbCr` only, but can be expanded to `RGB` space.
#[repr(i32)]
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
#[doc(alias = "RANGE_MASK")]
#[non_exhaustive]
pub enum DataSpaceRange {
/// Range is unknown or are determined by the application. Implementations shall use the
/// following suggested ranges:
///
/// - All YCbCr formats: limited range.
/// - All RGB or RGBA formats (including RAW and Bayer): full range.
/// - All Y formats: full range
///
/// For all other formats range is undefined, and implementations should use an appropriate
/// range for the data represented.
#[doc(alias = "RANGE_UNSPECIFIED")]
Unspecified = ffi::ADataSpace::RANGE_UNSPECIFIED.0,
/// Full range uses all values for `Y`, `Cb` and `Cr` from `0` to `2^b-1`, where `b` is the bit
/// depth of the color format.
#[doc(alias = "RANGE_FULL")]
Full = ffi::ADataSpace::RANGE_FULL.0,
/// Limited range uses values `16/256*2^b` to `235/256*2^b` for `Y`, and `1/16*2^b` to
/// `15/16*2^b` for `Cb`, `Cr`, `R`, `G` and `B`, where `b` is the bit depth of the color
/// format.
///
/// E.g. For 8-bit-depth formats: Luma (`Y`) samples should range from `16` to `235`, inclusive
/// Chroma `(Cb, Cr)` samples should range from `16` to `240`, inclusive.
///
/// For 10-bit-depth formats: Luma (`Y`) samples should range from `64` to `940`, inclusive
/// Chroma `(Cb, Cr)` samples should range from `64` to `960`, inclusive.
#[doc(alias = "RANGE_LIMITED")]
Limited = ffi::ADataSpace::RANGE_LIMITED.0,
/// Extended range is used for `scRGB`.
///
/// Intended for use with floating point pixel formats. `[0.0 - 1.0]` is the standard `sRGB`
/// space. Values outside the range `0.0 - 1.0` can encode color outside the `sRGB` gamut. Used
/// to blend / merge multiple dataspaces on a single display.
#[doc(alias = "RANGE_EXTENDED")]
Extended = ffi::ADataSpace::RANGE_EXTENDED.0,
#[doc(hidden)]
#[num_enum(catch_all)]
__Unknown(i32),
}
File diff suppressed because it is too large Load Diff
+552
View File
@@ -0,0 +1,552 @@
//! Bindings for [`AFont`], [`AFontMatcher`], and [`ASystemFontIterator`]
//!
//! [`AFont`]: https://developer.android.com/ndk/reference/group/font
//! [`AFontMatcher`]: https://developer.android.com/ndk/reference/group/font#afontmatcher_create
//! [`ASystemFontIterator`]: https://developer.android.com/ndk/reference/group/font#asystemfontiterator_open
#![cfg(feature = "api-level-29")]
use std::convert::TryFrom;
use std::ffi::{CStr, OsStr};
use std::fmt::{self, Write};
use std::os::unix::prelude::OsStrExt;
use std::path::Path;
use std::ptr::NonNull;
use num_enum::IntoPrimitive;
/// An integer holding a valid font weight value between 1 and 1000.
///
/// See the [`Font::weight`] definition for more details.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct FontWeight(u16);
impl FontWeight {
pub const fn new(value: u16) -> Result<Self, FontWeightValueError> {
if Self::MIN.0 <= value && value <= Self::MAX.0 {
Ok(Self(value))
} else {
Err(FontWeightValueError(()))
}
}
pub const fn to_u16(self) -> u16 {
self.0
}
/// The minimum value for the font weight value. Unlike [`ffi::AFONT_WEIGHT_MIN`] being `0`,
/// [`FontWeight::MIN`] is `1` to make the `MIN..MAX` range be inclusive, keeping consistency
/// between [`FontWeight`] and other types like `std::num::NonZeroU*`.
pub const MIN: FontWeight = FontWeight(ffi::AFONT_WEIGHT_MIN as u16 + 1);
/// A font weight value for the thin weight.
pub const THIN: FontWeight = FontWeight(ffi::AFONT_WEIGHT_THIN as u16);
/// A font weight value for the extra-light weight.
pub const EXTRA_LIGHT: FontWeight = FontWeight(ffi::AFONT_WEIGHT_EXTRA_LIGHT as u16);
/// A font weight value for the light weight.
pub const LIGHT: FontWeight = FontWeight(ffi::AFONT_WEIGHT_LIGHT as u16);
/// A font weight value for the normal weight.
pub const NORMAL: FontWeight = FontWeight(ffi::AFONT_WEIGHT_NORMAL as u16);
/// A font weight value for the medium weight.
pub const MEDIUM: FontWeight = FontWeight(ffi::AFONT_WEIGHT_MEDIUM as u16);
/// A font weight value for the semi-bold weight.
pub const SEMI_BOLD: FontWeight = FontWeight(ffi::AFONT_WEIGHT_SEMI_BOLD as u16);
/// A font weight value for the bold weight.
pub const BOLD: FontWeight = FontWeight(ffi::AFONT_WEIGHT_BOLD as u16);
/// A font weight value for the extra-bold weight.
pub const EXTRA_BOLD: FontWeight = FontWeight(ffi::AFONT_WEIGHT_EXTRA_BOLD as u16);
/// A font weight value for the black weight.
pub const BLACK: FontWeight = FontWeight(ffi::AFONT_WEIGHT_BLACK as u16);
/// The maximum value for the font weight value.
pub const MAX: FontWeight = FontWeight(ffi::AFONT_WEIGHT_MAX as u16);
}
impl fmt::Display for FontWeight {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match *self {
FontWeight::THIN => "Thin",
FontWeight::EXTRA_LIGHT => "Extra Light (Ultra Light)",
FontWeight::LIGHT => "Light",
FontWeight::NORMAL => "Normal (Regular)",
FontWeight::MEDIUM => "Medium",
FontWeight::SEMI_BOLD => "Semi Bold (Demi Bold)",
FontWeight::BOLD => "Bold",
FontWeight::EXTRA_BOLD => "Extra Bold (Ultra Bold)",
FontWeight::BLACK => "Black (Heavy)",
_ => return writeln!(f, "{}", self.0),
})
}
}
/// The error type returned when an invalid font weight value is passed.
#[derive(Debug)]
pub struct FontWeightValueError(());
impl fmt::Display for FontWeightValueError {
fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt.write_str("font weight must be positive and less than or equal to 1000")
}
}
impl std::error::Error for FontWeightValueError {}
impl TryFrom<u16> for FontWeight {
type Error = FontWeightValueError;
fn try_from(value: u16) -> Result<Self, Self::Error> {
FontWeight::new(value)
}
}
/// A 4-byte integer representing an OpenType axis tag.
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct AxisTag(u32);
impl AxisTag {
/// Checks whether the given 4-byte array can construct a valid axis tag and returns
/// [`Ok(AxisTag)`] if the array is valid.
///
/// Each byte in a tag must be in the range 0x20 to 0x7E. A space character cannot be followed
/// by a non-space character. A tag must have one to four non-space characters. See the
/// [OpenType spec] for more details.
///
/// [OpenType spec]: https://learn.microsoft.com/en-us/typography/opentype/spec/otff#data-types
pub const fn from_be_bytes_checked(value: [u8; 4]) -> Result<Self, AxisTagValueError> {
// Each byte in a tag must be in the range 0x20 to 0x7E.
macro_rules! check_byte_range {
($($e:expr)+) => {
$(
if !(value[$e] as char).is_ascii_graphic() && value[$e] != b' ' {
return Err(AxisTagValueError::InvalidCharacter);
}
)+
};
}
check_byte_range!(0 1 2 3);
if value[0] == b' ' {
return Err(
if value[1] == b' ' && value[2] == b' ' && value[3] == b' ' {
// A tag must have one to four non-space characters.
AxisTagValueError::EmptyTag
} else {
// A space character cannot be followed by a non-space character.
AxisTagValueError::InvalidSpacePadding
},
);
}
macro_rules! check_if_valid {
($e:expr ; $($f:expr)+) => {
if value[$e] == b' ' {
return if true $(&& value[$f] == b' ')+ {
Ok(Self(u32::from_be_bytes(value)))
} else {
// A space character cannot be followed by a non-space character.
Err(AxisTagValueError::InvalidSpacePadding)
};
}
};
}
check_if_valid!(1; 2 3);
check_if_valid!(2; 3);
// Whether or not value[3] is b' ', value is a valid axis tag.
Ok(Self(u32::from_be_bytes(value)))
}
/// Checks whether the given 4-byte array can construct a valid axis tag and returns
/// [`Ok(AxisTag)`] if the array is valid.
///
/// See [`AxisTag::from_be()`] for more details.
pub const fn from_be_checked(value: u32) -> Result<Self, AxisTagValueError> {
Self::from_be_bytes_checked(value.to_be_bytes())
}
/// Construct an axis tag from the given 4-byte array. If the resulting axis tag is invalid,
/// this function panics.
///
/// See [`AxisTag::from_be()`] for more details.
pub const fn from_be_bytes(value: [u8; 4]) -> Self {
Self::unwrap_result(Self::from_be_bytes_checked(value))
}
/// Construct an axis tag from the given 4-byte integer. If the resulting axis tag is invalid,
/// this function panics.
///
/// See [`AxisTag::from_be()`] for more details.
pub const fn from_be(value: u32) -> Self {
Self::unwrap_result(Self::from_be_checked(value))
}
/// const-version of [`Result::unwrap`]. Should be removed when [`Option::unwrap`] or
/// [`Result::unwrap`] become `const`-stable.
const fn unwrap_result(result: Result<Self, AxisTagValueError>) -> Self {
match result {
Ok(t) => t,
Err(e) => panic!("{}", e.as_str()),
}
}
pub const fn to_u32(self) -> u32 {
self.0
}
pub const fn to_be_bytes(self) -> [u8; 4] {
self.0.to_be_bytes()
}
}
impl fmt::Display for AxisTag {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let bytes = self.to_be_bytes();
f.write_char(bytes[0] as char)?;
f.write_char(bytes[1] as char)?;
f.write_char(bytes[2] as char)?;
f.write_char(bytes[3] as char)
}
}
impl fmt::Debug for AxisTag {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "AxisTag({} {:#x})", self, self.0)
}
}
/// The error type returned when an invalid axis tag value is passed.
#[derive(Clone, Copy, Debug)]
pub enum AxisTagValueError {
/// There is a byte not in the range 0x20 to 0x7E.
InvalidCharacter,
/// There is a space character followed by a non-space character.
InvalidSpacePadding,
/// The tag only consists of space characters.
EmptyTag,
}
impl AxisTagValueError {
pub const fn as_str(&self) -> &'static str {
match self {
Self::InvalidCharacter => "each byte in an axis tag must be in the range 0x20 to 0x7E",
Self::InvalidSpacePadding => {
"a space character cannot be followed by a non-space character"
}
Self::EmptyTag => "a tag must have one to four non-space characters",
}
}
}
impl fmt::Display for AxisTagValueError {
fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt.write_str(self.as_str())
}
}
impl std::error::Error for AxisTagValueError {}
/// A native [`AFont *`]
///
/// [`AFont *`]: https://developer.android.com/ndk/reference/group/font
#[derive(Debug)]
pub struct Font {
ptr: NonNull<ffi::AFont>,
}
impl Font {
/// Assumes ownership of `ptr`.
///
/// # Safety
/// `ptr` must be a valid owning pointer to an Android [`ffi::AFont`].
pub unsafe fn from_ptr(ptr: NonNull<ffi::AFont>) -> Self {
Self { ptr }
}
/// Returns s the pointer to the native [`ffi::AFont`].
pub fn ptr(&self) -> NonNull<ffi::AFont> {
self.ptr
}
/// Returns a count of font variation settings associated with the current font.
///
/// The font variation settings are provided as multiple tag-value pairs.
///
/// For example, bold italic font may have following font variation settings: `'wght' 700`,
/// `'slnt' -12`. In this case, [`Font::axis_count()`] returns `2` and [`Font::axis_tag_at()`] and
/// [`Font::axis_value_at()`] return those variation names and the corresponding values.
///
/// ```no_run
/// use ndk::font::Font;
///
/// let font: Font = todo!();
/// for idx in 0..font.axis_count() {
/// log::debug!("{}: {}", font.axis_tag_at(idx), font.axis_value_at(idx));
/// }
/// // Output:
/// // wght: 700
/// // slnt: -12
/// ```
pub fn axis_count(&self) -> usize {
unsafe { ffi::AFont_getAxisCount(self.ptr.as_ptr()) }
}
/// Returns an OpenType axis tag associated with the current font.
///
/// See [`Font::axis_count()`] for more details.
pub fn axis_tag_at(&self, idx: usize) -> AxisTag {
// Android returns Axis Tag in big-endian.
// See https://cs.android.com/android/platform/superproject/+/refs/heads/master:frameworks/base/native/android/system_fonts.cpp;l=197 for details
AxisTag(unsafe { ffi::AFont_getAxisTag(self.ptr.as_ptr(), idx as u32) })
}
/// Returns an OpenType axis value associated with the current font.
///
/// See [`Font::axis_count()`] for more details.
pub fn axis_value_at(&self, idx: usize) -> f32 {
unsafe { ffi::AFont_getAxisValue(self.ptr.as_ptr(), idx as u32) }
}
/// Returns a font collection index value associated with the current font.
///
/// In case the target font file is a font collection (e.g. `.ttc` or `.otc`), this returns a
/// non-negative value as a font offset in the collection. This always returns 0 if the target
/// font file is a regular font.
pub fn collection_index(&self) -> usize {
unsafe { ffi::AFont_getCollectionIndex(self.ptr.as_ptr()) }
}
/// Returns an absolute path to the current font file.
///
/// Here is a list of font formats returned by this method:
///
/// * OpenType
/// * OpenType Font Collection
/// * TrueType
/// * TrueType Collection
///
/// The file extension could be one of `*.otf`, `*.ttf`, `*.otc` or `*.ttc`.
/// The font file specified by the returned path is guaranteed to be openable with `O_RDONLY`.
pub fn path(&self) -> &Path {
let path = unsafe { CStr::from_ptr(ffi::AFont_getFontFilePath(self.ptr.as_ptr())) };
OsStr::from_bytes(path.to_bytes()).as_ref()
}
/// Returns an IETF BCP47 compliant language tag associated with the current font.
///
/// For information about IETF BCP47, read [`Locale.forLanguageTag(java.lang.String)`].
///
/// [`Locale.forLanguageTag(java.lang.String)`]: https://developer.android.com/reference/java/util/Locale.html#forLanguageTag(java.lang.String)
pub fn locale(&self) -> Option<&CStr> {
let ptr = unsafe { ffi::AFont_getLocale(self.ptr.as_ptr()) };
if ptr.is_null() {
None
} else {
Some(unsafe { CStr::from_ptr(ptr) })
}
}
/// Returns a weight value associated with the current font.
///
/// The weight values are positive and less than or equal to 1000. Here are pairs of the common
/// names and their values.
///
/// | Value | Name | NDK Definition |
/// | ----- | ------------------------- | --------------------------- |
/// | 100 | Thin | [`FontWeight::THIN`] |
/// | 200 | Extra Light (Ultra Light) | [`FontWeight::EXTRA_LIGHT`] |
/// | 300 | Light | [`FontWeight::LIGHT`] |
/// | 400 | Normal (Regular) | [`FontWeight::NORMAL`] |
/// | 500 | Medium | [`FontWeight::MEDIUM`] |
/// | 600 | Semi Bold (Demi Bold) | [`FontWeight::SEMI_BOLD`] |
/// | 700 | Bold | [`FontWeight::BOLD`] |
/// | 800 | Extra Bold (Ultra Bold) | [`FontWeight::EXTRA_BOLD`] |
/// | 900 | Black (Heavy) | [`FontWeight::BLACK`] |
pub fn weight(&self) -> FontWeight {
FontWeight(unsafe { ffi::AFont_getWeight(self.ptr.as_ptr()) })
}
/// Returns [`true`] if the current font is italic, otherwise returns [`false`].
pub fn is_italic(&self) -> bool {
unsafe { ffi::AFont_isItalic(self.ptr.as_ptr()) }
}
}
impl Drop for Font {
fn drop(&mut self) {
unsafe { ffi::AFont_close(self.ptr.as_ptr()) }
}
}
/// Corresponds to [`AFAMILY_VARIANT_*`].
///
/// [`AFAMILY_VARIANT_*`]: https://developer.android.com/ndk/reference/group/font#group___font_1gga96a58e29e8dbf2b5bdeb775cba46556ea662aafc7016e35d6758da93416fc0833
#[repr(u32)]
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, IntoPrimitive)]
#[non_exhaustive]
pub enum FamilyVariant {
/// A family variant value for the compact font family variant.
/// The compact font family has Latin-based vertical metrics.
Compact = ffi::AFAMILY_VARIANT_COMPACT,
/// A family variant value for the system default variant.
Default = ffi::AFAMILY_VARIANT_DEFAULT,
/// A family variant value for the elegant font family variant.
/// The elegant font family may have larger vertical metrics than Latin font.
Elegant = ffi::AFAMILY_VARIANT_ELEGANT,
#[doc(hidden)]
#[num_enum(catch_all)]
__Unknown(u32),
}
/// A native [`AFontMatcher *`]
///
/// [`AFontMatcher *`]: https://developer.android.com/ndk/reference/group/font#afontmatcher_create
#[derive(Debug)]
pub struct FontMatcher {
ptr: NonNull<ffi::AFontMatcher>,
}
impl FontMatcher {
/// Assumes ownership of `ptr`.
///
/// # Safety
/// `ptr` must be a valid owning pointer to an Android [`ffi::AFontMatcher`].
pub unsafe fn from_ptr(ptr: NonNull<ffi::AFontMatcher>) -> Self {
Self { ptr }
}
/// Returns s the pointer to the native [`ffi::AFontMatcher`].
pub fn ptr(&self) -> NonNull<ffi::AFontMatcher> {
self.ptr
}
/// Creates a new [`FontMatcher`] object. [`FontMatcher`] selects the best font from the
/// parameters set by the user.
pub fn new() -> Self {
let ptr = NonNull::new(unsafe { ffi::AFontMatcher_create() })
.expect("AFontMatcher_create returned NULL");
unsafe { FontMatcher::from_ptr(ptr) }
}
/// Performs the matching from the generic font family for the text and select one font.
///
/// For more information about generic font families, please read the
/// [W3C spec](https://www.w3.org/TR/css-fonts-4/#generic-font-families).
///
/// Even if no font can render the given text, this function will return a non-null result for
/// drawing Tofu character.
///
/// # Parameters
///
/// - `family_name`: A font family name.
/// - `text`: A UTF-16 encoded text buffer to be rendered. If an empty string is given, this
/// function will panic.
/// - `run_length_out`: Set this to [`Some`] if you want to get the length of the text run with
/// the font returned.
pub fn match_font(
&mut self,
family_name: &CStr,
text: &[u16],
run_length_out: Option<&mut u32>,
) -> Font {
if text.is_empty() {
panic!("text is empty");
}
unsafe {
Font::from_ptr(
NonNull::new(ffi::AFontMatcher_match(
self.ptr.as_ptr(),
family_name.as_ptr(),
text.as_ptr(),
text.len() as _,
run_length_out.map_or(std::ptr::null_mut(), |u| u),
))
.expect("AFontMatcher_match returned NULL"),
)
}
}
/// Sets the family variant of the font to be matched.
///
/// If this function is not called, the match is performed with [`FamilyVariant::Default`].
pub fn set_family_variant(&mut self, family_variant: FamilyVariant) {
unsafe { ffi::AFontMatcher_setFamilyVariant(self.ptr.as_ptr(), family_variant.into()) }
}
/// Sets the locale of the font to be matched.
///
/// If this function is not called, the match is performed with an empty locale list.
///
/// # Parameters
///
/// - `language_tags`: comma separated IETF BCP47 compliant language tags.
pub fn set_locales(&mut self, language_tags: &CStr) {
unsafe { ffi::AFontMatcher_setLocales(self.ptr.as_ptr(), language_tags.as_ptr()) }
}
/// Sets the style of the font to be matched.
///
/// If this function is not called, the match is performed with [`FontWeight::NORMAL`] with non-italic style.
pub fn set_style(&mut self, weight: FontWeight, italic: bool) {
unsafe { ffi::AFontMatcher_setStyle(self.ptr.as_ptr(), weight.to_u16(), italic) }
}
}
impl Drop for FontMatcher {
fn drop(&mut self) {
unsafe { ffi::AFontMatcher_destroy(self.ptr.as_ptr()) }
}
}
/// A native [`ASystemFontIterator *`]
///
/// [`ASystemFontIterator *`]: https://developer.android.com/ndk/reference/group/font#asystemfontiterator_open
#[derive(Debug)]
pub struct SystemFontIterator {
ptr: NonNull<ffi::ASystemFontIterator>,
}
impl SystemFontIterator {
/// Assumes ownership of `ptr`.
///
/// # Safety
/// `ptr` must be a valid owning pointer to an Android [`ffi::ASystemFontIterator`].
pub unsafe fn from_ptr(ptr: NonNull<ffi::ASystemFontIterator>) -> Self {
Self { ptr }
}
/// Returns the pointer to the native [`ffi::ASystemFontIterator`].
pub fn ptr(&self) -> NonNull<ffi::ASystemFontIterator> {
self.ptr
}
/// Creates a system font iterator.
pub fn new() -> Option<Self> {
NonNull::new(unsafe { ffi::ASystemFontIterator_open() })
.map(|p| unsafe { SystemFontIterator::from_ptr(p) })
}
}
impl Iterator for SystemFontIterator {
type Item = Font;
fn next(&mut self) -> Option<Self::Item> {
NonNull::new(unsafe { ffi::ASystemFontIterator_next(self.ptr.as_ptr()) })
.map(|p| unsafe { Font::from_ptr(p) })
}
}
impl Drop for SystemFontIterator {
fn drop(&mut self) {
unsafe { ffi::ASystemFontIterator_close(self.ptr.as_ptr()) }
}
}
+640
View File
@@ -0,0 +1,640 @@
//! Bindings for [`AHardwareBuffer`]
//!
//! [`AHardwareBuffer`]: https://developer.android.com/ndk/reference/group/a-hardware-buffer#ahardwarebuffer
#![cfg(feature = "api-level-26")]
use std::{
io::Result,
mem::MaybeUninit,
ops::Deref,
os::{
fd::{AsRawFd, BorrowedFd, FromRawFd, IntoRawFd, OwnedFd},
raw::c_void,
},
ptr::NonNull,
};
use jni_sys::{jobject, JNIEnv};
use super::{hardware_buffer_format::HardwareBufferFormat, utils::status_to_io_result};
bitflags::bitflags! {
/// Buffer usage flags, specifying how the buffer will be accessed.
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
#[doc(alias = "AHardwareBuffer_UsageFlags")]
pub struct HardwareBufferUsage : u64 {
/// The buffer will never be locked for direct CPU reads using the
/// [`HardwareBuffer::lock()`] function. Note that reading the buffer using OpenGL or Vulkan
/// functions or memory mappings is still allowed.
#[doc(alias = "AHARDWAREBUFFER_USAGE_CPU_READ_NEVER")]
const CPU_READ_NEVER = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_CPU_READ_NEVER.0;
/// The buffer will sometimes be locked for direct CPU reads using the
/// [`HardwareBuffer::lock()`] function. Note that reading the buffer using OpenGL or Vulkan
/// functions or memory mappings does not require the presence of this flag.
#[doc(alias = "AHARDWAREBUFFER_USAGE_CPU_READ_RARELY")]
const CPU_READ_RARELY = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_CPU_READ_RARELY.0;
/// The buffer will often be locked for direct CPU reads using the
/// [`HardwareBuffer::lock()`] function. Note that reading the buffer using OpenGL or Vulkan
/// functions or memory mappings does not require the presence of this flag.
#[doc(alias = "AHARDWAREBUFFER_USAGE_CPU_READ_OFTEN")]
const CPU_READ_OFTEN = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_CPU_READ_OFTEN.0;
/// CPU read value mask.
#[doc(alias = "AHARDWAREBUFFER_USAGE_CPU_READ_MASK")]
const CPU_READ_MASK = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_CPU_READ_MASK.0;
/// The buffer will never be locked for direct CPU writes using the
/// [`HardwareBuffer::lock()`] function. Note that writing the buffer using OpenGL or Vulkan
/// functions or memory mappings is still allowed.
#[doc(alias = "AHARDWAREBUFFER_USAGE_CPU_WRITE_NEVER")]
const CPU_WRITE_NEVER = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_CPU_WRITE_NEVER.0;
/// The buffer will sometimes be locked for direct CPU writes using the
/// [`HardwareBuffer::lock()`] function. Note that writing the buffer using OpenGL or Vulkan
/// functions or memory mappings does not require the presence of this flag.
#[doc(alias = "AHARDWAREBUFFER_USAGE_CPU_WRITE_RARELY")]
const CPU_WRITE_RARELY = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_CPU_WRITE_RARELY.0;
/// The buffer will often be locked for direct CPU writes using the
/// [`HardwareBuffer::lock()`] function. Note that writing the buffer using OpenGL or Vulkan
/// functions or memory mappings does not require the presence of this flag.
#[doc(alias = "AHARDWAREBUFFER_USAGE_CPU_WRITE_OFTEN")]
const CPU_WRITE_OFTEN = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_CPU_WRITE_OFTEN.0;
/// CPU write value mask.
#[doc(alias = "AHARDWAREBUFFER_USAGE_CPU_WRITE_MASK")]
const CPU_WRITE_MASK = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_CPU_WRITE_MASK.0;
/// The buffer will be read from by the GPU as a texture.
#[doc(alias = "AHARDWAREBUFFER_USAGE_GPU_SAMPLED_IMAGE")]
const GPU_SAMPLED_IMAGE = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_GPU_SAMPLED_IMAGE.0;
/// The buffer will be written to by the GPU as a framebuffer attachment.
#[doc(alias = "AHARDWAREBUFFER_USAGE_GPU_FRAMEBUFFER")]
const GPU_FRAMEBUFFER = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_GPU_FRAMEBUFFER.0;
/// The buffer will be written to by the GPU as a framebuffer attachment.
///
/// Note that the name of this flag is somewhat misleading: it does not imply that the
/// buffer contains a color format. A buffer with depth or stencil format that will be
/// used as a framebuffer attachment should also have this flag. Use the equivalent flag
/// [`HardwareBufferusage::GPU_FRAMEBUFFER`] to avoid this confusion.
#[doc(alias = "AHARDWAREBUFFER_USAGE_GPU_COLOR_OUTPUT")]
const GPU_COLOR_OUTPUT = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_GPU_COLOR_OUTPUT.0;
/// The buffer will be used as a composer HAL overlay layer.
///
/// This flag is currently only needed when using [`SurfaceTransaction::set_buffer()`] to
/// set a buffer. In all other cases, the framework adds this flag internally to buffers
/// that could be presented in a composer overlay. [`SurfaceTransaction::set_buffer()`]
/// is special because it uses buffers allocated directly through
/// [`HardwareBuffer::allocate()`] instead of buffers allocated by the framework.
#[doc(alias = "AHARDWAREBUFFER_USAGE_COMPOSER_OVERLAY")]
const COMPOSER_OVERLAY = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_COMPOSER_OVERLAY.0;
/// The buffer is protected from direct CPU access or being read by non-secure hardware,
/// such as video encoders.
///
/// This flag is incompatible with CPU read and write flags. It is mainly used when handling
/// DRM video. Refer to the EGL extension [`EGL_EXT_protected_content`] and GL extension
/// [`GL_EXT_protected_textures`] for more information on how these buffers are expected
/// to behave.
///
/// [`EGL_EXT_protected_content`]: https://registry.khronos.org/EGL/extensions/EXT/EGL_EXT_protected_content.txt
/// [`GL_EXT_protected_textures`]: https://registry.khronos.org/OpenGL/extensions/EXT/EXT_protected_textures.txt
#[doc(alias = "AHARDWAREBUFFER_USAGE_PROTECTED_CONTENT")]
const PROTECTED_CONTENT = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_PROTECTED_CONTENT.0;
/// The buffer will be read by a hardware video encoder.
#[doc(alias = "AHARDWAREBUFFER_USAGE_VIDEO_ENCODE")]
const VIDEO_ENCODE = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VIDEO_ENCODE.0;
/// The buffer will be used for direct writes from sensors. When this flag is present, the
/// format must be [`HardwareBufferFormat::Blob`].
#[doc(alias = "AHARDWAREBUFFER_USAGE_SENSOR_DIRECT_DATA")]
const SENSOR_DIRECT_DATA = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_SENSOR_DIRECT_DATA.0;
/// The buffer will be used as a shader storage or uniform buffer object. When this flag is
/// present, the format must be [`HardwareBufferFormat::Blob`].
#[doc(alias = "AHARDWAREBUFFER_USAGE_GPU_DATA_BUFFER")]
const GPU_DATA_BUFFER = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_GPU_DATA_BUFFER.0;
/// The buffer will be used as a cube map texture. When this flag is present, the buffer
/// must have a layer count that is a multiple of 6. Note that buffers with this flag must
/// be bound to OpenGL textures using the extension [`GL_EXT_EGL_image_storage`] instead
/// of [`GL_KHR_EGL_image`].
///
/// [`GL_EXT_EGL_image_storage`]: https://registry.khronos.org/OpenGL/extensions/EXT/EXT_EGL_image_storage.txt
// TODO: This extension only exists for VG. Reported at https://issuetracker.google.com/issues/300602767#comment16
/// [`GL_KHR_EGL_image`]: https://registry.khronos.org/OpenVG/extensions/KHR/VG_KHR_EGL_image.txt
#[doc(alias = "AHARDWAREBUFFER_USAGE_GPU_CUBE_MAP")]
const GPU_CUBE_MAP = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_GPU_CUBE_MAP.0;
/// The buffer contains a complete mipmap hierarchy. Note that buffers with this flag must
/// be bound to OpenGL textures using the extension [`GL_EXT_EGL_image_storage`] instead
/// of [`GL_KHR_EGL_image`].
///
/// [`GL_EXT_EGL_image_storage`]: https://registry.khronos.org/OpenGL/extensions/EXT/EXT_EGL_image_storage.txt
// TODO: This extension only exists for VG. Reported at https://issuetracker.google.com/issues/300602767#comment16
/// [`GL_KHR_EGL_image`]: https://registry.khronos.org/OpenVG/extensions/KHR/VG_KHR_EGL_image.txt
#[doc(alias = "AHARDWAREBUFFER_USAGE_GPU_MIPMAP_COMPLETE")]
const GPU_MIPMAP_COMPLETE = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_GPU_MIPMAP_COMPLETE.0;
// TODO: Only available in a newer NDK
// /// Usage: The buffer is used for front-buffer rendering. When front-buffering rendering
// /// is specified, different usages may adjust their behavior as a result. For example, when
// /// used as [`HardwareBufferFormat::GPU_COLOR_OUTPUT`] the buffer will behave similar to a
// /// single-buffered window. When used with [`HardwareBufferFormat::COMPOSER_OVERLAY`], the
// /// system will try to prioritize the buffer receiving an overlay plane & avoid caching it
// /// in intermediate composition buffers.
// #[doc(alias = "AHARDWAREBUFFER_USAGE_FRONT_BUFFER")]
// const USAGE_FRONT_BUFFER = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_FRONT_BUFFER.0;
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_0")]
const VENDOR_0 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_0.0;
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_1")]
const VENDOR_1 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_1.0;
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_2")]
const VENDOR_2 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_2.0;
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_3")]
const VENDOR_3 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_3.0;
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_4")]
const VENDOR_4 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_4.0;
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_5")]
const VENDOR_5 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_5.0;
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_6")]
const VENDOR_6 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_6.0;
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_7")]
const VENDOR_7 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_7.0;
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_8")]
const VENDOR_8 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_8.0;
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_9")]
const VENDOR_9 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_9.0;
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_10")]
const VENDOR_10 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_10.0;
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_11")]
const VENDOR_11 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_11.0;
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_12")]
const VENDOR_12 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_12.0;
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_13")]
const VENDOR_13 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_13.0;
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_14")]
const VENDOR_14 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_14.0;
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_15")]
const VENDOR_15 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_15.0;
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_16")]
const VENDOR_16 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_16.0;
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_17")]
const VENDOR_17 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_17.0;
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_18")]
const VENDOR_18 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_18.0;
#[doc(alias = "AHARDWAREBUFFER_USAGE_VENDOR_19")]
const VENDOR_19 = ffi::AHardwareBuffer_UsageFlags::AHARDWAREBUFFER_USAGE_VENDOR_19.0;
}
}
impl HardwareBufferUsage {
/// Helper to read [`HardwareBufferUsage::CPU_READ_MASK`] values.
#[doc(alias = "AHARDWAREBUFFER_USAGE_CPU_READ_MASK")]
pub fn cpu_read(self) -> HardwareBufferUsage {
self.intersection(Self::CPU_READ_MASK)
}
/// Helper to read [`HardwareBufferUsage::CPU_WRITE_MASK`] values.
#[doc(alias = "AHARDWAREBUFFER_USAGE_CPU_WRITE_MASK")]
pub fn cpu_write(self) -> HardwareBufferUsage {
self.intersection(Self::CPU_WRITE_MASK)
}
}
pub type Rect = ffi::ARect;
fn construct<T>(with_ptr: impl FnOnce(*mut T) -> i32) -> Result<T> {
let mut result = MaybeUninit::uninit();
let status = with_ptr(result.as_mut_ptr());
status_to_io_result(status).map(|()| unsafe { result.assume_init() })
}
/// A native [`AHardwareBuffer *`]
///
/// [`HardwareBuffer`] objects represent chunks of memory that can be accessed by various hardware
/// components in the system.
///
/// It can be easily converted to the Java counterpart [`android.hardware.HardwareBuffer`] and
/// passed between processes using Binder. All operations involving [`HardwareBuffer`] and
/// [`android.hardware.HardwareBuffer`] are zero-copy, i.e., passing [`HardwareBuffer`] to another
/// process creates a shared view of the same region of memory.
///
/// [`HardwareBuffer`] can be bound to EGL/OpenGL and Vulkan primitives. For EGL, use the extension
/// function [`eglGetNativeClientBufferANDROID`] to obtain an `EGLClientBuffer` and pass it
/// directly to [`eglCreateImageKHR`]. Refer to the EGL extensions
/// [`EGL_ANDROID_get_native_client_buffer`] and [`EGL_ANDROID_image_native_buffer`] for more
/// information. In Vulkan, the contents of the [`HardwareBuffer`] can be accessed as [external
/// memory]. See the [`VK_ANDROID_external_memory_android_hardware_buffer`] extension for details.
///
/// [`AHardwareBuffer *`]: https://developer.android.com/ndk/reference/group/a-hardware-buffer#ahardwarebuffer
/// [`android.hardware.HardwareBuffer`]: https://developer.android.com/reference/android/hardware/HardwareBuffer
/// [`eglGetNativeClientBufferANDROID`]: https://www.khronos.org/registry/EGL/extensions/ANDROID/EGL_ANDROID_get_native_client_buffer.txt
/// [`eglCreateImageKHR`]: https://www.khronos.org/registry/EGL/extensions/KHR/EGL_KHR_image_base.txt
/// [`EGL_ANDROID_get_native_client_buffer`]: https://www.khronos.org/registry/EGL/extensions/ANDROID/EGL_ANDROID_get_native_client_buffer.txt
/// [`EGL_ANDROID_image_native_buffer`]: https://www.khronos.org/registry/EGL/extensions/ANDROID/EGL_ANDROID_image_native_buffer.txt
/// [external memory]: https://www.khronos.org/registry/vulkan/specs/1.3-extensions/man/html/VK_KHR_external_memory.html
/// [`VK_ANDROID_external_memory_android_hardware_buffer`]: https://www.khronos.org/registry/vulkan/specs/1.3-extensions/man/html/VK_ANDROID_external_memory_android_hardware_buffer.html
#[derive(Debug)]
pub struct HardwareBuffer {
inner: NonNull<ffi::AHardwareBuffer>,
}
impl HardwareBuffer {
/// Create an _unowned_ [`HardwareBuffer`] from a native pointer
///
/// To wrap a strong reference (that is `release`d on [`Drop`]), call
/// [`HardwareBufferRef::from_ptr()`] instead.
///
/// # Safety
/// By calling this function, you assert that it is a valid pointer to an NDK
/// [`ffi::AHardwareBuffer`] that is kept alive externally, or retrieve a strong reference
/// using [`HardwareBuffer::acquire()`].
pub unsafe fn from_ptr(ptr: NonNull<ffi::AHardwareBuffer>) -> Self {
Self { inner: ptr }
}
/// Returns the underlying [`ffi::AHardwareBuffer`] pointer
///
/// See the top-level [`HardwareBuffer`] struct documentation for (graphics) APIs that accept
/// this pointer.
pub fn as_ptr(&self) -> *mut ffi::AHardwareBuffer {
self.inner.as_ptr()
}
/// Allocates a buffer that matches the passed [`HardwareBufferDesc`].
///
/// If allocation succeeds, the buffer can be used according to the usage flags specified in
/// its description. If a buffer is used in ways not compatible with its usage flags, the
/// results are undefined and may include program termination.
pub fn allocate(desc: HardwareBufferDesc) -> Result<HardwareBufferRef> {
unsafe {
let ptr = construct(|res| ffi::AHardwareBuffer_allocate(&desc.into_native(), res))?;
Ok(HardwareBufferRef::from_ptr(NonNull::new_unchecked(ptr)))
}
}
/// Create a [`HardwareBuffer`] from JNI pointers
///
/// # Safety
/// By calling this function, you assert that these are valid pointers to JNI objects.
///
/// This method does not acquire any additional reference to the AHardwareBuffer that is
/// returned. To keep the [`HardwareBuffer`] alive after the [Java `HardwareBuffer`] object
/// is closed, explicitly or by the garbage collector, be sure to retrieve a strong reference
/// using [`HardwareBuffer::acquire()`].
///
/// [Java `HardwareBuffer`]: https://developer.android.com/reference/android/hardware/HardwareBuffer
pub unsafe fn from_jni(env: *mut JNIEnv, hardware_buffer: jobject) -> Self {
let ptr = ffi::AHardwareBuffer_fromHardwareBuffer(env, hardware_buffer);
Self::from_ptr(NonNull::new_unchecked(ptr))
}
/// # Safety
/// By calling this function, you assert that `env` is a valid pointer to a [`JNIEnv`].
pub unsafe fn to_jni(&self, env: *mut JNIEnv) -> jobject {
ffi::AHardwareBuffer_toHardwareBuffer(env, self.as_ptr())
}
/// Return a description of the [`HardwareBuffer`] in the passed [`HardwareBufferDesc`] struct.
pub fn describe(&self) -> HardwareBufferDesc {
let desc = unsafe {
let mut result = MaybeUninit::uninit();
ffi::AHardwareBuffer_describe(self.as_ptr(), result.as_mut_ptr());
result.assume_init()
};
HardwareBufferDesc {
width: desc.width,
height: desc.height,
layers: desc.layers,
format: i32::try_from(desc.format)
.expect("i32->u32 overflow in HardwareBuffer::describe()")
.into(),
usage: HardwareBufferUsage::from_bits_retain(desc.usage),
stride: desc.stride,
}
}
/// Test whether the given format and usage flag combination is allocatable.
///
/// If this function returns [`true`], it means that a buffer with the given description can
/// be allocated on this implementation, unless resource exhaustion occurs. If this function
/// returns [`false`], it means that the allocation of the given description will never
/// succeed.
///
/// The return value of this function may depend on all fields in the description, except
/// [`HardwareBufferDesc::stride`], which is always ignored. For example, some implementations
/// have implementation-defined limits on texture size and layer count.
#[cfg(feature = "api-level-29")]
pub fn is_supported(desc: HardwareBufferDesc) -> bool {
let res = unsafe { ffi::AHardwareBuffer_isSupported(&desc.into_native()) };
res == 1
}
/// Get the system-wide unique id for this [`HardwareBuffer`].
#[cfg(feature = "api-level-31")]
#[doc(alias = "AHardwareBuffer_getId")]
pub fn id(&self) -> Result<u64> {
construct(|res| unsafe { ffi::AHardwareBuffer_getId(self.as_ptr(), res) })
}
/// Lock the [`HardwareBuffer`] for direct CPU access.
///
/// This function can lock the buffer for either reading or writing. It may block if the
/// hardware needs to finish rendering, if CPU caches need to be synchronized, or possibly for
/// other implementation-specific reasons.
///
/// The [`HardwareBuffer`] must have one layer, otherwise the call will fail.
///
/// If `fence` is not [`None`], it specifies a fence file descriptor on which to wait before
/// locking the buffer. If it's [`None`], the caller is responsible for ensuring that writes
/// to the buffer have completed before calling this function. Using this parameter is more
/// efficient than waiting on the fence and then calling this function.
///
/// The `usage` parameter may only specify `HardwareBufferUsage::CPU_*`. If set, then the
/// address of the buffer in virtual memory is returned. The flags must also be compatible with
/// usage flags specified at buffer creation: if a read flag is passed, the buffer must have
/// been created with [`HardwareBufferUsage::CPU_READ_RARELY`] or
/// [`HardwareBufferUsage::CPU_READ_OFTEN`]. If a write flag is passed, it must have been
/// created with [`HardwareBufferUsage::CPU_WRITE_RARELY`] or
/// [`HardwareBufferUsage::CPU_WRITE_OFTEN`].
///
/// If `rect` is not [`None`], the caller promises to modify only data in the area specified by
/// `rect`. If rect is [`None`], the caller may modify the contents of the entire buffer. The
/// content of the buffer outside of the specified rect is NOT modified by this call.
///
/// It is legal for several different threads to lock a buffer for read access; none of the
/// threads are blocked.
///
/// Locking a buffer simultaneously for write or read/write is undefined, but will neither
/// terminate the process nor block the caller. This function may return an error or leave the
/// buffer's content in an indeterminate state.
///
/// If the buffer has [`HardwareBufferFormat::BLOB`], it is legal lock it for reading and
/// writing in multiple threads and/or processes simultaneously, and the contents of the buffer
/// behave like shared memory.
pub fn lock(
&self,
usage: HardwareBufferUsage,
fence: Option<OwnedFd>,
rect: Option<Rect>,
) -> Result<*mut c_void> {
let fence = fence.map_or(-1, IntoRawFd::into_raw_fd);
let rect = match rect {
Some(rect) => &rect,
None => std::ptr::null(),
};
construct(|res| unsafe {
ffi::AHardwareBuffer_lock(self.as_ptr(), usage.bits(), fence, rect, res)
})
}
/// Lock a [`HardwareBuffer`] for direct CPU access.
///
/// This function is the same as the above [`lock()`][Self::lock()] function, but passes back
/// additional information about the bytes per pixel and the bytes per stride of the locked
/// buffer. If the bytes per pixel or bytes per stride are unknown or variable, or if the
/// underlying mapper implementation does not support returning additional information, then
/// this call will fail with [`std::io::Error::kind()`] = [`std::io::ErrorKind::Unsupported`].
#[cfg(feature = "api-level-29")]
pub fn lock_and_get_info(
&self,
usage: HardwareBufferUsage,
fence: Option<OwnedFd>,
rect: Option<Rect>,
) -> Result<LockedPlaneInfo> {
let fence = fence.map_or(-1, IntoRawFd::into_raw_fd);
let rect = match rect {
Some(rect) => &rect,
None => std::ptr::null(),
};
let mut virtual_address = MaybeUninit::uninit();
let mut bytes_per_pixel = MaybeUninit::uninit();
let mut bytes_per_stride = MaybeUninit::uninit();
let status = unsafe {
ffi::AHardwareBuffer_lockAndGetInfo(
self.as_ptr(),
usage.bits(),
fence,
rect,
virtual_address.as_mut_ptr(),
bytes_per_pixel.as_mut_ptr(),
bytes_per_stride.as_mut_ptr(),
)
};
status_to_io_result(status).map(|()| unsafe {
LockedPlaneInfo {
virtual_address: virtual_address.assume_init(),
bytes_per_pixel: bytes_per_pixel.assume_init() as u32,
bytes_per_stride: bytes_per_stride.assume_init() as u32,
}
})
}
/// Lock a potentially multi-planar [`HardwareBuffer`] for direct CPU access.
///
/// This function is similar to [`lock()`][Self::lock()], but can lock multi-planar formats.
/// Note, that multi-planar should not be confused with multi-layer images, which this locking
/// function does not support.
///
/// YUV formats are always represented by three separate planes of data, one for each color
/// plane. The order of planes in the array is guaranteed such that plane #0 is always `Y`,
/// plane #1 is always `U` (`Cb`), and plane #2 is always `V` (`Cr`). All other formats are
/// represented by a single plane.
///
/// Additional information always accompanies the buffers, describing the row stride and the
/// pixel stride for each plane.
///
/// In case the buffer cannot be locked, this will return zero planes.
///
/// See the [`lock()`][Self::lock()] documentation for all other locking semantics.
#[cfg(feature = "api-level-29")]
pub fn lock_planes(
&self,
usage: HardwareBufferUsage,
fence: Option<OwnedFd>,
rect: Option<Rect>,
) -> Result<HardwareBufferPlanes> {
let fence = fence.map_or(-1, IntoRawFd::into_raw_fd);
let rect = match rect {
Some(rect) => &rect,
None => std::ptr::null(),
};
let planes = construct(|res| unsafe {
ffi::AHardwareBuffer_lockPlanes(self.as_ptr(), usage.bits(), fence, rect, res)
})?;
Ok(HardwareBufferPlanes {
inner: planes,
index: 0,
})
}
/// Unlock the [`HardwareBuffer`] from direct CPU access.
///
/// Must be called after all changes to the buffer are completed by the caller. The function
/// will block until all work is completed. See [`unlock_async()`][Self::unlock_async()] for
/// a non-blocking variant that returns a file descriptor to be signaled on unlocking instead.
pub fn unlock(&self) -> Result<()> {
let status = unsafe { ffi::AHardwareBuffer_unlock(self.as_ptr(), std::ptr::null_mut()) };
status_to_io_result(status)
}
/// Unlock the [`HardwareBuffer`] from direct CPU access.
///
/// Returns a fence file descriptor that will become signaled when unlocking is completed, or
/// [`None`] if unlocking is already finished. The caller is responsible for closing the file
/// descriptor once it's no longer needed. See [`unlock()`][Self::unlock()] for a variant that
/// blocks instead.
pub fn unlock_async(&self) -> Result<Option<OwnedFd>> {
let fence = construct(|res| unsafe { ffi::AHardwareBuffer_unlock(self.as_ptr(), res) })?;
Ok(match fence {
-1 => None,
fence => Some(unsafe { OwnedFd::from_raw_fd(fence) }),
})
}
/// Receive a [`HardwareBuffer`] from an `AF_UNIX` socket.
///
/// `AF_UNIX` sockets are wrapped by [`std::os::unix::net::UnixListener`] and
/// [`std::os::unix::net::UnixStream`] in Rust and have a corresponding
/// [`std::os::unix::io::AsFd::as_fd()`] implementation.
pub fn recv_handle_from_unix_socket(socket_fd: BorrowedFd<'_>) -> Result<Self> {
unsafe {
let ptr = construct(|res| {
ffi::AHardwareBuffer_recvHandleFromUnixSocket(socket_fd.as_raw_fd(), res)
})?;
Ok(Self::from_ptr(NonNull::new_unchecked(ptr)))
}
}
/// Send the [`HardwareBuffer`] to an `AF_UNIX` socket.
///
/// `AF_UNIX` sockets are wrapped by [`std::os::unix::net::UnixListener`] and
/// [`std::os::unix::net::UnixStream`] in Rust and have a corresponding
/// [`std::os::unix::io::AsFd::as_fd()`] implementation.
pub fn send_handle_to_unix_socket(&self, socket_fd: BorrowedFd<'_>) -> Result<()> {
let status = unsafe {
ffi::AHardwareBuffer_sendHandleToUnixSocket(self.as_ptr(), socket_fd.as_raw_fd())
};
status_to_io_result(status)
}
/// Acquire a reference on the given [`HardwareBuffer`] object.
///
/// This prevents the object from being deleted until the last strong reference, represented
/// by [`HardwareBufferRef`], is [`drop()`]ped.
pub fn acquire(&self) -> HardwareBufferRef {
unsafe {
ffi::AHardwareBuffer_acquire(self.as_ptr());
HardwareBufferRef::from_ptr(self.inner)
}
}
}
/// A [`HardwareBuffer`] with an owned reference, that is released when dropped.
/// It behaves much like a strong [`std::rc::Rc`] reference.
#[derive(Debug)]
pub struct HardwareBufferRef {
inner: HardwareBuffer,
}
impl HardwareBufferRef {
/// Create an _owned_ [`HardwareBuffer`] from a native pointer
///
/// To wrap a weak reference (that is **not** `release`d on [`Drop`]), call
/// [`HardwareBuffer::from_ptr()`] instead.
///
/// # Safety
/// By calling this function, you assert that it is a valid pointer to an NDK
/// [`ffi::AHardwareBuffer`].
pub unsafe fn from_ptr(ptr: NonNull<ffi::AHardwareBuffer>) -> Self {
Self {
inner: HardwareBuffer { inner: ptr },
}
}
}
impl Deref for HardwareBufferRef {
type Target = HardwareBuffer;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl Drop for HardwareBufferRef {
fn drop(&mut self) {
unsafe { ffi::AHardwareBuffer_release(self.inner.as_ptr()) }
}
}
impl Clone for HardwareBufferRef {
fn clone(&self) -> Self {
self.acquire()
}
}
/// Buffer description.
///
/// Used for allocating new buffers and querying parameters of existing ones.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct HardwareBufferDesc {
pub width: u32,
pub height: u32,
pub layers: u32,
pub format: HardwareBufferFormat,
pub usage: HardwareBufferUsage,
pub stride: u32,
}
impl HardwareBufferDesc {
fn into_native(self) -> ffi::AHardwareBuffer_Desc {
ffi::AHardwareBuffer_Desc {
width: self.width,
height: self.height,
layers: self.layers,
format: i32::from(self.format)
.try_into()
.expect("i32->u32 overflow in HardwareBufferDesc::into_native()"),
usage: self.usage.bits(),
stride: self.stride,
rfu0: 0,
rfu1: 0,
}
}
}
/// A native [`AHardwareBuffer_Plane`]
///
/// Contains the same fields as [`ffi::AHardwareBuffer_Plane`].
///
/// [`AHardwareBuffer_Plane`]: https://developer.android.com/ndk/reference/group/a-hardware-buffer#ahardwarebuffer_plane
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct LockedPlaneInfo {
pub virtual_address: *mut c_void,
pub bytes_per_pixel: u32,
pub bytes_per_stride: u32,
}
/// Iterator over [`ffi::AHardwareBuffer_Planes`], containing a list of [`LockedPlaneInfo`].
#[derive(Debug)]
pub struct HardwareBufferPlanes {
inner: ffi::AHardwareBuffer_Planes,
index: u32,
}
impl Iterator for HardwareBufferPlanes {
type Item = LockedPlaneInfo;
fn next(&mut self) -> Option<LockedPlaneInfo> {
if self.index == self.inner.planeCount {
None
} else {
let plane = self.inner.planes[self.index as usize];
self.index += 1;
Some(LockedPlaneInfo {
virtual_address: plane.data,
bytes_per_pixel: plane.pixelStride,
bytes_per_stride: plane.rowStride,
})
}
}
}
@@ -0,0 +1,106 @@
//! Bindings for [`AHardwareBuffer_Format`]
//!
//! [`AHardwareBuffer_Format`]: https://developer.android.com/ndk/reference/group/a-hardware-buffer#ahardwarebuffer_format
use num_enum::{FromPrimitive, IntoPrimitive};
/// Buffer pixel formats.
#[repr(i32)]
#[derive(Copy, Clone, Debug, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
#[allow(non_camel_case_types)]
#[non_exhaustive]
pub enum HardwareBufferFormat {
/// Matches deprecated [`ffi::ANativeWindow_LegacyFormat::WINDOW_FORMAT_RGBA_8888`].0.
#[doc(alias = "AHARDWAREBUFFER_FORMAT_R8G8B8A8_UNORM")]
R8G8B8A8_UNORM = ffi::AHardwareBuffer_Format::AHARDWAREBUFFER_FORMAT_R8G8B8A8_UNORM.0 as i32,
/// Matches deprecated [`ffi::ANativeWindow_LegacyFormat::WINDOW_FORMAT_RGBX_8888`].0.
#[doc(alias = "AHARDWAREBUFFER_FORMAT_R8G8B8X8_UNORM")]
R8G8B8X8_UNORM = ffi::AHardwareBuffer_Format::AHARDWAREBUFFER_FORMAT_R8G8B8X8_UNORM.0 as i32,
#[cfg(feature = "api-level-26")]
#[doc(alias = "AHARDWAREBUFFER_FORMAT_R8G8B8_UNORM")]
R8G8B8_UNORM = ffi::AHardwareBuffer_Format::AHARDWAREBUFFER_FORMAT_R8G8B8_UNORM.0 as i32,
/// Matches deprecated [`ffi::ANativeWindow_LegacyFormat::WINDOW_FORMAT_RGB_565`].0.
#[doc(alias = "AHARDWAREBUFFER_FORMAT_R5G6B5_UNORM")]
R5G6B5_UNORM = ffi::AHardwareBuffer_Format::AHARDWAREBUFFER_FORMAT_R5G6B5_UNORM.0 as i32,
#[cfg(feature = "api-level-26")]
#[doc(alias = "AHARDWAREBUFFER_FORMAT_R16G16B16A16_FLOAT")]
R16G16B16A16_FLOAT =
ffi::AHardwareBuffer_Format::AHARDWAREBUFFER_FORMAT_R16G16B16A16_FLOAT.0 as i32,
#[cfg(feature = "api-level-26")]
#[doc(alias = "AHARDWAREBUFFER_FORMAT_R10G10B10A2_UNORM")]
R10G10B10A2_UNORM =
ffi::AHardwareBuffer_Format::AHARDWAREBUFFER_FORMAT_R10G10B10A2_UNORM.0 as i32,
#[cfg(feature = "api-level-26")]
#[doc(alias = "AHARDWAREBUFFER_FORMAT_BLOB")]
BLOB = ffi::AHardwareBuffer_Format::AHARDWAREBUFFER_FORMAT_BLOB.0 as i32,
#[cfg(feature = "api-level-26")]
#[doc(alias = "AHARDWAREBUFFER_FORMAT_D16_UNORM")]
D16_UNORM = ffi::AHardwareBuffer_Format::AHARDWAREBUFFER_FORMAT_D16_UNORM.0 as i32,
#[cfg(feature = "api-level-26")]
#[doc(alias = "AHARDWAREBUFFER_FORMAT_D24_UNORM")]
D24_UNORM = ffi::AHardwareBuffer_Format::AHARDWAREBUFFER_FORMAT_D24_UNORM.0 as i32,
#[cfg(feature = "api-level-26")]
#[doc(alias = "AHARDWAREBUFFER_FORMAT_D24_UNORM_S8_UINT")]
D24_UNORM_S8_UINT =
ffi::AHardwareBuffer_Format::AHARDWAREBUFFER_FORMAT_D24_UNORM_S8_UINT.0 as i32,
#[cfg(feature = "api-level-26")]
#[doc(alias = "AHARDWAREBUFFER_FORMAT_D32_FLOAT")]
D32_FLOAT = ffi::AHardwareBuffer_Format::AHARDWAREBUFFER_FORMAT_D32_FLOAT.0 as i32,
#[cfg(feature = "api-level-26")]
#[doc(alias = "AHARDWAREBUFFER_FORMAT_D32_FLOAT_S8_UINT")]
D32_FLOAT_S8_UINT =
ffi::AHardwareBuffer_Format::AHARDWAREBUFFER_FORMAT_D32_FLOAT_S8_UINT.0 as i32,
#[cfg(feature = "api-level-26")]
#[doc(alias = "AHARDWAREBUFFER_FORMAT_S8_UINT")]
S8_UINT = ffi::AHardwareBuffer_Format::AHARDWAREBUFFER_FORMAT_S8_UINT.0 as i32,
#[cfg(feature = "api-level-26")]
#[doc(alias = "AHARDWAREBUFFER_FORMAT_Y8Cb8Cr8_420")]
Y8Cb8Cr8_420 = ffi::AHardwareBuffer_Format::AHARDWAREBUFFER_FORMAT_Y8Cb8Cr8_420.0 as i32,
#[cfg(feature = "api-level-26")]
#[doc(alias = "AHARDWAREBUFFER_FORMAT_YCbCr_P010")]
YCbCr_P010 = ffi::AHardwareBuffer_Format::AHARDWAREBUFFER_FORMAT_YCbCr_P010.0 as i32,
#[cfg(feature = "api-level-26")]
R8_UNORM = ffi::AHardwareBuffer_Format::AHARDWAREBUFFER_FORMAT_R8_UNORM.0 as i32,
#[doc(hidden)]
#[num_enum(catch_all)]
__Unknown(i32),
}
impl HardwareBufferFormat {
/// Returns [`None`] when there is no immediate byte size available for this format, for
/// example on planar buffer formats.
pub fn bytes_per_pixel(self) -> Option<usize> {
Some(match self {
Self::R8G8B8A8_UNORM | Self::R8G8B8X8_UNORM => 4,
#[cfg(feature = "api-level-26")]
Self::R8G8B8_UNORM => 3,
Self::R5G6B5_UNORM => 2,
#[cfg(feature = "api-level-26")]
Self::R16G16B16A16_FLOAT => 8,
#[cfg(feature = "api-level-26")]
Self::R10G10B10A2_UNORM => 4,
#[cfg(feature = "api-level-26")]
Self::BLOB => 1,
#[cfg(feature = "api-level-26")]
Self::D16_UNORM => 2,
#[cfg(feature = "api-level-26")]
Self::D24_UNORM => 3,
#[cfg(feature = "api-level-26")]
Self::D24_UNORM_S8_UINT => 4,
#[cfg(feature = "api-level-26")]
Self::D32_FLOAT => 4,
#[cfg(feature = "api-level-26")]
Self::D32_FLOAT_S8_UINT => 5,
#[cfg(feature = "api-level-26")]
Self::S8_UINT => 1,
#[cfg(feature = "api-level-26")]
Self::Y8Cb8Cr8_420 => 3,
#[cfg(feature = "api-level-26")]
Self::YCbCr_P010 => return None,
#[cfg(feature = "api-level-26")]
Self::R8_UNORM => 1,
Self::__Unknown(_) => return None,
})
}
}
+143
View File
@@ -0,0 +1,143 @@
//! Bindings for [`AInputQueue`]
//!
//! [`AInputQueue`]: https://developer.android.com/ndk/reference/group/input#ainputqueue
use std::io::Result;
use std::os::raw::c_int;
use std::ptr::{self, NonNull};
#[cfg(feature = "api-level-33")]
use jni_sys::{jobject, JNIEnv};
use crate::event::InputEvent;
#[cfg(doc)]
use crate::event::KeyEvent;
use crate::looper::ForeignLooper;
use crate::utils::status_to_io_result;
/// A native [`AInputQueue *`]
///
/// An input queue is the facility through which you retrieve input events.
///
/// [`AInputQueue *`]: https://developer.android.com/ndk/reference/group/input#ainputqueue
#[derive(Debug)]
pub struct InputQueue {
ptr: NonNull<ffi::AInputQueue>,
}
// It gets shared between threads in `ndk-glue`
unsafe impl Send for InputQueue {}
unsafe impl Sync for InputQueue {}
impl InputQueue {
/// Construct an [`InputQueue`] from the native pointer.
///
/// # Safety
/// By calling this function, you assert that the pointer is a valid pointer to an NDK [`ffi::AInputQueue`].
pub unsafe fn from_ptr(ptr: NonNull<ffi::AInputQueue>) -> Self {
Self { ptr }
}
/// Returns the [`InputQueue`] object associated with the supplied
/// [Java `InputQueue`][`android.view.InputQueue`] object.
///
/// # Safety
///
/// This function should be called with a healthy JVM pointer and with a non-null
/// [`android.view.InputQueue`], which must be kept alive on the Java/Kotlin side.
///
/// The returned native object holds a weak reference to the Java object, and is only valid as
/// long as the Java object has not yet been disposed. You should ensure that there is a strong
/// reference to the Java object and that it has not been disposed before using the returned
/// object.
///
/// [`android.view.InputQueue`]: https://developer.android.com/reference/android/view/InputQueue
#[cfg(feature = "api-level-33")]
#[doc(alias = "AInputQueue_fromJava")]
pub unsafe fn from_java(env: *mut JNIEnv, input_queue: jobject) -> Option<Self> {
let ptr = unsafe { ffi::AInputQueue_fromJava(env, input_queue) };
Some(Self::from_ptr(NonNull::new(ptr)?))
}
pub fn ptr(&self) -> NonNull<ffi::AInputQueue> {
self.ptr
}
/// Returns the next available [`InputEvent`] from the queue.
///
/// Returns [`None`] if no event is available.
#[doc(alias = "AInputQueue_getEvent")]
pub fn event(&self) -> Result<Option<InputEvent>> {
let mut out_event = ptr::null_mut();
let status = unsafe { ffi::AInputQueue_getEvent(self.ptr.as_ptr(), &mut out_event) };
match status_to_io_result(status) {
Ok(()) => {
debug_assert!(!out_event.is_null());
Ok(Some(unsafe {
InputEvent::from_ptr(NonNull::new_unchecked(out_event))
}))
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => Ok(None),
Err(e) => Err(e),
}
}
/// Returns [`true`] if there are one or more events available in the input queue.
#[doc(alias = "AInputQueue_hasEvents")]
pub fn has_events(&self) -> bool {
match unsafe { ffi::AInputQueue_hasEvents(self.ptr.as_ptr()) } {
0 => false,
1 => true,
r => unreachable!("AInputQueue_hasEvents returned non-boolean {}", r),
}
}
/// Sends the key for standard pre-dispatching that is, possibly deliver it to the current IME
/// to be consumed before the app.
///
/// Returns [`Some`] if it was not pre-dispatched, meaning you can process it right now. If
/// [`None`] is returned, you must abandon the current event processing and allow the event to
/// appear again in the event queue (if it does not get consumed during pre-dispatching).
///
/// Also returns [`None`] if `event` is not a [`KeyEvent`].
#[doc(alias = "AInputQueue_preDispatchEvent")]
pub fn pre_dispatch(&self, event: InputEvent) -> Option<InputEvent> {
match unsafe { ffi::AInputQueue_preDispatchEvent(self.ptr.as_ptr(), event.ptr().as_ptr()) }
{
0 => Some(event),
_ => None,
}
}
/// Report that dispatching has finished with the given [`InputEvent`].
///
/// This must be called after receiving an event with [`InputQueue::event()`].
#[doc(alias = "AInputQueue_finishEvent")]
pub fn finish_event(&self, event: InputEvent, handled: bool) {
unsafe {
ffi::AInputQueue_finishEvent(self.ptr.as_ptr(), event.ptr().as_ptr(), handled as c_int)
}
}
/// Add this input queue to a [`ForeignLooper`] for processing.
///
/// See [`ForeignLooper::add_fd()`] for information on the `ident`, `callback`, and `data` params.
#[doc(alias = "AInputQueue_attachLooper")]
pub fn attach_looper(&self, looper: &ForeignLooper, id: i32) {
unsafe {
ffi::AInputQueue_attachLooper(
self.ptr.as_ptr(),
looper.ptr().as_ptr(),
id,
None,
std::ptr::null_mut(),
)
}
}
/// Remove this input queue from the [`ForeignLooper`] it is currently attached to.
#[doc(alias = "AInputQueue_detachLooper")]
pub fn detach_looper(&self) {
unsafe { ffi::AInputQueue_detachLooper(self.ptr.as_ptr()) }
}
}
+33
View File
@@ -0,0 +1,33 @@
//! # Android NDK
//!
//! Bindings to the [Android NDK].
//!
//! [Android NDK]: https://developer.android.com/ndk/reference
#![warn(
missing_debug_implementations,
rust_2018_idioms,
trivial_casts,
unused_qualifications
)]
#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))]
pub mod asset;
pub mod audio;
pub mod bitmap;
pub mod configuration;
pub mod data_space;
pub mod event;
pub mod font;
pub mod hardware_buffer;
pub mod hardware_buffer_format;
pub mod input_queue;
pub mod looper;
pub mod media;
pub mod media_error;
pub mod native_activity;
pub mod native_window;
pub mod shared_memory;
pub mod surface_texture;
pub mod sync;
pub mod trace;
mod utils;
+459
View File
@@ -0,0 +1,459 @@
//! Bindings for [`ALooper`]
//!
//! In Android, [`ALooper`]s are inherently thread-local. Due to this, there are two different
//! [`ALooper`] interfaces exposed in this module:
//!
//! * [`ThreadLooper`], which has methods for the operations performable with a looper in one's own
//! thread; and
//! * [`ForeignLooper`], which has methods for the operations performable with any thread's looper.
//!
//! [`ALooper`]: https://developer.android.com/ndk/reference/group/looper#alooper
use std::mem::ManuallyDrop;
use std::os::{
fd::{AsRawFd, BorrowedFd, RawFd},
raw::c_void,
};
use std::ptr;
use std::time::Duration;
use thiserror::Error;
use crate::utils::abort_on_panic;
/// A thread-local native [`ALooper *`]. This promises that there is a looper associated with the
/// current thread.
///
/// [`ALooper *`]: https://developer.android.com/ndk/reference/group/looper#alooper
#[derive(Debug)]
pub struct ThreadLooper {
_marker: std::marker::PhantomData<*mut ()>, // Not send or sync
foreign: ForeignLooper,
}
bitflags::bitflags! {
/// Flags for file descriptor events that a looper can monitor.
///
/// These flag bits can be combined to monitor multiple events at once.
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub struct FdEvent : u32 {
/// The file descriptor is available for read operations.
#[doc(alias = "ALOOPER_EVENT_INPUT")]
const INPUT = ffi::ALOOPER_EVENT_INPUT;
/// The file descriptor is available for write operations.
#[doc(alias = "ALOOPER_EVENT_OUTPUT")]
const OUTPUT = ffi::ALOOPER_EVENT_OUTPUT;
/// The file descriptor has encountered an error condition.
///
/// The looper always sends notifications about errors; it is not necessary to specify this
/// event flag in the requested event set.
#[doc(alias = "ALOOPER_EVENT_ERROR")]
const ERROR = ffi::ALOOPER_EVENT_ERROR;
/// The file descriptor was hung up.
///
/// For example, indicates that the remote end of a pipe or socket was closed.
///
/// The looper always sends notifications about hangups; it is not necessary to specify this
/// event flag in the requested event set.
#[doc(alias = "ALOOPER_EVENT_HANGUP")]
const HANGUP = ffi::ALOOPER_EVENT_HANGUP;
/// The file descriptor is invalid.
///
/// For example, the file descriptor was closed prematurely.
///
/// The looper always sends notifications about invalid file descriptors; it is not
/// necessary to specify this event flag in the requested event set.
#[doc(alias = "ALOOPER_EVENT_INVALID")]
const INVALID = ffi::ALOOPER_EVENT_INVALID;
// https://docs.rs/bitflags/latest/bitflags/#externally-defined-flags
const _ = !0;
}
}
/// The poll result from a [`ThreadLooper`].
#[derive(Debug)]
pub enum Poll<'fd> {
/// This looper was woken using [`ForeignLooper::wake()`]
Wake,
/// For [`ThreadLooper::poll_once*()`][ThreadLooper::poll_once()], an event was received and processed using a callback.
Callback,
/// For [`ThreadLooper::poll_*_timeout()`][ThreadLooper::poll_once_timeout()], the requested timeout was reached before any events.
Timeout,
/// An event was received
Event {
ident: i32,
/// # Safety
/// The caller should guarantee that this file descriptor remains open after it was added
/// via [`ForeignLooper::add_fd()`] or [`ForeignLooper::add_fd_with_callback()`].
fd: BorrowedFd<'fd>,
events: FdEvent,
data: *mut c_void,
},
}
#[derive(Debug, Copy, Clone, Error)]
#[error("Android Looper error")]
pub struct LooperError;
impl ThreadLooper {
/// Prepares a looper for the current thread and returns it
pub fn prepare() -> Self {
unsafe {
let ptr = ffi::ALooper_prepare(ffi::ALOOPER_PREPARE_ALLOW_NON_CALLBACKS as _);
let foreign = ForeignLooper::from_ptr(ptr::NonNull::new(ptr).expect("looper non null"));
Self {
_marker: std::marker::PhantomData,
foreign,
}
}
}
/// Returns the looper associated with the current thread, if any.
pub fn for_thread() -> Option<Self> {
Some(Self {
_marker: std::marker::PhantomData,
foreign: ForeignLooper::for_thread()?,
})
}
/// Polls the looper, blocking on processing an event, but with a timeout in milliseconds.
/// Give a timeout of `0` to make this non-blocking.
fn poll_once_ms(&self, ms: i32) -> Result<Poll<'_>, LooperError> {
let mut fd = -1;
let mut events = -1;
let mut data: *mut c_void = ptr::null_mut();
match unsafe { ffi::ALooper_pollOnce(ms, &mut fd, &mut events, &mut data) } {
ffi::ALOOPER_POLL_WAKE => Ok(Poll::Wake),
ffi::ALOOPER_POLL_CALLBACK => Ok(Poll::Callback),
ffi::ALOOPER_POLL_TIMEOUT => Ok(Poll::Timeout),
ffi::ALOOPER_POLL_ERROR => Err(LooperError),
ident if ident >= 0 => Ok(Poll::Event {
ident,
// SAFETY: Even though this FD at least shouldn't outlive self, a user could have
// closed it after calling add_fd or add_fd_with_callback.
fd: unsafe { BorrowedFd::borrow_raw(fd) },
events: FdEvent::from_bits(events as u32)
.expect("poll event contains unknown bits"),
data,
}),
_ => unreachable!(),
}
}
/// Polls the looper, blocking on processing an event.
#[inline]
pub fn poll_once(&self) -> Result<Poll<'_>, LooperError> {
self.poll_once_ms(-1)
}
/// Polls the looper, blocking on processing an event, but with a timeout. Give a timeout of
/// [`Duration::ZERO`] to make this non-blocking.
///
/// It panics if the timeout is larger than expressible as an [`i32`] of milliseconds (roughly 25
/// days).
#[inline]
pub fn poll_once_timeout(&self, timeout: Duration) -> Result<Poll<'_>, LooperError> {
self.poll_once_ms(
timeout
.as_millis()
.try_into()
.expect("Supplied timeout is too large"),
)
}
/// Repeatedly polls the looper, blocking on processing an event, but with a timeout in
/// milliseconds. Give a timeout of `0` to make this non-blocking.
///
/// This function will never return [`Poll::Callback`].
fn poll_all_ms(&self, ms: i32) -> Result<Poll<'_>, LooperError> {
let mut fd = -1;
let mut events = -1;
let mut data: *mut c_void = ptr::null_mut();
match unsafe { ffi::ALooper_pollAll(ms, &mut fd, &mut events, &mut data) } {
ffi::ALOOPER_POLL_WAKE => Ok(Poll::Wake),
ffi::ALOOPER_POLL_TIMEOUT => Ok(Poll::Timeout),
ffi::ALOOPER_POLL_ERROR => Err(LooperError),
ident if ident >= 0 => Ok(Poll::Event {
ident,
// SAFETY: Even though this FD at least shouldn't outlive self, a user could have
// closed it after calling add_fd or add_fd_with_callback.
fd: unsafe { BorrowedFd::borrow_raw(fd) },
events: FdEvent::from_bits(events as u32)
.expect("poll event contains unknown bits"),
data,
}),
_ => unreachable!(),
}
}
/// Repeatedly polls the looper, blocking on processing an event.
///
/// This function will never return [`Poll::Callback`].
#[inline]
pub fn poll_all(&self) -> Result<Poll<'_>, LooperError> {
self.poll_all_ms(-1)
}
/// Repeatedly polls the looper, blocking on processing an event, but with a timeout. Give a
/// timeout of [`Duration::ZERO`] to make this non-blocking.
///
/// This function will never return [`Poll::Callback`].
///
/// It panics if the timeout is larger than expressible as an [`i32`] of milliseconds (roughly 25
/// days).
#[inline]
pub fn poll_all_timeout(&self, timeout: Duration) -> Result<Poll<'_>, LooperError> {
self.poll_all_ms(
timeout
.as_millis()
.try_into()
.expect("Supplied timeout is too large"),
)
}
/// Adds a file descriptor to be polled, with a callback that is invoked when any of the
/// [`FdEvent`]s described in `events` is triggered.
///
/// The callback receives the file descriptor it is associated with and a bitmask of the poll
/// events that were triggered (typically [`FdEvent::INPUT`]). It should return [`true`] to
/// continue receiving callbacks, or [`false`] to have the callback unregistered.
///
/// See also [the NDK
/// docs](https://developer.android.com/ndk/reference/group/looper.html#alooper_addfd).
///
/// Note that this will leak a [`Box`] unless the callback returns [`false`] to unregister
/// itself.
///
/// # Threading
/// This function will be called on the current thread when this [`ThreadLooper`] is
/// polled. A callback can also be registered from other threads via the equivalent
/// [`ForeignLooper::add_fd_with_callback()`] function, which requires a [`Send`] bound.
///
/// # Safety
/// The caller should guarantee that this file descriptor stays open until it is removed via
/// [`remove_fd()`][ForeignLooper::remove_fd()] or by returning [`false`] from the callback,
/// and for however long the caller wishes to use this file descriptor inside and after the
/// callback.
#[doc(alias = "ALooper_addFd")]
pub fn add_fd_with_callback<F: FnMut(BorrowedFd<'_>, FdEvent) -> bool>(
&self,
fd: BorrowedFd<'_>,
events: FdEvent,
callback: F,
) -> Result<(), LooperError> {
unsafe {
self.foreign
.add_fd_with_callback_assume_send(fd, events, callback)
}
}
/// Returns a reference to the [`ForeignLooper`] that is associated with the current thread.
pub fn as_foreign(&self) -> &ForeignLooper {
&self.foreign
}
pub fn into_foreign(self) -> ForeignLooper {
self.foreign
}
}
/// A native [`ALooper *`], not necessarily allocated with the current thread.
///
/// [`ALooper *`]: https://developer.android.com/ndk/reference/group/looper#alooper
#[derive(Debug)]
pub struct ForeignLooper {
ptr: ptr::NonNull<ffi::ALooper>,
}
unsafe impl Send for ForeignLooper {}
unsafe impl Sync for ForeignLooper {}
impl Drop for ForeignLooper {
fn drop(&mut self) {
unsafe { ffi::ALooper_release(self.ptr.as_ptr()) }
}
}
impl Clone for ForeignLooper {
fn clone(&self) -> Self {
unsafe {
ffi::ALooper_acquire(self.ptr.as_ptr());
Self { ptr: self.ptr }
}
}
}
impl ForeignLooper {
/// Returns the looper associated with the current thread, if any.
#[inline]
pub fn for_thread() -> Option<Self> {
ptr::NonNull::new(unsafe { ffi::ALooper_forThread() })
.map(|ptr| unsafe { Self::from_ptr(ptr) })
}
/// Construct a [`ForeignLooper`] object from the given pointer.
///
/// # Safety
/// By calling this function, you guarantee that the pointer is a valid, non-null pointer to an
/// NDK [`ffi::ALooper`].
#[inline]
pub unsafe fn from_ptr(ptr: ptr::NonNull<ffi::ALooper>) -> Self {
ffi::ALooper_acquire(ptr.as_ptr());
Self { ptr }
}
/// Returns a pointer to the NDK `ALooper` object.
#[inline]
pub fn ptr(&self) -> ptr::NonNull<ffi::ALooper> {
self.ptr
}
/// Wakes the looper. An event of [`Poll::Wake`] will be sent.
pub fn wake(&self) {
unsafe { ffi::ALooper_wake(self.ptr.as_ptr()) }
}
/// Adds a file descriptor to be polled, without a callback.
///
/// See also [the NDK
/// docs](https://developer.android.com/ndk/reference/group/looper.html#alooper_addfd).
///
/// # Safety
/// The caller should guarantee that this file descriptor stays open until it is removed via
/// [`remove_fd()`][Self::remove_fd()], and for however long the caller wishes to use this file
/// descriptor when it is returned in [`Poll::Event::fd`].
// `ALooper_addFd` won't dereference `data`; it will only pass it on to the event.
// Optionally dereferencing it there already enforces `unsafe` context.
#[allow(clippy::not_unsafe_ptr_arg_deref)]
pub fn add_fd(
&self,
fd: BorrowedFd<'_>,
ident: i32,
events: FdEvent,
data: *mut c_void,
) -> Result<(), LooperError> {
match unsafe {
ffi::ALooper_addFd(
self.ptr.as_ptr(),
fd.as_raw_fd(),
ident,
events.bits() as i32,
None,
data,
)
} {
1 => Ok(()),
-1 => Err(LooperError),
_ => unreachable!(),
}
}
/// Adds a file descriptor to be polled, with a callback that is invoked when any of the
/// [`FdEvent`]s described in `events` is triggered.
///
/// The callback receives the file descriptor it is associated with and a bitmask of the poll
/// events that were triggered (typically [`FdEvent::INPUT`]). It should return [`true`] to
/// continue receiving callbacks, or [`false`] to have the callback unregistered.
///
/// See also [the NDK
/// docs](https://developer.android.com/ndk/reference/group/looper.html#alooper_addfd).
///
/// Note that this will leak a [`Box`] unless the callback returns [`false`] to unregister
/// itself.
///
/// # Threading
/// This function will be called on the looper thread where and when it is polled.
/// For registering callbacks without [`Send`] requirement, call the equivalent
/// [`ThreadLooper::add_fd_with_callback()`] function on the Looper thread.
///
/// # Safety
/// The caller should guarantee that this file descriptor stays open until it is removed via
/// [`remove_fd()`][Self::remove_fd()] or by returning [`false`] from the callback, and for
/// however long the caller wishes to use this file descriptor inside and after the callback.
#[doc(alias = "ALooper_addFd")]
pub fn add_fd_with_callback<F: FnMut(BorrowedFd<'_>, FdEvent) -> bool + Send>(
&self,
fd: BorrowedFd<'_>,
events: FdEvent,
callback: F,
) -> Result<(), LooperError> {
unsafe { self.add_fd_with_callback_assume_send(fd, events, callback) }
}
/// Private helper to deduplicate/commonize the implementation behind
/// [`ForeignLooper::add_fd_with_callback()`] and [`ThreadLooper::add_fd_with_callback()`],
/// as both have their own way of guaranteeing thread-safety. The former, [`ForeignLooper`],
/// requires the closure to be [`Send`]. The latter, [`ThreadLooper`], can only exist on the
/// thread where polling happens and where the closure will end up being invoked, and does not
/// require [`Send`].
///
/// # Safety
/// The caller must guarantee that `F` is [`Send`] or that `F` will only run on the current
/// thread. See the explanation above about why this function exists.
unsafe fn add_fd_with_callback_assume_send<F: FnMut(BorrowedFd<'_>, FdEvent) -> bool>(
&self,
fd: BorrowedFd<'_>,
events: FdEvent,
callback: F,
) -> Result<(), LooperError> {
extern "C" fn cb_handler<F: FnMut(BorrowedFd<'_>, FdEvent) -> bool>(
fd: RawFd,
events: i32,
data: *mut c_void,
) -> i32 {
abort_on_panic(|| unsafe {
let mut cb = ManuallyDrop::new(Box::<F>::from_raw(data as *mut _));
let events = FdEvent::from_bits_retain(
events.try_into().expect("Unexpected sign bit in `events`"),
);
let keep_registered = cb(BorrowedFd::borrow_raw(fd), events);
if !keep_registered {
ManuallyDrop::into_inner(cb);
}
keep_registered as i32
})
}
let data = Box::into_raw(Box::new(callback)) as *mut _;
match unsafe {
ffi::ALooper_addFd(
self.ptr.as_ptr(),
fd.as_raw_fd(),
ffi::ALOOPER_POLL_CALLBACK,
events.bits() as i32,
Some(cb_handler::<F>),
data,
)
} {
1 => Ok(()),
-1 => Err(LooperError),
_ => unreachable!(),
}
}
/// Removes a previously added file descriptor from the looper.
///
/// Returns [`true`] if the file descriptor was removed, [`false`] if it was not previously
/// registered.
///
/// # Safety
/// When this method returns, it is safe to close the file descriptor since the looper will no
/// longer have a reference to it. However, it is possible for the callback to already be
/// running or for it to run one last time if the file descriptor was already signalled.
/// Calling code is responsible for ensuring that this case is safely handled. For example, if
/// the callback takes care of removing itself during its own execution either by returning `0`
/// or by calling this method, then it can be guaranteed to not be invoked again at any later
/// time unless registered anew.
///
/// Note that unregistering a file descriptor with callback will leak a [`Box`] created in
/// [`add_fd_with_callback()`][Self::add_fd_with_callback()]. Consider returning [`false`]
/// from the callback instead to drop it.
pub fn remove_fd(&self, fd: BorrowedFd<'_>) -> Result<bool, LooperError> {
match unsafe { ffi::ALooper_removeFd(self.ptr.as_ptr(), fd.as_raw_fd()) } {
1 => Ok(true),
0 => Ok(false),
-1 => Err(LooperError),
_ => unreachable!(),
}
}
}
@@ -0,0 +1,459 @@
//! Bindings for [`AImageReader`] and [`AImage`]
//!
//! [`AImageReader`]: https://developer.android.com/ndk/reference/group/media#aimagereader
//! [`AImage`]: https://developer.android.com/ndk/reference/group/media#aimage
#![cfg(feature = "api-level-24")]
use crate::media_error::{construct, construct_never_null, MediaError, Result};
use crate::native_window::NativeWindow;
use crate::utils::abort_on_panic;
use num_enum::{FromPrimitive, IntoPrimitive};
use std::{
ffi::c_void,
fmt::{self, Debug, Formatter},
mem::MaybeUninit,
ptr::NonNull,
};
#[cfg(feature = "api-level-26")]
use std::os::fd::{FromRawFd, IntoRawFd, OwnedFd};
#[cfg(feature = "api-level-26")]
use crate::hardware_buffer::{HardwareBuffer, HardwareBufferUsage};
#[repr(i32)]
#[derive(Copy, Clone, Debug, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
#[allow(non_camel_case_types)]
#[non_exhaustive]
pub enum ImageFormat {
RGBA_8888 = ffi::AIMAGE_FORMATS::AIMAGE_FORMAT_RGBA_8888.0 as i32,
RGBX_8888 = ffi::AIMAGE_FORMATS::AIMAGE_FORMAT_RGBX_8888.0 as i32,
RGB_888 = ffi::AIMAGE_FORMATS::AIMAGE_FORMAT_RGB_888.0 as i32,
RGB_565 = ffi::AIMAGE_FORMATS::AIMAGE_FORMAT_RGB_565.0 as i32,
RGBA_FP16 = ffi::AIMAGE_FORMATS::AIMAGE_FORMAT_RGBA_FP16.0 as i32,
YUV_420_888 = ffi::AIMAGE_FORMATS::AIMAGE_FORMAT_YUV_420_888.0 as i32,
JPEG = ffi::AIMAGE_FORMATS::AIMAGE_FORMAT_JPEG.0 as i32,
RAW16 = ffi::AIMAGE_FORMATS::AIMAGE_FORMAT_RAW16.0 as i32,
RAW_PRIVATE = ffi::AIMAGE_FORMATS::AIMAGE_FORMAT_RAW_PRIVATE.0 as i32,
RAW10 = ffi::AIMAGE_FORMATS::AIMAGE_FORMAT_RAW10.0 as i32,
RAW12 = ffi::AIMAGE_FORMATS::AIMAGE_FORMAT_RAW12.0 as i32,
DEPTH16 = ffi::AIMAGE_FORMATS::AIMAGE_FORMAT_DEPTH16.0 as i32,
DEPTH_POINT_CLOUD = ffi::AIMAGE_FORMATS::AIMAGE_FORMAT_DEPTH_POINT_CLOUD.0 as i32,
PRIVATE = ffi::AIMAGE_FORMATS::AIMAGE_FORMAT_PRIVATE.0 as i32,
Y8 = ffi::AIMAGE_FORMATS::AIMAGE_FORMAT_Y8.0 as i32,
HEIC = ffi::AIMAGE_FORMATS::AIMAGE_FORMAT_HEIC.0 as i32,
DEPTH_JPEG = ffi::AIMAGE_FORMATS::AIMAGE_FORMAT_DEPTH_JPEG.0 as i32,
#[doc(hidden)]
#[num_enum(catch_all)]
__Unknown(i32),
}
pub type ImageListener = Box<dyn FnMut(&ImageReader) + Send>;
#[cfg(feature = "api-level-26")]
pub type BufferRemovedListener = Box<dyn FnMut(&ImageReader, &HardwareBuffer) + Send>;
/// Result returned by:
/// - [`ImageReader::acquire_next_image()`]`
/// - [`ImageReader::acquire_next_image_async()`]`
/// - [`ImageReader::acquire_latest_image()`]`
/// - [`ImageReader::acquire_latest_image_async()`]`
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum AcquireResult<T> {
/// Returned if there is no buffers currently available in the reader queue.
#[doc(alias = "AMEDIA_IMGREADER_NO_BUFFER_AVAILABLE")]
NoBufferAvailable,
/// Returned if the number of concurrently acquired images has reached the limit.
#[doc(alias = "AMEDIA_IMGREADER_MAX_IMAGES_ACQUIRED")]
MaxImagesAcquired,
/// Returned if an [`Image`] (optionally with fence) was successfully acquired.
Image(T),
}
impl<T> AcquireResult<T> {
fn map<U>(self, f: impl FnOnce(T) -> U) -> AcquireResult<U> {
match self {
AcquireResult::Image(img) => AcquireResult::Image(f(img)),
AcquireResult::NoBufferAvailable => AcquireResult::NoBufferAvailable,
AcquireResult::MaxImagesAcquired => AcquireResult::MaxImagesAcquired,
}
}
}
impl AcquireResult<Image> {
/// Inlined version of [`construct_never_null()`] with IMGREADER-specific result mapping.
fn construct_never_null(
with_ptr: impl FnOnce(*mut *mut ffi::AImage) -> ffi::media_status_t,
) -> Result<Self> {
let mut result = MaybeUninit::uninit();
let status = with_ptr(result.as_mut_ptr());
match status {
ffi::media_status_t::AMEDIA_IMGREADER_NO_BUFFER_AVAILABLE => {
Ok(Self::NoBufferAvailable)
}
ffi::media_status_t::AMEDIA_IMGREADER_MAX_IMAGES_ACQUIRED => {
Ok(Self::MaxImagesAcquired)
}
status => MediaError::from_status(status).map(|()| {
let result = unsafe { result.assume_init() };
Self::Image(Image {
inner: if cfg!(debug_assertions) {
NonNull::new(result).expect("result should never be null")
} else {
unsafe { NonNull::new_unchecked(result) }
},
})
}),
}
}
}
/// A native [`AImageReader *`]
///
/// [`AImageReader *`]: https://developer.android.com/ndk/reference/group/media#aimagereader
pub struct ImageReader {
inner: NonNull<ffi::AImageReader>,
image_cb: Option<Box<ImageListener>>,
#[cfg(feature = "api-level-26")]
buffer_removed_cb: Option<Box<BufferRemovedListener>>,
}
impl Debug for ImageReader {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.debug_struct("ImageReader")
.field("inner", &self.inner)
.field(
"image_cb",
match &self.image_cb {
Some(_) => &"Some(_)",
None => &"None",
},
)
.finish()
}
}
impl ImageReader {
fn from_ptr(inner: NonNull<ffi::AImageReader>) -> Self {
Self {
inner,
image_cb: None,
#[cfg(feature = "api-level-26")]
buffer_removed_cb: None,
}
}
fn as_ptr(&self) -> *mut ffi::AImageReader {
self.inner.as_ptr()
}
pub fn new(width: i32, height: i32, format: ImageFormat, max_images: i32) -> Result<Self> {
let inner = construct_never_null(|res| unsafe {
ffi::AImageReader_new(width, height, format.into(), max_images, res)
})?;
Ok(Self::from_ptr(inner))
}
#[cfg(feature = "api-level-26")]
pub fn new_with_usage(
width: i32,
height: i32,
format: ImageFormat,
usage: HardwareBufferUsage,
max_images: i32,
) -> Result<Self> {
let inner = construct_never_null(|res| unsafe {
ffi::AImageReader_newWithUsage(
width,
height,
format.into(),
usage.bits(),
max_images,
res,
)
})?;
Ok(Self::from_ptr(inner))
}
#[doc(alias = "AImageReader_setImageListener")]
pub fn set_image_listener(&mut self, listener: ImageListener) -> Result<()> {
let mut boxed = Box::new(listener);
let ptr: *mut ImageListener = &mut *boxed;
unsafe extern "C" fn on_image_available(
context: *mut c_void,
reader: *mut ffi::AImageReader,
) {
abort_on_panic(|| {
let reader = ImageReader::from_ptr(NonNull::new_unchecked(reader));
let listener: *mut ImageListener = context.cast();
(*listener)(&reader);
std::mem::forget(reader);
})
}
let mut listener = ffi::AImageReader_ImageListener {
context: ptr as _,
onImageAvailable: Some(on_image_available),
};
let status = unsafe { ffi::AImageReader_setImageListener(self.as_ptr(), &mut listener) };
// keep listener alive until Drop or new listener is assigned
self.image_cb = Some(boxed);
MediaError::from_status(status)
}
#[cfg(feature = "api-level-26")]
#[doc(alias = "AImageReader_setBufferRemovedListener")]
pub fn set_buffer_removed_listener(&mut self, listener: BufferRemovedListener) -> Result<()> {
let mut boxed = Box::new(listener);
let ptr: *mut BufferRemovedListener = &mut *boxed;
unsafe extern "C" fn on_buffer_removed(
context: *mut c_void,
reader: *mut ffi::AImageReader,
buffer: *mut ffi::AHardwareBuffer,
) {
abort_on_panic(|| {
let reader = ImageReader::from_ptr(NonNull::new_unchecked(reader));
let buffer = HardwareBuffer::from_ptr(NonNull::new_unchecked(buffer));
let listener: *mut BufferRemovedListener = context.cast();
(*listener)(&reader, &buffer);
std::mem::forget(reader);
})
}
let mut listener = ffi::AImageReader_BufferRemovedListener {
context: ptr as _,
onBufferRemoved: Some(on_buffer_removed),
};
let status =
unsafe { ffi::AImageReader_setBufferRemovedListener(self.as_ptr(), &mut listener) };
// keep listener alive until Drop or new listener is assigned
self.buffer_removed_cb = Some(boxed);
MediaError::from_status(status)
}
/// Get a [`NativeWindow`] that can be used to produce [`Image`]s for this [`ImageReader`].
///
/// <https://developer.android.com/ndk/reference/group/media#aimagereader_getwindow>
#[doc(alias = "AImageReader_getWindow")]
pub fn window(&self) -> Result<NativeWindow> {
unsafe {
let ptr = construct_never_null(|res| ffi::AImageReader_getWindow(self.as_ptr(), res))?;
Ok(NativeWindow::clone_from_ptr(ptr))
}
}
#[doc(alias = "AImageReader_getWidth")]
pub fn width(&self) -> Result<i32> {
construct(|res| unsafe { ffi::AImageReader_getWidth(self.as_ptr(), res) })
}
#[doc(alias = "AImageReader_getHeight")]
pub fn height(&self) -> Result<i32> {
construct(|res| unsafe { ffi::AImageReader_getHeight(self.as_ptr(), res) })
}
#[doc(alias = "AImageReader_getFormat")]
pub fn format(&self) -> Result<ImageFormat> {
let format = construct(|res| unsafe { ffi::AImageReader_getFormat(self.as_ptr(), res) })?;
Ok(format.into())
}
#[doc(alias = "AImageReader_getMaxImages")]
pub fn max_images(&self) -> Result<i32> {
construct(|res| unsafe { ffi::AImageReader_getMaxImages(self.as_ptr(), res) })
}
#[doc(alias = "AImageReader_acquireNextImage")]
pub fn acquire_next_image(&self) -> Result<AcquireResult<Image>> {
AcquireResult::construct_never_null(|res| unsafe {
ffi::AImageReader_acquireNextImage(self.as_ptr(), res)
})
}
/// Acquire the next [`Image`] from the image reader's queue asynchronously.
///
/// # Safety
/// If the returned file descriptor is not [`None`], it must be awaited before attempting to
/// access the [`Image`] returned.
///
/// <https://developer.android.com/ndk/reference/group/media#aimagereader_acquirenextimageasync>
#[cfg(feature = "api-level-26")]
#[doc(alias = "AImageReader_acquireNextImageAsync")]
pub unsafe fn acquire_next_image_async(
&self,
) -> Result<AcquireResult<(Image, Option<OwnedFd>)>> {
let mut fence = MaybeUninit::uninit();
AcquireResult::construct_never_null(|res| {
ffi::AImageReader_acquireNextImageAsync(self.as_ptr(), res, fence.as_mut_ptr())
})
.map(|result| {
result.map(|image| match fence.assume_init() {
-1 => (image, None),
fence => (image, Some(unsafe { OwnedFd::from_raw_fd(fence) })),
})
})
}
#[doc(alias = "AImageReader_acquireLatestImage")]
pub fn acquire_latest_image(&self) -> Result<AcquireResult<Image>> {
AcquireResult::construct_never_null(|res| unsafe {
ffi::AImageReader_acquireLatestImage(self.as_ptr(), res)
})
}
/// Acquire the latest [`Image`] from the image reader's queue asynchronously, dropping older images.
///
/// # Safety
/// If the returned file descriptor is not [`None`], it must be awaited before attempting to
/// access the [`Image`] returned.
///
/// <https://developer.android.com/ndk/reference/group/media#aimagereader_acquirelatestimageasync>
#[cfg(feature = "api-level-26")]
#[doc(alias = "AImageReader_acquireLatestImageAsync")]
pub unsafe fn acquire_latest_image_async(
&self,
) -> Result<AcquireResult<(Image, Option<OwnedFd>)>> {
let mut fence = MaybeUninit::uninit();
AcquireResult::construct_never_null(|res| {
ffi::AImageReader_acquireLatestImageAsync(self.as_ptr(), res, fence.as_mut_ptr())
})
.map(|result| {
result.map(|image| match fence.assume_init() {
-1 => (image, None),
fence => (image, Some(unsafe { OwnedFd::from_raw_fd(fence) })),
})
})
}
}
impl Drop for ImageReader {
#[doc(alias = "AImageReader_delete")]
fn drop(&mut self) {
unsafe { ffi::AImageReader_delete(self.as_ptr()) };
}
}
/// A native [`AImage *`]
///
/// [`AImage *`]: https://developer.android.com/ndk/reference/group/media#aimage
#[derive(Debug)]
#[doc(alias = "AImage")]
pub struct Image {
inner: NonNull<ffi::AImage>,
}
#[doc(alias = "AImageCropRect")]
pub type CropRect = ffi::AImageCropRect;
impl Image {
fn as_ptr(&self) -> *mut ffi::AImage {
self.inner.as_ptr()
}
#[doc(alias = "AImage_getPlaneData")]
pub fn plane_data(&self, plane_idx: i32) -> Result<&[u8]> {
let mut result_ptr = MaybeUninit::uninit();
let mut result_len = MaybeUninit::uninit();
let status = unsafe {
ffi::AImage_getPlaneData(
self.as_ptr(),
plane_idx,
result_ptr.as_mut_ptr(),
result_len.as_mut_ptr(),
)
};
MediaError::from_status(status).map(|()| unsafe {
std::slice::from_raw_parts(result_ptr.assume_init(), result_len.assume_init() as _)
})
}
#[doc(alias = "AImage_getPlanePixelStride")]
pub fn plane_pixel_stride(&self, plane_idx: i32) -> Result<i32> {
construct(|res| unsafe { ffi::AImage_getPlanePixelStride(self.as_ptr(), plane_idx, res) })
}
#[doc(alias = "AImage_getPlaneRowStride")]
pub fn plane_row_stride(&self, plane_idx: i32) -> Result<i32> {
construct(|res| unsafe { ffi::AImage_getPlaneRowStride(self.as_ptr(), plane_idx, res) })
}
#[doc(alias = "AImage_getCropRect")]
pub fn crop_rect(&self) -> Result<CropRect> {
construct(|res| unsafe { ffi::AImage_getCropRect(self.as_ptr(), res) })
}
#[doc(alias = "AImage_getWidth")]
pub fn width(&self) -> Result<i32> {
construct(|res| unsafe { ffi::AImage_getWidth(self.as_ptr(), res) })
}
#[doc(alias = "AImage_getHeight")]
pub fn height(&self) -> Result<i32> {
construct(|res| unsafe { ffi::AImage_getHeight(self.as_ptr(), res) })
}
#[doc(alias = "AImage_getFormat")]
pub fn format(&self) -> Result<ImageFormat> {
let format = construct(|res| unsafe { ffi::AImage_getFormat(self.as_ptr(), res) })?;
Ok(format.into())
}
#[doc(alias = "AImage_getTimestamp")]
pub fn timestamp(&self) -> Result<i64> {
construct(|res| unsafe { ffi::AImage_getTimestamp(self.as_ptr(), res) })
}
#[doc(alias = "AImage_getNumberOfPlanes")]
pub fn number_of_planes(&self) -> Result<i32> {
construct(|res| unsafe { ffi::AImage_getNumberOfPlanes(self.as_ptr(), res) })
}
/// Get the hardware buffer handle of the input image intended for GPU and/or hardware access.
///
/// Note that no reference on the returned [`HardwareBuffer`] handle is acquired automatically.
/// Once the [`Image`] or the parent [`ImageReader`] is deleted, the [`HardwareBuffer`] handle
/// from previous [`Image::hardware_buffer()`] becomes invalid.
///
/// If the caller ever needs to hold on a reference to the [`HardwareBuffer`] handle after the
/// [`Image`] or the parent [`ImageReader`] is deleted, it must call
/// [`HardwareBuffer::acquire()`] to acquire an extra reference, and [`drop()`] it when
/// finished using it in order to properly deallocate the underlying memory managed by
/// [`HardwareBuffer`]. If the caller has acquired an extra reference on a [`HardwareBuffer`]
/// returned from this function, it must also register a listener using
/// [`ImageReader::set_buffer_removed_listener()`] to be notified when the buffer is no longer
/// used by [`ImageReader`].
#[cfg(feature = "api-level-26")]
#[doc(alias = "AImage_getHardwareBuffer")]
pub fn hardware_buffer(&self) -> Result<HardwareBuffer> {
unsafe {
let ptr =
construct_never_null(|res| ffi::AImage_getHardwareBuffer(self.as_ptr(), res))?;
Ok(HardwareBuffer::from_ptr(ptr))
}
}
#[cfg(feature = "api-level-26")]
#[doc(alias = "AImage_deleteAsync")]
pub fn delete_async(self, release_fence_fd: OwnedFd) {
unsafe { ffi::AImage_deleteAsync(self.as_ptr(), release_fence_fd.into_raw_fd()) };
std::mem::forget(self);
}
}
impl Drop for Image {
#[doc(alias = "AImage_delete")]
fn drop(&mut self) {
unsafe { ffi::AImage_delete(self.as_ptr()) };
}
}
@@ -0,0 +1,634 @@
//! Bindings for [`AMediaCodec`]
//!
//! [`AMediaCodec`]: https://developer.android.com/ndk/reference/group/media#amediacodec
#[deprecated = "MediaFormat should be referenced directly from the media_format module"]
pub use super::media_format::MediaFormat;
use crate::media_error::{MediaError, Result};
use crate::native_window::NativeWindow;
use crate::utils::abort_on_panic;
use std::{
ffi::{c_char, c_void, CStr, CString},
fmt,
mem::MaybeUninit,
pin::Pin,
ptr::{self, NonNull},
slice,
time::Duration,
};
#[derive(Debug, PartialEq, Eq)]
pub enum MediaCodecDirection {
Decoder,
Encoder,
}
/// A native [`AMediaCodec *`]
///
/// [`AMediaCodec *`]: https://developer.android.com/ndk/reference/group/media#amediacodec
#[derive(Debug)]
pub struct MediaCodec {
inner: NonNull<ffi::AMediaCodec>,
async_notify_callback: Option<Pin<Box<AsyncNotifyCallback>>>,
}
pub struct AsyncNotifyCallback {
/// Called when an input buffer becomes available.
///
/// The specified index is the index of the available input buffer.
pub on_input_available: Option<InputAvailableCallback>,
/// Called when an output buffer becomes available.
///
/// The specified index is the index of the available output buffer. The specified
/// [`BufferInfo`] contains information regarding the available output buffer.
pub on_output_available: Option<OutputAvailableCallback>,
/// Called when the output format has changed.
///
/// The specified format contains the new output format.
pub on_format_changed: Option<FormatChangedCallback>,
/// Called when the [`MediaCodec`] encountered an error.
///
/// The specified [`ActionCode`] indicates the possible actions that client can take, and it can
/// be checked by calling [`ActionCode::is_recoverable`] or [`ActionCode::is_transient`]. If
/// both [`ActionCode::is_recoverable`] and [`ActionCode::is_transient`] return [`false`], then
/// the codec error is fatal and the codec must be deleted. The specified detail string may
/// contain more detailed messages about this error.
pub on_error: Option<ErrorCallback>,
}
impl fmt::Debug for AsyncNotifyCallback {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AsyncNotifyCallback")
.field(
"on_input_available",
match &self.on_input_available {
Some(_) => &"Some(_)",
None => &"None",
},
)
.field(
"on_output_available",
match &self.on_output_available {
Some(_) => &"Some(_)",
None => &"None",
},
)
.field(
"on_format_changed",
match &self.on_format_changed {
Some(_) => &"Some(_)",
None => &"None",
},
)
.field(
"on_error",
match &self.on_error {
Some(_) => &"Some(_)",
None => &"None",
},
)
.finish()
}
}
pub type InputAvailableCallback = Box<dyn FnMut(usize) + Send>;
pub type OutputAvailableCallback = Box<dyn FnMut(usize, &BufferInfo) + Send>;
pub type FormatChangedCallback = Box<dyn FnMut(&MediaFormat) + Send>;
pub type ErrorCallback = Box<dyn FnMut(MediaError, ActionCode, &CStr) + Send>;
impl MediaCodec {
/// [punktfunk vendored patch — the ONLY change to this crate] Public so callers can bind
/// `AMediaCodec_*` entry points the wrapper doesn't expose yet (here:
/// `AMediaCodec_setOnFrameRenderedCallback` for the HUD's display stage). The pointer is valid
/// for `&self`'s lifetime; callers must not delete or re-configure the codec through it.
pub fn as_ptr(&self) -> *mut ffi::AMediaCodec {
self.inner.as_ptr()
}
pub fn from_codec_name(name: &str) -> Option<Self> {
let c_string = CString::new(name).unwrap();
Some(Self {
inner: NonNull::new(unsafe { ffi::AMediaCodec_createCodecByName(c_string.as_ptr()) })?,
async_notify_callback: None,
})
}
pub fn from_decoder_type(mime_type: &str) -> Option<Self> {
let c_string = CString::new(mime_type).unwrap();
Some(Self {
inner: NonNull::new(unsafe {
ffi::AMediaCodec_createDecoderByType(c_string.as_ptr())
})?,
async_notify_callback: None,
})
}
pub fn from_encoder_type(mime_type: &str) -> Option<Self> {
let c_string = CString::new(mime_type).unwrap();
Some(Self {
inner: NonNull::new(unsafe {
ffi::AMediaCodec_createEncoderByType(c_string.as_ptr())
})?,
async_notify_callback: None,
})
}
/// Set an asynchronous callback for actionable [`MediaCodec`] events.
///
/// When asynchronous callback is enabled, it is an error for the client to call
/// [`MediaCodec::dequeue_input_buffer()`] or [`MediaCodec::dequeue_output_buffer()`].
///
/// [`MediaCodec::flush()`] behaves differently in asynchronous mode. After calling
/// [`MediaCodec::flush()`], the client must call [`MediaCodec::start()`] to "resume" receiving
/// input buffers. Even if the client does not receive
/// [`AsyncNotifyCallback::on_input_available`] callbacks from video encoders configured with an
/// input surface, the client still needs to call [`MediaCodec::start()`] to resume the input
/// surface to send buffers to the encoders.
///
/// When called with [`None`] callback, this method unregisters any previously set callback.
///
/// Refer to the definition of [`AsyncNotifyCallback`] on how each callback function is called
/// and what are specified.
///
/// Once the callback is unregistered or the codec is reset / released, the previously
/// registered callback will not be called.
///
/// All callbacks are fired on one NDK internal thread.
/// [`MediaCodec::set_async_notify_callback()`] should not be called on the callback thread. No
/// heavy duty task should be performed on callback thread.
#[cfg(feature = "api-level-28")]
pub fn set_async_notify_callback(
&mut self,
callback: Option<AsyncNotifyCallback>,
) -> Result<()> {
unsafe extern "C" fn ffi_on_input_available(
_codec: *mut ffi::AMediaCodec,
user_data: *mut c_void,
index: i32,
) {
abort_on_panic(|| {
let callback = &mut *(user_data as *mut AsyncNotifyCallback);
if let Some(f) = callback.on_input_available.as_mut() {
f(index as usize);
}
})
}
unsafe extern "C" fn ffi_on_output_available(
_codec: *mut ffi::AMediaCodec,
user_data: *mut c_void,
index: i32,
buffer_info: *mut ffi::AMediaCodecBufferInfo,
) {
abort_on_panic(|| {
let callback = &mut *(user_data as *mut AsyncNotifyCallback);
if let Some(f) = callback.on_output_available.as_mut() {
let buffer_info = BufferInfo {
inner: *buffer_info,
};
f(index as usize, &buffer_info);
}
})
}
unsafe extern "C" fn ffi_on_format_changed(
_codec: *mut ffi::AMediaCodec,
user_data: *mut c_void,
format: *mut ffi::AMediaFormat,
) {
abort_on_panic(|| {
// Ownership of the format is not documented, but the implementation allocates a new instance and does
// not free it, so assume it is ok for us to do so
// https://cs.android.com/android/platform/superproject/main/+/refs/heads/main:frameworks/av/media/ndk/NdkMediaCodec.cpp;l=248-254;drc=5e15c3e22f3fa32d64e57302201123ce41589adf
let format = MediaFormat::from_ptr(NonNull::new_unchecked(format));
let callback = &mut *(user_data as *mut AsyncNotifyCallback);
if let Some(f) = callback.on_format_changed.as_mut() {
f(&format);
}
})
}
unsafe extern "C" fn ffi_on_error(
_codec: *mut ffi::AMediaCodec,
user_data: *mut c_void,
error: ffi::media_status_t,
action_code: i32,
detail: *const c_char,
) {
abort_on_panic(|| {
let callback = &mut *(user_data as *mut AsyncNotifyCallback);
if let Some(f) = callback.on_error.as_mut() {
f(
MediaError::from_status(error).unwrap_err(),
ActionCode(action_code),
CStr::from_ptr(detail),
);
}
})
}
let (callback, ffi_callback, user_data) = if let Some(callback) = callback {
// On Android 12 and earlier, due to faulty null checks, if a callback is not set, but at least one other
// callback *is* set, then it will segfault in when trying to invoke the unset callback. See for example:
// https://cs.android.com/android/platform/superproject/+/android-12.0.0_r34:frameworks/av/media/ndk/NdkMediaCodec.cpp;l=161-162;drc=ef058464777739e2d9ffad5f00d0e57b186d9a13
// To work around this we just enable all callbacks and do nothing if the corresponding callback is not set
// in AsyncNotifyCallback
let ffi_callback = ffi::AMediaCodecOnAsyncNotifyCallback {
onAsyncInputAvailable: Some(ffi_on_input_available),
onAsyncOutputAvailable: Some(ffi_on_output_available),
onAsyncFormatChanged: Some(ffi_on_format_changed),
onAsyncError: Some(ffi_on_error),
};
let mut boxed = Box::pin(callback);
let ptr: *mut AsyncNotifyCallback = &mut *boxed;
(Some(boxed), ffi_callback, ptr as *mut c_void)
} else {
let ffi_callback = ffi::AMediaCodecOnAsyncNotifyCallback {
onAsyncInputAvailable: None,
onAsyncOutputAvailable: None,
onAsyncFormatChanged: None,
onAsyncError: None,
};
(None, ffi_callback, ptr::null_mut())
};
let status = unsafe {
ffi::AMediaCodec_setAsyncNotifyCallback(self.as_ptr(), ffi_callback, user_data)
};
let result = MediaError::from_status(status);
// This behavior is not documented, but the implementation always clears the callback on failure, so we must
// clear any callback that may have been previously registered
// https://cs.android.com/android/platform/superproject/main/+/main:frameworks/av/media/ndk/NdkMediaCodec.cpp;l=581-584;drc=8c4e619c7461ac1a8c20c55364643662e9185e4d
if result.is_ok() {
self.async_notify_callback = callback;
} else {
self.async_notify_callback = None;
}
result
}
pub fn configure(
&self,
format: &MediaFormat,
surface: Option<&NativeWindow>,
direction: MediaCodecDirection,
) -> Result<()> {
let status = unsafe {
ffi::AMediaCodec_configure(
self.as_ptr(),
format.as_ptr(),
surface.map_or(ptr::null_mut(), |s| s.ptr().as_ptr()),
ptr::null_mut(),
if direction == MediaCodecDirection::Encoder {
ffi::AMEDIACODEC_CONFIGURE_FLAG_ENCODE as u32
} else {
0
},
)
};
MediaError::from_status(status)
}
#[cfg(feature = "api-level-26")]
pub fn create_input_surface(&self) -> Result<NativeWindow> {
use crate::media_error::construct_never_null;
unsafe {
let ptr = construct_never_null(|res| {
ffi::AMediaCodec_createInputSurface(self.as_ptr(), res)
})?;
Ok(NativeWindow::from_ptr(ptr))
}
}
#[cfg(feature = "api-level-26")]
pub fn create_persistent_input_surface() -> Result<NativeWindow> {
use crate::media_error::construct_never_null;
unsafe {
let ptr =
construct_never_null(|res| ffi::AMediaCodec_createPersistentInputSurface(res))?;
Ok(NativeWindow::from_ptr(ptr))
}
}
pub fn dequeue_input_buffer(&self, timeout: Duration) -> Result<DequeuedInputBufferResult<'_>> {
let result = unsafe {
ffi::AMediaCodec_dequeueInputBuffer(
self.as_ptr(),
timeout
.as_micros()
.try_into()
.expect("Supplied timeout is too large"),
)
};
if result == ffi::AMEDIACODEC_INFO_TRY_AGAIN_LATER as isize {
Ok(DequeuedInputBufferResult::TryAgainLater)
} else {
let index = MediaError::from_status_if_negative(result)? as usize;
Ok(DequeuedInputBufferResult::Buffer(InputBuffer {
codec: self,
index,
}))
}
}
pub fn dequeue_output_buffer(
&self,
timeout: Duration,
) -> Result<DequeuedOutputBufferInfoResult<'_>> {
let mut info = MaybeUninit::uninit();
let result = unsafe {
ffi::AMediaCodec_dequeueOutputBuffer(
self.as_ptr(),
info.as_mut_ptr(),
timeout
.as_micros()
.try_into()
.expect("Supplied timeout is too large"),
)
};
if result == ffi::AMEDIACODEC_INFO_TRY_AGAIN_LATER as isize {
Ok(DequeuedOutputBufferInfoResult::TryAgainLater)
} else if result == ffi::AMEDIACODEC_INFO_OUTPUT_FORMAT_CHANGED as isize {
Ok(DequeuedOutputBufferInfoResult::OutputFormatChanged)
} else if result == ffi::AMEDIACODEC_INFO_OUTPUT_BUFFERS_CHANGED as isize {
Ok(DequeuedOutputBufferInfoResult::OutputBuffersChanged)
} else {
let index = MediaError::from_status_if_negative(result)? as usize;
Ok(DequeuedOutputBufferInfoResult::Buffer(OutputBuffer {
codec: self,
index,
info: BufferInfo {
inner: unsafe { info.assume_init() },
},
}))
}
}
pub fn flush(&self) -> Result<()> {
let status = unsafe { ffi::AMediaCodec_flush(self.as_ptr()) };
MediaError::from_status(status)
}
pub fn input_buffer(&self, index: usize) -> Option<&mut [MaybeUninit<u8>]> {
unsafe {
let mut out_size = 0;
let buffer_ptr = ffi::AMediaCodec_getInputBuffer(self.as_ptr(), index, &mut out_size);
if buffer_ptr.is_null() {
return None;
}
Some(slice::from_raw_parts_mut(buffer_ptr.cast(), out_size))
}
}
pub fn output_buffer(&self, index: usize) -> Option<&[u8]> {
unsafe {
let mut out_size = 0;
let buffer_ptr = ffi::AMediaCodec_getOutputBuffer(self.as_ptr(), index, &mut out_size);
if buffer_ptr.is_null() {
return None;
}
Some(slice::from_raw_parts(buffer_ptr, out_size))
}
}
#[cfg(feature = "api-level-28")]
pub fn input_format(&self) -> MediaFormat {
let inner = NonNull::new(unsafe { ffi::AMediaCodec_getInputFormat(self.as_ptr()) })
.expect("AMediaCodec_getInputFormat returned NULL");
unsafe { MediaFormat::from_ptr(inner) }
}
pub fn output_format(&self) -> MediaFormat {
let inner = NonNull::new(unsafe { ffi::AMediaCodec_getOutputFormat(self.as_ptr()) })
.expect("AMediaCodec_getOutputFormat returned NULL");
unsafe { MediaFormat::from_ptr(inner) }
}
#[cfg(feature = "api-level-28")]
pub fn name(&self) -> Result<String> {
use crate::media_error::construct;
unsafe {
let name_ptr = construct(|name| ffi::AMediaCodec_getName(self.as_ptr(), name))?;
let name = CStr::from_ptr(name_ptr).to_str().unwrap().to_owned();
ffi::AMediaCodec_releaseName(self.as_ptr(), name_ptr);
Ok(name)
}
}
pub fn queue_input_buffer(
&self,
buffer: InputBuffer<'_>,
offset: usize,
size: usize,
time: u64,
flags: u32,
) -> Result<()> {
debug_assert!(ptr::eq(self, buffer.codec));
self.queue_input_buffer_by_index(buffer.index, offset, size, time, flags)
}
pub fn queue_input_buffer_by_index(
&self,
buffer_index: usize,
offset: usize,
size: usize,
time: u64,
flags: u32,
) -> Result<()> {
let status = unsafe {
ffi::AMediaCodec_queueInputBuffer(
self.as_ptr(),
buffer_index,
offset as ffi::off_t,
size,
time,
flags,
)
};
MediaError::from_status(status)
}
pub fn release_output_buffer(&self, buffer: OutputBuffer<'_>, render: bool) -> Result<()> {
debug_assert!(ptr::eq(self, buffer.codec));
self.release_output_buffer_by_index(buffer.index, render)
}
pub fn release_output_buffer_by_index(&self, buffer_index: usize, render: bool) -> Result<()> {
let status =
unsafe { ffi::AMediaCodec_releaseOutputBuffer(self.as_ptr(), buffer_index, render) };
MediaError::from_status(status)
}
pub fn release_output_buffer_at_time(
&self,
buffer: OutputBuffer<'_>,
timestamp_ns: i64,
) -> Result<()> {
debug_assert!(ptr::eq(self, buffer.codec));
self.release_output_buffer_at_time_by_index(buffer.index, timestamp_ns)
}
pub fn release_output_buffer_at_time_by_index(
&self,
buffer_index: usize,
timestamp_ns: i64,
) -> Result<()> {
let status = unsafe {
ffi::AMediaCodec_releaseOutputBufferAtTime(self.as_ptr(), buffer_index, timestamp_ns)
};
MediaError::from_status(status)
}
#[cfg(feature = "api-level-26")]
pub fn set_input_surface(&self, surface: &NativeWindow) -> Result<()> {
let status =
unsafe { ffi::AMediaCodec_setInputSurface(self.as_ptr(), surface.ptr().as_ptr()) };
MediaError::from_status(status)
}
pub fn set_output_surface(&self, surface: &NativeWindow) -> Result<()> {
let status =
unsafe { ffi::AMediaCodec_setOutputSurface(self.as_ptr(), surface.ptr().as_ptr()) };
MediaError::from_status(status)
}
#[cfg(feature = "api-level-26")]
pub fn set_parameters(&self, params: MediaFormat) -> Result<()> {
let status = unsafe { ffi::AMediaCodec_setParameters(self.as_ptr(), params.as_ptr()) };
MediaError::from_status(status)
}
#[cfg(feature = "api-level-26")]
pub fn set_signal_end_of_input_stream(&self) -> Result<()> {
let status = unsafe { ffi::AMediaCodec_signalEndOfInputStream(self.as_ptr()) };
MediaError::from_status(status)
}
pub fn start(&self) -> Result<()> {
let status = unsafe { ffi::AMediaCodec_start(self.as_ptr()) };
MediaError::from_status(status)
}
pub fn stop(&self) -> Result<()> {
let status = unsafe { ffi::AMediaCodec_stop(self.as_ptr()) };
MediaError::from_status(status)
}
}
impl Drop for MediaCodec {
fn drop(&mut self) {
let status = unsafe { ffi::AMediaCodec_delete(self.as_ptr()) };
MediaError::from_status(status).unwrap();
}
}
#[derive(Debug)]
pub struct InputBuffer<'a> {
codec: &'a MediaCodec,
index: usize,
}
impl InputBuffer<'_> {
pub fn buffer_mut(&mut self) -> &mut [MaybeUninit<u8>] {
self.codec.input_buffer(self.index).unwrap_or_else(|| {
panic!(
"AMediaCodec_getInputBuffer returned NULL for index {}",
self.index
)
})
}
}
#[derive(Debug)]
pub enum DequeuedInputBufferResult<'a> {
Buffer(InputBuffer<'a>),
TryAgainLater,
}
#[derive(Debug)]
pub struct OutputBuffer<'a> {
codec: &'a MediaCodec,
index: usize,
info: BufferInfo,
}
impl OutputBuffer<'_> {
pub fn buffer(&self) -> &[u8] {
self.codec.output_buffer(self.index).unwrap_or_else(|| {
panic!(
"AMediaCodec_getOutputBuffer returned NULL for index {}",
self.index
)
})
}
#[cfg(feature = "api-level-28")]
pub fn format(&self) -> MediaFormat {
let inner = NonNull::new(unsafe {
ffi::AMediaCodec_getBufferFormat(self.codec.as_ptr(), self.index)
})
.expect("AMediaCodec_getBufferFormat returned NULL");
unsafe { MediaFormat::from_ptr(inner) }
}
pub fn info(&self) -> &BufferInfo {
&self.info
}
}
#[derive(Debug)]
pub enum DequeuedOutputBufferInfoResult<'a> {
Buffer(OutputBuffer<'a>),
TryAgainLater,
OutputFormatChanged,
OutputBuffersChanged,
}
#[derive(Copy, Clone, Debug)]
pub struct BufferInfo {
inner: ffi::AMediaCodecBufferInfo,
}
impl BufferInfo {
pub fn offset(&self) -> i32 {
self.inner.offset
}
pub fn size(&self) -> i32 {
self.inner.size
}
pub fn presentation_time_us(&self) -> i64 {
self.inner.presentationTimeUs
}
pub fn flags(&self) -> u32 {
self.inner.flags
}
}
#[derive(Copy, Clone, Debug)]
pub struct ActionCode(pub i32);
impl ActionCode {
pub fn is_recoverable(self) -> bool {
unsafe { ffi::AMediaCodecActionCode_isRecoverable(self.0) }
}
pub fn is_transient(self) -> bool {
unsafe { ffi::AMediaCodecActionCode_isTransient(self.0) }
}
}
@@ -0,0 +1,264 @@
//! Bindings for [`AMediaFormat`]
//!
//! [`AMediaFormat`]: https://developer.android.com/ndk/reference/group/media#amediaformat
use std::{
ffi::{CStr, CString},
fmt,
ptr::{self, NonNull},
slice,
};
use crate::media_error::{MediaError, Result};
/// A native [`AMediaFormat *`]
///
/// [`AMediaFormat *`]: https://developer.android.com/ndk/reference/group/media#amediaformat
#[doc(alias = "AMediaFormat")]
pub struct MediaFormat {
inner: NonNull<ffi::AMediaFormat>,
}
impl fmt::Display for MediaFormat {
/// Human readable representation of the format.
#[doc(alias = "AMediaFormat_toString")]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let c_str = unsafe { CStr::from_ptr(ffi::AMediaFormat_toString(self.as_ptr())) };
f.write_str(c_str.to_str().unwrap())
}
}
impl fmt::Debug for MediaFormat {
#[doc(alias = "AMediaFormat_toString")]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "MediaFormat({:?}: {})", self.inner, self)
}
}
impl Default for MediaFormat {
#[doc(alias = "AMediaFormat_new")]
fn default() -> Self {
Self::new()
}
}
impl MediaFormat {
/// Assumes ownership of `ptr`
///
/// # Safety
/// `ptr` must be a valid pointer to an Android [`ffi::AMediaFormat`].
pub unsafe fn from_ptr(ptr: NonNull<ffi::AMediaFormat>) -> Self {
Self { inner: ptr }
}
pub fn as_ptr(&self) -> *mut ffi::AMediaFormat {
self.inner.as_ptr()
}
#[doc(alias = "AMediaFormat_new")]
pub fn new() -> Self {
Self {
inner: NonNull::new(unsafe { ffi::AMediaFormat_new() }).unwrap(),
}
}
#[doc(alias = "AMediaFormat_getInt32")]
pub fn i32(&self, key: &str) -> Option<i32> {
let name = CString::new(key).unwrap();
let mut out = 0;
if unsafe { ffi::AMediaFormat_getInt32(self.as_ptr(), name.as_ptr(), &mut out) } {
Some(out)
} else {
None
}
}
#[doc(alias = "AMediaFormat_getInt64")]
pub fn i64(&self, key: &str) -> Option<i64> {
let name = CString::new(key).unwrap();
let mut out = 0;
if unsafe { ffi::AMediaFormat_getInt64(self.as_ptr(), name.as_ptr(), &mut out) } {
Some(out)
} else {
None
}
}
#[doc(alias = "AMediaFormat_getFloat")]
pub fn f32(&self, key: &str) -> Option<f32> {
let name = CString::new(key).unwrap();
let mut out = 0.0;
if unsafe { ffi::AMediaFormat_getFloat(self.as_ptr(), name.as_ptr(), &mut out) } {
Some(out)
} else {
None
}
}
#[doc(alias = "AMediaFormat_getSize")]
pub fn usize(&self, key: &str) -> Option<usize> {
let name = CString::new(key).unwrap();
let mut out = 0;
if unsafe { ffi::AMediaFormat_getSize(self.as_ptr(), name.as_ptr(), &mut out) } {
Some(out)
} else {
None
}
}
#[doc(alias = "AMediaFormat_getBuffer")]
pub fn buffer(&self, key: &str) -> Option<&[u8]> {
let name = CString::new(key).unwrap();
let mut out_buffer = ptr::null_mut();
let mut out_size = 0;
unsafe {
ffi::AMediaFormat_getBuffer(
self.as_ptr(),
name.as_ptr(),
&mut out_buffer,
&mut out_size,
)
}
.then(|| unsafe { slice::from_raw_parts(out_buffer.cast(), out_size) })
}
/// The returned `&str` borrow is only valid until the next call to [`MediaFormat::str()`] for
/// the same key.
#[doc(alias = "AMediaFormat_getString")]
pub fn str(&mut self, key: &str) -> Option<&str> {
let name = CString::new(key).unwrap();
let mut out = ptr::null();
unsafe { ffi::AMediaFormat_getString(self.as_ptr(), name.as_ptr(), &mut out) }
.then(|| unsafe { CStr::from_ptr(out) }.to_str().unwrap())
}
#[doc(alias = "AMediaFormat_setInt32")]
pub fn set_i32(&mut self, key: &str, value: i32) {
let name = CString::new(key).unwrap();
unsafe { ffi::AMediaFormat_setInt32(self.as_ptr(), name.as_ptr(), value) }
}
#[doc(alias = "AMediaFormat_setInt64")]
pub fn set_i64(&mut self, key: &str, value: i64) {
let name = CString::new(key).unwrap();
unsafe { ffi::AMediaFormat_setInt64(self.as_ptr(), name.as_ptr(), value) }
}
#[doc(alias = "AMediaFormat_setFloat")]
pub fn set_f32(&mut self, key: &str, value: f32) {
let name = CString::new(key).unwrap();
unsafe { ffi::AMediaFormat_setFloat(self.as_ptr(), name.as_ptr(), value) }
}
#[doc(alias = "AMediaFormat_setString")]
pub fn set_str(&mut self, key: &str, value: &str) {
let name = CString::new(key).unwrap();
let c_string = CString::new(value).unwrap();
unsafe { ffi::AMediaFormat_setString(self.as_ptr(), name.as_ptr(), c_string.as_ptr()) }
}
#[doc(alias = "AMediaFormat_setBuffer")]
pub fn set_buffer(&mut self, key: &str, value: &[u8]) {
let name = CString::new(key).unwrap();
unsafe {
ffi::AMediaFormat_setBuffer(
self.as_ptr(),
name.as_ptr(),
value.as_ptr().cast(),
value.len(),
)
}
}
#[cfg(feature = "api-level-28")]
#[doc(alias = "AMediaFormat_getDouble")]
pub fn f64(&self, key: &str) -> Option<f64> {
let name = CString::new(key).unwrap();
let mut out = 0.0;
if unsafe { ffi::AMediaFormat_getDouble(self.as_ptr(), name.as_ptr(), &mut out) } {
Some(out)
} else {
None
}
}
/// Returns (left, top, right, bottom)
#[cfg(feature = "api-level-28")]
#[doc(alias = "AMediaFormat_getRect")]
pub fn rect(&self, key: &str) -> Option<(i32, i32, i32, i32)> {
let name = CString::new(key).unwrap();
let mut left = 0;
let mut top = 0;
let mut right = 0;
let mut bottom = 0;
if unsafe {
ffi::AMediaFormat_getRect(
self.as_ptr(),
name.as_ptr(),
&mut left,
&mut top,
&mut right,
&mut bottom,
)
} {
Some((left, top, right, bottom))
} else {
None
}
}
#[cfg(feature = "api-level-28")]
#[doc(alias = "AMediaFormat_setDouble")]
pub fn set_f64(&mut self, key: &str, value: f64) {
let name = CString::new(key).unwrap();
unsafe { ffi::AMediaFormat_setDouble(self.as_ptr(), name.as_ptr(), value) }
}
#[cfg(feature = "api-level-28")]
#[doc(alias = "AMediaFormat_setRect")]
pub fn set_rect(&mut self, key: &str, left: i32, top: i32, right: i32, bottom: i32) {
let name = CString::new(key).unwrap();
unsafe { ffi::AMediaFormat_setRect(self.as_ptr(), name.as_ptr(), left, top, right, bottom) }
}
#[cfg(feature = "api-level-28")]
#[doc(alias = "AMediaFormat_setSize")]
pub fn set_usize(&mut self, key: &str, value: usize) {
let name = CString::new(key).unwrap();
unsafe { ffi::AMediaFormat_setSize(self.as_ptr(), name.as_ptr(), value) }
}
/// Copy one [`MediaFormat`] to another.
#[cfg(feature = "api-level-29")]
#[doc(alias = "AMediaFormat_copy")]
pub fn copy(&self, to: &mut Self) -> Result<()> {
let status = unsafe { ffi::AMediaFormat_copy(to.as_ptr(), self.as_ptr()) };
MediaError::from_status(status)
}
/// Clones this [`MediaFormat`] into a [`MediaFormat::new()`] object using
/// [`MediaFormat::copy()`].
#[cfg(feature = "api-level-29")]
#[doc(alias = "AMediaFormat_new")]
#[doc(alias = "AMediaFormat_copy")]
pub fn try_clone(&self) -> Result<Self> {
let mut copy = Self::new();
self.copy(&mut copy)?;
Ok(copy)
}
/// Remove all key/value pairs from this [`MediaFormat`].
#[cfg(feature = "api-level-29")]
#[doc(alias = "AMediaFormat_clear")]
pub fn clear(&mut self) {
unsafe { ffi::AMediaFormat_clear(self.as_ptr()) }
}
}
impl Drop for MediaFormat {
#[doc(alias = "AMediaFormat_delete")]
fn drop(&mut self) {
let status = unsafe { ffi::AMediaFormat_delete(self.as_ptr()) };
MediaError::from_status(status).unwrap()
}
}
+8
View File
@@ -0,0 +1,8 @@
//! Bindings for the NDK media classes.
//!
//! See also [the NDK docs](https://developer.android.com/ndk/reference/group/media)
#![cfg(feature = "media")]
pub mod image_reader;
pub mod media_codec;
pub mod media_format;
+140
View File
@@ -0,0 +1,140 @@
//! Bindings for NDK media status codes.
//!
//! Also used outside of `libmediandk.so` in `libamidi.so` for example.
#![cfg(feature = "media")]
// The cfg(feature) bounds for some pub(crate) fn uses are non-trivial and will become even more
// complex going forward. Allow them to be unused when compiling with certain feature combinations.
#![allow(dead_code)]
use std::{fmt, mem::MaybeUninit, ptr::NonNull};
use num_enum::{FromPrimitive, IntoPrimitive};
pub type Result<T, E = MediaError> = std::result::Result<T, E>;
/// Media Status codes for [`media_status_t`](https://developer.android.com/ndk/reference/group/media#group___media_1ga009a49041fe39f7bdc6d8b5cddbe760c)
#[repr(i32)]
#[derive(Copy, Clone, Debug, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
#[doc(alias = "media_status_t")]
#[non_exhaustive]
pub enum MediaError {
#[doc(alias = "AMEDIACODEC_ERROR_INSUFFICIENT_RESOURCE")]
CodecErrorInsufficientResource = ffi::media_status_t::AMEDIACODEC_ERROR_INSUFFICIENT_RESOURCE.0,
#[doc(alias = "AMEDIACODEC_ERROR_RECLAIMED")]
CodecErrorReclaimed = ffi::media_status_t::AMEDIACODEC_ERROR_RECLAIMED.0,
#[doc(alias = "AMEDIA_ERROR_UNKNOWN")]
ErrorUnknown = ffi::media_status_t::AMEDIA_ERROR_UNKNOWN.0,
#[doc(alias = "AMEDIA_ERROR_MALFORMED")]
ErrorMalformed = ffi::media_status_t::AMEDIA_ERROR_MALFORMED.0,
#[doc(alias = "AMEDIA_ERROR_UNSUPPORTED")]
ErrorUnsupported = ffi::media_status_t::AMEDIA_ERROR_UNSUPPORTED.0,
#[doc(alias = "AMEDIA_ERROR_INVALID_OBJECT")]
ErrorInvalidObject = ffi::media_status_t::AMEDIA_ERROR_INVALID_OBJECT.0,
#[doc(alias = "AMEDIA_ERROR_INVALID_PARAMETER")]
ErrorInvalidParameter = ffi::media_status_t::AMEDIA_ERROR_INVALID_PARAMETER.0,
#[doc(alias = "AMEDIA_ERROR_INVALID_OPERATION")]
ErrorInvalidOperation = ffi::media_status_t::AMEDIA_ERROR_INVALID_OPERATION.0,
#[doc(alias = "AMEDIA_ERROR_END_OF_STREAM")]
ErrorEndOfStream = ffi::media_status_t::AMEDIA_ERROR_END_OF_STREAM.0,
#[doc(alias = "AMEDIA_ERROR_IO")]
ErrorIo = ffi::media_status_t::AMEDIA_ERROR_IO.0,
#[doc(alias = "AMEDIA_ERROR_WOULD_BLOCK")]
ErrorWouldBlock = ffi::media_status_t::AMEDIA_ERROR_WOULD_BLOCK.0,
#[doc(alias = "AMEDIA_DRM_ERROR_BASE")]
DrmErrorBase = ffi::media_status_t::AMEDIA_DRM_ERROR_BASE.0,
#[doc(alias = "AMEDIA_DRM_NOT_PROVISIONED")]
DrmNotProvisioned = ffi::media_status_t::AMEDIA_DRM_NOT_PROVISIONED.0,
#[doc(alias = "AMEDIA_DRM_RESOURCE_BUSY")]
DrmResourceBusy = ffi::media_status_t::AMEDIA_DRM_RESOURCE_BUSY.0,
#[doc(alias = "AMEDIA_DRM_DEVICE_REVOKED")]
DrmDeviceRevoked = ffi::media_status_t::AMEDIA_DRM_DEVICE_REVOKED.0,
#[doc(alias = "AMEDIA_DRM_SHORT_BUFFER")]
DrmShortBuffer = ffi::media_status_t::AMEDIA_DRM_SHORT_BUFFER.0,
#[doc(alias = "AMEDIA_DRM_SESSION_NOT_OPENED")]
DrmSessionNotOpened = ffi::media_status_t::AMEDIA_DRM_SESSION_NOT_OPENED.0,
#[doc(alias = "AMEDIA_DRM_TAMPER_DETECTED")]
DrmTamperDetected = ffi::media_status_t::AMEDIA_DRM_TAMPER_DETECTED.0,
#[doc(alias = "AMEDIA_DRM_VERIFY_FAILED")]
DrmVerifyFailed = ffi::media_status_t::AMEDIA_DRM_VERIFY_FAILED.0,
#[doc(alias = "AMEDIA_DRM_NEED_KEY")]
DrmNeedKey = ffi::media_status_t::AMEDIA_DRM_NEED_KEY.0,
#[doc(alias = "AMEDIA_DRM_LICENSE_EXPIRED")]
DrmLicenseExpired = ffi::media_status_t::AMEDIA_DRM_LICENSE_EXPIRED.0,
#[doc(alias = "AMEDIA_IMGREADER_ERROR_BASE")]
ImgreaderErrorBase = ffi::media_status_t::AMEDIA_IMGREADER_ERROR_BASE.0,
#[doc(alias = "AMEDIA_IMGREADER_CANNOT_LOCK_IMAGE")]
ImgreaderCannotLockImage = ffi::media_status_t::AMEDIA_IMGREADER_CANNOT_LOCK_IMAGE.0,
#[doc(alias = "AMEDIA_IMGREADER_CANNOT_UNLOCK_IMAGE")]
ImgreaderCannotUnlockImage = ffi::media_status_t::AMEDIA_IMGREADER_CANNOT_UNLOCK_IMAGE.0,
#[doc(alias = "AMEDIA_IMGREADER_IMAGE_NOT_LOCKED")]
ImgreaderImageNotLocked = ffi::media_status_t::AMEDIA_IMGREADER_IMAGE_NOT_LOCKED.0,
/// This error code is unknown to the [`ndk`][crate] crate. Please report an issue if you
/// believe this code needs to be added to our mapping.
// Use the OK discriminant, as no-one will be able to call `as i32` and only has access to the
// constants via `From` provided by `IntoPrimitive` which reads the contained value.
// An autogenerated `<previous variant> + 1` discriminant is normally fine, except that the
// previous variant is negative and `+1` would match the variant before that.
#[doc(hidden)]
#[num_enum(catch_all)]
__Unknown(i32) = ffi::media_status_t::AMEDIA_OK.0,
}
impl fmt::Display for MediaError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:?}", self)
}
}
impl std::error::Error for MediaError {}
impl MediaError {
/// Returns [`Ok`] on [`ffi::media_status_t::AMEDIA_OK`], [`Err`] otherwise (including positive
/// values).
///
/// Note that some known error codes (currently only for `AMediaCodec`) are positive.
pub(crate) fn from_status(status: ffi::media_status_t) -> Result<()> {
match status {
ffi::media_status_t::AMEDIA_OK => Ok(()),
x => Err(Self::from(x.0)),
}
}
/// Returns the original value in [`Ok`] if it is not negative, [`Err`] otherwise.
///
/// Note that some [`ffi::media_status_t`] codes are positive but will never be returned as
/// [`Err`] from this function. As of writing these codes are specific to the `AMediaCodec` API
/// and should not be handled generically.
pub(crate) fn from_status_if_negative<T: Into<isize> + Copy>(value: T) -> Result<T> {
let v = value.into();
if v >= 0 {
Ok(value)
} else {
Err(Self::from(
i32::try_from(v).expect("Error code out of bounds"),
))
}
}
}
/// Calls the `with_ptr` construction function with a pointer to uninitialized stack memory,
/// expecting `with_ptr` to initialize it or otherwise return an error code.
pub(crate) fn construct<T>(with_ptr: impl FnOnce(*mut T) -> ffi::media_status_t) -> Result<T> {
let mut result = MaybeUninit::uninit();
let status = with_ptr(result.as_mut_ptr());
MediaError::from_status(status).map(|()| unsafe { result.assume_init() })
}
/// Calls the `with_ptr` construction function with a pointer to a pointer, and expects `with_ptr`
/// to initialize the second pointer to a valid address. That address is returned in the form of a
/// [`NonNull`] object.
pub(crate) fn construct_never_null<T>(
with_ptr: impl FnOnce(*mut *mut T) -> ffi::media_status_t,
) -> Result<NonNull<T>> {
let result = construct(with_ptr)?;
Ok(if cfg!(debug_assertions) {
NonNull::new(result).expect("result should never be null")
} else {
unsafe { NonNull::new_unchecked(result) }
})
}
+241
View File
@@ -0,0 +1,241 @@
//! Bindings for [`ANativeActivity`]
//!
//! [`ANativeActivity`]: https://developer.android.com/ndk/reference/group/native-activity#anativeactivity
use super::hardware_buffer_format::HardwareBufferFormat;
use std::{
ffi::{CStr, OsStr},
os::{raw::c_void, unix::prelude::OsStrExt},
path::Path,
ptr::NonNull,
};
bitflags::bitflags! {
/// Window flags, as per the Java API at [`android.view.WindowManager.LayoutParams`].
///
/// <https://developer.android.com/ndk/reference/group/native-activity#group___native_activity_1ga2f1398dba5e4a5616b83437528bdb28e>
///
/// [`android.view.WindowManager.LayoutParams`]: https://developer.android.com/reference/android/view/WindowManager.LayoutParams
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub struct WindowFlags : u32 {
const ALLOW_LOCK_WHILE_SCREEN_ON = ffi::AWINDOW_FLAG_ALLOW_LOCK_WHILE_SCREEN_ON;
const DIM_BEHIND = ffi::AWINDOW_FLAG_DIM_BEHIND;
#[deprecated = "Deprecated. Blurring is no longer supported."]
const BLUR_BEHIND = ffi::AWINDOW_FLAG_BLUR_BEHIND;
const NOT_FOCUSABLE = ffi::AWINDOW_FLAG_NOT_FOCUSABLE;
const NOT_TOUCHABLE = ffi::AWINDOW_FLAG_NOT_TOUCHABLE;
const NOT_TOUCH_MODAL = ffi::AWINDOW_FLAG_NOT_TOUCH_MODAL;
#[deprecated = "This constant was deprecated in API level 20. This flag has no effect."]
const TOUCHABLE_WHEN_WAKING = ffi::AWINDOW_FLAG_TOUCHABLE_WHEN_WAKING;
const KEEP_SCREEN_ON = ffi::AWINDOW_FLAG_KEEP_SCREEN_ON;
const LAYOUT_IN_SCREEN = ffi::AWINDOW_FLAG_LAYOUT_IN_SCREEN;
const LAYOUT_NO_LIMITS = ffi::AWINDOW_FLAG_LAYOUT_NO_LIMITS;
const FULLSCREEN = ffi::AWINDOW_FLAG_FULLSCREEN;
#[cfg_attr(feature = "api-level-30", deprecated = "This constant was deprecated in API level 30. This value became API \"by accident\", and shouldn't be used by 3rd party applications.")]
const FORCE_NOT_FULLSCREEN = ffi::AWINDOW_FLAG_FORCE_NOT_FULLSCREEN;
#[deprecated = "This constant was deprecated in API level 17. This flag is no longer used."]
const DITHER = ffi::AWINDOW_FLAG_DITHER;
const SECURE = ffi::AWINDOW_FLAG_SECURE;
const SCALED = ffi::AWINDOW_FLAG_SCALED;
const IGNORE_CHEEK_PRESSES = ffi::AWINDOW_FLAG_IGNORE_CHEEK_PRESSES;
const LAYOUT_INSET_DECOR = ffi::AWINDOW_FLAG_LAYOUT_INSET_DECOR;
const ALT_FOCUSABLE_IM = ffi::AWINDOW_FLAG_ALT_FOCUSABLE_IM;
const WATCH_OUTSIDE_TOUCH = ffi::AWINDOW_FLAG_WATCH_OUTSIDE_TOUCH;
const SHOW_WHEN_LOCKED = ffi::AWINDOW_FLAG_SHOW_WHEN_LOCKED;
const SHOW_WALLPAPER = ffi::AWINDOW_FLAG_SHOW_WALLPAPER;
const TURN_SCREEN_ON = ffi::AWINDOW_FLAG_TURN_SCREEN_ON;
#[cfg_attr(feature = "api-level-26", deprecated = "This constant was deprecated in API level 26. Use `SHOW_WHEN_LOCKED` instead.")]
const DISMISS_KEYGUARD = ffi::AWINDOW_FLAG_DISMISS_KEYGUARD;
const ATTACHED_IN_DECOR = 0x40000000;
// https://docs.rs/bitflags/latest/bitflags/#externally-defined-flags
const _ = !0;
}
}
/// A native [`ANativeActivity *`]
///
/// This is either provided in [`ffi::ANativeActivity_onCreate()`], or accessible through
/// `ndk_glue::native_activity()`.
///
/// [`ANativeActivity *`]: https://developer.android.com/ndk/reference/struct/a-native-activity
#[derive(Debug)]
pub struct NativeActivity {
ptr: NonNull<ffi::ANativeActivity>,
}
// It gets shared between threads in `ndk-glue`
unsafe impl Send for NativeActivity {}
unsafe impl Sync for NativeActivity {}
impl NativeActivity {
/// Create a [`NativeActivity`] from a pointer
///
/// # Safety
/// By calling this function, you assert that it is a valid pointer to a native
/// [`ffi::ANativeActivity`].
pub unsafe fn from_ptr(ptr: NonNull<ffi::ANativeActivity>) -> Self {
Self { ptr }
}
/// The pointer to the native `ANativeActivity`
pub fn ptr(&self) -> NonNull<ffi::ANativeActivity> {
self.ptr
}
}
/// Methods that relate to fields of the struct itself
///
/// The relevant NDK docs can be found
/// [here](https://developer.android.com/ndk/reference/struct/a-native-activity).
impl NativeActivity {
/// The platform's SDK version code
pub fn sdk_version(&self) -> i32 {
unsafe { self.ptr.as_ref().sdkVersion }
}
/// Path to this application's internal data directory
pub fn internal_data_path(&self) -> &Path {
OsStr::from_bytes(unsafe { CStr::from_ptr(self.ptr.as_ref().internalDataPath) }.to_bytes())
.as_ref()
}
/// Path to this application's external (removable, mountable) data directory
pub fn external_data_path(&self) -> &Path {
OsStr::from_bytes(unsafe { CStr::from_ptr(self.ptr.as_ref().externalDataPath) }.to_bytes())
.as_ref()
}
/// This app's asset manager, which can be used to access assets from the `.apk` file.
pub fn asset_manager(&self) -> crate::asset::AssetManager {
unsafe {
crate::asset::AssetManager::from_ptr(
NonNull::new(self.ptr.as_ref().assetManager).unwrap(),
)
}
}
/// Instance data associated with the activity
pub fn instance(&self) -> *mut c_void {
unsafe { self.ptr.as_ref().instance }
}
/// Set the instance data associated with the activity
///
/// # Safety
/// This can invalidate assumptions held by `ndk-glue`, as well as cause data
/// races with concurrent access to the instance data.
pub unsafe fn set_instance(&mut self, data: *mut c_void) {
// FIXME Does this create undefined behavior by creating a mutable reference to what could
// also be accessed immutably at the same time?
//
// I think that as long as we warn the users to avoid concurrent access, and we pass along
// the `unsafe` burden, it's OK.
self.ptr.as_mut().instance = data;
}
/// This process's `JavaVM` object.
///
/// Usage with [__jni__](https://crates.io/crates/jni) crate:
/// ```no_run
/// # use ndk::native_activity::NativeActivity;
/// # let native_activity: NativeActivity = unimplemented!();
/// let vm_ptr = native_activity.vm();
/// let vm = unsafe { jni::JavaVM::from_raw(vm_ptr) }.unwrap();
/// let env = vm.attach_current_thread();
/// // Do JNI with env ...
/// ```
pub fn vm(&self) -> *mut jni_sys::JavaVM {
unsafe { self.ptr.as_ref() }.vm
}
/// The [`android.app.NativeActivity`] instance
///
/// In the JNI, this is named `clazz`; however, as the docs say, "it should really be named
/// 'activity' instead of 'clazz', since it's a reference to the NativeActivity instance".
///
/// [`android.app.NativeActivity`]: https://developer.android.com/reference/android/app/NativeActivity
pub fn activity(&self) -> jni_sys::jobject {
unsafe { self.ptr.as_ref() }.clazz
}
/// Path to the directory with the application's OBB files.
///
/// # Safety
/// Only available as of Honeycomb (Android 3.0+, API level 11+)
pub unsafe fn obb_path(&self) -> &Path {
OsStr::from_bytes(CStr::from_ptr(self.ptr.as_ref().obbPath).to_bytes()).as_ref()
}
}
/// Methods that relate to `ANativeActivity_*` functions.
///
/// The relevant NDK docs can be found
/// [here](https://developer.android.com/ndk/reference/group/native-activity).
impl NativeActivity {
/// Sends a destroy event to the activity and stops it.
pub fn finish(&self) {
unsafe { ffi::ANativeActivity_finish(self.ptr.as_ptr()) }
}
/// Shows the IME (the on-screen keyboard).
///
/// If `force` is true, the `SHOW_FORCED` flag is used; otherwise, the `SHOW_IMPLICIT` flag is
/// used. Depending on the value of this flag, the `hide_soft_input` method with behave
/// differently. See [the relevant
/// javadoc](https://developer.android.com/reference/android/view/inputmethod/InputMethodManager#constants_2)
/// for more information.
pub fn show_soft_input(&self, force: bool) {
let flag = if force {
ffi::ANATIVEACTIVITY_SHOW_SOFT_INPUT_FORCED
} else {
ffi::ANATIVEACTIVITY_SHOW_SOFT_INPUT_IMPLICIT
};
unsafe { ffi::ANativeActivity_showSoftInput(self.ptr.as_ptr(), flag) }
}
/// Hides the IME (the on-screen keyboard).
///
/// If `not_always` is true, the `HIDE_NOT_ALWAYS` flag is used; otherwise, the
/// `HIDE_IMPLICIT_ONLY` flag is used. Depending on the value of this flag and the way the IME
/// was shown, it may or may not be hidden. See [the relevant
/// javadoc](https://developer.android.com/reference/android/view/inputmethod/InputMethodManager#constants_2)
/// for more information.
pub fn hide_soft_input(&self, not_always: bool) {
let flag = if not_always {
ffi::ANATIVEACTIVITY_HIDE_SOFT_INPUT_NOT_ALWAYS
} else {
ffi::ANATIVEACTIVITY_HIDE_SOFT_INPUT_IMPLICIT_ONLY
};
unsafe { ffi::ANativeActivity_hideSoftInput(self.ptr.as_ptr(), flag) }
}
/// Change the window format of the given activity.
///
/// Calls [`getWindow().setFormat()`] of the given activity. Note that this method can be
/// called from any thread; it will send a message to the main thread of the process where the
/// Java finish call will take place.
///
/// [`getWindow().setFormat()`]: https://developer.android.com/reference/android/view/Window#setFormat(int)
pub fn set_window_format(&self, format: HardwareBufferFormat) {
unsafe { ffi::ANativeActivity_setWindowFormat(self.ptr.as_ptr(), format.into()) }
}
/// Change the window flags of the given activity.
///
/// Calls [`getWindow().setFlags()`] of the given activity.
///
/// Note that this method can be called from any thread; it will send a message to the main
/// thread of the process where the Java finish call will take place.
///
/// [`getWindow().setFlags()`]: https://developer.android.com/reference/android/view/Window#setFlags(int,%20int)
pub fn set_window_flags(&self, add_flags: WindowFlags, remove_flags: WindowFlags) {
unsafe {
ffi::ANativeActivity_setWindowFlags(
self.ptr.as_ptr(),
add_flags.bits(),
remove_flags.bits(),
)
}
}
}
+459
View File
@@ -0,0 +1,459 @@
//! Bindings for [`ANativeWindow`]
//!
//! [`ANativeWindow`]: https://developer.android.com/ndk/reference/group/a-native-window#anativewindow
use std::{ffi::c_void, io, mem::MaybeUninit, ptr::NonNull};
use jni_sys::{jobject, JNIEnv};
use super::{hardware_buffer_format::HardwareBufferFormat, utils::status_to_io_result};
#[cfg(all(feature = "nativewindow", feature = "api-level-28"))]
use crate::data_space::DataSpace;
pub type Rect = ffi::ARect;
// [`NativeWindow`] represents the producer end of an image queue
///
/// It is the C counterpart of the [`android.view.Surface`] object in Java, and can be converted
/// both ways. Depending on the consumer, images submitted to [`NativeWindow`] can be shown on the
/// display or sent to other consumers, such as video encoders.
///
/// [`android.view.Surface`]: https://developer.android.com/reference/android/view/Surface
#[derive(Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct NativeWindow {
ptr: NonNull<ffi::ANativeWindow>,
}
unsafe impl Send for NativeWindow {}
unsafe impl Sync for NativeWindow {}
impl Drop for NativeWindow {
fn drop(&mut self) {
unsafe { ffi::ANativeWindow_release(self.ptr.as_ptr()) }
}
}
impl Clone for NativeWindow {
fn clone(&self) -> Self {
unsafe { ffi::ANativeWindow_acquire(self.ptr.as_ptr()) }
Self { ptr: self.ptr }
}
}
#[cfg(feature = "rwh_04")]
unsafe impl rwh_04::HasRawWindowHandle for NativeWindow {
fn raw_window_handle(&self) -> rwh_04::RawWindowHandle {
let mut handle = rwh_04::AndroidNdkHandle::empty();
handle.a_native_window = self.ptr.as_ptr().cast();
rwh_04::RawWindowHandle::AndroidNdk(handle)
}
}
#[cfg(feature = "rwh_05")]
unsafe impl rwh_05::HasRawWindowHandle for NativeWindow {
fn raw_window_handle(&self) -> rwh_05::RawWindowHandle {
let mut handle = rwh_05::AndroidNdkWindowHandle::empty();
handle.a_native_window = self.ptr.as_ptr().cast();
rwh_05::RawWindowHandle::AndroidNdk(handle)
}
}
#[cfg(feature = "rwh_06")]
impl rwh_06::HasWindowHandle for NativeWindow {
fn window_handle(&self) -> Result<rwh_06::WindowHandle<'_>, rwh_06::HandleError> {
let handle = rwh_06::AndroidNdkWindowHandle::new(self.ptr.cast());
let handle = rwh_06::RawWindowHandle::AndroidNdk(handle);
// SAFETY: All fields of the "raw" `AndroidNdkWindowHandle` struct are filled out. The
// returned pointer is also kept valid by `NativeWindow` (until `Drop`), which is lifetime-
// borrowed in the returned `WindowHandle<'_>` and cannot be outlived. Its value won't
// change throughout the lifetime of this `NativeWindow`.
Ok(unsafe { rwh_06::WindowHandle::borrow_raw(handle) })
}
}
impl NativeWindow {
/// Assumes ownership of `ptr`
///
/// # Safety
/// `ptr` must be a valid pointer to an Android [`ffi::ANativeWindow`].
pub unsafe fn from_ptr(ptr: NonNull<ffi::ANativeWindow>) -> Self {
Self { ptr }
}
/// Acquires ownership of `ptr`
///
/// # Safety
/// `ptr` must be a valid pointer to an Android [`ffi::ANativeWindow`].
pub unsafe fn clone_from_ptr(ptr: NonNull<ffi::ANativeWindow>) -> Self {
ffi::ANativeWindow_acquire(ptr.as_ptr());
Self::from_ptr(ptr)
}
pub fn ptr(&self) -> NonNull<ffi::ANativeWindow> {
self.ptr
}
pub fn height(&self) -> i32 {
unsafe { ffi::ANativeWindow_getHeight(self.ptr.as_ptr()) }
}
pub fn width(&self) -> i32 {
unsafe { ffi::ANativeWindow_getWidth(self.ptr.as_ptr()) }
}
/// Return the current pixel format ([`HardwareBufferFormat`]) of the window surface.
pub fn format(&self) -> HardwareBufferFormat {
let value = unsafe { ffi::ANativeWindow_getFormat(self.ptr.as_ptr()) };
value.into()
}
/// Change the format and size of the window buffers.
///
/// The width and height control the number of pixels in the buffers, not the dimensions of the
/// window on screen. If these are different than the window's physical size, then its buffer
/// will be scaled to match that size when compositing it to the screen. The width and height
/// must be either both zero or both non-zero.
///
/// For all of these parameters, if `0` or [`None`] is supplied then the window's base value
/// will come back in force.
pub fn set_buffers_geometry(
&self,
width: i32,
height: i32,
format: Option<HardwareBufferFormat>,
) -> io::Result<()> {
let format = format.map_or(0i32, |f| f.into());
let status = unsafe {
ffi::ANativeWindow_setBuffersGeometry(self.ptr.as_ptr(), width, height, format)
};
status_to_io_result(status)
}
/// Set a transform that will be applied to future buffers posted to the window.
#[cfg(all(feature = "nativewindow", feature = "api-level-26"))]
#[doc(alias = "ANativeWindow_setBuffersTransform")]
pub fn set_buffers_transform(&self, transform: NativeWindowTransform) -> io::Result<()> {
let status =
unsafe { ffi::ANativeWindow_setBuffersTransform(self.ptr.as_ptr(), transform.bits()) };
status_to_io_result(status)
}
/// All buffers queued after this call will be associated with the dataSpace parameter
/// specified.
///
/// `data_space` specifies additional information about the buffer. For example, it can be used
/// to convey the color space of the image data in the buffer, or it can be used to indicate
/// that the buffers contain depth measurement data instead of color images. The default
/// dataSpace is `0`, [`DataSpace::Unknown`], unless it has been overridden by the producer.
#[cfg(all(feature = "nativewindow", feature = "api-level-28"))]
#[doc(alias = "ANativeWindow_setBuffersDataSpace")]
pub fn set_buffers_data_space(&self, data_space: DataSpace) -> io::Result<()> {
let status =
unsafe { ffi::ANativeWindow_setBuffersDataSpace(self.ptr.as_ptr(), data_space.into()) };
status_to_io_result(status)
}
/// Get the dataspace of the buffers in this [`NativeWindow`].
#[cfg(all(feature = "nativewindow", feature = "api-level-28"))]
#[doc(alias = "ANativeWindow_getBuffersDataSpace")]
pub fn buffers_data_space(&self) -> io::Result<DataSpace> {
let status = unsafe { ffi::ANativeWindow_getBuffersDataSpace(self.ptr.as_ptr()) };
if status >= 0 {
Ok(status.into())
} else {
Err(status_to_io_result(status).unwrap_err())
}
}
/// Sets the intended frame rate for this window.
///
/// Same as [`set_frame_rate_with_change_strategy(window, frame_rate, compatibility, ChangeFrameRateStrategy::OnlyIfSeamless)`][`NativeWindow::set_frame_rate_with_change_strategy()`].
///
#[cfg_attr(
not(feature = "api-level-31"),
doc = "[`NativeWindow::set_frame_rate_with_change_strategy()`]: https://developer.android.com/ndk/reference/group/a-native-window#anativewindow_setframeratewithchangestrategy"
)]
#[cfg(all(feature = "nativewindow", feature = "api-level-30"))]
#[doc(alias = "ANativeWindow_setFrameRate")]
pub fn set_frame_rate(
&self,
frame_rate: f32,
compatibility: FrameRateCompatibility,
) -> io::Result<()> {
let status = unsafe {
ffi::ANativeWindow_setFrameRate(self.ptr.as_ptr(), frame_rate, compatibility as i8)
};
status_to_io_result(status)
}
/// Sets the intended frame rate for this window.
///
/// On devices that are capable of running the display at different refresh rates, the system
/// may choose a display refresh rate to better match this window's frame rate. Usage of this
/// API won't introduce frame rate throttling, or affect other aspects of the application's
/// frame production pipeline. However, because the system may change the display refresh rate,
/// calls to this function may result in changes to Choreographer callback timings, and changes
/// to the time interval at which the system releases buffers back to the application.
///
/// Note that this only has an effect for windows presented on the display. If this
/// [`NativeWindow`] is consumed by something other than the system compositor, e.g. a media
/// codec, this call has no effect.
///
/// You can register for changes in the refresh rate using
/// [`ffi::AChoreographer_registerRefreshRateCallback()`].
///
/// # Parameters
///
/// - `frame_rate`: The intended frame rate of this window, in frames per second. `0` is a
/// special value that indicates the app will accept the system's choice for the display
/// frame rate, which is the default behavior if this function isn't called. The `frame_rate`
/// param does not need to be a valid refresh rate for this device's display - e.g., it's
/// fine to pass `30`fps to a device that can only run the display at `60`fps.
/// - `compatibility`: The frame rate compatibility of this window. The compatibility value may
/// influence the system's choice of display refresh rate. See the [`FrameRateCompatibility`]
/// values for more info. This parameter is ignored when `frame_rate` is `0`.
/// - `change_frame_rate_strategy`: Whether display refresh rate transitions caused by this
/// window should be seamless. A seamless transition is one that doesn't have any visual
/// interruptions, such as a black screen for a second or two. See the
/// [`ChangeFrameRateStrategy`] values. This parameter is ignored when `frame_rate` is `0`.
#[cfg(all(feature = "nativewindow", feature = "api-level-31"))]
#[doc(alias = "ANativeWindow_setFrameRateWithChangeStrategy")]
pub fn set_frame_rate_with_change_strategy(
&self,
frame_rate: f32,
compatibility: FrameRateCompatibility,
change_frame_rate_strategy: ChangeFrameRateStrategy,
) -> io::Result<()> {
let status = unsafe {
ffi::ANativeWindow_setFrameRateWithChangeStrategy(
self.ptr.as_ptr(),
frame_rate,
compatibility as i8,
change_frame_rate_strategy as i8,
)
};
status_to_io_result(status)
}
/// Provides a hint to the window that buffers should be preallocated ahead of time.
///
/// Note that the window implementation is not guaranteed to preallocate any buffers, for
/// instance if an implementation disallows allocation of new buffers, or if there is
/// insufficient memory in the system to preallocate additional buffers
#[cfg(all(feature = "nativewindow", feature = "api-level-30"))]
pub fn try_allocate_buffers(&self) {
unsafe { ffi::ANativeWindow_tryAllocateBuffers(self.ptr.as_ptr()) }
}
/// Return the [`NativeWindow`] associated with a JNI [`android.view.Surface`] pointer.
///
/// # Safety
/// By calling this function, you assert that `env` is a valid pointer to a [`JNIEnv`] and
/// `surface` is a valid pointer to an [`android.view.Surface`].
///
/// [`android.view.Surface`]: https://developer.android.com/reference/android/view/Surface
pub unsafe fn from_surface(env: *mut JNIEnv, surface: jobject) -> Option<Self> {
let ptr = ffi::ANativeWindow_fromSurface(env, surface);
Some(Self::from_ptr(NonNull::new(ptr)?))
}
/// Return a JNI [`android.view.Surface`] pointer derived from this [`NativeWindow`].
///
/// # Safety
/// By calling this function, you assert that `env` is a valid pointer to a [`JNIEnv`].
///
/// [`android.view.Surface`]: https://developer.android.com/reference/android/view/Surface
#[cfg(feature = "api-level-26")]
pub unsafe fn to_surface(&self, env: *mut JNIEnv) -> jobject {
ffi::ANativeWindow_toSurface(env, self.ptr().as_ptr())
}
/// Lock the window's next drawing surface for writing.
///
/// Optionally pass the region you intend to draw into `dirty_bounds`. When this function
/// returns it is updated (commonly enlarged) with the actual area the caller needs to redraw.
pub fn lock(
&self,
dirty_bounds: Option<&mut Rect>,
) -> io::Result<NativeWindowBufferLockGuard<'_>> {
let dirty_bounds = match dirty_bounds {
Some(dirty_bounds) => dirty_bounds,
None => std::ptr::null_mut(),
};
let mut buffer = MaybeUninit::uninit();
let status = unsafe {
ffi::ANativeWindow_lock(self.ptr.as_ptr(), buffer.as_mut_ptr(), dirty_bounds)
};
status_to_io_result(status)?;
Ok(NativeWindowBufferLockGuard {
window: self,
buffer: unsafe { buffer.assume_init() },
})
}
}
/// Lock holding the next drawing surface for writing. It is unlocked and posted on [`drop()`].
#[derive(Debug)]
pub struct NativeWindowBufferLockGuard<'a> {
window: &'a NativeWindow,
buffer: ffi::ANativeWindow_Buffer,
}
impl<'a> NativeWindowBufferLockGuard<'a> {
/// The number of pixels that are shown horizontally.
pub fn width(&self) -> usize {
usize::try_from(self.buffer.width).unwrap()
}
// The number of pixels that are shown vertically.
pub fn height(&self) -> usize {
usize::try_from(self.buffer.height).unwrap()
}
/// The number of _pixels_ that a line in the buffer takes in memory.
///
/// This may be `>= width`.
pub fn stride(&self) -> usize {
usize::try_from(self.buffer.stride).unwrap()
}
/// The format of the buffer. One of [`HardwareBufferFormat`].
pub fn format(&self) -> HardwareBufferFormat {
self.buffer.format.into()
}
/// The actual bits.
///
/// This points to a memory segment of [`stride()`][Self::stride()] *
/// [`height()`][Self::height()] * [`HardwareBufferFormat::bytes_per_pixel()`] bytes.
///
/// Only [`width()`][Self::width()] pixels are visible for each [`stride()`][Self::stride()]
/// line of pixels in the buffer.
///
/// See [`bytes()`][Self::bytes()] for safe access to these bytes.
pub fn bits(&mut self) -> *mut c_void {
self.buffer.bits
}
/// Safe write access to likely uninitialized pixel buffer data.
///
/// Returns [`None`] when there is no [`HardwareBufferFormat::bytes_per_pixel()`] size
/// available for this [`format()`][Self::format()].
///
/// The returned slice consists of [`stride()`][Self::stride()] * [`height()`][Self::height()]
/// \* [`HardwareBufferFormat::bytes_per_pixel()`] bytes.
///
/// Only [`width()`][Self::width()] pixels are visible for each [`stride()`][Self::stride()]
/// line of pixels in the buffer.
pub fn bytes(&mut self) -> Option<&mut [MaybeUninit<u8>]> {
let num_pixels = self.stride() * self.height();
let num_bytes = num_pixels * self.format().bytes_per_pixel()?;
Some(unsafe { std::slice::from_raw_parts_mut(self.bits().cast(), num_bytes) })
}
/// Returns a slice of bytes for each line of visible pixels in the buffer, ignoring any
/// padding pixels incurred by the stride.
///
/// See [`bits()`][Self::bits()] and [`bytes()`][Self::bytes()] for contiguous access to the
/// underlying buffer.
pub fn lines(&mut self) -> Option<impl Iterator<Item = &mut [MaybeUninit<u8>]>> {
let bpp = self.format().bytes_per_pixel()?;
let scanline_bytes = bpp * self.stride();
let width_bytes = bpp * self.width();
let bytes = self.bytes()?;
Some(
bytes
.chunks_exact_mut(scanline_bytes)
.map(move |scanline| &mut scanline[..width_bytes]),
)
}
}
impl<'a> Drop for NativeWindowBufferLockGuard<'a> {
fn drop(&mut self) {
let ret = unsafe { ffi::ANativeWindow_unlockAndPost(self.window.ptr.as_ptr()) };
assert_eq!(ret, 0);
}
}
#[cfg(all(feature = "nativewindow", feature = "api-level-26"))]
bitflags::bitflags! {
/// Transforms that can be applied to buffers as they are displayed to a window.
///
/// Supported transforms are any combination of horizontal mirror, vertical mirror, and
/// clockwise 90 degree rotation, in that order. Rotations of 180 and 270 degrees are made up
/// of those basic transforms.
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
#[doc(alias = "ANativeWindowTransform")]
pub struct NativeWindowTransform : i32 {
#[doc(alias = "ANATIVEWINDOW_TRANSFORM_IDENTITY")]
const IDENTITY = ffi::ANativeWindowTransform::ANATIVEWINDOW_TRANSFORM_IDENTITY.0 as i32;
#[doc(alias = "ANATIVEWINDOW_TRANSFORM_MIRROR_HORIZONTAL")]
const MIRROR_HORIZONTAL = ffi::ANativeWindowTransform::ANATIVEWINDOW_TRANSFORM_MIRROR_HORIZONTAL.0 as i32;
#[doc(alias = "ANATIVEWINDOW_TRANSFORM_MIRROR_VERTICAL")]
const MIRROR_VERTICAL = ffi::ANativeWindowTransform::ANATIVEWINDOW_TRANSFORM_MIRROR_VERTICAL.0 as i32;
#[doc(alias = "ANATIVEWINDOW_TRANSFORM_ROTATE_90")]
const ROTATE_90 = ffi::ANativeWindowTransform::ANATIVEWINDOW_TRANSFORM_ROTATE_90.0 as i32;
/// Defined as [`Self::MIRROR_HORIZONTAL`] `|` [`Self::MIRROR_VERTICAL`].
#[doc(alias = "ANATIVEWINDOW_TRANSFORM_ROTATE_180")]
const ROTATE_180 = ffi::ANativeWindowTransform::ANATIVEWINDOW_TRANSFORM_ROTATE_180.0 as i32;
/// Defined as [`Self::ROTATE_180`] `|` [`Self::ROTATE_90`].
#[doc(alias = "ANATIVEWINDOW_TRANSFORM_ROTATE_270")]
const ROTATE_270 = ffi::ANativeWindowTransform::ANATIVEWINDOW_TRANSFORM_ROTATE_270.0 as i32;
// https://docs.rs/bitflags/latest/bitflags/#externally-defined-flags
const _ = !0;
}
}
/// Compatibility value for [`NativeWindow::set_frame_rate()`]
#[cfg_attr(
feature = "api-level-31",
doc = " and [`NativeWindow::set_frame_rate_with_change_strategy()`]"
)]
/// .
#[cfg(all(feature = "nativewindow", feature = "api-level-30"))]
#[repr(i8)]
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
#[doc(alias = "ANativeWindow_FrameRateCompatibility")]
#[non_exhaustive]
pub enum FrameRateCompatibility {
/// There are no inherent restrictions on the frame rate of this window.
///
/// When the system selects a frame rate other than what the app requested, the app will be
/// able to run at the system frame rate without requiring pull down. This value should be used
/// when displaying game content, UIs, and anything that isn't video.
#[doc(alias = "ANATIVEWINDOW_FRAME_RATE_COMPATIBILITY_DEFAULT")]
Default =
ffi::ANativeWindow_FrameRateCompatibility::ANATIVEWINDOW_FRAME_RATE_COMPATIBILITY_DEFAULT.0 as i8,
/// This window is being used to display content with an inherently fixed frame rate, e.g. a
/// video that has a specific frame rate.
///
/// When the system selects a frame rate other than what the app requested, the app will need
/// to do pull down or use some other technique to adapt to the system's frame rate. The user
/// experience is likely to be worse (e.g. more frame stuttering) than it would be if the
/// system had chosen the app's requested frame rate. This value should be used for video
/// content.
#[doc(alias = "ANATIVEWINDOW_FRAME_RATE_COMPATIBILITY_FIXED_SOURCE")]
FixedSource = ffi::ANativeWindow_FrameRateCompatibility::ANATIVEWINDOW_FRAME_RATE_COMPATIBILITY_FIXED_SOURCE.0 as i8,
}
/// Change frame rate strategy value for [`NativeWindow::set_frame_rate_with_change_strategy()`].
#[cfg(all(feature = "nativewindow", feature = "api-level-31"))]
#[repr(i8)]
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
#[doc(alias = "ANativeWindow_ChangeFrameRateStrategy")]
#[non_exhaustive]
pub enum ChangeFrameRateStrategy {
/// Change the frame rate only if the transition is going to be seamless.
#[doc(alias = "ANATIVEWINDOW_CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS")]
OnlyIfSeamless =
ffi::ANativeWindow_ChangeFrameRateStrategy::ANATIVEWINDOW_CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS
.0 as i8,
/// Change the frame rate even if the transition is going to be non-seamless, i.e. with visual interruptions for the user.
#[doc(alias = "ANATIVEWINDOW_CHANGE_FRAME_RATE_ALWAYS")]
Always =
ffi::ANativeWindow_ChangeFrameRateStrategy::ANATIVEWINDOW_CHANGE_FRAME_RATE_ALWAYS.0 as i8,
}
+153
View File
@@ -0,0 +1,153 @@
//! Bindings for [`ASharedMemory`]
//!
//! [`ASharedMemory`]: https://developer.android.com/ndk/reference/group/memory
#![cfg(feature = "api-level-26")]
use std::{
ffi::CStr,
io::{Error, Result},
// TODO: Import from std::os::fd::{} since Rust 1.66
os::unix::io::{AsFd, AsRawFd, BorrowedFd, FromRawFd, IntoRawFd, OwnedFd, RawFd},
ptr,
};
#[cfg(feature = "api-level-27")]
use jni_sys::{jobject, JNIEnv};
/// Enables the creation, mapping, and protection control over anonymous shared memory.
#[derive(Debug)]
#[doc(alias = "ASharedMemory")]
pub struct SharedMemory(OwnedFd);
impl SharedMemory {
/// Create a shared memory region.
///
/// Creates shared memory region and returns a file descriptor. The resulting file descriptor
/// can be `mmap`'ed to process memory space with `PROT_READ | PROT_WRITE | PROT_EXEC`. Access
/// to this shared memory region can be restricted with [`set_prot()`][Self::set_prot()].
///
/// Use [`android.os.ParcelFileDescriptor`] to pass the file descriptor to another process.
/// File descriptors may also be sent to other processes over a Unix domain socket with
/// `sendmsg` and `SCM_RIGHTS`. See `sendmsg(3)` and `cmsg(3)` man pages for more information.
///
/// If you intend to share this file descriptor with a child process after calling `exec(3)`,
/// note that you will need to use `fcntl(2)` with `F_SETFD` to clear the `FD_CLOEXEC` flag for
/// this to work on all versions of Android.
///
/// [`android.os.ParcelFileDescriptor`]: https://developer.android.com/reference/android/os/ParcelFileDescriptor
#[doc(alias = "ASharedMemory_create")]
pub fn create(name: Option<&CStr>, size: usize) -> Result<Self> {
let fd =
unsafe { ffi::ASharedMemory_create(name.map_or(ptr::null(), |p| p.as_ptr()), size) };
if fd < 0 {
Err(Error::last_os_error())
} else {
Ok(unsafe { Self::from_raw_fd(fd) })
}
}
/// Returns a `dup`'d FD from the given Java [`android.os.SharedMemory`] object.
///
/// The returned file descriptor has all the same properties & capabilities as the FD returned
/// from [`create()`][Self::create()], however the protection flags will be the same as those
/// of the [`android.os.SharedMemory`] object.
///
/// [`android.os.SharedMemory`]: https://developer.android.com/reference/android/os/SharedMemory
#[cfg(feature = "api-level-27")]
#[doc(alias = "ASharedMemory_dupFromJava")]
#[allow(clippy::not_unsafe_ptr_arg_deref)]
pub fn dup_from_java(env: *mut JNIEnv, shared_memory: jobject) -> Result<Self> {
let fd = unsafe { ffi::ASharedMemory_dupFromJava(env, shared_memory) };
if fd < 0 {
Err(Error::last_os_error())
} else {
Ok(unsafe { Self::from_raw_fd(fd) })
}
}
/// Get the size of the shared memory region.
#[doc(alias = "ASharedMemory_getSize")]
pub fn size(&self) -> usize {
unsafe { ffi::ASharedMemory_getSize(self.as_raw_fd()) }
}
/// Restrict access of shared memory region.
///
/// This function restricts access of a shared memory region. Access can only be removed. The
/// effect applies globally to all file descriptors in all processes across the system that
/// refer to this shared memory region. Existing memory mapped regions are not affected.
///
/// It is a common use case to create a shared memory region, map it read/write locally to
/// initialize content, and then send the shared memory to another process with read only
/// access. Code example as below:
///
/// ```no_run
/// # use ndk::shared_memory::SharedMemory;
/// # // TODO: Import from std::os::fd::{} since Rust 1.66
/// # use std::os::unix::io::AsRawFd;
/// # use std::ffi::CStr;
/// # unsafe {
/// let mem = SharedMemory::create(Some(CStr::from_bytes_with_nul_unchecked(b"memory\0")), 127).unwrap();
/// // By default it has PROT_READ | PROT_WRITE | PROT_EXEC.
/// let size = mem.size();
/// let buffer = libc::mmap(
/// std::ptr::null_mut(),
/// size,
/// libc::PROT_READ | libc::PROT_WRITE,
/// libc::MAP_SHARED,
/// mem.as_raw_fd(),
/// 0,
/// );
/// let buffer_slice = std::slice::from_raw_parts_mut(buffer.cast(), size);
///
/// // trivially initialize content
/// buffer_slice[..7].copy_from_slice(b"hello!\0");
///
/// // Existing mappings will retain their protection flags (PROT_WRITE here) after set_prod()
/// // unless it is unmapped:
/// libc::munmap(buffer, size);
///
/// // limit access to read only
/// mem.set_prot(libc::PROT_READ);
///
/// // share fd with another process here and the other process can only map with PROT_READ.
/// # }
/// ```
#[doc(alias = "ASharedMemory_setProt")]
pub fn set_prot(&self, prot: i32) -> Result<()> {
let status = unsafe { ffi::ASharedMemory_setProt(self.as_raw_fd(), prot) };
if status < 0 {
Err(Error::last_os_error())
} else {
Ok(())
}
}
}
impl AsFd for SharedMemory {
fn as_fd(&self) -> BorrowedFd<'_> {
self.0.as_fd()
}
}
impl AsRawFd for SharedMemory {
fn as_raw_fd(&self) -> RawFd {
self.0.as_raw_fd()
}
}
impl IntoRawFd for SharedMemory {
fn into_raw_fd(self) -> RawFd {
self.0.into_raw_fd()
}
}
impl FromRawFd for SharedMemory {
/// # Safety
///
/// The resource pointed to by `fd` must be open and suitable for assuming
/// ownership. The resource must not require any cleanup other than `close`.
unsafe fn from_raw_fd(fd: RawFd) -> Self {
Self(OwnedFd::from_raw_fd(fd))
}
}
+159
View File
@@ -0,0 +1,159 @@
//! Bindings for [`ASurfaceTexture`]
//!
//! See <https://source.android.com/devices/graphics/arch-st> for an architectural overview of
//! [`SurfaceTexture`] internals.
//!
//! [`ASurfaceTexture`]: https://developer.android.com/ndk/reference/group/surface-texture
#![cfg(feature = "api-level-28")]
use crate::{native_window::NativeWindow, utils::status_to_io_result};
use jni_sys::{jobject, JNIEnv};
use std::{io::Result, ptr::NonNull, time::Duration};
/// An opaque type to manage [`android.graphics.SurfaceTexture`] from native code
///
/// [`android.graphics.SurfaceTexture`]: https://developer.android.com/reference/android/graphics/SurfaceTexture
#[derive(Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct SurfaceTexture {
ptr: NonNull<ffi::ASurfaceTexture>,
}
unsafe impl Send for SurfaceTexture {}
impl Drop for SurfaceTexture {
fn drop(&mut self) {
unsafe { ffi::ASurfaceTexture_release(self.ptr.as_ptr()) }
}
}
impl SurfaceTexture {
/// Assumes ownership of `ptr`
///
/// # Safety
/// `ptr` must be a valid pointer to an Android [`ffi::ASurfaceTexture`].
pub unsafe fn from_ptr(ptr: NonNull<ffi::ASurfaceTexture>) -> Self {
Self { ptr }
}
/// Get a reference to the native [`SurfaceTexture`] from the corresponding Java object.
///
/// # Safety
///
/// This function should be called with a healthy JVM pointer and with a non-null
/// [`android.graphics.SurfaceTexture`], which must be kept alive on the Java/Kotlin side.
///
/// The caller must keep a reference to the Java [`android.graphics.SurfaceTexture`] during the
/// lifetime of the returned [`SurfaceTexture`]. Failing to do so could result in the
/// [`SurfaceTexture`] to stop functioning properly once the Java object gets finalized.
/// However, this will not result in program termination.
///
/// [`android.graphics.SurfaceTexture`]: https://developer.android.com/reference/android/graphics/SurfaceTexture
pub unsafe fn from_surface_texture(env: *mut JNIEnv, surface_texture: jobject) -> Option<Self> {
let a_surface_texture_ptr = ffi::ASurfaceTexture_fromSurfaceTexture(env, surface_texture);
let s = NonNull::new(a_surface_texture_ptr)?;
Some(SurfaceTexture::from_ptr(s))
}
/// Returns a pointer to the native [`ffi::ASurfaceTexture`].
pub fn ptr(&self) -> NonNull<ffi::ASurfaceTexture> {
self.ptr
}
/// Returns a reference to a [`NativeWindow`] (i.e. the Producer) for this [`SurfaceTexture`].
///
/// This is equivalent to Java's:
/// ```java
/// Surface sur = new Surface(surfaceTexture);
/// ```
pub fn acquire_native_window(&self) -> Option<NativeWindow> {
let native_window = unsafe { ffi::ASurfaceTexture_acquireANativeWindow(self.ptr.as_ptr()) };
let n = NonNull::new(native_window)?;
Some(unsafe { NativeWindow::from_ptr(n) })
}
/// Attach the [`SurfaceTexture`] to the OpenGL ES context that is current on the calling
/// thread.
///
/// A new OpenGL ES texture object is created and populated with the [`SurfaceTexture`] image
/// frame that was current at the time of the last call to
/// [`detach_from_gl_context()`][Self::detach_from_gl_context()]. This new texture is bound to
/// the `GL_TEXTURE_EXTERNAL_OES` texture target.
///
/// This can be used to access the [`SurfaceTexture`] image contents from multiple OpenGL ES
/// contexts. Note, however, that the image contents are only accessible from one OpenGL ES
/// context at a time.
pub fn attach_to_gl_context(&self, tex_name: u32) -> Result<()> {
let status = unsafe { ffi::ASurfaceTexture_attachToGLContext(self.ptr.as_ptr(), tex_name) };
status_to_io_result(status)
}
/// Detach the [`SurfaceTexture`] from the OpenGL ES context that owns the OpenGL ES texture
/// object.
///
/// This call must be made with the OpenGL ES context current on the calling thread. The OpenGL
/// ES texture object will be deleted as a result of this call. After calling this method all
/// calls to [`update_tex_image()`][Self::update_tex_image()] will fail until a successful call
/// to [`attach_to_gl_context()`][Self::attach_to_gl_context()] is made.
///
/// This can be used to access the [`SurfaceTexture`] image contents from multiple OpenGL ES
/// contexts. Note, however, that the image contents are only accessible from one OpenGL ES
/// context at a time.
pub fn detach_from_gl_context(&self) -> Result<()> {
let status = unsafe { ffi::ASurfaceTexture_detachFromGLContext(self.ptr.as_ptr()) };
status_to_io_result(status)
}
/// Retrieve the 4x4 texture coordinate transform matrix associated with the texture image set
/// by the most recent call to [`update_tex_image()`][Self::update_tex_image()].
///
/// This transform matrix maps 2D homogeneous texture coordinates of the form `(s, t, 0, 1)`
/// with `s` and `t` in the inclusive range `[0, 1]` to the texture coordinate that should be
/// used to sample that location from the texture. Sampling the texture outside of the range of
/// this transform is undefined.
///
/// The matrix is stored in column-major order so that it may be passed directly to OpenGL ES
/// via the [`glLoadMatrixf()`] or [`glUniformMatrix4fv()`] functions.
///
/// [`glLoadMatrixf()`]: https://www.khronos.org/registry/OpenGL-Refpages/gl2.1/xhtml/glLoadMatrix.xml
/// [`gluniformmatrix4fv()`]: https://www.khronos.org/registry/OpenGL-Refpages/es3.1/html/glUniform.xhtml
pub fn transform_matrix(&self) -> [f32; 16] {
let mut r = [0f32; 16];
unsafe { ffi::ASurfaceTexture_getTransformMatrix(self.ptr.as_ptr(), r.as_mut_ptr()) };
r
}
/// Retrieve the timestamp associated with the texture image set by the most recent call to
/// [`update_tex_image()`][Self::update_tex_image()].
///
/// This timestamp is in nanoseconds, and is normally monotonically increasing. The timestamp
/// should be unaffected by time-of-day adjustments, and for a camera should be strictly
/// monotonic but for a [`MediaPlayer`] may be reset when the position is set. The specific
/// meaning and zero point of the timestamp depends on the source providing images to the
/// [`SurfaceTexture`]. Unless otherwise specified by the image source, timestamps cannot
/// generally be compared across [`SurfaceTexture`] instances, or across multiple program
/// invocations. It is mostly useful for determining time offsets between subsequent frames.
///
/// For EGL/Vulkan producers, this timestamp is the desired present time set with the
/// [`EGL_ANDROID_presentation_time`] or [`VK_GOOGLE_display_timing`] extensions.
///
/// [`MediaPlayer`]: https://developer.android.com/reference/android/media/MediaPlayer
/// [`EGL_ANDROID_presentation_time`]: https://www.khronos.org/registry/EGL/extensions/ANDROID/EGL_ANDROID_presentation_time.txt
/// [`VK_GOOGLE_display_timing`]: https://www.khronos.org/registry/vulkan/specs/1.3-extensions/man/html/VK_GOOGLE_display_timing.html
pub fn timestamp(&self) -> Duration {
Duration::from_nanos(
unsafe { ffi::ASurfaceTexture_getTimestamp(self.ptr.as_ptr()) }
.try_into()
.unwrap(),
)
}
/// Update the texture image to the most recent frame from the image stream.
///
/// This may only be called while the OpenGL ES context that owns the texture is current on the
/// calling thread. It will implicitly bind its texture to the `GL_TEXTURE_EXTERNAL_OES`
/// texture target.
pub fn update_tex_image(&self) -> Result<()> {
let status = unsafe { ffi::ASurfaceTexture_updateTexImage(self.ptr.as_ptr()) };
status_to_io_result(status)
}
}
+143
View File
@@ -0,0 +1,143 @@
//! Bindings for [sync functions]
//!
//! [sync functions]: https://developer.android.com/ndk/reference/group/sync
#![cfg(feature = "sync")]
use std::{
ffi::CStr,
fmt::Debug,
// TODO: Import from std::os::fd::{} since Rust 1.66
os::unix::io::{AsRawFd, BorrowedFd, FromRawFd, OwnedFd},
ptr::NonNull,
};
#[doc(alias = "sync_file_info")]
#[repr(transparent)]
pub struct SyncFileInfo {
inner: NonNull<ffi::sync_file_info>,
}
impl Debug for SyncFileInfo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SyncFileInfo")
.field("name", &self.name())
.field("status", &self.status())
.field("flags", &self.flags())
.field("num_fences", &self.num_fences())
.field("fence_info", &self.fence_info())
.finish()
}
}
impl SyncFileInfo {
/// Retrieve detailed information about a sync file and its fences.
#[doc(alias = "sync_file_info")]
pub fn new(fd: BorrowedFd<'_>) -> Option<Self> {
let inner = NonNull::new(unsafe { ffi::sync_file_info(fd.as_raw_fd()) })?;
Some(Self { inner })
}
pub fn name(&self) -> &CStr {
let inner = unsafe { self.inner.as_ref() };
// TODO: Switch to CStr::from_bytes_until_nul (with c_char -> u8 transmute) since MSRV 1.69
// https://github.com/ash-rs/ash/pull/746
unsafe { CStr::from_ptr(inner.name.as_ptr()) }
}
pub fn status(&self) -> i32 {
let inner = unsafe { self.inner.as_ref() };
inner.status
}
pub fn flags(&self) -> u32 {
let inner = unsafe { self.inner.as_ref() };
inner.flags
}
pub fn num_fences(&self) -> usize {
let inner = unsafe { self.inner.as_ref() };
inner.num_fences as usize
}
/// Get the array of fence infos from the sync file's info.
#[doc(alias = "sync_get_fence_info")]
pub fn fence_info(&self) -> &[SyncFenceInfo] {
let inner = unsafe { self.inner.as_ref() };
if inner.num_fences == 0 {
&[]
} else {
let sync_fence_info = NonNull::new(inner.sync_fence_info as *mut _)
.expect("sync_fence_info cannot be null if num_fences > 0");
unsafe {
std::slice::from_raw_parts(sync_fence_info.as_ptr(), inner.num_fences as usize)
}
}
}
}
impl Drop for SyncFileInfo {
/// Free a [`struct@ffi::sync_file_info`] structure.
#[doc(alias = "sync_file_info_free")]
fn drop(&mut self) {
unsafe { ffi::sync_file_info_free(self.inner.as_ptr()) }
}
}
#[doc(alias = "sync_fence_info")]
#[repr(transparent)]
pub struct SyncFenceInfo(ffi::sync_fence_info);
impl Debug for SyncFenceInfo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SyncFenceInfo")
.field("obj_name", &self.obj_name())
.field("driver_name", &self.driver_name())
.field("status", &self.status())
.field("flags", &self.flags())
.field("timestamp_ns", &self.timestamp_ns())
.finish()
}
}
impl SyncFenceInfo {
pub fn obj_name(&self) -> &CStr {
// TODO: Switch to CStr::from_bytes_until_nul (with c_char -> u8 transmute) since MSRV 1.69
unsafe { CStr::from_ptr(self.0.obj_name.as_ptr()) }
}
pub fn driver_name(&self) -> &CStr {
// TODO: Switch to CStr::from_bytes_until_nul (with c_char -> u8 transmute) since MSRV 1.69
unsafe { CStr::from_ptr(self.0.driver_name.as_ptr()) }
}
pub fn status(&self) -> i32 {
self.0.status
}
pub fn flags(&self) -> u32 {
self.0.flags
}
pub fn timestamp_ns(&self) -> u64 {
self.0.timestamp_ns
}
}
/// Merge two sync files.
///
/// This produces a new sync file with the given name which has the union of the two original sync
/// file's fences; redundant fences may be removed.
///
/// If one of the input sync files is signaled or invalid, then this function may behave like
/// `dup()`: the new file descriptor refers to the valid/unsignaled sync file with its original
/// name, rather than a new sync file.
pub fn sync_merge(name: &CStr, fd1: BorrowedFd<'_>, fd2: BorrowedFd<'_>) -> OwnedFd {
unsafe {
OwnedFd::from_raw_fd(ffi::sync_merge(
name.as_ptr(),
fd1.as_raw_fd(),
fd2.as_raw_fd(),
))
}
}
+92
View File
@@ -0,0 +1,92 @@
//! Bindings for the NDK tracing API.
//!
//! See also [the NDK docs](https://developer.android.com/ndk/reference/group/tracing)
#![cfg(feature = "api-level-23")]
use std::ffi::{CString, NulError};
use std::marker::PhantomData;
pub fn is_trace_enabled() -> bool {
unsafe { ffi::ATrace_isEnabled() }
}
#[derive(Debug)]
pub struct Section {
// Section is !Sync and !Send
_pd: PhantomData<*mut ()>,
}
impl Section {
pub fn new(name: &str) -> Result<Self, NulError> {
let section_name = CString::new(name)?;
unsafe { ffi::ATrace_beginSection(section_name.as_ptr()) };
Ok(Self { _pd: PhantomData })
}
pub fn end(self) {
drop(self)
}
}
impl Drop for Section {
fn drop(&mut self) {
unsafe { ffi::ATrace_endSection() };
}
}
/// Unique identifier for distinguishing simultaneous events
#[derive(Debug)]
#[cfg(feature = "api-level-29")]
pub struct Cookie(pub i32);
#[derive(Debug)]
#[cfg(feature = "api-level-29")]
pub struct AsyncSection {
section_name: CString,
cookie: Cookie,
// AsyncSection is !Sync
_pd: PhantomData<&'static ()>,
}
#[cfg(feature = "api-level-29")]
impl AsyncSection {
pub fn new(name: &str, cookie: Cookie) -> Result<Self, NulError> {
let section_name = CString::new(name)?;
unsafe { ffi::ATrace_beginAsyncSection(section_name.as_ptr(), cookie.0) };
Ok(Self {
section_name,
cookie,
_pd: PhantomData,
})
}
pub fn end(self) {
drop(self)
}
}
#[cfg(feature = "api-level-29")]
impl Drop for AsyncSection {
fn drop(&mut self) {
unsafe { ffi::ATrace_endAsyncSection(self.section_name.as_ptr(), self.cookie.0) };
}
}
#[cfg(feature = "api-level-29")]
#[derive(Debug)]
pub struct Counter {
name: CString,
}
#[cfg(feature = "api-level-29")]
impl Counter {
pub fn new(name: &str) -> Result<Self, NulError> {
let name = CString::new(name)?;
Ok(Self { name })
}
pub fn set_value(&self, value: i64) {
unsafe { ffi::ATrace_setCounter(self.name.as_ptr(), value) }
}
}
+70
View File
@@ -0,0 +1,70 @@
//! Internal utilities
use log::{error, log_enabled, Level};
use std::ffi::{c_int, CStr, CString};
use std::io::{Error, Result};
/// Turns standard `<errno.h>` status codes - typically rewrapped by Android's [`Errors.h`] - into
/// Rust's [`std::io::Error`].
///
/// [`Errors.h`]: https://cs.android.com/android/platform/superproject/+/master:system/core/libutils/include/utils/Errors.h
pub(crate) fn status_to_io_result(status: i32) -> Result<()> {
match status {
0 => Ok(()),
r if r < 0 => Err(Error::from_raw_os_error(-r)),
r => unreachable!("Status is positive integer {}", r),
}
}
pub(crate) fn android_log(level: Level, tag: &CStr, msg: &CStr) {
let prio = match level {
Level::Error => ffi::android_LogPriority::ANDROID_LOG_ERROR,
Level::Warn => ffi::android_LogPriority::ANDROID_LOG_WARN,
Level::Info => ffi::android_LogPriority::ANDROID_LOG_INFO,
Level::Debug => ffi::android_LogPriority::ANDROID_LOG_DEBUG,
Level::Trace => ffi::android_LogPriority::ANDROID_LOG_VERBOSE,
};
unsafe {
ffi::__android_log_write(prio.0 as c_int, tag.as_ptr(), msg.as_ptr());
}
}
pub(crate) fn log_panic(panic: Box<dyn std::any::Any + Send>) {
fn log_panic(panic_str: &str) {
const RUST_PANIC_TAG: &CStr =
unsafe { CStr::from_bytes_with_nul_unchecked(b"RustPanic\0") };
let panic_str = CString::new(panic_str).unwrap_or_default();
// Use the Rust logger if installed and enabled, otherwise fall back to the Android system
// logger so there is at least some record of the panic
if log_enabled!(Level::Error) {
error!("RustPanic: {}", panic_str.to_string_lossy());
log::logger().flush();
} else {
android_log(Level::Error, RUST_PANIC_TAG, &panic_str);
}
}
match panic.downcast::<String>() {
Ok(panic_string) => log_panic(&panic_string),
Err(panic) => match panic.downcast::<&str>() {
Ok(panic_str) => log_panic(&panic_str),
Err(_) => log_panic("Unknown panic message type"),
},
}
}
/// Run a closure and abort the program if it panics.
///
/// This is generally used to ensure Rust callbacks won't unwind past the FFI boundary, which leads
/// to undefined behaviour.
pub(crate) fn abort_on_panic<R>(f: impl FnOnce() -> R) -> R {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or_else(|panic| {
// Try logging the panic before aborting
//
// Just in case our attempt to log a panic could itself cause a panic we use a
// second catch_unwind here.
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| log_panic(panic)));
std::process::abort();
})
}
@@ -366,7 +366,8 @@
INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_GCSupportsGameMode = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
@@ -401,7 +402,8 @@
INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_GCSupportsGameMode = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
@@ -433,7 +435,8 @@
INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_GCSupportsGameMode = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
@@ -473,7 +476,8 @@
INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_GCSupportsGameMode = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
@@ -511,7 +515,7 @@
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
LD_RUNPATH_SEARCH_PATHS = (
@@ -541,7 +545,7 @@
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
LD_RUNPATH_SEARCH_PATHS = (
+8 -4
View File
@@ -10,7 +10,8 @@ Opus audio, cert pinning — lives in the shared Rust **`punktfunk-core`** (stat
## Features
- **Hardware decode** — VideoToolbox HEVC, with a low-latency **stage-2 presenter**
- **Hardware decode** — VideoToolbox H.264/HEVC (plus **AV1** on devices with an AV1 hardware
decoder — M3-class Macs, A17 Pro-class iPhones), with a low-latency **stage-2 presenter**
(`VTDecompressionSession``CAMetalLayer`, presented off a `CADisplayLink`, ~11 ms p50) as the
default and an `AVSampleBufferDisplayLayer` fallback.
- **HDR & 4:4:4** — PQ passthrough with a correct reference-white anchor, mid-session SDR↔HDR
@@ -22,8 +23,9 @@ Opus audio, cert pinning — lives in the shared Rust **`punktfunk-core`** (stat
- **Full controller support** — one selected controller forwarded as pad 0, including **DualSense**
feedback (rumble → CoreHaptics, lightbar, player LEDs, adaptive triggers) and touchpad/motion. The
virtual pad type auto-resolves from your physical controller.
- **Mouse & keyboard**`GCMouse`/`GCKeyboard` capture with click-to-capture and a ⌘⎋ release, plus
iPad pointer lock and touch input.
- **Mouse & keyboard**`GCMouse`/`GCKeyboard` capture with click-to-capture and a ⌃⌥⇧Q release
(the cross-client Ctrl+Alt+Shift+Q; ⌘⎋ still works as the macOS/iPad toggle), plus iPad pointer
lock and touch input.
- **Find hosts automatically** — mDNS discovery (`NWBrowser` over `_punktfunk._udp`); first connect
does a one-time **SPAKE2 PIN pairing** (or TOFU on trusted LANs), then reconnects on a pinned,
Keychain-stored identity.
@@ -83,7 +85,9 @@ PUNKTFUNK_AUTOCONNECT=<box-ip> PUNKTFUNK_MODE=1280x720x60 swift run PunktfunkCli
- **`PunktfunkClient`** (the app) — hosts grid with an *On this network* section, add-host sheet,
the two trust flows (TOFU prompt + SPAKE2 `PairSheet`), the stream view with the HUD, a
tabbed Settings pane (General / Display / Audio / Controllers / Advanced), and the network speed
test. A Scene-level **Stream** menu carries Disconnect (⌘D) and the HUD toggle (⌘⇧S).
test. A Scene-level **Stream** menu carries the cross-client shortcut set: Release Mouse (⌃⌥⇧Q),
Disconnect (⌃⌥⇧D) and the HUD toggle (⌃⌥⇧S) — the same Ctrl+Alt+Shift combos the Windows and
Linux clients reserve, also shown on a 6-second banner at stream start.
On iOS/iPadOS **and macOS** a connected controller swaps the whole home for the **gamepad UI**
(`Home/Gamepad*`, `Settings/GamepadSettingsView`): a console-style host carousel (A connect · Y
library · X settings), a controller-navigable settings screen, an add-host flow with an
@@ -55,6 +55,18 @@ struct ContentView: View {
/// 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.
@StateObject private var waker = HostWaker()
#if os(macOS)
/// Whether the hosting window is native-fullscreen right now (reported by
/// FullscreenController). Drives the session view's safe-area choice: fullscreen goes
/// edge-to-edge (behind the notch); windowed respects the top inset so the title bar
/// never covers the video.
@State private var isFullscreen = false
/// Shows the start-of-stream shortcut banner (the Windows client's discoverability
/// pattern): raised on every transition to `.streaming`, dropped by the banner's own
/// 6-second task. Independent of the stats HUD so the keys are discoverable even with
/// statistics off.
@State private var showShortcutHint = false
#endif
#if !os(macOS)
@State private var showSettings = false
#endif
@@ -89,6 +101,9 @@ struct ContentView: View {
.onChange(of: model.phase) { _, phase in
switch phase {
case .streaming:
#if os(macOS)
showShortcutHint = true // the 6 s shortcut banner, per session start
#endif
// A session actually started remember it on the card ("Connected ago"
// plus the accent ring on the most recent host).
guard let host = model.activeHost else { break }
@@ -115,7 +130,7 @@ struct ContentView: View {
}
}
.onDisappear { model.disconnect() } // window closed mid-session (Cmd+N spawns more)
// Expose the session to the Scene-level Stream menu (Disconnect D works even when
// Expose the session to the Scene-level Stream menu (Disconnect D works even when
// the HUD is hidden). tvOS has no such menu.
#if !os(tvOS)
.focusedSceneValue(\.sessionFocus, SessionFocus(
@@ -125,7 +140,12 @@ struct ContentView: View {
#if os(macOS)
// Fullscreen only while a session is up (incl. the trust prompt over the blurred stream),
// windowed on the host list so the picker isn't forced fullscreen. Opt-out in Settings.
.background(FullscreenController(active: fullscreenWhileStreaming && model.connection != nil))
// The controller also reports the window's ACTUAL fullscreen state back into
// `isFullscreen` (the user can toggle it manually), which drives the session view's
// safe-area handling below.
.background(FullscreenController(
active: fullscreenWhileStreaming && model.connection != nil,
isFullscreen: $isFullscreen))
#endif
// On the outer Group so the sheet survives the trust-prompt home transition
// (the "Pair with PIN instead" path disconnects first the host's accept loop
@@ -188,7 +208,21 @@ struct ContentView: View {
.alert(
"Connection failed",
isPresented: Binding(
get: { model.errorMessage != nil },
get: {
guard model.errorMessage != nil else { return false }
#if os(macOS)
// Defer the alert while a forced-fullscreen exit is still pending: a sheet
// attached to a fullscreen window makes AppKit drop `-toggleFullScreen:`, so
// presenting it now strands the window fullscreen on the home screen after a
// session error (a deliberate disconnect sets no `errorMessage`, which is why
// it never stuck). Tearing the session down already flipped `active`false;
// once the window leaves fullscreen and `isFullscreen` flips, the alert shows
// over the windowed home UI. Not gated when fullscreen is the user's own manual
// choice (opt-out setting) nothing is auto-exiting there to conflict with.
if fullscreenWhileStreaming && isFullscreen { return false }
#endif
return true
},
set: { if !$0 { model.errorMessage = nil } })
) {
Button("OK", role: .cancel) {}
@@ -300,13 +334,17 @@ struct ContentView: View {
#if os(macOS)
.frame(minWidth: 640, minHeight: 360)
.background(Color.black)
// Fill the whole display in fullscreen, INCLUDING behind the camera housing (notch).
// FULLSCREEN fills the whole display, INCLUDING behind the camera housing (notch).
// Without this the stream is laid out in the safe area below the notch, so an
// aspect-fit video at the display's native mode scales down and leaves black borders.
// A fullscreen video behind the notch (a thin top-center strip occluded) is the
// expected behavior same edge-to-edge intent as the iOS/tvOS branches below. Inert
// in windowed mode (no notch safe-area inset on a titled window).
.ignoresSafeArea()
// expected behavior same edge-to-edge intent as the iOS/tvOS branches below.
// WINDOWED keeps the TOP inset: macOS 26 windows extend content under the (glass)
// title bar and report its height as top safe area ignoring it there put the top of
// the video (and the HUD) underneath the title bar. The black `.background` above is a
// ShapeStyle background, which always extends under every inset, so the strip behind
// the title bar stays black rather than showing the video.
.ignoresSafeArea(edges: isFullscreen ? .all : [.horizontal, .bottom])
#elseif os(iOS)
// Streaming is immersive: edge-to-edge under the status bar and home
// indicator, both hidden for the session (they return with the hosts grid).
@@ -335,6 +373,9 @@ struct ContentView: View {
onCaptureChange: { [weak model] captured in
model?.mouseCaptured = captured
},
onDisconnectRequest: { [weak model] in
model?.disconnect() // the captured-state D combo
},
onFrame: { [meter = model.meter, latency = model.latency,
split = model.latencySplit, offset = conn.clockOffsetNs] au in
meter.note(byteCount: au.data.count)
@@ -356,6 +397,30 @@ struct ContentView: View {
StreamHUDView(model: model, connection: conn, placement: placement)
}
}
#if os(macOS)
// The start-of-stream shortcut banner (Windows-client parity): the full
// reserved key set on a glass pill, bottom-centre, for the first 6 seconds of
// every session independent of the stats HUD, so the keys are discoverable
// even with statistics off. The banner's own task drops it (cancelled cleanly
// if the session view goes away first).
.overlay(alignment: .bottom) {
if captureEnabled && showShortcutHint {
Text("Click the stream to capture · ⌃⌥⇧Q releases the mouse · "
+ "⌃⌥⇧D disconnects · ⌃⌥⇧S stats")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.glassBackground(Capsule())
.padding(.bottom, 24)
.transition(.opacity)
.task {
try? await Task.sleep(for: .seconds(6))
withAnimation(.easeOut(duration: 0.6)) { showShortcutHint = false }
}
}
}
#endif
#if os(iOS)
// Touch users have no menu / D, so when the HUD (and its Disconnect button)
// is hidden, keep a minimal always-reachable exit in a corner. It rides a
@@ -422,14 +487,24 @@ struct ContentView: View {
host, launchID: launchID, allowTofu: allowTofu,
requestAccess: requestAccess, approvalReq: approvalReq)
}
// Asleep (not advertising) and we can wake it? Fire the magic packet and WAIT for it to come
// back online a cold box takes far longer to boot than a connect will sit showing the
// "Waking" overlay meanwhile. Then connect. Otherwise dial straight away.
// Not advertising and we can wake it? DIAL FIRST anyway no mDNS advert does NOT mean
// unreachable: a host reached over a routed network (Tailscale/VPN/another subnet) is
// mDNS-blind forever, and gating the dial on presence bricked exactly those reconnects
// (the host log shows no connection attempt at all; the tile pip and this gate share the
// LAN-only `advertises` predicate). `prepareWake` inside the dial already fires the magic
// packet up front, so a genuinely-asleep host is waking while the connect times out; only
// when that dial FAILS do we fall into the visible "Waking" wait a cold box takes far
// longer to boot than a connect will sit and redial once it's back on mDNS.
if PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty, !discovery.advertises(host) {
discovery.start() // so we can observe it reappear
waker.start(
host: host, connectsAfter: true, macs: host.wakeMacs, lastIP: host.address,
isOnline: { discovery.advertises(host) }, onOnline: go)
discovery.start() // so the wake-wait can observe it reappear
startSessionDirect(
host, launchID: launchID, allowTofu: allowTofu,
requestAccess: requestAccess, approvalReq: approvalReq,
onUnreachable: {
waker.start(
host: host, connectsAfter: true, macs: host.wakeMacs, lastIP: host.address,
isOnline: { discovery.advertises(host) }, onOnline: go)
})
} else {
go()
}
@@ -437,10 +512,12 @@ struct ContentView: View {
/// The actual dial reached directly when the host is awake, or from the waker once a woken
/// host is back online. `prepareWake` still runs here to LEARN/refresh the MAC now that the host
/// is advertising (and is a harmless no-op otherwise).
/// is advertising (and is a harmless no-op otherwise). `onUnreachable` hands a plain connect
/// failure back to the caller (the wake-wait fallback) instead of the error alert.
private func startSessionDirect(
_ host: StoredHost, launchID: String? = nil,
allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil
allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil,
onUnreachable: (@MainActor () -> Void)? = nil
) {
prepareWake(for: host)
// The delegated-approval wait prompt only makes sense once we're actually dialing set it
@@ -461,7 +538,8 @@ struct ContentView: View {
preferredCodec: preferredCodecByte,
launchID: launchID,
allowTofu: allowTofu,
requestAccess: requestAccess)
requestAccess: requestAccess,
onUnreachable: onUnreachable)
}
/// Learn-while-awake, wake-while-asleep run just before every connect:
@@ -641,23 +719,62 @@ struct ContentView: View {
}
#if os(macOS)
/// Drives the hosting window in/out of native fullscreen from SwiftUI state. Mounted invisibly in
/// the view tree; on each `active` change it captures the window and toggles fullscreen only when
/// the current state differs (so it never fights a toggle already in flight, and never touches a
/// window the user fullscreened manually unless `active` says otherwise).
/// Drives the hosting window in/out of native fullscreen from SwiftUI state, and mirrors the
/// window's ACTUAL fullscreen state back into `isFullscreen` (the user can also toggle it with the
/// green button / F ContentView keys the session view's safe-area handling off the real state,
/// not the setting). Mounted invisibly in the view tree; on each `active` change it captures the
/// window and toggles fullscreen only when the current state differs (so it never fights a toggle
/// already in flight, and never touches a window the user fullscreened manually unless `active`
/// says otherwise).
private struct FullscreenController: NSViewRepresentable {
let active: Bool
@Binding var isFullscreen: Bool
/// Holds the window's fullscreen-transition observers so they're rebound on a window change
/// and removed on dismantle.
final class Coordinator {
var observers: [NSObjectProtocol] = []
weak var observedWindow: NSWindow?
deinit { observers.forEach(NotificationCenter.default.removeObserver(_:)) }
}
func makeCoordinator() -> Coordinator { Coordinator() }
func makeNSView(context: Context) -> NSView { NSView() }
func updateNSView(_ view: NSView, context: Context) {
let want = active
let isFullscreen = $isFullscreen
let coordinator = context.coordinator
DispatchQueue.main.async {
guard let window = view.window else { return }
observeTransitions(of: window, coordinator: coordinator)
let isFull = window.styleMask.contains(.fullScreen)
if isFullscreen.wrappedValue != isFull { isFullscreen.wrappedValue = isFull }
if want != isFull { window.toggleFullScreen(nil) }
}
}
/// `willEnter` (not did) so the video goes edge-to-edge while the title bar is already
/// animating away; `didExit` so the top inset returns only once the title bar is back
/// no black gap in either direction.
private func observeTransitions(of window: NSWindow, coordinator: Coordinator) {
guard coordinator.observedWindow !== window else { return }
coordinator.observers.forEach(NotificationCenter.default.removeObserver(_:))
coordinator.observers.removeAll()
coordinator.observedWindow = window
let isFullscreen = $isFullscreen
for (name, value) in [
(NSWindow.willEnterFullScreenNotification, true),
(NSWindow.didExitFullScreenNotification, false),
] {
coordinator.observers.append(NotificationCenter.default.addObserver(
forName: name, object: window, queue: .main
) { _ in
isFullscreen.wrappedValue = value
})
}
}
}
#endif
@@ -96,6 +96,14 @@ struct GamepadHomeView: View {
.background { GamepadScreenBackground() }
.onAppear { discovery.start() }
.onDisappear { discovery.stop() }
// Reachability sweep (mDNS-independent) so routed/VPN hosts that never advertise still show
// Online the console mirror of HomeView's `.task`; cancelled on disappear.
.task {
while !Task.isCancelled {
await store.refreshReachability(discovery: discovery)
try? await Task.sleep(for: .seconds(10))
}
}
// The settings / add-host screens take over the controller (the carousel's `isActive`
// gate above). iOS presents them full screen the immersive console feel; macOS has no
// fullScreenCover, so they become generously sized sheets over the dimmed launcher.
@@ -218,13 +226,16 @@ struct GamepadHomeView: View {
id: .saved(host.id),
title: host.displayName,
subtitle: "\(host.address):\(String(host.port))",
isOnline: discovery.advertises(host),
// Online = advertising on mDNS OR proven reachable by the probe (a routed/VPN host
// never advertises); the wake item is offered only when neither holds.
isOnline: discovery.advertises(host) || store.probedOnline.contains(host.id),
isPaired: host.pinnedSHA256 != nil,
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
filled: true,
hasLibrary: true,
canWake: PunktfunkConnection.wakeOnLANAvailable
&& !discovery.advertises(host) && !host.wakeMacs.isEmpty,
&& !discovery.advertises(host) && !store.probedOnline.contains(host.id)
&& !host.wakeMacs.isEmpty,
activate: { connect(host) })
}
let discovered = discovery.unsaved(among: store.hosts).map { d in
@@ -76,6 +76,17 @@ struct HomeView: View {
// session. The home appears/disappears as the stream swaps in and out.
.onAppear { discovery.start() }
.onDisappear { discovery.stop() }
// Reachability sweep while the grid is up: a saved host reached only over a routed
// network (Tailscale/VPN) never advertises on mDNS, so `advertises` can't see it. Probe
// every non-advertising saved host ~every 10 s and publish the reachable set for the
// pips (`isOnline` above OR's it in). The `.task` is cancelled on disappear, matching
// `discovery.stop()`.
.task {
while !Task.isCancelled {
await store.refreshReachability(discovery: discovery)
try? await Task.sleep(for: .seconds(10))
}
}
#if os(tvOS)
// Pushed routes the Settings-app navigation feel (push animation, Menu
// pops) instead of modal overlays.
@@ -157,7 +168,7 @@ struct HomeView: View {
let onBrowseLibrary: (() -> Void)? = libraryEnabled ? { libraryTarget = host } : nil
return HostCardView(
host: host,
isOnline: discovery.advertises(host),
isOnline: discovery.advertises(host) || store.probedOnline.contains(host.id),
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
isMostRecent: host.id == mostRecentHostID,
isBusy: model.isBusy,
@@ -43,8 +43,9 @@ struct PunktfunkClientApp: App {
// form row labels; views that pick an explicit size/weight use `.geist()` directly.
.font(.geist(17, relativeTo: .body))
}
// The Stream menu (Disconnect D, Show/Hide Statistics S) a real menu bar on
// macOS, hardware-keyboard shortcuts on iPad. tvOS has neither.
// 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
// shortcuts on iPad. tvOS has neither.
#if !os(tvOS)
.commands { StreamCommands() }
#endif
@@ -135,6 +135,10 @@ final class SessionModel: ObservableObject {
/// successful connect streams directly (the approval IS the trust decision) the caller pins
/// the observed fingerprint as paired. `host.pinnedSHA256`, when set, pins the advertised cert
/// for the wait; nil = trust-on-first-use.
/// `onUnreachable`, when set, replaces the "could not connect" alert for a plain connect
/// failure: the caller takes over recovery (the Wake-on-LAN wait for a host that stopped
/// advertising). It never fires for the delegated-approval path, whose failure text carries
/// its own instructions.
func connect(to host: StoredHost, width: UInt32, height: UInt32, hz: UInt32,
compositor: PunktfunkConnection.Compositor = .auto,
gamepad: PunktfunkConnection.GamepadType = .auto,
@@ -145,7 +149,8 @@ final class SessionModel: ObservableObject {
launchID: String? = nil,
allowTofu: Bool = false,
autoTrust: Bool = false,
requestAccess: Bool = false) {
requestAccess: Bool = false,
onUnreachable: (@MainActor () -> Void)? = nil) {
guard phase == .idle else { return }
phase = .connecting
activeHost = host
@@ -189,10 +194,13 @@ final class SessionModel: ObservableObject {
if want444, canDecode444 {
videoCaps |= PunktfunkConnection.videoCap444
}
// This client's VideoToolbox path decodes H.264 and HEVC (AV1 isn't wired hosts don't
// emit it on the native path yet). The host resolves the emitted codec from these + the
// soft `preferredCodec`; `resolvedCodec` reflects what it chose.
let videoCodecs = PunktfunkConnection.codecH264 | PunktfunkConnection.codecHEVC
// This client's VideoToolbox path decodes H.264 and HEVC everywhere, and AV1 when
// this device has an AV1 hardware decoder (M3-class Macs, A17 Pro-class iPhones
// VideoToolbox has no software AV1 decoder, so advertising it elsewhere would invite
// a stream that can't decode; see AV1.swift). The host resolves the emitted codec
// from these + the soft `preferredCodec`; `resolvedCodec` reflects what it chose.
var videoCodecs = PunktfunkConnection.codecH264 | PunktfunkConnection.codecHEVC
if AV1.hardwareDecodeSupported { videoCodecs |= PunktfunkConnection.codecAV1 }
let result = Result { try PunktfunkConnection(
host: host.address, port: host.port,
width: width, height: height, refreshHz: hz,
@@ -241,7 +249,11 @@ final class SessionModel: ObservableObject {
case .failure:
self.phase = .idle
self.activeHost = nil
if requestAccess {
if let onUnreachable, !requestAccess {
// The caller owns recovery (wake-and-retry) no error alert here; its
// own overlay explains what's happening.
onUnreachable()
} else if requestAccess {
// The delegated-approval connect ended without being admitted: the
// operator didn't approve it before the host's park window elapsed (or
// the host was unreachable).
@@ -1,6 +1,11 @@
// The app's "Stream" menu (macOS menu bar + iPad hardware-keyboard shortcuts). These live at
// the Scene level so they keep working when the HUD overlay is hidden in particular D
// disconnect, which used to be reachable only via the HUD's button. The toggle just flips the
// the Scene level so they keep working when the HUD overlay is hidden. The shortcuts are the
// CROSS-CLIENT set every punktfunk client reserves Ctrl+Alt+Shift+Q (release the captured
// mouse) / +D (disconnect) / +S (stats) and the menu is their discoverable surface on macOS
// (the Linux client has its GTK Shortcuts window, Windows its start-of-stream banner). While
// input is CAPTURED these key equivalents never reach the menu (the stream view swallows
// keys); InputCapture's monitor detects the same combos there and performs the same actions
// the menu covers the released state and discoverability. The stats toggle just flips the
// shared `hudEnabled` setting; ContentView reads the same @AppStorage and reacts.
//
// tvOS has no menu bar / hardware-keyboard command surface (disconnect there is the Siri
@@ -38,10 +43,19 @@ struct StreamCommands: Commands {
Button(hudEnabled ? "Hide Statistics" : "Show Statistics") {
hudEnabled.toggle()
}
.keyboardShortcut("s", modifiers: [.command, .shift])
.keyboardShortcut("s", modifiers: [.control, .option, .shift])
// Reaches the key window's stream view via NotificationCenter capture is view
// state the Scene can't touch directly. (Captured, the combo is handled by
// InputCapture's monitor before menus see it; this item is the released-state
// path and the shortcut's menu-bar documentation.)
Button("Release Mouse") {
NotificationCenter.default.post(name: .punktfunkReleaseCapture, object: nil)
}
.keyboardShortcut("q", modifiers: [.control, .option, .shift])
.disabled(session?.isStreaming != true)
Divider()
Button("Disconnect") { session?.disconnect() }
.keyboardShortcut("d", modifiers: .command)
.keyboardShortcut("d", modifiers: [.control, .option, .shift])
.disabled(session?.isStreaming != true)
}
}
@@ -64,10 +64,11 @@ struct StreamHUDView: View {
.foregroundStyle(.secondary)
}
// While captured the cursor is hidden+frozen, so the button is keyboard-only
// ( or Cmd+Tab release the cursor; released, it's clickable again).
// (Q the cross-client Ctrl+Alt+Shift+Q or /Cmd+Tab release the cursor;
// released, it's clickable again).
#if os(macOS)
Text(model.mouseCaptured
? "⌘⎋ releases the mouse"
? "⌃⌥⇧Q releases the mouse"
: "Click the stream to capture input")
.font(.geist(11, relativeTo: .caption2))
.foregroundStyle(.secondary)
@@ -87,10 +88,16 @@ struct StreamHUDView: View {
.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);
// this button is the in-overlay, click-to-disconnect affordance.
Button("Disconnect (⌘D)") { model.disconnect() }
// D lives on the app's Stream menu (so it still works when the HUD is hidden)
// and in InputCapture's monitor while captured; this button is the in-overlay,
// click-to-disconnect affordance.
#if os(macOS)
Button("Disconnect (⌃⌥⇧D)") { model.disconnect() }
.font(.geist(12, relativeTo: .caption))
#else
Button("Disconnect") { model.disconnect() }
.font(.geist(12, relativeTo: .caption))
#endif
#endif
}
.padding(10)
@@ -38,13 +38,21 @@ enum SettingsOptions {
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
/// Video-codec preference (`DefaultsKey.codec`) a soft preference the host falls back from.
/// No AV1: this client's VideoToolbox path decodes H.264/HEVC only (hosts don't emit AV1 on
/// the native path yet).
static let codecs: [(label: String, tag: String)] = [
("Automatic", "auto"),
("HEVC (H.265)", "hevc"),
("H.264 (AVC)", "h264"),
]
/// AV1 appears only on devices with an AV1 hardware decoder (the same
/// `AV1.hardwareDecodeSupported` gate SessionModel advertises by) elsewhere it would be a
/// dead setting the host could never honor. Ordered by the host's resolve precedence
/// (HEVC > AV1 > H.264).
static let codecs: [(label: String, tag: String)] = {
var options: [(label: String, tag: String)] = [
("Automatic", "auto"),
("HEVC (H.265)", "hevc"),
("H.264 (AVC)", "h264"),
]
if AV1.hardwareDecodeSupported {
options.insert(("AV1", "av1"), at: 2)
}
return options
}()
// MARK: - Bitrate
@@ -223,9 +223,7 @@ extension SettingsView {
} header: {
Text("Audio")
} footer: {
Text("Host audio plays through the speaker; the microphone feeds the "
+ "host's virtual mic. System default follows macOS device changes. "
+ "Applies from the next session.")
Text(Self.audioFooter)
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
@@ -257,17 +255,14 @@ extension SettingsView {
/// `+` chain (with a ternary) inside the ViewBuilder that single expression blew Swift's
/// type-checker budget and was what actually broke the iOS archive.
private var pointerFooterText: String {
var text = "Trackpad: your finger nudges the host cursor like a laptop touchpad — tap to "
text += "click, two-finger tap for a right click, two-finger drag to scroll, "
text += "tap-then-drag to hold the button, three-finger tap for the stats overlay. "
text += "Direct pointer: the cursor jumps to your finger. Touch passthrough: real "
text += "multi-touch reaches the host, for apps that understand touch. Applies from "
text += "the next touch."
var text = "Trackpad: your finger moves the host cursor like a laptop touchpad — tap "
text += "to click, two-finger tap to right-click, two-finger drag to scroll, "
text += "tap-and-drag to hold, three-finger tap for the stats overlay. Direct pointer: "
text += "the cursor jumps to your finger. Touch passthrough: real multi-touch reaches "
text += "the host. Applies from the next touch."
if UIDevice.current.userInterfaceIdiom == .pad {
text += " Pointer capture locks a hardware mouse/trackpad for relative movement "
text += "(mouse-look); off keeps the pointer free and sends absolute positions. "
text += "The lock needs the stream full-screen and frontmost, and falls back "
text += "automatically (Stage Manager, Slide Over)."
text += " Pointer capture locks a hardware mouse for relative mouse-look; off sends "
text += "absolute positions. Needs the stream full-screen and frontmost."
}
return text
}
@@ -306,6 +301,42 @@ extension SettingsView {
#endif
}
// Non-tvOS: the Apple TV drives a fixed HDMI mode, so there's no adaptive refresh to allow.
@ViewBuilder var vrrSection: some View {
#if !os(tvOS)
Section {
Toggle("Allow VRR", isOn: $allowVRR)
} header: {
Text("Variable refresh rate")
} footer: {
Text("Let a ProMotion or adaptive-sync display vary its refresh rate to match the "
+ "stream — smoother motion without tearing. No effect on fixed-refresh displays. "
+ "Applies from the next session.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
#endif
}
// macOS-only: iOS/tvOS layers always present on the display's vsync, so the choice only
// exists on the Mac (where the layer's own sync must stay off see MetalVideoPresenter).
@ViewBuilder var vsyncSection: some View {
#if os(macOS)
Section {
Toggle("V-Sync", isOn: $vsync)
} header: {
Text("Presentation")
} footer: {
Text("Off (default): each frame is shown as soon as it's ready — lowest latency, "
+ "but frame timing can look uneven and fullscreen may tear. On: frames flip "
+ "in step with the display's refresh — evenly paced, up to one refresh of "
+ "added latency. Applies from the next session.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
#endif
}
// Stage-2 (Metal/VTDecompressionSession) is the default and only user-visible presenter it
// recovers from a wedged decoder, where stage-1's AVSampleBufferDisplayLayer freezes hard on a
// lost HEVC reference. Stage-1 is kept reachable as a DEBUG-only override for diagnostics, like
@@ -320,12 +351,10 @@ extension SettingsView {
} header: {
Text("Video presenter · debug")
} footer: {
Text("Stage 2 (default) decodes explicitly and presents through Metal with a display "
+ "link — it gives the HUD the end-to-end (capture→on-glass) headline with the "
+ "host+network/decode/display stage equation and self-recovers from decode "
+ "stalls. Stage 1 feeds compressed video straight to the system display layer; "
+ "it freezes on a lost HEVC reference frame, so it's a debug fallback only. "
+ "Applies from the next session.")
Text("Stage 2 (default): explicit decode + Metal present — full HUD latency "
+ "breakdown and self-recovery from decode stalls. Stage 1: compressed video "
+ "straight to the system layer; freezes on a lost HEVC reference, so it's a "
+ "debug fallback only. Applies from the next session.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
@@ -344,12 +373,11 @@ extension SettingsView {
} header: {
Text("Video quality")
} footer: {
Text("Codec is a preference the host falls back if it can't encode the one you pick "
+ "(and 10-bit/4:4:4 are HEVC-only). HDR requests a 10-bit BT.2020 PQ (HDR10) stream — "
+ "it only engages when the host is sending HDR content AND this display supports HDR. "
+ "4:4:4 requests full chroma (sharper text/UI, more bandwidth) — it only engages when "
+ "this device can hardware-decode it AND the host opted in. Otherwise the stream stays "
+ "8-bit 4:2:0 SDR. Applies from the next session.")
Text("Codec is a preference; the host falls back if it can't encode your choice. "
+ "HDR (HDR10) and full chroma (4:4:4) are HEVC-only, and each engages only when "
+ "both this device and the host support it — otherwise the stream stays 8-bit "
+ "4:2:0 SDR. 4:4:4 sharpens text and UI for extra bandwidth. Applies from the "
+ "next session.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
@@ -380,9 +408,8 @@ extension SettingsView {
Text("Experimental")
} footer: {
Text("Adds a “Browse Library…” action to each host that lists its games "
+ "(Steam + custom) via the host's management API; tap a title to launch it. "
+ "Works once you've paired with the host — the library is authorized by this "
+ "device's certificate, with no extra host setup.")
+ "(Steam + custom); tap a title to launch it. Works once you've paired — no "
+ "extra host setup.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
@@ -54,32 +54,41 @@ extension SettingsView {
// MARK: - Statistics
static var statisticsFooter: String {
let base = "The overlay shows resolution, frame rate, throughput and latency while "
+ "streaming, in the chosen corner."
let base = "Shows resolution, frame rate, throughput and latency in the chosen "
+ "corner while streaming."
#if os(macOS) || os(iOS)
return base + " Toggle it any time with ⇧S."
return base + " Toggle it any time with ⌃⌥⇧S."
#else
return base
#endif
}
// MARK: - Audio
static var audioFooter: String {
#if os(macOS)
return "Host audio plays through the chosen speaker; your microphone feeds the host's "
+ "virtual mic. System default follows your Mac's device changes. Applies from the "
+ "next session."
#else
return "Host audio plays locally; your microphone feeds the host's virtual mic. "
+ "Applies from the next session."
#endif
}
// MARK: - Controllers
static let controllersFooter =
"One controller is forwarded to the host, as player 1 — Automatic picks the most "
+ "recently connected one. The type is the virtual pad the host creates: Automatic "
+ "matches the controller (a DualSense gets adaptive triggers, lightbar, touchpad "
+ "and motion; a DualShock 4 the same minus adaptive triggers), and changes apply "
+ "from the next session. Two identical controllers may swap a manual selection "
+ "after reconnecting."
"One controller is forwarded as player 1 — Automatic picks the most recently "
+ "connected. Type is the virtual pad the host creates; Automatic matches your "
+ "controller (a DualSense keeps adaptive triggers, lightbar, touchpad and motion). "
+ "Applies from the next session."
#if !os(tvOS)
static let gamepadUIFooter =
"When a controller is connected, the host list and game library switch to a "
+ "controller-friendly layout — larger focus targets, controller-navigable settings, "
+ "and a swipeable cover browser for the library. Turn this off to always use the "
+ "standard layout. (The system may still move basic focus with a controller "
+ "connected even with this off — that's outside the app's control.)"
"When a controller connects, the host list and library switch to a controller-"
+ "friendly layout — larger focus targets and a swipeable cover browser. Turn this "
+ "off to always use the standard layout."
#endif
/// "Use controller" choices for this view's manager (see `SettingsOptions.controllerOptions`).
@@ -25,6 +25,12 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
@AppStorage(DefaultsKey.presenter) var presenter = "stage2"
#if os(macOS)
@AppStorage(DefaultsKey.vsync) var vsync = false
#endif
#if !os(tvOS)
@AppStorage(DefaultsKey.allowVRR) var allowVRR = true
#endif
@AppStorage(DefaultsKey.hdrEnabled) var hdrEnabled = true
@AppStorage(DefaultsKey.enable444) var enable444 = true
@AppStorage(DefaultsKey.libraryEnabled) var libraryEnabled = false
@@ -106,6 +112,8 @@ struct SettingsView: View {
Form {
presenterSection
hdrSection
vrrSection
vsyncSection
windowSection
statisticsSection
}
@@ -234,6 +242,7 @@ struct SettingsView: View {
Form {
presenterSection
hdrSection
vrrSection
statisticsSection
}
.formStyle(.grouped)
@@ -81,6 +81,11 @@ final class HostStore: ObservableObject {
didSet { persist() }
}
/// Saved hosts proven reachable by the periodic QUIC probe (by id) the mDNS-independent
/// counterpart to discovery presence, OR'd into the "online" pip so a routed/VPN host that
/// never advertises still reads Online. Not persisted (it's live reachability, not config).
@Published var probedOnline: Set<StoredHost.ID> = []
init() {
if let data = UserDefaults.standard.data(forKey: Self.key),
let decoded = try? JSONDecoder().decode([StoredHost].self, from: data) {
@@ -110,6 +115,23 @@ final class HostStore: ObservableObject {
hosts[i].lastConnected = Date()
}
/// One reachability sweep, driving `probedOnline`: probe every saved host NOT currently
/// advertising on `discovery` (mDNS already answers for those) off the main actor via a bounded,
/// trust-agnostic QUIC handshake, then publish the reachable set. This is the mDNS-independent
/// half of presence a host reached over a routed network (Tailscale/VPN) never advertises but
/// answers here. Call in a loop from a home view's `.task` (cancelled on disappear).
func refreshReachability(discovery: HostDiscovery) async {
let targets = hosts.filter { !discovery.advertises($0) }
var online: Set<StoredHost.ID> = []
for host in targets {
let reachable = await Task.detached(priority: .utility) {
PunktfunkConnection.probe(host: host.address, port: host.port)
}.value
if reachable { online.insert(host.id) }
}
probedOnline = online
}
func pin(_ hostID: UUID, fingerprint: Data) {
guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return }
hosts[i].pinnedSHA256 = fingerprint
@@ -127,7 +127,16 @@ public final class HostDiscovery: ObservableObject {
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
}
let conn = NWConnection(to: result.endpoint, using: .udp)
// Resolve over IPv4 only: Network.framework prefers IPv6 (RFC 6724), and the host's OS
// mDNS responder often answers AAAA for its hostname even though the punktfunk host stack
// (control QUIC + data UDP) binds IPv4 sockets exclusively a v6-resolved address would
// produce a host card whose connect always fails in the Rust core (`host:port` parse).
// Same policy as the Android/desktop clients; lift when the stack speaks IPv6.
let params = NWParameters.udp
if let ip = params.defaultProtocolStack.internetProtocol as? NWProtocolIP.Options {
ip.version = .v4
}
let conn = NWConnection(to: result.endpoint, using: params)
connections[key] = conn
conn.stateUpdateHandler = { state in
MainActor.assumeIsolated { [weak self] in
@@ -112,6 +112,16 @@ public extension PunktfunkConnection {
}
return rc == statusOK
}
/// Bounded, trust-agnostic QUIC-handshake reachability probe to `host:port` mDNS-INDEPENDENT,
/// so a host reached over a routed network (Tailscale/VPN/another subnet), which never
/// advertises, still reports reachable. No pin/identity presented. The display-side companion
/// to the dial-first connect fix: lets saved-host "online" pips reflect real reachability.
/// Blocking (builds its own runtime) call OFF the main thread.
static func probe(host: String, port: UInt16, timeoutMs: UInt32 = 1500) -> Bool {
let rc: Int32 = host.withCString { punktfunk_probe($0, port, timeoutMs) }
return rc == statusOK
}
}
public final class PunktfunkConnection {
@@ -259,7 +269,8 @@ public final class PunktfunkConnection {
/// `2` = HEVC (default / older host), `1` = H.264, `4` = AV1. Build the decoder from THIS. The
/// resolved value honors the client's `preferredCodec` when the host could emit it.
public private(set) var resolvedCodec: UInt8 = 2 // PUNKTFUNK_CODEC_HEVC
/// The resolved codec as an `AnnexB.VideoCodec` (H.264 vs HEVC) drives the NAL parsing.
/// The resolved codec as a `VideoCodec` (H.264 / HEVC / AV1) drives the bitstream framing
/// (Annex-B NAL parsing vs the AV1 OBU repack).
public var videoCodec: VideoCodec { VideoCodec(wire: resolvedCodec) }
/// Connect and start a session at the requested mode (the host creates a native virtual
@@ -24,8 +24,9 @@
//
// Forwarding is gated by `forwarding` (driven by StreamLayerView's capture state): the
// handlers stay attached for the whole session, but while the user has released capture
// (, focus loss) nothing reaches the host and key events travel the responder chain
// normally. Everything held is flushed host-side on each transition to released.
// (Q the cross-client Ctrl+Alt+Shift+Q or , focus loss) nothing reaches the host
// and key events travel the responder chain normally. Everything held is flushed host-side
// on each transition to released.
//
// GCMouse.current/GCKeyboard.coalesced are process-global singletons with one handler
// slot each: only one InputCapture can be live per process. `activeCapture` tracks
@@ -108,6 +109,16 @@ public final class InputCapture {
/// the absolute-vs-relative forwarding lives entirely in StreamLayerView. Main queue.
public var onToggleCursor: (() -> Void)?
/// The cross-client combos (Windows/Linux parity: Ctrl+Alt+Shift+Q/D/S), fired from the macOS
/// keyDown monitor only WHILE FORWARDING that's the state in which the app's menu (which
/// carries the same key equivalents for discoverability) can't see them, so the monitor is the
/// captured-state delivery path; released, the events pass through and the menu handles them.
/// Q releases the captured mouse/keyboard; D disconnects; S toggles the stats
/// overlay. Main queue.
public var onReleaseCapture: (() -> Void)?
public var onDisconnect: (() -> Void)?
public var onToggleStats: (() -> Void)?
/// Fired when a newer InputCapture takes the process-global GC handler slots (the
/// singletons hold ONE handler each): the preempted owner must drop its capture
/// state its handlers are gone, so it would otherwise sit "captured" with dead
@@ -215,6 +226,32 @@ public final class InputCapture {
self.onToggleCursor?()
return nil
}
// The cross-client combos (Ctrl+Alt+Shift+Q/D/S the same set every other
// punktfunk client reserves), intercepted only while forwarding so the host never
// sees the letter (the modifiers were already forwarded as they went down;
// they're flushed by the release path / released by the user as usual). The letter
// is latched (suppressedVK) so its keyUp doesn't leak to the host either. While
// NOT forwarding the events pass through and the menu's identical key equivalents
// handle them (with the standard menu-flash feedback). keyCodes are kVK_ANSI_*
// physical positions, layout-independent.
if self.forwarding, flags == [.control, .option, .shift] {
switch event.keyCode {
case 12 /* Q */:
self.suppressedVK = 0x51
self.onReleaseCapture?()
return nil
case 2 /* D */:
self.suppressedVK = 0x44
self.onDisconnect?()
return nil
case 1 /* S */:
self.suppressedVK = 0x53
self.onToggleStats?()
return nil
default:
break
}
}
return event
}
#endif
@@ -31,6 +31,17 @@ public enum DefaultsKey {
/// is higher up), so we fold to mono ourselves. Only meaningful for multi-channel devices.
public static let micChannel = "punktfunk.micChannel"
public static let presenter = "punktfunk.presenter"
/// macOS: V-Sync the stream's presents each decoded frame flips on the next display vsync
/// (evenly paced, no tearing under direct scanout) instead of as soon as the GPU finishes
/// (lowest latency the default, OFF). Resolved once per session;
/// PUNKTFUNK_PRESENT_MODE=immediate|vsync overrides it for A/B. See Stage2Pipeline's header.
public static let vsync = "punktfunk.vsync"
/// Allow variable refresh rate: hand the display link a wide frame-rate RANGE (low floor,
/// preferred = stream rate) so a ProMotion / adaptive-sync display can vary its physical
/// refresh to match the stream. On by default; a no-op on fixed-refresh displays. When off,
/// macOS lets the link free-run at the display's native rate and iOS keeps its proven 30 Hz
/// floor. Read per session/reconfigure by `SessionPresenter.syncFrameRate`.
public static let allowVRR = "punktfunk.allowVRR"
/// Request a 10-bit BT.2020 PQ (HDR10) stream. On by default; only takes effect when the host
/// has HDR content AND this display supports HDR otherwise the stream stays 8-bit SDR.
public static let hdrEnabled = "punktfunk.hdrEnabled"
@@ -57,7 +68,7 @@ public enum DefaultsKey {
/// macOS: take the window fullscreen while streaming and restore it on the host list. On by default.
public static let fullscreenWhileStreaming = "punktfunk.fullscreenWhileStreaming"
/// Show the streaming statistics overlay (mode/fps/throughput/latency). On by default; toggle
/// while streaming with S (macOS / hardware keyboard).
/// while streaming with S (the cross-client Ctrl+Alt+Shift+S; macOS / hardware keyboard).
public static let hudEnabled = "punktfunk.hudEnabled"
/// Which corner the statistics overlay sits in a `HUDPlacement` raw value
/// ("topLeading"/"topTrailing"/"bottomLeading"/"bottomTrailing"). Default top-trailing.
@@ -67,3 +78,12 @@ public enum DefaultsKey {
/// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`.
public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled"
}
extension Notification.Name {
/// Posted by the app's Stream menu ("Release Mouse", Q): the key window's stream view
/// releases input capture if it holds it. Only reachable while NOT captured (a captured
/// session swallows the combo in InputCapture's monitor and the frozen cursor can't click
/// menus) it exists so the menu item is honest whenever it CAN fire, and as the shortcut's
/// discoverable menu-bar surface.
public static let punktfunkReleaseCapture = Notification.Name("io.unom.punktfunk.release-capture")
}
@@ -0,0 +1,561 @@
// AV1 (low-overhead OBU bitstream) CoreMedia plumbing the AV1 sibling of AnnexB.swift.
//
// The punktfunk host emits AV1 access units as low-overhead temporal units (the raw encoder
// output every other client feeds ffmpeg): a temporal-delimiter OBU, then on every keyframe,
// per the same in-band-config policy as the NAL codecs a sequence-header OBU, then the frame
// OBUs. VideoToolbox instead wants the ISOBMFF 'av01' flavor: a CMVideoFormatDescription
// carrying an `av1C` configuration record (built from the sequence header), and sample buffers
// holding the temporal unit with the temporal delimiter stripped and every OBU size-fielded.
// This file converts between the two.
//
// HOT PATH: like AnnexB, both pumps run `formatDescription(fromKeyframe:)` +
// `sampleBuffer(au:format:)` once per AU, so everything is built on `forEachOBU` a zero-copy
// scan over the AU's bytes (ranges, not materialized Datas). A delta AU (no sequence header)
// costs a few OBU-header reads; the sample repack leaves exactly one copy (source block
// buffer), mirroring AnnexB.sampleBuffer.
//
// The full sequence-header parse (AV1 spec 5.5.1) runs only when a keyframe actually carries
// one it exists to fill the `av1C` record fields (profile/level/tier/depth/chroma) and the
// colorimetry extensions (so VideoDecoder.isHDRFormat and the presenter's color handling work
// identically across codecs). The host currently gates 10-bit and 4:4:4 to HEVC, so an AV1
// stream is 8-bit 4:2:0 today; the parser still reads depth/chroma/color faithfully so nothing
// here needs touching when that gate lifts.
import CoreMedia
import Foundation
import VideoToolbox
public enum AV1 {
/// True when this device can hardware-decode AV1 (M3-class Macs, A17 Pro-class iPhones,
/// current iPads; false on every Apple TV to date). VideoToolbox has no software AV1
/// decoder, so this is the advertisement gate: a client must never invite a stream it
/// can't decode in real time.
public static let hardwareDecodeSupported: Bool =
VTIsHardwareDecodeSupported(kCMVideoCodecType_AV1)
// MARK: - OBU walking
/// OBU types (AV1 spec 6.2.2) only the ones this file dispatches on.
enum OBUType {
static let sequenceHeader: UInt8 = 1
static let temporalDelimiter: UInt8 = 2
static let padding: UInt8 = 15
}
/// Walk the OBUs of a low-overhead temporal unit without copying: `body` receives the buffer
/// base, each OBU's header range (header byte + optional extension byte + size field, i.e.
/// everything before the payload), payload range, and type and returns false to stop early.
/// The walk ends at the first malformed OBU (forbidden bit set, truncated header, or a size
/// field overrunning the buffer): a torn AU decodes as garbage anyway and the pumps' keyframe
/// recovery re-anchors, so bailing beats guessing at boundaries. An OBU with
/// `obu_has_size_field == 0` extends to the end of the buffer (legal only for the last one).
/// The base pointer is only valid inside `body`.
static func forEachOBU(
in data: Data,
_ body: (
_ base: UnsafePointer<UInt8>, _ header: Range<Int>, _ payload: Range<Int>,
_ type: UInt8
) -> Bool
) {
data.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in
guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return }
let count = raw.count
var i = 0
while i < count {
let start = i
let h = base[i]
guard h & 0x80 == 0 else { return } // obu_forbidden_bit not an OBU stream
let type = (h >> 3) & 0x0F
let hasExtension = h & 0x04 != 0
let hasSize = h & 0x02 != 0
i += 1
if hasExtension {
guard i < count else { return }
i += 1
}
let payloadLen: Int
if hasSize {
guard let (size, sizeLen) = leb128(base: base, at: i, count: count)
else { return }
i += sizeLen
payloadLen = size
} else {
payloadLen = count - i // no size field: extends to the end (must be last)
}
guard i + payloadLen <= count else { return }
if !body(base, start..<i, i..<(i + payloadLen), type) { return }
i += payloadLen
}
}
}
/// Decode a leb128 value at `at` (AV1 spec 4.10.5). Returns (value, encoded length) or nil
/// on truncation / a value past 32 bits (sizes beyond that are nonsense for an OBU).
private static func leb128(
base: UnsafePointer<UInt8>, at: Int, count: Int
) -> (Int, Int)? {
var value: UInt64 = 0
for i in 0..<8 {
guard at + i < count else { return nil }
let byte = base[at + i]
value |= UInt64(byte & 0x7F) << (7 * i)
if byte & 0x80 == 0 {
guard value <= UInt64(UInt32.max) else { return nil }
return (Int(value), i + 1)
}
}
return nil
}
/// leb128-encoded byte length of `value`.
private static func leb128Length(_ value: Int) -> Int {
var v = UInt32(value)
var n = 1
while v >= 0x80 {
v >>= 7
n += 1
}
return n
}
/// Encode `value` as leb128 into `dst`; returns the byte count written.
private static func putLeb128(_ value: Int, into dst: UnsafeMutableRawPointer) -> Int {
var v = UInt32(value)
var n = 0
repeat {
var byte = UInt8(v & 0x7F)
v >>= 7
if v != 0 { byte |= 0x80 }
dst.storeBytes(of: byte, toByteOffset: n, as: UInt8.self)
n += 1
} while v != 0
return n
}
// MARK: - Sequence header
/// The sequence-header fields the `av1C` record and the colorimetry extensions need
/// (AV1 spec 5.5; color codes are ITU-T H.273, shared with the HEVC VUI).
struct SequenceHeader {
var profile: UInt8 = 0
var levelIdx0: UInt8 = 0
var tier0: UInt8 = 0
var highBitdepth = false
var twelveBit = false
var monochrome = false
var subsamplingX = true
var subsamplingY = true
var chromaSamplePosition: UInt8 = 0
/// H.273 codes; 2 = unspecified (the spec default when no color description is coded).
var colorPrimaries: UInt8 = 2
var transferCharacteristics: UInt8 = 2
var matrixCoefficients: UInt8 = 2
var fullRange = false
var maxWidth = 0
var maxHeight = 0
}
/// MSB-first bit reader over the sequence-header payload. Every read is bounds-checked and
/// returns nil on overrun the parser guard-lets each field so a truncated header yields
/// nil rather than garbage.
private struct BitReader {
private let bytes: UnsafePointer<UInt8>
private let bitCount: Int
private var pos = 0
init(bytes: UnsafePointer<UInt8>, count: Int) {
self.bytes = bytes
self.bitCount = count * 8
}
mutating func f(_ n: Int) -> UInt32? {
guard n <= 32, pos + n <= bitCount else { return nil }
var v: UInt32 = 0
for _ in 0..<n {
let bit = (bytes[pos >> 3] >> (7 - UInt8(pos & 7))) & 1
v = (v << 1) | UInt32(bit)
pos += 1
}
return v
}
mutating func flag() -> Bool? { f(1).map { $0 == 1 } }
/// uvlc() (spec 4.10.3) only `num_ticks_per_picture_minus_1` uses it here.
mutating func uvlc() -> UInt32? {
var leadingZeros = 0
while true {
guard let b = f(1) else { return nil }
if b == 1 { break }
leadingZeros += 1
if leadingZeros >= 32 { return nil }
}
if leadingZeros == 0 { return 0 }
guard let v = f(leadingZeros) else { return nil }
return v + (1 << leadingZeros) - 1
}
}
/// Parse a sequence-header OBU payload (spec 5.5.1 the full walk down to color_config,
/// which is what `av1C` + the colorimetry extensions are built from). Returns nil on any
/// truncation or spec violation.
static func parseSequenceHeader(_ payload: Data) -> SequenceHeader? {
payload.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> SequenceHeader? in
guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return nil }
var r = BitReader(bytes: base, count: raw.count)
var sh = SequenceHeader()
guard let profile = r.f(3), profile <= 2 else { return nil }
sh.profile = UInt8(profile)
guard r.flag() != nil else { return nil } // still_picture
guard let reduced = r.flag() else { return nil }
var decoderModelInfoPresent = false
var bufferDelayLengthMinus1 = 0
if reduced {
guard let level = r.f(5) else { return nil }
sh.levelIdx0 = UInt8(level)
sh.tier0 = 0
} else {
guard let timingInfoPresent = r.flag() else { return nil }
if timingInfoPresent {
guard r.f(32) != nil, r.f(32) != nil, // num_units_in_display_tick, time_scale
let equalPictureInterval = r.flag()
else { return nil }
if equalPictureInterval, r.uvlc() == nil { return nil }
guard let dmip = r.flag() else { return nil }
decoderModelInfoPresent = dmip
if decoderModelInfoPresent {
guard let bdl = r.f(5), r.f(32) != nil, r.f(5) != nil, r.f(5) != nil
else { return nil }
bufferDelayLengthMinus1 = Int(bdl)
}
}
guard let initialDisplayDelayPresent = r.flag(),
let opCountMinus1 = r.f(5)
else { return nil }
for i in 0...Int(opCountMinus1) {
guard r.f(12) != nil, let level = r.f(5) else { return nil }
var tier: UInt32 = 0
if level > 7 {
guard let t = r.f(1) else { return nil }
tier = t
}
if i == 0 {
sh.levelIdx0 = UInt8(level)
sh.tier0 = UInt8(tier)
}
if decoderModelInfoPresent {
guard let present = r.flag() else { return nil }
if present {
let n = bufferDelayLengthMinus1 + 1
guard r.f(n) != nil, r.f(n) != nil, r.f(1) != nil else { return nil }
}
}
if initialDisplayDelayPresent {
guard let present = r.flag() else { return nil }
if present, r.f(4) == nil { return nil }
}
}
}
guard let widthBitsMinus1 = r.f(4), let heightBitsMinus1 = r.f(4),
let maxWidthMinus1 = r.f(Int(widthBitsMinus1) + 1),
let maxHeightMinus1 = r.f(Int(heightBitsMinus1) + 1)
else { return nil }
sh.maxWidth = Int(maxWidthMinus1) + 1
sh.maxHeight = Int(maxHeightMinus1) + 1
if !reduced {
guard let frameIdNumbersPresent = r.flag() else { return nil }
if frameIdNumbersPresent {
guard r.f(4) != nil, r.f(3) != nil else { return nil }
}
}
// use_128x128_superblock, enable_filter_intra, enable_intra_edge_filter
guard r.f(3) != nil else { return nil }
if !reduced {
// enable_interintra_compound enable_dual_filter
guard r.f(4) != nil, let enableOrderHint = r.flag() else { return nil }
if enableOrderHint {
guard r.f(2) != nil else { return nil } // jnt_comp, ref_frame_mvs
}
guard let chooseScreenContentTools = r.flag() else { return nil }
let forceScreenContentTools: UInt32
if chooseScreenContentTools {
forceScreenContentTools = 2 // SELECT_SCREEN_CONTENT_TOOLS
} else {
guard let v = r.f(1) else { return nil }
forceScreenContentTools = v
}
if forceScreenContentTools > 0 {
guard let chooseIntegerMv = r.flag() else { return nil }
if !chooseIntegerMv, r.f(1) == nil { return nil }
}
if enableOrderHint, r.f(3) == nil { return nil } // order_hint_bits_minus_1
}
// enable_superres, enable_cdef, enable_restoration
guard r.f(3) != nil else { return nil }
// color_config() (spec 5.5.2)
guard let highBitdepth = r.flag() else { return nil }
sh.highBitdepth = highBitdepth
if sh.profile == 2, highBitdepth {
guard let twelveBit = r.flag() else { return nil }
sh.twelveBit = twelveBit
}
if sh.profile == 1 {
sh.monochrome = false
} else {
guard let mono = r.flag() else { return nil }
sh.monochrome = mono
}
guard let colorDescriptionPresent = r.flag() else { return nil }
if colorDescriptionPresent {
guard let cp = r.f(8), let tc = r.f(8), let mc = r.f(8) else { return nil }
sh.colorPrimaries = UInt8(cp)
sh.transferCharacteristics = UInt8(tc)
sh.matrixCoefficients = UInt8(mc)
}
if sh.monochrome {
guard let fullRange = r.flag() else { return nil }
sh.fullRange = fullRange
sh.subsamplingX = true
sh.subsamplingY = true
sh.chromaSamplePosition = 0
return sh
}
if sh.colorPrimaries == 1, sh.transferCharacteristics == 13,
sh.matrixCoefficients == 0 {
// BT.709 + sRGB + identity forces full-range 4:4:4.
sh.fullRange = true
sh.subsamplingX = false
sh.subsamplingY = false
return sh
}
guard let fullRange = r.flag() else { return nil }
sh.fullRange = fullRange
switch sh.profile {
case 0:
sh.subsamplingX = true
sh.subsamplingY = true
case 1:
sh.subsamplingX = false
sh.subsamplingY = false
default: // profile 2
if sh.highBitdepth, sh.twelveBit {
guard let ssx = r.flag() else { return nil }
sh.subsamplingX = ssx
if ssx {
guard let ssy = r.flag() else { return nil }
sh.subsamplingY = ssy
} else {
sh.subsamplingY = false
}
} else {
sh.subsamplingX = true
sh.subsamplingY = false
}
}
if sh.subsamplingX, sh.subsamplingY {
guard let csp = r.f(2) else { return nil }
sh.chromaSamplePosition = UInt8(csp)
}
return sh
}
}
// MARK: - Format description
/// Build a format description from a keyframe AU's in-band sequence header the AV1
/// equivalent of `AnnexB.formatDescription(fromIDR:)`. Returns nil when the AU carries no
/// sequence-header OBU (a delta frame): the pumps latch the previous description exactly as
/// they do for the NAL codecs. The description carries the `av1C` record (with the sequence
/// header as its configOBUs) plus colorimetry extensions mapped from color_config, so
/// `VideoDecoder.isHDRFormat` and the presenter treat AV1 like any other stream.
public static func formatDescription(fromKeyframe au: Data) -> CMVideoFormatDescription? {
// The sequence-header OBU, re-emitted with a size field (encoders size-field everything
// in practice; the rewrap also covers a last-OBU-without-size corner).
var seqHeaderOBU: Data?
var seqHeaderPayload: Data?
forEachOBU(in: au) { base, header, payload, type in
guard type == OBUType.sequenceHeader else { return true }
var obu = Data(capacity: 2 + leb128Length(payload.count) + payload.count)
obu.append(base[header.lowerBound] | 0x02) // has_size_field set
if base[header.lowerBound] & 0x04 != 0 { // extension byte rides along
obu.append(base[header.lowerBound + 1])
}
var lenBuf = [UInt8](repeating: 0, count: 8)
let lenLen = lenBuf.withUnsafeMutableBytes {
putLeb128(payload.count, into: $0.baseAddress!)
}
obu.append(contentsOf: lenBuf[0..<lenLen])
obu.append(UnsafeBufferPointer(start: base + payload.lowerBound, count: payload.count))
seqHeaderOBU = obu
seqHeaderPayload = Data(bytes: base + payload.lowerBound, count: payload.count)
return false
}
guard let seqHeaderOBU, let seqHeaderPayload,
let sh = parseSequenceHeader(seqHeaderPayload),
sh.maxWidth > 0, sh.maxHeight > 0
else { return nil }
// AV1CodecConfigurationRecord (AV1-ISOBMFF §2.3): 4 fixed bytes + configOBUs.
var av1C = Data(capacity: 4 + seqHeaderOBU.count)
av1C.append(0x81) // marker=1, version=1
av1C.append((sh.profile << 5) | sh.levelIdx0)
av1C.append(
(sh.tier0 << 7)
| ((sh.highBitdepth ? 1 : 0) << 6)
| ((sh.twelveBit ? 1 : 0) << 5)
| ((sh.monochrome ? 1 : 0) << 4)
| ((sh.subsamplingX ? 1 : 0) << 3)
| ((sh.subsamplingY ? 1 : 0) << 2)
| sh.chromaSamplePosition)
av1C.append(0) // no initial_presentation_delay
av1C.append(seqHeaderOBU)
// Colorimetry from color_config's H.273 codes; unspecified (2) falls back to BT.709
// the host's SDR default, same policy the presenter applies elsewhere.
let primaries: CFString = {
switch sh.colorPrimaries {
case 9: return kCMFormatDescriptionColorPrimaries_ITU_R_2020
case 6: return kCMFormatDescriptionColorPrimaries_SMPTE_C
case 5: return kCMFormatDescriptionColorPrimaries_EBU_3213
default: return kCMFormatDescriptionColorPrimaries_ITU_R_709_2
}
}()
let transfer: CFString = {
switch sh.transferCharacteristics {
case 16: return kCMFormatDescriptionTransferFunction_SMPTE_ST_2084_PQ
case 18: return kCMFormatDescriptionTransferFunction_ITU_R_2100_HLG
case 13: return kCMFormatDescriptionTransferFunction_sRGB
case 8: return kCMFormatDescriptionTransferFunction_Linear
default: return kCMFormatDescriptionTransferFunction_ITU_R_709_2
}
}()
let matrix: CFString = {
switch sh.matrixCoefficients {
case 9, 10: return kCMFormatDescriptionYCbCrMatrix_ITU_R_2020
case 5, 6: return kCMFormatDescriptionYCbCrMatrix_ITU_R_601_4
default: return kCMFormatDescriptionYCbCrMatrix_ITU_R_709_2
}
}()
let extensions: [CFString: Any] = [
kCMFormatDescriptionExtension_SampleDescriptionExtensionAtoms: ["av1C": av1C],
kCMFormatDescriptionExtension_ColorPrimaries: primaries,
kCMFormatDescriptionExtension_TransferFunction: transfer,
kCMFormatDescriptionExtension_YCbCrMatrix: matrix,
kCMFormatDescriptionExtension_FullRangeVideo: sh.fullRange,
]
var format: CMVideoFormatDescription?
let status = CMVideoFormatDescriptionCreate(
allocator: kCFAllocatorDefault,
codecType: kCMVideoCodecType_AV1,
width: Int32(sh.maxWidth), height: Int32(sh.maxHeight),
extensions: extensions as CFDictionary,
formatDescriptionOut: &format)
return status == noErr ? format : nil
}
// MARK: - Sample buffers
/// Wrap one temporal unit as a decode-ready CMSampleBuffer in the ISOBMFF 'av01' sample
/// format: the temporal-delimiter (and padding) OBUs are dropped, every remaining OBU is
/// re-emitted with a size field, and mirroring AnnexB.sampleBuffer the result is packed
/// straight into the CMBlockBuffer's allocation (sized by a first cheap scan). The sequence
/// header stays in-band (spec-legal: it's bit-identical to the one in `av1C`, which is
/// rebuilt from the same keyframe), preserving the host's self-contained-keyframe policy.
public static func sampleBuffer(
au: AccessUnit, format: CMVideoFormatDescription
) -> CMSampleBuffer? {
// Pass 1: byte scan only total repacked size of the kept OBUs.
var total = 0
forEachOBU(in: au.data) { base, header, payload, type in
if type == OBUType.temporalDelimiter || type == OBUType.padding { return true }
let headerLen = base[header.lowerBound] & 0x04 != 0 ? 2 : 1
total += headerLen + leb128Length(payload.count) + payload.count
return true
}
// Nothing decodable (a delimiter-only AU our host never sends one): drop it rather
// than hand the decoder an empty sample.
guard total > 0 else { return nil }
var blockBuffer: CMBlockBuffer?
guard CMBlockBufferCreateWithMemoryBlock(
allocator: kCFAllocatorDefault, memoryBlock: nil,
blockLength: total, blockAllocator: kCFAllocatorDefault,
customBlockSource: nil, offsetToData: 0, dataLength: total,
flags: kCMBlockBufferAssureMemoryNowFlag, blockBufferOut: &blockBuffer) == noErr,
let block = blockBuffer
else { return nil }
var dstLen = 0
var dstPtr: UnsafeMutablePointer<CChar>?
guard CMBlockBufferGetDataPointer(
block, atOffset: 0, lengthAtOffsetOut: nil, totalLengthOut: &dstLen,
dataPointerOut: &dstPtr) == noErr,
dstLen == total, let dstPtr
else { return nil }
// Pass 2: the single copy header (+extension) byte, size field, payload per OBU.
let dst = UnsafeMutableRawPointer(dstPtr)
var off = 0
forEachOBU(in: au.data) { base, header, payload, type in
if type == OBUType.temporalDelimiter || type == OBUType.padding { return true }
dst.storeBytes(
of: base[header.lowerBound] | 0x02, toByteOffset: off, as: UInt8.self)
off += 1
if base[header.lowerBound] & 0x04 != 0 {
dst.storeBytes(
of: base[header.lowerBound + 1], toByteOffset: off, as: UInt8.self)
off += 1
}
off += putLeb128(payload.count, into: dst.advanced(by: off))
dst.advanced(by: off)
.copyMemory(from: base + payload.lowerBound, byteCount: payload.count)
off += payload.count
return true
}
var timing = CMSampleTimingInfo(
duration: .invalid,
presentationTimeStamp: CMTime(value: Int64(au.ptsNs), timescale: 1_000_000_000),
decodeTimeStamp: .invalid)
var sampleSize = total
var sample: CMSampleBuffer?
guard CMSampleBufferCreate(
allocator: kCFAllocatorDefault, dataBuffer: block, dataReady: true,
makeDataReadyCallback: nil, refcon: nil, formatDescription: format,
sampleCount: 1, sampleTimingEntryCount: 1, sampleTimingArray: &timing,
sampleSizeEntryCount: 1, sampleSizeArray: &sampleSize,
sampleBufferOut: &sample) == noErr
else { return nil }
// Low-latency display: render on arrival, don't wait for a clock.
if let attachments = CMSampleBufferGetSampleAttachmentsArray(sample!, createIfNecessary: true) {
let dict = unsafeBitCast(CFArrayGetValueAtIndex(attachments, 0), to: CFMutableDictionary.self)
CFDictionarySetValue(
dict,
Unmanaged.passUnretained(kCMSampleAttachmentKey_DisplayImmediately).toOpaque(),
Unmanaged.passUnretained(kCFBooleanTrue).toOpaque())
}
return sample
}
}
extension VideoCodec {
/// Codec-dispatching format-description refresh: the AV1 path keys on an in-band sequence
/// header, the NAL codecs on in-band parameter sets one call site in each pump.
public func formatDescription(fromKeyframe au: Data) -> CMVideoFormatDescription? {
self == .av1
? AV1.formatDescription(fromKeyframe: au)
: AnnexB.formatDescription(fromIDR: au, codec: self)
}
/// Codec-dispatching sample wrap (see `formatDescription(fromKeyframe:)`).
public func sampleBuffer(
au: AccessUnit, format: CMVideoFormatDescription
) -> CMSampleBuffer? {
self == .av1
? AV1.sampleBuffer(au: au, format: format)
: AnnexB.sampleBuffer(au: au, format: format, codec: self)
}
}
@@ -6,8 +6,10 @@
// buffers whose NALs are 4-byte-length-prefixed. This file converts between the two, for
// the codec the host resolved in the Welcome (`connection.videoCodec`) HEVC and H.264
// differ only in NAL-header layout and which parameter sets exist (HEVC adds a VPS). AV1
// is not an Annex-B/NAL codec and isn't handled here (hosts don't emit it on the native
// path yet).
// is not an Annex-B/NAL codec and isn't handled here its OBU flavor of the same plumbing
// lives in AV1.swift, and the pumps reach both through `VideoCodec`'s dispatching
// `formatDescription(fromKeyframe:)` / `sampleBuffer(au:format:)`, so nothing below is ever
// called with `.av1`.
//
// HOT PATH: both pumps run `formatDescription(fromIDR:codec:)` + `sampleBuffer(au:format:codec:)`
// once per AU, so the conversion is built on `forEachNAL` a zero-copy scan over the AU's bytes
@@ -23,10 +25,15 @@ import Foundation
public enum VideoCodec: Equatable {
case h264
case hevc
case av1
/// Resolve from the wire `Welcome.codec` byte (`PUNKTFUNK_CODEC_*`; unknown HEVC).
public init(wire: UInt8) {
self = wire == 0x01 ? .h264 : .hevc // 0x01 = PUNKTFUNK_CODEC_H264
switch wire {
case 0x01: self = .h264 // PUNKTFUNK_CODEC_H264
case 0x04: self = .av1 // PUNKTFUNK_CODEC_AV1
default: self = .hevc // PUNKTFUNK_CODEC_HEVC the default / older-host codec
}
}
/// NAL unit type from a NAL's first byte. HEVC: bits 1..6; H.264: bits 0..4.
@@ -140,6 +147,8 @@ public enum AnnexB {
sets = [vps, sps, pps]
case .h264:
sets = [sps, pps]
case .av1:
return nil // OBU stream, no parameter-set NALs handled in AV1.swift, never here
}
var format: CMVideoFormatDescription?
@@ -175,6 +184,8 @@ public enum AnnexB {
parameterSetSizes: sizes,
nalUnitHeaderLength: 4,
formatDescriptionOut: &format)
case .av1:
break // unreachable the .av1 arm above already returned
}
}
return status == noErr ? format : nil
@@ -1,12 +1,15 @@
// Stage-2 presenter, present half: draw a decoded NV12 / P010 / 4:4:4 CVPixelBuffer into a CAMetalLayer
// drawable with a YCbCrRGB shader. The hosting view's CADisplayLink drives `render` once per vsync
// (via Stage2Pipeline.renderTick) with the target present time, so a present can be stamped and the
// present tail hand-paced. See docs apple-stage2-presenter.md.
// drawable with a YCbCrRGB shader. The hosting view's CADisplayLink still paces the pipeline once per
// vsync, but the actual `render` runs on Stage2Pipeline's dedicated RENDER THREAD (the link tick just
// signals it), so `nextDrawable()`'s blocking never lands on the main thread. See docs
// apple-stage2-presenter.md.
//
// Main-thread only: created during view setup, `render`/`configure` called from the view's CADisplayLink
// (which fires on the main runloop). The Metal objects + texture cache are touched only here. The one
// exception is `setHdrMeta`, called from the pump thread it hops the layer write to main so every
// CALayer mutation stays on one thread.
// Threading: created during view setup (main); `render`/`configure` run on the render thread the
// layer's drawable/format/colour mutations all happen there (CAMetalLayer is designed for dedicated
// render threads; only the layer's GEOMETRY frame/contentsScale is touched from main, in
// SessionPresenter.layout, which also pushes the resulting pixel size here via `setDrawableTarget`
// so the render thread never reads layer geometry cross-thread). `setHdrMeta` (pump thread) and
// `setDrawableTarget` (main) only write lock-guarded staging state the render thread drains.
#if canImport(Metal) && canImport(QuartzCore)
import CoreGraphics
@@ -144,14 +147,23 @@ public final class MetalVideoPresenter {
private var textureCache: CVMetalTextureCache?
/// Current layer configuration switched in `configure(hdr:)` when a frame's HDR-ness differs.
/// Main-thread only (read + written from `render`/`configure`, all on the display-link runloop).
/// Render-thread confined once the pipeline runs (Stage2Pipeline.start's one pre-thread
/// `configure` call is ordered before the thread starts, so it doesn't race).
private var hdrActive = false
/// Last HDR mastering grade received via `setHdrMeta` (the host's 0xCE). Cached so a mid-session
/// SDRHDR 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
/// `edrMetadata`). Main-thread only.
/// `edrMetadata`). Render-thread confined (drained from `pendingHdrMeta` at the top of `render`).
private var lastHdrMeta: PunktfunkConnection.HdrMeta?
/// Cross-thread staging, all under `stagingLock`: the pump thread parks a fresh 0xCE grade in
/// `pendingHdrMeta` and the main thread parks the layout-derived drawable pixel size in
/// `drawableTarget`; the render thread drains both at the top of `render`, so every layer
/// format/colour mutation stays on the one thread that also calls `nextDrawable()`.
private let stagingLock = NSLock()
private var pendingHdrMeta: PunktfunkConnection.HdrMeta?
private var drawableTarget: CGSize = .zero
#if DEBUG
/// Last logged "decodeddrawable" signature, so the diagnostic logs only on a size/HDR change.
private var lastSizeSig = ""
@@ -191,12 +203,18 @@ public final class MetalVideoPresenter {
layer.framebufferOnly = true
layer.isOpaque = true
#if os(macOS)
// The display link already paces exactly one present per vsync. Leaving the layer's own vsync
// wait on means `commandBuffer.present` ALSO blocks for the hardware vsync, so `nextDrawable()`
// stalls the MAIN thread until a drawable frees windowed, the WindowServer's looser
// compositing hides it; FULLSCREEN's tighter path serializes the main thread to the display and
// the stall surfaces as bad judder. Disabling the layer-level sync lets present return promptly
// (the display link is the pacing source) the fix for the fullscreen stutter. macOS-only.
// displaySyncEnabled MUST stay false on macOS. It has flip-flopped, so the full history:
// sync ON was tried twice and starves the drawable pool both times on macOS 26 a synced
// present only reaches glass when the WindowServer composites the window, and its FramePacing
// path does not treat our out-of-band image-queue presents as damage, so with a static scene
// the ONLY recurring damage is the 1 Hz stats HUD update: presents queue, all drawables stay
// held, `nextDrawable()` sleeps (sampled: ~70% of the render thread inside
// CAMetalLayerPrivateNextDrawableLocked usleep), and the stream turns into a ~1 fps
// slideshow with normal-LOOKING stats (each rare frame is fresh, newest-wins ring). The
// 94fb7d1b fullscreen judder was the same starvation biting the then-main-thread render.
// With sync OFF the flip is immediate; the vsync alignment that sync was supposed to give
// (the HUD-off direct-scanout pacing fix) comes from scheduling the present at the display
// link's target time instead (`present(at:)` see `render`).
layer.displaySyncEnabled = false
#endif
// The drawable is rendered at the LAYER's pixel size (set per-frame in `render`), so the
@@ -226,11 +244,12 @@ public final class MetalVideoPresenter {
self.layer = layer
}
/// Configure the layer + active pipeline for an SDR or HDR session. MAIN THREAD ONLY. Called once at
/// session start and again per-frame from `render` (idempotent the guard makes a same-state call a
/// no-op), so a mid-session HDR toggle (the host re-inits its encoder; the decoded `frame.isHDR`
/// flips) reconfigures here automatically. HDR uses an rgba16Float drawable + BT.2020 PQ colour space
/// + EDR with a 203-nit reference-white anchor; SDR uses the plain 8-bit sRGB path.
/// Configure the layer + active pipeline for an SDR or HDR session. Called once at session start
/// (main, before the render thread exists) and again per-frame from `render` on the RENDER THREAD
/// (idempotent the guard makes a same-state call a no-op), so a mid-session HDR toggle (the host
/// re-inits its encoder; the decoded `frame.isHDR` flips) reconfigures here automatically. HDR uses
/// an rgba16Float drawable + BT.2020 PQ colour space + EDR with a 203-nit reference-white anchor;
/// SDR uses the plain 8-bit sRGB path.
public func configure(hdr: Bool) {
guard hdr != hdrActive else { return }
hdrActive = hdr
@@ -273,36 +292,65 @@ public final class MetalVideoPresenter {
#endif
/// Update the HDR mastering metadata (drained from the host's 0xCE datagram) to refine the system
/// tone-map from the real grade. Called from the PUMP thread, so the layer write is hopped to MAIN
/// (every CALayer mutation stays on one thread). The grade is cached so a later SDRHDR
/// `configureColor` re-applies it; the `edrMetadata` write is gated on `hdrActive` (setting it on an
/// SDR layer is harmless but pointless, and the flip will apply it anyway).
/// tone-map from the real grade. Called from the PUMP thread the grade is only PARKED here (lock-
/// guarded); the render thread applies it at the top of the next `render`, keeping every layer
/// colour mutation on the one thread that also vends drawables.
public func setHdrMeta(_ meta: PunktfunkConnection.HdrMeta) {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.lastHdrMeta = meta
// tvOS has no edrMetadata the cached grade is still kept above (harmless), it just can't
// be applied to the layer there. macOS/iOS refine the system tone-map from the real grade.
#if !os(tvOS)
if self.hdrActive { self.layer.edrMetadata = self.makeEDR(meta) }
#endif
}
stagingLock.lock()
pendingHdrMeta = meta
stagingLock.unlock()
}
/// Draw one decoded frame to the next drawable and present it. MAIN THREAD (the display link).
/// `isHDR` selects the 10-bit BT.2020 PQ path vs the 8-bit BT.709 path and is reconciled with the
/// Park the drawable pixel size the shader should render at: the metal layer's laid-out frame ×
/// contentsScale, both owned by the MAIN thread (SessionPresenter.layout pushes it on every layout/
/// backing change). The render thread reads this instead of the layer's geometry so it never
/// touches main-owned CALayer state. Zero until the first layout `render` falls back to the
/// decoded frame size.
public func setDrawableTarget(_ size: CGSize) {
stagingLock.lock()
drawableTarget = size
stagingLock.unlock()
}
/// Draw one decoded frame to the next drawable and present it. RENDER THREAD (Stage2Pipeline's;
/// `nextDrawable()` may block up to a frame that wait belongs here, never on main). `isHDR`
/// selects the 10-bit BT.2020 PQ path vs the 8-bit BT.709 path and is reconciled with the
/// layer config via `configure`. Returns true on success; false when there's no drawable yet, a
/// texture couldn't be made, or Metal errored the caller then doesn't stamp a present (and can
/// requeue the frame). `onPresented` fires once the drawable actually reached glass, with the
/// `CLOCK_REALTIME` instant from the drawable's `presentedTime` or nil when the system reports
/// none (a dropped drawable). It runs on a Metal callback thread; keep the handler thread-safe.
///
/// `presentAtMediaTime` (a `CACurrentMediaTime`-basis host time the display link's
/// `targetTimestamp`) schedules the flip ON the vsync instead of "as soon as the GPU finishes":
/// with the layer's own sync disabled (mandatory on macOS see init) an immediate present hits
/// glass mid-refresh whenever the layer is direct-scanout promoted (fullscreen, no HUD), which
/// is the "frametimes are off with the stats HUD closed" report. nil presents immediately
/// (`PUNKTFUNK_PRESENT_MODE=immediate` the pre-fix behavior, kept as a diagnostic A/B).
@discardableResult
public func render(
_ pixelBuffer: CVPixelBuffer, isHDR: Bool = false,
presentAtMediaTime: CFTimeInterval? = nil,
onPresented: ((Int64?) -> Void)? = nil
) -> Bool {
// Drain the cross-thread staging (see `stagingLock`): the layout-derived drawable size and
// any freshly-arrived HDR grade, both applied from this thread.
stagingLock.lock()
let targetFromLayout = drawableTarget
let newHdrMeta = pendingHdrMeta
pendingHdrMeta = nil
stagingLock.unlock()
// Reconcile the layer with the decoded frame's HDR-ness (handles a mid-session SDRHDR flip).
configure(hdr: isHDR)
if let newHdrMeta {
self.lastHdrMeta = newHdrMeta
// tvOS has no edrMetadata the cached grade is still kept (a later HDR flip's
// configureColor is where it matters there). macOS/iOS refine the live tone-map now.
#if !os(tvOS)
if hdrActive { layer.edrMetadata = makeEDR(newHdrMeta) }
#endif
}
// P010/x444 store 10-bit luma/chroma in 16-bit samples R16/RG16; NV12/444v is 8-bit R8/RG8.
// Derived from the actual decoded buffer so a 4:4:4 (full chroma plane) frame just works.
@@ -319,22 +367,18 @@ public final class MetalVideoPresenter {
pixelBuffer, plane: 1, format: tenBit ? .rg16Unorm : .rg8Unorm, cache: textureCache)
else { return false }
// Size the drawable to the LAYER's pixels (bounds × contentsScale, both set by the hosting
// view's layout) so the Catmull-Rom shader performs the decodedon-screen scale in one pass:
// Size the drawable to the LAYER's pixels (its laid-out frame × contentsScale, pushed here by
// SessionPresenter.layout via `setDrawableTarget` not read off the layer, whose geometry the
// main thread owns) so the Catmull-Rom shader performs the decodedon-screen scale in one pass:
// a native-mode session stays exactly 1:1 (the kernel reduces to the identity texel), and a
// window bigger than the host's mode gets bicubic luma instead of the compositor's bilinear.
// Before the first layout (empty bounds) fall back to the decoded size. drawableSize does NOT
// Before the first layout (zero target) fall back to the decoded size. drawableSize does NOT
// track bounds (defaults to 0), so set it BEFORE nextDrawable; re-set only on a change
// (layout / Reconfigure / HDR flip and every frame of a live resize, which is fine).
let decodedSize = CGSize(
width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer))
let scale = layer.contentsScale
let boundsSize = layer.bounds.size
let targetSize = (boundsSize.width > 0 && boundsSize.height > 0)
? CGSize(
width: (boundsSize.width * scale).rounded(),
height: (boundsSize.height * scale).rounded())
: decodedSize
let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0)
? targetFromLayout : decodedSize
if layer.drawableSize != targetSize { layer.drawableSize = targetSize }
#if DEBUG
logSizeIfChanged(decoded: decodedSize, drawable: targetSize)
@@ -374,7 +418,13 @@ public final class MetalVideoPresenter {
}
#endif
}
commandBuffer.present(drawable) // present at the next vsync lowest latency
// Scheduled on the vsync when the pipeline gave us the link's target (see the doc comment);
// immediate otherwise. A target already in the past presents immediately same thing.
if let presentAtMediaTime {
commandBuffer.present(drawable, atTime: presentAtMediaTime)
} else {
commandBuffer.present(drawable)
}
// Hold the CVMetalTextures + source pixel buffer (its IOSurface) alive until the GPU finishes
// sampling releasing them at scope exit could free the backing mid-read.
commandBuffer.addCompletedHandler { _ in _ = (luma, chroma, pixelBuffer) }
@@ -68,10 +68,13 @@ final class SessionPresenter {
baseLayer.addSublayer(metal)
metalLayer = metal
stage2 = pipeline
// The link is the vsync CLOCK + putBack-retry nudge, not the presentation trigger
// (frame arrival is see Stage2Pipeline's header). timestamptargetTimestamp is the
// link's own report of the current refresh period (tracks VRR rate changes).
let proxy = DisplayLinkProxy { [weak self] link in
self?.stage2?.renderTick(
targetPresentNs: Stage2Pipeline.realtimeNs(
forDisplayLinkTimestamp: link.targetTimestamp))
targetMediaTime: link.targetTimestamp,
period: link.targetTimestamp - link.timestamp)
}
let link = makeDisplayLink(proxy, #selector(DisplayLinkProxy.tick(_:)))
link.add(to: .main, forMode: .common)
@@ -87,23 +90,37 @@ final class SessionPresenter {
}
}
/// Ask the display link for the stream's own cadence. iOS/tvOS-only: without an explicit
/// range, ProMotion devices cap CADisplayLink at 60 Hz (iPhones additionally need
/// Hint the display link with the stream's cadence. On iOS/tvOS a range is always required:
/// without one, ProMotion devices cap CADisplayLink at 60 Hz (iPhones additionally need
/// `CADisableMinimumFrameDurationOnPhone` in Info.plist), so a 120 fps stream would present
/// at half rate with the ring silently dropping every other frame. `maximum` allows up to
/// 120 so the system MAY tick faster than a sub-120 stream (each extra tick is a near-free
/// empty `renderTick`, and presenting on a denser grid shortens the decodeglass wait); the
/// macOS NSView link already tracks its display and must NOT be capped to the stream rate.
/// at half rate with the ring silently dropping every other frame. `maximum` allows up to 120
/// so the system MAY tick faster than a sub-120 stream (each extra tick is a near-free empty
/// `renderTick`, and presenting on a denser grid shortens the decodeglass wait).
///
/// The `allowVRR` setting (default on) widens that hint into a true variable-refresh request:
/// `preferred` = the stream rate with a low floor, so a ProMotion / adaptive-sync display can
/// drop its physical refresh to match the content. With VRR off we fall back to the proven
/// behavior iOS keeps a 30 Hz floor; macOS leaves the NSView link at its display's native
/// rate (it already tracks the display and must NOT be capped to the stream rate).
/// Re-applied from `layout` so a mid-session `Reconfigure` picks up a new refresh.
private func syncFrameRate(hz: UInt32) {
#if !os(macOS)
guard hz > 0, let link = stage2Link else { return }
let hzF = Float(hz)
if link.preferredFrameRateRange.preferred != hzF {
link.preferredFrameRateRange = CAFrameRateRange(
minimum: min(30, hzF), maximum: max(hzF, 120), preferred: hzF)
}
let allowVRR = UserDefaults.standard.object(forKey: DefaultsKey.allowVRR) as? Bool ?? true
#if os(macOS)
// Off: `.default` = the link free-runs at the display's native rate (pre-VRR behavior).
// On: request the content rate with a 24 Hz floor capped at the display, never at the
// stream rate, so an adaptive-sync panel can track the stream.
let range: CAFrameRateRange = allowVRR
? CAFrameRateRange(minimum: min(hzF, 24), maximum: max(hzF, 120), preferred: hzF)
: .default
#else
// A range is mandatory here (see above); VRR only lowers the floor (24 vs 30) so the
// panel can drop deeper to match content on a sub-rate or momentarily stalling stream.
let floor = allowVRR ? min(hzF, 24) : min(hzF, 30)
let range = CAFrameRateRange(minimum: floor, maximum: max(hzF, 120), preferred: hzF)
#endif
if link.preferredFrameRateRange != range { link.preferredFrameRateRange = range }
}
/// Position the stage-2 metal sublayer aspect-fit in the hosting view (the host streams at the
@@ -127,6 +144,11 @@ final class SessionPresenter {
metalLayer.contentsScale = contentsScale
metalLayer.frame = fit
CATransaction.commit()
// Hand the resulting pixel size to the render thread (it must not read layer geometry
// cross-thread) this is what the presenter sizes its drawable to.
stage2?.setDrawableTarget(CGSize(
width: (fit.width * contentsScale).rounded(),
height: (fit.height * contentsScale).rounded()))
}
/// Stop the active pump/pipeline ( one poll timeout; stage-2 joins its pump) and detach the
@@ -1,25 +1,60 @@
// Stage-2 presenter orchestrator: a pump thread pulls AUs VideoDecoder; the decoder's async output
// drops the newest decoded frame into a 1-slot ring; the hosting view's display link calls `renderTick`
// once per vsync to draw + present the newest ready frame and stamp the unified latency stages
// (end-to-end captureon-glass, plus the decode and display stage terms
// design/stats-unification.md). Mirrors StreamPump's lifecycle (one per start; cancel is permanent).
// Stage-2 presenter orchestrator. GOAL ARCHITECTURE (the result of the 2026-07 pacing saga
// read this before touching presentation):
//
// Threading: the pump runs on its own thread; the decoder callback on a VT thread; `renderTick` +
// `start`/`stop` on the MAIN thread (the view's CADisplayLink fires there). Only the ring (lock-guarded)
// and the decoder/presenter (internally locked / main-hopped) cross threads.
// net pump VideoDecoder (VT async) newest-wins 1-slot ring RENDER THREAD CAMetalLayer
//
// The render thread is woken by FRAME ARRIVAL (the decoder callback signals it), never gated on
// the display link: on macOS the WindowServer's damage tracking / FramePacing does not count our
// out-of-band presents, so anything display-link-gated stalls exactly when the rest of the screen
// goes quiet (adaptive-sync displays idle the link down). A decoded frame is always presented
// promptly. The display link remains only as (a) a vsync CLOCK (phase + period, for the opt-in
// V-Sync policy below), (b) a retry tick for a frame that couldn't get a drawable (`putBack`),
// and (c) the iOS ProMotion rate hint.
// The layer's own displaySyncEnabled stays FALSE on macOS synced presents starve the drawable
// pool outright (see MetalVideoPresenter's init for the post-mortem).
// Present policy is a USER SETTING (DefaultsKey.vsync; PUNKTFUNK_PRESENT_MODE=immediate|vsync
// overrides it for A/B), resolved once per session in start():
// V-Sync OFF (default): present immediately lowest latency, the long-proven behavior.
// V-Sync ON: present(at: next vsync) predicted from the link's last phase/period, at most one
// period ahead by construction, falling back to immediate when the link data is stale a
// schedule can never sit far in the future holding drawables hostage.
// Rendering lives on its own thread so any `nextDrawable()` wait lands off-main (input, SwiftUI).
//
// The render thread also stamps the unified latency stages (end-to-end captureon-glass + decode and
// display stage terms design/stats-unification.md). Mirrors StreamPump's lifecycle (one per start;
// cancel is permanent). PUNKTFUNK_PRESENT_DEBUG=1 prints per-second pacing stats (see
// PresentDebugStats).
//
// Threading: the pump runs on its own thread; the decoder callback on a VT thread; the render loop on
// the render thread; `renderTick` + `start`/`stop` on the MAIN thread (the view's CADisplayLink fires
// there). Only the ring (lock-guarded), the vsync clock (lock-guarded), and the decoder/presenter
// (internally locked / staged) cross threads.
#if canImport(Metal) && canImport(QuartzCore)
import AVFoundation
import Foundation
import QuartzCore
/// PUNKTFUNK_PRESENT_DEBUG=1: the render thread prints a once-per-second line with the decode
/// (ring-submit) rate, present rate, failed/empty wakes and the slowest render call for
/// diagnosing pacing regressions without instruments. Plain print: the unbundled CLI client's
/// stdout is the cheapest reliable capture channel.
let presentDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_DEBUG"] == "1"
/// Newest-ready 1-slot ring: the decoder overwrites (drops the older undisplayed frame lowest
/// latency, no smoothing buffer), the display link takes-and-clears. Sendable; lock-guarded.
private final class ReadyRing: @unchecked Sendable {
private let lock = NSLock()
private var frame: ReadyFrame?
/// Ring submissions since the last `drainSubmitted` the decode rate for the
/// PUNKTFUNK_PRESENT_DEBUG stat line.
private var submitted = 0
func submit(_ f: ReadyFrame) {
lock.lock(); frame = f; lock.unlock()
lock.lock(); frame = f; submitted += 1; lock.unlock()
}
func drainSubmitted() -> Int {
lock.lock(); defer { lock.unlock() }
let n = submitted; submitted = 0; return n
}
func take() -> ReadyFrame? {
lock.lock(); defer { lock.unlock() }
@@ -37,6 +72,86 @@ private final class ReadyRing: @unchecked Sendable {
}
}
/// The display's vsync grid as last reported by the display link (target timestamp + period,
/// `CACurrentMediaTime` basis), written on main by `renderTick`, read by the render thread to
/// schedule V-Sync-mode presents. A shared box (like `ReadyRing`) so neither thread captures the
/// pipeline itself. Sendable; lock-guarded.
private final class VsyncClock: @unchecked Sendable {
private let lock = NSLock()
private var target: CFTimeInterval = 0
private var period: CFTimeInterval = 0
func set(target t: CFTimeInterval, period p: CFTimeInterval) {
lock.lock(); target = t; period = p; lock.unlock()
}
/// The next vsync at or after `now`, extrapolated from the last reported phase/period by
/// construction less than one period ahead, so a scheduled present can never sit far in the
/// future holding its drawable. nil ( present immediately) when the link has reported nothing
/// yet, its period is nonsense, or its data is STALE (an idle/suspended link on an
/// adaptive-sync display exactly the case where scheduling onto its grid stalls the stream).
func nextVsync(after now: CFTimeInterval) -> CFTimeInterval? {
lock.lock(); defer { lock.unlock() }
guard period > 0.0005, target > 0, now - target < 0.25 else { return nil }
if target >= now { return target }
return target + ceil((now - target) / period) * period
}
}
/// PUNKTFUNK_PRESENT_DEBUG=1 aggregation: one printed line per second from the render thread with
/// the decode rate, render outcomes, the slowest render call ( nextDrawable wait) and the deltas
/// between system-reported on-glass times (vsync-aligned presents show clean refresh-period
/// multiples; immediate flips scatter). Lock-guarded `presented` lands on a Metal callback thread.
private final class PresentDebugStats: @unchecked Sendable {
private let lock = NSLock()
private var last = CACurrentMediaTime()
private var ok = 0, failed = 0, empty = 0, dropped = 0
private var maxRenderMs = 0.0
private var lastGlassNs: Int64 = 0
private var glassDeltasMs: [Double] = []
func emptyWake() { lock.lock(); empty += 1; lock.unlock() }
func renderReturned(ok rendered: Bool, tookMs: Double) {
lock.lock()
if rendered { ok += 1 } else { failed += 1 }
maxRenderMs = max(maxRenderMs, tookMs)
lock.unlock()
}
func presented(atNs: Int64?) {
lock.lock()
if let atNs {
if lastGlassNs > 0 { glassDeltasMs.append(Double(atNs - lastGlassNs) / 1e6) }
lastGlassNs = atNs
} else {
dropped += 1
}
lock.unlock()
}
func flushIfDue(ring: ReadyRing) {
lock.lock()
let now = CACurrentMediaTime()
guard now - last >= 1 else { lock.unlock(); return }
last = now
let decoded = ring.drainSubmitted()
let deltas = glassDeltasMs.sorted()
let p50 = deltas.isEmpty ? 0 : deltas[deltas.count / 2]
let dMax = deltas.last ?? 0
let line = String(
format: "pf-present decoded=%d ok=%d fail=%d empty=%d dropped=%d "
+ "maxRenderMs=%.1f glassDeltaMs p50=%.2f max=%.2f n=%d",
decoded, ok, failed, empty, dropped, maxRenderMs, p50, dMax, deltas.count)
ok = 0; failed = 0; empty = 0; dropped = 0
maxRenderMs = 0
glassDeltasMs.removeAll(keepingCapacity: true)
lock.unlock()
print(line)
fflush(stdout) // stdout is a pipe when captured flush per line or nothing shows
}
}
public final class Stage2Pipeline {
private let ring = ReadyRing()
private let presenter: MetalVideoPresenter
@@ -54,6 +169,19 @@ public final class Stage2Pipeline {
private let pumpStopped = DispatchSemaphore(value: 0)
private var pumpJoinable = false
/// Render-thread plumbing. `renderSignal` wakes the render thread signalled by the DECODER
/// callback on every frame (the primary trigger: presentation must never be gated on the
/// display link, see the header) and by each display-link tick (the `putBack` retry + the
/// vsync-clock refresh). Signals coalesce harmlessly (an extra wake finds an empty ring and
/// goes back to sleep). `vsyncClock` is the link's last phase/period for V-Sync-mode
/// scheduling. Lock-guarded boxes the render thread, like the pump thread, must not capture
/// `self`, or a missed stop() would leak a spinning pipeline. `renderStopped`/`renderJoinable`
/// mirror the pump's bounded join.
private let renderSignal = DispatchSemaphore(value: 0)
private let vsyncClock = VsyncClock()
private let renderStopped = DispatchSemaphore(value: 0)
private var renderJoinable = false
/// The Metal layer the hosting view installs + sizes.
public var layer: CAMetalLayer { presenter.layer }
@@ -74,6 +202,7 @@ public final class Stage2Pipeline {
self.displayMeter = displayMeter
let ring = ring
let recovery = recovery
let renderSignal = renderSignal
self.decoder = VideoDecoder(
onDecoded: { frame in
// Decode stage = receiveddecoded, both client CLOCK_REALTIME (offset 0 no
@@ -82,6 +211,8 @@ public final class Stage2Pipeline {
decodeMeter?.record(
ptsNs: UInt64(frame.receivedNs), atNs: frame.decodedNs, offsetNs: 0)
ring.submit(frame)
// FRAME ARRIVAL is the render trigger (never the display link see the header).
renderSignal.signal()
},
// Async decode failure (a bad P-frame referencing a lost/corrupt IDR): the pump resets to
// re-gate on the next IDR, and we ask the host to send one now (infinite GOP it wouldn't
@@ -146,7 +277,7 @@ public final class Stage2Pipeline {
}
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
onFrame?(au)
if let f = AnnexB.formatDescription(fromIDR: au.data, codec: connection.videoCodec) {
if let f = connection.videoCodec.formatDescription(fromKeyframe: au.data) {
format = f // refreshed on every IDR (mode changes included)
awaitingIDR = false // a fresh IDR re-anchored decode recovery complete
}
@@ -176,34 +307,89 @@ public final class Stage2Pipeline {
thread.qualityOfService = .userInteractive
pumpJoinable = true
thread.start()
}
/// MAIN thread, once per vsync. Present the newest ready frame (if any). The latency stamps
/// use the drawable's ACTUAL on-glass instant (`addPresentedHandler`/`presentedTime` the
/// handler fires on a Metal callback thread; the meters are thread-safe), falling back to
/// `targetPresentNs` the display link's target present instant, already converted to
/// `CLOCK_REALTIME` (see `realtimeNs(forDisplayLinkTimestamp:)`) when the system reports
/// no presented time (a dropped drawable). A frame that could not be rendered (no drawable
/// yet) goes back into the ring so the next tick retries it.
public func renderTick(targetPresentNs: Int64) {
guard let frame = ring.take() else { return }
let offsetNs = offsetNs
// The render thread: one present per display-link signal. It owns every layer format/colour/
// drawable interaction (see MetalVideoPresenter's threading notes); with displaySyncEnabled on,
// nextDrawable's up-to-a-frame wait lands here instead of on main. The 100 ms timed wait is
// only the stop-flag poll for a session whose link stopped ticking.
let ring = ring
let endToEndMeter = endToEndMeter
let displayMeter = displayMeter
let rendered = presenter.render(frame.pixelBuffer, isHDR: frame.isHDR) { presentedNs in
let atNs = presentedNs ?? targetPresentNs
// End-to-end = captureon-glass, measured directly (skew-corrected via the
// connect-time clock offset) the HUD headline.
endToEndMeter?.record(ptsNs: frame.ptsNs, atNs: atNs, offsetNs: offsetNs)
// Display stage = decoded on-glass. Both instants are client CLOCK_REALTIME,
// so no skew offset applies.
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
let offsetNs = offsetNs
let renderSignal = renderSignal
let renderStopped = renderStopped
// Present policy the user's V-Sync setting (default OFF = immediate, the long-proven
// lowest-latency behavior); PUNKTFUNK_PRESENT_MODE=immediate|vsync overrides it for A/B.
// Resolved once per session.
let presentMode = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_MODE"]
let vsyncEnabled = presentMode == "vsync"
|| (presentMode != "immediate"
&& UserDefaults.standard.bool(forKey: DefaultsKey.vsync))
let debugStats = presentDebug ? PresentDebugStats() : nil
let vsyncClock = vsyncClock
let renderThread = Thread {
defer { renderStopped.signal() }
while !token.isStopped {
if renderSignal.wait(timeout: .now() + .milliseconds(100)) == .timedOut {
debugStats?.flushIfDue(ring: ring)
continue
}
guard !token.isStopped, let frame = ring.take() else {
debugStats?.emptyWake()
debugStats?.flushIfDue(ring: ring)
continue
}
// 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.
let presentAt = vsyncEnabled
? vsyncClock.nextVsync(after: CACurrentMediaTime()) : nil
let renderStarted = CACurrentMediaTime()
let rendered = presenter.render(
frame.pixelBuffer, isHDR: frame.isHDR, presentAtMediaTime: presentAt
) { presentedNs in
// Fallback stamp for a dropped drawable (no system presentedTime): "now" on
// the Metal callback, converted to the CLOCK_REALTIME the meters live in.
let atNs = presentedNs
?? Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: CACurrentMediaTime())
// End-to-end = captureon-glass, measured directly (skew-corrected via the
// connect-time clock offset) the HUD headline.
endToEndMeter?.record(ptsNs: frame.ptsNs, atNs: atNs, offsetNs: offsetNs)
// Display stage = decoded on-glass. Both instants are client CLOCK_REALTIME,
// so no skew offset applies.
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
debugStats?.presented(atNs: presentedNs)
}
debugStats?.renderReturned(
ok: rendered, tookMs: (CACurrentMediaTime() - renderStarted) * 1000)
if !rendered { ring.putBack(frame) }
debugStats?.flushIfDue(ring: ring)
}
}
if !rendered { ring.putBack(frame) }
renderThread.name = "punktfunk-stage2-render"
renderThread.qualityOfService = .userInteractive
renderJoinable = true
renderThread.start()
}
/// Stop the pump ( one poll timeout) and drop the decode session. MAIN THREAD; idempotent. Does not
/// close the connection. A restart needs a fresh Stage2Pipeline (the stop is permanent).
/// MAIN thread, once per display-link tick: refresh the vsync clock (V-Sync-mode scheduling)
/// and nudge the render thread. The nudge is NOT the presentation trigger frame arrival is
/// (see the header) it only retries a frame a transient `nextDrawable` failure put back into
/// the ring, which matters under the host's infinite GOP where a static scene sends no
/// replacement frame.
public func renderTick(targetMediaTime: CFTimeInterval, period: CFTimeInterval) {
vsyncClock.set(target: targetMediaTime, period: period)
renderSignal.signal()
}
/// Forward the layout-derived drawable pixel size to the presenter (MAIN thread see
/// `MetalVideoPresenter.setDrawableTarget`).
public func setDrawableTarget(_ size: CGSize) {
presenter.setDrawableTarget(size)
}
/// Stop the pump + render thread ( one poll timeout each) and drop the decode session. MAIN
/// THREAD; idempotent. Does not close the connection. A restart needs a fresh Stage2Pipeline
/// (the stop is permanent).
public func stop() {
token.stop()
// Join the pump (bounded: one nextAU poll + an in-flight decode) before resetting the decoder,
@@ -213,11 +399,22 @@ public final class Stage2Pipeline {
pumpJoinable = false
_ = pumpStopped.wait(timeout: .now() + 0.5)
}
// Wake + join the render thread (bounded: it may sit in `nextDrawable` for up to ~a frame; a
// timed-out join is fine the loop exits at its next stop-flag check, and a final present on
// the detached layer is harmless).
if renderJoinable {
renderJoinable = false
renderSignal.signal()
_ = renderStopped.wait(timeout: .now() + 0.5)
}
decoder.reset()
recovery.bind(nil) // stop requesting keyframes once the session is torn down
}
deinit { token.stop() }
deinit {
token.stop()
renderSignal.signal() // wake the render thread so it can observe the stop and exit
}
/// Convert a `CADisplayLink.targetTimestamp` (CACurrentMediaTime basis) to a `CLOCK_REALTIME`
/// nanosecond instant the present clock the AU pts + skew offset live in. Projects to the target
@@ -71,7 +71,7 @@ final class StreamPump {
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
onFrame?(au)
let idrFormat = AnnexB.formatDescription(fromIDR: au.data, codec: connection.videoCodec)
let idrFormat = connection.videoCodec.formatDescription(fromKeyframe: au.data)
if let f = idrFormat {
format = f // refreshed on every IDR (mode changes included)
if awaitingIDR {
@@ -95,7 +95,7 @@ final class StreamPump {
}
wasFailed = failed
guard let f = format,
let sample = AnnexB.sampleBuffer(au: au, format: f, codec: connection.videoCodec),
let sample = connection.videoCodec.sampleBuffer(au: au, format: f),
!token.isStopped // don't enqueue a stale frame after a restart
else { continue }
layer.enqueue(sample)
@@ -1,4 +1,5 @@
// Stage-2 presenter, decode half: explicit VideoToolbox decode of the host's HEVC AUs.
// Stage-2 presenter, decode half: explicit VideoToolbox decode of the host's AUs (H.264 /
// HEVC / AV1 whatever the Welcome resolved).
//
// Stage-1 hands compressed samples to AVSampleBufferDisplayLayer, which decodes AND presents
// internally with no per-frame callback so neither decode-completion nor present can be
@@ -61,8 +62,8 @@ public final class VideoDecoder: @unchecked Sendable {
/// depth / HDR). Read inside `createSessionLocked` under `lock`.
private var chroma444 = false
/// The negotiated codec (`connection.videoCodec`), set once at session start. Drives the AnnexB
/// NAL parsing (H.264 vs HEVC parameter sets). Read under `lock`.
/// The negotiated codec (`connection.videoCodec`), set once at session start. Drives the
/// bitstream framing (H.264/HEVC NAL parsing vs AV1 OBU repack). Read under `lock`.
private var codec: VideoCodec = .hevc
public init(
@@ -84,8 +85,8 @@ public final class VideoDecoder: @unchecked Sendable {
lock.unlock()
}
/// Select the negotiated codec (H.264 vs HEVC). Call once at session start, before decoding,
/// from `connection.videoCodec`. Thread-safe.
/// Select the negotiated codec (H.264 / HEVC / AV1). Call once at session start, before
/// decoding, from `connection.videoCodec`. Thread-safe.
public func setCodec(_ c: VideoCodec) {
lock.lock()
codec = c
@@ -93,8 +94,9 @@ public final class VideoDecoder: @unchecked Sendable {
}
/// Submit one AU for asynchronous decode, (re)creating the session if `format` changed. The
/// caller resolves `format` from the IDR exactly as stage-1 does (`AnnexB.formatDescription`).
/// Returns false if the session couldn't be created or the frame couldn't be submitted.
/// caller resolves `format` from the keyframe exactly as stage-1 does
/// (`VideoCodec.formatDescription(fromKeyframe:)`). Returns false if the session couldn't be
/// created or the frame couldn't be submitted.
@discardableResult
public func decode(au: AccessUnit, format newFormat: CMVideoFormatDescription) -> Bool {
lock.lock()
@@ -112,7 +114,7 @@ public final class VideoDecoder: @unchecked Sendable {
// invalidate the session between here and DecodeFrame. The VT output callback takes the
// ring lock, not this one, so there's no re-entrancy. DecodeFrame is async non-blocking.
guard let session,
let sample = AnnexB.sampleBuffer(au: au, format: newFormat, codec: codec)
let sample = codec.sampleBuffer(au: au, format: newFormat)
else { lock.unlock(); return false }
var infoOut = VTDecodeInfoFlags()
let status = VTDecompressionSessionDecodeFrame(
@@ -199,13 +201,14 @@ public final class VideoDecoder: @unchecked Sendable {
var callback = VTDecompressionOutputCallbackRecord(
decompressionOutputCallback: decoderOutputCallback,
decompressionOutputRefCon: Unmanaged.passUnretained(self).toOpaque())
// 4:4:4 sessions REQUIRE a hardware decoder: we only advertise 4:4:4 when the hardware probe
// passed, so a hardware-incapable mode (e.g. a resolution past the HW 4:4:4 ceiling) must fail
// HERE, synchronously, letting the pump's backstop end the session rather than silently
// falling back to a software 4:4:4 decoder far too slow for a real-time stream. 4:2:0 keeps the
// software fallback (nil spec) as a robustness net.
// 4:4:4 and AV1 sessions REQUIRE a hardware decoder: both are only advertised when the
// hardware gate passed (the 4:4:4 probe / `AV1.hardwareDecodeSupported`), so a
// hardware-incapable mode (e.g. a resolution past a HW ceiling) must fail HERE,
// synchronously, letting the pump's backstop end the session rather than silently
// falling back to a software decoder far too slow for a real-time stream. 4:2:0
// H.264/HEVC keeps the software fallback (nil spec) as a robustness net.
let spec: CFDictionary? =
chroma444
chroma444 || codec == .av1
? [kVTVideoDecoderSpecification_RequireHardwareAcceleratedVideoDecoder: true] as CFDictionary
: nil
var newSession: VTDecompressionSession?
@@ -7,10 +7,11 @@
//
// The view also owns the input-capture state machine (Moonlight-style): capture is a
// deliberate, reversible state engaged when the stream starts and when the user clicks
// into the video, released by or focus loss, and NEVER engaged by mere app
// activation (the click that activates the window may be a title-bar drag or a resize
// warping the cursor there is exactly the intrusiveness this design removes). While
// released, nothing is forwarded to the host and the local cursor is free.
// into the video, released by Q (the cross-client Ctrl+Alt+Shift+Q), , or focus
// loss, and NEVER engaged by mere app activation (the click that activates the window may
// be a title-bar drag or a resize warping the cursor there is exactly the intrusiveness
// this design removes). While released, nothing is forwarded to the host and the local
// cursor is free.
//
// macOS-first (NSViewRepresentable); the iOS variant is the same layer under
// UIViewRepresentable.
@@ -83,6 +84,7 @@ public struct StreamView: NSViewRepresentable {
private let connection: PunktfunkConnection
private let captureEnabled: Bool
private let onCaptureChange: ((Bool) -> Void)?
private let onDisconnectRequest: (() -> Void)?
private let onFrame: (@Sendable (AccessUnit) -> Void)?
private let onSessionEnd: (@Sendable () -> Void)?
private let endToEndMeter: LatencyMeter?
@@ -93,14 +95,17 @@ public struct StreamView: NSViewRepresentable {
/// `captureEnabled: false` disables input capture entirely while UI (e.g. a trust
/// prompt) is layered over the stream; flipping it to true auto-engages capture
/// once. `onCaptureChange` (main thread) reports engage/release drive the HUD's
/// "click to capture" / " releases" hint with it. The meters record the unified latency
/// stages when the stage-2 presenter is active (design/stats-unification.md):
/// `endToEndMeter` captureon-glass, `decodeMeter` receiveddecoded, `displayMeter`
/// decodedon-glass.
/// "click to capture" / "Q releases" hint with it. `onDisconnectRequest` (main
/// thread) fires on the reserved D combo while captured the owner ends the
/// session (released, the same combo reaches the Stream menu instead). The meters
/// record the unified latency stages when the stage-2 presenter is active
/// (design/stats-unification.md): `endToEndMeter` captureon-glass, `decodeMeter`
/// receiveddecoded, `displayMeter` decodedon-glass.
public init(
connection: PunktfunkConnection,
captureEnabled: Bool = true,
onCaptureChange: ((Bool) -> Void)? = nil,
onDisconnectRequest: (() -> Void)? = nil,
onFrame: (@Sendable (AccessUnit) -> Void)? = nil,
onSessionEnd: (@Sendable () -> Void)? = nil,
endToEndMeter: LatencyMeter? = nil,
@@ -110,6 +115,7 @@ public struct StreamView: NSViewRepresentable {
self.connection = connection
self.captureEnabled = captureEnabled
self.onCaptureChange = onCaptureChange
self.onDisconnectRequest = onDisconnectRequest
self.onFrame = onFrame
self.onSessionEnd = onSessionEnd
self.endToEndMeter = endToEndMeter
@@ -120,6 +126,7 @@ public struct StreamView: NSViewRepresentable {
public func makeNSView(context: Context) -> StreamLayerView {
let view = StreamLayerView()
view.onCaptureChange = onCaptureChange
view.onDisconnectRequest = onDisconnectRequest
view.captureEnabled = captureEnabled
view.endToEndMeter = endToEndMeter
view.decodeMeter = decodeMeter
@@ -130,6 +137,7 @@ public struct StreamView: NSViewRepresentable {
public func updateNSView(_ view: StreamLayerView, context: Context) {
view.onCaptureChange = onCaptureChange
view.onDisconnectRequest = onDisconnectRequest
view.captureEnabled = captureEnabled
view.endToEndMeter = endToEndMeter
view.decodeMeter = decodeMeter
@@ -189,6 +197,10 @@ public final class StreamLayerView: NSView {
/// Reports engage/release on the main thread.
public var onCaptureChange: ((Bool) -> Void)?
/// Fired (main thread) when the captured-state D combo asks to end the session the
/// view can't do that itself (the connection's owner disconnects).
public var onDisconnectRequest: (() -> Void)?
/// Main-thread only. False = input capture disabled outright (UI layered over the
/// stream); flipping to true auto-engages once.
public var captureEnabled = true {
@@ -215,6 +227,16 @@ public final class StreamLayerView: NSView {
) { [weak self] _ in
self?.releaseCapture()
})
// The Stream menu's "Release Mouse" item (Q's discoverable menu-bar surface). Only
// the key window's stream may act same ownership rule as the toggle. (While
// captured the combo never reaches the menu InputCapture's monitor handles it so
// in practice this fires only as a not-captured no-op; wired for honesty.)
appObservers.append(NotificationCenter.default.addObserver(
forName: .punktfunkReleaseCapture, object: nil, queue: .main
) { [weak self] _ in
guard let self, self.window?.isKeyWindow == true else { return }
self.releaseCapture()
})
}
public required init?(coder: NSCoder) { fatalError("not used") }
@@ -562,6 +584,24 @@ public final class StreamLayerView: NSView {
// (see the cursorVisible resolution below): toggling it on under gamescope's relative-only
// input traps the pointer. Restore this body when absolute/synthetic-cursor support lands.
capture.onToggleCursor = {}
// The cross-client combos (Q/D/S Ctrl+Alt+Shift on the other clients), delivered by
// the monitor only while captured; the same key-window ownership rule as throughout.
capture.onReleaseCapture = { [weak self] in
guard let self, self.window?.isKeyWindow == true else { return }
self.releaseCapture()
}
capture.onDisconnect = { [weak self] in
guard let self, self.window?.isKeyWindow == true else { return }
self.onDisconnectRequest?()
}
capture.onToggleStats = { [weak self] in
guard self?.window?.isKeyWindow == true else { return }
// Flip the shared setting directly every @AppStorage reader (the HUD's visibility,
// the menu item's title) observes UserDefaults, so this is the same as the menu path.
let defaults = UserDefaults.standard
let current = defaults.object(forKey: DefaultsKey.hudEnabled) as? Bool ?? true
defaults.set(!current, forKey: DefaultsKey.hudEnabled)
}
capture.start()
inputCapture = capture

Some files were not shown because too many files have changed in this diff Show More