Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
34a1c2d
feat: add How Long To Beat stats to game pages
xXJSONDeruloXx Apr 23, 2026
3e3a69d
fix: use HttpURLConnection for HLTB search POST to avoid CDN 404
xXJSONDeruloXx Apr 23, 2026
2dbb586
refactor: shrink HLTB implementation to ~260 lines
xXJSONDeruloXx Apr 23, 2026
0450dd8
feat: move HLTB stats to hero strip above play button
xXJSONDeruloXx Apr 23, 2026
c2271db
feat: add HLTB link button to hero strip
xXJSONDeruloXx Apr 23, 2026
a84cb06
fix: hltb service robustness — IO dispatcher, timeouts, CancellationE…
xXJSONDeruloXx Apr 23, 2026
35d059b
refactor: address phobos review — thread-safe cache, naming, tests, t…
xXJSONDeruloXx Apr 23, 2026
def120d
fix: address follow-up hltb review comments
xXJSONDeruloXx Apr 23, 2026
0c99c2f
fix: address remaining hltb review feedback
xXJSONDeruloXx Apr 24, 2026
c5f1bfa
test: reduce HLTB test boilerplate
xXJSONDeruloXx Apr 24, 2026
9b78bec
Merge remote-tracking branch 'upstream/master' into feat/hltb-stats
xXJSONDeruloXx Apr 28, 2026
a4e9abb
merge: resolve conflicts with upstream/master
xXJSONDeruloXx Apr 28, 2026
a57235e
fix: resolve hltb stats conflicts with upstream
xXJSONDeruloXx May 8, 2026
2fd6930
chore: merge upstream/master into feat/hltb-stats
xXJSONDeruloXx May 13, 2026
36175d6
Merge remote-tracking branch 'upstream/master' into feat/hltb-stats
xXJSONDeruloXx May 19, 2026
8b7b11c
fix: address hltb review feedback
xXJSONDeruloXx May 31, 2026
fdcd300
fix: address hltb follow-up comments
xXJSONDeruloXx May 31, 2026
5de8d11
fix: normalize hltb search terms
xXJSONDeruloXx May 31, 2026
ba7640d
fix: refine hltb info bar layout
xXJSONDeruloXx May 31, 2026
0b665c2
fix: match hltb bar height to info cards
xXJSONDeruloXx Jun 1, 2026
b92f39c
fix: match hltb typography to info cards
xXJSONDeruloXx Jun 1, 2026
f6c5b71
fix: add hltb info heading
xXJSONDeruloXx Jun 1, 2026
bb107b2
fix: keep hltb bar compact
xXJSONDeruloXx Jun 1, 2026
9ebf73d
fix: shorten hltb heading
xXJSONDeruloXx Jun 1, 2026
096b282
Merge remote-tracking branch 'upstream/master' into feat/hltb-stats
xXJSONDeruloXx Jun 12, 2026
7ef5b6f
Fixed How Long to Beat on Portrait
Jun 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions app/src/main/java/app/gamenative/PrefManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1182,6 +1182,14 @@ object PrefManager {
setPref(GAME_COMPATIBILITY_CACHE, value)
}

// HLTB cache (JSON string)
private val HLTB_CACHE = stringPreferencesKey("hltb_cache")
var hltbCache: String
get() = getPref(HLTB_CACHE, "{}")
set(value) {
setPref(HLTB_CACHE, value)
}

/* Security / Attestation */
private val KEY_ATTESTATION_AVAILABLE = booleanPreferencesKey("key_attestation_available")
var keyAttestationAvailable: Boolean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,6 @@ data class GameDisplayInfo(
val headerUrl: String? = null, // Header image URL (for SteamGridDB, can use grid as header)
val compatibilityMessage: String? = null, // Compatibility message text (e.g., "Works on your GPU")
val compatibilityColor: ULong? = null, // Compatibility message color (ARGB)
val hltbStats: app.gamenative.utils.HltbService.Stats? = null, // How Long To Beat stats
)

Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
package app.gamenative.ui.screen.library

import android.content.Intent
import androidx.compose.foundation.clickable
import app.gamenative.ui.screen.library.components.HltbHeroStrip
import android.content.res.Configuration
import app.gamenative.ui.screen.library.components.ambient.AmbientDownloadOverlay
import android.view.KeyEvent
Expand Down Expand Up @@ -46,6 +48,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
Expand Down Expand Up @@ -95,6 +98,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
Expand Down Expand Up @@ -803,6 +807,12 @@ internal fun AppScreenContent(

Spacer(modifier = Modifier.height(16.dp))

// HLTB stats strip (above play bar)
displayInfo.hltbStats?.let { hltb ->
HltbHeroStrip(hltb)
Spacer(modifier = Modifier.height(8.dp))
}

// Integrated action bar - overlaid on hero
val isPortrait = LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT
Column(
Expand Down Expand Up @@ -1094,6 +1104,7 @@ internal fun AppScreenContent(
}
}
}

}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -895,9 +895,23 @@ abstract class BaseAppScreen {
onBack: () -> Unit,
) {
val context = LocalContext.current
val displayInfo = getGameDisplayInfo(context, libraryItem)
val displayInfoBase = getGameDisplayInfo(context, libraryItem)
val appId = libraryItem.appId

// Fetch HLTB stats asynchronously (best-effort)
var hltbStats by remember(displayInfoBase.name) {
mutableStateOf<app.gamenative.utils.HltbService.Stats?>(null)
}
LaunchedEffect(displayInfoBase.name) {
if (displayInfoBase.name.isNotBlank())
hltbStats = try {
app.gamenative.utils.HltbService.getStats(displayInfoBase.name)
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (_: Exception) { null }
}
val displayInfo = displayInfoBase.copy(hltbStats = hltbStats)

// Use composable state for values that change over time
var isInstalledState by remember(libraryItem.appId) {
mutableStateOf(isInstalled(context, libraryItem))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package app.gamenative.ui.screen.library.components

import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
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.layout.size
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.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import app.gamenative.R
import app.gamenative.utils.HltbService
import timber.log.Timber

@Composable
fun HltbHeroStrip(stats: HltbService.Stats) {
val context = LocalContext.current
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(Color.Black.copy(alpha = 0.45f))
.padding(horizontal = 8.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
listOf(
stringResource(R.string.hltb_main_story) to stats.mainHours,
stringResource(R.string.hltb_main_plus_extras) to stats.mainPlusHours,
stringResource(R.string.hltb_completionist) to stats.completeHours,
stringResource(R.string.hltb_all_styles) to stats.allStylesHours,
).forEach { (label, hours) ->
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = if (hours == "--") "--" else "${hours}h",
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold),
color = Color.White,
)
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = Color.White.copy(alpha = 0.7f),
textAlign = TextAlign.Center,
)
}
}
if (stats.gameId > 0) {
IconButton(
onClick = {
try {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("${HltbService.GAME_URL}${stats.gameId}"))
)
} catch (e: ActivityNotFoundException) {
Timber.tag("HLTB").w(e, "No handler for HLTB game URL")
}
},
modifier = Modifier.size(48.dp),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = stringResource(R.string.hltb_view_on_hltb),
tint = Color.White.copy(alpha = 0.7f),
modifier = Modifier.size(18.dp),
)
}
}
}
}
Loading
Loading