Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ad7f5172e | ||
|
|
753ab3a3ab | ||
|
|
f157323626 |
@@ -38,7 +38,7 @@ TV-Fernbedienung oder steuert alles bequem vom Handy aus.
|
|||||||
1. Auf dem Google TV die App **Downloader** installieren (oder einen anderen
|
1. Auf dem Google TV die App **Downloader** installieren (oder einen anderen
|
||||||
Weg nutzen, eine APK-Datei zu öffnen).
|
Weg nutzen, eine APK-Datei zu öffnen).
|
||||||
2. Diese Adresse eingeben:
|
2. Diese Adresse eingeben:
|
||||||
`git.beckm4nn.net/benjamin/castarr/raw/branch/apk/castarr.apk`
|
`git.beckm4nn.net/be-nj/castarr/raw/branch/apk/castarr.apk`
|
||||||
3. Installation bestätigen („Unbekannte Quellen" für Downloader erlauben,
|
3. Installation bestätigen („Unbekannte Quellen" für Downloader erlauben,
|
||||||
wenn der Fernseher fragt).
|
wenn der Fernseher fragt).
|
||||||
4. Castarr starten und den drei Schritten auf dem Bildschirm folgen:
|
4. Castarr starten und den drei Schritten auf dem Bildschirm folgen:
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ android {
|
|||||||
applicationId = "dev.castarr.tv"
|
applicationId = "dev.castarr.tv"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 23
|
versionCode = 26
|
||||||
versionName = "0.9.0"
|
versionName = "0.10.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release signing from environment (see ~/.keys/castarr-release.env on the
|
// 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.auth.DeviceAuth
|
||||||
import dev.castarr.tv.data.DispatcharrRepository
|
import dev.castarr.tv.data.DispatcharrRepository
|
||||||
import dev.castarr.tv.data.NowNext
|
import dev.castarr.tv.data.NowNext
|
||||||
|
import dev.castarr.tv.data.isEpgPlaceholder
|
||||||
import dev.castarr.tv.data.SourceRepository
|
import dev.castarr.tv.data.SourceRepository
|
||||||
import dev.castarr.tv.player.PlayerController
|
import dev.castarr.tv.player.PlayerController
|
||||||
import dev.castarr.tv.playlist.Channel
|
import dev.castarr.tv.playlist.Channel
|
||||||
@@ -38,6 +39,30 @@ class AppState(
|
|||||||
var isLive by mutableStateOf(false)
|
var isLive by mutableStateOf(false)
|
||||||
var favoritesOnly by mutableStateOf(false)
|
var favoritesOnly by mutableStateOf(false)
|
||||||
var groupFilter by mutableStateOf<String?>(null)
|
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())
|
var audioTracks by mutableStateOf<List<PlayerController.AudioTrack>>(emptyList())
|
||||||
private set
|
private set
|
||||||
private var lastNowTitle: String? = null
|
private var lastNowTitle: String? = null
|
||||||
@@ -51,6 +76,13 @@ class AppState(
|
|||||||
/** Channel to restore focus to when the list comes back (#13). */
|
/** Channel to restore focus to when the list comes back (#13). */
|
||||||
var lastWatched by mutableStateOf<Channel?>(null)
|
var lastWatched by mutableStateOf<Channel?>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True while the list is being restored after playback. The group rail
|
||||||
|
* opens whatever gets focused, so without this the focus landing there
|
||||||
|
* would silently switch the view back to "Alle Sender".
|
||||||
|
*/
|
||||||
|
var restorePending by mutableStateOf(false)
|
||||||
|
|
||||||
private val reentryHandler = android.os.Handler(android.os.Looper.getMainLooper())
|
private val reentryHandler = android.os.Handler(android.os.Looper.getMainLooper())
|
||||||
private var lastStoppedUrl: String = ""
|
private var lastStoppedUrl: String = ""
|
||||||
private var lastStoppedAt: Long = 0L
|
private var lastStoppedAt: Long = 0L
|
||||||
@@ -125,6 +157,29 @@ class AppState(
|
|||||||
SourceMode.DISPATCHARR -> dispatcharr.nowNext(channel)
|
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() {
|
fun refreshActive() {
|
||||||
when (sourceMode) {
|
when (sourceMode) {
|
||||||
SourceMode.GENERIC -> source.refresh()
|
SourceMode.GENERIC -> source.refresh()
|
||||||
@@ -161,6 +216,8 @@ class AppState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun startOnboarding() {
|
fun startOnboarding() {
|
||||||
|
prefs.edit().remove("teams").apply()
|
||||||
|
activeTeam = null
|
||||||
auth.logout()
|
auth.logout()
|
||||||
appError = AppError.NONE
|
appError = AppError.NONE
|
||||||
welcomePhase = WelcomePhase.WAIT_PHONE
|
welcomePhase = WelcomePhase.WAIT_PHONE
|
||||||
@@ -190,6 +247,7 @@ class AppState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun stopPlayback() {
|
fun stopPlayback() {
|
||||||
|
restorePending = lastWatched != null
|
||||||
reentryHandler.removeCallbacksAndMessages(null)
|
reentryHandler.removeCallbacksAndMessages(null)
|
||||||
lastStoppedUrl = currentChannel?.url.orEmpty()
|
lastStoppedUrl = currentChannel?.url.orEmpty()
|
||||||
lastStoppedAt = System.currentTimeMillis()
|
lastStoppedAt = System.currentTimeMillis()
|
||||||
|
|||||||
@@ -71,6 +71,12 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) {
|
|||||||
fun nowNext(channel: Channel): NowNext =
|
fun nowNext(channel: Channel): NowNext =
|
||||||
XmltvParser.nowNext(programmesByTvgId[channel.tvgId])
|
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) {
|
fun toggleFavorite(channel: Channel) {
|
||||||
if (channel.backendId == 0) return
|
if (channel.backendId == 0) return
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
|||||||
@@ -82,13 +82,20 @@ class SourceRepository(context: Context) {
|
|||||||
status.value = ""
|
status.value = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Now/Next for an M3U channel: match tvg-id first, then name. */
|
/** Programmes of this channel between now and now + [windowMs]. */
|
||||||
fun nowNext(channel: Channel): NowNext {
|
fun upcoming(channel: Channel, windowMs: Long): List<Programme> {
|
||||||
val direct = channel.tvgId.takeIf { it.isNotEmpty() }?.let { programmes[it] }
|
val now = System.currentTimeMillis()
|
||||||
val byName = direct ?: nameToEpgId[channel.name.lowercase()]?.let { programmes[it] }
|
return XmltvParser.programmesIn(programmesFor(channel), now, now + windowMs)
|
||||||
return XmltvParser.nowNext(byName)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
private fun loadCachedChannels() {
|
||||||
runCatching {
|
runCatching {
|
||||||
val cached = prefs.getString("channels_cache", null) ?: return
|
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 {
|
fun nowNext(programmes: List<Programme>?, at: Long = System.currentTimeMillis()): NowNext {
|
||||||
if (programmes.isNullOrEmpty()) return NowNext(null, null)
|
if (programmes.isNullOrEmpty()) return NowNext(null, null)
|
||||||
val index = programmes.indexOfFirst { at < it.stop }
|
val index = programmes.indexOfFirst { at < it.stop }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package dev.castarr.tv.ui
|
package dev.castarr.tv.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -29,6 +30,9 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusProperties
|
import androidx.compose.ui.focus.focusProperties
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
@@ -71,7 +75,19 @@ fun LiveScreen(state: AppState) {
|
|||||||
val groups = remember(allChannels) {
|
val groups = remember(allChannels) {
|
||||||
allChannels.mapNotNull { it.group.ifEmpty { null } }.distinct().sorted()
|
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 {
|
val channels = when {
|
||||||
|
activeTeam != null -> activeMatches.map { it.first }
|
||||||
state.favoritesOnly -> allChannels.filter { it.backendId in favorites }
|
state.favoritesOnly -> allChannels.filter { it.backendId in favorites }
|
||||||
state.groupFilter != null -> allChannels.filter { it.group == state.groupFilter }
|
state.groupFilter != null -> allChannels.filter { it.group == state.groupFilter }
|
||||||
else -> allChannels
|
else -> allChannels
|
||||||
@@ -106,11 +122,12 @@ fun LiveScreen(state: AppState) {
|
|||||||
// A fresh group starts at the top; a return from playback does not.
|
// A fresh group starts at the top; a return from playback does not.
|
||||||
if (restoreIndex == null) listState.scrollToItem(0)
|
if (restoreIndex == null) listState.scrollToItem(0)
|
||||||
}
|
}
|
||||||
LaunchedEffect(restoreIndex, channels.size) {
|
LaunchedEffect(restoreIndex, channels.size, state.restorePending) {
|
||||||
if (restoreIndex != null && !restored) {
|
if (restoreIndex != null && (state.restorePending || !restored)) {
|
||||||
listState.scrollToItem(restoreIndex)
|
listState.scrollToItem(restoreIndex)
|
||||||
runCatching { restoreFocus.requestFocus() }
|
runCatching { restoreFocus.requestFocus() }
|
||||||
restored = true
|
restored = true
|
||||||
|
state.restorePending = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val intoList = if (channels.isEmpty()) Modifier
|
val intoList = if (channels.isEmpty()) Modifier
|
||||||
@@ -127,7 +144,8 @@ fun LiveScreen(state: AppState) {
|
|||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
val selected = !state.favoritesOnly && state.groupFilter == null
|
val selected = !state.favoritesOnly && state.groupFilter == null &&
|
||||||
|
state.activeTeam == null
|
||||||
GroupItem(
|
GroupItem(
|
||||||
label = "Alle Sender",
|
label = "Alle Sender",
|
||||||
count = allChannels.size,
|
count = allChannels.size,
|
||||||
@@ -135,7 +153,9 @@ fun LiveScreen(state: AppState) {
|
|||||||
modifier = intoList.then(
|
modifier = intoList.then(
|
||||||
if (selected) Modifier.focusRequester(railFocus) else Modifier
|
if (selected) Modifier.focusRequester(railFocus) else Modifier
|
||||||
),
|
),
|
||||||
|
suppressAutoSelect = { state.restorePending },
|
||||||
) {
|
) {
|
||||||
|
state.activeTeam = null
|
||||||
state.favoritesOnly = false
|
state.favoritesOnly = false
|
||||||
state.groupFilter = null
|
state.groupFilter = null
|
||||||
}
|
}
|
||||||
@@ -145,16 +165,35 @@ fun LiveScreen(state: AppState) {
|
|||||||
GroupItem(
|
GroupItem(
|
||||||
label = "★ Favoriten",
|
label = "★ Favoriten",
|
||||||
count = favorites.size,
|
count = favorites.size,
|
||||||
selected = state.favoritesOnly,
|
selected = state.favoritesOnly && state.activeTeam == null,
|
||||||
modifier = intoList.then(
|
modifier = intoList.then(
|
||||||
if (state.favoritesOnly) Modifier.focusRequester(railFocus) else Modifier
|
if (state.favoritesOnly) Modifier.focusRequester(railFocus) else Modifier
|
||||||
),
|
),
|
||||||
|
suppressAutoSelect = { state.restorePending },
|
||||||
) {
|
) {
|
||||||
|
state.activeTeam = null
|
||||||
state.favoritesOnly = true
|
state.favoritesOnly = true
|
||||||
state.groupFilter = null
|
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 {
|
item {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -168,11 +207,13 @@ fun LiveScreen(state: AppState) {
|
|||||||
GroupItem(
|
GroupItem(
|
||||||
label = group,
|
label = group,
|
||||||
count = remember(allChannels, group) { allChannels.count { it.group == group } },
|
count = remember(allChannels, group) { allChannels.count { it.group == group } },
|
||||||
selected = state.groupFilter == group,
|
selected = state.groupFilter == group && state.activeTeam == null,
|
||||||
modifier = intoList.then(
|
modifier = intoList.then(
|
||||||
if (state.groupFilter == group) Modifier.focusRequester(railFocus) else Modifier
|
if (state.groupFilter == group) Modifier.focusRequester(railFocus) else Modifier
|
||||||
),
|
),
|
||||||
|
suppressAutoSelect = { state.restorePending },
|
||||||
) {
|
) {
|
||||||
|
state.activeTeam = null
|
||||||
state.favoritesOnly = false
|
state.favoritesOnly = false
|
||||||
state.groupFilter = group
|
state.groupFilter = group
|
||||||
}
|
}
|
||||||
@@ -192,9 +233,13 @@ fun LiveScreen(state: AppState) {
|
|||||||
if (channels.isEmpty()) {
|
if (channels.isEmpty()) {
|
||||||
item {
|
item {
|
||||||
Text(
|
Text(
|
||||||
if (state.favoritesOnly)
|
when {
|
||||||
"Noch keine Favoriten — halte OK auf einem Sender gedrückt."
|
activeTeam != null ->
|
||||||
else "Diese Gruppe ist leer.",
|
"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,
|
color = CastarrColors.faint,
|
||||||
fontFamily = AppFont,
|
fontFamily = AppFont,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
@@ -216,6 +261,7 @@ fun LiveScreen(state: AppState) {
|
|||||||
else Modifier
|
else Modifier
|
||||||
),
|
),
|
||||||
nowNext = state.nowNext(channel),
|
nowNext = state.nowNext(channel),
|
||||||
|
highlight = teamHits[channel.url],
|
||||||
playing = state.currentChannel?.url == channel.url,
|
playing = state.currentChannel?.url == channel.url,
|
||||||
// All rows are favorites in the favorites view — the
|
// All rows are favorites in the favorites view — the
|
||||||
// star only carries meaning elsewhere.
|
// star only carries meaning elsewhere.
|
||||||
@@ -237,6 +283,8 @@ private fun GroupItem(
|
|||||||
count: Int,
|
count: Int,
|
||||||
selected: Boolean,
|
selected: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
leading: (@Composable () -> Unit)? = null,
|
||||||
|
suppressAutoSelect: () -> Boolean = { false },
|
||||||
onSelect: () -> Unit,
|
onSelect: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
@@ -245,7 +293,7 @@ private fun GroupItem(
|
|||||||
onClick = onSelect,
|
onClick = onSelect,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.onFocusChanged { if (it.isFocused) onSelect() },
|
.onFocusChanged { if (it.isFocused && !suppressAutoSelect()) onSelect() },
|
||||||
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)),
|
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)),
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
@@ -259,6 +307,10 @@ private fun GroupItem(
|
|||||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp),
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
|
if (leading != null) {
|
||||||
|
leading()
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
fontFamily = AppFont,
|
fontFamily = AppFont,
|
||||||
@@ -284,6 +336,7 @@ private fun ChannelRow(
|
|||||||
number: Int,
|
number: Int,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
nowNext: NowNext,
|
nowNext: NowNext,
|
||||||
|
highlight: dev.castarr.tv.data.Programme? = null,
|
||||||
playing: Boolean,
|
playing: Boolean,
|
||||||
favorite: Boolean,
|
favorite: Boolean,
|
||||||
epgStamp: Long,
|
epgStamp: Long,
|
||||||
@@ -344,7 +397,13 @@ private fun ChannelRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(24.dp))
|
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) {
|
if (playing) {
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
Box(
|
Box(
|
||||||
@@ -358,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. */
|
/** Bare channel logo (they ship transparent); initials as fallback. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun LogoTile(channel: Channel) {
|
private fun LogoTile(channel: Channel) {
|
||||||
@@ -392,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 =
|
private fun formatClock(millis: Long): String =
|
||||||
SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis))
|
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") {
|
SettingsCard("App") {
|
||||||
SettingRow(
|
SettingRow(
|
||||||
"Version ${BuildConfig.VERSION_NAME}",
|
"Version ${BuildConfig.VERSION_NAME}",
|
||||||
|
|||||||
@@ -18,12 +18,12 @@ object UpdateChecker {
|
|||||||
|
|
||||||
private const val TAG = "UpdateChecker"
|
private const val TAG = "UpdateChecker"
|
||||||
private const val LATEST =
|
private const val LATEST =
|
||||||
"https://git.beckm4nn.net/api/v1/repos/benjamin/castarr/releases/latest"
|
"https://git.beckm4nn.net/api/v1/repos/be-nj/castarr/releases/latest"
|
||||||
|
|
||||||
// Release assets need an API token to upload, so the APK is served from
|
// Release assets need an API token to upload, so the APK is served from
|
||||||
// an orphan branch instead — anonymously fetchable on a public repo.
|
// an orphan branch instead — anonymously fetchable on a public repo.
|
||||||
private const val APK_FALLBACK =
|
private const val APK_FALLBACK =
|
||||||
"https://git.beckm4nn.net/benjamin/castarr/raw/branch/apk/castarr.apk"
|
"https://git.beckm4nn.net/be-nj/castarr/raw/branch/apk/castarr.apk"
|
||||||
|
|
||||||
private var apkUrl: String = ""
|
private var apkUrl: String = ""
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ object UpdateChecker {
|
|||||||
return try {
|
return try {
|
||||||
connection.connectTimeout = 10_000
|
connection.connectTimeout = 10_000
|
||||||
connection.readTimeout = 15_000
|
connection.readTimeout = 15_000
|
||||||
connection.setRequestProperty("Accept", "application/vnd.github+json")
|
connection.setRequestProperty("Accept", "application/json")
|
||||||
connection.inputStream.bufferedReader().use { it.readText() }
|
connection.inputStream.bufferedReader().use { it.readText() }
|
||||||
} finally {
|
} finally {
|
||||||
connection.disconnect()
|
connection.disconnect()
|
||||||
|
|||||||
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