Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
facaa18a3c | ||
|
|
8838cf28ed | ||
|
|
65f44e2b80 | ||
|
|
e321f5025d | ||
|
|
a105e4e49c | ||
|
|
2016a363f2 | ||
|
|
1873a9aee7 | ||
|
|
d5628f7b70 | ||
|
|
e10fc86a76 |
No files matched your search
@@ -47,7 +47,7 @@ Compatibility and performance vary by device; hardware validation is limited. De
|
||||
|
||||
## Credits and licence
|
||||
|
||||
GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
|
||||
GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
|
||||
|
||||
XR glasses support builds on WinNative's VITURE glasses work. VITURE protocol details were checked against [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) by [@wheaney](https://github.com/wheaney), including the VITURE protocol header it ships, [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) by [@badicsalex](https://github.com/badicsalex) and the [go-macos/iokit VITURE package](https://pkg.go.dev/github.com/go-macos/iokit/viture); the optional VITURE SDK belongs to VITURE and is not included. RayNeo support adapts [@informalTechCode](https://github.com/informalTechCode)'s [RayNeo-Air-Series-3DoF-Scaffold](https://github.com/informalTechCode/RayNeo-Air-Series-3DoF-Scaffold) (Apache-2.0) and references [@verncat](https://github.com/verncat)'s [RayNeo-Air-3S-Pro-OpenVR](https://github.com/verncat/RayNeo-Air-3S-Pro-OpenVR) (MIT) and [Monado](https://gitlab.freedesktop.org/monado/monado) (BSL-1.0); see [docs/third-party/rayneo](docs/third-party/rayneo/README.md). XREAL 1S support references [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) and [xrealOneDeviceKit](https://github.com/wheaney/xrealOneDeviceKit) by @wheaney, [xreal_one_driver](https://github.com/rohitsangwan01/xreal_one_driver) by [@rohitsangwan01](https://github.com/rohitsangwan01) (MIT) and [xr-tools](https://github.com/0xcaff/xr-tools) by [@0xcaff](https://github.com/0xcaff). The REDMAGIC glasses-display findings draw on [@mekhontsev](https://github.com/mekhontsev)'s [MagicDesk investigation](https://github.com/mekhontsev/magicdesk/blob/main/docs/xr-resolution-fix.md) and [@Coding-BR](https://github.com/Coding-BR)'s [decompiled Nubia kernel](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube). VITURE, RayNeo, XREAL and REDMAGIC are trademarks of their owners; this project is not affiliated with them.
|
||||
|
||||
|
||||
@@ -192,6 +192,8 @@ android {
|
||||
unitTests.returnDefaultValues = true
|
||||
unitTests.includeAndroidResources = true
|
||||
unitTests.all {
|
||||
// Robolectric keeps a sandbox per SDK level the tests ask for; seven no longer fit in Gradle's default 512 MB.
|
||||
maxHeapSize = '2g'
|
||||
def logTarget = providers.systemProperty('robolectric.logging').orNull
|
||||
if (logTarget != null) systemProperty 'robolectric.logging', logTarget
|
||||
}
|
||||
|
||||
@@ -65,11 +65,6 @@
|
||||
android:exported="true"
|
||||
android:screenOrientation="sensorLandscape"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density|keyboard|keyboardHidden|navigation">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.CAR_LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
@@ -88,6 +83,31 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity-alias
|
||||
android:name=".LauncherDark"
|
||||
android:targetActivity=".MainActivity"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.CAR_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".LauncherLight"
|
||||
android:targetActivity=".MainActivity"
|
||||
android:icon="@mipmap/ic_launcher_light"
|
||||
android:enabled="false"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.CAR_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<activity
|
||||
android:name=".HomeActivity"
|
||||
android:enabled="false"
|
||||
|
||||
@@ -35,5 +35,6 @@ class App : Application() {
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.gog.GogBackend)
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.epic.EpicBackend)
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.amazon.AmazonBackend)
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.itch.ItchBackend)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.droiddeck.launcher
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
|
||||
object LauncherIcon {
|
||||
const val DARK = "dark"
|
||||
const val LIGHT = "light"
|
||||
|
||||
private val aliases = linkedMapOf(DARK to "com.droiddeck.launcher.LauncherDark", LIGHT to "com.droiddeck.launcher.LauncherLight")
|
||||
|
||||
val options: List<Pair<String, Int>> = listOf(
|
||||
DARK to R.string.setup_launcher_icon_dark,
|
||||
LIGHT to R.string.setup_launcher_icon_light,
|
||||
)
|
||||
|
||||
private fun component(context: Context, style: String) =
|
||||
ComponentName(context, aliases.getValue(style))
|
||||
|
||||
private fun enabled(context: Context, style: String): Boolean =
|
||||
when (context.packageManager.getComponentEnabledSetting(component(context, style))) {
|
||||
PackageManager.COMPONENT_ENABLED_STATE_ENABLED -> true
|
||||
PackageManager.COMPONENT_ENABLED_STATE_DEFAULT -> style == DARK
|
||||
else -> false
|
||||
}
|
||||
|
||||
fun current(context: Context): String = aliases.keys.firstOrNull { enabled(context, it) } ?: DARK
|
||||
|
||||
fun activity(context: Context): ComponentName = component(context, current(context))
|
||||
|
||||
fun set(context: Context, style: String) {
|
||||
if (style !in aliases || style == current(context)) return
|
||||
val pm = context.packageManager
|
||||
pm.setComponentEnabledSetting(component(context, style),
|
||||
PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP)
|
||||
aliases.keys.filter { it != style }.forEach {
|
||||
pm.setComponentEnabledSetting(component(context, it),
|
||||
PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,6 +164,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var downloadMirror by mutableStateOf("")
|
||||
private var downloadPrefix by mutableStateOf("")
|
||||
private var animationsEnabled by mutableStateOf(true)
|
||||
private var launcherIcon by mutableStateOf(LauncherIcon.DARK)
|
||||
private var gameStoresEnabled by mutableStateOf(true)
|
||||
private var gameStoresSpeedTier by mutableStateOf("fast")
|
||||
private var storesOpenTab by mutableStateOf(SessionPrefs.STORES_OPEN_LIBRARY)
|
||||
@@ -484,6 +485,20 @@ class MainActivity : ComponentActivity() {
|
||||
pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.game_file_folder), gameSyncFolder))
|
||||
}
|
||||
|
||||
private fun switchLauncherIcon(style: String): Boolean {
|
||||
val before = LauncherIcon.current(this)
|
||||
LauncherIcon.set(this, style)
|
||||
launcherIcon = LauncherIcon.current(this)
|
||||
if (launcherIcon == before) return false
|
||||
// Android finishes activities started through the alias that was just disabled.
|
||||
startActivity(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
.setComponent(LauncherIcon.activity(this))
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
|
||||
.putExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV, "setup:launcher"))
|
||||
finishAndRemoveTask()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun readGameIntent(request: Intent) {
|
||||
request.getStringExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV)?.let { navRequest = it; request.removeExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV) }
|
||||
shortcutPicker = request.action == Intent.ACTION_CREATE_SHORTCUT
|
||||
@@ -739,6 +754,7 @@ class MainActivity : ComponentActivity() {
|
||||
downloadPrefix = DownloadSource.custom(this)
|
||||
DownloadSource.probe()
|
||||
animationsEnabled = SessionPrefs.animationsEnabled(this)
|
||||
launcherIcon = LauncherIcon.current(this)
|
||||
gameStoresEnabled = SessionPrefs.gameStoresEnabled(this)
|
||||
gameStoresSpeedTier = SessionPrefs.gameStoresSpeedTier(this)
|
||||
storesOpenTab = SessionPrefs.storesOpenTab(this)
|
||||
@@ -799,6 +815,7 @@ class MainActivity : ComponentActivity() {
|
||||
downloadMirror = downloadMirror,
|
||||
downloadPrefix = downloadPrefix,
|
||||
animationsEnabled = animationsEnabled,
|
||||
launcherIcon = launcherIcon,
|
||||
gameStoresEnabled = gameStoresEnabled,
|
||||
gameStoresSpeedTier = gameStoresSpeedTier,
|
||||
storesOpenTab = storesOpenTab,
|
||||
@@ -952,6 +969,7 @@ class MainActivity : ComponentActivity() {
|
||||
onPageBack = { showPerformance = false; showComponents = false; showMapping = false },
|
||||
onGameStoresEnabled = { on -> SessionPrefs.setGameStoresEnabled(this, on); gameStoresEnabled = on },
|
||||
onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id },
|
||||
onLauncherIcon = { style -> switchLauncherIcon(style) },
|
||||
onLanguage = { tag ->
|
||||
if (tag != com.droiddeck.launcher.core.AppLanguage.chosen(this)) {
|
||||
com.droiddeck.launcher.core.AppLanguage.setChosen(this, tag)
|
||||
@@ -1882,7 +1900,7 @@ class MainActivity : ComponentActivity() {
|
||||
val listX64 = SessionPrefs.activeSteamClientArch(this) == "x86_64"
|
||||
if (listX64 != steamGamesX64) {
|
||||
steamGamesX64 = listX64
|
||||
steamGames = steamGames.filterNot(Library::fromSteamClient)
|
||||
steamGames = steamGames.filterNot { Library.fromSteamClient(it) || (it.library == Library.ADDED && it.gameId in 1..0xFFFFFFFFL) }
|
||||
}
|
||||
if (!busy && LinuxRuntimeInstaller.isBusy()) followRuntimeOperation { LinuxRuntimeInstaller.attach(it) }
|
||||
if (showComponents) drivers.refreshDrivers()
|
||||
@@ -1916,12 +1934,13 @@ class MainActivity : ComponentActivity() {
|
||||
ui.post { if (scanGeneration == libraryScanGeneration) emulatorList = emus }
|
||||
if (ready) {
|
||||
val quick = runCatching { Library.steamGames(this) }.getOrNull()
|
||||
val claimed = quick?.let { runCatching { Library.claimedFolders(it) }.getOrDefault(emptySet()) }.orEmpty()
|
||||
if (quick != null) ui.post {
|
||||
if (scanGeneration == libraryScanGeneration && steamGames.isNotEmpty()) {
|
||||
val before = steamGames.map { it.gameId }.toSet()
|
||||
val fresh = quick.filter { it.gameId !in before }
|
||||
if (fresh.isNotEmpty()) {
|
||||
steamGames = (quick + steamGames.filter { it.library == Library.ADDED }).distinctBy { it.gameId }
|
||||
steamGames = (quick + steamGames.filter { it.library == Library.ADDED && it.gameFiles?.absolutePath.let { path -> path == null || path !in claimed } }).distinctBy { it.gameId }
|
||||
fresh.forEach { com.droiddeck.launcher.frontend.AutoComponents.queueGame(this, it, asked = false) }
|
||||
}
|
||||
}
|
||||
@@ -2044,7 +2063,7 @@ class MainActivity : ComponentActivity() {
|
||||
com.droiddeck.launcher.core.AppLanguage.setChosen(this, com.droiddeck.launcher.core.AppLanguage.SYSTEM)
|
||||
if (!ok) android.widget.Toast.makeText(this, R.string.setup_reset_settings_partial, android.widget.Toast.LENGTH_LONG).show()
|
||||
com.droiddeck.launcher.ui.Motion.refresh(this)
|
||||
recreate()
|
||||
if (!switchLauncherIcon(LauncherIcon.DARK)) recreate()
|
||||
}
|
||||
}, "reset-settings").start()
|
||||
}
|
||||
|
||||
@@ -1609,8 +1609,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
glassesScreen?.hz?.takeIf { it > 1f }?.let { return it }
|
||||
val display = if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay
|
||||
val wanted = window.attributes.preferredDisplayModeId
|
||||
val hz = display?.supportedModes?.firstOrNull { wanted != 0 && it.modeId == wanted }?.refreshRate
|
||||
?: display?.refreshRate ?: 60f
|
||||
val requested = display?.supportedModes?.firstOrNull { wanted != 0 && it.modeId == wanted }?.refreshRate
|
||||
val running = display?.refreshRate?.takeIf { it > 1f }
|
||||
val hz = listOfNotNull(requested, running).minOrNull() ?: 60f
|
||||
val mirrorCap = mirrorRefreshLimit().takeIf { it > 0 }?.toFloat() ?: Float.POSITIVE_INFINITY
|
||||
val capped = minOf(hz, PerfMode.peakRefreshCap(this), mirrorCap)
|
||||
return if (capped > 1f) capped else 60f
|
||||
|
||||
@@ -10,6 +10,7 @@ import android.graphics.BitmapFactory
|
||||
import android.graphics.drawable.Icon
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import com.droiddeck.launcher.LauncherIcon
|
||||
import com.droiddeck.launcher.MainActivity
|
||||
import com.droiddeck.launcher.R
|
||||
import java.io.File
|
||||
@@ -23,7 +24,7 @@ object GameShortcuts {
|
||||
val name = game.name.ifBlank { context.getString(R.string.app_name) }
|
||||
val icon = iconBitmap(game)?.let(Icon::createWithBitmap) ?: Icon.createWithResource(context, R.mipmap.ic_launcher)
|
||||
return ShortcutInfo.Builder(context, "game:${game.gameIdString}")
|
||||
.setActivity(android.content.ComponentName(context, MainActivity::class.java))
|
||||
.setActivity(LauncherIcon.activity(context))
|
||||
.setShortLabel(name.take(40)).setLongLabel(name).setIcon(icon)
|
||||
.setIntent(launchIntent(context, game.gameIdString)).build()
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ object Library {
|
||||
private val NAME = Regex("^\\s*\"name\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE)
|
||||
private val STATE = Regex("^\\s*\"StateFlags\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
|
||||
private val LAST_PLAYED = Regex("^\\s*\"LastPlayed\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
|
||||
private val PRIVATE_APPS = Regex("\"PrivateApps_\\d+\"\\s*\"\\[([^\\]]*)\\]\"")
|
||||
private val INSTALL_DIR = Regex("^\\s*\"installdir\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE)
|
||||
|
||||
internal fun steamLibraries(context: Context, arch: String = SessionPrefs.activeSteamClientArch(context)): List<Pair<File, String>> {
|
||||
@@ -137,13 +138,35 @@ object Library {
|
||||
}
|
||||
|
||||
fun steamLibraryArt(context: Context): List<Pair<Int, File>> {
|
||||
val cache = File(SessionPrefs.activeSteamRoot(context), "appcache/librarycache")
|
||||
val root = SessionPrefs.activeSteamRoot(context)
|
||||
val cache = File(root, "appcache/librarycache")
|
||||
val concealed = concealedSteamApps(root)
|
||||
return cache.listFiles().orEmpty().mapNotNull { dir ->
|
||||
val appId = dir.name.toIntOrNull()?.takeIf { it !in NOT_GAMES } ?: return@mapNotNull null
|
||||
val appId = dir.name.toIntOrNull()?.takeIf { it !in NOT_GAMES && it.toLong() !in concealed } ?: return@mapNotNull null
|
||||
steamCacheImage(cache, appId, STEAM_CAPSULES)?.let { appId to it }
|
||||
}
|
||||
}
|
||||
|
||||
/** Apps the user hid (the "hidden" collection) or marked private in Steam. */
|
||||
internal fun concealedSteamApps(root: File): Set<Long> {
|
||||
val config = AddedGames.accountConfig(root) ?: return emptySet()
|
||||
val hidden = runCatching {
|
||||
val entries = org.json.JSONArray(File(config, "cloudstorage/cloud-storage-namespace-1.json").readText())
|
||||
val entry = (0 until entries.length()).map { entries.getJSONArray(it) }
|
||||
.firstOrNull { it.optString(0) == "user-collections.hidden" }?.optJSONObject(1)
|
||||
if (entry == null || entry.optBoolean("is_deleted")) emptySet() else {
|
||||
val collection = org.json.JSONObject(entry.optString("value"))
|
||||
fun ids(key: String) = collection.optJSONArray(key)?.let { a -> (0 until a.length()).map { a.optLong(it) } }.orEmpty()
|
||||
ids("added").toSet() - ids("removed").toSet()
|
||||
}
|
||||
}.getOrDefault(emptySet())
|
||||
val private = runCatching {
|
||||
PRIVATE_APPS.find(File(config, "localconfig.vdf").readText())?.groupValues?.get(1)
|
||||
?.split(',')?.mapNotNull { it.trim().toLongOrNull() }?.toSet()
|
||||
}.getOrNull().orEmpty()
|
||||
return hidden + private
|
||||
}
|
||||
|
||||
fun fromSteamClient(g: SteamGame): Boolean = g.library != ADDED && g.source == SOURCE_STEAM
|
||||
|
||||
fun launchableGames(
|
||||
@@ -155,9 +178,23 @@ object Library {
|
||||
var steam = steamGames(context, strictRead, arch)
|
||||
val targets = steam.filter(::fromSteamClient).map { SteamGrid.target(it, context) } + list.map { SteamGrid.target(it, context) }
|
||||
if (runCatching { SteamGrid.sync(context, targets) }.getOrDefault(false)) steam = steamGames(context, strictRead, arch)
|
||||
return (steam + list.map { g -> addedGame(context, g, roots) }).distinctBy { it.gameId }
|
||||
val claimed = claimedFolders(steam)
|
||||
val own = list.filter { g ->
|
||||
g.steamAppId != null || claimed.isEmpty() ||
|
||||
(runCatching { g.folder.canonicalPath }.getOrDefault(g.folder.absolutePath) !in claimed &&
|
||||
AddedGames.guestPath(context, g.folder).let { it == null || it !in claimed })
|
||||
}
|
||||
return (steam + own.map { g -> addedGame(context, g, roots) }).distinctBy { it.gameId }
|
||||
}
|
||||
|
||||
fun claimedFolders(steam: List<SteamGame>): Set<String> = steam.filter(::fromSteamClient).flatMap { g ->
|
||||
val dir = g.gameFiles ?: return@flatMap emptyList()
|
||||
listOfNotNull(
|
||||
runCatching { dir.canonicalPath }.getOrNull(),
|
||||
runCatching { java.nio.file.Files.readSymbolicLink(dir.toPath()).toString() }.getOrNull(),
|
||||
)
|
||||
}.toSet()
|
||||
|
||||
fun addedGame(context: Context, g: AddedGames.Game, roots: List<File> = steamLibraries(context).map { it.first }): SteamGame =
|
||||
AddedGameArt.resolve(context, g).let { art ->
|
||||
SteamGame(
|
||||
|
||||
@@ -11,7 +11,7 @@ import java.io.File
|
||||
|
||||
object GuestCommand {
|
||||
fun run(context: Context, argv: List<String>, fakeRoot: Boolean = false, logName: String? = null,
|
||||
linkDir: File? = null, onLine: (String) -> Unit): Int {
|
||||
linkDir: File? = null, extraBinds: List<String>? = null, fastPath: Boolean = false, onLine: (String) -> Unit): Int {
|
||||
val log = logName?.let {
|
||||
try {
|
||||
File(File(LinuxRuntime.logDir(context), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter()
|
||||
@@ -19,13 +19,14 @@ object GuestCommand {
|
||||
}
|
||||
log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}")
|
||||
try {
|
||||
return runLogged(context, argv, fakeRoot, linkDir) { line -> log?.println(line); log?.flush(); onLine(line) }
|
||||
return runLogged(context, argv, fakeRoot, linkDir, extraBinds, fastPath) { line -> log?.println(line); log?.flush(); onLine(line) }
|
||||
} finally {
|
||||
log?.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun runLogged(context: Context, argv: List<String>, fakeRoot: Boolean, linkDir: File?, onLine: (String) -> Unit): Int {
|
||||
private fun runLogged(context: Context, argv: List<String>, fakeRoot: Boolean, linkDir: File?, extraBinds: List<String>?,
|
||||
fastPath: Boolean, onLine: (String) -> Unit): Int {
|
||||
val root = LinuxRuntime.rootDir(context)
|
||||
LinuxRuntime.writeAccounts(context)
|
||||
SessionFiles.stage(context, root)
|
||||
@@ -33,8 +34,10 @@ object GuestCommand {
|
||||
val runtimeDir = File(context.filesDir, ".flatpak-rt").apply { mkdirs() }
|
||||
val cmd = LinuxRuntime.prootPrefix(context, root, "/root", fakeRoot)
|
||||
if (linkDir != null) cmd.add(1, "--link2symlink")
|
||||
LinuxRuntime.binds(context, null, runtimeDir, Environment.getExternalStorageDirectory(), null)
|
||||
.forEach { cmd.add("-b"); cmd.add(it) }
|
||||
val binds = LinuxRuntime.binds(context, null, runtimeDir, Environment.getExternalStorageDirectory(), extraBinds)
|
||||
binds.forEach { cmd.add("-b"); cmd.add(it) }
|
||||
// Fake root and link2symlink are emulated by proot, so their syscalls must stay on the traced path.
|
||||
val fastPathKey = if (fastPath && !fakeRoot && linkDir == null && ProotFastPath.enabled(context)) ProotFastPath.key(root, binds) else null
|
||||
cmd += listOf(
|
||||
"/usr/bin/env", "-i", "HOME=/root", "USER=root", "LANG=C.UTF-8",
|
||||
"PATH=/usr/local/bin:/usr/bin:/bin", "XDG_RUNTIME_DIR=${LinuxRuntime.GUEST_RUNTIME_DIR}",
|
||||
@@ -42,6 +45,7 @@ object GuestCommand {
|
||||
"DROIDDECK_BWRAP_SOCKET=${BwrapSpawner.socket(context)}",
|
||||
"XDG_DATA_DIRS=/root/.local/share/flatpak/exports/share:/usr/local/share:/usr/share",
|
||||
)
|
||||
fastPathKey?.let { cmd += ProotFastPath.guestEnv(root, binds, it) }
|
||||
cmd += argv
|
||||
val builder = ProcessBuilder(cmd).directory(root).redirectErrorStream(true)
|
||||
builder.environment().apply {
|
||||
@@ -49,6 +53,7 @@ object GuestCommand {
|
||||
put("PROOT_TMP_DIR", LinuxRuntime.prootTempDir(context).path)
|
||||
if (linkDir != null) put("PROOT_L2S_DIR", linkDir.path)
|
||||
if (SessionPrefs.prootNoSeccomp(context)) put("PROOT_NO_SECCOMP", "1")
|
||||
fastPathKey?.let { ProotFastPath.hostEnv(it).let { (k, v) -> put(k, v) } }
|
||||
LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
|
||||
}
|
||||
val process = builder.start()
|
||||
|
||||
@@ -214,6 +214,9 @@ public final class LinuxRuntime {
|
||||
bind(cmd, fake.getPath() + ":" + entry[1]);
|
||||
}
|
||||
}
|
||||
if (unreadablePciDevice()) {
|
||||
bind(cmd, new File(root, "etc/droiddeck/empty").getPath() + ":/sys/bus/pci/devices");
|
||||
}
|
||||
bindGpuNode(context, cmd);
|
||||
bindAdrenoStats(cmd, extraBinds);
|
||||
bindCpuTemps(cmd, root);
|
||||
@@ -225,6 +228,15 @@ public final class LinuxRuntime {
|
||||
return specs;
|
||||
}
|
||||
|
||||
private static boolean unreadablePciDevice() {
|
||||
File[] devices = new File("/sys/bus/pci/devices").listFiles();
|
||||
if (devices == null) return false;
|
||||
for (File device : devices) {
|
||||
if (!new File(device, "vendor").canRead()) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void bindGpuNode(Context context, List<String> cmd) {
|
||||
StructStat st;
|
||||
try {
|
||||
|
||||
@@ -57,7 +57,7 @@ object CloudSaves {
|
||||
val item = sidecar.extra["catalogItemId"].orEmpty()
|
||||
if (ns.isEmpty() || item.isEmpty()) "" else EpicApiClient.getCustomAttribute(token, ns, item, "CloudSaveFolder")
|
||||
}
|
||||
Store.AMAZON -> ""
|
||||
Store.AMAZON, Store.ITCH -> ""
|
||||
} ?: return null
|
||||
p.edit().putString("cloud_template_$id", fetched).apply()
|
||||
Log.i(TAG, "cloud ${store.id} $id template ${if (fetched.isEmpty()) "none" else "found"}")
|
||||
@@ -95,7 +95,7 @@ object CloudSaves {
|
||||
val dir = when (store) {
|
||||
Store.GOG -> CloudSavePaths.gog(template, pfx, folder)
|
||||
Store.EPIC -> CloudSavePaths.epic(template, pfx, folder, EpicCredentialStore.load(context)?.accountId.orEmpty(), id)
|
||||
Store.AMAZON -> null
|
||||
Store.AMAZON, Store.ITCH -> null
|
||||
}
|
||||
return dir to (if (dir == null) "unresolved" else "ok")
|
||||
}
|
||||
@@ -120,7 +120,7 @@ object CloudSaves {
|
||||
val account = EpicCredentialStore.load(context)?.accountId.orEmpty()
|
||||
if (token == null || account.isEmpty()) null else EpicCloud(account, id, token)
|
||||
}
|
||||
Store.AMAZON -> null
|
||||
Store.AMAZON, Store.ITCH -> null
|
||||
}
|
||||
|
||||
private fun gogScopedToken(context: Context, id: String, clientId: String): String? {
|
||||
|
||||
@@ -12,6 +12,7 @@ internal object LoginFlows {
|
||||
Store.GOG -> GogFlow(state)
|
||||
Store.EPIC -> epicFlow()
|
||||
Store.AMAZON -> amazonFlow()
|
||||
Store.ITCH -> null
|
||||
}
|
||||
|
||||
@Volatile var epicFlow: () -> StoreLoginActivity.Flow? = { null }
|
||||
|
||||
@@ -3,7 +3,8 @@ package com.droiddeck.launcher.stores
|
||||
enum class Store(val id: String, val label: String, val shortLabel: String) {
|
||||
GOG("gog", "GOG", "GOG"),
|
||||
EPIC("epic", "Epic Games", "Epic"),
|
||||
AMAZON("amazon", "Amazon Games", "Amazon");
|
||||
AMAZON("amazon", "Amazon Games", "Amazon"),
|
||||
ITCH("itch", "itch.io", "itch");
|
||||
|
||||
companion object {
|
||||
fun byId(id: String?): Store? = entries.firstOrNull { it.id == id }
|
||||
|
||||
@@ -54,6 +54,7 @@ object StoreSizes {
|
||||
when (item.store) {
|
||||
Store.EPIC -> EpicPrefs.get(app).edit().putLong("size_${item.id}", it).apply()
|
||||
Store.AMAZON -> AmazonPrefs.get(app).edit().putLong("size_${item.id}", it).apply()
|
||||
Store.ITCH -> {}
|
||||
Store.GOG -> {}
|
||||
}
|
||||
}
|
||||
@@ -108,5 +109,6 @@ object StoreSizes {
|
||||
AmazonManifest.parse(bytes).totalInstallSize
|
||||
}
|
||||
Store.GOG -> 0L
|
||||
Store.ITCH -> com.droiddeck.launcher.stores.itch.ItchLibrary.size(context, item)
|
||||
} }
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import java.util.Locale
|
||||
|
||||
object StoresState {
|
||||
private const val TAG = "Stores"
|
||||
private const val SEARCH_DELAY_MS = 400L
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
val accounts = mutableStateMapOf<Store, String>()
|
||||
@@ -24,6 +25,8 @@ object StoresState {
|
||||
|
||||
val shelves = mutableStateMapOf<Store, StoreShelves>()
|
||||
|
||||
val found = mutableStateMapOf<Store, List<CatalogItem>>()
|
||||
|
||||
val status = mutableStateMapOf<Store, String>()
|
||||
|
||||
val problems = mutableStateMapOf<Store, String>()
|
||||
@@ -64,6 +67,8 @@ object StoresState {
|
||||
|
||||
private val backends = HashMap<Store, StoreBackend>()
|
||||
|
||||
private val searchSeq = java.util.concurrent.ConcurrentHashMap<Store, Int>()
|
||||
|
||||
fun backend(store: Store): StoreBackend? = backends[store]
|
||||
|
||||
fun register(backend: StoreBackend) { backends[backend.store] = backend }
|
||||
@@ -129,6 +134,26 @@ object StoresState {
|
||||
if (!shelves.containsKey(store)) backends[store]?.loadShelves(context.applicationContext, false)
|
||||
}
|
||||
|
||||
fun search(context: Context, store: Store, query: String) {
|
||||
val backend = backends[store] ?: return
|
||||
val text = query.trim()
|
||||
if (text.isEmpty()) {
|
||||
searchSeq.remove(store)
|
||||
found.remove(store)
|
||||
return
|
||||
}
|
||||
val seq = (searchSeq[store] ?: 0) + 1
|
||||
searchSeq[store] = seq
|
||||
val app = context.applicationContext
|
||||
Thread({
|
||||
Thread.sleep(SEARCH_DELAY_MS)
|
||||
if (searchSeq[store] != seq) return@Thread
|
||||
val items = runCatching { backend.search(app, text) }.getOrDefault(emptyList())
|
||||
if (searchSeq[store] != seq) return@Thread
|
||||
main.post { found[store] = items }
|
||||
}, "stores-search").start()
|
||||
}
|
||||
|
||||
fun signIn(context: Context, store: Store) {
|
||||
expired.remove(store)
|
||||
backends[store]?.signIn(context) ?: logLine("${store.label}: this build has no sign-in for it yet")
|
||||
@@ -265,6 +290,7 @@ interface StoreBackend {
|
||||
fun signOut(context: Context)
|
||||
fun syncLibrary(context: Context, force: Boolean)
|
||||
fun loadShelves(context: Context, force: Boolean)
|
||||
fun search(context: Context, query: String): List<CatalogItem> = emptyList()
|
||||
fun install(context: Context, item: CatalogItem, root: java.io.File)
|
||||
fun uninstall(context: Context, game: InstalledStoreGame)
|
||||
}
|
||||
@@ -335,7 +335,7 @@ object DownloadQueue {
|
||||
StoresState.logLine("installed \"${item.entry.name}\" → ${result}")
|
||||
StoresState.notifyLibraryChanged(context)
|
||||
val games = runCatching {
|
||||
com.droiddeck.launcher.frontend.Library.launchableGames(context).firstOrNull { it.gameFiles?.absolutePath == result }?.let { "app:${it.appId}" }
|
||||
com.droiddeck.launcher.frontend.LibraryCache.load(context).firstOrNull { it.gameFiles?.absolutePath == result }?.let { "app:${it.appId}" }
|
||||
}.getOrNull()
|
||||
StoreDownloadService.ended(context, item.entry, installed = true, gamesKey = games)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import com.droiddeck.launcher.stores.StoreNet
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
internal object ItchApi {
|
||||
const val KEYS_PAGE = "https://itch.io/user/settings/api-keys"
|
||||
|
||||
private const val BASE = "https://api.itch.io"
|
||||
private const val WEB = "https://itch.io"
|
||||
|
||||
fun get(path: String, key: String): JSONObject? {
|
||||
val body = StoreNet.get(BASE + path, bearer = key, timeoutMs = 25_000) ?: return null
|
||||
return runCatching { JSONObject(body) }.getOrNull()
|
||||
}
|
||||
|
||||
fun profile(key: String): JSONObject? = get("/profile", key)?.optJSONObject("user")
|
||||
|
||||
fun ownedKeys(key: String, page: Int): JSONObject? = get("/profile/owned-keys?page=$page&per_page=500", key)
|
||||
|
||||
fun uploads(key: String, gameId: String, downloadKeyId: String): JSONArray? =
|
||||
get("/games/$gameId/uploads?download_key_id=$downloadKeyId", key)?.optJSONArray("uploads")
|
||||
|
||||
fun search(key: String, query: String, page: Int): JSONArray? {
|
||||
val q = java.net.URLEncoder.encode(query, "UTF-8")
|
||||
return get("/search/games?query=$q&page=$page", key)?.optJSONArray("games")
|
||||
}
|
||||
|
||||
fun downloadUrl(uploadId: String, downloadKeyId: String): String =
|
||||
"$BASE/uploads/$uploadId/download?download_key_id=$downloadKeyId"
|
||||
|
||||
fun pageUrl(slug: String): String = "$WEB/$slug"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreAccounts
|
||||
import org.json.JSONObject
|
||||
|
||||
internal object ItchAuth {
|
||||
private val TOKEN = Regex("[A-Za-z0-9]{40,64}")
|
||||
|
||||
fun key(context: Context): String = StoreAccounts.read(context, Store.ITCH)?.optString("access_token").orEmpty()
|
||||
|
||||
fun name(context: Context): String = StoreAccounts.signedInAs(context, Store.ITCH).orEmpty()
|
||||
|
||||
fun verify(key: String): JSONObject? = ItchApi.profile(key)
|
||||
|
||||
fun save(context: Context, key: String, user: JSONObject) {
|
||||
StoreAccounts.write(
|
||||
context,
|
||||
Store.ITCH,
|
||||
JSONObject()
|
||||
.put("access_token", key)
|
||||
.put("username", user.optString("username"))
|
||||
.put("display_name", user.optString("display_name").ifBlank { user.optString("displayName") }.ifBlank { user.optString("username") })
|
||||
.put("user_id", user.optLong("id")),
|
||||
)
|
||||
}
|
||||
|
||||
fun candidates(text: String): List<String> {
|
||||
if (text.isBlank()) return emptyList()
|
||||
val out = LinkedHashSet<String>()
|
||||
text.lineSequence().map { it.trim() }.filter { TOKEN.matches(it) }.forEach { out.add(it) }
|
||||
TOKEN.findAll(text).forEach { out.add(it.value) }
|
||||
return out.take(8)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.InstalledStoreGame
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreBackend
|
||||
import com.droiddeck.launcher.stores.StoreInstallRoot
|
||||
import com.droiddeck.launcher.stores.StoreSizes
|
||||
import com.droiddeck.launcher.stores.StoresState
|
||||
import com.droiddeck.launcher.stores.download.DownloadEntry
|
||||
import com.droiddeck.launcher.stores.download.DownloadQueue
|
||||
import java.io.File
|
||||
|
||||
object ItchBackend : StoreBackend {
|
||||
override val store: Store = Store.ITCH
|
||||
|
||||
override fun signIn(context: Context) {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(ItchApi.KEYS_PAGE)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
|
||||
override fun signOut(context: Context) {
|
||||
ItchPrefs.get(context).edit().remove("library_cache").remove("library_synced_at").apply()
|
||||
StoresState.post {
|
||||
StoresState.library.remove(store)
|
||||
StoresState.shelves.remove(store)
|
||||
StoresState.found.remove(store)
|
||||
StoresState.problems.remove(store)
|
||||
StoresState.status.remove(store)
|
||||
}
|
||||
}
|
||||
|
||||
override fun syncLibrary(context: Context, force: Boolean) {
|
||||
val app = context.applicationContext
|
||||
Thread({ ItchLibrary.sync(app, force) }, "itch-sync").start()
|
||||
}
|
||||
|
||||
override fun loadShelves(context: Context, force: Boolean) {
|
||||
val app = context.applicationContext
|
||||
ItchShelves.cached(app)?.let { cached -> StoresState.post { StoresState.shelves[store] = cached } }
|
||||
Thread({
|
||||
val shelves = ItchShelves.featured(app, force)
|
||||
if (shelves != null) {
|
||||
StoresState.post { StoresState.shelves[store] = shelves }
|
||||
} else {
|
||||
StoresState.post { StoresState.problems[store] = app.getString(R.string.stores_itch_no_catalog) }
|
||||
}
|
||||
}, "itch-shelves").start()
|
||||
}
|
||||
|
||||
override fun search(context: Context, query: String): List<CatalogItem> = ItchShelves.search(context, query)
|
||||
|
||||
override fun install(context: Context, item: CatalogItem, root: File) {
|
||||
val app = context.applicationContext
|
||||
if (item.extra["downloadKeyId"].isNullOrEmpty()) {
|
||||
StoresState.logLine("itch.io: \"${item.title}\" is not in the library")
|
||||
return
|
||||
}
|
||||
val folder = StoreInstallRoot.folderFor(app, Store.ITCH, item.id, item.title, root)
|
||||
val entry = DownloadEntry(
|
||||
Store.ITCH, item.id, item.title, cover = item.imageUrl, bytesTotal = StoreSizes.size(item),
|
||||
location = StoreInstallRoot.labelFor(app, folder),
|
||||
)
|
||||
DownloadQueue.enqueue(app, entry) { ItchInstaller(app, item, folder) }
|
||||
}
|
||||
|
||||
override fun uninstall(context: Context, game: InstalledStoreGame) {}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreShelves
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
internal object ItchCatalog {
|
||||
fun slug(url: String): String = url.substringAfter("://", url).trimEnd('/')
|
||||
|
||||
fun shelvesJson(shelves: StoreShelves): String = JSONObject()
|
||||
.put("new", array(shelves.whatsNew))
|
||||
.put("deals", array(shelves.deals))
|
||||
.put("free", array(shelves.free))
|
||||
.put("top", array(shelves.trending))
|
||||
.toString()
|
||||
|
||||
fun shelves(raw: String?): StoreShelves? {
|
||||
if (raw.isNullOrBlank()) return null
|
||||
val o = runCatching { JSONObject(raw) }.getOrNull() ?: return null
|
||||
val shelves = StoreShelves(
|
||||
whatsNew = list(o.optJSONArray("new")),
|
||||
deals = list(o.optJSONArray("deals")),
|
||||
free = list(o.optJSONArray("free")),
|
||||
trending = list(o.optJSONArray("top")),
|
||||
)
|
||||
return shelves.takeUnless { it.isEmpty }
|
||||
}
|
||||
|
||||
fun array(items: List<CatalogItem>): JSONArray {
|
||||
val out = JSONArray()
|
||||
items.forEach { out.put(json(it)) }
|
||||
return out
|
||||
}
|
||||
|
||||
fun list(raw: JSONArray?): List<CatalogItem> {
|
||||
if (raw == null) return emptyList()
|
||||
return (0 until raw.length()).mapNotNull { raw.optJSONObject(it)?.let(::item) }
|
||||
}
|
||||
|
||||
private fun json(i: CatalogItem): JSONObject = JSONObject()
|
||||
.put("id", i.id)
|
||||
.put("title", i.title)
|
||||
.put("image", i.imageUrl ?: "")
|
||||
.put("tall", i.tallImageUrl ?: "")
|
||||
.put("tags", i.tags)
|
||||
.put("free", i.isFree)
|
||||
.put("hasPrice", i.hasPrice)
|
||||
.put("finalPrice", i.finalPrice)
|
||||
.put("originalPrice", i.originalPrice)
|
||||
.put("discount", i.discountPercent)
|
||||
.put("url", i.storeUrl)
|
||||
.put("developer", i.developer)
|
||||
.put("publisher", i.publisher)
|
||||
.put("releaseDate", i.releaseDate)
|
||||
.put("description", i.description)
|
||||
.put("owned", i.owned)
|
||||
.put("size", i.sizeBytes)
|
||||
.put("extra", JSONObject(i.extra))
|
||||
.put("mature", i.mature)
|
||||
|
||||
private fun item(o: JSONObject): CatalogItem {
|
||||
val extra = o.optJSONObject("extra")
|
||||
val map = HashMap<String, String>()
|
||||
extra?.keys()?.forEach { k -> map[k] = extra.optString(k) }
|
||||
return CatalogItem(
|
||||
store = Store.ITCH,
|
||||
id = o.optString("id"),
|
||||
title = o.optString("title"),
|
||||
imageUrl = o.optString("image").ifBlank { null },
|
||||
tallImageUrl = o.optString("tall").ifBlank { null },
|
||||
tags = o.optString("tags"),
|
||||
isFree = o.optBoolean("free"),
|
||||
hasPrice = o.optBoolean("hasPrice"),
|
||||
finalPrice = o.optString("finalPrice"),
|
||||
originalPrice = o.optString("originalPrice"),
|
||||
discountPercent = o.optInt("discount"),
|
||||
storeUrl = o.optString("url"),
|
||||
developer = o.optString("developer"),
|
||||
publisher = o.optString("publisher"),
|
||||
releaseDate = o.optString("releaseDate"),
|
||||
description = o.optString("description"),
|
||||
owned = o.optBoolean("owned"),
|
||||
sizeBytes = o.optLong("size"),
|
||||
extra = map,
|
||||
mature = o.optBoolean("mature"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.runtime.GuestCommand
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreExe
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import com.droiddeck.launcher.stores.StoreInstallRoot
|
||||
import com.droiddeck.launcher.stores.StoreInstalls
|
||||
import com.droiddeck.launcher.stores.StoreNet
|
||||
import com.droiddeck.launcher.stores.download.DownloadQueue
|
||||
import com.droiddeck.launcher.stores.download.DownloadStage
|
||||
import org.apache.commons.compress.archivers.sevenz.SevenZFile
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
|
||||
import org.apache.commons.compress.archivers.zip.ZipFile
|
||||
import org.apache.commons.compress.compressors.bzip2.BZip2CompressorInputStream
|
||||
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream
|
||||
import org.apache.commons.compress.compressors.xz.XZCompressorInputStream
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
internal class ItchInstaller(
|
||||
private val app: Context,
|
||||
private val item: CatalogItem,
|
||||
private val folder: File,
|
||||
) : DownloadQueue.DownloadJob {
|
||||
private val cancelled = AtomicBoolean(false)
|
||||
|
||||
override fun run(handle: DownloadQueue.JobHandle): String? {
|
||||
val key = ItchAuth.key(app)
|
||||
if (key.isBlank()) throw IllegalStateException(app.getString(R.string.stores_sync_sign_in))
|
||||
val downloadKeyId = item.extra["downloadKeyId"].orEmpty()
|
||||
if (downloadKeyId.isBlank()) throw IllegalStateException(app.getString(R.string.stores_itch_not_owned))
|
||||
val gameId = item.extra["gameId"].orEmpty().ifBlank { item.id.substringAfterLast('/') }
|
||||
StoreInstalls.begin(folder, Store.ITCH, item.id, item.title, item.imageUrl, item.imageUrl)
|
||||
handle.stage(DownloadStage.MANIFEST)
|
||||
val upload = ItchUploads.windows(ItchApi.uploads(key, gameId, downloadKeyId))
|
||||
?: throw IllegalStateException(app.getString(R.string.stores_itch_no_upload))
|
||||
val uploadId = upload.optString("id")
|
||||
val filename = ItchUploads.filename(upload)
|
||||
val size = ItchUploads.size(upload)
|
||||
val scratch = StoreInstallRoot.scratchDir(app, Store.ITCH, item.id)
|
||||
scratch.mkdirs()
|
||||
val archive = File(scratch, filename.ifBlank { "download.bin" })
|
||||
val exe = try {
|
||||
handle.stage(DownloadStage.DOWNLOAD)
|
||||
download(key, downloadKeyId, uploadId, upload.optString("updated_at", upload.optString("updatedAt")), archive, size, handle)
|
||||
if (cancelled.get()) return null
|
||||
handle.stage(DownloadStage.INSTALL)
|
||||
install(archive, filename, handle)
|
||||
} catch (t: Throwable) {
|
||||
if (!cancelled.get()) StoreInstalls.discard(app, folder, listOf(scratch))
|
||||
throw t
|
||||
}
|
||||
if (exe == null) {
|
||||
if (cancelled.get()) return null
|
||||
StoreInstalls.discard(app, folder, listOf(scratch))
|
||||
throw IllegalStateException(app.getString(R.string.stores_itch_no_exe))
|
||||
}
|
||||
archive.delete()
|
||||
StoreInstalls.deleteTree(scratch)
|
||||
val sidecar = StoreGameSidecar(
|
||||
store = Store.ITCH,
|
||||
id = item.id,
|
||||
title = item.title,
|
||||
exe = exe,
|
||||
installVersion = upload.optString("updated_at", upload.optString("updatedAt")),
|
||||
installedAt = System.currentTimeMillis(),
|
||||
cover = item.imageUrl,
|
||||
hero = item.imageUrl,
|
||||
extra = mapOf("gameId" to gameId, "downloadKeyId" to downloadKeyId, "uploadId" to uploadId),
|
||||
cloud = false,
|
||||
)
|
||||
StoreInstalls.complete(app, folder, sidecar) { done, steps -> handle.stageProgress(done.toLong(), steps.toLong()) }
|
||||
return folder.path
|
||||
}
|
||||
|
||||
override fun cancel(deleteFiles: Boolean) {
|
||||
cancelled.set(true)
|
||||
if (deleteFiles) StoreInstalls.discard(app, folder, listOf(StoreInstallRoot.scratchDir(app, Store.ITCH, item.id)))
|
||||
}
|
||||
|
||||
private fun download(key: String, downloadKeyId: String, uploadId: String, version: String, target: File, expected: Long,
|
||||
handle: DownloadQueue.JobHandle) {
|
||||
// A partial file is only resumed when it came from this exact upload build.
|
||||
val origin = File(target.path + ".from")
|
||||
val stamp = "$uploadId $version $expected"
|
||||
if (!target.isFile || runCatching { origin.readText() }.getOrNull() != stamp) {
|
||||
target.delete()
|
||||
origin.writeText(stamp)
|
||||
}
|
||||
if (expected > 0 && target.length() == expected) {
|
||||
handle.progress(expected, expected)
|
||||
return
|
||||
}
|
||||
for (attempt in 0..1) {
|
||||
val have = target.length()
|
||||
var conn: HttpURLConnection? = null
|
||||
try {
|
||||
conn = (URL(ItchApi.downloadUrl(uploadId, downloadKeyId)).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = 30_000
|
||||
readTimeout = 60_000
|
||||
instanceFollowRedirects = true
|
||||
setRequestProperty("User-Agent", StoreNet.BROWSER_UA)
|
||||
setRequestProperty("Authorization", "Bearer $key")
|
||||
setRequestProperty("Accept", "*/*")
|
||||
if (have > 0) setRequestProperty("Range", "bytes=$have-")
|
||||
}
|
||||
val code = conn.responseCode
|
||||
if (code == 416 && attempt == 0) {
|
||||
target.delete()
|
||||
continue
|
||||
}
|
||||
if (code !in 200..299) throw IllegalStateException(app.getString(R.string.stores_itch_download_failed, code))
|
||||
val append = code == 206 && have > 0
|
||||
if (have > 0) handle.log(if (append) "itch.io: resuming at ${have} bytes" else "itch.io: server ignored the resume, starting over")
|
||||
var done = if (append) have else 0L
|
||||
val total = conn.contentLengthLong.takeIf { it > 0 }?.let { if (append) it + have else it } ?: expected
|
||||
// The first report is the queue's speed baseline, so resumed bytes don't read as instant throughput.
|
||||
handle.progress(done, total)
|
||||
val buffer = ByteArray(128 * 1024)
|
||||
conn.inputStream.use { input ->
|
||||
FileOutputStream(target, append).use { out ->
|
||||
while (true) {
|
||||
if (cancelled.get()) return
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
done += read
|
||||
handle.progress(done, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (total > 0 && done < total) throw IllegalStateException(app.getString(R.string.stores_itch_download_failed, code))
|
||||
return
|
||||
} finally {
|
||||
runCatching { conn?.disconnect() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun install(archive: File, filename: String, handle: DownloadQueue.JobHandle): String? {
|
||||
folder.mkdirs()
|
||||
when (val kind = sniff(archive, filename)) {
|
||||
"zip" -> unzip(archive, handle)
|
||||
"7z" -> sevenZip(archive, handle)
|
||||
"rar" -> unrar(archive, handle)
|
||||
"exe" -> copy(archive, File(folder, filename.ifBlank { "game.exe" }))
|
||||
"tar" -> untar(archive, null, handle)
|
||||
"gz", "bz2", "xz" -> untar(archive, kind, handle) ?: single(archive, kind, handle)
|
||||
else -> copy(archive, File(folder, filename.ifBlank { "game" }))
|
||||
}
|
||||
val hint = filename.substringBeforeLast('.')
|
||||
val exe = StoreExe.pick(folder, hint.ifBlank { null }, item.title)
|
||||
if (exe.isBlank()) {
|
||||
handle.log("itch.io: no exe found in ${folder.name}")
|
||||
return null
|
||||
}
|
||||
return exe
|
||||
}
|
||||
|
||||
private fun sniff(file: File, filename: String): String {
|
||||
val extension = filename.substringAfterLast('.', "").lowercase()
|
||||
if (extension == "exe") return "exe"
|
||||
val head = runCatching { FileInputStream(file).use { input -> ByteArray(300).also { input.read(it) } } }.getOrNull()
|
||||
?: return extension
|
||||
fun at(i: Int, vararg bytes: Int) = bytes.indices.all { head.getOrNull(i + it) == bytes[it].toByte() }
|
||||
return when {
|
||||
at(0, 0x50, 0x4B) -> "zip"
|
||||
at(0, 0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C) -> "7z"
|
||||
at(0, 0x52, 0x61, 0x72, 0x21) -> "rar"
|
||||
at(0, 0x1F, 0x8B) -> "gz"
|
||||
at(0, 0x42, 0x5A, 0x68) -> "bz2"
|
||||
at(0, 0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00) -> "xz"
|
||||
at(257, 0x75, 0x73, 0x74, 0x61, 0x72) -> "tar"
|
||||
else -> extension
|
||||
}
|
||||
}
|
||||
|
||||
private fun target(name: String): File? {
|
||||
val file = File(folder, name.replace('\\', '/'))
|
||||
val root = folder.canonicalPath
|
||||
val path = file.canonicalPath
|
||||
return if (path == root || path.startsWith(root + File.separator)) file else null
|
||||
}
|
||||
|
||||
// No JVM library reads RAR5; the runtime's bsdtar (libarchive) does.
|
||||
private fun unrar(archive: File, handle: DownloadQueue.JobHandle) {
|
||||
if (!LinuxRuntime.isInstalled(app)) throw IllegalStateException(app.getString(R.string.stores_itch_archive, "rar"))
|
||||
val binds = if (listOf(app.filesDir, app.cacheDir).any { folder.absolutePath.startsWith(it.absolutePath + "/") }) null
|
||||
else listOf(folder.absolutePath)
|
||||
fun bsdtar(args: List<String>, onLine: (String) -> Unit) =
|
||||
GuestCommand.run(app, listOf("/usr/bin/bsdtar") + args, logName = "itch-unrar", extraBinds = binds, fastPath = true) { line ->
|
||||
if (!line.startsWith("proot ")) onLine(line)
|
||||
}
|
||||
var entries = 0L
|
||||
bsdtar(listOf("-tf", archive.absolutePath)) { entries++ }
|
||||
var done = 0L
|
||||
val tail = ArrayDeque<String>()
|
||||
val code = bsdtar(listOf("-xvf", archive.absolutePath, "-C", folder.absolutePath)) { line ->
|
||||
if (line.startsWith("x ")) handle.stageProgress(++done, entries) else {
|
||||
if (tail.size == 5) tail.removeFirst()
|
||||
tail.addLast(line)
|
||||
}
|
||||
}
|
||||
if (cancelled.get()) return
|
||||
if (code != 0) {
|
||||
handle.log("itch.io: bsdtar exited $code: ${tail.joinToString(" | ")}")
|
||||
throw IllegalStateException(app.getString(R.string.stores_itch_extract_failed, code))
|
||||
}
|
||||
}
|
||||
|
||||
private fun sevenZip(archive: File, handle: DownloadQueue.JobHandle) {
|
||||
val total = archive.length()
|
||||
var written = 0L
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
SevenZFile.builder().setFile(archive).get().use { seven ->
|
||||
while (true) {
|
||||
if (cancelled.get()) return
|
||||
val entry = seven.nextEntry ?: break
|
||||
val file = target(entry.name)
|
||||
if (file == null) {
|
||||
handle.log("itch.io: skipped an entry outside the game folder")
|
||||
continue
|
||||
}
|
||||
if (entry.isDirectory) {
|
||||
file.mkdirs()
|
||||
continue
|
||||
}
|
||||
file.parentFile?.mkdirs()
|
||||
FileOutputStream(file).use { out ->
|
||||
while (true) {
|
||||
if (cancelled.get()) return
|
||||
val read = seven.read(buffer, 0, buffer.size)
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
written += read
|
||||
handle.stageProgress(written, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun untar(archive: File, codec: String?, handle: DownloadQueue.JobHandle): Unit? {
|
||||
val total = archive.length()
|
||||
var written = 0L
|
||||
var entries = 0
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
val opened = runCatching {
|
||||
FileInputStream(archive).use { raw ->
|
||||
val stream = when (codec) {
|
||||
"gz" -> GzipCompressorInputStream(BufferedInputStream(raw))
|
||||
"bz2" -> BZip2CompressorInputStream(BufferedInputStream(raw))
|
||||
"xz" -> XZCompressorInputStream(BufferedInputStream(raw))
|
||||
else -> BufferedInputStream(raw)
|
||||
}
|
||||
stream.use { input ->
|
||||
TarArchiveInputStream(input).use { tar ->
|
||||
while (true) {
|
||||
if (cancelled.get()) return null
|
||||
val entry = tar.nextEntry ?: break
|
||||
entries++
|
||||
val file = target(entry.name)
|
||||
if (file == null) {
|
||||
handle.log("itch.io: skipped an entry outside the game folder")
|
||||
continue
|
||||
}
|
||||
if (entry.isDirectory) {
|
||||
file.mkdirs()
|
||||
continue
|
||||
}
|
||||
file.parentFile?.mkdirs()
|
||||
FileOutputStream(file).use { out ->
|
||||
while (true) {
|
||||
if (cancelled.get()) return null
|
||||
val read = tar.read(buffer, 0, buffer.size)
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
written += read
|
||||
handle.stageProgress(written, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
if (opened.isSuccess && entries > 0) return Unit
|
||||
if (opened.isFailure && codec == null) throw opened.exceptionOrNull() ?: IllegalStateException("tar")
|
||||
return null
|
||||
}
|
||||
|
||||
private fun single(archive: File, codec: String, handle: DownloadQueue.JobHandle) {
|
||||
val name = archive.name.substringBeforeLast('.')
|
||||
val file = target(name.ifBlank { "game" }) ?: throw IllegalStateException("archive")
|
||||
file.parentFile?.mkdirs()
|
||||
val total = archive.length()
|
||||
var written = 0L
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
FileInputStream(archive).use { raw ->
|
||||
val stream = when (codec) {
|
||||
"bz2" -> BZip2CompressorInputStream(BufferedInputStream(raw))
|
||||
"xz" -> XZCompressorInputStream(BufferedInputStream(raw))
|
||||
else -> GzipCompressorInputStream(BufferedInputStream(raw))
|
||||
}
|
||||
stream.use { input ->
|
||||
FileOutputStream(file).use { out ->
|
||||
while (true) {
|
||||
if (cancelled.get()) return
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
written += read
|
||||
handle.stageProgress(written, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun copy(source: File, target: File) {
|
||||
target.parentFile?.mkdirs()
|
||||
FileInputStream(source).use { input -> FileOutputStream(target).use { out -> input.copyTo(out, 128 * 1024) } }
|
||||
}
|
||||
|
||||
// Streaming readers stop silently on zips whose data descriptors disagree with the local headers;
|
||||
// the central directory is authoritative.
|
||||
private fun unzip(archive: File, handle: DownloadQueue.JobHandle) {
|
||||
val total = archive.length()
|
||||
var written = 0L
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
ZipFile.builder().setFile(archive).get().use { zip ->
|
||||
for (entry in zip.entries) {
|
||||
if (cancelled.get()) return
|
||||
val target = target(entry.name)
|
||||
if (target == null) {
|
||||
handle.log("itch.io: skipped an entry outside the game folder")
|
||||
continue
|
||||
}
|
||||
if (entry.isDirectory) {
|
||||
target.mkdirs()
|
||||
continue
|
||||
}
|
||||
target.parentFile?.mkdirs()
|
||||
zip.getInputStream(entry).use { input ->
|
||||
FileOutputStream(target).use { out ->
|
||||
while (true) {
|
||||
if (cancelled.get()) return
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
written += read
|
||||
handle.stageProgress(written, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoresState
|
||||
import com.droiddeck.launcher.stores.cleanStoreText
|
||||
import com.droiddeck.launcher.stores.mergeItems
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
internal object ItchLibrary {
|
||||
private const val KEY_CACHE = "library_cache"
|
||||
private const val KEY_AT = "library_synced_at"
|
||||
private const val MAX_PAGES = 20
|
||||
private const val THROTTLE_MS = 6 * 60 * 60 * 1000L
|
||||
|
||||
private val running = AtomicBoolean(false)
|
||||
|
||||
fun cached(context: Context): List<CatalogItem> {
|
||||
val raw = ItchPrefs.get(context).getString(KEY_CACHE, null) ?: return emptyList()
|
||||
return runCatching { ItchCatalog.list(JSONArray(raw)) }.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
fun sync(context: Context, force: Boolean) {
|
||||
val app = context.applicationContext
|
||||
if (!running.compareAndSet(false, true)) return
|
||||
try {
|
||||
val key = ItchAuth.key(app)
|
||||
val cache = cached(app)
|
||||
val prefs = ItchPrefs.get(app)
|
||||
if (key.isBlank()) {
|
||||
StoresState.post {
|
||||
StoresState.status.remove(Store.ITCH)
|
||||
if (cache.isNotEmpty()) StoresState.library[Store.ITCH] = cache
|
||||
StoresState.problems[Store.ITCH] = app.getString(R.string.stores_sync_sign_in)
|
||||
}
|
||||
return
|
||||
}
|
||||
val quiet = cache.isNotEmpty() && !force
|
||||
if (quiet && System.currentTimeMillis() - prefs.getLong(KEY_AT, 0L) < THROTTLE_MS) {
|
||||
StoresState.post { StoresState.library[Store.ITCH] = cache }
|
||||
return
|
||||
}
|
||||
if (!quiet) StoresState.post { StoresState.status[Store.ITCH] = app.getString(R.string.stores_sync_game_list) }
|
||||
val items = fetch(key)
|
||||
if (items == null) {
|
||||
StoresState.post {
|
||||
StoresState.status.remove(Store.ITCH)
|
||||
if (cache.isNotEmpty()) StoresState.library[Store.ITCH] = cache
|
||||
StoresState.problems[Store.ITCH] = app.getString(R.string.stores_sync_failed)
|
||||
}
|
||||
return
|
||||
}
|
||||
prefs.edit()
|
||||
.putString(KEY_CACHE, ItchCatalog.array(items).toString())
|
||||
.putLong(KEY_AT, System.currentTimeMillis())
|
||||
.apply()
|
||||
StoresState.post {
|
||||
StoresState.status.remove(Store.ITCH)
|
||||
StoresState.problems.remove(Store.ITCH)
|
||||
StoresState.library[Store.ITCH] = mergeItems(StoresState.library[Store.ITCH] ?: cache, items)
|
||||
}
|
||||
StoresState.logLine("itch.io: ${items.size} owned games")
|
||||
} finally {
|
||||
running.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun size(context: Context, item: CatalogItem): Long {
|
||||
val key = ItchAuth.key(context.applicationContext)
|
||||
if (key.isBlank()) return 0L
|
||||
val downloadKeyId = item.extra["downloadKeyId"].orEmpty().ifEmpty { return 0L }
|
||||
val gameId = item.extra["gameId"].orEmpty().ifEmpty { return 0L }
|
||||
val upload = ItchUploads.windows(ItchApi.uploads(key, gameId, downloadKeyId)) ?: return 0L
|
||||
return ItchUploads.size(upload)
|
||||
}
|
||||
|
||||
private fun fetch(key: String): List<CatalogItem>? {
|
||||
val out = ArrayList<CatalogItem>()
|
||||
var page = 1
|
||||
var keys = 0
|
||||
while (page <= MAX_PAGES) {
|
||||
val body = ItchApi.ownedKeys(key, page) ?: return null
|
||||
val list = body.optJSONArray("owned_keys") ?: body.optJSONArray("ownedKeys") ?: return out
|
||||
if (list.length() == 0) break
|
||||
out.addAll(items(list))
|
||||
keys += list.length()
|
||||
page++
|
||||
}
|
||||
val games = out.distinctBy { it.id }
|
||||
StoresState.logLine("itch.io: $keys download keys, ${games.size} games")
|
||||
return games
|
||||
}
|
||||
|
||||
internal fun items(keys: JSONArray): List<CatalogItem> {
|
||||
val out = ArrayList<CatalogItem>(keys.length())
|
||||
for (i in 0 until keys.length()) {
|
||||
val entry = keys.optJSONObject(i) ?: continue
|
||||
val game = entry.optJSONObject("game") ?: continue
|
||||
item(game, entry.optString("id"))?.let { out.add(it) }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun JSONObject.text(snake: String, camel: String): String =
|
||||
optString(snake).ifBlank { optString(camel) }
|
||||
|
||||
internal fun item(game: JSONObject, downloadKeyId: String): CatalogItem? {
|
||||
val gameId = game.optLong("id")
|
||||
if (gameId <= 0L) return null
|
||||
val user = game.optJSONObject("user")
|
||||
val url = game.optString("url").ifBlank {
|
||||
val owner = user?.optString("username").orEmpty()
|
||||
if (owner.isBlank()) ItchApi.pageUrl(gameId.toString()) else ItchApi.pageUrl("$owner/$gameId")
|
||||
}
|
||||
val traits = game.optJSONArray("traits")?.let { arr ->
|
||||
(0 until arr.length()).map { arr.optString(it) }
|
||||
}.orEmpty()
|
||||
val platforms = game.optJSONObject("platforms")
|
||||
val sale = game.optJSONObject("sale")
|
||||
val rate = sale?.optDouble("rate", 0.0) ?: 0.0
|
||||
val cents = game.optLong("min_price", game.optLong("minPrice"))
|
||||
val canBuy = game.optBoolean("can_be_bought", game.optBoolean("canBeBought", "can_be_bought" in traits))
|
||||
val discount = if (rate > 0.0 && rate < 1.0) (rate * 100).roundToInt() else 0
|
||||
val price = if (canBuy && cents > 0) money(cents) else ""
|
||||
val salePrice = if (discount > 0 && price.isNotEmpty()) money((cents * (1.0 - rate)).roundToInt().toLong()) else ""
|
||||
val tags = buildList {
|
||||
if ("p_windows" in traits || platforms?.optBoolean("windows") == true) add("Windows")
|
||||
if ("p_linux" in traits || platforms?.optBoolean("linux") == true) add("Linux")
|
||||
if ("p_osx" in traits || platforms?.optBoolean("osx") == true) add("macOS")
|
||||
}.joinToString(", ")
|
||||
val cover = game.text("cover_url", "coverUrl").ifBlank { game.text("still_cover_url", "stillCoverUrl") }
|
||||
val tall = game.text("still_cover_url", "stillCoverUrl")
|
||||
val author = user?.text("display_name", "displayName").orEmpty().ifBlank { user?.optString("username").orEmpty() }
|
||||
return CatalogItem(
|
||||
store = Store.ITCH,
|
||||
id = ItchCatalog.slug(url).ifBlank { gameId.toString() },
|
||||
title = game.optString("title").ifBlank { "itch.io game $gameId" },
|
||||
imageUrl = cover.ifBlank { null },
|
||||
tallImageUrl = tall.ifBlank { null },
|
||||
tags = tags,
|
||||
isFree = price.isEmpty(),
|
||||
hasPrice = price.isNotEmpty(),
|
||||
finalPrice = salePrice.ifEmpty { price },
|
||||
originalPrice = if (salePrice.isNotEmpty()) price else "",
|
||||
discountPercent = if (salePrice.isNotEmpty()) discount else 0,
|
||||
storeUrl = url,
|
||||
developer = author,
|
||||
publisher = author,
|
||||
releaseDate = game.text("published_at", "publishedAt").take(10),
|
||||
description = cleanStoreText(game.text("short_text", "shortText")),
|
||||
owned = downloadKeyId.isNotBlank(),
|
||||
extra = if (downloadKeyId.isBlank()) emptyMap() else mapOf("downloadKeyId" to downloadKeyId, "gameId" to gameId.toString()),
|
||||
)
|
||||
}
|
||||
|
||||
private fun money(cents: Long): String = "$" + (cents / 100) + "." + (cents % 100).toString().padStart(2, '0')
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
|
||||
object ItchPrefs {
|
||||
const val FILE = "stores.itch"
|
||||
|
||||
@JvmStatic
|
||||
fun get(context: Context): SharedPreferences = context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import android.util.Xml
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreNet
|
||||
import com.droiddeck.launcher.stores.StoreShelves
|
||||
import com.droiddeck.launcher.stores.cleanStoreText
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.io.StringReader
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
internal object ItchShelves {
|
||||
private const val TAG = "ItchShelves"
|
||||
private const val KEY = "shelves_cache"
|
||||
private const val KEY_AT = "shelves_at"
|
||||
private const val FRESH_MS = 3 * 60 * 60 * 1000L
|
||||
private const val GAP_MS = 1_200L
|
||||
private const val RETRY_MS = 20_000L
|
||||
|
||||
private val FEEDS = listOf(
|
||||
"https://itch.io/games/newest.xml",
|
||||
"https://itch.io/games/on-sale.xml",
|
||||
"https://itch.io/games/price-free.xml",
|
||||
"https://itch.io/games/top-rated.xml",
|
||||
)
|
||||
private val PLATFORMS = setOf("windows", "linux", "osx", "mac", "android", "web", "vr", "mobile")
|
||||
|
||||
@Volatile
|
||||
private var memory: StoreShelves? = null
|
||||
|
||||
fun cached(context: Context): StoreShelves? {
|
||||
memory?.let { return it }
|
||||
val stored = stored(ItchPrefs.get(context.applicationContext))
|
||||
if (stored != null) memory = stored
|
||||
return stored
|
||||
}
|
||||
|
||||
fun featured(context: Context, force: Boolean): StoreShelves? {
|
||||
val prefs = ItchPrefs.get(context.applicationContext)
|
||||
val fresh = System.currentTimeMillis() - prefs.getLong(KEY_AT, 0L) < FRESH_MS
|
||||
if (fresh && !force) {
|
||||
cached(context)?.let { return it }
|
||||
}
|
||||
val fetched = fetch()
|
||||
if (fetched != null) {
|
||||
memory = fetched
|
||||
prefs.edit().putString(KEY, ItchCatalog.shelvesJson(fetched)).putLong(KEY_AT, System.currentTimeMillis()).apply()
|
||||
return fetched
|
||||
}
|
||||
return cached(context)
|
||||
}
|
||||
|
||||
fun search(context: Context, query: String): List<CatalogItem> {
|
||||
val key = ItchAuth.key(context.applicationContext)
|
||||
if (key.isBlank()) return emptyList()
|
||||
val out = ArrayList<CatalogItem>()
|
||||
for (page in 1..2) {
|
||||
val games = ItchApi.search(key, query, page) ?: break
|
||||
if (games.length() == 0) break
|
||||
for (i in 0 until games.length()) {
|
||||
val game = games.optJSONObject(i) ?: continue
|
||||
ItchLibrary.item(game, "")?.let { out.add(it) }
|
||||
}
|
||||
if (games.length() < 30) break
|
||||
Thread.sleep(GAP_MS)
|
||||
}
|
||||
return out.distinctBy { it.id }
|
||||
}
|
||||
|
||||
private fun stored(prefs: SharedPreferences): StoreShelves? = ItchCatalog.shelves(prefs.getString(KEY, null))
|
||||
|
||||
private fun fetch(): StoreShelves? {
|
||||
val lists = ArrayList<List<CatalogItem>>(FEEDS.size)
|
||||
var answered = false
|
||||
for ((index, url) in FEEDS.withIndex()) {
|
||||
if (index > 0) Thread.sleep(GAP_MS)
|
||||
var (code, body) = open(url)
|
||||
if (code == 429) {
|
||||
Log.w(TAG, "feed 429; backing off")
|
||||
Thread.sleep(RETRY_MS)
|
||||
val again = open(url)
|
||||
code = again.first
|
||||
body = again.second
|
||||
if (code == 429) {
|
||||
Log.w(TAG, "feed still 429; keeping the cached shelves")
|
||||
lists.add(emptyList())
|
||||
break
|
||||
}
|
||||
}
|
||||
if (code in 200..299 && body != null) {
|
||||
answered = true
|
||||
lists.add(parse(body))
|
||||
} else {
|
||||
lists.add(emptyList())
|
||||
}
|
||||
}
|
||||
if (!answered) return null
|
||||
while (lists.size < FEEDS.size) lists.add(emptyList())
|
||||
val art = (lists[0] + lists[3]).mapNotNull { it.imageUrl }.distinct().take(12)
|
||||
val shelves = StoreShelves(lists[0], lists[1], lists[2], lists[3], art)
|
||||
return shelves.takeUnless { it.isEmpty }
|
||||
}
|
||||
|
||||
private fun open(url: String): Pair<Int, String?> {
|
||||
var conn: HttpURLConnection? = null
|
||||
return try {
|
||||
conn = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = 20_000
|
||||
readTimeout = 20_000
|
||||
instanceFollowRedirects = true
|
||||
setRequestProperty("User-Agent", StoreNet.BROWSER_UA)
|
||||
setRequestProperty("Accept", "application/xml, text/xml, application/rss+xml, */*")
|
||||
}
|
||||
val code = conn.responseCode
|
||||
code to if (code in 200..299) conn.inputStream.bufferedReader().use { it.readText() } else null
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "feed ${e.javaClass.simpleName}")
|
||||
0 to null
|
||||
} finally {
|
||||
runCatching { conn?.disconnect() }
|
||||
}
|
||||
}
|
||||
|
||||
internal fun parse(xml: String): List<CatalogItem> {
|
||||
val out = ArrayList<CatalogItem>()
|
||||
val parser = Xml.newPullParser()
|
||||
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
|
||||
parser.setInput(StringReader(xml))
|
||||
var event = parser.eventType
|
||||
var field: String? = null
|
||||
var current: HashMap<String, String>? = null
|
||||
while (event != XmlPullParser.END_DOCUMENT) {
|
||||
when (event) {
|
||||
XmlPullParser.START_TAG -> {
|
||||
if (parser.name == "item") current = HashMap()
|
||||
field = parser.name
|
||||
}
|
||||
XmlPullParser.TEXT -> {
|
||||
val map = current
|
||||
val name = field
|
||||
if (map != null && name != null) map[name] = (map[name] ?: "") + parser.text
|
||||
}
|
||||
XmlPullParser.END_TAG -> {
|
||||
if (parser.name == "item") {
|
||||
current?.let { item(it)?.let(out::add) }
|
||||
current = null
|
||||
}
|
||||
field = null
|
||||
}
|
||||
}
|
||||
event = parser.next()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
internal fun item(m: Map<String, String>): CatalogItem? {
|
||||
val link = m["link"]?.trim().orEmpty().ifBlank { m["guid"]?.trim().orEmpty() }
|
||||
if (link.isBlank()) return null
|
||||
val title = m["plainTitle"]?.trim().orEmpty()
|
||||
.ifBlank { m["title"].orEmpty().substringBefore(" [").trim() }
|
||||
.ifBlank { ItchCatalog.slug(link) }
|
||||
val price = m["price"]?.trim().orEmpty()
|
||||
val free = price.isBlank() || price == "$0.00" || price == "0.00" || price.equals("free", ignoreCase = true)
|
||||
val tags = Regex("\\[([^]]+)]").findAll(m["title"].orEmpty())
|
||||
.map { it.groupValues[1].trim() }
|
||||
.filterNot { it.endsWith("% Off") || it.startsWith("$") || it.equals("Free", true) || it.lowercase() in PLATFORMS }
|
||||
.joinToString(", ")
|
||||
return CatalogItem(
|
||||
store = Store.ITCH,
|
||||
id = ItchCatalog.slug(link),
|
||||
title = title,
|
||||
imageUrl = m["imageurl"]?.trim()?.ifBlank { null },
|
||||
tags = tags,
|
||||
isFree = free,
|
||||
hasPrice = !free,
|
||||
finalPrice = if (free) "" else price,
|
||||
originalPrice = if (free) "" else m["fullPrice"]?.trim().orEmpty(),
|
||||
discountPercent = m["discountpercent"]?.trim()?.toIntOrNull() ?: 0,
|
||||
storeUrl = link,
|
||||
releaseDate = date(m["pubDate"]),
|
||||
description = cleanStoreText(m["description"]),
|
||||
)
|
||||
}
|
||||
|
||||
private fun date(raw: String?): String {
|
||||
val text = raw?.trim().orEmpty()
|
||||
if (text.isBlank()) return ""
|
||||
return runCatching {
|
||||
SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US).parse(text)
|
||||
}.getOrNull()?.let { SimpleDateFormat("yyyy-MM-dd", Locale.US).format(it) }.orEmpty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
internal object ItchUploads {
|
||||
private val GAME_TYPES = setOf("default", "flash", "unity", "java", "html", "")
|
||||
private val PLATFORM_TRAITS = setOf("p_windows", "p_linux", "p_osx", "p_android")
|
||||
|
||||
fun windows(uploads: JSONArray?): JSONObject? {
|
||||
if (uploads == null) return null
|
||||
val all = (0 until uploads.length()).mapNotNull { uploads.optJSONObject(it) }
|
||||
.filter { it.optString("storage") != "external" && !it.optBoolean("demo") }
|
||||
if (all.isEmpty()) return null
|
||||
val games = all.filter { it.optString("type") in GAME_TYPES }
|
||||
val preferred = games.ifEmpty { all }
|
||||
val candidates = preferred.filter { windows(it) }.ifEmpty { preferred }
|
||||
return candidates.maxWithOrNull(compareBy({ it.optString("updated_at", it.optString("updatedAt")) }, { it.optLong("id") }))
|
||||
}
|
||||
|
||||
fun windows(upload: JSONObject): Boolean {
|
||||
val platforms = upload.optJSONObject("platforms")
|
||||
if (platforms != null) {
|
||||
if (platforms.optBoolean("windows")) return true
|
||||
if (platforms.optBoolean("linux") || platforms.optBoolean("osx") || platforms.optBoolean("android")) return false
|
||||
}
|
||||
val traits = upload.optJSONArray("traits") ?: return true
|
||||
val names = (0 until traits.length()).map { traits.optString(it) }
|
||||
if (names.any { it == "p_windows" }) return true
|
||||
return names.none { it in PLATFORM_TRAITS }
|
||||
}
|
||||
|
||||
fun size(upload: JSONObject): Long = upload.optLong("size", upload.optLong("size_bytes"))
|
||||
|
||||
fun filename(upload: JSONObject): String = upload.optString("filename").substringAfterLast('/')
|
||||
}
|
||||
@@ -138,6 +138,7 @@ class FrontEndState(
|
||||
val downloadMirror: String = "",
|
||||
val downloadPrefix: String = "",
|
||||
val animationsEnabled: Boolean = true,
|
||||
val launcherIcon: String = com.droiddeck.launcher.LauncherIcon.DARK,
|
||||
val gameStoresEnabled: Boolean = true,
|
||||
val gameStoresSpeedTier: String = "fast",
|
||||
val storesOpenTab: String = SessionPrefs.STORES_OPEN_LIBRARY,
|
||||
@@ -205,6 +206,7 @@ class FrontEndActions(
|
||||
val onTheme: (String) -> Unit = {},
|
||||
val onLanguage: (String) -> Unit = {},
|
||||
val onAppScale: (Int) -> Unit = {},
|
||||
val onLauncherIcon: (String) -> Unit = {},
|
||||
val onOrientation: (Int) -> Unit = {},
|
||||
val onLauncherFullscreen: (Boolean) -> Unit = {},
|
||||
val onAnimationsEnabled: (Boolean) -> Unit = {},
|
||||
@@ -386,6 +388,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
|
||||
when (target) {
|
||||
"stores:downloads" -> { com.droiddeck.launcher.stores.StoresState.openDownloads = true; selected = "stores" }
|
||||
"components" -> a.onComponents(true)
|
||||
"setup:launcher" -> { SetupTabs.openLauncher = true; selected = "setup" }
|
||||
else -> selected = target
|
||||
}
|
||||
a.onNavigated()
|
||||
|
||||
@@ -86,6 +86,11 @@ import com.droiddeck.launcher.R
|
||||
private enum class CheckState { OK, WARN, BUSY }
|
||||
|
||||
private const val STORAGE_TAB = 3
|
||||
private const val LAUNCHER_TAB = 2
|
||||
|
||||
internal object SetupTabs {
|
||||
var openLauncher = false
|
||||
}
|
||||
private const val GLASSES_TAB = 5
|
||||
|
||||
@Composable
|
||||
@@ -177,7 +182,8 @@ internal fun SetupPanel(
|
||||
onSave = { a.onDownloadPrefix(it); editDownloadPrefix = false },
|
||||
)
|
||||
val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_launcher), stringResource(R.string.setup_tab_storage), stringResource(R.string.setup_tab_diagnostics), stringResource(R.string.setup_tab_glasses))
|
||||
var tab by rememberSaveable { mutableStateOf(0) }
|
||||
var tab by rememberSaveable { mutableStateOf(if (SetupTabs.openLauncher) LAUNCHER_TAB else 0) }
|
||||
SetupTabs.openLauncher = false
|
||||
val chipFocus = remember { List(tabs.size) { FocusRequester() } }
|
||||
var tabTurned by remember { mutableStateOf(false) }
|
||||
val pick: (Int) -> Unit = { i -> tab = i; tabTurned = true }
|
||||
@@ -362,6 +368,11 @@ internal fun SetupPanel(
|
||||
)
|
||||
}, s.appScale, onPick = a.onAppScale,
|
||||
)
|
||||
ChoiceRow(
|
||||
host, "launcher-icon", stringResource(R.string.setup_launcher_icon), null,
|
||||
com.droiddeck.launcher.LauncherIcon.options.map { (style, label) -> style to stringResource(label) },
|
||||
s.launcherIcon, onPick = a.onLauncherIcon,
|
||||
)
|
||||
ToggleRow(host, "launcher-animations", stringResource(R.string.setup_animations), null,
|
||||
s.animationsEnabled, onChange = a.onAnimationsEnabled)
|
||||
LanguageRow(host, s.language, a.onLanguage)
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoresState
|
||||
import com.droiddeck.launcher.stores.itch.ItchApi
|
||||
import com.droiddeck.launcher.stores.itch.ItchAuth
|
||||
import com.droiddeck.launcher.stores.itch.ItchLibrary
|
||||
|
||||
private fun copiedKey(ctx: Context): String? {
|
||||
val clipboard = ctx.getSystemService(ClipboardManager::class.java)
|
||||
val text = runCatching { clipboard?.primaryClip?.getItemAt(0)?.text?.toString() }.getOrNull() ?: return null
|
||||
return ItchAuth.candidates(text).firstOrNull()
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ItchKeyDialog(onDismiss: () -> Unit) {
|
||||
val ctx = LocalContext.current
|
||||
var key by remember { mutableStateOf(copiedKey(ctx).orEmpty()) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var rejected by remember { mutableStateOf(false) }
|
||||
|
||||
val lifecycle = androidx.compose.ui.platform.LocalLifecycleOwner.current.lifecycle
|
||||
DisposableEffect(lifecycle) {
|
||||
val observer = androidx.lifecycle.LifecycleEventObserver { _, event ->
|
||||
if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME && key.isBlank()) {
|
||||
copiedKey(ctx)?.let { key = it }
|
||||
}
|
||||
}
|
||||
lifecycle.addObserver(observer)
|
||||
onDispose { lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
fun submit() {
|
||||
val raw = key.trim()
|
||||
if (raw.isEmpty() || busy) return
|
||||
busy = true
|
||||
rejected = false
|
||||
Thread({
|
||||
val user = ItchAuth.verify(raw)
|
||||
if (user == null) {
|
||||
StoresState.post { busy = false; rejected = true }
|
||||
return@Thread
|
||||
}
|
||||
ItchAuth.save(ctx, raw, user)
|
||||
StoresState.post { busy = false; onDismiss() }
|
||||
StoresState.refresh(ctx)
|
||||
ItchLibrary.sync(ctx, true)
|
||||
}, "itch-key").start()
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { if (!busy) onDismiss() },
|
||||
title = { Text(Store.ITCH.label) },
|
||||
text = {
|
||||
Column {
|
||||
AdbTextField(
|
||||
value = key,
|
||||
onValueChange = { key = it; rejected = false },
|
||||
label = stringResource(R.string.stores_itch_key),
|
||||
keyboardType = KeyboardType.Ascii,
|
||||
imeAction = ImeAction.Done,
|
||||
focusOnShow = true,
|
||||
onDone = { submit() },
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.Start) {
|
||||
TextButton(onClick = {
|
||||
ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(ItchApi.KEYS_PAGE)))
|
||||
}) { Text(stringResource(R.string.stores_itch_get_key)) }
|
||||
}
|
||||
if (rejected) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
stringResource(R.string.stores_itch_key_rejected),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(enabled = key.isNotBlank() && !busy, onClick = { submit() }) {
|
||||
Text(stringResource(R.string.game_env_save))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(enabled = !busy, onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -29,6 +29,7 @@ internal fun sourceColours(source: String): SourceColours = when (source) {
|
||||
Store.GOG.id -> SourceColours(Color(0xFF3A1A3C), Color(0xFFE6A3EA), Color(0xFFC25BC8))
|
||||
Store.EPIC.id -> SourceColours(Color(0xFF2A2A2A), Color(0xFFDDDDDD), Color(0xFFBDBDBD))
|
||||
Store.AMAZON.id -> SourceColours(Color(0xFF3A2A0A), Color(0xFFFFC266), Color(0xFFFF9900))
|
||||
Store.ITCH.id -> SourceColours(Color(0xFF3A1A1E), Color(0xFFFFB3B3), Color(0xFFFF5C5C))
|
||||
Library.SOURCE_STEAM -> SourceColours(Color(0xFF16293D), Color(0xFF7CC4FF), Color(0xFF1A9FFF))
|
||||
FLATHUB -> SourceColours(Color(0xFF0B2E2B), Color(0xFF9FE3DD), FlathubTeal)
|
||||
else -> SourceColours(Color(0xFF1A1D22), Color(0xFF9AA3AF), Color(0xFF9AA3AF))
|
||||
@@ -40,6 +41,7 @@ internal fun sourceLabel(source: String): String = when (source) {
|
||||
Store.GOG.id -> Store.GOG.label
|
||||
Store.EPIC.id -> Store.EPIC.label
|
||||
Store.AMAZON.id -> Store.AMAZON.label
|
||||
Store.ITCH.id -> Store.ITCH.label
|
||||
else -> stringResource(R.string.source_added)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
internal fun storeGridNeighbor(index: Int, count: Int, columns: Int, direction: Int): Int? {
|
||||
// `split` is the size of a leading section that starts its own rows; 0 means one continuous grid.
|
||||
internal fun storeGridNeighbor(index: Int, count: Int, columns: Int, direction: Int, split: Int = 0): Int? {
|
||||
if (index !in 0 until count || columns < 1 || direction !in listOf(-1, 1)) return null
|
||||
return (index + columns * direction).takeIf { it in 0 until count }
|
||||
if (split !in 1 until count) return (index + columns * direction).takeIf { it in 0 until count }
|
||||
val inFirst = index < split
|
||||
val start = if (inFirst) 0 else split
|
||||
val end = if (inFirst) split else count
|
||||
val local = index - start
|
||||
val within = start + local + columns * direction
|
||||
if (within in start until end) return within
|
||||
val column = local % columns
|
||||
return when {
|
||||
inFirst && direction > 0 -> split + minOf(column, count - split - 1)
|
||||
!inFirst && direction < 0 && local < columns -> ((split - 1) / columns) * columns + column.coerceAtMost((split - 1) % columns)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -182,10 +182,7 @@ private fun DownloadList(s: FrontEndState, a: FrontEndActions) {
|
||||
private fun InterruptedRow(item: com.droiddeck.launcher.stores.CatalogItem) {
|
||||
val ctx = LocalContext.current
|
||||
ArtStrip(item.imageUrl ?: item.tallImageUrl, item.title) {
|
||||
Text(item.title, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
|
||||
SourceChip(item.store.id, small = true)
|
||||
Text(stringResource(R.string.stores_dl_interrupted), fontSize = 12.sp, color = Color(0xFFFFC24D), maxLines = 1)
|
||||
Spacer(Modifier.weight(1f))
|
||||
StripLabel(item.title, item.store.id, stringResource(R.string.stores_dl_interrupted), Color(0xFFFFC24D))
|
||||
RoundAction(Icons.Filled.PlayArrow, stringResource(R.string.stores_dl_resume), primary = true) { StoresState.requestInstall(ctx, item) }
|
||||
CancelAction { StoresState.clearUnfinished(ctx, item) }
|
||||
}
|
||||
@@ -207,14 +204,20 @@ private fun ArtStrip(cover: String?, name: String, content: @Composable androidx
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun androidx.compose.foundation.layout.RowScope.StripLabel(title: String, store: String, status: String, statusColor: Color) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.weight(1f)) {
|
||||
Text(title, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
|
||||
SourceChip(store, small = true)
|
||||
Text(status, fontSize = 12.sp, color = statusColor, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FinishedRow(d: DownloadEntry, s: FrontEndState, a: FrontEndActions, bloom: Boolean = false) {
|
||||
val ctx = LocalContext.current
|
||||
ArtStrip(d.cover, d.name) {
|
||||
Text(d.name, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
|
||||
SourceChip(d.store.id, small = true)
|
||||
Text(stringResource(if (d.state == DownloadState.INSTALLED) R.string.stores_installed_chip else R.string.stores_dl_cancelled), fontSize = 12.sp, color = Color.White.copy(alpha = 0.72f), maxLines = 1)
|
||||
Spacer(Modifier.weight(1f))
|
||||
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 launchStoreGame(ctx, d.store, d.id, s, a) }
|
||||
}
|
||||
|
||||
@@ -148,9 +148,9 @@ import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
|
||||
private const val DOWNLOADS = "downloads"
|
||||
|
||||
private val TABS = listOf("store", "library", "installed")
|
||||
private val TABS = listOf("library", "store")
|
||||
|
||||
internal fun tabsFor(store: Store?): List<String> = if (store == Store.AMAZON) TABS.drop(1) else TABS
|
||||
internal fun tabsFor(store: Store?): List<String> = if (store == Store.AMAZON) TABS.take(1) else TABS
|
||||
|
||||
internal fun tabFor(store: Store?, tab: String): String = if (tab in tabsFor(store)) tab else "library"
|
||||
|
||||
@@ -214,41 +214,13 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier
|
||||
}
|
||||
}
|
||||
val pal = LocalPalette.current
|
||||
fun openModes(st: Store, at: Rect?) {
|
||||
if (chip != st.id) { pickChip(st.id); return }
|
||||
if (at == null || !StoresState.isSignedIn(st) || StoresState.expired[st] == true) { move("first"); return }
|
||||
val modes = tabsFor(st)
|
||||
val current = if (chip == st.id) shownTab else null
|
||||
var picked = false
|
||||
StoresMotion.ask(StepAsk(
|
||||
anchor = at, side = StepSide.Right, accent = storePalette(pal, st).signal, items = modes.size,
|
||||
onDismiss = { move(if (picked) "first" else "item:storechip:${st.id}") },
|
||||
handle = { ChipLabel(st, MaterialTheme.colorScheme.onBackground) },
|
||||
) {
|
||||
StoreTheme(st) {
|
||||
val library = StoresState.library[st].orEmpty()
|
||||
val installedCount = remember(StoresState.installed, library) { com.droiddeck.launcher.stores.installedCards(st, StoresState.installed, library).size }
|
||||
StepList {
|
||||
modes.forEachIndexed { i, m ->
|
||||
val label = when (m) {
|
||||
"store" -> stringResource(R.string.stores_tab_store)
|
||||
"installed" -> stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_installed), installedCount)
|
||||
else -> stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_library), library.size)
|
||||
}
|
||||
val focusHere = if (current != null) m == current else i == 0
|
||||
StepChoice(
|
||||
label, selected = m == current, enabled = open,
|
||||
modifier = Modifier.fillMaxWidth().stepItem(i).then(if (focusHere) Modifier.focusRequester(first) else Modifier),
|
||||
) {
|
||||
picked = true
|
||||
if (chip != st.id) { chip = st.id; openGame = null; query = "" }
|
||||
tab = m
|
||||
StoresMotion.fold()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
fun cycleStore(step: Int) {
|
||||
val ids = Store.entries.map { it.id }
|
||||
val at = ids.indexOf(chip)
|
||||
pickChip(ids[if (at < 0) (if (step > 0) 0 else ids.size - 1) else (at + step + ids.size) % ids.size])
|
||||
}
|
||||
fun openModes(st: Store) {
|
||||
if (chip != st.id) pickChip(st.id) else move("first")
|
||||
}
|
||||
BackHandler(enabled = openGame != null) { closeGame() }
|
||||
val scroll = rememberScrollState()
|
||||
@@ -326,13 +298,13 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = padH, vertical = if (narrow) 10.dp else 14.dp)
|
||||
.bumpers(
|
||||
onPrevious = { if (openGame == null && chip != DOWNLOADS) { val t = tabsFor(store); switchTab(t[(t.indexOf(shownTab) + t.size - 1) % t.size]) } },
|
||||
onNext = { if (openGame == null && chip != DOWNLOADS) { val t = tabsFor(store); switchTab(t[(t.indexOf(shownTab) + 1) % t.size]) } },
|
||||
onPrevious = { if (openGame == null) cycleStore(-1) },
|
||||
onNext = { if (openGame == null) cycleStore(1) },
|
||||
),
|
||||
) {
|
||||
Box(Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
||||
StoreTheme(Store.byId(focusedChip ?: chip)) {
|
||||
Rise(0) { StoreChips(chip, focusedChip, s.storeDownloadsActive, onStore = { st, at -> openModes(st, at) }, onDownloads = { if (chip != DOWNLOADS) pickChip(DOWNLOADS) else move("first") },
|
||||
Rise(0) { StoreChips(chip, focusedChip, s.storeDownloadsActive, onStore = { st, _ -> openModes(st) }, onDownloads = { if (chip != DOWNLOADS) pickChip(DOWNLOADS) else move("first") },
|
||||
onFocusChip = { focusedChip = it }, onSettings = { at -> if (at != null) StoresMotion.ask(storesSettingsStep(at, liveState, a, pal.signal)) }) }
|
||||
}
|
||||
}
|
||||
@@ -390,7 +362,7 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier
|
||||
k.chip == DOWNLOADS -> Column(Modifier.fillMaxWidth().verticalScroll(scroll).padding(top = 4.dp, bottom = 16.dp)) { StoresDownloadsPane(s, a) }
|
||||
st == null -> {}
|
||||
!k.signedIn -> Box(Modifier.fillMaxWidth().verticalScroll(scroll).padding(top = 4.dp, bottom = 16.dp)) { SignInCard(st, viewportH - 20.dp) }
|
||||
else -> Storefront(st, k.tab, query, s, a, grids.getOrPut(k) { LazyGridState() }, onQuery = { query = it }, onOpen = { open(it) })
|
||||
else -> Storefront(st, k.tab, query, s, a, grids.getOrPut(k) { LazyGridState() }, onQuery = { query = it }, onOpen = { open(it) }, onTab = { switchTab(it) })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -576,13 +548,14 @@ private fun StoreChips(
|
||||
) { onStore(store, rootBounds[store.id]) }
|
||||
}
|
||||
StoreChip(
|
||||
label = stringResource(R.string.stores_tab_downloads), on = selected == DOWNLOADS, under = fillShown && highlighted == DOWNLOADS, key = DOWNLOADS, mark = pal.signal,
|
||||
modifier = Modifier.weight(1f).onGloballyPositioned {
|
||||
label = null, on = selected == DOWNLOADS, under = fillShown && highlighted == DOWNLOADS, key = DOWNLOADS, mark = pal.signal,
|
||||
modifier = Modifier.onGloballyPositioned {
|
||||
bounds[DOWNLOADS] = it.boundsInParent()
|
||||
StoresMotion.downloadsChip = it.boundsInRoot()
|
||||
},
|
||||
lead = { Icon(Icons.Outlined.Download, null, modifier = Modifier.size(18.dp)) },
|
||||
trail = if (active > 0) { { CountPill(active) } } else null,
|
||||
description = stringResource(R.string.stores_tab_downloads),
|
||||
onFocused = onFocusChip,
|
||||
) { onDownloads() }
|
||||
var cogAt by remember { mutableStateOf<Rect?>(null) }
|
||||
@@ -596,7 +569,7 @@ private fun StoreChips(
|
||||
|
||||
@Composable
|
||||
private fun StoreChip(
|
||||
label: String, on: Boolean, under: Boolean, key: String, mark: Color, modifier: Modifier, lead: @Composable () -> Unit,
|
||||
label: String?, on: Boolean, under: Boolean, key: String, mark: Color, modifier: Modifier, lead: @Composable () -> Unit,
|
||||
trail: (@Composable () -> Unit)? = null, description: String? = null,
|
||||
onFocused: (String?) -> Unit = {}, onClick: () -> Unit,
|
||||
) {
|
||||
@@ -634,7 +607,7 @@ private fun StoreChip(
|
||||
androidx.compose.runtime.CompositionLocalProvider(androidx.compose.material3.LocalContentColor provides ink) {
|
||||
Box(Modifier.pulseRing(picked, mark), contentAlignment = Alignment.Center) { lead() }
|
||||
}
|
||||
Text(label, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = ink, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (label != null) Text(label, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = ink, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
trail?.invoke()
|
||||
}
|
||||
}
|
||||
@@ -724,11 +697,16 @@ private fun SignInButton(store: Store, large: Boolean = false) {
|
||||
val ctx = LocalContext.current
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val placed = remember { arrayOfNulls<androidx.compose.ui.layout.LayoutCoordinates>(1) }
|
||||
var askKey by remember { mutableStateOf(false) }
|
||||
if (askKey) ItchKeyDialog { askKey = false }
|
||||
Box(Modifier.onGloballyPositioned { placed[0] = it }) {
|
||||
PrimaryButton(stringResource(R.string.stores_signin), main = true, large = large, icon = if (large) Icons.AutoMirrored.Filled.Login else null) {
|
||||
val at = placed[0]?.takeIf { it.isAttached }?.boundsInRoot()
|
||||
if (at == null || Motion.scale == 0f || StoresMotion.signIn != null) StoresState.signIn(ctx, store)
|
||||
else StoresMotion.signIn = SignInFlood(store, at, colors.primary, sourceColours(store.id).dot)
|
||||
if (store == Store.ITCH) askKey = true
|
||||
else {
|
||||
val at = placed[0]?.takeIf { it.isAttached }?.boundsInRoot()
|
||||
if (at == null || Motion.scale == 0f || StoresMotion.signIn != null) StoresState.signIn(ctx, store)
|
||||
else StoresMotion.signIn = SignInFlood(store, at, colors.primary, sourceColours(store.id).dot)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -746,20 +724,22 @@ internal fun StoreLogo(store: Store, size: Dp) {
|
||||
private fun Storefront(
|
||||
store: Store, tab: String, query: String, s: FrontEndState, a: FrontEndActions,
|
||||
grid: LazyGridState,
|
||||
onQuery: (String) -> Unit, onOpen: (String) -> Unit,
|
||||
onQuery: (String) -> Unit, onOpen: (String) -> Unit, onTab: (String) -> Unit,
|
||||
) {
|
||||
val ctx = LocalContext.current
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val library = StoresState.library[store] ?: emptyList()
|
||||
val shelves = StoresState.shelves[store]
|
||||
val found = StoresState.found[store] ?: emptyList()
|
||||
val installedItems = remember(StoresState.installed, library) { com.droiddeck.launcher.stores.installedCards(store, StoresState.installed, library) }
|
||||
val installedKeys = remember(installedItems) { installedItems.map { it.id }.toSet() + StoresState.installed.filter { it.sidecar.store == store }.map { it.sidecar.id } }
|
||||
val q = query.trim().lowercase()
|
||||
fun matches(i: CatalogItem) = q.isEmpty() || i.title.lowercase().contains(q)
|
||||
val foundKeys = remember(found) { found.map { it.key }.toSet() }
|
||||
fun matches(i: CatalogItem) = q.isEmpty() || i.title.lowercase().contains(q) || i.key in foundKeys
|
||||
fun shown(i: CatalogItem) = s.storesShowMature || !i.mature || i.owned
|
||||
fun shelf(l: List<CatalogItem>?) = l?.filter(::shown)
|
||||
val everything = remember(library, shelves) { (library + (shelves?.all ?: emptyList())).distinctBy { it.id } }
|
||||
val installedOnly = tab == "installed"
|
||||
val everything = remember(library, shelves, found) { (found + library + (shelves?.all ?: emptyList())).distinctBy { it.id } }
|
||||
LaunchedEffect(store, q) { StoresState.search(ctx, store, q) }
|
||||
val loaded = StoresState.library.containsKey(store)
|
||||
val showGrid = tab != "store" || q.isNotEmpty()
|
||||
val sort = s.storesSort
|
||||
@@ -773,63 +753,45 @@ private fun Storefront(
|
||||
val lookup: (CatalogItem) -> Long = { i -> byKey[i.key] ?: 0L }
|
||||
lookup
|
||||
}
|
||||
val isSizeSort = (tab == "library" || tab == "installed") && (sort == "sizeAsc" || sort == "sizeDesc")
|
||||
val candidates = remember(library, installedItems, everything, tab, q, s.storesShowMature) {
|
||||
when (tab) {
|
||||
"library" -> library.filter(::matches)
|
||||
"installed" -> installedItems.filter(::matches)
|
||||
else -> everything.filter { matches(it) && shown(it) }
|
||||
}
|
||||
val isSizeSort = tab == "library" && (sort == "sizeAsc" || sort == "sizeDesc")
|
||||
val candidates = remember(library, everything, tab, q, s.storesShowMature) {
|
||||
if (tab == "library") library.filter(::matches) else everything.filter { matches(it) && shown(it) }
|
||||
}
|
||||
val candidateKeys = remember(candidates) { candidates.map { it.key } }
|
||||
var resolvedSizeSortKey by remember(store, tab) { mutableStateOf<List<String>?>(null) }
|
||||
val sizeSortPending = isSizeSort && resolvedSizeSortKey != candidateKeys
|
||||
var stableVisible by remember(store, tab) { mutableStateOf(emptyList<CatalogItem>()) }
|
||||
val resolvedSizes = when {
|
||||
!isSizeSort -> emptyMap<String, Long>()
|
||||
installedOnly -> com.droiddeck.launcher.stores.StoreSizes.onDisk.toMap()
|
||||
else -> com.droiddeck.launcher.stores.StoreSizes.known.toMap()
|
||||
}
|
||||
val sizeFor = remember(resolvedSizes, installedOnly) {
|
||||
val resolvedSizes = if (isSizeSort) com.droiddeck.launcher.stores.StoreSizes.known.toMap() else emptyMap()
|
||||
val sizeFor = remember(resolvedSizes) {
|
||||
val resolved = resolvedSizes
|
||||
val lookup: (CatalogItem) -> Long = { item ->
|
||||
resolved[item.key] ?: if (installedOnly) 0L else item.sizeBytes
|
||||
}
|
||||
val lookup: (CatalogItem) -> Long = { item -> resolved[item.key] ?: item.sizeBytes }
|
||||
lookup
|
||||
}
|
||||
val sortedVisible = remember(candidates, tab, sort, lastPlayedFor, installedAtFor, sizeFor) {
|
||||
if (tab == "library" || tab == "installed")
|
||||
sortCatalogItems(candidates, sort, lastPlayed = lastPlayedFor, installedAt = installedAtFor, size = sizeFor)
|
||||
val sortedVisible = remember(candidates, tab, sort, lastPlayedFor, installedAtFor, sizeFor, installedKeys) {
|
||||
if (tab == "library") installedFirst(sortCatalogItems(candidates, sort, lastPlayed = lastPlayedFor, installedAt = installedAtFor, size = sizeFor), installedKeys)
|
||||
else candidates
|
||||
}
|
||||
val visible = remember(sizeSortPending, candidates, stableVisible, sortedVisible) {
|
||||
if (sizeSortPending) retainCatalogOrder(candidates, stableVisible) else sortedVisible
|
||||
}
|
||||
val installedShown = if (tab == "library") visible.count { it.id in installedKeys } else 0
|
||||
val split = installedShown.takeIf { it in 1 until visible.size } ?: 0
|
||||
SideEffect {
|
||||
if (!sizeSortPending && stableVisible != sortedVisible) stableVisible = sortedVisible
|
||||
}
|
||||
LaunchedEffect(store, tab, sort, candidates, candidateKeys) {
|
||||
if (!isSizeSort || resolvedSizeSortKey == candidateKeys) return@LaunchedEffect
|
||||
val requests = candidates.map { item ->
|
||||
val future: CompletableFuture<Long?> = if (tab == "installed") {
|
||||
StoresState.installedGame(store, item.id)?.let {
|
||||
com.droiddeck.launcher.stores.StoreSizes.resolveDisk(ctx, item.key, it)
|
||||
} ?: CompletableFuture.completedFuture<Long?>(null)
|
||||
} else {
|
||||
com.droiddeck.launcher.stores.StoreSizes.resolve(ctx, item)
|
||||
}
|
||||
item to future
|
||||
item to com.droiddeck.launcher.stores.StoreSizes.resolve(ctx, item)
|
||||
}
|
||||
CompletableFuture.allOf(*requests.map { it.second }.toTypedArray()).await()
|
||||
requests.forEach { (item, future) ->
|
||||
val bytes = future.getNow(null) ?: return@forEach
|
||||
if (tab == "installed") com.droiddeck.launcher.stores.StoreSizes.onDisk[item.key] = bytes
|
||||
else if (item.owned && item.sizeBytes <= 0) com.droiddeck.launcher.stores.StoreSizes.known[item.key] = bytes
|
||||
if (item.owned && item.sizeBytes <= 0) com.droiddeck.launcher.stores.StoreSizes.known[item.key] = bytes
|
||||
}
|
||||
resolvedSizeSortKey = candidateKeys
|
||||
}
|
||||
val empty = stringResource(when {
|
||||
installedOnly -> R.string.stores_installed_empty
|
||||
tab == "library" && library.isEmpty() && StoresState.status[store] != null -> R.string.stores_library_loading
|
||||
else -> R.string.stores_nothing_matches
|
||||
})
|
||||
@@ -840,7 +802,7 @@ private fun Storefront(
|
||||
val scope = rememberCoroutineScope()
|
||||
val headerItems = 1 + (if (sizeSortPending) 1 else 0) +
|
||||
(if (StoresState.status[store] != null) 1 else 0) + (if (StoresState.problems[store] != null) 1 else 0)
|
||||
val listMode = s.storesView == "list" && (tab == "library" || tab == "installed")
|
||||
val listMode = s.storesView == "list" && tab == "library"
|
||||
val card: @Composable (CatalogItem) -> Unit = { item ->
|
||||
val first = if (showGrid) item.key == visible.firstOrNull()?.key else !firstPlaced
|
||||
firstPlaced = true
|
||||
@@ -852,14 +814,14 @@ private fun Storefront(
|
||||
else -> 0
|
||||
}
|
||||
val index = visible.indexOfFirst { it.key == item.key }
|
||||
val next = storeGridNeighbor(index, visible.size, if (listMode) 1 else columns, direction)
|
||||
val next = storeGridNeighbor(index, visible.size, if (listMode) 1 else columns, direction, split)
|
||||
when {
|
||||
direction == 0 || index < 0 -> false
|
||||
next == null -> direction > 0
|
||||
else -> {
|
||||
if (key.action == KeyEvent.ACTION_DOWN) {
|
||||
val target = "card:${visible[next].key}"
|
||||
val targetIndex = headerItems + next
|
||||
val targetIndex = headerItems + next + if (split == 0) 0 else if (next < split) 1 else 2
|
||||
val visibleTarget = grid.layoutInfo.visibleItemsInfo.any { it.index == targetIndex }
|
||||
if (!visibleTarget || (focus.attached[target] ?: 0) == 0 ||
|
||||
runCatching { focus.items.getValue(target).requestFocus() }.isFailure) scope.launch {
|
||||
@@ -885,21 +847,19 @@ private fun Storefront(
|
||||
) {
|
||||
item(key = "header", span = { GridItemSpan(maxLineSpan) }) {
|
||||
Rise(1) {
|
||||
val tabs = tabsFor(store)
|
||||
val libraryLabel = if (loaded) stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_library), library.size)
|
||||
else stringResource(R.string.stores_tab_library)
|
||||
ModeHeader(
|
||||
when {
|
||||
tab == "store" -> stringResource(R.string.stores_tab_store)
|
||||
!loaded -> stringResource(if (installedOnly) R.string.stores_tab_installed else R.string.stores_tab_library)
|
||||
installedOnly -> stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_installed), installedItems.size)
|
||||
else -> stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_library), library.size)
|
||||
},
|
||||
libraryLabel,
|
||||
query, onQuery,
|
||||
placeholder = when {
|
||||
tab == "store" -> stringResource(R.string.stores_search_catalog, store.label)
|
||||
installedOnly -> stringResource(R.string.stores_search_installed)
|
||||
else -> stringResource(R.string.stores_search_library)
|
||||
},
|
||||
placeholder = if (tab == "store") stringResource(R.string.stores_search_catalog, store.label) else stringResource(R.string.stores_search_library),
|
||||
lead = if (tabs.size < 2) null else { {
|
||||
val labels = tabs.map { if (it == "store") stringResource(R.string.stores_tab_store) else libraryLabel }
|
||||
HeaderTabs(labels, tabs.indexOf(tab).coerceAtLeast(0)) { onTab(tabs[it]) }
|
||||
} },
|
||||
) {
|
||||
if (tab == "library" || tab == "installed") {
|
||||
if (tab == "library") {
|
||||
SortButton(s, a)
|
||||
ViewButton(s, a)
|
||||
}
|
||||
@@ -925,8 +885,14 @@ private fun Storefront(
|
||||
StoresState.status[store]?.let { line -> item(key = "sync", span = { GridItemSpan(maxLineSpan) }) { Rise(3) { SyncBar(line) } } }
|
||||
StoresState.problems[store]?.let { line -> item(key = "problem", span = { GridItemSpan(maxLineSpan) }) { Rise(3) { Box(Modifier.padding(bottom = 8.dp)) { Note(line) } } } }
|
||||
when (tab) {
|
||||
"library", "installed" -> if (!loaded) Unit
|
||||
else storeGrid(visible, empty, card)
|
||||
"library" -> if (!loaded) Unit
|
||||
else if (split == 0) storeGrid(visible, empty, card)
|
||||
else {
|
||||
item(key = "installed-label", span = { GridItemSpan(maxLineSpan) }) { SectionLabel(stringResource(R.string.stores_section_installed)) }
|
||||
storeGrid(visible.take(split), empty, card)
|
||||
item(key = "not-installed-label", span = { GridItemSpan(maxLineSpan) }) { SectionLabel(stringResource(R.string.stores_section_not_installed)) }
|
||||
storeGrid(visible.drop(split), empty, card)
|
||||
}
|
||||
else -> {
|
||||
if (q.isNotEmpty()) storeGrid(visible, empty, card)
|
||||
else {
|
||||
@@ -956,14 +922,48 @@ private suspend fun <T> CompletableFuture<T>.await(): T =
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ModeHeader(title: String, query: String, onQuery: (String) -> Unit, placeholder: String, trailing: @Composable () -> Unit) {
|
||||
private fun SectionLabel(title: String) {
|
||||
Text(
|
||||
title.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth().padding(top = 6.dp, start = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeaderTabs(labels: List<String>, selected: Int, onSelect: (Int) -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val shape = RoundedCornerShape(8.dp)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
labels.forEachIndexed { i, label ->
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val pick = { onSelect(i) }
|
||||
Text(
|
||||
label, fontSize = 15.sp, fontWeight = FontWeight.Bold, maxLines = 1,
|
||||
color = if (i == selected || hot) colors.onBackground else colors.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
modifier = Modifier.clip(shape).glideBorder(hot, shape, pal.signal)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = pick)
|
||||
.controllerConfirm(onClick = pick)
|
||||
.padding(horizontal = 4.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ModeHeader(
|
||||
title: String, query: String, onQuery: (String) -> Unit, placeholder: String,
|
||||
lead: (@Composable () -> Unit)? = null, trailing: @Composable () -> Unit,
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
var searching by remember { mutableStateOf(query.isNotEmpty()) }
|
||||
val open by animateFloatAsState(if (searching) 1f else 0f, Motion.sp(0.75f, 420f), label = "searchOpen")
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(vertical = 4.dp)) {
|
||||
Text(
|
||||
if (lead != null) lead()
|
||||
else Text(
|
||||
title, fontSize = 15.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1,
|
||||
modifier = Modifier.padding(start = 4.dp, end = 4.dp),
|
||||
)
|
||||
@@ -985,19 +985,12 @@ internal fun ModeHeader(title: String, query: String, onQuery: (String) -> Unit,
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChipLabel(store: Store, ink: Color) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Box(Modifier.size(12.dp).clip(CircleShape).background(sourceColours(store.id).dot))
|
||||
Text(store.shortLabel, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = ink, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun storePalette(base: Palette, store: Store?): Palette {
|
||||
val (accent, deep) = when (store) {
|
||||
Store.GOG -> Color(0xFFC25BC8) to Color(0xFF9A3FA1)
|
||||
Store.EPIC -> Color(0xFFE6E6E6) to Color(0xFFBDBDBD)
|
||||
Store.AMAZON -> Color(0xFFFF9900) to Color(0xFFE07800)
|
||||
Store.ITCH -> Color(0xFFFF5C5C) to Color(0xFFD93B3B)
|
||||
null -> return base
|
||||
}
|
||||
val on = if (accent.luminance() > 0.18f) Color(0xFF0B0B0D) else Color.White
|
||||
@@ -1218,6 +1211,11 @@ private fun Shelf(title: String, items: List<CatalogItem>?, card: @Composable (C
|
||||
}
|
||||
}
|
||||
|
||||
internal fun installedFirst(items: List<CatalogItem>, installed: Set<String>): List<CatalogItem> {
|
||||
val (on, off) = items.partition { it.id in installed }
|
||||
return on + off
|
||||
}
|
||||
|
||||
private fun LazyGridScope.storeGrid(games: List<CatalogItem>, empty: String, card: @Composable (CatalogItem) -> Unit) {
|
||||
if (games.isEmpty()) item(key = "empty", span = { GridItemSpan(maxLineSpan) }) {
|
||||
Box(Modifier.padding(vertical = 32.dp).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background_light" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground_light" />
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 370 B |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 235 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 511 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 898 B |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
@@ -741,6 +741,9 @@
|
||||
<string name="setup_app_scale_default">%1$d%% (افتراضي)</string>
|
||||
<string name="setup_theme">المظهر</string>
|
||||
<string name="setup_theme_hint">مظهر المشغّل</string>
|
||||
<string name="setup_launcher_icon">أيقونة التطبيق</string>
|
||||
<string name="setup_launcher_icon_dark">داكنة</string>
|
||||
<string name="setup_launcher_icon_light">فاتحة</string>
|
||||
<string name="setup_home">استخدام كشاشة رئيسية</string>
|
||||
<string name="setup_home_on">يمكن أن يكون DroidDeck تطبيق الشاشة الرئيسية</string>
|
||||
<string name="setup_home_off">لا يُعرَض DroidDeck أبدًا كتطبيق للشاشة الرئيسية</string>
|
||||
@@ -2007,7 +2010,6 @@
|
||||
<string name="stores_settings_eyebrow">الإعدادات</string>
|
||||
<string name="stores_chip_signed_out">(لم يتم تسجيل الدخول)</string>
|
||||
<string name="stores_tab_store">المتجر</string>
|
||||
<string name="stores_tab_installed">المثبَّتة</string>
|
||||
<string name="stores_tab_library">المكتبة</string>
|
||||
<string name="stores_tab_all">الكل</string>
|
||||
<string name="stores_tab_downloads">التنزيلات</string>
|
||||
@@ -2019,10 +2021,8 @@
|
||||
<string name="stores_signout_stay">البقاء مسجَّل الدخول</string>
|
||||
<string name="stores_signout_go">تسجيل الخروج</string>
|
||||
<string name="stores_search_catalog">البحث في كتالوج %1$s…</string>
|
||||
<string name="stores_search_installed">البحث في الألعاب المثبَّتة…</string>
|
||||
<string name="stores_search_library">البحث في مكتبتك…</string>
|
||||
<string name="stores_search_open">البحث</string>
|
||||
<string name="stores_installed_empty">لا شيء مثبَّت</string>
|
||||
<string name="stores_library_loading">جارٍ التحميل</string>
|
||||
<string name="stores_nothing_matches">لا توجد ألعاب</string>
|
||||
<string name="stores_shelves_loading">جارٍ تحميل المتجر…</string>
|
||||
@@ -2041,7 +2041,19 @@
|
||||
<string name="stores_view_on">عرض على %1$s</string>
|
||||
<string name="stores_buy_price">شراء · %1$s</string>
|
||||
<string name="stores_installed_chip">مثبَّتة</string>
|
||||
<string name="stores_section_installed">مثبَّتة</string>
|
||||
<string name="stores_section_not_installed">غير مثبَّت</string>
|
||||
<string name="stores_in_library">في المكتبة</string>
|
||||
<string name="stores_itch_key">مفتاح API</string>
|
||||
<string name="stores_itch_get_key">الحصول على مفتاح</string>
|
||||
<string name="stores_itch_key_rejected">تم رفض المفتاح</string>
|
||||
<string name="stores_itch_not_owned">ليس في مكتبة itch.io الخاصة بك</string>
|
||||
<string name="stores_itch_no_upload">لا يوجد تنزيل لنظام Windows لهذه اللعبة</string>
|
||||
<string name="stores_itch_no_exe">لم يتم العثور على لعبة Windows في التنزيل</string>
|
||||
<string name="stores_itch_extract_failed">تعذر استخراج التنزيل (رمز الخروج %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">لم يستجب itch.io</string>
|
||||
<string name="stores_itch_archive">أرشيفات %1$s غير مدعومة</string>
|
||||
<string name="stores_itch_download_failed">استجاب itch.io بـ HTTP %1$d</string>
|
||||
<string name="stores_free">مجاني</string>
|
||||
<string name="stores_resume">استئناف</string>
|
||||
<string name="stores_refresh">تحديث</string>
|
||||
@@ -2687,33 +2699,61 @@
|
||||
<string name="storage_free_up">تحرير مساحة</string>
|
||||
<string name="storage_play_anyway">اللعب على أي حال</string>
|
||||
<plurals name="storage_games_installed">
|
||||
<item quantity="zero">%d مثبَّتة</item>
|
||||
<item quantity="one">%d مثبَّتة</item>
|
||||
<item quantity="two">%d مثبَّتة</item>
|
||||
<item quantity="few">%d مثبَّتة</item>
|
||||
<item quantity="many">%d مثبَّتة</item>
|
||||
<item quantity="other">%d مثبَّتة</item>
|
||||
</plurals>
|
||||
<plurals name="storage_games_on_sd">
|
||||
<item quantity="zero">%d على بطاقة SD</item>
|
||||
<item quantity="one">%d على بطاقة SD</item>
|
||||
<item quantity="two">%d على بطاقة SD</item>
|
||||
<item quantity="few">%d على بطاقة SD</item>
|
||||
<item quantity="many">%d على بطاقة SD</item>
|
||||
<item quantity="other">%d على بطاقة SD</item>
|
||||
</plurals>
|
||||
<plurals name="storage_tools_count">
|
||||
<item quantity="zero">%d أداة</item>
|
||||
<item quantity="one">%d أداة</item>
|
||||
<item quantity="other">%d أدوات</item>
|
||||
<item quantity="two">%d أداة</item>
|
||||
<item quantity="few">%d أدوات</item>
|
||||
<item quantity="many">%d أداة</item>
|
||||
<item quantity="other">%d أداة</item>
|
||||
</plurals>
|
||||
<plurals name="storage_item_leftovers_detail">
|
||||
<item quantity="zero">مجلدات بادئة فارغة وذاكرة شيدرات من %d لعبة</item>
|
||||
<item quantity="one">مجلدات بادئة فارغة وذاكرة شيدرات من %d لعبة</item>
|
||||
<item quantity="two">مجلدات بادئة فارغة وذاكرة شيدرات من %d لعبة</item>
|
||||
<item quantity="few">مجلدات بادئة فارغة وذاكرة شيدرات من %d ألعاب</item>
|
||||
<item quantity="many">مجلدات بادئة فارغة وذاكرة شيدرات من %d لعبة</item>
|
||||
<item quantity="other">مجلدات بادئة فارغة وذاكرة شيدرات من %d لعبة</item>
|
||||
</plurals>
|
||||
<plurals name="storage_item_leftovers_unsure_detail">
|
||||
<item quantity="zero">بادئات %d لعبة تحتوي على ملفات، منها حفظات محلية. انسخها أولًا إن كنت تحتاجها.</item>
|
||||
<item quantity="one">بادئة %d لعبة تحتوي على ملفات، منها حفظات محلية. انسخها أولًا إن كنت تحتاجها.</item>
|
||||
<item quantity="two">بادئات %d لعبة تحتوي على ملفات، منها حفظات محلية. انسخها أولًا إن كنت تحتاجها.</item>
|
||||
<item quantity="few">بادئات %d ألعاب تحتوي على ملفات، منها حفظات محلية. انسخها أولًا إن كنت تحتاجها.</item>
|
||||
<item quantity="many">بادئات %d لعبة تحتوي على ملفات، منها حفظات محلية. انسخها أولًا إن كنت تحتاجها.</item>
|
||||
<item quantity="other">بادئات %d لعبة تحتوي على ملفات، منها حفظات محلية. انسخها أولًا إن كنت تحتاجها.</item>
|
||||
</plurals>
|
||||
<plurals name="storage_tag_games">
|
||||
<item quantity="zero">%d لعبة</item>
|
||||
<item quantity="one">%d لعبة</item>
|
||||
<item quantity="two">%d لعبة</item>
|
||||
<item quantity="few">%d ألعاب</item>
|
||||
<item quantity="many">%d لعبة</item>
|
||||
<item quantity="other">%d لعبة</item>
|
||||
</plurals>
|
||||
<string name="storage_tool_default_proton">Proton الافتراضي في DroidDeck</string>
|
||||
<string name="storage_tool_proton">خيار Proton 11 في DroidDeck</string>
|
||||
<plurals name="storage_tool_custom_games">
|
||||
<item quantity="zero">تستخدمه %d لعبة</item>
|
||||
<item quantity="one">تستخدمه %d لعبة</item>
|
||||
<item quantity="two">تستخدمه %d لعبة</item>
|
||||
<item quantity="few">تستخدمه %d ألعاب</item>
|
||||
<item quantity="many">تستخدمه %d لعبة</item>
|
||||
<item quantity="other">تستخدمه %d لعبة</item>
|
||||
</plurals>
|
||||
<string name="storage_legend_components">المكوّنات</string>
|
||||
@@ -2756,12 +2796,20 @@
|
||||
<string name="storage_desktop_prune_body">يحذف بيئات التشغيل التي لا يحتاجها أي تطبيق مثبَّت وينظّف مخزن كائنات Flatpak. تبقى التطبيقات المثبَّتة تعمل.</string>
|
||||
<string name="storage_flatpak_uninstall_body">يحذف التطبيق (%1$s) وبيئات التشغيل التي لا يستخدمها غيره. تبقى بياناته في مجلدك الرئيسي.</string>
|
||||
<plurals name="storage_components_count">
|
||||
<item quantity="zero">%d حزمة</item>
|
||||
<item quantity="one">%d حزمة</item>
|
||||
<item quantity="other">%d حزم</item>
|
||||
<item quantity="two">%d حزمة</item>
|
||||
<item quantity="few">%d حزم</item>
|
||||
<item quantity="many">%d حزمة</item>
|
||||
<item quantity="other">%d حزمة</item>
|
||||
</plurals>
|
||||
<plurals name="storage_desktop_apps">
|
||||
<item quantity="zero">%d تطبيق</item>
|
||||
<item quantity="one">%d تطبيق</item>
|
||||
<item quantity="other">%d تطبيقات</item>
|
||||
<item quantity="two">%d تطبيق</item>
|
||||
<item quantity="few">%d تطبيقات</item>
|
||||
<item quantity="many">%d تطبيقًا</item>
|
||||
<item quantity="other">%d تطبيق</item>
|
||||
</plurals>
|
||||
<string name="boot_movie">فيديو الإقلاع</string>
|
||||
<string name="boot_movie_video">الفيديو</string>
|
||||
|
||||
@@ -752,6 +752,9 @@
|
||||
<string name="setup_app_scale_default">%1$d%% (predeterminada)</string>
|
||||
<string name="setup_theme">Tema</string>
|
||||
<string name="setup_theme_hint">Apariencia del launcher</string>
|
||||
<string name="setup_launcher_icon">Ícono de la app</string>
|
||||
<string name="setup_launcher_icon_dark">Oscuro</string>
|
||||
<string name="setup_launcher_icon_light">Claro</string>
|
||||
<string name="setup_home">Usar como pantalla de inicio</string>
|
||||
<string name="setup_home_on">DroidDeck puede ser la app de inicio</string>
|
||||
<string name="setup_home_off">DroidDeck nunca se ofrece como app de inicio</string>
|
||||
@@ -1974,7 +1977,6 @@
|
||||
<string name="stores_settings_eyebrow">Ajustes</string>
|
||||
<string name="stores_chip_signed_out">(sin iniciar sesión)</string>
|
||||
<string name="stores_tab_store">Tienda</string>
|
||||
<string name="stores_tab_installed">Instalados</string>
|
||||
<string name="stores_tab_library">Biblioteca</string>
|
||||
<string name="stores_tab_all">Todos</string>
|
||||
<string name="stores_tab_downloads">Descargas</string>
|
||||
@@ -1986,10 +1988,8 @@
|
||||
<string name="stores_signout_stay">Mantener la sesión</string>
|
||||
<string name="stores_signout_go">Cerrar sesión</string>
|
||||
<string name="stores_search_catalog">Buscar en el catálogo de %1$s…</string>
|
||||
<string name="stores_search_installed">Buscar juegos instalados…</string>
|
||||
<string name="stores_search_library">Buscar en tu biblioteca…</string>
|
||||
<string name="stores_search_open">Buscar</string>
|
||||
<string name="stores_installed_empty">Nada instalado</string>
|
||||
<string name="stores_library_loading">Cargando</string>
|
||||
<string name="stores_nothing_matches">No hay juegos</string>
|
||||
<string name="stores_shelves_loading">Cargando la tienda…</string>
|
||||
@@ -2008,6 +2008,8 @@
|
||||
<string name="stores_view_on">Ver en %1$s</string>
|
||||
<string name="stores_buy_price">Comprar · %1$s</string>
|
||||
<string name="stores_installed_chip">Instalado</string>
|
||||
<string name="stores_section_installed">Instalado</string>
|
||||
<string name="stores_section_not_installed">No instalada</string>
|
||||
<string name="stores_in_library">En la biblioteca</string>
|
||||
<string name="stores_free">Gratis</string>
|
||||
<string name="stores_resume">Reanudar</string>
|
||||
@@ -2565,32 +2567,39 @@
|
||||
<string name="storage_play_anyway">Jugar igualmente</string>
|
||||
<plurals name="storage_games_installed">
|
||||
<item quantity="one">%d instalado</item>
|
||||
<item quantity="many">%d instalados</item>
|
||||
<item quantity="other">%d instalados</item>
|
||||
</plurals>
|
||||
<plurals name="storage_games_on_sd">
|
||||
<item quantity="one">%d en la tarjeta SD</item>
|
||||
<item quantity="many">%d en la tarjeta SD</item>
|
||||
<item quantity="other">%d en la tarjeta SD</item>
|
||||
</plurals>
|
||||
<plurals name="storage_tools_count">
|
||||
<item quantity="one">%d herramienta</item>
|
||||
<item quantity="many">%d herramientas</item>
|
||||
<item quantity="other">%d herramientas</item>
|
||||
</plurals>
|
||||
<plurals name="storage_item_leftovers_detail">
|
||||
<item quantity="one">Carpetas de prefijo vacías y caché de shaders de %d juego</item>
|
||||
<item quantity="many">Carpetas de prefijo vacías y cachés de shaders de %d juegos</item>
|
||||
<item quantity="other">Carpetas de prefijo vacías y cachés de shaders de %d juegos</item>
|
||||
</plurals>
|
||||
<plurals name="storage_item_leftovers_unsure_detail">
|
||||
<item quantity="one">El prefijo de %d juego aún contiene archivos, que pueden incluir partidas guardadas locales. Haz una copia antes si las quieres.</item>
|
||||
<item quantity="many">Los prefijos de %d juegos aún contienen archivos, que pueden incluir partidas guardadas locales. Haz una copia antes si las quieres.</item>
|
||||
<item quantity="other">Los prefijos de %d juegos aún contienen archivos, que pueden incluir partidas guardadas locales. Haz una copia antes si las quieres.</item>
|
||||
</plurals>
|
||||
<plurals name="storage_tag_games">
|
||||
<item quantity="one">%d juego</item>
|
||||
<item quantity="many">%d juegos</item>
|
||||
<item quantity="other">%d juegos</item>
|
||||
</plurals>
|
||||
<string name="storage_tool_default_proton">Proton predeterminado de DroidDeck</string>
|
||||
<string name="storage_tool_proton">Opción Proton 11 de DroidDeck</string>
|
||||
<plurals name="storage_tool_custom_games">
|
||||
<item quantity="one">Usado por %d juego</item>
|
||||
<item quantity="many">Usado por %d juegos</item>
|
||||
<item quantity="other">Usado por %d juegos</item>
|
||||
</plurals>
|
||||
<string name="storage_legend_components">Componentes</string>
|
||||
@@ -2634,10 +2643,12 @@
|
||||
<string name="storage_flatpak_uninstall_body">Quita la app (%1$s) y cualquier runtime que no use nada más. Sus datos en tu carpeta personal se quedan.</string>
|
||||
<plurals name="storage_components_count">
|
||||
<item quantity="one">%d paquete</item>
|
||||
<item quantity="many">%d paquetes</item>
|
||||
<item quantity="other">%d paquetes</item>
|
||||
</plurals>
|
||||
<plurals name="storage_desktop_apps">
|
||||
<item quantity="one">%d app</item>
|
||||
<item quantity="many">%d apps</item>
|
||||
<item quantity="other">%d apps</item>
|
||||
</plurals>
|
||||
<string name="boot_movie">Video de arranque</string>
|
||||
@@ -2657,6 +2668,16 @@
|
||||
<string name="boot_movie_converting_started">Convirtiendo %1$s para Steam. Mantén DroidDeck abierto.</string>
|
||||
<string name="boot_movie_unsupported">Este dispositivo no puede decodificar ese video.</string>
|
||||
<string name="boot_movie_import_failed">No se pudo importar ese video</string>
|
||||
<string name="stores_itch_key">Clave de API</string>
|
||||
<string name="stores_itch_get_key">Obtener una clave</string>
|
||||
<string name="stores_itch_key_rejected">Clave rechazada</string>
|
||||
<string name="stores_itch_not_owned">No está en tu biblioteca de itch.io</string>
|
||||
<string name="stores_itch_no_upload">Este juego no tiene descarga para Windows</string>
|
||||
<string name="stores_itch_no_exe">No se encontró un juego de Windows en la descarga</string>
|
||||
<string name="stores_itch_extract_failed">No se pudo extraer la descarga (salida %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io no respondió</string>
|
||||
<string name="stores_itch_archive">Los archivos %1$s no son compatibles</string>
|
||||
<string name="stores_itch_download_failed">itch.io respondió HTTP %1$d</string>
|
||||
<string name="glasses_experimental">Cámaras Luma Ultra — Experimental</string>
|
||||
<string name="glasses_joycons">Seguimiento de posición de los Joy-Con</string>
|
||||
<string name="glasses_joycons_hint">Usa las cámaras de las gafas inteligentes para posicionar tus mandos Joy-Con emparejados. La rotación y las pulsaciones de los botones provienen de los mandos Joy-Con. La posición se estima cuando tus muñecas están ocultas. Reemplaza los mandos operados con las manos desnudas y pausa el seguimiento de la cámara del teléfono. Apunta los mandos Joy-Con hacia adelante al conectar o restablecer el centro de seguimiento.</string>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<string name="frame_gen_off">Изключено</string>
|
||||
<string name="frame_gen_winfg">Win-FG %1$d×</string>
|
||||
<string name="frame_gen_lsfg">LSFG %1$d×</string>
|
||||
<string name="frame_gen_lsfg_adaptive">LSFG Adaptive %1$d</string>
|
||||
<string name="frame_gen_lsfg_adaptive">LSFG Адаптивно %1$d</string>
|
||||
<string name="lsfg_needs_steam">Изисква Lossless Scaling в Steam</string>
|
||||
<string name="lsfg_needs_install">Инсталирайте Lossless Scaling в Steam или импортирайте Lossless.dll</string>
|
||||
<string name="lsfg_import">Импортиране на Lossless.dll…</string>
|
||||
@@ -115,7 +115,7 @@
|
||||
<string name="fex_extreme_detail">Изключва проверките за самопроменящ се код. Игри с JIT и DRM може да спрат да работят.</string>
|
||||
<string name="fex_extreme_tso_label">Екстремно + TSO</string>
|
||||
<string name="fex_extreme_tso_detail">Екстремни настройки с включен основен TSO.</string>
|
||||
<string name="fex_extreme_gn_label">Extreme-gn</string>
|
||||
<string name="fex_extreme_gn_label">Екстремно-gn</string>
|
||||
<string name="fex_extreme_gn_detail">Производителност с TSC и подсказки за метаданни. Пробвайте преди Екстремно.</string>
|
||||
<string name="fex_denuvo_label">Denuvo</string>
|
||||
<string name="fex_denuvo_detail">Производителност с пълни проверки на кода и скрит hypervisor бит.</string>
|
||||
@@ -299,7 +299,7 @@
|
||||
<string name="mode_decky_installed">Инсталирано · %1$s</string>
|
||||
<string name="mode_decky_update">Налична актуализация · %1$s</string>
|
||||
<string name="mode_decky_current">Актуално · %1$s</string>
|
||||
<string name="mode_decky_loader">Loader</string>
|
||||
<string name="mode_decky_loader">Зареждач</string>
|
||||
<string name="mode_decky_install_latest">Инсталиране на последната версия</string>
|
||||
<string name="mode_decky_on">Стартира заедно със Steam. Докато е включено, други приложения на това устройство може да могат да контролират Steam.</string>
|
||||
<string name="mode_decky_off">Изключено. Портът за отдалечено дебъгване на Steam остава затворен.</string>
|
||||
@@ -729,6 +729,9 @@
|
||||
<string name="setup_app_scale_default">%1$d%% (по подразбиране)</string>
|
||||
<string name="setup_theme">Тема</string>
|
||||
<string name="setup_theme_hint">Облик на лаунчъра</string>
|
||||
<string name="setup_launcher_icon">Икона на приложението</string>
|
||||
<string name="setup_launcher_icon_dark">Тъмна</string>
|
||||
<string name="setup_launcher_icon_light">Светла</string>
|
||||
<string name="setup_home">Използване като начален екран</string>
|
||||
<string name="setup_home_on">DroidDeck може да бъде приложението за начален екран</string>
|
||||
<string name="setup_home_off">DroidDeck никога не се предлага като приложение за начален екран</string>
|
||||
@@ -1097,7 +1100,7 @@
|
||||
<string name="second_terminal_not_ready">Сесията на Linux все още не е готова за терминал.</string>
|
||||
<string name="rail_steam">Steam</string>
|
||||
<string name="rail_games">Игри</string>
|
||||
<string name="rail_desktop">Desktop</string>
|
||||
<string name="rail_desktop">Работен плот</string>
|
||||
<string name="rail_store">Магазин</string>
|
||||
<string name="rail_components">Компоненти</string>
|
||||
<string name="rail_apps">Приложения</string>
|
||||
@@ -1923,7 +1926,6 @@
|
||||
<string name="stores_settings_eyebrow">Настройки</string>
|
||||
<string name="stores_chip_signed_out">(не е вписан)</string>
|
||||
<string name="stores_tab_store">Магазин</string>
|
||||
<string name="stores_tab_installed">Инсталирани</string>
|
||||
<string name="stores_tab_library">Библиотека</string>
|
||||
<string name="stores_tab_all">Всички</string>
|
||||
<string name="stores_tab_downloads">Изтегляния</string>
|
||||
@@ -1935,10 +1937,8 @@
|
||||
<string name="stores_signout_stay">Остани вписан</string>
|
||||
<string name="stores_signout_go">Изход</string>
|
||||
<string name="stores_search_catalog">Търсене в каталога на %1$s…</string>
|
||||
<string name="stores_search_installed">Търсене в инсталираните игри…</string>
|
||||
<string name="stores_search_library">Търсене в твоята библиотека…</string>
|
||||
<string name="stores_search_open">Търсене</string>
|
||||
<string name="stores_installed_empty">Нищо инсталирано</string>
|
||||
<string name="stores_library_loading">Зареждане</string>
|
||||
<string name="stores_nothing_matches">Няма игри</string>
|
||||
<string name="stores_shelves_loading">Зареждане на магазина…</string>
|
||||
@@ -1957,7 +1957,19 @@
|
||||
<string name="stores_view_on">Виж в %1$s</string>
|
||||
<string name="stores_buy_price">Купи · %1$s</string>
|
||||
<string name="stores_installed_chip">Инсталирана</string>
|
||||
<string name="stores_section_installed">Инсталирана</string>
|
||||
<string name="stores_section_not_installed">Не е инсталирано</string>
|
||||
<string name="stores_in_library">В библиотеката</string>
|
||||
<string name="stores_itch_key">API ключ</string>
|
||||
<string name="stores_itch_get_key">Вземи ключ</string>
|
||||
<string name="stores_itch_key_rejected">Ключът е отказан</string>
|
||||
<string name="stores_itch_not_owned">Не е в библиотеката ви в itch.io</string>
|
||||
<string name="stores_itch_no_upload">Няма изтегляне за Windows за тази игра</string>
|
||||
<string name="stores_itch_no_exe">В изтеглянето не е намерена игра за Windows</string>
|
||||
<string name="stores_itch_extract_failed">Изтеглянето не можа да бъде разархивирано (код %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io не отговори</string>
|
||||
<string name="stores_itch_archive">Архивите %1$s не се поддържат</string>
|
||||
<string name="stores_itch_download_failed">itch.io отговори HTTP %1$d</string>
|
||||
<string name="stores_free">Безплатна</string>
|
||||
<string name="stores_resume">Продължи</string>
|
||||
<string name="stores_refresh">Обнови</string>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<string name="frame_gen_off">Vypnuto</string>
|
||||
<string name="frame_gen_winfg">Win-FG %1$d×</string>
|
||||
<string name="frame_gen_lsfg">LSFG %1$d×</string>
|
||||
<string name="frame_gen_lsfg_adaptive">LSFG Adaptive %1$d</string>
|
||||
<string name="frame_gen_lsfg_adaptive">LSFG Adaptivní %1$d</string>
|
||||
<string name="lsfg_needs_steam">Vyžaduje Lossless Scaling na Steamu</string>
|
||||
<string name="lsfg_needs_install">Nainstalujte Lossless Scaling ve Steamu nebo naimportujte Lossless.dll</string>
|
||||
<string name="lsfg_import">Importovat Lossless.dll…</string>
|
||||
@@ -735,6 +735,9 @@
|
||||
<string name="setup_app_scale_default">%1$d %% (výchozí)</string>
|
||||
<string name="setup_theme">Téma</string>
|
||||
<string name="setup_theme_hint">Vzhled spouštěče</string>
|
||||
<string name="setup_launcher_icon">Ikona aplikace</string>
|
||||
<string name="setup_launcher_icon_dark">Tmavá</string>
|
||||
<string name="setup_launcher_icon_light">Světlá</string>
|
||||
<string name="setup_home">Použít jako domovskou obrazovku</string>
|
||||
<string name="setup_home_on">DroidDeck může být domovská aplikace</string>
|
||||
<string name="setup_home_off">DroidDeck se nikdy nenabízí jako domovská aplikace</string>
|
||||
@@ -1965,7 +1968,6 @@
|
||||
<string name="stores_settings_eyebrow">Nastavení</string>
|
||||
<string name="stores_chip_signed_out">(nepřihlášen)</string>
|
||||
<string name="stores_tab_store">Obchod</string>
|
||||
<string name="stores_tab_installed">Nainstalované</string>
|
||||
<string name="stores_tab_library">Knihovna</string>
|
||||
<string name="stores_tab_all">Vše</string>
|
||||
<string name="stores_tab_downloads">Stahování</string>
|
||||
@@ -1977,10 +1979,8 @@
|
||||
<string name="stores_signout_stay">Zůstat přihlášen</string>
|
||||
<string name="stores_signout_go">Odhlásit se</string>
|
||||
<string name="stores_search_catalog">Hledat v katalogu %1$s…</string>
|
||||
<string name="stores_search_installed">Hledat v nainstalovaných hrách…</string>
|
||||
<string name="stores_search_library">Hledat ve vaší knihovně…</string>
|
||||
<string name="stores_search_open">Hledat</string>
|
||||
<string name="stores_installed_empty">Nic není nainstalováno</string>
|
||||
<string name="stores_library_loading">Načítání</string>
|
||||
<string name="stores_nothing_matches">Žádné hry</string>
|
||||
<string name="stores_shelves_loading">Načítání obchodu…</string>
|
||||
@@ -1999,7 +1999,19 @@
|
||||
<string name="stores_view_on">Zobrazit na %1$s</string>
|
||||
<string name="stores_buy_price">Koupit · %1$s</string>
|
||||
<string name="stores_installed_chip">Nainstalováno</string>
|
||||
<string name="stores_section_installed">Nainstalováno</string>
|
||||
<string name="stores_section_not_installed">Není nainstalováno</string>
|
||||
<string name="stores_in_library">V knihovně</string>
|
||||
<string name="stores_itch_key">Klíč API</string>
|
||||
<string name="stores_itch_get_key">Získat klíč</string>
|
||||
<string name="stores_itch_key_rejected">Klíč odmítnut</string>
|
||||
<string name="stores_itch_not_owned">Není ve vaší knihovně itch.io</string>
|
||||
<string name="stores_itch_no_upload">Pro tuto hru není ke stažení verze pro Windows</string>
|
||||
<string name="stores_itch_no_exe">Ve stahování nebyla nalezena hra pro Windows</string>
|
||||
<string name="stores_itch_extract_failed">Stažený soubor nelze rozbalit (kód %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io neodpovědělo</string>
|
||||
<string name="stores_itch_archive">Archivy %1$s nejsou podporovány</string>
|
||||
<string name="stores_itch_download_failed">itch.io odpovědělo HTTP %1$d</string>
|
||||
<string name="stores_free">Zdarma</string>
|
||||
<string name="stores_resume">Pokračovat</string>
|
||||
<string name="stores_refresh">Obnovit</string>
|
||||
|
||||
@@ -527,7 +527,7 @@
|
||||
<string name="perf_client_ui">Klientgrænseflade</string>
|
||||
<string name="perf_glthread">Threaded GL</string>
|
||||
<string name="perf_glthread_hint">Kan gøre Steam-menuer mere lydhøre.</string>
|
||||
<string name="perf_zink">Zink: lazy descriptors</string>
|
||||
<string name="perf_zink">Zink: dovne deskriptorer</string>
|
||||
<string name="perf_zink_hint">Anbefalet til drivere uden descriptor buffers.</string>
|
||||
<string name="perf_noglerror">Spring GL-fejlkontrol over</string>
|
||||
<string name="perf_noglerror_hint">Slår validering af GL fra for hvert kald.</string>
|
||||
@@ -549,7 +549,7 @@
|
||||
<string name="perf_xalia_hint">Deaktiverer Protons gamepad-navigationshjælper, som koster CPU i hvert spil.</string>
|
||||
<string name="perf_seccomp">Kør proot uden seccomp</string>
|
||||
<string name="perf_seccomp_hint">Kan afhjælpe fejl om manglende systemkald på nogle kerner. Kan reducere ydelsen.</string>
|
||||
<string name="perf_fastpath">proot fast path</string>
|
||||
<string name="perf_fastpath">proot-hurtigvej</string>
|
||||
<string name="perf_fastpath_needs_seccomp">Kræver prootets seccomp-filter.</string>
|
||||
<string name="perf_fastpath_hint">Hurtigere filopslag og mindre hak. Slå fra, hvis et program ikke kan finde sine filer.</string>
|
||||
<string name="perf_identity">Sessionsidentitet</string>
|
||||
@@ -729,6 +729,9 @@
|
||||
<string name="setup_app_scale_default">%1$d%% (standard)</string>
|
||||
<string name="setup_theme">Tema</string>
|
||||
<string name="setup_theme_hint">Launcherens udseende</string>
|
||||
<string name="setup_launcher_icon">Appikon</string>
|
||||
<string name="setup_launcher_icon_dark">Mørk</string>
|
||||
<string name="setup_launcher_icon_light">Lys</string>
|
||||
<string name="setup_home">Brug som startskærm</string>
|
||||
<string name="setup_home_on">DroidDeck kan være Home-appen</string>
|
||||
<string name="setup_home_off">DroidDeck tilbydes aldrig som Home-app</string>
|
||||
@@ -942,7 +945,7 @@
|
||||
<string name="ctrl_size_hint" formatted="false">100% er standardstørrelsen</string>
|
||||
<string name="ctrl_stick_click">Stick-klik</string>
|
||||
<string name="ctrl_stick_click_hint">Dobbelttryk og hold en stick nede for L3 eller R3</string>
|
||||
<string name="ctrl_adaptive">Adaptive sticks</string>
|
||||
<string name="ctrl_adaptive">Adaptive styrepinde</string>
|
||||
<string name="ctrl_rumble">Spilvibration</string>
|
||||
<string name="ctrl_steam_button">Steam-knap</string>
|
||||
<string name="ctrl_qam_button">QAM-knap</string>
|
||||
@@ -1923,7 +1926,6 @@
|
||||
<string name="stores_settings_eyebrow">Indstillinger</string>
|
||||
<string name="stores_chip_signed_out">(ikke logget ind)</string>
|
||||
<string name="stores_tab_store">Butik</string>
|
||||
<string name="stores_tab_installed">Installeret</string>
|
||||
<string name="stores_tab_library">Bibliotek</string>
|
||||
<string name="stores_tab_all">Alle</string>
|
||||
<string name="stores_tab_downloads">Downloads</string>
|
||||
@@ -1935,10 +1937,8 @@
|
||||
<string name="stores_signout_stay">Bliv logget ind</string>
|
||||
<string name="stores_signout_go">Log ud</string>
|
||||
<string name="stores_search_catalog">Søg i %1$s-kataloget…</string>
|
||||
<string name="stores_search_installed">Søg i installerede spil…</string>
|
||||
<string name="stores_search_library">Søg i dit bibliotek…</string>
|
||||
<string name="stores_search_open">Søg</string>
|
||||
<string name="stores_installed_empty">Intet installeret</string>
|
||||
<string name="stores_library_loading">Indlæser</string>
|
||||
<string name="stores_nothing_matches">Ingen spil</string>
|
||||
<string name="stores_shelves_loading">Indlæser butikken…</string>
|
||||
@@ -1957,7 +1957,19 @@
|
||||
<string name="stores_view_on">Se på %1$s</string>
|
||||
<string name="stores_buy_price">Køb · %1$s</string>
|
||||
<string name="stores_installed_chip">Installeret</string>
|
||||
<string name="stores_section_installed">Installeret</string>
|
||||
<string name="stores_section_not_installed">Ikke installeret</string>
|
||||
<string name="stores_in_library">I biblioteket</string>
|
||||
<string name="stores_itch_key">API-nøgle</string>
|
||||
<string name="stores_itch_get_key">Hent en nøgle</string>
|
||||
<string name="stores_itch_key_rejected">Nøgle afvist</string>
|
||||
<string name="stores_itch_not_owned">Ikke i dit itch.io-bibliotek</string>
|
||||
<string name="stores_itch_no_upload">Ingen Windows-download til dette spil</string>
|
||||
<string name="stores_itch_no_exe">Der blev ikke fundet et Windows-spil i downloaden</string>
|
||||
<string name="stores_itch_extract_failed">Downloaden kunne ikke udpakkes (kode %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io svarede ikke</string>
|
||||
<string name="stores_itch_archive">%1$s-arkiver understøttes ikke</string>
|
||||
<string name="stores_itch_download_failed">itch.io svarede HTTP %1$d</string>
|
||||
<string name="stores_free">Gratis</string>
|
||||
<string name="stores_resume">Genoptag</string>
|
||||
<string name="stores_refresh">Opdater</string>
|
||||
|
||||
@@ -718,6 +718,9 @@
|
||||
<string name="setup_app_scale_default">%1$d %% (Standard)</string>
|
||||
<string name="setup_theme">Design</string>
|
||||
<string name="setup_theme_hint">Aussehen des Launchers</string>
|
||||
<string name="setup_launcher_icon">App-Symbol</string>
|
||||
<string name="setup_launcher_icon_dark">Dunkel</string>
|
||||
<string name="setup_launcher_icon_light">Hell</string>
|
||||
<string name="setup_home">Als Startbildschirm verwenden</string>
|
||||
<string name="setup_home_on">DroidDeck kann die Startbildschirm-App sein</string>
|
||||
<string name="setup_home_off">DroidDeck wird nie als Startbildschirm-App angeboten</string>
|
||||
@@ -1912,7 +1915,6 @@
|
||||
<string name="stores_settings_eyebrow">Einstellungen</string>
|
||||
<string name="stores_chip_signed_out">(nicht angemeldet)</string>
|
||||
<string name="stores_tab_store">Store</string>
|
||||
<string name="stores_tab_installed">Installiert</string>
|
||||
<string name="stores_tab_library">Bibliothek</string>
|
||||
<string name="stores_tab_all">Alle</string>
|
||||
<string name="stores_tab_downloads">Downloads</string>
|
||||
@@ -1924,10 +1926,8 @@
|
||||
<string name="stores_signout_stay">Angemeldet bleiben</string>
|
||||
<string name="stores_signout_go">Abmelden</string>
|
||||
<string name="stores_search_catalog">Katalog von %1$s durchsuchen…</string>
|
||||
<string name="stores_search_installed">Installierte Spiele durchsuchen…</string>
|
||||
<string name="stores_search_library">Deine Bibliothek durchsuchen…</string>
|
||||
<string name="stores_search_open">Suchen</string>
|
||||
<string name="stores_installed_empty">Nichts installiert</string>
|
||||
<string name="stores_library_loading">Wird geladen</string>
|
||||
<string name="stores_nothing_matches">Keine Spiele</string>
|
||||
<string name="stores_shelves_loading">Store wird geladen…</string>
|
||||
@@ -1946,7 +1946,19 @@
|
||||
<string name="stores_view_on">Auf %1$s ansehen</string>
|
||||
<string name="stores_buy_price">Kaufen · %1$s</string>
|
||||
<string name="stores_installed_chip">Installiert</string>
|
||||
<string name="stores_section_installed">Installiert</string>
|
||||
<string name="stores_section_not_installed">Nicht installiert</string>
|
||||
<string name="stores_in_library">In der Bibliothek</string>
|
||||
<string name="stores_itch_key">API-Schlüssel</string>
|
||||
<string name="stores_itch_get_key">Schlüssel holen</string>
|
||||
<string name="stores_itch_key_rejected">Schlüssel abgelehnt</string>
|
||||
<string name="stores_itch_not_owned">Nicht in deiner itch.io-Bibliothek</string>
|
||||
<string name="stores_itch_no_upload">Kein Windows-Download für dieses Spiel</string>
|
||||
<string name="stores_itch_no_exe">Im Download wurde kein Windows-Spiel gefunden</string>
|
||||
<string name="stores_itch_extract_failed">Download konnte nicht entpackt werden (Code %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io hat nicht geantwortet</string>
|
||||
<string name="stores_itch_archive">%1$s-Archive werden nicht unterstützt</string>
|
||||
<string name="stores_itch_download_failed">itch.io antwortete mit HTTP %1$d</string>
|
||||
<string name="stores_free">Kostenlos</string>
|
||||
<string name="stores_resume">Fortsetzen</string>
|
||||
<string name="stores_refresh">Aktualisieren</string>
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
<string name="fex_extreme_detail">Απενεργοποιεί τους ελέγχους αυτο-τροποποιούμενου κώδικα. Παιχνίδια με JIT και DRM μπορεί να πάθουν βλάβη.</string>
|
||||
<string name="fex_extreme_tso_label">Ακραίο + TSO</string>
|
||||
<string name="fex_extreme_tso_detail">Ρυθμίσεις Ακραίο με το κύριο TSO ενεργοποιημένο.</string>
|
||||
<string name="fex_extreme_gn_label">Extreme-gn</string>
|
||||
<string name="fex_extreme_gn_label">Ακραίο-gn</string>
|
||||
<string name="fex_extreme_gn_detail">Απόδοση με υποδείξεις TSC και μεταδεδομένων. Δοκιμάστε το πριν το Ακραίο.</string>
|
||||
<string name="fex_denuvo_label">Denuvo</string>
|
||||
<string name="fex_denuvo_detail">Απόδοση με πλήρεις ελέγχους κώδικα και το bit υπερεπόπτη κρυμμένο.</string>
|
||||
@@ -299,7 +299,7 @@
|
||||
<string name="mode_decky_installed">Εγκατεστημένο · %1$s</string>
|
||||
<string name="mode_decky_update">Διαθέσιμη ενημέρωση · %1$s</string>
|
||||
<string name="mode_decky_current">Ενημερωμένο · %1$s</string>
|
||||
<string name="mode_decky_loader">Loader</string>
|
||||
<string name="mode_decky_loader">Φορτωτής</string>
|
||||
<string name="mode_decky_install_latest">Εγκατάσταση τελευταίας έκδοσης</string>
|
||||
<string name="mode_decky_on">Ξεκινά με το Steam. Όσο είναι ενεργό, άλλες εφαρμογές σε αυτή τη συσκευή μπορεί να είναι σε θέση να ελέγξουν το Steam.</string>
|
||||
<string name="mode_decky_off">Ανενεργό. Η θύρα απομακρυσμένου εντοπισμού σφαλμάτων του Steam παραμένει κλειστή.</string>
|
||||
@@ -527,7 +527,7 @@
|
||||
<string name="perf_client_ui">Διεπαφή πελάτη</string>
|
||||
<string name="perf_glthread">Νηματοποιημένο GL</string>
|
||||
<string name="perf_glthread_hint">Μπορεί να κάνει τα μενού του Steam πιο αποκρίσιμα.</string>
|
||||
<string name="perf_zink">Zink: lazy descriptors</string>
|
||||
<string name="perf_zink">Zink: καθυστερημένοι περιγραφείς</string>
|
||||
<string name="perf_zink_hint">Προτείνεται για προγράμματα οδήγησης χωρίς descriptor buffers.</string>
|
||||
<string name="perf_noglerror">Παράβλεψη ελέγχων σφαλμάτων GL</string>
|
||||
<string name="perf_noglerror_hint">Απενεργοποιεί την επικύρωση GL ανά κλήση.</string>
|
||||
@@ -575,7 +575,7 @@
|
||||
<string name="drawer_steam">Steam</string>
|
||||
<string name="drawer_desktop">Επιφάνεια εργασίας</string>
|
||||
<string name="drawer_steam_menu">Μενού Steam</string>
|
||||
<string name="drawer_quick_access">Quick access</string>
|
||||
<string name="drawer_quick_access">Γρήγορη πρόσβαση</string>
|
||||
<string name="drawer_open_qam">Άνοιγμα Quick Access Menu</string>
|
||||
<string name="drawer_prev_page">Προηγούμενη σελίδα</string>
|
||||
<string name="drawer_next_page">Επόμενη σελίδα</string>
|
||||
@@ -729,6 +729,9 @@
|
||||
<string name="setup_app_scale_default">%1$d%% (Προεπιλογή)</string>
|
||||
<string name="setup_theme">Θέμα</string>
|
||||
<string name="setup_theme_hint">Εμφάνιση εκκινητή</string>
|
||||
<string name="setup_launcher_icon">Εικονίδιο εφαρμογής</string>
|
||||
<string name="setup_launcher_icon_dark">Σκούρο</string>
|
||||
<string name="setup_launcher_icon_light">Ανοιχτό</string>
|
||||
<string name="setup_home">Χρήση ως αρχική οθόνη</string>
|
||||
<string name="setup_home_on">Το DroidDeck μπορεί να είναι η εφαρμογή αρχικής οθόνης</string>
|
||||
<string name="setup_home_off">Το DroidDeck δεν προτείνεται ποτέ ως εφαρμογή αρχικής οθόνης</string>
|
||||
@@ -1059,7 +1062,7 @@
|
||||
<string name="load_runtime_detail">Προετοιμασία του runtime Linux</string>
|
||||
<string name="load_session">Συνεδρία</string>
|
||||
<string name="load_session_detail">Εκκίνηση οθόνης και ήχου</string>
|
||||
<string name="load_steam">Steam client</string>
|
||||
<string name="load_steam">Πρόγραμμα-πελάτης Steam</string>
|
||||
<string name="load_steam_detail">Έλεγχος του Steam και των εργαλείων συμβατότητάς του</string>
|
||||
<string name="load_opening_steam">Άνοιγμα Steam</string>
|
||||
<string name="load_opening_steam_detail">Αναμονή για το πρώτο καρέ του Steam</string>
|
||||
@@ -1428,8 +1431,8 @@
|
||||
<string name="fm_pin_current">Καρφίτσωμα τρέχοντος φακέλου</string>
|
||||
<string name="fm_no_favorites">Δεν υπάρχουν ακόμα αγαπημένα - καρφιτσώστε έναν φάκελο με το μενού ⋮ του για να έρχεστε εδώ γρήγορα.</string>
|
||||
<string name="fm_unpin_favorite">Κατάργηση από τα αγαπημένα</string>
|
||||
<string name="fm_size_zero">0 bytes</string>
|
||||
<string name="fm_unit_bytes">bytes</string>
|
||||
<string name="fm_size_zero">0 μπάιτ</string>
|
||||
<string name="fm_unit_bytes">μπάιτ</string>
|
||||
<string name="fm_prop_location">Τοποθεσία</string>
|
||||
<string name="fm_prop_type">Τύπος</string>
|
||||
<string name="fm_prop_size">Μέγεθος</string>
|
||||
@@ -1785,7 +1788,7 @@
|
||||
<string name="look_adaptive_sharpen_desc">Μόνο όξυνση, το χρώμα παραμένει ανεπηρέαστο.</string>
|
||||
<string name="look_filmic">Φιλμικό</string>
|
||||
<string name="look_filmic_desc">Καμπύλη σε στιλ φιλμ, ελαφρώς απαλή.</string>
|
||||
<string name="look_arcade">Arcade</string>
|
||||
<string name="look_arcade">Αρκέιντ</string>
|
||||
<string name="look_arcade_desc">Έντονο και δυναμικό.</string>
|
||||
<string name="look_retro_crt">Ρετρό CRT</string>
|
||||
<string name="look_retro_crt_desc">Ο σωλήνας CRT και τα αναλογικά περάσματα σήματος μαζί.</string>
|
||||
@@ -1923,7 +1926,6 @@
|
||||
<string name="stores_settings_eyebrow">Ρυθμίσεις</string>
|
||||
<string name="stores_chip_signed_out">(μη συνδεδεμένος)</string>
|
||||
<string name="stores_tab_store">Κατάστημα</string>
|
||||
<string name="stores_tab_installed">Εγκατεστημένα</string>
|
||||
<string name="stores_tab_library">Βιβλιοθήκη</string>
|
||||
<string name="stores_tab_all">Όλα</string>
|
||||
<string name="stores_tab_downloads">Λήψεις</string>
|
||||
@@ -1935,10 +1937,8 @@
|
||||
<string name="stores_signout_stay">Παραμονή συνδεδεμένου</string>
|
||||
<string name="stores_signout_go">Αποσύνδεση</string>
|
||||
<string name="stores_search_catalog">Αναζήτηση στον κατάλογο %1$s…</string>
|
||||
<string name="stores_search_installed">Αναζήτηση εγκατεστημένων παιχνιδιών…</string>
|
||||
<string name="stores_search_library">Αναζήτηση στη βιβλιοθήκη σας…</string>
|
||||
<string name="stores_search_open">Αναζήτηση</string>
|
||||
<string name="stores_installed_empty">Τίποτα εγκατεστημένο</string>
|
||||
<string name="stores_library_loading">Φόρτωση</string>
|
||||
<string name="stores_nothing_matches">Δεν υπάρχουν παιχνίδια</string>
|
||||
<string name="stores_shelves_loading">Φόρτωση του καταστήματος…</string>
|
||||
@@ -1957,7 +1957,19 @@
|
||||
<string name="stores_view_on">Προβολή στο %1$s</string>
|
||||
<string name="stores_buy_price">Αγορά · %1$s</string>
|
||||
<string name="stores_installed_chip">Εγκατεστημένο</string>
|
||||
<string name="stores_section_installed">Εγκατεστημένο</string>
|
||||
<string name="stores_section_not_installed">Δεν είναι εγκατεστημένο</string>
|
||||
<string name="stores_in_library">Στη βιβλιοθήκη</string>
|
||||
<string name="stores_itch_key">Κλειδί API</string>
|
||||
<string name="stores_itch_get_key">Λήψη κλειδιού</string>
|
||||
<string name="stores_itch_key_rejected">Το κλειδί απορρίφθηκε</string>
|
||||
<string name="stores_itch_not_owned">Δεν είναι στη βιβλιοθήκη σας στο itch.io</string>
|
||||
<string name="stores_itch_no_upload">Δεν υπάρχει λήψη για Windows για αυτό το παιχνίδι</string>
|
||||
<string name="stores_itch_no_exe">Δεν βρέθηκε παιχνίδι Windows στη λήψη</string>
|
||||
<string name="stores_itch_extract_failed">Δεν ήταν δυνατή η αποσυμπίεση της λήψης (κωδικός %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">Το itch.io δεν απάντησε</string>
|
||||
<string name="stores_itch_archive">Τα αρχεία %1$s δεν υποστηρίζονται</string>
|
||||
<string name="stores_itch_download_failed">Το itch.io απάντησε HTTP %1$d</string>
|
||||
<string name="stores_free">Δωρεάν</string>
|
||||
<string name="stores_resume">Συνέχιση</string>
|
||||
<string name="stores_refresh">Ανανέωση</string>
|
||||
@@ -2066,7 +2078,7 @@
|
||||
<string name="stores_dl_retry">Επανάληψη</string>
|
||||
<string name="stores_dl_clear">Εκκαθάριση</string>
|
||||
<string name="stores_dl_clear_all">Εκκαθάριση ολοκληρωμένων</string>
|
||||
<string name="stores_stage_manifest">Manifest</string>
|
||||
<string name="stores_stage_manifest">Μανιφέστο</string>
|
||||
<string name="stores_stage_download">Λήψη</string>
|
||||
<string name="stores_stage_verify">Επαλήθευση</string>
|
||||
<string name="stores_stage_install">Εγκατάσταση</string>
|
||||
@@ -2247,7 +2259,7 @@
|
||||
<string name="setup_download_source_mirror">Καθρέφτης</string>
|
||||
<string name="setup_download_source_custom">Δικό σου prefix</string>
|
||||
<string name="setup_download_mirror">Ιστότοπος</string>
|
||||
<string name="setup_download_prefix">Prefix</string>
|
||||
<string name="setup_download_prefix">Πρόθεμα</string>
|
||||
<string name="flathub_eyebrow">ΕΦΑΡΜΟΓΕΣ LINUX</string>
|
||||
<string name="flathub_name">Flathub</string>
|
||||
<string name="flathub_line">Εφαρμογές επιφάνειας εργασίας για την επιφάνεια εργασίας Linux, ανοίγουν από την Επιφάνεια εργασίας.</string>
|
||||
@@ -2625,7 +2637,7 @@
|
||||
<string name="storage_failed">Απέτυχε: %1$s</string>
|
||||
<string name="storage_desktop_shared">%1$s κοινά</string>
|
||||
<string name="storage_data_installed">Εγκατεστημένα παιχνίδια</string>
|
||||
<string name="storage_data_parts">Prefix %1$s · shaders %2$s · workshop %3$s</string>
|
||||
<string name="storage_data_parts">Πρόθεμα %1$s · σκιαστές %2$s · εργαστήριο %3$s</string>
|
||||
<string name="storage_clear_shaders">Εκκαθάριση shaders</string>
|
||||
<string name="storage_data_leftovers">Διαγραμμένα παιχνίδια</string>
|
||||
<string name="storage_steam_app">Εφαρμογή Steam %1$d</string>
|
||||
|
||||
@@ -729,6 +729,9 @@
|
||||
<string name="setup_app_scale_default">%1$d%% (predeterminada)</string>
|
||||
<string name="setup_theme">Tema</string>
|
||||
<string name="setup_theme_hint">Apariencia del launcher</string>
|
||||
<string name="setup_launcher_icon">Icono de la app</string>
|
||||
<string name="setup_launcher_icon_dark">Oscuro</string>
|
||||
<string name="setup_launcher_icon_light">Claro</string>
|
||||
<string name="setup_home">Usar como pantalla de inicio</string>
|
||||
<string name="setup_home_on">DroidDeck puede ser la app de inicio</string>
|
||||
<string name="setup_home_off">DroidDeck nunca se ofrece como app de inicio</string>
|
||||
@@ -1941,7 +1944,6 @@
|
||||
<string name="stores_settings_eyebrow">Ajustes</string>
|
||||
<string name="stores_chip_signed_out">(sin iniciar sesión)</string>
|
||||
<string name="stores_tab_store">Tienda</string>
|
||||
<string name="stores_tab_installed">Instalados</string>
|
||||
<string name="stores_tab_library">Biblioteca</string>
|
||||
<string name="stores_tab_all">Todos</string>
|
||||
<string name="stores_tab_downloads">Descargas</string>
|
||||
@@ -1953,10 +1955,8 @@
|
||||
<string name="stores_signout_stay">Mantener la sesión</string>
|
||||
<string name="stores_signout_go">Cerrar sesión</string>
|
||||
<string name="stores_search_catalog">Buscar en el catálogo de %1$s…</string>
|
||||
<string name="stores_search_installed">Buscar juegos instalados…</string>
|
||||
<string name="stores_search_library">Buscar en tu biblioteca…</string>
|
||||
<string name="stores_search_open">Buscar</string>
|
||||
<string name="stores_installed_empty">Nada instalado</string>
|
||||
<string name="stores_library_loading">Cargando</string>
|
||||
<string name="stores_nothing_matches">No hay juegos</string>
|
||||
<string name="stores_shelves_loading">Cargando la tienda…</string>
|
||||
@@ -1975,7 +1975,19 @@
|
||||
<string name="stores_view_on">Ver en %1$s</string>
|
||||
<string name="stores_buy_price">Comprar · %1$s</string>
|
||||
<string name="stores_installed_chip">Instalado</string>
|
||||
<string name="stores_section_installed">Instalado</string>
|
||||
<string name="stores_section_not_installed">No instalada</string>
|
||||
<string name="stores_in_library">En la biblioteca</string>
|
||||
<string name="stores_itch_key">Clave de API</string>
|
||||
<string name="stores_itch_get_key">Obtener una clave</string>
|
||||
<string name="stores_itch_key_rejected">Clave rechazada</string>
|
||||
<string name="stores_itch_not_owned">No está en tu biblioteca de itch.io</string>
|
||||
<string name="stores_itch_no_upload">No hay descarga para Windows de este juego</string>
|
||||
<string name="stores_itch_no_exe">No se ha encontrado un juego de Windows en la descarga</string>
|
||||
<string name="stores_itch_extract_failed">No se ha podido extraer la descarga (código %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io no respondió</string>
|
||||
<string name="stores_itch_archive">Los archivos %1$s no son compatibles</string>
|
||||
<string name="stores_itch_download_failed">itch.io respondió HTTP %1$d</string>
|
||||
<string name="stores_free">Gratis</string>
|
||||
<string name="stores_resume">Reanudar</string>
|
||||
<string name="stores_refresh">Actualizar</string>
|
||||
@@ -2613,32 +2625,39 @@
|
||||
<string name="storage_play_anyway">Jugar igualmente</string>
|
||||
<plurals name="storage_games_installed">
|
||||
<item quantity="one">%d instalado</item>
|
||||
<item quantity="many">%d instalados</item>
|
||||
<item quantity="other">%d instalados</item>
|
||||
</plurals>
|
||||
<plurals name="storage_games_on_sd">
|
||||
<item quantity="one">%d en la tarjeta SD</item>
|
||||
<item quantity="many">%d en la tarjeta SD</item>
|
||||
<item quantity="other">%d en la tarjeta SD</item>
|
||||
</plurals>
|
||||
<plurals name="storage_tools_count">
|
||||
<item quantity="one">%d herramienta</item>
|
||||
<item quantity="many">%d herramientas</item>
|
||||
<item quantity="other">%d herramientas</item>
|
||||
</plurals>
|
||||
<plurals name="storage_item_leftovers_detail">
|
||||
<item quantity="one">Carpetas de prefijo vacías y caché de shaders de %d juego</item>
|
||||
<item quantity="many">Carpetas de prefijo vacías y cachés de shaders de %d juegos</item>
|
||||
<item quantity="other">Carpetas de prefijo vacías y cachés de shaders de %d juegos</item>
|
||||
</plurals>
|
||||
<plurals name="storage_item_leftovers_unsure_detail">
|
||||
<item quantity="one">El prefijo de %d juego aún contiene archivos, que pueden incluir partidas guardadas locales. Haz una copia antes si las quieres.</item>
|
||||
<item quantity="many">Los prefijos de %d juegos aún contienen archivos, que pueden incluir partidas guardadas locales. Haz una copia antes si las quieres.</item>
|
||||
<item quantity="other">Los prefijos de %d juegos aún contienen archivos, que pueden incluir partidas guardadas locales. Haz una copia antes si las quieres.</item>
|
||||
</plurals>
|
||||
<plurals name="storage_tag_games">
|
||||
<item quantity="one">%d juego</item>
|
||||
<item quantity="many">%d juegos</item>
|
||||
<item quantity="other">%d juegos</item>
|
||||
</plurals>
|
||||
<string name="storage_tool_default_proton">Proton predeterminado de DroidDeck</string>
|
||||
<string name="storage_tool_proton">Opción Proton 11 de DroidDeck</string>
|
||||
<plurals name="storage_tool_custom_games">
|
||||
<item quantity="one">Usado por %d juego</item>
|
||||
<item quantity="many">Usado por %d juegos</item>
|
||||
<item quantity="other">Usado por %d juegos</item>
|
||||
</plurals>
|
||||
<string name="storage_legend_components">Componentes</string>
|
||||
@@ -2682,10 +2701,12 @@
|
||||
<string name="storage_flatpak_uninstall_body">Quita la app (%1$s) y cualquier runtime que no use nada más. Sus datos en tu carpeta personal se quedan.</string>
|
||||
<plurals name="storage_components_count">
|
||||
<item quantity="one">%d paquete</item>
|
||||
<item quantity="many">%d paquetes</item>
|
||||
<item quantity="other">%d paquetes</item>
|
||||
</plurals>
|
||||
<plurals name="storage_desktop_apps">
|
||||
<item quantity="one">%d app</item>
|
||||
<item quantity="many">%d apps</item>
|
||||
<item quantity="other">%d apps</item>
|
||||
</plurals>
|
||||
<string name="boot_movie">Vídeo de arranque</string>
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
<string name="fex_extreme_detail">Poistaa itseään muokkaavan koodin tarkistukset käytöstä. JIT-pelit ja DRM voivat hajota.</string>
|
||||
<string name="fex_extreme_tso_label">Äärimmäinen + TSO</string>
|
||||
<string name="fex_extreme_tso_detail">Äärimmäiset asetukset, pääasiallinen TSO käytössä.</string>
|
||||
<string name="fex_extreme_gn_label">Extreme-gn</string>
|
||||
<string name="fex_extreme_gn_label">Äärimmäinen-gn</string>
|
||||
<string name="fex_extreme_gn_detail">Suorituskyky TSC- ja metadatavihjeillä. Kokeile ennen Äärimmäistä.</string>
|
||||
<string name="fex_denuvo_label">Denuvo</string>
|
||||
<string name="fex_denuvo_detail">Suorituskyky täysin koodintarkistuksin ja hypervisor-bitti piilotettuna.</string>
|
||||
@@ -299,7 +299,7 @@
|
||||
<string name="mode_decky_installed">Asennettu · %1$s</string>
|
||||
<string name="mode_decky_update">Päivitys saatavilla · %1$s</string>
|
||||
<string name="mode_decky_current">Ajan tasalla · %1$s</string>
|
||||
<string name="mode_decky_loader">Loader</string>
|
||||
<string name="mode_decky_loader">Lataaja</string>
|
||||
<string name="mode_decky_install_latest">Asenna uusin</string>
|
||||
<string name="mode_decky_on">Käynnistyy Steamin kanssa. Päällä ollessaan muut tämän laitteen sovellukset voivat pystyä ohjaamaan Steamia.</string>
|
||||
<string name="mode_decky_off">Pois. Steamin etävianmääritysportti pysyy suljettuna.</string>
|
||||
@@ -729,6 +729,9 @@
|
||||
<string name="setup_app_scale_default">%1$d %% (Oletus)</string>
|
||||
<string name="setup_theme">Teema</string>
|
||||
<string name="setup_theme_hint">Käynnistimen ulkoasu</string>
|
||||
<string name="setup_launcher_icon">Sovelluskuvake</string>
|
||||
<string name="setup_launcher_icon_dark">Tumma</string>
|
||||
<string name="setup_launcher_icon_light">Vaalea</string>
|
||||
<string name="setup_home">Käytä Aloitusnäyttönä</string>
|
||||
<string name="setup_home_on">DroidDeck voi olla kotisovellus</string>
|
||||
<string name="setup_home_off">DroidDeckiä ei koskaan tarjota kotisovellukseksi</string>
|
||||
@@ -1923,7 +1926,6 @@
|
||||
<string name="stores_settings_eyebrow">Asetukset</string>
|
||||
<string name="stores_chip_signed_out">(ei kirjautunut)</string>
|
||||
<string name="stores_tab_store">Kauppa</string>
|
||||
<string name="stores_tab_installed">Asennetut</string>
|
||||
<string name="stores_tab_library">Kirjasto</string>
|
||||
<string name="stores_tab_all">Kaikki</string>
|
||||
<string name="stores_tab_downloads">Lataukset</string>
|
||||
@@ -1935,10 +1937,8 @@
|
||||
<string name="stores_signout_stay">Pysy kirjautuneena</string>
|
||||
<string name="stores_signout_go">Kirjaudu ulos</string>
|
||||
<string name="stores_search_catalog">Hae %1$s-luettelosta…</string>
|
||||
<string name="stores_search_installed">Hae asennetuista peleistä…</string>
|
||||
<string name="stores_search_library">Hae kirjastostasi…</string>
|
||||
<string name="stores_search_open">Hae</string>
|
||||
<string name="stores_installed_empty">Ei mitään asennettuna</string>
|
||||
<string name="stores_library_loading">Latautuu</string>
|
||||
<string name="stores_nothing_matches">Ei pelejä</string>
|
||||
<string name="stores_shelves_loading">Ladataan kauppaa…</string>
|
||||
@@ -1957,7 +1957,19 @@
|
||||
<string name="stores_view_on">Näytä %1$s:ssä</string>
|
||||
<string name="stores_buy_price">Osta · %1$s</string>
|
||||
<string name="stores_installed_chip">Asennettu</string>
|
||||
<string name="stores_section_installed">Asennettu</string>
|
||||
<string name="stores_section_not_installed">Ei asennettu</string>
|
||||
<string name="stores_in_library">Kirjastossa</string>
|
||||
<string name="stores_itch_key">API-avain</string>
|
||||
<string name="stores_itch_get_key">Hae avain</string>
|
||||
<string name="stores_itch_key_rejected">Avain hylätty</string>
|
||||
<string name="stores_itch_not_owned">Ei itch.io-kirjastossasi</string>
|
||||
<string name="stores_itch_no_upload">Ei Windows-latausta tälle pelille</string>
|
||||
<string name="stores_itch_no_exe">Latauksesta ei löytynyt Windows-peliä</string>
|
||||
<string name="stores_itch_extract_failed">Latausta ei voitu purkaa (koodi %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io ei vastannut</string>
|
||||
<string name="stores_itch_archive">%1$s-arkistot eivät ole tuettuja</string>
|
||||
<string name="stores_itch_download_failed">itch.io vastasi HTTP %1$d</string>
|
||||
<string name="stores_free">Ilmainen</string>
|
||||
<string name="stores_resume">Jatka</string>
|
||||
<string name="stores_refresh">Päivitä</string>
|
||||
|
||||
@@ -729,6 +729,9 @@
|
||||
<string name="setup_app_scale_default">%1$d%% (par défaut)</string>
|
||||
<string name="setup_theme">Thème</string>
|
||||
<string name="setup_theme_hint">Apparence du lanceur</string>
|
||||
<string name="setup_launcher_icon">Icône de l\'app</string>
|
||||
<string name="setup_launcher_icon_dark">Sombre</string>
|
||||
<string name="setup_launcher_icon_light">Clair</string>
|
||||
<string name="setup_home">Utiliser comme écran d\'accueil</string>
|
||||
<string name="setup_home_on">DroidDeck peut être utilisé comme application d\'écran d\'accueil</string>
|
||||
<string name="setup_home_off">DroidDeck ne sera pas proposé comme application d\'écran d\'accueil</string>
|
||||
@@ -1941,7 +1944,6 @@
|
||||
<string name="stores_settings_eyebrow">Paramètres</string>
|
||||
<string name="stores_chip_signed_out">(non connecté)</string>
|
||||
<string name="stores_tab_store">Boutique</string>
|
||||
<string name="stores_tab_installed">Installés</string>
|
||||
<string name="stores_tab_library">Bibliothèque</string>
|
||||
<string name="stores_tab_all">Tous</string>
|
||||
<string name="stores_tab_downloads">Téléchargements</string>
|
||||
@@ -1953,10 +1955,8 @@
|
||||
<string name="stores_signout_stay">Rester connecté</string>
|
||||
<string name="stores_signout_go">Se déconnecter</string>
|
||||
<string name="stores_search_catalog">Rechercher dans le catalogue %1$s…</string>
|
||||
<string name="stores_search_installed">Rechercher dans les jeux installés…</string>
|
||||
<string name="stores_search_library">Rechercher dans votre bibliothèque…</string>
|
||||
<string name="stores_search_open">Rechercher</string>
|
||||
<string name="stores_installed_empty">Aucun jeu installé</string>
|
||||
<string name="stores_library_loading">Chargement</string>
|
||||
<string name="stores_nothing_matches">Aucun jeu</string>
|
||||
<string name="stores_shelves_loading">Chargement de la boutique…</string>
|
||||
@@ -1975,7 +1975,19 @@
|
||||
<string name="stores_view_on">Voir sur %1$s</string>
|
||||
<string name="stores_buy_price">Acheter · %1$s</string>
|
||||
<string name="stores_installed_chip">Installé</string>
|
||||
<string name="stores_section_installed">Installé</string>
|
||||
<string name="stores_section_not_installed">Non installé</string>
|
||||
<string name="stores_in_library">Dans la bibliothèque</string>
|
||||
<string name="stores_itch_key">Clé d\'API</string>
|
||||
<string name="stores_itch_get_key">Obtenir une clé</string>
|
||||
<string name="stores_itch_key_rejected">Clé refusée</string>
|
||||
<string name="stores_itch_not_owned">Absent de votre bibliothèque itch.io</string>
|
||||
<string name="stores_itch_no_upload">Pas de téléchargement Windows pour ce jeu</string>
|
||||
<string name="stores_itch_no_exe">Aucun jeu Windows trouvé dans le téléchargement</string>
|
||||
<string name="stores_itch_extract_failed">Impossible d\'extraire le téléchargement (code %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io n\'a pas répondu</string>
|
||||
<string name="stores_itch_archive">Les archives %1$s ne sont pas prises en charge</string>
|
||||
<string name="stores_itch_download_failed">itch.io a répondu HTTP %1$d</string>
|
||||
<string name="stores_free">Gratuit</string>
|
||||
<string name="stores_resume">Reprendre</string>
|
||||
<string name="stores_refresh">Actualiser</string>
|
||||
@@ -2613,32 +2625,39 @@
|
||||
<string name="storage_play_anyway">Jouer quand même</string>
|
||||
<plurals name="storage_games_installed">
|
||||
<item quantity="one">%d installé</item>
|
||||
<item quantity="many">%d installés</item>
|
||||
<item quantity="other">%d installés</item>
|
||||
</plurals>
|
||||
<plurals name="storage_games_on_sd">
|
||||
<item quantity="one">%d sur la carte SD</item>
|
||||
<item quantity="many">%d sur la carte SD</item>
|
||||
<item quantity="other">%d sur la carte SD</item>
|
||||
</plurals>
|
||||
<plurals name="storage_tools_count">
|
||||
<item quantity="one">%d outil</item>
|
||||
<item quantity="many">%d outils</item>
|
||||
<item quantity="other">%d outils</item>
|
||||
</plurals>
|
||||
<plurals name="storage_item_leftovers_detail">
|
||||
<item quantity="one">Dossiers de préfixe vides et cache de shaders de %d jeu</item>
|
||||
<item quantity="many">Dossiers de préfixe vides et caches de shaders de %d jeux</item>
|
||||
<item quantity="other">Dossiers de préfixe vides et caches de shaders de %d jeux</item>
|
||||
</plurals>
|
||||
<plurals name="storage_item_leftovers_unsure_detail">
|
||||
<item quantity="one">Le préfixe de %d jeu contient encore des fichiers, dont d\'éventuelles sauvegardes locales. Sauvegardez-les avant si vous y tenez.</item>
|
||||
<item quantity="many">Les préfixes de %d jeux contiennent encore des fichiers, dont d\'éventuelles sauvegardes locales. Sauvegardez-les avant si vous y tenez.</item>
|
||||
<item quantity="other">Les préfixes de %d jeux contiennent encore des fichiers, dont d\'éventuelles sauvegardes locales. Sauvegardez-les avant si vous y tenez.</item>
|
||||
</plurals>
|
||||
<plurals name="storage_tag_games">
|
||||
<item quantity="one">%d jeu</item>
|
||||
<item quantity="many">%d jeux</item>
|
||||
<item quantity="other">%d jeux</item>
|
||||
</plurals>
|
||||
<string name="storage_tool_default_proton">Proton par défaut de DroidDeck</string>
|
||||
<string name="storage_tool_proton">Option Proton 11 de DroidDeck</string>
|
||||
<plurals name="storage_tool_custom_games">
|
||||
<item quantity="one">Utilisé par %d jeu</item>
|
||||
<item quantity="many">Utilisé par %d jeux</item>
|
||||
<item quantity="other">Utilisé par %d jeux</item>
|
||||
</plurals>
|
||||
<string name="storage_legend_components">Composants</string>
|
||||
@@ -2682,10 +2701,12 @@
|
||||
<string name="storage_flatpak_uninstall_body">Supprime l\'application (%1$s) et tout environnement d\'exécution que rien d\'autre n\'utilise. Ses données dans votre dossier personnel restent.</string>
|
||||
<plurals name="storage_components_count">
|
||||
<item quantity="one">%d paquet</item>
|
||||
<item quantity="many">%d paquets</item>
|
||||
<item quantity="other">%d paquets</item>
|
||||
</plurals>
|
||||
<plurals name="storage_desktop_apps">
|
||||
<item quantity="one">%d application</item>
|
||||
<item quantity="many">%d applications</item>
|
||||
<item quantity="other">%d applications</item>
|
||||
</plurals>
|
||||
<string name="boot_movie">Vidéo de démarrage</string>
|
||||
|
||||
@@ -729,6 +729,9 @@
|
||||
<string name="setup_app_scale_default">%1$d%% (डिफ़ॉल्ट)</string>
|
||||
<string name="setup_theme">थीम</string>
|
||||
<string name="setup_theme_hint">लॉन्चर की दिखावट</string>
|
||||
<string name="setup_launcher_icon">ऐप आइकन</string>
|
||||
<string name="setup_launcher_icon_dark">गहरा</string>
|
||||
<string name="setup_launcher_icon_light">हल्का</string>
|
||||
<string name="setup_home">होम स्क्रीन के तौर पर इस्तेमाल करें</string>
|
||||
<string name="setup_home_on">DroidDeck होम ऐप हो सकता है</string>
|
||||
<string name="setup_home_off">DroidDeck को होम ऐप के तौर पर कभी नहीं दिखाया जाता</string>
|
||||
@@ -1894,7 +1897,7 @@
|
||||
<string name="display_sharpness">फ़िल्टर शार्पनेस</string>
|
||||
<string name="display_frame_rate">फ़्रेम रेट</string>
|
||||
<string name="display_look_filter">%1$s स्केलिंग को %2$s पर सेट करता है।</string>
|
||||
<string name="display_match_screen">Match screen (%1$d×%2$d)</string>
|
||||
<string name="display_match_screen">स्क्रीन से मिलाएँ (%1$d×%2$d)</string>
|
||||
<string name="display_follow_screen">Follow screen (खुलने या बंद होने पर आकार बदलता है)</string>
|
||||
<string name="mode_tab_steam">Steam</string>
|
||||
<string name="device_info_show">डिवाइस जानकारी दिखाएं</string>
|
||||
@@ -1923,7 +1926,6 @@
|
||||
<string name="stores_settings_eyebrow">सेटिंग</string>
|
||||
<string name="stores_chip_signed_out">(साइन इन नहीं है)</string>
|
||||
<string name="stores_tab_store">स्टोर</string>
|
||||
<string name="stores_tab_installed">इंस्टॉल किए</string>
|
||||
<string name="stores_tab_library">लाइब्रेरी</string>
|
||||
<string name="stores_tab_all">सभी</string>
|
||||
<string name="stores_tab_downloads">डाउनलोड</string>
|
||||
@@ -1935,10 +1937,8 @@
|
||||
<string name="stores_signout_stay">साइन इन रहें</string>
|
||||
<string name="stores_signout_go">साइन आउट करें</string>
|
||||
<string name="stores_search_catalog">%1$s के कैटलॉग में सर्च करें…</string>
|
||||
<string name="stores_search_installed">इंस्टॉल किए गेम में सर्च करें…</string>
|
||||
<string name="stores_search_library">अपनी लाइब्रेरी में सर्च करें…</string>
|
||||
<string name="stores_search_open">सर्च</string>
|
||||
<string name="stores_installed_empty">कुछ इंस्टॉल नहीं है</string>
|
||||
<string name="stores_library_loading">लोड हो रहा है</string>
|
||||
<string name="stores_nothing_matches">कोई गेम नहीं</string>
|
||||
<string name="stores_shelves_loading">स्टोर लोड हो रहा है…</string>
|
||||
@@ -1957,7 +1957,19 @@
|
||||
<string name="stores_view_on">%1$s पर देखें</string>
|
||||
<string name="stores_buy_price">खरीदें · %1$s</string>
|
||||
<string name="stores_installed_chip">इंस्टॉल है</string>
|
||||
<string name="stores_section_installed">इंस्टॉल है</string>
|
||||
<string name="stores_section_not_installed">इंस्टॉल नहीं है</string>
|
||||
<string name="stores_in_library">लाइब्रेरी में</string>
|
||||
<string name="stores_itch_key">API कुंजी</string>
|
||||
<string name="stores_itch_get_key">कुंजी पाएं</string>
|
||||
<string name="stores_itch_key_rejected">कुंजी अस्वीकृत</string>
|
||||
<string name="stores_itch_not_owned">आपकी itch.io लाइब्रेरी में नहीं</string>
|
||||
<string name="stores_itch_no_upload">इस गेम के लिए Windows डाउनलोड नहीं है</string>
|
||||
<string name="stores_itch_no_exe">डाउनलोड में कोई Windows गेम नहीं मिला</string>
|
||||
<string name="stores_itch_extract_failed">डाउनलोड एक्सट्रैक्ट नहीं हो सका (कोड %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io ने जवाब नहीं दिया</string>
|
||||
<string name="stores_itch_archive">%1$s आर्काइव समर्थित नहीं हैं</string>
|
||||
<string name="stores_itch_download_failed">itch.io ने HTTP %1$d लौटाया</string>
|
||||
<string name="stores_free">फ़्री</string>
|
||||
<string name="stores_resume">फिर से शुरू करें</string>
|
||||
<string name="stores_refresh">रीफ़्रेश करें</string>
|
||||
|
||||
@@ -729,6 +729,9 @@
|
||||
<string name="setup_app_scale_default">%1$d%% (Alapértelmezett)</string>
|
||||
<string name="setup_theme">Téma</string>
|
||||
<string name="setup_theme_hint">Az indítóalkalmazás megjelenése</string>
|
||||
<string name="setup_launcher_icon">Alkalmazásikon</string>
|
||||
<string name="setup_launcher_icon_dark">Sötét</string>
|
||||
<string name="setup_launcher_icon_light">Világos</string>
|
||||
<string name="setup_home">Használat kezdőképernyőként</string>
|
||||
<string name="setup_home_on">A DroidDeck lehet a kezdőképernyő alkalmazás</string>
|
||||
<string name="setup_home_off">A DroidDeck soha nem jelenik meg kezdőképernyő alkalmazásként</string>
|
||||
@@ -1012,7 +1015,7 @@
|
||||
<string name="games_launch_settings">Indítási beállítások</string>
|
||||
<string name="games_launch">Indítás</string>
|
||||
<string name="games_files">Játékfájlok</string>
|
||||
<string name="games_prefix">Proton prefix</string>
|
||||
<string name="games_prefix">Proton-prefix</string>
|
||||
<string name="games_saves_looking">Mentések keresése…</string>
|
||||
<string name="games_saves_none">Nem található mentésmappa név szerint. Az exportálás a teljes felhasználói mappát veszi.</string>
|
||||
<string name="games_saves_folders">%1$d mentésmappa</string>
|
||||
@@ -1923,7 +1926,6 @@
|
||||
<string name="stores_settings_eyebrow">Beállítások</string>
|
||||
<string name="stores_chip_signed_out">(nincs bejelentkezve)</string>
|
||||
<string name="stores_tab_store">Áruház</string>
|
||||
<string name="stores_tab_installed">Telepítve</string>
|
||||
<string name="stores_tab_library">Könyvtár</string>
|
||||
<string name="stores_tab_all">Összes</string>
|
||||
<string name="stores_tab_downloads">Letöltések</string>
|
||||
@@ -1935,10 +1937,8 @@
|
||||
<string name="stores_signout_stay">Bejelentkezve marad</string>
|
||||
<string name="stores_signout_go">Kijelentkezés</string>
|
||||
<string name="stores_search_catalog">Keresés a %1$s katalógusban…</string>
|
||||
<string name="stores_search_installed">Keresés a telepített játékok között…</string>
|
||||
<string name="stores_search_library">Keresés a könyvtárában…</string>
|
||||
<string name="stores_search_open">Keresés</string>
|
||||
<string name="stores_installed_empty">Nincs telepítve semmi</string>
|
||||
<string name="stores_library_loading">Betöltés</string>
|
||||
<string name="stores_nothing_matches">Nincs játék</string>
|
||||
<string name="stores_shelves_loading">Az áruház betöltése…</string>
|
||||
@@ -1957,7 +1957,19 @@
|
||||
<string name="stores_view_on">Megtekintés itt: %1$s</string>
|
||||
<string name="stores_buy_price">Megvétel · %1$s</string>
|
||||
<string name="stores_installed_chip">Telepítve</string>
|
||||
<string name="stores_section_installed">Telepítve</string>
|
||||
<string name="stores_section_not_installed">Nincs telepítve</string>
|
||||
<string name="stores_in_library">A könyvtárban</string>
|
||||
<string name="stores_itch_key">API-kulcs</string>
|
||||
<string name="stores_itch_get_key">Kulcs beszerzése</string>
|
||||
<string name="stores_itch_key_rejected">A kulcs elutasítva</string>
|
||||
<string name="stores_itch_not_owned">Nincs az itch.io-könyvtáradban</string>
|
||||
<string name="stores_itch_no_upload">Ehhez a játékhoz nincs Windows-letöltés</string>
|
||||
<string name="stores_itch_no_exe">A letöltésben nem található Windows-játék</string>
|
||||
<string name="stores_itch_extract_failed">A letöltés nem bontható ki (kód: %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">Az itch.io nem válaszolt</string>
|
||||
<string name="stores_itch_archive">A(z) %1$s archívumok nem támogatottak</string>
|
||||
<string name="stores_itch_download_failed">Az itch.io HTTP %1$d választ adott</string>
|
||||
<string name="stores_free">Ingyenes</string>
|
||||
<string name="stores_resume">Folytatás</string>
|
||||
<string name="stores_refresh">Frissítés</string>
|
||||
|
||||
@@ -726,6 +726,9 @@
|
||||
<string name="setup_app_scale_default">%1$d%% (Default)</string>
|
||||
<string name="setup_theme">Tema</string>
|
||||
<string name="setup_theme_hint">Tampilan peluncur</string>
|
||||
<string name="setup_launcher_icon">Ikon aplikasi</string>
|
||||
<string name="setup_launcher_icon_dark">Gelap</string>
|
||||
<string name="setup_launcher_icon_light">Terang</string>
|
||||
<string name="setup_home">Gunakan sebagai Layar Utama</string>
|
||||
<string name="setup_home_on">DroidDeck dapat menjadi aplikasi Utama</string>
|
||||
<string name="setup_home_off">DroidDeck tidak pernah ditawarkan sebagai aplikasi Utama</string>
|
||||
@@ -1520,9 +1523,9 @@
|
||||
<string name="ctrl_tint_steam_blue">Biru Steam</string>
|
||||
<string name="ctrl_tint_sky">Langit</string>
|
||||
<string name="ctrl_tint_white">Putih</string>
|
||||
<string name="ctrl_tint_violet">Violet</string>
|
||||
<string name="ctrl_tint_violet">Ungu</string>
|
||||
<string name="ctrl_tint_green">Hijau</string>
|
||||
<string name="ctrl_tint_amber">Amber</string>
|
||||
<string name="ctrl_tint_amber">Ambar</string>
|
||||
<string name="ctrl_tint_red">Merah</string>
|
||||
<string name="ctrl_tint_pink">Merah muda</string>
|
||||
<string name="ctrl_button_a">Tombol A</string>
|
||||
@@ -1745,8 +1748,8 @@
|
||||
<string name="cmgr_list_predates_checksums">Daftar paket ini dibuat sebelum adanya checksum - segarkan dan coba lagi</string>
|
||||
<string name="cmgr_checksum_mismatch">Checksum tidak cocok</string>
|
||||
|
||||
<string name="sprefs_upscaler_linear">Linear</string>
|
||||
<string name="sprefs_upscaler_nearest">Nearest</string>
|
||||
<string name="sprefs_upscaler_linear">Linier</string>
|
||||
<string name="sprefs_upscaler_nearest">Terdekat</string>
|
||||
<string name="sprefs_upscaler_gsr_quality">Snapdragon GSR (kualitas)</string>
|
||||
<string name="sprefs_upscaler_sharpen">Hanya penajaman</string>
|
||||
<string name="sprefs_lod_bias_auto">Otomatis (sesuaikan skala)</string>
|
||||
@@ -1754,7 +1757,7 @@
|
||||
<string name="look_off_desc">Tidak ada yang diterapkan.</string>
|
||||
<string name="look_game_clarity">Kejernihan Gim</string>
|
||||
<string name="look_game_clarity_desc">Penajaman dengan sedikit kontras - cocok untuk sehari-hari.</string>
|
||||
<string name="look_vivid">Vivid</string>
|
||||
<string name="look_vivid">Cerah</string>
|
||||
<string name="look_vivid_desc">Warna didorong lebih kuat. Deband aktif agar langit tidak bergaris.</string>
|
||||
<string name="look_cinematic">Sinematik</string>
|
||||
<string name="look_cinematic_desc">Kontras lebih dalam, warna lebih dingin, tepi dihaluskan.</string>
|
||||
@@ -1902,7 +1905,6 @@
|
||||
<string name="stores_settings_eyebrow">Setelan</string>
|
||||
<string name="stores_chip_signed_out">(belum masuk)</string>
|
||||
<string name="stores_tab_store">Toko</string>
|
||||
<string name="stores_tab_installed">Terinstal</string>
|
||||
<string name="stores_tab_library">Pustaka</string>
|
||||
<string name="stores_tab_all">Semua</string>
|
||||
<string name="stores_tab_downloads">Unduhan</string>
|
||||
@@ -1914,10 +1916,8 @@
|
||||
<string name="stores_signout_stay">Tetap masuk</string>
|
||||
<string name="stores_signout_go">Keluar</string>
|
||||
<string name="stores_search_catalog">Cari katalog %1$s…</string>
|
||||
<string name="stores_search_installed">Cari gim yang terinstal…</string>
|
||||
<string name="stores_search_library">Cari pustaka Anda…</string>
|
||||
<string name="stores_search_open">Cari</string>
|
||||
<string name="stores_installed_empty">Belum ada yang terinstal</string>
|
||||
<string name="stores_library_loading">Memuat</string>
|
||||
<string name="stores_nothing_matches">Tidak ada gim</string>
|
||||
<string name="stores_shelves_loading">Memuat toko…</string>
|
||||
@@ -1936,7 +1936,19 @@
|
||||
<string name="stores_view_on">Lihat di %1$s</string>
|
||||
<string name="stores_buy_price">Beli · %1$s</string>
|
||||
<string name="stores_installed_chip">Terinstal</string>
|
||||
<string name="stores_section_installed">Terinstal</string>
|
||||
<string name="stores_section_not_installed">Belum terinstal</string>
|
||||
<string name="stores_in_library">Dalam pustaka</string>
|
||||
<string name="stores_itch_key">Kunci API</string>
|
||||
<string name="stores_itch_get_key">Dapatkan kunci</string>
|
||||
<string name="stores_itch_key_rejected">Kunci ditolak</string>
|
||||
<string name="stores_itch_not_owned">Tidak ada di pustaka itch.io Anda</string>
|
||||
<string name="stores_itch_no_upload">Tidak ada unduhan Windows untuk game ini</string>
|
||||
<string name="stores_itch_no_exe">Tidak ada game Windows di unduhan</string>
|
||||
<string name="stores_itch_extract_failed">Tidak dapat mengekstrak unduhan (kode %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io tidak menjawab</string>
|
||||
<string name="stores_itch_archive">Arsip %1$s tidak didukung</string>
|
||||
<string name="stores_itch_download_failed">itch.io menjawab HTTP %1$d</string>
|
||||
<string name="stores_free">Gratis</string>
|
||||
<string name="stores_resume">Lanjutkan</string>
|
||||
<string name="stores_refresh">Segarkan</string>
|
||||
|
||||
@@ -721,6 +721,9 @@
|
||||
<string name="setup_app_scale_default">%1$d%% (predefinita)</string>
|
||||
<string name="setup_theme">Tema</string>
|
||||
<string name="setup_theme_hint">Aspetto del launcher</string>
|
||||
<string name="setup_launcher_icon">Icona dell\'app</string>
|
||||
<string name="setup_launcher_icon_dark">Scura</string>
|
||||
<string name="setup_launcher_icon_light">Chiara</string>
|
||||
<string name="setup_home">Usa come schermata Home</string>
|
||||
<string name="setup_home_on">DroidDeck può essere l\'app Home</string>
|
||||
<string name="setup_home_off">DroidDeck non viene mai proposta come app Home</string>
|
||||
@@ -1933,7 +1936,6 @@
|
||||
<string name="stores_settings_eyebrow">Impostazioni</string>
|
||||
<string name="stores_chip_signed_out">(accesso non eseguito)</string>
|
||||
<string name="stores_tab_store">Negozio</string>
|
||||
<string name="stores_tab_installed">Installati</string>
|
||||
<string name="stores_tab_library">Libreria</string>
|
||||
<string name="stores_tab_all">Tutti</string>
|
||||
<string name="stores_tab_downloads">Download</string>
|
||||
@@ -1945,10 +1947,8 @@
|
||||
<string name="stores_signout_stay">Resta connesso</string>
|
||||
<string name="stores_signout_go">Esci</string>
|
||||
<string name="stores_search_catalog">Cerca nel catalogo di %1$s…</string>
|
||||
<string name="stores_search_installed">Cerca tra i giochi installati…</string>
|
||||
<string name="stores_search_library">Cerca nella tua libreria…</string>
|
||||
<string name="stores_search_open">Cerca</string>
|
||||
<string name="stores_installed_empty">Niente di installato</string>
|
||||
<string name="stores_library_loading">Caricamento</string>
|
||||
<string name="stores_nothing_matches">Nessun gioco</string>
|
||||
<string name="stores_shelves_loading">Caricamento del negozio…</string>
|
||||
@@ -1967,7 +1967,19 @@
|
||||
<string name="stores_view_on">Visualizza su %1$s</string>
|
||||
<string name="stores_buy_price">Acquista · %1$s</string>
|
||||
<string name="stores_installed_chip">Installato</string>
|
||||
<string name="stores_section_installed">Installato</string>
|
||||
<string name="stores_section_not_installed">Non installata</string>
|
||||
<string name="stores_in_library">In libreria</string>
|
||||
<string name="stores_itch_key">Chiave API</string>
|
||||
<string name="stores_itch_get_key">Ottieni una chiave</string>
|
||||
<string name="stores_itch_key_rejected">Chiave rifiutata</string>
|
||||
<string name="stores_itch_not_owned">Non è nella tua libreria itch.io</string>
|
||||
<string name="stores_itch_no_upload">Nessun download per Windows di questo gioco</string>
|
||||
<string name="stores_itch_no_exe">Nessun gioco Windows trovato nel download</string>
|
||||
<string name="stores_itch_extract_failed">Impossibile estrarre il download (codice %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io non ha risposto</string>
|
||||
<string name="stores_itch_archive">Gli archivi %1$s non sono supportati</string>
|
||||
<string name="stores_itch_download_failed">itch.io ha risposto HTTP %1$d</string>
|
||||
<string name="stores_free">Gratis</string>
|
||||
<string name="stores_resume">Riprendi</string>
|
||||
<string name="stores_refresh">Aggiorna</string>
|
||||
@@ -2613,32 +2625,39 @@
|
||||
<string name="storage_play_anyway">Gioca comunque</string>
|
||||
<plurals name="storage_games_installed">
|
||||
<item quantity="one">%d installato</item>
|
||||
<item quantity="many">%d installati</item>
|
||||
<item quantity="other">%d installati</item>
|
||||
</plurals>
|
||||
<plurals name="storage_games_on_sd">
|
||||
<item quantity="one">%d su scheda SD</item>
|
||||
<item quantity="many">%d su scheda SD</item>
|
||||
<item quantity="other">%d su scheda SD</item>
|
||||
</plurals>
|
||||
<plurals name="storage_tools_count">
|
||||
<item quantity="one">%d strumento</item>
|
||||
<item quantity="many">%d strumenti</item>
|
||||
<item quantity="other">%d strumenti</item>
|
||||
</plurals>
|
||||
<plurals name="storage_item_leftovers_detail">
|
||||
<item quantity="one">Cartelle di prefisso vuote e cache shader di %d gioco</item>
|
||||
<item quantity="many">Cartelle di prefisso vuote e cache shader di %d giochi</item>
|
||||
<item quantity="other">Cartelle di prefisso vuote e cache shader di %d giochi</item>
|
||||
</plurals>
|
||||
<plurals name="storage_item_leftovers_unsure_detail">
|
||||
<item quantity="one">Il prefisso di %d gioco contiene ancora file, che possono includere salvataggi locali. Se ti servono, fanne una copia prima.</item>
|
||||
<item quantity="many">I prefissi di %d giochi contengono ancora file, che possono includere salvataggi locali. Se ti servono, fanne una copia prima.</item>
|
||||
<item quantity="other">I prefissi di %d giochi contengono ancora file, che possono includere salvataggi locali. Se ti servono, fanne una copia prima.</item>
|
||||
</plurals>
|
||||
<plurals name="storage_tag_games">
|
||||
<item quantity="one">%d gioco</item>
|
||||
<item quantity="many">%d giochi</item>
|
||||
<item quantity="other">%d giochi</item>
|
||||
</plurals>
|
||||
<string name="storage_tool_default_proton">Proton predefinito di DroidDeck</string>
|
||||
<string name="storage_tool_proton">Opzione Proton 11 di DroidDeck</string>
|
||||
<plurals name="storage_tool_custom_games">
|
||||
<item quantity="one">Usato da %d gioco</item>
|
||||
<item quantity="many">Usato da %d giochi</item>
|
||||
<item quantity="other">Usato da %d giochi</item>
|
||||
</plurals>
|
||||
<string name="storage_legend_components">Componenti</string>
|
||||
@@ -2682,10 +2701,12 @@
|
||||
<string name="storage_flatpak_uninstall_body">Rimuove l\'app (%1$s) e i runtime che nessun altro usa. I suoi dati nella cartella home restano.</string>
|
||||
<plurals name="storage_components_count">
|
||||
<item quantity="one">%d pacchetto</item>
|
||||
<item quantity="many">%d pacchetti</item>
|
||||
<item quantity="other">%d pacchetti</item>
|
||||
</plurals>
|
||||
<plurals name="storage_desktop_apps">
|
||||
<item quantity="one">%d app</item>
|
||||
<item quantity="many">%d app</item>
|
||||
<item quantity="other">%d app</item>
|
||||
</plurals>
|
||||
<string name="boot_movie">Video di avvio</string>
|
||||
|
||||
@@ -726,6 +726,9 @@
|
||||
<string name="setup_app_scale_default">%1$d%%(デフォルト)</string>
|
||||
<string name="setup_theme">テーマ</string>
|
||||
<string name="setup_theme_hint">ランチャーの外観</string>
|
||||
<string name="setup_launcher_icon">アプリアイコン</string>
|
||||
<string name="setup_launcher_icon_dark">ダーク</string>
|
||||
<string name="setup_launcher_icon_light">ライト</string>
|
||||
<string name="setup_home">ホーム画面として使用</string>
|
||||
<string name="setup_home_on">DroidDeckをホームアプリとして使用できます</string>
|
||||
<string name="setup_home_off">DroidDeckはホームアプリとして提供されません</string>
|
||||
@@ -1902,7 +1905,6 @@
|
||||
<string name="stores_settings_eyebrow">設定</string>
|
||||
<string name="stores_chip_signed_out">(未ログイン)</string>
|
||||
<string name="stores_tab_store">ストア</string>
|
||||
<string name="stores_tab_installed">インストール済み</string>
|
||||
<string name="stores_tab_library">ライブラリ</string>
|
||||
<string name="stores_tab_all">すべて</string>
|
||||
<string name="stores_tab_downloads">ダウンロード</string>
|
||||
@@ -1914,10 +1916,8 @@
|
||||
<string name="stores_signout_stay">ログインしたままにする</string>
|
||||
<string name="stores_signout_go">ログアウト</string>
|
||||
<string name="stores_search_catalog">%1$sのカタログを検索…</string>
|
||||
<string name="stores_search_installed">インストール済みのゲームを検索…</string>
|
||||
<string name="stores_search_library">ライブラリを検索…</string>
|
||||
<string name="stores_search_open">検索</string>
|
||||
<string name="stores_installed_empty">何もインストールされていません</string>
|
||||
<string name="stores_library_loading">読み込み中</string>
|
||||
<string name="stores_nothing_matches">ゲームがありません</string>
|
||||
<string name="stores_shelves_loading">ストアを読み込み中…</string>
|
||||
@@ -1936,7 +1936,19 @@
|
||||
<string name="stores_view_on">%1$sで見る</string>
|
||||
<string name="stores_buy_price">購入 · %1$s</string>
|
||||
<string name="stores_installed_chip">インストール済み</string>
|
||||
<string name="stores_section_installed">インストール済み</string>
|
||||
<string name="stores_section_not_installed">未インストール</string>
|
||||
<string name="stores_in_library">ライブラリにあり</string>
|
||||
<string name="stores_itch_key">APIキー</string>
|
||||
<string name="stores_itch_get_key">キーを取得</string>
|
||||
<string name="stores_itch_key_rejected">キーが拒否されました</string>
|
||||
<string name="stores_itch_not_owned">itch.io ライブラリにありません</string>
|
||||
<string name="stores_itch_no_upload">このゲームには Windows 版のダウンロードがありません</string>
|
||||
<string name="stores_itch_no_exe">ダウンロードに Windows 版のゲームが見つかりません</string>
|
||||
<string name="stores_itch_extract_failed">ダウンロードを展開できませんでした (終了コード %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io から応答がありません</string>
|
||||
<string name="stores_itch_archive">%1$s 形式のアーカイブには対応していません</string>
|
||||
<string name="stores_itch_download_failed">itch.io が HTTP %1$d を返しました</string>
|
||||
<string name="stores_free">無料</string>
|
||||
<string name="stores_resume">再開</string>
|
||||
<string name="stores_refresh">更新</string>
|
||||
|
||||
@@ -726,6 +726,9 @@
|
||||
<string name="setup_app_scale_default">%1$d%%(기본값)</string>
|
||||
<string name="setup_theme">테마</string>
|
||||
<string name="setup_theme_hint">런처 외관</string>
|
||||
<string name="setup_launcher_icon">앱 아이콘</string>
|
||||
<string name="setup_launcher_icon_dark">다크</string>
|
||||
<string name="setup_launcher_icon_light">라이트</string>
|
||||
<string name="setup_home">홈 화면으로 사용</string>
|
||||
<string name="setup_home_on">DroidDeck을 홈 앱으로 사용할 수 있습니다</string>
|
||||
<string name="setup_home_off">DroidDeck은 홈 앱으로 제공되지 않습니다</string>
|
||||
@@ -1902,7 +1905,6 @@
|
||||
<string name="stores_settings_eyebrow">설정</string>
|
||||
<string name="stores_chip_signed_out">(로그인 안 됨)</string>
|
||||
<string name="stores_tab_store">스토어</string>
|
||||
<string name="stores_tab_installed">설치됨</string>
|
||||
<string name="stores_tab_library">라이브러리</string>
|
||||
<string name="stores_tab_all">전체</string>
|
||||
<string name="stores_tab_downloads">다운로드</string>
|
||||
@@ -1914,10 +1916,8 @@
|
||||
<string name="stores_signout_stay">로그인 유지</string>
|
||||
<string name="stores_signout_go">로그아웃</string>
|
||||
<string name="stores_search_catalog">%1$s 카탈로그 검색…</string>
|
||||
<string name="stores_search_installed">설치된 게임 검색…</string>
|
||||
<string name="stores_search_library">내 라이브러리 검색…</string>
|
||||
<string name="stores_search_open">검색</string>
|
||||
<string name="stores_installed_empty">설치된 항목 없음</string>
|
||||
<string name="stores_library_loading">불러오는 중</string>
|
||||
<string name="stores_nothing_matches">게임 없음</string>
|
||||
<string name="stores_shelves_loading">스토어 불러오는 중…</string>
|
||||
@@ -1936,7 +1936,19 @@
|
||||
<string name="stores_view_on">%1$s에서 보기</string>
|
||||
<string name="stores_buy_price">구매 · %1$s</string>
|
||||
<string name="stores_installed_chip">설치됨</string>
|
||||
<string name="stores_section_installed">설치됨</string>
|
||||
<string name="stores_section_not_installed">설치되지 않음</string>
|
||||
<string name="stores_in_library">라이브러리에 있음</string>
|
||||
<string name="stores_itch_key">API 키</string>
|
||||
<string name="stores_itch_get_key">키 받기</string>
|
||||
<string name="stores_itch_key_rejected">키가 거부되었습니다</string>
|
||||
<string name="stores_itch_not_owned">itch.io 라이브러리에 없습니다</string>
|
||||
<string name="stores_itch_no_upload">이 게임의 Windows 다운로드가 없습니다</string>
|
||||
<string name="stores_itch_no_exe">다운로드에서 Windows 게임을 찾을 수 없습니다</string>
|
||||
<string name="stores_itch_extract_failed">다운로드한 파일의 압축을 풀 수 없습니다(코드 %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io가 응답하지 않았습니다</string>
|
||||
<string name="stores_itch_archive">%1$s 압축 파일은 지원하지 않습니다</string>
|
||||
<string name="stores_itch_download_failed">itch.io가 HTTP %1$d을(를) 반환했습니다</string>
|
||||
<string name="stores_free">무료</string>
|
||||
<string name="stores_resume">재개</string>
|
||||
<string name="stores_refresh">새로 고침</string>
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
<string name="second_library_import_hint">Menambah pustaka Steam dan mengimbasnya untuk permainan Windows. Permainan yang dimiliki menggunakan entri Steam masing-masing; selebihnya menjadi pintasan bukan Steam.</string>
|
||||
<string name="game_env_title">Persekitaran permainan</string>
|
||||
<string name="game_env_hint">Pembolehubah persekitaran untuk permainan Proton.</string>
|
||||
<string name="game_env_edit">Edit</string>
|
||||
<string name="game_env_edit">Sunting</string>
|
||||
<string name="game_env_shared">Semua permainan Proton</string>
|
||||
<string name="game_env_scope">Profil</string>
|
||||
<string name="game_env_app_id">ID apl Steam</string>
|
||||
@@ -153,7 +153,7 @@
|
||||
<string name="user_apps_update_failed">Mengemas kini apl gagal</string>
|
||||
<string name="user_apps_icon_failed">Ikon itu tidak dapat dibaca</string>
|
||||
<string name="user_apps_saved">%1$s disimpan</string>
|
||||
<string name="user_apps_edit">Edit</string>
|
||||
<string name="user_apps_edit">Sunting</string>
|
||||
<string name="user_apps_check_updates">Semak kemas kini</string>
|
||||
<string name="user_apps_checking_updates">Menyemak kemas kini…</string>
|
||||
<string name="user_apps_update_to">Kemas kini kepada %1$s</string>
|
||||
@@ -205,7 +205,7 @@
|
||||
<string name="add_app_icon_suggestions">Daripada repositori</string>
|
||||
<string name="add_app_icon_searching">Mencari ikon…</string>
|
||||
<string name="add_app_icon_reset">Lalai</string>
|
||||
<string name="edit_app_title">Edit %1$s</string>
|
||||
<string name="edit_app_title">Sunting %1$s</string>
|
||||
<string name="edit_app_save">Simpan</string>
|
||||
<string name="add_app_checking_folder">Menyemak folder…</string>
|
||||
<string name="add_app_folder_linked">Foldernya dipautkan ke dalam sesi: %1$s</string>
|
||||
@@ -315,7 +315,7 @@
|
||||
<string name="mode_added_art">Karya seni</string>
|
||||
<string name="mode_sgdb_key">Kunci API SteamGridDB</string>
|
||||
<string name="mode_sgdb_key_yours">Kunci anda</string>
|
||||
<string name="mode_sgdb_edit">Edit…</string>
|
||||
<string name="mode_sgdb_edit">Sunting…</string>
|
||||
<string name="mode_added_art_hint">Melengkapkan karya seni yang hilang daripada Steam, kemudian SteamGridDB. cover.jpg, hero.jpg, logo.png atau icon.png anda sendiri dalam folder permainan diutamakan.</string>
|
||||
<string name="summary_remove_game">Alih keluar %1$s</string>
|
||||
<string name="mode_added_remove_confirm">Alih keluar?</string>
|
||||
@@ -726,6 +726,9 @@
|
||||
<string name="setup_app_scale_default">%1$d%% (Lalai)</string>
|
||||
<string name="setup_theme">Tema</string>
|
||||
<string name="setup_theme_hint">Rupa pelancar</string>
|
||||
<string name="setup_launcher_icon">Ikon apl</string>
|
||||
<string name="setup_launcher_icon_dark">Gelap</string>
|
||||
<string name="setup_launcher_icon_light">Cerah</string>
|
||||
<string name="setup_home">Gunakan sebagai skrin Utama</string>
|
||||
<string name="setup_home_on">DroidDeck boleh menjadi apl Utama</string>
|
||||
<string name="setup_home_off">DroidDeck tidak sekali-kali ditawarkan sebagai apl Utama</string>
|
||||
@@ -1520,7 +1523,7 @@
|
||||
<string name="ctrl_tint_steam_blue">Biru Steam</string>
|
||||
<string name="ctrl_tint_sky">Langit</string>
|
||||
<string name="ctrl_tint_white">Putih</string>
|
||||
<string name="ctrl_tint_violet">Violet</string>
|
||||
<string name="ctrl_tint_violet">Ungu</string>
|
||||
<string name="ctrl_tint_green">Hijau</string>
|
||||
<string name="ctrl_tint_amber">Ambar</string>
|
||||
<string name="ctrl_tint_red">Merah</string>
|
||||
@@ -1902,7 +1905,6 @@
|
||||
<string name="stores_settings_eyebrow">Tetapan</string>
|
||||
<string name="stores_chip_signed_out">(belum log masuk)</string>
|
||||
<string name="stores_tab_store">Kedai</string>
|
||||
<string name="stores_tab_installed">Dipasang</string>
|
||||
<string name="stores_tab_library">Pustaka</string>
|
||||
<string name="stores_tab_all">Semua</string>
|
||||
<string name="stores_tab_downloads">Muat turun</string>
|
||||
@@ -1914,10 +1916,8 @@
|
||||
<string name="stores_signout_stay">Kekal log masuk</string>
|
||||
<string name="stores_signout_go">Log keluar</string>
|
||||
<string name="stores_search_catalog">Cari katalog %1$s…</string>
|
||||
<string name="stores_search_installed">Cari permainan dipasang…</string>
|
||||
<string name="stores_search_library">Cari pustaka anda…</string>
|
||||
<string name="stores_search_open">Cari</string>
|
||||
<string name="stores_installed_empty">Tiada apa dipasang</string>
|
||||
<string name="stores_library_loading">Memuatkan</string>
|
||||
<string name="stores_nothing_matches">Tiada permainan</string>
|
||||
<string name="stores_shelves_loading">Memuatkan kedai…</string>
|
||||
@@ -1936,7 +1936,19 @@
|
||||
<string name="stores_view_on">Lihat pada %1$s</string>
|
||||
<string name="stores_buy_price">Beli · %1$s</string>
|
||||
<string name="stores_installed_chip">Dipasang</string>
|
||||
<string name="stores_section_installed">Dipasang</string>
|
||||
<string name="stores_section_not_installed">Belum dipasang</string>
|
||||
<string name="stores_in_library">Dalam pustaka</string>
|
||||
<string name="stores_itch_key">Kunci API</string>
|
||||
<string name="stores_itch_get_key">Dapatkan kunci</string>
|
||||
<string name="stores_itch_key_rejected">Kunci ditolak</string>
|
||||
<string name="stores_itch_not_owned">Tiada dalam pustaka itch.io anda</string>
|
||||
<string name="stores_itch_no_upload">Tiada muat turun Windows untuk permainan ini</string>
|
||||
<string name="stores_itch_no_exe">Tiada permainan Windows ditemui dalam muat turun</string>
|
||||
<string name="stores_itch_extract_failed">Tidak dapat mengekstrak muat turun (kod %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io tidak menjawab</string>
|
||||
<string name="stores_itch_archive">Arkib %1$s tidak disokong</string>
|
||||
<string name="stores_itch_download_failed">itch.io menjawab HTTP %1$d</string>
|
||||
<string name="stores_free">Percuma</string>
|
||||
<string name="stores_resume">Sambung</string>
|
||||
<string name="stores_refresh">Segar semula</string>
|
||||
|
||||
@@ -527,7 +527,7 @@
|
||||
<string name="perf_client_ui">Klientgrensesnitt</string>
|
||||
<string name="perf_glthread">Trådet GL</string>
|
||||
<string name="perf_glthread_hint">Kan gjøre Steam-menyer mer responsive.</string>
|
||||
<string name="perf_zink">Zink: lazy descriptors</string>
|
||||
<string name="perf_zink">Zink: late deskriptorer</string>
|
||||
<string name="perf_zink_hint">Anbefales for drivere uten descriptor buffers.</string>
|
||||
<string name="perf_noglerror">Hopp over GL-feilkontroller</string>
|
||||
<string name="perf_noglerror_hint">Slår av GL-validering per kall.</string>
|
||||
@@ -729,6 +729,9 @@
|
||||
<string name="setup_app_scale_default">%1$d%% (Standard)</string>
|
||||
<string name="setup_theme">Tema</string>
|
||||
<string name="setup_theme_hint">Launcherens utseende</string>
|
||||
<string name="setup_launcher_icon">Appikon</string>
|
||||
<string name="setup_launcher_icon_dark">Mørk</string>
|
||||
<string name="setup_launcher_icon_light">Lys</string>
|
||||
<string name="setup_home">Bruk som Hjem-skjerm</string>
|
||||
<string name="setup_home_on">DroidDeck kan være Hjem-appen</string>
|
||||
<string name="setup_home_off">DroidDeck tilbys aldri som en Hjem-app</string>
|
||||
@@ -1923,7 +1926,6 @@
|
||||
<string name="stores_settings_eyebrow">Innstillinger</string>
|
||||
<string name="stores_chip_signed_out">(ikke innlogget)</string>
|
||||
<string name="stores_tab_store">Butikk</string>
|
||||
<string name="stores_tab_installed">Installert</string>
|
||||
<string name="stores_tab_library">Bibliotek</string>
|
||||
<string name="stores_tab_all">Alle</string>
|
||||
<string name="stores_tab_downloads">Nedlastinger</string>
|
||||
@@ -1935,10 +1937,8 @@
|
||||
<string name="stores_signout_stay">Fortsett innlogget</string>
|
||||
<string name="stores_signout_go">Logg ut</string>
|
||||
<string name="stores_search_catalog">Søk i %1$s-katalogen…</string>
|
||||
<string name="stores_search_installed">Søk i installerte spill…</string>
|
||||
<string name="stores_search_library">Søk i biblioteket ditt…</string>
|
||||
<string name="stores_search_open">Søk</string>
|
||||
<string name="stores_installed_empty">Ingenting installert</string>
|
||||
<string name="stores_library_loading">Laster</string>
|
||||
<string name="stores_nothing_matches">Ingen spill</string>
|
||||
<string name="stores_shelves_loading">Laster butikken…</string>
|
||||
@@ -1957,7 +1957,19 @@
|
||||
<string name="stores_view_on">Vis på %1$s</string>
|
||||
<string name="stores_buy_price">Kjøp · %1$s</string>
|
||||
<string name="stores_installed_chip">Installert</string>
|
||||
<string name="stores_section_installed">Installert</string>
|
||||
<string name="stores_section_not_installed">Ikke installert</string>
|
||||
<string name="stores_in_library">I biblioteket</string>
|
||||
<string name="stores_itch_key">API-nøkkel</string>
|
||||
<string name="stores_itch_get_key">Hent en nøkkel</string>
|
||||
<string name="stores_itch_key_rejected">Nøkkelen ble avvist</string>
|
||||
<string name="stores_itch_not_owned">Ikke i itch.io-biblioteket ditt</string>
|
||||
<string name="stores_itch_no_upload">Ingen Windows-nedlasting for dette spillet</string>
|
||||
<string name="stores_itch_no_exe">Fant ikke noe Windows-spill i nedlastingen</string>
|
||||
<string name="stores_itch_extract_failed">Kunne ikke pakke ut nedlastingen (kode %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io svarte ikke</string>
|
||||
<string name="stores_itch_archive">%1$s-arkiver støttes ikke</string>
|
||||
<string name="stores_itch_download_failed">itch.io svarte HTTP %1$d</string>
|
||||
<string name="stores_free">Gratis</string>
|
||||
<string name="stores_resume">Gjenoppta</string>
|
||||
<string name="stores_refresh">Oppdater</string>
|
||||
|
||||
@@ -729,6 +729,9 @@
|
||||
<string name="setup_app_scale_default">%1$d%% (Standaard)</string>
|
||||
<string name="setup_theme">Thema</string>
|
||||
<string name="setup_theme_hint">Uiterlijk van de launcher</string>
|
||||
<string name="setup_launcher_icon">App-pictogram</string>
|
||||
<string name="setup_launcher_icon_dark">Donker</string>
|
||||
<string name="setup_launcher_icon_light">Licht</string>
|
||||
<string name="setup_home">Als startscherm gebruiken</string>
|
||||
<string name="setup_home_on">DroidDeck kan de startschermapp zijn</string>
|
||||
<string name="setup_home_off">DroidDeck wordt nooit aangeboden als startschermapp</string>
|
||||
@@ -1923,7 +1926,6 @@
|
||||
<string name="stores_settings_eyebrow">Instellingen</string>
|
||||
<string name="stores_chip_signed_out">(niet aangemeld)</string>
|
||||
<string name="stores_tab_store">Winkel</string>
|
||||
<string name="stores_tab_installed">Geïnstalleerd</string>
|
||||
<string name="stores_tab_library">Bibliotheek</string>
|
||||
<string name="stores_tab_all">Alle</string>
|
||||
<string name="stores_tab_downloads">Downloads</string>
|
||||
@@ -1935,10 +1937,8 @@
|
||||
<string name="stores_signout_stay">Aangemeld blijven</string>
|
||||
<string name="stores_signout_go">Afmelden</string>
|
||||
<string name="stores_search_catalog">Zoeken in de catalogus van %1$s…</string>
|
||||
<string name="stores_search_installed">Geïnstalleerde games zoeken…</string>
|
||||
<string name="stores_search_library">Zoeken in je bibliotheek…</string>
|
||||
<string name="stores_search_open">Zoeken</string>
|
||||
<string name="stores_installed_empty">Niets geïnstalleerd</string>
|
||||
<string name="stores_library_loading">Laden</string>
|
||||
<string name="stores_nothing_matches">Geen games</string>
|
||||
<string name="stores_shelves_loading">Winkel laden…</string>
|
||||
@@ -1957,7 +1957,19 @@
|
||||
<string name="stores_view_on">Bekijken op %1$s</string>
|
||||
<string name="stores_buy_price">Kopen · %1$s</string>
|
||||
<string name="stores_installed_chip">Geïnstalleerd</string>
|
||||
<string name="stores_section_installed">Geïnstalleerd</string>
|
||||
<string name="stores_section_not_installed">Niet geïnstalleerd</string>
|
||||
<string name="stores_in_library">In bibliotheek</string>
|
||||
<string name="stores_itch_key">API-sleutel</string>
|
||||
<string name="stores_itch_get_key">Sleutel ophalen</string>
|
||||
<string name="stores_itch_key_rejected">Sleutel geweigerd</string>
|
||||
<string name="stores_itch_not_owned">Niet in je itch.io-bibliotheek</string>
|
||||
<string name="stores_itch_no_upload">Geen Windows-download voor dit spel</string>
|
||||
<string name="stores_itch_no_exe">Geen Windows-game gevonden in de download</string>
|
||||
<string name="stores_itch_extract_failed">Download kan niet worden uitgepakt (code %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io gaf geen antwoord</string>
|
||||
<string name="stores_itch_archive">%1$s-archieven worden niet ondersteund</string>
|
||||
<string name="stores_itch_download_failed">itch.io antwoordde HTTP %1$d</string>
|
||||
<string name="stores_free">Gratis</string>
|
||||
<string name="stores_resume">Hervatten</string>
|
||||
<string name="stores_refresh">Vernieuwen</string>
|
||||
|
||||
@@ -735,6 +735,9 @@
|
||||
<string name="setup_app_scale_default">%1$d%% (domyślnie)</string>
|
||||
<string name="setup_theme">Motyw</string>
|
||||
<string name="setup_theme_hint">Wygląd aplikacji</string>
|
||||
<string name="setup_launcher_icon">Ikona aplikacji</string>
|
||||
<string name="setup_launcher_icon_dark">Ciemna</string>
|
||||
<string name="setup_launcher_icon_light">Jasna</string>
|
||||
<string name="setup_home">Użyj jako ekranu głównego</string>
|
||||
<string name="setup_home_on">DroidDeck może być aplikacją ekranu głównego</string>
|
||||
<string name="setup_home_off">DroidDeck nigdy nie jest proponowany jako aplikacja ekranu głównego</string>
|
||||
@@ -1965,7 +1968,6 @@
|
||||
<string name="stores_settings_eyebrow">Ustawienia</string>
|
||||
<string name="stores_chip_signed_out">(niezalogowano)</string>
|
||||
<string name="stores_tab_store">Sklep</string>
|
||||
<string name="stores_tab_installed">Zainstalowane</string>
|
||||
<string name="stores_tab_library">Biblioteka</string>
|
||||
<string name="stores_tab_all">Wszystkie</string>
|
||||
<string name="stores_tab_downloads">Pobierania</string>
|
||||
@@ -1977,10 +1979,8 @@
|
||||
<string name="stores_signout_stay">Zostań zalogowany</string>
|
||||
<string name="stores_signout_go">Wyloguj się</string>
|
||||
<string name="stores_search_catalog">Szukaj w katalogu %1$s…</string>
|
||||
<string name="stores_search_installed">Szukaj zainstalowanych gier…</string>
|
||||
<string name="stores_search_library">Szukaj w Twojej bibliotece…</string>
|
||||
<string name="stores_search_open">Szukaj</string>
|
||||
<string name="stores_installed_empty">Nic nie zainstalowano</string>
|
||||
<string name="stores_library_loading">Wczytywanie</string>
|
||||
<string name="stores_nothing_matches">Brak gier</string>
|
||||
<string name="stores_shelves_loading">Wczytywanie sklepu…</string>
|
||||
@@ -1999,7 +1999,19 @@
|
||||
<string name="stores_view_on">Zobacz w %1$s</string>
|
||||
<string name="stores_buy_price">Kup · %1$s</string>
|
||||
<string name="stores_installed_chip">Zainstalowano</string>
|
||||
<string name="stores_section_installed">Zainstalowano</string>
|
||||
<string name="stores_section_not_installed">Nie zainstalowano</string>
|
||||
<string name="stores_in_library">W bibliotece</string>
|
||||
<string name="stores_itch_key">Klucz API</string>
|
||||
<string name="stores_itch_get_key">Pobierz klucz</string>
|
||||
<string name="stores_itch_key_rejected">Klucz odrzucony</string>
|
||||
<string name="stores_itch_not_owned">Nie ma go w Twojej bibliotece itch.io</string>
|
||||
<string name="stores_itch_no_upload">Brak wersji na Windows dla tej gry</string>
|
||||
<string name="stores_itch_no_exe">W pobranych plikach nie znaleziono gry na Windows</string>
|
||||
<string name="stores_itch_extract_failed">Nie można rozpakować pobranego pliku (kod %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io nie odpowiedziało</string>
|
||||
<string name="stores_itch_archive">Archiwa %1$s nie są obsługiwane</string>
|
||||
<string name="stores_itch_download_failed">itch.io odpowiedziało HTTP %1$d</string>
|
||||
<string name="stores_free">Bezpłatna</string>
|
||||
<string name="stores_resume">Wznów</string>
|
||||
<string name="stores_refresh">Odśwież</string>
|
||||
|
||||
@@ -721,6 +721,9 @@
|
||||
<string name="setup_app_scale_default">%1$d%% (padrão)</string>
|
||||
<string name="setup_theme">Tema</string>
|
||||
<string name="setup_theme_hint">Aspeto do iniciador</string>
|
||||
<string name="setup_launcher_icon">Ícone da app</string>
|
||||
<string name="setup_launcher_icon_dark">Escuro</string>
|
||||
<string name="setup_launcher_icon_light">Claro</string>
|
||||
<string name="setup_home">Utilizar como página inicial</string>
|
||||
<string name="setup_home_on">O DroidDeck pode ser a app de página inicial</string>
|
||||
<string name="setup_home_off">O DroidDeck nunca é proposto como app de página inicial</string>
|
||||
@@ -1092,10 +1095,10 @@
|
||||
<string name="second_terminal_not_ready">A sessão Linux ainda não está pronta para um terminal.</string>
|
||||
<string name="rail_steam">Steam</string>
|
||||
<string name="rail_games">Jogos</string>
|
||||
<string name="rail_desktop">Desktop</string>
|
||||
<string name="rail_desktop">Ambiente de trabalho</string>
|
||||
<string name="rail_store">Loja</string>
|
||||
<string name="rail_components">Componentes</string>
|
||||
<string name="rail_apps">Apps</string>
|
||||
<string name="rail_apps">Aplicações</string>
|
||||
<string name="rail_setup">Configuração</string>
|
||||
<string name="rail_updates">Atualizações</string>
|
||||
<string name="rail_percent">%1$d%%</string>
|
||||
@@ -1933,7 +1936,6 @@
|
||||
<string name="stores_settings_eyebrow">Definições</string>
|
||||
<string name="stores_chip_signed_out">(sessão não iniciada)</string>
|
||||
<string name="stores_tab_store">Loja</string>
|
||||
<string name="stores_tab_installed">Instalados</string>
|
||||
<string name="stores_tab_library">Biblioteca</string>
|
||||
<string name="stores_tab_all">Todos</string>
|
||||
<string name="stores_tab_downloads">Transferências</string>
|
||||
@@ -1945,10 +1947,8 @@
|
||||
<string name="stores_signout_stay">Manter sessão iniciada</string>
|
||||
<string name="stores_signout_go">Terminar sessão</string>
|
||||
<string name="stores_search_catalog">Pesquisar no catálogo %1$s…</string>
|
||||
<string name="stores_search_installed">Pesquisar jogos instalados…</string>
|
||||
<string name="stores_search_library">Pesquisar na sua biblioteca…</string>
|
||||
<string name="stores_search_open">Pesquisar</string>
|
||||
<string name="stores_installed_empty">Nada instalado</string>
|
||||
<string name="stores_library_loading">A carregar</string>
|
||||
<string name="stores_nothing_matches">Nenhum jogo</string>
|
||||
<string name="stores_shelves_loading">A carregar a loja…</string>
|
||||
@@ -1967,7 +1967,19 @@
|
||||
<string name="stores_view_on">Ver em %1$s</string>
|
||||
<string name="stores_buy_price">Comprar · %1$s</string>
|
||||
<string name="stores_installed_chip">Instalado</string>
|
||||
<string name="stores_section_installed">Instalado</string>
|
||||
<string name="stores_section_not_installed">Não instalado</string>
|
||||
<string name="stores_in_library">Na biblioteca</string>
|
||||
<string name="stores_itch_key">Chave de API</string>
|
||||
<string name="stores_itch_get_key">Obter uma chave</string>
|
||||
<string name="stores_itch_key_rejected">Chave rejeitada</string>
|
||||
<string name="stores_itch_not_owned">Não está na sua biblioteca do itch.io</string>
|
||||
<string name="stores_itch_no_upload">Sem transferência para Windows deste jogo</string>
|
||||
<string name="stores_itch_no_exe">Não foi encontrado nenhum jogo para Windows na transferência</string>
|
||||
<string name="stores_itch_extract_failed">Não foi possível extrair a transferência (código %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">O itch.io não respondeu</string>
|
||||
<string name="stores_itch_archive">Ficheiros %1$s não são suportados</string>
|
||||
<string name="stores_itch_download_failed">O itch.io respondeu HTTP %1$d</string>
|
||||
<string name="stores_free">Grátis</string>
|
||||
<string name="stores_resume">Retomar</string>
|
||||
<string name="stores_refresh">Atualizar</string>
|
||||
@@ -2242,7 +2254,7 @@
|
||||
<string name="setup_tab_diagnostics">Diagnóstico</string>
|
||||
<string name="setup_ready">Pronto para jogar</string>
|
||||
<string name="setup_not_ready">Ainda não está pronto para jogar</string>
|
||||
<string name="setup_ready_runtime">runtime %1$s</string>
|
||||
<string name="setup_ready_runtime">ambiente de execução %1$s</string>
|
||||
<string name="setup_ready_limit_off">limite de processos desativado</string>
|
||||
<string name="setup_ready_signed_in">sessão iniciada como %1$s</string>
|
||||
<string name="setup_needs_you">Quando algo precisa de si</string>
|
||||
@@ -2613,32 +2625,39 @@
|
||||
<string name="storage_play_anyway">Jogar mesmo assim</string>
|
||||
<plurals name="storage_games_installed">
|
||||
<item quantity="one">%d instalado</item>
|
||||
<item quantity="many">%d instalados</item>
|
||||
<item quantity="other">%d instalados</item>
|
||||
</plurals>
|
||||
<plurals name="storage_games_on_sd">
|
||||
<item quantity="one">%d no cartão SD</item>
|
||||
<item quantity="many">%d no cartão SD</item>
|
||||
<item quantity="other">%d no cartão SD</item>
|
||||
</plurals>
|
||||
<plurals name="storage_tools_count">
|
||||
<item quantity="one">%d ferramenta</item>
|
||||
<item quantity="many">%d ferramentas</item>
|
||||
<item quantity="other">%d ferramentas</item>
|
||||
</plurals>
|
||||
<plurals name="storage_item_leftovers_detail">
|
||||
<item quantity="one">Pastas de prefixo vazias e cache de shaders de %d jogo</item>
|
||||
<item quantity="many">Pastas de prefixo vazias e cache de shaders de %d jogos</item>
|
||||
<item quantity="other">Pastas de prefixo vazias e cache de shaders de %d jogos</item>
|
||||
</plurals>
|
||||
<plurals name="storage_item_leftovers_unsure_detail">
|
||||
<item quantity="one">O prefixo de %d jogo ainda tem ficheiros, que podem incluir jogos guardados locais. Se precisar deles, faça uma cópia antes.</item>
|
||||
<item quantity="many">Os prefixos de %d jogos ainda têm ficheiros, que podem incluir jogos guardados locais. Se precisar deles, faça uma cópia antes.</item>
|
||||
<item quantity="other">Os prefixos de %d jogos ainda têm ficheiros, que podem incluir jogos guardados locais. Se precisar deles, faça uma cópia antes.</item>
|
||||
</plurals>
|
||||
<plurals name="storage_tag_games">
|
||||
<item quantity="one">%d jogo</item>
|
||||
<item quantity="many">%d jogos</item>
|
||||
<item quantity="other">%d jogos</item>
|
||||
</plurals>
|
||||
<string name="storage_tool_default_proton">Proton predefinido do DroidDeck</string>
|
||||
<string name="storage_tool_proton">Opção Proton 11 do DroidDeck</string>
|
||||
<plurals name="storage_tool_custom_games">
|
||||
<item quantity="one">Usado por %d jogo</item>
|
||||
<item quantity="many">Usado por %d jogos</item>
|
||||
<item quantity="other">Usado por %d jogos</item>
|
||||
</plurals>
|
||||
<string name="storage_legend_components">Componentes</string>
|
||||
@@ -2682,10 +2701,12 @@
|
||||
<string name="storage_flatpak_uninstall_body">Remove a aplicação (%1$s) e os ambientes de execução que mais ninguém usa. Os dados dela na pasta pessoal mantêm-se.</string>
|
||||
<plurals name="storage_components_count">
|
||||
<item quantity="one">%d pacote</item>
|
||||
<item quantity="many">%d pacotes</item>
|
||||
<item quantity="other">%d pacotes</item>
|
||||
</plurals>
|
||||
<plurals name="storage_desktop_apps">
|
||||
<item quantity="one">%d aplicação</item>
|
||||
<item quantity="many">%d aplicações</item>
|
||||
<item quantity="other">%d aplicações</item>
|
||||
</plurals>
|
||||
<string name="boot_movie">Vídeo de arranque</string>
|
||||
|
||||
@@ -721,6 +721,9 @@
|
||||
<string name="setup_app_scale_default">%1$d%% (padrão)</string>
|
||||
<string name="setup_theme">Tema</string>
|
||||
<string name="setup_theme_hint">Aparência do inicializador</string>
|
||||
<string name="setup_launcher_icon">Ícone do app</string>
|
||||
<string name="setup_launcher_icon_dark">Escuro</string>
|
||||
<string name="setup_launcher_icon_light">Claro</string>
|
||||
<string name="setup_home">Usar como tela inicial</string>
|
||||
<string name="setup_home_on">O DroidDeck pode ser o app de início</string>
|
||||
<string name="setup_home_off">O DroidDeck nunca é oferecido como app de início</string>
|
||||
@@ -1092,7 +1095,7 @@
|
||||
<string name="second_terminal_not_ready">A sessão Linux ainda não está pronta para um terminal.</string>
|
||||
<string name="rail_steam">Steam</string>
|
||||
<string name="rail_games">Jogos</string>
|
||||
<string name="rail_desktop">Desktop</string>
|
||||
<string name="rail_desktop">Área de trabalho</string>
|
||||
<string name="rail_store">Loja</string>
|
||||
<string name="rail_components">Componentes</string>
|
||||
<string name="rail_apps">Apps</string>
|
||||
@@ -1933,7 +1936,6 @@
|
||||
<string name="stores_settings_eyebrow">Configurações</string>
|
||||
<string name="stores_chip_signed_out">(sem login)</string>
|
||||
<string name="stores_tab_store">Loja</string>
|
||||
<string name="stores_tab_installed">Instalados</string>
|
||||
<string name="stores_tab_library">Biblioteca</string>
|
||||
<string name="stores_tab_all">Todos</string>
|
||||
<string name="stores_tab_downloads">Downloads</string>
|
||||
@@ -1945,10 +1947,8 @@
|
||||
<string name="stores_signout_stay">Continuar conectado</string>
|
||||
<string name="stores_signout_go">Sair</string>
|
||||
<string name="stores_search_catalog">Pesquisar no catálogo de %1$s…</string>
|
||||
<string name="stores_search_installed">Pesquisar jogos instalados…</string>
|
||||
<string name="stores_search_library">Pesquisar na sua biblioteca…</string>
|
||||
<string name="stores_search_open">Pesquisar</string>
|
||||
<string name="stores_installed_empty">Nada instalado</string>
|
||||
<string name="stores_library_loading">Carregando</string>
|
||||
<string name="stores_nothing_matches">Nenhum jogo</string>
|
||||
<string name="stores_shelves_loading">Carregando a loja…</string>
|
||||
@@ -1967,7 +1967,19 @@
|
||||
<string name="stores_view_on">Ver em %1$s</string>
|
||||
<string name="stores_buy_price">Comprar · %1$s</string>
|
||||
<string name="stores_installed_chip">Instalado</string>
|
||||
<string name="stores_section_installed">Instalado</string>
|
||||
<string name="stores_section_not_installed">Não instalado</string>
|
||||
<string name="stores_in_library">Na biblioteca</string>
|
||||
<string name="stores_itch_key">Chave de API</string>
|
||||
<string name="stores_itch_get_key">Obter uma chave</string>
|
||||
<string name="stores_itch_key_rejected">Chave rejeitada</string>
|
||||
<string name="stores_itch_not_owned">Não está na sua biblioteca do itch.io</string>
|
||||
<string name="stores_itch_no_upload">Sem download para Windows deste jogo</string>
|
||||
<string name="stores_itch_no_exe">Nenhum jogo para Windows encontrado no download</string>
|
||||
<string name="stores_itch_extract_failed">Não foi possível extrair o download (código %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">O itch.io não respondeu</string>
|
||||
<string name="stores_itch_archive">Arquivos %1$s não são suportados</string>
|
||||
<string name="stores_itch_download_failed">O itch.io respondeu HTTP %1$d</string>
|
||||
<string name="stores_free">Grátis</string>
|
||||
<string name="stores_resume">Retomar</string>
|
||||
<string name="stores_refresh">Atualizar</string>
|
||||
@@ -2613,32 +2625,39 @@
|
||||
<string name="storage_play_anyway">Jogar assim mesmo</string>
|
||||
<plurals name="storage_games_installed">
|
||||
<item quantity="one">%d instalado</item>
|
||||
<item quantity="many">%d instalados</item>
|
||||
<item quantity="other">%d instalados</item>
|
||||
</plurals>
|
||||
<plurals name="storage_games_on_sd">
|
||||
<item quantity="one">%d no cartão SD</item>
|
||||
<item quantity="many">%d no cartão SD</item>
|
||||
<item quantity="other">%d no cartão SD</item>
|
||||
</plurals>
|
||||
<plurals name="storage_tools_count">
|
||||
<item quantity="one">%d ferramenta</item>
|
||||
<item quantity="many">%d ferramentas</item>
|
||||
<item quantity="other">%d ferramentas</item>
|
||||
</plurals>
|
||||
<plurals name="storage_item_leftovers_detail">
|
||||
<item quantity="one">Pastas de prefixo vazias e cache de shaders de %d jogo</item>
|
||||
<item quantity="many">Pastas de prefixo vazias e cache de shaders de %d jogos</item>
|
||||
<item quantity="other">Pastas de prefixo vazias e cache de shaders de %d jogos</item>
|
||||
</plurals>
|
||||
<plurals name="storage_item_leftovers_unsure_detail">
|
||||
<item quantity="one">O prefixo de %d jogo ainda tem arquivos, que podem incluir saves locais. Se precisar deles, faça uma cópia antes.</item>
|
||||
<item quantity="many">Os prefixos de %d jogos ainda têm arquivos, que podem incluir saves locais. Se precisar deles, faça uma cópia antes.</item>
|
||||
<item quantity="other">Os prefixos de %d jogos ainda têm arquivos, que podem incluir saves locais. Se precisar deles, faça uma cópia antes.</item>
|
||||
</plurals>
|
||||
<plurals name="storage_tag_games">
|
||||
<item quantity="one">%d jogo</item>
|
||||
<item quantity="many">%d jogos</item>
|
||||
<item quantity="other">%d jogos</item>
|
||||
</plurals>
|
||||
<string name="storage_tool_default_proton">Proton padrão do DroidDeck</string>
|
||||
<string name="storage_tool_proton">Opção Proton 11 do DroidDeck</string>
|
||||
<plurals name="storage_tool_custom_games">
|
||||
<item quantity="one">Usado por %d jogo</item>
|
||||
<item quantity="many">Usado por %d jogos</item>
|
||||
<item quantity="other">Usado por %d jogos</item>
|
||||
</plurals>
|
||||
<string name="storage_legend_components">Componentes</string>
|
||||
@@ -2682,10 +2701,12 @@
|
||||
<string name="storage_flatpak_uninstall_body">Remove o app (%1$s) e os runtimes que mais ninguém usa. Os dados dele na pasta pessoal ficam.</string>
|
||||
<plurals name="storage_components_count">
|
||||
<item quantity="one">%d pacote</item>
|
||||
<item quantity="many">%d pacotes</item>
|
||||
<item quantity="other">%d pacotes</item>
|
||||
</plurals>
|
||||
<plurals name="storage_desktop_apps">
|
||||
<item quantity="one">%d app</item>
|
||||
<item quantity="many">%d apps</item>
|
||||
<item quantity="other">%d apps</item>
|
||||
</plurals>
|
||||
<string name="boot_movie">Vídeo de inicialização</string>
|
||||
|
||||
@@ -732,6 +732,9 @@
|
||||
<string name="setup_app_scale_default">%1$d%% (Implicit)</string>
|
||||
<string name="setup_theme">Temă</string>
|
||||
<string name="setup_theme_hint">Aspectul launcherului</string>
|
||||
<string name="setup_launcher_icon">Pictograma aplicației</string>
|
||||
<string name="setup_launcher_icon_dark">Întunecată</string>
|
||||
<string name="setup_launcher_icon_light">Luminoasă</string>
|
||||
<string name="setup_home">Folosește ca ecran Acasă</string>
|
||||
<string name="setup_home_on">DroidDeck poate fi aplicația Acasă</string>
|
||||
<string name="setup_home_off">DroidDeck nu este niciodată oferit ca aplicație Acasă</string>
|
||||
@@ -1442,8 +1445,8 @@
|
||||
<string name="fm_pin_current">Fixează folderul curent</string>
|
||||
<string name="fm_no_favorites">Niciun favorit încă - fixează un folder din meniul său ⋮ pentru a reveni rapid aici.</string>
|
||||
<string name="fm_unpin_favorite">Elimină din favorite</string>
|
||||
<string name="fm_size_zero">0 bytes</string>
|
||||
<string name="fm_unit_bytes">bytes</string>
|
||||
<string name="fm_size_zero">0 octeți</string>
|
||||
<string name="fm_unit_bytes">octeți</string>
|
||||
<string name="fm_prop_location">Locație</string>
|
||||
<string name="fm_prop_type">Tip</string>
|
||||
<string name="fm_prop_size">Dimensiune</string>
|
||||
@@ -1562,7 +1565,7 @@
|
||||
<string name="ctrl_tint_white">Alb</string>
|
||||
<string name="ctrl_tint_violet">Violet</string>
|
||||
<string name="ctrl_tint_green">Verde</string>
|
||||
<string name="ctrl_tint_amber">Amber</string>
|
||||
<string name="ctrl_tint_amber">Chihlimbariu</string>
|
||||
<string name="ctrl_tint_red">Roșu</string>
|
||||
<string name="ctrl_tint_pink">Roz</string>
|
||||
<string name="ctrl_button_a">Butonul A</string>
|
||||
@@ -1798,7 +1801,7 @@
|
||||
<string name="look_game_clarity_desc">Claritate cu puțin contrast - cea de zi cu zi.</string>
|
||||
<string name="look_vivid">Viu</string>
|
||||
<string name="look_vivid_desc">Culoare intensificată puternic. Eliminarea benzilor activată, ca cerurile să nu aibă dungi.</string>
|
||||
<string name="look_cinematic">Cinematic</string>
|
||||
<string name="look_cinematic">Cinematografic</string>
|
||||
<string name="look_cinematic_desc">Contrast mai profund, culoare mai rece, margini netezite.</string>
|
||||
<string name="look_competitive">Competitiv</string>
|
||||
<string name="look_competitive_desc">Umbre ridicate și claritate puternică - observă persoanele mai întâi.</string>
|
||||
@@ -1944,7 +1947,6 @@
|
||||
<string name="stores_settings_eyebrow">Setări</string>
|
||||
<string name="stores_chip_signed_out">(neautentificat)</string>
|
||||
<string name="stores_tab_store">Magazin</string>
|
||||
<string name="stores_tab_installed">Instalate</string>
|
||||
<string name="stores_tab_library">Bibliotecă</string>
|
||||
<string name="stores_tab_all">Toate</string>
|
||||
<string name="stores_tab_downloads">Descărcări</string>
|
||||
@@ -1956,10 +1958,8 @@
|
||||
<string name="stores_signout_stay">Rămâi autentificat</string>
|
||||
<string name="stores_signout_go">Deconectare</string>
|
||||
<string name="stores_search_catalog">Caută în catalogul %1$s…</string>
|
||||
<string name="stores_search_installed">Caută jocurile instalate…</string>
|
||||
<string name="stores_search_library">Caută în biblioteca ta…</string>
|
||||
<string name="stores_search_open">Căutare</string>
|
||||
<string name="stores_installed_empty">Nimic instalat</string>
|
||||
<string name="stores_library_loading">Se încarcă</string>
|
||||
<string name="stores_nothing_matches">Niciun joc</string>
|
||||
<string name="stores_shelves_loading">Se încarcă magazinul…</string>
|
||||
@@ -1978,7 +1978,19 @@
|
||||
<string name="stores_view_on">Vezi pe %1$s</string>
|
||||
<string name="stores_buy_price">Cumpără · %1$s</string>
|
||||
<string name="stores_installed_chip">Instalat</string>
|
||||
<string name="stores_section_installed">Instalat</string>
|
||||
<string name="stores_section_not_installed">Neinstalat</string>
|
||||
<string name="stores_in_library">În bibliotecă</string>
|
||||
<string name="stores_itch_key">Cheie API</string>
|
||||
<string name="stores_itch_get_key">Obține o cheie</string>
|
||||
<string name="stores_itch_key_rejected">Cheie respinsă</string>
|
||||
<string name="stores_itch_not_owned">Nu este în biblioteca ta itch.io</string>
|
||||
<string name="stores_itch_no_upload">Nu există descărcare pentru Windows la acest joc</string>
|
||||
<string name="stores_itch_no_exe">Nu s-a găsit niciun joc Windows în descărcare</string>
|
||||
<string name="stores_itch_extract_failed">Descărcarea nu a putut fi extrasă (cod %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io nu a răspuns</string>
|
||||
<string name="stores_itch_archive">Arhivele %1$s nu sunt acceptate</string>
|
||||
<string name="stores_itch_download_failed">itch.io a răspuns HTTP %1$d</string>
|
||||
<string name="stores_free">Gratuit</string>
|
||||
<string name="stores_resume">Reluare</string>
|
||||
<string name="stores_refresh">Reîmprospătează</string>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<string name="frame_gen_off">Выкл.</string>
|
||||
<string name="frame_gen_winfg">Win-FG %1$d×</string>
|
||||
<string name="frame_gen_lsfg">LSFG %1$d×</string>
|
||||
<string name="frame_gen_lsfg_adaptive">LSFG Adaptive %1$d</string>
|
||||
<string name="frame_gen_lsfg_adaptive">LSFG Адаптивный %1$d</string>
|
||||
<string name="lsfg_needs_steam">Требуется Lossless Scaling из Steam</string>
|
||||
<string name="lsfg_needs_install">Установите Lossless Scaling в Steam или импортируйте Lossless.dll</string>
|
||||
<string name="lsfg_import">Импорт Lossless.dll…</string>
|
||||
@@ -732,6 +732,9 @@
|
||||
<string name="setup_app_scale_default">%1$d%% (по умолчанию)</string>
|
||||
<string name="setup_theme">Тема</string>
|
||||
<string name="setup_theme_hint">Внешний вид лаунчера</string>
|
||||
<string name="setup_launcher_icon">Значок приложения</string>
|
||||
<string name="setup_launcher_icon_dark">Тёмный</string>
|
||||
<string name="setup_launcher_icon_light">Светлый</string>
|
||||
<string name="setup_home">Использовать как главный экран</string>
|
||||
<string name="setup_home_on">DroidDeck может использоваться как приложение главного экрана</string>
|
||||
<string name="setup_home_off">DroidDeck не будет доступен как приложение главного экрана</string>
|
||||
@@ -1592,7 +1595,7 @@
|
||||
<string name="ctrl_button_rt">Правый триггер</string>
|
||||
<string name="ctrl_button_view">Кнопка View</string>
|
||||
<string name="ctrl_button_menu">Кнопка «Меню»</string>
|
||||
<string name="ctrl_target_view">View</string>
|
||||
<string name="ctrl_target_view">Вид</string>
|
||||
<string name="ctrl_target_menu">Меню</string>
|
||||
<string name="ctrl_target_up">Крестовина вверх</string>
|
||||
<string name="ctrl_target_down">Крестовина вниз</string>
|
||||
@@ -1962,7 +1965,6 @@
|
||||
<string name="stores_settings_eyebrow">Настройки</string>
|
||||
<string name="stores_chip_signed_out">(вход не выполнен)</string>
|
||||
<string name="stores_tab_store">Магазин</string>
|
||||
<string name="stores_tab_installed">Установлено</string>
|
||||
<string name="stores_tab_library">Библиотека</string>
|
||||
<string name="stores_tab_all">Все</string>
|
||||
<string name="stores_tab_downloads">Загрузки</string>
|
||||
@@ -1974,10 +1976,8 @@
|
||||
<string name="stores_signout_stay">Не выходить</string>
|
||||
<string name="stores_signout_go">Выйти</string>
|
||||
<string name="stores_search_catalog">Поиск в каталоге %1$s…</string>
|
||||
<string name="stores_search_installed">Поиск установленных игр…</string>
|
||||
<string name="stores_search_library">Поиск в библиотеке…</string>
|
||||
<string name="stores_search_open">Поиск</string>
|
||||
<string name="stores_installed_empty">Ничего не установлено</string>
|
||||
<string name="stores_library_loading">Загрузка</string>
|
||||
<string name="stores_nothing_matches">Нет игр</string>
|
||||
<string name="stores_shelves_loading">Загрузка магазина…</string>
|
||||
@@ -1996,7 +1996,19 @@
|
||||
<string name="stores_view_on">Открыть в %1$s</string>
|
||||
<string name="stores_buy_price">Купить · %1$s</string>
|
||||
<string name="stores_installed_chip">Установлено</string>
|
||||
<string name="stores_section_installed">Установлено</string>
|
||||
<string name="stores_section_not_installed">Не установлено</string>
|
||||
<string name="stores_in_library">В библиотеке</string>
|
||||
<string name="stores_itch_key">Ключ API</string>
|
||||
<string name="stores_itch_get_key">Получить ключ</string>
|
||||
<string name="stores_itch_key_rejected">Ключ отклонён</string>
|
||||
<string name="stores_itch_not_owned">Нет в вашей библиотеке itch.io</string>
|
||||
<string name="stores_itch_no_upload">Для этой игры нет загрузки для Windows</string>
|
||||
<string name="stores_itch_no_exe">В загрузке не найдена игра для Windows</string>
|
||||
<string name="stores_itch_extract_failed">Не удалось распаковать загрузку (код %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io не ответил</string>
|
||||
<string name="stores_itch_archive">Архивы %1$s не поддерживаются</string>
|
||||
<string name="stores_itch_download_failed">itch.io ответил HTTP %1$d</string>
|
||||
<string name="stores_free">Бесплатно</string>
|
||||
<string name="stores_resume">Продолжить</string>
|
||||
<string name="stores_refresh">Обновить</string>
|
||||
|
||||
@@ -729,6 +729,9 @@
|
||||
<string name="setup_app_scale_default">%1$d %% (Standard)</string>
|
||||
<string name="setup_theme">Tema</string>
|
||||
<string name="setup_theme_hint">Startprogrammets utseende</string>
|
||||
<string name="setup_launcher_icon">Appikon</string>
|
||||
<string name="setup_launcher_icon_dark">Mörk</string>
|
||||
<string name="setup_launcher_icon_light">Ljus</string>
|
||||
<string name="setup_home">Använd som startskärm</string>
|
||||
<string name="setup_home_on">DroidDeck kan vara startskärmsappen</string>
|
||||
<string name="setup_home_off">DroidDeck erbjuds aldrig som startskärmsapp</string>
|
||||
@@ -1923,7 +1926,6 @@
|
||||
<string name="stores_settings_eyebrow">Inställningar</string>
|
||||
<string name="stores_chip_signed_out">(inte inloggad)</string>
|
||||
<string name="stores_tab_store">Butik</string>
|
||||
<string name="stores_tab_installed">Installerade</string>
|
||||
<string name="stores_tab_library">Bibliotek</string>
|
||||
<string name="stores_tab_all">Alla</string>
|
||||
<string name="stores_tab_downloads">Nedladdningar</string>
|
||||
@@ -1935,10 +1937,8 @@
|
||||
<string name="stores_signout_stay">Fortsätt vara inloggad</string>
|
||||
<string name="stores_signout_go">Logga ut</string>
|
||||
<string name="stores_search_catalog">Sök i %1$s-katalogen…</string>
|
||||
<string name="stores_search_installed">Sök installerade spel…</string>
|
||||
<string name="stores_search_library">Sök i ditt bibliotek…</string>
|
||||
<string name="stores_search_open">Sök</string>
|
||||
<string name="stores_installed_empty">Inget installerat</string>
|
||||
<string name="stores_library_loading">Läser in</string>
|
||||
<string name="stores_nothing_matches">Inga spel</string>
|
||||
<string name="stores_shelves_loading">Läser in butiken…</string>
|
||||
@@ -1957,7 +1957,19 @@
|
||||
<string name="stores_view_on">Visa på %1$s</string>
|
||||
<string name="stores_buy_price">Köp · %1$s</string>
|
||||
<string name="stores_installed_chip">Installerat</string>
|
||||
<string name="stores_section_installed">Installerat</string>
|
||||
<string name="stores_section_not_installed">Inte installerad</string>
|
||||
<string name="stores_in_library">I biblioteket</string>
|
||||
<string name="stores_itch_key">API-nyckel</string>
|
||||
<string name="stores_itch_get_key">Hämta en nyckel</string>
|
||||
<string name="stores_itch_key_rejected">Nyckeln avvisades</string>
|
||||
<string name="stores_itch_not_owned">Inte i ditt itch.io-bibliotek</string>
|
||||
<string name="stores_itch_no_upload">Ingen Windows-nedladdning för det här spelet</string>
|
||||
<string name="stores_itch_no_exe">Inget Windows-spel hittades i nedladdningen</string>
|
||||
<string name="stores_itch_extract_failed">Det gick inte att packa upp nedladdningen (kod %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io svarade inte</string>
|
||||
<string name="stores_itch_archive">%1$s-arkiv stöds inte</string>
|
||||
<string name="stores_itch_download_failed">itch.io svarade HTTP %1$d</string>
|
||||
<string name="stores_free">Gratis</string>
|
||||
<string name="stores_resume">Återuppta</string>
|
||||
<string name="stores_refresh">Uppdatera</string>
|
||||
|
||||
@@ -726,6 +726,9 @@
|
||||
<string name="setup_app_scale_default">%1$d%% (ค่าเริ่มต้น)</string>
|
||||
<string name="setup_theme">ธีม</string>
|
||||
<string name="setup_theme_hint">รูปลักษณ์ของตัวเปิดแอป</string>
|
||||
<string name="setup_launcher_icon">ไอคอนแอป</string>
|
||||
<string name="setup_launcher_icon_dark">มืด</string>
|
||||
<string name="setup_launcher_icon_light">สว่าง</string>
|
||||
<string name="setup_home">ใช้เป็นหน้าจอหลัก</string>
|
||||
<string name="setup_home_on">DroidDeck สามารถเป็นแอปหน้าจอหลักได้</string>
|
||||
<string name="setup_home_off">DroidDeck จะไม่ถูกเสนอให้เป็นแอปหน้าจอหลัก</string>
|
||||
@@ -1362,7 +1365,7 @@
|
||||
<string name="store_updates_check_failed">ไม่สามารถตรวจสอบการอัปเดตจาก Flathub ได้</string>
|
||||
<string name="content_rom_installed">ติดตั้งแล้ว</string>
|
||||
<string name="content_rom_folder">โฟลเดอร์</string>
|
||||
<string name="theme_graphite">Graphite</string>
|
||||
<string name="theme_graphite">แกรไฟต์</string>
|
||||
<string name="theme_graphite_detail">พื้นผิวสีเทาแกรไฟต์ พร้อมสีฟ้าของโลโก้สำหรับทุกสิ่งที่ถูกเลือก โฟกัส หรือกด</string>
|
||||
<string name="theme_paper">กระดาษบนพื้นดำ</string>
|
||||
<string name="theme_paper_detail">สีขาวเป็นหลัก สีฟ้าของไอคอนเป็นสัญญาณสำหรับเส้น วงแหวน และจุด</string>
|
||||
@@ -1745,8 +1748,8 @@
|
||||
<string name="cmgr_list_predates_checksums">รายการแพ็กเกจนี้เก่ากว่าระบบค่าตรวจสอบ - รีเฟรชแล้วลองใหม่</string>
|
||||
<string name="cmgr_checksum_mismatch">ค่าตรวจสอบไม่ตรงกัน</string>
|
||||
|
||||
<string name="sprefs_upscaler_linear">Linear</string>
|
||||
<string name="sprefs_upscaler_nearest">Nearest</string>
|
||||
<string name="sprefs_upscaler_linear">เชิงเส้น</string>
|
||||
<string name="sprefs_upscaler_nearest">ใกล้ที่สุด</string>
|
||||
<string name="sprefs_upscaler_gsr_quality">Snapdragon GSR (คุณภาพ)</string>
|
||||
<string name="sprefs_upscaler_sharpen">เพิ่มความคมชัดเท่านั้น</string>
|
||||
<string name="sprefs_lod_bias_auto">อัตโนมัติ (ตามอัตราขยาย)</string>
|
||||
@@ -1902,7 +1905,6 @@
|
||||
<string name="stores_settings_eyebrow">การตั้งค่า</string>
|
||||
<string name="stores_chip_signed_out">(ยังไม่ลงชื่อเข้าใช้)</string>
|
||||
<string name="stores_tab_store">สโตร์</string>
|
||||
<string name="stores_tab_installed">ติดตั้งแล้ว</string>
|
||||
<string name="stores_tab_library">ไลบรารี</string>
|
||||
<string name="stores_tab_all">ทั้งหมด</string>
|
||||
<string name="stores_tab_downloads">การดาวน์โหลด</string>
|
||||
@@ -1914,10 +1916,8 @@
|
||||
<string name="stores_signout_stay">ลงชื่อเข้าใช้ต่อไป</string>
|
||||
<string name="stores_signout_go">ลงชื่อออก</string>
|
||||
<string name="stores_search_catalog">ค้นหาแคตตาล็อกของ %1$s…</string>
|
||||
<string name="stores_search_installed">ค้นหาเกมที่ติดตั้งไว้…</string>
|
||||
<string name="stores_search_library">ค้นหาไลบรารีของคุณ…</string>
|
||||
<string name="stores_search_open">ค้นหา</string>
|
||||
<string name="stores_installed_empty">ไม่มีสิ่งที่ติดตั้งไว้</string>
|
||||
<string name="stores_library_loading">กำลังโหลด</string>
|
||||
<string name="stores_nothing_matches">ไม่มีเกม</string>
|
||||
<string name="stores_shelves_loading">กำลังโหลดสโตร์…</string>
|
||||
@@ -1936,7 +1936,19 @@
|
||||
<string name="stores_view_on">ดูบน %1$s</string>
|
||||
<string name="stores_buy_price">ซื้อ · %1$s</string>
|
||||
<string name="stores_installed_chip">ติดตั้งแล้ว</string>
|
||||
<string name="stores_section_installed">ติดตั้งแล้ว</string>
|
||||
<string name="stores_section_not_installed">ยังไม่ติดตั้ง</string>
|
||||
<string name="stores_in_library">อยู่ในไลบรารี</string>
|
||||
<string name="stores_itch_key">คีย์ API</string>
|
||||
<string name="stores_itch_get_key">รับคีย์</string>
|
||||
<string name="stores_itch_key_rejected">คีย์ถูกปฏิเสธ</string>
|
||||
<string name="stores_itch_not_owned">ไม่มีในคลัง itch.io ของคุณ</string>
|
||||
<string name="stores_itch_no_upload">เกมนี้ไม่มีการดาวน์โหลดสำหรับ Windows</string>
|
||||
<string name="stores_itch_no_exe">ไม่พบเกม Windows ในไฟล์ที่ดาวน์โหลด</string>
|
||||
<string name="stores_itch_extract_failed">แตกไฟล์ที่ดาวน์โหลดไม่ได้ (รหัส %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io ไม่ตอบสนอง</string>
|
||||
<string name="stores_itch_archive">ไม่รองรับไฟล์ %1$s</string>
|
||||
<string name="stores_itch_download_failed">itch.io ตอบกลับ HTTP %1$d</string>
|
||||
<string name="stores_free">ฟรี</string>
|
||||
<string name="stores_resume">ดำเนินการต่อ</string>
|
||||
<string name="stores_refresh">รีเฟรช</string>
|
||||
|
||||
@@ -718,6 +718,9 @@
|
||||
<string name="setup_app_scale_default">%%%1$d (Varsayılan)</string>
|
||||
<string name="setup_theme">Tema</string>
|
||||
<string name="setup_theme_hint">Başlatıcı görünümü</string>
|
||||
<string name="setup_launcher_icon">Uygulama simgesi</string>
|
||||
<string name="setup_launcher_icon_dark">Koyu</string>
|
||||
<string name="setup_launcher_icon_light">Açık</string>
|
||||
<string name="setup_home">Ana ekran olarak kullan</string>
|
||||
<string name="setup_home_on">DroidDeck ana ekran uygulaması olabilir</string>
|
||||
<string name="setup_home_off">DroidDeck hiçbir zaman ana ekran uygulaması olarak sunulmaz</string>
|
||||
@@ -1912,7 +1915,6 @@
|
||||
<string name="stores_settings_eyebrow">Ayarlar</string>
|
||||
<string name="stores_chip_signed_out">(oturum açılmadı)</string>
|
||||
<string name="stores_tab_store">Mağaza</string>
|
||||
<string name="stores_tab_installed">Yüklü</string>
|
||||
<string name="stores_tab_library">Kütüphane</string>
|
||||
<string name="stores_tab_all">Tümü</string>
|
||||
<string name="stores_tab_downloads">İndirmeler</string>
|
||||
@@ -1924,10 +1926,8 @@
|
||||
<string name="stores_signout_stay">Oturumu açık tut</string>
|
||||
<string name="stores_signout_go">Oturumu kapat</string>
|
||||
<string name="stores_search_catalog">%1$s kataloğunda ara…</string>
|
||||
<string name="stores_search_installed">Yüklü oyunlarda ara…</string>
|
||||
<string name="stores_search_library">Kütüphanenizde ara…</string>
|
||||
<string name="stores_search_open">Ara</string>
|
||||
<string name="stores_installed_empty">Yüklü bir şey yok</string>
|
||||
<string name="stores_library_loading">Yükleniyor</string>
|
||||
<string name="stores_nothing_matches">Oyun yok</string>
|
||||
<string name="stores_shelves_loading">Mağaza yükleniyor…</string>
|
||||
@@ -1946,7 +1946,19 @@
|
||||
<string name="stores_view_on">%1$s\'te görüntüle</string>
|
||||
<string name="stores_buy_price">Satın al · %1$s</string>
|
||||
<string name="stores_installed_chip">Yüklü</string>
|
||||
<string name="stores_section_installed">Yüklü</string>
|
||||
<string name="stores_section_not_installed">Yüklü değil</string>
|
||||
<string name="stores_in_library">Kütüphanede</string>
|
||||
<string name="stores_itch_key">API anahtarı</string>
|
||||
<string name="stores_itch_get_key">Anahtar al</string>
|
||||
<string name="stores_itch_key_rejected">Anahtar reddedildi</string>
|
||||
<string name="stores_itch_not_owned">itch.io kütüphanenizde yok</string>
|
||||
<string name="stores_itch_no_upload">Bu oyunun Windows indirmesi yok</string>
|
||||
<string name="stores_itch_no_exe">İndirmede Windows oyunu bulunamadı</string>
|
||||
<string name="stores_itch_extract_failed">İndirme ayıklanamadı (kod %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io yanıt vermedi</string>
|
||||
<string name="stores_itch_archive">%1$s arşivleri desteklenmiyor</string>
|
||||
<string name="stores_itch_download_failed">itch.io HTTP %1$d yanıtı verdi</string>
|
||||
<string name="stores_free">Ücretsiz</string>
|
||||
<string name="stores_resume">Sürdür</string>
|
||||
<string name="stores_refresh">Yenile</string>
|
||||
|
||||
@@ -735,6 +735,9 @@
|
||||
<string name="setup_app_scale_default">%1$d%% (стандартно)</string>
|
||||
<string name="setup_theme">Тема</string>
|
||||
<string name="setup_theme_hint">Вигляд лаунчера</string>
|
||||
<string name="setup_launcher_icon">Значок застосунку</string>
|
||||
<string name="setup_launcher_icon_dark">Темний</string>
|
||||
<string name="setup_launcher_icon_light">Світлий</string>
|
||||
<string name="setup_home">Використовувати як головний екран</string>
|
||||
<string name="setup_home_on">DroidDeck може бути додатком головного екрана</string>
|
||||
<string name="setup_home_off">DroidDeck ніколи не пропонується як додаток головного екрана</string>
|
||||
@@ -1965,7 +1968,6 @@
|
||||
<string name="stores_settings_eyebrow">Налаштування</string>
|
||||
<string name="stores_chip_signed_out">(вхід не виконано)</string>
|
||||
<string name="stores_tab_store">Магазин</string>
|
||||
<string name="stores_tab_installed">Встановлені</string>
|
||||
<string name="stores_tab_library">Бібліотека</string>
|
||||
<string name="stores_tab_all">Усі</string>
|
||||
<string name="stores_tab_downloads">Завантаження</string>
|
||||
@@ -1977,10 +1979,8 @@
|
||||
<string name="stores_signout_stay">Не виходити</string>
|
||||
<string name="stores_signout_go">Вийти</string>
|
||||
<string name="stores_search_catalog">Пошук у каталозі %1$s…</string>
|
||||
<string name="stores_search_installed">Пошук встановлених ігор…</string>
|
||||
<string name="stores_search_library">Пошук у вашій бібліотеці…</string>
|
||||
<string name="stores_search_open">Пошук</string>
|
||||
<string name="stores_installed_empty">Нічого не встановлено</string>
|
||||
<string name="stores_library_loading">Завантаження</string>
|
||||
<string name="stores_nothing_matches">Немає ігор</string>
|
||||
<string name="stores_shelves_loading">Завантаження магазину…</string>
|
||||
@@ -1999,7 +1999,19 @@
|
||||
<string name="stores_view_on">Переглянути на %1$s</string>
|
||||
<string name="stores_buy_price">Купити · %1$s</string>
|
||||
<string name="stores_installed_chip">Встановлено</string>
|
||||
<string name="stores_section_installed">Встановлено</string>
|
||||
<string name="stores_section_not_installed">Не встановлено</string>
|
||||
<string name="stores_in_library">У бібліотеці</string>
|
||||
<string name="stores_itch_key">Ключ API</string>
|
||||
<string name="stores_itch_get_key">Отримати ключ</string>
|
||||
<string name="stores_itch_key_rejected">Ключ відхилено</string>
|
||||
<string name="stores_itch_not_owned">Немає у вашій бібліотеці itch.io</string>
|
||||
<string name="stores_itch_no_upload">Для цієї гри немає завантаження для Windows</string>
|
||||
<string name="stores_itch_no_exe">У завантаженні не знайдено гри для Windows</string>
|
||||
<string name="stores_itch_extract_failed">Не вдалося розпакувати завантаження (код %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io не відповів</string>
|
||||
<string name="stores_itch_archive">Архіви %1$s не підтримуються</string>
|
||||
<string name="stores_itch_download_failed">itch.io відповів HTTP %1$d</string>
|
||||
<string name="stores_free">Безкоштовно</string>
|
||||
<string name="stores_resume">Продовжити</string>
|
||||
<string name="stores_refresh">Оновити</string>
|
||||
|
||||
@@ -372,7 +372,7 @@
|
||||
<string name="mode_audio">Âm thanh</string>
|
||||
<string name="mode_directaudio">DirectAudio cho game</string>
|
||||
<string name="mode_directaudio_hint">Bỏ qua PulseAudio để giảm độ trễ.</string>
|
||||
<string name="mode_mic">Microphone</string>
|
||||
<string name="mode_mic">Micro</string>
|
||||
<string name="mode_mic_hint">Dùng cho chat bằng giọng nói.</string>
|
||||
<string name="mode_network">Mạng</string>
|
||||
<string name="mode_wifi_discovery">Dò tìm Wi-Fi</string>
|
||||
@@ -726,6 +726,9 @@
|
||||
<string name="setup_app_scale_default">%1$d%% (Mặc định)</string>
|
||||
<string name="setup_theme">Giao diện</string>
|
||||
<string name="setup_theme_hint">Giao diện launcher</string>
|
||||
<string name="setup_launcher_icon">Biểu tượng ứng dụng</string>
|
||||
<string name="setup_launcher_icon_dark">Tối</string>
|
||||
<string name="setup_launcher_icon_light">Sáng</string>
|
||||
<string name="setup_home">Dùng làm màn hình chính</string>
|
||||
<string name="setup_home_on">DroidDeck có thể là ứng dụng Màn hình chính</string>
|
||||
<string name="setup_home_off">DroidDeck không bao giờ được đề xuất làm ứng dụng Màn hình chính</string>
|
||||
@@ -756,7 +759,7 @@
|
||||
<string name="store_tab_search">Tìm kiếm</string>
|
||||
<string name="store_tab_installed">Đã cài</string>
|
||||
<string name="store_tab_count">%1$s · %2$d</string>
|
||||
<string name="store_title">Store</string>
|
||||
<string name="store_title">Cửa hàng</string>
|
||||
<string name="store_chip_flathub">Flathub · ARM64</string>
|
||||
<string name="store_chip_ready">● Flatpak đã sẵn sàng</string>
|
||||
<string name="store_needs_runtime">Cài runtime Linux từ Thiết lập trước.</string>
|
||||
@@ -1069,7 +1072,7 @@
|
||||
<string name="deck_tab_left">Pad trái</string>
|
||||
<string name="deck_tab_right">Pad phải</string>
|
||||
<string name="deck_tab_both">Cả hai</string>
|
||||
<string name="deck_tab_gamepad">Gamepad</string>
|
||||
<string name="deck_tab_gamepad">Tay cầm</string>
|
||||
<string name="deck_surface_stick">Cần</string>
|
||||
<string name="deck_surface_trackpad">Trackpad</string>
|
||||
<string name="deck_surface_switch">Chuyển giữa cần và trackpad</string>
|
||||
@@ -1092,8 +1095,8 @@
|
||||
<string name="rail_steam">Steam</string>
|
||||
<string name="rail_games">Games</string>
|
||||
<string name="rail_desktop">Desktop</string>
|
||||
<string name="rail_store">Store</string>
|
||||
<string name="rail_components">Components</string>
|
||||
<string name="rail_store">Cửa hàng</string>
|
||||
<string name="rail_components">Thành phần</string>
|
||||
<string name="rail_apps">Ứng dụng</string>
|
||||
<string name="rail_setup">Thiết lập</string>
|
||||
<string name="rail_updates">Cập nhật</string>
|
||||
@@ -1506,11 +1509,11 @@
|
||||
<string name="fm_rail_storage">LƯU TRỮ</string>
|
||||
<string name="fm_rail_quick">NHANH</string>
|
||||
<string name="fm_rail_favorites">YÊU THÍCH</string>
|
||||
<string name="fm_rail_downloads">Downloads</string>
|
||||
<string name="fm_rail_downloads_short">Downlds</string>
|
||||
<string name="fm_rail_downloads">TẢI XUỐNG</string>
|
||||
<string name="fm_rail_downloads_short">TẢI</string>
|
||||
<string name="fm_rail_roms">ROMs</string>
|
||||
<string name="fm_rail_session_logs">Log phiên</string>
|
||||
<string name="fm_rail_pictures">Pictures</string>
|
||||
<string name="fm_rail_pictures">HÌNH ẢNH</string>
|
||||
<string name="fm_favorite_added">Đã thêm \"%1$s\" vào Yêu thích</string>
|
||||
<string name="fm_favorite_removed">Đã xóa \"%1$s\" khỏi Yêu thích</string>
|
||||
<string name="fm_empty_directory">Thư mục trống</string>
|
||||
@@ -1752,27 +1755,27 @@
|
||||
<string name="sprefs_lod_bias_auto">Tự động (khớp tỷ lệ)</string>
|
||||
|
||||
<string name="look_off_desc">Không áp dụng gì.</string>
|
||||
<string name="look_game_clarity">Game Clarity</string>
|
||||
<string name="look_game_clarity">Rõ nét trò chơi</string>
|
||||
<string name="look_game_clarity_desc">Làm nét với một chút tương phản - lựa chọn hàng ngày.</string>
|
||||
<string name="look_vivid">Vivid</string>
|
||||
<string name="look_vivid">Sống động</string>
|
||||
<string name="look_vivid_desc">Màu sắc được đẩy mạnh. Khử dải màu được bật để bầu trời không bị sọc.</string>
|
||||
<string name="look_cinematic">Cinematic</string>
|
||||
<string name="look_cinematic">Điện ảnh</string>
|
||||
<string name="look_cinematic_desc">Tương phản sâu hơn, màu lạnh hơn, cạnh được làm mượt.</string>
|
||||
<string name="look_competitive">Competitive</string>
|
||||
<string name="look_competitive">Thi đấu</string>
|
||||
<string name="look_competitive_desc">Vùng tối được nâng lên và làm nét mạnh - phát hiện người trước tiên.</string>
|
||||
<string name="look_adaptive_sharpen">Adaptive Sharpen</string>
|
||||
<string name="look_adaptive_sharpen">Làm nét thích ứng</string>
|
||||
<string name="look_adaptive_sharpen_desc">Chỉ làm nét, không đổi màu sắc.</string>
|
||||
<string name="look_filmic">Filmic</string>
|
||||
<string name="look_filmic_desc">Đường cong giống phim, hơi trầm.</string>
|
||||
<string name="look_arcade">Arcade</string>
|
||||
<string name="look_arcade_desc">Mạnh mẽ và rực rỡ.</string>
|
||||
<string name="look_retro_crt">Retro CRT</string>
|
||||
<string name="look_retro_crt">CRT cổ điển</string>
|
||||
<string name="look_retro_crt_desc">Ống CRT và tín hiệu analog cùng áp dụng.</string>
|
||||
<string name="look_upscale_sharp">Upscale Sharp</string>
|
||||
<string name="look_upscale_sharp">Nâng cấp sắc nét</string>
|
||||
<string name="look_upscale_sharp_desc">Dùng khi chạy dưới độ phân giải màn hình.</string>
|
||||
<string name="look_pixel_clean">Pixel Clean</string>
|
||||
<string name="look_pixel_clean">Điểm ảnh sạch</string>
|
||||
<string name="look_pixel_clean_desc">Pixel sắc nét cho game 2D và các game cũ.</string>
|
||||
<string name="look_anime_edge">Anime Edge</string>
|
||||
<string name="look_anime_edge">Viền anime</string>
|
||||
<string name="look_anime_edge_desc">Viền hoạt hình cộng làm nét.</string>
|
||||
|
||||
<string name="gpuinfo_not_adreno">Không phải GPU Adreno</string>
|
||||
@@ -1885,10 +1888,10 @@
|
||||
<string name="force_ssbs_title">Buộc SSBS</string>
|
||||
<string name="force_ssbs_hint">Giữ một thiết lập tốc độ CPU mà Wine liên tục tắt. Có thể giúp game Windows nhanh hơn trên Snapdragon 8 Elite; thay đổi ít thấy trên chip cũ hơn. Áp dụng vào lần game khởi động kế tiếp.</string>
|
||||
|
||||
<string name="rail_stores">Stores</string>
|
||||
<string name="rail_stores">Cửa hàng game</string>
|
||||
<string name="source_steam">Steam</string>
|
||||
<string name="source_added">Tùy chỉnh</string>
|
||||
<string name="setup_stores">Stores</string>
|
||||
<string name="setup_stores">Cửa hàng</string>
|
||||
<string name="setup_stores_open_on">Mở một store trên</string>
|
||||
<string name="setup_stores_speed">Mức tốc độ tải</string>
|
||||
<string name="setup_stores_engine">Engine stores</string>
|
||||
@@ -1897,15 +1900,14 @@
|
||||
<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>
|
||||
<string name="stores_title">Stores</string>
|
||||
<string name="stores_title">Cửa hàng</string>
|
||||
<string name="stores_settings">Thiết lập Stores</string>
|
||||
<string name="stores_settings_eyebrow">Thiết lập</string>
|
||||
<string name="stores_chip_signed_out">(chưa đăng nhập)</string>
|
||||
<string name="stores_tab_store">Store</string>
|
||||
<string name="stores_tab_installed">Đã cài</string>
|
||||
<string name="stores_tab_store">Cửa hàng</string>
|
||||
<string name="stores_tab_library">Thư viện</string>
|
||||
<string name="stores_tab_all">Tất cả</string>
|
||||
<string name="stores_tab_downloads">Downloads</string>
|
||||
<string name="stores_tab_downloads">Tải xuống</string>
|
||||
<string name="stores_tab_count">%1$s (%2$d)</string>
|
||||
<string name="stores_signin">Đăng nhập</string>
|
||||
<string name="stores_signout_named">%1$s · đăng xuất</string>
|
||||
@@ -1914,10 +1916,8 @@
|
||||
<string name="stores_signout_stay">Vẫn đăng nhập</string>
|
||||
<string name="stores_signout_go">Đăng xuất</string>
|
||||
<string name="stores_search_catalog">Tìm trong danh mục %1$s…</string>
|
||||
<string name="stores_search_installed">Tìm game đã cài…</string>
|
||||
<string name="stores_search_library">Tìm trong thư viện của bạn…</string>
|
||||
<string name="stores_search_open">Tìm kiếm</string>
|
||||
<string name="stores_installed_empty">Chưa cài gì</string>
|
||||
<string name="stores_library_loading">Đang tải</string>
|
||||
<string name="stores_nothing_matches">Không có game</string>
|
||||
<string name="stores_shelves_loading">Đang tải store…</string>
|
||||
@@ -1936,7 +1936,19 @@
|
||||
<string name="stores_view_on">Xem trên %1$s</string>
|
||||
<string name="stores_buy_price">Mua · %1$s</string>
|
||||
<string name="stores_installed_chip">Đã cài</string>
|
||||
<string name="stores_section_installed">Đã cài</string>
|
||||
<string name="stores_section_not_installed">Chưa cài</string>
|
||||
<string name="stores_in_library">Trong thư viện</string>
|
||||
<string name="stores_itch_key">Khóa API</string>
|
||||
<string name="stores_itch_get_key">Lấy khóa</string>
|
||||
<string name="stores_itch_key_rejected">Khóa bị từ chối</string>
|
||||
<string name="stores_itch_not_owned">Không có trong thư viện itch.io của bạn</string>
|
||||
<string name="stores_itch_no_upload">Không có bản tải Windows cho game này</string>
|
||||
<string name="stores_itch_no_exe">Không tìm thấy trò chơi Windows trong bản tải xuống</string>
|
||||
<string name="stores_itch_extract_failed">Không thể giải nén bản tải xuống (mã %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io không phản hồi</string>
|
||||
<string name="stores_itch_archive">Không hỗ trợ tệp %1$s</string>
|
||||
<string name="stores_itch_download_failed">itch.io trả về HTTP %1$d</string>
|
||||
<string name="stores_free">Miễn phí</string>
|
||||
<string name="stores_resume">Tiếp tục</string>
|
||||
<string name="stores_refresh">Làm mới</string>
|
||||
@@ -1987,7 +1999,7 @@
|
||||
<string name="cloud_done">%1$d tệp</string>
|
||||
<string name="stores_eyebrow_installed">đã cài</string>
|
||||
<string name="stores_eyebrow_library">trong thư viện của bạn</string>
|
||||
<string name="stores_eyebrow_store">store</string>
|
||||
<string name="stores_eyebrow_store">cửa hàng</string>
|
||||
<string name="achievements_title">Thành tích</string>
|
||||
<string name="achievements_count">Đã mở khóa %1$d/%2$d</string>
|
||||
<string name="achievements_count_short">%1$d / %2$d</string>
|
||||
@@ -2014,7 +2026,7 @@
|
||||
<string name="stores_card_proton_value">Lựa chọn của Steam cho mỗi game</string>
|
||||
<string name="stores_card_audio">Âm thanh</string>
|
||||
<string name="stores_card_audio_value">DirectAudio</string>
|
||||
<string name="stores_card_components">Components</string>
|
||||
<string name="stores_card_components">Thành phần</string>
|
||||
<string name="stores_card_components_value">FEX, DXVK, VKD3D</string>
|
||||
<string name="stores_card_engine">Engine</string>
|
||||
<string name="stores_card_engine_value">%1$s · gốc</string>
|
||||
|
||||
@@ -726,6 +726,9 @@
|
||||
<string name="setup_app_scale_default">%1$d%%(默认)</string>
|
||||
<string name="setup_theme">主题</string>
|
||||
<string name="setup_theme_hint">启动器外观</string>
|
||||
<string name="setup_launcher_icon">应用图标</string>
|
||||
<string name="setup_launcher_icon_dark">深色</string>
|
||||
<string name="setup_launcher_icon_light">浅色</string>
|
||||
<string name="setup_home">用作主屏幕</string>
|
||||
<string name="setup_home_on">DroidDeck 可以设为主屏幕应用</string>
|
||||
<string name="setup_home_off">系统不会把 DroidDeck 作为主屏幕应用提供</string>
|
||||
@@ -1978,7 +1981,6 @@
|
||||
<string name="stores_settings_eyebrow">设置</string>
|
||||
<string name="stores_chip_signed_out">(未登录)</string>
|
||||
<string name="stores_tab_store">商店</string>
|
||||
<string name="stores_tab_installed">已安装</string>
|
||||
<string name="stores_tab_library">库</string>
|
||||
<string name="stores_tab_all">全部</string>
|
||||
<string name="stores_tab_downloads">下载</string>
|
||||
@@ -1990,10 +1992,8 @@
|
||||
<string name="stores_signout_stay">保持登录</string>
|
||||
<string name="stores_signout_go">退出登录</string>
|
||||
<string name="stores_search_catalog">搜索 %1$s 目录…</string>
|
||||
<string name="stores_search_installed">搜索已安装的游戏…</string>
|
||||
<string name="stores_search_library">搜索你的库…</string>
|
||||
<string name="stores_search_open">搜索</string>
|
||||
<string name="stores_installed_empty">尚未安装任何内容</string>
|
||||
<string name="stores_library_loading">加载中</string>
|
||||
<string name="stores_nothing_matches">没有游戏</string>
|
||||
<string name="stores_shelves_loading">正在加载商店…</string>
|
||||
@@ -2012,7 +2012,19 @@
|
||||
<string name="stores_view_on">在 %1$s 上查看</string>
|
||||
<string name="stores_buy_price">购买 · %1$s</string>
|
||||
<string name="stores_installed_chip">已安装</string>
|
||||
<string name="stores_section_installed">已安装</string>
|
||||
<string name="stores_section_not_installed">未安装</string>
|
||||
<string name="stores_in_library">已在库中</string>
|
||||
<string name="stores_itch_key">API 密钥</string>
|
||||
<string name="stores_itch_get_key">获取密钥</string>
|
||||
<string name="stores_itch_key_rejected">密钥被拒绝</string>
|
||||
<string name="stores_itch_not_owned">不在你的 itch.io 库中</string>
|
||||
<string name="stores_itch_no_upload">这个游戏没有 Windows 下载</string>
|
||||
<string name="stores_itch_no_exe">下载内容中未找到 Windows 游戏</string>
|
||||
<string name="stores_itch_extract_failed">无法解压下载内容(代码 %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io 没有响应</string>
|
||||
<string name="stores_itch_archive">不支持 %1$s 压缩包</string>
|
||||
<string name="stores_itch_download_failed">itch.io 返回 HTTP %1$d</string>
|
||||
<string name="stores_free">免费</string>
|
||||
<string name="stores_resume">继续</string>
|
||||
<string name="stores_refresh">刷新</string>
|
||||
|
||||
@@ -726,6 +726,9 @@
|
||||
<string name="setup_app_scale_default">%1$d%%(預設)</string>
|
||||
<string name="setup_theme">主題</string>
|
||||
<string name="setup_theme_hint">啟動器外觀</string>
|
||||
<string name="setup_launcher_icon">應用程式圖示</string>
|
||||
<string name="setup_launcher_icon_dark">深色</string>
|
||||
<string name="setup_launcher_icon_light">淺色</string>
|
||||
<string name="setup_home">用作主畫面</string>
|
||||
<string name="setup_home_on">DroidDeck 可用作主畫面應用程式</string>
|
||||
<string name="setup_home_off">DroidDeck 不會作為主畫面應用程式的選項</string>
|
||||
@@ -1902,7 +1905,6 @@
|
||||
<string name="stores_settings_eyebrow">設定</string>
|
||||
<string name="stores_chip_signed_out">(未登入)</string>
|
||||
<string name="stores_tab_store">商店</string>
|
||||
<string name="stores_tab_installed">已安裝</string>
|
||||
<string name="stores_tab_library">收藏庫</string>
|
||||
<string name="stores_tab_all">全部</string>
|
||||
<string name="stores_tab_downloads">下載</string>
|
||||
@@ -1914,10 +1916,8 @@
|
||||
<string name="stores_signout_stay">保持登入</string>
|
||||
<string name="stores_signout_go">登出</string>
|
||||
<string name="stores_search_catalog">搜尋 %1$s 目錄…</string>
|
||||
<string name="stores_search_installed">搜尋已安裝的遊戲…</string>
|
||||
<string name="stores_search_library">搜尋你的收藏庫…</string>
|
||||
<string name="stores_search_open">搜尋</string>
|
||||
<string name="stores_installed_empty">未安裝任何遊戲</string>
|
||||
<string name="stores_library_loading">正在載入</string>
|
||||
<string name="stores_nothing_matches">沒有遊戲</string>
|
||||
<string name="stores_shelves_loading">正在載入商店…</string>
|
||||
@@ -1936,7 +1936,19 @@
|
||||
<string name="stores_view_on">在 %1$s 查看</string>
|
||||
<string name="stores_buy_price">購買 · %1$s</string>
|
||||
<string name="stores_installed_chip">已安裝</string>
|
||||
<string name="stores_section_installed">已安裝</string>
|
||||
<string name="stores_section_not_installed">未安裝</string>
|
||||
<string name="stores_in_library">已在收藏庫中</string>
|
||||
<string name="stores_itch_key">API 金鑰</string>
|
||||
<string name="stores_itch_get_key">取得金鑰</string>
|
||||
<string name="stores_itch_key_rejected">金鑰被拒絕</string>
|
||||
<string name="stores_itch_not_owned">不在你的 itch.io 收藏庫中</string>
|
||||
<string name="stores_itch_no_upload">這個遊戲沒有 Windows 下載</string>
|
||||
<string name="stores_itch_no_exe">下載內容中找不到 Windows 遊戲</string>
|
||||
<string name="stores_itch_extract_failed">無法解壓下載內容(代碼 %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io 沒有回應</string>
|
||||
<string name="stores_itch_archive">不支援 %1$s 壓縮檔</string>
|
||||
<string name="stores_itch_download_failed">itch.io 回應 HTTP %1$d</string>
|
||||
<string name="stores_free">免費</string>
|
||||
<string name="stores_resume">繼續</string>
|
||||
<string name="stores_refresh">重新整理</string>
|
||||
|
||||
@@ -726,6 +726,9 @@
|
||||
<string name="setup_app_scale_default">%1$d%%(預設)</string>
|
||||
<string name="setup_theme">佈景主題</string>
|
||||
<string name="setup_theme_hint">啟動器外觀</string>
|
||||
<string name="setup_launcher_icon">應用程式圖示</string>
|
||||
<string name="setup_launcher_icon_dark">深色</string>
|
||||
<string name="setup_launcher_icon_light">淺色</string>
|
||||
<string name="setup_home">用作主螢幕</string>
|
||||
<string name="setup_home_on">DroidDeck 可以作為主螢幕應用程式</string>
|
||||
<string name="setup_home_off">DroidDeck 不會作為主螢幕應用程式提供</string>
|
||||
@@ -1902,7 +1905,6 @@
|
||||
<string name="stores_settings_eyebrow">設定</string>
|
||||
<string name="stores_chip_signed_out">(未登入)</string>
|
||||
<string name="stores_tab_store">商店</string>
|
||||
<string name="stores_tab_installed">已安裝</string>
|
||||
<string name="stores_tab_library">收藏庫</string>
|
||||
<string name="stores_tab_all">全部</string>
|
||||
<string name="stores_tab_downloads">下載</string>
|
||||
@@ -1914,10 +1916,8 @@
|
||||
<string name="stores_signout_stay">保持登入</string>
|
||||
<string name="stores_signout_go">登出</string>
|
||||
<string name="stores_search_catalog">搜尋 %1$s 目錄…</string>
|
||||
<string name="stores_search_installed">搜尋已安裝的遊戲…</string>
|
||||
<string name="stores_search_library">搜尋你的收藏庫…</string>
|
||||
<string name="stores_search_open">搜尋</string>
|
||||
<string name="stores_installed_empty">尚未安裝任何內容</string>
|
||||
<string name="stores_library_loading">正在載入</string>
|
||||
<string name="stores_nothing_matches">沒有遊戲</string>
|
||||
<string name="stores_shelves_loading">正在載入商店…</string>
|
||||
@@ -1936,7 +1936,19 @@
|
||||
<string name="stores_view_on">在 %1$s 檢視</string>
|
||||
<string name="stores_buy_price">購買 · %1$s</string>
|
||||
<string name="stores_installed_chip">已安裝</string>
|
||||
<string name="stores_section_installed">已安裝</string>
|
||||
<string name="stores_section_not_installed">未安裝</string>
|
||||
<string name="stores_in_library">已在收藏庫中</string>
|
||||
<string name="stores_itch_key">API 金鑰</string>
|
||||
<string name="stores_itch_get_key">取得金鑰</string>
|
||||
<string name="stores_itch_key_rejected">金鑰被拒絕</string>
|
||||
<string name="stores_itch_not_owned">不在你的 itch.io 收藏庫中</string>
|
||||
<string name="stores_itch_no_upload">這個遊戲沒有 Windows 下載</string>
|
||||
<string name="stores_itch_no_exe">下載內容中找不到 Windows 遊戲</string>
|
||||
<string name="stores_itch_extract_failed">無法解壓縮下載內容(代碼 %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io 沒有回應</string>
|
||||
<string name="stores_itch_archive">不支援 %1$s 壓縮檔</string>
|
||||
<string name="stores_itch_download_failed">itch.io 回應 HTTP %1$d</string>
|
||||
<string name="stores_free">免費</string>
|
||||
<string name="stores_resume">繼續</string>
|
||||
<string name="stores_refresh">重新整理</string>
|
||||
|
||||
@@ -760,6 +760,9 @@
|
||||
<string name="setup_app_scale_default">%1$d%% (Default)</string>
|
||||
<string name="setup_theme">Theme</string>
|
||||
<string name="setup_theme_hint">Launcher appearance</string>
|
||||
<string name="setup_launcher_icon">App icon</string>
|
||||
<string name="setup_launcher_icon_dark">Dark</string>
|
||||
<string name="setup_launcher_icon_light">Light</string>
|
||||
<string name="setup_home">Use as Home screen</string>
|
||||
<string name="setup_home_on">DroidDeck can be the Home app</string>
|
||||
<string name="setup_home_off">DroidDeck is never offered as a Home app</string>
|
||||
@@ -1966,7 +1969,6 @@
|
||||
<string name="stores_settings_eyebrow">Settings</string>
|
||||
<string name="stores_chip_signed_out">(not signed in)</string>
|
||||
<string name="stores_tab_store">Store</string>
|
||||
<string name="stores_tab_installed">Installed</string>
|
||||
<string name="stores_tab_library">Library</string>
|
||||
<string name="stores_tab_all">All</string>
|
||||
<string name="stores_tab_downloads">Downloads</string>
|
||||
@@ -1978,10 +1980,8 @@
|
||||
<string name="stores_signout_stay">Stay signed in</string>
|
||||
<string name="stores_signout_go">Sign out</string>
|
||||
<string name="stores_search_catalog">Search the %1$s catalog…</string>
|
||||
<string name="stores_search_installed">Search installed games…</string>
|
||||
<string name="stores_search_library">Search your library…</string>
|
||||
<string name="stores_search_open">Search</string>
|
||||
<string name="stores_installed_empty">Nothing installed</string>
|
||||
<string name="stores_library_loading">Loading</string>
|
||||
<string name="stores_nothing_matches">No games</string>
|
||||
<string name="stores_shelves_loading">Loading the store…</string>
|
||||
@@ -2000,7 +2000,19 @@
|
||||
<string name="stores_view_on">View on %1$s</string>
|
||||
<string name="stores_buy_price">Buy · %1$s</string>
|
||||
<string name="stores_installed_chip">Installed</string>
|
||||
<string name="stores_section_installed">Installed</string>
|
||||
<string name="stores_section_not_installed">Not installed</string>
|
||||
<string name="stores_in_library">In library</string>
|
||||
<string name="stores_itch_key">API key</string>
|
||||
<string name="stores_itch_get_key">Get a key</string>
|
||||
<string name="stores_itch_key_rejected">Key rejected</string>
|
||||
<string name="stores_itch_not_owned">Not in your itch.io library</string>
|
||||
<string name="stores_itch_no_upload">No Windows download for this game</string>
|
||||
<string name="stores_itch_no_exe">No Windows game found in the download</string>
|
||||
<string name="stores_itch_extract_failed">Could not extract the download (exit %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io did not answer</string>
|
||||
<string name="stores_itch_archive">%1$s archives are not supported</string>
|
||||
<string name="stores_itch_download_failed">itch.io answered HTTP %1$d</string>
|
||||
<string name="stores_free">Free</string>
|
||||
<string name="stores_resume">Resume</string>
|
||||
<string name="stores_refresh">Refresh</string>
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.frontend
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.app.Activity
|
||||
import com.droiddeck.launcher.LauncherIcon
|
||||
import com.droiddeck.launcher.MainActivity
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -68,12 +69,24 @@ class GameShortcutIntentTest {
|
||||
val launch = requireNotNull(shortcut.intent)
|
||||
assertEquals("game:8400", shortcut.id)
|
||||
assertEquals("Geometry Wars: Retro Evolved", shortcut.shortLabel.toString())
|
||||
assertEquals(MainActivity::class.java.name, shortcut.activity?.className)
|
||||
assertEquals("com.droiddeck.launcher.LauncherDark", shortcut.activity?.className)
|
||||
assertEquals(MainActivity::class.java.name, launch.component?.className)
|
||||
assertEquals(Intent.ACTION_VIEW, launch.action)
|
||||
assertEquals(Uri.parse("droiddeck://game/8400"), launch.data)
|
||||
assertEquals(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP, launch.flags)
|
||||
}
|
||||
|
||||
@Test fun pinnedShortcutFollowsTheLightLauncherIcon() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
LauncherIcon.set(activity, LauncherIcon.LIGHT)
|
||||
|
||||
GameShortcuts.pin(activity, Library.SteamGame(8400, "Geometry Wars: Retro Evolved", null, "steam", 8400L))
|
||||
|
||||
val shortcut = activity.getSystemService(android.content.pm.ShortcutManager::class.java).pinnedShortcuts.single()
|
||||
assertEquals(LauncherIcon.LIGHT, LauncherIcon.current(activity))
|
||||
assertEquals("com.droiddeck.launcher.LauncherLight", shortcut.activity?.className)
|
||||
}
|
||||
|
||||
@Test fun actionCreateShortcutUsesAndroidsShortcutResultIntentApi() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val game = Library.SteamGame(8400, "Geometry Wars: Retro Evolved", null, "steam", 8400L)
|
||||
|
||||
@@ -22,4 +22,17 @@ class LibraryArchTest {
|
||||
assertEquals(listOf("Team Fortress 2"), Library.launchableGames(app, added = emptyList(), arch = "x86_64").map { it.name })
|
||||
assertEquals(emptyList<String>(), Library.launchableGames(app, added = emptyList(), arch = "arm64").map { it.name })
|
||||
}
|
||||
|
||||
@Test fun anAddedGameSteamAlreadyListsFromItsFolderIsShownOnce() {
|
||||
SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "")
|
||||
val folder = File(app.filesDir, "games/Example").apply { mkdirs() }
|
||||
val exe = File(folder, "Example.exe").apply { writeText("game") }
|
||||
val apps = File(SessionPrefs.steamRoot(app, "arm64"), "steamapps").apply { mkdirs() }
|
||||
java.nio.file.Files.createSymbolicLink(File(apps, "common").apply { mkdirs() }.toPath().resolve("DroidDeck-42"), folder.toPath())
|
||||
File(apps, "appmanifest_42.acf").writeText("\"AppState\"\n{\n\t\"appid\"\t\t\"42\"\n\t\"name\"\t\t\"Example\"\n\t\"StateFlags\"\t\t\"4\"\n\t\"installdir\"\t\t\"DroidDeck-42\"\n}\n")
|
||||
val added = AddedGames.Game(folder, "Example", exe, "/g/Example.exe", "/g", 0x87654321L, (0x87654321L shl 32) or 0x02000000L, emptyList())
|
||||
val other = File(app.filesDir, "games/Other").apply { mkdirs() }
|
||||
val second = AddedGames.Game(other, "Other", File(other, "Other.exe"), "/g/Other.exe", "/g", 0x81234567L, (0x81234567L shl 32) or 0x02000000L, emptyList())
|
||||
assertEquals(listOf("Example" to "internal", "Other" to Library.ADDED), Library.launchableGames(app, added = listOf(added, second), arch = "arm64").map { it.name to it.library })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import java.io.File
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33])
|
||||
class LibraryConcealedAppsTest {
|
||||
@get:Rule val tmp = TemporaryFolder()
|
||||
|
||||
private fun steamRoot(): File = tmp.newFolder("Steam").also { root ->
|
||||
File(root, "config").mkdirs()
|
||||
File(root, "config/loginusers.vdf").writeText(
|
||||
"\"users\"\n{\n\t\"76561198352563669\"\n\t{\n\t\t\"AccountName\"\t\t\"a\"\n\t\t\"MostRecent\"\t\t\"1\"\n\t}\n}\n")
|
||||
}
|
||||
|
||||
private fun config(root: File) = File(root, "userdata/392297941/config").apply { mkdirs() }
|
||||
|
||||
@Test fun hiddenCollectionAndPrivateAppsAreBothConcealed() {
|
||||
val root = steamRoot()
|
||||
val config = config(root)
|
||||
File(config, "cloudstorage").mkdirs()
|
||||
File(config, "cloudstorage/cloud-storage-namespace-1.json").writeText(
|
||||
"""[["user-collections.favorite",{"key":"user-collections.favorite","value":"{\"id\":\"favorite\",\"added\":[620],\"removed\":[]}"}],""" +
|
||||
"""["user-collections.hidden",{"key":"user-collections.hidden","value":"{\"id\":\"hidden\",\"name\":\"Hidden\",\"added\":[223470,2931908220,400],\"removed\":[400]}"}]]""")
|
||||
File(config, "localconfig.vdf").writeText(
|
||||
"\"UserLocalConfigStore\"\n{\n\t\"WebStorage\"\n\t{\n\t\t\"PrivateApps_392297941\"\t\t\"[339800,567090]\"\n\t}\n}\n")
|
||||
assertEquals(setOf(223470L, 2931908220L, 339800L, 567090L), Library.concealedSteamApps(root))
|
||||
}
|
||||
|
||||
@Test fun deletedHiddenCollectionAndMissingFilesConcealNothing() {
|
||||
val root = steamRoot()
|
||||
assertEquals(emptySet<Long>(), Library.concealedSteamApps(root))
|
||||
val config = config(root)
|
||||
File(config, "cloudstorage").mkdirs()
|
||||
File(config, "cloudstorage/cloud-storage-namespace-1.json").writeText(
|
||||
"""[["user-collections.hidden",{"key":"user-collections.hidden","is_deleted":true}]]""")
|
||||
assertEquals(emptySet<Long>(), Library.concealedSteamApps(root))
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,12 @@ class GlassesMirrorPacingTest {
|
||||
assertEquals(30, SessionState.fpsLimit)
|
||||
}
|
||||
|
||||
@Test fun pacingFollowsThePanelWhenTheSystemKeepsALowerRateThanRequested() {
|
||||
Shadow.extract<ModeDisplay>(activity.display).current = mode(2, 90f)
|
||||
apply(GlassesState(), settings)
|
||||
expect(3, 90f, 0)
|
||||
}
|
||||
|
||||
private fun apply(state: GlassesState, config: GlassesSettings) {
|
||||
ReflectionHelpers.getField<MutableState<GlassesState>>(activity, "glasses\$delegate").value = state
|
||||
ReflectionHelpers.getField<MutableState<GlassesSettings>>(activity, "glassesSettings\$delegate").value = config
|
||||
@@ -83,5 +89,6 @@ class GlassesMirrorPacingTest {
|
||||
var supported = emptyArray<Display.Mode>()
|
||||
@Implementation protected fun getMode(): Display.Mode = current
|
||||
@Implementation protected fun getSupportedModes(): Array<Display.Mode> = supported
|
||||
@Implementation override fun getRefreshRate(): Float = current.refreshRate
|
||||
}
|
||||
}
|
||||
@@ -26,4 +26,29 @@ class StoreGridNavigationTest {
|
||||
assertEquals(5, storeGridNeighbor(2, 10, 3, 1))
|
||||
assertEquals(6, storeGridNeighbor(2, 10, 4, 1))
|
||||
}
|
||||
|
||||
@Test fun downFromTheInstalledSectionLandsInTheSameColumnBelowTheDivider() {
|
||||
assertEquals(6, storeGridNeighbor(0, 12, 4, 1, split = 6).let { storeGridNeighbor(it!!, 12, 4, 1, split = 6) })
|
||||
assertEquals(6, storeGridNeighbor(4, 12, 4, 1, split = 6))
|
||||
assertEquals(7, storeGridNeighbor(5, 12, 4, 1, split = 6))
|
||||
assertEquals(8, storeGridNeighbor(2, 12, 4, 1, split = 6))
|
||||
}
|
||||
|
||||
@Test fun upFromBelowTheDividerClampsToTheShortInstalledRow() {
|
||||
assertEquals(4, storeGridNeighbor(6, 12, 4, -1, split = 6))
|
||||
assertEquals(5, storeGridNeighbor(7, 12, 4, -1, split = 6))
|
||||
assertEquals(5, storeGridNeighbor(9, 12, 4, -1, split = 6))
|
||||
assertEquals(6, storeGridNeighbor(10, 12, 4, -1, split = 6))
|
||||
}
|
||||
|
||||
@Test fun lastSectionStillStopsAtItsIncompleteRow() {
|
||||
assertNull(storeGridNeighbor(9, 11, 4, 1, split = 6))
|
||||
assertEquals(10, storeGridNeighbor(6, 11, 4, 1, split = 6))
|
||||
assertNull(storeGridNeighbor(1, 11, 4, -1, split = 6))
|
||||
}
|
||||
|
||||
@Test fun installedGamesComeFirstKeepingTheirSortedOrder() {
|
||||
val items = listOf("a", "b", "c", "d").map { com.droiddeck.launcher.stores.CatalogItem(com.droiddeck.launcher.stores.Store.GOG, it, it, null) }
|
||||
assertEquals(listOf("b", "d", "a", "c"), installedFirst(items, setOf("d", "b")).map { it.id })
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,13 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class StoreTabsTest {
|
||||
@Test fun storeLibraryInstalledAmazonWithoutStore() {
|
||||
assertEquals(listOf("store", "library", "installed"), tabsFor(Store.GOG))
|
||||
assertEquals(listOf("library", "installed"), tabsFor(Store.AMAZON))
|
||||
@Test fun libraryAndStoreAmazonLibraryOnly() {
|
||||
assertEquals(listOf("library", "store"), tabsFor(Store.GOG))
|
||||
assertEquals(listOf("library", "store"), tabsFor(Store.ITCH))
|
||||
assertEquals(listOf("library"), tabsFor(Store.AMAZON))
|
||||
assertEquals("library", tabFor(Store.AMAZON, "store"))
|
||||
assertEquals("library", tabFor(Store.GOG, "all"))
|
||||
assertEquals("installed", tabFor(Store.EPIC, "installed"))
|
||||
assertEquals("library", tabFor(Store.EPIC, "installed"))
|
||||
assertEquals("store", tabFor(Store.GOG, "store"))
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
`droiddeck-mark.svg` is the editable source for the white split-D mark. The blue orb is `#1A9FFF`; its halo and the two separator bars are transparent cutouts. The bars are centered on x=63.97, the endpoint of the curved half in the original Steam Deck symbol, and use the halo's 15.83-unit thickness. The mark is optically centered in its view box with a small rightward offset to balance its asymmetric silhouette. Place it on black when a background is needed.
|
||||
|
||||
Android currently consumes raster resources for the launcher layers and the Compose logo. The `mipmap-*/ic_launcher_foreground.png` files are density-sized exports of the SVG, with the mark kept inside the adaptive icon safe area. The matching backgrounds are black. `drawable-nodpi/logo.png` composes the same mark on a black circular badge.
|
||||
Android currently consumes raster resources for the launcher layers and the Compose logo. The `mipmap-*/ic_launcher_foreground.png` files are density-sized exports of the SVG, with the mark kept inside the adaptive icon safe area. The matching backgrounds are black. The `_light` variants recolor the mark black on a white background and back the light launcher icon users can pick in Settings. `drawable-nodpi/logo.png` composes the same mark on a black circular badge.
|
||||
@@ -39,6 +39,11 @@ arm64 client down first.
|
||||
standalone launcher honours up to five times, as `steam.sh` does).
|
||||
- Before each start `x64_sync_client` brings the client to Valve's current build, downloading only the
|
||||
changed parts (9 of 28 on a device). If that fails, the client updates itself.
|
||||
- The client is the `steamrt64` platform (`steamrt64/steam`, the manifest it checks for updates), so
|
||||
the install, the sync and the web-helper build check read `steam_client_<channel>_steamrt64`. The
|
||||
`ubuntu12` manifest lags it (device: 1792108788 against 1792192922): an install from it was always
|
||||
one build behind the arm64 client, so the arm64 web helper was never used, and the client updated
|
||||
itself in place at its first start (506 MB) after the helper had already been chosen.
|
||||
- `.steam-enable-steamrt64-client` in the client folder opts into the SteamRT3 build
|
||||
(`steamrt64/steam`). Without it the client re-executes itself in the scout environment through
|
||||
`steam.sh`, whose start-up checks need what proot cannot give; the session stalled before the
|
||||
@@ -119,6 +124,11 @@ The client's web helper draws its whole UI. There are two:
|
||||
backend through FEX's GL thunk to zink and Turnip. On ANGLE's Vulkan backend the x86 compositor
|
||||
thread was the bottleneck (16 fps against 60). Never `--in-process-gpu`, the preload's default: a
|
||||
GPU crash there took the whole helper down (102 restarts). `BL_X64_CEF_GPU=0` keeps it in software.
|
||||
Its GPU process loads libpci, which exits when `/sys/bus/pci/devices` lists a device whose files
|
||||
the app may not read (a RedMagic's PCIe root ports): every GPU process exited with status 1 in about
|
||||
50 ms, software ones too, until Chromium gave up ("GPU process isn't usable") and the menus stayed
|
||||
blank. `LinuxRuntime` binds an empty folder over `/sys/bus/pci/devices` on such devices; device:
|
||||
0 GPU process exits, ANGLE on zink and Turnip.
|
||||
|
||||
The session shim (`preload/net.c`) turns `steamrt64/steamwebhelper.sh`'s exec of `./steamwebhelper`
|
||||
into `BL_X64_ARM_WEBHELPER`. Nothing in the client's folder is edited: the client checksums its files
|
||||
@@ -217,7 +227,7 @@ alone would then read as an installed client.
|
||||
|
||||
- **What a session log shows.** `FEX ready (<tag>, <source>)` names the build and where it came from
|
||||
(`catalog`, `built-in`, or `kept` when a newer one did not install, with the reason a line above);
|
||||
`x86-64 rootfs:` names the image; `x86-64 client:` the client's ubuntu12 and steamrt64 builds at start
|
||||
`x86-64 rootfs:` names the image; `x86-64 client:` the client's steamrt64 build at start
|
||||
(it may update itself after). The rootfs download reports every tenth of its size as a step. When the
|
||||
web helper's GPU process keeps exiting until Chromium gives up on the GPU (the menus then never appear),
|
||||
one line says so with the exit codes, followed by x86 `glxinfo -B` and `vulkaninfo --summary` through
|
||||
@@ -301,15 +311,24 @@ alone would then read as an installed client.
|
||||
in 51 s.
|
||||
- **Sign-in.** Before either client starts, the newer sign-in (`config/loginusers.vdf`, the login token
|
||||
in `local.vdf`, `AutoLoginUser` and `RememberPassword` in `registry.vdf`) is copied to the other, so
|
||||
signing in or switching accounts in one counts for both. Steam renews its token every few days and
|
||||
refuses an old copy (a 3-day-old one asked for the password), so the newest is copied at every start,
|
||||
never while either client runs, since each rewrites these files. A client that refused its token
|
||||
rewrites `local.vdf` with an empty `ConnectCache`: newest, but copied over the other it would sign
|
||||
that one out too (seen after the x86 client's "Invalid Password"), so it is never the source. The copy keeps
|
||||
the time stamp, so both match until one client signs in again. The rest of `registry.vdf`
|
||||
(language, skin) stays each client's.
|
||||
signing in or switching accounts in one counts for both; never while either client runs, since each
|
||||
rewrites these files. A client that refused its token rewrites `local.vdf` with an empty
|
||||
`ConnectCache`, so it is never the source. The copy keeps the time stamp, so both match until one
|
||||
client signs in again. The rest of `registry.vdf` (language, skin) stays each client's.
|
||||
- **Account IDs.** A client logs on with the token only once it knows the account's SteamID, which it
|
||||
takes from `Accounts` in `config/config.vdf`. The x86 client's `config.vdf` is made by this script
|
||||
without one, so it logged on as `[U:1:0]` with the arm64 token and Steam answered "Invalid Password"
|
||||
(and the client then dropped the token and `AutoLoginUser`): every switch asked for the password.
|
||||
Each start adds the accounts either client knows to the other's `Accounts`, and an `AutoLoginUser` or
|
||||
`RememberPassword` one client has and the other lacks. Device: the x86 client signed in with the
|
||||
arm64 token (`RecvMsgClientLogOnResponse ... 'OK'`).
|
||||
- **App side.** The library, art picker, grid sync and boot movie follow the running client, else the
|
||||
selected one; a changed client applies from the next session. Offline Mode is written to both
|
||||
selected one; a changed client applies from the next session. A game is listed once per client: an added game
|
||||
whose folder a Steam install already covers (its `DroidDeck-<appid>` import) is shown as that Steam
|
||||
game only, and a client switch drops the old client's imported entries with its Steam games. In
|
||||
Steam, an import stays one when its ownership cannot be confirmed that session instead of gaining a
|
||||
shortcut beside it, and only the client's own library folders count as an install (the arm64 card
|
||||
library made the x86 client skip a game's import and its shortcut). Offline Mode is written to both
|
||||
clients' `loginusers.vdf`; until the x86 client's first start its sign-in is the arm64 one. The log
|
||||
redactor learns account names from both clients. The startup movie is staged into the x86 client's
|
||||
config only once its first start has created it; `--prepare` links the arm64 `config/uioverrides`
|
||||
|
||||
@@ -23,7 +23,8 @@ if [[ ! -d "$TOOLCHAIN" && "$NDK_HOST" == linux-aarch64 && -d "$NDK/toolchains/l
|
||||
TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin"
|
||||
fi
|
||||
CC="$TOOLCHAIN/aarch64-linux-android${API}-clang"
|
||||
if [[ ! -x "$CC" ]]; then
|
||||
CC28="$TOOLCHAIN/aarch64-linux-android28-clang"
|
||||
if [[ ! -x "$CC" || ! -x "$CC28" ]]; then
|
||||
echo "Android NDK toolchain not found at $TOOLCHAIN" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -34,8 +35,8 @@ sed -e 's/@PA_MAJOR@/13/g' -e 's/@PA_MINOR@/0/g' -e 's/@PA_API_VERSION@/12/g' -e
|
||||
"$PA_SRC/src/pulse/version.h.in" > "$INC/pulse/version.h"
|
||||
cp "$HERE/config.h" "$HERE/ltdl.h" "$INC/"
|
||||
build_one() {
|
||||
local src=$1 OUT=$2 extra=$3
|
||||
"$CC" -O2 -shared -fPIC -Wall -Wno-unused-parameter -Wno-error=int-conversion -Wno-visibility -DHAVE_CONFIG_H \
|
||||
local src=$1 OUT=$2 extra=$3 cc=${4:-$CC}
|
||||
"$cc" -O2 -shared -fPIC -Wall -Wno-unused-parameter -Wno-error=int-conversion -Wno-visibility -DHAVE_CONFIG_H \
|
||||
-I"$INC" -I"$PA_SRC/src" -I"$HERE" \
|
||||
-o "$OUT" "$HERE/$src" \
|
||||
-L"$LIBS" -l:libpulsecore-13.0.so -l:libpulsecommon-13.0.so -l:libpulse.so $extra
|
||||
@@ -54,3 +55,4 @@ build_one() {
|
||||
ls -l "$OUT"
|
||||
}
|
||||
build_one module-aaudio-sink.c "$OUTDIR/module-aaudio-sink.so" -laaudio
|
||||
build_one ../directaudio/sink/module-directaudio-native-sink.c "$OUTDIR/module-directaudio-native-sink.so" -laaudio "$CC28"
|
||||
@@ -0,0 +1,502 @@
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
Version 2.1, February 1999
|
||||
|
||||
Copyright (C) 1991, 1999 Free Software Foundation, Inc.
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
[This is the first released version of the Lesser GPL. It also counts
|
||||
as the successor of the GNU Library Public License, version 2, hence
|
||||
the version number 2.1.]
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
Licenses are intended to guarantee your freedom to share and change
|
||||
free software--to make sure the software is free for all its users.
|
||||
|
||||
This license, the Lesser General Public License, applies to some
|
||||
specially designated software packages--typically libraries--of the
|
||||
Free Software Foundation and other authors who decide to use it. You
|
||||
can use it too, but we suggest you first think carefully about whether
|
||||
this license or the ordinary General Public License is the better
|
||||
strategy to use in any particular case, based on the explanations below.
|
||||
|
||||
When we speak of free software, we are referring to freedom of use,
|
||||
not price. Our General Public Licenses are designed to make sure that
|
||||
you have the freedom to distribute copies of free software (and charge
|
||||
for this service if you wish); that you receive source code or can get
|
||||
it if you want it; that you can change the software and use pieces of
|
||||
it in new free programs; and that you are informed that you can do
|
||||
these things.
|
||||
|
||||
To protect your rights, we need to make restrictions that forbid
|
||||
distributors to deny you these rights or to ask you to surrender these
|
||||
rights. These restrictions translate to certain responsibilities for
|
||||
you if you distribute copies of the library or if you modify it.
|
||||
|
||||
For example, if you distribute copies of the library, whether gratis
|
||||
or for a fee, you must give the recipients all the rights that we gave
|
||||
you. You must make sure that they, too, receive or can get the source
|
||||
code. If you link other code with the library, you must provide
|
||||
complete object files to the recipients, so that they can relink them
|
||||
with the library after making changes to the library and recompiling
|
||||
it. And you must show them these terms so they know their rights.
|
||||
|
||||
We protect your rights with a two-step method: (1) we copyright the
|
||||
library, and (2) we offer you this license, which gives you legal
|
||||
permission to copy, distribute and/or modify the library.
|
||||
|
||||
To protect each distributor, we want to make it very clear that
|
||||
there is no warranty for the free library. Also, if the library is
|
||||
modified by someone else and passed on, the recipients should know
|
||||
that what they have is not the original version, so that the original
|
||||
author's reputation will not be affected by problems that might be
|
||||
introduced by others.
|
||||
|
||||
Finally, software patents pose a constant threat to the existence of
|
||||
any free program. We wish to make sure that a company cannot
|
||||
effectively restrict the users of a free program by obtaining a
|
||||
restrictive license from a patent holder. Therefore, we insist that
|
||||
any patent license obtained for a version of the library must be
|
||||
consistent with the full freedom of use specified in this license.
|
||||
|
||||
Most GNU software, including some libraries, is covered by the
|
||||
ordinary GNU General Public License. This license, the GNU Lesser
|
||||
General Public License, applies to certain designated libraries, and
|
||||
is quite different from the ordinary General Public License. We use
|
||||
this license for certain libraries in order to permit linking those
|
||||
libraries into non-free programs.
|
||||
|
||||
When a program is linked with a library, whether statically or using
|
||||
a shared library, the combination of the two is legally speaking a
|
||||
combined work, a derivative of the original library. The ordinary
|
||||
General Public License therefore permits such linking only if the
|
||||
entire combination fits its criteria of freedom. The Lesser General
|
||||
Public License permits more lax criteria for linking other code with
|
||||
the library.
|
||||
|
||||
We call this license the "Lesser" General Public License because it
|
||||
does Less to protect the user's freedom than the ordinary General
|
||||
Public License. It also provides other free software developers Less
|
||||
of an advantage over competing non-free programs. These disadvantages
|
||||
are the reason we use the ordinary General Public License for many
|
||||
libraries. However, the Lesser license provides advantages in certain
|
||||
special circumstances.
|
||||
|
||||
For example, on rare occasions, there may be a special need to
|
||||
encourage the widest possible use of a certain library, so that it becomes
|
||||
a de-facto standard. To achieve this, non-free programs must be
|
||||
allowed to use the library. A more frequent case is that a free
|
||||
library does the same job as widely used non-free libraries. In this
|
||||
case, there is little to gain by limiting the free library to free
|
||||
software only, so we use the Lesser General Public License.
|
||||
|
||||
In other cases, permission to use a particular library in non-free
|
||||
programs enables a greater number of people to use a large body of
|
||||
free software. For example, permission to use the GNU C Library in
|
||||
non-free programs enables many more people to use the whole GNU
|
||||
operating system, as well as its variant, the GNU/Linux operating
|
||||
system.
|
||||
|
||||
Although the Lesser General Public License is Less protective of the
|
||||
users' freedom, it does ensure that the user of a program that is
|
||||
linked with the Library has the freedom and the wherewithal to run
|
||||
that program using a modified version of the Library.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow. Pay close attention to the difference between a
|
||||
"work based on the library" and a "work that uses the library". The
|
||||
former contains code derived from the library, whereas the latter must
|
||||
be combined with the library in order to run.
|
||||
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License Agreement applies to any software library or other
|
||||
program which contains a notice placed by the copyright holder or
|
||||
other authorized party saying it may be distributed under the terms of
|
||||
this Lesser General Public License (also called "this License").
|
||||
Each licensee is addressed as "you".
|
||||
|
||||
A "library" means a collection of software functions and/or data
|
||||
prepared so as to be conveniently linked with application programs
|
||||
(which use some of those functions and data) to form executables.
|
||||
|
||||
The "Library", below, refers to any such software library or work
|
||||
which has been distributed under these terms. A "work based on the
|
||||
Library" means either the Library or any derivative work under
|
||||
copyright law: that is to say, a work containing the Library or a
|
||||
portion of it, either verbatim or with modifications and/or translated
|
||||
straightforwardly into another language. (Hereinafter, translation is
|
||||
included without limitation in the term "modification".)
|
||||
|
||||
"Source code" for a work means the preferred form of the work for
|
||||
making modifications to it. For a library, complete source code means
|
||||
all the source code for all modules it contains, plus any associated
|
||||
interface definition files, plus the scripts used to control compilation
|
||||
and installation of the library.
|
||||
|
||||
Activities other than copying, distribution and modification are not
|
||||
covered by this License; they are outside its scope. The act of
|
||||
running a program using the Library is not restricted, and output from
|
||||
such a program is covered only if its contents constitute a work based
|
||||
on the Library (independent of the use of the Library in a tool for
|
||||
writing it). Whether that is true depends on what the Library does
|
||||
and what the program that uses the Library does.
|
||||
|
||||
1. You may copy and distribute verbatim copies of the Library's
|
||||
complete source code as you receive it, in any medium, provided that
|
||||
you conspicuously and appropriately publish on each copy an
|
||||
appropriate copyright notice and disclaimer of warranty; keep intact
|
||||
all the notices that refer to this License and to the absence of any
|
||||
warranty; and distribute a copy of this License along with the
|
||||
Library.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy,
|
||||
and you may at your option offer warranty protection in exchange for a
|
||||
fee.
|
||||
|
||||
2. You may modify your copy or copies of the Library or any portion
|
||||
of it, thus forming a work based on the Library, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
above, provided that you also meet all of these conditions:
|
||||
|
||||
a) The modified work must itself be a software library.
|
||||
|
||||
b) You must cause the files modified to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
|
||||
c) You must cause the whole of the work to be licensed at no
|
||||
charge to all third parties under the terms of this License.
|
||||
|
||||
d) If a facility in the modified Library refers to a function or a
|
||||
table of data to be supplied by an application program that uses
|
||||
the facility, other than as an argument passed when the facility
|
||||
is invoked, then you must make a good faith effort to ensure that,
|
||||
in the event an application does not supply such function or
|
||||
table, the facility still operates, and performs whatever part of
|
||||
its purpose remains meaningful.
|
||||
|
||||
(For example, a function in a library to compute square roots has
|
||||
a purpose that is entirely well-defined independent of the
|
||||
application. Therefore, Subsection 2d requires that any
|
||||
application-supplied function or table used by this function must
|
||||
be optional: if the application does not supply it, the square
|
||||
root function must still compute square roots.)
|
||||
|
||||
These requirements apply to the modified work as a whole. If
|
||||
identifiable sections of that work are not derived from the Library,
|
||||
and can be reasonably considered independent and separate works in
|
||||
themselves, then this License, and its terms, do not apply to those
|
||||
sections when you distribute them as separate works. But when you
|
||||
distribute the same sections as part of a whole which is a work based
|
||||
on the Library, the distribution of the whole must be on the terms of
|
||||
this License, whose permissions for other licensees extend to the
|
||||
entire whole, and thus to each and every part regardless of who wrote
|
||||
it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest
|
||||
your rights to work written entirely by you; rather, the intent is to
|
||||
exercise the right to control the distribution of derivative or
|
||||
collective works based on the Library.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Library
|
||||
with the Library (or with a work based on the Library) on a volume of
|
||||
a storage or distribution medium does not bring the other work under
|
||||
the scope of this License.
|
||||
|
||||
3. You may opt to apply the terms of the ordinary GNU General Public
|
||||
License instead of this License to a given copy of the Library. To do
|
||||
this, you must alter all the notices that refer to this License, so
|
||||
that they refer to the ordinary GNU General Public License, version 2,
|
||||
instead of to this License. (If a newer version than version 2 of the
|
||||
ordinary GNU General Public License has appeared, then you can specify
|
||||
that version instead if you wish.) Do not make any other change in
|
||||
these notices.
|
||||
|
||||
Once this change is made in a given copy, it is irreversible for
|
||||
that copy, so the ordinary GNU General Public License applies to all
|
||||
subsequent copies and derivative works made from that copy.
|
||||
|
||||
This option is useful when you wish to copy part of the code of
|
||||
the Library into a program that is not a library.
|
||||
|
||||
4. You may copy and distribute the Library (or a portion or
|
||||
derivative of it, under Section 2) in object code or executable form
|
||||
under the terms of Sections 1 and 2 above provided that you accompany
|
||||
it with the complete corresponding machine-readable source code, which
|
||||
must be distributed under the terms of Sections 1 and 2 above on a
|
||||
medium customarily used for software interchange.
|
||||
|
||||
If distribution of object code is made by offering access to copy
|
||||
from a designated place, then offering equivalent access to copy the
|
||||
source code from the same place satisfies the requirement to
|
||||
distribute the source code, even though third parties are not
|
||||
compelled to copy the source along with the object code.
|
||||
|
||||
5. A program that contains no derivative of any portion of the
|
||||
Library, but is designed to work with the Library by being compiled or
|
||||
linked with it, is called a "work that uses the Library". Such a
|
||||
work, in isolation, is not a derivative work of the Library, and
|
||||
therefore falls outside the scope of this License.
|
||||
|
||||
However, linking a "work that uses the Library" with the Library
|
||||
creates an executable that is a derivative of the Library (because it
|
||||
contains portions of the Library), rather than a "work that uses the
|
||||
library". The executable is therefore covered by this License.
|
||||
Section 6 states terms for distribution of such executables.
|
||||
|
||||
When a "work that uses the Library" uses material from a header file
|
||||
that is part of the Library, the object code for the work may be a
|
||||
derivative work of the Library even though the source code is not.
|
||||
Whether this is true is especially significant if the work can be
|
||||
linked without the Library, or if the work is itself a library. The
|
||||
threshold for this to be true is not precisely defined by law.
|
||||
|
||||
If such an object file uses only numerical parameters, data
|
||||
structure layouts and accessors, and small macros and small inline
|
||||
functions (ten lines or less in length), then the use of the object
|
||||
file is unrestricted, regardless of whether it is legally a derivative
|
||||
work. (Executables containing this object code plus portions of the
|
||||
Library will still fall under Section 6.)
|
||||
|
||||
Otherwise, if the work is a derivative of the Library, you may
|
||||
distribute the object code for the work under the terms of Section 6.
|
||||
Any executables containing that work also fall under Section 6,
|
||||
whether or not they are linked directly with the Library itself.
|
||||
|
||||
6. As an exception to the Sections above, you may also combine or
|
||||
link a "work that uses the Library" with the Library to produce a
|
||||
work containing portions of the Library, and distribute that work
|
||||
under terms of your choice, provided that the terms permit
|
||||
modification of the work for the customer's own use and reverse
|
||||
engineering for debugging such modifications.
|
||||
|
||||
You must give prominent notice with each copy of the work that the
|
||||
Library is used in it and that the Library and its use are covered by
|
||||
this License. You must supply a copy of this License. If the work
|
||||
during execution displays copyright notices, you must include the
|
||||
copyright notice for the Library among them, as well as a reference
|
||||
directing the user to the copy of this License. Also, you must do one
|
||||
of these things:
|
||||
|
||||
a) Accompany the work with the complete corresponding
|
||||
machine-readable source code for the Library including whatever
|
||||
changes were used in the work (which must be distributed under
|
||||
Sections 1 and 2 above); and, if the work is an executable linked
|
||||
with the Library, with the complete machine-readable "work that
|
||||
uses the Library", as object code and/or source code, so that the
|
||||
user can modify the Library and then relink to produce a modified
|
||||
executable containing the modified Library. (It is understood
|
||||
that the user who changes the contents of definitions files in the
|
||||
Library will not necessarily be able to recompile the application
|
||||
to use the modified definitions.)
|
||||
|
||||
b) Use a suitable shared library mechanism for linking with the
|
||||
Library. A suitable mechanism is one that (1) uses at run time a
|
||||
copy of the library already present on the user's computer system,
|
||||
rather than copying library functions into the executable, and (2)
|
||||
will operate properly with a modified version of the library, if
|
||||
the user installs one, as long as the modified version is
|
||||
interface-compatible with the version that the work was made with.
|
||||
|
||||
c) Accompany the work with a written offer, valid for at
|
||||
least three years, to give the same user the materials
|
||||
specified in Subsection 6a, above, for a charge no more
|
||||
than the cost of performing this distribution.
|
||||
|
||||
d) If distribution of the work is made by offering access to copy
|
||||
from a designated place, offer equivalent access to copy the above
|
||||
specified materials from the same place.
|
||||
|
||||
e) Verify that the user has already received a copy of these
|
||||
materials or that you have already sent this user a copy.
|
||||
|
||||
For an executable, the required form of the "work that uses the
|
||||
Library" must include any data and utility programs needed for
|
||||
reproducing the executable from it. However, as a special exception,
|
||||
the materials to be distributed need not include anything that is
|
||||
normally distributed (in either source or binary form) with the major
|
||||
components (compiler, kernel, and so on) of the operating system on
|
||||
which the executable runs, unless that component itself accompanies
|
||||
the executable.
|
||||
|
||||
It may happen that this requirement contradicts the license
|
||||
restrictions of other proprietary libraries that do not normally
|
||||
accompany the operating system. Such a contradiction means you cannot
|
||||
use both them and the Library together in an executable that you
|
||||
distribute.
|
||||
|
||||
7. You may place library facilities that are a work based on the
|
||||
Library side-by-side in a single library together with other library
|
||||
facilities not covered by this License, and distribute such a combined
|
||||
library, provided that the separate distribution of the work based on
|
||||
the Library and of the other library facilities is otherwise
|
||||
permitted, and provided that you do these two things:
|
||||
|
||||
a) Accompany the combined library with a copy of the same work
|
||||
based on the Library, uncombined with any other library
|
||||
facilities. This must be distributed under the terms of the
|
||||
Sections above.
|
||||
|
||||
b) Give prominent notice with the combined library of the fact
|
||||
that part of it is a work based on the Library, and explaining
|
||||
where to find the accompanying uncombined form of the same work.
|
||||
|
||||
8. You may not copy, modify, sublicense, link with, or distribute
|
||||
the Library except as expressly provided under this License. Any
|
||||
attempt otherwise to copy, modify, sublicense, link with, or
|
||||
distribute the Library is void, and will automatically terminate your
|
||||
rights under this License. However, parties who have received copies,
|
||||
or rights, from you under this License will not have their licenses
|
||||
terminated so long as such parties remain in full compliance.
|
||||
|
||||
9. You are not required to accept this License, since you have not
|
||||
signed it. However, nothing else grants you permission to modify or
|
||||
distribute the Library or its derivative works. These actions are
|
||||
prohibited by law if you do not accept this License. Therefore, by
|
||||
modifying or distributing the Library (or any work based on the
|
||||
Library), you indicate your acceptance of this License to do so, and
|
||||
all its terms and conditions for copying, distributing or modifying
|
||||
the Library or works based on it.
|
||||
|
||||
10. Each time you redistribute the Library (or any work based on the
|
||||
Library), the recipient automatically receives a license from the
|
||||
original licensor to copy, distribute, link with or modify the Library
|
||||
subject to these terms and conditions. You may not impose any further
|
||||
restrictions on the recipients' exercise of the rights granted herein.
|
||||
You are not responsible for enforcing compliance by third parties with
|
||||
this License.
|
||||
|
||||
11. If, as a consequence of a court judgment or allegation of patent
|
||||
infringement or for any other reason (not limited to patent issues),
|
||||
conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot
|
||||
distribute so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you
|
||||
may not distribute the Library at all. For example, if a patent
|
||||
license would not permit royalty-free redistribution of the Library by
|
||||
all those who receive copies directly or indirectly through you, then
|
||||
the only way you could satisfy both it and this License would be to
|
||||
refrain entirely from distribution of the Library.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under any
|
||||
particular circumstance, the balance of the section is intended to apply,
|
||||
and the section as a whole is intended to apply in other circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any
|
||||
patents or other property right claims or to contest validity of any
|
||||
such claims; this section has the sole purpose of protecting the
|
||||
integrity of the free software distribution system which is
|
||||
implemented by public license practices. Many people have made
|
||||
generous contributions to the wide range of software distributed
|
||||
through that system in reliance on consistent application of that
|
||||
system; it is up to the author/donor to decide if he or she is willing
|
||||
to distribute software through any other system and a licensee cannot
|
||||
impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to
|
||||
be a consequence of the rest of this License.
|
||||
|
||||
12. If the distribution and/or use of the Library is restricted in
|
||||
certain countries either by patents or by copyrighted interfaces, the
|
||||
original copyright holder who places the Library under this License may add
|
||||
an explicit geographical distribution limitation excluding those countries,
|
||||
so that distribution is permitted only in or among countries not thus
|
||||
excluded. In such case, this License incorporates the limitation as if
|
||||
written in the body of this License.
|
||||
|
||||
13. The Free Software Foundation may publish revised and/or new
|
||||
versions of the Lesser General Public License from time to time.
|
||||
Such new versions will be similar in spirit to the present version,
|
||||
but may differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Library
|
||||
specifies a version number of this License which applies to it and
|
||||
"any later version", you have the option of following the terms and
|
||||
conditions either of that version or of any later version published by
|
||||
the Free Software Foundation. If the Library does not specify a
|
||||
license version number, you may choose any version ever published by
|
||||
the Free Software Foundation.
|
||||
|
||||
14. If you wish to incorporate parts of the Library into other free
|
||||
programs whose distribution conditions are incompatible with these,
|
||||
write to the author to ask for permission. For software which is
|
||||
copyrighted by the Free Software Foundation, write to the Free
|
||||
Software Foundation; we sometimes make exceptions for this. Our
|
||||
decision will be guided by the two goals of preserving the free status
|
||||
of all derivatives of our free software and of promoting the sharing
|
||||
and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
|
||||
WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
|
||||
EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
|
||||
OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
|
||||
KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
|
||||
LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
|
||||
THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
|
||||
WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
|
||||
AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
|
||||
FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
|
||||
CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
|
||||
LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
|
||||
RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
|
||||
FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
|
||||
SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
|
||||
DAMAGES.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Libraries
|
||||
|
||||
If you develop a new library, and you want it to be of the greatest
|
||||
possible use to the public, we recommend making it free software that
|
||||
everyone can redistribute and change. You can do so by permitting
|
||||
redistribution under these terms (or, alternatively, under the terms of the
|
||||
ordinary General Public License).
|
||||
|
||||
To apply these terms, attach the following notices to the library. It is
|
||||
safest to attach them to the start of each source file to most effectively
|
||||
convey the exclusion of warranty; and each file should have at least the
|
||||
"copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the library's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This library is free software; you can redistribute it and/or
|
||||
modify it under the terms of the GNU Lesser General Public
|
||||
License as published by the Free Software Foundation; either
|
||||
version 2.1 of the License, or (at your option) any later version.
|
||||
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public
|
||||
License along with this library; if not, write to the Free Software
|
||||
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your
|
||||
school, if any, to sign a "copyright disclaimer" for the library, if
|
||||
necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the
|
||||
library `Frob' (a library for tweaking knobs) written by James Random Hacker.
|
||||
|
||||
<signature of Ty Coon>, 1 April 1990
|
||||
Ty Coon, President of Vice
|
||||
|
||||
That's all there is to it!
|
||||
@@ -0,0 +1,209 @@
|
||||
/* DirectAudio relay - the contract between the driver and the relay helper.
|
||||
*
|
||||
* Copyright 2026 The412Banner
|
||||
*
|
||||
* This library is free software; you can redistribute it and/or
|
||||
* modify it under the terms of the GNU Lesser General Public
|
||||
* License as published by the Free Software Foundation; either
|
||||
* version 2.1 of the License, or (at your option) any later version.
|
||||
*
|
||||
* WHY THIS EXISTS. The driver's whole premise is an in-process AAudio client:
|
||||
* the guest's WASAPI buffers are mixed and handed to Android's audio server
|
||||
* from inside the game, no daemon in the path. That is only possible when the
|
||||
* game process is a bionic process. Under a glibc runtime (the Linux Steam
|
||||
* client, where the game runs inside a proot'd Arch rootfs on Valve's own
|
||||
* ARM64 Proton) libaaudio cannot be loaded at all: it is a bionic library and
|
||||
* the rootfs's dynamic linker does not know how to bring it into a glibc
|
||||
* process. The game side therefore has to stop at a boundary it CAN cross, and
|
||||
* the only thing both worlds agree on is the kernel: a unix socket, a memfd and
|
||||
* a futex word.
|
||||
*
|
||||
* SHAPE. One helper process (directaudio-relay, bionic, links libaaudio) runs
|
||||
* on the Android side and listens on a unix socket. Each game process that
|
||||
* needs audio connects once, sends its launch config (the same knobs the
|
||||
* in-process build reads from BANNER_AUDIO_DIRECT_*), and gets back one or two
|
||||
* shared rings (memfds passed with SCM_RIGHTS):
|
||||
*
|
||||
* render ring game (producer) -> helper (consumer) -> AAudio OUTPUT
|
||||
* capture ring helper (producer, AAudio INPUT) -> game (consumer)
|
||||
*
|
||||
* The driver keeps its mixer and its per-voice WASAPI rings exactly as in the
|
||||
* in-process build; what used to be the AAudio data callback is now a pump
|
||||
* thread that keeps the render ring topped up, and the capture callback is a
|
||||
* pump that drains the capture ring into the capture voices. Everything that
|
||||
* needs an AAudioStream (open, adaptive buffer growth and decay, route-change
|
||||
* reopen, the stalled-callback watchdog, the live-config mailbox) moves to the
|
||||
* helper unchanged in spirit.
|
||||
*
|
||||
* COST. One extra hand-off: the ring. The helper's callback pulls a burst from
|
||||
* the ring, so the ring must hold at least one burst at every callback; the
|
||||
* driver keeps DA_RING_TARGET_BURSTS bursts queued and the helper raises that
|
||||
* target when a callback finds the ring short. Everything else - the AAudio
|
||||
* buffer, AudioFlinger's own latency - is what the in-process build pays too.
|
||||
*
|
||||
* WIRE FORMAT. Fixed-width, native-endian, same-machine only. The two sides are
|
||||
* different libcs but the same CPU, so no marshalling beyond fixed-size structs.
|
||||
* Bump DA_RELAY_VERSION on any layout change; the helper refuses a mismatch.
|
||||
*/
|
||||
#ifndef DA_RELAY_PROTO_H
|
||||
#define DA_RELAY_PROTO_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#define DA_RELAY_VERSION 1
|
||||
#define DA_RING_MAGIC 0x44415247u /* 'DARG' */
|
||||
#define DA_HELLO_MAGIC 0x44414831u /* 'DAH1' */
|
||||
#define DA_ACK_MAGIC 0x44414131u /* 'DAA1' */
|
||||
|
||||
/* Socket path. The driver reads BANNER_AUDIO_DIRECT_RELAY; when unset it falls
|
||||
* back to $XDG_RUNTIME_DIR/DA_RELAY_DEFAULT_NAME, which a host that already
|
||||
* hands its runtime dir into the session gets for free. */
|
||||
#define DA_RELAY_ENV "BANNER_AUDIO_DIRECT_RELAY"
|
||||
#define DA_RELAY_DEFAULT_NAME "directaudio-relay"
|
||||
|
||||
/* Both rings carry float32 interleaved stereo. The render ring is always
|
||||
* 48 kHz (the mixer's output rate); the capture ring runs at whatever rate the
|
||||
* input stream was granted, published in da_ring.rate, and the driver resamples
|
||||
* per voice as the in-process build already does. */
|
||||
#define DA_RING_RATE 48000
|
||||
#define DA_RING_CHANNELS 2
|
||||
|
||||
/* Render ring sizing. Capacity is the ceiling adaptive growth can reach; the
|
||||
* initial target is what the driver keeps queued. Two bursts: the helper's
|
||||
* callback consumes one burst and wakes the pump, which then has a full burst
|
||||
* period to refill before the next callback. */
|
||||
#define DA_RING_CAP_MS 250
|
||||
#define DA_RING_TARGET_BURSTS 2
|
||||
#define DA_RING_MAX_TARGET_MS 100
|
||||
|
||||
/* Capture ring: 200 ms is far more than a game ever leaves queued; on overrun
|
||||
* the helper drops the newest block, matching the in-process "guest is not
|
||||
* draining" behaviour of dropping data rather than blocking the mic thread. */
|
||||
#define DA_CAPTURE_RING_MS 200
|
||||
|
||||
enum da_ring_state
|
||||
{
|
||||
DA_RING_OPENING = 0,
|
||||
DA_RING_PLAYING = 1,
|
||||
DA_RING_ERROR = 2,
|
||||
};
|
||||
|
||||
struct da_ring
|
||||
{
|
||||
uint32_t magic;
|
||||
uint32_t version;
|
||||
uint32_t cap_frames; /* ring capacity in frames */
|
||||
uint32_t rate; /* frames per second of the data in this ring */
|
||||
uint32_t channels; /* DA_RING_CHANNELS */
|
||||
uint32_t _pad0;
|
||||
|
||||
/* Single producer, single consumer; monotonic frame totals, wrap-safe with
|
||||
* unsigned arithmetic. Producer writes widx with RELEASE after the data;
|
||||
* consumer writes ridx with RELEASE after it has copied the data out. */
|
||||
volatile uint32_t widx;
|
||||
volatile uint32_t ridx;
|
||||
|
||||
/* Futex word the CONSUMER of a render ring / PRODUCER of a capture ring
|
||||
* bumps after each block, so the other side can sleep on it instead of
|
||||
* polling. Waiters always use a timeout, so a missed wake only costs one
|
||||
* period. */
|
||||
volatile uint32_t wake;
|
||||
|
||||
volatile uint32_t quit; /* either side: tear down */
|
||||
volatile uint32_t state; /* enum da_ring_state, helper-owned */
|
||||
|
||||
/* Render ring only, helper-owned, read by the driver: */
|
||||
volatile int32_t target_frames; /* keep at least this many queued */
|
||||
volatile int32_t hw_buf_frames; /* AAudio buffer size right now (get_latency) */
|
||||
volatile int32_t hw_burst; /* frames per burst of the output stream */
|
||||
volatile uint32_t underruns; /* callbacks that found fewer than they needed */
|
||||
volatile uint32_t cb_count; /* data callbacks so far (liveness) */
|
||||
uint32_t _pad1[3];
|
||||
|
||||
float data[]; /* cap_frames * channels */
|
||||
};
|
||||
|
||||
static inline size_t da_ring_bytes(uint32_t cap_frames)
|
||||
{
|
||||
return sizeof(struct da_ring) + (size_t)cap_frames * DA_RING_CHANNELS * sizeof(float);
|
||||
}
|
||||
|
||||
static inline uint32_t da_ring_avail(const struct da_ring *r)
|
||||
{
|
||||
uint32_t w = __atomic_load_n(&r->widx, __ATOMIC_ACQUIRE);
|
||||
uint32_t rd = __atomic_load_n(&r->ridx, __ATOMIC_ACQUIRE);
|
||||
return w - rd;
|
||||
}
|
||||
|
||||
static inline uint32_t da_ring_space(const struct da_ring *r)
|
||||
{
|
||||
return r->cap_frames - da_ring_avail(r);
|
||||
}
|
||||
|
||||
/* ---- wire messages ------------------------------------------------------- */
|
||||
|
||||
#define DA_HELLO_MIC 0x01u /* also open an INPUT stream + capture ring */
|
||||
#define DA_HELLO_EXCLUSIVE 0x02u /* AAUDIO_SHARING_MODE_EXCLUSIVE requested */
|
||||
#define DA_HELLO_ADAPTIVE 0x04u
|
||||
#define DA_HELLO_DECAY 0x08u
|
||||
#define DA_HELLO_WATCHDOG 0x10u
|
||||
|
||||
/* First message on a fresh connection, driver -> helper. Mirrors the launch
|
||||
* config the in-process build derives from BANNER_AUDIO_DIRECT_* so a preset
|
||||
* means the same thing under both runtimes. */
|
||||
struct da_hello
|
||||
{
|
||||
uint32_t magic; /* DA_HELLO_MAGIC */
|
||||
uint32_t version; /* DA_RELAY_VERSION */
|
||||
uint32_t flags; /* DA_HELLO_* */
|
||||
int32_t perf; /* 0 NONE, 1 LOW_LATENCY, 2 POWER_SAVING (host convention) */
|
||||
int32_t target_ms; /* AAudio buffer, ms; 0 = use target_frames or the default */
|
||||
int32_t max_ms; /* adaptive ceiling, ms; 0 = max_frames or the default */
|
||||
int32_t target_frames; /* frame forms, used only when the ms forms are 0 */
|
||||
int32_t max_frames;
|
||||
uint32_t stall_ms; /* watchdog: callback silent this long => rebuild */
|
||||
uint32_t quiet_ms; /* decay pacing, see the driver */
|
||||
uint32_t punish_ms;
|
||||
uint32_t max_backoff;
|
||||
int32_t pid; /* the game process, for the log */
|
||||
uint32_t log; /* BANNER_AUDIO_DIRECT_LOG */
|
||||
char runtime_path[256]; /* BANNER_AUDIO_DIRECT_RUNTIME mailbox, "" = none */
|
||||
char name[64]; /* process name, for the log */
|
||||
};
|
||||
|
||||
#define DA_ACK_MIC_FAILED 0x01u /* mic asked for, input stream would not open */
|
||||
|
||||
/* Reply, helper -> driver, followed by SCM_RIGHTS carrying the ring fds:
|
||||
* fd[0] = render ring, fd[1] = capture ring (present iff in_cap_frames > 0). */
|
||||
struct da_ack
|
||||
{
|
||||
uint32_t magic; /* DA_ACK_MAGIC */
|
||||
int32_t status; /* 0 = ok, else an AAudio error code (negative) */
|
||||
int32_t rate; /* output stream rate (48000) */
|
||||
int32_t burst; /* output frames per burst */
|
||||
int32_t buf_frames; /* output buffer size as opened */
|
||||
int32_t out_cap_frames; /* render ring capacity */
|
||||
int32_t in_cap_frames; /* capture ring capacity, 0 = no capture ring */
|
||||
int32_t in_rate; /* rate of the capture ring's data */
|
||||
uint32_t flags; /* DA_ACK_* */
|
||||
uint32_t _pad;
|
||||
};
|
||||
|
||||
/* Subsequent messages, driver -> helper, on the same socket. The helper never
|
||||
* writes after the ack; everything it has to say is in the ring header. EOF on
|
||||
* the socket is how the helper learns the game is gone. */
|
||||
enum da_msg_type
|
||||
{
|
||||
DA_MSG_MIC_START = 1, /* first capture voice started: make the mic hot */
|
||||
DA_MSG_MIC_STOP = 2, /* last capture voice gone: stop (keep open) */
|
||||
DA_MSG_PING = 3,
|
||||
};
|
||||
|
||||
struct da_msg
|
||||
{
|
||||
uint32_t type;
|
||||
int32_t arg;
|
||||
};
|
||||
|
||||
#endif /* DA_RELAY_PROTO_H */
|
||||
@@ -0,0 +1,782 @@
|
||||
/*
|
||||
* module-directaudio-native-sink: a PulseAudio 13 sink that is DirectAudio's AAudio engine,
|
||||
* in-process. For the native Linux Steam client on Android (DroidDeck, Bannerlator's Linux
|
||||
* session), whose sound can only ever reach a PulseAudio daemon: this module is where that
|
||||
* daemon's mixed output meets Android, one step away from AudioFlinger, with the same engine the
|
||||
* game driver has - adaptive device buffer that grows a burst on every xrun, optional decay with a
|
||||
* remembered floor, a primed ring with fades instead of clicks, route-change / error reopen on a
|
||||
* fresh stream, and a stall watchdog.
|
||||
*
|
||||
* It is the relay helper's per-client OUTPUT engine (directaudio-relay.c) with the socket and the
|
||||
* shared-memory ring replaced by a ring in this process, fed by the sink's IO thread exactly as
|
||||
* module-directaudio-sink feeds the relay. Compared with that module there is no second process
|
||||
* and no 40 ms proot safety margin: the ring here only has to cover the IO thread's own
|
||||
* scheduling jitter, so it starts at two bursts and grows only if a callback finds it short.
|
||||
*
|
||||
* The daemon must run on the Android side (the host app's process), not inside proot, for AAudio
|
||||
* to be reachable at all - which is where both host apps already run it.
|
||||
*
|
||||
* Field fixes carried from the relay (MaxsTechReview, Droid-Deck/DroidDeck#338): prime before
|
||||
* playing, fade on an underrun, one consumer per ring across a reopen, a device buffer of at
|
||||
* least two bursts.
|
||||
*
|
||||
* SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
*/
|
||||
#ifdef HAVE_CONFIG_H
|
||||
#include <config.h>
|
||||
#endif
|
||||
|
||||
#include <errno.h>
|
||||
#include <pthread.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/resource.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <aaudio/AAudio.h>
|
||||
|
||||
#include <pulse/rtclock.h>
|
||||
#include <pulse/timeval.h>
|
||||
#include <pulse/util.h>
|
||||
#include <pulse/xmalloc.h>
|
||||
|
||||
#include <pulsecore/core-util.h>
|
||||
#include <pulsecore/log.h>
|
||||
#include <pulsecore/macro.h>
|
||||
#include <pulsecore/modargs.h>
|
||||
#include <pulsecore/module.h>
|
||||
#include <pulsecore/rtpoll.h>
|
||||
#include <pulsecore/sink.h>
|
||||
#include <pulsecore/thread-mq.h>
|
||||
#include <pulsecore/thread.h>
|
||||
|
||||
#include "da_relay_proto.h" /* struct da_ring + the ring helpers; nothing of the socket protocol */
|
||||
|
||||
PA_MODULE_AUTHOR("The412Banner");
|
||||
PA_MODULE_DESCRIPTION("Android audio output: DirectAudio's AAudio engine, in-process");
|
||||
PA_MODULE_VERSION(PACKAGE_VERSION);
|
||||
PA_MODULE_LOAD_ONCE(false);
|
||||
PA_MODULE_USAGE(
|
||||
"sink_name=<name for the sink> "
|
||||
"sink_properties=<properties for the sink> "
|
||||
"volume=<initial volume, linear, 1.0 = full> "
|
||||
"performance_mode=<0 none, 1 low latency, 2 power saving> "
|
||||
"buffer_ms=<device buffer to start with, ms; rounded up to whole bursts, never below two> "
|
||||
"max_ms=<largest the device buffer may grow to, ms> "
|
||||
"ring_ms=<queue kept ahead of the device, ms; never below two bursts> "
|
||||
"adaptive=<grow the buffers after underruns: 0 or 1> "
|
||||
"decay=<shrink the device buffer again after a quiet spell: 0 or 1> "
|
||||
"limiter=<soft-knee limiter on the way out: 0 or 1> "
|
||||
"watchdog=<rebuild the stream when its callback stalls: 0 or 1>");
|
||||
|
||||
#define DEFAULT_SINK_NAME "DirectAudio"
|
||||
#define STATS_EVERY_USEC (30 * PA_USEC_PER_SEC)
|
||||
#define WATCHDOG_TICK_USEC (1 * PA_USEC_PER_SEC)
|
||||
#define AUDIO_NICE (-16)
|
||||
|
||||
/* ---- tunables: the in-process driver's, same defaults ----------------------- */
|
||||
#define DA_KNEE 0.75f
|
||||
#define DA_DEFAULT_MS 12
|
||||
#define DA_DEFAULT_MAX_MS 100
|
||||
#define DA_DEFAULT_RING_MS 12
|
||||
#define DA_RING_CAP_MS_LOCAL 250
|
||||
#define DA_FREEZE_GAP_NS 500000000ull
|
||||
#define DA_CB_STALL_NS 1000000000ull
|
||||
#define DA_DECAY_QUIET_NS 10000000000ull
|
||||
#define DA_DECAY_PUNISH_NS 5000000000ull
|
||||
#define DA_DECAY_MAX_BACKOFF 32
|
||||
#define DA_MIN_BUFFER_BURSTS 2
|
||||
#define DA_FADE_FRAMES 64
|
||||
|
||||
static const char* const valid_modargs[] = {
|
||||
"sink_name", "sink_properties", "volume", "performance_mode", "buffer_ms", "max_ms", "ring_ms",
|
||||
"adaptive", "decay", "limiter", "watchdog", NULL
|
||||
};
|
||||
|
||||
struct userdata {
|
||||
pa_core *core;
|
||||
pa_module *module;
|
||||
pa_sink *sink;
|
||||
|
||||
pa_thread *thread;
|
||||
pa_thread_mq thread_mq;
|
||||
pa_rtpoll *rtpoll;
|
||||
|
||||
pa_sample_spec ss;
|
||||
size_t frame_size;
|
||||
|
||||
/* configuration */
|
||||
aaudio_performance_mode_t perf;
|
||||
int32_t target_ms, max_ms, ring_ms;
|
||||
bool adaptive, decay, limiter, watchdog_on;
|
||||
|
||||
/* the ring: this process's, same layout as the relay's so the engine is shared */
|
||||
struct da_ring *ring;
|
||||
int32_t burst;
|
||||
int32_t ring_max_target;
|
||||
|
||||
/* the one OUTPUT stream and its adaptive state (the relay's struct client, minus the socket) */
|
||||
AAudioStream *aq;
|
||||
pthread_mutex_t lock;
|
||||
int32_t last_xrun;
|
||||
uint64_t last_cb_ns, last_xrun_ns, last_decay_ns, wd_last_tick_ns;
|
||||
int32_t base_buf_frames, decay_floor;
|
||||
unsigned int decay_backoff, cb_count, wd_last_cb;
|
||||
int reopen_running, reopen_redo;
|
||||
int consuming, primed, starved;
|
||||
volatile int dead;
|
||||
|
||||
/* what the IO thread last logged, so engine changes made on the AAudio thread get a line */
|
||||
int32_t seen_target, seen_hw_buf;
|
||||
uint32_t seen_underruns;
|
||||
pa_usec_t next_watchdog, settled_at, stats_at;
|
||||
unsigned int opens;
|
||||
};
|
||||
|
||||
static uint64_t now_ns(void) {
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||
return (uint64_t) ts.tv_sec * 1000000000ull + (uint64_t) ts.tv_nsec;
|
||||
}
|
||||
|
||||
static void request_reopen(struct userdata *u, const char *why);
|
||||
|
||||
/* ---- the engine: the relay's out_cb / open_output / reopen / watchdog, on a local ring ---- */
|
||||
|
||||
static void fade(float *p, uint32_t frames, int in) {
|
||||
uint32_t i, ch;
|
||||
|
||||
for (i = 0; i < frames; i++) {
|
||||
float g = (float) (in ? i + 1 : frames - i) / (float) (frames + 1);
|
||||
|
||||
for (ch = 0; ch < DA_RING_CHANNELS; ch++)
|
||||
p[(size_t) i * DA_RING_CHANNELS + ch] *= g;
|
||||
}
|
||||
}
|
||||
|
||||
/* The driver's soft-knee limiter: below DA_KNEE the signal is untouched, so ordinary material is
|
||||
* bit-identical; above it a smooth knee folds the overshoot back under full scale. */
|
||||
static void limit(float *p, uint32_t samples) {
|
||||
uint32_t i;
|
||||
|
||||
for (i = 0; i < samples; i++) {
|
||||
float x = p[i], a = x < 0.0f ? -x : x;
|
||||
|
||||
if (a > DA_KNEE) {
|
||||
float t = (a - DA_KNEE) / (1.0f - DA_KNEE);
|
||||
float y = DA_KNEE + (1.0f - DA_KNEE) * (t / (1.0f + t));
|
||||
|
||||
p[i] = x < 0.0f ? -y : y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static aaudio_data_callback_result_t out_cb(AAudioStream *aq, void *user, void *audioData, int32_t numFrames) {
|
||||
struct userdata *u = user;
|
||||
struct da_ring *r = u->ring;
|
||||
float *out = audioData;
|
||||
AAudioStream *live = __atomic_load_n(&u->aq, __ATOMIC_SEQ_CST);
|
||||
uint32_t cap = r->cap_frames;
|
||||
uint32_t frames = (uint32_t) numFrames;
|
||||
uint32_t ridx, avail, n = 0, i;
|
||||
int fade_in = 0, short_read = 0, starving = 0;
|
||||
uint64_t now;
|
||||
|
||||
/* Only the promoted stream reads the ring; an outgoing one during a reopen plays silence. */
|
||||
if ((live && aq != live) || __atomic_exchange_n(&u->consuming, 1, __ATOMIC_ACQUIRE)) {
|
||||
memset(out, 0, (size_t) frames * DA_RING_CHANNELS * sizeof(float));
|
||||
return AAUDIO_CALLBACK_RESULT_CONTINUE;
|
||||
}
|
||||
|
||||
ridx = __atomic_load_n(&r->ridx, __ATOMIC_ACQUIRE);
|
||||
avail = __atomic_load_n(&r->widx, __ATOMIC_ACQUIRE) - ridx;
|
||||
if (!u->primed) {
|
||||
uint32_t start = (uint32_t) __atomic_load_n(&r->target_frames, __ATOMIC_ACQUIRE);
|
||||
|
||||
if (start < frames) start = frames;
|
||||
if (start > cap) start = cap;
|
||||
u->primed = fade_in = avail >= start;
|
||||
if (u->primed) u->starved = 0;
|
||||
else starving = u->starved;
|
||||
}
|
||||
if (u->primed) {
|
||||
uint32_t slot = ridx % cap, first;
|
||||
|
||||
n = frames < avail ? frames : avail;
|
||||
first = cap - slot < n ? cap - slot : n;
|
||||
memcpy(out, &r->data[(size_t) slot * DA_RING_CHANNELS], (size_t) first * DA_RING_CHANNELS * sizeof(float));
|
||||
if (n > first)
|
||||
memcpy(out + (size_t) first * DA_RING_CHANNELS, r->data, (size_t) (n - first) * DA_RING_CHANNELS * sizeof(float));
|
||||
if (u->limiter)
|
||||
limit(out, n * DA_RING_CHANNELS);
|
||||
if (fade_in)
|
||||
fade(out, n < DA_FADE_FRAMES ? n : DA_FADE_FRAMES, 1);
|
||||
if (n < frames) {
|
||||
uint32_t tail = n < DA_FADE_FRAMES ? n : DA_FADE_FRAMES;
|
||||
|
||||
fade(out + (size_t) (n - tail) * DA_RING_CHANNELS, tail, 0);
|
||||
u->primed = 0;
|
||||
u->starved = 1;
|
||||
short_read = 1;
|
||||
}
|
||||
__atomic_store_n(&r->ridx, ridx + n, __ATOMIC_RELEASE);
|
||||
}
|
||||
for (i = n * DA_RING_CHANNELS; i < frames * DA_RING_CHANNELS; i++)
|
||||
out[i] = 0.0f;
|
||||
__atomic_store_n(&u->consuming, 0, __ATOMIC_RELEASE);
|
||||
|
||||
now = now_ns();
|
||||
{
|
||||
uint64_t prev = __atomic_exchange_n(&u->last_cb_ns, now, __ATOMIC_SEQ_CST);
|
||||
|
||||
if (prev && now - prev > DA_FREEZE_GAP_NS) {
|
||||
u->last_xrun = AAudioStream_getXRunCount(aq);
|
||||
u->last_xrun_ns = now;
|
||||
}
|
||||
}
|
||||
__atomic_add_fetch(&u->cb_count, 1, __ATOMIC_SEQ_CST);
|
||||
__atomic_store_n(&r->cb_count, u->cb_count, __ATOMIC_RELAXED);
|
||||
|
||||
/* The ring's own adaptive step: a short ring means the IO thread did not get scheduled in
|
||||
* time - an xrun one stage earlier - so keep one more burst queued. Only grows. */
|
||||
if (short_read)
|
||||
__atomic_add_fetch(&r->underruns, 1, __ATOMIC_RELAXED);
|
||||
if ((short_read || starving) && u->adaptive) {
|
||||
int32_t burst = AAudioStream_getFramesPerBurst(aq);
|
||||
int32_t cur = __atomic_load_n(&r->target_frames, __ATOMIC_RELAXED);
|
||||
int32_t want = cur + (burst > 0 ? burst : numFrames);
|
||||
|
||||
if (want > u->ring_max_target) want = u->ring_max_target;
|
||||
if (want > cur)
|
||||
__atomic_store_n(&r->target_frames, want, __ATOMIC_RELEASE);
|
||||
}
|
||||
|
||||
/* The device buffer: grow a burst per xrun; decay a burst after a quiet spell, never below
|
||||
* the floor a punished decay taught us. */
|
||||
if (u->adaptive) {
|
||||
int32_t xruns = AAudioStream_getXRunCount(aq);
|
||||
int32_t burst = AAudioStream_getFramesPerBurst(aq);
|
||||
int32_t cur = AAudioStream_getBufferSizeInFrames(aq);
|
||||
|
||||
if (xruns > u->last_xrun) {
|
||||
int32_t capf = AAudioStream_getBufferCapacityInFrames(aq);
|
||||
int32_t want = cur + (burst > 0 ? burst : 1);
|
||||
int32_t max_frames = u->max_ms > 0 ? u->max_ms * DA_RING_RATE / 1000 : 0;
|
||||
|
||||
if (max_frames > 0 && want > max_frames) want = max_frames;
|
||||
if (want > capf) want = capf;
|
||||
if (want > cur) AAudioStream_setBufferSizeInFrames(aq, want);
|
||||
|
||||
if (u->decay && u->last_decay_ns && now - u->last_decay_ns < DA_DECAY_PUNISH_NS) {
|
||||
if (want > u->decay_floor) u->decay_floor = want;
|
||||
if (u->decay_backoff < DA_DECAY_MAX_BACKOFF) u->decay_backoff *= 2;
|
||||
}
|
||||
u->last_xrun = xruns;
|
||||
u->last_xrun_ns = now;
|
||||
__atomic_store_n(&r->hw_buf_frames, AAudioStream_getBufferSizeInFrames(aq), __ATOMIC_RELAXED);
|
||||
} else if (u->decay && burst > 0) {
|
||||
uint64_t quiet = DA_DECAY_QUIET_NS * u->decay_backoff;
|
||||
int32_t floor = u->base_buf_frames;
|
||||
|
||||
if (u->decay_floor > floor) floor = u->decay_floor;
|
||||
if (cur > floor && u->last_xrun_ns && now - u->last_xrun_ns > quiet &&
|
||||
(!u->last_decay_ns || now - u->last_decay_ns > quiet)) {
|
||||
int32_t want = cur - burst;
|
||||
|
||||
if (want < floor) want = floor;
|
||||
if (want < cur) {
|
||||
AAudioStream_setBufferSizeInFrames(aq, want);
|
||||
u->last_decay_ns = now;
|
||||
__atomic_store_n(&r->hw_buf_frames, AAudioStream_getBufferSizeInFrames(aq), __ATOMIC_RELAXED);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return AAUDIO_CALLBACK_RESULT_CONTINUE;
|
||||
}
|
||||
|
||||
static void out_error_cb(AAudioStream *aq, void *user, aaudio_result_t error) {
|
||||
struct userdata *u = user;
|
||||
|
||||
if (aq != __atomic_load_n(&u->aq, __ATOMIC_SEQ_CST)) return;
|
||||
/* A disconnected stream (headphones plugged or unplugged, a Bluetooth route coming or going)
|
||||
* can never be restarted: it has to be closed and a new one opened on the current route. */
|
||||
if (error != AAUDIO_ERROR_DISCONNECTED &&
|
||||
error != AAUDIO_ERROR_INVALID_STATE &&
|
||||
error != AAUDIO_ERROR_INVALID_HANDLE &&
|
||||
error != AAUDIO_ERROR_TIMEOUT)
|
||||
return;
|
||||
request_reopen(u, "stream error");
|
||||
}
|
||||
|
||||
/* 48 kHz / float / stereo, buffer from the ms form rounded up to a burst and never below two,
|
||||
* capacity from the ceiling. The ring target floor is two bursts or ring_ms, whichever is more. */
|
||||
static aaudio_result_t open_output(struct userdata *u, AAudioStream **out) {
|
||||
AAudioStreamBuilder *builder = NULL;
|
||||
AAudioStream *aq = NULL;
|
||||
aaudio_result_t res;
|
||||
int32_t capf, burst, want;
|
||||
|
||||
res = AAudio_createStreamBuilder(&builder);
|
||||
if (res != AAUDIO_OK || !builder) return res != AAUDIO_OK ? res : AAUDIO_ERROR_NO_MEMORY;
|
||||
|
||||
AAudioStreamBuilder_setDirection(builder, AAUDIO_DIRECTION_OUTPUT);
|
||||
AAudioStreamBuilder_setSharingMode(builder, AAUDIO_SHARING_MODE_SHARED);
|
||||
AAudioStreamBuilder_setPerformanceMode(builder, u->perf);
|
||||
AAudioStreamBuilder_setUsage(builder, AAUDIO_USAGE_GAME);
|
||||
AAudioStreamBuilder_setFormat(builder, AAUDIO_FORMAT_PCM_FLOAT);
|
||||
AAudioStreamBuilder_setChannelCount(builder, DA_RING_CHANNELS);
|
||||
AAudioStreamBuilder_setSampleRate(builder, DA_RING_RATE);
|
||||
AAudioStreamBuilder_setDataCallback(builder, out_cb, u);
|
||||
AAudioStreamBuilder_setErrorCallback(builder, out_error_cb, u);
|
||||
AAudioStreamBuilder_setBufferCapacityInFrames(builder, (u->max_ms > 0 ? u->max_ms : DA_DEFAULT_MAX_MS) * DA_RING_RATE / 1000);
|
||||
|
||||
res = AAudioStreamBuilder_openStream(builder, &aq);
|
||||
AAudioStreamBuilder_delete(builder);
|
||||
if (res != AAUDIO_OK || !aq) return res != AAUDIO_OK ? res : AAUDIO_ERROR_INTERNAL;
|
||||
|
||||
capf = AAudioStream_getBufferCapacityInFrames(aq);
|
||||
burst = AAudioStream_getFramesPerBurst(aq);
|
||||
want = (u->target_ms > 0 ? u->target_ms : DA_DEFAULT_MS) * DA_RING_RATE / 1000;
|
||||
if (burst > 0) {
|
||||
int32_t nb = (want + burst - 1) / burst;
|
||||
want = (nb < 1 ? 1 : nb) * burst;
|
||||
if (want < DA_MIN_BUFFER_BURSTS * burst) want = DA_MIN_BUFFER_BURSTS * burst;
|
||||
}
|
||||
if (want > capf) want = capf;
|
||||
if (want > 0) AAudioStream_setBufferSizeInFrames(aq, want);
|
||||
|
||||
if (burst <= 0) burst = 192;
|
||||
u->burst = burst;
|
||||
u->ring_max_target = DA_RING_MAX_TARGET_MS * DA_RING_RATE / 1000;
|
||||
if (u->ring_max_target > (int32_t) u->ring->cap_frames) u->ring_max_target = (int32_t) u->ring->cap_frames;
|
||||
{
|
||||
int32_t floor = DA_RING_TARGET_BURSTS * burst;
|
||||
int32_t asked = (u->ring_ms > 0 ? u->ring_ms : DA_DEFAULT_RING_MS) * DA_RING_RATE / 1000;
|
||||
|
||||
if (floor < asked) floor = asked;
|
||||
if (floor > u->ring_max_target) floor = u->ring_max_target;
|
||||
if (__atomic_load_n(&u->ring->target_frames, __ATOMIC_ACQUIRE) < floor)
|
||||
__atomic_store_n(&u->ring->target_frames, floor, __ATOMIC_RELEASE);
|
||||
}
|
||||
__atomic_store_n(&u->ring->hw_burst, burst, __ATOMIC_RELAXED);
|
||||
|
||||
u->last_xrun = AAudioStream_getXRunCount(aq);
|
||||
u->base_buf_frames = AAudioStream_getBufferSizeInFrames(aq);
|
||||
u->decay_floor = 0;
|
||||
u->decay_backoff = 1;
|
||||
u->last_xrun_ns = now_ns();
|
||||
u->last_decay_ns = 0;
|
||||
__atomic_store_n(&u->ring->hw_buf_frames, u->base_buf_frames, __ATOMIC_RELAXED);
|
||||
|
||||
pa_log("directaudio-native-sink: open #%u: device buffer %d frames (%d ms), burst %d, capacity %d, performance mode %d (asked %d), ring target %d frames - Android adds its own output latency on top",
|
||||
++u->opens, u->base_buf_frames, u->base_buf_frames * 1000 / DA_RING_RATE, AAudioStream_getFramesPerBurst(aq),
|
||||
capf, AAudioStream_getPerformanceMode(aq), u->perf, (int) u->ring->target_frames);
|
||||
|
||||
res = AAudioStream_requestStart(aq);
|
||||
if (res != AAUDIO_OK) { AAudioStream_close(aq); return res; }
|
||||
|
||||
__atomic_store_n(&u->last_cb_ns, now_ns(), __ATOMIC_SEQ_CST);
|
||||
u->wd_last_tick_ns = 0;
|
||||
__atomic_store_n(&u->ring->state, DA_RING_PLAYING, __ATOMIC_RELEASE);
|
||||
*out = aq;
|
||||
return AAUDIO_OK;
|
||||
}
|
||||
|
||||
static void *reopen_thread(void *user) {
|
||||
struct userdata *u = user;
|
||||
|
||||
for (;;) {
|
||||
AAudioStream *old = NULL, *neu = NULL;
|
||||
int expected = 0;
|
||||
|
||||
__atomic_store_n(&u->reopen_redo, 0, __ATOMIC_SEQ_CST);
|
||||
if (u->dead) break;
|
||||
|
||||
if (open_output(u, &neu) == AAUDIO_OK) {
|
||||
pthread_mutex_lock(&u->lock);
|
||||
old = u->aq;
|
||||
__atomic_store_n(&u->aq, neu, __ATOMIC_SEQ_CST);
|
||||
pthread_mutex_unlock(&u->lock);
|
||||
} else
|
||||
pa_log("directaudio-native-sink: reopen failed; keeping the old stream");
|
||||
|
||||
if (old) {
|
||||
AAudioStream_requestStop(old);
|
||||
AAudioStream_close(old);
|
||||
pa_log("directaudio-native-sink: reopened the output on the current route");
|
||||
}
|
||||
|
||||
if (__atomic_load_n(&u->reopen_redo, __ATOMIC_SEQ_CST)) { usleep(20000); continue; }
|
||||
__atomic_store_n(&u->reopen_running, 0, __ATOMIC_SEQ_CST);
|
||||
if (!__atomic_load_n(&u->reopen_redo, __ATOMIC_SEQ_CST)) break;
|
||||
if (!__atomic_compare_exchange_n(&u->reopen_running, &expected, 1, 0, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST))
|
||||
break;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void request_reopen(struct userdata *u, const char *why) {
|
||||
int expected = 0;
|
||||
|
||||
__atomic_store_n(&u->reopen_redo, 1, __ATOMIC_SEQ_CST);
|
||||
if (__atomic_compare_exchange_n(&u->reopen_running, &expected, 1, 0, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST)) {
|
||||
pthread_t th;
|
||||
|
||||
pa_log("directaudio-native-sink: reopen: %s", why);
|
||||
if (pthread_create(&th, NULL, reopen_thread, u))
|
||||
__atomic_store_n(&u->reopen_running, 0, __ATOMIC_SEQ_CST);
|
||||
else
|
||||
pthread_detach(th);
|
||||
}
|
||||
}
|
||||
|
||||
/* Ticked once a second from the IO thread: a callback silent for a second is a dead stream. */
|
||||
static void watchdog(struct userdata *u) {
|
||||
uint64_t now = now_ns(), last_cb, prev_tick;
|
||||
unsigned int cb;
|
||||
|
||||
if (!u->watchdog_on) return;
|
||||
if (!__atomic_load_n(&u->aq, __ATOMIC_SEQ_CST)) return;
|
||||
|
||||
prev_tick = u->wd_last_tick_ns;
|
||||
u->wd_last_tick_ns = now;
|
||||
cb = __atomic_load_n(&u->cb_count, __ATOMIC_SEQ_CST);
|
||||
|
||||
if (!prev_tick || now - prev_tick > 2 * DA_FREEZE_GAP_NS + 1000000000ull) {
|
||||
u->wd_last_cb = cb;
|
||||
__atomic_store_n(&u->last_cb_ns, now, __ATOMIC_SEQ_CST);
|
||||
return;
|
||||
}
|
||||
if (cb != u->wd_last_cb) { u->wd_last_cb = cb; return; }
|
||||
|
||||
last_cb = __atomic_load_n(&u->last_cb_ns, __ATOMIC_SEQ_CST);
|
||||
if (!last_cb || now - last_cb < DA_CB_STALL_NS) return;
|
||||
|
||||
__atomic_store_n(&u->last_cb_ns, now, __ATOMIC_SEQ_CST);
|
||||
request_reopen(u, "data callback stalled");
|
||||
}
|
||||
|
||||
static void close_output(struct userdata *u) {
|
||||
AAudioStream *aq;
|
||||
|
||||
u->dead = 1;
|
||||
pthread_mutex_lock(&u->lock);
|
||||
aq = u->aq;
|
||||
__atomic_store_n(&u->aq, NULL, __ATOMIC_SEQ_CST);
|
||||
pthread_mutex_unlock(&u->lock);
|
||||
if (aq) {
|
||||
AAudioStream_requestStop(aq);
|
||||
AAudioStream_close(aq);
|
||||
}
|
||||
/* A reopen still in flight sees u->dead and stops; give it a moment to let go of u. */
|
||||
while (__atomic_load_n(&u->reopen_running, __ATOMIC_SEQ_CST))
|
||||
usleep(10000);
|
||||
}
|
||||
|
||||
/* ---- the PulseAudio side: module-directaudio-sink's producer, on the local ring ---- */
|
||||
|
||||
static pa_usec_t frames_to_usec(const struct userdata *u, int64_t frames) {
|
||||
return (pa_usec_t) (frames * (int64_t) PA_USEC_PER_SEC / (int64_t) u->ss.rate);
|
||||
}
|
||||
|
||||
static pa_usec_t sink_latency(struct userdata *u) {
|
||||
return frames_to_usec(u, (int64_t) da_ring_avail(u->ring) + (u->ring->hw_buf_frames > 0 ? u->ring->hw_buf_frames : 0));
|
||||
}
|
||||
|
||||
static void produce(struct userdata *u, uint32_t frames) {
|
||||
struct da_ring *r = u->ring;
|
||||
uint32_t w = __atomic_load_n(&r->widx, __ATOMIC_ACQUIRE);
|
||||
uint32_t pos = w % r->cap_frames;
|
||||
uint32_t first = PA_MIN(frames, r->cap_frames - pos);
|
||||
float *dst = r->data + (size_t) pos * DA_RING_CHANNELS;
|
||||
size_t bytes = (size_t) frames * u->frame_size;
|
||||
|
||||
if (PA_SINK_IS_OPENED(u->sink->thread_info.state)) {
|
||||
pa_memchunk chunk;
|
||||
const uint8_t *src;
|
||||
|
||||
if (u->sink->thread_info.rewind_requested)
|
||||
pa_sink_process_rewind(u->sink, 0);
|
||||
pa_sink_render_full(u->sink, bytes, &chunk);
|
||||
src = pa_memblock_acquire_chunk(&chunk);
|
||||
memcpy(dst, src, (size_t) first * u->frame_size);
|
||||
if (frames > first)
|
||||
memcpy(r->data, src + (size_t) first * u->frame_size, (size_t) (frames - first) * u->frame_size);
|
||||
pa_memblock_release(chunk.memblock);
|
||||
pa_memblock_unref(chunk.memblock);
|
||||
} else {
|
||||
memset(dst, 0, (size_t) first * u->frame_size);
|
||||
if (frames > first)
|
||||
memset(r->data, 0, (size_t) (frames - first) * u->frame_size);
|
||||
}
|
||||
__atomic_store_n(&r->widx, w + frames, __ATOMIC_RELEASE);
|
||||
}
|
||||
|
||||
/* Tops the ring up to the engine's target and narrates what the engine changed. */
|
||||
static void service_ring(struct userdata *u) {
|
||||
struct da_ring *r = u->ring;
|
||||
uint32_t avail, space;
|
||||
int32_t target = __atomic_load_n(&r->target_frames, __ATOMIC_ACQUIRE);
|
||||
int32_t hw = __atomic_load_n(&r->hw_buf_frames, __ATOMIC_RELAXED);
|
||||
uint32_t under = __atomic_load_n(&r->underruns, __ATOMIC_RELAXED);
|
||||
pa_usec_t now = pa_rtclock_now();
|
||||
|
||||
if (target <= 0) target = 2 * u->burst;
|
||||
avail = da_ring_avail(r);
|
||||
space = da_ring_space(r);
|
||||
if (avail < (uint32_t) target) {
|
||||
uint32_t want = PA_MIN((uint32_t) target - avail, space);
|
||||
|
||||
if (want > 0)
|
||||
produce(u, want);
|
||||
}
|
||||
if (target != u->seen_target || hw != u->seen_hw_buf) {
|
||||
pa_sink_set_max_request_within_thread(u->sink, (size_t) target * u->frame_size);
|
||||
pa_sink_set_fixed_latency_within_thread(u->sink, frames_to_usec(u, (int64_t) target + (hw > 0 ? hw : 0)));
|
||||
}
|
||||
if (target != u->seen_target) {
|
||||
if (u->seen_target)
|
||||
pa_log("directaudio-native-sink: ring grow: target %d -> %d frames (%d ms)", u->seen_target, target, target * 1000 / DA_RING_RATE);
|
||||
u->seen_target = target;
|
||||
}
|
||||
if (hw != u->seen_hw_buf) {
|
||||
if (u->seen_hw_buf)
|
||||
pa_log("directaudio-native-sink: device buffer %d -> %d frames (%d ms)", u->seen_hw_buf, hw, hw * 1000 / DA_RING_RATE);
|
||||
u->seen_hw_buf = hw;
|
||||
}
|
||||
if (u->settled_at && now >= u->settled_at) {
|
||||
pa_log("directaudio-native-sink: after 10 s: device buffer %d frames, ring target %d, ring underruns %u, stream xruns %d",
|
||||
hw, target, under, u->aq ? AAudioStream_getXRunCount(u->aq) : -1);
|
||||
u->settled_at = 0;
|
||||
}
|
||||
if (under != u->seen_underruns && now - u->stats_at >= STATS_EVERY_USEC) {
|
||||
pa_log("directaudio-native-sink: %u ring underrun(s) so far, target %d frames, device buffer %d", under, target, hw);
|
||||
u->seen_underruns = under;
|
||||
u->stats_at = now;
|
||||
}
|
||||
if (now >= u->next_watchdog) {
|
||||
watchdog(u);
|
||||
u->next_watchdog = now + WATCHDOG_TICK_USEC;
|
||||
}
|
||||
}
|
||||
|
||||
static int sink_process_msg(pa_msgobject *o, int code, void *data, int64_t offset, pa_memchunk *chunk) {
|
||||
struct userdata *u = PA_SINK(o)->userdata;
|
||||
|
||||
switch (code) {
|
||||
case PA_SINK_MESSAGE_GET_LATENCY:
|
||||
*((pa_usec_t*) data) = sink_latency(u);
|
||||
return 0;
|
||||
}
|
||||
return pa_sink_process_msg(o, code, data, offset, chunk);
|
||||
}
|
||||
|
||||
static void thread_func(void *userdata) {
|
||||
struct userdata *u = userdata;
|
||||
|
||||
pa_log_debug("IO thread starting");
|
||||
pa_thread_mq_install(&u->thread_mq);
|
||||
if (setpriority(PRIO_PROCESS, (id_t) syscall(SYS_gettid), AUDIO_NICE) < 0)
|
||||
pa_log_debug("directaudio-native-sink: could not raise the IO thread's priority (%s)", strerror(errno));
|
||||
if (u->core->realtime_scheduling)
|
||||
pa_thread_make_realtime(u->core->realtime_priority);
|
||||
pa_rtpoll_set_timer_relative(u->rtpoll, 0);
|
||||
|
||||
for (;;) {
|
||||
int ret;
|
||||
|
||||
/* Half a burst between visits, never blocking on the stream: clients' audio reaches this
|
||||
* thread as messages pa_rtpoll_run handles, and a thread stuck in a write or a wait lets
|
||||
* them run dry (the crackle #338 fixed). */
|
||||
service_ring(u);
|
||||
pa_rtpoll_set_timer_relative(u->rtpoll, PA_MAX(frames_to_usec(u, u->burst) / 2, PA_USEC_PER_MSEC));
|
||||
|
||||
if ((ret = pa_rtpoll_run(u->rtpoll)) < 0)
|
||||
goto fail;
|
||||
if (ret == 0)
|
||||
goto finish;
|
||||
}
|
||||
|
||||
fail:
|
||||
pa_asyncmsgq_post(u->thread_mq.outq, PA_MSGOBJECT(u->core), PA_CORE_MESSAGE_UNLOAD_MODULE, u->module, 0, NULL, NULL);
|
||||
pa_asyncmsgq_wait_for(u->thread_mq.inq, PA_MESSAGE_SHUTDOWN);
|
||||
|
||||
finish:
|
||||
pa_log_debug("IO thread shutting down");
|
||||
}
|
||||
|
||||
void pa__done(pa_module *m);
|
||||
|
||||
int pa__init(pa_module *m) {
|
||||
struct userdata *u = NULL;
|
||||
pa_modargs *ma = NULL;
|
||||
pa_sink_new_data data;
|
||||
pa_channel_map map;
|
||||
pa_cvolume volume;
|
||||
uint32_t perf = 1, buffer_ms = DA_DEFAULT_MS, max_ms = DA_DEFAULT_MAX_MS, ring_ms = DA_DEFAULT_RING_MS;
|
||||
double linear_volume = 1.0;
|
||||
bool adaptive = true, decay = false, limiter = true, watchdog_on = true;
|
||||
AAudioStream *aq = NULL;
|
||||
aaudio_result_t res;
|
||||
uint32_t cap_frames;
|
||||
|
||||
pa_assert(m);
|
||||
|
||||
if (!(ma = pa_modargs_new(m->argument, valid_modargs))) {
|
||||
pa_log("Failed to parse module arguments.");
|
||||
goto fail;
|
||||
}
|
||||
if (pa_modargs_get_value_u32(ma, "performance_mode", &perf) < 0 || perf > 2) {
|
||||
pa_log("performance_mode must be 0, 1 or 2");
|
||||
goto fail;
|
||||
}
|
||||
if (pa_modargs_get_value_u32(ma, "buffer_ms", &buffer_ms) < 0 || buffer_ms == 0 || buffer_ms > 200) {
|
||||
pa_log("buffer_ms must be between 1 and 200");
|
||||
goto fail;
|
||||
}
|
||||
if (pa_modargs_get_value_u32(ma, "max_ms", &max_ms) < 0 || max_ms < buffer_ms || max_ms > 250) {
|
||||
pa_log("max_ms must be between buffer_ms and 250");
|
||||
goto fail;
|
||||
}
|
||||
if (pa_modargs_get_value_u32(ma, "ring_ms", &ring_ms) < 0 || ring_ms == 0 || ring_ms > 100) {
|
||||
pa_log("ring_ms must be between 1 and 100");
|
||||
goto fail;
|
||||
}
|
||||
if (pa_modargs_get_value_boolean(ma, "adaptive", &adaptive) < 0 ||
|
||||
pa_modargs_get_value_boolean(ma, "decay", &decay) < 0 ||
|
||||
pa_modargs_get_value_boolean(ma, "limiter", &limiter) < 0 ||
|
||||
pa_modargs_get_value_boolean(ma, "watchdog", &watchdog_on) < 0) {
|
||||
pa_log("adaptive, decay, limiter and watchdog must be 0 or 1");
|
||||
goto fail;
|
||||
}
|
||||
if (pa_modargs_get_value_double(ma, "volume", &linear_volume) < 0 || linear_volume < 0.0 || linear_volume > 10.0) {
|
||||
pa_log("volume must be a linear factor between 0 and 10");
|
||||
goto fail;
|
||||
}
|
||||
|
||||
m->userdata = u = pa_xnew0(struct userdata, 1);
|
||||
u->core = m->core;
|
||||
u->module = m;
|
||||
u->perf = perf == 0 ? AAUDIO_PERFORMANCE_MODE_NONE : perf == 2 ? AAUDIO_PERFORMANCE_MODE_POWER_SAVING : AAUDIO_PERFORMANCE_MODE_LOW_LATENCY;
|
||||
u->target_ms = (int32_t) buffer_ms;
|
||||
u->max_ms = (int32_t) max_ms;
|
||||
u->ring_ms = (int32_t) ring_ms;
|
||||
u->adaptive = adaptive;
|
||||
u->decay = decay;
|
||||
u->limiter = limiter;
|
||||
u->watchdog_on = watchdog_on;
|
||||
u->ss.format = PA_SAMPLE_FLOAT32NE;
|
||||
u->ss.rate = DA_RING_RATE;
|
||||
u->ss.channels = DA_RING_CHANNELS;
|
||||
u->frame_size = pa_frame_size(&u->ss);
|
||||
u->burst = 192;
|
||||
pthread_mutex_init(&u->lock, NULL);
|
||||
pa_channel_map_init_stereo(&map);
|
||||
|
||||
/* The ring: the relay's layout in this process's memory. */
|
||||
cap_frames = DA_RING_CAP_MS_LOCAL * DA_RING_RATE / 1000;
|
||||
u->ring = pa_xmalloc0(da_ring_bytes(cap_frames));
|
||||
u->ring->magic = DA_RING_MAGIC;
|
||||
u->ring->version = DA_RELAY_VERSION;
|
||||
u->ring->cap_frames = cap_frames;
|
||||
u->ring->rate = DA_RING_RATE;
|
||||
u->ring->channels = DA_RING_CHANNELS;
|
||||
u->ring->target_frames = 0;
|
||||
|
||||
if ((res = open_output(u, &aq)) != AAUDIO_OK) {
|
||||
pa_log("directaudio-native-sink: could not open the AAudio output (%s); is the daemon running on the Android side?", AAudio_convertResultToText(res));
|
||||
goto fail;
|
||||
}
|
||||
__atomic_store_n(&u->aq, aq, __ATOMIC_SEQ_CST);
|
||||
u->seen_hw_buf = u->base_buf_frames;
|
||||
u->seen_target = u->ring->target_frames;
|
||||
u->settled_at = pa_rtclock_now() + 10 * PA_USEC_PER_SEC;
|
||||
u->stats_at = pa_rtclock_now();
|
||||
|
||||
u->rtpoll = pa_rtpoll_new();
|
||||
if (pa_thread_mq_init(&u->thread_mq, m->core->mainloop, u->rtpoll) < 0) {
|
||||
pa_log("pa_thread_mq_init() failed.");
|
||||
goto fail;
|
||||
}
|
||||
|
||||
pa_sink_new_data_init(&data);
|
||||
data.driver = __FILE__;
|
||||
data.module = m;
|
||||
pa_sink_new_data_set_name(&data, pa_modargs_get_value(ma, "sink_name", DEFAULT_SINK_NAME));
|
||||
pa_sink_new_data_set_sample_spec(&data, &u->ss);
|
||||
pa_sink_new_data_set_channel_map(&data, &map);
|
||||
pa_cvolume_set(&volume, u->ss.channels, pa_sw_volume_from_linear(linear_volume));
|
||||
pa_sink_new_data_set_volume(&data, &volume);
|
||||
pa_proplist_sets(data.proplist, PA_PROP_DEVICE_STRING, "directaudio");
|
||||
pa_proplist_sets(data.proplist, PA_PROP_DEVICE_DESCRIPTION, "Android audio output (DirectAudio)");
|
||||
pa_proplist_sets(data.proplist, PA_PROP_DEVICE_CLASS, "sound");
|
||||
pa_proplist_sets(data.proplist, PA_PROP_DEVICE_API, "directaudio");
|
||||
if (pa_modargs_get_proplist(ma, "sink_properties", data.proplist, PA_UPDATE_REPLACE) < 0) {
|
||||
pa_log("Invalid properties");
|
||||
pa_sink_new_data_done(&data);
|
||||
goto fail;
|
||||
}
|
||||
u->sink = pa_sink_new(m->core, &data, PA_SINK_HARDWARE | PA_SINK_LATENCY);
|
||||
pa_sink_new_data_done(&data);
|
||||
if (!u->sink) {
|
||||
pa_log("Failed to create sink.");
|
||||
goto fail;
|
||||
}
|
||||
u->sink->parent.process_msg = sink_process_msg;
|
||||
u->sink->userdata = u;
|
||||
pa_sink_set_asyncmsgq(u->sink, u->thread_mq.inq);
|
||||
pa_sink_set_rtpoll(u->sink, u->rtpoll);
|
||||
pa_sink_set_max_request(u->sink, (size_t) u->ring->target_frames * u->frame_size);
|
||||
/* What clients should keep queued on their side: the ring's starting target plus the device
|
||||
* buffer. Grows are reported live through GET_LATENCY. */
|
||||
pa_sink_set_fixed_latency(u->sink, frames_to_usec(u, (int64_t) u->ring->target_frames + u->base_buf_frames));
|
||||
|
||||
if (!(u->thread = pa_thread_new("directaudio-native-sink", thread_func, u))) {
|
||||
pa_log("Failed to create thread.");
|
||||
goto fail;
|
||||
}
|
||||
pa_sink_put(u->sink);
|
||||
pa_modargs_free(ma);
|
||||
return 0;
|
||||
|
||||
fail:
|
||||
if (ma)
|
||||
pa_modargs_free(ma);
|
||||
pa__done(m);
|
||||
return -1;
|
||||
}
|
||||
|
||||
int pa__get_n_used(pa_module *m) {
|
||||
struct userdata *u;
|
||||
|
||||
pa_assert(m);
|
||||
pa_assert_se(u = m->userdata);
|
||||
return pa_sink_linked_by(u->sink);
|
||||
}
|
||||
|
||||
void pa__done(pa_module *m) {
|
||||
struct userdata *u;
|
||||
|
||||
pa_assert(m);
|
||||
if (!(u = m->userdata))
|
||||
return;
|
||||
if (u->sink)
|
||||
pa_sink_unlink(u->sink);
|
||||
if (u->thread) {
|
||||
pa_asyncmsgq_send(u->thread_mq.inq, NULL, PA_MESSAGE_SHUTDOWN, NULL, 0, NULL);
|
||||
pa_thread_free(u->thread);
|
||||
}
|
||||
pa_thread_mq_done(&u->thread_mq);
|
||||
if (u->sink)
|
||||
pa_sink_unref(u->sink);
|
||||
close_output(u);
|
||||
if (u->rtpoll)
|
||||
pa_rtpoll_free(u->rtpoll);
|
||||
pthread_mutex_destroy(&u->lock);
|
||||
pa_xfree(u->ring);
|
||||
pa_xfree(u);
|
||||
}
|
||||
@@ -291,7 +291,7 @@ def libraries(steam_root):
|
||||
except OSError:
|
||||
text = ""
|
||||
paths = [path.replace("\\\\", "\\") for path in re.findall(r'"path"\s+"((?:[^"\\]|\\.)*)"', text)]
|
||||
return list(dict.fromkeys([Path(steam_root), Path(LIBRARY)] + [Path(path) for path in paths]))
|
||||
return list(dict.fromkeys([Path(steam_root)] + [Path(path) for path in paths]))
|
||||
|
||||
|
||||
def ours(manifest, appid):
|
||||
@@ -311,6 +311,16 @@ def unpark(steamapps):
|
||||
os.replace(manifest, live)
|
||||
|
||||
|
||||
def seeded(steamapps, game, appid):
|
||||
target = steamapps / "common" / f"DroidDeck-{appid}"
|
||||
manifest = steamapps / f"appmanifest_{appid}.acf"
|
||||
try:
|
||||
return (target.is_symlink() and target.is_dir() and manifest.is_file() and ours(manifest, appid)
|
||||
and os.readlink(target) in (game.get("folder", ""), legacy_path(game.get("folder", ""))))
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def park(steamapps):
|
||||
for target in sorted((steamapps / "common").glob("DroidDeck-*")):
|
||||
appid = target.name.removeprefix("DroidDeck-")
|
||||
@@ -428,6 +438,9 @@ def route(steam_root, acct, games):
|
||||
continue
|
||||
except OSError as error:
|
||||
print(f"droiddeck-steam-games: import unavailable ({error})")
|
||||
elif isinstance(appid, int) and 0 < appid < 0x80000000 and seeded(steamapps, game, appid):
|
||||
routes[str(game["appid"])] = appid
|
||||
continue
|
||||
shortcuts.append(game)
|
||||
try:
|
||||
park(steamapps)
|
||||
|
||||
@@ -8,7 +8,7 @@ ROOTFS_SHA256=2854b06d3ff1b8f6e526135bfb6dd5b7b30ab3ab73e79ae933a3d9fed959a178
|
||||
CDN=https://client-update.fastly.steamstatic.com
|
||||
# Must match the -clientbeta droiddeck-session starts this client with (steam_channel).
|
||||
X64_CHANNEL=steamdeck_publicbeta
|
||||
MANIFEST=steam_client_${X64_CHANNEL}_ubuntu12
|
||||
MANIFEST=steam_client_${X64_CHANNEL}_steamrt64
|
||||
|
||||
REAL_HOME=${HOME:-/root}
|
||||
X64_HOME=${BL_X64_HOME:-$REAL_HOME/.droiddeck-x64}
|
||||
@@ -37,17 +37,17 @@ x64_client_parts() {
|
||||
/^\t"/ && NF >= 3 && $4 == "" { block = $2 }
|
||||
$2 == "file" { file[block] = $4 }
|
||||
$2 == "sha2" { sha[block] = $4 }
|
||||
END { for (b in file) if (b ~ /_all$/ || b ~ /_ubuntu12$/) print b, file[b], sha[b] }
|
||||
END { for (b in file) if (b ~ /_all$/ || b ~ /_steamrt64$/) print b, file[b], sha[b] }
|
||||
' "$1" | sort
|
||||
}
|
||||
|
||||
x64_sync_client() {
|
||||
local chan cur version work total i block file sum old
|
||||
chan=$(cat "$STEAM_X64/package/beta" 2>/dev/null || echo "$X64_CHANNEL")
|
||||
old=$STEAM_X64/package/steam_client_${chan}_ubuntu12.manifest
|
||||
old=$STEAM_X64/package/steam_client_${chan}_steamrt64.manifest
|
||||
cur=$(awk -F'"' '$2 == "version" { print $4; exit }' "$old" 2>/dev/null || true)
|
||||
work=$(mktemp -d)
|
||||
if ! curl -fsSL "$CDN/steam_client_${chan}_ubuntu12" -o "$work/manifest"; then
|
||||
if ! curl -fsSL "$CDN/steam_client_${chan}_steamrt64" -o "$work/manifest"; then
|
||||
rm -rf "$work"; return 0
|
||||
fi
|
||||
version=$(awk -F'"' '$2 == "version" { print $4; exit }' "$work/manifest")
|
||||
@@ -471,7 +471,7 @@ if [ ! -x "$STEAM_X64/steamrt64/steam" ] || [ ! -f "$STEAM_X64/package/droiddeck
|
||||
/^\t"/ && NF >= 3 && $4 == "" { block = $2 }
|
||||
$2 == "file" { file[block] = $4 }
|
||||
$2 == "sha2" { sha[block] = $4 }
|
||||
END { for (b in file) if (b ~ /_all$/ || b ~ /_ubuntu12$/) print b, file[b], sha[b] }
|
||||
END { for (b in file) if (b ~ /_all$/ || b ~ /_steamrt64$/) print b, file[b], sha[b] }
|
||||
' "$work/manifest" | sort > "$work/parts"
|
||||
[ -s "$work/parts" ] || { step "no client components in $MANIFEST"; exit 1; }
|
||||
mkdir -p "$STEAM_X64/package"
|
||||
@@ -910,7 +910,7 @@ x64_exec() {
|
||||
[ -x "$ARM_STEAM/steamrtarm64/steamwebhelper" ]; then
|
||||
local chan x86_build arm_build
|
||||
chan=$(cat "$STEAM_X64/package/beta" 2>/dev/null || echo "$X64_CHANNEL")
|
||||
x86_build=$(awk -F'"' '$2 == "version" { print $4; exit }' "$STEAM_X64/package/steam_client_${chan}_ubuntu12.manifest" 2>/dev/null || true)
|
||||
x86_build=$(awk -F'"' '$2 == "version" { print $4; exit }' "$STEAM_X64/package/steam_client_${chan}_steamrt64.manifest" 2>/dev/null || true)
|
||||
arm_build=$(awk -F'"' '$2 == "version" { print $4; exit }' "$ARM_STEAM/package/steam_client_${chan}_linuxarm64.manifest" 2>/dev/null || true)
|
||||
if [ -n "$x86_build" ] && [ "$x86_build" = "$arm_build" ]; then
|
||||
export BL_X64_ARM_WEBHELPER=/usr/local/bin/droiddeck-x64-arm-webhelper
|
||||
@@ -937,7 +937,7 @@ x64_exec() {
|
||||
touch "$STEAM_X64/logs/bootstrap_log.txt"
|
||||
( tail -n0 -F --pid=$$ "$STEAM_X64/logs/bootstrap_log.txt" 2>/dev/null |
|
||||
grep --line-buffered -oE 'Downloading update \([0-9,]+ of [0-9,]+ KB\)' | sed -u 's/,//g' >&2 ) &
|
||||
echo "== x86-64 client: ubuntu12 $(x64_manifest_version ubuntu12), steamrt64 $(x64_manifest_version steamrt64) (the client may update itself)" >&2
|
||||
echo "== x86-64 client: steamrt64 $(x64_manifest_version steamrt64) (the client may update itself)" >&2
|
||||
x64_webhelper_gpu && { x64_gpu_watch >&2 & }
|
||||
exec "${BL_FEX:-/usr/local/bin/FEX}" "$STEAM_X64/steamrt64/steam" "$@"
|
||||
}
|
||||
@@ -952,6 +952,76 @@ arm, x64, arm_reg, x64_reg = sys.argv[1:5]
|
||||
FILES = ("config/loginusers.vdf", "local.vdf")
|
||||
|
||||
|
||||
def read(path):
|
||||
try:
|
||||
return open(path, encoding="utf-8", errors="surrogateescape").read()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def write(path, text):
|
||||
open(path, "w", encoding="utf-8", errors="surrogateescape").write(text)
|
||||
|
||||
|
||||
def block(text, name):
|
||||
m = re.search(r'"%s"\s*\{' % name, text)
|
||||
if not m:
|
||||
return None
|
||||
depth, k = 1, m.end()
|
||||
while depth and k < len(text):
|
||||
depth += text[k] == "{"
|
||||
depth -= text[k] == "}"
|
||||
k += 1
|
||||
return m, k
|
||||
|
||||
|
||||
def accounts(root):
|
||||
text = read(os.path.join(root, "config/config.vdf")) or ""
|
||||
found = block(text, "Accounts")
|
||||
if not found:
|
||||
return {}
|
||||
return dict(re.findall(r'"([^"]+)"\s*\{\s*"SteamID"\s*"(\d+)"', text[found[0].end():found[1]]))
|
||||
|
||||
|
||||
def add_accounts(root, wanted):
|
||||
path = os.path.join(root, "config/config.vdf")
|
||||
text = read(path)
|
||||
if text is None:
|
||||
return
|
||||
have = accounts(root)
|
||||
missing = {name: sid for name, sid in wanted.items() if name not in have}
|
||||
if not missing:
|
||||
return
|
||||
entries = "".join('\n\t\t\t\t\t"%s"\n\t\t\t\t\t{\n\t\t\t\t\t\t"SteamID"\t\t"%s"\n\t\t\t\t\t}' % item for item in missing.items())
|
||||
found = block(text, "Accounts")
|
||||
if found:
|
||||
text = text[:found[0].end()] + entries + text[found[0].end():]
|
||||
else:
|
||||
steam = re.search(r'"Valve"\s*\{\s*"Steam"\s*\{', text)
|
||||
if not steam:
|
||||
return
|
||||
text = text[:steam.end()] + '\n\t\t\t\t"Accounts"\n\t\t\t\t{' + entries + '\n\t\t\t\t}' + text[steam.end():]
|
||||
write(path, text)
|
||||
|
||||
|
||||
def set_keys(src_reg, dst_reg, force):
|
||||
st, dt = read(src_reg), read(dst_reg)
|
||||
if st is None or dt is None:
|
||||
return
|
||||
for key in ("AutoLoginUser", "RememberPassword"):
|
||||
v = re.search(r'"%s"\s*"([^"]*)"' % key, st)
|
||||
if not v:
|
||||
continue
|
||||
if re.search(r'"%s"\s*"' % key, dt):
|
||||
if force:
|
||||
dt = re.sub(r'("%s"\s*")[^"]*(")' % key, lambda m: m.group(1) + v.group(1) + m.group(2), dt, count=1)
|
||||
continue
|
||||
hkcu = re.search(r'"HKCU"\s*\{\s*"Software"\s*\{\s*"Valve"\s*\{\s*"Steam"\s*\{\n', dt)
|
||||
if hkcu:
|
||||
dt = dt[:hkcu.end()] + '\t\t\t\t\t"%s"\t\t"%s"\n' % (key, v.group(1)) + dt[hkcu.end():]
|
||||
write(dst_reg, dt)
|
||||
|
||||
|
||||
def stamp(root):
|
||||
if not all(os.path.isfile(os.path.join(root, f)) for f in FILES):
|
||||
return None
|
||||
@@ -963,31 +1033,26 @@ def stamp(root):
|
||||
|
||||
|
||||
a, x = stamp(arm), stamp(x64)
|
||||
if a is None and x is None:
|
||||
sys.exit(0)
|
||||
if a is not None and x is not None and abs(a - x) < 1:
|
||||
sys.exit(0)
|
||||
if x is None or (a is not None and a > x):
|
||||
src, dst, sreg, dreg, who = arm, x64, arm_reg, x64_reg, "arm64"
|
||||
else:
|
||||
src, dst, sreg, dreg, who = x64, arm, x64_reg, arm_reg, "x86-64"
|
||||
for f in FILES:
|
||||
os.makedirs(os.path.dirname(os.path.join(dst, f)), exist_ok=True)
|
||||
# copy2 keeps the time stamp, so both match until one client signs in again.
|
||||
shutil.copy2(os.path.join(src, f), os.path.join(dst, f))
|
||||
if os.path.isfile(sreg):
|
||||
st = open(sreg, encoding="utf-8", errors="surrogateescape").read()
|
||||
if not os.path.isfile(dreg):
|
||||
os.makedirs(os.path.dirname(dreg), exist_ok=True)
|
||||
shutil.copy2(sreg, dreg)
|
||||
if a is not None or x is not None:
|
||||
if x is None or (a is not None and a > x):
|
||||
src, dst, sreg, dreg, who = arm, x64, arm_reg, x64_reg, "arm64"
|
||||
else:
|
||||
dt = open(dreg, encoding="utf-8", errors="surrogateescape").read()
|
||||
for key in ("AutoLoginUser", "RememberPassword"):
|
||||
v = re.search(r'"%s"\s*"([^"]*)"' % key, st)
|
||||
if v and re.search(r'"%s"\s*"' % key, dt):
|
||||
dt = re.sub(r'("%s"\s*")[^"]*(")' % key, lambda m: m.group(1) + v.group(1) + m.group(2), dt, count=1)
|
||||
open(dreg, "w", encoding="utf-8", errors="surrogateescape").write(dt)
|
||||
print("droiddeck-steam-x64: sign-in shared from the %s client (the newer one)" % who)
|
||||
src, dst, sreg, dreg, who = x64, arm, x64_reg, arm_reg, "x86-64"
|
||||
copy = a is None or x is None or abs(a - x) >= 1
|
||||
if copy:
|
||||
for f in FILES:
|
||||
os.makedirs(os.path.dirname(os.path.join(dst, f)), exist_ok=True)
|
||||
# copy2 keeps the time stamp, so both match until one client signs in again.
|
||||
shutil.copy2(os.path.join(src, f), os.path.join(dst, f))
|
||||
if os.path.isfile(sreg) and not os.path.isfile(dreg):
|
||||
os.makedirs(os.path.dirname(dreg), exist_ok=True)
|
||||
shutil.copy2(sreg, dreg)
|
||||
set_keys(sreg, dreg, copy)
|
||||
set_keys(dreg, sreg, False)
|
||||
add_accounts(dst, accounts(src))
|
||||
add_accounts(src, accounts(dst))
|
||||
if copy:
|
||||
print("droiddeck-steam-x64: sign-in shared from the %s client (the newer one)" % who)
|
||||
PY
|
||||
}
|
||||
|
||||
|
||||
@@ -208,6 +208,19 @@ static long openat_in_root(int dirfd, const char *path, int flags, long mode) {
|
||||
}
|
||||
memmove(pending + target_len, tail, rest_len + 1);
|
||||
if (pending[0] == '/') {
|
||||
char saved = pending[target_len];
|
||||
pending[target_len] = 0;
|
||||
const char *inside = pending;
|
||||
while (*inside == '/') {
|
||||
inside++;
|
||||
}
|
||||
bl_stat probe;
|
||||
int host = *inside && real_syscall(BL_SYS_FSTATAT, (long)dirfd, (long)inside, (long)&probe, (long)AT_SYMLINK_NOFOLLOW) != 0 &&
|
||||
errno == ENOENT && real_syscall(BL_SYS_FSTATAT, (long)AT_FDCWD, (long)pending, (long)&probe, 0L) == 0;
|
||||
pending[target_len] = saved;
|
||||
if (host) {
|
||||
return real_syscall(SYS_openat, (long)AT_FDCWD, (long)pending, (long)flags, mode);
|
||||
}
|
||||
resolved_len = 0;
|
||||
}
|
||||
rest = pending;
|
||||
|
||||
@@ -117,8 +117,17 @@ CASES = [
|
||||
("o", os.O_RDONLY, "proc/self/status", "proc"),
|
||||
]
|
||||
|
||||
# Only the preload: an absolute link to a path missing in the tree but present outside it, as droiddeck-fex's rootfs has.
|
||||
HOST_CASES = [
|
||||
("r", os.O_RDONLY, "lib/x86_64-linux-gnu/libbar.so.2", "host:libbar.so.2"),
|
||||
("r", os.O_RDONLY, "hostdir/sub/data", "host:sub/data"),
|
||||
("r", os.O_RDONLY | os.O_DIRECTORY, "hostdir/sub", "host:sub"),
|
||||
("r", os.O_RDONLY, "etc/alternatives/hn", "etc/hostname"),
|
||||
("r", os.O_RDONLY, "hostdir/missing", "ENOENT"),
|
||||
]
|
||||
|
||||
def make_tree(root):
|
||||
|
||||
def make_tree(root, outside):
|
||||
for folder in ("usr/lib/x86_64-linux-gnu", "etc/alternatives", "loop", "chain"):
|
||||
(root / folder).mkdir(parents=True)
|
||||
(root / "usr/lib/x86_64-linux-gnu/libfoo.so.1.2").write_text("x86")
|
||||
@@ -129,16 +138,24 @@ def make_tree(root):
|
||||
"etc/alternatives/hn": "/etc/hostname", "etc/escape": "../../../../../../etc/hostname", "loop/a": "b",
|
||||
"loop/b": "a", "dirlink": "/usr", "rootlink": "/", "dangling": "/usr/newfile"}
|
||||
links.update({"chain/l%d" % i: ("l%d" % (i + 1) if i < 41 else "end") for i in range(1, 42)})
|
||||
links.update({"usr/lib/x86_64-linux-gnu/libbar.so.2": str(outside / "libbar.so.2"), "hostdir": str(outside)})
|
||||
for link, target in links.items():
|
||||
os.symlink(target, root / link)
|
||||
|
||||
|
||||
def labels(lines, root):
|
||||
def make_outside(outside):
|
||||
(outside / "sub").mkdir(parents=True)
|
||||
(outside / "libbar.so.2").write_text("host")
|
||||
(outside / "sub/data").write_text("host")
|
||||
|
||||
|
||||
def labels(lines, root, outside):
|
||||
inodes = {(os.lstat(root).st_dev, os.lstat(root).st_ino): "."}
|
||||
for folder, dirs, files in os.walk(root):
|
||||
for name in dirs + files:
|
||||
st = os.lstat(os.path.join(folder, name))
|
||||
inodes[(st.st_dev, st.st_ino)] = os.path.relpath(os.path.join(folder, name), root)
|
||||
for base, prefix in ((root, ""), (outside, "host:")):
|
||||
for folder, dirs, files in os.walk(base):
|
||||
for name in dirs + files:
|
||||
st = os.lstat(os.path.join(folder, name))
|
||||
inodes[(st.st_dev, st.st_ino)] = prefix + os.path.relpath(os.path.join(folder, name), base)
|
||||
out = []
|
||||
for line in lines:
|
||||
kind, *values = line.split()
|
||||
@@ -161,16 +178,19 @@ class Openat2PreloadTest(unittest.TestCase):
|
||||
source.write_text(PROGRAM)
|
||||
subprocess.run(["cc", "-O2", "-Wall", "-Werror", "-o", str(cls.tmp / "openat2"), str(source), "-ldl"], check=True)
|
||||
cls.trees = (cls.tmp / "kernel", cls.tmp / "preload")
|
||||
cls.outside = cls.tmp / "outside"
|
||||
make_outside(cls.outside)
|
||||
for tree in cls.trees:
|
||||
make_tree(tree)
|
||||
cases = "".join("%s\t%d\t%s\n" % case[:3] for case in CASES)
|
||||
make_tree(tree, cls.outside)
|
||||
every = CASES + HOST_CASES
|
||||
cases = "".join("%s\t%d\t%s\n" % case[:3] for case in every)
|
||||
result = subprocess.run([str(cls.tmp / "openat2"), *map(str, cls.trees)], input=cases, capture_output=True, text=True,
|
||||
env=dict(os.environ, LD_PRELOAD=str(cls.lib)), timeout=60)
|
||||
if result.returncode != 0:
|
||||
raise AssertionError(result.stdout + result.stderr)
|
||||
lines = result.stdout.splitlines()
|
||||
cls.kernel = labels(lines[1:1 + len(CASES)], cls.trees[0]) if lines[0] == "kernel" else None
|
||||
cls.preload = labels(lines[-len(CASES):], cls.trees[1])
|
||||
cls.kernel = labels(lines[1:1 + len(CASES)], cls.trees[0], cls.outside) if lines[0] == "kernel" else None
|
||||
cls.preload = labels(lines[-len(every):], cls.trees[1], cls.outside)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
@@ -181,6 +201,10 @@ class Openat2PreloadTest(unittest.TestCase):
|
||||
self.assertEqual(got, case[3], case)
|
||||
self.assertTrue((self.trees[1] / "usr/newfile").exists())
|
||||
|
||||
def test_preload_finishes_a_link_to_a_path_only_outside_the_root_there(self):
|
||||
for case, got in zip(HOST_CASES, self.preload[len(CASES):]):
|
||||
self.assertEqual(got, case[3], case)
|
||||
|
||||
def test_the_kernel_agrees(self):
|
||||
if self.kernel is None:
|
||||
self.skipTest("this kernel or sandbox has no openat2")
|
||||
|
||||
@@ -311,6 +311,24 @@ class ImportsTest(unittest.TestCase):
|
||||
self.assertFalse((self.steam / 'steamapps/appmanifest_42.acf').exists())
|
||||
self.assertFalse(os.path.lexists(self.steam / 'steamapps/common/DroidDeck-42'))
|
||||
|
||||
def test_imported_game_stays_imported_when_ownership_is_unconfirmed(self):
|
||||
self.snapshot()
|
||||
imports.route(self.steam, self.acct, [self.game])
|
||||
for owned, account in [(False, '123'), (True, '456')]:
|
||||
self.snapshot(owned, account)
|
||||
self.assertEqual(([], {str(self.game['appid']): 42}), imports.route(self.steam, self.acct, [self.game]))
|
||||
self.assertTrue((self.steam / 'steamapps/appmanifest_42.acf').is_file())
|
||||
|
||||
def test_unlisted_card_library_is_not_another_clients_install(self):
|
||||
card = self.root / 'card'
|
||||
(card / 'steamapps').mkdir(parents=True)
|
||||
(card / 'steamapps/appmanifest_42.acf').write_text('the other client installed it here')
|
||||
self.snapshot()
|
||||
with patch.object(imports, 'LIBRARY', str(card)):
|
||||
self.assertEqual(([], {str(self.game['appid']): 42}), imports.route(self.steam, self.acct, [self.game]))
|
||||
self.assertTrue((self.steam / 'steamapps/appmanifest_42.acf').is_file())
|
||||
self.assertEqual(self.folder, (self.steam / 'steamapps/common/DroidDeck-42').resolve())
|
||||
|
||||
def test_replaced_game_does_not_inherit_cached_ownership(self):
|
||||
self.snapshot()
|
||||
self.exe.write_bytes(b'a different game')
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
from pathlib import Path
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin/droiddeck-steam-x64'
|
||||
|
||||
REGISTRY = ('"Registry"\n{\n\t"HKCU"\n\t{\n\t\t"Software"\n\t\t{\n\t\t\t"Valve"\n\t\t\t{\n\t\t\t\t"Steam"\n\t\t\t\t{\n'
|
||||
'\t\t\t\t\t"language"\t\t"english"\n%s\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n')
|
||||
CONFIG = ('"InstallConfigStore"\n{\n\t"Software"\n\t{\n\t\t"Valve"\n\t\t{\n\t\t\t"Steam"\n\t\t\t{\n%s'
|
||||
'\t\t\t\t"CompatToolMapping"\n\t\t\t\t{\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n')
|
||||
ACCOUNTS = ('\t\t\t\t"Accounts"\n\t\t\t\t{\n\t\t\t\t\t"someone"\n\t\t\t\t\t{\n'
|
||||
'\t\t\t\t\t\t"SteamID"\t\t"76561198000000001"\n\t\t\t\t\t}\n\t\t\t\t}\n')
|
||||
|
||||
|
||||
def local(token):
|
||||
entry = '\t\t"abc1"\t\t"%s"\n' % token if token else ''
|
||||
return '"MachineUserConfigStore"\n{\n\t"ConnectCache"\n\t{\n%s\t}\n}\n' % entry
|
||||
|
||||
|
||||
class X64SignInTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.home = Path(self.temp.name)
|
||||
self.arm = self.home / '.local/share/Steam'
|
||||
self.x64 = self.home / '.droiddeck-x64/.local/share/Steam'
|
||||
for root in (self.arm, self.x64):
|
||||
(root / 'config').mkdir(parents=True)
|
||||
(self.home / '.steam').mkdir()
|
||||
(self.home / '.droiddeck-x64/.steam').mkdir(parents=True)
|
||||
self.write(self.arm / 'local.vdf', local('00ff'))
|
||||
self.write(self.arm / 'config/loginusers.vdf', '"users"\n{\n}\n')
|
||||
self.write(self.arm / 'config/config.vdf', CONFIG % ACCOUNTS)
|
||||
self.write(self.home / '.steam/registry.vdf', REGISTRY % '\t\t\t\t\t"AutoLoginUser"\t\t"someone"\n')
|
||||
self.write(self.x64 / 'local.vdf', local(None))
|
||||
self.write(self.x64 / 'config/loginusers.vdf', '"users"\n{\n}\n')
|
||||
self.write(self.x64 / 'config/config.vdf', CONFIG % '')
|
||||
self.write(self.home / '.droiddeck-x64/.steam/registry.vdf', REGISTRY % '')
|
||||
|
||||
def write(self, path, text):
|
||||
path.write_text(text)
|
||||
|
||||
def sync(self):
|
||||
env = dict(os.environ, HOME=str(self.home))
|
||||
env.pop('XDG_DATA_HOME', None)
|
||||
env.pop('BL_X64_HOME', None)
|
||||
return subprocess.run(['bash', str(SCRIPT), '--sync-login'], env=env, capture_output=True, text=True, check=True)
|
||||
|
||||
def test_a_refused_x64_sign_in_gets_the_token_account_and_auto_login(self):
|
||||
self.assertIn('sign-in shared from the arm64 client', self.sync().stdout)
|
||||
self.assertEqual(local('00ff'), (self.x64 / 'local.vdf').read_text())
|
||||
self.assertIn('"SteamID"\t\t"76561198000000001"', (self.x64 / 'config/config.vdf').read_text())
|
||||
self.assertIn('"AutoLoginUser"\t\t"someone"', (self.home / '.droiddeck-x64/.steam/registry.vdf').read_text())
|
||||
|
||||
def test_an_earlier_copy_still_gains_the_account_and_nothing_repeats(self):
|
||||
self.sync()
|
||||
self.write(self.x64 / 'config/config.vdf', CONFIG % '')
|
||||
self.write(self.home / '.droiddeck-x64/.steam/registry.vdf', REGISTRY % '')
|
||||
self.assertNotIn('sign-in shared', self.sync().stdout)
|
||||
self.assertIn('"76561198000000001"', (self.x64 / 'config/config.vdf').read_text())
|
||||
self.assertIn('"AutoLoginUser"', (self.home / '.droiddeck-x64/.steam/registry.vdf').read_text())
|
||||
self.sync()
|
||||
for root in (self.arm, self.x64):
|
||||
self.assertEqual(1, (root / 'config/config.vdf').read_text().count('"SteamID"'))
|
||||
self.assertEqual(1, (self.home / '.droiddeck-x64/.steam/registry.vdf').read_text().count('AutoLoginUser'))
|
||||
|
||||
def test_a_newer_x64_sign_in_moves_to_arm64(self):
|
||||
self.write(self.x64 / 'local.vdf', local('11ee'))
|
||||
os.utime(self.arm / 'local.vdf', (1, 1))
|
||||
self.assertIn('from the x86-64 client', self.sync().stdout)
|
||||
self.assertEqual(local('11ee'), (self.arm / 'local.vdf').read_text())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||