mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-08 08:04:47 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efd9f36985 | ||
|
|
1dabc20e92 |
No files matched your search
@@ -136,18 +136,11 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
components-runner:
|
||||
needs: source
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
uses: ./.github/workflows/pick-runner.yml
|
||||
secrets:
|
||||
RUNNER_STATUS_TOKEN: ${{ secrets.RUNNER_STATUS_TOKEN }}
|
||||
|
||||
components:
|
||||
name: Prepare verified components
|
||||
needs: [source, components-runner]
|
||||
needs: source
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
runs-on: ${{ fromJSON(needs.components-runner.outputs.labels) }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -313,11 +306,6 @@ jobs:
|
||||
- name: Build the DiRT 3 / DiRT Showdown ws2_32 stand-in
|
||||
run: python3 tools/components/prepare.py dirt-ws2
|
||||
|
||||
- name: Cache graphics driver inputs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: app/build/quest-inputs
|
||||
key: quest-inputs-${{ hashFiles('tools/quest/build-driver.py') }}
|
||||
- name: Prepare graphics driver variants
|
||||
run: python3 tools/quest/build-driver.py "$ANDROID_HOME/ndk/$NDK_VERSION"
|
||||
- name: Record and pack prepared components
|
||||
@@ -330,18 +318,11 @@ jobs:
|
||||
compression-level: 0
|
||||
retention-days: 14
|
||||
|
||||
build-runner:
|
||||
needs: [components]
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
uses: ./.github/workflows/pick-runner.yml
|
||||
secrets:
|
||||
RUNNER_STATUS_TOKEN: ${{ secrets.RUNNER_STATUS_TOKEN }}
|
||||
|
||||
build:
|
||||
needs: [source, components, xbox, build-runner]
|
||||
needs: [source, components, xbox]
|
||||
name: Compile APK and Android tests
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
runs-on: ${{ fromJSON(needs.build-runner.outputs.labels) }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
outputs:
|
||||
variants: ${{ steps.variants.outputs.list }}
|
||||
@@ -563,11 +544,9 @@ jobs:
|
||||
- name: Cache checksum-pinned pack inputs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/droiddeck-pack-inputs
|
||||
~/.cache/droiddeck-build
|
||||
key: pack-inputs-v2-${{ hashFiles('tools/packs/build.sh', 'tools/packs/fetch-verified.sh', 'tools/packs/tracking/release.env', 'tools/powervr/release.json') }}
|
||||
restore-keys: pack-inputs-v2-
|
||||
path: ~/.cache/droiddeck-pack-inputs
|
||||
key: pack-inputs-v1-${{ hashFiles('tools/packs/build.sh', 'tools/packs/fetch-verified.sh', 'tools/packs/tracking/release.env') }}
|
||||
restore-keys: pack-inputs-v1-
|
||||
- name: Build the add-on packs
|
||||
run: |
|
||||
python3 tools/powervr/fetch.py
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
name: Pick runner
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
labels:
|
||||
value: ${{ jobs.pick.outputs.labels }}
|
||||
secrets:
|
||||
RUNNER_STATUS_TOKEN:
|
||||
required: false
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
name: Pick runner
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 2
|
||||
outputs:
|
||||
labels: ${{ steps.runner.outputs.labels || steps.fork.outputs.labels || '"ubuntu-24.04"' }}
|
||||
steps:
|
||||
- name: Pick the compile runner
|
||||
id: runner
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RUNNER_STATUS_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
labels='"ubuntu-24.04"'
|
||||
if [ -n "$GH_TOKEN" ]; then
|
||||
idle=$(gh api "orgs/${GITHUB_REPOSITORY_OWNER}/actions/runners" --jq '[.runners[] | select(.status == "online" and .busy == false and any(.labels[]; .name == "droiddeck-ws"))] | length' 2>/dev/null || echo 0)
|
||||
if [ "${idle:-0}" -gt 0 ]; then
|
||||
labels='["self-hosted","droiddeck-ws"]'
|
||||
fi
|
||||
fi
|
||||
echo "labels=$labels" >> "$GITHUB_OUTPUT"
|
||||
echo "Compile runner: \`$labels\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Pick the fork runner
|
||||
id: fork
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
labels='"ubuntu-24.04"'
|
||||
gist=https://api.github.com/gists/0c7ed7ad6d6493de84f5123689c64ae6
|
||||
body=$(curl -fsS --max-time 15 -H "Authorization: Bearer $GH_TOKEN" "$gist" 2>/dev/null || curl -fsS --max-time 15 "$gist" 2>/dev/null || echo '{}')
|
||||
IDLE_AT=$(jq -r 'select(.owner.login == "maxjivi05") | .files["idle-at"].content // "0"' <<< "$body" 2>/dev/null || echo 0)
|
||||
echo "Heartbeat: ${IDLE_AT:-none}"
|
||||
age=-1
|
||||
if [[ "${IDLE_AT:-0}" =~ ^[0-9]+$ ]] && [ "${IDLE_AT:-0}" -gt 0 ]; then
|
||||
age=$(( $(date +%s) - IDLE_AT ))
|
||||
fi
|
||||
if [ "$age" -ge 0 ] && [ "$age" -lt 180 ]; then
|
||||
labels='"droiddeck-fork"'
|
||||
fi
|
||||
echo "labels=$labels" >> "$GITHUB_OUTPUT"
|
||||
echo "Compile runner: \`$labels\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
DroidDeck brings the SteamOS experience to Android: Steam Gaming Mode, full Linux desktop, integration with GOG, Epic Games, Xbox, and other storefronts, Steam VR, and more!
|
||||
|
||||
<p align="center"><b>100% open source. No ads. No telemetry. No proprietary components. No source withheld.</b></p>
|
||||
<p align="center"><b>100% open source. No ads. No telemetry. No source withheld.</b></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Droid-Deck/DroidDeck/releases/download/0.6.9/DroidDeck-0.6.9.apk"><img src="https://img.shields.io/badge/Download-DroidDeck%200.6.9%20APK-3DDC84?style=for-the-badge&logo=android&logoColor=white" alt="Download DroidDeck 0.6.9"></a>
|
||||
@@ -79,8 +79,7 @@ DroidDeck is [GPL-3.0](LICENSE). Steam and Proton belong to Valve Corporation. V
|
||||
- droiddeck-ntsync follows the [Linux](https://github.com/torvalds/linux) ntsync semantics in `Documentation/userspace-api/ntsync.rst` (GPL-2.0).
|
||||
- The proot fast path orders bindings like [PRoot](https://github.com/proot-me/proot)'s `path/binding.c` (GPL-2.0-or-later).
|
||||
- DiRT 3 and DiRT Showdown keep their telemetry socket the way RoccoC's DiRTTelemetryErrorFix (MIT) does, based on the fix by Carsten Gottschling.
|
||||
- Zink on PowerVR keeps colour textures in colour with Icenowy Zheng's upstream [Mesa](https://gitlab.freedesktop.org/mesa/mesa) fix d55418dae7 (MIT), carried as a runtime patch.
|
||||
- Games and AppImage programs such as emulators use [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's per render pass memory choice. The Steam client and Chromium-based apps keep `TU_DEBUG=sysmem`.
|
||||
- Games use [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's per render pass memory choice. The Steam client keeps `TU_DEBUG=sysmem`.
|
||||
- Black Ops III and Dark Souls Remastered start without [umu-protonfixes](https://github.com/Open-Wine-Components/umu-protonfixes) (BSD-2-Clause), whose `311210.py` and `570940.py` fail under ARM64 Proton.
|
||||
- [ReSkate](https://github.com/Dingo-Shenanigans/ReSkate) (GPL-3.0) by Dingo Shenanigans uses the launch option from its README and D3D12 feature level 12_1.
|
||||
- The arm64 client's sign in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest).
|
||||
@@ -95,7 +94,6 @@ DroidDeck is [GPL-3.0](LICENSE). Steam and Proton belong to Valve Corporation. V
|
||||
- Battle.net is by [@kayakyakr](https://github.com/kayakyakr). It runs Blizzard's own client in Proton (downloaded from Blizzard, not included) and shows key art from the [Battle.net Shop](https://shop.battle.net). Without Steam, its games see the pad as an Xbox 360 controller via `PROTON_SPOOF_STEAMINPUT_VIDPID` in [Proton](https://github.com/ValveSoftware/wine)'s `dlls/winebus.sys`.
|
||||
- Battle.net installs drive Blizzard's Agent the way [@barncastle](https://github.com/barncastle)'s [Battle.Net-Installer](https://github.com/barncastle/Battle.Net-Installer) does, with requests from [wowdev.wiki](https://wowdev.wiki/Agent). Read without taking code: [@mubaraknumann](https://github.com/mubaraknumann)'s [unifideck](https://github.com/mubaraknumann/unifideck), [@djmittens](https://github.com/djmittens)'s [neo-mittens](https://github.com/djmittens/neo-mittens) `bnetswitch`, [@BCD1210](https://github.com/BCD1210)'s [soju](https://github.com/BCD1210/soju) and [Lutris](https://github.com/lutris/lutris)'s `product.db` reader.
|
||||
- World of Warcraft addon imports replace an addon's folder from a temporary copy, as [WowUp](https://github.com/WowUp/WowUp) does, and read flavour folders the way [WoWDeck](https://github.com/sigboe/wowdeck) does. No code from either is used.
|
||||
- The World of Warcraft addon Download window searches [WoWInterface](https://www.wowinterface.com) through its MMOUI API. [CurseForge](https://www.curseforge.com/wow) opens in the user's browser, with no API and no key.
|
||||
- Xbox games load [@domsvoboda](https://github.com/domsvoboda)'s [xodus-nexus](https://github.com/domsvoboda/xodus-nexus) modules (LGPL-2.1-or-later), including `wintypes.dll`. Minecraft's GameInput setup was checked against [@Wyze3306](https://github.com/Wyze3306)'s [BedrockOnLinux](https://github.com/Wyze3306/BedrockOnLinux) (MIT).
|
||||
- The Xbox Cloud Gaming player uses the WebRTC client of [@unknownskl](https://github.com/unknownskl)'s [Greenlight](https://github.com/unknownskl/greenlight) (MIT), and its offer tweaks and sharpening follow [@redphx](https://github.com/redphx)'s [Better xCloud](https://github.com/redphx/better-xcloud) (MIT). See `tools/xcloud-player/NOTICE.md`.
|
||||
- The itch.io butler pack ships butler's `7z.so`, itch.io's build of [7-Zip](https://www.7-zip.org) by Igor Pavlov (LGPL-2.1, unRAR restriction on RAR code), unmodified. Other archives use Apache Commons Compress and the runtime's bsdtar (libarchive).
|
||||
|
||||
+2
-2
@@ -131,8 +131,8 @@ android {
|
||||
applicationId "com.droiddeck.launcher"
|
||||
minSdkVersion 26
|
||||
targetSdkVersion 28
|
||||
versionCode 18
|
||||
versionName "0.7.0"
|
||||
versionCode 17
|
||||
versionName "0.6.9"
|
||||
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
|
||||
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
|
||||
buildConfigField "int", "PR_NUMBER", "${prNumber}"
|
||||
|
||||
@@ -195,6 +195,7 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
|
||||
<activity
|
||||
android:name=".PinRequestActivity"
|
||||
android:exported="true"
|
||||
@@ -207,6 +208,13 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".input.ControllerEditorActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/SessionTheme"
|
||||
android:screenOrientation="sensorLandscape"
|
||||
android:resizeableActivity="true"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density" />
|
||||
|
||||
<activity
|
||||
android:name=".sync.SyncthingWebActivity"
|
||||
|
||||
@@ -2953,19 +2953,15 @@ EXPORT DIR *opendir(const char *name) {
|
||||
return dir;
|
||||
}
|
||||
|
||||
static bool open_has_mode(int flags) {
|
||||
return (flags & O_CREAT) != 0 || (flags & O_TMPFILE) == O_TMPFILE;
|
||||
}
|
||||
|
||||
EXPORT int open(const char *pathname, int flags, ...) {
|
||||
va_list va;
|
||||
mode_t mode = 0;
|
||||
mode_t mode;
|
||||
int fd;
|
||||
bool hasMode;
|
||||
|
||||
va_start(va, flags);
|
||||
|
||||
hasMode = open_has_mode(flags);
|
||||
hasMode = flags & O_CREAT;
|
||||
|
||||
if (hasMode) {
|
||||
mode = va_arg(va, mode_t);
|
||||
@@ -3037,13 +3033,13 @@ EXPORT int open(const char *pathname, int flags, ...) {
|
||||
|
||||
EXPORT int openat(int dirfd, const char *pathname, int flags, ...) {
|
||||
va_list va;
|
||||
mode_t mode = 0;
|
||||
mode_t mode;
|
||||
int fd;
|
||||
bool hasMode;
|
||||
|
||||
va_start(va, flags);
|
||||
|
||||
hasMode = open_has_mode(flags);
|
||||
hasMode = flags & O_CREAT;
|
||||
|
||||
if (hasMode) {
|
||||
mode = va_arg(va, mode_t);
|
||||
@@ -4255,7 +4251,7 @@ EXPORT int select(int nfds, fd_set *readfds, fd_set *writefds,
|
||||
|
||||
#ifdef __GLIBC__
|
||||
static mode_t open_mode(int flags, va_list va) {
|
||||
return open_has_mode(flags) ? va_arg(va, mode_t) : 0;
|
||||
return (flags & (O_CREAT | O_TMPFILE)) ? va_arg(va, mode_t) : 0;
|
||||
}
|
||||
|
||||
EXPORT int open64(const char *pathname, int flags, ...) {
|
||||
|
||||
@@ -39,7 +39,7 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
|
||||
runCatching { ComponentUpdater.settle(activity) }
|
||||
val modes = ComponentsManager.COMPONENTS.associateWith { SessionPrefs.componentMode(activity, it) }
|
||||
val auto = ComponentsManager.COMPONENTS.associateWith { runCatching { ComponentUpdater.status(activity, it) }.getOrDefault(emptyList()) }
|
||||
val held = if (runCatching { ComponentsManager.autoDxvkHeld(activity) }.getOrDefault(false)) setOf("dxvk") else emptySet()
|
||||
val held = if (runCatching { ComponentsManager.hardwareDxvk(activity) }.getOrDefault(false)) setOf("dxvk") else emptySet()
|
||||
ui.post {
|
||||
compModes = modes
|
||||
compAuto = auto
|
||||
|
||||
@@ -79,12 +79,10 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
mode = uiMode, display = displaySlot(), runtime = runtimeSlot(),
|
||||
canRestart = !sessionRunning,
|
||||
checkedAt = TurnipReleases.cached(activity)?.checkedAt ?: 0L,
|
||||
tpUbwcMode = tpUbwcMode.takeIf { gpu.model == 740 },
|
||||
tpUbwcRestartRequired = com.droiddeck.launcher.gpu.TpUbwcHint.restartRequired(activity),
|
||||
)
|
||||
|
||||
fun tpUbwcModeForDevice(): String? = tpUbwcMode.takeIf { gpu.model == 740 }
|
||||
|
||||
fun tpUbwcRestartRequired(): Boolean = com.droiddeck.launcher.gpu.TpUbwcHint.restartRequired(activity)
|
||||
|
||||
fun chooseTpUbwcMode(mode: String) {
|
||||
if (gpu.model != 740) return
|
||||
SessionPrefs.setTpUbwcMode(activity, mode)
|
||||
@@ -129,14 +127,8 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
)
|
||||
}
|
||||
|
||||
var restartBlocked: () -> Boolean = { SessionState.running }
|
||||
|
||||
fun loadTpUbwcMode() {
|
||||
tpUbwcMode = SessionPrefs.tpUbwcMode(activity)
|
||||
}
|
||||
|
||||
fun restartApp() {
|
||||
if (restartBlocked()) return
|
||||
if (SessionState.running) return
|
||||
val launch = activity.packageManager.getLaunchIntentForPackage(activity.packageName) ?: return
|
||||
activity.startActivity(android.content.Intent.makeRestartActivityTask(launch.component)
|
||||
.putExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV, "components"))
|
||||
@@ -603,19 +595,11 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
}
|
||||
|
||||
fun selectDriver(id: String, linux: Boolean) {
|
||||
if (!linux && id == TurnipDriver.AUTO) {
|
||||
DriverBundle.selectDisplay(activity, id)
|
||||
pairModeOpen = false
|
||||
lastAutoCheck = 0L
|
||||
setMode(auto = true)
|
||||
refreshDrivers()
|
||||
return
|
||||
}
|
||||
val bundle = DriverBundle.containing(activity, id, linux)
|
||||
when {
|
||||
bundle != null -> useBundle(bundle)
|
||||
linux -> SessionPrefs.setLinuxDriver(activity, id)
|
||||
else -> DriverBundle.selectDisplay(activity, id)
|
||||
else -> SessionPrefs.setAndroidDriver(activity, id)
|
||||
}
|
||||
pairModeOpen = false
|
||||
setMode(auto = false, mix = true)
|
||||
|
||||
@@ -23,7 +23,6 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -64,7 +63,9 @@ import com.droiddeck.launcher.ui.ModeSettings
|
||||
import com.droiddeck.launcher.ui.ModeSettingsActions
|
||||
import com.droiddeck.launcher.ui.ConfirmDialog
|
||||
import com.droiddeck.launcher.ui.ControllerActions
|
||||
import com.droiddeck.launcher.ui.ControllerMappingPage
|
||||
import com.droiddeck.launcher.input.ControllerPrefs
|
||||
import com.droiddeck.launcher.input.ControllerEditorActivity
|
||||
import com.droiddeck.launcher.ui.FrontEndScreen
|
||||
import com.droiddeck.launcher.ui.FrontEndState
|
||||
import com.droiddeck.launcher.ui.FrontEndActions
|
||||
@@ -201,7 +202,6 @@ class FrontEndActivity : ComponentActivity() {
|
||||
private var downloadPrefix by mutableStateOf("")
|
||||
private var downloadProxy by mutableStateOf("")
|
||||
private var animationsEnabled by mutableStateOf(true)
|
||||
private var lowStorageWarning by mutableStateOf(true)
|
||||
private var launcherIcon by mutableStateOf(LauncherIcon.DARK)
|
||||
private var gameStoresEnabled by mutableStateOf(true)
|
||||
private var gameStoresSpeedTier by mutableStateOf("fast")
|
||||
@@ -625,7 +625,6 @@ class FrontEndActivity : ComponentActivity() {
|
||||
private var dualScreenMode by mutableStateOf(com.droiddeck.launcher.input.DualScreen.MODE_AUTO)
|
||||
private var dualScreenSessions by mutableStateOf(com.droiddeck.launcher.input.DualScreen.MODE_AUTO)
|
||||
private var dualScreenControls by mutableStateOf(true)
|
||||
private var androidTextFieldKeyboard by mutableStateOf(false)
|
||||
private var launcherStarted = false
|
||||
private lateinit var displayManager: DisplayManager
|
||||
private val secondScreenDisplayListener = object : DisplayManager.DisplayListener {
|
||||
@@ -874,7 +873,6 @@ class FrontEndActivity : ComponentActivity() {
|
||||
android.widget.Toast.makeText(this, R.string.game_link_failed, android.widget.Toast.LENGTH_LONG).show()
|
||||
return false
|
||||
}
|
||||
Library.recordPlayed(this, game)
|
||||
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
|
||||
.putExtra(SessionActivity.EXTRA_STEAMVR, steamVr)
|
||||
.returnTo(returnHome)
|
||||
@@ -890,15 +888,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
putExtra(SessionService.EXTRA_STEAM_URL,
|
||||
com.droiddeck.launcher.frontend.GameLaunchLink.steamUrl(game.gameIdString))
|
||||
}
|
||||
}
|
||||
.playing(game), steamSession = true)
|
||||
}
|
||||
|
||||
private fun Intent.playing(game: Library.SteamGame): Intent =
|
||||
apply { if (game.library == Library.ADDED) putExtra(SessionActivity.EXTRA_PLAYED_GAME, game.gameIdString) }
|
||||
|
||||
private fun recordPlayed(intent: Intent) {
|
||||
intent.getStringExtra(SessionActivity.EXTRA_PLAYED_GAME)?.let { Library.recordPlayed(this, it) }
|
||||
}, steamSession = true)
|
||||
}
|
||||
|
||||
private fun Intent.returnTo(returnHome: Boolean): Intent {
|
||||
@@ -925,17 +915,14 @@ class FrontEndActivity : ComponentActivity() {
|
||||
.putExtra(SessionService.EXTRA_PROGRAM, command.program)
|
||||
.putExtra(SessionService.EXTRA_PROGRAM_ARGS, command.args.toTypedArray())
|
||||
.putExtra(SessionService.EXTRA_GAME_ID, com.droiddeck.launcher.session.NoSteamLaunch.scope(game))
|
||||
.playing(game)
|
||||
when {
|
||||
!SessionState.running -> startSessionOrWait(next)
|
||||
SessionState.stopRequested -> android.widget.Toast.makeText(this, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
|
||||
command.program == com.droiddeck.launcher.session.NoSteamLaunch.BATTLENET && handToBattleNet(command.args, next, returnHome) -> {}
|
||||
SessionState.mode == SessionService.MODE_RUN && SessionState.program == command.program && SessionState.programArgs == command.args -> {
|
||||
recordPlayed(next)
|
||||
command.program == com.droiddeck.launcher.session.NoSteamLaunch.BATTLENET && handToBattleNet(command.args, returnHome) { pendingSwitch = next } -> {}
|
||||
SessionState.mode == SessionService.MODE_RUN && SessionState.program == command.program && SessionState.programArgs == command.args ->
|
||||
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
|
||||
.returnTo(returnHome)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
|
||||
}
|
||||
else -> pendingSwitch = next
|
||||
}
|
||||
}
|
||||
@@ -1097,8 +1084,6 @@ class FrontEndActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
drivers.restartBlocked = ::runtimeChangesBlocked
|
||||
drivers.loadTpUbwcMode()
|
||||
pendingSessionLogExport = savedInstanceState?.getString(KEY_PENDING_SESSION_LOG_EXPORT)?.let(::File)
|
||||
homeChoiceOpen = savedInstanceState?.getBoolean(STATE_HOME_CHOICE_OPEN) == true
|
||||
gameSpaceOffered = savedInstanceState?.getBoolean(STATE_GAME_SPACE_OFFERED) == true
|
||||
@@ -1125,7 +1110,6 @@ class FrontEndActivity : ComponentActivity() {
|
||||
downloadProxy = DownloadSource.proxyHostPort(this)
|
||||
DownloadSource.probe()
|
||||
animationsEnabled = SessionPrefs.animationsEnabled(this)
|
||||
lowStorageWarning = SessionPrefs.lowStorageWarning(this)
|
||||
LauncherIcon.repair(this, intent?.component)
|
||||
launcherIcon = LauncherIcon.current(this)
|
||||
gameStoresEnabled = SessionPrefs.gameStoresEnabled(this)
|
||||
@@ -1136,10 +1120,6 @@ class FrontEndActivity : ComponentActivity() {
|
||||
applyLauncherFullscreen()
|
||||
updates.start()
|
||||
setContent {
|
||||
val prootPack by com.droiddeck.launcher.core.AddonPack.PROOT.state.collectAsState()
|
||||
androidx.compose.runtime.LaunchedEffect(prootPack) {
|
||||
if (prootPack == com.droiddeck.launcher.core.AddonPack.State.Ready && installed == null) refresh()
|
||||
}
|
||||
DroidDeckTheme(theme, appScale = appScale) {
|
||||
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize().exposeTestTags()) {
|
||||
if (decky.deckyPendingZip != null) com.droiddeck.launcher.ui.DeckyZipWarningDialog(
|
||||
@@ -1176,7 +1156,6 @@ class FrontEndActivity : ComponentActivity() {
|
||||
vacDiagnostics = vacDiagnostics, agentCommands = agentCommands,
|
||||
graphicsLogs = graphicsLogs, steamGpuDrawing = steamGpuDrawing, bridgeFellBack = bridgeFellBack, bridgeFellBackMemory = bridgeFellBackMemory,
|
||||
clearCachedApps = clearCachedApps, adbPaired = adbPaired, memoryHud = memoryHud, backgroundAppLimit = backgroundAppLimit,
|
||||
tpUbwcMode = drivers.tpUbwcModeForDevice(), tpUbwcRestartRequired = drivers.tpUbwcRestartRequired(), canRestartApp = !runtimeChangesBlocked(),
|
||||
steamGames = steamGames, steamGamesX64 = steamGamesX64, emulators = emulatorList, running = runningLabel,
|
||||
frameGen = FrameGen.mode(this),
|
||||
lossless = lossless,
|
||||
@@ -1196,7 +1175,6 @@ class FrontEndActivity : ComponentActivity() {
|
||||
dualScreenMode = dualScreenMode,
|
||||
dualScreenSessions = dualScreenSessions,
|
||||
dualScreenControls = dualScreenControls,
|
||||
androidTextFieldKeyboard = androidTextFieldKeyboard,
|
||||
packages = packageRows,
|
||||
packageCatalogLoading = catalogLoading,
|
||||
packageBusyId = pkgId,
|
||||
@@ -1219,7 +1197,6 @@ class FrontEndActivity : ComponentActivity() {
|
||||
downloadPrefix = downloadPrefix,
|
||||
downloadProxy = downloadProxy,
|
||||
animationsEnabled = animationsEnabled,
|
||||
lowStorageWarning = lowStorageWarning,
|
||||
launcherIcon = launcherIcon,
|
||||
gameStoresEnabled = gameStoresEnabled,
|
||||
gameStoresSpeedTier = gameStoresSpeedTier,
|
||||
@@ -1423,8 +1400,6 @@ class FrontEndActivity : ComponentActivity() {
|
||||
},
|
||||
onClearCachedApps = { on -> SessionPrefs.setClearCachedApps(this, on); clearCachedApps = on },
|
||||
onMemoryHud = { value -> SessionPrefs.setMemoryHud(this, value); memoryHud = value },
|
||||
onTpUbwcMode = { drivers.chooseTpUbwcMode(it) },
|
||||
onRestartApp = { drivers.restartApp() },
|
||||
onBackgroundAppLimit = { limit ->
|
||||
SessionPrefs.setBackgroundAppLimit(this, limit); backgroundAppLimit = limit
|
||||
if (limit == com.droiddeck.launcher.core.BackgroundAppLimit.OFF) com.droiddeck.launcher.core.BackgroundAppLimit.restoreAsync(this)
|
||||
@@ -1528,14 +1503,6 @@ class FrontEndActivity : ComponentActivity() {
|
||||
SessionPrefs.setDualScreenSessionControls(this, on)
|
||||
dualScreenControls = on
|
||||
},
|
||||
onAndroidTextFieldKeyboard = { on ->
|
||||
SessionPrefs.setAndroidTextFieldKeyboard(this, on)
|
||||
androidTextFieldKeyboard = on
|
||||
},
|
||||
onLowStorageWarning = { on ->
|
||||
SessionPrefs.setLowStorageWarning(this, on)
|
||||
lowStorageWarning = on
|
||||
},
|
||||
onAnimationsEnabled = { on ->
|
||||
SessionPrefs.setAnimationsEnabled(this, on)
|
||||
animationsEnabled = on
|
||||
@@ -1635,7 +1602,23 @@ class FrontEndActivity : ComponentActivity() {
|
||||
},
|
||||
updates = updates.actions(),
|
||||
controller = ControllerActions(
|
||||
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
|
||||
onTint = { t -> ControllerPrefs.setTint(this, t); refreshController() },
|
||||
onOpacity = { o -> ControllerPrefs.setOpacity(this, o); refreshController() },
|
||||
onSize = { v -> ControllerPrefs.setSize(this, v); refreshController() },
|
||||
onStickClick = { on -> ControllerPrefs.setStickClick(this, on); refreshController() },
|
||||
onAdaptiveSticks = { on -> ControllerPrefs.setAdaptiveSticks(this, on); refreshController() },
|
||||
onRumble = { on -> ControllerPrefs.setRumble(this, on); refreshController() },
|
||||
onDeadZone = { v -> ControllerPrefs.setDeadZone(this, v); refreshController() },
|
||||
onSteamButton = { on -> ControllerPrefs.setSteamButton(this, on); refreshController() },
|
||||
onQamButton = { on -> ControllerPrefs.setQamButton(this, on); refreshController() },
|
||||
onKeyboardButton = { on -> ControllerPrefs.setKeyboardButton(this, on); refreshController() },
|
||||
onSelectSteam = { on -> ControllerPrefs.setSelectSteam(this, on); refreshController() },
|
||||
onJoyConVr = { on -> ControllerPrefs.setJoyConVr(this, on); refreshController() },
|
||||
onEditLayout = { startActivity(Intent(this, ControllerEditorActivity::class.java)) },
|
||||
onResetLayout = { ControllerPrefs.resetAllLayouts(this); refreshController() },
|
||||
onMapping = { showPerformance = false; showComponents = false; showMapping = true },
|
||||
onResetAll = { ControllerPrefs.resetAll(this); refreshController() },
|
||||
),
|
||||
),
|
||||
page = page,
|
||||
@@ -1685,8 +1668,6 @@ class FrontEndActivity : ComponentActivity() {
|
||||
lowSpace?.let { free ->
|
||||
com.droiddeck.launcher.ui.LowSpaceDialog(
|
||||
free,
|
||||
warn = lowStorageWarning,
|
||||
onWarnChange = { on -> SessionPrefs.setLowStorageWarning(this, on); lowStorageWarning = on },
|
||||
onFreeUp = { lowSpace = null; com.droiddeck.launcher.storage.StorageState.requestOpen() },
|
||||
onPlay = { lowSpace = null; com.droiddeck.launcher.storage.StorageState.lowSpaceDismissed = true; startSteamSession() },
|
||||
onDismiss = { lowSpace = null },
|
||||
@@ -1782,9 +1763,8 @@ class FrontEndActivity : ComponentActivity() {
|
||||
|
||||
private fun playSteam() {
|
||||
val storage = com.droiddeck.launcher.storage.StorageState
|
||||
val free = storage.freeBytes(this)
|
||||
if (storage.shouldWarnLowSpace(SessionPrefs.lowStorageWarning(this), storage.lowSpaceDismissed, SessionState.running, free)) {
|
||||
lowSpace = free
|
||||
if (!SessionState.running && !storage.lowSpaceDismissed && storage.lowOnSpace(this)) {
|
||||
lowSpace = storage.freeBytes(this)
|
||||
storage.refresh(this)
|
||||
} else startSteamSession()
|
||||
}
|
||||
@@ -2064,7 +2044,6 @@ class FrontEndActivity : ComponentActivity() {
|
||||
dualScreenDevice = com.droiddeck.launcher.input.DualScreen.anyDisplay(displayManager, ownDisplayId) != null
|
||||
dualScreenSessions = SessionPrefs.dualScreenSessionMode(this)
|
||||
dualScreenControls = SessionPrefs.dualScreenSessionControls(this)
|
||||
androidTextFieldKeyboard = SessionPrefs.androidTextFieldKeyboard(this)
|
||||
val target = com.droiddeck.launcher.input.DualScreen.display(this, displayManager, ownDisplayId)
|
||||
dualDisplayId = target?.displayId ?: -1
|
||||
syncDualPanel()
|
||||
@@ -2157,7 +2136,13 @@ class FrontEndActivity : ComponentActivity() {
|
||||
|
||||
@Composable
|
||||
private fun MappingHost() {
|
||||
com.droiddeck.launcher.ui.InputSettingsPage(onBack = { showMapping = false })
|
||||
val settings = controllerSettings ?: return
|
||||
ControllerMappingPage(
|
||||
mapping = settings.mapping,
|
||||
onPick = { id, target -> ControllerPrefs.setTarget(this, id, target); refreshController() },
|
||||
onReset = { ControllerPrefs.resetMapping(this); refreshController() },
|
||||
onBack = { showMapping = false },
|
||||
)
|
||||
}
|
||||
|
||||
private fun openComponents(focusContent: Boolean = true, tab: String? = null, gameKey: String? = null) {
|
||||
@@ -2241,6 +2226,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() },
|
||||
onMode = { m -> drivers.chooseMode(m) },
|
||||
onRestart = { drivers.restartApp() },
|
||||
onTpUbwcMode = { drivers.chooseTpUbwcMode(it) },
|
||||
),
|
||||
protons = com.droiddeck.launcher.ui.ProtonsState(
|
||||
rows = protons.protonRows, busyId = protons.protonBusyId, stage = protons.protonStage, percent = protons.protonPercent,
|
||||
@@ -2293,14 +2279,14 @@ class FrontEndActivity : ComponentActivity() {
|
||||
when {
|
||||
!SessionState.running -> startSessionOrWait(next)
|
||||
SessionState.stopRequested -> android.widget.Toast.makeText(this, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
|
||||
handToBattleNet(full, next) -> {}
|
||||
handToBattleNet(full) { pendingSwitch = next } -> {}
|
||||
else -> pendingSwitch = next
|
||||
}
|
||||
}
|
||||
}, "battlenet-open").start()
|
||||
}
|
||||
|
||||
private fun handToBattleNet(args: List<String>, next: Intent, returnHome: Boolean = false): Boolean {
|
||||
private fun handToBattleNet(args: List<String>, returnHome: Boolean = false, otherwise: () -> Unit): Boolean {
|
||||
if (SessionState.mode != SessionService.MODE_RUN || SessionState.program != com.droiddeck.launcher.session.NoSteamLaunch.BATTLENET) return false
|
||||
val library = args.takeIf { it.firstOrNull() == "--library" }?.getOrNull(1)
|
||||
val sessionLibrary = SessionState.programArgs.takeIf { it.firstOrNull() == "--library" }?.getOrNull(1)
|
||||
@@ -2311,13 +2297,10 @@ class FrontEndActivity : ComponentActivity() {
|
||||
val handed = request.isEmpty() || com.droiddeck.launcher.stores.battlenet.BattleNetClient.request(LinuxRuntime.sessionRoot(app), request)
|
||||
ui.post {
|
||||
if (isDestroyed) return@post
|
||||
if (!handed) pendingSwitch = next
|
||||
else {
|
||||
recordPlayed(next)
|
||||
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
|
||||
.returnTo(returnHome)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
|
||||
}
|
||||
if (!handed) otherwise()
|
||||
else startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
|
||||
.returnTo(returnHome)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
|
||||
}
|
||||
}, "battlenet-request").start()
|
||||
return true
|
||||
@@ -2455,7 +2438,6 @@ class FrontEndActivity : ComponentActivity() {
|
||||
SessionPrefs.setUnfoldedControlsWithController(this, on); unfoldedControlsWithController = on
|
||||
},
|
||||
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
|
||||
onInputSettings = { showPerformance = false; showComponents = false; showMapping = true },
|
||||
onBackHold = { on -> SessionPrefs.setBackHold(this, on); backHold = on },
|
||||
onBackActionsInverted = { inverted ->
|
||||
SessionPrefs.setBackActionsInverted(this, inverted)
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -58,7 +58,7 @@ public final class Downloader {
|
||||
|
||||
private static void closeAsync(InputStream active) {
|
||||
CANCEL_EXECUTOR.execute(() -> {
|
||||
try { active.close(); } catch (IOException | IllegalStateException ignored) { }
|
||||
try { active.close(); } catch (IOException ignored) { }
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,9 +12,6 @@ object Hashes {
|
||||
@JvmStatic
|
||||
fun sha512(file: File): String = hex(file, "SHA-512")
|
||||
|
||||
@JvmStatic
|
||||
fun md5(file: File): String = hex(file, "MD5")
|
||||
|
||||
@JvmStatic
|
||||
fun isGithubSha256(digest: String?): Boolean = digest != null && digest.matches(GITHUB_SHA256)
|
||||
|
||||
|
||||
@@ -90,8 +90,7 @@ object StorageRoots {
|
||||
.filter { present(it.state) }
|
||||
.forEachIndexed { index, volume ->
|
||||
val uuid = volume.uuid?.takeIf { it.isNotBlank() }
|
||||
val directory = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) volume.directory else null
|
||||
val dirName = directory?.name?.takeIf { it.isNotBlank() }
|
||||
val dirName = volume.directory?.name?.takeIf { it.isNotBlank() }
|
||||
val descSlug = volume.getDescription(context)?.trim()
|
||||
?.takeIf { it.isNotBlank() && !it.equals("android", ignoreCase = true) }
|
||||
?.replace(Regex("[^A-Za-z0-9._-]+"), "-")
|
||||
@@ -99,7 +98,9 @@ object StorageRoots {
|
||||
entry.removable = !volume.isPrimary && volume.isRemovable
|
||||
if (entry.label == null) entry.label = labelFor(context, volume, uuid ?: dirName ?: descSlug)
|
||||
|
||||
directory?.let { entry.candidates += it }
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
volume.directory?.let { entry.candidates += it }
|
||||
}
|
||||
val name = uuid ?: dirName
|
||||
if (name != null) {
|
||||
entry.candidates += File("/storage/$name")
|
||||
|
||||
@@ -18,10 +18,9 @@ object Library {
|
||||
val source: String = SOURCE_STEAM,
|
||||
val storeId: String? = null,
|
||||
val logo: File? = null,
|
||||
val installedAt: Long = 0L,
|
||||
) {
|
||||
fun withArt(art: File?, hero: File?, icon: File?, logo: File? = this.logo) =
|
||||
SteamGame(appId, name, art, library, gameId, hero, lastPlayed, gameFiles, protonPrefix, icon, source, storeId, logo, installedAt)
|
||||
SteamGame(appId, name, art, library, gameId, hero, lastPlayed, gameFiles, protonPrefix, icon, source, storeId, logo)
|
||||
|
||||
val gameIdString: String get() = java.lang.Long.toUnsignedString(gameId)
|
||||
val launchKey: String get() = if (storeId != null) "$source:$storeId" else gameIdString
|
||||
@@ -32,7 +31,6 @@ object Library {
|
||||
}
|
||||
}
|
||||
const val ADDED = "added"
|
||||
private const val PLAYED = "added_games_played"
|
||||
const val EPIC_LAUNCHER = "epic-launcher"
|
||||
const val EPIC_LAUNCHER_NAME = "Epic Games Launcher"
|
||||
const val BATTLENET_LAUNCHER = "battlenet-launcher"
|
||||
@@ -223,18 +221,6 @@ object Library {
|
||||
return (steam + own.map { g -> addedGame(context, g, roots, played[g.appId] ?: 0L) }).distinctBy { it.gameId }
|
||||
}
|
||||
|
||||
fun recordPlayed(context: Context, game: SteamGame) {
|
||||
if (game.library == ADDED) recordPlayed(context, game.gameIdString)
|
||||
}
|
||||
|
||||
fun recordPlayed(context: Context, gameId: String) {
|
||||
context.getSharedPreferences(PLAYED, Context.MODE_PRIVATE).edit()
|
||||
.putLong(gameId, System.currentTimeMillis() / 1000).apply()
|
||||
}
|
||||
|
||||
internal fun recordedPlay(context: Context, gameId: Long): Long =
|
||||
context.getSharedPreferences(PLAYED, Context.MODE_PRIVATE).getLong(java.lang.Long.toUnsignedString(gameId), 0L)
|
||||
|
||||
internal fun shortcutsLastPlayed(root: File): Map<Long, Long> = runCatching {
|
||||
val config = AddedGames.accountConfig(root) ?: return emptyMap()
|
||||
val bytes = File(config, "shortcuts.vdf").takeIf { it.isFile }?.readBytes() ?: return emptyMap()
|
||||
@@ -263,13 +249,12 @@ object Library {
|
||||
AddedGameArt.resolve(context, g).let { art ->
|
||||
SteamGame(
|
||||
g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, ADDED, g.gameId,
|
||||
hero = art.hero ?: art.header, lastPlayed = maxOf(lastPlayed, recordedPlay(context, g.gameId)), gameFiles = g.folder,
|
||||
hero = art.hero ?: art.header, lastPlayed = lastPlayed, gameFiles = g.folder,
|
||||
protonPrefix = if (g.source == com.droiddeck.launcher.stores.Store.BATTLENET.id) File(com.droiddeck.launcher.runtime.BattleNet.prefix(context), "pfx").takeIf { it.isDirectory }
|
||||
else protonPrefix(context, g.steamAppId?.toLong() ?: g.appId, libraries = roots),
|
||||
icon = art.icon?.takeIf { it.extension.lowercase() != "ico" },
|
||||
source = g.source, storeId = g.storeId,
|
||||
logo = art.logo,
|
||||
installedAt = g.folder.lastModified(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ object LibraryCache {
|
||||
source = o.optString("source").ifEmpty { if (o.getString("library") == Library.ADDED) Library.ADDED else Library.SOURCE_STEAM },
|
||||
storeId = o.optString("storeId").ifEmpty { null },
|
||||
logo = o.file("logo"),
|
||||
installedAt = o.optLong("installedAt"),
|
||||
)
|
||||
} to x64
|
||||
}.getOrElse { e -> Log.w(TAG, "unreadable, ignored: ${e.message}"); Pair(emptyList(), false) }
|
||||
@@ -45,7 +44,7 @@ object LibraryCache {
|
||||
JSONObject().put("appId", g.appId).put("name", g.name).put("library", g.library).put("gameId", g.gameId)
|
||||
.put("lastPlayed", g.lastPlayed).putFile("art", g.art).putFile("hero", g.hero)
|
||||
.putFile("gameFiles", g.gameFiles).putFile("protonPrefix", g.protonPrefix).putFile("icon", g.icon).putFile("logo", g.logo)
|
||||
.put("source", g.source).put("installedAt", g.installedAt).apply { g.storeId?.let { put("storeId", it) } },
|
||||
.put("source", g.source).apply { g.storeId?.let { put("storeId", it) } },
|
||||
)
|
||||
val f = file(context)
|
||||
val tmp = File(f.path + ".tmp")
|
||||
|
||||
@@ -27,11 +27,12 @@ object AutoPicks {
|
||||
}
|
||||
|
||||
val BUILT_IN = listOf(
|
||||
Rule(setOf(Family.A8XX), prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.WN_BALANCED)),
|
||||
Rule(setOf(Family.A8XX), models = setOf(840), prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.WN_BALANCED)),
|
||||
Rule(setOf(Family.A8XX), prefer = listOf(DriverPairs.WN_BALANCED)),
|
||||
Rule(setOf(Family.A7XX_LOW), prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.BANNER_710)),
|
||||
Rule(setOf(Family.A7XX), oneUi8Gen2 = true, prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.BANNER_ONEUI)),
|
||||
Rule(setOf(Family.A7XX), oneUi8Gen2 = true, prefer = listOf(DriverPairs.BANNER_ONEUI)),
|
||||
Rule(setOf(Family.A7XX), prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.BANNER)),
|
||||
Rule(setOf(Family.A6XX), prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.BANNER)),
|
||||
Rule(setOf(Family.A6XX), prefer = listOf(DriverPairs.BANNER)),
|
||||
Rule(setOf(Family.ADRENO_UNKNOWN), minSdk = 36, prefer = listOf(DriverPairs.WN_BALANCED)),
|
||||
Rule(setOf(Family.ADRENO_UNKNOWN), prefer = listOf(DriverPairs.BANNER)),
|
||||
)
|
||||
|
||||
@@ -159,12 +159,6 @@ object DriverBundle {
|
||||
SessionPrefs.setLinuxDriver(context, bundle.linuxId)
|
||||
}
|
||||
|
||||
fun selectDisplay(context: Context, id: String) {
|
||||
val left = active(context)
|
||||
SessionPrefs.setAndroidDriver(context, id)
|
||||
if (left != null && left.androidId != id) SessionPrefs.setLinuxDriver(context, "")
|
||||
}
|
||||
|
||||
fun remove(context: Context, bundle: Bundle) {
|
||||
TurnipDriver(context).remove(bundle.androidId)
|
||||
LinuxVulkanDriverManager(context).removeDriver(bundle.linuxId)
|
||||
|
||||
@@ -97,7 +97,6 @@ object TurnipReleases {
|
||||
// Venus): its driver pairs fall past the first 15, so it is read in one full page.
|
||||
val perPage = if (src.repo == NON_ADRENO_REPO) 100 else 15
|
||||
val releases = JSONArray(get(context, "https://api.github.com/repos/${src.repo}/releases?per_page=$perPage"))
|
||||
val found = JSONArray()
|
||||
val seen = HashSet<String>()
|
||||
var newest: String? = null
|
||||
for (i in 0 until releases.length()) {
|
||||
@@ -115,13 +114,12 @@ object TurnipReleases {
|
||||
val label = kind.label
|
||||
val sha = Hashes.githubSha256(a.optString("digest")) ?: continue
|
||||
if (!seen.add(if (kind.bundle) kind.pair else "$linux|$label")) continue
|
||||
found.put(JSONObject().put("source", src.label).put("tag", tag).put("name", name)
|
||||
assets.put(JSONObject().put("source", src.label).put("tag", tag).put("name", name)
|
||||
.put("url", a.getString("browser_download_url")).put("size", a.optLong("size"))
|
||||
.put("linux", linux).put("label", label).put("sha256", sha).put("pair", kind.pair)
|
||||
.put("bundle", kind.bundle).put("id", a.optLong("id")).put("updated_at", a.optString("updated_at")))
|
||||
}
|
||||
}
|
||||
for (i in 0 until found.length()) assets.put(found.get(i))
|
||||
if (newest != null) latest.put(JSONObject().put("source", src.label).put("tag", newest))
|
||||
} catch (e: Exception) {
|
||||
failed.add(src.label)
|
||||
@@ -129,8 +127,7 @@ object TurnipReleases {
|
||||
}
|
||||
}
|
||||
if (failed.size == sources.size) throw lastError ?: IOException("no source answered")
|
||||
carryOver(failed, prefs(context).getString(KEY_RELEASE, null)?.let { runCatching { JSONObject(it) }.getOrNull() }, assets, latest)
|
||||
runCatching { fetch(context, AutoPicks.URL, AutoPicks.URL) }
|
||||
runCatching { get(context, AutoPicks.URL, AutoPicks.URL) }
|
||||
.onFailure { Log.i(TAG, "auto picks: ${it.message}") }
|
||||
.getOrNull()?.takeIf { AutoPicks.parse(it) != null }
|
||||
?.let { prefs(context).edit().putString(KEY_PICKS, it).apply() }
|
||||
@@ -140,22 +137,12 @@ object TurnipReleases {
|
||||
return parse(stored)
|
||||
}
|
||||
|
||||
internal var fetch: (Context, String, String) -> String = { context, source, url -> get(context, source, url) }
|
||||
|
||||
internal fun carryOver(failed: List<String>, previous: JSONObject?, assets: JSONArray, latest: JSONArray) {
|
||||
if (previous == null || failed.isEmpty()) return
|
||||
for ((key, into) in listOf("assets" to assets, "latest" to latest)) {
|
||||
val old = previous.optJSONArray(key) ?: continue
|
||||
for (i in 0 until old.length()) old.optJSONObject(i)?.takeIf { it.optString("source") in failed }?.let(into::put)
|
||||
}
|
||||
}
|
||||
|
||||
private fun get(context: Context, url: String): String {
|
||||
val sources = DownloadSource.candidates(url)
|
||||
var last: IOException? = null
|
||||
for ((index, source) in sources.withIndex()) {
|
||||
try {
|
||||
val body = fetch(context, source, url)
|
||||
val body = get(context, source, url)
|
||||
DownloadSource.succeeded(source)
|
||||
return body
|
||||
} catch (e: IOException) {
|
||||
|
||||
@@ -534,6 +534,9 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
private val inkRect = android.graphics.Rect()
|
||||
|
||||
private fun rebuild() {
|
||||
val renderingLayer = if (size == DroidDeckHudSize.STRIP && dock == DroidDeckHudDock.NONE)
|
||||
LAYER_TYPE_SOFTWARE else LAYER_TYPE_NONE
|
||||
if (layerType != renderingLayer) setLayerType(renderingLayer, null)
|
||||
glyphs.clear(); tileRects.clear(); graphRect = null
|
||||
headerMark = null; headerWordmark = null; chipRect = null; dPanel = null; dDividerTop = -1f; tabRect = null; stripRect = null
|
||||
val s = snap
|
||||
@@ -1350,15 +1353,10 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
canvas.drawText(g.text, g.x, g.baseline, paint)
|
||||
if (g.glow) paint.clearShadowLayer()
|
||||
}
|
||||
headerMark?.let {
|
||||
if (stripRect != null && layerType != LAYER_TYPE_SOFTWARE) drawCachedMark(canvas, it)
|
||||
else DroidDeckMark.draw(canvas, it.left, it.top, it.height(), blend(colValue), accent)
|
||||
}
|
||||
headerMark?.let { DroidDeckMark.draw(canvas, it.left, it.top, it.height(), blend(colValue), accent) }
|
||||
headerWordmark?.let { WordmarkArt.draw(canvas, it.left, it.top, it.height(), blend(colValue), accent) }
|
||||
|
||||
graphRect?.let {
|
||||
if (stripRect != null && layerType != LAYER_TYPE_SOFTWARE) drawCachedGraph(canvas, it) else drawGraph(canvas, it)
|
||||
}
|
||||
graphRect?.let { drawGraph(canvas, it) }
|
||||
|
||||
lockController.drawBadge(canvas)
|
||||
}
|
||||
@@ -1383,41 +1381,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
canvas.drawPath(graphPath, graphPaint)
|
||||
}
|
||||
|
||||
private var cachedGraphBitmap: Bitmap? = null
|
||||
private var cachedGraphCanvas: Canvas? = null
|
||||
private val cachedGraphRect = RectF()
|
||||
private var cachedMarkBitmap: Bitmap? = null
|
||||
private var cachedMarkKey = ""
|
||||
|
||||
private fun drawCachedGraph(canvas: Canvas, rect: RectF) {
|
||||
val w = rect.width().roundToInt().coerceAtLeast(1)
|
||||
val h = rect.height().roundToInt().coerceAtLeast(1)
|
||||
var bitmap = cachedGraphBitmap
|
||||
if (bitmap == null || bitmap.width != w || bitmap.height != h) {
|
||||
bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
cachedGraphBitmap = bitmap
|
||||
cachedGraphCanvas = Canvas(bitmap)
|
||||
cachedGraphRect.set(0f, 0f, w.toFloat(), h.toFloat())
|
||||
}
|
||||
bitmap.eraseColor(Color.TRANSPARENT)
|
||||
drawGraph(cachedGraphCanvas!!, cachedGraphRect)
|
||||
canvas.drawBitmap(bitmap, rect.left, rect.top, null)
|
||||
}
|
||||
|
||||
private fun drawCachedMark(canvas: Canvas, rect: RectF) {
|
||||
val w = rect.width().roundToInt().coerceAtLeast(1)
|
||||
val h = rect.height().roundToInt().coerceAtLeast(1)
|
||||
val key = "$w,$h,${blend(colValue)},$accent"
|
||||
var bitmap = cachedMarkBitmap
|
||||
if (bitmap == null || key != cachedMarkKey) {
|
||||
bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
DroidDeckMark.draw(Canvas(bitmap), 0f, 0f, h.toFloat(), blend(colValue), accent)
|
||||
cachedMarkBitmap = bitmap
|
||||
cachedMarkKey = key
|
||||
}
|
||||
canvas.drawBitmap(bitmap, rect.left, rect.top, null)
|
||||
}
|
||||
|
||||
private fun drawStrip(canvas: Canvas, rect: RectF) {
|
||||
val p = parent as? View
|
||||
val atBottom = p != null && p.height > 0 && y + height / 2f > p.height / 2f
|
||||
|
||||
@@ -9,7 +9,6 @@ import android.hardware.display.DisplayManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import android.view.Display
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
@@ -34,8 +33,6 @@ object BottomScreenGuests {
|
||||
else -> null
|
||||
}
|
||||
|
||||
internal fun current(arrivedAt: Long, ownShownAt: Long?): Boolean = ownShownAt == null || arrivedAt >= ownShownAt
|
||||
|
||||
internal fun ignorable(type: Int): Boolean = type == AccessibilityWindowInfo.TYPE_INPUT_METHOD ||
|
||||
type == AccessibilityWindowInfo.TYPE_ACCESSIBILITY_OVERLAY ||
|
||||
type == AccessibilityWindowInfo.TYPE_SPLIT_SCREEN_DIVIDER ||
|
||||
@@ -46,7 +43,7 @@ object BottomScreenGuests {
|
||||
}
|
||||
|
||||
class BottomScreenWatch(private val service: AccessibilityService) {
|
||||
private class Arrival(val pkg: String, val at: Long)
|
||||
private class Arrival(val pkg: String)
|
||||
|
||||
private val thread = HandlerThread("bottom-screen").apply { start() }
|
||||
private val handler = Handler(thread.looper)
|
||||
@@ -55,8 +52,6 @@ class BottomScreenWatch(private val service: AccessibilityService) {
|
||||
private var homes: Set<String> = emptySet()
|
||||
private var keyboards: Set<String> = emptySet()
|
||||
private var holders: Map<Int, String?> = emptyMap()
|
||||
private var ownWindows: Map<Int, Set<Int>> = emptyMap()
|
||||
private val ownShownAt = mutableMapOf<Int, Long>()
|
||||
private val check = Runnable { evaluate() }
|
||||
|
||||
fun start() {
|
||||
@@ -77,7 +72,7 @@ class BottomScreenWatch(private val service: AccessibilityService) {
|
||||
AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED -> {
|
||||
val pkg = event.packageName?.toString() ?: return
|
||||
if (event.displayId == Display.DEFAULT_DISPLAY || pkg == own || pkg == SYSTEM_UI) return
|
||||
arrivals[event.displayId] = Arrival(pkg, event.eventTime)
|
||||
arrivals[event.displayId] = Arrival(pkg)
|
||||
}
|
||||
AccessibilityEvent.TYPE_WINDOWS_CHANGED -> Unit
|
||||
else -> return
|
||||
@@ -102,25 +97,19 @@ class BottomScreenWatch(private val service: AccessibilityService) {
|
||||
val all = runCatching { service.windowsOnAllDisplays }.getOrNull() ?: return
|
||||
val displays = service.getSystemService(DisplayManager::class.java) ?: return
|
||||
val taken = mutableMapOf<Int, String?>()
|
||||
val shown = mutableMapOf<Int, Set<Int>>()
|
||||
val now = SystemClock.uptimeMillis()
|
||||
for (i in 0 until all.size()) {
|
||||
val id = all.keyAt(i)
|
||||
if (id == Display.DEFAULT_DISPLAY) continue
|
||||
val display = displays.getDisplay(id)?.takeIf(DualScreen::builtIn) ?: continue
|
||||
val windows = all.valueAt(i)
|
||||
val topPkg = windows.asSequence()
|
||||
.filter { !BottomScreenGuests.ignorable(it.type) && cover(it, display) >= BottomScreenGuests.MIN_COVER }
|
||||
.sortedByDescending { it.layer }
|
||||
.map { it to packageOf(it) }
|
||||
val ours = windows.filter { it.second == own }.map { it.first.id }.toSet()
|
||||
if (!ownWindows[id].orEmpty().containsAll(ours)) ownShownAt[id] = now
|
||||
shown[id] = ours
|
||||
val topPkg = windows.firstNotNullOfOrNull { it.second?.takeUnless { pkg -> pkg == own } }
|
||||
val arrival = seen[id]?.takeIf { BottomScreenGuests.current(it.at, ownShownAt[id]) }
|
||||
seen[id]?.takeIf { topPkg != null || arrival == null }?.let { arrivals.remove(id, it) }
|
||||
.firstNotNullOfOrNull { packageOf(it)?.takeUnless { pkg -> pkg == own } }
|
||||
val arrival = seen[id]
|
||||
if (topPkg != null && arrival != null) arrivals.remove(id, arrival)
|
||||
BottomScreenGuests.holder(topPkg, arrival?.pkg?.takeUnless { it in keyboards }, homes)?.let { taken[id] = it }
|
||||
}
|
||||
ownWindows = shown
|
||||
if (current !== this) return
|
||||
if (taken != holders) Log.i(TAG, "bottom screen holders: $taken")
|
||||
holders = taken
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import com.droiddeck.launcher.ui.focusGlide
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.FrameLayout
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.ui.DroidDeckTheme
|
||||
import com.droiddeck.launcher.ui.onSignal
|
||||
import com.droiddeck.launcher.ui.LocalPalette
|
||||
|
||||
class ControllerEditorActivity : ComponentActivity() {
|
||||
override fun attachBaseContext(newBase: android.content.Context) {
|
||||
super.attachBaseContext(newBase)
|
||||
com.droiddeck.launcher.core.AppLanguage.applyTo(this, newBase)
|
||||
}
|
||||
|
||||
private lateinit var controls: OnScreenControls
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this)
|
||||
immersive()
|
||||
val root = FrameLayout(this)
|
||||
root.setBackgroundColor(0xFF0B0F14.toInt())
|
||||
controls = OnScreenControls(this, null, editing = true)
|
||||
root.addView(controls, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
|
||||
val bar = ComposeView(this).apply {
|
||||
setContent {
|
||||
DroidDeckTheme {
|
||||
var snap by remember { mutableStateOf(ControllerPrefs.read(context).snap) }
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp),
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), RoundedCornerShape(20.dp))
|
||||
.padding(start = 5.dp, end = 5.dp, top = 8.dp, bottom = 5.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.ctrl_editor_drag), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
EditorButton(stringResource(R.string.ctrl_editor_snap), false, selected = snap) {
|
||||
snap = !snap
|
||||
ControllerPrefs.setSnap(context, snap)
|
||||
controls.setSnap(snap)
|
||||
}
|
||||
EditorButton(stringResource(R.string.ctrl_reset), false) { controls.resetLayout() }
|
||||
EditorButton(stringResource(R.string.common_cancel), false) { finish() }
|
||||
EditorButton(stringResource(R.string.ctrl_editor_save), true) {
|
||||
controls.saveLayout()
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
root.addView(bar, FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.CENTER))
|
||||
setContentView(root)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditorButton(label: String, primary: Boolean, selected: Boolean? = null, onClick: () -> Unit) {
|
||||
val pal = LocalPalette.current
|
||||
Text(
|
||||
label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold,
|
||||
color = when {
|
||||
primary -> pal.onSignal
|
||||
selected == true -> pal.signal
|
||||
else -> MaterialTheme.colorScheme.onBackground
|
||||
},
|
||||
modifier = Modifier.clip(RoundedCornerShape(50))
|
||||
.background(
|
||||
when {
|
||||
primary -> pal.signal
|
||||
selected == true -> pal.signal.copy(alpha = 0.18f)
|
||||
else -> Color.White.copy(alpha = 0.06f)
|
||||
},
|
||||
)
|
||||
.focusGlide(RoundedCornerShape(50))
|
||||
.then(
|
||||
if (selected == null) Modifier.clickable(onClick = onClick)
|
||||
else Modifier.toggleable(value = selected, role = Role.Switch, onValueChange = { onClick() }),
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
||||
super.onWindowFocusChanged(hasFocus)
|
||||
if (hasFocus) immersive()
|
||||
}
|
||||
|
||||
private fun immersive() {
|
||||
@Suppress("DEPRECATION")
|
||||
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
or View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION)
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
window.attributes = window.attributes.apply {
|
||||
layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ object ControllerPrefs {
|
||||
keyboardButton = p.getBoolean("keyboardButton", true),
|
||||
selectSteam = p.getBoolean("selectSteam", false),
|
||||
joyConVr = joyConVr(context),
|
||||
snap = snap(context),
|
||||
snap = p.getBoolean("snap", true),
|
||||
customLayout = p.all.keys.any { it.startsWith("layout.") },
|
||||
mapping = mappable.associate { (id, _) -> id to target(context, id) },
|
||||
deadZone = deadZone(context),
|
||||
@@ -87,6 +87,11 @@ object ControllerPrefs {
|
||||
prefs(context).edit().putString("map.$id", target).apply()
|
||||
}
|
||||
|
||||
fun resetMapping(context: Context) {
|
||||
val p = prefs(context)
|
||||
p.edit().apply { p.all.keys.filter { it.startsWith("map.") }.forEach { remove(it) } }.apply()
|
||||
}
|
||||
|
||||
fun setTint(context: Context, tint: Int) {
|
||||
prefs(context).edit().putInt("tint", tint).apply()
|
||||
}
|
||||
@@ -124,6 +129,10 @@ object ControllerPrefs {
|
||||
prefs(context).edit().putBoolean("steamButton", on).apply()
|
||||
}
|
||||
|
||||
fun setQamButton(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("qamButton", on).apply()
|
||||
}
|
||||
|
||||
fun setKeyboardButton(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("keyboardButton", on).apply()
|
||||
}
|
||||
@@ -138,8 +147,6 @@ object ControllerPrefs {
|
||||
prefs(context).edit().putBoolean("joyConVr", on).apply()
|
||||
}
|
||||
|
||||
fun snap(context: Context): Boolean = prefs(context).getBoolean("snap", true)
|
||||
|
||||
fun setSnap(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("snap", on).apply()
|
||||
}
|
||||
@@ -162,7 +169,62 @@ object ControllerPrefs {
|
||||
prefs(context).edit().remove("layout.${width}x$height").apply()
|
||||
}
|
||||
|
||||
fun screenKeysShown(context: Context): Boolean = prefs(context).getBoolean("screenKeysShown", true)
|
||||
|
||||
fun setScreenKeysShown(context: Context, shown: Boolean) {
|
||||
prefs(context).edit().putBoolean("screenKeysShown", shown).apply()
|
||||
}
|
||||
|
||||
fun screenKeys(context: Context): List<ScreenKey> = decodeScreenKeys(prefs(context).getString("screenKeys", null).orEmpty())
|
||||
|
||||
fun setScreenKeys(context: Context, keys: List<ScreenKey>) {
|
||||
prefs(context).edit().putString("screenKeys", encodeScreenKeys(keys)).apply()
|
||||
}
|
||||
|
||||
fun encodeScreenKeys(keys: List<ScreenKey>): String = keys.joinToString(";") {
|
||||
"${it.codes.joinToString("+")}:${it.x},${it.y},${it.size}:${escape(it.glyph)}:${escape(it.text)}:${escape(it.image)}:${it.opacity}:${it.shape}:${if (it.toggle) 1 else 0}:${if (it.labels) 1 else 0}"
|
||||
}
|
||||
|
||||
fun decodeScreenKeys(raw: String): List<ScreenKey> = raw.split(';').mapNotNull { entry ->
|
||||
val parts = entry.split(':')
|
||||
if (parts.size < 2) return@mapNotNull null
|
||||
val keys = parts[0].split('+').mapNotNull { it.toIntOrNull() }.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
|
||||
val nums = parts[1].split(',').mapNotNull { it.toFloatOrNull() }.takeIf { it.size == 3 } ?: return@mapNotNull null
|
||||
ScreenKey(
|
||||
keys, nums[0], nums[1], nums[2].toInt(),
|
||||
parts.getOrNull(2)?.let(::unescape).orEmpty(),
|
||||
parts.getOrNull(3)?.let(::unescape).orEmpty(),
|
||||
parts.getOrNull(4)?.let(::unescape).orEmpty(),
|
||||
parts.getOrNull(5)?.toFloatOrNull()?.coerceIn(0.2f, 1f) ?: 1f,
|
||||
parts.getOrNull(6)?.toIntOrNull()?.coerceIn(SCREEN_KEY_PILL, SCREEN_KEY_STICK) ?: SCREEN_KEY_PILL,
|
||||
parts.getOrNull(7) == "1",
|
||||
parts.getOrNull(8) != "0",
|
||||
)
|
||||
}
|
||||
|
||||
fun screenLayouts(context: Context): Map<String, String> =
|
||||
prefs(context).all.mapNotNull { (key, value) ->
|
||||
if (!key.startsWith("screenLayout.") || value !is String) null else key.removePrefix("screenLayout.") to value
|
||||
}.toMap()
|
||||
|
||||
fun saveScreenLayout(context: Context, name: String, raw: String) {
|
||||
prefs(context).edit().putString("screenLayout.$name", raw).apply()
|
||||
}
|
||||
|
||||
fun deleteScreenLayout(context: Context, name: String) {
|
||||
prefs(context).edit().remove("screenLayout.$name").apply()
|
||||
}
|
||||
|
||||
private fun escape(value: String) = value.replace("%", "%25").replace(";", "%3B").replace(":", "%3A").replace(",", "%2C")
|
||||
|
||||
private fun unescape(value: String) = value.replace("%2C", ",").replace("%3A", ":").replace("%3B", ";").replace("%25", "%")
|
||||
|
||||
fun resetAll(context: Context) {
|
||||
prefs(context).edit().clear().putBoolean("rumble", true).apply()
|
||||
}
|
||||
|
||||
fun resetAllLayouts(context: Context) {
|
||||
val p = prefs(context)
|
||||
p.edit().apply { p.all.keys.filter { it.startsWith("layout.") }.forEach { remove(it) } }.apply()
|
||||
}
|
||||
}
|
||||
@@ -5,165 +5,53 @@ object DeckControls {
|
||||
const val R4 = 2
|
||||
const val L5 = 4
|
||||
const val R5 = 8
|
||||
private val controls = arrayOf(InputControl.L4, InputControl.R4, InputControl.L5, InputControl.R5,
|
||||
InputControl.LEFT_PAD, InputControl.RIGHT_PAD, InputControl.LEFT_CLICK, InputControl.RIGHT_CLICK)
|
||||
private val sources = ArrayList<Source>()
|
||||
private val physical = Source(InputRole.PHYSICAL)
|
||||
private var router: InputRouter? = null
|
||||
private const val LEFT_TOUCH = 16
|
||||
private const val RIGHT_TOUCH = 32
|
||||
private const val LEFT_CLICK = 64
|
||||
private const val RIGHT_CLICK = 128
|
||||
|
||||
private const val SLOT = 0
|
||||
|
||||
private var controls = 0
|
||||
private val pads = ShortArray(4)
|
||||
private val pressure = ShortArray(2)
|
||||
private val touchOwners = arrayOfNulls<Source>(2)
|
||||
|
||||
@Synchronized
|
||||
fun setRouter(next: InputRouter?) {
|
||||
if (router === next) return
|
||||
for (source in sources) source.closeRoutes()
|
||||
router = next
|
||||
for (source in sources) source.configure()
|
||||
fun setGrip(bit: Int, down: Boolean) {
|
||||
set(bit, down)
|
||||
publish()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun clearRouter(owner: InputRouter?) {
|
||||
if (owner != null && router === owner) setRouter(null)
|
||||
fun setPad(right: Boolean, touching: Boolean, x: Float = 0f, y: Float = 0f) {
|
||||
val at = if (right) 2 else 0
|
||||
set(if (right) RIGHT_TOUCH else LEFT_TOUCH, touching)
|
||||
pads[at] = if (touching) axis(x) else 0
|
||||
pads[at + 1] = if (touching) axis(y) else 0
|
||||
publish()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@JvmOverloads
|
||||
fun source(role: InputRole, routed: Boolean = false) = Source(role, routed)
|
||||
|
||||
@Synchronized
|
||||
fun setGrip(bit: Int, down: Boolean) = physical.setGrip(bit, down)
|
||||
|
||||
@Synchronized
|
||||
fun setPad(right: Boolean, touching: Boolean, x: Float = 0f, y: Float = 0f) = physical.setPad(right, touching, x, y)
|
||||
|
||||
@Synchronized
|
||||
fun setClick(right: Boolean, down: Boolean) = physical.setClick(right, down)
|
||||
fun setClick(right: Boolean, down: Boolean) {
|
||||
set(if (right) RIGHT_CLICK else LEFT_CLICK, down)
|
||||
pressure[if (right) 1 else 0] = if (down) Short.MAX_VALUE else 0
|
||||
publish()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun releaseAll() {
|
||||
for (source in sources) source.clear()
|
||||
publish()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun suspend() {
|
||||
for (source in sources) source.suspend()
|
||||
publish()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun refresh() {
|
||||
for (source in sources) source.route()
|
||||
publish()
|
||||
}
|
||||
|
||||
class Source internal constructor(private val role: InputRole, private val routed: Boolean = false) {
|
||||
private val x = FloatArray(8)
|
||||
private val y = FloatArray(8)
|
||||
private val touched = BooleanArray(8)
|
||||
private var routes: Array<InputRouter.Channel>? = null
|
||||
internal var mask = 0
|
||||
internal val axes = ShortArray(4)
|
||||
|
||||
internal fun configure() {
|
||||
routes = router?.takeUnless { routed }?.let { r -> Array(8) { i ->
|
||||
r.channel(role, controls[i]).also { it.awaitNeutral(x[i], y[i], touched[i]) }
|
||||
} }
|
||||
mask = 0
|
||||
axes.fill(0)
|
||||
}
|
||||
|
||||
private fun attach() {
|
||||
if (this !in sources) { sources.add(this); configure() }
|
||||
}
|
||||
|
||||
fun setGrip(bit: Int, down: Boolean) = synchronized(DeckControls) {
|
||||
val at = when (bit) { L4 -> 0; R4 -> 1; L5 -> 2; R5 -> 3; else -> return@synchronized }
|
||||
update(at, if (down) 1f else 0f)
|
||||
}
|
||||
|
||||
fun setPad(right: Boolean, touching: Boolean, x: Float = 0f, y: Float = 0f) = synchronized(DeckControls) {
|
||||
update(if (right) 5 else 4, if (touching) x else 0f, if (touching) y else 0f, touching)
|
||||
}
|
||||
|
||||
fun setClick(right: Boolean, down: Boolean) = synchronized(DeckControls) {
|
||||
update(if (right) 7 else 6, if (down) 1f else 0f)
|
||||
}
|
||||
|
||||
private fun update(at: Int, value: Float, second: Float = 0f, contact: Boolean = false) {
|
||||
val timing = InputTiming.begin()
|
||||
attach()
|
||||
x[at] = value; y[at] = second; touched[at] = contact
|
||||
route()
|
||||
publish()
|
||||
InputTiming.end(timing)
|
||||
}
|
||||
|
||||
internal fun native(control: InputControl?, value: Float, second: Float, contact: Boolean) = synchronized(DeckControls) {
|
||||
require(routed)
|
||||
attach()
|
||||
x.fill(0f); y.fill(0f); touched.fill(false)
|
||||
val at = controls.indexOf(control)
|
||||
if (at >= 0) { x[at] = value; y[at] = second; touched[at] = contact }
|
||||
route(); publish()
|
||||
}
|
||||
|
||||
internal fun route() {
|
||||
mask = 0
|
||||
axes.fill(0)
|
||||
for (i in controls.indices) {
|
||||
val target = if (routes == null) controls[i] else routes!![i].update(x[i], y[i], touched[i]) ?: continue
|
||||
val at = controls.indexOf(target)
|
||||
if (at < 0) continue
|
||||
if (at == 4 || at == 5) {
|
||||
if (touched[i]) {
|
||||
mask = mask or (1 shl at)
|
||||
axes[(at - 4) * 2] = axis(x[i]); axes[(at - 4) * 2 + 1] = axis(y[i])
|
||||
}
|
||||
} else if (x[i] > 0.5f) mask = mask or (1 shl at)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun clear() {
|
||||
x.fill(0f); y.fill(0f); touched.fill(false); mask = 0; axes.fill(0)
|
||||
routes?.forEach { it.release() }
|
||||
}
|
||||
|
||||
internal fun suspend() {
|
||||
mask = 0; axes.fill(0)
|
||||
routes?.forEach { it.suspend() }
|
||||
}
|
||||
|
||||
fun release() = synchronized(DeckControls) { clear(); publish() }
|
||||
|
||||
internal fun closeRoutes() { routes?.forEach { it.close() }; routes = null }
|
||||
|
||||
fun close() = synchronized(DeckControls) {
|
||||
clear(); closeRoutes(); sources.remove(this); publish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun axis(value: Float) = (value.coerceIn(-1f, 1f) * Short.MAX_VALUE).toInt().toShort()
|
||||
|
||||
private fun publish() {
|
||||
var mask = 0
|
||||
if (controls == 0 && pads.all { it.toInt() == 0 } && pressure.all { it.toInt() == 0 }) return
|
||||
controls = 0
|
||||
pads.fill(0)
|
||||
for (source in sources) {
|
||||
mask = mask or source.mask
|
||||
}
|
||||
for (side in 0..1) {
|
||||
val contact = 16 shl side
|
||||
val owner = touchOwners[side]?.takeIf { it.mask and contact != 0 }
|
||||
?: sources.firstOrNull { it.mask and contact != 0 }
|
||||
touchOwners[side] = owner
|
||||
if (owner == null) continue
|
||||
pads[side * 2] = owner.axes[side * 2]
|
||||
pads[side * 2 + 1] = owner.axes[side * 2 + 1]
|
||||
}
|
||||
pressure[0] = if (mask and 64 != 0) Short.MAX_VALUE else 0
|
||||
pressure[1] = if (mask and 128 != 0) Short.MAX_VALUE else 0
|
||||
FakeInputWriter.writeDeckControls(0, mask, pads, pressure)
|
||||
pressure.fill(0)
|
||||
publish()
|
||||
}
|
||||
|
||||
private fun set(bit: Int, on: Boolean) {
|
||||
controls = if (on) controls or bit else controls and bit.inv()
|
||||
}
|
||||
|
||||
private fun axis(value: Float): Short = (value.coerceIn(-1f, 1f) * Short.MAX_VALUE).toInt().toShort()
|
||||
|
||||
private fun publish() = FakeInputWriter.writeDeckControls(SLOT, controls, pads, pressure)
|
||||
}
|
||||
@@ -70,14 +70,6 @@ class DirectTouchHold(
|
||||
handler.removeCallbacks(hold)
|
||||
}
|
||||
|
||||
fun cancelTouch() {
|
||||
handler.removeCallbacks(hold)
|
||||
handler.removeCallbacks(lift)
|
||||
liftPending = false
|
||||
if (forwarded) touch.cancel()
|
||||
forwarded = false
|
||||
}
|
||||
|
||||
fun onTouch(event: MotionEvent): Boolean {
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
|
||||
if (liftPending) {
|
||||
|
||||
@@ -16,7 +16,6 @@ object DualScreen {
|
||||
const val MODE_OFF = "off"
|
||||
val modes = listOf(MODE_AUTO, MODE_ON, MODE_OFF)
|
||||
private const val TYPE_INTERNAL = 1
|
||||
private const val TYPE_EXTERNAL = 2
|
||||
private val knownModels = setOf("retroid pocket duo", "ayn thor")
|
||||
private val touchedDisplays = mutableSetOf<Int>()
|
||||
private val takenListeners = java.util.concurrent.CopyOnWriteArraySet<() -> Unit>()
|
||||
@@ -86,22 +85,9 @@ object DualScreen {
|
||||
when (mode) {
|
||||
MODE_OFF -> null
|
||||
MODE_ON -> internalDisplay(displayManager, primaryDisplayId) ?: anyDisplay(displayManager, primaryDisplayId)
|
||||
else -> if (dockedToDisplay(displayManager, primaryDisplayId)) null else builtInDisplay(displayManager, primaryDisplayId)
|
||||
else -> builtInDisplay(displayManager, primaryDisplayId)
|
||||
}?.takeUnless { taken(it.displayId) }
|
||||
|
||||
fun leavesBottomOnDock(wasDocked: Boolean, docked: Boolean, mode: String, automaticOnBuiltIn: Boolean): Boolean =
|
||||
docked && !wasDocked && mode == MODE_AUTO && automaticOnBuiltIn
|
||||
|
||||
fun dockedToDisplay(displayManager: DisplayManager, primaryDisplayId: Int): Boolean {
|
||||
val glasses = com.droiddeck.launcher.glasses.Glasses.state.value
|
||||
val external = displayManager.getDisplays().filter {
|
||||
it.isValid && it.displayId != primaryDisplayId && it.state != Display.STATE_OFF && displayType(it) == TYPE_EXTERNAL
|
||||
}
|
||||
val glassesId = glasses.displayId.takeIf { it != Display.INVALID_DISPLAY }
|
||||
?: com.droiddeck.launcher.glasses.Glasses.selectGlassesDisplay(external, glasses.present)?.displayId
|
||||
return external.any { it.displayId != glassesId && com.droiddeck.launcher.glasses.Glasses.selectGlassesDisplay(listOf(it), false) == null }
|
||||
}
|
||||
|
||||
fun anyDisplay(displayManager: DisplayManager, primaryDisplayId: Int): Display? =
|
||||
displayManager.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION).firstOrNull {
|
||||
it.isValid && it.displayId != primaryDisplayId && (it.flags and Display.FLAG_PRESENTATION) != 0
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.droiddeck.launcher.input
|
||||
import android.view.MotionEvent
|
||||
|
||||
internal class GestureTouchStreamGate {
|
||||
enum class Source { SURFACE, ON_SCREEN_CONTROLS }
|
||||
enum class Source { SURFACE, SCREEN_KEYS, ON_SCREEN_CONTROLS }
|
||||
|
||||
private var owner: Source? = null
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
class HeldInput(private val emit: (Int, Boolean) -> Unit) {
|
||||
private val owners = IntArray(768)
|
||||
|
||||
@Synchronized
|
||||
fun set(source: Int, code: Int, down: Boolean) {
|
||||
if (code !in owners.indices) return
|
||||
val before = owners[code]
|
||||
val after = if (down) before or source else before and source.inv()
|
||||
owners[code] = after
|
||||
if ((before == 0) != (after == 0)) emit(code, after != 0)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun release(source: Int) {
|
||||
for (code in owners.indices) if (owners[code] and source != 0) set(source, code, false)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun clear() {
|
||||
for (code in owners.indices) if (owners[code] != 0) {
|
||||
owners[code] = 0
|
||||
emit(code, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
internal object InputButtonImages {
|
||||
const val DIR = "screenkeys"
|
||||
private const val MAX_SIZE_DP = 140
|
||||
|
||||
fun file(context: Context, name: String): File? =
|
||||
name.takeIf { it.matches(Regex("[A-Za-z0-9_.-]{1,128}")) && it != "." && it != ".." }?.let { File(File(context.filesDir, DIR), it) }
|
||||
|
||||
fun import(context: Context, uri: Uri): String? {
|
||||
val dir = File(context.filesDir, DIR).apply { mkdirs() }
|
||||
val name = "key_" + UUID.randomUUID() + ".png"
|
||||
val longest = (MAX_SIZE_DP * context.resources.displayMetrics.density).toInt()
|
||||
return runCatching {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) }
|
||||
val source = maxOf(bounds.outWidth, bounds.outHeight)
|
||||
if (source <= 0) return@runCatching null
|
||||
var sample = 1
|
||||
while (source / (sample * 2) >= longest) sample *= 2
|
||||
val bitmap = context.contentResolver.openInputStream(uri)?.use {
|
||||
BitmapFactory.decodeStream(it, null, BitmapFactory.Options().apply { inSampleSize = sample })
|
||||
} ?: return@runCatching null
|
||||
val scale = longest.toFloat() / maxOf(bitmap.width, bitmap.height)
|
||||
val scaled = if (scale < 1f) Bitmap.createScaledBitmap(bitmap, (bitmap.width * scale).toInt().coerceAtLeast(1),
|
||||
(bitmap.height * scale).toInt().coerceAtLeast(1), true) else bitmap
|
||||
try {
|
||||
val saved = File(dir, name).outputStream().use { scaled.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||
if (saved) name else null
|
||||
} finally {
|
||||
if (scaled !== bitmap) bitmap.recycle()
|
||||
scaled.recycle()
|
||||
}
|
||||
}.getOrNull().also { if (it == null) File(dir, name).delete() }
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
internal object InputLayoutFile {
|
||||
private val NAMES: Map<Int, String> = mapOf(
|
||||
103 to "KEY_UP", 106 to "KEY_RIGHT", 108 to "KEY_DOWN", 105 to "KEY_LEFT",
|
||||
28 to "KEY_ENTER", 1 to "KEY_ESC", 14 to "KEY_BKSP", 111 to "KEY_DEL", 15 to "KEY_TAB",
|
||||
57 to "KEY_SPACE", 29 to "KEY_CTRL_L", 97 to "KEY_CTRL_R", 110 to "KEY_INSERT",
|
||||
42 to "KEY_SHIFT_L", 54 to "KEY_SHIFT_R", 56 to "KEY_ALT_L", 100 to "KEY_ALT_R",
|
||||
102 to "KEY_HOME", 107 to "KEY_END", 99 to "KEY_PRTSCN", 104 to "KEY_PG_UP", 109 to "KEY_PG_DOWN",
|
||||
58 to "KEY_CAPS_LOCK", 69 to "KEY_NUM_LOCK", 41 to "KEY_GRAVE", 12 to "KEY_MINUS",
|
||||
11 to "KEY_0", 2 to "KEY_1", 3 to "KEY_2", 4 to "KEY_3", 5 to "KEY_4", 6 to "KEY_5",
|
||||
7 to "KEY_6", 8 to "KEY_7", 9 to "KEY_8", 10 to "KEY_9",
|
||||
30 to "KEY_A", 48 to "KEY_B", 46 to "KEY_C", 32 to "KEY_D", 18 to "KEY_E", 33 to "KEY_F",
|
||||
34 to "KEY_G", 35 to "KEY_H", 23 to "KEY_I", 36 to "KEY_J", 37 to "KEY_K", 38 to "KEY_L",
|
||||
50 to "KEY_M", 49 to "KEY_N", 24 to "KEY_O", 25 to "KEY_P", 16 to "KEY_Q", 19 to "KEY_R",
|
||||
31 to "KEY_S", 20 to "KEY_T", 22 to "KEY_U", 47 to "KEY_V", 17 to "KEY_W", 45 to "KEY_X",
|
||||
21 to "KEY_Y", 44 to "KEY_Z",
|
||||
26 to "KEY_BRACKET_LEFT", 27 to "KEY_BRACKET_RIGHT", 43 to "KEY_BACKSLASH", 53 to "KEY_SLASH",
|
||||
39 to "KEY_SEMICOLON", 51 to "KEY_COMMA", 52 to "KEY_PERIOD", 40 to "KEY_APOSTROPHE",
|
||||
59 to "KEY_F1", 60 to "KEY_F2", 61 to "KEY_F3", 62 to "KEY_F4", 63 to "KEY_F5", 64 to "KEY_F6",
|
||||
65 to "KEY_F7", 66 to "KEY_F8", 67 to "KEY_F9", 68 to "KEY_F10", 87 to "KEY_F11", 88 to "KEY_F12",
|
||||
79 to "KEY_KP_1", 80 to "KEY_KP_2", 81 to "KEY_KP_3", 75 to "KEY_KP_4", 76 to "KEY_KP_5",
|
||||
77 to "KEY_KP_6", 71 to "KEY_KP_7", 72 to "KEY_KP_8", 73 to "KEY_KP_9", 82 to "KEY_KP_0",
|
||||
)
|
||||
private val CODES: Map<String, Int> = NAMES.entries.associate { (code, name) -> name to code }
|
||||
|
||||
fun fileName(layout: SavedInputLayout) = layout.name.replace(Regex("[^A-Za-z0-9 _.-]"), "_").trim().ifEmpty { "droiddeck-layout" } + ".icp"
|
||||
|
||||
fun export(layout: SavedInputLayout, bindings: SavedInputBindings?, width: Int, height: Int): String? {
|
||||
val snap = width.coerceAtLeast(1) / 100f
|
||||
val elements = JSONArray()
|
||||
for (e in layout.elements) {
|
||||
val action = bindings?.action(e) ?: InputAction.Disabled
|
||||
val directional = e.kind == InputElementKind.DPAD || e.kind == InputElementKind.STICK
|
||||
val codes = when {
|
||||
directional && action is InputAction.Directions -> listOf(action.up, action.right, action.down, action.left)
|
||||
!directional && action is InputAction.Keys -> action.codes
|
||||
else -> continue
|
||||
}
|
||||
val binds = JSONArray()
|
||||
for (code in codes) {
|
||||
val name = NAMES[code]
|
||||
if (name != null) binds.put(name) else if (directional) binds.put("")
|
||||
}
|
||||
if ((0 until binds.length()).all { binds.getString(it).isEmpty() }) continue
|
||||
val shape = when {
|
||||
directional -> "ROUND_RECT"
|
||||
e.buttonShape == InputButtonShape.CIRCLE -> "CIRCLE"
|
||||
e.buttonShape == InputButtonShape.RECTANGLE -> "RECT"
|
||||
else -> "ROUND_RECT"
|
||||
}
|
||||
val sizePx = e.height * height
|
||||
val scale = when {
|
||||
directional -> sizePx * 2.3f / (14f * snap)
|
||||
shape == "CIRCLE" -> sizePx / (6f * snap)
|
||||
else -> sizePx / (4f * snap)
|
||||
}
|
||||
elements.put(JSONObject()
|
||||
.put("type", when (e.kind) { InputElementKind.DPAD -> "D_PAD"; InputElementKind.STICK -> "STICK"; else -> "BUTTON" })
|
||||
.put("shape", shape).put("customColor", 0).put("bindings", binds).put("scale", scale.toDouble())
|
||||
.put("x", (e.x + e.width / 2).toDouble()).put("y", (e.y + e.height / 2).toDouble())
|
||||
.put("toggleSwitch", e.toggle).put("swipeable", false)
|
||||
.put("text", if (e.icon != 0 || e.image.isNotEmpty()) "" else e.name).put("iconId", 0)
|
||||
.apply { if (e.opacity < 1f) put("opacity", e.opacity.toDouble()) })
|
||||
}
|
||||
if (elements.length() == 0) return null
|
||||
return JSONObject().put("id", 0).put("name", layout.name).put("cursorSpeed", 1.5).put("elements", elements).toString()
|
||||
}
|
||||
|
||||
fun parse(raw: String, width: Int, density: Float): List<LegacyScreenKey>? {
|
||||
val root = runCatching { JSONObject(raw) }.getOrNull() ?: return null
|
||||
val elements = root.optJSONArray("elements") ?: return null
|
||||
val snap = width.coerceAtLeast(1) / 100f
|
||||
val keys = mutableListOf<LegacyScreenKey>()
|
||||
for (i in 0 until elements.length()) {
|
||||
val element = elements.optJSONObject(i) ?: continue
|
||||
val type = element.optString("type", "BUTTON")
|
||||
if (type != "BUTTON" && type != "D_PAD" && type != "STICK") continue
|
||||
val binds = element.optJSONArray("bindings") ?: continue
|
||||
val raw = (0 until binds.length()).map { CODES[binds.optString(it)] ?: 0 }
|
||||
val directional = type != "BUTTON"
|
||||
val codes = when {
|
||||
!directional -> raw.filter { it != 0 }.distinct().take(8)
|
||||
type == "STICK" && raw.size <= 4 -> listOf(raw.getOrElse(0) { 0 }, 0, raw.getOrElse(1) { 0 }, 0, raw.getOrElse(2) { 0 }, 0, raw.getOrElse(3) { 0 }, 0)
|
||||
else -> raw.take(if (type == "STICK") 8 else 4)
|
||||
}
|
||||
if (codes.none { it != 0 }) continue
|
||||
val shape = when {
|
||||
type == "D_PAD" -> 5
|
||||
type == "STICK" -> 6
|
||||
else -> when (element.optString("shape")) { "CIRCLE" -> 2; "RECT" -> 3; "SQUARE" -> 1; else -> 0 }
|
||||
}
|
||||
val scale = element.optDouble("scale", 1.0).toFloat().takeIf { it.isFinite() && it > 0f } ?: 1f
|
||||
val sizePx = when {
|
||||
directional -> 14f * snap * scale / 2.3f
|
||||
shape == 2 -> 6f * snap * scale
|
||||
else -> 4f * snap * scale
|
||||
}
|
||||
fun coordinate(name: String) = element.optDouble(name, .5).toFloat().takeIf { it.isFinite() }?.coerceIn(0f, 1f) ?: .5f
|
||||
keys += LegacyScreenKey(codes, coordinate("x"), coordinate("y"), (sizePx / density).roundToInt().coerceIn(24, 140).toFloat(),
|
||||
text = element.optString("text").trim().take(24),
|
||||
opacity = element.optDouble("opacity", 1.0).toFloat().takeIf { it.isFinite() }?.coerceIn(.2f, 1f) ?: 1f,
|
||||
shape = shape, toggle = element.optBoolean("toggleSwitch"))
|
||||
if (keys.size == 64) break
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
fun import(context: Context, raw: String, fallbackName: String, screen: InputLayoutScreen, style: InputSurfaceStyle,
|
||||
width: Int, height: Int, density: Float, id: String = newInputId()): Pair<SavedInputLayout, SavedInputBindings>? {
|
||||
val keys = parse(raw, width, density)?.takeIf { it.isNotEmpty() } ?: return null
|
||||
val converted = runCatching { LegacyScreenKeyMigration.convert(context, keys, width, height, density) }.getOrNull() ?: return null
|
||||
val name = runCatching { JSONObject(raw).optString("name") }.getOrNull().orEmpty().trim().take(80).ifEmpty { fallbackName.trim().take(80) }
|
||||
val layout = SavedInputLayout(id, name, converted.map { it.first }, style = style, screen = screen,
|
||||
aspect = (width.coerceAtLeast(1).toFloat() / height.coerceAtLeast(1)).coerceIn(.05f, 20f))
|
||||
return layout to SavedInputBindings(newInputId(), name, false, id, elements = converted.associate { it.first.id to it.second })
|
||||
}
|
||||
}
|
||||
@@ -1,395 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.util.DisplayMetrics
|
||||
import android.view.Display
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.UUID
|
||||
|
||||
object InputLibraries {
|
||||
private const val VERSION = 14
|
||||
private const val KEY = "library"
|
||||
private const val RECOVERY = "library-recovery."
|
||||
const val OVERLAY = "builtin.overlay"
|
||||
const val DECK = "builtin.deck"
|
||||
const val TRACKPAD = "builtin.trackpad"
|
||||
|
||||
private fun prefs(context: Context) = context.getSharedPreferences("input-profiles", Context.MODE_PRIVATE)
|
||||
|
||||
@Synchronized
|
||||
fun read(context: Context): InputLibrary {
|
||||
val p = prefs(context)
|
||||
val raw = p.getString(KEY, null)
|
||||
val decoded = raw?.let(::decode)
|
||||
if (raw != null && decoded == null) return scopeLayouts(upgradeStockLayouts(upgradeOverlay(context, migrate(context))))
|
||||
var library = decoded ?: scopeLayouts(upgradeStockLayouts(upgradeOverlay(context, migrate(context))))
|
||||
val version = raw?.let { JSONObject(it).getInt("version") } ?: 0
|
||||
if (version < 5) library = scopeLayouts(upgradeStockLayouts(upgradeOverlay(context, library)))
|
||||
if (version < 11) library = neutralStockLayouts(library)
|
||||
if (version < 10) library = restyleGamepads(context, library)
|
||||
if (version < 12) library = barStockLayouts(library)
|
||||
if (version < 13) library = LegacyScreenKeyMigration.pin(library)
|
||||
if (version < 14) library = deckGamepadSurfaces(context, library)
|
||||
if (version < 6 || !p.contains(LegacyScreenKeyMigration.BACKUP)) {
|
||||
val legacy = LegacyScreenKeyMigration.snapshot(context)
|
||||
if (legacy != null) {
|
||||
val migrated = runCatching { LegacyScreenKeyMigration.migrate(context, library, legacy) }.getOrElse {
|
||||
android.util.Log.e("InputLibraries", "Could not migrate screen keys; keeping the original settings", it)
|
||||
return library
|
||||
}
|
||||
val merged = mergeScreenKeys(migrated, legacy)
|
||||
val upgraded = merged ?: migrated
|
||||
val encoded = encode(upgraded)
|
||||
require(encoded.length <= 2_000_000)
|
||||
val edit = p.edit().putString(KEY, encoded).putString(LegacyScreenKeyMigration.BACKUP, legacy)
|
||||
if (merged != null) edit.putBoolean(LegacyScreenKeyMigration.MERGED, true)
|
||||
if (!edit.commit()) return library
|
||||
LegacyScreenKeyMigration.clean(context)
|
||||
return upgraded
|
||||
}
|
||||
}
|
||||
val backup = p.getString(LegacyScreenKeyMigration.BACKUP, null)
|
||||
if (backup != null && !p.contains(LegacyScreenKeyMigration.MERGED)) {
|
||||
val merged = mergeScreenKeys(library, backup)
|
||||
if (merged != null) {
|
||||
val encoded = encode(merged)
|
||||
if (encoded.length <= 2_000_000 && p.edit().putString(KEY, encoded).putBoolean(LegacyScreenKeyMigration.MERGED, true).commit()) library = merged
|
||||
}
|
||||
}
|
||||
if (raw == null || version < VERSION) write(context, library)
|
||||
if (p.contains(LegacyScreenKeyMigration.BACKUP)) LegacyScreenKeyMigration.clean(context)
|
||||
return library
|
||||
}
|
||||
|
||||
private fun mergeScreenKeys(library: InputLibrary, legacy: String): InputLibrary? = runCatching { LegacyScreenKeyMigration.merge(library, legacy) }
|
||||
.onFailure { android.util.Log.e("InputLibraries", "Could not show the imported screen keys with the gamepad", it) }.getOrNull()
|
||||
|
||||
fun hasPendingScreenKeyMigration(context: Context) = !prefs(context).contains(LegacyScreenKeyMigration.BACKUP) && LegacyScreenKeyMigration.snapshot(context) != null
|
||||
|
||||
fun hasLibraryRecovery(context: Context) = prefs(context).all.keys.any { it.startsWith(RECOVERY) }
|
||||
|
||||
@Synchronized
|
||||
fun update(context: Context, change: (InputLibrary) -> InputLibrary): InputLibrary {
|
||||
val p = prefs(context)
|
||||
val stored = p.getString(KEY, null)
|
||||
val invalid = stored != null && decode(stored) == null
|
||||
require(!invalid || runCatching { JSONObject(stored!!).getInt("version") }.getOrNull()?.let { it <= VERSION } != false) {
|
||||
"The saved input library cannot be edited by this version."
|
||||
}
|
||||
val updated = change(read(context)).validate()
|
||||
if (invalid) {
|
||||
val encoded = encode(updated)
|
||||
require(encoded.length <= 2_000_000) { "The input library is too large to save." }
|
||||
check(p.edit().putString(RECOVERY + UUID.nameUUIDFromBytes(stored!!.toByteArray(Charsets.UTF_8)), stored)
|
||||
.putString(KEY, encoded).commit()) { "Could not save the input library and its recovery copy." }
|
||||
} else write(context, updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
private fun write(context: Context, library: InputLibrary) {
|
||||
val encoded = encode(library.validate())
|
||||
require(encoded.length <= 2_000_000) { "The input library is too large to save." }
|
||||
prefs(context).edit().putString(KEY, encoded).apply()
|
||||
}
|
||||
|
||||
private fun migrate(context: Context): InputLibrary {
|
||||
val profiles = mutableListOf<SavedInputProfile>()
|
||||
val stock = InputStockLayouts.layouts()
|
||||
val layouts = (listOf(SavedInputLayout(OVERLAY, "Built-in gamepad", template = InputLayoutTemplate.OVERLAY)) + stock).toMutableList()
|
||||
val bindings = InputStockLayouts.presets(stock).toMutableList()
|
||||
for (id in listOf(InputProfile.GAME, InputProfile.DESKTOP)) {
|
||||
val old = InputProfiles.readLegacy(context, id)
|
||||
val name = if (id == InputProfile.GAME) "Game" else "Desktop"
|
||||
val legacySurface = InputSurfaceLayout.hasLegacy(context, id) ||
|
||||
old.bindings[InputRole.SECONDARY] != InputProfile.defaults(id).bindings[InputRole.SECONDARY]
|
||||
val layout = if (legacySurface) SavedInputLayout("$id.layout", "$name touch", InputSurfaceLayout.readLegacy(context, id)).also { layouts += it }
|
||||
else stock.first { it.id == if (id == InputProfile.DESKTOP) TRACKPAD else DECK }
|
||||
val parts = InputRole.entries.associateWith { role ->
|
||||
if (role == InputRole.SECONDARY && !legacySurface) {
|
||||
val preset = bindings.first { it.layoutId == layout.id }
|
||||
return@associateWith InputPart(preset.id, layout.id)
|
||||
}
|
||||
val preset = SavedInputBindings("$id.${role.name.lowercase()}", "$name / ${role.name.lowercase()}",
|
||||
role == InputRole.PHYSICAL, if (role == InputRole.SECONDARY) layout.id else null,
|
||||
old.bindings[role].orEmpty(), keys = if (role == InputRole.PHYSICAL) old.keys else emptyList())
|
||||
bindings += preset
|
||||
InputPart(preset.id, when (role) { InputRole.PHYSICAL -> null; InputRole.PRIMARY -> OVERLAY; InputRole.SECONDARY -> layout.id })
|
||||
}
|
||||
profiles += SavedInputProfile(id, name, id, parts)
|
||||
}
|
||||
val selected = prefs(context).getString("selected", InputProfiles.AUTO)
|
||||
?.takeIf { it == InputProfiles.AUTO || profiles.any { p -> p.id == it } } ?: InputProfiles.AUTO
|
||||
return InputLibrary(profiles, layouts, bindings, selected).validate()
|
||||
}
|
||||
|
||||
fun setAdaptiveSticks(context: Context, layoutId: String, on: Boolean): Boolean = runCatching {
|
||||
update(context) { library ->
|
||||
library.copy(layouts = library.layouts.map { layout ->
|
||||
if (layout.id != layoutId || !layout.editable) layout
|
||||
else layout.copy(elements = layout.elements.map { if (it.kind == InputElementKind.STICK) it.copy(floating = on, hideWhenIdle = on) else it })
|
||||
})
|
||||
}
|
||||
}.onSuccess { ControllerPrefs.setAdaptiveSticks(context, on) }.isSuccess
|
||||
|
||||
fun setQuickButton(context: Context, layoutId: String, button: InputCommand, shown: Boolean): Boolean = runCatching {
|
||||
update(context) { library -> library.layouts.firstOrNull { it.id == layoutId && it.editable }?.let { library.withLayout(it.withQuickButton(button, shown)) } ?: library }
|
||||
}.isSuccess
|
||||
|
||||
fun defaultPositions(context: Context, library: InputLibrary, layout: SavedInputLayout): List<InputSurfaceElement>? {
|
||||
val origin = layout.origin ?: layout.id
|
||||
InputStockLayouts.layouts().firstOrNull { it.id == origin }?.let { return it.elements }
|
||||
if (origin == OVERLAY || origin.startsWith("migrated.layout.")) {
|
||||
val (surfaceContext, metrics) = primaryDisplay(context)
|
||||
val size = if (origin == OVERLAY) listOf(metrics.widthPixels, metrics.heightPixels)
|
||||
else origin.removePrefix("migrated.layout.").split('x').mapNotNull { it.toIntOrNull() }
|
||||
return size.takeIf { it.size == 2 && it.all { side -> side > 0 } }?.let { LegacyGamepadLayout(surfaceContext, it[0], it[1]).elements() }
|
||||
}
|
||||
return library.layouts.firstOrNull { it.id == origin && it.id != layout.id }?.elements
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
internal fun primaryDisplay(context: Context): Pair<Context, DisplayMetrics> {
|
||||
val display = context.getSystemService(DisplayManager::class.java)?.getDisplay(Display.DEFAULT_DISPLAY)
|
||||
val surfaceContext = display?.let(context::createDisplayContext) ?: context
|
||||
return surfaceContext to DisplayMetrics().also { if (display != null) display.getRealMetrics(it) else it.setTo(context.resources.displayMetrics) }
|
||||
}
|
||||
|
||||
private fun restyleGamepads(context: Context, library: InputLibrary): InputLibrary {
|
||||
if (library.layouts.none { it.id == OVERLAY || it.id.startsWith("migrated.layout.") }) return library
|
||||
val (surfaceContext, metrics) = primaryDisplay(context)
|
||||
return library.copy(layouts = library.layouts.map { layout ->
|
||||
val size = when {
|
||||
layout.id == OVERLAY -> listOf(metrics.widthPixels, metrics.heightPixels)
|
||||
layout.id.startsWith("migrated.layout.") -> layout.id.removePrefix("migrated.layout.").split('x').mapNotNull { it.toIntOrNull() }
|
||||
else -> emptyList()
|
||||
}
|
||||
if (size.size != 2 || size.any { it <= 0 }) layout
|
||||
else layout.copy(elements = LegacyGamepadLayout(surfaceContext, size[0], size[1]).restyle(layout.elements))
|
||||
})
|
||||
}
|
||||
|
||||
private fun upgradeOverlay(context: Context, library: InputLibrary): InputLibrary {
|
||||
if (library.layouts.none { it.template == InputLayoutTemplate.OVERLAY }) return library
|
||||
val (surfaceContext, metrics) = primaryDisplay(context)
|
||||
val settings = ControllerPrefs.read(context)
|
||||
val elements = LegacyGamepadLayout(surfaceContext, metrics.widthPixels.coerceAtLeast(1), metrics.heightPixels.coerceAtLeast(1)).elements()
|
||||
val style = InputSurfaceStyle(settings.tint, settings.opacity)
|
||||
val replacements = library.layouts.filter { it.template == InputLayoutTemplate.OVERLAY }.associate { old ->
|
||||
old.id to old.copy(name = "Gamepad", elements = elements, template = InputLayoutTemplate.CUSTOM, style = style,
|
||||
aspect = (metrics.widthPixels.coerceAtLeast(1).toFloat() / metrics.heightPixels.coerceAtLeast(1)).coerceIn(.05f, 20f))
|
||||
}
|
||||
var result = library.copy(layouts = library.layouts.map { replacements[it.id] ?: it })
|
||||
val migrated = mutableMapOf<Pair<String, String>, String>()
|
||||
for (profile in library.profiles) {
|
||||
val parts = profile.parts.mapValues { (role, part) ->
|
||||
val layout = replacements[part.layout] ?: return@mapValues part
|
||||
val key = part.bindings to layout.id
|
||||
val id = migrated.getOrPut(key) {
|
||||
val old = library.preset(part.bindings)
|
||||
val newId = migrationBindingId(result, old.id, layout.id)
|
||||
val keyboard = elements.filter { it.id == "builtin:keyboard" }.associate { it.id to InputAction.Command(InputCommand.KEYBOARD) }
|
||||
val clicks = if (settings.stickClick) old.clicks else old.clicks + elements.filter { it.hasClick }.associate { it.id to InputAction.Disabled }
|
||||
result = result.withPreset(old.copy(id = newId, layoutId = layout.id, elements = old.elements + keyboard, clicks = clicks))
|
||||
newId
|
||||
}
|
||||
part.copy(bindings = id)
|
||||
}
|
||||
result = result.withProfile(profile.copy(parts = parts))
|
||||
}
|
||||
val oldBindings = migrated.keys.map { it.first }.toSet()
|
||||
result = result.copy(bindings = result.bindings.filter { it.id !in oldBindings || result.usersOfBindings(it.id).isNotEmpty() })
|
||||
// Older editor layouts were resolution-specific. Keep each as a selectable saved layout.
|
||||
for (key in ControllerPrefs.prefs(context).all.keys.filter { it.startsWith("layout.") }.sorted()) {
|
||||
val size = key.removePrefix("layout.").split('x').mapNotNull { it.toIntOrNull() }
|
||||
if (size.size != 2 || size.any { it <= 0 } || result.layouts.size >= 128) continue
|
||||
if (size[0] == metrics.widthPixels && size[1] == metrics.heightPixels) continue
|
||||
val id = "migrated.$key"
|
||||
if (result.layouts.none { it.id == id }) result = result.withLayout(SavedInputLayout(id, "Gamepad ${size[0]} × ${size[1]}",
|
||||
LegacyGamepadLayout(surfaceContext, size[0], size[1]).elements(), style = style, aspect = (size[0].toFloat() / size[1]).coerceIn(.05f, 20f)))
|
||||
}
|
||||
return result.validate()
|
||||
}
|
||||
|
||||
private fun neutralStockLayouts(library: InputLibrary): InputLibrary = library.copy(layouts = library.layouts.map { layout ->
|
||||
if ((layout.id == DECK || layout.id == TRACKPAD) && layout.style.tint == ControllerPrefs.STEAM_BLUE) layout.copy(style = layout.style.copy(tint = null))
|
||||
else layout
|
||||
})
|
||||
|
||||
private fun upgradeStockLayouts(library: InputLibrary): InputLibrary {
|
||||
val stock = InputStockLayouts.layouts()
|
||||
val replacements = library.layouts.filter { it.template == InputLayoutTemplate.DECK || it.template == InputLayoutTemplate.TRACKPAD }
|
||||
.associate { old -> old.id to stock.first { it.id == if (old.template == InputLayoutTemplate.DECK) DECK else TRACKPAD }.copy(id = old.id, name = old.name) }
|
||||
var result = library.copy(layouts = library.layouts.map { replacements[it.id] ?: it })
|
||||
val migrated = mutableMapOf<Pair<String, String>, String>()
|
||||
for (profile in library.profiles) {
|
||||
val parts = profile.parts.mapValues { (_, part) ->
|
||||
val layout = replacements[part.layout] ?: return@mapValues part
|
||||
val id = migrated.getOrPut(part.bindings to layout.id) {
|
||||
val old = library.preset(part.bindings)
|
||||
val id = migrationBindingId(result, old.id, layout.id)
|
||||
val original = library.layouts.first { it.id == layout.id }
|
||||
val stockLayout = layout.copy(id = if (original.template == InputLayoutTemplate.TRACKPAD) TRACKPAD else DECK)
|
||||
result = result.withPreset(old.copy(id = id, layoutId = layout.id,
|
||||
elements = InputStockLayouts.actions(stockLayout) + old.elements, clicks = InputStockLayouts.clicks(stockLayout) + old.clicks))
|
||||
id
|
||||
}
|
||||
part.copy(bindings = id)
|
||||
}
|
||||
result = result.withProfile(profile.copy(parts = parts))
|
||||
}
|
||||
val oldBindings = migrated.keys.map { it.first }.toSet()
|
||||
result = result.copy(bindings = result.bindings.filter { it.id !in oldBindings || result.usersOfBindings(it.id).isNotEmpty() })
|
||||
return addStockLayouts(result)
|
||||
}
|
||||
|
||||
private fun addStockLayouts(library: InputLibrary): InputLibrary {
|
||||
val stock = InputStockLayouts.layouts()
|
||||
var result = library
|
||||
for (layout in stock) if (result.layouts.none { it.id == layout.id }) result = result.withLayout(layout)
|
||||
for (preset in InputStockLayouts.presets(stock)) if (result.bindings.none { it.id == preset.id }) result = result.withPreset(preset)
|
||||
return result.validate()
|
||||
}
|
||||
|
||||
private fun barStockLayouts(library: InputLibrary): InputLibrary {
|
||||
var result = library
|
||||
for (stock in InputStockLayouts.layouts().filter { it.id == DECK || it.id == TRACKPAD }) {
|
||||
val saved = result.layouts.firstOrNull { it.id == stock.id } ?: continue
|
||||
val (commands, rest) = saved.elements.partition { it.id.startsWith("command:") }
|
||||
val untouched = commands.isNotEmpty() && commands.all { it.control == InputControl.A && it.kind == InputElementKind.BUTTON } &&
|
||||
rest.size == stock.elements.size && rest.all { e -> stock.elements.any { s -> s.id == e.id && s.copy(y = e.y, height = e.height) == e &&
|
||||
kotlin.math.abs(s.y + s.height - e.y - e.height) < 1e-3f } }
|
||||
if (untouched) result = result.withLayout(saved.copy(elements = stock.elements))
|
||||
}
|
||||
return addStockLayouts(result)
|
||||
}
|
||||
|
||||
private fun deckGamepadSurfaces(context: Context, library: InputLibrary): InputLibrary {
|
||||
val leftPad = SessionPrefs.gamepadSurface(context, right = false) == SessionPrefs.SURFACE_TRACKPAD
|
||||
val rightStick = SessionPrefs.gamepadSurface(context, right = true) == SessionPrefs.SURFACE_STICK
|
||||
if (!leftPad && !rightStick) return library
|
||||
val saved = library.layouts.firstOrNull { it.id == DeckPanel.GAMEPAD.layout } ?: return library
|
||||
if (saved.elements != InputStockLayouts.gamepad(leftPad = false, rightStick = false)) return library
|
||||
return library.withLayout(saved.copy(elements = InputStockLayouts.gamepad(leftPad, rightStick))).validate()
|
||||
}
|
||||
|
||||
private fun migrationBindingId(library: InputLibrary, binding: String, layout: String): String {
|
||||
val seed = JSONArray(listOf(binding, layout)).toString().toByteArray(Charsets.UTF_8)
|
||||
val base = "migrated.bindings." + UUID.nameUUIDFromBytes(seed)
|
||||
var id = base
|
||||
var suffix = 0
|
||||
while (library.bindings.any { it.id == id }) id = "$base.${++suffix}"
|
||||
return id
|
||||
}
|
||||
|
||||
private fun scopeLayouts(library: InputLibrary): InputLibrary = library.copy(layouts = library.layouts.map { layout ->
|
||||
val roles = library.profiles.flatMap { p -> p.parts.filterValues { it.layout == layout.id }.keys } +
|
||||
library.selections.values.flatMap { s -> s.parts.filterValues { library.preset(it).layoutId == layout.id }.keys }
|
||||
val screen = when {
|
||||
InputRole.PRIMARY in roles && InputRole.SECONDARY in roles -> InputLayoutScreen.BOTH
|
||||
InputRole.PRIMARY in roles -> InputLayoutScreen.MAIN
|
||||
InputRole.SECONDARY in roles -> InputLayoutScreen.SECONDARY
|
||||
layout.id == OVERLAY || layout.id.startsWith("migrated.layout.") -> InputLayoutScreen.MAIN
|
||||
else -> InputLayoutScreen.SECONDARY
|
||||
}
|
||||
layout.copy(screen = screen)
|
||||
})
|
||||
|
||||
internal fun encode(library: InputLibrary): String {
|
||||
val layouts = JSONArray()
|
||||
for (layout in library.layouts) {
|
||||
val elements = JSONArray()
|
||||
for (e in layout.elements) elements.put(JSONObject().put("id", e.id).put("kind", e.kind.name)
|
||||
.put("control", e.control.id).put("name", e.name).put("x", e.x.toDouble()).put("y", e.y.toDouble())
|
||||
.put("width", e.width.toDouble()).put("height", e.height.toDouble()).put("floating", e.floating).put("hideWhenIdle", e.hideWhenIdle).put("mouseGestures", e.mouseGestures).put("toggle", e.toggle).put("icon", e.icon).put("image", e.image)
|
||||
.put("elementOpacity", e.opacity.toDouble()).put("buttonShape", e.buttonShape.name).put("showLabel", e.showLabel).put("alwaysShown", e.alwaysShown))
|
||||
layouts.put(JSONObject().put("id", layout.id).put("name", layout.name).put("template", layout.template.name).put("elements", elements)
|
||||
.put("tint", layout.style.tint ?: JSONObject.NULL).put("opacity", layout.style.opacity).put("screen", layout.screen.name).put("aspect", layout.aspect.toDouble()).put("scale", layout.scale.toDouble())
|
||||
.put("hidden", JSONArray(layout.style.hidden.map { it.name }.sorted())).put("origin", layout.origin ?: JSONObject.NULL))
|
||||
}
|
||||
val bindings = JSONArray()
|
||||
for (b in library.bindings) {
|
||||
val controls = JSONObject(); b.controls.forEach { (c, a) -> controls.put(c.id, InputProfiles.encode(a)) }
|
||||
val elements = JSONObject(); b.elements.forEach { (id, a) -> elements.put(id, InputProfiles.encode(a)) }
|
||||
val clicks = JSONObject(); b.clicks.forEach { (id, a) -> clicks.put(id, InputProfiles.encode(a)) }
|
||||
val keys = JSONArray()
|
||||
b.keys.forEach { binding -> keys.put(JSONObject().put("device", binding.key.device).put("name", binding.key.name)
|
||||
.put("keyCode", binding.key.keyCode).put("scanCode", binding.key.scanCode).put("action", InputProfiles.encode(binding.action))) }
|
||||
bindings.put(JSONObject().put("id", b.id).put("name", b.name).put("physical", b.physical)
|
||||
.put("layout", b.layoutId).put("controls", controls).put("elements", elements).put("keys", keys).put("clicks", clicks))
|
||||
}
|
||||
val profiles = JSONArray()
|
||||
for (p in library.profiles) {
|
||||
val parts = JSONObject()
|
||||
p.parts.forEach { (role, part) -> parts.put(role.name, JSONObject().put("bindings", part.bindings).put("layout", part.layout)) }
|
||||
profiles.put(JSONObject().put("id", p.id).put("name", p.name).put("base", p.base).put("parts", parts))
|
||||
}
|
||||
val selections = JSONObject()
|
||||
for ((mode, selection) in library.selections) {
|
||||
val parts = JSONObject()
|
||||
selection.parts.forEach { (role, id) -> parts.put(role.name, id) }
|
||||
selections.put(mode, JSONObject().put("profile", selection.profile).put("parts", parts).put("profileSelected", selection.profileSelected))
|
||||
}
|
||||
return JSONObject().put("selections", selections).put("version", VERSION).put("selected", library.selected).put("steam", library.steam).put("desktop", library.desktop)
|
||||
.put("profiles", profiles).put("layouts", layouts).put("bindings", bindings).toString()
|
||||
}
|
||||
|
||||
internal fun decode(raw: String): InputLibrary? = runCatching {
|
||||
require(raw.length <= 2_000_000)
|
||||
val doc = JSONObject(raw); require(doc.getInt("version") in 2..VERSION)
|
||||
val layouts = doc.getJSONArray("layouts").objects(256).map { layout ->
|
||||
val elements = layout.getJSONArray("elements").objects(64).map { e ->
|
||||
InputSurfaceElement(requireNotNull(InputControl.fromId(e.getString("control"))), e.getDouble("x").toFloat(),
|
||||
e.getDouble("y").toFloat(), e.getDouble("width").toFloat(), e.getDouble("height").toFloat(),
|
||||
e.getString("id"), InputElementKind.valueOf(e.getString("kind")), e.getString("name"),
|
||||
e.optBoolean("floating", true), e.optBoolean("hideWhenIdle", false), e.optBoolean("mouseGestures", false), e.optBoolean("toggle", false), e.optInt("icon", 0), e.optString("image", ""),
|
||||
e.optDouble("elementOpacity", 1.0).toFloat(), InputButtonShape.valueOf(e.optString("buttonShape", InputButtonShape.ROUNDED.name)), e.optBoolean("showLabel", true), e.optBoolean("alwaysShown", false))
|
||||
}
|
||||
SavedInputLayout(layout.getString("id"), layout.getString("name"), elements, InputLayoutTemplate.valueOf(layout.getString("template")),
|
||||
InputSurfaceStyle(if (!layout.has("tint")) ControllerPrefs.STEAM_BLUE else if (layout.isNull("tint")) null else layout.getInt("tint"), layout.optInt("opacity", 100),
|
||||
layout.optJSONArray("hidden")?.let { hidden -> (0 until hidden.length()).map { InputCommand.valueOf(hidden.getString(it)) }.toSet() }.orEmpty()),
|
||||
InputLayoutScreen.valueOf(layout.optString("screen", InputLayoutScreen.BOTH.name)), layout.optDouble("aspect", 0.0).toFloat(), layout.optionalString("origin"),
|
||||
layout.optDouble("scale", 1.0).toFloat())
|
||||
}
|
||||
val bindings = doc.getJSONArray("bindings").objects(512).map { b ->
|
||||
val controls = b.getJSONObject("controls").actions(28).mapKeys { requireNotNull(InputControl.fromId(it.key)) }
|
||||
val keys = b.getJSONArray("keys").objects(64).map { k -> PhysicalInputBinding(
|
||||
PhysicalInputKey(k.getString("device"), k.getString("name"), k.getInt("keyCode"), k.getInt("scanCode")),
|
||||
requireNotNull(InputProfiles.decode(k.getJSONObject("action")))) }
|
||||
SavedInputBindings(b.getString("id"), b.getString("name"), b.getBoolean("physical"), b.optionalString("layout"),
|
||||
controls, b.getJSONObject("elements").actions(64), keys, b.optJSONObject("clicks")?.actions(64).orEmpty())
|
||||
}
|
||||
val profiles = doc.getJSONArray("profiles").objects(64).map { p ->
|
||||
val parts = p.getJSONObject("parts")
|
||||
SavedInputProfile(p.getString("id"), p.getString("name"), p.getString("base"), InputRole.entries.associateWith { role ->
|
||||
val part = parts.getJSONObject(role.name); InputPart(part.getString("bindings"), part.optionalString("layout"))
|
||||
})
|
||||
}
|
||||
val selections = doc.optJSONObject("selections")?.let { saved ->
|
||||
require(saved.length() <= 2)
|
||||
saved.keys().asSequence().associateWith { mode ->
|
||||
val value = saved.getJSONObject(mode)
|
||||
val parts = value.getJSONObject("parts")
|
||||
require(parts.length() <= InputRole.entries.size)
|
||||
val profile = value.getString("profile")
|
||||
InputSelection(profile, parts.keys().asSequence().associate { role -> InputRole.valueOf(role) to parts.getString(role) },
|
||||
value.optBoolean("profileSelected", profile != InputProfiles.AUTO || parts.length() == 0))
|
||||
}
|
||||
}.orEmpty()
|
||||
InputLibrary(profiles, layouts, bindings, doc.getString("selected"), doc.getString("steam"), doc.getString("desktop"), selections).validate()
|
||||
}.getOrNull()
|
||||
|
||||
private fun JSONArray.objects(limit: Int): List<JSONObject> {
|
||||
require(length() <= limit)
|
||||
return (0 until length()).map { getJSONObject(it) }
|
||||
}
|
||||
|
||||
private fun JSONObject.actions(limit: Int): Map<String, InputAction> {
|
||||
require(length() <= limit)
|
||||
return keys().asSequence().associateWith { requireNotNull(InputProfiles.decode(getJSONObject(it))) }
|
||||
}
|
||||
|
||||
private fun JSONObject.optionalString(key: String): String? = if (isNull(key)) null else getString(key)
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
enum class InputLayoutTemplate { CUSTOM, OVERLAY, DECK, TRACKPAD }
|
||||
|
||||
enum class InputLayoutScreen { MAIN, SECONDARY, BOTH;
|
||||
fun supports(role: InputRole) = role != InputRole.PHYSICAL && (this == BOTH ||
|
||||
if (role == InputRole.PRIMARY) this == MAIN else this == SECONDARY)
|
||||
}
|
||||
|
||||
data class InputSurfaceStyle(val tint: Int? = ControllerPrefs.STEAM_BLUE, val opacity: Int = 100, val hidden: Set<InputCommand> = emptySet()) {
|
||||
fun hides(element: InputSurfaceElement) = element.quickButton?.let { it in hidden } == true
|
||||
fun shown(elements: List<InputSurfaceElement>) = if (hidden.isEmpty()) elements else elements.filterNot(::hides)
|
||||
}
|
||||
|
||||
data class SavedInputLayout(
|
||||
val id: String, val name: String, val elements: List<InputSurfaceElement> = emptyList(),
|
||||
val template: InputLayoutTemplate = InputLayoutTemplate.CUSTOM,
|
||||
val style: InputSurfaceStyle = InputSurfaceStyle(),
|
||||
val screen: InputLayoutScreen = InputLayoutScreen.BOTH,
|
||||
val aspect: Float = 0f,
|
||||
val origin: String? = null,
|
||||
val scale: Float = 1f,
|
||||
) {
|
||||
val editable get() = template == InputLayoutTemplate.CUSTOM
|
||||
val quickButtons get() = QUICK_BUTTONS.filter { button -> elements.any { it.quickButton == button } }
|
||||
fun withQuickButton(button: InputCommand, shown: Boolean) =
|
||||
copy(style = style.copy(hidden = if (shown) style.hidden - button else style.hidden + button))
|
||||
fun derived(id: String, name: String) = copy(id = id, name = name, origin = origin ?: this.id)
|
||||
val adaptiveSticks get() = elements.filter { it.kind == InputElementKind.STICK }.takeIf { editable && it.isNotEmpty() }?.all { it.floating && it.hideWhenIdle }
|
||||
fun supports(role: InputRole) = screen.supports(role) && when (template) {
|
||||
InputLayoutTemplate.OVERLAY -> role == InputRole.PRIMARY
|
||||
InputLayoutTemplate.DECK, InputLayoutTemplate.TRACKPAD -> role == InputRole.SECONDARY
|
||||
InputLayoutTemplate.CUSTOM -> true
|
||||
}
|
||||
}
|
||||
|
||||
data class SavedInputBindings(
|
||||
val id: String, val name: String, val physical: Boolean,
|
||||
val layoutId: String? = null,
|
||||
val controls: Map<InputControl, InputAction> = emptyMap(),
|
||||
val elements: Map<String, InputAction> = emptyMap(),
|
||||
val keys: List<PhysicalInputBinding> = emptyList(),
|
||||
val clicks: Map<String, InputAction> = emptyMap(),
|
||||
) {
|
||||
fun compatible(layout: SavedInputLayout?) = if (physical) layout == null
|
||||
else layout != null && if (layout.editable) layoutId == layout.id else layoutId == null
|
||||
|
||||
fun action(element: InputSurfaceElement) = elements[element.id]
|
||||
?: if (element.id == "legacy:${element.control.id}") controls[element.control] ?: InputAction.Native()
|
||||
else InputAction.Disabled
|
||||
|
||||
fun clickAction(element: InputSurfaceElement) = clicks[element.id]
|
||||
?: defaultElementClick(element, action(element)) { controls[it] ?: InputAction.Native() }
|
||||
}
|
||||
|
||||
data class InputSelection(
|
||||
val profile: String = InputProfiles.AUTO, val parts: Map<InputRole, String> = emptyMap(), val profileSelected: Boolean = false,
|
||||
)
|
||||
|
||||
data class InputPart(val bindings: String, val layout: String? = null)
|
||||
|
||||
data class SavedInputProfile(val id: String, val name: String, val base: String, val parts: Map<InputRole, InputPart>) {
|
||||
fun binding(role: InputRole) = parts.getValue(role).bindings
|
||||
}
|
||||
|
||||
data class InputLibrary(
|
||||
val profiles: List<SavedInputProfile>, val layouts: List<SavedInputLayout>, val bindings: List<SavedInputBindings>,
|
||||
val selected: String = InputProfiles.AUTO,
|
||||
val steam: String = InputProfile.GAME, val desktop: String = InputProfile.DESKTOP,
|
||||
val selections: Map<String, InputSelection> = emptyMap(),
|
||||
) {
|
||||
fun profile(id: String) = profiles.first { it.id == id }
|
||||
fun preset(id: String) = bindings.first { it.id == id }
|
||||
fun layout(profile: String, role: InputRole) = profiles.firstOrNull { it.id == profile }?.parts?.get(role)?.layout
|
||||
?.let { id -> layouts.firstOrNull { it.id == id } }
|
||||
private fun mode(isDesktop: Boolean) = if (isDesktop) InputProfile.DESKTOP else InputProfile.GAME
|
||||
fun selection(isDesktop: Boolean) = selections[mode(isDesktop)] ?: InputSelection(selected)
|
||||
fun activeId(isDesktop: Boolean): String {
|
||||
val id = selection(isDesktop).profile
|
||||
return if (id == InputProfiles.AUTO) { if (isDesktop) desktop else steam } else id
|
||||
}
|
||||
fun selectProfile(isDesktop: Boolean, id: String) = copy(selections = selections + (mode(isDesktop) to InputSelection(id, profileSelected = true)))
|
||||
fun selectPart(isDesktop: Boolean, role: InputRole, bindingsId: String): InputLibrary {
|
||||
val previous = selection(isDesktop)
|
||||
return copy(selections = selections + (mode(isDesktop) to previous.copy(parts = previous.parts + (role to bindingsId))))
|
||||
}
|
||||
fun activePart(isDesktop: Boolean, role: InputRole): InputPart {
|
||||
val binding = selection(isDesktop).parts[role]?.let(::preset)
|
||||
return if (binding != null) InputPart(binding.id, binding.layoutId) else profile(activeId(isDesktop)).parts.getValue(role)
|
||||
}
|
||||
fun activeLayout(isDesktop: Boolean, role: InputRole) = activePart(isDesktop, role).layout?.let { id -> layouts.first { it.id == id } }
|
||||
fun panelPart(isDesktop: Boolean, panel: String?): InputPart =
|
||||
panel?.let { id -> bindings.firstOrNull { it.id == id } }?.let { InputPart(it.id, it.layoutId) } ?: activePart(isDesktop, InputRole.SECONDARY)
|
||||
fun panelLayout(isDesktop: Boolean, panel: String?) = panelPart(isDesktop, panel).layout?.let { id -> layouts.firstOrNull { it.id == id } }
|
||||
fun compileActive(isDesktop: Boolean, panel: String? = null): InputProfile = compileParts(activeId(isDesktop), InputRole.entries.associateWith {
|
||||
preset(if (it == InputRole.SECONDARY) panelPart(isDesktop, panel).bindings else activePart(isDesktop, it).bindings)
|
||||
})
|
||||
|
||||
fun saveSelection(isDesktop: Boolean, name: String, id: String = newInputId()): InputLibrary {
|
||||
val current = profile(activeId(isDesktop))
|
||||
var result = withProfile(SavedInputProfile(id, inputName(name), current.base, InputRole.entries.associateWith { activePart(isDesktop, it) }))
|
||||
for (role in InputRole.entries) result = result.usePart(id, role, id, shared = false)
|
||||
return result.selectProfile(isDesktop, id)
|
||||
}
|
||||
|
||||
|
||||
fun compile(id: String): InputProfile {
|
||||
val profile = profile(id)
|
||||
val parts = InputRole.entries.associateWith { preset(profile.binding(it)) }
|
||||
return compileParts(id, parts)
|
||||
}
|
||||
|
||||
private fun compileParts(id: String, parts: Map<InputRole, SavedInputBindings>): InputProfile {
|
||||
return InputProfile(id, parts.mapValues { it.value.controls }.filterValues { it.isNotEmpty() },
|
||||
parts.getValue(InputRole.PHYSICAL).keys,
|
||||
parts.mapValues { it.value.elements }.filterValues { it.isNotEmpty() },
|
||||
parts.mapValues { it.value.clicks }.filterValues { it.isNotEmpty() })
|
||||
}
|
||||
|
||||
fun usersOfBindings(id: String) = profiles.filter { p -> p.parts.values.any { it.bindings == id } }
|
||||
fun selectedBindings(id: String) = selections.values.any { id in it.parts.values }
|
||||
fun usersOfLayout(id: String) = profiles.filter { p -> p.parts.values.any { it.layout == id } }
|
||||
fun withProfile(value: SavedInputProfile) = copy(profiles = profiles.filterNot { it.id == value.id } + value)
|
||||
fun withPreset(value: SavedInputBindings) = copy(bindings = bindings.filterNot { it.id == value.id } + value)
|
||||
fun withLayout(value: SavedInputLayout) = copy(layouts = layouts.filterNot { it.id == value.id } + value,
|
||||
bindings = bindings.map { preset -> if (preset.layoutId != value.id) preset else preset.copy(
|
||||
elements = preset.elements.filter { (id, action) -> value.elements.any { it.id == id && action.validFor(it) } },
|
||||
clicks = preset.clicks.filter { (id, action) -> value.elements.any { it.id == id && it.hasClick && action.validForClick() } }) })
|
||||
|
||||
fun duplicateProfile(id: String, name: String, newId: String = newInputId(), sharePhysical: Boolean = false): InputLibrary {
|
||||
require(profiles.size < 64)
|
||||
var result = withProfile(profile(id).copy(id = newId, name = inputName(name)))
|
||||
for (role in InputRole.entries) result = result.usePart(newId, role, id, shared = sharePhysical && role == InputRole.PHYSICAL)
|
||||
return result
|
||||
}
|
||||
|
||||
fun usePart(profileId: String, role: InputRole, sourceId: String, shared: Boolean): InputLibrary {
|
||||
val target = profile(profileId)
|
||||
val source = profile(sourceId).parts.getValue(role)
|
||||
if (shared) return withProfile(target.copy(parts = target.parts + (role to source)))
|
||||
val sourceLayout = layout(sourceId, role)
|
||||
val name = (target.name + " / " + role.name.lowercase()).take(80)
|
||||
val copiedLayout = sourceLayout?.takeIf { it.editable }?.derived(newInputId(), name)
|
||||
val copiedBindings = preset(source.bindings).copy(id = newInputId(), name = name,
|
||||
layoutId = copiedLayout?.id ?: preset(source.bindings).layoutId)
|
||||
return (if (copiedLayout == null) this else withLayout(copiedLayout)).withPreset(copiedBindings)
|
||||
.withProfile(target.copy(parts = target.parts + (role to InputPart(copiedBindings.id, copiedLayout?.id ?: source.layout))))
|
||||
}
|
||||
|
||||
fun useLayout(profileId: String, role: InputRole, layoutId: String, shared: Boolean): InputLibrary {
|
||||
val source = layouts.first { it.id == layoutId }
|
||||
require(source.supports(role))
|
||||
val target = profile(profileId)
|
||||
val layout = if (shared || !source.editable) source else source.derived(newInputId(), (target.name + " / " + source.name).take(80))
|
||||
val previous = preset(target.binding(role))
|
||||
val actions = if (previous.compatible(source)) previous else bindings.firstOrNull { it.compatible(source) } ?: SavedInputBindings(newInputId(), target.name, false,
|
||||
controls = if (source.editable) emptyMap() else InputProfile.defaults(target.base).bindings[role].orEmpty(),
|
||||
elements = if (source.editable) source.elements.associate { it.id to InputAction.Disabled } else emptyMap())
|
||||
val copied = actions.copy(id = newInputId(), name = (target.name + " / " + role.name.lowercase()).take(80),
|
||||
layoutId = layout.id.takeIf { layout.editable })
|
||||
return withLayout(layout).withPreset(copied)
|
||||
.withProfile(target.copy(parts = target.parts + (role to InputPart(copied.id, layout.id))))
|
||||
}
|
||||
|
||||
fun copyBindings(id: String, name: String, profileId: String? = null, role: InputRole? = null, newId: String = newInputId()): InputLibrary {
|
||||
require(bindings.size < 512)
|
||||
var result = withPreset(preset(id).copy(id = newId, name = inputName(name)))
|
||||
if (profileId != null && role != null) result = result.chooseBindings(profileId, role, newId)
|
||||
return result
|
||||
}
|
||||
|
||||
fun chooseBindings(profileId: String, role: InputRole, id: String): InputLibrary {
|
||||
val profile = profile(profileId)
|
||||
require(preset(id).compatible(layout(profileId, role)))
|
||||
return withProfile(profile.copy(parts = profile.parts + (role to profile.parts.getValue(role).copy(bindings = id))))
|
||||
}
|
||||
|
||||
fun chooseLayout(profileId: String, role: InputRole, id: String): InputLibrary {
|
||||
val layout = layouts.first { it.id == id }
|
||||
require(layout.supports(role))
|
||||
val profile = profile(profileId)
|
||||
val previous = preset(profile.binding(role))
|
||||
val compatible = bindings.firstOrNull { it.id == previous.id && it.compatible(layout) }
|
||||
?: bindings.firstOrNull { it.compatible(layout) }
|
||||
?: SavedInputBindings(newInputId(), layout.name, false, if (layout.editable) layout.id else null)
|
||||
return withPreset(compatible).withProfile(profile.copy(parts = profile.parts + (role to InputPart(compatible.id, id))))
|
||||
}
|
||||
|
||||
fun copyLayout(id: String, name: String, newId: String = newInputId()): InputLibrary {
|
||||
val source = layouts.first { it.id == id }
|
||||
require(source.editable)
|
||||
return withLayout(source.derived(newId, inputName(name)))
|
||||
}
|
||||
|
||||
fun removeProfile(id: String): InputLibrary {
|
||||
require(id != InputProfile.GAME && id != InputProfile.DESKTOP)
|
||||
return copy(profiles = profiles.filterNot { it.id == id }, selected = selected.takeUnless { it == id } ?: InputProfiles.AUTO,
|
||||
steam = steam.takeUnless { it == id } ?: InputProfile.GAME, desktop = desktop.takeUnless { it == id } ?: InputProfile.DESKTOP,
|
||||
selections = selections.mapValues { (_, selection) -> if (selection.profile == id) selection.copy(profile = InputProfiles.AUTO) else selection })
|
||||
}
|
||||
|
||||
fun removeLayout(id: String): InputLibrary {
|
||||
require(usersOfLayout(id).isEmpty() && bindings.none { it.layoutId == id && selectedBindings(it.id) } && layouts.first { it.id == id }.editable)
|
||||
return copy(layouts = layouts.filterNot { it.id == id }, bindings = bindings.filterNot { it.layoutId == id })
|
||||
}
|
||||
|
||||
fun removeBindings(id: String): InputLibrary {
|
||||
require(usersOfBindings(id).isEmpty() && !selectedBindings(id))
|
||||
return copy(bindings = bindings.filterNot { it.id == id })
|
||||
}
|
||||
|
||||
fun resetProfile(id: String): InputLibrary {
|
||||
val p = profile(id)
|
||||
var result = this
|
||||
val defaults = InputProfile.defaults(p.base)
|
||||
val parts = p.parts.mapValues { (role, part) ->
|
||||
val previous = preset(part.bindings)
|
||||
val isPrivate = usersOfBindings(part.bindings).all { it.id == id }
|
||||
val preset = SavedInputBindings(if (isPrivate) previous.id else newInputId(),
|
||||
if (isPrivate) previous.name else (p.name + " / " + role.name.lowercase()).take(80), role == InputRole.PHYSICAL,
|
||||
part.layout?.takeIf { layout(id, role)?.editable == true }, defaults.bindings[role].orEmpty(),
|
||||
elements = (layout(id, role)?.let { InputStockLayouts.actions(it) } ?: emptyMap()) + layout(id, role)?.elements.orEmpty().filter { it.id == "builtin:keyboard" }
|
||||
.associate { it.id to InputAction.Command(InputCommand.KEYBOARD) })
|
||||
result = result.withPreset(preset)
|
||||
part.copy(bindings = preset.id)
|
||||
}
|
||||
return result.withProfile(p.copy(parts = parts))
|
||||
}
|
||||
|
||||
fun validate(): InputLibrary {
|
||||
require(profiles.size in 2..64 && layouts.size in 3..256 && bindings.size in 1..512)
|
||||
fun unique(ids: List<String>) { require(ids.all { it.isNotBlank() && it.length <= 128 } && ids.distinct().size == ids.size) }
|
||||
unique(profiles.map { it.id }); unique(layouts.map { it.id }); unique(bindings.map { it.id })
|
||||
require(profiles.any { it.id == InputProfile.GAME } && profiles.any { it.id == InputProfile.DESKTOP })
|
||||
require(selected == InputProfiles.AUTO || profiles.any { it.id == selected })
|
||||
require(profiles.any { it.id == steam } && profiles.any { it.id == desktop })
|
||||
for (layout in layouts) {
|
||||
require(layout.style.opacity in 1..100 && layout.style.hidden.all { it in QUICK_BUTTONS })
|
||||
require(layout.aspect == 0f || layout.aspect.isFinite() && layout.aspect in .05f..20f)
|
||||
require(layout.scale.isFinite() && layout.scale in .5f..2f)
|
||||
inputName(layout.name); require(layout.elements.size <= 64); unique(layout.elements.map { it.id })
|
||||
for (e in layout.elements) {
|
||||
inputName(e.name)
|
||||
require(e.icon == 0 || InputButtonIcons.has(e.icon))
|
||||
require(e.image.isEmpty() || e.image.matches(Regex("[A-Za-z0-9_.-]{1,128}")) && e.image != "." && e.image != "..")
|
||||
require(e.opacity.isFinite() && e.opacity in 0.01f..1f)
|
||||
require(!e.toggle || e.kind == InputElementKind.BUTTON || e.kind == InputElementKind.TRIGGER)
|
||||
require(listOf(e.x, e.y, e.width, e.height).all { it.isFinite() })
|
||||
require(e == e.bounded())
|
||||
require(if (e.id.startsWith("legacy:")) e.id == "legacy:${e.control.id}" && e.kind == InputElementKind.from(e.control)
|
||||
else e.control == e.kind.control)
|
||||
}
|
||||
}
|
||||
for (preset in bindings) {
|
||||
inputName(preset.name)
|
||||
require(preset.controls.all { (c, a) -> a.validFor(c) })
|
||||
require(preset.elements.size <= 64 && preset.keys.size <= 64 && preset.clicks.size <= 64)
|
||||
require(preset.keys.map { Triple(it.key.device, it.key.keyCode, it.key.scanCode) }.distinct().size == preset.keys.size)
|
||||
require(preset.physical || preset.keys.isEmpty())
|
||||
val layout = preset.layoutId?.let { id -> layouts.first { it.id == id } }
|
||||
require(!preset.physical || layout == null && preset.elements.isEmpty() && preset.clicks.isEmpty())
|
||||
require(preset.elements.all { (id, action) -> layout?.elements?.firstOrNull { it.id == id }?.let { action.validFor(it) } == true })
|
||||
require(preset.clicks.all { (id, action) -> layout?.elements?.any { it.id == id && it.hasClick } == true && action.validForClick() })
|
||||
require(preset.keys.all { it.key.device.isNotBlank() && it.key.keyCode >= 0 && it.key.scanCode >= 0 &&
|
||||
it.action.validFor(InputControl.A) && (it.action !is InputAction.Native || it.action.target == null) })
|
||||
}
|
||||
require(selections.keys.all { it == InputProfile.GAME || it == InputProfile.DESKTOP })
|
||||
for (selection in selections.values) {
|
||||
require(selection.profile == InputProfiles.AUTO || profiles.any { it.id == selection.profile })
|
||||
for ((role, id) in selection.parts) {
|
||||
val binding = preset(id)
|
||||
require(binding.physical == (role == InputRole.PHYSICAL))
|
||||
val layout = binding.layoutId?.let { lid -> layouts.first { it.id == lid } }
|
||||
require(binding.compatible(layout) && (layout == null || layout.supports(role)))
|
||||
}
|
||||
}
|
||||
for (p in profiles) {
|
||||
inputName(p.name); require(p.base == InputProfile.GAME || p.base == InputProfile.DESKTOP)
|
||||
require(p.parts.keys == InputRole.entries.toSet())
|
||||
for ((role, part) in p.parts) {
|
||||
val layout = part.layout?.let { id -> layouts.first { it.id == id } }
|
||||
require(if (role == InputRole.PHYSICAL) layout == null else layout?.supports(role) == true)
|
||||
require(preset(part.bindings).compatible(layout))
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
internal fun inputName(value: String): String = value.trim().also { require(it.isNotEmpty() && it.length <= 80) }
|
||||
fun newInputId(): String = UUID.randomUUID().toString()
|
||||
|
||||
internal fun InputAction.validFor(element: InputSurfaceElement): Boolean = when {
|
||||
this !is InputAction.Native -> validFor(element.control)
|
||||
element.id.startsWith("legacy:") -> validFor(element.control)
|
||||
element.kind == InputElementKind.DPAD -> target == null
|
||||
target == null -> false
|
||||
element.kind == InputElementKind.TRACKPAD -> target.isTrackpad
|
||||
element.kind == InputElementKind.STICK -> target == InputControl.LS || target == InputControl.RS
|
||||
element.kind == InputElementKind.TRIGGER -> target == InputControl.LT || target == InputControl.RT
|
||||
else -> target.dimensions == 1 && target != InputControl.LT && target != InputControl.RT
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.Choreographer
|
||||
|
||||
class InputMotion(private val router: () -> InputRouter?) : Choreographer.FrameCallback {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var scheduled = false
|
||||
private var lastFrame = 0L
|
||||
private val start = Runnable {
|
||||
if (!scheduled) {
|
||||
scheduled = true
|
||||
lastFrame = System.nanoTime()
|
||||
Choreographer.getInstance().postFrameCallback(this)
|
||||
}
|
||||
}
|
||||
|
||||
fun request() {
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) start.run() else handler.post(start)
|
||||
}
|
||||
|
||||
override fun doFrame(frameTimeNanos: Long) {
|
||||
val dt = (frameTimeNanos - lastFrame).coerceAtLeast(0) / 1_000_000_000f
|
||||
lastFrame = frameTimeNanos
|
||||
if (router()?.tick(dt) == true) Choreographer.getInstance().postFrameCallback(this)
|
||||
else scheduled = false
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
handler.removeCallbacks(start)
|
||||
Choreographer.getInstance().removeFrameCallback(this)
|
||||
scheduled = false
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
enum class InputRole { PHYSICAL, PRIMARY, SECONDARY }
|
||||
|
||||
enum class InputControl(val id: String, val label: String, val dimensions: Int = 1) {
|
||||
A("a", "A"), B("b", "B"), X("x", "X"), Y("y", "Y"),
|
||||
LB("lb", "L1"), RB("rb", "R1"), SELECT("select", "View / Select"), START("start", "Menu / Start"),
|
||||
L3("l3", "L3"), R3("r3", "R3"), GUIDE("guide", "Steam / Guide"), QAM("qam", "Quick access"),
|
||||
UP("up", "D-pad up"), RIGHT("right", "D-pad right"), DOWN("down", "D-pad down"), LEFT("left", "D-pad left"),
|
||||
LT("lt", "L2"), RT("rt", "R2"), LS("ls", "Left stick", 2), RS("rs", "Right stick", 2),
|
||||
L4("l4", "L4"), R4("r4", "R4"), L5("l5", "L5"), R5("r5", "R5"),
|
||||
LEFT_PAD("left-pad", "Left trackpad", 2), RIGHT_PAD("right-pad", "Right trackpad", 2),
|
||||
LEFT_CLICK("left-click", "Left trackpad click"), RIGHT_CLICK("right-click", "Right trackpad click");
|
||||
|
||||
val isPad: Boolean get() = ordinal <= RS.ordinal
|
||||
val isTrackpad: Boolean get() = this == LEFT_PAD || this == RIGHT_PAD
|
||||
|
||||
companion object {
|
||||
val padControls = entries.filter { it.isPad }.toTypedArray()
|
||||
fun fromId(id: String) = entries.firstOrNull { it.id == id }
|
||||
}
|
||||
}
|
||||
|
||||
enum class InputCommand { MENU, KEYBOARD, STEAM, QUICK_ACCESS }
|
||||
|
||||
sealed interface InputAction {
|
||||
data class Native(val target: InputControl? = null) : InputAction
|
||||
data object Disabled : InputAction
|
||||
data class Keys(val codes: List<Int>) : InputAction
|
||||
data class MouseButton(val code: Int) : InputAction
|
||||
data class Command(val command: InputCommand) : InputAction
|
||||
data class Directions(val up: Int, val right: Int, val down: Int, val left: Int, val diagonals: List<Int>? = null) : InputAction
|
||||
data class Pointer(val speed: Float = 900f) : InputAction
|
||||
data class Scroll(val speed: Float = 10f, val horizontal: Boolean = false) : InputAction
|
||||
}
|
||||
|
||||
data class InputProfile(
|
||||
val id: String,
|
||||
val bindings: Map<InputRole, Map<InputControl, InputAction>> = emptyMap(),
|
||||
val keys: List<PhysicalInputBinding> = emptyList(),
|
||||
val elements: Map<InputRole, Map<String, InputAction>> = emptyMap(),
|
||||
val clicks: Map<InputRole, Map<String, InputAction>> = emptyMap(),
|
||||
) {
|
||||
fun action(role: InputRole, control: InputControl): InputAction =
|
||||
bindings[role]?.get(control) ?: InputAction.Native()
|
||||
|
||||
fun withBinding(role: InputRole, control: InputControl, action: InputAction) =
|
||||
copy(bindings = bindings + (role to (bindings[role].orEmpty() + (control to action))))
|
||||
|
||||
fun elementAction(role: InputRole, id: String, control: InputControl): InputAction =
|
||||
elements[role]?.get(id) ?: if (id == "legacy:${control.id}") action(role, control) else InputAction.Disabled
|
||||
|
||||
fun elementClickAction(role: InputRole, element: InputSurfaceElement): InputAction = clicks[role]?.get(element.id)
|
||||
?: defaultElementClick(element, elementAction(role, element.id, element.control)) { action(role, it) }
|
||||
|
||||
companion object {
|
||||
const val GAME = "game"
|
||||
const val DESKTOP = "desktop"
|
||||
|
||||
fun defaults(id: String): InputProfile {
|
||||
if (id != DESKTOP) return InputProfile(GAME)
|
||||
val desktop = mapOf(
|
||||
InputControl.LS to InputAction.Pointer(), InputControl.RS to InputAction.Scroll(),
|
||||
InputControl.A to InputAction.MouseButton(272), InputControl.B to InputAction.MouseButton(273),
|
||||
InputControl.X to InputAction.Keys(listOf(28)), InputControl.Y to InputAction.Keys(listOf(1)),
|
||||
InputControl.UP to InputAction.Keys(listOf(103)), InputControl.RIGHT to InputAction.Keys(listOf(106)),
|
||||
InputControl.DOWN to InputAction.Keys(listOf(108)), InputControl.LEFT to InputAction.Keys(listOf(105)),
|
||||
InputControl.LT to InputAction.MouseButton(273), InputControl.RT to InputAction.MouseButton(272),
|
||||
InputControl.LB to InputAction.Keys(listOf(29)), InputControl.RB to InputAction.Keys(listOf(56)),
|
||||
InputControl.SELECT to InputAction.Command(InputCommand.KEYBOARD),
|
||||
InputControl.START to InputAction.Command(InputCommand.MENU),
|
||||
InputControl.LEFT_PAD to InputAction.Scroll(), InputControl.RIGHT_PAD to InputAction.Pointer(),
|
||||
InputControl.LEFT_CLICK to InputAction.MouseButton(273), InputControl.RIGHT_CLICK to InputAction.MouseButton(272),
|
||||
)
|
||||
return InputProfile(DESKTOP, InputRole.entries.associateWith { desktop })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun defaultElementClick(element: InputSurfaceElement, action: InputAction, legacy: (InputControl) -> InputAction): InputAction {
|
||||
fun click(control: InputControl) = when (control) {
|
||||
InputControl.LS -> InputControl.L3; InputControl.RS -> InputControl.R3
|
||||
InputControl.LEFT_PAD -> InputControl.LEFT_CLICK; InputControl.RIGHT_PAD -> InputControl.RIGHT_CLICK
|
||||
else -> null
|
||||
}
|
||||
if (!element.hasClick) return InputAction.Disabled
|
||||
if (element.id == "legacy:${element.control.id}") {
|
||||
val target = click(element.control) ?: return InputAction.Disabled
|
||||
return legacy(target).let { if (it is InputAction.Native && it.target == null) InputAction.Native(target) else it }
|
||||
}
|
||||
return (action as? InputAction.Native)?.let { click(it.target ?: element.control) }?.let { InputAction.Native(it) } ?: InputAction.Disabled
|
||||
}
|
||||
|
||||
internal fun InputAction.validForClick(): Boolean = if (this is InputAction.Native)
|
||||
target != null && target.dimensions == 1 && target != InputControl.LT && target != InputControl.RT
|
||||
else validFor(InputControl.A)
|
||||
|
||||
internal fun InputAction.validFor(control: InputControl): Boolean = when (this) {
|
||||
is InputAction.Native -> target == null || (control.isPad == target.isPad && control.dimensions == target.dimensions &&
|
||||
(!control.isTrackpad && !target.isTrackpad || control.isTrackpad && target.isTrackpad))
|
||||
is InputAction.Keys -> codes.isNotEmpty() && codes.size <= 8 && codes.distinct().size == codes.size && codes.all { it in 1..255 }
|
||||
is InputAction.MouseButton -> code in 272..276
|
||||
is InputAction.Directions -> control.dimensions == 2 && listOf(up, right, down, left).all { it in 0..255 } && (diagonals == null || diagonals.size == 4 && diagonals.all { it in 0..255 })
|
||||
is InputAction.Pointer -> control.dimensions == 2 && speed.isFinite() && speed in 100f..3000f
|
||||
is InputAction.Scroll -> control.dimensions == 2 && speed.isFinite() && speed in 1f..60f
|
||||
else -> true
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
object InputProfiles {
|
||||
const val AUTO = "auto"
|
||||
private const val VERSION = 1
|
||||
|
||||
private fun prefs(context: Context) = context.getSharedPreferences("input-profiles", Context.MODE_PRIVATE)
|
||||
|
||||
fun selection(context: Context): String = InputLibraries.read(context).selected
|
||||
|
||||
fun select(context: Context, id: String) {
|
||||
InputLibraries.update(context) { library ->
|
||||
require(id == AUTO || library.profiles.any { it.id == id })
|
||||
library.copy(selected = id, selections = emptyMap())
|
||||
}
|
||||
}
|
||||
|
||||
fun active(context: Context, desktop: Boolean): InputProfile {
|
||||
val library = InputLibraries.read(context)
|
||||
return library.compileActive(desktop)
|
||||
}
|
||||
|
||||
fun read(context: Context, id: String): InputProfile {
|
||||
return InputLibraries.read(context).compile(id)
|
||||
}
|
||||
|
||||
internal fun readLegacy(context: Context, id: String): InputProfile {
|
||||
migrate(context)
|
||||
var result = InputProfile.defaults(id)
|
||||
val stored = prefs(context).getString("profile.$id", null) ?: return result
|
||||
val document = runCatching { JSONObject(stored) }.getOrNull() ?: return result
|
||||
if (document.optInt("version") != VERSION) return result
|
||||
val bindings = document.optJSONArray("bindings") ?: return result
|
||||
for (i in 0 until minOf(bindings.length(), 256)) {
|
||||
val entry = bindings.optJSONObject(i) ?: continue
|
||||
val role = runCatching { InputRole.valueOf(entry.getString("role")) }.getOrNull() ?: continue
|
||||
val control = InputControl.fromId(entry.optString("control")) ?: continue
|
||||
val action = decode(entry.optJSONObject("action") ?: continue) ?: continue
|
||||
if (action.validFor(control)) result = result.withBinding(role, control, action)
|
||||
}
|
||||
val keys = document.optJSONArray("keys")
|
||||
if (keys != null) result = result.copy(keys = (0 until minOf(keys.length(), 64)).mapNotNull { i ->
|
||||
runCatching {
|
||||
val item = keys.getJSONObject(i)
|
||||
val action = decode(item.getJSONObject("action")) ?: return@mapNotNull null
|
||||
if (!action.validFor(InputControl.A) || action is InputAction.Native && action.target != null) return@mapNotNull null
|
||||
val key = PhysicalInputKey(item.getString("device"), item.getString("name"), item.getInt("keyCode"), item.getInt("scanCode"))
|
||||
if (key.device.isBlank() || key.keyCode < 0 || key.scanCode < 0) return@mapNotNull null
|
||||
PhysicalInputBinding(key, action)
|
||||
}.getOrNull()
|
||||
}.distinctBy { Triple(it.key.device, it.key.keyCode, it.key.scanCode) })
|
||||
return result
|
||||
}
|
||||
|
||||
fun save(context: Context, profile: InputProfile) {
|
||||
InputLibraries.update(context) { library ->
|
||||
val definition = library.profile(profile.id)
|
||||
var updated = library
|
||||
for (role in InputRole.entries) {
|
||||
val preset = updated.preset(definition.binding(role))
|
||||
updated = updated.withPreset(preset.copy(controls = profile.bindings[role].orEmpty(),
|
||||
elements = profile.elements[role].orEmpty(), keys = if (role == InputRole.PHYSICAL) profile.keys else emptyList(),
|
||||
clicks = profile.clicks[role].orEmpty()))
|
||||
}
|
||||
updated
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveLegacy(context: Context, profile: InputProfile) {
|
||||
val bindings = JSONArray()
|
||||
for ((role, controls) in profile.bindings) for ((control, action) in controls) {
|
||||
require(action.validFor(control))
|
||||
bindings.put(JSONObject().put("role", role.name).put("control", control.id).put("action", encode(action)))
|
||||
}
|
||||
val keys = JSONArray()
|
||||
for (binding in profile.keys) keys.put(JSONObject().put("device", binding.key.device).put("name", binding.key.name)
|
||||
.put("keyCode", binding.key.keyCode).put("scanCode", binding.key.scanCode).put("action", encode(binding.action)))
|
||||
prefs(context).edit().putString("profile.${profile.id}", JSONObject()
|
||||
.put("version", VERSION).put("id", profile.id).put("bindings", bindings).put("keys", keys).toString()).apply()
|
||||
}
|
||||
|
||||
fun reset(context: Context, id: String) = InputLibraries.update(context) { it.resetProfile(id) }.let { }
|
||||
|
||||
fun resetAll(context: Context) {
|
||||
prefs(context).edit().clear().putBoolean("migrated", true).apply()
|
||||
InputSurfaceLayout.resetAll(context)
|
||||
}
|
||||
|
||||
private fun migrate(context: Context) {
|
||||
val p = prefs(context)
|
||||
if (p.getBoolean("migrated", false)) return
|
||||
var game = InputProfile.defaults(InputProfile.GAME)
|
||||
val old = ControllerPrefs.read(context)
|
||||
for ((id, target) in old.mapping) {
|
||||
val source = InputControl.fromId(id) ?: continue
|
||||
val action = if (target == ControllerPrefs.OFF) InputAction.Disabled
|
||||
else InputAction.Native(InputControl.fromId(target) ?: continue)
|
||||
if (target != id) game = game.withBinding(InputRole.PRIMARY, source, action)
|
||||
}
|
||||
if (old.selectSteam) game = game.withBinding(InputRole.PHYSICAL, InputControl.SELECT, InputAction.Native(InputControl.GUIDE))
|
||||
if (!p.contains("profile.${InputProfile.GAME}")) saveLegacy(context, game)
|
||||
if (old.selectSteam && !p.contains("profile.${InputProfile.DESKTOP}")) {
|
||||
saveLegacy(context, InputProfile.defaults(InputProfile.DESKTOP)
|
||||
.withBinding(InputRole.PHYSICAL, InputControl.SELECT, InputAction.Native(InputControl.GUIDE)))
|
||||
}
|
||||
p.edit().putBoolean("migrated", true).apply()
|
||||
}
|
||||
|
||||
internal fun encode(action: InputAction): JSONObject = JSONObject().apply {
|
||||
when (action) {
|
||||
is InputAction.Native -> { put("type", "native"); action.target?.let { put("target", it.id) } }
|
||||
InputAction.Disabled -> put("type", "disabled")
|
||||
is InputAction.Keys -> { put("type", "keys"); put("codes", JSONArray(action.codes)) }
|
||||
is InputAction.MouseButton -> { put("type", "button"); put("code", action.code) }
|
||||
is InputAction.Command -> { put("type", "command"); put("command", action.command.name) }
|
||||
is InputAction.Directions -> { put("type", "directions"); put("codes", JSONArray(listOf(action.up, action.right, action.down, action.left))); action.diagonals?.let { put("diagonals", JSONArray(it)) } }
|
||||
is InputAction.Pointer -> { put("type", "pointer"); put("speed", action.speed.toDouble()) }
|
||||
is InputAction.Scroll -> { put("type", "scroll"); put("speed", action.speed.toDouble()); put("horizontal", action.horizontal) }
|
||||
}
|
||||
}
|
||||
|
||||
internal fun decode(json: JSONObject): InputAction? = runCatching {
|
||||
when (json.getString("type")) {
|
||||
"native" -> InputAction.Native(if (json.has("target")) InputControl.fromId(json.getString("target")) ?: return null else null)
|
||||
"disabled" -> InputAction.Disabled
|
||||
"keys" -> InputAction.Keys(json.getJSONArray("codes").let { a -> (0 until a.length()).map { a.getInt(it) } })
|
||||
"button" -> InputAction.MouseButton(json.getInt("code"))
|
||||
"command" -> InputAction.Command(InputCommand.valueOf(json.getString("command")))
|
||||
"directions" -> json.getJSONArray("codes").let { InputAction.Directions(it.getInt(0), it.getInt(1), it.getInt(2), it.getInt(3), json.optJSONArray("diagonals")?.let { d -> (0 until d.length()).map { d.getInt(it) } }) }
|
||||
"pointer" -> InputAction.Pointer(json.getDouble("speed").toFloat())
|
||||
"scroll" -> InputAction.Scroll(json.getDouble("speed").toFloat(), json.optBoolean("horizontal", false))
|
||||
else -> null
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
@@ -1,361 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import kotlin.math.abs
|
||||
|
||||
interface InputOutput {
|
||||
fun key(code: Int, down: Boolean)
|
||||
fun button(code: Int, down: Boolean)
|
||||
fun pointer(dx: Float, dy: Float)
|
||||
fun scroll(steps: Int)
|
||||
fun scrollHorizontal(steps: Int) {}
|
||||
fun command(command: InputCommand)
|
||||
fun command(command: InputCommand, role: InputRole) = command(command)
|
||||
fun requestMotion()
|
||||
}
|
||||
|
||||
class InputRouter(private val output: InputOutput) {
|
||||
private val keys = IntArray(256)
|
||||
private val buttons = IntArray(5)
|
||||
private val channels = ArrayList<Channel>()
|
||||
private val pads = ArrayList<PadSource>()
|
||||
private var profile = InputProfile.defaults(InputProfile.GAME)
|
||||
private var moving = false
|
||||
private var wheel = 0f
|
||||
private var horizontalWheel = 0f
|
||||
private var enabled = true
|
||||
private val snapshots = ArrayList<() -> Unit>()
|
||||
|
||||
@Synchronized
|
||||
fun beforeProfileChange(snapshot: () -> Unit) { snapshots.add(snapshot) }
|
||||
|
||||
@Synchronized
|
||||
fun removeSnapshot(snapshot: () -> Unit) { snapshots.remove(snapshot) }
|
||||
|
||||
@Synchronized
|
||||
fun setEnabled(on: Boolean): Boolean {
|
||||
if (enabled == on) return false
|
||||
if (!on) releaseAll()
|
||||
enabled = on
|
||||
for (pad in pads) pad.refreshIdentity()
|
||||
return true
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun setProfile(next: InputProfile) {
|
||||
if (profile == next) return
|
||||
for (snapshot in snapshots) snapshot()
|
||||
for (pad in pads) pad.snapshot()
|
||||
profile = next
|
||||
for (channel in channels) channel.configure(channel.actionFrom(next))
|
||||
for (pad in pads) pad.refreshIdentity()
|
||||
wheel = 0f
|
||||
horizontalWheel = 0f
|
||||
moving = false
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
@Synchronized
|
||||
fun pad(role: InputRole, retained: PadState? = null): PadSource = PadSource(role, retained).also { pads.add(it) }
|
||||
|
||||
@Synchronized
|
||||
fun channel(role: InputRole, control: InputControl, fixedAction: InputAction? = null, element: InputSurfaceElement? = null, elementClick: Boolean = false): Channel = Channel(role, control, fixedAction, element, elementClick).also {
|
||||
channels.add(it)
|
||||
it.configure(it.actionFrom(profile))
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun releaseAll() {
|
||||
for (snapshot in snapshots) snapshot()
|
||||
for (pad in pads) pad.snapshot()
|
||||
for (channel in channels) channel.suspend()
|
||||
for (pad in pads) pad.refreshIdentity()
|
||||
wheel = 0f
|
||||
horizontalWheel = 0f
|
||||
moving = false
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun tick(seconds: Float): Boolean {
|
||||
if (!enabled) return false
|
||||
var x = 0f
|
||||
var y = 0f
|
||||
var scroll = 0f
|
||||
var horizontalScroll = 0f
|
||||
for (channel in channels) {
|
||||
if (channel.blocked || channel.control.isTrackpad) continue
|
||||
val cx = if (abs(channel.x) > 0.12f) channel.x else 0f
|
||||
val cy = if (abs(channel.y) > 0.12f) channel.y else 0f
|
||||
when (val action = channel.action) {
|
||||
is InputAction.Pointer -> { x += cx * action.speed; y += cy * action.speed }
|
||||
is InputAction.Scroll -> if (action.horizontal) horizontalScroll += cx * action.speed else scroll += cy * action.speed
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
val dt = seconds.coerceIn(0f, 0.05f)
|
||||
if (x != 0f || y != 0f) output.pointer(x * dt, y * dt)
|
||||
wheel += scroll * dt
|
||||
horizontalWheel += horizontalScroll * dt
|
||||
flushWheel()
|
||||
moving = x != 0f || y != 0f || scroll != 0f || horizontalScroll != 0f
|
||||
return moving
|
||||
}
|
||||
|
||||
private fun flushWheel() {
|
||||
val steps = wheel.toInt()
|
||||
if (steps != 0) { wheel -= steps; output.scroll(steps) }
|
||||
val horizontalSteps = horizontalWheel.toInt()
|
||||
if (horizontalSteps != 0) { horizontalWheel -= horizontalSteps; output.scrollHorizontal(horizontalSteps) }
|
||||
}
|
||||
|
||||
private fun key(code: Int, down: Boolean) {
|
||||
val was = keys[code]
|
||||
keys[code] = (was + if (down) 1 else -1).coerceAtLeast(0)
|
||||
if ((was == 0) != (keys[code] == 0)) output.key(code, down)
|
||||
}
|
||||
|
||||
private fun button(code: Int, down: Boolean) {
|
||||
val at = code - 272
|
||||
val was = buttons[at]
|
||||
buttons[at] = (was + if (down) 1 else -1).coerceAtLeast(0)
|
||||
if ((was == 0) != (buttons[at] == 0)) output.button(code, down)
|
||||
}
|
||||
|
||||
inner class Channel internal constructor(val role: InputRole, val control: InputControl, private val fixedAction: InputAction?, private val element: InputSurfaceElement?, private val elementClick: Boolean) {
|
||||
var action: InputAction = InputAction.Native()
|
||||
private set
|
||||
internal var x = 0f
|
||||
internal var y = 0f
|
||||
internal var contact = false
|
||||
internal var blocked = false
|
||||
private var held = false
|
||||
private val directions = BooleanArray(4)
|
||||
private var radialKeys = emptyList<Int>()
|
||||
|
||||
internal fun actionFrom(profile: InputProfile) = element?.let { if (elementClick) profile.elementClickAction(role, it) else profile.elementAction(role, it.id, control) }
|
||||
?: profile.action(role, control)
|
||||
|
||||
internal fun configure(next: InputAction) {
|
||||
suspend()
|
||||
action = (fixedAction ?: next).takeIf { if (elementClick) it.validForClick() else if (element != null) it.validFor(element) else it.validFor(control) }
|
||||
?: if (element != null) InputAction.Disabled else InputAction.Native()
|
||||
}
|
||||
|
||||
internal fun observe(value: Float, second: Float = 0f, touching: Boolean = false) {
|
||||
x = value; y = second; contact = touching
|
||||
}
|
||||
|
||||
internal fun awaitNeutral(value: Float, second: Float, touching: Boolean) = synchronized(this@InputRouter) {
|
||||
observe(value, second, touching)
|
||||
suspend()
|
||||
}
|
||||
|
||||
internal fun nativeIdentity(): Boolean = enabled && !blocked && (action as? InputAction.Native)?.target == null && action is InputAction.Native
|
||||
|
||||
internal fun suspend() {
|
||||
releaseOutputs()
|
||||
blocked = contact || abs(x) > 0.12f || abs(y) > 0.12f
|
||||
}
|
||||
|
||||
fun release() = synchronized(this@InputRouter) {
|
||||
releaseOutputs()
|
||||
x = 0f; y = 0f; contact = false; blocked = false
|
||||
}
|
||||
|
||||
fun close() = synchronized(this@InputRouter) {
|
||||
release()
|
||||
channels.remove(this)
|
||||
}
|
||||
|
||||
fun acceptsPointerGesture() = synchronized(this@InputRouter) { enabled && !blocked && contact && action is InputAction.Pointer }
|
||||
|
||||
fun pointerDelta(dx: Float, dy: Float) = synchronized(this@InputRouter) {
|
||||
val pointer = action as? InputAction.Pointer
|
||||
if (enabled && !blocked && contact && pointer != null && dx.isFinite() && dy.isFinite()) output.pointer(dx * pointer.speed, dy * pointer.speed)
|
||||
}
|
||||
|
||||
fun wheelSteps(steps: Int) = synchronized(this@InputRouter) {
|
||||
val scroll = action as? InputAction.Scroll
|
||||
if (enabled && !blocked && scroll != null && steps != 0) {
|
||||
if (scroll.horizontal) output.scrollHorizontal(steps) else output.scroll(steps)
|
||||
}
|
||||
}
|
||||
|
||||
fun update(value: Float, second: Float = 0f, touching: Boolean = false): InputControl? = synchronized(this@InputRouter) {
|
||||
val oldX = x
|
||||
val oldY = y
|
||||
val oldContact = contact
|
||||
x = value.coerceIn(-1f, 1f)
|
||||
y = second.coerceIn(-1f, 1f)
|
||||
contact = touching
|
||||
if (!enabled) {
|
||||
blocked = contact || abs(x) > 0.12f || abs(y) > 0.12f
|
||||
return@synchronized null
|
||||
}
|
||||
if (blocked) {
|
||||
if (!contact && abs(x) <= 0.12f && abs(y) <= 0.12f) blocked = false
|
||||
return@synchronized null
|
||||
}
|
||||
when (val a = action) {
|
||||
is InputAction.Native -> return@synchronized a.target ?: control
|
||||
is InputAction.Disabled -> Unit
|
||||
is InputAction.Pointer -> {
|
||||
if (control.isTrackpad) {
|
||||
if (contact && oldContact) output.pointer((x - oldX) * a.speed, -(y - oldY) * a.speed)
|
||||
} else requestMotion()
|
||||
}
|
||||
is InputAction.Scroll -> {
|
||||
if (control.isTrackpad) {
|
||||
if (contact && oldContact) {
|
||||
if (a.horizontal) horizontalWheel -= (x - oldX) * a.speed else wheel += (y - oldY) * a.speed
|
||||
flushWheel()
|
||||
}
|
||||
} else requestMotion()
|
||||
}
|
||||
is InputAction.Directions -> {
|
||||
val vertical = if (control.isTrackpad) -y else y
|
||||
if (a.diagonals == null) {
|
||||
direction(0, -vertical, a.up); direction(1, x, a.right)
|
||||
direction(2, vertical, a.down); direction(3, -x, a.left)
|
||||
} else {
|
||||
val cardinal = listOf(a.up, a.right, a.down, a.left)
|
||||
val sector = ((kotlin.math.round(Math.toDegrees(kotlin.math.atan2(vertical, x).toDouble()) / 45).toInt() + 2) % 8 + 8) % 8
|
||||
val next = if (kotlin.math.hypot(x, vertical) < .24f) emptyList() else if (sector % 2 == 0) listOf(cardinal[sector / 2])
|
||||
else a.diagonals[sector / 2].let { if (it != 0) listOf(it) else listOf(cardinal[sector / 2], cardinal[(sector / 2 + 1) % 4]) }
|
||||
val codes = next.filter { it != 0 }.distinct()
|
||||
radialKeys.filterNot { it in codes }.forEach { key(it, false) }
|
||||
codes.filterNot { it in radialKeys }.forEach { key(it, true) }
|
||||
radialKeys = codes
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
val down = if (control.isTrackpad) contact else maxOf(abs(x), abs(y)) > if (held) 0.35f else 0.55f
|
||||
if (held != down) {
|
||||
held = down
|
||||
when (a) {
|
||||
is InputAction.Keys -> if (down) a.codes.forEach { key(it, true) } else a.codes.asReversed().forEach { key(it, false) }
|
||||
is InputAction.MouseButton -> button(a.code, down)
|
||||
is InputAction.Command -> if (!down) output.command(a.command, role)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
null
|
||||
}
|
||||
|
||||
private fun requestMotion() {
|
||||
if (!moving && (abs(x) > 0.12f || abs(y) > 0.12f)) { moving = true; output.requestMotion() }
|
||||
}
|
||||
|
||||
private fun direction(at: Int, value: Float, code: Int) {
|
||||
val down = value > if (directions[at]) 0.35f else 0.55f
|
||||
if (directions[at] != down) { directions[at] = down; if (code != 0) key(code, down) }
|
||||
}
|
||||
|
||||
private fun releaseOutputs() {
|
||||
when (val a = action) {
|
||||
is InputAction.Keys -> if (held) a.codes.asReversed().forEach { key(it, false) }
|
||||
is InputAction.MouseButton -> if (held) button(a.code, false)
|
||||
is InputAction.Directions -> {
|
||||
val codes = intArrayOf(a.up, a.right, a.down, a.left)
|
||||
for (i in directions.indices) if (directions[i] && codes[i] != 0) key(codes[i], false)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
radialKeys.forEach { key(it, false) }; radialKeys = emptyList()
|
||||
held = false
|
||||
directions.fill(false)
|
||||
}
|
||||
}
|
||||
|
||||
inner class PadSource internal constructor(role: InputRole, retained: PadState?) {
|
||||
private val routes = Array(InputControl.padControls.size) { channel(role, InputControl.padControls[it]) }
|
||||
private val result = PadState()
|
||||
private var last: PadState? = null
|
||||
private var identity = true
|
||||
private var nativeCandidate = true
|
||||
|
||||
init {
|
||||
last = retained
|
||||
if (retained != null) for (route in routes) {
|
||||
route.awaitNeutral(retained.value(route.control), retained.secondValue(route.control), false)
|
||||
}
|
||||
refreshIdentity()
|
||||
}
|
||||
|
||||
internal fun refreshIdentity() {
|
||||
nativeCandidate = routes.all { (it.action as? InputAction.Native)?.target == null && it.action is InputAction.Native }
|
||||
identity = enabled && nativeCandidate && routes.none { it.blocked }
|
||||
}
|
||||
|
||||
internal fun snapshot() {
|
||||
val raw = last ?: return
|
||||
for (route in routes) {
|
||||
route.x = raw.value(route.control)
|
||||
route.y = raw.secondValue(route.control)
|
||||
}
|
||||
}
|
||||
|
||||
fun route(raw: PadState): PadState = synchronized(this@InputRouter) {
|
||||
last = raw
|
||||
if (identity) return@synchronized raw
|
||||
result.clear()
|
||||
for (route in routes) {
|
||||
val x = raw.value(route.control)
|
||||
val y = raw.secondValue(route.control)
|
||||
val target = route.update(x, y) ?: continue
|
||||
result.add(target, x, y)
|
||||
}
|
||||
if (nativeCandidate && enabled && routes.none { it.blocked }) identity = true
|
||||
result
|
||||
}
|
||||
|
||||
fun release() = synchronized(this@InputRouter) {
|
||||
for (route in routes) route.release()
|
||||
last = null
|
||||
result.clear()
|
||||
refreshIdentity()
|
||||
}
|
||||
|
||||
fun close() = synchronized(this@InputRouter) {
|
||||
release()
|
||||
for (route in routes) route.close()
|
||||
pads.remove(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val padButtons = intArrayOf(PadState.A, PadState.B, PadState.X, PadState.Y, PadState.LB, PadState.RB,
|
||||
PadState.SELECT, PadState.START, PadState.L3, PadState.R3, PadState.GUIDE, PadState.QAM)
|
||||
|
||||
internal fun PadState.value(control: InputControl): Float = when (control) {
|
||||
InputControl.UP -> if (up) 1f else 0f
|
||||
InputControl.RIGHT -> if (right) 1f else 0f
|
||||
InputControl.DOWN -> if (down) 1f else 0f
|
||||
InputControl.LEFT -> if (left) 1f else 0f
|
||||
InputControl.LT -> leftTrigger
|
||||
InputControl.RT -> rightTrigger
|
||||
InputControl.LS -> leftX
|
||||
InputControl.RS -> rightX
|
||||
else -> if (control.ordinal < padButtons.size && isDown(padButtons[control.ordinal])) 1f else 0f
|
||||
}
|
||||
|
||||
internal fun PadState.secondValue(control: InputControl): Float = when (control) {
|
||||
InputControl.LS -> leftY
|
||||
InputControl.RS -> rightY
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
internal fun PadState.add(control: InputControl, x: Float, y: Float = 0f) {
|
||||
when (control) {
|
||||
InputControl.UP -> up = up || x > 0.5f
|
||||
InputControl.RIGHT -> right = right || x > 0.5f
|
||||
InputControl.DOWN -> down = down || x > 0.5f
|
||||
InputControl.LEFT -> left = left || x > 0.5f
|
||||
InputControl.LT -> leftTrigger = maxOf(leftTrigger, x)
|
||||
InputControl.RT -> rightTrigger = maxOf(rightTrigger, x)
|
||||
InputControl.LS -> if (x * x + y * y > leftX * leftX + leftY * leftY) { leftX = x; leftY = y }
|
||||
InputControl.RS -> if (x * x + y * y > rightX * rightX + rightY * rightY) { rightX = x; rightY = y }
|
||||
else -> if (control.ordinal < padButtons.size && x > 0.5f) press(padButtons[control.ordinal], true)
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
enum class DeckPanel(val id: String, val layout: String, val bindings: String, @StringRes val label: Int) {
|
||||
GAMEPAD("gamepad", "builtin.deck.gamepad", "preset.deck.gamepad", R.string.deck_tab_gamepad),
|
||||
GRIPS("grips", "builtin.deck.grips", "preset.deck.grips", R.string.deck_tab_grips),
|
||||
LEFT("left", "builtin.deck.left", "preset.deck.left", R.string.deck_tab_left),
|
||||
RIGHT("right", "builtin.deck.right", "preset.deck.right", R.string.deck_tab_right),
|
||||
BOTH("both", InputLibraries.DECK, InputStockLayouts.DECK, R.string.deck_tab_both);
|
||||
|
||||
companion object {
|
||||
fun from(id: String?) = entries.firstOrNull { it.id == id } ?: BOTH
|
||||
}
|
||||
}
|
||||
|
||||
internal object InputStockLayouts {
|
||||
private val NEUTRAL = InputSurfaceStyle(tint = null)
|
||||
const val DECK = "preset.deck"
|
||||
const val TRACKPAD = "preset.trackpad"
|
||||
private fun grips() = listOf(InputControl.L4, InputControl.L5, InputControl.R5, InputControl.R4).mapIndexed { i, c ->
|
||||
InputSurfaceElement(c, .02f + i * .245f, .78f, .225f, .20f)
|
||||
}
|
||||
private fun pads() = listOf(InputSurfaceElement(InputControl.LEFT_PAD, .02f, .02f, .47f, .74f),
|
||||
InputSurfaceElement(InputControl.RIGHT_PAD, .51f, .02f, .47f, .74f))
|
||||
private fun gripGrid() = listOf(InputControl.L4 to (.02f to .02f), InputControl.L5 to (.02f to .51f),
|
||||
InputControl.R4 to (.51f to .02f), InputControl.R5 to (.51f to .51f)).map { (c, at) ->
|
||||
InputSurfaceElement(c, at.first, at.second, .47f, .47f)
|
||||
}
|
||||
private fun pad(right: Boolean) = listOf(
|
||||
InputSurfaceElement(if (right) InputControl.RIGHT_PAD else InputControl.LEFT_PAD, .02f, .02f, .96f, .74f),
|
||||
InputSurfaceElement(if (right) InputControl.RIGHT_CLICK else InputControl.LEFT_CLICK, .02f, .78f, .96f, .20f),
|
||||
)
|
||||
private fun gamepad(): List<InputSurfaceElement> {
|
||||
fun round(c: InputControl, x: Float, y: Float, size: Float) = InputSurfaceElement(c, x, y, size, size, buttonShape = InputButtonShape.CIRCLE)
|
||||
fun pill(c: InputControl, x: Float) = InputSurfaceElement(c, x, .02f, .2f, .12f, buttonShape = InputButtonShape.PILL)
|
||||
return listOf(pill(InputControl.LT, .02f), pill(InputControl.LB, .24f), pill(InputControl.RB, .56f), pill(InputControl.RT, .78f),
|
||||
InputSurfaceElement(InputControl.LS, .02f, .17f, .4f, .4f),
|
||||
InputSurfaceElement(InputControl.RIGHT_PAD, .58f, .59f, .4f, .39f),
|
||||
round(InputControl.UP, .14f, .6f, .12f), round(InputControl.LEFT, .03f, .73f, .12f),
|
||||
round(InputControl.RIGHT, .25f, .73f, .12f), round(InputControl.DOWN, .14f, .86f, .12f),
|
||||
round(InputControl.Y, .72f, .17f, .12f), round(InputControl.X, .61f, .3f, .12f),
|
||||
round(InputControl.B, .83f, .3f, .12f), round(InputControl.A, .72f, .43f, .12f),
|
||||
InputSurfaceElement(InputControl.SELECT, .42f, .3f, .07f, .1f, buttonShape = InputButtonShape.PILL),
|
||||
InputSurfaceElement(InputControl.START, .51f, .3f, .07f, .1f, buttonShape = InputButtonShape.PILL))
|
||||
}
|
||||
fun gamepad(leftPad: Boolean, rightStick: Boolean) = gamepad().map {
|
||||
when {
|
||||
leftPad && it.control == InputControl.LS -> InputSurfaceElement(InputControl.LEFT_PAD, it.x, it.y, it.width, it.height)
|
||||
rightStick && it.control == InputControl.RIGHT_PAD -> InputSurfaceElement(InputControl.RS, it.x, it.y, it.width, it.height)
|
||||
else -> it
|
||||
}
|
||||
}
|
||||
|
||||
fun layouts() = listOf(
|
||||
SavedInputLayout(InputLibraries.DECK, "Deck grips + trackpads", pads() + grips(), style = NEUTRAL, screen = InputLayoutScreen.SECONDARY),
|
||||
SavedInputLayout(InputLibraries.TRACKPAD, "Trackpad + keyboard", listOf(
|
||||
InputSurfaceElement(InputControl.RIGHT_PAD, .02f, .02f, .96f, .96f, mouseGestures = true)), style = NEUTRAL, screen = InputLayoutScreen.SECONDARY),
|
||||
SavedInputLayout(DeckPanel.GAMEPAD.layout, "Deck gamepad", gamepad(), style = NEUTRAL, screen = InputLayoutScreen.SECONDARY),
|
||||
SavedInputLayout(DeckPanel.GRIPS.layout, "Deck grips", gripGrid(), style = NEUTRAL, screen = InputLayoutScreen.SECONDARY),
|
||||
SavedInputLayout(DeckPanel.LEFT.layout, "Deck left trackpad", pad(right = false), style = NEUTRAL, screen = InputLayoutScreen.SECONDARY),
|
||||
SavedInputLayout(DeckPanel.RIGHT.layout, "Deck right trackpad", pad(right = true), style = NEUTRAL, screen = InputLayoutScreen.SECONDARY),
|
||||
)
|
||||
|
||||
fun actions(layout: SavedInputLayout): Map<String, InputAction> = (if (layout.id == InputLibraries.TRACKPAD) mapOf(
|
||||
"legacy:right-pad" to InputAction.Pointer(), "legacy:a" to InputAction.MouseButton(272), "legacy:b" to InputAction.MouseButton(273),
|
||||
) else emptyMap()).filterKeys { id -> layout.elements.any { it.id == id } }
|
||||
|
||||
fun clicks(layout: SavedInputLayout): Map<String, InputAction> = if (layout.id == InputLibraries.TRACKPAD)
|
||||
mapOf("legacy:right-pad" to InputAction.MouseButton(272)) else emptyMap()
|
||||
|
||||
fun presets(layouts: List<SavedInputLayout>) = layouts.map { layout ->
|
||||
val desktop = layout.id == InputLibraries.TRACKPAD
|
||||
val id = if (desktop) TRACKPAD else DeckPanel.entries.first { it.layout == layout.id }.bindings
|
||||
SavedInputBindings(id, layout.name, false, layout.id,
|
||||
controls = InputProfile.defaults(if (desktop) InputProfile.DESKTOP else InputProfile.GAME).bindings[InputRole.SECONDARY].orEmpty(),
|
||||
elements = actions(layout), clicks = clicks(layout))
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
enum class InputElementKind(val control: InputControl) {
|
||||
BUTTON(InputControl.A), STICK(InputControl.LS), TRACKPAD(InputControl.LEFT_PAD),
|
||||
DPAD(InputControl.LS), TRIGGER(InputControl.LT);
|
||||
|
||||
companion object {
|
||||
fun from(control: InputControl) = when {
|
||||
control.isTrackpad -> TRACKPAD
|
||||
control.dimensions == 2 -> STICK
|
||||
control == InputControl.LT || control == InputControl.RT -> TRIGGER
|
||||
else -> BUTTON
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class InputButtonShape { ROUNDED, PILL, CIRCLE, RECTANGLE }
|
||||
|
||||
data class InputSurfaceElement(
|
||||
val control: InputControl, val x: Float, val y: Float, val width: Float, val height: Float,
|
||||
val id: String = "legacy:${control.id}",
|
||||
val kind: InputElementKind = InputElementKind.from(control),
|
||||
val name: String = control.label,
|
||||
val floating: Boolean = true,
|
||||
val hideWhenIdle: Boolean = false,
|
||||
val mouseGestures: Boolean = false,
|
||||
val toggle: Boolean = false,
|
||||
val icon: Int = 0,
|
||||
val image: String = "",
|
||||
val opacity: Float = 1f,
|
||||
val buttonShape: InputButtonShape = InputButtonShape.ROUNDED,
|
||||
val showLabel: Boolean = true,
|
||||
val alwaysShown: Boolean = false,
|
||||
) {
|
||||
val hasClick get() = kind == InputElementKind.STICK || kind == InputElementKind.TRACKPAD
|
||||
|
||||
fun bounded() = copy(x = x.coerceIn(0f, 0.97f), y = y.coerceIn(0f, 0.97f),
|
||||
width = width.coerceIn(0.03f, (1f - x.coerceIn(0f, 0.97f)).coerceAtLeast(0.03f)), height = height.coerceIn(0.03f, (1f - y.coerceIn(0f, 0.97f)).coerceAtLeast(0.03f)))
|
||||
}
|
||||
|
||||
internal val QUICK_BUTTONS = listOf(InputCommand.STEAM, InputCommand.QUICK_ACCESS, InputCommand.KEYBOARD)
|
||||
|
||||
val InputSurfaceElement.quickButton: InputCommand? get() = when {
|
||||
id == "legacy:${InputControl.GUIDE.id}" -> InputCommand.STEAM
|
||||
id == "legacy:${InputControl.QAM.id}" -> InputCommand.QUICK_ACCESS
|
||||
id == "builtin:keyboard" -> InputCommand.KEYBOARD
|
||||
else -> null
|
||||
}
|
||||
|
||||
internal fun List<InputSurfaceElement>.scaled(factor: Float): List<InputSurfaceElement> = map { e ->
|
||||
fun anchor(centre: Float) = when { centre < 1f / 3f -> 0f; centre > 2f / 3f -> 1f; else -> .5f }
|
||||
val w = (e.width * factor).coerceIn(.03f, 1f)
|
||||
val h = (e.height * factor).coerceIn(.03f, 1f)
|
||||
val cx = e.x + e.width / 2
|
||||
val cy = e.y + e.height / 2
|
||||
val ax = anchor(cx)
|
||||
val ay = anchor(cy)
|
||||
e.copy(x = (ax + (cx - ax) * factor - w / 2).coerceIn(0f, 1f - w), y = (ay + (cy - ay) * factor - h / 2).coerceIn(0f, 1f - h),
|
||||
width = w, height = h).bounded()
|
||||
}
|
||||
|
||||
internal fun List<InputSurfaceElement>.withPositionsOf(defaults: List<InputSurfaceElement>): List<InputSurfaceElement> {
|
||||
val byId = defaults.associateBy { it.id }
|
||||
return map { e -> byId[e.id]?.let { d -> e.copy(x = d.x, y = d.y, width = d.width, height = d.height).bounded() } ?: e }
|
||||
}
|
||||
|
||||
object InputSurfaceLayout {
|
||||
private fun prefs(context: Context) = context.getSharedPreferences("input-surfaces", Context.MODE_PRIVATE)
|
||||
|
||||
fun resetAll(context: Context) { prefs(context).edit().clear().apply() }
|
||||
|
||||
fun defaults() = listOf(
|
||||
InputSurfaceElement(InputControl.LEFT_PAD, 0.02f, 0.02f, 0.46f, 0.65f),
|
||||
InputSurfaceElement(InputControl.RIGHT_PAD, 0.52f, 0.02f, 0.46f, 0.65f),
|
||||
InputSurfaceElement(InputControl.A, 0.02f, 0.72f, 0.20f, 0.25f),
|
||||
InputSurfaceElement(InputControl.B, 0.26f, 0.72f, 0.20f, 0.25f),
|
||||
InputSurfaceElement(InputControl.SELECT, 0.52f, 0.72f, 0.20f, 0.25f),
|
||||
InputSurfaceElement(InputControl.START, 0.76f, 0.72f, 0.22f, 0.25f),
|
||||
)
|
||||
|
||||
fun read(context: Context, profile: String, role: InputRole = InputRole.SECONDARY): List<InputSurfaceElement> =
|
||||
InputLibraries.read(context).layout(profile, role)?.elements.orEmpty()
|
||||
|
||||
internal fun hasLegacy(context: Context, profile: String) = prefs(context).contains(profile)
|
||||
|
||||
internal fun readLegacy(context: Context, profile: String): List<InputSurfaceElement> {
|
||||
val text = prefs(context).getString(profile, null) ?: return defaults()
|
||||
return runCatching {
|
||||
val doc = JSONObject(text)
|
||||
require(doc.getInt("version") == 1)
|
||||
val elements = doc.getJSONArray("elements")
|
||||
(0 until minOf(elements.length(), 28)).mapNotNull { i ->
|
||||
val item = elements.optJSONObject(i) ?: return@mapNotNull null
|
||||
val control = InputControl.fromId(item.optString("control")) ?: return@mapNotNull null
|
||||
val values = listOf("x", "y", "w", "h").map { item.optDouble(it, Double.NaN).toFloat() }
|
||||
if (values.any { !it.isFinite() }) return@mapNotNull null
|
||||
InputSurfaceElement(control, values[0], values[1], values[2], values[3]).bounded()
|
||||
}.distinctBy { it.control }
|
||||
}.getOrElse { defaults() }
|
||||
}
|
||||
|
||||
fun save(context: Context, profile: String, layout: List<InputSurfaceElement>) {
|
||||
InputLibraries.update(context) { library ->
|
||||
val saved = library.layout(profile, InputRole.SECONDARY) ?: return@update library
|
||||
library.withLayout(saved.copy(elements = layout.map { it.bounded() }))
|
||||
}
|
||||
}
|
||||
|
||||
internal fun saveLegacy(context: Context, profile: String, layout: List<InputSurfaceElement>) {
|
||||
val array = JSONArray()
|
||||
for (element in layout.distinctBy { it.control }.take(28)) {
|
||||
val e = element.bounded()
|
||||
array.put(JSONObject().put("control", e.control.id).put("x", e.x.toDouble()).put("y", e.y.toDouble())
|
||||
.put("w", e.width.toDouble()).put("h", e.height.toDouble()))
|
||||
}
|
||||
prefs(context).edit().putString(profile, JSONObject().put("version", 1).put("elements", array).toString()).apply()
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import kotlin.math.abs
|
||||
|
||||
internal data class SurfaceSnapResult(val element: InputSurfaceElement, val x: Float? = null, val y: Float? = null)
|
||||
|
||||
internal fun snapSurfaceElement(element: InputSurfaceElement, others: List<InputSurfaceElement>, thresholdX: Float, thresholdY: Float,
|
||||
corner: Int = 0): SurfaceSnapResult {
|
||||
fun axis(start: Float, size: Float, targets: List<Float>, threshold: Float, low: Boolean, high: Boolean): Triple<Float, Float, Float?> {
|
||||
val anchors = when { low -> listOf(0f); high -> listOf(size); else -> listOf(0f, size / 2f, size) }
|
||||
val candidate = anchors.flatMap { anchor -> targets.map { target -> Triple(target - start - anchor, target, anchor) } }
|
||||
.filter { abs(it.first) <= threshold }.minByOrNull { abs(it.first) } ?: return Triple(start, size, null)
|
||||
val offset = candidate.first
|
||||
val nextStart = if (high) start else start + offset
|
||||
val nextSize = when { low -> size - offset; high -> size + offset; else -> size }
|
||||
if (nextStart < 0f || nextSize < .03f || nextStart + nextSize > 1.00001f) return Triple(start, size, null)
|
||||
return Triple(nextStart.coerceAtLeast(0f), nextSize, candidate.second)
|
||||
}
|
||||
val xTargets = listOf(0f, .5f, 1f) + others.flatMap { listOf(it.x, it.x + it.width / 2f, it.x + it.width) }
|
||||
val yTargets = listOf(0f, .5f, 1f) + others.flatMap { listOf(it.y, it.y + it.height / 2f, it.y + it.height) }
|
||||
val x = axis(element.x, element.width, xTargets, thresholdX, corner and 1 != 0, corner and 2 != 0)
|
||||
val y = axis(element.y, element.height, yTargets, thresholdY, corner and 4 != 0, corner and 8 != 0)
|
||||
return SurfaceSnapResult(element.copy(x = x.first, y = y.first, width = x.second, height = y.second).bounded(), x.third, y.third)
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
class InputSurfaceSource(private val pad: PadBridge, private val role: InputRole, val element: InputSurfaceElement) {
|
||||
private val router = pad.inputRouter
|
||||
private val route = router?.channel(role, element.control, element = element)
|
||||
private val deck = DeckControls.source(role, routed = true)
|
||||
private val clickDeck = DeckControls.source(role, routed = true)
|
||||
private val clickOwner = Any()
|
||||
private val clickRoute = if (element.hasClick) router?.channel(role, InputControl.A, element = element, elementClick = true) else null
|
||||
private val gestureWheel = if (element.mouseGestures) router?.channel(role, InputControl.RIGHT_PAD, fixedAction = InputAction.Scroll()) else null
|
||||
private val gestureRight = if (element.mouseGestures) router?.channel(role, InputControl.A, fixedAction = InputAction.MouseButton(273)) else null
|
||||
private val gestureMiddle = if (element.mouseGestures) router?.channel(role, InputControl.A, fixedAction = InputAction.MouseButton(274)) else null
|
||||
val mouseGestures get() = element.mouseGestures && route?.action is InputAction.Pointer
|
||||
val directions get() = route?.action as? InputAction.Directions
|
||||
|
||||
fun gestureContact(touching: Boolean) { if (!closed) route?.update(0f, 0f, touching) }
|
||||
fun pointerDelta(dx: Float, dy: Float) {
|
||||
if (closed) return
|
||||
val timing = InputTiming.begin()
|
||||
route?.pointerDelta(dx, dy)
|
||||
InputTiming.end(timing)
|
||||
}
|
||||
fun gestureWheel(steps: Int) { if (!closed && route?.acceptsPointerGesture() == true) gestureWheel?.wheelSteps(steps) }
|
||||
fun gestureButton(code: Int, down: Boolean) {
|
||||
if (closed || down && route?.acceptsPointerGesture() != true) return
|
||||
when (code) {
|
||||
272 -> click(down)
|
||||
273 -> gestureRight?.update(if (down) 1f else 0f)
|
||||
274 -> gestureMiddle?.update(if (down) 1f else 0f)
|
||||
}
|
||||
}
|
||||
|
||||
private var nativePad = false
|
||||
private var nativeDeck = false
|
||||
private var clickPad = false
|
||||
private var clickDeckActive = false
|
||||
private var closed = false
|
||||
private var fingerDown = false
|
||||
private var latchBlocked = false
|
||||
var latched = false
|
||||
private set
|
||||
var onStateChanged: () -> Unit = {}
|
||||
private fun resetLatch() { latched = false; latchBlocked = fingerDown; onStateChanged() }
|
||||
private val snapshot: () -> Unit = { releaseNative(); resetLatch(); if (element.toggle) route?.release() }
|
||||
|
||||
init { router?.beforeProfileChange(snapshot) }
|
||||
|
||||
fun update(value: Float, y: Float = 0f, touching: Boolean = false) {
|
||||
if (closed) return
|
||||
val timing = InputTiming.begin()
|
||||
val x = if (element.toggle && route?.action !is InputAction.Command && route?.action !is InputAction.Disabled) {
|
||||
val down = value > .5f
|
||||
if (!down) latchBlocked = false
|
||||
if (down && !fingerDown && !latchBlocked) latched = !latched
|
||||
fingerDown = down
|
||||
if (latched) 1f else 0f
|
||||
} else value
|
||||
val target = if (router == null) element.control else route?.update(x, y, touching)
|
||||
val toPad = target?.isPad == true
|
||||
val toDeck = target != null && !target.isPad
|
||||
if (toPad || nativePad) writePad(this, target) { state ->
|
||||
state.clear()
|
||||
if (toPad) {
|
||||
if (element.kind == InputElementKind.DPAD) {
|
||||
state.up = y < -.5f; state.down = y > .5f; state.left = x < -.5f; state.right = x > .5f
|
||||
} else state.add(target, x, y)
|
||||
}
|
||||
}
|
||||
if (toDeck || nativeDeck) deck.native(if (toDeck) target else null, x, y, touching)
|
||||
nativePad = toPad; nativeDeck = toDeck
|
||||
if (element.toggle) onStateChanged()
|
||||
InputTiming.end(timing)
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
if (closed) return
|
||||
route?.takeIf { it.action is InputAction.Command }?.release()
|
||||
update(0f)
|
||||
}
|
||||
|
||||
fun click(down: Boolean) {
|
||||
if (closed) return
|
||||
val timing = InputTiming.begin()
|
||||
val target = if (router == null) when (element.control) { InputControl.LS -> InputControl.L3; InputControl.RS -> InputControl.R3; else -> null } else clickRoute?.update(if (down) 1f else 0f)
|
||||
val toPad = target?.isPad == true
|
||||
val toDeck = target != null && !target.isPad
|
||||
if (toPad || clickPad) writePad(clickOwner, target) { state ->
|
||||
state.clear()
|
||||
if (toPad && down) state.add(target, 1f)
|
||||
}
|
||||
if (toDeck || clickDeckActive) clickDeck.native(if (toDeck) target else null, if (down) 1f else 0f, 0f, false)
|
||||
clickPad = toPad; clickDeckActive = toDeck
|
||||
InputTiming.end(timing)
|
||||
}
|
||||
|
||||
private fun writePad(owner: Any, target: InputControl?, mutation: (PadState) -> Unit) {
|
||||
if (router != null) pad.applyRoutedTouch(owner, role, mutation)
|
||||
else pad.applyTouch(owner, role, when (target) { InputControl.LS -> PadBridge.LEFT_STICK; InputControl.RS -> PadBridge.RIGHT_STICK; else -> 0 }, mutation)
|
||||
}
|
||||
|
||||
private fun releaseNative() {
|
||||
if (nativePad) pad.releaseTouch(this)
|
||||
if (nativeDeck) deck.release()
|
||||
if (clickPad) pad.releaseTouch(clickOwner)
|
||||
if (clickDeckActive) clickDeck.release()
|
||||
clickPad = false; clickDeckActive = false
|
||||
nativePad = false; nativeDeck = false
|
||||
}
|
||||
|
||||
fun close() {
|
||||
if (closed) return
|
||||
closed = true
|
||||
router?.removeSnapshot(snapshot)
|
||||
releaseNative(); resetLatch()
|
||||
route?.close(); clickRoute?.close(); gestureWheel?.close(); gestureRight?.close(); gestureMiddle?.close(); deck.close(); clickDeck.close()
|
||||
}
|
||||
|
||||
fun release() {
|
||||
if (closed) return
|
||||
releaseNative(); resetLatch()
|
||||
route?.release(); clickRoute?.release(); gestureWheel?.release(); gestureRight?.release(); gestureMiddle?.release()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
object InputTiming {
|
||||
@JvmField @Volatile var enabled = false
|
||||
private val samples = LongArray(4096)
|
||||
private var count = 0L
|
||||
private var next = 0
|
||||
|
||||
@JvmStatic fun begin(): Long = if (enabled) System.nanoTime() else 0L
|
||||
|
||||
@JvmStatic fun end(start: Long) {
|
||||
if (start == 0L) return
|
||||
val elapsed = System.nanoTime() - start
|
||||
synchronized(this) {
|
||||
samples[next] = elapsed
|
||||
next = (next + 1) % samples.size
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized fun reset() { count = 0; next = 0; samples.fill(0) }
|
||||
|
||||
@Synchronized fun report(): String {
|
||||
val size = minOf(count, samples.size.toLong()).toInt()
|
||||
if (size == 0) return "No input samples"
|
||||
val values = samples.copyOf(size).sortedArray()
|
||||
fun percentile(p: Double) = values[((size - 1) * p).toInt()] / 1000.0
|
||||
return String.format(Locale.ROOT, "Dispatch to publication (µs): p50 %.1f, p95 %.1f, p99 %.1f; %d samples, last %d retained",
|
||||
percentile(.5), percentile(.95), percentile(.99), count, size)
|
||||
}
|
||||
}
|
||||
@@ -1,352 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import kotlin.math.*
|
||||
|
||||
internal class LegacyGamepadLayout(private val context: Context, private val width: Int, private val height: Int, private val safe: Rect = Rect(),
|
||||
private val scale: Float = 1f) {
|
||||
private val resources get() = context.resources
|
||||
private val settings = ControllerPrefs.read(context)
|
||||
private var fit = 1f
|
||||
private class Control(val id: String, val group: String, val stick: Int, val radiusDp: Float, val offsetXDp: Float, val offsetYDp: Float) {
|
||||
var radius = 0f
|
||||
var cx = 0f
|
||||
var cy = 0f
|
||||
val halfW get() = if (id in shoulderIds) radius * SHOULDER_WIDTH else radius
|
||||
val halfH get() = if (id in shoulderIds) radius * SHOULDER_HEIGHT else radius
|
||||
}
|
||||
private val controls = listOf(
|
||||
Control("up", "dpad", -1, 30f, 0f, -50f),
|
||||
Control("right", "dpad", -1, 30f, 50f, 0f),
|
||||
Control("down", "dpad", -1, 30f, 0f, 50f),
|
||||
Control("left", "dpad", -1, 30f, -50f, 0f),
|
||||
Control("ls", "ls", 0, 48f, 0f, 0f),
|
||||
Control("rs", "rs", 1, 48f, 0f, 0f),
|
||||
Control("a", "face", -1, 30f, 0f, 50f),
|
||||
Control("b", "face", -1, 30f, 50f, 0f),
|
||||
Control("x", "face", -1, 30f, -50f, 0f),
|
||||
Control("y", "face", -1, 30f, 0f, -50f),
|
||||
Control("lb", "lb", -1, 24f, 0f, 0f),
|
||||
Control("rb", "rb", -1, 24f, 0f, 0f),
|
||||
Control("lt", "lt", -1, 24f, 0f, 0f),
|
||||
Control("rt", "rt", -1, 24f, 0f, 0f),
|
||||
Control("select", "select", -1, 20f, 0f, 0f),
|
||||
Control("start", "start", -1, 20f, 0f, 0f),
|
||||
Control("guide", "guide", -1, 22f, 0f, 0f),
|
||||
Control("qam", "qam", -1, 22f, 0f, 0f),
|
||||
)
|
||||
|
||||
private val groups = controls.map { it.group }.distinct()
|
||||
|
||||
private fun dp(value: Float) = value * resources.displayMetrics.density
|
||||
|
||||
private fun scaled(value: Float) = dp(value) * settings.size / 100f * scale * fit
|
||||
|
||||
private fun fitScale(): Float {
|
||||
val metrics = resources.displayMetrics
|
||||
val heightMm = (height - safe.top - safe.bottom) / pxPerMm(metrics.ydpi)
|
||||
val faceMm = dp(60f) / pxPerMm(metrics.xdpi)
|
||||
val floor = (MIN_FACE_MM / faceMm).coerceAtMost(1f)
|
||||
return (heightMm / FULL_SIZE_HEIGHT_MM).coerceIn(floor, 1f)
|
||||
}
|
||||
|
||||
private fun pxPerMm(dpi: Float): Float {
|
||||
val nominal = resources.displayMetrics.densityDpi.toFloat()
|
||||
return (if (dpi > nominal * 0.6f && dpi < nominal * 1.6f) dpi else nominal) / 25.4f
|
||||
}
|
||||
|
||||
private fun placeAuto(w: Float, h: Float) {
|
||||
val metrics = resources.displayMetrics
|
||||
val mmX = pxPerMm(metrics.xdpi)
|
||||
val mmY = pxPerMm(metrics.ydpi)
|
||||
val usableW = w - safe.left - safe.right
|
||||
val usableH = h - safe.top - safe.bottom
|
||||
val inboard = (0.17f * usableW / mmX).coerceIn(24f, 40f) * mmX
|
||||
val lift = (0.285f * usableH / mmY).coerceIn(22f, 45f) * mmY
|
||||
val gap = dp(12f)
|
||||
val floor = BOTTOM_MARGIN_MM * mmY
|
||||
val drop = max(drop("dpad", "ls", gap), drop("rs", "face", gap))
|
||||
val restY = min(h - safe.bottom - lift, h - safe.bottom - floor - drop)
|
||||
put("ls", safe.left + inboard, restY)
|
||||
put("face", w - safe.right - inboard, restY)
|
||||
besideBelow("dpad", "ls", 1f, gap, floor)
|
||||
besideBelow("rs", "face", -1f, gap, floor)
|
||||
val phone = usableH / mmY < PHONE_MAX_HEIGHT_MM
|
||||
val shoulderY = if (phone) safe.top + floor + halfH("lb")
|
||||
else min(centre("ls").second - extent("ls"), centre("face").second - extent("face")) - gap - halfH("lb")
|
||||
shoulders("ls", "lb", "lt", 1f, gap, shoulderY)
|
||||
shoulders("face", "rb", "rt", -1f, gap, shoulderY)
|
||||
val bottom = h - safe.bottom - floor - extent("guide")
|
||||
val spread = extent("guide") + gap / 2f
|
||||
put("guide", w / 2f - spread, bottom)
|
||||
put("qam", w / 2f + spread, bottom)
|
||||
val inner = max(centre("dpad").first + extent("dpad"), w - centre("rs").first + extent("rs")) + gap + extent("select")
|
||||
put("select", inner, restY)
|
||||
put("start", w - inner, restY)
|
||||
if (phone) {
|
||||
val top = max(centre("lb").first + extent("lb"), w - centre("rb").first + extent("rb")) + gap * 2.5f + extent("select")
|
||||
put("select", top, shoulderY)
|
||||
put("start", w - top, shoulderY)
|
||||
}
|
||||
if (crowded("select", gap) || crowded("start", gap)) {
|
||||
above("select", "dpad", gap)
|
||||
val (sx, sy) = centre("select")
|
||||
put("start", w - sx, sy)
|
||||
if (crowded("start", gap)) above("start", "rs", gap)
|
||||
}
|
||||
if (crowded("guide", gap, gap, "qam") || crowded("qam", gap, gap, "guide")) {
|
||||
val row = centre("select").second
|
||||
put("guide", w / 2f - spread, row)
|
||||
put("qam", w / 2f + spread, row)
|
||||
if (crowded("guide", gap, gap, "qam") || crowded("qam", gap, gap, "guide")) {
|
||||
val top = safe.top + dp(8f) + extent("guide")
|
||||
put("guide", w / 2f - spread, top)
|
||||
put("qam", w / 2f + spread, top)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun drop(group: String, anchor: String, gap: Float): Float =
|
||||
(extent(anchor) + extent(group) + gap) * sin(Math.toRadians(DROP_DEGREES)).toFloat() + extent(group)
|
||||
|
||||
private fun besideBelow(group: String, anchor: String, inward: Float, gap: Float, margin: Float) {
|
||||
val (ax, ay) = centre(anchor)
|
||||
val reach = extent(anchor) + extent(group) + gap
|
||||
val floor = height - safe.bottom - margin - extent(group)
|
||||
val dy = min(reach * sin(Math.toRadians(DROP_DEGREES)).toFloat(), floor - ay)
|
||||
val dx = sqrt(max(0f, reach * reach - dy * dy))
|
||||
put(group, ax + inward * dx, ay + dy)
|
||||
}
|
||||
|
||||
private fun shoulders(anchor: String, bumper: String, trigger: String, inward: Float, gap: Float, y: Float) {
|
||||
val (ax, _) = centre(anchor)
|
||||
val spread = extent(bumper) + gap / 2f
|
||||
put(bumper, ax + inward * spread, y)
|
||||
put(trigger, ax - inward * spread, y)
|
||||
}
|
||||
|
||||
private fun above(group: String, anchor: String, gap: Float) {
|
||||
val (ax, ay) = centre(anchor)
|
||||
put(group, ax, ay - extent(anchor) - gap - extent(group))
|
||||
}
|
||||
|
||||
private fun crowded(group: String, gap: Float, space: Float = gap / 2f, except: String? = null): Boolean {
|
||||
val (x, y) = centre(group)
|
||||
return groups.any { other ->
|
||||
if (other == group || other == except) return@any false
|
||||
val (ox, oy) = centre(other)
|
||||
val dx = x - ox
|
||||
val dy = y - oy
|
||||
sqrt(dx * dx + dy * dy) < extent(group) + extent(other) + space
|
||||
}
|
||||
}
|
||||
|
||||
private fun members(group: String) = controls.filter { it.group == group }
|
||||
|
||||
private fun centre(group: String): Pair<Float, Float> {
|
||||
val first = members(group).first()
|
||||
return (first.cx - scaled(first.offsetXDp)) to (first.cy - scaled(first.offsetYDp))
|
||||
}
|
||||
|
||||
private fun extent(group: String): Float = members(group).maxOf { control ->
|
||||
sqrt(scaled(control.offsetXDp).let { it * it } + scaled(control.offsetYDp).let { it * it }) + control.halfW
|
||||
}
|
||||
|
||||
private fun halfH(group: String): Float = members(group).maxOf { abs(scaled(it.offsetYDp)) + it.halfH }
|
||||
|
||||
private fun put(group: String, x: Float, y: Float) {
|
||||
for (control in members(group)) {
|
||||
control.cx = x + scaled(control.offsetXDp)
|
||||
control.cy = y + scaled(control.offsetYDp)
|
||||
}
|
||||
}
|
||||
|
||||
private fun clamp(group: String) {
|
||||
val list = members(group)
|
||||
val margin = dp(4f)
|
||||
val minX = list.minOf { it.cx - it.halfW } - safe.left - margin
|
||||
val maxX = list.maxOf { it.cx + it.halfW } - (width - safe.right - margin)
|
||||
val minY = list.minOf { it.cy - it.halfH } - safe.top - margin
|
||||
val maxY = list.maxOf { it.cy + it.halfH } - (height - safe.bottom - margin)
|
||||
val dx = if (minX < 0) -minX else if (maxX > 0) -maxX else 0f
|
||||
val dy = if (minY < 0) -minY else if (maxY > 0) -maxY else 0f
|
||||
if (dx == 0f && dy == 0f) return
|
||||
for (control in list) { control.cx += dx; control.cy += dy }
|
||||
}
|
||||
|
||||
|
||||
fun elements(): List<InputSurfaceElement> {
|
||||
fit = fitScale()
|
||||
controls.forEach { it.radius = scaled(it.radiusDp) }
|
||||
placeAuto(width.toFloat(), height.toFloat())
|
||||
for ((group, position) in ControllerPrefs.layout(context, width, height)) {
|
||||
if (group in groups && position.first.isFinite() && position.second.isFinite()) put(group, position.first * width, position.second * height)
|
||||
}
|
||||
groups.forEach(::clamp)
|
||||
val result = controls.filter {
|
||||
settings.mapping[it.id] != ControllerPrefs.OFF && (it.id != "guide" || settings.steamButton) && (it.id != "qam" || settings.qamButton)
|
||||
}.map { c ->
|
||||
InputSurfaceElement(requireNotNull(InputControl.fromId(c.id)), (c.cx - c.halfW) / width, (c.cy - c.halfH) / height,
|
||||
2 * c.halfW / width, 2 * c.halfH / height, floating = settings.adaptiveSticks, hideWhenIdle = c.stick >= 0 && settings.adaptiveSticks,
|
||||
buttonShape = if (c.id in shoulderIds) InputButtonShape.PILL else InputButtonShape.CIRCLE).let(::widenStick).bounded()
|
||||
}.toMutableList()
|
||||
if (settings.keyboardButton) result += keyboard()
|
||||
return result
|
||||
}
|
||||
|
||||
fun regenerate(stored: List<InputSurfaceElement>): List<InputSurfaceElement> = withGeometry(stored, elements())
|
||||
|
||||
fun rescale(stored: List<InputSurfaceElement>, from: LegacyGamepadLayout): List<InputSurfaceElement> {
|
||||
val before = from.elements().associateBy { it.id }
|
||||
val after = elements().associateBy { it.id }
|
||||
return stored.map { e ->
|
||||
val b = before[e.id] ?: return@map e
|
||||
val a = after[e.id] ?: return@map e
|
||||
val w = (a.width * e.width / b.width).coerceIn(.03f, 1f)
|
||||
val h = (a.height * e.height / b.height).coerceIn(.03f, 1f)
|
||||
val cx = a.x + a.width / 2 + (e.x + e.width / 2 - b.x - b.width / 2)
|
||||
val cy = a.y + a.height / 2 + (e.y + e.height / 2 - b.y - b.height / 2)
|
||||
e.copy(x = (cx - w / 2).coerceIn(0f, 1f - w), y = (cy - h / 2).coerceIn(0f, 1f - h), width = w, height = h).bounded()
|
||||
}
|
||||
}
|
||||
|
||||
fun buttonsOnly(stored: List<InputSurfaceElement>): List<InputSurfaceElement> {
|
||||
val inset = dp(BUTTONS_ONLY_INSET_DP)
|
||||
val bottom = height - safe.bottom - inset
|
||||
val radius = dp(BUTTONS_ONLY_RADIUS_DP)
|
||||
fun pick(control: InputControl, shown: Boolean) = stored.firstOrNull { it.id == "legacy:${control.id}" }
|
||||
?: stored.firstOrNull { it.control == control && it.kind == InputElementKind.BUTTON }
|
||||
?: InputSurfaceElement(control, 0f, 0f, 0f, 0f, buttonShape = InputButtonShape.CIRCLE).takeIf { shown }
|
||||
return listOfNotNull(
|
||||
pick(InputControl.GUIDE, settings.steamButton)?.let { circle(it, safe.left + inset, bottom, radius) },
|
||||
pick(InputControl.QAM, settings.qamButton)?.let { circle(it, width - safe.right - inset, bottom, radius) },
|
||||
stored.firstOrNull { it.id == KEYBOARD_ID }?.let { circle(it, width / 2f, bottom, dp(KEYBOARD_RADIUS_DP)) },
|
||||
)
|
||||
}
|
||||
|
||||
fun dock(stored: List<InputSurfaceElement>, portrait: Boolean, grips: Boolean, left: InputControl = InputControl.LS,
|
||||
right: InputControl = InputControl.RS): List<InputSurfaceElement> {
|
||||
val boxes = GamepadGeometry.layout(width.toFloat(), height.toFloat(), resources.displayMetrics.density, portrait)
|
||||
val placed = mutableListOf<InputSurfaceElement>()
|
||||
fun box(control: InputControl, slot: GamepadGeometry.Slot, shape: InputButtonShape) {
|
||||
val b = boxes[slot] ?: return
|
||||
placed += InputSurfaceElement(control, b.left / width, b.top / height, b.width / width, b.height / height,
|
||||
floating = false, buttonShape = shape).bounded()
|
||||
}
|
||||
fun cross(controls: List<InputControl>, slot: GamepadGeometry.Slot) {
|
||||
val b = boxes[slot] ?: return
|
||||
val side = min(b.width, b.height)
|
||||
val spread = side * CROSS_SPREAD
|
||||
val radius = side * CROSS_RADIUS
|
||||
controls.zip(listOf(0f to -1f, 1f to 0f, 0f to 1f, -1f to 0f)).forEach { (control, direction) ->
|
||||
placed += circle(InputSurfaceElement(control, 0f, 0f, 0f, 0f, buttonShape = InputButtonShape.CIRCLE),
|
||||
b.cx + direction.first * spread, b.cy + direction.second * spread, radius)
|
||||
}
|
||||
}
|
||||
box(left, GamepadGeometry.Slot.LEFT_MAIN, InputButtonShape.CIRCLE)
|
||||
cross(DPAD_ORDER, GamepadGeometry.Slot.LEFT_SECOND)
|
||||
box(right, GamepadGeometry.Slot.RIGHT_MAIN, InputButtonShape.CIRCLE)
|
||||
cross(FACE_ORDER, GamepadGeometry.Slot.RIGHT_SECOND)
|
||||
box(InputControl.LT, GamepadGeometry.Slot.LEFT_OUTER_SHOULDER, InputButtonShape.PILL)
|
||||
box(InputControl.LB, GamepadGeometry.Slot.LEFT_INNER_SHOULDER, InputButtonShape.PILL)
|
||||
box(InputControl.RB, GamepadGeometry.Slot.RIGHT_INNER_SHOULDER, InputButtonShape.PILL)
|
||||
box(InputControl.RT, GamepadGeometry.Slot.RIGHT_OUTER_SHOULDER, InputButtonShape.PILL)
|
||||
box(InputControl.SELECT, GamepadGeometry.Slot.VIEW, InputButtonShape.PILL)
|
||||
box(InputControl.START, GamepadGeometry.Slot.MENU, InputButtonShape.PILL)
|
||||
if (grips) GRIPS.forEach { (control, slot) -> box(control, slot, InputButtonShape.ROUNDED) }
|
||||
val surfaces = listOf(left, right).filter { it.isTrackpad }
|
||||
return withGeometry(stored, placed) + placed.filter { p -> (GRIPS.any { it.first == p.control } || p.control in surfaces) && stored.none { it.id == p.id } }
|
||||
}
|
||||
|
||||
private fun circle(e: InputSurfaceElement, cx: Float, cy: Float, radius: Float) =
|
||||
e.copy(x = (cx - radius) / width, y = (cy - radius) / height, width = 2 * radius / width, height = 2 * radius / height).bounded()
|
||||
|
||||
private fun withGeometry(stored: List<InputSurfaceElement>, placed: List<InputSurfaceElement>): List<InputSurfaceElement> {
|
||||
val byId = placed.associateBy { it.id }
|
||||
return stored.mapNotNull { e -> byId[e.id]?.let { e.copy(x = it.x, y = it.y, width = it.width, height = it.height) } }
|
||||
}
|
||||
|
||||
private fun keyboard(): InputSurfaceElement {
|
||||
val radius = dp(KEYBOARD_RADIUS_DP)
|
||||
val cx = safe.left + dp(KEYBOARD_X_DP)
|
||||
val cy = height - safe.bottom - dp(KEYBOARD_Y_DP)
|
||||
return InputSurfaceElement(InputControl.A, (cx - radius) / width, (cy - radius) / height, 2 * radius / width, 2 * radius / height,
|
||||
KEYBOARD_ID, name = "Keyboard", icon = InputButtonIcons.KEYBOARD, buttonShape = InputButtonShape.CIRCLE).bounded()
|
||||
}
|
||||
|
||||
fun restyle(elements: List<InputSurfaceElement>): List<InputSurfaceElement> {
|
||||
val dpad = elements.filter { it.id == "legacy:${it.control.id}" && it.control in DPAD && it.buttonShape == InputButtonShape.ROUNDED }
|
||||
val centreX = dpad.map { (it.x + it.width / 2f) * width }.average().toFloat()
|
||||
val centreY = dpad.map { (it.y + it.height / 2f) * height }.average().toFloat()
|
||||
val resize = untouchedDpad(dpad, centreX, centreY)
|
||||
return elements.map { e ->
|
||||
when {
|
||||
e.id == KEYBOARD_ID && e.icon == 0 && e.name == "Keyboard" && e.buttonShape == InputButtonShape.ROUNDED &&
|
||||
e.x == .02f && e.y == .9f && e.width == .12f && e.height == .08f -> keyboard()
|
||||
e.id == "legacy:${e.control.id}" && e.buttonShape == InputButtonShape.ROUNDED -> {
|
||||
val round = e.copy(buttonShape = if (e.control.id in shoulderIds) InputButtonShape.PILL else InputButtonShape.CIRCLE)
|
||||
(if (resize && e.control in DPAD) resizeDpad(round, centreX, centreY) else widenStick(round)).bounded()
|
||||
}
|
||||
else -> e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun untouchedDpad(dpad: List<InputSurfaceElement>, centreX: Float, centreY: Float): Boolean {
|
||||
if (dpad.size != DPAD.size) return false
|
||||
val radius = dpad[0].width * width / 2f
|
||||
val tolerance = 1.5f
|
||||
val offset = radius * LEGACY_DPAD_OFFSET_DP / LEGACY_DPAD_RADIUS_DP
|
||||
return dpad.all { e ->
|
||||
val old = controls.first { it.id == e.control.id }
|
||||
val dx = (e.x + e.width / 2f) * width - centreX
|
||||
val dy = (e.y + e.height / 2f) * height - centreY
|
||||
abs(e.width * width / 2f - radius) <= tolerance && abs(e.height * height / 2f - radius) <= tolerance &&
|
||||
abs(dx - offset * sign(old.offsetXDp)) <= tolerance && abs(dy - offset * sign(old.offsetYDp)) <= tolerance
|
||||
}
|
||||
}
|
||||
|
||||
private fun resizeDpad(e: InputSurfaceElement, centreX: Float, centreY: Float): InputSurfaceElement {
|
||||
val old = controls.first { it.id == e.control.id }
|
||||
val radius = e.width * width / 2f * old.radiusDp / LEGACY_DPAD_RADIUS_DP
|
||||
val cx = centreX + ((e.x + e.width / 2f) * width - centreX) * abs(old.offsetXDp + old.offsetYDp) / LEGACY_DPAD_OFFSET_DP
|
||||
val cy = centreY + ((e.y + e.height / 2f) * height - centreY) * abs(old.offsetXDp + old.offsetYDp) / LEGACY_DPAD_OFFSET_DP
|
||||
return e.copy(x = (cx - radius) / width, y = (cy - radius) / height, width = 2 * radius / width, height = 2 * radius / height)
|
||||
}
|
||||
|
||||
private fun widenStick(e: InputSurfaceElement): InputSurfaceElement {
|
||||
val extra = e.width * (STICK_REACH - 1f)
|
||||
return when (e.control) {
|
||||
InputControl.LS -> (e.x - extra).coerceAtLeast(0f).let { x -> e.copy(x = x, width = e.x + e.width - x) }
|
||||
InputControl.RS -> e.copy(width = (e.width + extra).coerceAtMost(1f - e.x))
|
||||
else -> e
|
||||
}
|
||||
}
|
||||
private companion object {
|
||||
const val MIN_FACE_MM = 9.5f
|
||||
const val FULL_SIZE_HEIGHT_MM = 85f
|
||||
const val BOTTOM_MARGIN_MM = 5f
|
||||
const val DROP_DEGREES = 35.0
|
||||
const val PHONE_MAX_HEIGHT_MM = 90f
|
||||
const val SHOULDER_WIDTH = 1.45f
|
||||
const val SHOULDER_HEIGHT = 0.8f
|
||||
val shoulderIds = setOf("lb", "rb", "lt", "rt")
|
||||
const val STICK_REACH = 1.25f
|
||||
const val LEGACY_DPAD_RADIUS_DP = 26f
|
||||
const val LEGACY_DPAD_OFFSET_DP = 52f
|
||||
val DPAD = setOf(InputControl.UP, InputControl.RIGHT, InputControl.DOWN, InputControl.LEFT)
|
||||
const val KEYBOARD_ID = "builtin:keyboard"
|
||||
const val KEYBOARD_RADIUS_DP = 18f
|
||||
const val KEYBOARD_X_DP = 90f
|
||||
const val KEYBOARD_Y_DP = 34f
|
||||
const val BUTTONS_ONLY_RADIUS_DP = 30f
|
||||
const val BUTTONS_ONLY_INSET_DP = 44f
|
||||
const val CROSS_SPREAD = .30f
|
||||
const val CROSS_RADIUS = .16f
|
||||
val DPAD_ORDER = listOf(InputControl.UP, InputControl.RIGHT, InputControl.DOWN, InputControl.LEFT)
|
||||
val FACE_ORDER = listOf(InputControl.Y, InputControl.B, InputControl.A, InputControl.X)
|
||||
val GRIPS = listOf(InputControl.L4 to GamepadGeometry.Slot.L4, InputControl.L5 to GamepadGeometry.Slot.L5,
|
||||
InputControl.R5 to GamepadGeometry.Slot.R5, InputControl.R4 to GamepadGeometry.Slot.R4)
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.util.DisplayMetrics
|
||||
import android.view.Display
|
||||
import org.json.JSONObject
|
||||
|
||||
internal object LegacyScreenKeyMigration {
|
||||
const val BACKUP = "legacy-screen-keys-backup"
|
||||
const val MERGED = "legacy-screen-keys-merged"
|
||||
private const val SHOWN = "migrated.screenkeys.0"
|
||||
|
||||
fun snapshot(context: Context): String? {
|
||||
val values = ControllerPrefs.prefs(context).all.filterKeys { it == "screenKeys" || it == "screenKeysShown" || it.startsWith("screenLayout.") }
|
||||
return values.takeIf { it.isNotEmpty() }?.let { JSONObject(it).toString() }
|
||||
}
|
||||
|
||||
fun clean(context: Context) {
|
||||
val prefs = ControllerPrefs.prefs(context)
|
||||
prefs.edit().apply { prefs.all.keys.filter { it == "screenKeys" || it == "screenKeysShown" || it.startsWith("screenLayout.") }.forEach(::remove) }.apply()
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
fun migrate(context: Context, library: InputLibrary, raw: String): InputLibrary {
|
||||
val old = JSONObject(raw)
|
||||
val display = context.getSystemService(DisplayManager::class.java)?.getDisplay(Display.DEFAULT_DISPLAY)
|
||||
val metrics = DisplayMetrics().also { if (display != null) display.getRealMetrics(it) else it.setTo(context.resources.displayMetrics) }
|
||||
val settings = ControllerPrefs.read(context)
|
||||
val style = InputSurfaceStyle(settings.tint, settings.opacity)
|
||||
val sets = buildList {
|
||||
old.optString("screenKeys").takeIf { it.isNotBlank() }?.let { add("Screen keys (imported)" to it) }
|
||||
old.keys().asSequence().filter { it.startsWith("screenLayout.") }.sorted().forEach { key ->
|
||||
add((key.removePrefix("screenLayout.").ifBlank { "Screen keys" } + " (imported)").take(80) to old.getString(key))
|
||||
}
|
||||
}
|
||||
var result = library
|
||||
for ((index, set) in sets.withIndex()) {
|
||||
val id = "migrated.screenkeys.$index"
|
||||
if (result.profiles.any { it.id == id }) continue
|
||||
val converted = convert(context, set.second, metrics.widthPixels, metrics.heightPixels, metrics.density)
|
||||
if (converted.isEmpty()) continue
|
||||
val layout = SavedInputLayout("$id.layout", set.first, converted.map { it.first.copy(alwaysShown = true) }, style = style, screen = InputLayoutScreen.MAIN,
|
||||
aspect = (metrics.widthPixels.coerceAtLeast(1).toFloat() / metrics.heightPixels.coerceAtLeast(1)).coerceIn(.05f, 20f))
|
||||
val bindings = SavedInputBindings("$id.bindings", set.first, false, layout.id, elements = converted.associate { it.first.id to it.second })
|
||||
val base = library.profile(library.steam)
|
||||
val profile = SavedInputProfile(id, set.first, base.base, base.parts + (InputRole.PRIMARY to InputPart(bindings.id, layout.id)))
|
||||
result = result.withLayout(layout).withPreset(bindings).withProfile(profile)
|
||||
}
|
||||
return result.validate()
|
||||
}
|
||||
|
||||
fun merge(library: InputLibrary, raw: String): InputLibrary {
|
||||
val old = JSONObject(raw)
|
||||
if (!old.optBoolean("screenKeysShown", true) || old.optString("screenKeys").isBlank()) return library
|
||||
val keys = library.layouts.firstOrNull { it.id == "$SHOWN.layout" } ?: return library
|
||||
val actions = library.bindings.firstOrNull { it.id == "$SHOWN.bindings" }?.elements.orEmpty()
|
||||
var result = library
|
||||
for (part in listOf(false, true).map { library.activePart(it, InputRole.PRIMARY) }.distinct()) {
|
||||
val layout = result.layouts.firstOrNull { it.id == part.layout } ?: continue
|
||||
val preset = result.preset(part.bindings)
|
||||
if (!layout.editable || preset.layoutId != layout.id || layout.elements.any { e -> keys.elements.any { it.id == e.id } } ||
|
||||
layout.elements.size + keys.elements.size > 64) continue
|
||||
val added = keys.elements.mapNotNull { e -> actions[e.id]?.let { e.id to it } }.toMap()
|
||||
result = result.withLayout(layout.copy(elements = layout.elements + keys.elements))
|
||||
.withPreset(preset.copy(elements = added + preset.elements))
|
||||
}
|
||||
return result.validate()
|
||||
}
|
||||
|
||||
fun pin(library: InputLibrary): InputLibrary {
|
||||
val keys = library.layouts.firstOrNull { it.id == "$SHOWN.layout" }?.elements.orEmpty()
|
||||
return library.copy(layouts = library.layouts.map { layout ->
|
||||
val imported = layout.id.startsWith("migrated.screenkeys.")
|
||||
layout.copy(elements = layout.elements.map { e ->
|
||||
val key = e.id.startsWith("imported:") && (imported || keys.any { it.id == e.id && it.kind == e.kind && it.name == e.name })
|
||||
if (key) e.copy(alwaysShown = true) else e
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
internal fun convert(context: Context, raw: String, width: Int, height: Int, density: Float): List<Pair<InputSurfaceElement, InputAction>> =
|
||||
convert(context, parse(raw), width, height, density)
|
||||
|
||||
internal fun parse(raw: String): List<LegacyScreenKey> = raw.split(';').filter { it.isNotBlank() }.map { entry ->
|
||||
val parts = entry.split(':')
|
||||
require(parts.size >= 2) { "Invalid legacy screen key" }
|
||||
val codes = parts[0].split('+').map { it.toInt() }
|
||||
val position = parts[1].split(',').map { it.toFloat() }
|
||||
require(position.size == 3)
|
||||
fun text(at: Int) = parts.getOrNull(at).orEmpty().replace("%2C", ",").replace("%3A", ":").replace("%3B", ";").replace("%25", "%")
|
||||
LegacyScreenKey(codes, position[0], position[1], position[2], text(2), text(3), text(4),
|
||||
parts.getOrNull(5)?.toFloatOrNull() ?: 1f, parts.getOrNull(6)?.toIntOrNull() ?: 0, parts.getOrNull(7) == "1", parts.getOrNull(8) != "0")
|
||||
}
|
||||
|
||||
internal fun convert(context: Context, keys: List<LegacyScreenKey>, width: Int, height: Int, density: Float): List<Pair<InputSurfaceElement, InputAction>> =
|
||||
keys.mapIndexed { index, key ->
|
||||
val codes = key.codes
|
||||
require(codes.all { it in 0..255 })
|
||||
require(listOf(key.x, key.y, key.size).all { it.isFinite() })
|
||||
val shape = key.shape
|
||||
val kind = when (shape) { 5 -> InputElementKind.DPAD; 6 -> InputElementKind.STICK; 4 -> InputElementKind.TRIGGER; else -> InputElementKind.BUTTON }
|
||||
val label = key.text.ifEmpty { key.glyph }.ifEmpty { codes.filter { it != 0 }.joinToString("+") { pcKeyLabel(context, it) } }.ifBlank { "Button" }.take(80)
|
||||
val image = key.image
|
||||
val icon = codes.singleOrNull()?.takeIf { InputButtonIcons.has(it) && key.text.isEmpty() && key.glyph.isEmpty() } ?: 0
|
||||
val heightPx = key.size.coerceIn(24f, 140f) * density
|
||||
val ratio = when { shape == 2 -> 1f; shape == 4 -> 1.7f; shape == 5 || shape == 6 || image.isNotEmpty() || icon != 0 -> 1.15f; else -> (.55f * label.length + 1.05f).coerceIn(1.7f, 4.6f) }
|
||||
val w = (heightPx * ratio / width.coerceAtLeast(1)).coerceIn(.03f, 1f)
|
||||
val h = (heightPx / height.coerceAtLeast(1)).coerceIn(.03f, 1f)
|
||||
val element = InputSurfaceElement(kind.control, (key.x - w / 2).coerceIn(0f, 1f - w), (key.y - h / 2).coerceIn(0f, 1f - h), w, h,
|
||||
id = "imported:$index", kind = kind, name = label, floating = false,
|
||||
toggle = key.toggle && kind != InputElementKind.DPAD && kind != InputElementKind.STICK,
|
||||
icon = icon, image = image, opacity = key.opacity.coerceIn(.2f, 1f),
|
||||
buttonShape = when (shape) { 0 -> InputButtonShape.PILL; 2 -> InputButtonShape.CIRCLE; 3 -> InputButtonShape.RECTANGLE; else -> InputButtonShape.ROUNDED },
|
||||
showLabel = key.labels).bounded()
|
||||
val action = when (kind) {
|
||||
InputElementKind.DPAD -> InputAction.Directions(codes.getOrElse(0) { 0 }, codes.getOrElse(1) { 0 }, codes.getOrElse(2) { 0 }, codes.getOrElse(3) { 0 })
|
||||
InputElementKind.STICK -> InputAction.Directions(codes.getOrElse(0) { 0 }, codes.getOrElse(2) { 0 }, codes.getOrElse(4) { 0 }, codes.getOrElse(6) { 0 }, listOf(1, 3, 5, 7).map { codes.getOrElse(it) { 0 } })
|
||||
else -> codes.filter { it != 0 }.distinct().let { if (it.isEmpty()) InputAction.Disabled else InputAction.Keys(it) }
|
||||
}
|
||||
require(action.validFor(element))
|
||||
element to action
|
||||
}
|
||||
}
|
||||
|
||||
internal data class LegacyScreenKey(
|
||||
val codes: List<Int>, val x: Float, val y: Float, val size: Float,
|
||||
val glyph: String = "", val text: String = "", val image: String = "",
|
||||
val opacity: Float = 1f, val shape: Int = 0, val toggle: Boolean = false, val labels: Boolean = true,
|
||||
)
|
||||
@@ -3,181 +3,831 @@ package com.droiddeck.launcher.input
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.Rect
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.RectF
|
||||
import android.os.Build
|
||||
import android.view.Gravity
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.WindowInsets
|
||||
import android.widget.FrameLayout
|
||||
import com.droiddeck.launcher.ui.InputSurfaceView
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
class OnScreenControls(context: Context, pad: PadBridge?, val dock: OnScreenLayout.Dock? = null) : FrameLayout(context) {
|
||||
private val surface = InputSurfaceView(context, pad, inputRole = InputRole.PRIMARY, haptics = dock != null)
|
||||
private var layout: SavedInputLayout? = null
|
||||
private val safe = Rect()
|
||||
private var viewWidth = 0
|
||||
private var viewHeight = 0
|
||||
private val placements = object : LinkedHashMap<Placement, List<InputSurfaceElement>>(4, .75f, true) {
|
||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Placement, List<InputSurfaceElement>>) = size > PLACEMENT_CACHE
|
||||
}
|
||||
private val quickToggle = QuickToggle(context)
|
||||
var isButtonsOnly = false
|
||||
private set
|
||||
var isQuickHidden = false
|
||||
private set
|
||||
var isPinnedOnly = false
|
||||
private set
|
||||
val hasPinned get() = layout?.elements?.any { it.alwaysShown } == true
|
||||
var onTouchMiss: ((MotionEvent) -> Unit)?
|
||||
get() = surface.onTouchMiss
|
||||
set(value) { surface.onTouchMiss = value }
|
||||
@SuppressLint("ViewConstructor")
|
||||
class OnScreenControls(
|
||||
context: Context,
|
||||
private val pad: PadBridge?,
|
||||
private val editing: Boolean = false,
|
||||
private val onKeyboard: (() -> Unit)? = null,
|
||||
) : View(context) {
|
||||
|
||||
init {
|
||||
addView(surface, LayoutParams(-1, -1))
|
||||
val size = (QUICK_HIT_DP * 2 * resources.displayMetrics.density).toInt()
|
||||
addView(quickToggle, LayoutParams(size, size, Gravity.START or Gravity.BOTTOM))
|
||||
}
|
||||
private class Control(
|
||||
val id: String,
|
||||
val group: String,
|
||||
val stick: Int,
|
||||
val radiusDp: Float,
|
||||
val offsetXDp: Float,
|
||||
val offsetYDp: Float,
|
||||
) {
|
||||
var target = id
|
||||
var radius = 0f
|
||||
var cx = 0f
|
||||
var cy = 0f
|
||||
var pressedBy = -1
|
||||
var kx = 0f
|
||||
var ky = 0f
|
||||
var ax = 0f
|
||||
var ay = 0f
|
||||
var dirty = false
|
||||
var clicked = false
|
||||
var lastUp = 0L
|
||||
var downAt = 0L
|
||||
var downX = 0f
|
||||
var downY = 0f
|
||||
var travel = 0f
|
||||
val wide = id in shoulderIds
|
||||
val halfW get() = if (wide) radius * SHOULDER_WIDTH else radius
|
||||
val halfH get() = if (wide) radius * SHOULDER_HEIGHT else radius
|
||||
|
||||
fun updateLayout(value: SavedInputLayout?) {
|
||||
if (layout == value) return
|
||||
releaseAll()
|
||||
layout = value
|
||||
render()
|
||||
}
|
||||
|
||||
fun setButtonsOnly(enabled: Boolean) {
|
||||
if (isButtonsOnly == enabled) return
|
||||
releaseAll()
|
||||
isButtonsOnly = enabled
|
||||
isQuickHidden = false
|
||||
render()
|
||||
}
|
||||
|
||||
fun setQuickHidden(hidden: Boolean) {
|
||||
if (isButtonsOnly || isQuickHidden == hidden) return
|
||||
releaseAll()
|
||||
isQuickHidden = hidden
|
||||
render()
|
||||
}
|
||||
|
||||
fun setPinnedOnly(enabled: Boolean) {
|
||||
if (isPinnedOnly == enabled) return
|
||||
releaseAll()
|
||||
isPinnedOnly = enabled
|
||||
render()
|
||||
}
|
||||
|
||||
private data class Placement(val layout: SavedInputLayout?, val width: Int, val height: Int, val safe: Rect, val buttonsOnly: Boolean)
|
||||
|
||||
private fun placed(): List<InputSurfaceElement> {
|
||||
val key = Placement(layout, viewWidth, viewHeight, Rect(safe), isButtonsOnly)
|
||||
return placements.getOrPut(key) {
|
||||
OnScreenLayout.elements(context, layout, viewWidth, viewHeight, safe, isButtonsOnly, dock)
|
||||
}
|
||||
}
|
||||
|
||||
private fun render() {
|
||||
val elements = placed()
|
||||
surface.updateCustomLayout(when {
|
||||
isPinnedOnly -> elements.filter { it.alwaysShown }
|
||||
isQuickHidden -> elements.filter { it.id == "builtin:keyboard" || it.alwaysShown }
|
||||
else -> elements
|
||||
})
|
||||
val style = layout?.style ?: InputSurfaceStyle()
|
||||
surface.updateStyle(style)
|
||||
quickToggle.tint = style.tint
|
||||
quickToggle.alpha = style.opacity / 100f
|
||||
quickToggle.visibility = if (isButtonsOnly || isPinnedOnly || layout == null || dock != null) GONE else VISIBLE
|
||||
quickToggle.invalidate()
|
||||
}
|
||||
|
||||
fun takesTouchAt(x: Float, y: Float) = surface.takesTouchAt(x, y) ||
|
||||
quickToggle.visibility == VISIBLE && x >= quickToggle.left && x < quickToggle.right && y >= quickToggle.top && y < quickToggle.bottom
|
||||
fun releaseAll() = surface.releaseInputs()
|
||||
fun reload() { releaseAll(); placements.clear(); render() }
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val w = MeasureSpec.getSize(widthMeasureSpec)
|
||||
val h = MeasureSpec.getSize(heightMeasureSpec)
|
||||
if (w != viewWidth || h != viewHeight) {
|
||||
viewWidth = w
|
||||
viewHeight = h
|
||||
render()
|
||||
}
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() { releaseAll(); super.onDetachedFromWindow() }
|
||||
|
||||
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
|
||||
val cutout = if (Build.VERSION.SDK_INT >= 28) insets.displayCutout else null
|
||||
val offset = ((QUICK_CENTER_DP - QUICK_HIT_DP) * resources.displayMetrics.density).toInt()
|
||||
val params = quickToggle.layoutParams as LayoutParams
|
||||
val left = (cutout?.safeInsetLeft ?: 0) + offset
|
||||
val bottom = (cutout?.safeInsetBottom ?: 0) + offset
|
||||
if (params.leftMargin != left || params.bottomMargin != bottom) {
|
||||
params.leftMargin = left
|
||||
params.bottomMargin = bottom
|
||||
quickToggle.layoutParams = params
|
||||
}
|
||||
val fresh = Rect(cutout?.safeInsetLeft ?: 0, if (dock == null) cutout?.safeInsetTop ?: 0 else 0, cutout?.safeInsetRight ?: 0, cutout?.safeInsetBottom ?: 0)
|
||||
if (fresh != safe) {
|
||||
safe.set(fresh)
|
||||
render()
|
||||
}
|
||||
return super.onApplyWindowInsets(insets)
|
||||
}
|
||||
|
||||
private inner class QuickToggle(context: Context) : View(context) {
|
||||
var tint: Int? = ControllerPrefs.STEAM_BLUE
|
||||
private val density = resources.displayMetrics.density
|
||||
private var armed = false
|
||||
private val fill = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE; strokeWidth = 1.5f * density }
|
||||
private val text = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
textAlign = Paint.Align.CENTER
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
textSize = 22 * density
|
||||
fun contains(x: Float, y: Float, r: Float, padding: Float = 0f): Boolean {
|
||||
val dx = x - cx
|
||||
val dy = y - cy
|
||||
if (wide) return abs(dx) <= halfW + r * 0.25f + padding && abs(dy) <= halfH + r * 0.25f + padding
|
||||
val hit = r * 1.25f + padding
|
||||
return dx * dx + dy * dy <= hit * hit
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
val x = width / 2f
|
||||
val y = height / 2f
|
||||
val radius = QUICK_RADIUS_DP * density
|
||||
val color = tint
|
||||
fill.color = if (color != null) InputSurfaceView.shade(color, 190, 0.12f) else (InputSurfaceView.SURFACE and 0x00ffffff) or (190 shl 24)
|
||||
canvas.drawCircle(x, y, radius, fill)
|
||||
stroke.color = if (color != null) InputSurfaceView.light(color, 150, 0.25f) else InputSurfaceView.LINE
|
||||
canvas.drawCircle(x, y, radius, stroke)
|
||||
text.color = if (color != null) InputSurfaceView.light(color, 210, 0.75f) else InputSurfaceView.TEXT
|
||||
canvas.drawText(if (isQuickHidden) "+" else "−", x, y + text.textSize * 0.35f, text)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> armed = true
|
||||
MotionEvent.ACTION_CANCEL -> armed = false
|
||||
MotionEvent.ACTION_UP -> {
|
||||
if (armed && event.x in 0f..width.toFloat() && event.y in 0f..height.toFloat()) {
|
||||
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
setQuickHidden(!isQuickHidden)
|
||||
}
|
||||
armed = false
|
||||
}
|
||||
}
|
||||
fun drag(x: Float, y: Float): Boolean {
|
||||
travel = max(travel, hypot(x - downX, y - downY))
|
||||
var dx = x - ax
|
||||
var dy = y - ay
|
||||
val d = sqrt(dx * dx + dy * dy)
|
||||
val atLimit = d > radius
|
||||
if (atLimit) { dx = dx / d * radius; dy = dy / d * radius }
|
||||
if (!atLimit && abs(dx - kx) < MIN_STICK_MOVE_PX && abs(dy - ky) < MIN_STICK_MOVE_PX) return false
|
||||
kx = dx; ky = dy; dirty = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private val stickRedraw = ThrottledRedraw(this, STICK_REDRAW_MS)
|
||||
private val tapSlop = android.view.ViewConfiguration.get(context).scaledTouchSlop.toFloat()
|
||||
|
||||
private fun stickTap(control: Control, event: MotionEvent): Boolean {
|
||||
val i = event.actionIndex
|
||||
val travel = max(control.travel, hypot(event.getX(i) - control.downX, event.getY(i) - control.downY))
|
||||
return stickTap(control.downAt, event.eventTime, travel, tapSlop, control.clicked,
|
||||
event.actionMasked == MotionEvent.ACTION_CANCEL || (event.flags and MotionEvent.FLAG_CANCELED) != 0)
|
||||
}
|
||||
|
||||
private val controls = listOf(
|
||||
Control("up", "dpad", -1, 26f, 0f, -52f),
|
||||
Control("right", "dpad", -1, 26f, 52f, 0f),
|
||||
Control("down", "dpad", -1, 26f, 0f, 52f),
|
||||
Control("left", "dpad", -1, 26f, -52f, 0f),
|
||||
Control("ls", "ls", 0, 48f, 0f, 0f),
|
||||
Control("rs", "rs", 1, 48f, 0f, 0f),
|
||||
Control("a", "face", -1, 30f, 0f, 50f),
|
||||
Control("b", "face", -1, 30f, 50f, 0f),
|
||||
Control("x", "face", -1, 30f, -50f, 0f),
|
||||
Control("y", "face", -1, 30f, 0f, -50f),
|
||||
Control("lb", "lb", -1, 24f, 0f, 0f),
|
||||
Control("rb", "rb", -1, 24f, 0f, 0f),
|
||||
Control("lt", "lt", -1, 24f, 0f, 0f),
|
||||
Control("rt", "rt", -1, 24f, 0f, 0f),
|
||||
Control("select", "select", -1, 20f, 0f, 0f),
|
||||
Control("start", "start", -1, 20f, 0f, 0f),
|
||||
Control("guide", "guide", -1, 22f, 0f, 0f),
|
||||
Control("qam", "qam", -1, 22f, 0f, 0f),
|
||||
)
|
||||
|
||||
private val groups = controls.map { it.group }.distinct()
|
||||
|
||||
private var buttonsOnly = false
|
||||
val isButtonsOnly: Boolean get() = buttonsOnly
|
||||
private var quickHidden = false
|
||||
private var quickPressedBy = -1
|
||||
private var keyboardPressedBy = -1
|
||||
private var forwardedPointerBits = 0
|
||||
var onTouchMiss: ((MotionEvent) -> Unit)? = null
|
||||
private var settings = ControllerPrefs.read(context)
|
||||
private var snap = settings.snap
|
||||
private var safe = Rect()
|
||||
private var selected: String? = null
|
||||
private var snapX: Float? = null
|
||||
private var snapY: Float? = null
|
||||
private var grabX = 0f
|
||||
private var grabY = 0f
|
||||
private var ignoreSaved = false
|
||||
private var fit = 1f
|
||||
|
||||
private var idleFill = 0
|
||||
private var heldFill = 0
|
||||
private var idleStroke = 0
|
||||
private var heldStroke = 0
|
||||
private var idleText = 0
|
||||
private var stickFill = 0
|
||||
private var knobFill = 0
|
||||
|
||||
private val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL }
|
||||
private val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
|
||||
private val arrow = Path()
|
||||
private val box = RectF()
|
||||
private val text = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
textAlign = Paint.Align.CENTER
|
||||
typeface = android.graphics.Typeface.DEFAULT_BOLD
|
||||
}
|
||||
|
||||
init {
|
||||
applySettings()
|
||||
}
|
||||
|
||||
private fun dp(value: Float) = value * resources.displayMetrics.density
|
||||
|
||||
private fun scaled(value: Float) = dp(value) * settings.size / 100f * fit
|
||||
|
||||
private fun fitScale(): Float {
|
||||
val metrics = resources.displayMetrics
|
||||
val heightMm = (height - safe.top - safe.bottom) / pxPerMm(metrics.ydpi)
|
||||
val faceMm = dp(60f) / pxPerMm(metrics.xdpi)
|
||||
val floor = (MIN_FACE_MM / faceMm).coerceAtMost(1f)
|
||||
return (heightMm / FULL_SIZE_HEIGHT_MM).coerceIn(floor, 1f)
|
||||
}
|
||||
|
||||
fun reload() {
|
||||
releaseAll()
|
||||
settings = ControllerPrefs.read(context)
|
||||
setSnap(settings.snap)
|
||||
applySettings()
|
||||
relayout()
|
||||
}
|
||||
|
||||
private fun clearSnapGuides() {
|
||||
if (snapX == null && snapY == null) return
|
||||
snapX = null
|
||||
snapY = null
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun applySettings() {
|
||||
for (control in controls) control.target = settings.mapping[control.id] ?: control.id
|
||||
val tint = settings.tint
|
||||
val alpha = settings.opacity / 100f
|
||||
fun shade(a: Int, f: Float) = Color.argb((a * alpha).toInt(), (Color.red(tint) * f).toInt(), (Color.green(tint) * f).toInt(), (Color.blue(tint) * f).toInt())
|
||||
fun light(a: Int, f: Float) = Color.argb(
|
||||
(a * alpha).toInt(),
|
||||
(Color.red(tint) + (255 - Color.red(tint)) * f).toInt(),
|
||||
(Color.green(tint) + (255 - Color.green(tint)) * f).toInt(),
|
||||
(Color.blue(tint) + (255 - Color.blue(tint)) * f).toInt(),
|
||||
)
|
||||
idleFill = shade(80, 0.12f)
|
||||
heldFill = shade(160, 1f)
|
||||
idleStroke = light(150, 0.25f)
|
||||
heldStroke = light(230, 0.7f)
|
||||
idleText = light(210, 0.75f)
|
||||
stickFill = shade(60, 0.12f)
|
||||
knobFill = shade(120, 0.35f)
|
||||
stroke.strokeWidth = dp(1.5f)
|
||||
}
|
||||
|
||||
fun setButtonsOnly(enabled: Boolean) {
|
||||
if (buttonsOnly == enabled) return
|
||||
releaseAll()
|
||||
buttonsOnly = enabled
|
||||
quickHidden = false
|
||||
relayout()
|
||||
}
|
||||
|
||||
fun setQuickHidden(hidden: Boolean) {
|
||||
if (buttonsOnly || editing || quickHidden == hidden) return
|
||||
if (hidden) releaseAll()
|
||||
quickHidden = hidden
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun setSnap(on: Boolean) {
|
||||
snap = on
|
||||
if (!on) clearSnapGuides()
|
||||
}
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
super.onSizeChanged(w, h, oldw, oldh)
|
||||
relayout()
|
||||
}
|
||||
|
||||
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
|
||||
val cutout = if (Build.VERSION.SDK_INT >= 28) insets.displayCutout else null
|
||||
val fresh = Rect(cutout?.safeInsetLeft ?: 0, cutout?.safeInsetTop ?: 0, cutout?.safeInsetRight ?: 0, cutout?.safeInsetBottom ?: 0)
|
||||
if (fresh != safe) { safe = fresh; relayout() }
|
||||
return super.onApplyWindowInsets(insets)
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
stickRedraw.cancel()
|
||||
releaseAll()
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
|
||||
private fun relayout() {
|
||||
if (width <= 0 || height <= 0) return
|
||||
fit = if (buttonsOnly && !editing) 1f else fitScale()
|
||||
for (control in controls) control.radius = if (buttonsOnly && !editing && (control.id == "guide" || control.id == "qam")) dp(30f) else scaled(control.radiusDp)
|
||||
if (buttonsOnly && !editing) {
|
||||
val inset = dp(44f)
|
||||
put("guide", safe.left + inset, height - safe.bottom - inset)
|
||||
put("qam", width - safe.right - inset, height - safe.bottom - inset)
|
||||
} else {
|
||||
placeAuto(width.toFloat(), height.toFloat())
|
||||
if (!ignoreSaved) for ((group, pos) in ControllerPrefs.layout(context, width, height)) {
|
||||
if (group in groups) put(group, pos.first * width, pos.second * height)
|
||||
}
|
||||
}
|
||||
groups.forEach { clamp(it) }
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun pxPerMm(dpi: Float): Float {
|
||||
val nominal = resources.displayMetrics.densityDpi.toFloat()
|
||||
return (if (dpi > nominal * 0.6f && dpi < nominal * 1.6f) dpi else nominal) / 25.4f
|
||||
}
|
||||
|
||||
private fun placeAuto(w: Float, h: Float) {
|
||||
val metrics = resources.displayMetrics
|
||||
val mmX = pxPerMm(metrics.xdpi)
|
||||
val mmY = pxPerMm(metrics.ydpi)
|
||||
val usableW = w - safe.left - safe.right
|
||||
val usableH = h - safe.top - safe.bottom
|
||||
val inboard = (0.17f * usableW / mmX).coerceIn(24f, 40f) * mmX
|
||||
val lift = (0.285f * usableH / mmY).coerceIn(22f, 45f) * mmY
|
||||
val gap = dp(12f)
|
||||
val floor = BOTTOM_MARGIN_MM * mmY
|
||||
val drop = max(drop("dpad", "ls", gap), drop("rs", "face", gap))
|
||||
val restY = min(h - safe.bottom - lift, h - safe.bottom - floor - drop)
|
||||
put("ls", safe.left + inboard, restY)
|
||||
put("face", w - safe.right - inboard, restY)
|
||||
besideBelow("dpad", "ls", 1f, gap, floor)
|
||||
besideBelow("rs", "face", -1f, gap, floor)
|
||||
val phone = usableH / mmY < PHONE_MAX_HEIGHT_MM
|
||||
val shoulderY = if (phone) safe.top + floor + halfH("lb")
|
||||
else min(centre("ls").second - extent("ls"), centre("face").second - extent("face")) - gap - halfH("lb")
|
||||
shoulders("ls", "lb", "lt", 1f, gap, shoulderY)
|
||||
shoulders("face", "rb", "rt", -1f, gap, shoulderY)
|
||||
val bottom = h - safe.bottom - floor - extent("guide")
|
||||
val spread = extent("guide") + gap / 2f
|
||||
put("guide", w / 2f - spread, bottom)
|
||||
put("qam", w / 2f + spread, bottom)
|
||||
val inner = max(centre("dpad").first + extent("dpad"), w - centre("rs").first + extent("rs")) + gap + extent("select")
|
||||
put("select", inner, restY)
|
||||
put("start", w - inner, restY)
|
||||
if (phone) {
|
||||
val top = max(centre("lb").first + extent("lb"), w - centre("rb").first + extent("rb")) + gap * 2.5f + extent("select")
|
||||
put("select", top, shoulderY)
|
||||
put("start", w - top, shoulderY)
|
||||
}
|
||||
if (crowded("select", gap) || crowded("start", gap)) {
|
||||
above("select", "dpad", gap)
|
||||
val (sx, sy) = centre("select")
|
||||
put("start", w - sx, sy)
|
||||
if (crowded("start", gap)) above("start", "rs", gap)
|
||||
}
|
||||
if (crowded("guide", gap, gap, "qam") || crowded("qam", gap, gap, "guide")) {
|
||||
val row = centre("select").second
|
||||
put("guide", w / 2f - spread, row)
|
||||
put("qam", w / 2f + spread, row)
|
||||
if (crowded("guide", gap, gap, "qam") || crowded("qam", gap, gap, "guide")) {
|
||||
val top = safe.top + dp(8f) + extent("guide")
|
||||
put("guide", w / 2f - spread, top)
|
||||
put("qam", w / 2f + spread, top)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun drop(group: String, anchor: String, gap: Float): Float =
|
||||
(extent(anchor) + extent(group) + gap) * sin(Math.toRadians(DROP_DEGREES)).toFloat() + extent(group)
|
||||
|
||||
private fun besideBelow(group: String, anchor: String, inward: Float, gap: Float, margin: Float) {
|
||||
val (ax, ay) = centre(anchor)
|
||||
val reach = extent(anchor) + extent(group) + gap
|
||||
val floor = height - safe.bottom - margin - extent(group)
|
||||
val dy = min(reach * sin(Math.toRadians(DROP_DEGREES)).toFloat(), floor - ay)
|
||||
val dx = sqrt(max(0f, reach * reach - dy * dy))
|
||||
put(group, ax + inward * dx, ay + dy)
|
||||
}
|
||||
|
||||
private fun shoulders(anchor: String, bumper: String, trigger: String, inward: Float, gap: Float, y: Float) {
|
||||
val (ax, _) = centre(anchor)
|
||||
val spread = extent(bumper) + gap / 2f
|
||||
put(bumper, ax + inward * spread, y)
|
||||
put(trigger, ax - inward * spread, y)
|
||||
}
|
||||
|
||||
private fun above(group: String, anchor: String, gap: Float) {
|
||||
val (ax, ay) = centre(anchor)
|
||||
put(group, ax, ay - extent(anchor) - gap - extent(group))
|
||||
}
|
||||
|
||||
private fun crowded(group: String, gap: Float, space: Float = gap / 2f, except: String? = null): Boolean {
|
||||
val (x, y) = centre(group)
|
||||
return groups.any { other ->
|
||||
if (other == group || other == except) return@any false
|
||||
val (ox, oy) = centre(other)
|
||||
val dx = x - ox
|
||||
val dy = y - oy
|
||||
sqrt(dx * dx + dy * dy) < extent(group) + extent(other) + space
|
||||
}
|
||||
}
|
||||
|
||||
private fun members(group: String) = controls.filter { it.group == group }
|
||||
|
||||
private fun centre(group: String): Pair<Float, Float> {
|
||||
val first = members(group).first()
|
||||
return (first.cx - scaled(first.offsetXDp)) to (first.cy - scaled(first.offsetYDp))
|
||||
}
|
||||
|
||||
private fun extent(group: String): Float = members(group).maxOf { control ->
|
||||
sqrt(scaled(control.offsetXDp).let { it * it } + scaled(control.offsetYDp).let { it * it }) + control.halfW
|
||||
}
|
||||
|
||||
private fun halfH(group: String): Float = members(group).maxOf { abs(scaled(it.offsetYDp)) + it.halfH }
|
||||
|
||||
private fun put(group: String, x: Float, y: Float) {
|
||||
for (control in members(group)) {
|
||||
control.cx = x + scaled(control.offsetXDp)
|
||||
control.cy = y + scaled(control.offsetYDp)
|
||||
}
|
||||
}
|
||||
|
||||
private fun clamp(group: String) {
|
||||
val list = members(group)
|
||||
val margin = dp(4f)
|
||||
val minX = list.minOf { it.cx - it.halfW } - safe.left - margin
|
||||
val maxX = list.maxOf { it.cx + it.halfW } - (width - safe.right - margin)
|
||||
val minY = list.minOf { it.cy - it.halfH } - safe.top - margin
|
||||
val maxY = list.maxOf { it.cy + it.halfH } - (height - safe.bottom - margin)
|
||||
val dx = if (minX < 0) -minX else if (maxX > 0) -maxX else 0f
|
||||
val dy = if (minY < 0) -minY else if (maxY > 0) -maxY else 0f
|
||||
if (dx == 0f && dy == 0f) return
|
||||
for (control in list) { control.cx += dx; control.cy += dy }
|
||||
}
|
||||
|
||||
fun saveLayout() {
|
||||
if (width <= 0 || height <= 0) return
|
||||
if (ignoreSaved) { ControllerPrefs.resetLayout(context, width, height); return }
|
||||
ControllerPrefs.setLayout(context, width, height, groups.associateWith { group ->
|
||||
val (x, y) = centre(group)
|
||||
x / width to y / height
|
||||
})
|
||||
}
|
||||
|
||||
fun resetLayout() {
|
||||
ControllerPrefs.resetLayout(context, width, height)
|
||||
ignoreSaved = true
|
||||
selected = null
|
||||
relayout()
|
||||
}
|
||||
|
||||
private fun label(control: Control): String = when (control.target) {
|
||||
"ls" -> "L"
|
||||
"rs" -> "R"
|
||||
"select" -> "⧉"
|
||||
"start" -> "☰"
|
||||
"guide" -> if (buttonsOnly && !editing) "Steam" else "◉"
|
||||
"qam" -> "⋯"
|
||||
else -> control.target.uppercase()
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
if (!quickHidden) for (control in controls) {
|
||||
if (!isVisible(control)) continue
|
||||
if (control.stick >= 0 && settings.adaptiveSticks && !editing && control.pressedBy == -1) continue
|
||||
val held = control.pressedBy != -1
|
||||
val radius = control.radius
|
||||
if (control.stick >= 0) {
|
||||
val bx = if (held) control.ax else control.cx
|
||||
val by = if (held) control.ay else control.cy
|
||||
fill.color = if (held) heldFill else stickFill
|
||||
canvas.drawCircle(bx, by, radius, fill)
|
||||
stroke.color = if (held) heldStroke else idleStroke
|
||||
canvas.drawCircle(bx, by, radius, stroke)
|
||||
val knob = radius * 0.46f
|
||||
fill.color = if (held || control.clicked) heldFill else knobFill
|
||||
canvas.drawCircle(bx + control.kx, by + control.ky, knob, fill)
|
||||
stroke.color = if (held) heldStroke else idleStroke
|
||||
canvas.drawCircle(bx + control.kx, by + control.ky, knob, stroke)
|
||||
text.color = if (held) Color.WHITE else idleText
|
||||
text.textSize = knob * 0.8f
|
||||
canvas.drawText(label(control), bx + control.kx, by + control.ky + text.textSize * 0.35f, text)
|
||||
} else if (control.wide) {
|
||||
box.set(control.cx - control.halfW, control.cy - control.halfH, control.cx + control.halfW, control.cy + control.halfH)
|
||||
val corner = control.halfH * 0.55f
|
||||
fill.color = if (held) heldFill else idleFill
|
||||
canvas.drawRoundRect(box, corner, corner, fill)
|
||||
stroke.color = if (held) heldStroke else idleStroke
|
||||
canvas.drawRoundRect(box, corner, corner, stroke)
|
||||
text.color = if (held) Color.WHITE else idleText
|
||||
text.textSize = control.halfH * 0.8f
|
||||
canvas.drawText(label(control), control.cx, control.cy + text.textSize * 0.35f, text)
|
||||
} else {
|
||||
fill.color = if (held) heldFill else idleFill
|
||||
canvas.drawCircle(control.cx, control.cy, radius, fill)
|
||||
stroke.color = if (held) heldStroke else idleStroke
|
||||
canvas.drawCircle(control.cx, control.cy, radius, stroke)
|
||||
text.color = if (held) Color.WHITE else idleText
|
||||
val direction = directions.indexOf(control.target)
|
||||
if (direction >= 0) {
|
||||
drawArrow(canvas, control.cx, control.cy, radius * 0.34f, direction)
|
||||
continue
|
||||
}
|
||||
val name = label(control)
|
||||
text.textSize = if (name.length > 2) dp(11f) else radius * if (name.length == 2) 0.62f else 0.85f
|
||||
canvas.drawText(name, control.cx, control.cy + text.textSize * 0.35f, text)
|
||||
}
|
||||
}
|
||||
if (editing) {
|
||||
stroke.color = heldStroke
|
||||
snapX?.let { canvas.drawLine(it, safe.top.toFloat(), it, (height - safe.bottom).toFloat(), stroke) }
|
||||
snapY?.let { canvas.drawLine(safe.left.toFloat(), it, (width - safe.right).toFloat(), it, stroke) }
|
||||
selected?.let { group ->
|
||||
val (x, y) = centre(group)
|
||||
val single = members(group).singleOrNull()
|
||||
if (single?.wide == true) {
|
||||
val pad = dp(6f)
|
||||
box.set(x - single.halfW - pad, y - single.halfH - pad, x + single.halfW + pad, y + single.halfH + pad)
|
||||
canvas.drawRoundRect(box, single.halfH, single.halfH, stroke)
|
||||
} else canvas.drawCircle(x, y, extent(group) + dp(6f), stroke)
|
||||
}
|
||||
} else if (!buttonsOnly) {
|
||||
val (x, y) = quickCenter()
|
||||
val radius = dp(18f)
|
||||
fill.color = Color.argb(190, Color.red(idleFill), Color.green(idleFill), Color.blue(idleFill))
|
||||
canvas.drawCircle(x, y, radius, fill)
|
||||
stroke.color = idleStroke
|
||||
canvas.drawCircle(x, y, radius, stroke)
|
||||
text.color = idleText
|
||||
text.textSize = dp(22f)
|
||||
canvas.drawText(if (quickHidden) "+" else "−", x, y + text.textSize * 0.35f, text)
|
||||
}
|
||||
if (!editing && settings.keyboardButton && onKeyboard != null) {
|
||||
val (x, y) = keyboardCenter()
|
||||
fill.color = Color.argb(190, Color.red(idleFill), Color.green(idleFill), Color.blue(idleFill))
|
||||
canvas.drawCircle(x, y, dp(18f), fill)
|
||||
stroke.color = if (keyboardPressedBy == -1) idleStroke else heldStroke
|
||||
canvas.drawCircle(x, y, dp(18f), stroke)
|
||||
box.set(x - dp(10f), y - dp(7f), x + dp(10f), y + dp(7f))
|
||||
canvas.drawRoundRect(box, dp(2f), dp(2f), stroke)
|
||||
for (row in 0..1) for (col in 0..2) {
|
||||
val kx = x + dp(-6f + col * 5f)
|
||||
val ky = y + dp(-3f + row * 4f)
|
||||
canvas.drawLine(kx, ky, kx + dp(2f), ky, stroke)
|
||||
}
|
||||
canvas.drawLine(x - dp(5f), y + dp(4f), x + dp(5f), y + dp(4f), stroke)
|
||||
}
|
||||
}
|
||||
|
||||
private fun quickCenter() = (safe.left + dp(34f)) to (height - safe.bottom - dp(34f))
|
||||
|
||||
private fun keyboardCenter() = if (buttonsOnly)
|
||||
width / 2f to (height - safe.bottom - dp(44f))
|
||||
else (safe.left + dp(90f)) to (height - safe.bottom - dp(34f))
|
||||
|
||||
private fun keyboardContains(x: Float, y: Float): Boolean {
|
||||
if (!settings.keyboardButton || onKeyboard == null) return false
|
||||
val (cx, cy) = keyboardCenter()
|
||||
val dx = x - cx
|
||||
val dy = y - cy
|
||||
return dx * dx + dy * dy <= dp(26f) * dp(26f)
|
||||
}
|
||||
|
||||
private fun quickContains(x: Float, y: Float): Boolean {
|
||||
val (cx, cy) = quickCenter()
|
||||
val radius = dp(26f)
|
||||
val dx = x - cx
|
||||
val dy = y - cy
|
||||
return dx * dx + dy * dy <= radius * radius
|
||||
}
|
||||
|
||||
private fun drawArrow(canvas: Canvas, x: Float, y: Float, size: Float, direction: Int) {
|
||||
val (fx, fy) = when (direction) {
|
||||
0 -> 0f to -1f
|
||||
1 -> 1f to 0f
|
||||
2 -> 0f to 1f
|
||||
else -> -1f to 0f
|
||||
}
|
||||
arrow.reset()
|
||||
arrow.moveTo(x + fx * size, y + fy * size)
|
||||
arrow.lineTo(x - fx * size * 0.7f - fy * size, y - fy * size * 0.7f + fx * size)
|
||||
arrow.lineTo(x - fx * size * 0.7f + fy * size, y - fy * size * 0.7f - fx * size)
|
||||
arrow.close()
|
||||
fill.color = text.color
|
||||
canvas.drawPath(arrow, fill)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
if (editing) return onEditTouch(event)
|
||||
if (event.getToolType(event.actionIndex) == MotionEvent.TOOL_TYPE_MOUSE) return false
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> {
|
||||
val index = event.actionIndex
|
||||
val x = event.getX(index)
|
||||
val y = event.getY(index)
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN) forwardedPointerBits = 0
|
||||
if (event.actionMasked == MotionEvent.ACTION_POINTER_DOWN && forwardMiss(event, x, y)) return true
|
||||
if (forwardedPointerBits != 0) forwardPointers(event, forwardedPointerBits)
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN && keyboardContains(x, y)) {
|
||||
keyboardPressedBy = event.getPointerId(index)
|
||||
invalidate()
|
||||
return true
|
||||
}
|
||||
if (keyboardPressedBy != -1) return true
|
||||
if (!buttonsOnly && event.actionMasked == MotionEvent.ACTION_DOWN && quickContains(x, y)) {
|
||||
quickPressedBy = event.getPointerId(index)
|
||||
return true
|
||||
}
|
||||
if (quickPressedBy != -1) return true
|
||||
if (quickHidden) return false
|
||||
val control = controlAt(x, y) ?: adaptiveStickAt(x, y) ?: return false
|
||||
if (control.pressedBy != -1) return true
|
||||
control.pressedBy = event.getPointerId(index)
|
||||
if (control.stick >= 0) {
|
||||
control.clicked = settings.stickClick && control.lastUp > 0L && event.eventTime - control.lastUp < DOUBLE_TAP_MS
|
||||
control.downAt = event.eventTime
|
||||
control.downX = event.getX(index)
|
||||
control.downY = event.getY(index)
|
||||
control.travel = 0f
|
||||
val adaptive = settings.adaptiveSticks
|
||||
control.ax = if (adaptive) event.getX(index) else control.cx
|
||||
control.ay = if (adaptive) event.getY(index) else control.cy
|
||||
control.drag(event.getX(index), event.getY(index))
|
||||
control.dirty = true
|
||||
}
|
||||
if (SessionPrefs.lowLatency(context)) requestUnbufferedDispatch(event)
|
||||
apply()
|
||||
return true
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
if (forwardedPointerBits != 0) forwardPointers(event, forwardedPointerBits)
|
||||
if (keyboardPressedBy != -1) return true
|
||||
if (quickPressedBy != -1) return true
|
||||
var changed = false
|
||||
var stickMoved = false
|
||||
for (index in 0 until event.pointerCount) {
|
||||
val pointer = event.getPointerId(index)
|
||||
if (forwardedPointerBits and (1 shl pointer) != 0) continue
|
||||
val x = event.getX(index)
|
||||
val y = event.getY(index)
|
||||
val stick = controls.firstOrNull { it.stick >= 0 && it.pressedBy == pointer }
|
||||
if (stick != null) { stickMoved = stick.drag(x, y) || stickMoved; continue }
|
||||
val over = controlAt(x, y)?.takeIf { it.stick < 0 }
|
||||
for (control in controls) {
|
||||
if (control.stick < 0 && control.pressedBy == pointer && control !== over) {
|
||||
control.pressedBy = -1
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (over != null && over.pressedBy == -1) {
|
||||
over.pressedBy = pointer
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) apply() else if (stickMoved) apply(stickOnly = true)
|
||||
return true
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
val pointer = event.getPointerId(event.actionIndex)
|
||||
if (forwardedPointerBits != 0) forwardPointers(event, forwardedPointerBits)
|
||||
if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
|
||||
forwardedPointerBits = 0
|
||||
releaseAll()
|
||||
return true
|
||||
}
|
||||
if (forwardedPointerBits and (1 shl pointer) != 0) {
|
||||
forwardedPointerBits = forwardedPointerBits and (1 shl pointer).inv()
|
||||
return true
|
||||
}
|
||||
if (keyboardPressedBy == pointer) {
|
||||
val tapped = event.actionMasked == MotionEvent.ACTION_UP && keyboardContains(event.x, event.y)
|
||||
keyboardPressedBy = -1
|
||||
invalidate()
|
||||
if (tapped) { releaseAll(); onKeyboard?.invoke() }
|
||||
return true
|
||||
}
|
||||
if (quickPressedBy == pointer) {
|
||||
val toggled = event.actionMasked == MotionEvent.ACTION_UP && quickContains(event.x, event.y)
|
||||
quickPressedBy = -1
|
||||
if (toggled) setQuickHidden(!quickHidden)
|
||||
return true
|
||||
}
|
||||
if (quickHidden) return false
|
||||
var changed = false
|
||||
for (control in controls) {
|
||||
if (control.pressedBy == pointer || event.actionMasked == MotionEvent.ACTION_CANCEL) {
|
||||
if (control.pressedBy != -1) changed = true
|
||||
if (control.stick >= 0 && control.pressedBy != -1) control.lastUp = if (stickTap(control, event)) event.eventTime else 0L
|
||||
control.pressedBy = -1
|
||||
control.clicked = false
|
||||
if (control.stick >= 0 && (control.kx != 0f || control.ky != 0f)) control.dirty = true
|
||||
control.kx = 0f; control.ky = 0f
|
||||
}
|
||||
}
|
||||
if (changed) apply()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun forwardMiss(event: MotionEvent, x: Float, y: Float): Boolean {
|
||||
if (onTouchMiss == null || keyboardPressedBy != -1 || quickPressedBy != -1) return false
|
||||
if (!quickHidden && (controlAt(x, y) ?: adaptiveStickAt(x, y)) != null) return false
|
||||
forwardedPointerBits = forwardedPointerBits or (1 shl event.getPointerId(event.actionIndex))
|
||||
forwardPointers(event, forwardedPointerBits)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun forwardPointers(event: MotionEvent, pointerIdBits: Int) {
|
||||
val split = event.pointerSubset(pointerIdBits) ?: return
|
||||
try {
|
||||
onTouchMiss?.invoke(split)
|
||||
} finally {
|
||||
split.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onEditTouch(event: MotionEvent): Boolean {
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
clearSnapGuides()
|
||||
val control = controlAt(event.x, event.y)
|
||||
selected = control?.group
|
||||
val group = selected ?: run { invalidate(); return false }
|
||||
val (x, y) = centre(group)
|
||||
grabX = x - event.x
|
||||
grabY = y - event.y
|
||||
invalidate()
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
val group = selected ?: return true
|
||||
ignoreSaved = false
|
||||
val rawX = event.x + grabX
|
||||
val rawY = event.y + grabY
|
||||
val others = groups.filter { it != group && members(it).any(::isVisible) }.map { centre(it) }
|
||||
val nextX = if (snap) closestSnap(rawX, others.map { it.first } + width / 2f) else null
|
||||
val nextY = if (snap) closestSnap(rawY, others.map { it.second }) else null
|
||||
put(group, nextX ?: rawX, nextY ?: rawY)
|
||||
clamp(group)
|
||||
val (x, y) = centre(group)
|
||||
val visibleX = nextX?.takeIf { abs(x - it) <= SNAP_TOLERANCE_PX }
|
||||
val visibleY = nextY?.takeIf { abs(y - it) <= SNAP_TOLERANCE_PX }
|
||||
if ((visibleX != null && visibleX != snapX) || (visibleY != null && visibleY != snapY)) performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)
|
||||
snapX = visibleX
|
||||
snapY = visibleY
|
||||
invalidate()
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> clearSnapGuides()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun closestSnap(position: Float, targets: List<Float>): Float? =
|
||||
targets.minByOrNull { abs(position - it) }?.takeIf { abs(position - it) <= dp(SNAP_DP) }
|
||||
|
||||
fun takesTouchAt(x: Float, y: Float): Boolean =
|
||||
editing || keyboardContains(x, y) || (!buttonsOnly && quickContains(x, y)) ||
|
||||
(!quickHidden && (controlAt(x, y) ?: adaptiveStickAt(x, y)) != null)
|
||||
|
||||
private fun controlAt(x: Float, y: Float): Control? =
|
||||
controls.firstOrNull {
|
||||
!quickHidden && isVisible(it) && (editing || !settings.adaptiveSticks || it.stick < 0) && it.contains(x, y, it.radius)
|
||||
}
|
||||
|
||||
private fun adaptiveStickAt(x: Float, y: Float): Control? {
|
||||
if (!settings.adaptiveSticks || editing || buttonsOnly || quickHidden) return null
|
||||
if (x < safe.left || x >= width - safe.right || y < safe.top || y >= height - safe.bottom) return null
|
||||
if (controls.any { control ->
|
||||
control.stick < 0 && isVisible(control) && control.contains(x, y, control.radius, 5f)
|
||||
}) return null
|
||||
return controls.filter { control ->
|
||||
val dx = if (control.stick == 0) max(0f, x - control.cx) else x - control.cx
|
||||
val dy = y - control.cy
|
||||
val reach = control.radius * 1.4f * sqrt(1.5f)
|
||||
control.stick >= 0 && isVisible(control) && control.pressedBy == -1 &&
|
||||
dx * dx + dy * dy <= reach * reach
|
||||
}.minByOrNull { control ->
|
||||
val dx = x - control.cx
|
||||
val dy = y - control.cy
|
||||
dx * dx + dy * dy
|
||||
}
|
||||
}
|
||||
|
||||
private fun isVisible(control: Control): Boolean = when {
|
||||
!editing && control.id == "guide" && !settings.steamButton -> false
|
||||
!editing && control.id == "qam" && !settings.qamButton -> false
|
||||
buttonsOnly && !editing -> control.id == "guide" || control.id == "qam"
|
||||
else -> control.target != ControllerPrefs.OFF
|
||||
}
|
||||
|
||||
private fun apply(stickOnly: Boolean = false) {
|
||||
val bridge = pad
|
||||
if (bridge == null || editing) { invalidate(); return }
|
||||
val qamHeld = controls.any { it.id == "qam" && it.pressedBy != -1 }
|
||||
if (buttonsOnly) {
|
||||
bridge.setSystemButtons(controls.any { it.id == "guide" && it.pressedBy != -1 }, qamHeld)
|
||||
} else {
|
||||
bridge.setSystemButtons(false, qamHeld)
|
||||
val stickWrites = controls.filter { it.stick >= 0 && it.dirty }.fold(0) { mask, control ->
|
||||
mask or if (control.stick == 0) PadBridge.LEFT_STICK else PadBridge.RIGHT_STICK
|
||||
}
|
||||
bridge.applyTouch(stickWrites) { state ->
|
||||
val used = HashSet<String>()
|
||||
val held = HashSet<String>()
|
||||
for (control in controls) {
|
||||
when {
|
||||
control.stick >= 0 -> {
|
||||
val click = if (control.stick == 0) "l3" else "r3"
|
||||
if (settings.stickClick) used.add(click)
|
||||
if (control.clicked) held.add(click)
|
||||
if (!control.dirty) continue
|
||||
if (control.stick == 0) { state.leftX = control.kx / control.radius; state.leftY = control.ky / control.radius }
|
||||
else { state.rightX = control.kx / control.radius; state.rightY = control.ky / control.radius }
|
||||
control.dirty = false
|
||||
}
|
||||
control.id == "qam" || control.target == ControllerPrefs.OFF -> {}
|
||||
else -> {
|
||||
used.add(control.target)
|
||||
if (control.pressedBy != -1) held.add(control.target)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (target in used) write(state, target, target in held)
|
||||
}
|
||||
}
|
||||
if (stickOnly) stickRedraw.request() else invalidate()
|
||||
}
|
||||
|
||||
private fun write(state: PadState, target: String, down: Boolean) {
|
||||
when (target) {
|
||||
"a" -> state.press(PadState.A, down)
|
||||
"b" -> state.press(PadState.B, down)
|
||||
"x" -> state.press(PadState.X, down)
|
||||
"y" -> state.press(PadState.Y, down)
|
||||
"lb" -> state.press(PadState.LB, down)
|
||||
"rb" -> state.press(PadState.RB, down)
|
||||
"select" -> state.press(PadState.SELECT, down)
|
||||
"start" -> state.press(PadState.START, down)
|
||||
"l3" -> state.press(PadState.L3, down)
|
||||
"r3" -> state.press(PadState.R3, down)
|
||||
"guide" -> state.press(PadState.GUIDE, down)
|
||||
"lt" -> state.leftTrigger = if (down) 1f else 0f
|
||||
"rt" -> state.rightTrigger = if (down) 1f else 0f
|
||||
"up" -> state.up = down
|
||||
"right" -> state.right = down
|
||||
"down" -> state.down = down
|
||||
"left" -> state.left = down
|
||||
}
|
||||
}
|
||||
|
||||
fun releaseAll() {
|
||||
quickPressedBy = -1
|
||||
keyboardPressedBy = -1
|
||||
invalidate()
|
||||
if (controls.none { it.pressedBy != -1 || it.clicked } && !buttonsOnly) return
|
||||
controls.forEach {
|
||||
it.pressedBy = -1
|
||||
it.clicked = false
|
||||
it.lastUp = 0L
|
||||
if (it.stick >= 0 && (it.kx != 0f || it.ky != 0f)) it.dirty = true
|
||||
it.kx = 0f; it.ky = 0f
|
||||
}
|
||||
apply()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val QUICK_CENTER_DP = 34f
|
||||
const val QUICK_HIT_DP = 26f
|
||||
const val QUICK_RADIUS_DP = 18f
|
||||
const val PLACEMENT_CACHE = 4
|
||||
const val DOUBLE_TAP_MS = 300L
|
||||
const val STICK_TAP_MS = 200L
|
||||
|
||||
internal fun stickTap(downAt: Long, upAt: Long, travel: Float, slop: Float, clicked: Boolean, cancelled: Boolean): Boolean =
|
||||
!clicked && !cancelled && upAt - downAt <= STICK_TAP_MS && travel <= slop
|
||||
const val STICK_REDRAW_MS = 33L
|
||||
const val MIN_STICK_MOVE_PX = 1f
|
||||
const val MIN_FACE_MM = 9.5f
|
||||
const val FULL_SIZE_HEIGHT_MM = 85f
|
||||
const val BOTTOM_MARGIN_MM = 5f
|
||||
const val DROP_DEGREES = 35.0
|
||||
const val PHONE_MAX_HEIGHT_MM = 90f
|
||||
const val SHOULDER_WIDTH = 1.45f
|
||||
const val SHOULDER_HEIGHT = 0.8f
|
||||
const val SNAP_DP = 9f
|
||||
const val SNAP_TOLERANCE_PX = 1f
|
||||
val shoulderIds = setOf("lb", "rb", "lt", "rt")
|
||||
val directions = listOf("up", "right", "down", "left")
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
object OnScreenLayout {
|
||||
data class Dock(val portrait: Boolean, val grips: Boolean, val left: InputControl = InputControl.LS, val right: InputControl = InputControl.RS) {
|
||||
companion object {
|
||||
fun deck(portrait: Boolean, gamepad: SavedInputLayout?): Dock {
|
||||
fun side(right: Boolean) = gamepad?.elements?.firstOrNull {
|
||||
(it.kind == InputElementKind.STICK || it.kind == InputElementKind.TRACKPAD) && (it.x + it.width / 2f >= .5f) == right
|
||||
}?.kind
|
||||
val left = if (side(false) == InputElementKind.TRACKPAD) InputControl.LEFT_PAD else InputControl.LS
|
||||
val right = if (side(true) == InputElementKind.STICK) InputControl.RS else InputControl.RIGHT_PAD
|
||||
return Dock(portrait, true, left, right)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val TOLERANCE = .01f
|
||||
private const val KEYBOARD_ID = "builtin:keyboard"
|
||||
|
||||
fun elements(context: Context, layout: SavedInputLayout?, width: Int, height: Int, safe: Rect,
|
||||
buttonsOnly: Boolean = false, dock: Dock? = null): List<InputSurfaceElement> {
|
||||
val stored = layout?.elements ?: return emptyList()
|
||||
if (width <= 0 || height <= 0) return stored
|
||||
val generator = LegacyGamepadLayout(context, width, height, safe, layout.scale)
|
||||
val (reference, generated) = reference(context, layout, width, height)
|
||||
if (buttonsOnly && dock == null) return generator.buttonsOnly(stored) + fit(stored.filter { it.alwaysShown }, reference.first, reference.second, width, height, safe)
|
||||
val gamepad = if (generated) stored.filter(::isGamepad) else emptyList()
|
||||
val others = fit(stored.filterNot { generated && isGamepad(it) || dock != null && it.alwaysShown }, reference.first, reference.second, width, height, safe)
|
||||
val placed = when {
|
||||
gamepad.isEmpty() -> emptyList()
|
||||
dock != null -> generator.dock(gamepad, dock.portrait, dock.grips, dock.left, dock.right)
|
||||
else -> generator.regenerate(gamepad)
|
||||
}
|
||||
val byId = (placed + others).associateBy { it.id }
|
||||
return stored.mapNotNull { byId[it.id] } + placed.filter { p -> stored.none { it.id == p.id } }
|
||||
}
|
||||
|
||||
fun fit(elements: List<InputSurfaceElement>, referenceWidth: Float, referenceHeight: Float, width: Int, height: Int, safe: Rect): List<InputSurfaceElement> {
|
||||
val w = width.toFloat()
|
||||
val h = height.toFloat()
|
||||
val scale = min(w / referenceWidth, h / referenceHeight)
|
||||
return elements.map { e ->
|
||||
val ew = e.width * referenceWidth * scale
|
||||
val eh = e.height * referenceHeight * scale
|
||||
val left = anchor(e.x, e.width, referenceWidth, w, scale).coerceIn(safe.left.toFloat(), max(safe.left.toFloat(), w - safe.right - ew))
|
||||
val top = anchor(e.y, e.height, referenceHeight, h, scale).coerceIn(safe.top.toFloat(), max(safe.top.toFloat(), h - safe.bottom - eh))
|
||||
e.copy(x = left / w, y = top / h, width = ew / w, height = eh / h).bounded()
|
||||
}
|
||||
}
|
||||
|
||||
private fun anchor(start: Float, size: Float, reference: Float, actual: Float, scale: Float): Float {
|
||||
val centre = start + size / 2f
|
||||
val scaled = size * reference * scale
|
||||
return when {
|
||||
centre < 1f / 3f -> start * reference * scale
|
||||
centre > 2f / 3f -> actual - (1f - start - size) * reference * scale - scaled
|
||||
else -> centre * actual - scaled / 2f
|
||||
}
|
||||
}
|
||||
|
||||
private fun isGamepad(e: InputSurfaceElement) = e.id.startsWith("legacy:") || e.id == KEYBOARD_ID
|
||||
|
||||
fun generatedSize(context: Context, layout: SavedInputLayout, width: Int, height: Int): Pair<Int, Int>? =
|
||||
reference(context, layout, width, height).takeIf { it.second }?.first?.let { it.first.toInt() to it.second.toInt() }
|
||||
|
||||
private fun reference(context: Context, layout: SavedInputLayout, width: Int, height: Int): Pair<Pair<Float, Float>, Boolean> {
|
||||
val (display, metrics) = InputLibraries.primaryDisplay(context)
|
||||
val long = max(metrics.widthPixels, metrics.heightPixels).coerceAtLeast(1)
|
||||
val short = min(metrics.widthPixels, metrics.heightPixels).coerceAtLeast(1)
|
||||
val gamepad = layout.elements.filter(::isGamepad)
|
||||
if (gamepad.any { it.id.startsWith("legacy:") }) {
|
||||
val named = layout.id.removePrefix("migrated.layout.").takeIf { it != layout.id }?.split('x')?.mapNotNull { it.toIntOrNull() }
|
||||
?.takeIf { it.size == 2 && it.all { size -> size > 0 } }?.let { it[0] to it[1] }
|
||||
for ((w, h) in listOfNotNull(named, width to height, long to short, short to long).distinct()) {
|
||||
if (matches(gamepad, LegacyGamepadLayout(display, w, h, scale = layout.scale).elements())) return (w.toFloat() to h.toFloat()) to true
|
||||
}
|
||||
}
|
||||
val aspect = layout.aspect.takeIf { it > 0f } ?: (long.toFloat() / short)
|
||||
return (aspect to 1f) to false
|
||||
}
|
||||
|
||||
private fun matches(stored: List<InputSurfaceElement>, generated: List<InputSurfaceElement>): Boolean {
|
||||
val byId = generated.associateBy { it.id }
|
||||
return stored.all { e ->
|
||||
val g = byId[e.id] ?: return@all false
|
||||
abs(e.x - g.x) <= TOLERANCE && abs(e.y - g.y) <= TOLERANCE && abs(e.width - g.width) <= TOLERANCE && abs(e.height - g.height) <= TOLERANCE
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,25 +59,11 @@ public final class PadBridge {
|
||||
private final int[] retainedSticks = new int[SLOTS];
|
||||
private final PadState effectiveState = new PadState();
|
||||
private final PadState homeGuideReport = new PadState();
|
||||
private final PadState combinedState = new PadState();
|
||||
private InputRouter inputRouter;
|
||||
private final InputRouter.PadSource[] physicalRoutes = new InputRouter.PadSource[SLOTS];
|
||||
private final java.util.IdentityHashMap<Object, TouchSource> touchSources = new java.util.IdentityHashMap<>();
|
||||
private final Object defaultTouchSource = new Object();
|
||||
|
||||
private static final class TouchSource {
|
||||
final PadState raw = new PadState();
|
||||
final PadState passthrough = new PadState();
|
||||
final InputRole role;
|
||||
boolean routed;
|
||||
InputRouter.PadSource route;
|
||||
TouchSource(InputRole role) { this.role = role; }
|
||||
}
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
private boolean open;
|
||||
private volatile boolean selectSteam;
|
||||
private volatile boolean builtinPad = true;
|
||||
private TouchRoute touchRoute;
|
||||
private boolean touchMouseActive;
|
||||
private final PadState routedTouch = new PadState();
|
||||
private boolean systemGuidePressed;
|
||||
private boolean systemQamPressed;
|
||||
@@ -86,7 +72,6 @@ public final class PadBridge {
|
||||
private int homeGuideSlot = -1;
|
||||
private final PadState lastBackQamReport = new PadState();
|
||||
private final PadState lastWrittenReport = new PadState();
|
||||
private final PadState liveReport = new PadState();
|
||||
private boolean backQamAxesBlocked;
|
||||
private final java.util.Set<Runnable> backQamWrites = new java.util.HashSet<>();
|
||||
private long backQamDelayMs, backQamWriteAt;
|
||||
@@ -94,11 +79,6 @@ public final class PadBridge {
|
||||
private int chordButton = -1;
|
||||
private boolean chordButtonPressed;
|
||||
private boolean qamTapPressed;
|
||||
private boolean guideTapPressed;
|
||||
private int guideTapGeneration;
|
||||
private boolean vrStickTapPressed;
|
||||
private int vrStickTapGeneration;
|
||||
private boolean routedQamPressed;
|
||||
private int qamChordGeneration;
|
||||
private volatile Runnable onPlayerInput;
|
||||
private Runnable onChordInput;
|
||||
@@ -139,36 +119,8 @@ public final class PadBridge {
|
||||
for (int slot = 1; slot < SLOTS; slot++) playerStates[slot] = new PadState();
|
||||
}
|
||||
|
||||
public synchronized void setInputRouter(InputRouter router) {
|
||||
if (inputRouter == router) return;
|
||||
for (InputRouter.PadSource route : physicalRoutes) if (route != null) route.close();
|
||||
for (TouchSource source : touchSources.values()) if (source.route != null) source.route.close();
|
||||
inputRouter = router;
|
||||
for (int slot = 0; slot < SLOTS; slot++) physicalRoutes[slot] = router == null ? null : router.pad(InputRole.PHYSICAL, stateFor(slot));
|
||||
for (TouchSource source : touchSources.values()) source.route = router == null || source.routed ? null : router.pad(source.role, source.raw);
|
||||
clearBackQamWrites();
|
||||
if (open) publish();
|
||||
for (int slot = 1; slot < SLOTS; slot++) if (playerWriters[slot] != null) publish(slot);
|
||||
}
|
||||
|
||||
public synchronized boolean hasInputRouter() { return inputRouter != null; }
|
||||
|
||||
public synchronized InputRouter getInputRouter() { return inputRouter; }
|
||||
|
||||
public synchronized void setInputProfile(InputProfile profile) {
|
||||
if (inputRouter == null) return;
|
||||
inputRouter.setProfile(profile);
|
||||
clearBackQamWrites();
|
||||
if (open) publish();
|
||||
for (int slot = 1; slot < SLOTS; slot++) if (playerWriters[slot] != null) publish(slot);
|
||||
}
|
||||
|
||||
public synchronized void setInputEnabled(boolean enabled) {
|
||||
if (inputRouter == null || !inputRouter.setEnabled(enabled)) return;
|
||||
if (!enabled) clearBackQamWrites();
|
||||
if (open) publish();
|
||||
for (int slot = 1; slot < SLOTS; slot++) if (playerWriters[slot] != null) publish(slot);
|
||||
DeckControls.INSTANCE.refresh();
|
||||
public void setSelectSteam(boolean on) {
|
||||
selectSteam = on;
|
||||
}
|
||||
|
||||
public void setExcluded(java.util.function.IntPredicate devices) {
|
||||
@@ -195,18 +147,10 @@ public final class PadBridge {
|
||||
homeGuidePressed = false;
|
||||
clearBackQamWrites();
|
||||
routedTouch.clear();
|
||||
for (TouchSource source : touchSources.values()) {
|
||||
source.raw.clear();
|
||||
if (source.route != null) source.route.release();
|
||||
}
|
||||
systemGuidePressed = false;
|
||||
systemQamPressed = false;
|
||||
backQamPressed = false;
|
||||
qamTapPressed = false;
|
||||
guideTapPressed = false;
|
||||
guideTapGeneration++;
|
||||
vrStickTapPressed = false;
|
||||
vrStickTapGeneration++;
|
||||
qamChordActive = false;
|
||||
chordButtonPressed = false;
|
||||
qamChordGeneration++;
|
||||
@@ -224,16 +168,6 @@ public final class PadBridge {
|
||||
}
|
||||
|
||||
public synchronized void stop() {
|
||||
guideTapPressed = false;
|
||||
guideTapGeneration++;
|
||||
vrStickTapPressed = false;
|
||||
vrStickTapGeneration++;
|
||||
if (inputRouter != null) inputRouter.releaseAll();
|
||||
for (TouchSource source : touchSources.values()) if (source.route != null) source.route.close();
|
||||
touchSources.clear();
|
||||
for (InputRouter.PadSource route : physicalRoutes) if (route != null) route.close();
|
||||
Arrays.fill(physicalRoutes, null);
|
||||
inputRouter = null;
|
||||
homeGuidePressed = false;
|
||||
homeGuideSlot = -1;
|
||||
clearBackQamWrites();
|
||||
@@ -380,7 +314,6 @@ public final class PadBridge {
|
||||
if (slot < 0) return false;
|
||||
int side = JoyCon.side(event.getDevice());
|
||||
if (side >= 0 && partners[slot] != NO_CONTROLLER && JoyCon.isRail(side, event.getScanCode())) return true;
|
||||
long timing = InputTiming.begin();
|
||||
PadState pad = stateFor(slot);
|
||||
boolean pressed = event.getAction() == KeyEvent.ACTION_DOWN;
|
||||
switch (event.getKeyCode()) {
|
||||
@@ -391,6 +324,9 @@ public final class PadBridge {
|
||||
case KeyEvent.KEYCODE_BUTTON_L1: pad.press(4, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_R1: pad.press(5, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_SELECT:
|
||||
if (selectSteam) pad.press(PadState.GUIDE, pressed);
|
||||
else pad.press(6, pressed);
|
||||
break;
|
||||
case KeyEvent.KEYCODE_BACK: pad.press(6, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_START:
|
||||
case KeyEvent.KEYCODE_MENU: pad.press(7, pressed); break;
|
||||
@@ -405,14 +341,8 @@ public final class PadBridge {
|
||||
case KeyEvent.KEYCODE_BUTTON_C:
|
||||
if (isPrimary(event)) DeckControls.INSTANCE.setGrip(isNovaController(event.getDevice()) ? DeckControls.L4 : DeckControls.R4, pressed);
|
||||
break;
|
||||
case KeyEvent.KEYCODE_BUTTON_L2:
|
||||
pad.leftTrigger = keyTrigger(pad.leftTrigger, pressed, event.getRepeatCount(),
|
||||
analogTrigger(event.getDevice(), MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_BRAKE));
|
||||
break;
|
||||
case KeyEvent.KEYCODE_BUTTON_R2:
|
||||
pad.rightTrigger = keyTrigger(pad.rightTrigger, pressed, event.getRepeatCount(),
|
||||
analogTrigger(event.getDevice(), MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_GAS));
|
||||
break;
|
||||
case KeyEvent.KEYCODE_BUTTON_L2: pad.leftTrigger = pressed ? 1f : 0f; break;
|
||||
case KeyEvent.KEYCODE_BUTTON_R2: pad.rightTrigger = pressed ? 1f : 0f; break;
|
||||
case KeyEvent.KEYCODE_DPAD_UP: pad.up = pressed; break;
|
||||
case KeyEvent.KEYCODE_DPAD_RIGHT: pad.right = pressed; break;
|
||||
case KeyEvent.KEYCODE_DPAD_DOWN: pad.down = pressed; break;
|
||||
@@ -425,7 +355,6 @@ public final class PadBridge {
|
||||
statButtons++;
|
||||
scheduleStats();
|
||||
publish(slot);
|
||||
InputTiming.end(timing);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -493,7 +422,6 @@ public final class PadBridge {
|
||||
if (event.getAction() != MotionEvent.ACTION_MOVE) return false;
|
||||
int slot = slotFor(event.getDevice());
|
||||
if (slot < 0) return false;
|
||||
long timing = InputTiming.begin();
|
||||
PadState pad = stateFor(slot);
|
||||
noteDevice(event.getDevice(), slot);
|
||||
statAxes++;
|
||||
@@ -510,7 +438,6 @@ public final class PadBridge {
|
||||
if (chordStickInput) noteChordInput(slot);
|
||||
if (Math.max(Math.abs(x), Math.abs(y)) > 0.5f) notePlayerInput();
|
||||
publish(slot);
|
||||
InputTiming.end(timing);
|
||||
return true;
|
||||
}
|
||||
float lx = event.getAxisValue(MotionEvent.AXIS_X);
|
||||
@@ -549,20 +476,9 @@ public final class PadBridge {
|
||||
notePlayerInput();
|
||||
}
|
||||
publish(slot);
|
||||
InputTiming.end(timing);
|
||||
return true;
|
||||
}
|
||||
|
||||
static float keyTrigger(float current, boolean pressed, int repeatCount, boolean analog) {
|
||||
if (analog || repeatCount > 0) return current;
|
||||
return pressed ? 1f : 0f;
|
||||
}
|
||||
|
||||
static boolean analogTrigger(InputDevice device, int... axes) {
|
||||
return wiredXboxLayout(device, InputDevice.SOURCE_JOYSTICK)
|
||||
|| hasAxis(device, InputDevice.SOURCE_JOYSTICK, axes);
|
||||
}
|
||||
|
||||
static boolean hasAxis(InputDevice device, int source, int... axes) {
|
||||
if (device == null) return false;
|
||||
for (int axis : axes) if (device.getMotionRange(axis, source) != null) return true;
|
||||
@@ -577,70 +493,41 @@ public final class PadBridge {
|
||||
|
||||
public synchronized void setTouchRoute(TouchRoute route) {
|
||||
touchRoute = route;
|
||||
touchMouseActive = false;
|
||||
routedTouch.clear();
|
||||
}
|
||||
|
||||
public synchronized void applyTouch(java.util.function.Consumer<PadState> mutation) {
|
||||
applyTouch(defaultTouchSource, InputRole.PRIMARY, 0, mutation);
|
||||
applyTouch(0, mutation);
|
||||
}
|
||||
|
||||
public synchronized void applyTouch(int stickWrites, java.util.function.Consumer<PadState> mutation) {
|
||||
applyTouch(defaultTouchSource, InputRole.PRIMARY, stickWrites, mutation);
|
||||
}
|
||||
|
||||
public synchronized void applyTouch(Object owner, InputRole role, java.util.function.Consumer<PadState> mutation) {
|
||||
applyTouch(owner, role, 0, mutation);
|
||||
}
|
||||
|
||||
public synchronized void applyTouch(Object owner, InputRole role, int stickWrites, java.util.function.Consumer<PadState> mutation) {
|
||||
if (!builtinPad) return;
|
||||
long timing = InputTiming.begin();
|
||||
TouchSource source = touchSources.get(owner);
|
||||
if (source == null) {
|
||||
source = new TouchSource(role);
|
||||
if (inputRouter != null) source.route = inputRouter.pad(role);
|
||||
touchSources.put(owner, source);
|
||||
}
|
||||
float lx = source.raw.leftX, ly = source.raw.leftY, rx = source.raw.rightX, ry = source.raw.rightY;
|
||||
mutation.accept(source.raw);
|
||||
if (inputRouter == null && (touchRoute == null || !touchRoute.active())) {
|
||||
if ((stickWrites & LEFT_STICK) != 0 || source.raw.leftX != lx || source.raw.leftY != ly) {
|
||||
retainedSticks[0] &= ~LEFT_STICK;
|
||||
state.leftX = state.leftY = 0f;
|
||||
if (touchRoute != null && touchRoute.active()) {
|
||||
mutation.accept(routedTouch);
|
||||
touchRoute.accept(routedTouch);
|
||||
boolean changed = false;
|
||||
for (int button : ROUTED_PASS_THROUGH) {
|
||||
boolean down = routedTouch.isDown(button);
|
||||
if (state.isDown(button) == down) continue;
|
||||
state.press(button, down);
|
||||
changed = true;
|
||||
}
|
||||
if ((stickWrites & RIGHT_STICK) != 0 || source.raw.rightX != rx || source.raw.rightY != ry) {
|
||||
retainedSticks[0] &= ~RIGHT_STICK;
|
||||
state.rightX = state.rightY = 0f;
|
||||
if (changed) {
|
||||
activeControllerId = NO_CONTROLLER;
|
||||
slotControllers.set(0, NO_CONTROLLER);
|
||||
publish();
|
||||
}
|
||||
return;
|
||||
}
|
||||
routedTouch.clear();
|
||||
retainedSticks[0] &= ~stickWrites;
|
||||
mutateState(mutation);
|
||||
activeControllerId = NO_CONTROLLER;
|
||||
slotControllers.set(0, NO_CONTROLLER);
|
||||
notePlayerInput();
|
||||
statOnScreen++;
|
||||
scheduleStats();
|
||||
publish();
|
||||
InputTiming.end(timing);
|
||||
}
|
||||
|
||||
public synchronized void releaseTouch(Object owner) {
|
||||
TouchSource source = touchSources.remove(owner);
|
||||
if (source == null) return;
|
||||
if (source.route != null) source.route.close();
|
||||
publish();
|
||||
}
|
||||
|
||||
public synchronized void applyRoutedTouch(Object owner, InputRole role, java.util.function.Consumer<PadState> mutation) {
|
||||
if (!builtinPad) return;
|
||||
TouchSource source = touchSources.get(owner);
|
||||
if (source == null) {
|
||||
source = new TouchSource(role);
|
||||
source.routed = true;
|
||||
touchSources.put(owner, source);
|
||||
}
|
||||
mutation.accept(source.raw);
|
||||
notePlayerInput();
|
||||
publish();
|
||||
}
|
||||
|
||||
public synchronized void applyExternal(java.util.function.Consumer<PadState> mutation, boolean changed) {
|
||||
@@ -665,30 +552,16 @@ public final class PadBridge {
|
||||
}
|
||||
|
||||
public synchronized void releaseAll() {
|
||||
guideTapGeneration++;
|
||||
vrStickTapPressed = false;
|
||||
vrStickTapGeneration++;
|
||||
routedQamPressed = false;
|
||||
systemGuidePressed = false;
|
||||
systemQamPressed = false;
|
||||
guideTapPressed = false;
|
||||
qamTapPressed = false;
|
||||
qamChordActive = false;
|
||||
chordButtonPressed = false;
|
||||
qamChordGeneration++;
|
||||
if (inputRouter != null) inputRouter.releaseAll();
|
||||
if (inputRouter == null) for (TouchSource source : touchSources.values()) source.raw.clear();
|
||||
routedTouch.clear();
|
||||
DeckControls.INSTANCE.suspend();
|
||||
if (inputRouter == null) clearPlayerState(0);
|
||||
homeGuidePressed = false;
|
||||
homeGuideSlot = -1;
|
||||
clearBackQamWrites();
|
||||
routedTouch.clear();
|
||||
backQamPressed = false;
|
||||
if (inputRouter == null) releaseBackPaddles();
|
||||
releaseBackPaddles();
|
||||
clearPlayerState(0);
|
||||
publish();
|
||||
for (int slot = 1; slot < SLOTS; slot++) {
|
||||
if (inputRouter == null) clearPlayerState(slot);
|
||||
clearPlayerState(slot);
|
||||
if (playerWriters[slot] == null) continue;
|
||||
publish(slot);
|
||||
}
|
||||
@@ -729,11 +602,7 @@ public final class PadBridge {
|
||||
if (!homeGuidePressed) return;
|
||||
clearBackQamWrites();
|
||||
homeGuidePressed = false;
|
||||
if (inputRouter == null) clearPlayerState(0);
|
||||
else {
|
||||
inputRouter.releaseAll();
|
||||
DeckControls.INSTANCE.suspend();
|
||||
}
|
||||
clearPlayerState(0);
|
||||
publish();
|
||||
}
|
||||
|
||||
@@ -772,11 +641,7 @@ public final class PadBridge {
|
||||
if (!backQamPressed && backQamWrites.isEmpty()) return;
|
||||
clearBackQamWrites();
|
||||
backQamPressed = false;
|
||||
if (inputRouter == null) clearPlayerState(0);
|
||||
else {
|
||||
inputRouter.releaseAll();
|
||||
DeckControls.INSTANCE.suspend();
|
||||
}
|
||||
clearPlayerState(0);
|
||||
publish();
|
||||
}
|
||||
|
||||
@@ -812,7 +677,6 @@ public final class PadBridge {
|
||||
}
|
||||
|
||||
private void writePad(PadState report) {
|
||||
liveReport.copyFrom(report);
|
||||
if (backQamAxesBlocked && !hasQamAxes()) backQamAxesBlocked = false;
|
||||
long at = android.os.SystemClock.uptimeMillis();
|
||||
if (backQamPressed || homeGuidePressed || !backQamWrites.isEmpty()) at += backQamDelayMs;
|
||||
@@ -820,11 +684,11 @@ public final class PadBridge {
|
||||
}
|
||||
|
||||
private boolean hasQamAxes() {
|
||||
boolean left = (retainedSticks[0] & 1) != 0 && liveReport.leftX == state.leftX && liveReport.leftY == state.leftY
|
||||
? hasChordStickInput(rawSticks[0][0], rawSticks[0][1]) : liveReport.leftX != 0f || liveReport.leftY != 0f;
|
||||
boolean right = (retainedSticks[0] & 2) != 0 && liveReport.rightX == state.rightX && liveReport.rightY == state.rightY
|
||||
? hasChordStickInput(rawSticks[0][2], rawSticks[0][3]) : liveReport.rightX != 0f || liveReport.rightY != 0f;
|
||||
return left || right || liveReport.leftTrigger != 0f || liveReport.rightTrigger != 0f;
|
||||
boolean left = (retainedSticks[0] & 1) != 0
|
||||
? hasChordStickInput(rawSticks[0][0], rawSticks[0][1]) : state.leftX != 0f || state.leftY != 0f;
|
||||
boolean right = (retainedSticks[0] & 2) != 0
|
||||
? hasChordStickInput(rawSticks[0][2], rawSticks[0][3]) : state.rightX != 0f || state.rightY != 0f;
|
||||
return left || right || state.leftTrigger != 0f || state.rightTrigger != 0f;
|
||||
}
|
||||
|
||||
private static void clearAxes(PadState pad) {
|
||||
@@ -845,9 +709,9 @@ public final class PadBridge {
|
||||
PadState live = new PadState();
|
||||
live.copyFrom(report);
|
||||
if (queued) {
|
||||
live.leftX = liveReport.leftX; live.leftY = liveReport.leftY;
|
||||
live.rightX = liveReport.rightX; live.rightY = liveReport.rightY;
|
||||
live.leftTrigger = liveReport.leftTrigger; live.rightTrigger = liveReport.rightTrigger;
|
||||
live.leftX = state.leftX; live.leftY = state.leftY;
|
||||
live.rightX = state.rightX; live.rightY = state.rightY;
|
||||
live.leftTrigger = state.leftTrigger; live.rightTrigger = state.rightTrigger;
|
||||
}
|
||||
boolean held = (backQamPressed && report.isDown(PadState.QAM)) || (homeGuidePressed && report.isDown(PadState.GUIDE));
|
||||
if ((queued && !held) || backQamAxesBlocked) clearAxes(live);
|
||||
@@ -868,34 +732,6 @@ public final class PadBridge {
|
||||
chordHandler.postDelayed(() -> releaseQamTap(generation), QAM_A_HOLD_MS);
|
||||
}
|
||||
|
||||
public synchronized void triggerSteamMenu() {
|
||||
if (!builtinPad || guideTapPressed) return;
|
||||
guideTapPressed = true;
|
||||
int generation = ++guideTapGeneration;
|
||||
publish();
|
||||
chordHandler.postDelayed(() -> {
|
||||
synchronized (PadBridge.this) {
|
||||
if (generation != guideTapGeneration) return;
|
||||
guideTapPressed = false;
|
||||
publish();
|
||||
}
|
||||
}, 90);
|
||||
}
|
||||
|
||||
public synchronized void triggerVrStickSwitch() {
|
||||
if (!builtinPad || vrStickTapPressed) return;
|
||||
vrStickTapPressed = true;
|
||||
int generation = ++vrStickTapGeneration;
|
||||
publish();
|
||||
chordHandler.postDelayed(() -> {
|
||||
synchronized (PadBridge.this) {
|
||||
if (generation != vrStickTapGeneration) return;
|
||||
vrStickTapPressed = false;
|
||||
publish();
|
||||
}
|
||||
}, 120);
|
||||
}
|
||||
|
||||
private synchronized void releaseQamTap(int generation) {
|
||||
if (generation != qamChordGeneration || !qamTapPressed) return;
|
||||
qamTapPressed = false;
|
||||
@@ -1020,7 +856,6 @@ public final class PadBridge {
|
||||
}
|
||||
|
||||
private void free(int slot) {
|
||||
if (physicalRoutes[slot] != null) physicalRoutes[slot].release();
|
||||
Log.i(TAG, "player " + (slot + 1) + " (id " + owners[slot] + ") left");
|
||||
owners[slot] = NO_CONTROLLER;
|
||||
partners[slot] = NO_CONTROLLER;
|
||||
@@ -1074,7 +909,6 @@ public final class PadBridge {
|
||||
publish();
|
||||
return;
|
||||
}
|
||||
PadState output = physicalRoutes[slot] == null ? playerStates[slot] : physicalRoutes[slot].route(playerStates[slot]);
|
||||
FakeInputWriter player = playerWriters[slot];
|
||||
if (player == null) {
|
||||
player = new FakeInputWriter(fakeInputDir.getAbsolutePath(), slot);
|
||||
@@ -1084,75 +918,43 @@ public final class PadBridge {
|
||||
}
|
||||
playerWriters[slot] = player;
|
||||
}
|
||||
PadState report = playerStates[slot];
|
||||
if (slot == homeGuideSlot) {
|
||||
homeGuideReport.copyFrom(output);
|
||||
homeGuideReport.copyFrom(report);
|
||||
homeGuideReport.press(PadState.GUIDE, true);
|
||||
output = homeGuideReport;
|
||||
report = homeGuideReport;
|
||||
}
|
||||
player.writePad(output);
|
||||
player.writePad(report);
|
||||
}
|
||||
|
||||
private void publish() {
|
||||
if (!builtinPad && !open && owners[0] == NO_CONTROLLER && partners[0] == NO_CONTROLLER) return;
|
||||
PadState output = physicalRoutes[0] == null ? state : physicalRoutes[0].route(state);
|
||||
boolean mouseRoute = (!touchSources.isEmpty() || touchMouseActive) && touchRoute != null && touchRoute.active();
|
||||
touchMouseActive = mouseRoute && !touchSources.isEmpty();
|
||||
if (mouseRoute) routedTouch.clear();
|
||||
if (!touchSources.isEmpty()) {
|
||||
combinedState.copyFrom(output);
|
||||
for (TouchSource source : touchSources.values()) {
|
||||
PadState touch = source.route == null ? source.raw : source.route.route(source.raw);
|
||||
if (mouseRoute) {
|
||||
routedTouch.merge(touch);
|
||||
source.passthrough.clear();
|
||||
for (int button : ROUTED_PASS_THROUGH) source.passthrough.press(button, touch.isDown(button));
|
||||
combinedState.merge(source.passthrough);
|
||||
} else combinedState.merge(touch);
|
||||
}
|
||||
output = combinedState;
|
||||
}
|
||||
if (mouseRoute) touchRoute.accept(routedTouch);
|
||||
boolean guide = systemGuidePressed || qamChordActive || guideTapPressed || homeGuidePressed;
|
||||
boolean routedQam = output.isDown(PadState.QAM);
|
||||
boolean routedQamStarted = routedQam && !routedQamPressed;
|
||||
routedQamPressed = routedQam;
|
||||
if (routedQamStarted && !nativeQam() && !qamChordActive) {
|
||||
startChord(PadState.A);
|
||||
return;
|
||||
}
|
||||
boolean qam = nativeQam() && (systemQamPressed || qamTapPressed || backQamPressed);
|
||||
if (!open && !start()) {
|
||||
statDropped++;
|
||||
return;
|
||||
}
|
||||
if (!guide && !qam && !vrStickTapPressed && (!routedQam || nativeQam())) {
|
||||
writePad(output);
|
||||
boolean guide = systemGuidePressed || qamChordActive || homeGuidePressed;
|
||||
boolean qam = nativeQam() && (systemQamPressed || qamTapPressed || backQamPressed);
|
||||
if (!guide && !qam) {
|
||||
writePad(state);
|
||||
return;
|
||||
}
|
||||
effectiveState.copyFrom(output);
|
||||
if (!nativeQam()) effectiveState.press(PadState.QAM, false);
|
||||
effectiveState.copyFrom(state);
|
||||
if (guide) effectiveState.press(PadState.GUIDE, true);
|
||||
if (qam) effectiveState.press(PadState.QAM, true);
|
||||
if (chordButtonPressed) effectiveState.press(chordButton, true);
|
||||
if (vrStickTapPressed) {
|
||||
effectiveState.press(PadState.L3, true);
|
||||
effectiveState.press(PadState.R3, true);
|
||||
}
|
||||
writePad(effectiveState);
|
||||
}
|
||||
|
||||
public static boolean wiredXboxLayout(MotionEvent event) {
|
||||
return wiredXboxLayout(event.getDevice(), event.getSource());
|
||||
}
|
||||
|
||||
static boolean wiredXboxLayout(InputDevice device, int source) {
|
||||
InputDevice device = event.getDevice();
|
||||
return device != null
|
||||
&& ((device.getVendorId() == 0x20d6 && device.getProductId() == 0x2063)
|
||||
|| (device.getVendorId() == 0x0e6f && device.getProductId() == 0x02f0))
|
||||
&& device.getMotionRange(MotionEvent.AXIS_RX, source) != null
|
||||
&& device.getMotionRange(MotionEvent.AXIS_RY, source) != null
|
||||
&& device.getMotionRange(MotionEvent.AXIS_Z, source) != null
|
||||
&& device.getMotionRange(MotionEvent.AXIS_RZ, source) != null;
|
||||
&& device.getMotionRange(MotionEvent.AXIS_RX, event.getSource()) != null
|
||||
&& device.getMotionRange(MotionEvent.AXIS_RY, event.getSource()) != null
|
||||
&& device.getMotionRange(MotionEvent.AXIS_Z, event.getSource()) != null
|
||||
&& device.getMotionRange(MotionEvent.AXIS_RZ, event.getSource()) != null;
|
||||
}
|
||||
|
||||
public static float leftTrigger(MotionEvent event, boolean wired) {
|
||||
|
||||
@@ -36,19 +36,6 @@ class PadState {
|
||||
buttons = 0
|
||||
}
|
||||
|
||||
fun merge(other: PadState) {
|
||||
buttons = buttons or other.buttons
|
||||
up = up || other.up; right = right || other.right; down = down || other.down; left = left || other.left
|
||||
leftTrigger = maxOf(leftTrigger, other.leftTrigger)
|
||||
rightTrigger = maxOf(rightTrigger, other.rightTrigger)
|
||||
if (other.leftX * other.leftX + other.leftY * other.leftY > leftX * leftX + leftY * leftY) {
|
||||
leftX = other.leftX; leftY = other.leftY
|
||||
}
|
||||
if (other.rightX * other.rightX + other.rightY * other.rightY > rightX * rightX + rightY * rightY) {
|
||||
rightX = other.rightX; rightY = other.rightY
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val A = 0
|
||||
const val B = 1
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
|
||||
data class PhysicalInputKey(val device: String, val name: String, val keyCode: Int, val scanCode: Int) {
|
||||
val label: String get() = "$name · ${KeyEvent.keyCodeToString(keyCode).removePrefix("KEYCODE_")} ($scanCode)"
|
||||
|
||||
companion object {
|
||||
fun identity(device: InputDevice) = "${device.vendorId}:${device.productId}:${device.descriptor.ifBlank { device.name }}"
|
||||
|
||||
fun capture(event: KeyEvent): PhysicalInputKey? {
|
||||
val device = event.device ?: return null
|
||||
if (device.isVirtual || PadBridge.isSystemVirtualInputName(device.name)) return null
|
||||
when (event.keyCode) {
|
||||
KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_MUTE,
|
||||
KeyEvent.KEYCODE_HOME, KeyEvent.KEYCODE_POWER, KeyEvent.KEYCODE_SLEEP,
|
||||
KeyEvent.KEYCODE_WAKEUP, KeyEvent.KEYCODE_APP_SWITCH -> return null
|
||||
}
|
||||
if (event.keyCode == KeyEvent.KEYCODE_UNKNOWN && event.scanCode <= 0) return null
|
||||
return PhysicalInputKey(identity(device), device.name, event.keyCode, event.scanCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class PhysicalInputBinding(val key: PhysicalInputKey, val action: InputAction)
|
||||
|
||||
class PhysicalInputBindings(private val router: InputRouter) {
|
||||
private class Entry(val binding: PhysicalInputBinding, val channel: InputRouter.Channel) {
|
||||
var heldDevice = -1
|
||||
var suppressed = false
|
||||
}
|
||||
private var entries = emptyList<Entry>()
|
||||
private val released = ArrayList<Entry>()
|
||||
private val devices = HashMap<Int, String>()
|
||||
|
||||
fun configure(bindings: List<PhysicalInputBinding>) {
|
||||
if (entries.map { it.binding } == bindings) return
|
||||
for (entry in entries) {
|
||||
entry.channel.close()
|
||||
if (entry.heldDevice != -1) released.add(entry)
|
||||
}
|
||||
entries = bindings.map { Entry(it, router.channel(InputRole.PHYSICAL, InputControl.A, it.action)) }
|
||||
}
|
||||
|
||||
fun onKey(event: KeyEvent, enabled: Boolean): Boolean {
|
||||
if (event.action != KeyEvent.ACTION_DOWN && event.action != KeyEvent.ACTION_UP) return false
|
||||
for (i in released.indices.reversed()) {
|
||||
val old = released[i]
|
||||
if (old.heldDevice == event.deviceId && old.binding.key.keyCode == event.keyCode && old.binding.key.scanCode == event.scanCode) {
|
||||
if (event.action == KeyEvent.ACTION_UP) released.removeAt(i)
|
||||
return true
|
||||
}
|
||||
}
|
||||
for (entry in entries) {
|
||||
val key = entry.binding.key
|
||||
if (key.keyCode != event.keyCode || key.scanCode != event.scanCode) continue
|
||||
if (entry.heldDevice != -1 && entry.heldDevice == event.deviceId) {
|
||||
if (event.action == KeyEvent.ACTION_UP) {
|
||||
entry.channel.update(0f); entry.heldDevice = -1; entry.suppressed = false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (!enabled || event.action != KeyEvent.ACTION_DOWN || entry.binding.action is InputAction.Native) continue
|
||||
val identity = devices[event.deviceId] ?: event.device?.let { PhysicalInputKey.identity(it) }?.also { devices[event.deviceId] = it } ?: continue
|
||||
if (identity != key.device) continue
|
||||
entry.heldDevice = event.deviceId
|
||||
entry.suppressed = event.repeatCount != 0
|
||||
if (!entry.suppressed) entry.channel.update(1f)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun release() {
|
||||
for (entry in entries) {
|
||||
entry.channel.release()
|
||||
if (entry.heldDevice != -1) entry.suppressed = true
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnected(id: Int) {
|
||||
devices.remove(id)
|
||||
released.removeAll { it.heldDevice == id }
|
||||
for (entry in entries) if (entry.heldDevice == id) { entry.channel.release(); entry.heldDevice = -1 }
|
||||
}
|
||||
|
||||
fun close() { entries.forEach { it.channel.close() }; entries = emptyList(); released.clear(); devices.clear() }
|
||||
}
|
||||
@@ -105,10 +105,6 @@ object PhysicalShortcuts {
|
||||
write(context, read(context).filterNot { it == binding })
|
||||
}
|
||||
|
||||
fun restoreDefault(context: Context, action: Action) {
|
||||
write(context, read(context).filterNot { it.action == action })
|
||||
}
|
||||
|
||||
private fun write(context: Context, bindings: List<Binding>) {
|
||||
val array = JSONArray()
|
||||
bindings.forEach {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
|
||||
fun screenKeyLabel(context: Context, codes: List<Int>): String =
|
||||
codes.filter { it != 0 }.joinToString("+") { short(context, it) }
|
||||
|
||||
private fun short(context: Context, code: Int): String = when (code) {
|
||||
28 -> "⏎"
|
||||
14 -> "⌫"
|
||||
15 -> "Tab"
|
||||
57 -> "Space"
|
||||
58 -> "Caps"
|
||||
42, 54 -> "Shift"
|
||||
29, 97 -> "Ctrl"
|
||||
56 -> "Alt"
|
||||
100 -> "AltGr"
|
||||
125 -> "Super"
|
||||
else -> pcKeyLabel(context, code)
|
||||
}
|
||||
|
||||
const val SCREEN_KEY_PILL = 0
|
||||
const val SCREEN_KEY_ROUND = 1
|
||||
const val SCREEN_KEY_CIRCLE = 2
|
||||
const val SCREEN_KEY_RECT = 3
|
||||
const val SCREEN_KEY_TRIGGER = 4
|
||||
const val SCREEN_KEY_DPAD = 5
|
||||
const val SCREEN_KEY_STICK = 6
|
||||
|
||||
val SCREEN_KEY_DPAD_DIRS = listOf("↑", "→", "↓", "←")
|
||||
val SCREEN_KEY_STICK_DIRS = listOf("↑", "↗", "→", "↘", "↓", "↙", "←", "↖")
|
||||
|
||||
fun screenKeyDirections(shape: Int): List<String> = when (shape) {
|
||||
SCREEN_KEY_DPAD -> SCREEN_KEY_DPAD_DIRS
|
||||
SCREEN_KEY_STICK -> SCREEN_KEY_STICK_DIRS
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
data class ScreenKey(
|
||||
val codes: List<Int>,
|
||||
val x: Float,
|
||||
val y: Float,
|
||||
val size: Int,
|
||||
val glyph: String = "",
|
||||
val text: String = "",
|
||||
val image: String = "",
|
||||
val opacity: Float = 1f,
|
||||
val shape: Int = SCREEN_KEY_PILL,
|
||||
val toggle: Boolean = false,
|
||||
val labels: Boolean = true,
|
||||
) {
|
||||
}
|
||||
+5
-38
@@ -1,49 +1,16 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.drawable.Drawable
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
object InputButtonIcons {
|
||||
// Persisted symbol IDs must not depend on Android resource IDs or overlap Linux key codes.
|
||||
const val MENU = 1001
|
||||
const val KEYBOARD = 1002
|
||||
const val STEAM = 1003
|
||||
const val QUICK_ACCESS = 1004
|
||||
object ScreenKeyIcons {
|
||||
fun has(code: Int): Boolean = code in SET
|
||||
|
||||
fun has(code: Int): Boolean = code in codes
|
||||
private val SET = setOf(28, 14, 15, 57, 42, 54, 29, 97, 125, 103, 108, 105, 106)
|
||||
|
||||
private val keyCodes = listOf(28, 14, 15, 57, 42, 54, 29, 97, 125, 103, 108, 105, 106)
|
||||
val codes = listOf(MENU, KEYBOARD, STEAM, QUICK_ACCESS) + keyCodes
|
||||
|
||||
fun label(context: Context, code: Int): String = when (code) {
|
||||
MENU -> context.getString(R.string.touch_droiddeck_menu)
|
||||
KEYBOARD -> context.getString(R.string.drawer_keyboard)
|
||||
STEAM -> context.getString(R.string.drawer_steam_menu)
|
||||
QUICK_ACCESS -> context.getString(R.string.drawer_open_qam)
|
||||
else -> pcKeyLabel(context, code)
|
||||
}
|
||||
|
||||
fun drawable(context: Context, code: Int): Drawable? = when (code) {
|
||||
MENU -> R.drawable.ic_stat_session
|
||||
KEYBOARD -> R.drawable.ic_bottom_keyboard
|
||||
STEAM -> R.drawable.ic_input_steam
|
||||
QUICK_ACCESS -> R.drawable.ic_input_quick_access
|
||||
else -> null
|
||||
}?.let { context.getDrawable(it)?.mutate() }
|
||||
|
||||
fun draw(canvas: Canvas, code: Int, cx: Float, cy: Float, box: Float, paint: Paint, drawable: Drawable? = null): Boolean {
|
||||
if (drawable != null) {
|
||||
drawable.setTint(paint.color)
|
||||
val half = box / 2f
|
||||
drawable.setBounds((cx - half).toInt(), (cy - half).toInt(), (cx + half).toInt(), (cy + half).toInt())
|
||||
drawable.draw(canvas)
|
||||
return true
|
||||
}
|
||||
if (code !in keyCodes) return false
|
||||
fun draw(canvas: Canvas, code: Int, cx: Float, cy: Float, box: Float, paint: Paint): Boolean {
|
||||
if (!has(code)) return false
|
||||
val h = box / 2f
|
||||
val stroke = box * 0.085f
|
||||
val path = Path()
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,140 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val ICP_NAMES: Map<Int, String> = mapOf(
|
||||
103 to "KEY_UP", 106 to "KEY_RIGHT", 108 to "KEY_DOWN", 105 to "KEY_LEFT",
|
||||
28 to "KEY_ENTER", 1 to "KEY_ESC", 14 to "KEY_BKSP", 111 to "KEY_DEL", 15 to "KEY_TAB",
|
||||
57 to "KEY_SPACE", 29 to "KEY_CTRL_L", 97 to "KEY_CTRL_R", 110 to "KEY_INSERT",
|
||||
42 to "KEY_SHIFT_L", 54 to "KEY_SHIFT_R", 56 to "KEY_ALT_L", 100 to "KEY_ALT_R",
|
||||
102 to "KEY_HOME", 107 to "KEY_END", 99 to "KEY_PRTSCN", 104 to "KEY_PG_UP", 109 to "KEY_PG_DOWN",
|
||||
58 to "KEY_CAPS_LOCK", 69 to "KEY_NUM_LOCK", 41 to "KEY_GRAVE", 12 to "KEY_MINUS",
|
||||
11 to "KEY_0", 2 to "KEY_1", 3 to "KEY_2", 4 to "KEY_3", 5 to "KEY_4", 6 to "KEY_5",
|
||||
7 to "KEY_6", 8 to "KEY_7", 9 to "KEY_8", 10 to "KEY_9",
|
||||
30 to "KEY_A", 48 to "KEY_B", 46 to "KEY_C", 32 to "KEY_D", 18 to "KEY_E", 33 to "KEY_F",
|
||||
34 to "KEY_G", 35 to "KEY_H", 23 to "KEY_I", 36 to "KEY_J", 37 to "KEY_K", 38 to "KEY_L",
|
||||
50 to "KEY_M", 49 to "KEY_N", 24 to "KEY_O", 25 to "KEY_P", 16 to "KEY_Q", 19 to "KEY_R",
|
||||
31 to "KEY_S", 20 to "KEY_T", 22 to "KEY_U", 47 to "KEY_V", 17 to "KEY_W", 45 to "KEY_X",
|
||||
21 to "KEY_Y", 44 to "KEY_Z",
|
||||
26 to "KEY_BRACKET_LEFT", 27 to "KEY_BRACKET_RIGHT", 43 to "KEY_BACKSLASH", 53 to "KEY_SLASH",
|
||||
39 to "KEY_SEMICOLON", 51 to "KEY_COMMA", 52 to "KEY_PERIOD", 40 to "KEY_APOSTROPHE",
|
||||
59 to "KEY_F1", 60 to "KEY_F2", 61 to "KEY_F3", 62 to "KEY_F4", 63 to "KEY_F5", 64 to "KEY_F6",
|
||||
65 to "KEY_F7", 66 to "KEY_F8", 67 to "KEY_F9", 68 to "KEY_F10", 87 to "KEY_F11", 88 to "KEY_F12",
|
||||
79 to "KEY_KP_1", 80 to "KEY_KP_2", 81 to "KEY_KP_3", 75 to "KEY_KP_4", 76 to "KEY_KP_5",
|
||||
77 to "KEY_KP_6", 71 to "KEY_KP_7", 72 to "KEY_KP_8", 73 to "KEY_KP_9", 82 to "KEY_KP_0",
|
||||
)
|
||||
|
||||
private val ICP_CODES: Map<String, Int> = ICP_NAMES.entries.associate { (code, name) -> name to code }
|
||||
|
||||
private fun icpShape(shape: Int): String = when (shape) {
|
||||
SCREEN_KEY_CIRCLE -> "CIRCLE"
|
||||
SCREEN_KEY_RECT -> "RECT"
|
||||
SCREEN_KEY_ROUND -> "ROUND_RECT"
|
||||
SCREEN_KEY_DPAD -> "ROUND_RECT"
|
||||
SCREEN_KEY_STICK -> "ROUND_RECT"
|
||||
SCREEN_KEY_TRIGGER -> "ROUND_RECT"
|
||||
else -> "ROUND_RECT"
|
||||
}
|
||||
|
||||
private fun screenKeyShape(shape: String): Int = when (shape) {
|
||||
"CIRCLE" -> SCREEN_KEY_CIRCLE
|
||||
"RECT" -> SCREEN_KEY_RECT
|
||||
"SQUARE" -> SCREEN_KEY_ROUND
|
||||
else -> SCREEN_KEY_PILL
|
||||
}
|
||||
|
||||
fun exportScreenLayout(keys: List<ScreenKey>, widthPx: Int, density: Float): String {
|
||||
val snap = widthPx / 100f
|
||||
val elements = JSONArray()
|
||||
keys.forEach { key ->
|
||||
val binds = JSONArray()
|
||||
val directional = key.shape == SCREEN_KEY_DPAD || key.shape == SCREEN_KEY_STICK
|
||||
key.codes.forEach { code ->
|
||||
val name = ICP_NAMES[code]
|
||||
if (name != null) binds.put(name) else if (directional) binds.put("")
|
||||
}
|
||||
if (binds.length() == 0) return@forEach
|
||||
val shape = icpShape(key.shape)
|
||||
val sizePx = key.size * density
|
||||
val scale = when {
|
||||
key.shape == SCREEN_KEY_DPAD -> sizePx * 2.3f / (14f * snap)
|
||||
key.shape == SCREEN_KEY_STICK -> sizePx * 2.3f / (14f * snap)
|
||||
shape == "CIRCLE" -> sizePx / (6f * snap)
|
||||
else -> sizePx / (4f * snap)
|
||||
}
|
||||
elements.put(
|
||||
JSONObject()
|
||||
.put(
|
||||
"type",
|
||||
when (key.shape) {
|
||||
SCREEN_KEY_DPAD -> "D_PAD"
|
||||
SCREEN_KEY_STICK -> "STICK"
|
||||
else -> "BUTTON"
|
||||
},
|
||||
)
|
||||
.put("shape", shape)
|
||||
.put("customColor", 0)
|
||||
.put("bindings", binds)
|
||||
.put("scale", scale)
|
||||
.put("x", key.x)
|
||||
.put("y", key.y)
|
||||
.put("toggleSwitch", key.toggle)
|
||||
.put("swipeable", false)
|
||||
.put("text", key.text)
|
||||
.put("iconId", 0)
|
||||
.apply { if (key.opacity < 1f) put("opacity", key.opacity) },
|
||||
)
|
||||
}
|
||||
return JSONObject()
|
||||
.put("id", 0)
|
||||
.put("name", "DroidDeck")
|
||||
.put("cursorSpeed", 1.5f)
|
||||
.put("elements", elements)
|
||||
.toString()
|
||||
}
|
||||
|
||||
fun importScreenLayout(raw: String, widthPx: Int, density: Float): List<ScreenKey>? {
|
||||
val root = runCatching { JSONObject(raw) }.getOrNull() ?: return null
|
||||
val elements = root.optJSONArray("elements") ?: return null
|
||||
val snap = widthPx / 100f
|
||||
val keys = mutableListOf<ScreenKey>()
|
||||
for (i in 0 until elements.length()) {
|
||||
val element = elements.optJSONObject(i) ?: continue
|
||||
val type = element.optString("type", "BUTTON")
|
||||
if (type != "BUTTON" && type != "D_PAD" && type != "STICK") continue
|
||||
val binds = element.optJSONArray("bindings") ?: continue
|
||||
val raw = (0 until binds.length()).map { ICP_CODES[binds.optString(it)] ?: 0 }
|
||||
val codes = if (type == "D_PAD" || type == "STICK") {
|
||||
raw.dropLastWhile { it == 0 }.ifEmpty { listOf(0) }
|
||||
} else {
|
||||
raw.filter { it != 0 }
|
||||
}
|
||||
if (codes.isEmpty()) continue
|
||||
val dpad = type == "D_PAD"
|
||||
val stick = type == "STICK"
|
||||
val shape = when {
|
||||
dpad -> SCREEN_KEY_DPAD
|
||||
stick -> SCREEN_KEY_STICK
|
||||
else -> screenKeyShape(element.optString("shape"))
|
||||
}
|
||||
val scale = element.optDouble("scale", 1.0).toFloat().takeIf { it > 0f } ?: 1f
|
||||
val sizePx = when {
|
||||
dpad || stick -> 14f * snap * scale / 2.3f
|
||||
shape == SCREEN_KEY_CIRCLE -> 6f * snap * scale
|
||||
else -> 4f * snap * scale
|
||||
}
|
||||
keys += ScreenKey(
|
||||
codes = codes,
|
||||
x = element.optDouble("x", 0.5).toFloat().coerceIn(0f, 1f),
|
||||
y = element.optDouble("y", 0.5).toFloat().coerceIn(0f, 1f),
|
||||
size = (sizePx / density).roundToInt().coerceIn(ScreenKeys.MIN_SIZE, ScreenKeys.MAX_SIZE),
|
||||
text = element.optString("text").take(24),
|
||||
opacity = element.optDouble("opacity", 1.0).toFloat().coerceIn(0.2f, 1f),
|
||||
shape = shape,
|
||||
toggle = element.optBoolean("toggleSwitch"),
|
||||
)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
@@ -1,34 +1,26 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.view.Display
|
||||
import androidx.annotation.StringRes
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
|
||||
enum class SecondScreenMode(val id: String, @StringRes val label: Int, val bindings: String? = null) {
|
||||
enum class SecondScreenMode(val id: String, @StringRes val label: Int) {
|
||||
NONE("none", R.string.second_mode_none),
|
||||
KEYBOARD_TRACKPAD("keyboard-trackpad", R.string.second_mode_keyboard_trackpad, InputStockLayouts.TRACKPAD),
|
||||
KEYBOARD_TRACKPAD("keyboard-trackpad", R.string.second_mode_keyboard_trackpad),
|
||||
TERMINAL("terminal", R.string.second_mode_terminal),
|
||||
DECK_CONTROLS("deck-controls", R.string.second_mode_deck_controls, InputStockLayouts.DECK),
|
||||
CUSTOM_CONTROLS("custom-controls", R.string.input_profile_controls_mode),
|
||||
DECK_CONTROLS("deck-controls", R.string.second_mode_deck_controls),
|
||||
DASHBOARD("dashboard", R.string.second_mode_dashboard),
|
||||
DESKTOP("desktop", R.string.second_mode_desktop);
|
||||
|
||||
val controls: Boolean get() = this == CUSTOM_CONTROLS || bindings != null
|
||||
|
||||
fun panel(context: Context): String? = if (this == DECK_CONTROLS) SessionPrefs.deckPanel(context).bindings else bindings
|
||||
|
||||
companion object {
|
||||
fun opening(remembered: String?, deckPad: Boolean, dashboard: Boolean = false, bottomDesktop: Boolean = false, desktopSession: Boolean = false): SecondScreenMode {
|
||||
fun opening(remembered: String?, deckPad: Boolean, dashboard: Boolean = false, desktop: Boolean = false): SecondScreenMode {
|
||||
entries.firstOrNull {
|
||||
it != NONE && it.id == remembered && (it != DECK_CONTROLS || deckPad) && (it != DASHBOARD || dashboard) &&
|
||||
(it != DESKTOP || bottomDesktop)
|
||||
(it != DESKTOP || desktop)
|
||||
}?.let { return it }
|
||||
val picked = entries.any { it != NONE && it.id == remembered }
|
||||
return when {
|
||||
desktopSession -> KEYBOARD_TRACKPAD
|
||||
deckPad -> DECK_CONTROLS
|
||||
dashboard && !picked -> DASHBOARD
|
||||
else -> KEYBOARD_TRACKPAD
|
||||
@@ -52,7 +44,4 @@ object SecondScreenDisplays {
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
|
||||
fun editorCandidates(selected: Int, available: List<Int>, taken: (Int) -> Boolean): List<Int> =
|
||||
(listOf(selected) + available).distinct().filterNot(taken)
|
||||
}
|
||||
@@ -20,11 +20,7 @@ import java.util.ArrayDeque
|
||||
import java.util.UUID
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
class SteamControllerBle(private val context: Context, router: InputRouter? = null) {
|
||||
private val inputLock = router ?: Any()
|
||||
private var routedInput = router?.let(::TritonInputRouter)
|
||||
@Volatile private var stopped = false
|
||||
private var started = false
|
||||
class SteamControllerBle(private val context: Context) {
|
||||
private val thread = HandlerThread("steam-controller-ble").apply { start() }
|
||||
private val handler = Handler(thread.looper)
|
||||
private val outputReports = mutableMapOf<Int, UUID>()
|
||||
@@ -40,54 +36,22 @@ class SteamControllerBle(private val context: Context, router: InputRouter? = nu
|
||||
private var outputWritePending = false
|
||||
private var featureRead = 0L
|
||||
|
||||
fun start(): Unit = synchronized(inputLock) {
|
||||
if (stopped || started) return@synchronized
|
||||
started = true
|
||||
synchronized(slotLock) {
|
||||
slotOwner = this
|
||||
connected = false
|
||||
FakeInputWriter.writeTritonState(SLOT, 0x45, ByteArray(STATE_BYTES))
|
||||
FakeInputWriter.setTritonConnected(SLOT, false)
|
||||
}
|
||||
handler.post {
|
||||
if (stopped || slotOwner !== this || running) return@post
|
||||
running = true
|
||||
Log.i(TAG, "looking for a paired Steam Controller")
|
||||
connect()
|
||||
}
|
||||
fun start() = handler.post {
|
||||
if (running) return@post
|
||||
running = true
|
||||
Log.i(TAG, "looking for a paired Steam Controller")
|
||||
connect()
|
||||
}
|
||||
|
||||
private inline fun withSlot(block: () -> Unit) = synchronized(inputLock) {
|
||||
synchronized(slotLock) {
|
||||
if (!stopped && slotOwner === this) block()
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
synchronized(inputLock) {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
routedInput?.close()
|
||||
routedInput = null
|
||||
synchronized(slotLock) {
|
||||
if (slotOwner === this) {
|
||||
FakeInputWriter.writeTritonState(SLOT, if (reportId == 0x47) 0x47 else 0x45, ByteArray(STATE_BYTES))
|
||||
connected = false
|
||||
FakeInputWriter.setTritonConnected(SLOT, false)
|
||||
slotOwner = null
|
||||
}
|
||||
}
|
||||
}
|
||||
handler.post {
|
||||
running = false
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
disconnect("session ended")
|
||||
thread.quitSafely()
|
||||
}
|
||||
fun stop() = handler.post {
|
||||
running = false
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
disconnect("session ended")
|
||||
thread.quitSafely()
|
||||
}
|
||||
|
||||
private fun connect() {
|
||||
if (stopped || slotOwner !== this || !running || gatt != null) return
|
||||
if (!running || gatt != null) return
|
||||
if (SessionState.suspended) {
|
||||
handler.postDelayed(::connect, RETRY_MS)
|
||||
return
|
||||
@@ -120,17 +84,11 @@ class SteamControllerBle(private val context: Context, router: InputRouter? = nu
|
||||
featureRead = 0L
|
||||
queuedWrites.clear()
|
||||
outputReports.clear()
|
||||
synchronized(inputLock) {
|
||||
if (!stopped) {
|
||||
release()
|
||||
withSlot {
|
||||
connected = false
|
||||
FakeInputWriter.setTritonConnected(SLOT, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
connected = false
|
||||
FakeInputWriter.setTritonConnected(SLOT, false)
|
||||
g.disconnect()
|
||||
g.close()
|
||||
release()
|
||||
Log.i(TAG, "disconnected: $why")
|
||||
listener?.invoke()
|
||||
}
|
||||
@@ -161,12 +119,10 @@ class SteamControllerBle(private val context: Context, router: InputRouter? = nu
|
||||
}
|
||||
|
||||
override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
|
||||
withSlot {
|
||||
if (g !== gatt || descriptor.characteristic.uuid != input) return
|
||||
Log.i(TAG, "subscribed to report 0x%02x (status %d)".format(reportId, status))
|
||||
connected = status == BluetoothGatt.GATT_SUCCESS
|
||||
FakeInputWriter.setTritonConnected(SLOT, connected)
|
||||
}
|
||||
if (g !== gatt || descriptor.characteristic.uuid != input) return
|
||||
Log.i(TAG, "subscribed to report 0x%02x (status %d)".format(reportId, status))
|
||||
connected = status == BluetoothGatt.GATT_SUCCESS
|
||||
FakeInputWriter.setTritonConnected(SLOT, connected)
|
||||
listener?.invoke()
|
||||
if (connected) {
|
||||
refreshLizardMode()
|
||||
@@ -208,7 +164,6 @@ class SteamControllerBle(private val context: Context, router: InputRouter? = nu
|
||||
}
|
||||
|
||||
private fun subscribe(g: BluetoothGatt) {
|
||||
if (stopped || slotOwner !== this) return
|
||||
val service = g.getService(SERVICE)
|
||||
if (service == null) {
|
||||
Log.w(TAG, "no Valve service on \"${g.device.name}\" (attempt ${discoveries + 1}); services: " +
|
||||
@@ -252,31 +207,25 @@ class SteamControllerBle(private val context: Context, router: InputRouter? = nu
|
||||
.onFailure { Log.w(TAG, "could not refresh the service cache", it) }
|
||||
}
|
||||
|
||||
private fun refreshLizardMode() = withSlot {
|
||||
if (!connected) return@withSlot
|
||||
private fun refreshLizardMode() {
|
||||
if (!connected) return
|
||||
val msg = ByteArray(FEATURE_BYTES)
|
||||
msg[0] = 0x87.toByte(); msg[1] = 3; msg[2] = 9
|
||||
enqueueOutput(REPORT, msg)
|
||||
handler.postDelayed(::refreshLizardMode, LIZARD_REFRESH_MS)
|
||||
}
|
||||
|
||||
private fun onState(data: ByteArray) = withSlot {
|
||||
if (data.size < STATE_BYTES) return@withSlot
|
||||
val timing = InputTiming.begin()
|
||||
FakeInputWriter.writeTritonState(SLOT, reportId, routedInput?.route(data) ?: data)
|
||||
InputTiming.end(timing)
|
||||
private fun onState(data: ByteArray) {
|
||||
if (data.size < STATE_BYTES) return
|
||||
FakeInputWriter.writeTritonState(SLOT, reportId, data)
|
||||
}
|
||||
|
||||
private fun release() = synchronized(inputLock) {
|
||||
if (stopped) return@synchronized
|
||||
routedInput?.release()
|
||||
withSlot {
|
||||
FakeInputWriter.writeTritonState(SLOT, if (reportId == 0x47) 0x47 else 0x45, ByteArray(STATE_BYTES))
|
||||
}
|
||||
private fun release() {
|
||||
FakeInputWriter.writeTritonState(SLOT, if (reportId == 0x47) 0x47 else 0x45, ByteArray(STATE_BYTES))
|
||||
}
|
||||
|
||||
private fun pollOutput() = withSlot {
|
||||
if (!running || !connected || gatt == null) return@withSlot
|
||||
private fun pollOutput() {
|
||||
if (!running || !connected || gatt == null) return
|
||||
FakeInputWriter.readTritonOutput(SLOT, outputSequence)?.let { output ->
|
||||
outputSequence = ByteBuffer.wrap(output, 0, 8).order(ByteOrder.LITTLE_ENDIAN).long
|
||||
sendOutputReport(output[8] != 0.toByte(), output.copyOfRange(9, output.size))
|
||||
@@ -290,7 +239,7 @@ class SteamControllerBle(private val context: Context, router: InputRouter? = nu
|
||||
handler.postDelayed(::pollOutput, OUTPUT_POLL_MS)
|
||||
}
|
||||
|
||||
private fun onFeatureRead(value: ByteArray?) = withSlot {
|
||||
private fun onFeatureRead(value: ByteArray?) {
|
||||
outputWritePending = false
|
||||
if (featureRead != 0L) {
|
||||
if (value == null) Log.w(TAG, "feature read failed")
|
||||
@@ -318,14 +267,14 @@ class SteamControllerBle(private val context: Context, router: InputRouter? = nu
|
||||
writeNextOutput()
|
||||
}
|
||||
|
||||
private fun writeNextOutput() = withSlot {
|
||||
if (outputWritePending) return@withSlot
|
||||
val g = gatt ?: return@withSlot
|
||||
val (uuid, value) = queuedWrites.pollFirst() ?: return@withSlot
|
||||
private fun writeNextOutput() {
|
||||
if (outputWritePending) return
|
||||
val g = gatt ?: return
|
||||
val (uuid, value) = queuedWrites.pollFirst() ?: return
|
||||
val characteristic = g.getService(SERVICE)?.getCharacteristic(uuid)
|
||||
if (characteristic == null) {
|
||||
Log.w(TAG, "BLE output characteristic $uuid disappeared")
|
||||
return@withSlot
|
||||
return
|
||||
}
|
||||
if (value == null) {
|
||||
if (g.readCharacteristic(characteristic)) {
|
||||
@@ -334,7 +283,7 @@ class SteamControllerBle(private val context: Context, router: InputRouter? = nu
|
||||
queuedWrites.addFirst(uuid to null)
|
||||
handler.postDelayed(::writeNextOutput, OUTPUT_RETRY_MS)
|
||||
}
|
||||
return@withSlot
|
||||
return
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
||||
@@ -350,8 +299,6 @@ class SteamControllerBle(private val context: Context, router: InputRouter? = nu
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val slotLock = Any()
|
||||
@Volatile private var slotOwner: SteamControllerBle? = null
|
||||
private const val TAG = "SteamControllerBle"
|
||||
private const val SLOT = 0
|
||||
private const val RETRY_MS = 5_000L
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class TritonInputRouter(private val router: InputRouter) {
|
||||
private val channels = InputControl.entries.map { router.channel(InputRole.PHYSICAL, it) }.toTypedArray()
|
||||
private var last: ByteArray? = null
|
||||
private var buffer = ByteArray(45)
|
||||
private val snapshot: () -> Unit = { last?.let { read(it, false) } }
|
||||
|
||||
init { router.beforeProfileChange(snapshot) }
|
||||
|
||||
fun route(data: ByteArray): ByteArray = synchronized(router) {
|
||||
if (data.size < 45) return@synchronized data
|
||||
last = data
|
||||
if (channels.all { it.nativeIdentity() }) return@synchronized data
|
||||
if (buffer.size != data.size) buffer = ByteArray(data.size)
|
||||
data.copyInto(buffer)
|
||||
putBits(buffer, bits(data) and CONTROL_BITS.inv())
|
||||
for (offset in 5..27 step 2) putShort(buffer, offset, 0)
|
||||
read(data, true)
|
||||
buffer
|
||||
}
|
||||
|
||||
private fun read(data: ByteArray, emit: Boolean) {
|
||||
val bits = bits(data)
|
||||
for (control in InputControl.entries) {
|
||||
val channel = channels[control.ordinal]
|
||||
val offset = offset(control)
|
||||
val pair = control.dimensions == 2
|
||||
val x = if (offset != 0) axis(data, offset) else if (bits and bit(control) != 0) 1f else 0f
|
||||
val y = if (pair) axis(data, offset + 2) * if (control.isTrackpad) 1f else -1f else 0f
|
||||
val contact = control.isTrackpad && bits and bit(control) != 0
|
||||
if (!emit) { channel.observe(x, y, contact); continue }
|
||||
val target = channel.update(x, y, contact) ?: continue
|
||||
val targetOffset = offset(target)
|
||||
if (targetOffset != 0) {
|
||||
val value = shortValue(x)
|
||||
if (target.dimensions == 2) {
|
||||
val second = shortValue(y * if (target.isTrackpad) 1f else -1f)
|
||||
val oldX = short(buffer, targetOffset).toLong()
|
||||
val oldY = short(buffer, targetOffset + 2).toLong()
|
||||
if (value.toLong() * value + second.toLong() * second >= oldX * oldX + oldY * oldY) {
|
||||
putShort(buffer, targetOffset, value)
|
||||
putShort(buffer, targetOffset + 2, second)
|
||||
}
|
||||
val touch = if (control.isTrackpad) contact else bits and bit(control) != 0
|
||||
if (touch) putBits(buffer, bits(buffer) or bit(target))
|
||||
} else {
|
||||
if (value >= short(buffer, targetOffset)) putShort(buffer, targetOffset, value)
|
||||
if (bits and bit(control) != 0) putBits(buffer, bits(buffer) or bit(target))
|
||||
}
|
||||
} else if (x > 0.5f) putBits(buffer, bits(buffer) or bit(target))
|
||||
if (target == InputControl.LEFT_CLICK || target == InputControl.RIGHT_CLICK) {
|
||||
val pressureOffset = if (target == InputControl.LEFT_CLICK) 21 else 27
|
||||
val from = if (control == InputControl.LEFT_CLICK) 21 else if (control == InputControl.RIGHT_CLICK) 27 else 0
|
||||
if (from != 0) putShort(buffer, pressureOffset, short(data, from))
|
||||
else if (x > 0.5f) putShort(buffer, pressureOffset, 32767)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun release() = synchronized(router) { channels.forEach { it.release() }; last = null }
|
||||
fun close() = synchronized(router) { release(); channels.forEach { it.close() }; router.removeSnapshot(snapshot) }
|
||||
|
||||
private fun bits(data: ByteArray): Int = (data[1].toInt() and 255) or ((data[2].toInt() and 255) shl 8) or
|
||||
((data[3].toInt() and 255) shl 16) or ((data[4].toInt() and 255) shl 24)
|
||||
private fun putBits(data: ByteArray, value: Int) { for (i in 0..3) data[i + 1] = (value ushr (i * 8)).toByte() }
|
||||
private fun short(data: ByteArray, offset: Int) = ((data[offset].toInt() and 255) or (data[offset + 1].toInt() shl 8)).toShort().toInt()
|
||||
private fun axis(data: ByteArray, offset: Int): Float = short(data, offset).let { it / if (it < 0) 32768f else 32767f }
|
||||
private fun shortValue(value: Float) = (value * if (value < 0f) 32768f else 32767f).roundToInt().coerceIn(-32768, 32767)
|
||||
private fun putShort(data: ByteArray, offset: Int, value: Int) { data[offset] = value.toByte(); data[offset + 1] = (value shr 8).toByte() }
|
||||
|
||||
companion object {
|
||||
private const val CONTROL_BITS = 0x0fffffff
|
||||
private fun bit(c: InputControl): Int = when (c) {
|
||||
InputControl.A -> 0x1; InputControl.B -> 0x2; InputControl.X -> 0x4; InputControl.Y -> 0x8
|
||||
InputControl.QAM -> 0x10; InputControl.R3 -> 0x20; InputControl.START -> 0x40
|
||||
InputControl.R4 -> 0x80; InputControl.R5 -> 0x100; InputControl.RB -> 0x200
|
||||
InputControl.DOWN -> 0x400; InputControl.RIGHT -> 0x800; InputControl.LEFT -> 0x1000; InputControl.UP -> 0x2000
|
||||
InputControl.SELECT -> 0x4000; InputControl.L3 -> 0x8000; InputControl.GUIDE -> 0x10000
|
||||
InputControl.L4 -> 0x20000; InputControl.L5 -> 0x40000; InputControl.LB -> 0x80000
|
||||
InputControl.RS -> 0x100000; InputControl.RIGHT_PAD -> 0x200000; InputControl.RIGHT_CLICK -> 0x400000
|
||||
InputControl.RT -> 0x800000; InputControl.LS -> 0x1000000; InputControl.LEFT_PAD -> 0x2000000
|
||||
InputControl.LEFT_CLICK -> 0x4000000; InputControl.LT -> 0x8000000
|
||||
}
|
||||
private fun offset(c: InputControl): Int = when (c) {
|
||||
InputControl.LT -> 5; InputControl.RT -> 7; InputControl.LS -> 9; InputControl.RS -> 13
|
||||
InputControl.LEFT_PAD -> 17; InputControl.RIGHT_PAD -> 23
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ object DesktopCatalog {
|
||||
const val CATALOG_URL = "https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/desktop.json"
|
||||
const val STEAM_SEED_URL = "https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/steam-seed.json"
|
||||
const val PROTON_SEED_ID = "proton-arm64"
|
||||
const val APPIMAGE_EXEC = "/usr/local/bin/droiddeck-appimage-exec"
|
||||
|
||||
class Entry(
|
||||
val id: String, val name: String, val tier: Int, val version: String, val kind: String,
|
||||
@@ -217,7 +216,7 @@ object DesktopCatalog {
|
||||
target.setExecutable(true, false)
|
||||
FileUtils.writeString(File(root, "usr/share/applications/droiddeck-${entry.id}.desktop"),
|
||||
"[Desktop Entry]\nType=Application\nName=${entry.name}\n" +
|
||||
"Exec=$APPIMAGE_EXEC /opt/appimages/${entry.id}.AppImage\n" +
|
||||
"Exec=env APPIMAGE_EXTRACT_AND_RUN=1 /opt/appimages/${entry.id}.AppImage\n" +
|
||||
"Icon=${entry.icon}\nTerminal=false\nCategories=${entry.category};\n")
|
||||
}
|
||||
else -> {
|
||||
|
||||
@@ -21,11 +21,9 @@ object LegacyNames {
|
||||
"etc/bannerlator-network.json" to "etc/droiddeck-network.json",
|
||||
"etc/bannerlator-wifi-scan-request" to "etc/droiddeck-wifi-scan-request",
|
||||
)
|
||||
private val MENU_REWRITES = listOf(
|
||||
Triple("usr/local/share/applications", "/usr/local/bin/bannerlator-", "/usr/local/bin/droiddeck-"),
|
||||
Triple("usr/share/applications", "Exec=env APPIMAGE_EXTRACT_AND_RUN=1 /opt/appimages/",
|
||||
"Exec=${DesktopCatalog.APPIMAGE_EXEC} /opt/appimages/"),
|
||||
)
|
||||
private const val MENU_DIR = "usr/local/share/applications"
|
||||
private const val OLD_BIN = "/usr/local/bin/bannerlator-"
|
||||
private const val NEW_BIN = "/usr/local/bin/droiddeck-"
|
||||
|
||||
@JvmStatic
|
||||
@Synchronized
|
||||
@@ -97,16 +95,14 @@ object LegacyNames {
|
||||
}
|
||||
|
||||
private fun rewriteMenuEntries(root: File) {
|
||||
for ((dir, old, new) in MENU_REWRITES) {
|
||||
val entries = File(root, dir).listFiles { f -> f.isFile && f.name.startsWith("droiddeck-") && f.name.endsWith(".desktop") } ?: continue
|
||||
for (entry in entries) {
|
||||
val text = FileUtils.readString(entry) ?: continue
|
||||
if (!text.contains(old)) continue
|
||||
val staged = File(entry.parentFile, entry.name + ".staged")
|
||||
if (!FileUtils.writeString(staged, text.replace(old, new)) || !staged.renameTo(entry)) {
|
||||
staged.delete()
|
||||
Log.w(TAG, "could not rewrite $entry")
|
||||
}
|
||||
val entries = File(root, MENU_DIR).listFiles { f -> f.isFile && f.name.startsWith("droiddeck-") && f.name.endsWith(".desktop") } ?: return
|
||||
for (entry in entries) {
|
||||
val text = FileUtils.readString(entry) ?: continue
|
||||
if (!text.contains(OLD_BIN)) continue
|
||||
val staged = File(entry.parentFile, entry.name + ".staged")
|
||||
if (!FileUtils.writeString(staged, text.replace(OLD_BIN, NEW_BIN)) || !staged.renameTo(entry)) {
|
||||
staged.delete()
|
||||
Log.w(TAG, "could not rewrite $entry")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,12 +82,8 @@ public final class LinuxRuntimeInstaller {
|
||||
private LinuxRuntimeInstaller() {}
|
||||
|
||||
public static String installedVersion(Context context) {
|
||||
return LinuxRuntime.isInstalled(context) ? rootfsVersion(context) : null;
|
||||
}
|
||||
|
||||
private static String rootfsVersion(Context context) {
|
||||
File marker = new File(LinuxRuntime.rootDir(context), VERSION_FILE);
|
||||
if (!marker.isFile()) return null;
|
||||
if (!marker.isFile() || !LinuxRuntime.isInstalled(context)) return null;
|
||||
String v = FileUtils.readString(marker);
|
||||
return v == null ? null : v.trim();
|
||||
}
|
||||
@@ -148,10 +144,6 @@ public final class LinuxRuntimeInstaller {
|
||||
}
|
||||
};
|
||||
boolean desktop = DesktopCatalog.INSTANCE.desktopInstalled(app);
|
||||
if (packsOnly(app, release, progress)) {
|
||||
job.ok = true;
|
||||
return true;
|
||||
}
|
||||
job.ok = installOnce(app, release, progress);
|
||||
if (job.ok && desktop) keepDesktop(app, progress);
|
||||
return job.ok;
|
||||
@@ -291,31 +283,6 @@ public final class LinuxRuntimeInstaller {
|
||||
for (File file : files) file.delete();
|
||||
}
|
||||
|
||||
private static kotlin.jvm.functions.Function1<AddonPack.State, kotlin.Unit> packProgress(ProgressListener listener, String stage) {
|
||||
return state -> {
|
||||
if (listener != null && state instanceof AddonPack.State.Downloading) {
|
||||
listener.onProgress(Step.DOWNLOADING, stage, Math.round(((AddonPack.State.Downloading) state).getProgress() * 100f));
|
||||
}
|
||||
return kotlin.Unit.INSTANCE;
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean packsOnly(Context context, Release release, ProgressListener listener) {
|
||||
if (!LinuxRuntime.hasRootfs(context) || LinuxRuntime.isInstalled(context) || !release.version.equals(rootfsVersion(context))) return false;
|
||||
File root = LinuxRuntime.rootDir(context);
|
||||
try {
|
||||
recoverInterruptedSwap(root, new File(root.getParentFile(), LinuxRuntime.DIR + ".new"), new File(root.getParentFile(), LinuxRuntime.DIR + ".old"));
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "packs only: " + e);
|
||||
return false;
|
||||
}
|
||||
String downloading = context.getString(R.string.rtinst_downloading);
|
||||
if (listener != null) listener.onProgress(Step.DOWNLOADING, downloading, 0);
|
||||
return AddonPack.PROOT.await(context, () -> true, packProgress(listener, downloading))
|
||||
&& AddonPack.DIRECTAUDIO.await(context, () -> true, packProgress(listener, downloading))
|
||||
&& LinuxRuntime.isInstalled(context);
|
||||
}
|
||||
|
||||
private static boolean installOnce(Context context, Release release, ProgressListener listener) {
|
||||
File archive = new File(context.getCacheDir(), archiveName(release));
|
||||
boolean keepPartial = false;
|
||||
@@ -327,10 +294,10 @@ public final class LinuxRuntimeInstaller {
|
||||
emptyTrash(LinuxRuntime.rootDir(context).getParentFile());
|
||||
String downloading = context.getString(R.string.rtinst_downloading);
|
||||
if (listener != null) listener.onProgress(Step.DOWNLOADING, downloading, 0);
|
||||
if (!LinuxRuntime.patchedProot(context) && !AddonPack.PROOT.await(context, () -> true, packProgress(listener, downloading))
|
||||
if (!LinuxRuntime.patchedProot(context) && !AddonPack.PROOT.await(context)
|
||||
&& !SessionPrefs.allowStockProot(context))
|
||||
throw new IOException("Cannot download proot");
|
||||
if (!AddonPack.DIRECTAUDIO.await(context, () -> true, packProgress(listener, downloading))) throw new IOException("Cannot download DirectAudio");
|
||||
if (!AddonPack.DIRECTAUDIO.await(context)) throw new IOException("Cannot download DirectAudio");
|
||||
boolean ok = Downloader.downloadFile(release.url, archive, true, release.sha256, (fraction) -> {
|
||||
if (listener != null) {
|
||||
listener.onProgress(Step.DOWNLOADING, downloading,
|
||||
|
||||
@@ -35,7 +35,6 @@ object UserApps {
|
||||
private const val GITHUB_REPO = "github-repo"
|
||||
private const val GITHUB_TAG = "github-tag"
|
||||
private const val GITHUB_PUBLISHED = "github-published"
|
||||
private const val GITHUB_ASSET = "github-asset"
|
||||
|
||||
enum class Kind { SCRIPT, APPIMAGE, FLATPAK }
|
||||
|
||||
@@ -57,7 +56,7 @@ object UserApps {
|
||||
sealed class Source {
|
||||
class Script(val file: File) : Source()
|
||||
class AppImage(val file: File) : Source()
|
||||
class GitHub(val repo: String, val asset: String? = null) : Source()
|
||||
class GitHub(val repo: String) : Source()
|
||||
class Flatpak(val id: String) : Source()
|
||||
class FlatpakBundle(val file: File) : Source()
|
||||
}
|
||||
@@ -114,7 +113,7 @@ object UserApps {
|
||||
when (val s = request.source) {
|
||||
is Source.Script -> addScript(context, s.file, name, icon, request.meta, onProgress)
|
||||
is Source.AppImage -> AppImageManager.import(context, s.file, name, icon, request.meta) { onProgress(it, -1) }
|
||||
is Source.GitHub -> addFromGitHub(context, s.repo, s.asset, name, icon, onProgress)
|
||||
is Source.GitHub -> addFromGitHub(context, s.repo, name, icon, onProgress)
|
||||
is Source.Flatpak -> addFlatpak(context, s.id, name, icon, onProgress)
|
||||
is Source.FlatpakBundle -> addFlatpakBundle(context, s.file, name, icon, onProgress)
|
||||
}
|
||||
@@ -306,26 +305,14 @@ object UserApps {
|
||||
return if (repo.isEmpty() || repo == "." || repo == "..") null else "${m.groupValues[1]}/$repo"
|
||||
}
|
||||
|
||||
fun githubAsset(input: String): String? =
|
||||
Regex("github\\.com/[\\w.-]+/[\\w.-]+/releases/(?:download/[^/]+|latest/download)/([^/?#]+\\.appimage)(?:[?#].*)?$", RegexOption.IGNORE_CASE)
|
||||
.find(input.trim())?.groupValues?.get(1)?.let { runCatching { java.net.URLDecoder.decode(it, "UTF-8") }.getOrNull() }
|
||||
|
||||
private fun assetVariant(name: String): Set<String> =
|
||||
name.lowercase().replace(Regex("x86[_-]64|amd64"), "x64").replace(Regex("x86[_-]32|i[36]86"), "x32")
|
||||
.split(Regex("[^a-z0-9]+")).filter { it.isNotEmpty() && !Regex("[0-9a-f]{7,}|v?[0-9]+").matches(it) }.toSet()
|
||||
|
||||
internal fun pickAsset(names: List<String>, wanted: String? = null): String? {
|
||||
internal fun pickAsset(names: List<String>): String? {
|
||||
val images = names.filter { it.endsWith(".appimage", ignoreCase = true) }
|
||||
if (wanted != null) {
|
||||
images.firstOrNull { it == wanted }?.let { return it }
|
||||
images.firstOrNull { assetVariant(it) == assetVariant(wanted) }?.let { return it }
|
||||
}
|
||||
return images.firstOrNull { Regex("aarch64|arm64", RegexOption.IGNORE_CASE).containsMatchIn(it) }
|
||||
?: images.firstOrNull { !Regex("x86_64|x86-64|amd64|x64|i[36]86|armhf|armv7", RegexOption.IGNORE_CASE).containsMatchIn(it) }
|
||||
?: images.firstOrNull { Regex("x86_64|x86-64|amd64|x64", RegexOption.IGNORE_CASE).containsMatchIn(it) }
|
||||
}
|
||||
|
||||
private fun latestRelease(context: Context, repo: String, wanted: String? = null): Pair<Release?, String?> {
|
||||
private fun latestRelease(context: Context, repo: String): Pair<Release?, String?> {
|
||||
val body = Downloader.downloadString("https://api.github.com/repos/$repo/releases?per_page=15")
|
||||
val releases = body?.let { runCatching { JSONArray(it) }.getOrNull() }
|
||||
?: return null to context.getString(R.string.user_apps_github_unreachable, repo)
|
||||
@@ -334,7 +321,7 @@ object UserApps {
|
||||
if (r.optBoolean("draft")) return@mapNotNull null
|
||||
val assets = r.optJSONArray("assets") ?: return@mapNotNull null
|
||||
val byName = (0 until assets.length()).associate { j -> assets.getJSONObject(j).let { it.getString("name") to it } }
|
||||
pickAsset(byName.keys.toList(), wanted)?.let { asset ->
|
||||
pickAsset(byName.keys.toList())?.let { asset ->
|
||||
r.optBoolean("prerelease") to byName.getValue(asset).let { a ->
|
||||
Release(r.optString("tag_name", asset), r.optString("published_at"), asset, a.getString("browser_download_url"),
|
||||
Hashes.githubSha256(a.optString("digest")))
|
||||
@@ -357,11 +344,11 @@ object UserApps {
|
||||
}
|
||||
|
||||
private fun githubMeta(repo: String, release: Release) =
|
||||
mapOf(GITHUB_REPO to repo, GITHUB_TAG to release.tag, GITHUB_PUBLISHED to release.published, GITHUB_ASSET to release.asset)
|
||||
mapOf(GITHUB_REPO to repo, GITHUB_TAG to release.tag, GITHUB_PUBLISHED to release.published)
|
||||
|
||||
private fun addFromGitHub(context: Context, repo: String, asset: String?, name: String?, icon: File?, onProgress: (String, Int) -> Unit): String? {
|
||||
private fun addFromGitHub(context: Context, repo: String, name: String?, icon: File?, onProgress: (String, Int) -> Unit): String? {
|
||||
onProgress(context.getString(R.string.user_apps_github_checking, repo), -1)
|
||||
val (release, problem) = latestRelease(context, repo, asset)
|
||||
val (release, problem) = latestRelease(context, repo)
|
||||
if (release == null) return problem
|
||||
return withDownload(context, release, onProgress) { file ->
|
||||
AppImageManager.import(context, file, name ?: repo.substringAfter('/'), icon, githubMeta(repo, release)) { onProgress(it, -1) }
|
||||
@@ -394,10 +381,9 @@ object UserApps {
|
||||
|
||||
fun checkUpdate(context: Context, app: App): UpdateCheck {
|
||||
val repo = app.repo ?: return UpdateCheck.Failed(context.getString(R.string.user_apps_update_failed))
|
||||
val dir = AppImageManager.dir(context, app.key.substringAfter(':'))
|
||||
val (release, problem) = latestRelease(context, repo, FileUtils.readString(File(dir, GITHUB_ASSET))?.trim()?.takeIf { it.isNotEmpty() })
|
||||
val (release, problem) = latestRelease(context, repo)
|
||||
if (release == null) return UpdateCheck.Failed(problem ?: context.getString(R.string.user_apps_update_failed))
|
||||
val published = FileUtils.readString(File(dir, GITHUB_PUBLISHED))?.trim().orEmpty()
|
||||
val published = FileUtils.readString(File(AppImageManager.dir(context, app.key.substringAfter(':')), GITHUB_PUBLISHED))?.trim().orEmpty()
|
||||
val newer = if (published.isNotEmpty() && release.published.isNotEmpty()) release.published > published else release.tag != app.version
|
||||
return if (newer) UpdateCheck.Available(release) else UpdateCheck.Current(app.version ?: release.tag)
|
||||
}
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
package com.droiddeck.launcher.runtime
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.Downloader
|
||||
import com.droiddeck.launcher.core.Hashes
|
||||
import com.droiddeck.launcher.stores.StoreNet
|
||||
import org.json.JSONArray
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.net.URLEncoder
|
||||
|
||||
object WowAddonCatalog {
|
||||
private const val TAG = "WowAddonCatalog"
|
||||
enum class Sort { POPULAR, UPDATED, NAME, DOWNLOADS }
|
||||
|
||||
class Category(val id: String, val name: String)
|
||||
|
||||
class Client(val folder: String, val family: String?)
|
||||
|
||||
class Query(val text: String = "", val category: String? = null, val client: Client, val sort: Sort = Sort.POPULAR)
|
||||
|
||||
class Addon(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val author: String,
|
||||
val summary: String,
|
||||
val downloads: Long,
|
||||
val updated: Long,
|
||||
val thumb: String?,
|
||||
)
|
||||
|
||||
class Page(val items: List<Addon>, val more: Boolean)
|
||||
|
||||
internal class Entry(val addon: Addon, val category: String, val flavors: Set<String>?, val monthly: Long = 0L)
|
||||
|
||||
internal class Cat(val id: String, val name: String, val parents: List<String>)
|
||||
|
||||
private const val WOWI = "https://api.mmoui.com/v3/game/WOW/"
|
||||
private const val CURSEFORGE = "https://www.curseforge.com/wow/search?class=addons"
|
||||
private const val WOWI_FILES = "wowi-filelist.json"
|
||||
internal const val FRESH_MS = 6L * 60 * 60 * 1000
|
||||
|
||||
internal const val RETAIL = "_retail_"
|
||||
internal const val ERA = "_classic_era_"
|
||||
internal const val CLASSIC = "_classic_"
|
||||
internal const val ANNIVERSARY = "_anniversary_"
|
||||
internal const val FOREVER = "_forever_"
|
||||
|
||||
private val TEST_SUFFIX = Regex("_(beta|ptr|xptr)_$")
|
||||
|
||||
internal fun flavorOf(version: String): String? {
|
||||
val major = version.substringBefore('.').trim().toIntOrNull() ?: return null
|
||||
return when {
|
||||
major == 1 -> ERA
|
||||
major == 2 -> ANNIVERSARY
|
||||
major in 3..5 -> CLASSIC
|
||||
major >= 10 -> RETAIL
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
internal fun baseFolder(folder: String): String = folder.replace(TEST_SUFFIX, "_")
|
||||
|
||||
internal fun folderFamily(folder: String): String? = when (val base = baseFolder(folder)) {
|
||||
FOREVER -> RETAIL
|
||||
RETAIL, ERA, CLASSIC, ANNIVERSARY -> base
|
||||
else -> null
|
||||
}
|
||||
|
||||
internal fun buildInfoVersion(buildInfo: String, product: String): String? {
|
||||
val lines = buildInfo.lineSequence().map { it.trim() }.filter { it.isNotEmpty() }.toList()
|
||||
if (lines.isEmpty()) return null
|
||||
val header = lines.first().split('|').map { it.substringBefore('!').trim().lowercase() }
|
||||
val productAt = header.indexOf("product")
|
||||
val versionAt = header.indexOf("version")
|
||||
if (productAt < 0 || versionAt < 0) return null
|
||||
return lines.drop(1).map { it.split('|') }
|
||||
.firstOrNull { it.getOrNull(productAt)?.trim().equals(product, ignoreCase = true) }
|
||||
?.getOrNull(versionAt)?.trim()?.ifEmpty { null }
|
||||
}
|
||||
|
||||
internal fun flavorProduct(flavorInfo: String): String? =
|
||||
flavorInfo.lineSequence().map { it.trim() }.filter { it.isNotEmpty() && '!' !in it }.firstOrNull()
|
||||
|
||||
internal fun clientVersion(flavorDir: File): String? {
|
||||
val product = runCatching { File(flavorDir, ".flavor.info").readText() }.getOrNull()?.let(::flavorProduct) ?: return null
|
||||
val info = runCatching { File(flavorDir.parentFile, ".build.info").readText() }.getOrNull() ?: return null
|
||||
return buildInfoVersion(info, product)
|
||||
}
|
||||
|
||||
internal fun client(folder: String, version: String?): Client =
|
||||
Client(folder, version?.let(::flavorOf) ?: folderFamily(folder))
|
||||
|
||||
fun client(target: WowAddons.Target): Client {
|
||||
val dir = target.addOns.parentFile?.parentFile
|
||||
return client(target.flavor, dir?.let(::clientVersion))
|
||||
}
|
||||
|
||||
internal fun flavorMatches(flavors: Set<String>?, family: String?): Boolean =
|
||||
flavors == null || family == null || family in flavors
|
||||
|
||||
internal fun parseWowiFiles(json: String): List<Entry> {
|
||||
val arr = JSONArray(json)
|
||||
val out = ArrayList<Entry>(arr.length())
|
||||
for (i in 0 until arr.length()) {
|
||||
val o = arr.optJSONObject(i) ?: continue
|
||||
val id = o.optString("UID")
|
||||
if (id.isEmpty()) continue
|
||||
val compat = o.optJSONArray("UICompatibility")
|
||||
val flavors = if (compat == null || compat.length() == 0) null else {
|
||||
val set = HashSet<String>()
|
||||
for (k in 0 until compat.length()) compat.optJSONObject(k)?.optString("version")?.let(::flavorOf)?.let(set::add)
|
||||
set
|
||||
}
|
||||
val thumbs = o.optJSONArray("UIIMG_Thumbs")
|
||||
val thumb = if (thumbs != null && thumbs.length() > 0) thumbs.optString(0).ifEmpty { null } else null
|
||||
val addon = Addon(
|
||||
id, o.optString("UIName"), o.optString("UIAuthorName"), o.optString("UIVersion"),
|
||||
o.optString("UIDownloadTotal").toLongOrNull() ?: 0L, o.optLong("UIDate"), thumb,
|
||||
)
|
||||
out.add(Entry(addon, o.optString("UICATID"), flavors, o.optString("UIDownloadMonthly").toLongOrNull() ?: 0L))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
internal fun parseWowiCategories(json: String): List<Cat> {
|
||||
val arr = JSONArray(json)
|
||||
val out = ArrayList<Cat>(arr.length())
|
||||
for (i in 0 until arr.length()) {
|
||||
val o = arr.optJSONObject(i) ?: continue
|
||||
val parents = o.optJSONArray("UICATParentIDs")
|
||||
out.add(Cat(o.optString("UICATID"), o.optString("UICATTitle"), (0 until (parents?.length() ?: 0)).map { parents!!.optString(it) }))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
internal fun filterWowi(entries: List<Entry>, cats: List<Cat>, query: Query): List<Addon> {
|
||||
val parents = cats.associate { it.id to it.parents }
|
||||
val text = query.text.trim().lowercase()
|
||||
val list = entries.filter { e ->
|
||||
flavorMatches(e.flavors, query.client.family) &&
|
||||
(query.category == null || e.category == query.category || query.category in (parents[e.category] ?: emptyList())) &&
|
||||
(text.isEmpty() || e.addon.name.lowercase().contains(text) || e.addon.author.lowercase().contains(text))
|
||||
}
|
||||
return when (query.sort) {
|
||||
Sort.POPULAR -> list.sortedWith(compareByDescending<Entry> { it.monthly }.thenByDescending { it.addon.downloads })
|
||||
Sort.DOWNLOADS -> list.sortedByDescending { it.addon.downloads }
|
||||
Sort.UPDATED -> list.sortedByDescending { it.addon.updated }
|
||||
Sort.NAME -> list.sortedBy { it.addon.name.lowercase() }
|
||||
}.map { it.addon }
|
||||
}
|
||||
|
||||
internal fun pageOf(all: List<Addon>, page: Int, pageSize: Int): Page {
|
||||
val from = (page * pageSize).coerceAtMost(all.size)
|
||||
val to = (from + pageSize).coerceAtMost(all.size)
|
||||
return Page(all.subList(from, to), to < all.size)
|
||||
}
|
||||
|
||||
internal fun cached(file: File, now: Long, fetch: () -> String?): String? {
|
||||
val kept = if (file.isFile) file.readText().takeIf(::isJsonArray) else null
|
||||
if (kept != null && isFresh(file, now)) return kept
|
||||
val body = fetch()?.takeIf(::isJsonArray)
|
||||
if (body != null) {
|
||||
val part = File(file.parentFile, "${file.name}.${java.util.UUID.randomUUID()}.part")
|
||||
runCatching {
|
||||
file.parentFile?.mkdirs()
|
||||
part.writeText(body)
|
||||
part.setLastModified(now)
|
||||
if (!part.renameTo(file)) Log.w(TAG, "could not keep ${file.name}")
|
||||
}.onFailure { Log.w(TAG, "could not keep ${file.name}", it) }
|
||||
part.delete()
|
||||
return body
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
private fun isJsonArray(body: String): Boolean =
|
||||
runCatching { org.json.JSONTokener(body).nextValue() }.getOrNull() is JSONArray
|
||||
|
||||
private fun isFresh(file: File, now: Long) = file.isFile && now - file.lastModified() < FRESH_MS
|
||||
|
||||
private fun cacheFile(context: Context, name: String) = File(File(context.cacheDir, "wowaddons"), name)
|
||||
|
||||
private fun cachedGet(context: Context, name: String, url: String): String? =
|
||||
cached(cacheFile(context, name), System.currentTimeMillis()) { StoreNet.get(url, timeoutMs = 60_000) }
|
||||
|
||||
private fun unreachable(context: Context) =
|
||||
IOException(context.getString(R.string.addons_unreachable, context.getString(R.string.addons_source_wowi)))
|
||||
|
||||
fun search(context: Context, query: Query, page: Int, pageSize: Int = 40): Page =
|
||||
pageOf(filterWowi(wowiEntries(context), wowiCategories(context), query), page, pageSize)
|
||||
|
||||
private class Memo(val stamp: Long, val size: Long, val entries: List<Entry>)
|
||||
|
||||
@Volatile private var wowiMemo: Memo? = null
|
||||
|
||||
private fun wowiEntries(context: Context): List<Entry> {
|
||||
val file = cacheFile(context, WOWI_FILES)
|
||||
val memo = wowiMemo
|
||||
if (memo != null && isFresh(file, System.currentTimeMillis()) && memo.stamp == file.lastModified() && memo.size == file.length()) return memo.entries
|
||||
val body = cachedGet(context, WOWI_FILES, WOWI + "filelist.json") ?: throw unreachable(context)
|
||||
return parseWowiFiles(body).also { wowiMemo = Memo(file.lastModified(), file.length(), it) }
|
||||
}
|
||||
|
||||
private fun wowiCategories(context: Context): List<Cat> =
|
||||
cachedGet(context, "wowi-categories.json", WOWI + "categorylist.json")?.let(::parseWowiCategories) ?: emptyList()
|
||||
|
||||
fun categories(context: Context): List<Category> = wowiCategories(context).map { Category(it.id, it.name) }
|
||||
|
||||
internal fun parseWowiDetails(json: String): Pair<String, String?>? {
|
||||
val o = JSONArray(json).optJSONObject(0) ?: return null
|
||||
val url = o.optString("UIDownload").ifEmpty { return null }
|
||||
return url to o.optString("UIMD5").ifEmpty { null }
|
||||
}
|
||||
|
||||
internal fun isHttps(url: String) = url.startsWith("https://", ignoreCase = true)
|
||||
|
||||
internal fun md5Matches(file: File, expected: String?): Boolean =
|
||||
expected.isNullOrBlank() || Hashes.md5(file).equals(expected.trim(), ignoreCase = true)
|
||||
|
||||
fun download(context: Context, addon: Addon, dest: File, progress: (Int) -> Unit): File {
|
||||
val body = StoreNet.get(WOWI + "filedetails/${URLEncoder.encode(addon.id, "UTF-8")}.json", timeoutMs = 30_000)
|
||||
?: throw unreachable(context)
|
||||
val (url, md5) = parseWowiDetails(body) ?: throw IOException(context.getString(R.string.addons_no_file))
|
||||
if (!isHttps(url)) throw IOException(context.getString(R.string.addons_no_file))
|
||||
dest.parentFile?.mkdirs()
|
||||
dest.delete()
|
||||
val ok = Downloader.downloadFile(url, dest, false) { f -> if (f >= 0) progress((f * 100).toInt().coerceIn(0, 100)) }
|
||||
if (!ok || !dest.isFile) {
|
||||
dest.delete()
|
||||
throw unreachable(context)
|
||||
}
|
||||
if (!md5Matches(dest, md5)) {
|
||||
dest.delete()
|
||||
throw IOException(context.getString(R.string.selfinst_checksum_mismatch))
|
||||
}
|
||||
progress(100)
|
||||
return dest
|
||||
}
|
||||
|
||||
internal fun curseForgeUrl(text: String): String =
|
||||
text.trim().let { if (it.isEmpty()) CURSEFORGE else CURSEFORGE + "&search=" + URLEncoder.encode(it, "UTF-8") }
|
||||
}
|
||||
@@ -5,17 +5,15 @@ import android.util.AtomicFile
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.frontend.AutoComponents
|
||||
import com.droiddeck.launcher.gpu.GpuInfo
|
||||
import java.io.File
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
object ComponentUpdater {
|
||||
private const val TAG = "ComponentUpdater"
|
||||
private const val CHECK_EVERY_MS = 3_600_000L
|
||||
private const val BOOTED_KEPT = 200
|
||||
private const val HANDED_BACK = "handedBack"
|
||||
private const val PINNED_NOTE = "pinnedNote"
|
||||
internal const val HAND_BACK_FILES = "handBackFiles"
|
||||
private const val IMPORTED = "imported"
|
||||
private val lock = Any()
|
||||
@@ -105,13 +103,6 @@ object ComponentUpdater {
|
||||
val protons = found ?: emptyList()
|
||||
if (found != null) handBack(context, state, found)
|
||||
judge(context, state, protons, notes)
|
||||
ComponentsManager.pinnedAutoDxvk(context)?.let { pinned ->
|
||||
runCatching { ComponentsManager.ensureForcedPackages(context) }
|
||||
.onSuccess { if (it != null) Log.i(TAG, it) }.onFailure { Log.w(TAG, "pinned DXVK", it) }
|
||||
val followers = protons.filter { ComponentUpdates.follows(it) && ComponentsManager.supports(it, "dxvk") }.map { it.id to it.version }
|
||||
if (pinnedHeld(pinned, followers, ComponentsManager.activeChoices(context), pinnedBase())) state.remove(PINNED_NOTE)
|
||||
else state.put(PINNED_NOTE, context.getString(R.string.comp_auto_install_failed, pinned.removeSuffix(".wcp")))
|
||||
}
|
||||
val due = force || System.currentTimeMillis() - state.optLong("checkedAt") >= CHECK_EVERY_MS
|
||||
val wanted = ComponentsManager.COMPONENTS.filter { managed(context, it) }
|
||||
if (wanted.isEmpty() || protons.isEmpty()) {
|
||||
@@ -394,32 +385,7 @@ object ComponentUpdater {
|
||||
}
|
||||
}
|
||||
|
||||
internal fun pinnedHeld(pinned: String, followers: List<Pair<String, String>>, choices: Map<String, Map<String, ComponentsManager.Choice>>, base: String?): Boolean =
|
||||
followers.all { (id, version) ->
|
||||
val choice = choices[id]?.get("dxvk")
|
||||
ownChoice(choice, version, pinned, base) || choice?.let { it.file == ComponentsManager.safeName(pinned) && it.protonVersion == version } == true
|
||||
}
|
||||
|
||||
internal fun ownChoice(choice: ComponentsManager.Choice?, version: String, pinned: String, base: String?): Boolean =
|
||||
choice != null && !choice.auto && choice.protonVersion == version && choice.file != ComponentsManager.safeName(pinned) &&
|
||||
(base == null || choice.file != ComponentsManager.safeName(base))
|
||||
|
||||
private fun pinnedBase(): String? = ComponentsManager.forcedDxvk(GpuInfo.detect())?.file
|
||||
|
||||
fun status(context: Context, comp: String): List<Status> {
|
||||
val pinned = if (comp == "dxvk") ComponentsManager.pinnedAutoDxvk(context) else null
|
||||
if (pinned != null) {
|
||||
val on = runCatching { ComponentsManager.protons(context) }.getOrDefault(emptyList())
|
||||
.filter { ComponentUpdates.follows(it) && ComponentsManager.supports(it, comp) }
|
||||
if (on.isEmpty()) return emptyList()
|
||||
val choices = ComponentsManager.activeChoices(context)
|
||||
val base = pinnedBase()
|
||||
val (own, auto) = on.partition { ownChoice(choices[it.id]?.get(comp), it.version, pinned, base) }
|
||||
val held = pinnedHeld(pinned, auto.map { it.id to it.version }, choices, base)
|
||||
val pinnedLine = if (auto.isEmpty()) emptyList()
|
||||
else listOf(Status(comp, pinned, pinned, auto.map { it.name }, if (held) "" else load(context).optString(PINNED_NOTE)))
|
||||
return pinnedLine + own.groupBy { choices.getValue(it.id).getValue(comp).file }.map { (file, ps) -> Status(comp, file, file, ps.map { it.name }, "") }
|
||||
}
|
||||
val state = load(context)
|
||||
val lines = state.optJSONObject("lines") ?: JSONObject()
|
||||
val protons = runCatching { ComponentsManager.protons(context) }.getOrDefault(emptyList())
|
||||
|
||||
@@ -1065,24 +1065,14 @@ object ComponentsManager {
|
||||
val file: String,
|
||||
val release: String,
|
||||
val models: Set<Int> = emptySet(),
|
||||
val autoFile: String? = null,
|
||||
val autoRelease: String? = null,
|
||||
) {
|
||||
fun matches(gpu: GpuInfo) = gpu.family in families && (models.isEmpty() || gpu.model in models)
|
||||
|
||||
fun autoRule(): ForcedPackage? =
|
||||
if (autoFile != null && autoRelease != null) copy(file = autoFile, release = autoRelease, autoFile = null, autoRelease = null) else null
|
||||
}
|
||||
|
||||
internal val FORCED_PACKAGES = listOf(
|
||||
ForcedPackage(setOf(GpuInfo.Family.A6XX), "dxvk", "dxvk-2.7.1-linux.wcp", "Dxvk-Linux",
|
||||
autoFile = "dxvk-gplasync-2.7.1-1-linux.wcp", autoRelease = "Dxvk-gplasync-Linux"),
|
||||
ForcedPackage(setOf(GpuInfo.Family.A6XX), "dxvk", "dxvk-2.7.1-linux.wcp", "Dxvk-Linux"),
|
||||
)
|
||||
|
||||
internal fun autoDxvk(gpu: GpuInfo): ForcedPackage? = forcedDxvk(gpu)?.autoRule()
|
||||
|
||||
internal fun forcedDxvk(gpu: GpuInfo): ForcedPackage? = FORCED_PACKAGES.firstOrNull { it.comp == "dxvk" && it.matches(gpu) }
|
||||
|
||||
private val ARM64EC_PROBE = mapOf("dxvk" to "d3d11.dll", "vkd3d" to "d3d12.dll")
|
||||
|
||||
fun arm64ec(context: Context, p: Proton, comp: String): Boolean? = stockProbe(context, p, comp)?.let(ComponentUpdates::arm64ec)
|
||||
@@ -1134,12 +1124,6 @@ object ComponentsManager {
|
||||
(com.droiddeck.launcher.core.DeviceSupport.vkBridge(context) && !SessionPrefs.bridgeProtonDxvk(context) &&
|
||||
bridgeDxvk(AppVulkan.detect(context)) != null)
|
||||
|
||||
fun autoDxvkHeld(context: Context): Boolean =
|
||||
hardwareDxvk(context) && (runtimeManaged(context, "dxvk") || autoDxvk(GpuInfo.detect()) == null)
|
||||
|
||||
fun pinnedAutoDxvk(context: Context): String? =
|
||||
if (SessionPrefs.componentMode(context, "dxvk") == ComponentUpdates.AUTO) autoDxvk(GpuInfo.detect())?.file else null
|
||||
|
||||
data class Choice(val file: String, val auto: Boolean, val protonVersion: String)
|
||||
|
||||
fun activeChoices(context: Context): Map<String, Map<String, Choice>> = synchronized(lock) {
|
||||
@@ -1184,14 +1168,6 @@ object ComponentsManager {
|
||||
internal const val DXVK_FOR_VULKAN_1_1 = "dxvk-1.10.3-linux.wcp"
|
||||
internal const val DXVK_FOR_VULKAN_1_3_NO_INT64 = "dxvk-2.6.2-linux.wcp"
|
||||
|
||||
private data class ForcedRule(
|
||||
val pkg: ForcedPackage,
|
||||
val auto: Boolean,
|
||||
val covers: (Proton) -> Boolean,
|
||||
val replaces: String? = null,
|
||||
val label: String? = null,
|
||||
)
|
||||
|
||||
internal fun autoDxvkUndoWanted(bridgeSession: Boolean, keepProtonDxvk: Boolean, vulkan: AppVulkan?, bridgeRule: ForcedPackage?): Boolean =
|
||||
!bridgeSession || keepProtonDxvk || (vulkan != null && bridgeRule == null)
|
||||
|
||||
@@ -1224,7 +1200,6 @@ object ComponentsManager {
|
||||
undoAuto = { id, comp -> undoAutoChoice(context, id, comp) },
|
||||
keepProtonDxvk = SessionPrefs.bridgeProtonDxvk(context),
|
||||
autoPossible = !com.droiddeck.launcher.core.DeviceSupport.adreno(),
|
||||
dxvkAuto = pinnedAutoDxvk(context) != null,
|
||||
) }
|
||||
|
||||
fun settleAutoDxvk(context: Context) {
|
||||
@@ -1264,16 +1239,10 @@ object ComponentsManager {
|
||||
undoAuto: (protonId: String, comp: String) -> String = { _, _ -> "" },
|
||||
keepProtonDxvk: Boolean = false,
|
||||
autoPossible: Boolean = true,
|
||||
dxvkAuto: Boolean = false,
|
||||
follows: (Proton) -> Boolean = ComponentUpdates::follows,
|
||||
): String? {
|
||||
val chooseDxvk = bridgeSession && !keepProtonDxvk
|
||||
val bridgeRule = if (chooseDxvk) bridgeDxvk(vulkan) else null
|
||||
val family = FORCED_PACKAGES.filter { it.matches(gpu) }
|
||||
val pinned = if (dxvkAuto) family.mapNotNull { base -> base.autoRule()?.let { base to it } } else emptyList()
|
||||
val rules = family.map { base -> ForcedRule(base, false, if (pinned.any { it.first == base }) { p -> !follows(p) } else { _ -> true }) } +
|
||||
pinned.map { (base, rule) -> ForcedRule(rule, true, follows, replaces = base.file) } +
|
||||
listOfNotNull(bridgeRule?.let { ForcedRule(it, true, { true }, label = "${vulkan?.label}: DXVK ${it.file}") })
|
||||
val rules = FORCED_PACKAGES.filter { it.matches(gpu) }.map { it to false } + listOfNotNull(bridgeRule?.let { it to true })
|
||||
if (rules.isEmpty() && !autoPossible) return null
|
||||
val installed = protons(context)
|
||||
val lines = mutableListOf<String>()
|
||||
@@ -1287,13 +1256,12 @@ object ComponentsManager {
|
||||
if (autoChoiceStale(entry, null)) lines.add(each(p) { undoAuto(p.id, "dxvk") })
|
||||
}
|
||||
}
|
||||
for ((rule, auto, covers, replaces, label) in rules) {
|
||||
for ((rule, auto) in rules) {
|
||||
val state = loadState(context)
|
||||
val targets = installed.filter { p ->
|
||||
val active = state.optJSONObject("active")?.optJSONObject(p.id)?.optJSONObject(rule.comp)
|
||||
val current = active?.optString("protonVersion") == p.version
|
||||
covers(p) && (forcedPackageWanted(active?.optString("file"), current) ||
|
||||
(auto && autoChoiceStale(active, rule.file)) || (replaces != null && current && active?.optString("file") == replaces))
|
||||
forcedPackageWanted(active?.optString("file"), active?.optString("protonVersion") == p.version) ||
|
||||
(auto && autoChoiceStale(active, rule.file))
|
||||
}
|
||||
if (targets.isEmpty()) continue
|
||||
val missing = missingPackage(context, rule, lookup, fetch)
|
||||
@@ -1301,7 +1269,7 @@ object ComponentsManager {
|
||||
lines.add(missing)
|
||||
continue
|
||||
}
|
||||
if (label != null) lines.add(label)
|
||||
if (auto) lines.add("${vulkan?.label}: DXVK ${rule.file}")
|
||||
lines.add(targets.joinToString("; ") { p -> if (auto) each(p) { applyAuto(p.id, rule.file) } else apply(p.id, rule.file) })
|
||||
}
|
||||
return lines.filter { it.isNotEmpty() }.joinToString("; ").ifEmpty { null }
|
||||
|
||||
@@ -57,10 +57,8 @@ object GameStorage {
|
||||
return pref.isNotEmpty() && pref != SessionPrefs.GAME_STORAGE_OFF && !attached(File(pref))
|
||||
}
|
||||
|
||||
internal fun attached(
|
||||
path: File,
|
||||
stateOf: (File) -> String? = { runCatching { Environment.getExternalStorageState(it) }.getOrNull() },
|
||||
): Boolean = attached(path, stateOf(File(UsbLink.storagePath(path.path))))
|
||||
internal fun attached(path: File): Boolean =
|
||||
attached(path, runCatching { Environment.getExternalStorageState(path) }.getOrNull())
|
||||
|
||||
internal fun attached(
|
||||
path: File,
|
||||
@@ -68,9 +66,8 @@ object GameStorage {
|
||||
externalStoragePath: Boolean = isExternalStoragePath(path),
|
||||
): Boolean = when {
|
||||
state == Environment.MEDIA_MOUNTED || state == Environment.MEDIA_MOUNTED_READ_ONLY -> true
|
||||
path.isDirectory -> true
|
||||
externalStoragePath -> false
|
||||
state == Environment.MEDIA_UNKNOWN || state == null -> path.parentFile?.isDirectory == true
|
||||
state == Environment.MEDIA_UNKNOWN || state == null -> path.isDirectory || path.parentFile?.isDirectory == true
|
||||
else -> false
|
||||
}
|
||||
|
||||
|
||||
@@ -112,7 +112,6 @@ object SessionFiles {
|
||||
"usr/local/bin/droiddeck-flatpak-setup" to "usr/local/bin/droiddeck-flatpak-setup",
|
||||
"usr/local/bin/droiddeck-flatpak-run" to "usr/local/bin/droiddeck-flatpak-run",
|
||||
"usr/local/bin/droiddeck-appimage-run" to "usr/local/bin/droiddeck-appimage-run",
|
||||
"usr/local/bin/droiddeck-appimage-exec" to "usr/local/bin/droiddeck-appimage-exec",
|
||||
"usr/local/bin/droiddeck-script-run" to "usr/local/bin/droiddeck-script-run",
|
||||
"usr/local/bin/droiddeck-open" to "usr/local/bin/droiddeck-open",
|
||||
"usr/local/bin/droiddeck-proton-run" to "usr/local/bin/droiddeck-proton-run",
|
||||
|
||||
@@ -23,7 +23,6 @@ object SessionPrefs {
|
||||
private const val LEGACY_SUSPEND_DOWNLOADS = "downloads"
|
||||
private const val STEAM_DOWNLOADS_IN_BACKGROUND = "steamDownloadsInBackground"
|
||||
private const val DECKY_ZIP_WARNING_SEEN = "deckyZipWarningSeen"
|
||||
private const val LOW_STORAGE_WARNING = "lowStorageWarning"
|
||||
private const val SLOW_USB_WARNING_OFF = "slowUsbWarningOff"
|
||||
private const val SLOW_USB_DISMISSED = "slowUsbDismissed"
|
||||
|
||||
@@ -111,16 +110,12 @@ object SessionPrefs {
|
||||
prefs(context).edit().remove("dualScreenLauncher").putString("dualScreenMode", launcher).putString("dualScreenSessionMode", mode).apply()
|
||||
}
|
||||
|
||||
fun secondScreenPanel(context: Context, desktop: Boolean): String? = prefs(context).let {
|
||||
it.getString(secondScreenPanelKey(desktop), null) ?: if (desktop) null else it.getString("secondScreenPanel", null)?.takeIf { id -> id != "custom-controls" }
|
||||
}
|
||||
fun secondScreenPanel(context: Context): String? = prefs(context).getString("secondScreenPanel", null)
|
||||
|
||||
fun setSecondScreenPanel(context: Context, desktop: Boolean, mode: String) {
|
||||
prefs(context).edit().putString(secondScreenPanelKey(desktop), mode).apply()
|
||||
fun setSecondScreenPanel(context: Context, mode: String) {
|
||||
prefs(context).edit().putString("secondScreenPanel", mode).apply()
|
||||
}
|
||||
|
||||
private fun secondScreenPanelKey(desktop: Boolean) = if (desktop) "secondScreenPanel.desktop" else "secondScreenPanel.game"
|
||||
|
||||
/** The bottom-screen desktop takes touch as a trackpad unless the user switched it to direct touch. */
|
||||
fun bottomDesktopDirectTouch(context: Context): Boolean = prefs(context).getBoolean("bottomDesktopDirectTouch", false)
|
||||
|
||||
@@ -189,12 +184,6 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean("dualScreenSessionControls", on).apply()
|
||||
}
|
||||
|
||||
fun androidTextFieldKeyboard(context: Context): Boolean = prefs(context).getBoolean("androidTextFieldKeyboard", false)
|
||||
|
||||
fun setAndroidTextFieldKeyboard(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("androidTextFieldKeyboard", on).apply()
|
||||
}
|
||||
|
||||
fun gameStoresEnabled(context: Context): Boolean = prefs(context).getBoolean("gameStoresEnabled", true)
|
||||
|
||||
fun setGameStoresEnabled(context: Context, on: Boolean) {
|
||||
@@ -1555,11 +1544,8 @@ object SessionPrefs {
|
||||
prefs(context).getString(if (right) "gamepadRight" else "gamepadLeft", null)
|
||||
?: if (right) SURFACE_TRACKPAD else SURFACE_STICK
|
||||
|
||||
fun deckPanel(context: Context): com.droiddeck.launcher.input.DeckPanel =
|
||||
com.droiddeck.launcher.input.DeckPanel.from(prefs(context).getString("deckPanel", null))
|
||||
|
||||
fun setDeckPanel(context: Context, panel: com.droiddeck.launcher.input.DeckPanel) {
|
||||
prefs(context).edit().putString("deckPanel", panel.id).apply()
|
||||
fun setGamepadSurface(context: Context, right: Boolean, surface: String) {
|
||||
prefs(context).edit().putString(if (right) "gamepadRight" else "gamepadLeft", surface).apply()
|
||||
}
|
||||
|
||||
fun suspendPolicy(context: Context, mode: String): String =
|
||||
@@ -1590,12 +1576,6 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean(DECKY_ZIP_WARNING_SEEN, true).apply()
|
||||
}
|
||||
|
||||
fun lowStorageWarning(context: Context): Boolean = prefs(context).getBoolean(LOW_STORAGE_WARNING, true)
|
||||
|
||||
fun setLowStorageWarning(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean(LOW_STORAGE_WARNING, on).apply()
|
||||
}
|
||||
|
||||
fun slowUsbWarningOff(context: Context): Boolean = prefs(context).getBoolean(SLOW_USB_WARNING_OFF, false)
|
||||
|
||||
fun setSlowUsbWarningOff(context: Context) {
|
||||
|
||||
@@ -93,7 +93,6 @@ object SessionState {
|
||||
@Volatile var secondScreenDisplay = -1
|
||||
@Volatile var secondScreenAutoOpened = false
|
||||
@Volatile var secondScreenProvisional = false
|
||||
@Volatile var secondScreenAutomatic = false
|
||||
@Volatile var gameOnBottom = false
|
||||
|
||||
@Volatile var steamLibraryMounts: Map<String, java.io.File> = emptyMap()
|
||||
|
||||
@@ -5,15 +5,14 @@ import android.util.Log
|
||||
import com.droiddeck.launcher.core.DownloadSource
|
||||
import com.droiddeck.launcher.core.GameEnvironment
|
||||
import com.droiddeck.launcher.input.ControllerPrefs
|
||||
import com.droiddeck.launcher.input.InputProfiles
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
|
||||
object SettingsReset {
|
||||
private const val TAG = "SettingsReset"
|
||||
|
||||
internal val SESSION_KEYS = setOf(
|
||||
"orientation", "launcherFullscreen", "animations", "lowStorageWarning", "theme", "runSteamAtStartup", "startupSession", "addedGamesArt",
|
||||
"dualScreenMode", "dualScreenSessionMode", "dualScreenLauncher", "dualScreenSessionControls", "androidTextFieldKeyboard",
|
||||
"orientation", "launcherFullscreen", "animations", "theme", "runSteamAtStartup", "startupSession", "addedGamesArt",
|
||||
"dualScreenMode", "dualScreenSessionMode", "dualScreenLauncher", "dualScreenSessionControls",
|
||||
"gameStoresEnabled", "gameStoresSpeedTier", "gameStoresParallel", "storesOpenTab", "storesShowMature",
|
||||
"storesInstallLanguage",
|
||||
"hud", "hudDeck", "hudFps", "hudBattery", "hudGraph", "hudColor", "hudOpacity", "__KEEP_droidDeckHud__", "__KEEP_droidDeckHudPos__", "backHold", "backActionsInverted", "backDoubleTapMs", "touch", "osc", "shape", "steamController",
|
||||
@@ -44,7 +43,6 @@ object SettingsReset {
|
||||
val session = context.getSharedPreferences("session", Context.MODE_PRIVATE)
|
||||
session.edit().apply { session.all.keys.filter { isSessionSetting(it) }.forEach { remove(it) } }.commit()
|
||||
ControllerPrefs.resetAll(context)
|
||||
InputProfiles.resetAll(context)
|
||||
context.getSharedPreferences("app-ui", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
context.getSharedPreferences("frame_gen", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
context.getSharedPreferences("steam_touch", Context.MODE_PRIVATE).edit()
|
||||
|
||||
@@ -96,7 +96,7 @@ object SteamLibraries {
|
||||
if (picked.none { overlaps(canonical(it.first), canonical(hit.first)) }) picked += hit
|
||||
}
|
||||
return picked.map { (dir, games) ->
|
||||
val shown = File(UsbLink.storagePath(dir.path))
|
||||
val shown = File(storagePath(dir.path))
|
||||
Found(dir.path, shown.path, defaultLabel(context, shown), games)
|
||||
}
|
||||
}
|
||||
@@ -155,7 +155,7 @@ object SteamLibraries {
|
||||
}
|
||||
|
||||
fun defaultLabel(context: Context, picked: File): String {
|
||||
val folder = File(UsbLink.storagePath(picked.path))
|
||||
val folder = File(storagePath(picked.path))
|
||||
val volume = try {
|
||||
context.getSystemService(StorageManager::class.java)?.getStorageVolume(folder)?.getDescription(context)
|
||||
} catch (e: Exception) {
|
||||
@@ -171,7 +171,9 @@ object SteamLibraries {
|
||||
|
||||
internal fun overlaps(a: String, b: String): Boolean = a == b || a.startsWith("$b/") || b.startsWith("$a/")
|
||||
|
||||
private fun canonical(file: File): String = UsbLink.storagePath(runCatching { file.canonicalPath }.getOrDefault(file.absolutePath))
|
||||
private fun storagePath(path: String): String = if (path.startsWith("/mnt/media_rw/")) "/storage/" + path.removePrefix("/mnt/media_rw/") else path
|
||||
|
||||
private fun canonical(file: File): String = storagePath(runCatching { file.canonicalPath }.getOrDefault(file.absolutePath))
|
||||
|
||||
private const val SCAN_DEPTH = 2
|
||||
private const val SCAN_LIMIT = 4000
|
||||
|
||||
@@ -66,9 +66,6 @@ object StorageState {
|
||||
|
||||
fun lowOnSpace(context: Context): Boolean = freeBytes(context) < LOW_SPACE_BYTES
|
||||
|
||||
fun shouldWarnLowSpace(warningEnabled: Boolean, dismissed: Boolean, sessionRunning: Boolean, freeBytes: Long): Boolean =
|
||||
warningEnabled && !dismissed && !sessionRunning && freeBytes < LOW_SPACE_BYTES
|
||||
|
||||
fun bytes(report: StorageReport, item: CleanItem): Long = when (item) {
|
||||
CleanItem.OLD_TOOLS -> report.unusedTools.filter { it.reason != ToolReason.CUSTOM }.sumOf { it.bytes }
|
||||
CleanItem.LEFTOVERS -> report.leftovers.filter { it.safe }.sumOf { it.bytes }
|
||||
|
||||
@@ -65,8 +65,6 @@ private fun releaseDateValue(raw: String): Long =
|
||||
runCatching { java.time.LocalDate.parse(day).toEpochDay() }.getOrDefault(0L)
|
||||
} ?: 0L
|
||||
|
||||
internal fun entryTime(first: Long?, fallback: Long?): Long = first?.takeIf { it > 0 } ?: fallback?.takeIf { it > 0 } ?: 0L
|
||||
|
||||
fun <T> sortGames(
|
||||
items: List<T>,
|
||||
sort: String,
|
||||
|
||||
@@ -204,8 +204,7 @@ object BattleNetBackend : StoreBackend {
|
||||
fun installedGames(context: Context): List<InstalledStoreGame> = installed(context).map { game ->
|
||||
val art = BattleNet.art(context, game.title)
|
||||
val cover = art.cover?.let { Uri.fromFile(it).toString() }
|
||||
InstalledStoreGame(StoreGameSidecar(store, game.title.uid, game.name, "", installedAt = game.folder.lastModified(),
|
||||
cover = cover, hero = art.header?.let { Uri.fromFile(it).toString() } ?: cover), game.folder)
|
||||
InstalledStoreGame(StoreGameSidecar(store, game.title.uid, game.name, "", cover = cover, hero = art.header?.let { Uri.fromFile(it).toString() } ?: cover), game.folder)
|
||||
}
|
||||
|
||||
private fun shelves(context: Context): StoreShelves {
|
||||
|
||||
@@ -151,7 +151,7 @@ internal fun AddAppDialog(runtimeReady: Boolean, onDismiss: () -> Unit, onAdd: (
|
||||
?.let { UserApps.Request(UserApps.Source.Script(it), custom, iconPath) to (custom ?: it.nameWithoutExtension) }
|
||||
AddSource.APPIMAGE -> imagePath?.takeIf { it.endsWith(".appimage", ignoreCase = true) }?.let { File(it) }
|
||||
?.let { UserApps.Request(UserApps.Source.AppImage(it), custom, iconPath) to (custom ?: it.nameWithoutExtension) }
|
||||
AddSource.GITHUB -> repoId?.let { UserApps.Request(UserApps.Source.GitHub(it, UserApps.githubAsset(repo)), custom, iconPath) to (custom ?: it) }
|
||||
AddSource.GITHUB -> repoId?.let { UserApps.Request(UserApps.Source.GitHub(it), custom, iconPath) to (custom ?: it) }
|
||||
AddSource.FLATPAK -> bundlePath?.takeIf { it.endsWith(".flatpak", ignoreCase = true) }?.let { File(it) }
|
||||
?.let { UserApps.Request(UserApps.Source.FlatpakBundle(it), custom, iconPath) to (custom ?: it.nameWithoutExtension) }
|
||||
?: flatpakId?.let { UserApps.Request(UserApps.Source.Flatpak(it), custom, iconPath) to (custom ?: it) }
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.runtime.WowAddonCatalog
|
||||
import com.droiddeck.launcher.runtime.WowAddons
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
private class AddonResults {
|
||||
val items = mutableStateListOf<WowAddonCatalog.Addon>()
|
||||
var page by mutableIntStateOf(0)
|
||||
var loading by mutableStateOf(false)
|
||||
var failed by mutableStateOf<String?>(null)
|
||||
var more by mutableStateOf(false)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun AddonBrowserDialog(targets: List<WowAddons.Target>, initial: WowAddons.Target, onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current.applicationContext
|
||||
val scope = rememberCoroutineScope()
|
||||
val shown = rememberShown(onDismiss)
|
||||
val close = { shown.targetState = false }
|
||||
var target by remember { mutableStateOf(initial) }
|
||||
var text by remember { mutableStateOf("") }
|
||||
var category by remember { mutableStateOf<WowAddonCatalog.Category?>(null) }
|
||||
var sort by remember { mutableStateOf(WowAddonCatalog.Sort.POPULAR) }
|
||||
val results = remember(text, category, sort, target) { AddonResults() }
|
||||
val installing = remember { mutableStateMapOf<String, Int>() }
|
||||
val installed = remember { mutableStateMapOf<String, Boolean>() }
|
||||
val categories by produceState(emptyList<WowAddonCatalog.Category>()) {
|
||||
value = withContext(Dispatchers.IO) { runCatching { WowAddonCatalog.categories(context) }.getOrDefault(emptyList()) }
|
||||
}
|
||||
fun load(page: Int) {
|
||||
if (results.loading) return
|
||||
results.loading = true
|
||||
results.failed = null
|
||||
val into = target
|
||||
val words = text
|
||||
val cat = category?.id
|
||||
val order = sort
|
||||
scope.launch {
|
||||
val got = withContext(Dispatchers.IO) {
|
||||
runCatching { WowAddonCatalog.search(context, WowAddonCatalog.Query(words, cat, WowAddonCatalog.client(into), order), page) }
|
||||
}
|
||||
got.onSuccess {
|
||||
if (page == 0) results.items.clear()
|
||||
results.items.addAll(it.items.filter { addon -> results.items.none { seen -> seen.id == addon.id } })
|
||||
results.page = page
|
||||
results.more = it.more
|
||||
}.onFailure { results.failed = it.message ?: it.javaClass.simpleName }
|
||||
results.loading = false
|
||||
}
|
||||
}
|
||||
LaunchedEffect(results) {
|
||||
if (text.isNotEmpty()) delay(350)
|
||||
load(0)
|
||||
}
|
||||
fun install(addon: WowAddonCatalog.Addon) {
|
||||
val key = installKey(addon, target)
|
||||
if (key in installing) return
|
||||
if (SessionState.running) {
|
||||
Toast.makeText(context, R.string.main_addons_close_session, Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
val into = target
|
||||
installing[key] = 0
|
||||
scope.launch {
|
||||
val message = withContext(Dispatchers.IO) { installAddon(context, addon, into) { pct -> scope.launch { if (key in installing) installing[key] = pct } } }
|
||||
installing.remove(key)
|
||||
if (message.second) installed[key] = true
|
||||
Toast.makeText(context, message.first, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
val sourceName = stringResource(R.string.addons_source_wowi)
|
||||
AppDialog(shown, close, "addonBrowser", wide = true, scrollable = false) {
|
||||
DialogTitleRow(stringResource(R.string.addons_download), close)
|
||||
ModeHeader(sourceName, text, { text = it }, stringResource(R.string.stores_search_catalog, sourceName)) {
|
||||
BrowseChip(stringResource(R.string.addons_source_curse), false) {
|
||||
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(WowAddonCatalog.curseForgeUrl(text))).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) }
|
||||
}
|
||||
}
|
||||
ChipRow {
|
||||
BrowseDropdown(sortLabel(sort)) { first, done ->
|
||||
WowAddonCatalog.Sort.entries.forEachIndexed { index, option ->
|
||||
MenuItem(sortLabel(option), sort == option, focusRequester = if (index == 0) first else null) { sort = option; done() }
|
||||
}
|
||||
}
|
||||
if (categories.isNotEmpty()) BrowseDropdown(category?.name ?: stringResource(R.string.addons_category_all)) { first, done ->
|
||||
MenuItem(stringResource(R.string.addons_category_all), category == null, focusRequester = first) { category = null; done() }
|
||||
categories.forEach { option -> MenuItem(option.name, category?.id == option.id) { category = option; done() } }
|
||||
}
|
||||
if (targets.size > 1) BrowseDropdown(target.flavor) { first, done ->
|
||||
targets.forEachIndexed { index, option ->
|
||||
MenuItem(option.flavor, option.addOns == target.addOns, focusRequester = if (index == 0) first else null) { target = option; done() }
|
||||
}
|
||||
}
|
||||
}
|
||||
val height = (LocalConfiguration.current.screenHeightDp * 0.62f).dp
|
||||
LazyVerticalGrid(
|
||||
GridCells.Adaptive(libraryTileWidth() + 40.dp), Modifier.fillMaxWidth().height(height),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
items(results.items, key = { it.id }) { addon ->
|
||||
AddonCard(addon, installing[installKey(addon, target)], installed[installKey(addon, target)] == true) { install(addon) }
|
||||
}
|
||||
item(key = "tail", span = { GridItemSpan(maxLineSpan) }) {
|
||||
Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) {
|
||||
when {
|
||||
results.loading -> LinearProgressIndicator(Modifier.fillMaxWidth())
|
||||
results.failed != null -> BrowseChip(stringResource(R.string.stores_dl_retry), false) { load(if (results.items.isEmpty()) 0 else results.page + 1) }
|
||||
results.more -> BrowseChip(stringResource(R.string.stores_itch_load_more), false) { load(results.page + 1) }
|
||||
results.items.isEmpty() -> Text(stringResource(R.string.addons_none_found), fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddonCard(addon: WowAddonCatalog.Addon, progress: Int?, done: Boolean, onInstall: () -> Unit) {
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val shape = androidx.compose.foundation.shape.RoundedCornerShape(10.dp)
|
||||
Column(
|
||||
Modifier.fillMaxWidth().clip(shape).glideBorder(hot, shape, pal.signal, pal.line)
|
||||
.hoverable(src)
|
||||
.clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onInstall)
|
||||
.controllerConfirm(onClick = onInstall),
|
||||
) {
|
||||
ArtCard(addon.name, addon.thumb, null, null, hot, capsule = false, chip = progress?.let { "$it%" }) {
|
||||
if (done) Icon(Icons.Filled.Check, stringResource(R.string.stores_installed_chip), tint = pal.good, modifier = Modifier.height(11.dp))
|
||||
else Text(stringResource(if (progress != null) R.string.store_installing else R.string.store_install), fontSize = 10.sp, color = pal.signal, maxLines = 1)
|
||||
}
|
||||
Text(
|
||||
listOf(addon.author, formatCount(addon.downloads)).filter { it.isNotEmpty() }.joinToString(" · "),
|
||||
fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun sortLabel(sort: WowAddonCatalog.Sort): String = stringResource(
|
||||
when (sort) {
|
||||
WowAddonCatalog.Sort.POPULAR -> R.string.stores_itch_sort_popular
|
||||
WowAddonCatalog.Sort.UPDATED -> R.string.stores_itch_sort_new
|
||||
WowAddonCatalog.Sort.NAME -> R.string.addons_sort_name
|
||||
WowAddonCatalog.Sort.DOWNLOADS -> R.string.addons_sort_downloads
|
||||
},
|
||||
)
|
||||
|
||||
private fun formatCount(count: Long): String = when {
|
||||
count <= 0 -> ""
|
||||
count >= 1_000_000 -> String.format(java.util.Locale.getDefault(), "%.1fM", count / 1_000_000.0)
|
||||
count >= 1_000 -> String.format(java.util.Locale.getDefault(), "%.1fK", count / 1_000.0)
|
||||
else -> count.toString()
|
||||
}
|
||||
|
||||
private fun installAddon(context: Context, addon: WowAddonCatalog.Addon, target: WowAddons.Target, progress: (Int) -> Unit): Pair<String, Boolean> {
|
||||
val zip = File(File(context.cacheDir, "wowaddons"), "download-wowi-${addon.id.filter(Char::isLetterOrDigit)}-${java.util.UUID.randomUUID()}.zip")
|
||||
return try {
|
||||
WowAddonCatalog.download(context, addon, zip, progress)
|
||||
if (SessionState.running) return context.getString(R.string.main_addons_close_session) to false
|
||||
val names = WowAddons.import(zip, target)
|
||||
if (names.isEmpty()) context.getString(R.string.main_addons_none, addon.name) to false
|
||||
else context.resources.getQuantityString(R.plurals.main_addons_imported, names.size, names.size, target.flavor) to true
|
||||
} catch (_: WowAddons.TooLarge) {
|
||||
context.getString(R.string.main_addons_too_large, addon.name) to false
|
||||
} catch (e: Exception) {
|
||||
context.getString(R.string.stores_dl_failed_reason, e.message ?: e.javaClass.simpleName) to false
|
||||
} finally {
|
||||
zip.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun installKey(addon: WowAddonCatalog.Addon, target: WowAddons.Target) = addon.id + "|" + target.addOns.path
|
||||
@@ -12,7 +12,6 @@ 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
|
||||
@@ -48,7 +47,6 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.input.DeckPanel
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
|
||||
@@ -61,8 +59,6 @@ internal class BarExtra(
|
||||
val onClick: () -> Unit,
|
||||
)
|
||||
|
||||
internal class BarTabs(val labels: List<String>, val selected: Int, val onSelect: (Int) -> Unit)
|
||||
|
||||
internal class BottomBarModel(
|
||||
val title: String?,
|
||||
val onClose: (() -> Unit)?,
|
||||
@@ -71,22 +67,10 @@ internal class BottomBarModel(
|
||||
val onQam: (() -> Unit)?,
|
||||
val extras: List<BarExtra> = emptyList(),
|
||||
val menuActive: Boolean = false,
|
||||
val tabs: BarTabs? = null,
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun BottomScreenBar(model: BottomBarModel, modifier: Modifier = Modifier) {
|
||||
val tabs = model.tabs
|
||||
if (tabs == null) BarRow(model, modifier) else Column(modifier.fillMaxWidth().blockTouches()) {
|
||||
BarRow(model)
|
||||
Box(Modifier.fillMaxWidth().padding(start = BAR_PAD, end = BAR_PAD, bottom = 8.dp), contentAlignment = Alignment.Center) {
|
||||
TabStrip(tabs.labels, tabs.selected, tabs.onSelect, Modifier.testTag("bottom-bar-tabs"), bumpers = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BarRow(model: BottomBarModel, modifier: Modifier = Modifier) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
BoxWithConstraints(modifier.fillMaxWidth().height(BAR_HEIGHT).blockTouches()) {
|
||||
val buttons = listOfNotNull(model.onClose, model.onMenu, model.onQam).size + model.extras.size
|
||||
@@ -211,15 +195,11 @@ internal fun sessionBarModel(
|
||||
onSteam: () -> Unit,
|
||||
onQam: () -> Unit,
|
||||
extras: List<BarExtra> = emptyList(),
|
||||
tabs: BarTabs? = null,
|
||||
): BottomBarModel {
|
||||
val steam = SessionState.mode == SessionService.MODE_STEAM
|
||||
return BottomBarModel(title, onClose, onMenu, onSteam.takeIf { steam }, onQam.takeIf { steam }, extras, tabs = tabs)
|
||||
return BottomBarModel(title, onClose, onMenu, onSteam.takeIf { steam }, onQam.takeIf { steam }, extras)
|
||||
}
|
||||
|
||||
internal fun deckPanelTabs(context: Context, selected: DeckPanel, onSelect: (DeckPanel) -> Unit) =
|
||||
BarTabs(DeckPanel.entries.map { context.getString(it.label) }, selected.ordinal) { onSelect(DeckPanel.entries[it]) }
|
||||
|
||||
internal fun bottomScreenBarView(context: Context, model: () -> BottomBarModel): ComposeView = ComposeView(context).apply {
|
||||
setContent { DroidDeckTheme { BottomScreenBar(model()) } }
|
||||
}
|
||||
@@ -1112,7 +1112,7 @@ private fun AutoComponentBody(label: String, lines: List<ComponentUpdater.Status
|
||||
if (lines.isEmpty()) EmptyLine(stringResource(R.string.comp_auto_none))
|
||||
for (line in lines) {
|
||||
val previous = line.previous.removeSuffix(".wcp").ifEmpty { stringResource(R.string.comp_tag_original) }
|
||||
val fallback = if (line.current.isEmpty() || line.current == line.previous) null else stringResource(R.string.comp_auto_fallback, previous)
|
||||
val fallback = if (line.current.isEmpty()) null else stringResource(R.string.comp_auto_fallback, previous)
|
||||
PanelRow(
|
||||
line.current.removeSuffix(".wcp").ifEmpty { stringResource(if (checking) R.string.comp_auto_checking else R.string.comp_tag_original) },
|
||||
listOfNotNull(line.protons.joinToString(", ").ifEmpty { null }, fallback).joinToString(" · "),
|
||||
@@ -1183,7 +1183,6 @@ private fun GameComponentOverrides(
|
||||
open = fexMenu,
|
||||
enabled = config != null && !busy,
|
||||
allowInheritance = true,
|
||||
generalPreset = generalFexPreset,
|
||||
onToggle = { onFexMenu(!fexMenu) },
|
||||
onDismiss = { onFexMenu(false) },
|
||||
onPick = onFexPreset,
|
||||
@@ -1350,16 +1349,15 @@ internal fun FexPresetChip(
|
||||
onDismiss: () -> Unit,
|
||||
onPick: (String?) -> Unit,
|
||||
allowInheritance: Boolean = false,
|
||||
generalPreset: String = preset,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val chosen = FexPreset.all.firstOrNull { it.id == if (inherited) generalPreset else preset }
|
||||
val chosen = FexPreset.all.firstOrNull { it.id == preset }
|
||||
val label = when {
|
||||
preset == GameEnvironment.CUSTOM_FEX_PRESET -> stringResource(R.string.comp_game_fex_custom)
|
||||
chosen == null -> stringResource(FexPreset.byId("").label)
|
||||
inherited -> stringResource(R.string.comp_game_use_general)
|
||||
else -> stringResource(chosen.label)
|
||||
}
|
||||
val general = FexPreset.all.firstOrNull { it.id == generalPreset }
|
||||
Box {
|
||||
ValueChip(label, open, enabled = enabled, modifier = modifier.widthIn(max = 260.dp), onClick = onToggle)
|
||||
AnchoredMenu(open && enabled, onDismiss = onDismiss, title = stringResource(R.string.fex_preset_title)) { first ->
|
||||
@@ -1367,7 +1365,6 @@ internal fun FexPresetChip(
|
||||
MenuItem(
|
||||
stringResource(R.string.comp_game_use_general),
|
||||
checked = inherited,
|
||||
detail = general?.let { stringResource(it.label) },
|
||||
focusRequester = first,
|
||||
) { onPick(null); onDismiss() }
|
||||
}
|
||||
|
||||
@@ -1,20 +1,111 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.droiddeck.launcher.input.ControllerPrefs
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
|
||||
class ControllerActions(val onMapping: () -> Unit)
|
||||
class ControllerActions(
|
||||
val onOsc: (String) -> Unit,
|
||||
val onTint: (Int) -> Unit,
|
||||
val onOpacity: (Int) -> Unit,
|
||||
val onSize: (Int) -> Unit,
|
||||
val onStickClick: (Boolean) -> Unit,
|
||||
val onAdaptiveSticks: (Boolean) -> Unit,
|
||||
val onRumble: (Boolean) -> Unit,
|
||||
val onDeadZone: (Int) -> Unit,
|
||||
val onSteamButton: (Boolean) -> Unit,
|
||||
val onQamButton: (Boolean) -> Unit,
|
||||
val onKeyboardButton: (Boolean) -> Unit,
|
||||
val onSelectSteam: (Boolean) -> Unit,
|
||||
val onJoyConVr: (Boolean) -> Unit,
|
||||
val onEditLayout: () -> Unit,
|
||||
val onResetLayout: () -> Unit,
|
||||
val onMapping: () -> Unit,
|
||||
val onResetAll: () -> Unit,
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun rememberControllerSettings(): ControllerPrefs.Settings {
|
||||
val context = LocalContext.current
|
||||
var settings by remember { mutableStateOf(ControllerPrefs.read(context)) }
|
||||
DisposableEffect(context) {
|
||||
val prefs = ControllerPrefs.prefs(context)
|
||||
val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, _ -> settings = ControllerPrefs.read(context) }
|
||||
prefs.registerOnSharedPreferenceChangeListener(listener)
|
||||
onDispose { prefs.unregisterOnSharedPreferenceChangeListener(listener) }
|
||||
private fun Swatch(color: Int) {
|
||||
Box(Modifier.size(14.dp).background(Color(color), CircleShape).border(1.dp, Color.White.copy(alpha = 0.35f), CircleShape))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPrefs.Settings, a: ControllerActions) {
|
||||
ChoiceRow(
|
||||
host, "controller-osc", stringResource(R.string.mode_osc), stringResource(R.string.ctrl_osc_hint),
|
||||
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)),
|
||||
oscMode, note = stringResource(R.string.mode_osc_note), onPick = a.onOsc,
|
||||
)
|
||||
val tintOpen = host.open == "controller-tint"
|
||||
SettingsRow(stringResource(R.string.ctrl_color), stringResource(R.string.ctrl_color_hint), highlighted = tintOpen) {
|
||||
Box {
|
||||
ValueChip(ControllerPrefs.tints.firstOrNull { it.first == c.tint }?.second?.let { stringResource(it) } ?: stringResource(R.string.ctrl_custom), tintOpen) {
|
||||
host.open = if (tintOpen) null else "controller-tint"
|
||||
}
|
||||
AnchoredMenu(tintOpen, onDismiss = { if (host.open == "controller-tint") host.open = null }, title = stringResource(R.string.ctrl_color)) { firstItemFocus ->
|
||||
ControllerPrefs.tints.forEachIndexed { index, (color, name) ->
|
||||
MenuItem(stringResource(name), checked = c.tint == color, leading = { Swatch(color) }, focusRequester = if (index == 0) firstItemFocus else null) {
|
||||
a.onTint(color)
|
||||
host.open = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return settings
|
||||
ChoiceRow(host, "controller-opacity", stringResource(R.string.ctrl_opacity), null, ControllerPrefs.opacities.map { it to stringResource(R.string.ctrl_percent, it) }, c.opacity, onPick = a.onOpacity)
|
||||
ChoiceRow(host, "controller-size", stringResource(R.string.ctrl_size), stringResource(R.string.ctrl_size_hint), ControllerPrefs.sizes.map { it to stringResource(R.string.ctrl_percent, it) }, c.size, onPick = a.onSize)
|
||||
ToggleRow(host, "controller-stick-click", stringResource(R.string.ctrl_stick_click), stringResource(R.string.ctrl_stick_click_hint), c.stickClick, onChange = a.onStickClick)
|
||||
ToggleRow(host, "controller-adaptive", stringResource(R.string.ctrl_adaptive), stringResource(R.string.ctrl_adaptive_hint), c.adaptiveSticks, onChange = a.onAdaptiveSticks)
|
||||
ToggleRow(host, "controller-rumble", stringResource(R.string.ctrl_rumble), null, c.rumble, onChange = a.onRumble)
|
||||
ChoiceRow(host, "controller-dead-zone", stringResource(R.string.ctrl_dead_zone), null, ControllerPrefs.deadZones.map { it to stringResource(R.string.ctrl_percent, it) }, c.deadZone, onPick = a.onDeadZone)
|
||||
ToggleRow(host, "controller-steam", stringResource(R.string.ctrl_steam_button), null, c.steamButton, onChange = a.onSteamButton)
|
||||
ToggleRow(host, "controller-qam", stringResource(R.string.ctrl_qam_button), null, c.qamButton, onChange = a.onQamButton)
|
||||
ToggleRow(host, "controller-keyboard", stringResource(R.string.ctrl_keyboard_button), null, c.keyboardButton, onChange = a.onKeyboardButton)
|
||||
ToggleRow(host, "controller-select-steam", stringResource(R.string.ctrl_select_steam), stringResource(R.string.ctrl_select_steam_hint), c.selectSteam, onChange = a.onSelectSteam)
|
||||
ToggleRow(host, "controller-joycon-vr", stringResource(R.string.ctrl_joycon_vr), stringResource(R.string.ctrl_joycon_vr_hint), c.joyConVr, onChange = a.onJoyConVr)
|
||||
SettingsRow(stringResource(R.string.ctrl_layout), if (c.customLayout) stringResource(R.string.ctrl_layout_custom) else stringResource(R.string.ctrl_layout_default)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
SecondaryButton(stringResource(R.string.ctrl_edit)) { a.onEditLayout() }
|
||||
SecondaryButton(stringResource(R.string.ctrl_reset), enabled = c.customLayout) { a.onResetLayout() }
|
||||
}
|
||||
}
|
||||
val remapped = c.mapping.count { (id, target) -> id != target }
|
||||
ActionRow(stringResource(R.string.ctrl_mapping), if (remapped == 0) stringResource(R.string.ctrl_mapping_default) else stringResource(R.string.ctrl_mapping_count, remapped, c.mapping.size), stringResource(R.string.ctrl_configure), a.onMapping)
|
||||
ActionRow(stringResource(R.string.ctrl_reset_all), stringResource(R.string.ctrl_reset_all_hint), stringResource(R.string.ctrl_reset), a.onResetAll)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ControllerMappingPage(mapping: Map<String, String>, onPick: (String, String) -> Unit, onReset: () -> Unit, onBack: () -> Unit) {
|
||||
val host = rememberMenuHost()
|
||||
SettingsPage(
|
||||
host, title = stringResource(R.string.ctrl_mapping), eyebrow = stringResource(R.string.setup_controller),
|
||||
lede = stringResource(R.string.ctrl_mapping_lede),
|
||||
onBack = onBack,
|
||||
) {
|
||||
SettingsGroup(stringResource(R.string.shortcuts_title)) {
|
||||
PhysicalShortcutRows()
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.ctrl_onscreen_buttons)) {
|
||||
val targets = ControllerPrefs.targets.map { (id, label) -> id to stringResource(label) }
|
||||
for ((id, name) in ControllerPrefs.mappable) {
|
||||
ChoiceRow(host, "map-$id", stringResource(name), null, targets, mapping[id] ?: id) { onPick(id, it) }
|
||||
}
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.ctrl_defaults)) {
|
||||
ActionRow(stringResource(R.string.ctrl_reset_mapping), stringResource(R.string.ctrl_mapping_default), stringResource(R.string.ctrl_reset), onReset)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,720 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RadialGradient
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Shader
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.view.Gravity
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.HorizontalScrollView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import com.droiddeck.launcher.input.DeckControls
|
||||
import com.droiddeck.launcher.input.PadBridge
|
||||
import com.droiddeck.launcher.input.PadState
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.min
|
||||
|
||||
@SuppressLint("ViewConstructor")
|
||||
class DeckControlsPanel(
|
||||
context: Context,
|
||||
private val onClose: () -> Unit,
|
||||
private val onSteamMenu: () -> Unit,
|
||||
private val onQam: () -> Unit,
|
||||
private val pad: PadBridge? = null,
|
||||
private val portraitDock: Boolean = false,
|
||||
private val onMenu: (() -> Unit)? = null,
|
||||
private val onSwap: (() -> Unit)? = null,
|
||||
) : LinearLayout(context) {
|
||||
private enum class Tab(val label: Int) { GAMEPAD(R.string.deck_tab_gamepad), GRIPS(R.string.deck_tab_grips), LEFT(R.string.deck_tab_left), RIGHT(R.string.deck_tab_right), BOTH(R.string.deck_tab_both) }
|
||||
|
||||
private val content = FrameLayout(context)
|
||||
val deckControls = pad == null || SessionState.deckPad
|
||||
private val tabViews = HashMap<Tab, TextView>()
|
||||
private val topBar: View by lazy { tabsRow() }
|
||||
|
||||
init {
|
||||
orientation = VERTICAL
|
||||
setBackgroundColor(Color.BLACK)
|
||||
val title = context.getString(if (deckControls) R.string.second_mode_deck_controls else R.string.deck_tab_gamepad)
|
||||
val extras = listOfNotNull(onSwap?.let { BarExtra(context.getString(R.string.swap_screens), icon = R.drawable.ic_bottom_swap, onClick = it) })
|
||||
addView(bottomScreenBarView(context) { sessionBarModel(title, onClose, onMenu, onSteamMenu, onQam, extras) },
|
||||
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
|
||||
addView(topBar, LayoutParams(LayoutParams.MATCH_PARENT, dp(52)))
|
||||
addView(content, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f))
|
||||
select(if (pad != null && !deckControls) Tab.GAMEPAD else if (pad != null) selectedWithPad else selected)
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
DeckControls.releaseAll()
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
|
||||
private fun tabsRow(): View = LinearLayout(context).apply {
|
||||
orientation = HORIZONTAL
|
||||
gravity = Gravity.CENTER
|
||||
setPadding(dp(12), 0, dp(12), dp(8))
|
||||
val tabs = LinearLayout(context).apply {
|
||||
orientation = HORIZONTAL
|
||||
background = outline(CORNER_PILL, SURFACE)
|
||||
setPadding(dp(4), dp(4), dp(4), dp(4))
|
||||
for (tab in Tab.values()) {
|
||||
if (!deckControls && tab != Tab.GAMEPAD) continue
|
||||
if (tab == Tab.GAMEPAD && pad == null) continue
|
||||
val view = TextView(context).apply {
|
||||
text = context.getString(tab.label)
|
||||
textSize = 14f
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
gravity = Gravity.CENTER
|
||||
isSingleLine = true
|
||||
setPadding(dp(14), 0, dp(14), 0)
|
||||
setOnClickListener { select(tab) }
|
||||
}
|
||||
tabViews[tab] = view
|
||||
addView(view, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT))
|
||||
}
|
||||
}
|
||||
val centre = HorizontalScrollView(context).apply {
|
||||
isHorizontalScrollBarEnabled = false
|
||||
overScrollMode = View.OVER_SCROLL_NEVER
|
||||
addView(tabs, FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, dp(44)))
|
||||
}
|
||||
addView(centre, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT))
|
||||
}
|
||||
|
||||
private fun select(tab: Tab) {
|
||||
if (pad != null) selectedWithPad = tab else selected = tab
|
||||
DeckControls.releaseAll()
|
||||
for ((each, view) in tabViews) {
|
||||
val on = each == tab
|
||||
view.background = if (on) filled(CORNER_PILL, ACCENT) else null
|
||||
view.setTextColor(if (on) ON_ACCENT else TEXT_DIM)
|
||||
}
|
||||
tabViews[tab]?.let { selectedView ->
|
||||
(selectedView.parent?.parent as? HorizontalScrollView)?.post {
|
||||
val left = selectedView.left
|
||||
val right = selectedView.right
|
||||
val scroll = selectedView.parent?.parent as? HorizontalScrollView ?: return@post
|
||||
when {
|
||||
left < scroll.scrollX -> scroll.smoothScrollTo(left, 0)
|
||||
right > scroll.scrollX + scroll.width -> scroll.smoothScrollTo(right - scroll.width, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
content.removeAllViews()
|
||||
topBar.visibility = if (tab == Tab.GAMEPAD || tabViews.size < 2) View.GONE else View.VISIBLE
|
||||
content.addView(
|
||||
when (tab) {
|
||||
Tab.GRIPS -> gripsGrid()
|
||||
Tab.LEFT -> singlePad(right = false)
|
||||
Tab.RIGHT -> singlePad(right = true)
|
||||
Tab.BOTH -> bothPads()
|
||||
Tab.GAMEPAD -> gamepad(pad ?: return)
|
||||
},
|
||||
FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT),
|
||||
)
|
||||
}
|
||||
|
||||
private fun gripsGrid(): View = LinearLayout(context).apply {
|
||||
orientation = HORIZONTAL
|
||||
setPadding(dp(12), dp(6), dp(12), dp(12))
|
||||
fun column(upper: View, lower: View) = LinearLayout(context).apply {
|
||||
orientation = VERTICAL
|
||||
addView(upper, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f).apply { bottomMargin = dp(6) })
|
||||
addView(lower, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f).apply { topMargin = dp(6) })
|
||||
}
|
||||
addView(column(GripButton(context, "L4", context.getString(R.string.deck_left_upper), DeckControls.L4), GripButton(context, "L5", context.getString(R.string.deck_left_lower), DeckControls.L5)),
|
||||
LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { rightMargin = dp(6) })
|
||||
addView(column(GripButton(context, "R4", context.getString(R.string.deck_right_upper), DeckControls.R4), GripButton(context, "R5", context.getString(R.string.deck_right_lower), DeckControls.R5)),
|
||||
LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { leftMargin = dp(6) })
|
||||
}
|
||||
|
||||
private fun singlePad(right: Boolean): View = LinearLayout(context).apply {
|
||||
orientation = VERTICAL
|
||||
setPadding(dp(12), dp(6), dp(12), dp(12))
|
||||
addView(TrackpadView(context, right), LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f).apply { bottomMargin = dp(12) })
|
||||
addView(ClickBar(context, right), LayoutParams(LayoutParams.MATCH_PARENT, dp(76)))
|
||||
}
|
||||
|
||||
private fun bothPads(): View = LinearLayout(context).apply {
|
||||
orientation = VERTICAL
|
||||
setPadding(dp(12), dp(6), dp(12), dp(12))
|
||||
val pads = LinearLayout(context).apply {
|
||||
orientation = HORIZONTAL
|
||||
addView(TrackpadView(context, right = false), LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { rightMargin = dp(6) })
|
||||
addView(TrackpadView(context, right = true), LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { leftMargin = dp(6) })
|
||||
}
|
||||
addView(pads, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f).apply { bottomMargin = dp(12) })
|
||||
val grips = LinearLayout(context).apply {
|
||||
orientation = HORIZONTAL
|
||||
val row = listOf("L4" to DeckControls.L4, "L5" to DeckControls.L5, "R5" to DeckControls.R5, "R4" to DeckControls.R4)
|
||||
row.forEachIndexed { index, (label, bit) ->
|
||||
addView(GripButton(context, label, null, bit), LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply {
|
||||
if (index > 0) leftMargin = dp(6)
|
||||
if (index < row.size - 1) rightMargin = dp(6)
|
||||
})
|
||||
}
|
||||
}
|
||||
addView(grips, LayoutParams(LayoutParams.MATCH_PARENT, dp(84)))
|
||||
}
|
||||
|
||||
private fun gamepad(pad: PadBridge): View = GamepadLayout(context, portraitDock).apply {
|
||||
fun pill(label: String, write: (PadState, Boolean) -> Unit) = PadButton(context, pad, label, write)
|
||||
place(surfaceSlot(pad, right = false), GamepadGeometry.Slot.LEFT_MAIN)
|
||||
place(DirectionPad(context, pad), GamepadGeometry.Slot.LEFT_SECOND)
|
||||
place(surfaceSlot(pad, right = true), GamepadGeometry.Slot.RIGHT_MAIN)
|
||||
place(FaceButtons(context, pad), GamepadGeometry.Slot.RIGHT_SECOND)
|
||||
place(pill("LT") { st, d -> st.leftTrigger = if (d) 1f else 0f }, GamepadGeometry.Slot.LEFT_OUTER_SHOULDER)
|
||||
place(pill("LB") { st, d -> st.press(PadState.LB, d) }, GamepadGeometry.Slot.LEFT_INNER_SHOULDER)
|
||||
place(pill("RB") { st, d -> st.press(PadState.RB, d) }, GamepadGeometry.Slot.RIGHT_INNER_SHOULDER)
|
||||
place(pill("RT") { st, d -> st.rightTrigger = if (d) 1f else 0f }, GamepadGeometry.Slot.RIGHT_OUTER_SHOULDER)
|
||||
place(pill("VIEW") { st, d -> st.press(PadState.SELECT, d) }, GamepadGeometry.Slot.VIEW)
|
||||
place(pill("MENU") { st, d -> st.press(PadState.START, d) }, GamepadGeometry.Slot.MENU)
|
||||
if (deckControls) place(centreTap("\u25a6 " + context.getString(R.string.deck_panels), context.getString(R.string.deck_more_controls)) { select(Tab.BOTH) },
|
||||
GamepadGeometry.Slot.PANELS)
|
||||
if (portraitDock && deckControls) {
|
||||
place(GripButton(context, "L4", null, DeckControls.L4), GamepadGeometry.Slot.L4)
|
||||
place(GripButton(context, "L5", null, DeckControls.L5), GamepadGeometry.Slot.L5)
|
||||
place(GripButton(context, "R5", null, DeckControls.R5), GamepadGeometry.Slot.R5)
|
||||
place(GripButton(context, "R4", null, DeckControls.R4), GamepadGeometry.Slot.R4)
|
||||
}
|
||||
}
|
||||
|
||||
private fun centreTap(label: String, description: String, onClick: () -> Unit) = TextView(context).apply {
|
||||
text = label
|
||||
contentDescription = description
|
||||
textSize = 13f
|
||||
maxLines = 1
|
||||
setAutoSizeTextTypeUniformWithConfiguration(9, 13, 1, android.util.TypedValue.COMPLEX_UNIT_SP)
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
letterSpacing = 0.08f
|
||||
gravity = Gravity.CENTER
|
||||
setTextColor(TEXT)
|
||||
background = outline(CORNER_PILL, SURFACE)
|
||||
setOnClickListener {
|
||||
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
|
||||
private fun surfaceSlot(pad: PadBridge, right: Boolean): View = FrameLayout(context).apply {
|
||||
if (!deckControls) {
|
||||
addView(StickView(context, pad, right),
|
||||
FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
|
||||
return@apply
|
||||
}
|
||||
val slot = this
|
||||
fun show() {
|
||||
slot.removeAllViews()
|
||||
val stick = SessionPrefs.gamepadSurface(context, right) == SessionPrefs.SURFACE_STICK
|
||||
slot.addView(if (stick) StickView(context, pad, right) else TrackpadView(context, right, captioned = false),
|
||||
FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
|
||||
val chip = TextView(context).apply {
|
||||
text = "\u21c4"
|
||||
contentDescription = context.getString(R.string.deck_surface_switch)
|
||||
textSize = 14f
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
gravity = Gravity.CENTER
|
||||
setTextColor(TEXT_DIM)
|
||||
background = outline(CORNER_PILL, SURFACE)
|
||||
setOnClickListener {
|
||||
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
SessionPrefs.setGamepadSurface(context, right,
|
||||
if (stick) SessionPrefs.SURFACE_TRACKPAD else SessionPrefs.SURFACE_STICK)
|
||||
show()
|
||||
}
|
||||
}
|
||||
slot.addView(chip, FrameLayout.LayoutParams(dp(30), dp(30),
|
||||
(if (right) Gravity.START else Gravity.END) or Gravity.TOP).apply { setMargins(dp(6), dp(6), dp(6), dp(6)) })
|
||||
}
|
||||
show()
|
||||
}
|
||||
|
||||
fun takesTouchAt(x: Float, y: Float): Boolean = controlUnder(this, x, y)
|
||||
|
||||
private fun controlUnder(view: View, x: Float, y: Float): Boolean {
|
||||
if (view.visibility != VISIBLE || x < 0f || y < 0f || x >= view.width || y >= view.height) return false
|
||||
if (view.isClickable || view is HorizontalScrollView || view is Surface || view is DirectionPad || view is FaceButtons ||
|
||||
view is androidx.compose.ui.platform.ComposeView) return true
|
||||
val group = view as? ViewGroup ?: return false
|
||||
return (group.childCount - 1 downTo 0).any { i ->
|
||||
val child = group.getChildAt(i)
|
||||
controlUnder(child, x + group.scrollX - child.x, y + group.scrollY - child.y)
|
||||
}
|
||||
}
|
||||
|
||||
private fun outline(radius: Float, fill: Int) = outline(context, radius, fill)
|
||||
|
||||
private fun filled(radius: Float, fill: Int) = GradientDrawable().apply {
|
||||
cornerRadius = radius * resources.displayMetrics.density
|
||||
setColor(fill)
|
||||
}
|
||||
|
||||
private fun dp(value: Int): Int = dp(context, value)
|
||||
|
||||
|
||||
private abstract class Surface(context: Context) : View(context) {
|
||||
protected val density = resources.displayMetrics.density
|
||||
protected val rect = RectF()
|
||||
private val fill = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
|
||||
protected val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
textAlign = Paint.Align.CENTER
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
}
|
||||
protected val captionPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
textAlign = Paint.Align.CENTER
|
||||
color = TEXT_DIM
|
||||
}
|
||||
|
||||
protected fun drawSurface(canvas: Canvas, lit: Boolean) {
|
||||
val inset = density
|
||||
rect.set(inset, inset, width - inset, height - inset)
|
||||
val radius = CORNER * density
|
||||
fill.color = if (lit) ACCENT_WASH else SURFACE
|
||||
canvas.drawRoundRect(rect, radius, radius, fill)
|
||||
stroke.color = if (lit) ACCENT else LINE
|
||||
stroke.strokeWidth = (if (lit) 2f else 1f) * density
|
||||
canvas.drawRoundRect(rect, radius, radius, stroke)
|
||||
}
|
||||
}
|
||||
|
||||
private open class HoldButton(
|
||||
context: Context,
|
||||
private val label: String,
|
||||
private val caption: String?,
|
||||
private val onChange: (Boolean) -> Unit,
|
||||
) : Surface(context) {
|
||||
private var down = false
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
drawSurface(canvas, down)
|
||||
val cx = width / 2f
|
||||
labelPaint.textSize = min(height * 0.3f, 30 * density)
|
||||
labelPaint.color = if (down) ACCENT else TEXT
|
||||
val labelY = height / 2f + labelPaint.textSize * (if (caption == null) 0.35f else 0.1f)
|
||||
canvas.drawText(label, cx, labelY, labelPaint)
|
||||
if (caption != null) {
|
||||
captionPaint.textSize = 12 * density
|
||||
canvas.drawText(caption, cx, labelY + 22 * density, captionPaint)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> set(true)
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> set(false)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun set(pressed: Boolean) {
|
||||
if (down == pressed) return
|
||||
down = pressed
|
||||
if (pressed) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
onChange(pressed)
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
private class GripButton(context: Context, label: String, caption: String?, bit: Int) :
|
||||
HoldButton(context, label, caption, { DeckControls.setGrip(bit, it) })
|
||||
|
||||
private class ClickBar(context: Context, right: Boolean) :
|
||||
HoldButton(context, context.getString(R.string.deck_click), if (right) context.getString(R.string.deck_right_trackpad) else context.getString(R.string.deck_left_trackpad), { DeckControls.setClick(right, it) })
|
||||
|
||||
private class TrackpadView(context: Context, private val right: Boolean, private val captioned: Boolean = true) : Surface(context) {
|
||||
private var touching = false
|
||||
private var clicked = false
|
||||
private var fingerX = 0f
|
||||
private var fingerY = 0f
|
||||
private val guide = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE; color = GUIDE }
|
||||
private val dot = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = ACCENT }
|
||||
private val glow = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
drawSurface(canvas, clicked)
|
||||
val cx = width / 2f
|
||||
val cy = height / 2f
|
||||
guide.strokeWidth = density
|
||||
val reach = min(width, height) / 2f - 24 * density
|
||||
canvas.drawCircle(cx, cy, reach, guide)
|
||||
canvas.drawCircle(cx, cy, reach / 2f, guide)
|
||||
canvas.drawLine(cx - reach, cy, cx + reach, cy, guide)
|
||||
canvas.drawLine(cx, cy - reach, cx, cy + reach, guide)
|
||||
captionPaint.textSize = 12 * density
|
||||
captionPaint.color = if (touching) ACCENT else TEXT_DIM
|
||||
if (captioned) canvas.drawText((if (right) context.getString(R.string.deck_right_trackpad) else context.getString(R.string.deck_left_trackpad)).uppercase(), cx, 28 * density, captionPaint)
|
||||
if (touching) {
|
||||
val radius = 56 * density
|
||||
glow.shader = RadialGradient(fingerX, fingerY, radius, ACCENT_GLOW, Color.TRANSPARENT, Shader.TileMode.CLAMP)
|
||||
canvas.drawCircle(fingerX, fingerY, radius, glow)
|
||||
canvas.drawCircle(fingerX, fingerY, (if (clicked) 16 else 12) * density, dot)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
|
||||
touch(event.getX(0), event.getY(0))
|
||||
click(event.pointerCount > 1)
|
||||
}
|
||||
MotionEvent.ACTION_POINTER_DOWN -> click(true)
|
||||
MotionEvent.ACTION_POINTER_UP -> {
|
||||
val stays = if (event.actionIndex == 0) 1 else 0
|
||||
touch(event.getX(stays), event.getY(stays))
|
||||
click(event.pointerCount - 1 > 1)
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
click(false)
|
||||
touching = false
|
||||
DeckControls.setPad(right, touching = false)
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun touch(x: Float, y: Float) {
|
||||
fingerX = x.coerceIn(0f, width.toFloat())
|
||||
fingerY = y.coerceIn(0f, height.toFloat())
|
||||
touching = true
|
||||
DeckControls.setPad(right, true, (fingerX - width / 2f) / (width / 2f), -(fingerY - height / 2f) / (height / 2f))
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun click(down: Boolean) {
|
||||
if (clicked == down) return
|
||||
clicked = down
|
||||
if (down) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
DeckControls.setClick(right, down)
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
private class PadButton(
|
||||
context: Context,
|
||||
private val pad: PadBridge,
|
||||
label: String,
|
||||
private val write: (PadState, Boolean) -> Unit,
|
||||
) : HoldButton(context, label, null, { down -> pad.applyTouch { write(it, down) } }) {
|
||||
override fun onDetachedFromWindow() {
|
||||
pad.applyTouch { write(it, false) }
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
}
|
||||
|
||||
private class StickView(context: Context, private val pad: PadBridge, private val right: Boolean) : Surface(context) {
|
||||
private var touching = false
|
||||
private var clicking = false
|
||||
private var lastUp = 0L
|
||||
private var originX = 0f
|
||||
private var originY = 0f
|
||||
private var knobX = 0f
|
||||
private var knobY = 0f
|
||||
private val ring = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
|
||||
private val knob = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
|
||||
private fun reach() = min(width, height) * 0.30f
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
drawSurface(canvas, clicking)
|
||||
val cx = if (touching) originX else width / 2f
|
||||
val cy = if (touching) originY else height / 2f
|
||||
ring.strokeWidth = 2 * density
|
||||
ring.color = if (touching) ACCENT else LINE
|
||||
canvas.drawCircle(cx, cy, reach(), ring)
|
||||
knob.color = if (touching) ACCENT else KNOB
|
||||
canvas.drawCircle(cx + knobX, cy + knobY, reach() * 0.45f, knob)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
val r = reach().coerceAtLeast(1f)
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
originX = event.x.coerceIn(r, (width - r).coerceAtLeast(r))
|
||||
originY = event.y.coerceIn(r, (height - r).coerceAtLeast(r))
|
||||
touching = true
|
||||
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
if (event.eventTime - lastUp < CLICK_MS) setClick(true)
|
||||
follow(event.x, event.y, r)
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> follow(event.getX(0), event.getY(0), r)
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
touching = false
|
||||
lastUp = event.eventTime
|
||||
setClick(false)
|
||||
move(0f, 0f, 1f)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun follow(x: Float, y: Float, r: Float) {
|
||||
var dx = x - originX
|
||||
var dy = y - originY
|
||||
val length = hypot(dx, dy)
|
||||
if (length > r) { dx *= r / length; dy *= r / length }
|
||||
move(dx, dy, r)
|
||||
}
|
||||
|
||||
private fun move(dx: Float, dy: Float, r: Float) {
|
||||
knobX = dx
|
||||
knobY = dy
|
||||
pad.applyTouch(if (right) PadBridge.RIGHT_STICK else PadBridge.LEFT_STICK) { s ->
|
||||
if (right) { s.rightX = dx / r; s.rightY = dy / r } else { s.leftX = dx / r; s.leftY = dy / r }
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun setClick(down: Boolean) {
|
||||
if (clicking == down) return
|
||||
clicking = down
|
||||
if (down) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
pad.applyTouch { s -> s.press(if (right) PadState.R3 else PadState.L3, down) }
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
setClick(false)
|
||||
if (knobX != 0f || knobY != 0f) move(0f, 0f, 1f)
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
}
|
||||
|
||||
private class DirectionPad(context: Context, private val pad: PadBridge) : View(context) {
|
||||
private val density = resources.displayMetrics.density
|
||||
private var held = 0
|
||||
private val arm = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val rect = RectF()
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
val size = min(width, height) * 0.86f
|
||||
val cx = width / 2f
|
||||
val cy = height / 2f
|
||||
val half = size / 2f
|
||||
val thick = size / 3f / 2f
|
||||
fun armRect(bit: Int, l: Float, t: Float, r: Float, b: Float) {
|
||||
arm.color = if (held and bit != 0) ACCENT else KNOB
|
||||
rect.set(l, t, r, b)
|
||||
canvas.drawRoundRect(rect, 8 * density, 8 * density, arm)
|
||||
}
|
||||
armRect(1, cx - thick, cy - half, cx + thick, cy - thick)
|
||||
armRect(4, cx - thick, cy + thick, cx + thick, cy + half)
|
||||
armRect(8, cx - half, cy - thick, cx - thick, cy + thick)
|
||||
armRect(2, cx + thick, cy - thick, cx + half, cy + thick)
|
||||
arm.color = KNOB
|
||||
canvas.drawRect(cx - thick, cy - thick, cx + thick, cy + thick, arm)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
val bits = when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
|
||||
val dx = event.getX(0) - width / 2f
|
||||
val dy = event.getY(0) - height / 2f
|
||||
if (hypot(dx, dy) < min(width, height) * 0.08f) 0 else {
|
||||
val sector = ((Math.toDegrees(atan2(dy.toDouble(), dx.toDouble())) + 360 + 22.5) % 360 / 45).toInt()
|
||||
intArrayOf(2, 2 or 4, 4, 4 or 8, 8, 8 or 1, 1, 1 or 2)[sector]
|
||||
}
|
||||
}
|
||||
else -> 0
|
||||
}
|
||||
set(bits)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun set(bits: Int) {
|
||||
if (bits == held) return
|
||||
if (bits and held.inv() != 0) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
held = bits
|
||||
pad.applyTouch { s ->
|
||||
s.up = bits and 1 != 0; s.right = bits and 2 != 0; s.down = bits and 4 != 0; s.left = bits and 8 != 0
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
set(0)
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
}
|
||||
|
||||
private class FaceButtons(context: Context, private val pad: PadBridge) : View(context) {
|
||||
private val buttons = listOf(PadState.Y to "Y", PadState.B to "B", PadState.A to "A", PadState.X to "X")
|
||||
private var held = emptySet<Int>()
|
||||
private val fill = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val text = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
textAlign = Paint.Align.CENTER
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
}
|
||||
|
||||
private fun layout(): Pair<List<Pair<Float, Float>>, Float> {
|
||||
val cx = width / 2f
|
||||
val cy = height / 2f
|
||||
val spread = min(width, height) * 0.30f
|
||||
val radius = min(width, height) * 0.16f
|
||||
return listOf(cx to cy - spread, cx + spread to cy, cx to cy + spread, cx - spread to cy) to radius
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
val (centres, radius) = layout()
|
||||
text.textSize = radius * 0.9f
|
||||
buttons.forEachIndexed { i, (button, label) ->
|
||||
val (x, y) = centres[i]
|
||||
val down = button in held
|
||||
fill.color = if (down) ACCENT else KNOB
|
||||
canvas.drawCircle(x, y, radius, fill)
|
||||
text.color = if (down) ON_ACCENT else TEXT
|
||||
canvas.drawText(label, x, y + text.textSize * 0.35f, text)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
val (centres, radius) = layout()
|
||||
val now = HashSet<Int>()
|
||||
val ending = event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL
|
||||
if (!ending) for (p in 0 until event.pointerCount) {
|
||||
if (event.actionMasked == MotionEvent.ACTION_POINTER_UP && p == event.actionIndex) continue
|
||||
val x = event.getX(p)
|
||||
val y = event.getY(p)
|
||||
val nearest = centres.indices.minByOrNull { hypot(centres[it].first - x, centres[it].second - y) } ?: continue
|
||||
if (hypot(centres[nearest].first - x, centres[nearest].second - y) <= radius * 1.6f) now.add(buttons[nearest].first)
|
||||
}
|
||||
set(now)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun set(now: Set<Int>) {
|
||||
if (now == held) return
|
||||
if ((now - held).isNotEmpty()) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
val before = held
|
||||
held = now
|
||||
pad.applyTouch { s -> for (b in before + now) s.press(b, b in now) }
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
set(emptySet())
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
}
|
||||
|
||||
private class GamepadLayout(context: Context, private val portraitDock: Boolean) : ViewGroup(context) {
|
||||
private val slots = LinkedHashMap<View, GamepadGeometry.Slot>()
|
||||
|
||||
fun place(view: View, slot: GamepadGeometry.Slot) {
|
||||
slots[view] = slot
|
||||
addView(view)
|
||||
}
|
||||
|
||||
private fun boxes(w: Int, h: Int) = GamepadGeometry.layout(w.toFloat(), h.toFloat(), resources.displayMetrics.density, portraitDock)
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val w = MeasureSpec.getSize(widthMeasureSpec)
|
||||
val h = MeasureSpec.getSize(heightMeasureSpec)
|
||||
setMeasuredDimension(w, h)
|
||||
val boxes = boxes(w, h)
|
||||
for ((view, slot) in slots) {
|
||||
val box = boxes[slot]
|
||||
view.visibility = if (box == null) View.GONE else View.VISIBLE
|
||||
if (box == null) continue
|
||||
view.measure(MeasureSpec.makeMeasureSpec(box.width.toInt().coerceAtLeast(1), MeasureSpec.EXACTLY),
|
||||
MeasureSpec.makeMeasureSpec(box.height.toInt().coerceAtLeast(1), MeasureSpec.EXACTLY))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
|
||||
val boxes = boxes(r - l, b - t)
|
||||
for ((view, slot) in slots) {
|
||||
val box = boxes[slot] ?: continue
|
||||
val left = box.left.toInt()
|
||||
val top = box.top.toInt()
|
||||
view.layout(left, top, left + box.width.toInt().coerceAtLeast(1), top + box.height.toInt().coerceAtLeast(1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CLICK_MS = 300L
|
||||
|
||||
private var selected = Tab.BOTH
|
||||
private var selectedWithPad = Tab.BOTH
|
||||
|
||||
private const val CORNER = 20f
|
||||
private const val CORNER_PILL = 22f
|
||||
private val ACCENT = Color.rgb(0x1A, 0x9F, 0xFF)
|
||||
private val ACCENT_WASH = Color.argb(0x2E, 0x1A, 0x9F, 0xFF)
|
||||
private val ACCENT_GLOW = Color.argb(0x66, 0x1A, 0x9F, 0xFF)
|
||||
private val ON_ACCENT = Color.rgb(0x03, 0x11, 0x1F)
|
||||
private val SURFACE = Color.rgb(0x0A, 0x0B, 0x0D)
|
||||
private val LINE = Color.rgb(0x26, 0x29, 0x2E)
|
||||
private val GUIDE = Color.rgb(0x16, 0x18, 0x1B)
|
||||
private val TEXT = Color.rgb(0xF2, 0xF4, 0xF7)
|
||||
private val TEXT_DIM = Color.rgb(0x7A, 0x80, 0x8A)
|
||||
private val KNOB = Color.rgb(0x2A, 0x2D, 0x33)
|
||||
|
||||
internal fun dp(context: Context, value: Int): Int = (value * context.resources.displayMetrics.density + 0.5f).toInt()
|
||||
|
||||
internal fun outline(context: Context, radius: Float, fill: Int) = GradientDrawable().apply {
|
||||
cornerRadius = radius * context.resources.displayMetrics.density
|
||||
setColor(fill)
|
||||
setStroke(dp(context, 1), LINE)
|
||||
}
|
||||
|
||||
internal fun roundButton(context: Context, label: String, description: String, onClick: () -> Unit, wide: Boolean = false) =
|
||||
TextView(context).apply {
|
||||
text = label
|
||||
contentDescription = description
|
||||
textSize = if (wide) 13f else 20f
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
letterSpacing = if (wide) 0.08f else 0f
|
||||
gravity = Gravity.CENTER
|
||||
setTextColor(TEXT)
|
||||
background = outline(context, CORNER_PILL, SURFACE)
|
||||
setPadding(if (wide) dp(context, 16) else 0, 0, if (wide) dp(context, 16) else 0, 0)
|
||||
minWidth = dp(context, 44)
|
||||
layoutParams = LinearLayout.LayoutParams(if (wide) LinearLayout.LayoutParams.WRAP_CONTENT else dp(context, 44), dp(context, 44))
|
||||
setOnClickListener {
|
||||
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun menuButton(context: Context, onClick: () -> Unit) =
|
||||
iconButton(context, R.drawable.ic_stat_session, context.getString(R.string.touch_droiddeck_menu), onClick)
|
||||
|
||||
internal fun iconButton(context: Context, icon: Int, description: String, onClick: () -> Unit) = android.widget.ImageView(context).apply {
|
||||
setImageResource(icon)
|
||||
setColorFilter(TEXT)
|
||||
contentDescription = description
|
||||
scaleType = android.widget.ImageView.ScaleType.CENTER_INSIDE
|
||||
val pad = dp(context, 11)
|
||||
setPadding(pad, pad, pad, pad)
|
||||
background = outline(context, CORNER_PILL, SURFACE)
|
||||
layoutParams = LinearLayout.LayoutParams(dp(context, 44), dp(context, 44))
|
||||
setOnClickListener {
|
||||
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,9 +115,6 @@ class FrontEndState(
|
||||
val adbPaired: Boolean = false,
|
||||
val backgroundAppLimit: Int = -1,
|
||||
val memoryHud: String = "off",
|
||||
val tpUbwcMode: String? = null,
|
||||
val tpUbwcRestartRequired: Boolean = false,
|
||||
val canRestartApp: Boolean = false,
|
||||
val steamGames: List<Library.SteamGame>,
|
||||
val steamGamesX64: Boolean = false,
|
||||
val emulators: List<Library.Emulator>,
|
||||
@@ -144,7 +141,6 @@ class FrontEndState(
|
||||
val dualScreenMode: String = com.droiddeck.launcher.input.DualScreen.MODE_AUTO,
|
||||
val dualScreenSessions: String = com.droiddeck.launcher.input.DualScreen.MODE_AUTO,
|
||||
val dualScreenControls: Boolean = true,
|
||||
val androidTextFieldKeyboard: Boolean = false,
|
||||
val packages: List<PackageRow>? = null,
|
||||
val packageCatalogLoading: Boolean = false,
|
||||
val packageBusyId: String? = null,
|
||||
@@ -174,7 +170,6 @@ class FrontEndState(
|
||||
val downloadPrefix: String = "",
|
||||
val downloadProxy: String = "",
|
||||
val animationsEnabled: Boolean = true,
|
||||
val lowStorageWarning: Boolean = true,
|
||||
val launcherIcon: String = com.droiddeck.launcher.LauncherIcon.DARK,
|
||||
val gameStoresEnabled: Boolean = true,
|
||||
val gameStoresSpeedTier: String = "fast",
|
||||
@@ -250,8 +245,6 @@ class FrontEndActions(
|
||||
val onSteamGpuDrawing: () -> Unit = {},
|
||||
val onClearCachedApps: (Boolean) -> Unit = {},
|
||||
val onMemoryHud: (String) -> Unit = {},
|
||||
val onTpUbwcMode: (String) -> Unit = {},
|
||||
val onRestartApp: () -> Unit = {},
|
||||
val onBackgroundAppLimit: (Int) -> Unit = {},
|
||||
val onShareLogs: () -> Unit = {},
|
||||
val onShareSessionLog: (File) -> Unit = {},
|
||||
@@ -269,11 +262,9 @@ class FrontEndActions(
|
||||
val onLauncherOrientation: (Int) -> Unit = {},
|
||||
val onLauncherFullscreen: (Boolean) -> Unit = {},
|
||||
val onAnimationsEnabled: (Boolean) -> Unit = {},
|
||||
val onLowStorageWarning: (Boolean) -> Unit = {},
|
||||
val onDualScreenMode: (String) -> Unit = {},
|
||||
val onDualScreenSessions: (String) -> Unit = {},
|
||||
val onDualScreenControls: (Boolean) -> Unit = {},
|
||||
val onAndroidTextFieldKeyboard: (Boolean) -> Unit = {},
|
||||
val onDownloadSource: (String) -> Unit = {},
|
||||
val onDownloadMirror: (String) -> Unit = {},
|
||||
val onDownloadPrefix: (String) -> Unit = {},
|
||||
|
||||
@@ -245,7 +245,7 @@ internal fun SetupPanel(
|
||||
PageHeader(stringResource(R.string.setup_title)) {
|
||||
Box(Modifier.weight(1f)) { SheetChips(tabs, tab, chipFocus, onPick = pick) }
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f).fillMaxWidth().then(if (tab == 1) Modifier else Modifier.verticalScroll(rememberScrollState())).padding(bottom = if (tab == 1) 0.dp else 24.dp)) {
|
||||
Column(modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) {
|
||||
when (tab) {
|
||||
0 -> {
|
||||
// Setup's "Safe mode" row, under the GPU row while the bridge is on and can be used here.
|
||||
@@ -413,7 +413,9 @@ internal fun SetupPanel(
|
||||
}
|
||||
1 -> {
|
||||
val controller = s.controller
|
||||
if (controller != null && a.controller != null) InputSettingsPage(onBack = { pick(0) }, embedded = true)
|
||||
if (controller != null && a.controller != null) SettingsGroup(stringResource(R.string.setup_controller)) {
|
||||
ControllerRows(host, s.oscMode, controller, a.controller)
|
||||
}
|
||||
if (s.controller == null || a.controller == null) Note(stringResource(R.string.setup_controller_unavailable))
|
||||
}
|
||||
2 -> {
|
||||
@@ -450,8 +452,6 @@ internal fun SetupPanel(
|
||||
)
|
||||
ToggleRow(host, "launcher-animations", stringResource(R.string.setup_animations), null,
|
||||
s.animationsEnabled, onChange = a.onAnimationsEnabled)
|
||||
ToggleRow(host, "low-storage-warning", stringResource(R.string.setup_low_storage_warning), stringResource(R.string.setup_low_storage_warning_hint),
|
||||
s.lowStorageWarning, onChange = a.onLowStorageWarning)
|
||||
val railContext = androidx.compose.ui.platform.LocalContext.current
|
||||
remember { RailLabels.load(railContext) }
|
||||
ToggleRow(host, "rail-labels", stringResource(R.string.setup_rail_labels), stringResource(R.string.setup_rail_labels_hint), RailLabels.shown) { RailLabels.set(railContext, it) }
|
||||
@@ -474,20 +474,6 @@ internal fun SetupPanel(
|
||||
com.droiddeck.launcher.core.AppUiPrefs.orientationOptions.map { (value, label) -> value to stringResource(label) },
|
||||
s.launcherOrientation, onPick = a.onLauncherOrientation,
|
||||
)
|
||||
s.tpUbwcMode?.let { ubwc ->
|
||||
ChoiceRow(
|
||||
host, "tp-ubwc", stringResource(R.string.gpu_ubwc_title), stringResource(R.string.gpu_ubwc_hint),
|
||||
listOf(
|
||||
SessionPrefs.TP_UBWC_AUTO to stringResource(R.string.common_auto),
|
||||
SessionPrefs.TP_UBWC_ON to stringResource(R.string.widgets_on),
|
||||
SessionPrefs.TP_UBWC_OFF to stringResource(R.string.widgets_off),
|
||||
), ubwc, onPick = a.onTpUbwcMode,
|
||||
)
|
||||
if (s.tpUbwcRestartRequired) {
|
||||
if (s.canRestartApp) ActionRow(stringResource(R.string.gpu_ubwc_restart), null, stringResource(R.string.gpu_restart_now), a.onRestartApp)
|
||||
else Note(stringResource(R.string.gpu_ubwc_restart))
|
||||
}
|
||||
}
|
||||
ChoiceRow(
|
||||
host, "orientation", stringResource(R.string.setup_session_orientation), stringResource(R.string.setup_session_orientation_hint),
|
||||
SessionPrefs.orientationOptions.map { (value, label) -> value to stringResource(label) },
|
||||
@@ -516,8 +502,6 @@ internal fun SetupPanel(
|
||||
ToggleRow(host, "dual-screen-controls", stringResource(R.string.setup_dual_screen_controls),
|
||||
stringResource(R.string.setup_dual_screen_controls_hint), s.dualScreenControls) { a.onDualScreenControls(it) }
|
||||
}
|
||||
ToggleRow(host, "android-text-field-keyboard", stringResource(R.string.setup_android_text_field_keyboard),
|
||||
null, s.androidTextFieldKeyboard) { a.onAndroidTextFieldKeyboard(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -851,7 +835,6 @@ internal fun GameSettingsMenus(host: MenuHost, game: com.droiddeck.launcher.fron
|
||||
)
|
||||
val appSync = syncLabels[com.droiddeck.launcher.session.SessionPrefs.syncBackend(appContext)]
|
||||
val useDefault = stringResource(R.string.comp_game_use_general)
|
||||
val generalFex = com.droiddeck.launcher.core.FexPreset.byId(com.droiddeck.launcher.session.SessionPrefs.fexPreset(appContext))
|
||||
val custom = stringResource(R.string.comp_game_fex_custom)
|
||||
val customSync = stringResource(R.string.game_sync_custom)
|
||||
val resetDone = stringResource(R.string.comp_game_reset_done, game.name)
|
||||
@@ -866,7 +849,7 @@ internal fun GameSettingsMenus(host: MenuHost, game: com.droiddeck.launcher.fron
|
||||
MenuItem(
|
||||
stringResource(R.string.fex_preset_title), checked = false, enabled = ready,
|
||||
detail = when {
|
||||
fex == null -> "$useDefault · ${stringResource(generalFex.label)}"
|
||||
fex == null -> useDefault
|
||||
chosen == null -> custom
|
||||
else -> stringResource(chosen.label)
|
||||
},
|
||||
@@ -898,7 +881,7 @@ internal fun GameSettingsMenus(host: MenuHost, game: com.droiddeck.launcher.fron
|
||||
}
|
||||
}
|
||||
AnchoredMenu(host.open == fexKey, onDismiss = { if (host.open == fexKey) host.open = null }, title = stringResource(R.string.fex_preset_title)) { first ->
|
||||
MenuItem(useDefault, checked = fex == null, enabled = ready, detail = stringResource(generalFex.label), focusRequester = first) {
|
||||
MenuItem(useDefault, checked = fex == null, enabled = ready, focusRequester = first) {
|
||||
host.open = key
|
||||
save { com.droiddeck.launcher.session.GameEnvironmentStore.setGameFexPreset(it, scope, null) }
|
||||
}
|
||||
|
||||
@@ -874,15 +874,12 @@ private fun SavesTab(g: Library.SteamGame, prefix: File, a: FrontEndActions) {
|
||||
|
||||
@Composable
|
||||
private fun AddonsTab(g: Library.SteamGame, targets: List<WowAddons.Target>, a: FrontEndActions) {
|
||||
var browsing by remember(g.gameId) { mutableStateOf<WowAddons.Target?>(null) }
|
||||
browsing?.let { AddonBrowserDialog(targets, it) { browsing = null } }
|
||||
Column(Modifier.fillMaxWidth().tabPanel(), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
targets.forEach { target ->
|
||||
val names by produceState(emptyList<String>(), target.addOns, WowAddons.imports) {
|
||||
value = withContext(Dispatchers.IO) { WowAddons.installed(target) }
|
||||
}
|
||||
if (targets.size > 1) SectionLabel(target.flavor)
|
||||
MenuItem(stringResource(R.string.addons_download), checked = false) { browsing = target }
|
||||
MenuItem(
|
||||
stringResource(R.string.games_import_addons), checked = false,
|
||||
detail = pluralStringResource(R.plurals.games_addons_count, names.size, names.size),
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.droiddeck.launcher.input
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import kotlin.math.min
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
@@ -133,6 +134,8 @@ class GpuDriversState(
|
||||
val runtime: DriverSlot = DriverSlot(),
|
||||
val canRestart: Boolean = true,
|
||||
val checkedAt: Long = 0L,
|
||||
val tpUbwcMode: String? = null,
|
||||
val tpUbwcRestartRequired: Boolean = false,
|
||||
) {
|
||||
val activePair: PairRow? get() = pairs.firstOrNull { it.active }
|
||||
}
|
||||
@@ -153,6 +156,7 @@ class GpuDriversActions(
|
||||
val onRestoreBundled: () -> Unit = {},
|
||||
val onMode: (String) -> Unit = {},
|
||||
val onRestart: () -> Unit = {},
|
||||
val onTpUbwcMode: (String) -> Unit = {},
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -254,6 +258,27 @@ internal fun GpuDriversBody(
|
||||
GPU_MODE_PAIR -> PairBody(gpu, actions)
|
||||
else -> MixBody(single, actions, slotFocus, onOpenSlot)
|
||||
}
|
||||
gpu.tpUbwcMode?.let { selected ->
|
||||
var advanced by rememberSaveable { mutableStateOf(false) }
|
||||
SecondaryButton(stringResource(R.string.gpu_advanced) + if (advanced) " ▴" else " ▾", modifier = Modifier.fillMaxWidth()) { advanced = !advanced }
|
||||
AnimatedVisibility(advanced) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ChoiceRow(
|
||||
rememberMenuHost(), "tp-ubwc", stringResource(R.string.gpu_ubwc_title),
|
||||
stringResource(R.string.gpu_ubwc_hint),
|
||||
listOf(
|
||||
SessionPrefs.TP_UBWC_AUTO to stringResource(R.string.common_auto),
|
||||
SessionPrefs.TP_UBWC_ON to stringResource(R.string.widgets_on),
|
||||
SessionPrefs.TP_UBWC_OFF to stringResource(R.string.widgets_off),
|
||||
), selected, onPick = actions.onTpUbwcMode,
|
||||
)
|
||||
if (gpu.tpUbwcRestartRequired) {
|
||||
Text(stringResource(R.string.gpu_ubwc_restart), fontSize = 13.sp, color = AttentionAmber)
|
||||
if (gpu.canRestart) SecondaryButton(stringResource(R.string.gpu_restart_now), onClick = actions.onRestart)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.input.*
|
||||
|
||||
@Composable
|
||||
internal fun InputActionEditor(
|
||||
control: InputControl, current: InputAction, title: String, onBack: () -> Unit, onSave: (InputAction) -> Unit,
|
||||
element: InputSurfaceElement? = null, nativeTargets: Boolean = true, detail: String? = null,
|
||||
properties: (@Composable ColumnScope.(InputAction) -> Unit)? = null,
|
||||
click: Boolean = false,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val host = rememberMenuHost()
|
||||
var draft by remember(current, title) { mutableStateOf(current) }
|
||||
val drafts = remember(current, title) { mutableMapOf<String, InputAction>() }
|
||||
fun typeOf(action: InputAction): String = when (action) {
|
||||
is InputAction.Native -> "native"; InputAction.Disabled -> "disabled"
|
||||
is InputAction.MouseButton -> "mouse"; is InputAction.Command -> "command"
|
||||
is InputAction.Pointer -> "pointer"; is InputAction.Scroll -> "scroll"
|
||||
is InputAction.Directions -> "directions"; is InputAction.Keys -> "keys"
|
||||
}
|
||||
var localType by remember(current, title) { mutableStateOf(typeOf(current).takeUnless { it == "native" || it == "disabled" } ?: "keys") }
|
||||
fun switch(type: String, initial: () -> InputAction) {
|
||||
drafts[typeOf(draft)] = draft
|
||||
draft = drafts[type] ?: initial()
|
||||
if (type != "native" && type != "disabled") localType = type
|
||||
}
|
||||
var keys by remember { mutableStateOf(false) }
|
||||
var direction by remember { mutableStateOf<Int?>(null) }
|
||||
fun valid(action: InputAction) = if (click) action.validForClick() else if (element != null) action.validFor(element) else action.validFor(control)
|
||||
val nativeOptions = remember(control, element, nativeTargets, click) {
|
||||
(listOf(InputAction.Native()) + if (nativeTargets) InputControl.entries.map { InputAction.Native(it) } else emptyList()).filter(::valid)
|
||||
}
|
||||
val route = when (draft) { is InputAction.Native -> 0; InputAction.Disabled -> 2; else -> 1 }
|
||||
BackHandler(onBack = onBack)
|
||||
SettingsPage(host, title, onBack, eyebrow = stringResource(R.string.input_properties), lede = detail,
|
||||
action = { PrimaryButton(stringResource(R.string.gesture_save_changes), enabled = valid(draft),
|
||||
modifier = Modifier.testTag("input-action-save")) { onSave(draft) } }) {
|
||||
properties?.invoke(this, draft)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
SheetChips(listOf(stringResource(R.string.input_route_controller), stringResource(R.string.input_route_local), stringResource(R.string.input_disabled)), route) {
|
||||
when (it) {
|
||||
0 -> switch("native") { nativeOptions.firstOrNull() ?: InputAction.Disabled }
|
||||
2 -> switch("disabled") { InputAction.Disabled }
|
||||
else -> switch(localType) { InputAction.Keys(listOf(28)) }
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
when (route) {
|
||||
0 -> SettingsGroup(stringResource(R.string.input_route_controller)) {
|
||||
ChoiceRow(host, "native-target", stringResource(R.string.input_remap_native), stringResource(R.string.input_controller_route_hint),
|
||||
nativeOptions.map { it to (it.target?.label ?: stringResource(if (nativeTargets) R.string.input_native else R.string.input_normal_binding)) },
|
||||
draft as InputAction.Native) { draft = it }
|
||||
}
|
||||
2 -> Text(stringResource(R.string.input_disabled_hint), Modifier.padding(16.dp))
|
||||
else -> {
|
||||
val types = listOf("keys" to stringResource(R.string.input_keys), "mouse" to stringResource(R.string.input_mouse_click),
|
||||
"command" to stringResource(R.string.input_command)) + if (control.dimensions == 2) listOf(
|
||||
"pointer" to stringResource(R.string.input_pointer), "scroll" to stringResource(R.string.input_scroll),
|
||||
"directions" to stringResource(R.string.input_direction_keys)) else emptyList()
|
||||
val type = when (draft) { is InputAction.MouseButton -> "mouse"; is InputAction.Command -> "command";
|
||||
is InputAction.Pointer -> "pointer"; is InputAction.Scroll -> "scroll"; is InputAction.Directions -> "directions"; else -> "keys" }
|
||||
SettingsGroup(stringResource(R.string.input_action)) {
|
||||
ChoiceRow(host, "action-type", stringResource(R.string.input_action), null, types, type) {
|
||||
switch(it) { when (it) { "mouse" -> InputAction.MouseButton(272); "command" -> InputAction.Command(InputCommand.MENU);
|
||||
"pointer" -> InputAction.Pointer(); "scroll" -> InputAction.Scroll();
|
||||
"directions" -> InputAction.Directions(103, 106, 108, 105); else -> InputAction.Keys(listOf(28)) } }
|
||||
}
|
||||
if (!click && draft !is InputAction.Pointer && draft !is InputAction.Scroll && draft !is InputAction.Directions) {
|
||||
val hint = when {
|
||||
control.isTrackpad -> R.string.input_contact_action_hint
|
||||
control.dimensions == 2 -> R.string.input_axis_action_hint
|
||||
control == InputControl.LT || control == InputControl.RT -> R.string.input_trigger_action_hint
|
||||
else -> null
|
||||
}
|
||||
hint?.let { Text(stringResource(it), Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
}
|
||||
when (val action = draft) {
|
||||
is InputAction.Keys -> ActionRow(stringResource(R.string.input_keys), actionLabel(action), stringResource(R.string.ctrl_edit), { keys = true })
|
||||
is InputAction.MouseButton -> ChoiceRow(host, "mouse-button", stringResource(R.string.input_mouse_click), null,
|
||||
(272..274).map { it to actionLabel(InputAction.MouseButton(it)) }, action.code) { draft = InputAction.MouseButton(it) }
|
||||
is InputAction.Command -> ChoiceRow(host, "command", stringResource(R.string.input_command), null,
|
||||
InputCommand.entries.map { it to commandLabel(it) }, action.command) { draft = InputAction.Command(it) }
|
||||
is InputAction.Pointer -> {
|
||||
Text(stringResource(R.string.input_pointer_speed, action.speed.toInt()), Modifier.padding(horizontal = 16.dp, vertical = 8.dp), color = MaterialTheme.colorScheme.onSurface)
|
||||
Slider(action.speed, { draft = action.copy(speed = it) }, valueRange = 100f..3000f, modifier = Modifier.padding(horizontal = 16.dp))
|
||||
}
|
||||
is InputAction.Scroll -> {
|
||||
ChoiceRow(host, "scroll-axis", stringResource(R.string.input_scroll_axis), null,
|
||||
listOf(false to stringResource(R.string.input_vertical), true to stringResource(R.string.input_horizontal)), action.horizontal) {
|
||||
draft = action.copy(horizontal = it)
|
||||
}
|
||||
Text(stringResource(R.string.input_scroll_speed, action.speed.toInt()), Modifier.padding(horizontal = 16.dp, vertical = 8.dp), color = MaterialTheme.colorScheme.onSurface)
|
||||
Slider(action.speed, { draft = action.copy(speed = it) }, valueRange = 1f..60f, modifier = Modifier.padding(horizontal = 16.dp))
|
||||
}
|
||||
is InputAction.Directions -> {
|
||||
ToggleRow(host, "direction-diagonals", stringResource(R.string.input_diagonal_keys), stringResource(R.string.input_diagonal_keys_hint), action.diagonals != null) {
|
||||
draft = action.copy(diagonals = if (it) action.diagonals ?: listOf(0, 0, 0, 0) else null)
|
||||
}
|
||||
val codes = listOf(action.up, action.right, action.down, action.left) + action.diagonals.orEmpty()
|
||||
val arrows = listOf("↑", "→", "↓", "←", "↗", "↘", "↙", "↖")
|
||||
codes.forEachIndexed { i, code ->
|
||||
ActionRow(arrows[i], if (code == 0) stringResource(if (i < 4) R.string.input_disabled else R.string.input_diagonal_neighbors) else pcKeyLabel(context, code), stringResource(R.string.ctrl_edit), { direction = i })
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (keys) KeyCombinationPicker(onDismiss = { keys = false }, initial = (draft as? InputAction.Keys)?.codes.orEmpty(),
|
||||
sequence = true, sequenceLimit = 8, title = stringResource(R.string.input_keys), confirm = stringResource(R.string.gesture_save_changes), onAdd = {
|
||||
draft = InputAction.Keys(it.sortedBy { code -> if (code in PC_MODIFIERS) 0 else 1 }); keys = false
|
||||
})
|
||||
direction?.let { at ->
|
||||
val action = draft as? InputAction.Directions ?: return@let
|
||||
val codes = listOf(action.up, action.right, action.down, action.left) + action.diagonals.orEmpty()
|
||||
KeyCombinationPicker(onDismiss = { direction = null }, initial = listOf(codes[at]).filter { it != 0 }, sequence = true, sequenceLimit = 1, allowEmpty = true,
|
||||
title = stringResource(R.string.input_direction_keys), confirm = stringResource(R.string.gesture_save_changes), onAdd = {
|
||||
val next = codes.toMutableList().also { values -> values[at] = it.firstOrNull() ?: 0 }
|
||||
draft = InputAction.Directions(next[0], next[1], next[2], next[3], if (action.diagonals != null) next.drop(4) else null); direction = null
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.input.*
|
||||
|
||||
@Composable
|
||||
internal fun actionLabel(action: InputAction): String {
|
||||
val context = LocalContext.current
|
||||
return when (action) {
|
||||
is InputAction.Native -> action.target?.let { stringResource(R.string.input_native_target, it.label) } ?: stringResource(R.string.input_native)
|
||||
InputAction.Disabled -> stringResource(R.string.input_disabled)
|
||||
is InputAction.Keys -> action.codes.joinToString(" + ") { pcKeyLabel(context, it) }
|
||||
is InputAction.MouseButton -> stringResource(when (action.code) { 272 -> R.string.input_mouse_left; 273 -> R.string.input_mouse_right; else -> R.string.input_mouse_middle })
|
||||
is InputAction.Command -> commandLabel(action.command)
|
||||
is InputAction.Directions -> listOf(action.up, action.right, action.down, action.left).joinToString(" / ") { pcKeyLabel(context, it) }
|
||||
is InputAction.Pointer -> stringResource(R.string.input_pointer)
|
||||
is InputAction.Scroll -> stringResource(if (action.horizontal) R.string.input_scroll_horizontal else R.string.input_scroll_vertical)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun commandLabel(command: InputCommand) = stringResource(when (command) {
|
||||
InputCommand.MENU -> R.string.input_command_menu
|
||||
InputCommand.KEYBOARD -> R.string.input_command_keyboard
|
||||
InputCommand.STEAM -> R.string.input_command_steam
|
||||
InputCommand.QUICK_ACCESS -> R.string.input_command_qam
|
||||
})
|
||||
|
||||
@Composable
|
||||
internal fun InputCaptureDialog(onDismiss: () -> Unit, onPick: (PhysicalInputKey) -> Unit) {
|
||||
val confirmFocus = remember { FocusRequester() }
|
||||
var captured by remember { mutableStateOf<PhysicalInputKey?>(null) }
|
||||
var capturedDevice by remember { mutableIntStateOf(-1) }
|
||||
var ready by remember { mutableStateOf(false) }
|
||||
AlertDialog(onDismissRequest = onDismiss, title = { Text(stringResource(R.string.input_capture)) },
|
||||
properties = DialogProperties(dismissOnBackPress = ready),
|
||||
text = {
|
||||
CaptureShortcutKeys { event ->
|
||||
if (ready) {
|
||||
if (event.keyCode == android.view.KeyEvent.KEYCODE_BACK || event.keyCode == android.view.KeyEvent.KEYCODE_BUTTON_B) {
|
||||
if (event.action == android.view.KeyEvent.ACTION_DOWN && event.repeatCount == 0) onDismiss()
|
||||
true
|
||||
} else false
|
||||
} else {
|
||||
val key = PhysicalInputKey.capture(event)
|
||||
if (key == null) false else {
|
||||
if (captured == null && event.action == android.view.KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
|
||||
captured = key
|
||||
capturedDevice = event.deviceId
|
||||
} else if (captured == key && capturedDevice == event.deviceId && event.action == android.view.KeyEvent.ACTION_UP) ready = true
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
Column {
|
||||
Text(captured?.label ?: stringResource(R.string.input_capture_hint))
|
||||
if (captured != null) Text(stringResource(R.string.input_capture_confirm))
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(enabled = ready, modifier = Modifier.focusRequester(confirmFocus)
|
||||
.controllerConfirm(ready) { captured?.let(onPick) }, onClick = { captured?.let(onPick) }) { Text(stringResource(R.string.ctrl_edit)) }
|
||||
LaunchedEffect(ready) { if (ready) confirmFocus.requestFocus() }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(modifier = Modifier.controllerConfirm { onDismiss() }, onClick = onDismiss) { Text(stringResource(R.string.gesture_cancel)) }
|
||||
if (ready) TextButton(modifier = Modifier.controllerConfirm { captured = null; ready = false },
|
||||
onClick = { captured = null; ready = false }) { Text(stringResource(R.string.input_capture_retry)) }
|
||||
})
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.app.Presentation
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.os.Bundle
|
||||
import android.view.Display
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.setViewTreeOnBackPressedDispatcherOwner
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.setViewTreeLifecycleOwner
|
||||
import androidx.lifecycle.setViewTreeViewModelStoreOwner
|
||||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||
|
||||
internal val LocalInputActivity = staticCompositionLocalOf<ComponentActivity?> { null }
|
||||
|
||||
fun ComponentActivity.screenIsOff(): Boolean = getSystemService(android.os.PowerManager::class.java)?.isInteractive == false
|
||||
|
||||
@Composable
|
||||
internal fun InputDisplayContent(host: ComponentActivity, display: Display, onDismiss: () -> Unit, content: @Composable () -> Unit) {
|
||||
val parent = rememberCompositionContext()
|
||||
val currentContent by rememberUpdatedState(content)
|
||||
val dismiss by rememberUpdatedState(onDismiss)
|
||||
DisposableEffect(host, display.displayId) {
|
||||
val presentation = object : Presentation(host, display) {
|
||||
var compose: ComposeView? = null
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
window?.apply {
|
||||
setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
|
||||
clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
|
||||
prepareInputEditor()
|
||||
decorView.setViewTreeLifecycleOwner(host)
|
||||
decorView.setViewTreeViewModelStoreOwner(host)
|
||||
decorView.setViewTreeSavedStateRegistryOwner(host)
|
||||
decorView.setViewTreeOnBackPressedDispatcherOwner(host)
|
||||
}
|
||||
// Presentation contexts cannot host TYPE_APPLICATION dialogs on Android 12+.
|
||||
compose = ComposeView(host).also {
|
||||
it.setParentCompositionContext(parent)
|
||||
it.setContent { CompositionLocalProvider(LocalInputActivity provides host) { currentContent() } }
|
||||
setContentView(it)
|
||||
}
|
||||
}
|
||||
override fun dismiss() { compose?.disposeComposition(); compose = null; super.dismiss() }
|
||||
}
|
||||
presentation.setOnCancelListener { dismiss() }
|
||||
fun showPresentation() {
|
||||
if (!display.isValid) { dismiss(); return }
|
||||
try {
|
||||
presentation.show()
|
||||
} catch (_: WindowManager.InvalidDisplayException) {
|
||||
presentation.dismiss()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
(host as? com.droiddeck.launcher.SessionActivity)?.inputWindowShown(display.displayId)
|
||||
showPresentation()
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_STOP && !host.screenIsOff()) presentation.hide()
|
||||
if (event == Lifecycle.Event.ON_START) showPresentation()
|
||||
}
|
||||
host.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
host.lifecycle.removeObserver(observer)
|
||||
presentation.dismiss()
|
||||
(host as? com.droiddeck.launcher.SessionActivity)?.inputWindowClosed(display.displayId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import com.droiddeck.launcher.input.*
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import com.droiddeck.launcher.R
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
internal fun InputElementProperties(element: InputSurfaceElement, action: InputAction? = null, onChange: (InputSurfaceElement) -> Unit) {
|
||||
val current by rememberUpdatedState(element)
|
||||
val change by rememberUpdatedState(onChange)
|
||||
OutlinedTextField(element.name, { onChange(element.copy(name = it.take(80))) }, label = { Text(stringResource(R.string.input_name)) },
|
||||
singleLine = true, modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp))
|
||||
if (element.kind == InputElementKind.BUTTON || element.kind == InputElementKind.TRIGGER) {
|
||||
val host = rememberMenuHost()
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val pickImage = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
if (uri != null) scope.launch {
|
||||
withContext(Dispatchers.IO) { InputButtonImages.import(context.applicationContext, uri) }?.let { change(current.copy(icon = 0, image = it)) }
|
||||
}
|
||||
}
|
||||
ToggleRow(host, "element-toggle", stringResource(R.string.input_toggle_button), stringResource(R.string.input_toggle_button_hint), element.toggle) { onChange(element.copy(toggle = it)) }
|
||||
ChoiceRow(host, "element-shape", stringResource(R.string.input_button_shape), null,
|
||||
InputButtonShape.entries.map { it to stringResource(when (it) { InputButtonShape.ROUNDED -> R.string.input_shape_rounded; InputButtonShape.PILL -> R.string.input_shape_pill; InputButtonShape.CIRCLE -> R.string.input_shape_circle; InputButtonShape.RECTANGLE -> R.string.input_shape_rectangle }) },
|
||||
element.buttonShape) { onChange(element.copy(buttonShape = it)) }
|
||||
val open = host.open == "element-icon"
|
||||
SettingsRow(stringResource(R.string.input_button_symbol), null, highlighted = open) {
|
||||
Box {
|
||||
ValueChip(if (element.image.isNotEmpty()) stringResource(R.string.input_imported_image) else if (element.icon == 0) stringResource(R.string.input_button_text) else InputButtonIcons.label(context, element.icon), open) { host.open = if (open) null else "element-icon" }
|
||||
AnchoredMenu(open, onDismiss = { host.open = null }, title = stringResource(R.string.input_button_symbol)) { firstItemFocus ->
|
||||
MenuItem(stringResource(R.string.input_button_text), element.icon == 0 && element.image.isEmpty(), focusRequester = firstItemFocus) { onChange(element.copy(icon = 0, image = "")); host.open = null }
|
||||
MenuItem(stringResource(R.string.screen_key_image), element.image.isNotEmpty()) { host.open = null; pickImage.launch("image/*") }
|
||||
for (code in InputButtonIcons.codes) MenuItem(InputButtonIcons.label(context, code), element.icon == code && element.image.isEmpty(), leading = {
|
||||
val color = MaterialTheme.colorScheme.onSurface
|
||||
val symbol = remember(context, code) { InputButtonIcons.drawable(context, code) }
|
||||
val paint = remember { android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG) }
|
||||
Canvas(Modifier.size(22.dp)) {
|
||||
paint.color = android.graphics.Color.argb((color.alpha * 255).toInt(), (color.red * 255).toInt(), (color.green * 255).toInt(), (color.blue * 255).toInt())
|
||||
InputButtonIcons.draw(drawContext.canvas.nativeCanvas, code, size.width / 2, size.height / 2, size.minDimension, paint, symbol)
|
||||
}
|
||||
}) { onChange(element.copy(icon = code, image = "")); host.open = null }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (element.kind == InputElementKind.TRACKPAD) {
|
||||
ToggleRow(rememberMenuHost(), "element-mouse-gestures", stringResource(R.string.input_mouse_gestures), stringResource(R.string.input_mouse_gestures_hint), element.mouseGestures) { onChange(element.copy(mouseGestures = it)) }
|
||||
}
|
||||
if (element.kind == InputElementKind.STICK) {
|
||||
val host = rememberMenuHost()
|
||||
ToggleRow(host, "element-floating", stringResource(R.string.input_floating_stick), null, element.floating) { onChange(element.copy(floating = it)) }
|
||||
ToggleRow(host, "element-hide-idle", stringResource(R.string.input_hide_idle), null, element.hideWhenIdle) { onChange(element.copy(hideWhenIdle = it)) }
|
||||
}
|
||||
val host = rememberMenuHost()
|
||||
if ((element.kind == InputElementKind.BUTTON || element.kind == InputElementKind.TRIGGER) && element.icon == 0 && element.image.isEmpty() ||
|
||||
element.kind == InputElementKind.STICK && element.id.startsWith("legacy:") ||
|
||||
(element.kind == InputElementKind.STICK || element.kind == InputElementKind.DPAD) && (action as? InputAction.Directions)
|
||||
?.let { listOf(it.up, it.right, it.down, it.left).plus(if (element.kind == InputElementKind.STICK) it.diagonals.orEmpty() else emptyList()).any { code -> code != 0 } } == true)
|
||||
ToggleRow(host, "element-label", stringResource(R.string.input_show_label), null, element.showLabel) { onChange(element.copy(showLabel = it)) }
|
||||
SliderRow(stringResource(R.string.ctrl_opacity), null, (element.opacity * 100).roundToInt().coerceIn(20, 100), 20..100, step = 5,
|
||||
format = { "$it%" }, modifier = Modifier.testTag("setting-element-opacity")) { onChange(element.copy(opacity = it / 100f)) }
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
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.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.selected
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.toggleableState
|
||||
import androidx.compose.ui.state.ToggleableState
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/** A row that shows focus and answers the controller's A button, like the rest of the settings widgets. */
|
||||
@Composable
|
||||
internal fun Modifier.focusRow(shape: Shape = RoundedCornerShape(10.dp), role: Role = Role.Button, enabled: Boolean = true, checked: Boolean? = null, onClick: () -> Unit): Modifier {
|
||||
val source = remember { MutableInteractionSource() }
|
||||
val hot = source.collectIsFocusedAsState().value || source.collectIsHoveredAsState().value
|
||||
return clip(shape)
|
||||
.background(if (hot && enabled) Color.White.copy(alpha = 0.07f) else Color.Transparent)
|
||||
.glideBorder(hot && enabled, shape, LocalPalette.current.signal)
|
||||
.semantics(mergeDescendants = true) {
|
||||
if (checked != null) {
|
||||
if (role == Role.RadioButton) selected = checked else toggleableState = if (checked) ToggleableState.On else ToggleableState.Off
|
||||
}
|
||||
}
|
||||
.hoverable(source)
|
||||
.clickable(interactionSource = source, indication = LocalIndication.current, enabled = enabled, role = role, onClick = onClick)
|
||||
.controllerConfirm(enabled = enabled, onClick = onClick)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PadTextButton(onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, content: @Composable RowScope.() -> Unit) {
|
||||
TextButton(onClick = onClick, enabled = enabled, modifier = modifier.controllerConfirm(enabled = enabled, onClick = onClick), content = content)
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.view.View
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.input.*
|
||||
|
||||
@Composable
|
||||
internal fun InputLayoutRow(layout: SavedInputLayout, role: InputRole, detail: String?, onManage: () -> Unit, onPick: () -> Unit) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(Modifier.weight(1f).focusRow(onClick = onPick).padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
InputLayoutThumbnail(layout, role)
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(layout.name, style = MaterialTheme.typography.titleMedium)
|
||||
Text(detail ?: stringResource(R.string.input_layout_count, layout.elements.size),
|
||||
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
PadTextButton(onClick = onManage, modifier = Modifier.testTag("layout-manage-${layout.id}")) { Text("⋮", style = MaterialTheme.typography.titleLarge) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun InputLayoutThumbnail(layout: SavedInputLayout, role: InputRole) {
|
||||
AndroidView(factory = { InputLayoutPreviewView(it) }, modifier = Modifier.width(112.dp).height(76.dp).testTag("layout-preview-${layout.id}"),
|
||||
update = { it.update(layout, role) })
|
||||
}
|
||||
|
||||
internal class InputLayoutPreviewView(context: Context) : View(context) {
|
||||
private val renderer = InputSurfaceView(context)
|
||||
private var sourceHeight = 563
|
||||
fun update(layout: SavedInputLayout, role: InputRole) {
|
||||
sourceHeight = if (role == InputRole.PRIMARY) 563 else 870
|
||||
renderer.updateCustomLayout(layout.elements)
|
||||
renderer.updateStyle(layout.style.copy(opacity = 100))
|
||||
renderer.measure(MeasureSpec.makeMeasureSpec(1000, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(sourceHeight, MeasureSpec.EXACTLY))
|
||||
renderer.layout(0, 0, 1000, sourceHeight)
|
||||
contentDescription = layout.name
|
||||
invalidate()
|
||||
}
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
canvas.save()
|
||||
canvas.clipRect(0, 0, width, height)
|
||||
canvas.drawColor(android.graphics.Color.rgb(10, 11, 13))
|
||||
val scale = minOf(width / 1000f, height / sourceHeight.toFloat())
|
||||
canvas.translate((width - 1000 * scale) / 2, (height - sourceHeight * scale) / 2)
|
||||
canvas.scale(scale, scale)
|
||||
renderer.draw(canvas)
|
||||
canvas.restore()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun InputColorRow(host: MenuHost, value: Int?, onPick: (Int?) -> Unit) {
|
||||
val open = host.open == "layout-color"
|
||||
val tints = listOf<Pair<Int?, Int>>(null to R.string.ctrl_tint_gray) + ControllerPrefs.tints
|
||||
SettingsRow(stringResource(R.string.ctrl_color), null, highlighted = open) {
|
||||
Box {
|
||||
ValueChip(tints.firstOrNull { it.first == value }?.second?.let { stringResource(it) }
|
||||
?: stringResource(R.string.ctrl_custom), open, modifier = Modifier.testTag("setting-layout-color")) {
|
||||
host.open = if (open) null else "layout-color"
|
||||
}
|
||||
AnchoredMenu(open, onDismiss = { if (host.open == "layout-color") host.open = null }, title = stringResource(R.string.ctrl_color)) { firstItemFocus ->
|
||||
tints.forEachIndexed { index, (color, name) ->
|
||||
MenuItem(stringResource(name), checked = value == color, leading = {
|
||||
Box(Modifier.size(14.dp).background(Color(color ?: GRAY_SWATCH), CircleShape).border(1.dp, Color.White.copy(alpha = .35f), CircleShape))
|
||||
}, focusRequester = if (index == 0) firstItemFocus else null) {
|
||||
onPick(color)
|
||||
host.open = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val GRAY_SWATCH = 0xFF26292E.toInt()
|
||||
@@ -1,81 +0,0 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.input.*
|
||||
|
||||
@Composable
|
||||
internal fun InputQuickSettings(host: MenuHost, desktop: Boolean, onChanged: (InputRole?) -> Unit = {}, onEdit: () -> Unit = {}, track: (String) -> Modifier = { Modifier }) {
|
||||
val context = LocalContext.current
|
||||
var library by remember { mutableStateOf(InputLibraries.read(context)) }
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var naming by remember { mutableStateOf(false) }
|
||||
var failure by remember { mutableStateOf<String?>(null) }
|
||||
DisposableEffect(context) {
|
||||
val preferences = context.getSharedPreferences("input-profiles", Context.MODE_PRIVATE)
|
||||
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> if (key == "library") library = InputLibraries.read(context) }
|
||||
preferences.registerOnSharedPreferenceChangeListener(listener)
|
||||
onDispose { preferences.unregisterOnSharedPreferenceChangeListener(listener) }
|
||||
}
|
||||
fun update(role: InputRole?, change: (InputLibrary) -> InputLibrary) {
|
||||
runCatching { InputLibraries.update(context, change) }.onSuccess { library = it; onChanged(role) }
|
||||
.onFailure { failure = it.message ?: context.getString(R.string.input_save_failed) }
|
||||
}
|
||||
val selection = library.selection(desktop)
|
||||
val mixed = selection.parts.isNotEmpty()
|
||||
val active = library.profile(library.activeId(desktop))
|
||||
val profiles = listOf(InputProfiles.AUTO to stringResource(R.string.input_quick_auto, if (desktop) library.profile(library.desktop).name else library.profile(library.steam).name)) +
|
||||
library.profiles.map { it.id to it.name } + if (mixed) listOf("mixed" to stringResource(R.string.input_quick_mixed, active.name)) else emptyList()
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(Modifier.weight(1f).focusRow { expanded = !expanded }.padding(14.dp).testTag("input-profile-expand"),
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(if (expanded) "▾" else "▸", color = MaterialTheme.colorScheme.onSurface)
|
||||
Text(stringResource(R.string.input_profile), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
Box(Modifier.padding(end = 14.dp).widthIn(max = 280.dp)) {
|
||||
ChoiceChip(host, "input-profile", stringResource(R.string.input_profile), profiles, if (mixed) "mixed" else selection.profile,
|
||||
modifier = track("input-profile")) { id -> if (id != "mixed") update(null) { it.selectProfile(desktop, id) } }
|
||||
}
|
||||
}
|
||||
if (expanded) Surface(
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 18.dp, end = 12.dp, bottom = 14.dp).testTag("input-profile-parts"),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = .08f),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = .35f)),
|
||||
) {
|
||||
Column(Modifier.padding(vertical = 6.dp)) {
|
||||
for (role in InputRole.entries) {
|
||||
val options = library.bindings.filter { binding ->
|
||||
if (role == InputRole.PHYSICAL) binding.physical
|
||||
else !binding.physical && library.layouts.any { it.id == binding.layoutId && it.supports(role) }
|
||||
}.map { it.id to it.name }
|
||||
ChoiceRow(host, "input-part-${role.name}", roleLabel(role), null, options, library.activePart(desktop, role).bindings,
|
||||
chipModifier = track("input-part-${role.name}")) { id -> update(role) { it.selectPart(desktop, role, id) } }
|
||||
}
|
||||
if (mixed) SecondaryButton(stringResource(R.string.input_save_combination)) { naming = true }
|
||||
ActionRow(stringResource(R.string.input_settings), null, stringResource(R.string.ctrl_edit), onEdit,
|
||||
modifier = track("input-settings").testTag("input-profile-edit"))
|
||||
}
|
||||
}
|
||||
if (naming) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
AlertDialog(onDismissRequest = { naming = false }, title = { Text(stringResource(R.string.input_save_combination)) },
|
||||
text = { OutlinedTextField(name, { name = it.take(80) }, singleLine = true, label = { Text(stringResource(R.string.input_name)) }) },
|
||||
confirmButton = { PadTextButton(enabled = name.isNotBlank(), onClick = { update(null) { it.saveSelection(desktop, name) }; naming = false }) { Text(stringResource(R.string.gesture_save_changes)) } },
|
||||
dismissButton = { PadTextButton(onClick = { naming = false }) { Text(stringResource(R.string.common_cancel)) } })
|
||||
}
|
||||
failure?.let { message -> AlertDialog(onDismissRequest = { failure = null }, title = { Text(stringResource(R.string.input_save_failed)) },
|
||||
text = { Text(message) }, confirmButton = { PadTextButton(onClick = { failure = null }) { Text(stringResource(R.string.common_ok)) } }) }
|
||||
}
|
||||
@@ -1,504 +0,0 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import android.util.DisplayMetrics
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.input.*
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun InputSettingsDialog(onBack: () -> Unit, onChanged: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val activity = context.findActivity() as? com.droiddeck.launcher.SessionActivity
|
||||
val target = activity?.inputEditorDisplay(InputRole.SECONDARY)
|
||||
if (activity != null && target != null && target.displayId != activity.window.decorView.display?.displayId) {
|
||||
InputDisplayContent(activity, target, onBack) { InputSettingsPane(onBack, onChanged) }
|
||||
} else InputSettingsWindow(onBack, onChanged)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InputSettingsWindow(onBack: () -> Unit, onChanged: () -> Unit) {
|
||||
Dialog(onDismissRequest = onBack, properties = DialogProperties(usePlatformDefaultWidth = false)) {
|
||||
InputSettingsPane(onBack, onChanged)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InputSettingsPane(onBack: () -> Unit, onChanged: () -> Unit) {
|
||||
var editingSurface by remember { mutableStateOf(false) }
|
||||
val window = (androidx.compose.ui.platform.LocalView.current.parent as? androidx.compose.ui.window.DialogWindowProvider)?.window
|
||||
DisposableEffect(window, editingSurface) {
|
||||
window?.setDimAmount(if (editingSurface) 0f else .32f)
|
||||
onDispose { }
|
||||
}
|
||||
BoxWithConstraints(Modifier.fillMaxSize().systemBarsPadding().padding(8.dp)) {
|
||||
CompositionLocalProvider(LocalNarrowPane provides (maxWidth < NarrowPaneWidth)) {
|
||||
Surface(Modifier.fillMaxSize(), color = if (editingSurface) androidx.compose.ui.graphics.Color.Transparent else MaterialTheme.colorScheme.surface) {
|
||||
InputSettingsPage(onBack, onChanged, onSurfaceEditing = { editingSurface = it })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class InputPage { HOME, PROFILE, SOURCE, LAYOUTS, REUSE }
|
||||
private data class InputBindingEdit(val preset: String, val control: InputControl, val element: InputSurfaceElement? = null, val key: PhysicalInputKey? = null, val click: Boolean = false)
|
||||
private data class InputNameRequest(val title: Int, val initial: String, val sources: List<Pair<String, String>> = emptyList(),
|
||||
val source: String = "", val offerSharing: Boolean = false, val save: (String, String, Boolean) -> Unit)
|
||||
private data class InputConfirmation(val title: String, val detail: String, val action: () -> Unit)
|
||||
private data class InputReuseRequest(val name: String, val layoutOnly: Boolean, val apply: (Boolean) -> Unit)
|
||||
|
||||
@Composable
|
||||
fun InputSettingsPage(onBack: () -> Unit, onChanged: () -> Unit = {}, embedded: Boolean = false, onSurfaceEditing: (Boolean) -> Unit = {}) {
|
||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
||||
InputSettingsContent(onBack, onChanged, embedded, onSurfaceEditing)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InputSettingsContent(onBack: () -> Unit, onChanged: () -> Unit, embedded: Boolean, onSurfaceEditing: (Boolean) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val host = rememberMenuHost()
|
||||
var library by remember { mutableStateOf(InputLibraries.read(context)) }
|
||||
var page by remember { mutableStateOf(InputPage.HOME) }
|
||||
var bindingsTab by remember { mutableIntStateOf(0) }
|
||||
var profileId by remember { mutableStateOf(InputProfile.GAME) }
|
||||
var role by remember { mutableStateOf(InputRole.PHYSICAL) }
|
||||
var edit by remember { mutableStateOf<InputBindingEdit?>(null) }
|
||||
var naming by remember { mutableStateOf<InputNameRequest?>(null) }
|
||||
var confirmation by remember { mutableStateOf<InputConfirmation?>(null) }
|
||||
var reuse by remember { mutableStateOf<InputReuseRequest?>(null) }
|
||||
var capture by remember { mutableStateOf(false) }
|
||||
var surface by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(surface) { onSurfaceEditing(surface) }
|
||||
DisposableEffect(Unit) { onDispose { onSurfaceEditing(false) } }
|
||||
var managingLayout by remember { mutableStateOf<SavedInputLayout?>(null) }
|
||||
var exporting by remember { mutableStateOf<String?>(null) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val controller = rememberControllerSettings()
|
||||
var oscMode by remember { mutableStateOf(SessionPrefs.oscMode(context)) }
|
||||
fun update(change: (InputLibrary) -> InputLibrary): Boolean = runCatching { InputLibraries.update(context, change) }
|
||||
.onSuccess { library = it; onChanged() }.onFailure { error = it.message ?: context.getString(R.string.input_save_failed) }.isSuccess
|
||||
fun go(next: InputPage) { page = next; host.open = null }
|
||||
fun back() {
|
||||
when {
|
||||
edit != null -> edit = null
|
||||
page == InputPage.HOME -> onBack()
|
||||
page == InputPage.PROFILE -> go(InputPage.HOME)
|
||||
page == InputPage.SOURCE -> go(InputPage.PROFILE)
|
||||
else -> go(InputPage.SOURCE)
|
||||
}
|
||||
}
|
||||
BackHandler(enabled = !embedded || page != InputPage.HOME || edit != null, onBack = ::back)
|
||||
val profile = library.profile(profileId)
|
||||
val preset = library.preset(profile.binding(role))
|
||||
val layout = library.layout(profileId, role)
|
||||
val importer = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
val target = role
|
||||
scope.launch {
|
||||
val raw = withContext(Dispatchers.IO) { readLayoutFile(context, uri) }
|
||||
val metrics = inputDisplayMetrics(context, target)
|
||||
val imported = raw?.let { InputLayoutFile.import(context, it, layoutFileName(context, uri) ?: context.getString(R.string.ctrl_layout),
|
||||
if (target == InputRole.PRIMARY) InputLayoutScreen.MAIN else InputLayoutScreen.SECONDARY, layout?.style ?: InputSurfaceStyle(),
|
||||
metrics.widthPixels, metrics.heightPixels, metrics.density) }
|
||||
if (imported == null) error = context.getString(R.string.input_layout_import_failed)
|
||||
else if (update { it.withLayout(imported.first).withPreset(imported.second).chooseLayout(profileId, target, imported.first.id) }) go(InputPage.SOURCE)
|
||||
}
|
||||
}
|
||||
val exporter = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri ->
|
||||
val text = exporting
|
||||
exporting = null
|
||||
if (uri != null && text != null) scope.launch {
|
||||
val saved = withContext(Dispatchers.IO) { runCatching { context.contentResolver.openOutputStream(uri, "wt")?.use { it.write(text.toByteArray()) } != null }.getOrDefault(false) }
|
||||
if (!saved) error = context.getString(R.string.input_save_failed)
|
||||
}
|
||||
}
|
||||
fun rename(title: Int, name: String, save: (String) -> Unit) {
|
||||
naming = InputNameRequest(title, name) { value, _, _ -> save(value) }
|
||||
}
|
||||
fun newProfile(copy: Boolean) {
|
||||
naming = InputNameRequest(if (copy) R.string.input_copy_profile else R.string.input_new_profile,
|
||||
if (copy) profile.name else "", library.profiles.map { it.id to it.name }, profileId, offerSharing = true) { name, source, shared ->
|
||||
val id = newInputId()
|
||||
if (update { it.duplicateProfile(source, name, id, sharePhysical = shared) }) { profileId = id; go(InputPage.PROFILE) }
|
||||
}
|
||||
}
|
||||
fun editPart(nextRole: InputRole) { role = nextRole; bindingsTab = 0; go(InputPage.SOURCE) }
|
||||
val pageFocus = remember { androidx.compose.ui.focus.FocusRequester() }
|
||||
val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current
|
||||
var settled by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(page, role, profileId, edit != null, surface) {
|
||||
if (!settled) { settled = true; return@LaunchedEffect }
|
||||
if (surface || inputMode.inputMode != androidx.compose.ui.input.InputMode.Keyboard) return@LaunchedEffect
|
||||
repeat(2) { withFrameNanos { } }
|
||||
runCatching { pageFocus.requestFocus() }
|
||||
}
|
||||
val editing = edit
|
||||
Box(Modifier.focusRequester(pageFocus).focusGroup()) {
|
||||
if (editing != null) {
|
||||
val b = library.preset(editing.preset)
|
||||
var elementProperties by remember(editing) { mutableStateOf(editing.element) }
|
||||
val current = editing.element?.let { if (editing.click) b.clickAction(it) else b.action(it) }
|
||||
?: editing.key?.let { k -> b.keys.firstOrNull { it.key == k }?.action ?: InputAction.Disabled }
|
||||
?: b.controls[editing.control] ?: InputAction.Native()
|
||||
InputActionEditor(editing.control, current, if (editing.click) stringResource(R.string.input_click_name, editing.element!!.name)
|
||||
else editing.element?.name ?: editing.key?.label ?: editing.control.label,
|
||||
properties = { action -> if (!editing.click) elementProperties?.let { e -> InputElementProperties(e, action) { elementProperties = it } } },
|
||||
onBack = { edit = null }, element = editing.element, nativeTargets = editing.key == null, click = editing.click,
|
||||
detail = if (library.usersOfBindings(b.id).size > 1) stringResource(R.string.input_shared_detail, b.name, library.usersOfBindings(b.id).size)
|
||||
else "${profile.name} · ${roleLabel(role)}", onSave = { action ->
|
||||
if (update { l ->
|
||||
val geometry = if (!editing.click && elementProperties != null && layout != null) l.withLayout(layout.copy(elements = layout.elements.map {
|
||||
if (it.id == elementProperties!!.id) elementProperties!!.copy(name = elementProperties!!.name.trim().ifBlank { it.name }) else it
|
||||
})) else l
|
||||
geometry.withPreset(when {
|
||||
editing.element != null && editing.click -> b.copy(clicks = b.clicks + (editing.element.id to action))
|
||||
editing.element != null -> b.copy(elements = b.elements + (editing.element.id to action))
|
||||
editing.key != null -> b.copy(keys = b.keys.filterNot { it.key == editing.key } + PhysicalInputBinding(editing.key, action))
|
||||
else -> b.copy(controls = b.controls + (editing.control to action))
|
||||
}) }) edit = null
|
||||
})
|
||||
} else if (!surface) key(page, profileId, role) {
|
||||
SettingsPage(host, title = when (page) {
|
||||
InputPage.HOME -> if (embedded) null else stringResource(R.string.setup_controller)
|
||||
InputPage.PROFILE -> profile.name
|
||||
InputPage.SOURCE -> roleLabel(role)
|
||||
InputPage.LAYOUTS -> stringResource(R.string.input_saved_layouts)
|
||||
InputPage.REUSE -> stringResource(R.string.input_use_settings_from)
|
||||
}, eyebrow = when (page) {
|
||||
InputPage.SOURCE -> profile.name
|
||||
InputPage.LAYOUTS, InputPage.REUSE -> "${profile.name} · ${roleLabel(role)}"
|
||||
else -> null
|
||||
},
|
||||
onBack = ::back, showBack = !(embedded && page == InputPage.HOME), compactLayout = true) {
|
||||
when (page) {
|
||||
InputPage.HOME -> {
|
||||
SettingsGroup(stringResource(R.string.input_default_profiles)) {
|
||||
ChoiceRow(host, "auto-desktop", stringResource(R.string.input_desktop_mode), null,
|
||||
library.profiles.map { it.id to it.name }, library.desktop) { id -> update { it.copy(desktop = id) } }
|
||||
ChoiceRow(host, "auto-game", stringResource(R.string.input_game_mode), null,
|
||||
library.profiles.map { it.id to it.name }, library.steam) { id -> update { it.copy(steam = id) } }
|
||||
for (desktop in listOf(true, false)) {
|
||||
val selection = library.selection(desktop)
|
||||
if (selection.profile != InputProfiles.AUTO || selection.parts.isNotEmpty()) InputHint(stringResource(R.string.input_quick_override_hint,
|
||||
stringResource(if (desktop) R.string.input_desktop_mode else R.string.input_game_mode)))
|
||||
}
|
||||
}
|
||||
if (InputLibraries.hasPendingScreenKeyMigration(context)) InputHint(stringResource(R.string.input_import_failed))
|
||||
if (InputLibraries.hasLibraryRecovery(context)) InputHint(stringResource(R.string.input_library_recovered))
|
||||
if (library.profiles.any { it.id.startsWith("migrated.screenkeys.") }) Text(stringResource(R.string.input_imported_keys_hint),
|
||||
Modifier.padding(16.dp), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
SettingsGroup(stringResource(R.string.input_profiles)) {
|
||||
for (p in library.profiles) InputNavigationRow(p.name, when {
|
||||
p.id == library.steam && p.id == library.desktop -> stringResource(R.string.input_default_both)
|
||||
p.id == library.steam -> stringResource(R.string.input_default_game)
|
||||
p.id == library.desktop -> stringResource(R.string.input_default_desktop)
|
||||
else -> null
|
||||
}, Modifier.testTag("input-profile-${p.id}")) { profileId = p.id; go(InputPage.PROFILE) }
|
||||
PadTextButton(onClick = { newProfile(false) }, modifier = Modifier.padding(8.dp)) { Text(stringResource(R.string.input_new_profile)) }
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.input_device_settings)) {
|
||||
ToggleRow(host, "controller-rumble", stringResource(R.string.ctrl_rumble), null, controller.rumble) { ControllerPrefs.setRumble(context, it); onChanged() }
|
||||
ChoiceRow(host, "controller-dead-zone", stringResource(R.string.ctrl_dead_zone), null, ControllerPrefs.deadZones.map { it to stringResource(R.string.ctrl_percent, it) }, controller.deadZone) { ControllerPrefs.setDeadZone(context, it); onChanged() }
|
||||
ToggleRow(host, "controller-joycon-vr", stringResource(R.string.ctrl_joycon_vr), stringResource(R.string.ctrl_joycon_vr_hint), controller.joyConVr) { ControllerPrefs.setJoyConVr(context, it); onChanged() }
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.shortcuts_title)) { PhysicalShortcutRows() }
|
||||
SettingsGroup(stringResource(R.string.input_latency)) {
|
||||
var measuring by remember { mutableStateOf(InputTiming.enabled) }
|
||||
var timing by remember { mutableStateOf(InputTiming.report()) }
|
||||
InputHint(stringResource(R.string.input_latency_hint))
|
||||
ToggleRow(host, "input-measure", stringResource(R.string.input_measure), null, measuring) { measuring = it; InputTiming.enabled = it; if (it) InputTiming.reset() }
|
||||
var reading by remember { mutableStateOf(false) }
|
||||
ActionRow(stringResource(R.string.input_measurements), stringResource(R.string.input_measure_instructions),
|
||||
stringResource(R.string.input_read_timing), { timing = InputTiming.report(); reading = true })
|
||||
if (reading) AlertDialog(onDismissRequest = { reading = false }, title = { Text(stringResource(R.string.input_measurements)) },
|
||||
text = { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(timing)
|
||||
Text(stringResource(R.string.input_measure_instructions), style = MaterialTheme.typography.bodySmall)
|
||||
} }, confirmButton = { PadTextButton(onClick = { reading = false }) { Text(stringResource(R.string.common_close)) } })
|
||||
}
|
||||
}
|
||||
InputPage.PROFILE -> {
|
||||
SettingsGroup(stringResource(R.string.input_profile_controls)) {
|
||||
for (r in InputRole.entries) InputNavigationRow(roleLabel(r),
|
||||
if (r == InputRole.PHYSICAL) stringResource(R.string.input_physical_hint) else library.layout(profileId, r)?.name,
|
||||
Modifier.testTag("input-part-${r.name.lowercase()}"), leading = {
|
||||
library.layout(profileId, r)?.let { InputLayoutThumbnail(it, r) }
|
||||
}) { editPart(r) }
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Actions {
|
||||
SecondaryButton(stringResource(R.string.input_rename)) { rename(R.string.input_rename, profile.name) { name -> update { it.withProfile(profile.copy(name = name)) } } }
|
||||
SecondaryButton(stringResource(R.string.input_copy_profile)) { newProfile(true) }
|
||||
SecondaryButton(stringResource(R.string.input_reset_actions)) {
|
||||
confirmation = InputConfirmation(context.getString(R.string.input_reset_actions), context.getString(R.string.input_reset_profile_detail, profile.name)) { update { it.resetProfile(profileId) } }
|
||||
}
|
||||
if (profileId !in listOf(InputProfile.GAME, InputProfile.DESKTOP)) SecondaryButton(stringResource(R.string.input_delete)) {
|
||||
confirmation = InputConfirmation(context.getString(R.string.input_delete), context.getString(R.string.input_delete_profile_detail, profile.name)) {
|
||||
if (update { it.removeProfile(profileId) }) { profileId = InputProfile.GAME; go(InputPage.HOME) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
InputPage.SOURCE -> {
|
||||
if (role == InputRole.PRIMARY) SettingsGroup(stringResource(R.string.input_screen_visibility)) {
|
||||
ChoiceRow(host, "controller-osc", stringResource(R.string.mode_osc), stringResource(R.string.input_visibility_shared),
|
||||
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)),
|
||||
oscMode) { SessionPrefs.setOscMode(context, it); oscMode = it; onChanged() }
|
||||
}
|
||||
if (layout != null) SettingsGroup(stringResource(R.string.ctrl_layout)) {
|
||||
if (layout.editable) InputNavigationRow(stringResource(R.string.input_edit_layout), layout.name, Modifier.testTag("input-surface-${role.name.lowercase()}")) { surface = true }
|
||||
else InputHint(layout.name)
|
||||
InputNavigationRow(stringResource(R.string.input_saved_layouts), stringResource(R.string.input_saved_layouts_hint)) { go(InputPage.LAYOUTS) }
|
||||
if (layout.editable) {
|
||||
InputColorRow(host, layout.style.tint) { color -> update { it.withLayout(layout.copy(style = layout.style.copy(tint = color))) } }
|
||||
SliderRow(stringResource(R.string.ctrl_opacity), null, layout.style.opacity, 1..100, step = 1,
|
||||
format = { "$it%" }) { opacity -> update { it.withLayout(layout.copy(style = layout.style.copy(opacity = opacity))) } }
|
||||
for (button in layout.quickButtons) ToggleRow(host, "quick-${button.name.lowercase()}", stringResource(quickButtonLabel(button)), null,
|
||||
button !in layout.style.hidden) { on -> update { it.withLayout(layout.withQuickButton(button, on)) } }
|
||||
}
|
||||
if (role == InputRole.SECONDARY) InputHint(stringResource(R.string.input_second_screen_enable))
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.input_assign_actions)) {
|
||||
val users = library.usersOfBindings(preset.id)
|
||||
if (users.size > 1) InputHint(stringResource(R.string.input_shared_with, users.filterNot { it.id == profileId }.joinToString { it.name }))
|
||||
val elements = layout?.takeIf { it.editable }?.elements
|
||||
if (elements != null) {
|
||||
if (elements.isEmpty()) InputHint(stringResource(R.string.input_empty_elements))
|
||||
for (e in elements) {
|
||||
InputNavigationRow(e.name, actionLabel(preset.action(e)), Modifier.testTag("input-element-${e.id}")) { edit = InputBindingEdit(preset.id, e.control, e) }
|
||||
if (e.hasClick) InputNavigationRow(stringResource(R.string.input_click_name, e.name), actionLabel(preset.clickAction(e)), Modifier.testTag("input-click-${e.id}")) { edit = InputBindingEdit(preset.id, InputControl.A, e, click = true) }
|
||||
}
|
||||
} else {
|
||||
Box(Modifier.padding(horizontal = 12.dp, vertical = 12.dp)) {
|
||||
SheetChips(listOf(stringResource(R.string.input_buttons), stringResource(R.string.input_axes)) +
|
||||
if (preset.physical) listOf(stringResource(R.string.input_extra_keys)) else emptyList(), bindingsTab) { bindingsTab = it }
|
||||
}
|
||||
val controls = InputControl.padControls.asList()
|
||||
if (bindingsTab != 2) InputControlGrid(preset, controls, bindingsTab == 1) { edit = InputBindingEdit(preset.id, it) }
|
||||
else {
|
||||
for (b in preset.keys) {
|
||||
InputNavigationRow(b.key.label, actionLabel(b.action)) { edit = InputBindingEdit(preset.id, InputControl.A, key = b.key) }
|
||||
PadTextButton(onClick = { update { it.withPreset(preset.copy(keys = preset.keys - b)) } }) { Text(stringResource(R.string.input_remove_binding)) }
|
||||
}
|
||||
InputNavigationRow(stringResource(R.string.input_capture), stringResource(R.string.input_capture_hint)) { capture = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.input_reuse)) {
|
||||
InputNavigationRow(stringResource(R.string.input_use_settings_from), stringResource(R.string.input_reuse_hint)) { go(InputPage.REUSE) }
|
||||
if (library.usersOfBindings(preset.id).size > 1 || layout?.let { it.editable && library.usersOfLayout(it.id).size > 1 } == true)
|
||||
InputNavigationRow(stringResource(R.string.input_make_independent), stringResource(R.string.input_independent_hint), Modifier.testTag("input-make-independent")) {
|
||||
update { it.usePart(profileId, role, profileId, shared = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
InputPage.LAYOUTS -> {
|
||||
InputHint(stringResource(R.string.input_layout_geometry_hint))
|
||||
SettingsGroup(stringResource(R.string.input_saved_layouts)) {
|
||||
var showOther by remember { mutableStateOf(false) }
|
||||
val available = library.layouts.filter { it.supports(role) }
|
||||
val other = available.filter { saved -> !saved.id.startsWith("builtin.") && saved.id != layout?.id &&
|
||||
library.usersOfLayout(saved.id).isEmpty() && library.bindings.none { it.layoutId == saved.id && library.selectedBindings(it.id) } }
|
||||
for (saved in available.filter { showOther || it !in other }) {
|
||||
InputLayoutRow(saved, role, if (saved.id == layout?.id) stringResource(R.string.input_current_layout) else null,
|
||||
onManage = { managingLayout = saved }) {
|
||||
reuse = InputReuseRequest(saved.name, layoutOnly = true) { shared -> if (update { it.useLayout(profileId, role, saved.id, shared) }) go(InputPage.SOURCE) }
|
||||
}
|
||||
}
|
||||
if (other.isNotEmpty()) PadTextButton(onClick = { showOther = !showOther }, modifier = Modifier.padding(8.dp)) {
|
||||
Text(stringResource(if (showOther) R.string.input_hide_other_layouts else R.string.input_other_layouts, other.size))
|
||||
}
|
||||
Row {
|
||||
PadTextButton(onClick = { naming = InputNameRequest(R.string.input_new_layout, "") { name, _, _ ->
|
||||
val created = SavedInputLayout(newInputId(), name, screen = if (role == InputRole.PRIMARY) InputLayoutScreen.MAIN else InputLayoutScreen.SECONDARY)
|
||||
if (update { it.withLayout(created).useLayout(profileId, role, created.id, shared = true) }) { go(InputPage.SOURCE); surface = true }
|
||||
} }, modifier = Modifier.padding(8.dp)) { Text(stringResource(R.string.input_new_layout)) }
|
||||
PadTextButton(onClick = { importer.launch(arrayOf("application/json", "text/plain", "application/octet-stream", "*/*")) },
|
||||
modifier = Modifier.padding(8.dp).testTag("input-layout-import")) { Text(stringResource(R.string.boot_movie_import)) }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
InputPage.REUSE -> {
|
||||
InputHint(stringResource(R.string.input_reuse_explanation, roleLabel(role)))
|
||||
SettingsGroup(stringResource(R.string.input_profiles)) {
|
||||
for (p in library.profiles.filterNot { it.id == profileId }) InputNavigationRow(p.name, library.layout(p.id, role)?.name) {
|
||||
reuse = InputReuseRequest(p.name, layoutOnly = false) { shared -> if (update { it.usePart(profileId, role, p.id, shared) }) go(InputPage.SOURCE) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
managingLayout?.let { saved -> AlertDialog(onDismissRequest = { managingLayout = null }, title = { Text(saved.name) },
|
||||
text = { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(stringResource(R.string.input_layout_count, saved.elements.size))
|
||||
SecondaryButton(stringResource(R.string.input_rename)) {
|
||||
managingLayout = null
|
||||
rename(R.string.input_rename_layout, saved.name) { name -> update { it.withLayout(saved.copy(name = name)) } }
|
||||
}
|
||||
val exported = remember(saved, library) {
|
||||
val metrics = inputDisplayMetrics(context, if (saved.screen == InputLayoutScreen.SECONDARY) InputRole.SECONDARY else InputRole.PRIMARY)
|
||||
InputLayoutFile.export(saved, library.bindings.firstOrNull { it.id == preset.id && it.layoutId == saved.id }
|
||||
?: library.bindings.firstOrNull { it.layoutId == saved.id }, metrics.widthPixels, metrics.heightPixels)
|
||||
}
|
||||
if (exported != null) SecondaryButton(stringResource(R.string.screen_key_layout_export)) {
|
||||
managingLayout = null
|
||||
exporting = exported
|
||||
exporter.launch(InputLayoutFile.fileName(saved))
|
||||
}
|
||||
val used = library.usersOfLayout(saved.id).isNotEmpty() || library.bindings.any { it.layoutId == saved.id && library.selectedBindings(it.id) }
|
||||
if (!used && saved.editable) SecondaryButton(stringResource(R.string.input_delete)) {
|
||||
managingLayout = null
|
||||
confirmation = InputConfirmation(context.getString(R.string.input_delete), context.getString(R.string.input_delete_layout_detail, saved.name)) { update { it.removeLayout(saved.id) } }
|
||||
}
|
||||
} }, confirmButton = { PadTextButton(onClick = { managingLayout = null }) { Text(stringResource(R.string.common_close)) } }) }
|
||||
if (capture) InputCaptureDialog(onDismiss = { capture = false }) { capture = false; edit = InputBindingEdit(preset.id, InputControl.A, key = it) }
|
||||
if (surface) InputSurfaceDialog(profileId, onDismiss = { surface = false; library = InputLibraries.read(context) }, onChanged = {
|
||||
library = InputLibraries.read(context); onChanged()
|
||||
}, role = role, layoutId = layout?.id, bindingsId = preset.id)
|
||||
naming?.let { request -> key(request) { InputNameDialog(request) { naming = null } } }
|
||||
reuse?.let { request -> key(request) { InputReuseDialog(request) { reuse = null } } }
|
||||
confirmation?.let { request -> AlertDialog(onDismissRequest = { confirmation = null }, title = { Text(request.title) },
|
||||
text = { Text(request.detail) }, confirmButton = { PadTextButton(onClick = { confirmation = null; request.action() }) { Text(stringResource(R.string.common_ok)) } },
|
||||
dismissButton = { PadTextButton(onClick = { confirmation = null }) { Text(stringResource(R.string.common_cancel)) } }) }
|
||||
error?.let { message -> AlertDialog(onDismissRequest = { error = null }, title = { Text(stringResource(R.string.input_save_failed)) },
|
||||
text = { Text(message) }, confirmButton = { PadTextButton(onClick = { error = null }) { Text(stringResource(R.string.common_ok)) } }) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InputNameDialog(request: InputNameRequest, onDismiss: () -> Unit) {
|
||||
var name by remember { mutableStateOf(request.initial) }
|
||||
var source by remember { mutableStateOf(request.source) }
|
||||
var shared by remember { mutableStateOf(false) }
|
||||
val host = rememberMenuHost()
|
||||
AlertDialog(onDismissRequest = onDismiss, title = { Text(stringResource(request.title)) }, text = { Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
OutlinedTextField(name, { name = it.take(80) }, label = { Text(stringResource(R.string.input_name)) }, singleLine = true, modifier = Modifier.testTag("input-name"))
|
||||
if (request.sources.isNotEmpty()) ChoiceRow(host, "input-copy-source", stringResource(R.string.input_start_from), null, request.sources, source) { source = it }
|
||||
if (request.offerSharing) {
|
||||
Row(Modifier.fillMaxWidth().focusRow(role = Role.Checkbox, checked = shared) { shared = !shared }.testTag("input-share-physical"), verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(shared, null)
|
||||
Text(stringResource(R.string.input_share_physical))
|
||||
}
|
||||
Text(stringResource(R.string.input_new_independent_hint), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
} }, confirmButton = { PadTextButton(enabled = name.isNotBlank(), onClick = { request.save(name.trim(), source, shared); onDismiss() }) { Text(stringResource(R.string.gesture_save_changes)) } },
|
||||
dismissButton = { PadTextButton(onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) } })
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InputReuseDialog(request: InputReuseRequest, onDismiss: () -> Unit) {
|
||||
var shared by remember { mutableStateOf(false) }
|
||||
AlertDialog(onDismissRequest = onDismiss, title = { Text(request.name) }, text = { Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
Text(stringResource(if (request.layoutOnly) R.string.input_reuse_layout_detail else R.string.input_reuse_part_detail))
|
||||
for (link in listOf(false, true)) Row(Modifier.fillMaxWidth().focusRow(role = Role.RadioButton, checked = shared == link) { shared = link }, verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(shared == link, null)
|
||||
Text(stringResource(if (link) R.string.input_keep_shared else R.string.input_independent_copy))
|
||||
}
|
||||
} }, confirmButton = { PadTextButton(onClick = { request.apply(shared); onDismiss() }) { Text(stringResource(R.string.common_ok)) } },
|
||||
dismissButton = { PadTextButton(onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) } })
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InputControlGrid(preset: SavedInputBindings, available: List<InputControl>, axes: Boolean, onPick: (InputControl) -> Unit) {
|
||||
val controls = available.filter { (it.dimensions == 2 || it == InputControl.LT || it == InputControl.RT) == axes }
|
||||
BoxWithConstraints(Modifier.fillMaxWidth().padding(12.dp)) {
|
||||
val columns = if (maxWidth < 480.dp) 2 else 4
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
for (row in controls.chunked(columns)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
for (control in row) OutlinedCard(onClick = { onPick(control) }, modifier = Modifier.weight(1f).controllerConfirm { onPick(control) }.testTag("input-binding-${control.id}")) {
|
||||
Column(Modifier.padding(12.dp).heightIn(min = 48.dp)) {
|
||||
Text(control.label, style = MaterialTheme.typography.titleMedium)
|
||||
Text(actionLabel(preset.controls[control] ?: InputAction.Native()), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InputNavigationRow(title: String, detail: String?, modifier: Modifier = Modifier, leading: (@Composable () -> Unit)? = null, onClick: () -> Unit) {
|
||||
Row(modifier.fillMaxWidth().focusRow(RoundedCornerShape(0.dp), onClick = onClick).heightIn(min = 64.dp).padding(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
leading?.invoke()
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||
if (detail != null) Text(detail, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Text("›", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun quickButtonLabel(button: InputCommand) = when (button) {
|
||||
InputCommand.STEAM -> R.string.ctrl_steam_button
|
||||
InputCommand.QUICK_ACCESS -> R.string.ctrl_qam_button
|
||||
else -> R.string.ctrl_keyboard_button
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
internal fun inputDisplayMetrics(context: Context, role: InputRole): DisplayMetrics = DisplayMetrics().also { metrics ->
|
||||
(context.findActivity() as? com.droiddeck.launcher.SessionActivity)?.inputEditorDisplay(role)?.getRealMetrics(metrics)
|
||||
?: metrics.setTo(context.resources.displayMetrics)
|
||||
}
|
||||
|
||||
private fun readLayoutFile(context: Context, uri: Uri): String? = runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
val out = java.io.ByteArrayOutputStream()
|
||||
val buffer = ByteArray(8192)
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
if (out.size() > 1_000_000) return@use null
|
||||
}
|
||||
out.toString(Charsets.UTF_8.name())
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
private fun layoutFileName(context: Context, uri: Uri): String? = runCatching {
|
||||
context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) cursor.getString(0)?.substringBeforeLast('.')?.trim()?.takeIf { it.isNotEmpty() } else null
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
@Composable
|
||||
internal fun roleLabel(role: InputRole) = stringResource(when (role) {
|
||||
InputRole.PHYSICAL -> R.string.input_physical; InputRole.PRIMARY -> R.string.input_primary; InputRole.SECONDARY -> R.string.input_secondary
|
||||
})
|
||||
|
||||
@Composable
|
||||
private fun InputHint(text: String) {
|
||||
Text(text, Modifier.padding(14.dp), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
@@ -1,383 +0,0 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.app.Presentation
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.Display
|
||||
import android.view.View
|
||||
import android.view.Window
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.setViewTreeOnBackPressedDispatcherOwner
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import kotlin.math.roundToInt
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.DialogWindowProvider
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.setViewTreeLifecycleOwner
|
||||
import androidx.lifecycle.setViewTreeViewModelStoreOwner
|
||||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.SessionActivity
|
||||
import com.droiddeck.launcher.input.*
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
|
||||
private data class SurfaceSnapshot(val elements: List<InputSurfaceElement>, val actions: Map<String, InputAction>,
|
||||
val clicks: Map<String, InputAction>, val scale: Float)
|
||||
|
||||
private class GamepadScaler(private val context: android.content.Context, private val width: Int, private val height: Int) {
|
||||
private fun generator(scale: Float) = LegacyGamepadLayout(context, width, height, scale = scale)
|
||||
val ids = generator(1f).elements().map { it.id }.toSet()
|
||||
fun rescale(stored: List<InputSurfaceElement>, from: Float, to: Float) = generator(to).rescale(stored, generator(from))
|
||||
fun reset(stored: List<InputSurfaceElement>) = generator(1f).regenerate(stored).associateBy { it.id }.let { placed -> stored.map { placed[it.id] ?: it } }
|
||||
}
|
||||
|
||||
private class SurfaceDraft(val layout: SavedInputLayout, val preset: SavedInputBindings, val defaults: List<InputSurfaceElement>? = null,
|
||||
private val gamepad: GamepadScaler? = null, snap: Boolean = true) {
|
||||
var elements by mutableStateOf(layout.elements)
|
||||
var actions by mutableStateOf(preset.elements)
|
||||
var clicks by mutableStateOf(preset.clicks)
|
||||
var selected by mutableStateOf<String?>(null)
|
||||
var toolsExpanded by mutableStateOf(false)
|
||||
var toolsX by mutableFloatStateOf(.02f)
|
||||
var toolsY by mutableFloatStateOf(.02f)
|
||||
var snap by mutableStateOf(snap)
|
||||
var adding by mutableStateOf(false)
|
||||
var properties by mutableStateOf(false)
|
||||
var clickProperties by mutableStateOf(false)
|
||||
var history by mutableStateOf<List<SurfaceSnapshot>>(emptyList())
|
||||
var layoutScale by mutableFloatStateOf(layout.scale)
|
||||
private var scaleBase by mutableStateOf<Triple<List<InputSurfaceElement>, List<InputSurfaceElement>, Float>?>(null)
|
||||
private var scaleValue by mutableIntStateOf(100)
|
||||
private val rescales get() = gamepad != null && elements.any { it.id in gamepad.ids }
|
||||
val scale get() = when {
|
||||
scaleBase?.second == elements -> scaleValue
|
||||
rescales -> (layoutScale * 100).roundToInt()
|
||||
else -> 100
|
||||
}
|
||||
val positioned get() = (if (rescales) gamepad!!.reset(elements) else defaults?.let { elements.withPositionsOf(it) })
|
||||
?.takeIf { it != elements || rescales && layoutScale != 1f }
|
||||
val element get() = elements.firstOrNull { it.id == selected }
|
||||
val dirty get() = elements != layout.elements || actions != preset.elements || clicks != preset.clicks || layoutScale != layout.scale
|
||||
fun checkpoint() { history = (history + SurfaceSnapshot(elements, actions, clicks, layoutScale)).takeLast(40) }
|
||||
fun undo() { history.lastOrNull()?.let { elements = it.elements; actions = it.actions; clicks = it.clicks; layoutScale = it.scale; history = history.dropLast(1) } }
|
||||
fun scaleAll(percent: Int) {
|
||||
val base = scaleBase?.takeIf { it.second == elements } ?: Triple(elements, elements, if (rescales) layoutScale else 0f).also { checkpoint() }
|
||||
val factor = percent / 100f
|
||||
elements = if (base.third > 0f && gamepad != null) {
|
||||
layoutScale = factor
|
||||
val others = base.first.filterNot { it.id in gamepad.ids }.scaled(factor / base.third).associateBy { it.id }
|
||||
gamepad.rescale(base.first, base.third, factor).map { others[it.id] ?: it }
|
||||
} else base.first.scaled(factor)
|
||||
scaleValue = percent; scaleBase = Triple(base.first, elements, base.third)
|
||||
}
|
||||
fun resetPositions() { positioned?.let { checkpoint(); elements = it; if (rescales) layoutScale = 1f } }
|
||||
fun action(e: InputSurfaceElement) = preset.copy(elements = actions).action(e)
|
||||
fun clickAction(e: InputSurfaceElement) = preset.copy(elements = actions, clicks = clicks).clickAction(e)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun displayAspect(display: Display): Float? = android.util.DisplayMetrics().also(display::getRealMetrics)
|
||||
.takeIf { it.widthPixels > 0 && it.heightPixels > 0 }?.let { (it.widthPixels.toFloat() / it.heightPixels).coerceIn(.05f, 20f) }
|
||||
|
||||
@Composable
|
||||
fun InputSurfaceDialog(profile: String, onDismiss: () -> Unit, onChanged: () -> Unit,
|
||||
role: InputRole = InputRole.SECONDARY, layoutId: String? = null, bindingsId: String? = null) {
|
||||
val context = LocalContext.current
|
||||
val host = LocalInputActivity.current ?: context.findActivity() as? ComponentActivity
|
||||
val library = remember { InputLibraries.read(context) }
|
||||
val savedLayout = remember { library.layouts.first { it.id == (layoutId ?: library.profile(profile).parts.getValue(role).layout) } }
|
||||
val savedBindings = remember { library.preset(bindingsId ?: library.profile(profile).binding(role)) }
|
||||
val layoutUsers = library.usersOfLayout(savedLayout.id).size
|
||||
val bindingUsers = library.usersOfBindings(savedBindings.id).size
|
||||
val draft = remember {
|
||||
val (display, metrics) = InputLibraries.primaryDisplay(context)
|
||||
val gamepad = savedLayout.elements.takeIf { e -> e.any { it.id.startsWith("legacy:") } }?.let {
|
||||
val (w, h) = OnScreenLayout.generatedSize(context, savedLayout, metrics.widthPixels, metrics.heightPixels) ?: (metrics.widthPixels to metrics.heightPixels)
|
||||
GamepadScaler(display, w, h)
|
||||
}
|
||||
SurfaceDraft(savedLayout, savedBindings, InputLibraries.defaultPositions(context, library, savedLayout), gamepad, ControllerPrefs.snap(context))
|
||||
}
|
||||
val manager = remember { context.getSystemService(DisplayManager::class.java) }
|
||||
var revision by remember { mutableIntStateOf(0) }
|
||||
var discard by remember { mutableStateOf(false) }
|
||||
var failure by remember { mutableStateOf<String?>(null) }
|
||||
val target = remember(revision, role) {
|
||||
if (host is SessionActivity) host.inputEditorDisplay(role)
|
||||
else if (role == InputRole.PRIMARY) host?.window?.decorView?.display
|
||||
else {
|
||||
val displays = SecondScreenDisplays.available(manager, host?.window?.decorView?.display?.displayId ?: Display.DEFAULT_DISPLAY)
|
||||
manager.getDisplay(displays.firstOrNull { it.id == SessionState.secondScreenDisplay }?.id ?: displays.firstOrNull()?.id ?: -1)
|
||||
}
|
||||
}
|
||||
val remote = target != null && target.displayId != host?.window?.decorView?.display?.displayId
|
||||
fun leave() { if (draft.dirty) discard = true else onDismiss() }
|
||||
fun save() {
|
||||
runCatching {
|
||||
InputLibraries.update(context) { latest ->
|
||||
val l = latest.layouts.first { it.id == savedLayout.id }
|
||||
val p = latest.preset(savedBindings.id)
|
||||
latest.withLayout(l.copy(elements = draft.elements, aspect = target?.let(::displayAspect) ?: l.aspect, scale = draft.layoutScale)).withPreset(p.copy(elements = draft.actions.filterKeys { id -> draft.elements.any { it.id == id } },
|
||||
clicks = draft.clicks.filterKeys { id -> draft.elements.any { it.id == id && it.hasClick } }))
|
||||
}
|
||||
}.onSuccess { onChanged(); onDismiss() }.onFailure { failure = it.message ?: context.getString(R.string.input_save_failed) }
|
||||
}
|
||||
DisposableEffect(manager) {
|
||||
val listener = object : DisplayManager.DisplayListener {
|
||||
override fun onDisplayAdded(id: Int) { revision++ }
|
||||
override fun onDisplayRemoved(id: Int) { revision++ }
|
||||
override fun onDisplayChanged(id: Int) = Unit
|
||||
}
|
||||
manager.registerDisplayListener(listener, Handler(Looper.getMainLooper()))
|
||||
onDispose { manager.unregisterDisplayListener(listener) }
|
||||
}
|
||||
if (remote && host != null) DisposableEffect(target!!.displayId, host) {
|
||||
val presentation = SurfaceEditorPresentation(host, target, transparent = host is SessionActivity && role == InputRole.PRIMARY) {
|
||||
SurfaceCanvas(draft, transparent = host is SessionActivity && role == InputRole.PRIMARY)
|
||||
}
|
||||
(host as? SessionActivity)?.inputWindowShown(target.displayId)
|
||||
runCatching { presentation.show() }.onFailure { failure = it.message }
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_STOP && !host.screenIsOff()) presentation.hide()
|
||||
if (event == Lifecycle.Event.ON_START) runCatching { presentation.show() }
|
||||
}
|
||||
host.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
host.lifecycle.removeObserver(observer)
|
||||
presentation.dismiss()
|
||||
(host as? SessionActivity)?.inputWindowClosed(target.displayId)
|
||||
}
|
||||
}
|
||||
// Compose 1.6 custom dialog sizing subtracts system bars even when they are hidden.
|
||||
Dialog(onDismissRequest = ::leave, properties = DialogProperties(usePlatformDefaultWidth = true, decorFitsSystemWindows = false)) {
|
||||
val editorWindow = (LocalView.current.parent as? DialogWindowProvider)?.window
|
||||
DisposableEffect(editorWindow) {
|
||||
editorWindow?.prepareInputEditor()
|
||||
if (host is SessionActivity && role == InputRole.PRIMARY) editorWindow?.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
|
||||
onDispose { }
|
||||
}
|
||||
Surface(Modifier.fillMaxSize(), color = if (host is SessionActivity && role == InputRole.PRIMARY && !draft.properties && !remote)
|
||||
androidx.compose.ui.graphics.Color.Transparent else MaterialTheme.colorScheme.surface) {
|
||||
val element = draft.element
|
||||
BackHandler { if (draft.properties) draft.properties = false else leave() }
|
||||
when {
|
||||
draft.properties && element != null -> {
|
||||
var properties by remember(element.id) { mutableStateOf(element) }
|
||||
InputActionEditor(if (draft.clickProperties) InputControl.A else element.control,
|
||||
if (draft.clickProperties) draft.clickAction(element) else draft.action(element),
|
||||
if (draft.clickProperties) stringResource(R.string.input_click_name, element.name) else element.name,
|
||||
onBack = { draft.properties = false }, element = element, click = draft.clickProperties,
|
||||
properties = { action ->
|
||||
if (!draft.clickProperties) InputElementProperties(properties, action) { properties = it }
|
||||
if (layoutUsers > 1) Text(stringResource(R.string.input_editor_shared, layoutUsers),
|
||||
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 12.dp))
|
||||
}, detail = if (bindingUsers > 1) stringResource(R.string.input_shared_detail, savedBindings.name, bindingUsers)
|
||||
else "${library.profile(profile).name} · ${roleLabel(role)}", onSave = { action ->
|
||||
draft.checkpoint()
|
||||
if (draft.clickProperties) draft.clicks = draft.clicks + (element.id to action) else draft.actions = draft.actions + (element.id to action)
|
||||
draft.elements = draft.elements.map { if (it.id == element.id) properties.copy(name = properties.name.trim().ifBlank { element.name }) else it }
|
||||
draft.properties = false
|
||||
})
|
||||
}
|
||||
target == null -> SettingsPage(rememberMenuHost(), savedLayout.name, ::leave) {
|
||||
Text(stringResource(R.string.input_display_unavailable))
|
||||
PrimaryButton(stringResource(R.string.input_editor_done)) { save() }
|
||||
}
|
||||
remote -> SettingsPage(rememberMenuHost(), stringResource(R.string.input_editor_remote), ::leave,
|
||||
lede = if (layoutUsers > 1) stringResource(R.string.input_editor_shared, layoutUsers) else savedLayout.name) {
|
||||
SurfaceToolbar(draft, ::save, ::leave)
|
||||
SurfaceSelection(draft)
|
||||
}
|
||||
else -> Box(Modifier.fillMaxSize()) {
|
||||
SurfaceCanvas(draft, transparent = host is SessionActivity && role == InputRole.PRIMARY)
|
||||
FloatingSurfaceTools(draft, ::save, ::leave)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (draft.adding) AlertDialog(onDismissRequest = { draft.adding = false }, title = { Text(stringResource(R.string.input_add_element)) }, confirmButton = {}, text = { Column {
|
||||
for (kind in InputElementKind.entries) PadTextButton(onClick = { draft.checkpoint()
|
||||
val w = if (kind.control.dimensions == 2) .4f else .2f
|
||||
val h = .3f
|
||||
val name = context.getString(kind.label()) + " " + (draft.elements.count { it.kind == kind } + 1)
|
||||
val element = InputSurfaceElement(kind.control, (1f - w) / 2, (1f - h) / 2, w, h, newInputId(), kind, name)
|
||||
draft.elements = draft.elements + element; draft.selected = element.id
|
||||
draft.adding = false; draft.toolsExpanded = false }) { Text(stringResource(kind.label())) }
|
||||
} })
|
||||
if (discard) AlertDialog(onDismissRequest = { discard = false }, title = { Text(stringResource(R.string.input_editor_discard)) },
|
||||
confirmButton = { PadTextButton(onClick = onDismiss) { Text(stringResource(R.string.common_ok)) } },
|
||||
dismissButton = { PadTextButton(onClick = { discard = false }) { Text(stringResource(R.string.common_cancel)) } })
|
||||
failure?.let { AlertDialog(onDismissRequest = { failure = null }, title = { Text(stringResource(R.string.input_save_failed)) }, text = { Text(it) },
|
||||
confirmButton = { PadTextButton(onClick = { failure = null }) { Text(stringResource(R.string.common_ok)) } }) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SurfaceCanvas(draft: SurfaceDraft, transparent: Boolean = false) {
|
||||
AndroidView(factory = { InputSurfaceEditorView(it) }, modifier = Modifier.fillMaxSize().testTag("input-layout-canvas"),
|
||||
update = { view ->
|
||||
view.setBackgroundColor(if (transparent) android.graphics.Color.TRANSPARENT else android.graphics.Color.rgb(10, 11, 13))
|
||||
view.style = draft.layout.style.copy(hidden = emptySet()); view.elements = draft.elements; view.selected = draft.selected; view.snap = draft.snap
|
||||
view.isEnabled = !draft.properties && !draft.adding
|
||||
view.onSelect = { if (!draft.properties) draft.selected = it }
|
||||
view.onBeginEdit = draft::checkpoint
|
||||
view.onChange = { e -> if (!draft.properties) draft.elements = draft.elements.map { if (it.id == e.id) e else it } }
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FloatingSurfaceTools(draft: SurfaceDraft, onSave: () -> Unit, onLeave: () -> Unit) {
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
val density = LocalDensity.current
|
||||
val screenWidth = with(density) { maxWidth.toPx() }
|
||||
val screenHeight = with(density) { maxHeight.toPx() }
|
||||
val margin = with(density) { 8.dp.toPx() }
|
||||
var panelSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
val x = (draft.toolsX * screenWidth).coerceIn(margin, (screenWidth - panelSize.width - margin).coerceAtLeast(margin))
|
||||
val y = (draft.toolsY * screenHeight).coerceIn(margin, (screenHeight - panelSize.height - margin).coerceAtLeast(margin))
|
||||
val position by rememberUpdatedState(Offset(x, y))
|
||||
val expandedWidth = minOf(420.dp, maxWidth - 16.dp)
|
||||
val panelWidth by animateDpAsState(if (draft.toolsExpanded) expandedWidth else 152.dp, label = "input-tools-width")
|
||||
Surface(Modifier.offset { IntOffset(x.roundToInt(), y.roundToInt()) }
|
||||
.width(panelWidth).clipToBounds()
|
||||
.heightIn(max = maxHeight - 16.dp).onSizeChanged { panelSize = it },
|
||||
shape = RoundedCornerShape(18.dp), color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 6.dp, shadowElevation = 10.dp) {
|
||||
Column {
|
||||
Row(Modifier.fillMaxWidth().heightIn(min = 52.dp).testTag("input-editor-tools")
|
||||
.pointerInput(screenWidth, screenHeight, panelSize) {
|
||||
var dragPosition = Offset.Zero
|
||||
detectDragGestures(onDragStart = { dragPosition = position }) { change, delta ->
|
||||
change.consume()
|
||||
dragPosition = Offset((dragPosition.x + delta.x).coerceIn(margin, (screenWidth - panelSize.width - margin).coerceAtLeast(margin)),
|
||||
(dragPosition.y + delta.y).coerceIn(margin, (screenHeight - panelSize.height - margin).coerceAtLeast(margin)))
|
||||
draft.toolsX = dragPosition.x / screenWidth
|
||||
draft.toolsY = dragPosition.y / screenHeight
|
||||
}
|
||||
}
|
||||
.clickable { draft.toolsExpanded = !draft.toolsExpanded }.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("⠿", color = MaterialTheme.colorScheme.primary)
|
||||
Text(stringResource(if (draft.toolsExpanded) R.string.input_hide_tools else R.string.input_tools), style = MaterialTheme.typography.titleSmall, maxLines = 1)
|
||||
}
|
||||
AnimatedVisibility(draft.toolsExpanded, enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
|
||||
exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut()) {
|
||||
Column(Modifier.requiredWidth(expandedWidth).verticalScroll(rememberScrollState())) {
|
||||
HorizontalDivider()
|
||||
SurfaceToolbar(draft, onSave, onLeave)
|
||||
HorizontalDivider()
|
||||
SurfaceSelection(draft)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun SurfaceToolbar(draft: SurfaceDraft, onSave: () -> Unit, onLeave: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
FlowRow(Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
SecondaryButton(stringResource(R.string.common_cancel), compact = true, onClick = onLeave)
|
||||
SecondaryButton(stringResource(R.string.input_editor_add), enabled = draft.elements.size < 64, compact = true) { draft.adding = true }
|
||||
SecondaryButton(stringResource(R.string.input_editor_undo), enabled = draft.history.isNotEmpty(), compact = true) { draft.undo() }
|
||||
if (draft.defaults != null) SecondaryButton(stringResource(R.string.input_reset_positions), enabled = draft.positioned != null, compact = true) { draft.resetPositions() }
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(draft.snap, { draft.snap = it; ControllerPrefs.setSnap(context, it) })
|
||||
Text(stringResource(R.string.ctrl_editor_snap))
|
||||
}
|
||||
PrimaryButton(stringResource(R.string.input_editor_done), compact = true, onClick = onSave)
|
||||
}
|
||||
if (draft.elements.isNotEmpty()) SliderRow(stringResource(R.string.ctrl_size), null, draft.scale, 80..125, step = 5,
|
||||
format = { "$it%" }, modifier = Modifier.testTag("input-editor-scale")) { draft.scaleAll(it) }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun SurfaceSelection(draft: SurfaceDraft) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
draft.element?.let { e ->
|
||||
Text(e.name, style = MaterialTheme.typography.titleMedium)
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
PrimaryButton(stringResource(R.string.input_properties), compact = true) { draft.clickProperties = false; draft.properties = true }
|
||||
if (e.hasClick) SecondaryButton(stringResource(R.string.input_click_properties), compact = true) { draft.clickProperties = true; draft.properties = true }
|
||||
SecondaryButton(stringResource(R.string.input_remove_element), compact = true) {
|
||||
draft.checkpoint(); draft.elements = draft.elements.filterNot { it.id == e.id }; draft.actions = draft.actions - e.id; draft.clicks = draft.clicks - e.id; draft.selected = null
|
||||
}
|
||||
}
|
||||
} ?: Text(stringResource(R.string.input_editor_hint))
|
||||
}
|
||||
}
|
||||
|
||||
private fun InputElementKind.label() = when (this) {
|
||||
InputElementKind.BUTTON -> R.string.input_element_button
|
||||
InputElementKind.STICK -> R.string.input_element_stick
|
||||
InputElementKind.TRACKPAD -> R.string.input_element_trackpad
|
||||
InputElementKind.DPAD -> R.string.input_element_dpad
|
||||
InputElementKind.TRIGGER -> R.string.input_element_trigger
|
||||
}
|
||||
|
||||
private class SurfaceEditorPresentation(private val host: ComponentActivity, display: Display, private val transparent: Boolean, private val content: @Composable () -> Unit) : Presentation(host, display) {
|
||||
private var view: ComposeView? = null
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setCancelable(false)
|
||||
window?.addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE)
|
||||
window?.prepareInputEditor()
|
||||
window?.decorView?.let {
|
||||
it.setViewTreeLifecycleOwner(host); it.setViewTreeViewModelStoreOwner(host)
|
||||
it.setViewTreeSavedStateRegistryOwner(host); it.setViewTreeOnBackPressedDispatcherOwner(host)
|
||||
}
|
||||
window?.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(android.graphics.Color.TRANSPARENT))
|
||||
view = ComposeView(context).also { it.setContent { DroidDeckTheme {
|
||||
Surface(Modifier.fillMaxSize(), color = if (transparent) androidx.compose.ui.graphics.Color.Transparent else MaterialTheme.colorScheme.surface) { content() }
|
||||
} }; setContentView(it) }
|
||||
}
|
||||
override fun dismiss() { view?.disposeComposition(); view = null; super.dismiss() }
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
internal fun Window.prepareInputEditor() {
|
||||
addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or WindowManager.LayoutParams.FLAG_FULLSCREEN)
|
||||
setLayout(WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.MATCH_PARENT)
|
||||
decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.MotionEvent
|
||||
import android.view.ViewConfiguration
|
||||
import android.widget.FrameLayout
|
||||
import com.droiddeck.launcher.input.InputSurfaceElement
|
||||
import com.droiddeck.launcher.input.InputSurfaceStyle
|
||||
import com.droiddeck.launcher.input.snapSurfaceElement
|
||||
import kotlin.math.abs
|
||||
|
||||
internal class InputSurfaceEditorView(context: Context) : FrameLayout(context) {
|
||||
var elements: List<InputSurfaceElement> = emptyList()
|
||||
set(value) { field = value; preview.updateCustomLayout(value); invalidate() }
|
||||
var style = InputSurfaceStyle()
|
||||
set(value) { field = value; preview.updateStyle(value) }
|
||||
var snap = true
|
||||
set(value) { field = value; if (!value) clearGuides() }
|
||||
private var guideX: Float? = null
|
||||
private var guideY: Float? = null
|
||||
var selected: String? = null
|
||||
set(value) { field = value; invalidate() }
|
||||
var onSelect: (String?) -> Unit = {}
|
||||
var onBeginEdit: () -> Unit = {}
|
||||
var onChange: (InputSurfaceElement) -> Unit = {}
|
||||
private val preview = InputSurfaceView(context)
|
||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.rgb(26, 159, 255); strokeWidth = 2 * resources.displayMetrics.density }
|
||||
private val radius = 14 * resources.displayMetrics.density
|
||||
private var original: InputSurfaceElement? = null
|
||||
private var downX = 0f
|
||||
private var downY = 0f
|
||||
private var corner = 0
|
||||
private var dragging = false
|
||||
|
||||
init {
|
||||
setBackgroundColor(Color.rgb(10, 11, 13))
|
||||
addView(preview, LayoutParams(-1, -1))
|
||||
}
|
||||
|
||||
override fun dispatchDraw(canvas: Canvas) {
|
||||
super.dispatchDraw(canvas)
|
||||
paint.style = Paint.Style.STROKE
|
||||
guideX?.let { canvas.drawLine(width * it, 0f, width * it, height.toFloat(), paint) }
|
||||
guideY?.let { canvas.drawLine(0f, height * it, width.toFloat(), height * it, paint) }
|
||||
val e = elements.firstOrNull { it.id == selected } ?: return
|
||||
val l = width * e.x; val t = height * e.y; val r = l + width * e.width; val b = t + height * e.height
|
||||
paint.style = Paint.Style.STROKE; canvas.drawRect(l, t, r, b, paint)
|
||||
paint.style = Paint.Style.FILL
|
||||
for (x in listOf(l, r)) for (y in listOf(t, b)) canvas.drawCircle(x, y, radius / 3, paint)
|
||||
}
|
||||
|
||||
private fun clearGuides() { guideX = null; guideY = null; invalidate() }
|
||||
|
||||
override fun onInterceptTouchEvent(event: MotionEvent) = true
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
if (!isEnabled || width == 0 || height == 0) return true
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
clearGuides()
|
||||
downX = event.x; downY = event.y; dragging = false; corner = 0
|
||||
val current = elements.firstOrNull { it.id == selected }
|
||||
if (current != null) {
|
||||
val left = width * current.x; val top = height * current.y
|
||||
val right = left + width * current.width; val bottom = top + height * current.height
|
||||
val horizontal = if (abs(downX - left) < radius) 1 else if (abs(downX - right) < radius) 2 else 0
|
||||
val vertical = if (abs(downY - top) < radius) 4 else if (abs(downY - bottom) < radius) 8 else 0
|
||||
if (horizontal != 0 && vertical != 0) corner = horizontal or vertical
|
||||
}
|
||||
original = if (corner != 0) current else elements.asReversed().firstOrNull {
|
||||
downX / width in it.x..(it.x + it.width) && downY / height in it.y..(it.y + it.height)
|
||||
}
|
||||
selected = original?.id; onSelect(selected)
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
val e = original ?: return true
|
||||
if (!dragging && abs(event.x - downX) + abs(event.y - downY) > ViewConfiguration.get(context).scaledTouchSlop) {
|
||||
dragging = true; onBeginEdit()
|
||||
}
|
||||
if (!dragging) return true
|
||||
val dx = (event.x - downX) / width; val dy = (event.y - downY) / height
|
||||
val next = if (corner == 0) e.copy(x = (e.x + dx).coerceIn(0f, 1f - e.width), y = (e.y + dy).coerceIn(0f, 1f - e.height))
|
||||
else {
|
||||
val left = if (corner and 1 != 0) (e.x + dx).coerceIn(0f, e.x + e.width - .03f) else e.x
|
||||
val top = if (corner and 4 != 0) (e.y + dy).coerceIn(0f, e.y + e.height - .03f) else e.y
|
||||
val right = if (corner and 2 != 0) (e.x + e.width + dx).coerceIn(left + .03f, 1f) else e.x + e.width
|
||||
val bottom = if (corner and 8 != 0) (e.y + e.height + dy).coerceIn(top + .03f, 1f) else e.y + e.height
|
||||
e.copy(x = left, y = top, width = right - left, height = bottom - top).bounded()
|
||||
}
|
||||
val aligned = if (snap) snapSurfaceElement(next, elements.filter { it.id != e.id }, 9 * resources.displayMetrics.density / width,
|
||||
9 * resources.displayMetrics.density / height, corner) else null
|
||||
if (aligned != null && (aligned.x != null && aligned.x != guideX || aligned.y != null && aligned.y != guideY)) performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)
|
||||
guideX = aligned?.x; guideY = aligned?.y
|
||||
onChange(aligned?.element ?: next)
|
||||
invalidate()
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { original = null; dragging = false; clearGuides() }
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -1,789 +0,0 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.RadialGradient
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Shader
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.view.Gravity
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.ViewConfiguration
|
||||
import android.view.MotionEvent
|
||||
import com.droiddeck.launcher.input.TouchpadGestures
|
||||
import com.droiddeck.launcher.input.PointerGestures
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import com.droiddeck.launcher.input.InputRole
|
||||
import com.droiddeck.launcher.input.InputControl
|
||||
import com.droiddeck.launcher.input.InputSurfaceElement
|
||||
import com.droiddeck.launcher.input.InputSurfaceStyle
|
||||
import com.droiddeck.launcher.input.pointerSubset
|
||||
import com.droiddeck.launcher.input.InputSurfaceSource
|
||||
import com.droiddeck.launcher.input.InputElementKind
|
||||
import com.droiddeck.launcher.input.InputAction
|
||||
import com.droiddeck.launcher.input.InputButtonIcons
|
||||
import com.droiddeck.launcher.input.InputButtonShape
|
||||
import com.droiddeck.launcher.input.InputButtonImages
|
||||
import com.droiddeck.launcher.input.PadBridge
|
||||
import com.droiddeck.launcher.input.pcKeyLabel
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.min
|
||||
|
||||
@SuppressLint("ViewConstructor")
|
||||
class InputSurfaceView(
|
||||
context: Context,
|
||||
private val pad: PadBridge? = null,
|
||||
private var customLayout: List<InputSurfaceElement> = emptyList(),
|
||||
private val inputRole: InputRole = InputRole.SECONDARY,
|
||||
private var surfaceStyle: InputSurfaceStyle = InputSurfaceStyle(),
|
||||
private val haptics: Boolean = inputRole != InputRole.PRIMARY,
|
||||
) : FrameLayout(context) {
|
||||
private val content = FrameLayout(context)
|
||||
private val elementSources = ArrayList<InputSurfaceSource>()
|
||||
private var customCanvas: ViewGroup? = null
|
||||
private val owners = HashMap<Int, View>()
|
||||
private var requestedLayout = customLayout
|
||||
init { customLayout = surfaceStyle.shown(customLayout); addView(content, LayoutParams(-1, -1)); renderCustom(pad) }
|
||||
|
||||
var onTouchMiss: ((MotionEvent) -> Unit)? = null
|
||||
private var forwardedPointers = 0
|
||||
|
||||
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN) { forwardedPointers = 0; cancelOwners(event) }
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN || event.actionMasked == MotionEvent.ACTION_POINTER_DOWN) {
|
||||
val index = event.actionIndex
|
||||
if (event.getToolType(index) == MotionEvent.TOOL_TYPE_MOUSE || !takesTouchAt(event.getX(index), event.getY(index)))
|
||||
forwardedPointers = forwardedPointers or (1 shl event.getPointerId(index))
|
||||
else if (onTouchMiss != null && SessionPrefs.lowLatency(context)) requestUnbufferedDispatch(event)
|
||||
}
|
||||
if (forwardedPointers == 0) return route(event)
|
||||
var all = 0
|
||||
for (index in 0 until event.pointerCount) all = all or (1 shl event.getPointerId(index))
|
||||
val routed = event.pointerSubset(all and forwardedPointers.inv())?.let { split ->
|
||||
try { route(split) } finally { split.recycle() }
|
||||
} ?: false
|
||||
event.pointerSubset(all and forwardedPointers)?.let { split ->
|
||||
try { onTouchMiss?.invoke(split) } finally { split.recycle() }
|
||||
}
|
||||
if (event.actionMasked == MotionEvent.ACTION_POINTER_UP) forwardedPointers = forwardedPointers and (1 shl event.getPointerId(event.actionIndex)).inv()
|
||||
if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) forwardedPointers = 0
|
||||
return onTouchMiss != null || routed
|
||||
}
|
||||
|
||||
private fun route(event: MotionEvent): Boolean {
|
||||
val action = event.actionMasked
|
||||
if (action == MotionEvent.ACTION_CANCEL) { cancelOwners(event); return true }
|
||||
val index = event.actionIndex
|
||||
val pointer = event.getPointerId(index)
|
||||
var taken = true
|
||||
if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_POINTER_DOWN) {
|
||||
val target = elementAt(event.getX(index), event.getY(index))
|
||||
taken = target != null
|
||||
when {
|
||||
target is HoldButton -> { owners[pointer] = target; target.press(pointer, true) }
|
||||
target != null && !(target is StickView && target in owners.values) -> owners[pointer] = target
|
||||
}
|
||||
}
|
||||
if (action == MotionEvent.ACTION_MOVE) for (i in 0 until event.pointerCount) slide(event.getPointerId(i), event.getX(i), event.getY(i))
|
||||
for (view in owners.values.distinct()) {
|
||||
if (view is HoldButton) continue
|
||||
var bits = 0
|
||||
for ((id, owner) in owners) if (owner === view) bits = bits or (1 shl id)
|
||||
event.pointerSubset(bits)?.let { split ->
|
||||
try { split.offsetLocation(-originX(view), -originY(view)); view.dispatchTouchEvent(split) } finally { split.recycle() }
|
||||
}
|
||||
}
|
||||
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_POINTER_UP) {
|
||||
(owners.remove(pointer) as? HoldButton)?.press(pointer, false, event.flags and MotionEvent.FLAG_CANCELED != 0)
|
||||
}
|
||||
return taken
|
||||
}
|
||||
|
||||
private fun slide(pointer: Int, x: Float, y: Float) {
|
||||
val owner = owners[pointer]
|
||||
if (owner != null && owner !is HoldButton) return
|
||||
val over = elementAt(x, y) as? HoldButton
|
||||
if (over === owner) return
|
||||
(owner as? HoldButton)?.press(pointer, false, cancelled = true)
|
||||
if (over == null) owners.remove(pointer) else { owners[pointer] = over; over.press(pointer, true) }
|
||||
}
|
||||
|
||||
private fun cancelOwners(event: MotionEvent) {
|
||||
if (owners.isEmpty()) return
|
||||
val views = owners.values.distinct()
|
||||
owners.clear()
|
||||
for (view in views) {
|
||||
if (view is HoldButton) { view.cancel(); continue }
|
||||
val cancel = MotionEvent.obtain(event.downTime, event.eventTime, MotionEvent.ACTION_CANCEL, 0f, 0f, 0)
|
||||
try { view.dispatchTouchEvent(cancel) } finally { cancel.recycle() }
|
||||
}
|
||||
}
|
||||
|
||||
fun updateStyle(style: InputSurfaceStyle) {
|
||||
if (surfaceStyle == style) return
|
||||
val relayout = surfaceStyle.hidden != style.hidden
|
||||
surfaceStyle = style
|
||||
if (relayout) updateCustomLayout(requestedLayout)
|
||||
customCanvas?.let { canvas -> for (i in 0 until canvas.childCount) styleElement(canvas.getChildAt(i)) }
|
||||
}
|
||||
|
||||
private fun styleElement(view: View) {
|
||||
view.alpha = surfaceStyle.opacity / 100f * ((view.tag as? InputSurfaceElement)?.opacity ?: 1f)
|
||||
(view as? Surface)?.tint = surfaceStyle.tint
|
||||
view.invalidate()
|
||||
}
|
||||
|
||||
fun updateCustomLayout(requested: List<InputSurfaceElement>) {
|
||||
requestedLayout = requested
|
||||
val layout = surfaceStyle.shown(requested)
|
||||
val sameElements = customLayout.map { it.copy(x = 0f, y = 0f, width = 1f, height = 1f) } == layout.map { it.copy(x = 0f, y = 0f, width = 1f, height = 1f) }
|
||||
customLayout = layout
|
||||
if (sameElements) customCanvas?.requestLayout() else {
|
||||
elementSources.forEach { it.close() }; elementSources.clear()
|
||||
owners.clear(); content.removeAllViews(); renderCustom(pad)
|
||||
}
|
||||
}
|
||||
|
||||
fun releaseInputs() {
|
||||
val now = android.os.SystemClock.uptimeMillis()
|
||||
val cancel = MotionEvent.obtain(now, now, MotionEvent.ACTION_CANCEL, 0f, 0f, 0)
|
||||
try { cancelOwners(cancel) } finally { cancel.recycle() }
|
||||
forwardedPointers = 0
|
||||
elementSources.forEach { it.release() }
|
||||
}
|
||||
|
||||
override fun onVisibilityChanged(changedView: View, visibility: Int) {
|
||||
super.onVisibilityChanged(changedView, visibility)
|
||||
if (visibility != VISIBLE) releaseInputs()
|
||||
}
|
||||
|
||||
private fun renderCustom(pad: PadBridge?) {
|
||||
val canvas = object : ViewGroup(context) {
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val layout = customLayout
|
||||
val w = MeasureSpec.getSize(widthMeasureSpec)
|
||||
val h = MeasureSpec.getSize(heightMeasureSpec)
|
||||
setMeasuredDimension(w, h)
|
||||
for (i in layout.indices) getChildAt(i).measure(MeasureSpec.makeMeasureSpec((w * layout[i].width).toInt(), MeasureSpec.EXACTLY),
|
||||
MeasureSpec.makeMeasureSpec((h * layout[i].height).toInt(), MeasureSpec.EXACTLY))
|
||||
}
|
||||
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
|
||||
val layout = customLayout
|
||||
for (i in layout.indices) {
|
||||
val e = layout[i]
|
||||
val child = getChildAt(i)
|
||||
val x = (width * e.x).toInt(); val y = (height * e.y).toInt()
|
||||
child.layout(x, y, x + child.measuredWidth, y + child.measuredHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
canvas.clipChildren = customLayout.none { it.kind == InputElementKind.STICK && (classic(it) || adaptive(it)) }
|
||||
for (element in customLayout) {
|
||||
val source = pad?.let { InputSurfaceSource(it, inputRole, element).also { s -> elementSources += s } }
|
||||
canvas.addView(when (element.kind) {
|
||||
InputElementKind.STICK -> StickView(context, element, source, element.floating, element.hideWhenIdle && pad != null)
|
||||
InputElementKind.TRACKPAD -> TrackpadView(context, element.control == InputControl.RIGHT_PAD, source = source)
|
||||
InputElementKind.DPAD -> DirectionPad(context, element, source)
|
||||
else -> HoldButton(context, element, source)
|
||||
}.apply { tag = element; contentDescription = element.name; styleElement(this); source?.onStateChanged = { postInvalidate() } })
|
||||
}
|
||||
content.addView(canvas, FrameLayout.LayoutParams(-1, -1))
|
||||
customCanvas = canvas
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
if (pad != null && elementSources.isEmpty()) {
|
||||
owners.clear(); content.removeAllViews()
|
||||
renderCustom(pad)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
elementSources.forEach { it.close() }
|
||||
elementSources.clear()
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
|
||||
fun takesTouchAt(x: Float, y: Float): Boolean = elementAt(x, y) != null
|
||||
|
||||
private fun elementAt(x: Float, y: Float): View? {
|
||||
val canvas = customCanvas ?: return null
|
||||
if (visibility != VISIBLE || x < 0f || y < 0f || x >= width || y >= height) return null
|
||||
val views = (canvas.childCount - 1 downTo 0).map(canvas::getChildAt).filter { it.visibility == VISIBLE }
|
||||
views.firstOrNull { !(it is StickView && it.adaptive) && hits(it, x, y, 0f) }?.let { return it }
|
||||
views.filter { it is HoldButton && hits(it, x, y, slack(it)) }.minByOrNull { distance(it, x, y) }?.let { return it }
|
||||
if (views.any { it is HoldButton && hits(it, x, y, slack(it) + ADAPTIVE_CLEARANCE_PX) }) return null
|
||||
return views.filter { it is StickView && it.adaptive && it !in owners.values && (hits(it, x, y, 0f) || it.captures(x - originX(it), y - originY(it))) }
|
||||
.minByOrNull { distance(it, x, y) }
|
||||
}
|
||||
|
||||
private fun hits(view: View, x: Float, y: Float, slack: Float): Boolean {
|
||||
val left = originX(view)
|
||||
val top = originY(view)
|
||||
if ((view as? HoldButton)?.round == true && slack > 0f) {
|
||||
val reach = min(view.width, view.height) / 2f + slack
|
||||
return hypot(x - left - view.width / 2f, y - top - view.height / 2f) <= reach
|
||||
}
|
||||
return x >= left - slack && x < left + view.width + slack && y >= top - slack && y < top + view.height + slack
|
||||
}
|
||||
|
||||
private fun slack(view: View) = if ((view as? HoldButton)?.gamepad == true) min(view.width, view.height) / 2f * HIT_SLACK else 0f
|
||||
|
||||
private fun distance(view: View, x: Float, y: Float): Float {
|
||||
val cx = originX(view) + ((view as? StickView)?.centreX() ?: (view.width / 2f))
|
||||
return hypot(x - cx, y - originY(view) - view.height / 2f)
|
||||
}
|
||||
|
||||
private fun originX(view: View): Float {
|
||||
var x = 0f
|
||||
var at: View = view
|
||||
while (at !== this) { x += at.left - ((at.parent as? View)?.scrollX ?: 0); at = at.parent as? View ?: break }
|
||||
return x
|
||||
}
|
||||
|
||||
private fun originY(view: View): Float {
|
||||
var y = 0f
|
||||
var at: View = view
|
||||
while (at !== this) { y += at.top - ((at.parent as? View)?.scrollY ?: 0); at = at.parent as? View ?: break }
|
||||
return y
|
||||
}
|
||||
|
||||
private fun feedback(view: View, type: Int) { if (haptics) view.performHapticFeedback(type) }
|
||||
|
||||
private abstract class Surface(context: Context) : View(context) {
|
||||
var tint: Int? = null
|
||||
protected val density = resources.displayMetrics.density
|
||||
protected val rect = RectF()
|
||||
private val fill = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
|
||||
protected val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
textAlign = Paint.Align.CENTER
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
}
|
||||
protected val captionPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
textAlign = Paint.Align.CENTER
|
||||
color = TEXT_DIM
|
||||
}
|
||||
|
||||
protected fun drawSurface(canvas: Canvas, lit: Boolean, shape: InputButtonShape = InputButtonShape.ROUNDED, classic: Boolean = false) {
|
||||
val inset = if (classic && tint != null) 0.75f * density else density
|
||||
rect.set(inset, inset, width - inset, height - inset)
|
||||
val radius = when (shape) { InputButtonShape.CIRCLE, InputButtonShape.PILL -> min(width, height) / 2f; InputButtonShape.RECTANGLE -> 2 * density; else -> CORNER * density }
|
||||
val color = tint
|
||||
if (classic && color != null) {
|
||||
val corner = if (shape == InputButtonShape.PILL) radius * 0.55f else radius
|
||||
fill.color = if (lit) shade(color, 160, 1f) else shade(color, 80, 0.12f)
|
||||
canvas.drawRoundRect(rect, corner, corner, fill)
|
||||
stroke.color = if (lit) light(color, 230, 0.7f) else light(color, 150, 0.25f)
|
||||
stroke.strokeWidth = 1.5f * density
|
||||
canvas.drawRoundRect(rect, corner, corner, stroke)
|
||||
return
|
||||
}
|
||||
fill.color = tint?.let { color -> (color and 0x00ffffff) or ((if (lit) 140 else 48) shl 24) } ?: if (lit) ACCENT_WASH else SURFACE
|
||||
canvas.drawRoundRect(rect, radius, radius, fill)
|
||||
stroke.color = tint?.let { color -> (color and 0x00ffffff) or ((if (lit) 255 else 160) shl 24) } ?: if (lit) ACCENT else LINE
|
||||
stroke.strokeWidth = (if (lit) 2f else 1f) * density
|
||||
canvas.drawRoundRect(rect, radius, radius, stroke)
|
||||
}
|
||||
|
||||
protected fun drawCircle(canvas: Canvas, x: Float, y: Float, radius: Float, fillColor: Int, strokeColor: Int) {
|
||||
fill.color = fillColor
|
||||
canvas.drawCircle(x, y, radius, fill)
|
||||
stroke.color = strokeColor
|
||||
stroke.strokeWidth = 1.5f * density
|
||||
canvas.drawCircle(x, y, radius, stroke)
|
||||
}
|
||||
|
||||
protected fun drawKeyboard(canvas: Canvas, x: Float, y: Float, lit: Boolean) {
|
||||
val color = tint ?: return
|
||||
stroke.color = if (lit) light(color, 230, 0.7f) else light(color, 150, 0.25f)
|
||||
stroke.strokeWidth = 1.5f * density
|
||||
rect.set(x - 10 * density, y - 7 * density, x + 10 * density, y + 7 * density)
|
||||
canvas.drawRoundRect(rect, 2 * density, 2 * density, stroke)
|
||||
for (row in 0..1) for (col in 0..2) {
|
||||
val kx = x + (-6f + col * 5f) * density
|
||||
val ky = y + (-3f + row * 4f) * density
|
||||
canvas.drawLine(kx, ky, kx + 2 * density, ky, stroke)
|
||||
}
|
||||
canvas.drawLine(x - 5 * density, y + 4 * density, x + 5 * density, y + 4 * density, stroke)
|
||||
}
|
||||
|
||||
protected fun textColor(lit: Boolean) = if (lit) Color.WHITE else tint?.let { light(it, 210, 0.75f) } ?: TEXT
|
||||
}
|
||||
|
||||
private inner class HoldButton(context: Context, private val element: InputSurfaceElement, private val source: InputSurfaceSource?) : Surface(context) {
|
||||
private var down = false
|
||||
private val pointers = HashSet<Int>()
|
||||
val round get() = element.buttonShape == InputButtonShape.CIRCLE
|
||||
val gamepad get() = round || element.buttonShape == InputButtonShape.PILL
|
||||
private val symbol = InputButtonIcons.drawable(context, element.icon)
|
||||
private val bitmap = InputButtonImages.file(context, element.image)?.let { file ->
|
||||
val size = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(file.path, size)
|
||||
val options = BitmapFactory.Options().apply {
|
||||
while (size.outWidth / inSampleSize.coerceAtLeast(1) > 512 || size.outHeight / inSampleSize.coerceAtLeast(1) > 512)
|
||||
inSampleSize = inSampleSize.coerceAtLeast(1) * 2
|
||||
}
|
||||
if (file.isFile) BitmapFactory.decodeFile(file.path, options) else null
|
||||
}
|
||||
private val imageRect = RectF()
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
val lit = if (element.toggle) source?.latched == true else down
|
||||
val classic = classic(element)
|
||||
drawSurface(canvas, lit, element.buttonShape, classic)
|
||||
val cx = width / 2f
|
||||
labelPaint.color = if (classic && tint != null) textColor(lit) else if (lit) ACCENT else TEXT
|
||||
bitmap?.let {
|
||||
val scale = min(width * .84f / it.width, height * .84f / it.height)
|
||||
imageRect.set(cx - it.width * scale / 2, (height - it.height * scale) / 2,
|
||||
cx + it.width * scale / 2, (height + it.height * scale) / 2)
|
||||
canvas.drawBitmap(it, null, imageRect, labelPaint)
|
||||
return
|
||||
}
|
||||
if (classic && element.id == "builtin:keyboard" && element.icon == InputButtonIcons.KEYBOARD) { drawKeyboard(canvas, cx, height / 2f, lit); return }
|
||||
if (element.icon != 0 && InputButtonIcons.draw(canvas, element.icon, cx, height / 2f, min(width, height) * .68f, labelPaint, symbol)) return
|
||||
if (!element.showLabel) return
|
||||
labelPaint.style = Paint.Style.FILL
|
||||
if (classic && element.name == element.control.label) {
|
||||
val radius = min(width, height) / 2f
|
||||
val direction = DIRECTIONS.indexOf(element.control)
|
||||
if (direction >= 0) { drawArrow(canvas, cx, height / 2f, radius * 0.34f, direction, labelPaint); return }
|
||||
val glyph = glyph(element.control)
|
||||
labelPaint.textSize = when {
|
||||
element.buttonShape == InputButtonShape.PILL -> height / 2f * 0.8f
|
||||
glyph.length > 2 -> 11 * density
|
||||
else -> radius * if (glyph.length == 2) 0.62f else 0.85f
|
||||
}
|
||||
canvas.drawText(glyph, cx, height / 2f + labelPaint.textSize * .35f, labelPaint)
|
||||
return
|
||||
}
|
||||
labelPaint.textSize = min(height * .3f, 30 * density)
|
||||
val labelWidth = labelPaint.measureText(element.name)
|
||||
if (labelWidth > width * .84f) labelPaint.textSize *= width * .84f / labelWidth
|
||||
canvas.drawText(element.name, cx, height / 2f + labelPaint.textSize * .35f, labelPaint)
|
||||
}
|
||||
|
||||
fun press(pointer: Int, pressed: Boolean, cancelled: Boolean = false) {
|
||||
if (pressed) pointers.add(pointer) else pointers.remove(pointer)
|
||||
set(pointers.isNotEmpty(), cancelled)
|
||||
}
|
||||
|
||||
fun cancel() { pointers.clear(); set(false, cancelled = true); source?.release() }
|
||||
|
||||
override fun onDetachedFromWindow() { cancel(); super.onDetachedFromWindow() }
|
||||
|
||||
private fun set(pressed: Boolean, cancelled: Boolean = false) {
|
||||
if (down == pressed) return
|
||||
down = pressed
|
||||
if (pressed) feedback(this, HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
if (!pressed && cancelled) source?.cancel() else source?.update(if (pressed) 1f else 0f)
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
private inner class TrackpadView(context: Context, private val right: Boolean, private val captioned: Boolean = true,
|
||||
private val source: InputSurfaceSource? = null) : Surface(context) {
|
||||
private var gestureX = 0f
|
||||
private var gestureY = 0f
|
||||
private val mouse = TouchpadGestures(ViewConfiguration.get(context).scaledTouchSlop.toFloat(), object : PointerGestures.Listener {
|
||||
override fun onMove(x: Float, y: Float) {
|
||||
source?.pointerDelta(2 * (x - gestureX) / width.coerceAtLeast(1), 2 * (y - gestureY) / height.coerceAtLeast(1))
|
||||
gestureX = x; gestureY = y
|
||||
}
|
||||
override fun onButton(button: Int, pressed: Boolean, x: Float, y: Float) { source?.gestureButton(button, pressed) }
|
||||
override fun onWheel(steps: Int) { source?.gestureWheel(steps) }
|
||||
override fun onLongPress() { feedback(this@TrackpadView, HapticFeedbackConstants.LONG_PRESS) }
|
||||
}).apply { bounds = RectF(-1e9f, -1e9f, 1e9f, 1e9f) }
|
||||
|
||||
private var touching = false
|
||||
private var clicked = false
|
||||
private var fingerX = 0f
|
||||
private var fingerY = 0f
|
||||
private val guide = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE; color = GUIDE }
|
||||
private val dot = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = ACCENT }
|
||||
private val glow = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
drawSurface(canvas, clicked)
|
||||
val cx = width / 2f
|
||||
val cy = height / 2f
|
||||
guide.strokeWidth = density
|
||||
val reach = min(width, height) / 2f - 24 * density
|
||||
canvas.drawCircle(cx, cy, reach, guide)
|
||||
canvas.drawCircle(cx, cy, reach / 2f, guide)
|
||||
canvas.drawLine(cx - reach, cy, cx + reach, cy, guide)
|
||||
canvas.drawLine(cx, cy - reach, cx, cy + reach, guide)
|
||||
captionPaint.textSize = 12 * density
|
||||
captionPaint.color = if (touching) ACCENT else TEXT_DIM
|
||||
if (captioned) canvas.drawText((contentDescription?.toString() ?: if (right) context.getString(R.string.deck_right_trackpad) else context.getString(R.string.deck_left_trackpad)).uppercase(), cx, 28 * density, captionPaint)
|
||||
if (touching) {
|
||||
val radius = 56 * density
|
||||
glow.shader = RadialGradient(fingerX, fingerY, radius, ACCENT_GLOW, Color.TRANSPARENT, Shader.TileMode.CLAMP)
|
||||
canvas.drawCircle(fingerX, fingerY, radius, glow)
|
||||
canvas.drawCircle(fingerX, fingerY, (if (clicked) 16 else 12) * density, dot)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
if (source?.mouseGestures == true) {
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
|
||||
gestureX = 0f; gestureY = 0f; mouse.place(0f, 0f)
|
||||
source.gestureContact(true)
|
||||
}
|
||||
touching = event.actionMasked != MotionEvent.ACTION_UP && event.actionMasked != MotionEvent.ACTION_CANCEL
|
||||
fingerX = event.x; fingerY = event.y
|
||||
mouse.onTouch(event)
|
||||
if (!touching) source.gestureContact(false)
|
||||
invalidate()
|
||||
return true
|
||||
}
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
|
||||
touch(event.getX(0), event.getY(0))
|
||||
click(event.pointerCount > 1)
|
||||
}
|
||||
MotionEvent.ACTION_POINTER_DOWN -> click(true)
|
||||
MotionEvent.ACTION_POINTER_UP -> {
|
||||
val stays = if (event.actionIndex == 0) 1 else 0
|
||||
touch(event.getX(stays), event.getY(stays))
|
||||
click(event.pointerCount - 1 > 1)
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
click(false)
|
||||
touching = false
|
||||
source?.update(0f, 0f, false)
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() { mouse.cancel(); source?.release(); super.onDetachedFromWindow() }
|
||||
|
||||
private fun touch(x: Float, y: Float) {
|
||||
fingerX = x.coerceIn(0f, width.toFloat())
|
||||
fingerY = y.coerceIn(0f, height.toFloat())
|
||||
touching = true
|
||||
val xAxis = (fingerX - width / 2f) / (width / 2f).coerceAtLeast(1f)
|
||||
val yAxis = -(fingerY - height / 2f) / (height / 2f).coerceAtLeast(1f)
|
||||
source?.update(xAxis, yAxis, true)
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun click(down: Boolean) {
|
||||
if (clicked == down) return
|
||||
clicked = down
|
||||
if (down) feedback(this, HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
source?.click(down)
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
private inner class StickView(context: Context, private val element: InputSurfaceElement, private val source: InputSurfaceSource? = null, private val floating: Boolean = true, private val hideWhenIdle: Boolean = false) : Surface(context) {
|
||||
private var touching = false
|
||||
private var clicking = false
|
||||
private var lastUp = 0L
|
||||
private var downAt = 0L
|
||||
private var downX = 0f
|
||||
private var downY = 0f
|
||||
private var travel = 0f
|
||||
private val tapSlop = ViewConfiguration.get(context).scaledTouchSlop.toFloat()
|
||||
private var originX = 0f
|
||||
private var originY = 0f
|
||||
private var knobX = 0f
|
||||
private var knobY = 0f
|
||||
private val ring = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
|
||||
private val knob = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
|
||||
val adaptive get() = adaptive(element)
|
||||
|
||||
fun reach() = min(width, height) * if (classic(element)) CLASSIC_REACH else PANEL_REACH
|
||||
|
||||
fun captures(x: Float, y: Float): Boolean {
|
||||
val cx = centreX()
|
||||
val dx = if (element.control == InputControl.LS) maxOf(0f, x - cx) else x - cx
|
||||
val limit = reach() * ADAPTIVE_REACH
|
||||
return dx * dx + (y - height / 2f) * (y - height / 2f) <= limit * limit
|
||||
}
|
||||
|
||||
fun centreX() = when {
|
||||
!classic(element) || width <= height -> width / 2f
|
||||
element.control == InputControl.LS -> width - height / 2f
|
||||
else -> height / 2f
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
if (hideWhenIdle && !touching) return
|
||||
val cx = if (touching) originX else centreX()
|
||||
val cy = if (touching) originY else height / 2f
|
||||
val color = tint
|
||||
if (classic(element) && color != null) {
|
||||
val radius = min(width, height) / 2f - 0.75f * density
|
||||
drawCircle(canvas, cx, cy, radius,
|
||||
if (touching) shade(color, 160, 1f) else shade(color, 60, 0.12f), if (touching) light(color, 230, 0.7f) else light(color, 150, 0.25f))
|
||||
val knobRadius = radius * 0.46f
|
||||
drawCircle(canvas, cx + knobX, cy + knobY, knobRadius,
|
||||
if (touching || clicking) shade(color, 160, 1f) else shade(color, 120, 0.35f), if (touching) light(color, 230, 0.7f) else light(color, 150, 0.25f))
|
||||
if (!element.showLabel) return
|
||||
labelPaint.color = textColor(touching)
|
||||
if (drawDirectionLabels(canvas, source, false, cx, cy, radius * 0.66f, radius * 0.34f, labelPaint)) return
|
||||
labelPaint.textSize = knobRadius * 0.8f
|
||||
val label = if (element.name == element.control.label) glyph(element.control) else element.name
|
||||
canvas.drawText(label, cx + knobX, cy + knobY + labelPaint.textSize * 0.35f, labelPaint)
|
||||
return
|
||||
}
|
||||
drawSurface(canvas, clicking)
|
||||
ring.strokeWidth = 2 * density
|
||||
ring.color = if (touching) ACCENT else LINE
|
||||
canvas.drawCircle(cx, cy, reach(), ring)
|
||||
knob.color = if (touching) ACCENT else KNOB
|
||||
canvas.drawCircle(cx + knobX, cy + knobY, reach() * 0.45f, knob)
|
||||
if (!element.showLabel) return
|
||||
labelPaint.color = if (touching) ACCENT else TEXT
|
||||
drawDirectionLabels(canvas, source, false, cx, cy, reach() * 1.32f, reach() * 0.68f, labelPaint)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
val r = reach().coerceAtLeast(1f)
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
downAt = event.eventTime
|
||||
downX = event.x
|
||||
downY = event.y
|
||||
travel = 0f
|
||||
originX = if (floating) event.x else centreX()
|
||||
originY = if (floating) event.y else height / 2f
|
||||
touching = true
|
||||
feedback(this, HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
if (lastUp != 0L && event.eventTime - lastUp < CLICK_MS) setClick(true)
|
||||
follow(event.x, event.y, r, force = true)
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> follow(event.getX(0), event.getY(0), r)
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
touching = false
|
||||
val cancelled = event.actionMasked == MotionEvent.ACTION_CANCEL || event.flags and MotionEvent.FLAG_CANCELED != 0
|
||||
travel = maxOf(travel, hypot(event.x - downX, event.y - downY))
|
||||
lastUp = if (!clicking && !cancelled && event.eventTime - downAt <= STICK_TAP_MS && travel <= tapSlop) event.eventTime else 0L
|
||||
setClick(false)
|
||||
move(0f, 0f, 1f)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun follow(x: Float, y: Float, r: Float, force: Boolean = false) {
|
||||
travel = maxOf(travel, hypot(x - downX, y - downY))
|
||||
var dx = x - originX
|
||||
var dy = y - originY
|
||||
val length = hypot(dx, dy)
|
||||
if (length > r) { dx *= r / length; dy *= r / length }
|
||||
if (force || dx != knobX || dy != knobY) move(dx, dy, r)
|
||||
}
|
||||
|
||||
private fun move(dx: Float, dy: Float, r: Float) {
|
||||
knobX = dx
|
||||
knobY = dy
|
||||
source?.update(dx / r, dy / r)
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun setClick(down: Boolean) {
|
||||
if (clicking == down) return
|
||||
clicking = down
|
||||
if (down) feedback(this, HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
source?.click(down)
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
setClick(false)
|
||||
if (knobX != 0f || knobY != 0f) move(0f, 0f, 1f)
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
}
|
||||
|
||||
private inner class DirectionPad(context: Context, private val element: InputSurfaceElement, private val source: InputSurfaceSource? = null) : View(context) {
|
||||
private val density = resources.displayMetrics.density
|
||||
private var held = 0
|
||||
private val arm = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val label = Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.CENTER; typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD); color = TEXT }
|
||||
private val rect = RectF()
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
val size = min(width, height) * 0.86f
|
||||
val cx = width / 2f
|
||||
val cy = height / 2f
|
||||
val half = size / 2f
|
||||
val thick = size / 3f / 2f
|
||||
fun armRect(bit: Int, l: Float, t: Float, r: Float, b: Float) {
|
||||
arm.color = if (held and bit != 0) ACCENT else KNOB
|
||||
rect.set(l, t, r, b)
|
||||
canvas.drawRoundRect(rect, 8 * density, 8 * density, arm)
|
||||
}
|
||||
armRect(1, cx - thick, cy - half, cx + thick, cy - thick)
|
||||
armRect(4, cx - thick, cy + thick, cx + thick, cy + half)
|
||||
armRect(8, cx - half, cy - thick, cx - thick, cy + thick)
|
||||
armRect(2, cx + thick, cy - thick, cx + half, cy + thick)
|
||||
arm.color = KNOB
|
||||
canvas.drawRect(cx - thick, cy - thick, cx + thick, cy + thick, arm)
|
||||
if (element.showLabel) drawDirectionLabels(canvas, source, true, cx, cy, (half + thick) / 2f, thick * 1.7f, label)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
val bits = when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
|
||||
val dx = event.getX(0) - width / 2f
|
||||
val dy = event.getY(0) - height / 2f
|
||||
if (hypot(dx, dy) < min(width, height) * 0.08f) 0 else {
|
||||
val sector = ((Math.toDegrees(atan2(dy.toDouble(), dx.toDouble())) + 360 + 22.5) % 360 / 45).toInt()
|
||||
intArrayOf(2, 2 or 4, 4, 4 or 8, 8, 8 or 1, 1, 1 or 2)[sector]
|
||||
}
|
||||
}
|
||||
else -> 0
|
||||
}
|
||||
set(bits)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun set(bits: Int) {
|
||||
if (bits == held) return
|
||||
if (bits and held.inv() != 0) feedback(this, HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
held = bits
|
||||
source?.update((if (bits and 2 != 0) 1f else 0f) - (if (bits and 8 != 0) 1f else 0f),
|
||||
(if (bits and 4 != 0) 1f else 0f) - (if (bits and 1 != 0) 1f else 0f))
|
||||
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
set(0)
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CLICK_MS = 300L
|
||||
private const val STICK_TAP_MS = 200L
|
||||
private const val CLASSIC_REACH = 0.5f
|
||||
private const val PANEL_REACH = 0.30f
|
||||
private const val HIT_SLACK = 0.25f
|
||||
private const val ADAPTIVE_CLEARANCE_PX = 5f
|
||||
private val ADAPTIVE_REACH = 1.4f * kotlin.math.sqrt(1.5f)
|
||||
|
||||
|
||||
private const val CORNER = 20f
|
||||
private const val CORNER_PILL = 22f
|
||||
private val ACCENT = Color.rgb(0x1A, 0x9F, 0xFF)
|
||||
private val ACCENT_WASH = Color.argb(0x2E, 0x1A, 0x9F, 0xFF)
|
||||
private val ACCENT_GLOW = Color.argb(0x66, 0x1A, 0x9F, 0xFF)
|
||||
private val ON_ACCENT = Color.rgb(0x03, 0x11, 0x1F)
|
||||
internal val SURFACE = Color.rgb(0x0A, 0x0B, 0x0D)
|
||||
internal val LINE = Color.rgb(0x26, 0x29, 0x2E)
|
||||
private val GUIDE = Color.rgb(0x16, 0x18, 0x1B)
|
||||
internal val TEXT = Color.rgb(0xF2, 0xF4, 0xF7)
|
||||
private val TEXT_DIM = Color.rgb(0x7A, 0x80, 0x8A)
|
||||
private val KNOB = Color.rgb(0x2A, 0x2D, 0x33)
|
||||
|
||||
private fun adaptive(element: InputSurfaceElement) = element.kind == InputElementKind.STICK && element.floating && element.hideWhenIdle
|
||||
|
||||
private fun classic(element: InputSurfaceElement) = (element.id == "legacy:${element.control.id}" || element.id == "builtin:keyboard") &&
|
||||
(element.buttonShape == InputButtonShape.CIRCLE || element.buttonShape == InputButtonShape.PILL)
|
||||
|
||||
private val DIRECTIONS = listOf(InputControl.UP, InputControl.RIGHT, InputControl.DOWN, InputControl.LEFT)
|
||||
|
||||
internal fun shade(tint: Int, alpha: Int, f: Float) =
|
||||
Color.argb(alpha, (Color.red(tint) * f).toInt(), (Color.green(tint) * f).toInt(), (Color.blue(tint) * f).toInt())
|
||||
|
||||
internal fun light(tint: Int, alpha: Int, f: Float) = Color.argb(alpha, (Color.red(tint) + (255 - Color.red(tint)) * f).toInt(),
|
||||
(Color.green(tint) + (255 - Color.green(tint)) * f).toInt(), (Color.blue(tint) + (255 - Color.blue(tint)) * f).toInt())
|
||||
|
||||
private fun glyph(control: InputControl) = when (control) {
|
||||
InputControl.LS -> "L"
|
||||
InputControl.RS -> "R"
|
||||
InputControl.SELECT -> "⧉"
|
||||
InputControl.START -> "☰"
|
||||
InputControl.GUIDE -> "◉"
|
||||
InputControl.QAM -> "⋯"
|
||||
else -> control.id.uppercase()
|
||||
}
|
||||
|
||||
private val CARDINAL = listOf(0f to -1f, 1f to 0f, 0f to 1f, -1f to 0f)
|
||||
private val DIAGONAL = listOf(.7071f to -.7071f, .7071f to .7071f, -.7071f to .7071f, -.7071f to -.7071f)
|
||||
|
||||
internal fun directionLabels(context: Context, action: InputAction.Directions?, cardinalOnly: Boolean): List<Pair<Pair<Float, Float>, String>> {
|
||||
if (action == null) return emptyList()
|
||||
val codes = listOf(action.up, action.right, action.down, action.left).zip(CARDINAL) +
|
||||
if (cardinalOnly) emptyList() else action.diagonals.orEmpty().zip(DIAGONAL)
|
||||
return codes.filter { it.first != 0 }.map { (code, unit) -> unit to pcKeyLabel(context, code) }
|
||||
}
|
||||
|
||||
private fun View.drawDirectionLabels(canvas: Canvas, source: InputSurfaceSource?, cardinalOnly: Boolean, cx: Float, cy: Float,
|
||||
distance: Float, box: Float, paint: Paint): Boolean {
|
||||
val labels = directionLabels(context, source?.directions, cardinalOnly)
|
||||
paint.style = Paint.Style.FILL
|
||||
for ((unit, text) in labels) {
|
||||
paint.textSize = box * .6f
|
||||
val measured = paint.measureText(text)
|
||||
if (measured > box) paint.textSize *= box / measured
|
||||
canvas.drawText(text, cx + unit.first * distance, cy + unit.second * distance - (paint.ascent() + paint.descent()) / 2f, paint)
|
||||
}
|
||||
return labels.isNotEmpty()
|
||||
}
|
||||
|
||||
private fun drawArrow(canvas: Canvas, x: Float, y: Float, size: Float, direction: Int, paint: Paint) {
|
||||
val (fx, fy) = when (direction) { 0 -> 0f to -1f; 1 -> 1f to 0f; 2 -> 0f to 1f; else -> -1f to 0f }
|
||||
val path = Path()
|
||||
path.moveTo(x + fx * size, y + fy * size)
|
||||
path.lineTo(x - fx * size * 0.7f - fy * size, y - fy * size * 0.7f + fx * size)
|
||||
path.lineTo(x - fx * size * 0.7f + fy * size, y - fy * size * 0.7f - fx * size)
|
||||
path.close()
|
||||
canvas.drawPath(path, paint)
|
||||
}
|
||||
|
||||
internal fun dp(context: Context, value: Int): Int = (value * context.resources.displayMetrics.density + 0.5f).toInt()
|
||||
|
||||
internal fun outline(context: Context, radius: Float, fill: Int) = GradientDrawable().apply {
|
||||
cornerRadius = radius * context.resources.displayMetrics.density
|
||||
setColor(fill)
|
||||
setStroke(dp(context, 1), LINE)
|
||||
}
|
||||
|
||||
internal fun roundButton(context: Context, label: String, description: String, onClick: () -> Unit, wide: Boolean = false) =
|
||||
TextView(context).apply {
|
||||
text = label
|
||||
contentDescription = description
|
||||
textSize = if (wide) 13f else 20f
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
letterSpacing = if (wide) 0.08f else 0f
|
||||
gravity = Gravity.CENTER
|
||||
setTextColor(TEXT)
|
||||
background = outline(context, CORNER_PILL, SURFACE)
|
||||
setPadding(if (wide) dp(context, 16) else 0, 0, if (wide) dp(context, 16) else 0, 0)
|
||||
minWidth = dp(context, 44)
|
||||
layoutParams = LinearLayout.LayoutParams(if (wide) LinearLayout.LayoutParams.WRAP_CONTENT else dp(context, 44), dp(context, 44))
|
||||
setOnClickListener {
|
||||
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -109,7 +109,6 @@ import com.droiddeck.launcher.stores.itch.ItchUpdates
|
||||
import com.droiddeck.launcher.stores.gog.GogMaintenance
|
||||
import com.droiddeck.launcher.stores.retainOrder
|
||||
import com.droiddeck.launcher.stores.xbox.XboxLibraryFilter
|
||||
import com.droiddeck.launcher.stores.entryTime
|
||||
import com.droiddeck.launcher.stores.sortGames
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -297,8 +296,8 @@ internal fun LibraryPage(s: FrontEndState, a: FrontEndActions, selected: String,
|
||||
val sizesPending = sizeSort && !sizesWaited && candidates.any { sizeOf(it) == null }
|
||||
val sorted = sortGames(
|
||||
candidates, sort, title = { it.title }, releaseDate = { it.item?.releaseDate.orEmpty() },
|
||||
lastPlayed = { e -> entryTime(e.game?.lastPlayed, e.storeKey?.let { lastPlayed[it] }) },
|
||||
installedAt = { e -> entryTime(e.storeKey?.let { installedAt[it] }, e.game?.installedAt) },
|
||||
lastPlayed = { e -> e.game?.lastPlayed ?: e.storeKey?.let { lastPlayed[it] } ?: 0L },
|
||||
installedAt = { e -> e.storeKey?.let { installedAt[it] } ?: 0L },
|
||||
size = { e -> sizeOf(e) ?: 0L },
|
||||
)
|
||||
var stable by remember { mutableStateOf(emptyList<LibraryEntry>()) }
|
||||
|
||||
@@ -120,7 +120,6 @@ class ModeSettingsActions(
|
||||
val onSteamDownloadsInBackground: (Boolean) -> Unit = {},
|
||||
val onPipAutoEnter: (Boolean) -> Unit = {},
|
||||
val onOsc: (String) -> Unit,
|
||||
val onInputSettings: () -> Unit,
|
||||
val onBackHold: (Boolean) -> Unit = {},
|
||||
val onBackActionsInverted: (Boolean) -> Unit = {},
|
||||
val onDirectAudio: (Boolean) -> Unit,
|
||||
@@ -260,7 +259,6 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras:
|
||||
extras.drivers(s.gpuDrivers)
|
||||
}
|
||||
if (tab == SheetTab.CONTROLS) {
|
||||
InputQuickSettings(host, desktop = s.mode == SessionService.MODE_DESKTOP, onEdit = a.onInputSettings)
|
||||
SettingsGroup(if (steam) stringResource(R.string.mode_touch_controls) else stringResource(R.string.mode_touch)) {
|
||||
ChoiceRow(
|
||||
host, "touch", stringResource(R.string.mode_touch), null,
|
||||
|
||||
@@ -36,7 +36,6 @@ import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.DialogWindowProvider
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.input.PhysicalShortcuts
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
|
||||
@Composable
|
||||
internal fun PhysicalShortcutRows(
|
||||
@@ -49,22 +48,14 @@ internal fun PhysicalShortcutRows(
|
||||
Column {
|
||||
for (action in PhysicalShortcuts.Action.entries) {
|
||||
val assigned = bindings.filter { it.action == action }
|
||||
val inverted = SessionPrefs.backActionsInverted(context)
|
||||
val default = stringResource(when (action) {
|
||||
PhysicalShortcuts.Action.STEAM -> R.string.input_shortcut_guide
|
||||
PhysicalShortcuts.Action.QAM -> if (inverted) R.string.input_shortcut_back_once else R.string.input_shortcut_back_twice
|
||||
PhysicalShortcuts.Action.DROIDDECK -> if (inverted) R.string.input_shortcut_back_modes else R.string.input_shortcut_back_once
|
||||
})
|
||||
SettingsRow(
|
||||
stringResource(action.label),
|
||||
assigned.joinToString { "${it.button.label} · ${it.button.name}" }.ifEmpty {
|
||||
if (action == PhysicalShortcuts.Action.QAM && SessionPrefs.backHold(context)) context.getString(R.string.input_shortcut_hold_hint) else null
|
||||
},
|
||||
assigned.joinToString { "${it.button.label} · ${it.button.name}" }.ifEmpty { null },
|
||||
) {
|
||||
SecondaryButton(
|
||||
if (assigned.isEmpty()) default else assigned.map { it.button.label }.distinct().joinToString(" / "),
|
||||
stringResource(if (assigned.isEmpty()) R.string.game_settings_default else R.string.ctrl_custom),
|
||||
compact = true,
|
||||
modifier = modifierFor(action).widthIn(min = LocalChipMinWidth.current, max = 240.dp)
|
||||
modifier = modifierFor(action).width(LocalChipMinWidth.current)
|
||||
.testTag("shortcuts-assign-${action.name}"),
|
||||
) { editing = action }
|
||||
}
|
||||
@@ -174,12 +165,6 @@ internal fun PhysicalShortcutsDialog(
|
||||
modifier = Modifier.testTag("shortcuts-remove"),
|
||||
)
|
||||
}
|
||||
SecondaryButton(stringResource(R.string.input_restore_shortcut), enabled = bindings.any { it.action == action },
|
||||
modifier = Modifier.testTag("shortcuts-restore-default")) {
|
||||
PhysicalShortcuts.restoreDefault(context, action)
|
||||
onChanged()
|
||||
onDismiss()
|
||||
}
|
||||
SecondaryButton(stringResource(R.string.common_close), modifier = Modifier.testTag("shortcuts-cancel"),
|
||||
onClick = onDismiss)
|
||||
}
|
||||
@@ -189,7 +174,7 @@ internal fun PhysicalShortcutsDialog(
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun CaptureShortcutKeys(onKey: (KeyEvent) -> Boolean) {
|
||||
private fun CaptureShortcutKeys(onKey: (KeyEvent) -> Boolean) {
|
||||
val currentOnKey by rememberUpdatedState(onKey)
|
||||
val window = (LocalView.current.parent as DialogWindowProvider).window
|
||||
DisposableEffect(window) {
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.DragIndicator
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.material.icons.filled.Keyboard
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.geometry.Offset
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.input.SCREEN_KEY_CIRCLE
|
||||
import com.droiddeck.launcher.input.SCREEN_KEY_DPAD
|
||||
import com.droiddeck.launcher.input.SCREEN_KEY_PILL
|
||||
import com.droiddeck.launcher.input.SCREEN_KEY_RECT
|
||||
import com.droiddeck.launcher.input.SCREEN_KEY_ROUND
|
||||
import com.droiddeck.launcher.input.SCREEN_KEY_STICK
|
||||
import com.droiddeck.launcher.input.SCREEN_KEY_TRIGGER
|
||||
import com.droiddeck.launcher.input.ScreenKeys
|
||||
import com.droiddeck.launcher.input.pcKeyLabel
|
||||
import com.droiddeck.launcher.input.screenKeyDirections
|
||||
import java.io.File
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val PANEL_WIDTH = 600.dp
|
||||
private val CHIP_PADDING = androidx.compose.foundation.layout.PaddingValues(horizontal = 6.dp, vertical = 2.dp)
|
||||
|
||||
@Composable
|
||||
fun ScreenKeyEditor(
|
||||
codes: List<Int>,
|
||||
keyY: Float,
|
||||
size: Int,
|
||||
opacity: Float,
|
||||
shape: Int,
|
||||
toggle: Boolean,
|
||||
labels: Boolean,
|
||||
text: String,
|
||||
hasImage: Boolean,
|
||||
onResize: (Int) -> Unit,
|
||||
onOpacity: (Float) -> Unit,
|
||||
onShape: (Int) -> Unit,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
onLabels: (Boolean) -> Unit,
|
||||
onText: (String) -> Unit,
|
||||
onImage: (String) -> Unit,
|
||||
onClearImage: () -> Unit,
|
||||
onBind: () -> Unit,
|
||||
onTextFocus: () -> Unit,
|
||||
onTextBlur: () -> Unit,
|
||||
onDirection: (Int) -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val p = LocalPalette.current
|
||||
val context = LocalContext.current
|
||||
var offset by remember(codes) { mutableStateOf(Offset.Zero) }
|
||||
val density = LocalDensity.current
|
||||
val screen = LocalConfiguration.current
|
||||
val panelWidthPx = with(density) { PANEL_WIDTH.toPx() }
|
||||
val screenWidthPx = screen.screenWidthDp * density.density
|
||||
val screenHeightPx = screen.screenHeightDp * density.density
|
||||
val atTop = keyY > 0.45f
|
||||
val button = shape <= SCREEN_KEY_RECT
|
||||
val directions = screenKeyDirections(shape)
|
||||
val pickImage = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
if (uri != null) importImage(context, uri, codes)?.let(onImage)
|
||||
}
|
||||
Box(
|
||||
Modifier.fillMaxSize().systemBarsPadding().padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
contentAlignment = if (atTop) Alignment.TopCenter else Alignment.BottomCenter,
|
||||
) {
|
||||
Surface(
|
||||
Modifier.widthIn(max = PANEL_WIDTH).fillMaxWidth()
|
||||
.offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) },
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = p.background, contentColor = p.onBackground, border = BorderStroke(1.dp, p.line2),
|
||||
) {
|
||||
Column(Modifier.padding(start = 10.dp, end = 6.dp, top = 2.dp, bottom = 6.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.DragIndicator, null,
|
||||
tint = p.onBackground.copy(alpha = 0.45f),
|
||||
modifier = Modifier.pointerInput(panelWidthPx, screenWidthPx, screenHeightPx) {
|
||||
detectDragGestures { change, drag ->
|
||||
change.consume()
|
||||
offset = Offset(
|
||||
(offset.x + drag.x).coerceIn(
|
||||
-(screenWidthPx - panelWidthPx) / 2f + 8f,
|
||||
(screenWidthPx - panelWidthPx) / 2f - 8f,
|
||||
),
|
||||
(offset.y + drag.y).coerceIn(
|
||||
-screenHeightPx * if (atTop) 0f else 0.75f,
|
||||
screenHeightPx * if (atTop) 0.75f else 0f,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
Box(
|
||||
Modifier.weight(1f).height(36.dp)
|
||||
.background(p.line2.copy(alpha = 0.24f), RoundedCornerShape(9.dp))
|
||||
.padding(horizontal = 10.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
BasicTextField(
|
||||
value = text,
|
||||
onValueChange = { onText(it.take(24)) },
|
||||
singleLine = true,
|
||||
textStyle = TextStyle(
|
||||
color = p.onBackground, fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
cursorBrush = androidx.compose.ui.graphics.SolidColor(p.primary),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
modifier = Modifier.fillMaxWidth().onFocusChanged {
|
||||
if (it.isFocused) onTextFocus() else onTextBlur()
|
||||
}.focusGlide(),
|
||||
)
|
||||
if (text.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.screen_key_text),
|
||||
color = p.onBackground.copy(alpha = 0.4f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (button) {
|
||||
IconButton(onClick = onBind, modifier = Modifier.width(38.dp).focusGlide(CircleShape)) {
|
||||
Icon(
|
||||
Icons.Default.Keyboard, stringResource(R.string.screen_key_bind),
|
||||
tint = p.onBackground.copy(alpha = 0.8f),
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { pickImage.launch("image/*") }, modifier = Modifier.width(38.dp).focusGlide(CircleShape)) {
|
||||
Icon(
|
||||
Icons.Default.Image, stringResource(R.string.screen_key_image),
|
||||
tint = p.onBackground.copy(alpha = 0.8f),
|
||||
)
|
||||
}
|
||||
if (hasImage) IconButton(onClick = onClearImage, modifier = Modifier.width(34.dp).focusGlide(CircleShape)) {
|
||||
Icon(Icons.Default.Close, stringResource(R.string.screen_key_image_clear), tint = p.error)
|
||||
}
|
||||
IconButton(onClick = onRemove, modifier = Modifier.width(38.dp).focusGlide(CircleShape)) {
|
||||
Icon(Icons.Default.Delete, stringResource(R.string.common_delete), tint = p.error)
|
||||
}
|
||||
IconButton(onClick = onDismiss, modifier = Modifier.width(34.dp).focusGlide(CircleShape)) {
|
||||
Icon(Icons.Default.Close, null, tint = p.onBackground)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
listOf<Pair<Int?, Int>>(
|
||||
(if (button) SCREEN_KEY_PILL else null) to R.string.screen_key_type_button,
|
||||
SCREEN_KEY_TRIGGER to R.string.screen_key_shape_trigger,
|
||||
SCREEN_KEY_DPAD to R.string.touch_dpad,
|
||||
SCREEN_KEY_STICK to R.string.deck_surface_stick,
|
||||
).forEach { (value, label) ->
|
||||
val selected = if (value == null) button else shape == value
|
||||
Chip(stringResource(label), selected, Modifier.weight(1f)) {
|
||||
onShape(if (value == null) (if (button) shape else SCREEN_KEY_PILL) else value)
|
||||
}
|
||||
}
|
||||
Box(Modifier.width(1.dp).height(20.dp).background(p.line2))
|
||||
listOf(
|
||||
SCREEN_KEY_PILL to R.string.screen_key_shape_pill,
|
||||
SCREEN_KEY_ROUND to R.string.screen_key_shape_round,
|
||||
SCREEN_KEY_CIRCLE to R.string.screen_key_shape_circle,
|
||||
SCREEN_KEY_RECT to R.string.screen_key_shape_rect,
|
||||
).forEach { (value, label) ->
|
||||
Chip(
|
||||
stringResource(label), button && shape == value, Modifier.weight(1f), enabled = button,
|
||||
) { onShape(value) }
|
||||
}
|
||||
Box(Modifier.width(1.dp).height(20.dp).background(p.line2))
|
||||
Text(
|
||||
stringResource(R.string.screen_key_toggle), Modifier.padding(start = 6.dp, end = 2.dp),
|
||||
style = MaterialTheme.typography.bodySmall, maxLines = 1,
|
||||
)
|
||||
Switch(checked = toggle, onCheckedChange = onToggle, modifier = Modifier.height(30.dp).focusGlide())
|
||||
}
|
||||
if (directions.isNotEmpty()) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
directions.forEachIndexed { index, arrow ->
|
||||
val code = codes.getOrNull(index) ?: 0
|
||||
Chip(
|
||||
if (code == 0) arrow else "$arrow ${pcKeyLabel(context, code)}",
|
||||
code != 0, Modifier.weight(1f),
|
||||
) { onDirection(index) }
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.screen_key_text), Modifier.padding(start = 6.dp, end = 2.dp),
|
||||
style = MaterialTheme.typography.bodySmall, maxLines = 1,
|
||||
)
|
||||
Switch(checked = labels, onCheckedChange = onLabels, modifier = Modifier.height(30.dp).focusGlide())
|
||||
}
|
||||
}
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(top = 2.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
KeySlider(
|
||||
label = stringResource(R.string.screen_key_size),
|
||||
value = "$size",
|
||||
current = size.toFloat(),
|
||||
min = ScreenKeys.MIN_SIZE.toFloat(),
|
||||
max = ScreenKeys.MAX_SIZE.toFloat(),
|
||||
modifier = Modifier.weight(1f),
|
||||
) { onResize(it.roundToInt()) }
|
||||
KeySlider(
|
||||
label = stringResource(R.string.screen_key_opacity),
|
||||
value = "${(opacity * 100).roundToInt()}%",
|
||||
current = opacity,
|
||||
min = 0.2f,
|
||||
max = 1f,
|
||||
modifier = Modifier.weight(1f),
|
||||
onChange = onOpacity,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KeySlider(
|
||||
label: String,
|
||||
value: String,
|
||||
current: Float,
|
||||
min: Float,
|
||||
max: Float,
|
||||
modifier: Modifier,
|
||||
onChange: (Float) -> Unit,
|
||||
) {
|
||||
Row(modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(label, style = MaterialTheme.typography.bodySmall, maxLines = 1)
|
||||
Slider(current, onChange, Modifier.weight(1f).height(28.dp).focusGlide(), valueRange = min..max)
|
||||
Text(
|
||||
value, Modifier.width(32.dp), style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.SemiBold, maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Chip(
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val p = LocalPalette.current
|
||||
Box(
|
||||
modifier
|
||||
.background(
|
||||
if (selected) p.primary.copy(alpha = 0.18f) else p.line2.copy(alpha = 0.28f),
|
||||
CircleShape,
|
||||
)
|
||||
.focusGlide(RoundedCornerShape(8.dp)).clickable(enabled = enabled, onClick = onClick)
|
||||
.padding(vertical = 6.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
label, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = when {
|
||||
!enabled -> p.onBackground.copy(alpha = 0.28f)
|
||||
selected -> p.primary
|
||||
else -> p.onBackground.copy(alpha = 0.7f)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun importImage(context: Context, uri: Uri, codes: List<Int>): String? {
|
||||
val dir = File(context.filesDir, ScreenKeys.IMAGE_DIR).apply { mkdirs() }
|
||||
val name = "key_" + codes.joinToString("_") + "_" + System.currentTimeMillis() + ".png"
|
||||
val longest = (ScreenKeys.MAX_SIZE * context.resources.displayMetrics.density).toInt()
|
||||
return runCatching {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) }
|
||||
val source = maxOf(bounds.outWidth, bounds.outHeight)
|
||||
if (source <= 0) return@runCatching null
|
||||
var sample = 1
|
||||
while (source / (sample * 2) >= longest) sample *= 2
|
||||
val bitmap = context.contentResolver.openInputStream(uri)?.use {
|
||||
BitmapFactory.decodeStream(it, null, BitmapFactory.Options().apply { inSampleSize = sample })
|
||||
} ?: return@runCatching null
|
||||
val scale = longest.toFloat() / maxOf(bitmap.width, bitmap.height)
|
||||
val scaled = if (scale < 1f) {
|
||||
Bitmap.createScaledBitmap(
|
||||
bitmap,
|
||||
(bitmap.width * scale).toInt().coerceAtLeast(1),
|
||||
(bitmap.height * scale).toInt().coerceAtLeast(1),
|
||||
true,
|
||||
)
|
||||
} else {
|
||||
bitmap
|
||||
}
|
||||
try {
|
||||
val saved = File(dir, name).outputStream().use { scaled.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||
if (saved) name else null
|
||||
} finally {
|
||||
if (scaled !== bitmap) bitmap.recycle()
|
||||
scaled.recycle()
|
||||
}
|
||||
}.getOrNull().also { if (it == null) File(dir, name).delete() }
|
||||
}
|
||||
+4
-7
@@ -52,16 +52,13 @@ import com.droiddeck.launcher.input.PcKey
|
||||
import com.droiddeck.launcher.input.pcKeyLabel
|
||||
|
||||
@Composable
|
||||
fun KeyCombinationPicker(
|
||||
fun ScreenKeyPicker(
|
||||
onDismiss: () -> Unit,
|
||||
onAdd: (List<Int>) -> Unit,
|
||||
initial: List<Int> = emptyList(),
|
||||
sequence: Boolean = false,
|
||||
sequenceLimit: Int = 4,
|
||||
allowEmpty: Boolean = false,
|
||||
slots: List<String> = emptyList(),
|
||||
title: String = stringResource(R.string.input_keys),
|
||||
confirm: String = stringResource(R.string.gesture_save_changes),
|
||||
) {
|
||||
var picked by remember { mutableStateOf(initial) }
|
||||
val p = LocalPalette.current
|
||||
@@ -89,7 +86,7 @@ fun KeyCombinationPicker(
|
||||
) {
|
||||
Column {
|
||||
Row(Modifier.fillMaxWidth().padding(start = 16.dp, end = 6.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(title, Modifier.weight(1f), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(stringResource(R.string.screen_keys_add), Modifier.weight(1f), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
IconButton(onClick = onDismiss, modifier = Modifier.focusGlide(CircleShape)) { Icon(Icons.Default.Close, null) }
|
||||
}
|
||||
Row(
|
||||
@@ -139,10 +136,10 @@ fun KeyCombinationPicker(
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = { onAdd(picked) },
|
||||
enabled = allowEmpty || picked.isNotEmpty(),
|
||||
enabled = picked.isNotEmpty(),
|
||||
modifier = Modifier.weight(1f).focusGlide(CircleShape),
|
||||
) {
|
||||
Text(confirm)
|
||||
Text(stringResource(R.string.screen_keys_add))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.droiddeck.launcher.ui.GlideDialog as Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
@Composable
|
||||
fun ScreenLayoutPanel(
|
||||
layouts: List<String>,
|
||||
onSave: (String) -> Unit,
|
||||
onLoad: (String) -> Unit,
|
||||
onDelete: (String) -> Unit,
|
||||
onImport: (String) -> Unit,
|
||||
exportText: () -> String,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val p = LocalPalette.current
|
||||
val context = LocalContext.current
|
||||
var naming by remember { mutableStateOf(false) }
|
||||
var name by remember { mutableStateOf("") }
|
||||
|
||||
val importer = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
val text = runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes().toString(Charsets.UTF_8) }
|
||||
}.getOrNull()
|
||||
if (text != null) onImport(text)
|
||||
}
|
||||
val exporter = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
runCatching { context.contentResolver.openOutputStream(uri)?.use { it.write(exportText().toByteArray()) } }
|
||||
}
|
||||
|
||||
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
|
||||
Box(Modifier.fillMaxSize().systemBarsPadding().padding(16.dp), contentAlignment = Alignment.Center) {
|
||||
Surface(
|
||||
Modifier.widthIn(max = 520.dp).fillMaxWidth(), shape = RoundedCornerShape(22.dp),
|
||||
color = p.background, contentColor = p.onBackground, border = BorderStroke(1.dp, p.line2),
|
||||
) {
|
||||
Column(Modifier.padding(start = 20.dp, end = 8.dp, top = 6.dp, bottom = 12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
stringResource(R.string.screen_key_layout), Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
IconButton(onClick = onDismiss, modifier = Modifier.focusGlide(CircleShape)) { Icon(Icons.Default.Close, null) }
|
||||
}
|
||||
Row(Modifier.fillMaxWidth().padding(end = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextButton(onClick = { naming = true }, modifier = Modifier.focusGlide(CircleShape)) { Text(stringResource(R.string.screen_key_layout_save)) }
|
||||
TextButton(onClick = { importer.launch(arrayOf("application/json", "text/plain", "*/*")) }, modifier = Modifier.focusGlide(CircleShape)) {
|
||||
Text(stringResource(R.string.boot_movie_import))
|
||||
}
|
||||
TextButton(onClick = { exporter.launch("droiddeck-layout.icp") }, modifier = Modifier.focusGlide(CircleShape)) {
|
||||
Text(stringResource(R.string.screen_key_layout_export))
|
||||
}
|
||||
}
|
||||
Column(Modifier.padding(end = 12.dp).heightIn(max = 320.dp).verticalScroll(rememberScrollState())) {
|
||||
layouts.forEach { layout ->
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
layout, Modifier.weight(1f).clickable { onLoad(layout) }.padding(vertical = 14.dp),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
TextButton(onClick = { onDelete(layout) }, modifier = Modifier.focusGlide(CircleShape)) {
|
||||
Text(stringResource(R.string.common_delete), color = p.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (naming) {
|
||||
Dialog(onDismissRequest = { naming = false }, properties = DialogProperties(usePlatformDefaultWidth = false)) {
|
||||
Box(Modifier.fillMaxSize().systemBarsPadding().padding(16.dp), contentAlignment = Alignment.Center) {
|
||||
Surface(
|
||||
Modifier.widthIn(max = 520.dp).fillMaxWidth(), shape = RoundedCornerShape(22.dp),
|
||||
color = p.background, contentColor = p.onBackground, border = BorderStroke(1.dp, p.line2),
|
||||
) {
|
||||
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedTextField(
|
||||
name, { name = it.take(24) }, Modifier.fillMaxWidth().focusGlide(), singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
TextButton(onClick = { naming = false }, modifier = Modifier.focusGlide(CircleShape)) { Text(stringResource(R.string.common_cancel)) }
|
||||
Button(onClick = { onSave(name.trim()); naming = false }, enabled = name.isNotBlank(), modifier = Modifier.focusGlide(CircleShape)) {
|
||||
Text(stringResource(R.string.screen_key_layout_save))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,19 +18,20 @@ import androidx.activity.setViewTreeOnBackPressedDispatcherOwner
|
||||
import androidx.lifecycle.setViewTreeLifecycleOwner
|
||||
import androidx.lifecycle.setViewTreeViewModelStoreOwner
|
||||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||
import com.droiddeck.launcher.input.DeckPanel
|
||||
import com.droiddeck.launcher.input.KeyboardHost
|
||||
import com.droiddeck.launcher.input.PadBridge
|
||||
import com.droiddeck.launcher.input.SecondScreenMode
|
||||
import com.droiddeck.launcher.SessionActivity
|
||||
import com.droiddeck.launcher.session.BottomDesktop
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
|
||||
class SecondScreenPresentation(
|
||||
private val sessionActivity: SessionActivity,
|
||||
display: Display,
|
||||
private val sendPointer: (x: Float, y: Float, width: Float, height: Float) -> Unit,
|
||||
private val sendButton: (button: Int, pressed: Boolean) -> Unit,
|
||||
private val sendWheel: (steps: Int) -> Unit,
|
||||
private val onSteamMenu: () -> Unit,
|
||||
private val onQam: () -> Unit,
|
||||
private val onClose: () -> Unit,
|
||||
@@ -41,7 +42,6 @@ class SecondScreenPresentation(
|
||||
private val dashboard: (@androidx.compose.runtime.Composable () -> Unit)? = null,
|
||||
private val onSwap: (() -> Unit)? = null,
|
||||
private val onMenuTouched: () -> Unit = {},
|
||||
private val onDeckPanel: (DeckPanel) -> Unit = { SessionPrefs.setDeckPanel(sessionActivity, it) },
|
||||
) : Presentation(sessionActivity, display) {
|
||||
private val frame = FrameLayout(context)
|
||||
private var menuView: androidx.compose.ui.platform.ComposeView? = null
|
||||
@@ -58,12 +58,10 @@ class SecondScreenPresentation(
|
||||
private var currentMode = SecondScreenMode.NONE
|
||||
private var keyboardHost: KeyboardHost? = null
|
||||
private val keyboardShown = androidx.compose.runtime.mutableStateOf(false)
|
||||
private val deckPanel = androidx.compose.runtime.mutableStateOf(DeckPanel.BOTH)
|
||||
private var keyboardPending = false
|
||||
private var imeSeen = false
|
||||
private var fieldKeyboard = false
|
||||
private var terminalPanel: SessionTerminalPanel? = null
|
||||
private var controlsView: InputSurfaceView? = null
|
||||
private var created = false
|
||||
|
||||
init {
|
||||
@@ -163,7 +161,7 @@ class SecondScreenPresentation(
|
||||
view.disposeComposition()
|
||||
}
|
||||
|
||||
val showsKeyboard: Boolean get() = isShowing && !menuShown && currentMode.controls
|
||||
val showsKeyboard: Boolean get() = isShowing && !menuShown && currentMode == SecondScreenMode.KEYBOARD_TRACKPAD
|
||||
|
||||
val showsControls: Boolean get() = isShowing && currentMode != SecondScreenMode.NONE
|
||||
|
||||
@@ -216,7 +214,6 @@ class SecondScreenPresentation(
|
||||
dropChat()
|
||||
keyboardHost?.hide()
|
||||
keyboardHost = null
|
||||
controlsView = null
|
||||
dismiss()
|
||||
}
|
||||
|
||||
@@ -239,21 +236,6 @@ class SecondScreenPresentation(
|
||||
return super.dispatchGenericMotionEvent(event)
|
||||
}
|
||||
|
||||
fun refreshControls() {
|
||||
if (!created || !currentMode.controls) return
|
||||
val controls = controlsView ?: return
|
||||
val layout = panelLayout(currentMode)
|
||||
controls.updateCustomLayout(layout?.elements.orEmpty())
|
||||
controls.updateStyle(layout?.style ?: com.droiddeck.launcher.input.InputSurfaceStyle())
|
||||
}
|
||||
|
||||
fun showKeyboard(): Boolean {
|
||||
val host = keyboardHost ?: return false
|
||||
host.toggle()
|
||||
keyboardShown.value = host.shown
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
||||
super.onWindowFocusChanged(hasFocus)
|
||||
if (hasFocus) {
|
||||
@@ -274,29 +256,19 @@ class SecondScreenPresentation(
|
||||
closeTerminal()
|
||||
closeDashboard()
|
||||
root.removeAllViews()
|
||||
controlsView = null
|
||||
ownComposeViews()
|
||||
val requested = currentMode
|
||||
val mode = when {
|
||||
requested == SecondScreenMode.DECK_CONTROLS && !SessionState.deckPad -> SecondScreenMode.NONE
|
||||
requested == SecondScreenMode.DASHBOARD && dashboard == null -> SecondScreenMode.NONE
|
||||
requested == SecondScreenMode.DESKTOP && !BottomDesktop.available() -> SecondScreenMode.NONE
|
||||
else -> requested
|
||||
currentMode == SecondScreenMode.DECK_CONTROLS && !SessionState.deckPad -> SecondScreenMode.NONE
|
||||
currentMode == SecondScreenMode.DASHBOARD && dashboard == null -> SecondScreenMode.NONE
|
||||
currentMode == SecondScreenMode.DESKTOP && !BottomDesktop.available() -> SecondScreenMode.NONE
|
||||
else -> currentMode
|
||||
}
|
||||
if (mode == SecondScreenMode.NONE) return
|
||||
|
||||
when (mode) {
|
||||
SecondScreenMode.KEYBOARD_TRACKPAD -> renderKeyboardAndTrackpad()
|
||||
SecondScreenMode.TERMINAL -> renderTerminal()
|
||||
SecondScreenMode.CUSTOM_CONTROLS, SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.DECK_CONTROLS -> {
|
||||
if (mode == SecondScreenMode.DECK_CONTROLS) deckPanel.value = SessionPrefs.deckPanel(context)
|
||||
bar(mode)
|
||||
val layout = panelLayout(mode)
|
||||
val controls = InputSurfaceView(context, pad, layout?.elements.orEmpty(),
|
||||
surfaceStyle = layout?.style ?: com.droiddeck.launcher.input.InputSurfaceStyle())
|
||||
controlsView = controls
|
||||
root.addView(controls, weightParams())
|
||||
attachKeyboard()
|
||||
}
|
||||
SecondScreenMode.DECK_CONTROLS -> root.addView(DeckControlsPanel(context, onClose, onSteamMenu, onQam, pad, onMenu = onMenu, onSwap = onSwap), weightParams())
|
||||
SecondScreenMode.DASHBOARD -> renderDashboard()
|
||||
SecondScreenMode.DESKTOP -> {
|
||||
root.addView(BottomDesktopPanel(context, onClose, onMenu,
|
||||
@@ -306,40 +278,17 @@ class SecondScreenPresentation(
|
||||
}
|
||||
}
|
||||
|
||||
private fun panelLayout(mode: SecondScreenMode) = com.droiddeck.launcher.input.InputLibraries.read(context)
|
||||
.panelLayout(SessionState.mode == SessionService.MODE_DESKTOP, mode.panel(context))
|
||||
|
||||
private fun bar(mode: SecondScreenMode) {
|
||||
val first = barModel(mode) ?: return
|
||||
root.addView(bottomScreenBarView(context) { barModel(mode) ?: first }, LinearLayout.LayoutParams(-1, -2))
|
||||
}
|
||||
|
||||
internal fun barModel(mode: SecondScreenMode): BottomBarModel? {
|
||||
val swap = listOfNotNull(onSwap?.let { BarExtra(context.getString(R.string.swap_screens), icon = R.drawable.ic_bottom_swap, tag = "bottom-swap", onClick = it) })
|
||||
fun model(extras: List<BarExtra>, tabs: BarTabs? = null) =
|
||||
sessionBarModel(context.getString(mode.label), onClose, onMenu, onSteamMenu, onQam, extras, tabs)
|
||||
return when (mode) {
|
||||
SecondScreenMode.KEYBOARD_TRACKPAD -> model(listOf(BarExtra(
|
||||
context.getString(if (keyboardShown.value) R.string.second_hide_keyboard else R.string.second_show_keyboard),
|
||||
icon = R.drawable.ic_bottom_keyboard, active = keyboardShown.value, tag = "bottom-keyboard") { showKeyboard() }))
|
||||
SecondScreenMode.DECK_CONTROLS -> model(swap, deckPanelTabs(context, deckPanel.value, ::selectDeckPanel))
|
||||
SecondScreenMode.DASHBOARD, SecondScreenMode.CUSTOM_CONTROLS -> model(swap)
|
||||
SecondScreenMode.TERMINAL -> model(listOf(BarExtra(context.getString(R.string.drawer_keyboard), icon = R.drawable.ic_bottom_keyboard,
|
||||
tag = "bottom-keyboard") { terminalPanel?.showKeyboard() }))
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectDeckPanel(panel: DeckPanel) {
|
||||
if (deckPanel.value == panel) return
|
||||
deckPanel.value = panel
|
||||
onDeckPanel(panel)
|
||||
refreshControls()
|
||||
private fun bar(mode: SecondScreenMode, extras: () -> List<BarExtra> = { emptyList() }) {
|
||||
root.addView(bottomScreenBarView(context) {
|
||||
sessionBarModel(context.getString(mode.label), onClose, onMenu, onSteamMenu, onQam, extras())
|
||||
}, LinearLayout.LayoutParams(-1, -2))
|
||||
}
|
||||
|
||||
private fun renderDashboard() {
|
||||
val content = dashboard ?: return
|
||||
bar(SecondScreenMode.DASHBOARD)
|
||||
bar(SecondScreenMode.DASHBOARD) {
|
||||
listOfNotNull(onSwap?.let { BarExtra(context.getString(R.string.swap_screens), icon = R.drawable.ic_bottom_swap, tag = "bottom-swap", onClick = it) })
|
||||
}
|
||||
val view = androidx.compose.ui.platform.ComposeView(context).apply { setContent { content() } }
|
||||
dashboardView = view
|
||||
root.addView(view, weightParams())
|
||||
@@ -350,7 +299,17 @@ class SecondScreenPresentation(
|
||||
dashboardView = null
|
||||
}
|
||||
|
||||
private fun attachKeyboard() {
|
||||
private fun renderKeyboardAndTrackpad() {
|
||||
bar(SecondScreenMode.KEYBOARD_TRACKPAD) {
|
||||
listOf(BarExtra(context.getString(if (keyboardShown.value) R.string.second_hide_keyboard else R.string.second_show_keyboard),
|
||||
icon = R.drawable.ic_bottom_keyboard, active = keyboardShown.value, tag = "bottom-keyboard") {
|
||||
keyboardHost?.let { host ->
|
||||
host.toggle()
|
||||
keyboardShown.value = host.shown
|
||||
}
|
||||
})
|
||||
}
|
||||
root.addView(trackpadView(), weightParams())
|
||||
val host = KeyboardHost(context) { event ->
|
||||
val handled = sessionActivity.dispatchKeyEvent(event)
|
||||
if (fieldKeyboard && event.action == KeyEvent.ACTION_UP &&
|
||||
@@ -362,8 +321,14 @@ class SecondScreenPresentation(
|
||||
root.addView(host, ViewGroup.LayoutParams(1, 1))
|
||||
}
|
||||
|
||||
private fun trackpadView(): View = SessionTrackpadView(context, sendPointer, sendButton, sendWheel)
|
||||
|
||||
private fun renderTerminal() {
|
||||
bar(SecondScreenMode.TERMINAL)
|
||||
bar(SecondScreenMode.TERMINAL) {
|
||||
listOf(BarExtra(context.getString(R.string.drawer_keyboard), icon = R.drawable.ic_bottom_keyboard, tag = "bottom-keyboard") {
|
||||
terminalPanel?.showKeyboard()
|
||||
})
|
||||
}
|
||||
val panel = SessionTerminalPanel(context)
|
||||
terminalPanel = panel
|
||||
root.addView(panel, weightParams())
|
||||
|
||||
Loaded 100 of 277 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user