f2e1b9872c3ec9ecb810604f49ed7ff7ec115bec
190
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
7cf71dd218 |
feat(clients): the Apple deadline presenter reports its latch phase
Phase-locked capture had no client half on Apple — the host's v3 controller (grid-locked submits, coherence-gated engage) shipped with only the Android reporter feeding it. Now the stage-4 link thread flushes the same v2 circular arrival-phase statistic at ~1 Hz: - PhaseReporter (Stage2Pipeline): the decode callback deposits per-AU reassembly-completion stamps, the CAMetalDisplayLink update deposits the latch grid (period = window-min of update spacing), and the flush ports punktfunk_core::phase::circular_latch verbatim — a period-smeared Wi-Fi link reads coherence ≈ 0 and the host correctly never engages; a wired link opens the gate. Binds/unbinds per session like DecodeReport. - PunktfunkConnection.reportPhase wraps the existing ABI entry point. - Hello honesty: iOS/tvOS advertise CLIENT_CAP_PHASE_LOCK (macOS stays without — the stage-2 arrival presenter has no latch grid), Android now sets the bit its reporter already earned, and the ABI grows the PUNKTFUNK_CLIENT_CAP_PHASE_LOCK mirror const (header regenerated). Advisory in v1: the host arms on report receipt. - Stage-3 doc comment no longer calls itself the tvOS default (stage-4 took that over in the 2026-07 rebuild). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c002ca8746 |
fix(clients): pen batches split at the wire cap and the heartbeat survives tracking mode
Three drawing-path defects on the pen clients: - The Apple in-range heartbeat was re-armed on every emit (~120 run-loop mutations/s during a stroke) and lived in .default run-loop mode only — a tracking-mode excursion >200 ms with a stationary pen crossed the host's force-release failsafe and dropped the held stroke. Now one long-lived 50 ms timer in .common mode, resending only after ≥50 ms of send silence. - Both clients TRUNCATED an over-8-sample coalesced run instead of splitting it into consecutive batches (the send_pen contract): Apple suffix(8) dropped the oldest samples, the Android JNI clamped count. An over-cap run means the UI thread hitched — exactly when dropping stroke geometry hurts most. Apple splits in emit(); the Android JNI loops send_pen over ≤8-sample chunks (Kotlin's per-emit ceiling is now 64 = a >250 ms stall). - The iOS letterbox mapper did a lock+FFI currentMode() read per coalesced sample on the main thread, contending the ABI lock the batch send takes next — now a 250 ms TTL cache (mid-stream requestMode still tracked). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
780791ed81 |
feat(android): the presenter's latch margin starts at zero and earns its width
The 2.5 ms submit margin ahead of expected present assumed SurfaceFlinger latches well before the vsync. On-glass (NP3, 120 Hz) that lead is not needed: with the margin at zero every release still made its vsync (paced=0 across full sessions) - meaning the constant was 2.5 ms of pure display latency on every frame, and it was quantizing away exactly the readiness gains the slice-overlap pipeline just bought. e2e on the Linux-host pairing dropped from 22-24 ms to 14-18 ms. The margin is now adaptive instead of assumed: it starts at 0 and only widens (+500 us per 1 Hz window, capped at the old 2.5 ms) when the paced counter shows real latch misses - one-way per stream, so a margin that proved necessary is never re-gambled mid-session. A device that needs lead converges within seconds; a device that doesn't keeps the full win. debug.punktfunk.latch_margin_us (0..=8000) pins the margin for rebuild-free sweeps, and the presenter logs the resolved mode at stream start. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
4240b76182 |
feat(android): slices feed the decoder as they arrive, not when the AU closes
The decode loop used to hold every access unit until its last wire packet landed. With slice-progressive delivery (frame_parts) each newly contiguous piece now goes straight into MediaCodec under BUFFER_FLAG_PARTIAL_FRAME, the closing piece drops the flag, and the decoder chews the front of the frame while its tail is still on the wire. Feeding partial input means owning its failure modes without a codec flush: a broken sequence (dropped piece, orphan, oversize) closes the dead AU with an empty non-partial buffer at its own pts, arms the re-anchor freeze so the concealed output never reaches glass, and requests a recovery keyframe - the same machinery ordinary loss already rides. Per-AU accounting keeps its units: the RFI gap detector notes an AU once, and the HUD stamps, host/network split and the phase-lock arrival sensor ride only the completing delivery. The opt-in is decoder truth and loop truth: every decoder this device would use must pass FEATURE_PartialFrame, and only the async loop may see parts - the legacy sync loop feeds whole AUs and stays that way. Smoke-checked on-glass (NP3 vs the Windows host): byte-identical degenerate path, presenter profile unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
de8430097e |
chore(clients): the bundled licence notices catch up with the root file
ci / web (push) Successful in 1m22s
ci / rust-arm64 (push) Successful in 1m31s
decky / build-publish (push) Failing after 7s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
ci / docs-site (push) Successful in 1m17s
ci / rust (push) Failing after 2m14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 28s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m2s
apple / swift (push) Successful in 3m35s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m28s
deb / build-publish-client-arm64 (push) Successful in 4m4s
docker / builders-arm64cross (push) Successful in 8s
deb / build-publish-host (push) Successful in 5m29s
deb / build-publish (push) Successful in 4m2s
android / android (push) Successful in 7m13s
docker / deploy-docs (push) Successful in 39s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m31s
arch / build-publish (push) Successful in 9m20s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 2m54s
flatpak / build-publish (push) Successful in 6m29s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 3m59s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m40s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m52s
apple / screenshots (push) Successful in 20m57s
windows-host / package (push) Successful in 14m16s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 24s
release / apple (push) Successful in 25m29s
The Apple and Android apps carry their own copy of THIRD-PARTY-NOTICES.txt and show it on the acknowledgements screen. `7fc2775f` refreshed the root file but not the copies — running the python generator directly skips the sync `scripts/gen-third-party-notices.sh` does — so they had drifted to a 531-crate snapshot that predates the whole vendored-source section. That section is where the OS marks' attribution lives, so both apps have been shipping Font Awesome's CC BY 4.0 icons with no attribution visible at all. Re-synced, which also carries the Bazzite Apache-2.0 notice the previous commit adds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5520167958 |
feat(clients): the OS marks tell gaming distros apart, and Windows looks current
Three things were wrong with the host-card OS icons. The Windows mark was Font Awesome 5's, which is still the Windows 8/10 flag with the perspective skew — dated next to the flat four-pane mark Microsoft has shipped since Windows 11. No icon set has the current one (Simple Icons carries no windows/microsoft slug at all), so it is drawn here: four equal squares at the authentic 11.377 + 1.246 proportion. The Decky plugin was pulling FaWindows straight from react-icons, so it now inlines the masters like the web console does, or it would have kept the old flag regardless. Bazzite, CachyOS and Nobara collapsed onto their family's mark. The host already advertises the full chain, so this is purely missing art: all three now ship a leaf mark, because "a Bazzite box" and "a Fedora box" are different machines to the person reading the card. CachyOS and Nobara come from Simple Icons; Bazzite has no icon anywhere, so its "b" is lifted out of the project's own badge (Apache-2.0, attributed). On Android every non-square mark was stretched. A VectorPainter maps the viewport onto the ImageVector's default size with independent x and y scales, so declaring a 448x512 Tux as 24x24 dp squashed it — silently, no crash, no warning. The longest viewport edge now sets the 24 dp box and the other follows the ratio, which is what Icon()'s ContentScale.Fit expects. A unit test pins the invariant; every other client was already correct. Also: scripts/gen-os-icons.sh replaces the undocumented hand-run pipeline that turns a master into the GTK symbolic SVG, the Windows PNG and the Apple template PDF. It reproduces the committed artifacts byte-identically. Verified: web and Decky typecheck, Decky bundles, Android compiles and its tests pass, PunktfunkKit builds, osinfo's tests pass. Not verified on glass, and the GTK/Windows client crates do not build on macOS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f4f83202cb |
feat(core/client): an AU's prefix reaches the decoder while its tail is on the wire
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
ci / web (push) Successful in 1m13s
apple / swift (push) Successful in 1m21s
ci / rust (push) Failing after 2m14s
android / android (push) Canceled after 2m26s
apple / screenshots (push) Canceled after 58s
arch / build-publish (push) Canceled after 2m42s
ci / docs-site (push) Successful in 2m36s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
ci / rust-arm64 (push) Canceled after 2m47s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
deb / build-publish (push) Canceled after 2m50s
deb / build-publish-host (push) Canceled after 1m38s
deb / build-publish-client-arm64 (push) Canceled after 41s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 9s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 10s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 25s
release / apple (push) Canceled after 0s
windows-host / package (push) Canceled after 3m36s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 7s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Delivery used to be all-or-nothing: the decoder saw byte 0 only after the last packet of the AU landed, so the whole transmit time sat in front of decode. With the slice-streamed wire (previous commit) blocks now arrive addressable, and a client can opt in (connect's new frame_parts) to receive each AU's newly-contiguous prefix as Frame::part pieces - offset tiling, first/last marked, the completing push carrying only the suffix. A PARTIAL_FRAME-capable decoder then chews slices concurrently with the remaining network transfer. The reassembler walks a per-frame cursor over successfully-completed blocks (failed FEC reconstructs don't advance it), coalesces blocks that finished out of order into one part, keeps probe filler whole, and stops short of the final block so the zero-padded tail still trims at completion. Whole-frame consumers see byte-identical behavior - parts never flow without the opt-in, and never on PyroWave (its newest-wins draining assumes whole AUs). Per-AU accounting keeps its units: OWD/ABR feeds, the inter-arrival series and the clock-based jump-to-live detector only count completing deliveries, and FrameChannel::depth() counts AUs so a part-rich queue can't trip jump-to-live at a fraction of the real backlog. The consumer contract (gap or orphan part = AU lost: abandon, flush, resync on the next first) is documented on FramePart; the C ABI keeps parts off until PunktfunkFrame can express them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
43868af1f5 |
feat(android): the client advertises multi-slice tolerance per-decoder
The P0 gating branch deliberately left Android off VIDEO_CAP_MULTI_SLICE
("embedder-set decoder truth") — this is that deferred item, and the P2
slice-pipeline prerequisite. VideoDecoders.multiSliceTolerant() probes the
pick for every advertised codec: tolerant unless any pick is an Amlogic
decoder (the 0.17.0 device-rebooting wedge) or uninspectable (a null pick =
platform default → conservatively single-slice). nativeConnect carries the
bit; the JNI ORs it into the Hello's video_caps beside the panel-truth
HDR bits. Phones (Qualcomm/Exynos) advertise it; the Amlogic TV boxes the
gate exists for never will.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
759aac255b |
feat(phase-lock): controller v3 — grid-locked submits, arrival sensing, antipode damping
ci / docs-site (push) Successful in 1m17s
ci / web (push) Successful in 2m14s
ci / rust-arm64 (push) Successful in 2m54s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 19s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 25s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
deb / build-publish-client-arm64 (push) Successful in 2m59s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
apple / swift (push) Successful in 4m48s
deb / build-publish-host (push) Successful in 4m51s
android / android (push) Successful in 5m31s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 32s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 31s
docker / builders-arm64cross (push) Successful in 12s
docker / deploy-docs (push) Successful in 35s
deb / build-publish (push) Successful in 6m0s
ci / rust (push) Successful in 9m53s
arch / build-publish (push) Successful in 10m32s
windows-host / package (push) Successful in 19m10s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 15s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m17s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m2s
apple / screenshots (push) Successful in 21m3s
Midday on-glass falsified v2's remaining assumptions: a per-frame additive hold SATURATES the arrival-slaved loop once hold + work >= interval — submits free-run at their own cadence and the commanded phase dissolves (measured: +-2 ms hold steps, zero client response) — and the latch statistic hovers at the target's ANTIPODE, where shortest-way errors sign-flip on noise (the 0<->2<->4 ms offset chatter). v3, one change per falsified assumption: - ACTUATOR: submits lock to an absolute grid (epoch + k x period + offset); the controller walks only the offset. A periodic grid cannot free-run — occupancy is one frame per period whatever the offset — so phase actuation is linear BY CONSTRUCTION. Disengaged = no grid sleeps = zero cost; every failure path DISENGAGES (never parks: v2's e2e-tax lesson). - SENSOR: the client reports the circular mean + coherence of the ARRIVAL lead (per-AU reassembly stamps vs the panel latch grid) — the phase the host actually controls; latch measured downstream of the decoder pipeline, which absorbed the actuation. pf.phase logs "arrival lead circ= coh=". - ANTIPODE DAMPING: errors within 1 ms of +-period/2 take half-steps until they commit to a side. Harness rewritten around plants glass validated: a GRID plant (linear by construction), the DECOUPLED plant (v2's saturation — must disengage, not orbit or park), an antipode start (must converge within one period of travel), incoherence (never engages), a regime change (re-engages after backoff), and the actuator's own periodicity/offset-linearity. Also fixes the harness's SIM_TARGET (a mis-derived max asserted 3.5 ms where the controller's actual target is 2.5 — the controller was right, the ruler was wrong; the Python cross-check inherited the same constant, a lesson in replicating the CODE, not the author's belief about it). Gates: v3 simulations 6/6; host bin suite 345/1 (the documented environmental qemu UDP test); clippy --all-targets -D warnings clean; cargo ndk arm64 check clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
4457356ee4 |
test(phase-lock): a closed-loop simulation harness — both on-glass failures become CI regressions
ci / web (push) Successful in 1m7s
ci / docs-site (push) Successful in 1m14s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 23s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 27s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
ci / rust-arm64 (push) Successful in 2m48s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 22s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 25s
deb / build-publish-client-arm64 (push) Successful in 2m48s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m1s
android / android (push) Successful in 5m31s
deb / build-publish-host (push) Successful in 6m5s
docker / builders-arm64cross (push) Successful in 18s
apple / swift (push) Successful in 4m41s
docker / deploy-docs (push) Successful in 46s
ci / rust (push) Successful in 6m45s
deb / build-publish (push) Successful in 5m9s
arch / build-publish (push) Successful in 9m4s
flatpak / build-publish (push) Successful in 6m54s
windows-host / package (push) Successful in 11m3s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 25s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m10s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m35s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m27s
release / apple (push) Successful in 25m17s
apple / screenshots (push) Successful in 21m0s
The controller iterated twice on the user's gaming PC before this existed; never again. punktfunk_core::phase::circular_latch is extracted from the Android presenter (shared: iOS reports next, and the harness generates its synthetic reports through the IDENTICAL statistic the clients ship), with unit tests including the wrap-straddling cluster an arithmetic mean gets maximally wrong. The stream.rs harness models the plant — latch = (base − hold + noise) mod P, deterministic LCG noise — and drives PhaseController::adjust through five regimes: - tight jitter locks into the deadband within ~5 adjusts and stops moving; - a wrap-side start takes the SHORTEST way (travel < P/2 asserted); - an incoherent phase never steps (zero hold, ever); - a v1 pinned-median report trips the travel budget and DECAYS TO ZERO — the 2026-07-31 orbit and the parked-hold e2e tax, both as asserts; - a post-decay regime tightening re-locks. Gates: host bin suite 344 passed / 1 failed = the documented environmental qemu UDP-loopback test (clean main fails identically in this container); core phase tests 4/4; clippy --all-targets -D warnings clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
fe552a50bb |
fix(android): the phase reporter actually sends the v2 circular statistic
ci / rust-arm64 (push) Successful in 2m45s
ci / web (push) Successful in 1m15s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
ci / docs-site (push) Successful in 1m29s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 37s
docker / builders-arm64cross (push) Successful in 18s
android / android (push) Successful in 6m17s
ci / rust (push) Successful in 9m44s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 12m57s
docker / deploy-docs (push) Canceled after 0s
The v2 client edit missed its anchor against rustfmt's reshaping and the reporter kept sending the v1 median — caught by the arm64 build the moment the tuple signature landed. Sends (circular mean, coherence) now. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
1d31e4c565 |
feat(phase-lock): controller v2 — circular phase, coherence gate, decay-to-zero
ci / web (push) Successful in 1m9s
ci / docs-site (push) Successful in 1m34s
ci / rust-arm64 (push) Successful in 1m39s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5s
android / android (push) Failing after 2m21s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 17s
ci / rust (push) Canceled after 2m52s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 34s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
apple / swift (push) Successful in 4m47s
deb / build-publish-client-arm64 (push) Successful in 3m59s
deb / build-publish-host (push) Successful in 4m29s
deb / build-publish (push) Successful in 5m59s
flatpak / build-publish (push) Successful in 4m58s
arch / build-publish (push) Successful in 10m18s
windows-host / package (push) Successful in 11m6s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 33s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m8s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m9s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m41s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m46s
apple / screenshots (push) Canceled after 0s
release / apple (push) Canceled after 19m16s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 21m16s
Round-1 on-glass falsified two v1 assumptions in one morning (NP3 ↔ .173): - A median lead is immovable under period-spanning jitter (uniform mod P), so v1 orbited the period at 2 ms/s forever — and the dead-signal freeze hotfix never armed, because ±1.5 ms of window sampling noise kept resetting its 'error responded' check. Thresholds can't referee noise. - A HELD hold is not free: it delays sampling after the capture stamp, taxing e2e by up to a period (user-measured: ~+4 ms during the orbit). The correct failure response is DECAY TO ZERO, never freeze-in-place. v2, all three layers: - Wire: PhaseReport grows a length-discriminated coherence tail (27-byte form; the u16::MAX sentinel encodes as the byte-identical 25-byte v1 — strict-prefix discipline, both forms pinned by tests). - Client: the reporter sends the CIRCULAR vector-mean latch phase mod the panel period + its coherence (‰); pf.present logs circ=/coh=. - Host: steps only while coherent (floor 300‰; a v1 report bypasses the gate), along the signed SHORTEST way around the period, under a cumulative 1.25-period travel budget that is noise-immune by construction (it integrates applied steps, not reported errors); incoherence or budget exhaustion decays the hold to zero and re-arms once flat. Deadband convergence resets the budget — tight regimes lock exactly as designed. report_phase + the C ABI mirror gain the coherence parameter (canary-only ABI, hours old, sole caller in-tree). Gates: docker amd64 clippy --all-targets -D warnings (host+core, nvenc) clean; core suite 244/244; cargo ndk arm64 check clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a5896f0883 |
feat(android): the phase-lock ACK is logged — the closed loop reads out in logcat
ci / web (push) Successful in 1m6s
ci / docs-site (push) Successful in 2m8s
deb / build-publish (push) Successful in 4m17s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
deb / build-publish-host (push) Successful in 4m25s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
ci / rust-arm64 (push) Successful in 5m46s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
deb / build-publish-client-arm64 (push) Successful in 3m40s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 31s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 30s
docker / builders-arm64cross (push) Successful in 11s
docker / deploy-docs (push) Successful in 50s
arch / build-publish (push) Successful in 7m59s
android / android (push) Successful in 8m20s
ci / rust (push) Successful in 9m33s
apple / swift (push) Successful in 5m3s
windows-host / package (push) Successful in 12m57s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 30s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m30s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m59s
apple / screenshots (push) Successful in 21m18s
The host's applied hold rides the 0xCF tail but nothing client-side showed it; 'adb logcat -s pf.phase' now logs transitions. This line is what exposed the dead-median controller orbit on the first on-glass run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
fa822744ff |
feat(core/host/android): phase-locked capture — frames arrive on the client's latch schedule
ci / web (push) Successful in 1m59s
ci / docs-site (push) Successful in 2m13s
ci / rust-arm64 (push) Successful in 5m17s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 4s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 4s
android / android (push) Successful in 5m52s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
deb / build-publish (push) Successful in 5m38s
ci / rust (push) Failing after 5m52s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 18s
arch / build-publish (push) Failing after 6m7s
deb / build-publish-client-arm64 (push) Successful in 3m55s
docker / builders-arm64cross (push) Successful in 19s
deb / build-publish-host (push) Failing after 4m24s
apple / swift (push) Successful in 4m35s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m13s
docker / deploy-docs (push) Successful in 31s
windows-host / package (push) Failing after 6m22s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m35s
flatpak / build-publish (push) Successful in 6m33s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m22s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 10m8s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 2m56s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 11m43s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m1s
release / apple (push) Successful in 25m5s
apple / screenshots (push) Successful in 20m50s
The host's capture tick and a client's panel vsync are independent ~120 Hz oscillators; their drifting phase sweeps every frame's wait-for-latch across a full refresh period (measured on-glass: latch p50 oscillating 5.4-8.9 ms with a fixed margin) — the beat is the residual judder and the fat p95, and no client can fix it alone. Design: punktfunk-planning design/phase-locked-capture.md. Protocol (punktfunk-core): - PhaseReport (control 0x32, next to the clock family): the client's next display latch ALREADY CONVERTED to host clock (the skew offset lives only client-side), panel period, uncertainty, and the measured median arrival-lead — the controller's error signal. ~1 Hz, latest-wins. CLIENT_CAP_PHASE_LOCK advertises it; CtrlRequest::Phase + report_phase() + the C ABI mirror carry it. - The 0xCF host-timing tail grows a phase ACK (applied_phase_ns, 29-byte form) under the same strict-prefix append discipline — old readers parse the shorter forms; degradation pinned by tests. Host engine (arrival-slaved loop — no backend can move the source vsync, per the tick-ownership audit in the design doc): - PhaseCtl bridges control task → encode loop (the fec_target pattern, multi-field). PhaseController walks a per-frame HOLD before submit toward the client's reported lead hitting target = max(2.5 ms, uncertainty+1ms): 1 Hz adjust, 2 ms max step, 300 µs deadband, period-wrapping (the newest-wins capture slot makes a wrapped hold sample fresher content, not staler). A loop local, so every mid-stream rebuild keeps the lock; a new session re-acquires. PUNKTFUNK_PHASE_LOCK=0 disarms. Android reporter: the presenter's 1 Hz pf.present flush returns the window's measured latch p50; the async loop converts the vsync clock's next timeline monotonic→realtime→host and reports. Inert toward old hosts. Gates: docker amd64 clippy --all-targets -D warnings (host+core, nvenc) clean; core suite incl. the new wire tests; cargo ndk arm64 check/clippy clean. On-glass A/B vs the .173 host owed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f9faab780e |
tune(android): the latch margin drops to SF's real lead (2.5 ms)
Each ms of submit-margin is a ms on every frame's display stage; SF's latch runs ~1-2 ms before present and the release is a sub-ms binder call, so 4 ms was padded. Measured (A024, 120 Hz game load): latch p50 8-10 → 5.4-8.9 (phase-drift dependent), paced stays 1-5/s. A device that misses at this margin shows it as a paced-counter rise, not stutter — the widen signal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a6ff0350e4 |
feat(android): frames target SurfaceFlinger's latch, not the GPU-render deadline — display 21→9.5 ms
The remaining ~21 ms display stage was the conservative release target: the presenter aimed at the first frame timeline whose DEADLINE was still ahead, and the platform's deadline budgets for GPU rendering the app has yet to submit (presDeadline = 11.3 ms on the A024 — more than a full 120 Hz period). A decoded video buffer has no GPU work left; its only real constraint is SurfaceFlinger's own latch lead. Every frame paid a whole extra refresh of waiting for a budget it never used. next_target now gates (and subdivides) on the timeline's EXPECTED PRESENT minus a 4 ms latch margin; the glass budget reopens at that latch instant (expected present − margin) rather than the deadline, which under the aggressive gate can already lie in the past — an instant reopen would let two releases pile onto one vsync. A mis-gamble presents one vsync later, which is exactly what the deadline gate paid on every frame — the trade is one-sided. On-glass (A024, 2800×1260@120 HDR, game load): latch p50 21→8-10 ms, display 26,3→9,5 (pace 0,8 + latch 7,7), e2e 43,7→26,4 p50 / 29,2 p95, released=displays=120, paced≈0, forced=0. The latch now sits under one refresh interval — the vsync-latch floor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
984f7be896 |
fix(android): the presenter paces to the panel's real grid, not the app's down-rated vsync
On-glass (A024, 120 Hz panel, 120 fps session) the first presenter build released only 60/s and the HUD display term hit 40 ms. Root cause, in two layers: Android down-rates a game-category uid's choreographer stream to 60 Hz (frame-rate categories / game default frame rate), and under that override Display.getRefreshRate REPORTS THE OVERRIDE — so the presenter's panel grid read 16.67 ms on an 8.33 ms panel and the subdivision became a no-op, pacing the video at half rate and dropping every other frame. Three-part fix, verified live on the same device: - Kotlin passes the panel rate from the supported-modes TABLE (MainActivity.streamPanelFps — the mode list is not override-filtered) instead of display.refreshRate, and votes the app's render rate up via View.requestedFrameRate = streamHz (API 35+) while streaming. - The native vsync clock LEARNS the panel period from observed timeline spacing (downward-only: the finest spacing SurfaceFlinger ever reports is the true grid) and next_target subdivides the reported timeline onto it — full-rate on down-rated devices, a no-op where callbacks match the panel. - OnFrameRendered display/latch samples get the e2e clamp (0..10 s): a vendor's first callbacks can carry a garbage system_nano (observed: an epoch-sized latch max) that would poison every max it lands in. pf.present gained panelMs next to vsyncMs, and a one-shot cadence diagnostic logs Δ/timelines/spacing/panel on the third tick. After: released=120 displays=120 paced=0, pace p50 <1 ms, latch p50 ~16 ms idle / ~22 ms under game load (2 refresh intervals at 8.33 — the same composited-pipeline law the Apple client measured), HUD display ~17-26 ms vs 40 before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
e08fd91cd1 |
feat(android): a timeline presenter — frames reach glass on the panel's schedule, not decode's
The Android port of the Apple client's stage-4 deadline discipline, closing the side-by-side feel gap (both clients 120 Hz; Android released decoded buffers the instant they appeared, with zero vsync awareness — the latch phase inherited every network+decode jitter and bursts queued behind the display). The presenter (async loop only; the sync loop stays the untouched escape hatch behind the Low-latency toggle): - decode/vsync.rs: an AChoreographer thread (dlsym'd like the other above-floor symbols) publishing the panel's vsync grid + frame timelines (postVsyncCallback, API 33; postFrameCallback64 fallback on 31/32) and ticking the decode loop's event channel. Started lazily on the first decoded frame. - decode/presenter.rs: a newest-wins slot (Lowest latency, default) or a 1-3 frame smoothing FIFO with preroll/underflow re-arm (Smoothness) between decode and release; a glass budget of exactly ONE undisplayed release in flight, reopened at the target timeline's DEADLINE (SurfaceFlinger's latch — reopening at present time would halve the sustainable rate) with a 100 ms stale force-open backstop; the release itself via releaseOutputBufferAtTime(expectedPresent) so the latch phase is deterministic. debug.punktfunk.presenter=arrival sysprop restores the legacy path for a rebuild-free on-device A/B. - Metrics: DisplayTracker is now always-on and carries the release stamp, so the display stage splits into pace (decoded→release) + latch (release→displayed); a 1 Hz pf.present logcat line (released/displays/ paced/noBudget/forced/qDry + pace/latch p50/max + measured vsync) makes a HUD-off wireless A/B readable; nativeVideoStats grows to 30 doubles (26=paceP50, 27=latchP50, 28=presents, 29=presenterActive; 0-25 frozen) and the DETAILED HUD prints the split + presents. - Intent parity: present_priority/smooth_buffer — the Apple client's stored values and labels — as globals, profile-overlay fields (round-trip + scope markers), and Settings pickers under Decoding; threaded through nativeStartVideo into the presenter config. Verified: cargo ndk check/clippy clean for arm64 (the two type_complexity warnings are pre-existing audio/mic ones), armv7 via the kit gradle task, host cargo check clean, rustfmt clean, gradle :app/:kit unit tests all pass. On-device before/after on the Nothing Phone 3 still owed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
581320df0c |
fix(android): the stream stops fighting large displays and owns the cutout explicitly
The three Play Console pre-release findings for 0.22.3, resolved: - Orientation restriction: the in-stream SENSOR_LANDSCAPE lock is now applied on compact devices (sw < 600 dp) only. On tablets/foldables/desktop windows it is a large-display anti-pattern (Android 16+ ignores it there outright) and unnecessary — the aspect-ratio letterbox renders correctly in any orientation; the lock was always a phone-ergonomics choice. No manifest restriction existed, and resizeability stays unrestricted. - Edge-to-edge determinism: the stream window now sets LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS explicitly (and restores it on the way out) — SDK-35 enforcement makes that the immersive default, pre-15 devices letterboxed the notch as a dead bar; being explicit gives both the same, correct behaviour. The stream's own letterbox is black, so the cutout region can never show anything wrong. - Deprecated edge-to-edge APIs: audited — no in-app use of setStatusBarColor/setNavigationBarColor/systemUiVisibility/translucent flags or theme attrs; enableEdgeToEdge (androidx.activity 1.13.0) is already in place with explicit SystemBarStyle. What the scanner sees are androidx's own API-level-guarded compat branches, which Google documents as ignorable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
87b6fa8813 |
feat(android): the panel is pinned to the stream's refresh, and touch skips the vsync batch
Three quick latency wins for phones, ahead of the presenter rebuild: - setStreamDisplayMode: the window-level preferredDisplayModeId is pinned to the stream's refresh (exact rate, else the smallest integer multiple, else the highest available) for the session. The surface-level frame-rate hint alone is advisory and some OEM refresh governors (Nothing OS's LTPO logic among them) ignore it for third-party apps — leaving a 120 Hz session presenting on a 60/90 Hz panel. nativeVideoSize gained a trailing refreshHz element for this (old readers index only 0/1). TV keeps the native HDMI mode switch instead. - The surface hint itself now passes compatibility = FIXED_SOURCE on every form factor: the stream is fixed-rate video the client cannot re-pace; DEFAULT invited governors to not switch. - requestUnbufferedDispatch(SOURCE_CLASS_POINTER) on the hosting view while streaming: touch/pointer events were vsync-batched — up to a frame of input latency the stream shouldn't pay. - The HUD polls the panel's live refresh each second and flags '⚠ panel N Hz' when it sits below the stream rate, so an unpinned panel is visible instead of reading as inexplicable judder. Stale nativeStartVideo kdoc (low-latency 'off, the default') corrected — it defaults ON under low_latency_mode_v2. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ec675261fc |
feat(android): the Sony-pad USB grant is asked on connect, not found in Settings
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 21s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
ci / docs-site (push) Successful in 1m4s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 19s
ci / rust-arm64 (push) Successful in 1m21s
ci / web (push) Successful in 1m33s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 1m6s
docker / builders-arm64cross (push) Successful in 6s
android / android (push) Canceled after 1m55s
ci / rust (push) Canceled after 1m56s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 47s
docker / deploy-docs (push) Canceled after 0s
On-glass feedback: burying the grant in the Controllers screen made the user go find it. Now MainActivity asks the moment a Sony pad appears — a fresh attach while the app is open, or the app foregrounding with one already plugged in — once per attach (a deny doesn't re-nag; the Controllers card's button stays as the re-ask). Nothing starts on the grant: an uncaptured pad is an ordinary InputDevice at menu time, so the grant is simply recorded and the next stream's capture engages silently. The grant broadcast is shared with the Controllers card so an open card refreshes from either dialog. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
1984ddb942 |
feat(android): a USB Sony pad is captured — rumble, adaptive triggers, lightbar, gyro
A DualSense on a phone had rumble only where the kernel exposed force feedback, and adaptive triggers / lightbar / player LEDs nowhere — Android has no platform API for any of them, and Bluetooth offers no raw path (L2CAP is LE-only; hidraw is root-sealed — Sony's own Remote Play declares Android triggers unsupported). Claiming the pad's HID interface over USB is the one unrooted route, so that is what the client now does. - HidUsbLink: the device-agnostic half of Sc2UsbLink (claim, multiplexed UsbRequest loop, newest-wins write queue, signalled-unplug discipline), parameterized by device match / interface filter / keep-alive. Sc2UsbLink keeps only its SC2 specifics (Puck interfaces 2..5, lizard refresh). - GamepadFeedback.PadFeedbackSink: 0xCA rumble + 0xCD Led/PlayerLeds/ Trigger now route to a capture link that owns the pad BEFORE the InputDevice vibrator/lights paths — Trigger stops being log-and-drop. - DsDevice: the byte-exact inverse of the host's dualsense_proto / dualshock4_proto — input report 0x01 parse (buttons/sticks/triggers, gyro+accel, both touch points; Edge FN/BACK → wire paddles) and output builders (DS5 0x02 valid-flag-selective incl. the 11-byte trigger blocks and the lightbar-animation release; DS4 0x05 as composed full-state writes). Covered by DsDeviceTest (pure JVM). - DsCapture: stream-mode capture for DualSense / Edge / DS4 — lazy wire slot on the first parsed report, typed mirror (exit chord included), touch normalized onto the rich plane + per-report motion, feedback rendering with a rumble backstop (a USB pad holds its level, so a stalled poll thread self-terminates via a scheduled zero-write) and a teardown motor-stop over EP0. The claim releases the pad's InputDevice slot itself so the wire index hands over deterministically; uncaptured (toggle off / permission denied / Bluetooth) the pad stays on the ordinary InputDevice path. - Rich-input shims: nativeSendPadTouch / nativeSendPadMotion → RichInput::Touchpad / Motion — the plane the desktop and Apple clients already feed; Android pads gain gyro + touchpad on the virtual pad. - Settings: "DualSense / DualShock passthrough (USB)" (ds_capture, opt-out like the SC2 toggle); Controllers screen card with capture status and a front-loaded USB grant so streams start without the permission dialog. The host needs nothing: the DS5/DS4 backends already consume the typed + rich planes and already emit every feedback event rendered here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a94b1d3ccc |
fix(client/android): the stream keeps its aspect instead of stretching to the panel
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
ci / web (push) Successful in 58s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 49s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 1m6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 22s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
ci / docs-site (push) Successful in 1m37s
docker / builders-arm64cross (push) Successful in 14s
docker / deploy-docs (push) Successful in 31s
ci / rust-arm64 (push) Failing after 4m29s
android / android (push) Canceled after 4m35s
ci / rust (push) Canceled after 4m39s
MediaCodec scales whatever it decodes to fill the Surface it renders into, and the Surface filled the screen — so a stream whose resolution didn't match the panel's aspect came out stretched. Nothing downstream of the Surface can correct that; the Surface itself has to carry the aspect. Size the video to the negotiated mode's ratio, centred, with the remainder black. The mode is known from the handshake before the first frame arrives, via a new `nativeVideoSize` (the same `client.mode()` the HUD already reports as `w×h@hz`); an older native lib returning nothing falls back to filling, exactly as before. Input follows the picture. Direct-pointer touch, multi-touch passthrough and the pen lane all map positions against the size of the node they sit on, so the gesture layer moves onto the same rect as the video and all three stay correct by construction instead of each needing an offset threaded through it. The physical-mouse path can't work that way — its events arrive from the activity in WINDOW coordinates — so it now measures against the SurfaceView's rect on screen, subtracting the letterbox origin and clamping into the picture: a pointer out on a bar has no host position of its own, and the edge is the honest answer for it. One deliberate consequence: trackpad swipes that START inside a letterbox bar no longer register. Trackpad input is relative and could have kept the whole panel, but one rule — input lands on the picture — beats a mode- dependent input surface, and the pen lane rides inside trackpad mode too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9ed967cbaf |
feat(client/android): the host card wears its OS mark where the initial was
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 20s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 1m7s
android / android (push) Canceled after 2m56s
ci / rust (push) Canceled after 2m37s
ci / rust-arm64 (push) Canceled after 2m25s
ci / web (push) Canceled after 2m11s
ci / docs-site (push) Canceled after 2m11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 18s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 1s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
The OS mark rode along at 12 dp in front of the address, competing with the text it prefixed. The avatar circle above it was showing the host's first letter — which says nothing the name underneath doesn't already say, twice over on a row of home-worker-N boxes. Put the mark in the circle instead, at 24 dp in the avatar's own onPrimaryContainer tint, and drop it from the address line. A host that advertises no OS chain — or one we ship no mark for — keeps the initial, so those cards look exactly as they did and a mixed row still reads as one set. On glass: the two Arch hosts wear the Arch mark; steamdeck and the Windows runner, both predating the `os=` TXT, keep their letters. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6a6be17ce7 |
fix(client/android): a session that receives no video at all now asks, and says so
The keyframe backstop added for the black LG TV only arms while AUs are actually going into the decoder (`fed > fed_at_output`) — deliberately, so an idle stream never asks for anything. That leaves its mirror image uncovered: a session that receives NOTHING. A decoder cannot be starved of output when it was handed no input, so no signal in either loop fires, and the session sits connected — audio, input and the control plane all alive — behind a black surface. That state is what a user just reported as "the stats are all basically 0": fps and Mb/s are counted at AU receipt (`note_received`), so all-zero stats with a drawn overlay means the decode thread started and received nothing. Same bug as the black screen, seen from the HUD. Both loops now watch for it: nothing received 1.5 s into a session ⇒ request a keyframe and log it, re-asking every 2 s while it lasts. Where it can help it does — the host encoding fine while every picture references an IDR this client never saw is precisely a keyframe request away. Where it can't, the log line is the point: "no video received N ms into the session" separates "the host never sent a picture" from "we received AUs and lost them", which no previous black-screen report could distinguish. Not a root cause. The remaining occurrences are still unattributed — this makes the next report diagnosable and recovers the case that is ours to recover. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
bb7baef20b |
fix(client/android): the menus keep their safe area after a stream
Returning from a stream left every menu laid out against the wrong safe area: content shoved right, the profile row sliding under the status bar, the tab labels crowding the gesture pill. Dumped on the reporter's phone, the window's real insets were bars=[0,162,0,72] cutout=[0,162,0,0] while the layout was using the landscape immersive set — cutout left=162 (Material3 lays out against systemBars.union(displayCutout)), bars all zero. No rotation and no IME animation could shake it loose. Compose attaches its OnApplyWindowInsets and WindowInsetsAnimation callbacks when the first composable reads an inset and removes them when the last reader goes away (WindowInsetsHolder.increment / decrementAccessors). StreamScreen reads no insets at all, so a stream drops that count to zero for its whole duration. Survivable on its own — but a session that ends while the app is BACKGROUNDED is the common case (leaving the app ends the session), and then the entire window restore runs on a stopped activity. The corrected insets arrive while Compose has no listener attached; when the menus recompose, incrementAccessors re-attaches and asks for a fresh pass, but a stopped window produces no dispatch and on resume nothing has changed any more, so none ever comes. Compose keeps serving the landscape, bars-hidden values for the rest of the process. Hold one inset reader at the root for the activity's whole life, so the listeners survive the stream and every dispatch lands. It subscribes to no inset VALUE, only the holder object, so it costs one DisposableEffect and no recomposition. Verified on the reporter's device by replaying the real teardown sequence (forced landscape + immersive, composition swapped to a screen that reads no insets, torn down while stopped) and reading the layout back through uiautomator: 3 of 4 runs wrong without this, 4 of 4 correct with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
91def82219 |
fix(client/android): the menus keep their safe area after a stream
Returning from a stream left every menu laid out against the wrong safe area: content shoved right by the landscape side inset, the profile row sliding under the status bar, the tab labels crowding the gesture pill. Compose attaches its OnApplyWindowInsets AND WindowInsetsAnimation callbacks when the first composable reads an inset, and tears both down when the last reader goes away (WindowInsetsHolder.increment / decrementAccessors). The immersive stream reads no insets at all — it is a bare full-screen surface — so entering one dropped the reader count to zero right in the middle of the hide(systemBars()) animation StreamScreen had just started. With the animation callback gone, that animation's onEnd never arrived, so the listener kept runningAnimation = true for the rest of the process, and from then on every onApplyWindowInsets was swallowed (it defers to an onProgress that can no longer come). The values froze at the last animation frame — landscape, bars hidden — and that is what the menus got when they came back. Hold one inset reader at the root for the activity's whole life: the listeners now survive the stream, the landscape lock and the bar animations, and every animation gets its onEnd. It subscribes to no inset VALUE, so it costs nothing per frame. As a backstop, request a fresh insets pass from onConfigurationChanged — the activity declares configChanges=orientation|screenSize, so a rotation re-lays out in place and a dropped dispatch would otherwise go unnoticed until the layout is already wrong on screen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
75e4f00e50 |
Merge branch 'chore/windows-rerender-semantics' into main
ci / docs-site (push) Failing after 31s
ci / web (push) Successful in 54s
apple / swift (push) Successful in 1m17s
ci / rust-arm64 (push) Failing after 7m25s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 58s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 16s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 49s
deb / build-publish-client-arm64 (push) Successful in 9m13s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m7s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m27s
deb / build-publish (push) Successful in 15m0s
android / android (push) Canceled after 15m44s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 15m46s
ci / rust (push) Canceled after 15m45s
deb / build-publish-host (push) Canceled after 15m12s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 4m11s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 44s
windows-host / package (push) Canceled after 15m35s
windows-host / winget-source (push) Canceled after 0s
release / apple (push) Successful in 17m52s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m29s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m12s
flatpak / build-publish (push) Failing after 12m18s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 2m59s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m12s
Windows 11 tray theming + per-connect device-name announcement, and the pairing approve button no longer escapes the canvas on portrait phones. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
43a9cf741b |
fix(client/android): the build resolves cargo from CARGO_HOME before guessing ~/.cargo
ci / web (push) Successful in 2m3s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m3s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
ci / docs-site (push) Successful in 3m27s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 24s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 21s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 31s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 34s
docker / builders-arm64cross (push) Successful in 15s
docker / deploy-docs (push) Successful in 49s
ci / rust-arm64 (push) Successful in 11m57s
android / android (push) Successful in 15m54s
ci / rust (push) Successful in 18m7s
The kts resolves cargo by ABSOLUTE path on purpose (a GUI Android Studio launch has no ~/.cargo/bin on PATH), but user.home is the wrong anchor in the CI image, where the shared toolchain lives at CARGO_HOME=/usr/local/cargo — gradle died starting /root/.cargo/bin/cargo. CARGO_HOME/bin is where rustup puts binaries whenever the variable is set, so it wins; the ~/.cargo fallback keeps GUI launches working. Also: android.yml's path filter learns ci/android-ci.Dockerfile — an image change must exercise its consumer instead of needing a manual rerun to prove itself. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9b2404a580 |
feat(core): every connect introduces the device by name
The wire always had Hello.name and the host always honored it - but the connect path hardcoded None (only the PIN-pairing ceremony sent a name), so every no-PIN "request access" knock surfaced as the fingerprint placeholder "device abcd1234", and approving one without retyping a name persisted that placeholder into the trust store forever. NativeClient::connect now takes the device name. The session workers and the probe connects pass trust::device_name() (the hostname), the C ABI defaults to the same without a signature change (an ex10 variant can make it explicit if an embedder wants a custom label), and Android threads Build.MODEL through nativeConnect - the same convention its pairing dialogs already use for nativePair. The host, in turn, resolves the streaming client's display name (trust store first, so an approval-time rename wins; else the sanitized Hello name) and exposes it as client_name in GET /api/v1/local/summary for the tray's connect toast - a deliberate, documented loosening of that route's "no device names" contract, in the local user's favor: it tells them who is on their machine. A paired-but-idle device's name still never appears, which the mgmt tests now pin explicitly. openapi.json, its docs-site copy, and the SDK bindings regenerate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
63224cd31d |
feat(client/android): the eighth field carries the OS, and cards wear it
The JNI discovery record appends `os` as its eighth ␟-field (append-only — the Kotlin parser's arity guard already tolerates both old and new records, now pinned by tests in both directions), sanitized on the Kotlin side by the mirrored chain grammar next to the shared `osIconTokens` walk. `KnownHost` persists it additively (optString — no schema bump, migration passes it through) with `learnOs` beside `learnMac`, learned on the same discovery tick. Compose ships no brand icons, so OsIcons.kt vendors the ten marks as raw SVG path strings built into ImageVectors via PathParser (lazy, cached) — they tint with the Material theme like any Icon. The host card's address line leads with the mark, live advert preferred over the stored chain. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
e44baf6768 |
fix(client/android): a decoder fed nothing it can decode must ask for a new anchor
android / android (push) Canceled after 33s
apple / swift (push) Canceled after 27s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 34s
ci / rust (push) Canceled after 8s
ci / rust-arm64 (push) Canceled after 3s
ci / web (push) Canceled after 2s
ci / docs-site (push) Canceled after 3s
ci / bench (push) Canceled after 2s
deb / build-publish (push) Canceled after 6s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
decky / build-publish (push) Canceled after 7s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 15s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 14s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 6s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 9s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 6s
An Android TV box reported a black screen with a perfectly healthy HUD: AUs arriving at 40 fps, ~312 bytes each. That size is all-P-frames — no IDR anywhere in the window, and nothing asking for one. The stats being readable is the rest of the story: the overlay is a layer over the SurfaceView in the same window, so the panel was fine and the surface simply never received a frame. The decode thread only starts at `surfaceCreated`, so a slow box can be handed the stream mid-GOP. A hardware decoder does not error on references it never had; it emits nothing at all. Under infinite GOP the host sends no further IDR unless asked, and neither Android loop ever asked: every recovery trigger they have keys off a drop, a gap or a decode error, and a decoder that quietly produces nothing trips none of them. The session stayed black for its whole life. The shared gate has this case (`on_no_output`, which pf-client-core and the Apple client both feed) but its per-AU streak counts one-in/one-out decodes, and MediaCodec is pipelined — "this AU produced no output" is not something these loops can observe. A wall-clock silence window is the same signal in the shape Android can measure: fed for 500 ms with nothing coming back arms the freeze and requests a re-anchor keyframe, and the gate's deadline keeps re-asking until one lands. 500 ms so it can never fire on a decoder that is merely slow to spin up. Also log the first presented frame. The periodic tally starts at 300 rendered frames, which is no help whatsoever on a session that renders none — its absence is what separates "never reached glass" from "reached glass and looked wrong". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
1862010003 |
fix(client/android): a settings edit belongs to the scope the chips show
Change a setting on the defaults, switch to a profile, change the same row: the globals moved again and the profile recorded nothing. Switch back, and the next edit went into the profile — which reads as "the default settings can't be changed any more". `update`/`resetField` reached the rows as `::update` — callable references, and two of those compare equal however different the scope they closed over. Compose saw an unchanged callback and skipped the whole detail page on a scope switch that moved no value on screen, which is the ordinary case: a profile inherits the globals until it overrides something. The row went on calling the reference it was first handed, one scope behind for the rest of the session. Resolve the scope from the live state at the edit instead of closing over it, so the write follows the chips whether or not the page recomposed. `key(active?.id)` around the detail page covers the other half — a row's own `remember` is per-scope state too, and "Custom…" picked while editing a profile has no business still being picked over on the defaults. The model tests can't see this: it takes the real Compose runtime to skip a composable. SettingsScopeTest drives the screen itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8fee1139f6 |
chore(client/android): adopt AGP 9.3.1 / Gradle 9.5.0, and tell CI about it
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 3m15s
android / android (push) Canceled after 4m30s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 4m51s
ci / bench (push) Canceled after 4m42s
ci / rust (push) Canceled after 5m2s
ci / rust-arm64 (push) Canceled after 5m3s
deb / build-publish (push) Canceled after 4m25s
deb / build-publish-client-arm64 (push) Canceled after 36s
deb / build-publish-host (push) Canceled after 1m45s
decky / build-publish (push) Canceled after 10s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 3s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 2s
The bump itself came from Android Studio; this makes the rest of the repo agree with it. The toolchain line at the top of the root build file still claimed AGP 9.2.0 and Gradle 9.4.1, and both Android workflows named "AGP 9.2" as the reason they pin JDK 21 — the sort of comment that is trusted precisely because it looks maintained. The Gradle caches also needed telling: they cache the DISTRIBUTION under `~/.gradle/wrapper`, keyed on a hash of `*.gradle.kts` only. This bump moved both files so the key changed anyway, but a wrapper-only bump would have restored a key that can never contain the new distribution. The wrapper properties are in the key now. Verified with the workflows' own commands on the new toolchain: `:app:testDebugUnitTest -PskipRustBuild --stacktrace --rerun-tasks` (the screenshot job, 45 tasks from cold) and `:app:assembleDebug` including the cargo-ndk native build. AGP 9.3.1 resolves no build-tools newer than the 37.0.0 both jobs install, so their sdkmanager lines still cover it. |
||
|
|
93fe9f134e |
refactor(client/android): the colour palette reads as one object
Three things were wrong with it, and they compounded. **The order was arbitrary** — orange, blue, pink, green, amber, violet, cyan, rose jumped back and forth across the wheel, so it looked like a bag of colours rather than a choice. It is sorted by hue now, one clean sweep, with the degrees in the source so it stays that way when a colour is swapped. That is also the order creation hands them out in, so someone making profiles one after another walks the spectrum. **The layout fought the form.** A fixed-size grid centred in the dialog floated free of the left-aligned labels above it; distributed across the full width the swatches stopped looking related to each other; and eight colours wrapped to a short second row that read as the grid running out. Each row now FILLS the width with its swatches sharing it equally, so the palette's edges line up with the name field's and both rows are the same length. The palette gained two hues to make that work: ten divides by five, eight didn't divide by anything useful. **Selection was a heavier circle.** A border drawn on the swatch's own edge reads as a thicker ring, not as "this one". It is a ring OUTSIDE the disc with a gap, plus a check — which also means the selection survives a reader who can't tell two of these hues apart. The ring's space is always reserved, so picking never nudges the grid. |
||
|
|
a3ec30c287 |
refactor(client/android): manage a profile from its own chip, and pick its colour up front
Two things about the profile UI were backwards. **The manage menu was parked after the last chip.** A single overflow button at the end of a horizontally-scrolling row, acting on whichever profile happened to be selected — so reaching it meant scrolling past every profile, and nothing on screen tied the button to its target. It now lives ON the chip: the selected profile grows a chevron, tapping it again opens Edit / Duplicate / Delete anchored underneath, and the action is attached to the object it acts on. The wandering ⋮ is gone. **Colour was an afterthought.** A profile was named at creation and coloured later, through a menu item most people would never find — so every profile looked colourless until someone went hunting, and the accent is precisely the signal that has to be right from the first moment (it is all a bound host card's chip and a pinned card's tint have to go on). Name and colour are now decided together, in one dialog that serves both creation and editing, with the next unused colour pre-selected. "Rename…" and "Change colour…" collapse into one "Edit…", which is three menu items instead of four and one dialog instead of two. The chevron rides inside the chip's label rather than the `trailingIcon` slot, for the same reason the accent dot does: those slots reserve an 18dp icon and shrink the chip's padding, which is what made a chip with a dot sit differently from "Default settings" beside it. Driven on glass: created a profile with a colour chosen in the dialog, opened the menu from the selected chip, and edited name and colour together. |
||
|
|
f2874a5324 |
feat(client/android): a profile gets a colour, and you can change it
The accent was reserved in the schema and used everywhere it mattered — the scope chip, a bound host card's chip, a pinned card's tint — but nothing ever set one. `newProfile` left it null, and design §5.1's "Change color" was the one item of the scope menu I didn't build. So every profile a user actually created was colourless, and the only one that wasn't was a test profile I had seeded by hand through adb. That inconsistency was the whole visible symptom. Creation now hands out the first unused colour from an eight-entry palette, so profiles are distinguishable from the moment they exist — which is the point of the accent on the surfaces where a profile has no room for its name. The palette avoids the presence green, which means "this host is up" and nothing else. Past eight it wraps rather than handing out nothing: a repeated colour beats an invisible chip, and the picker is right there. "Change colour…" joins Rename / Duplicate / Delete, with "no colour" offered as a real choice rather than only an initial state — a profile made before this existed keeps working, and its chip falls back to the theme's accent. The colour is presentation, not a setting: it is not in the overlay, so it never reaches a resolved connect. Tested, and driven on glass end to end — two profiles created through the dialog, distinct accents on their chips. |
||
|
|
4803260aff |
fix(client/android): a settings row's gaps are set, not inherited
One `spacedBy(4.dp)` was doing three different jobs in a settings row, and got all of them wrong. The caption sat as close to its control as the override marker did, so the row read as one undifferentiated stack — and the marker, being the FIRST child of a card that already pads 16dp, added its own vertical padding on top of that, so a marked row started with visibly double the gap every other row has. Each gap is now stated where it belongs: the marker has no vertical padding of its own (it sits exactly at the card's padding, level with an unmarked row's control) and owns the 6dp down to the field it annotates; the caption owns the 10dp up to it. Tighter above, roomier below — so the marker groups with its control and the caption reads as a separate note rather than part of it. The "Reset" hit area loses most of its vertical padding to make that work; it keeps the horizontal padding and the full-width row, so the target is wide rather than tall. Confirmed on glass, not just in the screenshots. |
||
|
|
7f40afc525 |
fix(client/android): the settings page stops shouting
Three things made the settings surface hard to read, all of them mine. **The captions were desktop prose on a phone.** A1 took the Windows client's `described()` text close to verbatim, which is right for a wide desktop row and wrong for a ~340dp column: four-line paragraphs under every control, so the page read as documentation with some dropdowns in it. Every caption is now one line, two at the outside. The wording still says the thing that isn't obvious from the label — "Native follows this device's refresh rate", not a restatement of "Refresh rate" — it just stops explaining the parts nobody needed explained. **The override marker was taller than the control it annotates.** "Reset to default" was a `TextButton`, which brings its own 48dp touch target, so a marked row grew a half-height band above it that dwarfed both the field and the caption underneath. It is one compact line now, with a padded hit area on the word itself — the right trade for a secondary action inside a dense list. **The scope note's spacing was lopsided**: 8dp above it, 4dp below, so it drifted toward the chips instead of sitting between them and the divider. The two settings screenshots cover all three. |
||
|
|
cee39b3751 |
fix(client/android): two spacing slips in the profile UI
The name field in "New profile" sat flush against its caption — the `Column` holding them had no arrangement at all, so the explanatory line read as part of the input. And a profile's scope chip didn't line up with the chips beside it: the accent dot was in `FilterChip`'s `leadingIcon` slot, which reserves an 18dp icon and shrinks the chip's leading padding to suit. A 10dp dot in that slot left the chip's insets visibly different from "Default settings" next to it. The dot now rides inside the label, so every chip keeps the same padding and the gap to the name is ours to set rather than a side effect. Both were eyeball-only surfaces: no screenshot covered either. The name field now has one — the dialog itself can't be captured (a focused text field inside a Dialog window never reaches idle under Robolectric, the same trap the PIN scene documents), so its body is extracted and the scene renders exactly that, in both the normal and duplicate-name states. The chip row was already in the profile-scope shot. |
||
|
|
b6819b80e2 |
refactor(client/android): the host card says as much in a third less space
Three stacked badges, each a coloured dot beside a word — presence, trust,
profile — turned a host card into a legend for itself, and made it ~220dp tall
for four facts. Two of the three didn't need to be badges at all.
**Presence moves onto the avatar**, the idiom every contact list already uses:
a dot on the corner, and one fewer labelled row. It is GREEN when the host is
up — a fixed green, not the scheme's primary, because Material You's primary
is whatever the wallpaper says and might itself be a green that then means
nothing. Offline is a hollow ring rather than a differently-coloured dot, so
the state survives a colour-blind reader and a greyscale screenshot; TalkBack
gets the word either way.
**Trust moves to the free top-left corner** as a glyph mirroring the overflow
on the right — locked (paired), a key (this host will ask for a PIN), an open
lock (trust-on-first-use). It costs no height at all, and it is a state you
glance at rather than read; the label rides along as the content description,
and the dialogs that actually make the trust decision spell it out in
sentences. That also retires the pill whose long label ("Trust on first use")
was what made cards tower over their neighbours in the first place.
**The profile chip stays a chip** — it is the one badge that earns the accent,
because it is the only one that says what a tap will DO.
Net: ~220dp down to ~150dp, one badge instead of three, and the only reserved
slot left is the chip's (the row-height rule still applies to it).
|
||
|
|
6236989b03 |
fix(client/android): host cards in one row are the same height again
`LazyVerticalGrid` sizes a row to its tallest item but does NOT stretch the others, so anything variable inside a card shows up as cards stepping up and down within a single row. Two things varied. The new one: the profile chip. A bound host's card grew ~34dp its unbound neighbour didn't have. The chip's space is now reserved on every card in a section as soon as ANY card there carries one — so a user with no profiles still never sees the gap, and a mixed row is flush. The older one, which profiles only made more visible: the trust pill. "Online" and "Trust on first use" were laid out on one Row, so the long label wrapped to three lines INSIDE its pill and that card towered over a "Paired" one. The pills now wrap as pills (a FlowRow), one line each, in a reserved slot that a two-pill card and a one-pill card both fit inside. Both slots are `heightIn(min =)` rather than fixed, so a large accessibility font scale grows the card instead of truncating a host's trust state — and the pill slot is sized with room to spare rather than to an exact two lines, because the equal-height guarantee only holds while every card fits INSIDE the reservation. The hosts screenshot scene now orders its mocks so an unchipped card sits beside a chipped one, and a long trust label beside a short one — the two shapes that used to step. Verified there and on the emulator. |
||
|
|
6ea10ab383 |
feat(client/android): the console settings read like every other surface
The console page had its own category names — Stream / Video / Audio / Controller / Interface — which is a sixth mental model for the same settings, on the surface least able to afford one (you navigate it a row at a time with a D-pad). Its headers are now the shared map with the same sub-sections the touch settings and the desktop clients use, so a setting sits in the same place whichever surface you found it on, and its groups appear in the same order. The ROWS stay the couch-relevant subset. A pad can't drive a touch-input picker, and adding one for the sake of symmetry would be parity in name only. Retitled "Default settings": this page edits the base layer only — the console honours a host's bound profile but doesn't edit profiles (design §5.4) — and a bare "Settings" quietly implies it changes whatever that host streams with. Same reason the session console and Decky are retitled. Also carries A2's SC2 fix onto this surface: the passthrough toggle was absent from the console page entirely, on the machines where a Steam Controller 2 is most often the only input. |
||
|
|
02f7cfbb1d |
feat(client/android): the speed test, writing where the tested host actually reads
Android had no speed test at all — the one client where "what bitrate should I use?" had no answer but guessing. It measures over the REAL data plane: a minimal 720p connect, then the host bursts filler for two seconds, so the answer is about the link this host's stream will take rather than generic throughput. Two new JNI calls (`nativeSpeedTest` / `nativeProbeResult`) front the core's probe, deliberately measure-only. The measurement is the easy half. The half that was wrong on every client for a long time is WHERE the answer goes. A measured bitrate belongs in the layer the tested host actually resolves bitrate from (design §5.3): its bound profile's override if it has one, the global if the host is unbound — and if the host is bound to a profile that INHERITS bitrate, both are defensible, so the user gets both buttons instead of us guessing. That target depends only on the host, so it is known before the result lands and the button can say where it will write: "Apply to “Travel”". Writing the global unconditionally — the old behaviour everywhere — is what made measuring the slow box downstairs quietly re-tune the desktop. Reachable from the host card's overflow and from the console's host options: a TV box on a powerline adapter is exactly the machine whose link is worth measuring, even though profile editing stays off that surface. While there: a successful write no longer renders in the error container. The connect screen's one status line was red by design — correct for a failure, a small lie for "75 Mbit/s set in “Travel”" — so confirmations got their own. Verified on the emulator against a real host: 108 Mbit/s measured on a host bound to a bitrate-setting profile, target resolved to that profile, and Apply wrote 75397 kbps into the profile's overlay with the global untouched and the profile's other overrides unmoved. |
||
|
|
f6f991648d |
fix(client/android): a pinned console tile is a shortcut, not a second host
The touch grid withholds Edit / Forget / Wake from a pinned card on purpose — a pin is a shortcut to one host+profile combination, and offering the host's destructive actions on it blurs exactly that. The console carousel didn't: its pinned tiles carried the host record, so Up on one opened the full host options (including Forget), and Y opened the host's library. They now carry which profile they pin, so the options dialog offers the one action a pin has — Unpin — and says what unpinning does and doesn't touch. |
||
|
|
42bd5be941 |
fix(client/android): a link must not end the stream it isn't allowed to preempt
Found on glass. "A URL may never preempt a live session" was enforced inside the composition — which is the one place that can't see it happen. With `launchMode = standard` a `punktfunk://` link arrives as a SECOND activity instance in its own task; that instance's session state is empty, so it routed the link as a fresh connect, and the streaming task being backgrounded ended the session it was supposed to protect. The rule held only for the rare `FLAG_ACTIVITY_SINGLE_TOP` caller that lands on `onNewIntent`. The live session is now published process-wide, and `onCreate` refuses there — before this instance is ever resumed, so finishing it leaves the streaming task in front, untouched. Static state is what crosses the gap between two activity instances that know nothing about each other; the process dying resets it, which is also the right answer. Verified on the emulator against a real host: with a stream up, a link naming a DIFFERENT host now leaves it running (same session, same HUD, no second connect) instead of tearing it down. |
||
|
|
8172e819a4 |
fix(client/android): the JNI crate wouldn't build under the unsafe-op lint
`unsafe_op_in_unsafe_fn` is denied workspace-wide, and two operations in the render-callback path were still bare inside their `unsafe fn` — so every Android build failed at `cargo ndk`, before any Kotlin work could reach a device. Pre-existing on main and unrelated to the Android settings/profiles work; found by building the APK for it. Both get the explicit block and the SAFETY note their neighbours in the same file already carry: the reclaimed pointer is the one `install_render_callback` leaked, and the callback's `userdata` is that same pointer, alive for as long as the codec that delivers the call. |
||
|
|
b63f9f4ac0 |
feat(client/android): punktfunk:// links open a stream
Android was the last client with no URL door at all: no VIEW intent filter, no `onNewIntent`, no parser. Now a Playnite entry, an OS shortcut, a Stream Deck macro or a wiki link can open a stream on a host this device already trusts. The parser is a PORT, not a new design. `clients/shared/deeplink-vectors.json` is the cross-language contract, and the Kotlin suite runs the same 44 cases the Rust one does — including every refusal code — so three parsers cannot drift into three different security postures. It is resolved from the shared path rather than copied, because a copy would be a fourth contract free to go stale. Strict percent-decoding with a REPORTING UTF-8 decoder, so `%FF` is a refusal rather than a U+FFFD that survives into a log line or a filename. The routing lives in `ConnectScreen` because that is what owns the connect path — trust decisions, the local-network grant, wake-and-retry — and a link must go THROUGH all of it, never around it. The rules are absolute and each one is a branch you can point at: a known, pinned host does exactly what tapping its card does; an unknown or never-pinned one gets the confirmation sheet, from which the normal pairing flow proceeds under the user's eyes; an `fp=` that contradicts the stored pin is a hard refusal; an ambiguous host name or a profile this device doesn't have refuses with a notice naming what failed, because a "Work" shortcut streaming with the wrong settings is worse than an error; a link arriving mid-stream never preempts it (pointing at the host already being streamed is a no-op — the intent has already brought the app forward, which is what focusing it means). `wake` and `browse` parse, and are refused with a notice rather than silently connecting. `launch=` and `profile=` ride the whole path, including through a trust decision: a link to a host that still needs pairing keeps its game and its profile across the confirmation instead of quietly landing on a plain desktop session. `launchMode` stays `standard` and the `configChanges` set is untouched — its `keyboard` entry is what keeps an SC2 claim from killing a running stream. The VIEW intent is read in both `onCreate` and `onNewIntent`, which is what that launch mode requires. |
||
|
|
46461eb0b6 |
feat(client/android): profiles — per-host settings, one settings UI
Every client setting was global: pick 4K@120 HEVC and it applies to the beefy desktop, the work laptop and the retro box alike. Android now has the same answer the desktop clients got — named bundles of overrides, bound per host. A profile is a bundle of OVERRIDES, not a snapshot, and getting that right is the whole feature. An untouched field keeps following the global live, so fixing a global once fixes it everywhere. A touched field is recorded even when it equals today's global — that is a pin, and it must survive the global later moving. The only way back to inheriting is an explicit reset, never a diff at save time. `SettingsOverlay.absorb` is the seam that makes this work with a per-control commit: it compares against what the control was SHOWING, not against the globals. One settings UI, not two. A scope chips row on top switches the whole surface between the defaults and one profile — same categories, same rows, same captions — so a profile editor cannot drift from the thing it overrides. In profile scope only profileable rows render (the console-UI toggle, the library, auto-wake, the controller diagnostics are facts about this device and simply aren't there), every row shows the effective value, and an overridden row carries a marker and a reset. On the host side: the Edit sheet binds a default profile and pins profiles as cards; a bound card wears a chip naming what a tap will do; a pinned host+profile combination gets its own card right after its host, one tap instead of a menu — which is also what makes profiles usable on the console, where menus are not. "Connect with" is a one-off from any card and never rebinds; rebinding is always the explicit act of opening the Edit sheet. Precedence is the cross-client one — one-off, else binding, else none — with the empty reference meaning "force the defaults", a real choice on a bound host. A deleted profile leaves a dangling binding that resolves to none and a pin that stops rendering: never an error, never a blocked connect. Resolution happens ONCE per connect, in `ConnectScreen`, and the resolved settings ride the `ActiveSession` into the stream — so the "applies from the next session" footers stay true and the stream can't disagree with the connect that opened it. The stats overlay's first line names the profile, which answers "which profile am I on?" from inside the stream. Nine tests cover the model against the Rust suite's cases (apply, absorb, pins, clear, unknown-key carry-through, id-before-name resolution with ambiguity refused, precedence, deletion) and two Roborazzi scenes cover the surfaces: a profile being edited, and a host grid with a bound chip and a pinned card. |
||
|
|
35ce0401a8 |
feat(client/android): hosts get a stable identity, and own their clipboard
The host store was keyed by `"address:port"`, which had two consequences. Editing a host's address had to re-key its record — delete the old key, write the new one — and nothing could hold a durable reference to a host at all, because the key moved whenever the host did. Profile bindings, pinned cards and `punktfunk://` shortcuts all need exactly such a reference, so the store is now keyed by a minted stable id: a lowercase UUID, the same shape Apple's `StoredHost.id` and the Rust `KnownHost.id` already carry, so a host reference is one grammar on every platform. The re-key-on-edit dance is gone; an edit is a plain save. `clipboardSync` moves from a global onto the record. It was always a decision about a HOST — whether text on this device may cross to that machine — and one global for the work box and the couch box was the wrong shape; the desktop clients have had it per-host since it shipped. It is edited from the host's Edit sheet, which is also where the profile binding will live. Both, plus the minted ids, ride ONE migration pass. The store is being rewritten anyway and every extra pass is another chance to strand somebody's hosts. It runs once against real user data, so its pure half is tested against a verbatim pre-migration blob — every host survives with its pin, paired flag and MACs, IPv6 addresses included; each lands on its own id; whatever the global clipboard setting said lands on every host, on or off; and a second pass over its own output changes nothing. Re-trusting a host now goes through `KnownHostStore.trust`, which preserves the existing record's identity and everything the user set on it. Three call sites used to construct a fresh `KnownHost` — harmless while the key was the address, but with an id it would have forked the record on every re-pair. While the wiring was open: `StreamScreen` takes an `ActiveSession` — the settings the connect actually resolved, plus that host's clipboard answer — instead of re-reading `SettingsStore` behind its own connect's back. That is also the seam profiles need. |