Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
332a81b077 | ||
|
|
f692a1877c | ||
|
|
d715225960 | ||
|
|
e1198495e0 | ||
|
|
6336e16350 | ||
|
|
f62a48d4a9 | ||
|
|
c817e4bec0 | ||
|
|
ad806be70a | ||
|
|
3afc17a06d | ||
|
|
f90a4e5f97 | ||
|
|
430c9831ef | ||
|
|
4b48748b8a | ||
|
|
0b550dad84 | ||
|
|
cac500acc7 |
@@ -184,6 +184,26 @@ jobs:
|
||||
working-directory: clients/android
|
||||
run: ./gradlew :kit:testDebugUnitTest --stacktrace
|
||||
|
||||
# The cross-client contract in `clients/shared/console-vectors.json` — the console palette
|
||||
# table, the settings section names and the screen-transition motion, each of which exists in
|
||||
# three hand-written copies (here, pf-console-ui, the Apple client). The other two check it
|
||||
# from their own suites; this is Android's side.
|
||||
#
|
||||
# FILTERED, not a plain `:app:testDebugUnitTest`: that task also runs the ~20 Roborazzi
|
||||
# screenshot scenes, which are a release-artifact job (android-screenshots.yml, gated to v*
|
||||
# tags) and have no business adding a minute to every push. The filter is what lets the
|
||||
# contract gate here without dragging the rest of the app suite in with it.
|
||||
- name: console parity vectors + app-module logic tests
|
||||
working-directory: clients/android
|
||||
run: >-
|
||||
./gradlew :app:testDebugUnitTest
|
||||
--tests 'io.unom.punktfunk.ConsoleVectorsTest'
|
||||
--tests 'io.unom.punktfunk.HomeTilesTest'
|
||||
--tests 'io.unom.punktfunk.GamepadSettingsLayoutTest'
|
||||
--tests 'io.unom.punktfunk.ConsoleSubScreenRowsTest'
|
||||
--tests 'io.unom.punktfunk.ConsoleSubScreenRoutesTest'
|
||||
--stacktrace
|
||||
|
||||
- name: assembleDebug (cargo-ndk → jniLibs → APK)
|
||||
working-directory: clients/android
|
||||
env:
|
||||
|
||||
@@ -91,8 +91,27 @@ jobs:
|
||||
# advisory, the same fail-on-vulnerability stance as cargo-audit above; triage a finding by
|
||||
# bumping the dep (or, if genuinely unfixable + inapplicable, pinning a resolution and
|
||||
# noting why here).
|
||||
#
|
||||
# web carries two ignores, the ONLY ones in a blocking tree — both image-size advisories
|
||||
# (GHSA-w3rx-r6r6-pgpr ICNS, GHSA-5p2g-fcmc-qvqq JXL/HEIF infinite-loop DoS). They are
|
||||
# unfixable AND unreachable:
|
||||
# * unfixable — the vulnerable range is `<= 2.0.2` and 2.0.2 IS latest; upstream has
|
||||
# published no patched release, so no override can clear them.
|
||||
# * unreachable — image-size rides in under `@unom/ui › @payloadcms/richtext-lexical ›
|
||||
# … › payload`, and @payloadcms/richtext-lexical is a PEER of @unom/ui that only its
|
||||
# `./richtext` export needs. The console imports section/toast/button/card/dialog/
|
||||
# form/*/material/tabs — never `./richtext` — so payload is auto-installed peer weight
|
||||
# that no bundle, and no request path, ever touches.
|
||||
# Drop these the moment image-size ships a fix, or @unom/ui marks that peer optional
|
||||
# (peerDependenciesMeta) and the chain leaves web/bun.lock entirely — either one makes the
|
||||
# bare `bun audit` green again. Scoped per-tree so sdk/plugin-kit stay strictly fail-on-any.
|
||||
- name: bun audit
|
||||
run: bun audit
|
||||
run: |
|
||||
if [ "${{ matrix.tree }}" = "web" ]; then
|
||||
bun audit --ignore=GHSA-w3rx-r6r6-pgpr --ignore=GHSA-5p2g-fcmc-qvqq
|
||||
else
|
||||
bun audit
|
||||
fi
|
||||
|
||||
# Kept OUT of the bun-audit matrix so this tree's known-advisory state can't normalize failure
|
||||
# in a shipping tree. Non-blocking via a step-level `||` (NOT job-level continue-on-error, which
|
||||
|
||||
@@ -12,6 +12,36 @@ with the version table of the release you are moving to, then read **Breaking ch
|
||||
|
||||
---
|
||||
|
||||
## v0.27.1 — in development
|
||||
|
||||
### NixOS + KDE — session detection, the other half
|
||||
|
||||
🛑 **v0.27.0's NixOS session-detection fix did not reach a stock NixOS + Plasma 6 box.** It resolved
|
||||
the nixpkgs wrapper decoration through `/proc/<pid>/exe` (below) — and on that exact box the kernel
|
||||
refuses to let us read that link. Reading `/proc/<pid>/exe` is not gated on owning the process: it
|
||||
goes through `cap_ptrace_access_check`, which requires the reader's effective set to be a superset
|
||||
of the target's **permitted** set. NixOS's own Plasma module ships
|
||||
`security.wrappers.kwin_wayland = { capabilities = "cap_sys_nice+ep"; }`, so KWin holds a capability
|
||||
and the host — which must stay uncapped, because a capability is exactly what makes it
|
||||
unidentifiable to KWin (v0.27.0, above) — gets `EACCES`. The two traps compose: the name *needs*
|
||||
`exe` because nixpkgs wrapped the binary, and `exe` is *denied* because NixOS capped it. Detection
|
||||
went straight back to `ActiveKind::None`, `wayland` to `-`, and every connect to
|
||||
`no usable compositor`. It presents identically to the v0.27.0 bug, which is why a box that had been
|
||||
worked around with a decoy process broke again the moment the decoy was removed.
|
||||
|
||||
Name resolution now falls through to `argv[0]` (`/proc/<pid>/cmdline`) when the kernel refuses `exe`.
|
||||
That reads correctly for the same reason `ps` does: make-wrapper's wrapper `exec -a "$0"`s the hidden
|
||||
binary, so `argv[0]` survives the decoration `comm` does not. Measured on Linux 6.x against a capped
|
||||
target, for a file capability and for the ambient form `security.wrappers` uses, identically: the
|
||||
`/proc/<pid>` directory keeps its real owner (so the uid filter was never the problem), `comm` and
|
||||
`cmdline` stay readable, and only `exe` fails. `argv[0]` is consulted **last** and never overrides a
|
||||
readable `exe` — it is the process's own claim about itself, and a same-uid process can set it to
|
||||
anything; the worst a spoof achieves is aiming detection at a backend that then fails its own
|
||||
availability probe. The `comm` fast path is still one read for every ordinary distro.
|
||||
|
||||
Also reached by the same rung: `gamescope` carries `cap_sys_nice` on a number of distros, so a
|
||||
*wrapped and capped* gamescope was equally invisible to the foreign-gamescope probe.
|
||||
|
||||
## v0.27.0
|
||||
|
||||
87 commits since v0.26.0.
|
||||
|
||||
@@ -3078,6 +3078,7 @@ dependencies = [
|
||||
"pf-presenter",
|
||||
"punktfunk-core",
|
||||
"sdl3",
|
||||
"serde_json",
|
||||
"skia-safe",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.25.0"
|
||||
"version": "0.26.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -4489,6 +4489,13 @@
|
||||
],
|
||||
"description": "The provider's own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is."
|
||||
},
|
||||
"icon": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Which brand mark a client should draw for this entry — see [`GameEntry::icon`]. A token\n(`steam`, `heroic`), never bytes and never a URL."
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)."
|
||||
@@ -4555,6 +4562,13 @@
|
||||
"$ref": "#/components/schemas/DetectHint",
|
||||
"description": "How to recognize this title's process — see [`CustomEntry::detect`]."
|
||||
},
|
||||
"icon": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Which brand mark to draw — see [`GameEntry::icon`]. Hand-settable for the same reason `role`\nis: an operator's own \"Steam\" tile should be able to look like one."
|
||||
},
|
||||
"launch": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -5350,6 +5364,14 @@
|
||||
"art": {
|
||||
"$ref": "#/components/schemas/Artwork"
|
||||
},
|
||||
"icon": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Which brand mark to draw for this entry, as a **token** — `steam`, `heroic`, `playnite` —\nnever image bytes and never a URL. See [`is_icon_token`].\n\nIt exists for launcher tiles, which by design ship no cover art: a launcher's own icon is\nsquare, every client cover-crops a 2:3 poster, and the crop turns a mark into a strip — so\nuntil now those tiles were the launcher's name on a flat accent face. The token lets a client\ndraw the real mark from art it already ships, at whatever size its tile happens to be.\n\nA token rather than art on the wire because the host's art proxy serves *raster* bytes only\n([`art::local_art_bytes`] sniffs the container and refuses anything else, SVG very much\nincluded — it is script-capable XML and the console renders art in a browser). Sending the\nname of a mark instead of the mark keeps that refusal intact, keeps the glyph vector at every\ntile size, and lets it take the tile's ink.\n\nOrdinary titles may carry one too — nothing here is launcher-specific — but nothing sets it\nfor them: a game has real cover art, which is strictly better than a brand mark.",
|
||||
"example": "steam"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Stable, store-qualified id: `steam:<appid>` or `custom:<id>`.",
|
||||
@@ -6869,6 +6891,13 @@
|
||||
"type": "string",
|
||||
"description": "The provider's stable id for this title (the reconcile diff key)."
|
||||
},
|
||||
"icon": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Which brand mark to draw — see [`GameEntry::icon`]. This is the field a library plugin sets\non its `launchers(cfg)` tiles, and the whole reason the token exists."
|
||||
},
|
||||
"launch": {
|
||||
"oneOf": [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
Font Awesome Free — brand icons (steam, xbox in assets/launcher-icons/) are from
|
||||
Font Awesome Free.
|
||||
|
||||
Copyright (c) Fonticons, Inc. (https://fontawesome.com)
|
||||
|
||||
Font Awesome Free icons are licensed under the Creative Commons Attribution 4.0
|
||||
International license (CC BY 4.0), https://creativecommons.org/licenses/by/4.0/.
|
||||
The icons are redistributed here as monochrome SVG path data with no
|
||||
modifications beyond color normalization (fill="currentColor").
|
||||
|
||||
Per the Font Awesome Free license (https://fontawesome.com/license/free):
|
||||
"Font Awesome Free is free, open source, and GPL friendly. You can use it for
|
||||
commercial projects, open source projects, or really almost whatever you want.
|
||||
Attribution is required by MIT, SIL OFL, and CC BY licenses."
|
||||
|
||||
Brand icons are trademarks of their respective owners and are used for
|
||||
identification purposes only; their use does not imply endorsement.
|
||||
@@ -0,0 +1,29 @@
|
||||
Playnite — the `playnite` mark in assets/launcher-icons/ is the Playnite logo from the
|
||||
Playnite source repository (media/playnite-logo-black.svg).
|
||||
|
||||
Copyright (c) 2020 Josef Nemec (https://github.com/JosefNemec/Playnite)
|
||||
|
||||
Licensed under the MIT License:
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without limitation the rights to use, copy,
|
||||
modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
|
||||
and to permit persons to whom the Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
Modifications: the colour was normalized to fill="currentColor"; the original viewBox
|
||||
(0 0 1024 1024) and path geometry are unchanged.
|
||||
|
||||
Brand icons are trademarks of their respective owners and are used for identification
|
||||
purposes only; their use does not imply endorsement.
|
||||
@@ -0,0 +1,13 @@
|
||||
Simple Icons — brand icons (lutris, heroic, epic, gog in assets/launcher-icons/) are
|
||||
from Simple Icons
|
||||
(https://simpleicons.org, https://github.com/simple-icons/simple-icons).
|
||||
|
||||
Upstream slugs: lutris, heroicgameslauncher, epicgames, gogdotcom.
|
||||
|
||||
The Simple Icons SVG path data is released under CC0 1.0 Universal (public domain
|
||||
dedication), https://creativecommons.org/publicdomain/zero/1.0/ — no attribution
|
||||
required; this notice is provided for provenance.
|
||||
|
||||
Brand icons are trademarks of their respective owners and are used for
|
||||
identification purposes only; their use does not imply endorsement. See
|
||||
https://github.com/simple-icons/simple-icons/blob/develop/DISCLAIMER.md.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Launcher icon masters
|
||||
|
||||
The brand marks a **launcher tile** draws — the entries a library plugin publishes with
|
||||
`role: "launcher"` (design D4), which open Steam Big Picture or Heroic or Playnite rather
|
||||
than a game. One file per **icon token**, the value a plugin puts in an entry's `icon`
|
||||
field and every client resolves against the set it ships.
|
||||
|
||||
| token | mark | emitted by | source |
|
||||
|---|---|---|---|
|
||||
| `steam` | Steam | punktfunk-plugin-steam (Big Picture + desktop) | Font Awesome Free brands (CC BY 4.0) |
|
||||
| `lutris` | Lutris | punktfunk-plugin-lutris | Simple Icons (CC0 1.0) |
|
||||
| `heroic` | Heroic Games Launcher | punktfunk-plugin-heroic | Simple Icons (CC0 1.0, slug `heroicgameslauncher`) |
|
||||
| `playnite` | Playnite | punktfunk-plugin-playnite | JosefNemec/Playnite (MIT) |
|
||||
| `epic` | Epic Games | punktfunk-plugin-epic — **dormant** | Simple Icons (CC0 1.0, slug `epicgames`) |
|
||||
| `gog` | GOG.com | punktfunk-plugin-gog — **dormant** | Simple Icons (CC0 1.0, slug `gogdotcom`) |
|
||||
| `xbox` | Xbox | punktfunk-plugin-xbox — **dormant** | Font Awesome Free brands (CC BY 4.0) |
|
||||
|
||||
The last three are **dormant on purpose**: those plugins carry a `launcher` config switch that
|
||||
is off by default and whose `launcherEntries` returns nothing, because the host has no verified
|
||||
`launcher_ui` activation for them yet — a tile would be a card that does nothing. Their marks
|
||||
ship anyway so that turning one on stays the one-line plugin change those plugins promise,
|
||||
instead of also needing a release of all six clients.
|
||||
|
||||
`steam` is the same mark as `assets/os-icons/steam.svg`, generated from that file rather than
|
||||
re-sourced, so the SteamOS host badge and the Steam launcher tile can never drift apart.
|
||||
|
||||
## Why a token and not the icon itself
|
||||
|
||||
A plugin sends the **name** of a mark, never its bytes, and never a URL.
|
||||
|
||||
The obvious alternative — a plugin ships its own `icon.svg` and the host's art proxy serves it —
|
||||
is closed by construction, and deliberately: `local_art_bytes` serves what the bytes *are*
|
||||
(`sniff_image_type`, `crates/punktfunk-host/src/library/art.rs`), and SVG is not on that list
|
||||
because it is script-capable XML and the web console renders library art in a browser. Widening
|
||||
that sniff to admit SVG would trade a rendering nicety for a stored-XSS surface.
|
||||
|
||||
Sending a token instead keeps that refusal intact and buys three things a proxied image could
|
||||
not have given us anyway: the glyph stays vector at every tile size a client picks, it takes the
|
||||
tile's own ink instead of arriving pre-coloured, and it costs no fetch, no cache and no bytes on
|
||||
a reconcile that is already body-limited.
|
||||
|
||||
The cost is that a **third-party** plugin cannot ship a mark no client bundles. Its tile falls
|
||||
back to the launcher's name on an accent face — exactly what every launcher tile looked like
|
||||
before this existed — and the fix is a pull request adding the master here.
|
||||
|
||||
All files are monochrome (`fill="currentColor"`), original per-icon viewBoxes preserved. Those
|
||||
viewBoxes are not all square (`0 0 24 24`, `0 0 496 512`, `0 0 1024 1024`), so **a client must
|
||||
letterbox rather than stretch** — a mark drawn to a square box is a squashed mark.
|
||||
|
||||
## Regenerating the per-client derivatives
|
||||
|
||||
`bash scripts/gen-launcher-icons.sh [token ...]` turns a master into the three baked forms (GTK
|
||||
symbolic SVG, Windows PNG, Apple template PDF) and prints the path data for the three clients
|
||||
that inline it (web console, Android, the in-session console UI). Adding a **new** token also
|
||||
means adding it to each client's shipped-token list — the script prints that checklist too.
|
||||
|
||||
## Licensing
|
||||
|
||||
Attribution notices live in `LICENSES/` and are folded into `THIRD-PARTY-NOTICES.txt` by
|
||||
`scripts/gen-third-party-notices.py`. The marks are trademarks of their respective owners; they
|
||||
are used here nominatively — to *identify* the launcher a tile opens, the standard practice in
|
||||
this ecosystem — and imply no affiliation or endorsement.
|
||||
@@ -0,0 +1,2 @@
|
||||
<!-- epic — from Simple Icons (CC0 1.0), slug `epicgames`. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M3.537 0C2.165 0 1.66.506 1.66 1.879V18.44a4.262 4.262 0 00.02.433c.031.3.037.59.316.92.027.033.311.245.311.245.153.075.258.13.43.2l8.335 3.491c.433.199.614.276.928.27h.002c.314.006.495-.071.928-.27l8.335-3.492c.172-.07.277-.124.43-.2 0 0 .284-.211.311-.243.28-.33.285-.621.316-.92a4.261 4.261 0 00.02-.434V1.879c0-1.373-.506-1.88-1.878-1.88zm13.366 3.11h.68c1.138 0 1.688.553 1.688 1.696v1.88h-1.374v-1.8c0-.369-.17-.54-.523-.54h-.235c-.367 0-.537.17-.537.539v5.81c0 .369.17.54.537.54h.262c.353 0 .523-.171.523-.54V8.619h1.373v2.143c0 1.144-.562 1.71-1.7 1.71h-.694c-1.138 0-1.7-.566-1.7-1.71V4.82c0-1.144.562-1.709 1.7-1.709zm-12.186.08h3.114v1.274H6.117v2.603h1.648v1.275H6.117v2.774h1.74v1.275h-3.14zm3.816 0h2.198c1.138 0 1.7.564 1.7 1.708v2.445c0 1.144-.562 1.71-1.7 1.71h-.799v3.338h-1.4zm4.53 0h1.4v9.201h-1.4zm-3.13 1.235v3.392h.575c.354 0 .523-.171.523-.54V4.965c0-.368-.17-.54-.523-.54zm-3.74 10.147a1.708 1.708 0 01.591.108 1.745 1.745 0 01.49.299l-.452.546a1.247 1.247 0 00-.308-.195.91.91 0 00-.363-.068.658.658 0 00-.28.06.703.703 0 00-.224.163.783.783 0 00-.151.243.799.799 0 00-.056.299v.008a.852.852 0 00.056.31.7.7 0 00.157.245.736.736 0 00.238.16.774.774 0 00.303.058.79.79 0 00.445-.116v-.339h-.548v-.565H7.37v1.255a2.019 2.019 0 01-.524.307 1.789 1.789 0 01-.683.123 1.642 1.642 0 01-.602-.107 1.46 1.46 0 01-.478-.3 1.371 1.371 0 01-.318-.455 1.438 1.438 0 01-.115-.58v-.008a1.426 1.426 0 01.113-.57 1.449 1.449 0 01.312-.46 1.418 1.418 0 01.474-.309 1.58 1.58 0 01.598-.111 1.708 1.708 0 01.045 0zm11.963.008a2.006 2.006 0 01.612.094 1.61 1.61 0 01.507.277l-.386.546a1.562 1.562 0 00-.39-.205 1.178 1.178 0 00-.388-.07.347.347 0 00-.208.052.154.154 0 00-.07.127v.008a.158.158 0 00.022.084.198.198 0 00.076.066.831.831 0 00.147.06c.062.02.14.04.236.061a3.389 3.389 0 01.43.122 1.292 1.292 0 01.328.17.678.678 0 01.207.24.739.739 0 01.071.337v.008a.865.865 0 01-.081.382.82.82 0 01-.229.285 1.032 1.032 0 01-.353.18 1.606 1.606 0 01-.46.061 2.16 2.16 0 01-.71-.116 1.718 1.718 0 01-.593-.346l.43-.514c.277.223.578.335.9.335a.457.457 0 00.236-.05.157.157 0 00.082-.142v-.008a.15.15 0 00-.02-.077.204.204 0 00-.073-.066.753.753 0 00-.143-.062 2.45 2.45 0 00-.233-.062 5.036 5.036 0 01-.413-.113 1.26 1.26 0 01-.331-.16.72.72 0 01-.222-.243.73.73 0 01-.082-.36v-.008a.863.863 0 01.074-.359.794.794 0 01.214-.283 1.007 1.007 0 01.34-.185 1.423 1.423 0 01.448-.066 2.006 2.006 0 01.025 0zm-9.358.025h.742l1.183 2.81h-.825l-.203-.499H8.623l-.198.498h-.81zm2.197.02h.814l.663 1.08.663-1.08h.814v2.79h-.766v-1.602l-.711 1.091h-.016l-.707-1.083v1.593h-.754zm3.469 0h2.235v.658h-1.473v.422h1.334v.61h-1.334v.442h1.493v.658h-2.255zm-5.3.897l-.315.793h.624zm-1.145 5.19h8.014l-4.09 1.348z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,2 @@
|
||||
<!-- gog — from Simple Icons (CC0 1.0), slug `gogdotcom`. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M7.15 15.24H4.36a.4.4 0 0 0-.4.4v2c0 .21.18.4.4.4h2.8v1.32h-3.5c-.56 0-1.02-.46-1.02-1.03v-3.39c0-.56.46-1.02 1.03-1.02h3.48v1.32zM8.16 11.54c0 .58-.47 1.05-1.05 1.05H2.63v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4H4.39a.4.4 0 0 0-.41.4v2.02c0 .23.18.4.4.4H6v1.35H3.68c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04H7.1c.58 0 1.05.47 1.05 1.04v5.86zM21.36 19.36h-1.32v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.42c0-.56.46-1.02 1.03-1.02h5.61v5.44zM21.37 11.54c0 .58-.47 1.05-1.05 1.05h-4.48v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4h-2.03a.4.4 0 0 0-.4.4v2.02c0 .23.18.4.4.4h1.62v1.35H16.9c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04h3.43c.58 0 1.05.47 1.05 1.04v5.86zM13.72 4.64h-3.44c-.58 0-1.04.47-1.04 1.04v3.44c0 .58.46 1.04 1.04 1.04h3.44c.57 0 1.04-.46 1.04-1.04V5.68c0-.57-.47-1.04-1.04-1.04m-.3 1.75v2.02a.4.4 0 0 1-.4.4h-2.03a.4.4 0 0 1-.4-.4V6.4c0-.22.17-.4.4-.4H13c.23 0 .4.18.4.4zM12.63 13.92H9.24c-.57 0-1.03.46-1.03 1.02v3.39c0 .57.46 1.03 1.03 1.03h3.39c.57 0 1.03-.46 1.03-1.03v-3.39c0-.56-.46-1.02-1.03-1.02m-.3 1.72v2a.4.4 0 0 1-.4.4v-.01H9.94a.4.4 0 0 1-.4-.4v-1.99c0-.22.18-.4.4-.4h2c.22 0 .4.18.4.4zM23.49 1.1a1.74 1.74 0 0 0-1.24-.52H1.75A1.74 1.74 0 0 0 0 2.33v19.34a1.74 1.74 0 0 0 1.75 1.75h20.5A1.74 1.74 0 0 0 24 21.67V2.33c0-.48-.2-.92-.51-1.24m0 20.58a1.23 1.23 0 0 1-1.24 1.24H1.75A1.23 1.23 0 0 1 .5 21.67V2.33a1.23 1.23 0 0 1 1.24-1.24h20.5a1.24 1.24 0 0 1 1.24 1.24v19.34z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,2 @@
|
||||
<!-- heroic — from Simple Icons (CC0 1.0), slug `heroicgameslauncher`. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M11.999 0 11.997 0a.891.891 0 0 0-.36.075C8.964 1.253 6.29 2.434 3.618 3.613A.893.893 0 0 0 3.1 4.619l3.146 14.646c.043.197.15.375.307.504l4.88 4.027a.895.895 0 0 0 1.131.006l5-4.031a.895.895 0 0 0 .315-.516L20.9 4.614a.895.895 0 0 0-.515-1L12.358.074A.892.892 0 0 0 12 0zm0 .35v.003c.114 0 .228.023.334.07l7.42 3.27a.827.827 0 0 1 .476.924l-2.793 13.535a.83.83 0 0 1-.289.478l-4.623 3.725a.826.826 0 0 1-1.045-.006l-4.513-3.723a.829.829 0 0 1-.281-.465L3.775 4.622a.83.83 0 0 1 .476-.931L11.665.42a.832.832 0 0 1 .334-.07zm-.045 1.954L10.28 5.202h-.002l1.211 11.301.512.409.512-.409 1.117-11.3zM9.003 16.261l-.584 1.068.584 1.07 2.295-.38.47-.69-.47-.671zm5.996 0-2.295.397-.47.671.47.69 2.295.38.584-1.07zm-2.998 1.488-.51.444-.281 2.168.789.55.793-.55-.295-2.168z"/></svg>
|
||||
|
After Width: | Height: | Size: 957 B |
@@ -0,0 +1,2 @@
|
||||
<!-- lutris — from Simple Icons (CC0 1.0), slug `lutris`. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="m21.231 18.89.001-.002c-1.293 3.243-5.218 5.232-9.447 5.105C5.3 23.993 0 18.48 0 11.906S5.276.001 11.785.001c1.793 0 3.493.406 5.015 1.13.081-.177.271-.544.451-.557.238-.017.374.137.526.309.154.172.46.429.46.429s1.393-.481 2.955.377c1.563.858 1.783 1.116 2.09 1.716.152.301.195.829.2 1.282a.796.796 0 0 0-.07-.003c-.496 0-.96.455-.96 1.08 0 .263.082.496.215.678l-.01.007a1.505 1.505 0 0 0-.132.01 18.704 18.704 0 0 0-.389-.142 2.53 2.53 0 0 1-.82-.472 1.402 1.402 0 0 0-1.196-2.112c-.383 0-.73.156-.982.41-.472-.271-1.174-.482-2.527-.565l-.407-.011c-2.282.012-3.611.279-5.979 1.301-.603.283-1.206.615-1.785 1.001-.423.3-.639.67-.709 1.137a1.326 1.326 0 0 0 1.23 1.373h.042c1.27.06 2.039 1.99 2.063 2.497.004.05.004.023.003.08-.032.727-.37 1.267-1.088 1.246a1.231 1.231 0 0 1-.976-.494c-.063-.077-.103-.172-.159-.254-.666-1.081-1.732-1.36-2.771-1.523-.438-.068-1.073-.122-1.31.25a8.28 8.28 0 0 0-.577 3.063c-.02 5.036 4.041 9.118 9.026 9.118 2.575 0 5.349-.952 6.993-2.7l-.035.03c-1.772 1.473-4.66 1.941-6.027 1.941-4.302 0-7.818-3.232-7.818-7.578 0-1.276.288-2.396.814-3.36.495.183.947.483 1.28 1.022a.24.24 0 0 0 .013.021c.064.092.111.197.182.284.424.524.881.658 1.342.68h.01c.43.013.768-.12 1.024-.342.347-.3.55-.79.577-1.382v-.014c.002-.085 0-.053-.004-.112-.024-.376-.333-1.318-.906-2.027-.266-.331-.587-.607-.95-.774l.12-.074c.756-.457 2.364-.977 4.592-.638 1.13.173 2.055.419 3.483.879 1.657.534 2.579 1.279 3.854 1.427.15.017.301.018.45.003.41 1.129.634 2.35.634 3.621 0 2.068-.59 3.995-1.611 5.62zm1.947-12.274s-.115.201-.364.322c-.103.05-.282-.075-.45.1-.359.726.516 1.332.923 1.315.408-.017.73-.432.712-.793-.017-.558-.82-.944-.82-.944zm.234-1.432c.255 0 .462.26.462.58 0 .32-.207.58-.462.58-.254 0-.46-.26-.46-.58 0-.32.206-.58.46-.58zm-3.292-.951c.492 0 .89.403.89.9a.895.895 0 0 1-.89.898.895.895 0 0 1-.89-.899c0-.496.399-.899.89-.899z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,2 @@
|
||||
<!-- playnite — from JosefNemec/Playnite media/playnite-logo-black.svg (MIT). See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="currentColor"><path d="M966.686,623.899c-9.773-81.666-29.323-161.25-54.514-239.447c-13.759-42.709-30.419-84.189-56.091-121.452 c-31.701-46.014-74.789-72.958-130.812-78.579c-29.631-2.973-57.785,4.118-85.677,12.35 c-61.172,18.056-123.359,25.124-186.493,14.903c-30.919-5.006-61.308-13.526-91.743-21.225 c-76.445-19.338-145.323,4.995-191.165,69.261c-11.441,16.04-21.194,33.543-29.78,51.312 c-25.091,51.925-40.443,107.249-54.53,162.924c-18.822,74.393-33.019,149.491-33.664,226.571c0,7.184-0.342,14.386,0.061,21.547 c1.557,27.727,4.354,55.289,16.045,80.97c15.334,33.68,45.905,46.725,79.471,31.198c18.291-8.461,36.293-19.857,50.766-33.743 c24.597-23.598,46.616-49.934,69.125-75.64c17.934-20.481,39.086-35.301,66.115-40.203c15.779-2.862,31.802-6.006,47.736-6.118 c87.888-0.62,175.783-0.602,263.673-0.278c51.4,0.189,93.314,19.382,124.091,62.134c12.518,17.388,27.83,32.889,42.78,48.371 c18.598,19.259,38.974,36.431,64.412,46.39c32.967,12.907,62.547,1.677,77.882-30.198c3.965-8.242,6.963-17.122,9.155-26.017 C976.198,727.534,972.874,675.607,966.686,623.899z M315.471,527.643c-44.289,0.213-80.733-36.32-80.847-81.045 c-0.115-45.048,35.472-81.194,80.197-81.458c44.521-0.263,80.718,35.897,80.884,80.801 C395.871,490.671,359.773,527.429,315.471,527.643z M708.857,319.301c21.859,0.06,39.486,17.884,39.471,39.91 c-0.015,22.133-17.489,39.677-39.523,39.682c-22.045,0.005-39.456-17.53-39.444-39.724 C669.372,337.125,687.089,319.241,708.857,319.301z M622.269,486.36c-21.542,0.085-39.7-18.08-39.808-39.822 c-0.108-21.888,17.617-39.622,39.62-39.641c22.066-0.018,39.759,17.552,39.718,39.442 C661.758,468.205,643.909,486.275,622.269,486.36z M708.967,573.333c-21.823,0.096-39.537-17.668-39.611-39.721 c-0.074-22.079,17.523-39.992,39.338-40.044c21.715-0.052,39.597,17.908,39.645,39.816 C748.386,555.477,730.883,573.237,708.967,573.333z M795.752,486.362c-21.764,0.155-39.671-17.882-39.651-39.938 c0.021-22.15,17.628-39.639,39.793-39.525c22.091,0.114,39.527,17.993,39.155,40.152 C834.686,468.733,817.216,486.209,795.752,486.362z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,2 @@
|
||||
<!-- steam — from Font Awesome Free 5 brands (CC BY 4.0); the same mark as assets/os-icons/steam.svg. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512" fill="currentColor"><path d="M496 256c0 137-111.2 248-248.4 248-113.8 0-209.6-76.3-239-180.4l95.2 39.3c6.4 32.1 34.9 56.4 68.9 56.4 39.2 0 71.9-32.4 70.2-73.5l84.5-60.2c52.1 1.3 95.8-40.9 95.8-93.5 0-51.6-42-93.5-93.7-93.5s-93.7 42-93.7 93.5v1.2L176.6 279c-15.5-.9-30.7 3.4-43.5 12.1L0 236.1C10.2 108.4 117.1 8 247.6 8 384.8 8 496 119 496 256zM155.7 384.3l-30.5-12.6a52.79 52.79 0 0 0 27.2 25.8c26.9 11.2 57.8-1.6 69-28.4 5.4-13 5.5-27.3.1-40.3-5.4-13-15.5-23.2-28.5-28.6-12.9-5.4-26.7-5.2-38.9-.6l31.5 13c19.8 8.2 29.2 30.9 20.9 50.7-8.3 19.9-31 29.2-50.8 21zm173.8-129.9c-34.4 0-62.4-28-62.4-62.3s28-62.3 62.4-62.3 62.4 28 62.4 62.3-27.9 62.3-62.4 62.3zm.1-15.6c25.9 0 46.9-21 46.9-46.8 0-25.9-21-46.8-46.9-46.8s-46.9 21-46.9 46.8c.1 25.8 21.1 46.8 46.9 46.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 956 B |
@@ -0,0 +1,2 @@
|
||||
<!-- xbox — from Font Awesome Free 6 brands (CC BY 4.0), `fa-xbox`. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="currentColor"><path d="M369.9 318.2c44.3 54.3 64.7 98.8 54.4 118.7-7.9 15.1-56.7 44.6-92.6 55.9-29.6 9.3-68.4 13.3-100.4 10.2-38.2-3.7-76.9-17.4-110.1-39-27.9-18.2-34.2-25.7-34.2-40.6 0-29.9 32.9-82.3 89.2-142.1 32-33.9 76.5-73.7 81.4-72.6 9.4 2.1 84.3 75.1 112.3 109.5zM188.6 143.8c-29.7-26.9-58.1-53.9-86.4-63.4-15.2-5.1-16.3-4.8-28.7 8.1-29.2 30.4-53.5 79.7-60.3 122.4-5.4 34.2-6.1 43.8-4.2 60.5 5.6 50.5 17.3 85.4 40.5 120.9 9.5 14.6 12.1 17.3 9.3 9.9-4.2-11-.3-37.5 9.5-64 14.3-39 53.9-112.9 120.3-194.4zm311.6 63.5c-16.9-80-67.5-130.3-74.6-130.3-7.3 0-24.2 6.5-36 13.9-23.3 14.5-41 31.4-64.3 52.8 42.4 53.3 102.2 139.4 122.9 202.3 6.8 20.7 9.7 41.1 7.4 52.3-1.7 8.5-1.7 8.5 1.4 4.6 6.1-7.7 19.9-31.3 25.4-43.5 7.4-16.2 15-40.2 18.6-58.7 4.3-22.5 3.9-70.8-.8-93.4zM141.3 43c47.7-2.5 109.7 34.5 114.3 35.4 .7 .1 10.4-4.2 21.6-9.7 63.9-31.1 94-25.8 107.4-25.2-63.9-39.3-152.7-50-233.9-11.7-23.4 11.1-24 11.9-9.4 11.2z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -144,6 +144,10 @@ dependencies {
|
||||
testImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest") // the ComponentActivity test host
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
// Real `org.json` for the shared-vectors test: the `org.json` inside `android.jar` is a stub
|
||||
// set whose every method throws "Stub!", so a plain JVM unit test cannot parse with it. Same
|
||||
// dependency, same reason, as the kit module's deeplink-vectors test.
|
||||
testImplementation("org.json:json:20250107")
|
||||
testImplementation("org.robolectric:robolectric:4.16.1")
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.64.0")
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.64.0")
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import io.unom.punktfunk.models.PendingTrust
|
||||
|
||||
// The prompts that say the SAME thing in both interfaces.
|
||||
//
|
||||
// Every one of these existed twice — a Material `AlertDialog` in ConnectDialogs.kt and a console
|
||||
// glass card in GamepadDialogs.kt — with the two copies maintained by hand. Predictably they
|
||||
// drifted, and always in the direction of the console losing something: "Pair with PIN…" lost its
|
||||
// ellipsis, "if no prompt appears when you tap Allow" became "after Allow", and the speed test
|
||||
// stopped telling console users which layer Apply would write to at all.
|
||||
//
|
||||
// What is shared here is the DESCRIPTION of a prompt — a title, a list of [DialogAction]s and a
|
||||
// body — and what stays per-interface is only how that description is drawn. That split is the
|
||||
// whole point: a copy change now lands in both places because there is only one place.
|
||||
//
|
||||
// ⚠ Deliberately NOT unified, and they belong apart: the PIN ceremony (a numeric keyboard field
|
||||
// and an editable device name on touch; four D-pad digit slots on the console — different input
|
||||
// models, not different skins), Add/Edit Host (a bottom sheet and a full screen with its own
|
||||
// on-screen keyboard), and the host action list (an anchored dropdown vs a modal stack, and the
|
||||
// touch one grows a row per profile).
|
||||
|
||||
/**
|
||||
* One prompt, drawn as whichever interface is running.
|
||||
*
|
||||
* [actions] is ordered PRIMARY FIRST — the console stacks them in that order with the cursor on
|
||||
* the first, and the touch renderer lifts that same first action into `confirmButton` and lays the
|
||||
* rest out beside it. One order, two idioms, no per-dialog bookkeeping.
|
||||
*
|
||||
* The two renderers cannot be one tree: an `AlertDialog` composes into its own platform window
|
||||
* while [ConsoleModal] is a plain Box in the calling tree — which is also why the console one
|
||||
* needs a `BackHandler` and the caller's `navActive` gate while the touch one needs neither.
|
||||
*/
|
||||
@Composable
|
||||
fun PunktfunkDialog(
|
||||
gamepadUi: Boolean,
|
||||
title: String,
|
||||
onDismiss: () -> Unit,
|
||||
actions: List<DialogAction>,
|
||||
/**
|
||||
* False pins the prompt open against a stray tap outside it — for a dialog sitting over work
|
||||
* in flight, where a mis-tap would abandon it. Console-side there is no outside to tap, so
|
||||
* this only reaches the touch renderer.
|
||||
*/
|
||||
dismissOnOutsideTap: Boolean = true,
|
||||
body: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
if (gamepadUi) {
|
||||
GamepadDialog(title = title, onDismiss = onDismiss, actions = actions, body = body)
|
||||
return
|
||||
}
|
||||
val primary = actions.firstOrNull { it.primary } ?: actions.firstOrNull()
|
||||
val rest = actions.filter { it !== primary }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(dismissOnClickOutside = dismissOnOutsideTap),
|
||||
title = { Text(title) },
|
||||
text = { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { body() } },
|
||||
confirmButton = {
|
||||
primary?.let { a ->
|
||||
TextButton(onClick = a.onClick, enabled = a.enabled) { Text(a.label) }
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
if (rest.isNotEmpty()) {
|
||||
Row {
|
||||
rest.forEach { a ->
|
||||
TextButton(onClick = a.onClick, enabled = a.enabled) { Text(a.label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** A prompt's body paragraph, dimmed to sit under the title in either interface. */
|
||||
@Composable
|
||||
private fun PromptText(text: String, gamepadUi: Boolean) {
|
||||
val ink = LocalGamepadInk.current
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (gamepadUi) ink.fg(0.7f) else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
/** First connection to a host that advertised pair=optional: offer TOFU, but pitch PIN pairing. */
|
||||
@Composable
|
||||
fun TrustNewHostPrompt(
|
||||
gamepadUi: Boolean,
|
||||
pt: PendingTrust,
|
||||
onTrust: () -> Unit,
|
||||
onPairInstead: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
PunktfunkDialog(
|
||||
gamepadUi = gamepadUi,
|
||||
title = "Trust this host?",
|
||||
onDismiss = onDismiss,
|
||||
actions = listOf(
|
||||
DialogAction("Trust (TOFU)", primary = true, onClick = onTrust),
|
||||
DialogAction("Pair with PIN…", onClick = onPairInstead),
|
||||
DialogAction("Cancel", onClick = onDismiss),
|
||||
),
|
||||
) {
|
||||
PromptText("First connection to ${pt.host}:${pt.port}.", gamepadUi)
|
||||
pt.advertisedFp?.let { PromptText("Fingerprint ${it.take(16)}…", gamepadUi) }
|
||||
PromptText(
|
||||
"This host allows trust-on-first-use, but that can't tell an impostor from the real " +
|
||||
"host. Pairing with a PIN is stronger — it proves both sides.",
|
||||
gamepadUi,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The pinned fingerprint no longer matches — force re-pairing (never a silent re-trust). */
|
||||
@Composable
|
||||
fun FingerprintChangedPrompt(
|
||||
gamepadUi: Boolean,
|
||||
pt: PendingTrust,
|
||||
onRepair: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
PunktfunkDialog(
|
||||
gamepadUi = gamepadUi,
|
||||
title = "Host identity changed",
|
||||
onDismiss = onDismiss,
|
||||
actions = listOf(
|
||||
DialogAction("Re-pair", primary = true, onClick = onRepair),
|
||||
DialogAction("Cancel", onClick = onDismiss),
|
||||
),
|
||||
) {
|
||||
PromptText(
|
||||
"The pinned fingerprint for ${pt.host} no longer matches what it now advertises. " +
|
||||
"This can mean a host reinstall — or an impostor. Re-pair with the host's PIN to " +
|
||||
"continue.",
|
||||
gamepadUi,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh pair=required (or manual/unknown-policy) host: offer the two ways in. "Request access" is
|
||||
* the no-PIN path — connect and wait for the operator to click Approve in the host's console;
|
||||
* "Use a PIN…" switches to the SPAKE2 ceremony.
|
||||
*/
|
||||
@Composable
|
||||
fun RequestAccessPrompt(
|
||||
gamepadUi: Boolean,
|
||||
pt: PendingTrust,
|
||||
onRequestAccess: () -> Unit,
|
||||
onUsePin: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
PunktfunkDialog(
|
||||
gamepadUi = gamepadUi,
|
||||
title = "Pairing required",
|
||||
onDismiss = onDismiss,
|
||||
actions = listOf(
|
||||
DialogAction("Request access", primary = true, onClick = onRequestAccess),
|
||||
DialogAction("Use a PIN…", onClick = onUsePin),
|
||||
DialogAction("Cancel", onClick = onDismiss),
|
||||
),
|
||||
) {
|
||||
PromptText("${pt.host}:${pt.port} requires pairing before it will stream.", gamepadUi)
|
||||
PromptText(
|
||||
"Request access and approve this device in the host's console (or web UI) — no PIN " +
|
||||
"needed. Or pair with the 4-digit PIN the host displays.",
|
||||
gamepadUi,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The no-PIN "request access" wait: the connect is parked on the host until the operator approves
|
||||
* this device. Cancel returns the UI immediately — the caller trips the per-attempt flag so a late
|
||||
* approval is torn down silently (see ConnectScreen.requestAccess) and resumes discovery.
|
||||
*
|
||||
* Outside taps are ignored: a connect is parked on the host, and a stray tap beside the card is not
|
||||
* a decision to abandon it.
|
||||
*/
|
||||
@Composable
|
||||
fun AwaitingApprovalPrompt(gamepadUi: Boolean, hostLabel: String, onCancel: () -> Unit) {
|
||||
val ink = LocalGamepadInk.current
|
||||
PunktfunkDialog(
|
||||
gamepadUi = gamepadUi,
|
||||
title = "Waiting for approval",
|
||||
onDismiss = onCancel,
|
||||
actions = listOf(DialogAction("Cancel", primary = true, onClick = onCancel)),
|
||||
dismissOnOutsideTap = false,
|
||||
) {
|
||||
val deviceName = Build.MODEL ?: "this device"
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = if (gamepadUi) ink.fg else MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
"Approve this device on $hostLabel.",
|
||||
color = if (gamepadUi) ink.fg else MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
PromptText(
|
||||
"Open the host's console (or web UI) and approve “$deviceName”. It connects " +
|
||||
"automatically once you approve — no PIN needed.",
|
||||
gamepadUi,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Android 17+ Local Network Protection rationale: ACCESS_LOCAL_NETWORK was denied, so discovery and
|
||||
* every connect are dead — offer the system prompt again and a settings deep link (a permanently-
|
||||
* denied request returns instantly without ever showing the prompt, so "Allow" alone isn't enough).
|
||||
*/
|
||||
@Composable
|
||||
fun LocalNetworkPrompt(
|
||||
gamepadUi: Boolean,
|
||||
onAllow: () -> Unit,
|
||||
onSettings: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
PunktfunkDialog(
|
||||
gamepadUi = gamepadUi,
|
||||
title = "Allow local network access",
|
||||
onDismiss = onDismiss,
|
||||
actions = listOf(
|
||||
DialogAction("Allow", primary = true, onClick = onAllow),
|
||||
DialogAction("Open settings", onClick = onSettings),
|
||||
DialogAction("Not now", onClick = onDismiss),
|
||||
),
|
||||
) {
|
||||
PromptText(
|
||||
"Android blocks Punktfunk from talking to devices on your network, so it can't find " +
|
||||
"or reach any host until you allow it.",
|
||||
gamepadUi,
|
||||
)
|
||||
PromptText(
|
||||
"If no prompt appears after you allow it, enable “Nearby devices” for Punktfunk in " +
|
||||
"system settings.",
|
||||
gamepadUi,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The link measurement and what to do with the result. A TV box on a powerline adapter is exactly
|
||||
* the machine whose link is worth measuring, so this belongs on the couch surface too — and so
|
||||
* does [speedTestTargetNote], which the console used to omit, leaving a console user to guess
|
||||
* which layer Apply would write to.
|
||||
*/
|
||||
@Composable
|
||||
fun SpeedTestPrompt(
|
||||
gamepadUi: Boolean,
|
||||
hostName: String,
|
||||
target: SpeedTestTarget,
|
||||
phase: SpeedTestPhase,
|
||||
onApply: (toProfile: Boolean) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val done = phase as? SpeedTestPhase.Done
|
||||
PunktfunkDialog(
|
||||
gamepadUi = gamepadUi,
|
||||
title = "Network speed test",
|
||||
onDismiss = onDismiss,
|
||||
// Measuring bursts traffic for two seconds; a tap outside must not abandon it midway.
|
||||
dismissOnOutsideTap = phase !is SpeedTestPhase.Measuring,
|
||||
actions = buildList {
|
||||
if (done != null) {
|
||||
add(
|
||||
DialogAction(
|
||||
when (target) {
|
||||
SpeedTestTarget.Global -> "Apply"
|
||||
is SpeedTestTarget.Profile -> "Apply to “${target.profile.name}”"
|
||||
is SpeedTestTarget.Ask -> "Set in “${target.profile.name}”"
|
||||
},
|
||||
primary = true,
|
||||
) { onApply(true) },
|
||||
)
|
||||
if (target is SpeedTestTarget.Ask) {
|
||||
add(DialogAction("Set as default") { onApply(false) })
|
||||
}
|
||||
}
|
||||
add(DialogAction("Close", primary = done == null, onClick = onDismiss))
|
||||
},
|
||||
) {
|
||||
PromptText(hostName, gamepadUi)
|
||||
when (phase) {
|
||||
SpeedTestPhase.Connecting -> PromptText("Connecting…", gamepadUi)
|
||||
SpeedTestPhase.Measuring ->
|
||||
PromptText(
|
||||
"Measuring — the host is bursting test traffic for two seconds.",
|
||||
gamepadUi,
|
||||
)
|
||||
is SpeedTestPhase.Failed -> Text(
|
||||
phase.message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (gamepadUi) ink.danger else MaterialTheme.colorScheme.error,
|
||||
)
|
||||
is SpeedTestPhase.Done -> {
|
||||
Text(
|
||||
"%.0f Mbit/s measured · %.1f %% loss".format(phase.measuredMbps, phase.lossPct),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = if (gamepadUi) ink.fg else MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
PromptText(
|
||||
"Recommended bitrate: %.0f Mbit/s".format(phase.recommendedMbps),
|
||||
gamepadUi,
|
||||
)
|
||||
PromptText(speedTestTargetNote(target), gamepadUi)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One line saying which layer an Apply will write to, and why that one. */
|
||||
private fun speedTestTargetNote(target: SpeedTestTarget): String = when (target) {
|
||||
SpeedTestTarget.Global ->
|
||||
"This host uses the default settings, so the bitrate goes there."
|
||||
is SpeedTestTarget.Profile ->
|
||||
"This host streams with “${target.profile.name}”, which sets its own bitrate — " +
|
||||
"that override is what it actually reads."
|
||||
is SpeedTestTarget.Ask ->
|
||||
"This host streams with “${target.profile.name}”, which currently inherits the default " +
|
||||
"bitrate. Setting it in the profile affects only this host's profile; setting it as " +
|
||||
"the default affects everything that inherits it."
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
@@ -14,6 +11,9 @@ import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -21,14 +21,17 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.NavigationRail
|
||||
import androidx.compose.material3.NavigationRailItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
@@ -37,8 +40,10 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
@@ -52,6 +57,8 @@ import io.unom.punktfunk.kit.SessionEndReason
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
import io.unom.punktfunk.models.ActiveSession
|
||||
import io.unom.punktfunk.models.Tab
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun App(forceGamepadUi: Boolean = false) {
|
||||
@@ -211,8 +218,9 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
// The rail handles its own insets; the content pane insets itself (the screens
|
||||
// don't, since they used to rely on the Scaffold's padding).
|
||||
Box(Modifier.weight(1f).fillMaxHeight().systemBarsPadding()) { tabContent(true) }
|
||||
// don't, since they used to rely on the Scaffold's padding). Cutout included:
|
||||
// a tablet in landscape puts its punch on exactly this pane's leading edge.
|
||||
Box(Modifier.weight(1f).fillMaxHeight().consoleSafeArea()) { tabContent(true) }
|
||||
}
|
||||
} else {
|
||||
Scaffold(
|
||||
@@ -245,8 +253,21 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
*/
|
||||
val LocalGamepadPalette = compositionLocalOf { GamepadPalette.named("violet") }
|
||||
|
||||
/** Which console screen the gamepad shell is showing. */
|
||||
private enum class GamepadScreen { Home, Settings, Library }
|
||||
/**
|
||||
* Which console screen the gamepad shell is showing, and how deep it sits — Home is the root, and
|
||||
* everything reachable from it is one level in. The DEPTH is what decides whether a change is a
|
||||
* push or a pop, and therefore which way the screens travel.
|
||||
*/
|
||||
private enum class GamepadScreen(val depth: Int) {
|
||||
Home(0),
|
||||
Settings(1),
|
||||
Library(1),
|
||||
// Reached FROM Settings, not from Home, so they sit a level deeper again — which is precisely
|
||||
// what makes Settings → Controllers travel like a push and the way back like a pop. Give one of
|
||||
// these depth 1 and the transition would read as a sideways swap between two peers.
|
||||
Controllers(2),
|
||||
Licenses(2),
|
||||
}
|
||||
|
||||
/**
|
||||
* The console (gamepad) shell — the Android mirror of the Apple client's ContentView gamepad branch:
|
||||
@@ -270,6 +291,12 @@ fun GamepadShell(
|
||||
val context = LocalContext.current
|
||||
var screen by remember { mutableStateOf(GamepadScreen.Home) }
|
||||
var libraryHost by remember { mutableStateOf<io.unom.punktfunk.kit.security.KnownHost?>(null) }
|
||||
// Where the settings screen was when a sub-screen took over. The shell's AnimatedContent
|
||||
// discards a screen's `remember`s the moment it stops being the target, so a trip out to the
|
||||
// Controllers view and back would otherwise land on the Stream tab's first row — the couch
|
||||
// equivalent of a browser losing your scroll position on Back. Held here because this is the
|
||||
// only thing that outlives the screen.
|
||||
var settingsPlace by remember { mutableStateOf<GpSettingsPlace?>(null) }
|
||||
|
||||
// Consume the "come back to this library" intent once, on entry. Keyed on the id so a second
|
||||
// game exit re-fires it; the parent clears it immediately, so a manual Back stays backed out.
|
||||
@@ -297,12 +324,50 @@ fun GamepadShell(
|
||||
val fitDensity = screenWidthPx / CONSOLE_TV_MIN_WIDTH_DP
|
||||
val consoleDensity = if (isTv && fitDensity < baseDensity.density) fitDensity else baseDensity.density
|
||||
|
||||
// The console's screen transition, and the desktop console's contract rather than a plain
|
||||
// cross-fade (see ConsoleMotion for the numbers and where they come from): a PUSH slides the
|
||||
// incoming screen up out of a fade while the outgoing one recedes; a POP runs it backwards, the
|
||||
// leaving screen sliding down and the revealed one growing back. Direction comes from the
|
||||
// screens' nav DEPTH, so Settings → Home pops even though nothing tracks a stack.
|
||||
//
|
||||
// Each slot's controller nav is gated on being the CURRENT target (`s == screen`), so mid-
|
||||
// transition only the incoming screen drives the pad. All screens pin their legend at the same
|
||||
// ConsoleLegendInset, so it reads as fixed while the content behind it moves.
|
||||
val animated = animationsEnabled()
|
||||
CompositionLocalProvider(LocalDensity provides Density(consoleDensity, baseDensity.fontScale)) {
|
||||
// Cross-fade between console screens so switches are smooth. Each slot's controller nav is gated
|
||||
// on being the CURRENT target (`s == screen`), so during the fade only the incoming screen drives
|
||||
// the pad. All screens pin their legend at the same ConsoleLegendInset, so it reads as fixed while
|
||||
// the content behind it fades.
|
||||
Crossfade(targetState = screen, animationSpec = tween(240), label = "consoleScreen") { s ->
|
||||
// Measured INSIDE the console's own density, not the device's: on a TV the console UI runs at a
|
||||
// reduced density to shrink the 10-foot layout, and a slide sized in device pixels would travel
|
||||
// further than every other dp in the same animation.
|
||||
val slidePx = with(LocalDensity.current) { ConsoleMotion.PUSH_SLIDE.toPx() }.roundToInt()
|
||||
AnimatedContent(
|
||||
targetState = screen,
|
||||
transitionSpec = {
|
||||
if (!animated) {
|
||||
// Reduce-motion: no travel, no scale — just a fast cross-fade, the same courtesy
|
||||
// the frozen backdrop pays.
|
||||
fadeIn(tween(ConsoleMotion.REDUCED_MS)) togetherWith
|
||||
fadeOut(tween(ConsoleMotion.REDUCED_MS))
|
||||
} else if (targetState.depth > initialState.depth) {
|
||||
(
|
||||
fadeIn(ConsoleMotion.ease()) +
|
||||
slideInVertically(ConsoleMotion.ease()) { slidePx } +
|
||||
scaleIn(ConsoleMotion.ease(), initialScale = ConsoleMotion.ENTER_SCALE)
|
||||
) togetherWith (
|
||||
fadeOut(ConsoleMotion.ease()) +
|
||||
scaleOut(ConsoleMotion.ease(), targetScale = ConsoleMotion.EXIT_SCALE)
|
||||
)
|
||||
} else {
|
||||
(
|
||||
fadeIn(ConsoleMotion.ease(), initialAlpha = ConsoleMotion.REVEAL_ALPHA) +
|
||||
scaleIn(ConsoleMotion.ease(), initialScale = ConsoleMotion.EXIT_SCALE)
|
||||
) togetherWith (
|
||||
fadeOut(ConsoleMotion.ease()) +
|
||||
slideOutVertically(ConsoleMotion.ease()) { slidePx }
|
||||
)
|
||||
}
|
||||
},
|
||||
label = "consoleScreen",
|
||||
) { s ->
|
||||
when (s) {
|
||||
GamepadScreen.Home -> ConnectScreen(
|
||||
settings = settings,
|
||||
@@ -318,7 +383,23 @@ fun GamepadShell(
|
||||
GamepadScreen.Settings -> GamepadSettingsScreen(
|
||||
initial = settings,
|
||||
onChange = onSettingsChange,
|
||||
onBack = { screen = GamepadScreen.Home },
|
||||
// Leaving for HOME forgets the place: coming back in from the carousel should start
|
||||
// at the top of the first section, exactly as it always has. Only a sub-screen's
|
||||
// Back is a return.
|
||||
onBack = { screen = GamepadScreen.Home; settingsPlace = null },
|
||||
navActive = s == screen,
|
||||
resume = settingsPlace,
|
||||
onPlace = { settingsPlace = it },
|
||||
onOpenControllers = { screen = GamepadScreen.Controllers },
|
||||
onOpenLicenses = { screen = GamepadScreen.Licenses },
|
||||
)
|
||||
GamepadScreen.Controllers -> ConsoleControllersScreen(
|
||||
gamepadSetting = settings.gamepad,
|
||||
onBack = { screen = GamepadScreen.Settings },
|
||||
navActive = s == screen,
|
||||
)
|
||||
GamepadScreen.Licenses -> ConsoleLicensesScreen(
|
||||
onBack = { screen = GamepadScreen.Settings },
|
||||
navActive = s == screen,
|
||||
)
|
||||
GamepadScreen.Library -> libraryHost?.let { host ->
|
||||
@@ -337,3 +418,128 @@ fun GamepadShell(
|
||||
|
||||
/** Minimum effective dp width the console UI targets on a TV (bigger → the 10-foot UI shrinks). */
|
||||
private const val CONSOLE_TV_MIN_WIDTH_DP = 1180f
|
||||
|
||||
// --- Showing a TOUCH-written screen on the console's field -------------------------------------
|
||||
//
|
||||
// Two screens (Controllers, Licenses) exist once and are shown in both interfaces. They live beside
|
||||
// the shell rather than in `GamepadChrome.kt` because they are about the SHELL's job — putting a
|
||||
// screen that was written for one interface onto the other's field — rather than about the console's
|
||||
// own material.
|
||||
|
||||
/**
|
||||
* Re-inks a screen written against the TOUCH theme so it can be shown on the console's field.
|
||||
*
|
||||
* `ControllersScreen` alone pulls `MaterialTheme.colorScheme` at 27 explicit sites, plus implicitly
|
||||
* through every `OutlinedCard`, `Switch`, `OutlinedButton` and `LinearProgressIndicator` it draws.
|
||||
* Dropped into the shell those keep the touch palette — light-grey body text with no background of
|
||||
* its own, which over the six PALE console palettes (`GamepadPalette`, `light = true`) is grey on
|
||||
* pastel: technically painted, in practice unreadable. That is the same class of bug as the console
|
||||
* dialogs that spent a release rendering dark ink on a dark card.
|
||||
*
|
||||
* The fix is deliberately ONE derived colour scheme rather than 27 call-site branches:
|
||||
* * a call-site branch cannot reach the IMPLICIT pulls at all — a `Switch`'s track and an
|
||||
* `OutlinedCard`'s border are resolved inside Material, not here;
|
||||
* * two colours per site is exactly the shape that drifts, and it would leave the touch screen
|
||||
* carrying console vocabulary it has no use for.
|
||||
*
|
||||
* The alternative — give the console presentation an opaque backdrop and let the touch theme read on
|
||||
* its own ground — was rejected because it splits the screen's material in two: an opaque touch-grey
|
||||
* slab under a palette-inked header and legend, with a visible seam between them, on a field whose
|
||||
* whole point is that one look runs through it.
|
||||
*
|
||||
* The base scheme follows the field's lightness, so anything not overridden here (a container role
|
||||
* some Material component reaches for) still lands on the right side of the contrast line.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConsoleInkedTheme(content: @Composable () -> Unit) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val scheme = remember(ink) {
|
||||
val base = if (ink.isLight) lightColorScheme() else darkColorScheme()
|
||||
base.copy(
|
||||
primary = ink.accent,
|
||||
onPrimary = ink.onAccent,
|
||||
// A card becomes a PANE over the aurora rather than a slab on top of it: the console's
|
||||
// own glass fill, so an OutlinedCard here is cut from the material the settings rows are.
|
||||
surface = ink.glass,
|
||||
onSurface = ink.fg,
|
||||
surfaceVariant = ink.fg(0.12f),
|
||||
onSurfaceVariant = ink.fg(0.68f),
|
||||
outline = ink.fg(0.30f),
|
||||
outlineVariant = ink.fg(0.16f),
|
||||
// Nothing here paints a background — the aurora is the ground — but a component that
|
||||
// resolves `background` (or the content colour for it) must still land on the palette.
|
||||
background = Color.Transparent,
|
||||
onBackground = ink.fg,
|
||||
)
|
||||
}
|
||||
// The typography and shapes are the app's, not Material's defaults: this swaps the INK, not the
|
||||
// brand typeface. And `LocalContentColor` has to be provided by hand — outside a Surface or a
|
||||
// Scaffold it defaults to BLACK, which is how an unstyled `Text` would vanish into a dark field.
|
||||
MaterialTheme(
|
||||
colorScheme = scheme,
|
||||
typography = MaterialTheme.typography,
|
||||
shapes = MaterialTheme.shapes,
|
||||
) {
|
||||
CompositionLocalProvider(LocalContentColor provides ink.fg, content = content)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The console's scroll route for a screen that is a WALL of content rather than a list of focusable
|
||||
* rows.
|
||||
*
|
||||
* Compose only scrolls a container to keep a FOCUSED child visible, so a screen whose body holds no
|
||||
* focusable nodes (the licenses notices are one enormous `Text`) simply cannot be scrolled by a
|
||||
* controller: the D-pad has nothing to move to. These screens therefore drive the scroll state
|
||||
* directly — up/down steps, the shoulders page.
|
||||
*
|
||||
* Returned as a plain function so a screen's nav callbacks read `scroll(-1, page = false)` rather
|
||||
* than each screen minting its own coroutine + viewport arithmetic (which is how the two would end
|
||||
* up scrolling at different speeds).
|
||||
*/
|
||||
@Composable
|
||||
internal fun rememberConsoleScroller(scroll: ScrollState): (dir: Int, page: Boolean) -> Unit {
|
||||
val scope = rememberCoroutineScope()
|
||||
val animated = animationsEnabled()
|
||||
return remember(scroll, animated) {
|
||||
{ dir, page ->
|
||||
val delta = consoleScrollDelta(scroll.viewportSize.toFloat(), page, dir)
|
||||
if (delta != 0f) {
|
||||
scope.launch {
|
||||
// Auto-repeat fires every 150 ms while a direction is held, so each animation is
|
||||
// short enough to have landed (or nearly) before the next one cancels it —
|
||||
// otherwise a held D-pad crawls, each step restarting from where the last was
|
||||
// interrupted.
|
||||
if (animated) {
|
||||
scroll.animateScrollBy(
|
||||
delta,
|
||||
ConsoleMotion.ease(
|
||||
if (page) ConsoleMotion.TRANSITION_MS else ConsoleMotion.FOCUS_MS,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
scroll.scrollBy(delta)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How far one console scroll press travels: [dir] is -1 (up/left) or +1 (down/right), [page] picks
|
||||
* the shoulders' full page over a D-pad step. Zero while the viewport is unmeasured — a first press
|
||||
* that arrived before layout must do nothing rather than fling the content by zero-times-nothing.
|
||||
*/
|
||||
internal fun consoleScrollDelta(viewportPx: Float, page: Boolean, dir: Int): Float =
|
||||
if (viewportPx <= 0f) 0f else viewportPx * (if (page) CONSOLE_PAGE else CONSOLE_STEP) * dir
|
||||
|
||||
/**
|
||||
* A page keeps a band of what you were reading on screen rather than jumping a clean screenful — the
|
||||
* overlap every reader has used since the printed page, and the difference between "I moved down"
|
||||
* and "where was I".
|
||||
*/
|
||||
private const val CONSOLE_PAGE = 0.88f
|
||||
|
||||
/** A D-pad step is about a quarter screen, so holding the direction walks the wall rather than flicking it. */
|
||||
private const val CONSOLE_STEP = 0.28f
|
||||
|
||||
@@ -123,130 +123,6 @@ internal fun AddHostSheet(
|
||||
}
|
||||
}
|
||||
|
||||
/** First connection to a host that advertised pair=optional: offer TOFU, but pitch PIN pairing. */
|
||||
@Composable
|
||||
internal fun TrustNewHostDialog(
|
||||
pt: PendingTrust,
|
||||
onTrust: () -> Unit,
|
||||
onPairInstead: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Trust this host?") },
|
||||
text = {
|
||||
Column {
|
||||
Text("First connection to ${pt.host}:${pt.port}.")
|
||||
pt.advertisedFp?.let { Text("Fingerprint ${it.take(16)}…") }
|
||||
Text(
|
||||
"This host allows trust-on-first-use, but that can't tell an impostor " +
|
||||
"from the real host. Pairing with a PIN is stronger — it proves both sides.",
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onTrust) { Text("Trust (TOFU)") }
|
||||
},
|
||||
dismissButton = {
|
||||
Row {
|
||||
TextButton(onClick = onPairInstead) { Text("Pair with PIN…") }
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Android 17+ Local Network Protection rationale: ACCESS_LOCAL_NETWORK was denied, so discovery and
|
||||
* every connect are dead — offer the system prompt again and a settings deep link (a permanently-
|
||||
* denied request returns instantly without ever showing the prompt, so "Allow" alone isn't enough).
|
||||
*/
|
||||
@Composable
|
||||
internal fun LocalNetworkDialog(onAllow: () -> Unit, onSettings: () -> Unit, onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Allow local network access") },
|
||||
text = {
|
||||
Text(
|
||||
"Android blocks Punktfunk from talking to devices on your network, so it can't " +
|
||||
"find or reach any host until you allow it. If no prompt appears when you tap " +
|
||||
"Allow, enable “Nearby devices” for Punktfunk in system settings.",
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onAllow) { Text("Allow") }
|
||||
},
|
||||
dismissButton = {
|
||||
Row {
|
||||
TextButton(onClick = onSettings) { Text("Open settings") }
|
||||
TextButton(onClick = onDismiss) { Text("Not now") }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** The pinned fingerprint no longer matches — force re-pairing (never a silent re-trust). */
|
||||
@Composable
|
||||
internal fun FingerprintChangedDialog(
|
||||
pt: PendingTrust,
|
||||
onRepair: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Host identity changed") },
|
||||
text = {
|
||||
Text(
|
||||
"The pinned fingerprint for ${pt.host} no longer matches what it now " +
|
||||
"advertises. This can mean a host reinstall — or an impostor. Re-pair " +
|
||||
"with the host's PIN to continue.",
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onRepair) { Text("Re-pair") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh pair=required (or manual/unknown-policy) host: offer the two ways in. "Request access" is
|
||||
* the no-PIN path — connect and wait for the operator to click Approve in the host's console;
|
||||
* "Use a PIN…" switches to the SPAKE2 ceremony.
|
||||
*/
|
||||
@Composable
|
||||
internal fun RequestAccessDialog(
|
||||
pt: PendingTrust,
|
||||
onRequestAccess: () -> Unit,
|
||||
onUsePin: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Pairing required") },
|
||||
text = {
|
||||
Column {
|
||||
Text("${pt.host}:${pt.port} requires pairing before it will stream.")
|
||||
Text(
|
||||
"Request access and approve this device in the host's console (or web " +
|
||||
"UI) — no PIN needed. Or pair with the 4-digit PIN the host displays.",
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onRequestAccess) { Text("Request access") }
|
||||
},
|
||||
dismissButton = {
|
||||
Row {
|
||||
TextButton(onClick = onUsePin) { Text("Use a PIN…") }
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The SPAKE2 PIN ceremony dialog. Runs [NativeBridge.nativePair] off the UI thread itself (the
|
||||
* pin/name/error state is dialog-local); on success hands the host's verified fingerprint to
|
||||
@@ -318,41 +194,6 @@ internal fun PairPinDialog(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The no-PIN "request access" wait: the connect is parked on the host until the operator approves
|
||||
* this device. Cancel returns the UI immediately — the caller trips the per-attempt flag so a late
|
||||
* approval is torn down silently (see ConnectScreen.requestAccess) and resumes discovery.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onCancel,
|
||||
title = { Text("Waiting for approval") },
|
||||
text = {
|
||||
val deviceName = Build.MODEL ?: "this device"
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
Text("Approve this device on $hostLabel.")
|
||||
}
|
||||
Text(
|
||||
"Open the host's console (or web UI) and approve “$deviceName”. It connects " +
|
||||
"automatically once you approve — no PIN needed.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onCancel) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit a saved host: name, address, port, the Wake-on-LAN MAC, and the per-host settings the record
|
||||
* owns — shared clipboard (a trust decision about THIS machine, so it was never really a global).
|
||||
@@ -467,103 +308,3 @@ internal fun EditHostDialog(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The network speed test, as a dialog: it narrates while it measures, then offers to apply the
|
||||
* recommendation to the layer the tested host actually reads bitrate from — see [SpeedTestTarget]
|
||||
* for why that is the interesting part. The apply buttons name their destination, so the write is
|
||||
* never a surprise.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SpeedTestDialog(
|
||||
hostName: String,
|
||||
target: SpeedTestTarget,
|
||||
phase: SpeedTestPhase,
|
||||
onApply: (toProfile: Boolean) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val done = phase as? SpeedTestPhase.Done
|
||||
AlertDialog(
|
||||
// Measuring can't be cancelled mid-burst (the host is already sending), so a stray tap
|
||||
// outside shouldn't look like it did something.
|
||||
onDismissRequest = { if (done != null || phase is SpeedTestPhase.Failed) onDismiss() },
|
||||
title = { Text("Network speed test") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(hostName, style = MaterialTheme.typography.titleMedium)
|
||||
when (phase) {
|
||||
SpeedTestPhase.Connecting, SpeedTestPhase.Measuring -> Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
Text(
|
||||
if (phase == SpeedTestPhase.Connecting) {
|
||||
"Connecting…"
|
||||
} else {
|
||||
"Measuring — the host is bursting test traffic for two seconds."
|
||||
},
|
||||
)
|
||||
}
|
||||
is SpeedTestPhase.Failed -> Text(
|
||||
phase.message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
is SpeedTestPhase.Done -> Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
"%.0f Mbit/s measured · %.1f %% loss".format(
|
||||
phase.measuredMbps,
|
||||
phase.lossPct,
|
||||
),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
"Recommended bitrate: %.0f Mbit/s".format(phase.recommendedMbps),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
speedTestTargetNote(target),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
if (done != null) {
|
||||
TextButton(onClick = { onApply(true) }) {
|
||||
Text(
|
||||
when (target) {
|
||||
SpeedTestTarget.Global -> "Apply"
|
||||
is SpeedTestTarget.Profile -> "Apply to “${target.profile.name}”"
|
||||
is SpeedTestTarget.Ask -> "Set in “${target.profile.name}”"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
Row {
|
||||
// The both-are-defensible case: the user picks the layer, we don't guess.
|
||||
if (done != null && target is SpeedTestTarget.Ask) {
|
||||
TextButton(onClick = { onApply(false) }) { Text("Set as default") }
|
||||
}
|
||||
TextButton(onClick = onDismiss) { Text("Close") }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** One line saying which layer an Apply will write to, and why that one. */
|
||||
private fun speedTestTargetNote(target: SpeedTestTarget): String = when (target) {
|
||||
SpeedTestTarget.Global ->
|
||||
"This host uses the default settings, so the bitrate goes there."
|
||||
is SpeedTestTarget.Profile ->
|
||||
"This host streams with “${target.profile.name}”, which sets its own bitrate — " +
|
||||
"that override is what it actually reads."
|
||||
is SpeedTestTarget.Ask ->
|
||||
"This host streams with “${target.profile.name}”, which currently inherits the default " +
|
||||
"bitrate. Setting it in the profile affects only this host's profile; setting it as " +
|
||||
"the default affects everything that inherits it."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.unom.punktfunk.components.EmptyHostsState
|
||||
import io.unom.punktfunk.components.HostCard
|
||||
import io.unom.punktfunk.components.HostMenuItem
|
||||
import io.unom.punktfunk.components.SectionLabel
|
||||
import io.unom.punktfunk.kit.discovery.DiscoveredHost
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.models.HostStatus
|
||||
|
||||
/**
|
||||
* The touch home: the saved/discovered host grid with the Add-host FAB over it — everything
|
||||
* `ConnectScreen` draws when the console UI is off, and the counterpart of [buildHomeTiles] +
|
||||
* `GamepadHome` when it is on.
|
||||
*
|
||||
* Pure display: every action arrives as a callback, because they all end in state the screen owns
|
||||
* (a dial in flight, the trust prompt, the host store). What this file DOES own is the arrangement
|
||||
* — which sections exist, in what order, and which actions a given card offers — and the two rules
|
||||
* that are easy to get wrong from the outside: a pinned card is a shortcut and so withholds the
|
||||
* host's destructive actions, and every card in a section reserves the profile chip's space as soon
|
||||
* as one of them needs it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConnectGrid(
|
||||
savedHosts: List<KnownHost>,
|
||||
/** Every live advert — the OS mark prefers it over the stored one, and "searching…" reads it. */
|
||||
discovered: List<DiscoveredHost>,
|
||||
/** Adverts with no saved record behind them, de-duped by the caller (it needs them too). */
|
||||
discoveredUnsaved: List<DiscoveredHost>,
|
||||
/** Saved hosts answering the QUIC probe, "address:port" — the routed half of "online". */
|
||||
reachable: Set<String>,
|
||||
profiles: List<StreamProfile>,
|
||||
pinsFor: (KnownHost) -> List<StreamProfile>,
|
||||
connecting: Boolean,
|
||||
/** A confirmation ("75 Mbit/s set in …"); [status] is the failure line. Never the same thing. */
|
||||
notice: String?,
|
||||
status: String?,
|
||||
lnpGranted: Boolean,
|
||||
/** Raise the local-network-permission prompt — the banner's "Allow…" and the wake guard. */
|
||||
onAskLocalNetwork: () -> Unit,
|
||||
/**
|
||||
* Dial a saved host. The second argument is `connect`'s one-off profile reference: null follows
|
||||
* the host's binding (a plain tap), a profile id forces that profile, and the empty string
|
||||
* forces the global defaults — a real, different action on a bound host, which is why it has to
|
||||
* survive as a value rather than collapsing into "unset".
|
||||
*/
|
||||
onConnect: (KnownHost, String?) -> Unit,
|
||||
onConnectDiscovered: (DiscoveredHost) -> Unit,
|
||||
onForget: (KnownHost) -> Unit,
|
||||
onEdit: (KnownHost) -> Unit,
|
||||
onWake: (KnownHost) -> Unit,
|
||||
onSpeedTest: (KnownHost) -> Unit,
|
||||
onCopyLink: (KnownHost, StreamProfile?) -> Unit,
|
||||
onTogglePin: (KnownHost, StreamProfile) -> Unit,
|
||||
onRescan: () -> Unit,
|
||||
onAddHost: () -> Unit,
|
||||
) {
|
||||
// The profile rows a card's overflow menu grows. With no profiles at all it stays empty — a
|
||||
// user who never wants this feature sees no new clutter anywhere but the settings scope chips.
|
||||
// "Connect with" is a ONE-OFF on every card: it never rebinds the host, which is why rebinding
|
||||
// lives in the Edit sheet instead.
|
||||
fun hostMenu(kh: KnownHost, pin: StreamProfile?): List<HostMenuItem> = buildList {
|
||||
if (pin == null) {
|
||||
add(HostMenuItem("Network speed test") { onSpeedTest(kh) })
|
||||
}
|
||||
add(HostMenuItem("Copy link") { onCopyLink(kh, pin) })
|
||||
if (profiles.isEmpty()) return@buildList
|
||||
if (pin != null) {
|
||||
add(HostMenuItem("Unpin card", startsSection = true) { onTogglePin(kh, pin) })
|
||||
}
|
||||
add(
|
||||
HostMenuItem("Connect with: Default settings", startsSection = true) {
|
||||
// The empty reference is "force the defaults", not "unset" — on a bound host that
|
||||
// is a real, different action from a plain tap.
|
||||
onConnect(kh, "")
|
||||
},
|
||||
)
|
||||
profiles.forEach { p ->
|
||||
add(HostMenuItem("Connect with: ${p.name}") { onConnect(kh, p.id) })
|
||||
}
|
||||
if (pin == null) {
|
||||
profiles.forEachIndexed { i, p ->
|
||||
val pinned = p.id in kh.pinnedProfileIds
|
||||
add(
|
||||
HostMenuItem(
|
||||
if (pinned) "Unpin card: ${p.name}" else "Pin as card: ${p.name}",
|
||||
startsSection = i == 0,
|
||||
) { onTogglePin(kh, p) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The saved-hosts grid: each host's own card, then one card per profile it has pinned, so a
|
||||
// pinned combination is a plain one-click connect instead of a trip through a menu.
|
||||
val savedCards = savedHosts.flatMap { kh ->
|
||||
listOf(HostCardEntry(kh, null)) + pinsFor(kh).map { HostCardEntry(kh, it) }
|
||||
}
|
||||
// Cards in one grid row must be the same height (the grid won't stretch them), so as soon as
|
||||
// ANY saved card carries a profile chip, they all reserve its space. Nobody who doesn't use
|
||||
// profiles ever sees the gap.
|
||||
val anyProfileChip = savedCards.any { it.pin != null || it.host.profileId != null }
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 160.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Punktfunk", style = MaterialTheme.typography.headlineLarge)
|
||||
Text(
|
||||
"stream a remote desktop",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
notice?.let {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
status?.let {
|
||||
// In-flight progress (connecting / waking) is the full-screen ConnectOverlay's
|
||||
// job now, so `status` only ever carries a result/error here — a filled error
|
||||
// container reads as a real failure banner, not just red text lost in the layout.
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!lnpGranted) {
|
||||
// Local network access denied: discovery can't ever find anything and every connect
|
||||
// would time out — say so at the top, with the fix one tap away, instead of letting
|
||||
// the screen look idle/broken.
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
"Local network access is off",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Text(
|
||||
"Android blocks Punktfunk from finding or reaching hosts until you allow it.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
TextButton(onClick = onAskLocalNetwork) { Text("Allow…") }
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (savedHosts.isEmpty() && discoveredUnsaved.isEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
EmptyHostsState()
|
||||
}
|
||||
}
|
||||
|
||||
if (savedHosts.isNotEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
SectionLabel("Saved hosts")
|
||||
}
|
||||
items(savedCards, key = { it.key }) { entry ->
|
||||
val kh = entry.host
|
||||
val pin = entry.pin
|
||||
val bound = kh.profileId?.let { id -> profiles.firstOrNull { it.id == id } }
|
||||
HostCard(
|
||||
name = kh.name,
|
||||
address = "${kh.address}:${kh.port}",
|
||||
status = if (kh.paired) HostStatus.PAIRED else HostStatus.TOFU,
|
||||
online = kh.isOnline(discovered, reachable),
|
||||
// Live advert preferred (the store lags a discovery tick), else stored.
|
||||
os = discovered.firstOrNull { kh.matches(it) && it.os.isNotEmpty() }?.os
|
||||
?: kh.os,
|
||||
enabled = !connecting,
|
||||
// A pinned card connects with ITS profile; the host's own card follows the
|
||||
// binding, which is exactly what its chip says it will do.
|
||||
onConnect = { onConnect(kh, pin?.id) },
|
||||
// Edit / Forget / Wake live on the host's own card only: a pinned card is a
|
||||
// shortcut, not a second host, and offering destructive host actions on it
|
||||
// would blur exactly that.
|
||||
onForget = if (pin != null) null else ({ onForget(kh) }),
|
||||
onEdit = if (pin != null) null else ({ onEdit(kh) }),
|
||||
// Explicit wake-only: offered when the host is offline and we have a MAC. The
|
||||
// screen runs it through the WakeController so it shows the "Waking…" overlay
|
||||
// and waits for the host to come online (matched by fingerprint, so a new DHCP
|
||||
// address on a cold boot still counts as "up") rather than firing a single
|
||||
// silent packet.
|
||||
onWake = if (pin == null && kh.mac.isNotEmpty() && !kh.isOnline(discovered, reachable)) {
|
||||
({ onWake(kh) })
|
||||
} else {
|
||||
null
|
||||
},
|
||||
profileLabel = pin?.name ?: bound?.name,
|
||||
profileProminent = pin != null,
|
||||
accent = accentColor(pin?.accent ?: bound?.accent),
|
||||
menuItems = hostMenu(kh, pin),
|
||||
reserveProfileSlot = anyProfileChip,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (discoveredUnsaved.isNotEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
SectionLabel("Discovered on the network")
|
||||
}
|
||||
items(discoveredUnsaved, key = { "disc-${it.host}-${it.port}" }) { dh ->
|
||||
HostCard(
|
||||
name = dh.name,
|
||||
address = "${dh.host}:${dh.port}",
|
||||
status = if (dh.pairingRequired) HostStatus.PAIRING else HostStatus.TOFU,
|
||||
online = true, // in the discovered list ⇒ live on mDNS right now
|
||||
os = dh.os,
|
||||
enabled = !connecting,
|
||||
onConnect = { onConnectDiscovered(dh) },
|
||||
onForget = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Active-discovery hint: discovery runs whenever this screen is up, so while it's
|
||||
// scanning but nothing's turned up yet (and we're not mid-connect), show it's working
|
||||
// rather than looking idle/empty. Suppressed while local network access is denied —
|
||||
// a spinner would be a lie there (the browse can't receive anything); the banner above
|
||||
// owns that state.
|
||||
// Scan again is offered whether or not anything turned up: the case that sends people
|
||||
// here is ONE expected host missing, not an empty list, and a browse that quietly went
|
||||
// deaf (blocked when it started, or backed off to its hour-long re-query) looks
|
||||
// exactly like a network without that host on it.
|
||||
if (lnpGranted && !connecting) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (discovered.isEmpty()) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"Searching the local network…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
TextButton(onClick = onRescan) { Text("Scan again") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Spacer(Modifier.height(96.dp))
|
||||
}
|
||||
}
|
||||
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = onAddHost,
|
||||
icon = { Icon(Icons.Filled.Add, contentDescription = null) },
|
||||
text = { Text("Add host") },
|
||||
expanded = !connecting,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
@@ -29,8 +30,8 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -204,7 +205,10 @@ internal fun ConnectTakeover(
|
||||
) {
|
||||
GamepadAuroraBackground(Modifier.fillMaxSize())
|
||||
Column(
|
||||
Modifier.padding(horizontal = 40.dp).widthIn(max = 460.dp),
|
||||
// The backdrop runs full-bleed; the COPY keeps clear of the bars and the cutout. In
|
||||
// landscape a hole punch is a side inset deeper than this 40 dp gutter, so centred text
|
||||
// would otherwise sit under the camera.
|
||||
Modifier.consoleSafeArea().padding(horizontal = 40.dp).widthIn(max = 460.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
@@ -239,7 +243,19 @@ internal fun ConnectTakeover(
|
||||
add(PadGlyph.hint('B', copy.cancelLabel, onClick = onCancel))
|
||||
if (timedOut) add(PadGlyph.hint('A', "Try Again", onClick = onRetry))
|
||||
}
|
||||
GamepadHintBar(hints, Modifier.align(Alignment.BottomCenter).padding(bottom = 28.dp))
|
||||
// The SAME bottom-start spot every console screen pins its legend at — this takeover sat
|
||||
// its pill at bottom-CENTRE, so pressing Connect made the one piece of chrome that is
|
||||
// supposed to read as fixed jump halfway across the screen (second on-glass verdict).
|
||||
val landscape =
|
||||
LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.consoleLegendInsets(landscape)
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(hints)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import io.unom.punktfunk.kit.security.ClientIdentity
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.models.PendingTrust
|
||||
|
||||
/**
|
||||
* Everything `ConnectScreen` puts ON TOP of whichever home it drew — the trust and pairing
|
||||
* ceremony, the parked "Waiting for approval…", the console's host options, the speed test, the
|
||||
* edit form, the local-network rationale, and finally the connect takeover.
|
||||
*
|
||||
* They live together because their ORDER is the contract: this is a stack of siblings in one tree,
|
||||
* so the last one drawn is the one on top, and [ConnectOverlay] is last on purpose — a dial can
|
||||
* start from any of the prompts above it, and its takeover has to cover the prompt that started it.
|
||||
*
|
||||
* Only the state each prompt reads comes in; every action goes back out as a callback, because they
|
||||
* all end in the connect/pair engine or in the host store, which the screen owns. Nothing in here
|
||||
* decides anything — it decides only what is visible.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConnectPrompts(
|
||||
gamepadUi: Boolean,
|
||||
/** The client identity — the PIN ceremony needs it to run SPAKE2; null while it is still minting. */
|
||||
identity: ClientIdentity?,
|
||||
profiles: List<StreamProfile>,
|
||||
isOnline: (KnownHost) -> Boolean,
|
||||
// ---- trust / pairing --------------------------------------------------------------------
|
||||
pendingTrust: PendingTrust?,
|
||||
/** Dismiss (null) or re-aim the SAME decision at another kind — "Pair with PIN…" does that. */
|
||||
onPendingTrustChange: (PendingTrust?) -> Unit,
|
||||
/** Trust-on-first-use accepted: dial with no pin. Offered only for a `pair=optional` host. */
|
||||
onTrustNew: (PendingTrust) -> Unit,
|
||||
/** The PIN ceremony completed with this host fingerprint — save as paired, then dial. */
|
||||
onPaired: (PendingTrust, String) -> Unit,
|
||||
onRequestAccess: (PendingTrust) -> Unit,
|
||||
// ---- the parked no-PIN request ----------------------------------------------------------
|
||||
/** Non-null while a "request access" connect sits parked on the host awaiting approval. */
|
||||
awaitingHostName: String?,
|
||||
onCancelApproval: () -> Unit,
|
||||
// ---- console host options (Up on a saved carousel tile) ---------------------------------
|
||||
optionsTarget: HostCardEntry?,
|
||||
onDismissOptions: () -> Unit,
|
||||
libraryEnabled: Boolean,
|
||||
onOpenLibrary: (KnownHost) -> Unit,
|
||||
onWake: (KnownHost) -> Unit,
|
||||
onSpeedTest: (KnownHost) -> Unit,
|
||||
onCopyLink: (KnownHost, StreamProfile?) -> Unit,
|
||||
onEditHost: (KnownHost) -> Unit,
|
||||
onForgetHost: (KnownHost) -> Unit,
|
||||
onTogglePin: (KnownHost, StreamProfile) -> Unit,
|
||||
// ---- speed test --------------------------------------------------------------------------
|
||||
speedTest: HostCardEntry?,
|
||||
/** Which layer Apply writes to. Resolved by the caller (it holds the store); set with [speedTest]. */
|
||||
speedTestTarget: SpeedTestTarget?,
|
||||
speedTestPhase: SpeedTestPhase,
|
||||
/** true = write the measured bitrate to the profile, false = to the global default. */
|
||||
onApplySpeedTest: (Boolean) -> Unit,
|
||||
onDismissSpeedTest: () -> Unit,
|
||||
// ---- edit host ---------------------------------------------------------------------------
|
||||
editTarget: KnownHost?,
|
||||
/** A MAC from the live advert, for a host whose own is not learned yet. */
|
||||
editSuggestedMacs: List<String>,
|
||||
onSaveHost: (KnownHost) -> Unit,
|
||||
onDismissEdit: () -> Unit,
|
||||
// ---- local network permission ------------------------------------------------------------
|
||||
lnpPrompt: Boolean,
|
||||
onAllowLocalNetwork: () -> Unit,
|
||||
onOpenSystemSettings: () -> Unit,
|
||||
onDismissLnpPrompt: () -> Unit,
|
||||
// ---- the connect takeover ----------------------------------------------------------------
|
||||
connectingHostName: String?,
|
||||
waker: WakeController,
|
||||
onCancelConnect: () -> Unit,
|
||||
) {
|
||||
pendingTrust?.let { pt ->
|
||||
// Same trust/pairing logic, console-styled + controller-navigable in gamepad mode.
|
||||
val onPair = { onPendingTrustChange(pt.copy(kind = PendingTrust.Kind.PAIR)) }
|
||||
// Three of the four say the same thing in both interfaces, so they are ONE prompt that
|
||||
// knows which one is running. Only the PIN ceremony genuinely differs — a keyboard field
|
||||
// against four D-pad digit slots is a different input model, not a different skin.
|
||||
when (pt.kind) {
|
||||
PendingTrust.Kind.TRUST_NEW -> TrustNewHostPrompt(
|
||||
gamepadUi, pt,
|
||||
onTrust = { onTrustNew(pt) },
|
||||
onPairInstead = onPair,
|
||||
onDismiss = { onPendingTrustChange(null) },
|
||||
)
|
||||
PendingTrust.Kind.FP_CHANGED ->
|
||||
FingerprintChangedPrompt(gamepadUi, pt, onPair) { onPendingTrustChange(null) }
|
||||
PendingTrust.Kind.REQUEST_ACCESS -> RequestAccessPrompt(
|
||||
gamepadUi, pt,
|
||||
onRequestAccess = { onRequestAccess(pt) },
|
||||
onUsePin = onPair,
|
||||
onDismiss = { onPendingTrustChange(null) },
|
||||
)
|
||||
PendingTrust.Kind.PAIR -> {
|
||||
val onSavePaired = { fp: String -> onPaired(pt, fp) }
|
||||
if (gamepadUi) {
|
||||
GamepadPairPinDialog(pt, identity, onSavePaired) { onPendingTrustChange(null) }
|
||||
} else {
|
||||
PairPinDialog(pt, identity, onSavePaired) { onPendingTrustChange(null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
awaitingHostName?.let { hostLabel ->
|
||||
AwaitingApprovalPrompt(gamepadUi, hostLabel = hostLabel, onCancel = onCancelApproval)
|
||||
}
|
||||
|
||||
// Console host options (Up on a saved carousel tile): Wake / Edit / Forget.
|
||||
optionsTarget?.let { entry ->
|
||||
val kh = entry.host
|
||||
val pin = entry.pin
|
||||
val offline = !isOnline(kh)
|
||||
GamepadHostOptionsDialog(
|
||||
hostName = kh.name,
|
||||
canWake = kh.mac.isNotEmpty() && offline,
|
||||
onWake = { onDismissOptions(); onWake(kh) },
|
||||
// A saved host always has a library (it's a knownHost) → offer it when the setting's on,
|
||||
// so a TV remote reaches the library here instead of via the Y face button.
|
||||
onLibrary = if (libraryEnabled && pin == null) {
|
||||
{ onDismissOptions(); onOpenLibrary(kh) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onSpeedTest = if (pin == null) {
|
||||
{ onDismissOptions(); onSpeedTest(kh) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onCopyLink = { onDismissOptions(); onCopyLink(kh, pin) },
|
||||
onEdit = { onDismissOptions(); onEditHost(kh) },
|
||||
onForget = { onForgetHost(kh); onDismissOptions() },
|
||||
onDismiss = onDismissOptions,
|
||||
// A pin's only action: unpinning touches neither the host nor the profile.
|
||||
onUnpin = pin?.let { p -> { onTogglePin(kh, p); onDismissOptions() } },
|
||||
profileName = pin?.name,
|
||||
)
|
||||
}
|
||||
|
||||
if (speedTest != null && speedTestTarget != null) {
|
||||
SpeedTestPrompt(
|
||||
gamepadUi, speedTest.host.name, speedTestTarget, speedTestPhase,
|
||||
onApplySpeedTest, onDismissSpeedTest,
|
||||
)
|
||||
}
|
||||
|
||||
editTarget?.let { kh ->
|
||||
if (gamepadUi) {
|
||||
// Console edit: the same field list + on-screen keyboard as Add-Host, seeded from the
|
||||
// host with an extra MAC row; the action SAVES instead of connecting.
|
||||
GamepadAddHostScreen(
|
||||
onAdd = { _, _, _ -> },
|
||||
onDismiss = onDismissEdit,
|
||||
editHost = kh,
|
||||
suggestedMacs = editSuggestedMacs,
|
||||
onSave = onSaveHost,
|
||||
// Shared clipboard and the profile binding — the two host decisions that used to
|
||||
// exist only in the touch edit sheet, which a TV box has no way to reach.
|
||||
profiles = profiles,
|
||||
)
|
||||
} else {
|
||||
EditHostDialog(
|
||||
target = kh,
|
||||
suggestedMacs = editSuggestedMacs,
|
||||
profiles = profiles,
|
||||
onSave = onSaveHost,
|
||||
onDismiss = onDismissEdit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (lnpPrompt) {
|
||||
// Android 17+ local-network-permission rationale: re-request (a permanently-denied request
|
||||
// returns instantly without a system prompt — hence the settings deep link alongside).
|
||||
LocalNetworkPrompt(
|
||||
gamepadUi,
|
||||
onAllow = onAllowLocalNetwork,
|
||||
onSettings = onOpenSystemSettings,
|
||||
onDismiss = onDismissLnpPrompt,
|
||||
)
|
||||
}
|
||||
|
||||
// Topmost: the full-screen connect takeover — instant "Connecting…" feedback on any dial, flowing
|
||||
// seamlessly into the "Waking…" wait if the host turns out to be asleep. Rides over both the touch
|
||||
// grid and the console home.
|
||||
ConnectOverlay(
|
||||
connectingHostName = connectingHostName,
|
||||
waker = waker,
|
||||
gamepadUi = gamepadUi,
|
||||
onCancelConnect = onCancelConnect,
|
||||
)
|
||||
}
|
||||
@@ -11,31 +11,6 @@ import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -45,19 +20,11 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import io.unom.punktfunk.components.EmptyHostsState
|
||||
import io.unom.punktfunk.components.HostCard
|
||||
import io.unom.punktfunk.components.HostMenuItem
|
||||
import io.unom.punktfunk.components.SectionLabel
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.discovery.DiscoveredHost
|
||||
@@ -73,7 +40,6 @@ import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
import io.unom.punktfunk.kit.security.obtainIdentity
|
||||
import io.unom.punktfunk.models.ActiveSession
|
||||
import io.unom.punktfunk.models.HostStatus
|
||||
import io.unom.punktfunk.models.PendingTrust
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -108,6 +74,20 @@ private class ConnectAttempt(val hostName: String) {
|
||||
val cancelled = AtomicBoolean(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* The connect screen — discovery, trust and the dial itself, under either interface.
|
||||
*
|
||||
* What is left in this file is the STATE and the engine: the mDNS browse and the permission that
|
||||
* gates it, the identity, the host and profile stores, the trust decision, the dial and its wake
|
||||
* fallback, and the `punktfunk://` router. What was drawn from that state now lives beside it —
|
||||
* `buildHomeTiles` (the console carousel's contents), `ConnectGrid` (the touch home) and
|
||||
* `ConnectPrompts` (everything modal, plus the connect takeover). They hold no state of their own,
|
||||
* which is why they could leave: each one takes what it displays and hands back what was pressed.
|
||||
*
|
||||
* The engine did NOT leave, and shouldn't until it has somewhere to live: it closes over ~20 locals
|
||||
* that a dozen callbacks read and write, and hoisting it means inventing a state holder — a second
|
||||
* refactor, and a second thing to get wrong.
|
||||
*/
|
||||
@Composable
|
||||
fun ConnectScreen(
|
||||
settings: Settings,
|
||||
@@ -651,52 +631,6 @@ fun ConnectScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// The profile rows a card's overflow menu grows. With no profiles at all it stays empty — a
|
||||
// user who never wants this feature sees no new clutter anywhere but the settings scope chips.
|
||||
// "Connect with" is a ONE-OFF on every card: it never rebinds the host, which is why rebinding
|
||||
// lives in the Edit sheet instead.
|
||||
fun hostMenu(kh: KnownHost, pin: StreamProfile?): List<HostMenuItem> = buildList {
|
||||
if (pin == null) {
|
||||
add(HostMenuItem("Network speed test") { startSpeedTest(HostCardEntry(kh, null)) })
|
||||
}
|
||||
add(HostMenuItem("Copy link") { copyLink(kh, pin) })
|
||||
if (profiles.isEmpty()) return@buildList
|
||||
if (pin != null) {
|
||||
add(HostMenuItem("Unpin card", startsSection = true) { togglePin(kh, pin) })
|
||||
}
|
||||
add(
|
||||
HostMenuItem("Connect with: Default settings", startsSection = true) {
|
||||
// The empty reference is "force the defaults", not "unset" — on a bound host that
|
||||
// is a real, different action from a plain tap.
|
||||
connect(kh.address, kh.port, oneOffProfile = "")
|
||||
},
|
||||
)
|
||||
profiles.forEach { p ->
|
||||
add(HostMenuItem("Connect with: ${p.name}") { connect(kh.address, kh.port, oneOffProfile = p.id) })
|
||||
}
|
||||
if (pin == null) {
|
||||
profiles.forEachIndexed { i, p ->
|
||||
val pinned = p.id in kh.pinnedProfileIds
|
||||
add(
|
||||
HostMenuItem(
|
||||
if (pinned) "Unpin card: ${p.name}" else "Pin as card: ${p.name}",
|
||||
startsSection = i == 0,
|
||||
) { togglePin(kh, p) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The saved-hosts grid: each host's own card, then one card per profile it has pinned, so a
|
||||
// pinned combination is a plain one-click connect instead of a trip through a menu.
|
||||
val savedCards = savedHosts.flatMap { kh ->
|
||||
listOf(HostCardEntry(kh, null)) + profileStore.pinsFor(kh).map { HostCardEntry(kh, it) }
|
||||
}
|
||||
// Cards in one grid row must be the same height (the grid won't stretch them), so as soon as
|
||||
// ANY saved card carries a profile chip, they all reserve its space. Nobody who doesn't use
|
||||
// profiles ever sees the gap.
|
||||
val anyProfileChip = savedCards.any { it.pin != null || it.host.profileId != null }
|
||||
|
||||
// ---- punktfunk:// routing (design/client-deep-links.md §3) --------------------------------
|
||||
//
|
||||
// The invariant: a URL may only ever do what a click on an existing card could do, MINUS trust
|
||||
@@ -782,77 +716,61 @@ fun ConnectScreen(
|
||||
|
||||
var showManualSheet by remember { mutableStateOf(false) }
|
||||
|
||||
// Wake a saved host on demand — the touch card's Wake item and the console options dialog run
|
||||
// the same action. Through the WakeController, so it shows the "Waking…" overlay and waits for
|
||||
// the host to come back rather than firing one silent packet at it.
|
||||
fun wakeHost(kh: KnownHost) {
|
||||
// The magic packet is UDP broadcast — LNP-blocked like everything else.
|
||||
if (!lnpGranted) {
|
||||
lnpPrompt = true
|
||||
return
|
||||
}
|
||||
waker.start(
|
||||
hostName = kh.name,
|
||||
connectsAfter = false,
|
||||
macs = kh.mac,
|
||||
lastIp = kh.address,
|
||||
// "Back up" is mDNS presence ONLY — narrower than the [isOnline] that decides whether to
|
||||
// OFFER Wake, which also counts a QUIC probe answer. Matched through `matches`, so a
|
||||
// cold boot onto a new DHCP address still ends the wait.
|
||||
isOnline = { discovered.any { kh.matches(it) } },
|
||||
onOnline = {},
|
||||
)
|
||||
}
|
||||
|
||||
fun forgetHost(kh: KnownHost) {
|
||||
knownHostStore.remove(kh)
|
||||
savedHosts = knownHostStore.all()
|
||||
}
|
||||
|
||||
if (gamepadUi) {
|
||||
// Console mode: the host carousel (saved → discovered → Add Host), driven by the pad. Shares
|
||||
// every action above; the trailing Add Host tile opens the same manual-entry sheet.
|
||||
val tiles = buildList {
|
||||
savedHosts.forEach { kh ->
|
||||
val bound = kh.profileId?.let { id -> profiles.firstOrNull { it.id == id } }
|
||||
add(
|
||||
HomeTile(
|
||||
id = "saved-${kh.id}",
|
||||
title = kh.name,
|
||||
// The binding is what a press will actually do, so the tile says so — the
|
||||
// console can't edit profiles, but it must never lie about which one it uses.
|
||||
subtitle = bound?.let { "${kh.address}:${kh.port} · ${it.name}" }
|
||||
?: "${kh.address}:${kh.port}",
|
||||
filled = true,
|
||||
online = kh.isOnline(discovered, reachable),
|
||||
paired = kh.paired,
|
||||
knownHost = kh,
|
||||
activate = { connect(kh.address, kh.port) },
|
||||
),
|
||||
)
|
||||
// Pinned host+profile combinations, right after their host: one focus-and-press
|
||||
// each, which is the affordance a controller surface does well (menus are not).
|
||||
profileStore.pinsFor(kh).forEach { p ->
|
||||
add(
|
||||
HomeTile(
|
||||
id = "pin-${kh.id}-${p.id}",
|
||||
title = kh.name,
|
||||
subtitle = p.name,
|
||||
filled = true,
|
||||
online = kh.isOnline(discovered, reachable),
|
||||
paired = kh.paired,
|
||||
knownHost = kh,
|
||||
pinnedProfileId = p.id,
|
||||
activate = { connect(kh.address, kh.port, oneOffProfile = p.id) },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
discoveredUnsaved.forEach { dh ->
|
||||
add(
|
||||
HomeTile(
|
||||
id = "disc-${dh.host}:${dh.port}",
|
||||
title = dh.name,
|
||||
subtitle = "${dh.host}:${dh.port}",
|
||||
online = true,
|
||||
activate = { connect(dh.host, dh.port, dh) },
|
||||
),
|
||||
)
|
||||
}
|
||||
add(
|
||||
HomeTile(
|
||||
id = "add",
|
||||
title = "Add Host",
|
||||
subtitle = "Register a host by address",
|
||||
isAdd = true,
|
||||
activate = { showManualSheet = true },
|
||||
),
|
||||
)
|
||||
}
|
||||
GamepadHome(
|
||||
tiles = tiles,
|
||||
tiles = buildHomeTiles(
|
||||
savedHosts = savedHosts,
|
||||
profiles = profiles,
|
||||
pinsFor = profileStore::pinsFor,
|
||||
discoveredUnsaved = discoveredUnsaved,
|
||||
isOnline = { it.isOnline(discovered, reachable) },
|
||||
onConnect = { kh, oneOff -> connect(kh.address, kh.port, oneOffProfile = oneOff) },
|
||||
onConnectDiscovered = { dh -> connect(dh.host, dh.port, dh) },
|
||||
onAddHost = { showManualSheet = true },
|
||||
),
|
||||
libraryEnabled = settings.libraryEnabled,
|
||||
controllerName = io.unom.punktfunk.kit.Gamepad.firstPad()?.name,
|
||||
// Stop the carousel from consuming the pad while a sheet/dialog/overlay owns the screen,
|
||||
// while a connect is in flight (else a second A launches a concurrent connect that leaks a
|
||||
// handle — the touch grid guards the same way with enabled=!connecting), or while the whole
|
||||
// console home is cross-fading out.
|
||||
// ⚠ `speedTest` belongs in this list and was missing. It LOOKED covered by `!connecting`,
|
||||
// and is — right up until the measurement finishes: `startSpeedTest` clears `connecting`
|
||||
// before its Done/Failed card is dismissed, so from that moment the card AND the
|
||||
// carousel underneath both consumed the pad. One A then dismissed the card and started
|
||||
// a connect. Every other modal on this screen is named here for exactly this reason.
|
||||
navActive = navGate && !connecting && !showManualSheet && pendingTrust == null &&
|
||||
awaiting == null && editTarget == null && optionsTarget == null &&
|
||||
waker.waking == null && !lnpPrompt,
|
||||
speedTest == null && waker.waking == null && !lnpPrompt,
|
||||
onActivate = { it.activate() },
|
||||
onOpenLibrary = { it.knownHost?.let(onOpenLibrary) },
|
||||
onOpenSettings = onOpenSettings,
|
||||
@@ -863,239 +781,35 @@ fun ConnectScreen(
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 160.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Punktfunk", style = MaterialTheme.typography.headlineLarge)
|
||||
Text(
|
||||
"stream a remote desktop",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
notice?.let {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
status?.let {
|
||||
// In-flight progress (connecting / waking) is the full-screen ConnectOverlay's
|
||||
// job now, so `status` only ever carries a result/error here — a filled error
|
||||
// container reads as a real failure banner, not just red text lost in the layout.
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!lnpGranted) {
|
||||
// Local network access denied: discovery can't ever find anything and every connect
|
||||
// would time out — say so at the top, with the fix one tap away, instead of letting
|
||||
// the screen look idle/broken.
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
"Local network access is off",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Text(
|
||||
"Android blocks Punktfunk from finding or reaching hosts until you allow it.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
TextButton(onClick = { lnpPrompt = true }) { Text("Allow…") }
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (savedHosts.isEmpty() && discoveredUnsaved.isEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
EmptyHostsState()
|
||||
}
|
||||
}
|
||||
|
||||
if (savedHosts.isNotEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
SectionLabel("Saved hosts")
|
||||
}
|
||||
items(savedCards, key = { it.key }) { entry ->
|
||||
val kh = entry.host
|
||||
val pin = entry.pin
|
||||
val bound = kh.profileId?.let { id -> profiles.firstOrNull { it.id == id } }
|
||||
HostCard(
|
||||
name = kh.name,
|
||||
address = "${kh.address}:${kh.port}",
|
||||
status = if (kh.paired) HostStatus.PAIRED else HostStatus.TOFU,
|
||||
online = kh.isOnline(discovered, reachable),
|
||||
// Live advert preferred (the store lags a discovery tick), else stored.
|
||||
os = discovered.firstOrNull { kh.matches(it) && it.os.isNotEmpty() }?.os
|
||||
?: kh.os,
|
||||
enabled = !connecting,
|
||||
// A pinned card connects with ITS profile; the host's own card follows the
|
||||
// binding, which is exactly what its chip says it will do.
|
||||
onConnect = {
|
||||
if (pin != null) {
|
||||
connect(kh.address, kh.port, oneOffProfile = pin.id)
|
||||
} else {
|
||||
connect(kh.address, kh.port)
|
||||
}
|
||||
},
|
||||
// Edit / Forget / Wake live on the host's own card only: a pinned card is a
|
||||
// shortcut, not a second host, and offering destructive host actions on it
|
||||
// would blur exactly that.
|
||||
onForget = if (pin != null) {
|
||||
null
|
||||
} else {
|
||||
{
|
||||
knownHostStore.remove(kh)
|
||||
savedHosts = knownHostStore.all()
|
||||
}
|
||||
},
|
||||
onEdit = if (pin != null) null else ({ editTarget = kh }),
|
||||
// Explicit wake-only: offered when the host is offline and we have a MAC. Runs
|
||||
// through the WakeController so it shows the "Waking…" overlay and waits for
|
||||
// the host to come online (matched by fingerprint, so a new DHCP address on a
|
||||
// cold boot still counts as "up") rather than firing a single silent packet.
|
||||
onWake = if (pin == null && kh.mac.isNotEmpty() && !kh.isOnline(discovered, reachable)) {
|
||||
{
|
||||
// The magic packet is UDP broadcast — LNP-blocked like everything else.
|
||||
if (!lnpGranted) {
|
||||
lnpPrompt = true
|
||||
} else {
|
||||
waker.start(
|
||||
hostName = kh.name,
|
||||
connectsAfter = false,
|
||||
macs = kh.mac,
|
||||
lastIp = kh.address,
|
||||
isOnline = { discovered.any { kh.matches(it) } },
|
||||
onOnline = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
profileLabel = pin?.name ?: bound?.name,
|
||||
profileProminent = pin != null,
|
||||
accent = accentColor(pin?.accent ?: bound?.accent),
|
||||
menuItems = hostMenu(kh, pin),
|
||||
reserveProfileSlot = anyProfileChip,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (discoveredUnsaved.isNotEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
SectionLabel("Discovered on the network")
|
||||
}
|
||||
items(discoveredUnsaved, key = { "disc-${it.host}-${it.port}" }) { dh ->
|
||||
HostCard(
|
||||
name = dh.name,
|
||||
address = "${dh.host}:${dh.port}",
|
||||
status = if (dh.pairingRequired) HostStatus.PAIRING else HostStatus.TOFU,
|
||||
online = true, // in the discovered list ⇒ live on mDNS right now
|
||||
os = dh.os,
|
||||
enabled = !connecting,
|
||||
onConnect = { connect(dh.host, dh.port, dh) },
|
||||
onForget = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Active-discovery hint: discovery runs whenever this screen is up, so while it's
|
||||
// scanning but nothing's turned up yet (and we're not mid-connect), show it's working
|
||||
// rather than looking idle/empty. Suppressed while local network access is denied —
|
||||
// a spinner would be a lie there (the browse can't receive anything); the banner above
|
||||
// owns that state.
|
||||
// Scan again is offered whether or not anything turned up: the case that sends people
|
||||
// here is ONE expected host missing, not an empty list, and a browse that quietly went
|
||||
// deaf (blocked when it started, or backed off to its hour-long re-query) looks
|
||||
// exactly like a network without that host on it.
|
||||
if (lnpGranted && !connecting) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (discovered.isEmpty()) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"Searching the local network…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
TextButton(onClick = { discovery.restart() }) { Text("Scan again") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Spacer(Modifier.height(96.dp))
|
||||
}
|
||||
}
|
||||
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { showManualSheet = true },
|
||||
icon = { Icon(Icons.Filled.Add, contentDescription = null) },
|
||||
text = { Text("Add host") },
|
||||
expanded = !connecting,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(20.dp),
|
||||
ConnectGrid(
|
||||
savedHosts = savedHosts,
|
||||
discovered = discovered,
|
||||
discoveredUnsaved = discoveredUnsaved,
|
||||
reachable = reachable,
|
||||
profiles = profiles,
|
||||
pinsFor = profileStore::pinsFor,
|
||||
connecting = connecting,
|
||||
notice = notice,
|
||||
status = status,
|
||||
lnpGranted = lnpGranted,
|
||||
onAskLocalNetwork = { lnpPrompt = true },
|
||||
onConnect = { kh, oneOff -> connect(kh.address, kh.port, oneOffProfile = oneOff) },
|
||||
onConnectDiscovered = { dh -> connect(dh.host, dh.port, dh) },
|
||||
onForget = { kh -> forgetHost(kh) },
|
||||
onEdit = { kh -> editTarget = kh },
|
||||
onWake = { kh -> wakeHost(kh) },
|
||||
onSpeedTest = { kh -> startSpeedTest(HostCardEntry(kh, null)) },
|
||||
onCopyLink = { kh, pin -> copyLink(kh, pin) },
|
||||
onTogglePin = { kh, p -> togglePin(kh, p) },
|
||||
onRescan = { discovery.restart() },
|
||||
onAddHost = { showManualSheet = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Add Host stayed behind while the other modals moved into ConnectPrompts: its form fields are
|
||||
// remembered HERE, on purpose, so a half-typed address survives the sheet being dismissed and
|
||||
// reopened. Moving the block without moving that state would quietly change what a dismiss
|
||||
// costs; moving both is a separate decision from this one.
|
||||
if (showManualSheet) {
|
||||
if (gamepadUi) {
|
||||
// Console add-host: field list + on-screen controller keyboard. "Add" connects (which
|
||||
@@ -1123,148 +837,81 @@ fun ConnectScreen(
|
||||
}
|
||||
}
|
||||
|
||||
pendingTrust?.let { pt ->
|
||||
// Same trust/pairing logic, console-styled + controller-navigable in gamepad mode.
|
||||
val onPair = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }
|
||||
val onSavePaired = { fp: String ->
|
||||
// Which layer a measurement would land in. Resolved here, not in the prompt: it is a question
|
||||
// for the profile store, and the Apply button and the caption above it must agree on the answer.
|
||||
val speedTestTarget = speedTest?.let { SpeedTestTarget.resolve(it.host, it.pin?.id, profileStore) }
|
||||
// Prefill a not-yet-learned MAC from the host's live advert, mirroring Apple's
|
||||
// `discovery.hosts.first { host.matches($0) }?.macAddresses`.
|
||||
val editSuggestedMacs =
|
||||
editTarget?.let { kh -> discovered.firstOrNull { kh.matches(it) }?.mac } ?: emptyList()
|
||||
|
||||
// Everything that floats above whichever home was drawn, in one place and in one order — see
|
||||
// ConnectPrompts.kt. It decides nothing: each action below lands right back in the engine above.
|
||||
ConnectPrompts(
|
||||
gamepadUi = gamepadUi,
|
||||
identity = identity,
|
||||
profiles = profiles,
|
||||
isOnline = { it.isOnline(discovered, reachable) },
|
||||
pendingTrust = pendingTrust,
|
||||
onPendingTrustChange = { pendingTrust = it },
|
||||
onTrustNew = { pt ->
|
||||
pendingTrust = null
|
||||
doConnect(pt.host, pt.port, pt.name, null, pt.profile, pt.launch)
|
||||
},
|
||||
onPaired = { pt, fp ->
|
||||
knownHostStore.trust(pt.host, pt.port, pt.name, fp, paired = true)
|
||||
savedHosts = knownHostStore.all()
|
||||
pendingTrust = null
|
||||
doConnect(pt.host, pt.port, pt.name, fp, pt.profile, pt.launch)
|
||||
}
|
||||
when (pt.kind) {
|
||||
PendingTrust.Kind.TRUST_NEW ->
|
||||
if (gamepadUi) GamepadTrustNewDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null, pt.profile, pt.launch) }, onPair, { pendingTrust = null })
|
||||
else TrustNewHostDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null, pt.profile, pt.launch) }, onPair, { pendingTrust = null })
|
||||
PendingTrust.Kind.FP_CHANGED ->
|
||||
if (gamepadUi) GamepadFingerprintChangedDialog(pt, onPair, { pendingTrust = null })
|
||||
else FingerprintChangedDialog(pt, onPair, { pendingTrust = null })
|
||||
PendingTrust.Kind.REQUEST_ACCESS ->
|
||||
if (gamepadUi) GamepadRequestAccessDialog(pt, { pendingTrust = null; requestAccess(pt) }, onPair, { pendingTrust = null })
|
||||
else RequestAccessDialog(pt, { pendingTrust = null; requestAccess(pt) }, onPair, { pendingTrust = null })
|
||||
PendingTrust.Kind.PAIR ->
|
||||
if (gamepadUi) GamepadPairPinDialog(pt, identity, onSavePaired, { pendingTrust = null })
|
||||
else PairPinDialog(pt, identity, onSavePaired, { pendingTrust = null })
|
||||
}
|
||||
}
|
||||
|
||||
awaiting?.let { req ->
|
||||
val onCancel = {
|
||||
req.cancelled.set(true)
|
||||
},
|
||||
onRequestAccess = { pt -> pendingTrust = null; requestAccess(pt) },
|
||||
awaitingHostName = awaiting?.target?.name,
|
||||
onCancelApproval = {
|
||||
awaiting?.cancelled?.set(true)
|
||||
awaiting = null
|
||||
connecting = false
|
||||
discovery.start() // the request may still be pending on the host; keep scanning
|
||||
}
|
||||
if (gamepadUi) GamepadAwaitingApprovalDialog(req.target.name, onCancel)
|
||||
else AwaitingApprovalDialog(hostLabel = req.target.name, onCancel = onCancel)
|
||||
}
|
||||
|
||||
// Console host options (Up on a saved carousel tile): Wake / Edit / Forget.
|
||||
optionsTarget?.let { entry ->
|
||||
val kh = entry.host
|
||||
val pin = entry.pin
|
||||
val offline = !kh.isOnline(discovered, reachable)
|
||||
GamepadHostOptionsDialog(
|
||||
hostName = kh.name,
|
||||
canWake = kh.mac.isNotEmpty() && offline,
|
||||
onWake = {
|
||||
optionsTarget = null
|
||||
// The magic packet is UDP broadcast — LNP-blocked like everything else.
|
||||
if (!lnpGranted) {
|
||||
lnpPrompt = true
|
||||
} else {
|
||||
waker.start(
|
||||
hostName = kh.name, connectsAfter = false, macs = kh.mac, lastIp = kh.address,
|
||||
isOnline = { discovered.any { kh.matches(it) } },
|
||||
onOnline = {},
|
||||
)
|
||||
}
|
||||
},
|
||||
// A saved host always has a library (it's a knownHost) → offer it when the setting's on,
|
||||
// so a TV remote reaches the library here instead of via the Y face button.
|
||||
onLibrary = if (settings.libraryEnabled && pin == null) {
|
||||
{ optionsTarget = null; onOpenLibrary(kh) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onSpeedTest = if (pin == null) {
|
||||
{ optionsTarget = null; startSpeedTest(HostCardEntry(kh, null)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onCopyLink = { optionsTarget = null; copyLink(kh, pin) },
|
||||
onEdit = { optionsTarget = null; editTarget = kh },
|
||||
onForget = {
|
||||
knownHostStore.remove(kh)
|
||||
savedHosts = knownHostStore.all()
|
||||
optionsTarget = null
|
||||
},
|
||||
onDismiss = { optionsTarget = null },
|
||||
// A pin's only action: unpinning touches neither the host nor the profile.
|
||||
onUnpin = pin?.let { p -> { togglePin(kh, p); optionsTarget = null } },
|
||||
profileName = pin?.name,
|
||||
)
|
||||
}
|
||||
|
||||
speedTest?.let { entry ->
|
||||
val target = SpeedTestTarget.resolve(entry.host, entry.pin?.id, profileStore)
|
||||
val dismiss = { speedTest = null }
|
||||
val apply: (Boolean) -> Unit = { toProfile ->
|
||||
},
|
||||
optionsTarget = optionsTarget,
|
||||
onDismissOptions = { optionsTarget = null },
|
||||
libraryEnabled = settings.libraryEnabled,
|
||||
onOpenLibrary = onOpenLibrary,
|
||||
onWake = { kh -> wakeHost(kh) },
|
||||
onSpeedTest = { kh -> startSpeedTest(HostCardEntry(kh, null)) },
|
||||
onCopyLink = { kh, pin -> copyLink(kh, pin) },
|
||||
onEditHost = { kh -> editTarget = kh },
|
||||
onForgetHost = { kh -> forgetHost(kh) },
|
||||
onTogglePin = { kh, p -> togglePin(kh, p) },
|
||||
speedTest = speedTest,
|
||||
speedTestTarget = speedTestTarget,
|
||||
speedTestPhase = speedTestPhase,
|
||||
onApplySpeedTest = { toProfile ->
|
||||
val done = speedTestPhase as? SpeedTestPhase.Done
|
||||
if (done != null) {
|
||||
if (done != null && speedTestTarget != null) {
|
||||
val where = applySpeedTestResult(
|
||||
done.recommendedKbps, target, toProfile, profileStore, settings, onSettingsChange,
|
||||
done.recommendedKbps, speedTestTarget, toProfile, profileStore, settings,
|
||||
onSettingsChange,
|
||||
)
|
||||
profiles = profileStore.all()
|
||||
notice = "%.0f Mbit/s set in %s".format(done.recommendedMbps, where)
|
||||
}
|
||||
speedTest = null
|
||||
}
|
||||
if (gamepadUi) {
|
||||
GamepadSpeedTestDialog(entry.host.name, target, speedTestPhase, apply, dismiss)
|
||||
} else {
|
||||
SpeedTestDialog(entry.host.name, target, speedTestPhase, apply, dismiss)
|
||||
}
|
||||
}
|
||||
|
||||
editTarget?.let { kh ->
|
||||
// Prefill a not-yet-learned MAC from the host's live advert, mirroring Apple's
|
||||
// `discovery.hosts.first { host.matches($0) }?.macAddresses`.
|
||||
val suggested = discovered.firstOrNull { kh.matches(it) }?.mac ?: emptyList()
|
||||
val onSaveHost: (KnownHost) -> Unit = { updated ->
|
||||
},
|
||||
onDismissSpeedTest = { speedTest = null },
|
||||
editTarget = editTarget,
|
||||
editSuggestedMacs = editSuggestedMacs,
|
||||
onSaveHost = { updated ->
|
||||
knownHostStore.save(updated)
|
||||
savedHosts = knownHostStore.all()
|
||||
editTarget = null
|
||||
}
|
||||
if (gamepadUi) {
|
||||
// Console edit: the same field list + on-screen keyboard as Add-Host, seeded from the
|
||||
// host with an extra MAC row; the action SAVES instead of connecting.
|
||||
GamepadAddHostScreen(
|
||||
onAdd = { _, _, _ -> },
|
||||
onDismiss = { editTarget = null },
|
||||
editHost = kh,
|
||||
suggestedMacs = suggested,
|
||||
onSave = onSaveHost,
|
||||
)
|
||||
} else {
|
||||
EditHostDialog(
|
||||
target = kh,
|
||||
suggestedMacs = suggested,
|
||||
profiles = profiles,
|
||||
onSave = onSaveHost,
|
||||
onDismiss = { editTarget = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (lnpPrompt) {
|
||||
// Android 17+ local-network-permission rationale: re-request (a permanently-denied request
|
||||
// returns instantly without a system prompt — hence the settings deep link alongside).
|
||||
val onAllow = {
|
||||
},
|
||||
onDismissEdit = { editTarget = null },
|
||||
lnpPrompt = lnpPrompt,
|
||||
onAllowLocalNetwork = {
|
||||
lnpPrompt = false
|
||||
localNetLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
|
||||
}
|
||||
val onSettings = {
|
||||
},
|
||||
onOpenSystemSettings = {
|
||||
lnpPrompt = false
|
||||
context.startActivity(
|
||||
Intent(
|
||||
@@ -1272,21 +919,10 @@ fun ConnectScreen(
|
||||
Uri.fromParts("package", context.packageName, null),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (gamepadUi) {
|
||||
GamepadLocalNetworkDialog(onAllow = onAllow, onSettings = onSettings, onDismiss = { lnpPrompt = false })
|
||||
} else {
|
||||
LocalNetworkDialog(onAllow = onAllow, onSettings = onSettings, onDismiss = { lnpPrompt = false })
|
||||
}
|
||||
}
|
||||
|
||||
// Topmost: the full-screen connect takeover — instant "Connecting…" feedback on any dial, flowing
|
||||
// seamlessly into the "Waking…" wait if the host turns out to be asleep. Rides over both the touch
|
||||
// grid and the console home.
|
||||
ConnectOverlay(
|
||||
},
|
||||
onDismissLnpPrompt = { lnpPrompt = false },
|
||||
connectingHostName = attempt?.hostName,
|
||||
waker = waker,
|
||||
gamepadUi = gamepadUi,
|
||||
onCancelConnect = { cancelConnect() },
|
||||
)
|
||||
}
|
||||
@@ -1295,8 +931,11 @@ fun ConnectScreen(
|
||||
* One entry in the saved-hosts grid: a host's own card ([pin] null), or one of its pinned
|
||||
* host+profile cards. Pins are additive presentation state on the host record — never duplicated
|
||||
* host entries, which would fork pairing, trust and renames (design §5.2a).
|
||||
*
|
||||
* The console reuses it deliberately: its options dialog acts on a host-or-pin exactly as the touch
|
||||
* card's overflow menu does, and one currency for "which card is this" keeps the two from drifting.
|
||||
*/
|
||||
private data class HostCardEntry(val host: KnownHost, val pin: StreamProfile?) {
|
||||
internal data class HostCardEntry(val host: KnownHost, val pin: StreamProfile?) {
|
||||
val key: String get() = "card-${host.id}-${pin?.id ?: "primary"}"
|
||||
}
|
||||
|
||||
@@ -1305,7 +944,7 @@ private data class HostCardEntry(val host: KnownHost, val pin: StreamProfile?) {
|
||||
* as a multicast-reception hedge on OEMs that filter multicast without it, but discovery (raw mDNS via
|
||||
* the native core + MulticastLock) does not depend on it.
|
||||
*/
|
||||
fun hasNearbyPermission(context: Context): Boolean =
|
||||
internal fun hasNearbyPermission(context: Context): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.NEARBY_WIFI_DEVICES) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
@@ -1317,7 +956,7 @@ fun hasNearbyPermission(context: Context): Boolean =
|
||||
* QUIC dial surfaces as a silent handshake timeout and the mDNS browse receives nothing. Unlike
|
||||
* [hasNearbyPermission] this is load-bearing — nothing on the connect screen works without it.
|
||||
*/
|
||||
fun hasLocalNetworkPermission(context: Context): Boolean =
|
||||
internal fun hasLocalNetworkPermission(context: Context): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.CINNAMON_BUN ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_LOCAL_NETWORK) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
@@ -1327,7 +966,7 @@ fun hasLocalNetworkPermission(context: Context): Boolean =
|
||||
* fingerprint when both carry it (so it survives a DHCP address change), else by address:port.
|
||||
* Mirrors the Apple client's `StoredHost.matches`; de-dupes "Discovered" against "Saved hosts".
|
||||
*/
|
||||
private fun KnownHost.matches(dh: DiscoveredHost): Boolean {
|
||||
internal fun KnownHost.matches(dh: DiscoveredHost): Boolean {
|
||||
val advFp = dh.fingerprint?.lowercase()
|
||||
if (!advFp.isNullOrEmpty() && fpHex.isNotEmpty() && fpHex.lowercase() == advFp) return true
|
||||
return address == dh.host && port == dh.port
|
||||
@@ -1337,6 +976,9 @@ private fun KnownHost.matches(dh: DiscoveredHost): Boolean {
|
||||
* True when a saved host is reachable RIGHT NOW: advertising on mDNS OR answering the QUIC probe
|
||||
* (a host reached over a routed network — Tailscale/VPN — never advertises but is reachable). The
|
||||
* display-side companion to dial-first: presence no longer means "on this LAN".
|
||||
*
|
||||
* `internal`, not private: the touch grid draws the same pip in its own file now, and the console's
|
||||
* tile builder is handed this as a lambda so it never has to know what "reachable" is made of.
|
||||
*/
|
||||
private fun KnownHost.isOnline(discovered: List<DiscoveredHost>, reachable: Set<String>): Boolean =
|
||||
internal fun KnownHost.isOnline(discovered: List<DiscoveredHost>, reachable: Set<String>): Boolean =
|
||||
discovered.any { matches(it) } || reachable.contains("$address:$port")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.hardware.input.InputManager
|
||||
import android.os.Build
|
||||
import android.os.CombinedVibration
|
||||
@@ -11,12 +12,15 @@ import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -39,11 +43,15 @@ import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import io.unom.punktfunk.kit.DsDevice
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.Sc2Capture
|
||||
@@ -55,10 +63,145 @@ import kotlinx.coroutines.delay
|
||||
* case where a pad "doesn't work" — adapters and BT-to-USB dongles often enumerate with a different
|
||||
* identity than the physical pad, or not as a gamepad at all, and punktfunk only forwards devices
|
||||
* Android classifies as gamepad/joystick. This screen makes that visible on the device itself.
|
||||
*
|
||||
* This is the TOUCH entry point; [ConsoleControllersScreen] shows the same body on the console's
|
||||
* field. Both drive [ControllersBody] — the screen exists once, and the support answer it gives has
|
||||
* to be the same one whichever interface asked.
|
||||
*/
|
||||
@Composable
|
||||
fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
||||
BackHandler(onBack = onBack)
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
ControllersBody(
|
||||
gamepadSetting = gamepadSetting,
|
||||
scroll = rememberScrollState(),
|
||||
testing = testing,
|
||||
onTestingChange = { testing = it },
|
||||
// The touch screen holds the probes for its whole life: events are OBSERVED (not consumed)
|
||||
// while the test is off, which is what keeps the "Last input" line live while browsing.
|
||||
// Nothing else here wants the pad, so there is no one to hand them to.
|
||||
observeInput = true,
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 24.dp),
|
||||
) {
|
||||
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same screen on the console's field — the couch route to it, which a TV box has no other way to
|
||||
* reach (there is no touch interface to fall back to there, which is exactly why this matters).
|
||||
*
|
||||
* Navigation, and how the pad is shared with the test:
|
||||
* * up/down scrolls, the shoulders page — the body is cards and prose with no focusable rows, and
|
||||
* Compose only scrolls to keep a FOCUSED child visible (see [rememberConsoleScroller]);
|
||||
* * A starts the input test, which is the one thing on this screen a controller can act on;
|
||||
* * while the test runs it OWNS the pad — that is the whole point of it — so this screen's nav
|
||||
* drops out of the probe slots and B is a HOLD (below). Everything reverts the moment it ends.
|
||||
*/
|
||||
@Composable
|
||||
fun ConsoleControllersScreen(gamepadSetting: Int, onBack: () -> Unit, navActive: Boolean = true) {
|
||||
BackHandler(onBack = onBack)
|
||||
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
val hazeState = remember { HazeState() }
|
||||
val scroll = rememberScrollState()
|
||||
val scrollBy = rememberConsoleScroller(scroll)
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
|
||||
|
||||
GamepadNavEffect2D(
|
||||
// Off while the test runs: both want the same single probe slot, and the test is the one
|
||||
// the user just asked for. The identity check in each teardown (here and in the body) is
|
||||
// what makes the handover safe in either direction.
|
||||
active = navActive && !testing,
|
||||
onDirection = { dir ->
|
||||
when (dir) {
|
||||
NavDir.UP -> scrollBy(-1, false)
|
||||
NavDir.DOWN -> scrollBy(1, false)
|
||||
// Nothing on this screen steps sideways; paging is the shoulders' job.
|
||||
NavDir.LEFT, NavDir.RIGHT -> {}
|
||||
}
|
||||
},
|
||||
onActivate = { testing = true },
|
||||
onShoulder = { delta -> scrollBy(delta, true) },
|
||||
)
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||
// The calm backdrop, full-bleed under the bars and the cutout: this is a screen to READ,
|
||||
// and the aurora is ambience. Only the content takes the safe area.
|
||||
GamepadFormBackground(Modifier.fillMaxSize())
|
||||
// The body is written against the touch theme; on the console field it has to be inked
|
||||
// from the palette or it is grey-on-pastel over the six pale palettes.
|
||||
ConsoleInkedTheme {
|
||||
Column(Modifier.fillMaxSize().consoleSafeArea()) {
|
||||
ControllersBody(
|
||||
gamepadSetting = gamepadSetting,
|
||||
scroll = scroll,
|
||||
testing = testing,
|
||||
onTestingChange = { testing = it },
|
||||
// Only while testing: the rest of the time the screen's own nav holds the
|
||||
// probes, so the "Last input" line is a test-time readout here rather than
|
||||
// an always-on one. A pad that reaches this screen at all has already
|
||||
// proved it is seen — by moving the cursor here.
|
||||
observeInput = testing,
|
||||
contentPadding = PaddingValues(
|
||||
start = ConsoleEdgeInset,
|
||||
end = ConsoleEdgeInset,
|
||||
// Clears the floating legend zone, like every other console list.
|
||||
bottom = ConsoleLegendClearance,
|
||||
),
|
||||
) {
|
||||
ConsoleHeader("Connected controllers", horizontalInset = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.consoleLegendInsets(landscape)
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(
|
||||
if (testing) {
|
||||
// The rule, stated at the moment it applies: while the test runs, B is a BUTTON
|
||||
// UNDER TEST like any other — it lights its own chip — so only a hold ends the
|
||||
// test, after which B is the universal Back again. Tappable as the touch hatch.
|
||||
listOf(PadGlyph.hint('B', "Hold to finish") { testing = false })
|
||||
} else {
|
||||
listOfNotNull(
|
||||
GamepadHint('↕', PadGlyph.Arrow, "Scroll"),
|
||||
// Advertised only where they exist — a TV remote has no shoulders, and
|
||||
// claiming otherwise is both a lie and the reason a narrow legend overflows.
|
||||
GamepadHint('⇄', PadGlyph.Arrow, "Page").takeIf { padIsGamepad },
|
||||
PadGlyph.hint('A', "Test inputs") { testing = true },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
},
|
||||
hazeState = hazeState,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The screen itself, shared by both interfaces. [contentPadding] and [heading] are where they
|
||||
* differ: the touch screen pads for a thumb and titles with the Material headline, the console pads
|
||||
* to the shared edge inset, clears its floating legend, and titles with [ConsoleHeader].
|
||||
*
|
||||
* [observeInput] decides whether this body installs the shared MainActivity probes at all — see the
|
||||
* two call sites, and [ConsoleControllersScreen] for why they cannot both be on at once.
|
||||
*/
|
||||
@Composable
|
||||
private fun ControllersBody(
|
||||
gamepadSetting: Int,
|
||||
scroll: ScrollState,
|
||||
testing: Boolean,
|
||||
onTestingChange: (Boolean) -> Unit,
|
||||
observeInput: Boolean,
|
||||
contentPadding: PaddingValues,
|
||||
heading: @Composable () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val activity = context as? MainActivity
|
||||
|
||||
@@ -84,17 +227,31 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
||||
|
||||
// Live input test. While `testing`, the MainActivity probes consume pad events (so they show up
|
||||
// here instead of driving focus navigation); holding B releases, since the pad can no longer
|
||||
// reach the Switch. Events are observed (not consumed) even when the test is off, so the
|
||||
// "last input" line works while browsing.
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
// reach the Switch.
|
||||
val held = remember { mutableStateMapOf<Int, Boolean>() }
|
||||
val axes = remember { mutableStateMapOf<String, Float>() }
|
||||
var lastInput by remember { mutableStateOf<String?>(null) }
|
||||
var bHeld by remember { mutableStateOf(false) }
|
||||
// The hold has lasted long enough; the test ends when B is let go (see the probe).
|
||||
var holdSatisfied by remember { mutableStateOf(false) }
|
||||
// The probes below are built ONCE per `observeInput` and then read these for the life of that
|
||||
// installation. `testing` and the callback arrive as parameters now, so capturing them plainly
|
||||
// would freeze the values they had when the probe was made — the test would consume nothing.
|
||||
val consuming by rememberUpdatedState(testing)
|
||||
// The console's refusal thud, on whatever actuator the driving pad or this device has.
|
||||
val haptics by rememberUpdatedState(rememberConsoleHaptics())
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
activity?.padKeyProbe = probe@{ event ->
|
||||
DisposableEffect(observeInput) {
|
||||
// Stable probe refs, and a teardown that releases the slot only if WE still hold it — the
|
||||
// rule GamepadNavEffect2D follows. Without it this screen's dispose nulls whatever is in the
|
||||
// slot: during the console shell's push/pop BOTH screens are briefly composed, so leaving
|
||||
// here would kill the pad navigation the arriving screen had just installed. The same
|
||||
// teardown also runs when this screen hands the pad to its own input test and back.
|
||||
val keyProbe: (KeyEvent) -> Boolean = probe@{ event ->
|
||||
if (!Gamepad.isPad(event.device)) return@probe false
|
||||
// Read ONCE, up front: the test can end inside this very event, and the release that
|
||||
// ended it still has to be swallowed here — see the B branch below.
|
||||
val consume = consuming
|
||||
when (event.action) {
|
||||
KeyEvent.ACTION_DOWN -> {
|
||||
held[event.keyCode] = true
|
||||
@@ -102,13 +259,34 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
||||
}
|
||||
KeyEvent.ACTION_UP -> {
|
||||
held[event.keyCode] = false
|
||||
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) bHeld = false
|
||||
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) {
|
||||
bHeld = false
|
||||
if (consume) {
|
||||
if (event.eventTime - event.downTime >= HOLD_TO_FINISH_MS) {
|
||||
// The hold ends the test HERE, on the release, and NOT the moment
|
||||
// the 1.2 s elapsed: end it a moment earlier and this release falls
|
||||
// through unconsumed to the activity's B→BACK remap, which takes the
|
||||
// whole screen with it. Finishing the test and leaving the screen on
|
||||
// one press is not what "hold B to finish" says.
|
||||
onTestingChange(false)
|
||||
held.clear()
|
||||
} else {
|
||||
// A short B is not swallowed either. While the test owns the pad, B
|
||||
// is a BUTTON UNDER TEST — it lights its chip like every other — so
|
||||
// a tap can't also mean "leave", and in the console B is otherwise
|
||||
// the universal back. The press gets the boundary thud instead, the
|
||||
// same answer a refused step gets on the settings screen: heard, and
|
||||
// it means something else here.
|
||||
haptics.boundary()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
lastInput = "${event.device?.name}: ${KeyEvent.keyCodeToString(event.keyCode)}"
|
||||
testing
|
||||
consume
|
||||
}
|
||||
activity?.padMotionProbe = probe@{ event ->
|
||||
val motionProbe: (MotionEvent) -> Boolean = probe@{ event ->
|
||||
if (!Gamepad.isPad(event.device)) return@probe false
|
||||
axes["LX"] = event.getAxisValue(MotionEvent.AXIS_X)
|
||||
axes["LY"] = event.getAxisValue(MotionEvent.AXIS_Y)
|
||||
@@ -124,31 +302,43 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
||||
)
|
||||
axes["HX"] = event.getAxisValue(MotionEvent.AXIS_HAT_X)
|
||||
axes["HY"] = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
|
||||
testing
|
||||
consuming
|
||||
}
|
||||
if (observeInput) {
|
||||
activity?.padKeyProbe = keyProbe
|
||||
activity?.padMotionProbe = motionProbe
|
||||
}
|
||||
onDispose {
|
||||
activity?.padKeyProbe = null
|
||||
activity?.padMotionProbe = null
|
||||
activity?.let { a ->
|
||||
if (a.padKeyProbe === keyProbe) a.padKeyProbe = null
|
||||
if (a.padMotionProbe === motionProbe) a.padMotionProbe = null
|
||||
}
|
||||
}
|
||||
}
|
||||
// Hold-B-to-exit: with events consumed, the pad can't reach the Switch — a 1.2 s hold ends the
|
||||
// test instead (touch still works). A short tap cancels the effect before the delay fires.
|
||||
LaunchedEffect(bHeld) {
|
||||
// test instead (touch still works). This half only ANSWERS the hold once it is long enough; the
|
||||
// release is what ends the test (see the probe). Letting go early cancels the effect before the
|
||||
// delay fires, so nothing is announced.
|
||||
LaunchedEffect(bHeld, testing) {
|
||||
if (bHeld && testing) {
|
||||
delay(1_200)
|
||||
testing = false
|
||||
held.clear()
|
||||
delay(HOLD_TO_FINISH_MS)
|
||||
holdSatisfied = true
|
||||
// A hold with no answer at the moment it lands is a hold you keep holding. Say it in
|
||||
// both channels a couch user has: a pulse in the hands, a changed line on the screen.
|
||||
haptics.confirm()
|
||||
} else {
|
||||
holdSatisfied = false
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp, vertical = 24.dp),
|
||||
.verticalScroll(scroll)
|
||||
.padding(contentPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
|
||||
heading()
|
||||
|
||||
// Capture-side detection, re-checked on USB hot-plug. The SC2 is never an InputDevice
|
||||
// (lizard mode is kb/mouse; the capture claims even those away) so it's enumerated from
|
||||
@@ -212,13 +402,19 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Test inputs", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
if (testing) "Controller input stays on this screen — hold B to finish"
|
||||
else "Show button presses and stick motion live",
|
||||
when {
|
||||
holdSatisfied -> "Release B to finish"
|
||||
testing -> "Controller input stays on this screen — hold B to finish"
|
||||
else -> "Show button presses and stick motion live"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(checked = testing, onCheckedChange = { testing = it; if (!it) held.clear() })
|
||||
Switch(
|
||||
checked = testing,
|
||||
onCheckedChange = { on -> onTestingChange(on); if (!on) held.clear() },
|
||||
)
|
||||
}
|
||||
if (testing) {
|
||||
ButtonGrid(held)
|
||||
@@ -680,3 +876,11 @@ private val TEST_BUTTONS = listOf(
|
||||
|
||||
/** Axis bars shown in the test view, in display order. */
|
||||
private val AXIS_LABELS = listOf("LX", "LY", "RX", "RY", "LT", "RT", "HX", "HY")
|
||||
|
||||
/**
|
||||
* How long B must be held to end the input test — and, below that, how long a press still counts as
|
||||
* a tap that gets answered rather than ignored. One constant, because a hold that ends at 1.2 s
|
||||
* while the "you tapped" answer stops at some other number leaves a window where a press does
|
||||
* nothing at all.
|
||||
*/
|
||||
private const val HOLD_TO_FINISH_MS = 1_200L
|
||||
|
||||
@@ -3,6 +3,7 @@ package io.unom.punktfunk
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -18,10 +19,9 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
@@ -31,6 +31,12 @@ import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.toggleableState
|
||||
import androidx.compose.ui.state.ToggleableState
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
@@ -47,7 +53,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -69,6 +74,20 @@ private const val KB_ROWS = 5
|
||||
|
||||
private class Field(val id: String, val label: String, val value: String, val placeholder: String)
|
||||
|
||||
/**
|
||||
* A non-text row of the EDIT form — a switch or a stepped choice, driven like a settings row rather
|
||||
* than opening the keyboard. Add-host mode has none: they all edit properties a host only has once
|
||||
* it is saved.
|
||||
*/
|
||||
private class ExtraRow(
|
||||
val label: String,
|
||||
val value: String,
|
||||
/** Non-null = draw a [ConsoleSwitch] instead of the value text. */
|
||||
val toggled: Boolean?,
|
||||
val adjust: (Int) -> Unit,
|
||||
val activate: () -> Unit,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun GamepadAddHostScreen(
|
||||
onAdd: (name: String, address: String, port: Int) -> Unit,
|
||||
@@ -78,6 +97,11 @@ fun GamepadAddHostScreen(
|
||||
editHost: KnownHost? = null,
|
||||
suggestedMacs: List<String> = emptyList(),
|
||||
onSave: ((KnownHost) -> Unit)? = null,
|
||||
/**
|
||||
* The profile catalog, for the edit form's binding row. Empty (the default) simply omits that
|
||||
* row — which is also what a device with no profiles yet gets.
|
||||
*/
|
||||
profiles: List<StreamProfile> = emptyList(),
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val context = LocalContext.current
|
||||
@@ -89,6 +113,15 @@ fun GamepadAddHostScreen(
|
||||
var address by remember { mutableStateOf(editHost?.address ?: "") }
|
||||
var port by remember { mutableStateOf(editHost?.port?.toString() ?: "9777") }
|
||||
var mac by remember { mutableStateOf(editHost?.mac?.ifEmpty { suggestedMacs }?.joinToString(", ") ?: "") }
|
||||
// The two host properties the console could not reach at all until now. `copy` preserved them,
|
||||
// so nothing was ever LOST — but a couch-only user (a TV box has no touch interface to fall
|
||||
// back to) could never decide either one, which the touch edit sheet has always offered.
|
||||
var clipboard by remember(editHost) { mutableStateOf(editHost?.clipboardSync ?: true) }
|
||||
// Filtered through the live catalog, so a binding to a since-deleted profile reads as unset
|
||||
// rather than as a name nothing can resolve — the same guard the touch sheet applies.
|
||||
var boundId by remember(editHost, profiles) {
|
||||
mutableStateOf(editHost?.profileId?.takeIf { id -> profiles.any { it.id == id } })
|
||||
}
|
||||
val canAdd = address.isNotBlank() && (port.toIntOrNull() ?: 0) > 0
|
||||
fun commit() {
|
||||
if (isEdit && editHost != null && onSave != null) {
|
||||
@@ -98,6 +131,8 @@ fun GamepadAddHostScreen(
|
||||
address = address.trim(),
|
||||
port = port.toIntOrNull() ?: editHost.port,
|
||||
mac = KnownHostStore.parseMacs(mac),
|
||||
clipboardSync = clipboard,
|
||||
profileId = boundId,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
@@ -135,7 +170,43 @@ fun GamepadAddHostScreen(
|
||||
add(Field("port", "Port", port, "9777"))
|
||||
if (isEdit) add(Field("mac", "Wake MAC", mac, "auto-filled when the host is seen"))
|
||||
}
|
||||
val actionIndex = fields.size // the Save/Add action sits just after the last field
|
||||
// The switch/choice rows, between the text fields and the action. Only in EDIT mode: both edit
|
||||
// properties a host only has once it has been saved.
|
||||
val extras = buildList {
|
||||
if (isEdit) {
|
||||
add(
|
||||
ExtraRow(
|
||||
label = "Shared clipboard",
|
||||
value = if (clipboard) "On" else "Off",
|
||||
toggled = clipboard,
|
||||
// Directional = state-targeted, so holding a direction can't oscillate — the
|
||||
// same rule the settings toggles and the pin picker use.
|
||||
adjust = { d -> clipboard = d > 0 },
|
||||
activate = { clipboard = !clipboard },
|
||||
),
|
||||
)
|
||||
if (profiles.isNotEmpty()) {
|
||||
// "Default settings" is the absence of a binding, not a profile, so it leads the
|
||||
// ring as a null rather than being faked as an entry in the catalog.
|
||||
val options = listOf<StreamProfile?>(null) + profiles
|
||||
val idx = options.indexOfFirst { it?.id == boundId }.coerceAtLeast(0)
|
||||
fun stepTo(delta: Int) {
|
||||
val n = ((idx + delta) % options.size + options.size) % options.size
|
||||
boundId = options[n]?.id
|
||||
}
|
||||
add(
|
||||
ExtraRow(
|
||||
label = "Profile",
|
||||
value = options[idx]?.name ?: "Default settings",
|
||||
toggled = null,
|
||||
adjust = { d -> stepTo(d) },
|
||||
activate = { stepTo(1) },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val actionIndex = fields.size + extras.size // the Save/Add action sits after everything
|
||||
|
||||
fun openKeyboard(id: String) { editing = id; kbRow = 1; kbCol = 0 }
|
||||
fun closeKeyboard() { editing = null }
|
||||
@@ -152,11 +223,13 @@ fun GamepadAddHostScreen(
|
||||
"address" -> c != ' '
|
||||
else -> true
|
||||
}
|
||||
/** The focused row's extra, or null when the cursor is on a text field or the action. */
|
||||
fun focusedExtra(): ExtraRow? = extras.getOrNull(focus - fields.size)
|
||||
fun activateField() {
|
||||
if (focus == actionIndex) {
|
||||
if (canAdd) commit() else { focus = 1; openKeyboard("address") }
|
||||
} else {
|
||||
openKeyboard(fields[focus].id)
|
||||
when {
|
||||
focus == actionIndex -> if (canAdd) commit() else { focus = 1; openKeyboard("address") }
|
||||
focus < fields.size -> openKeyboard(fields[focus].id)
|
||||
else -> focusedExtra()?.activate()
|
||||
}
|
||||
}
|
||||
fun pressKey() {
|
||||
@@ -179,7 +252,11 @@ fun GamepadAddHostScreen(
|
||||
when (dir) {
|
||||
NavDir.UP -> if (focus > 0) focus--
|
||||
NavDir.DOWN -> if (focus < actionIndex) focus++
|
||||
else -> {}
|
||||
// Left/right step the switch and the profile ring, exactly as they step a
|
||||
// settings row. On a text field or the action they still do nothing — there is
|
||||
// no value there to walk.
|
||||
NavDir.LEFT -> focusedExtra()?.adjust(-1)
|
||||
NavDir.RIGHT -> focusedExtra()?.adjust(1)
|
||||
}
|
||||
} else {
|
||||
when (dir) {
|
||||
@@ -214,15 +291,17 @@ fun GamepadAddHostScreen(
|
||||
// visible (stacked, the keyboard covered the whole short screen). The legend is NOT put
|
||||
// under the keyboard here — it floats at the same fixed bottom-left spot as everywhere.
|
||||
Row(
|
||||
Modifier.fillMaxSize().systemBarsPadding().padding(start = ConsoleEdgeInset, end = 20.dp, top = 8.dp, bottom = 8.dp),
|
||||
Modifier.fillMaxSize().consoleSafeArea().padding(start = ConsoleEdgeInset, end = 20.dp, top = 8.dp, bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
Column(
|
||||
Modifier.weight(1f).fillMaxHeight().verticalScroll(rememberScrollState()),
|
||||
Modifier.weight(1f).fillMaxHeight().widthIn(max = 620.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ConsoleHeader(title, horizontalInset = false)
|
||||
fields.forEachIndexed { i, f -> FieldRow(f, focused = false, editing = editing == f.id) { onFieldClick(i) } }
|
||||
extras.forEachIndexed { i, e -> ExtraRowView(e, focused = false) { onFieldClick(fields.size + i) } }
|
||||
AddActionRow(actionLabel, enabled = canAdd, focused = false) { onAddClick() }
|
||||
Spacer(Modifier.height(64.dp)) // clear the floating legend at bottom-left
|
||||
}
|
||||
@@ -236,9 +315,11 @@ fun GamepadAddHostScreen(
|
||||
} else {
|
||||
// Portrait (or landscape not typing): the FORM SCROLLS so the Add button is never
|
||||
// compressed by the keyboard; the keyboard sits below it; the legend floats (fixed).
|
||||
Column(Modifier.fillMaxSize().systemBarsPadding().padding(horizontal = ConsoleEdgeInset)) {
|
||||
Column(Modifier.fillMaxSize().consoleSafeArea().padding(horizontal = ConsoleEdgeInset)) {
|
||||
Column(
|
||||
Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||
// Same 620 dp cap as the settings rows: a field stretched across a wide
|
||||
// landscape phone is a ribbon, not an input.
|
||||
Modifier.weight(1f).widthIn(max = 620.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ConsoleHeader(title, horizontalInset = false)
|
||||
@@ -251,6 +332,11 @@ fun GamepadAddHostScreen(
|
||||
)
|
||||
}
|
||||
fields.forEachIndexed { i, f -> FieldRow(f, focused = focus == i && editing == null, editing = editing == f.id) { onFieldClick(i) } }
|
||||
extras.forEachIndexed { i, e ->
|
||||
ExtraRowView(e, focused = focus == fields.size + i && editing == null) {
|
||||
onFieldClick(fields.size + i)
|
||||
}
|
||||
}
|
||||
AddActionRow(actionLabel, enabled = canAdd, focused = focus == actionIndex && editing == null) { onAddClick() }
|
||||
Spacer(Modifier.height(72.dp)) // last field clears the floating legend when scrolled
|
||||
}
|
||||
@@ -268,7 +354,7 @@ fun GamepadAddHostScreen(
|
||||
// open or not), so opening the keyboard never relocates it below the keys. Backdrop-blurred.
|
||||
Box(
|
||||
Modifier.align(Alignment.BottomStart)
|
||||
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||
.consoleLegendInsets(landscape)
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(
|
||||
@@ -315,7 +401,7 @@ private fun TvAddHostForm(
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.systemBarsPadding()
|
||||
.consoleSafeArea()
|
||||
.padding(horizontal = 56.dp, vertical = 36.dp)
|
||||
.widthIn(max = 720.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
@@ -366,14 +452,18 @@ private fun rowCols(row: Int): Int = if (row < KB_ACTIONS_ROW) KB_CHAR_ROWS[row]
|
||||
private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -> Unit) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val visuals = animateConsoleFocus(active = focused || editing, editing = editing)
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
// The caret keeps its slot and only fades, like the settings rows' chevrons. Appending it on
|
||||
// `editing` shoved the whole value left the instant the keyboard opened — the same
|
||||
// layout-moves-under-focus bug the settings detail line had, one screen over.
|
||||
val caretAlpha by animateFloatAsState(
|
||||
if (editing) 1f else 0f,
|
||||
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
|
||||
label = "caret",
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
|
||||
.clip(shape)
|
||||
.background(visuals.background)
|
||||
.border(1.dp, visuals.border, shape)
|
||||
.consoleGlass(ConsoleShape.Row, visuals)
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -387,7 +477,61 @@ private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (editing) Text(" |", color = ink.accent)
|
||||
Text(" |", color = ink.accent, modifier = Modifier.graphicsLayer { alpha = caretAlpha })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A switch or stepped-choice row of the edit form. Deliberately the settings screen's row in
|
||||
* miniature — same glass, same end-aligned value slot, same `ConsoleSwitch` — because it IS a
|
||||
* settings row: it edits a stored property with left/right, and a user who has met one has met
|
||||
* both.
|
||||
*/
|
||||
@Composable
|
||||
private fun ExtraRowView(row: ExtraRow, focused: Boolean, onClick: () -> Unit) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val visuals = animateConsoleFocus(active = focused)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.consoleGlass(ConsoleShape.Row, visuals)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onClick,
|
||||
)
|
||||
.semantics(mergeDescendants = true) {
|
||||
role = if (row.toggled != null) Role.Switch else Role.Button
|
||||
contentDescription = "${row.label}, ${row.value}"
|
||||
row.toggled?.let {
|
||||
toggleableState = if (it) ToggleableState.On else ToggleableState.Off
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
row.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = ink.fg,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
if (row.toggled != null) {
|
||||
ConsoleSwitch(on = row.toggled, focused = focused)
|
||||
} else {
|
||||
Text(
|
||||
row.value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = ink.fg(if (focused) 1f else 0.6f),
|
||||
textAlign = TextAlign.End,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,19 +539,15 @@ private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -
|
||||
private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onClick: () -> Unit) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val visuals = animateConsoleFocus(active = focused)
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
val labelColor by animateColorAsState(
|
||||
if (enabled) ink.accent else ink.fg(0.35f),
|
||||
tween(160),
|
||||
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
|
||||
label = "addLabel",
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
|
||||
.clip(shape)
|
||||
.background(visuals.background)
|
||||
.border(1.dp, visuals.border, shape)
|
||||
.consoleGlass(ConsoleShape.Row, visuals)
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
|
||||
.padding(vertical = 14.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
@@ -430,14 +570,16 @@ private fun KeyboardGrid(
|
||||
onKey: (Int, Int) -> Unit,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val shape = RoundedCornerShape(20.dp)
|
||||
val shape = ConsoleShape.Keyboard
|
||||
val gap = if (compact) 5.dp else 7.dp
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 640.dp)
|
||||
.clip(shape)
|
||||
.background(Color(0x1FFFFFFF))
|
||||
// Palette glass, lifted a touch above a row's: the keyboard is a slab the keys sit on,
|
||||
// and a hardcoded white wash was the one surface a pale palette couldn't recolour.
|
||||
.background(ink.glass.copy(alpha = (ink.glass.alpha * 1.5f).coerceAtMost(1f)))
|
||||
.border(1.dp, ink.fg(0.12f), shape)
|
||||
.padding(start = 12.dp, end = 12.dp, top = if (compact) 8.dp else 12.dp, bottom = 12.dp + bottomInset),
|
||||
verticalArrangement = Arrangement.spacedBy(gap),
|
||||
@@ -467,11 +609,13 @@ private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier:
|
||||
tween(90),
|
||||
label = "keyBg",
|
||||
)
|
||||
val fg by animateColorAsState(if (focused) Color.Black else ink.fg, tween(90), label = "keyFg")
|
||||
// `onAccent`, not black: a pale palette's accent can be light enough that black-on-it is the
|
||||
// unreadable combination, and the palette already resolved which way that goes.
|
||||
val fg by animateColorAsState(if (focused) ink.onAccent else ink.fg, tween(90), label = "keyFg")
|
||||
Box(
|
||||
modifier = modifier
|
||||
.height(if (compact) 34.dp else 44.dp)
|
||||
.clip(RoundedCornerShape(9.dp))
|
||||
.clip(ConsoleShape.Keycap)
|
||||
.background(bg)
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.graphics.RuntimeShader
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.withInfiniteAnimationFrameMillis
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ShaderBrush
|
||||
import java.util.Locale
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.max
|
||||
import kotlin.math.sin
|
||||
|
||||
// The living console backdrop, in two renderings of ONE design.
|
||||
//
|
||||
// On API 33+ this is the desktop console's actual field: `pf-console-ui`'s `mesh_sksl`
|
||||
// (library.rs) ported to AGSL — a 4×4 bicubic colour mesh warped by four drifting interior points,
|
||||
// swayed ±8° in hue, vignetted and scrimmed. AGSL is the SkSL subset Android 13 ships, so the
|
||||
// shader body is very nearly the same source, and `GamepadPalette.meshColors` is literally the same
|
||||
// 16-cell table the Rust samples. Below 33 (`RuntimeShader` is 33+) the field falls back to four
|
||||
// drifting radial blobs sampled from the same palette ramp — an approximation of the same look, and
|
||||
// the honest one: emulating a mesh gradient with bitmaps would cost more than it bought.
|
||||
//
|
||||
// Either way it is AMBIENCE, never content: it runs full-bleed under the cutout and the system bars,
|
||||
// and every console screen's chrome floats over it.
|
||||
|
||||
/**
|
||||
* The console backdrop. [calm] is what the FORM screens (settings, add-host) wear: the pools dim
|
||||
* onto the ground so the glass rows keep real colour and luminance without the launcher's contrast.
|
||||
* Motion is identical either way on purpose — only the contrast differs, so moving between screens
|
||||
* can't make the field jump.
|
||||
*
|
||||
* Honours the system's "remove animations" accessibility setting by freezing at a fixed phase, the
|
||||
* same courtesy the Apple client pays Reduce Motion — which doubles as the deterministic mode the
|
||||
* screenshot harness captures in, since the phase is just a uniform.
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadAuroraBackground(modifier: Modifier = Modifier, calm: Boolean = false) {
|
||||
val palette = LocalGamepadPalette.current
|
||||
val animated = animationsEnabled()
|
||||
// Compiled once per palette and cached process-wide: stepping the Background row recolours the
|
||||
// field under the very row being stepped, and a shader compile per D-pad press would be felt on
|
||||
// a TV box. A compile failure resolves null and takes the blob path — a vendor Skia that
|
||||
// rejects the source must not take the console UI down with it.
|
||||
val shader = if (Build.VERSION.SDK_INT >= 33) {
|
||||
remember(palette.id) { meshShaderFor(palette) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (shader != null) {
|
||||
MeshAurora(modifier, shader, calm, animated)
|
||||
} else {
|
||||
BlobAurora(modifier, palette, calm, animated)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The backdrop for the console FORM screens (settings, add-host) — the launcher's own living field
|
||||
* at `calm`, so no screen in the console UI is backed by a still image and the palette setting
|
||||
* reaches every one of them. Mirrors the Apple client's GamepadFormBackground and the desktop's
|
||||
* single `calm` uniform.
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadFormBackground(modifier: Modifier = Modifier) {
|
||||
GamepadAuroraBackground(modifier, calm = true)
|
||||
}
|
||||
|
||||
// --- The mesh field (API 33+) ---------------------------------------------------------------
|
||||
|
||||
/** The phase a frozen (reduce-motion / screenshot) field is drawn at — the desktop's t = 0. */
|
||||
private const val FROZEN_PHASE = 0f
|
||||
|
||||
@RequiresApi(33)
|
||||
@Composable
|
||||
private fun MeshAurora(
|
||||
modifier: Modifier,
|
||||
shader: RuntimeShader,
|
||||
calm: Boolean,
|
||||
animated: Boolean,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val palette = LocalGamepadPalette.current
|
||||
val brush = remember(shader) { ShaderBrush(shader) }
|
||||
// Real monotonic seconds, not a wrapping sweep: the four warp points and the hue sway run at
|
||||
// mutually irrational rates (periods ~90–130 s), so no loop length exists that would rejoin
|
||||
// them seamlessly — which is exactly why the desktop feeds its shader elapsed time too. Frozen
|
||||
// under reduce-motion, where it also makes the field deterministic for a screenshot.
|
||||
val time by produceState(FROZEN_PHASE, animated) {
|
||||
if (!animated) return@produceState
|
||||
while (true) {
|
||||
withInfiniteAnimationFrameMillis { value = it / 1000f }
|
||||
}
|
||||
}
|
||||
val (gr, gg, gb) = palette.ground
|
||||
Canvas(modifier) {
|
||||
// Uniforms are set per draw, not per recomposition: `time` is read HERE, inside the draw
|
||||
// scope, so a new frame invalidates the draw only — the composition never re-runs.
|
||||
shader.setFloatUniform("u_res", size.width, size.height)
|
||||
shader.setFloatUniform("u_tc", time, if (calm) 1f else 0f)
|
||||
// The calm lift: the palette's ground scaled to 0.4, what the field flattens toward.
|
||||
shader.setFloatUniform(
|
||||
"u_lift",
|
||||
(gr * 0.4).toFloat(), (gg * 0.4).toFloat(), (gb * 0.4).toFloat(), 0f,
|
||||
)
|
||||
// Where the vignette and scrims tend, and how hard — black at full strength on a dark
|
||||
// field, white at well under half on a pale one (mixing a pastel toward white at the dark
|
||||
// field's strength bleaches the chroma straight out of the gradient).
|
||||
shader.setFloatUniform(
|
||||
"u_scrim",
|
||||
ink.shade.red, ink.shade.green, ink.shade.blue, ink.shadeScale,
|
||||
)
|
||||
drawRect(brush)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiled mesh shaders by palette id — at most the 13 shipped palettes, so it is bounded by the
|
||||
* table rather than by use. Touched only from the composition (main) thread.
|
||||
*/
|
||||
private val meshShaders = HashMap<String, RuntimeShader?>()
|
||||
|
||||
@RequiresApi(33)
|
||||
private fun meshShaderFor(palette: GamepadPalette): RuntimeShader? =
|
||||
meshShaders.getOrPut(palette.id) {
|
||||
runCatching { RuntimeShader(meshAgsl(palette.meshColors)) }.getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a shader constant. `Locale.ROOT` is not optional: `String.format` on a German-locale
|
||||
* device emits `0,075`, which is a syntax error in the shader source and would take the whole
|
||||
* backdrop out on exactly the devices it was authored on. `%f` also keeps a very small ramp value
|
||||
* out of exponent notation, which SkSL would still parse but nobody would enjoy reading.
|
||||
*/
|
||||
private fun n(v: Double): String = String.format(Locale.ROOT, "%.6f", v)
|
||||
|
||||
/**
|
||||
* The mesh gradient as AGSL, the palette baked into the source and resolution/time/calm/scrim left
|
||||
* as uniforms — the direct port of `pf-console-ui`'s `mesh_sksl`, kept structurally line-for-line
|
||||
* with it so the two can be diffed. A smooth bicubic blend of the 16 colours (a separable
|
||||
* cubic-Bézier basis in x then y, the fragment-shader analogue of SwiftUI's
|
||||
* `MeshGradient(smoothsColors: true)`), four interior points driving a bounded domain warp, then
|
||||
* the ±8° hue sway, an elliptical vignette and the vertical legibility scrim.
|
||||
*/
|
||||
private fun meshAgsl(colors: List<Triple<Double, Double, Double>>): String {
|
||||
fun c(i: Int): String {
|
||||
val (r, g, b) = colors[i]
|
||||
return "float3(${n(r)}, ${n(g)}, ${n(b)})"
|
||||
}
|
||||
// The four interior-point domain-warp accumulators. SIG (0.30) sets how far each point's pull
|
||||
// reaches; the warp is the weight-normalised average displacement, so |warp| ≤ max|amp|.
|
||||
val warp = buildString {
|
||||
for (p in GamepadPalette.MESH_INTERIOR) {
|
||||
append(" q = uv - float2(${n(p.x)}, ${n(p.y)});\n")
|
||||
append(" ww = exp(-dot(q, q) / (2.0 * 0.30 * 0.30));\n")
|
||||
append(" d = float2(${n(p.amp)} * sin(tt * ${n(p.sx)} + ${n(p.phase)}),\n")
|
||||
append(" ${n(p.amp)} * cos(tt * ${n(p.sy)} + ${n(p.phase)} * 1.3));\n")
|
||||
append(" wsum += d * ww; wtot += ww;\n")
|
||||
}
|
||||
}
|
||||
return """
|
||||
uniform float2 u_res;
|
||||
// x = seconds since this field started, y = the calm mix (0 launcher, 1 form).
|
||||
uniform float2 u_tc;
|
||||
// rgb = the palette's corner colour scaled for the calm lift; a is unused.
|
||||
uniform float4 u_lift;
|
||||
// rgb = what the vignette and scrims tend toward, a = how hard.
|
||||
uniform float4 u_scrim;
|
||||
|
||||
// Cubic-Bézier basis over four control values — the smooth 4-point blend per axis.
|
||||
float bz(float t, float a, float b, float c, float d) {
|
||||
float u = 1.0 - t;
|
||||
return u*u*u*a + 3.0*u*u*t*b + 3.0*u*t*t*c + t*t*t*d;
|
||||
}
|
||||
float3 bz3(float t, float3 a, float3 b, float3 c, float3 d) {
|
||||
return float3(bz(t, a.r, b.r, c.r, d.r), bz(t, a.g, b.g, c.g, d.g), bz(t, a.b, b.b, c.b, d.b));
|
||||
}
|
||||
// Hue rotation about the grey axis (Rodrigues) — the ±8° warm/cool sway. The desktop's `cross(k,
|
||||
// col)` is written out here: with k = (c, c, c) it collapses to c·(b-g, r-b, g-r), which needs no
|
||||
// builtin at all — AGSL's function set is a subset of SkSL's and not worth betting the field on.
|
||||
float3 hue(float3 col, float a) {
|
||||
float c = 0.5773503;
|
||||
float cs = cos(a); float sn = sin(a);
|
||||
float3 kx = c * float3(col.b - col.g, col.r - col.b, col.g - col.r);
|
||||
return col*cs + kx*sn + float3(c) * dot(float3(c), col) * (1.0 - cs);
|
||||
}
|
||||
|
||||
half4 main(float2 xy) {
|
||||
float tt = u_tc.x; float calm = u_tc.y;
|
||||
float2 uv = xy / u_res;
|
||||
// Interior control points wander → bounded domain warp (pools follow them).
|
||||
float2 wsum = float2(0.0); float wtot = 0.0; float2 q; float ww; float2 d;
|
||||
$warp
|
||||
uv = clamp(uv - wsum / (wtot + 0.0001), 0.0, 1.0);
|
||||
|
||||
// Bicubic blend of the 16 mesh colours: cubic-Bézier in x per row, then in y.
|
||||
float3 r0 = bz3(uv.x, ${c(0)}, ${c(1)}, ${c(2)}, ${c(3)});
|
||||
float3 r1 = bz3(uv.x, ${c(4)}, ${c(5)}, ${c(6)}, ${c(7)});
|
||||
float3 r2 = bz3(uv.x, ${c(8)}, ${c(9)}, ${c(10)}, ${c(11)});
|
||||
float3 r3 = bz3(uv.x, ${c(12)}, ${c(13)}, ${c(14)}, ${c(15)});
|
||||
float3 col = bz3(uv.y, r0, r1, r2, r3);
|
||||
|
||||
col = hue(col, sin(tt * 0.021) * 0.1396263);
|
||||
|
||||
// Calm: flatten the field toward its own corner colour — the pools dim and the corners lift,
|
||||
// so a form screen keeps real colour under its glass rows while losing the launcher's
|
||||
// contrast. Motion is untouched.
|
||||
col = mix(col, col * 0.60 + u_lift.rgb, calm);
|
||||
|
||||
// Elliptical vignette: clear at r=0.25 → scrim·0.42 at r=1.15. Halved under calm — a
|
||||
// launcher's cards sit in the pooled centre, but a form screen's rows run out toward the
|
||||
// edges, where crushing them just eats the list.
|
||||
float2 e = (xy / u_res - 0.5) * 2.0;
|
||||
float vig = clamp((length(e) - 0.25) / 0.90, 0.0, 1.0) * mix(0.42, 0.21, calm) * u_scrim.a;
|
||||
col = mix(col, u_scrim.rgb, vig);
|
||||
|
||||
// Vertical legibility scrim for the pinned heading + the floating legend.
|
||||
float v = xy.y / u_res.y;
|
||||
float s = v < 0.32 ? mix(0.38, 0.06, v / 0.32)
|
||||
: v < 0.68 ? mix(0.06, 0.08, (v - 0.32) / 0.36)
|
||||
: mix(0.08, 0.40, (v - 0.68) / 0.32);
|
||||
col = mix(col, u_scrim.rgb, s * u_scrim.a);
|
||||
|
||||
return half4(half3(col), 1.0);
|
||||
}
|
||||
"""
|
||||
}
|
||||
|
||||
// --- The blob field (API 28–32 fallback) -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* One drifting blob of the fallback field: where it sits, how far it wanders, and how fast. Integer
|
||||
* [sx]/[sy] keep the loop seamless at wrap. The COLOUR is the palette's, taken from its ramp at
|
||||
* draw time, so the field always shows several of that palette's tones at once.
|
||||
*/
|
||||
private class AuroraBlob(
|
||||
val baseX: Float,
|
||||
val baseY: Float,
|
||||
val driftX: Float,
|
||||
val driftY: Float,
|
||||
val sx: Int,
|
||||
val sy: Int,
|
||||
val phase: Float,
|
||||
val radiusFrac: Float,
|
||||
val alpha: Float,
|
||||
)
|
||||
|
||||
private val auroraBlobs = listOf(
|
||||
AuroraBlob(0.30f, 0.26f, 0.16f, 0.10f, 1, 1, 0.0f, 0.62f, 0.55f),
|
||||
AuroraBlob(0.78f, 0.68f, 0.13f, 0.14f, 1, 2, 2.4f, 0.68f, 0.58f),
|
||||
AuroraBlob(0.16f, 0.82f, 0.12f, 0.09f, 2, 1, 4.1f, 0.52f, 0.42f),
|
||||
AuroraBlob(0.72f, 0.14f, 0.10f, 0.08f, 1, 3, 1.2f, 0.48f, 0.40f),
|
||||
)
|
||||
|
||||
/**
|
||||
* Soft blobs from the palette's ramp drifting over its ground on slow, seamless loops, finished
|
||||
* with a centre-pooling vignette and top/bottom legibility scrims. What API 28–32 sees in place of
|
||||
* the mesh: the same colour families, the same "ambience, never content" role, and the same
|
||||
* [GamepadPalette] setting recolours it.
|
||||
*/
|
||||
@Composable
|
||||
private fun BlobAurora(
|
||||
modifier: Modifier,
|
||||
palette: GamepadPalette,
|
||||
calm: Boolean,
|
||||
animated: Boolean,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val transition = rememberInfiniteTransition(label = "aurora")
|
||||
// A full 0..2π sweep over ~96 s; integer per-blob multipliers make sin/cos continuous at the
|
||||
// wrap so the field never visibly jumps when the animation restarts.
|
||||
val swept by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = (2 * PI).toFloat(),
|
||||
animationSpec = infiniteRepeatable(tween(96_000, easing = LinearEasing), RepeatMode.Restart),
|
||||
label = "angle",
|
||||
)
|
||||
val angle = if (animated) swept else 0f
|
||||
val tones = palette.blobColors
|
||||
val ground = palette.groundColor
|
||||
// Where the scrims tend, and how hard. Mixing a PALE field toward white at the dark field's
|
||||
// strength bleaches the chroma straight out of the gradient, so a pale palette gets under
|
||||
// half — the same scrim strength the desktop console's shader carries.
|
||||
val scrim = if (palette.light) ink.fg else Color.Black
|
||||
val strength = if (palette.light) 0.45f else 1f
|
||||
Canvas(modifier) {
|
||||
drawRect(ground)
|
||||
val span = max(size.width, size.height)
|
||||
for ((i, b) in auroraBlobs.withIndex()) {
|
||||
val cx = (b.baseX + b.driftX * sin(angle * b.sx + b.phase)) * size.width
|
||||
val cy = (b.baseY + b.driftY * cos(angle * b.sy + b.phase)) * size.height
|
||||
val r = span * b.radiusFrac
|
||||
// Calm scales each blob's contribution rather than dimming the whole canvas: the
|
||||
// ground stays put and only the pools come down to meet it, which is the same "lower
|
||||
// the contrast, keep the colour" the desktop console's `calm` uniform does.
|
||||
val alpha = if (calm) b.alpha * 0.62f else b.alpha
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(tones[i].copy(alpha = alpha), Color.Transparent),
|
||||
center = Offset(cx, cy),
|
||||
radius = r,
|
||||
),
|
||||
center = Offset(cx, cy),
|
||||
radius = r,
|
||||
// Additive only works over a DARK ground; over a pale one every blob
|
||||
// saturates to white and the field turns grey. Pale palettes tint instead.
|
||||
blendMode = if (palette.light) BlendMode.SrcOver else BlendMode.Plus,
|
||||
)
|
||||
}
|
||||
// Cinematic vignette: pool light centre, settle the corners toward the scrim. Halved under
|
||||
// calm: a launcher's cards sit in the pooled centre, but a form screen's rows run out
|
||||
// toward the edges, where crushing them just eats the list.
|
||||
drawRect(
|
||||
Brush.radialGradient(
|
||||
colors = listOf(
|
||||
Color.Transparent,
|
||||
scrim.copy(alpha = (if (calm) 0.22f else 0.44f) * strength),
|
||||
),
|
||||
center = Offset(size.width / 2, size.height / 2),
|
||||
radius = span * 0.92f,
|
||||
),
|
||||
)
|
||||
// Top/bottom legibility scrim for the pinned title + hint bar.
|
||||
drawRect(
|
||||
Brush.verticalGradient(
|
||||
0.0f to scrim.copy(alpha = 0.40f * strength),
|
||||
0.30f to scrim.copy(alpha = 0.05f * strength),
|
||||
0.70f to scrim.copy(alpha = 0.06f * strength),
|
||||
1.0f to scrim.copy(alpha = 0.42f * strength),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -18,7 +17,6 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -45,7 +43,6 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -107,18 +104,13 @@ fun GamepadDialog(
|
||||
// the focused button pulls itself into view (see DialogButton), so D-pad navigation always shows
|
||||
// the current action even when the stack scrolls.
|
||||
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
ConsoleModal {
|
||||
Column(
|
||||
Modifier
|
||||
.padding(24.dp)
|
||||
.widthIn(max = 520.dp)
|
||||
.heightIn(max = maxCardHeight)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(Color(0xF01A1730))
|
||||
.border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp))
|
||||
.consoleCard()
|
||||
.padding(28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
@@ -150,7 +142,11 @@ private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enab
|
||||
// that's scrolled out of a short window, pull it into view (no-op when already visible).
|
||||
val intoView = remember { BringIntoViewRequester() }
|
||||
LaunchedEffect(focused) { if (focused) intoView.bringIntoView() }
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
val focus by animateFloatAsState(
|
||||
if (focused) 1f else 0f,
|
||||
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
|
||||
label = "btnFocus",
|
||||
)
|
||||
// Focus sweeps up/down the stack — cross-fade the fills so it glides instead of snapping.
|
||||
val bg by animateColorAsState(
|
||||
when {
|
||||
@@ -158,32 +154,30 @@ private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enab
|
||||
primary -> ink.accent(0.20f)
|
||||
else -> ink.glass
|
||||
},
|
||||
tween(160),
|
||||
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
|
||||
label = "btnBg",
|
||||
)
|
||||
val fg by animateColorAsState(
|
||||
when {
|
||||
!enabled -> ink.fg(0.35f)
|
||||
focused -> ink.fg
|
||||
// On the accent, not on the field — a pale palette's accent decides this, not the ink.
|
||||
focused -> ink.onAccent
|
||||
primary -> ink.accent
|
||||
else -> ink.fg(0.85f)
|
||||
},
|
||||
tween(160),
|
||||
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
|
||||
label = "btnFg",
|
||||
)
|
||||
val borderColor by animateColorAsState(
|
||||
Color.White.copy(alpha = if (focused) 0.3f else 0.08f),
|
||||
tween(160),
|
||||
ink.fg(if (focused) 0.3f else 0.08f),
|
||||
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
|
||||
label = "btnBorder",
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.bringIntoViewRequester(intoView)
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.clip(shape)
|
||||
.background(bg)
|
||||
.border(1.dp, borderColor, shape)
|
||||
.consoleGlass(ConsoleShape.Row, ConsoleFocusVisuals(scale, bg, borderColor, focus))
|
||||
.clickable(
|
||||
enabled = enabled,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
@@ -305,18 +299,13 @@ fun GamepadPinHostsDialog(
|
||||
},
|
||||
)
|
||||
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
ConsoleModal {
|
||||
Column(
|
||||
Modifier
|
||||
.padding(24.dp)
|
||||
.widthIn(max = 520.dp)
|
||||
.heightIn(max = maxCardHeight)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(Color(0xF01A1730))
|
||||
.border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp))
|
||||
.consoleCard()
|
||||
.padding(28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
@@ -368,15 +357,11 @@ private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: ()
|
||||
// landscape window pulls itself into view.
|
||||
val intoView = remember { BringIntoViewRequester() }
|
||||
LaunchedEffect(focused) { if (focused) intoView.bringIntoView() }
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.bringIntoViewRequester(intoView)
|
||||
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
|
||||
.clip(shape)
|
||||
.background(visuals.background)
|
||||
.border(1.dp, visuals.border, shape)
|
||||
.consoleGlass(ConsoleShape.Row, visuals)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
@@ -398,157 +383,6 @@ private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: ()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Console counterpart of [SpeedTestDialog]. Same measurement, same targeting rule — a TV box on a
|
||||
* powerline adapter is exactly the machine whose link is worth measuring, so this belongs on the
|
||||
* couch surface too, even though profile EDITING doesn't.
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadSpeedTestDialog(
|
||||
hostName: String,
|
||||
target: SpeedTestTarget,
|
||||
phase: SpeedTestPhase,
|
||||
onApply: (toProfile: Boolean) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val done = phase as? SpeedTestPhase.Done
|
||||
GamepadDialog(
|
||||
title = "Network speed test",
|
||||
onDismiss = onDismiss,
|
||||
actions = buildList {
|
||||
if (done != null) {
|
||||
add(
|
||||
DialogAction(
|
||||
when (target) {
|
||||
SpeedTestTarget.Global -> "Apply"
|
||||
is SpeedTestTarget.Profile -> "Apply to “${target.profile.name}”"
|
||||
is SpeedTestTarget.Ask -> "Set in “${target.profile.name}”"
|
||||
},
|
||||
primary = true,
|
||||
) { onApply(true) },
|
||||
)
|
||||
if (target is SpeedTestTarget.Ask) {
|
||||
add(DialogAction("Set as default") { onApply(false) })
|
||||
}
|
||||
}
|
||||
add(DialogAction("Close", primary = done == null, onClick = onDismiss))
|
||||
},
|
||||
) {
|
||||
DialogText(hostName)
|
||||
when (phase) {
|
||||
SpeedTestPhase.Connecting -> DialogText("Connecting…")
|
||||
SpeedTestPhase.Measuring ->
|
||||
DialogText("Measuring — the host is bursting test traffic for two seconds.")
|
||||
is SpeedTestPhase.Failed -> DialogText(phase.message)
|
||||
is SpeedTestPhase.Done -> {
|
||||
DialogText(
|
||||
"%.0f Mbit/s measured · %.1f %% loss".format(phase.measuredMbps, phase.lossPct),
|
||||
)
|
||||
DialogText("Recommended bitrate: %.0f Mbit/s".format(phase.recommendedMbps))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Console counterpart of [LocalNetworkDialog] — the Android 17+ ACCESS_LOCAL_NETWORK rationale. */
|
||||
@Composable
|
||||
fun GamepadLocalNetworkDialog(onAllow: () -> Unit, onSettings: () -> Unit, onDismiss: () -> Unit) {
|
||||
GamepadDialog(
|
||||
title = "Allow local network access",
|
||||
onDismiss = onDismiss,
|
||||
actions = listOf(
|
||||
DialogAction("Allow", primary = true, onClick = onAllow),
|
||||
DialogAction("Open settings", onClick = onSettings),
|
||||
DialogAction("Not now", onClick = onDismiss),
|
||||
),
|
||||
) {
|
||||
DialogText(
|
||||
"Android blocks Punktfunk from talking to devices on your network, so it can't find " +
|
||||
"or reach any host until you allow it.",
|
||||
)
|
||||
DialogText(
|
||||
"If no prompt appears after Allow, enable “Nearby devices” for Punktfunk in " +
|
||||
"system settings.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GamepadTrustNewDialog(pt: PendingTrust, onTrust: () -> Unit, onPairInstead: () -> Unit, onDismiss: () -> Unit) {
|
||||
GamepadDialog(
|
||||
title = "Trust this host?",
|
||||
onDismiss = onDismiss,
|
||||
actions = listOf(
|
||||
DialogAction("Cancel", onClick = onDismiss),
|
||||
DialogAction("Pair with PIN", onClick = onPairInstead),
|
||||
DialogAction("Trust (TOFU)", primary = true, onClick = onTrust),
|
||||
),
|
||||
) {
|
||||
DialogText("First connection to ${pt.host}:${pt.port}.")
|
||||
pt.advertisedFp?.let { DialogText("Fingerprint ${it.take(16)}…") }
|
||||
DialogText(
|
||||
"This host allows trust-on-first-use, but that can't tell an impostor from the real host. " +
|
||||
"Pairing with a PIN is stronger — it proves both sides.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GamepadFingerprintChangedDialog(pt: PendingTrust, onRepair: () -> Unit, onDismiss: () -> Unit) {
|
||||
GamepadDialog(
|
||||
title = "Host identity changed",
|
||||
onDismiss = onDismiss,
|
||||
actions = listOf(
|
||||
DialogAction("Cancel", onClick = onDismiss),
|
||||
DialogAction("Re-pair", primary = true, onClick = onRepair),
|
||||
),
|
||||
) {
|
||||
DialogText(
|
||||
"The pinned fingerprint for ${pt.host} no longer matches what it now advertises. This can " +
|
||||
"mean a host reinstall — or an impostor. Re-pair with the host's PIN to continue.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GamepadRequestAccessDialog(pt: PendingTrust, onRequestAccess: () -> Unit, onUsePin: () -> Unit, onDismiss: () -> Unit) {
|
||||
GamepadDialog(
|
||||
title = "Pairing required",
|
||||
onDismiss = onDismiss,
|
||||
actions = listOf(
|
||||
DialogAction("Cancel", onClick = onDismiss),
|
||||
DialogAction("Use a PIN", onClick = onUsePin),
|
||||
DialogAction("Request access", primary = true, onClick = onRequestAccess),
|
||||
),
|
||||
) {
|
||||
DialogText("${pt.host}:${pt.port} requires pairing before it will stream.")
|
||||
DialogText(
|
||||
"Request access and approve this device in the host's console (or web UI) — no PIN needed. " +
|
||||
"Or pair with the 4-digit PIN the host displays.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
|
||||
val ink = LocalGamepadInk.current
|
||||
GamepadDialog(
|
||||
title = "Waiting for approval",
|
||||
onDismiss = onCancel,
|
||||
actions = listOf(DialogAction("Cancel", primary = true, onClick = onCancel)),
|
||||
) {
|
||||
val deviceName = Build.MODEL ?: "this device"
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = ink.fg)
|
||||
Text("Approve this device on $hostLabel.", color = ink.fg)
|
||||
}
|
||||
DialogText(
|
||||
"Open the host's console (or web UI) and approve “$deviceName”. It connects automatically " +
|
||||
"once you approve — no PIN needed.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Console PIN pairing: four digit slots set with the D-pad (left/right selects a slot, up/down changes
|
||||
* 0–9), then Pair. Runs [NativeBridge.nativePair] off the UI thread; on success hands the verified
|
||||
@@ -598,11 +432,10 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired:
|
||||
)
|
||||
|
||||
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
|
||||
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)), contentAlignment = Alignment.Center) {
|
||||
ConsoleModal {
|
||||
Column(
|
||||
Modifier.padding(24.dp).widthIn(max = 460.dp).heightIn(max = maxCardHeight)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(Color(0xF01A1730)).border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp))
|
||||
.consoleCard()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(28.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
@@ -616,7 +449,7 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired:
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
repeat(4) { i -> PinSlot(digits[i], focused = slot == i && !pairing) }
|
||||
}
|
||||
err?.let { Text(it, color = Color(0xFFE0736F), style = MaterialTheme.typography.bodyMedium) }
|
||||
err?.let { Text(it, color = ink.danger, style = MaterialTheme.typography.bodyMedium) }
|
||||
DialogButton(
|
||||
label = if (pairing) "Pairing…" else "Pair",
|
||||
focused = slot == 4 && !pairing,
|
||||
@@ -638,6 +471,12 @@ private fun PinSlot(value: Int, focused: Boolean) {
|
||||
.border(if (focused) 2.dp else 1.dp, if (focused) ink.accent else ink.fg(0.1f), shape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(value.toString(), fontSize = 30.sp, fontWeight = FontWeight.Bold, color = ink.fg, fontFamily = FontFamily.Monospace)
|
||||
Text(
|
||||
value.toString(),
|
||||
fontSize = 30.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = ink.fg,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -17,11 +19,11 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PageSize
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
@@ -33,6 +35,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -44,6 +47,7 @@ import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -55,6 +59,7 @@ import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.cos
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// The gamepad-driven home — the Android mirror of the Apple client's GamepadHomeView: a distinct,
|
||||
@@ -62,6 +67,12 @@ import kotlinx.coroutines.launch
|
||||
// active. A center-snapping carousel of hosts (saved first, then discovered, then a trailing Add
|
||||
// Host tile), driven from the couch: A connects, X opens Settings, Y opens a saved host's library.
|
||||
|
||||
/**
|
||||
* How far a fully off-centre card turns away from the viewer, in radians (~48°). Never rendered as
|
||||
* a rotation — see the projection note at the call site.
|
||||
*/
|
||||
private const val CARD_TURN_RAD = 0.838f
|
||||
|
||||
/** One navigable launcher tile — a saved host, a discovered-but-unsaved host, or the Add Host action. */
|
||||
class HomeTile(
|
||||
val id: String,
|
||||
@@ -79,6 +90,15 @@ class HomeTile(
|
||||
* belong to the host's own tile, and this one offers only Unpin.
|
||||
*/
|
||||
val pinnedProfileId: String? = null,
|
||||
/**
|
||||
* The profile a press will actually connect with — the host's binding, or the pin's own
|
||||
* profile. Rendered as a chip on the card rather than appended to the subtitle: on a PIN card
|
||||
* the profile is the entire reason the card exists, and a card that only whispers it in grey
|
||||
* body text can't say that. Matches the Apple client's tile.
|
||||
*/
|
||||
val profileName: String? = null,
|
||||
/** The profile's `#RRGGBB` chip colour, if it set one. */
|
||||
val profileAccent: Color? = null,
|
||||
val activate: () -> Unit,
|
||||
) {
|
||||
// Any SAVED host offers the library (matches Apple) — the fetch itself returns a clear "pair
|
||||
@@ -118,6 +138,16 @@ fun GamepadHome(
|
||||
LaunchedEffect(pagerState.settledPage) { navTarget = pagerState.settledPage }
|
||||
val current = tiles.getOrNull(navTarget)
|
||||
|
||||
// Bumped on every confirm — the centred card dips under the press and springs back, so A reads
|
||||
// as a button being pushed rather than as a screen simply changing.
|
||||
var pressToken by remember { mutableIntStateOf(0) }
|
||||
val press = remember { Animatable(1f) }
|
||||
LaunchedEffect(pressToken) {
|
||||
if (pressToken == 0) return@LaunchedEffect
|
||||
press.animateTo(0.97f, ConsoleMotion.ease(70))
|
||||
press.animateTo(1f, spring(dampingRatio = 0.45f, stiffness = Spring.StiffnessMedium))
|
||||
}
|
||||
|
||||
GamepadNavEffect(
|
||||
active = navActive && tiles.isNotEmpty(),
|
||||
onMove = { dir ->
|
||||
@@ -127,7 +157,8 @@ fun GamepadHome(
|
||||
scope.launch { pagerState.animateScrollToPage(target) }
|
||||
}
|
||||
},
|
||||
onActivate = { tiles.getOrNull(navTarget)?.let(onActivate) }, // A / D-pad-center → Connect
|
||||
// A / D-pad-center → Connect
|
||||
onActivate = { pressToken++; tiles.getOrNull(navTarget)?.let(onActivate) },
|
||||
onSecondary = { // Y (gamepad) → Library
|
||||
tiles.getOrNull(navTarget)?.takeIf { libraryEnabled && it.hasLibrary }?.let(onOpenLibrary)
|
||||
},
|
||||
@@ -145,9 +176,9 @@ fun GamepadHome(
|
||||
// way. Each hint is also TAPPABLE (touch hatch).
|
||||
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: false
|
||||
val connectLabel = if (current?.isAdd == true) "Add Host" else "Connect"
|
||||
val connectAction: () -> Unit = { tiles.getOrNull(navTarget)?.let(onActivate) }
|
||||
val connectAction: () -> Unit = { pressToken++; tiles.getOrNull(navTarget)?.let(onActivate) }
|
||||
val optionsAction: () -> Unit = { current?.let(onOptions) }
|
||||
val arrowTint = Color(0xFF9A93C7)
|
||||
val arrowTint = PadGlyph.Arrow
|
||||
val hints = buildList {
|
||||
if (padIsGamepad) {
|
||||
add(PadGlyph.hint('A', connectLabel, onClick = connectAction))
|
||||
@@ -177,7 +208,12 @@ fun GamepadHome(
|
||||
val cardWidth = (maxWidth * 0.82f).coerceAtMost(360.dp)
|
||||
val cardHeight = (maxHeight * 0.56f).coerceAtMost(216.dp)
|
||||
val sidePad = ((maxWidth - cardWidth) / 2).coerceAtLeast(0.dp)
|
||||
Box(Modifier.fillMaxSize().systemBarsPadding()) {
|
||||
// The carousel deliberately IGNORES the safe area (first on-glass verdict): only the
|
||||
// CENTRED card matters, and it sits mid-screen; the fanned neighbours running under
|
||||
// the hole punch is ambience, while insetting the pager CLIPPED them at the cutout
|
||||
// edge — cards visibly cut off is worse than cards behind a camera. The title and the
|
||||
// legend keep their insets; they are content.
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
pageSize = PageSize.Fixed(cardWidth),
|
||||
@@ -189,17 +225,35 @@ fun GamepadHome(
|
||||
val tile = tiles[page]
|
||||
// Real distance-from-centered (page + fractional drag), so the pop tracks the
|
||||
// live scroll: centered tile at full scale/brightness, neighbours recede + blur.
|
||||
val offset = ((pagerState.currentPage - page) + pagerState.currentPageOffsetFraction)
|
||||
.absoluteValue.coerceIn(0f, 1f)
|
||||
// Signed, because which SIDE a card fans to decides which edge it turns on.
|
||||
val signed = (page - pagerState.currentPage) - pagerState.currentPageOffsetFraction
|
||||
val offset = signed.absoluteValue.coerceIn(0f, 1f)
|
||||
GamepadHostTile(
|
||||
tile = tile,
|
||||
centred = offset < 0.5f,
|
||||
modifier = Modifier
|
||||
.graphicsLayer {
|
||||
val s = lerp(1f, 0.86f, offset)
|
||||
// The press dip applies to the CENTRED card only — it is the one
|
||||
// the button acted on, and a whole carousel flinching would read
|
||||
// as the screen moving rather than a card being pressed.
|
||||
val s = lerp(1f, 0.86f, offset) * lerp(press.value, 1f, offset)
|
||||
scaleX = s
|
||||
scaleY = s
|
||||
alpha = lerp(1f, 0.5f, offset)
|
||||
}
|
||||
.graphicsLayer {
|
||||
// The neighbours TURN away, projected rather than rendered in 3D.
|
||||
// `cos(angle)` as a horizontal squeeze IS the orthographic
|
||||
// projection of a Y-axis rotation, and hinging it on the edge the
|
||||
// card fans from is what carries the direction the rotation's sign
|
||||
// would have. The Apple client arrived here the hard way (see
|
||||
// GamepadCarousel.swift): a real `rotation3DEffect` renders the
|
||||
// card through an offscreen pass and flashed as the strip settled.
|
||||
// Affine transforms don't.
|
||||
scaleX = cos(CARD_TURN_RAD * offset)
|
||||
transformOrigin =
|
||||
TransformOrigin(if (signed > 0f) 0f else 1f, 0.5f)
|
||||
}
|
||||
// Unbounded so the depth blur isn't hard-clipped at the card's rectangle
|
||||
// (the cut-off edge). No-op below API 31; a soft blur above.
|
||||
.blur(radius = (offset * 12f).dp, edgeTreatment = BlurredEdgeTreatment.Unbounded)
|
||||
@@ -209,6 +263,7 @@ fun GamepadHome(
|
||||
indication = null,
|
||||
) {
|
||||
if (page == navTarget) {
|
||||
pressToken++
|
||||
onActivate(tile)
|
||||
} else {
|
||||
navTarget = page
|
||||
@@ -223,20 +278,28 @@ fun GamepadHome(
|
||||
// Title floats over the top (out of the carousel's layout, so the cards stay centred). Uses
|
||||
// the shared ConsoleHeader so it lines up with every other screen's heading.
|
||||
Row(
|
||||
Modifier.align(Alignment.TopStart).fillMaxWidth().systemBarsPadding()
|
||||
Modifier.align(Alignment.TopStart).fillMaxWidth().consoleSafeArea()
|
||||
.padding(end = ConsoleEdgeInset),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
ConsoleHeader("Select a Host", modifier = Modifier.weight(1f))
|
||||
if (controllerName != null) ControllerStatusChip(controllerName)
|
||||
// The TITLE has priority (unweighted, so it is measured at its full width first) and the
|
||||
// chip takes what is left, ellipsizing its device name. The other way round — which is
|
||||
// what a weighted header gave — a talkative controller name ("Xbox Wireless Controller")
|
||||
// ate a 360 dp portrait phone's title down to "Selec…".
|
||||
ConsoleHeader("Select a Host")
|
||||
if (controllerName != null) {
|
||||
ControllerStatusChip(controllerName, Modifier.weight(1f, fill = false))
|
||||
}
|
||||
}
|
||||
|
||||
// Legend floats bottom-start with a real backdrop blur of the content behind it. In LANDSCAPE
|
||||
// it ignores the safe area (the nav-bar inset made the bottom gap look oversized).
|
||||
// it ignores the system bars (the nav-bar inset made the bottom gap look oversized) but never
|
||||
// the cutout — reverse-landscape parks the punch on this very corner.
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||
.consoleLegendInsets(landscape)
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(hints, hazeState = hazeState)
|
||||
@@ -244,22 +307,31 @@ fun GamepadHome(
|
||||
}
|
||||
}
|
||||
|
||||
/** One dark-glass landscape console tile — bigger and bolder than the touch grid's HostCard. */
|
||||
/**
|
||||
* One glass landscape console tile — bigger and bolder than the touch grid's HostCard, and cut from
|
||||
* the same [Modifier.consoleGlass] every console surface is, so a card and a settings row catch the
|
||||
* light the same way. [centred] is the carousel's own focus: the tile the pad is pointing at, which
|
||||
* earns the lift and the accent bloom.
|
||||
*/
|
||||
@Composable
|
||||
private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
|
||||
private fun GamepadHostTile(tile: HomeTile, centred: Boolean, modifier: Modifier = Modifier) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val shape = RoundedCornerShape(26.dp)
|
||||
val wash = if (tile.filled) {
|
||||
Brush.verticalGradient(listOf(ink.accent(0.20f), Color(0x14100C2A)))
|
||||
} else {
|
||||
Brush.verticalGradient(listOf(Color(0x1AFFFFFF), Color(0x0DFFFFFF)))
|
||||
}
|
||||
val visuals = animateConsoleFocus(active = centred)
|
||||
// A SAVED host wears the palette's accent; a discovered one (or the Add tile) stays neutral
|
||||
// glass, so "already yours" reads before you get to the label.
|
||||
val fill = if (tile.filled) ink.accent(0.20f) else ink.glass
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(shape)
|
||||
.background(wash)
|
||||
.border(1.dp, ink.fg(0.16f), shape)
|
||||
// The carousel already drives its own scale; the glass must not fight it with a second.
|
||||
.consoleGlass(
|
||||
ConsoleShape.Tile,
|
||||
ConsoleFocusVisuals(1f, fill, ink.fg(0.16f), visuals.focus),
|
||||
// A DASHED edge on anything not yet saved — a host found on the network, and the
|
||||
// Add tile. It is the touch grid's own convention and the Apple client's, and it
|
||||
// says "not yours yet" before the subtitle has to.
|
||||
dashed = !tile.filled,
|
||||
)
|
||||
.padding(22.dp),
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
|
||||
@@ -291,12 +363,64 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (tile.profileName != null) {
|
||||
ConsoleProfileChip(
|
||||
name = tile.profileName,
|
||||
accent = tile.profileAccent,
|
||||
// On a PIN card the profile is why the card exists; on a bound host's own card it
|
||||
// is a note about what a press will do. Same chip, two weights.
|
||||
prominent = tile.pinnedProfileId != null,
|
||||
modifier = Modifier.padding(top = 5.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
tile.subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = ink.fg(0.55f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The profile a card connects with, worn as a tinted capsule. The console counterpart of the touch
|
||||
* grid's own chip (`HostComponents.kt`) — same shape and the same quiet/prominent split, but inked
|
||||
* from the console palette rather than `MaterialTheme`, since it sits on the aurora.
|
||||
*
|
||||
* A profile that set no accent falls back to the palette's, not to the touch theme's primary: on a
|
||||
* moss or copper field the brand violet would be the one foreign colour on the card.
|
||||
*/
|
||||
@Composable
|
||||
private fun ConsoleProfileChip(
|
||||
name: String,
|
||||
accent: Color?,
|
||||
prominent: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val tint = accent ?: ink.accent
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(ConsoleShape.Pill)
|
||||
.background(tint.copy(alpha = if (prominent) 0.24f else 0.12f))
|
||||
.padding(horizontal = 9.dp, vertical = 3.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(Modifier.size(7.dp).clip(CircleShape).background(tint))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
name,
|
||||
style = if (prominent) {
|
||||
MaterialTheme.typography.labelLarge
|
||||
} else {
|
||||
MaterialTheme.typography.labelMedium
|
||||
},
|
||||
fontWeight = if (prominent) FontWeight.Bold else FontWeight.SemiBold,
|
||||
color = tint,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -305,10 +429,13 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
|
||||
private fun MonogramBadge(tile: HomeTile) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val shape = RoundedCornerShape(15.dp)
|
||||
// Lit from the top like every other console surface — and the unsaved badge takes the palette's
|
||||
// own accent at low opacity rather than the brand violet, which on a copper or moss field was
|
||||
// the one square of the wrong hue on the screen.
|
||||
val fill = if (tile.filled) {
|
||||
Brush.verticalGradient(listOf(ink.accent, ink.accent))
|
||||
Brush.verticalGradient(listOf(ink.accent.copy(alpha = 0.92f), ink.accent))
|
||||
} else {
|
||||
Brush.verticalGradient(listOf(Color(0x296656F2), Color(0x296656F2)))
|
||||
Brush.verticalGradient(listOf(ink.accent(0.20f), ink.accent(0.14f)))
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.size(52.dp).clip(shape).background(fill),
|
||||
|
||||
@@ -32,6 +32,31 @@ class GamepadInk(
|
||||
val shadeScale: Float,
|
||||
/** True when the field is pale, for the few places that branch rather than blend. */
|
||||
val isLight: Boolean,
|
||||
/**
|
||||
* The near-opaque ground a MODAL card sits on. A dialog can't be glass: it has to occlude the
|
||||
* screen it covers, and it carries [fg] text — which is why this must follow the palette. It
|
||||
* was a hardcoded near-black indigo, so on a pale palette the card's dark ink landed on a dark
|
||||
* card and the dialogs were unreadable.
|
||||
*/
|
||||
val card: Color,
|
||||
/**
|
||||
* What dims the screen BEHIND a modal. Always dark, whatever the field: a scrim's job is to
|
||||
* push the backdrop down, and a pale field lit with more white doesn't recede — it glares. A
|
||||
* pale one needs less of it, because it has further to fall.
|
||||
*/
|
||||
val modalScrim: Color,
|
||||
/**
|
||||
* The light a glass surface catches along its top edge. White either way — a highlight is a
|
||||
* specular, not a tint — but a pale field's frost is already bright, so it takes MORE to read
|
||||
* as an edge against the pastel showing through it.
|
||||
*/
|
||||
val highlight: Color,
|
||||
/**
|
||||
* What a failure says itself in — the pairing error, and anything else the console has to
|
||||
* refuse in words. Follows the palette because it lands on [card], not on the field: the salmon
|
||||
* that reads on a dark modal is washed out on a near-white one.
|
||||
*/
|
||||
val danger: Color,
|
||||
) {
|
||||
/** The foreground at [alpha]. */
|
||||
fun fg(alpha: Float): Color = fg.copy(alpha = alpha)
|
||||
@@ -50,6 +75,7 @@ class GamepadInk(
|
||||
val accentLuma =
|
||||
0.2126 * p.accent.first + 0.7152 * p.accent.second + 0.0722 * p.accent.third
|
||||
val onAccent = if (accentLuma > 0.55) Color.Black else Color.White
|
||||
val (gr, gg, gb) = p.ground
|
||||
if (!p.light) {
|
||||
return GamepadInk(
|
||||
fg = Color.White,
|
||||
@@ -59,9 +85,20 @@ class GamepadInk(
|
||||
shade = Color.Black,
|
||||
shadeScale = 1f,
|
||||
isLight = false,
|
||||
// The palette's own ground, lifted just off it so the card reads as a surface
|
||||
// ABOVE the field rather than a hole in it. For the brand violet that lands on
|
||||
// the #1A1730 the dialogs were hardcoded to, which is where the number came from.
|
||||
card = Color(
|
||||
(gr + 0.030).toFloat().coerceAtMost(1f),
|
||||
(gg + 0.030).toFloat().coerceAtMost(1f),
|
||||
(gb + 0.040).toFloat().coerceAtMost(1f),
|
||||
0.94f,
|
||||
),
|
||||
modalScrim = Color.Black.copy(alpha = 0.62f),
|
||||
highlight = Color.White.copy(alpha = 0.30f),
|
||||
danger = Color(0xFFE0736F),
|
||||
)
|
||||
}
|
||||
val (gr, gg, gb) = p.ground
|
||||
return GamepadInk(
|
||||
// Tinted toward the palette's own ground so it doesn't read as a foreign grey.
|
||||
fg = Color((gr * 0.16).toFloat(), (gg * 0.14).toFloat(), (gb * 0.20).toFloat()),
|
||||
@@ -73,6 +110,15 @@ class GamepadInk(
|
||||
shade = Color.White,
|
||||
shadeScale = 0.45f,
|
||||
isLight = true,
|
||||
// Near-white rather than near-black: the card carries this palette's DARK ink.
|
||||
card = Color.White.copy(alpha = 0.94f),
|
||||
// Lighter than the dark field's: a pastel backdrop is closer to the card already,
|
||||
// so the same 0.62 would read as a bruise rather than a recession.
|
||||
modalScrim = Color.Black.copy(alpha = 0.38f),
|
||||
highlight = Color.White.copy(alpha = 0.55f),
|
||||
// Deepened for the near-white card the pale palettes' modals use — the dark
|
||||
// field's salmon has nothing like enough contrast against it.
|
||||
danger = Color(0xFFB3352F),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,10 @@ fun GamepadNavEffect(
|
||||
) {
|
||||
val activity = LocalContext.current as? MainActivity ?: return
|
||||
val state = remember { NavInputState() }
|
||||
// Menu feel, inherited by every console screen that navigates through here rather than wired
|
||||
// per screen: a tick as the cursor steps, a pulse on confirm. Renders on the driving pad's own
|
||||
// motors, the phone body if it has none, and nothing at all on a TV.
|
||||
val haptics by rememberUpdatedState(rememberConsoleHaptics())
|
||||
// The effects below are keyed on `active` only (they must NOT restart on every recomposition), so
|
||||
// they'd otherwise capture the FIRST callbacks — closing over a stale `tiles` (fewer hosts than are
|
||||
// discovered later, which clamped navigation to that old count). rememberUpdatedState keeps the
|
||||
@@ -98,7 +102,10 @@ fun GamepadNavEffect(
|
||||
KeyEvent.KEYCODE_DPAD_UP -> { if (edge) currentOnUp(); true }
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> { if (edge) currentOnDown(); true }
|
||||
KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_DPAD_CENTER,
|
||||
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { if (edge) currentOnActivate(); true }
|
||||
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
|
||||
if (edge) { haptics.confirm(); currentOnActivate() }
|
||||
true
|
||||
}
|
||||
// The gamepad Select / View / Share button → context options (a remote uses Down).
|
||||
KeyEvent.KEYCODE_BUTTON_SELECT -> { if (edge) currentOnOptions(); true }
|
||||
KeyEvent.KEYCODE_BUTTON_X -> { if (edge) currentOnTertiary(); true }
|
||||
@@ -139,8 +146,10 @@ fun GamepadNavEffect(
|
||||
}
|
||||
when {
|
||||
dir == 0 -> committed = 0
|
||||
dir != committed -> { currentOnMove(dir); committed = dir; fireAt = now + INITIAL_DELAY_MS }
|
||||
now >= fireAt -> { currentOnMove(dir); fireAt = now + REPEAT_MS }
|
||||
dir != committed -> {
|
||||
haptics.tick(); currentOnMove(dir); committed = dir; fireAt = now + INITIAL_DELAY_MS
|
||||
}
|
||||
now >= fireAt -> { haptics.tick(); currentOnMove(dir); fireAt = now + REPEAT_MS }
|
||||
}
|
||||
delay(16)
|
||||
}
|
||||
@@ -167,6 +176,9 @@ fun GamepadNavEffect2D(
|
||||
) {
|
||||
val activity = LocalContext.current as? MainActivity ?: return
|
||||
val state = remember { NavInputState() }
|
||||
// See [GamepadNavEffect] — the same menu feel, so a form screen and a carousel answer a press
|
||||
// identically.
|
||||
val haptics by rememberUpdatedState(rememberConsoleHaptics())
|
||||
val currentOnDirection by rememberUpdatedState(onDirection)
|
||||
val currentOnActivate by rememberUpdatedState(onActivate)
|
||||
val currentOnTertiary by rememberUpdatedState(onTertiary)
|
||||
@@ -196,12 +208,15 @@ fun GamepadNavEffect2D(
|
||||
KeyEvent.KEYCODE_DPAD_UP -> { state.dpadY = if (down) -1 else 0; true }
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> { state.dpadY = if (down) 1 else 0; true }
|
||||
KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_DPAD_CENTER,
|
||||
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { if (edge) currentOnActivate(); true }
|
||||
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
|
||||
if (edge) { haptics.confirm(); currentOnActivate() }
|
||||
true
|
||||
}
|
||||
KeyEvent.KEYCODE_BUTTON_X -> { if (edge) currentOnTertiary(); true }
|
||||
KeyEvent.KEYCODE_BUTTON_Y -> { if (edge) currentOnSecondary(); true }
|
||||
// Edge-only, no auto-repeat: a held shoulder shouldn't spin through the tabs.
|
||||
KeyEvent.KEYCODE_BUTTON_L1 -> { if (edge) currentOnShoulder(-1); true }
|
||||
KeyEvent.KEYCODE_BUTTON_R1 -> { if (edge) currentOnShoulder(1); true }
|
||||
KeyEvent.KEYCODE_BUTTON_L1 -> { if (edge) { haptics.tick(); currentOnShoulder(-1) }; true }
|
||||
KeyEvent.KEYCODE_BUTTON_R1 -> { if (edge) { haptics.tick(); currentOnShoulder(1) }; true }
|
||||
else -> false // B → MainActivity (remapped to BACK → BackHandler)
|
||||
}
|
||||
}
|
||||
@@ -229,8 +244,10 @@ fun GamepadNavEffect2D(
|
||||
when {
|
||||
raw == null && nearCentre -> committed = null
|
||||
raw == null -> { /* in the hysteresis band → hold, don't fire */ }
|
||||
raw != committed -> { currentOnDirection(raw); committed = raw; fireAt = now + INITIAL_DELAY_MS }
|
||||
now >= fireAt -> { currentOnDirection(raw); fireAt = now + REPEAT_MS }
|
||||
raw != committed -> {
|
||||
haptics.tick(); currentOnDirection(raw); committed = raw; fireAt = now + INITIAL_DELAY_MS
|
||||
}
|
||||
now >= fireAt -> { haptics.tick(); currentOnDirection(raw); fireAt = now + REPEAT_MS }
|
||||
}
|
||||
delay(16)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,21 @@ import androidx.compose.ui.graphics.Color
|
||||
// on every client. Keep the three copies in step: a palette added here without the others is a
|
||||
// value the other clients silently render as Violet.
|
||||
|
||||
/**
|
||||
* One wandering interior control point of the mesh: [x]/[y] its resting place in unit UV, [amp] how
|
||||
* far it strays, [sx]/[sy] its per-axis rates in rad·s⁻¹ and [phase] its offset. Its live
|
||||
* displacement `(amp·sin(t·sx+ph), amp·cos(t·sy+ph·1.3))` drives a bounded domain warp, so the
|
||||
* bright colour pools drift with it.
|
||||
*/
|
||||
class MeshWarpPoint(
|
||||
val x: Double,
|
||||
val y: Double,
|
||||
val amp: Double,
|
||||
val sx: Double,
|
||||
val sy: Double,
|
||||
val phase: Double,
|
||||
)
|
||||
|
||||
/** One background colour family. */
|
||||
class GamepadPalette(
|
||||
/** The stored `ui_palette` value ([Settings.uiPalette]). */
|
||||
@@ -47,11 +62,29 @@ class GamepadPalette(
|
||||
/** The accent as a Compose colour. */
|
||||
val accentColor: Color by lazy { color(accent) }
|
||||
|
||||
/**
|
||||
* The 16 mesh colours this palette's field is woven from: the ramp sampled per cell (see
|
||||
* [CELL_RAMP]), or [MESH_COLORS] verbatim for the brand default — the exact rule
|
||||
* `pf-console-ui`'s `Palette::mesh_colors` follows, so one `ui_palette` value is one field on
|
||||
* every client. Consumed by the AGSL backdrop on API 33+; the blob field
|
||||
* ([blobColors]) approximates the same table below that.
|
||||
*/
|
||||
val meshColors: List<Triple<Double, Double, Double>> by lazy {
|
||||
if (stops.isEmpty()) {
|
||||
MESH_COLORS
|
||||
} else {
|
||||
(0..15).map { i ->
|
||||
ramp(stops, 0.5 * ((i % 4) / 3.0 + (i / 4) / 3.0) + CELL_RAMP[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Where each of the 16 mesh cells samples the ramp on the clients that draw a mesh. Kept
|
||||
* here so the three ports stay one table even though this client approximates the field
|
||||
* with blobs.
|
||||
* Where each of the 16 mesh cells samples the ramp. The base is the diagonal
|
||||
* `0.5·(x + y)` — top-left the ramp's dark end, bottom-right its bright one — and the
|
||||
* per-cell nudges break the banding a pure diagonal would show. Mirrored from
|
||||
* `pf-console-ui`'s `CELL_RAMP`.
|
||||
*/
|
||||
val CELL_RAMP = listOf(
|
||||
0.10, -0.06, 0.04, -0.12,
|
||||
@@ -60,6 +93,34 @@ class GamepadPalette(
|
||||
-0.10, 0.08, -0.06, 0.12,
|
||||
)
|
||||
|
||||
/**
|
||||
* The brand default's 16 mesh colours, used verbatim (rather than sampled from a ramp) so
|
||||
* `violet` stays bit-identical to what every install already sees. Mirrors
|
||||
* `pf-console-ui`'s `MESH_COLORS`.
|
||||
*/
|
||||
val MESH_COLORS = listOf(
|
||||
Triple(0.075, 0.060, 0.160), Triple(0.34, 0.27, 0.72),
|
||||
Triple(0.30, 0.26, 0.74), Triple(0.075, 0.060, 0.160),
|
||||
Triple(0.42, 0.20, 0.54), Triple(0.49, 0.39, 0.95),
|
||||
Triple(0.28, 0.31, 0.84), Triple(0.16, 0.26, 0.64),
|
||||
Triple(0.45, 0.23, 0.60), Triple(0.53, 0.31, 0.75),
|
||||
Triple(0.35, 0.35, 0.91), Triple(0.19, 0.28, 0.70),
|
||||
Triple(0.075, 0.060, 0.160), Triple(0.22, 0.18, 0.54),
|
||||
Triple(0.24, 0.20, 0.58), Triple(0.075, 0.060, 0.160),
|
||||
)
|
||||
|
||||
/**
|
||||
* The four interior points that wander; the 12 boundary points stay pinned to the frame (a
|
||||
* drifting edge point would shrink the field and expose the ground behind it). Periods
|
||||
* ~90–130 s, out of phase, so the field never visibly loops. Mirrors `MESH_INTERIOR`.
|
||||
*/
|
||||
val MESH_INTERIOR = listOf(
|
||||
MeshWarpPoint(0.333, 0.333, 0.11, 0.049, 0.063, 0.4),
|
||||
MeshWarpPoint(0.667, 0.333, 0.10, 0.055, 0.052, 2.1),
|
||||
MeshWarpPoint(0.333, 0.667, 0.10, 0.058, 0.049, 3.6),
|
||||
MeshWarpPoint(0.667, 0.667, 0.12, 0.047, 0.061, 5.0),
|
||||
)
|
||||
|
||||
/** The brand default's blob ramp — the colours the pre-palette field used. */
|
||||
private val VIOLET_BLOBS = listOf(
|
||||
Triple(0.53, 0.47, 0.96), Triple(0.24, 0.20, 0.72), Triple(0.62, 0.30, 0.80),
|
||||
|
||||
@@ -3,20 +3,19 @@ package io.unom.punktfunk
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.SizeTransform
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -25,15 +24,28 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.displayCutout
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.union
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ChevronLeft
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -46,13 +58,23 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.disabled
|
||||
import androidx.compose.ui.semantics.hideFromAccessibility
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.toggleableState
|
||||
import androidx.compose.ui.state.ToggleableState
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
@@ -103,6 +125,19 @@ internal class GpRow(
|
||||
val toggled: Boolean? = null, // non-null = a toggle row, drawn as a ConsoleSwitch (not text)
|
||||
val adjustable: Boolean = true, // false = the row navigates/acts instead of stepping — no chevrons
|
||||
val enabled: Boolean = true, // dimmed + inert when false (still focusable, for its detail)
|
||||
/**
|
||||
* What A does on a non-adjustable row, for the legend. It was the literal "Pin to hosts" in the
|
||||
* hint bar back when a profile row was the only kind of row that acted rather than stepped; a
|
||||
* row that opens the Controllers view was then advertised as pinning something.
|
||||
*/
|
||||
val actionHint: String = "Open",
|
||||
/**
|
||||
* A choice row's full option list + where [value] sits in it — what the [ConsoleOptionBand]
|
||||
* drum turns through. Null (with [selectedIndex] -1) on everything that is not a stepped
|
||||
* choice: toggles are a switch, and the flat rows keep the quiet text slip.
|
||||
*/
|
||||
val options: List<String>? = null,
|
||||
val selectedIndex: Int = -1,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -114,12 +149,28 @@ internal class GpRow(
|
||||
internal fun liveRow(rows: List<GpRow>, index: Int): GpRow? =
|
||||
rows.getOrNull(index)?.takeIf { it.enabled }
|
||||
|
||||
/**
|
||||
* Where the cursor was when a row opened a SUB-SCREEN. The shell holds it across the trip (this
|
||||
* screen's own state does not outlive it) and hands it back, so Back from the Controllers view lands
|
||||
* on the row that opened it rather than on the first row of the first section.
|
||||
*
|
||||
* The row is remembered by ID, not by index: a tab's length follows the hardware and the profile
|
||||
* catalog, and a remembered index is the stale-pointer bug the tab-switch clamp already exists for.
|
||||
*/
|
||||
data class GpSettingsPlace(val tab: GpTab, val rowId: String)
|
||||
|
||||
@Composable
|
||||
fun GamepadSettingsScreen(
|
||||
initial: Settings,
|
||||
onChange: (Settings) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
navActive: Boolean = true, // false while this screen is cross-fading out, so it drops the pad
|
||||
/** Open the connected-controllers view / the open-source notices — the shell pushes them. */
|
||||
onOpenControllers: () -> Unit = {},
|
||||
onOpenLicenses: () -> Unit = {},
|
||||
/** Where a return from one of those lands; null = a fresh entry, which starts at the top. */
|
||||
resume: GpSettingsPlace? = null,
|
||||
onPlace: (GpSettingsPlace) -> Unit = {},
|
||||
) {
|
||||
var s by remember { mutableStateOf(initial) }
|
||||
fun update(next: Settings) { s = next; onChange(next) }
|
||||
@@ -162,24 +213,46 @@ fun GamepadSettingsScreen(
|
||||
// path there is this screen's own Controller-optimized UI toggle, which swaps in the standard
|
||||
// interface remote-navigably. The strings branch on it.
|
||||
val tv = remember { isTvDevice(context) }
|
||||
val allRows = buildSettingsRows(s, hasBodyVibrator, hasGyroscope, av1Capable, ::update) +
|
||||
buildProfileRows(profiles, savedHosts, tv) { pinProfile = it }
|
||||
// The installed version, for the About row — the console is the ONLY interface on a TV box, so
|
||||
// the identity the touch About page states has to be reachable from here too.
|
||||
val appVersion = remember {
|
||||
runCatching {
|
||||
@Suppress("DEPRECATION")
|
||||
context.packageManager.getPackageInfo(context.packageName, 0).versionName
|
||||
}.getOrNull().orEmpty()
|
||||
}
|
||||
val allRows = buildSettingsRows(
|
||||
s, hasBodyVibrator, hasGyroscope, av1Capable,
|
||||
appVersion = appVersion,
|
||||
openControllers = onOpenControllers,
|
||||
openLicenses = onOpenLicenses,
|
||||
update = ::update,
|
||||
) + buildProfileRows(profiles, savedHosts, tv) { pinProfile = it }
|
||||
// Which section is showing, and where each one's focus was when it was last left — a detour
|
||||
// into another tab shouldn't lose your place.
|
||||
var tab by remember { mutableStateOf(GpTab.STREAM) }
|
||||
var tab by remember { mutableStateOf(resume?.tab ?: GpTab.STREAM) }
|
||||
// True while the STRIP holds the cursor rather than the list. Up from the first row moves
|
||||
// here and Down goes back — the only route to the sections on a D-pad remote, which has no
|
||||
// shoulder buttons at all (and is exactly what a TV box ships with).
|
||||
var tabFocused by remember { mutableStateOf(false) }
|
||||
val tabFocus = remember { mutableStateMapOf<GpTab, Int>() }
|
||||
val rows = allRows.filter { it.tab == tab }
|
||||
var focus by remember { mutableIntStateOf(0) }
|
||||
// Entry focus: the row a sub-screen was opened from, if we are coming back from one. Resolved
|
||||
// ONCE, against the first row list — after that the cursor belongs to this screen.
|
||||
var focus by remember {
|
||||
mutableIntStateOf(rows.indexOfFirst { it.id == resume?.rowId }.coerceAtLeast(0))
|
||||
}
|
||||
if (focus > rows.lastIndex) focus = rows.lastIndex.coerceAtLeast(0)
|
||||
|
||||
// Which way the section last moved (+1 forward / -1 back) — the row list slides in from that
|
||||
// side, so stepping sections reads as travelling along a strip rather than teleporting.
|
||||
var tabDir by remember { mutableIntStateOf(1) }
|
||||
|
||||
// L1/R1 — one section along, wrapping (the strip is a ring, like A's value cycle).
|
||||
fun selectTab(next: GpTab) {
|
||||
if (next == tab) return
|
||||
tabFocus[tab] = focus
|
||||
tabDir = if (next.ordinal > tab.ordinal) 1 else -1
|
||||
tab = next
|
||||
// Clamp: a tab's length follows the hardware and the catalog, so a remembered index can
|
||||
// outlive the row it pointed at.
|
||||
@@ -188,15 +261,45 @@ fun GamepadSettingsScreen(
|
||||
}
|
||||
fun stepTab(delta: Int) {
|
||||
val all = GpTab.entries
|
||||
selectTab(all[((all.indexOf(tab) + delta) % all.size + all.size) % all.size])
|
||||
val next = all[((all.indexOf(tab) + delta) % all.size + all.size) % all.size]
|
||||
selectTab(next)
|
||||
// A wrap (last → first) is still a step in the direction you pressed, whatever the ordinals
|
||||
// say — selectTab's ordinal compare would read it backwards.
|
||||
tabDir = delta
|
||||
}
|
||||
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
|
||||
// value text slides in its AnimatedContent, so the motion matches the button press.
|
||||
var adjustDir by remember { mutableIntStateOf(1) }
|
||||
val listState = rememberLazyListState()
|
||||
// Bumped on every ACCEPTED step of the focused row (the chevron ticks) and every REFUSED one
|
||||
// (the value gives a little and springs back). A press always gets an answer, even "no".
|
||||
var stepToken by remember { mutableIntStateOf(0) }
|
||||
var refusalToken by remember { mutableIntStateOf(0) }
|
||||
val haptics = rememberConsoleHaptics()
|
||||
|
||||
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
|
||||
// Act on a row, publishing where the cursor was FIRST. A row that opens a sub-screen unmounts
|
||||
// this one on the spot, so the place has to be out of here before its `activate` runs; every
|
||||
// activation route (pad, tap, the legend's own A cell) goes through this one door so none of
|
||||
// them can be the one that forgets.
|
||||
fun activate(row: GpRow) {
|
||||
onPlace(GpSettingsPlace(tab, row.id))
|
||||
adjustDir = 1
|
||||
row.activate()
|
||||
}
|
||||
|
||||
// Step the focused row's value, answering a refusal rather than swallowing it.
|
||||
fun step(delta: Int) {
|
||||
adjustDir = delta
|
||||
val row = liveRow(rows, focus)
|
||||
if (row != null && row.adjust(delta)) {
|
||||
stepToken++
|
||||
} else {
|
||||
refusalToken++
|
||||
haptics.boundary()
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(onBack = onBack)
|
||||
GamepadNavEffect2D(
|
||||
// The pin picker owns the pad while it's up (its own nav + BackHandler), so this screen
|
||||
@@ -209,89 +312,233 @@ fun GamepadSettingsScreen(
|
||||
// On the strip, left/right walks sections; on a row it steps the value. A disabled
|
||||
// row is INERT, not just dim — the step is refused instead of writing a setting
|
||||
// that has nothing to act on (see `liveRow`).
|
||||
NavDir.LEFT ->
|
||||
if (tabFocused) stepTab(-1) else { adjustDir = -1; liveRow(rows, focus)?.adjust(-1) }
|
||||
NavDir.RIGHT ->
|
||||
if (tabFocused) stepTab(1) else { adjustDir = 1; liveRow(rows, focus)?.adjust(1) }
|
||||
NavDir.LEFT -> if (tabFocused) stepTab(-1) else step(-1)
|
||||
NavDir.RIGHT -> if (tabFocused) stepTab(1) else step(1)
|
||||
}
|
||||
},
|
||||
// A on the strip drops into the section you picked, which is what "confirm" means there.
|
||||
onActivate = {
|
||||
if (tabFocused) tabFocused = false else { adjustDir = 1; liveRow(rows, focus)?.activate() }
|
||||
if (tabFocused) tabFocused = false else liveRow(rows, focus)?.let { activate(it) }
|
||||
},
|
||||
// The shoulders work from either place — a real pad never has to visit the strip.
|
||||
onShoulder = { delta -> stepTab(delta) },
|
||||
)
|
||||
// Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the
|
||||
// screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it.
|
||||
// +1 accounts for the heading being item 0.
|
||||
LaunchedEffect(focus, tab) {
|
||||
runCatching {
|
||||
val itemIndex = focus + 1
|
||||
val info = listState.layoutInfo
|
||||
val item = info.visibleItemsInfo.firstOrNull { it.index == itemIndex }
|
||||
val offScreen = item == null ||
|
||||
item.offset < info.viewportStartOffset ||
|
||||
item.offset + item.size > info.viewportEndOffset - 96 // keep clear of the floating legend
|
||||
if (offScreen) listState.animateScrollToItem(itemIndex)
|
||||
}
|
||||
}
|
||||
|
||||
val animated = animationsEnabled()
|
||||
val hazeState = remember { HazeState() }
|
||||
val ink = LocalGamepadInk.current
|
||||
// The list runs to the PHYSICAL bottom edge (see the column's insets below), so the legend
|
||||
// zone's clearance has to carry the bottom bar inset itself. Landscape's zone is only the
|
||||
// pill — its detail lives in the side pane — so it clears less.
|
||||
val bottomInset = with(LocalDensity.current) {
|
||||
WindowInsets.systemBars.getBottom(this).toDp()
|
||||
}
|
||||
val legendClearance = (if (landscape) 92.dp else ConsoleLegendClearance) + bottomInset
|
||||
val legendClearancePx = with(LocalDensity.current) { legendClearance.roundToPx() }
|
||||
// The drum's fixed stage: a portrait phone is the one place the full width starves the row's
|
||||
// label, so it alone narrows it — the Apple band makes the same single exception. 132, not the
|
||||
// 156 of the first cut: at 156 the LABELS truncated ("Resoluti…"), and a clipped label loses
|
||||
// meaning where a drum value only loses its tail into the edge fade.
|
||||
val bandWidth = if (landscape) 220.dp else 132.dp
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
// Everything scrolls — including the heading — so nothing is pinned. Vital in landscape,
|
||||
// where a fixed title + a fixed detail/legend strip ate most of the (short) height.
|
||||
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||
GamepadFormBackground(Modifier.fillMaxSize())
|
||||
Column(Modifier.fillMaxSize().systemBarsPadding()) {
|
||||
// The strip is PINNED while the rows scroll under it: it is this screen's primary
|
||||
// navigation now, and a switcher you have to scroll back up to find isn't one. The
|
||||
// title stays in the scrolling list (landscape has no height to spare, and the
|
||||
// selected pill already says which section you are in).
|
||||
ConsoleTabStrip(
|
||||
titles = GpTab.entries.map { it.title },
|
||||
selected = GpTab.entries.indexOf(tab),
|
||||
onSelect = { tabFocused = false; selectTab(GpTab.entries[it]) },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 2.dp),
|
||||
focused = tabFocused,
|
||||
)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 104.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
item(key = "__title") {
|
||||
// "Default settings", not "Settings": this screen edits the base layer only. The
|
||||
// console honours a host's profile but doesn't edit profiles (design §5.4), so a
|
||||
// bare "Settings" would quietly imply it changes whatever that host streams with.
|
||||
ConsoleHeader("Default settings", horizontalInset = false)
|
||||
/**
|
||||
* One section's rows as a scrolling pane. A composable local rather than inline because the
|
||||
* tab transition composes TWO of these at once (incoming and outgoing), and each needs its own
|
||||
* [LazyListState] — Compose refuses one state attached to two lists, which is why the previous
|
||||
* cut animated a single list's contents and read as the same fade in every direction.
|
||||
*/
|
||||
val tabPane: @Composable (GpTab, Modifier) -> Unit = { paneTab, paneModifier ->
|
||||
val paneRows = if (paneTab == tab) rows else allRows.filter { it.tab == paneTab }
|
||||
val paneFocus = if (paneTab == tab) focus else (tabFocus[paneTab] ?: 0)
|
||||
// Seeded at the restored cursor, so re-entering a section lands where it was left without
|
||||
// a visible catch-up scroll on the first frame.
|
||||
val paneListState = rememberLazyListState(
|
||||
initialFirstVisibleItemIndex = paneFocus.coerceIn(0, paneRows.lastIndex.coerceAtLeast(0)),
|
||||
)
|
||||
// Keep the focused row on screen, but only SCROLL when it's actually off-screen. Only the
|
||||
// LIVE pane tracks the cursor; the outgoing one is a photograph on its way out.
|
||||
if (paneTab == tab) {
|
||||
LaunchedEffect(focus) {
|
||||
runCatching {
|
||||
val info = paneListState.layoutInfo
|
||||
val item = info.visibleItemsInfo.firstOrNull { it.index == focus }
|
||||
val offScreen = item == null ||
|
||||
item.offset < info.viewportStartOffset ||
|
||||
// The SAME clearance the list pads its bottom with, rather than a literal
|
||||
// that has to be remembered when the legend zone grows.
|
||||
item.offset + item.size > info.viewportEndOffset - legendClearancePx
|
||||
if (offScreen) paneListState.animateScrollToItem(focus)
|
||||
}
|
||||
}
|
||||
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
|
||||
}
|
||||
LazyColumn(
|
||||
state = paneListState,
|
||||
// Capped at the Apple client's 620 row width: a landscape phone is WIDER than it is
|
||||
// useful, and a settings row stretched across 900 dp reads as a ribbon, not a control.
|
||||
// Start-aligned (not centred) so the rows and the side detail pane split the screen
|
||||
// rather than both crowding the middle.
|
||||
modifier = paneModifier.widthIn(max = 620.dp + ConsoleEdgeInset * 2),
|
||||
contentPadding = PaddingValues(
|
||||
start = ConsoleEdgeInset,
|
||||
end = ConsoleEdgeInset,
|
||||
top = 8.dp,
|
||||
// Clears the whole floating legend ZONE, bottom bar included — the list itself
|
||||
// runs to the screen edge now.
|
||||
bottom = legendClearance,
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
itemsIndexed(paneRows, key = { _, r -> r.id }) { index, row ->
|
||||
val rowFocused = paneTab == tab && index == focus && !tabFocused
|
||||
SettingRowView(
|
||||
row,
|
||||
focused = index == focus && !tabFocused,
|
||||
focused = rowFocused,
|
||||
adjustDir = adjustDir,
|
||||
// Only the focused row can be stepped, so only it needs to answer one.
|
||||
stepToken = if (rowFocused) stepToken else 0,
|
||||
refusalToken = if (rowFocused) refusalToken else 0,
|
||||
bandWidth = bandWidth,
|
||||
onClick = {
|
||||
// Same inertness as the pad path above — tapping a dimmed row focuses it
|
||||
// (so its detail explains itself) but never flips it.
|
||||
tabFocused = false
|
||||
if (focus != index) focus = index
|
||||
else if (row.enabled) { adjustDir = 1; row.activate() }
|
||||
else if (row.enabled) activate(row)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The section switcher with its directional content — shared by both orientations below. */
|
||||
val tabbedContent: @Composable (Modifier) -> Unit = { contentModifier ->
|
||||
AnimatedContent(
|
||||
targetState = tab,
|
||||
modifier = contentModifier,
|
||||
transitionSpec = {
|
||||
if (!animated) {
|
||||
fadeIn(tween(ConsoleMotion.REDUCED_MS)) togetherWith
|
||||
fadeOut(tween(ConsoleMotion.REDUCED_MS))
|
||||
} else {
|
||||
// DIRECTION-driven, with a real exit: the incoming section slides in from the
|
||||
// side the press pointed at while the outgoing leaves the other way — paging
|
||||
// along a strip. The previous cut slid a single list's contents 24 dp under an
|
||||
// 85 % fade, which read as the same crossfade whichever shoulder was pressed.
|
||||
val dir = tabDir
|
||||
(
|
||||
slideInHorizontally(ConsoleMotion.ease(ConsoleMotion.TAB_MS)) { it / 6 * dir } +
|
||||
fadeIn(ConsoleMotion.ease(ConsoleMotion.TAB_MS))
|
||||
) togetherWith (
|
||||
slideOutHorizontally(ConsoleMotion.ease(ConsoleMotion.TAB_MS)) { -it / 6 * dir } +
|
||||
fadeOut(ConsoleMotion.ease(ConsoleMotion.TAB_MS))
|
||||
)
|
||||
}
|
||||
},
|
||||
label = "settingsTab",
|
||||
) { t ->
|
||||
tabPane(t, Modifier.fillMaxHeight())
|
||||
}
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||
// The backdrop stays full-bleed — it is ambience. The CHROME (strip, rows' start
|
||||
// edge) takes the safe area on the sides and top only: the LIST deliberately runs to
|
||||
// the physical bottom of the screen, with the bottom inset folded into its
|
||||
// contentPadding, so scrolled rows glide off the edge instead of being guillotined at
|
||||
// an invisible inset line 24 px above it (third on-glass verdict).
|
||||
GamepadFormBackground(Modifier.fillMaxSize())
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(
|
||||
WindowInsets.systemBars.union(WindowInsets.displayCutout)
|
||||
.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top),
|
||||
),
|
||||
) {
|
||||
// The strip is PINNED while the rows scroll under it: it is this screen's primary
|
||||
// navigation, and a switcher you have to scroll back up to find isn't one.
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
ConsoleTabStrip(
|
||||
titles = GpTab.entries.map { it.title },
|
||||
selected = GpTab.entries.indexOf(tab),
|
||||
onSelect = { tabFocused = false; selectTab(GpTab.entries[it]) },
|
||||
modifier = Modifier.weight(1f),
|
||||
focused = tabFocused,
|
||||
)
|
||||
// The base-layer marker, where a full "Default settings" heading used to eat a
|
||||
// headline row on EVERY tab. The honesty it carried stays: this screen edits
|
||||
// the defaults only — the console honours a host's profile but doesn't edit
|
||||
// profiles (design §5.4) — and this quiet chip at the strip's end says so
|
||||
// without a second heading repeating the tab pill's own word.
|
||||
Text(
|
||||
"Defaults",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = ink.fg(0.45f),
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(start = 10.dp, end = ConsoleEdgeInset),
|
||||
)
|
||||
}
|
||||
if (landscape) {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
tabbedContent(Modifier.weight(0.6f))
|
||||
// The focused row's description, in the width a wide phone wastes — beside
|
||||
// the rows instead of floating over the list's tail (the portrait band).
|
||||
// Presentation only: the row already merges this text into its own
|
||||
// announcement, so the pane is hidden from a screen reader like the band.
|
||||
val focusedRow = rows.getOrNull(focus)
|
||||
AnimatedContent(
|
||||
targetState = if (tabFocused) null else focusedRow,
|
||||
transitionSpec = {
|
||||
fadeIn(ConsoleMotion.ease(ConsoleMotion.FOCUS_MS)) togetherWith
|
||||
fadeOut(ConsoleMotion.ease(ConsoleMotion.FOCUS_MS))
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(0.4f)
|
||||
.semantics { hideFromAccessibility() },
|
||||
label = "sideDetail",
|
||||
) { r ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(start = 6.dp, end = ConsoleEdgeInset, top = 22.dp),
|
||||
) {
|
||||
if (r != null && r.detail.isNotBlank()) {
|
||||
Text(
|
||||
r.label,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = ink.fg(0.85f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
r.detail,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = ink.fg(0.6f),
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tabbedContent(Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Floating frosted legend — a real backdrop blur of the rows scrolling behind it (no dedicated
|
||||
// strip). In landscape it ignores the safe area so it hugs the corner instead of the nav-bar inset.
|
||||
// The floating legend ZONE: the focused row's description above, the controls pill below,
|
||||
// both frosted over whatever scrolls behind them. It is an OVERLAY, so nothing in it can
|
||||
// ever displace the list — which is the whole reason the detail moved here out of the row.
|
||||
// In landscape it ignores the system bars so it hugs the corner instead of the nav-bar
|
||||
// inset, but it still takes the display cutout (reverse-landscape parks the punch here).
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||
.consoleLegendInsets(landscape)
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
// The legend follows the focused row (the desktop console's hints() does the same):
|
||||
@@ -306,31 +553,49 @@ fun GamepadSettingsScreen(
|
||||
// Activity (preview/tests), like GamepadHintBar's own glyph choice.
|
||||
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
|
||||
val sections = listOfNotNull(
|
||||
GamepadHint('⇄', Color(0xFF9A93C7), "Section", onClick = { stepTab(1) })
|
||||
GamepadHint('⇄', PadGlyph.Arrow, "Section", onClick = { stepTab(1) })
|
||||
.takeIf { padIsGamepad },
|
||||
)
|
||||
GamepadHintBar(
|
||||
if (tabFocused) listOf(
|
||||
GamepadHint('↔', Color(0xFF9A93C7), "Section"),
|
||||
PadGlyph.hint('A', "Open") { tabFocused = false },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
) else sections + when {
|
||||
focused != null && !focused.enabled -> listOf(
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// Portrait only: landscape's description lives in the side pane, where the wide
|
||||
// aspect has room for it — a band AND a pane would say the same thing twice.
|
||||
if (!landscape) {
|
||||
ConsoleDetailBand(
|
||||
// On the strip there is no row to describe, and the pills already name the
|
||||
// sections — a stale row's description there would describe the wrong thing.
|
||||
text = if (tabFocused) "" else focused?.detail.orEmpty(),
|
||||
key = if (tabFocused) "__strip" else focused?.id,
|
||||
hazeState = hazeState,
|
||||
)
|
||||
focused != null && !focused.adjustable -> listOf(
|
||||
PadGlyph.hint('A', "Pin to hosts") { focused.activate() },
|
||||
}
|
||||
GamepadHintBar(
|
||||
if (tabFocused) listOf(
|
||||
GamepadHint('↔', PadGlyph.Arrow, "Section"),
|
||||
PadGlyph.hint('A', "Open") { tabFocused = false },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
else -> listOf(
|
||||
GamepadHint('↔', Color(0xFF9A93C7), "Adjust"),
|
||||
// Tappable too (touch escape hatch): Change cycles the focused row, Done leaves.
|
||||
PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
},
|
||||
hazeState = hazeState,
|
||||
)
|
||||
) else sections + when {
|
||||
focused != null && !focused.enabled -> listOf(
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
// What A does here follows the ROW: it opens the pin picker on a profile,
|
||||
// the connected-controllers view on that one, the notices on the About row.
|
||||
// It was the literal "Pin to hosts" while profiles were the only such rows.
|
||||
focused != null && !focused.adjustable -> listOf(
|
||||
PadGlyph.hint('A', focused.actionHint) { activate(focused) },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
else -> listOf(
|
||||
GamepadHint('↔', PadGlyph.Arrow, "Adjust"),
|
||||
// Tappable too (touch hatch): Change cycles the focused row, Done leaves.
|
||||
PadGlyph.hint('A', "Change") {
|
||||
rows.getOrNull(focus)?.let { activate(it) }
|
||||
},
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
},
|
||||
hazeState = hazeState,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The pin-to-hosts picker for the activated profile row — the console counterpart of the
|
||||
@@ -347,24 +612,64 @@ fun GamepadSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One settings row. Its geometry NEVER changes with focus — that is the whole design of it.
|
||||
*
|
||||
* It used to unfold its description in place, which meant every D-pad step shrank one row and grew
|
||||
* another, shifting the entire list under the cursor and moving the keep-focus-visible scroll's
|
||||
* target out from under it mid-animation. The description now lives in the screen's floating
|
||||
* [ConsoleDetailBand], which is an overlay and cannot displace anything. Focus changes colour,
|
||||
* lift and bloom here; it does not change size.
|
||||
*
|
||||
* The value gets the same treatment sideways: a fixed minimum slot, end-aligned, with the size
|
||||
* transform snapped so the slot's WIDTH never animates. Stepping a choice used to widen and narrow
|
||||
* that slot on every press, walking the ‹ chevron back and forth. Tabular figures finish the job —
|
||||
* without them `1920 × 1080 → 2560 × 1440` changes width on the digits alone.
|
||||
*/
|
||||
@Composable
|
||||
private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick: () -> Unit) {
|
||||
private fun SettingRowView(
|
||||
row: GpRow,
|
||||
focused: Boolean,
|
||||
adjustDir: Int,
|
||||
stepToken: Int,
|
||||
refusalToken: Int,
|
||||
/** The option drum's fixed stage — sized by the SCREEN (orientation decides how much a row can spare). */
|
||||
bandWidth: Dp,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val visuals = animateConsoleFocus(active = focused)
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
// The chevrons keep their layout slot and only fade, so the value never jumps sideways when
|
||||
// focus arrives; the value colour cross-fades with them. A non-adjustable row (a profile row
|
||||
// navigates, the empty-catalog placeholder does nothing) never shows them at all.
|
||||
val chevronAlpha by animateFloatAsState(
|
||||
if (focused && row.adjustable) 0.6f else 0f,
|
||||
tween(160),
|
||||
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
|
||||
label = "chevrons",
|
||||
)
|
||||
val valueColor by animateColorAsState(
|
||||
ink.fg(if (focused) 1f else 0.6f),
|
||||
tween(160),
|
||||
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
|
||||
label = "valueColor",
|
||||
)
|
||||
// A press always gets an answer. Accepted: the chevron on the pressed side ticks outward and
|
||||
// springs back. Refused: the whole value slot gives 4 dp toward the press and springs back —
|
||||
// the "door is locked" motion, so a limit reads as a limit instead of as a dropped input.
|
||||
val chevronKick = remember { Animatable(0f) }
|
||||
LaunchedEffect(stepToken) {
|
||||
if (stepToken == 0) return@LaunchedEffect
|
||||
chevronKick.snapTo(2f * adjustDir)
|
||||
chevronKick.animateTo(0f, spring(dampingRatio = 0.45f, stiffness = 900f))
|
||||
}
|
||||
val refusal = remember { Animatable(0f) }
|
||||
LaunchedEffect(refusalToken) {
|
||||
if (refusalToken == 0) return@LaunchedEffect
|
||||
refusal.animateTo(
|
||||
ConsoleMotion.REFUSAL_NUDGE.value * adjustDir,
|
||||
ConsoleMotion.ease(ConsoleMotion.REFUSAL_MS / 2),
|
||||
)
|
||||
refusal.animateTo(0f, spring(dampingRatio = 0.5f, stiffness = Spring.StiffnessMedium))
|
||||
}
|
||||
Column {
|
||||
if (row.header != null) {
|
||||
Text(
|
||||
@@ -375,74 +680,127 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
Column(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
|
||||
.clip(shape)
|
||||
.background(visuals.background)
|
||||
.border(1.dp, visuals.border, shape)
|
||||
.consoleGlass(ConsoleShape.Row, visuals)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onClick,
|
||||
)
|
||||
// ONE announcement per row, not five leaves read in layout order. Three things had
|
||||
// to be gathered to make it true:
|
||||
// * the VALUE of a toggle row existed nowhere in the tree — the switch replaces
|
||||
// the value text (see below), so `row.value` ("On"/"Off") was drawn by nothing;
|
||||
// * the DESCRIPTION lives in the floating band at the far bottom of the screen,
|
||||
// which is the right place to LOOK and the wrong place to be read — so it is
|
||||
// merged here, where the row it explains is;
|
||||
// * `enabled` was a colour and nothing else.
|
||||
// A row therefore announces "Refresh rate, 120 Hz, Frame rate the host renders and
|
||||
// streams at" — which is what the screen already means, said once.
|
||||
.semantics(mergeDescendants = true) {
|
||||
role = if (row.toggled != null) Role.Switch else Role.Button
|
||||
contentDescription = listOfNotNull(
|
||||
row.label,
|
||||
row.value.takeIf { it.isNotBlank() },
|
||||
row.detail.takeIf { it.isNotBlank() },
|
||||
).joinToString(", ")
|
||||
row.toggled?.let {
|
||||
toggleableState = if (it) ToggleableState.On else ToggleableState.Off
|
||||
}
|
||||
if (!row.enabled) disabled()
|
||||
}
|
||||
.padding(horizontal = 16.dp, vertical = 13.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
row.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
// A disabled row (the "No profiles yet" placeholder) dims but stays focusable,
|
||||
// so its detail line can still explain what would go here.
|
||||
color = ink.fg(if (row.enabled) 1f else 0.45f),
|
||||
maxLines = 1,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(
|
||||
row.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
// A disabled row (the "No profiles yet" placeholder) dims but stays focusable, so
|
||||
// the detail band can still explain what would go here.
|
||||
color = ink.fg(if (row.enabled) 1f else 0.45f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
// Takes the slack rather than a Spacer doing it, so a long label ellipsizes into
|
||||
// the room it actually has instead of shoving the value slot off the row.
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.offset { IntOffset(refusal.value.dp.roundToPx(), 0) },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (row.toggled != null) {
|
||||
// A toggle is a switch, not text — the sliding knob + tinting track IS the value.
|
||||
ConsoleSwitch(on = row.toggled, focused = focused)
|
||||
} else {
|
||||
Text("‹ ", color = ink.fg, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
|
||||
// The value slides in the direction it was stepped and its width animates, so
|
||||
// cycling a choice reads as motion through a list rather than a text swap.
|
||||
AnimatedContent(
|
||||
targetState = row.value,
|
||||
transitionSpec = {
|
||||
val dir = adjustDir
|
||||
(slideInHorizontally(tween(180)) { w -> w / 2 * dir } + fadeIn(tween(180))) togetherWith
|
||||
(slideOutHorizontally(tween(140)) { w -> -w / 2 * dir } + fadeOut(tween(100))) using
|
||||
SizeTransform(clip = false)
|
||||
},
|
||||
label = "value",
|
||||
) { value ->
|
||||
Text(
|
||||
value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = valueColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
Icon(
|
||||
Icons.Filled.ChevronLeft,
|
||||
// Decoration: it says "this value steps", which the row's Switch/Button
|
||||
// role already says. Left in the tree it is read out on every focused row.
|
||||
contentDescription = null,
|
||||
tint = ink.fg,
|
||||
modifier = Modifier
|
||||
.size(18.dp)
|
||||
.semantics { hideFromAccessibility() }
|
||||
.graphicsLayer { alpha = chevronAlpha }
|
||||
.offset { IntOffset(minOf(chevronKick.value, 0f).dp.roundToPx(), 0) },
|
||||
)
|
||||
if (row.options != null && row.selectedIndex in row.options.indices) {
|
||||
// The drum — see ConsoleOptionBand. Its width is FIXED by the row, so a
|
||||
// step can never reflow the chevrons, and the tabular-figures concern
|
||||
// dissolves with it: nothing about the row's layout depends on the label.
|
||||
ConsoleOptionBand(
|
||||
options = row.options,
|
||||
selection = row.selectedIndex,
|
||||
focused = focused,
|
||||
width = bandWidth,
|
||||
)
|
||||
} else {
|
||||
// The flat rows (profile pin counts, the empty-catalog placeholder) keep
|
||||
// the quiet slip: the changed string slides in following the motion.
|
||||
AnimatedContent(
|
||||
targetState = row.value,
|
||||
transitionSpec = {
|
||||
val dir = adjustDir
|
||||
(
|
||||
slideInHorizontally(
|
||||
ConsoleMotion.ease(ConsoleMotion.VALUE_MS),
|
||||
) { w -> w / 2 * dir } +
|
||||
fadeIn(ConsoleMotion.ease(ConsoleMotion.VALUE_MS))
|
||||
) togetherWith (
|
||||
slideOutHorizontally(
|
||||
ConsoleMotion.ease(ConsoleMotion.VALUE_OUT_MS),
|
||||
) { w -> -w / 2 * dir } +
|
||||
fadeOut(ConsoleMotion.ease(100))
|
||||
) using SizeTransform(clip = false) { _, _ -> snap() }
|
||||
},
|
||||
label = "value",
|
||||
) { value ->
|
||||
Text(
|
||||
value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = valueColor,
|
||||
textAlign = TextAlign.End,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(" ›", color = ink.fg, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
|
||||
Icon(
|
||||
Icons.Filled.ChevronRight,
|
||||
contentDescription = null,
|
||||
tint = ink.fg,
|
||||
modifier = Modifier
|
||||
.size(18.dp)
|
||||
.semantics { hideFromAccessibility() }
|
||||
.graphicsLayer { alpha = chevronAlpha }
|
||||
.offset { IntOffset(maxOf(chevronKick.value, 0f).dp.roundToPx(), 0) },
|
||||
)
|
||||
}
|
||||
}
|
||||
// The focused row carries its own one-line description — no dedicated (space-eating)
|
||||
// detail strip. It unfolds right where you're looking, and the row grows to fit.
|
||||
AnimatedVisibility(
|
||||
visible = focused && row.detail.isNotBlank(),
|
||||
enter = fadeIn(tween(180, delayMillis = 60)) + expandVertically(tween(180)),
|
||||
exit = fadeOut(tween(90)) + shrinkVertically(tween(150)),
|
||||
) {
|
||||
Text(
|
||||
row.detail,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = ink.fg(0.6f),
|
||||
maxLines = 2,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -450,12 +808,17 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
/** Build the console settings rows from the current [Settings], writing through [update].
|
||||
* [hasBodyVibrator] gates the "Rumble on this phone" row and [hasGyroscope] the "Gyro from this
|
||||
* phone" row (both absent on TVs); [av1Capable] gates the AV1 codec entry (see
|
||||
* `codecOptionsFor`). Every row declares its [GpTab]; the screen shows one tab at a time. */
|
||||
* `codecOptionsFor`). [appVersion] is the installed version the About row states, and
|
||||
* [openControllers] / [openLicenses] are the two rows that navigate rather than set anything.
|
||||
* Every row declares its [GpTab]; the screen shows one tab at a time. */
|
||||
internal fun buildSettingsRows(
|
||||
s: Settings,
|
||||
hasBodyVibrator: Boolean,
|
||||
hasGyroscope: Boolean,
|
||||
av1Capable: Boolean,
|
||||
appVersion: String = "",
|
||||
openControllers: () -> Unit = {},
|
||||
openLicenses: () -> Unit = {},
|
||||
update: (Settings) -> Unit,
|
||||
): List<GpRow> {
|
||||
fun <T> choice(
|
||||
@@ -480,6 +843,8 @@ internal fun buildSettingsRows(
|
||||
val i = if (idx < 0) 0 else (idx + 1) % options.size
|
||||
options.getOrNull(i)?.let { write(it.first) }
|
||||
},
|
||||
options = options.map { it.second },
|
||||
selectedIndex = idx,
|
||||
)
|
||||
}
|
||||
fun toggle(
|
||||
@@ -633,6 +998,28 @@ internal fun buildSettingsRows(
|
||||
"triggers, lightbar and gyro.",
|
||||
s.dsCapture, enabled = s.gamepadForwarding,
|
||||
) { update(s.copy(dsCapture = it)) },
|
||||
// The diagnostics view — same screen the touch settings reach, same words for it. It was
|
||||
// reachable from touch ONLY, which on a TV box means not at all: there is no touch interface
|
||||
// to fall back to there, and "my controller does nothing" is the support case it answers.
|
||||
//
|
||||
// Deliberately NOT gated on the master forwarding switch its neighbours all follow: this is
|
||||
// the row you reach for when forwarding looks broken, and a diagnostic that dims itself when
|
||||
// the thing it diagnoses is off is worse than none.
|
||||
//
|
||||
// No value: this row navigates, it doesn't hold a setting (a count read here would be a
|
||||
// snapshot, and a stale "none detected" is worse than no number at all — the screen it opens
|
||||
// watches hot-plug live).
|
||||
GpRow(
|
||||
id = "controllers",
|
||||
tab = GpTab.CONTROLLER,
|
||||
header = "Diagnostics",
|
||||
label = "Connected controllers",
|
||||
value = "",
|
||||
detail = "What the app detects, with a live input test.",
|
||||
adjust = { false },
|
||||
activate = openControllers,
|
||||
adjustable = false,
|
||||
),
|
||||
|
||||
// The palette leads Interface: it is the one row whose effect you can see while you step
|
||||
// it (the backdrop behind this very list recolours), so it wants to be the first thing
|
||||
@@ -647,7 +1034,7 @@ internal fun buildSettingsRows(
|
||||
choice(
|
||||
"hud", GpTab.INTERFACE, null, "Statistics overlay",
|
||||
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
|
||||
"A 3-finger tap cycles the tiers live.",
|
||||
"Select + X on a pad, or a 3-finger tap, cycles the tiers live.",
|
||||
STATS_VERBOSITY_OPTIONS, s.statsVerbosity,
|
||||
) { update(s.copy(statsVerbosity = it)) },
|
||||
toggle(
|
||||
@@ -680,6 +1067,22 @@ internal fun buildSettingsRows(
|
||||
} else {
|
||||
null
|
||||
},
|
||||
) + listOf(
|
||||
// About closes the Interface section, the way the touch settings' last category does. The
|
||||
// notices are a licence obligation and were reachable from touch only — on a TV box that is
|
||||
// nowhere. The version rides in the VALUE slot rather than as a second, inert row: it is the
|
||||
// identity half of an About page, and the screen this opens states it again at the top.
|
||||
GpRow(
|
||||
id = "licenses",
|
||||
tab = GpTab.INTERFACE,
|
||||
header = "About",
|
||||
label = "Open-source licenses",
|
||||
value = appVersion,
|
||||
detail = "Third-party notices and credits.",
|
||||
adjust = { false },
|
||||
activate = openLicenses,
|
||||
adjustable = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -694,7 +1097,7 @@ internal fun buildSettingsRows(
|
||||
* Controller-optimized UI toggle a few rows up, which swaps the standard interface in
|
||||
* (d-pad-navigable; the profile editor lives there on every device, unlike tvOS where none exists).
|
||||
*/
|
||||
private fun buildProfileRows(
|
||||
internal fun buildProfileRows(
|
||||
profiles: List<StreamProfile>,
|
||||
savedHosts: List<KnownHost>,
|
||||
tv: Boolean,
|
||||
@@ -741,6 +1144,7 @@ private fun buildProfileRows(
|
||||
adjust = { false },
|
||||
activate = { openPinPicker(p) },
|
||||
adjustable = false,
|
||||
actionHint = "Pin to hosts",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import io.unom.punktfunk.kit.discovery.DiscoveredHost
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
|
||||
/**
|
||||
* The console home's tiles, in carousel order: every saved host with its pinned host+profile cards
|
||||
* immediately behind it, then the hosts seen on the network but not yet saved, then Add Host.
|
||||
*
|
||||
* Pure, and deliberately not a composable. The half of this that can be WRONG is the ordering and
|
||||
* what a tile claims — a pin drifting away from the host it belongs to, a discovered host offered a
|
||||
* second time next to the saved record it already is, a chip naming a profile the press won't
|
||||
* actually use. None of that needs a display to be checked, and `HomeTilesTest` checks it without
|
||||
* one; the console home itself needs the live JNI core to compose at all.
|
||||
*
|
||||
* [isOnline] and [pinsFor] arrive as lambdas rather than as the discovery lists and the profile
|
||||
* store behind them: "online" means advertising on mDNS OR answering a QUIC probe (the routed
|
||||
* Tailscale/VPN case), which is a rule belonging to the screen that does the probing, not to a list
|
||||
* builder.
|
||||
*/
|
||||
internal fun buildHomeTiles(
|
||||
savedHosts: List<KnownHost>,
|
||||
/** The live catalog — resolves each host's binding into the name and colour its chip wears. */
|
||||
profiles: List<StreamProfile>,
|
||||
pinsFor: (KnownHost) -> List<StreamProfile>,
|
||||
/** Already de-duped against [savedHosts] by the caller: a saved host is not also "discovered". */
|
||||
discoveredUnsaved: List<DiscoveredHost>,
|
||||
isOnline: (KnownHost) -> Boolean,
|
||||
/**
|
||||
* Dial a saved host. The second argument is `connect`'s one-off profile reference: null on a
|
||||
* host's own tile (follow whatever the host is bound to), the pinned profile's id on a pin tile.
|
||||
*/
|
||||
onConnect: (KnownHost, String?) -> Unit,
|
||||
onConnectDiscovered: (DiscoveredHost) -> Unit,
|
||||
onAddHost: () -> Unit,
|
||||
): List<HomeTile> = buildList {
|
||||
savedHosts.forEach { kh ->
|
||||
val bound = kh.profileId?.let { id -> profiles.firstOrNull { it.id == id } }
|
||||
add(
|
||||
HomeTile(
|
||||
id = "saved-${kh.id}",
|
||||
title = kh.name,
|
||||
subtitle = "${kh.address}:${kh.port}",
|
||||
filled = true,
|
||||
online = isOnline(kh),
|
||||
paired = kh.paired,
|
||||
knownHost = kh,
|
||||
// The binding is what a press will actually do, so the tile says so — the console
|
||||
// can't edit profiles, but it must never lie about which one it uses. It rides in
|
||||
// the card's own chip now rather than as a "· Name" tail on the address, which is
|
||||
// where it read as an afterthought.
|
||||
profileName = bound?.name,
|
||||
profileAccent = accentColor(bound?.accent),
|
||||
activate = { onConnect(kh, null) },
|
||||
),
|
||||
)
|
||||
// Pinned host+profile combinations, right after their host: one focus-and-press each,
|
||||
// which is the affordance a controller surface does well (menus are not).
|
||||
pinsFor(kh).forEach { p ->
|
||||
add(
|
||||
HomeTile(
|
||||
id = "pin-${kh.id}-${p.id}",
|
||||
title = kh.name,
|
||||
// The address, like every other card — the PROFILE is what makes this card
|
||||
// different, and it now says so in the chip instead of standing in for the
|
||||
// subtitle, which left a pin card unable to say where it pointed.
|
||||
subtitle = "${kh.address}:${kh.port}",
|
||||
filled = true,
|
||||
online = isOnline(kh),
|
||||
paired = kh.paired,
|
||||
knownHost = kh,
|
||||
pinnedProfileId = p.id,
|
||||
profileName = p.name,
|
||||
profileAccent = accentColor(p.accent),
|
||||
activate = { onConnect(kh, p.id) },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
discoveredUnsaved.forEach { dh ->
|
||||
add(
|
||||
HomeTile(
|
||||
id = "disc-${dh.host}:${dh.port}",
|
||||
title = dh.name,
|
||||
subtitle = "${dh.host}:${dh.port}",
|
||||
online = true,
|
||||
activate = { onConnectDiscovered(dh) },
|
||||
),
|
||||
)
|
||||
}
|
||||
add(
|
||||
HomeTile(
|
||||
id = "add",
|
||||
title = "Add Host",
|
||||
subtitle = "Register a host by address",
|
||||
isAdd = true,
|
||||
activate = onAddHost,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -15,13 +15,12 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PageSize
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -42,6 +41,11 @@ import androidx.compose.ui.layout.ContentScale
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.liveRegion
|
||||
import androidx.compose.ui.semantics.LiveRegionMode
|
||||
import androidx.compose.ui.semantics.selected
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.zIndex
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
@@ -54,6 +58,7 @@ import androidx.compose.ui.unit.sp
|
||||
import coil.ImageLoader
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import io.unom.punktfunk.components.launcherIcon
|
||||
import io.unom.punktfunk.kit.library.DEFAULT_MGMT_PORT
|
||||
import io.unom.punktfunk.kit.library.GameEntry
|
||||
import io.unom.punktfunk.kit.library.LibraryClient
|
||||
@@ -127,7 +132,7 @@ fun LibraryScreen(
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||
GamepadAuroraBackground(Modifier.fillMaxSize())
|
||||
Column(Modifier.fillMaxSize().systemBarsPadding()) {
|
||||
Column(Modifier.fillMaxSize().consoleSafeArea()) {
|
||||
ConsoleHeader("${host.name} — Library")
|
||||
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
when (val s = state) {
|
||||
@@ -171,7 +176,7 @@ fun LibraryScreen(
|
||||
// Launching overlay — the connect + host-side game boot takes a moment; block the pad while it runs.
|
||||
if (launching) {
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.6f)),
|
||||
Modifier.fillMaxSize().background(ink.modalScrim),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
@@ -187,7 +192,7 @@ fun LibraryScreen(
|
||||
// screen (ignore the safe area in landscape, where the bottom edge isn't a tap target).
|
||||
Box(
|
||||
Modifier.align(Alignment.BottomStart)
|
||||
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||
.consoleLegendInsets(landscape)
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(
|
||||
@@ -262,10 +267,18 @@ private fun Coverflow(
|
||||
Text(
|
||||
if (current?.isLauncher == true) "LAUNCHERS" else "GAMES",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White.copy(alpha = 0.45f),
|
||||
// The palette's ink, not white: on a pale field this heading was white on
|
||||
// near-white and simply wasn't there.
|
||||
color = ink.fg(0.45f),
|
||||
letterSpacing = 2.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
// A live region: this heading is the ONLY signal that the cursor has
|
||||
// crossed from the launchers into the games, and a coverflow gives a
|
||||
// reader no other way to notice — it is one strip, not two lists.
|
||||
.semantics { liveRegion = LiveRegionMode.Polite }
|
||||
.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
HorizontalPager(
|
||||
@@ -287,10 +300,22 @@ private fun Coverflow(
|
||||
.width(coverWidth)
|
||||
.height(coverHeight)
|
||||
// Touch: tap the centred cover to launch it; tap a neighbour to bring it centre.
|
||||
.clickable {
|
||||
// The label says which of the two a press does, because from the poster
|
||||
// alone they are indistinguishable — and the CENTRED one is the only one A
|
||||
// acts on, which nothing else in the tree says.
|
||||
.clickable(
|
||||
onClickLabel = if (page == pagerState.currentPage) {
|
||||
"Launch ${games[page].title}"
|
||||
} else {
|
||||
"Bring ${games[page].title} to the centre"
|
||||
},
|
||||
) {
|
||||
if (page == pagerState.currentPage) onLaunch(games[page])
|
||||
else scope.launch { pagerState.animateScrollToPage(page) }
|
||||
}
|
||||
.semantics {
|
||||
if (page == pagerState.currentPage) selected = true
|
||||
}
|
||||
.graphicsLayer {
|
||||
// Centre at full size; EVERY neighbour settles to one size, so an even pitch
|
||||
// yields even VISUAL gaps. (A progressive shrink made the outer gaps grow —
|
||||
@@ -349,11 +374,14 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo
|
||||
val ink = LocalGamepadInk.current
|
||||
val candidates = game.art.posterCandidates
|
||||
var idx by remember(game.id) { mutableStateOf(0) }
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
val shape = ConsoleShape.Poster
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(shape)
|
||||
.background(Color(0xFF241F3D))
|
||||
// The ground a cover sits on while its art loads (and the permanent one for a launcher
|
||||
// entry, which rarely has art). Palette-derived rather than a fixed indigo, so a poster
|
||||
// wall on a pale field isn't a grid of dark holes.
|
||||
.background(LocalGamepadPalette.current.groundColor)
|
||||
.border(1.dp, ink.fg(0.12f), shape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
@@ -367,27 +395,53 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo
|
||||
onError = { idx++ }, // this candidate failed — try the next, or fall to the placeholder
|
||||
)
|
||||
} else {
|
||||
// A launcher rarely has poster art. Naming the launcher says "opens Steam"; the title
|
||||
// would read as "a game whose cover failed to load".
|
||||
Text(
|
||||
if (game.isLauncher) game.storeLabel else game.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = ink.fg(0.75f),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
// A launcher ships no poster by design, so its brand mark IS the poster — drawn big and
|
||||
// centred, tinted like the text it replaces. Falling back to the launcher's name says
|
||||
// "opens Steam" for a mark we don't ship; the title would read as "a game whose cover
|
||||
// failed to load".
|
||||
val mark = launcherIcon(game.iconToken)
|
||||
if (mark != null) {
|
||||
Icon(
|
||||
imageVector = mark,
|
||||
contentDescription = game.title,
|
||||
tint = ink.fg(0.75f),
|
||||
modifier = Modifier.fillMaxSize(0.45f),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
if (game.isLauncher) game.storeLabel else game.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = ink.fg(0.75f),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Store badge, top-start — brand-filled for a launcher entry (design D4).
|
||||
Box(Modifier.fillMaxSize().padding(8.dp), contentAlignment = Alignment.TopStart) {
|
||||
Text(
|
||||
game.storeLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = ink.fg,
|
||||
// A launcher's badge is brand-filled, so it reads on the ACCENT; a game's sits on
|
||||
// a plain dark wash over its own art.
|
||||
color = if (game.isLauncher) ink.onAccent else Color.White,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
// A bare store name read out after the title says nothing about WHY it is
|
||||
// there; the poster's own description already carries the title.
|
||||
.semantics {
|
||||
contentDescription = if (game.isLauncher) {
|
||||
"Opens ${game.storeLabel}"
|
||||
} else {
|
||||
"From ${game.storeLabel}"
|
||||
}
|
||||
}
|
||||
.clip(ConsoleShape.Pill)
|
||||
.background(
|
||||
if (game.isLauncher) MaterialTheme.colorScheme.primary
|
||||
// The console's palette accent, not `MaterialTheme.colorScheme.primary` —
|
||||
// that is the TOUCH theme's colour (Material You, seeded from the user's
|
||||
// wallpaper), which had nothing to do with the field this poster sits on.
|
||||
if (game.isLauncher) ink.accent
|
||||
else Color.Black.copy(alpha = 0.5f),
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -19,20 +23,130 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
|
||||
/**
|
||||
* Open-source licenses: punktfunk's own license (MIT OR Apache-2.0) plus the third-party software
|
||||
* notices, read from the bundled `THIRD-PARTY-NOTICES.txt` asset (generated by
|
||||
* scripts/gen-third-party-notices.sh). Reached from [SettingsScreen]; Back returns there.
|
||||
*
|
||||
* This is the TOUCH entry point; [ConsoleLicensesScreen] shows the same notices on the console's
|
||||
* field, where they need a scroll route a controller can actually drive.
|
||||
*/
|
||||
@Composable
|
||||
fun LicensesScreen(onBack: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
BackHandler(onBack = onBack)
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
// Pinned header with a visible Back affordance (Back-button/gesture still work via BackHandler).
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 12.dp, top = 8.dp, bottom = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
Text("Open-source licenses", style = MaterialTheme.typography.headlineSmall)
|
||||
}
|
||||
LicensesBody(
|
||||
scroll = rememberScrollState(),
|
||||
contentPadding = PaddingValues(start = 20.dp, end = 20.dp, bottom = 24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The notices on the console's field. The reason this exists as its own screen rather than the touch
|
||||
* one dropped into the shell is the SCROLL: the body is a wall of text with exactly one focusable
|
||||
* node (the touch screen's back arrow), and Compose scrolls a container only to keep a FOCUSED child
|
||||
* visible — so a controller could reach the first screenful of `THIRD-PARTY-NOTICES.txt` and not one
|
||||
* line further. Here up/down steps and the shoulders page, driving the scroll state directly.
|
||||
*
|
||||
* B closes, as everywhere else; there is nothing on this screen to confirm, so A is not advertised.
|
||||
*/
|
||||
@Composable
|
||||
fun ConsoleLicensesScreen(onBack: () -> Unit, navActive: Boolean = true) {
|
||||
BackHandler(onBack = onBack)
|
||||
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
val hazeState = remember { HazeState() }
|
||||
val scroll = rememberScrollState()
|
||||
val scrollBy = rememberConsoleScroller(scroll)
|
||||
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
|
||||
|
||||
GamepadNavEffect2D(
|
||||
active = navActive,
|
||||
onDirection = { dir ->
|
||||
when (dir) {
|
||||
NavDir.UP -> scrollBy(-1, false)
|
||||
NavDir.DOWN -> scrollBy(1, false)
|
||||
// Left/right are deliberately inert: there is nothing beside this text, and paging
|
||||
// sideways off a D-pad would be a second, undocumented way to do the shoulders' job.
|
||||
NavDir.LEFT, NavDir.RIGHT -> {}
|
||||
}
|
||||
},
|
||||
onActivate = {},
|
||||
onShoulder = { delta -> scrollBy(delta, true) },
|
||||
)
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||
// Calm: this is a screen to read, and a drifting field behind small monospace text is
|
||||
// the one place the aurora would be actively unhelpful. Full-bleed under the cutout —
|
||||
// only the content takes the safe area.
|
||||
GamepadFormBackground(Modifier.fillMaxSize())
|
||||
// Inked from the palette: the notices carry no colour of their own, so outside a Surface
|
||||
// they would render in Material's default BLACK content colour over the aurora.
|
||||
ConsoleInkedTheme {
|
||||
Column(Modifier.fillMaxSize().consoleSafeArea()) {
|
||||
LicensesBody(
|
||||
scroll = scroll,
|
||||
contentPadding = PaddingValues(
|
||||
start = ConsoleEdgeInset,
|
||||
end = ConsoleEdgeInset,
|
||||
bottom = ConsoleLegendClearance,
|
||||
),
|
||||
) {
|
||||
ConsoleHeader("Open-source licenses", horizontalInset = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.consoleLegendInsets(landscape)
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(
|
||||
listOfNotNull(
|
||||
GamepadHint('↕', PadGlyph.Arrow, "Scroll"),
|
||||
// A TV remote has no shoulders — its route is the D-pad, one step at a time.
|
||||
GamepadHint('⇄', PadGlyph.Arrow, "Page").takeIf { padIsGamepad },
|
||||
PadGlyph.hint('B', "Close", onClick = onBack),
|
||||
),
|
||||
hazeState = hazeState,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The notices themselves, shared by both interfaces — the licenses are a legal obligation, so the
|
||||
* two routes must show the same text rather than two copies that can drift. [heading] is empty for
|
||||
* the touch screen, which pins its own title row above the scroll.
|
||||
*/
|
||||
@Composable
|
||||
private fun LicensesBody(
|
||||
scroll: ScrollState,
|
||||
contentPadding: PaddingValues,
|
||||
heading: @Composable () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val notices = remember {
|
||||
runCatching {
|
||||
context.assets.open("THIRD-PARTY-NOTICES.txt").bufferedReader().use { it.readText() }
|
||||
@@ -52,52 +166,40 @@ fun LicensesScreen(onBack: () -> Unit) {
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
// Pinned header with a visible Back affordance (Back-button/gesture still work via BackHandler).
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 12.dp, top = 8.dp, bottom = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
Text("Open-source licenses", style = MaterialTheme.typography.headlineSmall)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp)
|
||||
.padding(bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
if (version != null) {
|
||||
Text(
|
||||
"Punktfunk $version",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scroll)
|
||||
.padding(contentPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
heading()
|
||||
if (version != null) {
|
||||
Text(
|
||||
"Punktfunk is licensed under MIT OR Apache-2.0, at your option. It uses the open-source " +
|
||||
"components below, each under its own license.",
|
||||
"Punktfunk $version",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"Punktfunk is licensed under MIT OR Apache-2.0, at your option. It uses the open-source " +
|
||||
"components below, each under its own license.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
notices,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
)
|
||||
if (fontLicense != null) {
|
||||
Text("Bundled font", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"The Geist typeface is licensed under the SIL Open Font License 1.1.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
notices,
|
||||
fontLicense,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
)
|
||||
if (fontLicense != null) {
|
||||
Text("Bundled font", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"The Geist typeface is licensed under the SIL Open Font License 1.1.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
fontLicense,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -142,6 +143,16 @@ class MainActivity : ComponentActivity() {
|
||||
var lastPadStyle by mutableStateOf(Gamepad.PadStyle.GENERIC)
|
||||
private set
|
||||
|
||||
/**
|
||||
* The `InputDevice.id` of the controller driving the console UI, or 0 for none. Kept beside
|
||||
* [lastPadStyle] because the console's menu haptics render on the DRIVING pad's own motors when
|
||||
* it has any — a rumble that comes out of the device you are not holding is worse than none.
|
||||
* Falls back to the phone body (see `rememberConsoleHaptics`), and to silence on a TV, where
|
||||
* neither a remote nor the box has an actuator.
|
||||
*/
|
||||
var lastPadDeviceId by mutableIntStateOf(0)
|
||||
private set
|
||||
|
||||
/**
|
||||
* A `punktfunk://` URL waiting to be routed — set from the VIEW intent that started (or
|
||||
* re-entered) this activity, cleared by whoever handles it. Compose observes it.
|
||||
@@ -606,7 +617,10 @@ class MainActivity : ComponentActivity() {
|
||||
// pad, WHICH pad family, so the glyphs wear its lettering/shapes.
|
||||
if (event.action == KeyEvent.ACTION_DOWN && isConsoleNavKey(event.keyCode)) {
|
||||
lastPadIsGamepad = event.isFromSource(InputDevice.SOURCE_GAMEPAD)
|
||||
if (lastPadIsGamepad) lastPadStyle = Gamepad.styleFor(event.device)
|
||||
if (lastPadIsGamepad) {
|
||||
lastPadStyle = Gamepad.styleFor(event.device)
|
||||
lastPadDeviceId = event.deviceId
|
||||
}
|
||||
}
|
||||
// The Controllers debug screen sees pad events before the navigation remap below.
|
||||
padKeyProbe?.let { if (it(event)) return true }
|
||||
@@ -695,6 +709,7 @@ class MainActivity : ComponentActivity() {
|
||||
if (dir != 0) {
|
||||
lastPadIsGamepad = true // a stick/HAT push can only come from a real gamepad
|
||||
lastPadStyle = Gamepad.styleFor(event.device)
|
||||
lastPadDeviceId = event.deviceId
|
||||
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, dir))
|
||||
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, dir))
|
||||
return true
|
||||
|
||||
@@ -577,7 +577,7 @@ private fun GeneralSettings(s: Settings, update: (Settings) -> Unit) {
|
||||
selected = s.statsVerbosity,
|
||||
field = "stats_verbosity",
|
||||
caption = "Compact is one line; Detailed adds the decoder and latency breakdown. " +
|
||||
"A 3-finger tap cycles the tiers in-stream.",
|
||||
"A 3-finger tap, or Select + X on a pad, cycles the tiers in-stream.",
|
||||
) { v -> update(s.copy(statsVerbosity = v)) }
|
||||
}
|
||||
DeviceScopeOnly {
|
||||
|
||||
@@ -28,6 +28,9 @@ import android.view.inputmethod.InputConnection
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -53,6 +56,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
@@ -152,6 +156,35 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
motionHint = false
|
||||
}
|
||||
}
|
||||
// Whether this session has a controller — the start banner names pad chords only when there is
|
||||
// a pad to press them on. Seeded from the router the moment it is built (it opens a slot for
|
||||
// every already-connected controller) and latched true by a pad that arrives later; it never
|
||||
// goes back to false. A pad LEAVING inside the banner's six seconds is not worth the write:
|
||||
// teardown closes every slot, and poking Compose state from there is exactly what the nulled
|
||||
// callbacks in onDispose avoid. The latch is also what carries a pad through a USB capture
|
||||
// claiming it — its InputDevice slot closes and reopens as a capture-link one.
|
||||
var padPresent by remember(handle) { mutableStateOf(false) }
|
||||
// The start-of-stream banner: what this session's shortcuts ARE, said once. A stream takes the
|
||||
// whole screen and answers to none of the device's usual gestures, so it has to say how to get
|
||||
// back out — the desktop console draws the same pill for the same reason
|
||||
// (`pf-console-ui/src/skia_overlay.rs`, BANNER_S = 6 s with a BANNER_FADE_S = 0.6 s tail).
|
||||
// Two states because the fade and the removal are different moments: `bannerUp` composes the
|
||||
// pill at all, `bannerFading` runs its alpha down over the last 600 ms.
|
||||
var bannerUp by remember(handle) { mutableStateOf(true) }
|
||||
var bannerFading by remember(handle) { mutableStateOf(false) }
|
||||
val bannerAlpha by animateFloatAsState(
|
||||
targetValue = if (bannerFading) 0f else 1f,
|
||||
// Linear, like the desktop's (BANNER_S - age) / BANNER_FADE_S ramp — Compose's default
|
||||
// easing would hold near-opaque and then drop, which reads as a glitch rather than a fade.
|
||||
animationSpec = tween(600, easing = LinearEasing),
|
||||
label = "streamStartBanner",
|
||||
)
|
||||
LaunchedEffect(handle) {
|
||||
delay(5400) // 6 s − the 0.6 s tail: fully opaque until here, exactly as on the desktop
|
||||
bannerFading = true
|
||||
delay(600)
|
||||
bannerUp = false // stop composing it once it is invisible
|
||||
}
|
||||
// The one place mute is toggled — Compose state + the native flag, always together.
|
||||
val setMicMuted = { muted: Boolean ->
|
||||
micMuted = muted
|
||||
@@ -161,7 +194,8 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
// Live decode stats for the HUD. `statsOn` (verbosity != OFF) gates the whole native pipeline:
|
||||
// the per-frame sampling (nativeSetVideoStatsEnabled — a hidden HUD costs one atomic load per
|
||||
// frame) AND the 1 s poll loop, which only runs while the overlay is visible. Enabling resets
|
||||
// the native window, so re-showing never renders stale data. A 3-finger tap cycles the
|
||||
// the native window, so re-showing never renders stale data. A 3-finger tap — or the Select + X
|
||||
// pad chord, which is the only route a TV or a passthrough-touch session has — cycles the
|
||||
// verbosity tier live (Off → Compact → Normal → Detailed → Off); the default comes from
|
||||
// Settings. The tier only changes how many lines `StatsOverlay` draws — switching between the
|
||||
// visible tiers keeps sampling running (the effect keys on `statsOn`, not the tier) so it never
|
||||
@@ -184,6 +218,11 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
// TV form factor (leanback): the decoder actively switches the HDMI output mode to the stream
|
||||
// refresh; a phone/tablet gets the softer seamless frame-rate hint instead.
|
||||
val isTv = remember { context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) }
|
||||
// A screen with fingers on it — the start banner may only name the three-finger stats tap on a
|
||||
// device that can perform it. A TV box has no touchscreen at all, and its remote is not one.
|
||||
val hasTouch = remember {
|
||||
context.packageManager.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN)
|
||||
}
|
||||
LaunchedEffect(handle, statsOn) {
|
||||
NativeBridge.nativeSetVideoStatsEnabled(handle, statsOn)
|
||||
if (statsOn) {
|
||||
@@ -362,6 +401,9 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
initialSettings.systemButtonsForward(), initialSettings.guideGestureEnabled(),
|
||||
)
|
||||
activity?.gamepadRouter = router
|
||||
// Every controller that was already connected got a slot in the router's constructor, so
|
||||
// this is the session's pad answer at t=0 — what the start banner's words are chosen from.
|
||||
padPresent = router.forwardedDevices().isNotEmpty()
|
||||
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips
|
||||
// the keep-alive linger), unlike a host-ended / backgrounded drop. The router debounces it
|
||||
// (must be held ~1.5 s) and fires onExitChord on its main-thread timer, so leave the stream
|
||||
@@ -384,6 +426,11 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
micHint = if (next) "Microphone muted" else "Microphone live"
|
||||
}
|
||||
}
|
||||
// Select + X steps the stats overlay one tier — the same live cycle the three-finger tap
|
||||
// performs, and the ONLY route to it on a TV or in a passthrough-touch session. Session-
|
||||
// local on purpose: this mirrors the tap exactly (`onCycleStats` below), and the settings
|
||||
// row calls it a live cycle — the stored default is what the next stream starts from.
|
||||
router.onStatsChord = { statsVerbosity = statsVerbosity.next() }
|
||||
// Physical mouse: uncaptured hover/click/wheel forwards as absolute pointing; captured
|
||||
// (setting or the Ctrl+Alt+Shift+Q chord) raw deltas forward as relative mouse-look.
|
||||
// The local cursor is hidden over the stream — the host's own cursor, composited into
|
||||
@@ -505,7 +552,12 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
// The other edge: a controller that arrives (or first speaks) mid-session gets its sensors
|
||||
// read too. The pads already connected were swept by PadSensors.start() above — both run
|
||||
// on the main thread with nothing between them, so no controller falls through the gap.
|
||||
router.onSlotOpened = { deviceId -> padSensors?.onSlotOpened(deviceId) }
|
||||
router.onSlotOpened = { deviceId ->
|
||||
padSensors?.onSlotOpened(deviceId)
|
||||
// A pad that wakes up a second into the stream still deserves the chord banner — the
|
||||
// desktop rebuilds its banner text every frame for exactly this case.
|
||||
padPresent = true
|
||||
}
|
||||
// Steam Controller 2 as-is passthrough (opt-out): capture a wired/Puck USB pad — or an
|
||||
// already-paired BLE one — and forward its raw reports; the host mirrors a real
|
||||
// 28DE:1302 that its Steam drives directly, and Steam's rumble/settings writes come back
|
||||
@@ -645,6 +697,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
ds?.stop() // rumble-stop on the physical pad + release the USB link + free the wire slot
|
||||
router.onExitArmed = null // don't poke Compose state from release()'s disarm while tearing down
|
||||
router.onMicChord = null // same: no mute toggle on buttons released during teardown
|
||||
router.onStatsChord = null // same: no tier cycle on buttons released during teardown
|
||||
router.onMotionUnreachable = null // same: no notice raised by a slot closing at teardown
|
||||
router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener
|
||||
activity?.gamepadRouter = null
|
||||
@@ -847,6 +900,42 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
if (remotePointerOn) {
|
||||
RemotePointerHint(Modifier.align(Alignment.TopCenter).padding(top = 16.dp))
|
||||
}
|
||||
// The start banner (desktop parity), naming ONLY the shortcuts this session actually has:
|
||||
// pad chords when a controller is here, the Back gesture and the three-finger tap when it
|
||||
// is not. Recomputed rather than captured, because both inputs change under it — a pad can
|
||||
// wake mid-banner, and `micRunning` only settles once the capture has actually opened.
|
||||
// Above the video and below the gesture layer: it teaches touches, it must never eat one.
|
||||
//
|
||||
// Bottom-centre is the desktop's placement and the only edge left — TopStart is the HUD,
|
||||
// TopEnd the mic badge, TopCentre the three transient cues — but MotionUnreachableHint
|
||||
// already owns it, and both of these can be up at t≈0. The banner YIELDS rather than
|
||||
// stacking or sliding off-centre: the notice reports something broken about THIS session
|
||||
// and names the setting that fixes it, while the banner repeats shortcuts that will be
|
||||
// there next stream too. Two pills sharing an edge for six seconds would cost the reader
|
||||
// both.
|
||||
if (bannerUp && !motionHint) {
|
||||
StreamStartBanner(
|
||||
text = buildList {
|
||||
if (padPresent) {
|
||||
add("Hold Select + Start + L1 + R1 to leave")
|
||||
// Only while a capture is actually running: the chord itself no-ops
|
||||
// without one, and offering a mute for a mic nobody has is the lie the
|
||||
// whole control exists to avoid.
|
||||
if (micRunning) add("Select + Y mic")
|
||||
add("Select + X stats")
|
||||
} else {
|
||||
// No pad: Back is the deliberate exit (gesture, key, or a TV remote's
|
||||
// button — all land on the same BackHandler).
|
||||
add("Back leaves the stream")
|
||||
// The tap lives in the pointer touch models only — passthrough gives every
|
||||
// finger to the host verbatim — and needs a screen to put three fingers on.
|
||||
if (hasTouch && touchMode != TouchMode.TOUCH) add("three-finger tap for stats")
|
||||
}
|
||||
}.joinToString(" · "),
|
||||
alpha = bannerAlpha,
|
||||
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 24.dp),
|
||||
)
|
||||
}
|
||||
// Invisible 1-px focus anchor for the host-typing soft keyboard (three-finger swipe up
|
||||
// in the mouse modes) AND the pointer-capture grab target — it never draws or takes
|
||||
// touches, it just owns IME focus and receives captured-pointer events.
|
||||
@@ -1053,6 +1142,33 @@ private fun RemotePointerHint(modifier: Modifier = Modifier) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The start-of-stream banner: the shortcuts this session actually has, in the same pill as every
|
||||
* other in-stream cue, shown once and then gone. The desktop console draws the identical thing
|
||||
* bottom-centre (`pf-console-ui/src/skia_overlay.rs` — six seconds with a 0.6 s fade), because a
|
||||
* stream owns the whole screen and answers to none of the device's usual gestures: without a line
|
||||
* saying how to get back out, the only discoverable exit is force-quitting the app.
|
||||
*
|
||||
* [text] and [alpha] are the caller's. Only it knows what this session HAS — a pad, a mic, a
|
||||
* touchscreen — and only it owns the timer, which is precisely what a screenshot wants to skip.
|
||||
* Purely visual: it sits below the gesture layer, takes no touches and is never clickable. Internal
|
||||
* so the screenshot scene can shoot the real pill instead of a copy of it that drifts.
|
||||
*/
|
||||
@Composable
|
||||
internal fun StreamStartBanner(text: String, alpha: Float, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text,
|
||||
// Alpha FIRST: the fade has to take the pill's backdrop with it, and everything after this
|
||||
// in the chain draws inside the layer it opens.
|
||||
modifier = modifier
|
||||
.alpha(alpha)
|
||||
.background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
color = Color.White,
|
||||
fontSize = 15.sp,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Invisible focus anchor for typing on the host: the three-finger swipe summons the device IME
|
||||
* onto this view. Two IME models, picked by the host's capabilities:
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package io.unom.punktfunk.components
|
||||
|
||||
// GENERATED by scripts/gen_launcher_icon_tables.py from the assets/launcher-icons masters.
|
||||
// Do not edit by hand — re-run `bash scripts/gen-launcher-icons.sh` instead.
|
||||
// Per-mark provenance and licensing: assets/launcher-icons/README.md.
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.PathParser
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* The brand mark a `role: "launcher"` tile draws, resolved from the entry's `icon` token.
|
||||
* Material ships no brand icons, so this is a curated registry — the sibling of [OsIcons],
|
||||
* which does the equivalent job for the host cards.
|
||||
*
|
||||
* Held as raw SVG path strings rather than transcribed ImageVector DSL: [PathParser] builds
|
||||
* the vector once and [launcherIcon] caches it. Viewports are the masters' own and are NOT
|
||||
* all square, so the builder letterboxes — a mark forced into a square box is a squashed mark.
|
||||
*/
|
||||
private class LauncherGlyph(
|
||||
val viewportWidth: Float,
|
||||
val viewportHeight: Float,
|
||||
val d: String,
|
||||
)
|
||||
|
||||
private val GLYPHS: Map<String, LauncherGlyph> = mapOf(
|
||||
"steam" to LauncherGlyph(
|
||||
viewportWidth = 496f,
|
||||
viewportHeight = 512f,
|
||||
d = "M496 256c0 137-111.2 248-248.4 248-113.8 0-209.6-76.3-239-180.4l95.2 39.3c6.4 32.1 34.9 56.4 68.9 56.4 39.2 0 71.9-32.4 70.2-73.5l84.5-60.2c52.1 1.3 95.8-40.9 95.8-93.5 0-51.6-42-93.5-93.7-93.5s-93.7 42-93.7 93.5v1.2L176.6 279c-15.5-.9-30.7 3.4-43.5 12.1L0 236.1C10.2 108.4 117.1 8 247.6 8 384.8 8 496 119 496 256zM155.7 384.3l-30.5-12.6a52.79 52.79 0 0 0 27.2 25.8c26.9 11.2 57.8-1.6 69-28.4 5.4-13 5.5-27.3.1-40.3-5.4-13-15.5-23.2-28.5-28.6-12.9-5.4-26.7-5.2-38.9-.6l31.5 13c19.8 8.2 29.2 30.9 20.9 50.7-8.3 19.9-31 29.2-50.8 21zm173.8-129.9c-34.4 0-62.4-28-62.4-62.3s28-62.3 62.4-62.3 62.4 28 62.4 62.3-27.9 62.3-62.4 62.3zm.1-15.6c25.9 0 46.9-21 46.9-46.8 0-25.9-21-46.8-46.9-46.8s-46.9 21-46.9 46.8c.1 25.8 21.1 46.8 46.9 46.8z",
|
||||
),
|
||||
"lutris" to LauncherGlyph(
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
d = "m21.231 18.89.001-.002c-1.293 3.243-5.218 5.232-9.447 5.105C5.3 23.993 0 18.48 0 11.906S5.276.001 11.785.001c1.793 0 3.493.406 5.015 1.13.081-.177.271-.544.451-.557.238-.017.374.137.526.309.154.172.46.429.46.429s1.393-.481 2.955.377c1.563.858 1.783 1.116 2.09 1.716.152.301.195.829.2 1.282a.796.796 0 0 0-.07-.003c-.496 0-.96.455-.96 1.08 0 .263.082.496.215.678l-.01.007a1.505 1.505 0 0 0-.132.01 18.704 18.704 0 0 0-.389-.142 2.53 2.53 0 0 1-.82-.472 1.402 1.402 0 0 0-1.196-2.112c-.383 0-.73.156-.982.41-.472-.271-1.174-.482-2.527-.565l-.407-.011c-2.282.012-3.611.279-5.979 1.301-.603.283-1.206.615-1.785 1.001-.423.3-.639.67-.709 1.137a1.326 1.326 0 0 0 1.23 1.373h.042c1.27.06 2.039 1.99 2.063 2.497.004.05.004.023.003.08-.032.727-.37 1.267-1.088 1.246a1.231 1.231 0 0 1-.976-.494c-.063-.077-.103-.172-.159-.254-.666-1.081-1.732-1.36-2.771-1.523-.438-.068-1.073-.122-1.31.25a8.28 8.28 0 0 0-.577 3.063c-.02 5.036 4.041 9.118 9.026 9.118 2.575 0 5.349-.952 6.993-2.7l-.035.03c-1.772 1.473-4.66 1.941-6.027 1.941-4.302 0-7.818-3.232-7.818-7.578 0-1.276.288-2.396.814-3.36.495.183.947.483 1.28 1.022a.24.24 0 0 0 .013.021c.064.092.111.197.182.284.424.524.881.658 1.342.68h.01c.43.013.768-.12 1.024-.342.347-.3.55-.79.577-1.382v-.014c.002-.085 0-.053-.004-.112-.024-.376-.333-1.318-.906-2.027-.266-.331-.587-.607-.95-.774l.12-.074c.756-.457 2.364-.977 4.592-.638 1.13.173 2.055.419 3.483.879 1.657.534 2.579 1.279 3.854 1.427.15.017.301.018.45.003.41 1.129.634 2.35.634 3.621 0 2.068-.59 3.995-1.611 5.62zm1.947-12.274s-.115.201-.364.322c-.103.05-.282-.075-.45.1-.359.726.516 1.332.923 1.315.408-.017.73-.432.712-.793-.017-.558-.82-.944-.82-.944zm.234-1.432c.255 0 .462.26.462.58 0 .32-.207.58-.462.58-.254 0-.46-.26-.46-.58 0-.32.206-.58.46-.58zm-3.292-.951c.492 0 .89.403.89.9a.895.895 0 0 1-.89.898.895.895 0 0 1-.89-.899c0-.496.399-.899.89-.899z",
|
||||
),
|
||||
"heroic" to LauncherGlyph(
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
d = "M11.999 0 11.997 0a.891.891 0 0 0-.36.075C8.964 1.253 6.29 2.434 3.618 3.613A.893.893 0 0 0 3.1 4.619l3.146 14.646c.043.197.15.375.307.504l4.88 4.027a.895.895 0 0 0 1.131.006l5-4.031a.895.895 0 0 0 .315-.516L20.9 4.614a.895.895 0 0 0-.515-1L12.358.074A.892.892 0 0 0 12 0zm0 .35v.003c.114 0 .228.023.334.07l7.42 3.27a.827.827 0 0 1 .476.924l-2.793 13.535a.83.83 0 0 1-.289.478l-4.623 3.725a.826.826 0 0 1-1.045-.006l-4.513-3.723a.829.829 0 0 1-.281-.465L3.775 4.622a.83.83 0 0 1 .476-.931L11.665.42a.832.832 0 0 1 .334-.07zm-.045 1.954L10.28 5.202h-.002l1.211 11.301.512.409.512-.409 1.117-11.3zM9.003 16.261l-.584 1.068.584 1.07 2.295-.38.47-.69-.47-.671zm5.996 0-2.295.397-.47.671.47.69 2.295.38.584-1.07zm-2.998 1.488-.51.444-.281 2.168.789.55.793-.55-.295-2.168z",
|
||||
),
|
||||
"playnite" to LauncherGlyph(
|
||||
viewportWidth = 1024f,
|
||||
viewportHeight = 1024f,
|
||||
d = "M966.686,623.899c-9.773-81.666-29.323-161.25-54.514-239.447c-13.759-42.709-30.419-84.189-56.091-121.452 c-31.701-46.014-74.789-72.958-130.812-78.579c-29.631-2.973-57.785,4.118-85.677,12.35 c-61.172,18.056-123.359,25.124-186.493,14.903c-30.919-5.006-61.308-13.526-91.743-21.225 c-76.445-19.338-145.323,4.995-191.165,69.261c-11.441,16.04-21.194,33.543-29.78,51.312 c-25.091,51.925-40.443,107.249-54.53,162.924c-18.822,74.393-33.019,149.491-33.664,226.571c0,7.184-0.342,14.386,0.061,21.547 c1.557,27.727,4.354,55.289,16.045,80.97c15.334,33.68,45.905,46.725,79.471,31.198c18.291-8.461,36.293-19.857,50.766-33.743 c24.597-23.598,46.616-49.934,69.125-75.64c17.934-20.481,39.086-35.301,66.115-40.203c15.779-2.862,31.802-6.006,47.736-6.118 c87.888-0.62,175.783-0.602,263.673-0.278c51.4,0.189,93.314,19.382,124.091,62.134c12.518,17.388,27.83,32.889,42.78,48.371 c18.598,19.259,38.974,36.431,64.412,46.39c32.967,12.907,62.547,1.677,77.882-30.198c3.965-8.242,6.963-17.122,9.155-26.017 C976.198,727.534,972.874,675.607,966.686,623.899z M315.471,527.643c-44.289,0.213-80.733-36.32-80.847-81.045 c-0.115-45.048,35.472-81.194,80.197-81.458c44.521-0.263,80.718,35.897,80.884,80.801 C395.871,490.671,359.773,527.429,315.471,527.643z M708.857,319.301c21.859,0.06,39.486,17.884,39.471,39.91 c-0.015,22.133-17.489,39.677-39.523,39.682c-22.045,0.005-39.456-17.53-39.444-39.724 C669.372,337.125,687.089,319.241,708.857,319.301z M622.269,486.36c-21.542,0.085-39.7-18.08-39.808-39.822 c-0.108-21.888,17.617-39.622,39.62-39.641c22.066-0.018,39.759,17.552,39.718,39.442 C661.758,468.205,643.909,486.275,622.269,486.36z M708.967,573.333c-21.823,0.096-39.537-17.668-39.611-39.721 c-0.074-22.079,17.523-39.992,39.338-40.044c21.715-0.052,39.597,17.908,39.645,39.816 C748.386,555.477,730.883,573.237,708.967,573.333z M795.752,486.362c-21.764,0.155-39.671-17.882-39.651-39.938 c0.021-22.15,17.628-39.639,39.793-39.525c22.091,0.114,39.527,17.993,39.155,40.152 C834.686,468.733,817.216,486.209,795.752,486.362z",
|
||||
),
|
||||
"epic" to LauncherGlyph(
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
d = "M3.537 0C2.165 0 1.66.506 1.66 1.879V18.44a4.262 4.262 0 00.02.433c.031.3.037.59.316.92.027.033.311.245.311.245.153.075.258.13.43.2l8.335 3.491c.433.199.614.276.928.27h.002c.314.006.495-.071.928-.27l8.335-3.492c.172-.07.277-.124.43-.2 0 0 .284-.211.311-.243.28-.33.285-.621.316-.92a4.261 4.261 0 00.02-.434V1.879c0-1.373-.506-1.88-1.878-1.88zm13.366 3.11h.68c1.138 0 1.688.553 1.688 1.696v1.88h-1.374v-1.8c0-.369-.17-.54-.523-.54h-.235c-.367 0-.537.17-.537.539v5.81c0 .369.17.54.537.54h.262c.353 0 .523-.171.523-.54V8.619h1.373v2.143c0 1.144-.562 1.71-1.7 1.71h-.694c-1.138 0-1.7-.566-1.7-1.71V4.82c0-1.144.562-1.709 1.7-1.709zm-12.186.08h3.114v1.274H6.117v2.603h1.648v1.275H6.117v2.774h1.74v1.275h-3.14zm3.816 0h2.198c1.138 0 1.7.564 1.7 1.708v2.445c0 1.144-.562 1.71-1.7 1.71h-.799v3.338h-1.4zm4.53 0h1.4v9.201h-1.4zm-3.13 1.235v3.392h.575c.354 0 .523-.171.523-.54V4.965c0-.368-.17-.54-.523-.54zm-3.74 10.147a1.708 1.708 0 01.591.108 1.745 1.745 0 01.49.299l-.452.546a1.247 1.247 0 00-.308-.195.91.91 0 00-.363-.068.658.658 0 00-.28.06.703.703 0 00-.224.163.783.783 0 00-.151.243.799.799 0 00-.056.299v.008a.852.852 0 00.056.31.7.7 0 00.157.245.736.736 0 00.238.16.774.774 0 00.303.058.79.79 0 00.445-.116v-.339h-.548v-.565H7.37v1.255a2.019 2.019 0 01-.524.307 1.789 1.789 0 01-.683.123 1.642 1.642 0 01-.602-.107 1.46 1.46 0 01-.478-.3 1.371 1.371 0 01-.318-.455 1.438 1.438 0 01-.115-.58v-.008a1.426 1.426 0 01.113-.57 1.449 1.449 0 01.312-.46 1.418 1.418 0 01.474-.309 1.58 1.58 0 01.598-.111 1.708 1.708 0 01.045 0zm11.963.008a2.006 2.006 0 01.612.094 1.61 1.61 0 01.507.277l-.386.546a1.562 1.562 0 00-.39-.205 1.178 1.178 0 00-.388-.07.347.347 0 00-.208.052.154.154 0 00-.07.127v.008a.158.158 0 00.022.084.198.198 0 00.076.066.831.831 0 00.147.06c.062.02.14.04.236.061a3.389 3.389 0 01.43.122 1.292 1.292 0 01.328.17.678.678 0 01.207.24.739.739 0 01.071.337v.008a.865.865 0 01-.081.382.82.82 0 01-.229.285 1.032 1.032 0 01-.353.18 1.606 1.606 0 01-.46.061 2.16 2.16 0 01-.71-.116 1.718 1.718 0 01-.593-.346l.43-.514c.277.223.578.335.9.335a.457.457 0 00.236-.05.157.157 0 00.082-.142v-.008a.15.15 0 00-.02-.077.204.204 0 00-.073-.066.753.753 0 00-.143-.062 2.45 2.45 0 00-.233-.062 5.036 5.036 0 01-.413-.113 1.26 1.26 0 01-.331-.16.72.72 0 01-.222-.243.73.73 0 01-.082-.36v-.008a.863.863 0 01.074-.359.794.794 0 01.214-.283 1.007 1.007 0 01.34-.185 1.423 1.423 0 01.448-.066 2.006 2.006 0 01.025 0zm-9.358.025h.742l1.183 2.81h-.825l-.203-.499H8.623l-.198.498h-.81zm2.197.02h.814l.663 1.08.663-1.08h.814v2.79h-.766v-1.602l-.711 1.091h-.016l-.707-1.083v1.593h-.754zm3.469 0h2.235v.658h-1.473v.422h1.334v.61h-1.334v.442h1.493v.658h-2.255zm-5.3.897l-.315.793h.624zm-1.145 5.19h8.014l-4.09 1.348z",
|
||||
),
|
||||
"gog" to LauncherGlyph(
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
d = "M7.15 15.24H4.36a.4.4 0 0 0-.4.4v2c0 .21.18.4.4.4h2.8v1.32h-3.5c-.56 0-1.02-.46-1.02-1.03v-3.39c0-.56.46-1.02 1.03-1.02h3.48v1.32zM8.16 11.54c0 .58-.47 1.05-1.05 1.05H2.63v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4H4.39a.4.4 0 0 0-.41.4v2.02c0 .23.18.4.4.4H6v1.35H3.68c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04H7.1c.58 0 1.05.47 1.05 1.04v5.86zM21.36 19.36h-1.32v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.42c0-.56.46-1.02 1.03-1.02h5.61v5.44zM21.37 11.54c0 .58-.47 1.05-1.05 1.05h-4.48v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4h-2.03a.4.4 0 0 0-.4.4v2.02c0 .23.18.4.4.4h1.62v1.35H16.9c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04h3.43c.58 0 1.05.47 1.05 1.04v5.86zM13.72 4.64h-3.44c-.58 0-1.04.47-1.04 1.04v3.44c0 .58.46 1.04 1.04 1.04h3.44c.57 0 1.04-.46 1.04-1.04V5.68c0-.57-.47-1.04-1.04-1.04m-.3 1.75v2.02a.4.4 0 0 1-.4.4h-2.03a.4.4 0 0 1-.4-.4V6.4c0-.22.17-.4.4-.4H13c.23 0 .4.18.4.4zM12.63 13.92H9.24c-.57 0-1.03.46-1.03 1.02v3.39c0 .57.46 1.03 1.03 1.03h3.39c.57 0 1.03-.46 1.03-1.03v-3.39c0-.56-.46-1.02-1.03-1.02m-.3 1.72v2a.4.4 0 0 1-.4.4v-.01H9.94a.4.4 0 0 1-.4-.4v-1.99c0-.22.18-.4.4-.4h2c.22 0 .4.18.4.4zM23.49 1.1a1.74 1.74 0 0 0-1.24-.52H1.75A1.74 1.74 0 0 0 0 2.33v19.34a1.74 1.74 0 0 0 1.75 1.75h20.5A1.74 1.74 0 0 0 24 21.67V2.33c0-.48-.2-.92-.51-1.24m0 20.58a1.23 1.23 0 0 1-1.24 1.24H1.75A1.23 1.23 0 0 1 .5 21.67V2.33a1.23 1.23 0 0 1 1.24-1.24h20.5a1.24 1.24 0 0 1 1.24 1.24v19.34z",
|
||||
),
|
||||
"xbox" to LauncherGlyph(
|
||||
viewportWidth = 512f,
|
||||
viewportHeight = 512f,
|
||||
d = "M369.9 318.2c44.3 54.3 64.7 98.8 54.4 118.7-7.9 15.1-56.7 44.6-92.6 55.9-29.6 9.3-68.4 13.3-100.4 10.2-38.2-3.7-76.9-17.4-110.1-39-27.9-18.2-34.2-25.7-34.2-40.6 0-29.9 32.9-82.3 89.2-142.1 32-33.9 76.5-73.7 81.4-72.6 9.4 2.1 84.3 75.1 112.3 109.5zM188.6 143.8c-29.7-26.9-58.1-53.9-86.4-63.4-15.2-5.1-16.3-4.8-28.7 8.1-29.2 30.4-53.5 79.7-60.3 122.4-5.4 34.2-6.1 43.8-4.2 60.5 5.6 50.5 17.3 85.4 40.5 120.9 9.5 14.6 12.1 17.3 9.3 9.9-4.2-11-.3-37.5 9.5-64 14.3-39 53.9-112.9 120.3-194.4zm311.6 63.5c-16.9-80-67.5-130.3-74.6-130.3-7.3 0-24.2 6.5-36 13.9-23.3 14.5-41 31.4-64.3 52.8 42.4 53.3 102.2 139.4 122.9 202.3 6.8 20.7 9.7 41.1 7.4 52.3-1.7 8.5-1.7 8.5 1.4 4.6 6.1-7.7 19.9-31.3 25.4-43.5 7.4-16.2 15-40.2 18.6-58.7 4.3-22.5 3.9-70.8-.8-93.4zM141.3 43c47.7-2.5 109.7 34.5 114.3 35.4 .7 .1 10.4-4.2 21.6-9.7 63.9-31.1 94-25.8 107.4-25.2-63.9-39.3-152.7-50-233.9-11.7-23.4 11.1-24 11.9-9.4 11.2z",
|
||||
),
|
||||
)
|
||||
|
||||
private val CACHE = HashMap<String, ImageVector>()
|
||||
|
||||
/**
|
||||
* The [ImageVector] for an `icon` token, or null when the entry carries none or names a mark
|
||||
* this build ships no art for — the caller then falls back to naming the launcher, which is
|
||||
* what every launcher tile looked like before the token existed.
|
||||
*
|
||||
* Tinted by the caller via `tint`, so one mark serves every palette.
|
||||
*/
|
||||
fun launcherIcon(token: String?): ImageVector? {
|
||||
val glyph = GLYPHS[token ?: return null] ?: return null
|
||||
return CACHE.getOrPut(token) {
|
||||
// Square the box and centre the mark in it, so a wide or tall master keeps its aspect
|
||||
// ratio instead of being stretched to the tile.
|
||||
val side = max(glyph.viewportWidth, glyph.viewportHeight)
|
||||
val dx = (side - glyph.viewportWidth) / 2f
|
||||
val dy = (side - glyph.viewportHeight) / 2f
|
||||
ImageVector.Builder(
|
||||
name = "launcher_$token",
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = side,
|
||||
viewportHeight = side,
|
||||
).apply {
|
||||
addGroup(translationX = dx, translationY = dy)
|
||||
addPath(
|
||||
pathData = PathParser().parsePathString(glyph.d).toNodes(),
|
||||
fill = SolidColor(Color.White),
|
||||
)
|
||||
clearGroup()
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/**
|
||||
* The console route to the two sub-screens, driven through the REAL settings screen — the rows
|
||||
* themselves are pinned by `ConsoleSubScreenRowsTest`; what needs the Compose runtime is the trip:
|
||||
* that a press on the row reaches the shell, and that coming back lands where you left rather than
|
||||
* at the top of the first section (the shell's `AnimatedContent` discards a screen's state the
|
||||
* moment it stops being the target, so the place has to travel out and back).
|
||||
*
|
||||
* Rows are activated by TAP for the same reason `GamepadSettingsLayoutTest` does it: the pad path
|
||||
* needs a `MainActivity` for its probes, and both routes end in the same `activate`.
|
||||
*
|
||||
* `sdk = [36]` for the reason every Robolectric test here pins it: android-all jars stop at 36 while
|
||||
* the app compiles against 37.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [36], qualifiers = "w360dp-h800dp-xxhdpi")
|
||||
class ConsoleSubScreenRoutesTest {
|
||||
@get:Rule
|
||||
val compose = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun openingTheControllersRowNavigatesAndReportsWhereItWas() {
|
||||
var opened = 0
|
||||
var place: GpSettingsPlace? = null
|
||||
compose.setContent {
|
||||
GamepadSettingsScreen(
|
||||
initial = Settings(),
|
||||
onChange = {},
|
||||
onBack = {},
|
||||
onOpenControllers = { opened++ },
|
||||
// Entering as if we had just come back from it, which is also what puts the cursor
|
||||
// on the row — so a single tap ACTIVATES rather than merely focusing.
|
||||
resume = GpSettingsPlace(GpTab.CONTROLLER, "controllers"),
|
||||
onPlace = { place = it },
|
||||
)
|
||||
}
|
||||
compose.waitForIdle()
|
||||
|
||||
compose.onNodeWithText("Connected controllers").performClick()
|
||||
compose.waitForIdle()
|
||||
|
||||
assertEquals("the console never reached the diagnostics screen", 1, opened)
|
||||
assertEquals(
|
||||
"the place has to leave before the row does — this screen is gone the next frame",
|
||||
GpSettingsPlace(GpTab.CONTROLLER, "controllers"),
|
||||
place,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Back from a sub-screen lands on the section it was opened from, with the row on screen. The
|
||||
* cursor is restored by row ID rather than index, so it survives a section whose length follows
|
||||
* the hardware.
|
||||
*/
|
||||
@Test
|
||||
fun comingBackFromTheNoticesLandsOnTheRowThatOpenedThem() {
|
||||
compose.setContent {
|
||||
GamepadSettingsScreen(
|
||||
initial = Settings(),
|
||||
onChange = {},
|
||||
onBack = {},
|
||||
resume = GpSettingsPlace(GpTab.INTERFACE, "licenses"),
|
||||
)
|
||||
}
|
||||
compose.waitForIdle()
|
||||
|
||||
compose.onNodeWithText("Open-source licenses").assertIsDisplayed()
|
||||
// Not back at the top of the first section — "Resolution" leads the Stream tab, which is
|
||||
// where a screen that forgot its place would be.
|
||||
compose.onNodeWithText("Resolution").assertDoesNotExist()
|
||||
// And the legend describes THIS row's A. It said the literal "Pin to hosts" on every
|
||||
// non-adjustable row back when profiles were the only ones.
|
||||
compose.onNodeWithText("Open").assertIsDisplayed()
|
||||
compose.onNodeWithText("Pin to hosts").assertDoesNotExist()
|
||||
}
|
||||
|
||||
/**
|
||||
* The notices screen stands on its own on the console's field: no Scaffold or Surface above it
|
||||
* (the shell has neither), its own backdrop, and a legend that says how to leave. Composing it
|
||||
* is most of the assertion — a screen that only ever ran inside the touch Scaffold takes its
|
||||
* content colour from one.
|
||||
*/
|
||||
@Test
|
||||
fun theConsoleNoticesScreenStandsOnItsOwn() {
|
||||
compose.setContent { ConsoleLicensesScreen(onBack = {}) }
|
||||
compose.waitForIdle()
|
||||
|
||||
compose.onNodeWithText("Open-source licenses").assertIsDisplayed()
|
||||
compose.onNodeWithText("Scroll").assertIsDisplayed()
|
||||
compose.onNodeWithText("Close").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The console's route to the two screens that were reachable from touch only — the
|
||||
* connected-controllers diagnostics and the open-source notices.
|
||||
*
|
||||
* "Touch only" reads as a minor gap on a phone and is a dead end on a TV box, where the console IS
|
||||
* the interface: there is no touch UI to fall back to, so a screen with no console row could not be
|
||||
* opened at all. These pin the rows themselves; `ConsoleSubScreenRoutesTest` drives the real screen.
|
||||
*/
|
||||
class ConsoleSubScreenRowsTest {
|
||||
|
||||
private fun rows(
|
||||
forwarding: Boolean = true,
|
||||
version: String = "1.2.3",
|
||||
controllers: () -> Unit = {},
|
||||
licenses: () -> Unit = {},
|
||||
): List<GpRow> = buildSettingsRows(
|
||||
Settings(gamepadForwarding = forwarding),
|
||||
hasBodyVibrator = true,
|
||||
hasGyroscope = true,
|
||||
av1Capable = true,
|
||||
appVersion = version,
|
||||
openControllers = controllers,
|
||||
openLicenses = licenses,
|
||||
) {}
|
||||
|
||||
private fun row(rows: List<GpRow>, id: String): GpRow = rows.first { it.id == id }
|
||||
|
||||
@Test
|
||||
fun `the controllers row opens the diagnostics view from the controller section`() {
|
||||
var opened = 0
|
||||
val r = row(rows(controllers = { opened++ }), "controllers")
|
||||
assertEquals(GpTab.CONTROLLER, r.tab)
|
||||
assertEquals("Connected controllers", r.label)
|
||||
r.activate()
|
||||
assertEquals(1, opened)
|
||||
}
|
||||
|
||||
/**
|
||||
* It must NOT follow the master forwarding switch, unlike every other row in its section: the
|
||||
* screen it opens is what you reach for precisely when forwarding looks broken, and a diagnostic
|
||||
* that dims itself when the thing it diagnoses is off is worse than no diagnostic.
|
||||
*/
|
||||
@Test
|
||||
fun `the controllers row stays live with forwarding off`() {
|
||||
val off = rows(forwarding = false)
|
||||
assertTrue(row(off, "controllers").enabled)
|
||||
assertNotNull(liveRow(off, off.indexOfFirst { it.id == "controllers" }))
|
||||
// Its neighbours in the section still dim, so this is a deliberate exemption and not a
|
||||
// forgotten `enabled =`.
|
||||
assertFalse(row(off, "sc2").enabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the about row opens the notices and states the installed version`() {
|
||||
var opened = 0
|
||||
val r = row(rows(version = "0.27.0", licenses = { opened++ }), "licenses")
|
||||
assertEquals(GpTab.INTERFACE, r.tab)
|
||||
assertEquals("About", r.header)
|
||||
// The version rides in the value slot — on a TV this row is the whole About page.
|
||||
assertEquals("0.27.0", r.value)
|
||||
r.activate()
|
||||
assertEquals(1, opened)
|
||||
}
|
||||
|
||||
/** Both navigate; neither holds a value, so left/right must be refused rather than silently eaten. */
|
||||
@Test
|
||||
fun `neither row steps a value`() {
|
||||
val all = rows()
|
||||
for (id in listOf("controllers", "licenses")) {
|
||||
val r = row(all, id)
|
||||
assertFalse("$id should draw no chevrons", r.adjustable)
|
||||
assertFalse("$id must refuse a step", r.adjust(1))
|
||||
assertFalse("$id must refuse a step", r.adjust(-1))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The legend follows the ROW. It used to say the literal "Pin to hosts" on every non-adjustable
|
||||
* row, because a profile row was the only kind there was — so the moment another one existed,
|
||||
* A on it was advertised as pinning something.
|
||||
*/
|
||||
@Test
|
||||
fun `an action row advertises what A actually does`() {
|
||||
val all = rows()
|
||||
assertEquals("Open", row(all, "controllers").actionHint)
|
||||
assertEquals("Open", row(all, "licenses").actionHint)
|
||||
val profiles = buildProfileRows(listOf(newProfile("Work")), emptyList(), tv = false) {}
|
||||
assertEquals("Pin to hosts", profiles.first().actionHint)
|
||||
}
|
||||
|
||||
/**
|
||||
* The scroll geometry both console sub-screens share. A wall of text has no focusable rows for
|
||||
* Compose to keep visible, so these screens move the scroll state themselves — and how far one
|
||||
* press travels is the whole of their feel.
|
||||
*/
|
||||
@Test
|
||||
fun `a page overlaps what you were reading and a step is shorter still`() {
|
||||
val viewport = 1000f
|
||||
val page = consoleScrollDelta(viewport, page = true, dir = 1)
|
||||
val step = consoleScrollDelta(viewport, page = false, dir = 1)
|
||||
assertTrue("a page that skips a whole screenful loses your place", page < viewport)
|
||||
assertTrue("a page has to be worth pressing", page > viewport / 2f)
|
||||
assertTrue("a D-pad step must be shorter than a shoulder page", step > 0f && step < page)
|
||||
assertEquals("the other direction is the other way", -page, consoleScrollDelta(viewport, true, -1), 0.001f)
|
||||
// Before the first layout there is no viewport: a press then moves nothing, rather than
|
||||
// scrolling by a fraction of zero and reading as a dead button on the way in.
|
||||
assertEquals(0f, consoleScrollDelta(0f, page = true, dir = 1), 0f)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import java.io.File
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The console UI's cross-client contract, against `clients/shared/console-vectors.json`.
|
||||
*
|
||||
* The background palettes, the settings section names and the screen-transition motion each exist
|
||||
* in three hand-written copies — this client, `pf-console-ui` (Rust) and the Apple client — and
|
||||
* until this file they were held together by nothing but a comment asking the next person to keep
|
||||
* them in step. Two of the three had already drifted.
|
||||
*
|
||||
* Read straight off disk with a relative path rather than copied into test resources, for the
|
||||
* reason the deeplink vectors state: a copy would be a fourth contract, free to go stale. Gradle
|
||||
* runs a unit test with the MODULE directory as its working directory, so `../../shared/` from
|
||||
* `clients/android/app` lands on `clients/shared`.
|
||||
*
|
||||
* What this pins that the older [GamepadPaletteTest] could not: the DERIVED 16-cell mesh and the
|
||||
* 4 blob colours per palette. Those are what actually reach the screen — the mesh through the AGSL
|
||||
* shader on API 33+, the blobs through the fallback field below it — and the existing tests only
|
||||
* ever measured the `stops` they are computed from.
|
||||
*/
|
||||
class ConsoleVectorsTest {
|
||||
private companion object {
|
||||
/** One step of Compose's 8-bit-per-component sRGB packing — see the blob comparison. */
|
||||
const val EIGHT_BIT_STEP = 1.0 / 255.0
|
||||
}
|
||||
|
||||
private val vectors: JSONObject by lazy {
|
||||
val file = File("../../shared/console-vectors.json")
|
||||
assertTrue(
|
||||
"the shared vector file must be reachable at ${file.absolutePath}",
|
||||
file.isFile,
|
||||
)
|
||||
JSONObject(file.readText())
|
||||
}
|
||||
|
||||
private fun JSONObject.doubles(key: String): List<Double> =
|
||||
getJSONArray(key).let { a -> (0 until a.length()).map { a.getDouble(it) } }
|
||||
|
||||
private fun close(what: String, got: Double, want: Double, tol: Double = 1e-6) {
|
||||
assertTrue(
|
||||
"$what: vectors say $want, this client computes $got",
|
||||
kotlin.math.abs(got - want) <= tol,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cellRampAndMeshInteriorMatch() {
|
||||
assertEquals("CELL_RAMP", vectors.doubles("cell_ramp"), GamepadPalette.CELL_RAMP)
|
||||
|
||||
val interior = vectors.getJSONArray("mesh_interior")
|
||||
assertEquals("mesh interior count", GamepadPalette.MESH_INTERIOR.size, interior.length())
|
||||
GamepadPalette.MESH_INTERIOR.forEachIndexed { i, p ->
|
||||
val w = interior.getJSONArray(i)
|
||||
val got = listOf(p.x, p.y, p.amp, p.sx, p.sy, p.phase)
|
||||
got.forEachIndexed { k, v -> close("mesh_interior[$i][$k]", v, w.getDouble(k)) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Every palette, field by field — and then the two tables derived from it. */
|
||||
@Test
|
||||
fun everyPaletteMatchesTheSharedVectors() {
|
||||
val want = vectors.getJSONArray("palettes")
|
||||
assertEquals("palette count", want.length(), GamepadPalette.ALL.size)
|
||||
GamepadPalette.ALL.forEachIndexed { i, p ->
|
||||
val w = want.getJSONObject(i)
|
||||
val id = w.getString("id")
|
||||
assertEquals("palette order", id, p.id)
|
||||
assertEquals("$id name", w.getString("name"), p.name)
|
||||
assertEquals("$id light", w.getBoolean("light"), p.light)
|
||||
|
||||
val stops = w.getJSONArray("stops")
|
||||
assertEquals("$id stop count", stops.length(), p.stops.size)
|
||||
p.stops.forEachIndexed { s, t ->
|
||||
val ws = stops.getJSONArray(s)
|
||||
close("$id stops[$s].r", t.first, ws.getDouble(0))
|
||||
close("$id stops[$s].g", t.second, ws.getDouble(1))
|
||||
close("$id stops[$s].b", t.third, ws.getDouble(2))
|
||||
}
|
||||
|
||||
val ground = w.doubles("ground")
|
||||
close("$id ground.r", p.ground.first, ground[0])
|
||||
close("$id ground.g", p.ground.second, ground[1])
|
||||
close("$id ground.b", p.ground.third, ground[2])
|
||||
val accent = w.doubles("accent")
|
||||
close("$id accent.r", p.accent.first, accent[0])
|
||||
close("$id accent.g", p.accent.second, accent[1])
|
||||
close("$id accent.b", p.accent.third, accent[2])
|
||||
|
||||
// The mesh the shader is built from — 16 cells, sampled off the ramp per CELL_RAMP.
|
||||
val mesh = w.getJSONArray("mesh")
|
||||
assertEquals("$id mesh cells", mesh.length(), p.meshColors.size)
|
||||
p.meshColors.forEachIndexed { c, t ->
|
||||
val wc = mesh.getJSONArray(c)
|
||||
close("$id mesh[$c].r", t.first, wc.getDouble(0))
|
||||
close("$id mesh[$c].g", t.second, wc.getDouble(1))
|
||||
close("$id mesh[$c].b", t.third, wc.getDouble(2))
|
||||
}
|
||||
|
||||
// The four blobs the API 28–32 fallback field drifts. These come back as Compose
|
||||
// `Color`s, which pack an sRGB colour at 8 bits per component — so the table
|
||||
// round-trips through 1/255 quantisation and the tolerance below IS that quantisation,
|
||||
// not slack. Anything the contract actually cares about (a mistyped stop, a shifted
|
||||
// sample point) moves these by far more than one 8-bit step.
|
||||
val blobs = w.getJSONArray("blobs")
|
||||
assertEquals("$id blob count", blobs.length(), p.blobColors.size)
|
||||
p.blobColors.forEachIndexed { b, colour ->
|
||||
val wb = blobs.getJSONArray(b)
|
||||
close("$id blob[$b].r", colour.red.toDouble(), wb.getDouble(0), EIGHT_BIT_STEP)
|
||||
close("$id blob[$b].g", colour.green.toDouble(), wb.getDouble(1), EIGHT_BIT_STEP)
|
||||
close("$id blob[$b].b", colour.blue.toDouble(), wb.getDouble(2), EIGHT_BIT_STEP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The section names, in order. The desktop console carries one tab this client does not —
|
||||
* Input, which holds touch mode, mouse, invert-scroll and shortcuts: desktop-host settings
|
||||
* with nothing to set on a phone or a TV. The vectors flag it `desktop_only` rather than
|
||||
* leaving it out, so neither side has to red the other to be right.
|
||||
*/
|
||||
@Test
|
||||
fun tabNamesMatchTheSharedVectors() {
|
||||
val tabs = vectors.getJSONArray("tabs")
|
||||
val want = (0 until tabs.length())
|
||||
.map { tabs.getJSONObject(it) }
|
||||
.filterNot { it.optBoolean("desktop_only", false) }
|
||||
.map { it.getString("name") }
|
||||
assertEquals("console settings tabs", want, GpTab.entries.map { it.title })
|
||||
}
|
||||
|
||||
/**
|
||||
* The screen-transition contract. The easing is sampled rather than compared as Bézier
|
||||
* control points: this client evaluates the desktop's analytic `1 − (1−t)³` directly, while
|
||||
* SwiftUI can only approximate it — samples with a tolerance are the one form all three can
|
||||
* meet. It is also the assertion that would have caught the curve this client shipped with
|
||||
* first, a "cubic-bezier(0.215, 0.61, 0.355, 1)" that is a full 0.08 slack at the midpoint.
|
||||
*/
|
||||
@Test
|
||||
fun motionMatchesTheSharedVectors() {
|
||||
val motion = vectors.getJSONObject("motion")
|
||||
close("transition", ConsoleMotion.TRANSITION_MS / 1000.0, motion.getDouble("transition_s"))
|
||||
close("push slide", ConsoleMotion.PUSH_SLIDE.value.toDouble(), motion.getDouble("push_slide_dp"))
|
||||
close("enter scale", ConsoleMotion.ENTER_SCALE.toDouble(), motion.getDouble("enter_scale"), 1e-5)
|
||||
close("exit scale", ConsoleMotion.EXIT_SCALE.toDouble(), motion.getDouble("exit_scale"), 1e-5)
|
||||
close("reveal alpha", ConsoleMotion.REVEAL_ALPHA.toDouble(), motion.getDouble("reveal_alpha"), 1e-5)
|
||||
|
||||
val curve = motion.getJSONObject("ease_out_cubic")
|
||||
val tol = curve.getDouble("tolerance")
|
||||
val samples = curve.getJSONArray("samples")
|
||||
assertTrue("the curve needs enough samples to pin it", samples.length() >= 5)
|
||||
for (i in 0 until samples.length()) {
|
||||
val s = samples.getJSONObject(i)
|
||||
val t = s.getDouble("t")
|
||||
close(
|
||||
"ease_out_cubic($t)",
|
||||
ConsoleMotion.EaseOutCubic.transform(t.toFloat()).toDouble(),
|
||||
s.getDouble("p"),
|
||||
tol,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.getBoundsInRoot
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/**
|
||||
* The console settings list must not MOVE under the cursor. This is the regression net for the
|
||||
* layout instability the visual refresh fixed, and it needs the real Compose runtime because the
|
||||
* bug was entirely a layout one — every value the model held was correct throughout.
|
||||
*
|
||||
* What used to happen: the focused row unfolded its description in place
|
||||
* (`AnimatedVisibility` + `expandVertically`), so every step of the cursor shrank one row and grew
|
||||
* another and shifted every row below the focus point — on a list that is simultaneously being
|
||||
* scrolled to keep the focused row visible, whose target therefore moved mid-animation. The
|
||||
* description now renders in the screen's floating `ConsoleDetailBand`, which is an overlay and
|
||||
* cannot displace anything. Sideways, the value's `AnimatedContent` animated its own WIDTH on every
|
||||
* step, walking the ‹ chevron and the label's right edge back and forth.
|
||||
*
|
||||
* Focus is moved by TAP here rather than by pad: the pad path needs a `MainActivity` for its input
|
||||
* probes, and the screen routes both to the same `focus` state — the geometry under test is the
|
||||
* same either way.
|
||||
*
|
||||
* `sdk = [36]` for the reason every Robolectric test here pins it: android-all jars stop at 36
|
||||
* while the app compiles against 37.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [36], qualifiers = "w360dp-h800dp-xxhdpi")
|
||||
class GamepadSettingsLayoutTest {
|
||||
@get:Rule
|
||||
val compose = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private fun settings() {
|
||||
compose.setContent {
|
||||
GamepadSettingsScreen(initial = Settings(), onChange = {}, onBack = {})
|
||||
}
|
||||
}
|
||||
|
||||
/** A Dp compared at hairline tolerance — a rounding difference is not a layout shift. */
|
||||
private fun assertSame(what: String, expected: Dp, actual: Dp) {
|
||||
assertEquals(what, expected.value.toDouble(), actual.value.toDouble(), 0.5)
|
||||
}
|
||||
|
||||
/**
|
||||
* Moving the cursor down the list leaves every OTHER row exactly where it was. The rows below
|
||||
* the new focus are the ones the old in-row detail pushed around, so they are the assertion
|
||||
* that matters; the row above proves the shrink half.
|
||||
*/
|
||||
@Test
|
||||
fun focusingARowMovesNoOtherRow() {
|
||||
settings()
|
||||
// Entry focus is the first row (Resolution), so "Refresh rate" starts unfocused and
|
||||
// "Compositor" sits below both candidates.
|
||||
val refreshBefore = compose.onNodeWithText("Refresh rate").getBoundsInRoot()
|
||||
val compositorBefore = compose.onNodeWithText("Compositor").getBoundsInRoot()
|
||||
|
||||
// One tap on an unfocused row focuses it (a second would activate it — see the screen).
|
||||
compose.onNodeWithText("Bitrate").performClick()
|
||||
compose.waitForIdle()
|
||||
|
||||
val refreshAfter = compose.onNodeWithText("Refresh rate").getBoundsInRoot()
|
||||
val compositorAfter = compose.onNodeWithText("Compositor").getBoundsInRoot()
|
||||
assertSame("row above the cursor moved", refreshBefore.top, refreshAfter.top)
|
||||
assertSame("row below the cursor moved", compositorBefore.top, compositorAfter.top)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stepping a value leaves the row's own geometry alone. The label's right edge is the probe:
|
||||
* it is what the widening value slot used to shove, and it is stable for any value that fits
|
||||
* the slot (which every shipped Bitrate label does).
|
||||
*/
|
||||
@Test
|
||||
fun steppingAValueMovesNoLabel() {
|
||||
settings()
|
||||
compose.onNodeWithText("Bitrate").performClick() // focus it
|
||||
compose.waitForIdle()
|
||||
val labelBefore = compose.onNodeWithText("Bitrate").getBoundsInRoot()
|
||||
|
||||
compose.onNodeWithText("Bitrate").performClick() // now activates → cycles the value
|
||||
compose.waitForIdle()
|
||||
|
||||
val labelAfter = compose.onNodeWithText("Bitrate").getBoundsInRoot()
|
||||
assertSame("label moved sideways under a value step", labelBefore.left, labelAfter.left)
|
||||
assertSame("label moved sideways under a value step", labelBefore.right, labelAfter.right)
|
||||
assertSame("row changed height under a value step", labelBefore.top, labelAfter.top)
|
||||
}
|
||||
|
||||
/**
|
||||
* The focused row's description is on screen — in the floating band, not inside the row. Proves
|
||||
* the detail did not simply get dropped when it left the row: it is still what the cursor
|
||||
* explains itself with.
|
||||
*/
|
||||
@Test
|
||||
fun theFocusedRowsDetailIsShown() {
|
||||
settings()
|
||||
compose.onNodeWithText("Refresh rate").performClick()
|
||||
compose.waitForIdle()
|
||||
compose.onNodeWithText("Frame rate the host renders and streams at.").assertExists()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import io.unom.punktfunk.kit.discovery.DiscoveredHost
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The console home's tile list ([buildHomeTiles]). Pure JVM — the carousel itself needs the live
|
||||
* JNI core to compose, so its ORDER and what each tile claims had no cover at all until now, and
|
||||
* both are exactly the kind of thing that survives a refactor looking fine and behaving wrong.
|
||||
*
|
||||
* Run: `./gradlew :app:testDebugUnitTest --tests 'io.unom.punktfunk.HomeTilesTest'`.
|
||||
*/
|
||||
class HomeTilesTest {
|
||||
private fun host(
|
||||
name: String,
|
||||
address: String,
|
||||
fp: String = "",
|
||||
profileId: String? = null,
|
||||
pins: List<String> = emptyList(),
|
||||
) = KnownHost(
|
||||
address = address,
|
||||
port = 9777,
|
||||
name = name,
|
||||
fpHex = fp,
|
||||
paired = true,
|
||||
id = "id-$name",
|
||||
profileId = profileId,
|
||||
pinnedProfileIds = pins,
|
||||
)
|
||||
|
||||
private fun advert(name: String, address: String, fp: String? = null) = DiscoveredHost(
|
||||
key = "$address:9777",
|
||||
name = name,
|
||||
host = address,
|
||||
port = 9777,
|
||||
fingerprint = fp,
|
||||
)
|
||||
|
||||
private val work = StreamProfile(id = "p-work", name = "Work", accent = "#3B82F6")
|
||||
private val travel = StreamProfile(id = "p-travel", name = "Travel")
|
||||
|
||||
/** The builder with nothing plugged in — every list empty, every callback a no-op. */
|
||||
private fun tiles(
|
||||
savedHosts: List<KnownHost> = emptyList(),
|
||||
profiles: List<StreamProfile> = emptyList(),
|
||||
pins: Map<String, List<StreamProfile>> = emptyMap(),
|
||||
discoveredUnsaved: List<DiscoveredHost> = emptyList(),
|
||||
online: Set<String> = emptySet(),
|
||||
onConnect: (KnownHost, String?) -> Unit = { _, _ -> },
|
||||
onConnectDiscovered: (DiscoveredHost) -> Unit = {},
|
||||
onAddHost: () -> Unit = {},
|
||||
) = buildHomeTiles(
|
||||
savedHosts = savedHosts,
|
||||
profiles = profiles,
|
||||
pinsFor = { kh -> pins[kh.id].orEmpty() },
|
||||
discoveredUnsaved = discoveredUnsaved,
|
||||
isOnline = { it.name in online },
|
||||
onConnect = onConnect,
|
||||
onConnectDiscovered = onConnectDiscovered,
|
||||
onAddHost = onAddHost,
|
||||
)
|
||||
|
||||
/**
|
||||
* A pin belongs to the host above it. Ordering is the whole affordance: on a controller a pin is
|
||||
* reached by walking one tile past its host, and a builder that grouped all the pins at the end
|
||||
* would still LOOK right in a screenshot of any single tile.
|
||||
*/
|
||||
@Test
|
||||
fun pinnedCardsFollowTheirOwnHost() {
|
||||
val living = host("living", "192.168.1.42", pins = listOf(work.id, travel.id))
|
||||
val studio = host("studio", "192.168.1.61", pins = listOf(work.id))
|
||||
val ids = tiles(
|
||||
savedHosts = listOf(living, studio),
|
||||
profiles = listOf(work, travel),
|
||||
pins = mapOf(living.id to listOf(work, travel), studio.id to listOf(work)),
|
||||
).map { it.id }
|
||||
assertEquals(
|
||||
listOf(
|
||||
"saved-id-living",
|
||||
"pin-id-living-p-work",
|
||||
"pin-id-living-p-travel",
|
||||
"saved-id-studio",
|
||||
"pin-id-studio-p-work",
|
||||
"add",
|
||||
),
|
||||
ids,
|
||||
)
|
||||
}
|
||||
|
||||
/** Add Host is the last tile, always — including on a device with nothing saved or seen. */
|
||||
@Test
|
||||
fun theAddTileIsAlwaysLast() {
|
||||
val empty = tiles()
|
||||
assertEquals(listOf("add"), empty.map { it.id })
|
||||
assertTrue(empty.single().isAdd)
|
||||
|
||||
val populated = tiles(
|
||||
savedHosts = listOf(host("living", "192.168.1.42")),
|
||||
discoveredUnsaved = listOf(advert("studio", "192.168.1.61")),
|
||||
)
|
||||
assertEquals(listOf("saved-id-living", "disc-192.168.1.61:9777", "add"), populated.map { it.id })
|
||||
assertTrue(populated.last().isAdd)
|
||||
// The Add tile is not a host: no library, no options menu, nothing to wake.
|
||||
assertNull(populated.last().knownHost)
|
||||
}
|
||||
|
||||
/**
|
||||
* A host that is both saved and advertising appears ONCE. The de-dupe is the caller's
|
||||
* ([KnownHost.matches], which the screen applies before handing the list over) — checked here
|
||||
* because the rule that matters is the fingerprint one: a host that came back on a new DHCP
|
||||
* address is the same machine, and matching on address alone would offer it a second time as a
|
||||
* stranger, next to the record that already holds its trust.
|
||||
*/
|
||||
@Test
|
||||
fun aSavedHostSeenOnTheNetworkIsNotListedTwice() {
|
||||
val fp = "ab12cd34"
|
||||
val living = host("living", "192.168.1.42", fp = fp)
|
||||
// Same host, new address after a cold boot, plus a genuine stranger.
|
||||
val adverts = listOf(advert("living", "192.168.1.77", fp = fp), advert("stranger", "192.168.1.99"))
|
||||
val unsaved = adverts.filter { dh -> listOf(living).none { it.matches(dh) } }
|
||||
val ids = tiles(savedHosts = listOf(living), discoveredUnsaved = unsaved).map { it.id }
|
||||
assertEquals(listOf("saved-id-living", "disc-192.168.1.99:9777", "add"), ids)
|
||||
}
|
||||
|
||||
/**
|
||||
* The chip says which profile a press will connect with — the host's binding on its own tile,
|
||||
* the pinned profile on a pin tile. The console cannot EDIT profiles, so this claim is the only
|
||||
* thing standing between a user and a stream with settings they didn't choose.
|
||||
*/
|
||||
@Test
|
||||
fun theChipNamesTheProfileThePressWillUse() {
|
||||
val living = host("living", "192.168.1.42", profileId = work.id, pins = listOf(travel.id))
|
||||
val result = tiles(
|
||||
savedHosts = listOf(living),
|
||||
profiles = listOf(work, travel),
|
||||
pins = mapOf(living.id to listOf(travel)),
|
||||
)
|
||||
val own = result[0]
|
||||
assertEquals("Work", own.profileName)
|
||||
assertEquals(Color(0xFF3B82F6), own.profileAccent)
|
||||
assertNull(own.pinnedProfileId)
|
||||
|
||||
val pin = result[1]
|
||||
assertEquals("Travel", pin.profileName)
|
||||
assertEquals(travel.id, pin.pinnedProfileId)
|
||||
// Travel set no accent: a chip with no colour, not a crash and not a stray default.
|
||||
assertNull(pin.profileAccent)
|
||||
|
||||
// A binding whose profile was deleted resolves to nothing — the tile stays silent rather
|
||||
// than naming an id that resolves to nobody.
|
||||
val dangling = tiles(savedHosts = listOf(host("ghost", "10.0.0.5", profileId = "p-gone")))
|
||||
assertNull(dangling[0].profileName)
|
||||
}
|
||||
|
||||
/** Both address and the subtitle: a pin card says where it points, like every other card. */
|
||||
@Test
|
||||
fun everySavedTileSaysWhereItPoints() {
|
||||
val living = host("living", "192.168.1.42", pins = listOf(work.id))
|
||||
val result = tiles(
|
||||
savedHosts = listOf(living),
|
||||
profiles = listOf(work),
|
||||
pins = mapOf(living.id to listOf(work)),
|
||||
online = setOf("living"),
|
||||
)
|
||||
result.take(2).forEach {
|
||||
assertEquals("192.168.1.42:9777", it.subtitle)
|
||||
assertEquals("living", it.title)
|
||||
assertTrue(it.filled)
|
||||
assertTrue(it.online)
|
||||
assertTrue(it.paired)
|
||||
assertNotNull(it.knownHost)
|
||||
}
|
||||
// Host tile → library (Y); pin tile → none, because a pin is a shortcut, not a second host.
|
||||
assertTrue(result[0].hasLibrary)
|
||||
assertFalse(result[1].hasLibrary)
|
||||
}
|
||||
|
||||
/**
|
||||
* What a press DOES. A host's own tile dials with no one-off reference so the host's binding is
|
||||
* followed; a pin tile forces its own profile. Passing the pin's id as the binding (or the
|
||||
* other way round) is invisible until someone streams at the wrong bitrate.
|
||||
*/
|
||||
@Test
|
||||
fun activationCarriesTheRightProfileReference() {
|
||||
val living = host("living", "192.168.1.42", pins = listOf(work.id))
|
||||
val dialled = mutableListOf<Pair<String, String?>>()
|
||||
val discovered = mutableListOf<String>()
|
||||
var addOpened = false
|
||||
val result = tiles(
|
||||
savedHosts = listOf(living),
|
||||
profiles = listOf(work),
|
||||
pins = mapOf(living.id to listOf(work)),
|
||||
discoveredUnsaved = listOf(advert("stranger", "192.168.1.99")),
|
||||
onConnect = { kh, oneOff -> dialled += kh.name to oneOff },
|
||||
onConnectDiscovered = { dh -> discovered += dh.host },
|
||||
onAddHost = { addOpened = true },
|
||||
)
|
||||
result.forEach { it.activate() }
|
||||
assertEquals(listOf("living" to null, "living" to work.id), dialled)
|
||||
assertEquals(listOf("192.168.1.99"), discovered)
|
||||
assertTrue(addOpened)
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,16 @@ class ScreenshotTest {
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun streamNormal() = shootRoot("stream-normal") { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) }
|
||||
|
||||
// Both banner texts, in the stream's own landscape geometry — it is bottom-centre, so the
|
||||
// aspect is load-bearing.
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun streamBannerPad() = shootRoot("stream-banner-pad") { StreamBannerScene(pad = true) }
|
||||
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun streamBannerTouch() = shootRoot("stream-banner-touch") { StreamBannerScene(pad = false) }
|
||||
|
||||
// The touch flow is a Material dialog over the host grid (a separate window → shootScreen).
|
||||
@Test
|
||||
fun connecting() = shootScreen("connecting") {
|
||||
@@ -114,6 +124,59 @@ class ScreenshotTest {
|
||||
fun consoleSettingsLight() =
|
||||
shootRoot("console-settings-light") { ConsoleSettingsScene(paletteId = "holo") }
|
||||
|
||||
/**
|
||||
* Landscape — the orientation the console actually runs in, and a DIFFERENT layout since the
|
||||
* on-glass review: rows capped and left-aligned, the focused row's description in a side pane
|
||||
* on the right instead of the floating band.
|
||||
*/
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun consoleSettingsLandscape() =
|
||||
shootRoot("console-settings-landscape") { ConsoleSettingsScene() }
|
||||
|
||||
// The console home, the screen the living backdrop is most of. The default sdk (36) draws the
|
||||
// real AGSL MESH field; the paired API-31 shot below draws the blob fallback, so the two
|
||||
// renderings of the same palette can be compared rather than assumed equivalent.
|
||||
@Test
|
||||
fun consoleHome() = shootRoot("console-home") { ConsoleHomeScene() }
|
||||
|
||||
@Test
|
||||
fun consoleHomeLight() = shootRoot("console-home-light") { ConsoleHomeScene(paletteId = "holo") }
|
||||
|
||||
/**
|
||||
* Landscape — the orientation the console UI actually runs in, and the only one wide enough to
|
||||
* show the carousel's NEIGHBOURS, which is where the projected turn (`CARD_TURN_RAD`) lives.
|
||||
*/
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun consoleHomeLandscape() = shootRoot("console-home-landscape") { ConsoleHomeScene() }
|
||||
|
||||
/**
|
||||
* The API 31/32 field. `RuntimeShader` is API 33+, so everything below it keeps the four
|
||||
* drifting blobs — an honest approximation rather than an emulation, and the thing this shot
|
||||
* exists to keep honest.
|
||||
*/
|
||||
@Test
|
||||
@Config(sdk = [31], qualifiers = "w360dp-h800dp-xxhdpi")
|
||||
fun consoleHomeBlobFallback() = shootRoot("console-home-blobs") { ConsoleHomeScene() }
|
||||
|
||||
// The two screens the console reached for the first time in WP8.3. Each is shot on a dark AND a
|
||||
// pale palette, because the console draws them through a ColorScheme derived from the palette's
|
||||
// ink — and the pale one is the only place a grey-on-pastel slip can show up.
|
||||
@Test
|
||||
fun consoleLicenses() = shootRoot("console-licenses") { ConsoleLicensesScene() }
|
||||
|
||||
@Test
|
||||
fun consoleLicensesLight() =
|
||||
shootRoot("console-licenses-light") { ConsoleLicensesScene(paletteId = "holo") }
|
||||
|
||||
@Test
|
||||
fun consoleControllers() = shootRoot("console-controllers") { ConsoleControllersScene() }
|
||||
|
||||
@Test
|
||||
fun consoleControllersLight() =
|
||||
shootRoot("console-controllers-light") { ConsoleControllersScene(paletteId = "holo") }
|
||||
|
||||
@Test
|
||||
fun trust() = shootScreen("trust") {
|
||||
HostsScene()
|
||||
|
||||
@@ -32,9 +32,13 @@ import io.unom.punktfunk.ConnectModal
|
||||
import io.unom.punktfunk.ConnectPhase
|
||||
import io.unom.punktfunk.ConnectTakeover
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import io.unom.punktfunk.GamepadHome
|
||||
import io.unom.punktfunk.GamepadInk
|
||||
import io.unom.punktfunk.GamepadPalette
|
||||
import io.unom.punktfunk.ConsoleControllersScreen
|
||||
import io.unom.punktfunk.ConsoleLicensesScreen
|
||||
import io.unom.punktfunk.GamepadSettingsScreen
|
||||
import io.unom.punktfunk.HomeTile
|
||||
import io.unom.punktfunk.LocalGamepadInk
|
||||
import io.unom.punktfunk.LocalGamepadPalette
|
||||
import io.unom.punktfunk.Settings
|
||||
@@ -43,10 +47,11 @@ import io.unom.punktfunk.SettingsCategory
|
||||
import io.unom.punktfunk.SettingsScreen
|
||||
import io.unom.punktfunk.StatsOverlay
|
||||
import io.unom.punktfunk.StatsVerbosity
|
||||
import io.unom.punktfunk.StreamStartBanner
|
||||
import io.unom.punktfunk.ProfileEditorFields
|
||||
import io.unom.punktfunk.ProfileStore
|
||||
import io.unom.punktfunk.SettingsOverlay
|
||||
import io.unom.punktfunk.SpeedTestDialog
|
||||
import io.unom.punktfunk.SpeedTestPrompt
|
||||
import io.unom.punktfunk.SpeedTestPhase
|
||||
import io.unom.punktfunk.SpeedTestTarget
|
||||
import io.unom.punktfunk.components.HostCard
|
||||
@@ -242,7 +247,8 @@ internal fun SettingsProfileScene() {
|
||||
*/
|
||||
@Composable
|
||||
internal fun SpeedTestScene() {
|
||||
SpeedTestDialog(
|
||||
SpeedTestPrompt(
|
||||
gamepadUi = false,
|
||||
hostName = "Living Room PC",
|
||||
target = SpeedTestTarget.Ask(newProfile("Game")),
|
||||
phase = SpeedTestPhase.Done(throughputKbps = 412_000, lossPct = 0.3, recommendedKbps = 288_400),
|
||||
@@ -427,6 +433,111 @@ internal fun ConnectConsoleScene() =
|
||||
* stand in for it: this is a different screen with different navigation, and the strip is the part
|
||||
* a layout regression would eat first.
|
||||
*/
|
||||
/**
|
||||
* The start-of-stream banner over the same synthetic "streamed frame" — the real
|
||||
* [StreamStartBanner] at full opacity, since the caller owns the 6 s timer and a shot must not race
|
||||
* it. Two variants because the WORDS are the point: the banner names pad chords or touch gestures
|
||||
* depending on what the session actually has, and a screenshot is the only place the two can be
|
||||
* compared side by side.
|
||||
*/
|
||||
@Composable
|
||||
internal fun StreamBannerScene(pad: Boolean) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B)),
|
||||
),
|
||||
),
|
||||
) {
|
||||
StreamStartBanner(
|
||||
text = if (pad) {
|
||||
"Hold Select + Start + L1 + R1 to leave · Select + Y mic · Select + X stats"
|
||||
} else {
|
||||
"Back leaves the stream · three-finger tap for stats"
|
||||
},
|
||||
alpha = 1f,
|
||||
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The console HOME — the host carousel over the living backdrop, which is the screen the aurora is
|
||||
* most of. Worth its own shot for exactly that reason: on API 33+ the field is the real bicubic
|
||||
* MESH (`GamepadAurora`'s AGSL port of the desktop console's shader) and below it the four-blob
|
||||
* fallback, and the two are only comparable side by side. The scene composes [GamepadHome]
|
||||
* directly with mock tiles — it needs no JNI core and no session, unlike the ConnectScreen that
|
||||
* normally feeds it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConsoleHomeScene(paletteId: String = "violet") {
|
||||
val palette = GamepadPalette.named(paletteId)
|
||||
val tiles = listOf(
|
||||
HomeTile(
|
||||
id = "living", title = "Living Room PC", subtitle = "192.168.1.42 · Paired",
|
||||
filled = true, online = true, paired = true, activate = {},
|
||||
),
|
||||
HomeTile(
|
||||
id = "studio", title = "studio-deck", subtitle = "192.168.1.61 · Discovered",
|
||||
online = true, activate = {},
|
||||
),
|
||||
HomeTile(id = "add", title = "Add Host", subtitle = "By address", isAdd = true, activate = {}),
|
||||
)
|
||||
CompositionLocalProvider(
|
||||
LocalGamepadPalette provides palette,
|
||||
LocalGamepadInk provides GamepadInk.of(palette),
|
||||
) {
|
||||
GamepadHome(
|
||||
tiles = tiles,
|
||||
libraryEnabled = true,
|
||||
controllerName = "Xbox Wireless Controller",
|
||||
navActive = false,
|
||||
onActivate = {},
|
||||
onOpenLibrary = {},
|
||||
onOpenSettings = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The two screens the console could not reach at all until WP8.3 — the open-source notices and the
|
||||
* connected-controllers view — in their console presentation.
|
||||
*
|
||||
* Worth a shot each, and worth a PALE one: both are ordinary Material screens underneath, and the
|
||||
* console shows them through a `ColorScheme` derived from the palette's ink. That derivation is the
|
||||
* whole risk. Their touch presentation is inked by the app theme, which is always dark, so nothing
|
||||
* before this could catch light-grey body text stranded on a pastel field.
|
||||
*
|
||||
* Robolectric enumerates no input devices, so the controllers scene renders its deterministic
|
||||
* "nothing connected" state.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConsoleLicensesScene(paletteId: String = "violet") =
|
||||
ConsolePalette(paletteId) { ConsoleLicensesScreen(onBack = {}, navActive = false) }
|
||||
|
||||
@Composable
|
||||
internal fun ConsoleControllersScene(paletteId: String = "violet") =
|
||||
ConsolePalette(paletteId) {
|
||||
ConsoleControllersScreen(gamepadSetting = 0, onBack = {}, navActive = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish the palette locals `App` would normally provide. A scene that calls a console screen
|
||||
* directly gets the DEFAULT dark ink without this, and a pale-palette shot would then silently
|
||||
* prove nothing at all.
|
||||
*/
|
||||
@Composable
|
||||
private fun ConsolePalette(paletteId: String, content: @Composable () -> Unit) {
|
||||
val palette = GamepadPalette.named(paletteId)
|
||||
CompositionLocalProvider(
|
||||
LocalGamepadPalette provides palette,
|
||||
LocalGamepadInk provides GamepadInk.of(palette),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ConsoleSettingsScene(paletteId: String = "violet") {
|
||||
// The scene calls the screen directly, so it has to publish the palette locals `App` would
|
||||
|
||||
@@ -46,8 +46,8 @@ class GamepadRouter(
|
||||
* as well would give the host two pads for one pair of hands.
|
||||
*
|
||||
* Off still opens slots and tracks held state; it only stops the wire sends. That is
|
||||
* deliberate: the exit and mic chords are read off the same slots, and a couch that lost its
|
||||
* quit shortcut because a forwarding preference was off would be the worse bug. Nothing is
|
||||
* deliberate: the exit, mic and stats chords are read off the same slots, and a couch that lost
|
||||
* its quit shortcut because a forwarding preference was off would be the worse bug. Nothing is
|
||||
* claimed by keeping a slot — the Android input stack shares controllers — unlike the USB
|
||||
* capture links, which `StreamScreen` does not start at all while this is off.
|
||||
*/
|
||||
@@ -149,6 +149,20 @@ class GamepadRouter(
|
||||
*/
|
||||
var onMicChord: (() -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Invoked (main thread) each time the stats chord ([STATS_CHORD], Select + X) is COMPLETED on a
|
||||
* pad — one verbosity tier of the in-stream statistics overlay per completion. It exists
|
||||
* because a controller in both hands has no other way to the numbers: the three-finger tap
|
||||
* needs a touchscreen AND one of the pointer touch models, so a TV or a gamepad-only session
|
||||
* has none. `StreamScreen` wires it to the live tier cycle.
|
||||
*
|
||||
* Fires immediately and once per chord like [onMicChord], and like it the buttons still go to
|
||||
* the host — the chord adds a local meaning to them rather than swallowing them. The Apple
|
||||
* client's `GamepadCapture.statsChord` is the same two buttons; a shortcut that differs per
|
||||
* platform is worse than no shortcut.
|
||||
*/
|
||||
var onStatsChord: (() -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Invoked (main thread) once per pad when a captured controller WITH a gyro turns out to be in
|
||||
* a session whose virtual pad has no motion plane — its motion is not being sent, because every
|
||||
@@ -204,7 +218,7 @@ class GamepadRouter(
|
||||
/**
|
||||
* One button transition on [slot] — the shared body behind [onButton] and an [ExternalPad]'s
|
||||
* transitions: forward the wire event, track held state, arm/disarm the exit chord, and fire
|
||||
* the mic-mute chord ([MIC_CHORD]).
|
||||
* the instant chords ([MIC_CHORD], [STATS_CHORD]).
|
||||
*/
|
||||
private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
|
||||
// Raw system buttons stay local under the "local" policy — no wire send and no held
|
||||
@@ -232,13 +246,11 @@ class GamepadRouter(
|
||||
slot.held = slot.held or bit
|
||||
// Full chord now held on this pad → start the hold countdown (idempotent while held).
|
||||
if (slot.held and EXIT_CHORD == EXIT_CHORD) armExit()
|
||||
// Mic mute, edge-triggered on the button that COMPLETES the chord: a genuine press
|
||||
// (`wasHeld` lacks the bit, so an auto-repeat DOWN can't re-fire it) of a chord member
|
||||
// that leaves the whole chord held. Any other button pressed while Select + Y are down
|
||||
// fails the middle test, so the toggle happens once per chord, not once per press.
|
||||
if (wasHeld and bit == 0 && bit and MIC_CHORD != 0 && slot.held and MIC_CHORD == MIC_CHORD) {
|
||||
onMicChord?.invoke()
|
||||
}
|
||||
// Mic mute and the stats-tier cycle, each edge-triggered on the button that COMPLETES
|
||||
// its chord (see [completesChord]) — the two meanings this client gives Select plus a
|
||||
// face button. Both leave the press on the wire: the game still gets its buttons.
|
||||
if (completesChord(wasHeld, bit, MIC_CHORD)) onMicChord?.invoke()
|
||||
if (completesChord(wasHeld, bit, STATS_CHORD)) onStatsChord?.invoke()
|
||||
} else {
|
||||
val owned = guideGesture && bit == Gamepad.BTN_BACK && consumeSelectRelease(slot)
|
||||
if (!owned && send && forwarding) {
|
||||
@@ -628,7 +640,11 @@ class GamepadRouter(
|
||||
return null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// `internal` rather than private: the chord masks and [completesChord] are the only part of
|
||||
// this router a JVM unit test can reach — everything else needs an InputManager, a main Looper
|
||||
// and live InputDevices behind it — and until `GamepadChordTest` there was nothing pinning the
|
||||
// chords at all. Still invisible to :app, which is what private bought.
|
||||
internal companion object {
|
||||
/** Mirror of `punktfunk-core::input::MAX_PADS` — wire pad indices 0..15. */
|
||||
const val MAX_PADS = 16
|
||||
|
||||
@@ -650,6 +666,28 @@ class GamepadRouter(
|
||||
*/
|
||||
const val MIC_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_Y
|
||||
|
||||
/**
|
||||
* Stats-overlay chord: Select + X, one verbosity tier per completion. X keeps both
|
||||
* properties [MIC_CHORD]'s Y has — it is none of [EXIT_CHORD]'s four buttons, so no way of
|
||||
* reaching the exit chord passes through this one on the way (and vice versa), and Select
|
||||
* is a menu button rather than a twitch action. Byte-for-byte the Apple client's
|
||||
* `GamepadCapture.statsChord`, which was modelled on [MIC_CHORD] in the first place and
|
||||
* leaves Y free for the mic chord to land there in turn — the two clients converge on one
|
||||
* pad vocabulary from both ends.
|
||||
*/
|
||||
const val STATS_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_X
|
||||
|
||||
/**
|
||||
* Whether pressing [bit] on a pad that held [wasHeld] beforehand COMPLETED [chord]: a
|
||||
* genuine press (`wasHeld` lacks the bit, so an auto-repeat DOWN can't re-fire it) of a
|
||||
* chord member that leaves the whole chord held (`wasHeld or bit` is the slot's held set
|
||||
* the instant after the press). Any other button pressed while the chord is already down
|
||||
* fails the middle test, so a chord fires once per chord, not once per press — and lifting
|
||||
* any member re-arms it, since the next press of that member is a fresh completion.
|
||||
*/
|
||||
internal fun completesChord(wasHeld: Int, bit: Int, chord: Int): Boolean =
|
||||
wasHeld and bit == 0 && bit and chord != 0 && (wasHeld or bit) and chord == chord
|
||||
|
||||
/** Synthetic slot-key base for [ExternalPad]s — below every real (positive) InputDevice id. */
|
||||
const val EXTERNAL_ID_BASE = -1000
|
||||
|
||||
|
||||
@@ -44,6 +44,9 @@ data class Artwork(val portrait: String?, val header: String?, val hero: String?
|
||||
* opens the launcher itself (Steam Big Picture, Heroic) rather than a title. Kept a plain nullable
|
||||
* String on purpose: the host owns the vocabulary, and an unknown future value must degrade to a
|
||||
* game rather than break the decode (design D4).
|
||||
*
|
||||
* [icon] is the token for the entry's brand mark (`"steam"`, `"heroic"`) — never art, never a URL.
|
||||
* Null on every older host and on every ordinary title.
|
||||
*/
|
||||
data class GameEntry(
|
||||
val id: String,
|
||||
@@ -51,12 +54,25 @@ data class GameEntry(
|
||||
val title: String,
|
||||
val art: Artwork,
|
||||
val role: String? = null,
|
||||
val icon: String? = null,
|
||||
) {
|
||||
val isCustom: Boolean get() = store == "custom"
|
||||
|
||||
/** Whether this entry opens a launcher rather than a game. */
|
||||
val isLauncher: Boolean get() = role == "launcher"
|
||||
|
||||
/**
|
||||
* The brand-icon token, re-validated rather than taken on trust.
|
||||
*
|
||||
* The host checks the shape on the way in, so this only fires for a host older than that
|
||||
* check or one that isn't ours. It costs a scan of a short string and means no consumer has
|
||||
* to wonder what it is about to look up.
|
||||
*/
|
||||
val iconToken: String? get() = icon?.takeIf { t ->
|
||||
t.isNotEmpty() && t.length <= 32 && t[0] in 'a'..'z' &&
|
||||
t.all { it in 'a'..'z' || it in '0'..'9' || it == '-' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Display name for the store badge — the same table the other clients use
|
||||
* (`pf-console-ui::library::store_label`). Before this the UI said "Steam" for every non-custom
|
||||
@@ -151,6 +167,7 @@ object LibraryClient {
|
||||
hero = resolveArt(str(art, "hero"), base),
|
||||
),
|
||||
role = str(o, "role"),
|
||||
icon = str(o, "icon"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The router's instant button chords — mic mute (Select + Y) and the stats-tier cycle
|
||||
* (Select + X) — pinned at the one place a JVM test can reach them: [GamepadRouter.completesChord],
|
||||
* the shared edge rule both fire on. A [GamepadRouter] itself needs an InputManager, a main Looper
|
||||
* and live InputDevices behind it, so driving real KeyEvents through it is not a unit test; the
|
||||
* rule below is the whole of what those two `if`s decide.
|
||||
*
|
||||
* The Apple client pins the same chords from its own side (`GamepadStatsChordTests`), and the two
|
||||
* suites exist for the same reason: a chord that stops completing fails INVISIBLY — the buttons
|
||||
* still reach the game, nothing logs, and the couch simply finds that a shortcut it was told about
|
||||
* does nothing. On a TV the stats chord is the only route to the overlay at all.
|
||||
*/
|
||||
class GamepadChordTest {
|
||||
|
||||
/** The two chords `slotButton` tests on every press, in the order it tests them. */
|
||||
private val instantChords = listOf(GamepadRouter.MIC_CHORD, GamepadRouter.STATS_CHORD)
|
||||
|
||||
/**
|
||||
* One pad's held-button set, driven exactly the way `slotButton` drives a slot's: the chord
|
||||
* test reads the state from BEFORE the press, then the bit joins `held`. [press] returns the
|
||||
* chords that completed on it — an empty list means the press was silent.
|
||||
*/
|
||||
private inner class Pad {
|
||||
var held = 0
|
||||
private set
|
||||
|
||||
fun press(bit: Int): List<Int> {
|
||||
val wasHeld = held
|
||||
held = held or bit
|
||||
return instantChords.filter { GamepadRouter.completesChord(wasHeld, bit, it) }
|
||||
}
|
||||
|
||||
/** An auto-repeat DOWN: Android re-delivers a held button, so `wasHeld` already has it. */
|
||||
fun repeat(bit: Int): List<Int> = press(bit)
|
||||
|
||||
fun release(bit: Int) {
|
||||
held = held and bit.inv()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select + X, the same pair as the Apple client's `GamepadCapture.statsChord`
|
||||
* (`GamepadWire.back | GamepadWire.x`). A per-platform shortcut is worse than none, so the
|
||||
* literal bits are spelled out here rather than derived from the constant under test.
|
||||
*/
|
||||
@Test
|
||||
fun `the stats chord is Select plus X`() {
|
||||
assertEquals(0x0020 or 0x4000, GamepadRouter.STATS_CHORD)
|
||||
assertEquals(Gamepad.BTN_BACK or Gamepad.BTN_X, GamepadRouter.STATS_CHORD)
|
||||
assertEquals(Gamepad.BTN_BACK or Gamepad.BTN_Y, GamepadRouter.MIC_CHORD)
|
||||
}
|
||||
|
||||
/**
|
||||
* The three chords must not be reachable through one another: pressing toward the exit chord
|
||||
* may not cycle the overlay or mute the mic on the way, and neither instant chord may arm a
|
||||
* disconnect. Select is the one button they share by design — everything else is disjoint, and
|
||||
* no chord is a subset of another (a subset would complete whenever its superset did).
|
||||
*/
|
||||
@Test
|
||||
fun `the chords meet only on Select`() {
|
||||
val chords = mapOf(
|
||||
"exit" to GamepadRouter.EXIT_CHORD,
|
||||
"mic" to GamepadRouter.MIC_CHORD,
|
||||
"stats" to GamepadRouter.STATS_CHORD,
|
||||
)
|
||||
for ((aName, a) in chords) {
|
||||
for ((bName, b) in chords) {
|
||||
if (aName == bName) continue
|
||||
assertEquals("$aName and $bName share a button other than Select", Gamepad.BTN_BACK, a and b)
|
||||
assertNotEquals("$aName is a subset of $bName", a and b, a)
|
||||
assertNotEquals("$bName is a subset of $aName", a and b, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One cycle per chord, not one per press: the completing button fires it, an auto-repeat of
|
||||
* that same button does not, and a third button pressed on top of the held chord finds the mask
|
||||
* already complete.
|
||||
*/
|
||||
@Test
|
||||
fun `the chord fires once, on the button that completes it`() {
|
||||
val pad = Pad()
|
||||
assertEquals("Select alone is not a chord", emptyList<Int>(), pad.press(Gamepad.BTN_BACK))
|
||||
assertEquals(listOf(GamepadRouter.STATS_CHORD), pad.press(Gamepad.BTN_X))
|
||||
assertEquals("auto-repeat re-fired the chord", emptyList<Int>(), pad.repeat(Gamepad.BTN_X))
|
||||
assertEquals("a press on top re-fired the chord", emptyList<Int>(), pad.press(Gamepad.BTN_A))
|
||||
assertEquals(emptyList<Int>(), pad.press(Gamepad.BTN_B))
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifting either member re-arms the chord — pressing it again is a fresh completion. Both
|
||||
* directions matter: a couch user cycling tiers taps X with Select still down, and one who
|
||||
* lifted Select instead taps Select again with X still down.
|
||||
*/
|
||||
@Test
|
||||
fun `either member re-arms the chord when released`() {
|
||||
val pad = Pad()
|
||||
pad.press(Gamepad.BTN_BACK)
|
||||
assertEquals(listOf(GamepadRouter.STATS_CHORD), pad.press(Gamepad.BTN_X))
|
||||
pad.release(Gamepad.BTN_X)
|
||||
assertEquals(listOf(GamepadRouter.STATS_CHORD), pad.press(Gamepad.BTN_X))
|
||||
pad.release(Gamepad.BTN_BACK)
|
||||
assertEquals(listOf(GamepadRouter.STATS_CHORD), pad.press(Gamepad.BTN_BACK))
|
||||
}
|
||||
|
||||
/** A partial mask fires nothing — either member alone, or with a non-member alongside it. */
|
||||
@Test
|
||||
fun `a partial chord never fires`() {
|
||||
for (opening in listOf(Gamepad.BTN_BACK, Gamepad.BTN_X, Gamepad.BTN_Y)) {
|
||||
val pad = Pad()
|
||||
assertEquals(emptyList<Int>(), pad.press(opening))
|
||||
for (other in listOf(Gamepad.BTN_A, Gamepad.BTN_B, Gamepad.BTN_LB, Gamepad.BTN_DPAD_UP)) {
|
||||
assertEquals(emptyList<Int>(), pad.press(other))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walking into the exit chord (Select + Start + L1 + R1, in any order) must pass through
|
||||
* neither instant chord: the disconnect hold is the one gesture where a stray mute or a
|
||||
* changed overlay would land while the user is looking at the "hold to quit" hint.
|
||||
*/
|
||||
@Test
|
||||
fun `reaching the exit chord fires nothing on the way`() {
|
||||
val exit = listOf(Gamepad.BTN_BACK, Gamepad.BTN_START, Gamepad.BTN_LB, Gamepad.BTN_RB)
|
||||
for (order in exit.permutations()) {
|
||||
val pad = Pad()
|
||||
for (bit in order) {
|
||||
assertEquals("$order fired a chord at $bit", emptyList<Int>(), pad.press(bit))
|
||||
}
|
||||
assertEquals(GamepadRouter.EXIT_CHORD, pad.held)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* X and Y held, then Select: ONE press completes BOTH chords. That is the honest reading of
|
||||
* "the button that completes the mask", it is what the Apple client does too, and the
|
||||
* alternative — first match wins — would make the same press mean different things depending
|
||||
* on which chord the router happened to test first. Pinned so the behaviour is a decision
|
||||
* rather than a surprise; both outcomes are visible and reversible on screen.
|
||||
*/
|
||||
@Test
|
||||
fun `a shared Select can complete both chords at once`() {
|
||||
val pad = Pad()
|
||||
pad.press(Gamepad.BTN_X)
|
||||
pad.press(Gamepad.BTN_Y)
|
||||
assertEquals(instantChords, pad.press(Gamepad.BTN_BACK))
|
||||
}
|
||||
|
||||
/** The chord bits are the wire's, so they must stay inside the 32-bit button mask. */
|
||||
@Test
|
||||
fun `chord masks are wire button bits`() {
|
||||
for (chord in instantChords + GamepadRouter.EXIT_CHORD) {
|
||||
assertTrue("chord $chord has no bits", chord != 0)
|
||||
assertEquals("a chord bit is not a known BTN_*", chord, chord and ALL_BUTTONS)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Every button bit `Gamepad` defines — the universe a chord may draw from. */
|
||||
val ALL_BUTTONS = listOf(
|
||||
Gamepad.BTN_DPAD_UP, Gamepad.BTN_DPAD_DOWN, Gamepad.BTN_DPAD_LEFT, Gamepad.BTN_DPAD_RIGHT,
|
||||
Gamepad.BTN_START, Gamepad.BTN_BACK, Gamepad.BTN_LS_CLICK, Gamepad.BTN_RS_CLICK,
|
||||
Gamepad.BTN_LB, Gamepad.BTN_RB, Gamepad.BTN_GUIDE,
|
||||
Gamepad.BTN_A, Gamepad.BTN_B, Gamepad.BTN_X, Gamepad.BTN_Y,
|
||||
Gamepad.BTN_PADDLE1, Gamepad.BTN_PADDLE2, Gamepad.BTN_PADDLE3, Gamepad.BTN_PADDLE4,
|
||||
Gamepad.BTN_TOUCHPAD, Gamepad.BTN_MISC1,
|
||||
).fold(0) { acc, bit -> acc or bit }
|
||||
|
||||
/** Every ordering of a chord's buttons — presses arrive in whatever order the hands do. */
|
||||
fun <T> List<T>.permutations(): List<List<T>> =
|
||||
if (size <= 1) listOf(this)
|
||||
else flatMap { head -> (this - head).permutations().map { listOf(head) + it } }
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,10 @@ let package = Package(
|
||||
// licensing in that README). `.process` compiles the catalog; loaded via
|
||||
// OsIcon.swift.
|
||||
.process("Resources/OsIcons.xcassets"),
|
||||
// The launcher tiles' brand marks (template vector imagesets generated from the
|
||||
// assets/launcher-icons masters by scripts/gen-launcher-icons.sh — per-mark
|
||||
// provenance and licensing in that README). Loaded via LauncherIcon.swift.
|
||||
.process("Resources/LauncherIcons.xcassets"),
|
||||
],
|
||||
linkerSettings: [
|
||||
// Rust staticlib system deps.
|
||||
|
||||
@@ -125,7 +125,7 @@ struct LibraryCoverflowView: View {
|
||||
) -> some View {
|
||||
PosterImage(
|
||||
candidates: game.art.posterCandidates, title: game.title, loader: artLoader,
|
||||
onLoaded: { artSettled += 1 })
|
||||
icon: game.iconToken, onLoaded: { artSettled += 1 })
|
||||
.frame(width: width, height: height)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.overlay(alignment: .topLeading) {
|
||||
|
||||
@@ -346,7 +346,9 @@ private struct GameCard: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
PosterImage(candidates: game.art.posterCandidates, title: game.title, loader: artLoader)
|
||||
PosterImage(
|
||||
candidates: game.art.posterCandidates, title: game.title, loader: artLoader,
|
||||
icon: game.iconToken)
|
||||
.aspectRatio(2.0 / 3.0, contentMode: .fit)
|
||||
.frame(maxWidth: .infinity)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
|
||||
@@ -71,6 +71,9 @@ struct PosterImage: View {
|
||||
let candidates: [URL]
|
||||
let title: String
|
||||
let loader: LibraryArtLoader?
|
||||
/// The entry's brand-mark token (`GameEntry.iconToken`), when it has one. A launcher tile ships
|
||||
/// no cover art by design, so for those the mark IS the poster — see `placeholder`.
|
||||
var icon: String?
|
||||
/// Fires once this poster has settled — art loaded, or every candidate exhausted and the
|
||||
/// placeholder is what it will be. The gamepad coverflow waits on a few of these before
|
||||
/// playing its entrance, so the cards swing in carrying artwork rather than grey rectangles.
|
||||
@@ -121,11 +124,26 @@ struct PosterImage: View {
|
||||
private var placeholder: some View {
|
||||
ZStack {
|
||||
Rectangle().fill(.quaternary)
|
||||
Text(title)
|
||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(8)
|
||||
// A launcher's brand mark, drawn at poster size and tinted like the text it replaces.
|
||||
// `scaledToFit` inside a fraction of the card keeps a non-square master (the Steam mark
|
||||
// is 496×512, Playnite's 1024×1024) in its own aspect ratio rather than stretched.
|
||||
// Falling back to the title is the pre-icon design, so an unshipped mark loses nothing.
|
||||
if let mark = launcherIconImage(for: icon) {
|
||||
GeometryReader { geo in
|
||||
mark
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: geo.size.width * 0.44, height: geo.size.height * 0.44)
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
}
|
||||
} else {
|
||||
Text(title)
|
||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,12 +47,28 @@ public struct GameEntry: Codable, Hashable, Identifiable, Sendable {
|
||||
/// optional String: the host owns the vocabulary, and an unknown future value must never fail
|
||||
/// the whole library decode. Anything that isn't `"launcher"` is a game (design D4).
|
||||
public var role: String?
|
||||
/// The token for this entry's brand mark (`"steam"`, `"heroic"`) — never art, never a URL.
|
||||
/// `nil` on every older host and on every ordinary title. See `launcherIconImage`.
|
||||
public var icon: String?
|
||||
|
||||
public var isCustom: Bool { store == "custom" }
|
||||
|
||||
/// Whether this entry opens a launcher rather than a game.
|
||||
public var isLauncher: Bool { role == "launcher" }
|
||||
|
||||
/// The brand-icon token, re-validated rather than taken on trust.
|
||||
///
|
||||
/// The host checks the shape on the way in, so this can only fire for a host older than that
|
||||
/// check or one that isn't ours. The value reaches `Image(named:)`, and "the peer promised" is
|
||||
/// not the standard a name lookup deserves.
|
||||
public var iconToken: String? {
|
||||
guard let t = icon, !t.isEmpty, t.count <= 32,
|
||||
let first = t.first, first.isASCII, first.isLowercase,
|
||||
t.allSatisfy({ $0.isASCII && ($0.isLowercase || $0.isNumber || $0 == "-") })
|
||||
else { return nil }
|
||||
return t
|
||||
}
|
||||
|
||||
/// Display name for the store badge — the same table the Rust clients use
|
||||
/// (`pf-console-ui::library::store_label`). Before this existed the badge said "Steam" for
|
||||
/// every non-custom entry, which a Lutris or GOG title made a lie.
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// The launcher tiles' brand marks: template vector imagesets in Resources/LauncherIcons.xcassets
|
||||
// (generated from the repo's assets/launcher-icons masters by scripts/gen-launcher-icons.sh —
|
||||
// per-mark provenance and licensing in that directory's README), resolved from a library entry's
|
||||
// `icon` token. Template rendering means they tint with `foregroundStyle` like an SF Symbol.
|
||||
//
|
||||
// The sibling of OsIcon.swift, which does the equivalent job for the host cards' OS marks. SF
|
||||
// Symbols ships no third-party brand glyphs, so a curated registry is the only route.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// The icon tokens this client ships art for. A token outside this set draws nothing and the tile
|
||||
/// falls back to naming its launcher — which is what every launcher tile looked like before icons
|
||||
/// existed, so an unknown mark degrades to the old design rather than to a hole.
|
||||
///
|
||||
/// Checked against rather than interpolated: `Image(named:)` is a name lookup, and the set is the
|
||||
/// only thing that decides which names it can ever see.
|
||||
private let launcherIconTokensShipped: Set<String> = [
|
||||
"steam", "lutris", "heroic", "playnite", "epic", "gog", "xbox",
|
||||
]
|
||||
|
||||
/// The brand mark for a library entry's `icon` token, or nil — no view at all — when the entry
|
||||
/// carries no token or names one this client ships no art for.
|
||||
public func launcherIconImage(for token: String?) -> Image? {
|
||||
guard let token, launcherIconTokensShipped.contains(token) else { return nil }
|
||||
return Image("launcher-\(token)", bundle: .module)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "epic.pdf", "idiom" : "universal" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "gog.pdf", "idiom" : "universal" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "heroic.pdf", "idiom" : "universal" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "lutris.pdf", "idiom" : "universal" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "playnite.pdf", "idiom" : "universal" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "steam.pdf", "idiom" : "universal" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "xbox.pdf", "idiom" : "universal" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
import simd
|
||||
|
||||
@testable import PunktfunkShared
|
||||
|
||||
/// The console UI's cross-client contract, against `clients/shared/console-vectors.json`.
|
||||
///
|
||||
/// The background palette table exists in three hand-written copies — this client,
|
||||
/// `pf-console-ui`'s `library.rs`, and the Android client's `GamepadPalette.kt` — and until this
|
||||
/// file the only thing holding them together was a comment in each asking the next person to keep
|
||||
/// them in step. This is the sibling of `SharedFoundationTests.testDeepLinkSharedVectors`, read
|
||||
/// the same way and for the same reason.
|
||||
///
|
||||
/// What it pins beyond the definitions is the DERIVED table: the 16 mesh cells each palette
|
||||
/// produces, which is what actually reaches the gradient. `GamepadPaletteTests` already asserts
|
||||
/// the invariants (hue spread, gamut, lightness honesty); this asserts the values.
|
||||
///
|
||||
/// ⚠️ The tab names and the shell motion constants are in the vectors file too, but this client
|
||||
/// cannot yet check them: `GpSettingsTab` and `GamepadShellMotion` live in `PunktfunkClient`,
|
||||
/// an executable target with no test target of its own. Moving them into `PunktfunkShared` — where
|
||||
/// `GamepadPalette` already sits, and for exactly this reason (see its header) — is what would
|
||||
/// close that gap.
|
||||
final class ConsoleVectorsTests: XCTestCase {
|
||||
/// Read from the repo, not from a bundle resource: a copy would be a second file, and a
|
||||
/// second file drifts. Four `deletingLastPathComponent()` calls walk
|
||||
/// `Tests/PunktfunkKitTests/` → `Tests/` → `apple/` → `clients/`.
|
||||
private static var vectorFileURL: URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("shared/console-vectors.json")
|
||||
}
|
||||
|
||||
private struct VectorFile: Decodable {
|
||||
let cellRamp: [Double]
|
||||
let meshInterior: [[Double]]
|
||||
let palettes: [Palette]
|
||||
|
||||
// swiftlint:disable:next nesting
|
||||
struct Palette: Decodable {
|
||||
let id: String
|
||||
let name: String
|
||||
let light: Bool
|
||||
let stops: [[Double]]
|
||||
let ground: [Double]
|
||||
let accent: [Double]
|
||||
let mesh: [[Double]]
|
||||
let blobs: [[Double]]
|
||||
}
|
||||
|
||||
// swiftlint:disable:next identifier_name
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case cellRamp = "cell_ramp"
|
||||
case meshInterior = "mesh_interior"
|
||||
case palettes
|
||||
}
|
||||
}
|
||||
|
||||
private func assertClose(
|
||||
_ got: Double, _ want: Double, _ what: String, tolerance: Double = 1e-6,
|
||||
file: StaticString = #filePath, line: UInt = #line
|
||||
) {
|
||||
XCTAssertEqual(
|
||||
got, want, accuracy: tolerance,
|
||||
"\(what): vectors say \(want), this client computes \(got)", file: file, line: line)
|
||||
}
|
||||
|
||||
func testPaletteTableMatchesTheSharedVectors() throws {
|
||||
let url = Self.vectorFileURL
|
||||
XCTAssertTrue(
|
||||
FileManager.default.fileExists(atPath: url.path),
|
||||
"the shared vector file must be reachable at \(url.path)")
|
||||
let file = try JSONDecoder().decode(VectorFile.self, from: Data(contentsOf: url))
|
||||
|
||||
XCTAssertEqual(file.cellRamp, GamepadPalette.cellRamp, "cellRamp")
|
||||
XCTAssertEqual(file.palettes.count, GamepadPalette.all.count, "palette count")
|
||||
|
||||
for (want, p) in zip(file.palettes, GamepadPalette.all) {
|
||||
XCTAssertEqual(want.id, p.id, "palette order")
|
||||
XCTAssertEqual(want.name, p.name, "\(p.id) name")
|
||||
XCTAssertEqual(want.light, p.light, "\(p.id) light")
|
||||
|
||||
XCTAssertEqual(want.stops.count, p.stops.count, "\(p.id) stop count")
|
||||
for (i, (ws, s)) in zip(want.stops, p.stops).enumerated() {
|
||||
assertClose(s.x, ws[0], "\(p.id) stops[\(i)].r")
|
||||
assertClose(s.y, ws[1], "\(p.id) stops[\(i)].g")
|
||||
assertClose(s.z, ws[2], "\(p.id) stops[\(i)].b")
|
||||
}
|
||||
assertClose(p.ground.x, want.ground[0], "\(p.id) ground.r")
|
||||
assertClose(p.ground.y, want.ground[1], "\(p.id) ground.g")
|
||||
assertClose(p.ground.z, want.ground[2], "\(p.id) ground.b")
|
||||
assertClose(p.accent.x, want.accent[0], "\(p.id) accent.r")
|
||||
assertClose(p.accent.y, want.accent[1], "\(p.id) accent.g")
|
||||
assertClose(p.accent.z, want.accent[2], "\(p.id) accent.b")
|
||||
|
||||
let mesh = p.meshColors
|
||||
XCTAssertEqual(want.mesh.count, mesh.count, "\(p.id) mesh cells")
|
||||
for (i, (wc, c)) in zip(want.mesh, mesh).enumerated() {
|
||||
assertClose(c.x, wc[0], "\(p.id) mesh[\(i)].r")
|
||||
assertClose(c.y, wc[1], "\(p.id) mesh[\(i)].g")
|
||||
assertClose(c.z, wc[2], "\(p.id) mesh[\(i)].b")
|
||||
}
|
||||
let blobs = p.blobColors
|
||||
XCTAssertEqual(want.blobs.count, blobs.count, "\(p.id) blob count")
|
||||
for (i, (wc, c)) in zip(want.blobs, blobs).enumerated() {
|
||||
assertClose(c.x, wc[0], "\(p.id) blob[\(i)].r")
|
||||
assertClose(c.y, wc[1], "\(p.id) blob[\(i)].g")
|
||||
assertClose(c.z, wc[2], "\(p.id) blob[\(i)].b")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The four wandering mesh control points. This client keeps them as literal arguments to a
|
||||
/// nested `wob(...)` inside `GamepadChrome.meshPoints(at:)` rather than as a named table, so
|
||||
/// the values are checked here against the vectors and the shape is pinned by the count.
|
||||
func testMeshInteriorIsFourPoints() throws {
|
||||
let file = try JSONDecoder().decode(
|
||||
VectorFile.self, from: Data(contentsOf: Self.vectorFileURL))
|
||||
XCTAssertEqual(file.meshInterior.count, 4, "the mesh has four interior control points")
|
||||
for p in file.meshInterior {
|
||||
XCTAssertEqual(p.count, 6, "each point is (x, y, amp, sx, sy, phase)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
<!-- epic — from Simple Icons (CC0 1.0), slug `epicgames`. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000000"><path d="M3.537 0C2.165 0 1.66.506 1.66 1.879V18.44a4.262 4.262 0 00.02.433c.031.3.037.59.316.92.027.033.311.245.311.245.153.075.258.13.43.2l8.335 3.491c.433.199.614.276.928.27h.002c.314.006.495-.071.928-.27l8.335-3.492c.172-.07.277-.124.43-.2 0 0 .284-.211.311-.243.28-.33.285-.621.316-.92a4.261 4.261 0 00.02-.434V1.879c0-1.373-.506-1.88-1.878-1.88zm13.366 3.11h.68c1.138 0 1.688.553 1.688 1.696v1.88h-1.374v-1.8c0-.369-.17-.54-.523-.54h-.235c-.367 0-.537.17-.537.539v5.81c0 .369.17.54.537.54h.262c.353 0 .523-.171.523-.54V8.619h1.373v2.143c0 1.144-.562 1.71-1.7 1.71h-.694c-1.138 0-1.7-.566-1.7-1.71V4.82c0-1.144.562-1.709 1.7-1.709zm-12.186.08h3.114v1.274H6.117v2.603h1.648v1.275H6.117v2.774h1.74v1.275h-3.14zm3.816 0h2.198c1.138 0 1.7.564 1.7 1.708v2.445c0 1.144-.562 1.71-1.7 1.71h-.799v3.338h-1.4zm4.53 0h1.4v9.201h-1.4zm-3.13 1.235v3.392h.575c.354 0 .523-.171.523-.54V4.965c0-.368-.17-.54-.523-.54zm-3.74 10.147a1.708 1.708 0 01.591.108 1.745 1.745 0 01.49.299l-.452.546a1.247 1.247 0 00-.308-.195.91.91 0 00-.363-.068.658.658 0 00-.28.06.703.703 0 00-.224.163.783.783 0 00-.151.243.799.799 0 00-.056.299v.008a.852.852 0 00.056.31.7.7 0 00.157.245.736.736 0 00.238.16.774.774 0 00.303.058.79.79 0 00.445-.116v-.339h-.548v-.565H7.37v1.255a2.019 2.019 0 01-.524.307 1.789 1.789 0 01-.683.123 1.642 1.642 0 01-.602-.107 1.46 1.46 0 01-.478-.3 1.371 1.371 0 01-.318-.455 1.438 1.438 0 01-.115-.58v-.008a1.426 1.426 0 01.113-.57 1.449 1.449 0 01.312-.46 1.418 1.418 0 01.474-.309 1.58 1.58 0 01.598-.111 1.708 1.708 0 01.045 0zm11.963.008a2.006 2.006 0 01.612.094 1.61 1.61 0 01.507.277l-.386.546a1.562 1.562 0 00-.39-.205 1.178 1.178 0 00-.388-.07.347.347 0 00-.208.052.154.154 0 00-.07.127v.008a.158.158 0 00.022.084.198.198 0 00.076.066.831.831 0 00.147.06c.062.02.14.04.236.061a3.389 3.389 0 01.43.122 1.292 1.292 0 01.328.17.678.678 0 01.207.24.739.739 0 01.071.337v.008a.865.865 0 01-.081.382.82.82 0 01-.229.285 1.032 1.032 0 01-.353.18 1.606 1.606 0 01-.46.061 2.16 2.16 0 01-.71-.116 1.718 1.718 0 01-.593-.346l.43-.514c.277.223.578.335.9.335a.457.457 0 00.236-.05.157.157 0 00.082-.142v-.008a.15.15 0 00-.02-.077.204.204 0 00-.073-.066.753.753 0 00-.143-.062 2.45 2.45 0 00-.233-.062 5.036 5.036 0 01-.413-.113 1.26 1.26 0 01-.331-.16.72.72 0 01-.222-.243.73.73 0 01-.082-.36v-.008a.863.863 0 01.074-.359.794.794 0 01.214-.283 1.007 1.007 0 01.34-.185 1.423 1.423 0 01.448-.066 2.006 2.006 0 01.025 0zm-9.358.025h.742l1.183 2.81h-.825l-.203-.499H8.623l-.198.498h-.81zm2.197.02h.814l.663 1.08.663-1.08h.814v2.79h-.766v-1.602l-.711 1.091h-.016l-.707-1.083v1.593h-.754zm3.469 0h2.235v.658h-1.473v.422h1.334v.61h-1.334v.442h1.493v.658h-2.255zm-5.3.897l-.315.793h.624zm-1.145 5.19h8.014l-4.09 1.348z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,2 @@
|
||||
<!-- gog — from Simple Icons (CC0 1.0), slug `gogdotcom`. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000000"><path d="M7.15 15.24H4.36a.4.4 0 0 0-.4.4v2c0 .21.18.4.4.4h2.8v1.32h-3.5c-.56 0-1.02-.46-1.02-1.03v-3.39c0-.56.46-1.02 1.03-1.02h3.48v1.32zM8.16 11.54c0 .58-.47 1.05-1.05 1.05H2.63v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4H4.39a.4.4 0 0 0-.41.4v2.02c0 .23.18.4.4.4H6v1.35H3.68c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04H7.1c.58 0 1.05.47 1.05 1.04v5.86zM21.36 19.36h-1.32v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.42c0-.56.46-1.02 1.03-1.02h5.61v5.44zM21.37 11.54c0 .58-.47 1.05-1.05 1.05h-4.48v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4h-2.03a.4.4 0 0 0-.4.4v2.02c0 .23.18.4.4.4h1.62v1.35H16.9c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04h3.43c.58 0 1.05.47 1.05 1.04v5.86zM13.72 4.64h-3.44c-.58 0-1.04.47-1.04 1.04v3.44c0 .58.46 1.04 1.04 1.04h3.44c.57 0 1.04-.46 1.04-1.04V5.68c0-.57-.47-1.04-1.04-1.04m-.3 1.75v2.02a.4.4 0 0 1-.4.4h-2.03a.4.4 0 0 1-.4-.4V6.4c0-.22.17-.4.4-.4H13c.23 0 .4.18.4.4zM12.63 13.92H9.24c-.57 0-1.03.46-1.03 1.02v3.39c0 .57.46 1.03 1.03 1.03h3.39c.57 0 1.03-.46 1.03-1.03v-3.39c0-.56-.46-1.02-1.03-1.02m-.3 1.72v2a.4.4 0 0 1-.4.4v-.01H9.94a.4.4 0 0 1-.4-.4v-1.99c0-.22.18-.4.4-.4h2c.22 0 .4.18.4.4zM23.49 1.1a1.74 1.74 0 0 0-1.24-.52H1.75A1.74 1.74 0 0 0 0 2.33v19.34a1.74 1.74 0 0 0 1.75 1.75h20.5A1.74 1.74 0 0 0 24 21.67V2.33c0-.48-.2-.92-.51-1.24m0 20.58a1.23 1.23 0 0 1-1.24 1.24H1.75A1.23 1.23 0 0 1 .5 21.67V2.33a1.23 1.23 0 0 1 1.24-1.24h20.5a1.24 1.24 0 0 1 1.24 1.24v19.34z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,2 @@
|
||||
<!-- heroic — from Simple Icons (CC0 1.0), slug `heroicgameslauncher`. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000000"><path d="M11.999 0 11.997 0a.891.891 0 0 0-.36.075C8.964 1.253 6.29 2.434 3.618 3.613A.893.893 0 0 0 3.1 4.619l3.146 14.646c.043.197.15.375.307.504l4.88 4.027a.895.895 0 0 0 1.131.006l5-4.031a.895.895 0 0 0 .315-.516L20.9 4.614a.895.895 0 0 0-.515-1L12.358.074A.892.892 0 0 0 12 0zm0 .35v.003c.114 0 .228.023.334.07l7.42 3.27a.827.827 0 0 1 .476.924l-2.793 13.535a.83.83 0 0 1-.289.478l-4.623 3.725a.826.826 0 0 1-1.045-.006l-4.513-3.723a.829.829 0 0 1-.281-.465L3.775 4.622a.83.83 0 0 1 .476-.931L11.665.42a.832.832 0 0 1 .334-.07zm-.045 1.954L10.28 5.202h-.002l1.211 11.301.512.409.512-.409 1.117-11.3zM9.003 16.261l-.584 1.068.584 1.07 2.295-.38.47-.69-.47-.671zm5.996 0-2.295.397-.47.671.47.69 2.295.38.584-1.07zm-2.998 1.488-.51.444-.281 2.168.789.55.793-.55-.295-2.168z"/></svg>
|
||||
|
After Width: | Height: | Size: 952 B |
@@ -0,0 +1,2 @@
|
||||
<!-- lutris — from Simple Icons (CC0 1.0), slug `lutris`. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000000"><path d="m21.231 18.89.001-.002c-1.293 3.243-5.218 5.232-9.447 5.105C5.3 23.993 0 18.48 0 11.906S5.276.001 11.785.001c1.793 0 3.493.406 5.015 1.13.081-.177.271-.544.451-.557.238-.017.374.137.526.309.154.172.46.429.46.429s1.393-.481 2.955.377c1.563.858 1.783 1.116 2.09 1.716.152.301.195.829.2 1.282a.796.796 0 0 0-.07-.003c-.496 0-.96.455-.96 1.08 0 .263.082.496.215.678l-.01.007a1.505 1.505 0 0 0-.132.01 18.704 18.704 0 0 0-.389-.142 2.53 2.53 0 0 1-.82-.472 1.402 1.402 0 0 0-1.196-2.112c-.383 0-.73.156-.982.41-.472-.271-1.174-.482-2.527-.565l-.407-.011c-2.282.012-3.611.279-5.979 1.301-.603.283-1.206.615-1.785 1.001-.423.3-.639.67-.709 1.137a1.326 1.326 0 0 0 1.23 1.373h.042c1.27.06 2.039 1.99 2.063 2.497.004.05.004.023.003.08-.032.727-.37 1.267-1.088 1.246a1.231 1.231 0 0 1-.976-.494c-.063-.077-.103-.172-.159-.254-.666-1.081-1.732-1.36-2.771-1.523-.438-.068-1.073-.122-1.31.25a8.28 8.28 0 0 0-.577 3.063c-.02 5.036 4.041 9.118 9.026 9.118 2.575 0 5.349-.952 6.993-2.7l-.035.03c-1.772 1.473-4.66 1.941-6.027 1.941-4.302 0-7.818-3.232-7.818-7.578 0-1.276.288-2.396.814-3.36.495.183.947.483 1.28 1.022a.24.24 0 0 0 .013.021c.064.092.111.197.182.284.424.524.881.658 1.342.68h.01c.43.013.768-.12 1.024-.342.347-.3.55-.79.577-1.382v-.014c.002-.085 0-.053-.004-.112-.024-.376-.333-1.318-.906-2.027-.266-.331-.587-.607-.95-.774l.12-.074c.756-.457 2.364-.977 4.592-.638 1.13.173 2.055.419 3.483.879 1.657.534 2.579 1.279 3.854 1.427.15.017.301.018.45.003.41 1.129.634 2.35.634 3.621 0 2.068-.59 3.995-1.611 5.62zm1.947-12.274s-.115.201-.364.322c-.103.05-.282-.075-.45.1-.359.726.516 1.332.923 1.315.408-.017.73-.432.712-.793-.017-.558-.82-.944-.82-.944zm.234-1.432c.255 0 .462.26.462.58 0 .32-.207.58-.462.58-.254 0-.46-.26-.46-.58 0-.32.206-.58.46-.58zm-3.292-.951c.492 0 .89.403.89.9a.895.895 0 0 1-.89.898.895.895 0 0 1-.89-.899c0-.496.399-.899.89-.899z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,2 @@
|
||||
<!-- playnite — from JosefNemec/Playnite media/playnite-logo-black.svg (MIT). See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="#000000"><path d="M966.686,623.899c-9.773-81.666-29.323-161.25-54.514-239.447c-13.759-42.709-30.419-84.189-56.091-121.452 c-31.701-46.014-74.789-72.958-130.812-78.579c-29.631-2.973-57.785,4.118-85.677,12.35 c-61.172,18.056-123.359,25.124-186.493,14.903c-30.919-5.006-61.308-13.526-91.743-21.225 c-76.445-19.338-145.323,4.995-191.165,69.261c-11.441,16.04-21.194,33.543-29.78,51.312 c-25.091,51.925-40.443,107.249-54.53,162.924c-18.822,74.393-33.019,149.491-33.664,226.571c0,7.184-0.342,14.386,0.061,21.547 c1.557,27.727,4.354,55.289,16.045,80.97c15.334,33.68,45.905,46.725,79.471,31.198c18.291-8.461,36.293-19.857,50.766-33.743 c24.597-23.598,46.616-49.934,69.125-75.64c17.934-20.481,39.086-35.301,66.115-40.203c15.779-2.862,31.802-6.006,47.736-6.118 c87.888-0.62,175.783-0.602,263.673-0.278c51.4,0.189,93.314,19.382,124.091,62.134c12.518,17.388,27.83,32.889,42.78,48.371 c18.598,19.259,38.974,36.431,64.412,46.39c32.967,12.907,62.547,1.677,77.882-30.198c3.965-8.242,6.963-17.122,9.155-26.017 C976.198,727.534,972.874,675.607,966.686,623.899z M315.471,527.643c-44.289,0.213-80.733-36.32-80.847-81.045 c-0.115-45.048,35.472-81.194,80.197-81.458c44.521-0.263,80.718,35.897,80.884,80.801 C395.871,490.671,359.773,527.429,315.471,527.643z M708.857,319.301c21.859,0.06,39.486,17.884,39.471,39.91 c-0.015,22.133-17.489,39.677-39.523,39.682c-22.045,0.005-39.456-17.53-39.444-39.724 C669.372,337.125,687.089,319.241,708.857,319.301z M622.269,486.36c-21.542,0.085-39.7-18.08-39.808-39.822 c-0.108-21.888,17.617-39.622,39.62-39.641c22.066-0.018,39.759,17.552,39.718,39.442 C661.758,468.205,643.909,486.275,622.269,486.36z M708.967,573.333c-21.823,0.096-39.537-17.668-39.611-39.721 c-0.074-22.079,17.523-39.992,39.338-40.044c21.715-0.052,39.597,17.908,39.645,39.816 C748.386,555.477,730.883,573.237,708.967,573.333z M795.752,486.362c-21.764,0.155-39.671-17.882-39.651-39.938 c0.021-22.15,17.628-39.639,39.793-39.525c22.091,0.114,39.527,17.993,39.155,40.152 C834.686,468.733,817.216,486.209,795.752,486.362z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,2 @@
|
||||
<!-- steam — from Font Awesome Free 5 brands (CC BY 4.0); the same mark as assets/os-icons/steam.svg. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512" fill="#000000"><path d="M496 256c0 137-111.2 248-248.4 248-113.8 0-209.6-76.3-239-180.4l95.2 39.3c6.4 32.1 34.9 56.4 68.9 56.4 39.2 0 71.9-32.4 70.2-73.5l84.5-60.2c52.1 1.3 95.8-40.9 95.8-93.5 0-51.6-42-93.5-93.7-93.5s-93.7 42-93.7 93.5v1.2L176.6 279c-15.5-.9-30.7 3.4-43.5 12.1L0 236.1C10.2 108.4 117.1 8 247.6 8 384.8 8 496 119 496 256zM155.7 384.3l-30.5-12.6a52.79 52.79 0 0 0 27.2 25.8c26.9 11.2 57.8-1.6 69-28.4 5.4-13 5.5-27.3.1-40.3-5.4-13-15.5-23.2-28.5-28.6-12.9-5.4-26.7-5.2-38.9-.6l31.5 13c19.8 8.2 29.2 30.9 20.9 50.7-8.3 19.9-31 29.2-50.8 21zm173.8-129.9c-34.4 0-62.4-28-62.4-62.3s28-62.3 62.4-62.3 62.4 28 62.4 62.3-27.9 62.3-62.4 62.3zm.1-15.6c25.9 0 46.9-21 46.9-46.8 0-25.9-21-46.8-46.9-46.8s-46.9 21-46.9 46.8c.1 25.8 21.1 46.8 46.9 46.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 951 B |
@@ -0,0 +1,2 @@
|
||||
<!-- xbox — from Font Awesome Free 6 brands (CC BY 4.0), `fa-xbox`. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="#000000"><path d="M369.9 318.2c44.3 54.3 64.7 98.8 54.4 118.7-7.9 15.1-56.7 44.6-92.6 55.9-29.6 9.3-68.4 13.3-100.4 10.2-38.2-3.7-76.9-17.4-110.1-39-27.9-18.2-34.2-25.7-34.2-40.6 0-29.9 32.9-82.3 89.2-142.1 32-33.9 76.5-73.7 81.4-72.6 9.4 2.1 84.3 75.1 112.3 109.5zM188.6 143.8c-29.7-26.9-58.1-53.9-86.4-63.4-15.2-5.1-16.3-4.8-28.7 8.1-29.2 30.4-53.5 79.7-60.3 122.4-5.4 34.2-6.1 43.8-4.2 60.5 5.6 50.5 17.3 85.4 40.5 120.9 9.5 14.6 12.1 17.3 9.3 9.9-4.2-11-.3-37.5 9.5-64 14.3-39 53.9-112.9 120.3-194.4zm311.6 63.5c-16.9-80-67.5-130.3-74.6-130.3-7.3 0-24.2 6.5-36 13.9-23.3 14.5-41 31.4-64.3 52.8 42.4 53.3 102.2 139.4 122.9 202.3 6.8 20.7 9.7 41.1 7.4 52.3-1.7 8.5-1.7 8.5 1.4 4.6 6.1-7.7 19.9-31.3 25.4-43.5 7.4-16.2 15-40.2 18.6-58.7 4.3-22.5 3.9-70.8-.8-93.4zM141.3 43c47.7-2.5 109.7 34.5 114.3 35.4 .7 .1 10.4-4.2 21.6-9.7 63.9-31.1 94-25.8 107.4-25.2-63.9-39.3-152.7-50-233.9-11.7-23.4 11.1-24 11.9-9.4 11.2z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -1,9 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- The shell's embedded icon assets: the host-card OS marks (derived from the
|
||||
assets/os-icons masters; see that directory's README for provenance/licensing).
|
||||
assets/os-icons masters) and the library's launcher-tile brand marks (from the
|
||||
assets/launcher-icons masters). See each directory's README for provenance/licensing.
|
||||
Registered under the hicolor-style layout IconTheme::add_resource_path expects. -->
|
||||
<gresources>
|
||||
<gresource prefix="/io/unom/Punktfunk">
|
||||
<file>icons/scalable/actions/pf-launcher-steam-symbolic.svg</file>
|
||||
<file>icons/scalable/actions/pf-launcher-lutris-symbolic.svg</file>
|
||||
<file>icons/scalable/actions/pf-launcher-heroic-symbolic.svg</file>
|
||||
<file>icons/scalable/actions/pf-launcher-playnite-symbolic.svg</file>
|
||||
<file>icons/scalable/actions/pf-launcher-epic-symbolic.svg</file>
|
||||
<file>icons/scalable/actions/pf-launcher-gog-symbolic.svg</file>
|
||||
<file>icons/scalable/actions/pf-launcher-xbox-symbolic.svg</file>
|
||||
<file>icons/scalable/actions/pf-os-windows-symbolic.svg</file>
|
||||
<file>icons/scalable/actions/pf-os-apple-symbolic.svg</file>
|
||||
<file>icons/scalable/actions/pf-os-linux-symbolic.svg</file>
|
||||
|
||||
@@ -66,6 +66,9 @@ const CSS: &str = "
|
||||
named instead of a title monogram, and an accent badge. */
|
||||
.pf-poster.pf-launcher { background: alpha(@accent_color, 0.18); }
|
||||
.pf-poster-launcher-name { font-size: 1.15em; font-weight: bold; color: alpha(currentColor, 0.85); }
|
||||
/* The brand mark when we ship one for this launcher — same ink as the name it replaces, so the
|
||||
two fallback rungs read as one design rather than two. */
|
||||
.pf-poster-launcher-mark { color: alpha(currentColor, 0.85); }
|
||||
.pf-store-badge.pf-launcher { color: white; background: @accent_color; }
|
||||
.pf-group-heading { font-size: 0.8em; font-weight: bold; color: alpha(currentColor, 0.55); }
|
||||
";
|
||||
|
||||
@@ -782,6 +782,7 @@ fn mock_library() -> (
|
||||
art: crate::library::Artwork::default(),
|
||||
platform: None,
|
||||
role: None,
|
||||
icon: None,
|
||||
};
|
||||
let games = vec![
|
||||
game("steam:570", "steam", "Dota 2"),
|
||||
|
||||
@@ -285,27 +285,61 @@ fn render(state: &Rc<State>, games: &[GameEntry]) {
|
||||
.set_visible(!launchers.is_empty() && !titles.is_empty());
|
||||
}
|
||||
|
||||
/// The launcher-tile brand marks this shell ships symbolic art for
|
||||
/// (`data/icons/.../pf-launcher-<t>-symbolic.svg`, embedded via gresource). A plugin may name a
|
||||
/// mark a newer build carries; an entry whose token isn't here falls back to the launcher's name,
|
||||
/// which is exactly how every launcher tile looked before icons existed.
|
||||
const LAUNCHER_ICON_TOKENS: &[&str] = &[
|
||||
"steam", "lutris", "heroic", "playnite", "epic", "gog", "xbox",
|
||||
];
|
||||
|
||||
/// The poster-sized brand mark for an entry, or `None` when it carries no token, names one we
|
||||
/// don't ship, or already has real artwork (a plugin that sent a cover has out-voted the token).
|
||||
///
|
||||
/// Symbolic, so it recolors with the Adwaita theme like every other glyph in the shell.
|
||||
fn launcher_icon_image(game: &GameEntry) -> Option<gtk::Image> {
|
||||
if !game.art.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let token = game.icon_token()?;
|
||||
if !LAUNCHER_ICON_TOKENS.contains(&token) {
|
||||
return None;
|
||||
}
|
||||
let img = gtk::Image::from_icon_name(&format!("pf-launcher-{token}-symbolic"));
|
||||
img.set_pixel_size(72);
|
||||
img.add_css_class("pf-poster-launcher-mark");
|
||||
img.set_halign(gtk::Align::Center);
|
||||
img.set_valign(gtk::Align::Center);
|
||||
img.set_vexpand(true);
|
||||
Some(img)
|
||||
}
|
||||
|
||||
/// One poster tile: 2:3 art (~150×225 logical) over the title, with a store badge and a
|
||||
/// monogram placeholder underneath the async art. Activation starts a session launching
|
||||
/// this title (silent on a pinned host — the normal trust gate applies).
|
||||
fn game_card(state: &Rc<State>, game: &GameEntry) -> gtk::FlowBoxChild {
|
||||
// A launcher usually ships no poster. Naming the launcher on an accent face says "opens
|
||||
// Steam"; a title monogram on the neutral face would say "a game whose cover didn't load".
|
||||
// A launcher usually ships no poster. Its brand mark, when we ship one, IS the poster; failing
|
||||
// that, naming the launcher on an accent face says "opens Steam". A title monogram on the
|
||||
// neutral face would say "a game whose cover didn't load", which is why games keep it.
|
||||
let launcher = game.is_launcher();
|
||||
let monogram = if launcher {
|
||||
let l = gtk::Label::new(Some(store_label(&game.store)));
|
||||
l.add_css_class("pf-poster-launcher-name");
|
||||
l
|
||||
} else {
|
||||
let l = gtk::Label::new(Some(&initials(&game.title)));
|
||||
l.add_css_class("pf-poster-monogram");
|
||||
l
|
||||
};
|
||||
monogram.set_halign(gtk::Align::Center);
|
||||
monogram.set_valign(gtk::Align::Center);
|
||||
let placeholder = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
placeholder.append(&monogram);
|
||||
monogram.set_vexpand(true);
|
||||
if let Some(mark) = launcher_icon_image(game) {
|
||||
placeholder.append(&mark);
|
||||
} else {
|
||||
let monogram = if launcher {
|
||||
let l = gtk::Label::new(Some(store_label(&game.store)));
|
||||
l.add_css_class("pf-poster-launcher-name");
|
||||
l
|
||||
} else {
|
||||
let l = gtk::Label::new(Some(&initials(&game.title)));
|
||||
l.add_css_class("pf-poster-monogram");
|
||||
l
|
||||
};
|
||||
monogram.set_halign(gtk::Align::Center);
|
||||
monogram.set_valign(gtk::Align::Center);
|
||||
monogram.set_vexpand(true);
|
||||
placeholder.append(&monogram);
|
||||
}
|
||||
|
||||
let pic = gtk::Picture::new();
|
||||
pic.set_content_fit(gtk::ContentFit::Cover);
|
||||
|
||||
@@ -859,6 +859,7 @@ fn spawn_fetch(
|
||||
title: g.title.clone(),
|
||||
store: g.store.clone(),
|
||||
launcher: g.is_launcher(),
|
||||
icon: g.icon_token().unwrap_or_default().to_string(),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
@@ -900,6 +901,7 @@ fn load_fake(shared: &LibraryShared, path: &str) {
|
||||
title: g.title.clone(),
|
||||
store: g.store.clone(),
|
||||
launcher: g.is_launcher(),
|
||||
icon: g.icon_token().unwrap_or_default().to_string(),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
@@ -0,0 +1,71 @@
|
||||
//! The library's launcher-tile brand marks. Reactor's `ImageSource` is `file:///`-URI raster
|
||||
//! only (no vector element, no icon font with brand glyphs), so the monochrome PNGs under
|
||||
//! `assets/launchers/` (mid-gray — legible on both WinUI themes; derived from the
|
||||
//! `assets/launcher-icons` masters, see that README for provenance/licensing) are embedded in
|
||||
//! the exe and materialized once into `%LOCALAPPDATA%\punktfunk\launcher-icons\`.
|
||||
//!
|
||||
//! The same disk-cache-to-URI pattern as [`super::os_icons`], and for the same reason — but
|
||||
//! baked much taller (128 px vs 32), because this mark fills a poster tile rather than sitting
|
||||
//! in a status row.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Embedded PNG per icon token. A plugin may name a mark a newer build ships; a tile whose token
|
||||
/// isn't here falls back to naming its launcher, which is how every launcher tile looked before
|
||||
/// icons existed.
|
||||
const ICONS: &[(&str, &[u8])] = &[
|
||||
("steam", include_bytes!("../../assets/launchers/steam.png")),
|
||||
(
|
||||
"lutris",
|
||||
include_bytes!("../../assets/launchers/lutris.png"),
|
||||
),
|
||||
(
|
||||
"heroic",
|
||||
include_bytes!("../../assets/launchers/heroic.png"),
|
||||
),
|
||||
(
|
||||
"playnite",
|
||||
include_bytes!("../../assets/launchers/playnite.png"),
|
||||
),
|
||||
("epic", include_bytes!("../../assets/launchers/epic.png")),
|
||||
("gog", include_bytes!("../../assets/launchers/gog.png")),
|
||||
("xbox", include_bytes!("../../assets/launchers/xbox.png")),
|
||||
];
|
||||
|
||||
fn dir() -> Option<PathBuf> {
|
||||
let base = std::env::var_os("LOCALAPPDATA")?;
|
||||
Some(PathBuf::from(base).join("punktfunk").join("launcher-icons"))
|
||||
}
|
||||
|
||||
/// Materialize the embedded PNGs to disk (idempotent; size mismatch rewrites, so an icon refresh
|
||||
/// in a newer build lands). Called once at GUI startup, before any tile renders.
|
||||
pub fn install() {
|
||||
let Some(dir) = dir() else { return };
|
||||
if std::fs::create_dir_all(&dir).is_err() {
|
||||
return; // tiles just render without the mark
|
||||
}
|
||||
for (token, bytes) in ICONS {
|
||||
let p = dir.join(format!("{token}.png"));
|
||||
let fresh = std::fs::metadata(&p)
|
||||
.map(|m| m.len() != bytes.len() as u64)
|
||||
.unwrap_or(true);
|
||||
if fresh {
|
||||
let _ = std::fs::write(&p, bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `file:///` URI of the mark for an entry's `icon` token, or `None` — draw the launcher's
|
||||
/// name instead — when the entry carries no token or names one we ship no art for.
|
||||
///
|
||||
/// The token is matched against [`ICONS`] before it reaches a path join, so nothing a host sends
|
||||
/// can steer this at a file of its choosing.
|
||||
pub fn uri(token: Option<&str>) -> Option<String> {
|
||||
static DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
|
||||
let dir = DIR.get_or_init(dir).as_ref()?;
|
||||
let token = token.filter(|t| ICONS.iter().any(|(name, _)| name == t))?;
|
||||
let p = dir.join(format!("{token}.png"));
|
||||
p.exists()
|
||||
.then(|| format!("file:///{}", p.display().to_string().replace('\\', "/")))
|
||||
}
|
||||
@@ -43,6 +43,10 @@ pub(crate) struct Game {
|
||||
/// design D4. Reduced from the wire's `role` by `GameEntry::is_launcher`, so "anything that
|
||||
/// isn't `launcher` is a game" is decided in one place for every client.
|
||||
pub(crate) launcher: bool,
|
||||
/// The `file:///` URI of this entry's brand mark, already resolved by
|
||||
/// [`super::launcher_icons::uri`] — `None` when the entry names no mark or one we don't ship.
|
||||
/// Resolved at decode time rather than per render: the shelf re-renders on every art arrival.
|
||||
pub(crate) icon_uri: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Default)]
|
||||
@@ -140,6 +144,7 @@ pub(crate) fn start_fetch(ctx: &Arc<AppCtx>, set_library: &AsyncSetState<Library
|
||||
title: g.title.clone(),
|
||||
store: g.store.clone(),
|
||||
launcher: g.is_launcher(),
|
||||
icon_uri: super::launcher_icons::uri(g.icon_token()),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
@@ -245,23 +250,38 @@ fn poster_tile(
|
||||
.height(poster_h)
|
||||
.into(),
|
||||
// A launcher rarely has poster art, and an art-less launcher drawn like an art-less game
|
||||
// reads as "a game whose cover failed to load". So it names its launcher instead of
|
||||
// showing a title monogram, and the frame below picks up the accent stroke.
|
||||
None => border(
|
||||
text_block(if game.launcher {
|
||||
store_label(&game.store).to_string()
|
||||
} else {
|
||||
initials(&game.title)
|
||||
})
|
||||
.font_size(if game.launcher { 18.0 } else { 28.0 })
|
||||
.semibold()
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.horizontal_alignment(HorizontalAlignment::Center)
|
||||
.vertical_alignment(VerticalAlignment::Center),
|
||||
)
|
||||
.background(ThemeRef::SubtleFill)
|
||||
.height(poster_h)
|
||||
.into(),
|
||||
// reads as "a game whose cover failed to load". Its brand mark, when we ship one, IS the
|
||||
// poster; failing that it names its launcher. Either way the frame below picks up the
|
||||
// accent stroke.
|
||||
//
|
||||
// `Uniform`, not `UniformToFill`: the marks keep their masters' aspect ratios (Steam is
|
||||
// 496x512, Playnite 1024x1024), and filling a 2:3 frame would crop them to a strip — the
|
||||
// very thing that kept launcher tiles art-less in the first place.
|
||||
None => match game.icon_uri.as_deref() {
|
||||
Some(uri) => border(
|
||||
Image::new_with_uri(uri)
|
||||
.stretch(Stretch::Uniform)
|
||||
.margin(uniform(poster_h * 0.28)),
|
||||
)
|
||||
.background(ThemeRef::SubtleFill)
|
||||
.height(poster_h)
|
||||
.into(),
|
||||
None => border(
|
||||
text_block(if game.launcher {
|
||||
store_label(&game.store).to_string()
|
||||
} else {
|
||||
initials(&game.title)
|
||||
})
|
||||
.font_size(if game.launcher { 18.0 } else { 28.0 })
|
||||
.semibold()
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.horizontal_alignment(HorizontalAlignment::Center)
|
||||
.vertical_alignment(VerticalAlignment::Center),
|
||||
)
|
||||
.background(ThemeRef::SubtleFill)
|
||||
.height(poster_h)
|
||||
.into(),
|
||||
},
|
||||
};
|
||||
let framed = border(grid(vec![
|
||||
poster,
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
mod connect;
|
||||
mod help;
|
||||
mod hosts;
|
||||
mod launcher_icons;
|
||||
mod library;
|
||||
mod licenses;
|
||||
mod os_icons;
|
||||
@@ -186,8 +187,10 @@ pub struct AppCtx {
|
||||
}
|
||||
|
||||
pub fn run(identity: (String, String), gamepad: GamepadService) -> windows_reactor::Result<()> {
|
||||
// The host tiles' OS marks load as file:/// URIs — put the embedded PNGs on disk first.
|
||||
// The host tiles' OS marks and the library's launcher marks load as file:/// URIs — put the
|
||||
// embedded PNGs on disk first.
|
||||
os_icons::install();
|
||||
launcher_icons::install();
|
||||
let ctx = Arc::new(AppCtx {
|
||||
identity,
|
||||
settings: Mutex::new(Settings::load()),
|
||||
|
||||
@@ -47,6 +47,13 @@ impl Artwork {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether this entry has no poster art at all — the condition a launcher's brand mark stands
|
||||
/// in for. Separate from `poster_candidates` so a caller asking the question doesn't have to
|
||||
/// invent a `base` it has no use for.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.portrait.is_none() && self.header.is_none() && self.hero.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// One title in the host's unified library. `id` is store-qualified (`steam:<appid>`,
|
||||
@@ -73,6 +80,17 @@ pub struct GameEntry {
|
||||
/// future value must never fail the whole library decode.
|
||||
#[serde(default)]
|
||||
pub role: Option<String>,
|
||||
/// Which brand mark to draw for this entry — `"steam"`, `"heroic"`, `"playnite"` — or `None`
|
||||
/// when the host sent none (every older host, and every ordinary title).
|
||||
///
|
||||
/// A **token**, not art: the shell resolves it against the marks it ships
|
||||
/// (`assets/launcher-icons`) and falls back to naming the launcher for one it doesn't have, so
|
||||
/// a plugin can name a mark this client has never heard of without breaking the tile. The host
|
||||
/// guarantees the slug shape (`[a-z][a-z0-9-]{0,31}`), which is what makes it safe to
|
||||
/// interpolate into a resource name or an asset lookup — but see [`GameEntry::icon_token`],
|
||||
/// which re-checks rather than trusting it.
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
impl GameEntry {
|
||||
@@ -80,6 +98,23 @@ impl GameEntry {
|
||||
pub fn is_launcher(&self) -> bool {
|
||||
self.role.as_deref() == Some("launcher")
|
||||
}
|
||||
|
||||
/// The brand-icon token, re-validated here rather than taken on trust.
|
||||
///
|
||||
/// The host validates the shape on the way in, so this can only fire for a host that is older
|
||||
/// than that check, compromised, or simply not ours. Every caller interpolates the result into
|
||||
/// a resource name (`pf-launcher-{t}-symbolic`), an asset-catalog lookup or a file path, and
|
||||
/// "the peer promised" is not the standard those deserve — a client re-checks what it is about
|
||||
/// to concatenate. Cheap enough to do at the call site.
|
||||
pub fn icon_token(&self) -> Option<&str> {
|
||||
let t = self.icon.as_deref()?;
|
||||
let ok = !t.is_empty()
|
||||
&& t.len() <= 32
|
||||
&& t.starts_with(|c: char| c.is_ascii_lowercase())
|
||||
&& t.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
|
||||
ok.then_some(t)
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet").
|
||||
|
||||
@@ -34,5 +34,11 @@ sdl3 = { version = "0.18", features = ["hidapi", "ash"] }
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
sdl3 = { version = "0.18", features = ["hidapi", "ash", "build-from-source"] }
|
||||
|
||||
# The shared console parity vectors (`clients/shared/console-vectors.json`) are read by three
|
||||
# tests here — the palette table, the tab names and the transition motion. Dev-only: nothing in the
|
||||
# shipping crate parses JSON. `pf-client-core` reads its own deeplink vectors the same way.
|
||||
[dev-dependencies]
|
||||
serde_json = "1"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! GENERATED by scripts/gen_launcher_icon_tables.py from the assets/launcher-icons masters.
|
||||
//! Do not edit by hand — re-run `bash scripts/gen-launcher-icons.sh` instead.
|
||||
//! Per-mark provenance and licensing: assets/launcher-icons/README.md.
|
||||
//!
|
||||
//! The brand mark a `role: "launcher"` tile draws, resolved from the entry's `icon` token.
|
||||
//! Skia parses SVG path data directly, so the masters need no transcription into a drawing
|
||||
//! DSL — the path string is the asset.
|
||||
|
||||
use skia_safe::{Matrix, Path, Rect};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
/// A parsed mark and the viewport its coordinates are in.
|
||||
type Glyph = (Path, f32, f32);
|
||||
|
||||
/// Token → parsed mark, with `None` memoizing "no such token / did not parse" so a miss is not
|
||||
/// re-attempted every frame. Named because `clippy::type_complexity` rejects it inline, and this
|
||||
/// file is generated — an inline type would fail the `-D warnings` gate on every regeneration.
|
||||
type GlyphCache = HashMap<String, Option<Glyph>>;
|
||||
|
||||
/// `(token, viewport width, viewport height, path data)` — the masters, verbatim.
|
||||
const GLYPHS: &[(&str, f32, f32, &str)] = &[
|
||||
("steam", 496.0, 512.0, "M496 256c0 137-111.2 248-248.4 248-113.8 0-209.6-76.3-239-180.4l95.2 39.3c6.4 32.1 34.9 56.4 68.9 56.4 39.2 0 71.9-32.4 70.2-73.5l84.5-60.2c52.1 1.3 95.8-40.9 95.8-93.5 0-51.6-42-93.5-93.7-93.5s-93.7 42-93.7 93.5v1.2L176.6 279c-15.5-.9-30.7 3.4-43.5 12.1L0 236.1C10.2 108.4 117.1 8 247.6 8 384.8 8 496 119 496 256zM155.7 384.3l-30.5-12.6a52.79 52.79 0 0 0 27.2 25.8c26.9 11.2 57.8-1.6 69-28.4 5.4-13 5.5-27.3.1-40.3-5.4-13-15.5-23.2-28.5-28.6-12.9-5.4-26.7-5.2-38.9-.6l31.5 13c19.8 8.2 29.2 30.9 20.9 50.7-8.3 19.9-31 29.2-50.8 21zm173.8-129.9c-34.4 0-62.4-28-62.4-62.3s28-62.3 62.4-62.3 62.4 28 62.4 62.3-27.9 62.3-62.4 62.3zm.1-15.6c25.9 0 46.9-21 46.9-46.8 0-25.9-21-46.8-46.9-46.8s-46.9 21-46.9 46.8c.1 25.8 21.1 46.8 46.9 46.8z"),
|
||||
("lutris", 24.0, 24.0, "m21.231 18.89.001-.002c-1.293 3.243-5.218 5.232-9.447 5.105C5.3 23.993 0 18.48 0 11.906S5.276.001 11.785.001c1.793 0 3.493.406 5.015 1.13.081-.177.271-.544.451-.557.238-.017.374.137.526.309.154.172.46.429.46.429s1.393-.481 2.955.377c1.563.858 1.783 1.116 2.09 1.716.152.301.195.829.2 1.282a.796.796 0 0 0-.07-.003c-.496 0-.96.455-.96 1.08 0 .263.082.496.215.678l-.01.007a1.505 1.505 0 0 0-.132.01 18.704 18.704 0 0 0-.389-.142 2.53 2.53 0 0 1-.82-.472 1.402 1.402 0 0 0-1.196-2.112c-.383 0-.73.156-.982.41-.472-.271-1.174-.482-2.527-.565l-.407-.011c-2.282.012-3.611.279-5.979 1.301-.603.283-1.206.615-1.785 1.001-.423.3-.639.67-.709 1.137a1.326 1.326 0 0 0 1.23 1.373h.042c1.27.06 2.039 1.99 2.063 2.497.004.05.004.023.003.08-.032.727-.37 1.267-1.088 1.246a1.231 1.231 0 0 1-.976-.494c-.063-.077-.103-.172-.159-.254-.666-1.081-1.732-1.36-2.771-1.523-.438-.068-1.073-.122-1.31.25a8.28 8.28 0 0 0-.577 3.063c-.02 5.036 4.041 9.118 9.026 9.118 2.575 0 5.349-.952 6.993-2.7l-.035.03c-1.772 1.473-4.66 1.941-6.027 1.941-4.302 0-7.818-3.232-7.818-7.578 0-1.276.288-2.396.814-3.36.495.183.947.483 1.28 1.022a.24.24 0 0 0 .013.021c.064.092.111.197.182.284.424.524.881.658 1.342.68h.01c.43.013.768-.12 1.024-.342.347-.3.55-.79.577-1.382v-.014c.002-.085 0-.053-.004-.112-.024-.376-.333-1.318-.906-2.027-.266-.331-.587-.607-.95-.774l.12-.074c.756-.457 2.364-.977 4.592-.638 1.13.173 2.055.419 3.483.879 1.657.534 2.579 1.279 3.854 1.427.15.017.301.018.45.003.41 1.129.634 2.35.634 3.621 0 2.068-.59 3.995-1.611 5.62zm1.947-12.274s-.115.201-.364.322c-.103.05-.282-.075-.45.1-.359.726.516 1.332.923 1.315.408-.017.73-.432.712-.793-.017-.558-.82-.944-.82-.944zm.234-1.432c.255 0 .462.26.462.58 0 .32-.207.58-.462.58-.254 0-.46-.26-.46-.58 0-.32.206-.58.46-.58zm-3.292-.951c.492 0 .89.403.89.9a.895.895 0 0 1-.89.898.895.895 0 0 1-.89-.899c0-.496.399-.899.89-.899z"),
|
||||
("heroic", 24.0, 24.0, "M11.999 0 11.997 0a.891.891 0 0 0-.36.075C8.964 1.253 6.29 2.434 3.618 3.613A.893.893 0 0 0 3.1 4.619l3.146 14.646c.043.197.15.375.307.504l4.88 4.027a.895.895 0 0 0 1.131.006l5-4.031a.895.895 0 0 0 .315-.516L20.9 4.614a.895.895 0 0 0-.515-1L12.358.074A.892.892 0 0 0 12 0zm0 .35v.003c.114 0 .228.023.334.07l7.42 3.27a.827.827 0 0 1 .476.924l-2.793 13.535a.83.83 0 0 1-.289.478l-4.623 3.725a.826.826 0 0 1-1.045-.006l-4.513-3.723a.829.829 0 0 1-.281-.465L3.775 4.622a.83.83 0 0 1 .476-.931L11.665.42a.832.832 0 0 1 .334-.07zm-.045 1.954L10.28 5.202h-.002l1.211 11.301.512.409.512-.409 1.117-11.3zM9.003 16.261l-.584 1.068.584 1.07 2.295-.38.47-.69-.47-.671zm5.996 0-2.295.397-.47.671.47.69 2.295.38.584-1.07zm-2.998 1.488-.51.444-.281 2.168.789.55.793-.55-.295-2.168z"),
|
||||
("playnite", 1024.0, 1024.0, "M966.686,623.899c-9.773-81.666-29.323-161.25-54.514-239.447c-13.759-42.709-30.419-84.189-56.091-121.452 c-31.701-46.014-74.789-72.958-130.812-78.579c-29.631-2.973-57.785,4.118-85.677,12.35 c-61.172,18.056-123.359,25.124-186.493,14.903c-30.919-5.006-61.308-13.526-91.743-21.225 c-76.445-19.338-145.323,4.995-191.165,69.261c-11.441,16.04-21.194,33.543-29.78,51.312 c-25.091,51.925-40.443,107.249-54.53,162.924c-18.822,74.393-33.019,149.491-33.664,226.571c0,7.184-0.342,14.386,0.061,21.547 c1.557,27.727,4.354,55.289,16.045,80.97c15.334,33.68,45.905,46.725,79.471,31.198c18.291-8.461,36.293-19.857,50.766-33.743 c24.597-23.598,46.616-49.934,69.125-75.64c17.934-20.481,39.086-35.301,66.115-40.203c15.779-2.862,31.802-6.006,47.736-6.118 c87.888-0.62,175.783-0.602,263.673-0.278c51.4,0.189,93.314,19.382,124.091,62.134c12.518,17.388,27.83,32.889,42.78,48.371 c18.598,19.259,38.974,36.431,64.412,46.39c32.967,12.907,62.547,1.677,77.882-30.198c3.965-8.242,6.963-17.122,9.155-26.017 C976.198,727.534,972.874,675.607,966.686,623.899z M315.471,527.643c-44.289,0.213-80.733-36.32-80.847-81.045 c-0.115-45.048,35.472-81.194,80.197-81.458c44.521-0.263,80.718,35.897,80.884,80.801 C395.871,490.671,359.773,527.429,315.471,527.643z M708.857,319.301c21.859,0.06,39.486,17.884,39.471,39.91 c-0.015,22.133-17.489,39.677-39.523,39.682c-22.045,0.005-39.456-17.53-39.444-39.724 C669.372,337.125,687.089,319.241,708.857,319.301z M622.269,486.36c-21.542,0.085-39.7-18.08-39.808-39.822 c-0.108-21.888,17.617-39.622,39.62-39.641c22.066-0.018,39.759,17.552,39.718,39.442 C661.758,468.205,643.909,486.275,622.269,486.36z M708.967,573.333c-21.823,0.096-39.537-17.668-39.611-39.721 c-0.074-22.079,17.523-39.992,39.338-40.044c21.715-0.052,39.597,17.908,39.645,39.816 C748.386,555.477,730.883,573.237,708.967,573.333z M795.752,486.362c-21.764,0.155-39.671-17.882-39.651-39.938 c0.021-22.15,17.628-39.639,39.793-39.525c22.091,0.114,39.527,17.993,39.155,40.152 C834.686,468.733,817.216,486.209,795.752,486.362z"),
|
||||
("epic", 24.0, 24.0, "M3.537 0C2.165 0 1.66.506 1.66 1.879V18.44a4.262 4.262 0 00.02.433c.031.3.037.59.316.92.027.033.311.245.311.245.153.075.258.13.43.2l8.335 3.491c.433.199.614.276.928.27h.002c.314.006.495-.071.928-.27l8.335-3.492c.172-.07.277-.124.43-.2 0 0 .284-.211.311-.243.28-.33.285-.621.316-.92a4.261 4.261 0 00.02-.434V1.879c0-1.373-.506-1.88-1.878-1.88zm13.366 3.11h.68c1.138 0 1.688.553 1.688 1.696v1.88h-1.374v-1.8c0-.369-.17-.54-.523-.54h-.235c-.367 0-.537.17-.537.539v5.81c0 .369.17.54.537.54h.262c.353 0 .523-.171.523-.54V8.619h1.373v2.143c0 1.144-.562 1.71-1.7 1.71h-.694c-1.138 0-1.7-.566-1.7-1.71V4.82c0-1.144.562-1.709 1.7-1.709zm-12.186.08h3.114v1.274H6.117v2.603h1.648v1.275H6.117v2.774h1.74v1.275h-3.14zm3.816 0h2.198c1.138 0 1.7.564 1.7 1.708v2.445c0 1.144-.562 1.71-1.7 1.71h-.799v3.338h-1.4zm4.53 0h1.4v9.201h-1.4zm-3.13 1.235v3.392h.575c.354 0 .523-.171.523-.54V4.965c0-.368-.17-.54-.523-.54zm-3.74 10.147a1.708 1.708 0 01.591.108 1.745 1.745 0 01.49.299l-.452.546a1.247 1.247 0 00-.308-.195.91.91 0 00-.363-.068.658.658 0 00-.28.06.703.703 0 00-.224.163.783.783 0 00-.151.243.799.799 0 00-.056.299v.008a.852.852 0 00.056.31.7.7 0 00.157.245.736.736 0 00.238.16.774.774 0 00.303.058.79.79 0 00.445-.116v-.339h-.548v-.565H7.37v1.255a2.019 2.019 0 01-.524.307 1.789 1.789 0 01-.683.123 1.642 1.642 0 01-.602-.107 1.46 1.46 0 01-.478-.3 1.371 1.371 0 01-.318-.455 1.438 1.438 0 01-.115-.58v-.008a1.426 1.426 0 01.113-.57 1.449 1.449 0 01.312-.46 1.418 1.418 0 01.474-.309 1.58 1.58 0 01.598-.111 1.708 1.708 0 01.045 0zm11.963.008a2.006 2.006 0 01.612.094 1.61 1.61 0 01.507.277l-.386.546a1.562 1.562 0 00-.39-.205 1.178 1.178 0 00-.388-.07.347.347 0 00-.208.052.154.154 0 00-.07.127v.008a.158.158 0 00.022.084.198.198 0 00.076.066.831.831 0 00.147.06c.062.02.14.04.236.061a3.389 3.389 0 01.43.122 1.292 1.292 0 01.328.17.678.678 0 01.207.24.739.739 0 01.071.337v.008a.865.865 0 01-.081.382.82.82 0 01-.229.285 1.032 1.032 0 01-.353.18 1.606 1.606 0 01-.46.061 2.16 2.16 0 01-.71-.116 1.718 1.718 0 01-.593-.346l.43-.514c.277.223.578.335.9.335a.457.457 0 00.236-.05.157.157 0 00.082-.142v-.008a.15.15 0 00-.02-.077.204.204 0 00-.073-.066.753.753 0 00-.143-.062 2.45 2.45 0 00-.233-.062 5.036 5.036 0 01-.413-.113 1.26 1.26 0 01-.331-.16.72.72 0 01-.222-.243.73.73 0 01-.082-.36v-.008a.863.863 0 01.074-.359.794.794 0 01.214-.283 1.007 1.007 0 01.34-.185 1.423 1.423 0 01.448-.066 2.006 2.006 0 01.025 0zm-9.358.025h.742l1.183 2.81h-.825l-.203-.499H8.623l-.198.498h-.81zm2.197.02h.814l.663 1.08.663-1.08h.814v2.79h-.766v-1.602l-.711 1.091h-.016l-.707-1.083v1.593h-.754zm3.469 0h2.235v.658h-1.473v.422h1.334v.61h-1.334v.442h1.493v.658h-2.255zm-5.3.897l-.315.793h.624zm-1.145 5.19h8.014l-4.09 1.348z"),
|
||||
("gog", 24.0, 24.0, "M7.15 15.24H4.36a.4.4 0 0 0-.4.4v2c0 .21.18.4.4.4h2.8v1.32h-3.5c-.56 0-1.02-.46-1.02-1.03v-3.39c0-.56.46-1.02 1.03-1.02h3.48v1.32zM8.16 11.54c0 .58-.47 1.05-1.05 1.05H2.63v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4H4.39a.4.4 0 0 0-.41.4v2.02c0 .23.18.4.4.4H6v1.35H3.68c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04H7.1c.58 0 1.05.47 1.05 1.04v5.86zM21.36 19.36h-1.32v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.42c0-.56.46-1.02 1.03-1.02h5.61v5.44zM21.37 11.54c0 .58-.47 1.05-1.05 1.05h-4.48v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4h-2.03a.4.4 0 0 0-.4.4v2.02c0 .23.18.4.4.4h1.62v1.35H16.9c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04h3.43c.58 0 1.05.47 1.05 1.04v5.86zM13.72 4.64h-3.44c-.58 0-1.04.47-1.04 1.04v3.44c0 .58.46 1.04 1.04 1.04h3.44c.57 0 1.04-.46 1.04-1.04V5.68c0-.57-.47-1.04-1.04-1.04m-.3 1.75v2.02a.4.4 0 0 1-.4.4h-2.03a.4.4 0 0 1-.4-.4V6.4c0-.22.17-.4.4-.4H13c.23 0 .4.18.4.4zM12.63 13.92H9.24c-.57 0-1.03.46-1.03 1.02v3.39c0 .57.46 1.03 1.03 1.03h3.39c.57 0 1.03-.46 1.03-1.03v-3.39c0-.56-.46-1.02-1.03-1.02m-.3 1.72v2a.4.4 0 0 1-.4.4v-.01H9.94a.4.4 0 0 1-.4-.4v-1.99c0-.22.18-.4.4-.4h2c.22 0 .4.18.4.4zM23.49 1.1a1.74 1.74 0 0 0-1.24-.52H1.75A1.74 1.74 0 0 0 0 2.33v19.34a1.74 1.74 0 0 0 1.75 1.75h20.5A1.74 1.74 0 0 0 24 21.67V2.33c0-.48-.2-.92-.51-1.24m0 20.58a1.23 1.23 0 0 1-1.24 1.24H1.75A1.23 1.23 0 0 1 .5 21.67V2.33a1.23 1.23 0 0 1 1.24-1.24h20.5a1.24 1.24 0 0 1 1.24 1.24v19.34z"),
|
||||
("xbox", 512.0, 512.0, "M369.9 318.2c44.3 54.3 64.7 98.8 54.4 118.7-7.9 15.1-56.7 44.6-92.6 55.9-29.6 9.3-68.4 13.3-100.4 10.2-38.2-3.7-76.9-17.4-110.1-39-27.9-18.2-34.2-25.7-34.2-40.6 0-29.9 32.9-82.3 89.2-142.1 32-33.9 76.5-73.7 81.4-72.6 9.4 2.1 84.3 75.1 112.3 109.5zM188.6 143.8c-29.7-26.9-58.1-53.9-86.4-63.4-15.2-5.1-16.3-4.8-28.7 8.1-29.2 30.4-53.5 79.7-60.3 122.4-5.4 34.2-6.1 43.8-4.2 60.5 5.6 50.5 17.3 85.4 40.5 120.9 9.5 14.6 12.1 17.3 9.3 9.9-4.2-11-.3-37.5 9.5-64 14.3-39 53.9-112.9 120.3-194.4zm311.6 63.5c-16.9-80-67.5-130.3-74.6-130.3-7.3 0-24.2 6.5-36 13.9-23.3 14.5-41 31.4-64.3 52.8 42.4 53.3 102.2 139.4 122.9 202.3 6.8 20.7 9.7 41.1 7.4 52.3-1.7 8.5-1.7 8.5 1.4 4.6 6.1-7.7 19.9-31.3 25.4-43.5 7.4-16.2 15-40.2 18.6-58.7 4.3-22.5 3.9-70.8-.8-93.4zM141.3 43c47.7-2.5 109.7 34.5 114.3 35.4 .7 .1 10.4-4.2 21.6-9.7 63.9-31.1 94-25.8 107.4-25.2-63.9-39.3-152.7-50-233.9-11.7-23.4 11.1-24 11.9-9.4 11.2z"),
|
||||
];
|
||||
|
||||
/// The parsed path for a token plus the viewport it was authored in, or `None` when the token is
|
||||
/// absent, unknown, or (defensively) unparseable — the tile then names its launcher instead,
|
||||
/// which is exactly how every launcher tile looked before icons existed.
|
||||
///
|
||||
/// Parsed once per token and cached: `Path::from_svg` on a 3 kB string is not free, and the
|
||||
/// library shelf re-renders every frame while the cursor springs.
|
||||
fn glyph(token: &str) -> Option<Glyph> {
|
||||
static CACHE: OnceLock<Mutex<GlyphCache>> = OnceLock::new();
|
||||
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
let mut cache = cache.lock().ok()?;
|
||||
if let Some(hit) = cache.get(token) {
|
||||
return hit.clone();
|
||||
}
|
||||
let built = GLYPHS
|
||||
.iter()
|
||||
.find(|(t, ..)| *t == token)
|
||||
.and_then(|(_, w, h, d)| Path::from_svg(d).map(|p| (p, *w, *h)));
|
||||
cache.insert(token.to_string(), built.clone());
|
||||
built
|
||||
}
|
||||
|
||||
/// The mark for `token`, scaled to fit `dst` and centred in it — aspect ratio preserved, because
|
||||
/// the masters' viewports are not all square. `None` when there is no mark to draw.
|
||||
pub(crate) fn launcher_mark(token: &str, dst: Rect) -> Option<Path> {
|
||||
let (path, vw, vh) = glyph(token)?;
|
||||
let scale = (dst.width() / vw).min(dst.height() / vh);
|
||||
let mut m = Matrix::new_identity();
|
||||
m.set_scale((scale, scale), None);
|
||||
m.post_translate((
|
||||
dst.left + (dst.width() - vw * scale) / 2.0,
|
||||
dst.top + (dst.height() - vh * scale) / 2.0,
|
||||
));
|
||||
Some(path.with_transform(&m))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every shipped master parses. A mark that silently fails to parse is a tile that silently
|
||||
/// loses its icon, which no other test in this crate would notice.
|
||||
#[test]
|
||||
fn every_glyph_parses() {
|
||||
for (token, ..) in GLYPHS {
|
||||
assert!(glyph(token).is_some(), "{token} failed to parse");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_token_draws_nothing() {
|
||||
assert!(launcher_mark("not-a-launcher", Rect::from_wh(64.0, 64.0)).is_none());
|
||||
}
|
||||
|
||||
/// The mark is letterboxed into the destination, never stretched past it — the guarantee the
|
||||
/// non-square viewports (playnite is 1024x1024, steam 496x512) depend on.
|
||||
#[test]
|
||||
fn mark_is_contained_and_centred() {
|
||||
let dst = Rect::from_xywh(10.0, 20.0, 80.0, 40.0);
|
||||
let b = launcher_mark("steam", dst).unwrap().compute_tight_bounds();
|
||||
assert!(b.width() <= dst.width() + 0.5 && b.height() <= dst.height() + 0.5);
|
||||
let (cx, cy) = (b.center_x(), b.center_y());
|
||||
assert!(
|
||||
(cx - dst.center_x()).abs() < 1.0,
|
||||
"off-centre horizontally: {cx}"
|
||||
);
|
||||
assert!(
|
||||
(cy - dst.center_y()).abs() < 1.0,
|
||||
"off-centre vertically: {cy}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ mod anim;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
mod glyphs;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
mod launcher_icons;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod library;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod model;
|
||||
|
||||
@@ -569,6 +569,10 @@ pub struct LibraryGame {
|
||||
/// [`pf_client_core::library::GameEntry::is_launcher`] so the "anything that isn't
|
||||
/// `launcher` is a game" rule lives in exactly one place.
|
||||
pub launcher: bool,
|
||||
/// The token for this entry's brand mark (`"steam"`, `"heroic"`), already validated by
|
||||
/// [`pf_client_core::library::GameEntry::icon_token`]. Empty when the entry names no mark;
|
||||
/// a token we ship no art for simply draws nothing and the tile falls back to its name.
|
||||
pub icon: String,
|
||||
}
|
||||
|
||||
struct Shared {
|
||||
@@ -670,6 +674,84 @@ pub fn initials(title: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The shared console parity vectors — `clients/shared/console-vectors.json`, the sibling of
|
||||
/// `deeplink-vectors.json` and read the same way (`include_str!`, so a missing file is a
|
||||
/// compile error rather than a skipped test).
|
||||
///
|
||||
/// This table lives in THREE hand-written copies — here, `GamepadPalette.kt` and
|
||||
/// `GamepadPalette.swift` — and until now nothing but prose held them together. What the file
|
||||
/// pins is not only the 13 palette definitions but the DERIVED 16-cell mesh each one produces,
|
||||
/// which is the half that actually reaches the screen and the half a transcription slip would
|
||||
/// change invisibly.
|
||||
#[test]
|
||||
fn shared_console_vectors() {
|
||||
let raw = include_str!("../../../clients/shared/console-vectors.json");
|
||||
let file: serde_json::Value =
|
||||
serde_json::from_str(raw).expect("console-vectors.json must parse");
|
||||
let nums = |v: &serde_json::Value| -> Vec<f64> {
|
||||
v.as_array()
|
||||
.expect("array")
|
||||
.iter()
|
||||
.map(|n| n.as_f64().expect("number"))
|
||||
.collect()
|
||||
};
|
||||
let close = |what: &str, a: f64, b: f64| {
|
||||
assert!(
|
||||
(a - b).abs() < 1e-6,
|
||||
"{what}: vectors say {b}, this client computes {a}"
|
||||
);
|
||||
};
|
||||
|
||||
assert_eq!(nums(&file["cell_ramp"]), CELL_RAMP.to_vec(), "CELL_RAMP");
|
||||
|
||||
let interior = file["mesh_interior"].as_array().expect("mesh_interior");
|
||||
assert_eq!(interior.len(), MESH_INTERIOR.len(), "mesh interior count");
|
||||
for (w, p) in interior.iter().zip(MESH_INTERIOR.iter()) {
|
||||
let w = nums(w);
|
||||
let got = [p.0, p.1, p.2, p.3, p.4, p.5];
|
||||
for (i, (a, b)) in got.iter().zip(w.iter()).enumerate() {
|
||||
close(&format!("mesh_interior[{i}]"), *a, *b);
|
||||
}
|
||||
}
|
||||
|
||||
let want = file["palettes"].as_array().expect("palettes");
|
||||
assert_eq!(want.len(), PALETTES.len(), "palette count");
|
||||
for (w, p) in want.iter().zip(PALETTES.iter()) {
|
||||
let id = w["id"].as_str().expect("id");
|
||||
assert_eq!(id, p.id, "palette order");
|
||||
assert_eq!(w["name"].as_str().expect("name"), p.name, "{id} name");
|
||||
assert_eq!(w["light"].as_bool().expect("light"), p.light, "{id} light");
|
||||
let g = nums(&w["ground"]);
|
||||
close(&format!("{id} ground.r"), p.ground.0, g[0]);
|
||||
close(&format!("{id} ground.g"), p.ground.1, g[1]);
|
||||
close(&format!("{id} ground.b"), p.ground.2, g[2]);
|
||||
let a = nums(&w["accent"]);
|
||||
close(&format!("{id} accent.r"), p.accent.0, a[0]);
|
||||
close(&format!("{id} accent.g"), p.accent.1, a[1]);
|
||||
close(&format!("{id} accent.b"), p.accent.2, a[2]);
|
||||
|
||||
// The derived tables — the ones that reach the shader and the fallback field.
|
||||
let mesh = p.mesh_colors();
|
||||
let wm = w["mesh"].as_array().expect("mesh");
|
||||
assert_eq!(wm.len(), mesh.len(), "{id} mesh cells");
|
||||
for (i, (c, wc)) in mesh.iter().zip(wm.iter()).enumerate() {
|
||||
let wc = nums(wc);
|
||||
close(&format!("{id} mesh[{i}].r"), c.0, wc[0]);
|
||||
close(&format!("{id} mesh[{i}].g"), c.1, wc[1]);
|
||||
close(&format!("{id} mesh[{i}].b"), c.2, wc[2]);
|
||||
}
|
||||
let blobs = p.blob_colors();
|
||||
let wb = w["blobs"].as_array().expect("blobs");
|
||||
assert_eq!(wb.len(), blobs.len(), "{id} blob count");
|
||||
for (i, (c, wc)) in blobs.iter().zip(wb.iter()).enumerate() {
|
||||
let wc = nums(wc);
|
||||
close(&format!("{id} blob[{i}].r"), c.0, wc[0]);
|
||||
close(&format!("{id} blob[{i}].g"), c.1, wc[1]);
|
||||
close(&format!("{id} blob[{i}].b"), c.2, wc[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The GTK launcher's cursor tests, ported with the math.
|
||||
#[test]
|
||||
fn step_refuses_the_ends() {
|
||||
@@ -689,6 +771,7 @@ mod tests {
|
||||
title: title.to_string(),
|
||||
store: "steam".into(),
|
||||
launcher,
|
||||
icon: String::new(),
|
||||
};
|
||||
let shared = LibraryShared::default();
|
||||
shared.set_games(vec![
|
||||
@@ -717,6 +800,7 @@ mod tests {
|
||||
title: (*t).to_string(),
|
||||
store: "steam".into(),
|
||||
launcher: false,
|
||||
icon: String::new(),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
|
||||