2 Commits

Author SHA1 Message Date
be-nj
a3105925bb Make a waiting update visible instead of hiding it in the settings
Some checks failed
Build TV app / build (push) Failing after 5s
The start-up check already ran, but its result only showed as a badge for
whoever happened to open the settings. A chip in the top bar now says
"Update <version>" and leads straight there, and the check repeats every
six hours because a TV keeps the same process alive for days.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 03:07:12 +02:00
be-nj
7f1dff5661 Real club crests, and no more crash on "Verein hinzufügen"
Some checks failed
Build TV app / build (push) Failing after 5s
Crests are resolved from the German Wikipedia page summary at runtime and
cached per device, so nothing copyrighted is stored in the repository. The
coloured shield remains as the fallback while the badge loads or when a
club has none.

The add dialog crashed for the same reason the rail did: it focused a row
that only exists when something is preselected, and adding a club has no
preselection. It now also scrolls, which 56 clubs rather need.

CI: this Gitea resolves bare action names against itself, so the workflow
now uses full GitHub URLs, and it runs the unit tests too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 02:59:23 +02:00
9 changed files with 167 additions and 17 deletions

View File

@@ -8,20 +8,32 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Full URLs: this Gitea resolves bare action names against itself,
# where these actions do not exist.
- uses: https://github.com/actions/checkout@v4
- uses: actions/setup-java@v4
- uses: https://github.com/actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- uses: gradle/actions/setup-gradle@v4
- uses: https://github.com/gradle/actions/setup-gradle@v4
- name: Unit tests
run: ./gradlew testDebugUnitTest --stacktrace
- name: Build debug APK
run: ./gradlew assembleDebug --stacktrace
- name: Upload APK
uses: actions/upload-artifact@v4
uses: https://github.com/actions/upload-artifact@v4
with:
name: castarr-debug-apk
path: app/build/outputs/apk/debug/app-debug.apk
- name: Upload test report
if: always()
uses: https://github.com/actions/upload-artifact@v4
with:
name: castarr-test-report
path: tests/runs/

View File

@@ -12,8 +12,8 @@ android {
applicationId = "dev.castarr.tv"
minSdk = 26
targetSdk = 35
versionCode = 27
versionName = "0.10.1"
versionCode = 29
versionName = "0.10.3"
}
// Release signing from environment (see ~/.keys/castarr-release.env on the

View File

@@ -27,6 +27,7 @@ class AppState(
val dispatcharr: DispatcharrRepository,
) {
private val prefs = context.getSharedPreferences("app", Context.MODE_PRIVATE)
val crests = dev.castarr.tv.data.Crests(context)
var screen by mutableStateOf(Screen.LIVE)
var playerVisible by mutableStateOf(false)

View File

@@ -50,7 +50,14 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
server.startServer()
state.remoteAvailable = server.running
setContent { CastarrApp(state) }
lifecycleScope.launch { UpdateChecker.check(state) }
lifecycleScope.launch {
// At start-up and then occasionally: a TV often keeps the same
// app process alive for days.
while (true) {
UpdateChecker.check(state)
delay(UPDATE_CHECK_INTERVAL_MS)
}
}
}
private fun onPlaybackChanged() {
@@ -325,5 +332,6 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
const val TICK_INTERVAL_MS = 2_000L
const val SEEK_STEP_SECONDS = 10L
const val DIGIT_COMMIT_MS = 1_800L
const val UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000L
}
}

View File

@@ -0,0 +1,54 @@
package dev.castarr.tv.data
import android.content.Context
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
/**
* Resolves club crests at runtime from Wikipedia's page summary. The badges
* are trademarks, so they are fetched and cached on the device rather than
* shipped with the app — nothing copyrighted lives in the repository.
*/
class Crests(context: Context) {
private val prefs = context.getSharedPreferences("crests", Context.MODE_PRIVATE)
/** Cached crest URL for a club, fetching it once per article. */
suspend fun urlFor(article: String): String? = withContext(Dispatchers.IO) {
prefs.getString(article, null)?.let { return@withContext it.ifEmpty { null } }
val resolved = runCatching { fetch(article) }
.onFailure { Log.w(TAG, "crest lookup failed: ${it.javaClass.simpleName}") }
.getOrNull()
// Remember misses too, so a club without a crest is not looked up
// again on every screen.
prefs.edit().putString(article, resolved.orEmpty()).apply()
resolved
}
private fun fetch(article: String): String? {
val encoded = URLEncoder.encode(article, "UTF-8").replace("+", "%20")
val connection = URL("$SUMMARY$encoded").openConnection() as HttpURLConnection
return try {
connection.connectTimeout = 10_000
connection.readTimeout = 10_000
connection.setRequestProperty("User-Agent", USER_AGENT)
connection.setRequestProperty("Accept", "application/json")
val body = connection.inputStream.bufferedReader().use { it.readText() }
JSONObject(body).optJSONObject("thumbnail")?.optString("source")
?.takeIf { it.isNotEmpty() }
} finally {
connection.disconnect()
}
}
private companion object {
const val TAG = "Crests"
const val SUMMARY = "https://de.wikipedia.org/api/rest_v1/page/summary/"
const val USER_AGENT = "Castarr TV (private use)"
}
}

View File

@@ -5,8 +5,8 @@ import androidx.compose.ui.graphics.Color
/**
* Per-club shortcut to "where does my club play tonight": a rail entry that
* lists every channel whose EPG mentions the club within the next few hours.
* Club crests are trademarks, so the icon is a plain shield in the club
* colours rather than the real badge.
* Crests are fetched from Wikipedia at runtime and cached on the device;
* the club colours are the fallback while that is pending or unavailable.
*/
data class TeamFilter(
val key: String,
@@ -18,6 +18,8 @@ data class TeamFilter(
val needles: List<String>,
val primary: Color,
val secondary: Color,
/** German Wikipedia article the crest is resolved from. */
val article: String,
) {
fun matches(title: String): Boolean {
val haystack = title.lowercase()
@@ -43,7 +45,8 @@ object TeamFilters {
needles: List<String>,
primary: Long,
secondary: Long,
) = TeamFilter(key, label, fullName, needles, Color(primary), Color(secondary))
article: String = fullName,
) = TeamFilter(key, label, fullName, needles, Color(primary), Color(secondary), article)
/** Clubs of the top three German divisions. */
val all: List<TeamFilter> = listOf(

View File

@@ -132,6 +132,10 @@ private fun TopBar(state: AppState) {
}
Spacer(Modifier.width(18.dp))
}
state.updateAvailable?.let { version ->
UpdateChip(version) { state.screen = AppState.Screen.SETTINGS }
Spacer(Modifier.width(10.dp))
}
GearButton {
state.screen = if (state.screen == AppState.Screen.LIVE) AppState.Screen.SETTINGS
else AppState.Screen.LIVE
@@ -139,6 +143,33 @@ private fun TopBar(state: AppState) {
}
}
/**
* Shown in the top bar as soon as the start-up check finds a newer
* release — otherwise an update would only be noticed by someone who
* happens to open the settings.
*/
@Composable
private fun UpdateChip(version: String, onClick: () -> Unit) {
Surface(
onClick = onClick,
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)),
colors = ClickableSurfaceDefaults.colors(
containerColor = CastarrColors.accentDim,
contentColor = CastarrColors.accent,
focusedContainerColor = CastarrColors.accent,
focusedContentColor = CastarrColors.onAccent,
),
) {
Text(
"Update $version",
fontFamily = AppFont,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
)
}
}
/** Round settings button with a drawn gear (glyphs render as emoji). */
@Composable
private fun GearButton(onClick: () -> Unit) {

View File

@@ -193,7 +193,7 @@ fun LiveScreen(state: AppState) {
label = club.label,
count = matchesByTeam[club.key]?.size ?: 0,
selected = state.activeTeam == club.key,
leading = { Crest(club) },
leading = { Crest(club, state) },
modifier = intoList.then(
if (state.activeTeam == club.key) Modifier.focusRequester(railFocus)
else Modifier
@@ -433,11 +433,33 @@ private fun ChannelRow(
}
/**
* Stand-in for the club badge: real crests are trademarks, so this draws a
* simple shield in the club colours instead.
* The club badge, resolved from Wikipedia and cached on the device. Until
* it arrives (or if it never does) a shield in the club colours stands in.
*/
@Composable
private fun Crest(team: dev.castarr.tv.data.TeamFilter) {
private fun Crest(team: dev.castarr.tv.data.TeamFilter, state: AppState) {
val url by androidx.compose.runtime.produceState<String?>(null, team.key) {
value = state.crests.urlFor(team.article)
}
Box(Modifier.size(20.dp), contentAlignment = Alignment.Center) {
val crestUrl = url
if (crestUrl != null) {
SubcomposeAsyncImage(
model = crestUrl,
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(),
loading = { ShieldFallback(team) },
error = { ShieldFallback(team) },
)
} else {
ShieldFallback(team)
}
}
}
@Composable
private fun ShieldFallback(team: dev.castarr.tv.data.TeamFilter) {
Canvas(Modifier.size(18.dp)) {
val w = size.width
val h = size.height

View File

@@ -14,9 +14,13 @@ import androidx.compose.foundation.layout.Spacer
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
@@ -308,7 +312,13 @@ fun SettingsScreen(state: AppState) {
@Composable
private fun PickerDialog(picker: Picker, onClose: () -> Unit) {
val selectedFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { selectedFocus.requestFocus() }
// A requester bound to no composed row throws; with no preselection
// (adding something new) there is deliberately no such row.
LaunchedEffect(Unit) { runCatching { selectedFocus.requestFocus() } }
val listState = rememberLazyListState()
LaunchedEffect(picker.selected) {
if (picker.selected > 0) listState.scrollToItem(picker.selected)
}
Dialog(
onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false),
@@ -337,7 +347,11 @@ private fun PickerDialog(picker: Picker, onClose: () -> Unit) {
letterSpacing = 2.sp,
modifier = Modifier.padding(start = 14.dp, bottom = 10.dp),
)
picker.options.forEachIndexed { index, option ->
LazyColumn(
state = listState,
modifier = Modifier.heightIn(max = 420.dp),
) {
itemsIndexed(picker.options) { index, option ->
val selected = index == picker.selected
Surface(
onClick = {
@@ -346,7 +360,11 @@ private fun PickerDialog(picker: Picker, onClose: () -> Unit) {
},
modifier = Modifier
.fillMaxWidth()
.then(if (selected) Modifier.focusRequester(selectedFocus) else Modifier),
.then(
if (selected || (picker.selected < 0 && index == 0))
Modifier.focusRequester(selectedFocus)
else Modifier
),
shape = ClickableSurfaceDefaults.shape(rowShape),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
colors = ClickableSurfaceDefaults.colors(
@@ -387,6 +405,7 @@ private fun PickerDialog(picker: Picker, onClose: () -> Unit) {
}
}
}
}
}
}
}