5 Commits

Author SHA1 Message Date
be-nj
f1a6600e42 Emulator helper so tests start in seconds, not minutes
All checks were successful
Build TV app / build (push) Successful in 2m54s
The Google TV emulator was launched by hand with -no-snapshot, which forces
a cold boot every time. tests/helpers/emulator.sh boots from the AVD's
default_boot snapshot (~7s) and refreshes that snapshot on stop, so the next
run stays fast. It also wraps the two things a test run needs afterwards:
installing an APK and pulling a screenshot into tests/runs/.

CONTEXT.md's example dialogue still described Xtream credentials entered
through the Remote, which ADR-0005 and ADR-0007 replaced with device flow and
phone-side onboarding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 13:29:33 +02:00
be-nj
637679fd90 Release 0.11.5
All checks were successful
Build TV app / build (push) Successful in 3m13s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 12:07:24 +02:00
be-nj
cbdac7ddb2 Make the club highlight visible and the updater honest
Three fixes.

The pulse ring never showed. It was drawn on a 30dp canvas inside a box
sized to the 20dp crest, so the parent's constraints shrank the canvas
to 20dp and the crest image covered what was left. The box is now wider
than the crest and the ring has room.

"läuft" was drawn straight onto the row, and a focused row is filled
with the accent colour — red on turquoise. The signal now sits on a
dark chip, which reads on an unfocused row, a selected one and a
focused one alike.

The updater handed the installer whatever had been written so far. It
now checks the response code, downloads to a .part file, compares the
byte count against Content-Length before renaming, and refuses a second
concurrent attempt instead of letting two writers share one file. A
half-written APK leaves the installer spinning with nothing to report,
which is what a hang looks like from the sofa.

Crests are also resampled to 128px with a proper filter when fetched;
Android's 8x downscale from the 330px originals left ragged edges.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 12:04:58 +02:00
be-nj
8bf1ed3128 Give Heidenheim its own short label
Two clubs mapped to FCH; Hansa Rostock keeps it, Heidenheim becomes
HDH, which is what its own supporters use anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 11:49:37 +02:00
be-nj
3b6a8d7d0a Show crests and league sections when picking a club
All checks were successful
Build TV app / build (push) Successful in 2m53s
The club chooser was a flat list of 56 names in a generic picker
dialog, which is unreadable from a couch. It now has its own dialog
with a crest per row, the short label on the right, and section
headers per division. Enabled clubs in the settings card show their
crest too.

A search field would have been the obvious alternative, but text
entry on a remote is exactly what the Ten-Foot rule rules out, so
grouping plus crests carries the recognition instead.

Also adds tests/demo, a generator for neutral playlist and EPG data
so screenshots never carry real channel names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 11:42:18 +02:00
12 changed files with 597 additions and 133 deletions

2
.gitignore vendored
View File

@@ -6,3 +6,5 @@ local.properties
.kotlin/
tests/runs/
app/src/main/assets/crests/
tests/demo/playlist.m3u
tests/demo/epg.xml

View File

@@ -73,9 +73,10 @@ Channel, no full guide timeline.
## Example dialogue
> **Dev:** "Does the **Remote** need Backend credentials?"
> **Domain expert:** "No — the user enters the Xtream credentials of a
> **Quelle** once via the Remote, the **TV-App** stores them and is the only
> one talking to the **Backend**."
> **Domain expert:** "No — the Remote is where **Onboarding** happens, not
> where credentials live: the user types the server URL there and finishes the
> IdP login on the phone. The **TV-App** holds the tokens and is the only one
> talking to the **Backend**. A generic M3U **Quelle** needs no login at all."
## Flagged ambiguities

View File

@@ -79,4 +79,22 @@ sind signiert; über einem Debug-Build muss einmal deinstalliert werden
(Signaturwechsel). Updates bezieht die App über die Release-API dieses
Repos, die APK selbst liegt auf dem Branch `apk`.
### Tests
```
./gradlew test # Unit-Tests, Reports unter tests/runs/
tests/helpers/emulator.sh start # headloser Google-TV-Emulator
tests/helpers/emulator.sh install app/build/outputs/apk/debug/app-debug.apk
tests/helpers/emulator.sh shot name # Screenshot nach tests/runs/screenshots/
tests/helpers/emulator.sh stop
```
Der Emulator (AVD `castarr-googletv`, API 34) startet aus einem Snapshot und
ist in wenigen Sekunden oben; `stop` schreibt den Snapshot vorher neu. Ein
Kaltstart entsteht nur, wenn der Snapshot fehlt.
Demo-Daten für einen Lauf ohne echtes Backend: `tests/demo/make-demo-data.py`
erzeugt Playlist und EPG, die ein lokaler HTTP-Server als generische Quelle
ausliefert.
Architektur-Notizen: [CONTEXT.md](CONTEXT.md) und [docs/adr/](docs/adr/).

View File

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

View File

@@ -179,19 +179,25 @@ class AppState(
/** How urgent a club menu is right now — drives the rail highlight. */
enum class TeamUrgency { NONE, SOON, LIVE }
/** The urgent state plus the kick-off it refers to, for the rail label. */
data class TeamSignal(val urgency: TeamUrgency, val kickOff: Long)
/**
* LIVE while a match is running, SOON within half an hour of kick-off —
* the window in which someone actually wants to be nudged.
*/
fun teamUrgency(matches: List<Pair<Channel, TeamHit>>): TeamUrgency {
fun teamSignal(matches: List<Pair<Channel, TeamHit>>): TeamSignal {
val now = System.currentTimeMillis()
var soon = false
var soonAt = 0L
matches.forEach { (_, hit) ->
val p = hit.programme
if (now in p.start until p.stop) return TeamUrgency.LIVE
if (p.start in now..(now + SOON_WINDOW_MS)) soon = true
if (now in p.start until p.stop) return TeamSignal(TeamUrgency.LIVE, p.start)
if (p.start in now..(now + SOON_WINDOW_MS) && (soonAt == 0L || p.start < soonAt)) {
soonAt = p.start
}
}
return if (soon) TeamUrgency.SOON else TeamUrgency.NONE
return if (soonAt > 0) TeamSignal(TeamUrgency.SOON, soonAt)
else TeamSignal(TeamUrgency.NONE, 0L)
}
/**

View File

@@ -20,6 +20,8 @@ data class TeamFilter(
val secondary: Color,
/** German Wikipedia article the crest is resolved from. */
val article: String,
/** 1, 2 or 3 — groups the picker by division. */
val league: Int,
) {
fun matches(title: String): Boolean {
val haystack = title.lowercase()
@@ -63,69 +65,70 @@ object TeamFilters {
primary: Long,
secondary: Long,
article: String = fullName,
) = TeamFilter(key, label, fullName, needles, Color(primary), Color(secondary), article)
league: Int = 1,
) = TeamFilter(key, label, fullName, needles, Color(primary), Color(secondary), article, league)
/** 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),
club("bayern", "FCB", "FC Bayern München", listOf("bayern münchen", "fc bayern"), 0xFFDC052D, WHITE, league = 1),
club("bvb", "BVB", "Borussia Dortmund", listOf("borussia dortmund", "bvb"), 0xFFFDE100, BLACK, league = 1),
club("leipzig", "RBL", "RB Leipzig", listOf("rb leipzig"), 0xFFDD0741, WHITE, league = 1),
club("leverkusen", "B04", "Bayer 04 Leverkusen", listOf("leverkusen"), 0xFFE32219, BLACK, league = 1),
club("frankfurt", "SGE", "Eintracht Frankfurt", listOf("eintracht frankfurt"), 0xFF1A1A1A, 0xFFE1000F, league = 1),
club("stuttgart", "1893", "VfB Stuttgart", listOf("vfb stuttgart"), 0xFFE32219, WHITE, league = 1),
club("gladbach", "BMG", "Borussia Mönchengladbach", listOf("mönchengladbach", "gladbach"), 0xFF1A1A1A, 0xFF00A94F, league = 1),
club("wolfsburg", "WOB", "VfL Wolfsburg", listOf("wolfsburg"), 0xFF65B32E, WHITE, league = 1),
club("bremen", "SVW", "SV Werder Bremen", listOf("werder bremen", "werder"), 0xFF1D9053, WHITE, league = 1),
club("freiburg", "SCF", "SC Freiburg", listOf("sc freiburg", "freiburg"), 0xFFE2001A, WHITE, league = 1),
club("hoffenheim", "TSG", "TSG Hoffenheim", listOf("hoffenheim"), 0xFF1C63B7, WHITE, league = 1),
club("mainz", "M05", "1. FSV Mainz 05", listOf("mainz 05", "mainz"), 0xFFE1000F, WHITE, league = 1),
club("augsburg", "FCA", "FC Augsburg", listOf("augsburg"), 0xFF00693F, 0xFFE1000F, league = 1),
club("union", "FCU", "1. FC Union Berlin", listOf("union berlin"), 0xFFE1000F, 0xFFFDE100, league = 1),
club("koeln", "EFC", "1. FC Köln", listOf("1. fc köln", "fc köln"), 0xFFE1000F, WHITE, league = 1),
club("hsv", "HSV", "Hamburger SV", listOf("hamburger sv", "hsv"), 0xFF0E5EA6, BLACK, league = 1),
club("heidenheim", "HDH", "1. FC Heidenheim", listOf("heidenheim"), 0xFFE1000F, 0xFF1656A4, league = 1),
club("st-pauli", "FCSP", "FC St. Pauli", listOf("st. pauli", "st pauli"), 0xFF6B4423, WHITE, league = 1),
// --- 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),
club("schalke", "S04", "FC Schalke 04", listOf("schalke"), 0xFF004D9D, WHITE, league = 2),
club("hertha", "BSC", "Hertha BSC", listOf("hertha"), 0xFF004D9D, WHITE, league = 2),
club("duesseldorf", "F95", "Fortuna Düsseldorf", listOf("fortuna düsseldorf"), 0xFFE1000F, WHITE, league = 2),
club("nuernberg", "FCN", "1. FC Nürnberg", listOf("nürnberg"), 0xFF8B1A1A, WHITE, league = 2),
club("kaiserslautern", "FCK", "1. FC Kaiserslautern", listOf("kaiserslautern"), 0xFFE1000F, WHITE, league = 2),
club("karlsruhe", "KSC", "Karlsruher SC", listOf("karlsruher sc", "ksc"), 0xFF0055A5, WHITE, league = 2),
club("hannover", "H96", "Hannover 96", listOf("hannover 96"), 0xFF00A94F, WHITE, league = 2),
club("paderborn", "SCP", "SC Paderborn 07", listOf("paderborn"), 0xFF004D9D, WHITE, league = 2),
club("magdeburg", "FCM", "1. FC Magdeburg", listOf("magdeburg"), 0xFF0B7A3E, WHITE, league = 2),
club("elversberg", "SVE", "SV Elversberg", listOf("elversberg"), 0xFF1A1A1A, 0xFFE1000F, league = 2),
club("darmstadt", "SV98", "SV Darmstadt 98", listOf("darmstadt"), 0xFF004E9E, WHITE, league = 2),
club("braunschweig", "BTSV", "Eintracht Braunschweig", listOf("braunschweig"), 0xFFFDE100, 0xFF004E9E, league = 2),
club("bochum", "BOC", "VfL Bochum", listOf("bochum"), 0xFF005CA9, WHITE, league = 2),
club("muenster", "SCPM", "Preußen Münster", listOf("preußen münster", "münster"), 0xFF00703C, WHITE, league = 2),
club("fuerth", "SGF", "SpVgg Greuther Fürth", listOf("greuther fürth", "fürth"), 0xFF00A94F, WHITE, league = 2),
club("holstein", "KSV", "Holstein Kiel", listOf("holstein kiel"), 0xFF004E9E, 0xFFE1000F, league = 2),
club("dresden", "SGD", "Dynamo Dresden", listOf("dynamo dresden"), 0xFFFDE100, BLACK, league = 2),
club("bielefeld", "DSC", "Arminia Bielefeld", listOf("arminia bielefeld", "bielefeld"), 0xFF00539F, WHITE, league = 2),
// --- 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"),
club("hansa", "FCH", "FC Hansa Rostock", listOf("hansa"), 0xFF0B4EA2, WHITE, league = 3),
club("saarbruecken", "FCS", "1. FC Saarbrücken", listOf("saarbrücken"), 0xFF0B4EA2, BLACK, league = 3),
club("aue", "FCE", "Erzgebirge Aue", listOf("erzgebirge aue"), 0xFF7C0A02, WHITE, league = 3),
club("cottbus", "FCEC", "Energie Cottbus", listOf("energie cottbus", "cottbus"), 0xFFE1000F, WHITE, league = 3),
club("essen", "RWE", "Rot-Weiss Essen", listOf("rot-weiss essen", "rot weiss essen"), 0xFFE1000F, WHITE, league = 3),
club("duisburg", "MSV", "MSV Duisburg", listOf("msv duisburg", "duisburg"), 0xFF004E9E, WHITE, league = 3),
club("mannheim", "SVWM", "SV Waldhof Mannheim", listOf("waldhof mannheim", "waldhof"), 0xFF0B4EA2, WHITE, league = 3),
club("wehen", "SVWW", "SV Wehen Wiesbaden", listOf("wehen wiesbaden", "wehen"), 0xFFE1000F, BLACK, league = 3),
club("ulm", "SSV", "SSV Ulm 1846", listOf("ssv ulm"), 0xFFE1000F, WHITE, article = "SSV Ulm 1846 Fußball", league = 3),
club("regensburg", "SSVJ", "SSV Jahn Regensburg", listOf("jahn regensburg", "regensburg"), 0xFFE1000F, WHITE, league = 3),
club("verl", "SCV", "SC Verl", listOf("sc verl"), 0xFF00703C, WHITE, league = 3),
club("viktoria", "VKÖ", "Viktoria Köln", listOf("viktoria köln"), 0xFFE1000F, WHITE, league = 3),
club("havelse", "TSVH", "TSV Havelse", listOf("havelse"), 0xFF004E9E, WHITE, league = 3),
club("schweinfurt", "FC05", "1. FC Schweinfurt 05", listOf("schweinfurt"), 0xFF00A94F, WHITE, league = 3),
club("osnabrueck", "VfLO", "VfL Osnabrück", listOf("osnabrück"), 0xFF6A2C8F, WHITE, league = 3),
club("aachen", "ALE", "Alemannia Aachen", listOf("alemannia aachen", "aachen"), 0xFFFDE100, BLACK, league = 3),
club("ingolstadt", "FCI", "FC Ingolstadt 04", listOf("ingolstadt"), 0xFFE1000F, BLACK, league = 3),
club("wuppertal", "WSV", "Wuppertaler SV", listOf("wuppertaler sv"), 0xFFE1000F, 0xFF0B4EA2, league = 3),
club("stuttgart-ii", "VfB2", "VfB Stuttgart II", listOf("vfb stuttgart ii"), 0xFFE32219, BLACK, league = 3),
club("hoffenheim-ii", "TSG2", "TSG Hoffenheim II", listOf("hoffenheim ii"), 0xFF1C63B7, BLACK, article = "TSG 1899 Hoffenheim", league = 3),
)
/** Clubs switched on for a viewer before they touch the settings. */
@@ -155,6 +158,14 @@ object TeamFilters {
fun byKey(key: String): TeamFilter? = all.firstOrNull { it.key == key }
/** Clubs grouped by division, in the order the picker shows them. */
fun byLeague(clubs: List<TeamFilter> = all): List<Pair<String, List<TeamFilter>>> =
listOf(
"1. Bundesliga" to clubs.filter { it.league == 1 },
"2. Bundesliga" to clubs.filter { it.league == 2 },
"3. Liga" to clubs.filter { it.league == 3 },
).filter { it.second.isNotEmpty() }
fun defaultKeysFor(username: String): List<String> =
defaultForUser[username.trim().lowercase()].orEmpty()
}

View File

@@ -21,6 +21,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.animation.core.animateFloat
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -32,6 +33,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
@@ -199,14 +201,15 @@ fun LiveScreen(state: AppState) {
}
items(teams, key = { it.key }) { club ->
val clubMatches = matchesByTeam[club.key].orEmpty()
val clubSignal = remember(clubMatches, urgencyTick) {
state.teamSignal(clubMatches)
}
GroupItem(
label = club.label,
count = clubMatches.size,
selected = state.activeTeam == club.key,
leading = { Crest(club, state) },
urgency = remember(clubMatches, urgencyTick) {
state.teamUrgency(clubMatches)
},
leading = { Crest(club, state, clubSignal.urgency) },
signal = clubSignal,
modifier = intoList.then(
if (state.activeTeam == club.key) Modifier.focusRequester(railFocus)
else Modifier
@@ -312,8 +315,8 @@ private fun GroupItem(
selected: Boolean,
modifier: Modifier = Modifier,
leading: (@Composable () -> Unit)? = null,
/** Replaces the count and adds a dot when a match is on or imminent. */
urgency: AppState.TeamUrgency = AppState.TeamUrgency.NONE,
/** Replaces the count when a match is on or about to start. */
signal: AppState.TeamSignal? = null,
suppressAutoSelect: () -> Boolean = { false },
onSelect: () -> Unit,
) {
@@ -351,31 +354,35 @@ private fun GroupItem(
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
when (urgency) {
AppState.TeamUrgency.LIVE, AppState.TeamUrgency.SOON -> {
val accent =
if (urgency == AppState.TeamUrgency.LIVE) CastarrColors.live
else CastarrColors.accent
Box(
Modifier
.size(6.dp)
.clip(CircleShape)
.background(accent)
)
Spacer(Modifier.width(6.dp))
// The signal lives in the value column instead of adding a
// badge — the count is worth less than "now" or "16:00" is.
val urgent = signal?.urgency ?: AppState.TeamUrgency.NONE
if (urgent == AppState.TeamUrgency.NONE) {
Text(
"$count",
fontFamily = AppFont,
fontSize = 12.sp,
)
} else {
// A focused row is filled with the accent colour, so signal
// colour straight on the row was red on turquoise. The chip
// gives it a dark ground that holds on every row state.
Box(
Modifier
.clip(RoundedCornerShape(999.dp))
.background(CastarrColors.bgDeep.copy(alpha = 0.92f))
.padding(horizontal = 8.dp, vertical = 3.dp)
) {
Text(
if (urgency == AppState.TeamUrgency.LIVE) "läuft" else "gleich",
color = accent,
if (urgent == AppState.TeamUrgency.LIVE) "läuft"
else formatClock(signal!!.kickOff),
color = if (urgent == AppState.TeamUrgency.LIVE) CastarrColors.live
else CastarrColors.accent,
fontFamily = AppFont,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
)
}
AppState.TeamUrgency.NONE -> Text(
"$count",
fontFamily = AppFont,
fontSize = 12.sp,
)
}
}
}
@@ -474,13 +481,47 @@ private fun ChannelRow(
* 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) {
private fun Crest(
team: dev.castarr.tv.data.TeamFilter,
state: AppState,
urgency: AppState.TeamUrgency = AppState.TeamUrgency.NONE,
) {
// The box is wider than the crest on purpose: the pulse ring is drawn
// around it, and a box sized to the crest would clip the ring away.
Box(Modifier.size(30.dp), contentAlignment = Alignment.Center) {
if (urgency != AppState.TeamUrgency.NONE) {
// A slow pulse is what actually catches the eye from the sofa;
// colour alone does not at that distance.
val transition = androidx.compose.animation.core.rememberInfiniteTransition(
label = "crest-pulse",
)
val phase by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = androidx.compose.animation.core.infiniteRepeatable(
androidx.compose.animation.core.tween(2000, easing = androidx.compose.animation.core.LinearEasing),
androidx.compose.animation.core.RepeatMode.Restart,
),
label = "phase",
)
val ringColor =
if (urgency == AppState.TeamUrgency.LIVE) CastarrColors.live
else CastarrColors.accent
Canvas(Modifier.fillMaxSize()) {
val grow = phase.coerceAtMost(0.75f) / 0.75f
drawCircle(
color = ringColor.copy(alpha = (1f - grow) * 0.85f),
radius = size.minDimension * (0.34f + grow * 0.15f),
style = Stroke(width = 2.dp.toPx()),
)
}
}
SubcomposeAsyncImage(
model = "file:///android_asset/crests/${team.key}.png",
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(),
filterQuality = androidx.compose.ui.graphics.FilterQuality.High,
modifier = Modifier.size(20.dp),
loading = { ShieldFallback(team) },
error = { ShieldFallback(team) },
)

View File

@@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
@@ -79,6 +80,7 @@ fun SettingsScreen(state: AppState) {
val profiles by state.dispatcharr.profiles.collectAsState()
var updateStatus by remember { mutableStateOf("") }
var picker by remember { mutableStateOf<Picker?>(null) }
var clubPicker by remember { mutableStateOf(false) }
// Bumped on reset so the QR code and the four-digit code redraw.
var pairingEpoch by remember { mutableStateOf(0) }
@@ -191,6 +193,7 @@ fun SettingsScreen(state: AppState) {
SettingRow(
club.fullName,
subtitle = "Erscheint als ${club.label} in der Senderliste · Entfernen",
leading = { ClubCrest(club, state) },
trailing = { RemoveIcon() },
) { state.toggleTeam(club.key) }
}
@@ -198,17 +201,7 @@ fun SettingsScreen(state: AppState) {
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) }
}
}
) { clubPicker = true }
}
}
@@ -320,6 +313,155 @@ fun SettingsScreen(state: AppState) {
picker?.let { current ->
PickerDialog(current) { picker = null }
}
if (clubPicker) {
ClubPickerDialog(
state = state,
onPick = { club -> state.toggleTeam(club.key); clubPicker = false },
onClose = { clubPicker = false },
)
}
}
/** Club crest from the bundled assets; initials while it loads. */
@Composable
private fun ClubCrest(team: dev.castarr.tv.data.TeamFilter, state: AppState, size: Int = 26) {
Box(Modifier.size(size.dp), contentAlignment = Alignment.Center) {
coil.compose.SubcomposeAsyncImage(
model = "file:///android_asset/crests/${team.key}.png",
contentDescription = null,
contentScale = androidx.compose.ui.layout.ContentScale.Fit,
modifier = Modifier.fillMaxSize(),
loading = { CrestInitials(team) },
error = { CrestInitials(team) },
)
}
}
@Composable
private fun CrestInitials(team: dev.castarr.tv.data.TeamFilter) {
Box(
Modifier
.fillMaxSize()
.clip(CircleShape)
.background(team.primary),
contentAlignment = Alignment.Center,
) {
Text(
team.label.take(3),
color = team.secondary,
fontFamily = AppFont,
fontSize = 8.sp,
fontWeight = FontWeight.Bold,
)
}
}
/**
* Club chooser: crests plus division headers, because a flat list of 56
* names is unreadable on a remote.
*/
@Composable
private fun ClubPickerDialog(
state: AppState,
onPick: (dev.castarr.tv.data.TeamFilter) -> Unit,
onClose: () -> Unit,
) {
val firstFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { runCatching { firstFocus.requestFocus() } }
val sections = remember(state.enabledTeams) {
dev.castarr.tv.data.TeamFilters.byLeague(
dev.castarr.tv.data.TeamFilters.all.filter { it.key !in state.enabledTeams }
)
}
val firstKey = sections.firstOrNull()?.second?.firstOrNull()?.key
Dialog(onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false)) {
Box(
Modifier
.fillMaxSize()
.background(CastarrColors.bgDeep.copy(alpha = 0.88f)),
contentAlignment = Alignment.Center,
) {
Column(
Modifier
.width(420.dp)
.clip(RoundedCornerShape(16.dp))
.background(CastarrColors.surface)
.padding(horizontal = 10.dp, vertical = 14.dp)
) {
Text(
"VEREIN HINZUFÜGEN",
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 2.sp,
modifier = Modifier.padding(start = 14.dp, bottom = 10.dp),
)
LazyColumn(Modifier.heightIn(max = 460.dp)) {
sections.forEach { (league, clubs) ->
item(key = "h-$league") {
Text(
league,
color = CastarrColors.accent,
fontFamily = AppFont,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 1.5.sp,
modifier = Modifier.padding(start = 14.dp, top = 12.dp, bottom = 6.dp),
)
}
items(clubs, key = { it.key }) { club ->
val focusMod =
if (club.key == firstKey) Modifier.focusRequester(firstFocus)
else Modifier
Surface(
onClick = { onPick(club) },
modifier = Modifier
.fillMaxWidth()
.then(focusMod),
shape = ClickableSurfaceDefaults.shape(rowShape),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
colors = ClickableSurfaceDefaults.colors(
containerColor = androidx.compose.ui.graphics.Color.Transparent,
contentColor = CastarrColors.fg,
focusedContainerColor = CastarrColors.surfaceFocused,
focusedContentColor = CastarrColors.fg,
),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
border = BorderStroke(2.dp, CastarrColors.accent),
shape = rowShape,
),
),
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ClubCrest(club, state, size = 24)
Spacer(Modifier.width(12.dp))
Text(
club.fullName,
fontFamily = AppFont,
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(
club.label,
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 12.sp,
)
}
}
}
}
}
}
}
}
}
/** TV-friendly dropdown replacement: fullscreen scrim, options centered. */
@@ -512,6 +654,7 @@ private fun SettingRow(
label: String,
subtitle: String? = null,
danger: Boolean = false,
leading: (@Composable () -> Unit)? = null,
trailing: (@Composable () -> Unit)? = null,
onClick: () -> Unit,
) {
@@ -537,6 +680,10 @@ private fun SettingRow(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (leading != null) {
leading()
Spacer(Modifier.width(12.dp))
}
Column(Modifier.weight(1f)) {
Text(
label,

View File

@@ -27,6 +27,9 @@ object UpdateChecker {
private var apkUrl: String = ""
/** Guards against a second click landing on the same download. */
private val busy = java.util.concurrent.atomic.AtomicBoolean(false)
/** Returns the newer version tag, or null when current. Never throws. */
suspend fun check(state: AppState): String? = withContext(Dispatchers.IO) {
runCatching {
@@ -54,35 +57,69 @@ object UpdateChecker {
}.onFailure { Log.w(TAG, "check failed: ${it.javaClass.simpleName}") }.getOrNull()
}
/** Downloads the APK and hands it to the package installer. */
/**
* Downloads the APK and hands it to the package installer.
*
* The download lands in a .part file that is only renamed once the byte
* count matches what the server announced. Handing a half-written APK to
* the installer leaves it sitting on a spinner with nothing to report,
* which is indistinguishable from a hang.
*/
suspend fun downloadAndInstall(context: Context, state: AppState): String? =
withContext(Dispatchers.IO) {
runCatching {
if (apkUrl.isEmpty()) check(state)
require(apkUrl.isNotEmpty()) { "no update available" }
val dir = File(context.cacheDir, "updates").apply { mkdirs() }
val file = File(dir, "castarr-update.apk")
(URL(apkUrl).openConnection() as HttpURLConnection).run {
connectTimeout = 15_000
readTimeout = 120_000
instanceFollowRedirects = true
inputStream.use { input -> file.outputStream().use { input.copyTo(it) } }
disconnect()
// A second click while the first download runs would have two
// writers on one file, and the installer reads whichever half won.
if (!busy.compareAndSet(false, true)) return@withContext "Update läuft bereits"
try {
runCatching {
if (apkUrl.isEmpty()) check(state)
require(apkUrl.isNotEmpty()) { "no update available" }
val dir = File(context.cacheDir, "updates").apply { mkdirs() }
val file = File(dir, "castarr-update.apk")
val part = File(dir, "castarr-update.apk.part")
part.delete()
val connection = URL(apkUrl).openConnection() as HttpURLConnection
val expected = try {
connection.connectTimeout = 15_000
connection.readTimeout = 120_000
connection.instanceFollowRedirects = true
val code = connection.responseCode
require(code == HttpURLConnection.HTTP_OK) { "http $code" }
val announced = connection.contentLengthLong
connection.inputStream.use { input ->
part.outputStream().use { output -> input.copyTo(output) }
}
announced
} finally {
connection.disconnect()
}
require(part.length() > 0) { "empty download" }
require(expected <= 0 || part.length() == expected) {
"truncated: ${part.length()} of $expected"
}
file.delete()
require(part.renameTo(file)) { "rename failed" }
val uri = FileProvider.getUriForFile(
context, "${BuildConfig.APPLICATION_ID}.fileprovider", file,
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK
)
}
// Launching from the IO dispatcher stalled the installer on
// the first attempt — activities start from the main thread.
withContext(Dispatchers.Main) { context.startActivity(intent) }
null
}.getOrElse {
Log.w(TAG, "install failed: ${it.javaClass.simpleName}: ${it.message}")
File(context.cacheDir, "updates/castarr-update.apk.part").delete()
"Update fehlgeschlagen — später erneut versuchen"
}
val uri = FileProvider.getUriForFile(
context, "${BuildConfig.APPLICATION_ID}.fileprovider", file,
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
// Launching from the IO dispatcher stalled the installer on
// the first attempt — activities start from the main thread.
withContext(Dispatchers.Main) { context.startActivity(intent) }
null
}.getOrElse {
Log.w(TAG, "install failed: ${it.javaClass.simpleName}")
"Update fehlgeschlagen — später erneut versuchen"
} finally {
busy.set(false)
}
}

107
tests/demo/make-demo-data.py Executable file
View File

@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Neutral demo line-up for screenshots and manual testing.
Invented channels and programmes only — no real provider data ever ends up
in a screenshot. One club match is placed a few minutes ahead so the club
menu shows its "kick-off imminent" state.
"""
import argparse
import datetime
import os
p = argparse.ArgumentParser()
p.add_argument("--out", default=os.path.dirname(os.path.abspath(__file__)))
p.add_argument("--kickoff-in", type=int, default=18,
help="minutes until the club match starts")
args = p.parse_args()
now = datetime.datetime.now().astimezone()
def fmt(dt):
# Local offset, so the emulator's clock agrees with ours (CEST vs CET).
return dt.strftime("%Y%m%d%H%M%S %z")
CHANNELS = [
("Unterhaltung", "Blau TV HD", "blau"),
("Unterhaltung", "Nordlicht HD", "nordlicht"),
("Unterhaltung", "Kanal Sieben", "sieben"),
("Filme", "Kinohaus HD", "kinohaus"),
("Filme", "Filmwerk Classic", "filmwerk"),
("Filme", "Nachtkino HD", "nachtkino"),
("Serien", "Serienbox HD", "serienbox"),
("Serien", "Staffel Eins", "staffel"),
("Sport", "Arena Sport 1 HD", "arena1"),
("Sport", "Arena Sport 2 HD", "arena2"),
("Sport", "Liga Live UHD", "ligalive"),
("Sport", "Stadionkanal HD", "stadion"),
("Nachrichten", "Tagesblick HD", "tagesblick"),
("Nachrichten", "Weltbericht", "weltbericht"),
("Doku & Wissen", "Terra Doku HD", "terra"),
("Doku & Wissen", "Wissenswelt", "wissen"),
("Kinder", "Krümelkiste", "kruemel"),
("Musik", "Tonspur HD", "tonspur"),
("Regional", "Hafenkanal HD", "hafen"),
("International", "Globus TV", "globus"),
]
PROGRAMMES = {
"blau": [("Morgenmagazin", -40, 80), ("Kochduell", 40, 60)],
"nordlicht": [("Küstenrevier", -20, 70), ("Nordlicht Talk", 50, 45)],
"sieben": [("Quiz um Sieben", -10, 60), ("Sieben Reportage", 50, 45)],
"kinohaus": [("Der weite Weg", -55, 125), ("Sturmhöhe", 70, 110)],
"filmwerk": [("Schwarzweiß", -30, 95), ("Die Erbschaft", 65, 100)],
"nachtkino": [("Mitternachtszug", -15, 105), ("Letzte Runde", 90, 95)],
"serienbox": [("Praxis Ostwind (3/12)", -25, 45), ("Praxis Ostwind (4/12)", 20, 45)],
"staffel": [("Hafenwache (7/10)", -35, 50), ("Hafenwache (8/10)", 15, 50)],
"arena1": [("Handball: Pokalrunde", -45, 105), ("Sport am Abend", 60, 60)],
"arena2": [("Radsport: Etappe 4", -50, 130), ("Motorsport kompakt", 80, 55)],
"ligalive": [("Sport aktuell", -25, 43), ("__CLUB__", None, 120)],
"stadion": [("Stadionmagazin", -20, 60), ("FC St. Pauli: Der Rückblick", 40, 45)],
"tagesblick": [("Tagesblick am Mittag", -12, 30), ("Wetter und Verkehr", 18, 15)],
"weltbericht": [("Weltbericht Spezial", -35, 75), ("Auslandsjournal", 40, 45)],
"terra": [("Wale der Arktis", -40, 90), ("Vulkane Islands", 50, 45)],
"wissen": [("Wie Brücken halten", -20, 45), ("Chemie des Alltags", 25, 45)],
"kruemel": [("Bärenbande", -15, 25), ("Malstunde", 10, 25)],
"tonspur": [("Charts der Woche", -60, 120), ("Akustik-Session", 60, 60)],
"hafen": [("Hafenrundfahrt", -30, 60), ("Regional um sechs", 30, 30)],
"globus": [("Globus Reportage", -45, 90), ("Fernweh", 45, 60)],
}
CLUB_TITLE = "Pokal: Nordstadt - FC St. Pauli, 1. Runde"
os.makedirs(args.out, exist_ok=True)
m3u = os.path.join(args.out, "playlist.m3u")
xml = os.path.join(args.out, "epg.xml")
with open(m3u, "w", encoding="utf-8") as f:
f.write("#EXTM3U\n")
for group, name, cid in CHANNELS:
f.write(f'#EXTINF:-1 tvg-id="{cid}" group-title="{group}",{name}\n')
f.write(f"http://10.0.2.2:8099/stream/{cid}.ts\n")
def escape(text):
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
with open(xml, "w", encoding="utf-8") as f:
f.write('<?xml version="1.0" encoding="UTF-8"?>\n<tv>\n')
for _, name, cid in CHANNELS:
f.write(f' <channel id="{cid}"><display-name>{escape(name)}</display-name></channel>\n')
for cid, items in PROGRAMMES.items():
for title, offset, length in items:
if title == "__CLUB__":
title, offset = CLUB_TITLE, args.kickoff_in
start = now + datetime.timedelta(minutes=offset)
stop = start + datetime.timedelta(minutes=length)
f.write(
f' <programme start="{fmt(start)}" stop="{fmt(stop)}" channel="{cid}">'
f"<title>{escape(title)}</title></programme>\n"
)
f.write("</tv>\n")
print(f"{len(CHANNELS)} Sender, {sum(len(v) for v in PROGRAMMES.values())} Sendungen")
print(f"Anstoß in {args.kickoff_in} Min: {CLUB_TITLE}")
print(f"-> {m3u}\n-> {xml}")

70
tests/helpers/emulator.sh Executable file
View File

@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Headless Google-TV emulator for test runs.
#
# The AVD keeps a "default_boot" snapshot, so a start restores a booted
# system in seconds instead of cold-booting for minutes. "stop" refreshes
# that snapshot before killing, so the next start stays fast.
#
# tests/helpers/emulator.sh start # boot (or restore) and wait
# tests/helpers/emulator.sh stop # snapshot, then kill
# tests/helpers/emulator.sh status
# tests/helpers/emulator.sh install <apk>
# tests/helpers/emulator.sh shot <name> # screenshot into tests/runs/screenshots
set -euo pipefail
AVD="${CASTARR_AVD:-castarr-googletv}"
SERIAL="${CASTARR_SERIAL:-emulator-5554}"
SDK="${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}"
ADB="$SDK/platform-tools/adb"
EMULATOR="$SDK/emulator/emulator"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
RUNS="$ROOT/tests/runs"
LOG="$RUNS/emulator.log"
booted() { [ "$("$ADB" -s "$SERIAL" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = "1" ]; }
start() {
if booted; then echo "already up: $SERIAL"; return 0; fi
mkdir -p "$RUNS"
# No -no-snapshot here: that is what makes a start slow.
nohup "$EMULATOR" -avd "$AVD" \
-no-window -no-audio -no-boot-anim \
-gpu swiftshader_indirect \
>"$LOG" 2>&1 &
local start_ts=$SECONDS
"$ADB" -s "$SERIAL" wait-for-device
for _ in $(seq 1 180); do
booted && { echo "up after $((SECONDS - start_ts))s"; return 0; }
sleep 1
done
echo "timeout waiting for boot; see $LOG" >&2
return 1
}
stop() {
if ! pgrep -f "qemu-system-.*-avd $AVD" >/dev/null 2>&1 && ! booted; then
echo "not running"; return 0
fi
# Refresh the snapshot so the next start is a restore, not a cold boot.
"$ADB" -s "$SERIAL" emu avd snapshot save default_boot
"$ADB" -s "$SERIAL" emu kill
echo "stopped, snapshot saved"
}
case "${1:-status}" in
start) start ;;
stop) stop ;;
status) booted && echo "up: $SERIAL" || echo "down" ;;
install)
[ $# -ge 2 ] || { echo "usage: $0 install <apk>" >&2; exit 2; }
"$ADB" -s "$SERIAL" install -r "$2"
;;
shot)
name="${2:-shot}"
mkdir -p "$RUNS/screenshots"
"$ADB" -s "$SERIAL" exec-out screencap -p > "$RUNS/screenshots/$name.png"
echo "$RUNS/screenshots/$name.png"
;;
*) echo "usage: $0 {start|stop|status|install <apk>|shot <name>}" >&2; exit 2 ;;
esac

View File

@@ -27,6 +27,30 @@ if os.path.exists(URLS):
kotlin = open(SRC, encoding="utf-8").read()
entries = re.findall(r'club\((.*?)\)\s*,\s*(?://.*)?$', kotlin, re.M | re.S)
clubs = []
# Wikipedia serves ~330px thumbnails. The rail draws them at 20dp, which on a
# 1080p TV is 40px -- an 8x downscale that Android does with a cheap filter,
# and the edges come out ragged. Resampling once here with a proper filter
# fixes that and shrinks the APK.
CREST_PX = 128
def downscale(data: bytes) -> bytes:
try:
from PIL import Image
except ImportError:
return data
import io
image = Image.open(io.BytesIO(data)).convert("RGBA")
if max(image.size) <= CREST_PX:
return data
scale = CREST_PX / max(image.size)
size = (max(1, round(image.width * scale)), max(1, round(image.height * scale)))
out = io.BytesIO()
image.resize(size, Image.LANCZOS).save(out, format="PNG", optimize=True)
return out.getvalue()
for raw in re.findall(r'club\(\s*"([^"]+)",\s*"[^"]*",\s*"([^"]+)"[^\n]*', kotlin):
clubs.append(raw)
# an explicit article = "..." wins over the club name
@@ -52,7 +76,7 @@ for key, full_name in clubs:
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)
f.write(downscale(data))
fetched += 1
except Exception as exc: # noqa: BLE001 - best effort, shield is the fallback
print(f" {key}: {exc}", file=sys.stderr)