mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 02:51:10 +00:00
feat(itch): collections filter in the Library (#1597)
* Add itch.io collections to the Library A Collections button on the Library's itch tab filters the library to one collection of the signed-in account. Its games that are not installed show in the Not installed section. * Keep itch collections complete across failures, sign-out and empty libraries A failed later page no longer publishes a partial collection, selecting a collection refreshes it, pagination runs until an empty page, signing out clears collection state, and the itch chip shows for a signed-in account with no owned games. * Tie itch collection requests to the account that started them Requests capture the account generation before reading the key and dedupe per generation, sign-out clears collections after the token is removed, and the collections load follows the itch sign-in state. * Keep paging itch collections past a page of already seen games * Run the itch collections tests under Robolectric like the other itch tests * Let the Installed filter apply inside a collection and keep the grid for an empty itch account * fix(itch): stop paging a collection when a page adds no new games * Keep the collections icon on the itch collections tab when it opens --------- Co-authored-by: maxjivi05 <maxjividen@gmail.com>
This commit is contained in:
1 parent
f13916ee25
commit
d905f03bf7
41 files changed
+313
-12
No files matched your search
@@ -130,7 +130,7 @@ object StoresState {
|
||||
|
||||
fun detailItem(store: Store, id: String): CatalogItem? = detailCatalogItem(
|
||||
store, id, library[store].orEmpty(),
|
||||
found[store].orEmpty() + if (store == Store.ITCH) com.droiddeck.launcher.stores.itch.ItchBrowseState.items else emptyList(),
|
||||
found[store].orEmpty() + if (store == Store.ITCH) com.droiddeck.launcher.stores.itch.ItchBrowseState.items + com.droiddeck.launcher.stores.itch.ItchCollections.items() else emptyList(),
|
||||
shelves[store],
|
||||
)
|
||||
|
||||
@@ -191,6 +191,7 @@ object StoresState {
|
||||
backend?.signOut(app)
|
||||
if (store == Store.GOG) com.droiddeck.launcher.stores.gog.GogComet.replaceAccount { StoreAccounts.clear(app, store) }
|
||||
else if (store != Store.XBOX || backend == null) StoreAccounts.clear(app, store)
|
||||
if (store == Store.ITCH) com.droiddeck.launcher.stores.itch.ItchCollections.clear()
|
||||
main.post {
|
||||
if (store == Store.XBOX && StoreAccounts.signedInAs(app, store) != null) return@post
|
||||
accounts.remove(store); library.remove(store); status.remove(store); problems.remove(store); libraryAsked.remove(store)
|
||||
|
||||
@@ -19,6 +19,11 @@ internal object ItchApi {
|
||||
|
||||
fun ownedKeys(key: String, page: Int): JSONObject? = get("/profile/owned-keys?page=$page&per_page=500", key)
|
||||
|
||||
fun collections(key: String): JSONArray? = get("/profile/collections", key)?.optJSONArray("collections")
|
||||
|
||||
fun collectionGames(key: String, collectionId: String, page: Int): JSONObject? =
|
||||
get("/collections/$collectionId/collection-games?page=$page", key)
|
||||
|
||||
fun uploads(key: String, gameId: String, downloadKeyId: String): JSONArray? =
|
||||
get("/games/$gameId/uploads" + keyQuery(downloadKeyId), key)?.optJSONArray("uploads")
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.StoresState
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
internal class ItchCollection(val id: String, val title: String, val count: Int)
|
||||
|
||||
internal object ItchCollections {
|
||||
var list by mutableStateOf<List<ItchCollection>>(emptyList())
|
||||
private set
|
||||
var selected by mutableStateOf<String?>(null)
|
||||
private set
|
||||
private val games = mutableStateMapOf<String, List<CatalogItem>>()
|
||||
private val loading = ConcurrentHashMap.newKeySet<String>()
|
||||
@Volatile private var generation = 0
|
||||
|
||||
fun items(): List<CatalogItem> = games.values.flatten()
|
||||
|
||||
fun gamesOf(id: String): List<CatalogItem>? = games[id]
|
||||
|
||||
fun chosen(): ItchCollection? = list.firstOrNull { it.id == selected }
|
||||
|
||||
fun clear() {
|
||||
generation++
|
||||
StoresState.post {
|
||||
list = emptyList()
|
||||
selected = null
|
||||
games.clear()
|
||||
}
|
||||
}
|
||||
|
||||
fun load(context: Context) {
|
||||
val app = context.applicationContext
|
||||
val started = generation
|
||||
val guard = "$started:"
|
||||
if (!loading.add(guard)) return
|
||||
Thread({
|
||||
try {
|
||||
val key = ItchAuth.key(app)
|
||||
if (key.isBlank()) return@Thread
|
||||
val parsed = parseCollections(ItchApi.collections(key) ?: return@Thread)
|
||||
if (started != generation) return@Thread
|
||||
StoresState.post {
|
||||
if (started != generation) return@post
|
||||
list = parsed
|
||||
if (selected != null && parsed.none { it.id == selected }) selected = null
|
||||
}
|
||||
StoresState.logLine("itch.io: ${parsed.size} collections")
|
||||
selected?.let { fetchGames(key, it, started) }
|
||||
} finally {
|
||||
loading.remove(guard)
|
||||
}
|
||||
}, "itch-collections").start()
|
||||
}
|
||||
|
||||
fun select(context: Context, id: String?) {
|
||||
selected = id
|
||||
if (id == null) return
|
||||
val app = context.applicationContext
|
||||
val started = generation
|
||||
Thread({
|
||||
val key = ItchAuth.key(app)
|
||||
if (key.isNotBlank()) fetchGames(key, id, started)
|
||||
}, "itch-collection-games").start()
|
||||
}
|
||||
|
||||
private fun fetchGames(key: String, id: String, started: Int) {
|
||||
val guard = "$started:$id"
|
||||
if (!loading.add(guard)) return
|
||||
try {
|
||||
val out = ArrayList<CatalogItem>()
|
||||
val seen = HashSet<String>()
|
||||
var page = 1
|
||||
while (true) {
|
||||
val body = ItchApi.collectionGames(key, id, page)
|
||||
if (body == null) {
|
||||
StoresState.logLine("itch.io: collection $id page $page failed")
|
||||
return
|
||||
}
|
||||
val batch = parseGames(body)
|
||||
if (batch.isEmpty()) break
|
||||
val before = out.size
|
||||
out.addAll(batch.filter { seen.add(it.id) })
|
||||
if (out.size == before) break
|
||||
page++
|
||||
}
|
||||
val distinct = out.toList()
|
||||
StoresState.post { if (started == generation) games[id] = distinct }
|
||||
StoresState.logLine("itch.io: collection $id has ${distinct.size} games")
|
||||
} finally {
|
||||
loading.remove(guard)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun parseCollections(array: JSONArray): List<ItchCollection> = (0 until array.length()).mapNotNull { i ->
|
||||
val c = array.optJSONObject(i) ?: return@mapNotNull null
|
||||
val id = c.opt("id")?.toString().orEmpty().takeIf { it.isNotBlank() && it != "null" } ?: return@mapNotNull null
|
||||
val title = c.optString("title").ifBlank { return@mapNotNull null }
|
||||
ItchCollection(id, title, c.optInt("games_count", c.optInt("gamesCount")))
|
||||
}
|
||||
|
||||
internal fun parseGames(body: JSONObject): List<CatalogItem> {
|
||||
val rows = body.optJSONArray("collection_games") ?: body.optJSONArray("collectionGames") ?: return emptyList()
|
||||
return (0 until rows.length()).mapNotNull { i ->
|
||||
rows.optJSONObject(i)?.optJSONObject("game")?.let { ItchLibrary.item(it, "") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.addPathNodes
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
internal val CollectionsIcon: ImageVector = ImageVector.Builder("Collections", 24.dp, 24.dp, 24f, 24f).apply {
|
||||
for (d in listOf(
|
||||
"M2.65,4.6H7.33V22H2.65Z",
|
||||
"M8.32,2H13V22H8.32Z",
|
||||
"M13.9,5.4H18.5L21.4,22H16.8Z",
|
||||
)) addPath(addPathNodes(d), fill = SolidColor(Color.Black))
|
||||
}.build()
|
||||
@@ -89,6 +89,8 @@ import com.droiddeck.launcher.stores.StoresState
|
||||
import com.droiddeck.launcher.stores.arrangeStoreTabs
|
||||
import com.droiddeck.launcher.stores.download.DownloadEntry
|
||||
import com.droiddeck.launcher.stores.formatBytes
|
||||
import com.droiddeck.launcher.stores.itch.ItchCollection
|
||||
import com.droiddeck.launcher.stores.itch.ItchCollections
|
||||
import com.droiddeck.launcher.stores.retainOrder
|
||||
import com.droiddeck.launcher.stores.xbox.XboxLibraryFilter
|
||||
import com.droiddeck.launcher.stores.sortGames
|
||||
@@ -127,6 +129,14 @@ internal fun libraryEntries(
|
||||
return out.distinctBy { it.key }
|
||||
}
|
||||
|
||||
internal fun collectionEntries(all: List<LibraryEntry>, items: List<CatalogItem>): List<LibraryEntry> {
|
||||
val keys = items.map { it.key }.toSet()
|
||||
val itch = all.filter { it.source == Store.ITCH.id }
|
||||
val have = itch.mapNotNull { it.storeKey }.toSet()
|
||||
return itch.filter { it.storeKey in keys } +
|
||||
items.filter { it.key !in have }.map { LibraryEntry(GameRef.key(it), Store.ITCH.id, it.title, null, it, false) }
|
||||
}
|
||||
|
||||
internal fun installedFirst(entries: List<LibraryEntry>): List<LibraryEntry> = entries.partition { it.installed }.let { (on, off) -> on + off }
|
||||
|
||||
internal const val LIBRARY_ALL = "all"
|
||||
@@ -169,19 +179,30 @@ internal fun LibraryPage(s: FrontEndState, a: FrontEndActions, selected: String,
|
||||
val all = remember(s.steamGames, cloudRows, owned, installedStore) { libraryEntries(s.steamGames + cloudRows, owned, installedStore) }
|
||||
LaunchedEffect(Unit) { XboxLibraryFilter.load(ctx) }
|
||||
var showUninstalled by remember(LibraryView.ownedRequest) { mutableStateOf(SessionPrefs.libraryShowUninstalled(ctx)) }
|
||||
var collectionShows by remember { mutableStateOf<Pair<String, Boolean>?>(null) }
|
||||
val setShowUninstalled = { show: Boolean ->
|
||||
showUninstalled = show
|
||||
SessionPrefs.setLibraryShowUninstalled(ctx, show)
|
||||
val picked = ItchCollections.selected.takeIf { LibraryView.source == Store.ITCH.id }
|
||||
if (picked != null) collectionShows = picked to show
|
||||
else {
|
||||
showUninstalled = show
|
||||
SessionPrefs.setLibraryShowUninstalled(ctx, show)
|
||||
}
|
||||
}
|
||||
val sources = librarySources(all, SessionPrefs.storesTabOrder(ctx))
|
||||
val itchSignedIn = owned.containsKey(Store.ITCH)
|
||||
val listed = librarySources(all, SessionPrefs.storesTabOrder(ctx))
|
||||
val sources = if (itchSignedIn && Store.ITCH.id !in listed) listed + Store.ITCH.id else listed
|
||||
val source = LibraryView.source.takeIf { it == LIBRARY_ALL || it in sources } ?: LIBRARY_ALL
|
||||
val q = LibraryView.query.trim().lowercase()
|
||||
val xboxFilter = if (source == Store.XBOX.id) XboxLibraryFilter.current else XboxLibraryFilter.ALL
|
||||
val matching = all.filter {
|
||||
LaunchedEffect(source, itchSignedIn) { if (source == Store.ITCH.id && itchSignedIn) ItchCollections.load(ctx) }
|
||||
val collection = if (source == Store.ITCH.id) ItchCollections.chosen() else null
|
||||
val pool = if (collection == null) all else collectionEntries(all, ItchCollections.gamesOf(collection.id).orEmpty())
|
||||
val matching = pool.filter {
|
||||
(source == LIBRARY_ALL || it.source == source) && (q.isEmpty() || it.title.lowercase().contains(q)) && keepsXbox(xboxFilter, it)
|
||||
}
|
||||
val candidates = if (showUninstalled) matching else matching.filter { it.installed }
|
||||
val hiddenUninstalled = if (showUninstalled) 0 else matching.count { !it.installed }
|
||||
val listUninstalled = if (collection == null) showUninstalled else collectionShows?.takeIf { it.first == collection.id }?.second ?: true
|
||||
val candidates = if (listUninstalled) matching else matching.filter { it.installed }
|
||||
val hiddenUninstalled = if (listUninstalled) 0 else matching.count { !it.installed }
|
||||
val sort = s.storesSort
|
||||
val sizeSort = sort == "sizeAsc" || sort == "sizeDesc"
|
||||
val installedAt = StoresState.installed.associate { it.key to it.sidecar.installedAt }
|
||||
@@ -264,7 +285,7 @@ internal fun LibraryPage(s: FrontEndState, a: FrontEndActions, selected: String,
|
||||
kotlinx.coroutines.delay(Motion.ms(240).toLong())
|
||||
focusWithinFrames({ ff.last?.startsWith("card:") == true }) { if (ff.firstTileAttached > 0) ff.firstTile else FocusRequester.Default }
|
||||
}
|
||||
if (all.isEmpty() && open == null) {
|
||||
if (all.isEmpty() && open == null && !itchSignedIn) {
|
||||
LibraryEmpty(s, a, modifier)
|
||||
return
|
||||
}
|
||||
@@ -272,7 +293,7 @@ internal fun LibraryPage(s: FrontEndState, a: FrontEndActions, selected: String,
|
||||
val detailScroll = rememberScrollState()
|
||||
var cycled by remember { mutableIntStateOf(0) }
|
||||
var gridFocused by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(source, sort, showUninstalled) { grid.scrollToItem(0) }
|
||||
LaunchedEffect(source, sort, listUninstalled) { grid.scrollToItem(0) }
|
||||
LaunchedEffect(cycled) {
|
||||
if (cycled == 0 || ff == null || inputMode.inputMode != androidx.compose.ui.input.InputMode.Keyboard) return@LaunchedEffect
|
||||
focusWithinFrames({ gridFocused }) {
|
||||
@@ -288,7 +309,7 @@ internal fun LibraryPage(s: FrontEndState, a: FrontEndActions, selected: String,
|
||||
grid = {
|
||||
LibraryGrid(
|
||||
s, a, grid, visible, split, visible.size, sources, source, sizesPending, syncing,
|
||||
showUninstalled = showUninstalled.takeIf { stores }, onShowUninstalled = setShowUninstalled,
|
||||
showUninstalled = listUninstalled.takeIf { stores }, onShowUninstalled = setShowUninstalled,
|
||||
hiddenUninstalled = hiddenUninstalled,
|
||||
onSource = { LibraryView.source = it },
|
||||
onCycle = { step ->
|
||||
@@ -383,7 +404,7 @@ private fun LibraryGrid(
|
||||
contentPadding = PaddingValues(top = 4.dp, bottom = 16.dp),
|
||||
) {
|
||||
item(key = "header", span = { GridItemSpan(maxLineSpan) }) {
|
||||
Rise(0) { LibraryHeader(s, a, total, searchCleared, source == Store.XBOX.id, showUninstalled, onShowUninstalled) }
|
||||
Rise(0) { LibraryHeader(s, a, total, searchCleared, source == Store.XBOX.id, showUninstalled, onShowUninstalled, collections = source == Store.ITCH.id) }
|
||||
}
|
||||
item(key = "filters", span = { GridItemSpan(maxLineSpan) }) {
|
||||
Rise(1) { LibraryFilters(sources, source, onSource) }
|
||||
@@ -430,7 +451,7 @@ private fun LazyGridScope.libraryGrid(entries: List<LibraryEntry>, empty: Int, t
|
||||
@Composable
|
||||
private fun LibraryHeader(
|
||||
s: FrontEndState, a: FrontEndActions, total: Int, searchCleared: Boolean, xbox: Boolean,
|
||||
showUninstalled: Boolean?, onShowUninstalled: (Boolean) -> Unit,
|
||||
showUninstalled: Boolean?, onShowUninstalled: (Boolean) -> Unit, collections: Boolean,
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val narrow = LocalNarrowPane.current
|
||||
@@ -447,6 +468,7 @@ private fun LibraryHeader(
|
||||
},
|
||||
) {
|
||||
if (showUninstalled != null || xbox) LibraryFilterButton(showUninstalled, onShowUninstalled, xbox)
|
||||
if (collections) CollectionsButton()
|
||||
SortButton(s, a)
|
||||
ViewButton(s, a)
|
||||
GameFileFolderActions(s, a)
|
||||
@@ -700,6 +722,39 @@ private fun LibraryFilterButton(showUninstalled: Boolean?, onShowUninstalled: (B
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CollectionsButton() {
|
||||
val pal = LocalPalette.current
|
||||
val ctx = LocalContext.current
|
||||
val title = stringResource(R.string.itch_collections)
|
||||
val all = stringResource(R.string.stores_tab_all)
|
||||
var at by remember { mutableStateOf<Rect?>(null) }
|
||||
Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) {
|
||||
HeaderIconButton(title + " · " + (ItchCollections.chosen()?.title ?: all), CollectionsIcon, "itch-collections") {
|
||||
val from = at ?: return@HeaderIconButton
|
||||
val choices = listOf<ItchCollection?>(null) + ItchCollections.list
|
||||
Steps.ask(StepAsk(
|
||||
anchor = from, side = StepSide.Left, accent = pal.signal, pillCorner = 8.dp, items = choices.size,
|
||||
handle = { Icon(CollectionsIcon, null, tint = pal.signal, modifier = Modifier.size(18.dp)) },
|
||||
) {
|
||||
StepTitle(title)
|
||||
StepList {
|
||||
choices.forEachIndexed { i, c ->
|
||||
val picked = c?.id == ItchCollections.selected
|
||||
StepChoice(
|
||||
c?.let { it.title + " (" + it.count + ")" } ?: all, selected = picked, enabled = open,
|
||||
modifier = Modifier.fillMaxWidth().stepItem(i).then(if (picked) Modifier.focusRequester(first) else Modifier),
|
||||
) {
|
||||
ItchCollections.select(ctx, c?.id)
|
||||
Steps.fold()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SortButton(s: FrontEndState, a: FrontEndActions) {
|
||||
val pal = LocalPalette.current
|
||||
|
||||
@@ -3219,6 +3219,7 @@
|
||||
<string name="library_remove">إزالة من المكتبة</string>
|
||||
<string name="library_in">في المكتبة</string>
|
||||
<string name="xbox_filter_show">عرض</string>
|
||||
<string name="itch_collections">المجموعات</string>
|
||||
<string name="xbox_filter_cloud">سحابية</string>
|
||||
<string name="stores_xbox_cloud_settings">إعدادات السحابة</string>
|
||||
</resources>
|
||||
@@ -3069,6 +3069,7 @@
|
||||
<string name="library_remove">Quitar de Biblioteca</string>
|
||||
<string name="library_in">En Biblioteca</string>
|
||||
<string name="xbox_filter_show">Mostrar</string>
|
||||
<string name="itch_collections">Colecciones</string>
|
||||
<string name="xbox_filter_cloud">Nube</string>
|
||||
<string name="stores_xbox_cloud_settings">Configuración de nube</string>
|
||||
</resources>
|
||||
@@ -3083,6 +3083,7 @@
|
||||
<string name="library_remove">Премахни от Библиотека</string>
|
||||
<string name="library_in">В Библиотека</string>
|
||||
<string name="xbox_filter_show">Показване</string>
|
||||
<string name="itch_collections">Колекции</string>
|
||||
<string name="xbox_filter_cloud">Облак</string>
|
||||
<string name="stores_xbox_cloud_settings">Настройки на облака</string>
|
||||
</resources>
|
||||
@@ -3151,6 +3151,7 @@
|
||||
<string name="library_remove">Odebrat z knihovny</string>
|
||||
<string name="library_in">V knihovně</string>
|
||||
<string name="xbox_filter_show">Zobrazit</string>
|
||||
<string name="itch_collections">Kolekce</string>
|
||||
<string name="xbox_filter_cloud">Cloud</string>
|
||||
<string name="stores_xbox_cloud_settings">Nastavení cloudu</string>
|
||||
</resources>
|
||||
@@ -3083,6 +3083,7 @@
|
||||
<string name="library_remove">Fjern fra Bibliotek</string>
|
||||
<string name="library_in">I Bibliotek</string>
|
||||
<string name="xbox_filter_show">Vis</string>
|
||||
<string name="itch_collections">Samlinger</string>
|
||||
<string name="xbox_filter_cloud">Sky</string>
|
||||
<string name="stores_xbox_cloud_settings">Skyindstillinger</string>
|
||||
</resources>
|
||||
@@ -3083,6 +3083,7 @@
|
||||
<string name="library_remove">Aus der Bibliothek entfernen</string>
|
||||
<string name="library_in">In der Bibliothek</string>
|
||||
<string name="xbox_filter_show">Anzeigen</string>
|
||||
<string name="itch_collections">Sammlungen</string>
|
||||
<string name="xbox_filter_cloud">Cloud</string>
|
||||
<string name="stores_xbox_cloud_settings">Cloud-Einstellungen</string>
|
||||
</resources>
|
||||
@@ -3083,6 +3083,7 @@
|
||||
<string name="library_remove">Αφαίρεση από τη Βιβλιοθήκη</string>
|
||||
<string name="library_in">Στη Βιβλιοθήκη</string>
|
||||
<string name="xbox_filter_show">Εμφάνιση</string>
|
||||
<string name="itch_collections">Συλλογές</string>
|
||||
<string name="xbox_filter_cloud">Cloud</string>
|
||||
<string name="stores_xbox_cloud_settings">Ρυθμίσεις cloud</string>
|
||||
</resources>
|
||||
@@ -3119,6 +3119,7 @@
|
||||
<string name="library_remove">Quitar de la Biblioteca</string>
|
||||
<string name="library_in">En la Biblioteca</string>
|
||||
<string name="xbox_filter_show">Mostrar</string>
|
||||
<string name="itch_collections">Colecciones</string>
|
||||
<string name="xbox_filter_cloud">Nube</string>
|
||||
<string name="stores_xbox_cloud_settings">Ajustes de nube</string>
|
||||
</resources>
|
||||
@@ -3083,6 +3083,7 @@
|
||||
<string name="library_remove">Poista kirjastosta</string>
|
||||
<string name="library_in">Kirjastossa</string>
|
||||
<string name="xbox_filter_show">Näytä</string>
|
||||
<string name="itch_collections">Kokoelmat</string>
|
||||
<string name="xbox_filter_cloud">Pilvi</string>
|
||||
<string name="stores_xbox_cloud_settings">Pilviasetukset</string>
|
||||
</resources>
|
||||
@@ -3119,6 +3119,7 @@
|
||||
<string name="library_remove">Retirer de la Bibliothèque</string>
|
||||
<string name="library_in">Dans la Bibliothèque</string>
|
||||
<string name="xbox_filter_show">Afficher</string>
|
||||
<string name="itch_collections">Collections</string>
|
||||
<string name="xbox_filter_cloud">Cloud</string>
|
||||
<string name="stores_xbox_cloud_settings">Paramètres cloud</string>
|
||||
</resources>
|
||||
@@ -3083,6 +3083,7 @@
|
||||
<string name="library_remove">लाइब्रेरी से हटाएँ</string>
|
||||
<string name="library_in">लाइब्रेरी में</string>
|
||||
<string name="xbox_filter_show">दिखाएँ</string>
|
||||
<string name="itch_collections">संग्रह</string>
|
||||
<string name="xbox_filter_cloud">क्लाउड</string>
|
||||
<string name="stores_xbox_cloud_settings">क्लाउड सेटिंग्स</string>
|
||||
</resources>
|
||||
@@ -3083,6 +3083,7 @@
|
||||
<string name="library_remove">Eltávolítás a könyvtárból</string>
|
||||
<string name="library_in">A könyvtárban</string>
|
||||
<string name="xbox_filter_show">Megjelenítés</string>
|
||||
<string name="itch_collections">Gyűjtemények</string>
|
||||
<string name="xbox_filter_cloud">Felhő</string>
|
||||
<string name="stores_xbox_cloud_settings">Felhőbeállítások</string>
|
||||
</resources>
|
||||
@@ -3073,6 +3073,7 @@
|
||||
<string name="library_remove">Hapus dari Pustaka</string>
|
||||
<string name="library_in">Di Pustaka</string>
|
||||
<string name="xbox_filter_show">Tampilkan</string>
|
||||
<string name="itch_collections">Koleksi</string>
|
||||
<string name="xbox_filter_cloud">Cloud</string>
|
||||
<string name="stores_xbox_cloud_settings">Pengaturan cloud</string>
|
||||
</resources>
|
||||
@@ -3117,6 +3117,7 @@
|
||||
<string name="library_remove">Rimuovi dalla Libreria</string>
|
||||
<string name="library_in">Nella Libreria</string>
|
||||
<string name="xbox_filter_show">Mostra</string>
|
||||
<string name="itch_collections">Raccolte</string>
|
||||
<string name="xbox_filter_cloud">Cloud</string>
|
||||
<string name="stores_xbox_cloud_settings">Impostazioni cloud</string>
|
||||
</resources>
|
||||
@@ -3060,6 +3060,7 @@
|
||||
<string name="library_remove">ライブラリから削除</string>
|
||||
<string name="library_in">ライブラリにあります</string>
|
||||
<string name="xbox_filter_show">表示</string>
|
||||
<string name="itch_collections">コレクション</string>
|
||||
<string name="xbox_filter_cloud">クラウド</string>
|
||||
<string name="stores_xbox_cloud_settings">クラウド設定</string>
|
||||
</resources>
|
||||
@@ -3060,6 +3060,7 @@
|
||||
<string name="library_remove">라이브러리에서 제거</string>
|
||||
<string name="library_in">라이브러리에 있음</string>
|
||||
<string name="xbox_filter_show">표시</string>
|
||||
<string name="itch_collections">컬렉션</string>
|
||||
<string name="xbox_filter_cloud">클라우드</string>
|
||||
<string name="stores_xbox_cloud_settings">클라우드 설정</string>
|
||||
</resources>
|
||||
@@ -3058,6 +3058,7 @@
|
||||
<string name="library_remove">Alih keluar dari Pustaka</string>
|
||||
<string name="library_in">Dalam Pustaka</string>
|
||||
<string name="xbox_filter_show">Tunjukkan</string>
|
||||
<string name="itch_collections">Koleksi</string>
|
||||
<string name="xbox_filter_cloud">Awan</string>
|
||||
<string name="stores_xbox_cloud_settings">Tetapan awan</string>
|
||||
</resources>
|
||||
@@ -3083,6 +3083,7 @@
|
||||
<string name="library_remove">Fjern fra Bibliotek</string>
|
||||
<string name="library_in">I Bibliotek</string>
|
||||
<string name="xbox_filter_show">Vis</string>
|
||||
<string name="itch_collections">Samlinger</string>
|
||||
<string name="xbox_filter_cloud">Sky</string>
|
||||
<string name="stores_xbox_cloud_settings">Skyinnstillinger</string>
|
||||
</resources>
|
||||
@@ -3083,6 +3083,7 @@
|
||||
<string name="library_remove">Verwijderen uit Bibliotheek</string>
|
||||
<string name="library_in">In Bibliotheek</string>
|
||||
<string name="xbox_filter_show">Weergeven</string>
|
||||
<string name="itch_collections">Collecties</string>
|
||||
<string name="xbox_filter_cloud">Cloud</string>
|
||||
<string name="stores_xbox_cloud_settings">Cloudinstellingen</string>
|
||||
</resources>
|
||||
@@ -3154,6 +3154,7 @@
|
||||
<string name="library_remove">Usuń z biblioteki</string>
|
||||
<string name="library_in">W bibliotece</string>
|
||||
<string name="xbox_filter_show">Pokaż</string>
|
||||
<string name="itch_collections">Kolekcje</string>
|
||||
<string name="xbox_filter_cloud">Chmura</string>
|
||||
<string name="stores_xbox_cloud_settings">Ustawienia chmury</string>
|
||||
</resources>
|
||||
@@ -3117,6 +3117,7 @@
|
||||
<string name="library_remove">Remover da Biblioteca</string>
|
||||
<string name="library_in">Na Biblioteca</string>
|
||||
<string name="xbox_filter_show">Mostrar</string>
|
||||
<string name="itch_collections">Coleções</string>
|
||||
<string name="xbox_filter_cloud">Nuvem</string>
|
||||
<string name="stores_xbox_cloud_settings">Definições da nuvem</string>
|
||||
</resources>
|
||||
@@ -3117,6 +3117,7 @@
|
||||
<string name="library_remove">Remover da Biblioteca</string>
|
||||
<string name="library_in">Na Biblioteca</string>
|
||||
<string name="xbox_filter_show">Mostrar</string>
|
||||
<string name="itch_collections">Coleções</string>
|
||||
<string name="xbox_filter_cloud">Nuvem</string>
|
||||
<string name="stores_xbox_cloud_settings">Configurações da nuvem</string>
|
||||
</resources>
|
||||
@@ -3117,6 +3117,7 @@
|
||||
<string name="library_remove">Elimină din Bibliotecă</string>
|
||||
<string name="library_in">În Bibliotecă</string>
|
||||
<string name="xbox_filter_show">Afișează</string>
|
||||
<string name="itch_collections">Colecții</string>
|
||||
<string name="xbox_filter_cloud">Cloud</string>
|
||||
<string name="stores_xbox_cloud_settings">Setări cloud</string>
|
||||
</resources>
|
||||
@@ -3153,6 +3153,7 @@
|
||||
<string name="library_remove">Убрать из библиотеки</string>
|
||||
<string name="library_in">В библиотеке</string>
|
||||
<string name="xbox_filter_show">Показать</string>
|
||||
<string name="itch_collections">Коллекции</string>
|
||||
<string name="xbox_filter_cloud">Облако</string>
|
||||
<string name="stores_xbox_cloud_settings">Настройки облака</string>
|
||||
</resources>
|
||||
@@ -3083,6 +3083,7 @@
|
||||
<string name="library_remove">Ta bort från Bibliotek</string>
|
||||
<string name="library_in">I Bibliotek</string>
|
||||
<string name="xbox_filter_show">Visa</string>
|
||||
<string name="itch_collections">Samlingar</string>
|
||||
<string name="xbox_filter_cloud">Moln</string>
|
||||
<string name="stores_xbox_cloud_settings">Molninställningar</string>
|
||||
</resources>
|
||||
@@ -3058,6 +3058,7 @@
|
||||
<string name="library_remove">นำออกจากไลบรารี</string>
|
||||
<string name="library_in">อยู่ในไลบรารี</string>
|
||||
<string name="xbox_filter_show">แสดง</string>
|
||||
<string name="itch_collections">คอลเลกชัน</string>
|
||||
<string name="xbox_filter_cloud">คลาวด์</string>
|
||||
<string name="stores_xbox_cloud_settings">การตั้งค่าคลาวด์</string>
|
||||
</resources>
|
||||
@@ -3085,6 +3085,7 @@
|
||||
<string name="library_remove">Kütüphane içinden kaldır</string>
|
||||
<string name="library_in">Kütüphane içinde</string>
|
||||
<string name="xbox_filter_show">Göster</string>
|
||||
<string name="itch_collections">Koleksiyonlar</string>
|
||||
<string name="xbox_filter_cloud">Bulut</string>
|
||||
<string name="stores_xbox_cloud_settings">Bulut ayarları</string>
|
||||
</resources>
|
||||
@@ -3151,6 +3151,7 @@
|
||||
<string name="library_remove">Прибрати з бібліотеки</string>
|
||||
<string name="library_in">У бібліотеці</string>
|
||||
<string name="xbox_filter_show">Показати</string>
|
||||
<string name="itch_collections">Колекції</string>
|
||||
<string name="xbox_filter_cloud">Хмара</string>
|
||||
<string name="stores_xbox_cloud_settings">Налаштування хмари</string>
|
||||
</resources>
|
||||
@@ -3058,6 +3058,7 @@
|
||||
<string name="library_remove">Xóa khỏi Thư viện</string>
|
||||
<string name="library_in">Trong Thư viện</string>
|
||||
<string name="xbox_filter_show">Hiển thị</string>
|
||||
<string name="itch_collections">Bộ sưu tập</string>
|
||||
<string name="xbox_filter_cloud">Đám mây</string>
|
||||
<string name="stores_xbox_cloud_settings">Cài đặt đám mây</string>
|
||||
</resources>
|
||||
@@ -3062,6 +3062,7 @@
|
||||
<string name="library_remove">从游戏库移除</string>
|
||||
<string name="library_in">已在游戏库</string>
|
||||
<string name="xbox_filter_show">显示</string>
|
||||
<string name="itch_collections">收藏集</string>
|
||||
<string name="xbox_filter_cloud">云</string>
|
||||
<string name="stores_xbox_cloud_settings">云设置</string>
|
||||
</resources>
|
||||
@@ -3061,6 +3061,7 @@
|
||||
<string name="library_remove">從收藏庫移除</string>
|
||||
<string name="library_in">已在收藏庫</string>
|
||||
<string name="xbox_filter_show">顯示</string>
|
||||
<string name="itch_collections">收藏集</string>
|
||||
<string name="xbox_filter_cloud">雲端</string>
|
||||
<string name="stores_xbox_cloud_settings">雲端設定</string>
|
||||
</resources>
|
||||
@@ -3061,6 +3061,7 @@
|
||||
<string name="library_remove">從收藏庫移除</string>
|
||||
<string name="library_in">已在收藏庫</string>
|
||||
<string name="xbox_filter_show">顯示</string>
|
||||
<string name="itch_collections">收藏集</string>
|
||||
<string name="xbox_filter_cloud">雲端</string>
|
||||
<string name="stores_xbox_cloud_settings">雲端設定</string>
|
||||
</resources>
|
||||
@@ -3236,6 +3236,7 @@
|
||||
<string name="library_remove">Remove from Library</string>
|
||||
<string name="library_in">In Library</string>
|
||||
<string name="xbox_filter_show">Show</string>
|
||||
<string name="itch_collections">Collections</string>
|
||||
<string name="xbox_filter_cloud">Cloud</string>
|
||||
<string name="stores_xbox_cloud_settings">Cloud settings</string>
|
||||
<string name="xbox_cloud_gaming" translatable="false">Xbox Cloud Gaming</string>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
@org.junit.runner.RunWith(org.robolectric.RobolectricTestRunner::class)
|
||||
@org.robolectric.annotation.Config(sdk = [33], application = android.app.Application::class)
|
||||
class ItchCollectionsTest {
|
||||
@Test fun collectionsKeepTheirIdTitleAndCount() {
|
||||
val body = JSONArray()
|
||||
.put(JSONObject().put("id", 8356509).put("title", "library").put("games_count", 12))
|
||||
.put(JSONObject().put("id", 7).put("title", "").put("games_count", 3))
|
||||
.put(JSONObject().put("title", "no id"))
|
||||
val list = ItchCollections.parseCollections(body)
|
||||
assertEquals(listOf("8356509"), list.map { it.id })
|
||||
assertEquals("library", list.single().title)
|
||||
assertEquals(12, list.single().count)
|
||||
}
|
||||
|
||||
@Test fun collectionGamesReadBothKeyStylesAndSkipRowsWithoutAGame() {
|
||||
val game = JSONObject().put("id", 99).put("title", "Celeste 64").put("url", "https://noel.itch.io/celeste64")
|
||||
val snake = JSONObject().put("collection_games", JSONArray().put(JSONObject().put("game", game)).put(JSONObject().put("position", 1)))
|
||||
val camel = JSONObject().put("collectionGames", JSONArray().put(JSONObject().put("game", game)))
|
||||
assertEquals(listOf("noel.itch.io/celeste64"), ItchCollections.parseGames(snake).map { it.id })
|
||||
assertEquals(1, ItchCollections.parseGames(camel).size)
|
||||
assertEquals(false, ItchCollections.parseGames(snake).single().owned)
|
||||
assertEquals(0, ItchCollections.parseGames(JSONObject()).size)
|
||||
}
|
||||
}
|
||||
@@ -94,4 +94,23 @@ class LibraryEntriesTest {
|
||||
GameRef.parse("xbox:9NBLGGH4R315").let { assertEquals(Store.XBOX, it.store); assertEquals("9NBLGGH4R315", it.storeId) }
|
||||
GameRef.parse("app:nope:1").let { assertNull(it.store); assertNull(it.appId) }
|
||||
}
|
||||
|
||||
@Test fun aCollectionKeepsItsInstalledGamesAndListsTheRestAsNotInstalled() {
|
||||
val games = listOf(
|
||||
Library.SteamGame(440, "Team Fortress 2", null, "Steam library"),
|
||||
Library.SteamGame(-5, "Celeste", null, Library.ADDED, source = "itch", storeId = "a/celeste"),
|
||||
Library.SteamGame(-6, "Other", null, Library.ADDED, source = "itch", storeId = "a/other"),
|
||||
)
|
||||
val library = mapOf(Store.ITCH to listOf(owned(Store.ITCH, "a/celeste"), owned(Store.ITCH, "a/other"), owned(Store.ITCH, "b/owned")))
|
||||
val all = libraryEntries(games, library, setOf("itch:a/celeste", "itch:a/other"))
|
||||
val collection = listOf(owned(Store.ITCH, "a/celeste"), owned(Store.ITCH, "b/owned"), owned(Store.ITCH, "c/new"))
|
||||
val out = collectionEntries(all, collection)
|
||||
assertEquals(listOf("app:-5", "app:itch:b/owned", "app:itch:c/new"), out.map { it.key })
|
||||
assertEquals(listOf(true, false, false), out.map { it.installed })
|
||||
}
|
||||
|
||||
@Test fun anEmptyCollectionHidesEveryGame() {
|
||||
val all = libraryEntries(listOf(Library.SteamGame(440, "Team Fortress 2", null, "Steam library")), emptyMap(), emptySet())
|
||||
assertEquals(emptyList<String>(), collectionEntries(all, emptyList()).map { it.key })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
# itch.io collections
|
||||
|
||||
On the Library's itch tab a Collections button in the header lists the collections of the signed-in
|
||||
itch account. Picking one hides every game that is not in it; the games of the collection that are not
|
||||
installed show in the existing "Not installed" section, and All brings the full library back. The button
|
||||
exists only while the itch chip is selected.
|
||||
|
||||
## Data
|
||||
|
||||
`ItchCollections` (`stores/itch/ItchCollections.kt`) asks itch with the account's API key:
|
||||
|
||||
```
|
||||
GET /profile/collections -> collections[{id, title, games_count}]
|
||||
GET /collections/<id>/collection-games?page=N -> collection_games[{game{...}}]
|
||||
```
|
||||
|
||||
Pages are read until one comes back empty. Each game goes through `ItchLibrary.item`, the reader the
|
||||
library and shelves share, so it looks the same everywhere. The selection and the loaded games live in
|
||||
memory; the list is asked again whenever the itch tab opens. Counts are logged
|
||||
(`itch.io: <n> collections`, `itch.io: collection <id> has <n> games`).
|
||||
|
||||
## Filtering
|
||||
|
||||
`collectionEntries` keeps the itch entries whose store key is in the collection, then adds the
|
||||
collection games the library has no entry for as not installed ones. Their game page resolves through
|
||||
`StoresState.detailItem`, which also looks at the collection games.
|
||||
Reference in new issue
Block a user