diff --git a/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt b/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt index 55c4f7c..dfe85fd 100644 --- a/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt @@ -357,22 +357,33 @@ private fun GroupItem( // The signal lives in the value column instead of adding a // badge — the count is worth less than "now" or "16:00" is. val urgent = signal?.urgency ?: AppState.TeamUrgency.NONE - Text( - when (urgent) { - AppState.TeamUrgency.LIVE -> "läuft" - AppState.TeamUrgency.SOON -> formatClock(signal!!.kickOff) - AppState.TeamUrgency.NONE -> "$count" - }, - color = when (urgent) { - AppState.TeamUrgency.LIVE -> CastarrColors.live - AppState.TeamUrgency.SOON -> CastarrColors.accent - AppState.TeamUrgency.NONE -> Color.Unspecified - }, - fontFamily = AppFont, - fontSize = 12.sp, - fontWeight = if (urgent == AppState.TeamUrgency.NONE) FontWeight.Normal - else FontWeight.SemiBold, - ) + if (urgent == AppState.TeamUrgency.NONE) { + Text( + "$count", + fontFamily = AppFont, + fontSize = 12.sp, + ) + } else { + // A focused row is filled with the accent colour, so signal + // colour straight on the row was red on turquoise. The chip + // gives it a dark ground that holds on every row state. + Box( + Modifier + .clip(RoundedCornerShape(999.dp)) + .background(CastarrColors.bgDeep.copy(alpha = 0.92f)) + .padding(horizontal = 8.dp, vertical = 3.dp) + ) { + Text( + if (urgent == AppState.TeamUrgency.LIVE) "läuft" + else formatClock(signal!!.kickOff), + color = if (urgent == AppState.TeamUrgency.LIVE) CastarrColors.live + else CastarrColors.accent, + fontFamily = AppFont, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + ) + } + } } } } @@ -475,7 +486,9 @@ private fun Crest( state: AppState, urgency: AppState.TeamUrgency = AppState.TeamUrgency.NONE, ) { - Box(Modifier.size(20.dp), contentAlignment = Alignment.Center) { + // The box is wider than the crest on purpose: the pulse ring is drawn + // around it, and a box sized to the crest would clip the ring away. + Box(Modifier.size(30.dp), contentAlignment = Alignment.Center) { if (urgency != AppState.TeamUrgency.NONE) { // A slow pulse is what actually catches the eye from the sofa; // colour alone does not at that distance. @@ -494,11 +507,11 @@ private fun Crest( val ringColor = if (urgency == AppState.TeamUrgency.LIVE) CastarrColors.live else CastarrColors.accent - Canvas(Modifier.size(30.dp)) { - val grow = phase.coerceAtMost(0.7f) / 0.7f + Canvas(Modifier.fillMaxSize()) { + val grow = phase.coerceAtMost(0.75f) / 0.75f drawCircle( - color = ringColor.copy(alpha = (1f - grow) * 0.9f), - radius = size.minDimension * (0.33f + grow * 0.17f), + color = ringColor.copy(alpha = (1f - grow) * 0.85f), + radius = size.minDimension * (0.34f + grow * 0.15f), style = Stroke(width = 2.dp.toPx()), ) } @@ -507,7 +520,8 @@ private fun Crest( model = "file:///android_asset/crests/${team.key}.png", contentDescription = null, contentScale = ContentScale.Fit, - modifier = Modifier.fillMaxSize(), + filterQuality = androidx.compose.ui.graphics.FilterQuality.High, + modifier = Modifier.size(20.dp), loading = { ShieldFallback(team) }, error = { ShieldFallback(team) }, ) diff --git a/app/src/main/java/dev/castarr/tv/update/UpdateChecker.kt b/app/src/main/java/dev/castarr/tv/update/UpdateChecker.kt index 6a6f175..4eaab69 100644 --- a/app/src/main/java/dev/castarr/tv/update/UpdateChecker.kt +++ b/app/src/main/java/dev/castarr/tv/update/UpdateChecker.kt @@ -27,6 +27,9 @@ object UpdateChecker { private var apkUrl: String = "" + /** Guards against a second click landing on the same download. */ + private val busy = java.util.concurrent.atomic.AtomicBoolean(false) + /** Returns the newer version tag, or null when current. Never throws. */ suspend fun check(state: AppState): String? = withContext(Dispatchers.IO) { runCatching { @@ -54,35 +57,69 @@ object UpdateChecker { }.onFailure { Log.w(TAG, "check failed: ${it.javaClass.simpleName}") }.getOrNull() } - /** Downloads the APK and hands it to the package installer. */ + /** + * Downloads the APK and hands it to the package installer. + * + * The download lands in a .part file that is only renamed once the byte + * count matches what the server announced. Handing a half-written APK to + * the installer leaves it sitting on a spinner with nothing to report, + * which is indistinguishable from a hang. + */ suspend fun downloadAndInstall(context: Context, state: AppState): String? = withContext(Dispatchers.IO) { - runCatching { - if (apkUrl.isEmpty()) check(state) - require(apkUrl.isNotEmpty()) { "no update available" } - val dir = File(context.cacheDir, "updates").apply { mkdirs() } - val file = File(dir, "castarr-update.apk") - (URL(apkUrl).openConnection() as HttpURLConnection).run { - connectTimeout = 15_000 - readTimeout = 120_000 - instanceFollowRedirects = true - inputStream.use { input -> file.outputStream().use { input.copyTo(it) } } - disconnect() + // A second click while the first download runs would have two + // writers on one file, and the installer reads whichever half won. + if (!busy.compareAndSet(false, true)) return@withContext "Update läuft bereits" + try { + runCatching { + if (apkUrl.isEmpty()) check(state) + require(apkUrl.isNotEmpty()) { "no update available" } + val dir = File(context.cacheDir, "updates").apply { mkdirs() } + val file = File(dir, "castarr-update.apk") + val part = File(dir, "castarr-update.apk.part") + part.delete() + val connection = URL(apkUrl).openConnection() as HttpURLConnection + val expected = try { + connection.connectTimeout = 15_000 + connection.readTimeout = 120_000 + connection.instanceFollowRedirects = true + val code = connection.responseCode + require(code == HttpURLConnection.HTTP_OK) { "http $code" } + val announced = connection.contentLengthLong + connection.inputStream.use { input -> + part.outputStream().use { output -> input.copyTo(output) } + } + announced + } finally { + connection.disconnect() + } + require(part.length() > 0) { "empty download" } + require(expected <= 0 || part.length() == expected) { + "truncated: ${part.length()} of $expected" + } + file.delete() + require(part.renameTo(file)) { "rename failed" } + + val uri = FileProvider.getUriForFile( + context, "${BuildConfig.APPLICATION_ID}.fileprovider", file, + ) + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/vnd.android.package-archive") + addFlags( + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK + ) + } + // Launching from the IO dispatcher stalled the installer on + // the first attempt — activities start from the main thread. + withContext(Dispatchers.Main) { context.startActivity(intent) } + null + }.getOrElse { + Log.w(TAG, "install failed: ${it.javaClass.simpleName}: ${it.message}") + File(context.cacheDir, "updates/castarr-update.apk.part").delete() + "Update fehlgeschlagen — später erneut versuchen" } - val uri = FileProvider.getUriForFile( - context, "${BuildConfig.APPLICATION_ID}.fileprovider", file, - ) - val intent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(uri, "application/vnd.android.package-archive") - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) - } - // Launching from the IO dispatcher stalled the installer on - // the first attempt — activities start from the main thread. - withContext(Dispatchers.Main) { context.startActivity(intent) } - null - }.getOrElse { - Log.w(TAG, "install failed: ${it.javaClass.simpleName}") - "Update fehlgeschlagen — später erneut versuchen" + } finally { + busy.set(false) } } diff --git a/tools/fetch-crests.py b/tools/fetch-crests.py index 648cb25..150330a 100644 --- a/tools/fetch-crests.py +++ b/tools/fetch-crests.py @@ -27,6 +27,30 @@ if os.path.exists(URLS): kotlin = open(SRC, encoding="utf-8").read() entries = re.findall(r'club\((.*?)\)\s*,\s*(?://.*)?$', kotlin, re.M | re.S) clubs = [] +# Wikipedia serves ~330px thumbnails. The rail draws them at 20dp, which on a +# 1080p TV is 40px -- an 8x downscale that Android does with a cheap filter, +# and the edges come out ragged. Resampling once here with a proper filter +# fixes that and shrinks the APK. +CREST_PX = 128 + + +def downscale(data: bytes) -> bytes: + try: + from PIL import Image + except ImportError: + return data + import io + + image = Image.open(io.BytesIO(data)).convert("RGBA") + if max(image.size) <= CREST_PX: + return data + scale = CREST_PX / max(image.size) + size = (max(1, round(image.width * scale)), max(1, round(image.height * scale))) + out = io.BytesIO() + image.resize(size, Image.LANCZOS).save(out, format="PNG", optimize=True) + return out.getvalue() + + for raw in re.findall(r'club\(\s*"([^"]+)",\s*"[^"]*",\s*"([^"]+)"[^\n]*', kotlin): clubs.append(raw) # an explicit article = "..." wins over the club name @@ -52,7 +76,7 @@ for key, full_name in clubs: with urllib.request.urlopen(urllib.request.Request(thumb, headers=UA), timeout=20) as r: data = r.read() with open(target, "wb") as f: - f.write(data) + f.write(downscale(data)) fetched += 1 except Exception as exc: # noqa: BLE001 - best effort, shield is the fallback print(f" {key}: {exc}", file=sys.stderr)