Club shortcut: a rail group for "where is my club on tonight"
Some checks failed
Build TV app / build (push) Failing after 2s

Adds an optional rail entry per club that lists every channel whose EPG
mentions the club within the next three hours, with the matching kick-off
instead of the usual Now/Next. Only sport and free-to-air groups are
scanned (NFL excluded), so the scan stays cheap and quiet.

Which clubs appear is a per-TV setting: the signed-in viewer's own club is
on by default, and every club can be switched on or off under Einstellungen
-> Vereinsmenüs, so a household can follow more than one.

Crests are trademarks, so the icon is a plain shield in the club colours.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
be-nj
2026-08-26 02:47:06 +02:00
parent 753ab3a3ab
commit 7ad7f5172e
10 changed files with 401 additions and 14 deletions

View File

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

View File

@@ -10,6 +10,7 @@ import androidx.compose.runtime.setValue
import dev.castarr.tv.auth.DeviceAuth
import dev.castarr.tv.data.DispatcharrRepository
import dev.castarr.tv.data.NowNext
import dev.castarr.tv.data.isEpgPlaceholder
import dev.castarr.tv.data.SourceRepository
import dev.castarr.tv.player.PlayerController
import dev.castarr.tv.playlist.Channel
@@ -38,6 +39,30 @@ class AppState(
var isLive by mutableStateOf(false)
var favoritesOnly by mutableStateOf(false)
var groupFilter by mutableStateOf<String?>(null)
/** Club shortcut currently open in the rail, if any. */
var activeTeam by mutableStateOf<String?>(null)
/**
* Club menus switched on for this TV. Seeded from the signed-in viewer's
* own club; every club can be toggled on in the settings, so a household
* can watch for more than one.
*/
var enabledTeams by mutableStateOf(loadEnabledTeams())
private set
private fun loadEnabledTeams(): Set<String> {
val stored = prefs.getStringSet("teams", null)
if (stored != null) return stored.toSet()
return setOfNotNull(dev.castarr.tv.data.TeamFilters.defaultKeyFor(auth.username))
}
fun toggleTeam(key: String) {
enabledTeams =
if (key in enabledTeams) enabledTeams - key else enabledTeams + key
prefs.edit().putStringSet("teams", enabledTeams).apply()
if (activeTeam == key && key !in enabledTeams) activeTeam = null
}
var audioTracks by mutableStateOf<List<PlayerController.AudioTrack>>(emptyList())
private set
private var lastNowTitle: String? = null
@@ -132,6 +157,29 @@ class AppState(
SourceMode.DISPATCHARR -> dispatcharr.nowNext(channel)
}
private fun upcoming(channel: Channel, windowMs: Long) = when (sourceMode) {
SourceMode.GENERIC -> source.upcoming(channel, windowMs)
SourceMode.DISPATCHARR -> dispatcharr.upcoming(channel, windowMs)
}
/**
* Channels showing the viewer's club within the scanned window, paired
* with the programme that matched — earliest kick-off first, so whatever
* is running right now sits on top.
*/
fun teamMatches(key: String): List<Pair<Channel, dev.castarr.tv.data.Programme>> {
val filter = dev.castarr.tv.data.TeamFilters.byKey(key) ?: return emptyList()
val window = dev.castarr.tv.data.TeamFilters.WINDOW_MS
return activeChannels()
.filter { dev.castarr.tv.data.TeamFilters.scansGroup(it.group) }
.mapNotNull { channel ->
upcoming(channel, window)
.firstOrNull { !isEpgPlaceholder(it.title) && filter.matches(it.title) }
?.let { channel to it }
}
.sortedBy { it.second.start }
}
fun refreshActive() {
when (sourceMode) {
SourceMode.GENERIC -> source.refresh()
@@ -168,6 +216,8 @@ class AppState(
}
fun startOnboarding() {
prefs.edit().remove("teams").apply()
activeTeam = null
auth.logout()
appError = AppError.NONE
welcomePhase = WelcomePhase.WAIT_PHONE

View File

@@ -71,6 +71,12 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) {
fun nowNext(channel: Channel): NowNext =
XmltvParser.nowNext(programmesByTvgId[channel.tvgId])
/** Programmes of this channel between now and now + [windowMs]. */
fun upcoming(channel: Channel, windowMs: Long): List<Programme> {
val now = System.currentTimeMillis()
return XmltvParser.programmesIn(programmesByTvgId[channel.tvgId], now, now + windowMs)
}
fun toggleFavorite(channel: Channel) {
if (channel.backendId == 0) return
scope.launch {

View File

@@ -82,13 +82,20 @@ class SourceRepository(context: Context) {
status.value = ""
}
/** Now/Next for an M3U channel: match tvg-id first, then name. */
fun nowNext(channel: Channel): NowNext {
val direct = channel.tvgId.takeIf { it.isNotEmpty() }?.let { programmes[it] }
val byName = direct ?: nameToEpgId[channel.name.lowercase()]?.let { programmes[it] }
return XmltvParser.nowNext(byName)
/** Programmes of this channel between now and now + [windowMs]. */
fun upcoming(channel: Channel, windowMs: Long): List<Programme> {
val now = System.currentTimeMillis()
return XmltvParser.programmesIn(programmesFor(channel), now, now + windowMs)
}
private fun programmesFor(channel: Channel): List<Programme>? {
val direct = channel.tvgId.takeIf { it.isNotEmpty() }?.let { programmes[it] }
return direct ?: nameToEpgId[channel.name.lowercase()]?.let { programmes[it] }
}
/** Now/Next for an M3U channel: match tvg-id first, then name. */
fun nowNext(channel: Channel): NowNext = XmltvParser.nowNext(programmesFor(channel))
private fun loadCachedChannels() {
runCatching {
val cached = prefs.getString("channels_cache", null) ?: return

View File

@@ -0,0 +1,85 @@
package dev.castarr.tv.data
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.
*/
data class TeamFilter(
val key: String,
/** Short caption next to the crest in the rail. */
val label: String,
/** How the club is named in a sentence. */
val name: String,
/** Full club name, used in the settings. */
val fullName: String,
/** Lowercase needles matched against programme titles. */
val needles: List<String>,
val primary: Color,
val secondary: Color,
) {
fun matches(title: String): Boolean {
val haystack = title.lowercase()
return needles.any { haystack.contains(it) }
}
}
object TeamFilters {
/** Window scanned ahead of now. */
const val WINDOW_MS = 3 * 60 * 60 * 1000L
val all = listOf(
TeamFilter(
key = "hansa",
label = "FCH",
name = "Hansa",
fullName = "Hansa Rostock",
needles = listOf("hansa"),
primary = Color(0xFF0B4EA2),
secondary = Color(0xFFF2F3F5),
),
TeamFilter(
key = "vfb",
label = "1893",
name = "dem VfB",
fullName = "VfB Stuttgart",
needles = listOf("vfb stuttgart", "vfb"),
primary = Color(0xFFE32219),
secondary = Color(0xFFF2F3F5),
),
)
/** The club a viewer gets switched on by default. */
private val defaultForUser = mapOf(
"benjamin" to "hansa",
"tobiasb" to "vfb",
)
/**
* Only sport and free-to-air groups are scanned. Searching all 500
* channels would mostly turn up shopping and radio, and the club is
* never on those anyway.
*/
private val groupIncludes = listOf(
"sport", "dazn", "sky", "magenta", "prime", "free tv", "hd+",
"dyn", "del", "bundesliga", "fussball", "fußball", "at / ch", "at/ch",
)
/** Explicitly out of scope even though they match an include. */
private val groupExcludes = listOf("nfl")
fun scansGroup(group: String): Boolean {
val g = group.lowercase()
if (groupExcludes.any { g.contains(it) }) return false
return groupIncludes.any { g.contains(it) }
}
fun byKey(key: String): TeamFilter? = all.firstOrNull { it.key == key }
fun defaultKeyFor(username: String): String? =
defaultForUser[username.trim().lowercase()]
}

View File

@@ -105,6 +105,14 @@ object XmltvParser {
}
}
/** Programmes overlapping [from]..[to], in broadcast order. */
fun programmesIn(
programmes: List<Programme>?,
from: Long,
to: Long,
): List<Programme> =
programmes.orEmpty().filter { it.stop > from && it.start < to }
fun nowNext(programmes: List<Programme>?, at: Long = System.currentTimeMillis()): NowNext {
if (programmes.isNullOrEmpty()) return NowNext(null, null)
val index = programmes.indexOfFirst { at < it.stop }

View File

@@ -1,6 +1,7 @@
package dev.castarr.tv.ui
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -29,6 +30,9 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
@@ -71,7 +75,19 @@ fun LiveScreen(state: AppState) {
val groups = remember(allChannels) {
allChannels.mapNotNull { it.group.ifEmpty { null } }.distinct().sorted()
}
val teams = remember(state.enabledTeams) {
dev.castarr.tv.data.TeamFilters.all.filter { it.key in state.enabledTeams }
}
// Recomputed when the EPG or the channel list changes — scanning the
// sport groups' programmes is cheap, but not per frame.
val matchesByTeam = remember(teams, allChannels, epgStamp) {
teams.associate { it.key to state.teamMatches(it.key) }
}
val activeTeam = teams.firstOrNull { it.key == state.activeTeam }
val activeMatches = activeTeam?.let { matchesByTeam[it.key] }.orEmpty()
val teamHits = remember(activeMatches) { activeMatches.associate { it.first.url to it.second } }
val channels = when {
activeTeam != null -> activeMatches.map { it.first }
state.favoritesOnly -> allChannels.filter { it.backendId in favorites }
state.groupFilter != null -> allChannels.filter { it.group == state.groupFilter }
else -> allChannels
@@ -128,7 +144,8 @@ fun LiveScreen(state: AppState) {
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
item {
val selected = !state.favoritesOnly && state.groupFilter == null
val selected = !state.favoritesOnly && state.groupFilter == null &&
state.activeTeam == null
GroupItem(
label = "Alle Sender",
count = allChannels.size,
@@ -138,6 +155,7 @@ fun LiveScreen(state: AppState) {
),
suppressAutoSelect = { state.restorePending },
) {
state.activeTeam = null
state.favoritesOnly = false
state.groupFilter = null
}
@@ -147,17 +165,35 @@ fun LiveScreen(state: AppState) {
GroupItem(
label = "★ Favoriten",
count = favorites.size,
selected = state.favoritesOnly,
selected = state.favoritesOnly && state.activeTeam == null,
modifier = intoList.then(
if (state.favoritesOnly) Modifier.focusRequester(railFocus) else Modifier
),
suppressAutoSelect = { state.restorePending },
) {
state.activeTeam = null
state.favoritesOnly = true
state.groupFilter = null
}
}
}
items(teams, key = { it.key }) { club ->
GroupItem(
label = club.label,
count = matchesByTeam[club.key]?.size ?: 0,
selected = state.activeTeam == club.key,
leading = { Crest(club) },
modifier = intoList.then(
if (state.activeTeam == club.key) Modifier.focusRequester(railFocus)
else Modifier
),
suppressAutoSelect = { state.restorePending },
) {
state.activeTeam = club.key
state.favoritesOnly = false
state.groupFilter = null
}
}
item {
Box(
Modifier
@@ -171,12 +207,13 @@ fun LiveScreen(state: AppState) {
GroupItem(
label = group,
count = remember(allChannels, group) { allChannels.count { it.group == group } },
selected = state.groupFilter == group,
selected = state.groupFilter == group && state.activeTeam == null,
modifier = intoList.then(
if (state.groupFilter == group) Modifier.focusRequester(railFocus) else Modifier
),
suppressAutoSelect = { state.restorePending },
) {
state.activeTeam = null
state.favoritesOnly = false
state.groupFilter = group
}
@@ -196,9 +233,13 @@ fun LiveScreen(state: AppState) {
if (channels.isEmpty()) {
item {
Text(
if (state.favoritesOnly)
"Noch keine Favoriten — halte OK auf einem Sender gedrückt."
else "Diese Gruppe ist leer.",
when {
activeTeam != null ->
"In den nächsten 3 Stunden läuft nichts mit ${activeTeam.name}."
state.favoritesOnly ->
"Noch keine Favoriten — halte OK auf einem Sender gedrückt."
else -> "Diese Gruppe ist leer."
},
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 14.sp,
@@ -220,6 +261,7 @@ fun LiveScreen(state: AppState) {
else Modifier
),
nowNext = state.nowNext(channel),
highlight = teamHits[channel.url],
playing = state.currentChannel?.url == channel.url,
// All rows are favorites in the favorites view — the
// star only carries meaning elsewhere.
@@ -241,6 +283,7 @@ private fun GroupItem(
count: Int,
selected: Boolean,
modifier: Modifier = Modifier,
leading: (@Composable () -> Unit)? = null,
suppressAutoSelect: () -> Boolean = { false },
onSelect: () -> Unit,
) {
@@ -264,6 +307,10 @@ private fun GroupItem(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (leading != null) {
leading()
Spacer(Modifier.width(10.dp))
}
Text(
label,
fontFamily = AppFont,
@@ -289,6 +336,7 @@ private fun ChannelRow(
number: Int,
modifier: Modifier = Modifier,
nowNext: NowNext,
highlight: dev.castarr.tv.data.Programme? = null,
playing: Boolean,
favorite: Boolean,
epgStamp: Long,
@@ -349,7 +397,13 @@ private fun ChannelRow(
}
}
Spacer(Modifier.width(24.dp))
EpgCell(nowNext, Modifier.weight(1f))
if (highlight != null) {
// In the club view the matching broadcast is the point, not
// whatever happens to be running.
HighlightCell(highlight, Modifier.weight(1f))
} else {
EpgCell(nowNext, Modifier.weight(1f))
}
if (playing) {
Spacer(Modifier.width(12.dp))
Box(
@@ -363,6 +417,35 @@ private fun ChannelRow(
}
}
/**
* Stand-in for the club badge: real crests are trademarks, so this draws a
* simple shield in the club colours instead.
*/
@Composable
private fun Crest(team: dev.castarr.tv.data.TeamFilter) {
Canvas(Modifier.size(18.dp)) {
val w = size.width
val h = size.height
val shield = Path().apply {
moveTo(w * 0.5f, 0f)
lineTo(w, h * 0.18f)
lineTo(w, h * 0.55f)
cubicTo(w, h * 0.82f, w * 0.75f, h * 0.95f, w * 0.5f, h)
cubicTo(w * 0.25f, h * 0.95f, 0f, h * 0.82f, 0f, h * 0.55f)
lineTo(0f, h * 0.18f)
close()
}
drawPath(shield, team.primary)
clipPath(shield) {
drawRect(
team.secondary,
topLeft = Offset(0f, h * 0.42f),
size = androidx.compose.ui.geometry.Size(w, h * 0.16f),
)
}
}
}
/** Bare channel logo (they ship transparent); initials as fallback. */
@Composable
private fun LogoTile(channel: Channel) {
@@ -397,6 +480,40 @@ private fun LogoInitials(initials: String) {
}
}
/** The programme that matched the club filter, with its start time. */
@Composable
private fun HighlightCell(programme: dev.castarr.tv.data.Programme, modifier: Modifier = Modifier) {
val running = System.currentTimeMillis() in programme.start until programme.stop
Column(modifier) {
Row(verticalAlignment = Alignment.Bottom) {
Text(
programme.title,
color = CastarrColors.fg,
fontFamily = AppFont,
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
Spacer(Modifier.width(12.dp))
Text(
if (running) "läuft" else "ab ${formatClock(programme.start)}",
color = if (running) CastarrColors.accent else CastarrColors.muted,
fontFamily = AppFont,
fontSize = 12.sp,
fontWeight = if (running) FontWeight.SemiBold else FontWeight.Normal,
)
}
Spacer(Modifier.height(6.dp))
Text(
"${formatClock(programme.start)}${formatClock(programme.stop)}",
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 12.sp,
)
}
}
private fun formatClock(millis: Long): String =
SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis))

View File

@@ -163,6 +163,40 @@ fun SettingsScreen(state: AppState) {
}
}
SettingsCard("Vereinsmenüs") {
Text(
"Zeigt eine eigene Gruppe mit allen Sendern, auf denen der " +
"Verein in den nächsten 3 Stunden läuft.",
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 11.sp,
modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 6.dp),
)
dev.castarr.tv.data.TeamFilters.all.forEach { club ->
val on = club.key in state.enabledTeams
SettingRow(
club.fullName,
subtitle = "Erscheint als ${club.label} in der Senderliste",
trailing = {
Box(
Modifier
.clip(RoundedCornerShape(999.dp))
.background(
if (on) CastarrColors.accent else CastarrColors.bg
)
.padding(horizontal = 12.dp, vertical = 5.dp)
) {
Text(
if (on) "An" else "Aus",
color = if (on) CastarrColors.onAccent else CastarrColors.muted,
fontFamily = AppFont,
fontSize = 12.sp,
fontWeight = if (on) FontWeight.SemiBold else FontWeight.Normal,
)
}
},
) { state.toggleTeam(club.key) }
}
}
SettingsCard("App") {
SettingRow(
"Version ${BuildConfig.VERSION_NAME}",