Compare commits
7 Commits
v0.11.1
...
f1a6600e42
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1a6600e42 | ||
|
|
637679fd90 | ||
|
|
cbdac7ddb2 | ||
|
|
8bf1ed3128 | ||
|
|
3b6a8d7d0a | ||
|
|
e491f8e50f | ||
|
|
56b5d3f0b0 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -6,3 +6,5 @@ local.properties
|
|||||||
.kotlin/
|
.kotlin/
|
||||||
tests/runs/
|
tests/runs/
|
||||||
app/src/main/assets/crests/
|
app/src/main/assets/crests/
|
||||||
|
tests/demo/playlist.m3u
|
||||||
|
tests/demo/epg.xml
|
||||||
|
|||||||
@@ -73,9 +73,10 @@ Channel, no full guide timeline.
|
|||||||
## Example dialogue
|
## Example dialogue
|
||||||
|
|
||||||
> **Dev:** "Does the **Remote** need Backend credentials?"
|
> **Dev:** "Does the **Remote** need Backend credentials?"
|
||||||
> **Domain expert:** "No — the user enters the Xtream credentials of a
|
> **Domain expert:** "No — the Remote is where **Onboarding** happens, not
|
||||||
> **Quelle** once via the Remote, the **TV-App** stores them and is the only
|
> where credentials live: the user types the server URL there and finishes the
|
||||||
> one talking to the **Backend**."
|
> 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
|
## Flagged ambiguities
|
||||||
|
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -79,4 +79,22 @@ sind signiert; über einem Debug-Build muss einmal deinstalliert werden
|
|||||||
(Signaturwechsel). Updates bezieht die App über die Release-API dieses
|
(Signaturwechsel). Updates bezieht die App über die Release-API dieses
|
||||||
Repos, die APK selbst liegt auf dem Branch `apk`.
|
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/).
|
Architektur-Notizen: [CONTEXT.md](CONTEXT.md) und [docs/adr/](docs/adr/).
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ android {
|
|||||||
applicationId = "dev.castarr.tv"
|
applicationId = "dev.castarr.tv"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 34
|
versionCode = 38
|
||||||
versionName = "0.11.1"
|
versionName = "0.11.5"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release signing from environment (see ~/.keys/castarr-release.env on the
|
// Release signing from environment (see ~/.keys/castarr-release.env on the
|
||||||
|
|||||||
@@ -176,6 +176,30 @@ class AppState(
|
|||||||
val further: Int,
|
val further: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** 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 teamSignal(matches: List<Pair<Channel, TeamHit>>): TeamSignal {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
var soonAt = 0L
|
||||||
|
matches.forEach { (_, hit) ->
|
||||||
|
val p = hit.programme
|
||||||
|
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 (soonAt > 0) TeamSignal(TeamUrgency.SOON, soonAt)
|
||||||
|
else TeamSignal(TeamUrgency.NONE, 0L)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Channels showing the viewer's club within the scanned window, paired
|
* Channels showing the viewer's club within the scanned window, paired
|
||||||
* with the programme that matched — earliest kick-off first, so whatever
|
* with the programme that matched — earliest kick-off first, so whatever
|
||||||
@@ -230,6 +254,9 @@ class AppState(
|
|||||||
private companion object {
|
private companion object {
|
||||||
/** Grace period before re-opening the channel just closed. */
|
/** Grace period before re-opening the channel just closed. */
|
||||||
const val REENTRY_GRACE_MS = 2_500L
|
const val REENTRY_GRACE_MS = 2_500L
|
||||||
|
|
||||||
|
/** How far ahead a kick-off counts as "about to start". */
|
||||||
|
const val SOON_WINDOW_MS = 30 * 60 * 1000L
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isOnline(): Boolean {
|
fun isOnline(): Boolean {
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ data class TeamFilter(
|
|||||||
val secondary: Color,
|
val secondary: Color,
|
||||||
/** German Wikipedia article the crest is resolved from. */
|
/** German Wikipedia article the crest is resolved from. */
|
||||||
val article: String,
|
val article: String,
|
||||||
|
/** 1, 2 or 3 — groups the picker by division. */
|
||||||
|
val league: Int,
|
||||||
) {
|
) {
|
||||||
fun matches(title: String): Boolean {
|
fun matches(title: String): Boolean {
|
||||||
val haystack = title.lowercase()
|
val haystack = title.lowercase()
|
||||||
@@ -63,69 +65,70 @@ object TeamFilters {
|
|||||||
primary: Long,
|
primary: Long,
|
||||||
secondary: Long,
|
secondary: Long,
|
||||||
article: String = fullName,
|
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. */
|
/** Clubs of the top three German divisions. */
|
||||||
val all: List<TeamFilter> = listOf(
|
val all: List<TeamFilter> = listOf(
|
||||||
// --- Bundesliga ---
|
// --- Bundesliga ---
|
||||||
club("bayern", "FCB", "FC Bayern München", listOf("bayern münchen", "fc bayern"), 0xFFDC052D, 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),
|
club("bvb", "BVB", "Borussia Dortmund", listOf("borussia dortmund", "bvb"), 0xFFFDE100, BLACK, league = 1),
|
||||||
club("leipzig", "RBL", "RB Leipzig", listOf("rb leipzig"), 0xFFDD0741, WHITE),
|
club("leipzig", "RBL", "RB Leipzig", listOf("rb leipzig"), 0xFFDD0741, WHITE, league = 1),
|
||||||
club("leverkusen", "B04", "Bayer 04 Leverkusen", listOf("leverkusen"), 0xFFE32219, BLACK),
|
club("leverkusen", "B04", "Bayer 04 Leverkusen", listOf("leverkusen"), 0xFFE32219, BLACK, league = 1),
|
||||||
club("frankfurt", "SGE", "Eintracht Frankfurt", listOf("eintracht frankfurt"), 0xFF1A1A1A, 0xFFE1000F),
|
club("frankfurt", "SGE", "Eintracht Frankfurt", listOf("eintracht frankfurt"), 0xFF1A1A1A, 0xFFE1000F, league = 1),
|
||||||
club("stuttgart", "1893", "VfB Stuttgart", listOf("vfb stuttgart"), 0xFFE32219, WHITE),
|
club("stuttgart", "1893", "VfB Stuttgart", listOf("vfb stuttgart"), 0xFFE32219, WHITE, league = 1),
|
||||||
club("gladbach", "BMG", "Borussia Mönchengladbach", listOf("mönchengladbach", "gladbach"), 0xFF1A1A1A, 0xFF00A94F),
|
club("gladbach", "BMG", "Borussia Mönchengladbach", listOf("mönchengladbach", "gladbach"), 0xFF1A1A1A, 0xFF00A94F, league = 1),
|
||||||
club("wolfsburg", "WOB", "VfL Wolfsburg", listOf("wolfsburg"), 0xFF65B32E, WHITE),
|
club("wolfsburg", "WOB", "VfL Wolfsburg", listOf("wolfsburg"), 0xFF65B32E, WHITE, league = 1),
|
||||||
club("bremen", "SVW", "SV Werder Bremen", listOf("werder bremen", "werder"), 0xFF1D9053, WHITE),
|
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),
|
club("freiburg", "SCF", "SC Freiburg", listOf("sc freiburg", "freiburg"), 0xFFE2001A, WHITE, league = 1),
|
||||||
club("hoffenheim", "TSG", "TSG Hoffenheim", listOf("hoffenheim"), 0xFF1C63B7, WHITE),
|
club("hoffenheim", "TSG", "TSG Hoffenheim", listOf("hoffenheim"), 0xFF1C63B7, WHITE, league = 1),
|
||||||
club("mainz", "M05", "1. FSV Mainz 05", listOf("mainz 05", "mainz"), 0xFFE1000F, WHITE),
|
club("mainz", "M05", "1. FSV Mainz 05", listOf("mainz 05", "mainz"), 0xFFE1000F, WHITE, league = 1),
|
||||||
club("augsburg", "FCA", "FC Augsburg", listOf("augsburg"), 0xFF00693F, 0xFFE1000F),
|
club("augsburg", "FCA", "FC Augsburg", listOf("augsburg"), 0xFF00693F, 0xFFE1000F, league = 1),
|
||||||
club("union", "FCU", "1. FC Union Berlin", listOf("union berlin"), 0xFFE1000F, 0xFFFDE100),
|
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),
|
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),
|
club("hsv", "HSV", "Hamburger SV", listOf("hamburger sv", "hsv"), 0xFF0E5EA6, BLACK, league = 1),
|
||||||
club("heidenheim", "FCH1", "1. FC Heidenheim", listOf("heidenheim"), 0xFFE1000F, 0xFF1656A4),
|
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),
|
club("st-pauli", "FCSP", "FC St. Pauli", listOf("st. pauli", "st pauli"), 0xFF6B4423, WHITE, league = 1),
|
||||||
// --- 2. Bundesliga ---
|
// --- 2. Bundesliga ---
|
||||||
club("schalke", "S04", "FC Schalke 04", listOf("schalke"), 0xFF004D9D, WHITE),
|
club("schalke", "S04", "FC Schalke 04", listOf("schalke"), 0xFF004D9D, WHITE, league = 2),
|
||||||
club("hertha", "BSC", "Hertha BSC", listOf("hertha"), 0xFF004D9D, WHITE),
|
club("hertha", "BSC", "Hertha BSC", listOf("hertha"), 0xFF004D9D, WHITE, league = 2),
|
||||||
club("duesseldorf", "F95", "Fortuna Düsseldorf", listOf("fortuna düsseldorf"), 0xFFE1000F, WHITE),
|
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),
|
club("nuernberg", "FCN", "1. FC Nürnberg", listOf("nürnberg"), 0xFF8B1A1A, WHITE, league = 2),
|
||||||
club("kaiserslautern", "FCK", "1. FC Kaiserslautern", listOf("kaiserslautern"), 0xFFE1000F, WHITE),
|
club("kaiserslautern", "FCK", "1. FC Kaiserslautern", listOf("kaiserslautern"), 0xFFE1000F, WHITE, league = 2),
|
||||||
club("karlsruhe", "KSC", "Karlsruher SC", listOf("karlsruher sc", "ksc"), 0xFF0055A5, WHITE),
|
club("karlsruhe", "KSC", "Karlsruher SC", listOf("karlsruher sc", "ksc"), 0xFF0055A5, WHITE, league = 2),
|
||||||
club("hannover", "H96", "Hannover 96", listOf("hannover 96"), 0xFF00A94F, WHITE),
|
club("hannover", "H96", "Hannover 96", listOf("hannover 96"), 0xFF00A94F, WHITE, league = 2),
|
||||||
club("paderborn", "SCP", "SC Paderborn 07", listOf("paderborn"), 0xFF004D9D, WHITE),
|
club("paderborn", "SCP", "SC Paderborn 07", listOf("paderborn"), 0xFF004D9D, WHITE, league = 2),
|
||||||
club("magdeburg", "FCM", "1. FC Magdeburg", listOf("magdeburg"), 0xFF0B7A3E, WHITE),
|
club("magdeburg", "FCM", "1. FC Magdeburg", listOf("magdeburg"), 0xFF0B7A3E, WHITE, league = 2),
|
||||||
club("elversberg", "SVE", "SV Elversberg", listOf("elversberg"), 0xFF1A1A1A, 0xFFE1000F),
|
club("elversberg", "SVE", "SV Elversberg", listOf("elversberg"), 0xFF1A1A1A, 0xFFE1000F, league = 2),
|
||||||
club("darmstadt", "SV98", "SV Darmstadt 98", listOf("darmstadt"), 0xFF004E9E, WHITE),
|
club("darmstadt", "SV98", "SV Darmstadt 98", listOf("darmstadt"), 0xFF004E9E, WHITE, league = 2),
|
||||||
club("braunschweig", "BTSV", "Eintracht Braunschweig", listOf("braunschweig"), 0xFFFDE100, 0xFF004E9E),
|
club("braunschweig", "BTSV", "Eintracht Braunschweig", listOf("braunschweig"), 0xFFFDE100, 0xFF004E9E, league = 2),
|
||||||
club("bochum", "BOC", "VfL Bochum", listOf("bochum"), 0xFF005CA9, WHITE),
|
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),
|
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),
|
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),
|
club("holstein", "KSV", "Holstein Kiel", listOf("holstein kiel"), 0xFF004E9E, 0xFFE1000F, league = 2),
|
||||||
club("dresden", "SGD", "Dynamo Dresden", listOf("dynamo dresden"), 0xFFFDE100, BLACK),
|
club("dresden", "SGD", "Dynamo Dresden", listOf("dynamo dresden"), 0xFFFDE100, BLACK, league = 2),
|
||||||
club("bielefeld", "DSC", "Arminia Bielefeld", listOf("arminia bielefeld", "bielefeld"), 0xFF00539F, WHITE),
|
club("bielefeld", "DSC", "Arminia Bielefeld", listOf("arminia bielefeld", "bielefeld"), 0xFF00539F, WHITE, league = 2),
|
||||||
// --- 3. Liga ---
|
// --- 3. Liga ---
|
||||||
club("hansa", "FCH", "FC Hansa Rostock", listOf("hansa"), 0xFF0B4EA2, WHITE),
|
club("hansa", "FCH", "FC Hansa Rostock", listOf("hansa"), 0xFF0B4EA2, WHITE, league = 3),
|
||||||
club("saarbruecken", "FCS", "1. FC Saarbrücken", listOf("saarbrücken"), 0xFF0B4EA2, BLACK),
|
club("saarbruecken", "FCS", "1. FC Saarbrücken", listOf("saarbrücken"), 0xFF0B4EA2, BLACK, league = 3),
|
||||||
club("aue", "FCE", "Erzgebirge Aue", listOf("erzgebirge aue"), 0xFF7C0A02, WHITE),
|
club("aue", "FCE", "Erzgebirge Aue", listOf("erzgebirge aue"), 0xFF7C0A02, WHITE, league = 3),
|
||||||
club("cottbus", "FCEC", "Energie Cottbus", listOf("energie cottbus", "cottbus"), 0xFFE1000F, WHITE),
|
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),
|
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),
|
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),
|
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),
|
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"),
|
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),
|
club("regensburg", "SSVJ", "SSV Jahn Regensburg", listOf("jahn regensburg", "regensburg"), 0xFFE1000F, WHITE, league = 3),
|
||||||
club("verl", "SCV", "SC Verl", listOf("sc verl"), 0xFF00703C, WHITE),
|
club("verl", "SCV", "SC Verl", listOf("sc verl"), 0xFF00703C, WHITE, league = 3),
|
||||||
club("viktoria", "VKÖ", "Viktoria Köln", listOf("viktoria köln"), 0xFFE1000F, WHITE),
|
club("viktoria", "VKÖ", "Viktoria Köln", listOf("viktoria köln"), 0xFFE1000F, WHITE, league = 3),
|
||||||
club("havelse", "TSVH", "TSV Havelse", listOf("havelse"), 0xFF004E9E, WHITE),
|
club("havelse", "TSVH", "TSV Havelse", listOf("havelse"), 0xFF004E9E, WHITE, league = 3),
|
||||||
club("schweinfurt", "FC05", "1. FC Schweinfurt 05", listOf("schweinfurt"), 0xFF00A94F, WHITE),
|
club("schweinfurt", "FC05", "1. FC Schweinfurt 05", listOf("schweinfurt"), 0xFF00A94F, WHITE, league = 3),
|
||||||
club("osnabrueck", "VfLO", "VfL Osnabrück", listOf("osnabrück"), 0xFF6A2C8F, WHITE),
|
club("osnabrueck", "VfLO", "VfL Osnabrück", listOf("osnabrück"), 0xFF6A2C8F, WHITE, league = 3),
|
||||||
club("aachen", "ALE", "Alemannia Aachen", listOf("alemannia aachen", "aachen"), 0xFFFDE100, BLACK),
|
club("aachen", "ALE", "Alemannia Aachen", listOf("alemannia aachen", "aachen"), 0xFFFDE100, BLACK, league = 3),
|
||||||
club("ingolstadt", "FCI", "FC Ingolstadt 04", listOf("ingolstadt"), 0xFFE1000F, BLACK),
|
club("ingolstadt", "FCI", "FC Ingolstadt 04", listOf("ingolstadt"), 0xFFE1000F, BLACK, league = 3),
|
||||||
club("wuppertal", "WSV", "Wuppertaler SV", listOf("wuppertaler sv"), 0xFFE1000F, 0xFF0B4EA2),
|
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),
|
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"),
|
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. */
|
/** 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 }
|
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> =
|
fun defaultKeysFor(username: String): List<String> =
|
||||||
defaultForUser[username.trim().lowercase()].orEmpty()
|
defaultForUser[username.trim().lowercase()].orEmpty()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,12 @@ class ControlServer(
|
|||||||
fun startServer() {
|
fun startServer() {
|
||||||
// A busy port must not take the whole app down — the remote is
|
// A busy port must not take the whole app down — the remote is
|
||||||
// optional, everything else keeps working.
|
// optional, everything else keeps working.
|
||||||
running = runCatching { start(NanoHTTPD.SOCKET_READ_TIMEOUT, true) }
|
// NanoHTTPD's 5 s default also applies to the long-lived WebSocket:
|
||||||
|
// with pings only every 8 s the socket timed out mid-session and the
|
||||||
|
// remote was thrown out after a few seconds. The timeout still has to
|
||||||
|
// exist (idle connections must not pin threads), it just has to be
|
||||||
|
// comfortably longer than the ping interval.
|
||||||
|
running = runCatching { start(SOCKET_TIMEOUT_MS, true) }
|
||||||
.onFailure { Log.w(TAG, "control server unavailable: ${it.javaClass.simpleName}") }
|
.onFailure { Log.w(TAG, "control server unavailable: ${it.javaClass.simpleName}") }
|
||||||
.isSuccess
|
.isSuccess
|
||||||
if (!running) return
|
if (!running) return
|
||||||
@@ -354,6 +359,7 @@ class ControlServer(
|
|||||||
private companion object {
|
private companion object {
|
||||||
const val TAG = "ControlServer"
|
const val TAG = "ControlServer"
|
||||||
const val PING_INTERVAL_MS = 8_000L
|
const val PING_INTERVAL_MS = 8_000L
|
||||||
|
const val SOCKET_TIMEOUT_MS = 40_000
|
||||||
const val ATTEMPT_WINDOW_MS = 60_000L
|
const val ATTEMPT_WINDOW_MS = 60_000L
|
||||||
const val ATTEMPT_MAX = 5
|
const val ATTEMPT_MAX = 5
|
||||||
const val MAX_TRACKED_ADDRESSES = 64
|
const val MAX_TRACKED_ADDRESSES = 64
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
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.animation.core.animateFloat
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -32,6 +33,7 @@ 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.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Path
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.graphics.drawscope.clipPath
|
import androidx.compose.ui.graphics.drawscope.clipPath
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
@@ -87,6 +89,15 @@ fun LiveScreen(state: AppState) {
|
|||||||
val matchesByTeam = remember(teams, allChannels, epgStamp) {
|
val matchesByTeam = remember(teams, allChannels, epgStamp) {
|
||||||
teams.associate { it.key to state.teamMatches(it.key) }
|
teams.associate { it.key to state.teamMatches(it.key) }
|
||||||
}
|
}
|
||||||
|
// Re-evaluates the highlight every minute; without it "gleich" would
|
||||||
|
// only appear when something else happened to recompose.
|
||||||
|
var urgencyTick by remember { mutableStateOf(0) }
|
||||||
|
LaunchedEffect(teams) {
|
||||||
|
while (true) {
|
||||||
|
kotlinx.coroutines.delay(60_000)
|
||||||
|
urgencyTick++
|
||||||
|
}
|
||||||
|
}
|
||||||
val activeTeam = teams.firstOrNull { it.key == state.activeTeam }
|
val activeTeam = teams.firstOrNull { it.key == state.activeTeam }
|
||||||
val activeMatches = activeTeam?.let { matchesByTeam[it.key] }.orEmpty()
|
val activeMatches = activeTeam?.let { matchesByTeam[it.key] }.orEmpty()
|
||||||
val teamHits = remember(activeMatches) { activeMatches.associate { it.first.url to it.second } }
|
val teamHits = remember(activeMatches) { activeMatches.associate { it.first.url to it.second } }
|
||||||
@@ -189,11 +200,16 @@ fun LiveScreen(state: AppState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
items(teams, key = { it.key }) { club ->
|
items(teams, key = { it.key }) { club ->
|
||||||
|
val clubMatches = matchesByTeam[club.key].orEmpty()
|
||||||
|
val clubSignal = remember(clubMatches, urgencyTick) {
|
||||||
|
state.teamSignal(clubMatches)
|
||||||
|
}
|
||||||
GroupItem(
|
GroupItem(
|
||||||
label = club.label,
|
label = club.label,
|
||||||
count = matchesByTeam[club.key]?.size ?: 0,
|
count = clubMatches.size,
|
||||||
selected = state.activeTeam == club.key,
|
selected = state.activeTeam == club.key,
|
||||||
leading = { Crest(club, state) },
|
leading = { Crest(club, state, clubSignal.urgency) },
|
||||||
|
signal = clubSignal,
|
||||||
modifier = intoList.then(
|
modifier = intoList.then(
|
||||||
if (state.activeTeam == club.key) Modifier.focusRequester(railFocus)
|
if (state.activeTeam == club.key) Modifier.focusRequester(railFocus)
|
||||||
else Modifier
|
else Modifier
|
||||||
@@ -299,6 +315,8 @@ private fun GroupItem(
|
|||||||
selected: Boolean,
|
selected: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
leading: (@Composable () -> Unit)? = null,
|
leading: (@Composable () -> Unit)? = null,
|
||||||
|
/** Replaces the count when a match is on or about to start. */
|
||||||
|
signal: AppState.TeamSignal? = null,
|
||||||
suppressAutoSelect: () -> Boolean = { false },
|
suppressAutoSelect: () -> Boolean = { false },
|
||||||
onSelect: () -> Unit,
|
onSelect: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -336,11 +354,36 @@ private fun GroupItem(
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text(
|
// The signal lives in the value column instead of adding a
|
||||||
"$count",
|
// badge — the count is worth less than "now" or "16:00" is.
|
||||||
fontFamily = AppFont,
|
val urgent = signal?.urgency ?: AppState.TeamUrgency.NONE
|
||||||
fontSize = 12.sp,
|
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 (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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -438,13 +481,47 @@ private fun ChannelRow(
|
|||||||
* network; the coloured shield stands in if one is ever missing.
|
* network; the coloured shield stands in if one is ever missing.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun Crest(team: dev.castarr.tv.data.TeamFilter, state: AppState) {
|
private fun Crest(
|
||||||
Box(Modifier.size(20.dp), contentAlignment = Alignment.Center) {
|
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(
|
SubcomposeAsyncImage(
|
||||||
model = "file:///android_asset/crests/${team.key}.png",
|
model = "file:///android_asset/crests/${team.key}.png",
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
modifier = Modifier.fillMaxSize(),
|
filterQuality = androidx.compose.ui.graphics.FilterQuality.High,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
loading = { ShieldFallback(team) },
|
loading = { ShieldFallback(team) },
|
||||||
error = { ShieldFallback(team) },
|
error = { ShieldFallback(team) },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ 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.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
@@ -79,6 +80,7 @@ fun SettingsScreen(state: AppState) {
|
|||||||
val profiles by state.dispatcharr.profiles.collectAsState()
|
val profiles by state.dispatcharr.profiles.collectAsState()
|
||||||
var updateStatus by remember { mutableStateOf("") }
|
var updateStatus by remember { mutableStateOf("") }
|
||||||
var picker by remember { mutableStateOf<Picker?>(null) }
|
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.
|
// Bumped on reset so the QR code and the four-digit code redraw.
|
||||||
var pairingEpoch by remember { mutableStateOf(0) }
|
var pairingEpoch by remember { mutableStateOf(0) }
|
||||||
|
|
||||||
@@ -191,6 +193,7 @@ fun SettingsScreen(state: AppState) {
|
|||||||
SettingRow(
|
SettingRow(
|
||||||
club.fullName,
|
club.fullName,
|
||||||
subtitle = "Erscheint als ${club.label} in der Senderliste · Entfernen",
|
subtitle = "Erscheint als ${club.label} in der Senderliste · Entfernen",
|
||||||
|
leading = { ClubCrest(club, state) },
|
||||||
trailing = { RemoveIcon() },
|
trailing = { RemoveIcon() },
|
||||||
) { state.toggleTeam(club.key) }
|
) { state.toggleTeam(club.key) }
|
||||||
}
|
}
|
||||||
@@ -198,17 +201,7 @@ fun SettingsScreen(state: AppState) {
|
|||||||
SettingRow(
|
SettingRow(
|
||||||
"Verein hinzufügen",
|
"Verein hinzufügen",
|
||||||
subtitle = "1. bis 3. Liga",
|
subtitle = "1. bis 3. Liga",
|
||||||
) {
|
) { clubPicker = true }
|
||||||
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) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,6 +313,155 @@ fun SettingsScreen(state: AppState) {
|
|||||||
picker?.let { current ->
|
picker?.let { current ->
|
||||||
PickerDialog(current) { picker = null }
|
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. */
|
/** TV-friendly dropdown replacement: fullscreen scrim, options centered. */
|
||||||
@@ -512,6 +654,7 @@ private fun SettingRow(
|
|||||||
label: String,
|
label: String,
|
||||||
subtitle: String? = null,
|
subtitle: String? = null,
|
||||||
danger: Boolean = false,
|
danger: Boolean = false,
|
||||||
|
leading: (@Composable () -> Unit)? = null,
|
||||||
trailing: (@Composable () -> Unit)? = null,
|
trailing: (@Composable () -> Unit)? = null,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -537,6 +680,10 @@ private fun SettingRow(
|
|||||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
|
if (leading != null) {
|
||||||
|
leading()
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
}
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ object UpdateChecker {
|
|||||||
|
|
||||||
private var apkUrl: String = ""
|
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. */
|
/** Returns the newer version tag, or null when current. Never throws. */
|
||||||
suspend fun check(state: AppState): String? = withContext(Dispatchers.IO) {
|
suspend fun check(state: AppState): String? = withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -54,35 +57,69 @@ object UpdateChecker {
|
|||||||
}.onFailure { Log.w(TAG, "check failed: ${it.javaClass.simpleName}") }.getOrNull()
|
}.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? =
|
suspend fun downloadAndInstall(context: Context, state: AppState): String? =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
// A second click while the first download runs would have two
|
||||||
if (apkUrl.isEmpty()) check(state)
|
// writers on one file, and the installer reads whichever half won.
|
||||||
require(apkUrl.isNotEmpty()) { "no update available" }
|
if (!busy.compareAndSet(false, true)) return@withContext "Update läuft bereits"
|
||||||
val dir = File(context.cacheDir, "updates").apply { mkdirs() }
|
try {
|
||||||
val file = File(dir, "castarr-update.apk")
|
runCatching {
|
||||||
(URL(apkUrl).openConnection() as HttpURLConnection).run {
|
if (apkUrl.isEmpty()) check(state)
|
||||||
connectTimeout = 15_000
|
require(apkUrl.isNotEmpty()) { "no update available" }
|
||||||
readTimeout = 120_000
|
val dir = File(context.cacheDir, "updates").apply { mkdirs() }
|
||||||
instanceFollowRedirects = true
|
val file = File(dir, "castarr-update.apk")
|
||||||
inputStream.use { input -> file.outputStream().use { input.copyTo(it) } }
|
val part = File(dir, "castarr-update.apk.part")
|
||||||
disconnect()
|
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(
|
} finally {
|
||||||
context, "${BuildConfig.APPLICATION_ID}.fileprovider", file,
|
busy.set(false)
|
||||||
)
|
|
||||||
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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
107
tests/demo/make-demo-data.py
Executable file
107
tests/demo/make-demo-data.py
Executable 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("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
|
||||||
|
|
||||||
|
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
70
tests/helpers/emulator.sh
Executable 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
|
||||||
@@ -27,6 +27,30 @@ if os.path.exists(URLS):
|
|||||||
kotlin = open(SRC, encoding="utf-8").read()
|
kotlin = open(SRC, encoding="utf-8").read()
|
||||||
entries = re.findall(r'club\((.*?)\)\s*,\s*(?://.*)?$', kotlin, re.M | re.S)
|
entries = re.findall(r'club\((.*?)\)\s*,\s*(?://.*)?$', kotlin, re.M | re.S)
|
||||||
clubs = []
|
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):
|
for raw in re.findall(r'club\(\s*"([^"]+)",\s*"[^"]*",\s*"([^"]+)"[^\n]*', kotlin):
|
||||||
clubs.append(raw)
|
clubs.append(raw)
|
||||||
# an explicit article = "..." wins over the club name
|
# 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:
|
with urllib.request.urlopen(urllib.request.Request(thumb, headers=UA), timeout=20) as r:
|
||||||
data = r.read()
|
data = r.read()
|
||||||
with open(target, "wb") as f:
|
with open(target, "wb") as f:
|
||||||
f.write(data)
|
f.write(downscale(data))
|
||||||
fetched += 1
|
fetched += 1
|
||||||
except Exception as exc: # noqa: BLE001 - best effort, shield is the fallback
|
except Exception as exc: # noqa: BLE001 - best effort, shield is the fallback
|
||||||
print(f" {key}: {exc}", file=sys.stderr)
|
print(f" {key}: {exc}", file=sys.stderr)
|
||||||
|
|||||||
Reference in New Issue
Block a user