Compare commits
6 Commits
753ab3a3ab
...
v0.10.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8aa3a4da02 | ||
|
|
b7ee0523d1 | ||
|
|
a3105925bb | ||
|
|
7f1dff5661 | ||
|
|
869060c56f | ||
|
|
7ad7f5172e |
56
.github/workflows/build.yml
vendored
56
.github/workflows/build.yml
vendored
@@ -4,24 +4,56 @@ on:
|
|||||||
push:
|
push:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# No marketplace actions: this Gitea runner resolves bare action names
|
||||||
|
# against the local instance, and even with full URLs it cannot resolve
|
||||||
|
# annotated tags ("unsupported object type"). Everything below is plain
|
||||||
|
# shell, which also keeps the job readable.
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||||
|
CMDLINE_TOOLS: https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
git init -q .
|
||||||
|
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||||
|
git fetch -q --depth 1 origin "${GITHUB_SHA}"
|
||||||
|
git checkout -q FETCH_HEAD
|
||||||
|
|
||||||
- uses: actions/setup-java@v4
|
- name: Install JDK
|
||||||
with:
|
run: |
|
||||||
distribution: temurin
|
apt-get update -qq
|
||||||
java-version: "17"
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq openjdk-17-jdk-headless > /dev/null
|
||||||
|
java -version
|
||||||
|
|
||||||
- uses: gradle/actions/setup-gradle@v4
|
- name: Install Android SDK
|
||||||
|
run: |
|
||||||
|
mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools"
|
||||||
|
curl -sSL -o /tmp/tools.zip "$CMDLINE_TOOLS"
|
||||||
|
unzip -q /tmp/tools.zip -d "$ANDROID_SDK_ROOT/cmdline-tools"
|
||||||
|
mv "$ANDROID_SDK_ROOT/cmdline-tools/cmdline-tools" "$ANDROID_SDK_ROOT/cmdline-tools/latest"
|
||||||
|
# Accept licences by writing the hashes: piping "yes" into
|
||||||
|
# sdkmanager dies of SIGPIPE (exit 141) under pipefail.
|
||||||
|
mkdir -p "$ANDROID_SDK_ROOT/licenses"
|
||||||
|
echo "24333f8a63b6825ea9c5514f83c2829b004d1fee" > "$ANDROID_SDK_ROOT/licenses/android-sdk-license"
|
||||||
|
echo "84831b9409646a918e30573bab4c9c91346d8abd" > "$ANDROID_SDK_ROOT/licenses/android-sdk-preview-license"
|
||||||
|
"$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" \
|
||||||
|
"platforms;android-35" "build-tools;35.0.0" "platform-tools" > /dev/null
|
||||||
|
echo "sdk.dir=$ANDROID_SDK_ROOT" > local.properties
|
||||||
|
|
||||||
|
- name: Fetch club crests
|
||||||
|
run: python3 tools/fetch-crests.py || true
|
||||||
|
|
||||||
|
- name: Unit tests
|
||||||
|
run: ./gradlew testDebugUnitTest --no-daemon --stacktrace
|
||||||
|
|
||||||
- name: Build debug APK
|
- name: Build debug APK
|
||||||
run: ./gradlew assembleDebug --stacktrace
|
run: ./gradlew assembleDebug --no-daemon --stacktrace
|
||||||
|
|
||||||
- name: Upload APK
|
- name: Summary
|
||||||
uses: actions/upload-artifact@v4
|
if: always()
|
||||||
with:
|
run: |
|
||||||
name: castarr-debug-apk
|
echo "APK:"; ls -la app/build/outputs/apk/debug/ 2>/dev/null || echo " (kein Build)"
|
||||||
path: app/build/outputs/apk/debug/app-debug.apk
|
echo "Tests:"; ls tests/runs/junit/ 2>/dev/null || echo " (keine Reports)"
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ local.properties
|
|||||||
*.iml
|
*.iml
|
||||||
.kotlin/
|
.kotlin/
|
||||||
tests/runs/
|
tests/runs/
|
||||||
|
app/src/main/assets/crests/
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ android {
|
|||||||
applicationId = "dev.castarr.tv"
|
applicationId = "dev.castarr.tv"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 25
|
versionCode = 31
|
||||||
versionName = "0.9.2"
|
versionName = "0.10.5"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -26,6 +27,7 @@ class AppState(
|
|||||||
val dispatcharr: DispatcharrRepository,
|
val dispatcharr: DispatcharrRepository,
|
||||||
) {
|
) {
|
||||||
private val prefs = context.getSharedPreferences("app", Context.MODE_PRIVATE)
|
private val prefs = context.getSharedPreferences("app", Context.MODE_PRIVATE)
|
||||||
|
val crests = dev.castarr.tv.data.Crests(context)
|
||||||
|
|
||||||
var screen by mutableStateOf(Screen.LIVE)
|
var screen by mutableStateOf(Screen.LIVE)
|
||||||
var playerVisible by mutableStateOf(false)
|
var playerVisible by mutableStateOf(false)
|
||||||
@@ -38,6 +40,37 @@ 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 dev.castarr.tv.data.TeamFilters.defaultKeysFor(auth.username).toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns false when the cap of [TeamFilters.MAX_ACTIVE] is reached. */
|
||||||
|
fun toggleTeam(key: String): Boolean {
|
||||||
|
if (key !in enabledTeams &&
|
||||||
|
enabledTeams.size >= dev.castarr.tv.data.TeamFilters.MAX_ACTIVE
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
enabledTeams =
|
||||||
|
if (key in enabledTeams) enabledTeams - key else enabledTeams + key
|
||||||
|
prefs.edit().putStringSet("teams", enabledTeams).apply()
|
||||||
|
if (activeTeam == key && key !in enabledTeams) activeTeam = null
|
||||||
|
return true
|
||||||
|
}
|
||||||
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
|
||||||
@@ -132,6 +165,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()
|
||||||
@@ -168,6 +224,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
|
||||||
|
|||||||
@@ -50,7 +50,14 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
|||||||
server.startServer()
|
server.startServer()
|
||||||
state.remoteAvailable = server.running
|
state.remoteAvailable = server.running
|
||||||
setContent { CastarrApp(state) }
|
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() {
|
private fun onPlaybackChanged() {
|
||||||
@@ -325,5 +332,6 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
|||||||
const val TICK_INTERVAL_MS = 2_000L
|
const val TICK_INTERVAL_MS = 2_000L
|
||||||
const val SEEK_STEP_SECONDS = 10L
|
const val SEEK_STEP_SECONDS = 10L
|
||||||
const val DIGIT_COMMIT_MS = 1_800L
|
const val DIGIT_COMMIT_MS = 1_800L
|
||||||
|
const val UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
63
app/src/main/java/dev/castarr/tv/data/Crests.kt
Normal file
63
app/src/main/java/dev/castarr/tv/data/Crests.kt
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
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, fetched once and then reused. */
|
||||||
|
suspend fun urlFor(article: String): String? = withContext(Dispatchers.IO) {
|
||||||
|
prefs.getString(article, null)?.takeIf { it.isNotEmpty() }?.let {
|
||||||
|
return@withContext it
|
||||||
|
}
|
||||||
|
val resolved = runCatching { fetch(article) }
|
||||||
|
.onFailure { Log.w(TAG, "crest lookup failed for $article: $it") }
|
||||||
|
.getOrNull()
|
||||||
|
if (resolved != null) {
|
||||||
|
Log.i(TAG, "crest for $article: $resolved")
|
||||||
|
prefs.edit().putString(article, resolved).apply()
|
||||||
|
} else {
|
||||||
|
// Deliberately not cached: a single failed lookup (no network
|
||||||
|
// yet at start-up) must not hide the crest forever.
|
||||||
|
Log.w(TAG, "no crest for $article")
|
||||||
|
}
|
||||||
|
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() }
|
||||||
|
// Drop the analytics query the API appends.
|
||||||
|
?.substringBefore("?")
|
||||||
|
} 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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
143
app/src/main/java/dev/castarr/tv/data/TeamFilters.kt
Normal file
143
app/src/main/java/dev/castarr/tv/data/TeamFilters.kt
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
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.
|
||||||
|
* 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,
|
||||||
|
/** Short caption next to the crest in the rail. */
|
||||||
|
val label: String,
|
||||||
|
/** Full club name, shown in the settings and in messages. */
|
||||||
|
val fullName: String,
|
||||||
|
/** Lowercase needles matched against programme titles. */
|
||||||
|
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()
|
||||||
|
return needles.any { haystack.contains(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object TeamFilters {
|
||||||
|
|
||||||
|
/** Window scanned ahead of now. */
|
||||||
|
const val WINDOW_MS = 3 * 60 * 60 * 1000L
|
||||||
|
|
||||||
|
/** At most this many club groups sit in the rail at once. */
|
||||||
|
const val MAX_ACTIVE = 3
|
||||||
|
|
||||||
|
private const val WHITE = 0xFFF2F3F5
|
||||||
|
private const val BLACK = 0xFF15171B
|
||||||
|
|
||||||
|
private fun club(
|
||||||
|
key: String,
|
||||||
|
label: String,
|
||||||
|
fullName: String,
|
||||||
|
needles: List<String>,
|
||||||
|
primary: Long,
|
||||||
|
secondary: Long,
|
||||||
|
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(
|
||||||
|
// --- Bundesliga ---
|
||||||
|
club("bayern", "FCB", "FC Bayern München", listOf("bayern münchen", "fc bayern"), 0xFFDC052D, WHITE),
|
||||||
|
club("bvb", "BVB", "Borussia Dortmund", listOf("borussia dortmund", "bvb"), 0xFFFDE100, BLACK),
|
||||||
|
club("leipzig", "RBL", "RB Leipzig", listOf("rb leipzig"), 0xFFDD0741, WHITE),
|
||||||
|
club("leverkusen", "B04", "Bayer 04 Leverkusen", listOf("leverkusen"), 0xFFE32219, BLACK),
|
||||||
|
club("frankfurt", "SGE", "Eintracht Frankfurt", listOf("eintracht frankfurt"), 0xFF1A1A1A, 0xFFE1000F),
|
||||||
|
club("stuttgart", "1893", "VfB Stuttgart", listOf("vfb stuttgart"), 0xFFE32219, WHITE),
|
||||||
|
club("gladbach", "BMG", "Borussia Mönchengladbach", listOf("mönchengladbach", "gladbach"), 0xFF1A1A1A, 0xFF00A94F),
|
||||||
|
club("wolfsburg", "WOB", "VfL Wolfsburg", listOf("wolfsburg"), 0xFF65B32E, WHITE),
|
||||||
|
club("bremen", "SVW", "SV Werder Bremen", listOf("werder bremen", "werder"), 0xFF1D9053, WHITE),
|
||||||
|
club("freiburg", "SCF", "SC Freiburg", listOf("sc freiburg", "freiburg"), 0xFFE2001A, WHITE),
|
||||||
|
club("hoffenheim", "TSG", "TSG Hoffenheim", listOf("hoffenheim"), 0xFF1C63B7, WHITE),
|
||||||
|
club("mainz", "M05", "1. FSV Mainz 05", listOf("mainz 05", "mainz"), 0xFFE1000F, WHITE),
|
||||||
|
club("augsburg", "FCA", "FC Augsburg", listOf("augsburg"), 0xFF00693F, 0xFFE1000F),
|
||||||
|
club("union", "FCU", "1. FC Union Berlin", listOf("union berlin"), 0xFFE1000F, 0xFFFDE100),
|
||||||
|
club("koeln", "EFC", "1. FC Köln", listOf("1. fc köln", "fc köln"), 0xFFE1000F, WHITE),
|
||||||
|
club("hsv", "HSV", "Hamburger SV", listOf("hamburger sv", "hsv"), 0xFF0E5EA6, BLACK),
|
||||||
|
club("heidenheim", "FCH1", "1. FC Heidenheim", listOf("heidenheim"), 0xFFE1000F, 0xFF1656A4),
|
||||||
|
club("st-pauli", "FCSP", "FC St. Pauli", listOf("st. pauli", "st pauli"), 0xFF6B4423, WHITE),
|
||||||
|
// --- 2. Bundesliga ---
|
||||||
|
club("schalke", "S04", "FC Schalke 04", listOf("schalke"), 0xFF004D9D, WHITE),
|
||||||
|
club("hertha", "BSC", "Hertha BSC", listOf("hertha"), 0xFF004D9D, WHITE),
|
||||||
|
club("duesseldorf", "F95", "Fortuna Düsseldorf", listOf("fortuna düsseldorf"), 0xFFE1000F, WHITE),
|
||||||
|
club("nuernberg", "FCN", "1. FC Nürnberg", listOf("nürnberg"), 0xFF8B1A1A, WHITE),
|
||||||
|
club("kaiserslautern", "FCK", "1. FC Kaiserslautern", listOf("kaiserslautern"), 0xFFE1000F, WHITE),
|
||||||
|
club("karlsruhe", "KSC", "Karlsruher SC", listOf("karlsruher sc", "ksc"), 0xFF0055A5, WHITE),
|
||||||
|
club("hannover", "H96", "Hannover 96", listOf("hannover 96"), 0xFF00A94F, WHITE),
|
||||||
|
club("paderborn", "SCP", "SC Paderborn 07", listOf("paderborn"), 0xFF004D9D, WHITE),
|
||||||
|
club("magdeburg", "FCM", "1. FC Magdeburg", listOf("magdeburg"), 0xFF0B7A3E, WHITE),
|
||||||
|
club("elversberg", "SVE", "SV Elversberg", listOf("elversberg"), 0xFF1A1A1A, 0xFFE1000F),
|
||||||
|
club("darmstadt", "SV98", "SV Darmstadt 98", listOf("darmstadt"), 0xFF004E9E, WHITE),
|
||||||
|
club("braunschweig", "BTSV", "Eintracht Braunschweig", listOf("braunschweig"), 0xFFFDE100, 0xFF004E9E),
|
||||||
|
club("bochum", "BOC", "VfL Bochum", listOf("bochum"), 0xFF005CA9, WHITE),
|
||||||
|
club("muenster", "SCPM", "Preußen Münster", listOf("preußen münster", "münster"), 0xFF00703C, WHITE),
|
||||||
|
club("fuerth", "SGF", "SpVgg Greuther Fürth", listOf("greuther fürth", "fürth"), 0xFF00A94F, WHITE),
|
||||||
|
club("holstein", "KSV", "Holstein Kiel", listOf("holstein kiel"), 0xFF004E9E, 0xFFE1000F),
|
||||||
|
club("dresden", "SGD", "Dynamo Dresden", listOf("dynamo dresden"), 0xFFFDE100, BLACK),
|
||||||
|
club("bielefeld", "DSC", "Arminia Bielefeld", listOf("arminia bielefeld", "bielefeld"), 0xFF00539F, WHITE),
|
||||||
|
// --- 3. Liga ---
|
||||||
|
club("hansa", "FCH", "FC Hansa Rostock", listOf("hansa"), 0xFF0B4EA2, WHITE),
|
||||||
|
club("saarbruecken", "FCS", "1. FC Saarbrücken", listOf("saarbrücken"), 0xFF0B4EA2, BLACK),
|
||||||
|
club("aue", "FCE", "Erzgebirge Aue", listOf("erzgebirge aue"), 0xFF7C0A02, WHITE),
|
||||||
|
club("cottbus", "FCEC", "Energie Cottbus", listOf("energie cottbus", "cottbus"), 0xFFE1000F, WHITE),
|
||||||
|
club("essen", "RWE", "Rot-Weiss Essen", listOf("rot-weiss essen", "rot weiss essen"), 0xFFE1000F, WHITE),
|
||||||
|
club("duisburg", "MSV", "MSV Duisburg", listOf("msv duisburg", "duisburg"), 0xFF004E9E, WHITE),
|
||||||
|
club("mannheim", "SVWM", "SV Waldhof Mannheim", listOf("waldhof mannheim", "waldhof"), 0xFF0B4EA2, WHITE),
|
||||||
|
club("wehen", "SVWW", "SV Wehen Wiesbaden", listOf("wehen wiesbaden", "wehen"), 0xFFE1000F, BLACK),
|
||||||
|
club("ulm", "SSV", "SSV Ulm 1846", listOf("ssv ulm"), 0xFFE1000F, WHITE, article = "SSV Ulm 1846 Fußball"),
|
||||||
|
club("regensburg", "SSVJ", "SSV Jahn Regensburg", listOf("jahn regensburg", "regensburg"), 0xFFE1000F, WHITE),
|
||||||
|
club("verl", "SCV", "SC Verl", listOf("sc verl"), 0xFF00703C, WHITE),
|
||||||
|
club("viktoria", "VKÖ", "Viktoria Köln", listOf("viktoria köln"), 0xFFE1000F, WHITE),
|
||||||
|
club("havelse", "TSVH", "TSV Havelse", listOf("havelse"), 0xFF004E9E, WHITE),
|
||||||
|
club("schweinfurt", "FC05", "1. FC Schweinfurt 05", listOf("schweinfurt"), 0xFF00A94F, WHITE),
|
||||||
|
club("osnabrueck", "VfLO", "VfL Osnabrück", listOf("osnabrück"), 0xFF6A2C8F, WHITE),
|
||||||
|
club("aachen", "ALE", "Alemannia Aachen", listOf("alemannia aachen", "aachen"), 0xFFFDE100, BLACK),
|
||||||
|
club("ingolstadt", "FCI", "FC Ingolstadt 04", listOf("ingolstadt"), 0xFFE1000F, BLACK),
|
||||||
|
club("wuppertal", "WSV", "Wuppertaler SV", listOf("wuppertaler sv"), 0xFFE1000F, 0xFF0B4EA2),
|
||||||
|
club("stuttgart-ii", "VfB2", "VfB Stuttgart II", listOf("vfb stuttgart ii"), 0xFFE32219, BLACK),
|
||||||
|
club("hoffenheim-ii", "TSG2", "TSG Hoffenheim II", listOf("hoffenheim ii"), 0xFF1C63B7, BLACK, article = "TSG 1899 Hoffenheim"),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Clubs switched on for a viewer before they touch the settings. */
|
||||||
|
private val defaultForUser = mapOf(
|
||||||
|
"benjamin" to listOf("hansa"),
|
||||||
|
"tobiasb" to listOf("stuttgart"),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 defaultKeysFor(username: String): List<String> =
|
||||||
|
defaultForUser[username.trim().lowercase()].orEmpty()
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
|||||||
@@ -132,6 +132,10 @@ private fun TopBar(state: AppState) {
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.width(18.dp))
|
Spacer(Modifier.width(18.dp))
|
||||||
}
|
}
|
||||||
|
state.updateAvailable?.let { version ->
|
||||||
|
UpdateChip(version) { state.screen = AppState.Screen.SETTINGS }
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
}
|
||||||
GearButton {
|
GearButton {
|
||||||
state.screen = if (state.screen == AppState.Screen.LIVE) AppState.Screen.SETTINGS
|
state.screen = if (state.screen == AppState.Screen.LIVE) AppState.Screen.SETTINGS
|
||||||
else AppState.Screen.LIVE
|
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). */
|
/** Round settings button with a drawn gear (glyphs render as emoji). */
|
||||||
@Composable
|
@Composable
|
||||||
private fun GearButton(onClick: () -> Unit) {
|
private fun GearButton(onClick: () -> Unit) {
|
||||||
|
|||||||
@@ -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,9 +30,16 @@ 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.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -71,7 +79,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
|
||||||
@@ -114,11 +134,18 @@ fun LiveScreen(state: AppState) {
|
|||||||
state.restorePending = false
|
state.restorePending = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val intoList = if (channels.isEmpty()) Modifier
|
// A FocusRequester that is not currently attached throws when used, and
|
||||||
else Modifier.focusProperties { right = listFocus }
|
// in a LazyColumn the selected row may well be scrolled out of
|
||||||
|
// composition — so jumps are attempted, not declared.
|
||||||
|
val intoList = if (channels.isEmpty()) Modifier else Modifier.onPreviewKeyEvent { event ->
|
||||||
|
event.type == KeyEventType.KeyDown && event.key == Key.DirectionRight &&
|
||||||
|
runCatching { listFocus.requestFocus() }.isSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
val railState = rememberLazyListState()
|
||||||
Row(Modifier.fillMaxSize()) {
|
Row(Modifier.fillMaxSize()) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
|
state = railState,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(264.dp)
|
.width(264.dp)
|
||||||
.fillMaxHeight(),
|
.fillMaxHeight(),
|
||||||
@@ -128,7 +155,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,
|
||||||
@@ -138,6 +166,7 @@ fun LiveScreen(state: AppState) {
|
|||||||
),
|
),
|
||||||
suppressAutoSelect = { state.restorePending },
|
suppressAutoSelect = { state.restorePending },
|
||||||
) {
|
) {
|
||||||
|
state.activeTeam = null
|
||||||
state.favoritesOnly = false
|
state.favoritesOnly = false
|
||||||
state.groupFilter = null
|
state.groupFilter = null
|
||||||
}
|
}
|
||||||
@@ -147,17 +176,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 },
|
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, state) },
|
||||||
|
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
|
||||||
@@ -171,12 +218,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 },
|
suppressAutoSelect = { state.restorePending },
|
||||||
) {
|
) {
|
||||||
|
state.activeTeam = null
|
||||||
state.favoritesOnly = false
|
state.favoritesOnly = false
|
||||||
state.groupFilter = group
|
state.groupFilter = group
|
||||||
}
|
}
|
||||||
@@ -196,9 +244,13 @@ fun LiveScreen(state: AppState) {
|
|||||||
if (channels.isEmpty()) {
|
if (channels.isEmpty()) {
|
||||||
item {
|
item {
|
||||||
Text(
|
Text(
|
||||||
if (state.favoritesOnly)
|
when {
|
||||||
|
activeTeam != null ->
|
||||||
|
"In den nächsten 3 Stunden läuft nichts mit ${activeTeam.fullName}."
|
||||||
|
state.favoritesOnly ->
|
||||||
"Noch keine Favoriten — halte OK auf einem Sender gedrückt."
|
"Noch keine Favoriten — halte OK auf einem Sender gedrückt."
|
||||||
else "Diese Gruppe ist leer.",
|
else -> "Diese Gruppe ist leer."
|
||||||
|
},
|
||||||
color = CastarrColors.faint,
|
color = CastarrColors.faint,
|
||||||
fontFamily = AppFont,
|
fontFamily = AppFont,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
@@ -213,13 +265,18 @@ fun LiveScreen(state: AppState) {
|
|||||||
// matching the remote's number pad in every view.
|
// matching the remote's number pad in every view.
|
||||||
number = allChannels.indexOf(channel) + 1,
|
number = allChannels.indexOf(channel) + 1,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.focusProperties { left = railFocus }
|
.onPreviewKeyEvent { event ->
|
||||||
|
event.type == KeyEventType.KeyDown &&
|
||||||
|
event.key == Key.DirectionLeft &&
|
||||||
|
runCatching { railFocus.requestFocus() }.isSuccess
|
||||||
|
}
|
||||||
.then(if (listIndex == 0) Modifier.focusRequester(listFocus) else Modifier)
|
.then(if (listIndex == 0) Modifier.focusRequester(listFocus) else Modifier)
|
||||||
.then(
|
.then(
|
||||||
if (listIndex == restoreIndex) Modifier.focusRequester(restoreFocus)
|
if (listIndex == restoreIndex) Modifier.focusRequester(restoreFocus)
|
||||||
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.
|
||||||
@@ -241,6 +298,7 @@ private fun GroupItem(
|
|||||||
count: Int,
|
count: Int,
|
||||||
selected: Boolean,
|
selected: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
leading: (@Composable () -> Unit)? = null,
|
||||||
suppressAutoSelect: () -> Boolean = { false },
|
suppressAutoSelect: () -> Boolean = { false },
|
||||||
onSelect: () -> Unit,
|
onSelect: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -264,6 +322,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,
|
||||||
@@ -289,6 +351,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,
|
||||||
@@ -349,7 +412,13 @@ private fun ChannelRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(24.dp))
|
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))
|
EpgCell(nowNext, Modifier.weight(1f))
|
||||||
|
}
|
||||||
if (playing) {
|
if (playing) {
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
Box(
|
Box(
|
||||||
@@ -363,6 +432,50 @@ private fun ChannelRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The club badge. The images ship in the APK (fetched at build time by
|
||||||
|
* tools/fetch-crests.py, never committed), so nothing has to load over the
|
||||||
|
* network; the coloured shield stands in if one is ever missing.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun Crest(team: dev.castarr.tv.data.TeamFilter, state: AppState) {
|
||||||
|
Box(Modifier.size(20.dp), contentAlignment = Alignment.Center) {
|
||||||
|
SubcomposeAsyncImage(
|
||||||
|
model = "file:///android_asset/crests/${team.key}.png",
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
loading = { ShieldFallback(team) },
|
||||||
|
error = { 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
|
||||||
|
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) {
|
||||||
@@ -397,6 +510,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))
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import androidx.compose.foundation.Canvas
|
|||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -14,9 +16,13 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
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.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -80,7 +86,14 @@ fun SettingsScreen(state: AppState) {
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(horizontal = 40.dp, vertical = 12.dp)
|
.padding(horizontal = 40.dp, vertical = 12.dp)
|
||||||
) {
|
) {
|
||||||
Column(Modifier.weight(1.25f), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
// The cards outgrew one screen once club menus arrived; focus
|
||||||
|
// movement scrolls this column along.
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.weight(1.25f)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
SettingsCard("Konto") {
|
SettingsCard("Konto") {
|
||||||
if (state.auth.isLoggedIn) {
|
if (state.auth.isLoggedIn) {
|
||||||
Row(
|
Row(
|
||||||
@@ -163,6 +176,41 @@ fun SettingsScreen(state: AppState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsCard("Vereinsmenüs") {
|
||||||
|
Text(
|
||||||
|
"Eigene Gruppe mit allen Sendern, auf denen der Verein in den " +
|
||||||
|
"nächsten 3 Stunden läuft. Bis zu " +
|
||||||
|
"${dev.castarr.tv.data.TeamFilters.MAX_ACTIVE} Vereine.",
|
||||||
|
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 11.sp,
|
||||||
|
modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 6.dp),
|
||||||
|
)
|
||||||
|
val active = dev.castarr.tv.data.TeamFilters.all
|
||||||
|
.filter { it.key in state.enabledTeams }
|
||||||
|
active.forEach { club ->
|
||||||
|
SettingRow(
|
||||||
|
club.fullName,
|
||||||
|
subtitle = "Erscheint als ${club.label} in der Senderliste",
|
||||||
|
trailing = { TogglePill(on = true) },
|
||||||
|
) { state.toggleTeam(club.key) }
|
||||||
|
}
|
||||||
|
if (active.size < dev.castarr.tv.data.TeamFilters.MAX_ACTIVE) {
|
||||||
|
SettingRow(
|
||||||
|
"Verein hinzufügen",
|
||||||
|
subtitle = "1. bis 3. Liga",
|
||||||
|
) {
|
||||||
|
val choices = dev.castarr.tv.data.TeamFilters.all
|
||||||
|
.filter { it.key !in state.enabledTeams }
|
||||||
|
picker = Picker(
|
||||||
|
"Verein hinzufügen",
|
||||||
|
choices.map { it.fullName },
|
||||||
|
-1,
|
||||||
|
) { index ->
|
||||||
|
choices.getOrNull(index)?.let { state.toggleTeam(it.key) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard("App") {
|
SettingsCard("App") {
|
||||||
SettingRow(
|
SettingRow(
|
||||||
"Version ${BuildConfig.VERSION_NAME}",
|
"Version ${BuildConfig.VERSION_NAME}",
|
||||||
@@ -273,7 +321,13 @@ fun SettingsScreen(state: AppState) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun PickerDialog(picker: Picker, onClose: () -> Unit) {
|
private fun PickerDialog(picker: Picker, onClose: () -> Unit) {
|
||||||
val selectedFocus = remember { FocusRequester() }
|
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(
|
Dialog(
|
||||||
onDismissRequest = onClose,
|
onDismissRequest = onClose,
|
||||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||||
@@ -302,7 +356,11 @@ private fun PickerDialog(picker: Picker, onClose: () -> Unit) {
|
|||||||
letterSpacing = 2.sp,
|
letterSpacing = 2.sp,
|
||||||
modifier = Modifier.padding(start = 14.dp, bottom = 10.dp),
|
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
|
val selected = index == picker.selected
|
||||||
Surface(
|
Surface(
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -311,7 +369,11 @@ private fun PickerDialog(picker: Picker, onClose: () -> Unit) {
|
|||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.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),
|
shape = ClickableSurfaceDefaults.shape(rowShape),
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
@@ -355,6 +417,7 @@ private fun PickerDialog(picker: Picker, onClose: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Current value plus a small drawn caret, shown at a row's trailing edge. */
|
/** Current value plus a small drawn caret, shown at a row's trailing edge. */
|
||||||
@@ -385,6 +448,24 @@ private fun ValueWithCaret(value: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TogglePill(on: Boolean) {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SettingsCard(title: String, content: @Composable () -> Unit) {
|
private fun SettingsCard(title: String, content: @Composable () -> Unit) {
|
||||||
Column(
|
Column(
|
||||||
|
|||||||
61
tests/unit/TeamFiltersTest.kt
Normal file
61
tests/unit/TeamFiltersTest.kt
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package dev.castarr.tv.data
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class TeamFiltersTest {
|
||||||
|
|
||||||
|
private val hansa = TeamFilters.byKey("hansa")!!
|
||||||
|
private val vfb = TeamFilters.byKey("stuttgart")!!
|
||||||
|
|
||||||
|
@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(listOf("hansa"), TeamFilters.defaultKeysFor("benjamin"))
|
||||||
|
assertEquals(listOf("stuttgart"), TeamFilters.defaultKeysFor("TobiasB"))
|
||||||
|
assertTrue(TeamFilters.defaultKeysFor("someone-else").isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `club keys are unique and every club has needles`() {
|
||||||
|
val keys = TeamFilters.all.map { it.key }
|
||||||
|
assertEquals(keys.size, keys.toSet().size)
|
||||||
|
assertTrue(TeamFilters.all.all { it.needles.isNotEmpty() })
|
||||||
|
assertTrue(TeamFilters.all.all { c -> c.needles.all { it == it.lowercase() } })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `covers all three divisions`() {
|
||||||
|
assertTrue(TeamFilters.all.size > 50)
|
||||||
|
listOf("bayern", "schalke", "hansa").forEach {
|
||||||
|
assertTrue(it, TeamFilters.byKey(it) != null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
54
tools/fetch-crests.py
Normal file
54
tools/fetch-crests.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Downloads club crests into the APK's assets at build time.
|
||||||
|
|
||||||
|
The badges are trademarks: they are fetched into a generated, git-ignored
|
||||||
|
folder so the repository stays free of them while the app ships with them
|
||||||
|
and needs no network at runtime.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
OUT = sys.argv[1] if len(sys.argv) > 1 else "app/src/main/assets/crests"
|
||||||
|
SRC = "app/src/main/java/dev/castarr/tv/data/TeamFilters.kt"
|
||||||
|
SUMMARY = "https://de.wikipedia.org/api/rest_v1/page/summary/"
|
||||||
|
UA = {"User-Agent": "Castarr build script (private use)"}
|
||||||
|
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
kotlin = open(SRC, encoding="utf-8").read()
|
||||||
|
entries = re.findall(r'club\((.*?)\)\s*,\s*(?://.*)?$', kotlin, re.M | re.S)
|
||||||
|
clubs = []
|
||||||
|
for raw in re.findall(r'club\(\s*"([^"]+)",\s*"[^"]*",\s*"([^"]+)"[^\n]*', kotlin):
|
||||||
|
clubs.append(raw)
|
||||||
|
# an explicit article = "..." wins over the club name
|
||||||
|
overrides = dict(re.findall(r'club\(\s*"([^"]+)"[^\n]*article = "([^"]+)"', kotlin))
|
||||||
|
clubs = [(k, overrides.get(k, n)) for k, n in clubs]
|
||||||
|
|
||||||
|
fetched = skipped = failed = 0
|
||||||
|
for key, full_name in clubs:
|
||||||
|
target = os.path.join(OUT, f"{key}.png")
|
||||||
|
if os.path.exists(target) and os.path.getsize(target) > 0:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
url = SUMMARY + urllib.parse.quote(full_name)
|
||||||
|
with urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=20) as r:
|
||||||
|
thumb = json.load(r).get("thumbnail", {}).get("source")
|
||||||
|
if not thumb:
|
||||||
|
raise ValueError("no thumbnail")
|
||||||
|
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)
|
||||||
|
fetched += 1
|
||||||
|
except Exception as exc: # noqa: BLE001 - best effort, shield is the fallback
|
||||||
|
print(f" {key}: {exc}", file=sys.stderr)
|
||||||
|
failed += 1
|
||||||
|
# Wikipedia rate-limits bursts; this runs rarely and caches.
|
||||||
|
time.sleep(1.2)
|
||||||
|
|
||||||
|
print(f"crests: {fetched} geladen, {skipped} vorhanden, {failed} fehlgeschlagen")
|
||||||
Reference in New Issue
Block a user