5f71aeb02440050a474b92fc3092f7fdf8866263
149
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a11c672bea |
fix(android/hud): stop charging the compositor's wait to the stream
The Android HUD headlined `capture→displayed` with SurfaceFlinger's latch
and scanout inside it — pipeline depth no client can pace under. The usual
Android streaming overlays stop measuring at decode-complete, so users
comparing overlays read our honesty as latency: on a 60 Hz panel that floor
alone clears 30 ms, more than everything those overlays display put together.
Exclude it, the way the Apple clients have since the presentation rebuild
(
|
||
|
|
0a72959ef7 |
Merge main into feat/android-pad-audio
86 commits of main, including the whole M1-M12 haptics sweep. Twelve conflicting files; three of them were more than textual. **The capability bits collided.** Both branches allocated the SAME wire bits for DIFFERENT features: `client_caps 0x04` and `host_caps 0x20` are redundant desktop audio on main and pad audio here. Merged naively, a peer would negotiate one and get the other. Pad audio moves to the next free bits — `CLIENT_CAP_PAD_AUDIO = 0x08`, `HOST_CAP_PAD_AUDIO = 0x40` — and the `abi.rs` mirrors move with them (their compile-time equality assertions caught the mismatch, which is exactly what they are for). **Both branches also claimed ABI v15.** Main's shipped (the rumble-policy floor), so the pad-audio surface becomes **v16**. **`native/input.rs` would have reintroduced a fixed bug.** This branch resets `rumble_seq[idx]` on pad removal; M1 established that the client's reorder gate is per-connection with no reset path, so restarting the host counter strands every later envelope until it climbs back. Took main's seq-preserving `clear_pad_feedback` and kept only the branch's `pad_streams.stop(idx)`. The rest: `wiring_plan::plan` now delegates to main's `plan_with_formats`, so the pad-endpoint filter moved into that body and the predicate behind it is factored out as `is_pad_render` (also what B10 needs); `Ds5Feedback::AUDIO` derives from main's `REPORT_ID_LEN` like its siblings; `AudioCtl` joins the explicitly-listed unhandled variants so the guard-false case is covered rather than swept up by a `_`; `include/punktfunk_core.h` regenerated rather than hand-merged. |
||
|
|
9fb41affba |
fix(clients/settings): a controller setting you can't use no longer looks like one you can
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m37s
ci / rust-arm64 (pull_request) Successful in 2m41s
ci / web (pull_request) Successful in 3m30s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m16s
android / android (pull_request) Successful in 5m37s
ci / rust (pull_request) Successful in 8m4s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m14s
apple / swift (pull_request) Successful in 1m30s
Turn "Forward controllers" off and four rows below it stop meaning anything — nothing is forwarded, so there is no pad type to pick and no guide button to route. GTK desensitised them, the touch settings on both mobile clients dimmed them and the console UI refused the step; the Windows client and BOTH controller-navigable screens left them fully live, so you could sit there changing settings that did nothing. Windows: `.enabled(s.gamepad_forwarding)` on the forwarded-controller picker, pad type, guide button and hold-Select rows — the same builder the echo-cancellation row already used to follow the mic switch. Apple's gamepad settings had no way to say it: `Row` carried `adjustable` (which only hides the chevrons) and nothing else. Added `Row.enabled`, dimmed the row CONTENTS only so the glass still reads as a focusable row, and enforced the inertness centrally in `adjust(id:)` / `activate(id:)` rather than in each builder's closure. The hint bar drops "Adjust"/"Change" on a dimmed row, because advertising them was the same lie the live row told. Android's gamepad settings already had `GpRow.enabled` — documented as "dimmed + inert" — but it only faded the label: every dimmed row still stepped and still wrote its setting. The "No profiles yet" placeholder looked inert only because its own closures were empty. Made it real in one named place (`liveRow`), covering all three input paths (left/right, A, and a tap on the already-focused row), then gated the pad rows on it. Also on that screen: the DualSense / DualShock passthrough toggle, which the touch settings have carried beside its SC2 twin all along. It was missing exactly where it matters most — a TV box has no touch interface to fall back to, so there was no way to reach it at all. Apple capture, separately: with forwarding off, opening a slot still claimed EVERY element's system gesture and powered the controller's IMU. Neither reaches the host, so the first only took the user's screenshot/Home gestures away for nothing and the second drained the pad's battery streaming gyro over Bluetooth. Narrowed rather than skipped — the escape chord is read off the same slot and on tvOS is the ONLY controller way out of a stream, so the chord's own four buttons keep their claim. A test pins the alias list against the chord mask; if they drift the symptom is a session nobody can leave, with nothing logged. Closes R17, R18, R19 (design/haptics-sweep-2026-08-03.md M11). R17 as filed named Windows and "Apple"; Apple's TOUCH settings were already correct and Android's controller-navigable screen was not — both corrected here. Verified: Windows clippy -D warnings exit 0 on a real Windows box; Apple swift build clean + full suite 192 tests / 0 failures (3 new); Android :app: + :kit: green (5 new); cargo fmt --all --check clean. Each fix probed by reverting it — every probe failed the tests it should. |
||
|
|
1db7058a5d |
feat(clients/input): system buttons route around local overlays
apple / swift (pull_request) Successful in 1m30s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 4m16s
ci / rust-arm64 (pull_request) Successful in 3m20s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 59s
ci / web (pull_request) Successful in 1m36s
ci / docs-site (pull_request) Successful in 2m7s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m55s
ci / rust (pull_request) Successful in 10m36s
Pressing guide/Steam/QAM collided with the client device's own shell: iOS 26 opens its Game Overlay for the Home press (no app opt-out until iOS 27 makes it a user setting), and a Gaming-Mode client opened BOTH Steam overlays for one press — the local one covering the stream. Two cross-client tier-P settings, zero wire changes: - system_buttons (auto|forward|local): raw guide+misc1 passthrough. Auto forwards everywhere EXCEPT under gamescope, where SteamOS reacts to the same physical press no matter what. - guide_gesture (auto|on|off): hold Select ALONE ~350ms sends the HOST's guide, down until release — held on, that's the host's long-press, which opens a Gaming-Mode host's QAM for regular pads. A Select tap is delivered on release with its up TAP_PRESS (50ms) behind, because per-transition sends fold into seq'd GamepadState snapshots and a back-to-back pair can coalesce into no press at all. A Select inside a combo (the escape chord) passes through untouched. Auto arms it only where the raw press can't reach the host cleanly: gamescope, iOS/iPadOS, tvOS. The same SelectGesture rules live in pf-client-core (pure state machine + unit tests), the Apple client (mask-diff adaptation in GamepadCapture), and Android's GamepadRouter. Settings rows on every surface (GTK, WinUI, console UI, Decky, Apple x2, Android x2) with profile plumbing throughout. punktfunk-session grows a control socket ($XDG_RUNTIME_DIR[/app/$FLATPAK_ID]/punktfunk-session-ctl.sock — the one runtime path a flatpak and the host see identically): 'guide'/'qam' verbs inject synthetic taps. The Decky panel gains a Host menus section (visible while the client runs) whose buttons press the host's Steam/QAM and close the local menu so the host's shows through. iOS 27's GCControllerHomeButtonSettingsManager deep-link is a TODO (the class needs the Xcode 27 SDK to compile). Docs: input, client-settings, steam-deck. Design: punktfunk-planning design/system-buttons-routing.md. Gates: docker clippy --all-targets --locked -D warnings + tests (pf-client-core 88 incl. 6 new gesture tests, pf-console-ui 47), cargo fmt --all --check, swift build (macOS), gradle kit+app compile, decky tsc --noEmit + py_compile. clients/windows not compiled (no box). |
||
|
|
173be61213 |
fix(android/pad-audio): an unplugged pad comes back whole, and an idle one arrives at all
android / android (pull_request) Successful in 4m24s
ci / web (pull_request) Successful in 2m29s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 36s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 44s
ci / rust-arm64 (pull_request) Successful in 3m33s
apple / swift (pull_request) Successful in 1m18s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 2m45s
ci / rust (pull_request) Successful in 6m52s
Three faults on the default capture path, all of them silent.
Unplug tore nothing down. onLinkClosed() is the real unplug signal — silence
never is, an idle pad simply stops streaming — but it skipped the pad-audio
teardown that stop() performs, so the render thread went on writing to a
descriptor whose device was gone, the renderer's own UsbDeviceConnection leaked,
and because the started flag stayed set and the native tier-A registry stayed
armed for that wire index, the pad came back with neither pad audio nor wire
rumble: the next occupant of the index inherited a suppression nothing would
lift. The teardown is now one shared step and runs on both paths, before the
slot is released, since the renderer is addressed by the index the release
forgets.
The wire slot was claimed on the first parsed report. A captured pad that
reports nothing then gave the host no arrival, so no virtual pad, no pad-audio
capability, no 0xD1 — a renderer sitting at zero frames, which is exactly what a
broken pipeline looks like, and it took a physical replug to clear. A pad that
reports nothing is still a pad, so the slot is claimed when the capture engages;
the first report stays as the fallback for a claim that found no free index.
This also puts the common claim on the main thread, which is the contract
GamepadRouter.openExternal documents and the link thread was quietly breaking.
And the two settings had no UI. The model and its persistence existed but no
toggle did, so pad_speaker could only be set by hand-editing shared_prefs, and
pad_haptics — which decides whether the pad trades wire rumble at all — could
not be turned off by anyone who hit trouble with it. Both are now rows under the
DualSense passthrough toggle, gated on it, since neither does anything to an
uncaptured pad.
The padHaptics doc no longer describes the arbitration as a selection forced by
a firmware-level mutual exclusion. It is decided on evidence — the coils belong
to haptics only while haptics frames arrive — which is what
|
||
|
|
ff5602361f |
fix(android/gamepad): TV wording points at the Controller-optimized UI toggle
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m55s
apple / swift (pull_request) Successful in 1m18s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m30s
ci / docs-site (pull_request) Successful in 1m20s
ci / web (pull_request) Successful in 1m37s
ci / rust (pull_request) Successful in 8m35s
android / android (pull_request) Successful in 5m22s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m6s
'Created and edited in the touch interface' is dead advice on a TV box — no touch to reach it with. Unlike tvOS the editor DOES exist on-device (same APK), behind this screen's own Controller-optimized UI toggle, so on TV the Profiles strings now name that route instead. |
||
|
|
857d7d7b6b |
feat(android/gamepad): Profiles section + pin-to-hosts dialog in Default settings
GamepadSettingsScreen gains the trailing Profiles section (per-profile rows with live pin counts, touch-interface explainer) and a console-styled GamepadPinHostsDialog — controller- and TV-remote-navigable pin management writing KnownHost.pinnedProfileIds through the existing store path. Pin-add was previously touch-only; pinned-card rendering and unpin stay as they were. |
||
|
|
5be494f490 |
merge: bring main into the pad-audio branch
ci / web (pull_request) Successful in 1m0s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 1m2s
apple / swift (pull_request) Successful in 1m18s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m51s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 34s
ci / rust-arm64 (pull_request) Successful in 2m48s
android / android (pull_request) Successful in 3m49s
ci / rust (pull_request) Successful in 5m40s
Main had moved 34 commits past the merge-base and 13 files had diverged.
Resolving now rather than later, since the force-feedback sweep work is landing
in the same files.
Five conflicts needed hand resolution. Four were "each side added something
different" and keep both: the Forwarding and PadAudioPrefs control variants with
their handlers and setters (pf-client-core/gamepad.rs), both of the session's
pre-attach declarations (forwarding first, so slots still declare their
pad-audio caps at open time), main's WiredPlan/fingerprint alongside the
branch's pad_render_ids (audio_control.rs), and main's judge_default signature
(wasapi_cap.rs).
wiring_plan.rs was not mechanical. Main's
|
||
|
|
4fd240deab |
test(android): make the pad-audio self test reachable without a host
ci / web (pull_request) Successful in 1m13s
ci / docs-site (pull_request) Successful in 1m13s
apple / swift (pull_request) Successful in 1m20s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m46s
ci / rust-arm64 (pull_request) Successful in 2m37s
android / android (pull_request) Successful in 3m58s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m13s
ci / rust (pull_request) Successful in 4m6s
The self test shipped in the previous commit was gated behind a capture, which needs a stream, which needs a host — so it depended on precisely the thing it exists to rule out. It could not have been run in the situation that motivated it. It is now a "Test haptics" button on the DualSense passthrough card in Settings → Controllers → Connected controllers, which is reachable with no session at all. It opens its OWN connection to the pad — the same rule the renderer follows, and the rule whose violation caused the fault this test looks for — runs the tone on a worker thread, and reports a plain-language result: which of open / write / no-data failed, or how many frames reached the pad. The debug-property trigger stays for the in-session case; this is the one that answers "can this phone drive this pad at all" before a host is even involved. |
||
|
|
8ee224e5db |
fix(android): advertise CLIENT_CAP_PAD_AUDIO, without which nothing is ever sent
ci / web (pull_request) Successful in 1m6s
apple / swift (pull_request) Successful in 1m20s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m35s
ci / docs-site (pull_request) Successful in 1m14s
android / android (pull_request) Successful in 3m2s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m20s
ci / rust-arm64 (pull_request) Successful in 5m27s
ci / rust (pull_request) Successful in 12m30s
A gap in the previous commits, and the same silent-failure shape as the two they fixed. There are TWO negotiations, not one: the per-pad render capabilities that ride a gamepad arrival (bits 8/9), which those commits set, and the SESSION-level CLIENT_CAP_PAD_AUDIO in the Hello, which they did not. Without the latter the host never sets HOST_CAP_PAD_AUDIO and emits no 0xD1 at all — so the per-pad bits would have had nothing to gate, and the renderer would have sat on a permanently empty plane with every other piece looking correct. Threaded as an explicit `padAudioOk` on nativeConnect rather than advertised unconditionally: the cap makes a Windows host provision pad endpoints at startup, and a user who has pad audio switched off should not pay for that. Found by tracing what an on-glass run against a real host would actually need, not by a test — there is no test that could have caught it, since both halves are individually well-formed. |
||
|
|
e8499e6131 |
feat(android): wire tier-A pad audio through the capture lifecycle and settings
apple / swift (pull_request) Successful in 1m19s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m4s
android / android (pull_request) Successful in 5m27s
ci / rust (pull_request) Canceled after 3m50s
ci / rust-arm64 (pull_request) Canceled after 3m50s
ci / docs-site (pull_request) Canceled after 31s
windows / build (aarch64-pc-windows-msvc) (pull_request) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (pull_request) Canceled after 0s
The Kotlin half. Turns out Android needs to claim nothing extra: `uac-host` claims the pad's audio interface itself through usbfs on the fd, and usbfs claims are per interface, so the HID claim `HidUsbLink` already holds is untouched. The link therefore surrenders only its file descriptor. Two orderings carry the whole design, and both are easy to get wrong: - **Start on the first report, not at claim time.** The wire pad index does not exist until the router opens a slot, and the host addresses the 0xD1 stream by that index — starting earlier would declare capabilities for a pad that has no index yet. - **Stop before the link closes.** `usb.stop()` closes the connection whose descriptor the render thread borrows, so `padAudio.stop()` runs first, at the top of `DsCapture.stop()`. `nativeStopPadAudio` does not return until the thread is joined, which is what makes the borrow sound rather than merely usually-fine. `DsCapture` decides WHEN (it owns the wire index and the link lifetime); `StreamScreen` decides WHETHER (it owns the session handle and the settings). The capture stays ignorant of sessions. Settings: `padHaptics` defaults on — it is the whole point, and this client's rumble already drives the same actuators, so tier A is a strict improvement. `padSpeaker` defaults OFF: it is a small loudspeaker in the user's hands playing audio they can already hear, and surprising someone with that is worse than making them opt in. Verified: APK builds, and both JNI entry points are exported in the shipped arm64 .so — a missing one would be an UnsatisfiedLinkError only at runtime. 12 Rust tests, 0 clippy findings, fmt clean. |
||
|
|
b297542c4d |
feat(clients/input): controllers can stop being forwarded, for couches that hand the pad over another way
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m4s
apple / swift (pull_request) Successful in 1m21s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m39s
ci / docs-site (pull_request) Successful in 2m6s
ci / rust-arm64 (pull_request) Successful in 2m50s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m55s
android / android (pull_request) Successful in 3m27s
ci / rust (pull_request) Successful in 7m59s
A controller that reaches the host by USB passthrough — VirtualHere and friends, or simply a pad plugged into the host — arrived there twice: once as the real device, once as the virtual pad this client built from the same hands. Games read both, so a stick drifts against the centred second pad and menus take every input twice. New per-client setting, "Forward controllers", default on (today's behaviour). It is tier-P, so a profile can decline what another profile forwards. On Linux and Windows it is deliberately stronger than "send nothing". Opening a controller is what CLAIMS it — SDL's HIDAPI drivers take the device node — and a claimed device is one a passthrough tool cannot bind, so with this off the session opens no slot at all and never enables the Valve HIDAPI drivers. Menu navigation is untouched: the launcher still opens the active pad, and a session supersedes menu mode whether it forwards or not, so the pad is free for the whole time a stream is up. The consequence, documented at both the setting and the chord: the controller escape chord is read off forwarded pads, so it is unavailable there. The Apple and Android input stacks claim nothing, so those clients keep their slots and their chords and only gate the wire sends — losing tvOS's only controller way out of a stream would have been the worse bug. Android does stop its DualSense and Steam Controller 2 USB captures, which do claim the device. Surfaces: GTK, WinUI, the console settings screen, Apple's touch and gamepad settings, the Android touch and gamepad settings, and Decky (which also hides the rows that now have nothing to act on). Everywhere the "which pad" and "pad type" rows grey out while it is off. Verified: cargo clippy --all-targets -D warnings + 79 tests on pf-client-core, pf-console-ui, punktfunk-client-session and punktfunk-client-linux (linux/amd64 container, gate proven non-vacuous with a planted error); swift build for the Apple clients; gradle compile + 49 unit tests for Android (likewise proven); tsc for Decky. clients/windows is UNCOMPILED — both Windows boxes were offline; its edits were reviewed against the helper signatures by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2d3f9f8690 |
Merge remote-tracking branch 'origin/main' into audio/mic-latency-echo
ci / docs-site (push) Successful in 1m28s
ci / web (push) Successful in 2m36s
ci / rust (push) Failing after 2m43s
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
deb / build-publish (push) Successful in 3m30s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 56s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
ci / rust-arm64 (push) Successful in 4m10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m35s
deb / build-publish-client-arm64 (push) Successful in 3m38s
docker / builders-arm64cross (push) Successful in 5s
docker / deploy-docs (push) Successful in 30s
arch / build-publish (push) Successful in 7m0s
android / android (push) Canceled after 7m27s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 5m44s
release / apple (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 3m12s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 3m11s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 1m40s
windows-host / package (push) Canceled after 7m13s
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 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
|
||
|
|
951bcec650 | Merge remote-tracking branch 'origin/main' into audio/mic-latency-echo | ||
|
|
badda070ef |
docs(android): the stats-array KDoc counts the doubles it actually returns
ci / rust-arm64 (push) Successful in 1m25s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
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 5s
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 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
ci / web (push) Successful in 2m12s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 17s
ci / docs-site (push) Successful in 2m1s
docker / builders-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 31s
android / android (push) Canceled after 6m2s
ci / rust (push) Canceled after 5m55s
nativeVideoStats grew to 33 with the decode split and the overflow counter, but its own KDoc still promised 30 and StatsOverlay still said 26 — a count that was already two extensions stale before this one. Both now list the full index set, with the JNI KDoc named as the authoritative one so the next extension has a single place to update. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f9c56eaf5c |
feat(android): Automatic prefers AV1 where the silicon says it should
ci / docs-site (push) Successful in 2m12s
ci / web (push) Successful in 2m36s
ci / rust-arm64 (push) Successful in 3m20s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 6s
android / android (push) Successful in 6m13s
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 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 42s
ci / rust (push) Canceled after 6m49s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Successful in 3m42s
arch / build-publish (push) Successful in 7m46s
deb / build-publish (push) Successful in 5m58s
deb / build-publish-host (push) Successful in 5m58s
apple / swift (push) Successful in 6m9s
apple / screenshots (push) Canceled after 3m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 6m53s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 6m21s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
The P3 format A/B (NP3 ↔ RTX 4090, identical conditions) measured AV1 ~1.2 ms faster end-to-end than HEVC with slightly better codec-pure decode time. Under "Automatic" the client now sends AV1 as its soft preference when this device hardware-decodes it (the advertised AV1 bit is already gated on a real, non-blocked hardware decoder) AND it lacks FEATURE_PartialFrame — a partial-frame device keeps HEVC, whose slice-progressive overlap AV1 cannot ride (no slices, the chunked poll never arms). The host honors the preference only inside its probed shared codec set, so an AV1-less encoder still resolves HEVC, and an explicit user choice wins unchanged. The codec picker caption mirrors the same rule so "Automatic" says what it does on this device. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
b69ef02f4d |
fix(android): a cold-start connect no longer loses HDR or the native mode
A punktfunk:// deep link can reach the connect before the activity is attached to its display; context.display then throws and the display probes silently fell to their worst answers — displaySupportsHdr advertised SDR (the whole session pinned to 8-bit BT.709) and nativeDisplayMode fell back to 1080p60. Seen live on the NP3: one cold connect advertised hdr=false, the warm retry true, nothing in the log either way. Both probes now share probeDisplay: the context display when attached, else DisplayManager DEFAULT_DISPLAY — which IS the panel on phones and TVs; the activity-display distinction only matters on multi-display setups, where the attached path still wins whenever available. Each fallback leg logs itself, so a downgraded session can never again be silent about why. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
849baea881 |
feat(android): the stream re-votes its refresh rate and touches keep their curvature
surfaceChanged re-asserts the frame-rate vote (FIXED_SOURCE; ALWAYS only on the TV low-latency path, mirroring the native hint) — a buffer-geometry change on some OEM builds silently drops the 120 Hz pin mid-stream. Touch passthrough and direct-pointer moves forward the MotionEvent historical samples before the current point, so a fast swipe lands with its real shape; the trackpad path keeps summed deltas on purpose — its acceleration curve is tuned for per-frame dt and historicals would change the feel, not the sum. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c767a904d2 |
feat(android): the decode stage answers where its time goes, HUD on or off
P3 decode science: every AU is stamped as its last piece enters the codec, so the decode stage splits into feed (received→queued: hand-off + input-slot wait) and codec (queued→decoded: the decoder alone — a slice head start would show here). The split + an always-on capture→decoded e2e ride the 1 Hz pf-present line, so a wireless HUD-off A/B reads everything from logcat; the HUD equation gains the split (indices 30/31), the skipped counter tells benign newest-wins pacing from parked-AU overflow (32), and a −2-refresh Apple-HUD-equivalent twin makes iPhone comparisons honest (Apple shaves its OS floor; Android shows raw). Connect now logs the per-mime decoder picks + FEATURE_PartialFrame verdicts (tag pf.caps) — on the NP3 all three c2.qti low-latency decoders say no, so parts delivery never arms and P2d is inert there; a debug.punktfunk.force_parts sysprop overrides the probe for the on-glass question the API cannot answer. Forced on glass: c2.qti accepts PARTIAL_FRAME pieces without erroring but only assembles them — codec time unchanged, so the overlap is dead on SM8735 either way. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
253e0bbe7c |
feat(android): the mic has an off switch you can reach mid-stream
Wave 1 gave the Android client a mic worth using. It gave it no way to stop talking: leaving the stream, or digging through Settings to turn the whole feature off, were the only ways to stop the room being heard. Now a tap (or Select + Y on a pad) mutes it, and the screen says so while it lasts. How muting gates the capture, and why that way. The AAudio input stream is never stopped: a stop/start would re-run the input-preset fallback ladder and re-prime the buffers on every toggle — hundreds of milliseconds, and possibly a landing on a different rung, silently losing the HAL echo canceller wave 1 went to some trouble to get. Instead the encode loop reads an AtomicBool per 10 ms frame and, while it is set, drains the frame out of its ring and drops it there — the last point before it would have become an Opus packet. Nothing is encoded, nothing is sent, and the realtime capture callback is untouched, so its allocation-free discipline and the queue policy stay exactly as wave 1 verified them. A toggle costs one atomic store and takes effect on the next 10 ms boundary. The frame counter keeps advancing across a mute, because it numbers the captured 10 ms TIMELINE rather than the datagrams. The gap the host then sees is exactly the audio that never came: its de-jitter conceals at most a few frames of it before the pump's 600 ms stale-gap flush resets the chain outright, which is the right reading of a mute. Encoding silence instead would have kept a pointless uplink and a host-side ring alive for its whole duration. Mute is per session and nothing is persisted — a new stream always starts unmuted, and no new setting exists. The flag lives on the session handle rather than on the capture, so the mic stop/start a surface recreate performs brings the user's choice back with it, with no window in which the fresh capture could send an unmuted frame. The control is offered on the evidence that a capture is actually running (nativeMicActive), not on the setting: with the mic disabled, RECORD_AUDIO denied, or every AAudio input rung refused, there is nothing on screen to lie about. On touch it is a pill in the corner the stats HUD doesn't use — the one in-stream control, so it sits above the gesture layer to take its own taps — dim while live, a red "Muted" badge while it isn't. On TV that badge is the indicator alone: Select + Y is the control there, and a focusable button would fight the game for the D-pad. Y is deliberately not one of the exit chord's buttons, so neither chord can be reached through the other. One honest consequence of keeping the stream open: the platform's recording indicator stays lit while muted, because the mic really is still open. What stops is the encode and the send. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a681de7e5c |
feat(android): the mic goes through the echo canceller, and the host stops hearing itself
The capture stream opened under AAudio's default VoiceRecognition input preset, which deliberately bypasses the HAL's acoustic echo canceller — so a phone playing the game audio out of its own speaker fed that audio straight back to the host. Two layers fix it, both behind a new "Echo cancellation" setting (default ON, next to the Microphone toggle in the touch and console settings, per-profile like every tier-P setting): - Native: the mic opens under the VoiceCommunication preset (HAL AEC/NS on the capture path) and allocates an audio session id. The open ladder is Exclusive+voice → Shared+voice → Exclusive → Shared — some HALs refuse the preset or a session id outright, and a mic without echo cancellation still beats no mic; the last rungs are exactly the preset-less open this always did. - Kotlin backstop: nativeStartMic now returns the allocated session id (0 = none), and StreamScreen hangs the Java AcousticEchoCanceler + NoiseSuppressor off it (guarded by isAvailable), releasing them on every mic-stop path — the surface teardown and the final dispose — so a surface recreate re-attaches instead of leaking effect engines. The playback stream is untouched: retagging it voice/communication would route it through the phone-call chain and regress quality. 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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |