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 317757a8b..482619e6f 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt @@ -126,7 +126,9 @@ object StoresState { fun installedGame(store: Store, id: String): InstalledStoreGame? = installed.firstOrNull { it.sidecar.store == store && it.sidecar.id == id } fun detailItem(store: Store, id: String): CatalogItem? = detailCatalogItem( - store, id, library[store].orEmpty(), found[store].orEmpty(), shelves[store], + store, id, library[store].orEmpty(), + found[store].orEmpty() + if (store == Store.ITCH) com.droiddeck.launcher.stores.itch.ItchBrowseState.items else emptyList(), + shelves[store], ) fun open(context: Context, store: Store, force: Boolean = false) { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBrowse.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBrowse.kt new file mode 100644 index 000000000..f820be6d2 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBrowse.kt @@ -0,0 +1,176 @@ +package com.droiddeck.launcher.stores.itch + +import android.content.Context +import androidx.compose.runtime.getValue +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 java.io.File +import java.security.MessageDigest + +internal data class BrowseQuery( + val sort: String = ItchBrowse.SORT_POPULAR, + val price: String = ItchBrowse.PRICE_ANY, + val platforms: Set = emptySet(), + val tags: List = emptyList(), +) { + val isDefault: Boolean get() = this == BrowseQuery() +} + +internal class BrowsePage(val items: List, val code: Int, val cached: Boolean) + +internal object ItchBrowse { + const val SORT_POPULAR = "popular" + const val SORT_NEW_POPULAR = "new-and-popular" + const val SORT_TOP_SELLERS = "top-sellers" + const val SORT_TOP_RATED = "top-rated" + const val SORT_NEWEST = "newest" + val sorts = listOf(SORT_POPULAR, SORT_NEW_POPULAR, SORT_TOP_SELLERS, SORT_TOP_RATED, SORT_NEWEST) + + const val PRICE_ANY = "any" + const val PRICE_FREE = "free" + const val PRICE_SALE = "on-sale" + const val PRICE_PAID = "store" + const val PRICE_UNDER_5 = "5-dollars-or-less" + const val PRICE_UNDER_15 = "15-dollars-or-less" + val prices = listOf(PRICE_ANY, PRICE_FREE, PRICE_SALE, PRICE_PAID, PRICE_UNDER_5, PRICE_UNDER_15) + + val platformNames = listOf("windows", "osx", "linux", "android", "ios", "web", "mobile-web") + + private const val BASE = "https://itch.io/games" + private const val FRESH_MS = 3 * 60 * 60 * 1000L + private const val GAP_MS = 1_200L + private const val RETRY_MS = 8_000L + private const val FOLDER = "itch-browse" + + @Volatile private var lastRequestAt = 0L + private val lock = Any() + + private fun sortSegment(sort: String): String? = sort.takeIf { it != SORT_POPULAR && it in sorts } + + private fun priceSegment(price: String): String? = price.takeIf { it != PRICE_ANY && it in prices } + + fun path(query: BrowseQuery): String { + val segments = listOfNotNull(sortSegment(query.sort), priceSegment(query.price)) + + query.platforms.sorted().map { "platform-$it" } + + query.tags.map { "tag-" + tagSlug(it) } + return if (segments.isEmpty()) BASE else BASE + "/" + segments.joinToString("/") + } + + fun url(query: BrowseQuery, page: Int): String = path(query) + ".xml" + if (page > 1) "?page=$page" else "" + + fun tagSlug(tag: String): String = tag.trim().lowercase().replace(Regex("[^a-z0-9]+"), "-").trim('-') + + fun page(context: Context, query: BrowseQuery, page: Int, force: Boolean = false): BrowsePage { + val app = context.applicationContext + val url = url(query, page) + if (!force) read(app, url)?.let { return BrowsePage(it, 200, true) } + val (status, body) = fetch(url) + val items = if (status in 200..299 && body != null) ItchShelves.parse(body) else emptyList() + StoresState.logLine("itch.io browse: $url answered $status with ${items.size} games") + if (items.isNotEmpty()) write(app, url, items) + return BrowsePage(items, status, false) + } + + private fun fetch(url: String): Pair { + synchronized(lock) { + pause() + var answer = ItchShelves.open(url) + if (answer.first == 429) { + StoresState.logLine("itch.io browse: 429, waiting before one more try") + Thread.sleep(RETRY_MS) + pause() + answer = ItchShelves.open(url) + } + lastRequestAt = System.currentTimeMillis() + return answer + } + } + + private fun pause() { + val gap = GAP_MS - (System.currentTimeMillis() - lastRequestAt) + if (gap > 0) Thread.sleep(gap) + } + + private fun file(context: Context, url: String): File { + val digest = MessageDigest.getInstance("SHA-1").digest(url.toByteArray()).joinToString("") { "%02x".format(it) } + return File(File(context.cacheDir, FOLDER), "$digest.json") + } + + private fun read(context: Context, url: String): List? { + val file = file(context, url) + if (!file.isFile || System.currentTimeMillis() - file.lastModified() > FRESH_MS) return null + return runCatching { ItchCatalog.list(JSONArray(file.readText())) }.getOrNull()?.takeIf { it.isNotEmpty() } + } + + private fun write(context: Context, url: String, items: List) { + val file = file(context, url) + runCatching { + file.parentFile?.mkdirs() + file.writeText(ItchCatalog.array(items).toString()) + } + } +} + +internal object ItchBrowseState { + var query by mutableStateOf(BrowseQuery()) + private set + var items by mutableStateOf>(emptyList()) + private set + var loading by mutableStateOf(false) + private set + var ended by mutableStateOf(false) + private set + var failed by mutableStateOf(false) + private set + + private var next = 1 + private var generation = 0 + + val active: Boolean get() = !query.isDefault + + fun choose(context: Context, wanted: BrowseQuery) { + if (wanted == query) return + generation++ + query = wanted + items = emptyList() + ended = false + failed = false + loading = false + next = 1 + if (active) more(context) + } + + fun more(context: Context, retry: Boolean = false) { + if (loading || ended || !active || (failed && !retry)) return + loading = true + failed = false + val app = context.applicationContext + val mine = generation + val asked = query + val page = next + Thread({ + val answer = runCatching { ItchBrowse.page(app, asked, page) }.getOrNull() + StoresState.post { + if (mine != generation) return@post + loading = false + if (answer == null || answer.code !in 200..299) { + failed = true + return@post + } + if (answer.items.isEmpty()) { + ended = true + return@post + } + val known = items.mapTo(HashSet()) { it.id } + val fresh = answer.items.filter { known.add(it.id) } + if (fresh.isEmpty()) ended = true else { + items = items + fresh + next = page + 1 + } + } + }, "itch-browse").start() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchShelves.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchShelves.kt index 0fd783ed6..acf332431 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchShelves.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchShelves.kt @@ -108,7 +108,7 @@ internal object ItchShelves { return shelves.takeUnless { it.isEmpty } } - private fun open(url: String): Pair { + internal fun open(url: String): Pair { var conn: HttpURLConnection? = null return try { conn = (URL(url).openConnection() as HttpURLConnection).apply { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ItchBrowseBar.kt b/app/src/main/java/com/droiddeck/launcher/ui/ItchBrowseBar.kt new file mode 100644 index 000000000..e1e170fde --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/ItchBrowseBar.kt @@ -0,0 +1,112 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.droiddeck.launcher.R +import com.droiddeck.launcher.stores.itch.ItchBrowse +import com.droiddeck.launcher.stores.itch.ItchBrowseState + +@Composable +internal fun ItchBrowseBar() { + val ctx = LocalContext.current + val query = ItchBrowseState.query + Column(Modifier.fillMaxWidth().padding(top = 6.dp, bottom = 8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + ChipRow { + for (sort in ItchBrowse.sorts) BrowseChip(sortLabel(sort), query.sort == sort) { ItchBrowseState.choose(ctx, query.copy(sort = sort)) } + } + ChipRow { + for (price in ItchBrowse.prices) BrowseChip(priceLabel(price), query.price == price) { ItchBrowseState.choose(ctx, query.copy(price = price)) } + } + } +} + +@Composable +internal fun ItchLoadMore() { + val ctx = LocalContext.current + val count = ItchBrowseState.items.size + LaunchedEffect(Unit) { ItchBrowseState.more(ctx) } + if (ItchBrowseState.loading) LinearProgressIndicator(Modifier.fillMaxWidth().padding(vertical = 12.dp)) + else if (ItchBrowseState.failed) Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) { + BrowseChip(stringResource(R.string.stores_dl_retry), false) { ItchBrowseState.more(ctx, retry = true) } + } + else if (!ItchBrowseState.ended && count > 0) Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) { + BrowseChip(stringResource(R.string.stores_itch_load_more), false) { ItchBrowseState.more(ctx) } + } +} + +@Composable +private fun ChipRow(content: @Composable () -> Unit) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).selectableGroup(), + ) { content() } +} + +@Composable +private fun BrowseChip(label: String, selected: Boolean, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val shape = RoundedCornerShape(8.dp) + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + Text( + label, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, + color = if (selected) pal.onSignal else if (hot) colors.onBackground else colors.onSurfaceVariant, + modifier = Modifier.clip(shape).background(if (selected) pal.signal else colors.surfaceVariant) + .glideBorder(hot, shape, pal.signal) + .hoverable(src).selectable(selected = selected, interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = onClick) + .controllerConfirm(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 7.dp), + ) +} + +@Composable +private fun sortLabel(sort: String): String = stringResource( + when (sort) { + ItchBrowse.SORT_NEW_POPULAR -> R.string.stores_itch_sort_new_popular + ItchBrowse.SORT_TOP_SELLERS -> R.string.stores_itch_sort_top_sellers + ItchBrowse.SORT_TOP_RATED -> R.string.stores_itch_sort_top_rated + ItchBrowse.SORT_NEWEST -> R.string.stores_itch_sort_new + else -> R.string.stores_itch_sort_popular + }, +) + +@Composable +private fun priceLabel(price: String): String = stringResource( + when (price) { + ItchBrowse.PRICE_FREE -> R.string.stores_itch_price_free + ItchBrowse.PRICE_SALE -> R.string.stores_itch_price_sale + ItchBrowse.PRICE_PAID -> R.string.stores_itch_price_paid + ItchBrowse.PRICE_UNDER_5 -> R.string.stores_itch_price_under_5 + ItchBrowse.PRICE_UNDER_15 -> R.string.stores_itch_price_under_15 + else -> R.string.stores_itch_price_any + }, +) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt index db9fe6ed0..d443237c5 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt @@ -146,6 +146,7 @@ import com.droiddeck.launcher.R import com.droiddeck.launcher.frontend.Library import com.droiddeck.launcher.session.SessionPrefs import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.itch.ItchBrowseState import com.droiddeck.launcher.stores.Store import com.droiddeck.launcher.stores.StoreTab import com.droiddeck.launcher.stores.StoresState @@ -961,7 +962,8 @@ private fun Storefront( 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 browsing = store == Store.ITCH && tab == "store" && q.isEmpty() && ItchBrowseState.active + val showGrid = tab != "store" || q.isNotEmpty() || browsing val sort = s.storesSort val lastPlayedFor = remember(s.steamGames) { val byKey = s.steamGames.associate { "${it.source}:${it.storeId}" to it.lastPlayed } @@ -974,8 +976,13 @@ private fun Storefront( lookup } 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 browseItems = if (browsing) ItchBrowseState.items else emptyList() + val candidates = remember(library, everything, tab, q, s.storesShowMature, browsing, browseItems) { + if (tab == "library") library.filter(::matches) + else if (browsing) { + val owned = library.associateBy { it.id } + browseItems.map { owned[it.id] ?: it }.filter(::shown) + } else everything.filter { matches(it) && shown(it) } } val candidateKeys = remember(candidates) { candidates.map { it.key } } var resolvedSizeSortKey by remember(store, tab) { mutableStateOf?>(null) } @@ -1013,6 +1020,8 @@ private fun Storefront( } val empty = stringResource(when { tab == "library" && library.isEmpty() && StoresState.status[store] != null -> R.string.stores_library_loading + browsing && ItchBrowseState.loading -> R.string.stores_shelves_loading + browsing && ItchBrowseState.failed -> R.string.stores_itch_browse_failed else -> R.string.stores_nothing_matches }) var firstPlaced = false @@ -1023,8 +1032,9 @@ private fun Storefront( // A bar only where the tab has nothing else on it: shelves, the shelf of owned games, or a grid still loading. val gridEmpty = if (showGrid) visible.isEmpty() else (shelves == null || shelves.isEmpty) && library.isEmpty() val barLine = StoresState.status[store]?.takeIf { gridEmpty && syncCount(it) != null } + val itchBar = store == Store.ITCH && tab == "store" && q.isEmpty() val headerItems = 1 + (if (sizeSortPending) 1 else 0) + (if (barLine != null) 1 else 0) + - (if (StoresState.problems[store] != null) 1 else 0) + (if (StoresState.problems[store] != null) 1 else 0) + (if (itchBar) 1 else 0) val listMode = s.storesView == "list" && tab == "library" val capsules = s.storesView == "capsules" && tab == "library" val tileWidth = if (capsules) CapsuleWidth else CardWidth @@ -1110,6 +1120,7 @@ private fun Storefront( } barLine?.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) } } } } + if (itchBar) item(key = "itch-browse-bar", span = { GridItemSpan(maxLineSpan) }) { ItchBrowseBar() } when (tab) { "library" -> if (!loaded) Unit else if (split == 0) storeGrid(visible, empty, card) @@ -1120,8 +1131,10 @@ private fun Storefront( storeGrid(visible.drop(split), empty, card) } else -> { - if (q.isNotEmpty()) storeGrid(visible, empty, card) - else { + if (q.isNotEmpty() || browsing) { + storeGrid(visible, empty, card) + if (browsing) item(key = "itch-more", span = { GridItemSpan(maxLineSpan) }) { ItchLoadMore() } + } else { item(key = "shelves", span = { GridItemSpan(maxLineSpan) }) { Column { Shelf(stringResource(R.string.stores_shelf_new), shelf(shelves?.whatsNew), card) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 07212eeae..041c6d447 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -2088,6 +2088,19 @@ لم يتم العثور على لعبة Windows في التنزيل تعذر استخراج التنزيل (رمز الخروج %1$d) لم يستجب itch.io + الأكثر شعبية + الأحدث + جديد ورائج + مدفوع + 5 دولارات أو أقل + 15 دولارًا أو أقل + الأعلى تقييمًا + الأكثر مبيعًا + أي سعر + مجاني + مخفّض + تحميل المزيد + تعذّر تحميل الألعاب من itch.io أرشيفات %1$s غير مدعومة استجاب itch.io بـ HTTP %1$d مجاني 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 8c795fcc2..9ee23bd93 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -2772,6 +2772,19 @@ No se encontró un juego de Windows en la descarga No se pudo extraer la descarga (salida %1$d) itch.io no respondió + Populares + Más recientes + Nuevos y populares + De pago + $5 o menos + $15 o menos + Mejor valorados + Más vendidos + Cualquier precio + Gratis + En oferta + Cargar más + No se pudieron cargar los juegos de itch.io Los archivos %1$s no son compatibles itch.io respondió HTTP %1$d Cámaras Luma Ultra — Experimental diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 191e0254c..01342132f 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -2004,6 +2004,19 @@ В изтеглянето не е намерена игра за Windows Изтеглянето не можа да бъде разархивирано (код %1$d) itch.io не отговори + Популярни + Най-нови + Нови и популярни + Платени + До $5 + До $15 + Най-високо оценени + Най-продавани + Всякаква цена + Безплатни + С намаление + Зареди още + Игрите от itch.io не можаха да се заредят Архивите %1$s не се поддържат itch.io отговори HTTP %1$d Безплатна diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index f183eb12a..bf11dbf1b 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -2046,6 +2046,19 @@ Ve stahování nebyla nalezena hra pro Windows Stažený soubor nelze rozbalit (kód %1$d) itch.io neodpovědělo + Populární + Nejnovější + Nové a populární + Placené + Do $5 + Do $15 + Nejlépe hodnocené + Nejprodávanější + Jakákoli cena + Zdarma + Ve slevě + Načíst další + Hry z itch.io se nepodařilo načíst Archivy %1$s nejsou podporovány itch.io odpovědělo HTTP %1$d Zdarma diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 48c47c177..b13bcafa5 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -2004,6 +2004,19 @@ Der blev ikke fundet et Windows-spil i downloaden Downloaden kunne ikke udpakkes (kode %1$d) itch.io svarede ikke + Populære + Seneste + Nye og populære + Betalt + $5 eller mindre + $15 eller mindre + Bedst bedømte + Mest solgte + Alle priser + Gratis + På tilbud + Indlæs flere + Kunne ikke hente spil fra itch.io %1$s-arkiver understøttes ikke itch.io svarede HTTP %1$d Gratis diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 6613633fe..92f3b563b 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1996,6 +1996,19 @@ Im Download wurde kein Windows-Spiel gefunden Download konnte nicht entpackt werden (Code %1$d) itch.io hat nicht geantwortet + Beliebt + Neueste + Neu und beliebt + Kostenpflichtig + 5 $ oder weniger + 15 $ oder weniger + Bestbewertet + Meistverkauft + Beliebiger Preis + Kostenlos + Im Angebot + Mehr laden + Spiele von itch.io konnten nicht geladen werden %1$s-Archive werden nicht unterstützt itch.io antwortete mit HTTP %1$d Kostenlos diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index c65a0da05..7142c525c 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -2004,6 +2004,19 @@ Δεν βρέθηκε παιχνίδι Windows στη λήψη Δεν ήταν δυνατή η αποσυμπίεση της λήψης (κωδικός %1$d) Το itch.io δεν απάντησε + Δημοφιλή + Πιο πρόσφατα + Νέα και δημοφιλή + Επί πληρωμή + $5 ή λιγότερο + $15 ή λιγότερο + Κορυφαία βαθμολογία + Μπεστ σέλερ + Οποιαδήποτε τιμή + Δωρεάν + Σε προσφορά + Φόρτωση περισσότερων + Δεν ήταν δυνατή η φόρτωση παιχνιδιών από το itch.io Τα αρχεία %1$s δεν υποστηρίζονται Το itch.io απάντησε HTTP %1$d Δωρεάν diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index e0faa4ec1..2ffff046d 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -2027,6 +2027,19 @@ No se ha encontrado un juego de Windows en la descarga No se ha podido extraer la descarga (código %1$d) itch.io no respondió + Populares + Más recientes + Nuevos y populares + De pago + 5 $ o menos + 15 $ o menos + Mejor valorados + Más vendidos + Cualquier precio + Gratis + En oferta + Cargar más + No se pudieron cargar los juegos de itch.io Los archivos %1$s no son compatibles itch.io respondió HTTP %1$d Gratis diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 5cfb6fdae..f6fdd4f3c 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -2004,6 +2004,19 @@ Latauksesta ei löytynyt Windows-peliä Latausta ei voitu purkaa (koodi %1$d) itch.io ei vastannut + Suositut + Uusimmat + Uudet ja suositut + Maksulliset + Enintään 5 $ + Enintään 15 $ + Parhaiten arvioidut + Myydyimmät + Mikä tahansa hinta + Ilmaiset + Alennuksessa + Lataa lisää + Pelien lataaminen itch.io:sta epäonnistui %1$s-arkistot eivät ole tuettuja itch.io vastasi HTTP %1$d Ilmainen diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 1cd1c9be5..9e7e2318d 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -2027,6 +2027,19 @@ Aucun jeu Windows trouvé dans le téléchargement Impossible d\'extraire le téléchargement (code %1$d) itch.io n\'a pas répondu + Populaires + Plus récents + Nouveautés populaires + Payants + 5 $ ou moins + 15 $ ou moins + Les mieux notés + Meilleures ventes + Tous les prix + Gratuits + En promotion + Charger plus + Impossible de charger les jeux d\'itch.io Les archives %1$s ne sont pas prises en charge itch.io a répondu HTTP %1$d Gratuit diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index c30f55bd8..a1b084b65 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -2004,6 +2004,19 @@ डाउनलोड में कोई Windows गेम नहीं मिला डाउनलोड एक्सट्रैक्ट नहीं हो सका (कोड %1$d) itch.io ने जवाब नहीं दिया + लोकप्रिय + सबसे हाल के + नए और लोकप्रिय + सशुल्क + $5 या कम + $15 या कम + सबसे ऊँची रेटिंग + सबसे ज़्यादा बिकने वाले + कोई भी कीमत + मुफ़्त + सेल में + और लोड करें + itch.io से गेम लोड नहीं हो सके %1$s आर्काइव समर्थित नहीं हैं itch.io ने HTTP %1$d लौटाया फ़्री diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 2d54db9ad..12f7aa12e 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -2004,6 +2004,19 @@ A letöltésben nem található Windows-játék A letöltés nem bontható ki (kód: %1$d) Az itch.io nem válaszolt + Népszerű + Legfrissebb + Új és népszerű + Fizetős + Legfeljebb 5 $ + Legfeljebb 15 $ + Legjobbra értékelt + Legkelendőbb + Bármilyen ár + Ingyenes + Akciós + Továbbiak betöltése + Nem sikerült betölteni a játékokat az itch.io-ról A(z) %1$s archívumok nem támogatottak Az itch.io HTTP %1$d választ adott Ingyenes diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index ac15bce25..9d13ed5fd 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -1983,6 +1983,19 @@ Tidak ada game Windows di unduhan Tidak dapat mengekstrak unduhan (kode %1$d) itch.io tidak menjawab + Populer + Terbaru + Baru dan populer + Berbayar + $5 atau kurang + $15 atau kurang + Rating tertinggi + Terlaris + Harga apa saja + Gratis + Diskon + Muat lebih banyak + Tidak dapat memuat game dari itch.io Arsip %1$s tidak didukung itch.io menjawab HTTP %1$d Gratis diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index db64b04af..edb694b91 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -2017,6 +2017,19 @@ Nessun gioco Windows trovato nel download Impossibile estrarre il download (codice %1$d) itch.io non ha risposto + Popolari + Più recenti + Nuovi e popolari + A pagamento + 5 $ o meno + 15 $ o meno + I più votati + I più venduti + Qualsiasi prezzo + Gratis + In offerta + Carica altri + Impossibile caricare i giochi da itch.io Gli archivi %1$s non sono supportati itch.io ha risposto HTTP %1$d Gratis diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 5cd48e61c..11eb16b14 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1985,6 +1985,19 @@ ダウンロードに Windows 版のゲームが見つかりません ダウンロードを展開できませんでした (終了コード %1$d) itch.io から応答がありません + 人気 + 新着順 + 新着&人気 + 有料 + 5ドル以下 + 15ドル以下 + 高評価 + 売れ筋 + すべての価格 + 無料 + セール中 + さらに読み込む + itch.io のゲームを読み込めませんでした %1$s 形式のアーカイブには対応していません itch.io が HTTP %1$d を返しました 無料 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 2f52cf77e..079336087 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1985,6 +1985,19 @@ 다운로드에서 Windows 게임을 찾을 수 없습니다 다운로드한 파일의 압축을 풀 수 없습니다(코드 %1$d) itch.io가 응답하지 않았습니다 + 인기 + 최신순 + 신규 및 인기 + 유료 + $5 이하 + $15 이하 + 평점 높은 순 + 베스트셀러 + 모든 가격 + 무료 + 할인 중 + 더 불러오기 + itch.io에서 게임을 불러오지 못했습니다 %1$s 압축 파일은 지원하지 않습니다 itch.io가 HTTP %1$d을(를) 반환했습니다 무료 diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index b6c533da9..7efc8402e 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -1983,6 +1983,19 @@ Tiada permainan Windows ditemui dalam muat turun Tidak dapat mengekstrak muat turun (kod %1$d) itch.io tidak menjawab + Popular + Terkini + Baharu dan popular + Berbayar + $5 atau kurang + $15 atau kurang + Penilaian tertinggi + Terlaris + Sebarang harga + Percuma + Diskaun + Muat lagi + Tidak dapat memuatkan permainan daripada itch.io Arkib %1$s tidak disokong itch.io menjawab HTTP %1$d Percuma diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 10ee3fea0..374f32923 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -2004,6 +2004,19 @@ Fant ikke noe Windows-spill i nedlastingen Kunne ikke pakke ut nedlastingen (kode %1$d) itch.io svarte ikke + Populære + Nyeste + Nye og populære + Betalt + $5 eller mindre + $15 eller mindre + Best vurdert + Mest solgt + Alle priser + Gratis + På salg + Last inn flere + Kunne ikke laste spill fra itch.io %1$s-arkiver støttes ikke itch.io svarte HTTP %1$d Gratis diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 866d12633..f10021f5f 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -2004,6 +2004,19 @@ Geen Windows-game gevonden in de download Download kan niet worden uitgepakt (code %1$d) itch.io gaf geen antwoord + Populair + Meest recent + Nieuw en populair + Betaald + $5 of minder + $15 of minder + Hoogst beoordeeld + Bestverkocht + Elke prijs + Gratis + In de aanbieding + Meer laden + Games van itch.io konden niet worden geladen %1$s-archieven worden niet ondersteund itch.io antwoordde HTTP %1$d Gratis diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 774f207d4..d98a63787 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -2046,6 +2046,19 @@ W pobranych plikach nie znaleziono gry na Windows Nie można rozpakować pobranego pliku (kod %1$d) itch.io nie odpowiedziało + Popularne + Najnowsze + Nowe i popularne + Płatne + Do 5 $ + Do 15 $ + Najwyżej oceniane + Najlepiej sprzedające się + Dowolna cena + Za darmo + Na wyprzedaży + Wczytaj więcej + Nie udało się wczytać gier z itch.io Archiwa %1$s nie są obsługiwane itch.io odpowiedziało HTTP %1$d Bezpłatna diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 26ec662c2..22150da67 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -2017,6 +2017,19 @@ Não foi encontrado nenhum jogo para Windows na transferência Não foi possível extrair a transferência (código %1$d) O itch.io não respondeu + Populares + Mais recentes + Novos e populares + Pagos + 5 $ ou menos + 15 $ ou menos + Mais bem avaliados + Mais vendidos + Qualquer preço + Grátis + Em promoção + Carregar mais + Não foi possível carregar os jogos do itch.io Ficheiros %1$s não são suportados O itch.io respondeu HTTP %1$d Grátis diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index e45878dc9..6530e8261 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -2017,6 +2017,19 @@ Nenhum jogo para Windows encontrado no download Não foi possível extrair o download (código %1$d) O itch.io não respondeu + Populares + Mais recentes + Novos e populares + Pagos + US$ 5 ou menos + US$ 15 ou menos + Mais bem avaliados + Mais vendidos + Qualquer preço + Grátis + Em promoção + Carregar mais + Não foi possível carregar os jogos do itch.io Arquivos %1$s não são suportados O itch.io respondeu HTTP %1$d Grátis diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 997fd9352..a94daa065 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -2025,6 +2025,19 @@ Nu s-a găsit niciun joc Windows în descărcare Descărcarea nu a putut fi extrasă (cod %1$d) itch.io nu a răspuns + Populare + Cele mai recente + Noi și populare + Cu plată + 5 $ sau mai puțin + 15 $ sau mai puțin + Cele mai bine cotate + Cele mai vândute + Orice preț + Gratuite + La reducere + Încarcă mai multe + Nu s-au putut încărca jocurile de pe itch.io Arhivele %1$s nu sunt acceptate itch.io a răspuns HTTP %1$d Gratuit diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index fb845b999..1fac9fca3 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -2048,6 +2048,19 @@ В загрузке не найдена игра для Windows Не удалось распаковать загрузку (код %1$d) itch.io не ответил + Популярные + Самые новые + Новые и популярные + Платные + До $5 + До $15 + С лучшими оценками + Бестселлеры + Любая цена + Бесплатные + Со скидкой + Загрузить ещё + Не удалось загрузить игры с itch.io Архивы %1$s не поддерживаются itch.io ответил HTTP %1$d Бесплатно diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index f4cf34256..87708ceb8 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -2004,6 +2004,19 @@ Inget Windows-spel hittades i nedladdningen Det gick inte att packa upp nedladdningen (kod %1$d) itch.io svarade inte + Populära + Senaste + Nya och populära + Betalda + $5 eller mindre + $15 eller mindre + Högst betyg + Mest sålda + Alla priser + Gratis + På rea + Ladda fler + Det gick inte att läsa in spel från itch.io %1$s-arkiv stöds inte itch.io svarade HTTP %1$d Gratis diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 1d998b751..e8174559b 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -1983,6 +1983,19 @@ ไม่พบเกม Windows ในไฟล์ที่ดาวน์โหลด แตกไฟล์ที่ดาวน์โหลดไม่ได้ (รหัส %1$d) itch.io ไม่ตอบสนอง + ยอดนิยม + ล่าสุด + ใหม่และยอดนิยม + เสียเงิน + ไม่เกิน $5 + ไม่เกิน $15 + คะแนนสูงสุด + ขายดี + ทุกราคา + ฟรี + ลดราคา + โหลดเพิ่มเติม + โหลดเกมจาก itch.io ไม่ได้ ไม่รองรับไฟล์ %1$s itch.io ตอบกลับ HTTP %1$d ฟรี diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index fdd392dd9..a07f0e3c2 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1998,6 +1998,19 @@ İndirmede Windows oyunu bulunamadı İndirme ayıklanamadı (kod %1$d) itch.io yanıt vermedi + Popüler + En yeniler + Yeni ve popüler + Ücretli + $5 veya altı + $15 veya altı + En yüksek puanlı + En çok satanlar + Herhangi bir fiyat + Ücretsiz + İndirimde + Daha fazla yükle + itch.io oyunları yüklenemedi %1$s arşivleri desteklenmiyor itch.io HTTP %1$d yanıtı verdi Ücretsiz diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index bb5d65d25..9eca24517 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -2046,6 +2046,19 @@ У завантаженні не знайдено гри для Windows Не вдалося розпакувати завантаження (код %1$d) itch.io не відповів + Популярні + Найновіші + Нові й популярні + Платні + До $5 + До $15 + З найвищими оцінками + Бестселери + Будь-яка ціна + Безкоштовні + Зі знижкою + Завантажити ще + Не вдалося завантажити ігри з itch.io Архіви %1$s не підтримуються itch.io відповів HTTP %1$d Безкоштовно diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 8246e796b..89009d049 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -1983,6 +1983,19 @@ Không tìm thấy trò chơi Windows trong bản tải xuống Không thể giải nén bản tải xuống (mã %1$d) itch.io không phản hồi + Phổ biến + Mới nhất + Mới và phổ biến + Trả phí + Từ $5 trở xuống + Từ $15 trở xuống + Đánh giá cao nhất + Bán chạy nhất + Mọi mức giá + Miễn phí + Đang giảm giá + Tải thêm + Không tải được game từ itch.io Không hỗ trợ tệp %1$s itch.io trả về HTTP %1$d Miễn phí diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 5b22e5b77..43693ef69 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2062,6 +2062,19 @@ 下载内容中未找到 Windows 游戏 无法解压下载内容(代码 %1$d) itch.io 没有响应 + 热门 + 最新发布 + 新品与热门 + 付费 + 5 美元以下 + 15 美元以下 + 评分最高 + 热销 + 任意价格 + 免费 + 特价 + 加载更多 + 无法从 itch.io 加载游戏 不支持 %1$s 压缩包 itch.io 返回 HTTP %1$d 免费 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index e0c2b1d02..029e860ed 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -1986,6 +1986,19 @@ 下載內容中找不到 Windows 遊戲 無法解壓下載內容(代碼 %1$d) itch.io 沒有回應 + 熱門 + 最新發布 + 新品與熱門 + 付費 + 5 美元以下 + 15 美元以下 + 評分最高 + 熱賣 + 任何價格 + 免費 + 特價 + 載入更多 + 無法從 itch.io 載入遊戲 不支援 %1$s 壓縮檔 itch.io 回應 HTTP %1$d 免費 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 2f2f66e1d..ae9617e45 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1986,6 +1986,19 @@ 下載內容中找不到 Windows 遊戲 無法解壓縮下載內容(代碼 %1$d) itch.io 沒有回應 + 熱門 + 最新發布 + 新品與熱門 + 付費 + 5 美元以下 + 15 美元以下 + 評分最高 + 熱銷 + 任何價格 + 免費 + 特價 + 載入更多 + 無法從 itch.io 載入遊戲 不支援 %1$s 壓縮檔 itch.io 回應 HTTP %1$d 免費 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1bd64ac04..36d2e0dba 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2081,6 +2081,19 @@ No Windows game found in the download Could not extract the download (exit %1$d) itch.io did not answer + Popular + Most recent + New & popular + Paid + $5 or less + $15 or less + Top rated + Top sellers + Any price + Free + On sale + Load more + Could not load games from itch.io %1$s archives are not supported itch.io answered HTTP %1$d Free diff --git a/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchBrowseTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchBrowseTest.kt new file mode 100644 index 000000000..6b7315334 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchBrowseTest.kt @@ -0,0 +1,64 @@ +package com.droiddeck.launcher.stores.itch + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ItchBrowseTest { + private fun path(sort: String = ItchBrowse.SORT_POPULAR, price: String = ItchBrowse.PRICE_ANY, platforms: Set = emptySet(), tags: List = emptyList()) = + ItchBrowse.path(BrowseQuery(sort, price, platforms, tags)) + + @Test fun theDefaultQueryIsTheFrontPage() { + assertTrue(BrowseQuery().isDefault) + assertFalse(BrowseQuery(sort = ItchBrowse.SORT_NEWEST).isDefault) + assertFalse(BrowseQuery(price = ItchBrowse.PRICE_FREE).isDefault) + } + + @Test fun sortsUseItchsOwnPaths() { + assertEquals("https://itch.io/games", path()) + assertEquals("https://itch.io/games/new-and-popular", path(sort = ItchBrowse.SORT_NEW_POPULAR)) + assertEquals("https://itch.io/games/top-sellers", path(sort = ItchBrowse.SORT_TOP_SELLERS)) + assertEquals("https://itch.io/games/top-rated", path(sort = ItchBrowse.SORT_TOP_RATED)) + assertEquals("https://itch.io/games/newest", path(sort = ItchBrowse.SORT_NEWEST)) + } + + @Test fun pricesUseItchsOwnPaths() { + assertEquals("https://itch.io/games/free", path(price = ItchBrowse.PRICE_FREE)) + assertEquals("https://itch.io/games/on-sale", path(price = ItchBrowse.PRICE_SALE)) + assertEquals("https://itch.io/games/store", path(price = ItchBrowse.PRICE_PAID)) + assertEquals("https://itch.io/games/5-dollars-or-less", path(price = ItchBrowse.PRICE_UNDER_5)) + assertEquals("https://itch.io/games/15-dollars-or-less", path(price = ItchBrowse.PRICE_UNDER_15)) + } + + @Test fun platformsUseItchsOwnNames() { + val expected = mapOf( + "windows" to "platform-windows", "osx" to "platform-osx", "linux" to "platform-linux", "android" to "platform-android", + "ios" to "platform-ios", "web" to "platform-web", "mobile-web" to "platform-mobile-web", + ) + assertEquals(expected.keys.toList(), ItchBrowse.platformNames) + for ((name, segment) in expected) assertEquals("https://itch.io/games/$segment", path(platforms = setOf(name))) + } + + @Test fun aSortAPriceAndAPlatformCombineInThatOrder() { + assertEquals( + "https://itch.io/games/top-rated/free/platform-windows", + path(ItchBrowse.SORT_TOP_RATED, ItchBrowse.PRICE_FREE, setOf("windows")), + ) + } + + @Test fun aTagIsItsLowercaseNameWithHyphens() { + assertEquals("https://itch.io/games/tag-pixel-art", path(tags = listOf("Pixel Art"))) + assertEquals("pixel-art", ItchBrowse.tagSlug(" Pixel Art ")) + } + + @Test fun theSamePlatformsInAnyOrderGiveTheSameAddress() { + assertEquals(path(platforms = setOf("web", "linux")), path(platforms = setOf("linux", "web"))) + } + + @Test fun laterPagesAskForThePageNumberAndTheFirstDoesNot() { + val query = BrowseQuery(sort = ItchBrowse.SORT_TOP_RATED) + assertEquals("https://itch.io/games/top-rated.xml", ItchBrowse.url(query, 1)) + assertEquals("https://itch.io/games/top-rated.xml?page=2", ItchBrowse.url(query, 2)) + } +} diff --git a/docs/development/itch-browse.md b/docs/development/itch-browse.md new file mode 100644 index 000000000..241b50853 --- /dev/null +++ b/docs/development/itch-browse.md @@ -0,0 +1,36 @@ +# itch.io browsing + +The itch Store tab opens on the front page shelves. A row of chips above them (`ui/ItchBrowseBar.kt`) +sets an order (Popular, New, Top rated, Top sellers) and a price (Any, Free, On sale). Leave both at +their defaults and the shelves stay; pick anything else and the tab shows that query's games as a grid +that loads the next page as it scrolls into view. + +## Data + +`ItchBrowse` (`stores/itch/ItchBrowse.kt`) turns a `BrowseQuery` into itch's listing feed: + +``` +https://itch.io/games[/][/][/platform-...][/tag-...].xml[?page=N] +``` + +`BrowseQuery` already carries platforms and tags, so the URL builder is complete; the controls for them +come in a later change. The path pieces are itch's own, copied from its links: sorts `new-and-popular`, `top-sellers`, `top-rated`, +`newest` (Popular has none); prices `free`, `on-sale`, `store` (Paid), `5-dollars-or-less`, +`15-dollars-or-less`; platforms `platform-windows`, `-osx`, `-linux`, `-android`, `-ios`, `-web`, +`-mobile-web`; a tag is `tag-` and its lowercase name with hyphens. `ItchBrowse.path()` builds the page +address, `url()` adds `.xml` and `?page=N`. Every request is logged +(`itch.io browse: answered with games`). + +Requests go out one at a time, at least 1.2 s apart. A 429 waits 8 s and tries once more; if that fails +the games already on screen stay. Each page is kept under the app cache for three hours, so going back to +a query does not ask itch again. Results are parsed by `ItchShelves.parse`, the same reader the shelves +use, so a game looks the same wherever it appears. + +`ItchBrowseState` holds the query, the games loaded so far and whether more can be asked for. A newer +query bumps a generation counter, so a slow answer for an older one is dropped. A game already in the +library replaces its browse copy on the grid, so Install and the owned markers are right; the detail page +looks browse results up through `StoresState.detailItem`. + +## When a page fails + +A fetch that throws or answers with a non-2xx status marks the list as failed but leaves it open. The end of the list then shows a Retry button that asks for the same page again. Scrolling does not retry on its own, so a failing page cannot loop. Only a successful page with no new games ends the list.