mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-09 10:09:45 -10:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efd9f36985 | ||
|
|
1dabc20e92 | ||
|
|
18c7d56409 | ||
|
|
0b4bc827e7 | ||
|
|
a552f1e293 | ||
|
|
066834d17d | ||
|
|
f33d5b9e09 | ||
|
|
250f80fbb5 | ||
|
|
542bbf89c3 | ||
|
|
e1987ca785 | ||
|
|
a81da1e476 | ||
|
|
116728917f | ||
|
|
5f180d909b | ||
|
|
88acdbc3ab | ||
|
|
42b2810822 | ||
|
|
1001bc00af | ||
|
|
69c6c14f3f | ||
|
|
6060f2288f | ||
|
|
a1bc8467fd | ||
|
|
d6566f0dbe |
No files matched your search
@@ -39,11 +39,25 @@ jobs:
|
||||
path: |
|
||||
gamescope.tzst
|
||||
gamescope.tzst.sha256
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: components-token
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
|
||||
with:
|
||||
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
|
||||
private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
|
||||
owner: Droid-Deck
|
||||
repositories: DroidDeck-Components
|
||||
- name: Publish
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_TOKEN: ${{ steps.components-token.outputs.token }}
|
||||
GH_REPO: Droid-Deck/DroidDeck-Components
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh release create "${{ inputs.tag }}" --latest=false --prerelease=false --title "${{ inputs.tag }}" --notes "gamescope 3.16.29 with tools/gamescope/patches; staged by the apk over /usr/local/bin/gamescope. sha256 $(cut -d' ' -f1 gamescope.tzst.sha256)" gamescope.tzst gamescope.tzst.sha256 || gh release upload "${{ inputs.tag }}" gamescope.tzst gamescope.tzst.sha256 --clobber
|
||||
gh release edit "${{ inputs.tag }}" --latest=false --prerelease=false
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
echo "::error::$TAG is already published in DroidDeck-Components, and published tags are never replaced: use a new tag"
|
||||
exit 1
|
||||
fi
|
||||
gh release create "$TAG" --latest=false --prerelease=false --title "$TAG" --notes "gamescope 3.16.29 with tools/gamescope/patches ($GITHUB_REPOSITORY $GITHUB_SHA, run $GITHUB_RUN_ID); staged by the apk over /usr/local/bin/gamescope. sha256 $(cut -d' ' -f1 gamescope.tzst.sha256)" gamescope.tzst gamescope.tzst.sha256
|
||||
gh release edit "$TAG" --latest=false --prerelease=false
|
||||
@@ -47,10 +47,18 @@ jobs:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
retry() {
|
||||
for attempt in 1 2 3 4; do
|
||||
"$@" && return 0
|
||||
[ "$attempt" = 4 ] || sleep $((attempt * 20))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
existing=$(retry gh api --paginate "repos/Droid-Deck/DroidDeck-CI/releases?per_page=100" --jq '.[].tag_name')
|
||||
found=0
|
||||
for tag in "pr-$PR_NUMBER" "pr-$PR_NUMBER.0"; do
|
||||
if gh release view "$tag" -R Droid-Deck/DroidDeck-CI >/dev/null 2>&1; then
|
||||
gh release delete "$tag" -R Droid-Deck/DroidDeck-CI --yes --cleanup-tag
|
||||
if grep -qxF "$tag" <<<"$existing"; then
|
||||
retry gh release delete "$tag" -R Droid-Deck/DroidDeck-CI --yes --cleanup-tag
|
||||
echo "Deleted $tag"
|
||||
found=1
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
name: PR Release Sweep
|
||||
run-name: sweep test builds of closed PRs
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '17 */6 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: pr-release-sweep
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sweep:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: ci-token
|
||||
with:
|
||||
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
|
||||
private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
|
||||
owner: Droid-Deck
|
||||
repositories: DroidDeck-CI
|
||||
- name: Delete test builds whose PR is no longer open
|
||||
env:
|
||||
CI_TOKEN: ${{ steps.ci-token.outputs.token }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
retry() {
|
||||
for attempt in 1 2 3 4; do
|
||||
"$@" && return 0
|
||||
[ "$attempt" = 4 ] || sleep $((attempt * 20))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
tags=$(GH_TOKEN="$CI_TOKEN" retry gh api --paginate "repos/Droid-Deck/DroidDeck-CI/releases?per_page=100" --jq '.[].tag_name' |
|
||||
grep -E '^pr-[0-9]+(\.0)?$' || true)
|
||||
failed=0
|
||||
for tag in $tags; do
|
||||
number=${tag#pr-}
|
||||
number=${number%.0}
|
||||
state=$(retry gh api "repos/$GITHUB_REPOSITORY/pulls/$number" --jq .state) || { failed=1; continue; }
|
||||
if [ "$state" != open ]; then
|
||||
if GH_TOKEN="$CI_TOKEN" retry gh release delete "$tag" -R Droid-Deck/DroidDeck-CI --yes --cleanup-tag; then
|
||||
echo "Deleted $tag (PR #$number is $state)"
|
||||
else
|
||||
failed=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
exit "$failed"
|
||||
@@ -5,8 +5,11 @@ import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.session.ComponentUpdater
|
||||
import com.droiddeck.launcher.session.ComponentUpdates
|
||||
import com.droiddeck.launcher.session.ComponentsManager
|
||||
import com.droiddeck.launcher.session.ProtonDefault
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
|
||||
internal class ComponentsMenu(private val activity: android.app.Activity, private val ui: Handler) {
|
||||
var compSnapshot by mutableStateOf<ComponentsManager.Snapshot?>(null)
|
||||
@@ -20,6 +23,10 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
|
||||
get() = downloads
|
||||
private set(value) { downloads = value }
|
||||
val compDownloadRevision: Long get() = downloadRevision
|
||||
var compModes by mutableStateOf<Map<String, String>>(emptyMap())
|
||||
var compAuto by mutableStateOf<Map<String, List<ComponentUpdater.Status>>>(emptyMap())
|
||||
var compAutoHeld by mutableStateOf<Set<String>>(emptySet())
|
||||
var compAutoChecking by mutableStateOf(false)
|
||||
private var shownDefault: String? = null
|
||||
|
||||
fun refreshComponents(snapshotFirst: Boolean = false) {
|
||||
@@ -29,7 +36,14 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
|
||||
val snap = runCatching { ComponentsManager.snapshot(activity) }.onFailure { Log.w(TAG, "components", it) }.getOrNull()
|
||||
val cat = runCatching { ComponentsManager.catalog(activity, false) }.getOrNull()
|
||||
val chosen = snap?.let { s -> runCatching { ProtonDefault.selectedId(activity, s.protons.map { it.proton }) }.getOrNull() }
|
||||
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.hardwareDxvk(activity) }.getOrDefault(false)) setOf("dxvk") else emptySet()
|
||||
ui.post {
|
||||
compModes = modes
|
||||
compAuto = auto
|
||||
compAutoHeld = held
|
||||
compSnapshot = snap ?: ComponentsManager.Snapshot(emptyList(), emptyList())
|
||||
if (cat != null && cat.fetchedAt > 0) { compCatalog = cat.items; compCatalogAt = cat.fetchedAt }
|
||||
if (chosen != null && chosen != shownDefault) {
|
||||
@@ -42,6 +56,35 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
|
||||
}, "components").start()
|
||||
}
|
||||
|
||||
fun chooseComponentMode(comp: String, mode: String) {
|
||||
SessionPrefs.setComponentMode(activity, comp, mode)
|
||||
compModes = compModes + (comp to mode)
|
||||
if (mode != ComponentUpdates.AUTO) return
|
||||
checkComponentUpdates(force = true)
|
||||
}
|
||||
|
||||
private var checkAgain: Boolean? = null
|
||||
|
||||
fun checkComponentUpdates(force: Boolean) {
|
||||
if (compAutoChecking) {
|
||||
checkAgain = force || checkAgain == true
|
||||
return
|
||||
}
|
||||
compAutoChecking = true
|
||||
Thread({
|
||||
val notes = runCatching { ComponentUpdater.ensure(activity, force) }.onFailure { Log.w(TAG, "component updates", it) }.getOrDefault(emptyList())
|
||||
ui.post {
|
||||
compAutoChecking = false
|
||||
checkAgain?.let { again ->
|
||||
checkAgain = null
|
||||
checkComponentUpdates(again)
|
||||
}
|
||||
if (notes.isNotEmpty()) android.widget.Toast.makeText(activity, notes.joinToString("\n"), android.widget.Toast.LENGTH_LONG).show()
|
||||
refreshComponents()
|
||||
}
|
||||
}, "component-updates").start()
|
||||
}
|
||||
|
||||
fun chooseProton(id: String) {
|
||||
val proton = compSnapshot?.protons?.firstOrNull { it.proton.id == id }?.proton ?: return
|
||||
compProton = id
|
||||
|
||||
@@ -40,6 +40,8 @@ import com.droiddeck.launcher.ui.PackageRow
|
||||
import com.droiddeck.launcher.ui.exposeTestTags
|
||||
import com.droiddeck.launcher.session.OfflineMode
|
||||
import com.droiddeck.launcher.session.ProtonExtras
|
||||
import com.droiddeck.launcher.session.ComponentUpdater
|
||||
import com.droiddeck.launcher.session.ComponentUpdates
|
||||
import com.droiddeck.launcher.session.ComponentsManager
|
||||
import com.droiddeck.launcher.ui.ComponentsPage
|
||||
import com.droiddeck.launcher.session.GameSaves
|
||||
@@ -94,7 +96,11 @@ class FrontEndActivity : ComponentActivity() {
|
||||
private val components = ComponentsMenu(this, ui)
|
||||
private val decky = DeckyMenu(this, ui)
|
||||
private val updates = UpdatesMenu(this, ui)
|
||||
private val protons = ProtonMenu(this, ui) { !busy && flood == null && compatLaunch == null && pkgStage == null && components.compBusy == null }
|
||||
private val protons = ProtonMenu(
|
||||
this, ui,
|
||||
idle = { !busy && flood == null && compatLaunch == null && pkgStage == null && components.compBusy == null },
|
||||
onInstalled = { if (!OfflineMode.enabled(this)) components.checkComponentUpdates(force = false) },
|
||||
)
|
||||
|
||||
private var installed by mutableStateOf<String?>(null)
|
||||
private var flood by mutableStateOf<PendingFlood?>(null)
|
||||
@@ -201,6 +207,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
private var gameStoresSpeedTier by mutableStateOf("fast")
|
||||
private var navRequest by mutableStateOf<String?>(null)
|
||||
private val gameLaunchRequest = java.util.concurrent.atomic.AtomicLong()
|
||||
private var preparingGames by mutableStateOf(emptySet<String>())
|
||||
private var storesShowMature by mutableStateOf(false)
|
||||
private var storesSort by mutableStateOf(SessionPrefs.STORES_SORTS.first())
|
||||
private var storesView by mutableStateOf(SessionPrefs.DEFAULT_STORES_VIEW)
|
||||
@@ -1137,6 +1144,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
installed = installed, ready = ready, available = available?.version, removalPending = removalPending, runtimeActionsBlocked = runtimeChangesBlocked(),
|
||||
vkBridge = vkBridge, venus = venus, venusHostVulkan = venusHostVulkan, vkBridgeSafe = vkBridgeSafe, bridgeProtonDxvk = bridgeProtonDxvk, adreno = adrenoGpu,
|
||||
shortcutPicker = shortcutPicker,
|
||||
preparingGames = preparingGames,
|
||||
shortcutLibraryScanning = shortcutLibraryScanning,
|
||||
gameSyncFolder = gameSyncFolder,
|
||||
busy = busy, stage = stage, percent = percent,
|
||||
@@ -1215,14 +1223,26 @@ class FrontEndActivity : ComponentActivity() {
|
||||
},
|
||||
onSteamGame = { g ->
|
||||
if (shortcutPicker) chooseGameShortcut(g)
|
||||
else {
|
||||
val request = gameLaunchRequest.incrementAndGet()
|
||||
com.droiddeck.launcher.frontend.AutoComponents.beforeLaunch(this, g) {
|
||||
if (request != gameLaunchRequest.get() || isFinishing || isDestroyed) return@beforeLaunch
|
||||
com.droiddeck.launcher.stores.StoreLaunch.prepare(this, g) {
|
||||
if (request == gameLaunchRequest.get() && !isFinishing && !isDestroyed) launchGame(g)
|
||||
else if (g.launchKey !in preparingGames) {
|
||||
preparingGames = preparingGames + g.launchKey
|
||||
prepareAndLaunch(g, g.launchKey)
|
||||
}
|
||||
},
|
||||
onStoreGame = { store, id ->
|
||||
val key = "${store.id}:$id"
|
||||
if (key !in preparingGames) {
|
||||
preparingGames = preparingGames + key
|
||||
val app = applicationContext
|
||||
Thread({
|
||||
val g = runCatching { com.droiddeck.launcher.stores.StoreLaunch.installedGame(app, store, id) }.getOrNull()
|
||||
runOnUiThread {
|
||||
if (g != null && !isFinishing && !isDestroyed) prepareAndLaunch(g, key)
|
||||
else {
|
||||
preparingGames = preparingGames - key
|
||||
if (g == null && !isFinishing && !isDestroyed) android.widget.Toast.makeText(this, R.string.stores_launch_not_registered, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}, "store-play").start()
|
||||
}
|
||||
},
|
||||
onEpicLauncher = { openEpicLauncher() },
|
||||
@@ -1880,6 +1900,13 @@ class FrontEndActivity : ComponentActivity() {
|
||||
if (applied.isNotEmpty()) ui.post {
|
||||
android.widget.Toast.makeText(this, getString(R.string.main_applied, applied.joinToString(", ")), android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
if (!OfflineMode.enabled(this)) {
|
||||
val updated = runCatching { ComponentUpdater.ensure(this, force = false) }
|
||||
.onFailure { android.util.Log.w("ComponentUpdater", "automatic component updates", it) }.getOrDefault(emptyList())
|
||||
if (updated.isNotEmpty()) ui.post {
|
||||
android.widget.Toast.makeText(this, updated.joinToString("\n"), android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
ui.post {
|
||||
componentsQueueBusy = false
|
||||
if (showComponents) components.refreshComponents()
|
||||
@@ -1942,6 +1969,18 @@ class FrontEndActivity : ComponentActivity() {
|
||||
if (Build.VERSION.SDK_INT >= 33) state.getParcelable(key, Intent::class.java)
|
||||
else @Suppress("DEPRECATION") state.getParcelable(key)
|
||||
|
||||
private fun prepareAndLaunch(g: Library.SteamGame, key: String) {
|
||||
val request = gameLaunchRequest.incrementAndGet()
|
||||
val done = { preparingGames = preparingGames - key }
|
||||
com.droiddeck.launcher.frontend.AutoComponents.beforeLaunch(this, g, onBlocked = done) {
|
||||
if (request != gameLaunchRequest.get() || isFinishing || isDestroyed) { done(); return@beforeLaunch }
|
||||
com.droiddeck.launcher.stores.StoreLaunch.prepare(this, g) {
|
||||
done()
|
||||
if (request == gameLaunchRequest.get() && !isFinishing && !isDestroyed) launchGame(g)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
gameLaunchRequest.incrementAndGet()
|
||||
cancelCompatLaunch()
|
||||
@@ -2127,9 +2166,11 @@ class FrontEndActivity : ComponentActivity() {
|
||||
private fun importComponent(uri: Uri) {
|
||||
val name = displayNameOf(uri) ?: "imported.wcp"
|
||||
components.componentAction(getString(R.string.comp_busy_importing)) {
|
||||
val tmp = File(cacheDir, "component-import.wcp")
|
||||
contentResolver.openInputStream(uri)?.use { input -> tmp.outputStream().use { input.copyTo(it) } } ?: error(getString(R.string.comp_cannot_read))
|
||||
try { getString(R.string.comp_imported, ComponentsManager.importPackage(this, tmp, name).version) } finally { tmp.delete() }
|
||||
val tmp = File.createTempFile("component-import", ".wcp", cacheDir)
|
||||
try {
|
||||
contentResolver.openInputStream(uri)?.use { input -> tmp.outputStream().use { input.copyTo(it) } } ?: error(getString(R.string.comp_cannot_read))
|
||||
getString(R.string.comp_imported, ComponentUpdater.importPackage(this, tmp, name).version)
|
||||
} finally { tmp.delete() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2153,7 +2194,15 @@ class FrontEndActivity : ComponentActivity() {
|
||||
onDeletePackage = { file -> components.componentAction(getString(R.string.comp_busy_deleting)) { ComponentsManager.deletePackage(this, file) } },
|
||||
onDeleteOriginal = { version -> components.compProton?.let { pid -> components.componentAction(getString(R.string.comp_busy_deleting)) { ComponentsManager.deleteOriginal(this, pid, components.compComp, version) } } },
|
||||
onDownload = { components.downloadComponent(it) },
|
||||
onRefresh = { components.refreshComponentCatalog() },
|
||||
onRefresh = {
|
||||
components.refreshComponentCatalog()
|
||||
if (components.compModes.values.any { it == ComponentUpdates.AUTO }) components.checkComponentUpdates(force = true)
|
||||
},
|
||||
compModes = components.compModes,
|
||||
compAuto = components.compAuto,
|
||||
compAutoHeld = components.compAutoHeld,
|
||||
compAutoChecking = components.compAutoChecking,
|
||||
onCompMode = { comp, mode -> components.chooseComponentMode(comp, mode) },
|
||||
onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, getString(R.string.comp_pick_wcp))) },
|
||||
onBack = { if (!com.droiddeck.launcher.ui.Hops.back { showComponents = false }) showComponents = false },
|
||||
games = steamGames,
|
||||
@@ -2982,6 +3031,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
private fun removeRuntime() {
|
||||
if (runtimeChangesBlocked()) return
|
||||
val removal = LinuxRuntimeInstaller.beginUninstall(this) ?: return
|
||||
com.droiddeck.launcher.runtime.RuntimeAutoUpdate.seed(this)
|
||||
ready = false
|
||||
com.droiddeck.launcher.runtime.RuntimeInstallService.keepRemovalAlive(this)
|
||||
followRuntimeOperation { removal.run(it) }
|
||||
@@ -3008,6 +3058,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
android.widget.Toast.makeText(this, it, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
refresh()
|
||||
if (ok == true && LinuxRuntime.isInstalled(this)) protons.checkUpdates()
|
||||
}
|
||||
}, "runtime-operation").start()
|
||||
}
|
||||
@@ -3015,9 +3066,12 @@ class FrontEndActivity : ComponentActivity() {
|
||||
private fun autoUpdateRuntime() {
|
||||
val release = available?.takeIf { catalogFresh } ?: return
|
||||
if (!lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED) || runtimeChangesBlocked() || removalPending) return
|
||||
if (release.version == installed || installed == null) return
|
||||
if (installed != null) com.droiddeck.launcher.runtime.RuntimeAutoUpdate.seed(this)
|
||||
if (release.version == installed) return
|
||||
if (installed == null && (OfflineMode.enabled(this) || !com.droiddeck.launcher.core.DeviceSupport.canDraw(this) ||
|
||||
!com.droiddeck.launcher.frontend.AutoComponents.online(this) || com.droiddeck.launcher.frontend.AutoComponents.unfavourable(this))) return
|
||||
if (com.droiddeck.launcher.runtime.RuntimeAutoUpdate.claim(this, installed, release.version)) {
|
||||
Log.i(TAG, "runtime: updating $installed to ${release.version} by itself")
|
||||
Log.i(TAG, "runtime: installing ${release.version} over ${installed ?: "nothing"} by itself")
|
||||
install(release)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller
|
||||
internal class ProtonMenu(
|
||||
private val activity: android.app.Activity,
|
||||
private val ui: Handler,
|
||||
private val onInstalled: () -> Unit = {},
|
||||
private val idle: () -> Boolean = { true },
|
||||
) {
|
||||
private object Operation {
|
||||
@@ -61,6 +62,7 @@ internal class ProtonMenu(
|
||||
if (automatic) Log.w("ProtonMenu", "auto-update ${tool.id}: $problem")
|
||||
else android.widget.Toast.makeText(activity, problem, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
if (problem == null) onInstalled()
|
||||
if (automatic) ensureAutoUpdates()
|
||||
}
|
||||
}, "install-proton-$id").start()
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import android.hardware.input.InputManager
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.content.Intent
|
||||
@@ -233,6 +234,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var screenKeyBind: List<Int>? = null
|
||||
private var screenKeyDir: Int? = null
|
||||
private var screenKeyTextFocus = false
|
||||
private val appTextFocus get() = screenKeyTextFocus || wowChat.request != null
|
||||
private val wowChat by lazy { com.droiddeck.launcher.ui.WowChat(this, ::sendWowChat, ::cancelWowChat, ::readGameField) }
|
||||
private var wowChatBottom: SecondScreenPresentation? = null
|
||||
private var wowChatOnTop by mutableStateOf(false)
|
||||
private var screenKeyEdit by mutableStateOf<com.droiddeck.launcher.input.ScreenKey?>(null)
|
||||
private var screenLayoutsOpen by mutableStateOf(false)
|
||||
private var screenKeysShown by mutableStateOf(true)
|
||||
@@ -826,7 +831,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(it))
|
||||
}
|
||||
sessionOverlay = createSessionOverlay()
|
||||
root.addView(sessionOverlay)
|
||||
root.addView(object : FrameLayout(this) {
|
||||
override fun onResolvePointerIcon(event: MotionEvent, pointerIndex: Int): android.view.PointerIcon? =
|
||||
if (platformPointerShown == true) super.onResolvePointerIcon(event, pointerIndex) else null
|
||||
}.apply { addView(sessionOverlay) })
|
||||
setContentView(root)
|
||||
glassesScope.launch { Glasses.state.collect {
|
||||
glasses = it
|
||||
@@ -910,6 +918,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
androidx.compose.runtime.LaunchedEffect(AgentGuest.steamKeyboard) { updateOnScreenControls() }
|
||||
androidx.compose.runtime.LaunchedEffect(AgentGuest.desktopTextField) { onDesktopTextField(AgentGuest.desktopTextField) }
|
||||
androidx.compose.runtime.LaunchedEffect(AgentGuest.gameTextField, AgentGuest.gameProgram) { onGameTextField(AgentGuest.gameTextField) }
|
||||
androidx.compose.runtime.LaunchedEffect(wowChat.request) { placeWowChat() }
|
||||
androidx.compose.runtime.LaunchedEffect(pipUi, pcKeyboardOpen) { updateDesktopKeyboardButton() }
|
||||
val keyboardRequestsSeen = androidx.compose.runtime.remember { intArrayOf(AgentGuest.keyboardRequests) }
|
||||
androidx.compose.runtime.LaunchedEffect(AgentGuest.keyboardRequests) {
|
||||
@@ -1033,6 +1043,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (wowChat.asking) com.droiddeck.launcher.ui.WowChatChoiceDialog(wowChat)
|
||||
if (wowChatOnTop && wowChat.request != null) androidx.compose.foundation.layout.Box(
|
||||
Modifier.fillMaxSize().imePadding(), contentAlignment = androidx.compose.ui.Alignment.BottomCenter,
|
||||
) { com.droiddeck.launcher.ui.WowChatPanel(wowChat) }
|
||||
if (gestureSettingsOpen) com.droiddeck.launcher.ui.TouchGestureDialog(
|
||||
game = gestureGame,
|
||||
onDismiss = { gestureSettingsOpen = false },
|
||||
@@ -1310,6 +1324,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
},
|
||||
onKeyboard = { drawerOpen = false; pcKeyboardOpen = false; keyboard?.toggle() },
|
||||
onHardwareKeyboard = ::togglePcKeyboard,
|
||||
wowChatShown = com.droiddeck.launcher.ui.WowChat.isWow(AgentGuest.gameProgram),
|
||||
wowChat = wowChat.remembered,
|
||||
onWowChat = wowChat::choose,
|
||||
controller = controllerSettings,
|
||||
onRumble = { on -> updateControllerPrefs { ControllerPrefs.setRumble(this@SessionActivity, on) } },
|
||||
onSteamButton = { on -> updateControllerPrefs { ControllerPrefs.setSteamButton(this@SessionActivity, on) } },
|
||||
@@ -2270,9 +2287,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
|
||||
private fun windowOutputSize(width: Int, height: Int): Pair<Int, Int>? {
|
||||
if (width <= 0 || height <= 0) return null
|
||||
if (isInPictureInPictureMode) return com.droiddeck.launcher.session.PipPolicy.outputSize(
|
||||
width, height, SessionState.outputSize.first, SessionState.outputSize.second)
|
||||
if (width <= 0 || height <= 0 || isInPictureInPictureMode) return null
|
||||
if (bottomGame != null) return when {
|
||||
SessionState.followScreen ->
|
||||
com.droiddeck.launcher.session.SessionDisplay.followSize(width, height, false, SessionState.followScale)
|
||||
@@ -2642,7 +2657,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
SessionState.mode == SessionService.MODE_STEAM && SessionState.running &&
|
||||
SessionState.firstFrameSeen && !SessionState.suspended && !ended &&
|
||||
!pipUi && !drawerOpen && !pcKeyboardOpen && !achievementsOpen &&
|
||||
!gestureSettingsOpen && !screenKeyTextFocus && window.decorView.hasWindowFocus()
|
||||
!gestureSettingsOpen && !appTextFocus && window.decorView.hasWindowFocus()
|
||||
if (steamVolume) {
|
||||
val audio = getSystemService(android.media.AudioManager::class.java)
|
||||
if (com.droiddeck.launcher.input.SteamVolumeKeys.dispatch(event, !audio.isVolumeFixed) { direction ->
|
||||
@@ -2656,10 +2671,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
Log.i(TAG, String.format(java.util.Locale.ROOT, "back key device=%d (%s) source=0x%x flags=0x%x",
|
||||
event.deviceId, event.device?.name, event.source, event.flags))
|
||||
}
|
||||
val mouseToGuest = !pipUi && !screenKeyTextFocus && !drawerOpen && !pcKeyboardOpen && !achievementsOpen && !gestureSettingsOpen &&
|
||||
val mouseToGuest = !pipUi && !appTextFocus && !drawerOpen && !pcKeyboardOpen && !achievementsOpen && !gestureSettingsOpen &&
|
||||
!SessionState.suspended && !(::loading.isInitialized && (loading.visible || loading.ended))
|
||||
if (mouseBackInput.onKeyEvent(event, mouseToGuest)) return true
|
||||
val backToQam = !pipUi && !screenKeyTextFocus && !drawerOpen && !pcKeyboardOpen && !achievementsOpen &&
|
||||
val backToQam = !pipUi && !appTextFocus && !drawerOpen && !pcKeyboardOpen && !achievementsOpen &&
|
||||
SessionState.mode == SessionService.MODE_STEAM && SessionState.running && SessionState.firstFrameSeen &&
|
||||
!SessionState.suspended && padBridge?.canHoldQam() == true
|
||||
if (backQamInput.onKeyEvent(event, backToQam)) {
|
||||
@@ -2668,7 +2683,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return true
|
||||
}
|
||||
if (pipUi) return true
|
||||
if (screenKeyTextFocus) return super.dispatchKeyEvent(event)
|
||||
if (appTextFocus) return super.dispatchKeyEvent(event)
|
||||
if (physicalShortcuts.onKey(
|
||||
event,
|
||||
enabled = SessionState.running && !SessionState.suspended &&
|
||||
@@ -3552,20 +3567,105 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
showCursor(px, py)
|
||||
}
|
||||
|
||||
private fun openBottomKeyboard() {
|
||||
if (!started) return
|
||||
private fun bottomKeyboardPresentation(): SecondScreenPresentation? {
|
||||
if (!started) return null
|
||||
if (secondScreenPresentation?.showsKeyboard != true && dockDrawer()) {
|
||||
val target = com.droiddeck.launcher.input.DualScreen.sessionDisplay(this, displayManager, shownOnDisplayId) ?: return
|
||||
val target = com.droiddeck.launcher.input.DualScreen.sessionDisplay(this, displayManager, shownOnDisplayId) ?: return null
|
||||
if (drawerOpen || drawerPinned) drawerOpen = false
|
||||
typingReturnMode = secondScreenMode
|
||||
selectedSecondScreenDisplay = target.displayId
|
||||
selectSecondScreenMode(SecondScreenMode.KEYBOARD_TRACKPAD)
|
||||
}
|
||||
val presentation = secondScreenPresentation ?: return
|
||||
if (!presentation.showsKeyboard) return
|
||||
val display = presentation.display?.displayId ?: return
|
||||
return secondScreenPresentation?.takeIf { it.showsKeyboard && it.display != null }
|
||||
}
|
||||
|
||||
private fun openBottomKeyboard() {
|
||||
val presentation = bottomKeyboardPresentation() ?: return
|
||||
presentation.requestKeyboard(forField = true)
|
||||
com.droiddeck.launcher.ui.SessionTyping.focusDisplay(this, display)
|
||||
com.droiddeck.launcher.ui.SessionTyping.focusDisplay(this, presentation.display.displayId)
|
||||
}
|
||||
|
||||
private fun onGameTextField(focused: Boolean) {
|
||||
if (!focused || !com.droiddeck.launcher.ui.WowChat.isWow(AgentGuest.gameProgram)) wowChat.chatClosed()
|
||||
else if (resumed && !pipUi && !pcKeyboardOpen &&
|
||||
resources.configuration.hardKeyboardHidden != android.content.res.Configuration.HARDKEYBOARDHIDDEN_NO) {
|
||||
keyboard?.takeIf { it.shown }?.hide()
|
||||
wowChat.chatOpened()
|
||||
}
|
||||
}
|
||||
|
||||
private fun placeWowChat() {
|
||||
val open = wowChat.request != null
|
||||
val shown = wowChatBottom
|
||||
wowChatOnTop = false
|
||||
if (open && shown == null) {
|
||||
val presentation = bottomKeyboardPresentation()
|
||||
if (presentation == null) {
|
||||
wowChatOnTop = true
|
||||
return
|
||||
}
|
||||
wowChatBottom = presentation
|
||||
presentation.showChat(onLost = {
|
||||
if (wowChatBottom === presentation) {
|
||||
wowChatBottom = null
|
||||
wowChatOnTop = wowChat.request != null
|
||||
}
|
||||
}) {
|
||||
androidx.compose.runtime.CompositionLocalProvider(
|
||||
androidx.activity.compose.LocalActivityResultRegistryOwner provides this,
|
||||
androidx.activity.compose.LocalOnBackPressedDispatcherOwner provides this,
|
||||
) { DroidDeckTheme { com.droiddeck.launcher.ui.WowChatPanel(wowChat) } }
|
||||
}
|
||||
com.droiddeck.launcher.ui.SessionTyping.focusDisplay(this, presentation.display.displayId)
|
||||
} else if (!open && shown != null) {
|
||||
wowChatBottom = null
|
||||
shown.hideChat()
|
||||
if (secondScreenPresentation === shown) bottomKeyboardClosed()
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendWowChat(text: String, replace: Boolean) {
|
||||
val host = keyboard ?: return
|
||||
if (replace) {
|
||||
selectGameField()
|
||||
if (text.isEmpty()) host.tap(KeyEvent.KEYCODE_DEL)
|
||||
} else {
|
||||
endGameField()
|
||||
}
|
||||
host.type(text)
|
||||
host.tap(KeyEvent.KEYCODE_ENTER)
|
||||
uiHandler.postDelayed({
|
||||
if (AgentGuest.gameTextField && com.droiddeck.launcher.ui.WowChat.isWow(AgentGuest.gameProgram)) endGameField()
|
||||
}, FIELD_SETTLE_MS)
|
||||
}
|
||||
|
||||
private fun endGameField() {
|
||||
WaylandCompositor.nativeSendKey(KEY_END, 1)
|
||||
WaylandCompositor.nativeSendKey(KEY_END, 0)
|
||||
}
|
||||
|
||||
private fun selectGameField(vararg then: Int) {
|
||||
val held = KEY_LEFTCTRL in guestKeysDown || KEY_LEFTCTRL in gestureKeysDown
|
||||
if (!held) WaylandCompositor.nativeSendKey(KEY_LEFTCTRL, 1)
|
||||
for (key in intArrayOf(KEY_A) + then) {
|
||||
WaylandCompositor.nativeSendKey(key, 1)
|
||||
WaylandCompositor.nativeSendKey(key, 0)
|
||||
}
|
||||
if (!held) WaylandCompositor.nativeSendKey(KEY_LEFTCTRL, 0)
|
||||
}
|
||||
|
||||
private fun readGameField(done: (String?) -> Unit) {
|
||||
val opened = wowChat.request
|
||||
val stillOpen = { wowChat.request === opened && AgentGuest.gameTextField }
|
||||
sessionClipboard.captureGuest(FIELD_READ_MS, onTimeout = { if (stillOpen()) endGameField() }) { text ->
|
||||
if (text != null && stillOpen()) endGameField()
|
||||
done(text)
|
||||
}
|
||||
selectGameField(KEY_C)
|
||||
}
|
||||
|
||||
private fun cancelWowChat() {
|
||||
keyboard?.tap(KeyEvent.KEYCODE_ESCAPE)
|
||||
}
|
||||
|
||||
private fun bottomKeyboardClosed() {
|
||||
@@ -3990,7 +4090,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
|
||||
private fun appUiOwnsPointer(): Boolean =
|
||||
drawerOpen || pcKeyboardOpen || achievementsOpen || pipUi || glassesOutputActive || bottomOutputActive || touchEditorOpen
|
||||
drawerOpen || pcKeyboardOpen || achievementsOpen || pipUi || glassesOutputActive || bottomOutputActive || touchEditorOpen || wowChatOnTop
|
||||
|
||||
private fun applyPlatformPointer(shown: Boolean) {
|
||||
if (!::sessionRoot.isInitialized || !::surfaceView.isInitialized) return
|
||||
@@ -4285,6 +4385,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private fun showPipUi(show: Boolean) {
|
||||
pipUi = show
|
||||
if (show) {
|
||||
if (::hud.isInitialized) hud.setHidden(true)
|
||||
drawerOpen = false
|
||||
pcKeyboardOpen = false
|
||||
achievementsOpen = false
|
||||
@@ -5199,6 +5300,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private const val AXIS_UNITS_PER_PIXEL = 3f
|
||||
private const val KEY_LEFTCTRL = 29
|
||||
private const val KEY_RIGHTCTRL = 97
|
||||
private const val KEY_A = 30
|
||||
private const val KEY_C = 46
|
||||
private const val KEY_END = 107
|
||||
private const val FIELD_READ_MS = 600L
|
||||
private const val FIELD_SETTLE_MS = 200L
|
||||
private const val BTN_SIDE = 0x113
|
||||
private const val BTN_EXTRA = 0x114
|
||||
private const val DESKTOP_GRAB_TAP_MS = 500L
|
||||
|
||||
@@ -26,6 +26,10 @@ object AgentGuest {
|
||||
private set
|
||||
var keyboardRequests by mutableIntStateOf(0)
|
||||
private set
|
||||
var gameTextField by mutableStateOf(false)
|
||||
private set
|
||||
var gameProgram by mutableStateOf<String?>(null)
|
||||
private set
|
||||
var keyboardDismissals by mutableIntStateOf(0)
|
||||
private set
|
||||
@Volatile private var keyboardRoute = false
|
||||
@@ -43,7 +47,7 @@ object AgentGuest {
|
||||
observer?.stopWatching()
|
||||
observer = null
|
||||
lastFocusKey = null
|
||||
main.post { steamKeyboard = false; desktopTextField = false }
|
||||
main.post { steamKeyboard = false; desktopTextField = false; gameTextField = false; gameProgram = null }
|
||||
val dir = dir(context)
|
||||
dir.deleteRecursively()
|
||||
dir.mkdirs()
|
||||
@@ -59,6 +63,7 @@ object AgentGuest {
|
||||
"focus.json" -> readJson(File(dir, path))?.let(::recordFocus)
|
||||
"keyboard.json" -> readJson(File(dir, path))?.let { recordKeyboard(this, it) }
|
||||
"ime.json" -> readJson(File(dir, path))?.let { recordTextField(this, it) }
|
||||
"wine-ime.json" -> readJson(File(dir, path))?.let { recordGameTextField(this, it) }
|
||||
"keyboard-request.json" -> readJson(File(dir, path))?.let { recordKeyboardRequest(this) }
|
||||
"keyboard-dismiss.json" -> readJson(File(dir, path))?.let { recordKeyboardDismiss(this) }
|
||||
}
|
||||
@@ -71,7 +76,7 @@ object AgentGuest {
|
||||
synchronized(lock) {
|
||||
observer?.stopWatching()
|
||||
observer = null
|
||||
main.post { steamKeyboard = false; desktopTextField = false }
|
||||
main.post { steamKeyboard = false; desktopTextField = false; gameTextField = false; gameProgram = null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +134,16 @@ object AgentGuest {
|
||||
SessionEvents.record("ime.changed", mapOf("textField" to focused, "seq" to snapshot.optInt("seq")))
|
||||
}
|
||||
|
||||
private fun recordGameTextField(source: FileObserver, snapshot: JSONObject) {
|
||||
val focused = snapshot.optBoolean("textField")
|
||||
val program = snapshot.optString("program").takeIf { it.isNotEmpty() }
|
||||
synchronized(lock) {
|
||||
if (source !== observer) return
|
||||
main.post { gameProgram = program; gameTextField = focused }
|
||||
}
|
||||
SessionEvents.record("game.ime.changed", mapOf("textField" to focused, "program" to program, "seq" to snapshot.optInt("seq")))
|
||||
}
|
||||
|
||||
private fun recordFocus(snapshot: JSONObject) {
|
||||
val fields = linkedMapOf<String, Any?>(
|
||||
"focusedApp" to snapshot.opt("focusedApp"),
|
||||
|
||||
@@ -475,15 +475,15 @@ object AutoComponents {
|
||||
return true
|
||||
}
|
||||
|
||||
fun beforeLaunch(context: Context, game: Library.SteamGame, onReady: () -> Unit) {
|
||||
fun beforeLaunch(context: Context, game: Library.SteamGame, onBlocked: () -> Unit = {}, onReady: () -> Unit) {
|
||||
val app = context.applicationContext
|
||||
Thread({
|
||||
val ready = runCatching { prepareLaunch(app, game) }.getOrElse {
|
||||
Log.w(TAG, "${game.name}: launch repair discovery failed: ${it.javaClass.simpleName}: ${it.message}")
|
||||
false
|
||||
}
|
||||
Handler(Looper.getMainLooper()).post(if (ready) onReady else onBlocked)
|
||||
queueGame(app, game, asked = false)
|
||||
if (ready) Handler(Looper.getMainLooper()).post(onReady)
|
||||
}, "auto-components-launch").apply { isDaemon = true; start() }
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ object Library {
|
||||
SteamGame(appId, name, art, library, gameId, hero, lastPlayed, gameFiles, protonPrefix, icon, source, storeId, logo)
|
||||
|
||||
val gameIdString: String get() = java.lang.Long.toUnsignedString(gameId)
|
||||
val launchKey: String get() = if (storeId != null) "$source:$storeId" else gameIdString
|
||||
val profileKey: String get() = when {
|
||||
source == com.droiddeck.launcher.stores.Store.BATTLENET.id -> com.droiddeck.launcher.runtime.BattleNet.APP_ID.toString()
|
||||
library == ADDED -> java.lang.Integer.toUnsignedString(appId)
|
||||
|
||||
@@ -14,8 +14,8 @@ object SteamLaunchOptions {
|
||||
private const val TAG = "SteamLaunchOptions"
|
||||
private val STEAM = listOf("UserLocalConfigStore", "Software", "Valve", "Steam")
|
||||
|
||||
fun current(context: Context, appId: Int): String =
|
||||
SteamLiveShortcuts.launchOptions(context, appId.toLong()) ?: steam(SessionPrefs.activeSteamRoot(context), appId).orEmpty()
|
||||
fun current(context: Context, appId: Int): String? =
|
||||
SteamLiveShortcuts.launchOptions(context, appId.toLong()) ?: steam(SessionPrefs.activeSteamRoot(context), appId)
|
||||
|
||||
enum class Saved { YES, NO, DESKTOP }
|
||||
|
||||
@@ -35,7 +35,8 @@ object SteamLaunchOptions {
|
||||
internal fun steam(steamHome: File, appId: Int): String? {
|
||||
val config = AddedGames.accountConfig(steamHome) ?: return null
|
||||
val text = runCatching { File(config, "localconfig.vdf").readText() }.getOrNull() ?: return null
|
||||
return launchOptions(text, appId)
|
||||
if (section(text, STEAM) == null) return null
|
||||
return launchOptions(text, appId).orEmpty()
|
||||
}
|
||||
|
||||
internal fun write(steamHome: File, appId: Int, options: String): Boolean {
|
||||
|
||||
@@ -141,6 +141,8 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
private var dDividerTop = -1f
|
||||
private var dFrame: Bitmap? = null
|
||||
private var dFrameKey = ""
|
||||
private var sFrame: Bitmap? = null
|
||||
private var sFrameKey = ""
|
||||
private val glowPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
|
||||
private val colGpu = 0xFF5EE08A.toInt()
|
||||
@@ -274,9 +276,17 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
seenFpsUpdates = updates
|
||||
periodDone.trySend(Unit)
|
||||
}
|
||||
if (graphRect != null) invalidate()
|
||||
if (graphRect == null) return
|
||||
if (stripRect != null) {
|
||||
val now = SystemClock.uptimeMillis()
|
||||
if (now - lastGraphDrawMs < STRIP_GRAPH_REDRAW_MS) return
|
||||
lastGraphDrawMs = now
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private var lastGraphDrawMs = 0L
|
||||
|
||||
private fun cycleSize() {
|
||||
size = size.next()
|
||||
onSizeCycledListener?.accept(size.token)
|
||||
@@ -524,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
|
||||
@@ -1369,17 +1382,30 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
}
|
||||
|
||||
private fun drawStrip(canvas: Canvas, rect: RectF) {
|
||||
headerPaint.shader = null
|
||||
headerPaint.color = panelColor()
|
||||
canvas.drawRect(rect, headerPaint)
|
||||
val stroke = outlineWidth()
|
||||
if (stroke <= 0f) return
|
||||
val line = max(stroke / 2f, density)
|
||||
val p = parent as? View
|
||||
val atBottom = p != null && p.height > 0 && y + height / 2f > p.height / 2f
|
||||
headerPaint.color = withAlpha(outlineColor(), (110 + 145 * outlineIntensity).roundToInt().coerceAtMost(255))
|
||||
if (atBottom) canvas.drawRect(rect.left, rect.top, rect.right, rect.top + line, headerPaint)
|
||||
else canvas.drawRect(rect.left, rect.bottom - line, rect.right, rect.bottom, headerPaint)
|
||||
stripFrame(rect, atBottom)?.let { canvas.drawBitmap(it, 0f, 0f, null) }
|
||||
}
|
||||
|
||||
private fun stripFrame(rect: RectF, atBottom: Boolean): Bitmap? {
|
||||
val w = rect.width().roundToInt(); val h = rect.height().roundToInt()
|
||||
if (w <= 0 || h <= 0) return null
|
||||
val stroke = outlineWidth()
|
||||
val key = "$w,$h,$stroke,$atBottom,$surfaceRgb,$bgOpacity,$outlineIntensity,${outlineColor()}"
|
||||
if (key == sFrameKey) sFrame?.let { return it }
|
||||
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
val c = Canvas(bmp)
|
||||
headerPaint.shader = null
|
||||
headerPaint.color = panelColor()
|
||||
c.drawRect(rect, headerPaint)
|
||||
if (stroke > 0f) {
|
||||
val line = max(stroke / 2f, density)
|
||||
headerPaint.color = withAlpha(outlineColor(), (110 + 145 * outlineIntensity).roundToInt().coerceAtMost(255))
|
||||
if (atBottom) c.drawRect(rect.left, rect.top, rect.right, rect.top + line, headerPaint)
|
||||
else c.drawRect(rect.left, rect.bottom - line, rect.right, rect.bottom, headerPaint)
|
||||
}
|
||||
sFrame = bmp; sFrameKey = key
|
||||
return bmp
|
||||
}
|
||||
|
||||
private fun outlineWidth(): Float = if (outlineIntensity > 0f) outlineIntensity * sp(3.5f) else 0f
|
||||
@@ -1499,6 +1525,7 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
const val TAB_PAD_X_SP = 7f
|
||||
const val TAB_PAD_Y_SP = 5.5f
|
||||
const val REFRESH_MS = 500L
|
||||
const val STRIP_GRAPH_REDRAW_MS = 66L
|
||||
private const val HUE_IDLE_MS = 500L
|
||||
const val MIN_SCALE = 0.5f
|
||||
const val MAX_SCALE = 2f
|
||||
|
||||
@@ -89,21 +89,31 @@ class KeyboardHost(
|
||||
clearFocus()
|
||||
}
|
||||
|
||||
fun type(text: CharSequence?) {
|
||||
if (text.isNullOrEmpty()) return
|
||||
val events = map.getEvents(text.toString().toCharArray())
|
||||
if (events != null) {
|
||||
for (event in events) sendKeyEvent(event)
|
||||
} else {
|
||||
for (ch in text) {
|
||||
val t = System.currentTimeMillis()
|
||||
val down = KeyEvent(t, t, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_UNKNOWN, 0, 0,
|
||||
KeyCharacterMap.VIRTUAL_KEYBOARD, 0, 0, 0)
|
||||
sendKeyEvent(CharKeyEvent(down, ch))
|
||||
sendKeyEvent(CharKeyEvent(KeyEvent.changeAction(down, KeyEvent.ACTION_UP), ch))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun tap(keyCode: Int) {
|
||||
val t = System.currentTimeMillis()
|
||||
sendKeyEvent(KeyEvent(t, t, KeyEvent.ACTION_DOWN, keyCode, 0))
|
||||
sendKeyEvent(KeyEvent(t, t, KeyEvent.ACTION_UP, keyCode, 0))
|
||||
}
|
||||
|
||||
private inner class KeyConnection : BaseInputConnection(this, false) {
|
||||
override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean {
|
||||
if (text.isNullOrEmpty()) return true
|
||||
val events = map.getEvents(text.toString().toCharArray())
|
||||
if (events != null) {
|
||||
for (event in events) sendKeyEvent(event)
|
||||
} else {
|
||||
for (ch in text) {
|
||||
val t = System.currentTimeMillis()
|
||||
val down = KeyEvent(t, t, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_UNKNOWN, 0, 0,
|
||||
KeyCharacterMap.VIRTUAL_KEYBOARD, 0, 0, 0)
|
||||
sendKeyEvent(CharKeyEvent(down, ch))
|
||||
sendKeyEvent(CharKeyEvent(KeyEvent.changeAction(down, KeyEvent.ACTION_UP), ch))
|
||||
}
|
||||
}
|
||||
type(text)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -123,12 +133,6 @@ class KeyboardHost(
|
||||
return true
|
||||
}
|
||||
|
||||
private fun tap(keyCode: Int) {
|
||||
val t = System.currentTimeMillis()
|
||||
sendKeyEvent(KeyEvent(t, t, KeyEvent.ACTION_DOWN, keyCode, 0))
|
||||
sendKeyEvent(KeyEvent(t, t, KeyEvent.ACTION_UP, keyCode, 0))
|
||||
}
|
||||
|
||||
override fun getTextBeforeCursor(n: Int, flags: Int): CharSequence = ""
|
||||
override fun getTextAfterCursor(n: Int, flags: Int): CharSequence = ""
|
||||
override fun getSelectedText(flags: Int): CharSequence? = null
|
||||
|
||||
@@ -15,6 +15,7 @@ import android.view.View
|
||||
import android.view.WindowInsets
|
||||
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
|
||||
@@ -48,6 +49,10 @@ class OnScreenControls(
|
||||
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
|
||||
@@ -61,6 +66,7 @@ class OnScreenControls(
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -73,6 +79,14 @@ class OnScreenControls(
|
||||
}
|
||||
|
||||
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),
|
||||
@@ -545,6 +559,10 @@ class OnScreenControls(
|
||||
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
|
||||
@@ -613,7 +631,7 @@ class OnScreenControls(
|
||||
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 (control.clicked || event.actionMasked == MotionEvent.ACTION_CANCEL) 0L else event.eventTime
|
||||
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
|
||||
@@ -794,6 +812,10 @@ class OnScreenControls(
|
||||
|
||||
private companion object {
|
||||
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
|
||||
|
||||
@@ -19,11 +19,20 @@ class SessionClipboard(context: Context, private val hasFocus: () -> Boolean) {
|
||||
private var published = false
|
||||
private var shared: String? = null
|
||||
private var echo: String? = null
|
||||
private var capture: ((String?) -> Unit)? = null
|
||||
private var captureBefore: String? = null
|
||||
private var captureLate: (() -> Unit)? = null
|
||||
private val androidListener = ClipboardManager.OnPrimaryClipChangedListener { refresh(changed = true) }
|
||||
private val guestListener = object : WaylandCompositor.ClipboardListener {
|
||||
override fun onGuestClipboardText(text: String) {
|
||||
handler.post {
|
||||
if (active && hasFocus() && WaylandCompositor.isClipboardListener(this)) {
|
||||
val taken = capture?.takeIf { text != captureBefore }
|
||||
if (taken != null && WaylandCompositor.isClipboardListener(this)) {
|
||||
capture = null
|
||||
handler.removeCallbacks(captureTimeout)
|
||||
WaylandCompositor.setClipboardText(shared)
|
||||
taken(text)
|
||||
} else if (active && hasFocus() && WaylandCompositor.isClipboardListener(this)) {
|
||||
val current = clipboard.primaryClip?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.text
|
||||
shared = text
|
||||
published = true
|
||||
@@ -36,6 +45,21 @@ class SessionClipboard(context: Context, private val hasFocus: () -> Boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
private val captureTimeout = Runnable {
|
||||
val taken = capture ?: return@Runnable
|
||||
capture = null
|
||||
captureLate?.invoke()
|
||||
taken(null)
|
||||
}
|
||||
|
||||
fun captureGuest(timeoutMs: Long, onTimeout: () -> Unit = {}, onText: (String?) -> Unit) {
|
||||
capture?.let { handler.removeCallbacks(captureTimeout); it(null) }
|
||||
capture = onText
|
||||
captureLate = onTimeout
|
||||
captureBefore = shared
|
||||
handler.postDelayed(captureTimeout, timeoutMs)
|
||||
}
|
||||
|
||||
fun start() {
|
||||
if (active) return
|
||||
active = true
|
||||
@@ -47,6 +71,8 @@ class SessionClipboard(context: Context, private val hasFocus: () -> Boolean) {
|
||||
fun stop() {
|
||||
if (!active) return
|
||||
active = false
|
||||
handler.removeCallbacks(captureTimeout)
|
||||
capture?.let { capture = null; it(null) }
|
||||
clipboard.removePrimaryClipChangedListener(androidListener)
|
||||
WaylandCompositor.clearClipboardListener(guestListener)
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
|
||||
@@ -8,8 +8,10 @@ object RuntimeAutoUpdate {
|
||||
const val MAX_ATTEMPTS = 3
|
||||
const val RETRY_MS = 10 * 60_000L
|
||||
|
||||
fun due(installed: String?, release: String?, attempts: Int, lastAt: Long = 0L, now: Long = lastAt + RETRY_MS): Boolean =
|
||||
installed != null && !release.isNullOrEmpty() && release != installed && attempts < MAX_ATTEMPTS &&
|
||||
private const val SEEDED = "seeded"
|
||||
|
||||
fun due(installed: String?, release: String?, attempts: Int, lastAt: Long = 0L, now: Long = lastAt + RETRY_MS, first: Boolean = false): Boolean =
|
||||
(installed != null || first) && !release.isNullOrEmpty() && release != installed && attempts < MAX_ATTEMPTS &&
|
||||
(attempts == 0 || now - lastAt !in 0 until RETRY_MS)
|
||||
|
||||
fun key(appVersion: Int, release: String) = "$appVersion:$release"
|
||||
@@ -20,11 +22,17 @@ object RuntimeAutoUpdate {
|
||||
val key = key(BuildConfig.VERSION_CODE, release)
|
||||
synchronized(this) {
|
||||
val attempts = prefs.getInt(key, 0)
|
||||
if (!due(installed, release, attempts, prefs.getLong("$key@at", 0L), now)) return false
|
||||
val first = installed == null && !prefs.getBoolean(SEEDED, false)
|
||||
if (!due(installed, release, attempts, prefs.getLong("$key@at", 0L), now, first)) return false
|
||||
val edit = prefs.edit()
|
||||
prefs.all.keys.filterNot { it.startsWith("${BuildConfig.VERSION_CODE}:") }.forEach { edit.remove(it) }
|
||||
prefs.all.keys.filterNot { it.startsWith("${BuildConfig.VERSION_CODE}:") || it == SEEDED }.forEach { edit.remove(it) }
|
||||
edit.putInt(key, attempts + 1).putLong("$key@at", now).apply()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
fun seed(context: Context) {
|
||||
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
if (!prefs.getBoolean(SEEDED, false)) prefs.edit().putBoolean(SEEDED, true).apply()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AtomicFile
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.frontend.AutoComponents
|
||||
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"
|
||||
internal const val HAND_BACK_FILES = "handBackFiles"
|
||||
private const val IMPORTED = "imported"
|
||||
private val lock = Any()
|
||||
private val io = Any()
|
||||
|
||||
data class Status(val line: String, val current: String, val previous: String, val protons: List<String>, val note: String)
|
||||
|
||||
private fun file(context: Context) = File(File(context.filesDir, "components").apply { mkdirs() }, "updates.json")
|
||||
|
||||
private fun load(context: Context): JSONObject = synchronized(io) {
|
||||
runCatching { JSONObject(String(AtomicFile(file(context)).readFully(), Charsets.UTF_8)) }.getOrDefault(JSONObject())
|
||||
}
|
||||
|
||||
private fun save(context: Context, state: JSONObject) {
|
||||
synchronized(io) {
|
||||
val atomic = AtomicFile(file(context))
|
||||
val output = runCatching { atomic.startWrite() }.onFailure { Log.w(TAG, "could not save", it) }.getOrNull() ?: return
|
||||
try {
|
||||
output.write(state.toString(1).toByteArray(Charsets.UTF_8))
|
||||
atomic.finishWrite(output)
|
||||
} catch (error: Exception) {
|
||||
atomic.failWrite(output)
|
||||
Log.w(TAG, "could not save", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Map<String, Map<String, ComponentsManager.Choice>>.choiceOf(p: ComponentsManager.Proton, comp: String): ComponentsManager.Choice? =
|
||||
this[p.id]?.get(comp)?.takeIf { it.protonVersion == p.version }
|
||||
|
||||
private fun Map<String, Map<String, ComponentsManager.Choice>>.fileOf(p: ComponentsManager.Proton, comp: String): String? = choiceOf(p, comp)?.file
|
||||
|
||||
private class Target(val comp: String, val p: ComponentsManager.Proton, val line: ComponentUpdates.Line, val ls: JSONObject) {
|
||||
val key: String get() = line.key
|
||||
}
|
||||
|
||||
private fun JSONObject.obj(key: String): JSONObject = optJSONObject(key) ?: JSONObject().also { put(key, it) }
|
||||
|
||||
private fun JSONArray?.strings(): Set<String> = this?.let { a -> (0 until a.length()).map { a.optString(it) }.toSet() } ?: emptySet()
|
||||
|
||||
private fun JSONObject.strings(key: String): Set<String> = optJSONArray(key).strings()
|
||||
|
||||
private fun JSONObject?.ints(): Map<String, Int> = this?.keys()?.asSequence()?.associateWith { optInt(it) } ?: emptyMap()
|
||||
|
||||
private fun JSONObject?.texts(): Map<String, String> = this?.keys()?.asSequence()?.associateWith { optString(it) } ?: emptyMap()
|
||||
|
||||
fun settle(context: Context) {
|
||||
val owned = load(context).optJSONObject("lines")?.let { lines -> lines.keys().asSequence().flatMap { lines.optJSONObject(it).strings("owned") }.toSet() } ?: emptySet()
|
||||
val own = (runCatching { ComponentsManager.protons(context) }.getOrNull() ?: return).filter(ComponentUpdates::follows).mapTo(HashSet()) { it.id }
|
||||
val choices = ComponentsManager.activeChoices(context).filterKeys { it in own }.values.flatMap { it.entries }
|
||||
for (comp in ComponentsManager.COMPONENTS) {
|
||||
if (SessionPrefs.componentModeChosen(context, comp)) continue
|
||||
val manual = choices.any { (c, choice) -> c == comp && ComponentUpdates.manual(choice, null, owned) }
|
||||
SessionPrefs.setComponentMode(context, comp, if (manual) ComponentUpdates.MANUAL else ComponentUpdates.AUTO)
|
||||
}
|
||||
}
|
||||
|
||||
private fun managed(context: Context, comp: String): Boolean =
|
||||
SessionPrefs.componentMode(context, comp) == ComponentUpdates.AUTO && !(comp == "dxvk" && ComponentsManager.hardwareDxvk(context))
|
||||
|
||||
private fun logLines(context: Context): Sequence<String> {
|
||||
val dir = ComponentsManager.steamLogs(context)
|
||||
return sequenceOf("console_log.previous.txt", "console_log.txt").map { File(dir, it) }.filter { it.isFile }
|
||||
.flatMap { f -> runCatching { f.readLines() }.getOrDefault(emptyList()).asSequence() }
|
||||
}
|
||||
|
||||
private val STAMP = Regex("""^\[(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d)]""")
|
||||
|
||||
private fun latestStamp(context: Context): String =
|
||||
logLines(context).mapNotNull { STAMP.find(it)?.groupValues?.get(1) }.maxOrNull() ?: "0"
|
||||
|
||||
private fun lineOf(context: Context, state: JSONObject, p: ComponentsManager.Proton, comp: String): ComponentUpdates.Line? {
|
||||
if (!ComponentUpdates.follows(p) || !ComponentsManager.supports(p, comp)) return null
|
||||
if (comp == "fex") return ComponentUpdates.line("fex", false)
|
||||
val key = "${p.id}|${p.version}"
|
||||
val flavors = state.obj("flavors").obj(key)
|
||||
val arm64ec = if (flavors.has(comp)) flavors.optBoolean(comp) else ComponentsManager.arm64ec(context, p, comp)?.also { flavors.put(comp, it) }
|
||||
return arm64ec?.let { ComponentUpdates.line(comp, it) }
|
||||
}
|
||||
|
||||
fun ensure(context: Context, force: Boolean): List<String> = synchronized(lock) {
|
||||
if (SessionState.running) return emptyList()
|
||||
settle(context)
|
||||
val state = load(context)
|
||||
val notes = ArrayList<String>()
|
||||
val found = runCatching { ComponentsManager.protons(context) }.getOrNull()
|
||||
val protons = found ?: emptyList()
|
||||
if (found != null) handBack(context, state, found)
|
||||
judge(context, state, protons, notes)
|
||||
val due = force || System.currentTimeMillis() - state.optLong("checkedAt") >= CHECK_EVERY_MS
|
||||
val wanted = ComponentsManager.COMPONENTS.filter { managed(context, it) }
|
||||
if (wanted.isEmpty() || protons.isEmpty()) {
|
||||
save(context, state)
|
||||
return notes
|
||||
}
|
||||
try {
|
||||
dropRemoved(state, protons)
|
||||
val checked = due && check(context, state, protons, wanted, force, notes)
|
||||
apply(context, state, protons, wanted, notes)
|
||||
prune(context, state)
|
||||
if (checked) state.put("checkedAt", System.currentTimeMillis())
|
||||
} finally {
|
||||
save(context, state)
|
||||
}
|
||||
notes
|
||||
}
|
||||
|
||||
private fun check(
|
||||
context: Context, state: JSONObject, protons: List<ComponentsManager.Proton>, wanted: List<String>, force: Boolean, notes: MutableList<String>,
|
||||
): Boolean {
|
||||
if (!AutoComponents.isOnline(context)) {
|
||||
state.put("note", context.getString(R.string.comp_auto_waits_network))
|
||||
return false
|
||||
}
|
||||
val catalog = runCatching { ComponentsManager.catalog(context, true).items }
|
||||
.onFailure { Log.w(TAG, "catalog", it) }.getOrDefault(emptyList())
|
||||
if (catalog.none { it.release == ComponentsManager.DD_NIGHTLY }) {
|
||||
state.put("note", context.getString(R.string.comp_auto_check_failed))
|
||||
return false
|
||||
}
|
||||
val holdBack = !force && AutoComponents.constrained(context)
|
||||
val lines = state.obj("lines")
|
||||
var complete = true
|
||||
state.remove("note")
|
||||
for (comp in wanted) for (p in protons) {
|
||||
val line = lineOf(context, state, p, comp) ?: continue
|
||||
val ls = lines.obj(line.key)
|
||||
if (rollingBack(ls)) {
|
||||
complete = false
|
||||
continue
|
||||
}
|
||||
val current = ls.optString("current")
|
||||
val missing = current.isNotEmpty() && !ComponentsManager.isStored(context, current)
|
||||
val relisted = if (missing) catalog.firstOrNull { it.file == current } else null
|
||||
if (relisted != null) {
|
||||
if (holdBack) {
|
||||
state.put("note", context.getString(R.string.comp_auto_waits_unmetered))
|
||||
complete = false
|
||||
continue
|
||||
}
|
||||
val got = runCatching { ComponentsManager.download(context, relisted) {} }
|
||||
if (got.isFailure) {
|
||||
Log.w(TAG, "download ${relisted.file} again", got.exceptionOrNull())
|
||||
state.put("note", context.getString(R.string.comp_auto_check_failed))
|
||||
complete = false
|
||||
continue
|
||||
}
|
||||
own(state, ls, current)
|
||||
}
|
||||
val target = ComponentUpdates.pick(catalog, line, ls.strings("bad"))
|
||||
if (target == null || current == target.file) continue
|
||||
val stored = ComponentsManager.isStored(context, target.file)
|
||||
if (!stored) {
|
||||
if (holdBack) {
|
||||
state.put("note", context.getString(R.string.comp_auto_waits_unmetered))
|
||||
complete = false
|
||||
continue
|
||||
}
|
||||
val got = runCatching { ComponentsManager.download(context, target) {} }
|
||||
if (got.isFailure) {
|
||||
Log.w(TAG, "download ${target.file}", got.exceptionOrNull())
|
||||
state.put("note", context.getString(R.string.comp_auto_check_failed))
|
||||
complete = false
|
||||
continue
|
||||
}
|
||||
}
|
||||
val stocks = protons.filter { lineOf(context, state, it, comp)?.key == line.key }.map { ComponentsManager.arm64x(context, it, comp) }
|
||||
if (ComponentUpdates.needsArm64x(line, stocks) && !ComponentsManager.packageArm64x(context, target.file, comp)) {
|
||||
Log.w(TAG, "${target.file} is not ARM64X like ${p.name}'s own ${line.key}; skipped")
|
||||
ls.put("bad", JSONArray(ls.strings("bad") + target.file))
|
||||
if (!stored) runCatching { ComponentsManager.deletePackage(context, target.file) }
|
||||
continue
|
||||
}
|
||||
ls.put("previous", if (missing && relisted == null) ComponentUpdates.ORIGINAL else current.ifEmpty { ComponentUpdates.ORIGINAL })
|
||||
.put("current", target.file).put("since", latestStamp(context)).put("failed", JSONObject())
|
||||
if (!stored) own(state, ls, target.file)
|
||||
ls.remove("note")
|
||||
notes += context.getString(R.string.comp_auto_updated, ComponentsManager.LABEL[comp], target.file.removeSuffix(".wcp"))
|
||||
}
|
||||
return complete
|
||||
}
|
||||
|
||||
private fun apply(context: Context, state: JSONObject, protons: List<ComponentsManager.Proton>, wanted: List<String>, notes: MutableList<String>) {
|
||||
val lines = state.obj("lines")
|
||||
val auto = placed(state)
|
||||
val choices = ComponentsManager.activeChoices(context)
|
||||
val targets = wanted.flatMap { comp ->
|
||||
protons.mapNotNull { p -> lineOf(context, state, p, comp)?.let { line -> lines.optJSONObject(line.key)?.let { Target(comp, p, line, it) } } }
|
||||
}.filterNot { t -> ComponentUpdates.manual(choices.choiceOf(t.p, t.comp), t.ls.optJSONObject("applied")?.optString(t.p.id), auto, t.ls.optString("current")) }
|
||||
for (t in targets) {
|
||||
val from = t.ls.optJSONObject("restore")?.optString(t.p.id).orEmpty()
|
||||
val current = t.ls.optString("current")
|
||||
if (from.isNotEmpty()) {
|
||||
if (choices.fileOf(t.p, t.comp) == from) runCatching { ComponentsManager.restore(context, t.p.id, t.comp, ComponentsManager.safeName(t.p.version)) }
|
||||
.onFailure { Log.w(TAG, "restore ${t.p.name} ${t.key}", it) }
|
||||
} else if (current.isNotEmpty() && choices.fileOf(t.p, t.comp) != current) {
|
||||
if (ComponentUpdates.needsArm64x(t.line, listOf(ComponentsManager.arm64x(context, t.p, t.comp))) &&
|
||||
!ComponentsManager.packageArm64x(context, current, t.comp)) continue
|
||||
runCatching { ComponentsManager.swap(context, t.p.id, current) }
|
||||
.onFailure { Log.w(TAG, "${t.p.name} ${t.key}", it) }
|
||||
}
|
||||
}
|
||||
val after = ComponentsManager.activeChoices(context)
|
||||
val unfinished = HashSet<String>()
|
||||
for (t in targets) {
|
||||
val restore = t.ls.optJSONObject("restore")
|
||||
val from = restore?.optString(t.p.id).orEmpty()
|
||||
val current = t.ls.optString("current")
|
||||
if (from.isNotEmpty()) {
|
||||
if (after.fileOf(t.p, t.comp) == from) unfinished += t.key
|
||||
else {
|
||||
restore?.remove(t.p.id)
|
||||
if (after.choiceOf(t.p, t.comp)?.file?.startsWith("original ") != false) t.ls.optJSONObject("applied")?.remove(t.p.id)
|
||||
}
|
||||
} else if (current.isNotEmpty()) {
|
||||
if (after.fileOf(t.p, t.comp) == current) t.ls.obj("applied").put(t.p.id, current) else unfinished += t.key
|
||||
}
|
||||
}
|
||||
for (key in lines.keys().asSequence().toList()) {
|
||||
val ls = lines.optJSONObject(key) ?: continue
|
||||
val pending = ls.optString("pendingNote")
|
||||
if (pending.isEmpty() || key in unfinished || (ls.optJSONObject("restore")?.length() ?: 0) > 0) continue
|
||||
ls.remove("pendingNote")
|
||||
ls.put("note", pending)
|
||||
notes += pending
|
||||
}
|
||||
}
|
||||
|
||||
private fun own(state: JSONObject, ls: JSONObject, file: String) {
|
||||
ls.put("owned", JSONArray(ls.strings("owned") + file))
|
||||
state.put(IMPORTED, JSONArray(state.strings(IMPORTED) - file))
|
||||
}
|
||||
|
||||
fun importPackage(context: Context, source: File, name: String): ComponentsManager.Package = synchronized(lock) {
|
||||
ComponentsManager.importPackage(context, source, name).also { disown(context, it.file) }
|
||||
}
|
||||
|
||||
fun disown(context: Context, file: String) = synchronized(lock) {
|
||||
val state = load(context)
|
||||
state.optJSONObject("lines")?.let { lines ->
|
||||
lines.keys().asSequence().mapNotNull { lines.optJSONObject(it) }.filter { file in it.strings("owned") }
|
||||
.forEach { it.put("owned", JSONArray(it.strings("owned") - file)) }
|
||||
}
|
||||
state.put(IMPORTED, JSONArray(state.strings(IMPORTED) + file))
|
||||
save(context, state)
|
||||
}
|
||||
|
||||
internal fun placed(state: JSONObject): Set<String> {
|
||||
val remembered = state.strings(HAND_BACK_FILES)
|
||||
val lines = state.optJSONObject("lines") ?: return remembered - state.strings(IMPORTED)
|
||||
return remembered + lines.keys().asSequence().flatMap { k ->
|
||||
val ls = lines.optJSONObject(k) ?: return@flatMap emptySequence()
|
||||
(ls.strings("owned") + ls.strings("bad") + ls.optString("current") + ls.optString("previous")).asSequence()
|
||||
}.filter { it.isNotEmpty() }.toSet() - state.strings(IMPORTED)
|
||||
}
|
||||
|
||||
private fun handBack(context: Context, state: JSONObject, protons: List<ComponentsManager.Proton>) {
|
||||
if (state.optBoolean(HANDED_BACK)) return
|
||||
val placed = placed(state)
|
||||
state.put(HAND_BACK_FILES, JSONArray(placed.sorted()))
|
||||
val own = protons.filter(ComponentUpdates::follows).mapTo(HashSet()) { it.id }
|
||||
runCatching { ComponentsManager.dropQueuedPackages(context, placed) { it in own || ComponentUpdates.ownId(it) } }.onFailure { Log.w(TAG, "drop queued Auto builds", it) }.getOrNull() ?: return
|
||||
val strays = ComponentUpdates.strays(protons, ComponentsManager.activeChoices(context), placed)
|
||||
for ((p, comp) in strays) {
|
||||
runCatching { ComponentsManager.restoreUnlessQueued(context, p.id, comp, ComponentsManager.safeName(p.version)) }
|
||||
.onFailure { Log.w(TAG, "hand back ${p.name} $comp", it) }
|
||||
}
|
||||
val after = ComponentsManager.activeChoices(context)
|
||||
if (ComponentUpdates.strays(protons, after, placed).isEmpty() && !ComponentUpdates.unseen(protons, after, placed)) {
|
||||
state.put(HANDED_BACK, true)
|
||||
state.remove(HAND_BACK_FILES)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun dropRemoved(state: JSONObject, protons: List<ComponentsManager.Proton>) {
|
||||
val installed = protons.mapTo(HashSet()) { it.id }
|
||||
val lines = state.optJSONObject("lines") ?: return
|
||||
for (key in lines.keys().asSequence().toList()) {
|
||||
val restore = lines.optJSONObject(key)?.optJSONObject("restore") ?: continue
|
||||
for (id in restore.keys().asSequence().toList()) if (id !in installed) restore.remove(id)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun rollingBack(ls: JSONObject): Boolean =
|
||||
(ls.optJSONObject("restore")?.length() ?: 0) > 0 || ls.optString("pendingNote").isNotEmpty()
|
||||
|
||||
private fun judge(context: Context, state: JSONObject, protons: List<ComponentsManager.Proton>, notes: MutableList<String>) {
|
||||
val mark = state.optString("logMark")
|
||||
val runs = ComponentUpdates.launches(logLines(context), mark)
|
||||
if (runs.isEmpty()) return
|
||||
state.put("logMark", runs.maxOf { it.start })
|
||||
val byTool = protons.associateBy { it.dir.name }
|
||||
val lines = state.obj("lines")
|
||||
for (comp in ComponentsManager.COMPONENTS) {
|
||||
val onLine = HashMap<String, MutableList<ComponentUpdates.Launch>>()
|
||||
for (run in runs) {
|
||||
val p = byTool[run.tool] ?: continue
|
||||
if (ComponentsManager.gameComponents(context, run.app).containsKey(comp)) continue
|
||||
val line = lineOf(context, state, p, comp) ?: continue
|
||||
onLine.getOrPut(line.key) { ArrayList() } += run
|
||||
}
|
||||
for ((key, list) in onLine) {
|
||||
val ls = lines.obj(key)
|
||||
val verdict = ComponentUpdates.judge(ComponentUpdates.Trial(
|
||||
ls.optString("current"), ls.optString("previous"), ls.optString("since"),
|
||||
ls.optJSONObject("booted").texts(), ls.optJSONObject("failed").ints(),
|
||||
), list)
|
||||
ls.put("booted", JSONObject(verdict.booted.entries.toList().takeLast(BOOTED_KEPT).associate { it.key to it.value } as Map<*, *>))
|
||||
ls.put("failed", JSONObject(verdict.failed as Map<*, *>))
|
||||
if (verdict.rollBack && managed(context, comp)) rollBack(context, state, protons, comp, key, notes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun rollBack(context: Context, state: JSONObject, protons: List<ComponentsManager.Proton>, comp: String, key: String, notes: MutableList<String>) {
|
||||
val ls = state.obj("lines").obj(key)
|
||||
val bad = ls.optString("current")
|
||||
val onLine = protons.filter { lineOf(context, state, it, comp)?.key == key }
|
||||
val line = ComponentUpdates.LINES.first { it.key == key }
|
||||
val back = ls.optString("previous").takeIf { prev -> prev == ComponentUpdates.ORIGINAL || (ComponentsManager.isStored(context, prev) &&
|
||||
(!ComponentUpdates.needsArm64x(line, onLine.map { ComponentsManager.arm64x(context, it, comp) }) || ComponentsManager.packageArm64x(context, prev, comp))) }
|
||||
?: ComponentUpdates.ORIGINAL
|
||||
val choices = ComponentsManager.activeChoices(context)
|
||||
val targets = onLine.filter { choices.fileOf(it, comp) == bad }
|
||||
for (p in targets) {
|
||||
runCatching {
|
||||
if (back == ComponentUpdates.ORIGINAL) ComponentsManager.restore(context, p.id, comp, ComponentsManager.safeName(p.version))
|
||||
else ComponentsManager.swap(context, p.id, back)
|
||||
}.onFailure { Log.w(TAG, "roll back ${p.name} $key", it) }
|
||||
}
|
||||
val after = ComponentsManager.activeChoices(context)
|
||||
val unfinished = targets.filter { after.fileOf(it, comp) == bad }
|
||||
for (p in targets - unfinished.toSet()) {
|
||||
if (back == ComponentUpdates.ORIGINAL) ls.optJSONObject("applied")?.remove(p.id)
|
||||
else if (after.fileOf(p, comp) == back) ls.obj("applied").put(p.id, back)
|
||||
}
|
||||
ls.put("bad", JSONArray(ls.strings("bad") + bad)).put("current", back).put("previous", ComponentUpdates.ORIGINAL)
|
||||
.put("since", latestStamp(context)).put("failed", JSONObject())
|
||||
if (back == ComponentUpdates.ORIGINAL) ls.obj("restore").let { restore -> unfinished.forEach { restore.put(it.id, bad) } }
|
||||
val label = ComponentsManager.LABEL[comp]
|
||||
val shown = back.ifEmpty { context.getString(R.string.comp_tag_original) }.removeSuffix(".wcp")
|
||||
val note = context.getString(R.string.comp_auto_rolled_back, label, bad.removeSuffix(".wcp"), shown)
|
||||
Log.w(TAG, note)
|
||||
if (unfinished.isNotEmpty()) {
|
||||
ls.put("pendingNote", note)
|
||||
return
|
||||
}
|
||||
ls.remove("pendingNote")
|
||||
ls.put("note", note)
|
||||
notes += note
|
||||
prune(context, state)
|
||||
}
|
||||
|
||||
private fun prune(context: Context, state: JSONObject) {
|
||||
val lines = state.obj("lines")
|
||||
val selected = ComponentsManager.selectedPackages(context)
|
||||
val keep = lines.keys().asSequence().flatMap { k ->
|
||||
val ls = lines.optJSONObject(k) ?: return@flatMap emptySequence()
|
||||
ComponentUpdates.keep(ls.optString("current"), ls.optString("previous"), selected).asSequence()
|
||||
}.toSet()
|
||||
val stored = ComponentsManager.storedFiles(context)
|
||||
val followed = ComponentUpdates.LINES.filter { managed(context, it.comp) }
|
||||
for (k in lines.keys().asSequence().toList()) {
|
||||
val ls = lines.optJSONObject(k) ?: continue
|
||||
val owned = ls.strings("owned")
|
||||
val stale = ComponentUpdates.stale(followed.firstOrNull { it.key == k }, owned, stored, keep, ls.optString("current"), ls.strings("bad").toSet())
|
||||
val gone = stale.filter { runCatching { ComponentsManager.deleteUnused(context, it) }.getOrDefault(false) }
|
||||
ls.put("owned", JSONArray(owned - gone.toSet()))
|
||||
}
|
||||
}
|
||||
|
||||
fun status(context: Context, comp: String): List<Status> {
|
||||
val state = load(context)
|
||||
val lines = state.optJSONObject("lines") ?: JSONObject()
|
||||
val protons = runCatching { ComponentsManager.protons(context) }.getOrDefault(emptyList())
|
||||
val byLine = protons.groupBy { lineOf(context, state, it, comp)?.key }
|
||||
return ComponentUpdates.LINES.filter { it.comp == comp }.mapNotNull { line ->
|
||||
val on = byLine[line.key].orEmpty()
|
||||
val ls = lines.optJSONObject(line.key)
|
||||
if (on.isEmpty() && ls == null) return@mapNotNull null
|
||||
val note = ls?.optString("note").orEmpty().ifEmpty { state.optString("note") }
|
||||
Status(line.key, ls?.optString("current").orEmpty(), ls?.optString("previous").orEmpty(), on.map { it.name }, note)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
object ComponentUpdates {
|
||||
const val AUTO = "auto"
|
||||
const val MANUAL = "manual"
|
||||
const val ORIGINAL = ""
|
||||
const val BOOT_SECONDS = 30L
|
||||
const val BOOTED_SECONDS = 60L
|
||||
const val FAILED_BOOTS_BEFORE_ROLLBACK = 2
|
||||
|
||||
data class Line(val key: String, val comp: String, val prefix: String)
|
||||
|
||||
val LINES = listOf(
|
||||
Line("dxvk", "dxvk", "DD-dxvk-"),
|
||||
Line("dxvk-arm64ec", "dxvk", "DD-dxvk-arm64ec-"),
|
||||
Line("vkd3d", "vkd3d", "DD-vkd3d-proton-"),
|
||||
Line("vkd3d-arm64ec", "vkd3d", "DD-vkd3d-proton-arm64ec-"),
|
||||
Line("fex", "fex", "DD-FEXCore-"),
|
||||
)
|
||||
|
||||
private val OWN_PREFIX = ProtonExtras.tools.first { it.id == ProtonExtras.OWN }.prefix
|
||||
|
||||
fun follows(p: ComponentsManager.Proton): Boolean = !p.valve && p.dir.name.startsWith(OWN_PREFIX)
|
||||
|
||||
fun ownId(protonId: String): Boolean = protonId.startsWith(ComponentsManager.safeName(OWN_PREFIX))
|
||||
|
||||
fun strays(
|
||||
protons: List<ComponentsManager.Proton>,
|
||||
choices: Map<String, Map<String, ComponentsManager.Choice>>,
|
||||
placed: Set<String>,
|
||||
): List<Pair<ComponentsManager.Proton, String>> =
|
||||
protons.filterNot(::follows).flatMap { p ->
|
||||
choices[p.id].orEmpty().filter { (_, c) -> !c.auto && c.protonVersion == p.version && c.file in placed }.keys.map { p to it }
|
||||
}
|
||||
|
||||
fun unseen(
|
||||
protons: List<ComponentsManager.Proton>,
|
||||
choices: Map<String, Map<String, ComponentsManager.Choice>>,
|
||||
placed: Set<String>,
|
||||
): Boolean {
|
||||
val found = protons.mapTo(HashSet()) { it.id }
|
||||
return choices.any { (id, comps) -> id !in found && comps.values.any { !it.auto && it.file in placed } }
|
||||
}
|
||||
|
||||
fun line(comp: String, arm64ec: Boolean): Line = LINES.first {
|
||||
it.comp == comp && (comp == "fex" || it.key.endsWith("-arm64ec") == arm64ec)
|
||||
}
|
||||
|
||||
fun pick(items: List<ComponentsManager.CatalogItem>, line: Line, bad: Set<String>): ComponentsManager.CatalogItem? {
|
||||
val newestBad = bad.filter { belongs(it, line) }.maxWithOrNull { a, b -> ProtonExtras.compareVersions(a, b) }
|
||||
return items.filter { it.release == ComponentsManager.DD_NIGHTLY && it.comp == line.comp && belongs(it.file, line) }
|
||||
.filter { newestBad == null || ProtonExtras.compareVersions(it.file, newestBad) > 0 }
|
||||
.maxWithOrNull { a, b -> ProtonExtras.compareVersions(a.file, b.file) }
|
||||
}
|
||||
|
||||
fun belongs(file: String, line: Line): Boolean =
|
||||
file.startsWith(line.prefix) && file.removePrefix(line.prefix).firstOrNull()?.isDigit() == true
|
||||
|
||||
fun arm64ec(pe: ByteArray): Boolean? = runCatching {
|
||||
val b = ByteBuffer.wrap(pe).order(ByteOrder.LITTLE_ENDIAN)
|
||||
val header = b.getInt(0x3c)
|
||||
if (b.getInt(header) != 0x4550) return null
|
||||
val sections = b.getShort(header + 6).toInt() and 0xffff
|
||||
val optionalSize = b.getShort(header + 20).toInt() and 0xffff
|
||||
val optional = header + 24
|
||||
if ((b.getShort(optional).toInt() and 0xffff) != 0x20b) return false
|
||||
val loadConfigRva = b.getInt(optional + 112 + 10 * 8)
|
||||
if (loadConfigRva == 0) return false
|
||||
val table = optional + optionalSize
|
||||
var offset = -1
|
||||
for (i in 0 until sections) {
|
||||
val s = table + i * 40
|
||||
val size = b.getInt(s + 8)
|
||||
val va = b.getInt(s + 12)
|
||||
if (loadConfigRva >= va && loadConfigRva < va + maxOf(size, 1)) offset = b.getInt(s + 20) + loadConfigRva - va
|
||||
}
|
||||
if (offset < 0) return null
|
||||
b.getInt(offset) >= 0xC8 + 8 && b.getLong(offset + 0xC8) != 0L
|
||||
}.getOrNull()
|
||||
|
||||
fun arm64x(pe: ByteArray): Boolean = runCatching {
|
||||
val b = ByteBuffer.wrap(pe).order(ByteOrder.LITTLE_ENDIAN)
|
||||
val header = b.getInt(0x3c)
|
||||
b.getInt(header) == 0x4550 && (b.getShort(header + 4).toInt() and 0xffff) == 0xaa64 && arm64ec(pe) == true
|
||||
}.getOrDefault(false)
|
||||
|
||||
fun allArm64x(dlls: Map<String, ByteArray>, owned: Set<String>): Boolean {
|
||||
val graphics = dlls.filterKeys { it.lowercase() in owned }
|
||||
return graphics.isNotEmpty() && graphics.values.all(::arm64x)
|
||||
}
|
||||
|
||||
fun needsArm64x(line: Line, stocks: List<Boolean?>): Boolean =
|
||||
line.key.endsWith("-arm64ec") && stocks.any { it != false }
|
||||
|
||||
data class Launch(val app: String, val tool: String, val start: String, val seconds: Long)
|
||||
|
||||
private val PROCESS = Regex("""^\[(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d)] Game process (added|removed)\s*: AppID (\d+)(?: "(.*))?""")
|
||||
private val TOOL = Regex("""compatibilitytools\.d/([^/'"]+)['"]?/|steamapps/common/([^/'"]+)['"]?/proton""")
|
||||
|
||||
fun launches(log: Sequence<String>, after: String): List<Launch> {
|
||||
val format = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
||||
val open = HashMap<String, Pair<String, String>>()
|
||||
val out = ArrayList<Launch>()
|
||||
for (line in log) {
|
||||
val m = PROCESS.find(line) ?: continue
|
||||
val (time, what, app) = m.destructured
|
||||
if (time <= after) continue
|
||||
if (what == "added") {
|
||||
val tool = TOOL.find(m.groupValues[4])?.let { t -> t.groupValues[1].ifEmpty { t.groupValues[2] } } ?: continue
|
||||
open[app] = time to tool
|
||||
} else {
|
||||
val (start, tool) = open.remove(app) ?: continue
|
||||
val seconds = runCatching { (format.parse(time)!!.time - format.parse(start)!!.time) / 1000 }.getOrNull() ?: continue
|
||||
out += Launch(app, tool, start, seconds)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
data class Trial(
|
||||
val current: String,
|
||||
val previous: String,
|
||||
val since: String,
|
||||
val booted: Map<String, String>,
|
||||
val failed: Map<String, Int>,
|
||||
)
|
||||
|
||||
data class Verdict(val booted: Map<String, String>, val failed: Map<String, Int>, val rollBack: Boolean)
|
||||
|
||||
fun judge(trial: Trial, runs: List<Launch>): Verdict {
|
||||
val booted = trial.booted.toMutableMap()
|
||||
val failed = trial.failed.toMutableMap()
|
||||
var rollBack = false
|
||||
for (run in runs) {
|
||||
val build = if (trial.since.isNotEmpty() && run.start > trial.since) trial.current else trial.previous
|
||||
if (run.seconds >= BOOTED_SECONDS) {
|
||||
booted[run.app] = build
|
||||
if (build == trial.current) failed.remove(run.app)
|
||||
} else if (run.seconds < BOOT_SECONDS && build == trial.current && trial.current != trial.previous &&
|
||||
booted[run.app] == trial.previous) {
|
||||
val n = (failed[run.app] ?: 0) + 1
|
||||
failed[run.app] = n
|
||||
if (n >= FAILED_BOOTS_BEFORE_ROLLBACK) rollBack = true
|
||||
}
|
||||
}
|
||||
return Verdict(booted, failed, rollBack)
|
||||
}
|
||||
|
||||
fun keep(current: String, previous: String, selected: Set<String>): Set<String> =
|
||||
(setOf(current, previous) + selected) - ORIGINAL
|
||||
|
||||
fun manual(choice: ComponentsManager.Choice?, applied: String?, auto: Set<String>, current: String? = null): Boolean =
|
||||
choice != null && !choice.auto && !choice.file.startsWith("original ") && choice.file != current &&
|
||||
(if (applied.isNullOrEmpty()) choice.file !in auto else choice.file != applied)
|
||||
|
||||
fun stale(line: Line?, owned: Set<String>, stored: List<String>, keep: Set<String>, current: String, bad: Set<String> = emptySet()): List<String> {
|
||||
val ranked = line?.takeIf { belongs(current, it) }
|
||||
fun newer(file: String) = ranked != null && belongs(file, ranked) && ProtonExtras.compareVersions(file, current) > 0
|
||||
return (owned + stored.filter { ranked != null && belongs(it, ranked) })
|
||||
.filter { it !in keep && ((it in bad && it in owned) || !newer(it)) }.distinct()
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,7 @@ object ComponentsManager {
|
||||
|
||||
private fun root(context: Context) = LinuxRuntime.rootDir(context)
|
||||
private fun steam(context: Context) = File(root(context), "root/.local/share/Steam")
|
||||
fun steamLogs(context: Context) = File(steam(context), "logs")
|
||||
private fun host(context: Context, guest: String) = File(root(context), guest.removePrefix("/"))
|
||||
private fun dataDir(context: Context) = File(context.filesDir, "components").apply { mkdirs() }
|
||||
private fun packagesDir(context: Context) = File(dataDir(context), "packages").apply { mkdirs() }
|
||||
@@ -699,6 +700,12 @@ object ComponentsManager {
|
||||
context.getString(R.string.cmgr_restore_done, LABEL[comp], p.name, protonVersion)
|
||||
}
|
||||
|
||||
fun restoreUnlessQueued(context: Context, protonId: String, comp: String, protonVersion: String): Boolean = synchronized(lock) {
|
||||
if (loadState(context).optJSONObject("queued")?.optJSONObject(protonId)?.has(comp) == true) return false
|
||||
restore(context, protonId, comp, protonVersion)
|
||||
true
|
||||
}
|
||||
|
||||
private fun applyOriginal(context: Context, p: Proton, comp: String, protonVersion: String, wcp: File, state: JSONObject) {
|
||||
val current = protonVersion == safeName(p.version)
|
||||
if (!current) ensureOriginal(context, p, comp, state)
|
||||
@@ -749,6 +756,25 @@ object ComponentsManager {
|
||||
saveState(context, state)
|
||||
}
|
||||
|
||||
fun dropQueuedPackages(context: Context, files: Set<String>, keep: (String) -> Boolean): Int = synchronized(lock) {
|
||||
val state = loadState(context)
|
||||
val queued = state.sub("queued")
|
||||
var dropped = 0
|
||||
for (pid in queued.keys().asSequence().toList()) {
|
||||
if (keep(pid)) continue
|
||||
val comps = queued.optJSONObject(pid) ?: continue
|
||||
for (comp in comps.keys().asSequence().toList()) {
|
||||
val entry = comps.optJSONObject(comp) ?: continue
|
||||
if (entry.optString("kind") != "package" || entry.optBoolean("auto") || entry.optString("file") !in files) continue
|
||||
comps.remove(comp)
|
||||
dropped++
|
||||
}
|
||||
if (comps.length() == 0) queued.remove(pid)
|
||||
}
|
||||
if (dropped > 0) saveState(context, state)
|
||||
dropped
|
||||
}
|
||||
|
||||
class StoredPackage(val pkg: Package, val inUse: Boolean)
|
||||
class StoredOriginal(val protonId: String, val protonName: String?, val comp: String, val protonVersion: String, val size: Long, val kept: Boolean)
|
||||
class Stored(val packages: List<StoredPackage>, val originals: List<StoredOriginal>)
|
||||
@@ -756,7 +782,11 @@ object ComponentsManager {
|
||||
internal fun liveSelection(key: String, entry: JSONObject, proton: Proton?): Boolean =
|
||||
proton != null && (key == "queued" || entry.optString("protonVersion") == proton.version)
|
||||
|
||||
private fun packagesInUse(state: JSONObject, installed: Map<String, Proton>): Set<String> {
|
||||
internal fun liveOnAny(key: String, entry: JSONObject, installs: List<Proton>?): Boolean =
|
||||
installs?.any { liveSelection(key, entry, it) } == true
|
||||
|
||||
internal fun packagesInUse(state: JSONObject, installed: List<Proton>): Set<String> {
|
||||
val byId = installed.groupBy { it.id }
|
||||
val names = HashSet<String>()
|
||||
for (key in listOf("active", "queued")) {
|
||||
val byProton = state.optJSONObject(key) ?: continue
|
||||
@@ -764,7 +794,7 @@ object ComponentsManager {
|
||||
val comps = byProton.optJSONObject(pid) ?: continue
|
||||
for (comp in comps.keys()) {
|
||||
val entry = comps.optJSONObject(comp) ?: continue
|
||||
if (liveSelection(key, entry, installed[pid])) entry.optString("file").takeIf { it.isNotEmpty() }?.let(names::add)
|
||||
if (liveOnAny(key, entry, byId[pid])) entry.optString("file").takeIf { it.isNotEmpty() }?.let(names::add)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -786,7 +816,7 @@ object ComponentsManager {
|
||||
fun stored(context: Context): Stored = synchronized(lock) {
|
||||
val state = loadState(context)
|
||||
val installed = protons(context).distinctBy { it.id }.associateBy { it.id }
|
||||
val used = packagesInUse(state, installed)
|
||||
val used = packagesInUse(state, protons(context))
|
||||
val originals = originalsDir(context).listFiles()?.filter { it.isDirectory }?.flatMap { proton ->
|
||||
proton.listFiles()?.filter { it.isDirectory }?.flatMap { version ->
|
||||
COMPONENTS.mapNotNull { comp ->
|
||||
@@ -813,15 +843,15 @@ object ComponentsManager {
|
||||
}
|
||||
|
||||
private fun checkPackageUnused(context: Context, state: JSONObject, name: String) {
|
||||
val installed = protons(context).distinctBy { it.id }.associateBy { it.id }
|
||||
val installed = protons(context).groupBy { it.id }
|
||||
for (key in listOf("queued", "active")) {
|
||||
val byProton = state.sub(key)
|
||||
for (pid in byProton.keys()) {
|
||||
val comps = byProton.optJSONObject(pid) ?: continue
|
||||
for (comp in comps.keys()) {
|
||||
val entry = comps.optJSONObject(comp) ?: continue
|
||||
check(entry.optString("file") != name || !liveSelection(key, entry, installed[pid])) {
|
||||
context.getString(R.string.cmgr_package_in_use, name, installed[pid]?.name ?: pid)
|
||||
check(entry.optString("file") != name || !liveOnAny(key, entry, installed[pid])) {
|
||||
context.getString(R.string.cmgr_package_in_use, name, installed[pid]?.firstOrNull()?.name ?: pid)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -911,7 +941,7 @@ object ComponentsManager {
|
||||
fun deletePackage(context: Context, file: String): String = synchronized(lock) {
|
||||
val name = safeName(file)
|
||||
val state = loadState(context)
|
||||
val installed = protons(context).distinctBy { it.id }.associateBy { it.id }
|
||||
val installed = protons(context).groupBy { it.id }
|
||||
var changed = false
|
||||
for (key in listOf("queued", "active")) {
|
||||
val byProton = state.sub(key)
|
||||
@@ -920,7 +950,7 @@ object ComponentsManager {
|
||||
for (comp in comps.keys().asSequence().toList()) {
|
||||
val entry = comps.optJSONObject(comp) ?: continue
|
||||
if (entry.optString("file") != name) continue
|
||||
check(!liveSelection(key, entry, installed[pid])) { context.getString(R.string.cmgr_package_in_use, name, installed[pid]?.name ?: pid) }
|
||||
check(!liveOnAny(key, entry, installed[pid])) { context.getString(R.string.cmgr_package_in_use, name, installed[pid]?.firstOrNull()?.name ?: pid) }
|
||||
comps.remove(comp)
|
||||
changed = true
|
||||
}
|
||||
@@ -967,9 +997,16 @@ object ComponentsManager {
|
||||
if (!force && cache.isFile) runCatching { return parseCatalog(JSONObject(cache.readText())) }
|
||||
if (!force) return Catalog(emptyList(), 0)
|
||||
val items = JSONArray()
|
||||
val previous = if (cache.isFile) runCatching { JSONObject(cache.readText()).optJSONArray("items") }.getOrNull() else null
|
||||
var fetched = 0
|
||||
for ((repo, tag) in SOURCES) {
|
||||
val body = Downloader.downloadString("https://api.github.com/repos/$repo/releases/tags/$tag") ?: continue
|
||||
val assets = runCatching { JSONObject(body).optJSONArray("assets") }.getOrNull() ?: continue
|
||||
val body = Downloader.downloadString("https://api.github.com/repos/$repo/releases/tags/$tag")
|
||||
val assets = body?.let { runCatching { JSONObject(it).optJSONArray("assets") }.getOrNull() }
|
||||
if (assets == null) {
|
||||
keepSource(previous, tag, items)
|
||||
continue
|
||||
}
|
||||
fetched++
|
||||
for (i in 0 until assets.length()) {
|
||||
val a = assets.getJSONObject(i)
|
||||
val name = a.optString("name")
|
||||
@@ -980,13 +1017,18 @@ object ComponentsManager {
|
||||
.put("url", a.optString("browser_download_url")).put("size", a.optLong("size")).put("digest", a.optString("digest")))
|
||||
}
|
||||
}
|
||||
if (items.length() == 0 && cache.isFile) runCatching { return parseCatalog(JSONObject(cache.readText())) }
|
||||
if (fetched == 0 && cache.isFile) runCatching { return parseCatalog(JSONObject(cache.readText())) }
|
||||
val out = JSONObject().put("items", items).put("fetchedAt", System.currentTimeMillis() / 1000)
|
||||
val tmp = File(cache.parentFile, cache.name + ".tmp")
|
||||
tmp.writeText(out.toString()); tmp.renameTo(cache)
|
||||
return parseCatalog(out)
|
||||
}
|
||||
|
||||
internal fun keepSource(previous: JSONArray?, tag: String, into: JSONArray) {
|
||||
previous ?: return
|
||||
for (i in 0 until previous.length()) previous.optJSONObject(i)?.takeIf { it.optString("release") == tag }?.let(into::put)
|
||||
}
|
||||
|
||||
private fun parseCatalog(o: JSONObject): Catalog {
|
||||
val arr = o.optJSONArray("items") ?: JSONArray()
|
||||
val items = (0 until arr.length()).map { arr.getJSONObject(it) }.map {
|
||||
@@ -1031,6 +1073,86 @@ object ComponentsManager {
|
||||
ForcedPackage(setOf(GpuInfo.Family.A6XX), "dxvk", "dxvk-2.7.1-linux.wcp", "Dxvk-Linux"),
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
fun arm64x(context: Context, p: Proton, comp: String): Boolean? = stockProbe(context, p, comp)?.let(ComponentUpdates::arm64x)
|
||||
|
||||
fun packageArm64x(context: Context, file: String, comp: String): Boolean {
|
||||
val dir = "${COMP_DIR[comp] ?: return false}/aarch64-windows/"
|
||||
val f = File(packagesDir(context), safeName(file)).takeIf { it.isFile } ?: return false
|
||||
val dlls = runCatching {
|
||||
readWcp(f) { tar ->
|
||||
val found = HashMap<String, ByteArray>()
|
||||
while (true) {
|
||||
val e = tar.nextTarEntry ?: break
|
||||
val rel = normalize(e.name)
|
||||
if (!e.isDirectory && rel.startsWith(dir) && '/' !in rel.removePrefix(dir)) found[rel.removePrefix(dir)] = tar.readBytes()
|
||||
}
|
||||
found
|
||||
}
|
||||
}.getOrNull() ?: return false
|
||||
return ComponentUpdates.allArm64x(dlls, COMP_DLLS.getValue(comp))
|
||||
}
|
||||
|
||||
private fun readWcpEntry(f: File, rel: String): ByteArray? = readWcp(f) { tar ->
|
||||
var found: ByteArray? = null
|
||||
while (found == null) {
|
||||
val e = tar.nextTarEntry ?: break
|
||||
if (!e.isDirectory && normalize(e.name) == rel) found = tar.readBytes()
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
private fun stockProbe(context: Context, p: Proton, comp: String): ByteArray? {
|
||||
val rel = "${COMP_DIR[comp] ?: return null}/aarch64-windows/${ARM64EC_PROBE.getValue(comp)}"
|
||||
val original = synchronized(lock) {
|
||||
val swapped = loadState(context).optJSONObject("active")?.optJSONObject(p.id)?.optJSONObject(comp)
|
||||
?.let { liveSelection("active", it, p) } == true
|
||||
if (!swapped) return File(p.dir, rel).takeIf { it.isFile }?.let { runCatching { it.readBytes() }.getOrNull() }
|
||||
originalFile(context, p, comp).takeIf { it.isFile } ?: return null
|
||||
}
|
||||
return runCatching { readWcpEntry(original, rel) }.getOrNull()
|
||||
}
|
||||
|
||||
fun supports(p: Proton, comp: String): Boolean =
|
||||
if (comp == "fex") File(p.dir, FEX_FILES.first()).isFile else File(p.dir, "${COMP_DIR.getValue(comp)}/aarch64-windows").isDirectory
|
||||
|
||||
fun hardwareDxvk(context: Context): Boolean =
|
||||
runtimeManaged(context, "dxvk") || FORCED_PACKAGES.any { it.comp == "dxvk" && it.matches(GpuInfo.detect()) } ||
|
||||
(com.droiddeck.launcher.core.DeviceSupport.vkBridge(context) && !SessionPrefs.bridgeProtonDxvk(context) &&
|
||||
bridgeDxvk(AppVulkan.detect(context)) != null)
|
||||
|
||||
data class Choice(val file: String, val auto: Boolean, val protonVersion: String)
|
||||
|
||||
fun activeChoices(context: Context): Map<String, Map<String, Choice>> = synchronized(lock) {
|
||||
val active = loadState(context).optJSONObject("active") ?: return emptyMap()
|
||||
active.keys().asSequence().toList().associateWith { pid ->
|
||||
val comps = active.optJSONObject(pid) ?: JSONObject()
|
||||
comps.keys().asSequence().toList().mapNotNull { c ->
|
||||
comps.optJSONObject(c)?.let { c to Choice(it.optString("file"), it.optBoolean("auto"), it.optString("protonVersion")) }
|
||||
}.toMap()
|
||||
}
|
||||
}
|
||||
|
||||
fun selectedPackages(context: Context): Set<String> = synchronized(lock) {
|
||||
packagesInUse(loadState(context), protons(context))
|
||||
}
|
||||
|
||||
fun deleteUnused(context: Context, file: String): Boolean = synchronized(lock) {
|
||||
val name = safeName(file)
|
||||
val f = File(packagesDir(context), name)
|
||||
if (!f.isFile || name in packagesInUse(loadState(context), protons(context))) return false
|
||||
deletePackage(context, name)
|
||||
true
|
||||
}
|
||||
|
||||
fun storedFiles(context: Context): List<String> =
|
||||
packagesDir(context).listFiles()?.filter { it.isFile && it.name.endsWith(".wcp") }?.map { it.name } ?: emptyList()
|
||||
|
||||
fun isStored(context: Context, file: String): Boolean = File(packagesDir(context), safeName(file)).isFile
|
||||
|
||||
internal fun forcedPackageWanted(activeFile: String?, protonVersionMatches: Boolean): Boolean =
|
||||
activeFile.isNullOrEmpty() || !protonVersionMatches
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ object PerfMode {
|
||||
val cur = display.mode
|
||||
val cap = minOf(peakRefreshCap(a), refreshLimit.takeIf { it > 0 }?.toFloat() ?: Float.POSITIVE_INFINITY)
|
||||
val best = selectMode(display.supportedModes, cur, fpsLimit, cap)
|
||||
if (best != null && a.window.attributes.preferredDisplayModeId != best.modeId) {
|
||||
if (best != null && needsChange(display.supportedModes, a.window.attributes.preferredDisplayModeId, best)) {
|
||||
a.window.attributes = a.window.attributes.apply { preferredDisplayModeId = best.modeId }
|
||||
"display ${cur.refreshRate.toInt()} → ${best.refreshRate.toInt()} Hz (mode ${best.modeId})"
|
||||
} else "display ${cur.refreshRate.toInt()} Hz"
|
||||
@@ -64,6 +64,11 @@ object PerfMode {
|
||||
?: sameSize.minByOrNull { it.refreshRate }
|
||||
}
|
||||
|
||||
internal fun needsChange(modes: Array<Display.Mode>, preferredId: Int, best: Display.Mode): Boolean {
|
||||
val preferred = modes.firstOrNull { preferredId != 0 && it.modeId == preferredId } ?: return true
|
||||
return Math.abs(preferred.refreshRate - best.refreshRate) > 0.5f
|
||||
}
|
||||
|
||||
fun cadenceFits(hz: Float, fpsLimit: Int): Boolean {
|
||||
if (fpsLimit <= 0) return true
|
||||
val ratio = hz / fpsLimit
|
||||
|
||||
@@ -11,12 +11,4 @@ object PipPolicy {
|
||||
height.toDouble() / width > 2.39 -> 100 to 239
|
||||
else -> width to height
|
||||
}
|
||||
|
||||
fun outputSize(width: Int, height: Int, outputWidth: Int, outputHeight: Int): Pair<Int, Int>? {
|
||||
if (width <= 0 || height <= 0 || outputWidth <= 0 || outputHeight <= 0) return null
|
||||
val scale = minOf(width.toFloat() / outputWidth, height.toFloat() / outputHeight)
|
||||
val fittedWidth = ((outputWidth * scale).toInt() and 1.inv()).coerceAtLeast(2)
|
||||
val fittedHeight = ((outputHeight * scale).toInt() and 1.inv()).coerceAtLeast(2)
|
||||
return fittedWidth to fittedHeight
|
||||
}
|
||||
}
|
||||
@@ -351,6 +351,8 @@ object SessionPrefs {
|
||||
}
|
||||
|
||||
const val PC_KEYBOARD_SIZE_DEFAULT = 47
|
||||
const val WOW_CHAT_VOICE = "voice"
|
||||
const val WOW_CHAT_KEYBOARD = "keyboard"
|
||||
val PC_KEYBOARD_SIZE_RANGE = 25..70
|
||||
const val PC_KEYBOARD_OPACITY_DEFAULT = 100
|
||||
val PC_KEYBOARD_OPACITY_RANGE = 30..100
|
||||
@@ -362,6 +364,13 @@ object SessionPrefs {
|
||||
prefs(context).edit().putInt("pcKeyboardSize", percent.coerceIn(PC_KEYBOARD_SIZE_RANGE)).apply()
|
||||
}
|
||||
|
||||
fun wowChatInput(context: Context): String? =
|
||||
prefs(context).getString("wowChatInput", null)?.takeIf { it == WOW_CHAT_VOICE || it == WOW_CHAT_KEYBOARD }
|
||||
|
||||
fun setWowChatInput(context: Context, mode: String?) {
|
||||
prefs(context).edit().apply { if (mode == null) remove("wowChatInput") else putString("wowChatInput", mode) }.apply()
|
||||
}
|
||||
|
||||
fun pcKeyboardOpacity(context: Context): Int =
|
||||
prefs(context).getInt("pcKeyboardOpacity", PC_KEYBOARD_OPACITY_DEFAULT).takeIf { it in PC_KEYBOARD_OPACITY_RANGE } ?: PC_KEYBOARD_OPACITY_DEFAULT
|
||||
|
||||
@@ -499,6 +508,17 @@ object SessionPrefs {
|
||||
prefs(context).edit().putString("gpuDriverMode", mode).apply()
|
||||
}
|
||||
|
||||
fun componentMode(context: Context, comp: String): String =
|
||||
prefs(context).getString("componentMode.$comp", ComponentUpdates.AUTO)
|
||||
?.takeIf { it == ComponentUpdates.AUTO || it == ComponentUpdates.MANUAL } ?: ComponentUpdates.AUTO
|
||||
|
||||
fun componentModeChosen(context: Context, comp: String): Boolean = prefs(context).contains("componentMode.$comp")
|
||||
|
||||
fun setComponentMode(context: Context, comp: String, mode: String) {
|
||||
require(mode == ComponentUpdates.AUTO || mode == ComponentUpdates.MANUAL)
|
||||
prefs(context).edit().putString("componentMode.$comp", mode).commit()
|
||||
}
|
||||
|
||||
fun settleGpuDriverMode(context: Context) {
|
||||
val p = prefs(context)
|
||||
if (p.contains("gpuDriverMode")) return
|
||||
|
||||
@@ -30,7 +30,7 @@ object SettingsReset {
|
||||
)
|
||||
|
||||
internal val SESSION_PREFIXES = listOf(
|
||||
"resolutionCap.", "displayResolution.", "customRes.", "hdr.", "fpsLimit.", "performanceMode.", "suspendPolicy.",
|
||||
"resolutionCap.", "displayResolution.", "customRes.", "hdr.", "fpsLimit.", "performanceMode.", "suspendPolicy.", "componentMode.",
|
||||
)
|
||||
|
||||
private val STEAM_TOUCH_KEYS = listOf("feedback", "autoFade", "gyro", "shake")
|
||||
|
||||
@@ -39,7 +39,7 @@ object GogComet {
|
||||
var status by mutableStateOf("off")
|
||||
private set
|
||||
|
||||
fun enabled(context: Context) = GogPrefs.get(context).getBoolean("achievements", false)
|
||||
fun enabled(context: Context) = GogPrefs.get(context).getBoolean("achievements", true)
|
||||
|
||||
fun setEnabled(context: Context, on: Boolean) {
|
||||
GogPrefs.get(context).edit().putBoolean("achievements", on).apply()
|
||||
|
||||
@@ -49,7 +49,9 @@ import androidx.compose.ui.input.InputMode
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
@@ -60,6 +62,8 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import coil.compose.AsyncImage
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.frontend.AddedGameArt
|
||||
@@ -192,7 +196,7 @@ internal fun SteamGameArtEditor(
|
||||
game: com.droiddeck.launcher.frontend.Library.SteamGame, onClose: () -> Unit, onChanged: () -> Unit,
|
||||
) {
|
||||
val target = remember(game.appId) { ArtTarget.of(game) }
|
||||
ArtOnlyEditor(game.name, target, game, { SteamGameArt.describe(it, game) }, onClose, onChanged, launchAppId = game.appId)
|
||||
ArtOnlyEditor(game.name, target, game, { SteamGameArt.describe(it, game) }, onClose, onChanged)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -208,25 +212,17 @@ internal fun StoreGameArtEditor(folder: String, name: String, onClose: () -> Uni
|
||||
@Composable
|
||||
private fun ArtOnlyEditor(
|
||||
name: String, target: ArtTarget?, refresh: Any?, describe: (Context) -> List<AddedGameArt.SlotState>,
|
||||
onClose: () -> Unit, onChanged: () -> Unit, launchAppId: Int? = null,
|
||||
onClose: () -> Unit, onChanged: () -> Unit,
|
||||
) {
|
||||
val app = LocalContext.current.applicationContext
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val scope = rememberCoroutineScope()
|
||||
val shown = rememberShown(onClose)
|
||||
val close = { shown.targetState = false }
|
||||
var version by remember { mutableIntStateOf(0) }
|
||||
var arts by remember { mutableStateOf<List<AddedGameArt.SlotState>>(emptyList()) }
|
||||
var artSlot by remember { mutableStateOf<AddedGameArt.Slot?>(null) }
|
||||
var logoOpen by remember { mutableStateOf(false) }
|
||||
var launchOptions by remember { mutableStateOf<String?>(null) }
|
||||
var launchVersion by remember { mutableIntStateOf(0) }
|
||||
var editingLaunch by remember { mutableStateOf<String?>(null) }
|
||||
var launchSaving by remember { mutableStateOf(false) }
|
||||
var launchError by remember { mutableStateOf<Int?>(null) }
|
||||
val position = rememberLogoPosition(target, version)
|
||||
LaunchedEffect(version, refresh) { if (target != null) arts = withContext(Dispatchers.IO) { describe(app) } }
|
||||
LaunchedEffect(launchAppId, launchVersion) { if (launchAppId != null) launchOptions = withContext(Dispatchers.IO) { SteamLaunchOptions.current(app, launchAppId) } }
|
||||
val first = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { focusWithinFrames({ false }) { first } }
|
||||
AppDialog(shown, close, "artEditor", wide = false, maxWidth = 460.dp) {
|
||||
@@ -236,19 +232,10 @@ private fun ArtOnlyEditor(
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.fillMaxWidth().heightIn(max = maxHeight).verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
if (launchAppId != null) {
|
||||
FieldRow(stringResource(R.string.edit_launch_options), focus = first, onClick = { if (!launchSaving) launchOptions?.let { editingLaunch = it } }) {
|
||||
val options = launchOptions
|
||||
if (options == null || launchSaving) Loading() else ValueText(options.ifEmpty { "—" }, muted = options.isEmpty())
|
||||
}
|
||||
val error = launchError
|
||||
if (error != null) Text(stringResource(error), fontSize = 12.sp, color = colors.error, modifier = Modifier.padding(start = 4.dp))
|
||||
else Text(stringResource(R.string.edit_launch_options_steam_hint), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 4.dp))
|
||||
}
|
||||
Text(stringResource(R.string.edit_artwork), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 4.dp, top = if (launchAppId != null) 8.dp else 2.dp))
|
||||
Text(stringResource(R.string.edit_artwork), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 4.dp, top = 2.dp))
|
||||
for (slot in AddedGameArt.Slot.values()) key(slot) {
|
||||
val state = arts.firstOrNull { it.slot == slot }
|
||||
FieldRow(slot.label(), focus = if (slot == AddedGameArt.Slot.COVER && launchAppId == null) first else null, onClick = { if (target != null) artSlot = slot }) {
|
||||
FieldRow(slot.label(), focus = if (slot == AddedGameArt.Slot.COVER) first else null, onClick = { if (target != null) artSlot = slot }) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
SlotThumb(slot, state?.file)
|
||||
if (state != null) ValueText(artSourceLabel(state.source), muted = true) else Loading()
|
||||
@@ -258,27 +245,6 @@ private fun ArtOnlyEditor(
|
||||
PickerRow(Icons.Outlined.Tune, stringResource(R.string.art_adjust_logo), logoPositionSummary(position?.value), enabled = target != null && position != null) { logoOpen = true }
|
||||
}
|
||||
}
|
||||
val before = editingLaunch
|
||||
if (before != null && launchAppId != null) TextEntryDialog(stringResource(R.string.edit_launch_options), before, onDismiss = { editingLaunch = null }) { value ->
|
||||
if (launchSaving || value == before.trim()) return@TextEntryDialog
|
||||
launchSaving = true
|
||||
scope.launch {
|
||||
try {
|
||||
val saved = withContext(Dispatchers.IO) { SteamLaunchOptions.set(app, launchAppId, value, before) }
|
||||
launchError = when (saved) {
|
||||
SteamLaunchOptions.Saved.YES -> null
|
||||
SteamLaunchOptions.Saved.DESKTOP -> R.string.edit_launch_options_desktop
|
||||
SteamLaunchOptions.Saved.NO -> R.string.edit_launch_options_not_saved
|
||||
}
|
||||
if (saved == SteamLaunchOptions.Saved.YES) launchOptions = value else {
|
||||
launchOptions = null
|
||||
launchVersion++
|
||||
}
|
||||
} finally {
|
||||
launchSaving = false
|
||||
}
|
||||
}
|
||||
}
|
||||
val chosen = target ?: return
|
||||
artSlot?.let { slot ->
|
||||
ArtChooserDialog(chosen, slot, arts.firstOrNull { it.slot == slot }, onBack = { artSlot = null }, onCloseAll = { artSlot = null; close() }) { version++; onChanged() }
|
||||
@@ -287,6 +253,72 @@ private fun ArtOnlyEditor(
|
||||
if (logoOpen && loaded != null) LogoPositionHost(chosen, arts, loaded, onBack = { logoOpen = false }, onCloseAll = { logoOpen = false; close() }) { version++; onChanged() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SteamLaunchOptionsPanel(appId: Int) {
|
||||
val app = LocalContext.current.applicationContext
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val scope = rememberCoroutineScope()
|
||||
var options by remember(appId) { mutableStateOf<String?>(null) }
|
||||
var version by remember(appId) { mutableIntStateOf(0) }
|
||||
var editing by remember(appId) { mutableStateOf<String?>(null) }
|
||||
var saving by remember(appId) { mutableStateOf(false) }
|
||||
var loading by remember(appId) { mutableStateOf(false) }
|
||||
var error by remember(appId) { mutableStateOf<Int?>(null) }
|
||||
LaunchedEffect(appId, version) {
|
||||
options = withContext(Dispatchers.IO) { SteamLaunchOptions.current(app, appId) } ?: options.orEmpty()
|
||||
loading = false
|
||||
}
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
DisposableEffect(lifecycle, appId) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME && !saving) {
|
||||
loading = true
|
||||
version++
|
||||
}
|
||||
}
|
||||
lifecycle.addObserver(observer)
|
||||
onDispose { lifecycle.removeObserver(observer) }
|
||||
}
|
||||
Column(Modifier.fillMaxWidth().tabPanel(), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
val value = options
|
||||
MenuItem(
|
||||
stringResource(R.string.edit_launch_options), checked = false, enabled = value != null && !saving && !loading,
|
||||
detail = if (value == null || saving || loading) "…" else value.ifEmpty { "—" },
|
||||
) { if (!saving && !loading) options?.let { editing = it } }
|
||||
val message = error
|
||||
Text(
|
||||
stringResource(message ?: R.string.edit_launch_options_steam_hint),
|
||||
fontSize = 12.sp,
|
||||
color = if (message == null) colors.onSurfaceVariant else colors.error,
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
)
|
||||
}
|
||||
val before = editing
|
||||
if (before != null) TextEntryDialog(stringResource(R.string.edit_launch_options), before, onDismiss = { editing = null }) { value ->
|
||||
if (saving || value == before.trim()) return@TextEntryDialog
|
||||
saving = true
|
||||
scope.launch {
|
||||
try {
|
||||
val saved = withContext(Dispatchers.IO) { SteamLaunchOptions.set(app, appId, value, before) }
|
||||
error = when (saved) {
|
||||
SteamLaunchOptions.Saved.YES -> null
|
||||
SteamLaunchOptions.Saved.DESKTOP -> R.string.edit_launch_options_desktop
|
||||
SteamLaunchOptions.Saved.NO -> R.string.edit_launch_options_not_saved
|
||||
}
|
||||
if (saved == SteamLaunchOptions.Saved.YES) {
|
||||
options = value
|
||||
version++
|
||||
} else {
|
||||
options = null
|
||||
version++
|
||||
}
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class LoadedPosition(val value: LogoPosition.Value?)
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -110,7 +111,16 @@ internal class BottomScreenTextInput(private val fallback: TextInputService) : P
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TypingPanel(request: TypingRequest, onDone: () -> Unit) {
|
||||
internal fun TypingPanel(
|
||||
request: TypingRequest,
|
||||
modifier: Modifier = Modifier.fillMaxSize(),
|
||||
title: String = stringResource(R.string.dual_typing_title),
|
||||
done: String = stringResource(R.string.dual_typing_done),
|
||||
onCancel: (() -> Unit)? = null,
|
||||
leading: (@Composable () -> Unit)? = null,
|
||||
showKeyboard: Boolean = true,
|
||||
onDone: () -> Unit,
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
var field by remember(request) { mutableStateOf(request.value) }
|
||||
LaunchedEffect(request.value) {
|
||||
@@ -120,19 +130,22 @@ internal fun TypingPanel(request: TypingRequest, onDone: () -> Unit) {
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
LaunchedEffect(request) {
|
||||
kotlinx.coroutines.delay(120)
|
||||
if (!showKeyboard) return@LaunchedEffect
|
||||
runCatching { focus.requestFocus() }
|
||||
keyboard?.show()
|
||||
}
|
||||
val options = request.imeOptions
|
||||
val secret = options.keyboardType == KeyboardType.Password || options.keyboardType == KeyboardType.NumberPassword
|
||||
Column(
|
||||
Modifier.fillMaxSize().background(colors.background).padding(horizontal = 20.dp, vertical = 14.dp),
|
||||
modifier.background(colors.background).padding(horizontal = 20.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(stringResource(R.string.dual_typing_title), fontSize = 15.sp, fontWeight = FontWeight.SemiBold,
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
leading?.invoke()
|
||||
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold,
|
||||
color = colors.onSurfaceVariant, modifier = Modifier.weight(1f))
|
||||
Button(onClick = onDone, modifier = Modifier.testTag("dual-typing-done")) { Text(stringResource(R.string.dual_typing_done)) }
|
||||
if (onCancel != null) OutlinedButton(onClick = onCancel, modifier = Modifier.testTag("dual-typing-cancel")) { Text(stringResource(R.string.common_cancel)) }
|
||||
Button(onClick = onDone, modifier = Modifier.testTag("dual-typing-done")) { Text(done) }
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = field,
|
||||
|
||||
@@ -81,6 +81,8 @@ 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.session.ComponentUpdater
|
||||
import com.droiddeck.launcher.session.ComponentUpdates
|
||||
import com.droiddeck.launcher.session.ComponentsManager
|
||||
import com.droiddeck.launcher.session.ComponentsManager.CatalogItem
|
||||
import com.droiddeck.launcher.session.ComponentsManager.Snapshot
|
||||
@@ -163,6 +165,11 @@ fun ComponentsPage(
|
||||
protons: ProtonsState = ProtonsState(),
|
||||
protonActions: ProtonsActions = ProtonsActions(),
|
||||
protonOptions: @Composable () -> Unit = {},
|
||||
compModes: Map<String, String> = emptyMap(),
|
||||
compAuto: Map<String, List<ComponentUpdater.Status>> = emptyMap(),
|
||||
compAutoHeld: Set<String> = emptySet(),
|
||||
compAutoChecking: Boolean = false,
|
||||
onCompMode: (String, String) -> Unit = { _, _ -> },
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
@@ -521,6 +528,9 @@ fun ComponentsPage(
|
||||
else -> stringResource(R.string.comp_nightlies_count, availableRows(l, catalog, snapshot).size)
|
||||
}
|
||||
}
|
||||
val followed = view?.proton?.let(ComponentUpdates::follows) == true
|
||||
val moded: (String) -> Boolean = { followed && it in compModes && it !in compAutoHeld && !ComponentsManager.runtimeManaged(it, gpu.systemPowerVr) }
|
||||
val automatic: (String) -> Boolean = { moded(it) && compModes[it] == ComponentUpdates.AUTO }
|
||||
val installedRows: @Composable ColumnScope.(String, Boolean) -> Unit = { l, right ->
|
||||
when (l) {
|
||||
PROTON_TAB -> {
|
||||
@@ -535,7 +545,7 @@ fun ComponentsPage(
|
||||
(if (swaps > 0) stringResource(R.string.comp_swapped, v.proton.version, swaps) else stringResource(R.string.comp_all_original, v.proton.version))
|
||||
.let { if (v.inUseByGame) stringResource(R.string.comp_game_running_suffix, it) else it },
|
||||
v.proton.id == view?.proton?.id, removable = removable,
|
||||
modifier = (if (i == 0) Modifier.focusRequester(firstRow) else Modifier).trackRow(key, rowsAt),
|
||||
modifier = (if (i == 0 && !moded(l)) Modifier.focusRequester(firstRow) else Modifier).trackRow(key, rowsAt),
|
||||
onSelect = { if (v.proton.id != view?.proton?.id) { onProton(v.proton.id); flyHome(key, PROTON_TAB) } },
|
||||
onDelete = {
|
||||
if (extra != null) ask(key, right, ctx.getString(R.string.proton_remove_title, v.proton.name), ctx.getString(R.string.proton_remove_body), ctx.getString(R.string.store_remove), true, v.proton.name) {
|
||||
@@ -555,7 +565,7 @@ fun ComponentsPage(
|
||||
val key = "row:$l:${item.file ?: item.protonVersion}"
|
||||
ChoiceLine(
|
||||
item.name, item.detail, item.selected, item.removable, tag = item.tag, enabled = !managedDxvk,
|
||||
modifier = (if (i == 0 && !managedDxvk) Modifier.focusRequester(firstRow) else Modifier).trackRow(key, rowsAt),
|
||||
modifier = (if (i == 0 && !managedDxvk && !moded(l)) Modifier.focusRequester(firstRow) else Modifier).trackRow(key, rowsAt),
|
||||
onSelect = {
|
||||
if (managedDxvk || item.selected || p == null) return@ChoiceLine
|
||||
val waits = if (view.inUseByGame) ctx.getString(R.string.comp_waits_long) else ctx.getString(R.string.comp_applies_long)
|
||||
@@ -680,15 +690,31 @@ fun ComponentsPage(
|
||||
label = "layerRows",
|
||||
) { k -> body(k) }
|
||||
}
|
||||
if (single) {
|
||||
slide { k -> RowGroup(installedTitle(k)) { installedRows(k, false) } }
|
||||
Spacer(Modifier.height(12.dp))
|
||||
slide { k -> RowGroup(availableTitle(k)) { availableRows(k) } }
|
||||
} else Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.Top) {
|
||||
Column(Modifier.weight(1f)) { slide { k -> RowGroup(installedTitle(k)) { installedRows(k, false) } } }
|
||||
Column(Modifier.weight(1f)) { slide { k -> RowGroup(availableTitle(k)) { availableRows(k) } } }
|
||||
if (moded(l)) slide { k ->
|
||||
if (moded(k)) Column(verticalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.padding(bottom = 14.dp)) {
|
||||
PanelHeading(stringResource(R.string.comp_mode_title))
|
||||
ModeChoices(
|
||||
listOf(
|
||||
Triple(ComponentUpdates.AUTO, R.string.common_auto, R.string.comp_mode_auto_detail),
|
||||
Triple(ComponentUpdates.MANUAL, R.string.drivers_manual, R.string.comp_mode_manual_detail),
|
||||
),
|
||||
if (automatic(k)) ComponentUpdates.AUTO else ComponentUpdates.MANUAL, single, firstRow,
|
||||
onPick = { onCompMode(k, it) },
|
||||
)
|
||||
if (automatic(k)) AutoComponentBody(ComponentsManager.LABEL[k] ?: k, compAuto[k].orEmpty(), compAutoChecking)
|
||||
}
|
||||
}
|
||||
if (!automatic(l)) {
|
||||
if (single) {
|
||||
slide { k -> RowGroup(installedTitle(k)) { installedRows(k, false) } }
|
||||
Spacer(Modifier.height(12.dp))
|
||||
slide { k -> RowGroup(availableTitle(k)) { availableRows(k) } }
|
||||
} else Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.Top) {
|
||||
Column(Modifier.weight(1f)) { slide { k -> RowGroup(installedTitle(k)) { installedRows(k, false) } } }
|
||||
Column(Modifier.weight(1f)) { slide { k -> RowGroup(availableTitle(k)) { availableRows(k) } } }
|
||||
}
|
||||
Box(Modifier.padding(top = 14.dp)) { slide { k -> actions(k) } }
|
||||
}
|
||||
Box(Modifier.padding(top = 14.dp)) { slide { k -> actions(k) } }
|
||||
if (l == "fex") {
|
||||
SettingsGroup(stringResource(R.string.fex_preset_title)) {
|
||||
SettingsRow(stringResource(R.string.fex_preset_title), null) {
|
||||
@@ -1078,6 +1104,25 @@ private fun RowGroup(title: String, content: @Composable ColumnScope.() -> Unit)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AutoComponentBody(label: String, lines: List<ComponentUpdater.Status>, checking: Boolean) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
PanelTitle(stringResource(R.string.comp_auto_title, label), stringResource(R.string.comp_auto_hint))
|
||||
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()) 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(" · "),
|
||||
selected = true, onClick = null,
|
||||
)
|
||||
}
|
||||
lines.map { it.note }.filter { it.isNotEmpty() }.distinct().forEach { Text(it, fontSize = 13.sp, color = colors.onSurfaceVariant) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyLine(text: String) {
|
||||
Text(text, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp))
|
||||
|
||||
@@ -122,6 +122,7 @@ class FrontEndState(
|
||||
val frameGen: FrameGen.Mode = FrameGen.Mode.OFF,
|
||||
val lossless: Lossless.State = Lossless.State.NONE,
|
||||
val shortcutPicker: Boolean = false,
|
||||
val preparingGames: Set<String> = emptySet(),
|
||||
val shortcutLibraryScanning: Boolean = false,
|
||||
val gameSyncFolder: String? = null,
|
||||
val pageKey: String? = null,
|
||||
@@ -188,6 +189,7 @@ class FrontEndActions(
|
||||
val onPlayDesktopUi: () -> Unit,
|
||||
val onSteamUrl: (String) -> Unit = {},
|
||||
val onSteamGame: (Library.SteamGame) -> Unit,
|
||||
val onStoreGame: (com.droiddeck.launcher.stores.Store, String) -> Unit = { _, _ -> },
|
||||
val onGameShortcut: (Library.SteamGame) -> Unit = {},
|
||||
val onExportGameFile: (Library.SteamGame) -> Unit = {},
|
||||
val onSyncGameFiles: () -> Unit = {},
|
||||
|
||||
@@ -56,6 +56,7 @@ import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.semantics.Role
|
||||
@@ -150,6 +151,27 @@ internal fun Chip(t: String, ok: Boolean) {
|
||||
@Composable
|
||||
internal fun rememberHot(src: MutableInteractionSource): Boolean = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
|
||||
|
||||
@Composable
|
||||
internal fun DialogToggleRow(label: String, checked: Boolean, onChange: (Boolean) -> Unit) {
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val flip = { onChange(!checked) }
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(Shape12)
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.12f) else Color.Transparent)
|
||||
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape12)
|
||||
.hoverable(src)
|
||||
.clickable(interactionSource = src, indication = null, role = Role.Switch, onClick = flip)
|
||||
.controllerConfirm(onClick = flip)
|
||||
.padding(start = 14.dp, end = 4.dp),
|
||||
) {
|
||||
Text(label, fontSize = 14.sp, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.weight(1f))
|
||||
ToggleSwitch(checked, label = label, modifier = Modifier.focusProperties { canFocus = false }) { onChange(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PrimaryButton(
|
||||
text: String,
|
||||
|
||||
@@ -408,11 +408,13 @@ internal fun GameActions(
|
||||
!cloud && (game != null || installed != null) -> {
|
||||
val gone = storeKey?.let { StoresState.uninstalling[it] }
|
||||
val xboxBusy = store == Store.XBOX && id != null && XboxContent.busy(id)
|
||||
val preparing = (game?.launchKey ?: storeKey)?.let { it in s.preparingGames } == true
|
||||
PrimaryButton(
|
||||
stringResource(R.string.stores_play), enabled = !s.busy && gone == null && download == null && !xboxBusy && (store != Store.GOG || installed?.sidecar?.isInstalled != false),
|
||||
stringResource(if (preparing) R.string.store_starting else R.string.stores_play),
|
||||
enabled = !s.busy && !preparing && gone == null && download == null && !xboxBusy && (store != Store.GOG || installed?.sidecar?.isInstalled != false),
|
||||
main = true, icon = Icons.Filled.PlayArrow, modifier = leadOn,
|
||||
) {
|
||||
if (game != null) a.onSteamGame(game) else if (store != null && id != null) launchStoreGame(ctx, store, id, s, a)
|
||||
if (game != null) a.onSteamGame(game) else if (store != null && id != null) launchStoreGame(store, id, s, a)
|
||||
}
|
||||
if (game != null) {
|
||||
if (compactMenu) GameMoreMenu(game, a) else {
|
||||
@@ -808,11 +810,17 @@ private fun LaunchTab(game: Library.SteamGame, s: FrontEndState, a: FrontEndActi
|
||||
val host = rememberMenuHost()
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
LaunchSettings(s, a, host, game, hopRail ?: GameRef.key(game))
|
||||
if (canEditSteamLaunchOptions(game, s.shortcutPicker)) {
|
||||
SteamLaunchOptionsPanel(game.appId)
|
||||
}
|
||||
val folder = game.gameFiles
|
||||
if (game.library == Library.ADDED && game.source == Library.ADDED && !s.shortcutPicker && folder != null) ShortcutFields(game, folder)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun canEditSteamLaunchOptions(game: Library.SteamGame, shortcutPicker: Boolean): Boolean =
|
||||
!shortcutPicker && game.source == Library.SOURCE_STEAM && game.library != Library.ADDED
|
||||
|
||||
@Composable
|
||||
private fun ShortcutFields(game: Library.SteamGame, folder: File) {
|
||||
val ctx = LocalContext.current
|
||||
|
||||
@@ -250,18 +250,8 @@ internal fun GpuDriversBody(
|
||||
Triple(GPU_MODE_PAIR, R.string.gpu_mode_pair, R.string.gpu_mode_pair_detail),
|
||||
Triple(GPU_MODE_MIX, R.string.gpu_mode_mix, R.string.gpu_mode_mix_detail),
|
||||
)
|
||||
val choice: @Composable (Int, Modifier) -> Unit = { i, mod ->
|
||||
val (id, title, detail) = modes[i]
|
||||
PanelRow(
|
||||
stringResource(title), stringResource(detail), gpu.mode == id,
|
||||
modifier = mod.then(if (gpu.mode == id) Modifier.focusRequester(first) else Modifier)
|
||||
.then(if (i == 0) Modifier.onGloballyPositioned { placed[1] = it }.onFocusChanged { if (it.isFocused) revealCard() } else Modifier),
|
||||
onClick = { if (gpu.mode != id) actions.onMode(id) },
|
||||
)
|
||||
}
|
||||
if (single) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { modes.indices.forEach { choice(it, Modifier) } }
|
||||
else Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
|
||||
modes.indices.forEach { choice(it, Modifier.weight(1f).fillMaxHeight()) }
|
||||
ModeChoices(modes, gpu.mode, single, first, onPick = actions.onMode) { i ->
|
||||
if (i == 0) Modifier.onGloballyPositioned { placed[1] = it }.onFocusChanged { if (it.isFocused) revealCard() } else Modifier
|
||||
}
|
||||
when (gpu.mode) {
|
||||
GPU_MODE_AUTO -> AutoBody(gpu, actions)
|
||||
@@ -439,12 +429,35 @@ internal const val SLOT_DISPLAY = "panel"
|
||||
internal const val SLOT_RUNTIME = "rt"
|
||||
|
||||
@Composable
|
||||
private fun PanelHeading(text: String) {
|
||||
internal fun ModeChoices(
|
||||
modes: List<Triple<String, Int, Int>>,
|
||||
selected: String,
|
||||
single: Boolean,
|
||||
first: FocusRequester,
|
||||
onPick: (String) -> Unit,
|
||||
extra: (Int) -> Modifier = { Modifier },
|
||||
) {
|
||||
val choice: @Composable (Int, Modifier) -> Unit = { i, mod ->
|
||||
val (id, title, detail) = modes[i]
|
||||
PanelRow(
|
||||
stringResource(title), stringResource(detail), selected == id,
|
||||
modifier = mod.then(if (selected == id) Modifier.focusRequester(first) else Modifier).then(extra(i)),
|
||||
onClick = { if (selected != id) onPick(id) },
|
||||
)
|
||||
}
|
||||
if (single) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { modes.indices.forEach { choice(it, Modifier) } }
|
||||
else Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
|
||||
modes.indices.forEach { choice(it, Modifier.weight(1f).fillMaxHeight()) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PanelHeading(text: String) {
|
||||
Text(text.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PanelTitle(title: String, hint: String) {
|
||||
internal fun PanelTitle(title: String, hint: String) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp), modifier = Modifier.padding(top = 4.dp)) {
|
||||
Text(title, fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onBackground)
|
||||
Text(hint, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
@@ -461,7 +474,7 @@ private fun Pill(text: String, fill: Color, ink: Color) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PanelRow(
|
||||
internal fun PanelRow(
|
||||
title: String,
|
||||
detail: String,
|
||||
selected: Boolean,
|
||||
|
||||
@@ -46,6 +46,8 @@ class SecondScreenPresentation(
|
||||
private val frame = FrameLayout(context)
|
||||
private var menuView: androidx.compose.ui.platform.ComposeView? = null
|
||||
private var dashboardView: androidx.compose.ui.platform.ComposeView? = null
|
||||
private var chatView: androidx.compose.ui.platform.ComposeView? = null
|
||||
private var chatLost: (() -> Unit)? = null
|
||||
var menuShown = false
|
||||
private set
|
||||
private val root = LinearLayout(context).apply {
|
||||
@@ -78,7 +80,7 @@ class SecondScreenPresentation(
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
root.setOnApplyWindowInsetsListener { view, insets ->
|
||||
if (insets.isVisible(WindowInsets.Type.ime())) imeSeen = true
|
||||
else if (imeSeen) keyboardClosed()
|
||||
else if (imeSeen && chatView == null) keyboardClosed()
|
||||
view.onApplyWindowInsets(insets)
|
||||
}
|
||||
}
|
||||
@@ -128,6 +130,37 @@ class SecondScreenPresentation(
|
||||
if (created && isShowing && currentMode == SecondScreenMode.TERMINAL && terminalPanel?.ready != true) renderMode()
|
||||
}
|
||||
|
||||
fun showChat(onLost: () -> Unit, content: @androidx.compose.runtime.Composable () -> Unit) {
|
||||
if (!showsKeyboard || chatView != null) return
|
||||
keyboardHost?.takeIf { it.shown }?.hide()
|
||||
keyboardShown.value = false
|
||||
keyboardPending = false
|
||||
fieldKeyboard = false
|
||||
chatLost = onLost
|
||||
ownComposeViews()
|
||||
val view = androidx.compose.ui.platform.ComposeView(context).apply {
|
||||
isFocusable = true
|
||||
isFocusableInTouchMode = true
|
||||
setContent { content() }
|
||||
}
|
||||
chatView = view
|
||||
root.addView(view, 1.coerceAtMost(root.childCount), LinearLayout.LayoutParams(-1, -2))
|
||||
}
|
||||
|
||||
fun hideChat() {
|
||||
chatLost = null
|
||||
dropChat()
|
||||
}
|
||||
|
||||
private fun dropChat() {
|
||||
val view = chatView ?: return
|
||||
chatView = null
|
||||
chatLost?.let { lost -> chatLost = null; sessionActivity.runOnUiThread(lost) }
|
||||
imeSeen = false
|
||||
root.removeView(view)
|
||||
view.disposeComposition()
|
||||
}
|
||||
|
||||
val showsKeyboard: Boolean get() = isShowing && !menuShown && currentMode == SecondScreenMode.KEYBOARD_TRACKPAD
|
||||
|
||||
val showsControls: Boolean get() = isShowing && currentMode != SecondScreenMode.NONE
|
||||
@@ -178,6 +211,7 @@ class SecondScreenPresentation(
|
||||
showMenu(false)
|
||||
closeDashboard()
|
||||
closeTerminal()
|
||||
dropChat()
|
||||
keyboardHost?.hide()
|
||||
keyboardHost = null
|
||||
dismiss()
|
||||
@@ -212,6 +246,7 @@ class SecondScreenPresentation(
|
||||
}
|
||||
|
||||
private fun renderMode() {
|
||||
dropChat()
|
||||
fieldKeyboard = false
|
||||
keyboardHost?.hide()
|
||||
keyboardHost = null
|
||||
|
||||
@@ -264,6 +264,9 @@ class DrawerActions(
|
||||
val onImportLossless: () -> Unit,
|
||||
val onKeyboard: () -> Unit,
|
||||
val onHardwareKeyboard: () -> Unit,
|
||||
val wowChatShown: Boolean = false,
|
||||
val wowChat: String? = null,
|
||||
val onWowChat: (String?) -> Unit = {},
|
||||
val onSteamMenu: (() -> Unit)?,
|
||||
val onQam: (() -> Unit)?,
|
||||
val onAchievements: (() -> Unit)? = null,
|
||||
@@ -698,6 +701,15 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
|
||||
host.open = null; a.onKeyboard()
|
||||
}
|
||||
}
|
||||
if (a.wowChatShown) ChoiceRow(
|
||||
host, "wow-chat", stringResource(R.string.wow_chat_title), null,
|
||||
listOf(
|
||||
null to stringResource(R.string.wow_chat_ask),
|
||||
SessionPrefs.WOW_CHAT_VOICE to stringResource(R.string.wow_chat_voice),
|
||||
SessionPrefs.WOW_CHAT_KEYBOARD to stringResource(R.string.pckb_android),
|
||||
),
|
||||
a.wowChat, chipModifier = focus.track(page, "wow-chat"), onPick = a.onWowChat,
|
||||
)
|
||||
val keyboardContext = LocalContext.current
|
||||
remember { PcKeyboardPrefs.load(keyboardContext) }
|
||||
SliderRow(
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Usb
|
||||
@@ -18,12 +12,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -48,23 +37,7 @@ internal fun SlowUsbDialog(drive: UsbLink.SlowDrive, never: Boolean, onNever: (B
|
||||
stringResource(message, drive.name, UsbLink.generation(drive.mbps), speed, UsbLink.megabytesPerSecond(drive.mbps)),
|
||||
fontSize = 13.sp, lineHeight = 18.sp, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val flip = { onNever(!never) }
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(Shape12)
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.12f) else Color.Transparent)
|
||||
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape12)
|
||||
.hoverable(src)
|
||||
.clickable(interactionSource = src, indication = null, role = Role.Switch, onClick = flip)
|
||||
.controllerConfirm(onClick = flip)
|
||||
.padding(start = 14.dp, end = 4.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.slow_usb_never), fontSize = 14.sp, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.weight(1f))
|
||||
ToggleSwitch(never, label = stringResource(R.string.slow_usb_never), modifier = Modifier.focusProperties { canFocus = false }) { onNever(it) }
|
||||
}
|
||||
DialogToggleRow(stringResource(R.string.slow_usb_never), never, onNever)
|
||||
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
|
||||
PrimaryButton(stringResource(R.string.slow_usb_dismiss), modifier = Modifier.focusRequester(focus), onClick = close)
|
||||
}
|
||||
|
||||
@@ -221,7 +221,7 @@ private fun FinishedRow(d: DownloadEntry, s: FrontEndState, a: FrontEndActions,
|
||||
ArtStrip(d.cover, d.name) {
|
||||
StripLabel(d.name, d.store.id, stringResource(if (d.state == DownloadState.INSTALLED) R.string.stores_installed_chip else R.string.stores_dl_cancelled), Color.White.copy(alpha = 0.72f))
|
||||
if (d.state == DownloadState.INSTALLED) Box(Modifier.bloomIn(bloom, GATHER_HOLD_MS + 150)) {
|
||||
RoundAction(Icons.Filled.PlayArrow, stringResource(R.string.stores_play), primary = true) { if (d.key == EpicClient.DOWNLOAD_KEY) a.onEpicLauncher() else if (d.key == com.droiddeck.launcher.stores.battlenet.BattleNetClient.DOWNLOAD_KEY) a.onBattleNet() else launchStoreGame(ctx, d.store, d.id, s, a) }
|
||||
RoundAction(Icons.Filled.PlayArrow, stringResource(R.string.stores_play), primary = true) { if (d.key == EpicClient.DOWNLOAD_KEY) a.onEpicLauncher() else if (d.key == com.droiddeck.launcher.stores.battlenet.BattleNetClient.DOWNLOAD_KEY) a.onBattleNet() else launchStoreGame(d.store, d.id, s, a) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -374,7 +374,7 @@ private fun DownloadCard(d: DownloadEntry, s: FrontEndState, a: FrontEndActions)
|
||||
CancelAction { DownloadQueue.clear(ctx, d.key) }
|
||||
}
|
||||
DownloadState.INSTALLED -> Box(Modifier.bloomIn(true, 420)) {
|
||||
RoundAction(Icons.Filled.PlayArrow, stringResource(R.string.stores_play), primary = true) { if (d.key == EpicClient.DOWNLOAD_KEY) a.onEpicLauncher() else if (d.key == com.droiddeck.launcher.stores.battlenet.BattleNetClient.DOWNLOAD_KEY) a.onBattleNet() else launchStoreGame(ctx, d.store, d.id, s, a) }
|
||||
RoundAction(Icons.Filled.PlayArrow, stringResource(R.string.stores_play), primary = true) { if (d.key == EpicClient.DOWNLOAD_KEY) a.onEpicLauncher() else if (d.key == com.droiddeck.launcher.stores.battlenet.BattleNetClient.DOWNLOAD_KEY) a.onBattleNet() else launchStoreGame(d.store, d.id, s, a) }
|
||||
}
|
||||
DownloadState.CANCELLED -> {}
|
||||
}
|
||||
@@ -545,6 +545,5 @@ internal fun storesSettingsStep(at: Rect, s: androidx.compose.runtime.State<Fron
|
||||
com.droiddeck.launcher.stores.gog.GogComet.setEnabled(context, it)
|
||||
}
|
||||
}
|
||||
StoresEngineRow()
|
||||
}
|
||||
}
|
||||
@@ -1430,18 +1430,7 @@ internal fun openUrl(ctx: android.content.Context, url: String) {
|
||||
runCatching { ctx.startActivity(android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url)).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)) }
|
||||
}
|
||||
|
||||
internal fun launchStoreGame(ctx: android.content.Context, store: Store, id: String, s: FrontEndState, a: FrontEndActions) {
|
||||
internal fun launchStoreGame(store: Store, id: String, s: FrontEndState, a: FrontEndActions) {
|
||||
val game = s.steamGames.firstOrNull { it.source == store.id && it.storeId == id }
|
||||
if (game != null) {
|
||||
a.onSteamGame(game)
|
||||
return
|
||||
}
|
||||
val app = ctx.applicationContext
|
||||
Thread({
|
||||
val installed = runCatching { com.droiddeck.launcher.stores.StoreLaunch.installedGame(app, store, id) }.getOrNull()
|
||||
StoresState.post {
|
||||
if (installed == null) android.widget.Toast.makeText(ctx, R.string.stores_launch_not_registered, android.widget.Toast.LENGTH_LONG).show()
|
||||
else a.onSteamGame(installed)
|
||||
}
|
||||
}, "store-play").start()
|
||||
if (game != null) a.onSteamGame(game) else a.onStoreGame(store, id)
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.stores.StoresNative
|
||||
|
||||
|
||||
@Composable
|
||||
internal fun StoresEngineRow() {
|
||||
val version = StoresNative.version
|
||||
SettingsRow(
|
||||
stringResource(R.string.setup_stores_engine),
|
||||
null,
|
||||
) {
|
||||
Chip(if (version != null) stringResource(R.string.setup_stores_engine_chip_ready) else stringResource(R.string.setup_stores_engine_chip_missing), ok = version != null)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.speech.RecognitionListener
|
||||
import android.speech.RecognizerIntent
|
||||
import android.speech.SpeechRecognizer
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material3.FilledIconToggleButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.ImeOptions
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
|
||||
class WowChat(
|
||||
private val context: Context,
|
||||
private val onSend: (String, Boolean) -> Unit,
|
||||
private val onCancel: () -> Unit,
|
||||
private val readField: ((String?) -> Unit) -> Unit = {},
|
||||
) {
|
||||
var request by mutableStateOf<TypingRequest?>(null)
|
||||
private set
|
||||
var asking by mutableStateOf(false)
|
||||
private set
|
||||
var listening by mutableStateOf(false)
|
||||
private set
|
||||
var voice by mutableStateOf(false)
|
||||
private set
|
||||
var remembered by mutableStateOf(SessionPrefs.wowChatInput(context))
|
||||
private set
|
||||
private var recognizer: SpeechRecognizer? = null
|
||||
private var spokenBase = ""
|
||||
private var fieldRead = false
|
||||
|
||||
val open: Boolean get() = request != null || asking
|
||||
|
||||
fun chatOpened() {
|
||||
if (open) return
|
||||
when (val mode = remembered) {
|
||||
null -> asking = true
|
||||
else -> start(mode)
|
||||
}
|
||||
}
|
||||
|
||||
fun chatClosed() {
|
||||
asking = false
|
||||
finish()
|
||||
}
|
||||
|
||||
fun pick(mode: String, remember: Boolean) {
|
||||
asking = false
|
||||
if (remember) choose(mode)
|
||||
start(mode)
|
||||
}
|
||||
|
||||
fun choose(mode: String?) {
|
||||
SessionPrefs.setWowChatInput(context, mode)
|
||||
remembered = SessionPrefs.wowChatInput(context)
|
||||
}
|
||||
|
||||
fun dismissChoice() {
|
||||
asking = false
|
||||
onCancel()
|
||||
}
|
||||
|
||||
fun send() {
|
||||
val text = request?.value?.text.orEmpty()
|
||||
val replace = fieldRead
|
||||
finish()
|
||||
onSend(text, replace)
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
finish()
|
||||
onCancel()
|
||||
}
|
||||
|
||||
private fun start(mode: String) {
|
||||
voice = mode == SessionPrefs.WOW_CHAT_VOICE && voiceAvailable(context)
|
||||
if (mode == SessionPrefs.WOW_CHAT_VOICE && !voice) Toast.makeText(context, R.string.wow_chat_voice_unavailable, Toast.LENGTH_SHORT).show()
|
||||
val opened = TypingRequest(TextFieldValue(""), ImeOptions(singleLine = true, imeAction = ImeAction.Send), {}, {})
|
||||
fieldRead = false
|
||||
request = opened
|
||||
readField { text ->
|
||||
if (request === opened && !text.isNullOrEmpty() && opened.value.text.isEmpty()) {
|
||||
fieldRead = true
|
||||
opened.value = TextFieldValue(text, TextRange(text.length))
|
||||
if (recognizer != null) spokenBase = "${text.trimEnd()} "
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun finish() {
|
||||
stopListening()
|
||||
request = null
|
||||
voice = false
|
||||
}
|
||||
|
||||
fun listen() {
|
||||
val active = request ?: return
|
||||
stopListening()
|
||||
val speech = runCatching { SpeechRecognizer.createSpeechRecognizer(context) }.getOrNull() ?: return
|
||||
recognizer = speech
|
||||
spokenBase = active.value.text.trimEnd().let { if (it.isEmpty()) it else "$it " }
|
||||
speech.setRecognitionListener(object : RecognitionListener {
|
||||
override fun onReadyForSpeech(params: Bundle?) { listening = true }
|
||||
override fun onBeginningOfSpeech() {}
|
||||
override fun onRmsChanged(rmsdB: Float) {}
|
||||
override fun onBufferReceived(buffer: ByteArray?) {}
|
||||
override fun onEndOfSpeech() {}
|
||||
override fun onError(error: Int) { if (recognizer === speech) stopListening() }
|
||||
override fun onResults(results: Bundle?) {
|
||||
if (recognizer !== speech) return
|
||||
heard(results)
|
||||
stopListening()
|
||||
}
|
||||
override fun onPartialResults(partialResults: Bundle?) { if (recognizer === speech) heard(partialResults) }
|
||||
override fun onEvent(eventType: Int, params: Bundle?) {}
|
||||
})
|
||||
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
|
||||
.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
|
||||
.putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
|
||||
.putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName)
|
||||
runCatching { speech.startListening(intent) }.onFailure { stopListening() }
|
||||
}
|
||||
|
||||
private fun heard(results: Bundle?) {
|
||||
val spoken = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)?.firstOrNull()?.takeIf { it.isNotBlank() } ?: return
|
||||
val text = spokenBase + spoken
|
||||
request?.value = TextFieldValue(text, TextRange(text.length))
|
||||
}
|
||||
|
||||
fun stopListening() {
|
||||
listening = false
|
||||
val speech = recognizer ?: return
|
||||
recognizer = null
|
||||
runCatching { speech.cancel() }
|
||||
runCatching { speech.destroy() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val PROGRAM = Regex("wow[\\w-]*\\.exe", RegexOption.IGNORE_CASE)
|
||||
|
||||
fun isWow(program: String?): Boolean = program != null && PROGRAM.matches(program)
|
||||
|
||||
fun voiceAvailable(context: Context): Boolean = SpeechRecognizer.isRecognitionAvailable(context)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun WowChatPanel(chat: WowChat, modifier: Modifier = Modifier.fillMaxWidth()) {
|
||||
val request = chat.request ?: return
|
||||
val context = LocalContext.current
|
||||
val mic = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
if (granted) chat.listen() else Toast.makeText(context, R.string.wow_chat_mic_denied, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
val listen = {
|
||||
if (context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) chat.listen()
|
||||
else mic.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
LaunchedEffect(request) { if (chat.voice) listen() }
|
||||
DisposableEffect(request) { onDispose { chat.stopListening() } }
|
||||
BackHandler(onBack = chat::cancel)
|
||||
TypingPanel(
|
||||
request, modifier,
|
||||
title = stringResource(if (chat.listening) R.string.wow_chat_listening else R.string.wow_chat_title),
|
||||
done = stringResource(R.string.wow_chat_send),
|
||||
onCancel = chat::cancel,
|
||||
showKeyboard = !chat.voice,
|
||||
leading = if (WowChat.voiceAvailable(context)) ({
|
||||
FilledIconToggleButton(
|
||||
checked = chat.listening,
|
||||
onCheckedChange = { on -> if (on) listen() else chat.stopListening() },
|
||||
modifier = Modifier.testTag("wow-chat-mic"),
|
||||
) { Icon(Icons.Filled.Mic, stringResource(R.string.wow_chat_voice)) }
|
||||
}) else null,
|
||||
onDone = chat::send,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun WowChatChoiceDialog(chat: WowChat) {
|
||||
var remember by remember { mutableStateOf(true) }
|
||||
var picked by remember { mutableStateOf<String?>(null) }
|
||||
val shown = rememberShown {
|
||||
val mode = picked
|
||||
if (mode != null) chat.pick(mode, remember) else chat.dismissChoice()
|
||||
}
|
||||
val close = { shown.targetState = false }
|
||||
val choose = { mode: String -> picked = mode; close() }
|
||||
val focus = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { runCatching { focus.requestFocus() } }
|
||||
AppDialog(shown, close, "wowChat", wide = false, maxWidth = 520.dp) {
|
||||
DialogTitleRow(stringResource(R.string.wow_chat_title), close)
|
||||
DialogToggleRow(stringResource(R.string.wow_chat_remember), remember) { remember = it }
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp, androidx.compose.ui.Alignment.End), modifier = Modifier.fillMaxWidth()) {
|
||||
SecondaryButton(stringResource(R.string.pckb_android), modifier = Modifier.testTag("wow-chat-keyboard")) { choose(SessionPrefs.WOW_CHAT_KEYBOARD) }
|
||||
PrimaryButton(stringResource(R.string.wow_chat_voice), icon = Icons.Filled.Mic,
|
||||
modifier = Modifier.focusRequester(focus).testTag("wow-chat-voice")) { choose(SessionPrefs.WOW_CHAT_VOICE) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -564,6 +553,19 @@
|
||||
<string name="comp_tag_in_use">قيد الاستخدام</string>
|
||||
<string name="comp_tag_new">جديد</string>
|
||||
<string name="comp_tag_stored">مخزّن</string>
|
||||
<string name="comp_mode_title">كيفية اختيار الإصدارات</string>
|
||||
<string name="comp_mode_auto_detail">أحدث إصدار من DroidDeck، محدّث دائمًا</string>
|
||||
<string name="comp_mode_manual_detail">اختر الإصدارات وثبّتها بنفسك</string>
|
||||
<string name="comp_auto_title">يُبقي Auto %1$s محدّثًا</string>
|
||||
<string name="comp_auto_hint">تُثبَّت الإصدارات الجديدة في الخلفية. إذا أُغلقت لعبة كانت تعمل خلال ثوانٍ مرتين، يعود Auto إلى الإصدار الذي كان يعمل.</string>
|
||||
<string name="comp_auto_fallback">الاحتياطي: %1$s</string>
|
||||
<string name="comp_auto_checking">جارٍ البحث عن إصدارات…</string>
|
||||
<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_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>
|
||||
<string name="perf_title">الأداء</string>
|
||||
<string name="perf_lede">أنوية CPU وإصلاحات الجلسة واسم المضيف. يُطبَّق في الجلسة التالية.</string>
|
||||
<string name="perf_client_cores_group">أنوية عميل Steam</string>
|
||||
@@ -2329,9 +2331,6 @@
|
||||
<string name="source_added">مخصَّص</string>
|
||||
<string name="setup_stores">المتاجر</string>
|
||||
<string name="setup_stores_speed">فئة سرعة التنزيل</string>
|
||||
<string name="setup_stores_engine">محرك المتاجر</string>
|
||||
<string name="setup_stores_engine_chip_ready">● المحرك جاهز</string>
|
||||
<string name="setup_stores_engine_chip_missing">غير مبنيّ</string>
|
||||
<string name="stores_tier_balanced">متوازن</string>
|
||||
<string name="stores_tier_fast">سريع</string>
|
||||
<string name="stores_tier_max">أقصى</string>
|
||||
@@ -3832,4 +3831,12 @@
|
||||
<string name="stores_gamejolt_filter_status">الحالة</string>
|
||||
<string name="stores_gamejolt_filter_partners">الشركاء</string>
|
||||
<string name="stores_gamejolt_followed">الألعاب المتابَعة</string>
|
||||
<string name="wow_chat_title">إدخال الدردشة</string>
|
||||
<string name="wow_chat_voice">الكتابة بالصوت</string>
|
||||
<string name="wow_chat_remember">تذكّر هذا الاختيار</string>
|
||||
<string name="wow_chat_ask">السؤال في كل مرة</string>
|
||||
<string name="wow_chat_send">إرسال</string>
|
||||
<string name="wow_chat_listening">جارٍ الاستماع…</string>
|
||||
<string name="wow_chat_voice_unavailable">الكتابة بالصوت غير متاحة على هذا الجهاز.</string>
|
||||
<string name="wow_chat_mic_denied">يلزم الوصول إلى الميكروفون للكتابة بالصوت.</string>
|
||||
</resources>
|
||||
@@ -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>
|
||||
@@ -558,6 +547,19 @@
|
||||
<string name="comp_tag_in_use">En uso</string>
|
||||
<string name="comp_tag_new">Nuevo</string>
|
||||
<string name="comp_tag_stored">Guardado</string>
|
||||
<string name="comp_mode_title">Cómo se eligen las versiones</string>
|
||||
<string name="comp_mode_auto_detail">Versión más reciente de DroidDeck, siempre al día</string>
|
||||
<string name="comp_mode_manual_detail">Elige e instala las versiones tú mismo</string>
|
||||
<string name="comp_auto_title">Auto mantiene %1$s al día</string>
|
||||
<string name="comp_auto_hint">Las nuevas versiones se instalan en segundo plano. Si un juego que funcionaba se cierra en segundos dos veces, Auto vuelve a la versión que funcionaba.</string>
|
||||
<string name="comp_auto_fallback">Respaldo: %1$s</string>
|
||||
<string name="comp_auto_checking">Buscando versiones…</string>
|
||||
<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_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>
|
||||
<string name="perf_title">Rendimiento</string>
|
||||
<string name="perf_lede">Núcleos de CPU, correcciones de sesión y nombre del host. Se aplica en la próxima sesión.</string>
|
||||
<string name="perf_client_cores_group">Núcleos del cliente de Steam</string>
|
||||
@@ -2298,9 +2300,6 @@
|
||||
<string name="source_added">Personalizados</string>
|
||||
<string name="setup_stores">Tiendas</string>
|
||||
<string name="setup_stores_speed">Nivel de velocidad de descarga</string>
|
||||
<string name="setup_stores_engine">Motor de las tiendas</string>
|
||||
<string name="setup_stores_engine_chip_ready">● Motor listo</string>
|
||||
<string name="setup_stores_engine_chip_missing">Sin compilar</string>
|
||||
<string name="stores_tier_balanced">Equilibrado</string>
|
||||
<string name="stores_tier_fast">Rápido</string>
|
||||
<string name="stores_tier_max">Máximo</string>
|
||||
@@ -3688,4 +3687,12 @@
|
||||
<string name="stores_gamejolt_filter_status">Estado</string>
|
||||
<string name="stores_gamejolt_filter_partners">Socios</string>
|
||||
<string name="stores_gamejolt_followed">Juegos seguidos</string>
|
||||
<string name="wow_chat_title">Entrada del chat</string>
|
||||
<string name="wow_chat_voice">Dictado por voz</string>
|
||||
<string name="wow_chat_remember">Recordar esta selección</string>
|
||||
<string name="wow_chat_ask">Preguntar cada vez</string>
|
||||
<string name="wow_chat_send">Enviar</string>
|
||||
<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>
|
||||
</resources>
|
||||
@@ -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>
|
||||
@@ -556,6 +545,19 @@
|
||||
<string name="comp_tag_in_use">В употреба</string>
|
||||
<string name="comp_tag_new">Ново</string>
|
||||
<string name="comp_tag_stored">Запазено</string>
|
||||
<string name="comp_mode_title">Как се избират версиите</string>
|
||||
<string name="comp_mode_auto_detail">Най-новата версия на DroidDeck, винаги актуална</string>
|
||||
<string name="comp_mode_manual_detail">Избирайте и инсталирайте версии сами</string>
|
||||
<string name="comp_auto_title">Auto поддържа %1$s актуален</string>
|
||||
<string name="comp_auto_hint">Новите версии се инсталират във фонов режим. Ако игра, която е работила, се затвори до секунди два пъти, Auto се връща към работещата версия.</string>
|
||||
<string name="comp_auto_fallback">Резервна: %1$s</string>
|
||||
<string name="comp_auto_checking">Търсене на версии…</string>
|
||||
<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_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>
|
||||
<string name="perf_title">Производителност</string>
|
||||
<string name="perf_lede">CPU ядра, корекции на сесията и името на хоста. Прилага се от следващата сесия.</string>
|
||||
<string name="perf_client_cores_group">Ядра за клиента на Steam</string>
|
||||
@@ -2229,9 +2231,6 @@
|
||||
<string name="source_added">Персонализирана</string>
|
||||
<string name="setup_stores">Магазини</string>
|
||||
<string name="setup_stores_speed">Скорост на изтегляне</string>
|
||||
<string name="setup_stores_engine">Ядро на магазините</string>
|
||||
<string name="setup_stores_engine_chip_ready">● Ядрото е готово</string>
|
||||
<string name="setup_stores_engine_chip_missing">Не е компилирано</string>
|
||||
<string name="stores_tier_balanced">Балансирано</string>
|
||||
<string name="stores_tier_fast">Бързо</string>
|
||||
<string name="stores_tier_max">Максимално</string>
|
||||
@@ -3676,4 +3675,12 @@
|
||||
<string name="stores_gamejolt_filter_status">Статус</string>
|
||||
<string name="stores_gamejolt_filter_partners">Партньори</string>
|
||||
<string name="stores_gamejolt_followed">Следвани игри</string>
|
||||
<string name="wow_chat_title">Въвеждане в чата</string>
|
||||
<string name="wow_chat_voice">Гласово въвеждане</string>
|
||||
<string name="wow_chat_remember">Запомни този избор</string>
|
||||
<string name="wow_chat_ask">Питай всеки път</string>
|
||||
<string name="wow_chat_send">Изпрати</string>
|
||||
<string name="wow_chat_listening">Слушане…</string>
|
||||
<string name="wow_chat_voice_unavailable">Гласовото въвеждане не е налично на това устройство.</string>
|
||||
<string name="wow_chat_mic_denied">За гласово въвеждане е нужен достъп до микрофона.</string>
|
||||
</resources>
|
||||
@@ -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>
|
||||
@@ -560,6 +549,19 @@
|
||||
<string name="comp_tag_in_use">Používá se</string>
|
||||
<string name="comp_tag_new">Nové</string>
|
||||
<string name="comp_tag_stored">Uloženo</string>
|
||||
<string name="comp_mode_title">Jak se vybírají sestavení</string>
|
||||
<string name="comp_mode_auto_detail">Nejnovější sestavení DroidDeck, stále aktuální</string>
|
||||
<string name="comp_mode_manual_detail">Vyberte a instalujte sestavení sami</string>
|
||||
<string name="comp_auto_title">Auto udržuje %1$s aktuální</string>
|
||||
<string name="comp_auto_hint">Nová sestavení se instalují na pozadí. Pokud se hra, která dřív fungovala, dvakrát zavře během pár sekund, Auto se vrátí k funkčnímu sestavení.</string>
|
||||
<string name="comp_auto_fallback">Záložní: %1$s</string>
|
||||
<string name="comp_auto_checking">Hledání sestavení…</string>
|
||||
<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_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>
|
||||
<string name="perf_title">Výkon</string>
|
||||
<string name="perf_lede">Jádra CPU, opravy relace a název hostitele. Použije se od příští relace.</string>
|
||||
<string name="perf_client_cores_group">Jádra pro klienta Steam</string>
|
||||
@@ -2279,9 +2281,6 @@
|
||||
<string name="source_added">Vlastní</string>
|
||||
<string name="setup_stores">Obchody</string>
|
||||
<string name="setup_stores_speed">Úroveň rychlosti stahování</string>
|
||||
<string name="setup_stores_engine">Jádro obchodů</string>
|
||||
<string name="setup_stores_engine_chip_ready">● Jádro je připraveno</string>
|
||||
<string name="setup_stores_engine_chip_missing">Není sestaveno</string>
|
||||
<string name="stores_tier_balanced">Vyvážené</string>
|
||||
<string name="stores_tier_fast">Rychlé</string>
|
||||
<string name="stores_tier_max">Maximální</string>
|
||||
@@ -3754,4 +3753,12 @@
|
||||
<string name="stores_gamejolt_filter_status">Stav</string>
|
||||
<string name="stores_gamejolt_filter_partners">Partneři</string>
|
||||
<string name="stores_gamejolt_followed">Sledované hry</string>
|
||||
<string name="wow_chat_title">Zadávání do chatu</string>
|
||||
<string name="wow_chat_voice">Hlasové psaní</string>
|
||||
<string name="wow_chat_remember">Zapamatovat tuto volbu</string>
|
||||
<string name="wow_chat_ask">Pokaždé se zeptat</string>
|
||||
<string name="wow_chat_send">Odeslat</string>
|
||||
<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>
|
||||
</resources>
|
||||
@@ -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>
|
||||
@@ -556,6 +545,19 @@
|
||||
<string name="comp_tag_in_use">I brug</string>
|
||||
<string name="comp_tag_new">Ny</string>
|
||||
<string name="comp_tag_stored">Gemt</string>
|
||||
<string name="comp_mode_title">Sådan vælges builds</string>
|
||||
<string name="comp_mode_auto_detail">Nyeste DroidDeck-build, altid opdateret</string>
|
||||
<string name="comp_mode_manual_detail">Vælg og installer builds selv</string>
|
||||
<string name="comp_auto_title">Auto holder %1$s opdateret</string>
|
||||
<string name="comp_auto_hint">Nye builds installeres i baggrunden. Hvis et spil, der virkede før, lukker inden for sekunder to gange, går Auto tilbage til det build, der virkede.</string>
|
||||
<string name="comp_auto_fallback">Reserve: %1$s</string>
|
||||
<string name="comp_auto_checking">Søger efter builds…</string>
|
||||
<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_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>
|
||||
<string name="perf_title">Ydelse</string>
|
||||
<string name="perf_lede">CPU-kerner, sessionsrettelser og værtsnavn. Gælder fra næste session.</string>
|
||||
<string name="perf_client_cores_group">Steam-klientens kerner</string>
|
||||
@@ -2229,9 +2231,6 @@
|
||||
<string name="source_added">Brugerdefineret</string>
|
||||
<string name="setup_stores">Butikker</string>
|
||||
<string name="setup_stores_speed">Downloadhastighed</string>
|
||||
<string name="setup_stores_engine">Butiksmotor</string>
|
||||
<string name="setup_stores_engine_chip_ready">● Motor klar</string>
|
||||
<string name="setup_stores_engine_chip_missing">Ikke bygget</string>
|
||||
<string name="stores_tier_balanced">Afbalanceret</string>
|
||||
<string name="stores_tier_fast">Hurtig</string>
|
||||
<string name="stores_tier_max">Maks</string>
|
||||
@@ -3676,4 +3675,12 @@
|
||||
<string name="stores_gamejolt_filter_status">Status</string>
|
||||
<string name="stores_gamejolt_filter_partners">Partnere</string>
|
||||
<string name="stores_gamejolt_followed">Fulgte spil</string>
|
||||
<string name="wow_chat_title">Chatinput</string>
|
||||
<string name="wow_chat_voice">Stemmeindtastning</string>
|
||||
<string name="wow_chat_remember">Husk dette valg</string>
|
||||
<string name="wow_chat_ask">Spørg hver gang</string>
|
||||
<string name="wow_chat_send">Send</string>
|
||||
<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>
|
||||
</resources>
|
||||
@@ -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>
|
||||
@@ -556,6 +545,19 @@
|
||||
<string name="comp_tag_in_use">In Verwendung</string>
|
||||
<string name="comp_tag_new">Neu</string>
|
||||
<string name="comp_tag_stored">Gespeichert</string>
|
||||
<string name="comp_mode_title">Wie Builds gewählt werden</string>
|
||||
<string name="comp_mode_auto_detail">Neuester DroidDeck-Build, stets aktuell</string>
|
||||
<string name="comp_mode_manual_detail">Builds selbst auswählen und installieren</string>
|
||||
<string name="comp_auto_title">Auto hält %1$s aktuell</string>
|
||||
<string name="comp_auto_hint">Neue Builds werden im Hintergrund installiert. Schließt sich ein Spiel, das vorher lief, zweimal nach wenigen Sekunden, kehrt Auto zum funktionierenden Build zurück.</string>
|
||||
<string name="comp_auto_fallback">Ausweich-Build: %1$s</string>
|
||||
<string name="comp_auto_checking">Suche nach Builds…</string>
|
||||
<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_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>
|
||||
<string name="perf_title">Leistung</string>
|
||||
<string name="perf_lede">CPU-Kerne, Sitzungskorrekturen und der Hostname. Gilt ab der nächsten Sitzung.</string>
|
||||
<string name="perf_client_cores_group">Steam-Client-Kerne</string>
|
||||
@@ -2221,9 +2223,6 @@
|
||||
<string name="source_added">Eigene</string>
|
||||
<string name="setup_stores">Stores</string>
|
||||
<string name="setup_stores_speed">Download-Tempo</string>
|
||||
<string name="setup_stores_engine">Store-Engine</string>
|
||||
<string name="setup_stores_engine_chip_ready">● Engine bereit</string>
|
||||
<string name="setup_stores_engine_chip_missing">Nicht gebaut</string>
|
||||
<string name="stores_tier_balanced">Ausgewogen</string>
|
||||
<string name="stores_tier_fast">Schnell</string>
|
||||
<string name="stores_tier_max">Max.</string>
|
||||
@@ -3676,4 +3675,12 @@
|
||||
<string name="stores_gamejolt_filter_status">Status</string>
|
||||
<string name="stores_gamejolt_filter_partners">Partner</string>
|
||||
<string name="stores_gamejolt_followed">Gefolgte Spiele</string>
|
||||
<string name="wow_chat_title">Chat-Eingabe</string>
|
||||
<string name="wow_chat_voice">Spracheingabe</string>
|
||||
<string name="wow_chat_remember">Diese Auswahl merken</string>
|
||||
<string name="wow_chat_ask">Jedes Mal fragen</string>
|
||||
<string name="wow_chat_send">Senden</string>
|
||||
<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>
|
||||
</resources>
|
||||
@@ -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>
|
||||
@@ -556,6 +545,19 @@
|
||||
<string name="comp_tag_in_use">Σε χρήση</string>
|
||||
<string name="comp_tag_new">Νέο</string>
|
||||
<string name="comp_tag_stored">Αποθηκευμένο</string>
|
||||
<string name="comp_mode_title">Πώς επιλέγονται οι εκδόσεις</string>
|
||||
<string name="comp_mode_auto_detail">Νεότερη έκδοση DroidDeck, πάντα ενημερωμένη</string>
|
||||
<string name="comp_mode_manual_detail">Επιλέξτε και εγκαταστήστε εκδόσεις μόνοι σας</string>
|
||||
<string name="comp_auto_title">Το Auto κρατά το %1$s ενημερωμένο</string>
|
||||
<string name="comp_auto_hint">Οι νέες εκδόσεις εγκαθίστανται στο παρασκήνιο. Αν ένα παιχνίδι που λειτουργούσε κλείσει μέσα σε δευτερόλεπτα δύο φορές, το Auto επιστρέφει στην έκδοση που λειτουργούσε.</string>
|
||||
<string name="comp_auto_fallback">Εφεδρική: %1$s</string>
|
||||
<string name="comp_auto_checking">Αναζήτηση εκδόσεων…</string>
|
||||
<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_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>
|
||||
<string name="perf_title">Απόδοση</string>
|
||||
<string name="perf_lede">Πυρήνες CPU, διορθώσεις συνεδρίας και το όνομα κεντρικού υπολογιστή. Εφαρμόζεται στην επόμενη συνεδρία.</string>
|
||||
<string name="perf_client_cores_group">Πυρήνες πελάτη Steam</string>
|
||||
@@ -2229,9 +2231,6 @@
|
||||
<string name="source_added">Προσαρμοσμένο</string>
|
||||
<string name="setup_stores">Καταστήματα</string>
|
||||
<string name="setup_stores_speed">Επίπεδο ταχύτητας λήψης</string>
|
||||
<string name="setup_stores_engine">Μηχανή καταστημάτων</string>
|
||||
<string name="setup_stores_engine_chip_ready">● Η μηχανή είναι έτοιμη</string>
|
||||
<string name="setup_stores_engine_chip_missing">Δεν έχει χτιστεί</string>
|
||||
<string name="stores_tier_balanced">Ισορροπημένο</string>
|
||||
<string name="stores_tier_fast">Ταχύ</string>
|
||||
<string name="stores_tier_max">Μέγιστο</string>
|
||||
@@ -3676,4 +3675,12 @@
|
||||
<string name="stores_gamejolt_filter_status">Κατάσταση</string>
|
||||
<string name="stores_gamejolt_filter_partners">Συνεργάτες</string>
|
||||
<string name="stores_gamejolt_followed">Παιχνίδια που ακολουθείτε</string>
|
||||
<string name="wow_chat_title">Εισαγωγή συνομιλίας</string>
|
||||
<string name="wow_chat_voice">Φωνητική πληκτρολόγηση</string>
|
||||
<string name="wow_chat_remember">Απομνημόνευση αυτής της επιλογής</string>
|
||||
<string name="wow_chat_ask">Ερώτηση κάθε φορά</string>
|
||||
<string name="wow_chat_send">Αποστολή</string>
|
||||
<string name="wow_chat_listening">Ακρόαση…</string>
|
||||
<string name="wow_chat_voice_unavailable">Η φωνητική πληκτρολόγηση δεν είναι διαθέσιμη σε αυτή τη συσκευή.</string>
|
||||
<string name="wow_chat_mic_denied">Απαιτείται πρόσβαση στο μικρόφωνο για φωνητική πληκτρολόγηση.</string>
|
||||
</resources>
|
||||
@@ -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>
|
||||
@@ -558,6 +547,19 @@
|
||||
<string name="comp_tag_in_use">En uso</string>
|
||||
<string name="comp_tag_new">Nuevo</string>
|
||||
<string name="comp_tag_stored">Guardado</string>
|
||||
<string name="comp_mode_title">Cómo se eligen las versiones</string>
|
||||
<string name="comp_mode_auto_detail">Versión más reciente de DroidDeck, siempre al día</string>
|
||||
<string name="comp_mode_manual_detail">Elige e instala las versiones tú mismo</string>
|
||||
<string name="comp_auto_title">Auto mantiene %1$s al día</string>
|
||||
<string name="comp_auto_hint">Las nuevas versiones se instalan en segundo plano. Si un juego que funcionaba se cierra en segundos dos veces, Auto vuelve a la versión que funcionaba.</string>
|
||||
<string name="comp_auto_fallback">Respaldo: %1$s</string>
|
||||
<string name="comp_auto_checking">Buscando versiones…</string>
|
||||
<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_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>
|
||||
<string name="perf_title">Rendimiento</string>
|
||||
<string name="perf_lede">Núcleos de CPU, correcciones de sesión y nombre del host. Se aplica en la próxima sesión.</string>
|
||||
<string name="perf_client_cores_group">Núcleos del cliente de Steam</string>
|
||||
@@ -2254,9 +2256,6 @@
|
||||
<string name="source_added">Personalizados</string>
|
||||
<string name="setup_stores">Tiendas</string>
|
||||
<string name="setup_stores_speed">Nivel de velocidad de descarga</string>
|
||||
<string name="setup_stores_engine">Motor de las tiendas</string>
|
||||
<string name="setup_stores_engine_chip_ready">● Motor listo</string>
|
||||
<string name="setup_stores_engine_chip_missing">Sin compilar</string>
|
||||
<string name="stores_tier_balanced">Equilibrado</string>
|
||||
<string name="stores_tier_fast">Rápido</string>
|
||||
<string name="stores_tier_max">Máximo</string>
|
||||
@@ -3715,4 +3714,12 @@
|
||||
<string name="stores_gamejolt_filter_status">Estado</string>
|
||||
<string name="stores_gamejolt_filter_partners">Socios</string>
|
||||
<string name="stores_gamejolt_followed">Juegos seguidos</string>
|
||||
<string name="wow_chat_title">Entrada del chat</string>
|
||||
<string name="wow_chat_voice">Dictado por voz</string>
|
||||
<string name="wow_chat_remember">Recordar esta selección</string>
|
||||
<string name="wow_chat_ask">Preguntar cada vez</string>
|
||||
<string name="wow_chat_send">Enviar</string>
|
||||
<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>
|
||||
</resources>
|
||||
@@ -8,17 +8,6 @@
|
||||
<string name="steamvr_recovery_restarting">Nollataan grafiikkaa ja avataan SteamVR uudelleen…</string>
|
||||
<string name="steamvr_recovery_failed">DroidDeck ei pystynyt käynnistymään uudelleen automaattisesti. Sulje sovellus kokonaan ja avaa se sitten uudelleen.</string>
|
||||
<string name="app_name">DroidDeck</string>
|
||||
<string-array name="loading_hints">
|
||||
<item>Ensimmäinen käynnistys lataa Steamin. Tämä voi kestää muutaman minuutin.</item>
|
||||
<item>Oletuksena sovelluksesta poistuminen keskeyttää Steamin; se jatkuu siitä mihin jäit, kun palaat takaisin.</item>
|
||||
<item>Ohjaimesi näkyy Xbox 360 -ohjaimena. Muussa tapauksessa käytä näytön ohjaimia.</item>
|
||||
</string-array>
|
||||
<string-array name="loading_hints_desktop">
|
||||
<item>Ensimmäinen käynnistys määrittää työpöydän. Tämä voi kestää muutaman minuutin.</item>
|
||||
<item>Oletuksena sovelluksesta poistuminen keskeyttää istunnon; se jatkuu siitä mihin jäit, kun palaat takaisin.</item>
|
||||
<item>Kosketus toimii kosketuslevynä: vedä liikuttaaksesi osoitinta, napauta napsauttaaksesi.</item>
|
||||
<item>Ohjaimesi näkyy Xbox 360 -ohjaimena. Muussa tapauksessa käytä näytön ohjaimia.</item>
|
||||
</string-array>
|
||||
<string name="session_channel">Istunto</string>
|
||||
<string name="session_channel_description">Näkyy, kun Steam- tai työpöytäistunto on käynnissä</string>
|
||||
<string name="session_notification">Steam on käynnissä</string>
|
||||
@@ -556,6 +545,19 @@
|
||||
<string name="comp_tag_in_use">Käytössä</string>
|
||||
<string name="comp_tag_new">Uusi</string>
|
||||
<string name="comp_tag_stored">Tallennettu</string>
|
||||
<string name="comp_mode_title">Miten versiot valitaan</string>
|
||||
<string name="comp_mode_auto_detail">Uusin DroidDeck-versio, aina ajan tasalla</string>
|
||||
<string name="comp_mode_manual_detail">Valitse ja asenna versiot itse</string>
|
||||
<string name="comp_auto_title">Auto pitää %1$s ajan tasalla</string>
|
||||
<string name="comp_auto_hint">Uudet versiot asentuvat taustalla. Jos aiemmin toiminut peli sulkeutuu sekunneissa kahdesti, Auto palaa toimineeseen versioon.</string>
|
||||
<string name="comp_auto_fallback">Varaversio: %1$s</string>
|
||||
<string name="comp_auto_checking">Haetaan versioita…</string>
|
||||
<string name="comp_auto_none">Mikään DroidDeck-Proton ei käytä tätä</string>
|
||||
<string name="comp_auto_waits_network">Auto-päivitykset odottavat yhteyttä.</string>
|
||||
<string name="comp_auto_check_failed">Uusia versioita ei voitu tarkistaa.</string>
|
||||
<string name="comp_auto_waits_unmetered">Uudet versiot odottavat rajoittamatonta yhteyttä, virransäästön poistoa ja viileää laitetta.</string>
|
||||
<string name="comp_auto_updated">%1$s päivitetty versioon %2$s.</string>
|
||||
<string name="comp_auto_rolled_back">%1$s %2$s sulki pelin sekunneissa kahdesti, joten Auto palasi versioon %3$s.</string>
|
||||
<string name="perf_title">Suorituskyky</string>
|
||||
<string name="perf_lede">CPU-ytimet, istunnon korjaukset ja isäntänimi. Tulee voimaan seuraavassa istunnossa.</string>
|
||||
<string name="perf_client_cores_group">Steam-asiakasohjelman ytimet</string>
|
||||
@@ -2229,9 +2231,6 @@
|
||||
<string name="source_added">Mukautettu</string>
|
||||
<string name="setup_stores">Kaupat</string>
|
||||
<string name="setup_stores_speed">Latausnopeuden taso</string>
|
||||
<string name="setup_stores_engine">Kauppojen moottori</string>
|
||||
<string name="setup_stores_engine_chip_ready">● Moottori valmis</string>
|
||||
<string name="setup_stores_engine_chip_missing">Ei koottu</string>
|
||||
<string name="stores_tier_balanced">Tasapainotettu</string>
|
||||
<string name="stores_tier_fast">Nopea</string>
|
||||
<string name="stores_tier_max">Maksimi</string>
|
||||
@@ -3676,4 +3675,12 @@
|
||||
<string name="stores_gamejolt_filter_status">Tila</string>
|
||||
<string name="stores_gamejolt_filter_partners">Kumppanit</string>
|
||||
<string name="stores_gamejolt_followed">Seuratut pelit</string>
|
||||
<string name="wow_chat_title">Chatin syöttö</string>
|
||||
<string name="wow_chat_voice">Puhekirjoitus</string>
|
||||
<string name="wow_chat_remember">Muista tämä valinta</string>
|
||||
<string name="wow_chat_ask">Kysy joka kerta</string>
|
||||
<string name="wow_chat_send">Lähetä</string>
|
||||
<string name="wow_chat_listening">Kuunnellaan…</string>
|
||||
<string name="wow_chat_voice_unavailable">Puhekirjoitus ei ole käytettävissä tällä laitteella.</string>
|
||||
<string name="wow_chat_mic_denied">Puhekirjoitus vaatii mikrofonin käyttöoikeuden.</string>
|
||||
</resources>
|
||||
@@ -8,17 +8,6 @@
|
||||
<string name="steamvr_recovery_restarting">Réinitialisation du rendu graphique et réouverture de SteamVR…</string>
|
||||
<string name="steamvr_recovery_failed">DroidDeck n\'a pas pu redémarrer automatiquement. Fermez complètement l\'application, puis rouvrez-la.</string>
|
||||
<string name="app_name">DroidDeck</string>
|
||||
<string-array name="loading_hints">
|
||||
<item>Le premier lancement télécharge Steam. Cela peut prendre quelques minutes.</item>
|
||||
<item>Par défaut, quitter l\'application met Steam en pause ; y revenir reprend là où vous vous étiez arrêté.</item>
|
||||
<item>Votre manette apparaîtra comme une manette Xbox 360. Sinon, utilisez les commandes à l\'écran.</item>
|
||||
</string-array>
|
||||
<string-array name="loading_hints_desktop">
|
||||
<item>Le premier lancement configure le bureau. Cela peut prendre quelques minutes.</item>
|
||||
<item>Par défaut, quitter l\'application met la session en pause ; y revenir reprend là où vous vous étiez arrêté.</item>
|
||||
<item>Le tactile fonctionne comme un pavé tactile : faites glisser pour déplacer le pointeur, tapotez pour cliquer.</item>
|
||||
<item>Votre manette apparaîtra comme une manette Xbox 360. Sinon, utilisez les commandes à l\'écran.</item>
|
||||
</string-array>
|
||||
<string name="session_channel">Session</string>
|
||||
<string name="session_channel_description">Affiché lorsqu\'une session Steam ou Bureau est ouverte</string>
|
||||
<string name="session_notification">Steam est en cours d\'exécution</string>
|
||||
@@ -558,6 +547,19 @@
|
||||
<string name="comp_tag_in_use">Utilisé</string>
|
||||
<string name="comp_tag_new">Nouveau</string>
|
||||
<string name="comp_tag_stored">Enregistré</string>
|
||||
<string name="comp_mode_title">Choix des versions</string>
|
||||
<string name="comp_mode_auto_detail">Dernière version DroidDeck, toujours à jour</string>
|
||||
<string name="comp_mode_manual_detail">Choisir et installer les versions vous-même</string>
|
||||
<string name="comp_auto_title">Auto garde %1$s à jour</string>
|
||||
<string name="comp_auto_hint">Les nouvelles versions s\'installent en arrière-plan. Si un jeu qui fonctionnait se ferme deux fois en quelques secondes, Auto revient à la version qui fonctionnait.</string>
|
||||
<string name="comp_auto_fallback">Secours : %1$s</string>
|
||||
<string name="comp_auto_checking">Recherche de versions…</string>
|
||||
<string name="comp_auto_none">Aucun DroidDeck-Proton ne l\'utilise</string>
|
||||
<string name="comp_auto_waits_network">Les mises à jour Auto attendent une connexion.</string>
|
||||
<string name="comp_auto_check_failed">Impossible de vérifier les nouvelles versions.</string>
|
||||
<string name="comp_auto_waits_unmetered">Les nouvelles versions attendent une connexion non limitée, l\'économiseur de batterie désactivé et un appareil frais.</string>
|
||||
<string name="comp_auto_updated">%1$s mis à jour vers %2$s.</string>
|
||||
<string name="comp_auto_rolled_back">%1$s %2$s a fermé un jeu en quelques secondes deux fois, Auto est donc revenu à %3$s.</string>
|
||||
<string name="perf_title">Performance</string>
|
||||
<string name="perf_lede">Cœurs CPU, correctifs de session et nom d\'hôte. Prendra effet à la prochaine session.</string>
|
||||
<string name="perf_client_cores_group">Cœurs du client Steam</string>
|
||||
@@ -2254,9 +2256,6 @@
|
||||
<string name="source_added">Personnalisé</string>
|
||||
<string name="setup_stores">Boutiques</string>
|
||||
<string name="setup_stores_speed">Niveau de vitesse de téléchargement</string>
|
||||
<string name="setup_stores_engine">Moteur des boutiques</string>
|
||||
<string name="setup_stores_engine_chip_ready">● Moteur prêt</string>
|
||||
<string name="setup_stores_engine_chip_missing">Non compilé</string>
|
||||
<string name="stores_tier_balanced">Équilibré</string>
|
||||
<string name="stores_tier_fast">Rapide</string>
|
||||
<string name="stores_tier_max">Max</string>
|
||||
@@ -3715,4 +3714,12 @@
|
||||
<string name="stores_gamejolt_filter_status">Statut</string>
|
||||
<string name="stores_gamejolt_filter_partners">Partenaires</string>
|
||||
<string name="stores_gamejolt_followed">Jeux suivis</string>
|
||||
<string name="wow_chat_title">Saisie du chat</string>
|
||||
<string name="wow_chat_voice">Saisie vocale</string>
|
||||
<string name="wow_chat_remember">Mémoriser ce choix</string>
|
||||
<string name="wow_chat_ask">Demander à chaque fois</string>
|
||||
<string name="wow_chat_send">Envoyer</string>
|
||||
<string name="wow_chat_listening">Écoute…</string>
|
||||
<string name="wow_chat_voice_unavailable">La saisie vocale n\'est pas disponible sur cet appareil.</string>
|
||||
<string name="wow_chat_mic_denied">L\'accès au micro est nécessaire pour la saisie vocale.</string>
|
||||
</resources>
|
||||
@@ -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>
|
||||
@@ -556,6 +545,19 @@
|
||||
<string name="comp_tag_in_use">उपयोग में</string>
|
||||
<string name="comp_tag_new">नया</string>
|
||||
<string name="comp_tag_stored">सेव है</string>
|
||||
<string name="comp_mode_title">बिल्ड कैसे चुने जाते हैं</string>
|
||||
<string name="comp_mode_auto_detail">सबसे नया DroidDeck बिल्ड, हमेशा अपडेट</string>
|
||||
<string name="comp_mode_manual_detail">बिल्ड खुद चुनें और इंस्टॉल करें</string>
|
||||
<string name="comp_auto_title">Auto %1$s को अपडेट रखता है</string>
|
||||
<string name="comp_auto_hint">नए बिल्ड बैकग्राउंड में इंस्टॉल होते हैं। अगर पहले चलने वाला गेम दो बार कुछ सेकंड में बंद हो जाए, तो Auto काम करने वाले बिल्ड पर लौट जाता है।</string>
|
||||
<string name="comp_auto_fallback">फ़ॉलबैक: %1$s</string>
|
||||
<string name="comp_auto_checking">बिल्ड खोजे जा रहे हैं…</string>
|
||||
<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_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>
|
||||
<string name="perf_title">परफ़ॉर्मेंस</string>
|
||||
<string name="perf_lede">CPU कोर, सेशन फ़िक्स और होस्ट नाम। अगले सेशन से लागू होता है।</string>
|
||||
<string name="perf_client_cores_group">Steam क्लाइंट कोर</string>
|
||||
@@ -2229,9 +2231,6 @@
|
||||
<string name="source_added">कस्टम</string>
|
||||
<string name="setup_stores">स्टोर</string>
|
||||
<string name="setup_stores_speed">डाउनलोड स्पीड टियर</string>
|
||||
<string name="setup_stores_engine">स्टोर इंजन</string>
|
||||
<string name="setup_stores_engine_chip_ready">● इंजन तैयार</string>
|
||||
<string name="setup_stores_engine_chip_missing">नहीं बना</string>
|
||||
<string name="stores_tier_balanced">बैलेंस्ड</string>
|
||||
<string name="stores_tier_fast">फ़ास्ट</string>
|
||||
<string name="stores_tier_max">मैक्स</string>
|
||||
@@ -3676,4 +3675,12 @@
|
||||
<string name="stores_gamejolt_filter_status">स्थिति</string>
|
||||
<string name="stores_gamejolt_filter_partners">पार्टनर</string>
|
||||
<string name="stores_gamejolt_followed">फ़ॉलो किए गए गेम</string>
|
||||
<string name="wow_chat_title">चैट इनपुट</string>
|
||||
<string name="wow_chat_voice">आवाज़ से टाइप करें</string>
|
||||
<string name="wow_chat_remember">यह चुनाव याद रखें</string>
|
||||
<string name="wow_chat_ask">हर बार पूछें</string>
|
||||
<string name="wow_chat_send">भेजें</string>
|
||||
<string name="wow_chat_listening">सुन रहा है…</string>
|
||||
<string name="wow_chat_voice_unavailable">इस डिवाइस पर आवाज़ से टाइपिंग उपलब्ध नहीं है।</string>
|
||||
<string name="wow_chat_mic_denied">आवाज़ से टाइपिंग के लिए माइक्रोफ़ोन की अनुमति चाहिए।</string>
|
||||
</resources>
|
||||
@@ -8,17 +8,6 @@
|
||||
<string name="steamvr_recovery_restarting">A grafika visszaállítása és a SteamVR újranyitása…</string>
|
||||
<string name="steamvr_recovery_failed">A DroidDeck nem tudott automatikusan újraindulni. Zárja be teljesen az alkalmazást, majd nyissa meg újra.</string>
|
||||
<string name="app_name">DroidDeck</string>
|
||||
<string-array name="loading_hints">
|
||||
<item>Az első indítás letölti a Steamet. Ez néhány percet vehet igénybe.</item>
|
||||
<item>Alapértelmezés szerint az alkalmazás elhagyása szünetelteti a Steamet; visszatéréskor ott folytatja, ahol abbahagyta.</item>
|
||||
<item>A kontroller Xbox 360 kontrollerként jelenik meg. Egyébként használja a képernyőn lévő vezérlőket.</item>
|
||||
</string-array>
|
||||
<string-array name="loading_hints_desktop">
|
||||
<item>Az első indítás beállítja az asztalt. Ez néhány percet vehet igénybe.</item>
|
||||
<item>Alapértelmezés szerint az alkalmazás elhagyása szünetelteti a munkamenetet; visszatéréskor ott folytatja, ahol abbahagyta.</item>
|
||||
<item>Az érintés érintőpadként működik: húzással mozgathatja a kurzort, koppintással kattinthat.</item>
|
||||
<item>A kontroller Xbox 360 kontrollerként jelenik meg. Egyébként használja a képernyőn lévő vezérlőket.</item>
|
||||
</string-array>
|
||||
<string name="session_channel">Munkamenet</string>
|
||||
<string name="session_channel_description">Megjelenik, amíg egy Steam- vagy asztali munkamenet aktív</string>
|
||||
<string name="session_notification">A Steam fut</string>
|
||||
@@ -556,6 +545,19 @@
|
||||
<string name="comp_tag_in_use">Használatban</string>
|
||||
<string name="comp_tag_new">Új</string>
|
||||
<string name="comp_tag_stored">Tárolva</string>
|
||||
<string name="comp_mode_title">Hogyan választódnak a buildek</string>
|
||||
<string name="comp_mode_auto_detail">Legújabb DroidDeck build, mindig naprakész</string>
|
||||
<string name="comp_mode_manual_detail">Buildek kiválasztása és telepítése saját kézzel</string>
|
||||
<string name="comp_auto_title">Az Auto naprakészen tartja: %1$s</string>
|
||||
<string name="comp_auto_hint">Az új buildek a háttérben települnek. Ha egy korábban működő játék kétszer is másodperceken belül bezárul, az Auto visszaáll a működő buildre.</string>
|
||||
<string name="comp_auto_fallback">Tartalék: %1$s</string>
|
||||
<string name="comp_auto_checking">Buildek keresése…</string>
|
||||
<string name="comp_auto_none">Egyik DroidDeck-Proton sem használja</string>
|
||||
<string name="comp_auto_waits_network">Az Auto frissítések kapcsolatra várnak.</string>
|
||||
<string name="comp_auto_check_failed">Nem sikerült új buildeket keresni.</string>
|
||||
<string name="comp_auto_waits_unmetered">Az új buildek nem forgalomkorlátos kapcsolatra, kikapcsolt energiatakarékos módra és hűvös eszközre várnak.</string>
|
||||
<string name="comp_auto_updated">%1$s frissítve: %2$s.</string>
|
||||
<string name="comp_auto_rolled_back">%1$s %2$s kétszer is másodperceken belül bezárt egy játékot, ezért az Auto visszaállt erre: %3$s.</string>
|
||||
<string name="perf_title">Teljesítmény</string>
|
||||
<string name="perf_lede">CPU-magok, munkamenet-javítások és a hosztnév. A következő munkamenettől érvényes.</string>
|
||||
<string name="perf_client_cores_group">Steam-kliens magok</string>
|
||||
@@ -2229,9 +2231,6 @@
|
||||
<string name="source_added">Egyéni</string>
|
||||
<string name="setup_stores">Áruházak</string>
|
||||
<string name="setup_stores_speed">Letöltési sebességi szint</string>
|
||||
<string name="setup_stores_engine">Áruházmotor</string>
|
||||
<string name="setup_stores_engine_chip_ready">● A motor kész</string>
|
||||
<string name="setup_stores_engine_chip_missing">Nincs buildelve</string>
|
||||
<string name="stores_tier_balanced">Kiegyensúlyozott</string>
|
||||
<string name="stores_tier_fast">Gyors</string>
|
||||
<string name="stores_tier_max">Max</string>
|
||||
@@ -3676,4 +3675,12 @@
|
||||
<string name="stores_gamejolt_filter_status">Állapot</string>
|
||||
<string name="stores_gamejolt_filter_partners">Partnerek</string>
|
||||
<string name="stores_gamejolt_followed">Követett játékok</string>
|
||||
<string name="wow_chat_title">Csevegés bevitele</string>
|
||||
<string name="wow_chat_voice">Hangalapú gépelés</string>
|
||||
<string name="wow_chat_remember">Választás megjegyzése</string>
|
||||
<string name="wow_chat_ask">Kérdezzen minden alkalommal</string>
|
||||
<string name="wow_chat_send">Küldés</string>
|
||||
<string name="wow_chat_listening">Figyelés…</string>
|
||||
<string name="wow_chat_voice_unavailable">A hangalapú gépelés nem érhető el ezen az eszközön.</string>
|
||||
<string name="wow_chat_mic_denied">A hangalapú gépeléshez mikrofon-hozzáférés szükséges.</string>
|
||||
</resources>
|
||||
@@ -8,17 +8,6 @@
|
||||
<string name="steamvr_recovery_restarting">Mereset grafis dan membuka kembali SteamVR…</string>
|
||||
<string name="steamvr_recovery_failed">DroidDeck tidak bisa dimulai ulang secara otomatis. Tutup aplikasi sepenuhnya, lalu buka lagi.</string>
|
||||
<string name="app_name">DroidDeck</string>
|
||||
<string-array name="loading_hints">
|
||||
<item>Proses pertama mengunduh Steam. Ini mungkin memerlukan beberapa menit.</item>
|
||||
<item>Secara default, keluar dari aplikasi akan menjeda Steam; sesi dilanjutkan dari posisi terakhir saat Anda kembali.</item>
|
||||
<item>Pengontrol Anda akan terlihat sebagai gamepad Xbox 360. Jika tidak, gunakan kontrol di layar.</item>
|
||||
</string-array>
|
||||
<string-array name="loading_hints_desktop">
|
||||
<item>Proses pertama menyiapkan desktop. Ini mungkin memerlukan beberapa menit.</item>
|
||||
<item>Secara default, keluar dari aplikasi akan menjeda sesi; sesi dilanjutkan dari posisi terakhir saat Anda kembali.</item>
|
||||
<item>Sentuhan berfungsi seperti touchpad: geser untuk menggerakkan penunjuk, ketuk untuk mengeklik.</item>
|
||||
<item>Pengontrol Anda akan terlihat sebagai gamepad Xbox 360. Jika tidak, gunakan kontrol di layar.</item>
|
||||
</string-array>
|
||||
<string name="session_channel">Sesi</string>
|
||||
<string name="session_channel_description">Ditampilkan saat sesi Steam atau desktop berjalan</string>
|
||||
<string name="session_notification">Steam berjalan</string>
|
||||
@@ -554,6 +543,19 @@
|
||||
<string name="comp_tag_in_use">Digunakan</string>
|
||||
<string name="comp_tag_new">Baru</string>
|
||||
<string name="comp_tag_stored">Tersimpan</string>
|
||||
<string name="comp_mode_title">Cara build dipilih</string>
|
||||
<string name="comp_mode_auto_detail">Build DroidDeck terbaru, selalu mutakhir</string>
|
||||
<string name="comp_mode_manual_detail">Pilih dan pasang build sendiri</string>
|
||||
<string name="comp_auto_title">Auto menjaga %1$s tetap mutakhir</string>
|
||||
<string name="comp_auto_hint">Build baru dipasang di latar belakang. Jika game yang sebelumnya berjalan tertutup dalam hitungan detik dua kali, Auto kembali ke build yang berfungsi.</string>
|
||||
<string name="comp_auto_fallback">Cadangan: %1$s</string>
|
||||
<string name="comp_auto_checking">Mencari build…</string>
|
||||
<string name="comp_auto_none">Tidak ada DroidDeck-Proton yang memakainya</string>
|
||||
<string name="comp_auto_waits_network">Pembaruan Auto menunggu koneksi.</string>
|
||||
<string name="comp_auto_check_failed">Tidak dapat memeriksa build baru.</string>
|
||||
<string name="comp_auto_waits_unmetered">Build baru menunggu koneksi tanpa kuota, penghemat baterai mati, dan perangkat yang dingin.</string>
|
||||
<string name="comp_auto_updated">%1$s diperbarui ke %2$s.</string>
|
||||
<string name="comp_auto_rolled_back">%1$s %2$s menutup game dalam hitungan detik dua kali, jadi Auto kembali ke %3$s.</string>
|
||||
<string name="perf_title">Performa</string>
|
||||
<string name="perf_lede">Core CPU, perbaikan sesi, dan nama host. Berlaku pada sesi berikutnya.</string>
|
||||
<string name="perf_client_cores_group">Core klien Steam</string>
|
||||
@@ -2204,9 +2206,6 @@
|
||||
<string name="source_added">Khusus</string>
|
||||
<string name="setup_stores">Toko</string>
|
||||
<string name="setup_stores_speed">Tingkat kecepatan unduhan</string>
|
||||
<string name="setup_stores_engine">Mesin toko</string>
|
||||
<string name="setup_stores_engine_chip_ready">● Mesin siap</string>
|
||||
<string name="setup_stores_engine_chip_missing">Belum dibuat</string>
|
||||
<string name="stores_tier_balanced">Seimbang</string>
|
||||
<string name="stores_tier_fast">Cepat</string>
|
||||
<string name="stores_tier_max">Maks</string>
|
||||
@@ -3646,4 +3645,12 @@
|
||||
<string name="stores_gamejolt_filter_status">Status</string>
|
||||
<string name="stores_gamejolt_filter_partners">Mitra</string>
|
||||
<string name="stores_gamejolt_followed">Game yang diikuti</string>
|
||||
<string name="wow_chat_title">Input obrolan</string>
|
||||
<string name="wow_chat_voice">Ketik dengan suara</string>
|
||||
<string name="wow_chat_remember">Ingat pilihan ini</string>
|
||||
<string name="wow_chat_ask">Tanya setiap kali</string>
|
||||
<string name="wow_chat_send">Kirim</string>
|
||||
<string name="wow_chat_listening">Mendengarkan…</string>
|
||||
<string name="wow_chat_voice_unavailable">Ketik dengan suara tidak tersedia di perangkat ini.</string>
|
||||
<string name="wow_chat_mic_denied">Akses mikrofon diperlukan untuk mengetik dengan suara.</string>
|
||||
</resources>
|
||||
@@ -8,17 +8,6 @@
|
||||
<string name="steamvr_recovery_restarting">Ripristino della grafica e riapertura di SteamVR…</string>
|
||||
<string name="steamvr_recovery_failed">DroidDeck non è riuscito a riavviarsi automaticamente. Chiudi completamente l\'app, poi riaprila.</string>
|
||||
<string name="app_name">DroidDeck</string>
|
||||
<string-array name="loading_hints">
|
||||
<item>Al primo avvio viene scaricato Steam. Potrebbero volerci alcuni minuti.</item>
|
||||
<item>Per impostazione predefinita, uscire dall\'app mette in pausa Steam; al tuo ritorno riprende da dove si era interrotto.</item>
|
||||
<item>Il tuo controller viene visto come un pad Xbox 360. In alternativa, usa i controlli su schermo.</item>
|
||||
</string-array>
|
||||
<string-array name="loading_hints_desktop">
|
||||
<item>Al primo avvio viene configurato il desktop. Potrebbero volerci alcuni minuti.</item>
|
||||
<item>Per impostazione predefinita, uscire dall\'app mette in pausa la sessione; al tuo ritorno riprende da dove si era interrotta.</item>
|
||||
<item>Il touchscreen funziona come un touchpad: trascina per spostare il puntatore, tocca per fare clic.</item>
|
||||
<item>Il tuo controller viene visto come un pad Xbox 360. In alternativa, usa i controlli su schermo.</item>
|
||||
</string-array>
|
||||
<string name="session_channel">Sessione</string>
|
||||
<string name="session_channel_description">Visibile mentre è aperta una sessione Steam o desktop</string>
|
||||
<string name="session_notification">Steam è in esecuzione</string>
|
||||
@@ -558,6 +547,19 @@
|
||||
<string name="comp_tag_in_use">In uso</string>
|
||||
<string name="comp_tag_new">Nuovo</string>
|
||||
<string name="comp_tag_stored">Salvato</string>
|
||||
<string name="comp_mode_title">Come vengono scelte le build</string>
|
||||
<string name="comp_mode_auto_detail">Build DroidDeck più recente, sempre aggiornata</string>
|
||||
<string name="comp_mode_manual_detail">Scegli e installa tu le build</string>
|
||||
<string name="comp_auto_title">Auto mantiene aggiornato %1$s</string>
|
||||
<string name="comp_auto_hint">Le nuove build si installano in background. Se un gioco che funzionava si chiude in pochi secondi per due volte, Auto torna alla build che funzionava.</string>
|
||||
<string name="comp_auto_fallback">Riserva: %1$s</string>
|
||||
<string name="comp_auto_checking">Ricerca build…</string>
|
||||
<string name="comp_auto_none">Nessun DroidDeck-Proton lo usa</string>
|
||||
<string name="comp_auto_waits_network">Gli aggiornamenti Auto attendono una connessione.</string>
|
||||
<string name="comp_auto_check_failed">Impossibile cercare nuove build.</string>
|
||||
<string name="comp_auto_waits_unmetered">Le nuove build attendono una connessione non a consumo, il risparmio batteria disattivato e un dispositivo fresco.</string>
|
||||
<string name="comp_auto_updated">%1$s aggiornato a %2$s.</string>
|
||||
<string name="comp_auto_rolled_back">%1$s %2$s ha chiuso un gioco in pochi secondi per due volte, quindi Auto è tornato a %3$s.</string>
|
||||
<string name="perf_title">Prestazioni</string>
|
||||
<string name="perf_lede">Core della CPU, correzioni della sessione e nome host. Si applica dalla prossima sessione.</string>
|
||||
<string name="perf_client_cores_group">Core del client Steam</string>
|
||||
@@ -2246,9 +2248,6 @@
|
||||
<string name="source_added">Personalizzati</string>
|
||||
<string name="setup_stores">Negozi</string>
|
||||
<string name="setup_stores_speed">Livello di velocità di download</string>
|
||||
<string name="setup_stores_engine">Motore dei negozi</string>
|
||||
<string name="setup_stores_engine_chip_ready">● Motore pronto</string>
|
||||
<string name="setup_stores_engine_chip_missing">Non compilato</string>
|
||||
<string name="stores_tier_balanced">Bilanciato</string>
|
||||
<string name="stores_tier_fast">Veloce</string>
|
||||
<string name="stores_tier_max">Massimo</string>
|
||||
@@ -3715,4 +3714,12 @@
|
||||
<string name="stores_gamejolt_filter_status">Stato</string>
|
||||
<string name="stores_gamejolt_filter_partners">Partner</string>
|
||||
<string name="stores_gamejolt_followed">Giochi seguiti</string>
|
||||
<string name="wow_chat_title">Input della chat</string>
|
||||
<string name="wow_chat_voice">Digitazione vocale</string>
|
||||
<string name="wow_chat_remember">Ricorda questa scelta</string>
|
||||
<string name="wow_chat_ask">Chiedi ogni volta</string>
|
||||
<string name="wow_chat_send">Invia</string>
|
||||
<string name="wow_chat_listening">In ascolto…</string>
|
||||
<string name="wow_chat_voice_unavailable">La digitazione vocale non è disponibile su questo dispositivo.</string>
|
||||
<string name="wow_chat_mic_denied">Per la digitazione vocale serve l\'accesso al microfono.</string>
|
||||
</resources>
|
||||
@@ -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>
|
||||
@@ -554,6 +543,19 @@
|
||||
<string name="comp_tag_in_use">使用中</string>
|
||||
<string name="comp_tag_new">新規</string>
|
||||
<string name="comp_tag_stored">保存済み</string>
|
||||
<string name="comp_mode_title">ビルドの選び方</string>
|
||||
<string name="comp_mode_auto_detail">最新の DroidDeck ビルドを常に維持</string>
|
||||
<string name="comp_mode_manual_detail">ビルドを自分で選んでインストール</string>
|
||||
<string name="comp_auto_title">Auto が %1$s を最新に保ちます</string>
|
||||
<string name="comp_auto_hint">新しいビルドはバックグラウンドでインストールされます。以前動いていたゲームが数秒で 2 回終了した場合、Auto は動作していたビルドに戻します。</string>
|
||||
<string name="comp_auto_fallback">予備: %1$s</string>
|
||||
<string name="comp_auto_checking">ビルドを確認中…</string>
|
||||
<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_waits_unmetered">新しいビルドは従量制でない接続、バッテリーセーバーのオフ、端末が熱くない状態を待ちます。</string>
|
||||
<string name="comp_auto_updated">%1$s を %2$s に更新しました。</string>
|
||||
<string name="comp_auto_rolled_back">%1$s %2$s でゲームが数秒で 2 回終了したため、Auto は %3$s に戻しました。</string>
|
||||
<string name="perf_title">パフォーマンス</string>
|
||||
<string name="perf_lede">CPUコア、セッション修正、ホスト名。次回のセッションで有効になります。</string>
|
||||
<string name="perf_client_cores_group">Steamクライアントコア</string>
|
||||
@@ -2204,9 +2206,6 @@
|
||||
<string name="source_added">カスタム</string>
|
||||
<string name="setup_stores">ストア</string>
|
||||
<string name="setup_stores_speed">ダウンロード速度レベル</string>
|
||||
<string name="setup_stores_engine">ストアエンジン</string>
|
||||
<string name="setup_stores_engine_chip_ready">● エンジン準備完了</string>
|
||||
<string name="setup_stores_engine_chip_missing">未ビルド</string>
|
||||
<string name="stores_tier_balanced">バランス</string>
|
||||
<string name="stores_tier_fast">高速</string>
|
||||
<string name="stores_tier_max">最大</string>
|
||||
@@ -3646,4 +3645,12 @@
|
||||
<string name="stores_gamejolt_filter_status">ステータス</string>
|
||||
<string name="stores_gamejolt_filter_partners">パートナー</string>
|
||||
<string name="stores_gamejolt_followed">フォロー中のゲーム</string>
|
||||
<string name="wow_chat_title">チャット入力</string>
|
||||
<string name="wow_chat_voice">音声入力</string>
|
||||
<string name="wow_chat_remember">この選択を記憶する</string>
|
||||
<string name="wow_chat_ask">毎回確認する</string>
|
||||
<string name="wow_chat_send">送信</string>
|
||||
<string name="wow_chat_listening">聞き取り中…</string>
|
||||
<string name="wow_chat_voice_unavailable">このデバイスでは音声入力を使用できません。</string>
|
||||
<string name="wow_chat_mic_denied">音声入力にはマイクへのアクセスが必要です。</string>
|
||||
</resources>
|
||||
@@ -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>
|
||||
@@ -554,6 +543,19 @@
|
||||
<string name="comp_tag_in_use">사용 중</string>
|
||||
<string name="comp_tag_new">신규</string>
|
||||
<string name="comp_tag_stored">저장됨</string>
|
||||
<string name="comp_mode_title">빌드 선택 방식</string>
|
||||
<string name="comp_mode_auto_detail">최신 DroidDeck 빌드, 항상 최신 유지</string>
|
||||
<string name="comp_mode_manual_detail">빌드를 직접 선택하고 설치</string>
|
||||
<string name="comp_auto_title">Auto가 %1$s을(를) 최신으로 유지합니다</string>
|
||||
<string name="comp_auto_hint">새 빌드는 백그라운드에서 설치됩니다. 이전에 실행되던 게임이 몇 초 만에 두 번 종료되면 Auto가 작동하던 빌드로 되돌립니다.</string>
|
||||
<string name="comp_auto_fallback">대체: %1$s</string>
|
||||
<string name="comp_auto_checking">빌드 확인 중…</string>
|
||||
<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_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>
|
||||
<string name="perf_title">성능</string>
|
||||
<string name="perf_lede">CPU 코어, 세션 수정 및 호스트 이름. 다음 세션에 적용됩니다.</string>
|
||||
<string name="perf_client_cores_group">Steam 클라이언트 코어</string>
|
||||
@@ -2204,9 +2206,6 @@
|
||||
<string name="source_added">사용자 정의</string>
|
||||
<string name="setup_stores">스토어</string>
|
||||
<string name="setup_stores_speed">다운로드 속도 단계</string>
|
||||
<string name="setup_stores_engine">스토어 엔진</string>
|
||||
<string name="setup_stores_engine_chip_ready">● 엔진 준비됨</string>
|
||||
<string name="setup_stores_engine_chip_missing">빌드되지 않음</string>
|
||||
<string name="stores_tier_balanced">균형</string>
|
||||
<string name="stores_tier_fast">빠름</string>
|
||||
<string name="stores_tier_max">최대</string>
|
||||
@@ -3646,4 +3645,12 @@
|
||||
<string name="stores_gamejolt_filter_status">상태</string>
|
||||
<string name="stores_gamejolt_filter_partners">파트너</string>
|
||||
<string name="stores_gamejolt_followed">팔로우한 게임</string>
|
||||
<string name="wow_chat_title">채팅 입력</string>
|
||||
<string name="wow_chat_voice">음성 입력</string>
|
||||
<string name="wow_chat_remember">이 선택 기억하기</string>
|
||||
<string name="wow_chat_ask">매번 묻기</string>
|
||||
<string name="wow_chat_send">보내기</string>
|
||||
<string name="wow_chat_listening">듣는 중…</string>
|
||||
<string name="wow_chat_voice_unavailable">이 기기에서는 음성 입력을 사용할 수 없습니다.</string>
|
||||
<string name="wow_chat_mic_denied">음성 입력을 하려면 마이크 접근 권한이 필요합니다.</string>
|
||||
</resources>
|
||||
@@ -8,17 +8,6 @@
|
||||
<string name="steamvr_recovery_restarting">Menetapkan semula grafik dan membuka SteamVR sekali lagi…</string>
|
||||
<string name="steamvr_recovery_failed">DroidDeck tidak dapat dimulakan semula secara automatik. Tutup apl sepenuhnya, kemudian buka semula.</string>
|
||||
<string name="app_name">DroidDeck</string>
|
||||
<string-array name="loading_hints">
|
||||
<item>Larian pertama memuat turun Steam. Ini mungkin mengambil beberapa minit.</item>
|
||||
<item>Secara lalai, meninggalkan apl ini menjedakan Steam; ia menyambung semula dari tempat ia berhenti apabila anda kembali.</item>
|
||||
<item>Pengawal anda akan muncul sebagai pad Xbox 360. Jika tidak, gunakan kawalan pada skrin.</item>
|
||||
</string-array>
|
||||
<string-array name="loading_hints_desktop">
|
||||
<item>Larian pertama menyediakan desktop. Ini mungkin mengambil beberapa minit.</item>
|
||||
<item>Secara lalai, meninggalkan apl ini menjedakan sesi; ia menyambung semula dari tempat ia berhenti apabila anda kembali.</item>
|
||||
<item>Sentuhan berfungsi sebagai pad sentuh: seret untuk menggerakkan penunjuk, ketik untuk klik.</item>
|
||||
<item>Pengawal anda akan muncul sebagai pad Xbox 360. Jika tidak, gunakan kawalan pada skrin.</item>
|
||||
</string-array>
|
||||
<string name="session_channel">Sesi</string>
|
||||
<string name="session_channel_description">Dipaparkan semasa sesi Steam atau desktop sedang dibuka</string>
|
||||
<string name="session_notification">Steam sedang berjalan</string>
|
||||
@@ -554,6 +543,19 @@
|
||||
<string name="comp_tag_in_use">Digunakan</string>
|
||||
<string name="comp_tag_new">Baharu</string>
|
||||
<string name="comp_tag_stored">Disimpan</string>
|
||||
<string name="comp_mode_title">Cara binaan dipilih</string>
|
||||
<string name="comp_mode_auto_detail">Binaan DroidDeck terbaharu, sentiasa terkini</string>
|
||||
<string name="comp_mode_manual_detail">Pilih dan pasang binaan sendiri</string>
|
||||
<string name="comp_auto_title">Auto memastikan %1$s sentiasa terkini</string>
|
||||
<string name="comp_auto_hint">Binaan baharu dipasang di latar belakang. Jika permainan yang pernah berjalan tertutup dalam beberapa saat dua kali, Auto kembali ke binaan yang berfungsi.</string>
|
||||
<string name="comp_auto_fallback">Sandaran: %1$s</string>
|
||||
<string name="comp_auto_checking">Mencari binaan…</string>
|
||||
<string name="comp_auto_none">Tiada DroidDeck-Proton yang menggunakannya</string>
|
||||
<string name="comp_auto_waits_network">Kemas kini Auto menunggu sambungan.</string>
|
||||
<string name="comp_auto_check_failed">Tidak dapat menyemak binaan baharu.</string>
|
||||
<string name="comp_auto_waits_unmetered">Binaan baharu menunggu sambungan tanpa meter, penjimat bateri dimatikan dan peranti yang sejuk.</string>
|
||||
<string name="comp_auto_updated">%1$s dikemas kini ke %2$s.</string>
|
||||
<string name="comp_auto_rolled_back">%1$s %2$s menutup permainan dalam beberapa saat dua kali, jadi Auto kembali ke %3$s.</string>
|
||||
<string name="perf_title">Prestasi</string>
|
||||
<string name="perf_lede">Teras CPU, pembaikan sesi dan nama hos. Berkuat kuasa pada sesi seterusnya.</string>
|
||||
<string name="perf_client_cores_group">Teras klien Steam</string>
|
||||
@@ -2204,9 +2206,6 @@
|
||||
<string name="source_added">Tersuai</string>
|
||||
<string name="setup_stores">Kedai</string>
|
||||
<string name="setup_stores_speed">Tahap kelajuan muat turun</string>
|
||||
<string name="setup_stores_engine">Enjin kedai</string>
|
||||
<string name="setup_stores_engine_chip_ready">● Enjin sedia</string>
|
||||
<string name="setup_stores_engine_chip_missing">Tidak dibina</string>
|
||||
<string name="stores_tier_balanced">Seimbang</string>
|
||||
<string name="stores_tier_fast">Pantas</string>
|
||||
<string name="stores_tier_max">Maksimum</string>
|
||||
@@ -3646,4 +3645,12 @@
|
||||
<string name="stores_gamejolt_filter_status">Status</string>
|
||||
<string name="stores_gamejolt_filter_partners">Rakan kongsi</string>
|
||||
<string name="stores_gamejolt_followed">Permainan diikuti</string>
|
||||
<string name="wow_chat_title">Input sembang</string>
|
||||
<string name="wow_chat_voice">Taip dengan suara</string>
|
||||
<string name="wow_chat_remember">Ingat pilihan ini</string>
|
||||
<string name="wow_chat_ask">Tanya setiap kali</string>
|
||||
<string name="wow_chat_send">Hantar</string>
|
||||
<string name="wow_chat_listening">Mendengar…</string>
|
||||
<string name="wow_chat_voice_unavailable">Taip dengan suara tidak tersedia pada peranti ini.</string>
|
||||
<string name="wow_chat_mic_denied">Akses mikrofon diperlukan untuk menaip dengan suara.</string>
|
||||
</resources>
|
||||
@@ -8,17 +8,6 @@
|
||||
<string name="steamvr_recovery_restarting">Tilbakestiller grafikken og åpner SteamVR igjen…</string>
|
||||
<string name="steamvr_recovery_failed">DroidDeck kunne ikke starte på nytt automatisk. Lukk appen helt, og åpne den igjen.</string>
|
||||
<string name="app_name">DroidDeck</string>
|
||||
<string-array name="loading_hints">
|
||||
<item>Første gang lastes Steam ned. Dette kan ta noen minutter.</item>
|
||||
<item>Som standard settes Steam på pause når du lukker appen, og fortsetter der du slapp når du kommer tilbake.</item>
|
||||
<item>Kontrolleren din vises som en Xbox 360-kontroller. Hvis ikke, bruk skjermkontrollene.</item>
|
||||
</string-array>
|
||||
<string-array name="loading_hints_desktop">
|
||||
<item>Første gang settes skrivebordet opp. Dette kan ta noen minutter.</item>
|
||||
<item>Som standard settes sesjonen på pause når du lukker appen, og fortsetter der du slapp når du kommer tilbake.</item>
|
||||
<item>Berøring fungerer som styreflate: dra for å flytte pekeren, trykk for å klikke.</item>
|
||||
<item>Kontrolleren din vises som en Xbox 360-kontroller. Hvis ikke, bruk skjermkontrollene.</item>
|
||||
</string-array>
|
||||
<string name="session_channel">Sesjon</string>
|
||||
<string name="session_channel_description">Vises mens en Steam- eller skrivebordssesjon er åpen</string>
|
||||
<string name="session_notification">Steam kjører</string>
|
||||
@@ -556,6 +545,19 @@
|
||||
<string name="comp_tag_in_use">I bruk</string>
|
||||
<string name="comp_tag_new">Ny</string>
|
||||
<string name="comp_tag_stored">Lagret</string>
|
||||
<string name="comp_mode_title">Slik velges bygg</string>
|
||||
<string name="comp_mode_auto_detail">Nyeste DroidDeck-bygg, alltid oppdatert</string>
|
||||
<string name="comp_mode_manual_detail">Velg og installer bygg selv</string>
|
||||
<string name="comp_auto_title">Auto holder %1$s oppdatert</string>
|
||||
<string name="comp_auto_hint">Nye bygg installeres i bakgrunnen. Hvis et spill som fungerte før lukkes innen sekunder to ganger, går Auto tilbake til bygget som fungerte.</string>
|
||||
<string name="comp_auto_fallback">Reserve: %1$s</string>
|
||||
<string name="comp_auto_checking">Ser etter bygg…</string>
|
||||
<string name="comp_auto_none">Ingen DroidDeck-Proton bruker dette</string>
|
||||
<string name="comp_auto_waits_network">Auto-oppdateringer venter på en tilkobling.</string>
|
||||
<string name="comp_auto_check_failed">Kunne ikke se etter nye bygg.</string>
|
||||
<string name="comp_auto_waits_unmetered">Nye bygg venter på en ubegrenset tilkobling, avslått batterisparing og en kjølig enhet.</string>
|
||||
<string name="comp_auto_updated">%1$s oppdatert til %2$s.</string>
|
||||
<string name="comp_auto_rolled_back">%1$s %2$s lukket et spill innen sekunder to ganger, så Auto gikk tilbake til %3$s.</string>
|
||||
<string name="perf_title">Ytelse</string>
|
||||
<string name="perf_lede">CPU-kjerner, sesjonsfikser og vertsnavnet. Gjelder fra neste sesjon.</string>
|
||||
<string name="perf_client_cores_group">Steam-klientens kjerner</string>
|
||||
@@ -2229,9 +2231,6 @@
|
||||
<string name="source_added">Egendefinert</string>
|
||||
<string name="setup_stores">Butikker</string>
|
||||
<string name="setup_stores_speed">Nedlastingshastighetsnivå</string>
|
||||
<string name="setup_stores_engine">Butikkmotor</string>
|
||||
<string name="setup_stores_engine_chip_ready">● Motor klar</string>
|
||||
<string name="setup_stores_engine_chip_missing">Ikke bygget</string>
|
||||
<string name="stores_tier_balanced">Balansert</string>
|
||||
<string name="stores_tier_fast">Rask</string>
|
||||
<string name="stores_tier_max">Maks</string>
|
||||
@@ -3676,4 +3675,12 @@
|
||||
<string name="stores_gamejolt_filter_status">Status</string>
|
||||
<string name="stores_gamejolt_filter_partners">Partnere</string>
|
||||
<string name="stores_gamejolt_followed">Fulgte spill</string>
|
||||
<string name="wow_chat_title">Chatinndata</string>
|
||||
<string name="wow_chat_voice">Taleinntasting</string>
|
||||
<string name="wow_chat_remember">Husk dette valget</string>
|
||||
<string name="wow_chat_ask">Spør hver gang</string>
|
||||
<string name="wow_chat_send">Send</string>
|
||||
<string name="wow_chat_listening">Lytter…</string>
|
||||
<string name="wow_chat_voice_unavailable">Taleinntasting er ikke tilgjengelig på denne enheten.</string>
|
||||
<string name="wow_chat_mic_denied">Mikrofontilgang trengs for taleinntasting.</string>
|
||||
</resources>
|
||||
@@ -8,17 +8,6 @@
|
||||
<string name="steamvr_recovery_restarting">Grafische weergave resetten en SteamVR opnieuw openen…</string>
|
||||
<string name="steamvr_recovery_failed">DroidDeck kon niet automatisch opnieuw starten. Sluit de app volledig af en open hem daarna opnieuw.</string>
|
||||
<string name="app_name">DroidDeck</string>
|
||||
<string-array name="loading_hints">
|
||||
<item>Bij de eerste start wordt Steam gedownload. Dit kan een paar minuten duren.</item>
|
||||
<item>Standaard wordt Steam gepauzeerd als je de app verlaat; als je terugkomt, gaat het verder waar je was.</item>
|
||||
<item>Je controller wordt gezien als Xbox 360-controller. Gebruik anders de schermbediening.</item>
|
||||
</string-array>
|
||||
<string-array name="loading_hints_desktop">
|
||||
<item>Bij de eerste start wordt de desktop ingericht. Dit kan een paar minuten duren.</item>
|
||||
<item>Standaard wordt de sessie gepauzeerd als je de app verlaat; als je terugkomt, gaat die verder waar je was.</item>
|
||||
<item>Aanraken werkt als touchpad: sleep om de aanwijzer te verplaatsen, tik om te klikken.</item>
|
||||
<item>Je controller wordt gezien als Xbox 360-controller. Gebruik anders de schermbediening.</item>
|
||||
</string-array>
|
||||
<string name="session_channel">Sessie</string>
|
||||
<string name="session_channel_description">Wordt getoond zolang er een Steam- of desktopsessie open is</string>
|
||||
<string name="session_notification">Steam is actief</string>
|
||||
@@ -556,6 +545,19 @@
|
||||
<string name="comp_tag_in_use">In gebruik</string>
|
||||
<string name="comp_tag_new">Nieuw</string>
|
||||
<string name="comp_tag_stored">Bewaard</string>
|
||||
<string name="comp_mode_title">Hoe builds worden gekozen</string>
|
||||
<string name="comp_mode_auto_detail">Nieuwste DroidDeck-build, altijd actueel</string>
|
||||
<string name="comp_mode_manual_detail">Kies en installeer builds zelf</string>
|
||||
<string name="comp_auto_title">Auto houdt %1$s actueel</string>
|
||||
<string name="comp_auto_hint">Nieuwe builds worden op de achtergrond geïnstalleerd. Sluit een game die eerder werkte twee keer binnen enkele seconden, dan gaat Auto terug naar de build die werkte.</string>
|
||||
<string name="comp_auto_fallback">Terugval: %1$s</string>
|
||||
<string name="comp_auto_checking">Zoeken naar builds…</string>
|
||||
<string name="comp_auto_none">Geen DroidDeck-Proton gebruikt dit</string>
|
||||
<string name="comp_auto_waits_network">Auto-updates wachten op een verbinding.</string>
|
||||
<string name="comp_auto_check_failed">Kon niet op nieuwe builds controleren.</string>
|
||||
<string name="comp_auto_waits_unmetered">Nieuwe builds wachten op een onbeperkte verbinding, batterijbesparing uit en een koel apparaat.</string>
|
||||
<string name="comp_auto_updated">%1$s bijgewerkt naar %2$s.</string>
|
||||
<string name="comp_auto_rolled_back">%1$s %2$s sloot een game twee keer binnen enkele seconden, dus Auto ging terug naar %3$s.</string>
|
||||
<string name="perf_title">Prestaties</string>
|
||||
<string name="perf_lede">CPU-cores, sessiefixes en de hostnaam. Geldt vanaf de volgende sessie.</string>
|
||||
<string name="perf_client_cores_group">Cores voor de Steam-client</string>
|
||||
@@ -2229,9 +2231,6 @@
|
||||
<string name="source_added">Aangepast</string>
|
||||
<string name="setup_stores">Winkels</string>
|
||||
<string name="setup_stores_speed">Niveau voor downloadsnelheid</string>
|
||||
<string name="setup_stores_engine">Winkels-engine</string>
|
||||
<string name="setup_stores_engine_chip_ready">● Engine gereed</string>
|
||||
<string name="setup_stores_engine_chip_missing">Niet gebouwd</string>
|
||||
<string name="stores_tier_balanced">Gebalanceerd</string>
|
||||
<string name="stores_tier_fast">Snel</string>
|
||||
<string name="stores_tier_max">Max</string>
|
||||
@@ -3676,4 +3675,12 @@
|
||||
<string name="stores_gamejolt_filter_status">Status</string>
|
||||
<string name="stores_gamejolt_filter_partners">Partners</string>
|
||||
<string name="stores_gamejolt_followed">Gevolgde games</string>
|
||||
<string name="wow_chat_title">Chatinvoer</string>
|
||||
<string name="wow_chat_voice">Spraakinvoer</string>
|
||||
<string name="wow_chat_remember">Deze keuze onthouden</string>
|
||||
<string name="wow_chat_ask">Elke keer vragen</string>
|
||||
<string name="wow_chat_send">Verzenden</string>
|
||||
<string name="wow_chat_listening">Luisteren…</string>
|
||||
<string name="wow_chat_voice_unavailable">Spraakinvoer is niet beschikbaar op dit apparaat.</string>
|
||||
<string name="wow_chat_mic_denied">Voor spraakinvoer is toegang tot de microfoon nodig.</string>
|
||||
</resources>
|
||||
@@ -8,17 +8,6 @@
|
||||
<string name="steamvr_recovery_restarting">Resetowanie grafiki i ponowne otwieranie SteamVR…</string>
|
||||
<string name="steamvr_recovery_failed">Nie udało się automatycznie uruchomić ponownie DroidDeck. Całkowicie zamknij aplikację, a potem otwórz ją ponownie.</string>
|
||||
<string name="app_name">DroidDeck</string>
|
||||
<string-array name="loading_hints">
|
||||
<item>Przy pierwszym uruchomieniu pobierany jest Steam. Może to potrwać kilka minut.</item>
|
||||
<item>Domyślnie wyjście z aplikacji wstrzymuje Steam; po powrocie wznowi on działanie tam, gdzie przerwał.</item>
|
||||
<item>Twój kontroler jest widoczny jako pad Xbox 360. Bez niego użyj sterowania ekranowego.</item>
|
||||
</string-array>
|
||||
<string-array name="loading_hints_desktop">
|
||||
<item>Przy pierwszym uruchomieniu konfigurowany jest pulpit. Może to potrwać kilka minut.</item>
|
||||
<item>Domyślnie wyjście z aplikacji wstrzymuje sesję; po powrocie wznowi się ona tam, gdzie została przerwana.</item>
|
||||
<item>Ekran działa jak touchpad: przeciągnij, aby przesunąć kursor, dotknij, aby kliknąć.</item>
|
||||
<item>Twój kontroler jest widoczny jako pad Xbox 360. Bez niego użyj sterowania ekranowego.</item>
|
||||
</string-array>
|
||||
<string name="session_channel">Sesja</string>
|
||||
<string name="session_channel_description">Widoczne, gdy otwarta jest sesja Steam lub pulpitu</string>
|
||||
<string name="session_notification">Steam działa</string>
|
||||
@@ -560,6 +549,19 @@
|
||||
<string name="comp_tag_in_use">W użyciu</string>
|
||||
<string name="comp_tag_new">Nowy</string>
|
||||
<string name="comp_tag_stored">Zapisany</string>
|
||||
<string name="comp_mode_title">Jak wybierane są wersje</string>
|
||||
<string name="comp_mode_auto_detail">Najnowsza wersja DroidDeck, zawsze aktualna</string>
|
||||
<string name="comp_mode_manual_detail">Wybieraj i instaluj wersje samodzielnie</string>
|
||||
<string name="comp_auto_title">Auto utrzymuje %1$s w aktualności</string>
|
||||
<string name="comp_auto_hint">Nowe wersje instalują się w tle. Jeśli gra, która działała, dwa razy zamknie się po kilku sekundach, Auto wróci do działającej wersji.</string>
|
||||
<string name="comp_auto_fallback">Zapasowa: %1$s</string>
|
||||
<string name="comp_auto_checking">Szukanie wersji…</string>
|
||||
<string name="comp_auto_none">Żaden DroidDeck-Proton tego nie używa</string>
|
||||
<string name="comp_auto_waits_network">Aktualizacje Auto czekają na połączenie.</string>
|
||||
<string name="comp_auto_check_failed">Nie udało się sprawdzić nowych wersji.</string>
|
||||
<string name="comp_auto_waits_unmetered">Nowe wersje czekają na połączenie bez limitu, wyłączone oszczędzanie baterii i chłodne urządzenie.</string>
|
||||
<string name="comp_auto_updated">%1$s zaktualizowano do %2$s.</string>
|
||||
<string name="comp_auto_rolled_back">%1$s %2$s dwa razy zamknął grę po kilku sekundach, więc Auto wróciło do %3$s.</string>
|
||||
<string name="perf_title">Wydajność</string>
|
||||
<string name="perf_lede">Rdzenie CPU, poprawki sesji i nazwa hosta. Działa od następnej sesji.</string>
|
||||
<string name="perf_client_cores_group">Rdzenie klienta Steam</string>
|
||||
@@ -2279,9 +2281,6 @@
|
||||
<string name="source_added">Własna</string>
|
||||
<string name="setup_stores">Sklepy</string>
|
||||
<string name="setup_stores_speed">Poziom szybkości pobierania</string>
|
||||
<string name="setup_stores_engine">Mechanizm sklepów</string>
|
||||
<string name="setup_stores_engine_chip_ready">● Mechanizm gotowy</string>
|
||||
<string name="setup_stores_engine_chip_missing">Nie zbudowano</string>
|
||||
<string name="stores_tier_balanced">Zbalansowany</string>
|
||||
<string name="stores_tier_fast">Szybki</string>
|
||||
<string name="stores_tier_max">Maksymalny</string>
|
||||
@@ -3754,4 +3753,12 @@
|
||||
<string name="stores_gamejolt_filter_status">Status</string>
|
||||
<string name="stores_gamejolt_filter_partners">Partnerzy</string>
|
||||
<string name="stores_gamejolt_followed">Obserwowane gry</string>
|
||||
<string name="wow_chat_title">Wpisywanie na czacie</string>
|
||||
<string name="wow_chat_voice">Pisanie głosowe</string>
|
||||
<string name="wow_chat_remember">Zapamiętaj ten wybór</string>
|
||||
<string name="wow_chat_ask">Pytaj za każdym razem</string>
|
||||
<string name="wow_chat_send">Wyślij</string>
|
||||
<string name="wow_chat_listening">Słucham…</string>
|
||||
<string name="wow_chat_voice_unavailable">Pisanie głosowe nie jest dostępne na tym urządzeniu.</string>
|
||||
<string name="wow_chat_mic_denied">Pisanie głosowe wymaga dostępu do mikrofonu.</string>
|
||||
</resources>
|
||||
@@ -8,17 +8,6 @@
|
||||
<string name="steamvr_recovery_restarting">A repor os gráficos e a reabrir o SteamVR…</string>
|
||||
<string name="steamvr_recovery_failed">O DroidDeck não conseguiu reiniciar automaticamente. Feche completamente a app e volte a abri-la.</string>
|
||||
<string name="app_name">DroidDeck</string>
|
||||
<string-array name="loading_hints">
|
||||
<item>A primeira execução transfere o Steam. Isto pode demorar alguns minutos.</item>
|
||||
<item>Por predefinição, sair da app coloca o Steam em pausa; ao voltar, continua onde ficou.</item>
|
||||
<item>O seu comando aparece como um comando Xbox 360. Se não tiver comando, utilize os controlos no ecrã.</item>
|
||||
</string-array>
|
||||
<string-array name="loading_hints_desktop">
|
||||
<item>A primeira execução configura o ambiente de trabalho. Isto pode demorar alguns minutos.</item>
|
||||
<item>Por predefinição, sair da app coloca a sessão em pausa; ao voltar, continua onde ficou.</item>
|
||||
<item>O toque funciona como um painel tátil: arraste para mover o ponteiro, toque para clicar.</item>
|
||||
<item>O seu comando aparece como um comando Xbox 360. Se não tiver comando, utilize os controlos no ecrã.</item>
|
||||
</string-array>
|
||||
<string name="session_channel">Sessão</string>
|
||||
<string name="session_channel_description">Apresentada enquanto houver uma sessão do Steam ou do ambiente de trabalho aberta</string>
|
||||
<string name="session_notification">O Steam está em execução</string>
|
||||
@@ -558,6 +547,19 @@
|
||||
<string name="comp_tag_in_use">Em utilização</string>
|
||||
<string name="comp_tag_new">Novo</string>
|
||||
<string name="comp_tag_stored">Guardado</string>
|
||||
<string name="comp_mode_title">Como as versões são escolhidas</string>
|
||||
<string name="comp_mode_auto_detail">Versão mais recente do DroidDeck, sempre atualizada</string>
|
||||
<string name="comp_mode_manual_detail">Escolha e instale as versões manualmente</string>
|
||||
<string name="comp_auto_title">Auto mantém %1$s atualizado</string>
|
||||
<string name="comp_auto_hint">As novas versões são instaladas em segundo plano. Se um jogo que funcionava fechar em segundos duas vezes, Auto volta à versão que funcionava.</string>
|
||||
<string name="comp_auto_fallback">Reserva: %1$s</string>
|
||||
<string name="comp_auto_checking">A procurar versões…</string>
|
||||
<string name="comp_auto_none">Nenhum DroidDeck-Proton usa isto</string>
|
||||
<string name="comp_auto_waits_network">As atualizações Auto aguardam uma ligação.</string>
|
||||
<string name="comp_auto_check_failed">Não foi possível procurar novas versões.</string>
|
||||
<string name="comp_auto_waits_unmetered">As novas versões aguardam uma ligação não limitada, a poupança de bateria desligada e o dispositivo fresco.</string>
|
||||
<string name="comp_auto_updated">%1$s atualizado para %2$s.</string>
|
||||
<string name="comp_auto_rolled_back">%1$s %2$s fechou um jogo em segundos duas vezes, por isso Auto voltou a %3$s.</string>
|
||||
<string name="perf_title">Desempenho</string>
|
||||
<string name="perf_lede">Núcleos da CPU, correções da sessão e nome do anfitrião. Aplica-se na próxima sessão.</string>
|
||||
<string name="perf_client_cores_group">Núcleos do cliente Steam</string>
|
||||
@@ -2246,9 +2248,6 @@
|
||||
<string name="source_added">Personalizados</string>
|
||||
<string name="setup_stores">Lojas</string>
|
||||
<string name="setup_stores_speed">Nível de velocidade de transferência</string>
|
||||
<string name="setup_stores_engine">Motor das lojas</string>
|
||||
<string name="setup_stores_engine_chip_ready">● Motor pronto</string>
|
||||
<string name="setup_stores_engine_chip_missing">Não compilado</string>
|
||||
<string name="stores_tier_balanced">Equilibrado</string>
|
||||
<string name="stores_tier_fast">Rápido</string>
|
||||
<string name="stores_tier_max">Máximo</string>
|
||||
@@ -3715,4 +3714,12 @@
|
||||
<string name="stores_gamejolt_filter_status">Estado</string>
|
||||
<string name="stores_gamejolt_filter_partners">Parceiros</string>
|
||||
<string name="stores_gamejolt_followed">Jogos seguidos</string>
|
||||
<string name="wow_chat_title">Introdução no chat</string>
|
||||
<string name="wow_chat_voice">Escrita por voz</string>
|
||||
<string name="wow_chat_remember">Lembrar esta escolha</string>
|
||||
<string name="wow_chat_ask">Perguntar sempre</string>
|
||||
<string name="wow_chat_send">Enviar</string>
|
||||
<string name="wow_chat_listening">A ouvir…</string>
|
||||
<string name="wow_chat_voice_unavailable">A escrita por voz não está disponível neste dispositivo.</string>
|
||||
<string name="wow_chat_mic_denied">É necessário acesso ao microfone para a escrita por voz.</string>
|
||||
</resources>
|
||||
@@ -8,17 +8,6 @@
|
||||
<string name="steamvr_recovery_restarting">Redefinindo os gráficos e reabrindo o SteamVR…</string>
|
||||
<string name="steamvr_recovery_failed">O DroidDeck não conseguiu reiniciar automaticamente. Feche o app completamente e abra-o de novo.</string>
|
||||
<string name="app_name">DroidDeck</string>
|
||||
<string-array name="loading_hints">
|
||||
<item>Na primeira execução, a Steam é baixada. Isso pode levar alguns minutos.</item>
|
||||
<item>Por padrão, sair do app pausa a Steam; ela continua de onde parou quando você voltar.</item>
|
||||
<item>Seu controle aparece como um controle de Xbox 360. Sem controle, use os controles na tela.</item>
|
||||
</string-array>
|
||||
<string-array name="loading_hints_desktop">
|
||||
<item>Na primeira execução, a área de trabalho é preparada. Isso pode levar alguns minutos.</item>
|
||||
<item>Por padrão, sair do app pausa a sessão; ela continua de onde parou quando você voltar.</item>
|
||||
<item>O toque funciona como um touchpad: arraste para mover o ponteiro, toque para clicar.</item>
|
||||
<item>Seu controle aparece como um controle de Xbox 360. Sem controle, use os controles na tela.</item>
|
||||
</string-array>
|
||||
<string name="session_channel">Sessão</string>
|
||||
<string name="session_channel_description">Aparece enquanto uma sessão da Steam ou da área de trabalho está aberta</string>
|
||||
<string name="session_notification">A Steam está em execução</string>
|
||||
@@ -558,6 +547,19 @@
|
||||
<string name="comp_tag_in_use">Em uso</string>
|
||||
<string name="comp_tag_new">Novo</string>
|
||||
<string name="comp_tag_stored">Armazenado</string>
|
||||
<string name="comp_mode_title">Como as versões são escolhidas</string>
|
||||
<string name="comp_mode_auto_detail">Versão mais recente do DroidDeck, sempre atualizada</string>
|
||||
<string name="comp_mode_manual_detail">Escolha e instale as versões você mesmo</string>
|
||||
<string name="comp_auto_title">Auto mantém %1$s atualizado</string>
|
||||
<string name="comp_auto_hint">Novas versões são instaladas em segundo plano. Se um jogo que funcionava fechar em segundos duas vezes, Auto volta para a versão que funcionava.</string>
|
||||
<string name="comp_auto_fallback">Reserva: %1$s</string>
|
||||
<string name="comp_auto_checking">Procurando versões…</string>
|
||||
<string name="comp_auto_none">Nenhum DroidDeck-Proton usa isto</string>
|
||||
<string name="comp_auto_waits_network">As atualizações Auto aguardam uma conexão.</string>
|
||||
<string name="comp_auto_check_failed">Não foi possível procurar novas versões.</string>
|
||||
<string name="comp_auto_waits_unmetered">Novas versões aguardam uma conexão não limitada, a economia de bateria desligada e o aparelho frio.</string>
|
||||
<string name="comp_auto_updated">%1$s atualizado para %2$s.</string>
|
||||
<string name="comp_auto_rolled_back">%1$s %2$s fechou um jogo em segundos duas vezes, então Auto voltou para %3$s.</string>
|
||||
<string name="perf_title">Desempenho</string>
|
||||
<string name="perf_lede">Núcleos da CPU, correções da sessão e o nome do host. Vale a partir da próxima sessão.</string>
|
||||
<string name="perf_client_cores_group">Núcleos do cliente Steam</string>
|
||||
@@ -2246,9 +2248,6 @@
|
||||
<string name="source_added">Personalizado</string>
|
||||
<string name="setup_stores">Lojas</string>
|
||||
<string name="setup_stores_speed">Nível de velocidade de download</string>
|
||||
<string name="setup_stores_engine">Mecanismo das lojas</string>
|
||||
<string name="setup_stores_engine_chip_ready">● Mecanismo pronto</string>
|
||||
<string name="setup_stores_engine_chip_missing">Não compilado</string>
|
||||
<string name="stores_tier_balanced">Equilibrado</string>
|
||||
<string name="stores_tier_fast">Rápido</string>
|
||||
<string name="stores_tier_max">Máximo</string>
|
||||
@@ -3715,4 +3714,12 @@
|
||||
<string name="stores_gamejolt_filter_status">Status</string>
|
||||
<string name="stores_gamejolt_filter_partners">Parceiros</string>
|
||||
<string name="stores_gamejolt_followed">Jogos seguidos</string>
|
||||
<string name="wow_chat_title">Entrada do chat</string>
|
||||
<string name="wow_chat_voice">Digitação por voz</string>
|
||||
<string name="wow_chat_remember">Lembrar esta escolha</string>
|
||||
<string name="wow_chat_ask">Perguntar sempre</string>
|
||||
<string name="wow_chat_send">Enviar</string>
|
||||
<string name="wow_chat_listening">Ouvindo…</string>
|
||||
<string name="wow_chat_voice_unavailable">A digitação por voz não está disponível neste dispositivo.</string>
|
||||
<string name="wow_chat_mic_denied">É preciso acesso ao microfone para a digitação por voz.</string>
|
||||
</resources>
|
||||
@@ -8,17 +8,6 @@
|
||||
<string name="steamvr_recovery_restarting">Se resetează grafica și se redeschide SteamVR…</string>
|
||||
<string name="steamvr_recovery_failed">DroidDeck nu a putut reporni automat. Închide complet aplicația, apoi redeschide-o.</string>
|
||||
<string name="app_name">DroidDeck</string>
|
||||
<string-array name="loading_hints">
|
||||
<item>Prima rulare descarcă Steam. Poate dura câteva minute.</item>
|
||||
<item>În mod implicit, la părăsirea aplicației, Steam este pus pe pauză; continuă de unde ai rămas când te întorci.</item>
|
||||
<item>Controllerul tău apare ca un gamepad Xbox 360. Altfel, folosește comenzile de pe ecran.</item>
|
||||
</string-array>
|
||||
<string-array name="loading_hints_desktop">
|
||||
<item>Prima rulare configurează desktopul. Poate dura câteva minute.</item>
|
||||
<item>În mod implicit, la părăsirea aplicației, sesiunea este pusă pe pauză; continuă de unde ai rămas când te întorci.</item>
|
||||
<item>Atingerea funcționează ca un touchpad: trage pentru a mișca cursorul, apasă scurt pentru a face clic.</item>
|
||||
<item>Controllerul tău apare ca un gamepad Xbox 360. Altfel, folosește comenzile de pe ecran.</item>
|
||||
</string-array>
|
||||
<string name="session_channel">Sesiune</string>
|
||||
<string name="session_channel_description">Apare cât timp o sesiune Steam sau desktop este activă</string>
|
||||
<string name="session_notification">Steam este în execuție</string>
|
||||
@@ -558,6 +547,19 @@
|
||||
<string name="comp_tag_in_use">În uz</string>
|
||||
<string name="comp_tag_new">Nou</string>
|
||||
<string name="comp_tag_stored">Salvat</string>
|
||||
<string name="comp_mode_title">Cum se aleg versiunile</string>
|
||||
<string name="comp_mode_auto_detail">Cea mai nouă versiune DroidDeck, mereu actualizată</string>
|
||||
<string name="comp_mode_manual_detail">Alege și instalează singur versiunile</string>
|
||||
<string name="comp_auto_title">Auto menține %1$s actualizat</string>
|
||||
<string name="comp_auto_hint">Versiunile noi se instalează în fundal. Dacă un joc care mergea se închide în câteva secunde de două ori, Auto revine la versiunea care funcționa.</string>
|
||||
<string name="comp_auto_fallback">Rezervă: %1$s</string>
|
||||
<string name="comp_auto_checking">Se caută versiuni…</string>
|
||||
<string name="comp_auto_none">Niciun DroidDeck-Proton nu folosește asta</string>
|
||||
<string name="comp_auto_waits_network">Actualizările Auto așteaptă o conexiune.</string>
|
||||
<string name="comp_auto_check_failed">Nu s-au putut căuta versiuni noi.</string>
|
||||
<string name="comp_auto_waits_unmetered">Versiunile noi așteaptă o conexiune fără limită de date, economisirea bateriei oprită și un dispozitiv răcit.</string>
|
||||
<string name="comp_auto_updated">%1$s actualizat la %2$s.</string>
|
||||
<string name="comp_auto_rolled_back">%1$s %2$s a închis un joc în câteva secunde de două ori, așa că Auto a revenit la %3$s.</string>
|
||||
<string name="perf_title">Performanță</string>
|
||||
<string name="perf_lede">Nuclee CPU, remedieri de sesiune și numele hostului. Se aplică la sesiunea următoare.</string>
|
||||
<string name="perf_client_cores_group">Nuclee pentru clientul Steam</string>
|
||||
@@ -2254,9 +2256,6 @@
|
||||
<string name="source_added">Personalizat</string>
|
||||
<string name="setup_stores">Magazine</string>
|
||||
<string name="setup_stores_speed">Nivelul vitezei de descărcare</string>
|
||||
<string name="setup_stores_engine">Motorul magazinelor</string>
|
||||
<string name="setup_stores_engine_chip_ready">● Motor pregătit</string>
|
||||
<string name="setup_stores_engine_chip_missing">Necompilat</string>
|
||||
<string name="stores_tier_balanced">Echilibrat</string>
|
||||
<string name="stores_tier_fast">Rapid</string>
|
||||
<string name="stores_tier_max">Maxim</string>
|
||||
@@ -3715,4 +3714,12 @@
|
||||
<string name="stores_gamejolt_filter_status">Stare</string>
|
||||
<string name="stores_gamejolt_filter_partners">Parteneri</string>
|
||||
<string name="stores_gamejolt_followed">Jocuri urmărite</string>
|
||||
<string name="wow_chat_title">Introducere în chat</string>
|
||||
<string name="wow_chat_voice">Tastare vocală</string>
|
||||
<string name="wow_chat_remember">Ține minte această alegere</string>
|
||||
<string name="wow_chat_ask">Întreabă de fiecare dată</string>
|
||||
<string name="wow_chat_send">Trimite</string>
|
||||
<string name="wow_chat_listening">Se ascultă…</string>
|
||||
<string name="wow_chat_voice_unavailable">Tastarea vocală nu este disponibilă pe acest dispozitiv.</string>
|
||||
<string name="wow_chat_mic_denied">Pentru tastarea vocală este necesar accesul la microfon.</string>
|
||||
</resources>
|
||||
@@ -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>
|
||||
@@ -579,6 +568,19 @@
|
||||
<string name="comp_tag_in_use">Используется</string>
|
||||
<string name="comp_tag_new">Новый</string>
|
||||
<string name="comp_tag_stored">Сохранено</string>
|
||||
<string name="comp_mode_title">Как выбираются сборки</string>
|
||||
<string name="comp_mode_auto_detail">Новейшая сборка DroidDeck, всегда актуальна</string>
|
||||
<string name="comp_mode_manual_detail">Выбирайте и устанавливайте сборки сами</string>
|
||||
<string name="comp_auto_title">Auto поддерживает %1$s в актуальном состоянии</string>
|
||||
<string name="comp_auto_hint">Новые сборки ставятся в фоне. Если игра, которая раньше работала, дважды закроется за несколько секунд, Auto вернётся к рабочей сборке.</string>
|
||||
<string name="comp_auto_fallback">Запасная: %1$s</string>
|
||||
<string name="comp_auto_checking">Поиск сборок…</string>
|
||||
<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_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>
|
||||
<string name="perf_title">Производительность</string>
|
||||
<string name="perf_lede">Ядра CPU, исправления сессии и имя хоста. Вступит в силу в следующей сессии.</string>
|
||||
<string name="perf_client_cores_group">Ядра клиента Steam</string>
|
||||
@@ -2362,9 +2364,6 @@
|
||||
<string name="source_added">Свои</string>
|
||||
<string name="setup_stores">Магазины</string>
|
||||
<string name="setup_stores_speed">Уровень скорости загрузки</string>
|
||||
<string name="setup_stores_engine">Движок магазинов</string>
|
||||
<string name="setup_stores_engine_chip_ready">● Движок готов</string>
|
||||
<string name="setup_stores_engine_chip_missing">Не собран</string>
|
||||
<string name="stores_tier_balanced">Сбалансированный</string>
|
||||
<string name="stores_tier_fast">Быстрый</string>
|
||||
<string name="stores_tier_max">Максимальный</string>
|
||||
@@ -3837,4 +3836,12 @@
|
||||
<string name="stores_gamejolt_filter_status">Статус</string>
|
||||
<string name="stores_gamejolt_filter_partners">Партнёры</string>
|
||||
<string name="stores_gamejolt_followed">Отслеживаемые игры</string>
|
||||
<string name="wow_chat_title">Ввод в чат</string>
|
||||
<string name="wow_chat_voice">Голосовой ввод</string>
|
||||
<string name="wow_chat_remember">Запомнить этот выбор</string>
|
||||
<string name="wow_chat_ask">Спрашивать каждый раз</string>
|
||||
<string name="wow_chat_send">Отправить</string>
|
||||
<string name="wow_chat_listening">Слушаю…</string>
|
||||
<string name="wow_chat_voice_unavailable">Голосовой ввод недоступен на этом устройстве.</string>
|
||||
<string name="wow_chat_mic_denied">Для голосового ввода нужен доступ к микрофону.</string>
|
||||
</resources>
|
||||
@@ -8,17 +8,6 @@
|
||||
<string name="steamvr_recovery_restarting">Återställer grafiken och öppnar SteamVR igen…</string>
|
||||
<string name="steamvr_recovery_failed">DroidDeck kunde inte starta om automatiskt. Stäng appen helt och öppna den sedan igen.</string>
|
||||
<string name="app_name">DroidDeck</string>
|
||||
<string-array name="loading_hints">
|
||||
<item>Första starten laddar ned Steam. Det kan ta några minuter.</item>
|
||||
<item>Som standard pausas Steam när du lämnar appen. Det fortsätter där det slutade när du kommer tillbaka.</item>
|
||||
<item>Din handkontroll visas som en Xbox 360-kontroll. Annars kan du använda skärmkontrollerna.</item>
|
||||
</string-array>
|
||||
<string-array name="loading_hints_desktop">
|
||||
<item>Första starten konfigurerar skrivbordet. Det kan ta några minuter.</item>
|
||||
<item>Som standard pausas sessionen när du lämnar appen. Den fortsätter där den slutade när du kommer tillbaka.</item>
|
||||
<item>Pekskärmen fungerar som en pekplatta: dra för att flytta pekaren, tryck för att klicka.</item>
|
||||
<item>Din handkontroll visas som en Xbox 360-kontroll. Annars kan du använda skärmkontrollerna.</item>
|
||||
</string-array>
|
||||
<string name="session_channel">Session</string>
|
||||
<string name="session_channel_description">Visas medan en Steam- eller skrivbordssession är öppen</string>
|
||||
<string name="session_notification">Steam körs</string>
|
||||
@@ -556,6 +545,19 @@
|
||||
<string name="comp_tag_in_use">I bruk</string>
|
||||
<string name="comp_tag_new">Ny</string>
|
||||
<string name="comp_tag_stored">Sparad</string>
|
||||
<string name="comp_mode_title">Hur byggen väljs</string>
|
||||
<string name="comp_mode_auto_detail">Senaste DroidDeck-bygget, alltid aktuellt</string>
|
||||
<string name="comp_mode_manual_detail">Välj och installera byggen själv</string>
|
||||
<string name="comp_auto_title">Auto håller %1$s aktuellt</string>
|
||||
<string name="comp_auto_hint">Nya byggen installeras i bakgrunden. Om ett spel som fungerade stängs inom sekunder två gånger går Auto tillbaka till bygget som fungerade.</string>
|
||||
<string name="comp_auto_fallback">Reserv: %1$s</string>
|
||||
<string name="comp_auto_checking">Söker efter byggen…</string>
|
||||
<string name="comp_auto_none">Ingen DroidDeck-Proton använder detta</string>
|
||||
<string name="comp_auto_waits_network">Auto-uppdateringar väntar på en anslutning.</string>
|
||||
<string name="comp_auto_check_failed">Kunde inte söka efter nya byggen.</string>
|
||||
<string name="comp_auto_waits_unmetered">Nya byggen väntar på en obegränsad anslutning, avstängt batterisparläge och en sval enhet.</string>
|
||||
<string name="comp_auto_updated">%1$s uppdaterat till %2$s.</string>
|
||||
<string name="comp_auto_rolled_back">%1$s %2$s stängde ett spel inom sekunder två gånger, så Auto gick tillbaka till %3$s.</string>
|
||||
<string name="perf_title">Prestanda</string>
|
||||
<string name="perf_lede">CPU-kärnor, sessionsfixar och värdnamnet. Gäller från nästa session.</string>
|
||||
<string name="perf_client_cores_group">Steam-klientens kärnor</string>
|
||||
@@ -2229,9 +2231,6 @@
|
||||
<string name="source_added">Egna</string>
|
||||
<string name="setup_stores">Butiker</string>
|
||||
<string name="setup_stores_speed">Nedladdningshastighet</string>
|
||||
<string name="setup_stores_engine">Butiksmotor</string>
|
||||
<string name="setup_stores_engine_chip_ready">● Motor redo</string>
|
||||
<string name="setup_stores_engine_chip_missing">Inte byggd</string>
|
||||
<string name="stores_tier_balanced">Balanserad</string>
|
||||
<string name="stores_tier_fast">Snabb</string>
|
||||
<string name="stores_tier_max">Max</string>
|
||||
@@ -3676,4 +3675,12 @@
|
||||
<string name="stores_gamejolt_filter_status">Status</string>
|
||||
<string name="stores_gamejolt_filter_partners">Partner</string>
|
||||
<string name="stores_gamejolt_followed">Följda spel</string>
|
||||
<string name="wow_chat_title">Chattinmatning</string>
|
||||
<string name="wow_chat_voice">Röstinmatning</string>
|
||||
<string name="wow_chat_remember">Kom ihåg det här valet</string>
|
||||
<string name="wow_chat_ask">Fråga varje gång</string>
|
||||
<string name="wow_chat_send">Skicka</string>
|
||||
<string name="wow_chat_listening">Lyssnar…</string>
|
||||
<string name="wow_chat_voice_unavailable">Röstinmatning är inte tillgänglig på den här enheten.</string>
|
||||
<string name="wow_chat_mic_denied">Mikrofonåtkomst krävs för röstinmatning.</string>
|
||||
</resources>
|
||||
@@ -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>
|
||||
@@ -554,6 +543,19 @@
|
||||
<string name="comp_tag_in_use">ใช้งานอยู่</string>
|
||||
<string name="comp_tag_new">ใหม่</string>
|
||||
<string name="comp_tag_stored">บันทึกไว้</string>
|
||||
<string name="comp_mode_title">วิธีเลือกบิลด์</string>
|
||||
<string name="comp_mode_auto_detail">บิลด์ DroidDeck ล่าสุด อัปเดตอยู่เสมอ</string>
|
||||
<string name="comp_mode_manual_detail">เลือกและติดตั้งบิลด์เอง</string>
|
||||
<string name="comp_auto_title">Auto ทำให้ %1$s เป็นปัจจุบัน</string>
|
||||
<string name="comp_auto_hint">บิลด์ใหม่จะติดตั้งในเบื้องหลัง หากเกมที่เคยเล่นได้ปิดลงภายในไม่กี่วินาทีสองครั้ง Auto จะกลับไปใช้บิลด์ที่ใช้งานได้</string>
|
||||
<string name="comp_auto_fallback">สำรอง: %1$s</string>
|
||||
<string name="comp_auto_checking">กำลังค้นหาบิลด์…</string>
|
||||
<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_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>
|
||||
<string name="perf_title">ประสิทธิภาพ</string>
|
||||
<string name="perf_lede">คอร์ CPU การแก้ไขเซสชัน และชื่อโฮสต์ จะมีผลในเซสชันถัดไป</string>
|
||||
<string name="perf_client_cores_group">คอร์สำหรับไคลเอนต์ Steam</string>
|
||||
@@ -2204,9 +2206,6 @@
|
||||
<string name="source_added">กำหนดเอง</string>
|
||||
<string name="setup_stores">สโตร์</string>
|
||||
<string name="setup_stores_speed">ระดับความเร็วการดาวน์โหลด</string>
|
||||
<string name="setup_stores_engine">เอนจินสโตร์</string>
|
||||
<string name="setup_stores_engine_chip_ready">● เอนจินพร้อมใช้งาน</string>
|
||||
<string name="setup_stores_engine_chip_missing">ยังไม่สร้าง</string>
|
||||
<string name="stores_tier_balanced">สมดุล</string>
|
||||
<string name="stores_tier_fast">เร็ว</string>
|
||||
<string name="stores_tier_max">สูงสุด</string>
|
||||
@@ -3646,4 +3645,12 @@
|
||||
<string name="stores_gamejolt_filter_status">สถานะ</string>
|
||||
<string name="stores_gamejolt_filter_partners">พาร์ตเนอร์</string>
|
||||
<string name="stores_gamejolt_followed">เกมที่ติดตาม</string>
|
||||
<string name="wow_chat_title">การป้อนแชท</string>
|
||||
<string name="wow_chat_voice">พิมพ์ด้วยเสียง</string>
|
||||
<string name="wow_chat_remember">จดจำตัวเลือกนี้</string>
|
||||
<string name="wow_chat_ask">ถามทุกครั้ง</string>
|
||||
<string name="wow_chat_send">ส่ง</string>
|
||||
<string name="wow_chat_listening">กำลังฟัง…</string>
|
||||
<string name="wow_chat_voice_unavailable">อุปกรณ์นี้ไม่รองรับการพิมพ์ด้วยเสียง</string>
|
||||
<string name="wow_chat_mic_denied">ต้องอนุญาตให้เข้าถึงไมโครโฟนเพื่อพิมพ์ด้วยเสียง</string>
|
||||
</resources>
|
||||
@@ -8,17 +8,6 @@
|
||||
<string name="steamvr_recovery_restarting">Grafikler sıfırlanıyor ve SteamVR yeniden açılıyor…</string>
|
||||
<string name="steamvr_recovery_failed">DroidDeck otomatik olarak yeniden başlatılamadı. Uygulamayı tamamen kapatıp yeniden açın.</string>
|
||||
<string name="app_name">DroidDeck</string>
|
||||
<string-array name="loading_hints">
|
||||
<item>İlk çalıştırmada Steam indirilir. Bu işlem birkaç dakika sürebilir.</item>
|
||||
<item>Varsayılan olarak uygulamadan çıkınca Steam duraklatılır; geri döndüğünüzde kaldığı yerden devam eder.</item>
|
||||
<item>Kumandanız Xbox 360 kumandası olarak görünür. Kumanda yoksa ekran üstü kontrolleri kullanın.</item>
|
||||
</string-array>
|
||||
<string-array name="loading_hints_desktop">
|
||||
<item>İlk çalıştırmada masaüstü kurulur. Bu işlem birkaç dakika sürebilir.</item>
|
||||
<item>Varsayılan olarak uygulamadan çıkınca oturum duraklatılır; geri döndüğünüzde kaldığı yerden devam eder.</item>
|
||||
<item>Dokunma, dokunmatik yüzey gibi çalışır: işaretçiyi taşımak için sürükleyin, tıklamak için dokunun.</item>
|
||||
<item>Kumandanız Xbox 360 kumandası olarak görünür. Kumanda yoksa ekran üstü kontrolleri kullanın.</item>
|
||||
</string-array>
|
||||
<string name="session_channel">Oturum</string>
|
||||
<string name="session_channel_description">Steam veya masaüstü oturumu açıkken gösterilir</string>
|
||||
<string name="session_notification">Steam çalışıyor</string>
|
||||
@@ -556,6 +545,19 @@
|
||||
<string name="comp_tag_in_use">Kullanımda</string>
|
||||
<string name="comp_tag_new">Yeni</string>
|
||||
<string name="comp_tag_stored">Saklandı</string>
|
||||
<string name="comp_mode_title">Sürümler nasıl seçilir</string>
|
||||
<string name="comp_mode_auto_detail">En yeni DroidDeck sürümü, hep güncel</string>
|
||||
<string name="comp_mode_manual_detail">Sürümleri kendin seç ve yükle</string>
|
||||
<string name="comp_auto_title">Auto %1$s bileşenini güncel tutar</string>
|
||||
<string name="comp_auto_hint">Yeni sürümler arka planda yüklenir. Önceden çalışan bir oyun iki kez saniyeler içinde kapanırsa Auto çalışan sürüme geri döner.</string>
|
||||
<string name="comp_auto_fallback">Yedek: %1$s</string>
|
||||
<string name="comp_auto_checking">Sürümler aranıyor…</string>
|
||||
<string name="comp_auto_none">Hiçbir DroidDeck-Proton bunu kullanmıyor</string>
|
||||
<string name="comp_auto_waits_network">Auto güncellemeleri bağlantı bekliyor.</string>
|
||||
<string name="comp_auto_check_failed">Yeni sürümler denetlenemedi.</string>
|
||||
<string name="comp_auto_waits_unmetered">Yeni sürümler sınırsız bir bağlantı, kapalı pil tasarrufu ve serin bir cihaz bekler.</string>
|
||||
<string name="comp_auto_updated">%1$s, %2$s sürümüne güncellendi.</string>
|
||||
<string name="comp_auto_rolled_back">%1$s %2$s bir oyunu iki kez saniyeler içinde kapattı, bu yüzden Auto %3$s sürümüne döndü.</string>
|
||||
<string name="perf_title">Performans</string>
|
||||
<string name="perf_lede">CPU çekirdekleri, oturum düzeltmeleri ve ana bilgisayar adı. Sonraki oturumda uygulanır.</string>
|
||||
<string name="perf_client_cores_group">Steam istemcisi çekirdekleri</string>
|
||||
@@ -2221,9 +2223,6 @@
|
||||
<string name="source_added">Özel</string>
|
||||
<string name="setup_stores">Mağazalar</string>
|
||||
<string name="setup_stores_speed">İndirme hızı katmanı</string>
|
||||
<string name="setup_stores_engine">Mağazalar motoru</string>
|
||||
<string name="setup_stores_engine_chip_ready">● Motor hazır</string>
|
||||
<string name="setup_stores_engine_chip_missing">Derlenmedi</string>
|
||||
<string name="stores_tier_balanced">Dengeli</string>
|
||||
<string name="stores_tier_fast">Hızlı</string>
|
||||
<string name="stores_tier_max">Maks</string>
|
||||
@@ -3676,4 +3675,12 @@
|
||||
<string name="stores_gamejolt_filter_status">Durum</string>
|
||||
<string name="stores_gamejolt_filter_partners">Ortaklar</string>
|
||||
<string name="stores_gamejolt_followed">Takip edilen oyunlar</string>
|
||||
<string name="wow_chat_title">Sohbet girişi</string>
|
||||
<string name="wow_chat_voice">Sesle yazma</string>
|
||||
<string name="wow_chat_remember">Bu seçimi hatırla</string>
|
||||
<string name="wow_chat_ask">Her seferinde sor</string>
|
||||
<string name="wow_chat_send">Gönder</string>
|
||||
<string name="wow_chat_listening">Dinleniyor…</string>
|
||||
<string name="wow_chat_voice_unavailable">Sesle yazma bu cihazda kullanılamıyor.</string>
|
||||
<string name="wow_chat_mic_denied">Sesle yazma için mikrofon erişimi gerekiyor.</string>
|
||||
</resources>
|
||||
@@ -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>
|
||||
@@ -560,6 +549,19 @@
|
||||
<string name="comp_tag_in_use">Використовується</string>
|
||||
<string name="comp_tag_new">Нове</string>
|
||||
<string name="comp_tag_stored">Збережено</string>
|
||||
<string name="comp_mode_title">Як вибираються збірки</string>
|
||||
<string name="comp_mode_auto_detail">Найновіша збірка DroidDeck, завжди актуальна</string>
|
||||
<string name="comp_mode_manual_detail">Вибирайте та встановлюйте збірки самостійно</string>
|
||||
<string name="comp_auto_title">Auto підтримує %1$s в актуальному стані</string>
|
||||
<string name="comp_auto_hint">Нові збірки встановлюються у фоні. Якщо гра, яка раніше працювала, двічі закриється за кілька секунд, Auto повернеться до робочої збірки.</string>
|
||||
<string name="comp_auto_fallback">Запасна: %1$s</string>
|
||||
<string name="comp_auto_checking">Пошук збірок…</string>
|
||||
<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_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>
|
||||
<string name="perf_title">Продуктивність</string>
|
||||
<string name="perf_lede">Ядра CPU, виправлення сеансу та ім’я хоста. Застосовується в наступному сеансі.</string>
|
||||
<string name="perf_client_cores_group">Ядра клієнта Steam</string>
|
||||
@@ -2279,9 +2281,6 @@
|
||||
<string name="source_added">Власна</string>
|
||||
<string name="setup_stores">Магазини</string>
|
||||
<string name="setup_stores_speed">Рівень швидкості завантаження</string>
|
||||
<string name="setup_stores_engine">Рушій магазинів</string>
|
||||
<string name="setup_stores_engine_chip_ready">● Рушій готовий</string>
|
||||
<string name="setup_stores_engine_chip_missing">Не зібрано</string>
|
||||
<string name="stores_tier_balanced">Збалансований</string>
|
||||
<string name="stores_tier_fast">Швидкий</string>
|
||||
<string name="stores_tier_max">Максимальний</string>
|
||||
@@ -3754,4 +3753,12 @@
|
||||
<string name="stores_gamejolt_filter_status">Статус</string>
|
||||
<string name="stores_gamejolt_filter_partners">Партнери</string>
|
||||
<string name="stores_gamejolt_followed">Ігри, за якими ви стежите</string>
|
||||
<string name="wow_chat_title">Введення в чат</string>
|
||||
<string name="wow_chat_voice">Голосове введення</string>
|
||||
<string name="wow_chat_remember">Запам\'ятати цей вибір</string>
|
||||
<string name="wow_chat_ask">Питати щоразу</string>
|
||||
<string name="wow_chat_send">Надіслати</string>
|
||||
<string name="wow_chat_listening">Слухаю…</string>
|
||||
<string name="wow_chat_voice_unavailable">Голосове введення недоступне на цьому пристрої.</string>
|
||||
<string name="wow_chat_mic_denied">Для голосового введення потрібен доступ до мікрофона.</string>
|
||||
</resources>
|
||||
@@ -8,17 +8,6 @@
|
||||
<string name="steamvr_recovery_restarting">Đang đặt lại đồ họa và mở lại SteamVR…</string>
|
||||
<string name="steamvr_recovery_failed">DroidDeck không thể tự khởi động lại. Hãy đóng hẳn ứng dụng rồi mở lại.</string>
|
||||
<string name="app_name">DroidDeck</string>
|
||||
<string-array name="loading_hints">
|
||||
<item>Lần chạy đầu tải Steam. Quá trình này có thể mất vài phút.</item>
|
||||
<item>Theo mặc định, rời khỏi ứng dụng sẽ tạm dừng Steam; nó tiếp tục từ nơi bạn dừng lại khi bạn quay lại.</item>
|
||||
<item>Tay cầm của bạn hiện ra như tay cầm Xbox 360. Nếu không, hãy dùng các nút điều khiển trên màn hình.</item>
|
||||
</string-array>
|
||||
<string-array name="loading_hints_desktop">
|
||||
<item>Lần chạy đầu thiết lập desktop. Quá trình này có thể mất vài phút.</item>
|
||||
<item>Theo mặc định, rời khỏi ứng dụng sẽ tạm dừng phiên; nó tiếp tục từ nơi bạn dừng lại khi bạn quay lại.</item>
|
||||
<item>Chạm hoạt động như touchpad: kéo để di chuyển con trỏ, chạm để nhấp.</item>
|
||||
<item>Tay cầm của bạn hiện ra như tay cầm Xbox 360. Nếu không, hãy dùng các nút điều khiển trên màn hình.</item>
|
||||
</string-array>
|
||||
<string name="session_channel">Phiên</string>
|
||||
<string name="session_channel_description">Hiển thị khi có phiên Steam hoặc desktop đang mở</string>
|
||||
<string name="session_notification">Steam đang chạy</string>
|
||||
@@ -554,6 +543,19 @@
|
||||
<string name="comp_tag_in_use">Đang dùng</string>
|
||||
<string name="comp_tag_new">Mới</string>
|
||||
<string name="comp_tag_stored">Đã lưu</string>
|
||||
<string name="comp_mode_title">Cách chọn bản dựng</string>
|
||||
<string name="comp_mode_auto_detail">Bản dựng DroidDeck mới nhất, luôn cập nhật</string>
|
||||
<string name="comp_mode_manual_detail">Tự chọn và cài bản dựng</string>
|
||||
<string name="comp_auto_title">Auto giữ %1$s luôn cập nhật</string>
|
||||
<string name="comp_auto_hint">Bản dựng mới được cài ở nền. Nếu một trò chơi từng chạy được bị đóng sau vài giây hai lần, Auto quay lại bản dựng đã chạy được.</string>
|
||||
<string name="comp_auto_fallback">Dự phòng: %1$s</string>
|
||||
<string name="comp_auto_checking">Đang tìm bản dựng…</string>
|
||||
<string name="comp_auto_none">Không DroidDeck-Proton nào dùng thành phần này</string>
|
||||
<string name="comp_auto_waits_network">Cập nhật Auto đang chờ kết nối.</string>
|
||||
<string name="comp_auto_check_failed">Không thể kiểm tra bản dựng mới.</string>
|
||||
<string name="comp_auto_waits_unmetered">Bản dựng mới chờ kết nối không đo lưu lượng, tắt tiết kiệm pin và thiết bị đủ mát.</string>
|
||||
<string name="comp_auto_updated">Đã cập nhật %1$s lên %2$s.</string>
|
||||
<string name="comp_auto_rolled_back">%1$s %2$s làm trò chơi đóng sau vài giây hai lần, nên Auto đã quay lại %3$s.</string>
|
||||
<string name="perf_title">Hiệu năng</string>
|
||||
<string name="perf_lede">Lõi CPU, các bản sửa phiên và tên máy chủ. Áp dụng từ phiên sau.</string>
|
||||
<string name="perf_client_cores_group">Lõi client Steam</string>
|
||||
@@ -2204,9 +2206,6 @@
|
||||
<string name="source_added">Tùy chỉnh</string>
|
||||
<string name="setup_stores">Cửa hàng</string>
|
||||
<string name="setup_stores_speed">Mức tốc độ tải</string>
|
||||
<string name="setup_stores_engine">Engine stores</string>
|
||||
<string name="setup_stores_engine_chip_ready">● Engine sẵn sàng</string>
|
||||
<string name="setup_stores_engine_chip_missing">Chưa build</string>
|
||||
<string name="stores_tier_balanced">Cân bằng</string>
|
||||
<string name="stores_tier_fast">Nhanh</string>
|
||||
<string name="stores_tier_max">Tối đa</string>
|
||||
@@ -3646,4 +3645,12 @@
|
||||
<string name="stores_gamejolt_filter_status">Trạng thái</string>
|
||||
<string name="stores_gamejolt_filter_partners">Đối tác</string>
|
||||
<string name="stores_gamejolt_followed">Game đang theo dõi</string>
|
||||
<string name="wow_chat_title">Nhập trò chuyện</string>
|
||||
<string name="wow_chat_voice">Nhập bằng giọng nói</string>
|
||||
<string name="wow_chat_remember">Ghi nhớ lựa chọn này</string>
|
||||
<string name="wow_chat_ask">Hỏi mỗi lần</string>
|
||||
<string name="wow_chat_send">Gửi</string>
|
||||
<string name="wow_chat_listening">Đang nghe…</string>
|
||||
<string name="wow_chat_voice_unavailable">Thiết bị này không hỗ trợ nhập bằng giọng nói.</string>
|
||||
<string name="wow_chat_mic_denied">Cần quyền truy cập micrô để nhập bằng giọng nói.</string>
|
||||
</resources>
|
||||
@@ -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>
|
||||
@@ -554,6 +543,19 @@
|
||||
<string name="comp_tag_in_use">使用中</string>
|
||||
<string name="comp_tag_new">新</string>
|
||||
<string name="comp_tag_stored">已存储</string>
|
||||
<string name="comp_mode_title">版本的选择方式</string>
|
||||
<string name="comp_mode_auto_detail">最新 DroidDeck 版本,始终保持更新</string>
|
||||
<string name="comp_mode_manual_detail">自行选择并安装版本</string>
|
||||
<string name="comp_auto_title">Auto 会让 %1$s 保持最新</string>
|
||||
<string name="comp_auto_hint">新版本会在后台安装。如果之前能运行的游戏两次在几秒内关闭,Auto 会退回到之前可用的版本。</string>
|
||||
<string name="comp_auto_fallback">备用:%1$s</string>
|
||||
<string name="comp_auto_checking">正在检查版本…</string>
|
||||
<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_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>
|
||||
<string name="perf_title">性能</string>
|
||||
<string name="perf_lede">CPU 核心、会话修复和主机名。将在下次会话生效。</string>
|
||||
<string name="perf_client_cores_group">Steam 客户端核心</string>
|
||||
@@ -2285,9 +2287,6 @@
|
||||
<string name="source_added">自定义</string>
|
||||
<string name="setup_stores">游戏商店</string>
|
||||
<string name="setup_stores_speed">下载速度档位</string>
|
||||
<string name="setup_stores_engine">商店引擎</string>
|
||||
<string name="setup_stores_engine_chip_ready">● 引擎就绪</string>
|
||||
<string name="setup_stores_engine_chip_missing">未构建</string>
|
||||
<string name="stores_tier_balanced">均衡</string>
|
||||
<string name="stores_tier_fast">快速</string>
|
||||
<string name="stores_tier_max">最高</string>
|
||||
@@ -3650,4 +3649,12 @@
|
||||
<string name="stores_gamejolt_filter_status">状态</string>
|
||||
<string name="stores_gamejolt_filter_partners">合作伙伴</string>
|
||||
<string name="stores_gamejolt_followed">已关注的游戏</string>
|
||||
<string name="wow_chat_title">聊天输入</string>
|
||||
<string name="wow_chat_voice">语音输入</string>
|
||||
<string name="wow_chat_remember">记住此选择</string>
|
||||
<string name="wow_chat_ask">每次询问</string>
|
||||
<string name="wow_chat_send">发送</string>
|
||||
<string name="wow_chat_listening">正在聆听…</string>
|
||||
<string name="wow_chat_voice_unavailable">此设备不支持语音输入。</string>
|
||||
<string name="wow_chat_mic_denied">语音输入需要麦克风权限。</string>
|
||||
</resources>
|
||||
@@ -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>
|
||||
@@ -554,6 +543,19 @@
|
||||
<string name="comp_tag_in_use">使用中</string>
|
||||
<string name="comp_tag_new">新</string>
|
||||
<string name="comp_tag_stored">已儲存</string>
|
||||
<string name="comp_mode_title">版本的選擇方式</string>
|
||||
<string name="comp_mode_auto_detail">最新 DroidDeck 版本,持續保持更新</string>
|
||||
<string name="comp_mode_manual_detail">自行選擇並安裝版本</string>
|
||||
<string name="comp_auto_title">Auto 會令 %1$s 保持最新</string>
|
||||
<string name="comp_auto_hint">新版本會在背景安裝。如果之前能執行的遊戲兩次在幾秒內關閉,Auto 會退回到之前可用的版本。</string>
|
||||
<string name="comp_auto_fallback">備用:%1$s</string>
|
||||
<string name="comp_auto_checking">正在檢查版本…</string>
|
||||
<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_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>
|
||||
<string name="perf_title">效能</string>
|
||||
<string name="perf_lede">CPU 核心、會話修正和主機名稱。下次會話生效。</string>
|
||||
<string name="perf_client_cores_group">Steam 用戶端核心</string>
|
||||
@@ -2204,9 +2206,6 @@
|
||||
<string name="source_added">自訂</string>
|
||||
<string name="setup_stores">商店</string>
|
||||
<string name="setup_stores_speed">下載速度等級</string>
|
||||
<string name="setup_stores_engine">商店引擎</string>
|
||||
<string name="setup_stores_engine_chip_ready">● 引擎已就緒</string>
|
||||
<string name="setup_stores_engine_chip_missing">未建置</string>
|
||||
<string name="stores_tier_balanced">平衡</string>
|
||||
<string name="stores_tier_fast">快速</string>
|
||||
<string name="stores_tier_max">最高</string>
|
||||
@@ -3649,4 +3648,12 @@
|
||||
<string name="setup_gpu_bridged">GPU 透過 Mali 橋接</string>
|
||||
<string name="gpuinfo_bridge">透過 Mali 橋接執行,使用手機內建的 Vulkan 驅動程式</string>
|
||||
<string name="gpuinfo_bridge_off">關閉 Mali 橋接時不受支援:Turnip 需要 Adreno(Snapdragon)GPU</string>
|
||||
<string name="wow_chat_title">聊天輸入</string>
|
||||
<string name="wow_chat_voice">語音輸入</string>
|
||||
<string name="wow_chat_remember">記住此選擇</string>
|
||||
<string name="wow_chat_ask">每次詢問</string>
|
||||
<string name="wow_chat_send">傳送</string>
|
||||
<string name="wow_chat_listening">正在聆聽…</string>
|
||||
<string name="wow_chat_voice_unavailable">此裝置不支援語音輸入。</string>
|
||||
<string name="wow_chat_mic_denied">語音輸入需要咪高峰權限。</string>
|
||||
</resources>
|
||||
@@ -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>
|
||||
@@ -554,6 +543,19 @@
|
||||
<string name="comp_tag_in_use">使用中</string>
|
||||
<string name="comp_tag_new">新</string>
|
||||
<string name="comp_tag_stored">已儲存</string>
|
||||
<string name="comp_mode_title">版本的選擇方式</string>
|
||||
<string name="comp_mode_auto_detail">最新 DroidDeck 版本,持續保持更新</string>
|
||||
<string name="comp_mode_manual_detail">自行選擇並安裝版本</string>
|
||||
<string name="comp_auto_title">Auto 會讓 %1$s 保持最新</string>
|
||||
<string name="comp_auto_hint">新版本會在背景安裝。如果先前能執行的遊戲兩次在幾秒內關閉,Auto 會退回到先前可用的版本。</string>
|
||||
<string name="comp_auto_fallback">備用:%1$s</string>
|
||||
<string name="comp_auto_checking">正在檢查版本…</string>
|
||||
<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_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>
|
||||
<string name="perf_title">效能</string>
|
||||
<string name="perf_lede">CPU 核心、工作階段修復和主機名稱。下次工作階段生效。</string>
|
||||
<string name="perf_client_cores_group">Steam 用戶端核心</string>
|
||||
@@ -2204,9 +2206,6 @@
|
||||
<string name="source_added">自訂</string>
|
||||
<string name="setup_stores">遊戲商店</string>
|
||||
<string name="setup_stores_speed">下載速度等級</string>
|
||||
<string name="setup_stores_engine">商店引擎</string>
|
||||
<string name="setup_stores_engine_chip_ready">● 引擎已就緒</string>
|
||||
<string name="setup_stores_engine_chip_missing">尚未組建</string>
|
||||
<string name="stores_tier_balanced">平衡</string>
|
||||
<string name="stores_tier_fast">快速</string>
|
||||
<string name="stores_tier_max">最高</string>
|
||||
@@ -3649,4 +3648,12 @@
|
||||
<string name="setup_gpu_bridged">GPU 透過 Mali 橋接</string>
|
||||
<string name="gpuinfo_bridge">透過 Mali 橋接執行,使用手機內建的 Vulkan 驅動程式</string>
|
||||
<string name="gpuinfo_bridge_off">關閉 Mali 橋接時不受支援:Turnip 需要 Adreno(Snapdragon)GPU</string>
|
||||
<string name="wow_chat_title">聊天輸入</string>
|
||||
<string name="wow_chat_voice">語音輸入</string>
|
||||
<string name="wow_chat_remember">記住此選擇</string>
|
||||
<string name="wow_chat_ask">每次詢問</string>
|
||||
<string name="wow_chat_send">傳送</string>
|
||||
<string name="wow_chat_listening">正在聆聽…</string>
|
||||
<string name="wow_chat_voice_unavailable">此裝置不支援語音輸入。</string>
|
||||
<string name="wow_chat_mic_denied">語音輸入需要麥克風權限。</string>
|
||||
</resources>
|
||||
@@ -37,14 +37,34 @@
|
||||
<string name="wall_settings_speed" translatable="false">Scroll speed</string>
|
||||
<string-array name="loading_hints">
|
||||
<item>First run downloads Steam. This may take a few minutes.</item>
|
||||
<item>By default, leaving the app pauses Steam; it picks up where it left off when you come back.</item>
|
||||
<item>Your controller appears as an Xbox 360 pad. Otherwise, use the on-screen controls.</item>
|
||||
<item>Your controller shows up to Steam as a Steam Deck controller. Switch it to a Steam Controller, DualShock 4, DualSense or Xbox 360 pad in the session settings; it applies next session.</item>
|
||||
<item>Hold Back on your controller to open Steam\'s Quick Access menu.</item>
|
||||
<item>Background behavior decides what happens when you leave the app: Native readies Steam for sleep, Auto pauses it, Manual waits for Resume, Never keeps it running.</item>
|
||||
<item>Steam games use Steam\'s Frame Limit in Quick Access. The display switches to a refresh rate the cap divides evenly.</item>
|
||||
<item>Low latency mode in the session menu sends controller input at 1000 Hz instead of 250 Hz, for a bit more CPU and battery.</item>
|
||||
<item>Each game has its own profile with Proton, FEX, components and environment settings.</item>
|
||||
<item>The session menu has a microphone toggle for voice chat and a Suspend now button to freeze the session on demand.</item>
|
||||
<item>Drag the DroidDeck HUD to move it, pinch to resize it, and push it off an edge to dock it as a small FPS tab.</item>
|
||||
<item>On dual-screen handhelds, the bottom screen can show downloads, notifications, screenshots and Steam chat. Swap screens puts the game down there instead.</item>
|
||||
<item>Add Steam libraries from an SD card, USB drive or SSD, and DroidDeck keeps the games together in your Library.</item>
|
||||
<item>Stores lets you sign in to GOG, itch.io, Battle.net, Amazon Games and Game Jolt, then install and update their games from DroidDeck.</item>
|
||||
<item>Components like FEX, DXVK and VKD3D-Proton update themselves, and DroidDeck-Proton is the default.</item>
|
||||
<item>Compress Steam while a game runs frees RAM on devices that run low.</item>
|
||||
<item>Swipe in from the right edge to open the session menu: touchpad mode, PC keyboard, low latency, game profile and more.</item>
|
||||
<item>Game running slow? Cap the frame rate in Quick Access, then try another Proton version or FEX preset in the game's components.</item>
|
||||
<item>Crackling or missing game audio? Try the DirectAudio for games toggle in Steam settings under Audio.</item>
|
||||
<item>Setup, then Storage, can clear shader caches and leftover prefixes from games you removed.</item>
|
||||
<item>Hitting a bug? Share logs in the session menu bundles diagnostics. Post them in #bug-reports on Discord.</item>
|
||||
</string-array>
|
||||
<string-array name="loading_hints_desktop">
|
||||
<item>First run sets up the desktop. This may take a few minutes.</item>
|
||||
<item>By default, leaving the app pauses the session; it picks up where it left off when you come back.</item>
|
||||
<item>Touch works as a touchpad: drag to move the pointer, tap to click.</item>
|
||||
<item>Your controller appears as an Xbox 360 pad. Otherwise, use the on-screen controls.</item>
|
||||
<item>Background behavior decides what happens when you leave the app: Manual waits for Resume by default, Auto pauses, Never keeps it running.</item>
|
||||
<item>Swipe in from the right edge to open the session menu, then pick PC keyboard to type in apps that ignore the Android keyboard.</item>
|
||||
<item>The PC keyboard in the session menu has adjustable size, opacity and key groups.</item>
|
||||
<item>Your controller shows up as an Xbox 360 pad. Otherwise, use the on-screen controls.</item>
|
||||
<item>The session menu has a microphone toggle and a Suspend now button to freeze the session on demand.</item>
|
||||
<item>Hitting a bug? Share logs in the session menu bundles diagnostics. Post them in #bug-reports on Discord.</item>
|
||||
</string-array>
|
||||
<string name="session_channel">Session</string>
|
||||
<string name="session_channel_description">Shows while a Steam or desktop session is open</string>
|
||||
@@ -604,6 +624,19 @@
|
||||
<string name="comp_tag_in_use">In use</string>
|
||||
<string name="comp_tag_new">New</string>
|
||||
<string name="comp_tag_stored">Stored</string>
|
||||
<string name="comp_mode_title">How builds are chosen</string>
|
||||
<string name="comp_mode_auto_detail">Newest DroidDeck build, kept current</string>
|
||||
<string name="comp_mode_manual_detail">Pick and install builds yourself</string>
|
||||
<string name="comp_auto_title">Auto keeps %1$s current</string>
|
||||
<string name="comp_auto_hint">New builds install in the background. If a game that ran before closes within seconds twice, Auto goes back to the build that worked.</string>
|
||||
<string name="comp_auto_fallback">Fallback: %1$s</string>
|
||||
<string name="comp_auto_checking">Checking for builds…</string>
|
||||
<string name="comp_auto_none">No DroidDeck-Proton uses this</string>
|
||||
<string name="comp_auto_waits_network">Auto updates wait for a connection.</string>
|
||||
<string name="comp_auto_check_failed">Couldn\'t check for new builds.</string>
|
||||
<string name="comp_auto_waits_unmetered">New builds wait for an unmetered connection, battery saver off and a cool device.</string>
|
||||
<string name="comp_auto_updated">%1$s updated to %2$s.</string>
|
||||
<string name="comp_auto_rolled_back">%1$s %2$s closed a game within seconds twice, so Auto went back to %3$s.</string>
|
||||
<string name="perf_title">Performance</string>
|
||||
<string name="perf_lede">CPU cores, session fixes and the host name. Applies next session.</string>
|
||||
<string name="perf_client_cores_group">Steam client cores</string>
|
||||
@@ -2454,9 +2487,6 @@
|
||||
<string name="source_added">Custom</string>
|
||||
<string name="setup_stores">Stores</string>
|
||||
<string name="setup_stores_speed">Download speed tier</string>
|
||||
<string name="setup_stores_engine">Stores engine</string>
|
||||
<string name="setup_stores_engine_chip_ready">● Engine ready</string>
|
||||
<string name="setup_stores_engine_chip_missing">Not built</string>
|
||||
<string name="stores_tier_balanced">Balanced</string>
|
||||
<string name="stores_tier_fast">Fast</string>
|
||||
<string name="stores_tier_max">Max</string>
|
||||
@@ -3834,4 +3864,12 @@
|
||||
<string name="stores_gamejolt_filter_status">Status</string>
|
||||
<string name="stores_gamejolt_filter_partners">Partners</string>
|
||||
<string name="stores_gamejolt_followed">Followed games</string>
|
||||
<string name="wow_chat_title">Chat input</string>
|
||||
<string name="wow_chat_voice">Voice type</string>
|
||||
<string name="wow_chat_remember">Remember this selection</string>
|
||||
<string name="wow_chat_ask">Ask each time</string>
|
||||
<string name="wow_chat_send">Send</string>
|
||||
<string name="wow_chat_listening">Listening…</string>
|
||||
<string name="wow_chat_voice_unavailable">Voice typing is not available on this device.</string>
|
||||
<string name="wow_chat_mic_denied">Microphone access is needed for voice typing.</string>
|
||||
</resources>
|
||||
@@ -134,6 +134,19 @@ class ProtonMenuAutoTest {
|
||||
assertEquals(listOf("droiddeck"), attempts)
|
||||
}
|
||||
|
||||
@Test fun aFinishedInstallStartsTheComponentCheckAndAFailedOneDoesNot() {
|
||||
var checks = 0
|
||||
val watched = ProtonMenu(activity, Handler(Looper.getMainLooper()), onInstalled = { checks++ }) { idle }
|
||||
watched.installer = { tool, _ -> install("${tool.prefix}-11-12"); null }
|
||||
watched.installProton("droiddeck")
|
||||
waitUntil { watched.protonBusyId == null && checks == 1 }
|
||||
assertEquals(1, checks)
|
||||
watched.installer = { _, _ -> "failed" }
|
||||
watched.installProton("ge")
|
||||
waitUntil { watched.protonBusyId == null }
|
||||
assertEquals(1, checks)
|
||||
}
|
||||
|
||||
@Test fun aFailedUpdateDoesNotLoopOrBlockOtherInstalledFamilies() {
|
||||
val ge = install("GE-Proton11-11")
|
||||
install("DroidDeck-Proton-11-11.1")
|
||||
|
||||
@@ -117,7 +117,10 @@ class SteamLaunchOptionsTest {
|
||||
assertTrue(SteamLaunchOptions.write(home, 1790600, "-dx12"))
|
||||
config.writeText(SteamLaunchOptions.withLaunchOptions(config.readText(), 1790600, "-vulkan")!!)
|
||||
assertEquals("-vulkan", SteamLaunchOptions.steam(home, 1790600))
|
||||
assertNull(SteamLaunchOptions.steam(home, 400))
|
||||
assertEquals("", SteamLaunchOptions.steam(home, 400))
|
||||
assertNull(SteamLaunchOptions.steam(steamHome(tmp.newFolder("Unread")), 1790600))
|
||||
config.writeText("")
|
||||
assertNull(SteamLaunchOptions.steam(home, 1790600))
|
||||
}
|
||||
|
||||
private val app get() = RuntimeEnvironment.getApplication()
|
||||
|
||||
@@ -139,6 +139,36 @@ class DroidDeckHudPanelTest {
|
||||
assertFalse(line, "BAT" in line)
|
||||
}
|
||||
|
||||
@Test fun stripGraphClearsThePreviousTraceWhenFrameHistoryChanges() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val parent = FrameLayout(app)
|
||||
val view = DroidDeckHudView(app)
|
||||
parent.addView(view)
|
||||
parent.layout(0, 0, 2400, 1080)
|
||||
val stats = FrameStats()
|
||||
var now = 1_000_000_000L
|
||||
repeat(300) { now += 25_000_000L; stats.add(25_000_000L, now) }
|
||||
view.setFrameStats(stats)
|
||||
view.applyConfig(DroidDeckHudConfig.encode(DroidDeckHudConfig.DEFAULTS +
|
||||
("hudSize" to "strip") + ("showFPSGraph" to "1") + ("hudOpacity" to "100")))
|
||||
view.showForTest(SNAPSHOT, "DXVK", now)
|
||||
val unspecified = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
|
||||
view.measure(unspecified, unspecified)
|
||||
view.layout(0, 0, view.measuredWidth, view.measuredHeight)
|
||||
fun render() = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888).also {
|
||||
view.draw(Canvas(it))
|
||||
}
|
||||
val graph = requireNotNull(view.graphForTest())
|
||||
val x = (graph.left + graph.width() * 0.75f).toInt()
|
||||
val y = graph.centerY().toInt()
|
||||
val before = render()
|
||||
stats.reset()
|
||||
val after = render()
|
||||
assertTrue("the graph initially crosses its midpoint", before.getPixel(x, y) != after.getPixel(x, y))
|
||||
assertEquals("the previous trace is cleared to the strip background",
|
||||
after.getPixel(x, graph.top.toInt() + 2), after.getPixel(x, y))
|
||||
}
|
||||
|
||||
@Test fun stripFollowsTheHudScaleInHeightAndKeepsTheFullWidth() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val parent = FrameLayout(app)
|
||||
|
||||
@@ -169,6 +169,59 @@ class OnScreenControlsTest {
|
||||
assertEquals(true, field(control("ls"), "clicked"))
|
||||
}
|
||||
|
||||
@Test fun aCancelledSecondFingerDoesNotArmAStickClick() {
|
||||
touch(MotionEvent.ACTION_DOWN, 7 to (660f to 400f))
|
||||
touch(MotionEvent.ACTION_POINTER_DOWN or (1 shl 8), 7 to (660f to 400f), 9 to (540f to 400f))
|
||||
touch(MotionEvent.ACTION_POINTER_UP or (1 shl 8), 7 to (660f to 400f), 9 to (540f to 400f), flags = MotionEvent.FLAG_CANCELED)
|
||||
touch(MotionEvent.ACTION_POINTER_DOWN or (1 shl 8), 7 to (660f to 400f), 9 to (540f to 400f))
|
||||
assertEquals(false, field(control("ls"), "clicked"))
|
||||
touch(MotionEvent.ACTION_POINTER_UP or (1 shl 8), 7 to (660f to 400f), 9 to (540f to 400f))
|
||||
touch(MotionEvent.ACTION_POINTER_DOWN or (1 shl 8), 7 to (660f to 400f), 9 to (540f to 400f))
|
||||
assertEquals(true, field(control("ls"), "clicked"))
|
||||
}
|
||||
|
||||
@Test fun movingTheStickAndTouchingItAgainDoesNotClickIt() {
|
||||
touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f))
|
||||
touch(MotionEvent.ACTION_MOVE, 1 to (600f to 400f))
|
||||
touch(MotionEvent.ACTION_UP, 1 to (600f to 400f))
|
||||
touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f))
|
||||
assertEquals(false, field(control("ls"), "clicked"))
|
||||
}
|
||||
|
||||
@Test fun liftingFarFromTheTouchPointDoesNotArmAStickClick() {
|
||||
touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f))
|
||||
touch(MotionEvent.ACTION_UP, 1 to (600f to 400f))
|
||||
touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f))
|
||||
assertEquals(false, field(control("ls"), "clicked"))
|
||||
}
|
||||
|
||||
@Test fun aLongHoldThenAQuickTouchDoesNotClickTheStick() {
|
||||
touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f))
|
||||
time += 600
|
||||
touch(MotionEvent.ACTION_UP, 1 to (540f to 400f))
|
||||
touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f))
|
||||
assertEquals(false, field(control("ls"), "clicked"))
|
||||
}
|
||||
|
||||
@Test fun aFastDoubleTapClicksTheStickAndCanBeHeldWhileMoving() {
|
||||
touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f))
|
||||
touch(MotionEvent.ACTION_UP, 1 to (540f to 400f))
|
||||
touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f))
|
||||
touch(MotionEvent.ACTION_MOVE, 1 to (600f to 400f))
|
||||
assertEquals(true, field(control("ls"), "clicked"))
|
||||
touch(MotionEvent.ACTION_UP, 1 to (600f to 400f))
|
||||
touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f))
|
||||
assertEquals(false, field(control("ls"), "clicked"))
|
||||
}
|
||||
|
||||
@Test fun aSlowSecondTapDoesNotClickTheStick() {
|
||||
touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f))
|
||||
touch(MotionEvent.ACTION_UP, 1 to (540f to 400f))
|
||||
time += 400
|
||||
touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f))
|
||||
assertEquals(false, field(control("ls"), "clicked"))
|
||||
}
|
||||
|
||||
@Test fun buttonsTakePriorityOverAdaptiveRegions() {
|
||||
val button = control("a")
|
||||
touch(MotionEvent.ACTION_DOWN, 3 to (value(button, "cx") to value(button, "cy")))
|
||||
@@ -461,10 +514,10 @@ class OnScreenControlsTest {
|
||||
return color
|
||||
}
|
||||
|
||||
private fun touch(action: Int, vararg pointers: Pair<Int, Pair<Float, Float>>): Boolean {
|
||||
private fun touch(action: Int, vararg pointers: Pair<Int, Pair<Float, Float>>, flags: Int = 0): Boolean {
|
||||
val properties = pointers.map { (id, _) -> MotionEvent.PointerProperties().apply { this.id = id; toolType = MotionEvent.TOOL_TYPE_FINGER } }.toTypedArray()
|
||||
val coords = pointers.map { (_, position) -> MotionEvent.PointerCoords().apply { x = position.first; y = position.second; pressure = 1f; size = 1f } }.toTypedArray()
|
||||
val event = MotionEvent.obtain(1000L, time++, action, pointers.size, properties, coords, 0, 0, 1f, 1f, 0, 0, 0, 0)
|
||||
val event = MotionEvent.obtain(1000L, time++, action, pointers.size, properties, coords, 0, 0, 1f, 1f, 0, 0, 0, flags)
|
||||
return try { view.onTouchEvent(event) } finally { event.recycle() }
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,23 @@ class RuntimeAutoUpdateTest {
|
||||
assertTrue(RuntimeAutoUpdate.claim(context, "ubuntu-r1-rc1", "ubuntu-r1-rc2"))
|
||||
}
|
||||
|
||||
@Test fun noRuntimeOrTheSameOneIsLeftAlone() {
|
||||
@Test fun aFirstLaunchInstallsTheRuntimeByItself() {
|
||||
assertTrue(RuntimeAutoUpdate.claim(context, null, "ubuntu-r1-rc2"))
|
||||
}
|
||||
|
||||
@Test fun aRuntimeTheUserRemovedIsNotPutBack() {
|
||||
RuntimeAutoUpdate.seed(context)
|
||||
assertFalse(RuntimeAutoUpdate.claim(context, null, "ubuntu-r1-rc2"))
|
||||
assertTrue(RuntimeAutoUpdate.claim(context, "ubuntu-r1-rc1", "ubuntu-r1-rc2"))
|
||||
}
|
||||
|
||||
@Test fun aFailingFirstInstallStopsTryingAfterAFewLaunches() {
|
||||
repeat(RuntimeAutoUpdate.MAX_ATTEMPTS) { assertTrue(RuntimeAutoUpdate.claim(context, null, "ubuntu-r1-rc2", it * RuntimeAutoUpdate.RETRY_MS)) }
|
||||
assertFalse(RuntimeAutoUpdate.claim(context, null, "ubuntu-r1-rc2", 10 * RuntimeAutoUpdate.RETRY_MS))
|
||||
}
|
||||
|
||||
@Test fun theSameOrNoReleaseIsLeftAlone() {
|
||||
assertFalse(RuntimeAutoUpdate.claim(context, null, null))
|
||||
assertFalse(RuntimeAutoUpdate.claim(context, "ubuntu-r1-rc2", "ubuntu-r1-rc2"))
|
||||
assertFalse(RuntimeAutoUpdate.claim(context, "ubuntu-r1-rc1", null))
|
||||
assertFalse(RuntimeAutoUpdate.claim(context, "ubuntu-r1-rc1", ""))
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.github.luben.zstd.ZstdOutputStream
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveEntry
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.nio.file.Files
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ComponentUpdatesTest {
|
||||
private fun item(file: String, comp: String, release: String = ComponentsManager.DD_NIGHTLY) =
|
||||
ComponentsManager.CatalogItem(file, comp, release, "https://github.com/Droid-Deck/DroidDeck-Components/releases/download/x/$file", 1, "sha256:" + "0".repeat(64))
|
||||
|
||||
private val catalog = listOf(
|
||||
item("DD-dxvk-3.1.1+188-nightly.wcp", "dxvk"),
|
||||
item("DD-dxvk-3.1.1+190-nightly.wcp", "dxvk"),
|
||||
item("DD-dxvk-arm64ec-3.1.1+188-nightly.wcp", "dxvk"),
|
||||
item("DD-dxvk-gplasync-3.1.1+190-nightly.wcp", "dxvk"),
|
||||
item("dxvk-3.1.1-linux.wcp", "dxvk", "Dxvk-Linux"),
|
||||
item("DD-vkd3d-proton-3.1+30-nightly.wcp", "vkd3d"),
|
||||
item("DD-vkd3d-proton-arm64ec-3.1+30-nightly.wcp", "vkd3d"),
|
||||
item("DD-FEXCore-2611+3-nightly.wcp", "fex"),
|
||||
)
|
||||
|
||||
@Test fun eachLineFollowsOnlyItsOwnPublishedFlavour() {
|
||||
assertEquals("DD-dxvk-3.1.1+190-nightly.wcp", ComponentUpdates.pick(catalog, ComponentUpdates.line("dxvk", false), emptySet())?.file)
|
||||
assertEquals("DD-dxvk-arm64ec-3.1.1+188-nightly.wcp", ComponentUpdates.pick(catalog, ComponentUpdates.line("dxvk", true), emptySet())?.file)
|
||||
assertEquals("DD-vkd3d-proton-arm64ec-3.1+30-nightly.wcp", ComponentUpdates.pick(catalog, ComponentUpdates.line("vkd3d", true), emptySet())?.file)
|
||||
assertEquals("DD-vkd3d-proton-3.1+30-nightly.wcp", ComponentUpdates.pick(catalog, ComponentUpdates.line("vkd3d", false), emptySet())?.file)
|
||||
assertEquals("DD-FEXCore-2611+3-nightly.wcp", ComponentUpdates.pick(catalog, ComponentUpdates.line("fex", true), emptySet())?.file)
|
||||
assertEquals("fex", ComponentUpdates.line("fex", false).key)
|
||||
}
|
||||
|
||||
@Test fun aBadBuildIsSkippedAndNothingElseIsPickedInItsPlace() {
|
||||
val plain = ComponentUpdates.line("dxvk", false)
|
||||
assertNull(ComponentUpdates.pick(catalog, plain, setOf("DD-dxvk-3.1.1+190-nightly.wcp")))
|
||||
assertEquals("DD-dxvk-3.1.1+190-nightly.wcp", ComponentUpdates.pick(catalog, plain, setOf("DD-dxvk-3.1.1+188-nightly.wcp"))?.file)
|
||||
assertEquals("DD-dxvk-3.1.1+190-nightly.wcp", ComponentUpdates.pick(catalog, plain, setOf("DD-vkd3d-proton-3.1+40-nightly.wcp"))?.file)
|
||||
assertNull(ComponentUpdates.pick(catalog, ComponentUpdates.line("fex", false), setOf("DD-FEXCore-2611+3-nightly.wcp")))
|
||||
assertNull(ComponentUpdates.pick(catalog.filter { it.release != ComponentsManager.DD_NIGHTLY }, plain, emptySet()))
|
||||
}
|
||||
|
||||
private fun pe(machine: Int, pe32plus: Boolean, hybrid: Boolean?): ByteArray {
|
||||
val b = ByteBuffer.allocate(0x800).order(ByteOrder.LITTLE_ENDIAN)
|
||||
b.put(0, 'M'.code.toByte()).put(1, 'Z'.code.toByte()).putInt(0x3c, 0x80)
|
||||
b.putInt(0x80, 0x4550).putShort(0x84, machine.toShort()).putShort(0x86, 1)
|
||||
val optional = 0x98
|
||||
val optionalSize = if (pe32plus) 240 else 224
|
||||
b.putShort(0x94, optionalSize.toShort())
|
||||
b.putShort(optional, if (pe32plus) 0x20b.toShort() else 0x10b.toShort())
|
||||
val section = optional + optionalSize
|
||||
b.putInt(section + 8, 0x1000).putInt(section + 12, 0x1000).putInt(section + 20, 0x400)
|
||||
if (hybrid != null && pe32plus) {
|
||||
b.putInt(optional + 112 + 10 * 8, 0x1100)
|
||||
b.putInt(0x500, 0x140)
|
||||
if (hybrid) b.putLong(0x500 + 0xC8, 0x180001000L)
|
||||
}
|
||||
return b.array()
|
||||
}
|
||||
|
||||
@Test fun arm64ecAndArm64xImagesAreToldApartFromPlainX64ByTheirHybridMetadata() {
|
||||
assertEquals(true, ComponentUpdates.arm64ec(pe(0xaa64, true, true)))
|
||||
assertEquals(true, ComponentUpdates.arm64ec(pe(0x8664, true, true)))
|
||||
assertEquals(false, ComponentUpdates.arm64ec(pe(0x8664, true, false)))
|
||||
assertEquals(false, ComponentUpdates.arm64ec(pe(0x8664, true, null)))
|
||||
assertEquals(false, ComponentUpdates.arm64ec(pe(0x14c, false, null)))
|
||||
assertNull(ComponentUpdates.arm64ec(ByteArray(16)))
|
||||
}
|
||||
|
||||
@Test fun onlyAHybridArm64ImageCountsAsArm64x() {
|
||||
assertTrue(ComponentUpdates.arm64x(pe(0xaa64, true, true)))
|
||||
assertFalse(ComponentUpdates.arm64x(pe(0x8664, true, true)))
|
||||
assertFalse(ComponentUpdates.arm64x(pe(0xaa64, true, false)))
|
||||
assertFalse(ComponentUpdates.arm64x(ByteArray(16)))
|
||||
}
|
||||
|
||||
@Test fun anArm64ecLineNeedsArm64xUnlessTheProtonsOwnBuildIsKnownNotToBe() {
|
||||
val ec = ComponentUpdates.line("dxvk", true)
|
||||
assertTrue(ComponentUpdates.needsArm64x(ec, listOf(true)))
|
||||
assertTrue(ComponentUpdates.needsArm64x(ec, listOf(null)))
|
||||
assertFalse(ComponentUpdates.needsArm64x(ec, listOf(false)))
|
||||
assertTrue(ComponentUpdates.needsArm64x(ec, listOf(false, true)))
|
||||
assertFalse(ComponentUpdates.needsArm64x(ec, listOf(false, false)))
|
||||
assertFalse(ComponentUpdates.needsArm64x(ComponentUpdates.line("dxvk", false), listOf(null)))
|
||||
assertFalse(ComponentUpdates.needsArm64x(ComponentUpdates.line("fex", false), listOf(null)))
|
||||
}
|
||||
|
||||
private val log = """
|
||||
[2026-11-05 20:00:00] Game process added : AppID 49520 "/root/.local/share/Steam/linuxarm64/steam-launch-wrapper -- /root/.local/share/Steam/steamrtarm64/reaper SteamLaunch AppId=49520 -- '/root/.local/share/Steam/compatibilitytools.d/DroidDeck-Proton 11-12.1'/droiddeck-proton-wrap waitforexitandrun 'Borderlands2.exe'"
|
||||
[2026-11-05 20:00:05] Game process updated : AppID 49520 "x"
|
||||
[2026-11-05 20:10:00] Game process removed: AppID 49520 "x"
|
||||
[2026-11-05 20:11:00] Game process added : AppID 413150 "/root/.local/share/Steam/steamapps/common/Proton - Experimental/proton waitforexitandrun 'Stardew Valley.exe'"
|
||||
[2026-11-05 20:11:12] Game process removed: AppID 413150 "x"
|
||||
[2026-11-05 20:12:00] Game process added : AppID 22380 "/usr/bin/true no tool here"
|
||||
[2026-11-05 20:12:02] Game process removed: AppID 22380 "x"
|
||||
""".trimIndent().lines().asSequence()
|
||||
|
||||
@Test fun launchesComeFromSteamsOwnProcessLinesWithTheirProtonAndDuration() {
|
||||
val runs = ComponentUpdates.launches(log, "")
|
||||
assertEquals(listOf(
|
||||
ComponentUpdates.Launch("49520", "DroidDeck-Proton 11-12.1", "2026-11-05 20:00:00", 600),
|
||||
ComponentUpdates.Launch("413150", "Proton - Experimental", "2026-11-05 20:11:00", 12),
|
||||
), runs)
|
||||
assertEquals(listOf("413150"), ComponentUpdates.launches(log, "2026-11-05 20:10:30").map { it.app })
|
||||
}
|
||||
|
||||
private fun run(app: String, start: String, seconds: Long) = ComponentUpdates.Launch(app, "DroidDeck-Proton 11-12.1", start, seconds)
|
||||
|
||||
private val trial = ComponentUpdates.Trial("new.wcp", ComponentUpdates.ORIGINAL, "2026-11-05 12:00:00", emptyMap(), emptyMap())
|
||||
|
||||
@Test fun aGameThatBootedOnThePreviousBuildAndTwiceFailsOnTheNewOneRollsTheLineBack() {
|
||||
val verdict = ComponentUpdates.judge(trial, listOf(
|
||||
run("100", "2026-11-05 10:00:00", 900),
|
||||
run("100", "2026-11-05 13:00:00", 4),
|
||||
run("100", "2026-11-05 13:05:00", 6),
|
||||
))
|
||||
assertTrue(verdict.rollBack)
|
||||
assertEquals(2, verdict.failed["100"])
|
||||
}
|
||||
|
||||
@Test fun oneFailedBootOrAGameThatNeverBootedBeforeDoesNotRollBack() {
|
||||
assertFalse(ComponentUpdates.judge(trial, listOf(run("100", "2026-11-05 10:00:00", 900), run("100", "2026-11-05 13:00:00", 4))).rollBack)
|
||||
assertFalse(ComponentUpdates.judge(trial, listOf(run("200", "2026-11-05 13:00:00", 4), run("200", "2026-11-05 13:05:00", 4))).rollBack)
|
||||
}
|
||||
|
||||
@Test fun aGameThatBootsOnTheNewBuildStopsCountingShortRunsAsFailures() {
|
||||
val verdict = ComponentUpdates.judge(trial, listOf(
|
||||
run("100", "2026-11-05 10:00:00", 900),
|
||||
run("100", "2026-11-05 13:00:00", 4),
|
||||
run("100", "2026-11-05 13:10:00", 1200),
|
||||
run("100", "2026-11-05 14:00:00", 4),
|
||||
))
|
||||
assertFalse(verdict.rollBack)
|
||||
assertEquals("new.wcp", verdict.booted["100"])
|
||||
assertNull(verdict.failed["100"])
|
||||
}
|
||||
|
||||
@Test fun bootsOnTheOriginalBeforeTheFirstUpdateLetTheFirstBuildRollBack() {
|
||||
val original = ComponentUpdates.Trial(ComponentUpdates.ORIGINAL, ComponentUpdates.ORIGINAL, "", emptyMap(), emptyMap())
|
||||
val before = ComponentUpdates.judge(original, listOf(run("100", "2026-11-05 10:00:00", 900), run("200", "2026-11-05 10:30:00", 3)))
|
||||
assertFalse(before.rollBack)
|
||||
assertEquals(mapOf("100" to ComponentUpdates.ORIGINAL), before.booted)
|
||||
val after = ComponentUpdates.judge(trial.copy(booted = before.booted), listOf(run("100", "2026-11-05 13:00:00", 4), run("100", "2026-11-05 13:05:00", 6)))
|
||||
assertTrue(after.rollBack)
|
||||
}
|
||||
|
||||
@Test fun aProtonUpdatedInPlaceIsReadFromItsOwnFilesAgain() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val dir = Files.createTempDirectory(context.cacheDir.toPath(), "proton").toFile()
|
||||
File(dir, "files/lib/wine/dxvk/aarch64-windows").apply { mkdirs() }.resolve("d3d11.dll").writeBytes(pe(0xaa64, true, true))
|
||||
val proton = ComponentsManager.Proton("Test-Proton", "Test Proton", dir, "/p", "Test-Proton-2", false)
|
||||
val state = File(context.filesDir, "components/state.json").apply { parentFile?.mkdirs() }
|
||||
fun active(version: String) = state.writeText(JSONObject().put("active", JSONObject().put("Test-Proton", JSONObject()
|
||||
.put("dxvk", JSONObject().put("file", "DD-dxvk-arm64ec-3.1.1+188-nightly.wcp").put("protonVersion", version)))).toString())
|
||||
|
||||
active("Test-Proton-1")
|
||||
assertEquals(true, ComponentsManager.arm64ec(context, proton, "dxvk"))
|
||||
active("Test-Proton-2")
|
||||
assertNull(ComponentsManager.arm64ec(context, proton, "dxvk"))
|
||||
}
|
||||
|
||||
@Test fun aFailedSaveKeepsTheLastGoodStateAndTheNextSaveReplacesItWhole() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val dir = File(context.filesDir, "components").apply { mkdirs() }
|
||||
val state = File(dir, "updates.json")
|
||||
val saved = JSONObject().put("checkedAt", System.currentTimeMillis()).put("lines", JSONObject().put("fex", JSONObject()
|
||||
.put("current", "DD-FEXCore-2611+3-nightly.wcp").put("bad", JSONArray().put("DD-FEXCore-2611+2-nightly.wcp")))).toString()
|
||||
state.writeText(saved)
|
||||
state.setWritable(false)
|
||||
dir.setWritable(false)
|
||||
try {
|
||||
org.junit.Assume.assumeFalse(state.canWrite() || dir.canWrite())
|
||||
ComponentUpdater.ensure(context, false)
|
||||
} finally {
|
||||
dir.setWritable(true)
|
||||
state.setWritable(true)
|
||||
}
|
||||
assertEquals(saved, state.readText())
|
||||
|
||||
ComponentUpdater.ensure(context, false)
|
||||
val fex = JSONObject(state.readText()).getJSONObject("lines").getJSONObject("fex")
|
||||
assertEquals("DD-FEXCore-2611+3-nightly.wcp", fex.getString("current"))
|
||||
assertEquals("DD-FEXCore-2611+2-nightly.wcp", fex.getJSONArray("bad").getString(0))
|
||||
assertFalse(state.readText() == saved)
|
||||
assertTrue(dir.list()!!.none { it.startsWith("updates.json.") })
|
||||
}
|
||||
|
||||
@Test fun aLineWithAnUnfinishedRollbackTakesNoNewerBuildUntilItIsApplied() {
|
||||
assertTrue(ComponentUpdater.rollingBack(JSONObject().put("restore", JSONObject().put("proton", "DD-dxvk-2.5.wcp"))))
|
||||
assertTrue(ComponentUpdater.rollingBack(JSONObject().put("pendingNote", "rolled back")))
|
||||
assertFalse(ComponentUpdater.rollingBack(JSONObject().put("restore", JSONObject()).put("current", "DD-dxvk-2.4.wcp")))
|
||||
}
|
||||
|
||||
@Test fun aRemovedProtonNoLongerHoldsItsLineInARollback() {
|
||||
val line = JSONObject().put("restore", JSONObject().put("gone", "DD-dxvk-2.5.wcp"))
|
||||
val state = JSONObject().put("lines", JSONObject().put("dxvk", line))
|
||||
ComponentUpdater.dropRemoved(state, emptyList())
|
||||
assertFalse(ComponentUpdater.rollingBack(line))
|
||||
}
|
||||
|
||||
@Test fun aTwoDigitNightlyBuildIsNewerThanASingleDigitOne() {
|
||||
val items = listOf(item("DD-FEXCore-2611+9-nightly.wcp", "fex"), item("DD-FEXCore-2611+10-nightly.wcp", "fex"))
|
||||
assertEquals("DD-FEXCore-2611+10-nightly.wcp", ComponentUpdates.pick(items, ComponentUpdates.line("fex", false), emptySet())?.file)
|
||||
assertNull(ComponentUpdates.pick(items, ComponentUpdates.line("fex", false), setOf("DD-FEXCore-2611+10-nightly.wcp")))
|
||||
}
|
||||
|
||||
@Test fun readsDuringSavesNeverLoseTheState() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val state = File(context.filesDir, "components/updates.json").apply { parentFile?.mkdirs() }
|
||||
state.writeText(JSONObject().put("checkedAt", System.currentTimeMillis()).put("lines", JSONObject().put("fex", JSONObject()
|
||||
.put("current", "DD-FEXCore-2611+3-nightly.wcp").put("bad", JSONArray().put("DD-FEXCore-2611+2-nightly.wcp")))).toString())
|
||||
val done = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||
val reader = Thread {
|
||||
while (!done.get()) {
|
||||
ComponentUpdater.settle(context)
|
||||
ComponentUpdater.status(context, "fex")
|
||||
}
|
||||
}.apply { start() }
|
||||
try {
|
||||
repeat(400) { ComponentUpdater.ensure(context, false) }
|
||||
} finally {
|
||||
done.set(true)
|
||||
reader.join()
|
||||
}
|
||||
val fex = JSONObject(state.readText()).getJSONObject("lines").getJSONObject("fex")
|
||||
assertEquals("DD-FEXCore-2611+2-nightly.wcp", fex.getJSONArray("bad").getString(0))
|
||||
}
|
||||
|
||||
private fun proton(dir: String, valve: Boolean = false, version: String = "$dir-1") =
|
||||
ComponentsManager.Proton(dir, dir, File("/steam/compatibilitytools.d/$dir"), "/p/$dir", version, valve)
|
||||
|
||||
@Test fun autoFollowsOnlyDroidDeckProton() {
|
||||
assertTrue(ComponentUpdates.follows(proton("DroidDeck-Proton-10.0-7")))
|
||||
assertFalse(ComponentUpdates.follows(proton("DroidDeck-Proton-10.0-7", valve = true)))
|
||||
assertFalse(ComponentUpdates.follows(proton("droiddeck-proton-arm64")))
|
||||
assertFalse(ComponentUpdates.follows(proton("droiddeck-proton-11-arm64")))
|
||||
assertFalse(ComponentUpdates.follows(proton("GE-Proton10-25")))
|
||||
assertFalse(ComponentUpdates.follows(proton("proton-cachyos-10.0-20251101-slr-arm64")))
|
||||
}
|
||||
|
||||
@Test fun onlyAutosOwnBuildsInOtherProtonsAreHandedBack() {
|
||||
val dd = proton("DroidDeck-Proton-10.0-7")
|
||||
val ge = proton("GE-Proton10-25")
|
||||
val valve = proton("Proton - Experimental", valve = true)
|
||||
val choices = mapOf(
|
||||
dd.id to mapOf("dxvk" to ComponentsManager.Choice("DD-dxvk-3.1.1+190-nightly.wcp", false, dd.version)),
|
||||
ge.id to mapOf(
|
||||
"dxvk" to ComponentsManager.Choice("DD-dxvk-3.1.1+190-nightly.wcp", false, ge.version),
|
||||
"vkd3d" to ComponentsManager.Choice("vkd3d-proton-3.0-mine.wcp", false, ge.version),
|
||||
"fex" to ComponentsManager.Choice("DD-FEXCore-2611+3-nightly.wcp", false, "GE-Proton10-24"),
|
||||
),
|
||||
valve.id to mapOf(
|
||||
"fex" to ComponentsManager.Choice("DD-FEXCore-2611+3-nightly.wcp", false, valve.version),
|
||||
"dxvk" to ComponentsManager.Choice("DD-dxvk-3.1.1+190-nightly.wcp", true, valve.version),
|
||||
),
|
||||
)
|
||||
val placed = setOf("DD-dxvk-3.1.1+190-nightly.wcp", "DD-FEXCore-2611+3-nightly.wcp")
|
||||
assertEquals(
|
||||
setOf(ge.id to "dxvk", valve.id to "fex"),
|
||||
ComponentUpdates.strays(listOf(dd, ge, valve), choices, placed).map { (p, c) -> p.id to c }.toSet(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun aProtonMissingFromDiscoveryKeepsTheHandBackPending() {
|
||||
val dd = proton("DroidDeck-Proton-10.0-7")
|
||||
val ge = proton("GE-Proton10-25")
|
||||
val placed = setOf("DD-dxvk-3.1.1+190-nightly.wcp")
|
||||
val choices = mapOf(ge.id to mapOf("dxvk" to ComponentsManager.Choice("DD-dxvk-3.1.1+190-nightly.wcp", false, ge.version)))
|
||||
assertTrue(ComponentUpdates.unseen(listOf(dd), choices, placed))
|
||||
assertFalse(ComponentUpdates.unseen(listOf(dd, ge), choices, placed))
|
||||
assertFalse(ComponentUpdates.unseen(listOf(dd), choices, emptySet()))
|
||||
}
|
||||
|
||||
@Test fun queuedAutoBuildsInOtherProtonsAreDroppedAndEverythingElseStays() {
|
||||
org.junit.Assume.assumeFalse(System.getProperty("os.name").startsWith("Windows"))
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val state = File(context.filesDir, "components/state.json").apply { parentFile?.mkdirs() }
|
||||
fun pkg(file: String, auto: Boolean = false) = JSONObject().put("kind", "package").put("file", file).put("label", file).apply { if (auto) put("auto", true) }
|
||||
state.writeText(JSONObject().put("queued", JSONObject()
|
||||
.put("GE-Proton10-25", JSONObject().put("dxvk", pkg("DD-dxvk-3.1.1+190-nightly.wcp")).put("vkd3d", pkg("vkd3d-proton-3.0-mine.wcp"))
|
||||
.put("fex", pkg("DD-FEXCore-2611+3-nightly.wcp", auto = true)))
|
||||
.put("proton-cachyos-10.0-arm64", JSONObject().put("dxvk", pkg("DD-dxvk-3.1.1+190-nightly.wcp")))
|
||||
.put("DroidDeck-Proton-10.0-7", JSONObject().put("dxvk", pkg("DD-dxvk-3.1.1+190-nightly.wcp")))).toString())
|
||||
val placed = setOf("DD-dxvk-3.1.1+190-nightly.wcp", "DD-FEXCore-2611+3-nightly.wcp")
|
||||
assertEquals(2, ComponentsManager.dropQueuedPackages(context, placed, ComponentUpdates::ownId))
|
||||
val queued = JSONObject(state.readText()).getJSONObject("queued")
|
||||
assertEquals(setOf("vkd3d", "fex"), queued.getJSONObject("GE-Proton10-25").keys().asSequence().toSet())
|
||||
assertFalse(queued.has("proton-cachyos-10.0-arm64"))
|
||||
assertTrue(queued.getJSONObject("DroidDeck-Proton-10.0-7").has("dxvk"))
|
||||
}
|
||||
|
||||
@Test fun aQueuedSwapIsNotReplacedByTheHandBack() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val state = File(context.filesDir, "components/state.json").apply { parentFile?.mkdirs() }
|
||||
val queued = JSONObject().put("kind", "package").put("file", "vkd3d-proton-3.0-mine.wcp").put("label", "3.0")
|
||||
state.writeText(JSONObject().put("queued", JSONObject().put("GE-Proton10-25", JSONObject().put("vkd3d", queued))).toString())
|
||||
assertFalse(ComponentsManager.restoreUnlessQueued(context, "GE-Proton10-25", "vkd3d", "GE-Proton10-25"))
|
||||
val after = JSONObject(state.readText()).getJSONObject("queued").getJSONObject("GE-Proton10-25").getJSONObject("vkd3d")
|
||||
assertEquals("vkd3d-proton-3.0-mine.wcp", after.getString("file"))
|
||||
}
|
||||
|
||||
@Test fun aSourceThatFailsToListKeepsItsPreviousPackages() {
|
||||
val previous = JSONArray()
|
||||
.put(JSONObject().put("file", "DD-dxvk-arm64ec-3.1.1+198-nightly.wcp").put("release", ComponentsManager.DD_NIGHTLY))
|
||||
.put(JSONObject().put("file", "dxvk-3.1.1-linux.wcp").put("release", "Dxvk-Linux"))
|
||||
val into = JSONArray()
|
||||
ComponentsManager.keepSource(previous, ComponentsManager.DD_NIGHTLY, into)
|
||||
assertEquals(1, into.length())
|
||||
assertEquals("DD-dxvk-arm64ec-3.1.1+198-nightly.wcp", into.getJSONObject(0).getString("file"))
|
||||
ComponentsManager.keepSource(null, ComponentsManager.DD_NIGHTLY, into)
|
||||
assertEquals(1, into.length())
|
||||
}
|
||||
|
||||
@Test fun everyBuildAutoEverPlacedCountsAsItsOwn() {
|
||||
val state = JSONObject().put("lines", JSONObject()
|
||||
.put("dxvk", JSONObject().put("current", "b.wcp").put("previous", ComponentUpdates.ORIGINAL)
|
||||
.put("owned", JSONArray().put("a.wcp").put("b.wcp")).put("bad", JSONArray().put("c.wcp")))
|
||||
.put("fex", JSONObject().put("current", "f2.wcp").put("previous", "f1.wcp")))
|
||||
assertEquals(setOf("a.wcp", "b.wcp", "c.wcp", "f1.wcp", "f2.wcp"), ComponentUpdater.placed(state))
|
||||
assertEquals(emptySet<String>(), ComponentUpdater.placed(JSONObject()))
|
||||
}
|
||||
|
||||
@Test fun aBuildAutoMovedPastStillCountsUntilTheHandBackCompletes() {
|
||||
val first = JSONObject().put("lines", JSONObject().put("dxvk", JSONObject().put("current", "a.wcp")))
|
||||
val remembered = ComponentUpdater.placed(first)
|
||||
val later = JSONObject().put(ComponentUpdater.HAND_BACK_FILES, JSONArray(remembered.sorted()))
|
||||
.put("lines", JSONObject().put("dxvk", JSONObject().put("current", "c.wcp").put("previous", "b.wcp")))
|
||||
val placed = ComponentUpdater.placed(later)
|
||||
assertEquals(setOf("a.wcp", "b.wcp", "c.wcp"), placed)
|
||||
val proton = proton("GE-Proton10-25")
|
||||
val choices = mapOf(proton.id to mapOf("dxvk" to ComponentsManager.Choice("a.wcp", false, proton.version)))
|
||||
assertTrue(ComponentUpdates.unseen(emptyList(), choices, placed))
|
||||
}
|
||||
|
||||
@Test fun onlyTheCurrentAndThePreviousBuildAndUserSelectionsAreKept() {
|
||||
assertEquals(setOf("b.wcp", "a.wcp", "mine.wcp"), ComponentUpdates.keep("b.wcp", "a.wcp", setOf("mine.wcp")))
|
||||
assertEquals(setOf("b.wcp"), ComponentUpdates.keep("b.wcp", ComponentUpdates.ORIGINAL, emptySet()))
|
||||
}
|
||||
|
||||
private fun choice(file: String, auto: Boolean = false) = ComponentsManager.Choice(file, auto, "1")
|
||||
|
||||
@Test fun aBuildTheUserPickedOnAProtonIsNeverReplacedByAuto() {
|
||||
val auto = setOf("DD-dxvk-3.1.1+188-nightly.wcp", "DD-dxvk-3.1.1+190-nightly.wcp")
|
||||
assertTrue(ComponentUpdates.manual(choice("DD-dxvk-gplasync-3.1.1+190-nightly.wcp"), null, auto))
|
||||
assertTrue(ComponentUpdates.manual(choice("DD-dxvk-3.1.1+188-nightly.wcp"), "DD-dxvk-3.1.1+190-nightly.wcp", auto))
|
||||
assertFalse(ComponentUpdates.manual(choice("DD-dxvk-3.1.1+190-nightly.wcp"), "DD-dxvk-3.1.1+190-nightly.wcp", auto))
|
||||
assertFalse(ComponentUpdates.manual(choice("DD-dxvk-3.1.1+188-nightly.wcp"), null, auto))
|
||||
assertFalse(ComponentUpdates.manual(choice("original 11-7.4"), null, auto))
|
||||
assertFalse(ComponentUpdates.manual(choice("dxvk-2.6.2-linux.wcp", auto = true), null, auto))
|
||||
assertFalse(ComponentUpdates.manual(null, null, auto))
|
||||
}
|
||||
|
||||
@Test fun aQueuedSwapThatNeverRanLeavesTheOldAutoBuildToAuto() {
|
||||
val auto = setOf("DD-dxvk-3.1.1+188-nightly.wcp", "DD-dxvk-3.1.1+190-nightly.wcp")
|
||||
assertFalse(ComponentUpdates.manual(choice("DD-dxvk-3.1.1+188-nightly.wcp"), "DD-dxvk-3.1.1+188-nightly.wcp", auto, "DD-dxvk-3.1.1+190-nightly.wcp"))
|
||||
assertFalse(ComponentUpdates.manual(choice("DD-dxvk-3.1.1+190-nightly.wcp"), "DD-dxvk-3.1.1+188-nightly.wcp", auto, "DD-dxvk-3.1.1+190-nightly.wcp"))
|
||||
assertTrue(ComponentUpdates.manual(choice("DD-dxvk-3.1.1+186-nightly.wcp"), "DD-dxvk-3.1.1+188-nightly.wcp", auto, "DD-dxvk-3.1.1+190-nightly.wcp"))
|
||||
}
|
||||
|
||||
@Test fun oldBuildsOfAnAutoLineAreDeletedButOtherFlavoursAndKeptBuildsStay() {
|
||||
val line = ComponentUpdates.line("dxvk", false)
|
||||
val stored = listOf(
|
||||
"DD-dxvk-3.1.1+186-nightly.wcp", "DD-dxvk-3.1.1+188-nightly.wcp", "DD-dxvk-3.1.1+190-nightly.wcp",
|
||||
"DD-dxvk-arm64ec-3.1.1+188-nightly.wcp", "DD-dxvk-gplasync-3.1.1+190-nightly.wcp", "dxvk-2.6.2-linux.wcp",
|
||||
)
|
||||
val keep = ComponentUpdates.keep("DD-dxvk-3.1.1+190-nightly.wcp", "DD-dxvk-3.1.1+188-nightly.wcp", emptySet())
|
||||
assertEquals(listOf("DD-dxvk-3.1.1+186-nightly.wcp"), ComponentUpdates.stale(line, emptySet(), stored, keep, "DD-dxvk-3.1.1+190-nightly.wcp"))
|
||||
assertEquals(listOf("DD-dxvk-3.1.1+184-nightly.wcp", "DD-dxvk-3.1.1+186-nightly.wcp"),
|
||||
ComponentUpdates.stale(line, setOf("DD-dxvk-3.1.1+184-nightly.wcp", "DD-dxvk-3.1.1+190-nightly.wcp"), stored, keep, "DD-dxvk-3.1.1+190-nightly.wcp"))
|
||||
assertEquals(emptyList<String>(), ComponentUpdates.stale(line, emptySet(), stored, keep + "DD-dxvk-3.1.1+186-nightly.wcp", "DD-dxvk-3.1.1+190-nightly.wcp"))
|
||||
assertEquals(emptyList<String>(), ComponentUpdates.stale(null, emptySet(), stored, keep, "DD-dxvk-3.1.1+190-nightly.wcp"))
|
||||
}
|
||||
|
||||
@Test fun aNewerAutoBuildIsKeptUnlessItWasRolledBack() {
|
||||
val line = ComponentUpdates.line("dxvk", false)
|
||||
val owned = setOf("DD-dxvk-3.1.1+188-nightly.wcp", "DD-dxvk-3.1.1+192-nightly.wcp", "DD-dxvk-3.1.1+194-nightly.wcp")
|
||||
val current = "DD-dxvk-3.1.1+190-nightly.wcp"
|
||||
val keep = ComponentUpdates.keep(current, ComponentUpdates.ORIGINAL, emptySet())
|
||||
assertEquals(listOf("DD-dxvk-3.1.1+188-nightly.wcp", "DD-dxvk-3.1.1+194-nightly.wcp"),
|
||||
ComponentUpdates.stale(line, owned, emptyList(), keep, current, setOf("DD-dxvk-3.1.1+194-nightly.wcp")))
|
||||
assertEquals(listOf("DD-dxvk-3.1.1+188-nightly.wcp"), ComponentUpdates.stale(line, owned, emptyList(), keep, current))
|
||||
}
|
||||
|
||||
@Test fun anImportBeforePruningTakesTheFileAwayFromAuto() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val updates = File(context.filesDir, "components/updates.json").apply { parentFile?.mkdirs() }
|
||||
updates.writeText(JSONObject().put("lines", JSONObject().put("dxvk", JSONObject()
|
||||
.put("current", "DD-dxvk-3.1.1+190-nightly.wcp")
|
||||
.put("owned", JSONArray(listOf("DD-dxvk-3.1.1+190-nightly.wcp", "DD-dxvk-3.1.1+194-nightly.wcp")))
|
||||
.put("bad", JSONArray(listOf("DD-dxvk-3.1.1+194-nightly.wcp"))))).toString())
|
||||
ComponentUpdater.disown(context, "DD-dxvk-3.1.1+194-nightly.wcp")
|
||||
val line = JSONObject(updates.readText()).getJSONObject("lines").getJSONObject("dxvk")
|
||||
val owned = (0 until line.getJSONArray("owned").length()).map { line.getJSONArray("owned").getString(it) }.toSet()
|
||||
assertEquals(setOf("DD-dxvk-3.1.1+190-nightly.wcp"), owned)
|
||||
val auto = ComponentUpdater.placed(JSONObject(updates.readText()))
|
||||
assertFalse("DD-dxvk-3.1.1+194-nightly.wcp" in auto)
|
||||
assertTrue(ComponentUpdates.manual(choice("DD-dxvk-3.1.1+194-nightly.wcp"), null, auto, "DD-dxvk-3.1.1+190-nightly.wcp"))
|
||||
val keep = ComponentUpdates.keep("DD-dxvk-3.1.1+190-nightly.wcp", ComponentUpdates.ORIGINAL, emptySet())
|
||||
assertEquals(emptyList<String>(), ComponentUpdates.stale(ComponentUpdates.line("dxvk", false), owned,
|
||||
listOf("DD-dxvk-3.1.1+194-nightly.wcp"), keep, "DD-dxvk-3.1.1+190-nightly.wcp", setOf("DD-dxvk-3.1.1+194-nightly.wcp")))
|
||||
}
|
||||
|
||||
@Test fun aRolledBackBuildTheUserImportsAgainIsKept() {
|
||||
val line = ComponentUpdates.line("dxvk", false)
|
||||
val current = "DD-dxvk-3.1.1+190-nightly.wcp"
|
||||
val bad = setOf("DD-dxvk-3.1.1+194-nightly.wcp")
|
||||
val keep = ComponentUpdates.keep(current, ComponentUpdates.ORIGINAL, emptySet())
|
||||
assertEquals(listOf("DD-dxvk-3.1.1+194-nightly.wcp"),
|
||||
ComponentUpdates.stale(line, bad, listOf("DD-dxvk-3.1.1+194-nightly.wcp"), keep, current, bad))
|
||||
assertEquals(emptyList<String>(), ComponentUpdates.stale(line, emptySet(), listOf("DD-dxvk-3.1.1+194-nightly.wcp"), keep, current, bad))
|
||||
}
|
||||
|
||||
@Test fun aNewerBuildTheUserStoredIsNotPrunedBeforeAutoCatchesUp() {
|
||||
val line = ComponentUpdates.line("dxvk", false)
|
||||
val stored = listOf("DD-dxvk-3.1.1+186-nightly.wcp", "DD-dxvk-3.1.1+192-nightly.wcp")
|
||||
val keep = ComponentUpdates.keep("DD-dxvk-3.1.1+190-nightly.wcp", ComponentUpdates.ORIGINAL, emptySet())
|
||||
assertEquals(listOf("DD-dxvk-3.1.1+186-nightly.wcp"), ComponentUpdates.stale(line, emptySet(), stored, keep, "DD-dxvk-3.1.1+190-nightly.wcp"))
|
||||
assertEquals(listOf("DD-dxvk-3.1.1+184-nightly.wcp"),
|
||||
ComponentUpdates.stale(line, setOf("DD-dxvk-3.1.1+184-nightly.wcp"), stored, emptySet(), ComponentUpdates.ORIGINAL))
|
||||
}
|
||||
|
||||
@Test fun aPackageLeftSelectedOnARemovedProtonIsNoLongerKept() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
File(context.filesDir, "components/state.json").apply { parentFile?.mkdirs() }.writeText(JSONObject()
|
||||
.put("active", JSONObject().put("DroidDeck-Proton-11-7.4", JSONObject()
|
||||
.put("dxvk", JSONObject().put("file", "DD-dxvk-3.1.1+186-nightly.wcp").put("protonVersion", "11-7.4"))))
|
||||
.put("profiles", JSONObject().put("app:1", JSONObject().put("dxvk", "mine.wcp"))).toString())
|
||||
assertEquals(setOf("mine.wcp"), ComponentsManager.selectedPackages(context))
|
||||
}
|
||||
|
||||
@Test fun pruningNeverDeletesAPackageAGameProfileSelected() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val dir = File(context.filesDir, "components").apply { mkdirs() }
|
||||
File(dir, "state.json").writeText(JSONObject()
|
||||
.put("profiles", JSONObject().put("app:1", JSONObject().put("dxvk", "DD-dxvk-3.1.1+186-nightly.wcp"))).toString())
|
||||
val packages = File(dir, "packages").apply { mkdirs() }
|
||||
val kept = File(packages, "DD-dxvk-3.1.1+186-nightly.wcp").apply { writeText("x") }
|
||||
val old = File(packages, "DD-dxvk-3.1.1+184-nightly.wcp").apply { writeText("x") }
|
||||
assertFalse(ComponentsManager.deleteUnused(context, kept.name))
|
||||
assertTrue(kept.isFile)
|
||||
assertEquals("DD-dxvk-3.1.1+186-nightly.wcp", JSONObject(File(dir, "state.json").readText())
|
||||
.getJSONObject("profiles").getJSONObject("app:1").getString("dxvk"))
|
||||
assertTrue(ComponentsManager.deleteUnused(context, old.name))
|
||||
assertFalse(old.isFile)
|
||||
assertFalse(ComponentsManager.deleteUnused(context, old.name))
|
||||
}
|
||||
|
||||
@Test fun aSelectionStaysLiveWhenAnotherInstallSharesItsProtonId() {
|
||||
fun p(version: String) = ComponentsManager.Proton("GE-Proton", "GE-Proton", File("/steam/$version"), "/p", version, false)
|
||||
val state = JSONObject().put("active", JSONObject().put("GE-Proton", JSONObject()
|
||||
.put("dxvk", JSONObject().put("file", "DD-dxvk-3.1.1+186-nightly.wcp").put("protonVersion", "2"))))
|
||||
assertTrue("DD-dxvk-3.1.1+186-nightly.wcp" in ComponentsManager.packagesInUse(state, listOf(p("1"), p("2"))))
|
||||
assertTrue("DD-dxvk-3.1.1+186-nightly.wcp" in ComponentsManager.packagesInUse(state, listOf(p("2"), p("1"))))
|
||||
assertFalse("DD-dxvk-3.1.1+186-nightly.wcp" in ComponentsManager.packagesInUse(state, listOf(p("1"))))
|
||||
val entry = state.getJSONObject("active").getJSONObject("GE-Proton").getJSONObject("dxvk")
|
||||
assertTrue(ComponentsManager.liveOnAny("active", entry, listOf(p("1"), p("2"))))
|
||||
assertFalse(ComponentsManager.liveOnAny("active", entry, listOf(p("1"))))
|
||||
assertFalse(ComponentsManager.liveOnAny("active", entry, null))
|
||||
}
|
||||
|
||||
@Test fun aPackageIsArm64xOnlyWhenEveryGraphicsDllInItIs() {
|
||||
val owned = setOf("d3d11.dll", "dxgi.dll", "d3d9.dll")
|
||||
val hybrid = pe(0xaa64, true, true)
|
||||
val ec = pe(0x8664, true, true)
|
||||
assertTrue(ComponentUpdates.allArm64x(mapOf("d3d11.dll" to hybrid, "dxgi.dll" to hybrid, "openvr_api_dxvk.dll" to pe(0x8664, true, false)), owned))
|
||||
assertFalse(ComponentUpdates.allArm64x(mapOf("d3d11.dll" to hybrid, "dxgi.dll" to ec), owned))
|
||||
assertFalse(ComponentUpdates.allArm64x(mapOf("D3D9.DLL" to ec, "d3d11.dll" to hybrid), owned))
|
||||
assertFalse(ComponentUpdates.allArm64x(mapOf("openvr_api_dxvk.dll" to hybrid), owned))
|
||||
assertFalse(ComponentUpdates.allArm64x(emptyMap(), owned))
|
||||
}
|
||||
|
||||
private fun wcp(context: Context, name: String, dlls: Map<String, ByteArray>) {
|
||||
val out = File(context.filesDir, "components/packages/$name").apply { parentFile!!.mkdirs() }
|
||||
TarArchiveOutputStream(ZstdOutputStream(out.outputStream())).use { tar ->
|
||||
for ((path, body) in dlls) {
|
||||
tar.putArchiveEntry(TarArchiveEntry(path).apply { size = body.size.toLong() })
|
||||
tar.write(body)
|
||||
tar.closeArchiveEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun anImportWaitsForPruningAndLeavesTheFileTheUsers() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val name = "DD-dxvk-3.1.1+194-nightly.wcp"
|
||||
File(context.filesDir, "components/updates.json").apply { parentFile?.mkdirs() }.writeText(JSONObject().put("lines", JSONObject()
|
||||
.put("dxvk", JSONObject().put("current", "DD-dxvk-3.1.1+190-nightly.wcp").put("owned", JSONArray(listOf(name))))).toString())
|
||||
wcp(context, "source.wcp", mapOf("profile.json" to JSONObject().put("type", "DXVK-Linux").put("versionName", "3.1.1+194").toString().toByteArray()))
|
||||
val source = File(context.filesDir, "source.wcp").also { File(context.filesDir, "components/packages/source.wcp").renameTo(it) }
|
||||
val lock = ComponentUpdater::class.java.getDeclaredField("lock").apply { isAccessible = true }.get(ComponentUpdater)
|
||||
val held = java.util.concurrent.CountDownLatch(1)
|
||||
val release = java.util.concurrent.CountDownLatch(1)
|
||||
val pruning = Thread { synchronized(lock) { held.countDown(); release.await() } }.apply { start() }
|
||||
held.await()
|
||||
val importing = Thread { ComponentUpdater.importPackage(context, source, name) }.apply { start() }
|
||||
importing.join(300)
|
||||
assertTrue(importing.isAlive)
|
||||
assertFalse(File(context.filesDir, "components/packages/$name").exists())
|
||||
release.countDown()
|
||||
pruning.join()
|
||||
importing.join(5000)
|
||||
assertTrue(File(context.filesDir, "components/packages/$name").isFile)
|
||||
val state = JSONObject(File(context.filesDir, "components/updates.json").readText())
|
||||
assertEquals(0, state.getJSONObject("lines").getJSONObject("dxvk").getJSONArray("owned").length())
|
||||
assertFalse(name in ComponentUpdater.placed(state))
|
||||
}
|
||||
|
||||
@Test fun aStoredPackageWithOneArm64ecOnlyDllIsNotArm64x() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val dir = "files/lib/wine/dxvk/aarch64-windows"
|
||||
val hybrid = pe(0xaa64, true, true)
|
||||
wcp(context, "mixed.wcp", mapOf("$dir/d3d11.dll" to hybrid, "$dir/dxgi.dll" to pe(0x8664, true, true),
|
||||
"files/lib/wine/dxvk/i386-windows/d3d11.dll" to pe(0x14c, false, null)))
|
||||
wcp(context, "hybrid.wcp", mapOf("./$dir/d3d11.dll" to hybrid, "./$dir/dxgi.dll" to hybrid,
|
||||
"$dir/openvr_api_dxvk.dll" to pe(0x8664, true, false)))
|
||||
assertFalse(ComponentsManager.packageArm64x(context, "mixed.wcp", "dxvk"))
|
||||
assertTrue(ComponentsManager.packageArm64x(context, "hybrid.wcp", "dxvk"))
|
||||
assertFalse(ComponentsManager.packageArm64x(context, "hybrid.wcp", "vkd3d"))
|
||||
assertFalse(ComponentsManager.packageArm64x(context, "missing.wcp", "dxvk"))
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package com.droiddeck.launcher.session
|
||||
|
||||
import android.view.Display
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
@@ -34,4 +36,24 @@ class PerfModeTest {
|
||||
val otherResolution = mode(3, 60f, 720)
|
||||
assertEquals(low, PerfMode.selectMode(arrayOf(low, high, otherResolution), high, 60, 60f))
|
||||
}
|
||||
|
||||
@Test fun anotherModeWithTheRequestedRefreshRateKeepsTheRequestInsteadOfSwitchingBackAndForth() {
|
||||
val sixty = mode(2, 60f)
|
||||
val requested = mode(1, 120f)
|
||||
val reported = mode(255, 120f, 1440)
|
||||
val modes = arrayOf(sixty, requested, reported)
|
||||
assertEquals(reported, PerfMode.selectMode(modes, reported, 0, Float.POSITIVE_INFINITY))
|
||||
assertFalse(PerfMode.needsChange(modes, requested.modeId, reported))
|
||||
assertFalse(PerfMode.needsChange(modes, reported.modeId, requested))
|
||||
}
|
||||
|
||||
@Test fun aDifferentRefreshRateNoRequestOrAMissingModeStillChangesTheMode() {
|
||||
val sixty = mode(2, 60f)
|
||||
val oneTwenty = mode(1, 120f)
|
||||
val modes = arrayOf(sixty, oneTwenty)
|
||||
assertTrue(PerfMode.needsChange(modes, sixty.modeId, oneTwenty))
|
||||
assertTrue(PerfMode.needsChange(modes, oneTwenty.modeId, sixty))
|
||||
assertTrue(PerfMode.needsChange(modes, 0, oneTwenty))
|
||||
assertTrue(PerfMode.needsChange(modes, 99, oneTwenty))
|
||||
}
|
||||
}
|
||||
@@ -24,10 +24,4 @@ class PipPolicyTest {
|
||||
assertEquals(100 to 239, PipPolicy.aspect(1080, 5760))
|
||||
assertEquals(16 to 9, PipPolicy.aspect(0, 0))
|
||||
}
|
||||
|
||||
@Test fun pipOutputFitsTheResizedSurfaceWithoutChangingAspectRatio() {
|
||||
assertEquals(640 to 360, PipPolicy.outputSize(640, 480, 1920, 1080))
|
||||
assertEquals(480 to 270, PipPolicy.outputSize(480, 270, 1920, 1080))
|
||||
assertNull(PipPolicy.outputSize(0, 270, 1920, 1080))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SteamLaunchOptionsVisibilityTest {
|
||||
private fun game(library: String = "main", source: String = Library.SOURCE_STEAM) =
|
||||
Library.SteamGame(42, "Test game", null, library, source = source)
|
||||
|
||||
@Test fun nativeSteamGamesExposeLaunchOptionsOutsideShortcutPicking() {
|
||||
assertTrue(canEditSteamLaunchOptions(game(), shortcutPicker = false))
|
||||
}
|
||||
|
||||
@Test fun pickerAddedAndStoreGamesDoNotExposeSteamLaunchOptions() {
|
||||
assertFalse(canEditSteamLaunchOptions(game(), shortcutPicker = true))
|
||||
assertFalse(canEditSteamLaunchOptions(game(library = Library.ADDED), shortcutPicker = false))
|
||||
assertFalse(canEditSteamLaunchOptions(game(source = "gog"), shortcutPicker = false))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class WowChatTest {
|
||||
@Test
|
||||
fun everyWowClientMatches() {
|
||||
listOf("wow.exe", "Wow.exe", "wowclassic.exe", "wowclassict.exe", "wowb.exe", "wow-64.exe", "wow-arm64.exe", "wowt.exe").forEach {
|
||||
assertTrue(it, WowChat.isWow(it))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun otherProgramsAreUnaffected() {
|
||||
listOf(null, "", "battle.net.exe", "steam.exe", "notwow.exe", "wow.exe.bak", "wow", "cyberpunk2077.exe").forEach {
|
||||
assertFalse(it.toString(), WowChat.isWow(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,29 @@ selection, and restores Default component choices for the next game. **Use
|
||||
Default** removes the game override. Component packages must first be downloaded
|
||||
or imported on the Default Components page.
|
||||
|
||||
FEX, DXVK and VKD3D-Proton each have an **Auto** or **Manual** mode on the Default
|
||||
Components page. Auto is the default unless a component already uses a package the
|
||||
user picked, which keeps it on Manual. In Auto, DroidDeck checks the
|
||||
DroidDeck-Components nightly release when DroidDeck is opened, at most hourly and
|
||||
never during a running session, and installs the newest build into DroidDeck-Proton.
|
||||
Valve's Proton, GE-Proton, proton-cachyos and any other tool keep their own components;
|
||||
builds an earlier Auto had placed in them go back to the original once. A build the user
|
||||
picks on a DroidDeck-Proton by hand stays until they restore the original. DXVK and
|
||||
VKD3D-Proton follow the flavour of each Proton's own files: ARM64EC/ARM64X builds get the
|
||||
`arm64ec` package, plain x64 builds the plain one. Where the Proton's own build is ARM64X, an
|
||||
`arm64ec` package is used only when every Direct3D and DXGI DLL in it is ARM64X, because native
|
||||
ARM64 programs such as `Wow-arm64.exe` cannot load an ARM64EC-only `dxgi.dll`. A rollback whose
|
||||
previous build fails that check, or was deleted, goes back to the original. Auto keeps the current build and the one
|
||||
it replaced and deletes every older stored build of the same line, including ones that were
|
||||
stored before Auto took over, and downloads the current build again if it was deleted.
|
||||
Other flavours such as gplasync and packages selected on an installed Proton or in a game
|
||||
profile are never removed. Steam's process log tells it how long each game ran. A game that ran
|
||||
for at least a minute on the previous build and then exits within 30 seconds twice
|
||||
on the new one rolls back every component line that changed since that boot, and
|
||||
each failed build is skipped until a newer one is published. Games with their own component override are
|
||||
not counted. DXVK stays on Manual while a GPU rule pins a DXVK version (Adreno 6xx and
|
||||
the Vulkan bridge). Manual keeps the existing download, import and restore lists.
|
||||
|
||||
Before publishing game component choices, DroidDeck preserves each Proton build's
|
||||
original components, including an explicitly empty bundle when a component is not
|
||||
shipped. Those restore choices remain after the last override is cleared. A missing
|
||||
|
||||
@@ -41,6 +41,63 @@ reduced aggregate launcher RSS only from 263 to 255 MiB and backed off. Thus lau
|
||||
does not resolve this load failure, and this run did not exhaust swap. The unevictable/shmem
|
||||
growth needs allocation attribution before treating it as a driver or GPU-memory diagnosis.
|
||||
|
||||
## Low-RAM devices: compressing idle game memory
|
||||
|
||||
On devices with at most 13 GiB `MemTotal` (every 12 GB phone and handheld and below), the
|
||||
monitor also compresses the game session itself when memory runs out. The trigger is absolute
|
||||
headroom, not a percentage: Android's `lmkd` killed a session on a 7.5 GiB device with
|
||||
490 MiB still available, so a 95% used threshold (about 400 MiB left on 8 GB) is already past the
|
||||
kill. A world load added 1 GiB of GPU memory in about ten seconds, and compressing 1.4 GiB took
|
||||
2.6 seconds, so the request has to leave room for both.
|
||||
|
||||
The session request runs when available memory falls below a quarter of RAM (bounded to
|
||||
768–2560 MiB, 1.85 GiB on an 8 GB device), or below one and a half times that with at least 1%
|
||||
`some avg10` memory stall. One request pages out every session process that loaded
|
||||
`libblsession.so`: the game, Wine and its services, plus the launcher processes unless their
|
||||
compression is turned off, under a single wait. It runs before the launcher-only check, which
|
||||
can wait up to 12 seconds for acknowledgements. While a game runs and available memory is under
|
||||
twice that level, memory is read every second instead of every five. Requests are at least 30 seconds apart and need the same swap reserve as launcher
|
||||
requests. When the session has read back more than half of what the previous request compressed,
|
||||
that memory is in use: the request is skipped and the wait doubles, up to five minutes. A
|
||||
request whose memory stayed compressed resets it to 30 seconds. Below half the headroom the
|
||||
wait is ignored and only the 30-second spacing applies. The state resets when the game exits. Devices above 13 GiB keep the
|
||||
launcher-only behaviour.
|
||||
|
||||
Measured on a Retroid Pocket Duo (7.5 GiB, kernel 6.6, 6 GiB lzo-rle zram plus a 4 GiB swap file)
|
||||
entering the world in World of Warcraft: Forever, one run each:
|
||||
|
||||
| | No session page-out | Session compressed at character select |
|
||||
| --- | --- | --- |
|
||||
| available before Enter World | 1,569–1,800 MiB | 2,329 MiB |
|
||||
| game resident / in zram before Enter World | 1,919 / 13 MiB | 409 / 1,523 MiB |
|
||||
| GPU (KGSL) memory during the load | 0.97 → 1.99 GiB | 0.96 → 1.99 GiB |
|
||||
| lowest available | 491 MiB | 623 MiB |
|
||||
| result | `LOW_MEMORY` kill of the whole session 58 s after Enter World | in the world at 35 FPS after three minutes |
|
||||
|
||||
The killed run had 7.4 GiB of swap free at the time: reclaim did not keep up with the GPU
|
||||
allocation, not swap capacity. Little Nightmares at the same settings compressed from 1.41 GiB to
|
||||
0.25 GiB resident in 2.6 seconds (1.1 GiB stored in about 0.5 GiB of zram) and read back 70 MiB in
|
||||
the following minute at its menu and 440 MiB while loading the next area; frame rate stayed at
|
||||
44–46 FPS with one or two short frame-time spikes. These are single runs, not averages.
|
||||
|
||||
On the PR build without any manual step, the monitor compressed WoW before character select and
|
||||
again during the load; the game entered the world and stayed there at 33 FPS. Once in the world,
|
||||
available memory settled near 1 GiB, under the headroom, and the first version of the policy
|
||||
compressed 200–400 MiB every 30 seconds while the game read back about 15 MiB/s. The
|
||||
read-back check therefore compares against the last request instead of all compressed memory.
|
||||
|
||||
A second run on that build showed the trigger arriving too late: character select sat at
|
||||
1.5–1.6 GiB available, just above the original one-fifth headroom, so nothing was compressed
|
||||
there. During the load, available memory crossed the headroom 9 seconds after Enter World, but the
|
||||
launcher check ran first and waited 12 seconds for acknowledgements; the game request started at
|
||||
21 seconds, moved about 200 MiB, and `lmkd` ended the session at 30 seconds. The quarter-of-RAM
|
||||
level and the game-first single request address both.
|
||||
|
||||
Without root an app cannot write its memory cgroup's `memory.reclaim` (owned by `system`),
|
||||
`process_madvise` on another process returns `EPERM`, `PAGEMAP_SCAN` is absent before Linux 6.7,
|
||||
and KGSL's per-process GPU reclaim `state` is root-only. Page-out therefore runs inside each
|
||||
process through the preload, and GPU memory itself cannot be compressed from userspace.
|
||||
|
||||
## CRAM and the bounded pager experiment
|
||||
|
||||
[Meta's CRAM proposal](https://lpc.events/event/20/contributions/2424/) uses memory compression
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
GAMESCOPE_REPO=Droid-Deck/DroidDeck
|
||||
GAMESCOPE_TAG=gamescope-3.16.29-pr2029-22b50d182
|
||||
GAMESCOPE_SHA256=d6bf0fe2ebb98e1fc7c9a09fccce7d7916c9cc6a10016018040cda01a940b781
|
||||
GAMESCOPE_REPO=Droid-Deck/DroidDeck-Components
|
||||
GAMESCOPE_TAG=gamescope-3.16.29-p22
|
||||
GAMESCOPE_SHA256=2b7e7481df5f5f7355d224d3a0d4205557367172653dff1d7206a230b6c78b46
|
||||
@@ -31,6 +31,9 @@ WALLPAPER_DIR = ("users", "steamuser", "AppData", "Local", "DroidDeck")
|
||||
DESKTOP_KEY = "[Control Panel\\\\Desktop]"
|
||||
COLORS_KEY = "[Control Panel\\\\Colors]"
|
||||
WALLPAPER_EDGE = (4, 6, 11)
|
||||
USER_PREFERENCES = "UserPreferencesMask"
|
||||
WINE_USER_PREFERENCES = bytes((0x10, 0x00, 0x00, 0x80, 0x12, 0x00, 0x00, 0x00))
|
||||
CLIENT_AREA_ANIMATION = (4, 0x02)
|
||||
WALLPAPER_MAX = (1280, 720)
|
||||
FEX_REQUEST = "battlenet-fex.request"
|
||||
FEX_RESULT = "battlenet-fex.result"
|
||||
@@ -331,24 +334,47 @@ def reg_dword(reg, key, name, number):
|
||||
return reg_line(reg, key, name, '"%s"=dword:%08x' % (name, number))
|
||||
|
||||
|
||||
def reg_line(reg, key, name, line):
|
||||
try:
|
||||
with open(reg, encoding="utf-8", errors="replace") as f:
|
||||
rows = f.read().splitlines()
|
||||
except OSError:
|
||||
return False
|
||||
def reg_read(reg, key, name):
|
||||
with open(reg, encoding="utf-8", errors="replace") as f:
|
||||
rows = f.read().splitlines()
|
||||
inside, last = False, None
|
||||
for row in rows:
|
||||
if row.startswith("["):
|
||||
inside = row.startswith(key + " ") or row == key
|
||||
elif inside and row.startswith('"%s"=' % name):
|
||||
last = row
|
||||
return last
|
||||
|
||||
|
||||
def reg_line(reg, key, name, line):
|
||||
try:
|
||||
last = reg_read(reg, key, name)
|
||||
except OSError:
|
||||
return False
|
||||
if last != line:
|
||||
with open(reg, "a", encoding="utf-8") as out:
|
||||
out.write("\n%s %d\n%s\n" % (key, int(time.time()), line))
|
||||
return True
|
||||
|
||||
|
||||
def still_pages(root):
|
||||
reg = os.path.join(prefix(root), "pfx", "user.reg")
|
||||
if not os.path.isfile(reg):
|
||||
return False
|
||||
row = reg_read(reg, DESKTOP_KEY, USER_PREFERENCES)
|
||||
found = re.fullmatch(r'"%s"=hex:([0-9a-fA-F]{2}(?:,[0-9a-fA-F]{2})*)' % USER_PREFERENCES, row or "")
|
||||
mask = bytearray(bytes.fromhex(found.group(1).replace(",", "")) if found else WINE_USER_PREFERENCES)
|
||||
at, bit = CLIENT_AREA_ANIMATION
|
||||
if len(mask) <= at or not mask[at] & bit:
|
||||
return False
|
||||
mask[at] &= ~bit
|
||||
if wine_processes(os.path.join(prefix(root), "pfx")):
|
||||
say("Battle.net's prefix is in use; Windows animations go off the next time it starts on its own")
|
||||
return False
|
||||
reg_line(reg, DESKTOP_KEY, USER_PREFERENCES, '"%s"=hex:%s' % (USER_PREFERENCES, ",".join("%02x" % b for b in mask)))
|
||||
return True
|
||||
|
||||
|
||||
def desktop_size():
|
||||
width, height = os.environ.get("BL_WIDTH", ""), os.environ.get("BL_HEIGHT", "")
|
||||
if width.isdigit() and height.isdigit() and 0 < int(width) <= 7680 and 0 < int(height) <= 4320:
|
||||
@@ -515,6 +541,11 @@ def configure(root, library=None):
|
||||
dpi(root)
|
||||
except OSError as error:
|
||||
say("Battle.net's desktop scale not set: %s" % error)
|
||||
try:
|
||||
if still_pages(root):
|
||||
say("Battle.net's desktop: Windows animations off, so its pages do not play videos the CPU has to draw")
|
||||
except OSError as error:
|
||||
say("Battle.net's desktop animations not set: %s" % error)
|
||||
wow_settings(root)
|
||||
if change == "first":
|
||||
say("wrote Battle.net's first config: hardware acceleration off, streaming off, closing itself as it launches a game, "
|
||||
|
||||
@@ -840,6 +840,9 @@ case $mode in
|
||||
_bl_headroom=$((_bl_mem_total / 5))
|
||||
[ "$_bl_headroom" -ge $((512 * 1024)) ] || _bl_headroom=$((512 * 1024))
|
||||
[ "$_bl_headroom" -le $((1536 * 1024)) ] || _bl_headroom=$((1536 * 1024))
|
||||
_bl_tier_headroom=$((_bl_mem_total / 4))
|
||||
[ "$_bl_tier_headroom" -ge $((768 * 1024)) ] || _bl_tier_headroom=$((768 * 1024))
|
||||
[ "$_bl_tier_headroom" -le $((2560 * 1024)) ] || _bl_tier_headroom=$((2560 * 1024))
|
||||
_bl_swap_reserve=$((_bl_swap_total / 5))
|
||||
[ "$_bl_swap_reserve" -ge $((256 * 1024)) ] || _bl_swap_reserve=$((256 * 1024))
|
||||
}
|
||||
@@ -884,6 +887,88 @@ case $mode in
|
||||
_bl_pressure_backoff=30
|
||||
fi
|
||||
}
|
||||
bl_memory_critical() {
|
||||
[ "$_bl_mem_total" -gt 0 ] && [ "$_bl_available" -ge 0 ] &&
|
||||
{ [ "$_bl_available" -lt "$_bl_tier_headroom" ] ||
|
||||
{ [ "$_bl_psi" -ge 1 ] && [ "$_bl_available" -lt $((_bl_tier_headroom * 3 / 2)) ]; }; }
|
||||
}
|
||||
bl_low_ram() {
|
||||
[ "$_bl_mem_total" -gt 0 ] && [ "$_bl_mem_total" -le $((13 * 1024 * 1024)) ]
|
||||
}
|
||||
bl_session_pressure() {
|
||||
bl_memory_read
|
||||
[ "$_bl_game" = 1 ] && bl_low_ram && bl_memory_critical || return 0
|
||||
local _wait=$_bl_tier_backoff _urgent=0 _before _pids _targets
|
||||
[ "$_bl_available" -ge $((_bl_headroom / 2)) ] || _urgent=1 _wait=30
|
||||
[ "$_bl_tier_at" -lt 0 ] || [ $((SECONDS - _bl_tier_at)) -ge "$_wait" ] || return 0
|
||||
if [ "$_bl_tier_at" -ge 0 ] && [ "$_bl_tier_pushed" -gt 0 ]; then
|
||||
bl_tier_readback
|
||||
if [ "$_bl_tier_back" -le $((_bl_tier_pushed / 2)) ]; then
|
||||
_bl_tier_backoff=30
|
||||
elif [ "$_urgent" = 0 ]; then
|
||||
_bl_tier_backoff=$((_bl_tier_backoff * 2 > 300 ? 300 : _bl_tier_backoff * 2))
|
||||
_bl_tier_at=$SECONDS _bl_tier_pushed=0
|
||||
step "zRAM: the game read back most of its last page-out, next try in ${_bl_tier_backoff} s"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
_bl_tier_at=$SECONDS
|
||||
bl_zram_pick game
|
||||
bl_zram_usage "${_pids[@]}" > /dev/null
|
||||
[ "$_bl_rss" -ge $((256 * 1024)) ] || return 0
|
||||
if [ "$_bl_swap_total" -le 0 ] || [ $((_bl_swap_free - _bl_rss)) -lt "$_bl_swap_reserve" ]; then
|
||||
step "zRAM: low-memory page-out skipped, swap headroom too small ($((_bl_swap_free / 1024)) MB free)"
|
||||
return 0
|
||||
fi
|
||||
_targets=("${_pids[@]}") _before=$_bl_swap
|
||||
bl_zram_send 1 "low memory (available $((_bl_available / 1024)) MB), compressing idle game memory" game
|
||||
bl_zram_usage "${_targets[@]}" > /dev/null
|
||||
_bl_tier_pushed=$((_bl_swap - _before))
|
||||
bl_tier_note "${_targets[@]}"
|
||||
if [ "${_bl_pageout_complete:-0}" = 1 ] && [ "${BL_ZRAM_CLIENT:-1}" = 1 ] && [ "${#_bl_pids[@]}" -gt 0 ]; then
|
||||
_bl_pressure_at=$SECONDS _bl_packed=$SECONDS
|
||||
fi
|
||||
}
|
||||
bl_proc_start() {
|
||||
local _s=
|
||||
_bl_start=
|
||||
read -r _s 2>/dev/null < "/proc/$1/stat" || return 1
|
||||
_s=${_s##*) }
|
||||
set -- $_s
|
||||
[ "$1" != Z ] && [ "$#" -ge 20 ] || return 1
|
||||
_bl_start=${20}
|
||||
}
|
||||
bl_tier_note() {
|
||||
local _p
|
||||
_bl_tier_targets=() _bl_tier_swaps=() _bl_tier_starts=()
|
||||
for _p in "$@"; do
|
||||
bl_proc_start "$_p" || continue
|
||||
bl_zram_usage "$_p" > /dev/null
|
||||
_bl_tier_targets+=("$_p") _bl_tier_swaps+=("$_bl_swap") _bl_tier_starts+=("$_bl_start")
|
||||
done
|
||||
}
|
||||
bl_tier_readback() {
|
||||
local _i _back=0
|
||||
for _i in "${!_bl_tier_targets[@]}"; do
|
||||
bl_proc_start "${_bl_tier_targets[$_i]}" && [ "$_bl_start" = "${_bl_tier_starts[$_i]}" ] || continue
|
||||
bl_zram_usage "${_bl_tier_targets[$_i]}" > /dev/null
|
||||
[ "${_bl_tier_swaps[$_i]}" -le "$_bl_swap" ] || _back=$((_back + _bl_tier_swaps[_i] - _bl_swap))
|
||||
done
|
||||
_bl_tier_back=$_back
|
||||
}
|
||||
bl_watch_nap() {
|
||||
local _i
|
||||
if [ "$_bl_game" = 1 ] && bl_low_ram && [ "$_bl_available" -ge 0 ] && [ "$_bl_available" -lt $((_bl_tier_headroom * 2)) ]; then
|
||||
for _i in 1 2 3 4 5; do
|
||||
nap 1
|
||||
bl_memory_read
|
||||
bl_memory_critical && return 0
|
||||
done
|
||||
else
|
||||
nap 5
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
bl_session_others() {
|
||||
local _p
|
||||
_bl_session=()
|
||||
@@ -892,7 +977,11 @@ case $mode in
|
||||
done
|
||||
}
|
||||
bl_zram_pick() {
|
||||
if [ "$1" = session ]; then
|
||||
if [ "$1" = game ]; then
|
||||
bl_session_others
|
||||
_pids=("${_bl_session[@]}")
|
||||
[ "${BL_ZRAM_CLIENT:-1}" != 1 ] || _pids+=("${_bl_pids[@]}")
|
||||
elif [ "$1" = session ]; then
|
||||
bl_session_others
|
||||
_pids=("${_bl_session[@]}")
|
||||
else
|
||||
@@ -954,7 +1043,7 @@ case $mode in
|
||||
[ "$_bl_ram_cap" -gt 0 ] && { [ "$_bl_measured" -lt 0 ] || [ $((SECONDS - _bl_measured)) -ge 30 ]; }
|
||||
}
|
||||
_bl_since=-1 _bl_since_apps= _bl_packed=-1 _bl_measured=-1 _bl_unpack=0 _bl_capped=-1 _bl_backoff=30
|
||||
_bl_pressure_at=-1 _bl_pressure_backoff=30
|
||||
_bl_pressure_at=-1 _bl_pressure_backoff=30 _bl_tier_at=-1 _bl_tier_backoff=30 _bl_tier_pushed=0 _bl_tier_back=0 _bl_tier_targets=() _bl_tier_swaps=() _bl_tier_starts=()
|
||||
while kill -0 $$ 2>/dev/null; do
|
||||
bl_zram_scan
|
||||
bl_memory_read
|
||||
@@ -981,6 +1070,7 @@ case $mode in
|
||||
_bl_backoff=30
|
||||
fi
|
||||
fi
|
||||
bl_session_pressure
|
||||
bl_client_pressure
|
||||
if [ "$_bl_game" = 1 ] && bl_game_focused; then
|
||||
if [ "$_bl_since" -lt 0 ] || [ "$_bl_since_apps" != "$_bl_apps" ]; then
|
||||
@@ -995,7 +1085,7 @@ case $mode in
|
||||
if [ "$_bl_packed" -ge 0 ]; then
|
||||
_bl_unpack=1
|
||||
fi
|
||||
_bl_since=-1 _bl_packed=-1
|
||||
_bl_since=-1 _bl_packed=-1 _bl_tier_at=-1 _bl_tier_backoff=30 _bl_tier_pushed=0 _bl_tier_targets=() _bl_tier_swaps=() _bl_tier_starts=()
|
||||
else
|
||||
_bl_since=-1
|
||||
fi
|
||||
@@ -1003,7 +1093,7 @@ case $mode in
|
||||
bl_zram_send 0 "memory recovered, reading the client back" client
|
||||
_bl_unpack=0
|
||||
fi
|
||||
nap 5
|
||||
bl_watch_nap
|
||||
done
|
||||
) &
|
||||
fi
|
||||
|
||||
@@ -909,6 +909,48 @@ class LaunchTest(Harness):
|
||||
self.assertEqual(image.read_bytes(), b"BM1152x720")
|
||||
self.assertIn('"WallpaperStyle"="2"', (pfx / "user.reg").read_text())
|
||||
|
||||
def test_battle_nets_desktop_turns_windows_animations_off_once(self):
|
||||
self.install()
|
||||
reg = Path(self.root) / ("steamapps/compatdata/%s/pfx/user.reg" % BNET["APP_ID"])
|
||||
reg.write_text('WINE REGISTRY Version 2\n\n[Control Panel\\\\Desktop] 1\n"UserPreferencesMask"=hex:30,00,02,80,12,00,00,00\n')
|
||||
self.assertTrue(BNET["still_pages"](self.root))
|
||||
self.assertFalse(BNET["still_pages"](self.root))
|
||||
text = reg.read_text()
|
||||
self.assertEqual(text.count('"UserPreferencesMask"=hex:30,00,02,80,10,00,00,00'), 1)
|
||||
self.assertEqual(BNET["reg_read"](str(reg), BNET["DESKTOP_KEY"], "UserPreferencesMask"),
|
||||
'"UserPreferencesMask"=hex:30,00,02,80,10,00,00,00')
|
||||
|
||||
def test_a_new_prefix_gets_wines_default_preferences_without_animations(self):
|
||||
self.install()
|
||||
reg = Path(self.root) / ("steamapps/compatdata/%s/pfx/user.reg" % BNET["APP_ID"])
|
||||
reg.write_text("WINE REGISTRY Version 2\n")
|
||||
self.assertTrue(BNET["still_pages"](self.root))
|
||||
self.assertEqual(BNET["reg_read"](str(reg), BNET["DESKTOP_KEY"], "UserPreferencesMask"),
|
||||
'"UserPreferencesMask"=hex:10,00,00,80,10,00,00,00')
|
||||
|
||||
def test_animations_already_off_and_other_preferences_are_left_alone(self):
|
||||
self.install()
|
||||
reg = Path(self.root) / ("steamapps/compatdata/%s/pfx/user.reg" % BNET["APP_ID"])
|
||||
before = 'WINE REGISTRY Version 2\n\n[Control Panel\\\\Desktop] 1\n"UserPreferencesMask"=hex:9e,3e,07,80,31,00,00,00\n'
|
||||
reg.write_text(before)
|
||||
self.assertFalse(BNET["still_pages"](self.root))
|
||||
self.assertEqual(reg.read_text(), before)
|
||||
reg.write_text('WINE REGISTRY Version 2\n\n[Control Panel\\\\Desktop] 1\n"UserPreferencesMask"=hex:9e,3e,07,80,33,00,00,00\n')
|
||||
self.assertTrue(BNET["still_pages"](self.root))
|
||||
self.assertEqual(BNET["reg_read"](str(reg), BNET["DESKTOP_KEY"], "UserPreferencesMask"),
|
||||
'"UserPreferencesMask"=hex:9e,3e,07,80,31,00,00,00')
|
||||
self.assertFalse(BNET["still_pages"](self.root + "/missing"))
|
||||
|
||||
def test_a_prefix_in_use_keeps_its_registry_until_it_starts_on_its_own(self):
|
||||
self.install()
|
||||
reg = Path(self.root) / ("steamapps/compatdata/%s/pfx/user.reg" % BNET["APP_ID"])
|
||||
before = 'WINE REGISTRY Version 2\n\n[Control Panel\\\\Desktop] 1\n"UserPreferencesMask"=hex:30,00,02,80,12,00,00,00\n'
|
||||
reg.write_text(before)
|
||||
with mock.patch.dict(BNET, {"wine_processes": lambda pfx: [(1, ["C:\\Games\\game.exe"])]}):
|
||||
self.assertFalse(BNET["still_pages"](self.root))
|
||||
self.assertEqual(reg.read_text(), before)
|
||||
self.assertTrue(BNET["still_pages"](self.root))
|
||||
|
||||
def test_a_large_desktop_scales_battle_net_to_fit_it(self):
|
||||
sizes = {("1280", "720"): 96, ("1920", "1080"): 96, ("2400", "1504"): 120, ("2448", "1848"): 120,
|
||||
("1080", "2400"): 96, ("3840", "2160"): 192, ("2560", "1600"): 120}
|
||||
|
||||
@@ -81,7 +81,7 @@ class ClientZramFocusTest(unittest.TestCase):
|
||||
def test_refocus_restarts_the_delay_and_game_exit_restores_a_packed_client(self):
|
||||
source = SCRIPT.read_text()
|
||||
start = source.index(' if [ "$_bl_game" = 1 ] && bl_game_focused; then')
|
||||
stop = source.index(' nap 5', start)
|
||||
stop = source.index(' bl_watch_nap\n', start)
|
||||
body = source[start:stop]
|
||||
harness = '''
|
||||
_bl_since=-1 _bl_since_apps= _bl_packed=-1 _bl_unpack=0 _bl_apps=" 413150"
|
||||
@@ -112,7 +112,7 @@ tick 60 0 0
|
||||
def monitor_harness(self):
|
||||
source = SCRIPT.read_text()
|
||||
start = source.index(' bl_zram_scan\n bl_memory_read\n')
|
||||
stop = source.index(' nap 5', start)
|
||||
stop = source.index(' bl_watch_nap\n', start)
|
||||
return '''
|
||||
_bl_since=-1 _bl_since_apps= _bl_packed=-1 _bl_measured=-1 _bl_unpack=0 _bl_capped=-1 _bl_backoff=30
|
||||
_bl_apps=" 413150"
|
||||
@@ -121,6 +121,7 @@ step() { :; }
|
||||
bl_zram_scan() { :; }
|
||||
bl_memory_read() { :; }
|
||||
bl_client_pressure() { :; }
|
||||
bl_session_pressure() { :; }
|
||||
bl_client_can_pageout() { return 0; }
|
||||
bl_client_can_readback() { [ "${readback:-1}" = 1 ]; }
|
||||
bl_game_focused() { [ "$focused" = 1 ]; }
|
||||
|
||||
@@ -137,5 +137,187 @@ echo "backoff:$_bl_pressure_backoff"
|
||||
self.assertEqual(['no'], self.run_policy('if bl_client_can_readback; then echo yes; else echo no; fi'))
|
||||
|
||||
|
||||
class SessionPressureTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.directory.cleanup)
|
||||
self.root = Path(self.directory.name)
|
||||
self.memory()
|
||||
|
||||
def memory(self, available=1024, swap_free=6144, total=7168, swap_total=8192, psi='0.00'):
|
||||
(self.root / 'meminfo').write_text(
|
||||
f'MemTotal: {total * 1024} kB\nMemAvailable: {available * 1024} kB\n'
|
||||
f'SwapTotal: {swap_total * 1024} kB\nSwapFree: {swap_free * 1024} kB\n')
|
||||
(self.root / 'pressure').write_text(f'some avg10={psi} avg60=0.00 avg300=0.00 total=1234\n')
|
||||
|
||||
def run_policy(self, body):
|
||||
functions = '\n'.join(function_source(name) for name in (
|
||||
'bl_memory_read', 'bl_memory_tight', 'bl_memory_critical', 'bl_low_ram',
|
||||
'bl_session_pressure', 'bl_watch_nap', 'bl_zram_pick', 'bl_tier_note', 'bl_tier_readback'))
|
||||
functions = functions.replace('/proc/meminfo', str(self.root / 'meminfo'))
|
||||
functions = functions.replace('/proc/pressure/memory', str(self.root / 'pressure'))
|
||||
harness = '''
|
||||
set -u
|
||||
_bl_game=1 _bl_tier_at=-1 _bl_tier_backoff=30 _bl_tier_pushed=0 _bl_tier_back=0 _bl_tier_targets=() _bl_tier_swaps=() _bl_tier_starts=() _bl_pids=(999)
|
||||
_bl_pressure_at=-1 _bl_packed=-1
|
||||
rss=$((1536 * 1024)) swap=0 pushed=$((1024 * 1024)) alive=999 started=100 complete=1
|
||||
step() { :; }
|
||||
bl_proc_start() { _bl_start=; case " $alive " in *" $1 "*) _bl_start=$started; return 0 ;; esac; return 1; }
|
||||
nap() { echo "nap:$1"; }
|
||||
bl_session_others() { _bl_session=(321 654); }
|
||||
bl_zram_usage() { _bl_rss=$rss _bl_swap=$swap; }
|
||||
bl_zram_send() { echo "send:$1:$3"; rss=$((rss - pushed)); swap=$((swap + pushed)); _bl_pageout_complete=$complete; }
|
||||
''' + functions + '\nbl_memory_read\n' + body
|
||||
result = run_bash(harness)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertEqual('', result.stderr)
|
||||
return result.stdout.splitlines()
|
||||
|
||||
def test_low_headroom_compresses_the_game_session_at_most_every_30_seconds(self):
|
||||
self.assertEqual(['send:1:game', 'send:1:game'], self.run_policy('''
|
||||
SECONDS=0; bl_session_pressure
|
||||
SECONDS=10; bl_session_pressure
|
||||
SECONDS=29; bl_session_pressure
|
||||
SECONDS=30; bl_session_pressure
|
||||
'''))
|
||||
|
||||
def test_twelve_gigabyte_devices_are_included_and_larger_ones_are_not(self):
|
||||
for total, expected in ((11264, ['send:1:game']), (12288, ['send:1:game']), (15360, [])):
|
||||
with self.subTest(total=total):
|
||||
self.memory(total=total)
|
||||
self.assertEqual(expected, self.run_policy('bl_session_pressure'))
|
||||
|
||||
def test_no_game_does_not_compress_the_session(self):
|
||||
self.assertEqual([], self.run_policy('_bl_game=0; bl_session_pressure'))
|
||||
|
||||
def test_enough_available_memory_does_not_compress_even_with_stalls(self):
|
||||
self.memory(available=3072, psi='5.00')
|
||||
self.assertEqual([], self.run_policy('bl_session_pressure'))
|
||||
|
||||
def test_stalls_with_moderate_headroom_compress_before_the_hard_limit(self):
|
||||
self.memory(available=2048, psi='1.50')
|
||||
self.assertEqual(['send:1:game'], self.run_policy('bl_session_pressure'))
|
||||
|
||||
def test_moderate_headroom_without_stalls_waits(self):
|
||||
self.memory(available=2048)
|
||||
self.assertEqual([], self.run_policy('bl_session_pressure'))
|
||||
|
||||
def test_game_reading_back_most_of_a_pageout_skips_and_doubles_the_wait(self):
|
||||
self.assertEqual(['send:1:game', 'backoff:60', 'send:1:game', 'backoff:60',
|
||||
'send:1:game', 'backoff:30'], self.run_policy('''
|
||||
pushed=$((512 * 1024))
|
||||
SECONDS=0; bl_session_pressure
|
||||
swap=$((swap / 4))
|
||||
SECONDS=30; bl_session_pressure
|
||||
echo "backoff:$_bl_tier_backoff"
|
||||
SECONDS=60; bl_session_pressure
|
||||
SECONDS=90; bl_session_pressure
|
||||
echo "backoff:$_bl_tier_backoff"
|
||||
SECONDS=150; bl_session_pressure
|
||||
echo "backoff:$_bl_tier_backoff"
|
||||
'''))
|
||||
|
||||
def test_repeated_read_back_keeps_doubling_up_to_five_minutes(self):
|
||||
self.assertEqual(['backoff:300'], self.run_policy('''
|
||||
_bl_tier_backoff=240 _bl_tier_at=0 _bl_tier_targets=(999) _bl_tier_swaps=($((1024 * 1024))) _bl_tier_starts=(100) _bl_tier_pushed=$((1024 * 1024)) swap=0
|
||||
SECONDS=240; bl_session_pressure
|
||||
echo "backoff:$_bl_tier_backoff"
|
||||
'''))
|
||||
|
||||
def test_nearly_out_of_memory_ignores_the_backoff_but_keeps_30_seconds(self):
|
||||
self.memory(available=512)
|
||||
self.assertEqual(['send:1:game', 'send:1:game'], self.run_policy('''
|
||||
_bl_tier_backoff=300
|
||||
SECONDS=0; bl_session_pressure
|
||||
swap=0
|
||||
SECONDS=20; bl_session_pressure
|
||||
SECONDS=30; bl_session_pressure
|
||||
'''))
|
||||
|
||||
def test_small_swap_headroom_skips_the_pageout(self):
|
||||
self.memory(swap_free=1536, swap_total=2048)
|
||||
self.assertEqual([], self.run_policy('bl_session_pressure'))
|
||||
|
||||
def test_small_session_is_left_alone(self):
|
||||
self.assertEqual([], self.run_policy('rss=$((128 * 1024)); bl_session_pressure'))
|
||||
|
||||
def test_watch_polls_every_second_near_the_limit_and_stops_when_critical(self):
|
||||
self.memory(available=2560)
|
||||
self.assertEqual(['nap:1', 'nap:1', 'nap:1', 'nap:1', 'nap:1'], self.run_policy('bl_watch_nap'))
|
||||
self.memory(available=1024)
|
||||
self.assertEqual(['nap:1'], self.run_policy('_bl_available=$((2560 * 1024)); bl_watch_nap'))
|
||||
self.memory(available=4096)
|
||||
self.assertEqual(['nap:5'], self.run_policy('bl_watch_nap'))
|
||||
self.assertEqual(['nap:5'], self.run_policy('_bl_game=0; bl_watch_nap'))
|
||||
|
||||
def test_one_push_compresses_the_game_and_the_launcher_and_restarts_the_launcher_wait(self):
|
||||
self.assertEqual(['send:1:game', 'pids:321 654 999', 'client:0:0'], self.run_policy('''
|
||||
SECONDS=0; bl_session_pressure
|
||||
bl_zram_pick game; echo "pids:${_pids[*]}"
|
||||
echo "client:$_bl_pressure_at:$_bl_packed"
|
||||
'''))
|
||||
|
||||
def test_launcher_compression_turned_off_keeps_the_launcher_out(self):
|
||||
self.assertEqual(['pids:321 654', 'client:-1:-1'], self.run_policy('''
|
||||
BL_ZRAM_CLIENT=0
|
||||
SECONDS=0; bl_session_pressure
|
||||
bl_zram_pick game; echo "pids:${_pids[*]}"
|
||||
echo "client:$_bl_pressure_at:$_bl_packed"
|
||||
''')[1:])
|
||||
|
||||
def test_low_ram_devices_compress_with_a_quarter_of_memory_left(self):
|
||||
self.memory(available=1700)
|
||||
self.assertEqual(['send:1:game'], self.run_policy('bl_session_pressure'))
|
||||
self.memory(available=1900)
|
||||
self.assertEqual([], self.run_policy('bl_session_pressure'))
|
||||
|
||||
|
||||
def test_a_compressed_process_that_exits_is_not_counted_as_read_back(self):
|
||||
self.assertEqual(['send:1:game', 'send:1:game', 'backoff:30'], self.run_policy('''
|
||||
SECONDS=0; bl_session_pressure
|
||||
alive=""; swap=0
|
||||
SECONDS=30; bl_session_pressure
|
||||
echo "backoff:$_bl_tier_backoff"
|
||||
'''))
|
||||
|
||||
def test_a_process_that_starts_after_a_pageout_is_not_in_its_read_back(self):
|
||||
self.assertEqual(['send:1:game', 'targets:999', 'send:1:game', 'backoff:30'], self.run_policy('''
|
||||
SECONDS=0; bl_session_pressure
|
||||
echo "targets:${_bl_tier_targets[*]}"
|
||||
alive="999 1234"
|
||||
SECONDS=30; bl_session_pressure
|
||||
echo "backoff:$_bl_tier_backoff"
|
||||
'''))
|
||||
|
||||
def test_an_incomplete_pageout_does_not_restart_the_launcher_wait(self):
|
||||
self.assertEqual(['send:1:game', 'client:-1:-1'], self.run_policy('''
|
||||
complete=0
|
||||
SECONDS=0; bl_session_pressure
|
||||
echo "client:$_bl_pressure_at:$_bl_packed"
|
||||
'''))
|
||||
|
||||
|
||||
def test_a_reused_process_id_is_not_counted_as_read_back(self):
|
||||
self.assertEqual(['send:1:game', 'send:1:game', 'backoff:30'], self.run_policy('''
|
||||
SECONDS=0; bl_session_pressure
|
||||
started=200; swap=0
|
||||
SECONDS=30; bl_session_pressure
|
||||
echo "backoff:$_bl_tier_backoff"
|
||||
'''))
|
||||
|
||||
def test_process_start_comes_from_stat_and_zombies_have_none(self):
|
||||
stat = self.root / 'stat'
|
||||
source = function_source('bl_proc_start').replace('/proc/$1/stat', str(stat))
|
||||
fields = ' '.join(['0'] * 17)
|
||||
for state, expected in (('S', ['ok:4242']), ('Z', ['gone:'])):
|
||||
with self.subTest(state=state):
|
||||
stat.write_text(f'77 (Wow (arm64).exe) {state} 1 {fields} 4242 99 0\n')
|
||||
result = run_bash(source + '\nif bl_proc_start 77; then echo "ok:$_bl_start"; else echo "gone:$_bl_start"; fi')
|
||||
self.assertEqual(expected, result.stdout.split())
|
||||
stat.unlink()
|
||||
result = run_bash(source + '\nbl_proc_start 77 || echo gone')
|
||||
self.assertEqual(['gone'], result.stdout.split())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user