diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt index 3488bf05a..3edac79a4 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchApi.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchApi.kt index 496691fee..293a59c46 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchApi.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchApi.kt @@ -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") diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchCollections.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchCollections.kt new file mode 100644 index 000000000..30e216f10 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchCollections.kt @@ -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>(emptyList()) + private set + var selected by mutableStateOf(null) + private set + private val games = mutableStateMapOf>() + private val loading = ConcurrentHashMap.newKeySet() + @Volatile private var generation = 0 + + fun items(): List = games.values.flatten() + + fun gamesOf(id: String): List? = 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() + val seen = HashSet() + 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 = (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 { + 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, "") } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/CollectionsIcon.kt b/app/src/main/java/com/droiddeck/launcher/ui/CollectionsIcon.kt new file mode 100644 index 000000000..ade8c967f --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/CollectionsIcon.kt @@ -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() diff --git a/app/src/main/java/com/droiddeck/launcher/ui/LibraryPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/LibraryPage.kt index 431dc647b..4a3b24ce6 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/LibraryPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/LibraryPage.kt @@ -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, items: List): List { + 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): List = 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?>(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, 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(null) } + Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) { + HeaderIconButton(title + " · " + (ItchCollections.chosen()?.title ?: all), CollectionsIcon, "itch-collections") { + val from = at ?: return@HeaderIconButton + val choices = listOf(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 diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index fe7c0e138..68dcfe180 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -3219,6 +3219,7 @@ إزالة من المكتبة في المكتبة عرض + المجموعات سحابية إعدادات السحابة diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 1609361f2..413588da6 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -3069,6 +3069,7 @@ Quitar de Biblioteca En Biblioteca Mostrar + Colecciones Nube Configuración de nube diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index a40f3e38d..3429ff0ec 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -3083,6 +3083,7 @@ Премахни от Библиотека В Библиотека Показване + Колекции Облак Настройки на облака diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 4d493645a..3c41609da 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -3151,6 +3151,7 @@ Odebrat z knihovny V knihovně Zobrazit + Kolekce Cloud Nastavení cloudu diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index f737e6bd7..166786e06 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -3083,6 +3083,7 @@ Fjern fra Bibliotek I Bibliotek Vis + Samlinger Sky Skyindstillinger diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 8e9b65332..76d7e59bc 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -3083,6 +3083,7 @@ Aus der Bibliothek entfernen In der Bibliothek Anzeigen + Sammlungen Cloud Cloud-Einstellungen diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 2da1c1b2a..266eeb6fd 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -3083,6 +3083,7 @@ Αφαίρεση από τη Βιβλιοθήκη Στη Βιβλιοθήκη Εμφάνιση + Συλλογές Cloud Ρυθμίσεις cloud diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 45f4cfc67..196207fb1 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -3119,6 +3119,7 @@ Quitar de la Biblioteca En la Biblioteca Mostrar + Colecciones Nube Ajustes de nube diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index ddf4d4605..ffe219902 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -3083,6 +3083,7 @@ Poista kirjastosta Kirjastossa Näytä + Kokoelmat Pilvi Pilviasetukset diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 535cbf487..79f4a85cd 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -3119,6 +3119,7 @@ Retirer de la Bibliothèque Dans la Bibliothèque Afficher + Collections Cloud Paramètres cloud diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index be2c1b803..858b13859 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -3083,6 +3083,7 @@ लाइब्रेरी से हटाएँ लाइब्रेरी में दिखाएँ + संग्रह क्लाउड क्लाउड सेटिंग्स diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 6902c2cc3..b68fb6a5f 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -3083,6 +3083,7 @@ Eltávolítás a könyvtárból A könyvtárban Megjelenítés + Gyűjtemények Felhő Felhőbeállítások diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 24ce23f45..8f2661e2a 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -3073,6 +3073,7 @@ Hapus dari Pustaka Di Pustaka Tampilkan + Koleksi Cloud Pengaturan cloud diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 524eba544..9c3628c8e 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -3117,6 +3117,7 @@ Rimuovi dalla Libreria Nella Libreria Mostra + Raccolte Cloud Impostazioni cloud diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 4d605fa6b..b75b63bc7 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -3060,6 +3060,7 @@ ライブラリから削除 ライブラリにあります 表示 + コレクション クラウド クラウド設定 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 9420ce605..cb0648a4f 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -3060,6 +3060,7 @@ 라이브러리에서 제거 라이브러리에 있음 표시 + 컬렉션 클라우드 클라우드 설정 diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 218fc9a81..88b80ac4b 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -3058,6 +3058,7 @@ Alih keluar dari Pustaka Dalam Pustaka Tunjukkan + Koleksi Awan Tetapan awan diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index bbb6c8a24..89be0988d 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -3083,6 +3083,7 @@ Fjern fra Bibliotek I Bibliotek Vis + Samlinger Sky Skyinnstillinger diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index e8c394cf1..8d0891046 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -3083,6 +3083,7 @@ Verwijderen uit Bibliotheek In Bibliotheek Weergeven + Collecties Cloud Cloudinstellingen diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 8d1736c64..3065d3644 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -3154,6 +3154,7 @@ Usuń z biblioteki W bibliotece Pokaż + Kolekcje Chmura Ustawienia chmury diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index f2c4bf67c..4043426b4 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -3117,6 +3117,7 @@ Remover da Biblioteca Na Biblioteca Mostrar + Coleções Nuvem Definições da nuvem diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 3ade72aee..9f37ed2c7 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -3117,6 +3117,7 @@ Remover da Biblioteca Na Biblioteca Mostrar + Coleções Nuvem Configurações da nuvem diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 2305d87ab..ddfca2fbc 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -3117,6 +3117,7 @@ Elimină din Bibliotecă În Bibliotecă Afișează + Colecții Cloud Setări cloud diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 9a7447b3d..040315b9a 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -3153,6 +3153,7 @@ Убрать из библиотеки В библиотеке Показать + Коллекции Облако Настройки облака diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index f50d90edf..8a379c576 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -3083,6 +3083,7 @@ Ta bort från Bibliotek I Bibliotek Visa + Samlingar Moln Molninställningar diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index ef164e408..d572994fb 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -3058,6 +3058,7 @@ นำออกจากไลบรารี อยู่ในไลบรารี แสดง + คอลเลกชัน คลาวด์ การตั้งค่าคลาวด์ diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 379511296..48a111359 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -3085,6 +3085,7 @@ Kütüphane içinden kaldır Kütüphane içinde Göster + Koleksiyonlar Bulut Bulut ayarları diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 78eef7de8..fdc4aca60 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -3151,6 +3151,7 @@ Прибрати з бібліотеки У бібліотеці Показати + Колекції Хмара Налаштування хмари diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 475bef045..cf89b0ba7 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -3058,6 +3058,7 @@ Xóa khỏi Thư viện Trong Thư viện Hiển thị + Bộ sưu tập Đám mây Cài đặt đám mây diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 2b5f4083a..d946abc57 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -3062,6 +3062,7 @@ 从游戏库移除 已在游戏库 显示 + 收藏集 云 云设置 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 54058fe19..cfd0eea3c 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -3061,6 +3061,7 @@ 從收藏庫移除 已在收藏庫 顯示 + 收藏集 雲端 雲端設定 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 88adc508f..d5e9d93f7 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -3061,6 +3061,7 @@ 從收藏庫移除 已在收藏庫 顯示 + 收藏集 雲端 雲端設定 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d826e83df..661b321f6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3236,6 +3236,7 @@ Remove from Library In Library Show + Collections Cloud Cloud settings Xbox Cloud Gaming diff --git a/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchCollectionsTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchCollectionsTest.kt new file mode 100644 index 000000000..77fe2b2d7 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchCollectionsTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/LibraryEntriesTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/LibraryEntriesTest.kt index 3bc153e4d..d703d09be 100644 --- a/app/src/test/java/com/droiddeck/launcher/ui/LibraryEntriesTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/ui/LibraryEntriesTest.kt @@ -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(), collectionEntries(all, emptyList()).map { it.key }) + } } diff --git a/docs/development/itch-collections.md b/docs/development/itch-collections.md new file mode 100644 index 000000000..9a2f1419a --- /dev/null +++ b/docs/development/itch-collections.md @@ -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//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: collections`, `itch.io: collection has 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.