Compare commits

..
219 changed files with 6660 additions and 16779 deletions

No files matched your search

+6 -27
View File
@@ -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
@@ -325,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 }}
@@ -785,18 +771,11 @@ jobs:
cd app/src/main/rust/stores
cargo test --locked 2>&1 | tail -60
python-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 }}
python-tests:
name: Python and native regression tests
needs: [source, python-runner]
needs: source
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ${{ fromJSON(needs.python-runner.outputs.labels) }}
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
-38
View File
@@ -1,38 +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 || '"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"
-1
View File
@@ -94,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).
+8
View File
@@ -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"
+5 -9
View File
@@ -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"))
@@ -63,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
@@ -200,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")
@@ -624,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 {
@@ -1084,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
@@ -1112,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)
@@ -1159,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,
@@ -1179,7 +1175,6 @@ class FrontEndActivity : ComponentActivity() {
dualScreenMode = dualScreenMode,
dualScreenSessions = dualScreenSessions,
dualScreenControls = dualScreenControls,
androidTextFieldKeyboard = androidTextFieldKeyboard,
packages = packageRows,
packageCatalogLoading = catalogLoading,
packageBusyId = pkgId,
@@ -1202,7 +1197,6 @@ class FrontEndActivity : ComponentActivity() {
downloadPrefix = downloadPrefix,
downloadProxy = downloadProxy,
animationsEnabled = animationsEnabled,
lowStorageWarning = lowStorageWarning,
launcherIcon = launcherIcon,
gameStoresEnabled = gameStoresEnabled,
gameStoresSpeedTier = gameStoresSpeedTier,
@@ -1406,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)
@@ -1511,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
@@ -1618,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,
@@ -1668,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 },
@@ -1765,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()
}
@@ -2047,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()
@@ -2140,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) {
@@ -2224,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,
@@ -2435,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")
@@ -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)),
)
@@ -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
@@ -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
}
}
}
}
@@ -169,6 +169,56 @@ 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()
}
@@ -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)
}
@@ -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,383 +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.JSONArray
import org.json.JSONObject
import java.util.UUID
object InputLibraries {
private const val VERSION = 12
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 < 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 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))
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))
}
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,72 +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 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,121 +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 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,123 +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
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,350 +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): 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(InputControl.LS, GamepadGeometry.Slot.LEFT_MAIN, InputButtonShape.CIRCLE)
cross(DPAD_ORDER, GamepadGeometry.Slot.LEFT_SECOND)
box(InputControl.RS, 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) }
return withGeometry(stored, placed) + placed.filter { p -> GRIPS.any { it.first == p.control } && 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,119 +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 }, 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()
}
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,167 +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 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()
}
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(if (isQuickHidden) elements.filter { it.id == "builtin:keyboard" } else elements)
val style = layout?.style ?: InputSurfaceStyle()
surface.updateStyle(style)
quickToggle.tint = style.tint
quickToggle.alpha = style.opacity / 100f
quickToggle.visibility = if (isButtonsOnly || 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,84 +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)
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)
if (buttonsOnly && dock == null) return generator.buttonsOnly(stored)
val (reference, generated) = reference(context, layout, width, height)
val gamepad = if (generated) stored.filter(::isGamepad) else emptyList()
val others = fit(stored.filterNot { generated && isGamepad(it) }, reference.first, reference.second, width, height, safe)
val placed = when {
gamepad.isEmpty() -> emptyList()
dock != null -> generator.dock(gamepad, dock.portrait, dock.grips)
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,
) {
}
@@ -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
@@ -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
}
}
}
@@ -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,
@@ -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) {
@@ -1559,13 +1548,6 @@ object SessionPrefs {
prefs(context).edit().putString(if (right) "gamepadRight" else "gamepadLeft", surface).apply()
}
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 suspendPolicy(context: Context, mode: String): String =
prefs(context).getString("suspendPolicy.${prefMode(mode)}",
if (mode == SessionService.MODE_STEAM) SUSPEND_NATIVE else SUSPEND_MANUAL)
@@ -1594,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 }
@@ -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,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.() -> 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)
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,69 +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()
}
}
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() }
}
}
@@ -1,79 +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, 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:"))
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 = { if (!editing.click) elementProperties?.let { e -> InputElementProperties(e) { 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,377 +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) {
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(true)
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)
}
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) { revision++ }
}
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)
}
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() }
}
// 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 = {
if (!draft.clickProperties) InputElementProperties(properties) { 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) {
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 })
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,758 +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.InputButtonIcons
import com.droiddeck.launcher.input.InputButtonShape
import com.droiddeck.launcher.input.InputButtonImages
import com.droiddeck.launcher.input.PadBridge
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, 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)
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)
}
@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 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 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) 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 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()
}
}
}
}
@@ -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() }
}
@@ -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 -> 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())
@@ -1,9 +1,9 @@
package com.droiddeck.launcher.ui
internal enum class SessionDrawerPage(val firstControl: String) {
CONTROLLER("input-profile"),
CONTROLLER("touch"),
GLASSES("glasses-game"),
SECOND_SCREEN("second-screen:custom-controls"),
SECOND_SCREEN("second-screen:keyboard-trackpad"),
DISPLAY("fg"),
HUD("hud"),
EFFECTS("look"),
@@ -209,7 +209,6 @@ private class DrawerFocus {
class DrawerActions(
val steam: Boolean,
val desktop: Boolean,
val direct: Boolean = false,
val title: String? = null,
val isHomeApp: Boolean,
@@ -276,6 +275,9 @@ class DrawerActions(
val onEditSteamTouch: (() -> Unit)? = null,
val controller: com.droiddeck.launcher.input.ControllerPrefs.Settings? = null,
val onRumble: (Boolean) -> Unit = {},
val onSteamButton: (Boolean) -> Unit = {},
val onQamButton: (Boolean) -> Unit = {},
val onKeyboardButton: (Boolean) -> Unit = {},
val onSuspendPolicy: (String) -> Unit,
val onSteamDownloadsInBackground: (Boolean) -> Unit = {},
val onBackActionsInverted: (Boolean) -> Unit,
@@ -288,12 +290,14 @@ class DrawerActions(
val directCursor: Boolean = false,
val onDirectCursor: (Boolean) -> Unit = {},
val onTouch: (String) -> Unit,
val onInputSettings: () -> Unit = {},
val onInputSelection: (com.droiddeck.launcher.input.InputRole?) -> Unit = {},
val onGestureSettings: () -> Unit = {},
val touchActive: String = "",
val directHoldBehavior: String = MOUSE_BEHAVIOR_CLICK,
val onDirectHoldBehavior: (String) -> Unit = {},
val onScreenKeys: (() -> Unit)? = null,
val onScreenLayouts: (() -> Unit)? = null,
val screenKeysShown: Boolean = true,
val onScreenKeysShown: (Boolean) -> Unit = {},
val onFexPreset: (String) -> Unit,
val onUpscaler: (Int) -> Unit = {},
val onUpscaleSharpness: (Int) -> Unit = {},
@@ -314,10 +318,6 @@ class DrawerActions(
val onPause: () -> Unit = {},
val onPipAutoEnter: (Boolean) -> Unit = {},
val unfoldedControls: Boolean? = null,
val adaptiveSticks: Boolean? = null,
val onAdaptiveSticks: (Boolean) -> Unit = {},
val quickButtons: Map<com.droiddeck.launcher.input.InputCommand, Boolean> = emptyMap(),
val onQuickButton: (com.droiddeck.launcher.input.InputCommand, Boolean) -> Unit = { _, _ -> },
val onUnfoldedControls: (Boolean) -> Unit = {},
val unfoldedControlsWithController: Boolean = false,
val onUnfoldedControlsWithController: (Boolean) -> Unit = {},
@@ -325,6 +325,7 @@ class DrawerActions(
val onStop: () -> Unit,
val onStopFrom: ((androidx.compose.ui.geometry.Rect?) -> Unit)? = null,
val onClose: () -> Unit,
val onDismissScreen: (() -> Unit)? = null,
val components: ComponentsManager.Snapshot? = null,
val systemPowerVr: Boolean = false,
val onComponentsRefresh: () -> Unit = {},
@@ -488,7 +489,7 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
) {
SteamActionPill(
stringResource(R.string.common_close), Icons.Outlined.Close, focus.track(page, "close"),
) { host.open = null; a.onClose() }
) { host.open = null; (a.onDismissScreen ?: a.onClose)() }
if (roomyHeader) Spacer(Modifier.weight(1f))
SteamActionPill(
stringResource(R.string.stores_dl_pause), Icons.Outlined.Pause, focus.track(page, "pause"),
@@ -594,11 +595,8 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
}
SessionDrawerPage.CONTROLLER -> {
SettingsGroup(stringResource(R.string.drawer_page_controller)) {
InputQuickSettings(host, desktop = a.desktop, onChanged = a.onInputSelection, onEdit = a.onInputSettings) { key -> focus.track(page, key) }
}
SettingsGroup(stringResource(R.string.input_device_settings)) {
a.padType?.let { type ->
ChoiceRow(host, "pad-type", stringResource(R.string.input_virtual_controller), stringResource(R.string.input_virtual_controller_hint),
ChoiceRow(host, "pad-type", stringResource(R.string.mode_controller), null,
listOf(SessionPrefs.CONTROLLER_DECK to stringResource(R.string.mode_controller_deck),
SessionPrefs.CONTROLLER_STEAM to stringResource(R.string.mode_controller_steam),
SessionPrefs.CONTROLLER_DS4 to stringResource(R.string.mode_controller_ds4),
@@ -606,52 +604,75 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
SessionPrefs.CONTROLLER_XBOX360 to stringResource(R.string.mode_controller_x360)),
type, chipModifier = focus.track(page, "pad-type"), onPick = a.onPadType)
}
a.controller?.let { c ->
ToggleRow(host, "rumble", stringResource(R.string.ctrl_rumble), null, c.rumble,
chipModifier = focus.track(page, "rumble"), onChange = a.onRumble)
}
}
SettingsGroup(stringResource(R.string.input_touchscreen)) {
ChoiceRow(host, "touch", stringResource(R.string.input_touchscreen), stringResource(R.string.input_touchscreen_hint),
ChoiceRow(host, "touch", stringResource(R.string.mode_touch), null,
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.drawer_touch_auto, a.touchAuto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_GESTURES to stringResource(R.string.mode_touch_gestures), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)),
a.touchMode, chipModifier = focus.track(page, "touch"), onPick = a.onTouch)
if (a.touchActive == SessionPrefs.TOUCH_PAD)
RelativeMouseRow(host, a, focus.track(page, "relative-mouse"))
if (a.touchActive == SessionPrefs.TOUCH_DIRECT)
ToggleRow(host, "direct-cursor", stringResource(R.string.input_touch_cursor),
stringResource(R.string.input_touch_cursor_hint), a.directCursor,
ToggleRow(host, "direct-cursor", stringResource(R.string.drawer_direct_cursor),
null, a.directCursor,
chipModifier = focus.track(page, "direct-cursor"), onChange = a.onDirectCursor)
when (a.touchActive) {
SessionPrefs.TOUCH_GESTURES -> ActionRow(stringResource(R.string.gesture_settings), null,
stringResource(R.string.ctrl_edit), a.onGestureSettings,
modifier = focus.track(page, "gesture-settings"))
SessionPrefs.TOUCH_DIRECT -> ChoiceRow(host, "long-press", stringResource(R.string.input_touch_hold), stringResource(R.string.input_touch_hold_hint),
SessionPrefs.TOUCH_DIRECT -> ChoiceRow(host, "long-press", stringResource(R.string.gesture_long_press), null,
listOf(MOUSE_BEHAVIOR_NONE to stringResource(R.string.gesture_hold_left),
MOUSE_BEHAVIOR_CLICK to stringResource(R.string.gesture_right_click),
MOUSE_BEHAVIOR_HOLD to stringResource(R.string.gesture_hold_right)),
a.directHoldBehavior, chipModifier = focus.track(page, "long-press"),
onPick = a.onDirectHoldBehavior)
}
}
SettingsGroup(stringResource(R.string.input_screen_visibility)) {
if (a.onScreenKeys != null) {
SettingsRow(stringResource(R.string.screen_keys), null, stacked = narrow) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
SecondaryButton(stringResource(R.string.ctrl_edit), enabled = a.screenKeysShown,
modifier = focus.track(page, "screen-keys"), onClick = a.onScreenKeys)
a.onScreenLayouts?.let { open ->
SecondaryButton(stringResource(R.string.screen_key_layout),
modifier = focus.track(page, "screen-key-layouts"), onClick = open)
}
ToggleSwitch(a.screenKeysShown, true,
modifier = focus.track(page, "screen-keys-shown"), onChange = a.onScreenKeysShown)
}
}
}
ChoiceRow(host, "osc", stringResource(R.string.mode_osc), null,
if (a.steam) 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))
else listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never)),
if (!a.steam && a.oscMode == SessionPrefs.OSC_STEAM_TOUCH) SessionPrefs.OSC_AUTO else a.oscMode,
chipModifier = focus.track(page, "osc"), onPick = a.onOsc)
a.adaptiveSticks?.let { on ->
ToggleRow(host, "adaptive-sticks", stringResource(R.string.ctrl_adaptive), null, on,
chipModifier = focus.track(page, "adaptive-sticks"), onChange = a.onAdaptiveSticks)
}
for ((button, shown) in a.quickButtons) {
val key = "quick-${button.name.lowercase()}"
ToggleRow(host, key, stringResource(quickButtonLabel(button)), null, shown,
chipModifier = focus.track(page, key)) { a.onQuickButton(button, it) }
}
a.onEditSteamTouch?.let { edit ->
ActionRow(stringResource(R.string.osc_steam_layout), stringResource(R.string.osc_steam_layout_hint), stringResource(R.string.ctrl_edit), edit)
}
a.controller?.let { c ->
ToggleRow(host, "rumble", stringResource(R.string.ctrl_rumble), null, c.rumble,
chipModifier = focus.track(page, "rumble"), onChange = a.onRumble)
if (a.onScreenButtonsVisible) {
if (a.steam) {
ToggleRow(host, "steam-button", stringResource(R.string.ctrl_steam_button), null, c.steamButton,
chipModifier = focus.track(page, "steam-button"), onChange = a.onSteamButton)
ToggleRow(host, "qam-button", stringResource(R.string.ctrl_qam_button), null, c.qamButton,
chipModifier = focus.track(page, "qam-button"), onChange = a.onQamButton)
}
ToggleRow(host, "keyboard-button", stringResource(R.string.ctrl_keyboard_button), null, c.keyboardButton,
chipModifier = focus.track(page, "keyboard-button"), onChange = a.onKeyboardButton)
}
}
if (a.steam) ToggleRow(host, "back-hold", stringResource(R.string.back_hold),
stringResource(R.string.back_hold_hint), a.backHold,
chipModifier = focus.track(page, "back-hold"), onChange = a.onBackHold)
if (a.steam) ChoiceRow(host, "back-actions", stringResource(R.string.mode_back), null,
listOf(false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM), true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU)),
a.backActionsInverted, chipModifier = focus.track(page, "back-actions"), onPick = a.onBackActionsInverted)
if (a.steam) ChoiceRow(host, "back-double-tap", stringResource(R.string.back_double_tap_window), null,
SessionPrefs.BACK_DOUBLE_TAP_MS_OPTIONS.map { it to stringResource(R.string.back_tap_ms, it) },
a.backDoubleTapMs, chipModifier = focus.track(page, "back-double-tap"), onPick = a.onBackDoubleTapMs)
PhysicalShortcutRows(modifierFor = { focus.track(page, "physical-shortcut-${it.name}") })
if (a.steam && a.unfoldedControls != null) ToggleRow(host, "unfolded-controls", stringResource(R.string.unfolded_controls),
stringResource(R.string.unfolded_controls_hint), a.unfoldedControls,
chipModifier = focus.track(page, "unfolded-controls"), onChange = a.onUnfoldedControls)
@@ -661,18 +682,6 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
chipModifier = focus.track(page, "unfolded-controls-with-controller"),
onChange = a.onUnfoldedControlsWithController)
}
SettingsGroup(stringResource(R.string.shortcuts_title)) {
if (a.steam) ToggleRow(host, "back-hold", stringResource(R.string.back_hold),
stringResource(R.string.back_hold_hint), a.backHold,
chipModifier = focus.track(page, "back-hold"), onChange = a.onBackHold)
if (a.steam) ChoiceRow(host, "back-actions", stringResource(R.string.input_back_taps), stringResource(R.string.input_back_taps_hint),
listOf(false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM), true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU)),
a.backActionsInverted, chipModifier = focus.track(page, "back-actions"), onPick = a.onBackActionsInverted)
if (a.steam) ChoiceRow(host, "back-double-tap", stringResource(R.string.back_double_tap_window), null,
SessionPrefs.BACK_DOUBLE_TAP_MS_OPTIONS.map { it to stringResource(R.string.back_tap_ms, it) },
a.backDoubleTapMs, chipModifier = focus.track(page, "back-double-tap"), onPick = a.onBackDoubleTapMs)
PhysicalShortcutRows(modifierFor = { focus.track(page, "physical-shortcut-${it.name}") })
}
SettingsGroup(stringResource(R.string.drawer_latency)) {
ToggleRow(host, "low-latency", stringResource(R.string.drawer_low_latency),
stringResource(R.string.drawer_low_latency_hint), a.lowLatency,
@@ -1166,7 +1175,7 @@ private fun SteamVrRow(a: DrawerActions) {
internal fun secondScreenModes(none: Boolean, deckPad: Boolean, dashboard: Boolean, desktop: Boolean): List<SecondScreenMode> =
(if (none) listOf(SecondScreenMode.NONE) else emptyList()) + listOf(SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.TERMINAL) +
(if (deckPad) listOf(SecondScreenMode.DECK_CONTROLS) else emptyList()) + listOf(SecondScreenMode.CUSTOM_CONTROLS) +
(if (deckPad) listOf(SecondScreenMode.DECK_CONTROLS) else emptyList()) +
(if (dashboard) listOf(SecondScreenMode.DASHBOARD) else emptyList()) +
(if (desktop) listOf(SecondScreenMode.DESKTOP) else emptyList())
@@ -1176,8 +1185,6 @@ private fun SecondScreenGroup(host: MenuHost, a: DrawerActions, track: (String)
val pal = LocalPalette.current
val modes = secondScreenModes(a.secondScreenNone, com.droiddeck.launcher.session.SessionState.deckPad, a.dashboardAvailable, a.desktopAvailable)
SettingsGroup(stringResource(R.string.drawer_second_screen_mode)) {
Text(stringResource(R.string.input_second_mode_hint), Modifier.padding(14.dp),
style = MaterialTheme.typography.bodySmall, color = colors.onSurfaceVariant)
Column(Modifier.fillMaxWidth().selectableGroup()) {
modes.forEach { mode ->
val selected = a.secondScreenMode == mode
@@ -1247,7 +1254,6 @@ private fun DrawerFexPresetRow(host: MenuHost, a: DrawerActions, modifier: Modif
}
},
allowInheritance = appId != null,
generalPreset = a.fexPreset,
modifier = modifier,
)
}
@@ -2,17 +2,21 @@ package com.droiddeck.launcher.ui
import android.content.ClipboardManager
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.util.Log
import android.view.Gravity
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
import android.view.inputmethod.InputMethodManager
import android.widget.HorizontalScrollView
import android.widget.LinearLayout
import android.widget.TextView
import com.droiddeck.launcher.R
import com.droiddeck.launcher.input.PointerGestures
import com.droiddeck.launcher.input.TouchpadGestures
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.session.SessionTerminal
import com.termux.terminal.TerminalEmulator
@@ -21,6 +25,65 @@ import com.termux.terminal.TerminalSessionClient
import com.termux.view.TerminalView
import com.termux.view.TerminalViewClient
class SessionTrackpadView(
context: Context,
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,
) : View(context) {
private fun dp(value: Int): Int = (value * resources.displayMetrics.density + 0.5f).toInt()
private val gestures = TouchpadGestures(ViewConfiguration.get(context).scaledTouchSlop.toFloat(),
object : PointerGestures.Listener {
override fun onMove(x: Float, y: Float) {
sendPointer(x, y, width.toFloat(), height.toFloat())
invalidate()
}
override fun onButton(button: Int, pressed: Boolean, x: Float, y: Float) {
sendPointer(x, y, width.toFloat(), height.toFloat())
sendButton(button, pressed)
}
override fun onWheel(steps: Int) = sendWheel(steps)
override fun onLongPress() { performHapticFeedback(android.view.HapticFeedbackConstants.LONG_PRESS) }
})
init {
isFocusable = true
setBackgroundColor(Color.BLACK)
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
gestures.bounds = android.graphics.RectF(0f, 0f, w.toFloat(), h.toFloat())
if (oldw == 0 || oldh == 0) gestures.place(w / 2f, h / 2f)
}
private val surface = android.graphics.RectF()
private val fill = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply { color = Color.rgb(0x0A, 0x0B, 0x0D) }
private val edge = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
style = android.graphics.Paint.Style.STROKE
color = Color.rgb(0x26, 0x29, 0x2E)
strokeWidth = resources.displayMetrics.density
}
override fun onDraw(canvas: Canvas) {
val inset = dp(12).toFloat()
surface.set(inset, 0f, width - inset, height - inset)
if (surface.height() <= inset) return
val radius = dp(20).toFloat()
canvas.drawRoundRect(surface, radius, radius, fill)
canvas.drawRoundRect(surface, radius, radius, edge)
}
override fun onTouchEvent(event: MotionEvent): Boolean = gestures.onTouch(event)
override fun onDetachedFromWindow() {
gestures.cancel()
super.onDetachedFromWindow()
}
}
class SessionTerminalPanel(context: Context) : LinearLayout(context) {
private var terminalSession: TerminalSession? = null
private var terminalView: TerminalView? = null
@@ -60,7 +123,7 @@ class SessionTerminalPanel(context: Context) : LinearLayout(context) {
val keys = LinearLayout(context).apply { orientation = HORIZONTAL; setPadding(dp(12), 0, dp(12), dp(8)) }
fun key(label: String, input: String, onClick: ((TextView) -> Unit)? = null) {
lateinit var button: TextView
button = InputSurfaceView.roundButton(context, label, label, { if (onClick != null) onClick(button) else session.write(input) }, wide = true)
button = DeckControlsPanel.roundButton(context, label, label, { if (onClick != null) onClick(button) else session.write(input) }, wide = true)
if (keys.childCount > 0) keys.addView(View(context), LinearLayout.LayoutParams(dp(8), 1))
keys.addView(button)
}
@@ -509,23 +509,16 @@ fun <T> ChoiceRow(
stacked: Boolean = false,
onPick: (T) -> Unit,
) {
SettingsRow(label, hint, highlighted = host.open == key, hintLines = hintLines, stacked = stacked) {
ChoiceChip(host, key, label, options, selected, enabled, note, chipModifier, onPick)
}
}
@Composable
internal fun <T> ChoiceChip(host: MenuHost, key: String, label: String, options: List<Pair<T, String>>, selected: T,
enabled: Boolean = true, note: String? = null, modifier: Modifier = Modifier, onPick: (T) -> Unit) {
val open = host.open == key
Box {
ValueChip(options.firstOrNull { it.first == selected }?.second ?: "-", open, enabled,
modifier = modifier.testTag("setting-$key")) { host.open = if (open) null else key }
AnchoredMenu(open, onDismiss = { if (host.open == key) host.open = null }, title = label, note = note) { firstItemFocus ->
options.forEachIndexed { index, (value, text) ->
MenuItem(text, checked = value == selected, focusRequester = if (index == 0) firstItemFocus else null) {
onPick(value)
host.open = null
SettingsRow(label, hint, highlighted = open, hintLines = hintLines, stacked = stacked) {
Box {
ValueChip(options.firstOrNull { it.first == selected }?.second ?: "-", open, enabled, modifier = chipModifier.testTag("setting-$key")) { host.open = if (open) null else key }
AnchoredMenu(open, onDismiss = { if (host.open == key) host.open = null }, title = label, note = note) { firstItemFocus ->
options.forEachIndexed { index, (value, text) ->
MenuItem(text, checked = value == selected, focusRequester = if (index == 0) firstItemFocus else null) {
onPick(value)
host.open = null
}
}
}
}
@@ -721,7 +714,6 @@ fun SettingsPage(
scroll: androidx.compose.foundation.ScrollState? = null,
scrollContent: Boolean = true,
compactLayout: Boolean = false,
showBack: Boolean = true,
header: (@Composable () -> Unit)? = null,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
@@ -744,7 +736,7 @@ fun SettingsPage(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth(),
) {
if (showBack) BackLink(stringResource(R.string.mode_back), compact = true, onClick = onBack)
BackLink(stringResource(R.string.mode_back), compact = true, onClick = onBack)
if (header != null) Box(Modifier.weight(1f)) { header() }
}
}
@@ -754,7 +746,7 @@ fun SettingsPage(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(bottom = 6.dp),
) {
if (showBack) BackLink(stringResource(R.string.mode_back), compact = true, onClick = onBack)
BackLink(stringResource(R.string.mode_back), compact = true, onClick = onBack)
Text(
title, fontSize = 20.sp, fontWeight = FontWeight.Bold, color = colors.onBackground,
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f),
@@ -766,7 +758,7 @@ fun SettingsPage(
} else {
Rise(0) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (showBack) BackLink(stringResource(R.string.mode_back), compact = compactLayout, onClick = onBack)
BackLink(stringResource(R.string.mode_back), compact = compactLayout, onClick = onBack)
if (eyebrow != null) {
Spacer(Modifier.width(10.dp))
Eyebrow(eyebrow)
@@ -755,7 +755,7 @@ private fun ToolList(tools: List<ToolUsage>, sessionRunning: Boolean, onRemove:
}
@Composable
internal fun LowSpaceDialog(free: Long, warn: Boolean, onWarnChange: (Boolean) -> Unit, onFreeUp: () -> Unit, onPlay: () -> Unit, onDismiss: () -> Unit) {
internal fun LowSpaceDialog(free: Long, onFreeUp: () -> Unit, onPlay: () -> Unit, onDismiss: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val report = StorageState.report
@@ -770,7 +770,6 @@ internal fun LowSpaceDialog(free: Long, warn: Boolean, onWarnChange: (Boolean) -
if (report != null && StorageState.safeBytes(report) > 0) Text(
stringResource(R.string.storage_low_safe, gb(StorageState.safeBytes(report))), fontSize = 13.sp, color = pal.good,
)
DialogToggleRow(stringResource(R.string.storage_low_never), !warn) { onWarnChange(!it) }
}
},
confirmButton = { FocusText(stringResource(R.string.storage_free_up), pal.signal, modifier = Modifier.focusRequester(focus), onClick = onFreeUp) },
@@ -1,5 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF"
android:pathData="M6,10a2,2 0,1 0,0 4a2,2 0,1 0,0 -4M12,10a2,2 0,1 0,0 4a2,2 0,1 0,0 -4M18,10a2,2 0,1 0,0 4a2,2 0,1 0,0 -4" />
</vector>
@@ -1,8 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="64" android:viewportHeight="64">
<path android:fillColor="#FFFFFFFF" android:pathData="M 30.6,23.6 34.8,33.4 44.7,34.8 29.2,44.6 V 37.6 H 22.2 Z" />
<path android:fillColor="#FFFFFFFF" android:pathData="M 40 15 A 11 11 0 0 0 29 26 A 11 11 0 0 0 40 37 A 11 11 0 0 0 51 26 A 11 11 0 0 0 40 15 z M 40 19 A 7 7 0 0 1 47 26 A 7 7 0 0 1 40 33 A 7 7 0 0 1 33 26 A 7 7 0 0 1 40 19 z" />
<path android:fillColor="#FFFFFFFF" android:pathData="M 23.5 35 A 8.5 8.5 0 0 0 15 43.5 A 8.5 8.5 0 0 0 23.5 52 A 8.5 8.5 0 0 0 32 43.5 A 8.5 8.5 0 0 0 23.5 35 z M 23.5 38 A 5.5 5.5 0 0 1 29 43.5 A 5.5 5.5 0 0 1 23.5 49 A 5.5 5.5 0 0 1 18 43.5 A 5.5 5.5 0 0 1 23.5 38 z" />
<path android:fillColor="#FFFFFFFF" android:pathData="M40,21 a5,5 0,1 0,0 10 a5,5 0,1 0,0 -10" />
<path android:fillColor="#FFFFFFFF" android:pathData="M 4.046875 31.078125 A 28 28 0 0 0 4 32 A 28 28 0 0 0 4.9375 39.123047 L 22.085938 46.701172 A 3.50035 3.50035 0 1 0 24.914062 40.298828 L 4.046875 31.078125 z" />
</vector>
+48 -26
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">جارٍ إعادة ضبط الرسومات وإعادة فتح SteamVR…</string>
<string name="steamvr_recovery_failed">تعذّر على DroidDeck إعادة التشغيل تلقائيًا. أغلق التطبيق تمامًا، ثم أعد فتحه.</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>يُنزَّل Steam عند التشغيل الأول. قد يستغرق ذلك بضع دقائق.</item>
<item>افتراضيًا، يؤدي الخروج من التطبيق إلى إيقاف Steam مؤقتًا، ويُستأنف من حيث توقف عند عودتك.</item>
<item>تظهر وحدة التحكم لديك كوحدة تحكم Xbox 360. وإلا، فاستخدم عناصر التحكم على الشاشة.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>يُعَدّ سطح المكتب عند التشغيل الأول. قد يستغرق ذلك بضع دقائق.</item>
<item>افتراضيًا، يؤدي الخروج من التطبيق إلى إيقاف الجلسة مؤقتًا، وتُستأنف من حيث توقفت عند عودتك.</item>
<item>يعمل اللمس كلوحة لمس: اسحب لتحريك المؤشر، وانقر لتنفيذ نقرة.</item>
<item>تظهر وحدة التحكم لديك كوحدة تحكم Xbox 360. وإلا، فاستخدم عناصر التحكم على الشاشة.</item>
</string-array>
<string name="session_channel">الجلسة</string>
<string name="session_channel_description">يظهر أثناء فتح جلسة Steam أو جلسة سطح المكتب</string>
<string name="session_notification">Steam قيد التشغيل</string>
@@ -272,8 +261,24 @@
<string name="osc_steam_touch">لمس Steam (تجريبي)</string>
<string name="osc_style_next_session">تبدأ عناصر التحكم باللمس من Steam مع جلسة Steam التالية</string>
<string name="osc_steam_layout">تخطيط لمس Steam</string>
<string name="screen_keys">مفاتيح الشاشة</string>
<string name="screen_keys_add">إضافة مفتاح</string>
<string name="screen_key_text">نص</string>
<string name="screen_key_size">الحجم</string>
<string name="screen_key_opacity">العتامة</string>
<string name="screen_key_type_button">مفتاح</string>
<string name="screen_key_shape_trigger">زناد</string>
<string name="screen_key_toggle">تبديل</string>
<string name="screen_key_layout_export">تصدير</string>
<string name="screen_key_shape_pill">كبسولة</string>
<string name="screen_key_shape_round">مستدير</string>
<string name="screen_key_shape_circle">دائرة</string>
<string name="screen_key_shape_rect">مستطيل</string>
<string name="screen_key_bind">مفتاح</string>
<string name="screen_key_image">صورة</string>
<string name="screen_key_image_clear">مسح</string>
<string name="screen_key_layout">التخطيط</string>
<string name="screen_key_layout_save">حفظ</string>
<string name="osc_steam_layout_hint">انقل عناصر التحكم باللمس لهذه اللعبة أو غيّر حجمها أو أخفها؛ ويتم الحفظ في Steam</string>
<string name="mode_controller">وحدة التحكم</string>
<string name="mode_controller_hint">كيف يرى Steam وحدة التحكم لديك. يُطبَّق في الجلسة التالية.</string>
@@ -558,7 +563,6 @@
<string name="comp_auto_none">لا يستخدمه أي DroidDeck-Proton</string>
<string name="comp_auto_waits_network">تنتظر تحديثات Auto اتصالًا.</string>
<string name="comp_auto_check_failed">تعذّر البحث عن إصدارات جديدة.</string>
<string name="comp_auto_install_failed">تعذّر تثبيت %1$s.</string>
<string name="comp_auto_waits_unmetered">تنتظر الإصدارات الجديدة اتصالًا غير محدود، وإيقاف توفير البطارية، وجهازًا غير ساخن.</string>
<string name="comp_auto_updated">تم تحديث %1$s إلى %2$s.</string>
<string name="comp_auto_rolled_back">أغلق %1$s %2$s لعبة خلال ثوانٍ مرتين، لذا عاد Auto إلى %3$s.</string>
@@ -760,6 +764,7 @@
<string name="drawer_relative_mouse_auto">تلقائي</string>
<string name="drawer_relative_mouse_on">تشغيل</string>
<string name="drawer_relative_mouse_off">إيقاف</string>
<string name="drawer_direct_cursor">المؤشر</string>
<string name="drawer_pc_keyboard">لوحة مفاتيح الكمبيوتر</string>
<string name="drawer_android_keyboard">Android</string>
<string name="drawer_second_screen">الشاشة الثانية</string>
@@ -1118,25 +1123,43 @@
<string name="wadb_computer_last">3 · ADB من كمبيوتر (كخيار أخير)</string>
<string name="wadb_only_if">فقط إذا كان تصحيح الأخطاء اللاسلكي غير متاح.</string>
<string name="wadb_computer_group">3 · ADB من كمبيوتر (كخيار أخير)</string>
<string name="ctrl_osc_hint">متى تظهر عناصر التحكم باللمس في الجلسة</string>
<string name="ctrl_color">اللون</string>
<string name="ctrl_color_hint">درجة لون الأزرار على الشاشة</string>
<string name="ctrl_custom">مخصّص</string>
<string name="ctrl_opacity">العتامة</string>
<string name="ctrl_percent">%1$d%%</string>
<string name="ctrl_size">حجم الأزرار</string>
<string name="ctrl_size_hint" formatted="false">100% هو الحجم القياسي</string>
<string name="ctrl_stick_click">ضغط العصا</string>
<string name="ctrl_stick_click_hint">انقر مرتين مع الاستمرار على عصا للحصول على L3 أو R3</string>
<string name="ctrl_adaptive">عصي تكيّفية</string>
<string name="ctrl_rumble">اهتزاز اللعبة</string>
<string name="ctrl_dead_zone">المنطقة الميتة لعصا التحكم</string>
<string name="ctrl_steam_button">زر Steam</string>
<string name="ctrl_qam_button">زر QAM</string>
<string name="ctrl_keyboard_button">زر لوحة المفاتيح</string>
<string name="ctrl_select_steam">اضغط على Select لفتح قائمة Steam</string>
<string name="ctrl_select_steam_hint">بدلًا من View/Select.</string>
<string name="ctrl_joycon_vr">أجهزة Joy-Con كوحدات تحكم VR</string>
<string name="ctrl_joycon_vr_hint">أثناء تشغيل SteamVR، يصبح كل Joy-Con يدًا. وجّهه إلى الأمام واضغط مطولًا على Home أو Capture لإعادة توسيطه.</string>
<string name="setup_animations">الرسوم المتحركة</string>
<string name="setup_low_storage_warning">التحذير عند انخفاض مساحة التخزين</string>
<string name="setup_low_storage_warning_hint">اسأل قبل تشغيل Steam عندما تكون المساحة الحرة أقل من 10 جيجابايت.</string>
<string name="ctrl_adaptive_hint">تظهر العصي حيث تلمس وتُخفى عند الرفع</string>
<string name="ctrl_layout">التخطيط</string>
<string name="ctrl_layout_custom">تم حفظ مواضع مخصّصة</string>
<string name="ctrl_layout_default">مهيّأ لهذه الشاشة</string>
<string name="ctrl_edit">تعديل</string>
<string name="ctrl_reset">إعادة ضبط</string>
<string name="ctrl_mapping">تعيين الأزرار</string>
<string name="ctrl_mapping_default">يرسل كل زر إدخاله الخاص</string>
<string name="ctrl_mapping_count">تمت إعادة تعيين %1$d من %2$d أزرار</string>
<string name="ctrl_configure">ضبط</string>
<string name="ctrl_reset_all">إعادة ضبط وحدة التحكم</string>
<string name="ctrl_reset_all_hint">يستعيد كل إعدادات وحدة التحكم والتخطيط</string>
<string name="ctrl_mapping_lede">ما يرسله كل زر على الشاشة إلى اللعبة. يؤدي إخفاؤه إلى إزالة الزر.</string>
<string name="ctrl_onscreen_buttons">أزرار الشاشة</string>
<string name="ctrl_defaults">الافتراضية</string>
<string name="ctrl_reset_mapping">إعادة ضبط التعيين</string>
<string name="content_android_apps">تطبيقات Android</string>
<string name="content_apps_count">%1$d تطبيق</string>
<string name="content_no_android_apps">لم يتم العثور على تطبيقات Android قابلة للتشغيل.</string>
@@ -1220,6 +1243,7 @@
<string name="gpu_none_listed">لا توجد برامج تشغيل مُدرَجة حتى الآن. حدِّث للبحث عنها.</string>
<string name="gpu_hide_other">إخفاء برامج التشغيل لوحدات GPU الأخرى</string>
<string name="gpu_show_other">إظهار برامج التشغيل لوحدات GPU الأخرى</string>
<string name="gpu_advanced">متقدّم</string>
<string name="gpu_ubwc_title">توافق UBWC</string>
<string name="gpu_ubwc_hint">جرّب التشغيل أو الإيقاف إذا ظهرت خطوط أو وميض في Steam أو Android. يُطبَّق على برنامجَي التشغيل كليهما بعد إعادة تشغيل DroidDeck.</string>
<string name="gpu_ubwc_restart">أعِد تشغيل DroidDeck للتطبيق.</string>
@@ -1260,8 +1284,15 @@
<string name="deck_surface_stick">عصا التحكّم</string>
<string name="deck_surface_trackpad">لوحة اللمس</string>
<string name="deck_surface_switch">التبديل بين عصا التحكّم ولوحة اللمس</string>
<string name="deck_more_controls">المزيد من عناصر تحكّم Deck</string>
<string name="deck_panels">اللوحات</string>
<string name="deck_close">إغلاق عناصر التحكّم على الشاشة الثانية</string>
<string name="deck_open_steam_menu">فتح قائمة Steam</string>
<string name="deck_left_upper">يسار · علوي</string>
<string name="deck_left_lower">يسار · سفلي</string>
<string name="deck_right_upper">يمين · علوي</string>
<string name="deck_right_lower">يمين · سفلي</string>
<string name="deck_click">نقر</string>
<string name="deck_right_trackpad">لوحة اللمس اليمنى</string>
<string name="deck_left_trackpad">لوحة اللمس اليسرى</string>
<string name="second_trackpad">لوحة اللمس</string>
@@ -1843,7 +1874,6 @@
<string name="ctrl_tint_amber">كهرماني</string>
<string name="ctrl_tint_red">أحمر</string>
<string name="ctrl_tint_pink">وردي</string>
<string name="ctrl_tint_gray">رمادي</string>
<string name="ctrl_button_a">الزر A</string>
<string name="ctrl_button_b">الزر B</string>
<string name="ctrl_button_x">الزر X</string>
@@ -1861,7 +1891,9 @@
<string name="ctrl_target_left">لوحة الاتجاهات لليسار</string>
<string name="ctrl_target_right">لوحة الاتجاهات لليمين</string>
<string name="ctrl_target_hidden">مخفي</string>
<string name="ctrl_editor_drag">اسحب للنقل</string>
<string name="ctrl_editor_snap">محاذاة</string>
<string name="ctrl_editor_save">حفظ</string>
<string name="second_mode_none">لا شيء</string>
<string name="second_mode_keyboard_trackpad">لوحة المفاتيح + لوحة اللمس</string>
<string name="second_mode_terminal">الوحدة الطرفية</string>
@@ -2721,6 +2753,7 @@
<string name="gog_achievements_unavailable">الإنجازات غير متوفرة؛ تحقّق من سجلات الجلسة</string>
<string name="achievement_unlocked">تم فتح الإنجاز</string>
<string name="touch_paste">لصق</string>
<string name="touch_dpad">لوحة الاتجاهات</string>
<string name="touch_left_stick">العصا اليسرى</string>
<string name="touch_right_stick">العصا اليمنى</string>
<string name="touch_menu">القائمة</string>
@@ -3090,7 +3123,6 @@
<string name="storage_low_title">المتاح %1$s فقط</string>
<string name="storage_low_body">يحتاج Steam مساحة للتحديثات وذاكرة الشيدرات. تحت 10 GB قد تفشل تحديثات الألعاب في المنتصف.</string>
<string name="storage_low_safe">يمكن تحرير %1$s بأمان.</string>
<string name="storage_low_never">لا تحذرني من هذا مرة أخرى</string>
<string name="storage_free_up">تحرير مساحة</string>
<string name="storage_play_anyway">اللعب على أي حال</string>
<plurals name="storage_games_installed">
@@ -3807,14 +3839,4 @@
<string name="wow_chat_listening">جارٍ الاستماع…</string>
<string name="wow_chat_voice_unavailable">الكتابة بالصوت غير متاحة على هذا الجهاز.</string>
<string name="wow_chat_mic_denied">يلزم الوصول إلى الميكروفون للكتابة بالصوت.</string>
<string name="addons_download">تنزيل الإضافات…</string>
<string name="addons_sort_name">الاسم</string>
<string name="addons_sort_downloads">الأكثر تنزيلًا</string>
<string name="addons_category_all">كل الفئات</string>
<string name="addons_none_found">لم يتم العثور على إضافات</string>
<string name="addons_unreachable">تعذّر الوصول إلى %1$s</string>
<string name="addons_no_file">لا يوجد ملف لإصدار اللعبة هذا</string>
<string name="input_reset_positions">إعادة تعيين المواضع</string>
<string name="input_show_label">إظهار التسمية</string>
<string name="input_layout_import_failed">تعذّر استيراد هذا التخطيط</string>
</resources>
+48 -26
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">Restableciendo los gráficos y volviendo a abrir SteamVR…</string>
<string name="steamvr_recovery_failed">DroidDeck no pudo reiniciarse automáticamente. Cierra la app por completo y vuelve a abrirla.</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>La primera vez se descarga Steam. Puede tardar unos minutos.</item>
<item>De forma predeterminada, al salir de la app Steam se pausa y continúa donde lo dejaste cuando vuelves.</item>
<item>Tu control aparece como un control de Xbox 360. Si no, usa los controles en pantalla.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>La primera vez se prepara el escritorio. Puede tardar unos minutos.</item>
<item>De forma predeterminada, al salir de la app la sesión se pausa y continúa donde la dejaste cuando vuelves.</item>
<item>El tacto funciona como un touchpad: arrastra para mover el puntero y toca para hacer clic.</item>
<item>Tu control aparece como un control de Xbox 360. Si no, usa los controles en pantalla.</item>
</string-array>
<string name="session_channel">Sesión</string>
<string name="session_channel_description">Se muestra mientras hay una sesión de Steam o de escritorio abierta</string>
<string name="session_notification">Steam se está ejecutando</string>
@@ -272,8 +261,24 @@
<string name="osc_steam_touch">Táctil de Steam (beta)</string>
<string name="osc_style_next_session">Los controles táctiles de Steam se activan en la próxima sesión de Steam</string>
<string name="osc_steam_layout">Disposición táctil de Steam</string>
<string name="screen_keys">Teclas en pantalla</string>
<string name="screen_keys_add">Agregar tecla</string>
<string name="screen_key_text">Texto</string>
<string name="screen_key_size">Tamaño</string>
<string name="screen_key_opacity">Opacidad</string>
<string name="screen_key_type_button">Tecla</string>
<string name="screen_key_shape_trigger">Gatillo</string>
<string name="screen_key_toggle">Alternar</string>
<string name="screen_key_layout_export">Exportar</string>
<string name="screen_key_shape_pill">Píldora</string>
<string name="screen_key_shape_round">Redondeado</string>
<string name="screen_key_shape_circle">Círculo</string>
<string name="screen_key_shape_rect">Rectángulo</string>
<string name="screen_key_bind">Tecla</string>
<string name="screen_key_image">Imagen</string>
<string name="screen_key_image_clear">Borrar</string>
<string name="screen_key_layout">Disposición</string>
<string name="screen_key_layout_save">Guardar</string>
<string name="osc_steam_layout_hint">Mueve, cambia de tamaño u oculta los controles táctiles de este juego; se guarda en Steam</string>
<string name="mode_controller">Control</string>
<string name="mode_controller_hint">Cómo ve Steam tu control. Se aplica en la próxima sesión.</string>
@@ -552,7 +557,6 @@
<string name="comp_auto_none">Ningún DroidDeck-Proton lo usa</string>
<string name="comp_auto_waits_network">Las actualizaciones Auto esperan una conexión.</string>
<string name="comp_auto_check_failed">No se pudieron buscar nuevas versiones.</string>
<string name="comp_auto_install_failed">No se pudo instalar %1$s.</string>
<string name="comp_auto_waits_unmetered">Las nuevas versiones esperan una conexión sin límite de datos, el ahorro de batería desactivado y el dispositivo frío.</string>
<string name="comp_auto_updated">%1$s actualizado a %2$s.</string>
<string name="comp_auto_rolled_back">%1$s %2$s cerró un juego en segundos dos veces, así que Auto volvió a %3$s.</string>
@@ -778,6 +782,7 @@
<string name="drawer_relative_mouse_auto">Automático</string>
<string name="drawer_relative_mouse_on">Activado</string>
<string name="drawer_relative_mouse_off">Desactivado</string>
<string name="drawer_direct_cursor">Cursor</string>
<string name="drawer_pc_keyboard">Teclado de PC</string>
<string name="drawer_android_keyboard">Android</string>
<string name="drawer_second_screen">Segunda pantalla</string>
@@ -1127,17 +1132,23 @@
<string name="wadb_computer_last">3 · ADB desde una computadora (último recurso)</string>
<string name="wadb_only_if">Solo si la Depuración inalámbrica no está disponible.</string>
<string name="wadb_computer_group">3 · ADB desde una computadora (último recurso)</string>
<string name="ctrl_osc_hint">Cuándo aparecen los controles táctiles en una sesión</string>
<string name="ctrl_color">Color</string>
<string name="ctrl_color_hint">Tinte de los botones en pantalla</string>
<string name="ctrl_custom">Personalizado</string>
<string name="ctrl_opacity">Opacidad</string>
<string name="ctrl_percent">%1$d%%</string>
<string name="ctrl_size">Tamaño de botones</string>
<string name="ctrl_size_hint" formatted="false">100% es el tamaño estándar</string>
<string name="ctrl_stick_click">Clic del stick</string>
<string name="ctrl_stick_click_hint">Toca dos veces y mantén un stick para L3 o R3</string>
<string name="ctrl_adaptive">Sticks adaptables</string>
<string name="ctrl_rumble">Vibración en juegos</string>
<string name="ctrl_dead_zone">Zona muerta de la palanca</string>
<string name="ctrl_steam_button">Botón Steam</string>
<string name="ctrl_qam_button">Botón QAM</string>
<string name="ctrl_keyboard_button">Botón de teclado</string>
<string name="ctrl_select_steam">Presiona Select para abrir el menú de Steam</string>
<string name="ctrl_joycon_vr">Joy-Con como controles de RV</string>
<string name="ctrl_joycon_vr_hint">Mientras SteamVR está activo, cada Joy-Con es una mano. Apúntalo al frente y mantén Home o Captura para recentrarlo.</string>
<string name="hand_camera_prompt_body">Sin ningún mando conectado, la cámara del teléfono rastrea tus manos en realidad virtual: pellizca con el pulgar y el índice para hacer clic, cierra la mano para agarrar y gira la palma hacia ti y pellizca para abrir el menú. Con los mandos Joy-Con emparejados, la cámara rastrea su posición mientras los mandos Joy-Con proporcionan la rotación y las entradas de los botones. Las imágenes se procesan en este dispositivo y nunca se guardan ni se envían.</string>
@@ -1183,12 +1194,24 @@
<string name="glasses_xreal_connection_failed">La conexión con las XREAL se interrumpió o devolvió una respuesta no compatible. Reintentando…</string>
<string name="glasses_xreal_experimental">Compatibilidad experimental con las 1S. Usa el modo Follow y desactiva la estabilización en el menú de las gafas. Aún falta probar en el dispositivo las direcciones del seguimiento y los modos de pantalla.</string>
<string name="glasses_pro2_experimental">Seguimiento de cabeza experimental para Pro 2. Los ajustes de brillo y volumen y el cambio de modo de pantalla aún no son compatibles.</string>
<string name="ctrl_select_steam_hint">En lugar de View/Select.</string>
<string name="setup_animations">Animaciones</string>
<string name="setup_low_storage_warning">Avisar cuando queda poco almacenamiento</string>
<string name="setup_low_storage_warning_hint">Preguntar antes de iniciar Steam cuando el espacio libre es menor a 10 GB.</string>
<string name="ctrl_adaptive_hint">Los sticks aparecen donde tocas y se ocultan al soltar</string>
<string name="ctrl_layout">Disposición</string>
<string name="ctrl_layout_custom">Posiciones personalizadas guardadas</string>
<string name="ctrl_layout_default">Ajustada a esta pantalla</string>
<string name="ctrl_edit">Editar</string>
<string name="ctrl_reset">Restablecer</string>
<string name="ctrl_mapping">Asignación de botones</string>
<string name="ctrl_mapping_default">Cada botón envía su propia entrada</string>
<string name="ctrl_mapping_count">%1$d de %2$d botones reasignados</string>
<string name="ctrl_configure">Configurar</string>
<string name="ctrl_reset_all">Restablecer control</string>
<string name="ctrl_reset_all_hint">Restaura todos los ajustes del control y la disposición</string>
<string name="ctrl_mapping_lede">Lo que cada botón en pantalla envía al juego. Oculto quita el botón.</string>
<string name="ctrl_onscreen_buttons">Botones en pantalla</string>
<string name="ctrl_defaults">Predeterminados</string>
<string name="ctrl_reset_mapping">Restablecer asignación</string>
<string name="content_android_apps">Apps de Android</string>
<string name="content_apps_count">%1$d apps</string>
<string name="content_no_android_apps">No se encontraron apps de Android que se puedan abrir.</string>
@@ -1269,6 +1292,7 @@
<string name="gpu_none_listed">Aún no hay controladores listados. Actualiza para buscarlos.</string>
<string name="gpu_hide_other">Ocultar controladores de otras GPU</string>
<string name="gpu_show_other">Mostrar controladores de otras GPU</string>
<string name="gpu_advanced">Avanzado</string>
<string name="gpu_ubwc_title">Compatibilidad con UBWC</string>
<string name="gpu_ubwc_hint">Prueba Activado o Desactivado si Steam o Android muestran franjas o parpadeos. Se aplica a ambos controladores después de reiniciar DroidDeck.</string>
<string name="gpu_ubwc_restart">Reinicia DroidDeck para aplicarlo.</string>
@@ -1309,8 +1333,15 @@
<string name="deck_surface_stick">Stick</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Cambiar entre stick y trackpad</string>
<string name="deck_more_controls">Más controles de Deck</string>
<string name="deck_panels">PANELES</string>
<string name="deck_close">Cerrar controles de la segunda pantalla</string>
<string name="deck_open_steam_menu">Abrir menú de Steam</string>
<string name="deck_left_upper">Izquierdo · superior</string>
<string name="deck_left_lower">Izquierdo · inferior</string>
<string name="deck_right_upper">Derecho · superior</string>
<string name="deck_right_lower">Derecho · inferior</string>
<string name="deck_click">Clic</string>
<string name="deck_right_trackpad">Trackpad derecho</string>
<string name="deck_left_trackpad">Trackpad izquierdo</string>
<string name="second_trackpad">Trackpad</string>
@@ -1835,7 +1866,6 @@
<string name="ctrl_tint_amber">Ámbar</string>
<string name="ctrl_tint_red">Rojo</string>
<string name="ctrl_tint_pink">Rosa</string>
<string name="ctrl_tint_gray">Gris</string>
<string name="ctrl_button_a">Botón A</string>
<string name="ctrl_button_b">Botón B</string>
<string name="ctrl_button_x">Botón X</string>
@@ -1853,7 +1883,9 @@
<string name="ctrl_target_left">Cruceta izquierda</string>
<string name="ctrl_target_right">Cruceta derecha</string>
<string name="ctrl_target_hidden">Oculto</string>
<string name="ctrl_editor_drag">Arrastra para mover</string>
<string name="ctrl_editor_snap">Ajustar</string>
<string name="ctrl_editor_save">Guardar</string>
<string name="second_mode_none">Nada</string>
<string name="second_mode_keyboard_trackpad">Teclado + trackpad</string>
<string name="second_mode_terminal">Terminal</string>
@@ -2658,6 +2690,7 @@
<string name="gog_achievements_unavailable">Logros no disponibles; revisa los registros de la sesión</string>
<string name="achievement_unlocked">Logro desbloqueado</string>
<string name="touch_paste">Pegar</string>
<string name="touch_dpad">Pad direccional</string>
<string name="touch_left_stick">Stick izquierdo</string>
<string name="touch_right_stick">Stick derecho</string>
<string name="touch_menu">Menú</string>
@@ -2942,7 +2975,6 @@
<string name="storage_low_title">Solo quedan %1$s libres</string>
<string name="storage_low_body">Steam necesita espacio para actualizaciones y cachés de shaders. Por debajo de 10 GB, las actualizaciones de juegos pueden fallar a medias.</string>
<string name="storage_low_safe">Se pueden liberar %1$s sin riesgo.</string>
<string name="storage_low_never">No volver a avisarme</string>
<string name="storage_free_up">Liberar espacio</string>
<string name="storage_play_anyway">Jugar igualmente</string>
<plurals name="storage_games_installed">
@@ -3663,14 +3695,4 @@
<string name="wow_chat_listening">Escuchando…</string>
<string name="wow_chat_voice_unavailable">El dictado por voz no está disponible en este dispositivo.</string>
<string name="wow_chat_mic_denied">Se necesita acceso al micrófono para el dictado por voz.</string>
<string name="addons_download">Descargar complementos…</string>
<string name="addons_sort_name">Nombre</string>
<string name="addons_sort_downloads">Más descargados</string>
<string name="addons_category_all">Todas las categorías</string>
<string name="addons_none_found">No se encontraron complementos</string>
<string name="addons_unreachable">No se pudo conectar con %1$s</string>
<string name="addons_no_file">No hay archivo para esta versión del juego</string>
<string name="input_reset_positions">Restablecer posiciones</string>
<string name="input_show_label">Mostrar etiqueta</string>
<string name="input_layout_import_failed">No se pudo importar esta disposición</string>
</resources>
+48 -26
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">Нулиране на графиката и повторно отваряне на SteamVR…</string>
<string name="steamvr_recovery_failed">DroidDeck не успя да се рестартира автоматично. Затворете приложението напълно и след това го отворете отново.</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>Първото стартиране изтегля Steam. Това може да отнеме няколко минути.</item>
<item>По подразбиране излизането от приложението поставя Steam на пауза; при връщане продължава от същото място.</item>
<item>Контролерът ви се разпознава като контролер Xbox 360. В противен случай използвайте екранните контроли.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>Първото стартиране подготвя работния плот. Това може да отнеме няколко минути.</item>
<item>По подразбиране излизането от приложението поставя сесията на пауза; при връщане тя продължава от същото място.</item>
<item>Докосването действа като тъчпад: плъзгане премества показалеца, докосване щраква.</item>
<item>Контролерът ви се разпознава като контролер Xbox 360. В противен случай използвайте екранните контроли.</item>
</string-array>
<string name="session_channel">Сесия</string>
<string name="session_channel_description">Показва се, докато има отворена сесия на Steam или работния плот</string>
<string name="session_notification">Steam работи</string>
@@ -272,8 +261,24 @@
<string name="osc_steam_touch">Докосване в Steam (бета)</string>
<string name="osc_style_next_session">Сензорните контроли на Steam ще стартират от следващата сесия на Steam</string>
<string name="osc_steam_layout">Разположение на докосването в Steam</string>
<string name="screen_keys">Клавиши на екрана</string>
<string name="screen_keys_add">Добавяне на клавиш</string>
<string name="screen_key_text">Текст</string>
<string name="screen_key_size">Размер</string>
<string name="screen_key_opacity">Прозрачност</string>
<string name="screen_key_type_button">Клавиш</string>
<string name="screen_key_shape_trigger">Спускът</string>
<string name="screen_key_toggle">Превключване</string>
<string name="screen_key_layout_export">Експорт</string>
<string name="screen_key_shape_pill">Капсула</string>
<string name="screen_key_shape_round">Заоблено</string>
<string name="screen_key_shape_circle">Кръг</string>
<string name="screen_key_shape_rect">Правоъгълник</string>
<string name="screen_key_bind">Клавиш</string>
<string name="screen_key_image">Изображение</string>
<string name="screen_key_image_clear">Изчистване</string>
<string name="screen_key_layout">Разположение</string>
<string name="screen_key_layout_save">Запиши</string>
<string name="osc_steam_layout_hint">Премествайте, преоразмерявайте или скривайте сензорните контроли на тази игра; записва се в Steam</string>
<string name="mode_controller">Контролер</string>
<string name="mode_controller_hint">Как Steam разпознава контролера ви. Прилага се от следващата сесия.</string>
@@ -550,7 +555,6 @@
<string name="comp_auto_none">Нито един DroidDeck-Proton не го използва</string>
<string name="comp_auto_waits_network">Актуализациите Auto чакат връзка.</string>
<string name="comp_auto_check_failed">Новите версии не можаха да бъдат проверени.</string>
<string name="comp_auto_install_failed">%1$s не можа да бъде инсталиран.</string>
<string name="comp_auto_waits_unmetered">Новите версии чакат неотчитана връзка, изключен режим за пестене на батерията и охладено устройство.</string>
<string name="comp_auto_updated">%1$s е обновен до %2$s.</string>
<string name="comp_auto_rolled_back">%1$s %2$s два пъти затвори игра до секунди, затова Auto се върна към %3$s.</string>
@@ -752,6 +756,7 @@
<string name="drawer_relative_mouse_auto">Автоматично</string>
<string name="drawer_relative_mouse_on">Включено</string>
<string name="drawer_relative_mouse_off">Изключено</string>
<string name="drawer_direct_cursor">Курсор</string>
<string name="drawer_pc_keyboard">Клавиатура за компютър</string>
<string name="drawer_android_keyboard">Android</string>
<string name="drawer_second_screen">Втори екран</string>
@@ -1094,25 +1099,43 @@
<string name="wadb_computer_last">3 · ADB от компютър (последна възможност)</string>
<string name="wadb_only_if">Само ако безжичното отстраняване на грешки не е достъпно.</string>
<string name="wadb_computer_group">3 · ADB от компютър (последна възможност)</string>
<string name="ctrl_osc_hint">Кога се появяват екранните контроли в сесия</string>
<string name="ctrl_color">Цвят</string>
<string name="ctrl_color_hint">Оттенък на екранните бутони</string>
<string name="ctrl_custom">Персонализиран</string>
<string name="ctrl_opacity">Прозрачност</string>
<string name="ctrl_percent">%1$d%%</string>
<string name="ctrl_size">Размер на бутоните</string>
<string name="ctrl_size_hint" formatted="false">100% е стандартният размер</string>
<string name="ctrl_stick_click">Натискане на стика</string>
<string name="ctrl_stick_click_hint">Докоснете двукратно и задръжте стик за L3 или R3</string>
<string name="ctrl_adaptive">Адаптивни стикове</string>
<string name="ctrl_rumble">Вибрация от играта</string>
<string name="ctrl_dead_zone">Мъртва зона на стика</string>
<string name="ctrl_steam_button">Бутон Steam</string>
<string name="ctrl_qam_button">Бутон QAM</string>
<string name="ctrl_keyboard_button">Бутон за клавиатура</string>
<string name="ctrl_select_steam">Натиснете Select, за да отворите менюто на Steam</string>
<string name="ctrl_select_steam_hint">Вместо View/Select.</string>
<string name="ctrl_joycon_vr">Joy-Con като VR контролери</string>
<string name="ctrl_joycon_vr_hint">Докато SteamVR работи, всеки Joy-Con става ръка. Насочете го напред и задръжте Home или Capture, за да го центрирате отново.</string>
<string name="setup_animations">Анимации</string>
<string name="setup_low_storage_warning">Предупреждение при малко място</string>
<string name="setup_low_storage_warning_hint">Питай преди стартиране на Steam, когато свободното място е под 10 GB.</string>
<string name="ctrl_adaptive_hint">Стиковете се появяват там, където докоснете, и се скриват при отпускане</string>
<string name="ctrl_layout">Разположение</string>
<string name="ctrl_layout_custom">Запазени персонализирани позиции</string>
<string name="ctrl_layout_default">Нагласено за този екран</string>
<string name="ctrl_edit">Редактиране</string>
<string name="ctrl_reset">Нулиране</string>
<string name="ctrl_mapping">Разпределение на бутоните</string>
<string name="ctrl_mapping_default">Всеки бутон изпраща собствения си сигнал</string>
<string name="ctrl_mapping_count">%1$d от %2$d бутона преназначени</string>
<string name="ctrl_configure">Конфигуриране</string>
<string name="ctrl_reset_all">Нулиране на контролера</string>
<string name="ctrl_reset_all_hint">Възстановява всички настройки на контролера и разположението</string>
<string name="ctrl_mapping_lede">Какво изпраща всеки екранен бутон към играта. „Скрит“ премахва бутона.</string>
<string name="ctrl_onscreen_buttons">Екранни бутони</string>
<string name="ctrl_defaults">Стандартни настройки</string>
<string name="ctrl_reset_mapping">Нулиране на разпределението</string>
<string name="content_android_apps">Приложения за Android</string>
<string name="content_apps_count">%1$d приложения</string>
<string name="content_no_android_apps">Не са намерени приложения за Android за стартиране.</string>
@@ -1192,6 +1215,7 @@
<string name="gpu_none_listed">Все още няма изброени драйвери. Обновете, за да ги потърсите.</string>
<string name="gpu_hide_other">Скриване на драйверите за други GPU</string>
<string name="gpu_show_other">Показване на драйверите за други GPU</string>
<string name="gpu_advanced">Разширени</string>
<string name="gpu_ubwc_title">Съвместимост с UBWC</string>
<string name="gpu_ubwc_hint">Опитайте Вкл. или Изкл., ако Steam или Android показва ивици или трептене. Прилага се и за двата драйвера след рестартиране на DroidDeck.</string>
<string name="gpu_ubwc_restart">Рестартирайте DroidDeck, за да се приложи.</string>
@@ -1232,8 +1256,15 @@
<string name="deck_surface_stick">Стик</string>
<string name="deck_surface_trackpad">Тракпад</string>
<string name="deck_surface_switch">Превключване между стик и тракпад</string>
<string name="deck_more_controls">Още контроли на Deck</string>
<string name="deck_panels">ПАНЕЛИ</string>
<string name="deck_close">Затваряне на контролите за втория екран</string>
<string name="deck_open_steam_menu">Отваряне на менюто на Steam</string>
<string name="deck_left_upper">Ляво · горе</string>
<string name="deck_left_lower">Ляво · долу</string>
<string name="deck_right_upper">Дясно · горе</string>
<string name="deck_right_lower">Дясно · долу</string>
<string name="deck_click">Клик</string>
<string name="deck_right_trackpad">Десен тракпад</string>
<string name="deck_left_trackpad">Ляв тракпад</string>
<string name="second_trackpad">Тракпад</string>
@@ -1747,7 +1778,6 @@
<string name="ctrl_tint_amber">Кехлибарено</string>
<string name="ctrl_tint_red">Червено</string>
<string name="ctrl_tint_pink">Розово</string>
<string name="ctrl_tint_gray">Сиво</string>
<string name="ctrl_button_a">Бутон A</string>
<string name="ctrl_button_b">Бутон B</string>
<string name="ctrl_button_x">Бутон X</string>
@@ -1765,7 +1795,9 @@
<string name="ctrl_target_left">D-pad наляво</string>
<string name="ctrl_target_right">D-pad надясно</string>
<string name="ctrl_target_hidden">Скрит</string>
<string name="ctrl_editor_drag">Плъзни, за да преместиш</string>
<string name="ctrl_editor_snap">Прилепване</string>
<string name="ctrl_editor_save">Запазване</string>
<string name="second_mode_none">Нищо</string>
<string name="second_mode_keyboard_trackpad">Клавиатура + тракпад</string>
<string name="second_mode_terminal">Терминал</string>
@@ -2613,6 +2645,7 @@
<string name="gog_achievements_unavailable">Постиженията не са достъпни; проверете логовете на сесията</string>
<string name="achievement_unlocked">Постижение отключено</string>
<string name="touch_paste">Поставяне</string>
<string name="touch_dpad">Кръстат бутон</string>
<string name="touch_left_stick">Ляв стик</string>
<string name="touch_right_stick">Десен стик</string>
<string name="touch_menu">Меню</string>
@@ -2974,7 +3007,6 @@
<string name="storage_low_title">Свободни са само %1$s</string>
<string name="storage_low_body">Steam се нуждае от място за актуализации и кеш на шейдърите. Под 10 GB актуализациите на игрите може да се провалят по средата.</string>
<string name="storage_low_safe">%1$s могат да се освободят безопасно.</string>
<string name="storage_low_never">Не ме предупреждавай повече</string>
<string name="storage_free_up">Освободи място</string>
<string name="storage_play_anyway">Играй въпреки това</string>
<plurals name="storage_games_installed">
@@ -3651,14 +3683,4 @@
<string name="wow_chat_listening">Слушане…</string>
<string name="wow_chat_voice_unavailable">Гласовото въвеждане не е налично на това устройство.</string>
<string name="wow_chat_mic_denied">За гласово въвеждане е нужен достъп до микрофона.</string>
<string name="addons_download">Изтегляне на добавки…</string>
<string name="addons_sort_name">Име</string>
<string name="addons_sort_downloads">Най-изтегляни</string>
<string name="addons_category_all">Всички категории</string>
<string name="addons_none_found">Няма намерени добавки</string>
<string name="addons_unreachable">Няма връзка с %1$s</string>
<string name="addons_no_file">Няма файл за тази версия на играта</string>
<string name="input_reset_positions">Нулиране на позициите</string>
<string name="input_show_label">Показване на етикета</string>
<string name="input_layout_import_failed">Това разположение не можа да бъде импортирано</string>
</resources>
+48 -26
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">Resetování grafiky a opětovné otevírání SteamVR…</string>
<string name="steamvr_recovery_failed">DroidDeck se nepodařilo automaticky restartovat. Aplikaci úplně zavřete a pak ji znovu otevřete.</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>První spuštění stáhne Steam. Může to trvat několik minut.</item>
<item>Ve výchozím nastavení se po opuštění aplikace Steam pozastaví; po návratu pokračuje, kde skončil.</item>
<item>Váš ovladač se zobrazí jako ovladač Xbox 360. Jinak použijte ovládání na obrazovce.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>První spuštění připraví Plochu. Může to trvat několik minut.</item>
<item>Ve výchozím nastavení se po opuštění aplikace relace pozastaví; po návratu pokračuje, kde skončila.</item>
<item>Dotyk funguje jako touchpad: tažením posouváte kurzor, dotykem klikáte.</item>
<item>Váš ovladač se zobrazí jako ovladač Xbox 360. Jinak použijte ovládání na obrazovce.</item>
</string-array>
<string name="session_channel">Relace</string>
<string name="session_channel_description">Zobrazuje se, když je otevřená relace Steamu nebo Plochy</string>
<string name="session_notification">Steam běží</string>
@@ -272,8 +261,24 @@
<string name="osc_steam_touch">Dotykové ovládání Steamu (beta)</string>
<string name="osc_style_next_session">Dotykové ovládání Steamu se spustí od příští relace Steamu</string>
<string name="osc_steam_layout">Rozvržení dotykového ovládání Steamu</string>
<string name="screen_keys">Klávesy na obrazovce</string>
<string name="screen_keys_add">Přidat klávesu</string>
<string name="screen_key_text">Text</string>
<string name="screen_key_size">Velikost</string>
<string name="screen_key_opacity">Průhlednost</string>
<string name="screen_key_type_button">Klávesa</string>
<string name="screen_key_shape_trigger">Spoušť</string>
<string name="screen_key_toggle">Přepínač</string>
<string name="screen_key_layout_export">Exportovat</string>
<string name="screen_key_shape_pill">Kapsle</string>
<string name="screen_key_shape_round">Zaoblené</string>
<string name="screen_key_shape_circle">Kruh</string>
<string name="screen_key_shape_rect">Obdélník</string>
<string name="screen_key_bind">Klávesa</string>
<string name="screen_key_image">Obrázek</string>
<string name="screen_key_image_clear">Vymazat</string>
<string name="screen_key_layout">Rozložení</string>
<string name="screen_key_layout_save">Uložit</string>
<string name="osc_steam_layout_hint">Přesuňte, změňte velikost nebo skryjte dotykové ovládání této hry; uloží se do Steamu</string>
<string name="mode_controller">Ovladač</string>
<string name="mode_controller_hint">Jak Steam vidí váš ovladač. Použije se od příští relace.</string>
@@ -554,7 +559,6 @@
<string name="comp_auto_none">Žádný DroidDeck-Proton to nepoužívá</string>
<string name="comp_auto_waits_network">Aktualizace Auto čekají na připojení.</string>
<string name="comp_auto_check_failed">Nová sestavení nelze zkontrolovat.</string>
<string name="comp_auto_install_failed">%1$s nelze nainstalovat.</string>
<string name="comp_auto_waits_unmetered">Nová sestavení čekají na neměřené připojení, vypnutou úsporu baterie a nepřehřáté zařízení.</string>
<string name="comp_auto_updated">%1$s aktualizováno na %2$s.</string>
<string name="comp_auto_rolled_back">%1$s %2$s dvakrát zavřelo hru během pár sekund, proto se Auto vrátilo k %3$s.</string>
@@ -756,6 +760,7 @@
<string name="drawer_relative_mouse_auto">Automaticky</string>
<string name="drawer_relative_mouse_on">Zapnuto</string>
<string name="drawer_relative_mouse_off">Vypnuto</string>
<string name="drawer_direct_cursor">Kurzor</string>
<string name="drawer_pc_keyboard">Klávesnice PC</string>
<string name="drawer_android_keyboard">Android</string>
<string name="drawer_second_screen">Druhá obrazovka</string>
@@ -1106,25 +1111,43 @@
<string name="wadb_computer_last">3 · ADB z počítače (poslední možnost)</string>
<string name="wadb_only_if">Jen pokud Bezdrátové ladění není k dispozici.</string>
<string name="wadb_computer_group">3 · ADB z počítače (poslední možnost)</string>
<string name="ctrl_osc_hint">Kdy se dotykové ovládání zobrazí v relaci</string>
<string name="ctrl_color">Barva</string>
<string name="ctrl_color_hint">Odstín tlačítek na obrazovce</string>
<string name="ctrl_custom">Vlastní</string>
<string name="ctrl_opacity">Průhlednost</string>
<string name="ctrl_percent">%1$d%%</string>
<string name="ctrl_size">Velikost tlačítek</string>
<string name="ctrl_size_hint" formatted="false">100 % je standardní velikost</string>
<string name="ctrl_stick_click">Kliknutí páčkou</string>
<string name="ctrl_stick_click_hint">Dvojitým dotykem a podržením páčky aktivujete L3 nebo R3</string>
<string name="ctrl_adaptive">Adaptivní páčky</string>
<string name="ctrl_rumble">Vibrace ve hře</string>
<string name="ctrl_dead_zone">Mrtvá zóna páčky</string>
<string name="ctrl_steam_button">Tlačítko Steam</string>
<string name="ctrl_qam_button">Tlačítko QAM</string>
<string name="ctrl_keyboard_button">Tlačítko klávesnice</string>
<string name="ctrl_select_steam">Otevřít nabídku Steam stisknutím Select</string>
<string name="ctrl_select_steam_hint">Místo View/Select.</string>
<string name="ctrl_joycon_vr">Joy-Cony jako ovladače VR</string>
<string name="ctrl_joycon_vr_hint">Když běží SteamVR, každý Joy-Con slouží jako ruka. Namiřte ho dopředu a podržením Home nebo Capture ho znovu vycentrujete.</string>
<string name="setup_animations">Animace</string>
<string name="setup_low_storage_warning">Upozornit na nedostatek úložiště</string>
<string name="setup_low_storage_warning_hint">Zeptat se před spuštěním Steamu, když je volné místo pod 10 GB.</string>
<string name="ctrl_adaptive_hint">Páčky se zobrazí tam, kde se dotknete, a skryjí se po uvolnění</string>
<string name="ctrl_layout">Rozložení</string>
<string name="ctrl_layout_custom">Uloženy vlastní pozice</string>
<string name="ctrl_layout_default">Přizpůsobeno této obrazovce</string>
<string name="ctrl_edit">Upravit</string>
<string name="ctrl_reset">Obnovit</string>
<string name="ctrl_mapping">Mapování tlačítek</string>
<string name="ctrl_mapping_default">Každé tlačítko odesílá svůj vlastní vstup</string>
<string name="ctrl_mapping_count">%1$d z %2$d tlačítek přemapováno</string>
<string name="ctrl_configure">Konfigurovat</string>
<string name="ctrl_reset_all">Obnovit ovladač</string>
<string name="ctrl_reset_all_hint">Obnoví všechna nastavení ovladače a rozložení</string>
<string name="ctrl_mapping_lede">Co každé tlačítko na obrazovce odesílá hře. Skryté tlačítko odstraní.</string>
<string name="ctrl_onscreen_buttons">Tlačítka na obrazovce</string>
<string name="ctrl_defaults">Výchozí</string>
<string name="ctrl_reset_mapping">Obnovit mapování</string>
<string name="content_android_apps">Aplikace Android</string>
<string name="content_apps_count">%1$d aplikací</string>
<string name="content_no_android_apps">Nebyly nalezeny žádné spustitelné aplikace Android.</string>
@@ -1206,6 +1229,7 @@
<string name="gpu_none_listed">Zatím žádné ovladače. Obnovte, aby se vyhledaly.</string>
<string name="gpu_hide_other">Skrýt ovladače pro jiné GPU</string>
<string name="gpu_show_other">Zobrazit ovladače pro jiné GPU</string>
<string name="gpu_advanced">Pokročilé</string>
<string name="gpu_ubwc_title">Kompatibilita UBWC</string>
<string name="gpu_ubwc_hint">Pokud Steam nebo Android zobrazuje pruhy nebo blikání, zkuste Zapnuto nebo Vypnuto. Platí pro oba ovladače po restartu DroidDeck.</string>
<string name="gpu_ubwc_restart">Pro použití restartujte DroidDeck.</string>
@@ -1246,8 +1270,15 @@
<string name="deck_surface_stick">Páčka</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Přepnout mezi páčkou a trackpadem</string>
<string name="deck_more_controls">Další ovládání Deck</string>
<string name="deck_panels">PANELY</string>
<string name="deck_close">Zavřít ovládání druhé obrazovky</string>
<string name="deck_open_steam_menu">Otevřít nabídku Steamu</string>
<string name="deck_left_upper">Levý · horní</string>
<string name="deck_left_lower">Levý · dolní</string>
<string name="deck_right_upper">Pravý · horní</string>
<string name="deck_right_lower">Pravý · dolní</string>
<string name="deck_click">Kliknutí</string>
<string name="deck_right_trackpad">Pravý trackpad</string>
<string name="deck_left_trackpad">Levý trackpad</string>
<string name="second_trackpad">Trackpad</string>
@@ -1795,7 +1826,6 @@
<string name="ctrl_tint_amber">Ambrová</string>
<string name="ctrl_tint_red">Červená</string>
<string name="ctrl_tint_pink">Růžová</string>
<string name="ctrl_tint_gray">Šedá</string>
<string name="ctrl_button_a">Tlačítko A</string>
<string name="ctrl_button_b">Tlačítko B</string>
<string name="ctrl_button_x">Tlačítko X</string>
@@ -1813,7 +1843,9 @@
<string name="ctrl_target_left">D-pad vlevo</string>
<string name="ctrl_target_right">D-pad vpravo</string>
<string name="ctrl_target_hidden">Skryté</string>
<string name="ctrl_editor_drag">Přetažením přesunete</string>
<string name="ctrl_editor_snap">Přichytit</string>
<string name="ctrl_editor_save">Uložit</string>
<string name="second_mode_none">Žádný</string>
<string name="second_mode_keyboard_trackpad">Klávesnice + trackpad</string>
<string name="second_mode_terminal">Terminál</string>
@@ -2667,6 +2699,7 @@
<string name="gog_achievements_unavailable">Achievementy nejsou k dispozici; zkontrolujte protokoly relace</string>
<string name="achievement_unlocked">Achievement odemčen</string>
<string name="touch_paste">Vložit</string>
<string name="touch_dpad">Křížový ovladač</string>
<string name="touch_left_stick">Levá páčka</string>
<string name="touch_right_stick">Pravá páčka</string>
<string name="touch_menu">Nabídka</string>
@@ -3032,7 +3065,6 @@
<string name="storage_low_title">Volně jen %1$s</string>
<string name="storage_low_body">Steam potřebuje místo na aktualizace a mezipaměť shaderů. Pod 10 GB mohou aktualizace her selhat v polovině.</string>
<string name="storage_low_safe">%1$s lze bezpečně uvolnit.</string>
<string name="storage_low_never">Už mě neupozorňovat</string>
<string name="storage_free_up">Uvolnit místo</string>
<string name="storage_play_anyway">Přesto hrát</string>
<plurals name="storage_games_installed">
@@ -3729,14 +3761,4 @@
<string name="wow_chat_listening">Poslouchám…</string>
<string name="wow_chat_voice_unavailable">Hlasové psaní není na tomto zařízení k dispozici.</string>
<string name="wow_chat_mic_denied">Pro hlasové psaní je potřeba přístup k mikrofonu.</string>
<string name="addons_download">Stáhnout doplňky…</string>
<string name="addons_sort_name">Název</string>
<string name="addons_sort_downloads">Nejstahovanější</string>
<string name="addons_category_all">Všechny kategorie</string>
<string name="addons_none_found">Nenalezeny žádné doplňky</string>
<string name="addons_unreachable">Nelze se připojit k %1$s</string>
<string name="addons_no_file">Pro tuto verzi hry není k dispozici soubor</string>
<string name="input_reset_positions">Obnovit pozice</string>
<string name="input_show_label">Zobrazit popisek</string>
<string name="input_layout_import_failed">Toto rozložení nelze importovat</string>
</resources>
+48 -26
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">Nulstiller grafikken og åbner SteamVR igen…</string>
<string name="steamvr_recovery_failed">DroidDeck kunne ikke genstarte automatisk. Luk appen helt, og åbn den så igen.</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>Den første start downloader Steam. Det kan tage nogle minutter.</item>
<item>Som standard sætter Steam på pause, når du forlader appen, og fortsætter, hvor du slap, når du kommer tilbage.</item>
<item>Din controller vises som en Xbox 360-controller. Brug ellers kontrollerne på skærmen.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>Den første start sætter skrivebordet op. Det kan tage nogle minutter.</item>
<item>Som standard sætter sessionen på pause, når du forlader appen, og fortsætter, hvor du slap, når du kommer tilbage.</item>
<item>Berøring fungerer som et touchpad: Træk for at flytte markøren, tryk for at klikke.</item>
<item>Din controller vises som en Xbox 360-controller. Brug ellers kontrollerne på skærmen.</item>
</string-array>
<string name="session_channel">Session</string>
<string name="session_channel_description">Vises, når en Steam- eller skrivebordssession er åben</string>
<string name="session_notification">Steam kører</string>
@@ -272,8 +261,24 @@
<string name="osc_steam_touch">Steam-berøring (beta)</string>
<string name="osc_style_next_session">Steams berøringskontroller starter med næste Steam-session</string>
<string name="osc_steam_layout">Steam-berøringslayout</string>
<string name="screen_keys">Skærmtaster</string>
<string name="screen_keys_add">Tilføj tast</string>
<string name="screen_key_text">Tekst</string>
<string name="screen_key_size">Størrelse</string>
<string name="screen_key_opacity">Gennemsigtighed</string>
<string name="screen_key_type_button">Tast</string>
<string name="screen_key_shape_trigger">Udløser</string>
<string name="screen_key_toggle">Skift</string>
<string name="screen_key_layout_export">Eksportér</string>
<string name="screen_key_shape_pill">Kapsel</string>
<string name="screen_key_shape_round">Afrundet</string>
<string name="screen_key_shape_circle">Cirkel</string>
<string name="screen_key_shape_rect">Rektangel</string>
<string name="screen_key_bind">Tast</string>
<string name="screen_key_image">Billede</string>
<string name="screen_key_image_clear">Ryd</string>
<string name="screen_key_layout">Layout</string>
<string name="screen_key_layout_save">Gem</string>
<string name="osc_steam_layout_hint">Flyt, tilpas størrelsen på eller skjul dette spils berøringskontroller; gemmes til Steam</string>
<string name="mode_controller">Controller</string>
<string name="mode_controller_hint">Hvordan Steam ser din controller. Gælder fra næste session.</string>
@@ -550,7 +555,6 @@
<string name="comp_auto_none">Ingen DroidDeck-Proton bruger dette</string>
<string name="comp_auto_waits_network">Auto-opdateringer venter på en forbindelse.</string>
<string name="comp_auto_check_failed">Kunne ikke søge efter nye builds.</string>
<string name="comp_auto_install_failed">Kunne ikke installere %1$s.</string>
<string name="comp_auto_waits_unmetered">Nye builds venter på en ubegrænset forbindelse, slået batterisparer fra og en kølig enhed.</string>
<string name="comp_auto_updated">%1$s opdateret til %2$s.</string>
<string name="comp_auto_rolled_back">%1$s %2$s lukkede et spil inden for sekunder to gange, så Auto gik tilbage til %3$s.</string>
@@ -752,6 +756,7 @@
<string name="drawer_relative_mouse_auto">Automatisk</string>
<string name="drawer_relative_mouse_on">Til</string>
<string name="drawer_relative_mouse_off">Fra</string>
<string name="drawer_direct_cursor">Markør</string>
<string name="drawer_pc_keyboard">PC-tastatur</string>
<string name="drawer_android_keyboard">Android</string>
<string name="drawer_second_screen">Anden skærm</string>
@@ -1094,25 +1099,43 @@
<string name="wadb_computer_last">3 · Computer-ADB (sidste udvej)</string>
<string name="wadb_only_if">Kun hvis Trådløs fejlretning ikke er tilgængelig.</string>
<string name="wadb_computer_group">3 · Computer-ADB (sidste udvej)</string>
<string name="ctrl_osc_hint">Hvornår berøringskontrollerne vises i en session</string>
<string name="ctrl_color">Farve</string>
<string name="ctrl_color_hint">Farvetone for knapperne på skærmen</string>
<string name="ctrl_custom">Brugerdefineret</string>
<string name="ctrl_opacity">Gennemsigtighed</string>
<string name="ctrl_percent">%1$d%%</string>
<string name="ctrl_size">Knapstørrelse</string>
<string name="ctrl_size_hint" formatted="false">100% er standardstørrelsen</string>
<string name="ctrl_stick_click">Stick-klik</string>
<string name="ctrl_stick_click_hint">Dobbelttryk og hold en stick nede for L3 eller R3</string>
<string name="ctrl_adaptive">Adaptive styrepinde</string>
<string name="ctrl_rumble">Spilvibration</string>
<string name="ctrl_dead_zone">Dødzone for styrepind</string>
<string name="ctrl_steam_button">Steam-knap</string>
<string name="ctrl_qam_button">QAM-knap</string>
<string name="ctrl_keyboard_button">Tastaturknap</string>
<string name="ctrl_select_steam">Tryk på Select for at åbne Steam-menuen</string>
<string name="ctrl_select_steam_hint">I stedet for View/Select.</string>
<string name="ctrl_joycon_vr">Joy-Cons som VR-controllere</string>
<string name="ctrl_joycon_vr_hint">Mens SteamVR kører, bliver hver Joy-Con en hånd. Peg den fremad, og hold Home eller Capture nede for at centrere den igen.</string>
<string name="setup_animations">Animationer</string>
<string name="setup_low_storage_warning">Advar, når lagerpladsen er lav</string>
<string name="setup_low_storage_warning_hint">Spørg, før Steam startes, når den ledige plads er under 10 GB.</string>
<string name="ctrl_adaptive_hint">Sticks vises, hvor du rører skærmen, og skjules, når du slipper</string>
<string name="ctrl_layout">Layout</string>
<string name="ctrl_layout_custom">Brugerdefinerede positioner gemt</string>
<string name="ctrl_layout_default">Tilpasset denne skærm</string>
<string name="ctrl_edit">Rediger</string>
<string name="ctrl_reset">Nulstil</string>
<string name="ctrl_mapping">Knapmapning</string>
<string name="ctrl_mapping_default">Hver knap sender sit eget input</string>
<string name="ctrl_mapping_count">%1$d af %2$d knapper ommappet</string>
<string name="ctrl_configure">Konfigurer</string>
<string name="ctrl_reset_all">Nulstil controller</string>
<string name="ctrl_reset_all_hint">Gendanner alle controllerindstillinger og layoutet</string>
<string name="ctrl_mapping_lede">Hvad hver knap på skærmen sender til spillet. Skjult fjerner knappen.</string>
<string name="ctrl_onscreen_buttons">Knapper på skærmen</string>
<string name="ctrl_defaults">Standarder</string>
<string name="ctrl_reset_mapping">Nulstil mapning</string>
<string name="content_android_apps">Android-apps</string>
<string name="content_apps_count">%1$d apps</string>
<string name="content_no_android_apps">Ingen Android-apps, der kan startes, fundet.</string>
@@ -1192,6 +1215,7 @@
<string name="gpu_none_listed">Ingen drivere på listen endnu. Opdater for at søge efter dem.</string>
<string name="gpu_hide_other">Skjul drivere til andre GPU’er</string>
<string name="gpu_show_other">Vis drivere til andre GPU’er</string>
<string name="gpu_advanced">Avanceret</string>
<string name="gpu_ubwc_title">UBWC-kompatibilitet</string>
<string name="gpu_ubwc_hint">Prøv Til eller Fra, hvis Steam eller Android viser striber eller flimmer. Gælder begge drivere efter genstart af DroidDeck.</string>
<string name="gpu_ubwc_restart">Genstart DroidDeck for at anvende.</string>
@@ -1232,8 +1256,15 @@
<string name="deck_surface_stick">Stick</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Skift mellem stick og trackpad</string>
<string name="deck_more_controls">Flere Deck-kontroller</string>
<string name="deck_panels">PANELER</string>
<string name="deck_close">Luk kontrollerne til den anden skærm</string>
<string name="deck_open_steam_menu">Åbn Steam-menuen</string>
<string name="deck_left_upper">Venstre · øverst</string>
<string name="deck_left_lower">Venstre · nederst</string>
<string name="deck_right_upper">Højre · øverst</string>
<string name="deck_right_lower">Højre · nederst</string>
<string name="deck_click">Klik</string>
<string name="deck_right_trackpad">Højre trackpad</string>
<string name="deck_left_trackpad">Venstre trackpad</string>
<string name="second_trackpad">Trackpad</string>
@@ -1747,7 +1778,6 @@
<string name="ctrl_tint_amber">Ravgul</string>
<string name="ctrl_tint_red">Rød</string>
<string name="ctrl_tint_pink">Pink</string>
<string name="ctrl_tint_gray">Grå</string>
<string name="ctrl_button_a">A-knap</string>
<string name="ctrl_button_b">B-knap</string>
<string name="ctrl_button_x">X-knap</string>
@@ -1765,7 +1795,9 @@
<string name="ctrl_target_left">D-pad venstre</string>
<string name="ctrl_target_right">D-pad højre</string>
<string name="ctrl_target_hidden">Skjult</string>
<string name="ctrl_editor_drag">Træk for at flytte</string>
<string name="ctrl_editor_snap">Fastgør</string>
<string name="ctrl_editor_save">Gem</string>
<string name="second_mode_none">Ingen</string>
<string name="second_mode_keyboard_trackpad">Tastatur + trackpad</string>
<string name="second_mode_terminal">Terminal</string>
@@ -2613,6 +2645,7 @@
<string name="gog_achievements_unavailable">Bedrifter ikke tilgængelige; se sessionslogs</string>
<string name="achievement_unlocked">Bedrift låst op</string>
<string name="touch_paste">Indsæt</string>
<string name="touch_dpad">Styrekryds</string>
<string name="touch_left_stick">Venstre stick</string>
<string name="touch_right_stick">Højre stick</string>
<string name="touch_menu">Menu</string>
@@ -2974,7 +3007,6 @@
<string name="storage_low_title">Kun %1$s ledig</string>
<string name="storage_low_body">Steam har brug for plads til opdateringer og shadercache. Under 10 GB kan spilopdateringer mislykkes halvvejs.</string>
<string name="storage_low_safe">%1$s kan frigøres sikkert.</string>
<string name="storage_low_never">Advar mig ikke igen</string>
<string name="storage_free_up">Frigør plads</string>
<string name="storage_play_anyway">Spil alligevel</string>
<plurals name="storage_games_installed">
@@ -3651,14 +3683,4 @@
<string name="wow_chat_listening">Lytter…</string>
<string name="wow_chat_voice_unavailable">Stemmeindtastning er ikke tilgængelig på denne enhed.</string>
<string name="wow_chat_mic_denied">Mikrofonadgang er nødvendig for stemmeindtastning.</string>
<string name="addons_download">Download tilføjelser…</string>
<string name="addons_sort_name">Navn</string>
<string name="addons_sort_downloads">Mest downloadet</string>
<string name="addons_category_all">Alle kategorier</string>
<string name="addons_none_found">Ingen tilføjelser fundet</string>
<string name="addons_unreachable">Kunne ikke nå %1$s</string>
<string name="addons_no_file">Ingen fil til denne spilversion</string>
<string name="input_reset_positions">Nulstil placeringer</string>
<string name="input_show_label">Vis etiket</string>
<string name="input_layout_import_failed">Dette layout kunne ikke importeres</string>
</resources>
+48 -26
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">Grafik wird zurückgesetzt und SteamVR neu geöffnet …</string>
<string name="steamvr_recovery_failed">DroidDeck konnte nicht automatisch neu starten. Schließe die App vollständig und öffne sie dann erneut.</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>Beim ersten Start wird Steam heruntergeladen. Das kann ein paar Minuten dauern.</item>
<item>Standardmäßig pausiert Steam, wenn du die App verlässt, und macht bei deiner Rückkehr dort weiter, wo es aufgehört hat.</item>
<item>Dein Controller erscheint als Xbox-360-Pad. Ansonsten nutze die Bildschirmsteuerung.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>Beim ersten Start wird der Desktop eingerichtet. Das kann ein paar Minuten dauern.</item>
<item>Standardmäßig wird die Sitzung pausiert, wenn du die App verlässt, und macht bei deiner Rückkehr dort weiter, wo sie aufgehört hat.</item>
<item>Touch funktioniert wie ein Touchpad: Ziehen bewegt den Zeiger, Tippen klickt.</item>
<item>Dein Controller erscheint als Xbox-360-Pad. Ansonsten nutze die Bildschirmsteuerung.</item>
</string-array>
<string name="session_channel">Sitzung</string>
<string name="session_channel_description">Wird angezeigt, solange eine Steam- oder Desktop-Sitzung geöffnet ist</string>
<string name="session_notification">Steam läuft</string>
@@ -272,8 +261,24 @@
<string name="osc_steam_touch">Steam-Touch (Beta)</string>
<string name="osc_style_next_session">Steams Touch-Steuerung startet mit der nächsten Steam-Sitzung</string>
<string name="osc_steam_layout">Steam-Touch-Layout</string>
<string name="screen_keys">Bildschirmtasten</string>
<string name="screen_keys_add">Taste hinzufügen</string>
<string name="screen_key_text">Text</string>
<string name="screen_key_size">Größe</string>
<string name="screen_key_opacity">Deckkraft</string>
<string name="screen_key_type_button">Taste</string>
<string name="screen_key_shape_trigger">Trigger</string>
<string name="screen_key_toggle">Umschalten</string>
<string name="screen_key_layout_export">Exportieren</string>
<string name="screen_key_shape_pill">Kapsel</string>
<string name="screen_key_shape_round">Rund</string>
<string name="screen_key_shape_circle">Kreis</string>
<string name="screen_key_shape_rect">Rechteck</string>
<string name="screen_key_bind">Taste</string>
<string name="screen_key_image">Bild</string>
<string name="screen_key_image_clear">Entfernen</string>
<string name="screen_key_layout">Layout</string>
<string name="screen_key_layout_save">Speichern</string>
<string name="osc_steam_layout_hint">Touch-Steuerung dieses Spiels verschieben, skalieren oder ausblenden; wird in Steam gespeichert</string>
<string name="mode_controller">Controller</string>
<string name="mode_controller_hint">Wie Steam deinen Controller erkennt. Gilt ab der nächsten Sitzung.</string>
@@ -550,7 +555,6 @@
<string name="comp_auto_none">Kein DroidDeck-Proton nutzt dies</string>
<string name="comp_auto_waits_network">Auto-Updates warten auf eine Verbindung.</string>
<string name="comp_auto_check_failed">Neue Builds konnten nicht geprüft werden.</string>
<string name="comp_auto_install_failed">%1$s konnte nicht installiert werden.</string>
<string name="comp_auto_waits_unmetered">Neue Builds warten auf eine nicht getaktete Verbindung, ausgeschalteten Energiesparmodus und ein kühles Gerät.</string>
<string name="comp_auto_updated">%1$s auf %2$s aktualisiert.</string>
<string name="comp_auto_rolled_back">%1$s %2$s hat ein Spiel zweimal nach wenigen Sekunden beendet, daher ist Auto zu %3$s zurückgekehrt.</string>
@@ -752,6 +756,7 @@
<string name="drawer_relative_mouse_auto">Automatisch</string>
<string name="drawer_relative_mouse_on">An</string>
<string name="drawer_relative_mouse_off">Aus</string>
<string name="drawer_direct_cursor">Zeiger</string>
<string name="drawer_pc_keyboard">PC-Tastatur</string>
<string name="drawer_android_keyboard">Android</string>
<string name="drawer_second_screen">Zweiter Bildschirm</string>
@@ -1086,25 +1091,43 @@
<string name="wadb_computer_last">3 · ADB am Computer (letzter Ausweg)</string>
<string name="wadb_only_if">Nur wenn „Debugging über WLAN“ nicht verfügbar ist.</string>
<string name="wadb_computer_group">3 · ADB am Computer (letzter Ausweg)</string>
<string name="ctrl_osc_hint">Wann die Touch-Steuerung in einer Sitzung erscheint</string>
<string name="ctrl_color">Farbe</string>
<string name="ctrl_color_hint">Farbton der Bildschirmtasten</string>
<string name="ctrl_custom">Benutzerdefiniert</string>
<string name="ctrl_opacity">Deckkraft</string>
<string name="ctrl_percent">%1$d %%</string>
<string name="ctrl_size">Tastengröße</string>
<string name="ctrl_size_hint" formatted="false">Standardgröße: 100 %</string>
<string name="ctrl_stick_click">Stick-Klick</string>
<string name="ctrl_stick_click_hint">Doppeltippen und Stick halten für L3 oder R3</string>
<string name="ctrl_adaptive">Adaptive Sticks</string>
<string name="ctrl_rumble">Spielvibration</string>
<string name="ctrl_dead_zone">Stick-Totzone</string>
<string name="ctrl_steam_button">Steam-Taste</string>
<string name="ctrl_qam_button">QAM-Taste</string>
<string name="ctrl_keyboard_button">Tastatur-Taste</string>
<string name="ctrl_select_steam">Mit Select das Steam-Menü öffnen</string>
<string name="ctrl_select_steam_hint">Statt View/Select.</string>
<string name="ctrl_joycon_vr">Joy-Cons als VR-Controller</string>
<string name="ctrl_joycon_vr_hint">Während SteamVR läuft, wird jeder Joy-Con zu einer Hand. Richte ihn nach vorn und halte HOME oder Capture gedrückt, um ihn neu auszurichten.</string>
<string name="setup_animations">Animationen</string>
<string name="setup_low_storage_warning">Bei knappem Speicher warnen</string>
<string name="setup_low_storage_warning_hint">Vor dem Start von Steam nachfragen, wenn weniger als 10 GB frei sind.</string>
<string name="ctrl_adaptive_hint">Sticks erscheinen dort, wo du tippst, und verschwinden beim Loslassen</string>
<string name="ctrl_layout">Layout</string>
<string name="ctrl_layout_custom">Eigene Positionen gespeichert</string>
<string name="ctrl_layout_default">An diesen Bildschirm angepasst</string>
<string name="ctrl_edit">Bearbeiten</string>
<string name="ctrl_reset">Zurücksetzen</string>
<string name="ctrl_mapping">Tastenbelegung</string>
<string name="ctrl_mapping_default">Jede Taste sendet ihre eigene Eingabe</string>
<string name="ctrl_mapping_count">%1$d von %2$d Tasten neu belegt</string>
<string name="ctrl_configure">Konfigurieren</string>
<string name="ctrl_reset_all">Controller zurücksetzen</string>
<string name="ctrl_reset_all_hint">Stellt alle Controller-Einstellungen und das Layout wieder her</string>
<string name="ctrl_mapping_lede">Was jede Bildschirmtaste an das Spiel sendet. „Ausgeblendet“ entfernt die Taste.</string>
<string name="ctrl_onscreen_buttons">Bildschirmtasten</string>
<string name="ctrl_defaults">Standardwerte</string>
<string name="ctrl_reset_mapping">Belegung zurücksetzen</string>
<string name="content_android_apps">Android-Apps</string>
<string name="content_apps_count">%1$d Apps</string>
<string name="content_no_android_apps">Keine startbaren Android-Apps gefunden.</string>
@@ -1184,6 +1207,7 @@
<string name="gpu_none_listed">Noch keine Treiber aufgeführt. Aktualisiere, um danach zu suchen.</string>
<string name="gpu_hide_other">Treiber für andere GPUs ausblenden</string>
<string name="gpu_show_other">Treiber für andere GPUs anzeigen</string>
<string name="gpu_advanced">Erweitert</string>
<string name="gpu_ubwc_title">UBWC-Kompatibilität</string>
<string name="gpu_ubwc_hint">Probiere An oder Aus, wenn Steam oder Android Streifen oder Flackern zeigt. Gilt für beide Treiber nach einem Neustart von DroidDeck.</string>
<string name="gpu_ubwc_restart">Starte DroidDeck neu, um dies anzuwenden.</string>
@@ -1224,8 +1248,15 @@
<string name="deck_surface_stick">Stick</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Zwischen Stick und Trackpad wechseln</string>
<string name="deck_more_controls">Weitere Deck-Steuerelemente</string>
<string name="deck_panels">BEREICHE</string>
<string name="deck_close">Steuerung auf dem Zweitbildschirm schließen</string>
<string name="deck_open_steam_menu">Steam-Menü öffnen</string>
<string name="deck_left_upper">Links · oben</string>
<string name="deck_left_lower">Links · unten</string>
<string name="deck_right_upper">Rechts · oben</string>
<string name="deck_right_lower">Rechts · unten</string>
<string name="deck_click">Klick</string>
<string name="deck_right_trackpad">Rechtes Trackpad</string>
<string name="deck_left_trackpad">Linkes Trackpad</string>
<string name="second_trackpad">Trackpad</string>
@@ -1739,7 +1770,6 @@
<string name="ctrl_tint_amber">Bernstein</string>
<string name="ctrl_tint_red">Rot</string>
<string name="ctrl_tint_pink">Pink</string>
<string name="ctrl_tint_gray">Grau</string>
<string name="ctrl_button_a">A-Taste</string>
<string name="ctrl_button_b">B-Taste</string>
<string name="ctrl_button_x">X-Taste</string>
@@ -1757,7 +1787,9 @@
<string name="ctrl_target_left">Steuerkreuz links</string>
<string name="ctrl_target_right">Steuerkreuz rechts</string>
<string name="ctrl_target_hidden">Ausgeblendet</string>
<string name="ctrl_editor_drag">Zum Verschieben ziehen</string>
<string name="ctrl_editor_snap">Einrasten</string>
<string name="ctrl_editor_save">Speichern</string>
<string name="second_mode_none">Nichts</string>
<string name="second_mode_keyboard_trackpad">Tastatur + Trackpad</string>
<string name="second_mode_terminal">Terminal</string>
@@ -2602,6 +2634,7 @@
<string name="gog_achievements_unavailable">Erfolge nicht verfügbar; Sitzungsprotokolle prüfen</string>
<string name="achievement_unlocked">Erfolg freigeschaltet</string>
<string name="touch_paste">Einfügen</string>
<string name="touch_dpad">Richtungstasten</string>
<string name="touch_left_stick">Linker Stick</string>
<string name="touch_right_stick">Rechter Stick</string>
<string name="touch_menu">Menü</string>
@@ -2974,7 +3007,6 @@
<string name="storage_low_title">Nur noch %1$s frei</string>
<string name="storage_low_body">Steam braucht Platz für Updates und Shader-Cache. Unter 10 GB können Spiel-Updates mittendrin fehlschlagen.</string>
<string name="storage_low_safe">%1$s lässt sich sicher freigeben.</string>
<string name="storage_low_never">Nicht mehr warnen</string>
<string name="storage_free_up">Platz schaffen</string>
<string name="storage_play_anyway">Trotzdem spielen</string>
<plurals name="storage_games_installed">
@@ -3651,14 +3683,4 @@
<string name="wow_chat_listening">Hört zu…</string>
<string name="wow_chat_voice_unavailable">Spracheingabe ist auf diesem Gerät nicht verfügbar.</string>
<string name="wow_chat_mic_denied">Für die Spracheingabe wird Mikrofonzugriff benötigt.</string>
<string name="addons_download">Addons herunterladen…</string>
<string name="addons_sort_name">Name</string>
<string name="addons_sort_downloads">Meistgeladen</string>
<string name="addons_category_all">Alle Kategorien</string>
<string name="addons_none_found">Keine Addons gefunden</string>
<string name="addons_unreachable">%1$s ist nicht erreichbar</string>
<string name="addons_no_file">Keine Datei für diese Spielversion</string>
<string name="input_reset_positions">Positionen zurücksetzen</string>
<string name="input_show_label">Beschriftung anzeigen</string>
<string name="input_layout_import_failed">Dieses Layout konnte nicht importiert werden</string>
</resources>
+48 -26
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">Επαναφορά γραφικών και εκ νέου άνοιγμα του SteamVR…</string>
<string name="steamvr_recovery_failed">Το DroidDeck δεν μπόρεσε να κάνει αυτόματη επανεκκίνηση. Κλείστε εντελώς την εφαρμογή και ανοίξτε την ξανά.</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>Η πρώτη εκτέλεση κάνει λήψη του Steam. Αυτό μπορεί να διαρκέσει μερικά λεπτά.</item>
<item>Από προεπιλογή, η έξοδος από την εφαρμογή θέτει το Steam σε παύση· συνεχίζει από εκεί που είχε μείνει όταν επιστρέψετε.</item>
<item>Το χειριστήριό σας εμφανίζεται ως χειριστήριο Xbox 360. Διαφορετικά, χρησιμοποιήστε τα χειριστήρια στην οθόνη.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>Η πρώτη εκτέλεση ρυθμίζει την επιφάνεια εργασίας. Αυτό μπορεί να διαρκέσει μερικά λεπτά.</item>
<item>Από προεπιλογή, η έξοδος από την εφαρμογή θέτει τη συνεδρία σε παύση· συνεχίζει από εκεί που είχε μείνει όταν επιστρέψετε.</item>
<item>Το άγγιγμα λειτουργεί ως πινακίδα αφής: σύρετε για να μετακινήσετε τον δείκτη, πατήστε για κλικ.</item>
<item>Το χειριστήριό σας εμφανίζεται ως χειριστήριο Xbox 360. Διαφορετικά, χρησιμοποιήστε τα χειριστήρια στην οθόνη.</item>
</string-array>
<string name="session_channel">Συνεδρία</string>
<string name="session_channel_description">Εμφανίζεται όσο μια συνεδρία Steam ή επιφάνειας εργασίας είναι ανοιχτή</string>
<string name="session_notification">Το Steam εκτελείται</string>
@@ -272,8 +261,24 @@
<string name="osc_steam_touch">Αφή Steam (beta)</string>
<string name="osc_style_next_session">Τα χειριστήρια αφής του Steam ξεκινούν από την επόμενη συνεδρία Steam</string>
<string name="osc_steam_layout">Διάταξη αφής Steam</string>
<string name="screen_keys">Πλήκτρα οθόνης</string>
<string name="screen_keys_add">Προσθήκη πλήκτρου</string>
<string name="screen_key_text">Κείμενο</string>
<string name="screen_key_size">Μέγεθος</string>
<string name="screen_key_opacity">Διαφάνεια</string>
<string name="screen_key_type_button">Πλήκτρο</string>
<string name="screen_key_shape_trigger">Σκανδάλη</string>
<string name="screen_key_toggle">Εναλλαγή</string>
<string name="screen_key_layout_export">Εξαγωγή</string>
<string name="screen_key_shape_pill">Κάψουλα</string>
<string name="screen_key_shape_round">Στρογγυλεμένο</string>
<string name="screen_key_shape_circle">Κύκλος</string>
<string name="screen_key_shape_rect">Ορθογώνιο</string>
<string name="screen_key_bind">Πλήκτρο</string>
<string name="screen_key_image">Εικόνα</string>
<string name="screen_key_image_clear">Εκκαθάριση</string>
<string name="screen_key_layout">Διάταξη</string>
<string name="screen_key_layout_save">Αποθήκευση</string>
<string name="osc_steam_layout_hint">Μετακινήστε, αλλάξτε μέγεθος ή αποκρύψτε τα χειριστήρια αφής αυτού του παιχνιδιού· αποθηκεύεται στο Steam</string>
<string name="mode_controller">Χειριστήριο</string>
<string name="mode_controller_hint">Πώς βλέπει το Steam το χειριστήριό σας. Εφαρμόζεται στην επόμενη συνεδρία.</string>
@@ -550,7 +555,6 @@
<string name="comp_auto_none">Κανένα DroidDeck-Proton δεν το χρησιμοποιεί</string>
<string name="comp_auto_waits_network">Οι ενημερώσεις Auto περιμένουν σύνδεση.</string>
<string name="comp_auto_check_failed">Δεν ήταν δυνατός ο έλεγχος για νέες εκδόσεις.</string>
<string name="comp_auto_install_failed">Δεν ήταν δυνατή η εγκατάσταση του %1$s.</string>
<string name="comp_auto_waits_unmetered">Οι νέες εκδόσεις περιμένουν σύνδεση χωρίς όριο δεδομένων, απενεργοποιημένη εξοικονόμηση μπαταρίας και δροσερή συσκευή.</string>
<string name="comp_auto_updated">Το %1$s ενημερώθηκε σε %2$s.</string>
<string name="comp_auto_rolled_back">Το %1$s %2$s έκλεισε ένα παιχνίδι μέσα σε δευτερόλεπτα δύο φορές, οπότε το Auto επέστρεψε στο %3$s.</string>
@@ -752,6 +756,7 @@
<string name="drawer_relative_mouse_auto">Αυτόματα</string>
<string name="drawer_relative_mouse_on">Ενεργό</string>
<string name="drawer_relative_mouse_off">Ανενεργό</string>
<string name="drawer_direct_cursor">Δείκτης</string>
<string name="drawer_pc_keyboard">Πληκτρολόγιο PC</string>
<string name="drawer_android_keyboard">Android</string>
<string name="drawer_second_screen">Δεύτερη οθόνη</string>
@@ -1094,25 +1099,43 @@
<string name="wadb_computer_last">3 · ADB υπολογιστή (τελευταία λύση)</string>
<string name="wadb_only_if">Μόνο αν ο Ασύρματος εντοπισμός σφαλμάτων δεν είναι διαθέσιμος.</string>
<string name="wadb_computer_group">3 · ADB υπολογιστή (τελευταία λύση)</string>
<string name="ctrl_osc_hint">Όταν εμφανίζονται τα χειριστήρια αφής σε μια συνεδρία</string>
<string name="ctrl_color">Χρώμα</string>
<string name="ctrl_color_hint">Απόχρωση των κουμπιών οθόνης</string>
<string name="ctrl_custom">Προσαρμοσμένο</string>
<string name="ctrl_opacity">Διαφάνεια</string>
<string name="ctrl_percent">%1$d%%</string>
<string name="ctrl_size">Μέγεθος κουμπιών</string>
<string name="ctrl_size_hint" formatted="false">Το 100% είναι το κανονικό μέγεθος</string>
<string name="ctrl_stick_click">Κλικ μοχλού</string>
<string name="ctrl_stick_click_hint">Πατήστε δύο φορές και κρατήστε έναν μοχλό για L3 ή R3</string>
<string name="ctrl_adaptive">Προσαρμοστικοί μοχλοί</string>
<string name="ctrl_rumble">Κραδασμός παιχνιδιού</string>
<string name="ctrl_dead_zone">Νεκρή ζώνη μοχλού</string>
<string name="ctrl_steam_button">Κουμπί Steam</string>
<string name="ctrl_qam_button">Κουμπί QAM</string>
<string name="ctrl_keyboard_button">Κουμπί πληκτρολογίου</string>
<string name="ctrl_select_steam">Πατήστε Select για να ανοίξετε το μενού Steam</string>
<string name="ctrl_select_steam_hint">Αντί για View/Select.</string>
<string name="ctrl_joycon_vr">Joy-Con ως χειριστήρια VR</string>
<string name="ctrl_joycon_vr_hint">Όσο εκτελείται το SteamVR, κάθε Joy-Con γίνεται χέρι. Στρέψτε το μπροστά και κρατήστε πατημένο το Home ή το Capture για να το επανακεντράρετε.</string>
<string name="setup_animations">Κινήσεις</string>
<string name="setup_low_storage_warning">Προειδοποίηση για χαμηλό χώρο αποθήκευσης</string>
<string name="setup_low_storage_warning_hint">Ερώτηση πριν από την εκκίνηση του Steam όταν ο ελεύθερος χώρος είναι κάτω από 10 GB.</string>
<string name="ctrl_adaptive_hint">Οι μοχλοί εμφανίζονται όπου αγγίζετε και εξαφανίζονται όταν αφήνετε</string>
<string name="ctrl_layout">Διάταξη</string>
<string name="ctrl_layout_custom">Αποθηκεύτηκαν προσαρμοσμένες θέσεις</string>
<string name="ctrl_layout_default">Προσαρμοσμένο σε αυτή την οθόνη</string>
<string name="ctrl_edit">Επεξεργασία</string>
<string name="ctrl_reset">Επαναφορά</string>
<string name="ctrl_mapping">Αντιστοίχιση κουμπιών</string>
<string name="ctrl_mapping_default">Κάθε κουμπί στέλνει τη δική του είσοδο</string>
<string name="ctrl_mapping_count">%1$d από %2$d κουμπιά επαναντιστοιχίστηκαν</string>
<string name="ctrl_configure">Διαμόρφωση</string>
<string name="ctrl_reset_all">Επαναφορά χειριστηρίου</string>
<string name="ctrl_reset_all_hint">Επαναφέρει όλες τις ρυθμίσεις χειριστηρίου και τη διάταξη</string>
<string name="ctrl_mapping_lede">Τι στέλνει κάθε κουμπί οθόνης στο παιχνίδι. Το Κρυμμένο αφαιρεί το κουμπί.</string>
<string name="ctrl_onscreen_buttons">Κουμπιά οθόνης</string>
<string name="ctrl_defaults">Προεπιλογές</string>
<string name="ctrl_reset_mapping">Επαναφορά αντιστοίχισης</string>
<string name="content_android_apps">Εφαρμογές Android</string>
<string name="content_apps_count">%1$d εφαρμογές</string>
<string name="content_no_android_apps">Δεν βρέθηκαν εφαρμογές Android για εκκίνηση.</string>
@@ -1192,6 +1215,7 @@
<string name="gpu_none_listed">Δεν υπάρχουν ακόμα προγράμματα οδήγησης. Ανανεώστε για να τα αναζητήσετε.</string>
<string name="gpu_hide_other">Απόκρυψη προγραμμάτων οδήγησης για άλλες GPU</string>
<string name="gpu_show_other">Εμφάνιση προγραμμάτων οδήγησης για άλλες GPU</string>
<string name="gpu_advanced">Για προχωρημένους</string>
<string name="gpu_ubwc_title">Συμβατότητα UBWC</string>
<string name="gpu_ubwc_hint">Δοκιμάστε Ενεργό ή Ανενεργό αν το Steam ή το Android εμφανίζει γραμμές ή τρεμόπαιγμα. Ισχύει και για τα δύο προγράμματα οδήγησης μετά την επανεκκίνηση του DroidDeck.</string>
<string name="gpu_ubwc_restart">Κάντε επανεκκίνηση του DroidDeck για εφαρμογή.</string>
@@ -1232,8 +1256,15 @@
<string name="deck_surface_stick">Μοχλός</string>
<string name="deck_surface_trackpad">Πινακίδα αφής</string>
<string name="deck_surface_switch">Εναλλαγή μεταξύ μοχλού και πινακίδας αφής</string>
<string name="deck_more_controls">Περισσότερα χειριστήρια Deck</string>
<string name="deck_panels">ΠΙΝΑΚΕΣ</string>
<string name="deck_close">Κλείσιμο χειριστηρίων δεύτερης οθόνης</string>
<string name="deck_open_steam_menu">Άνοιγμα μενού Steam</string>
<string name="deck_left_upper">Αριστερά · πάνω</string>
<string name="deck_left_lower">Αριστερά · κάτω</string>
<string name="deck_right_upper">Δεξιά · πάνω</string>
<string name="deck_right_lower">Δεξιά · κάτω</string>
<string name="deck_click">Κλικ</string>
<string name="deck_right_trackpad">Δεξιά πινακίδα αφής</string>
<string name="deck_left_trackpad">Αριστερή πινακίδα αφής</string>
<string name="second_trackpad">Πινακίδα αφής</string>
@@ -1747,7 +1778,6 @@
<string name="ctrl_tint_amber">Κεχριμπαρί</string>
<string name="ctrl_tint_red">Κόκκινο</string>
<string name="ctrl_tint_pink">Ροζ</string>
<string name="ctrl_tint_gray">Γκρι</string>
<string name="ctrl_button_a">Κουμπί A</string>
<string name="ctrl_button_b">Κουμπί B</string>
<string name="ctrl_button_x">Κουμπί X</string>
@@ -1765,7 +1795,9 @@
<string name="ctrl_target_left">D-pad αριστερά</string>
<string name="ctrl_target_right">D-pad δεξιά</string>
<string name="ctrl_target_hidden">Κρυφό</string>
<string name="ctrl_editor_drag">Σύρετε για μετακίνηση</string>
<string name="ctrl_editor_snap">Προσκόλληση</string>
<string name="ctrl_editor_save">Αποθήκευση</string>
<string name="second_mode_none">Κανένα</string>
<string name="second_mode_keyboard_trackpad">Πληκτρολόγιο + πινακίδα αφής</string>
<string name="second_mode_terminal">Τερματικό</string>
@@ -2613,6 +2645,7 @@
<string name="gog_achievements_unavailable">Τα επιτεύγματα δεν είναι διαθέσιμα· ελέγξτε τα αρχεία καταγραφής της συνεδρίας</string>
<string name="achievement_unlocked">Το επίτευγμα ξεκλειδώθηκε</string>
<string name="touch_paste">Επικόλληση</string>
<string name="touch_dpad">Σταυρός κατεύθυνσης</string>
<string name="touch_left_stick">Αριστερό στικ</string>
<string name="touch_right_stick">Δεξί στικ</string>
<string name="touch_menu">Μενού</string>
@@ -2974,7 +3007,6 @@
<string name="storage_low_title">Μόνο %1$s ελεύθερα</string>
<string name="storage_low_body">Το Steam χρειάζεται χώρο για ενημερώσεις και προσωρινή μνήμη shaders. Κάτω από 10 GB οι ενημερώσεις παιχνιδιών μπορεί να αποτύχουν στη μέση.</string>
<string name="storage_low_safe">Τα %1$s μπορούν να ελευθερωθούν με ασφάλεια.</string>
<string name="storage_low_never">Να μην εμφανιστεί ξανά η προειδοποίηση</string>
<string name="storage_free_up">Ελευθέρωσε χώρο</string>
<string name="storage_play_anyway">Παίξε παρόλα αυτά</string>
<plurals name="storage_games_installed">
@@ -3651,14 +3683,4 @@
<string name="wow_chat_listening">Ακρόαση…</string>
<string name="wow_chat_voice_unavailable">Η φωνητική πληκτρολόγηση δεν είναι διαθέσιμη σε αυτή τη συσκευή.</string>
<string name="wow_chat_mic_denied">Απαιτείται πρόσβαση στο μικρόφωνο για φωνητική πληκτρολόγηση.</string>
<string name="addons_download">Λήψη πρόσθετων…</string>
<string name="addons_sort_name">Όνομα</string>
<string name="addons_sort_downloads">Περισσότερες λήψεις</string>
<string name="addons_category_all">Όλες οι κατηγορίες</string>
<string name="addons_none_found">Δεν βρέθηκαν πρόσθετα</string>
<string name="addons_unreachable">Δεν ήταν δυνατή η σύνδεση με %1$s</string>
<string name="addons_no_file">Δεν υπάρχει αρχείο για αυτή την έκδοση του παιχνιδιού</string>
<string name="input_reset_positions">Επαναφορά θέσεων</string>
<string name="input_show_label">Εμφάνιση ετικέτας</string>
<string name="input_layout_import_failed">Δεν ήταν δυνατή η εισαγωγή αυτής της διάταξης</string>
</resources>
+48 -26
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">Restableciendo los gráficos y volviendo a abrir SteamVR…</string>
<string name="steamvr_recovery_failed">DroidDeck no pudo reiniciarse automáticamente. Cierra la app por completo y vuelve a abrirla.</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>La primera vez se descarga Steam. Puede tardar unos minutos.</item>
<item>De forma predeterminada, al salir de la app Steam se pausa y continúa donde lo dejaste cuando vuelves.</item>
<item>Tu mando aparece como un mando Xbox 360. Si no, usa los controles en pantalla.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>La primera vez se prepara el escritorio. Puede tardar unos minutos.</item>
<item>De forma predeterminada, al salir de la app la sesión se pausa y continúa donde la dejaste cuando vuelves.</item>
<item>El tacto funciona como un touchpad: arrastra para mover el puntero y toca para hacer clic.</item>
<item>Tu mando aparece como un mando Xbox 360. Si no, usa los controles en pantalla.</item>
</string-array>
<string name="session_channel">Sesión</string>
<string name="session_channel_description">Se muestra mientras hay una sesión de Steam o de escritorio abierta</string>
<string name="session_notification">Steam se está ejecutando</string>
@@ -272,8 +261,24 @@
<string name="osc_steam_touch">Táctil de Steam (beta)</string>
<string name="osc_style_next_session">Los controles táctiles de Steam se activan en la próxima sesión de Steam</string>
<string name="osc_steam_layout">Disposición táctil de Steam</string>
<string name="screen_keys">Teclas en pantalla</string>
<string name="screen_keys_add">Añadir tecla</string>
<string name="screen_key_text">Texto</string>
<string name="screen_key_size">Tamaño</string>
<string name="screen_key_opacity">Opacidad</string>
<string name="screen_key_type_button">Tecla</string>
<string name="screen_key_shape_trigger">Gatillo</string>
<string name="screen_key_toggle">Alternar</string>
<string name="screen_key_layout_export">Exportar</string>
<string name="screen_key_shape_pill">Píldora</string>
<string name="screen_key_shape_round">Redondeado</string>
<string name="screen_key_shape_circle">Círculo</string>
<string name="screen_key_shape_rect">Rectángulo</string>
<string name="screen_key_bind">Tecla</string>
<string name="screen_key_image">Imagen</string>
<string name="screen_key_image_clear">Borrar</string>
<string name="screen_key_layout">Disposición</string>
<string name="screen_key_layout_save">Guardar</string>
<string name="osc_steam_layout_hint">Mueve, cambia de tamaño u oculta los controles táctiles de este juego; se guarda en Steam</string>
<string name="mode_controller">Mando</string>
<string name="mode_controller_hint">Cómo ve Steam tu mando. Se aplica en la próxima sesión.</string>
@@ -552,7 +557,6 @@
<string name="comp_auto_none">Ningún DroidDeck-Proton lo usa</string>
<string name="comp_auto_waits_network">Las actualizaciones Auto esperan una conexión.</string>
<string name="comp_auto_check_failed">No se pudieron buscar nuevas versiones.</string>
<string name="comp_auto_install_failed">No se pudo instalar %1$s.</string>
<string name="comp_auto_waits_unmetered">Las nuevas versiones esperan una conexión sin límite de datos, el ahorro de batería desactivado y el dispositivo frío.</string>
<string name="comp_auto_updated">%1$s actualizado a %2$s.</string>
<string name="comp_auto_rolled_back">%1$s %2$s cerró un juego en segundos dos veces, así que Auto volvió a %3$s.</string>
@@ -754,6 +758,7 @@
<string name="drawer_relative_mouse_auto">Automático</string>
<string name="drawer_relative_mouse_on">Activado</string>
<string name="drawer_relative_mouse_off">Desactivado</string>
<string name="drawer_direct_cursor">Cursor</string>
<string name="drawer_pc_keyboard">Teclado de PC</string>
<string name="drawer_android_keyboard">Android</string>
<string name="drawer_second_screen">Segunda pantalla</string>
@@ -1100,25 +1105,43 @@
<string name="wadb_computer_last">3 · ADB desde ordenador (último recurso)</string>
<string name="wadb_only_if">Solo si la Depuración inalámbrica no está disponible.</string>
<string name="wadb_computer_group">3 · ADB desde ordenador (último recurso)</string>
<string name="ctrl_osc_hint">Cuándo aparecen los controles táctiles en una sesión</string>
<string name="ctrl_color">Color</string>
<string name="ctrl_color_hint">Tinte de los botones en pantalla</string>
<string name="ctrl_custom">Personalizado</string>
<string name="ctrl_opacity">Opacidad</string>
<string name="ctrl_percent">%1$d%%</string>
<string name="ctrl_size">Tamaño de botones</string>
<string name="ctrl_size_hint" formatted="false">100% es el tamaño estándar</string>
<string name="ctrl_stick_click">Clic del stick</string>
<string name="ctrl_stick_click_hint">Toca dos veces y mantén un stick para L3 o R3</string>
<string name="ctrl_adaptive">Sticks adaptables</string>
<string name="ctrl_rumble">Vibración en juegos</string>
<string name="ctrl_dead_zone">Zona muerta de la palanca</string>
<string name="ctrl_steam_button">Botón Steam</string>
<string name="ctrl_qam_button">Botón QAM</string>
<string name="ctrl_keyboard_button">Botón de teclado</string>
<string name="ctrl_select_steam">Pulsa Select para abrir el menú de Steam</string>
<string name="ctrl_select_steam_hint">En lugar de View/Select.</string>
<string name="ctrl_joycon_vr">Joy-Con como mandos de RV</string>
<string name="ctrl_joycon_vr_hint">Mientras SteamVR está activo, cada Joy-Con es una mano. Apúntalo al frente y mantén Home o Captura para recentrarlo.</string>
<string name="setup_animations">Animaciones</string>
<string name="setup_low_storage_warning">Avisar cuando queda poco almacenamiento</string>
<string name="setup_low_storage_warning_hint">Preguntar antes de iniciar Steam cuando el espacio libre es inferior a 10 GB.</string>
<string name="ctrl_adaptive_hint">Los sticks aparecen donde tocas y se ocultan al soltar</string>
<string name="ctrl_layout">Disposición</string>
<string name="ctrl_layout_custom">Posiciones personalizadas guardadas</string>
<string name="ctrl_layout_default">Ajustada a esta pantalla</string>
<string name="ctrl_edit">Editar</string>
<string name="ctrl_reset">Restablecer</string>
<string name="ctrl_mapping">Asignación de botones</string>
<string name="ctrl_mapping_default">Cada botón envía su propia entrada</string>
<string name="ctrl_mapping_count">%1$d de %2$d botones reasignados</string>
<string name="ctrl_configure">Configurar</string>
<string name="ctrl_reset_all">Restablecer mando</string>
<string name="ctrl_reset_all_hint">Restaura todos los ajustes del mando y la disposición</string>
<string name="ctrl_mapping_lede">Lo que cada botón en pantalla envía al juego. Oculto quita el botón.</string>
<string name="ctrl_onscreen_buttons">Botones en pantalla</string>
<string name="ctrl_defaults">Predeterminados</string>
<string name="ctrl_reset_mapping">Restablecer asignación</string>
<string name="content_android_apps">Apps de Android</string>
<string name="content_apps_count">%1$d apps</string>
<string name="content_no_android_apps">No se encontraron apps de Android que se puedan abrir.</string>
@@ -1199,6 +1222,7 @@
<string name="gpu_none_listed">Aún no hay controladores listados. Actualiza para buscarlos.</string>
<string name="gpu_hide_other">Ocultar controladores de otras GPU</string>
<string name="gpu_show_other">Mostrar controladores de otras GPU</string>
<string name="gpu_advanced">Avanzado</string>
<string name="gpu_ubwc_title">Compatibilidad con UBWC</string>
<string name="gpu_ubwc_hint">Prueba Activado o Desactivado si Steam o Android muestran franjas o parpadeos. Se aplica a ambos controladores después de reiniciar DroidDeck.</string>
<string name="gpu_ubwc_restart">Reinicia DroidDeck para aplicarlo.</string>
@@ -1239,8 +1263,15 @@
<string name="deck_surface_stick">Stick</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Cambiar entre stick y trackpad</string>
<string name="deck_more_controls">Más controles de Deck</string>
<string name="deck_panels">PANELES</string>
<string name="deck_close">Cerrar controles de la segunda pantalla</string>
<string name="deck_open_steam_menu">Abrir menú de Steam</string>
<string name="deck_left_upper">Izquierdo · superior</string>
<string name="deck_left_lower">Izquierdo · inferior</string>
<string name="deck_right_upper">Derecho · superior</string>
<string name="deck_right_lower">Derecho · inferior</string>
<string name="deck_click">Clic</string>
<string name="deck_right_trackpad">Trackpad derecho</string>
<string name="deck_left_trackpad">Trackpad izquierdo</string>
<string name="second_trackpad">Trackpad</string>
@@ -1771,7 +1802,6 @@
<string name="ctrl_tint_amber">Ámbar</string>
<string name="ctrl_tint_red">Rojo</string>
<string name="ctrl_tint_pink">Rosa</string>
<string name="ctrl_tint_gray">Gris</string>
<string name="ctrl_button_a">Botón A</string>
<string name="ctrl_button_b">Botón B</string>
<string name="ctrl_button_x">Botón X</string>
@@ -1789,7 +1819,9 @@
<string name="ctrl_target_left">Cruceta izquierda</string>
<string name="ctrl_target_right">Cruceta derecha</string>
<string name="ctrl_target_hidden">Oculto</string>
<string name="ctrl_editor_drag">Arrastra para mover</string>
<string name="ctrl_editor_snap">Ajustar</string>
<string name="ctrl_editor_save">Guardar</string>
<string name="second_mode_none">Nada</string>
<string name="second_mode_keyboard_trackpad">Teclado + trackpad</string>
<string name="second_mode_terminal">Terminal</string>
@@ -2637,6 +2669,7 @@
<string name="gog_achievements_unavailable">Logros no disponibles; revisa los registros de la sesión</string>
<string name="achievement_unlocked">Logro desbloqueado</string>
<string name="touch_paste">Pegar</string>
<string name="touch_dpad">Pad direccional</string>
<string name="touch_left_stick">Stick izquierdo</string>
<string name="touch_right_stick">Stick derecho</string>
<string name="touch_menu">Menú</string>
@@ -3003,7 +3036,6 @@
<string name="storage_low_title">Solo quedan %1$s libres</string>
<string name="storage_low_body">Steam necesita espacio para actualizaciones y cachés de shaders. Por debajo de 10 GB, las actualizaciones de juegos pueden fallar a medias.</string>
<string name="storage_low_safe">Se pueden liberar %1$s sin riesgo.</string>
<string name="storage_low_never">No volver a avisarme</string>
<string name="storage_free_up">Liberar espacio</string>
<string name="storage_play_anyway">Jugar igualmente</string>
<plurals name="storage_games_installed">
@@ -3690,14 +3722,4 @@
<string name="wow_chat_listening">Escuchando…</string>
<string name="wow_chat_voice_unavailable">El dictado por voz no está disponible en este dispositivo.</string>
<string name="wow_chat_mic_denied">Se necesita acceso al micrófono para el dictado por voz.</string>
<string name="addons_download">Descargar complementos…</string>
<string name="addons_sort_name">Nombre</string>
<string name="addons_sort_downloads">Más descargados</string>
<string name="addons_category_all">Todas las categorías</string>
<string name="addons_none_found">No se han encontrado complementos</string>
<string name="addons_unreachable">No se pudo conectar con %1$s</string>
<string name="addons_no_file">No hay ningún archivo para esta versión del juego</string>
<string name="input_reset_positions">Restablecer posiciones</string>
<string name="input_show_label">Mostrar etiqueta</string>
<string name="input_layout_import_failed">No se ha podido importar esta disposición</string>
</resources>
Loaded 100 of 219 files, more files were not shown because too many files have changed in this diff. Show more