From a394dfd31b2102e20dd99c6f51b4873bbdedab05 Mon Sep 17 00:00:00 2001 From: Phobos665 <5970062+phobos665@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:29:03 +0100 Subject: [PATCH] feat(): Rich Game details for GOG & Epic - Publisher, Developer, Release Date, HLTB & Metacritic (#712) --- .../droiddeck/launcher/stores/GameFacts.kt | 263 ++++++++++++++++ .../droiddeck/launcher/stores/StoreCatalog.kt | 1 + .../com/droiddeck/launcher/stores/StoreNet.kt | 3 +- .../launcher/stores/epic/EpicApiClient.java | 4 + .../launcher/stores/epic/EpicBackend.kt | 7 +- .../launcher/stores/epic/EpicGame.java | 1 + .../launcher/stores/epic/EpicLibrary.kt | 10 +- .../launcher/stores/epic/EpicStoreCatalog.kt | 16 +- .../launcher/stores/gog/GogBackend.kt | 2 +- .../launcher/stores/gog/GogGame.java | 7 +- .../launcher/stores/gog/GogLibrary.kt | 25 +- .../launcher/stores/gog/GogStoreCatalog.kt | 5 +- .../droiddeck/launcher/ui/StoreGameFacts.kt | 291 ++++++++++++++++++ .../com/droiddeck/launcher/ui/StoresDetail.kt | 13 +- .../com/droiddeck/launcher/ui/StoresPage.kt | 7 +- app/src/main/res/values-es/strings.xml | 8 + app/src/main/res/values-fr/strings.xml | 8 + app/src/main/res/values-ja/strings.xml | 8 + app/src/main/res/values-ko/strings.xml | 8 + app/src/main/res/values-ru/strings.xml | 8 + app/src/main/res/values-zh-rCN/strings.xml | 8 + app/src/main/res/values-zh-rHK/strings.xml | 8 + app/src/main/res/values-zh-rTW/strings.xml | 8 + app/src/main/res/values/strings.xml | 11 + .../launcher/stores/GameFactsTest.kt | 72 +++++ gradle.properties | 3 + 26 files changed, 780 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/GameFacts.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/StoreGameFacts.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/stores/GameFactsTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/stores/GameFacts.kt b/app/src/main/java/com/droiddeck/launcher/stores/GameFacts.kt new file mode 100644 index 000000000..e67922fbb --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/GameFacts.kt @@ -0,0 +1,263 @@ +package com.droiddeck.launcher.stores + +import android.content.Context +import android.util.Log +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.net.URLEncoder +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.concurrent.ConcurrentHashMap + +/** + * What other sites know of a store game, looked up by its title: the Metacritic score (as Steam's + * store search carries it) and HowLongToBeat's times. Neither store publishes these, so every + * lookup is a name match against someone else's catalog, kept on disk a fortnight (a miss for two + * days) and in memory for the process. Blocking: call off the main thread. + */ +object GameFacts { + private const val TAG = "GameFacts" + private const val HIT_TTL_MS = 14L * 24 * 60 * 60 * 1000 + private const val MISS_TTL_MS = 2L * 24 * 60 * 60 * 1000 + private const val TIMEOUT_MS = 10_000 + + /** Metacritic's critic score (0-100) and the game's page there. */ + data class Metacritic(val score: Int, val url: String) + + /** HowLongToBeat's community times, in seconds (0 when nobody has logged that kind of run), and the game's page there. */ + data class Hltb(val main: Int, val extras: Int, val completionist: Int, val url: String) { + val isEmpty: Boolean get() = main <= 0 && extras <= 0 && completionist <= 0 + } + + /** A remembered answer: [value] null is a remembered miss. */ + private class Entry(val value: T?, val at: Long) { + fun fresh(now: Long) = now - at < if (value != null) HIT_TTL_MS else MISS_TTL_MS + } + + private val memory = ConcurrentHashMap>() + + /** The score or the times already in memory, without touching disk or network; null when not yet known. */ + fun peekMetacritic(title: String): Lookup? = peek("metacritic", title) + fun peekHltb(title: String): Lookup? = peek("hltb", title) + + /** The score, or null when Steam does not list the game or has no score for it. */ + fun metacritic(context: Context, title: String): Metacritic? = + remembered(context, "metacritic", title, + read = { o -> o.optInt("score").takeIf { it > 0 }?.let { Metacritic(it, o.optString("url").ifEmpty { metacriticSearch(title) }) } }, + write = { o, v -> o.put("score", v.score).put("url", v.url) }, + ) { searchSteamScore(title) } + + fun hltb(context: Context, title: String): Hltb? = + remembered(context, "hltb", title, + read = { o -> Hltb(o.optInt("main"), o.optInt("extras"), o.optInt("completionist"), o.optString("url").ifEmpty { hltbSearch(title) }).takeUnless { it.isEmpty } }, + write = { o, v -> o.put("main", v.main).put("extras", v.extras).put("completionist", v.completionist).put("url", v.url) }, + ) { searchHltb(title) } + + // Where a game goes when its own page is not known: the site's search for its name. + private fun metacriticSearch(title: String) = "https://www.metacritic.com/search/${encodePath(TitleMatch.searchTerm(title))}/?category=13" + private fun hltbSearch(title: String) = "$HLTB/?q=${URLEncoder.encode(TitleMatch.searchTerm(title), "UTF-8")}" + private fun encodePath(s: String) = URLEncoder.encode(s, "UTF-8").replace("+", "%20") + + @Suppress("UNCHECKED_CAST") + private fun peek(kind: String, title: String): Lookup? { + val e = memory[key(kind, title)] as Entry? ?: return null + return e.value?.let { Lookup.Found(it) } ?: Lookup.None + } + + /** + * The memory copy, else the disk copy while fresh, else [fetch]. [fetch] answers a [Fetched]: + * a failed request is not a miss, so it leaves the stale copy (if any) standing. + */ + @Suppress("UNCHECKED_CAST") + private fun remembered( + context: Context, kind: String, title: String, + read: (JSONObject) -> T?, write: (JSONObject, T) -> Unit, fetch: () -> Fetched, + ): T? { + val now = System.currentTimeMillis() + val k = key(kind, title) + (memory[k] as Entry?)?.takeIf { it.fresh(now) }?.let { return it.value } + val file = File(context.filesDir, "game-facts/$kind/${fileName(title)}.json") + val disk = runCatching { JSONObject(file.readText()) }.getOrNull() + ?.let { o -> Entry(if (o.optBoolean("none")) null else read(o), o.optLong("at")) } + if (disk != null && disk.fresh(now)) { memory[k] = disk; return disk.value } + return when (val got = fetch()) { + is Fetched.Failed -> disk?.value + is Fetched.Answer -> { + val entry = Entry(got.value, now) + memory[k] = entry + runCatching { + file.parentFile?.mkdirs() + val o = JSONObject().put("at", now) + if (got.value != null) write(o, got.value) else o.put("none", true) + file.writeText(o.toString()) + } + got.value + } + } + } + + private sealed interface Fetched { + object Failed : Fetched + class Answer(val value: T?) : Fetched + } + + private fun key(kind: String, title: String) = "$kind:${TitleMatch.normalize(title)}" + + private fun fileName(title: String) = TitleMatch.normalize(title).take(80).ifEmpty { "_" + title.hashCode().toUInt() } + + // Steam's store search lists a game's Metacritic score beside its name: one request, no key. + // The game's own Metacritic page is in its app details, asked for only once there is a score. + private fun searchSteamScore(title: String): Fetched { + val term = URLEncoder.encode(TitleMatch.searchTerm(title), "UTF-8") + val body = StoreNet.get("https://store.steampowered.com/api/storesearch/?l=english&cc=US&term=$term", timeoutMs = TIMEOUT_MS) + ?: return Fetched.Failed + return runCatching { + val items = JSONObject(body).optJSONArray("items") ?: JSONArray() + val scored = (0 until items.length()).mapNotNull { items.optJSONObject(it) } + .filter { it.optString("type") == "app" && (it.optString("metascore").toIntOrNull() ?: 0) > 0 } + val best = TitleMatch.pick(title, scored) { listOf(it.optString("name")) } + val score = best?.optString("metascore")?.toIntOrNull() ?: return Fetched.Answer(null) + Fetched.Answer(Metacritic(score, metacriticPage(best.optLong("id")) ?: metacriticSearch(title))) + }.getOrElse { Fetched.Failed } + } + + /** Steam's link to the game on Metacritic, without its tracking tag; null when Steam gives none. */ + private fun metacriticPage(appId: Long): String? { + if (appId <= 0) return null + val body = StoreNet.get("https://store.steampowered.com/api/appdetails?appids=$appId&filters=metacritic", timeoutMs = TIMEOUT_MS) ?: return null + return runCatching { + JSONObject(body).optJSONObject("$appId")?.optJSONObject("data")?.optJSONObject("metacritic")?.optString("url") + ?.substringBefore('?')?.takeIf { it.startsWith("https://") } + }.getOrNull() + } + + // HowLongToBeat's own site search: a short-lived token from init, sent back as x-auth-token. + // The token is bound to the User-Agent that asked for it, so both calls send the same one. + private const val HLTB = "https://howlongtobeat.com" + private val hltbHeaders = mapOf("Referer" to "$HLTB/", "Origin" to HLTB) + @Volatile private var hltbToken: Pair? = null + + private fun hltbToken(renew: Boolean): String? { + val now = System.currentTimeMillis() + hltbToken?.takeIf { !renew && now - it.second < 10 * 60 * 1000 }?.let { return it.first } + val body = StoreNet.get("$HLTB/api/search/site/init?t=$now", timeoutMs = TIMEOUT_MS, headers = hltbHeaders) ?: return null + val token = runCatching { JSONObject(body).optString("token") }.getOrNull()?.takeIf { it.isNotBlank() } ?: return null + hltbToken = token to now + return token + } + + private fun searchHltb(title: String): Fetched { + val terms = JSONArray().apply { TitleMatch.searchTerm(title).split(' ').filter { it.isNotBlank() }.forEach { put(it) } } + val request = JSONObject() + .put("searchType", "games").put("searchTerms", terms).put("searchPage", 1).put("size", 20) + .put("searchOptions", JSONObject() + .put("games", JSONObject().put("userId", 0).put("platform", "").put("sortCategory", "popular").put("rangeCategory", "main") + .put("rangeTime", JSONObject().put("min", 0).put("max", 0)) + .put("gameplay", JSONObject().put("perspective", "").put("flow", "").put("genre", "")) + .put("year", "").put("modifier", "")) + .put("users", JSONObject().put("sortCategory", "postcount")) + .put("lists", JSONObject().put("sortCategory", "follows")) + .put("filter", "").put("sort", 0).put("randomizer", 0)) + .put("useCache", true) + .toString() + // A token can lapse between init and search; one fresh one is worth a second try. + var body: String? = null + for (renew in listOf(false, true)) { + val token = hltbToken(renew) ?: return Fetched.Failed + body = StoreNet.postJson("$HLTB/api/search/site", request, timeoutMs = TIMEOUT_MS, headers = hltbHeaders + ("x-auth-token" to token)) + if (body != null) break + } + if (body == null) return Fetched.Failed + return runCatching { + val data = JSONObject(body).optJSONArray("data") ?: JSONArray() + val games = (0 until data.length()).mapNotNull { data.optJSONObject(it) } + .sortedByDescending { it.optString("game_type") == "game" } + val best = TitleMatch.pick(title, games) { g -> listOf(g.optString("game_name")) + g.optString("game_alias").split(',') } + Fetched.Answer(best?.let { + val page = it.optInt("game_id").takeIf { id -> id > 0 }?.let { id -> "$HLTB/game/$id" } ?: hltbSearch(title) + Hltb(it.optInt("comp_main"), it.optInt("comp_plus"), it.optInt("comp_100"), page) + }?.takeUnless { it.isEmpty }) + }.getOrElse { + Log.w(TAG, "HowLongToBeat answer unreadable: ${it.javaClass.simpleName}") + Fetched.Failed + } + } + + /** A store's release date ("2015.05.19", "2015-05-19T00:00:00+02:00", ...) in the reader's own words, or null. */ + fun releaseLabel(raw: String): String? { + val m = Regex("""^(\d{4})[.\-/](\d{2})[.\-/](\d{2})""").find(raw.trim()) ?: return null + val (y, mo, d) = m.destructured + // Epic dates a title it has no date for 2099-01-01. + val date = runCatching { LocalDate.of(y.toInt(), mo.toInt(), d.toInt()) }.getOrNull()?.takeIf { it.year in 1970..2098 } ?: return null + return date.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)) + } + + /** Whole hours, with a half when the time is nearer one: "51½". Under an hour, null (the caller says minutes). */ + fun hours(seconds: Int): String? { + if (seconds < 3600) return null + val halves = Math.round(seconds / 1800.0).toInt() + return "${halves / 2}" + if (halves % 2 == 1) "½" else "" + } +} + +/** A fact looked up off the page: still asking, found, or not to be had. */ +sealed interface Lookup { + object Pending : Lookup + object None : Lookup + data class Found(val value: T) : Lookup +} + +/** + * Whether two catalogs mean the same game. Store titles carry marks, editions and subtitles the + * other site spells differently ("The Witcher 3: Wild Hunt" is Steam's "...Wild Hunt — Remastered"); + * a sequel is never the same game ("Hades" is not "Hades II"). + */ +internal object TitleMatch { + private val MARKS = Regex("[™®©]") + private val EDITION = Regex( + "(?i)[\\s:\\-–—]*\\b(?:(?:complete|definitive|legendary|deluxe|ultimate|enhanced|gold|premium|standard|special|anniversary|collector'?s|digital deluxe|game of the year|goty)\\s+edition|goty|director'?s cut)\\b", + ) + // Where a subtitle starts: a colon or dash with space around it, or an opening bracket. + private val SUBTITLE = Regex("\\s*[:–—]\\s*|\\s+-\\s+|\\s*\\(") + + fun normalize(s: String): String = s.lowercase().replace(MARKS, "").replace("&", "and").replace(Regex("[^\\p{L}\\p{N}]"), "") + + fun stripEdition(s: String): String = s.replace(EDITION, " ").trim().ifEmpty { s } + + /** + * What to type into another site's search: the title without its marks, edition or subtitle + * punctuation (Steam finds nothing for "Disco Elysium - The Final Cut", all of it for the words). + */ + fun searchTerm(title: String): String = + stripEdition(title.replace(MARKS, "")).replace(Regex("\\s+[-–—]\\s+|[:–—()\\[\\]]"), " ").replace(Regex("\\s+"), " ").trim() + + /** 3 the same name, 2 the same but for the edition, 1 one is the other with a subtitle, 0 not the same game. */ + fun score(want: String, got: String): Int { + val w = normalize(want) + val g = normalize(got) + if (w.isEmpty() || g.isEmpty()) return 0 + if (w == g) return 3 + val ws = normalize(stripEdition(want)) + val gs = normalize(stripEdition(got)) + if (ws == gs || ws == g || w == gs) return 2 + if (ws in heads(got) || gs in heads(want)) return 1 + return 0 + } + + /** The title up to each place a subtitle could start, normalized. */ + private fun heads(title: String): Set = + SUBTITLE.findAll(title).map { normalize(title.substring(0, it.range.first)) }.filter { it.isNotEmpty() }.toSet() + + /** The best-named of [items] in their order (the site's own ranking breaks a tie), or null when none is the game. */ + fun pick(want: String, items: List, names: (T) -> List): T? { + var best: T? = null + var bestScore = 0 + for (item in items) { + val s = names(item).filter { it.isNotBlank() }.maxOfOrNull { score(want, it.trim()) } ?: 0 + if (s > bestScore) { best = item; bestScore = s } + } + return best + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt index c5c85378b..88d6695d5 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt @@ -26,6 +26,7 @@ data class CatalogItem( /** The web product page: where "Get for free" and "View on " land. */ val storeUrl: String = "", val developer: String = "", + val publisher: String = "", val releaseDate: String = "", val description: String = "", /** True for a title the signed-in account owns (a library entry). */ diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreNet.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreNet.kt index 4eeba0cab..4aeac3517 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreNet.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreNet.kt @@ -41,7 +41,7 @@ internal object StoreNet { } } - fun postJson(url: String, body: String, bearer: String? = null, userAgent: String = BROWSER_UA, timeoutMs: Int = 25_000): String? { + fun postJson(url: String, body: String, bearer: String? = null, userAgent: String = BROWSER_UA, timeoutMs: Int = 25_000, headers: Map = emptyMap()): String? { var conn: HttpURLConnection? = null return try { conn = (URL(url).openConnection() as HttpURLConnection).apply { @@ -53,6 +53,7 @@ internal object StoreNet { setRequestProperty("Content-Type", "application/json") setRequestProperty("Accept", "application/json") if (bearer != null) setRequestProperty("Authorization", "Bearer $bearer") + headers.forEach { (k, v) -> setRequestProperty(k, v) } } OutputStreamWriter(conn.outputStream, "UTF-8").use { it.write(body) } val code = conn.responseCode diff --git a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicApiClient.java b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicApiClient.java index 3497d37da..44921af76 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicApiClient.java +++ b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicApiClient.java @@ -103,7 +103,11 @@ public final class EpicApiClient { if (attrs != null) { JSONObject offline = attrs.optJSONObject("CanRunOffline"); if (offline != null) game.canRunOffline = !"false".equalsIgnoreCase(offline.optString("value", "true")); + JSONObject publisher = attrs.optJSONObject("publisherName"); + if (publisher != null) game.publisher = publisher.optString("value", "").trim(); } + JSONObject seller = item.optJSONObject("seller"); + if (game.publisher.isEmpty() && seller != null) game.publisher = seller.optString("name", "").trim(); String rd = item.optString("viewableDate", ""); if (rd.isEmpty()) rd = item.optString("effectiveDate", ""); if (!rd.isEmpty()) game.releaseDate = rd; diff --git a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicBackend.kt index b14b4fb50..57b5c4812 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicBackend.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicBackend.kt @@ -80,7 +80,12 @@ object EpicBackend : StoreBackend, EpicLaunchSupport { val byCatalogItem = owned.associateBy { it.extra["catalogItemId"] ?: "" } fun mark(l: List) = l.map { i -> val match = i.extra["items"]?.split(',')?.firstNotNullOfOrNull { byCatalogItem[it] } - if (match != null) match.copy(imageUrl = i.imageUrl ?: match.imageUrl, tallImageUrl = i.tallImageUrl ?: match.tallImageUrl) else i + // The offer's art, and its facts where the library's catalog record has none. + if (match != null) match.copy( + imageUrl = i.imageUrl ?: match.imageUrl, tallImageUrl = i.tallImageUrl ?: match.tallImageUrl, + developer = match.developer.ifBlank { i.developer }, publisher = match.publisher.ifBlank { i.publisher }, + releaseDate = match.releaseDate.ifBlank { i.releaseDate }, + ) else i } return StoreShelves(mark(s.whatsNew), mark(s.deals), mark(s.free), mark(s.trending)) } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicGame.java b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicGame.java index 2cb9c7fb3..ba91eb32a 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicGame.java +++ b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicGame.java @@ -22,4 +22,5 @@ public class EpicGame { public boolean isDLC = false; public String baseGameCatalogItemId = ""; public String releaseDate = ""; + public String publisher = ""; } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicLibrary.kt b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicLibrary.kt index 5b623828c..d65b0c3e6 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicLibrary.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicLibrary.kt @@ -44,6 +44,8 @@ object EpicLibrary { g.developer = j.optString("developer", ""); g.description = j.optString("description", ""); g.version = j.optString("version", "") g.installSize = j.optLong("installSize", 0L).let { if (it > 1_099_511_627_776L) 0L else it } g.canRunOffline = j.optBoolean("canRunOffline", true) + g.releaseDate = j.optString("releaseDate", "") + g.publisher = j.optString("publisher", "") if (g.appName.isNotEmpty()) list.add(g) } list.distinctBy { it.appName }.sortedBy { it.title.lowercase() } @@ -74,7 +76,6 @@ object EpicLibrary { var done = 0 for (game in raw) { EpicApiClient.enrichFromCatalog(token, game) - if (game.releaseDate.isNotEmpty()) p.edit().putString("release_${game.appName}", game.releaseDate).apply() done++ if (done % 5 == 0) onStatus("Loading game details… ($done/$total)") } @@ -102,7 +103,8 @@ object EpicLibrary { val arr = JSONArray() for (g in games) arr.put(JSONObject().put("appName", g.appName).put("namespace", g.namespace).put("catalogItemId", g.catalogItemId) .put("title", g.title).put("artCover", g.artCover).put("artSquare", g.artSquare).put("developer", g.developer) - .put("description", g.description).put("version", g.version).put("installSize", g.installSize).put("canRunOffline", g.canRunOffline)) + .put("description", g.description).put("version", g.version).put("installSize", g.installSize).put("canRunOffline", g.canRunOffline) + .put("releaseDate", g.releaseDate).put("publisher", g.publisher)) prefs.edit().putString(CACHE_KEY, arr.toString()).apply() } } @@ -111,7 +113,9 @@ object EpicLibrary { store = Store.EPIC, id = g.appName, title = g.title.ifBlank { g.appName }, imageUrl = g.artSquare.ifBlank { null } ?: g.artCover.ifBlank { null }, tallImageUrl = g.artCover.ifBlank { null } ?: g.artSquare.ifBlank { null }, - developer = g.developer, description = com.droiddeck.launcher.stores.cleanStoreText(g.description), owned = true, + developer = g.developer, publisher = g.publisher, description = com.droiddeck.launcher.stores.cleanStoreText(g.description), owned = true, + // A cache from before the release date was kept still has it under the older per-game key. + releaseDate = g.releaseDate.ifEmpty { EpicPrefs.get(context).getString("release_${g.appName}", "").orEmpty() }, sizeBytes = EpicPrefs.get(context).getLong("size_${g.appName}", g.installSize), storeUrl = "https://store.epicgames.com/en-US/browse?q=${java.net.URLEncoder.encode(g.title, "UTF-8")}", extra = mapOf("namespace" to g.namespace, "catalogItemId" to g.catalogItemId, "version" to (g.version ?: "")), diff --git a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicStoreCatalog.kt b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicStoreCatalog.kt index 6d4f2d95c..4e07c78aa 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicStoreCatalog.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicStoreCatalog.kt @@ -32,7 +32,7 @@ object EpicStoreCatalog { private const val ELEMENT_FIELDS = "title id namespace description effectiveDate releaseDate offerType productSlug urlSlug " + - "items{id namespace} keyImages{type url} seller{name} tags{name} " + + "items{id namespace} keyImages{type url} seller{name} tags{name} customAttributes{key value} " + "catalogNs{mappings(pageType:\"productHome\"){pageSlug pageType}} " + "price(country:\$country){totalPrice{discountPrice originalPrice discount currencyCode " + "fmtPrice(locale:\$locale){originalPrice discountPrice}}}" @@ -116,13 +116,21 @@ object EpicStoreCatalog { return CatalogItem( store = Store.EPIC, id = id, title = title, imageUrl = wide ?: thumb, tallImageUrl = tall ?: thumb, tags = tagLine, isFree = isFree, hasPrice = hasPrice, finalPrice = finalPrice, originalPrice = if (discountPct > 0) fmtOriginal else "", discountPercent = discountPct, - storeUrl = storeUrl, developer = e.optJSONObject("seller")?.optString("name", "").orEmpty(), + storeUrl = storeUrl, developer = attribute(e, "developerName"), + publisher = attribute(e, "publisherName").ifEmpty { e.optJSONObject("seller")?.optString("name", "").orEmpty() }, releaseDate = e.optString("releaseDate", e.optString("effectiveDate", "")), description = com.droiddeck.launcher.stores.cleanStoreText(e.optString("description", "")), extra = buildMap { put("namespace", ns); if (itemIds.isNotEmpty()) put("items", itemIds.joinToString(",")); if (slug.isNotBlank()) put("slug", slug) }, mature = com.droiddeck.launcher.stores.matureByTags(allTags), ) } + // An offer's custom attribute (developerName, publisherName), or "". + private fun attribute(e: JSONObject, key: String): String { + val attrs = e.optJSONArray("customAttributes") ?: return "" + return (0 until attrs.length()).mapNotNull { attrs.optJSONObject(it) }.firstOrNull { it.optString("key") == key } + ?.optString("value", "")?.trim().orEmpty() + } + private fun pageSlugOf(e: JSONObject): String { e.optJSONObject("catalogNs")?.optJSONArray("mappings")?.let { m -> for (i in 0 until m.length()) { val map = m.optJSONObject(i) ?: continue; if (map.optString("pageType") == "productHome") { val s = map.optString("pageSlug", ""); if (s.isNotBlank()) return s } } @@ -193,7 +201,7 @@ object EpicStoreCatalog { private fun itemToJson(i: CatalogItem): JSONObject = JSONObject().apply { put("id", i.id); put("title", i.title); put("image", i.imageUrl ?: ""); put("tall", i.tallImageUrl ?: ""); put("tags", i.tags) put("free", i.isFree); put("hasPrice", i.hasPrice); put("final", i.finalPrice); put("orig", i.originalPrice); put("disc", i.discountPercent) - put("url", i.storeUrl); put("dev", i.developer); put("rel", i.releaseDate); put("desc", i.description); put("extra", JSONObject(i.extra as Map<*, *>)) + put("url", i.storeUrl); put("dev", i.developer); put("pub", i.publisher); put("rel", i.releaseDate); put("desc", i.description); put("extra", JSONObject(i.extra as Map<*, *>)) if (i.mature) put("mature", true) } @@ -203,7 +211,7 @@ object EpicStoreCatalog { return CatalogItem( store = Store.EPIC, id = o.optString("id"), title = o.optString("title"), imageUrl = o.optString("image").ifBlank { null }, tallImageUrl = o.optString("tall").ifBlank { null }, tags = o.optString("tags"), isFree = o.optBoolean("free"), hasPrice = o.optBoolean("hasPrice"), finalPrice = o.optString("final"), originalPrice = o.optString("orig"), - discountPercent = o.optInt("disc"), storeUrl = o.optString("url"), developer = o.optString("dev"), releaseDate = o.optString("rel"), description = o.optString("desc"), extra = extra, + discountPercent = o.optInt("disc"), storeUrl = o.optString("url"), developer = o.optString("dev"), publisher = o.optString("pub"), releaseDate = o.optString("rel"), description = o.optString("desc"), extra = extra, mature = o.optBoolean("mature"), ) } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogBackend.kt index bf175dd6a..c2bbfa635 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogBackend.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogBackend.kt @@ -71,7 +71,7 @@ object GogBackend : StoreBackend { override fun install(context: Context, item: CatalogItem, root: File) { val app = context.applicationContext - val game = GogGame(item.id, item.title, item.imageUrl ?: "", item.description, item.developer, item.tags, 2, item.tallImageUrl) + val game = GogGame(item.id, item.title, item.imageUrl ?: "", item.description, item.developer, item.tags, 2, item.tallImageUrl, item.releaseDate, item.publisher) val folder = StoreInstallRoot.folderFor(app, Store.GOG, game.gameId, game.title, root) val entry = DownloadEntry(store, item.id, item.title, cover = item.imageUrl, bytesTotal = item.sizeBytes, location = StoreInstallRoot.labelFor(app, folder)) DownloadQueue.enqueue(app, entry) { InstallJob(app, game, item, folder) } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogGame.java b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogGame.java index 63dbab9c7..9f74b2ead 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogGame.java +++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogGame.java @@ -12,9 +12,12 @@ public class GogGame { public final int generation; /** gamesdb.gog.com 2:3 box art, or null when the lookup found none. */ public final String verticalCover; + /** ISO date-time of the worldwide release, or "" when GOG gave none. */ + public final String releaseDate; + public final String publisher; public GogGame(String gameId, String title, String imageUrl, String description, String developer, - String category, int generation, String verticalCover) { + String category, int generation, String verticalCover, String releaseDate, String publisher) { this.gameId = gameId; this.title = title; this.imageUrl = imageUrl; @@ -23,5 +26,7 @@ public class GogGame { this.category = category; this.generation = generation; this.verticalCover = verticalCover; + this.releaseDate = releaseDate; + this.publisher = publisher; } } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogLibrary.kt b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogLibrary.kt index 778f972a4..cba199ce7 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogLibrary.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogLibrary.kt @@ -45,7 +45,8 @@ object GogLibrary { (0 until arr.length()).mapNotNull { i -> val o = arr.optJSONObject(i) ?: return@mapNotNull null GogGame(o.optString("gameId", ""), o.optString("title", ""), o.optString("imageUrl", ""), cleanStoreText(o.optString("description", "")), - o.optString("developer", ""), o.optString("category", ""), o.optInt("generation", 1), o.optString("verticalCover", "").ifEmpty { null }) + o.optString("developer", ""), o.optString("category", ""), o.optInt("generation", 1), o.optString("verticalCover", "").ifEmpty { null }, + o.optString("releaseDate", ""), o.optString("publisher", "")) }.filter { it.gameId.isNotEmpty() }.sortedBy { it.title.lowercase() } }.getOrDefault(emptyList()) } @@ -148,7 +149,15 @@ object GogLibrary { val desc = cleanStoreText(prod.optJSONObject("description")?.optString("lead", "")) .ifEmpty { cleanStoreText(v2?.optString("overview", "")) } .ifEmpty { cleanStoreText(v2?.optString("description", "")) } - val developer = prod.optJSONObject("developers")?.optString("name", "") ?: prod.optString("developer", "") + // The products endpoint names no developer or publisher; the v2 record does, with the worldwide release date. + val embedded = v2?.optJSONObject("_embedded") + fun names(key: String) = embedded?.optJSONArray(key) + ?.let { arr -> (0 until arr.length()).mapNotNull { arr.optJSONObject(it)?.optString("name")?.takeIf(String::isNotBlank) } } + ?.joinToString(", ").orEmpty() + val developer = names("developers") + val publisher = embedded?.optJSONObject("publisher")?.optString("name")?.takeIf(String::isNotBlank) ?: names("publishers") + val releaseDate = listOf(embedded?.optJSONObject("product")?.optString("globalReleaseDate"), prod.optString("release_date")) + .firstOrNull { !it.isNullOrBlank() && it != "null" }.orEmpty() val genres = prod.optJSONArray("genres") val category = if (genres != null && genres.length() > 0) genres.optJSONObject(0)?.optString("name", "") ?: "" else "" @@ -164,9 +173,7 @@ object GogLibrary { if (maxGen > 0) generation = maxGen } } - val ed = prefs.edit().putInt("gen_$id", generation) - prod.optString("release_date", "").takeIf { it.isNotEmpty() }?.let { ed.putString("release_$id", it) } - ed.apply() + prefs.edit().putInt("gen_$id", generation).apply() if (prefs.getLong("size_$id", -1L) <= 0) { val size = GogDownloadManager.fetchInstallSizeBytes(id, token) if (size > 0) prefs.edit().putLong("size_$id", size).apply() @@ -182,7 +189,7 @@ object GogLibrary { verticalCover = fetchVerticalCover(id).ifEmpty { StoreNet.sgdbPoster(title) } } if (!verticalCover.isNullOrEmpty()) prefs.edit().putString("vcover_$id", verticalCover).apply() - return GogGame(id, title, GogStoreCatalog.absolutize(imageUrl), desc, developer, category, generation, verticalCover?.ifEmpty { null }) + return GogGame(id, title, GogStoreCatalog.absolutize(imageUrl), desc, developer, category, generation, verticalCover?.ifEmpty { null }, releaseDate, publisher) } catch (_: Exception) { return null } @@ -203,7 +210,7 @@ object GogLibrary { val arr = JSONArray() for (g in games) { arr.put(JSONObject().put("gameId", g.gameId).put("title", g.title).put("imageUrl", g.imageUrl).put("description", g.description) - .put("developer", g.developer).put("category", g.category).put("generation", g.generation) + .put("developer", g.developer).put("category", g.category).put("generation", g.generation).put("releaseDate", g.releaseDate).put("publisher", g.publisher) .apply { if (!g.verticalCover.isNullOrEmpty()) put("verticalCover", g.verticalCover) }) } prefs.edit().putString(CACHE_KEY, arr.toString()).apply() @@ -215,7 +222,9 @@ object GogLibrary { store = Store.GOG, id = g.gameId, title = g.title, imageUrl = GogStoreCatalog.absolutize(g.imageUrl).ifBlank { null }, tallImageUrl = GogStoreCatalog.absolutize(g.verticalCover ?: g.imageUrl).ifBlank { null }, - tags = g.category, developer = g.developer, description = g.description, owned = true, + tags = g.category, developer = g.developer, publisher = g.publisher, description = g.description, owned = true, + // A cache from before the release date was kept still has it under the older per-game key. + releaseDate = g.releaseDate.ifEmpty { GogPrefs.get(context).getString("release_${g.gameId}", "").orEmpty() }, sizeBytes = sizeOf(context, g.gameId), storeUrl = "https://www.gog.com/en/game/${g.gameId}", ) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogStoreCatalog.kt b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogStoreCatalog.kt index bad1caff4..c05f9fdf9 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogStoreCatalog.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogStoreCatalog.kt @@ -133,6 +133,7 @@ object GogStoreCatalog { finalPrice = finalStr, originalPrice = if (discount > 0) baseStr else "", discountPercent = if (discount > 0 && baseStr.isNotBlank()) discount else 0, storeUrl = storeLink, developer = p.optJSONArray("developers")?.optString(0, "").orEmpty(), + publisher = p.optJSONArray("publishers")?.optString(0, "").orEmpty(), releaseDate = p.optString("releaseDate", ""), extra = buildMap { if (slug.isNotBlank()) put("slug", slug) }, mature = mature, @@ -175,7 +176,7 @@ object GogStoreCatalog { private fun itemToJson(i: CatalogItem): JSONObject = JSONObject().apply { put("id", i.id); put("title", i.title); put("image", i.imageUrl ?: ""); put("tall", i.tallImageUrl ?: "") put("tags", i.tags); put("free", i.isFree); put("hasPrice", i.hasPrice); put("final", i.finalPrice); put("orig", i.originalPrice) - put("disc", i.discountPercent); put("url", i.storeUrl); put("dev", i.developer); put("rel", i.releaseDate) + put("disc", i.discountPercent); put("url", i.storeUrl); put("dev", i.developer); put("pub", i.publisher); put("rel", i.releaseDate) if (i.mature) put("mature", true) } @@ -184,7 +185,7 @@ object GogStoreCatalog { imageUrl = o.optString("image").ifBlank { null }, tallImageUrl = o.optString("tall").ifBlank { null }, tags = o.optString("tags"), isFree = o.optBoolean("free"), hasPrice = o.optBoolean("hasPrice"), finalPrice = o.optString("final"), originalPrice = o.optString("orig"), discountPercent = o.optInt("disc"), - storeUrl = o.optString("url"), developer = o.optString("dev"), releaseDate = o.optString("rel"), + storeUrl = o.optString("url"), developer = o.optString("dev"), publisher = o.optString("pub"), releaseDate = o.optString("rel"), mature = o.optBoolean("mature"), ) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoreGameFacts.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoreGameFacts.kt new file mode 100644 index 000000000..f5967bdac --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoreGameFacts.kt @@ -0,0 +1,291 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.droiddeck.launcher.R +import com.droiddeck.launcher.stores.GameFacts +import com.droiddeck.launcher.stores.Lookup +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +// A store game's facts under its hero: who made it and when, from the store; the Metacritic score +// and HowLongToBeat's times, looked up elsewhere by name. The store's facts are there at once; the +// looked-up ones hold their place while they come and step out if there is nothing to show. + +internal class GameScores(val metacritic: Lookup, val hltb: Lookup) + +/** Both lookups at once and off the main thread, each landing on its own; what memory already has shows without a wait. */ +@Composable +internal fun rememberGameScores(title: String?): GameScores { + val ctx = LocalContext.current.applicationContext + var metacritic by remember(title) { mutableStateOf(title?.let { GameFacts.peekMetacritic(it) } ?: if (title == null) Lookup.None else Lookup.Pending) } + var hltb by remember(title) { mutableStateOf(title?.let { GameFacts.peekHltb(it) } ?: if (title == null) Lookup.None else Lookup.Pending) } + LaunchedEffect(title) { + if (title == null) return@LaunchedEffect + launch { metacritic = withContext(Dispatchers.IO) { GameFacts.metacritic(ctx, title) }?.let { Lookup.Found(it) } ?: Lookup.None } + launch { hltb = withContext(Dispatchers.IO) { GameFacts.hltb(ctx, title) }?.let { Lookup.Found(it) } ?: Lookup.None } + } + return GameScores(metacritic, hltb) +} + +/** + * The facts: who made it, when, and what critics made of it on the first row; how long it takes, + * across the page, on the second. A looked-up tile holds its place while it asks and steps out if + * the site does not have the game; nothing is drawn when there is nothing to say. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun StoreGameFacts(developer: String, publisher: String, releaseDate: String, scores: GameScores, modifier: Modifier = Modifier) { + val released = remember(releaseDate) { GameFacts.releaseLabel(releaseDate) } + // A studio that publishes itself is named once. + val publishedBy = publisher.takeUnless { it.isBlank() || it.trim().equals(developer.trim(), ignoreCase = true) } + val firstRow = developer.isNotBlank() || publishedBy != null || released != null || scores.metacritic != Lookup.None + if (!firstRow && scores.hltb == Lookup.None) return + Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier.fillMaxWidth().padding(top = 12.dp)) { + if (firstRow) FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (developer.isNotBlank()) FactTile(stringResource(R.string.stores_fact_developer)) { FactText(developer) } + if (publishedBy != null) FactTile(stringResource(R.string.stores_fact_publisher)) { FactText(publishedBy) } + if (released != null) FactTile(stringResource(R.string.stores_fact_released)) { FactText(released) } + Looked(scores.metacritic, exit = shrinkHorizontally()) { lookup -> + val found = (lookup as? Lookup.Found)?.value + FactTile(stringResource(R.string.stores_fact_metacritic), link = found?.url) { + if (found != null) MetacriticBadge(found.score) else Placeholder(ValueHeight) + } + } + } + Looked(scores.hltb, exit = shrinkVertically()) { lookup -> + val found = (lookup as? Lookup.Found)?.value + FactTile(stringResource(R.string.stores_fact_hltb), fill = true, link = found?.url) { + if (found != null) HltbTimes(found) else Placeholder(220.dp) + } + } + } +} + +/** A looked-up tile: in from the start (holding its place while it asks), out with a fade when there was nothing. */ +@Composable +private fun Looked(lookup: Lookup, exit: ExitTransition, content: @Composable (Lookup) -> Unit) { + // The last answer worth drawing, so the tile does not blank as it leaves. + var shown by remember { mutableStateOf(lookup) } + if (lookup != Lookup.None) shown = lookup + AnimatedVisibility(visible = lookup != Lookup.None, enter = fadeIn(), exit = fadeOut() + exit) { content(shown) } +} + +private val FactShape = RoundedCornerShape(12.dp) +private val ValueHeight = 24.dp + +/** + * A label over its value; [fill] takes the row's whole width, for a value laid out across it. With + * a [link] it is a control like any other on the page: focus, hover and A or a tap open the page. + */ +@Composable +private fun FactTile(label: String, fill: Boolean = false, link: String? = null, value: @Composable () -> Unit) { + val pal = LocalPalette.current + val ctx = LocalContext.current + val src = remember { MutableInteractionSource() } + val hot = link != null && rememberHot(src) + val open = { if (link != null) openUrl(ctx, link) } + val base = if (fill) Modifier.fillMaxWidth() else Modifier.widthIn(max = 360.dp) + val tile = if (link == null) base.clip(FactShape).background(pal.surface).border(1.dp, pal.line, FactShape) + else base.paneItem("fact:$label").clip(FactShape) + .background(if (hot) pal.signal.copy(alpha = 0.14f) else pal.surface) + .glideBorder(hot, FactShape, pal.signal, restColor = pal.line) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = open) + .controllerConfirm(onClick = open) + Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = tile.padding(horizontal = 14.dp, vertical = 10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Text(label.uppercase(), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp, color = if (hot) pal.signal else pal.onSurfaceVariant, maxLines = 1) + if (link != null) Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, tint = if (hot) pal.signal else pal.onSurfaceVariant, modifier = Modifier.size(12.dp)) + } + Box(contentAlignment = Alignment.CenterStart, modifier = (if (fill) Modifier.fillMaxWidth() else Modifier).heightIn(min = ValueHeight)) { value() } + } +} + +@Composable +private fun FactText(text: String) = + Text(text, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + +/** Metacritic's own bands: green from 75, yellow from 50, red below. */ +internal fun metacriticColour(score: Int): Color = when { + score >= 75 -> Color(0xFF66CC33) + score >= 50 -> Color(0xFFFFCC33) + else -> Color(0xFFFF6874) +} + +@Composable +private fun MetacriticBadge(score: Int) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(ValueHeight).clip(RoundedCornerShape(6.dp)).background(metacriticColour(score))) { + Text("$score", fontSize = 13.sp, fontWeight = FontWeight.Black, color = Color(0xFF0A0B0D), maxLines = 1) + } +} + +/** Each kind of run HowLongToBeat has times for, sharing the tile's width: the hours in full, what they are for beside them. */ +@Composable +private fun HltbTimes(t: GameFacts.Hltb) { + val colors = MaterialTheme.colorScheme + val runs = listOf( + t.main to stringResource(R.string.stores_hltb_main), + t.extras to stringResource(R.string.stores_hltb_extras), + t.completionist to stringResource(R.string.stores_hltb_completionist), + ).filter { it.first > 0 } + Row(Modifier.fillMaxWidth()) { + for ((secs, what) in runs) { + val hours = GameFacts.hours(secs)?.let { stringResource(R.string.stores_hltb_hours, it) } ?: stringResource(R.string.stores_hltb_minutes, maxOf(1, secs / 60)) + val run = buildAnnotatedString { + withStyle(SpanStyle(fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)) { append(hours) } + withStyle(SpanStyle(fontSize = 12.sp, color = colors.onSurfaceVariant)) { append(" $what") } + } + Text(run, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + } + } +} + +/** Where a value will be, breathing while it is asked for. */ +@Composable +private fun Placeholder(width: Dp) { + val pulse by rememberInfiniteTransition(label = "fact").animateFloat( + 0.35f, 0.8f, infiniteRepeatable(tween(900, easing = FastOutSlowInEasing), RepeatMode.Reverse), label = "pulse", + ) + Box(Modifier.width(width).height(16.dp).alpha(pulse).clip(RoundedCornerShape(5.dp)).background(LocalPalette.current.line2)) +} + +// Previews: every state the row can be in, at a TV pane's width and a narrow one. + +private val previewScores = GameScores( + Lookup.Found(GameFacts.Metacritic(93, "https://www.metacritic.com/game/the-witcher-3-wild-hunt/")), + Lookup.Found(GameFacts.Hltb(main = 186_070, extras = 373_519, completionist = 630_794, url = "https://howlongtobeat.com/game/10270")), +) + +private fun previewScore(score: Int) = Lookup.Found(GameFacts.Metacritic(score, "https://www.metacritic.com/")) + +@Composable +private fun FactsPreview(theme: String = Themes.GRAPHITE, narrow: Boolean = false, content: @Composable () -> Unit) { + DroidDeckTheme(theme = theme) { + CompositionLocalProvider(LocalNarrowPane provides narrow) { + Box(Modifier.fillMaxSize().background(LocalPalette.current.background).padding(16.dp)) { + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { content() } + } + } + } +} + +@Composable +private fun PreviewCaption(text: String) = Text(text, fontSize = 11.sp, color = LocalPalette.current.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp)) + +@Preview(name = "Facts, all found", widthDp = 800, heightDp = 200) +@Composable +private fun FactsFoundPreview() = FactsPreview { + StoreGameFacts("CD PROJEKT RED", "CD PROJEKT RED", "2015-05-19T00:00:00+02:00", previewScores) +} + +@Preview(name = "Facts, looking up", widthDp = 800, heightDp = 200) +@Composable +private fun FactsPendingPreview() = FactsPreview { + StoreGameFacts("Supergiant Games", "Supergiant Games", "2020.09.17", GameScores(Lookup.Pending, Lookup.Pending)) +} + +@Preview(name = "Facts, mixed", widthDp = 800, heightDp = 640) +@Composable +private fun FactsMixedPreview() = FactsPreview { + PreviewCaption("Another studio's publisher; score found, times still coming") + StoreGameFacts("New World Computing", "Ubisoft", "1999-02-28", GameScores(previewScore(87), Lookup.Pending)) + PreviewCaption("Score found, no times") + StoreGameFacts("Mane6", "Maximum Entertainment", "2022-12-19T16:00:00.000Z", GameScores(previewScore(68), Lookup.None)) + PreviewCaption("Neither site knows it: store facts only") + StoreGameFacts("Tiny Indie Studio", "", "2024.03.01", GameScores(Lookup.None, Lookup.None)) + PreviewCaption("A low score, a short game with only a main-story time") + StoreGameFacts("", "", "2019-11-05", GameScores(previewScore(42), Lookup.Found(GameFacts.Hltb(main = 2_400, extras = 0, completionist = 0, url = "https://howlongtobeat.com/")))) + PreviewCaption("Epic's no-date placeholder (2099) is not shown") + StoreGameFacts("Studio", "", "2099-01-01T00:00:00.000Z", GameScores(previewScore(81), Lookup.None)) +} + +@Preview(name = "Facts, narrow pane", widthDp = 420, heightDp = 300) +@Composable +private fun FactsNarrowPreview() = FactsPreview(narrow = true) { + StoreGameFacts("CD PROJEKT RED", "CD PROJEKT RED", "2015-05-19T00:00:00+02:00", previewScores) +} + +@Preview(name = "Facts, Paper theme", widthDp = 800, heightDp = 200) +@Composable +private fun FactsPaperPreview() = FactsPreview(theme = Themes.PAPER) { + StoreGameFacts("CD PROJEKT RED", "CD PROJEKT RED", "2015-05-19T00:00:00+02:00", previewScores) +} + +// The real hero reads the stores' live state, so this stands one in to show where the row sits. +@Preview(name = "Game page, facts in place", widthDp = 800, heightDp = 520) +@Composable +private fun FactsOnPagePreview() = FactsPreview { + val title = "The Witcher 3: Wild Hunt" + Box(Modifier.fillMaxWidth().height(180.dp).clip(Shape16).background(artBrush(hueOf(title)))) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.align(Alignment.BottomStart).padding(horizontal = 22.dp, vertical = 18.dp)) { + Text(stringResource(R.string.stores_eyebrow_library).uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = LocalPalette.current.signal) + Text(title, fontSize = 30.sp, fontWeight = FontWeight.Black, color = MaterialTheme.colorScheme.onBackground) + } + } + StoreGameFacts("CD PROJEKT RED", "CD PROJEKT RED", "2015-05-19T00:00:00+02:00", previewScores) + Text( + "As war rages on throughout the Northern Realms, you take on the greatest contract of your life — tracking down the Child of Prophecy, a living weapon that can alter the shape of the world.", + fontSize = 13.sp, lineHeight = 19.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 12.dp), + ) +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt index b4f11e09d..e77bd70a1 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt @@ -122,11 +122,20 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro } } if (StoresMotion.heroLands) hero() else Rise(1) { hero() } + if (store == Store.GOG || store == Store.EPIC) { + // An owned game resolves to its library entry; the storefront's copy of it may know more. + val listed = StoresState.shelves[store]?.all.orEmpty().firstOrNull { it.id == id } + val developer = item?.developer?.ifBlank { null } ?: listed?.developer.orEmpty() + val publisher = item?.publisher?.ifBlank { null } ?: listed?.publisher.orEmpty() + val releaseDate = item?.releaseDate?.ifBlank { null } ?: listed?.releaseDate.orEmpty() + val scores = rememberGameScores(title.takeIf { it != id }) + Rise(2) { StoreGameFacts(developer, publisher, releaseDate, scores) } + } // Plain text only, and nothing at all when the store sent a template key instead of words. // A description that is only the title again (some stores fill it so) is no description. val description = item?.let { com.droiddeck.launcher.stores.cleanStoreText(it.description) }.orEmpty() .takeUnless { it.trim().equals(title.trim(), ignoreCase = true) }.orEmpty() - if (description.isNotBlank()) Rise(2) { + if (description.isNotBlank()) Rise(3) { Text(description, fontSize = 13.sp, lineHeight = 19.sp, color = colors.onSurfaceVariant, maxLines = 6, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 12.dp)) } val achievements = remember(store, id, item?.owned, installed) { @@ -137,7 +146,7 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro else -> null } } - if (achievements != null) Rise(3) { StoreAchievementsSection(achievements) } + if (achievements != null) Rise(4) { StoreAchievementsSection(achievements) } } /** 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 bc2ba056d..1d722dd3d 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt @@ -1306,8 +1306,11 @@ internal fun stageLabel(stage: DownloadStage): String = when (stage) { } /** The game's web page, in the device's browser: this app buys nothing and claims nothing itself. */ -internal fun openStoreUrl(ctx: android.content.Context, item: CatalogItem) { - val url = item.storeUrl.ifBlank { return } +internal fun openStoreUrl(ctx: android.content.Context, item: CatalogItem) = openUrl(ctx, item.storeUrl) + +/** A web page in the browser; nothing when the URL is blank or no app can open it. */ +internal fun openUrl(ctx: android.content.Context, url: String) { + if (url.isBlank()) return runCatching { ctx.startActivity(android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url)).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)) } } diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 7fd550064..4fe9ef611 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -2147,4 +2147,12 @@ %1$s no indica logros para este juego. Ver los %1$d Logros · %1$s + Desarrollador + Distribuidor + Lanzamiento + Principal + + Extras + Completista + %1$s h + %1$d min diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index d7bb3e583..30e576eda 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -977,4 +977,12 @@ %1$s n\'indique aucun succès pour ce jeu. Tout afficher (%1$d) Succès · %1$s + Développeur + Éditeur + Sortie + Principal + + Extras + Complétionniste + %1$s h + %1$d min \ No newline at end of file diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 44da13653..19a74fc1c 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -2103,4 +2103,12 @@ %1$s にはこのゲームの実績がありません。 すべて表示(%1$d) 実績 · %1$s + 開発元 + パブリッシャー + 発売日 + メイン + + やり込み + 完全制覇 + %1$s時間 + %1$d分 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 944c8a327..687dfd59a 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -2103,4 +2103,12 @@ %1$s에 이 게임의 도전 과제가 없습니다. 모두 보기(%1$d) 도전 과제 · %1$s + 개발사 + 배급사 + 출시일 + 메인 + + 추가 요소 + 완전 정복 + %1$s시간 + %1$d분 diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index d98e99070..5f23a21f8 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1088,4 +1088,12 @@ В %1$s нет достижений для этой игры. Показать все (%1$d) Достижения · %1$s + Разработчик + Издатель + Дата выхода + Сюжет + + Доп. задания + Полностью + %1$s ч + %1$d мин \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 3f5765ea4..b3ec6abd5 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2103,4 +2103,12 @@ %1$s 未列出此游戏的成就。 显示全部 %1$d 项 成就 · %1$s + 开发商 + 发行商 + 发行日期 + 主线 + + 支线 + 全收集 + %1$s 小时 + %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 0a2096dab..c74140c50 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -2103,4 +2103,12 @@ %1$s 未列出此遊戲的成就。 顯示全部 %1$d 項 成就 · %1$s + 開發商 + 發行商 + 發行日期 + 主線 + + 支線 + 全收集 + %1$s 小時 + %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 0f38af5c3..768c2eb4c 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2103,4 +2103,12 @@ %1$s 沒有列出這款遊戲的成就。 顯示全部 %1$d 項 成就 · %1$s + 開發商 + 發行商 + 發行日期 + 主線 + + 支線 + 全收集 + %1$s 小時 + %1$d 分鐘 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d86d18244..bb1765615 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2171,6 +2171,17 @@ %1$s lists no achievements for this game. Show all %1$d Achievements · %1$s + + Developer + Publisher + Released + Metacritic + HowLongToBeat + Main + + Extras + Completionist + %1$s h + %1$d min ✓ In Steam Uninstall Uninstalling… diff --git a/app/src/test/java/com/droiddeck/launcher/stores/GameFactsTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/GameFactsTest.kt new file mode 100644 index 000000000..5e2cc02f3 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/GameFactsTest.kt @@ -0,0 +1,72 @@ +package com.droiddeck.launcher.stores + +import java.util.Locale +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +/** The name match that finds a store game on Steam and HowLongToBeat, and how its facts read. */ +class GameFactsTest { + private val saved = Locale.getDefault() + + @Before fun english() = Locale.setDefault(Locale.US) + @After fun restore() = Locale.setDefault(saved) + + @Test fun theSameNameMatchesWhateverItsMarksAndPunctuation() { + assertEquals(3, TitleMatch.score("The Witcher 3: Wild Hunt", "The Witcher 3: Wild Hunt")) + assertEquals(3, TitleMatch.score("DOOM Eternal™", "DOOM Eternal")) + assertEquals(3, TitleMatch.score("Ori & the Will of the Wisps", "Ori and the Will of the Wisps")) + } + + @Test fun anEditionIsTheSameGame() { + assertEquals(2, TitleMatch.score("The Witcher 3: Wild Hunt - Complete Edition", "The Witcher 3: Wild Hunt")) + assertEquals(2, TitleMatch.score("Control", "Control Ultimate Edition")) + assertEquals(2, TitleMatch.score("Fallout: New Vegas - Ultimate Edition", "Fallout: New Vegas")) + } + + @Test fun aSubtitleOnEitherSideStillMatchesButLessWell() { + assertEquals(1, TitleMatch.score("The Witcher 3: Wild Hunt", "The Witcher 3: Wild Hunt — Remastered")) + assertEquals(1, TitleMatch.score("Alan Wake Remastered (2021)", "Alan Wake Remastered")) + } + + @Test fun aSequelOrASiblingIsNotTheSameGame() { + assertEquals(0, TitleMatch.score("Hades", "Hades II")) + assertEquals(0, TitleMatch.score("Batman: Arkham City", "Batman: Arkham Knight")) + assertEquals(0, TitleMatch.score("Portal", "Portal 2")) + assertEquals(0, TitleMatch.score("", "Portal")) + } + + @Test fun thePickPrefersTheClosestNameThenTheSitesOwnOrder() { + val results = listOf("Hades II", "The Witcher 3: Wild Hunt — Songs of the Past", "Hades", "Hades") + assertEquals(2, TitleMatch.pick("Hades", results.withIndex().toList()) { listOf(it.value) }?.index) + assertNull(TitleMatch.pick("Hades", listOf("Hades II", "Hadesville")) { listOf(it) }) + // An alias counts as a name. + assertEquals("x", TitleMatch.pick("The Witcher III: Wild Hunt", listOf("x" to "The Witcher 3: Wild Hunt, The Witcher III: Wild Hunt")) { it.second.split(',') }?.first) + } + + @Test fun theSearchTermDropsMarksAndTheEdition() { + assertEquals("The Witcher 3 Wild Hunt", TitleMatch.searchTerm("The Witcher® 3: Wild Hunt - Game of the Year Edition")) + assertEquals("Disco Elysium The Final Cut", TitleMatch.searchTerm("Disco Elysium - The Final Cut")) + assertEquals("Marvel's Spider-Man Remastered", TitleMatch.searchTerm("Marvel's Spider-Man Remastered")) + assertEquals("Deluxe Ski Jump", TitleMatch.searchTerm("Deluxe Ski Jump")) + } + + @Test fun eachStoresDateReadsTheSameAndAPlaceholderIsDropped() { + assertEquals("May 19, 2015", GameFacts.releaseLabel("2015-05-19T00:00:00+02:00")) + assertEquals("May 19, 2015", GameFacts.releaseLabel("2015.05.19")) + assertEquals("Dec 19, 2022", GameFacts.releaseLabel("2022-12-19T16:00:00.000Z")) + assertNull(GameFacts.releaseLabel("2099-01-01T00:00:00.000Z")) + assertNull(GameFacts.releaseLabel("null")) + assertNull(GameFacts.releaseLabel("")) + } + + @Test fun hoursRoundToTheNearestHalf() { + assertEquals("51½", GameFacts.hours(186_070)) + assertEquals("52", GameFacts.hours(52 * 3600 + 500)) + assertEquals("51½", GameFacts.hours(51 * 3600 + 1900)) + assertEquals("1", GameFacts.hours(3600)) + assertNull(GameFacts.hours(2_400)) + } +} diff --git a/gradle.properties b/gradle.properties index 68abc99e0..1eda82b0c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,3 +8,6 @@ android.nonTransitiveRClass=true # The NDK the compositor is built with. CI passes the one the runner has installed # (-PndkVersion=…); this is the fallback for a local build. ndkVersion=27.3.13750724 + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true