Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ad7f5172e |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
85
app/src/main/java/dev/castarr/tv/data/TeamFilters.kt
Normal file
85
app/src/main/java/dev/castarr/tv/data/TeamFilters.kt
Normal 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()]
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
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.",
|
||||
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))
|
||||
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))
|
||||
|
||||
|
||||
@@ -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}",
|
||||
|
||||
46
tests/unit/TeamFiltersTest.kt
Normal file
46
tests/unit/TeamFiltersTest.kt
Normal file
@@ -0,0 +1,46 @@
|
||||
package dev.castarr.tv.data
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class TeamFiltersTest {
|
||||
|
||||
private val hansa = TeamFilters.byKey("hansa")!!
|
||||
private val vfb = TeamFilters.byKey("vfb")!!
|
||||
|
||||
@Test
|
||||
fun `matches the club regardless of case and surrounding text`() {
|
||||
assertTrue(hansa.matches("3. Liga: FC Hansa Rostock - Dynamo Dresden"))
|
||||
assertTrue(hansa.matches("HANSA ROSTOCK KOMPAKT"))
|
||||
assertTrue(vfb.matches("Bundesliga: VfB Stuttgart - Bayern München"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not match unrelated programmes`() {
|
||||
assertFalse(hansa.matches("Tagesschau"))
|
||||
assertFalse(vfb.matches("Hansa Rostock - Saarbrücken"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scans sport and free-to-air groups`() {
|
||||
listOf("DAZN Event", "Sky Sport", "Magenta Sport", "Amazon Prime", "Free TV / HD+", "DYN Sport")
|
||||
.forEach { assertTrue(it, TeamFilters.scansGroup(it)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skips NFL and unrelated groups`() {
|
||||
assertFalse(TeamFilters.scansGroup("DAZN Event NFL"))
|
||||
assertFalse(TeamFilters.scansGroup("Kids"))
|
||||
assertFalse(TeamFilters.scansGroup("Musik"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `assigns each viewer their own club by default`() {
|
||||
assertEquals("hansa", TeamFilters.defaultKeyFor("benjamin"))
|
||||
assertEquals("vfb", TeamFilters.defaultKeyFor("TobiasB"))
|
||||
assertNull(TeamFilters.defaultKeyFor("someone-else"))
|
||||
}
|
||||
}
|
||||
34
tests/unit/XmltvWindowTest.kt
Normal file
34
tests/unit/XmltvWindowTest.kt
Normal file
@@ -0,0 +1,34 @@
|
||||
package dev.castarr.tv.data
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class XmltvWindowTest {
|
||||
|
||||
private fun p(startMin: Long, endMin: Long, title: String) =
|
||||
Programme(startMin * 60_000, endMin * 60_000, title)
|
||||
|
||||
private val schedule = listOf(
|
||||
p(0, 60, "Läuft gerade"),
|
||||
p(60, 120, "Gleich danach"),
|
||||
p(150, 210, "In zweieinhalb Stunden"),
|
||||
p(300, 360, "Deutlich später"),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `returns programmes overlapping the window`() {
|
||||
val titles = XmltvParser.programmesIn(schedule, 30 * 60_000, 180 * 60_000).map { it.title }
|
||||
assertEquals(listOf("Läuft gerade", "Gleich danach", "In zweieinhalb Stunden"), titles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `excludes programmes outside the window`() {
|
||||
val titles = XmltvParser.programmesIn(schedule, 0, 60 * 60_000).map { it.title }
|
||||
assertEquals(listOf("Läuft gerade"), titles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handles a missing schedule`() {
|
||||
assertEquals(emptyList<Programme>(), XmltvParser.programmesIn(null, 0, 1_000))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user