Compare commits

...
Author SHA1 Message Date
xXJsonDeruloXx 66e238378e wip: framegen cadence compositor debug 2026-10-14 12:16:24 -04:00
xXJsonDeruloXx 9971d8bbf0 fix: retain host composition margin for gamescope Wayland planes 2026-10-13 14:21:39 -04:00
xXJsonDeruloXx 41807e320f chore: measure gamescope cadence without per-frame logging 2026-10-13 14:18:36 -04:00
xXJsonDeruloXx d7727d64e5 chore: trace the opaque game plane on nested outputs 2026-10-13 13:52:17 -04:00
xXJsonDeruloXx f533e9c5a7 chore: trace gamescope host-plane submissions 2026-10-13 13:51:03 -04:00
xXJsonDeruloXx b506652c36 fix: count each gamescope timer refresh once 2026-10-13 13:39:41 -04:00
xXJsonDeruloXx 60fba082e0 chore: instrument gamescope commit cadence for Thor diagnosis 2026-10-13 13:32:37 -04:00
Kurt Himebauch 975c76e3e8 fix: don't count a re-attached unchanged buffer as a new game frame (#731) 2026-10-13 13:24:50 -04:00
Phobos665 a394dfd31b feat(): Rich Game details for GOG & Epic - Publisher, Developer, Release Date, HLTB & Metacritic (#712) 2026-10-13 11:29:03 -04:00
Kurt Himebauch a2e555f4cc Use preview-builds for the Discord channel name (#729) 2026-10-13 11:27:39 -04:00
Kurt Himebauch 31d12a9f6c Align Discord build names with the app (#726) 2026-10-13 11:17:41 -04:00
Kurt Himebauch 3820445561 feat: Cardboard VR mode with ARM64 SteamVR (#704)
* feat: add Cardboard mode with side-by-side lens-distorted eyes and 3DoF head tracking

* fix: derive the Cardboard field of view from the panel and recentre fully when far from upright

* feat: share the Cardboard head pose with the guest and add a SteamVR driver for it

* feat: run SteamVR's compositor on Turnip/KGSL with a display-extension layer and a driver-direct HMD

* feat: show SteamVR frames in Cardboard mode through a driver-side presenter and a fake direct-mode display

* feat: install and start SteamVR for Cardboard mode from the session drawer

* docs: describe Cardboard mode and the SteamVR path

* feat: drive SteamVR hands from the game pad and keep the Cardboard headset out of standby

* docs: record where the SteamVR dashboard stops

* fix: map Cardboard head tracking to the screen's axes and keep turns about the real vertical as yaw

* fix: give Cardboard its previous picture back when SteamVR stops

* fix: report sem_otime from emulated SysV semaphores so SteamVR's web helper can start

* fix: bridge SteamVR's shared semaphores across processes and keep Steam alive while SteamVR runs

* feat: present SteamVR straight to DroidDeck's compositor at panel resolution

* docs: update Cardboard VR notes for the dashboard, semaphore bridge and direct presenter

* fix: keep Wine and FEX signal handlers in the VR layer; add semaphore tracing

* fix: clear a stale SteamVR active marker on status

* fix: clear only an old SteamVR active marker

* fix: unblock SteamVR clients on Turnip and keep Steam's web helper alive across SteamVR runs

* fix: judge shared waits before a submit's own signals and on Vulkan 1.0 devices

* docs: record VR games, SteamVR Home and OpenXR results on the Thor

* docs: add Cardboard VR learnings from the Thor bring-up

* docs: add Cardboard VR screenshots from the Thor

* fix: keep the VR layer's display stand-in to SteamVR runs and clear a leftover marker at session start

* fix: report SteamVR start and stop failures from the helper's exit code
2026-10-13 14:36:56 +00:00
Kurt Himebauch 75d7edd2c3 fix: anchor wp_presentation timestamps to the vsync tick (#717) 2026-10-13 13:29:32 +00:00
Kurt Himebauch da8fe068d9 fix: cap the session refresh rate at the device's peak refresh rate (#715) 2026-10-13 13:16:29 +00:00
Kurt Himebauch 8f9ce19638 Fix FEX dropdown scaling and Discover search focus (#681) 2026-10-13 07:01:05 -04:00
Kurt Himebauch b74edb7e8d Download components from the session drawer (#679) 2026-10-12 22:18:47 -04:00
Jagger15 2503d69293 Add Quest 3/3S compatibility to the standard APK (#642) 2026-10-12 17:13:07 -04:00
Kurt Himebauch 5e38e79c96 Add opt-in GOG achievements through Comet (#651) 2026-10-12 16:40:06 -04:00
Kurt Himebauch 5e5ffee93e Default Snapdragon 8 Gen 2 core allocation (#654) 2026-10-12 16:26:24 -04:00
Phobos665andxXJsonDeruloXx a7662ae13a feat(): Achievements UI for GOG & Epic game details page (#641)
* feat(): gog & epic achievements UI

* feat(): AI-generated translations - Please get review for these

* build_local: run on macOS hosts with Docker Desktop

Two steps of tools/build_local.sh failed on a Mac:

- The uruntime staging runs on the host, where macOS's BSD install has
  no -D, so `install -Dm644` stopped with "No such file or directory".
  The folders are now made first and the files copied with install -m644.
- Inside the cross-compile container, Docker Desktop's VirtioFS share
  now and then refuses the chmod that install -m makes (EPERM, on a
  different file each run, e.g. "setting permissions for
  .../droiddeck-esync: Operation not permitted"). The overlay and
  desktop scripts are copied with a plain write instead. Nothing is
  lost: the APK keeps no file modes, and the app sets them when it
  stages the runtime.

* feat(): adjust the store

* feat(): store-achievements adjustment for UI

* feat(): translation fixes and separate hidden achievements

* Show all achievements directly on store game details

---------

Co-authored-by: xXJsonDeruloXx <[email protected]>
2026-10-12 19:49:55 +00:00
The412Banner a249e896f7 feat: Start without Steam, a per-game switch for games that close right away (#659) 2026-10-12 15:29:59 -04:00
The412Banner ca3743520e Merge remote-tracking branch 'org/main' into feat/start-without-steam 2026-10-12 15:16:35 -04:00
Kirk 98822fdd27 fix: a touchpad tap holds its click long enough for games to see it (#644) 2026-10-12 19:12:20 +00:00
Gustash 8d0225932c fix: an unfolded Follow screen session turns to portrait again (#626) 2026-10-12 15:07:35 -04:00
The412Banner 40bca96db5 feat: Start without Steam, a per-game switch for games that close right away
A game that closes as soon as it starts can be tripped by Proton's Steam
setup (steam.exe, the Steam registry entries, the Steam client bridge).
The Games page of the session drawer gets a per-game Start without Steam
switch, stored as DROIDDECK_NO_STEAM=1 in the game's environment and listed
in its profile summary.

droiddeck-game-env acts on it for a game launch only: a Proton with umu's
launch (GE-Proton, Proton-CachyOS, DroidDeck-Proton) gets UMU_ID, so it
runs umu.exe in place of steam.exe and still sets up the prefix; a Steam
game keeps its protonfixes under its own id, an added game gets 0. Any
other Proton runs the game straight in the prefix (runinprefix).
2026-10-12 13:55:56 -04:00
Muaz Kadan a2a9586b54 fix: Shut Down in Steam's power menu ends the session, and Restart restarts the client (#629) 2026-10-12 13:44:16 -04:00
Muaz Kadan 2fe6ba36c4 Fix the QAM button doing nothing when the pad is an Xbox or PlayStation controller (#631) 2026-10-12 13:35:41 -04:00
Phobos665 e4c84e6e24 chore(): Fix the MacOS build process locally. (#637) 2026-10-12 17:11:01 +00:00
Kurt Himebauch 10ef8c4bd6 chore: bump version to 0.4.0 (13) (#627) 2026-10-12 10:56:11 -04:00
Kurt Himebauch 8e01cfb2ef docs: draft DroidDeck 0.4.0 release notes (#619)
* docs: draft 0.4.0 release notes

* docs: add Thor store and launcher media for 0.4.0

* docs: show Steam switching to KDE on Thor

* docs: show Castle Crashers touch layouts and controller choices

* docs: add Amazon game install view

* docs: add trimmed user recording of store downloads

* docs: smoother touch controller recording, GOG sign-in and per-game profile screenshots

* docs: clean desktop round-trip recording replaces the KDE gif and file explorer still

* docs: add multi-controller and Steam Controller media, rebuild desktop gif without glitch frames

* docs: add foldable touch gamepad screenshots from its PR

* docs: add the Discord badge to the 0.4.0 notes and tidy contributor lines

* docs: fold fixes to new 0.4.0 features into their entries, add Follow screen shots, point the changelog at the 0.4.0 tag

* docs: add the Add a game dialog to the non-Steam games entry

* docs: add a TL;DR for Mr. Sujano to the 0.4.0 notes
2026-10-12 10:49:14 -04:00
Neolyum b25c4ea26b Fix multiple controller discovery (#614) 2026-10-12 11:45:00 +00:00
Kurt Himebauch cebad5692d feat: store uninstall confirms in a step-out, shows progress, and lands on Install (#609)
feat: store uninstall confirms in a step-out, shows its progress in place, and the game reads Install the moment it is gone
2026-10-12 11:03:03 +00:00
Kurt Himebauch 6b2c8ace09 fix: keep the Steam desktop client in KDE (#610) 2026-10-12 06:56:56 -04:00
Kurt Himebauch 4d1b8421fb feat: store game pages flood onto their hero art, and downloads survive a restart (#608)
* feat: a store game's page floods out of its card onto its own hero art, and draws back from it

* fix: installs interrupted by the app closing show in Downloads with Resume and Clear

* feat: finished and interrupted downloads share the running card's art look, and the queue sits under what is running

* fix: the download queue outlives the app: queued, running and paused downloads come back after a restart
2026-10-12 03:58:07 +00:00
Kurt Himebauch ced4ac5db8 fix: a step-out out of a wide control draws its box again (#607) 2026-10-12 03:31:48 +00:00
Kurt Himebauch e190f57b53 feat: components newest first, Steam settings open on Steam with offline mode, smoother chip selection (#606)
* feat: components list newest first, Steam's sheet opens on its Steam tab with offline mode, and Setup's overview drops the account group

* fix: chip selections stretch across on every move, drawn over the chips instead of behind them
2026-10-12 03:25:55 +00:00
Kurt Himebauch 22f02fb6bf feat: the focus ring is the only fill and keeps up with focus (#605)
* feat: with a pad the focus ring fills what it holds and is the only fill; chip rows mark their pick with a bar

* fix: the focus ring chases focus on a continuous spring per edge instead of restarting on every move
2026-10-12 03:16:07 +00:00
Kurt Himebauch 52c67df4c7 fix: store chips mark the shown store without faking focus, and step-outs open and fold cleanly (#604)
* fix: a chip selection hopping past a chip fades across instead of crawling over as a drop

* fix: the store chips' fill only shows where a pad's focus is, not on the shown store

* feat: the shown store's chip carries a bar on its foot, every chip pulses when picked, and the queue's settings live only in the cog

* fix: step-outs grow and fold in one move with no tail, keep the ring out while moving, and hand focus back to their control
2026-10-12 03:04:33 +00:00
Kurt Himebauch de1a734fb5 fix: settings sheet focus ring, Left to fold, chips pick on focus (#600) 2026-10-12 02:11:53 +00:00
Kurt Himebauch be9939d82c Fix black Steam output with FSR 1 (#597) 2026-10-11 21:03:07 -04:00
Kurt Himebauch e4c375ad0d Fix Discord launcher layout and controller navigation (#596) 2026-10-11 20:08:59 -04:00
Gustash 0cafa83ea7 fix: Epic games with a JSON manifest download (#578) 2026-10-11 17:37:47 -04:00
Juan MartíandKurt Himebauch 435beb7f19 feat: add persistent per-game compatibility profiles (#449)
* feat(profiles): add persistent per-game compatibility profiles

* fix(profiles): show Windows component overrides

Include per-game Windows component selections in the resolved profile summary so the session overlay marks those games as custom and shows the selected components.

* fix(session): detect Proton games across runtime paths

* fix(profiles): select the active Gamescope game

* fix(profiles): improve per-game reset feedback

* fix(profiles): align session components with game overrides

* fix(session): prefer selected Steam game during refresh

* fix(session): resume profile following when reopening games

* chore: keep local dev testing outside the repository

* test(session): remove local dev package path

* fix(session): preserve profiles when refresh fails

* fix(session): reject empty game profile reset scope

* perf(session): cache drawer inventory by refresh revision

* fix(profiles): align game presets and profile reset behavior

* fix(profiles): preserve originals and normalize shortcut ids

* fix(profiles): keep editor scope stable after rebase

* fix(tests): host Compose tests in both build variants

* fix(profiles): recover interrupted component selection saves

---------

Co-authored-by: Kurt Himebauch <[email protected]>
2026-10-11 17:00:28 -04:00
The412Banner 78fcb60fd5 DroidDeck-Proton as a Proton source, and update checks for every source (#588) 2026-10-11 16:07:44 -04:00
The412Banner 4995b3553b Merge remote-tracking branch 'org/main' into feat/droiddeck-proton-source 2026-10-11 15:56:18 -04:00
MaxsTechReview 3c2878cc52 Red Dead Redemption 2: launches again, no screensaver over games, hidden launcher held to one core, /tmp cleaned (#585)
* fix: Red Dead Redemption 2 launches again, its Vulkan runtime installer is left to run

droiddeck-seed-redists marked every process in every game's install script
as already run, Red Dead Redemption 2's VulkanRT-1.1.108.0 installer
included. Steam's compat config for the game says nativevulkanloader, so
Proton loads vulkan-1.dll native only, and Khronos' loader is in the prefix
only once that installer has run. Without it the Rockstar launcher starts
RDR2.exe and it exits with STATUS_DLL_NOT_FOUND (0xc0000135) before it
opens a window.

A Vulkan runtime installer is now left out of the seeding, and a marker an
earlier seeding wrote for one comes out of each prefix and Proton template
once (dates kept), so the client runs the installer on the next launch.
The installer finishes in a few seconds under FEX.

* fix: Steam's screensaver no longer covers a running game

The client's defaults show screensavers while a game runs, after 300 s on
AC power without a button press - and a held stick or trigger is no press.
Red Dead Redemption 2's intro runs longer than that: the slideshow covered
the game, and the button that woke it reached the game too (B asked to
quit to the desktop). Customization/Screensaver/WhileInGame is seeded "0"
in config.vdf once, like the UI scale and native resolution, so turning it
back on in Settings stays the user's choice.

On the RedMagic: 18 minutes from the last button press through the intro
and riding, focus never left the game and no screensaver was created.

* fix: Rockstar's hidden launcher no longer takes cores from its game

The Rockstar Games Launcher keeps drawing its Chromium interface
(SocialClubHelper.exe: renderers, and a GPU process compositing through
DXVK) for as long as the game runs, hidden behind it - and Wine passes
Chromium's priorities on, so those threads ran above the game's (the GPU
process at nice -10), across every core. With Red Dead Redemption 2 in
front that was 1.3 to 2.5 cores under FEX for a page nobody sees.

droiddeck-launcher-cores follows gamescope's focus from the agent's
focus.json: while a game window has it, every SocialClubHelper thread runs
on the last core of the slowest cluster at nice 19; when the launcher's own
window has focus again each thread gets its own mask and priority back
(also on exit). Stopping the helpers would not hold - proot resumes a
stopped tracee. Started for Steam sessions; BL_LAUNCHER_CORES=0 turns it off.

RedMagic, RDR2 all Low, riding at the thermal limit, same spot and session:
released 46.5 fps, 1% low 27.5, p99 31.8 ms, CPU 89%, 2200 MHz;
held 48.7 fps, 1% low 32.6, p99 27.6 ms, CPU 79%, 2638 MHz.

* fix: sessions clean up what earlier ones left in /tmp

The guest's /tmp is a directory in the rootfs that no boot ever empties,
and Android ends sessions by killing them. Every session left a
/tmp/mangohud.XXXXXXXX (mktemp, never removed), and the Steam client, its
web helper, Chromium apps, dbus-run-session, AppImage runs and KDE their
own temporary files and sockets: 1,422 entries on the RedMagic.

mangoapp's config is now one fixed file rewritten each session, and the
session's first pass sweeps each kind of leftover while nothing that could
own one is running, with a single find. On the device that cleared 1,391
entries over two sessions, leaving 38.
2026-10-11 15:53:01 -04:00
The412Banner 715032062d feat: DroidDeck-Proton as a Proton source, and update checks for every source
Setup > Proton versions lists DroidDeck-Proton (The412Banner/DroidDeck-Proton) beside
GE-Proton and proton-cachyos: same aarch64 tarball pattern as GE, verified against its
sha512 file, installed by droiddeck-proton-extra (also as "droiddeck" in the session queue)
and adopted by steam-compatibility like any other extra, so it shows as
"DroidDeck-Proton <ver> (Compatible)". Its folder has a space; nothing in the install,
adopt, esync or components paths splits on it.

Update checks: each source's newest full release is looked up at app start and when the
page opens, at most every 6 h (offline keeps the last answer). A newer release shows
"Update available -> <tag>" with Update; it installs beside the old build, which stays
until removed ("Remove old ... versions"), since games are mapped to it by name.
Installed builds are ordered by their version numbers (11-9 before 11-10), not by name,
and the tag each was installed from is recorded. Prereleases and drafts are skipped.
2026-10-11 15:32:07 -04:00
Kurt Himebauch dde08414fc feat: big ass redesign: Carry the stores' motion and layout into Steam settings, Components, Setup, Flathub and Desktop (#548) 2026-10-11 19:05:21 +00:00
277 changed files with 33384 additions and 2375 deletions

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
# Vendored files preserve upstream license spacing and unified-diff context lines.
tools/comet/LICENSE whitespace=-blank-at-eof
tools/comet/droiddeck.patch whitespace=-blank-at-eol,-blank-at-eof
+17 -5
View File
@@ -186,7 +186,7 @@ jobs:
uses: actions/cache@v4
with:
path: app/src/main/assets/linuxfs
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**', 'tools/linuxfs/debian-image.env') }}
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'app/src/main/cpp/adrenotools/src/hook/kgsl.h', 'tools/quest/*', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**', 'tools/comet/**', 'tools/linuxfs/debian-image.env', 'tools/vr/**') }}
- name: Build libfakeinput.so and libblsession.so (glibc aarch64)
if: steps.preloads.outputs.cache-hit != 'true'
@@ -218,6 +218,9 @@ jobs:
mkdir -p "$D/usr/local/bin"
aarch64-linux-gnu-gcc -O2 -Wall -Wextra -o "$D/usr/local/bin/droiddeck-clipboard" tools/linuxfs/clipboard/clipboard.c -ldl
aarch64-linux-gnu-strip --strip-unneeded "$D/usr/local/bin/droiddeck-clipboard"
# Cardboard mode's SteamVR driver and Vulkan layer (tools/vr), Vulkan headers from the runner's NDK.
VK_INCLUDE=$(ls -d "${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"/toolchains/llvm/prebuilt/*/sysroot/usr/include | head -1) \
tools/vr/build.sh --native "$D/usr/local/lib/droiddeck-vr"
for script in tools/linuxfs/overlay/usr/local/bin/droiddeck-* tools/linuxfs/overlay/usr/local/bin/steam-compatibility; do
install -Dm644 "$script" "$D/usr/local/bin/$(basename "$script")"
done
@@ -450,6 +453,13 @@ jobs:
restore-keys: |
cxx-${{ env.NDK_VERSION }}-
- name: Build GOG Galaxy companion
if: steps.preloads.outputs.cache-hit != 'true'
run: |
rustup toolchain install 1.90.0 --profile minimal
rustup target add --toolchain 1.90.0 aarch64-unknown-linux-gnu
RUSTUP_TOOLCHAIN=1.90.0 bash tools/comet/build.sh
- name: Build
id: build
env:
@@ -457,6 +467,7 @@ jobs:
SGDB_API_KEY: ${{ secrets.SGDB_API_KEY }}
run: |
set -o pipefail
python3 tools/quest/build-driver.py "$ANDROID_HOME/ndk/$NDK_VERSION"
python3 tools/release/runtime_inputs.py record
./gradlew assembleRelease --console=plain -PndkVersion="$NDK_VERSION" 2>&1 | tee /tmp/build.log
python3 tools/release/check_session_assets.py app/build/outputs/apk/release/app-release.apk
@@ -617,10 +628,11 @@ jobs:
# Each signed main build as a prerelease of the public Droid-Deck/DroidDeck-CI (main-<sha>, the
# last KEEP kept), where anyone can download it without a GitHub account, then a post in the
# Discord's builds channel linking to it. The release is made with a token of the org's
# Discord's #preview-builds channel linking to it. The release is made with a token of the org's
# "DroidDeck CI Publisher" app (installed on DroidDeck-CI only, Contents: write): its ID in the
# DROIDDECK_CI_APP_ID variable, its private key in the DROIDDECK_CI_APP_KEY secret.
# DISCORD_BUILDS_WEBHOOK is the channel's webhook URL.
# DISCORD_BUILDS_WEBHOOK is the channel's webhook URL. Keep this secret name when renaming
# the channel: Discord routes the existing webhook by channel ID, not its display name.
announce:
needs: [build, sign]
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
@@ -722,7 +734,7 @@ jobs:
echo "apk=$GITHUB_SERVER_URL/$CI_REPO/releases/download/$tag/$apk" >> "$GITHUB_OUTPUT"
echo "Published $url"
- name: Post to Discord
- name: Post the Preview build to Discord
env:
WEBHOOK: ${{ secrets.DISCORD_BUILDS_WEBHOOK }}
GH_TOKEN: ${{ github.token }}
@@ -746,7 +758,7 @@ jobs:
what="[\`${GITHUB_SHA::7}\`]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA): $subject"
fi
get=$(printf 'Download: [standard APK](<%s>) · [all variants](<%s>)' "$APK" "$RELEASE")
content=$(printf '**New signed build** `main-%s`\n%s\n%s' "${GITHUB_SHA::7}" "$what" "$get")
content=$(printf '**New signed Preview build** `main-%s`\n%s\nIn the app: **Updates → Preview**\n%s' "${GITHUB_SHA::7}" "$what" "$get")
jq -n --arg content "$content" '{content: $content, allowed_mentions: {parse: []}}' |
curl -fsS -H 'Content-Type: application/json' -d @- "$WEBHOOK" -o /dev/null
echo "Posted: $content"
+1
View File
@@ -40,3 +40,4 @@ __pycache__/
# Local signing with the release key (tools/build_local.sh); holds a password.
.signing.env
app/src/main/assets/directaudio/
/tools/vr/out/
+8 -2
View File
@@ -92,8 +92,8 @@ android {
// file out of its own data directory, and this app's entire job is to exec a rootfs:
// proot, gamescope, the Steam client and everything it launches all live under files/.
targetSdkVersion 28
versionCode 12
versionName "0.3.2"
versionCode 13
versionName "0.4.0"
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
buildConfigField "int", "PR_NUMBER", "${prNumber}"
@@ -287,6 +287,9 @@ dependencies {
implementation platform('androidx.compose:compose-bom:2024.02.00')
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.material3:material3'
// @Preview for the composables; the tooling that renders them is a debug-only dependency.
implementation 'androidx.compose.ui:ui-tooling-preview'
debugImplementation 'androidx.compose.ui:ui-tooling'
implementation 'androidx.activity:activity-compose:1.8.2'
// The File Manager, Bannerlator's: its icon set, image thumbnails and the coroutines it
// copies on. Same versions as Bannerlator so the screen stays a straight copy.
@@ -302,4 +305,7 @@ dependencies {
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.robolectric:robolectric:4.16.1'
testImplementation 'androidx.compose.ui:ui-test-junit4'
// The Android archive does not include the host natives used by JVM archive tests.
testRuntimeOnly 'com.github.luben:zstd-jni:1.5.2-3@jar'
}
+10 -3
View File
@@ -3600,6 +3600,13 @@ static bool wait_snapshot(std::shared_ptr<FakeController> fake,
}
}
static int forward_ioctl(int fd, ioctl_request_t op, void *argp) {
using ioctl_fn = int (*)(int, ioctl_request_t, ...);
// Preserve later preload hooks, including GPU allocation adapters.
static const auto next = reinterpret_cast<ioctl_fn>(dlsym(RTLD_NEXT, "ioctl"));
return next ? next(fd, op, argp) : static_cast<int>(syscall(SYS_ioctl, fd, op, argp));
}
EXPORT int ioctl(int fd, ioctl_request_t op, ...) {
va_list va;
void *argp;
@@ -3611,7 +3618,7 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) {
// The lock must not be held across the passthrough: binder's transport is a
// blocking ioctl(BINDER_WRITE_READ), so a parked binder pool thread would own
// controller_mutex() for as long as it waits and deadlock every other ioctl.
if (!watched(fd)) return syscall(SYS_ioctl, fd, op, argp);
if (!watched(fd)) return forward_ioctl(fd, op, argp);
std::unique_lock<std::recursive_mutex> guard(controller_mutex());
auto maker = uinput_map().find(fd);
if (maker != uinput_map().end()) return ioctl_uinput(*maker->second, op, argp);
@@ -3629,7 +3636,7 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) {
auto controller = controller_map().find(fd);
if (controller == controller_map().end()) {
guard.unlock();
return syscall(SYS_ioctl, fd, op, argp);
return forward_ioctl(fd, op, argp);
}
int type = (op >> 8 & 0xFF);
@@ -3825,7 +3832,7 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) {
} else {
Logger::log("Unhandled evdev ioctl, type %d number %d\n", type, number);
guard.unlock();
return syscall(SYS_ioctl, fd, op, argp);
return forward_ioctl(fd, op, argp);
}
}
@@ -8,6 +8,7 @@ add_library(droiddeckwayland SHARED
src/compositor.c
src/vk_present.c
src/effects_chain.c
src/cardboard.c
src/vk_loader.c
src/sc_layer.c
src/ahb_swapchain.c
@@ -0,0 +1,447 @@
/* Cardboard pass - see cardboard.h. Compositor thread unless noted.
*
* The shader is cardboard.frag, pre-compiled like the rest of the compositor's SPIR-V:
* glslangValidator -V --vn cardboard_frag_code -o cardboard_frag.h cardboard.frag
*/
#define _POSIX_C_SOURCE 200809L
#include "cardboard.h"
#include <math.h>
#include <pthread.h>
#include <string.h>
#ifndef CARDBOARD_HOST_TEST
#include <fcntl.h>
#include <stdatomic.h>
#include <sys/mman.h>
#include <time.h>
#include <unistd.h>
#include "headpose_shm.h"
#endif
#ifndef CARDBOARD_HOST_TEST
#include "vk_loader.h"
#include "vk_present.h" /* droiddeck_log */
/* upscale_vert.h defines its array non-static, and effects_chain.c includes it too: a private name here. */
#define upscale_vert_code cardboard_upscale_vert_code
#include "upscale_vert.h"
#undef upscale_vert_code
#include "cardboard_frag.h"
#endif
/* ------------------------------------------------------------------ pure helpers */
static float clampf(float v, float lo, float hi) { return v < lo ? lo : v > hi ? hi : v; }
void cardboard_clamp(struct cardboard_params *p) {
p->enabled = p->enabled ? 1 : 0;
p->source = p->source == CARDBOARD_SOURCE_SBS ? CARDBOARD_SOURCE_SBS : CARDBOARD_SOURCE_SCREEN;
p->ipd_m = clampf(p->ipd_m, 0.0f, 0.1f);
p->screen_dist_m = clampf(p->screen_dist_m, 0.5f, 50.0f);
p->screen_width_m = clampf(p->screen_width_m, 0.2f, 60.0f);
p->k1 = clampf(p->k1, -1.0f, 2.0f);
p->k2 = clampf(p->k2, -1.0f, 2.0f);
p->fov_deg = clampf(p->fov_deg, 30.0f, 140.0f);
p->lens_inset = clampf(p->lens_inset, -0.25f, 0.25f);
p->chroma = clampf(p->chroma, 0.0f, 0.05f);
}
void cardboard_push_constants(const struct cardboard_params *p, const float pose[9],
int src_w, int src_h, int out_w, int out_h, float out[24]) {
/* rot0..rot2: the pose's rows, each with one scalar riding in w. */
for (int r = 0; r < 3; r++)
for (int c = 0; c < 3; c++) out[r * 4 + c] = pose[r * 3 + c];
out[3] = p->ipd_m * 0.5f;
out[7] = p->screen_dist_m;
out[11] = p->screen_width_m * 0.5f;
/* lens */
out[12] = p->k1;
out[13] = p->k2;
out[14] = tanf(p->fov_deg * 0.5f * (float)M_PI / 180.0f);
out[15] = p->chroma;
/* view */
out[16] = p->lens_inset;
out[17] = (src_w > 0 && src_h > 0) ? (float)src_w / (float)src_h : 16.0f / 9.0f;
if (p->source == CARDBOARD_SOURCE_SBS) out[17] *= 0.5f; /* one eye's half */
out[18] = (float)p->source;
out[19] = (out_w > 0 && out_h > 0) ? ((float)out_w * 0.5f) / (float)out_h : 1.0f;
out[20] = out[21] = out[22] = out[23] = 0.0f; /* reserved */
}
#ifndef CARDBOARD_HOST_TEST
/* ------------------------------------------------------------------ settings + pose */
static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;
static struct cardboard_params g_set = {
.enabled = 0, .source = CARDBOARD_SOURCE_SCREEN, .ipd_m = 0.064f, .screen_dist_m = 3.0f,
.screen_width_m = 4.0f, .k1 = 0.22f, .k2 = 0.24f, .fov_deg = 90.0f, .lens_inset = 0.0f, .chroma = 0.0f};
static float g_pose[9] = {1, 0, 0, 0, 1, 0, 0, 0, 1};
static volatile int g_changed = 1;
static int g_live; /* the last pose came from a sensor */
static void shm_publish(int live); /* below: the guest's copy of the pose */
void vkp_cardboard_set(const struct cardboard_params *p) {
struct cardboard_params c = *p;
cardboard_clamp(&c);
pthread_mutex_lock(&g_lock);
int was = g_set.enabled;
g_set = c;
g_changed = 1;
shm_publish(g_live);
pthread_mutex_unlock(&g_lock);
if (was != c.enabled || c.enabled)
droiddeck_log("cardboard", "%s: %s, ipd %.0f mm, screen %.1f m wide at %.1f m, lens k1 %.2f k2 %.2f, fov %.0f, inset %.3f, chroma %.3f",
c.enabled ? "on" : "off", c.source == CARDBOARD_SOURCE_SBS ? "side-by-side source" : "virtual screen",
c.ipd_m * 1000.0f, c.screen_width_m, c.screen_dist_m, c.k1, c.k2, c.fov_deg, c.lens_inset, c.chroma);
}
/* The guest's copy of the pose (headpose_shm.h), mapped once a path is known. */
static struct droiddeck_headpose *g_shm;
static int g_eye_w, g_eye_h; /* the last eye viewport drawn, for the guest */
void vkp_cardboard_set_pose_file(const char *path) {
pthread_mutex_lock(&g_lock);
if (!g_shm && path && *path) {
int fd = open(path, O_RDWR | O_CREAT | O_CLOEXEC, 0600);
if (fd >= 0 && ftruncate(fd, sizeof(struct droiddeck_headpose)) == 0) {
void *p = mmap(NULL, sizeof(struct droiddeck_headpose), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (p != MAP_FAILED) {
g_shm = p;
memset(g_shm, 0, sizeof(*g_shm));
g_shm->magic = DROIDDECK_HEADPOSE_MAGIC;
g_shm->version = DROIDDECK_HEADPOSE_VERSION;
memcpy(g_shm->rot, g_pose, sizeof(g_shm->rot));
}
}
if (fd >= 0) close(fd);
droiddeck_log("cardboard", g_shm ? "head pose shared with the guest at %s" : "could not share the head pose at %s", path);
}
pthread_mutex_unlock(&g_lock);
}
/* Under g_lock. */
static void shm_publish(int live) {
if (!g_shm) return;
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
atomic_uint *seq = (atomic_uint *)&g_shm->seq;
unsigned s = atomic_load_explicit(seq, memory_order_relaxed);
atomic_store_explicit(seq, s | 1u, memory_order_relaxed);
atomic_thread_fence(memory_order_release);
memcpy(g_shm->rot, g_pose, sizeof(g_shm->rot));
g_shm->ipd_m = g_set.ipd_m;
g_shm->flags = (live ? DROIDDECK_HEADPOSE_LIVE : 0u) | (g_set.enabled ? DROIDDECK_HEADPOSE_ENABLED : 0u);
g_shm->time_ns = (int64_t)ts.tv_sec * 1000000000LL + ts.tv_nsec;
g_shm->tan_half_fov = tanf(g_set.fov_deg * 0.5f * (float)M_PI / 180.0f);
g_shm->eye_w = (uint32_t)(g_eye_w > 0 ? g_eye_w : 0);
g_shm->eye_h = (uint32_t)(g_eye_h > 0 ? g_eye_h : 0);
g_shm->eye_aspect = g_eye_h > 0 ? (float)g_eye_w / (float)g_eye_h : 1.0f;
atomic_thread_fence(memory_order_release);
atomic_store_explicit(seq, (s | 1u) + 1u, memory_order_relaxed);
}
void vkp_cardboard_set_pose(const float m[9], int live) {
pthread_mutex_lock(&g_lock);
memcpy(g_pose, m, sizeof(g_pose));
g_live = live;
if (g_set.enabled && g_set.source == CARDBOARD_SOURCE_SCREEN) g_changed = 1;
shm_publish(live);
pthread_mutex_unlock(&g_lock);
}
static struct cardboard_params g_cur;
static float g_cur_pose[9];
static void snapshot(void) {
pthread_mutex_lock(&g_lock);
g_cur = g_set;
memcpy(g_cur_pose, g_pose, sizeof(g_cur_pose));
pthread_mutex_unlock(&g_lock);
}
int vkp_cardboard_active(void) {
pthread_mutex_lock(&g_lock);
int on = g_set.enabled;
pthread_mutex_unlock(&g_lock);
return on;
}
int vkp_cardboard_pose_changed(void) {
if (!g_changed) return 0;
g_changed = 0;
return 1;
}
/* ------------------------------------------------------------------ Vulkan objects */
#define CB_FORMAT VK_FORMAT_R8G8B8A8_UNORM
#define CB_PC_SIZE (4 * 4 + 24 * 4) /* ndc + the 24 floats: 112 bytes, under the 128 guaranteed */
#define CB_MAX_SOURCES 8 /* source views per frame: the real frame + generated ones */
static VkDevice g_dev;
static VkPhysicalDeviceMemoryProperties g_mp;
static int g_ready; /* 0 = not built, 1 = ok, -1 = failed (said once) */
static VkRenderPass g_rp;
static VkDescriptorSetLayout g_dsl;
static VkPipelineLayout g_pl;
static VkDescriptorPool g_dpool;
static VkSampler g_sampler;
static VkPipeline g_pipe;
static struct { VkImage img; VkDeviceMemory mem; VkImageView view; VkFramebuffer fb; int w, h; } g_t;
static struct { VkImageView view; VkDescriptorSet ds; } g_src[CB_MAX_SOURCES];
static int g_nsrc;
void vkp_cardboard_bind_device(VkDevice dev, const VkPhysicalDeviceMemoryProperties *memprops) {
g_dev = dev;
g_mp = *memprops;
}
static int memtype(uint32_t bits, VkMemoryPropertyFlags want) {
for (uint32_t i = 0; i < g_mp.memoryTypeCount; i++)
if ((bits & (1u << i)) && (g_mp.memoryTypes[i].propertyFlags & want) == want) return (int)i;
return -1;
}
static void sources_release(void) {
for (int i = 0; i < g_nsrc; i++) {
if (g_src[i].ds) g_vk.FreeDescriptorSets(g_dev, g_dpool, 1, &g_src[i].ds);
if (g_src[i].view) g_vk.DestroyImageView(g_dev, g_src[i].view, NULL);
}
memset(g_src, 0, sizeof(g_src));
g_nsrc = 0;
}
void vkp_cardboard_begin_frame(void) {
if (g_ready == 1) sources_release();
}
static void target_destroy(void) {
if (g_t.fb) g_vk.DestroyFramebuffer(g_dev, g_t.fb, NULL);
if (g_t.view) g_vk.DestroyImageView(g_dev, g_t.view, NULL);
if (g_t.img) g_vk.DestroyImage(g_dev, g_t.img, NULL);
if (g_t.mem) g_vk.FreeMemory(g_dev, g_t.mem, NULL);
memset(&g_t, 0, sizeof(g_t));
}
static VkImageView make_view(VkImage img) {
VkImageView v = VK_NULL_HANDLE;
VkImageViewCreateInfo ci = {.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO, .image = img,
.viewType = VK_IMAGE_VIEW_TYPE_2D, .format = CB_FORMAT,
.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}};
if (g_vk.CreateImageView(g_dev, &ci, NULL, &v) != VK_SUCCESS) return VK_NULL_HANDLE;
return v;
}
/* The panel-sized output: rendered into, then blitted 1:1 to the swapchain. Kept uncompressed
* (mutable format) like the effect chain's targets: UBWC render-then-blit targets corrupt on
* Adreno 740. */
static int target_ensure(int w, int h) {
if (g_t.img && g_t.w == w && g_t.h == h) return 0;
target_destroy();
VkImageCreateInfo ii = {.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, .flags = VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT,
.imageType = VK_IMAGE_TYPE_2D, .format = CB_FORMAT, .extent = {(uint32_t)w, (uint32_t)h, 1},
.mipLevels = 1, .arrayLayers = 1, .samples = VK_SAMPLE_COUNT_1_BIT,
.tiling = VK_IMAGE_TILING_OPTIMAL,
.usage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT,
.sharingMode = VK_SHARING_MODE_EXCLUSIVE, .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED};
if (g_vk.CreateImage(g_dev, &ii, NULL, &g_t.img) != VK_SUCCESS) goto fail;
VkMemoryRequirements req;
g_vk.GetImageMemoryRequirements(g_dev, g_t.img, &req);
int idx = memtype(req.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
if (idx < 0) idx = memtype(req.memoryTypeBits, 0);
VkMemoryAllocateInfo mai = {.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, .allocationSize = req.size,
.memoryTypeIndex = (uint32_t)(idx < 0 ? 0 : idx)};
if (g_vk.AllocateMemory(g_dev, &mai, NULL, &g_t.mem) != VK_SUCCESS) goto fail;
g_vk.BindImageMemory(g_dev, g_t.img, g_t.mem, 0);
if (!(g_t.view = make_view(g_t.img))) goto fail;
VkFramebufferCreateInfo fi = {.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO, .renderPass = g_rp,
.attachmentCount = 1, .pAttachments = &g_t.view, .width = (uint32_t)w,
.height = (uint32_t)h, .layers = 1};
if (g_vk.CreateFramebuffer(g_dev, &fi, NULL, &g_t.fb) != VK_SUCCESS) goto fail;
g_t.w = w;
g_t.h = h;
droiddeck_log("cardboard", "eye target %dx%d (two %dx%d eyes)", w, h, w / 2, h);
return 0;
fail:
droiddeck_log("error", "cardboard: could not create the %dx%d eye target", w, h);
target_destroy();
return -1;
}
static VkShaderModule shader(const uint32_t *code, size_t size) {
VkShaderModule m = VK_NULL_HANDLE;
VkShaderModuleCreateInfo ci = {.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO, .codeSize = size, .pCode = code};
if (g_vk.CreateShaderModule(g_dev, &ci, NULL, &m) != VK_SUCCESS) return VK_NULL_HANDLE;
return m;
}
static void objects_destroy(void) {
if (!g_dev) return;
sources_release();
target_destroy();
if (g_pipe) g_vk.DestroyPipeline(g_dev, g_pipe, NULL);
if (g_dpool) g_vk.DestroyDescriptorPool(g_dev, g_dpool, NULL);
if (g_pl) g_vk.DestroyPipelineLayout(g_dev, g_pl, NULL);
if (g_dsl) g_vk.DestroyDescriptorSetLayout(g_dev, g_dsl, NULL);
if (g_sampler) g_vk.DestroySampler(g_dev, g_sampler, NULL);
if (g_rp) g_vk.DestroyRenderPass(g_dev, g_rp, NULL);
g_pipe = VK_NULL_HANDLE; g_dpool = VK_NULL_HANDLE; g_pl = VK_NULL_HANDLE;
g_dsl = VK_NULL_HANDLE; g_sampler = VK_NULL_HANDLE; g_rp = VK_NULL_HANDLE;
}
static int objects_ensure(void) {
if (g_ready) return g_ready == 1 ? 0 : -1;
if (!g_dev || !g_vk.CreateGraphicsPipelines) goto fail;
{
/* One colour target, overwritten whole, left GENERAL for the 1:1 blit that follows. */
VkAttachmentDescription att = {.format = CB_FORMAT, .samples = VK_SAMPLE_COUNT_1_BIT,
.loadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE, .storeOp = VK_ATTACHMENT_STORE_OP_STORE,
.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE,
.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE,
.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED, .finalLayout = VK_IMAGE_LAYOUT_GENERAL};
VkAttachmentReference ref = {0, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL};
VkSubpassDescription sub = {.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS, .colorAttachmentCount = 1,
.pColorAttachments = &ref};
VkSubpassDependency deps[2] = {
{.srcSubpass = VK_SUBPASS_EXTERNAL, .dstSubpass = 0,
.srcStageMask = VK_PIPELINE_STAGE_ALL_COMMANDS_BIT,
.dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
.srcAccessMask = VK_ACCESS_MEMORY_WRITE_BIT | VK_ACCESS_TRANSFER_READ_BIT,
.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | VK_ACCESS_SHADER_READ_BIT},
{.srcSubpass = 0, .dstSubpass = VK_SUBPASS_EXTERNAL,
.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
.dstStageMask = VK_PIPELINE_STAGE_TRANSFER_BIT,
.srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, .dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT}};
VkRenderPassCreateInfo rci = {.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO, .attachmentCount = 1,
.pAttachments = &att, .subpassCount = 1, .pSubpasses = &sub,
.dependencyCount = 2, .pDependencies = deps};
if (g_vk.CreateRenderPass(g_dev, &rci, NULL, &g_rp) != VK_SUCCESS) goto fail;
VkSamplerCreateInfo sci = {.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO, .magFilter = VK_FILTER_LINEAR,
.minFilter = VK_FILTER_LINEAR, .mipmapMode = VK_SAMPLER_MIPMAP_MODE_NEAREST,
.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
.addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE};
if (g_vk.CreateSampler(g_dev, &sci, NULL, &g_sampler) != VK_SUCCESS) goto fail;
VkDescriptorSetLayoutBinding b = {.binding = 0, .descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
.descriptorCount = 1, .stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT};
VkDescriptorSetLayoutCreateInfo dli = {.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO,
.bindingCount = 1, .pBindings = &b};
if (g_vk.CreateDescriptorSetLayout(g_dev, &dli, NULL, &g_dsl) != VK_SUCCESS) goto fail;
VkPushConstantRange pcr = {.stageFlags = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT,
.offset = 0, .size = CB_PC_SIZE};
VkPipelineLayoutCreateInfo pli = {.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO, .setLayoutCount = 1,
.pSetLayouts = &g_dsl, .pushConstantRangeCount = 1, .pPushConstantRanges = &pcr};
if (g_vk.CreatePipelineLayout(g_dev, &pli, NULL, &g_pl) != VK_SUCCESS) goto fail;
VkDescriptorPoolSize ps = {VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, CB_MAX_SOURCES};
VkDescriptorPoolCreateInfo dpi = {.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO,
.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT,
.maxSets = CB_MAX_SOURCES, .poolSizeCount = 1, .pPoolSizes = &ps};
if (g_vk.CreateDescriptorPool(g_dev, &dpi, NULL, &g_dpool) != VK_SUCCESS) goto fail;
VkShaderModule vert = shader(cardboard_upscale_vert_code, sizeof(cardboard_upscale_vert_code));
VkShaderModule frag = shader(cardboard_frag_code, sizeof(cardboard_frag_code));
if (!vert || !frag) {
if (vert) g_vk.DestroyShaderModule(g_dev, vert, NULL);
if (frag) g_vk.DestroyShaderModule(g_dev, frag, NULL);
goto fail;
}
VkPipelineShaderStageCreateInfo st[2] = {
{.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, .stage = VK_SHADER_STAGE_VERTEX_BIT, .module = vert, .pName = "main"},
{.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, .stage = VK_SHADER_STAGE_FRAGMENT_BIT, .module = frag, .pName = "main"}};
VkPipelineVertexInputStateCreateInfo vi = {.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO};
VkPipelineInputAssemblyStateCreateInfo ia = {.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO,
.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP};
VkDynamicState dyn[2] = {VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR};
VkPipelineDynamicStateCreateInfo ds = {.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO,
.dynamicStateCount = 2, .pDynamicStates = dyn};
VkPipelineViewportStateCreateInfo vp = {.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO,
.viewportCount = 1, .scissorCount = 1};
VkPipelineRasterizationStateCreateInfo rs = {.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO,
.polygonMode = VK_POLYGON_MODE_FILL, .cullMode = VK_CULL_MODE_NONE,
.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE, .lineWidth = 1.0f};
VkPipelineMultisampleStateCreateInfo ms = {.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO,
.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT};
VkPipelineColorBlendAttachmentState ba = {.blendEnable = VK_FALSE, .colorWriteMask = 0xF};
VkPipelineColorBlendStateCreateInfo cb = {.sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO,
.attachmentCount = 1, .pAttachments = &ba};
VkGraphicsPipelineCreateInfo pi = {.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO, .stageCount = 2,
.pStages = st, .pVertexInputState = &vi, .pInputAssemblyState = &ia,
.pViewportState = &vp, .pRasterizationState = &rs, .pMultisampleState = &ms,
.pColorBlendState = &cb, .pDynamicState = &ds, .layout = g_pl,
.renderPass = g_rp, .subpass = 0};
VkResult r = g_vk.CreateGraphicsPipelines(g_dev, VK_NULL_HANDLE, 1, &pi, NULL, &g_pipe);
g_vk.DestroyShaderModule(g_dev, vert, NULL);
g_vk.DestroyShaderModule(g_dev, frag, NULL);
if (r != VK_SUCCESS) goto fail;
}
g_ready = 1;
droiddeck_log("cardboard", "pass ready on %s", vkp_gpu_name());
return 0;
fail:
objects_destroy();
g_ready = -1;
droiddeck_log("error", "cardboard: the pass could not be built on this driver; frames are shown flat");
return -1;
}
static VkDescriptorSet source_ds(VkImage src) {
if (g_nsrc >= CB_MAX_SOURCES) return VK_NULL_HANDLE;
VkImageView view = make_view(src);
if (!view) return VK_NULL_HANDLE;
VkDescriptorSet ds = VK_NULL_HANDLE;
VkDescriptorSetAllocateInfo ai = {.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO,
.descriptorPool = g_dpool, .descriptorSetCount = 1, .pSetLayouts = &g_dsl};
if (g_vk.AllocateDescriptorSets(g_dev, &ai, &ds) != VK_SUCCESS) {
g_vk.DestroyImageView(g_dev, view, NULL);
return VK_NULL_HANDLE;
}
VkDescriptorImageInfo ii = {.sampler = g_sampler, .imageView = view, .imageLayout = VK_IMAGE_LAYOUT_GENERAL};
VkWriteDescriptorSet wr = {.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, .dstSet = ds, .dstBinding = 0,
.descriptorCount = 1, .descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
.pImageInfo = &ii};
g_vk.UpdateDescriptorSets(g_dev, 1, &wr, 0, NULL);
g_src[g_nsrc].view = view;
g_src[g_nsrc].ds = ds;
g_nsrc++;
return ds;
}
VkImage vkp_cardboard_run(VkCommandBuffer cmd, VkImage src, int src_w, int src_h, int out_w, int out_h) {
if (out_w < 2 || out_h < 1 || objects_ensure() != 0 || target_ensure(out_w, out_h) != 0) return VK_NULL_HANDLE;
VkDescriptorSet ds = source_ds(src);
if (!ds) {
static int said;
if (!said) { said = 1; droiddeck_log("error", "cardboard: no descriptor for the frame; shown flat"); }
return VK_NULL_HANDLE;
}
if (g_eye_w != out_w / 2 || g_eye_h != out_h) {
pthread_mutex_lock(&g_lock);
g_eye_w = out_w / 2;
g_eye_h = out_h;
shm_publish(g_live);
pthread_mutex_unlock(&g_lock);
}
snapshot();
struct { float ndc[4]; float rest[24]; } pc = {{-1.0f, -1.0f, 1.0f, 1.0f}, {0}};
cardboard_push_constants(&g_cur, g_cur_pose, src_w, src_h, out_w, out_h, pc.rest);
VkRenderPassBeginInfo rpi = {.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO, .renderPass = g_rp,
.framebuffer = g_t.fb, .renderArea = {{0, 0}, {(uint32_t)out_w, (uint32_t)out_h}}};
VkViewport vp = {0, 0, (float)out_w, (float)out_h, 0, 1};
VkRect2D sc = {{0, 0}, {(uint32_t)out_w, (uint32_t)out_h}};
g_vk.CmdBeginRenderPass(cmd, &rpi, VK_SUBPASS_CONTENTS_INLINE);
g_vk.CmdSetViewport(cmd, 0, 1, &vp);
g_vk.CmdSetScissor(cmd, 0, 1, &sc);
g_vk.CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, g_pipe);
g_vk.CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, g_pl, 0, 1, &ds, 0, NULL);
g_vk.CmdPushConstants(cmd, g_pl, VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0, sizeof(pc), &pc);
g_vk.CmdDraw(cmd, 4, 1, 0, 0);
g_vk.CmdEndRenderPass(cmd);
return g_t.img;
}
#endif /* CARDBOARD_HOST_TEST */
@@ -0,0 +1,83 @@
#version 450
// Cardboard pass (cardboard.c): the finished frame -> two eye viewports, side by side, each
// barrel-distorted for a phone-in-a-viewer lens. Runs on upscale.vert's full quad, so uv covers
// the whole panel with (0,0) at the top left.
//
// Source modes:
// 0 = virtual screen: the frame is a flat screen hanging in front of the viewer. Each eye casts
// a ray through its lens, rotated by the head pose, from its own position (half the IPD to
// either side), so the screen has real stereo depth and stays put while the head turns.
// 1 = side by side: the frame already holds a left and a right eye image (SteamVR, an SBS 3D
// game or video); each eye samples its own half. The head pose belongs to whoever rendered
// it, so none is applied here.
//
// Regenerate cardboard_frag.h after editing (glslangValidator, see cardboard.c).
layout(binding = 0) uniform sampler2D Source;
layout(location = 0) in vec2 uv;
layout(location = 0) out vec4 outColor;
layout(push_constant) uniform PC {
vec4 ndc; // upscale.vert's quad
vec4 rot0; // xyz: head -> world rotation, row 0; w: half the IPD (m)
vec4 rot1; // xyz: row 1; w: distance to the virtual screen (m)
vec4 rot2; // xyz: row 2; w: half the virtual screen's width (m)
vec4 lens; // x: k1, y: k2, z: tan of half the eye's vertical field of view, w: chromatic spread
vec4 view; // x: lens centre inset (fraction of an eye viewport), y: source aspect w/h,
// z: source mode, w: eye viewport aspect w/h
} pc;
const vec3 BACKDROP = vec3(0.015, 0.015, 0.02);
// Lens: a point on the panel at tangent-space offset t (from the lens centre) shows the ray that
// leaves the eye at t * (1 + k1 r^2 + k2 r^4). Sampling outwards like this draws the picture
// barrel-shaped; the viewer's lens pincushions it back.
vec2 undistort(vec2 t, float scale) {
float r2 = dot(t, t);
return t * scale * (1.0 + pc.lens.x * r2 + pc.lens.y * r2 * r2);
}
// What one eye sees along tangent direction tan (x right, y down): colour, or the backdrop.
vec3 eyeSample(vec2 tan, float eye) {
if (pc.view.z > 0.5) {
// Side by side: this eye's half of the source covers the eye's field of view.
float tanH = pc.lens.z;
vec2 s = vec2(tan.x / (tanH * pc.view.w), tan.y / tanH) * 0.5 + 0.5;
if (any(lessThan(s, vec2(0.0))) || any(greaterThan(s, vec2(1.0)))) return vec3(0.0);
return texture(Source, vec2((s.x + eye) * 0.5, s.y)).rgb;
}
// mat3() takes columns; the push constants carry rows.
mat3 R = transpose(mat3(pc.rot0.xyz, pc.rot1.xyz, pc.rot2.xyz));
vec3 d = R * normalize(vec3(tan.x, -tan.y, -1.0));
vec3 o = R * vec3((eye < 0.5 ? -1.0 : 1.0) * pc.rot0.w, 0.0, 0.0);
if (d.z > -1e-4) return BACKDROP; // looking away from the screen
float t = (-pc.rot1.w - o.z) / d.z;
vec3 hit = o + d * t;
float halfW = pc.rot2.w, halfH = halfW / pc.view.y;
vec2 s = vec2(hit.x / halfW, -hit.y / halfH) * 0.5 + 0.5;
if (any(lessThan(s, vec2(0.0))) || any(greaterThan(s, vec2(1.0)))) return BACKDROP;
return texture(Source, s).rgb;
}
void main() {
float eye = uv.x < 0.5 ? 0.0 : 1.0;
vec2 local = vec2(uv.x * 2.0 - eye, uv.y);
// Lens centres sit inset towards the nose by view.x of a viewport.
float cx = 0.5 + (eye < 0.5 ? pc.view.x : -pc.view.x);
vec2 p = local - vec2(cx, 0.5);
float tanH = pc.lens.z;
vec2 t = vec2(p.x * pc.view.w, p.y) * 2.0 * tanH;
vec3 c;
if (pc.lens.w > 0.0) {
// Chromatic aberration: the lens bends red least and blue most; pre-spread to match.
c.r = eyeSample(undistort(t, 1.0 - pc.lens.w), eye).r;
c.g = eyeSample(undistort(t, 1.0), eye).g;
c.b = eyeSample(undistort(t, 1.0 + pc.lens.w), eye).b;
} else {
c = eyeSample(undistort(t, 1.0), eye);
}
// Fade out towards each viewport's edge so the lens rim never shows a hard cut.
vec2 e = min(local, 1.0 - local);
float fade = smoothstep(0.0, 0.03, min(e.x * pc.view.w, e.y));
outColor = vec4(c * fade, 1.0);
}
@@ -0,0 +1,63 @@
#ifndef CARDBOARD_H
#define CARDBOARD_H
/*
* Cardboard mode: the last stage of the compositor pass. The finished frame (after the effects and
* frame generation) is drawn twice, once per eye, side by side on the panel, barrel-distorted for a
* phone-in-a-viewer lens (cardboard.frag). The frame either hangs as a flat virtual screen in front
* of the viewer, world-locked by the phone's rotation sensor (3DoF), or is already a side-by-side
* stereo pair (SteamVR, SBS 3D content) that only gets the lens treatment.
*
* Present-pass order with Cardboard on (vk_present.c, render_impl):
* scene -> effects -> frame generation -> vkp_cardboard_run (whole panel) -> 1:1 blit -> present
* The Fullscreen Mode mapping does not apply: the pass fills the output itself.
*
* Settings and the head pose come from any thread (JNI, the sensor thread); the compositor thread
* snapshots them per frame. A new pose redraws even a static scene (vkp_cardboard_pose_changed), so
* the virtual screen stays put while the head turns.
*/
#define VK_USE_PLATFORM_ANDROID_KHR
#include <vulkan/vulkan.h>
enum { CARDBOARD_SOURCE_SCREEN = 0, CARDBOARD_SOURCE_SBS = 1 };
struct cardboard_params {
int enabled;
int source; /* CARDBOARD_SOURCE_* */
float ipd_m; /* distance between the eyes, metres */
float screen_dist_m; /* virtual screen distance, metres */
float screen_width_m;/* virtual screen width, metres */
float k1, k2; /* lens distortion coefficients */
float fov_deg; /* vertical field of view of one eye before distortion */
float lens_inset; /* lens centre inset towards the nose, fraction of an eye viewport */
float chroma; /* chromatic aberration pre-correction (0 = off) */
};
/* Any thread. Values are clamped to sane ranges. */
void vkp_cardboard_set(const struct cardboard_params *p);
/* Any thread: the head -> world rotation, row-major 3x3. Identity = looking straight at the screen.
* live = a sensor produced it. Also published to the guest (headpose_shm.h) once a file is set. */
void vkp_cardboard_set_pose(const float m[9], int live);
/* Any thread, once: where the guest's copy of the pose lives (<files>/.wayland-rt/droiddeck-headpose). */
void vkp_cardboard_set_pose_file(const char *path);
/* Compositor thread. */
void vkp_cardboard_bind_device(VkDevice dev, const VkPhysicalDeviceMemoryProperties *memprops);
/* 1 while Cardboard mode is on (the frame must take the compositor pass). */
int vkp_cardboard_active(void);
/* 1 when the settings or the pose changed since the last call: the caller redraws. */
int vkp_cardboard_pose_changed(void);
/* Called once per frame before any vkp_cardboard_run: releases the previous frame's source views
* (that frame is fenced by now). */
void vkp_cardboard_begin_frame(void);
/* Records the pass into cmd: src (src_w x src_h, R8G8B8A8, in GENERAL) -> a panel-sized image
* (out_w x out_h), returned in GENERAL. VK_NULL_HANDLE when the pass is unavailable (the caller
* presents the frame the usual way). */
VkImage vkp_cardboard_run(VkCommandBuffer cmd, VkImage src, int src_w, int src_h, int out_w, int out_h);
/* Pure helpers, shared with the host test (tools/tests). */
void cardboard_clamp(struct cardboard_params *p);
/* The 24 floats after the quad's ndc in the shader's push constants. */
void cardboard_push_constants(const struct cardboard_params *p, const float pose[9],
int src_w, int src_h, int out_w, int out_h, float out[24]);
#endif
@@ -0,0 +1,266 @@
// 1116.2.0
#pragma once
const uint32_t cardboard_frag_code[] = {
0x07230203,0x00010000,0x0008000b,0x0000016d,0x00000000,0x00020011,0x00000001,0x0006000b,
0x00000001,0x4c534c47,0x6474732e,0x3035342e,0x00000000,0x0003000e,0x00000000,0x00000001,
0x0007000f,0x00000004,0x00000004,0x6e69616d,0x00000000,0x000000ee,0x00000165,0x00030010,
0x00000004,0x00000007,0x00030003,0x00000002,0x000001c2,0x00040005,0x00000004,0x6e69616d,
0x00000000,0x00070005,0x0000000d,0x69646e75,0x726f7473,0x66762874,0x31663b32,0x0000003b,
0x00030005,0x0000000b,0x00000074,0x00040005,0x0000000c,0x6c616373,0x00000065,0x00070005,
0x00000013,0x53657965,0x6c706d61,0x66762865,0x31663b32,0x0000003b,0x00030005,0x00000011,
0x006e6174,0x00030005,0x00000012,0x00657965,0x00030005,0x00000015,0x00003272,0x00030005,
0x0000001e,0x00004350,0x00040006,0x0000001e,0x00000000,0x0063646e,0x00050006,0x0000001e,
0x00000001,0x30746f72,0x00000000,0x00050006,0x0000001e,0x00000002,0x31746f72,0x00000000,
0x00050006,0x0000001e,0x00000003,0x32746f72,0x00000000,0x00050006,0x0000001e,0x00000004,
0x736e656c,0x00000000,0x00050006,0x0000001e,0x00000005,0x77656976,0x00000000,0x00030005,
0x00000020,0x00006370,0x00040005,0x0000003f,0x486e6174,0x00000000,0x00030005,0x00000042,
0x00000073,0x00040005,0x00000068,0x72756f53,0x00006563,0x00030005,0x00000077,0x00000052,
0x00030005,0x00000094,0x00000064,0x00030005,0x0000009f,0x0000006f,0x00030005,0x000000b3,
0x00000074,0x00030005,0x000000bd,0x00746968,0x00040005,0x000000c3,0x666c6168,0x00000057,
0x00040005,0x000000c6,0x666c6168,0x00000048,0x00030005,0x000000cb,0x00000073,0x00030005,
0x000000ec,0x00657965,0x00030005,0x000000ee,0x00007675,0x00040005,0x000000f4,0x61636f6c,
0x0000006c,0x00030005,0x000000fe,0x00007863,0x00030005,0x0000010c,0x00000070,0x00040005,
0x00000111,0x486e6174,0x00000000,0x00030005,0x00000114,0x00000074,0x00030005,0x00000125,
0x00000063,0x00040005,0x00000129,0x61726170,0x0000006d,0x00040005,0x0000012b,0x61726170,
0x0000006d,0x00040005,0x0000012d,0x61726170,0x0000006d,0x00040005,0x0000012e,0x61726170,
0x0000006d,0x00040005,0x00000133,0x61726170,0x0000006d,0x00040005,0x00000135,0x61726170,
0x0000006d,0x00040005,0x00000137,0x61726170,0x0000006d,0x00040005,0x00000138,0x61726170,
0x0000006d,0x00040005,0x00000140,0x61726170,0x0000006d,0x00040005,0x00000142,0x61726170,
0x0000006d,0x00040005,0x00000144,0x61726170,0x0000006d,0x00040005,0x00000145,0x61726170,
0x0000006d,0x00040005,0x0000014b,0x61726170,0x0000006d,0x00040005,0x0000014d,0x61726170,
0x0000006d,0x00040005,0x0000014f,0x61726170,0x0000006d,0x00040005,0x00000150,0x61726170,
0x0000006d,0x00030005,0x00000153,0x00000065,0x00040005,0x00000159,0x65646166,0x00000000,
0x00050005,0x00000165,0x4374756f,0x726f6c6f,0x00000000,0x00030047,0x0000001e,0x00000002,
0x00050048,0x0000001e,0x00000000,0x00000023,0x00000000,0x00050048,0x0000001e,0x00000001,
0x00000023,0x00000010,0x00050048,0x0000001e,0x00000002,0x00000023,0x00000020,0x00050048,
0x0000001e,0x00000003,0x00000023,0x00000030,0x00050048,0x0000001e,0x00000004,0x00000023,
0x00000040,0x00050048,0x0000001e,0x00000005,0x00000023,0x00000050,0x00040047,0x00000068,
0x00000021,0x00000000,0x00040047,0x00000068,0x00000022,0x00000000,0x00040047,0x000000ee,
0x0000001e,0x00000000,0x00040047,0x00000165,0x0000001e,0x00000000,0x00020013,0x00000002,
0x00030021,0x00000003,0x00000002,0x00030016,0x00000006,0x00000020,0x00040017,0x00000007,
0x00000006,0x00000002,0x00040020,0x00000008,0x00000007,0x00000007,0x00040020,0x00000009,
0x00000007,0x00000006,0x00050021,0x0000000a,0x00000007,0x00000008,0x00000009,0x00040017,
0x0000000f,0x00000006,0x00000003,0x00050021,0x00000010,0x0000000f,0x00000008,0x00000009,
0x0004002b,0x00000006,0x0000001c,0x3f800000,0x00040017,0x0000001d,0x00000006,0x00000004,
0x0008001e,0x0000001e,0x0000001d,0x0000001d,0x0000001d,0x0000001d,0x0000001d,0x0000001d,
0x00040020,0x0000001f,0x00000009,0x0000001e,0x0004003b,0x0000001f,0x00000020,0x00000009,
0x00040015,0x00000021,0x00000020,0x00000001,0x0004002b,0x00000021,0x00000022,0x00000004,
0x00040015,0x00000023,0x00000020,0x00000000,0x0004002b,0x00000023,0x00000024,0x00000000,
0x00040020,0x00000025,0x00000009,0x00000006,0x0004002b,0x00000023,0x0000002b,0x00000001,
0x0004002b,0x00000021,0x00000036,0x00000005,0x0004002b,0x00000023,0x00000037,0x00000002,
0x0004002b,0x00000006,0x0000003a,0x3f000000,0x00020014,0x0000003b,0x0004002b,0x00000023,
0x00000046,0x00000003,0x0004002b,0x00000006,0x00000054,0x00000000,0x0005002c,0x00000007,
0x00000055,0x00000054,0x00000054,0x00040017,0x00000056,0x0000003b,0x00000002,0x0005002c,
0x00000007,0x0000005d,0x0000001c,0x0000001c,0x0006002c,0x0000000f,0x00000063,0x00000054,
0x00000054,0x00000054,0x00090019,0x00000065,0x00000006,0x00000001,0x00000000,0x00000000,
0x00000000,0x00000001,0x00000000,0x0003001b,0x00000066,0x00000065,0x00040020,0x00000067,
0x00000000,0x00000066,0x0004003b,0x00000067,0x00000068,0x00000000,0x00040018,0x00000075,
0x0000000f,0x00000003,0x00040020,0x00000076,0x00000007,0x00000075,0x0004002b,0x00000021,
0x00000078,0x00000001,0x00040020,0x00000079,0x00000009,0x0000001d,0x0004002b,0x00000021,
0x0000007d,0x00000002,0x0004002b,0x00000021,0x00000081,0x00000003,0x00040020,0x00000093,
0x00000007,0x0000000f,0x0004002b,0x00000006,0x0000009b,0xbf800000,0x0004002b,0x00000006,
0x000000ab,0xb8d1b717,0x0004002b,0x00000006,0x000000af,0x3c75c28f,0x0004002b,0x00000006,
0x000000b0,0x3ca3d70a,0x0006002c,0x0000000f,0x000000b1,0x000000af,0x000000af,0x000000b0,
0x00040020,0x000000ed,0x00000001,0x00000007,0x0004003b,0x000000ed,0x000000ee,0x00000001,
0x00040020,0x000000ef,0x00000001,0x00000006,0x0004002b,0x00000006,0x000000f7,0x40000000,
0x0004002b,0x00000006,0x0000015a,0x3cf5c28f,0x00040020,0x00000164,0x00000003,0x0000001d,
0x0004003b,0x00000164,0x00000165,0x00000003,0x00050036,0x00000002,0x00000004,0x00000000,
0x00000003,0x000200f8,0x00000005,0x0004003b,0x00000009,0x000000ec,0x00000007,0x0004003b,
0x00000008,0x000000f4,0x00000007,0x0004003b,0x00000009,0x000000fe,0x00000007,0x0004003b,
0x00000009,0x00000101,0x00000007,0x0004003b,0x00000008,0x0000010c,0x00000007,0x0004003b,
0x00000009,0x00000111,0x00000007,0x0004003b,0x00000008,0x00000114,0x00000007,0x0004003b,
0x00000093,0x00000125,0x00000007,0x0004003b,0x00000008,0x00000129,0x00000007,0x0004003b,
0x00000009,0x0000012b,0x00000007,0x0004003b,0x00000008,0x0000012d,0x00000007,0x0004003b,
0x00000009,0x0000012e,0x00000007,0x0004003b,0x00000008,0x00000133,0x00000007,0x0004003b,
0x00000009,0x00000135,0x00000007,0x0004003b,0x00000008,0x00000137,0x00000007,0x0004003b,
0x00000009,0x00000138,0x00000007,0x0004003b,0x00000008,0x00000140,0x00000007,0x0004003b,
0x00000009,0x00000142,0x00000007,0x0004003b,0x00000008,0x00000144,0x00000007,0x0004003b,
0x00000009,0x00000145,0x00000007,0x0004003b,0x00000008,0x0000014b,0x00000007,0x0004003b,
0x00000009,0x0000014d,0x00000007,0x0004003b,0x00000008,0x0000014f,0x00000007,0x0004003b,
0x00000009,0x00000150,0x00000007,0x0004003b,0x00000008,0x00000153,0x00000007,0x0004003b,
0x00000009,0x00000159,0x00000007,0x00050041,0x000000ef,0x000000f0,0x000000ee,0x00000024,
0x0004003d,0x00000006,0x000000f1,0x000000f0,0x000500b8,0x0000003b,0x000000f2,0x000000f1,
0x0000003a,0x000600a9,0x00000006,0x000000f3,0x000000f2,0x00000054,0x0000001c,0x0003003e,
0x000000ec,0x000000f3,0x00050041,0x000000ef,0x000000f5,0x000000ee,0x00000024,0x0004003d,
0x00000006,0x000000f6,0x000000f5,0x00050085,0x00000006,0x000000f8,0x000000f6,0x000000f7,
0x0004003d,0x00000006,0x000000f9,0x000000ec,0x00050083,0x00000006,0x000000fa,0x000000f8,
0x000000f9,0x00050041,0x000000ef,0x000000fb,0x000000ee,0x0000002b,0x0004003d,0x00000006,
0x000000fc,0x000000fb,0x00050050,0x00000007,0x000000fd,0x000000fa,0x000000fc,0x0003003e,
0x000000f4,0x000000fd,0x0004003d,0x00000006,0x000000ff,0x000000ec,0x000500b8,0x0000003b,
0x00000100,0x000000ff,0x0000003a,0x000300f7,0x00000103,0x00000000,0x000400fa,0x00000100,
0x00000102,0x00000106,0x000200f8,0x00000102,0x00060041,0x00000025,0x00000104,0x00000020,
0x00000036,0x00000024,0x0004003d,0x00000006,0x00000105,0x00000104,0x0003003e,0x00000101,
0x00000105,0x000200f9,0x00000103,0x000200f8,0x00000106,0x00060041,0x00000025,0x00000107,
0x00000020,0x00000036,0x00000024,0x0004003d,0x00000006,0x00000108,0x00000107,0x0004007f,
0x00000006,0x00000109,0x00000108,0x0003003e,0x00000101,0x00000109,0x000200f9,0x00000103,
0x000200f8,0x00000103,0x0004003d,0x00000006,0x0000010a,0x00000101,0x00050081,0x00000006,
0x0000010b,0x0000003a,0x0000010a,0x0003003e,0x000000fe,0x0000010b,0x0004003d,0x00000007,
0x0000010d,0x000000f4,0x0004003d,0x00000006,0x0000010e,0x000000fe,0x00050050,0x00000007,
0x0000010f,0x0000010e,0x0000003a,0x00050083,0x00000007,0x00000110,0x0000010d,0x0000010f,
0x0003003e,0x0000010c,0x00000110,0x00060041,0x00000025,0x00000112,0x00000020,0x00000022,
0x00000037,0x0004003d,0x00000006,0x00000113,0x00000112,0x0003003e,0x00000111,0x00000113,
0x00050041,0x00000009,0x00000115,0x0000010c,0x00000024,0x0004003d,0x00000006,0x00000116,
0x00000115,0x00060041,0x00000025,0x00000117,0x00000020,0x00000036,0x00000046,0x0004003d,
0x00000006,0x00000118,0x00000117,0x00050085,0x00000006,0x00000119,0x00000116,0x00000118,
0x00050041,0x00000009,0x0000011a,0x0000010c,0x0000002b,0x0004003d,0x00000006,0x0000011b,
0x0000011a,0x00050050,0x00000007,0x0000011c,0x00000119,0x0000011b,0x0005008e,0x00000007,
0x0000011d,0x0000011c,0x000000f7,0x0004003d,0x00000006,0x0000011e,0x00000111,0x0005008e,
0x00000007,0x0000011f,0x0000011d,0x0000011e,0x0003003e,0x00000114,0x0000011f,0x00060041,
0x00000025,0x00000120,0x00000020,0x00000022,0x00000046,0x0004003d,0x00000006,0x00000121,
0x00000120,0x000500ba,0x0000003b,0x00000122,0x00000121,0x00000054,0x000300f7,0x00000124,
0x00000000,0x000400fa,0x00000122,0x00000123,0x0000014a,0x000200f8,0x00000123,0x00060041,
0x00000025,0x00000126,0x00000020,0x00000022,0x00000046,0x0004003d,0x00000006,0x00000127,
0x00000126,0x00050083,0x00000006,0x00000128,0x0000001c,0x00000127,0x0004003d,0x00000007,
0x0000012a,0x00000114,0x0003003e,0x00000129,0x0000012a,0x0003003e,0x0000012b,0x00000128,
0x00060039,0x00000007,0x0000012c,0x0000000d,0x00000129,0x0000012b,0x0003003e,0x0000012d,
0x0000012c,0x0004003d,0x00000006,0x0000012f,0x000000ec,0x0003003e,0x0000012e,0x0000012f,
0x00060039,0x0000000f,0x00000130,0x00000013,0x0000012d,0x0000012e,0x00050051,0x00000006,
0x00000131,0x00000130,0x00000000,0x00050041,0x00000009,0x00000132,0x00000125,0x00000024,
0x0003003e,0x00000132,0x00000131,0x0004003d,0x00000007,0x00000134,0x00000114,0x0003003e,
0x00000133,0x00000134,0x0003003e,0x00000135,0x0000001c,0x00060039,0x00000007,0x00000136,
0x0000000d,0x00000133,0x00000135,0x0003003e,0x00000137,0x00000136,0x0004003d,0x00000006,
0x00000139,0x000000ec,0x0003003e,0x00000138,0x00000139,0x00060039,0x0000000f,0x0000013a,
0x00000013,0x00000137,0x00000138,0x00050051,0x00000006,0x0000013b,0x0000013a,0x00000001,
0x00050041,0x00000009,0x0000013c,0x00000125,0x0000002b,0x0003003e,0x0000013c,0x0000013b,
0x00060041,0x00000025,0x0000013d,0x00000020,0x00000022,0x00000046,0x0004003d,0x00000006,
0x0000013e,0x0000013d,0x00050081,0x00000006,0x0000013f,0x0000001c,0x0000013e,0x0004003d,
0x00000007,0x00000141,0x00000114,0x0003003e,0x00000140,0x00000141,0x0003003e,0x00000142,
0x0000013f,0x00060039,0x00000007,0x00000143,0x0000000d,0x00000140,0x00000142,0x0003003e,
0x00000144,0x00000143,0x0004003d,0x00000006,0x00000146,0x000000ec,0x0003003e,0x00000145,
0x00000146,0x00060039,0x0000000f,0x00000147,0x00000013,0x00000144,0x00000145,0x00050051,
0x00000006,0x00000148,0x00000147,0x00000002,0x00050041,0x00000009,0x00000149,0x00000125,
0x00000037,0x0003003e,0x00000149,0x00000148,0x000200f9,0x00000124,0x000200f8,0x0000014a,
0x0004003d,0x00000007,0x0000014c,0x00000114,0x0003003e,0x0000014b,0x0000014c,0x0003003e,
0x0000014d,0x0000001c,0x00060039,0x00000007,0x0000014e,0x0000000d,0x0000014b,0x0000014d,
0x0003003e,0x0000014f,0x0000014e,0x0004003d,0x00000006,0x00000151,0x000000ec,0x0003003e,
0x00000150,0x00000151,0x00060039,0x0000000f,0x00000152,0x00000013,0x0000014f,0x00000150,
0x0003003e,0x00000125,0x00000152,0x000200f9,0x00000124,0x000200f8,0x00000124,0x0004003d,
0x00000007,0x00000154,0x000000f4,0x0004003d,0x00000007,0x00000155,0x000000f4,0x00050050,
0x00000007,0x00000156,0x0000001c,0x0000001c,0x00050083,0x00000007,0x00000157,0x00000156,
0x00000155,0x0007000c,0x00000007,0x00000158,0x00000001,0x00000025,0x00000154,0x00000157,
0x0003003e,0x00000153,0x00000158,0x00050041,0x00000009,0x0000015b,0x00000153,0x00000024,
0x0004003d,0x00000006,0x0000015c,0x0000015b,0x00060041,0x00000025,0x0000015d,0x00000020,
0x00000036,0x00000046,0x0004003d,0x00000006,0x0000015e,0x0000015d,0x00050085,0x00000006,
0x0000015f,0x0000015c,0x0000015e,0x00050041,0x00000009,0x00000160,0x00000153,0x0000002b,
0x0004003d,0x00000006,0x00000161,0x00000160,0x0007000c,0x00000006,0x00000162,0x00000001,
0x00000025,0x0000015f,0x00000161,0x0008000c,0x00000006,0x00000163,0x00000001,0x00000031,
0x00000054,0x0000015a,0x00000162,0x0003003e,0x00000159,0x00000163,0x0004003d,0x0000000f,
0x00000166,0x00000125,0x0004003d,0x00000006,0x00000167,0x00000159,0x0005008e,0x0000000f,
0x00000168,0x00000166,0x00000167,0x00050051,0x00000006,0x00000169,0x00000168,0x00000000,
0x00050051,0x00000006,0x0000016a,0x00000168,0x00000001,0x00050051,0x00000006,0x0000016b,
0x00000168,0x00000002,0x00070050,0x0000001d,0x0000016c,0x00000169,0x0000016a,0x0000016b,
0x0000001c,0x0003003e,0x00000165,0x0000016c,0x000100fd,0x00010038,0x00050036,0x00000007,
0x0000000d,0x00000000,0x0000000a,0x00030037,0x00000008,0x0000000b,0x00030037,0x00000009,
0x0000000c,0x000200f8,0x0000000e,0x0004003b,0x00000009,0x00000015,0x00000007,0x0004003d,
0x00000007,0x00000016,0x0000000b,0x0004003d,0x00000007,0x00000017,0x0000000b,0x00050094,
0x00000006,0x00000018,0x00000016,0x00000017,0x0003003e,0x00000015,0x00000018,0x0004003d,
0x00000007,0x00000019,0x0000000b,0x0004003d,0x00000006,0x0000001a,0x0000000c,0x0005008e,
0x00000007,0x0000001b,0x00000019,0x0000001a,0x00060041,0x00000025,0x00000026,0x00000020,
0x00000022,0x00000024,0x0004003d,0x00000006,0x00000027,0x00000026,0x0004003d,0x00000006,
0x00000028,0x00000015,0x00050085,0x00000006,0x00000029,0x00000027,0x00000028,0x00050081,
0x00000006,0x0000002a,0x0000001c,0x00000029,0x00060041,0x00000025,0x0000002c,0x00000020,
0x00000022,0x0000002b,0x0004003d,0x00000006,0x0000002d,0x0000002c,0x0004003d,0x00000006,
0x0000002e,0x00000015,0x00050085,0x00000006,0x0000002f,0x0000002d,0x0000002e,0x0004003d,
0x00000006,0x00000030,0x00000015,0x00050085,0x00000006,0x00000031,0x0000002f,0x00000030,
0x00050081,0x00000006,0x00000032,0x0000002a,0x00000031,0x0005008e,0x00000007,0x00000033,
0x0000001b,0x00000032,0x000200fe,0x00000033,0x00010038,0x00050036,0x0000000f,0x00000013,
0x00000000,0x00000010,0x00030037,0x00000008,0x00000011,0x00030037,0x00000009,0x00000012,
0x000200f8,0x00000014,0x0004003b,0x00000009,0x0000003f,0x00000007,0x0004003b,0x00000008,
0x00000042,0x00000007,0x0004003b,0x00000076,0x00000077,0x00000007,0x0004003b,0x00000093,
0x00000094,0x00000007,0x0004003b,0x00000093,0x0000009f,0x00000007,0x0004003b,0x00000009,
0x000000b3,0x00000007,0x0004003b,0x00000093,0x000000bd,0x00000007,0x0004003b,0x00000009,
0x000000c3,0x00000007,0x0004003b,0x00000009,0x000000c6,0x00000007,0x0004003b,0x00000008,
0x000000cb,0x00000007,0x00060041,0x00000025,0x00000038,0x00000020,0x00000036,0x00000037,
0x0004003d,0x00000006,0x00000039,0x00000038,0x000500ba,0x0000003b,0x0000003c,0x00000039,
0x0000003a,0x000300f7,0x0000003e,0x00000000,0x000400fa,0x0000003c,0x0000003d,0x0000003e,
0x000200f8,0x0000003d,0x00060041,0x00000025,0x00000040,0x00000020,0x00000022,0x00000037,
0x0004003d,0x00000006,0x00000041,0x00000040,0x0003003e,0x0000003f,0x00000041,0x00050041,
0x00000009,0x00000043,0x00000011,0x00000024,0x0004003d,0x00000006,0x00000044,0x00000043,
0x0004003d,0x00000006,0x00000045,0x0000003f,0x00060041,0x00000025,0x00000047,0x00000020,
0x00000036,0x00000046,0x0004003d,0x00000006,0x00000048,0x00000047,0x00050085,0x00000006,
0x00000049,0x00000045,0x00000048,0x00050088,0x00000006,0x0000004a,0x00000044,0x00000049,
0x00050041,0x00000009,0x0000004b,0x00000011,0x0000002b,0x0004003d,0x00000006,0x0000004c,
0x0000004b,0x0004003d,0x00000006,0x0000004d,0x0000003f,0x00050088,0x00000006,0x0000004e,
0x0000004c,0x0000004d,0x00050050,0x00000007,0x0000004f,0x0000004a,0x0000004e,0x0005008e,
0x00000007,0x00000050,0x0000004f,0x0000003a,0x00050050,0x00000007,0x00000051,0x0000003a,
0x0000003a,0x00050081,0x00000007,0x00000052,0x00000050,0x00000051,0x0003003e,0x00000042,
0x00000052,0x0004003d,0x00000007,0x00000053,0x00000042,0x000500b8,0x00000056,0x00000057,
0x00000053,0x00000055,0x0004009a,0x0000003b,0x00000058,0x00000057,0x000400a8,0x0000003b,
0x00000059,0x00000058,0x000300f7,0x0000005b,0x00000000,0x000400fa,0x00000059,0x0000005a,
0x0000005b,0x000200f8,0x0000005a,0x0004003d,0x00000007,0x0000005c,0x00000042,0x000500ba,
0x00000056,0x0000005e,0x0000005c,0x0000005d,0x0004009a,0x0000003b,0x0000005f,0x0000005e,
0x000200f9,0x0000005b,0x000200f8,0x0000005b,0x000700f5,0x0000003b,0x00000060,0x00000058,
0x0000003d,0x0000005f,0x0000005a,0x000300f7,0x00000062,0x00000000,0x000400fa,0x00000060,
0x00000061,0x00000062,0x000200f8,0x00000061,0x000200fe,0x00000063,0x000200f8,0x00000062,
0x0004003d,0x00000066,0x00000069,0x00000068,0x00050041,0x00000009,0x0000006a,0x00000042,
0x00000024,0x0004003d,0x00000006,0x0000006b,0x0000006a,0x0004003d,0x00000006,0x0000006c,
0x00000012,0x00050081,0x00000006,0x0000006d,0x0000006b,0x0000006c,0x00050085,0x00000006,
0x0000006e,0x0000006d,0x0000003a,0x00050041,0x00000009,0x0000006f,0x00000042,0x0000002b,
0x0004003d,0x00000006,0x00000070,0x0000006f,0x00050050,0x00000007,0x00000071,0x0000006e,
0x00000070,0x00050057,0x0000001d,0x00000072,0x00000069,0x00000071,0x0008004f,0x0000000f,
0x00000073,0x00000072,0x00000072,0x00000000,0x00000001,0x00000002,0x000200fe,0x00000073,
0x000200f8,0x0000003e,0x00050041,0x00000079,0x0000007a,0x00000020,0x00000078,0x0004003d,
0x0000001d,0x0000007b,0x0000007a,0x0008004f,0x0000000f,0x0000007c,0x0000007b,0x0000007b,
0x00000000,0x00000001,0x00000002,0x00050041,0x00000079,0x0000007e,0x00000020,0x0000007d,
0x0004003d,0x0000001d,0x0000007f,0x0000007e,0x0008004f,0x0000000f,0x00000080,0x0000007f,
0x0000007f,0x00000000,0x00000001,0x00000002,0x00050041,0x00000079,0x00000082,0x00000020,
0x00000081,0x0004003d,0x0000001d,0x00000083,0x00000082,0x0008004f,0x0000000f,0x00000084,
0x00000083,0x00000083,0x00000000,0x00000001,0x00000002,0x00050051,0x00000006,0x00000085,
0x0000007c,0x00000000,0x00050051,0x00000006,0x00000086,0x0000007c,0x00000001,0x00050051,
0x00000006,0x00000087,0x0000007c,0x00000002,0x00050051,0x00000006,0x00000088,0x00000080,
0x00000000,0x00050051,0x00000006,0x00000089,0x00000080,0x00000001,0x00050051,0x00000006,
0x0000008a,0x00000080,0x00000002,0x00050051,0x00000006,0x0000008b,0x00000084,0x00000000,
0x00050051,0x00000006,0x0000008c,0x00000084,0x00000001,0x00050051,0x00000006,0x0000008d,
0x00000084,0x00000002,0x00060050,0x0000000f,0x0000008e,0x00000085,0x00000086,0x00000087,
0x00060050,0x0000000f,0x0000008f,0x00000088,0x00000089,0x0000008a,0x00060050,0x0000000f,
0x00000090,0x0000008b,0x0000008c,0x0000008d,0x00060050,0x00000075,0x00000091,0x0000008e,
0x0000008f,0x00000090,0x00040054,0x00000075,0x00000092,0x00000091,0x0003003e,0x00000077,
0x00000092,0x0004003d,0x00000075,0x00000095,0x00000077,0x00050041,0x00000009,0x00000096,
0x00000011,0x00000024,0x0004003d,0x00000006,0x00000097,0x00000096,0x00050041,0x00000009,
0x00000098,0x00000011,0x0000002b,0x0004003d,0x00000006,0x00000099,0x00000098,0x0004007f,
0x00000006,0x0000009a,0x00000099,0x00060050,0x0000000f,0x0000009c,0x00000097,0x0000009a,
0x0000009b,0x0006000c,0x0000000f,0x0000009d,0x00000001,0x00000045,0x0000009c,0x00050091,
0x0000000f,0x0000009e,0x00000095,0x0000009d,0x0003003e,0x00000094,0x0000009e,0x0004003d,
0x00000075,0x000000a0,0x00000077,0x0004003d,0x00000006,0x000000a1,0x00000012,0x000500b8,
0x0000003b,0x000000a2,0x000000a1,0x0000003a,0x000600a9,0x00000006,0x000000a3,0x000000a2,
0x0000009b,0x0000001c,0x00060041,0x00000025,0x000000a4,0x00000020,0x00000078,0x00000046,
0x0004003d,0x00000006,0x000000a5,0x000000a4,0x00050085,0x00000006,0x000000a6,0x000000a3,
0x000000a5,0x00060050,0x0000000f,0x000000a7,0x000000a6,0x00000054,0x00000054,0x00050091,
0x0000000f,0x000000a8,0x000000a0,0x000000a7,0x0003003e,0x0000009f,0x000000a8,0x00050041,
0x00000009,0x000000a9,0x00000094,0x00000037,0x0004003d,0x00000006,0x000000aa,0x000000a9,
0x000500ba,0x0000003b,0x000000ac,0x000000aa,0x000000ab,0x000300f7,0x000000ae,0x00000000,
0x000400fa,0x000000ac,0x000000ad,0x000000ae,0x000200f8,0x000000ad,0x000200fe,0x000000b1,
0x000200f8,0x000000ae,0x00060041,0x00000025,0x000000b4,0x00000020,0x0000007d,0x00000046,
0x0004003d,0x00000006,0x000000b5,0x000000b4,0x0004007f,0x00000006,0x000000b6,0x000000b5,
0x00050041,0x00000009,0x000000b7,0x0000009f,0x00000037,0x0004003d,0x00000006,0x000000b8,
0x000000b7,0x00050083,0x00000006,0x000000b9,0x000000b6,0x000000b8,0x00050041,0x00000009,
0x000000ba,0x00000094,0x00000037,0x0004003d,0x00000006,0x000000bb,0x000000ba,0x00050088,
0x00000006,0x000000bc,0x000000b9,0x000000bb,0x0003003e,0x000000b3,0x000000bc,0x0004003d,
0x0000000f,0x000000be,0x0000009f,0x0004003d,0x0000000f,0x000000bf,0x00000094,0x0004003d,
0x00000006,0x000000c0,0x000000b3,0x0005008e,0x0000000f,0x000000c1,0x000000bf,0x000000c0,
0x00050081,0x0000000f,0x000000c2,0x000000be,0x000000c1,0x0003003e,0x000000bd,0x000000c2,
0x00060041,0x00000025,0x000000c4,0x00000020,0x00000081,0x00000046,0x0004003d,0x00000006,
0x000000c5,0x000000c4,0x0003003e,0x000000c3,0x000000c5,0x0004003d,0x00000006,0x000000c7,
0x000000c3,0x00060041,0x00000025,0x000000c8,0x00000020,0x00000036,0x0000002b,0x0004003d,
0x00000006,0x000000c9,0x000000c8,0x00050088,0x00000006,0x000000ca,0x000000c7,0x000000c9,
0x0003003e,0x000000c6,0x000000ca,0x00050041,0x00000009,0x000000cc,0x000000bd,0x00000024,
0x0004003d,0x00000006,0x000000cd,0x000000cc,0x0004003d,0x00000006,0x000000ce,0x000000c3,
0x00050088,0x00000006,0x000000cf,0x000000cd,0x000000ce,0x00050041,0x00000009,0x000000d0,
0x000000bd,0x0000002b,0x0004003d,0x00000006,0x000000d1,0x000000d0,0x0004007f,0x00000006,
0x000000d2,0x000000d1,0x0004003d,0x00000006,0x000000d3,0x000000c6,0x00050088,0x00000006,
0x000000d4,0x000000d2,0x000000d3,0x00050050,0x00000007,0x000000d5,0x000000cf,0x000000d4,
0x0005008e,0x00000007,0x000000d6,0x000000d5,0x0000003a,0x00050050,0x00000007,0x000000d7,
0x0000003a,0x0000003a,0x00050081,0x00000007,0x000000d8,0x000000d6,0x000000d7,0x0003003e,
0x000000cb,0x000000d8,0x0004003d,0x00000007,0x000000d9,0x000000cb,0x000500b8,0x00000056,
0x000000da,0x000000d9,0x00000055,0x0004009a,0x0000003b,0x000000db,0x000000da,0x000400a8,
0x0000003b,0x000000dc,0x000000db,0x000300f7,0x000000de,0x00000000,0x000400fa,0x000000dc,
0x000000dd,0x000000de,0x000200f8,0x000000dd,0x0004003d,0x00000007,0x000000df,0x000000cb,
0x000500ba,0x00000056,0x000000e0,0x000000df,0x0000005d,0x0004009a,0x0000003b,0x000000e1,
0x000000e0,0x000200f9,0x000000de,0x000200f8,0x000000de,0x000700f5,0x0000003b,0x000000e2,
0x000000db,0x000000ae,0x000000e1,0x000000dd,0x000300f7,0x000000e4,0x00000000,0x000400fa,
0x000000e2,0x000000e3,0x000000e4,0x000200f8,0x000000e3,0x000200fe,0x000000b1,0x000200f8,
0x000000e4,0x0004003d,0x00000066,0x000000e6,0x00000068,0x0004003d,0x00000007,0x000000e7,
0x000000cb,0x00050057,0x0000001d,0x000000e8,0x000000e6,0x000000e7,0x0008004f,0x0000000f,
0x000000e9,0x000000e8,0x000000e8,0x00000000,0x00000001,0x00000002,0x000200fe,0x000000e9,
0x00010038
};
+69 -10
View File
@@ -50,6 +50,7 @@
#include "framegen_bridge.h"
#include <pthread.h>
#include "effects_chain.h"
#include "cardboard.h"
#include "droiddeck_ext.h"
#include "droiddeck_color.h"
#include "compositor_internal.h"
@@ -260,6 +261,7 @@ struct surface {
/* Toplevel placement on the scene. */
int mapped; /* in g_toplevels */
struct wl_list toplevel_link;
int keep_on_top; /* Cardboard mode's SteamVR window: above every other toplevel (raise_kept_on_top) */
int x, y, placed;
uint32_t hwnd;
@@ -291,6 +293,7 @@ static struct wl_event_source *g_render_idle, *g_frame_timer;
* window) a fallback timer renders instead. */
static int g_dirty; /* something changed since the last render */
static int64_t g_last_vsync_ns; /* last tick, 0 = none yet */
static int64_t g_tick_vsync_ns; /* the vsync that tick is for (Choreographer's frame time) */
static int64_t g_refresh_ns = 16666667; /* measured tick interval (pacing only, never reported) */
static struct wl_event_source *g_fallback_timer;
static int g_fallback_armed;
@@ -631,6 +634,8 @@ static struct surface *toplevel_by_hwnd(uint32_t hwnd) {
return NULL;
}
static void raise_kept_on_top(void);
/* Reorder the mapped toplevels to match the last reported Windows z-order. Windows the
* report doesn't mention keep their relative order below the ones it does. */
static void apply_zorder(void) {
@@ -640,6 +645,17 @@ static void apply_zorder(void) {
wl_list_remove(&s->toplevel_link);
wl_list_insert(g_toplevels.prev, &s->toplevel_link);
}
raise_kept_on_top();
}
/* Windows marked keep_on_top go back to the top of the stack (the end of g_toplevels). */
static void raise_kept_on_top(void) {
struct surface *s, *tmp, *kept[4];
int n = 0;
wl_list_for_each_safe(s, tmp, &g_toplevels, toplevel_link) {
if (s->keep_on_top && n < 4) { wl_list_remove(&s->toplevel_link); kept[n++] = s; }
}
for (int i = 0; i < n; i++) wl_list_insert(g_toplevels.prev, &kept[i]->toplevel_link);
}
static void map_toplevel(struct surface *s) {
@@ -654,6 +670,7 @@ static void map_toplevel(struct surface *s) {
s->placed ? "" : " (no desktop position yet)");
}
wl_list_insert(g_toplevels.prev, &s->toplevel_link); /* new windows start on top */
raise_kept_on_top();
/* Without a Wine desktop the clients are whole programs (the Linux desktop, a gamescope a
* program on it started over it): a window that opens on top takes the keys too, as it would
* on any desktop, rather than the one clicked last that is now underneath it. */
@@ -763,8 +780,11 @@ static void take_pixel(struct surface *s, const uint8_t bgra[4], struct wl_resou
/* The window the app's performance HUD follows: the latest one at least as big as the last to
* start presenting GPU frames (take_dmabuf) (X11 binds the HUD to the _MESA_DRV window and counts X presents instead). JNI upcalls. */
static struct surface *g_hud_surface;
/* When the HUD's window last presented a frame (now_ns), to let a replacement take over. */
static int64_t g_hud_last_ns;
/* When the HUD's window last presented a new frame (now_ns), and when it last committed at all,
* a repeat of the frame it shows included. A replacement may take over only once it has gone
* quiet in the second sense: gamescope repeats a paused game's frame under a busy overlay, and
* that overlay must not become the game. */
static int64_t g_hud_last_ns, g_hud_alive_ns;
/* That window committed a frame the screen has not drawn yet. */
static int g_hud_fresh;
extern void droiddeck_on_game_surface(const char *window, const char *gpu); /* window NULL = gone */
@@ -774,7 +794,14 @@ extern void droiddeck_on_game_frame(void);
extern void droiddeck_on_game_program(int pid, const char *program);
static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_resource *buffer) {
if (s->dmabuf != buffer || s->dmabuf_buf != b) {
/* The buffer this surface already shows, committed again. Gamescope's Wayland backend attaches
* every plane on every frame it outputs, so its game plane comes back unchanged whenever only
* something above it moved (MangoHud's graph, Steam's overlay). The surface still holds it and
* never released it, so the client cannot have drawn into it: it is no new frame. Counted as
* one, a game capped at 30 read 50-60 fps in the HUD, and frame generation took those repeats
* as source frames ("LSFG 2x 59 -> 54" instead of 30 -> 60). */
const int repeat = s->dmabuf == buffer && s->dmabuf_buf == b;
if (!repeat) {
drop_dmabuf(s, 1);
s->dmabuf = buffer;
s->dmabuf_buf = b;
@@ -809,7 +836,7 @@ static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_re
s->buf_h = b->height;
s->buf_alpha = (b->format & 0xff) == 'A';
s->has_content = b->img != NULL;
g_stat_dmabuf++;
if (!repeat) g_stat_dmabuf++;
const int announced_now = !s->announced_vulkan;
if (!s->announced_vulkan) {
char name[160];
@@ -840,7 +867,7 @@ static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_re
* full-screen gamescope over the desktop still does), and a window gone quiet for a second
* gives way to whatever draws next. */
if (s != g_hud_surface && (b->img || ahb_swapchain_has_ahb(b)) &&
(!g_hud_surface || now_ns() - g_hud_last_ns > 1000000000LL ||
(!g_hud_surface || now_ns() - g_hud_alive_ns > 1000000000LL ||
(announced_now && (int64_t)b->width * b->height >= (int64_t)g_hud_surface->buf_w * g_hud_surface->buf_h))) {
char name[160];
describe(s, name, sizeof(name));
@@ -869,7 +896,13 @@ static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_re
* Asking whether the compositor could import it, as this used to, is a question about the
* copy path and not about whether a frame happened: a game whose buffers go straight to
* the display layer draws on screen while the counter sat at 0.0 fps and 1000.0 ms. */
if (s == g_hud_surface) { g_hud_last_ns = now_ns(); g_hud_fresh = 1; droiddeck_on_game_frame(); }
if (s == g_hud_surface) {
g_hud_alive_ns = now_ns();
if (!repeat) { g_hud_last_ns = g_hud_alive_ns; g_hud_fresh = 1; droiddeck_on_game_frame();
droiddeck_log("dd-source", "recv=%lld tick=%lld buffer=%u", (long long)g_hud_last_ns,
(long long)g_tick_vsync_ns, wl_resource_get_id(buffer));
}
}
}
/* ---- hooks for ahb_swapchain.c (zero-copy layers) */
@@ -933,6 +966,20 @@ static uint32_t nominal_refresh_ns(void) {
return (uint32_t)(1000000000000LL / (int64_t)mhz);
}
/* The vblank a scene drawn at render_ns belongs to, for wp_presentation: the vsync of the tick that
* drew it (Choreographer's frame time). Gamescope's Wayland backend anchors its vblank clock to this
* and wakes a red zone before the next refresh. Reporting the end of the render instead (a few ms
* past the tick) put its wakeups on top of the next tick: some frames missed it and showed twice,
* others were replaced before they were shown (a 60 Hz Thor got ~50 distinct frames a second from
* gamescope). Not the refresh after it either: gamescope then wakes a whole refresh late and runs
* at half rate. Without a fresh tick (the fallback timer) it is the render time. */
static int64_t presented_ns(int64_t render_ns) {
if (!g_tick_vsync_ns || render_ns - g_last_vsync_ns > (int64_t)nominal_refresh_ns() ||
render_ns < g_tick_vsync_ns)
return render_ns;
return g_tick_vsync_ns;
}
static void feedback_present_all(struct wl_list *list, int64_t t) {
struct wl_resource *fb, *tmp;
uint64_t sec = (uint64_t)(t / 1000000000LL);
@@ -1458,7 +1505,13 @@ static void xdg_toplevel_set_title(struct wl_client *c, struct wl_resource *r, c
droiddeck_log("window", "renamed %s -> %s", before, after);
}
}
static void xdg_toplevel_set_app_id(struct wl_client *c, struct wl_resource *r, const char *id) {}
static void xdg_toplevel_set_app_id(struct wl_client *c, struct wl_resource *r, const char *id) {
struct surface *s = wl_resource_get_user_data(r);
/* Cardboard mode's SteamVR window (tools/vr/src/presenter.cpp) stays above every other window,
* gamescope's included: gamescope reopens its window when Steam notices VR, and a reopened window
* would otherwise come up over it (new windows start on top). */
if (s && id && !strcmp(id, "droiddeck-cardboard-vr")) s->keep_on_top = 1;
}
static void xdg_toplevel_show_menu(struct wl_client *c, struct wl_resource *r, struct wl_resource *seat,
uint32_t serial, int32_t x, int32_t y) {}
static void xdg_toplevel_move(struct wl_client *c, struct wl_resource *r, struct wl_resource *seat,
@@ -2071,6 +2124,8 @@ static void render_scene(void) {
int rendered = 0;
int fx_on = vkp_effects_active();
int framegen = vkp_framegen_active();
/* Cardboard mode draws the whole panel itself, so the game never gets a display layer of its own. */
const int cardboard = vkp_cardboard_active();
/* HDR (see hdr_plan): route 0 = the old paths below; 1 / 2 = composed; 3 = layer only, effects and
* frame generation skipped. Always 0 while the HDR gate is closed: nothing changes for that session. */
struct hdr_scene hs;
@@ -2117,7 +2172,7 @@ static void render_scene(void) {
* - screen effects DO when a window is drawn above the game: the chain has to see the whole
* scene to look the way it does on the copy path, and here the game is only part of it. */
const int pass_on = fx_on || framegen;
int li = (g_zero_copy && !framegen) ? layer_candidate(&dl, w, h) : -1;
int li = (g_zero_copy && !framegen && !cardboard) ? layer_candidate(&dl, w, h) : -1;
int over = li >= 0 ? dl.n - 1 - li : 0; /* draws above the fullscreen game (0 or 1) */
const int fx_blocks = (li >= 0 && fx_on && over > 0);
if (fx_blocks) { li = -1; over = 0; }
@@ -2147,6 +2202,7 @@ static void render_scene(void) {
/* HDR: the same, for a game's SUBSURFACE swapchain whose 10-bit frame the compositor could not
* import (NULL whenever the HDR gate is closed - nothing is ever noted then). */
if (li < 0 && !ls) ls = hdr_unimported_fullscreen(&dl, w, h);
if (cardboard) ls = NULL;
if (ls && pass_on && li < 0 && !g_zero_copy_fx_skip_said) {
g_zero_copy_fx_skip_said = 1;
droiddeck_log(framegen ? "framegen" : "effects",
@@ -2218,7 +2274,7 @@ static void render_scene(void) {
free(hs.is_hdr);
if (hdr_copy && rendered && droiddeck_color_hdr_open()) hdr_count_copied(&dl, hdr_copy);
if (rendered) {
int64_t t = now_ns();
int64_t t = presented_ns(t_scene);
g_stat_frames++;
/* A surface outside the scene (role-less, not placed yet, a hidden helper window such
* as wined3d's device window) was not shown, so its feedback is discarded rather than
@@ -2283,12 +2339,15 @@ static void on_vsync(int64_t frame_time_ns) {
if (d > 3000000LL && d < 40000000LL) g_refresh_ns = (g_refresh_ns * 7 + d) / 8;
}
g_last_vsync_ns = now;
(void)frame_time_ns;
g_tick_vsync_ns = frame_time_ns;
droiddeck_log("dd-vtick", "frame=%lld dispatch=%lld", (long long)frame_time_ns, (long long)now);
/* The app's surface may have been replaced or taken away since the last frame: apply that now
* (the app never waits for us), and redraw the scene onto a new one. */
if (vkp_apply_window_request()) g_dirty = 1;
/* A screen-effect setting changed (JNI, any thread): redraw so it shows on a static scene too. */
if (vkp_effects_sync()) g_dirty = 1;
/* Cardboard mode: a new head pose (or setting) moves the virtual screen, static scene or not. */
if (vkp_cardboard_pose_changed()) g_dirty = 1;
if (g_dirty && !scene_waits_for_game(now)) render_scene();
}
@@ -255,7 +255,11 @@ static void target_destroy(struct fx_target *t) {
static int target_ensure(struct fx_target *t, int w, int h, const char *what) {
if (t->img && t->w == w && t->h == h) return 0;
target_destroy(t);
VkImageCreateInfo ii = {.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, .imageType = VK_IMAGE_TYPE_2D,
/* Unrestricted mutable views keep these render-then-blit targets uncompressed on Turnip.
* UBWC targets corrupt even a constant-colour pass on Adreno 740 (FSR becomes black).
* Only the chain's intermediate images need this; the scene and guest images stay unchanged. */
VkImageCreateInfo ii = {.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO,
.flags = VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT, .imageType = VK_IMAGE_TYPE_2D,
.format = g_fx_fmt, .extent = {(uint32_t)w, (uint32_t)h, 1}, .mipLevels = 1,
.arrayLayers = 1, .samples = VK_SAMPLE_COUNT_1_BIT, .tiling = VK_IMAGE_TILING_OPTIMAL,
.usage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT |
@@ -292,6 +296,10 @@ static void scene_release(void) {
memset(&g_scene, 0, sizeof(g_scene));
}
void vkp_effects_forget_image(VkImage img) {
if (img && g_scene.img == img) scene_release();
}
static int scene_ensure(VkImage img, int w, int h) {
if (g_scene.img == img && g_scene.w == w && g_scene.h == h && g_scene.ds) return 0;
scene_release();
@@ -55,6 +55,10 @@ VkImage vkp_effects_run(VkCommandBuffer cmd, VkImage scene, int scene_w, int sce
/* Compositor thread: drop every Vulkan object (device teardown / device lost). */
void vkp_effects_destroy(void);
/* Compositor thread: release the cached source view/descriptor before its owner destroys img.
* A later image can reuse the same Vulkan handle and dimensions; that is not the same resource. */
void vkp_effects_forget_image(VkImage img);
/* Compositor thread, before vkp_effects_run(): the format of the scene image it will be handed and the
* format its own targets use. R8G8B8A8 / R8G8B8A8 for every SDR frame (the default); A2B10G10R10 /
* A2B10G10R10 while an HDR picture runs through it (hdr_compose.h). A target-format change rebuilds the
@@ -0,0 +1,49 @@
#ifndef HEADPOSE_SHM_H
#define HEADPOSE_SHM_H
/*
* The head pose Cardboard mode tracks, shared with the guest: a 128-byte file the compositor maps
* at <files>/.wayland-rt/droiddeck-headpose, which the guest sees as /run/droiddeck/droiddeck-headpose.
* Written by the app (cardboard.c) at the sensor's rate; read by guest VR code (the SteamVR driver in
* tools/vr) without any call into the app.
*
* Seqlock: the writer makes seq odd, writes the rest, then makes it even again. A reader copies the
* struct and keeps the copy only if seq was even and unchanged across the copy.
*
* Axes are OpenVR's (and the cardboard shader's): x right, y up, z back (-z is straight ahead).
* rot maps head directions to the recentred world, row-major. time_ns is CLOCK_MONOTONIC, the same
* clock inside the guest (one kernel).
*/
#include <stdint.h>
#define DROIDDECK_HEADPOSE_MAGIC 0x50484444u /* "DDHP" */
#define DROIDDECK_HEADPOSE_VERSION 1u
#define DROIDDECK_HEADPOSE_FILE "droiddeck-headpose"
enum {
DROIDDECK_HEADPOSE_LIVE = 1u << 0, /* a rotation sensor is feeding it (else identity) */
DROIDDECK_HEADPOSE_ENABLED = 1u << 1, /* Cardboard mode is on */
};
struct droiddeck_headpose {
uint32_t magic;
uint32_t version;
uint32_t seq;
uint32_t flags;
float rot[9];
float ipd_m;
int64_t time_ns;
/* What the lens pass expects of a side-by-side source, so a VR renderer can match it: one eye's
* half of the frame covers tan_half_fov up and down and eye_aspect times that left and right. */
float tan_half_fov;
float eye_aspect;
uint32_t eye_w, eye_h; /* one eye's viewport on the panel, pixels (0 = not drawn yet) */
uint32_t reserved[12];
};
#ifdef __cplusplus
static_assert(sizeof(struct droiddeck_headpose) == 128, "headpose layout");
#else
_Static_assert(sizeof(struct droiddeck_headpose) == 128, "headpose layout");
#endif
#endif
+43 -5
View File
@@ -5,6 +5,7 @@
#include "vk_loader.h"
#include "sc_layer.h"
#include "effects_chain.h"
#include "cardboard.h"
#include "framegen_bridge.h"
#include "hdr_compose.h"
#include "blend_pass.h"
@@ -541,6 +542,7 @@ static int dev_init(void) {
g_vk.CreateFence(g_dev, &fci, NULL, &g_fence);
vkp_effects_bind_device(g_dev, g_pd, &g_memprops);
vkp_cardboard_bind_device(g_dev, &g_memprops);
hdrc_bind_device(g_dev, &g_memprops);
blendp_bind_device(g_dev);
g_dev_state = 1;
@@ -1164,6 +1166,7 @@ static void record_draw(VkCommandBuffer cmd, const struct vkp_draw *d, const VkI
}
static void destroy_scene_image(void) {
if (g_scene.img) vkp_effects_forget_image(g_scene.img);
if (g_scene.img) hdrc_forget_image(g_scene.img); /* the HDR pass may have drawn into it */
if (g_scene.img) blendp_forget_image(g_scene.img);
if (g_scene.img) g_vk.DestroyImage(g_dev, g_scene.img, NULL);
@@ -1203,6 +1206,7 @@ static int ensure_scene_image(int w, int h) {
static int ensure_img(struct vkp_img_slot *s, int w, int h, VkFormat fmt, VkImageUsageFlags usage, const char *what) {
if (s->img && s->w == w && s->h == h && s->fmt == fmt) return 0;
if (s->img) hdrc_forget_image(s->img);
if (s->img) vkp_effects_forget_image(s->img);
if (s->img) blendp_forget_image(s->img);
if (s->img) g_vk.DestroyImage(g_dev, s->img, NULL);
if (s->mem) g_vk.FreeMemory(g_dev, s->mem, NULL);
@@ -1340,8 +1344,28 @@ static const char *vk_result_name(VkResult r) {
* standing for the whole scene) through the scene -> output mapping, and leave the image ready
* to present. */
static void record_screen_blit(VkCommandBuffer cmd, VkImage src, int src_w, int src_h,
int scene_w, int scene_h, uint32_t img, VkFilter filter) {
int scene_w, int scene_h, uint32_t img, VkFilter filter, int cardboard) {
VkImageSubresourceRange range = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
/* Cardboard mode (cardboard.h): the pass turns src into the two eye views over the whole panel,
* and that image is blitted 1:1 instead of src going through the scene mapping. */
int direct = 0;
if (cardboard) {
VkImageMemoryBarrier rd = {
.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, .oldLayout = VK_IMAGE_LAYOUT_GENERAL,
.newLayout = VK_IMAGE_LAYOUT_GENERAL,
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.image = src, .subresourceRange = range,
.srcAccessMask = VK_ACCESS_MEMORY_WRITE_BIT, .dstAccessMask = VK_ACCESS_SHADER_READ_BIT};
g_vk.CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
0, 0, NULL, 0, NULL, 1, &rd);
VkImage eyes = vkp_cardboard_run(cmd, src, src_w, src_h, (int)g_extent.width, (int)g_extent.height);
if (eyes) {
src = eyes;
src_w = (int)g_extent.width;
src_h = (int)g_extent.height;
direct = 1;
}
}
VkImageMemoryBarrier pre[2] = {
{.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, .oldLayout = VK_IMAGE_LAYOUT_UNDEFINED,
.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
@@ -1359,7 +1383,12 @@ static void record_screen_blit(VkCommandBuffer cmd, VkImage src, int src_w, int
struct vkp_image tmp = {.w = src_w, .h = src_h}; /* only its size is looked at */
struct vkp_draw d = {&tmp, 0, 0, (float)src_w, (float)src_h, 0, 0, scene_w, scene_h};
VkImageBlit blit;
const int have_blit = draw_to_blit(&d, &blit);
const int have_blit = direct ? 1 : draw_to_blit(&d, &blit);
if (direct)
blit = (VkImageBlit){.srcSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1},
.srcOffsets = {{0, 0, 0}, {src_w, src_h, 1}},
.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1},
.dstOffsets = {{0, 0, 0}, {src_w, src_h, 1}}};
/* The result fills its whole mapped rectangle, so only the letterbox bars around it need black. */
if (!have_blit || !record_bars_clear(cmd, g_images[img], &blit)) {
VkClearColorValue black = {.float32 = {0.0f, 0.0f, 0.0f, 1.0f}};
@@ -1569,6 +1598,15 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
* blit filter follows the scaling mode. */
const int fx = !g_plain_frame && vkp_effects_active();
const int fg = !g_plain_frame && vkp_framegen_active();
/* Cardboard mode needs the whole frame too; it works on the 8-bit picture only (an HDR frame
* is shown flat, said once). */
int cb = !g_plain_frame && vkp_cardboard_active();
if (cb && hf) {
static int said;
if (!said) { said = 1; droiddeck_log("cardboard", "HDR frames are shown flat: the cardboard pass is 8-bit only"); }
cb = 0;
}
vkp_cardboard_begin_frame();
/* HDR frames with frame generation through an HDR10 swapchain: the scene, the effects and the
* engine's frames in FP16 (FG_HDR_FMT) where the engine can take that format - more than 8 bits of
* PQ through interpolation - else the 8-bit scene, as before. */
@@ -1592,7 +1630,7 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
}
}
/* An HDR scene always takes the pass: its draws are composed into one encoding first. */
const int pass = deep || ((fx || fg || (hf && n > 0)) && ensure_scene_image(scene_w, scene_h) == 0);
const int pass = deep || ((fx || fg || cb || (hf && n > 0)) && ensure_scene_image(scene_w, scene_h) == 0);
VkImage scene_img = deep ? g_fgscene.img : g_scene.img;
const VkFormat scene_fmt = deep ? FG_HDR_FMT : SCENE_FMT;
const VkFilter blit_filter = vkp_effects_blit_filter();
@@ -1744,7 +1782,7 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
}
/* 4. Mapping/blit: the first present carries generated frame 0, or the result itself. */
record_screen_blit(cmd, ngen ? gens[0] : result, rw, rh, scene_w, scene_h, img, blit_filter);
record_screen_blit(cmd, ngen ? gens[0] : result, rw, rh, scene_w, scene_h, img, blit_filter, cb);
}
g_vk.EndCommandBuffer(cmd);
@@ -1768,7 +1806,7 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
VkCommandBuffer ck = g_cmds[k];
g_vk.ResetCommandBuffer(ck, 0);
g_vk.BeginCommandBuffer(ck, &bi);
record_screen_blit(ck, k < ngen ? gens[k] : result, rw, rh, scene_w, scene_h, img, blit_filter);
record_screen_blit(ck, k < ngen ? gens[k] : result, rw, rh, scene_w, scene_h, img, blit_filter, cb);
g_vk.EndCommandBuffer(ck);
}
const int last = (k + 1 == presents);
@@ -20,6 +20,7 @@
#include "ahb_swapchain.h"
#include "sc_layer.h"
#include "effects_chain.h"
#include "cardboard.h"
#include "droiddeck_color.h"
extern int droiddeck_wayland_run(void);
@@ -463,6 +464,36 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetScreenEffects(JNI
vkp_effects_set_screen(brightness, contrast, gamma, saturation, fxaa ? 1 : 0, toon ? 1 : 0, crt ? 1 : 0, ntsc ? 1 : 0);
}
/* Cardboard mode - WaylandCompositor.nativeSetCardboard. */
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetCardboard(JNIEnv *env, jclass clazz, jboolean enabled,
jint source, jfloat ipd, jfloat dist, jfloat width, jfloat k1, jfloat k2, jfloat fov, jfloat inset, jfloat chroma) {
struct cardboard_params p = {.enabled = enabled ? 1 : 0, .source = source, .ipd_m = ipd, .screen_dist_m = dist,
.screen_width_m = width, .k1 = k1, .k2 = k2, .fov_deg = fov, .lens_inset = inset,
.chroma = chroma};
vkp_cardboard_set(&p);
}
/* The head pose (row-major 3x3, head -> world), from the sensor thread. */
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetCardboardPose(JNIEnv *env, jclass clazz, jfloatArray rot,
jboolean live) {
float m[9];
if (!rot || (*env)->GetArrayLength(env, rot) < 9) return;
(*env)->GetFloatArrayRegion(env, rot, 0, 9, m);
vkp_cardboard_set_pose(m, live ? 1 : 0);
}
/* Where the head pose is shared with the guest - WaylandCompositor.nativeSetCardboardPoseFile. */
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetCardboardPoseFile(JNIEnv *env, jclass clazz, jstring path) {
if (!path) return;
const char *p = (*env)->GetStringUTFChars(env, path, NULL);
if (!p) return;
vkp_cardboard_set_pose_file(p);
(*env)->ReleaseStringUTFChars(env, path, p);
}
/* One "display" line in the session log, written from Java: facts that live in the Android
* framework (the panel's HDR capability) rather than in the compositor. droiddeck_log() mirrors to
* logcat and appends to the session file when one is open, so this is safe at any point. */
@@ -22,6 +22,7 @@ class App : Application() {
override fun onCreate() {
super.onCreate()
CrashHandler.install(this)
com.droiddeck.launcher.session.QuestExperimental.initialize(this)
com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this)
com.droiddeck.launcher.update.AppUpdates.init(this)
GpuClockPin.clearLeftover(this)
@@ -9,10 +9,10 @@ import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.session.ProtonDefault
/**
* The Components page's state and work: the Protons and their DXVK/VKD3D sets, the Nightlies
* catalog, downloads and the swap/restore actions. The launcher screen keeps one and shows it.
* Component state and work for the launcher and session drawer: the Protons and their
* DXVK/VKD3D sets, the Nightlies catalog, downloads and the swap/restore actions.
*/
internal class ComponentsMenu(private val activity: android.app.Activity, private val ui: android.os.Handler) {
internal class ComponentsMenu(private val activity: android.app.Activity, private val ui: Handler) {
var compSnapshot by mutableStateOf<ComponentsManager.Snapshot?>(null)
var compCatalog by mutableStateOf<List<ComponentsManager.CatalogItem>>(emptyList())
var compCatalogAt by mutableStateOf(0L)
@@ -20,7 +20,10 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
var compComp by mutableStateOf(com.droiddeck.launcher.ui.GPU_TAB)
var compChecking by mutableStateOf(false)
var compBusy by mutableStateOf<String?>(null)
var compDownloads by mutableStateOf<Map<String, Int>>(emptyMap())
var compDownloads: Map<String, Int>
get() = downloads
private set(value) { downloads = value }
val compDownloadRevision: Long get() = downloadRevision
private var shownDefault: String? = null
/** Reads the Protons (and on first open saves their originals) off the UI thread; the Nightlies list comes from its cache. */
@@ -72,15 +75,15 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
}, "components-action").start()
}
fun refreshComponentCatalog() {
fun refreshComponentCatalog(force: Boolean = true) {
if (compChecking) return
compChecking = true
Thread({
val cat = runCatching { ComponentsManager.catalog(activity, true) }.getOrNull()
val cat = runCatching { ComponentsManager.catalog(activity, force) }.getOrNull()
ui.post {
compChecking = false
if (cat == null || cat.items.isEmpty()) android.widget.Toast.makeText(activity, activity.getString(R.string.comp_nightlies_unreachable), android.widget.Toast.LENGTH_LONG).show()
else { compCatalog = cat.items; compCatalogAt = cat.fetchedAt }
if (cat != null && cat.items.isNotEmpty()) { compCatalog = cat.items; compCatalogAt = cat.fetchedAt }
else if (force) android.widget.Toast.makeText(activity, activity.getString(R.string.comp_nightlies_unreachable), android.widget.Toast.LENGTH_LONG).show()
}
}, "components-catalog").start()
}
@@ -95,6 +98,7 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
}.getOrElse { e -> activity.getString(R.string.comp_download_failed, e.message ?: e.javaClass.simpleName) }
ui.post {
compDownloads = compDownloads - item.file
downloadRevision++
android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_SHORT).show()
refreshComponents()
}
@@ -102,6 +106,10 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
}
private companion object {
const val TAG = "MainActivity"
const val TAG = "ComponentsMenu"
// Both screens use the same package store and .part files. Keep the in-flight registry
// across menu/activity instances so a package cannot be downloaded twice at once.
var downloads by mutableStateOf<Map<String, Int>>(emptyMap())
var downloadRevision by mutableStateOf(0L)
}
}
@@ -46,7 +46,6 @@ import com.droiddeck.launcher.session.GameSaves
import com.droiddeck.launcher.session.SessionLogShare
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.session.SteamRepair
import com.droiddeck.launcher.ui.ProtonPage
import com.droiddeck.launcher.core.CpuCores
import com.droiddeck.launcher.core.PhantomProcessLimit
import com.droiddeck.launcher.core.PhantomProcessStatus
@@ -55,7 +54,6 @@ import com.droiddeck.launcher.core.WifiDiscovery
import com.droiddeck.launcher.core.WirelessAdbPairingService
import com.droiddeck.launcher.ui.CoreRow
import com.droiddeck.launcher.ui.PerformancePage
import com.droiddeck.launcher.ui.ModeSettingsPage
import com.droiddeck.launcher.ui.ModeSettings
import com.droiddeck.launcher.ui.ModeSettingsActions
import com.droiddeck.launcher.ui.ConfirmDialog
@@ -124,7 +122,6 @@ class MainActivity : ComponentActivity() {
private var steamRepairQueued by mutableStateOf(false)
private var mangoapp by mutableStateOf(true)
private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
private var showProtons by mutableStateOf(false)
// Components page: FEX / DXVK / VKD3D-Proton per Proton (ComponentsManager).
private var showComponents by mutableStateOf(false)
private var focusComponentsContent by mutableStateOf(true)
@@ -166,8 +163,7 @@ class MainActivity : ComponentActivity() {
private var orientation by mutableStateOf(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE)
private var launcherFullscreen by mutableStateOf(true)
private var animationsEnabled by mutableStateOf(true)
private var storeEnabled by mutableStateOf(false)
private var gameStoresEnabled by mutableStateOf(false)
private var gameStoresEnabled by mutableStateOf(true)
private var gameStoresSpeedTier by mutableStateOf("fast")
private var storesOpenTab by mutableStateOf(SessionPrefs.STORES_OPEN_LIBRARY)
/** A page a notification asked for ("stores:downloads", "app:<id>"), until the front end has gone there. */
@@ -338,7 +334,7 @@ class MainActivity : ComponentActivity() {
romsDir = path
}
}
// The mode whose settings dialog is open, with what it shows; refreshed by openModeSettings().
// The mode whose lifted sheet last read its settings, with what it shows; refreshed by loadModeSettings().
private var settingsMode by mutableStateOf<String?>(null)
private var resolution by mutableStateOf(com.droiddeck.launcher.session.SessionDisplay.DEFAULT_RESOLUTION)
private var fexPreset by mutableStateOf("")
@@ -539,7 +535,6 @@ class MainActivity : ComponentActivity() {
requestedOrientation = orientation
launcherFullscreen = SessionPrefs.launcherFullscreen(this)
animationsEnabled = SessionPrefs.animationsEnabled(this)
storeEnabled = SessionPrefs.storeEnabled(this)
gameStoresEnabled = SessionPrefs.gameStoresEnabled(this)
gameStoresSpeedTier = SessionPrefs.gameStoresSpeedTier(this)
storesOpenTab = SessionPrefs.storesOpenTab(this)
@@ -549,11 +544,8 @@ class MainActivity : ComponentActivity() {
setContent {
DroidDeckTheme(theme, appScale = appScale) {
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize().exposeTestTags()) {
val sm = settingsMode
val page: (@Composable () -> Unit)? = when {
sm != null -> { { ModeSettingsHost(sm) } }
showPerformance -> { { PerformanceHost() } }
showProtons -> { { ProtonHost() } }
showComponents -> { { ComponentsHost() } }
showMapping -> { { MappingHost() } }
else -> null
@@ -572,7 +564,7 @@ class MainActivity : ComponentActivity() {
steamGames = steamGames, emulators = emulatorList, running = runningLabel,
frameGen = FrameGen.mode(this),
lossless = lossless,
pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
pageKey = if (showPerformance) "performance" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
theme = theme,
language = com.droiddeck.launcher.core.AppLanguage.chosen(this),
appScale = appScale,
@@ -597,7 +589,6 @@ class MainActivity : ComponentActivity() {
orientation = orientation,
launcherFullscreen = launcherFullscreen,
animationsEnabled = animationsEnabled,
storeEnabled = storeEnabled,
gameStoresEnabled = gameStoresEnabled,
gameStoresSpeedTier = gameStoresSpeedTier,
storesOpenTab = storesOpenTab,
@@ -683,9 +674,10 @@ class MainActivity : ComponentActivity() {
},
// The activity re-attaches to the session that is running; nothing restarts.
onResume = { startActivity(Intent(this, SessionActivity::class.java)) },
onSteamSettings = { openModeSettings(SessionService.MODE_STEAM) },
onSteamSettings = { loadModeSettings(SessionService.MODE_STEAM) },
onRemoveDesktop = { removeDesktop() },
onDesktopSettings = { openModeSettings(SessionService.MODE_DESKTOP) },
onDesktopSettings = { loadModeSettings(SessionService.MODE_DESKTOP) },
modeSheet = { mode, tab, extras -> ModeSheetHost(mode, tab, extras) },
onInstallPackage = { id -> installPackage(id) },
onRemovePackage = { id -> removePackage(id) },
onRuntime = { onRuntimeButton() },
@@ -698,10 +690,11 @@ class MainActivity : ComponentActivity() {
},
onProtons = { openProtons() },
onComponents = { focusContent -> openComponents(focusContent) },
onComponentsTab = { tab -> openComponents(focusContent = true, tab = tab) },
// A game page's Manage saves: the game's Proton and saves are read when the work runs, off the main thread.
onSaveImport = { sg -> importSaves(sg.name) { GameSaves.game(sg) } },
onSaveExport = { sg, layout -> exportSaves(sg.name, layout) { GameSaves.game(sg) } },
onPerformance = { refreshCores(); showProtons = false; showComponents = false; showMapping = false; showPerformance = true },
onPerformance = { refreshCores(); showComponents = false; showMapping = false; showPerformance = true },
onRoms = { showRoms = true },
onFiles = { startActivity(Intent(this, com.droiddeck.launcher.files.FileManagerActivity::class.java)) },
onBrowseFiles = { dir ->
@@ -736,7 +729,8 @@ class MainActivity : ComponentActivity() {
OfflineMode.setEnabled(this, !OfflineMode.enabled(this))
offline = OfflineMode.enabled(this)
},
onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = false },
onPageBack = { showPerformance = false; showComponents = false; showMapping = false },
onGameStoresEnabled = { on -> SessionPrefs.setGameStoresEnabled(this, on); gameStoresEnabled = on },
onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id },
onLanguage = { tag ->
if (tag != com.droiddeck.launcher.core.AppLanguage.chosen(this)) {
@@ -759,8 +753,6 @@ class MainActivity : ComponentActivity() {
launcherFullscreen = on
applyLauncherFullscreen()
},
onStoreEnabled = { on -> SessionPrefs.setStoreEnabled(this, on); storeEnabled = on },
onGameStoresEnabled = { on -> SessionPrefs.setGameStoresEnabled(this, on); gameStoresEnabled = on; if (on) com.droiddeck.launcher.stores.StoresState.refresh(this) },
onGameStoresSpeedTier = { tier -> SessionPrefs.setGameStoresSpeedTier(this, tier); gameStoresSpeedTier = tier },
onStoresOpenTab = { tab -> SessionPrefs.setStoresOpenTab(this, tab); storesOpenTab = tab },
onNavigated = { navRequest = null },
@@ -846,7 +838,7 @@ class MainActivity : ComponentActivity() {
onSelectSteam = { on -> ControllerPrefs.setSelectSteam(this, on); refreshController() },
onEditLayout = { startActivity(Intent(this, ControllerEditorActivity::class.java)) },
onResetLayout = { ControllerPrefs.resetAllLayouts(this); refreshController() },
onMapping = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = true },
onMapping = { showPerformance = false; showComponents = false; showMapping = true },
onResetAll = { ControllerPrefs.resetAll(this); refreshController() },
),
),
@@ -966,14 +958,12 @@ class MainActivity : ComponentActivity() {
}
}
/** On the Components page the pad's LB / RB step through GPU drivers, FEX, DXVK and VKD3D-Proton, wrapping around. */
/** On the Components page the pad's LB / RB step through the stack's layers, wrapping around. */
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_A) com.droiddeck.launcher.ui.HeldKeys.confirm = event.action == KeyEvent.ACTION_DOWN
if (showComponents && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
val all = listOf(com.droiddeck.launcher.ui.GPU_TAB) + ComponentsManager.COMPONENTS
val step = if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else all.size - 1
components.compComp = all[(all.indexOf(components.compComp).coerceAtLeast(0) + step) % all.size]
components.compComp = com.droiddeck.launcher.ui.layerStep(components.compComp, if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else -1)
}
return true
}
@@ -995,7 +985,7 @@ class MainActivity : ComponentActivity() {
refreshWifiDiscovery()
// Back from a store's sign-in page: the Stores chips show the account at once. A store
// install finishing while this screen is up rebuilds the Games list through the listener.
if (gameStoresEnabled) com.droiddeck.launcher.stores.StoresState.refresh(this)
com.droiddeck.launcher.stores.StoresState.refresh(this)
com.droiddeck.launcher.stores.StoresState.libraryListener = { refreshAddedGames(); refresh() }
com.droiddeck.launcher.ui.Motion.refresh(this)
// Back from a session stopped behind a flood: open on its blue, before the first frame.
@@ -1008,6 +998,8 @@ class MainActivity : ComponentActivity() {
updates.onResume()
// Auto keeps this GPU's recommended driver pair installed and set: once per app start.
drivers.ensureAuto(force = false)
// Newer GE-Proton / proton-cachyos / DroidDeck-Proton releases: looked up every few hours at most.
protons.checkUpdates()
// Swaps queued while a game ran on that Proton go in once nothing uses it (usually the
// session has just ended). Cheap when nothing is queued.
if (!SessionState.running) Thread({
@@ -1126,14 +1118,8 @@ class MainActivity : ComponentActivity() {
}, "game-saves-action").start()
}
private fun openProtons() {
settingsMode = null
showPerformance = false
showComponents = false
showMapping = false
showProtons = true
protons.refreshProtons()
}
/** The Protons DroidDeck installs are the Components page's Proton layer. */
private fun openProtons() = openComponents(focusContent = true, tab = com.droiddeck.launcher.ui.PROTON_TAB)
private fun refreshController() {
controllerSettings = ControllerPrefs.read(this)
@@ -1153,13 +1139,19 @@ class MainActivity : ComponentActivity() {
private fun openComponents(focusContent: Boolean = true, tab: String? = null) {
focusComponentsContent = focusContent
if (tab != null) components.compComp = tab
settingsMode = null
showPerformance = false
showProtons = false
showMapping = false
showComponents = true
// The Proton layer's options for every Proton, as they are now.
fexPreset = SessionPrefs.fexPreset(this)
forceSsbs = SessionPrefs.forceSsbs(this)
fastSync = SessionPrefs.fastSync(this)
fsyncFirst = SessionPrefs.fsyncFirst(this)
syncFallback = SessionPrefs.syncFallback(this)
components.refreshComponents(snapshotFirst = true)
drivers.refreshDrivers()
protons.refreshProtons()
protons.checkUpdates()
}
private fun importComponent(uri: Uri) {
@@ -1193,7 +1185,11 @@ class MainActivity : ComponentActivity() {
onDownload = { components.downloadComponent(it) },
onRefresh = { components.refreshComponentCatalog() },
onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, getString(R.string.comp_pick_wcp))) },
onBack = { showComponents = false },
// Reached by a hop (a game's Components card, Play's Graphics drivers): back the way it came.
onBack = { if (!com.droiddeck.launcher.ui.Hops.back { showComponents = false }) showComponents = false },
games = steamGames,
generalFexPreset = fexPreset,
onGeneralFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id },
gpu = drivers.state(),
gpuActions = com.droiddeck.launcher.ui.GpuDriversActions(
onAuto = { on -> drivers.setMode(on) },
@@ -1210,23 +1206,34 @@ class MainActivity : ComponentActivity() {
onImportZip = { pickAnyDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.drivers_pick_any))) },
onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() },
),
protons = com.droiddeck.launcher.ui.ProtonsState(
rows = protons.protonRows, busyId = protons.protonBusyId, stage = protons.protonStage, percent = protons.protonPercent,
runtimeReady = ready && !busy, sessionRunning = SessionState.running,
),
protonActions = com.droiddeck.launcher.ui.ProtonsActions(
onInstall = { id -> protons.installProton(id) },
onCancel = { id -> ProtonExtras.tools.firstOrNull { it.id == id }?.let { ProtonExtras.unqueue(this, it) }; protons.refreshProtons() },
onRemove = { id, dir -> protons.removeProton(id, dir) },
onRemoveOlder = { id -> protons.removeProton(id, older = true) },
),
protonOptions = {
com.droiddeck.launcher.ui.ProtonOptions(
syncBackend = SessionPrefs.syncBackendOf(fastSync, fsyncFirst, syncFallback), fexPreset = fexPreset, forceSsbs = forceSsbs,
onSyncBackend = { id ->
SessionPrefs.setSyncBackend(this, id)
fastSync = SessionPrefs.fastSync(this)
fsyncFirst = SessionPrefs.fsyncFirst(this)
syncFallback = SessionPrefs.syncFallback(this)
},
onFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id },
onForceSsbs = { on -> SessionPrefs.setForceSsbs(this, on); forceSsbs = on },
)
},
)
}
@Composable
private fun ProtonHost() {
ProtonPage(
rows = protons.protonRows,
busyId = protons.protonBusyId,
stage = protons.protonStage,
percent = protons.protonPercent,
runtimeReady = ready && !busy,
sessionRunning = SessionState.running,
onInstall = { id -> protons.installProton(id) },
onCancel = { id -> ProtonExtras.tools.firstOrNull { it.id == id }?.let { ProtonExtras.unqueue(this, it) }; protons.refreshProtons() },
onRemove = { id -> protons.removeProton(id) },
onBack = { showProtons = false },
)
// A download can finish in the session drawer while this page is visible.
androidx.compose.runtime.LaunchedEffect(components.compDownloadRevision) { components.refreshComponents() }
// A Proton installed or removed changes the stack's list of Protons.
androidx.compose.runtime.LaunchedEffect(protons.protonBusyId) { if (protons.protonBusyId == null) components.refreshComponents() }
}
private fun refreshPackages() {
@@ -1273,8 +1280,10 @@ class MainActivity : ComponentActivity() {
}
@Composable
private fun ModeSettingsHost(mode: String) {
ModeSettingsPage(
private fun ModeSheetHost(mode: String, tab: String, extras: com.droiddeck.launcher.ui.SheetExtras) {
// Read for the other mode last (or not yet): what this one shows is read as it opens.
if (settingsMode != mode) return
com.droiddeck.launcher.ui.ModeSheetRows(
ModeSettings(
mode = mode, resolution = resolution,
panelSize = com.droiddeck.launcher.session.SessionDisplay.panelSize(this),
@@ -1394,8 +1403,9 @@ class MainActivity : ComponentActivity() {
onPickDeckyPluginZip = {
pickDeckyPluginZip.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.main_pick_decky_zip)))
},
onDismiss = { settingsMode = null },
onDismiss = {},
),
tab, extras,
)
}
@@ -1496,13 +1506,9 @@ class MainActivity : ComponentActivity() {
private var pipAutoEnter by mutableStateOf(false)
private var unfoldedControls by mutableStateOf(false)
private fun openModeSettings(mode: String) {
private fun loadModeSettings(mode: String) {
pipAutoEnter = SessionPrefs.pipAutoEnter(this)
unfoldedControls = SessionPrefs.unfoldedControls(this)
showPerformance = false
showProtons = false
showComponents = false
showMapping = false
resolution = SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this))
fexPreset = SessionPrefs.fexPreset(this)
forceSsbs = SessionPrefs.forceSsbs(this)
@@ -1530,8 +1536,8 @@ class MainActivity : ComponentActivity() {
gameStorage = SessionPrefs.gameStorage(this)
storageDiagnostics = SessionPrefs.storageDiagnosticsEnabled(this)
settingsMode = mode
// The page opens at once, on what was last read; the slow part (driver files, a walk of the
// added-games folders, the storage volumes) lands while it animates in.
// The sheet lifts at once, on what was last read; the slow part (driver files, a walk of the
// added-games folders, the storage volumes) lands while it rises.
Thread({
drivers.refreshDrivers()
val storage = GameStorage.options(this)
@@ -1607,7 +1613,10 @@ class MainActivity : ComponentActivity() {
val scanGeneration = ++libraryScanGeneration
if (shortcutPicker) shortcutLibraryScanning = true
Thread({
// Steam's own games first, from their manifests alone (quick), beside the added games
// The emulators first: they need no walk of the games, and Desktop's dock is made of them.
val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null }
ui.post { if (scanGeneration == libraryScanGeneration) emulatorList = emus }
// Steam's own games next, from their manifests alone (quick), beside the added games
// already listed: a game Steam installed during the session shows as the session ends,
// without waiting for the added games' walk below. Its Windows components' list is
// queued then too, on the low-priority worker, so its page is usually ready.
@@ -1628,7 +1637,6 @@ class MainActivity : ComponentActivity() {
// so a partial list first would shuffle every capsule when the rest arrived.
// LibraryCache covers the wait.
val games = if (ready) Library.launchableGames(this) else emptyList()
val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null }
val all = games.distinctBy { it.gameId }
if (ready) com.droiddeck.launcher.frontend.LibraryCache.save(this, all)
// Once per app start: the Steam games' Windows components from Steam's lists.
@@ -1643,7 +1651,6 @@ class MainActivity : ComponentActivity() {
ui.post {
if (scanGeneration == libraryScanGeneration) {
steamGames = all
emulatorList = emus
shortcutLibraryScanning = false
handleGameLink(all)
}
@@ -10,14 +10,16 @@ import com.droiddeck.launcher.ui.ProtonRow
import com.droiddeck.launcher.session.SessionState
/**
* The optional ARM64 Protons (GE-Proton, proton-cachyos): the rows the page lists and an install or
* removal in progress. The launcher screen keeps one and shows it.
* The optional ARM64 Protons (GE-Proton, proton-cachyos, DroidDeck-Proton): the rows the page lists,
* an install or removal in progress, and whether a newer release is out. The launcher screen keeps
* one and shows it.
*/
internal class ProtonMenu(private val activity: android.app.Activity, private val ui: android.os.Handler) {
var protonRows by mutableStateOf<List<ProtonRow>>(emptyList())
var protonBusyId by mutableStateOf<String?>(null)
var protonStage by mutableStateOf<String?>(null)
var protonPercent by mutableIntStateOf(-1)
private val checking = java.util.concurrent.atomic.AtomicBoolean(false)
fun installProton(id: String) {
val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return
@@ -40,14 +42,15 @@ internal class ProtonMenu(private val activity: android.app.Activity, private va
}, "install-proton-$id").start()
}
fun removeProton(id: String) {
/** Removes the build in [dir], or with [older] every build of it but the newest. */
fun removeProton(id: String, dir: String? = null, older: Boolean = false) {
val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return
if (protonBusyId != null || SessionState.running) return
protonBusyId = id
protonStage = activity.getString(R.string.main_removing_named, tool.name)
protonStage = activity.getString(R.string.main_removing_named, dir ?: tool.name)
protonPercent = -1
Thread({
ProtonExtras.remove(activity, tool)
if (older) ProtonExtras.removeOlder(activity, tool) else ProtonExtras.remove(activity, tool, dir)
ui.post {
protonBusyId = null
protonStage = null
@@ -57,6 +60,18 @@ internal class ProtonMenu(private val activity: android.app.Activity, private va
}
fun refreshProtons() {
protonRows = ProtonExtras.tools.map { ProtonRow(it.id, it.name, ProtonExtras.installed(activity, it), ProtonExtras.queued(activity, it)) }
protonRows = ProtonExtras.tools.map {
val all = ProtonExtras.installedAll(activity, it)
ProtonRow(it.id, it.name, all.lastOrNull(), ProtonExtras.queued(activity, it), all, ProtonExtras.update(activity, it))
}
}
/** Looks for newer releases, at most every few hours (see ProtonExtras.checkLatest); quiet offline. */
fun checkUpdates(force: Boolean = false) {
if (!checking.compareAndSet(false, true)) return
Thread({
val asked = try { ProtonExtras.checkLatest(activity, force) } catch (_: Throwable) { false } finally { checking.set(false) }
if (asked) ui.post { refreshProtons() }
}, "proton-updates").start()
}
}
@@ -28,10 +28,12 @@ import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.graphics.toArgb
@@ -40,7 +42,17 @@ import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.core.TextureFiltering
import com.droiddeck.launcher.files.InAppFilePicker
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.agent.AgentGuest
import com.droiddeck.launcher.frontend.LibraryCache
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreAchievements
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.StoreLaunchRequests
import com.droiddeck.launcher.ui.SessionAchievements
import com.droiddeck.launcher.gpu.Cardboard
import com.droiddeck.launcher.gpu.FrameGen
import com.droiddeck.launcher.gpu.HeadTracker
import com.droiddeck.launcher.gpu.SteamVr
import com.droiddeck.launcher.gpu.ScreenEffectLooks
import com.droiddeck.launcher.gpu.ScreenEffects
import com.droiddeck.launcher.gpu.Lossless
@@ -64,6 +76,9 @@ import com.droiddeck.launcher.session.PerfHud
import com.droiddeck.launcher.session.PerfMode
import com.droiddeck.launcher.session.SteamRepair
import com.droiddeck.launcher.session.GameEnvironmentStore
import com.droiddeck.launcher.session.GameProfileFollowState
import com.droiddeck.launcher.session.GameProfileInventoryCache
import com.droiddeck.launcher.session.GameProfileRefreshState
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.session.SessionEvents
import com.droiddeck.launcher.session.SessionArtifacts
@@ -73,6 +88,9 @@ import com.droiddeck.launcher.session.SessionPaths
import com.droiddeck.launcher.wayland.HdrSupport
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.session.SessionState
import com.droiddeck.launcher.session.ProtonDefault
import com.droiddeck.launcher.session.SelectedGameProfile
import com.droiddeck.launcher.session.WinComponents
import com.droiddeck.launcher.ui.CursorOverlay
import androidx.compose.ui.graphics.asImageBitmap
import com.droiddeck.launcher.ui.DrawerActions
@@ -86,6 +104,7 @@ import com.droiddeck.launcher.wayland.CompositorHost
import com.droiddeck.launcher.wayland.WaylandCompositor
import java.io.File
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.session.LatestRequestCoordinator
import com.droiddeck.launcher.ui.SessionDrawerPage
import com.droiddeck.launcher.ui.sessionDrawerPages
import com.droiddeck.launcher.ui.step
@@ -247,6 +266,26 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var closeAtOnce = false
/** The drawer's Components tab: the Protons as last read (ComponentsManager). */
private var drawerComponents by mutableStateOf<ComponentsManager.Snapshot?>(null)
private val drawerComponentMenu by lazy { ComponentsMenu(this, uiHandler) }
private var drawerGameProfileId by mutableStateOf<Long?>(null)
private val drawerGameInventory = GameProfileInventoryCache<Library.SteamGame>()
private var drawerGameInventoryRevision = 0L
private var drawerGameFexPresetSaving by mutableStateOf(false)
private var drawerGameNoSteamSaving by mutableStateOf(false)
private val drawerGameProfileFollowState = GameProfileFollowState()
private data class DrawerGameProfileRefresh(
val followsSteam: Boolean,
val requestedAppId: Long?,
val inventoryRevision: Long,
)
private data class DrawerGameProfileResult(
val profiles: List<Pair<Long, String>>,
val selectedAppId: Long?,
val profile: SelectedGameProfile?,
val error: Exception? = null,
)
private var drawerGameProfileRefresh by mutableStateOf(GameProfileRefreshState<DrawerGameProfileResult>())
private val drawerGameProfileRefreshes = LatestRequestCoordinator<DrawerGameProfileRefresh>()
private var drawerPage by mutableStateOf(SessionDrawerPage.CONTROLLER)
private var drawerControllerActive by mutableStateOf(false)
private var backActionsInverted by mutableStateOf(false)
@@ -254,12 +293,36 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var padType by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
/** The on-screen PC keyboard (ui/PcKeyboard): real key presses, Esc and F1 included. */
private var pcKeyboardOpen by mutableStateOf(false)
/** The game in front, when its source has achievements. */
private var achievementsInFront by mutableStateOf<AchievementsInFront?>(null)
private var achievementsOpen by mutableStateOf(false)
private var hudOn by mutableStateOf(true)
private var upscaler by mutableStateOf(0)
private var upscaleSharpness by mutableStateOf(75)
private var effects by mutableStateOf(ScreenEffects.OFF)
/** Cardboard mode (gpu/Cardboard) and the sensor that turns the virtual screen with the head. */
private var cardboard by mutableStateOf(Cardboard.OFF)
private var headTracker: HeadTracker? = null
private var steamVrStatus by mutableStateOf<SteamVr.Status?>(null)
/** The picture was switched to side-by-side for SteamVR and goes back when it stops. */
private var steamVrSwitchedPicture by mutableStateOf(false)
/** SteamVR has been seen running since the switch: only then does "not running" mean it stopped. */
private var steamVrSeenRunning = false
private val hasRotationSensor by lazy {
(getSystemService(SENSOR_SERVICE) as android.hardware.SensorManager).let {
it.getDefaultSensor(android.hardware.Sensor.TYPE_GAME_ROTATION_VECTOR)
?: it.getDefaultSensor(android.hardware.Sensor.TYPE_ROTATION_VECTOR)
} != null
}
// Held in a field: SharedPreferences keeps its listeners weakly. A change from anywhere (the
// drawer, tools/droiddeckctl prefs set) applies live.
private val cardboardPrefListener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == SessionPrefs.CARDBOARD_KEY) uiHandler.post { applyCardboard(SessionPrefs.cardboard(this)) }
}
private var textureAnisotropy by mutableStateOf(0)
private var textureLodBias by mutableStateOf(TextureFiltering.LOD_BIAS_OFF)
private var drawerTextureAnisotropy by mutableStateOf<Int?>(null)
private var drawerTextureLodBias by mutableStateOf<String?>(null)
private var lossless by mutableStateOf(Lossless.State.NONE)
private val pickLossless = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importLossless(it) }
@@ -310,6 +373,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private val deviceListener = object : InputManager.InputDeviceListener {
override fun onInputDeviceAdded(deviceId: Int) {
logInputDevice("connected", deviceId)
padBridge?.syncConnectedControllers()
updateOnScreenControls()
updatePointerCapture()
}
@@ -326,7 +390,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestedOrientation = SessionPrefs.orientation(this)
if (com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isRemoving() ||
com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isMaintaining()) {
runtimeRemovalBlocked = true
@@ -466,6 +529,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
when {
::loading.isInitialized && loading.ended -> finish()
drawerOpen -> drawerOpen = false
achievementsOpen -> closeAchievements()
pcKeyboardOpen -> pcKeyboardOpen = false
else -> routeBackAction()
}
@@ -486,7 +550,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
defaultFocusHighlightEnabled = false
setContent {
// The drawer, the PC keyboard and picture in picture take the mouse back.
androidx.compose.runtime.LaunchedEffect(drawerOpen, pcKeyboardOpen, pipUi) { updatePointerCapture() }
androidx.compose.runtime.LaunchedEffect(drawerOpen, pcKeyboardOpen, achievementsOpen, pipUi) { updatePointerCapture() }
DroidDeckTheme {
if (pipUi) {
if (SessionState.suspended) androidx.compose.foundation.layout.Box(
@@ -529,6 +593,27 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
releaseDrawerDirection()
}
}
// SteamVR's state for the Cardboard group, refreshed while the drawer shows it.
// Watched for as long as Cardboard mode is on (or the picture is switched for it), drawer open or
// not: however SteamVR is started or quit, the picture follows it.
LaunchedEffect(cardboard.enabled, steamVrSwitchedPicture) {
while (cardboard.enabled || steamVrSwitchedPicture) {
val now = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
SteamVr.status(this@SessionActivity)
}
onSteamVrStatus(now)
kotlinx.coroutines.delay(3000)
}
}
LaunchedEffect(drawerOpen, achievementsOpen, AgentGuest.focusRevision, StoreAchievements.revision) {
if (!drawerOpen && !achievementsOpen) return@LaunchedEffect
val found = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
runCatching { resolveAchievementsInFront() }
.onFailure { Log.w(TAG, "achievements in front: ${it.javaClass.simpleName}") }.getOrNull()
}
achievementsInFront = found
if (found == null) achievementsOpen = false
}
// The PC keyboard takes it too, for moving over the keys; the drawer opens over it.
androidx.compose.runtime.LaunchedEffect(pcKeyboardOpen) { if (pcKeyboardOpen) padBridge?.releaseAll() }
if (pcKeyboardOpen) com.droiddeck.launcher.ui.PcKeyboard(
@@ -544,7 +629,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
androidApps = androidApps,
hudOn = hudOn,
upscaler = upscaler, upscaleSharpness = upscaleSharpness,
effects = effects, textureAnisotropy = textureAnisotropy, textureLodBias = textureLodBias,
effects = effects,
cardboard = cardboard,
cardboardSensor = hasRotationSensor,
onCardboard = { c -> SessionPrefs.setCardboard(this@SessionActivity, c); applyCardboard(c) },
onCardboardRecenter = { headTracker?.recenter() },
steamVr = steamVrStatus,
onSteamVr = { action -> steamVrAction(action) },
textureAnisotropy = if (drawerGameProfileId == null) textureAnisotropy else drawerTextureAnisotropy,
textureLodBias = if (drawerGameProfileId == null) textureLodBias else drawerTextureLodBias,
frameGen = frameGen,
lossless = lossless,
oscMode = oscMode, onScreenButtonsVisible = onScreenButtonsVisible, suspendPolicy = suspendPolicy,
@@ -569,8 +662,24 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionPrefs.setScreenEffects(this@SessionActivity, e); effects = e
e.push(upscaler)
},
onTextureAnisotropy = { v -> SessionPrefs.setTextureAnisotropy(this@SessionActivity, v); textureAnisotropy = v },
onTextureLodBias = { v -> SessionPrefs.setTextureLodBias(this@SessionActivity, v); textureLodBias = v },
onTextureAnisotropy = { value ->
drawerGameProfileId?.let { appId ->
SessionPrefs.setGameTextureAnisotropy(this@SessionActivity, appId.toString(), value)
drawerTextureAnisotropy = value
} ?: value?.let {
SessionPrefs.setTextureAnisotropy(this@SessionActivity, it)
textureAnisotropy = it
}
},
onTextureLodBias = { value ->
drawerGameProfileId?.let { appId ->
SessionPrefs.setGameTextureLodBias(this@SessionActivity, appId.toString(), value)
drawerTextureLodBias = value
} ?: value?.let {
SessionPrefs.setTextureLodBias(this@SessionActivity, it)
textureLodBias = it
}
},
onFrameGenPick = { mode ->
FrameGen.set(this@SessionActivity, mode)
readPrefs()
@@ -589,6 +698,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onKeyboardButton = { on -> updateControllerPrefs { ControllerPrefs.setKeyboardButton(this@SessionActivity, on) } },
onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null,
onQam = if (SessionState.mode == SessionService.MODE_STEAM) ({ triggerSteamQam() }) else null,
onAchievements = achievementsInFront?.let { { drawerOpen = false; achievementsOpen = true } },
achievementsProgress = achievementsInFront?.progress,
// Only where the session set the pad up to be switched (SessionService: deckPad).
padType = if (SessionState.mode == SessionService.MODE_STEAM && SessionState.deckPad) padType else null,
onPadType = { id ->
@@ -665,9 +776,48 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
},
onClose = { drawerOpen = false },
components = drawerComponents,
onComponentsRefresh = { refreshDrawerComponents() },
onComponentsRefresh = {
refreshDrawerComponents()
drawerComponentMenu.refreshComponentCatalog(force = false)
},
componentCatalog = drawerComponentMenu.compCatalog,
componentCatalogAt = drawerComponentMenu.compCatalogAt,
componentCatalogChecking = drawerComponentMenu.compChecking,
componentDownloads = drawerComponentMenu.compDownloads,
componentDownloadRevision = drawerComponentMenu.compDownloadRevision,
onComponentCatalogRefresh = { drawerComponentMenu.refreshComponentCatalog() },
onComponentDownload = drawerComponentMenu::downloadComponent,
gameProfiles = drawerGameProfileRefresh.data?.profiles.orEmpty(),
selectedGameProfileId = drawerGameProfileId,
selectedGameProfile = drawerGameProfileRefresh.data
?.takeIf { it.selectedAppId == drawerGameProfileId }?.profile,
gameProfilesLoaded = drawerGameProfileRefresh.loaded,
gameProfileRefreshFailed = drawerGameProfileRefresh.failed,
onSelectedGameProfile = ::selectDrawerGameProfile,
onGameProfilesPageOpened = {
drawerGameProfileFollowState.resumeFollowingSteam()
drawerGameInventoryRevision++
},
onSelectedGameProfileRefresh = ::refreshDrawerGameProfile,
gameFexPresetSaving = drawerGameFexPresetSaving,
onGameFexPreset = ::setDrawerGameFexPreset,
gameNoSteamSaving = drawerGameNoSteamSaving,
onGameNoSteam = ::setDrawerGameNoSteam,
onComponentSwap = { pid, comp, value -> swapDrawerComponent(pid, comp, value) },
onGameComponent = ::setDrawerGameComponent,
))
// The panel takes the pad, as the PC keyboard does.
LaunchedEffect(achievementsOpen) {
if (achievementsOpen) {
padBridge?.releaseAll()
withFrameNanos { }
sessionOverlay.requestFocus()
}
}
achievementsInFront?.takeIf { achievementsOpen }?.let { inFront ->
SessionAchievements(inFront.game, inFront.title, onClose = ::closeAchievements)
}
com.droiddeck.launcher.ui.AchievementNotificationOverlay()
if (SessionState.suspended) SessionPausedOverlay(
title = pausedTitle(),
onResume = { SessionService.resume(this@SessionActivity) },
@@ -708,6 +858,28 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
padBridge?.triggerQam()
}
private fun closeAchievements() { achievementsOpen = false }
/** A live game window only; Steam's overlay may be in front of that window. */
private fun resolveAchievementsInFront(): AchievementsInFront? {
if (!SessionState.running || SessionState.mode != SessionService.MODE_STEAM) return null
val snapshot = AgentGuest.focus(this) ?: return null
val games = LibraryCache.load(this)
val byKey = games.mapNotNull { g -> g.profileKey.toLongOrNull()?.let { it to g } }.toMap()
val windows = snapshot.optJSONArray("focusableApps")?.let { list -> List(list.length()) { list.optLong(it) } }.orEmpty()
val focusedId = snapshot.optLong("focusedApp").takeUnless { snapshot.isNull("focusedApp") }
val appId = com.droiddeck.launcher.session.SessionAchievementGame.activeAppId(focusedId, windows, byKey.keys) ?: return null
val focused = byKey[appId] ?: return null
val store = Store.byId(focused.source) ?: return null
val id = focused.storeId ?: return null
val source = StoreAchievements.forSource(store.id) ?: return null
val sidecar = StoreGameSidecar.find(this, store, id) ?: return null
val game = StoreAchievements.Game.of(sidecar)
val list = source.cached(this, game) ?: (source.fetch(this, game) as? StoreAchievements.Result.Found)?.list ?: return null
if (list.isEmpty()) return null
return AchievementsInFront(game, sidecar.title, list.count { it.unlocked } to list.size)
}
private fun togglePcKeyboard() {
drawerOpen = false
keyboard?.hide()
@@ -763,6 +935,107 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}, "drawer-components").start()
}
private fun selectDrawerGameProfile(appId: Long?) {
drawerGameProfileFollowState.selectManually()
drawerGameProfileId = appId
drawerGameProfileRefresh = drawerGameProfileRefresh.retrying()
readDrawerTextureFiltering(appId)
refreshDrawerGameProfile()
}
private fun readDrawerTextureFiltering(appId: Long?) {
val choice = appId?.let { SessionPrefs.gameTextureFiltering(this, it.toString()) }
drawerTextureAnisotropy = choice?.anisotropy
drawerTextureLodBias = choice?.lodBias
}
private fun refreshDrawerGameProfile() {
val refresh = DrawerGameProfileRefresh(
drawerGameProfileFollowState.followsSteam,
drawerGameProfileId,
drawerGameInventoryRevision,
)
if (!drawerGameProfileRefreshes.submit(refresh)) return
Thread({
var request = drawerGameProfileRefreshes.takeLatest()
while (request != null) {
val current = request
val result = resolveDrawerGameProfileRefresh(current.value)
uiHandler.post {
if (!drawerGameProfileRefreshes.isLatest(current)) return@post
applyDrawerGameProfileRefresh(current.value, result)
}
request = drawerGameProfileRefreshes.takeLatest()
}
}, "drawer-game-profile").start()
}
private fun resolveDrawerGameProfileRefresh(
refresh: DrawerGameProfileRefresh,
): DrawerGameProfileResult {
return try {
val games = drawerGameInventory.get(refresh.inventoryRevision) { Library.launchableGames(this) }
val options = games.mapNotNull { game ->
game.profileKey.toLongOrNull()?.let { it to game.name }
}.sortedBy { it.second.lowercase() }
val installedAppIds = options.mapTo(mutableSetOf()) { it.first }
val selectedAppId = if (refresh.followsSteam) {
val activeAppId = AgentGuest.activeSteamAppId(this, installedAppIds)
val selectedSteamAppId = runCatching { AgentGuest.selectedSteamAppId(this) }
AgentGuest.selectedOrActiveInstalledSteamAppId(selectedSteamAppId, activeAppId, installedAppIds)
?: refresh.requestedAppId
} else {
refresh.requestedAppId
}
val profile = selectedAppId?.let { resolveDrawerGameProfile(it, games) }
DrawerGameProfileResult(options, selectedAppId, profile)
} catch (error: Exception) {
DrawerGameProfileResult(emptyList(), refresh.requestedAppId, null, error)
}
}
private fun applyDrawerGameProfileRefresh(
refresh: DrawerGameProfileRefresh,
result: DrawerGameProfileResult,
) {
if (result.error != null) {
if (!drawerGameProfileRefresh.failed) {
Log.w(TAG, "Could not refresh the selected game profile", result.error)
}
drawerGameProfileRefresh = drawerGameProfileRefresh.failed()
return
}
drawerGameProfileRefresh = drawerGameProfileRefresh.succeeded(result)
if (refresh.followsSteam) {
if (drawerGameProfileFollowState.followsSteam) {
drawerGameProfileId = result.selectedAppId
readDrawerTextureFiltering(result.selectedAppId)
}
}
}
private fun resolveDrawerGameProfile(
selectedAppId: Long,
games: List<Library.SteamGame>,
): SelectedGameProfile? {
val game = games.firstOrNull { it.profileKey == selectedAppId.toString() } ?: return null
val snapshot = drawerComponents ?: ComponentsManager.snapshot(this)
val protons = snapshot.protons.map { it.proton }
val gameChoice = ProtonDefault.gameChoice(this, game.profileKey)
return SelectedGameProfile.resolve(
selectedAppId = selectedAppId,
games = games,
generalFexPreset = SessionPrefs.fexPreset(this),
environment = GameEnvironmentStore.read(this),
snapshot = snapshot,
generalProtonId = ProtonDefault.selectedId(this, protons),
gameProtonChoice = gameChoice,
gameProtonId = ProtonDefault.gameSelectedId(this, game.profileKey, protons),
gameComponents = ComponentsManager.gameComponents(this, game.profileKey),
gameWindowsComponents = WinComponents.picks(this, game.profileKey),
)
}
private fun swapDrawerComponent(protonId: String, comp: String, value: String) {
Thread({
val message = runCatching {
@@ -784,6 +1057,63 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}, "drawer-components-swap").start()
}
private fun setDrawerGameComponent(appId: Long, comp: String, file: String?) {
val name = drawerGameProfileRefresh.data?.profiles?.firstOrNull { it.first == appId }?.second ?: appId.toString()
Thread({
val message = runCatching {
ComponentsManager.setGameComponent(this, appId.toString(), comp, file)
getString(R.string.comp_game_saved_next, name)
}.getOrElse { e ->
getString(R.string.session_profile_save_failed, e.message ?: e.javaClass.simpleName)
}
uiHandler.post {
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
refreshDrawerComponents()
refreshDrawerGameProfile()
}
}, "drawer-game-component").start()
}
private fun setDrawerGameFexPreset(appId: Long, preset: String?) {
if (drawerGameFexPresetSaving) return
drawerGameFexPresetSaving = true
val name = drawerGameProfileRefresh.data?.profiles?.firstOrNull { it.first == appId }?.second ?: appId.toString()
Thread({
val message = try {
GameEnvironmentStore.setGameFexPreset(this, appId.toString(), preset)
getString(R.string.comp_game_saved_next, name)
} catch (error: Exception) {
Log.w(TAG, "Could not save the game FEX preset for $appId", error)
getString(R.string.session_profile_save_failed, error.message ?: error.javaClass.simpleName)
}
uiHandler.post {
drawerGameFexPresetSaving = false
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
refreshDrawerGameProfile()
}
}, "drawer-game-fex-preset").start()
}
private fun setDrawerGameNoSteam(appId: Long, on: Boolean) {
if (drawerGameNoSteamSaving) return
drawerGameNoSteamSaving = true
val name = drawerGameProfileRefresh.data?.profiles?.firstOrNull { it.first == appId }?.second ?: appId.toString()
Thread({
val message = try {
GameEnvironmentStore.setGameNoSteam(this, appId.toString(), on)
getString(R.string.comp_game_saved_next, name)
} catch (error: Exception) {
Log.w(TAG, "Could not save Start without Steam for $appId", error)
getString(R.string.session_profile_save_failed, error.message ?: error.javaClass.simpleName)
}
uiHandler.post {
drawerGameNoSteamSaving = false
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
refreshDrawerGameProfile()
}
}, "drawer-game-no-steam").start()
}
/** The session this screen is bringing up: the live one when re-attached, else the intent's. */
private fun loadingMode(): String =
if (SessionState.running) SessionState.mode
@@ -836,6 +1166,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
upscaler = SessionPrefs.upscaler(this)
upscaleSharpness = SessionPrefs.upscaleSharpness(this)
effects = SessionPrefs.screenEffects(this)
applyCardboard(SessionPrefs.cardboard(this))
// A switch left from before this activity (or this resume): SteamVR was running then, so the next
// "not running" hands the picture back.
if (SessionPrefs.cardboardSourceBeforeSteamVr(this) >= 0 && !steamVrSwitchedPicture) {
steamVrSwitchedPicture = true
steamVrSeenRunning = true
}
textureAnisotropy = SessionPrefs.textureAnisotropy(this)
textureLodBias = SessionPrefs.textureLodBias(this)
val nextTouchMode = SessionPrefs.touchMode(this)
@@ -1041,6 +1378,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
WaylandCompositor.nativeSetUpscaler(SessionPrefs.upscaler(this))
WaylandCompositor.nativeSetUpscaleSharpness(SessionPrefs.upscaleSharpness(this))
SessionPrefs.screenEffects(this).push(SessionPrefs.upscaler(this))
panelSizeMm().let { (w, h) -> SessionPrefs.cardboard(this).push(w, h) }
WaylandCompositor.nativeSetCardboardPoseFile(File(filesDir, ".wayland-rt/droiddeck-headpose").path)
// The session's folder, claimed here because the compositor starts before the service and
// opens its log once. The compositor reads the path from its environment; setting it after
// it has started changes nothing, which is why the service copies the file in at teardown.
@@ -1146,12 +1485,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
/**
* Follow screen on a large panel (a Fold's inner screen) may turn with the device: a window
* locked to landscape on a squarer panel is letterboxed by the system itself, which no output
* size can undo. A phone-sized panel (the cover screen) stays landscape as before.
* size can undo. Every other case keeps the landscape orientation picked in Setup.
*/
private fun applyFollowOrientation(config: android.content.res.Configuration) {
if (!SessionState.followScreen) return
val wanted = if (config.smallestScreenWidthDp >= 600) android.content.pm.ActivityInfo.SCREEN_ORIENTATION_USER
else android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
val wanted = if (SessionState.followScreen && config.smallestScreenWidthDp >= 600) {
android.content.pm.ActivityInfo.SCREEN_ORIENTATION_USER
} else {
SessionPrefs.orientation(this)
}
if (requestedOrientation != wanted) requestedOrientation = wanted
}
@@ -1197,13 +1538,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
* The rate of the mode PerfMode asked for, not the one the panel is still in: the switch lands
* some frames after onCreate, and read too early it gave gamescope `-r 60` on a 120 Hz panel -
* the client and every game then paced themselves to 60. (WinNative's requestedPanelRefreshRate.)
* Never above the user's peak refresh rate, which Android holds the panel to whatever mode is
* active; it is the rate gamescope gives Steam, and the QAM's Frame Limit divides it.
*/
private fun refreshHz(): Float {
val display = if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay
val wanted = window.attributes.preferredDisplayModeId
val hz = display?.supportedModes?.firstOrNull { wanted != 0 && it.modeId == wanted }?.refreshRate
?: display?.refreshRate ?: 60f
return if (hz > 1f) hz else 60f
val capped = minOf(hz, PerfMode.peakRefreshCap(this))
return if (capped > 1f) capped else 60f
}
/**
@@ -1438,7 +1782,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
return true
}
if (pcKeyboardOpen && fromController) return super.dispatchKeyEvent(event)
if ((pcKeyboardOpen || achievementsOpen) && fromController) return super.dispatchKeyEvent(event)
if (event.keyCode != KeyEvent.KEYCODE_BACK && padBridge?.onKeyEvent(event) == true) return true
// A hardware keyboard, forwarded to the compositor's wl_keyboard. Back is left to the
// activity, which opens the drawer.
@@ -1491,7 +1835,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return true
}
if (drawerDirectionKey != KeyEvent.KEYCODE_UNKNOWN) releaseDrawerDirection()
if (pcKeyboardOpen && PadBridge.isControllerMotionEvent(event)) return super.dispatchGenericMotionEvent(event)
if ((pcKeyboardOpen || achievementsOpen) && PadBridge.isControllerMotionEvent(event)) return super.dispatchGenericMotionEvent(event)
if (padBridge?.onMotionEvent(event) == true) return true
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE) && !drawerOpen && onMouse(event)) return true
return super.dispatchGenericMotionEvent(event)
@@ -1929,7 +2273,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun updatePointerCapture() {
if (!::surfaceView.isInitialized) return
val view = window.decorView
val want = pointerLocked && resumed && hasWindowFocus() && !drawerOpen && !pcKeyboardOpen && !pipUi && mouseAttached()
val want = pointerLocked && resumed && hasWindowFocus() && !drawerOpen && !pcKeyboardOpen && !achievementsOpen && !pipUi && mouseAttached()
if (want == view.hasPointerCapture()) return
if (want) view.requestPointerCapture() else view.releasePointerCapture()
}
@@ -2101,6 +2445,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (show) {
drawerOpen = false
pcKeyboardOpen = false
achievementsOpen = false
pendingBackAction?.let(uiHandler::removeCallbacks)
pendingBackAction = null
releaseDrawerDirection()
@@ -2198,7 +2543,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onResume() {
super.onResume()
requestedOrientation = SessionPrefs.orientation(this)
applyFollowOrientation(resources.configuration)
if (runtimeRemovalBlocked) return
com.droiddeck.launcher.ui.Motion.refresh(this)
refreshHomeApp()
@@ -2208,6 +2553,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
(getSystemService(INPUT_SERVICE) as? InputManager)
?.registerInputDeviceListener(deviceListener, Handler(Looper.getMainLooper()))
if (SessionState.guestPid > 1) padBridge?.syncConnectedControllers()
onScreenControls?.reload()
controllerSettings = ControllerPrefs.read(this)
updateOnScreenControls()
@@ -2218,6 +2564,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
readPrefs()
if (::pip.isInitialized) pip.resumed()
if (CompositorHost.isStarted) applyFrameGen()
SessionPrefs.sessionPrefs(this).registerOnSharedPreferenceChangeListener(cardboardPrefListener)
}
override fun onPause() {
@@ -2227,6 +2574,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// drawer's dim layer can otherwise remain over Steam when this activity returns.
drawerOpen = false
pcKeyboardOpen = false
achievementsOpen = false
(getSystemService(INPUT_SERVICE) as? InputManager)?.unregisterInputDeviceListener(deviceListener)
releaseDrawerDirection()
// A button held when the app goes away would stay held in the ring for the whole session.
@@ -2235,9 +2583,103 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
updatePointerCapture()
updatePadMotion()
keyboard?.takeIf { it.shown }?.hide()
SessionPrefs.sessionPrefs(this).unregisterOnSharedPreferenceChangeListener(cardboardPrefListener)
updateHeadTracker()
super.onPause()
}
/** SteamVR for Cardboard mode: install, start or stop it in the guest. Starting switches the
* picture to side-by-side, which is what SteamVR draws. */
private fun steamVrAction(action: String) {
if (action == "start") {
switchPictureForSteamVr()
steamVrSeenRunning = false
}
if (action == "stop") restoreSteamVrPicture()
if (action == "install") steamVrStatus = SteamVr.Status.INSTALLING
Thread({
val ok = SteamVr.run(this, action)
val now = SteamVr.status(this)
uiHandler.post {
// A start that failed leaves nothing side-by-side to show: the picture goes back.
if (!ok && action == "start" && now != SteamVr.Status.RUNNING) restoreSteamVrPicture()
onSteamVrStatus(now)
if (!ok) Toast.makeText(this, getString(R.string.cardboard_steamvr_failed), Toast.LENGTH_LONG).show()
}
}, "steamvr-$action").start()
}
/** SteamVR draws a side-by-side pair: the picture switches to that, remembering what it was. */
private fun switchPictureForSteamVr() {
if (cardboard.source != Cardboard.SOURCE_SBS) {
SessionPrefs.setCardboardSourceBeforeSteamVr(this, cardboard.source)
val c = cardboard.copy(source = Cardboard.SOURCE_SBS)
SessionPrefs.setCardboard(this, c)
applyCardboard(c)
}
steamVrSwitchedPicture = SessionPrefs.cardboardSourceBeforeSteamVr(this) >= 0
}
private fun onSteamVrStatus(now: SteamVr.Status) {
steamVrStatus = now
// Started some other way than the drawer (a script, a VR game): the picture follows it all the same.
if (now == SteamVr.Status.RUNNING && cardboard.enabled && !steamVrSwitchedPicture &&
cardboard.source != Cardboard.SOURCE_SBS) {
switchPictureForSteamVr()
steamVrSeenRunning = true
}
if (!steamVrSwitchedPicture) return
if (now == SteamVr.Status.RUNNING) steamVrSeenRunning = true
else if (steamVrSeenRunning && now != SteamVr.Status.UNAVAILABLE) restoreSteamVrPicture()
}
/** SteamVR stopped: the Cardboard picture goes back to what it was before Start. */
private fun restoreSteamVrPicture() {
val before = SessionPrefs.cardboardSourceBeforeSteamVr(this)
SessionPrefs.setCardboardSourceBeforeSteamVr(this, -1)
steamVrSwitchedPicture = false
steamVrSeenRunning = false
if (before < 0 || cardboard.source == before) return
val c = cardboard.copy(source = before)
SessionPrefs.setCardboard(this, c)
applyCardboard(c)
}
/** Cardboard mode: hand the settings to the compositor and run the head tracker while it needs one. */
private fun applyCardboard(c: Cardboard) {
val was = cardboard
cardboard = c
if (CompositorHost.isStarted) panelSizeMm().let { (w, h) -> c.push(w, h) }
if (c.enabled && !was.enabled) headTracker?.recenter()
updateHeadTracker()
}
private fun updateHeadTracker() {
// Side-by-side sources track too: the compositor leaves their picture alone, but whoever renders
// it (the SteamVR driver, tools/vr) reads the pose from the shared file.
val want = resumed && cardboard.enabled && cardboard.headTracking
if (want) {
val t = headTracker ?: HeadTracker(this) {
@Suppress("DEPRECATION")
(if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay)?.rotation ?: android.view.Surface.ROTATION_0
}.also { headTracker = it }
t.start()
} else {
headTracker?.stop()
}
}
/** The panel's physical size in millimetres, long side first, for lining the eyes up with the lenses. */
private fun panelSizeMm(): Pair<Float, Float> {
val dm = resources.displayMetrics
val landscape = dm.widthPixels >= dm.heightPixels
val longPx = maxOf(dm.widthPixels, dm.heightPixels).toFloat()
val shortPx = minOf(dm.widthPixels, dm.heightPixels).toFloat()
val longDpi = if (landscape) dm.xdpi else dm.ydpi
val shortDpi = if (landscape) dm.ydpi else dm.xdpi
return (if (longDpi > 0f) longPx / longDpi * 25.4f else 0f) to (if (shortDpi > 0f) shortPx / shortDpi * 25.4f else 0f)
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return
@@ -2401,3 +2843,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
)
}
}
/** [progress]: (unlocked, total) from the cached list. */
private class AchievementsInFront(val game: StoreAchievements.Game, val title: String, val progress: Pair<Int, Int>?)
@@ -1,6 +1,11 @@
package com.droiddeck.launcher.agent
import android.content.Context
import android.os.Handler
import android.os.Looper
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.setValue
import android.os.FileObserver
import android.util.Log
import com.droiddeck.launcher.runtime.LinuxRuntime
@@ -16,6 +21,9 @@ import java.util.UUID
* whatever runs does so inside the live session with its own environment.
*/
object AgentGuest {
private val main by lazy { Handler(Looper.getMainLooper()) }
var focusRevision by mutableIntStateOf(0)
private set
private const val TAG = "AgentGuest"
private const val POLL_MS = 50L
private val lock = Any()
@@ -70,6 +78,7 @@ object AgentGuest {
if (key == lastFocusKey) return
lastFocusKey = key
}
main.post { focusRevision++ }
SessionEvents.record("focus.changed", fields + ("seq" to snapshot.optInt("seq")))
}
@@ -85,6 +94,63 @@ object AgentGuest {
fun focus(context: Context): JSONObject? =
if (SessionState.running) readJson(File(dir(context), "focus.json")) else null
/** The Steam library game whose tile currently has controller or keyboard focus. */
fun selectedSteamAppId(context: Context): Long? {
val result = call(context, JSONObject().put("kind", "steam-selection"), 6_000)
if (!result.optBoolean("ok") || result.isNull("appId")) return null
return result.optLong("appId").takeIf { it in 1..4_294_967_295L }
}
/** The installed game Gamescope is presenting, including while Steam's overlay has focus. */
fun activeSteamAppId(context: Context, installedAppIds: Set<Long>): Long? {
val snapshot = focus(context) ?: return null
val focusedAppId = snapshot.optLong("focusedApp").takeIf { snapshot.has("focusedApp") }
val focusableAppIds = snapshot.optJSONArray("focusableApps")?.let { apps ->
buildList {
for (index in 0 until apps.length()) {
apps.optLong(index).takeIf { it > 0 }?.let(::add)
}
}
}.orEmpty()
val baselayerAppIds = snapshot.optJSONArray("baselayerAppIds")?.let { apps ->
buildList {
for (index in 0 until apps.length()) {
apps.optLong(index).takeIf { it > 0 }?.let(::add)
}
}
}.orEmpty()
return activeInstalledSteamAppId(focusedAppId, focusableAppIds, installedAppIds, baselayerAppIds)
}
internal fun focusedInstalledSteamAppId(focusedAppId: Long?, installedAppIds: Set<Long>): Long? =
focusedAppId?.takeIf { it in installedAppIds }
internal fun activeInstalledSteamAppId(
focusedAppId: Long?,
focusableAppIds: List<Long>,
installedAppIds: Set<Long>,
baselayerAppIds: List<Long> = emptyList(),
): Long? = focusedInstalledSteamAppId(focusedAppId, installedAppIds)
?: focusableAppIds.filter { it in installedAppIds }.distinct().singleOrNull()
?: baselayerAppIds.filter { it in installedAppIds }.distinct().singleOrNull()
internal fun preferredInstalledSteamAppId(
selectedAppId: Long?,
activeAppId: Long?,
installedAppIds: Set<Long>,
): Long? = selectedAppId?.takeIf { it in installedAppIds }
?: activeAppId?.takeIf { it in installedAppIds }
internal fun selectedOrActiveInstalledSteamAppId(
selectedAppId: Result<Long?>,
activeAppId: Long?,
installedAppIds: Set<Long>,
): Long? {
preferredInstalledSteamAppId(selectedAppId.getOrNull(), activeAppId, installedAppIds)?.let { return it }
selectedAppId.exceptionOrNull()?.let { throw it }
return null
}
/** Send one request and wait for its answer. Throws [AgentException] with a stable code. */
fun call(context: Context, request: JSONObject, timeoutMs: Long): JSONObject {
if (request.optString("kind") in setOf("exec", "cdp")) AgentAccess.requireCommands(context)
@@ -0,0 +1,29 @@
package com.droiddeck.launcher.core
import com.droiddeck.launcher.gpu.GpuInfo
import com.droiddeck.launcher.gpu.SocNames
/** Device defaults only: affinity does not reserve cores or lock their clocks. */
object CpuAllocationDefaults {
data class Allocation(val client: String, val game: String)
val device: Allocation? by lazy {
forDevice(SocNames.model(), GpuInfo.systemProperty("ro.board.platform"),
CpuCores.all.associateWith(CpuCores::maxGhz))
}
/** Select by topology, including faster SM8550 variants; never guess core indices. */
fun forDevice(model: String, platform: String, frequencies: Map<Int, Double?>): Allocation? {
val chip = model.trim().uppercase().substringBefore('-')
val gen2 = chip in setOf("SM8550", "QCS8550") ||
(chip.isEmpty() && platform.trim().equals("kalama", ignoreCase = true))
if (!gen2 || frequencies.size != 8 || frequencies.keys.any { it < 0 } ||
frequencies.values.any { it == null || !it.isFinite() || it <= 0.0 }) return null
val clusters = frequencies.entries.groupBy { it.value }.toSortedMap(compareBy { it ?: 0.0 }).values.toList()
if (clusters.map { it.size } != listOf(3, 4, 1)) return null
return Allocation(
client = CpuCores.format(clusters[2].map { it.key }.toSet()),
game = CpuCores.format(clusters[1].map { it.key }.toSet()),
)
}
}
@@ -28,6 +28,10 @@ object CpuCores {
return format(if (cores.isEmpty()) all.toSet() else cores)
}
/** An all-core game must undo the affinity inherited from a pinned Steam client. */
fun gameLaunchList(list: String, clientPinned: Boolean): String =
restrictionOrEmpty(list).ifEmpty { if (clientPinned) listOrAll("") else "" }
/**
* The list only when it is a real restriction, else "". Empty, absent, or naming every core
* all mean "no preference": pinning a process to all cores is what the scheduler does
@@ -147,7 +147,7 @@ object DeviceReport {
h("Settings in effect")
k("Client core override", SessionPrefs.clientCpusOverride(context))
k(" client cores", CpuCores.listOrAll(SessionPrefs.clientCpus(context)))
k(" game cores", CpuCores.restrictionOrEmpty(SessionPrefs.gameCpus(context)).ifEmpty { "every core (nothing sent)" })
k(" game cores", CpuCores.restrictionOrEmpty(SessionPrefs.gameCpus(context)).ifEmpty { "every core" })
k("Turnip sysmem", SessionPrefs.tuSysmem(context))
k("Zink lazy descriptors", SessionPrefs.zinkLazy(context))
k("Threaded GL (glthread)", SessionPrefs.glThread(context))
@@ -30,4 +30,11 @@ object FexPreset {
/** Game-launch variables in KEY=VALUE form. */
fun env(id: String): List<String> = byId(id).env
val environmentKeys: Set<String> by lazy {
all.flatMap { it.env }.mapTo(linkedSetOf()) { it.substringBefore('=') }
}
fun environment(id: String): Map<String, String> =
env(id).associate { it.substringBefore('=') to it.substringAfter('=') }
}
@@ -44,6 +44,53 @@ object GameEnvironment {
const val FORCE_SSBS = "DROIDDECK_FORCE_SSBS"
/**
* A game's Start without Steam: droiddeck-game-env launches it without Proton's steam.exe (umu's
* launch where the Proton has it, else runinprefix), for games that close right away.
*/
const val NO_STEAM = "DROIDDECK_NO_STEAM"
fun gameNoSteam(config: Config, scope: String): Boolean =
scope.isNotEmpty() && config.games[scope]?.get(NO_STEAM)?.lowercase() in setOf("1", "on", "true", "yes")
fun withGameNoSteam(config: Config, scope: String, on: Boolean): Config {
require(scope.isNotEmpty() && validScope(scope))
return withScopeValue(config, scope, NO_STEAM, if (on) "1" else null)
}
fun effective(config: Config, preset: String, scope: String, forceSsbs: Boolean = false): Map<String, String?> =
defaults(preset, forceSsbs) + config.shared + if (scope.isEmpty()) emptyMap() else config.games[scope].orEmpty()
fun hasOverride(config: Config, scope: String, name: String): Boolean =
scope.isNotEmpty() && config.entries(scope).containsKey(name)
fun withScopeValue(config: Config, scope: String, name: String, value: String?): Config {
require(scope.isEmpty() || validScope(scope))
val entries = config.entries(scope).toMutableMap()
if (value == null) entries.remove(name) else entries[name] = value
return config.withEntries(scope, entries)
}
fun gameFexPreset(config: Config, scope: String): String? {
require(scope.isNotEmpty() && validScope(scope))
val entries = config.games[scope].orEmpty()
if (FexPreset.environmentKeys.none(entries::containsKey)) return null
return FexPreset.all.firstOrNull { preset ->
val expected = FexPreset.environment(preset.id)
FexPreset.environmentKeys.all { key -> entries.containsKey(key) && entries[key] == expected[key] }
}?.id ?: CUSTOM_FEX_PRESET
}
fun withGameFexPreset(config: Config, scope: String, preset: String?): Config {
require(scope.isNotEmpty() && validScope(scope))
require(preset == null || FexPreset.all.any { it.id == preset })
val entries = config.entries(scope).filterKeys { it !in FexPreset.environmentKeys }.toMutableMap()
if (preset != null) {
val values = FexPreset.environment(preset)
FexPreset.environmentKeys.forEach { key -> entries[key] = values[key] }
}
return config.withEntries(scope, entries)
}
const val CUSTOM_FEX_PRESET = "__custom__"
}
@@ -31,6 +31,8 @@ object Library {
) {
/** Decimal form used by Steam links and Android shortcuts, including unsigned shortcut ids. */
val gameIdString: String get() = java.lang.Long.toUnsignedString(gameId)
/** Key used by per-game environment and Windows-component profiles. */
val profileKey: String get() = if (library == ADDED) java.lang.Integer.toUnsignedString(appId) else appId.toString()
}
/** The [SteamGame.library] of a game added to the library rather than installed by Steam, and the [SteamGame.source] of a plain added folder. */
const val ADDED = "added"
@@ -0,0 +1,202 @@
package com.droiddeck.launcher.gpu
import com.droiddeck.launcher.wayland.WaylandCompositor
import org.json.JSONObject
import kotlin.math.sqrt
/**
* Cardboard mode: the compositor draws the session as two lens-distorted eye views side by side
* (waylandcomp/src/cardboard.c), for a phone or handheld dropped into a Google Cardboard-style
* viewer. Units are the menu's own:
*
* - [ipdMm] distance between the eyes, millimetres
* - [screenWidthDm] the virtual screen's width, decimetres (40 = 4 m)
* - [screenDistanceDm] how far away it hangs, decimetres (30 = 3 m)
* - [viewer] a [Viewer] id: the lens it is drawn for
*
* [source] SCREEN shows the frame as a flat virtual screen, world-locked by the rotation sensor
* when [headTracking] is on (3DoF), with real stereo depth from the IPD. SBS takes a frame that is
* already a left/right pair (SteamVR, SBS 3D games and video) and only adds the lens.
*/
data class Cardboard(
val enabled: Boolean = false,
val source: Int = SOURCE_SCREEN,
val viewer: String = Viewer.CARDBOARD_V2.id,
val ipdMm: Int = 64,
val screenWidthDm: Int = 40,
val screenDistanceDm: Int = 30,
val headTracking: Boolean = true,
) {
val lens: Viewer get() = Viewer.byId(viewer)
/**
* Hands the settings to the compositor. [panelWidthMm] x [panelHeightMm] is the physical size of
* the panel the eyes are drawn on (0 = unknown). The width decides how far each lens centre sits
* from its viewport's centre, so the two pictures line up with the viewer's lenses; the height,
* seen through the lens from its focal distance, is the field of view the distortion works in.
*/
fun push(panelWidthMm: Float, panelHeightMm: Float) {
val l = lens
WaylandCompositor.nativeSetCardboard(enabled, source, ipdMm / 1000f, screenDistanceDm / 10f,
screenWidthDm / 10f, l.k1, l.k2, fovDeg(l, panelHeightMm), lensInset(panelWidthMm, l.lensSeparationMm), l.chroma)
}
fun encode(): JSONObject = JSONObject()
.put("enabled", enabled).put("source", source).put("viewer", viewer).put("ipdMm", ipdMm)
.put("screenWidthDm", screenWidthDm).put("screenDistanceDm", screenDistanceDm).put("headTracking", headTracking)
/**
* A lens the eyes can be drawn for: its distortion (Cardboard viewer profile coefficients, in
* tangent units at the panel), how far it sits from the panel, and how far apart the two are.
* [fallbackFovDeg] is the eye's field of view when the panel's size is unknown, or with no lens.
*/
enum class Viewer(val id: String, val k1: Float, val k2: Float, val screenToLensMm: Float,
val lensSeparationMm: Float, val chroma: Float, val fallbackFovDeg: Float) {
/** Google Cardboard (2015): the common plastic and cardboard viewers. */
CARDBOARD_V2("cardboard2", 0.34f, 0.55f, 39.3f, 63.9f, 0.006f, 90f),
/** The original 2014 Cardboard: narrower, gentler lenses. */
CARDBOARD_V1("cardboard1", 0.441f, 0.156f, 42f, 60f, 0.004f, 80f),
/** No lens: side-by-side 3D glasses, a 3D TV, or just looking at the stereo pair. */
NONE("none", 0f, 0f, 0f, 0f, 0f, 60f);
companion object {
fun byId(id: String): Viewer = entries.firstOrNull { it.id == id } ?: CARDBOARD_V2
}
}
companion object {
const val SOURCE_SCREEN = 0
const val SOURCE_SBS = 1
val OFF = Cardboard()
fun decode(json: JSONObject): Cardboard = Cardboard(
enabled = json.optBoolean("enabled", false),
source = if (json.optInt("source", SOURCE_SCREEN) == SOURCE_SBS) SOURCE_SBS else SOURCE_SCREEN,
viewer = Viewer.byId(json.optString("viewer", Viewer.CARDBOARD_V2.id)).id,
ipdMm = json.optInt("ipdMm", 64).coerceIn(50, 80),
screenWidthDm = json.optInt("screenWidthDm", 40).coerceIn(5, 200),
screenDistanceDm = json.optInt("screenDistanceDm", 30).coerceIn(5, 200),
headTracking = json.optBoolean("headTracking", true),
)
/**
* One eye's vertical field of view on the panel side of the lens: half the panel's height
* seen from the lens's focal distance. The lens then spreads it wider (the distortion).
*/
fun fovDeg(viewer: Viewer, panelHeightMm: Float): Float {
if (panelHeightMm <= 0f || viewer.screenToLensMm <= 0f) return viewer.fallbackFovDeg
val half = Math.toDegrees(kotlin.math.atan((panelHeightMm / 2f / viewer.screenToLensMm).toDouble())).toFloat()
return (2f * half).coerceIn(30f, 140f)
}
/**
* The lens centre's inset towards the nose, as a fraction of one eye viewport's width. Each
* eye gets half the panel, so its viewport centre is a quarter of the panel from the middle;
* the lens centre is half the lens separation from it. 0 when either is unknown.
*/
fun lensInset(panelWidthMm: Float, lensSeparationMm: Float): Float {
if (panelWidthMm <= 0f || lensSeparationMm <= 0f) return 0f
val inset = (panelWidthMm / 4f - lensSeparationMm / 2f) / (panelWidthMm / 2f)
return inset.coerceIn(-0.25f, 0.25f)
}
}
}
/**
* Head pose maths for Cardboard mode, kept free of Android types so it is unit-tested on the JVM.
* Matrices are row-major 3x3 FloatArrays. "World" is Android's sensor world (x east, y north,
* z up); "screen" axes are x right, y up, z out of the panel towards the viewer's eyes.
*/
object HeadPose {
val IDENTITY = floatArrayOf(1f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 1f)
/** Unit quaternion (x, y, z, w) -> rotation matrix: what SensorManager.getRotationMatrixFromVector gives. */
fun fromQuaternion(x: Float, y: Float, z: Float, w: Float): FloatArray {
val n = sqrt(x * x + y * y + z * z + w * w).takeIf { it > 1e-6f } ?: 1f
val qx = x / n; val qy = y / n; val qz = z / n; val qw = w / n
return floatArrayOf(
1 - 2 * (qy * qy + qz * qz), 2 * (qx * qy - qz * qw), 2 * (qx * qz + qy * qw),
2 * (qx * qy + qz * qw), 1 - 2 * (qx * qx + qz * qz), 2 * (qy * qz - qx * qw),
2 * (qx * qz - qy * qw), 2 * (qy * qz + qx * qw), 1 - 2 * (qx * qx + qy * qy),
)
}
/** The rotation vector sensor's values -> (x, y, z, w), computing w when the sensor leaves it out. */
fun quaternionFromRotationVector(v: FloatArray): FloatArray {
val x = v[0]; val y = v[1]; val z = v[2]
val w = if (v.size >= 4) v[3] else sqrt((1f - x * x - y * y - z * z).coerceAtLeast(0f))
return floatArrayOf(x, y, z, w)
}
/**
* Device -> screen axes for a Display rotation (Surface.ROTATION_* as 0..3): the returned matrix
* maps screen to world, given the device's own device -> world matrix. The same transform as
* input/PadMotion's and SensorManager.remapCoordinateSystem's (ROTATION_90 = AXIS_Y, AXIS_MINUS_X:
* the device's x is the screen's y, its y the screen's -x).
*/
fun toScreen(deviceToWorld: FloatArray, rotation: Int): FloatArray {
// Columns of A: where each screen axis points in device coordinates.
val a = when (rotation and 3) {
1 -> floatArrayOf(0f, 1f, 0f, -1f, 0f, 0f, 0f, 0f, 1f) // screen x = -device y, y = device x
2 -> floatArrayOf(-1f, 0f, 0f, 0f, -1f, 0f, 0f, 0f, 1f) // screen x = -device x, y = -device y
3 -> floatArrayOf(0f, -1f, 0f, 1f, 0f, 0f, 0f, 0f, 1f) // screen x = device y, y = -device x
else -> IDENTITY
}
return multiply(deviceToWorld, a)
}
/** sin(60 degrees): a view further from level than this is "held flat" for recentring. */
private const val FLAT_SIN = 0.866f
/**
* What a recentre locks to. The pose the compositor gets is basis^T * screenToWorld * tilt:
* - [basis]: a level frame (columns right, up, back in world) facing the recentred heading. Every
* rotation is measured in it, so turning about the real vertical always pans the view;
* - [tilt]: the device's own tilt at the recentre, taken out so that pose starts straight ahead.
* Identity when the device was held upright: the horizon then stays the true one.
*/
class Reference(val basis: FloatArray, val tilt: FloatArray)
/**
* Recentre on the current orientation. Held upright (the screen within 60 degrees of vertical, as
* in a viewer), only the heading resets: forward is where the screen faces. Held flat (lying back,
* or the device on a table or in the hands like a tray), forward is where the top of the screen
* points and the tilt resets too; turning at the hips still pans, tipping the device still tilts.
*/
fun recenter(screenToWorld: FloatArray): Reference {
val fz = -screenToWorld[8]
val flat = kotlin.math.abs(fz) > FLAT_SIN
// Upright: the screen's -z. Flat: the screen's +y (its top edge), which is level then.
var fx = if (flat) screenToWorld[1] else -screenToWorld[2]
var fy = if (flat) screenToWorld[4] else -screenToWorld[5]
val len = sqrt(fx * fx + fy * fy).takeIf { it > 1e-4f } ?: run { fx = 0f; fy = 1f; 1f }
fx /= len; fy /= len
// right = forward x up(0,0,1) = (fy, -fx, 0); up = (0,0,1); back = -forward.
val basis = floatArrayOf(
fy, 0f, -fx,
-fx, 0f, -fy,
0f, 1f, 0f,
)
val tilt = if (flat) multiply(transpose(screenToWorld), basis) else IDENTITY
return Reference(basis, tilt)
}
/** head -> recentred world: what the compositor takes. */
fun relative(ref: Reference, screenToWorld: FloatArray): FloatArray =
multiply(multiply(transpose(ref.basis), screenToWorld), ref.tilt)
fun multiply(a: FloatArray, b: FloatArray): FloatArray = FloatArray(9) { i ->
val r = i / 3; val c = i % 3
a[r * 3] * b[c] + a[r * 3 + 1] * b[3 + c] + a[r * 3 + 2] * b[6 + c]
}
fun transpose(m: FloatArray): FloatArray = floatArrayOf(m[0], m[3], m[6], m[1], m[4], m[7], m[2], m[5], m[8])
/** m * v for a 3-vector. */
fun apply(m: FloatArray, v: FloatArray): FloatArray = floatArrayOf(
m[0] * v[0] + m[1] * v[1] + m[2] * v[2],
m[3] * v[0] + m[4] * v[1] + m[5] * v[2],
m[6] * v[0] + m[7] * v[1] + m[8] * v[2],
)
}
@@ -0,0 +1,81 @@
package com.droiddeck.launcher.gpu
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import com.droiddeck.launcher.wayland.WaylandCompositor
/**
* 3DoF head tracking for Cardboard mode: the rotation sensor -> the compositor's head pose
* ([HeadPose] for the maths). Game rotation vector first (gyro + accelerometer: no compass jumps
* indoors, and only the heading drifts, which a recentre fixes); the plain rotation vector where a
* device has no game one. Events arrive on a thread of their own and go straight to native code,
* so the session's UI thread never sits between the sensor and the screen.
*
* [rotation] is the Display rotation (Surface.ROTATION_*) the session is drawn in.
*/
class HeadTracker(context: Context, private val rotation: () -> Int) : SensorEventListener {
private val sensors = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
private val sensor: Sensor? = sensors.getDefaultSensor(Sensor.TYPE_GAME_ROTATION_VECTOR)
?: sensors.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
private var thread: HandlerThread? = null
@Volatile private var reference: HeadPose.Reference? = null
@Volatile private var recenterPending = true
@Volatile private var latest: FloatArray? = null
/** Whether this device has a rotation sensor at all (no sensor = a fixed, head-locked screen). */
val available: Boolean get() = sensor != null
val sensorName: String get() = sensor?.let { "${it.name} (${if (it.type == Sensor.TYPE_GAME_ROTATION_VECTOR) "game rotation vector" else "rotation vector"})" } ?: "none"
fun start() {
val s = sensor ?: run {
WaylandCompositor.nativeLog("cardboard", "no rotation sensor: the virtual screen stays head-locked")
WaylandCompositor.nativeSetCardboardPose(HeadPose.IDENTITY, false)
return
}
if (thread != null) return
recenterPending = true
val t = HandlerThread("droiddeck-headtracker").also { it.start() }
thread = t
sensors.registerListener(this, s, SensorManager.SENSOR_DELAY_FASTEST, Handler(t.looper))
WaylandCompositor.nativeLog("cardboard", "head tracking on: $sensorName")
}
fun stop() {
val t = thread ?: return
sensors.unregisterListener(this)
t.quitSafely()
thread = null
WaylandCompositor.nativeSetCardboardPose(HeadPose.IDENTITY, false)
}
/** Make where the viewer looks now straight ahead (see HeadPose.recenter). */
fun recenter() {
recenterPending = true
latest?.let { apply(it) }
}
override fun onSensorChanged(event: SensorEvent) {
val q = HeadPose.quaternionFromRotationVector(event.values)
val m = HeadPose.toScreen(HeadPose.fromQuaternion(q[0], q[1], q[2], q[3]), rotation())
latest = m
apply(m)
}
private fun apply(screenToWorld: FloatArray) {
val ref = reference.takeUnless { recenterPending } ?: HeadPose.recenter(screenToWorld).also {
reference = it
recenterPending = false
}
WaylandCompositor.nativeSetCardboardPose(HeadPose.relative(ref, screenToWorld), true)
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {
Log.d("DroidDeck", "head tracker accuracy $accuracy")
}
}
@@ -0,0 +1,58 @@
package com.droiddeck.launcher.gpu
import android.content.Context
import com.droiddeck.launcher.agent.AgentGuest
import org.json.JSONObject
/**
* Cardboard mode's SteamVR (docs/cardboard-vr.md): Valve's ARM64 SteamVR in the guest, rendering
* through the DroidDeck driver as a side-by-side pair that the Cardboard pass puts the lens on. Driven
* through the guest agent's fixed "vr" request (droiddeck-steamvr), so it needs no agent commands.
* Every call blocks on the guest: run them off the main thread.
*/
object SteamVr {
enum class Status {
/** No session, no agent, or a runtime without the helper or the driver. */
UNAVAILABLE,
NOT_INSTALLED,
/** Downloading or unpacking (571 MB). */
INSTALLING,
READY,
RUNNING,
}
fun status(context: Context): Status = runCatching {
parse(AgentGuest.call(context, JSONObject().put("kind", "vr").put("action", "status"), STATUS_TIMEOUT_MS))
}.getOrDefault(Status.UNAVAILABLE)
/** install, start or stop; true when the guest did it. */
fun run(context: Context, action: String): Boolean = runCatching {
require(action in setOf("install", "start", "stop"))
succeeded(action, AgentGuest.call(context, JSONObject().put("kind", "vr").put("action", action), ACTION_TIMEOUT_MS))
}.getOrDefault(false)
/**
* The agent answers ok whenever it ran the request, even when droiddeck-steamvr then failed; the
* helper's own exit code says whether start or stop worked. install runs detached and only reports
* that it started (its progress shows in status).
*/
fun succeeded(action: String, answer: JSONObject): Boolean = when {
!answer.optBoolean("ok", false) -> false
action == "install" -> answer.optBoolean("started", false)
else -> answer.optInt("exitCode", -1) == 0
}
/** The agent's status answer -> what the menu shows. */
fun parse(answer: JSONObject): Status = when {
!answer.optBoolean("ok", false) -> Status.UNAVAILABLE
answer.optInt("exitCode", 0) != 0 -> Status.UNAVAILABLE
answer.optBoolean("installing") -> Status.INSTALLING
!answer.optBoolean("driver") -> Status.UNAVAILABLE
!answer.optBoolean("installed") -> Status.NOT_INSTALLED
answer.optBoolean("running") -> Status.RUNNING
else -> Status.READY
}
private const val STATUS_TIMEOUT_MS = 10_000L
private const val ACTION_TIMEOUT_MS = 40_000L
}
@@ -50,7 +50,8 @@ public final class TurnipDriver {
/** Adreno 8xx (8 Elite, e.g. a Fold 8's A830). */
private static final String DRIVER_A8XX = "turnip-sdk36";
/** The two the apk carries; never removable, unpacked on demand. */
public static final List<String> BUNDLED = Collections.unmodifiableList(Arrays.asList(DRIVER_A7XX, DRIVER_A8XX));
public static final List<String> BUNDLED = Collections.unmodifiableList(Arrays.asList(
DRIVER_A7XX, DRIVER_A8XX, com.droiddeck.launcher.session.QuestExperimental.DRIVER));
/** Overrides the pick, for a device we cannot reach: a7xx, a8xx or system. */
private static final String OVERRIDE_FILE = "Download/droiddeck-driver";
/** Bundled drivers the user deleted: hidden from the list, their unpacked copy removed. */
@@ -566,6 +566,16 @@ public class FakeInputWriter {
}
}
/**
* Whether the pad, as the Steam client now sees it, has a Quick Access button: a Deck's or a
* Steam Controller's does, an Xbox or PlayStation pad's does not (libfakeinput drops the bit).
* Unset is the Deck, libfakeinput's default.
*/
public static boolean padHasQam() {
int type = padType;
return type < 0 || type == PAD_TYPE_DECK || type == PAD_TYPE_STEAM;
}
/**
* Switches what the pad is to the Steam client while the session runs ([PAD_TYPE_DECK],
* [PAD_TYPE_STEAM] or [PAD_TYPE_XBOX]); libfakeinput unplugs the old device and plugs in the new.
@@ -257,6 +257,14 @@ public final class PadBridge {
return false;
}
/** Publishes every currently connected controller so the client can enumerate all players. */
public synchronized void syncConnectedControllers() {
for (int deviceId : InputDevice.getDeviceIds()) {
InputDevice device = InputDevice.getDevice(deviceId);
if (isFromController(device)) publish(slotFor(device));
}
}
/**
* Called (main thread) when a player presses a button, pushes a stick, trigger or d-pad past
* halfway, or uses the on-screen controls - the session hides its mouse cursor then. Resting
@@ -392,8 +400,8 @@ public final class PadBridge {
boolean qamStarted = qamPressed && !systemQamPressed;
systemGuidePressed = guidePressed;
systemQamPressed = qamPressed;
// A Deck controller's QAM button is held for as long as the touch one is.
if (qamStarted && !SessionState.getDeckPad()) startQamChord();
// A Deck or Steam Controller's QAM button is held for as long as the touch one is.
if (qamStarted && !nativeQam()) startQamChord();
else publish();
}
@@ -418,9 +426,18 @@ public final class PadBridge {
}
}
/** Opens the client's Quick Access menu: a tap of the Deck's button, or the Guide-then-A chord. */
/**
* Whether the client's pad has its own QAM button to press. The Deck's sysfs being up is not
* enough: the drawer can make the pad an Xbox or PlayStation one, which has none, and a press
* of it never reaches the client.
*/
private static boolean nativeQam() {
return SessionState.getDeckPad() && FakeInputWriter.padHasQam();
}
/** Opens the client's Quick Access menu: a tap of the pad's button, or the Guide-then-A chord. */
public synchronized void triggerQam() {
if (!SessionState.getDeckPad()) {
if (!nativeQam()) {
startQamChord();
return;
}
@@ -563,7 +580,7 @@ public final class PadBridge {
return;
}
boolean guide = systemGuidePressed || qamChordActive;
boolean qam = SessionState.getDeckPad() && (systemQamPressed || qamTapPressed);
boolean qam = nativeQam() && (systemQamPressed || qamTapPressed);
if (!guide && !qam) {
writer.writePad(state);
return;
@@ -59,15 +59,31 @@ class TouchpadGestures(
private var moverLastX = 0f
private var moverLastY = 0f
private var holdDrag = false
private var clicked = 0
private val longPress = Runnable {
held = true
dragging = true
listener.onLongPress()
listener.onButton(PointerGestures.BTN_LEFT, true, x, y)
}
private val clickUp = Runnable { releaseClick() }
private fun click(button: Int) {
releaseClick()
listener.onButton(button, true, x, y)
clicked = button
handler.postDelayed(clickUp, CLICK_MS)
}
private fun releaseClick() {
handler.removeCallbacks(clickUp)
if (clicked != 0) listener.onButton(clicked, false, x, y)
clicked = 0
}
fun cancel() {
handler.removeCallbacks(longPress)
releaseClick()
if (dragging) listener.onButton(PointerGestures.BTN_LEFT, false, x, y)
dragging = false
held = false
@@ -85,6 +101,7 @@ class TouchpadGestures(
fun onTouch(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
releaseClick()
lastX = event.x; lastY = event.y
downAt = event.eventTime
travelled = 0f; held = false; twoFingers = false; scrolled = false; secondWasTap = false
@@ -204,8 +221,7 @@ class TouchpadGestures(
}
// The second finger lifting quickly, with nothing scrolled: a right click.
if (twoFingers && secondWasTap && event.eventTime - secondDownAt < TAP_MS && !scrolled) {
listener.onButton(PointerGestures.BTN_RIGHT, true, x, y)
listener.onButton(PointerGestures.BTN_RIGHT, false, x, y)
click(PointerGestures.BTN_RIGHT)
secondWasTap = false
}
return true
@@ -218,13 +234,11 @@ class TouchpadGestures(
twoFingers -> {
// Both fingers down and up together, no scroll: a right click.
if (secondWasTap && !scrolled && !cancelled && event.eventTime - downAt < TAP_MS * 2) {
listener.onButton(PointerGestures.BTN_RIGHT, true, x, y)
listener.onButton(PointerGestures.BTN_RIGHT, false, x, y)
click(PointerGestures.BTN_RIGHT)
}
}
!cancelled && travelled <= slopPx && event.eventTime - downAt < TAP_MS -> {
listener.onButton(PointerGestures.BTN_LEFT, true, x, y)
listener.onButton(PointerGestures.BTN_LEFT, false, x, y)
click(PointerGestures.BTN_LEFT)
lastTapAt = event.eventTime
}
}
@@ -239,6 +253,7 @@ class TouchpadGestures(
companion object {
private const val HOLD_MS = 500L
private const val TAP_MS = 300L
private const val CLICK_MS = 50L
private const val DOUBLE_TAP_MS = 300L
private const val SCROLL_STEP_PX = 36f
}
@@ -33,7 +33,7 @@ import java.util.concurrent.atomic.AtomicInteger
*/
object BwrapSpawner {
private const val TAG = "BwrapSpawner"
const val SOCKET = "com.droiddeck.launcher.bwrap"
fun socket(context: Context): String = "${context.packageName}.bwrap"
private val seq = AtomicInteger()
@Volatile private var server: LocalServerSocket? = null
@@ -42,10 +42,11 @@ object BwrapSpawner {
fun start(context: Context) {
if (server != null) return
val app = context.applicationContext
val name = socket(app)
val socket = try {
LocalServerSocket(SOCKET)
LocalServerSocket(name)
} catch (e: IOException) {
Log.e(TAG, "cannot listen on $SOCKET", e)
Log.e(TAG, "cannot listen on $name", e)
return
}
server = socket
@@ -110,6 +110,9 @@ object DesktopCatalog {
return File(root, DesktopRemoval.PENDING).exists() || marker(context, DESKTOP_ID).exists()
}
/** The installed desktop's size on disk; off the main thread. */
fun desktopSize(context: Context): Long = DesktopRemoval.installedSize(context)
fun cleanLegacy(context: Context, listener: LinuxRuntimeInstaller.ProgressListener? = null) =
DesktopRemoval.cleanLegacy(context, listener)
@@ -19,6 +19,14 @@ internal object DesktopRemoval {
private fun protected(context: Context, root: File) =
DesktopFileRemoval.runtimeFiles(root) + lines(context, "emulators-keep.txt.gzip")
/**
* What the desktop's own files take on disk, from the same inventory its removal uses (each
* file's size); the bundled apps' and emulators' shared files are not counted. Reads an asset:
* call it off the main thread.
*/
fun installedSize(context: Context): Long =
lines(context, "kde-remove.tsv.gzip").sumOf { line -> line.split('\t').getOrNull(1)?.toLongOrNull() ?: 0L }
/** Called before a guest starts or just after KDE extraction; retries after a process death. */
fun cleanLegacy(context: Context, listener: LinuxRuntimeInstaller.ProgressListener? = null) {
val root = LinuxRuntime.rootDir(context)
@@ -51,6 +51,7 @@ object GuestCommand {
"/usr/bin/env", "-i", "HOME=/root", "USER=root", "LANG=C.UTF-8",
"PATH=/usr/local/bin:/usr/bin:/bin", "XDG_RUNTIME_DIR=${LinuxRuntime.GUEST_RUNTIME_DIR}",
"XDG_DATA_HOME=/root/.local/share", "FLATPAK_BWRAP=${FlatpakManager.BWRAP}",
"DROIDDECK_BWRAP_SOCKET=${BwrapSpawner.socket(context)}",
"XDG_DATA_DIRS=/root/.local/share/flatpak/exports/share:/usr/local/share:/usr/share",
)
cmd += argv
@@ -6,6 +6,7 @@ import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.Downloader
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.core.GameEnvironment
import com.droiddeck.launcher.gpu.GpuInfo
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.github.luben.zstd.ZstdInputStream
@@ -79,7 +80,14 @@ object ComponentsManager {
private const val PLUGIN_DATA = "root/homebrew/data/decky-components-manager"
data class Proton(val id: String, val name: String, val dir: File, val guestPath: String, val version: String, val valve: Boolean)
data class Component(val detected: String, val detail: String, val inUse: String, val activeFile: String?, val queued: String?)
data class Component(
val detected: String,
val detail: String,
val inUse: String,
val selected: String,
val activeFile: String?,
val queued: String?,
)
data class Original(val comp: String, val protonVersion: String, val label: String, val size: Long)
data class Package(val file: String, val comp: String, val version: String, val description: String, val size: Long)
data class CatalogItem(val file: String, val comp: String, val release: String, val url: String, val size: Long, val digest: String)
@@ -129,23 +137,121 @@ object ComponentsManager {
// ------------------------------------------------------------------ state
private fun loadState(context: Context): JSONObject =
runCatching { JSONObject(stateFile(context).readText()) }.getOrElse { JSONObject() }.apply {
private fun pendingStateFile(context: Context) = File(dataDir(context), "state.pending.json")
private fun loadState(context: Context): JSONObject {
recoverState(context)
return runCatching { JSONObject(stateFile(context).readText()) }.getOrElse { JSONObject() }.apply {
if (!has("active")) put("active", JSONObject())
if (!has("queued")) put("queued", JSONObject())
if (!has("profiles")) put("profiles", JSONObject())
}
}
private fun writeStateFile(file: File, text: String) {
val tmp = File(file.parentFile, file.name + ".tmp")
try {
writeSynced(tmp, text)
check(tmp.renameTo(file)) { "Could not save component selections: ${file.name}" }
} finally {
tmp.delete()
}
}
private fun writeSynced(file: File, text: String) {
FileOutputStream(file).use {
it.write(text.toByteArray(Charsets.UTF_8))
it.fd.sync()
}
}
private fun recoverState(context: Context) {
val pending = pendingStateFile(context)
if (!pending.exists()) return
val transaction = JSONObject(pending.readText())
val state = transaction.getJSONObject("state")
val expected = transaction.getString("launchSha256")
val published = File(launchDir(context), "desired.tsv")
val staged = File(launchDir(context), "desired.tsv.tmp")
// The launch-table rename is the commit point. Never read stale app selections after it.
if (!staged.exists() && published.isFile && Hashes.sha256(published) == expected) {
writeStateFile(stateFile(context), state.toString(2))
Log.i(TAG, "recovered published component selections")
} else {
Log.i(TAG, "discarded unpublished component selections")
}
check(pending.delete()) { "Could not finish component selection recovery" }
if (staged.exists()) check(staged.delete()) { "Could not remove unpublished component selections" }
}
private fun saveState(context: Context, state: JSONObject) {
val f = stateFile(context)
val tmp = File(f.parentFile, f.name + ".tmp")
tmp.writeText(state.toString(2))
tmp.renameTo(f)
// Every change of choice reaches the launch wrappers at once.
runCatching { syncLaunchState(context, state) }.onFailure { Log.w(TAG, "launch state", it) }
val prepared = prepareLaunchState(context, state)
val dir = launchDir(context)
val tmp = File(dir, "desired.tsv.tmp")
try {
writeSynced(tmp, prepared.text)
writeStateFile(pendingStateFile(context), JSONObject()
.put("state", state)
.put("launchSha256", Hashes.sha256(tmp))
.toString())
check(tmp.renameTo(File(dir, "desired.tsv"))) { "Could not publish component selections" }
writeStateFile(stateFile(context), state.toString(2))
check(pendingStateFile(context).delete()) { "Could not finish saving component selections" }
} finally {
if (!pendingStateFile(context).exists()) tmp.delete()
}
File(dir, "store").listFiles()?.filter { it.isDirectory && it.name !in prepared.keep }
?.forEach { it.deleteRecursively() }
}
private fun JSONObject.sub(key: String): JSONObject = optJSONObject(key) ?: JSONObject().also { put(key, it) }
// Keep launch-time restore rows after the last per-game selection is removed.
private fun rememberProfiledComponent(state: JSONObject, comp: String) {
val profiled = state.optJSONArray("profiledComponents")?.let { values ->
(0 until values.length()).mapNotNull { values.optString(it).takeIf(COMPONENTS::contains) }
}.orEmpty().toMutableSet()
if (profiled.add(comp)) state.put("profiledComponents", JSONArray(profiled.sorted()))
}
fun gameComponents(context: Context, scope: String): Map<String, String> = synchronized(lock) {
val profile = loadState(context).sub("profiles").optJSONObject(scope) ?: return emptyMap()
COMPONENTS.mapNotNull { comp -> profile.optString(comp).takeIf(String::isNotEmpty)?.let { comp to it } }.toMap()
}
fun setGameComponent(context: Context, scope: String, comp: String, file: String?) = synchronized(lock) {
require(scope.matches(Regex("[1-9][0-9]{0,9}")) && scope.toLong() <= 4294967295L)
require(comp in COMPONENTS)
val state = loadState(context)
val profiles = state.sub("profiles")
val profile = profiles.optJSONObject(scope) ?: JSONObject().also { profiles.put(scope, it) }
if (file == null) {
if (profile.optString(comp).isNotEmpty()) rememberProfiledComponent(state, comp)
profile.remove(comp)
if (profile.length() == 0) profiles.remove(scope)
} else {
val name = safeName(file)
val wcp = File(packagesDir(context), name)
require(packageInfo(context, wcp).comp == comp)
for (proton in protons(context)) ensureOriginal(context, proton, comp, state)
rememberProfiledComponent(state, comp)
profile.put(comp, name)
}
saveState(context, state)
}
fun clearGameComponents(context: Context, scope: String) = synchronized(lock) {
require(GameEnvironment.validScope(scope))
val state = loadState(context)
val profiles = state.sub("profiles")
profiles.optJSONObject(scope)?.let { profile ->
COMPONENTS.filter { profile.optString(it).isNotEmpty() }
.forEach { rememberProfiledComponent(state, it) }
}
profiles.remove(scope)
saveState(context, state)
}
/** Takes over the Decky plugin's packages, originals and swap records, once. */
private fun migratePlugin(context: Context) {
val marker = File(dataDir(context), ".plugin-imported")
@@ -220,24 +326,33 @@ object ComponentsManager {
* Is anything running on this Proton right now - a game, or the client's own start-up runs,
* whose Wine processes can outlive them? Swaps wait while it is. Read from the command lines.
*/
fun inUse(context: Context, proton: Proton): Boolean = anyProcess(context, proton) { needle, text -> text.contains(needle + "proton ") || text.contains(needle + "files/bin") }
fun inUse(context: Context, proton: Proton): Boolean =
anyProcess(context, proton) { paths, text -> processUsesProton(paths, text, gameOnly = false) }
/**
* Is a game running on this Proton? Only a launch runs proton with the verb waitforexitandrun
* (Proton's own script stays up until the game exits); the client's start-up runs do not.
*/
fun gameRunning(context: Context, proton: Proton): Boolean = anyProcess(context, proton) { needle, text -> text.contains(needle + "proton waitforexitandrun") }
fun gameRunning(context: Context, proton: Proton): Boolean =
anyProcess(context, proton) { paths, text -> processUsesProton(paths, text, gameOnly = true) }
private fun anyProcess(context: Context, proton: Proton, match: (String, String) -> Boolean): Boolean {
val needles = (listOf(proton.guestPath) + EsyncPacks.distPathsFor(root(context), proton.guestPath))
.map { it.trimEnd('/') + "/" }
internal fun processUsesProton(paths: List<String>, text: String, gameOnly: Boolean): Boolean =
paths.any { path ->
val needle = path.trimEnd('/') + "/"
if (gameOnly) text.contains(needle + "proton waitforexitandrun")
else text.contains(needle + "proton ") || text.contains(needle + "files/bin")
}
private fun anyProcess(context: Context, proton: Proton, match: (List<String>, String) -> Boolean): Boolean {
val guestPaths = listOf(proton.guestPath) + EsyncPacks.distPathsFor(root(context), proton.guestPath)
val paths = (guestPaths + guestPaths.map { host(context, it).absolutePath } + proton.dir.absolutePath).distinct()
val procs = File("/proc").listFiles() ?: return false
for (p in procs) {
if (!p.name.all(Char::isDigit)) continue
val cmd = runCatching { File(p, "cmdline").readBytes() }.getOrNull() ?: continue
if (cmd.isEmpty()) continue
val text = String(cmd).replace('\u0000', ' ')
if (needles.any { match(it, text) }) return true
if (match(paths, text)) return true
}
return false
}
@@ -261,7 +376,7 @@ object ComponentsManager {
private fun compFiles(dir: File, comp: String): List<String> {
if (comp == "fex") return FEX_FILES.filter { File(dir, it).isFile }
val base = File(dir, COMP_DIR.getValue(comp))
return base.walkTopDown().filter { it.isFile }.map { it.relativeTo(dir).path }.sorted().toList()
return base.walkTopDown().filter { it.isFile }.map { it.relativeTo(dir).invariantSeparatorsPath }.sorted().toList()
}
private fun owned(comp: String, rel: String) = comp == "fex" || PE_ARCHES.any { "/$it/" in rel }
@@ -291,9 +406,11 @@ object ComponentsManager {
return Package(f.name, comp, prof.optString("versionName", f.nameWithoutExtension), prof.optString("description"), f.length())
}
/** The stored packages, newest version first. */
fun packages(context: Context): List<Package> = packagesDir(context).listFiles()
?.filter { it.isFile && it.name.endsWith(".wcp") }?.sortedBy { it.name }
?.filter { it.isFile && it.name.endsWith(".wcp") }
?.mapNotNull { f -> runCatching { packageInfo(context, f) }.onFailure { Log.w(TAG, "skipping ${f.name}", it) }.getOrNull() }
?.sortedWith(newestFirst { it.version.ifEmpty { it.file } })
?: emptyList()
/** Unpacks a package's files/ into a Proton; for DXVK/VKD3D the component's own DLLs are replaced as a set. */
@@ -352,7 +469,7 @@ object ComponentsManager {
tar.closeArchiveEntry()
}
}
tmp.renameTo(dest)
check(tmp.renameTo(dest)) { "Could not save original bundle ${dest.name}" }
}
// ------------------------------------------------------------------ originals
@@ -380,8 +497,18 @@ object ComponentsManager {
if (dest.isFile) return
val active = state.sub("active").optJSONObject(p.id)?.optJSONObject(comp)
if (active != null && active.optString("protonVersion") == p.version) return
val published = File(launchDir(context), "desired.tsv")
if (published.isFile) {
val mayHaveOverride = published.useLines { rows ->
rows.any { row ->
val fields = row.split('\t')
fields.size == 7 && fields[0] != "*" && fields[1] == p.dir.name &&
fields[2] == p.version && fields[3] == comp
}
}
check(!mayHaveOverride) { context.getString(R.string.cmgr_original_missing) }
}
val rels = compFiles(p.dir, comp)
if (rels.isEmpty()) return
val current = if (comp == "fex") fexVersion(p.dir) else readVersionFile(File(p.dir, "${COMP_DIR.getValue(comp)}/version"))
val profile = JSONObject()
.put("type", TYPE_OF.getValue(comp))
@@ -400,7 +527,7 @@ object ComponentsManager {
for (p in protons(context)) for (comp in COMPONENTS) {
runCatching { ensureOriginal(context, p, comp, state) }.onFailure { Log.w(TAG, "original $comp of ${p.name}", it) }
}
runCatching { syncLaunchState(context, state) }.onFailure { Log.w(TAG, "launch state", it) }
runCatching { saveState(context, state) }.onFailure { Log.w(TAG, "launch state", it) }
}
// ------------------------------------------------------------------ view
@@ -431,9 +558,14 @@ object ComponentsManager {
else context.getString(R.string.cmgr_changed_outside)
}
}
val selectedLabel = when {
active == null || active.optString("protonVersion") != p.version ->
context.getString(R.string.comp_tag_original)
else -> originalLabel(context, active) ?: active.optString("label", active.optString("file"))
}
val q = state.sub("queued").optJSONObject(p.id)?.optJSONObject(comp)
Component(
detected.ifEmpty { context.getString(R.string.cmgr_not_present) }, detail, inUseLabel,
detected.ifEmpty { context.getString(R.string.cmgr_not_present) }, detail, inUseLabel, selectedLabel,
active?.optString("file")?.takeIf { active.optString("protonVersion") == p.version },
q?.let { originalLabel(context, it) ?: it.optString("label") },
)
@@ -455,64 +587,111 @@ object ComponentsManager {
// ------------------------------------------------------------------ launch-time enforcement
private data class PreparedLaunchState(val text: String, val keep: Set<String>)
/**
* Writes what each Proton should be using for the launch wrappers: desired.tsv, one line per
* file (tool dir, Proton build, component, file, sha256, source copy), and an unpacked copy of
* every package or older-build original in use to copy from. Rebuilt whole after every change, so
* a restored or deleted choice simply stops being enforced. The Proton's own current-build
* originals are not listed: the wrappers also give Steam's probe runs their own prefix, which is
* what overwrote files in the first place.
* Stages what each Proton should use for the launch wrappers. General rows use scope `*`;
* per-game rows use the game's compatdata id and replace General for that component. Deletion
* rows remove managed DLLs absent from the chosen package, so switching games cannot leave files
* from the previous game's component set behind.
*/
private fun syncLaunchState(context: Context, state: JSONObject) {
private fun prepareLaunchState(context: Context, state: JSONObject): PreparedLaunchState {
val dir = launchDir(context)
val store = File(dir, "store").apply { mkdirs() }
val all = protons(context)
val lines = StringBuilder()
val keep = HashSet<String>()
val active = state.sub("active")
for (pid in active.keys()) {
val p = all.firstOrNull { it.id == pid } ?: continue
val comps = active.optJSONObject(pid) ?: continue
for (comp in comps.keys()) {
val a = comps.optJSONObject(comp) ?: continue
if (a.optString("protonVersion") != p.version) continue
val file = a.optString("file")
val (key, wcp) = if (file.startsWith("original ")) {
val v = file.removePrefix("original ")
"original-$pid-${safeName(v)}-$comp" to File(originalsDir(context), "$pid/${safeName(v)}/$comp.wcp")
} else safeName(file.removeSuffix(".wcp")) to File(packagesDir(context), file)
if (!wcp.isFile) continue
val unpacked = File(store, key)
if (!File(unpacked, ".complete").isFile) {
unpacked.deleteRecursively()
readWcp(wcp) { tar ->
while (true) {
val e = tar.nextTarEntry ?: break
val rel = normalize(e.name)
if (e.isDirectory || !rel.startsWith("files/") || rel.split('/').any { it == ".." }) continue
val out = File(unpacked, rel)
out.parentFile?.mkdirs()
FileOutputStream(out).use { tar.copyTo(it) }
}
fun unpack(key: String, wcp: File, comp: String): File {
check(wcp.isFile) { context.getString(R.string.cmgr_not_stored) }
val unpacked = File(store, key)
if (!File(unpacked, ".complete").isFile) {
check(packageInfo(context, wcp).comp == comp) { "Wrong component in ${wcp.name}" }
unpacked.deleteRecursively()
check(unpacked.mkdirs()) { "Could not stage component bundle ${wcp.name}" }
readWcp(wcp) { tar ->
while (true) {
val e = tar.nextTarEntry ?: break
val rel = normalize(e.name)
if (e.isDirectory || !rel.startsWith("files/") || rel.split('/').any { it == ".." }) continue
val out = File(unpacked, rel)
out.parentFile?.mkdirs()
FileOutputStream(out).use { tar.copyTo(it) }
}
File(unpacked, ".complete").writeText("1\n")
}
keep += key
val files = a.optJSONObject("files") ?: continue
for (rel in files.keys()) {
val src = File(unpacked, rel)
if (!src.isFile) continue
val guestSrc = "/" + src.absolutePath.removePrefix(root(context).absolutePath.trimEnd('/')).trimStart('/')
lines.append(p.dir.name).append('\t').append(p.version).append('\t').append(comp).append('\t')
.append(rel).append('\t').append(files.optString(rel)).append('\t').append(guestSrc).append('\n')
}
File(unpacked, ".complete").writeText("1\n")
}
keep += key
return unpacked
}
fun managed(comp: String): Set<String> = if (comp == "fex") FEX_FILES.toSet() else buildSet {
for (arch in PE_ARCHES) for (dll in COMP_DLLS.getValue(comp)) add("${COMP_DIR.getValue(comp)}/$arch/$dll")
}
fun append(scope: String, p: Proton, comp: String, key: String, wcp: File) {
val unpacked = unpack(key, wcp, comp)
val files = unpacked.let { base ->
File(base, "files").walkTopDown().filter { it.isFile }.map { it.relativeTo(base).invariantSeparatorsPath }
.filter { owned(comp, it) }.associateWith { File(base, it) }
}
for (rel in managed(comp) - files.keys) {
lines.append(scope).append('\t').append(p.dir.name).append('\t').append(p.version).append('\t').append(comp)
.append('\t').append(rel).append("\t-\t-\n")
}
for ((rel, src) in files) {
val guestSrc = "/" + src.relativeTo(root(context)).invariantSeparatorsPath
lines.append(scope).append('\t').append(p.dir.name).append('\t').append(p.version).append('\t').append(comp)
.append('\t').append(rel).append('\t').append(Hashes.sha256(src)).append('\t').append(guestSrc).append('\n')
}
}
store.listFiles()?.filter { it.isDirectory && it.name !in keep }?.forEach { it.deleteRecursively() }
val list = File(dir, "desired.tsv")
val tmp = File(dir, "desired.tsv.tmp")
tmp.writeText(lines.toString())
tmp.renameTo(list)
fun choice(p: Proton, comp: String, entry: JSONObject): Pair<String, File>? {
val file = entry.optString("file")
return if (file.startsWith("original ")) {
val version = safeName(file.removePrefix("original "))
"original-${p.id}-$version-$comp" to File(originalsDir(context), "${p.id}/$version/$comp.wcp")
} else {
safeName(file.removeSuffix(".wcp")) to File(packagesDir(context), file)
}.takeIf { it.second.isFile }
}
val profiles = state.sub("profiles")
val profiledComponents = buildSet {
state.optJSONArray("profiledComponents")?.let { stored ->
for (index in 0 until stored.length()) {
stored.optString(index).takeIf(COMPONENTS::contains)?.let(::add)
}
}
for (scope in profiles.keys()) {
val profile = profiles.optJSONObject(scope) ?: continue
COMPONENTS.filterTo(this) { profile.optString(it).isNotEmpty() }
}
}
val active = state.sub("active")
for (p in all) for (comp in COMPONENTS) {
val entry = active.optJSONObject(p.id)?.optJSONObject(comp)
val selected = entry?.takeIf { it.optString("protonVersion") == p.version }?.let { choice(p, comp, it) }
if (selected != null) append("*", p, comp, selected.first, selected.second)
else if (comp in profiledComponents) {
ensureOriginal(context, p, comp, state)
val original = originalFile(context, p, comp)
check(original.isFile) { context.getString(R.string.cmgr_original_missing) }
append("*", p, comp, "original-${p.id}-${safeName(p.version)}-$comp", original)
}
}
for (scope in profiles.keys()) {
val profile = profiles.optJSONObject(scope) ?: continue
for (comp in COMPONENTS) {
val file = profile.optString(comp)
if (file.isEmpty()) continue
val wcp = File(packagesDir(context), file)
if (!wcp.isFile) continue
val key = safeName(file.removeSuffix(".wcp"))
for (p in all) append(scope, p, comp, key, wcp)
}
}
return PreparedLaunchState(lines.toString(), keep)
}
// ------------------------------------------------------------------ actions
@@ -615,6 +794,11 @@ object ComponentsManager {
val comps = active.optJSONObject(pid) ?: continue
for (comp in comps.keys()) check(comps.optJSONObject(comp)?.optString("file") != name) { context.getString(R.string.cmgr_package_in_use, name, pid) }
}
val profiles = state.sub("profiles")
for (scope in profiles.keys()) {
val profile = profiles.optJSONObject(scope) ?: continue
check(COMPONENTS.none { profile.optString(it) == name }) { context.getString(R.string.cmgr_package_in_use, name, scope) }
}
val f = File(packagesDir(context), name)
check(f.isFile) { context.getString(R.string.cmgr_package_not_stored, name) }
f.delete()
@@ -670,10 +854,14 @@ object ComponentsManager {
val arr = o.optJSONArray("items") ?: JSONArray()
val items = (0 until arr.length()).map { arr.getJSONObject(it) }.map {
CatalogItem(it.optString("file"), it.optString("comp"), it.optString("release"), it.optString("url"), it.optLong("size"), it.optString("digest"))
}.sortedWith(compareBy({ it.comp }, { it.release }, { it.file }))
}.sortedWith(compareBy<CatalogItem> { it.comp }.then(newestFirst { it.file }))
return Catalog(items, o.optLong("fetchedAt"))
}
/** By the version in [of], highest first: the newest builds lead a list, as they are what is picked. */
private fun <T> newestFirst(of: (T) -> String): Comparator<T> =
Comparator { a, b -> ProtonExtras.compareVersions(of(b), of(a)) }
/**
* A package Proton does not ship, required on the GPUs listed here. An empty [models] set
* means every model in [families]. A row the user already chose in Components is left alone;
@@ -24,6 +24,12 @@ object CrashHandler {
Thread.setDefaultUncaughtExceptionHandler { thread, error ->
try {
Log.e(TAG, "uncaught on ${thread.name}", error)
if (QuestExperimental.isSupported()) runCatching {
File(context.filesDir, "quest-last-crash.txt").writeText(
"${java.time.Instant.now()} thread=${thread.name}\n" +
StringWriter().also { error.printStackTrace(PrintWriter(it)) }.toString()
)
}
val dir = SessionPaths.current()
if (dir != null && dir.isDirectory) {
val trace = StringWriter().also { error.printStackTrace(PrintWriter(it)) }.toString()
@@ -13,6 +13,7 @@ object GameEnvironmentStore {
private const val GUEST_FILE = "root/.config/droiddeck/game-environment.json"
/** The published file's extra DXVK options (droiddeck-game-env reads it; the app's own file has none). */
const val DXVK_CONFIG = "dxvkConfig"
const val DXVK_CONFIG_GAMES = "dxvkConfigGames"
@Synchronized
fun read(context: Context): GameEnvironment.Config {
@@ -57,6 +58,20 @@ object GameEnvironmentStore {
publish(context, config)
}
@Synchronized
fun setGameFexPreset(context: Context, scope: String, preset: String?): GameEnvironment.Config {
val config = GameEnvironment.withGameFexPreset(read(context), scope, preset)
save(context, config)
return config
}
@Synchronized
fun setGameNoSteam(context: Context, scope: String, on: Boolean): GameEnvironment.Config {
val config = GameEnvironment.withGameNoSteam(read(context), scope, on)
save(context, config)
return config
}
@Synchronized
fun publish(context: Context, config: GameEnvironment.Config = read(context)) {
val resolved = config.copy(shared = GameEnvironment.defaults(SessionPrefs.fexPreset(context), SessionPrefs.forceSsbs(context)) + config.shared)
@@ -68,6 +83,21 @@ object GameEnvironmentStore {
TextureFiltering.autoLodBias(SessionState.upscaleRatio),
)
if (dxvk.isNotEmpty()) json.put(DXVK_CONFIG, dxvk)
val autoBias = TextureFiltering.autoLodBias(SessionState.upscaleRatio)
val generalAnisotropy = SessionPrefs.textureAnisotropy(context)
val generalLodBias = SessionPrefs.textureLodBias(context)
val gameDxvk = JSONObject()
SessionPrefs.gameTextureFiltering(context).forEach { (scope, choice) ->
gameDxvk.put(
scope,
TextureFiltering.dxvkOptions(
choice.anisotropy ?: generalAnisotropy,
choice.lodBias ?: generalLodBias,
autoBias,
),
)
}
if (gameDxvk.length() > 0) json.put(DXVK_CONFIG_GAMES, gameDxvk)
write(File(LinuxRuntime.rootDir(context), GUEST_FILE), json.toString())
}
@@ -0,0 +1,14 @@
package com.droiddeck.launcher.session
internal class GameProfileFollowState {
var followsSteam = true
private set
fun selectManually() {
followsSteam = false
}
fun resumeFollowingSteam() {
followsSteam = true
}
}
@@ -0,0 +1,16 @@
package com.droiddeck.launcher.session
internal class GameProfileInventoryCache<T : Any> {
private val lock = Any()
private var revision: Long? = null
private var value: List<T>? = null
// Called only by the refresh worker; the UI invalidates through request revisions.
fun get(requestedRevision: Long, load: () -> List<T>): List<T> = synchronized(lock) {
value?.takeIf { revision == requestedRevision } ?: load().toList().also {
value = it
revision = requestedRevision
}
}
}
@@ -0,0 +1,31 @@
package com.droiddeck.launcher.session
import android.content.Context
import com.droiddeck.launcher.core.GameEnvironment
object GameProfileManager {
@Synchronized
fun reset(context: Context, scope: String) {
require(scope.isNotEmpty() && GameEnvironment.validScope(scope))
val failures = mutableListOf<Pair<String, Throwable>>()
fun clear(name: String, action: () -> Unit) {
runCatching(action).onFailure { failures += name to it }
}
clear("environment") {
GameEnvironmentStore.save(
context,
GameEnvironmentStore.read(context).withEntries(scope, emptyMap()),
)
}
clear("Proton") { ProtonDefault.requestGame(context, scope, null) }
clear("component packages") { ComponentsManager.clearGameComponents(context, scope) }
clear("texture filtering") { SessionPrefs.clearGameTextureFiltering(context, scope) }
clear("Windows components") { WinComponents.setPicks(context, scope, emptyList()) }
if (failures.isNotEmpty()) {
val names = failures.joinToString { it.first }
throw IllegalStateException("Could not clear: $names", failures.first().second)
}
}
}
@@ -0,0 +1,13 @@
package com.droiddeck.launcher.session
internal data class GameProfileRefreshState<T>(
val data: T? = null,
val loaded: Boolean = false,
val failed: Boolean = false,
) {
fun succeeded(data: T) = GameProfileRefreshState(data, loaded = true)
fun failed() = copy(failed = true)
fun retrying() = copy(failed = false)
}
@@ -0,0 +1,33 @@
package com.droiddeck.launcher.session
internal class LatestRequestCoordinator<T> {
data class Request<T>(val generation: Long, val value: T)
private val lock = Any()
private var generation = 0L
private var running = false
private var latest: Request<T>? = null
private var pending: Request<T>? = null
fun submit(value: T): Boolean = synchronized(lock) {
if (latest?.value != value) generation++
pending = Request(generation, value).also { latest = it }
if (running) {
false
} else {
running = true
true
}
}
fun takeLatest(): Request<T>? = synchronized(lock) {
pending.also {
pending = null
if (it == null) running = false
}
}
fun isLatest(request: Request<T>): Boolean = synchronized(lock) {
request.generation == latest?.generation
}
}
@@ -3,7 +3,9 @@ package com.droiddeck.launcher.session
import android.app.Activity
import android.app.GameManager
import android.app.GameState
import android.content.Context
import android.os.Build
import android.provider.Settings
import android.util.Log
/**
@@ -16,8 +18,9 @@ import android.util.Log
* a level the device can hold indefinitely rather than holding a floor, which cost games and
* Big Picture their peak clocks. WinNative never asks for it; the thermal budget is left to
* the game;
* - the panel's fastest mode at its current size (Android 6+): a 120 Hz phone is switched to
* 120 Hz for the menu, not only when a game votes for it. With a frame cap, the fastest mode
* - the panel's fastest mode at its current size (Android 6+), up to the user's peak refresh rate
* ([peakRefreshCap]): a 120 Hz phone is switched to 120 Hz for the menu, not only when a game
* votes for it. With a frame cap, the fastest mode
* the cap divides evenly, so every capped frame is shown for the same number of refreshes;
* - GameManager's game state (Android 13+): "in gameplay", so an OEM framework that releases
* its boost on loading screens or menus keeps it up.
@@ -36,8 +39,11 @@ object PerfMode {
val display = if (Build.VERSION.SDK_INT >= 30) a.display else @Suppress("DEPRECATION") a.windowManager.defaultDisplay
if (display == null || Build.VERSION.SDK_INT < 23) "display mode unchanged" else {
val cur = display.mode
val cap = peakRefreshCap(a)
val sameSize = display.supportedModes
.filter { it.physicalWidth == cur.physicalWidth && it.physicalHeight == cur.physicalHeight }
.filter { it.refreshRate <= cap + 0.5f }
.ifEmpty { display.supportedModes.filter { it.modeId == cur.modeId } }
val best = sameSize.filter { cadenceFits(it.refreshRate, fpsLimit) }.maxByOrNull { it.refreshRate }
?: sameSize.maxByOrNull { it.refreshRate }
if (best != null && best.modeId != cur.modeId &&
@@ -68,6 +74,20 @@ object PerfMode {
return parts.joinToString(" · ")
}
/**
* The highest rate Android will run the panel at: the user's "peak refresh rate" (Smooth display,
* or the AYN/Odin quick-settings tile). Above it a mode can be requested and is even reported back
* as active, while SurfaceFlinger keeps composing at the cap. The Thor set to 60 Hz ran 120 Hz
* sessions at 60: gamescope and Steam were told 120, every game topped out at 60, and the QAM's
* Frame Limit read "60 FPS (120 Hz)" and changed nothing. Unset (most phones) is no cap.
*/
fun peakRefreshCap(context: Context): Float {
val peak = try {
Settings.System.getFloat(context.contentResolver, "peak_refresh_rate")
} catch (_: Throwable) { 0f }
return if (peak >= 1f) peak else Float.POSITIVE_INFINITY
}
/** A panel rate that shows every capped frame for a whole number of refreshes (no cap: any). */
fun cadenceFits(hz: Float, fpsLimit: Int): Boolean {
if (fpsLimit <= 0) return true
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.session
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.GameEnvironment
import com.droiddeck.launcher.runtime.LinuxRuntime
import org.json.JSONObject
import java.io.File
@@ -9,10 +10,12 @@ import java.io.File
object ProtonDefault {
private const val TAG = "ProtonDefault"
private const val DIR = "root/.local/share/droiddeck-compat"
private const val GAMES = "games.json"
data class Tool(val name: String, val display: String, val dir: String, val valve: Boolean)
data class Request(val seq: Long, val valve: Boolean, val dir: String)
data class State(val default: String?, val applied: Long, val live: Boolean, val pending: String?, val tools: List<Tool>)
data class GameChoice(val valve: Boolean, val dir: String)
enum class Outcome { LIVE, NEXT_START, NOT_RUNNABLE }
@@ -60,6 +63,33 @@ object ProtonDefault {
return protons.firstOrNull { it.valve == pick.first && it.dir.name == pick.second }?.id
}
internal fun parseGames(text: String?): Map<String, GameChoice?> {
val root = runCatching { JSONObject(text ?: "") }.getOrNull() ?: return emptyMap()
if (root.optInt("version", 0) != 1) return emptyMap()
val games = root.optJSONObject("games") ?: return emptyMap()
return buildMap {
games.keys().forEach { id ->
if (!GameEnvironment.validScope(id)) return@forEach
if (games.isNull(id)) {
put(id, null)
} else {
val value = games.optJSONObject(id) ?: return@forEach
val gameDir = value.optString("dir")
val valve = value.opt("valve")
if (gameDir.isNotEmpty() && valve is Boolean) put(id, GameChoice(valve, gameDir))
}
}
}
}
fun gameChoice(context: Context, scope: String): GameChoice? =
parseGames(read(File(dir(context), GAMES)))[scope]
fun gameSelectedId(context: Context, scope: String, protons: List<ComponentsManager.Proton>): String? {
val pick = gameChoice(context, scope) ?: return null
return protons.firstOrNull { it.valve == pick.valve && it.dir.name == pick.dir }?.id
}
internal fun nextSeq(now: Long, state: State, previous: Request?): Long =
maxOf(now, state.applied + 1, (previous?.seq ?: 0) + 1)
@@ -83,4 +113,33 @@ object ProtonDefault {
val tool = state.tools.firstOrNull { it.valve == proton.valve && it.dir == proton.dir.name }
return if (SessionState.running && state.live && (tool == null || state.pending != tool.name)) Outcome.LIVE else Outcome.NEXT_START
}
@Synchronized
fun requestGame(context: Context, scope: String, proton: ComponentsManager.Proton?): Outcome {
require(GameEnvironment.validScope(scope))
val d = dir(context)
val choices = parseGames(read(File(d, GAMES))).toMutableMap()
choices[scope] = proton?.let { GameChoice(it.valve, it.dir.name) }
val games = JSONObject()
choices.forEach { (id, choice) ->
games.put(id, choice?.let { JSONObject().put("valve", it.valve).put("dir", it.dir) } ?: JSONObject.NULL)
}
val staged = File(d, "$GAMES.staged")
try {
d.mkdirs()
staged.writeText(JSONObject().put("version", 1).put("games", games).toString(1) + "\n")
if (!staged.renameTo(File(d, GAMES))) throw java.io.IOException("rename failed")
} catch (e: Exception) {
staged.delete()
Log.w(TAG, "could not write the per-game Proton request", e)
throw e
}
val state = state(context)
val tool = proton?.let { selected -> state.tools.firstOrNull { it.valve == selected.valve && it.dir == selected.dir.name } }
return if (SessionState.running && state.live && (proton == null || tool != null && state.pending != tool.name)) {
Outcome.LIVE
} else {
Outcome.NEXT_START
}
}
}
@@ -10,12 +10,15 @@ import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
import org.json.JSONArray
import org.json.JSONObject
/** Immediate download and installation of the optional ARM64 Proton builds. */
object ProtonExtras {
private const val TAG = "ProtonExtras"
private const val NEED_BYTES = 4L * 1024 * 1024 * 1024
private const val RELEASES = "https://api.github.com/repos/%s/releases?per_page=15"
/** How long a release check stands before the next one goes to GitHub. */
const val CHECK_EVERY_MS = 6L * 60 * 60 * 1000
@Volatile
var installInProgress = false
private set
@@ -23,23 +26,122 @@ object ProtonExtras {
class Tool(val id: String, val name: String, val prefix: String, val repo: String, val assetPattern: Regex)
/** [sha256] from GitHub's asset digest; [sha512] the URL of a checksum file published beside it. */
private data class Asset(val tag: String, val name: String, val url: String, val sha512: String?, val size: Long,
internal data class Asset(val tag: String, val name: String, val url: String, val sha512: String?, val size: Long,
val sha256: String? = null)
val tools = listOf(
Tool("ge", "GE-Proton", "GE-Proton", "GloriousEggroll/proton-ge-custom", Regex("aarch64\\.tar\\.(gz|xz)$")),
Tool("cachyos", "proton-cachyos", "proton-cachyos", "CachyOS/proton-cachyos", Regex("arm64\\.tar\\.(gz|xz)$")),
Tool("droiddeck", "DroidDeck-Proton", "DroidDeck-Proton", "The412Banner/DroidDeck-Proton", Regex("aarch64\\.tar\\.(gz|xz)$")),
)
private fun home(context: Context) = File(LinuxRuntime.rootDir(context), "root")
private fun requests(context: Context) = File(home(context), ".bl-proton-extra")
private fun toolsDir(context: Context) = File(home(context), ".local/share/Steam/compatibilitytools.d")
/** The release tag each build was installed from, by directory name. */
private fun releasesFile(context: Context) = File(context.filesDir, "proton-extras.json")
/** The newest release seen per source, and when it was looked up. */
private fun latestFile(context: Context) = File(context.filesDir, "proton-latest.json")
/** The installed build's directory name (e.g. GE-Proton11-7), or null. */
fun installed(context: Context, tool: Tool): String? =
toolsDir(context).listFiles()
/** The installed build's directory name (e.g. GE-Proton11-7), the newest when there are several, or null. */
fun installed(context: Context, tool: Tool): String? = installedIn(toolsDir(context), tool).lastOrNull()
/** Every installed build's directory name, oldest first. */
fun installedAll(context: Context, tool: Tool): List<String> = installedIn(toolsDir(context), tool)
internal fun installedIn(dir: File, tool: Tool): List<String> =
dir.listFiles()
?.filter { it.isDirectory && it.name.startsWith(tool.prefix) && File(it, "toolmanifest.vdf").isFile }
?.maxByOrNull { it.name }?.name
?.map { it.name }
?.sortedWith { a, b -> compareVersions(a, b) }
?: emptyList()
// Architecture names carry digits (aarch64, x86_64) that are not part of a version.
private val ARCH = Regex("(?i)aarch64|arm64|x86_64|x86-64|amd64")
private val NUMBER = Regex("\\d+")
/** The numbers in a release tag or build folder, in order: GE-Proton11-10 is 11, 10. */
internal fun versionKey(name: String): List<Long> =
NUMBER.findAll(ARCH.replace(name, " ")).mapNotNull { it.value.toLongOrNull() }.toList()
/** Orders by those numbers, so 11-10 is after 11-9; the name itself only breaks a tie. */
internal fun compareVersions(a: String, b: String): Int {
val x = versionKey(a)
val y = versionKey(b)
for (i in 0 until minOf(x.size, y.size)) if (x[i] != y[i]) return x[i].compareTo(y[i])
return if (x.size != y.size) x.size.compareTo(y.size) else a.compareTo(b)
}
/** [latest] when it is newer than every installed release, else null. Nothing installed is not an update. */
internal fun updateFor(installed: List<String>, latest: String?): String? {
if (latest == null || installed.isEmpty()) return null
val newest = installed.maxWithOrNull { a, b -> compareVersions(a, b) } ?: return null
val x = versionKey(latest)
val y = versionKey(newest)
for (i in 0 until minOf(x.size, y.size)) if (x[i] != y[i]) return if (x[i] > y[i]) latest else null
return if (x.size > y.size) latest else null
}
/** The release each installed build came from: the tag recorded at install, else its folder name. */
fun installedTags(context: Context, tool: Tool): List<String> {
val recorded = readJson(releasesFile(context))
return installedAll(context, tool).map { recorded.optString(it).ifEmpty { it } }
}
/** A newer release than the installed one, from the last check (no network), or null. */
fun update(context: Context, tool: Tool): String? = updateFor(installedTags(context, tool), cachedLatest(context, tool))
fun cachedLatest(context: Context, tool: Tool): String? =
readJson(latestFile(context)).optJSONObject(tool.id)?.optString("tag")?.ifEmpty { null }
/** Its own lock, not the install's: a check must not wait behind a download. */
private val checkLock = Any()
/** A failed look-up (offline) is not repeated on every resume, only after this long. */
private const val RETRY_MS = 15L * 60 * 1000
private var lastAttempt = 0L
/**
* Looks up each source's newest release unless it was looked up within [CHECK_EVERY_MS] (or
* [force]). Offline or rate limited, the last answer stands. True when anything was asked.
*/
fun checkLatest(context: Context, force: Boolean = false, now: Long = System.currentTimeMillis()): Boolean {
synchronized(checkLock) {
val file = latestFile(context)
val cache = readJson(file)
val due = tools.filter { tool ->
val at = cache.optJSONObject(tool.id)?.optLong("at", 0L) ?: 0L
force || (now - at) !in 0 until CHECK_EVERY_MS
}
if (due.isEmpty() || !force && (now - lastAttempt) in 0 until RETRY_MS) return false
lastAttempt = now
for (tool in due) {
val tag = findLatestAsset(tool)?.tag ?: continue
cache.put(tool.id, JSONObject().put("tag", tag).put("at", now))
}
runCatching { file.writeText(cache.toString()) }
return true
}
}
private fun record(context: Context, tool: Tool, dir: String?, tag: String) {
runCatching {
val latest = readJson(latestFile(context))
val known = latest.optJSONObject(tool.id)?.optString("tag")
if (known.isNullOrEmpty() || compareVersions(tag, known) >= 0) {
latest.put(tool.id, JSONObject().put("tag", tag).put("at", System.currentTimeMillis()))
latestFile(context).writeText(latest.toString())
}
if (dir != null) {
val releases = readJson(releasesFile(context))
val present = toolsDir(context).list()?.toSet() ?: emptySet()
releases.keys().asSequence().toList().filter { it !in present }.forEach { releases.remove(it) }
releasesFile(context).writeText(releases.put(dir, tag).toString())
}
}.onFailure { Log.w(TAG, "record ${tool.id} $tag", it) }
}
private fun readJson(file: File): JSONObject =
runCatching { JSONObject(FileUtils.readString(file) ?: "{}") }.getOrDefault(JSONObject())
/** A request left by an earlier app version, still consumed by the session shim. */
fun queued(context: Context, tool: Tool): Boolean =
@@ -52,9 +154,15 @@ object ProtonExtras {
else file.writeText(lines.joinToString("\n") + "\n")
}
/** Deletes the installed build; Steam discovers its absence on its next start. */
fun remove(context: Context, tool: Tool) {
installed(context, tool)?.let { File(toolsDir(context), it).deleteRecursively() }
/** Deletes one installed build ([dir], else the newest); Steam discovers its absence on its next start. */
fun remove(context: Context, tool: Tool, dir: String? = null) {
val name = if (dir != null) dir.takeIf { it in installedAll(context, tool) } ?: return else installed(context, tool) ?: return
File(toolsDir(context), name).deleteRecursively()
}
/** Deletes every build but the newest. */
fun removeOlder(context: Context, tool: Tool) {
installedAll(context, tool).dropLast(1).forEach { File(toolsDir(context), it).deleteRecursively() }
}
/** Downloads, verifies and registers the latest build without waiting for another session. */
@@ -145,9 +253,12 @@ object ProtonExtras {
hostEnv["PROOT_TMP_DIR"] = context.cacheDir.path
LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { hostEnv["LD_LIBRARY_PATH"] = it }
val child = process.start()
var placed: String? = null
child.inputStream.bufferedReader().useLines { lines ->
lines.forEach { line ->
Log.i(TAG, line)
// "droiddeck-proton-extra: installed <folder>", the folder as the tarball names it.
line.substringAfter("droiddeck-proton-extra: installed ", "").takeIf { it.isNotEmpty() }?.let { placed = it }
if (line.contains("unpacking", ignoreCase = true) || line.contains("adopted", ignoreCase = true)) {
onProgress(installing, -1)
}
@@ -158,6 +269,7 @@ object ProtonExtras {
else {
archive.delete()
unqueue(context, tool)
record(context, tool, placed, asset.tag)
if (EsyncPacks.enabled(context)) {
onProgress(context.getString(R.string.esync_fetching), -1)
try {
@@ -177,32 +289,36 @@ object ProtonExtras {
private fun findLatestAsset(tool: Tool): Asset? {
val releases = Downloader.downloadString(RELEASES.format(tool.repo)) ?: return null
return try {
val array = JSONArray(releases)
for (i in 0 until array.length()) {
val release = array.getJSONObject(i)
if (release.optBoolean("draft", false)) continue
val assets = release.optJSONArray("assets") ?: continue
val entries = (0 until assets.length()).map { assets.getJSONObject(it) }
val archive = entries.sortedBy { it.optString("name") }.firstOrNull { tool.assetPattern.containsMatchIn(it.optString("name")) } ?: continue
val name = archive.optString("name")
val stem = name.substringBefore(".tar")
val checksum = entries.firstOrNull {
it.optString("name").startsWith(stem) && it.optString("name").contains("sha512", ignoreCase = true)
}?.optString("browser_download_url")?.takeIf { it.startsWith("http") }
return Asset(
release.optString("tag_name"), name,
archive.optString("browser_download_url").takeIf { it.startsWith("http") } ?: continue, checksum,
archive.optLong("size", 0L),
Hashes.githubSha256(archive.optString("digest")),
)
}
null
pickAsset(tool, JSONArray(releases))
} catch (e: Exception) {
Log.w(TAG, "release metadata for ${tool.id}", e)
null
}
}
/** The newest full release in GitHub's list (newest first) that carries [tool]'s ARM64 archive. */
internal fun pickAsset(tool: Tool, array: JSONArray): Asset? {
for (i in 0 until array.length()) {
val release = array.getJSONObject(i)
if (release.optBoolean("draft", false) || release.optBoolean("prerelease", false)) continue
val assets = release.optJSONArray("assets") ?: continue
val entries = (0 until assets.length()).map { assets.getJSONObject(it) }
val archive = entries.sortedBy { it.optString("name") }.firstOrNull { tool.assetPattern.containsMatchIn(it.optString("name")) } ?: continue
val name = archive.optString("name")
val stem = name.substringBefore(".tar")
val checksum = entries.firstOrNull {
it.optString("name").startsWith(stem) && it.optString("name").contains("sha512", ignoreCase = true)
}?.optString("browser_download_url")?.takeIf { it.startsWith("http") }
return Asset(
release.optString("tag_name"), name,
archive.optString("browser_download_url").takeIf { it.startsWith("http") } ?: continue, checksum,
archive.optLong("size", 0L),
Hashes.githubSha256(archive.optString("digest")),
)
}
return null
}
private fun requestLines(context: Context): List<String> =
FileUtils.readString(requests(context))?.lines()?.filter { it.isNotBlank() } ?: emptyList()
}
@@ -0,0 +1,60 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.os.Build
import android.util.Log
import com.droiddeck.launcher.BuildConfig
import java.io.File
/** Device-detected defaults and launch evidence for experimental Quest compatibility. */
object QuestExperimental {
private const val TAG = "QuestExperimental"
const val DRIVER = "turnip25.1.0-quest1"
// Quest 2 has an A6xx GPU and cannot use this bundled A7xx driver.
internal fun supported(model: String, device: String, manufacturer: String): Boolean =
model.equals("Quest 3", ignoreCase = true) || model.equals("Quest 3S", ignoreCase = true) ||
((manufacturer.equals("Oculus", ignoreCase = true) || manufacturer.equals("Meta", ignoreCase = true)) &&
(device == "eureka" || device == "panther"))
fun isSupported(): Boolean = supported(Build.MODEL, Build.DEVICE, Build.MANUFACTURER)
fun initialize(context: Context) {
if (!isSupported()) return
val prefs = context.getSharedPreferences("session", Context.MODE_PRIVATE)
if (!prefs.getBoolean("questDefaultsV1", false)) {
// Auto would download an unwrapped display driver and lose the compatibility path.
// Apply once so deliberate changes made during headset testing remain possible.
prefs.edit()
.putString("gpuDriverMode", SessionPrefs.GPU_DRIVERS_MANUAL)
.putString("androidDriver", DRIVER)
.putString("linuxDriver", "")
// The existing HDR option uses SurfaceControl presentation. This is a
// corruption workaround from upstream issue #245, not a claim of HDR accuracy.
.putBoolean("hdr.steam", true)
.putBoolean("questDefaultsV1", true)
.apply()
}
val report = buildString {
appendLine("DroidDeck Quest Experimental ${BuildConfig.VERSION_NAME}")
appendLine("package=${context.packageName}")
appendLine("manufacturer=${Build.MANUFACTURER} model=${Build.MODEL}")
appendLine("device=${Build.DEVICE} board=${Build.BOARD} hardware=${Build.HARDWARE}")
appendLine("android=${Build.VERSION.RELEASE} sdk=${Build.VERSION.SDK_INT}")
appendLine("abis=${Build.SUPPORTED_ABIS.joinToString()}")
for (name in listOf("gpu_model", "gpu_chipid")) {
val value = runCatching { File("/sys/class/kgsl/kgsl-3d0/$name").readText().trim() }
.getOrDefault("unavailable")
appendLine("$name=$value")
}
appendLine("driverMode=${SessionPrefs.gpuDriverMode(context)}")
appendLine("androidDriver=${SessionPrefs.androidDriver(context)}")
appendLine("steamSurfaceControlWorkaround=${SessionPrefs.hdr(context, "steam")}")
appendLine("GPUOBJ fallback installed in Android driver and ARM64 libblsession")
appendLine("Experimental Quest 3/3S compatibility; 2D panel application")
}
Log.i(TAG, report)
runCatching { File(context.filesDir, "quest-startup.txt").writeText(report) }
.onFailure { Log.w(TAG, "Could not write startup report", it) }
}
}
@@ -0,0 +1,76 @@
package com.droiddeck.launcher.session
import com.droiddeck.launcher.core.FexPreset
import com.droiddeck.launcher.core.GameEnvironment
import com.droiddeck.launcher.frontend.Library
data class ProfileValue(val value: String, val inherited: Boolean)
data class SelectedGameProfile(
val appId: Long,
val name: String,
val fexPreset: ProfileValue,
val proton: ProfileValue,
val protonId: String?,
val components: Map<String, ProfileValue>,
val componentFiles: Map<String, String>,
val environmentOverrides: Int,
val windowsComponentNames: List<String>,
/** Start without Steam (GameEnvironment.NO_STEAM) is on for this game. */
val startWithoutSteam: Boolean = false,
) {
val hasOverrides: Boolean
get() = !fexPreset.inherited ||
!proton.inherited ||
components.values.any { !it.inherited } ||
environmentOverrides > 0 ||
windowsComponentNames.isNotEmpty() ||
startWithoutSteam
companion object {
fun resolve(
selectedAppId: Long,
games: List<Library.SteamGame>,
generalFexPreset: String,
environment: GameEnvironment.Config,
snapshot: ComponentsManager.Snapshot,
generalProtonId: String?,
gameProtonChoice: ProtonDefault.GameChoice?,
gameProtonId: String?,
gameComponents: Map<String, String>,
gameWindowsComponents: List<String>,
): SelectedGameProfile? {
val game = games.firstOrNull { it.profileKey == selectedAppId.toString() } ?: return null
val fexOverride = GameEnvironment.gameFexPreset(environment, game.profileKey)
val selectedProtonId = if (gameProtonChoice == null) generalProtonId else gameProtonId
val selectedProton = snapshot.protons.firstOrNull { it.proton.id == selectedProtonId }
val packages = snapshot.packages.associateBy { it.file }
val components = ComponentsManager.COMPONENTS.associateWith { component ->
val override = gameComponents[component]
ProfileValue(
value = override?.let { packages[it]?.version ?: it }
?: selectedProton?.components?.get(component)?.selected.orEmpty(),
inherited = override == null,
)
}
// Start without Steam is a switch of its own on the Games page, listed by name.
val otherEnvironment = environment.games[game.profileKey].orEmpty().keys - FexPreset.environmentKeys -
GameEnvironment.NO_STEAM
return SelectedGameProfile(
appId = selectedAppId,
name = game.name,
fexPreset = ProfileValue(fexOverride ?: generalFexPreset, fexOverride == null),
proton = ProfileValue(
selectedProton?.proton?.name ?: gameProtonChoice?.dir.orEmpty(),
gameProtonChoice == null,
),
protonId = selectedProtonId,
components = components,
componentFiles = gameComponents,
environmentOverrides = otherEnvironment.size,
windowsComponentNames = gameWindowsComponents.map(WinComponentNames::of),
startWithoutSteam = GameEnvironment.gameNoSteam(environment, game.profileKey),
)
}
}
}
@@ -0,0 +1,11 @@
package com.droiddeck.launcher.session
/** An active game needs a live window; a launch URL or stale Steam base layer is insufficient. */
internal object SessionAchievementGame {
fun activeAppId(focused: Long?, windows: List<Long>, installed: Set<Long>): Long? {
if (focused != null && focused > 0 && focused != 769L) {
return focused.takeIf { it in windows && it in installed }
}
return windows.filter { it != 769L && it in installed }.distinct().singleOrNull()
}
}
@@ -72,6 +72,7 @@ object SessionFiles {
"usr/local/bin/droiddeck-steam-desktop-ui" to "usr/local/bin/droiddeck-steam-desktop-ui",
"usr/local/bin/droiddeck-steam-library" to "usr/local/bin/droiddeck-steam-library",
"usr/local/bin/droiddeck-seed-redists" to "usr/local/bin/droiddeck-seed-redists",
"usr/local/bin/droiddeck-launcher-cores" to "usr/local/bin/droiddeck-launcher-cores",
"usr/local/bin/droiddeck-proton-extra" to "usr/local/bin/droiddeck-proton-extra",
"usr/local/bin/droiddeck-netmanager" to "usr/local/bin/droiddeck-netmanager",
"usr/local/bin/droiddeck-login1" to "usr/local/bin/droiddeck-login1",
@@ -86,6 +87,8 @@ object SessionFiles {
"usr/local/bin/droiddeck-open-url" to "usr/local/bin/droiddeck-open-url",
"usr/local/bin/droiddeck-steam-games" to "usr/local/bin/droiddeck-steam-games",
"usr/local/bin/droiddeck-pad-defaults" to "usr/local/bin/droiddeck-pad-defaults",
// Cardboard mode's SteamVR: install, start and stop (docs/cardboard-vr.md).
"usr/local/bin/droiddeck-steamvr" to "usr/local/bin/droiddeck-steamvr",
// Flatpak: the bwrap stand-in, the store's helper and setup, and the front end's launcher.
"usr/local/bin/droiddeck-bwrap" to "usr/local/bin/droiddeck-bwrap",
"usr/local/bin/droiddeck-flatpak" to "usr/local/bin/droiddeck-flatpak",
@@ -132,6 +135,18 @@ object SessionFiles {
val msitools = listOf("msiinfo", "cabextract", "7z", "7z.so", "libmsi-1.0.so.0", "libgsf-1.so.114", "libgcab-1.0.so.0")
.map { "usr/local/lib/droiddeck-msitools/$it" } +
listOf("NOTICE", "GPL-2", "GPL-3", "LGPL-2.1", "7zip-License", "7zip-unRarLicense").map { "usr/local/share/licenses/droiddeck-msitools/$it" }
// Cardboard mode's SteamVR driver and the Vulkan layer its compositor needs (tools/vr).
val vr = listOf(
"droiddeck/bin/linuxarm64/driver_droiddeck.so",
"droiddeck/driver.vrdrivermanifest",
"droiddeck/resources/settings/default.vrsettings",
"layer/libVkLayer_droiddeck_vr.so",
"layer/VK_LAYER_DROIDDECK_vr.json",
).map { "usr/local/lib/droiddeck-vr/$it" to "usr/local/lib/droiddeck-vr/$it" } +
// The same layer, implicit for the whole session: it passes everything through until SteamVR
// runs, then Steam's own web helper gets the shared-texture support it needs (tools/vr).
listOf("usr/local/lib/droiddeck-vr/layer/VK_LAYER_DROIDDECK_vr_implicit.json" to
"usr/share/vulkan/implicit_layer.d/droiddeck_vr.json")
val fexPreloads = listOf("x86_64", "i386").flatMap { arch ->
listOf("libblsession.so", "libfakeinput.so", "libfaultreport.so").map { "$arch/$it" to "usr/local/lib/droiddeck-fex/$arch/$it" }
} + listOf("libthunkaudit.so", "libvulkan-thunk.so").map { "x86_64/$it" to "usr/local/lib/droiddeck-fex/x86_64/$it" }
@@ -139,7 +154,7 @@ object SessionFiles {
"usr/local/bin/gamescope" to "usr/local/bin/gamescope",
"usr/local/lib/droiddeck/uruntime" to "usr/local/lib/droiddeck/uruntime",
"usr/local/share/licenses/uruntime/LICENSE" to "usr/local/share/licenses/uruntime/LICENSE",
) + mangoapp + msitools.map { it to it } + fexPreloads).filter { (asset, _) ->
) + mangoapp + listOf("comet", "GalaxyCommunication.exe", "update-permissions.exe", "LICENSE", "version").map { "usr/local/lib/droiddeck-comet/$it" to "usr/local/lib/droiddeck-comet/$it" } + msitools.map { it to it } + vr + fexPreloads).filter { (asset, _) ->
val dir = asset.substringBeforeLast('/')
runCatching { context.assets.list("linuxfs/$dir")?.contains(asset.substringAfterLast('/')) == true }.getOrDefault(false)
}
@@ -4,12 +4,17 @@ import android.content.Context
import android.content.pm.ActivityInfo
import androidx.annotation.StringRes
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.CpuAllocationDefaults
import com.droiddeck.launcher.core.GameEnvironment
import com.droiddeck.launcher.core.TextureFiltering
import com.droiddeck.launcher.gpu.Cardboard
import com.droiddeck.launcher.gpu.ScreenEffects
import org.json.JSONObject
/** The in-session switches: the HUD and how the on-screen controls decide to appear. */
object SessionPrefs {
data class GameTextureFiltering(val anisotropy: Int? = null, val lodBias: String? = null)
const val SUSPEND_AUTO = "auto"
const val SUSPEND_NATIVE = "native"
const val SUSPEND_MANUAL = "manual"
@@ -68,15 +73,7 @@ object SessionPrefs {
prefs(context).edit().putBoolean("animations", on).apply()
}
/** The Flathub Store (beta): its rail item. Off by default. */
fun storeEnabled(context: Context): Boolean = prefs(context).getBoolean("storeEnabled", false)
fun setStoreEnabled(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("storeEnabled", on).apply()
}
/** The Stores section (GOG, Epic Games, Amazon Games): its rail item. Off by default. */
fun gameStoresEnabled(context: Context): Boolean = prefs(context).getBoolean("gameStoresEnabled", false)
fun gameStoresEnabled(context: Context): Boolean = prefs(context).getBoolean("gameStoresEnabled", true)
fun setGameStoresEnabled(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("gameStoresEnabled", on).apply()
@@ -276,26 +273,34 @@ object SessionPrefs {
prefs(context).edit().putBoolean("wifiDiscoveryAsked", true).apply()
}
/**
* Whether the client's own core pick is overridden. When on, BL_CLIENT_CPUS is sent even when
* it names every core - unlike a game mask, the point here is to undo a pin Steam applies to
* itself, and the scheduler's default is exactly what Steam's choice takes away.
*/
fun clientCpusOverride(context: Context): Boolean = prefs(context).getBoolean("clientCpusOverride", false)
internal data class CoreAllocation(val clientOverride: Boolean, val clientCpus: String, val gameCpus: String)
/** Defaults are read only for absent keys; even a saved empty/all-core choice wins. */
internal fun coreAllocation(context: Context, defaults: CpuAllocationDefaults.Allocation? = CpuAllocationDefaults.device): CoreAllocation {
val saved = prefs(context)
return CoreAllocation(
saved.getBoolean("clientCpusOverride", defaults != null && !saved.contains("clientCpus")),
saved.getString("clientCpus", defaults?.client.orEmpty()).orEmpty(),
saved.getString("gameCpus", defaults?.game.orEmpty()).orEmpty(),
)
}
/** Use the device default until the user saves an override or a client mask. */
fun clientCpusOverride(context: Context): Boolean = coreAllocation(context).clientOverride
fun setClientCpusOverride(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("clientCpusOverride", on).apply()
}
/** Comma-separated core list for the client (taskset -c syntax); "" = every core. */
fun clientCpus(context: Context): String = prefs(context).getString("clientCpus", "") ?: ""
fun clientCpus(context: Context): String = coreAllocation(context).clientCpus
fun setClientCpus(context: Context, list: String) {
prefs(context).edit().putString("clientCpus", list).apply()
}
/** Comma-separated core list for games (taskset -c syntax); "" = every core = nothing sent. */
fun gameCpus(context: Context): String = prefs(context).getString("gameCpus", "") ?: ""
/** Comma-separated core list for games (taskset -c syntax); "" = every core (explicitly restored when the client is pinned). */
fun gameCpus(context: Context): String = coreAllocation(context).gameCpus
fun setGameCpus(context: Context, list: String) {
prefs(context).edit().putString("gameCpus", list).apply()
@@ -841,6 +846,31 @@ object SessionPrefs {
prefs(context).edit().putString("screenEffects", effects.encode().toString()).apply()
}
/** The key [cardboard] lives under; SessionActivity watches it so a change applies live. */
const val CARDBOARD_KEY = "cardboard"
/** Cardboard mode (gpu/Cardboard) as last set; off by default. */
fun cardboard(context: Context): Cardboard {
val text = prefs(context).getString(CARDBOARD_KEY, null) ?: return Cardboard.OFF
return runCatching { Cardboard.decode(JSONObject(text)) }.getOrDefault(Cardboard.OFF)
}
fun setCardboard(context: Context, cardboard: Cardboard) {
prefs(context).edit().putString(CARDBOARD_KEY, cardboard.encode().toString()).apply()
}
/** The Cardboard picture (Cardboard.SOURCE_*) to go back to once SteamVR stops; -1 = none (it was
* not switched for SteamVR). Kept across activity restarts, so a SteamVR session that outlives the
* activity still hands the picture back. */
fun cardboardSourceBeforeSteamVr(context: Context): Int = prefs(context).getInt("cardboardSourceBeforeSteamVr", -1)
fun setCardboardSourceBeforeSteamVr(context: Context, source: Int) {
prefs(context).edit().putInt("cardboardSourceBeforeSteamVr", source).apply()
}
/** The prefs file [cardboard] lives in, for a change listener. */
fun sessionPrefs(context: Context): android.content.SharedPreferences = prefs(context)
val textureAnisotropyChoices = TextureFiltering.ANISOTROPY.map { it to if (it == 0) "Off" else "${it}x" }
/** [textureAnisotropyChoices] labelled in the app's language. */
@@ -882,6 +912,74 @@ object SessionPrefs {
publishGameEnvironment(context)
}
fun gameTextureFiltering(context: Context, scope: String): GameTextureFiltering {
require(GameEnvironment.validScope(scope))
return gameTextureFiltering(context)[scope] ?: GameTextureFiltering()
}
fun gameTextureFiltering(context: Context): Map<String, GameTextureFiltering> {
val json = runCatching {
JSONObject(prefs(context).getString("gameTextureFiltering", null) ?: "{}")
}.getOrElse { JSONObject() }
return buildMap {
json.keys().forEach { scope ->
if (!GameEnvironment.validScope(scope)) return@forEach
val entry = json.optJSONObject(scope) ?: return@forEach
val anisotropy = entry.optInt("anisotropy").takeIf {
entry.has("anisotropy") && it in TextureFiltering.ANISOTROPY
}
val lodBias = entry.optString("lodBias").takeIf {
entry.has("lodBias") && it in TextureFiltering.LOD_BIAS
}
if (anisotropy != null || lodBias != null) {
put(scope, GameTextureFiltering(anisotropy, lodBias))
}
}
}
}
fun setGameTextureAnisotropy(context: Context, scope: String, value: Int?) {
require(value == null || value in TextureFiltering.ANISOTROPY)
updateGameTextureFiltering(context, scope) { it.copy(anisotropy = value) }
}
fun setGameTextureLodBias(context: Context, scope: String, choice: String?) {
require(choice == null || choice in TextureFiltering.LOD_BIAS)
updateGameTextureFiltering(context, scope) { it.copy(lodBias = choice) }
}
fun clearGameTextureFiltering(context: Context, scope: String) {
require(GameEnvironment.validScope(scope))
val choices = gameTextureFiltering(context).toMutableMap()
if (choices.remove(scope) == null) return
saveGameTextureFiltering(context, choices)
publishGameEnvironment(context)
}
private fun updateGameTextureFiltering(
context: Context,
scope: String,
change: (GameTextureFiltering) -> GameTextureFiltering,
) {
require(GameEnvironment.validScope(scope))
val choices = gameTextureFiltering(context).toMutableMap()
val next = change(choices[scope] ?: GameTextureFiltering())
if (next.anisotropy == null && next.lodBias == null) choices.remove(scope) else choices[scope] = next
saveGameTextureFiltering(context, choices)
publishGameEnvironment(context)
}
private fun saveGameTextureFiltering(context: Context, choices: Map<String, GameTextureFiltering>) {
val json = JSONObject()
choices.forEach { (id, choice) ->
json.put(id, JSONObject().apply {
choice.anisotropy?.let { put("anisotropy", it) }
choice.lodBias?.let { put("lodBias", it) }
})
}
prefs(context).edit().putString("gameTextureFiltering", json.toString()).apply()
}
/** Hands the change to the next game launch (GameEnvironmentStore); the running game keeps its own. */
private fun publishGameEnvironment(context: Context) {
runCatching { GameEnvironmentStore.publish(context) }
@@ -334,6 +334,7 @@ class SessionService : Service() {
SessionFiles.stage(this, root)
com.droiddeck.launcher.agent.AgentGuest.reset(this)
// The compat tool asks for an Epic game's sign-in code at every launch, the client's Play button included.
com.droiddeck.launcher.stores.gog.GogComet.begin()
com.droiddeck.launcher.stores.StoreLaunchRequests.start(this)
val sessionDir = openSessionFolder()
@@ -381,7 +382,12 @@ class SessionService : Service() {
val controllersOn = !File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists()
SessionState.deckPad = false
deckBinds = emptyList()
if (controllersOn) addControllerEnvironment(guest, fakeInputDir, sessionDir)
if (controllersOn) {
addControllerEnvironment(guest, fakeInputDir, sessionDir)
// Steam enumerates /dev/input while it starts. Make every attached controller's
// discovery node exist after the rings and guest environment are ready.
SessionState.padBridge?.syncConnectedControllers()
}
// The user's own games, for the runtime's shortcuts writer to put in the client's library
// before the client starts (see frontend/AddedGames and droiddeck-steam-shortcuts).
if (steamHere) {
@@ -579,6 +585,8 @@ class SessionService : Service() {
private fun addClientEnvironment(guest: MutableList<String>, steamHere: Boolean) {
guest.add("/usr/bin/env")
guest.add("-i")
// PRoot executes guest[0] directly; assignments belong after env -i.
guest.add("DROIDDECK_BWRAP_SOCKET=" + com.droiddeck.launcher.runtime.BwrapSpawner.socket(this))
guest.add("HOME=/root")
guest.add("USER=root")
guest.add("PATH=/usr/local/bin:/usr/bin:/bin")
@@ -661,16 +669,15 @@ class SessionService : Service() {
extraEnv().forEach { guest.add(it) }
// The agent bridge's experiment lines (AgentEnv), after the user's so an agent's win.
com.droiddeck.launcher.agent.AgentEnv.beginSession(this).forEach { guest.add(it) }
// Core masks, Bannerlator's two (cfca3912). The client's is sent whenever the override is
// on, even naming every core: it exists to undo the pin Steam applies to its own interface
// renderer, and the scheduler's default is exactly what that pin takes away. A game's is
// sent only when it is a real restriction - a game has no pin of its own to undo.
if (steamHere && SessionPrefs.clientCpusOverride(this)) {
// A game launched by pinned Steam inherits its mask. Explicitly restore all game cores
// when requested, as well as applying restricted game choices in the Proton wrapper.
val clientPinned = steamHere && SessionPrefs.clientCpusOverride(this)
if (clientPinned) {
guest.add("BL_CLIENT_CPUS=" + CpuCores.listOrAll(SessionPrefs.clientCpus(this)))
}
// The game cores also pin the desktop and a program from the rail; without a choice the
// session script picks every core but the slowest cluster for those (program_cores).
CpuCores.restrictionOrEmpty(SessionPrefs.gameCpus(this))
CpuCores.gameLaunchList(SessionPrefs.gameCpus(this), clientPinned)
.takeIf { it.isNotEmpty() }?.let { guest.add("BL_GAME_CPUS=$it") }
}
@@ -1536,6 +1543,7 @@ class SessionService : Service() {
}
updateDownloadMonitoring()
val stoppedGen = sessionGen
val stoppedSession = SessionState.sessionId
SessionState.stopRequested = false
SessionEvents.record("guest.exited", mapOf("status" to status))
GpuClockPin.stop(this)
@@ -1582,13 +1590,14 @@ class SessionService : Service() {
if (prootPid > 1 || auxiliary.isNotEmpty()) {
Thread({
if (prootPid > 1 && steamClientMayRun) askSteamToExit(prootPid)
com.droiddeck.launcher.stores.gog.GogComet.end(stoppedSession)
auxiliary.forEach { (pid, started) -> teardown(pid, started) }
if (prootPid > 1) teardown(prootPid)
killGuestLeftovers()
finishSessionStop(status, stoppedGen)
}, "session-teardown").start()
} else {
finishSessionStop(status, stoppedGen)
Thread({ com.droiddeck.launcher.stores.gog.GogComet.end(stoppedSession); finishSessionStop(status, stoppedGen) }, "gog-session-stop").start()
}
}
if (prootPid > 1) acquireLocks()
@@ -48,6 +48,9 @@ object StoreState {
private set
var searching by mutableStateOf(false)
private set
/** An app whose Flathub page was asked for from outside the store (its card on Desktop), once. */
var openRequest by mutableStateOf<String?>(null)
private var searchSeq = 0
val SECTIONS = listOf("popular" to R.string.store_section_popular, "trending" to R.string.store_section_trending, "recently-added" to R.string.store_section_new, "recently-updated" to R.string.store_section_updated)
@@ -0,0 +1,50 @@
package com.droiddeck.launcher.stores
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import org.json.JSONObject
/** Session-owned unlocks; history reads and upload acknowledgements never create notifications. */
object AchievementNotifications {
data class Unlock(val client: String, val id: String, val name: String, val description: String, val icon: String?, val game: String)
private var session: String? = null
private val seen = HashSet<String>()
var pending by mutableStateOf<List<Unlock>>(emptyList())
private set
fun begin(owner: String?) = StoresState.post {
session = owner
seen.clear()
pending = emptyList()
}
fun end(owner: String?) = StoresState.post {
if (session == owner) {
session = null
seen.clear()
pending = emptyList()
}
}
fun unlock(owner: String, unlock: Unlock) = StoresState.post {
if (session == owner && seen.add("${unlock.client}:${unlock.id}")) {
pending = (pending + unlock).take(16)
}
}
fun dismiss(unlock: Unlock) {
if (pending.firstOrNull() === unlock) pending = pending.drop(1)
}
/** Only presentation fields from the owned daemon, bounded before entering Compose. */
fun parse(event: JSONObject, game: String): Unlock? {
val client = event.optString("client_id")
val id = event.optString("id")
val name = event.optString("name").trim()
if (!client.matches(Regex("[0-9]{1,32}")) || id.isBlank() || id.length > 256 || name.isEmpty()) return null
val icon = event.optString("icon").takeIf { it.length <= 2048 && it.startsWith("https://") }
return Unlock(client, id, name.take(256), event.optString("description").take(1024), icon, game.take(256))
}
}
@@ -137,7 +137,7 @@ object CloudSaves {
val galaxy = GogAuth.validToken(context)
val clientId = gogClientId(context, id, folder)
if (userId.isEmpty() || galaxy == null || clientId == null) null
else GogCloud(userId, clientId, gogScopedToken(context, id, clientId, account?.optString("refresh_token").orEmpty()) ?: galaxy)
else GogCloud(userId, clientId, gogScopedToken(context, id, clientId) ?: galaxy)
}
Store.EPIC -> {
val token = EpicCredentialStore.getValidAccessToken(context)
@@ -148,17 +148,9 @@ object CloudSaves {
}
/** GOG cloud storage wants a token issued to the game's own client: the Galaxy refresh token, exchanged with the game's id and secret. */
private fun gogScopedToken(context: Context, id: String, clientId: String, refresh: String): String? {
private fun gogScopedToken(context: Context, id: String, clientId: String): String? {
val secret = GogPrefs.get(context).getString("client_secret_$id", null)?.ifEmpty { null } ?: return null
if (refresh.isEmpty()) return null
return try {
val enc = { s: String -> java.net.URLEncoder.encode(s, "UTF-8") }
val c = URL("https://auth.gog.com/token?client_id=${enc(clientId)}&client_secret=${enc(secret)}&grant_type=refresh_token&refresh_token=${enc(refresh)}").openConnection() as HttpURLConnection
c.connectTimeout = 15_000; c.readTimeout = 15_000
c.setRequestProperty("User-Agent", "GOG Galaxy")
if (c.responseCode != 200) { c.disconnect(); return null }
JSONObject(c.inputStream.bufferedReader().readText()).optString("access_token").ifEmpty { null }.also { c.disconnect() }
} catch (e: Exception) { null }
return GogAuth.gameToken(context, clientId, secret)?.accessToken
}
// ---- sync -----------------------------------------------------------------------------------
@@ -0,0 +1,263 @@
package com.droiddeck.launcher.stores
import android.content.Context
import android.util.Log
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.net.URLEncoder
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.concurrent.ConcurrentHashMap
/**
* What other sites know of a store game, looked up by its title: the Metacritic score (as Steam's
* store search carries it) and HowLongToBeat's times. Neither store publishes these, so every
* lookup is a name match against someone else's catalog, kept on disk a fortnight (a miss for two
* days) and in memory for the process. Blocking: call off the main thread.
*/
object GameFacts {
private const val TAG = "GameFacts"
private const val HIT_TTL_MS = 14L * 24 * 60 * 60 * 1000
private const val MISS_TTL_MS = 2L * 24 * 60 * 60 * 1000
private const val TIMEOUT_MS = 10_000
/** Metacritic's critic score (0-100) and the game's page there. */
data class Metacritic(val score: Int, val url: String)
/** HowLongToBeat's community times, in seconds (0 when nobody has logged that kind of run), and the game's page there. */
data class Hltb(val main: Int, val extras: Int, val completionist: Int, val url: String) {
val isEmpty: Boolean get() = main <= 0 && extras <= 0 && completionist <= 0
}
/** A remembered answer: [value] null is a remembered miss. */
private class Entry<T>(val value: T?, val at: Long) {
fun fresh(now: Long) = now - at < if (value != null) HIT_TTL_MS else MISS_TTL_MS
}
private val memory = ConcurrentHashMap<String, Entry<*>>()
/** The score or the times already in memory, without touching disk or network; null when not yet known. */
fun peekMetacritic(title: String): Lookup<Metacritic>? = peek("metacritic", title)
fun peekHltb(title: String): Lookup<Hltb>? = peek("hltb", title)
/** The score, or null when Steam does not list the game or has no score for it. */
fun metacritic(context: Context, title: String): Metacritic? =
remembered(context, "metacritic", title,
read = { o -> o.optInt("score").takeIf { it > 0 }?.let { Metacritic(it, o.optString("url").ifEmpty { metacriticSearch(title) }) } },
write = { o, v -> o.put("score", v.score).put("url", v.url) },
) { searchSteamScore(title) }
fun hltb(context: Context, title: String): Hltb? =
remembered(context, "hltb", title,
read = { o -> Hltb(o.optInt("main"), o.optInt("extras"), o.optInt("completionist"), o.optString("url").ifEmpty { hltbSearch(title) }).takeUnless { it.isEmpty } },
write = { o, v -> o.put("main", v.main).put("extras", v.extras).put("completionist", v.completionist).put("url", v.url) },
) { searchHltb(title) }
// Where a game goes when its own page is not known: the site's search for its name.
private fun metacriticSearch(title: String) = "https://www.metacritic.com/search/${encodePath(TitleMatch.searchTerm(title))}/?category=13"
private fun hltbSearch(title: String) = "$HLTB/?q=${URLEncoder.encode(TitleMatch.searchTerm(title), "UTF-8")}"
private fun encodePath(s: String) = URLEncoder.encode(s, "UTF-8").replace("+", "%20")
@Suppress("UNCHECKED_CAST")
private fun <T> peek(kind: String, title: String): Lookup<T>? {
val e = memory[key(kind, title)] as Entry<T>? ?: return null
return e.value?.let { Lookup.Found(it) } ?: Lookup.None
}
/**
* The memory copy, else the disk copy while fresh, else [fetch]. [fetch] answers a [Fetched]:
* a failed request is not a miss, so it leaves the stale copy (if any) standing.
*/
@Suppress("UNCHECKED_CAST")
private fun <T : Any> remembered(
context: Context, kind: String, title: String,
read: (JSONObject) -> T?, write: (JSONObject, T) -> Unit, fetch: () -> Fetched<T>,
): T? {
val now = System.currentTimeMillis()
val k = key(kind, title)
(memory[k] as Entry<T>?)?.takeIf { it.fresh(now) }?.let { return it.value }
val file = File(context.filesDir, "game-facts/$kind/${fileName(title)}.json")
val disk = runCatching { JSONObject(file.readText()) }.getOrNull()
?.let { o -> Entry(if (o.optBoolean("none")) null else read(o), o.optLong("at")) }
if (disk != null && disk.fresh(now)) { memory[k] = disk; return disk.value }
return when (val got = fetch()) {
is Fetched.Failed -> disk?.value
is Fetched.Answer -> {
val entry = Entry(got.value, now)
memory[k] = entry
runCatching {
file.parentFile?.mkdirs()
val o = JSONObject().put("at", now)
if (got.value != null) write(o, got.value) else o.put("none", true)
file.writeText(o.toString())
}
got.value
}
}
}
private sealed interface Fetched<out T> {
object Failed : Fetched<Nothing>
class Answer<T>(val value: T?) : Fetched<T>
}
private fun key(kind: String, title: String) = "$kind:${TitleMatch.normalize(title)}"
private fun fileName(title: String) = TitleMatch.normalize(title).take(80).ifEmpty { "_" + title.hashCode().toUInt() }
// Steam's store search lists a game's Metacritic score beside its name: one request, no key.
// The game's own Metacritic page is in its app details, asked for only once there is a score.
private fun searchSteamScore(title: String): Fetched<Metacritic> {
val term = URLEncoder.encode(TitleMatch.searchTerm(title), "UTF-8")
val body = StoreNet.get("https://store.steampowered.com/api/storesearch/?l=english&cc=US&term=$term", timeoutMs = TIMEOUT_MS)
?: return Fetched.Failed
return runCatching {
val items = JSONObject(body).optJSONArray("items") ?: JSONArray()
val scored = (0 until items.length()).mapNotNull { items.optJSONObject(it) }
.filter { it.optString("type") == "app" && (it.optString("metascore").toIntOrNull() ?: 0) > 0 }
val best = TitleMatch.pick(title, scored) { listOf(it.optString("name")) }
val score = best?.optString("metascore")?.toIntOrNull() ?: return Fetched.Answer(null)
Fetched.Answer(Metacritic(score, metacriticPage(best.optLong("id")) ?: metacriticSearch(title)))
}.getOrElse { Fetched.Failed }
}
/** Steam's link to the game on Metacritic, without its tracking tag; null when Steam gives none. */
private fun metacriticPage(appId: Long): String? {
if (appId <= 0) return null
val body = StoreNet.get("https://store.steampowered.com/api/appdetails?appids=$appId&filters=metacritic", timeoutMs = TIMEOUT_MS) ?: return null
return runCatching {
JSONObject(body).optJSONObject("$appId")?.optJSONObject("data")?.optJSONObject("metacritic")?.optString("url")
?.substringBefore('?')?.takeIf { it.startsWith("https://") }
}.getOrNull()
}
// HowLongToBeat's own site search: a short-lived token from init, sent back as x-auth-token.
// The token is bound to the User-Agent that asked for it, so both calls send the same one.
private const val HLTB = "https://howlongtobeat.com"
private val hltbHeaders = mapOf("Referer" to "$HLTB/", "Origin" to HLTB)
@Volatile private var hltbToken: Pair<String, Long>? = null
private fun hltbToken(renew: Boolean): String? {
val now = System.currentTimeMillis()
hltbToken?.takeIf { !renew && now - it.second < 10 * 60 * 1000 }?.let { return it.first }
val body = StoreNet.get("$HLTB/api/search/site/init?t=$now", timeoutMs = TIMEOUT_MS, headers = hltbHeaders) ?: return null
val token = runCatching { JSONObject(body).optString("token") }.getOrNull()?.takeIf { it.isNotBlank() } ?: return null
hltbToken = token to now
return token
}
private fun searchHltb(title: String): Fetched<Hltb> {
val terms = JSONArray().apply { TitleMatch.searchTerm(title).split(' ').filter { it.isNotBlank() }.forEach { put(it) } }
val request = JSONObject()
.put("searchType", "games").put("searchTerms", terms).put("searchPage", 1).put("size", 20)
.put("searchOptions", JSONObject()
.put("games", JSONObject().put("userId", 0).put("platform", "").put("sortCategory", "popular").put("rangeCategory", "main")
.put("rangeTime", JSONObject().put("min", 0).put("max", 0))
.put("gameplay", JSONObject().put("perspective", "").put("flow", "").put("genre", ""))
.put("year", "").put("modifier", ""))
.put("users", JSONObject().put("sortCategory", "postcount"))
.put("lists", JSONObject().put("sortCategory", "follows"))
.put("filter", "").put("sort", 0).put("randomizer", 0))
.put("useCache", true)
.toString()
// A token can lapse between init and search; one fresh one is worth a second try.
var body: String? = null
for (renew in listOf(false, true)) {
val token = hltbToken(renew) ?: return Fetched.Failed
body = StoreNet.postJson("$HLTB/api/search/site", request, timeoutMs = TIMEOUT_MS, headers = hltbHeaders + ("x-auth-token" to token))
if (body != null) break
}
if (body == null) return Fetched.Failed
return runCatching {
val data = JSONObject(body).optJSONArray("data") ?: JSONArray()
val games = (0 until data.length()).mapNotNull { data.optJSONObject(it) }
.sortedByDescending { it.optString("game_type") == "game" }
val best = TitleMatch.pick(title, games) { g -> listOf(g.optString("game_name")) + g.optString("game_alias").split(',') }
Fetched.Answer(best?.let {
val page = it.optInt("game_id").takeIf { id -> id > 0 }?.let { id -> "$HLTB/game/$id" } ?: hltbSearch(title)
Hltb(it.optInt("comp_main"), it.optInt("comp_plus"), it.optInt("comp_100"), page)
}?.takeUnless { it.isEmpty })
}.getOrElse {
Log.w(TAG, "HowLongToBeat answer unreadable: ${it.javaClass.simpleName}")
Fetched.Failed
}
}
/** A store's release date ("2015.05.19", "2015-05-19T00:00:00+02:00", ...) in the reader's own words, or null. */
fun releaseLabel(raw: String): String? {
val m = Regex("""^(\d{4})[.\-/](\d{2})[.\-/](\d{2})""").find(raw.trim()) ?: return null
val (y, mo, d) = m.destructured
// Epic dates a title it has no date for 2099-01-01.
val date = runCatching { LocalDate.of(y.toInt(), mo.toInt(), d.toInt()) }.getOrNull()?.takeIf { it.year in 1970..2098 } ?: return null
return date.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM))
}
/** Whole hours, with a half when the time is nearer one: "51½". Under an hour, null (the caller says minutes). */
fun hours(seconds: Int): String? {
if (seconds < 3600) return null
val halves = Math.round(seconds / 1800.0).toInt()
return "${halves / 2}" + if (halves % 2 == 1) "½" else ""
}
}
/** A fact looked up off the page: still asking, found, or not to be had. */
sealed interface Lookup<out T> {
object Pending : Lookup<Nothing>
object None : Lookup<Nothing>
data class Found<T>(val value: T) : Lookup<T>
}
/**
* Whether two catalogs mean the same game. Store titles carry marks, editions and subtitles the
* other site spells differently ("The Witcher 3: Wild Hunt" is Steam's "...Wild Hunt — Remastered");
* a sequel is never the same game ("Hades" is not "Hades II").
*/
internal object TitleMatch {
private val MARKS = Regex("[™®©]")
private val EDITION = Regex(
"(?i)[\\s:\\-–—]*\\b(?:(?:complete|definitive|legendary|deluxe|ultimate|enhanced|gold|premium|standard|special|anniversary|collector'?s|digital deluxe|game of the year|goty)\\s+edition|goty|director'?s cut)\\b",
)
// Where a subtitle starts: a colon or dash with space around it, or an opening bracket.
private val SUBTITLE = Regex("\\s*[:–—]\\s*|\\s+-\\s+|\\s*\\(")
fun normalize(s: String): String = s.lowercase().replace(MARKS, "").replace("&", "and").replace(Regex("[^\\p{L}\\p{N}]"), "")
fun stripEdition(s: String): String = s.replace(EDITION, " ").trim().ifEmpty { s }
/**
* What to type into another site's search: the title without its marks, edition or subtitle
* punctuation (Steam finds nothing for "Disco Elysium - The Final Cut", all of it for the words).
*/
fun searchTerm(title: String): String =
stripEdition(title.replace(MARKS, "")).replace(Regex("\\s+[-–—]\\s+|[:–—()\\[\\]]"), " ").replace(Regex("\\s+"), " ").trim()
/** 3 the same name, 2 the same but for the edition, 1 one is the other with a subtitle, 0 not the same game. */
fun score(want: String, got: String): Int {
val w = normalize(want)
val g = normalize(got)
if (w.isEmpty() || g.isEmpty()) return 0
if (w == g) return 3
val ws = normalize(stripEdition(want))
val gs = normalize(stripEdition(got))
if (ws == gs || ws == g || w == gs) return 2
if (ws in heads(got) || gs in heads(want)) return 1
return 0
}
/** The title up to each place a subtitle could start, normalized. */
private fun heads(title: String): Set<String> =
SUBTITLE.findAll(title).map { normalize(title.substring(0, it.range.first)) }.filter { it.isNotEmpty() }.toSet()
/** The best-named of [items] in their order (the site's own ranking breaks a tie), or null when none is the game. */
fun <T> pick(want: String, items: List<T>, names: (T) -> List<String>): T? {
var best: T? = null
var bestScore = 0
for (item in items) {
val s = names(item).filter { it.isNotBlank() }.maxOfOrNull { score(want, it.trim()) } ?: 0
if (s > bestScore) { best = item; bestScore = s }
}
return best
}
}
@@ -0,0 +1,108 @@
package com.droiddeck.launcher.stores
import android.content.Context
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.stores.epic.EpicAchievements
import com.droiddeck.launcher.stores.gog.GogAchievements
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.time.Instant
import java.time.OffsetDateTime
import java.util.Locale
/**
* A source's achievements with the account's progress, the last good answer cached on disk. Blocking.
* [source] is the library's id for where a game came from ("gog", "epic"; "steam" once it has one).
*/
abstract class StoreAchievements(val source: String, val label: String) {
/** [extra]: what the source needs to find the game (Epic: "namespace"). */
data class Game(val source: String, val id: String, val extra: Map<String, String> = emptyMap()) {
companion object {
fun of(item: CatalogItem) = Game(item.store.id, item.id, item.extra)
fun of(sidecar: StoreGameSidecar) = Game(sidecar.store.id, sidecar.id, sidecar.extra)
}
}
class Achievement(
val id: String,
val name: String,
val description: String,
val hidden: Boolean,
val unlocked: Boolean,
/** Unix seconds; 0 when locked or unknown. */
val unlockedAt: Long,
val icon: String?,
val iconLocked: String?,
)
sealed class Result {
class Found(val list: List<Achievement>) : Result()
class Failed(val reason: Reason) : Result()
}
enum class Reason { SIGNED_OUT, UNREACHABLE, NO_SERVICE }
protected abstract fun request(context: Context, game: Game): Result
fun fetch(context: Context, game: Game): Result {
val result = runCatching { request(context, game) }.getOrElse { e ->
Log.w(TAG, "$source:${game.id}: ${e.javaClass.simpleName}")
Result.Failed(Reason.UNREACHABLE)
}
if (result is Result.Found) runCatching {
cacheFile(context, game).apply { parentFile?.mkdirs() }.writeText(toJson(result.list).toString())
}
return result
}
fun cached(context: Context, game: Game): List<Achievement>? = runCatching {
val file = cacheFile(context, game)
if (!file.exists()) null else fromJson(JSONArray(file.readText()))
}.getOrNull()
private fun cacheFile(context: Context, game: Game) =
File(context.filesDir, "achievements/$source/${game.id.replace(Regex("[^A-Za-z0-9._-]"), "_")}.json")
protected fun locale(): String = Locale.getDefault().toLanguageTag()
/** ISO-8601 (including GOG's "+0000" offsets) to Unix seconds; 0 if unreadable. */
protected fun epochSeconds(iso: String?): Long {
if (iso.isNullOrBlank()) return 0
val normalized = iso.replace(Regex("([+-]\\d{2})(\\d{2})$"), "$1:$2")
return runCatching { OffsetDateTime.parse(normalized).toEpochSecond() }
.recoverCatching { Instant.parse(normalized).epochSecond }
.getOrDefault(0)
}
private fun toJson(list: List<Achievement>) = JSONArray().apply {
for (a in list) put(
JSONObject().put("id", a.id).put("name", a.name).put("description", a.description).put("hidden", a.hidden)
.put("unlocked", a.unlocked).put("unlockedAt", a.unlockedAt).put("icon", a.icon ?: "").put("iconLocked", a.iconLocked ?: ""),
)
}
private fun fromJson(arr: JSONArray) = List(arr.length()) { i ->
val o = arr.getJSONObject(i)
Achievement(
id = o.getString("id"), name = o.optString("name"), description = o.optString("description"),
hidden = o.optBoolean("hidden"), unlocked = o.optBoolean("unlocked"), unlockedAt = o.optLong("unlockedAt"),
icon = o.optString("icon").ifEmpty { null }, iconLocked = o.optString("iconLocked").ifEmpty { null },
)
}
companion object {
private const val TAG = "StoreAchievements"
var revision by androidx.compose.runtime.mutableIntStateOf(0)
private set
fun invalidate() { revision++ }
private val all: List<StoreAchievements> by lazy { listOf(GogAchievements, EpicAchievements) }
/** Null for a source without achievements. */
fun forSource(source: String?): StoreAchievements? = all.firstOrNull { it.source == source }
}
}
@@ -26,6 +26,7 @@ data class CatalogItem(
/** The web product page: where "Get for free" and "View on <store>" land. */
val storeUrl: String = "",
val developer: String = "",
val publisher: String = "",
val releaseDate: String = "",
val description: String = "",
/** True for a title the signed-in account owns (a library entry). */
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.stores
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
@@ -42,6 +43,7 @@ class StoreGameSidecar(
val epic: EpicOptions = EpicOptions(),
/** GOG / Epic cloud saves for this game: down before a launch, up after it (the game card's switch). */
val cloud: Boolean = true,
val achievements: Boolean = true,
) {
val isInstalled: Boolean get() = state == STATE_INSTALLED
@@ -52,8 +54,8 @@ class StoreGameSidecar(
exe: String = this.exe, launcher: String? = this.launcher, args: List<String> = this.args,
env: Map<String, String> = this.env, installVersion: String = this.installVersion,
installedAt: Long = this.installedAt, cover: String? = this.cover, hero: String? = this.hero,
extra: Map<String, String> = this.extra, state: String = this.state, epic: EpicOptions = this.epic, cloud: Boolean = this.cloud,
) = StoreGameSidecar(store, id, title, exe, launcher, args, env, installVersion, installedAt, cover, hero, extra, state, epic, cloud)
extra: Map<String, String> = this.extra, state: String = this.state, epic: EpicOptions = this.epic, cloud: Boolean = this.cloud, achievements: Boolean = this.achievements,
) = StoreGameSidecar(store, id, title, exe, launcher, args, env, installVersion, installedAt, cover, hero, extra, state, epic, cloud, achievements)
fun toJson(): JSONObject = JSONObject().apply {
put("version", VERSION)
@@ -64,6 +66,7 @@ class StoreGameSidecar(
if (state != STATE_INSTALLED) put("state", state)
if (store == Store.EPIC) put("epic", JSONObject().put("eos", epic.eos).put("offline", epic.offline))
if (store != Store.AMAZON) put("cloud", cloud)
if (store == Store.GOG) put("achievements", achievements)
if (!launcher.isNullOrEmpty()) put("launcher", launcher)
if (args.isNotEmpty()) put("args", JSONArray(args))
if (env.isNotEmpty()) put("env", JSONObject(env as Map<*, *>))
@@ -90,6 +93,12 @@ class StoreGameSidecar(
fun file(folder: File): File = File(folder, FILE_NAME)
/** An installed game's sidecar; walks the install folders. */
fun find(context: Context, store: Store, id: String): StoreGameSidecar? =
StoreInstallRoot.gameFolders(context.applicationContext).asSequence()
.mapNotNull { read(it) }
.firstOrNull { it.store == store && it.id == id }
/**
* Changes an Epic game's launch choices on disk: reads the sidecar fresh (never a copy held
* by a screen), applies [change], writes it, and reads it back. The written sidecar, or null
@@ -109,6 +118,12 @@ class StoreGameSidecar(
return read(folder)?.takeIf { it.cloud == on }
}
fun updateAchievements(folder: File, on: Boolean): StoreGameSidecar? {
val current = read(folder) ?: return null
current.copy(achievements = on).write(folder)
return read(folder)?.takeIf { it.achievements == on }
}
/** The sidecar in [folder], or null when there is none or it cannot be read. */
fun read(folder: File): StoreGameSidecar? {
val f = file(folder)
@@ -146,6 +161,7 @@ class StoreGameSidecar(
// An "overlay" (or "v") field from an earlier build is read past: there is no overlay.
epic = o.optJSONObject("epic")?.let { e -> EpicOptions(eos = e.optBoolean("eos", true), offline = e.optBoolean("offline", false)) } ?: EpicOptions(),
cloud = o.optBoolean("cloud", true),
achievements = o.optBoolean("achievements", true),
)
}
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.stores
import com.droiddeck.launcher.session.SessionPrefs
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.frontend.AddedGames
@@ -44,7 +45,7 @@ object StoreInstalls {
onStep?.invoke(0, STEPS)
// The user's launch choices survive an update or a repair.
val before = StoreGameSidecar.read(folder)
val finished = StoreLaunch.writeLauncher(folder, sidecar.copy(state = StoreGameSidecar.STATE_INSTALLED, epic = before?.epic ?: sidecar.epic, cloud = before?.cloud ?: sidecar.cloud))
val finished = StoreLaunch.writeLauncher(folder, sidecar.copy(state = StoreGameSidecar.STATE_INSTALLED, epic = before?.epic ?: sidecar.epic, cloud = before?.cloud ?: sidecar.cloud, achievements = before?.achievements ?: sidecar.achievements))
finished.write(folder)
if (StoreGameSidecar.read(folder)?.isInstalled != true) throw java.io.IOException("the install record could not be written in ${folder.name}")
onStep?.invoke(1, STEPS)
@@ -76,14 +77,26 @@ object StoreInstalls {
/**
* Removes a store game: its folder with the sidecar, then the shortcut (the listing without
* it makes the writer drop the entry; the live client is asked too). Blocking.
* it makes the writer drop the entry; the live client is asked too). Blocking. [removed] runs
* once the files are gone, before the shortcut list is rebuilt (which reads every added game).
*/
fun uninstall(context: Context, game: InstalledStoreGame) {
fun uninstall(context: Context, game: InstalledStoreGame, progress: (Float) -> Unit = {}, removed: () -> Unit = {}) {
val app = context.applicationContext
val appId = AddedGames.scan(app).firstOrNull { it.folder.absolutePath == game.folder.absolutePath }?.appId
// The shortcut's id as stored for the folder: scanning every added game for it first held
// the bar at 0% for seconds before a file went.
val appId = SessionPrefs.addedGameStoredAppId(app, game.folder.path)
// File by file, deepest first, saying how far it has got: a big game takes a while to go.
val files = game.folder.walkBottomUp().toList()
val step = (files.size / 100).coerceAtLeast(1)
files.forEachIndexed { i, f ->
f.delete()
if (i % step == 0) progress((i + 1).toFloat() / files.size)
}
deleteTree(game.folder)
progress(1f)
// The scratch cache an install on a card may have left in the app's cache.
deleteTree(StoreInstallRoot.scratchDir(app, game.sidecar.store, game.sidecar.id))
removed()
register(app)
if (appId != null) SteamLiveShortcuts.remove(app, appId)
StoresState.logLine("uninstalled \"${game.sidecar.title}\"")
@@ -19,6 +19,9 @@ object StoreLaunchRequests {
private val lock = Any()
private var observer: FileObserver? = null
/** The store game this session last started; covers Steam's own Play button. */
@Volatile var lastLaunched: Pair<Store, String>? = null
fun dir(context: Context) = File(LinuxRuntime.sessionRoot(context), "stores")
/** Before each session: nothing of the last one's requests; then watch for new ones. */
@@ -28,6 +31,7 @@ object StoreLaunchRequests {
observer?.stopWatching()
val root = dir(app)
root.deleteRecursively()
lastLaunched = null
val req = File(root, "req").apply { mkdirs() }
File(root, "resp").mkdirs()
@Suppress("DEPRECATION")
@@ -55,10 +59,13 @@ object StoreLaunchRequests {
return // withdrawn already, or not complete yet (the rename brings it again)
}
val reply = when (request.optString("op")) {
"gog-achievements" -> runCatching { com.droiddeck.launcher.stores.gog.GogComet.prepare(app, request.optString("id")) }
.getOrElse { JSONObject().put("ok", false).put("ready", false).put("reason", "service-unavailable") }
"epic-code" -> {
val id = request.optString("id")
if (id.isEmpty()) JSONObject().put("ok", false).put("reason", "no-id")
else {
lastLaunched = Store.EPIC to id
val code = StoreLaunch.epicCode(app, id)
JSONObject().put("ok", true).put("code", code.written).put("reason", code.reason)
}
@@ -70,6 +77,7 @@ object StoreLaunchRequests {
val id = request.optString("id")
if (store == null || id.isEmpty()) JSONObject().put("ok", false).put("reason", "no-game")
else {
lastLaunched = store to id
// Marked before the game runs: whatever ends it, an upload follows (CloudSaves.uploadDirty).
CloudSaves.markDirty(app, store, id)
CloudSaves.downloadOrDefer(app, store, id).let { JSONObject().put("ok", it.ok).put("result", it.result).put("files", it.files).put("reason", it.reason) }
@@ -41,7 +41,7 @@ internal object StoreNet {
}
}
fun postJson(url: String, body: String, bearer: String? = null, userAgent: String = BROWSER_UA, timeoutMs: Int = 25_000): String? {
fun postJson(url: String, body: String, bearer: String? = null, userAgent: String = BROWSER_UA, timeoutMs: Int = 25_000, headers: Map<String, String> = emptyMap()): String? {
var conn: HttpURLConnection? = null
return try {
conn = (URL(url).openConnection() as HttpURLConnection).apply {
@@ -53,6 +53,7 @@ internal object StoreNet {
setRequestProperty("Content-Type", "application/json")
setRequestProperty("Accept", "application/json")
if (bearer != null) setRequestProperty("Authorization", "Bearer $bearer")
headers.forEach { (k, v) -> setRequestProperty(k, v) }
}
OutputStreamWriter(conn.outputStream, "UTF-8").use { it.write(body) }
val code = conn.responseCode
@@ -99,6 +99,8 @@ object StoresState {
/** Reads what is on disk for every store: sign-ins, installed games, the engine probe. Off the main thread. */
fun refresh(context: Context) {
val app = context.applicationContext
// The first refresh after the app starts brings back what was in the download queue.
com.droiddeck.launcher.stores.download.SavedQueue.restore(app)
Thread({
val found = Store.entries.mapNotNull { store -> StoreAccounts.signedInAs(app, store)?.let { store to it } }
val onDisk = StoreInstallRoot.gameFolders(app).mapNotNull { folder ->
@@ -172,7 +174,8 @@ object StoresState {
val app = context.applicationContext
Thread({
backends[store]?.signOut(app)
StoreAccounts.clear(app, store)
if (store == Store.GOG) com.droiddeck.launcher.stores.gog.GogComet.replaceAccount { StoreAccounts.clear(app, store) }
else StoreAccounts.clear(app, store)
main.post {
accounts.remove(store); library.remove(store); status.remove(store); problems.remove(store)
logLine("${store.label}: signed out")
@@ -217,15 +220,30 @@ object StoresState {
fun install(context: Context, item: CatalogItem, root: java.io.File? = null) {
val app = context.applicationContext
val target = root ?: StoreInstallRoot.installRoot(app)
com.droiddeck.launcher.stores.download.SavedQueue.remember(app, item, target)
backends[item.store]?.install(app, item, target) ?: logLine("${item.store.label}: this build cannot install yet")
}
/** Removes the game's folder and its shortcut, then [onDone] on the main thread. */
/** Games being removed, by key ("store:id"), and how far along (0..1): their page shows it. */
val uninstalling = mutableStateMapOf<String, Float>()
/**
* Removes the game's folder and its shortcut, then [onDone] on the main thread. The game reads
* as removed the moment it is gone (its page shows Install, its card its size), not once the
* whole library has been read again.
*/
fun uninstall(context: Context, game: InstalledStoreGame, onDone: () -> Unit = {}) {
val app = context.applicationContext
val key = "${game.sidecar.store.id}:${game.sidecar.id}"
uninstalling[key] = 0f
Thread({
runCatching { backends[game.sidecar.store]?.uninstall(app, game) }
StoreInstalls.uninstall(app, game)
StoreInstalls.uninstall(app, game, progress = { f -> main.post { if (key in uninstalling) uninstalling[key] = f } }, removed = {
main.post {
installed = installed.filterNot { it.folder.absolutePath == game.folder.absolutePath }
uninstalling.remove(key)
}
})
main.post(onDone)
}, "stores-uninstall").start()
}
@@ -137,6 +137,11 @@ object DownloadQueue {
private val lock = Any()
private val items = LinkedHashMap<String, Item>()
/** Keys a restart brings back paused: queued as paused, not started. */
private val heldPaused = HashSet<String>()
/** [key]'s next [enqueue] comes in paused (a paused download restored after a restart). */
fun holdPaused(key: String) = synchronized(lock) { heldPaused.add(key) }
private var running = 0
fun parallel(context: Context): Int = SessionPrefs.gameStoresParallel(context)
@@ -171,7 +176,8 @@ object DownloadQueue {
synchronized(lock) {
items[entry.key]?.let { if (it.entry.isActive) { Log.i(TAG, "${entry.key}: already queued"); return } }
items.remove(entry.key)
items[entry.key] = Item(entry.copy(state = DownloadState.QUEUED, stage = DownloadStage.MANIFEST, startedAt = System.currentTimeMillis()), factory)
val state = if (heldPaused.remove(entry.key)) DownloadState.PAUSED else DownloadState.QUEUED
items[entry.key] = Item(entry.copy(state = state, stage = DownloadStage.MANIFEST, startedAt = System.currentTimeMillis()), factory)
renumber()
}
StoresState.logLine("${entry.store.label}: queued \"${entry.name}\"" + if (entry.bytesTotal > 0) " (${com.droiddeck.launcher.stores.formatBytes(entry.bytesTotal)})" else "")
@@ -306,6 +312,8 @@ object DownloadQueue {
}
}
lastPublishAt = now
// A state change (not progress) is written down, so the queue outlives the app.
if (!progressOnly) SavedQueue.save(items.values.map { it.entry })
val list = items.values.map { it.entry }.sortedByDescending { it.startedAt }
StoresState.post { StoresState.publishDownloads(list) }
}
@@ -0,0 +1,108 @@
package com.droiddeck.launcher.stores.download
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoresState
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.util.concurrent.Executors
/**
* The download queue kept on disk, so what was queued, running or paused comes back after the app
* closes or is killed: the queue itself lives in memory, and a queued game that never started has
* nothing on disk to find. Each install records the game and where it goes ([remember]); every
* change of state writes the active rows in queue order ([save]); the first refresh after the app
* starts asks the stores for them again ([restore]) - running and queued ones carry on, paused ones
* come back paused.
*/
object SavedQueue {
private const val TAG = "StoreDownloads"
private const val FILE = "store-queue.json"
private class Wanted(val item: CatalogItem, val root: File?)
private val wanted = HashMap<String, Wanted>()
private val writer = Executors.newSingleThreadExecutor { r -> Thread(r, "store-queue-save") }
@Volatile private var app: Context? = null
private var restored = false
private fun key(item: CatalogItem) = "${item.store.id}:${item.id}"
/** An install is asked for: what to ask the store for again after a restart. */
@Synchronized
fun remember(context: Context, item: CatalogItem, root: File?) {
app = context.applicationContext
wanted[key(item)] = Wanted(item, root)
}
/** The active rows, in queue order, as they are now. Any thread; written off it. */
fun save(entries: List<DownloadEntry>) {
val context = app ?: return
val rows = synchronized(this) {
entries.filter { it.isActive }.mapNotNull { e -> wanted[e.key]?.let { e to it } }
}
val out = JSONArray()
for ((e, w) in rows) out.put(JSONObject().apply {
put("paused", e.state == DownloadState.PAUSED)
put("root", w.root?.absolutePath ?: "")
put("item", itemJson(w.item))
})
writer.execute {
runCatching {
val f = File(context.filesDir, FILE)
val tmp = File(context.filesDir, "$FILE.tmp")
tmp.writeText(out.toString())
tmp.renameTo(f)
}.onFailure { Log.w(TAG, "could not save the queue", it) }
}
}
/** Once per process: queues again what was in the queue when the app last closed. */
fun restore(context: Context) {
synchronized(this) { if (restored) return; restored = true }
val app = context.applicationContext
// Read before anything may write it: an install started at once must not save over it.
val rows = runCatching { JSONArray(File(app.filesDir, FILE).readText()) }.getOrNull()
this.app = app
rows ?: return
Thread({
val back = (0 until rows.length()).mapNotNull { i ->
runCatching {
val o = rows.getJSONObject(i)
Triple(itemOf(o.getJSONObject("item")), o.optString("root").takeIf { it.isNotEmpty() }?.let(::File), o.optBoolean("paused"))
}.getOrNull()
}
if (back.isEmpty()) return@Thread
StoresState.post {
for ((item, root, paused) in back) {
if (DownloadQueue.entry(key(item)) != null) continue
if (paused) DownloadQueue.holdPaused(key(item))
StoresState.install(app, item, root)
}
}
}, "store-queue-restore").start()
}
private fun itemJson(i: CatalogItem) = JSONObject().apply {
put("store", i.store.id); put("id", i.id); put("title", i.title)
put("image", i.imageUrl ?: ""); put("tall", i.tallImageUrl ?: "")
put("tags", i.tags); put("developer", i.developer); put("description", i.description)
put("size", i.sizeBytes); put("owned", i.owned)
put("extra", JSONObject(i.extra))
}
private fun itemOf(o: JSONObject): CatalogItem {
val extra = o.optJSONObject("extra")
return CatalogItem(
store = Store.byId(o.getString("store")) ?: error("unknown store"),
id = o.getString("id"), title = o.getString("title"),
imageUrl = o.optString("image").ifEmpty { null }, tallImageUrl = o.optString("tall").ifEmpty { null },
tags = o.optString("tags"), developer = o.optString("developer"), description = o.optString("description"),
owned = o.optBoolean("owned", true), sizeBytes = o.optLong("size"),
extra = extra?.keys()?.asSequence()?.associateWith { extra.getString(it) } ?: emptyMap(),
)
}
}
@@ -0,0 +1,91 @@
package com.droiddeck.launcher.stores.epic
import android.content.Context
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreAchievements
import com.droiddeck.launcher.stores.StoreNet
import org.json.JSONObject
/** Epic's GraphQL: the sandbox's achievements, then the player's records in it. */
object EpicAchievements : StoreAchievements(Store.EPIC.id, Store.EPIC.shortLabel) {
private const val GRAPHQL = "https://launcher.store.epicgames.com/graphql"
private const val USER_AGENT = "EpicGamesLauncher/14.0.8-22004686+++Portal+Release-Live"
override fun request(context: Context, game: Game): Result {
val namespace = game.extra["namespace"].orEmpty()
if (namespace.isEmpty()) return Result.Failed(Reason.NO_SERVICE)
val token = EpicCredentialStore.getValidAccessToken(context) ?: return Result.Failed(Reason.SIGNED_OUT)
val account = EpicCredentialStore.load(context)?.accountId.orEmpty()
val defs = query(token, GAME_ACHIEVEMENTS, JSONObject().put("sandboxId", namespace).put("locale", locale()))
?: return Result.Failed(Reason.UNREACHABLE)
val list = defs.optJSONObject("data")?.optJSONObject("Achievement")?.optJSONObject("productAchievementsRecordBySandbox")
?.optJSONArray("achievements") ?: return Result.Found(emptyList())
val unlocked = if (account.isEmpty()) emptyMap() else unlockTimes(token, account, namespace)
val out = ArrayList<Achievement>()
for (i in 0 until list.length()) {
val a = list.optJSONObject(i)?.optJSONObject("achievement") ?: continue
val id = a.optString("name")
if (id.isEmpty()) continue
val at = unlocked[id]
val done = at != null
out += Achievement(
id = id,
name = (if (done) a.optString("unlockedDisplayName") else a.optString("lockedDisplayName"))
.ifEmpty { a.optString("unlockedDisplayName") }.ifEmpty { id },
description = (if (done) a.optString("unlockedDescription") else a.optString("lockedDescription"))
.ifEmpty { a.optString("unlockedDescription") },
hidden = a.optBoolean("hidden"),
unlocked = done,
unlockedAt = at ?: 0,
icon = a.optString("unlockedIconLink").ifEmpty { null },
iconLocked = a.optString("lockedIconLink").ifEmpty { null },
)
}
return Result.Found(out)
}
/** Achievement name to unlock time, for the ones the player has. */
private fun unlockTimes(token: String, account: String, namespace: String): Map<String, Long> {
val out = HashMap<String, Long>()
val records = query(token, PLAYER_ACHIEVEMENTS, JSONObject().put("epicAccountId", account).put("sandboxId", namespace))
?.optJSONObject("data")?.optJSONObject("PlayerAchievement")?.optJSONObject("playerAchievementGameRecordsBySandbox")
?.optJSONArray("records") ?: return out
for (r in 0 until records.length()) {
val player = records.optJSONObject(r)?.optJSONArray("playerAchievements") ?: continue
for (i in 0 until player.length()) {
val pa = player.optJSONObject(i)?.optJSONObject("playerAchievement") ?: continue
if (pa.optBoolean("unlocked")) out[pa.optString("achievementName")] = epochSeconds(pa.optString("unlockDate"))
}
}
return out
}
private fun query(token: String, query: String, variables: JSONObject): JSONObject? =
StoreNet.postJson(GRAPHQL, JSONObject().put("query", query).put("variables", variables).toString(), bearer = token, userAgent = USER_AGENT)
?.let(::JSONObject)
private val GAME_ACHIEVEMENTS = """
query Achievement(${'$'}sandboxId: String!, ${'$'}locale: String!) {
Achievement {
productAchievementsRecordBySandbox(sandboxId: ${'$'}sandboxId, locale: ${'$'}locale) {
achievements {
achievement {
name hidden unlockedDisplayName lockedDisplayName unlockedDescription lockedDescription
unlockedIconLink lockedIconLink XP
}
}
}
}
}
""".trimIndent()
private val PLAYER_ACHIEVEMENTS = """
query PlayerAchievement(${'$'}epicAccountId: String!, ${'$'}sandboxId: String!) {
PlayerAchievement {
playerAchievementGameRecordsBySandbox(epicAccountId: ${'$'}epicAccountId, sandboxId: ${'$'}sandboxId) {
records { playerAchievements { playerAchievement { achievementName unlocked unlockDate XP } } }
}
}
}
""".trimIndent()
}
@@ -103,7 +103,11 @@ public final class EpicApiClient {
if (attrs != null) {
JSONObject offline = attrs.optJSONObject("CanRunOffline");
if (offline != null) game.canRunOffline = !"false".equalsIgnoreCase(offline.optString("value", "true"));
JSONObject publisher = attrs.optJSONObject("publisherName");
if (publisher != null) game.publisher = publisher.optString("value", "").trim();
}
JSONObject seller = item.optJSONObject("seller");
if (game.publisher.isEmpty() && seller != null) game.publisher = seller.optString("name", "").trim();
String rd = item.optString("viewableDate", "");
if (rd.isEmpty()) rd = item.optString("effectiveDate", "");
if (!rd.isEmpty()) game.releaseDate = rd;
@@ -80,7 +80,12 @@ object EpicBackend : StoreBackend, EpicLaunchSupport {
val byCatalogItem = owned.associateBy { it.extra["catalogItemId"] ?: "" }
fun mark(l: List<CatalogItem>) = l.map { i ->
val match = i.extra["items"]?.split(',')?.firstNotNullOfOrNull { byCatalogItem[it] }
if (match != null) match.copy(imageUrl = i.imageUrl ?: match.imageUrl, tallImageUrl = i.tallImageUrl ?: match.tallImageUrl) else i
// The offer's art, and its facts where the library's catalog record has none.
if (match != null) match.copy(
imageUrl = i.imageUrl ?: match.imageUrl, tallImageUrl = i.tallImageUrl ?: match.tallImageUrl,
developer = match.developer.ifBlank { i.developer }, publisher = match.publisher.ifBlank { i.publisher },
releaseDate = match.releaseDate.ifBlank { i.releaseDate },
) else i
}
return StoreShelves(mark(s.whatsNew), mark(s.deals), mark(s.free), mark(s.trending))
}
@@ -548,10 +548,10 @@ public final class EpicDownloadManager {
try {
JSONObject root = new JSONObject(new String(bytes, StandardCharsets.UTF_8));
int manifestVersion = 0;
try { manifestVersion = Integer.parseInt(root.optString("ManifestFileVersion", "0")); } catch (NumberFormatException ignored) {}
try { manifestVersion = (int) blobToNum(root.optString("ManifestFileVersion", "0")); } catch (NumberFormatException ignored) {}
Manifest m = new Manifest();
m.chunkDir = manifestVersion >= 15 ? "ChunksV4" : manifestVersion >= 6 ? "ChunksV3" : manifestVersion >= 3 ? "ChunksV2" : "ChunksV4";
m.launchExe = root.optString("LaunchExe", "").replace('\\', '/');
m.launchExe = root.optString("LaunchExeString", root.optString("LaunchExe", "")).replace('\\', '/');
m.launchCommand = root.optString("LaunchCommand", "");
m.buildVersion = root.optString("BuildVersionString", "");
m.prereqName = root.optString("PrereqName", "");
@@ -568,10 +568,9 @@ public final class EpicDownloadManager {
if (guidHex.length() < 32) continue;
ChunkInfo c = new ChunkInfo();
for (int k = 0; k < 4; k++) c.guid[k] = (int) Long.parseLong(guidHex.substring(k * 8, k * 8 + 8), 16);
String hashHex = chunkHashList.getString(guidHex);
if (hashHex != null && hashHex.length() >= 16) { try { c.hash = Long.parseUnsignedLong(hashHex.substring(0, 16), 16); } catch (Exception ignored) {} }
if (dataGroupList != null) { try { c.groupNum = Integer.parseInt(dataGroupList.optString(guidHex, "0")); } catch (NumberFormatException ignored) {} }
if (chunkFilesizeList != null) { try { c.fileSize = Long.parseLong(chunkFilesizeList.optString(guidHex, "0"), 16); } catch (NumberFormatException ignored) {} }
try { c.hash = blobToNum(chunkHashList.getString(guidHex)); } catch (NumberFormatException ignored) {}
if (dataGroupList != null) { try { c.groupNum = (int) blobToNum(dataGroupList.optString(guidHex, "0")); } catch (NumberFormatException ignored) {} }
if (chunkFilesizeList != null) { try { c.fileSize = blobToNum(chunkFilesizeList.optString(guidHex, "0")); } catch (NumberFormatException ignored) {} }
chunkMap.put(guidHex, c);
}
JSONArray fileList = root.optJSONArray("FileManifestList");
@@ -589,8 +588,8 @@ public final class EpicDownloadManager {
ChunkPart part = new ChunkPart();
String g = po.optString("Guid", "");
if (g.length() >= 32) for (int k = 0; k < 4; k++) part.guid[k] = (int) Long.parseLong(g.substring(k * 8, k * 8 + 8), 16);
try { part.offset = Integer.parseInt(po.optString("Offset", "0")); } catch (NumberFormatException ignored) {}
try { part.size = Integer.parseInt(po.optString("Size", "0")); } catch (NumberFormatException ignored) {}
try { part.offset = (int) blobToNum(po.optString("Offset", "0")); } catch (NumberFormatException ignored) {}
try { part.size = (int) blobToNum(po.optString("Size", "0")); } catch (NumberFormatException ignored) {}
fi.parts.add(part);
}
files.add(fi);
@@ -604,6 +603,24 @@ public final class EpicDownloadManager {
}
}
/**
* A JSON manifest writes every number as a "blob": each byte as three decimal digits, least
* significant first ("013000000000" = 13), as Legendary's blob_to_num reads it. Bits past 64
* are dropped; a group that is not digits throws.
*/
static long blobToNum(String blob) {
long num = 0;
for (int i = 0, shift = 0; i < blob.length(); i += 3, shift += 8) {
String group = blob.substring(i, Math.min(i + 3, blob.length()));
for (int k = 0; k < group.length(); k++) {
char ch = group.charAt(k);
if (ch < '0' || ch > '9') throw new NumberFormatException("not a blob: " + blob);
}
if (shift < 64) num += Long.parseLong(group) << shift;
}
return num;
}
public static String readFString(ByteBuffer buf) {
int length = buf.getInt();
if (length == 0) return "";
@@ -22,4 +22,5 @@ public class EpicGame {
public boolean isDLC = false;
public String baseGameCatalogItemId = "";
public String releaseDate = "";
public String publisher = "";
}
@@ -44,6 +44,8 @@ object EpicLibrary {
g.developer = j.optString("developer", ""); g.description = j.optString("description", ""); g.version = j.optString("version", "")
g.installSize = j.optLong("installSize", 0L).let { if (it > 1_099_511_627_776L) 0L else it }
g.canRunOffline = j.optBoolean("canRunOffline", true)
g.releaseDate = j.optString("releaseDate", "")
g.publisher = j.optString("publisher", "")
if (g.appName.isNotEmpty()) list.add(g)
}
list.distinctBy { it.appName }.sortedBy { it.title.lowercase() }
@@ -74,7 +76,6 @@ object EpicLibrary {
var done = 0
for (game in raw) {
EpicApiClient.enrichFromCatalog(token, game)
if (game.releaseDate.isNotEmpty()) p.edit().putString("release_${game.appName}", game.releaseDate).apply()
done++
if (done % 5 == 0) onStatus("Loading game details… ($done/$total)")
}
@@ -102,7 +103,8 @@ object EpicLibrary {
val arr = JSONArray()
for (g in games) arr.put(JSONObject().put("appName", g.appName).put("namespace", g.namespace).put("catalogItemId", g.catalogItemId)
.put("title", g.title).put("artCover", g.artCover).put("artSquare", g.artSquare).put("developer", g.developer)
.put("description", g.description).put("version", g.version).put("installSize", g.installSize).put("canRunOffline", g.canRunOffline))
.put("description", g.description).put("version", g.version).put("installSize", g.installSize).put("canRunOffline", g.canRunOffline)
.put("releaseDate", g.releaseDate).put("publisher", g.publisher))
prefs.edit().putString(CACHE_KEY, arr.toString()).apply()
}
}
@@ -111,7 +113,9 @@ object EpicLibrary {
store = Store.EPIC, id = g.appName, title = g.title.ifBlank { g.appName },
imageUrl = g.artSquare.ifBlank { null } ?: g.artCover.ifBlank { null },
tallImageUrl = g.artCover.ifBlank { null } ?: g.artSquare.ifBlank { null },
developer = g.developer, description = com.droiddeck.launcher.stores.cleanStoreText(g.description), owned = true,
developer = g.developer, publisher = g.publisher, description = com.droiddeck.launcher.stores.cleanStoreText(g.description), owned = true,
// A cache from before the release date was kept still has it under the older per-game key.
releaseDate = g.releaseDate.ifEmpty { EpicPrefs.get(context).getString("release_${g.appName}", "").orEmpty() },
sizeBytes = EpicPrefs.get(context).getLong("size_${g.appName}", g.installSize),
storeUrl = "https://store.epicgames.com/en-US/browse?q=${java.net.URLEncoder.encode(g.title, "UTF-8")}",
extra = mapOf("namespace" to g.namespace, "catalogItemId" to g.catalogItemId, "version" to (g.version ?: "")),
@@ -32,7 +32,7 @@ object EpicStoreCatalog {
private const val ELEMENT_FIELDS =
"title id namespace description effectiveDate releaseDate offerType productSlug urlSlug " +
"items{id namespace} keyImages{type url} seller{name} tags{name} " +
"items{id namespace} keyImages{type url} seller{name} tags{name} customAttributes{key value} " +
"catalogNs{mappings(pageType:\"productHome\"){pageSlug pageType}} " +
"price(country:\$country){totalPrice{discountPrice originalPrice discount currencyCode " +
"fmtPrice(locale:\$locale){originalPrice discountPrice}}}"
@@ -116,13 +116,21 @@ object EpicStoreCatalog {
return CatalogItem(
store = Store.EPIC, id = id, title = title, imageUrl = wide ?: thumb, tallImageUrl = tall ?: thumb, tags = tagLine,
isFree = isFree, hasPrice = hasPrice, finalPrice = finalPrice, originalPrice = if (discountPct > 0) fmtOriginal else "", discountPercent = discountPct,
storeUrl = storeUrl, developer = e.optJSONObject("seller")?.optString("name", "").orEmpty(),
storeUrl = storeUrl, developer = attribute(e, "developerName"),
publisher = attribute(e, "publisherName").ifEmpty { e.optJSONObject("seller")?.optString("name", "").orEmpty() },
releaseDate = e.optString("releaseDate", e.optString("effectiveDate", "")), description = com.droiddeck.launcher.stores.cleanStoreText(e.optString("description", "")),
extra = buildMap { put("namespace", ns); if (itemIds.isNotEmpty()) put("items", itemIds.joinToString(",")); if (slug.isNotBlank()) put("slug", slug) },
mature = com.droiddeck.launcher.stores.matureByTags(allTags),
)
}
// An offer's custom attribute (developerName, publisherName), or "".
private fun attribute(e: JSONObject, key: String): String {
val attrs = e.optJSONArray("customAttributes") ?: return ""
return (0 until attrs.length()).mapNotNull { attrs.optJSONObject(it) }.firstOrNull { it.optString("key") == key }
?.optString("value", "")?.trim().orEmpty()
}
private fun pageSlugOf(e: JSONObject): String {
e.optJSONObject("catalogNs")?.optJSONArray("mappings")?.let { m ->
for (i in 0 until m.length()) { val map = m.optJSONObject(i) ?: continue; if (map.optString("pageType") == "productHome") { val s = map.optString("pageSlug", ""); if (s.isNotBlank()) return s } }
@@ -193,7 +201,7 @@ object EpicStoreCatalog {
private fun itemToJson(i: CatalogItem): JSONObject = JSONObject().apply {
put("id", i.id); put("title", i.title); put("image", i.imageUrl ?: ""); put("tall", i.tallImageUrl ?: ""); put("tags", i.tags)
put("free", i.isFree); put("hasPrice", i.hasPrice); put("final", i.finalPrice); put("orig", i.originalPrice); put("disc", i.discountPercent)
put("url", i.storeUrl); put("dev", i.developer); put("rel", i.releaseDate); put("desc", i.description); put("extra", JSONObject(i.extra as Map<*, *>))
put("url", i.storeUrl); put("dev", i.developer); put("pub", i.publisher); put("rel", i.releaseDate); put("desc", i.description); put("extra", JSONObject(i.extra as Map<*, *>))
if (i.mature) put("mature", true)
}
@@ -203,7 +211,7 @@ object EpicStoreCatalog {
return CatalogItem(
store = Store.EPIC, id = o.optString("id"), title = o.optString("title"), imageUrl = o.optString("image").ifBlank { null }, tallImageUrl = o.optString("tall").ifBlank { null },
tags = o.optString("tags"), isFree = o.optBoolean("free"), hasPrice = o.optBoolean("hasPrice"), finalPrice = o.optString("final"), originalPrice = o.optString("orig"),
discountPercent = o.optInt("disc"), storeUrl = o.optString("url"), developer = o.optString("dev"), releaseDate = o.optString("rel"), description = o.optString("desc"), extra = extra,
discountPercent = o.optInt("disc"), storeUrl = o.optString("url"), developer = o.optString("dev"), publisher = o.optString("pub"), releaseDate = o.optString("rel"), description = o.optString("desc"), extra = extra,
mature = o.optBoolean("mature"),
)
}
@@ -0,0 +1,83 @@
package com.droiddeck.launcher.stores.gog
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreAchievements
import com.droiddeck.launcher.stores.StoreLog
import com.droiddeck.launcher.stores.StoreNet
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.util.zip.InflaterInputStream
/** gameplay.gog.com, with a token scoped to the game's own client. */
object GogAchievements : StoreAchievements(Store.GOG.id, Store.GOG.shortLabel) {
private const val TAG = "GogAchievements"
override fun request(context: Context, game: Game): Result {
val galaxy = GogAuth.validToken(context) ?: return Result.Failed(Reason.SIGNED_OUT)
val (clientId, clientSecret) = client(context, game.id, galaxy) ?: return Result.Failed(Reason.NO_SERVICE)
val scoped = GogAuth.gameToken(context, clientId, clientSecret) ?: return Result.Failed(Reason.UNREACHABLE)
val body = StoreNet.get(
"https://gameplay.gog.com/clients/$clientId/users/${scoped.userId}/achievements",
bearer = scoped.accessToken, userAgent = GogAuth.GALAXY_UA, headers = mapOf("X-Gog-Lc" to locale()),
) ?: return Result.Failed(Reason.UNREACHABLE)
val items = body.takeIf { it.isNotBlank() }?.let(::JSONObject)?.optJSONArray("items") ?: return Result.Found(emptyList())
val out = ArrayList<Achievement>()
for (i in 0 until items.length()) {
val item = items.optJSONObject(i) ?: continue
val id = item.optString("achievement_key").ifEmpty { item.optString("achievement_id") }
if (id.isEmpty()) continue
val date = item.optString("date_unlocked").takeIf { it.isNotBlank() && it != "null" }
out += Achievement(
id = id,
name = item.optString("name").ifEmpty { id },
description = item.optString("description"),
hidden = !item.optBoolean("visible", true),
unlocked = date != null,
unlockedAt = date?.let(::epochSeconds) ?: 0,
icon = item.optString("image_url_unlocked").ifEmpty { item.optString("image_url_locked") }.ifEmpty { null },
iconLocked = item.optString("image_url_locked").ifEmpty { null },
)
}
return Result.Found(out)
}
/** The game's client id and secret: saved at install, else from the newest build's manifest. */
private fun client(context: Context, productId: String, token: String): Pair<String, String>? {
val prefs = GogPrefs.get(context)
val savedId = prefs.getString("client_id_$productId", null)?.ifEmpty { null }
val savedSecret = prefs.getString("client_secret_$productId", null)?.ifEmpty { null }
if (savedId != null && savedSecret != null) return savedId to savedSecret
val builds = StoreNet.get("https://content-system.gog.com/products/$productId/os/windows/builds?generation=2", bearer = token)
?.let(::JSONObject) ?: return null
val link = builds.optJSONArray("items")?.optJSONObject(0)?.optString("link")?.ifEmpty { null } ?: return null
val manifest = bytes(link, token)?.let { JSONObject(inflate(it)) } ?: return null
val id = manifest.optString("clientId").ifEmpty { return null }
val secret = manifest.optString("clientSecret").ifEmpty { return null }
prefs.edit().putString("client_id_$productId", id).putString("client_secret_$productId", secret).apply()
return id to secret
}
private fun bytes(url: String, bearer: String): ByteArray? {
var conn: HttpURLConnection? = null
return try {
conn = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 20_000; readTimeout = 20_000
setRequestProperty("Authorization", "Bearer $bearer")
}
if (conn.responseCode !in 200..299) null else conn.inputStream.use { it.readBytes() }
} catch (e: Exception) {
Log.w(TAG, "GET failed ${StoreLog.redactUrl(url)}: ${e.javaClass.simpleName}")
null
} finally {
runCatching { conn?.disconnect() }
}
}
/** Gen2 manifests are zlib-compressed; plain JSON passes through. */
private fun inflate(data: ByteArray): String =
if (data.isNotEmpty() && data[0] == '{'.code.toByte()) String(data, Charsets.UTF_8)
else InflaterInputStream(data.inputStream()).use { String(it.readBytes(), Charsets.UTF_8) }
}
@@ -33,7 +33,12 @@ object GogAuth {
fun isSignedIn(context: Context): Boolean = StoreAccounts.read(context, Store.GOG)?.optString("access_token", "")?.isNotEmpty() == true
/** Saves a fresh sign-in and looks the account's name up for the chip. Blocking. */
fun saveLogin(context: Context, accessToken: String, refreshToken: String?, userId: String?) {
fun saveLogin(context: Context, accessToken: String, refreshToken: String?, userId: String?) = GogComet.replaceAccount {
saveAccount(context, accessToken, refreshToken, userId)
}
@Synchronized
private fun saveAccount(context: Context, accessToken: String, refreshToken: String?, userId: String?) {
var username = ""
runCatching {
val body = StoreNet.get("https://embed.gog.com/userData.json", bearer = accessToken, userAgent = GALAXY_UA)
@@ -52,6 +57,7 @@ object GogAuth {
/** A usable access token, refreshed when the recorded login time says it has expired; null when signed out or the refresh failed. */
@JvmStatic
@Synchronized
fun validToken(context: Context): String? {
val json = StoreAccounts.read(context, Store.GOG) ?: return null
val token = json.optString("access_token", "").ifEmpty { return null }
@@ -63,6 +69,7 @@ object GogAuth {
/** Refreshes the access token; the new one, or null on any failure. Blocking. */
@JvmStatic
@Synchronized
fun refresh(context: Context): String? {
val json = StoreAccounts.read(context, Store.GOG) ?: return null
val refreshToken = json.optString("refresh_token", "").ifEmpty { return null }
@@ -87,6 +94,51 @@ object GogAuth {
}
}
/** A complete companion snapshot. Never exported through the guest request channel. */
@Synchronized
fun cometCredentials(context: Context): JSONObject? {
val access = validToken(context) ?: return null
val saved = StoreAccounts.read(context, Store.GOG) ?: return null
val refresh = saved.optString("refresh_token").ifEmpty { return null }
var user = saved.optString("user_id")
var username = saved.optString("username")
if (!user.matches(Regex("[0-9]+")) || username.isEmpty()) {
val body = StoreNet.get("https://embed.gog.com/userData.json", bearer = access, userAgent = GALAXY_UA, timeoutMs = 5_000) ?: return null
val info = runCatching { JSONObject(body) }.getOrNull() ?: return null
user = info.optString("galaxyUserId", info.optString("userId", user))
username = info.optString("username", username)
if (!user.matches(Regex("[0-9]+")) || username.isEmpty()) return null
saved.put("user_id", user).put("username", username)
StoreAccounts.write(context, Store.GOG, saved)
}
return JSONObject().put("access_token", access).put("refresh_token", refresh)
.put("user_id", user).put("username", username)
}
class GameToken(val accessToken: String, val userId: String)
/** The Galaxy refresh token exchanged for one scoped to a game's own client. Blocking. */
fun gameToken(context: Context, clientId: String, clientSecret: String): GameToken? {
val json = StoreAccounts.read(context, Store.GOG) ?: return null
val refresh = json.optString("refresh_token", "").ifEmpty { return null }
var conn: HttpURLConnection? = null
return try {
val enc = { s: String -> java.net.URLEncoder.encode(s, "UTF-8") }
conn = (URL("https://auth.gog.com/token?client_id=${enc(clientId)}&client_secret=${enc(clientSecret)}&grant_type=refresh_token&refresh_token=${enc(refresh)}")
.openConnection() as HttpURLConnection).apply { connectTimeout = 15_000; readTimeout = 15_000 }
conn.setRequestProperty("User-Agent", "GOG Galaxy")
if (conn.responseCode != 200) { Log.w(TAG, "game token: HTTP ${conn.responseCode}"); return null }
val o = JSONObject(conn.inputStream.bufferedReader().readText())
val access = o.optString("access_token").ifEmpty { return null }
GameToken(access, o.optString("user_id").ifEmpty { json.optString("user_id") })
} catch (e: Exception) {
Log.w(TAG, "game token failed: ${e.javaClass.simpleName}")
null
} finally {
runCatching { conn?.disconnect() }
}
}
/** Hosts the GOG login page hands off to for a social sign-in; they refuse an embedded browser's identity. */
fun isIdentityProviderHost(host: String?): Boolean {
val h = host?.lowercase() ?: return false
@@ -71,7 +71,7 @@ object GogBackend : StoreBackend {
override fun install(context: Context, item: CatalogItem, root: File) {
val app = context.applicationContext
val game = GogGame(item.id, item.title, item.imageUrl ?: "", item.description, item.developer, item.tags, 2, item.tallImageUrl)
val game = GogGame(item.id, item.title, item.imageUrl ?: "", item.description, item.developer, item.tags, 2, item.tallImageUrl, item.releaseDate, item.publisher)
val folder = StoreInstallRoot.folderFor(app, Store.GOG, game.gameId, game.title, root)
val entry = DownloadEntry(store, item.id, item.title, cover = item.imageUrl, bytesTotal = item.sizeBytes, location = StoreInstallRoot.labelFor(app, folder))
DownloadQueue.enqueue(app, entry) { InstallJob(app, game, item, folder) }
@@ -0,0 +1,189 @@
package com.droiddeck.launcher.stores.gog
import android.content.Context
import android.os.Environment
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.SessionEvents
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.session.SessionState
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.AchievementNotifications
import com.droiddeck.launcher.stores.StoreAchievements
import com.droiddeck.launcher.stores.StoreAccounts
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.StoreInstallRoot
import com.droiddeck.launcher.stores.StoresState
import org.json.JSONObject
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/** Owns one Galaxy companion; only its structured, secret-free events reach Android logs. */
object GogComet {
private const val TAG = "GogComet"
private const val BIN = "/usr/local/lib/droiddeck-comet/comet"
private val lock = Any()
@Volatile private var process: Process? = null
private var pid = -1
private var started: String? = null
private var account = ""
private var session: String? = null
private var accepting = false
private var generation: String? = null
@Volatile private var gamesByClient: Map<String, StoreGameSidecar> = emptyMap()
var status by mutableStateOf("off")
private set
fun enabled(context: Context) = GogPrefs.get(context).getBoolean("achievements", false)
fun setEnabled(context: Context, on: Boolean) {
GogPrefs.get(context).edit().putBoolean("achievements", on).apply()
}
fun begin() = synchronized(lock) {
stopLocked()
accepting = true
generation = SessionState.sessionId
AchievementNotifications.begin(generation)
}
/** Account replacement and launch preparation use the same lock, preventing stale restarts. */
fun replaceAccount(change: () -> Unit) = synchronized(lock) {
stopLocked()
AchievementNotifications.begin(generation)
change()
}
fun stop() = synchronized(lock) {
stopLocked()
}
fun end(expected: String?) = synchronized(lock) {
if (generation == expected) {
accepting = false
stopLocked()
AchievementNotifications.end(expected)
generation = null
}
}
private fun report(value: String) {
StoresState.post { status = value }
Log.i(TAG, value)
SessionEvents.record("gog.achievements", mapOf("status" to value))
}
private fun publishPolicy(context: Context): File {
val dir = File(context.filesDir, "gog-achievements").apply { mkdirs() }
val games = if (!enabled(context)) emptyMap() else StoreInstallRoot.gameFolders(context).mapNotNull { folder ->
val game = StoreGameSidecar.read(folder) ?: return@mapNotNull null
if (game.store != Store.GOG || !game.achievements) return@mapNotNull null
val client = GogPrefs.get(context).getString("client_id_${game.id}", null)
?: runCatching { JSONObject(File(folder, "goggame-${game.id}.info").readText()).optString("clientId") }.getOrNull()
client?.takeIf { it.matches(Regex("[0-9]+")) }?.let { it to game }
}.toMap()
gamesByClient = games
val file = File(dir, "clients.json")
val tmp = File(dir, "clients.json.tmp")
tmp.writeText(org.json.JSONArray(games.keys.toList()).toString())
check(tmp.renameTo(file))
return file
}
/** No arbitrary paths or credentials from the guest are accepted. */
fun prepare(context: Context, id: String): JSONObject = synchronized(lock) {
fun reply(ready: Boolean, reason: String): JSONObject {
report(reason)
return JSONObject().put("ok", ready).put("ready", ready).put("reason", reason)
}
if (!accepting || !SessionState.running || generation != SessionState.sessionId) return reply(false, "no-session")
val folder = StoreInstallRoot.gameFolders(context).firstOrNull { f -> StoreGameSidecar.read(f)?.let { it.store == Store.GOG && it.id == id } == true }
val sidecar = folder?.let { StoreGameSidecar.read(it) }
if (sidecar?.store != Store.GOG || sidecar.id != id) return reply(false, "no-game")
val policy = publishPolicy(context)
if (!enabled(context) || !sidecar.achievements) return reply(false, "off")
val credentials = GogAuth.cometCredentials(context) ?: return reply(false,
if (StoreAccounts.isUnavailable(Store.GOG)) "credentials-unavailable"
else if (StoreAccounts.read(context, Store.GOG)?.optString("refresh_token").isNullOrEmpty()) "sign-in-needed" else "credentials-unavailable")
if (!SessionState.running || generation != SessionState.sessionId) return reply(false, "no-session")
val user = credentials.getString("user_id")
if (process?.isAlive == true && account == user && session == SessionState.sessionId && identity(pid) == started) {
return JSONObject().put("ok", true).put("ready", true).put("reason", "ready")
}
stopLocked()
if (!File(LinuxRuntime.rootDir(context), BIN.removePrefix("/")).isFile) return reply(false, "not-installed")
val data = File(context.filesDir, "gog-achievements").apply { mkdirs() }
val ready = CountDownLatch(1)
val command = LinuxRuntime.command(context, null, null, Environment.getExternalStorageDirectory(),
listOf("/usr/bin/env", "HOME=/root", "XDG_DATA_HOME=${data.path}/data", "XDG_CONFIG_HOME=${data.path}/config", "COMET_LOG=off", "COMET_ALLOWED_CLIENTS=${policy.path}", BIN, "--credentials-stdin"))
val pb = ProcessBuilder(command).redirectErrorStream(true)
pb.environment().apply {
remove("LD_PRELOAD"); remove("LD_AUDIT"); remove("LD_LIBRARY_PATH")
put("PROOT_LOADER", LinuxRuntime.prootLoader(context).path)
put("PROOT_TMP_DIR", context.cacheDir.path)
if (SessionPrefs.prootNoSeccomp(context)) put("PROOT_NO_SECCOMP", "1")
LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
}
try {
val child = pb.start()
process = child; account = user; session = SessionState.sessionId
Thread({
runCatching { child.inputStream.bufferedReader().useLines { lines -> lines.forEach { line ->
// Raw upstream errors/payloads can contain tokens. Never forward them.
when {
line.startsWith("DROIDDECK_COMET_READY:") -> {
val childPid = line.substringAfter(':').toIntOrNull() ?: -1
if (childPid > 1 && process === child) {
pid = childPid; started = identity(childPid); ready.countDown()
}
}
line.startsWith("DROIDDECK_COMET_UNLOCKED:") -> if (process === child) {
val event = runCatching { JSONObject(line.substringAfter(':')) }.getOrNull()
val game = event?.optString("client_id")?.let { gamesByClient[it] }
val unlock = event?.let { AchievementNotifications.parse(it, game?.title.orEmpty()) }
val owner = session
if (game != null && unlock != null && owner != null && owner == SessionState.sessionId && SessionState.running) {
report("unlocked-locally")
AchievementNotifications.unlock(owner, unlock)
SessionEvents.record("achievement.unlocked", mapOf("store" to "gog", "gameId" to game.id, "achievementId" to unlock.id))
}
}
line == "DROIDDECK_COMET_PENDING" -> if (process === child) report("pending-upload")
line.startsWith("DROIDDECK_COMET_UPLOADED:") -> if (process === child && line.substringAfter(':').toIntOrNull()?.let { it > 0 } == true) { report("uploaded"); StoresState.post { StoreAchievements.invalidate() } }
}
} } }
if (process === child) report("service-unavailable")
ready.countDown()
}, "gog-comet-output").start()
child.outputStream.bufferedWriter().use { it.write(credentials.toString()) }
if (!ready.await(5, TimeUnit.SECONDS) || pid <= 1 || started == null || !child.isAlive) {
stopLocked(); return reply(false, "service-unavailable")
}
reply(true, "ready")
} catch (e: Exception) {
stopLocked()
reply(false, "service-unavailable")
}
}
private fun identity(pid: Int): String? = if (pid <= 1) null else runCatching {
File("/proc/$pid/stat").readText().substringAfterLast(") ").split(' ')[19]
}.getOrNull()
private fun stopLocked() {
val child = process ?: return
// Signal the announced daemon, not proot; start time prevents signalling a reused PID.
if (started != null && identity(pid) == started) android.os.Process.sendSignal(pid, 2)
if (!child.waitFor(5, TimeUnit.SECONDS)) {
if (started != null && identity(pid) == started) android.os.Process.sendSignal(pid, 15)
child.destroy()
}
process = null; pid = -1; started = null; account = ""; session = null
report("stopped")
}
}
@@ -12,9 +12,12 @@ public class GogGame {
public final int generation;
/** gamesdb.gog.com 2:3 box art, or null when the lookup found none. */
public final String verticalCover;
/** ISO date-time of the worldwide release, or "" when GOG gave none. */
public final String releaseDate;
public final String publisher;
public GogGame(String gameId, String title, String imageUrl, String description, String developer,
String category, int generation, String verticalCover) {
String category, int generation, String verticalCover, String releaseDate, String publisher) {
this.gameId = gameId;
this.title = title;
this.imageUrl = imageUrl;
@@ -23,5 +26,7 @@ public class GogGame {
this.category = category;
this.generation = generation;
this.verticalCover = verticalCover;
this.releaseDate = releaseDate;
this.publisher = publisher;
}
}
@@ -45,7 +45,8 @@ object GogLibrary {
(0 until arr.length()).mapNotNull { i ->
val o = arr.optJSONObject(i) ?: return@mapNotNull null
GogGame(o.optString("gameId", ""), o.optString("title", ""), o.optString("imageUrl", ""), cleanStoreText(o.optString("description", "")),
o.optString("developer", ""), o.optString("category", ""), o.optInt("generation", 1), o.optString("verticalCover", "").ifEmpty { null })
o.optString("developer", ""), o.optString("category", ""), o.optInt("generation", 1), o.optString("verticalCover", "").ifEmpty { null },
o.optString("releaseDate", ""), o.optString("publisher", ""))
}.filter { it.gameId.isNotEmpty() }.sortedBy { it.title.lowercase() }
}.getOrDefault(emptyList())
}
@@ -148,7 +149,15 @@ object GogLibrary {
val desc = cleanStoreText(prod.optJSONObject("description")?.optString("lead", ""))
.ifEmpty { cleanStoreText(v2?.optString("overview", "")) }
.ifEmpty { cleanStoreText(v2?.optString("description", "")) }
val developer = prod.optJSONObject("developers")?.optString("name", "") ?: prod.optString("developer", "")
// The products endpoint names no developer or publisher; the v2 record does, with the worldwide release date.
val embedded = v2?.optJSONObject("_embedded")
fun names(key: String) = embedded?.optJSONArray(key)
?.let { arr -> (0 until arr.length()).mapNotNull { arr.optJSONObject(it)?.optString("name")?.takeIf(String::isNotBlank) } }
?.joinToString(", ").orEmpty()
val developer = names("developers")
val publisher = embedded?.optJSONObject("publisher")?.optString("name")?.takeIf(String::isNotBlank) ?: names("publishers")
val releaseDate = listOf(embedded?.optJSONObject("product")?.optString("globalReleaseDate"), prod.optString("release_date"))
.firstOrNull { !it.isNullOrBlank() && it != "null" }.orEmpty()
val genres = prod.optJSONArray("genres")
val category = if (genres != null && genres.length() > 0) genres.optJSONObject(0)?.optString("name", "") ?: "" else ""
@@ -164,9 +173,7 @@ object GogLibrary {
if (maxGen > 0) generation = maxGen
}
}
val ed = prefs.edit().putInt("gen_$id", generation)
prod.optString("release_date", "").takeIf { it.isNotEmpty() }?.let { ed.putString("release_$id", it) }
ed.apply()
prefs.edit().putInt("gen_$id", generation).apply()
if (prefs.getLong("size_$id", -1L) <= 0) {
val size = GogDownloadManager.fetchInstallSizeBytes(id, token)
if (size > 0) prefs.edit().putLong("size_$id", size).apply()
@@ -182,7 +189,7 @@ object GogLibrary {
verticalCover = fetchVerticalCover(id).ifEmpty { StoreNet.sgdbPoster(title) }
}
if (!verticalCover.isNullOrEmpty()) prefs.edit().putString("vcover_$id", verticalCover).apply()
return GogGame(id, title, GogStoreCatalog.absolutize(imageUrl), desc, developer, category, generation, verticalCover?.ifEmpty { null })
return GogGame(id, title, GogStoreCatalog.absolutize(imageUrl), desc, developer, category, generation, verticalCover?.ifEmpty { null }, releaseDate, publisher)
} catch (_: Exception) {
return null
}
@@ -203,7 +210,7 @@ object GogLibrary {
val arr = JSONArray()
for (g in games) {
arr.put(JSONObject().put("gameId", g.gameId).put("title", g.title).put("imageUrl", g.imageUrl).put("description", g.description)
.put("developer", g.developer).put("category", g.category).put("generation", g.generation)
.put("developer", g.developer).put("category", g.category).put("generation", g.generation).put("releaseDate", g.releaseDate).put("publisher", g.publisher)
.apply { if (!g.verticalCover.isNullOrEmpty()) put("verticalCover", g.verticalCover) })
}
prefs.edit().putString(CACHE_KEY, arr.toString()).apply()
@@ -215,7 +222,9 @@ object GogLibrary {
store = Store.GOG, id = g.gameId, title = g.title,
imageUrl = GogStoreCatalog.absolutize(g.imageUrl).ifBlank { null },
tallImageUrl = GogStoreCatalog.absolutize(g.verticalCover ?: g.imageUrl).ifBlank { null },
tags = g.category, developer = g.developer, description = g.description, owned = true,
tags = g.category, developer = g.developer, publisher = g.publisher, description = g.description, owned = true,
// A cache from before the release date was kept still has it under the older per-game key.
releaseDate = g.releaseDate.ifEmpty { GogPrefs.get(context).getString("release_${g.gameId}", "").orEmpty() },
sizeBytes = sizeOf(context, g.gameId),
storeUrl = "https://www.gog.com/en/game/${g.gameId}",
)
@@ -133,6 +133,7 @@ object GogStoreCatalog {
finalPrice = finalStr, originalPrice = if (discount > 0) baseStr else "",
discountPercent = if (discount > 0 && baseStr.isNotBlank()) discount else 0,
storeUrl = storeLink, developer = p.optJSONArray("developers")?.optString(0, "").orEmpty(),
publisher = p.optJSONArray("publishers")?.optString(0, "").orEmpty(),
releaseDate = p.optString("releaseDate", ""),
extra = buildMap { if (slug.isNotBlank()) put("slug", slug) },
mature = mature,
@@ -175,7 +176,7 @@ object GogStoreCatalog {
private fun itemToJson(i: CatalogItem): JSONObject = JSONObject().apply {
put("id", i.id); put("title", i.title); put("image", i.imageUrl ?: ""); put("tall", i.tallImageUrl ?: "")
put("tags", i.tags); put("free", i.isFree); put("hasPrice", i.hasPrice); put("final", i.finalPrice); put("orig", i.originalPrice)
put("disc", i.discountPercent); put("url", i.storeUrl); put("dev", i.developer); put("rel", i.releaseDate)
put("disc", i.discountPercent); put("url", i.storeUrl); put("dev", i.developer); put("pub", i.publisher); put("rel", i.releaseDate)
if (i.mature) put("mature", true)
}
@@ -184,7 +185,7 @@ object GogStoreCatalog {
imageUrl = o.optString("image").ifBlank { null }, tallImageUrl = o.optString("tall").ifBlank { null },
tags = o.optString("tags"), isFree = o.optBoolean("free"), hasPrice = o.optBoolean("hasPrice"),
finalPrice = o.optString("final"), originalPrice = o.optString("orig"), discountPercent = o.optInt("disc"),
storeUrl = o.optString("url"), developer = o.optString("dev"), releaseDate = o.optString("rel"),
storeUrl = o.optString("url"), developer = o.optString("dev"), publisher = o.optString("pub"), releaseDate = o.optString("rel"),
mature = o.optBoolean("mature"),
)
@@ -0,0 +1,96 @@
package com.droiddeck.launcher.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.droiddeck.launcher.R
import com.droiddeck.launcher.session.SessionEvents
import com.droiddeck.launcher.stores.AchievementNotifications
import kotlinx.coroutines.delay
@Composable
internal fun AchievementNotificationOverlay() {
val unlock = AchievementNotifications.pending.firstOrNull()
var visible by remember(unlock) { mutableStateOf(false) }
LaunchedEffect(unlock) {
if (unlock == null) return@LaunchedEffect
visible = true
SessionEvents.record("achievement.notification-shown", mapOf("achievementId" to unlock.id))
delay(5_000)
visible = false
delay(Motion.ms(220).toLong())
AchievementNotifications.dismiss(unlock)
}
Box(Modifier.fillMaxSize().padding(20.dp), contentAlignment = Alignment.BottomEnd) {
AnimatedVisibility(
visible, enter = fadeIn(Motion.tw(220)) + slideInVertically(Motion.tw(300)) { it / 2 },
exit = fadeOut(Motion.tw(180)) + slideOutVertically(Motion.tw(220)) { it / 3 },
) { if (unlock != null) AchievementNotificationCard(unlock) }
}
}
@Composable
private fun AchievementNotificationCard(unlock: AchievementNotifications.Unlock) {
val pal = LocalPalette.current
val shape = RoundedCornerShape(16.dp)
Row(
Modifier.widthIn(max = 360.dp).clip(shape).background(pal.surface.copy(alpha = 0.97f))
.border(1.dp, pal.signal.copy(alpha = 0.65f), shape).padding(14.dp)
.testTag("achievement-notification"),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(Modifier.size(48.dp).clip(RoundedCornerShape(10.dp)).background(pal.surfaceVariant), contentAlignment = Alignment.Center) {
Trophy(26.dp, description = null)
if (unlock.icon != null) AsyncImage(unlock.icon, null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
Trophy(13.dp, description = null)
Text(stringResource(R.string.achievement_unlocked).uppercase(), fontSize = 10.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.8.sp, color = pal.signal)
}
Text(unlock.name, color = pal.onBackground, fontSize = 15.sp, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis)
if (unlock.game.isNotBlank()) Text(unlock.game, color = pal.onSurfaceVariant, fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
@Preview(widthDp = 420, heightDp = 150)
@Composable
private fun AchievementNotificationPreview() = DroidDeckTheme {
Box(Modifier.padding(20.dp)) {
AchievementNotificationCard(AchievementNotifications.Unlock("1", "first", "First Steps", "", null, "Hollow Valley"))
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,455 @@
package com.droiddeck.launcher.ui
import android.os.Build
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.Image
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material.icons.outlined.DesktopWindows
import androidx.compose.material.icons.outlined.Storefront
import androidx.compose.material.icons.outlined.Terminal
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
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.zIndex
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.runtime.UserApps
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.store.UserAppsState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
// The Desktop tab in the Steam tab's shape: a tilted wall of little windows behind the word and
// one big Open desktop, with Play's lip and lifted sheet under it (Display, Controls, Session and
// Files). Under the hero the page scrolls to two shelves - the apps that run in the desktop, and
// what can be added - and the hero folds into a slim header as it goes.
/** The desktop's own chips: its session settings, then Files. */
private val DESKTOP_TABS = modeSheetTabs(steam = false) + SheetTab.FILES
@Composable
internal fun DesktopPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier, onSelect: (String) -> Unit) {
val ctx = LocalContext.current
val density = LocalDensity.current
val narrow = LocalNarrowPane.current
val colors = MaterialTheme.colorScheme
LaunchedEffect(Unit) { UserAppsState.refresh(ctx) }
// Flathub's installed apps sit with the others on the dock.
LaunchedEffect(s.ready) { com.droiddeck.launcher.store.StoreState.refresh(ctx) }
val installed = s.emulators.filter { it.installed }
val apps = UserAppsState.items
var adding by rememberSaveable { mutableStateOf(false) }
val mode = SessionService.MODE_DESKTOP
val tabs = DESKTOP_TABS.map { it to sheetTabLabel(it) }
val open = if (s.desktopInstalled) stringResource(R.string.content_open_desktop) else stringResource(R.string.content_install_open_desktop)
// Opened from a card: the page floods out of it, its picture on the tile.
fun openFrom(at: Rect?, art: Any?, page: String) {
at?.let { PageOrigin.mark(Origin(it, 16f * density.density, art = art)) }
onSelect(page)
}
// The Steam tab's shape, the whole pane: the dock of apps along the top of the wall, the word
// and Open desktop at the foot, and nothing under the settings sheet but the hero itself.
LiftHero(
key = "desktop", tabs = tabs,
onOpen = a.onDesktopSettings, onPrimary = a.onDesktop,
modifier = modifier.clipToBounds(),
start = if (narrow) 20.dp else 44.dp, bottom = if (narrow) 20.dp else 44.dp,
background = {
DesktopWall(installed.map { it.iconRes } + apps.mapNotNull { it.icon })
},
scrim = { lifted ->
WallFade()
// Over the fades, so the cards read at full strength; gone as the sheet lifts, so
// nothing but the wall is behind it.
Box(Modifier.graphicsLayer { val v = lifted(); alpha = 1f - v; translationY = -12.dp.toPx() * v }) {
DesktopDock(s, a, installed, apps, openFrom = ::openFrom)
}
},
words = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
// The word at the wordmark's size.
Text(
stringResource(R.string.rail_desktop), fontSize = if (narrow) 30.sp else 42.sp, lineHeight = if (narrow) 34.sp else 46.sp,
fontWeight = FontWeight.Black, color = colors.onBackground,
)
BusyChip(s)
}
},
button = { click ->
// Enabled without a runtime or the desktop: the session's loading screen installs them first.
PrimaryButton(open, enabled = !s.busy, main = true, large = true, icon = Icons.Outlined.DesktopWindows, onClick = click)
},
beside = {
// Flathub, whose apps run in the desktop: its storefront floods out of this button.
HeroIconButton(Icons.Outlined.Storefront, stringResource(R.string.flathub_name), FlathubTeal, "desktop:flathub") { at -> openFrom(at, null, "store") }
// Add your own app or game.
HeroIconButton(Icons.Filled.Add, stringResource(R.string.user_apps_add), null, "desktop:add") { if (!s.busy) adding = true }
},
rows = { tab ->
if (tab == SheetTab.FILES) DesktopFiles(s, a)
else a.modeSheet(mode, tab, SheetExtras())
},
)
if (adding && !s.busy) AddAppDialog(s.ready, onDismiss = { adding = false }) { request, label -> UserAppsState.add(ctx, request, label) }
}
/**
* The dock along the top of the Desktop hero: what runs in the desktop (emulators installed, apps
* added, Flathub's installed apps), then the emulators that can be added - one
* row of cards that scrolls sideways, over the wall. It sinks back with the wall as the sheet lifts.
*/
@Composable
private fun DesktopDock(
s: FrontEndState, a: FrontEndActions, installed: List<Library.Emulator>, apps: List<UserApps.App>,
openFrom: (Rect?, Any?, String) -> Unit,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val narrow = LocalNarrowPane.current
val available = s.emulators.filter { !it.installed }
val flatpaks = com.droiddeck.launcher.store.StoreState.installed
val keys = installed.map { "emu:${it.id}" } + apps.map { "user:${it.key}" } + flatpaks.map { "flatpak:${it.id}" }
// Where the last of your apps sits, for a new emulator's dot to land after it.
var lastAt by remember { mutableStateOf<Rect?>(null) }
var dockAt by remember { mutableStateOf<Rect?>(null) }
val seen = remember { mutableSetOf<String>() }
val mounted = remember { booleanArrayOf(false) }
LaunchedEffect(Unit) { mounted[0] = true }
val scroll = rememberScrollState()
// Nothing yet (the emulators' catalog still on its way): no heading over an empty row.
if (keys.isEmpty() && available.isEmpty() && UserAppsState.working == null && UserAppsState.lastError == null) return
Column(Modifier.fillMaxWidth().padding(top = if (narrow) 14.dp else 22.dp)) {
Text(
stringResource(R.string.user_apps_section).uppercase(), fontSize = 11.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.6.sp,
color = colors.onSurfaceVariant, modifier = Modifier.padding(start = if (narrow) 20.dp else 44.dp, bottom = 8.dp),
)
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top,
modifier = Modifier.fillMaxWidth().onGloballyPositioned { dockAt = it.boundsInRoot() }
.edgeFade(scroll).horizontalScroll(scroll).padding(start = if (narrow) 20.dp else 44.dp, end = 20.dp, top = 2.dp, bottom = 4.dp),
) {
keys.forEachIndexed { i, k ->
key(k) {
// A card that arrives after the dock is up (an emulator just installed) opens in.
val state = remember { MutableTransitionState(!mounted[0] || k in seen) }.apply { targetState = true }
LaunchedEffect(Unit) { seen += k }
AnimatedVisibility(state, enter = expandHorizontally(Motion.sp(0.75f)) + fadeIn(Motion.tw(300, 80))) {
Box(if (i == keys.lastIndex) Modifier.onGloballyPositioned { lastAt = it.boundsInRoot() } else Modifier) {
val e = installed.firstOrNull { "emu:${it.id}" == k }
val flatpak = flatpaks.firstOrNull { "flatpak:${it.id}" == k }
val app = apps.firstOrNull { "user:${it.key}" == k }
if (e != null) {
val system = if (e.systemRes != 0) stringResource(e.systemRes) else e.system.replaceFirstChar { c -> c.uppercase() }
DesktopCard(
k, e.name,
if (e.id == "retroarch") system else "$system · " + pluralStringResource(R.plurals.mode_added_count, e.games.size, e.games.size),
art = e.iconRes, first = i == 0,
) { at -> openFrom(at, e.iconRes, k) }
} else if (flatpak != null) DesktopCard(
k, flatpak.name, flatpak.summary ?: stringResource(R.string.flathub_name),
art = flatpak.icon ?: com.droiddeck.launcher.store.FlathubApi.iconUrl(flatpak.id), first = i == 0,
) { at ->
// Its page on Flathub: open, update or remove it there.
com.droiddeck.launcher.store.StoreState.openRequest = flatpak.id
openFrom(at, flatpak.icon, "store")
} else if (app != null) DesktopCard(k, app.name, stringResource(app.kind.label()), art = app.icon, fallback = if (app.kind == UserApps.Kind.SCRIPT) Icons.Outlined.Terminal else Icons.Outlined.Apps, first = i == 0) { at ->
openFrom(at, app.icon, k)
}
}
}
}
}
// Between yours and what can be added: a thin rule.
if (keys.isNotEmpty()) Box(Modifier.padding(horizontal = 4.dp).width(1.dp).height(120.dp).background(pal.line2))
available.forEach { e ->
key(e.id) {
val pkg = Library.packageId(e.id)?.let { id -> s.packages?.firstOrNull { it.id == id } }
val busyHere = pkg != null && s.packageBusyId == pkg.id
val canGet = pkg != null && !s.busy && s.packageBusyId == null && s.ready && !s.packageCatalogLoading && !s.sessionRunning
val system = if (e.systemRes != 0) stringResource(e.systemRes) else e.system.replaceFirstChar { c -> c.uppercase() }
DesktopCard(
"emu:${e.id}", e.name, if (busyHere) (s.packageStage ?: stringResource(R.string.store_installing)) else system,
art = e.iconRes, badge = if (busyHere) null else stringResource(R.string.comp_get),
progress = if (busyHere) s.packagePercent else null, first = keys.isEmpty() && e == available.first(),
) { at ->
// Get installs it there and then, and a dot flies to where its card will be;
// when it cannot (no runtime, a session running), its page says why.
if (canGet && pkg != null && at != null) {
a.onInstallPackage(pkg.id)
val end = lastAt
val row = dockAt
Flights.fly(Flight(at, to = {
val r = row ?: return@Flight null
Offset((end?.right ?: r.left) + 70.dp.toPx(), r.center.y)
}))
} else openFrom(at, e.iconRes, "emu:${e.id}")
}
}
}
}
if (UserAppsState.working != null || UserAppsState.lastError != null) {
Box(Modifier.padding(start = if (narrow) 20.dp else 44.dp, end = 20.dp, top = 6.dp)) { UserAppsProgressLine() }
}
}
}
/**
* A square button the big one's height beside it on the hero: an icon in [tint] (the palette's
* signal when null). [onClick] gets its bounds, for a page to flood out of.
*/
@Composable
private fun HeroIconButton(icon: androidx.compose.ui.graphics.vector.ImageVector, description: String, tint: Color?, id: String, onClick: (Rect?) -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val pressed by src.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.95f else if (hot) 1.04f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "heroIcon")
val shape = RoundedCornerShape(12.dp)
val accent = tint ?: pal.signal
var at by remember { mutableStateOf<Rect?>(null) }
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(56.dp).paneItem("btn:$id").onGloballyPositioned { at = it.boundsInRoot() }
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(shape).background(if (hot) accent.copy(alpha = 0.18f) else colors.surface.copy(alpha = 0.92f))
.glideBorder(hot, shape, accent, pal.line2)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button) { onClick(at) }
.controllerConfirm { onClick(at) }
.semantics { contentDescription = description },
) { Icon(icon, null, tint = accent, modifier = Modifier.size(26.dp)) }
}
/**
* The hero's wall: little windows tilted and drifting as Steam's capsules do, each a window with a
* bar along its foot and an emulator's or app's icon inside; blank windows when there are none.
*/
@Composable
private fun DesktopWall(icons: List<Any>) {
val pal = LocalPalette.current
val ground = remember(pal) { Brush.linearGradient(listOf(pal.surfaceVariant, pal.surface)) }
TiltedWall(icons.size, if (icons.isEmpty()) 75_000 else 50_000, capW = 132.dp, capH = 84.dp, gap = 14.dp, corner = 8.dp) { i ->
WallCapsule(ground, edge = pal.line2, icon = icons.getOrNull(i), bar = pal.background.copy(alpha = 0.7f))
}
}
/**
* One thing that runs in the desktop: its icon over a blurred, darkened copy of itself, a status on
* the art (Get, or how far an install is), and under it its name on up to two lines and what it
* is. [onClick] gets the art's bounds, for a page to flood out of or a dot to fly from.
*/
@Composable
private fun DesktopCard(
key: String, name: String, detail: String, art: Any?,
fallback: androidx.compose.ui.graphics.vector.ImageVector = Icons.Outlined.Apps,
first: Boolean = false, badge: String? = null, progress: Int? = null, accent: Boolean = false, tint: Color? = null,
onClick: (Rect?) -> Unit,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val pressed by src.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.96f else if (hot) 1.03f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "deskCard")
val shape = RoundedCornerShape(16.dp)
var artAt by remember { mutableStateOf<Rect?>(null) }
val width = if (LocalNarrowPane.current) 116.dp else 132.dp
val artH = width * 0.62f
val ground = tint?.copy(alpha = 0.25f) ?: if (accent) pal.signal.copy(alpha = 0.12f) else colors.surfaceVariant
Column(
Modifier.width(width).paneItem("tile:desktop:$key").then(if (first) Modifier.firstTile() else Modifier)
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(shape).background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
.glideBorder(hot, shape, pal.signal, pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button) { onClick(artAt) }
.controllerConfirm { onClick(artAt) }
.semantics { contentDescription = name },
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth().height(artH).onGloballyPositioned { artAt = it.boundsInRoot() }
.clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)).background(ground),
) {
when (art) {
is Int -> {
Image(
painterResource(art), null, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().graphicsLayer { scaleX = 1.8f; scaleY = 1.8f; alpha = 0.5f }
.then(if (Build.VERSION.SDK_INT >= 31) Modifier.blur(18.dp) else Modifier),
)
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.35f)))
Image(painterResource(art), null, modifier = Modifier.size(artH * 0.6f))
}
null -> Icon(fallback, null, tint = tint ?: if (accent) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(artH * 0.42f))
else -> {
AsyncImage(
model = art, contentDescription = null, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().graphicsLayer { scaleX = 1.8f; scaleY = 1.8f; alpha = 0.5f }
.then(if (Build.VERSION.SDK_INT >= 31) Modifier.blur(18.dp) else Modifier),
)
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.35f)))
AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.size(artH * 0.6f))
}
}
Box(Modifier.align(Alignment.TopEnd).padding(6.dp)) {
if (progress != null) Box(Modifier.clip(RoundedCornerShape(99.dp)).background(Color.Black.copy(alpha = 0.55f)).padding(4.dp)) {
if (progress >= 0) CircularProgressIndicator(progress = { progress / 100f }, strokeWidth = 2.dp, color = pal.signal, trackColor = pal.signal.copy(alpha = 0.25f), modifier = Modifier.size(16.dp))
else CircularProgressIndicator(strokeWidth = 2.dp, color = pal.signal, modifier = Modifier.size(16.dp))
}
else if (badge != null) Text(
badge, fontSize = 11.sp, fontWeight = FontWeight.Bold, color = pal.onSignal, maxLines = 1,
modifier = Modifier.clip(RoundedCornerShape(99.dp)).background(pal.signal).padding(horizontal = 8.dp, vertical = 2.dp),
)
}
}
Column(Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 8.dp, bottom = 10.dp)) {
Text(name, fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold, lineHeight = 17.sp, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(detail, fontSize = 12.sp, lineHeight = 15.sp, color = colors.onSurfaceVariant, minLines = 2, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 3.dp))
}
}
}
/**
* The desktop sheet's Files chip: the file manager and the ROMs folder (both from Setup's old
* tools), how much the desktop takes, and Uninstall - its only home - which asks in red first.
*/
@Composable
private fun DesktopFiles(s: FrontEndState, a: FrontEndActions) {
val ctx = LocalContext.current
SettingsGroup(stringResource(R.string.setup_tool_files)) {
SettingsRow(stringResource(R.string.setup_tool_files), stringResource(R.string.setup_tool_files_hint)) {
SecondaryButton(stringResource(R.string.store_open), compact = true, onClick = a.onFiles)
}
SettingsRow(stringResource(R.string.setup_tool_roms), s.romsDir ?: stringResource(R.string.setup_tool_roms_hint)) {
SecondaryButton(stringResource(R.string.setup_choose), compact = true, onClick = a.onRoms)
}
}
if (s.desktopPresent) SettingsGroup(stringResource(R.string.content_linux_desktop)) {
val size by produceState<Long?>(null) { value = withContext(Dispatchers.IO) { runCatching { com.droiddeck.launcher.runtime.DesktopCatalog.desktopSize(ctx) }.getOrNull() } }
SettingsRow(stringResource(R.string.desktop_size), size?.let { FileUtils.sizeToString(it) } ?: "…") {}
SettingsRow(stringResource(R.string.desktop_remove_title), stringResource(R.string.desktop_remove_text)) {
UninstallDesktop(enabled = !s.runtimeActionsBlocked, onUninstall = a.onRemoveDesktop)
}
}
}
/** Uninstall the desktop: are you sure steps out of it in red, Keep first and focused. */
@Composable
private fun UninstallDesktop(enabled: Boolean, onUninstall: () -> Unit) {
val colors = MaterialTheme.colorScheme
val label = stringResource(R.string.common_uninstall)
val title = stringResource(R.string.desktop_uninstall_title)
val note = stringResource(R.string.desktop_remove_text)
val keep = stringResource(R.string.flathub_keep)
var at by remember { mutableStateOf<Rect?>(null) }
Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) {
SecondaryButton(label, enabled = enabled, compact = true) {
val from = at ?: return@SecondaryButton
Steps.ask(StepAsk(
anchor = from, side = StepSide.Left, accent = colors.error, pillCorner = 12.dp, items = 3,
handle = { Text(label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.error, maxLines = 1) },
) {
StepTitle(title)
Text(note, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.stepItem(1).padding(start = 4.dp, bottom = 12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), modifier = Modifier.fillMaxWidth().stepItem(2)) {
StepChoice(keep, enabled = open, modifier = Modifier.focusRequester(first)) { Steps.fold() }
StepChoice(label, danger = true, enabled = open) { Steps.fold(); onUninstall() }
}
})
}
}
}
/** The add or remove under way, or why the last one failed. */
@Composable
private fun UserAppsProgressLine() {
val colors = MaterialTheme.colorScheme
val working = UserAppsState.working
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
if (working != null) {
val stage = UserAppsState.stage ?: stringResource(R.string.user_apps_starting)
val percent = UserAppsState.percent
Text(
if (percent >= 0) stringResource(R.string.user_apps_progress_percent, working, stage, percent) else stringResource(R.string.user_apps_progress, working, stage),
fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
if (percent >= 0) androidx.compose.material3.LinearProgressIndicator(progress = { percent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp))
else androidx.compose.material3.LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp))
} else UserAppsState.lastError?.let { Note(it) }
}
}
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.ui
import com.droiddeck.launcher.R
import androidx.compose.ui.res.stringResource
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.background
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -42,6 +43,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.first
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
@@ -84,11 +90,19 @@ fun DriverPage(
// D-pad: up from the first driver lands on refresh; the page opens with the driver in use focused.
val refreshFocus = remember { FocusRequester() }
val selectedFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { runCatching { selectedFocus.requestFocus() } }
val inputMode = LocalInputModeManager.current
var pageFocused by remember { mutableStateOf(false) }
LaunchedEffect(inputMode.inputMode) {
if (inputMode.inputMode != InputMode.Keyboard) return@LaunchedEffect
// The chooser holds controller focus until its closing animation finishes.
snapshotFlow { Steps.step }.first { it == null }
focusWithinFrames({ pageFocused }) { selectedFocus }
}
val refreshSrc = remember { MutableInteractionSource() }
val refreshHot = refreshSrc.collectIsFocusedAsState().value || refreshSrc.collectIsHoveredAsState().value
SettingsPage(
host, title = title, onBack = onBack, lede = "$hint\n$status",
modifier = Modifier.onFocusChanged { pageFocused = it.hasFocus }.focusGroup(),
action = {
Box(
contentAlignment = Alignment.Center,
@@ -3,7 +3,6 @@ package com.droiddeck.launcher.ui
import android.view.View
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
@@ -23,6 +22,7 @@ import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
@@ -58,6 +58,8 @@ internal class GlideSource {
var shape: Shape by mutableStateOf<Shape>(androidx.compose.ui.graphics.RectangleShape)
var color by mutableStateOf(Color.Transparent)
var width by mutableStateOf(2.dp)
/** Focuses the control again: a step-out hands focus back to the control it came out of. */
val requester = androidx.compose.ui.focus.FocusRequester()
}
internal class FocusGlide(val view: View) {
@@ -135,15 +137,18 @@ internal fun Modifier.glideBorder(
onDispose { glide.release(src) }
}
val base = if (restColor.alpha > 0f) border(restWidth, restColor, shape) else this
return base.onGloballyPositioned { src.coords = it; glide.moved(src) }
return base.focusRequester(src.requester).onGloballyPositioned { src.coords = it; glide.moved(src) }
}
/** Leading edge on a focus move: quick and a little loose. */
private fun lead(): AnimationSpec<Float> = Motion.sp(0.62f, 700f)
/** A spring an edge chases its target on: damping ratio and stiffness, as Compose's. */
private class Edge(val damping: Float, val stiffness: Float)
private fun lead() = Edge(0.62f, 700f)
/** Trailing edge: slower, settles without a bounce to speak of. */
private fun trail(): AnimationSpec<Float> = Motion.sp(0.78f, 360f)
private fun trail() = Edge(0.78f, 360f)
/** Following a control that moves under the ring (a scroll): tight, no wobble. */
private fun follow(): AnimationSpec<Float> = Motion.sp(1f, 2400f)
private fun follow() = Edge(1f, 2400f)
/** How long after a focus move the ring keeps its stretchy springs and keeps checking where it is. */
private const val SETTLE_MS = 450L
@@ -152,6 +157,9 @@ private const val STILL_MS = 120L
/** The longest the ring keeps checking after a move, for a control that never stands still. */
private const val TRACK_MAX_MS = 2_000L
/** How strongly the ring tints what it holds. */
private const val RING_FILL = 0.14f
/** Moves closer together than this are a held direction: the ring runs along as one, no stretch. */
private const val REPEAT_MS = 180L
@@ -174,8 +182,16 @@ internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable
val solid = remember { Animatable(0f) }
val alpha = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
// An edge each, the corner, and a droplet carrying all of them.
val jobs = remember { arrayOfNulls<Job>(6) }
// An edge each, the corner, a droplet carrying all of them, its solid fill, and the chase.
val jobs = remember { arrayOfNulls<Job>(8) }
// The chase: each edge on a spring of its own toward where focus is now. A new target only
// moves where it is heading - restarting an animation on every focus move started it from
// its first frame (which does not move), so a held direction over a busy page (each rail item
// swaps the page) left the ring standing while focus ran on ahead.
val target = remember { FloatArray(4) }
val vel = remember { FloatArray(4) }
val springs = remember { Array(4) { follow() } }
val startAt = remember { LongArray(4) }
val cur = glide.current
var lastColor by remember { mutableStateOf(Color.Transparent) }
if (cur != null) lastColor = cur.color
@@ -186,20 +202,53 @@ internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable
LaunchedEffect(glide, Motion.scale) {
var shown: GlideSource? = null
var movedAt = 0L
var dirSpecs: List<AnimationSpec<Float>> = List(4) { follow() }
var dirSpecs: List<Edge> = List(4) { follow() }
var delays = LongArray(4)
var lastMove = 0L
fun go(i: Int, v: Float, spec: AnimationSpec<Float>, wait: Long) {
fun go(i: Int, v: Float, spec: Edge, wait: Long) {
// A drop cut short by another move turns back into an outline on the way.
if (jobs[5]?.isActive == true || solid.targetValue > 0f) scope.launch { solid.animateTo(0f, Motion.tw(120)) }
jobs[5]?.cancel()
// Picks up the speed it is going at: restarting from rest on every step of a held
// direction is what made it stutter.
val speed = edges[i].velocity
jobs[i]?.cancel()
jobs[i] = scope.launch {
if (wait > 0) delay(Motion.ms(wait.toInt()).toLong())
edges[i].animateTo(v, spec, initialVelocity = if (wait > 0) 0f else speed)
if (jobs[5]?.isActive == true || solid.targetValue > 0f) {
jobs[5]?.cancel()
jobs[6]?.cancel()
jobs[6] = scope.launch { solid.animateTo(0f, Motion.tw(120)) }
}
val chasing = jobs[7]?.isActive == true
// An edge some other animation was moving (the appear, a drop's opening) is taken over
// at the speed it was going.
jobs[i]?.let { if (it.isActive) { it.cancel(); vel[i] = edges[i].velocity } }
if (!chasing) for (j in 0..3) if (jobs[j]?.isActive != true) vel[j] = edges[j].velocity
target[i] = v
springs[i] = spec
startAt[i] = System.nanoTime() + Motion.ms(wait.toInt()) * 1_000_000L
if (chasing) return
jobs[7] = scope.launch {
var last = withFrameNanos { it }
while (true) {
val frame = withFrameNanos { it }
// Real time, however long the frame took, in small steps so a stiff spring holds.
val dt = ((frame - last) / 1e9f).coerceAtMost(0.1f) / Motion.scale.coerceAtLeast(0.01f)
last = frame
val now = System.nanoTime()
var settled = true
for (j in 0..3) {
if (now < startAt[j]) { settled = false; continue }
val k = springs[j].stiffness
val c = 2f * springs[j].damping * kotlin.math.sqrt(k)
var x = edges[j].value
var u = vel[j]
var left = dt
while (left > 0f) {
val h = minOf(left, 0.004f)
u += (-k * (x - target[j]) - c * u) * h
x += u * h
left -= h
}
if (abs(x - target[j]) < 0.3f && abs(u) < 3f) { x = target[j]; u = 0f } else settled = false
vel[j] = u
edges[j].snapTo(x)
}
if (settled) break
}
}
}
/** Pinch into a solid dot where it is, carry it over, and open it into [b] as it arrives. */
@@ -270,6 +319,8 @@ internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable
// Focus passing between two controls can let go of one a moment before the next
// takes it: wait a beat, so that reads as a move and not a fade out and back in.
delay(60)
jobs.forEach { it?.cancel() }
solid.snapTo(0f)
scope.launch { alpha.animateTo(0f, Motion.tw(140)) }
shown = null
return@collectLatest
@@ -296,7 +347,7 @@ internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable
val grown = listOf(b.left - out, b.top - out, b.right + out, b.bottom + out)
jobs.forEach { it?.cancel() }
scope.launch { solid.snapTo(0f); edges.forEachIndexed { i, e -> e.snapTo(grown[i]) } }.join()
dirSpecs = List(4) { Motion.sp(0.6f, 700f) }
dirSpecs = List(4) { Edge(0.6f, 700f) }
delays = LongArray(4)
cornerTo(src, b, snap = true)
scope.launch { alpha.animateTo(1f, Motion.tw(140)) }
@@ -315,8 +366,8 @@ internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable
if (dropped) droplet(src, b)
else {
// A held direction runs along as one piece; a single press stretches.
dirSpecs = if (held) List(4) { Motion.sp(0.9f, 900f) }
else List(4) { i -> when (i) { leadIdx -> lead(); trailIdx -> trail(); else -> Motion.sp(0.7f, 520f) } }
dirSpecs = if (held) List(4) { Edge(0.9f, 900f) }
else List(4) { i -> when (i) { leadIdx -> lead(); trailIdx -> trail(); else -> Edge(0.7f, 520f) } }
delays = LongArray(4) { i -> if (i == trailIdx && !held) 30L else 0L }
cornerTo(src, b, snap = false)
}
@@ -359,10 +410,18 @@ internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable
color, topLeft = Offset(l, t), size = Size(r - l, b - t),
cornerRadius = CornerRadius(corner.value), alpha = a * f,
)
if (f < 0.99f) drawRoundRect(
color, topLeft = Offset(l + w / 2f, t + w / 2f), size = Size(sw, sh),
cornerRadius = CornerRadius(rad), style = Stroke(w), alpha = a,
)
if (f < 0.99f) {
// The ring's own infill: the only fill a pad sees, so it is always exactly what
// the ring holds and moves with it (a row's own sliding fill trailed it).
drawRoundRect(
color, topLeft = Offset(l + w / 2f, t + w / 2f), size = Size(sw, sh),
cornerRadius = CornerRadius(rad), alpha = a * (1f - f) * RING_FILL,
)
drawRoundRect(
color, topLeft = Offset(l + w / 2f, t + w / 2f), size = Size(sw, sh),
cornerRadius = CornerRadius(rad), style = Stroke(w), alpha = a,
)
}
},
) {
CompositionLocalProvider(LocalFocusGlide provides glide) { content() }
@@ -56,9 +56,21 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import coil.compose.AsyncImage
import java.io.File
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
// Cover art: posters, the art grid and its tiles.
/** Where each art tile sits, root px, by its key: a page opened from one floods out of it. */
internal object TileBounds {
val at = HashMap<String, androidx.compose.ui.geometry.Rect>()
/** The page about to open came from tile [key] (its art or icon for the flood), if it is on screen. */
fun mark(key: String, art: Any?, corner: Float) {
at[key]?.let { PageOrigin.mark(Origin(it, corner, art = art)) }
}
}
internal class Tile(
val title: String, val sub: String?, val art: File?, val key: String,
val iconRes: Int? = null, val dim: Boolean = false, val iconBitmap: Bitmap? = null,
@@ -102,6 +114,7 @@ internal fun ArtGrid(tiles: List<Tile>, wide: Boolean = false) {
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val track = Modifier.paneItem("tile:" + t.key).then(if (t === tiles.first()) Modifier.firstTile() else Modifier)
.onGloballyPositioned { TileBounds.at[t.key] = it.boundsInRoot() }
Box(modifier = Modifier.width(tileWidth).zIndex(if (hot) 1f else 0f)) { GameTile(t, wide, square, src, hot, track) }
}
}
@@ -76,6 +76,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
@@ -124,15 +126,30 @@ internal fun Pane(
val paneAt = remember { arrayOf(Offset.Zero) }
val livePage by rememberUpdatedState(s.pageKey)
// Picking another game changes the detail beside the list, not the whole page.
val target = if (page != null && s.pageKey != null) s.pageKey else if (selected.startsWith("app:")) "games" else selected
val railContent = if (selected.startsWith("app:")) "games" else selected
val target = if (page != null && s.pageKey != null) s.pageKey else railContent
// The game the Games page shows. It must outlive the selection: while the page animates out,
// drawing it with what is selected now (another rail item) turned it into a copy of that page.
val gameShown = remember { arrayOf("games") }
if (selected == "games" || selected.startsWith("app:")) gameShown[0] = selected
// What the pane showed before this change: the content a page opened over belongs to it.
val before = remember { arrayOf(target) }
if (page != null && s.pageKey != null) {
pages[s.pageKey] = page
if (s.pageKey !in origins) PageOrigin.take()?.let { origins[s.pageKey] = it.translate(-paneAt[0]) }
if (s.pageKey !in origins) PageOrigin.take()?.let { o ->
// A hop's page came out of its own rail item, and is that item's content.
origins[s.pageKey] = inPane(o.translate(-paneAt[0])).let { if (it.owner == null) it.ownedBy(railContent) else it }
}
} else if (target != before[0] && target !in origins) {
// A section's own page opened from a control in it (an emulator's card, a ROM's) floods
// out of that control and belongs to what was showing; a hop's comes out of its rail item.
PageOrigin.take()?.let { o -> origins[target] = inPane(o.translate(-paneAt[0])).let { if (it.owner == null) it.ownedBy(before[0]) else it } }
}
before[0] = target
val targetNow by rememberUpdatedState(target)
// A page leaving through a hop's link is not coming back to its control.
val leave = if (Hops.linking) PageLeave.Link else PageLeave.Ordinary
LaunchedEffect(target) { Hops.linking = false }
AnimatedContent(
targetState = target,
modifier = Modifier.onGloballyPositioned { paneAt[0] = it.positionInRoot() },
@@ -143,9 +160,14 @@ internal fun Pane(
.togetherWith(fadeOut(Motion.tw(320, 80)) + scaleOut(Motion.tw(480), targetScale = 0.95f))
.apply { targetContentZIndex = 1f }
// Back out of it: the flood draws back into the control as the pane comes forward again.
initialState in origins -> (fadeIn(Motion.tw(300, 100)) + scaleIn(Motion.tw(460, 40), initialScale = 0.95f))
.togetherWith(ExitTransition.None)
.apply { targetContentZIndex = -1f }
initialState in origins && pageExit(origins[initialState]?.owner, targetState, leave) == PageExit.Drain ->
(fadeIn(Motion.tw(300, 100)) + scaleIn(Motion.tw(460, 40), initialScale = 0.95f))
.togetherWith(ExitTransition.None)
.apply { targetContentZIndex = -1f }
// Away to something else: the control is not coming back, so the page sinks with the pane.
initialState in origins -> (fadeIn(Motion.tw(300, 80)) + slideInVertically(Motion.tw(420, 80)) { it / 24 })
.togetherWith(fadeOut(Motion.tw(170)) + scaleOut(Motion.tw(170), targetScale = 0.95f))
.apply { targetContentZIndex = 1f }
else -> (fadeIn(Motion.tw(300, 80)) + slideInVertically(Motion.tw(420, 80)) { it / 24 })
.togetherWith(fadeOut(Motion.tw(170)) + slideOutVertically(Motion.tw(170)) { -it / 40 })
.apply { targetContentZIndex = 1f }
@@ -153,26 +175,51 @@ internal fun Pane(
},
label = "pane",
) { key ->
val shown = if (page != null && key == s.pageKey) page else pages[key]
if (shown != null) {
DisposableEffect(key) { onDispose { if (key != livePage) { pages.remove(key); origins.remove(key) } } }
val drawn = (if (page != null && key == s.pageKey) page else pages[key])
?: { Content(s, if (key == "games") gameShown[0] else key, a, Modifier.fillMaxSize(), onSelect, onAndroidAppClick, onOpenDeveloperOptions, onRequestWirelessAdb) }
// A hop leaving this content: it sinks back as the dot leaves it (and comes forward
// again, freshly drawn, when the hop is undone).
val hopSink by androidx.compose.animation.core.animateFloatAsState(
if (Hops.sinking == key) 1f else 0f, Motion.tw(260), label = "hopSink",
)
val shown: @Composable () -> Unit = {
Box(Modifier.fillMaxSize().graphicsLayer { val k = 1f - 0.05f * hopSink; scaleX = k; scaleY = k; alpha = 1f - 0.35f * hopSink }) { drawn() }
}
run {
DisposableEffect(key) { onDispose { if (key != livePage && key != targetNow) { pages.remove(key); origins.remove(key) } } }
val from = origins[key]
if (from == null) shown()
else {
// The control's own picture (a card's art, an emulator's icon) on the tile as it grows.
val art = when (val m = from.art) {
null -> null
is Int -> androidx.compose.ui.res.painterResource(m)
else -> coil.compose.rememberAsyncImagePainter(m)
}
val leaving = transition.targetState == EnterExitState.PostExit
// Settled as the page starts to leave, from what is coming in, not from where it came in.
val drains = remember(leaving) { !leaving || pageExit(from.owner, target, leave) == PageExit.Drain }
// Keeps a leaving page on screen while its flood draws back into the control.
transition.animateFloat(
transitionSpec = { if (targetState == EnterExitState.PostExit) Motion.tw(PAGE_RETURN_MS) else snap() },
transitionSpec = { if (targetState == EnterExitState.PostExit && drains) Motion.tw(PAGE_RETURN_MS) else snap() },
label = "pageReturn",
) { if (it == EnterExitState.PostExit) 1f else 0f }
PageFlood(from, leaving = transition.targetState == EnterExitState.PostExit) { shown() }
PageFlood(from, leaving = leaving, art = art, sinks = !drains) { shown() }
}
}
else Content(s, if (key == "games") gameShown[0] else key, a, Modifier.fillMaxSize(), onSelect, onAndroidAppClick, onOpenDeveloperOptions, onRequestWirelessAdb)
}
}
}
}
/**
* An origin left of the pane (a rail item a hop flew to) starts half inside it, at the item's
* height: the page swells out of the pane's edge beside the item. Wholly outside, its tile was a
* bar that raced across from the far edge.
*/
internal fun inPane(o: Origin): Origin =
if (o.bounds.right > 0f) o else o.translate(Offset(-o.bounds.right + o.bounds.width / 2f, 0f))
@Composable
private fun Backdrop(art: File?) {
val colors = MaterialTheme.colorScheme
@@ -207,6 +254,7 @@ private fun Content(
val colors = MaterialTheme.colorScheme
val detailPosterWidth = if (LocalConfiguration.current.screenHeightDp < 600) 72.dp else 120.dp
val narrow = LocalNarrowPane.current
val density = androidx.compose.ui.platform.LocalDensity.current.density
val padH = if (narrow) 16.dp else 22.dp
val padV = if (narrow) 12.dp else 18.dp
// Setup scrolls inside itself, under its tabs.
@@ -226,12 +274,12 @@ private fun Content(
return
}
// The Store scrolls and navigates inside itself (its tabs and an app's page).
if (selected == "store" && s.storeEnabled) {
if (selected == "store") {
StorePage(s, a, modifier)
return
}
// Stores (GOG, Epic, Amazon) takes the whole pane: chips, shelves and a game's page inside it.
if (selected == "stores" && s.gameStoresEnabled) {
if (selected == "stores") {
StoresPage(s, a, modifier)
return
}
@@ -240,6 +288,11 @@ private fun Content(
GamesPage(s, a, selected, onSelect, modifier)
return
}
// Desktop is the Steam tab's shape: a hero with its lifted sheet, and shelves under it.
if (selected == "desktop") {
DesktopPage(s, a, modifier, onSelect)
return
}
// Every other page scrolls as one - header, hero and grid - so a short screen reaches the grid
// instead of showing a sliver of it under a fixed hero. A focused tile scrolls itself into view.
Column(modifier = modifier.verticalScroll(rememberScrollState()).padding(horizontal = padH, vertical = padV)) {
@@ -260,37 +313,6 @@ private fun Content(
})
}
}
selected == "desktop" -> {
val installed = s.emulators.filter { it.installed }
val available = s.emulators.filter { !it.installed }
val ctx = LocalContext.current
LaunchedEffect(Unit) { UserAppsState.refresh(ctx) }
val apps = UserAppsState.items
var adding by rememberSaveable { mutableStateOf(false) }
Rise(0) {
PageHeader(stringResource(R.string.drawer_desktop)) {
if (s.desktopInstalled) Chip(stringResource(R.string.content_desktop_installed), ok = true) else Chip(stringResource(R.string.content_desktop_first_open), ok = false)
}
}
Rise(2) { DesktopCard(s, a) }
// Add closes the last grid: the installable emulators, or the installed ones once none are left.
val ready = installed.map { GridItem.Emu(it) } + apps.map { GridItem.User(it) }
val addAfterReady = available.isEmpty()
if (ready.isNotEmpty() || addAfterReady) {
Rise(3) { SectionTitle(stringResource(R.string.user_apps_section), ready.size.takeIf { it > 0 }?.toString()) }
Rise(4) {
LauncherGrid(if (addAfterReady) ready + GridItem.Add else ready, first = true, onSelect = onSelect, onAdd = { if (!s.busy) adding = true })
}
}
if (UserAppsState.working != null || UserAppsState.lastError != null) Rise(5) { UserAppsProgress() }
if (!addAfterReady) {
Rise(5) { SectionTitle(stringResource(R.string.content_available), available.size.toString()) }
Rise(6) {
LauncherGrid(available.map { GridItem.Emu(it) } + GridItem.Add, first = ready.isEmpty(), onSelect = onSelect, onAdd = { if (!s.busy) adding = true })
}
}
if (adding && !s.busy) AddAppDialog(s.ready, onDismiss = { adding = false }) { request, label -> UserAppsState.add(ctx, request, label) }
}
selected.startsWith("user:") -> {
val app = UserAppsState.items.firstOrNull { "user:${it.key}" == selected }
if (app == null) Note(stringResource(R.string.user_apps_gone)) else UserAppPage(app, s, a, onSelect)
@@ -300,21 +322,26 @@ private fun Content(
if (e == null) Note(stringResource(R.string.content_not_installed)) else {
val pkgId = Library.packageId(e.id)
val pkg = pkgId?.let { id -> s.packages?.firstOrNull { it.id == id } }
Rise(0) {
BackLink(stringResource(R.string.drawer_desktop)) { onSelect("desktop") }
}
// Out of its card on Desktop, with the card's icon; B draws it back in.
Rise(1) {
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.padding(top = 12.dp, bottom = 12.dp),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(top = 4.dp, bottom = 12.dp),
) {
Image(painterResource(e.iconRes), null, modifier = Modifier.size(52.dp))
Image(painterResource(e.iconRes), null, modifier = Modifier.size(64.dp))
Column {
Text(e.name, fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
Text(e.name, fontSize = if (narrow) 24.sp else 30.sp, fontWeight = FontWeight.Black, color = colors.onBackground)
Text(
systemLabel(e).let { if (e.installed) it else stringResource(R.string.content_not_installed_suffix, it) },
fontSize = 14.sp, color = colors.onSurfaceVariant,
)
if (e.installed) Text(
listOfNotNull(
pluralStringResource(R.plurals.mode_added_count, e.games.size, e.games.size).takeIf { e.id != "retroarch" || e.games.isNotEmpty() },
s.romsDir,
).joinToString(" · "),
fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
}
}
@@ -339,7 +366,24 @@ private fun Content(
)
}
else Rise(5, Modifier.fillMaxWidth()) {
ArtGrid(e.games.mapIndexed { index, g -> Tile(g.name, if (g.art != null) stringResource(R.string.content_rom_installed) else g.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, g.art, "rom:${e.id}:$index", e.iconRes) { onSelect("rom:${e.id}:$index") } }, wide = e.games.none { it.art != null })
Column {
// RetroArch's games come from many systems: chips filter them, one
// selection gliding between them.
val systems = remember(e.games) { e.games.map { romSystem(it) }.distinct() }
var system by rememberSaveable(e.id) { mutableStateOf<String?>(null) }
if (systems.size > 1) Box(Modifier.padding(top = 10.dp)) {
val labels = listOf(stringResource(R.string.desktop_all_systems)) + systems
SheetChips(labels, if (system == null) 0 else systems.indexOf(system) + 1) { i -> system = if (i == 0) null else systems[i - 1] }
}
val shown = e.games.withIndex().filter { system == null || romSystem(it.value) == system }
ArtGrid(shown.map { (index, g) ->
val key = "rom:${e.id}:$index"
Tile(g.name, if (g.art != null) stringResource(R.string.content_rom_installed) else g.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, g.art, key, e.iconRes) {
TileBounds.mark(key, g.art ?: e.iconRes, 12f * density)
onSelect(key)
}
}, wide = e.games.none { it.art != null })
}
}
} else {
if (pkg != null) Rise(2) {
@@ -381,12 +425,7 @@ private fun Content(
val pair = romFor(s, selected)
if (pair == null) Note(stringResource(R.string.content_rom_gone)) else {
val (e, g) = pair
Rise(0) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
BackLink(e.name) { onSelect("emu:${e.id}") }
Eyebrow(stringResource(R.string.content_desktop_system, systemLabel(e)))
}
}
Rise(0) { Eyebrow(stringResource(R.string.content_desktop_system, systemLabel(e))) }
Rise(1) { Title(g.name) }
Rise(2) {
Row {
@@ -408,7 +447,10 @@ private fun Content(
Rise(4, Modifier.fillMaxWidth()) {
ArtGrid(others.map { x ->
val index = e.games.indexOf(x)
Tile(x.name, if (x.art != null) stringResource(R.string.content_rom_installed) else x.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, x.art, "rom:${e.id}:$index", e.iconRes) { onSelect("rom:${e.id}:$index") }
Tile(x.name, if (x.art != null) stringResource(R.string.content_rom_installed) else x.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, x.art, "rom:${e.id}:$index", e.iconRes) {
TileBounds.mark("rom:${e.id}:$index", x.art ?: e.iconRes, 12f * density)
onSelect("rom:${e.id}:$index")
}
}, wide = others.none { it.art != null })
}
}
@@ -419,122 +461,6 @@ private fun Content(
}
}
/** The Linux desktop itself, above the emulators that run in it. */
@Composable
private fun DesktopCard(s: FrontEndState, a: FrontEndActions) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
var confirmRemove by rememberSaveable { mutableStateOf(false) }
if (confirmRemove) ConfirmDialog(
stringResource(R.string.desktop_remove_title), stringResource(R.string.desktop_remove_text),
stringResource(R.string.common_uninstall), a.onRemoveDesktop, { confirmRemove = false },
)
val actions: @Composable () -> Unit = {
Actions {
// Enabled without a runtime or the desktop: the session's loading screen installs them first.
PrimaryButton(if (s.desktopInstalled) stringResource(R.string.content_open_desktop) else stringResource(R.string.content_install_open_desktop), enabled = !s.busy, main = true, onClick = a.onDesktop)
Cog(onClick = a.onDesktopSettings)
if (s.desktopPresent) SecondaryButton(stringResource(R.string.common_uninstall),
enabled = !s.runtimeActionsBlocked) { confirmRemove = true }
BusyChip(s)
}
}
// The button goes under the title unless the card is wide enough for both side by side: in some
// languages its label alone takes half a small phone's pane.
BoxWithConstraints(Modifier.fillMaxWidth()) {
val narrow = LocalNarrowPane.current || maxWidth < DesktopCardInlineWidth
Column(
verticalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().clip(Shape16).background(colors.surface).border(1.dp, pal.line, Shape16).padding(16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(52.dp).clip(Shape14).background(colors.surfaceVariant).border(1.dp, pal.line2, Shape14),
) { Icon(Icons.Outlined.DesktopWindows, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(26.dp)) }
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.content_linux_desktop), fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
Text(stringResource(R.string.content_linux_desktop_hint), fontSize = 14.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
if (!narrow) actions()
}
if (narrow) actions()
}
}
}
/** How wide the desktop card must be to keep its button beside the title. */
private val DesktopCardInlineWidth = 640.dp
/** What the Desktop page's grids hold: emulators, the user's own apps, and Add at the end. */
private sealed class GridItem(val key: String) {
class Emu(val e: Library.Emulator) : GridItem("emu:${e.id}")
class User(val app: UserApps.App) : GridItem("user:${app.key}")
data object Add : GridItem("add")
}
/** Wide tiles - three across, two on a small phone, or a list on a narrow page. */
@Composable
private fun LauncherGrid(items: List<GridItem>, first: Boolean, onSelect: (String) -> Unit, onAdd: () -> Unit = {}) {
BoxWithConstraints(Modifier.fillMaxWidth()) {
// A tile holds an icon, a name and an Install button: one much under 230dp wide leaves the
// name no room at all.
val columns = when {
LocalNarrowPane.current -> 1
maxWidth < 720.dp -> 2
else -> 3
}
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp)) {
items.chunked(columns).forEachIndexed { r, row ->
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
row.forEachIndexed { i, item ->
key(item.key) {
val m = Modifier.weight(1f).fillMaxHeight()
val isFirst = first && r == 0 && i == 0
when (item) {
is GridItem.Emu -> EmulatorTile(item.e, m, isFirst) { onSelect(item.key) }
is GridItem.User -> UserAppTile(item.app, m, isFirst) { onSelect(item.key) }
GridItem.Add -> AddTile(m, isFirst, onAdd)
}
}
}
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
}
}
}
}
}
/** A grid tile's frame: the emulator tiles' look, focus glide and press. */
@Composable
private fun TileFrame(id: String, modifier: Modifier, isFirst: Boolean, filled: Boolean, onClick: () -> Unit, content: @Composable RowScope.() -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val pressed by src.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "tileScale")
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = modifier.paneItem("tile:$id").then(if (isFirst) Modifier.firstTile() else Modifier)
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(Shape14)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else if (filled) colors.surface else Color.Transparent)
.glideBorder(hot, Shape14, pal.signal, pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.padding(horizontal = 12.dp, vertical = 10.dp),
) { content() }
}
@Composable
private fun TileText(title: String, detail: String, modifier: Modifier) {
val colors = MaterialTheme.colorScheme
Column(modifier = modifier) {
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(detail, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
internal fun UserApps.Kind.label() = when (this) {
UserApps.Kind.SCRIPT -> R.string.user_apps_kind_script
UserApps.Kind.APPIMAGE -> R.string.user_apps_kind_appimage
@@ -556,26 +482,6 @@ private fun UserAppIcon(app: UserApps.App, size: Int) {
}
}
@Composable
private fun UserAppTile(app: UserApps.App, modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) =
TileFrame("user:${app.key}", modifier, isFirst, filled = true, onClick = onClick) {
UserAppIcon(app, 44)
TileText(app.name, stringResource(app.kind.label()), Modifier.weight(1f))
}
/** The grid's last tile: opens the Add dialog. */
@Composable
private fun AddTile(modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) {
val pal = LocalPalette.current
TileFrame("add", modifier, isFirst, filled = false, onClick = onClick) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(44.dp).clip(Shape12).background(pal.signal.copy(alpha = 0.12f)).border(1.dp, pal.signal.copy(alpha = 0.35f), Shape12),
) { Icon(Icons.Filled.Add, null, tint = pal.signal, modifier = Modifier.size(24.dp)) }
TileText(stringResource(R.string.user_apps_add), stringResource(R.string.user_apps_add_detail), Modifier.weight(1f))
}
}
/** The add or remove under way, or why the last one failed. */
@Composable
private fun UserAppsProgress() {
@@ -595,20 +501,17 @@ private fun UserAppsProgress() {
}
}
/** An added app's page: open it, change its name and icon, update one added from GitHub, or remove it (pressed twice). */
/** An added app's page: open it, change its name and icon, update one added from GitHub, or remove it (asked first). */
@Composable
private fun UserAppPage(app: UserApps.App, s: FrontEndState, a: FrontEndActions, onSelect: (String) -> Unit) {
val colors = MaterialTheme.colorScheme
val ctx = LocalContext.current
val narrow = LocalNarrowPane.current
var confirm by remember(app.key) { mutableStateOf(false) }
var editing by rememberSaveable(app.key) { mutableStateOf(false) }
val x86 = (app.arch == "x86_64" || app.arch == "i386") && app.fex != com.droiddeck.launcher.runtime.LinuxFex.OFF
val fexReady by produceState<Boolean?>(null, app.key, x86, s.sessionRunning) {
value = if (x86) kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { com.droiddeck.launcher.runtime.LinuxFex.ready(ctx) } else true
}
LaunchedEffect(confirm) { if (confirm) { kotlinx.coroutines.delay(4000); confirm = false } }
Rise(0) { BackLink(stringResource(R.string.user_apps_back)) { onSelect("desktop") } }
Rise(1) {
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
@@ -631,12 +534,8 @@ private fun UserAppPage(app: UserApps.App, s: FrontEndState, a: FrontEndActions,
BusyChip(s)
SecondaryButton(stringResource(R.string.user_apps_edit), enabled = UserAppsState.working == null) { editing = true }
if (app.repo != null) UpdateButton(app, s)
SecondaryButton(
stringResource(if (confirm) R.string.user_apps_remove_confirm else R.string.user_apps_remove),
enabled = !s.busy && !s.sessionRunning && UserAppsState.working == null,
) {
if (!confirm) confirm = true
else { confirm = false; UserAppsState.remove(ctx, app); onSelect("desktop") }
RemoveAppButton(app, enabled = !s.busy && !s.sessionRunning && UserAppsState.working == null) {
UserAppsState.remove(ctx, app); onSelect("desktop")
}
if (s.sessionRunning) ActionChip(stringResource(R.string.user_apps_stop_session), ok = false)
}
@@ -657,6 +556,31 @@ private fun UserAppPage(app: UserApps.App, s: FrontEndState, a: FrontEndActions,
}
}
/** Remove, which asks first: are you sure steps out of it in red, Keep first and focused. */
@Composable
private fun RemoveAppButton(app: UserApps.App, enabled: Boolean, onRemove: () -> Unit) {
val colors = MaterialTheme.colorScheme
val label = stringResource(R.string.user_apps_remove)
val title = stringResource(R.string.flathub_remove_title, app.name)
val keep = stringResource(R.string.flathub_keep)
var at by remember { mutableStateOf<androidx.compose.ui.geometry.Rect?>(null) }
Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) {
SecondaryButton(label, enabled = enabled) {
val from = at ?: return@SecondaryButton
Steps.ask(StepAsk(
anchor = from, side = StepSide.Right, accent = colors.error, pillCorner = 12.dp, items = 2,
handle = { Text(label, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.error, maxLines = 1) },
) {
StepTitle(title)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.stepItem(1)) {
StepChoice(keep, enabled = open, modifier = Modifier.focusRequester(first)) { Steps.fold() }
StepChoice(label, danger = true, enabled = open) { Steps.fold(); onRemove() }
}
})
}
}
}
/** Looks up a newer release of an app added from GitHub, then installs it. */
@Composable
private fun UpdateButton(app: UserApps.App, s: FrontEndState) {
@@ -688,21 +612,5 @@ private fun UpdateButton(app: UserApps.App, s: FrontEndState) {
private fun systemLabel(e: Library.Emulator): String =
if (e.systemRes != 0) stringResource(e.systemRes) else e.system.replaceFirstChar { it.uppercase() }
@Composable
private fun EmulatorTile(e: Library.Emulator, modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val system = systemLabel(e)
val detail = if (e.installed && e.id != "retroarch") "$system · " + pluralStringResource(R.plurals.mode_added_count, e.games.size, e.games.size) else system
TileFrame("emu:${e.id}", modifier, isFirst, filled = e.installed, onClick = onClick) {
Image(painterResource(e.iconRes), contentDescription = null, modifier = Modifier.size(if (e.installed) 44.dp else 36.dp))
Column(modifier = Modifier.weight(1f)) {
Text(e.name, fontSize = if (e.installed) 15.sp else 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(detail, fontSize = if (e.installed) 13.sp else 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (!e.installed) Text(
stringResource(R.string.setup_install), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal,
modifier = Modifier.clip(RoundedCornerShape(8.dp)).border(1.dp, pal.line2, RoundedCornerShape(8.dp)).padding(horizontal = 10.dp, vertical = 6.dp),
)
}
}
/** The system a ROM belongs to: the folder it sits in under the ROMs folder. */
private fun romSystem(rom: Library.Rom): String = rom.hostPath.parentFile?.name.orEmpty()
@@ -77,6 +77,9 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.layout.boundsInParent
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import com.droiddeck.launcher.core.PhantomProcessLimit
// The front end's side rail: the page list and the resume entry under it.
@@ -103,27 +106,53 @@ internal fun SideRail(
modifier = modifier.width(if (iconOnly) 64.dp else 92.dp).background(colors.surface).padding(vertical = if (compact) 8.dp else 12.dp),
) {
// Every section on screen at once: items shrink (to 44dp, still a touch target) before the rail scrolls.
val count = 6 + (if (s.storeEnabled) 1 else 0) + (if (s.gameStoresEnabled) 1 else 0) + (if (s.isHomeApp) 1 else 0)
val count = 6 + (if (s.gameStoresEnabled) 1 else 0) + (if (s.isHomeApp) 1 else 0)
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
val fit = (maxHeight / count - 4.dp).coerceIn(44.dp, if (iconOnly) 48.dp else if (compact) 52.dp else 60.dp)
// A hop in flight moves the selection ahead of the page it is bringing.
val shown = Hops.railPreview ?: selected
val keys = buildList {
add("steam"); add("games")
if (s.gameStoresEnabled) add("stores")
add("desktop")
add("components")
if (s.isHomeApp) add("android-apps")
add("setup"); add("updates")
}
// Where each item sits in the column, for the selection that glides between them.
val bounds = remember { androidx.compose.runtime.mutableStateMapOf<String, androidx.compose.ui.geometry.Rect>() }
val tint = androidx.compose.runtime.rememberUpdatedState(LocalPalette.current.signal)
var railAt by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).onGloballyPositioned { railAt = it.boundsInRoot().topLeft }) {
// One selection for the whole rail, under the items: it glides from item to item as the
// chips' does, its leading edge first, and a jump of more than one item goes as a drop.
if (shown in keys) ChipGlide(keys.indexOf(shown), bounds[shown]?.translate(-railAt), tint, GlideAxis.Vertical, corner = 14.dp, fill = 0.14f, outline = 0f)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
modifier = Modifier.fillMaxWidth(),
) {
RailItem(stringResource(R.string.rail_steam), Icons.Outlined.SportsEsports, "steam", selected == "steam", compact, iconOnly, fit, onFocus = { onFocusSelect("steam") }) { onSelect("steam") }
val place: (String) -> Modifier = { key ->
// In root px: an item that comes and goes sits in its own wrapper, not the column.
Modifier.onGloballyPositioned {
bounds[key] = it.boundsInRoot()
Hops.railBounds[key] = it.boundsInRoot()
}
}
RailItem(stringResource(R.string.rail_steam), Icons.Outlined.SportsEsports, "steam", shown == "steam", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("steam") }) { onSelect("steam") }
// Always there, so the items below it never move; an empty library says how to fill it.
RailItem(stringResource(R.string.rail_games), Icons.Outlined.VideoLibrary, "games", selected == "games", compact, iconOnly, fit, onFocus = { onFocusSelect("games") }) { onSelect("games") }
RailItem(stringResource(R.string.rail_games), Icons.Outlined.VideoLibrary, "games", shown == "games", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("games") }) { onSelect("games") }
// The game stores, right under Games: what they install lands there. Its badge counts
// the downloads in flight, so a download started here is never out of sight.
if (s.gameStoresEnabled) RailItem(stringResource(R.string.rail_stores), Icons.Outlined.LocalMall, "stores", selected == "stores", compact, iconOnly, fit, count = s.storeDownloadsActive, onFocus = { onFocusSelect("stores") }) { onSelect("stores") }
RailItem(stringResource(R.string.rail_desktop), Icons.Outlined.DesktopWindows, "desktop", selected == "desktop", compact, iconOnly, fit, onFocus = { onFocusSelect("desktop") }) { onSelect("desktop") }
if (s.storeEnabled) RailItem(stringResource(R.string.rail_store), Icons.Outlined.Storefront, "store", selected == "store", compact, iconOnly, fit, onFocus = { onFocusSelect("store") }) { onSelect("store") }
RailItem(stringResource(R.string.rail_components), Icons.Outlined.Layers, "components", selected == "components", compact, iconOnly, fit, onFocus = { onFocusSelect("components") }) { onSelect("components") }
// A section switched on in Setup comes in as the rail's own entrance; off, it folds away.
if (s.gameStoresEnabled) RailItem(stringResource(R.string.rail_stores), Icons.Outlined.LocalMall, "stores", shown == "stores", compact, iconOnly, fit, count = s.storeDownloadsActive, place = place, onFocus = { onFocusSelect("stores") }) { onSelect("stores") }
RailItem(stringResource(R.string.rail_desktop), Icons.Outlined.DesktopWindows, "desktop", shown == "desktop", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("desktop") }) { onSelect("desktop") }
RailItem(stringResource(R.string.rail_components), Icons.Outlined.Layers, "components", shown == "components", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("components") }) { onSelect("components") }
// Home mode's extra section goes last, so it shifts nothing above it.
if (s.isHomeApp) RailItem(stringResource(R.string.rail_apps), Icons.Outlined.Apps, "android-apps", selected == "android-apps", compact, iconOnly, fit, onFocus = { onFocusSelect("android-apps") }) { onSelect("android-apps") }
RailItem(stringResource(R.string.rail_setup), Icons.Outlined.Tune, "setup", selected == "setup", compact, iconOnly, fit, badge = setupNeedsAttention, onFocus = { onFocusSelect("setup") }) { onSelect("setup") }
RailItem(stringResource(R.string.rail_updates), Icons.Outlined.SystemUpdate, "updates", selected == "updates", compact, iconOnly, fit, badge = s.updates.hasUpdate, onFocus = { onFocusSelect("updates") }) { onSelect("updates") }
if (s.isHomeApp) RailItem(stringResource(R.string.rail_apps), Icons.Outlined.Apps, "android-apps", shown == "android-apps", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("android-apps") }) { onSelect("android-apps") }
RailItem(stringResource(R.string.rail_setup), Icons.Outlined.Tune, "setup", shown == "setup", compact, iconOnly, fit, badge = setupNeedsAttention, place = place, onFocus = { onFocusSelect("setup") }) { onSelect("setup") }
RailItem(stringResource(R.string.rail_updates), Icons.Outlined.SystemUpdate, "updates", shown == "updates", compact, iconOnly, fit, badge = s.updates.hasUpdate, place = place, onFocus = { onFocusSelect("updates") }) { onSelect("updates") }
}
}
}
var lastRunning by remember { mutableStateOf("") }
@@ -142,6 +171,8 @@ private fun RailItem(
badge: Boolean = false,
/** A number on the item (downloads in flight); 0 shows nothing. */
count: Int = 0,
/** Reports where the item sits, for the rail's gliding selection and a hop's dot. */
place: (String) -> Modifier = { Modifier },
onFocus: () -> Unit = {}, onClick: () -> Unit,
) {
val colors = MaterialTheme.colorScheme
@@ -162,8 +193,9 @@ private fun RailItem(
if (current) pal.signal else if (focused || hovered) colors.onBackground else colors.onSurfaceVariant,
Motion.tw(220), label = "railFg",
)
// The selected look is the rail's glide under the item, so the item draws only hover here.
val fill by animateColorAsState(
if (current) pal.signal.copy(alpha = 0.14f) else if (focused || hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent,
if (current) Color.Transparent else if (focused || hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent,
Motion.tw(220), label = "railFill",
)
val scale by animateFloatAsState(if (pressed) 0.95f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "railScale")
@@ -172,6 +204,9 @@ private fun RailItem(
modifier = Modifier
.then(if (frontFocus != null) Modifier.focusRequester(frontFocus.railFor(key)) else Modifier)
.testTag("rail-$key")
.then(place(key))
// Pops as a hop's dot lands on it.
.landingPop(Hops.railLanded[key] ?: 0)
.graphicsLayer { scaleX = scale; scaleY = scale }
.size(width = if (iconOnly) 52.dp else 80.dp, height = height)
.clip(Shape14)
@@ -70,6 +70,8 @@ import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import com.droiddeck.launcher.HomeApp
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.gpu.FrameGen
@@ -132,10 +134,7 @@ class FrontEndState(
val orientation: Int = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE,
val launcherFullscreen: Boolean = true,
val animationsEnabled: Boolean = true,
/** The Flathub Store, a beta the user turns on in Setup. */
val storeEnabled: Boolean = false,
/** The Stores section (GOG, Epic Games, Amazon Games), turned on in Setup. */
val gameStoresEnabled: Boolean = false,
val gameStoresEnabled: Boolean = true,
/** Setup › Stores: the download engine's speed tier (stores/download/StoreDownloadTier ids). */
val gameStoresSpeedTier: String = "fast",
/** Setup › Stores: the tab a signed-in store opens on (SessionPrefs.STORES_OPEN_LIBRARY / STORE). */
@@ -178,8 +177,12 @@ class FrontEndActions(
val onUserApp: (com.droiddeck.launcher.runtime.UserApps.App) -> Unit = {},
val onRom: (Library.Rom) -> Unit,
val onResume: () -> Unit,
/** Reads Steam's session settings for its lifted sheet (under Play). */
val onSteamSettings: () -> Unit,
/** Reads the desktop's session settings for its lifted sheet (under Open desktop). */
val onDesktopSettings: () -> Unit,
/** A lifted sheet's rows for one tab of [mode]'s settings, as last read. */
val modeSheet: @Composable (mode: String, tab: String, extras: SheetExtras) -> Unit = { _, _, _ -> },
val onRemoveDesktop: () -> Unit = {},
val onInstallPackage: (String) -> Unit,
val onRemovePackage: (String) -> Unit,
@@ -189,6 +192,8 @@ class FrontEndActions(
val onProtons: () -> Unit,
/** The Components page: FEX / DXVK / VKD3D-Proton per Proton. */
val onComponents: (focusContent: Boolean) -> Unit,
/** The Components page on one layer ([GPU_TAB], a component id): a hop's destination. */
val onComponentsTab: (String) -> Unit = {},
/** A game page's Manage saves: import a save zip into this game, or export its saves in a layout. */
val onSaveImport: (Library.SteamGame) -> Unit = {},
val onSaveExport: (Library.SteamGame, com.droiddeck.launcher.session.GameSaves.Layout) -> Unit = { _, _ -> },
@@ -208,7 +213,6 @@ class FrontEndActions(
val onOrientation: (Int) -> Unit = {},
val onLauncherFullscreen: (Boolean) -> Unit = {},
val onAnimationsEnabled: (Boolean) -> Unit = {},
val onStoreEnabled: (Boolean) -> Unit = {},
val onGameStoresEnabled: (Boolean) -> Unit = {},
val onGameStoresSpeedTier: (String) -> Unit = {},
val onStoresOpenTab: (String) -> Unit = {},
@@ -337,6 +341,8 @@ internal class FrontFocus {
/** Down from this button goes to the first tile of the page's grid, when there is one. */
@Composable
internal fun Modifier.downToFirstTile(): Modifier {
// Sheet rows walk their settings; Desktop's dock stays composed behind the sheet.
if (LocalSheetBeat.current != null) return this
val ff = LocalFrontFocus.current ?: return this
return this.focusProperties { down = if (ff.firstTileAttached > 0) ff.firstTile else FocusRequester.Default }
}
@@ -382,6 +388,18 @@ internal suspend fun focusWithinFrames(done: () -> Boolean, target: () -> FocusR
}
}
/**
* Asks [target] for focus on the next frames until a request lands (a control can take focus only
* once it is laid out), for at most half a second; then leaves focus alone, so a press right after
* is never pulled back.
*/
internal suspend fun focusSoon(target: () -> FocusRequester) {
repeat(30) {
androidx.compose.runtime.withFrameNanos { }
if (runCatching { target().requestFocus() }.isSuccess) return
}
}
@Composable
fun FrontEndScreen(s: FrontEndState, a: FrontEndActions, page: (@Composable () -> Unit)? = null) {
val frontFocus = remember { FrontFocus() }
@@ -423,19 +441,27 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
}
}
}
BackHandler(enabled = !processSettingsPageVisible && s.pageKey != null && page != null) { a.onPageBack() }
// Back (and B) from a ROM, an emulator or an added app steps out one level, as its "‹" link
// does, instead of leaving the app: a ROM -> its emulator, the others -> Desktop.
// A page reached by a hop goes back the way it came; any other page just closes.
val pageBack = { if (!Hops.back(a.onPageBack)) a.onPageBack() }
BackHandler(enabled = !processSettingsPageVisible && s.pageKey != null && page != null) { pageBack() }
// Left any other way, the hop's page has no way back to keep.
LaunchedEffect(s.pageKey, selected) { Hops.forget(s.pageKey ?: selected) }
// A section reached by a hop (Desktop's Flathub card to the Store) goes back the way it came.
BackHandler(enabled = !processSettingsPageVisible && s.pageKey == null && Hops.origin?.page == selected) {
val rail = Hops.origin?.rail ?: return@BackHandler
Hops.back { selected = rail }
}
val density = androidx.compose.ui.platform.LocalDensity.current.density
remember(density) { Hops.density = density; true }
// Back (and B) from a ROM, an emulator, an added app or Flathub steps out one level instead
// of leaving the app: a ROM -> its emulator, the others -> Desktop.
BackHandler(
enabled = !processSettingsPageVisible && (s.pageKey == null || page == null) &&
(selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:")),
(selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:") || selected == "store"),
) {
selected = if (selected.startsWith("rom:")) "emu:" + selected.removePrefix("rom:").substringBefore(':') else "desktop"
}
LaunchedEffect(s.isHomeApp) { if (!s.isHomeApp && selected == "android-apps") selected = "steam" }
// The Store turned off in Setup takes its page with it; so does Stores.
LaunchedEffect(s.storeEnabled) { if (!s.storeEnabled && selected == "store") selected = "steam" }
LaunchedEffect(s.gameStoresEnabled) { if (!s.gameStoresEnabled && selected == "stores") selected = "games" }
// The last game uninstalled leaves the Games tab on its empty state.
LaunchedEffect(s.steamGames.isEmpty()) {
if (s.steamGames.isEmpty() && selected.startsWith("app:")) selected = "games"
@@ -443,11 +469,14 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
remember { Motion.refresh(ctx); true }
val railSelection = when {
s.pageKey == "performance" || s.pageKey == "protons" || s.pageKey == "controller-mapping" -> "setup"
s.pageKey?.startsWith("settings:steam") == true -> "steam"
s.pageKey?.startsWith("settings:") == true -> "desktop"
// A hop's page belongs to the rail item it flew to.
Hops.origin != null && s.pageKey == Hops.origin?.page -> Hops.origin!!.dest
// Performance opens from Play's sheet, and is Steam's.
s.pageKey == "performance" -> "steam"
s.pageKey == "controller-mapping" -> "setup"
selected.startsWith("app:") -> "games"
selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:") -> "desktop"
// Flathub is a page of Desktop's, as an emulator's is.
selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:") || selected == "store" -> "desktop"
else -> s.pageKey ?: selected
}
// Bumped each time a rail item is picked, so a controller moves on into the new page.
@@ -458,6 +487,10 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
if (key == "components") a.onComponents(focusContent)
else {
if (s.pageKey != null) a.onPageBack()
// Another section: no way back to keep, and no sheet to put back.
Hops.origin = null
Hops.restore = null
Hops.sinking = null
selected = key
}
}
@@ -470,6 +503,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
}
val inputModeManager = LocalInputModeManager.current
val window = LocalWindowInfo.current
val androidContext = androidx.compose.ui.platform.LocalContext.current
var anyFocused by remember { mutableStateOf(false) }
Box(
modifier = Modifier.fillMaxSize().background(colors.background)
@@ -501,7 +535,15 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
if (processSettingsPageVisible) return@LaunchedEffect
// Past the old page's exit (170ms), then the first frame the new page takes focus.
kotlinx.coroutines.delay(Motion.ms(PAGE_EXIT_MS).toLong())
if (anyFocused || inputModeManager.inputMode != InputMode.Keyboard) return@LaunchedEffect
if (inputModeManager.inputMode != InputMode.Keyboard) return@LaunchedEffect
if (s.pageKey == "performance") {
val back = "back:${androidContext.getString(R.string.mode_back)}"
focusWithinFrames({ frontFocus.last == back && anyFocused && frontFocus.focusedRail == null }) {
frontFocus.items[back] ?: FocusRequester.Default
}
return@LaunchedEffect
}
if (anyFocused) return@LaunchedEffect
focusWithinFrames({ anyFocused }) { if (frontFocus.primaryAttached > 0) frontFocus.primary else frontFocus.railFor(railSelection) }
}
// A rail item picked with a controller moves on into its page, once the page is in.
@@ -519,7 +561,22 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
val railFocus = Modifier
.focusProperties { enter = { frontFocus.railFor(railSelection) } }
.focusGroup()
Row(modifier = Modifier.fillMaxSize()) {
// Back from a hop onto a surface that is not a sheet (a game's Components card, Desktop's
// Flathub card): focus
// goes back to the control that asked, once the page has gone.
LaunchedEffect(Hops.restore) {
val r = Hops.restore ?: return@LaunchedEffect
if (r.surface == HOP_SURFACE_SHEET) return@LaunchedEffect
Hops.restore = null
if (r.rail != selected && !(r.rail == "games" && selected.startsWith("app:"))) selected = r.rail
kotlinx.coroutines.delay(Motion.ms(PAGE_EXIT_MS).toLong())
if (inputModeManager.inputMode != InputMode.Keyboard) return@LaunchedEffect
frontFocus.last = r.control
focusWithinFrames({ frontFocus.last == r.control && anyFocused }) { frontFocus.items[r.control] ?: frontFocus.paneEntry() }
}
var screenAt by remember { mutableStateOf(Offset.Zero) }
CompositionLocalProvider(LocalShowSection provides { key -> selected = key }) {
Row(modifier = Modifier.fillMaxSize().onGloballyPositioned { screenAt = it.positionInRoot() }) {
SideRail(s, railSelection, onRailSelect, onRailFocus, a, railFocus.fillMaxHeight())
Box(Modifier.width(1.dp).fillMaxHeight().background(pal.line))
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
@@ -535,6 +592,12 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
}
}
}
// Dots on their way somewhere: a hop to another section and back, a swap flying home.
FlightsLayer(screenAt)
// A confirm stepping out of the control that asked for it.
StepsLayer(screenAt)
appToChooseDisplay?.let { app ->
val secondaryDisplay = s.secondScreenDisplays.firstOrNull()
ChooseAppDisplayDialog(
@@ -597,8 +660,10 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
}
}
val hasBackTarget = (s.pageKey != null && page != null) ||
// A section reached by a hop goes back to where it came from, not to the rail.
(s.pageKey == null && Hops.origin?.page == selected) ||
((s.pageKey == null || page == null) &&
(selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:")))
(selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:") || selected == "store"))
// At the top of a section, Back goes to the rail - the launcher itself is never backed out of.
BackHandler(enabled = !processSettingsPageVisible && !hasBackTarget) {
if (inputModeManager.inputMode != InputMode.Keyboard) inputModeManager.requestInputMode(InputMode.Keyboard)
@@ -71,6 +71,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import com.droiddeck.launcher.core.AppLanguage
import com.droiddeck.launcher.core.DeviceSupport
import com.droiddeck.launcher.core.PhantomProcessLimit
@@ -166,21 +168,26 @@ internal fun SetupPanel(
var showLimitDetails by rememberSaveable { mutableStateOf(false) }
var editSgdbKey by remember { mutableStateOf(false) }
if (editSgdbKey) SgdbKeyDialog(s.sgdbUserKey, onSave = { a.onSgdbKey(it); editSgdbKey = false }, onDismiss = { editSgdbKey = false })
// Four tabs instead of one long scroll; LB and RB turn them from anywhere on the page. Build
// and credits are on the Updates page.
val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_session), stringResource(R.string.setup_tab_launcher))
// Four tabs by scope, a chip row with one gliding selection; LB and RB turn them from
// anywhere on the page. Build and credits are on the Updates page.
val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_launcher), stringResource(R.string.setup_tab_diagnostics))
var tab by rememberSaveable { mutableStateOf(0) }
val tabFocus = remember { List(tabs.size) { FocusRequester() } }
val chipFocus = remember { List(4) { FocusRequester() } }
var tabTurned by remember { mutableStateOf(false) }
val pick: (Int) -> Unit = { i -> tab = i; tabTurned = true }
val inputModeManager = LocalInputModeManager.current
// The control a controller was on went with the old tab: it lands on the new tab itself.
// The control a controller was on went with the old tab: it lands on the new tab's chip.
LaunchedEffect(tab) {
if (tabTurned && inputModeManager.inputMode == InputMode.Keyboard) {
androidx.compose.runtime.withFrameNanos { }
runCatching { tabFocus[tab].requestFocus() }
runCatching { chipFocus[tab].requestFocus() }
}
}
val wide = !LocalNarrowPane.current
val runtimeOk = s.ready && !s.busy && !s.removalPending
val checks = listOf(gpuOk, runtimeOk, !limitBlocks, signedIn)
val passing = checks.count { it }
var showChecks by rememberSaveable { mutableStateOf(false) }
Rise(0, Modifier.fillMaxSize()) {
Column(
modifier = Modifier.fillMaxSize().bumpers(
@@ -189,15 +196,12 @@ internal fun SetupPanel(
),
) {
PageHeader(stringResource(R.string.setup_title)) {
TabStrip(tabs, tab, pick, Modifier.weight(1f), tabFocus)
Box(Modifier.weight(1f)) { SheetChips(tabs, tab, chipFocus, pick) }
}
Column(modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) {
when (tab) {
0 -> {
SectionTitle(stringResource(R.string.setup_system_check), null)
// What Steam needs, one row each: green when done, one button when not. The
// process-limit controls only open under their row.
Column(modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
val gpuCheck: @Composable (Boolean) -> Unit = { divider ->
CheckRow(
if (gpuOk) CheckState.OK else CheckState.WARN,
when (gpu.support) {
@@ -210,7 +214,10 @@ internal fun SetupPanel(
com.droiddeck.launcher.gpu.GpuInfo.Support.UNTESTED -> stringResource(R.string.setup_gpu_untested_detail, gpu.displayName(ctx), gpu.supportText(ctx).replaceFirstChar { it.lowercase() })
else -> stringResource(R.string.setup_gpu_unsupported_detail, gpuName)
},
divider = divider,
)
}
val runtimeCheck: @Composable (Boolean) -> Unit = { divider ->
CheckRow(
when { s.busy -> CheckState.BUSY; !s.ready -> CheckState.WARN; else -> CheckState.OK },
stringResource(R.string.setup_runtime),
@@ -221,7 +228,7 @@ internal fun SetupPanel(
s.available != null && s.available != s.installed -> stringResource(R.string.setup_runtime_update, s.installed ?: stringResource(R.string.setup_installed))
else -> stringResource(R.string.setup_runtime_current, s.installed ?: stringResource(R.string.setup_installed))
},
divider = !s.busy,
divider = divider && !s.busy,
) { SecondaryButton(runtime, enabled = !s.busy && !s.runtimeActionsBlocked && !s.sessionRunning, compact = true, onClick = a.onRuntime) }
if (s.busy) {
Column(modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, bottom = 10.dp)) {
@@ -232,8 +239,10 @@ internal fun SetupPanel(
if (s.percent >= 0) LinearProgressIndicator(progress = { s.percent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp))
else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp))
}
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
if (divider) Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
}
val limitCheck: @Composable (Boolean) -> Unit = { divider ->
CheckRow(
if (limitBlocks) CheckState.WARN else CheckState.OK,
stringResource(R.string.setup_limit),
@@ -243,6 +252,7 @@ internal fun SetupPanel(
PhantomProcessStatus.UNREADABLE -> stringResource(R.string.setup_limit_unknown)
else -> PhantomProcessLimit.title(ctx, s.phantomProcessStatus)
},
divider = divider && !showLimitDetails,
) {
if (limitBlocks) PrimaryButton(if (showLimitDetails) stringResource(R.string.common_hide) else stringResource(R.string.setup_fix_it), compact = true) { showLimitDetails = !showLimitDetails }
else if (s.phantomProcessStatus != PhantomProcessStatus.NOT_APPLICABLE) {
@@ -271,6 +281,9 @@ internal fun SetupPanel(
processLimitMessage?.let { Text(it, fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp)) }
}
}
if (divider && showLimitDetails) Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
val accountCheck: @Composable (Boolean) -> Unit = { divider ->
CheckRow(
if (signedIn) CheckState.OK else CheckState.WARN,
stringResource(R.string.setup_account),
@@ -281,11 +294,34 @@ internal fun SetupPanel(
else -> stringResource(R.string.setup_signed_in, it)
}
} ?: stringResource(R.string.setup_sign_in),
divider = false,
divider = divider,
)
}
SectionTitle(stringResource(R.string.setup_tools), null)
ToolGrid(s, a)
val all = listOf(gpuCheck, runtimeCheck, limitCheck, accountCheck)
// Everything passes: one line says so, and Details opens the checks in place.
ReadinessCard(
passing, checks.size,
listOfNotNull(
gpu.displayName(ctx),
s.installed?.let { stringResource(R.string.setup_ready_runtime, it) },
stringResource(R.string.setup_ready_limit_off).takeIf { !limitBlocks },
s.offlineAccount?.takeIf { it.isNotEmpty() }?.let { stringResource(R.string.setup_ready_signed_in, it) },
).joinToString(" · "),
open = showChecks, onDetails = { showChecks = !showChecks },
) {
val shownChecks = all.filterIndexed { i, _ -> checks[i] }
shownChecks.forEachIndexed { i, check -> check(i < shownChecks.lastIndex) }
}
// Each check that warns is its own card, its Fix it as it always was.
if (passing < checks.size) {
SectionTitle(stringResource(R.string.setup_needs_you), null)
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
all.forEachIndexed { i, check ->
if (!checks[i]) Column(Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, AttentionAmber.copy(alpha = 0.5f), Shape14)) { check(false) }
}
}
}
SettingsGroup(stringResource(R.string.setup_runtime)) { runtimeCheck(false) }
}
1 -> {
val controller = s.controller
@@ -295,110 +331,80 @@ internal fun SetupPanel(
if (s.controller == null || a.controller == null) Note(stringResource(R.string.setup_controller_unavailable))
}
2 -> {
SettingsGroup(stringResource(R.string.setup_session)) {
ChoiceRow(
host, "back-actions", stringResource(R.string.mode_back), stringResource(SessionPrefs.backActionsOrder(s.backActionsInverted)),
listOf(
false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM),
true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU),
), s.backActionsInverted, onPick = a.onBackActionsInverted,
)
SettingsRow(stringResource(R.string.frame_gen_title), stringResource(R.string.frame_gen_hint)) {
Box {
ValueChip(s.frameGenLabel, host.open == "fg") { host.open = if (host.open == "fg") null else "fg" }
FrameGenMenu(s, a, host)
val look: @Composable () -> Unit = {
SettingsGroup(stringResource(R.string.setup_group_look)) {
SettingsRow(stringResource(R.string.setup_theme), stringResource(R.string.setup_theme_hint)) {
Box {
ValueChip(stringResource(Themes.byId(s.theme).label), host.open == "theme") { host.open = if (host.open == "theme") null else "theme" }
AnchoredMenu(host.open == "theme", onDismiss = { if (host.open == "theme") host.open = null }, title = stringResource(R.string.setup_theme)) { firstItemFocus ->
Themes.all.forEachIndexed { index, theme ->
MenuItem(stringResource(theme.label), checked = s.theme == theme.id, focusRequester = if (index == 0) firstItemFocus else null) {
a.onTheme(theme.id)
host.open = null
}
}
}
}
}
ChoiceRow(
host, "app-scale", stringResource(R.string.setup_app_scale), stringResource(R.string.setup_app_scale_hint),
com.droiddeck.launcher.core.AppUiPrefs.scales.map { percent ->
percent to stringResource(
if (percent == com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE) R.string.setup_app_scale_default
else R.string.ctrl_percent, percent,
)
}, s.appScale, onPick = a.onAppScale,
)
ToggleRow(host, "launcher-animations", stringResource(R.string.setup_animations), null,
s.animationsEnabled, onChange = a.onAnimationsEnabled)
LanguageRow(host, s.language, a.onLanguage)
ToggleRow(host, "show-stores", stringResource(R.string.setup_stores), null, s.gameStoresEnabled, onChange = a.onGameStoresEnabled)
}
}
val device: @Composable () -> Unit = {
SettingsGroup(stringResource(R.string.setup_group_device)) {
ToggleRow(
host, "home-screen", stringResource(R.string.setup_home),
if (s.homeScreenEnabled) stringResource(R.string.setup_home_on) else stringResource(R.string.setup_home_off),
s.homeScreenEnabled,
) { a.onHomeScreen(it) }
if (s.homeScreenEnabled) {
ActionRow(stringResource(R.string.setup_default_home), s.defaultHomeLabel ?: stringResource(R.string.setup_choose_home), stringResource(R.string.setup_choose), a.onHomeApp)
}
ChoiceRow(
host, "orientation", stringResource(R.string.setup_orientation), null,
SessionPrefs.orientationOptions.map { (value, label) -> value to stringResource(label) },
s.orientation, onPick = a.onOrientation,
)
ToggleRow(
host, "launcher-fullscreen", stringResource(R.string.setup_fullscreen),
if (s.launcherFullscreen) stringResource(R.string.setup_fullscreen_on) else stringResource(R.string.setup_fullscreen_off),
s.launcherFullscreen,
) { a.onLauncherFullscreen(it) }
}
}
val art: @Composable () -> Unit = {
SettingsGroup(stringResource(R.string.setup_group_art)) {
ToggleRow(host, "added-art", stringResource(R.string.mode_added_art), stringResource(R.string.mode_added_art_hint), s.addedGamesArt) { a.onAddedGamesArt(it) }
ActionRow(
stringResource(R.string.mode_sgdb_key), if (s.sgdbUserKey) stringResource(R.string.mode_sgdb_key_yours) else null,
stringResource(R.string.mode_sgdb_edit), onClick = { editSgdbKey = true },
)
}
}
// Two columns on a wide page: how it looks; this device and the library's art.
if (wide) Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.Top) {
Column(Modifier.weight(1f)) { look() }
Column(Modifier.weight(1f)) { device(); art() }
} else { look(); device(); art() }
}
3 -> {
SettingsGroup(stringResource(R.string.setup_tab_diagnostics)) {
ToggleRow(host, "logs", stringResource(R.string.setup_logs), stringResource(R.string.setup_logs_hint), s.logsEnabled) { a.onLogs() }
ToggleRow(host, "agent-commands", stringResource(R.string.setup_agent_commands), null, s.agentCommands) { a.onAgentCommands() }
ActionRow(stringResource(R.string.setup_latest_logs), stringResource(R.string.drawer_logs_hint), stringResource(R.string.drawer_share_logs), a.onShareLogs,
progress = com.droiddeck.launcher.session.SessionLogShare.progress)
ActionRow(stringResource(R.string.setup_saved_logs), stringResource(R.string.setup_saved_logs_hint, com.droiddeck.launcher.session.SessionPaths.KEEP_SESSIONS), stringResource(R.string.setup_clear_logs), a.onClearLogs)
ToggleRow(
host, "offline", stringResource(R.string.setup_offline),
s.offlineAccount?.let { if (it.isEmpty()) stringResource(R.string.setup_signed_in_unnamed) else stringResource(R.string.setup_signed_in, it) } ?: stringResource(R.string.setup_sign_in_first),
s.offline, enabled = s.offlineAccount != null,
) { a.onOffline() }
}
}
3 -> {
SettingsGroup(stringResource(R.string.setup_launcher)) {
LanguageRow(host, s.language, a.onLanguage)
ChoiceRow(
host, "app-scale", stringResource(R.string.setup_app_scale), stringResource(R.string.setup_app_scale_hint),
com.droiddeck.launcher.core.AppUiPrefs.scales.map { percent ->
percent to stringResource(
if (percent == com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE) R.string.setup_app_scale_default
else R.string.ctrl_percent, percent,
)
}, s.appScale, onPick = a.onAppScale,
)
SettingsRow(stringResource(R.string.setup_theme), stringResource(R.string.setup_theme_hint)) {
Box {
ValueChip(stringResource(Themes.byId(s.theme).label), host.open == "theme") { host.open = if (host.open == "theme") null else "theme" }
AnchoredMenu(host.open == "theme", onDismiss = { if (host.open == "theme") host.open = null }, title = stringResource(R.string.setup_theme)) { firstItemFocus ->
Themes.all.forEachIndexed { index, theme ->
MenuItem(stringResource(theme.label), checked = s.theme == theme.id, focusRequester = if (index == 0) firstItemFocus else null) {
a.onTheme(theme.id)
host.open = null
}
}
}
}
}
ToggleRow(
host, "home-screen", stringResource(R.string.setup_home),
if (s.homeScreenEnabled) stringResource(R.string.setup_home_on) else stringResource(R.string.setup_home_off),
s.homeScreenEnabled,
) { a.onHomeScreen(it) }
ChoiceRow(
host, "orientation", stringResource(R.string.setup_orientation), null,
SessionPrefs.orientationOptions.map { (value, label) -> value to stringResource(label) },
s.orientation, onPick = a.onOrientation,
)
ToggleRow(
host, "launcher-fullscreen", stringResource(R.string.setup_fullscreen),
if (s.launcherFullscreen) stringResource(R.string.setup_fullscreen_on) else stringResource(R.string.setup_fullscreen_off),
s.launcherFullscreen,
) { a.onLauncherFullscreen(it) }
ToggleRow(host, "launcher-animations", stringResource(R.string.setup_animations), null,
s.animationsEnabled, onChange = a.onAnimationsEnabled)
if (s.homeScreenEnabled) {
ActionRow(stringResource(R.string.setup_default_home), s.defaultHomeLabel ?: stringResource(R.string.setup_choose_home), stringResource(R.string.setup_choose), a.onHomeApp)
}
}
SettingsGroup(stringResource(R.string.setup_linux_apps)) {
ToggleRow(
host, "store-enabled", stringResource(R.string.setup_store),
if (s.storeEnabled) stringResource(R.string.setup_store_on)
else stringResource(R.string.setup_store_off),
s.storeEnabled,
) { a.onStoreEnabled(it) }
}
SettingsGroup(stringResource(R.string.content_games)) {
ToggleRow(host, "added-art", stringResource(R.string.mode_added_art), stringResource(R.string.mode_added_art_hint), s.addedGamesArt) { a.onAddedGamesArt(it) }
ActionRow(
stringResource(R.string.mode_sgdb_key), if (s.sgdbUserKey) stringResource(R.string.mode_sgdb_key_yours) else null,
stringResource(R.string.mode_sgdb_edit), onClick = { editSgdbKey = true },
)
}
SettingsGroup(stringResource(R.string.setup_stores)) {
ToggleRow(
host, "stores-enabled", stringResource(R.string.setup_stores_show),
null, s.gameStoresEnabled,
) { a.onGameStoresEnabled(it) }
// The same rows as the Stores page's own cog, for whoever looks here first.
SettingsRow(stringResource(R.string.setup_stores_open_on), null) {
SegmentedTabs(
listOf(SessionPrefs.STORES_OPEN_LIBRARY to stringResource(R.string.stores_tab_library), SessionPrefs.STORES_OPEN_STORE to stringResource(R.string.stores_tab_store)),
s.storesOpenTab,
) { a.onStoresOpenTab(it) }
}
ToggleRow(host, "stores-show-mature", stringResource(R.string.stores_show_mature), null, s.storesShowMature) { a.onStoresShowMature(it) }
SettingsRow(stringResource(R.string.setup_stores_speed), null) {
SegmentedTabs(StoreDownloadTier.ALL.map { it.id to stringResource(it.label) }, s.gameStoresSpeedTier) { a.onGameStoresSpeedTier(it) }
}
StoresEngineRow()
}
}
}
@@ -407,50 +413,43 @@ internal fun SetupPanel(
}
}
/** The launcher's own tools as cards: four across, two by two on a narrow page. */
/**
* The checks as one card while they all pass: a ring with how many pass, "Ready to play" and one
* line of what that means, and a Details chip that opens the checks themselves in place ([checks]).
*/
@Composable
private fun ToolGrid(s: FrontEndState, a: FrontEndActions) {
val columns = if (LocalNarrowPane.current) 2 else 4
val tools = listOf(
ToolSpec(Icons.Outlined.Folder, stringResource(R.string.setup_tool_files), stringResource(R.string.setup_tool_files_hint), a.onFiles),
ToolSpec(Icons.Outlined.Extension, stringResource(R.string.setup_tool_protons), stringResource(R.string.setup_tool_protons_hint), a.onProtons),
ToolSpec(Icons.Outlined.Speed, stringResource(R.string.setup_tool_performance), stringResource(R.string.setup_tool_performance_hint), a.onPerformance),
ToolSpec(Icons.Outlined.VideogameAsset, stringResource(R.string.setup_tool_roms), s.romsDir ?: stringResource(R.string.setup_tool_roms_hint), a.onRoms),
)
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
for (row in tools.chunked(columns)) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
for (t in row) ToolCard(t, Modifier.weight(1f).fillMaxHeight())
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
}
}
}
}
private class ToolSpec(val icon: ImageVector, val title: String, val detail: String, val onClick: () -> Unit)
@Composable
private fun ToolCard(t: ToolSpec, modifier: Modifier) {
private fun ReadinessCard(passing: Int, total: Int, line: String, open: Boolean, onDetails: () -> Unit, checks: @Composable () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val pressed by src.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "toolScale")
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier.paneItem("tool:${t.title}")
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(Shape14)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
.glideBorder(hot, Shape14, pal.signal, pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = t.onClick)
.controllerConfirm(onClick = t.onClick)
.padding(horizontal = 14.dp, vertical = 12.dp),
) {
Icon(t.icon, contentDescription = null, tint = if (hot) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(20.dp))
Text(t.title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(t.detail, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
val ready = passing == total
Column(Modifier.fillMaxWidth().padding(top = 8.dp).clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp),
) {
val tint = if (ready) pal.good else AttentionAmber
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(52.dp)) {
androidx.compose.material3.CircularProgressIndicator(
progress = { passing / total.toFloat().coerceAtLeast(1f) }, strokeWidth = 4.dp,
color = tint, trackColor = tint.copy(alpha = 0.18f), modifier = Modifier.size(52.dp),
)
Text("$passing/$total", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
}
Column(Modifier.weight(1f)) {
Text(
stringResource(if (ready) R.string.setup_ready else R.string.setup_not_ready),
fontSize = 17.sp, fontWeight = FontWeight.Bold, color = colors.onBackground,
)
if (line.isNotEmpty()) Text(line, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
if (passing > 0) SecondaryButton(stringResource(if (open) R.string.common_hide else R.string.setup_details), compact = true, onClick = onDetails)
}
AnimatedVisibility(open, enter = expandVertically(Motion.sp(1f)) + fadeIn(Motion.sp(1f)), exit = shrinkVertically(Motion.sp(1f)) + fadeOut(Motion.sp(1f))) {
Column(Modifier.fillMaxWidth()) {
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
checks()
}
}
}
}
@@ -472,14 +471,19 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost
val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current
// The game's prefix is compatdata/<id>, as droiddeck-game-env reads it: a Steam title's appid, an
// added game's shortcut appid (unsigned). Recommended scans the game's own folder either way.
val wincompKey = game?.let {
if (it.library == com.droiddeck.launcher.frontend.Library.ADDED) Integer.toUnsignedString(it.appId)
else it.appId.takeIf { id -> id > 0 }?.toString()
}
val wincompKey = game?.takeIf { it.library == com.droiddeck.launcher.frontend.Library.ADDED || it.appId > 0 }?.profileKey
var wincompOpen by remember(wincompKey) { mutableStateOf<Boolean?>(null) }
val wincompPicks = remember(wincompKey, wincompOpen) { wincompKey?.let { com.droiddeck.launcher.session.WinComponents.picks(context, it) }.orEmpty() }
val cards = buildList<@Composable (Modifier) -> Unit> {
add { m -> SettingCard(stringResource(R.string.setup_card_components), stringResource(R.string.setup_card_components_hint), "card:components", m) { a.onComponents(true) } }
add { m ->
val back = stringResource(R.string.hop_from_games)
// Components is its own section: the card hops there, and B on it comes back here.
SettingCard(stringResource(R.string.setup_card_components), stringResource(R.string.setup_card_components_hint), "card:components", m.hopSource("card:components")) {
val at = Hops.sources["card:components"]
if (at == null) a.onComponents(true)
else Hops.go(HopOrigin("games", HOP_SURFACE_GAMES, null, "card:components", at, back, dest = "components", page = "components")) { a.onComponents(true) }
}
}
add { m ->
Box(m) {
SettingCard(stringResource(R.string.frame_gen_title), s.frameGenLabel, "card:fg", Modifier.fillMaxSize()) {
@@ -491,8 +495,8 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost
if (controller != null) add { m -> SettingCard(stringResource(R.string.setup_card_controls), stringResource(R.string.setup_card_controls_hint), "card:controls", m, controller.onMapping) }
// An Epic game's own launch choices, kept in its sidecar so a launch from the Steam client
// honours them too (droiddeck-store-launch reads the same file).
// Epic's launch choices; cloud saves live in the game's Cloud saves view, so a GOG game has no card.
val cardStore = com.droiddeck.launcher.stores.Store.byId(game?.source.orEmpty())?.takeIf { it == com.droiddeck.launcher.stores.Store.EPIC }
// Store launch choices; cloud saves have their own view.
val cardStore = com.droiddeck.launcher.stores.Store.byId(game?.source.orEmpty())?.takeIf { it == com.droiddeck.launcher.stores.Store.EPIC || it == com.droiddeck.launcher.stores.Store.GOG }
if (cardStore != null && game?.gameFiles != null) add { m ->
Box(m) {
StoreLaunchCard(host, game.gameFiles!!, cardStore)
@@ -533,7 +537,14 @@ private fun StoreLaunchCard(host: MenuHost, folder: java.io.File, store: com.dro
val options = sidecar?.epic ?: com.droiddeck.launcher.stores.EpicOptions()
val epic = store == com.droiddeck.launcher.stores.Store.EPIC
val key = "store-" + store.id
val value = buildList {
val value = if (!epic) stringResource(when {
!com.droiddeck.launcher.stores.gog.GogComet.enabled(appContext) || sidecar?.achievements == false -> R.string.gog_achievements_off
com.droiddeck.launcher.stores.gog.GogComet.status == "sign-in-needed" -> R.string.gog_achievements_signin
com.droiddeck.launcher.stores.gog.GogComet.status in listOf("service-unavailable", "credentials-unavailable", "not-installed") -> R.string.gog_achievements_unavailable
com.droiddeck.launcher.stores.gog.GogComet.status == "uploaded" -> R.string.gog_achievements_uploaded
com.droiddeck.launcher.stores.gog.GogComet.status == "pending-upload" || com.droiddeck.launcher.stores.gog.GogComet.status == "unlocked-locally" -> R.string.gog_achievements_pending
else -> R.string.gog_achievements_on
}) else buildList {
if (epic && options.offline) add(stringResource(R.string.epic_card_offline))
else if (epic && options.eos) add(stringResource(R.string.epic_card_eos))
}.ifEmpty { listOf(stringResource(R.string.epic_card_none)) }.joinToString(" · ")
@@ -552,6 +563,16 @@ private fun StoreLaunchCard(host: MenuHost, folder: java.io.File, store: com.dro
host.open = if (host.open == key) null else key
}
AnchoredMenu(host.open == key, onDismiss = { if (host.open == key) host.open = null }, title = store.shortLabel) { first ->
if (!epic) {
MenuItem(stringResource(R.string.gog_achievements), checked = sidecar?.achievements != false, focusRequester = first,
detail = if (!com.droiddeck.launcher.stores.gog.GogComet.enabled(appContext)) stringResource(R.string.gog_achievements_enable_store) else null) {
val on = sidecar?.achievements == false
Thread({
val written = runCatching { com.droiddeck.launcher.stores.StoreGameSidecar.updateAchievements(folder, on) }.getOrNull()
com.droiddeck.launcher.stores.StoresState.post { sidecar = written ?: sidecar }
}, "gog-game-achievements").start()
}
}
if (epic) {
MenuItem(stringResource(R.string.epic_eos), checked = options.eos, focusRequester = first) { set { it.copy(eos = !it.eos) } }
MenuItem(stringResource(R.string.epic_offline), checked = options.offline) { set { it.copy(offline = !it.offline) } }
@@ -52,7 +52,11 @@ import kotlinx.coroutines.withContext
// dialog, with each variable's actions in a menu off its value as everywhere else.
@Composable
fun GameEnvironmentRow(modifier: Modifier = Modifier, showHint: Boolean = true) {
fun GameEnvironmentRow(
modifier: Modifier = Modifier,
showHint: Boolean = true,
initialScope: String = "",
) {
var open by remember { mutableStateOf(false) }
// Opened with a pad, the editor starts with focus in it; the dialog is a window of its own and
// would otherwise give the pad nothing to move from.
@@ -64,7 +68,7 @@ fun GameEnvironmentRow(modifier: Modifier = Modifier, showHint: Boolean = true)
open = true
}
}
if (open) GameEnvironmentEditor(byPad) { open = false }
if (open) GameEnvironmentEditor(byPad, initialScope) { open = false }
}
/** With a pad driving, focus [target] once the dialog is laid out, and keep the dialog in pad mode. */
@@ -81,19 +85,19 @@ internal fun PadFocus(byPad: Boolean, target: FocusRequester) {
}
@Composable
private fun GameEnvironmentEditor(byPad: Boolean, onClose: () -> Unit) {
internal fun GameEnvironmentEditor(byPad: Boolean, initialScope: String = "", onClose: () -> Unit) {
val context = LocalContext.current
val coroutine = rememberCoroutineScope()
val shown = rememberShown(onClose)
val close = { shown.targetState = false }
var config by remember { mutableStateOf<GameEnvironment.Config?>(null) }
var games by remember { mutableStateOf(emptyList<Pair<String, String>>()) }
var scope by remember { mutableStateOf("") }
var scope by remember { mutableStateOf(initialScope) }
var otherId by remember { mutableStateOf<String?>(null) }
var menu by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf(false) }
var editing by remember { mutableStateOf<Pair<String, String>?>(null) }
var editing by remember { mutableStateOf<EnvironmentVariableEdit?>(null) }
val preset = SessionPrefs.fexPreset(context)
val forceSsbs = SessionPrefs.forceSsbs(context)
val firstFocus = remember { FocusRequester() }
@@ -168,7 +172,7 @@ private fun GameEnvironmentEditor(byPad: Boolean, onClose: () -> Unit) {
VariableRow(
name, value, inherited = !own.containsKey(name), open = menu == "var:$name", enabled = !busy,
onToggle = { menu = if (menu == "var:$name") null else "var:$name" }, onDismiss = { if (menu == "var:$name") menu = null },
onEdit = { menu = null; editByPad = dialogInput.inputMode == InputMode.Keyboard; editing = name to (value ?: "") },
onEdit = { menu = null; editByPad = dialogInput.inputMode == InputMode.Keyboard; editing = EnvironmentVariableEdit(scope, profileName, name, value ?: "") },
// A value set here or inherited can be switched off for this profile; one set here can go back to inheriting.
onUnset = if (value != null) ({ menu = null; save(current.withEntries(scope, own + (name to null))) }) else null,
onRestore = if (own.containsKey(name)) ({ menu = null; save(current.withEntries(scope, own - name)) }) else null,
@@ -185,9 +189,9 @@ private fun GameEnvironmentEditor(byPad: Boolean, onClose: () -> Unit) {
SecondaryButton(stringResource(R.string.game_env_add), enabled = !busy) { menu = if (menu == "add") null else "add" }
AnchoredMenu(menu == "add", onDismiss = { if (menu == "add") menu = null }, title = stringResource(R.string.game_env_add)) { first ->
for ((i, option) in GameEnvironmentOptions.all.withIndex()) MenuItem(option.name, checked = false, focusRequester = if (i == 0) first else null) {
menu = null; editByPad = dialogInput.inputMode == InputMode.Keyboard; editing = option.name to option.value
menu = null; editByPad = dialogInput.inputMode == InputMode.Keyboard; editing = EnvironmentVariableEdit(scope, profileName, option.name, option.value)
}
MenuItem(stringResource(R.string.game_env_custom), checked = false) { menu = null; editByPad = dialogInput.inputMode == InputMode.Keyboard; editing = "" to "" }
MenuItem(stringResource(R.string.game_env_custom), checked = false) { menu = null; editByPad = dialogInput.inputMode == InputMode.Keyboard; editing = EnvironmentVariableEdit(scope, profileName, "", "") }
}
}
if (current != null && current.entries(scope).isNotEmpty()) FocusText(stringResource(R.string.game_env_reset), LocalPalette.current.signal) {
@@ -197,19 +201,21 @@ private fun GameEnvironmentEditor(byPad: Boolean, onClose: () -> Unit) {
PrimaryButton(stringResource(R.string.game_env_done), enabled = !busy, onClick = close)
}
}
editing?.let { (name, value) ->
VariableDialog(name, value, profileName, editByPad, onDismiss = { editing = null }) { key, content ->
editing?.let { edit ->
VariableDialog(edit.name, edit.value, edit.profileName, editByPad, onDismiss = { editing = null }) { key, content ->
config?.let {
val entries = it.entries(scope).toMutableMap()
if (name.isNotEmpty() && name != key) entries[name] = null
val entries = it.entries(edit.scope).toMutableMap()
if (edit.name.isNotEmpty() && edit.name != key) entries[edit.name] = null
entries[key] = content
save(it.withEntries(scope, entries))
save(it.withEntries(edit.scope, entries))
}
editing = null
}
}
}
private data class EnvironmentVariableEdit(val scope: String, val profileName: String, val name: String, val value: String)
/** The panel the profile and the variables sit in, as a settings group does. */
@Composable
private fun Panel(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) =
@@ -57,6 +57,10 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
/** One matched driver pair in the list (DriverPairs), and what this device makes of it. */
class PairRow(
@@ -119,155 +123,38 @@ class GpuDriversActions(
)
/**
* The GPU drivers tab: this GPU, and the drivers it draws with - a runtime (Linux) driver for the
* games and a display (Android) driver for the screen, set together as one matched pair. Auto keeps
* the pair recommended for the GPU installed and current; Manual picks a pair from the list; the
* two lists each on their own, and imports, are under Advanced.
* Everything about the chip - SoC, GPU, CPU, memory, system, the phone's own Vulkan driver, the
* drivers in use - gathered once, off the main thread, with a button that copies it for a bug
* report. Stepped out of the Components page's device chip; [first] is where a pad starts.
*/
@Composable
internal fun GpuDriversPanel(s: GpuDriversState, a: GpuDriversActions, onAdvanced: (String) -> Unit) {
internal fun DeviceDetails(first: FocusRequester) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
var showAll by rememberSaveable { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
DeviceCard(s)
if (!s.unsupported) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
ModeCard(Icons.Outlined.AutoAwesome, stringResource(R.string.common_auto), stringResource(R.string.gpu_auto_hint), s.auto, Modifier.weight(1f)) { a.onAuto(true) }
ModeCard(Icons.Outlined.Tune, stringResource(R.string.mode_suspend_manual), stringResource(R.string.gpu_manual_hint), !s.auto, Modifier.weight(1f)) { a.onAuto(false) }
}
if (s.auto) AutoCard(s)
else {
val shown = s.pairs.filter { it.suits || showAll }
if (shown.isEmpty()) Text(
if (s.checking) stringResource(R.string.gpu_checking) else stringResource(R.string.gpu_none_listed),
fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 8.dp),
)
for (p in shown) PairLine(p, s.busy == p.key, s.percent, enabled = s.busy == null) { a.onPair(p.key) }
if (s.pairs.any { !it.suits }) FocusText(
if (showAll) stringResource(R.string.gpu_hide_other) else stringResource(R.string.gpu_show_other), pal.signal,
) { showAll = !showAll }
}
}
SettingsGroup(stringResource(R.string.gpu_advanced)) {
SettingsRow(stringResource(R.string.comp_runtime_driver), s.activeBundle?.let { stringResource(R.string.gpu_set_with_display, it) } ?: stringResource(R.string.gpu_runtime_hint)) {
DriverDropdown(s.linuxRows, s.linuxSelected, stringResource(R.string.gpu_runtime_default), a.onSelectLinux) { onAdvanced("rt") }
}
SettingsRow(stringResource(R.string.comp_display_driver), s.activeBundle?.let { stringResource(R.string.gpu_set_with_runtime, it) } ?: stringResource(R.string.gpu_display_hint)) {
DriverDropdown(s.androidRows, s.androidSelected, stringResource(R.string.gpu_auto_by_gpu), a.onSelectAndroid) { onAdvanced("panel") }
}
}
}
}
/**
* One driver list as a drop-down: the installed drivers to pick from, and the full page for
* downloading and deleting them. An Android + Linux bundle's row is tagged, and picking it sets both.
*/
@Composable
private fun DriverDropdown(rows: List<DriverRow>, selected: String, fallback: String, onSelect: (String) -> Unit, onManage: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
var open by remember { mutableStateOf(false) }
Box {
SecondaryButton((rows.firstOrNull { it.id == selected }?.name ?: fallback) + " ▾", compact = true) { open = true }
DropdownMenu(open, onDismissRequest = { open = false }, modifier = Modifier.heightIn(max = 360.dp)) {
for (row in rows) DropdownMenuItem(
leadingIcon = { Radio(row.id == selected) },
text = {
Column {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(row.name, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = if (row.id == selected) pal.signal else colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (row.tag == DriverRow.BUNDLE) Text(driverTagLabel(row.tag), fontSize = 10.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = colors.onSurfaceVariant)
}
if (row.detail.isNotEmpty()) Text(row.detail, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
},
onClick = { open = false; if (row.id != selected) onSelect(row.id) },
)
HorizontalDivider(color = pal.line)
DropdownMenuItem(text = { Text(stringResource(R.string.gpu_manage), fontSize = 14.sp, color = pal.signal) }, onClick = { open = false; onManage() })
}
}
}
/**
* This GPU and how far it is supported. A tap (or A) opens the card onto everything about the chip -
* SoC, GPU, CPU, memory, system, the phone's own Vulkan driver, the drivers in use - gathered once,
* off the main thread, with a button that copies it for a bug report.
*/
@Composable
private fun DeviceCard(s: GpuDriversState) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val context = LocalContext.current
val clipboard = LocalClipboardManager.current
val tint = when {
s.unsupported -> pal.error
s.supported -> pal.good
else -> AttentionAmber
}
var open by rememberSaveable { mutableStateOf(false) }
var details by remember { mutableStateOf<List<DeviceInfo.Section>?>(null) }
var copied by remember { mutableStateOf(false) }
LaunchedEffect(open) {
if (open && details == null) details = withContext(Dispatchers.IO) { DeviceInfo.collect(context) }
}
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val toggle = { open = !open; copied = false }
LaunchedEffect(Unit) { details = withContext(Dispatchers.IO) { DeviceInfo.collect(context) } }
Column(
Modifier.fillMaxWidth().clip(Shape14).background(colors.surface)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else Color.Transparent)
.glideBorder(hot, Shape14, pal.signal, pal.line),
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().heightIn(max = 360.dp).verticalScroll(rememberScrollState()),
) {
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().paneItem("gpu-device")
.hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Button, onClick = toggle)
.controllerConfirm(onClick = toggle)
.padding(14.dp),
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(Shape12).background(tint.copy(alpha = 0.14f))) {
Icon(Icons.Outlined.Memory, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp))
}
Column(Modifier.weight(1f)) {
Text(s.gpuName, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
Text(listOf(s.soc, s.gpuFamily).filter { it.isNotEmpty() }.joinToString(" · "), fontSize = 13.sp, color = colors.onSurfaceVariant)
}
// The verdict shares the width with the GPU's name and wraps, rather than taking all it
// needs: a long one (in some languages) would squeeze the name to a letter a line.
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.weight(1f, fill = false)) {
Box(Modifier.size(7.dp).clip(CircleShape).background(tint))
Text(s.supportText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = tint, maxLines = 3, overflow = TextOverflow.Ellipsis)
}
Icon(
if (open) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore,
contentDescription = stringResource(if (open) R.string.device_info_hide else R.string.device_info_show),
tint = colors.onSurfaceVariant, modifier = Modifier.size(24.dp),
)
}
AnimatedVisibility(open) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth().padding(start = 14.dp, end = 14.dp, bottom = 14.dp)) {
HorizontalDivider(color = pal.line)
val sections = details
if (sections == null) {
Text(stringResource(R.string.device_info_loading), fontSize = 13.sp, color = colors.onSurfaceVariant)
LinearProgressIndicator(Modifier.fillMaxWidth())
} else {
for (section in sections) DeviceInfoSection(section)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
stringResource(if (copied) R.string.device_info_copied else R.string.device_info_copy_hint),
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.weight(1f),
)
SecondaryButton(stringResource(R.string.device_info_copy), compact = true) {
clipboard.setText(AnnotatedString(DeviceInfo.text(sections)))
copied = true
}
}
val sections = details
if (sections == null) {
Text(stringResource(R.string.device_info_loading), fontSize = 13.sp, color = colors.onSurfaceVariant)
LinearProgressIndicator(Modifier.fillMaxWidth())
} else {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
stringResource(if (copied) R.string.device_info_copied else R.string.device_info_copy_hint),
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.weight(1f),
)
SecondaryButton(stringResource(R.string.device_info_copy), compact = true, modifier = Modifier.focusRequester(first)) {
clipboard.setText(AnnotatedString(DeviceInfo.text(sections)))
copied = true
}
}
for (section in sections) DeviceInfoSection(section)
}
}
}
@@ -288,115 +175,9 @@ private fun DeviceInfoSection(section: DeviceInfo.Section) {
Text(section.title.uppercase(), fontSize = 11.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.2.sp, color = colors.onSurfaceVariant)
for ((label, value) in section.rows) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text(label, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.width(190.dp))
Text(label, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.width(150.dp))
Text(value, fontSize = 13.sp, fontWeight = FontWeight.Medium, color = colors.onBackground, modifier = Modifier.weight(1f))
}
}
}
}
/** What Auto has set and what it is doing. */
@Composable
private fun AutoCard(s: GpuDriversState) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val active = s.activePair
val recommended = s.pairs.firstOrNull { it.recommended }
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(14.dp),
) {
Text(stringResource(R.string.gpu_in_use_caps), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = colors.onSurfaceVariant)
Text(
active?.let { "${it.name} ${it.version}" } ?: s.activeBundle ?: stringResource(R.string.gpu_builtin),
fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground,
)
Text(
if (active != null || s.activeBundle != null) stringResource(R.string.gpu_matched_pair)
else recommended?.let { stringResource(R.string.gpu_recommended_for, it.name) } ?: stringResource(R.string.gpu_refresh_hint),
fontSize = 13.sp, color = colors.onSurfaceVariant,
)
if (s.busy != null) {
LinearProgressIndicator(
progress = { (s.percent.coerceAtLeast(0)) / 100f },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp).height(6.dp).clip(CircleShape),
)
}
if (s.autoStatus.isNotEmpty()) Text(s.autoStatus, fontSize = 13.sp, color = colors.onBackground, modifier = Modifier.padding(top = 4.dp))
}
}
@Composable
private fun ModeCard(icon: ImageVector, title: String, hint: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
// Selection is the radio; the ring is only ever focus.
val fill = if (hot) pal.signal.copy(alpha = 0.14f) else Color.Transparent
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = modifier.paneItem("gpu-mode:$title").heightIn(min = 56.dp)
.clip(Shape14).background(colors.surface).background(fill)
.glideBorder(hot, Shape14, pal.signal, pal.line)
.hoverable(src).clickable(interactionSource = src, indication = null, role = Role.RadioButton, onClick = onClick)
.controllerConfirm(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
Icon(icon, contentDescription = null, tint = if (selected) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(22.dp))
Column(Modifier.weight(1f)) {
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
Text(hint, fontSize = 12.5.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Radio(selected)
}
}
@Composable
private fun PairLine(p: PairRow, busy: Boolean, percent: Int, enabled: Boolean, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val usable = enabled && p.complete && !p.active
val fill = if (hot) pal.signal.copy(alpha = 0.14f) else Color.Transparent
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().paneItem("pair:${p.key}").heightIn(min = 56.dp)
.clip(Shape12).background(colors.surface).background(fill)
.glideBorder(hot, Shape12, pal.signal, pal.line)
.hoverable(src).clickable(interactionSource = src, indication = null, enabled = usable, role = Role.RadioButton, onClick = onClick)
.controllerConfirm(enabled = usable, onClick = onClick)
.padding(horizontal = 14.dp, vertical = 8.dp),
) {
Radio(p.active)
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(p.name, fontSize = 14.5.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
if (p.recommended) Text(
stringResource(R.string.gpu_recommended_caps), fontSize = 10.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = pal.good,
modifier = Modifier.clip(RoundedCornerShape(5.dp)).background(pal.good.copy(alpha = 0.12f)).padding(horizontal = 6.dp, vertical = 2.dp),
)
}
Text("${p.version} · ${p.detail}", fontSize = 12.5.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
when {
busy -> Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(96.dp)) {
Text(if (percent >= 0) "$percent%" else "…", fontSize = 12.sp, color = colors.onSurfaceVariant)
LinearProgressIndicator(progress = { percent.coerceAtLeast(0) / 100f }, modifier = Modifier.fillMaxWidth().padding(top = 4.dp))
}
p.active -> Text(stringResource(R.string.comp_tag_in_use), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal)
!p.complete -> Text(stringResource(R.string.gpu_unavailable), fontSize = 13.sp, color = colors.onSurfaceVariant)
p.installed -> Text(stringResource(R.string.common_use), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
else -> Text(stringResource(R.string.common_download), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
}
}
}
@Composable
private fun Radio(selected: Boolean) {
val pal = LocalPalette.current
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).clip(CircleShape).border(2.dp, if (selected) pal.signal else pal.line2, CircleShape)) {
if (selected) Box(Modifier.size(10.dp).clip(CircleShape).background(pal.signal))
}
}
@@ -0,0 +1,174 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
// A link from a control in one section to a page in another (the Play sheet's Graphics drivers,
// a game's Components card): the control squashes into a dot that flies to the destination's rail
// item, the rail's selection glides there as it passes the top of its arc, and the page floods
// out of the rail item once it lands. B on that page flies the dot back and puts the source
// surface back as it was, focus on the control that asked.
/**
* Where a hop came from: the rail item that was showing ([rail]), the surface on it (a sheet, the
* Games page) and its tab, the control's key and its root bounds, and the words for the way back.
* [page] is the page key the hop opened, so leaving it any other way forgets the way back.
*/
internal class HopOrigin(
val rail: String,
val surface: String,
val tab: String?,
val control: String,
val bounds: Rect,
val back: String,
val dest: String,
val page: String,
)
/** The surfaces a hop can start from and come back to. */
internal const val HOP_SURFACE_GAMES = "games"
internal const val HOP_SURFACE_DESKTOP = "desktop"
internal const val HOP_SURFACE_SHEET = "sheet"
internal object Hops {
/** The hop the page on screen was reached by, while B can still undo it. */
var origin by mutableStateOf<HopOrigin?>(null)
/** The pane content a hop is leaving (a rail key): it sinks back as the dot leaves it. */
var sinking by mutableStateOf<String?>(null)
/** Set while a hop's dot is flying: the rail item its selection has already glided to. */
var railPreview by mutableStateOf<String?>(null)
/**
* Set as a page leaves through a hop (either way), so the pane sinks it rather than draining
* it; the pane clears it once the change has started.
*/
var linking by mutableStateOf(false)
/** A surface to put back as it was (a sheet re-lifted on its tab), once, and the control to focus. */
var restore by mutableStateOf<HopOrigin?>(null)
/** Per rail item, bumped when a dot lands on it: the item pops. */
val railLanded = mutableStateMapOf<String, Int>()
/** Bumped when a hop's page has flooded in: its target (a layer card) sends out one ring. */
var arrived by mutableIntStateOf(0)
/** Where each rail item sits, root px. */
val railBounds = HashMap<String, Rect>()
/** Where each control that can start a hop sits, root px. */
val sources = HashMap<String, Rect>()
/**
* Hops from the control at [origin].bounds to [origin].dest: [go] opens the page there once the dot lands.
* With animations off there is no dot and no flood, only the page.
*/
fun go(origin: HopOrigin, go: () -> Unit) {
val railAt = railBounds[origin.dest]
val finish: () -> Unit = {
railPreview = null
this.origin = origin
if (railAt != null && Motion.scale != 0f) PageOrigin.mark(Origin(railAt, 14f * density, owner = origin.dest))
linking = true
go()
railLanded[origin.dest] = (railLanded[origin.dest] ?: 0) + 1
arrived++
}
if (railAt == null || Motion.scale == 0f) { finish(); return }
// The surface the control is on sinks as its dot leaves, and stays down as it goes.
sinking = origin.rail
Flights.fly(Flight(
origin.bounds, to = { railAt.center },
onHalf = { railPreview = origin.dest },
onLand = finish,
))
}
/**
* B on a page reached by a hop: the page sinks, the source surface comes back as it was, and
* the dot flies home from the rail item to the control. False when there is no hop to undo.
*/
fun back(close: () -> Unit): Boolean {
val o = origin ?: return false
origin = null
sinking = null
val railAt = railBounds[o.dest]
railPreview = if (railAt != null && Motion.scale != 0f) o.dest else null
restore = o
linking = true
close()
if (railAt == null || Motion.scale == 0f) { railPreview = null; return true }
Flights.fly(Flight(
railAt, to = { (sources[o.control] ?: o.bounds).center },
onHalf = { railPreview = null },
))
return true
}
/** The page the hop opened has gone some other way: there is no way back to keep. */
fun forget(pageKey: String?) {
val o = origin ?: return
if (pageKey != o.page) { origin = null; sinking = null }
}
/** Screen density, for the rail item's corner; set by the front end. */
var density = 1f
}
/** Shows a rail section directly (no page closing, nothing forgotten): what a hop's way back calls. */
internal val LocalShowSection = androidx.compose.runtime.staticCompositionLocalOf<(String) -> Unit> { {} }
/** Remembers where this control sits, so a hop from it can fly from it and back to it. */
internal fun Modifier.hopSource(key: String): Modifier = onGloballyPositioned { Hops.sources[key] = it.boundsInRoot() }
/**
* "B · back to <source>" at the right of a page's header while it was reached by a hop; a tap is B.
* Nothing when it was not.
*/
@Composable
internal fun HopBackChip(onBack: () -> Unit) {
val o = Hops.origin ?: return
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val shape = RoundedCornerShape(99.dp)
Text(
stringResource(R.string.hop_back, o.back), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, maxLines = 1,
color = if (hot) colors.onBackground else colors.onSurfaceVariant,
modifier = Modifier.heightIn(min = 44.dp).padding(vertical = 6.dp).clip(shape)
.background(pal.signal.copy(alpha = if (hot) 0.16f else 0.08f))
.glideBorder(hot, shape, pal.signal, pal.line2)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onBack)
.controllerConfirm(onClick = onBack)
.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
@@ -0,0 +1,848 @@
package com.droiddeck.launcher.ui
import android.view.KeyEvent
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
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.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.ui.platform.testTag
import kotlinx.coroutines.Job
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
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.runtime.staticCompositionLocalOf
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.zIndex
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.boundsInParent
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.animation.core.animateFloat
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
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 androidx.compose.ui.util.lerp
import com.droiddeck.launcher.R
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
// Play's settings, lifted out from under Play instead of opened on a page of their own. At rest a
// lip tucks out under Play and says what it will do. Down (or a pull up, or a tap on the lip)
// stretches the lip, the wall sinks back, and a sheet rises from the pane's foot with Play riding
// its top edge as the handle - StepOut's move, with the pane for a box. B, Up from the chips or a
// tap above folds it away; A on Play launches straight from it, the sheet folding into the pill
// before the launch flood. Open desktop does the same with the desktop's settings.
/** The sheets' tabs (their chips), by id. */
internal object SheetTab {
const val DISPLAY = "display"
const val CONTROLS = "controls"
const val AUDIO = "audio"
const val SESSION = "session"
const val LIBRARY = "library"
const val STEAM = "steam"
const val FILES = "files"
}
/**
* Where the open sheet's top edge sits in a pane [paneH] tall (px), for a button [buttonH] tall
* riding it: 60dp down a 360dp pane, the same share of any other, and never so high that the
* button's top comes closer than [minAbove] to the pane's. [ride] is how far below the edge the
* button's centre sits.
*/
internal fun sheetTop(paneH: Float, buttonH: Float, minAbove: Float, ride: Float): Float =
maxOf(paneH * 60f / 360f, minAbove + buttonH / 2f - ride)
/** A finger's hold on the sheet: where it started (0 rest, 1 open), where it is, whether it has it. */
private class SheetDrag {
var from = 0f
var progress = 0f
var engaged = false
}
/** One piece of the sheet's rows fades and rises in on its own beat (a settings group asks for its index). */
internal class SheetBeat(private val pieces: List<Animatable<Float, AnimationVector1D>>) {
private var next = 1
fun take(): Int = next++
fun value(i: Int): Float = pieces[i.coerceIn(0, pieces.lastIndex)].value
}
/** The sheet whose rows are being laid out, so each settings group can take its beat. */
internal val LocalSheetBeat = staticCompositionLocalOf<SheetBeat?> { null }
/** A settings group's piece of the sheet's entrance; nothing outside a sheet. */
@Composable
internal fun Modifier.sheetPiece(): Modifier {
val beat = LocalSheetBeat.current ?: return this
val i = remember { beat.take() }
return graphicsLayer {
val v = beat.value(i)
alpha = v
translationY = (1f - v) * 6.dp.toPx()
}
}
private val SheetInset = 12.dp
private val SheetCorner = 18.dp
private val ChipsTop = 14.dp
private val RowsTop = 54.dp
private const val PIECES = 12
/**
* A hero with one big button and the sheet that lifts out from under it. [key] names the sheet
* (a hop comes back to it, the lip remembers what it said); [tabs] are its chips (id to label) and
* [rows] what each holds. [background] sinks and [words] fade as it opens. [button] draws the
* button itself with the click it should make; [onPrimary] is what that click does once the
* sheet is out of the way. [onOpen] reads the settings the rows show.
*/
@OptIn(ExperimentalComposeUiApi::class, androidx.compose.animation.ExperimentalAnimationApi::class)
@Composable
internal fun LiftHero(
key: String,
tabs: List<Pair<String, String>>,
onOpen: () -> Unit,
onPrimary: () -> Unit,
background: @Composable () -> Unit,
/**
* Drawn over [background] at the pane's own size: the wall's fades, which must not sink with
* it. Given how far the sheet is up (0 to 1), for anything there that should give way to it.
*/
scrim: @Composable (lifted: () -> Float) -> Unit = {},
words: @Composable () -> Unit,
/**
* Buttons beside the big one (Desktop's Flathub and +). They stay where they are and fade as
* the sheet lifts: only the big button rides it.
*/
beside: @Composable () -> Unit = {},
button: @Composable (onClick: () -> Unit) -> Unit,
rows: @Composable (tab: String) -> Unit,
modifier: Modifier = Modifier,
/**
* Down on the button lifts the sheet (Play). False: Down goes to the lip, which A lifts, and Down
* from the lip goes on past it to what is under the hero (Desktop's shelves).
*/
downOpens: Boolean = true,
/** Told when the sheet starts to lift (true) and once it has gone (false). */
onOpenChange: (Boolean) -> Unit = {},
start: Dp = 44.dp,
bottom: Dp = 44.dp,
) {
val density = LocalDensity.current
val inputMode = LocalInputModeManager.current
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val ff = LocalFrontFocus.current
val narrow = LocalNarrowPane.current
// Held as states too, so the sheet's focus rule reads what they are now, not at the last composition.
val openState = remember { mutableStateOf(false) }
var open by openState
var shown by remember { mutableStateOf(false) }
val launchingState = remember { mutableStateOf(false) }
var launching by launchingState
var tab by remember { mutableStateOf(0) }
var travel by remember { mutableStateOf(1) }
// The control to focus once a sheet put back by a hop's B has lifted, instead of the first chip.
var focusOnLift by remember { mutableStateOf<String?>(null) }
// Set as the sheet closes and Play lands: the lip may come back before the sheet has quite gone.
var lipBack by remember { mutableStateOf(false) }
// Set as a finger's drag lets go: the sheet is already where it is going.
var dragSettled by remember { mutableStateOf(false) }
val scope = androidx.compose.runtime.rememberCoroutineScope()
var dragJob by remember { mutableStateOf<Job?>(null) }
val onOpenNow = rememberUpdatedState(onOpen)
val pull = remember { Animatable(0f) }
val sink = remember { Animatable(0f) }
val lift = remember { Animatable(0f) }
val ride = remember { Animatable(0f) }
val hot = remember { Animatable(1f) }
val lipShow = remember { Animatable(0f) }
val lipGone = remember { Animatable(0f) }
val fold = remember { Animatable(0f) }
val foldTop = remember { Animatable(0f) }
val pieces = remember { List(PIECES) { Animatable(0f) } }
val chipFocus = remember(tabs.size) { List(tabs.size) { FocusRequester() } }
val playSrc = remember { MutableInteractionSource() }
val playHovered by playSrc.collectIsHoveredAsState()
var playFocused by remember { mutableStateOf(false) }
// Set as a pad leaves the sheet to the left for Play, which folds it once Play has focus.
val leftForPlay = remember { booleanArrayOf(false) }
var heroAt by remember { mutableStateOf(Offset.Zero) }
var rest by remember { mutableStateOf<Rect?>(null) }
fun focusPlay() { if (ff != null && inputMode.inputMode == InputMode.Keyboard) runCatching { ff.primary.requestFocus() } }
fun close() {
if (!open) return
// Focus leaves the rows before they go; the sheet lets it go once it is closing.
open = false
focusPlay()
}
// Back from a hop the sheet started (or a page it opened): put it back as it was.
LaunchedEffect(Unit) {
val r = Hops.restore ?: return@LaunchedEffect
if (r.surface != HOP_SURFACE_SHEET || r.rail != key) return@LaunchedEffect
Hops.restore = null
tab = tabs.indexOfFirst { it.first == r.tab }.coerceAtLeast(0)
focusOnLift = r.control
open = true
}
// Launched: the session covers the screen, and the sheet is put away under it.
val lifecycle = androidx.compose.ui.platform.LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle) {
val obs = androidx.lifecycle.LifecycleEventObserver { _, e ->
if (e == androidx.lifecycle.Lifecycle.Event.ON_STOP && (open || shown)) {
open = false; shown = false; launching = false
}
}
lifecycle.addObserver(obs)
onDispose { lifecycle.removeObserver(obs) }
}
LaunchedEffect(open, launching) {
if (!dragSettled) { dragJob?.cancel(); dragJob = null }
if (dragSettled) {
// A finger brought it here and has already animated it: only what opening needs.
dragSettled = false
if (open) {
if (inputMode.inputMode == InputMode.Keyboard) { withFrameNanos { }; runCatching { chipFocus[tab].requestFocus() } }
} else focusPlay()
return@LaunchedEffect
}
if (open && !launching) {
lipBack = false
shown = true
onOpen()
fold.snapTo(0f); foldTop.snapTo(0f)
coroutineScope {
// Pull: the lip stretches down as the wall sinks back and dims.
launch { pull.animateTo(24f, Motion.sp(0.7f, 380f)) }
launch { sink.animateTo(1f, Motion.tw(260)) }
// Lift: the sheet rises with Play riding its top edge; the lip lets go.
launch {
delay(Motion.ms(120).toLong())
launch { lipGone.animateTo(1f, Motion.tw(140)) }
launch { pull.animateTo(0f, Motion.sp(1f, 500f)) }
launch { lift.animateTo(1f, Motion.sp(0.7f, 380f)) }
launch { ride.animateTo(1f, Motion.sp(0.7f, 380f)) }
launch { hot.snapTo(1f); delay(Motion.ms(100).toLong()); hot.animateTo(0f, Motion.tw(200)) }
}
// Each piece on its beat from the Lift, as the reel has it: 260ms + 55ms a piece after it starts.
pieces.forEachIndexed { i, p -> launch { delay(Motion.ms(120 + 260 + i * 55).toLong()); p.animateTo(1f, Motion.tw(220)) } }
if (inputMode.inputMode == InputMode.Keyboard) launch {
val control = focusOnLift
focusOnLift = null
delay(Motion.ms(if (control != null) 420 else 300).toLong())
withFrameNanos { }
val target = control?.let { ff?.items?.get(it) } ?: chipFocus[tab]
runCatching { target.requestFocus() }
}
}
} else if (launching) {
// Play from the sheet: the rows go, the sheet folds into the pill, then the launch.
coroutineScope {
pieces.forEach { launch { it.animateTo(0f, Motion.tw(90)) } }
launch { delay(Motion.ms(60).toLong()); fold.animateTo(1f, Motion.sp(1f, 420f)) }
launch { delay(Motion.ms(160).toLong()); foldTop.animateTo(1f, Motion.sp(1f, 380f)) }
}
shown = false
onPrimary()
// The session covers the screen and the sheet is put away under it (ON_STOP). If
// nothing started, put it away anyway.
delay(3_000)
launching = false
} else if (shown) {
coroutineScope {
pieces.forEach { launch { it.animateTo(0f, Motion.tw(90)) } }
launch { delay(Motion.ms(60).toLong()); lift.animateTo(0f, Motion.sp(1f, 380f)) }
launch { delay(Motion.ms(100).toLong()); ride.animateTo(0f, Motion.sp(0.55f, 300f)) }
launch { sink.animateTo(0f, Motion.tw(300)) }
// The lip tucks back under Play as it lands, not once every spring has settled.
launch { delay(Motion.ms(420).toLong()); lipGone.snapTo(0f); lipBack = true }
}
shown = false
lipBack = false
lipGone.snapTo(0f)
hot.snapTo(1f)
} else {
// Snapped shut under a session: everything back at rest, no animation.
listOf(pull, sink, lift, ride, lipGone, fold, foldTop).forEach { it.snapTo(0f) }
pieces.forEach { it.snapTo(0f) }
hot.snapTo(1f)
}
}
BackHandler(enabled = open && !launching) { close() }
// The ring keeps out of the lift and the fold, and comes back on whatever has focus once
// they are done: Play rides the sheet on a layer, and the ring hung where Play had been.
if (ride.isRunning || (shown && !open)) VeilRing()
val openNow = open || shown
LaunchedEffect(openNow) { onOpenChange(openNow) }
// The lip: there while Play has a pad or a pointer on it, or always with touch alone.
val touchOnly = inputMode.inputMode == InputMode.Touch
val lipFocus = remember { FocusRequester() }
val settingsLabel = stringResource(R.string.drawer_page_settings)
var lipFocused by remember { mutableStateOf(false) }
val lipWanted = !open && (!shown || lipBack) && (playFocused || playHovered || touchOnly || lipFocused)
LaunchedEffect(lipWanted) {
if (lipWanted) {
// Back from the sheet it comes straight away; otherwise a beat after Play has focus.
if (!lipBack) delay(Motion.ms(160).toLong())
lipShow.animateTo(1f, Motion.tw(220))
} else {
delay(Motion.ms(140).toLong())
lipShow.animateTo(0f, Motion.tw(220))
}
}
// How far the sheet travels, for a finger's drag to follow: set once the hero is measured.
val travelPx = remember { floatArrayOf(1f) }
val drag = remember { SheetDrag() }
/** The sheet [p] of the way up (0 at rest, 1 open) under a finger. */
fun follow(p: Float) {
drag.progress = p
scope.launch {
lift.snapTo(p); ride.snapTo(p); sink.snapTo(p.coerceIn(0f, 1f)); hot.snapTo(0f)
lipGone.snapTo(1f)
pieces.forEach { it.snapTo(p.coerceIn(0f, 1f)) }
}
}
/** A finger takes the sheet where it is: up from rest, or down from open. */
fun beginDrag() {
dragJob?.cancel(); dragJob = null
dragSettled = false
drag.engaged = true
drag.from = if (open) 1f else 0f
drag.progress = drag.from
if (!open) {
shown = true
scope.launch { fold.snapTo(0f); foldTop.snapTo(0f) }
onOpenNow.value()
}
}
/**
* The finger lets go with [v] px/s (up negative): a flick goes the way it was flicked; a slow
* drag stays once it is a third of the way from where it started.
*/
fun settleDrag(v: Float) {
drag.engaged = false
val flick = with(density) { 600.dp.toPx() }
val opens = when {
v < -flick -> true
v > flick -> false
drag.from > 0.5f -> drag.progress > 0.7f
else -> drag.progress > 0.3f
}
dragSettled = true
dragJob?.cancel()
dragJob = scope.launch {
val to = if (opens) 1f else 0f
coroutineScope {
launch { lift.animateTo(to, Motion.sp(if (opens) 0.7f else 1f, 380f)) }
launch { ride.animateTo(to, Motion.sp(if (opens) 0.7f else 0.55f, if (opens) 380f else 300f)) }
launch { sink.animateTo(to, Motion.tw(220)) }
pieces.forEachIndexed { i, piece -> launch { if (opens) delay(Motion.ms(i * 35).toLong()); piece.animateTo(to, Motion.tw(if (opens) 200 else 90)) } }
}
if (!opens) { shown = false; lipGone.snapTo(0f); hot.snapTo(1f); lipBack = false }
}
if (open != opens) open = opens else dragSettled = false
}
// Pulled down past the top of the rows, the sheet itself comes down with the finger; up again
// before letting go, it goes back up before the rows scroll.
val rowsPull = remember {
object : androidx.compose.ui.input.nestedscroll.NestedScrollConnection {
override fun onPreScroll(available: Offset, source: androidx.compose.ui.input.nestedscroll.NestedScrollSource): Offset {
if (!drag.engaged || available.y >= 0f) return Offset.Zero
val before = drag.progress
follow((before - available.y / travelPx[0]).coerceAtMost(1f))
return Offset(0f, (drag.progress - before) * -travelPx[0])
}
override fun onPostScroll(consumed: Offset, available: Offset, source: androidx.compose.ui.input.nestedscroll.NestedScrollSource): Offset {
if (available.y <= 0f || source != androidx.compose.ui.input.nestedscroll.NestedScrollSource.Drag || !openState.value || launchingState.value) return Offset.Zero
if (!drag.engaged) beginDrag()
follow((drag.progress - available.y / travelPx[0]).coerceIn(0f, 1f))
return available
}
override suspend fun onPreFling(available: androidx.compose.ui.unit.Velocity): androidx.compose.ui.unit.Velocity {
if (!drag.engaged) return androidx.compose.ui.unit.Velocity.Zero
settleDrag(available.y)
return available
}
}
}
BoxWithConstraints(
modifier.fillMaxSize().onGloballyPositioned { heroAt = it.boundsInRoot().topLeft }
// A swipe anywhere on the hero moves the sheet under the finger - up from rest, down
// when open - and lets go open or shut by how far it went and how fast. A swipe that
// starts the wrong way (down at rest) is left alone, for the page under it.
.pointerInput(Unit) {
val tracker = androidx.compose.ui.input.pointer.util.VelocityTracker()
var decided = false
detectVerticalDragGestures(
onDragStart = { tracker.resetTracking(); decided = false; drag.engaged = false },
onDragEnd = { if (drag.engaged) settleDrag(tracker.calculateVelocity().y) },
onDragCancel = { if (drag.engaged) settleDrag(0f) },
) { change, amount ->
if (launching) return@detectVerticalDragGestures
if (!decided) {
decided = true
// Up at rest opens; down when open closes. Anything else is not for the sheet.
if (if (open) amount > 0f else amount < 0f) beginDrag()
}
if (!drag.engaged) return@detectVerticalDragGestures
change.consume()
tracker.addPosition(change.uptimeMillis, change.position)
follow((drag.progress - amount / travelPx[0]).coerceIn(0f, 1.04f))
}
},
) {
val w = constraints.maxWidth.toFloat()
val h = constraints.maxHeight.toFloat()
val inset = with(density) { SheetInset.toPx() }
val restRect = rest?.translate(-heroAt)
val buttonH = restRect?.height ?: with(density) { 56.dp.toPx() }
val openTop = sheetTop(h, buttonH, with(density) { 16.dp.toPx() }, with(density) { 3.dp.toPx() })
// Play's raised place: its centre 3dp under the sheet's edge, same left inset.
val raise = restRect?.let { (openTop + with(density) { 3.dp.toPx() } - buttonH / 2f) - it.top } ?: 0f
travelPx[0] = (h - openTop).coerceAtLeast(1f)
// The wall behind sinks back about the pane's centre.
val foot = h
Box(Modifier.fillMaxSize().clipToBounds().graphicsLayer { val k = 1f - 0.05f * sink.value; scaleX = k; scaleY = k }) { background() }
// The fades stay put: scaled with the wall they stopped short of the pane's edges and left
// a bright band of unfaded wall down the side and along the top.
scrim { sink.value }
// The dim; a tap on it (above the sheet) folds the sheet away.
if (shown || open) Box(
Modifier.fillMaxSize()
.graphicsLayer { alpha = sink.value }.background(Color.Black.copy(alpha = 0.42f))
.focusProperties { canFocus = false }
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, enabled = open && !launching) { close() },
)
// The sheet: inset at the sides, rounded at the top, no bottom edge - the pane's foot is its foot.
if (shown) {
val top = lerp(foot, openTop, lift.value)
val sheetLeft = inset
val sheetRight = w - inset
val path = remember { Path() }
val rimPath = remember { Path() }
val corner = with(density) { SheetCorner.toPx() }
val rim = with(density) { 1.4.dp.toPx() }
val pill = restRect?.translate(0f, raise)
Canvas(Modifier.fillMaxSize()) {
val f = fold.value
val ft = foldTop.value
val l = if (pill != null) lerp(sheetLeft, pill.left, f) else sheetLeft
val r = if (pill != null) lerp(sheetRight, pill.right, f) else sheetRight
val t = if (pill != null) lerp(top, pill.top, ft) else top
val b = if (pill != null) lerp(foot + corner, pill.bottom, ft) else foot + corner
val c = if (pill != null) lerp(corner, with(density) { 12.dp.toPx() }, ft) else corner
val bc = if (ft > 0f) c else 0f
path.reset()
path.addRoundRect(RoundRect(l, t, r, b, CornerRadius(c), CornerRadius(c), CornerRadius(bc), CornerRadius(bc)))
val fill = lerp(colors.surface, pal.signal, 0.22f * hot.value)
drawPath(path, fill)
val rimColor = pal.signal.copy(alpha = lerp(0.55f, 0.9f, hot.value))
if (ft > 0f) drawPath(path, rimColor, style = Stroke(rim))
else {
// Sides and top only: the sheet runs on under the pane's foot.
rimPath.reset()
rimPath.moveTo(l, b)
rimPath.lineTo(l, t + c)
rimPath.arcTo(Rect(l, t, l + 2 * c, t + 2 * c), 180f, 90f, false)
rimPath.lineTo(r - c, t)
rimPath.arcTo(Rect(r - 2 * c, t, r, t + 2 * c), -90f, 90f, false)
rimPath.lineTo(r, b)
drawPath(rimPath, rimColor, style = Stroke(rim))
}
}
// What the sheet holds: the chips to the right of Play, the rows under them.
val chipsX = with(density) {
if (narrow || pill == null) 16.dp.toPx() else pill.right + 20.dp.toPx() - sheetLeft
}
val chipsY = with(density) { if (narrow && pill != null) (pill.bottom - openTop) + 10.dp.toPx() else ChipsTop.toPx() }
// The rows start under the chips, which are 44dp to touch: from 54dp they ran under the
// chips' feet and, scrolled, covered them.
val rowsY = with(density) { maxOf(RowsTop.toPx(), chipsY + 52.dp.toPx()) }
val sheetW = sheetRight - sheetLeft
Box(
Modifier
.offset { IntOffset(sheetLeft.roundToInt(), top.roundToInt()) }
.size(with(density) { sheetW.toDp() }, with(density) { (foot - openTop).coerceAtLeast(0f).toDp() })
// Touches on the sheet stay on it; none reach the dim behind.
.pointerInput(Unit) { detectTapGestures { } }
.controllerBack { close() }
.bumpers(
// LB / RB: focus goes to the new tab's chip, so a pad on a row (which goes with
// its tab) is never left with nothing.
onPrevious = { if (tab > 0) { travel = -1; tab--; runCatching { chipFocus[tab].requestFocus() } } },
onNext = { if (tab < tabs.lastIndex) { travel = 1; tab++; runCatching { chipFocus[tab].requestFocus() } } },
)
// A pad stays in the sheet until it folds; Left from it reaches Play, the handle, and folds it.
.focusProperties { exit = { dir -> if (!openState.value || launchingState.value) FocusRequester.Default else if (dir == FocusDirection.Left && ff != null) { leftForPlay[0] = true; ff.primary } else FocusRequester.Cancel } }
.focusGroup(),
) {
Box(
Modifier
.offset { IntOffset(chipsX.roundToInt(), chipsY.roundToInt()) }
// Over the rows, whatever they scroll under them.
.zIndex(1f)
.width(with(density) { (sheetW - chipsX - 64.dp.toPx()).coerceAtLeast(0f).toDp() })
.graphicsLayer { val v = pieces[0].value; alpha = v; translationY = (1f - v) * 6.dp.toPx() }
// Up from the chips folds the sheet (a finger drags it down, see the hero's drag).
.onPreviewKeyEvent { e ->
val k = e.nativeKeyEvent
if (k.keyCode == KeyEvent.KEYCODE_DPAD_UP) { if (k.action == KeyEvent.ACTION_DOWN) close(); true } else false
},
) {
SheetChips(tabs.map { it.second }, tab, chipFocus) { i -> travel = if (i > tab) 1 else -1; tab = i }
}
Box(
Modifier
.offset { IntOffset(0, rowsY.roundToInt()) }
.nestedScroll(rowsPull)
.fillMaxWidth()
.height(with(density) { (foot - openTop - rowsY).coerceAtLeast(0f).toDp() }),
) {
AnimatedContent(
targetState = tab,
transitionSpec = {
val d = travel
val shift = with(density) { 24.dp.roundToPx() }
(slideInHorizontally(Motion.tw(240)) { shift * d } + fadeIn(Motion.tw(240)))
.togetherWith(slideOutHorizontally(Motion.tw(240)) { -shift * d } + fadeOut(Motion.tw(240)))
},
label = "sheetTab",
) { t ->
val leaving = transition.targetState == androidx.compose.animation.EnterExitState.PostExit
val leavingNow = rememberUpdatedState(leaving)
CompositionLocalProvider(LocalSheetBeat provides remember { SheetBeat(pieces) }) {
Column(
Modifier.fillMaxSize()
// Focus never lands in rows on their way out. Same group always, only
// its rule changes (canFocus here made the scroll's own group a stop).
.focusProperties { enter = { if (leavingNow.value) FocusRequester.Cancel else FocusRequester.Default } }
.focusGroup()
.verticalScroll(rememberScrollState())
.padding(start = 16.dp, end = 16.dp, bottom = 24.dp),
) { rows(tabs[t].first) }
}
}
}
val closeSource = remember { MutableInteractionSource() }
val closeHot = rememberHot(closeSource)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.offset { IntOffset((sheetW - 52.dp.toPx()).roundToInt(), chipsY.roundToInt()) }
.zIndex(1f).size(44.dp).testTag("sheet-close")
.clip(CircleShape).glideBorder(closeHot, CircleShape, pal.signal)
.hoverable(closeSource)
.clickable(interactionSource = closeSource, indication = LocalIndication.current, enabled = open && !launching, role = Role.Button) { close() }
.controllerConfirm(enabled = open && !launching) { close() },
) { Icon(Icons.Filled.Close, stringResource(R.string.common_close), tint = colors.onSurfaceVariant, modifier = Modifier.size(20.dp)) }
}
}
// The words and the button, where they always were; the button rides the sheet's edge.
Column(Modifier.align(Alignment.BottomStart).padding(start = start, bottom = bottom, end = 20.dp)) {
Box(Modifier.graphicsLayer { alpha = 1f - sink.value }) { words() }
Spacer(Modifier.height(18.dp))
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Box(
Modifier
.onGloballyPositioned { rest = it.boundsInRoot() }
.graphicsLayer { translationY = raise * ride.value }
.onFocusChanged {
// Left out of the sheet (from the chips or a row) folds it, as Up from the chips does.
if (it.hasFocus && leftForPlay[0]) { leftForPlay[0] = false; if (openState.value && !launchingState.value) close() }
playFocused = it.hasFocus
}
.hoverable(playSrc)
.onPreviewKeyEvent { e ->
val k = e.nativeKeyEvent
if (k.keyCode != KeyEvent.KEYCODE_DPAD_DOWN) false
else {
if (k.action == KeyEvent.ACTION_DOWN && k.repeatCount == 0) {
if (open) runCatching { chipFocus[tab].requestFocus() }
else if (downOpens) open = true
else runCatching { lipFocus.requestFocus() }
}
true
}
},
) {
button {
if (launching) return@button
if (open || shown) { launching = true; open = false } else onPrimary()
}
}
// Gone with the words while the sheet is up; nothing a pad or a finger can reach then.
val besideShown = !(open || shown)
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.graphicsLayer { alpha = 1f - sink.value }
.focusProperties { canFocus = besideShown },
) { if (besideShown || sink.value < 0.99f) beside() }
}
}
// The lip, under Play: stretched by the pull, gone as the sheet lifts.
val r0 = restRect
// A lip a pad can reach stays composed (unseen at rest) so Down always has it to land on.
if (r0 != null && (lipShow.value > 0.01f || pull.value > 0.5f || (!downOpens && !open && !shown))) {
var handleW by remember { mutableStateOf(0f) }
val lipH = 32.dp + pull.value.dp
val lift = { if (!open) open = true }
Box(
Modifier
.offset { IntOffset((r0.center.x - handleW / 2f).roundToInt(), (r0.bottom + raise * ride.value + (lipShow.value - 1f) * 8.dp.toPx()).roundToInt()) }
.onSizeChanged { handleW = it.width.toFloat() }
.graphicsLayer { alpha = lipShow.value * (1f - lipGone.value) }
// 44dp to touch, with a 32dp visible handle.
.heightIn(min = 44.dp).testTag("sheet-open")
.semantics { contentDescription = settingsLabel }
.then(
if (downOpens) Modifier.focusProperties { canFocus = false }
else Modifier.focusRequester(lipFocus).onFocusChanged { lipFocused = it.isFocused }.controllerConfirm(onClick = lift),
)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, role = Role.Button, onClick = lift),
) {
Box(Modifier.glideBorder(lipFocused, RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp), LocalPalette.current.signal)) {
Handle(lipH)
}
}
}
}
}
/**
* The sheet's handle, tucked under Play: "Settings" and a chevron that bobs up every so often,
* saying there is a drawer to pull up. Still with animations off.
*/
@Composable
private fun Handle(height: Dp) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val shape = RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)
val still = Motion.scale == 0f
val bob = androidx.compose.animation.core.rememberInfiniteTransition(label = "handle")
// A quick lift and settle, then a rest: a nudge, not a pulse.
val lift by bob.animateFloat(
0f, 0f,
androidx.compose.animation.core.infiniteRepeatable(
androidx.compose.animation.core.keyframes {
durationMillis = 2400
0f at 0
1f at 180 using androidx.compose.animation.core.FastOutSlowInEasing
-0.25f at 420
0f at 620
},
),
label = "handleBob",
)
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.height(height).clip(shape).background(colors.surface)
.drawBorderSidesAndFoot(pal.line, 12.dp)
.padding(start = 14.dp, end = 10.dp),
) {
Text(stringResource(R.string.drawer_page_settings), fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onSurfaceVariant, maxLines = 1)
Icon(
Icons.Filled.KeyboardArrowUp, null, tint = colors.onSurfaceVariant,
modifier = Modifier.size(16.dp).graphicsLayer { translationY = if (still) 0f else -3.dp.toPx() * lift },
)
}
}
/** A 1dp outline down the sides and along the foot, none along the top (the lip tucks under Play). */
private fun Modifier.drawBorderSidesAndFoot(color: Color, corner: Dp): Modifier =
this.drawWithContent {
drawContent()
val c = corner.toPx()
val sw = 1.dp.toPx()
val p = Path().apply {
moveTo(sw / 2, 0f)
lineTo(sw / 2, size.height - c)
arcTo(Rect(sw / 2, size.height - 2 * c + sw / 2, 2 * c, size.height - sw / 2), 180f, -90f, false)
lineTo(size.width - c, size.height - sw / 2)
arcTo(Rect(size.width - 2 * c, size.height - 2 * c + sw / 2, size.width - sw / 2, size.height - sw / 2), 90f, -90f, false)
lineTo(size.width - sw / 2, 0f)
}
drawPath(p, color, style = Stroke(sw))
}
/**
* The sheet's chips: equal capsules with one selection gliding under them (the stores' chip row),
* LB and RB a chip at a time, a tap or A on a far one a hop as a drop. A pad picks a chip by
* moving onto it, and comes into the row on the picked one. [first] takes focus as the sheet
* opens. Too narrow for equal chips, the row scrolls.
*/
@OptIn(ExperimentalComposeUiApi::class)
@Composable
internal fun SheetChips(labels: List<String>, selected: Int, focus: List<FocusRequester>? = null, onPick: (Int) -> Unit) {
val pal = LocalPalette.current
val bounds = remember { mutableStateMapOf<Int, Rect>() }
val tint = rememberUpdatedState(pal.signal)
// Each chip its own requester: one moved from chip to chip under a focused chip lost the ring.
val chips = focus ?: remember(labels.size) { List(labels.size) { FocusRequester() } }
var within by remember { mutableStateOf(false) }
// LB / RB with a pad on the chips: focus goes with the selection.
LaunchedEffect(selected) { if (within) runCatching { chips[selected].requestFocus() } }
BoxWithConstraints(Modifier.fillMaxWidth().onFocusChanged { within = it.hasFocus }) {
val equal = maxWidth / labels.size.coerceAtLeast(1) >= 96.dp
val scroll = rememberScrollState()
// Room inside the scroll for the focus ring, which the scroll's clip would otherwise cut.
Box(if (equal) Modifier.fillMaxWidth() else Modifier.edgeFade(scroll).horizontalScroll(scroll).padding(3.dp)) {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
// Up from the rows lands on the picked chip, not whichever sits above: landing picks.
modifier = (if (equal) Modifier.fillMaxWidth() else Modifier)
.focusProperties { enter = { chips.getOrNull(selected) ?: FocusRequester.Default } }
.focusGroup(),
) {
labels.forEachIndexed { i, label ->
val m = (if (equal) Modifier.weight(1f) else Modifier.widthIn(min = 64.dp))
.onGloballyPositioned { bounds[i] = it.boundsInParent() }
.focusRequester(chips[i])
SheetChip(label, i == selected, m.onFocusChanged { if (it.isFocused && i != selected) onPick(i) }) { onPick(i) }
}
}
// Over the chips: under them, their rest fill hid it as it crossed them.
ChipGlide(selected, bounds[selected], tint)
}
}
}
@Composable
private fun SheetChip(label: String, on: Boolean, modifier: Modifier, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val shape = RoundedCornerShape(99.dp)
// The row's glide draws the picked chip's look by touch only; with a pad it draws its own rest
// look, and the bar says which is picked.
val under = on && LocalInputModeManager.current.inputMode != InputMode.Keyboard
Box(
contentAlignment = Alignment.Center,
modifier = modifier.heightIn(min = 44.dp).clip(shape)
.background(if (under) Color.Transparent else colors.surfaceVariant.copy(alpha = 0.5f))
.shownBar(on, pal.signal)
.glideBorder(hot, shape, pal.signal, if (under) Color.Transparent else pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = onClick)
.controllerConfirm(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 8.dp),
) {
Text(label, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = if (on || hot) colors.onBackground else colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
/**
* A read-only line that is a link: [title], [value] under it, and "[link] ↗" at the right in the
* signal blue - not a picker, so no value box. A (or a tap) follows it. [id] is its focus key.
*/
@Composable
internal fun LinkRow(title: String, value: String, link: String, id: String, arrow: String = "↗", onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val shape = RoundedCornerShape(12.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().paneItem(id).hopSource(id).heightIn(min = 56.dp).clip(shape)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else Color.Transparent)
.glideBorder(hot, shape, pal.signal)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.controllerConfirm(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 11.dp),
) {
Column(Modifier.weight(1f).padding(end = 12.dp)) {
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
Text(value, fontSize = 12.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp), maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Text("$link $arrow", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1)
}
}
@@ -23,6 +23,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import com.droiddeck.launcher.core.FexPreset
import com.droiddeck.launcher.runtime.DeckyManager
import com.droiddeck.launcher.session.SessionPrefs
@@ -152,293 +154,171 @@ class ModeSettingsActions(
val onDismiss: () -> Unit,
)
private enum class ModeSettingsTab(val label: Int) {
DISPLAY(R.string.drawer_page_display),
CONTROLS(R.string.drawer_page_controls),
GAMES(R.string.game_settings_title),
AUDIO(R.string.mode_audio),
SESSION(R.string.mode_session),
STEAM(R.string.mode_tab_steam),
/**
* The chips of a mode's lifted sheet, in order: Steam's six; the desktop's Display, Controls and
* Session (its Files chip is the Desktop page's own).
*/
internal fun modeSheetTabs(steam: Boolean): List<String> =
if (steam) listOf(SheetTab.STEAM, SheetTab.DISPLAY, SheetTab.CONTROLS, SheetTab.AUDIO, SheetTab.SESSION, SheetTab.LIBRARY)
else listOf(SheetTab.DISPLAY, SheetTab.CONTROLS, SheetTab.SESSION)
/** A sheet tab's chip label. */
@Composable
internal fun sheetTabLabel(tab: String): String = when (tab) {
SheetTab.DISPLAY -> stringResource(R.string.drawer_page_display)
SheetTab.CONTROLS -> stringResource(R.string.drawer_page_controls)
SheetTab.AUDIO -> stringResource(R.string.mode_audio)
SheetTab.SESSION -> stringResource(R.string.mode_session)
SheetTab.LIBRARY -> stringResource(R.string.stores_tab_library)
SheetTab.STEAM -> stringResource(R.string.mode_tab_steam)
else -> stringResource(R.string.setup_tool_files)
}
/**
* Rows the front end adds to a sheet's tabs: frame generation (Display), Performance (Session) and
* the Graphics drivers link to Components (Display, given the drivers' summary).
*/
class SheetExtras(
val frameGen: @Composable () -> Unit = {},
val performance: @Composable () -> Unit = {},
val drivers: @Composable (summary: String?) -> Unit = {},
/** The Steam tab's account rows (offline mode), from the front end's own state. */
val account: @Composable (host: MenuHost) -> Unit = {},
)
/** One tab of a mode's lifted sheet: the rows the settings page had, grouped by what they are for. */
@Composable
fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras: SheetExtras = SheetExtras()) {
val steam = s.mode == SessionService.MODE_STEAM
val host = rememberMenuHost()
var confirmDeckyRemoval by remember { mutableStateOf(false) }
var explainWifiDiscovery by remember { mutableStateOf(false) }
var tabIndex by androidx.compose.runtime.saveable.rememberSaveable(s.mode) { mutableStateOf(0) }
val tabs = if (steam) ModeSettingsTab.entries else listOf(
ModeSettingsTab.DISPLAY, ModeSettingsTab.CONTROLS, ModeSettingsTab.SESSION,
)
val tab = tabs[tabIndex]
val scrolls = tabs.map { androidx.compose.foundation.rememberScrollState() }
val tabFocus = remember(s.mode) { tabs.map { androidx.compose.ui.focus.FocusRequester() } }
val firstChip = remember { androidx.compose.ui.focus.FocusRequester() }
fun selectTab(index: Int) { host.open = null; tabIndex = index }
androidx.compose.runtime.LaunchedEffect(steam, tabIndex) {
androidx.compose.runtime.withFrameNanos { }
runCatching {
if (tab == ModeSettingsTab.DISPLAY) firstChip.requestFocus()
else tabFocus[tabIndex].requestFocus()
if (tab == SheetTab.DISPLAY) {
val displaySize = com.droiddeck.launcher.session.SessionDisplay.resolveChoice(s.panelSize, s.resolution)
SettingsGroup(stringResource(R.string.display_session)) {
ResolutionRow(host, s.resolution, s.panelSize, a.onResolution)
if (s.stretch16x9 != null && (com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(s.panelSize) ||
com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize))) {
val canStretch = com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize)
ChoiceRow(host, "game-fit", stringResource(R.string.display_game_fit),
stringResource(if (canStretch) R.string.display_game_fit_hint else R.string.display_game_fit_unavailable),
listOf(false to stringResource(R.string.display_preserve), true to stringResource(R.string.display_stretch)),
s.stretch16x9 && canStretch, enabled = canStretch, onPick = a.onStretch16x9)
}
}
SettingsGroup(stringResource(R.string.display_image_scaling)) {
ChoiceRow(
host, "upscaler", stringResource(R.string.display_filter), null,
SessionPrefs.upscalerChoices(LocalContext.current), s.upscaler,
note = stringResource(R.string.display_filter_note), onPick = a.onUpscaler,
)
if (SessionPrefs.upscalerHasSharpness(s.upscaler)) SliderRow(
stringResource(R.string.display_sharpness), null, s.upscaleSharpness, 0..100, step = 5,
format = { "$it%" }, onChange = a.onUpscaleSharpness,
)
}
SettingsGroup(stringResource(R.string.display_frame_rate)) {
ChoiceRow(host, "fps", stringResource(R.string.mode_fps), stringResource(R.string.common_applies_next_session),
SessionPrefs.fpsLimitChoices(LocalContext.current), s.fpsLimit, note = stringResource(R.string.mode_fps_note), onPick = a.onFpsLimit)
}
SettingsGroup(stringResource(R.string.mode_hdr)) {
ToggleRow(
host, "hdr", stringResource(R.string.mode_hdr10),
s.hdrReason?.let { stringResource(R.string.mode_hdr_unavailable, it) }
?: stringResource(R.string.mode_hdr_restart),
checked = s.hdr && s.hdrReason == null, enabled = s.hdrReason == null, onChange = a.onHdr,
)
}
extras.frameGen()
extras.drivers(s.gpuDrivers)
}
if (tab == SheetTab.CONTROLS) {
SettingsGroup(if (steam) stringResource(R.string.mode_touch_controls) else stringResource(R.string.mode_touch)) {
ChoiceRow(
host, "touch", stringResource(R.string.mode_touch), null,
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.common_auto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), s.touchMode,
note = stringResource(R.string.mode_touch_note),
onPick = a.onTouch,
)
if (steam && s.oscMode != null) ChoiceRow(
host, "osc", stringResource(R.string.mode_osc), null,
listOf(
SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto),
SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always),
SessionPrefs.OSC_STEAM_TOUCH to stringResource(R.string.osc_steam_touch),
SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam),
SessionPrefs.OSC_NEVER to stringResource(R.string.common_never),
), s.oscMode,
note = stringResource(R.string.mode_osc_note), onPick = a.onOsc,
)
if (steam && s.steamController != null) ChoiceRow(
host, "controller", stringResource(R.string.mode_controller), stringResource(R.string.mode_controller_hint),
listOf(
SessionPrefs.CONTROLLER_DECK to stringResource(R.string.mode_controller_deck),
SessionPrefs.CONTROLLER_STEAM to stringResource(R.string.mode_controller_steam),
SessionPrefs.CONTROLLER_DS4 to stringResource(R.string.mode_controller_ds4),
SessionPrefs.CONTROLLER_DUALSENSE to stringResource(R.string.mode_controller_dualsense),
SessionPrefs.CONTROLLER_XBOX360 to stringResource(R.string.mode_controller_x360),
), s.steamController,
note = stringResource(R.string.mode_controller_note),
onPick = a.onSteamController,
)
if (steam && s.unfoldedControls != null) ToggleRow(
host, "unfolded-controls", stringResource(R.string.unfolded_controls),
stringResource(R.string.unfolded_controls_hint),
s.unfoldedControls, onChange = a.onUnfoldedControls,
)
if (steam) ChoiceRow(
host, "back-actions", stringResource(R.string.mode_back), stringResource(SessionPrefs.backActionsOrder(s.backActionsInverted)),
listOf(
false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM),
true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU),
), s.backActionsInverted, onPick = a.onBackActionsInverted,
)
}
}
SettingsPage(
host,
title = null,
onBack = a.onDismiss,
scroll = scrolls[tabIndex],
modifier = Modifier.bumpers(
onPrevious = { selectTab((tabIndex + tabs.size - 1) % tabs.size) },
onNext = { selectTab((tabIndex + 1) % tabs.size) },
),
header = {
TabStrip(tabs.map { stringResource(it.label) }, tabIndex, ::selectTab,
modifier = Modifier.fillMaxWidth(), focusRequesters = tabFocus)
},
) {
if (tab == ModeSettingsTab.DISPLAY) {
val displaySize = com.droiddeck.launcher.session.SessionDisplay.resolveChoice(s.panelSize, s.resolution)
SettingsGroup(stringResource(R.string.display_session)) {
ResolutionRow(host, s.resolution, s.panelSize, a.onResolution,
chipModifier = Modifier.focusRequester(firstChip))
if (s.stretch16x9 != null && (com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(s.panelSize) ||
com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize))) {
val canStretch = com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize)
ChoiceRow(host, "game-fit", stringResource(R.string.display_game_fit),
stringResource(if (canStretch) R.string.display_game_fit_hint else R.string.display_game_fit_unavailable),
listOf(false to stringResource(R.string.display_preserve), true to stringResource(R.string.display_stretch)),
s.stretch16x9 && canStretch, enabled = canStretch, onPick = a.onStretch16x9)
}
}
SettingsGroup(stringResource(R.string.display_image_scaling)) {
ChoiceRow(
host, "upscaler", stringResource(R.string.display_filter), null,
SessionPrefs.upscalerChoices(LocalContext.current), s.upscaler,
note = stringResource(R.string.display_filter_note), onPick = a.onUpscaler,
)
if (SessionPrefs.upscalerHasSharpness(s.upscaler)) SliderRow(
stringResource(R.string.display_sharpness), null, s.upscaleSharpness, 0..100, step = 5,
format = { "$it%" }, onChange = a.onUpscaleSharpness,
)
}
SettingsGroup(stringResource(R.string.display_frame_rate)) {
ChoiceRow(host, "fps", stringResource(R.string.mode_fps), stringResource(R.string.common_applies_next_session),
SessionPrefs.fpsLimitChoices(LocalContext.current), s.fpsLimit, note = stringResource(R.string.mode_fps_note), onPick = a.onFpsLimit)
}
SettingsGroup(stringResource(R.string.mode_hdr)) {
ToggleRow(
host, "hdr", stringResource(R.string.mode_hdr10),
s.hdrReason?.let { stringResource(R.string.mode_hdr_unavailable, it) }
?: stringResource(R.string.mode_hdr_restart),
checked = s.hdr && s.hdrReason == null, enabled = s.hdrReason == null, onChange = a.onHdr,
)
}
SettingsGroup(stringResource(R.string.mode_drivers)) {
SettingsRow(stringResource(R.string.mode_gpu_drivers), stringResource(R.string.mode_gpu_drivers_hint)) {
ValueChip(s.gpuDrivers ?: stringResource(R.string.common_auto), open = false) { a.onGpuDrivers() }
}
}
}
if (tab == ModeSettingsTab.CONTROLS) {
SettingsGroup(if (steam) stringResource(R.string.mode_touch_controls) else stringResource(R.string.mode_touch)) {
ChoiceRow(
host, "touch", stringResource(R.string.mode_touch), null,
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.common_auto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), s.touchMode,
note = stringResource(R.string.mode_touch_note),
onPick = a.onTouch,
)
if (steam && s.oscMode != null) ChoiceRow(
host, "osc", stringResource(R.string.mode_osc), null,
listOf(
SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto),
SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always),
SessionPrefs.OSC_STEAM_TOUCH to stringResource(R.string.osc_steam_touch),
SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam),
SessionPrefs.OSC_NEVER to stringResource(R.string.common_never),
), s.oscMode,
note = stringResource(R.string.mode_osc_note), onPick = a.onOsc,
)
if (steam && s.steamController != null) ChoiceRow(
host, "controller", stringResource(R.string.mode_controller), stringResource(R.string.mode_controller_hint),
listOf(
SessionPrefs.CONTROLLER_DECK to stringResource(R.string.mode_controller_deck),
SessionPrefs.CONTROLLER_STEAM to stringResource(R.string.mode_controller_steam),
SessionPrefs.CONTROLLER_DS4 to stringResource(R.string.mode_controller_ds4),
SessionPrefs.CONTROLLER_DUALSENSE to stringResource(R.string.mode_controller_dualsense),
SessionPrefs.CONTROLLER_XBOX360 to stringResource(R.string.mode_controller_x360),
), s.steamController,
note = stringResource(R.string.mode_controller_note),
onPick = a.onSteamController,
)
if (steam && s.unfoldedControls != null) ToggleRow(
host, "unfolded-controls", stringResource(R.string.unfolded_controls),
stringResource(R.string.unfolded_controls_hint),
s.unfoldedControls, onChange = a.onUnfoldedControls,
)
if (steam) ChoiceRow(
host, "back-actions", stringResource(R.string.mode_back), stringResource(SessionPrefs.backActionsOrder(s.backActionsInverted)),
listOf(
false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM),
true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU),
), s.backActionsInverted, onPick = a.onBackActionsInverted,
)
}
}
if (tab == ModeSettingsTab.SESSION) {
SettingsGroup(stringResource(R.string.mode_session)) {
ChoiceRow(
host, "suspend", stringResource(R.string.mode_suspend),
stringResource(R.string.mode_suspend_hint),
listOf(
SessionPrefs.SUSPEND_AUTO to stringResource(R.string.common_auto),
SessionPrefs.SUSPEND_MANUAL to stringResource(R.string.mode_suspend_manual),
SessionPrefs.SUSPEND_NEVER to stringResource(R.string.common_never),
) + if (steam) listOf(
SessionPrefs.SUSPEND_NATIVE to stringResource(R.string.mode_suspend_native),
) else emptyList(),
s.suspendPolicy,
note = stringResource(if (steam) R.string.mode_suspend_steam_note else R.string.mode_suspend_note),
onPick = a.onSuspendPolicy,
)
if (steam && s.suspendPolicy != SessionPrefs.SUSPEND_NEVER) ToggleRow(
host, "background-downloads", stringResource(R.string.mode_background_downloads),
stringResource(R.string.mode_background_downloads_hint),
s.steamDownloadsInBackground, onChange = a.onSteamDownloadsInBackground,
)
}
if (s.pipSupported) SettingsGroup(stringResource(R.string.pip_title)) {
ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto), null,
s.pipAutoEnter, onChange = a.onPipAutoEnter)
}
if (steam) SettingsGroup(stringResource(R.string.mode_startup)) {
ToggleRow(
host, "steam-startup", stringResource(R.string.mode_steam_startup),
stringResource(R.string.mode_steam_startup_hint),
s.runSteamAtStartup, onChange = a.onRunSteamAtStartup,
)
}
}
if (steam && tab == ModeSettingsTab.STEAM) SettingsGroup(stringResource(R.string.mode_decky)) {
val updateAvailable = s.deckyInstalled != null && s.deckyLatestRelease != null &&
s.deckyInstalled != s.deckyLatestRelease.tag
val status = when {
s.deckyStage != null -> s.deckyStage
s.deckyChecking -> stringResource(R.string.mode_decky_checking)
s.deckyInstalled == null && s.deckyLatestRelease != null -> stringResource(R.string.mode_decky_ready, s.deckyLatestRelease.tag)
s.deckyInstalled == null -> stringResource(R.string.mode_decky_none)
s.deckyLatestRelease == null -> stringResource(R.string.mode_decky_installed, s.deckyInstalled)
updateAvailable -> stringResource(R.string.mode_decky_update, s.deckyLatestRelease.tag)
else -> stringResource(R.string.mode_decky_current, s.deckyInstalled)
}
SettingsRow(stringResource(R.string.mode_decky_loader), status) {
val action = when {
s.deckyStage != null -> stringResource(R.string.common_working)
s.deckyChecking -> stringResource(R.string.common_checking)
s.deckyInstalled == null && s.deckyLatestRelease != null -> stringResource(R.string.mode_decky_install_latest)
s.deckyInstalled != null && updateAvailable -> stringResource(R.string.common_update)
else -> stringResource(R.string.common_check)
}
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(6.dp)) {
SecondaryButton(
action,
enabled = s.deckyStage == null && !s.deckyChecking && !s.deckySessionRunning,
) {
if (s.deckyLatestRelease == null || (s.deckyInstalled != null && !updateAvailable)) a.onDeckyCheck()
else s.deckyLatestRelease?.let(a.onDeckyInstall)
}
if (s.deckyInstalled != null) SecondaryButton(
stringResource(R.string.common_uninstall),
enabled = s.deckyStage == null && !s.deckySessionRunning,
) { confirmDeckyRemoval = true }
}
}
if (s.deckyInstalled != null) ToggleRow(
host, "decky-enabled", stringResource(R.string.mode_decky),
if (s.deckyEnabled) stringResource(R.string.mode_decky_on)
else stringResource(R.string.mode_decky_off),
s.deckyEnabled,
enabled = !s.deckySessionRunning && s.deckyStage == null,
onChange = a.onDeckyEnabled,
)
SettingsRow(
stringResource(R.string.mode_decky_plugins),
stringResource(R.string.mode_decky_plugins_hint),
) {
SecondaryButton(
if (s.deckyStage != null && s.deckyDownloadingBinary) stringResource(R.string.mode_decky_plugin_downloading) else stringResource(R.string.mode_decky_plugin_install_zip),
enabled = s.deckyInstalled != null && s.deckyStage == null && !s.deckySessionRunning,
onClick = a.onPickDeckyPluginZip,
)
}
if (s.deckyStage != null && s.deckyPercent >= 0) {
LinearProgressIndicator(
progress = { s.deckyPercent / 100f },
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 8.dp),
)
}
}
if (steam && tab == ModeSettingsTab.STEAM) SettingsGroup(stringResource(R.string.mode_client)) {
ToggleRow(
host, "mangoapp", stringResource(R.string.mode_mangoapp),
stringResource(R.string.mode_mangoapp_hint),
s.mangoapp, onChange = a.onMangoapp,
)
SettingsRow(
stringResource(R.string.mode_steam_repair),
stringResource(if (s.steamRepairQueued) R.string.mode_steam_repair_queued else R.string.mode_steam_repair_hint),
) {
SecondaryButton(stringResource(R.string.mode_steam_repair_button), enabled = !s.steamRepairQueued, onClick = a.onSteamRepair)
}
}
if (steam && tab == ModeSettingsTab.STEAM && s.wifiDiscovery != null) SettingsGroup(stringResource(R.string.mode_network)) {
val hint = when {
s.wifiDiscovery && !s.wifiDiscoveryLocation -> stringResource(R.string.mode_wifi_location_off)
!s.wifiDiscoveryPermission && s.wifiDiscoveryAsked -> stringResource(R.string.mode_wifi_permission_denied)
else -> stringResource(R.string.mode_wifi_discovery_hint)
}
SettingsRow(stringResource(R.string.mode_wifi_discovery), hint) {
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp)) {
if (s.wifiDiscoveryBlocked || (s.wifiDiscovery && !s.wifiDiscoveryLocation)) {
SecondaryButton(stringResource(R.string.mode_wifi_open_settings), onClick = a.onWifiDiscoverySettings)
}
ToggleSwitch(s.wifiDiscovery, label = stringResource(R.string.mode_wifi_discovery)) { on ->
host.open = null
if (on) explainWifiDiscovery = true else a.onWifiDiscovery(false)
}
}
}
}
if (steam && tab == ModeSettingsTab.GAMES && s.fexPreset != null) SettingsGroup(stringResource(R.string.game_settings_title)) {
if (s.syncBackend != null) SettingsRow(
stringResource(R.string.sync_backend_title),
stringResource(R.string.sync_backend_hint),
) {
SegmentedTabs(
listOf(
SessionPrefs.SYNC_NTSYNC to stringResource(R.string.sync_backend_ntsync),
SessionPrefs.SYNC_FSYNC to stringResource(R.string.sync_backend_fsync),
SessionPrefs.SYNC_ESYNC to stringResource(R.string.sync_backend_esync),
SessionPrefs.SYNC_WINESERVER to stringResource(R.string.sync_backend_wineserver),
),
s.syncBackend,
) { id -> host.open = null; a.onSyncBackend(id) }
}
ChoiceRow(
host, "fex", stringResource(R.string.fex_preset_title), stringResource(R.string.fex_next_launch),
FexPreset.all.map { it.id to stringResource(it.label) }, s.fexPreset,
note = stringResource(FexPreset.byId(s.fexPreset).detail), onPick = a.onFexPreset,
)
ToggleRow(host, "ssbs", stringResource(R.string.force_ssbs_title), stringResource(R.string.force_ssbs_hint), s.forceSsbs, onChange = a.onForceSsbs)
GameEnvironmentRow()
}
if (steam && tab == ModeSettingsTab.AUDIO && s.directAudio != null && s.mic != null) SettingsGroup(stringResource(R.string.mode_audio)) {
if (tab == SheetTab.AUDIO && steam) {
if (s.directAudio != null && s.mic != null) SettingsGroup(stringResource(R.string.mode_audio)) {
ToggleRow(host, "da", stringResource(R.string.mode_directaudio), stringResource(R.string.mode_directaudio_hint), s.directAudio, onChange = a.onDirectAudio)
ToggleRow(host, "mic", stringResource(R.string.mode_mic), stringResource(R.string.mode_mic_hint), s.mic, onChange = a.onMic)
}
if (steam && tab == ModeSettingsTab.GAMES && s.gameStorage != null) SettingsGroup(stringResource(R.string.mode_storage)) {
}
if (tab == SheetTab.SESSION) {
SettingsGroup(stringResource(R.string.mode_session)) {
ChoiceRow(
host, "suspend", stringResource(R.string.mode_suspend),
stringResource(R.string.mode_suspend_hint),
listOf(
SessionPrefs.SUSPEND_AUTO to stringResource(R.string.common_auto),
SessionPrefs.SUSPEND_MANUAL to stringResource(R.string.mode_suspend_manual),
SessionPrefs.SUSPEND_NEVER to stringResource(R.string.common_never),
) + if (steam) listOf(
SessionPrefs.SUSPEND_NATIVE to stringResource(R.string.mode_suspend_native),
) else emptyList(),
s.suspendPolicy,
note = stringResource(if (steam) R.string.mode_suspend_steam_note else R.string.mode_suspend_note),
onPick = a.onSuspendPolicy,
)
if (steam && s.suspendPolicy != SessionPrefs.SUSPEND_NEVER) ToggleRow(
host, "background-downloads", stringResource(R.string.mode_background_downloads),
stringResource(R.string.mode_background_downloads_hint),
s.steamDownloadsInBackground, onChange = a.onSteamDownloadsInBackground,
)
}
if (s.pipSupported) SettingsGroup(stringResource(R.string.pip_title)) {
ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto), null,
s.pipAutoEnter, onChange = a.onPipAutoEnter)
}
if (steam) SettingsGroup(stringResource(R.string.mode_startup)) {
ToggleRow(
host, "steam-startup", stringResource(R.string.mode_steam_startup),
stringResource(R.string.mode_steam_startup_hint),
s.runSteamAtStartup, onChange = a.onRunSteamAtStartup,
)
}
extras.performance()
}
if (tab == SheetTab.LIBRARY && steam) {
if (s.gameStorage != null) SettingsGroup(stringResource(R.string.mode_storage)) {
val custom = s.gameStorage.isNotEmpty() && s.gameStorage != "off" && s.storageOptions.none { it.path == s.gameStorage }
// Each choice: its path, its menu line, the chip's shorter line, and the label it is saved with.
class StorageChoice(val path: String, val label: String, val chip: String, val saved: String)
@@ -481,6 +361,97 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
)
}
}
if (tab == SheetTab.STEAM && steam) {
extras.account(host)
SettingsGroup(stringResource(R.string.mode_decky)) {
val updateAvailable = s.deckyInstalled != null && s.deckyLatestRelease != null &&
s.deckyInstalled != s.deckyLatestRelease.tag
val status = when {
s.deckyStage != null -> s.deckyStage
s.deckyChecking -> stringResource(R.string.mode_decky_checking)
s.deckyInstalled == null && s.deckyLatestRelease != null -> stringResource(R.string.mode_decky_ready, s.deckyLatestRelease.tag)
s.deckyInstalled == null -> stringResource(R.string.mode_decky_none)
s.deckyLatestRelease == null -> stringResource(R.string.mode_decky_installed, s.deckyInstalled)
updateAvailable -> stringResource(R.string.mode_decky_update, s.deckyLatestRelease.tag)
else -> stringResource(R.string.mode_decky_current, s.deckyInstalled)
}
SettingsRow(stringResource(R.string.mode_decky_loader), status) {
val action = when {
s.deckyStage != null -> stringResource(R.string.common_working)
s.deckyChecking -> stringResource(R.string.common_checking)
s.deckyInstalled == null && s.deckyLatestRelease != null -> stringResource(R.string.mode_decky_install_latest)
s.deckyInstalled != null && updateAvailable -> stringResource(R.string.common_update)
else -> stringResource(R.string.common_check)
}
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(6.dp)) {
SecondaryButton(
action,
enabled = s.deckyStage == null && !s.deckyChecking && !s.deckySessionRunning,
) {
if (s.deckyLatestRelease == null || (s.deckyInstalled != null && !updateAvailable)) a.onDeckyCheck()
else s.deckyLatestRelease?.let(a.onDeckyInstall)
}
if (s.deckyInstalled != null) DeckyUninstall(enabled = s.deckyStage == null && !s.deckySessionRunning, onUninstall = a.onDeckyUninstall)
}
}
if (s.deckyInstalled != null) ToggleRow(
host, "decky-enabled", stringResource(R.string.mode_decky),
if (s.deckyEnabled) stringResource(R.string.mode_decky_on)
else stringResource(R.string.mode_decky_off),
s.deckyEnabled,
enabled = !s.deckySessionRunning && s.deckyStage == null,
onChange = a.onDeckyEnabled,
)
SettingsRow(
stringResource(R.string.mode_decky_plugins),
stringResource(R.string.mode_decky_plugins_hint),
) {
SecondaryButton(
if (s.deckyStage != null && s.deckyDownloadingBinary) stringResource(R.string.mode_decky_plugin_downloading) else stringResource(R.string.mode_decky_plugin_install_zip),
enabled = s.deckyInstalled != null && s.deckyStage == null && !s.deckySessionRunning,
onClick = a.onPickDeckyPluginZip,
)
}
if (s.deckyStage != null && s.deckyPercent >= 0) {
LinearProgressIndicator(
progress = { s.deckyPercent / 100f },
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 8.dp),
)
}
}
SettingsGroup(stringResource(R.string.mode_client)) {
ToggleRow(
host, "mangoapp", stringResource(R.string.mode_mangoapp),
stringResource(R.string.mode_mangoapp_hint),
s.mangoapp, onChange = a.onMangoapp,
)
SettingsRow(
stringResource(R.string.mode_steam_repair),
stringResource(if (s.steamRepairQueued) R.string.mode_steam_repair_queued else R.string.mode_steam_repair_hint),
) {
SecondaryButton(stringResource(R.string.mode_steam_repair_button), enabled = !s.steamRepairQueued, onClick = a.onSteamRepair)
}
}
if (s.wifiDiscovery != null) SettingsGroup(stringResource(R.string.mode_network)) {
val hint = when {
s.wifiDiscovery && !s.wifiDiscoveryLocation -> stringResource(R.string.mode_wifi_location_off)
!s.wifiDiscoveryPermission && s.wifiDiscoveryAsked -> stringResource(R.string.mode_wifi_permission_denied)
else -> stringResource(R.string.mode_wifi_discovery_hint)
}
SettingsRow(stringResource(R.string.mode_wifi_discovery), hint) {
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp)) {
if (s.wifiDiscoveryBlocked || (s.wifiDiscovery && !s.wifiDiscoveryLocation)) {
SecondaryButton(stringResource(R.string.mode_wifi_open_settings), onClick = a.onWifiDiscoverySettings)
}
ToggleSwitch(s.wifiDiscovery, label = stringResource(R.string.mode_wifi_discovery)) { on ->
host.open = null
if (on) explainWifiDiscovery = true else a.onWifiDiscovery(false)
}
}
}
}
}
if (explainWifiDiscovery) AlertDialog(
onDismissRequest = { explainWifiDiscovery = false },
title = { Text(stringResource(R.string.mode_wifi_explain_title)) },
@@ -494,15 +465,66 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
TextButton(onClick = { explainWifiDiscovery = false }) { Text(stringResource(R.string.common_cancel)) }
},
)
if (confirmDeckyRemoval) AlertDialog(
onDismissRequest = { confirmDeckyRemoval = false },
title = { Text(stringResource(R.string.mode_decky_remove_title)) },
text = { Text(stringResource(R.string.mode_decky_remove_text)) },
confirmButton = {
TextButton(onClick = { confirmDeckyRemoval = false; a.onDeckyUninstall() }) { Text(stringResource(R.string.common_uninstall)) }
},
dismissButton = { TextButton(onClick = { confirmDeckyRemoval = false }) { Text(stringResource(R.string.common_cancel)) } },
}
/**
* The options that apply to every Proton - how Wine syncs, FEX's preset, Force SSBS and the games'
* environment - for the Components page's Proton layer.
*/
@Composable
internal fun ProtonOptions(
syncBackend: String, fexPreset: String, forceSsbs: Boolean,
onSyncBackend: (String) -> Unit, onFexPreset: (String) -> Unit, onForceSsbs: (Boolean) -> Unit,
) {
val host = rememberMenuHost()
SettingsRow(stringResource(R.string.sync_backend_title), stringResource(R.string.sync_backend_hint), stacked = true) {
SegmentedTabs(
listOf(
SessionPrefs.SYNC_NTSYNC to stringResource(R.string.sync_backend_ntsync),
SessionPrefs.SYNC_FSYNC to stringResource(R.string.sync_backend_fsync),
SessionPrefs.SYNC_ESYNC to stringResource(R.string.sync_backend_esync),
SessionPrefs.SYNC_WINESERVER to stringResource(R.string.sync_backend_wineserver),
),
syncBackend,
) { id -> host.open = null; onSyncBackend(id) }
}
ChoiceRow(
host, "fex", stringResource(R.string.fex_preset_title), stringResource(R.string.fex_next_launch),
FexPreset.all.map { it.id to stringResource(it.label) }, fexPreset,
note = stringResource(FexPreset.byId(fexPreset).detail), onPick = onFexPreset,
)
ToggleRow(host, "ssbs", stringResource(R.string.force_ssbs_title), stringResource(R.string.force_ssbs_hint), forceSsbs, onChange = onForceSsbs)
GameEnvironmentRow()
}
/**
* Uninstall for Decky Loader: are you sure steps out of it in red, Cancel first, so a stray A
* costs nothing.
*/
@Composable
private fun DeckyUninstall(enabled: Boolean, onUninstall: () -> Unit) {
val colors = MaterialTheme.colorScheme
val label = stringResource(R.string.common_uninstall)
val title = stringResource(R.string.mode_decky_remove_title)
val note = stringResource(R.string.mode_decky_remove_text)
val cancel = stringResource(R.string.common_cancel)
var at by remember { mutableStateOf<androidx.compose.ui.geometry.Rect?>(null) }
androidx.compose.foundation.layout.Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) {
SecondaryButton(label, enabled = enabled) {
val from = at ?: return@SecondaryButton
Steps.ask(StepAsk(
anchor = from, side = StepSide.Left, accent = colors.error, pillCorner = 12.dp, items = 3,
handle = { Text(label, fontSize = 15.sp, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, color = colors.error, maxLines = 1) },
) {
StepTitle(title)
Text(note, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.stepItem(1).padding(start = 4.dp, bottom = 12.dp))
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp, androidx.compose.ui.Alignment.End), modifier = Modifier.stepItem(2)) {
StepChoice(cancel, enabled = open, modifier = Modifier.focusRequester(first)) { Steps.fold() }
StepChoice(label, danger = true, enabled = open) { Steps.fold(); onUninstall() }
}
})
}
}
}
/** Width × height for the session, with the common handheld shapes one tap away. */
@@ -30,7 +30,9 @@ import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import kotlinx.coroutines.coroutineScope
@@ -44,9 +46,38 @@ import kotlin.math.sin
// Play (LaunchFlood): the control stretches out over the pane on loose springs, the far edges
// first, and the page rises into it once it is covered. Back draws it down into the control again.
/** Where a page opened from: the control's bounds and corner radius, in px. */
internal class Origin(val bounds: Rect, val corner: Float) {
fun translate(by: Offset) = Origin(bounds.translate(by), corner)
/**
* Where a page opened from: the control's bounds and corner radius, in px, and the rail item that
* was showing it ([owner]), so the page only draws back into it when that content returns. [art]
* is the control's picture for the tile (a drawable id, or an image Coil loads), if it has one.
*/
internal class Origin(val bounds: Rect, val corner: Float, val owner: String? = null, val art: Any? = null) {
fun translate(by: Offset) = Origin(bounds.translate(by), corner, owner, art)
fun ownedBy(key: String) = Origin(bounds, corner, key, art)
}
/** How a page that opened from a control leaves. */
internal enum class PageExit {
/** Back into the control it came from, which is on screen again. */
Drain,
/** Out through a link into another section, which takes the page there itself. */
Hop,
/** Away with the pane, as any page goes: the control it came from is not coming back. */
Sink,
}
/** Why a page is leaving: an ordinary change of page, or a link into another section. */
internal enum class PageLeave { Ordinary, Link }
/**
* The exit for a page opened from a control on [owner]'s content, leaving for [incoming]. It only
* draws back into the control when the content that control belongs to is what comes back; a page
* left for another rail item or another page drained into a control that was no longer there.
*/
internal fun pageExit(owner: String?, incoming: String, leave: PageLeave): PageExit = when {
owner != null && incoming == owner -> PageExit.Drain
leave == PageLeave.Link -> PageExit.Hop
else -> PageExit.Sink
}
internal object PageOrigin {
@@ -70,6 +101,7 @@ internal const val PAGE_RETURN_MS = 900
* [content], a page that opened from the control at [from] (in this element's coordinates): the
* control's tile stretching over it until covered, then the page rising in as the tile fades.
* Once [leaving], the reverse: the tile comes back over the page and draws down into the control.
* A page that [sinks] instead goes with the pane and draws no tile at all.
* The focus ring stays out of it until it is done.
*/
@Composable
@@ -78,6 +110,15 @@ internal fun PageFlood(
leaving: Boolean,
/** What the tile shows instead of the control's fill: a game card's art, cropped to the tile as it grows. */
art: Painter? = null,
sinks: Boolean = false,
/**
* Where on the page the control's tile lands (root px), when the page has its own picture of
* the control - a game page's hero art: the tile grows onto it instead of over the whole pane,
* corner to corner ([ontoCorner] there), and the page comes in around it. Back leaves from it.
* Null, or off screen: the pane, as any page.
*/
onto: (() -> Rect?)? = null,
ontoCorner: androidx.compose.ui.unit.Dp = 0.dp,
content: @Composable () -> Unit,
) {
val pal = LocalPalette.current
@@ -87,9 +128,44 @@ internal fun PageFlood(
val shown = remember { Animatable(0f) }
val tile = remember { Animatable(1f) }
var size by remember { mutableStateOf(Size.Zero) }
LaunchedEffect(size != Size.Zero, leaving) {
var at by remember { mutableStateOf(Offset.Zero) }
// Where this flood lands (in its own space), read as it opens and again as it leaves.
var target by remember { mutableStateOf<Rect?>(null) }
val density = androidx.compose.ui.platform.LocalDensity.current
LaunchedEffect(size != Size.Zero, leaving, sinks) {
if (sinks) {
// Whatever the flood in was doing, the page is shown whole and leaves with the pane.
tile.snapTo(0f)
shown.snapTo(1f)
glide?.hidden = false
return@LaunchedEffect
}
if (size == Size.Zero) return@LaunchedEffect
glide?.hidden = true
// The page lays out under the tile at once, so its picture is there within a frame or two.
target = onto?.let { get ->
kotlinx.coroutines.withTimeoutOrNull(300) { snapshotFlow { get() }.first { it != null } }
}?.translate(-at)?.takeIf { it.height > 1f && it.overlaps(Rect(Offset.Zero, size)) }
if (target != null) {
coroutineScope {
if (!leaving) {
// Onto the page's picture, every edge together on one firm spring: no blob, and
// nowhere to go past it.
val runs = edges.map { launch { it.animateTo(1f, Motion.sp(0.85f, 320f)) } }
snapshotFlow { edges.all { it.value >= 0.9f } }.first { it }
launch { shown.animateTo(1f, Motion.tw(240)) }
launch { delay(Motion.ms(140).toLong()); tile.animateTo(0f, Motion.tw(220)) }
runs.forEach { it.join() }
} else {
launch { shown.animateTo(0f, Motion.tw(160)) }
tile.animateTo(1f, Motion.tw(120))
edges.map { launch { it.animateTo(0f, Motion.sp(0.9f, 320f)) } }.forEach { it.join() }
tile.animateTo(0f, Motion.tw(120))
}
}
glide?.hidden = false
return@LaunchedEffect
}
val b = from.bounds
val travel = listOf(b.left, b.top, size.width - b.right, size.height - b.bottom).map { it.coerceAtLeast(0f) }
val far = travel.max().coerceAtLeast(1f)
@@ -122,10 +198,16 @@ internal fun PageFlood(
glide?.hidden = false
}
DisposableEffect(Unit) { onDispose { glide?.hidden = false } }
Box(Modifier.fillMaxSize().clipToBounds().onSizeChanged { size = Size(it.width.toFloat(), it.height.toFloat()) }) {
Box(Modifier.fillMaxSize().graphicsLayer { alpha = shown.value; translationY = (1f - shown.value) * 14.dp.toPx() }) { content() }
Box(
Modifier.fillMaxSize().clipToBounds().onSizeChanged { size = Size(it.width.toFloat(), it.height.toFloat()) }
.onGloballyPositioned { at = it.positionInRoot() },
) {
// Landing on the page's picture, the page must not rise: its picture would move off the tile.
val rise = if (onto != null) 0f else 1f
Box(Modifier.fillMaxSize().graphicsLayer { alpha = shown.value; translationY = (1f - shown.value) * 14.dp.toPx() * rise }) { content() }
val onCorner = with(density) { ontoCorner.toPx() }
Canvas(Modifier.fillMaxSize()) {
if (tile.value > 0.01f) drawTile(from, edges.map { it.value }, pal.surface, pal.line2, tile.value, art)
if (tile.value > 0.01f) drawTile(from, edges.map { it.value }, pal.surface, pal.line2, tile.value, art, target, onCorner)
}
}
}
@@ -134,22 +216,33 @@ internal fun PageFlood(
* The control's tile with its edges [v] of the way to the pane's (left, top, right, bottom):
* sinking with the page behind, blobby in flight, square once it fills the pane.
*/
private fun DrawScope.drawTile(from: Origin, v: List<Float>, fill: Color, line: Color, alpha: Float, art: Painter? = null) {
private fun DrawScope.drawTile(
from: Origin, v: List<Float>, fill: Color, line: Color, alpha: Float, art: Painter? = null,
/** Lands on this instead of the pane (a page's picture of the control), corner [toCorner]. */
to: Rect? = null, toCorner: Float = 0f,
) {
val (l, t, r, b) = v
val mean = v.sumOf { it.coerceIn(0f, 1f).toDouble() }.toFloat() / 4f
// The control sinks with the page behind it (scaled to 0.95 about the pane's centre).
val k = 1f - 0.05f * mean
val cx = size.width / 2f
val cy = size.height / 2f
val f = from.bounds
val left = lerp(cx + (f.left - cx) * k, 0f, l)
val top = lerp(cy + (f.top - cy) * k, 0f, t)
val right = lerp(cx + (f.right - cx) * k, size.width, r)
val bottom = lerp(cy + (f.bottom - cy) * k, size.height, b)
val left: Float; val top: Float; val right: Float; val bottom: Float
if (to != null) {
left = lerp(f.left, to.left, l); top = lerp(f.top, to.top, t)
right = lerp(f.right, to.right, r); bottom = lerp(f.bottom, to.bottom, b)
} else {
// The control sinks with the page behind it (scaled to 0.95 about the pane's centre).
val k = 1f - 0.05f * mean
val cx = size.width / 2f
val cy = size.height / 2f
left = lerp(cx + (f.left - cx) * k, 0f, l)
top = lerp(cy + (f.top - cy) * k, 0f, t)
right = lerp(cx + (f.right - cx) * k, size.width, r)
bottom = lerp(cy + (f.bottom - cy) * k, size.height, b)
}
val w = (right - left).coerceAtLeast(0f)
val h = (bottom - top).coerceAtLeast(0f)
val blob = sin(PI * mean).toFloat().coerceAtLeast(0f)
val corner = lerp(from.corner * (1f - mean), minOf(w, h) * 0.42f, blob)
// Onto a picture it keeps a card's shape, corner to corner; over the pane it blobs in flight.
val corner = if (to != null) lerp(from.corner, toCorner, mean).coerceAtMost(minOf(w, h) / 2f)
else lerp(from.corner * (1f - mean), minOf(w, h) * 0.42f, sin(PI * mean).toFloat().coerceAtLeast(0f))
drawRoundRect(fill, Offset(left, top), Size(w, h), CornerRadius(corner), alpha = alpha)
val pic = art?.intrinsicSize?.takeIf { it.isSpecified && it.width > 0f && it.height > 0f }
if (art != null && pic != null && w > 0f && h > 0f) {
@@ -1,88 +0,0 @@
package com.droiddeck.launcher.ui
import com.droiddeck.launcher.R
import androidx.compose.ui.res.stringResource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
class ProtonRow(val id: String, val name: String, val installed: String?, val queued: Boolean)
@Composable
fun ProtonPage(
rows: List<ProtonRow>,
busyId: String?,
stage: String?,
percent: Int,
runtimeReady: Boolean,
sessionRunning: Boolean,
onInstall: (String) -> Unit,
onCancel: (String) -> Unit,
onRemove: (String) -> Unit,
onBack: () -> Unit,
) {
val host = rememberMenuHost()
val colors = MaterialTheme.colorScheme
SettingsPage(
host,
title = stringResource(R.string.setup_tool_protons),
eyebrow = stringResource(R.string.setup_title),
lede = stringResource(R.string.proton_lede),
onBack = onBack,
) {
if (!runtimeReady) Text(
stringResource(R.string.content_needs_runtime),
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp, bottom = 6.dp),
)
if (sessionRunning) Text(
stringResource(R.string.proton_stop_first),
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp, bottom = 6.dp),
)
val compatible = stringResource(R.string.steam_compat_label)
Text(
stringResource(R.string.proton_compatible_note, compatible),
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp, bottom = 6.dp),
)
SettingsGroup(stringResource(R.string.proton_available)) {
for (row in rows) {
SettingsRow(
row.name,
when {
row.installed != null -> stringResource(R.string.proton_installed_compatible, row.installed, compatible)
row.queued -> stringResource(R.string.proton_queued)
else -> stringResource(R.string.proton_not_installed)
},
) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
when {
row.installed != null -> SecondaryButton(stringResource(R.string.store_remove), enabled = busyId == null && runtimeReady && !sessionRunning) { onRemove(row.id) }
busyId != null -> SecondaryButton(if (busyId == row.id) stringResource(R.string.store_installing) else stringResource(R.string.store_install), enabled = false) {}
else -> SecondaryButton(stringResource(R.string.proton_install_now), enabled = runtimeReady && !sessionRunning) { onInstall(row.id) }
}
if (row.queued && row.installed == null && busyId == null) {
SecondaryButton(stringResource(R.string.proton_cancel_queued), enabled = !sessionRunning) { onCancel(row.id) }
}
}
}
if (busyId == row.id) Column(modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, bottom = 10.dp)) {
Text(
if (stage != null && percent >= 0) stringResource(R.string.store_busy_percent_short, stage, percent) else stage ?: stringResource(R.string.store_starting),
fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 5.dp),
)
if (percent >= 0) LinearProgressIndicator(progress = { percent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp))
else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp))
}
}
}
}
}
Loaded 100 of 277 files, more files were not shown because too many files have changed in this diff. Show more