11 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
be-nj
e491f8e50f Highlight a club menu while its match is on or about to start
All checks were successful
Build TV app / build (push) Successful in 3m6s
The rail entry showed a plain hit count, which says nothing about whether
that hit is happening now or six hours from now. It now turns into a red
dot with "läuft" while a match is running, and an accent dot with "gleich"
within half an hour of kick-off — the only two moments where the menu is
worth interrupting for. A one-minute ticker re-evaluates it so the state
appears on its own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 10:45:15 +02:00
be-nj
56b5d3f0b0 Stop the socket timeout from killing the remote mid-session
All checks were successful
Build TV app / build (push) Successful in 2m55s
Issue #1's fix gave accepted sockets a read deadline so idle connections
could no longer pin threads — but NanoHTTPD's 5 s default also applies to
the long-lived WebSocket, and pings only ran every 8 s. The remote was
therefore dropped roughly five seconds into every session, right after a
command or two, with no close frame.

The deadline stays (that was the point) but is now 40 s, comfortably above
the ping interval, so pongs keep an active session alive while a truly idle
socket still gets reaped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 10:33:39 +02:00
be-nj
b0b8f8801d Stop throwing out honest remotes, and mirror club menus to the phone
All checks were successful
Build TV app / build (push) Successful in 4m39s
The rate limit charged every hello, not just failed ones. The phone remote
reconnects on any network hiccup, so five reconnects in a minute — entirely
normal — got it kicked with "zu viele Versuche". Only failed authentication
costs budget now, and a success clears the address.

The club menus existed only on the TV. The phone now receives them and
shows a chip per club next to Alle/Favoriten, listing the matching channels
with kick-off time instead of the usual now/next.

Settings: the club row showed an "An" pill although it can only ever
remove, which read like a toggle. It is a remove icon now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 10:25:22 +02:00
be-nj
9a0fa37e55 Club menus look ahead to midnight and count repeat broadcasts
All checks were successful
Build TV app / build (push) Successful in 3m9s
A three-hour window was empty most of the day, which is exactly when
someone wants to know whether their club plays tonight. The scan now runs
until midnight, never less than three hours, so a late-evening question
still covers the rest of the night.

A channel can carry several of the club's broadcasts in one evening — a
preview at 18:20 and the match at 21:45 on the same station. The row names
the next one and appends "+n weitere" instead of silently hiding the rest,
so the list stays a channel list where one click means one station.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 10:01:14 +02:00
be-nj
73f6dbba75 Surface failed refreshes instead of swallowing them
All checks were successful
Build TV app / build (push) Successful in 2m54s
Favourites and stream profiles were refreshed inside a bare runCatching,
so when they failed nothing was logged and the app just showed a stale
cached list. An expired login now always raises the re-login screen, even
when cached channels could still be displayed, and the settings gained a
"Senderliste neu laden" row for a manual retry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 03:59:25 +02:00
be-nj
f6f6a2499f Stop the CI from re-fetching crests, cache their URLs instead
All checks were successful
Build TV app / build (push) Successful in 2m49s
Fetching 56 images on every run earned nothing but rate limits, and the
CI artefact is a test build that does not need the badges — the shield
fallback covers it. The resolved image URLs are cached in
tools/crest-sources.json, which is plain text and safe to commit, so a
local run needs one request per club instead of two and can resume after
a rate limit. The artwork itself stays out of the repository.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 03:42:49 +02:00
19 changed files with 899 additions and 156 deletions

View File

@@ -43,9 +43,6 @@ jobs:
"platforms;android-35" "build-tools;35.0.0" "platform-tools" > /dev/null
echo "sdk.dir=$ANDROID_SDK_ROOT" > local.properties
- name: Fetch club crests
run: python3 tools/fetch-crests.py || true
- name: Unit tests
run: ./gradlew testDebugUnitTest --no-daemon --stacktrace

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 = 31
versionName = "0.10.5"
versionCode = 38
versionName = "0.11.5"
}
// Release signing from environment (see ~/.keys/castarr-release.env on the

View File

@@ -267,7 +267,7 @@
ws: null, connected: false, authorized: false,
status: { state: 'idle', channel: '', group: '', live: false, volume: 0.5 },
channels: [], playlistUrl: '', extras: { nowNext: [], favorites: [], favoritesSupported: false }, favOnly: false,
retryDelay: 1000, volumeDragging: false, searchTerm: '',
retryDelay: 1000, volumeDragging: false, searchTerm: '', teamKey: null,
};
// --- pairing credentials ---
@@ -450,16 +450,31 @@
list.innerHTML = '';
const favSet = new Set(state.extras.favorites || []);
const favOn = state.extras.favoritesSupported && state.favOnly;
const shown = favOn ? filtered.filter((c) => favSet.has(c.backendId)) : filtered;
if (state.extras.favoritesSupported) {
const teams = state.extras.teams || [];
const club = teams.find((t) => t.key === state.teamKey);
// Club view: the TV's club menu, mirrored onto the phone.
const clubHits = club ? new Map(club.entries.map((e) => [e.url, e])) : null;
const shown = club
? filtered.filter((c) => clubHits.has(c.url))
: favOn ? filtered.filter((c) => favSet.has(c.backendId)) : filtered;
if (state.extras.favoritesSupported || teams.length) {
const bar = document.createElement('div');
bar.style.cssText = 'display:flex;gap:8px;padding:2px 12px 10px';
[['Alle', false], ['★ Favoriten', true]].forEach(([label, val]) => {
bar.style.cssText = 'display:flex;gap:8px;padding:2px 12px 10px;overflow-x:auto';
const views = [['Alle', null, false]];
if (state.extras.favoritesSupported) views.push(['★ Favoriten', null, true]);
teams.forEach((t) => views.push([t.label + ' ' + t.entries.length, t.key, false]));
views.forEach(([label, key, fav]) => {
const active = key ? state.teamKey === key : (!state.teamKey && state.favOnly === fav);
const chip = document.createElement('button');
chip.textContent = label;
chip.style.cssText = 'padding:7px 14px;border-radius:999px;font-size:12px;background:' +
(state.favOnly === val ? 'rgba(95,212,196,.12);color:#5fd4c4' : '#121418;color:#9aa0a8');
chip.addEventListener('click', () => { state.favOnly = val; renderChannels(); });
chip.style.cssText = 'flex:none;padding:7px 14px;border-radius:999px;font-size:12px;background:' +
(active ? 'rgba(95,212,196,.12);color:#5fd4c4' : '#121418;color:#9aa0a8');
chip.addEventListener('click', () => {
state.teamKey = key;
state.favOnly = fav;
listSignature = '';
renderChannels();
});
bar.appendChild(chip);
});
list.appendChild(bar);
@@ -467,8 +482,9 @@
if (!shown.length) {
const empty = document.createElement('div');
empty.className = 'empty';
empty.textContent = favOn
? 'Keine Favoriten — Stern auf einem Sender antippen.'
empty.textContent = club
? ('Heute läuft nichts mehr mit ' + club.name + '.')
: favOn ? 'Keine Favoriten — Stern auf einem Sender antippen.'
: 'Keine Sender gefunden.';
list.appendChild(empty);
return;
@@ -493,6 +509,18 @@
const name = document.createElement('span');
name.className = 'name'; name.textContent = c.name;
meta.appendChild(name);
const hit = clubHits ? clubHits.get(c.url) : null;
if (hit) {
const line = document.createElement('span');
line.className = 'grp';
const t = new Date(hit.start);
const hh = String(t.getHours()).padStart(2, '0') + ':' + String(t.getMinutes()).padStart(2, '0');
const running = Date.now() >= hit.start && Date.now() < hit.stop;
line.textContent = (running ? 'läuft · ' : 'ab ' + hh + ' · ') + hit.title +
(hit.further > 0 ? ' · +' + hit.further + ' weitere' : '');
line.style.color = running ? '#5fd4c4' : '#9aa0a8';
meta.appendChild(line);
} else {
const info = (state.extras.nowNext || [])[idx];
if (info && info.now) {
const now = document.createElement('span');
@@ -513,6 +541,7 @@
grp.className = 'grp'; grp.textContent = c.group;
meta.appendChild(grp);
}
}
btn.appendChild(num); btn.appendChild(meta);
if (state.extras.favoritesSupported && c.backendId) {
const star = document.createElement('span');
@@ -539,7 +568,8 @@
let listSignature = '';
function renderChannelsIfChanged() {
const sig = [
state.channels.length, state.searchTerm, state.favOnly,
state.channels.length, state.searchTerm, state.favOnly, state.teamKey,
JSON.stringify((state.extras.teams || []).map((t) => [t.key, t.entries.length])),
(state.extras.favorites || []).join(','),
state.status.channel || '',
].join('|');

View File

@@ -170,22 +170,55 @@ class AppState(
SourceMode.DISPATCHARR -> dispatcharr.upcoming(channel, windowMs)
}
/** One channel's club broadcasts: the next one plus how many follow. */
data class TeamHit(
val programme: dev.castarr.tv.data.Programme,
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
* with the programme that matched — earliest kick-off first, so whatever
* is running right now sits on top.
*/
fun teamMatches(key: String): List<Pair<Channel, dev.castarr.tv.data.Programme>> {
fun teamMatches(key: String): List<Pair<Channel, TeamHit>> {
val filter = dev.castarr.tv.data.TeamFilters.byKey(key) ?: return emptyList()
val window = dev.castarr.tv.data.TeamFilters.WINDOW_MS
val now = System.currentTimeMillis()
val window = dev.castarr.tv.data.TeamFilters.windowEnd(now) - now
return activeChannels()
.filter { dev.castarr.tv.data.TeamFilters.scansGroup(it.group) }
.mapNotNull { channel ->
upcoming(channel, window)
.firstOrNull { !isEpgPlaceholder(it.title) && filter.matches(it.title) }
?.let { channel to it }
// A channel can carry several of the club's broadcasts in one
// evening; the row names the next and counts the rest.
val hits = upcoming(channel, window)
.filter { !isEpgPlaceholder(it.title) && filter.matches(it.title) }
hits.firstOrNull()?.let { channel to TeamHit(it, hits.size - 1) }
}
.sortedBy { it.second.start }
.sortedBy { it.second.programme.start }
}
fun refreshActive() {
@@ -200,8 +233,14 @@ class AppState(
result.fold(
onSuccess = { appError = AppError.NONE },
onFailure = { throwable ->
// Cached channels keep the app usable; only surface a
// fullscreen state when there is nothing to show.
// An expired login must always surface: cached channels
// would otherwise sit there without EPG or favourites and
// nothing would say why.
if (throwable.message == "not logged in") {
appError = AppError.RELOGIN
return
}
// Otherwise cached channels keep the app usable.
if (activeChannels().isNotEmpty()) return
appError = when {
throwable.message == "not logged in" -> AppError.RELOGIN
@@ -215,6 +254,9 @@ class AppState(
private companion object {
/** Grace period before re-opening the channel just closed. */
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 {

View File

@@ -147,9 +147,36 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
}
val favorites = org.json.JSONArray()
state.dispatcharr.favorites.value.forEach { favorites.put(it) }
// The club menus exist on the TV; the phone should offer them too.
val teams = org.json.JSONArray()
dev.castarr.tv.data.TeamFilters.all
.filter { it.key in state.enabledTeams }
.forEach { club ->
val entries = org.json.JSONArray()
state.teamMatches(club.key).forEach { (channel, hit) ->
entries.put(
JSONObject()
.put("channel", channel.name)
.put("url", channel.url)
.put("group", channel.group)
.put("title", hit.programme.title)
.put("start", hit.programme.start)
.put("stop", hit.programme.stop)
.put("further", hit.further)
)
}
teams.put(
JSONObject()
.put("key", club.key)
.put("label", club.label)
.put("name", club.fullName)
.put("entries", entries)
)
}
return JSONObject()
.put("nowNext", nowNext)
.put("favorites", favorites)
.put("teams", teams)
.put("favoritesSupported", state.sourceMode == AppState.SourceMode.DISPATCHARR)
}

View File

@@ -49,13 +49,20 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) {
scope.launch {
val result = runCatching {
status.value = "loading_channels"
Log.i(TAG, "refresh start (loggedIn=${auth.isLoggedIn})")
val token = auth.accessToken() ?: error("not logged in")
val groups = fetchGroups(token)
val list = fetchChannels(token, groups)
channels.value = list
prefs.edit().putString("channels_cache", Channel.listToJson(list).toString()).apply()
launch { runCatching { refreshFavorites(token) } }
launch { runCatching { refreshProfiles(token) } }
launch {
runCatching { refreshFavorites(token) }
.onFailure { Log.w(TAG, "favorites failed: ${it.javaClass.simpleName}: ${it.message?.take(120)}") }
}
launch {
runCatching { refreshProfiles(token) }
.onFailure { Log.w(TAG, "profiles failed: ${it.javaClass.simpleName}: ${it.message?.take(120)}") }
}
launch {
runCatching { refreshEpg(token) }
.onFailure { Log.w(TAG, "epg failed: ${it.javaClass.simpleName}: ${it.message?.take(160)}") }

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()
@@ -29,8 +31,25 @@ data class TeamFilter(
object TeamFilters {
/** Window scanned ahead of now. */
const val WINDOW_MS = 3 * 60 * 60 * 1000L
/** Minimum window scanned ahead of now. */
const val MIN_WINDOW_MS = 3 * 60 * 60 * 1000L
/**
* Scans until midnight, but never less than [MIN_WINDOW_MS] — asking in
* the morning should already answer "does my club play tonight", and
* asking late at night must not cut the evening short.
*/
fun windowEnd(now: Long): Long {
val midnight = java.util.Calendar.getInstance().apply {
timeInMillis = now
add(java.util.Calendar.DAY_OF_YEAR, 1)
set(java.util.Calendar.HOUR_OF_DAY, 0)
set(java.util.Calendar.MINUTE, 0)
set(java.util.Calendar.SECOND, 0)
set(java.util.Calendar.MILLISECOND, 0)
}.timeInMillis
return maxOf(midnight, now + MIN_WINDOW_MS)
}
/** At most this many club groups sit in the rail at once. */
const val MAX_ACTIVE = 3
@@ -46,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. */
@@ -138,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

@@ -64,25 +64,45 @@ class ControlServer(
// from spending someone else's budget.
private val attempts = HashMap<String, ArrayDeque<Long>>()
/** True while this address may still try; does not consume budget. */
@Synchronized
private fun attemptAllowed(address: String): Boolean {
val now = System.currentTimeMillis()
val queue = attempts.getOrPut(address) { ArrayDeque() }
val queue = attempts[address] ?: return true
while (queue.isNotEmpty() && now - queue.first() > ATTEMPT_WINDOW_MS) {
queue.removeFirst()
}
return queue.size < ATTEMPT_MAX
}
/**
* Only *failed* authentication costs budget. Counting successes too
* threw out honest clients: the phone remote reconnects on every
* network hiccup, and five reconnects a minute are normal.
*/
@Synchronized
private fun recordFailure(address: String) {
val queue = attempts.getOrPut(address) { ArrayDeque() }
queue.addLast(System.currentTimeMillis())
if (attempts.size > MAX_TRACKED_ADDRESSES) {
attempts.entries.removeAll { it.value.isEmpty() }
}
if (queue.size >= ATTEMPT_MAX) return false
queue.addLast(now)
return true
}
@Synchronized
private fun clearFailures(address: String) {
attempts.remove(address)
}
fun startServer() {
// A busy port must not take the whole app down — the remote is
// 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}") }
.isSuccess
if (!running) return
@@ -292,8 +312,6 @@ class ControlServer(
}
private fun handleHello(msg: JSONObject) {
// Every failed attempt counts against this address, whether it
// carried a token or a code.
if (!attemptAllowed(remoteAddress)) {
trySend(JSONObject().put("type", "error").put("error", "rate_limited").toString())
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "rate limited", false) }
@@ -302,10 +320,12 @@ class ControlServer(
val tokenOk = Pairing.isValidToken(context, msg.optString("token"))
val codeOk = !tokenOk && Pairing.isValidCode(context, msg.optString("code"))
if (!tokenOk && !codeOk) {
recordFailure(remoteAddress)
trySend(JSONObject().put("type", "error").put("error", "bad_code").toString())
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "bad code", false) }
return
}
clearFailures(remoteAddress)
authorized = true
deviceName = msg.optString("name").ifEmpty { "Handy" }
// A code-authenticated client gets its own revocable token, never
@@ -339,6 +359,7 @@ class ControlServer(
private companion object {
const val TAG = "ControlServer"
const val PING_INTERVAL_MS = 8_000L
const val SOCKET_TIMEOUT_MS = 40_000
const val ATTEMPT_WINDOW_MS = 60_000L
const val ATTEMPT_MAX = 5
const val MAX_TRACKED_ADDRESSES = 64

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
@@ -87,6 +89,15 @@ fun LiveScreen(state: AppState) {
val matchesByTeam = remember(teams, allChannels, epgStamp) {
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 activeMatches = activeTeam?.let { matchesByTeam[it.key] }.orEmpty()
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 ->
val clubMatches = matchesByTeam[club.key].orEmpty()
val clubSignal = remember(clubMatches, urgencyTick) {
state.teamSignal(clubMatches)
}
GroupItem(
label = club.label,
count = matchesByTeam[club.key]?.size ?: 0,
count = clubMatches.size,
selected = state.activeTeam == club.key,
leading = { Crest(club, state) },
leading = { Crest(club, state, clubSignal.urgency) },
signal = clubSignal,
modifier = intoList.then(
if (state.activeTeam == club.key) Modifier.focusRequester(railFocus)
else Modifier
@@ -246,7 +262,7 @@ fun LiveScreen(state: AppState) {
Text(
when {
activeTeam != null ->
"In den nächsten 3 Stunden läuft nichts mit ${activeTeam.fullName}."
"Heute läuft nichts mehr mit ${activeTeam.fullName}."
state.favoritesOnly ->
"Noch keine Favoriten — halte OK auf einem Sender gedrückt."
else -> "Diese Gruppe ist leer."
@@ -299,6 +315,8 @@ private fun GroupItem(
selected: Boolean,
modifier: Modifier = Modifier,
leading: (@Composable () -> Unit)? = null,
/** Replaces the count when a match is on or about to start. */
signal: AppState.TeamSignal? = null,
suppressAutoSelect: () -> Boolean = { false },
onSelect: () -> Unit,
) {
@@ -336,11 +354,36 @@ private fun GroupItem(
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.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 (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,
)
}
}
}
}
}
@@ -351,7 +394,7 @@ private fun ChannelRow(
number: Int,
modifier: Modifier = Modifier,
nowNext: NowNext,
highlight: dev.castarr.tv.data.Programme? = null,
highlight: AppState.TeamHit? = null,
playing: Boolean,
favorite: Boolean,
epgStamp: Long,
@@ -438,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) },
)
@@ -510,9 +587,10 @@ private fun LogoInitials(initials: String) {
}
}
/** The programme that matched the club filter, with its start time. */
/** The next club broadcast on this channel, and how many follow it. */
@Composable
private fun HighlightCell(programme: dev.castarr.tv.data.Programme, modifier: Modifier = Modifier) {
private fun HighlightCell(hit: AppState.TeamHit, modifier: Modifier = Modifier) {
val programme = hit.programme
val running = System.currentTimeMillis() in programme.start until programme.stop
Column(modifier) {
Row(verticalAlignment = Alignment.Bottom) {
@@ -536,7 +614,8 @@ private fun HighlightCell(programme: dev.castarr.tv.data.Programme, modifier: Mo
}
Spacer(Modifier.height(6.dp))
Text(
"${formatClock(programme.start)}${formatClock(programme.stop)}",
"${formatClock(programme.start)}${formatClock(programme.stop)}" +
if (hit.further > 0) " · +${hit.further} weitere" else "",
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 12.sp,

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
@@ -38,6 +39,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
@@ -78,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) }
@@ -178,8 +181,8 @@ fun SettingsScreen(state: AppState) {
SettingsCard("Vereinsmenüs") {
Text(
"Eigene Gruppe mit allen Sendern, auf denen der Verein in den " +
"nächsten 3 Stunden läuft. Bis zu " +
"Eigene Gruppe mit allen Sendern, auf denen der Verein " +
"heute noch läuft. Bis zu " +
"${dev.castarr.tv.data.TeamFilters.MAX_ACTIVE} Vereine.",
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 11.sp,
modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 6.dp),
@@ -189,25 +192,16 @@ fun SettingsScreen(state: AppState) {
active.forEach { club ->
SettingRow(
club.fullName,
subtitle = "Erscheint als ${club.label} in der Senderliste",
trailing = { TogglePill(on = true) },
subtitle = "Erscheint als ${club.label} in der Senderliste · Entfernen",
leading = { ClubCrest(club, state) },
trailing = { RemoveIcon() },
) { state.toggleTeam(club.key) }
}
if (active.size < dev.castarr.tv.data.TeamFilters.MAX_ACTIVE) {
SettingRow(
"Verein hinzufügen",
subtitle = "1. bis 3. Liga",
) {
val choices = dev.castarr.tv.data.TeamFilters.all
.filter { it.key !in state.enabledTeams }
picker = Picker(
"Verein hinzufügen",
choices.map { it.fullName },
-1,
) { index ->
choices.getOrNull(index)?.let { state.toggleTeam(it.key) }
}
}
) { clubPicker = true }
}
}
@@ -251,6 +245,10 @@ fun SettingsScreen(state: AppState) {
}
}
}
SettingRow(
"Senderliste neu laden",
subtitle = "Holt Sender, Programm und Favoriten erneut",
) { state.refreshActive() }
SettingRow(
"Erweitert",
subtitle = "M3U/EPG-Adressen von Hand eintragen",
@@ -315,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. */
@@ -448,6 +595,17 @@ private fun ValueWithCaret(value: String) {
}
}
/** Drawn "x": the row only ever removes, so a toggle would mislead. */
@Composable
private fun RemoveIcon() {
val color = CastarrColors.muted
Canvas(Modifier.size(14.dp)) {
val stroke = 2.dp.toPx()
drawLine(color, Offset(0f, 0f), Offset(size.width, size.height), stroke)
drawLine(color, Offset(size.width, 0f), Offset(0f, size.height), stroke)
}
}
@Composable
private fun TogglePill(on: Boolean) {
Box(
@@ -496,6 +654,7 @@ private fun SettingRow(
label: String,
subtitle: String? = null,
danger: Boolean = false,
leading: (@Composable () -> Unit)? = null,
trailing: (@Composable () -> Unit)? = null,
onClick: () -> Unit,
) {
@@ -521,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,36 +57,70 @@ 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) {
// 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")
(URL(apkUrl).openConnection() as HttpURLConnection).run {
connectTimeout = 15_000
readTimeout = 120_000
instanceFollowRedirects = true
inputStream.use { input -> file.outputStream().use { input.copyTo(it) } }
disconnect()
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)
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}")
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"
}
} finally {
busy.set(false)
}
}
private fun isNewer(remote: String, local: String): Boolean {

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

@@ -51,6 +51,25 @@ class TeamFiltersTest {
assertTrue(TeamFilters.all.all { c -> c.needles.all { it == it.lowercase() } })
}
@Test
fun `window reaches midnight but never falls below three hours`() {
val cal = java.util.Calendar.getInstance()
// Morning: the window must reach into the evening.
cal.set(java.util.Calendar.HOUR_OF_DAY, 9)
cal.set(java.util.Calendar.MINUTE, 0)
val morning = cal.timeInMillis
val eveningKickoff = morning + 12 * 60 * 60 * 1000L
assertTrue(TeamFilters.windowEnd(morning) > eveningKickoff)
// Late at night: still at least three hours ahead.
cal.set(java.util.Calendar.HOUR_OF_DAY, 23)
cal.set(java.util.Calendar.MINUTE, 30)
val lateNight = cal.timeInMillis
assertTrue(
TeamFilters.windowEnd(lateNight) >= lateNight + TeamFilters.MIN_WINDOW_MS
)
}
@Test
fun `covers all three divisions`() {
assertTrue(TeamFilters.all.size > 50)

58
tools/crest-sources.json Normal file
View File

@@ -0,0 +1,58 @@
{
"aachen": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/94/Alemannia_Aachen_2010.svg/langde-330px-Alemannia_Aachen_2010.svg.png",
"aue": "https://thumb.wikimedia.org/wikipedia/de/thumb/1/13/Fc_erzgebirge_aue.svg/langde-330px-Fc_erzgebirge_aue.svg.png",
"augsburg": "https://upload.wikimedia.org/wikipedia/de/thumb/b/b5/Logo_FC_Augsburg.svg/langde-330px-Logo_FC_Augsburg.svg.png",
"bayern": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8d/FC_Bayern_M%C3%BCnchen_logo_%282024%29.svg/langde-330px-FC_Bayern_M%C3%BCnchen_logo_%282024%29.svg.png",
"bielefeld": "https://thumb.wikimedia.org/wikipedia/commons/thumb/f/fd/Arminia_Bielefeld_Logo_2021%E2%80%93.svg/langde-330px-Arminia_Bielefeld_Logo_2021%E2%80%93.svg.png",
"bochum": "https://thumb.wikimedia.org/wikipedia/commons/thumb/7/72/VfL_Bochum_logo.svg/langde-330px-VfL_Bochum_logo.svg.png",
"braunschweig": "https://thumb.wikimedia.org/wikipedia/de/thumb/4/45/Logo_Eintracht_Braunschweig.svg/langde-330px-Logo_Eintracht_Braunschweig.svg.png",
"bremen": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/be/SV-Werder-Bremen-Logo.svg/langde-330px-SV-Werder-Bremen-Logo.svg.png",
"bvb": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/67/Borussia_Dortmund_logo.svg/langde-330px-Borussia_Dortmund_logo.svg.png",
"cottbus": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/55/Logo_Energie_Cottbus.svg/langde-330px-Logo_Energie_Cottbus.svg.png",
"darmstadt": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e5/SV_Darmstadt_98_Logo.svg/langde-330px-SV_Darmstadt_98_Logo.svg.png",
"dresden": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e1/Logo_SG_Dynamo_Dresden_neu.svg/langde-330px-Logo_SG_Dynamo_Dresden_neu.svg.png",
"duesseldorf": "https://thumb.wikimedia.org/wikipedia/commons/thumb/9/94/Fortuna_D%C3%BCsseldorf.svg/langde-330px-Fortuna_D%C3%BCsseldorf.svg.png",
"duisburg": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/02/Msv_duisburg_%282017%29.svg/langde-330px-Msv_duisburg_%282017%29.svg.png",
"elversberg": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d4/SV_Elversberg_Logo_2021.svg/langde-330px-SV_Elversberg_Logo_2021.svg.png",
"essen": "https://upload.wikimedia.org/wikipedia/de/thumb/8/8a/Logo_Rot-Weiss_Essen.svg/langde-330px-Logo_Rot-Weiss_Essen.svg.png",
"frankfurt": "https://upload.wikimedia.org/wikipedia/de/thumb/3/32/Logo_Eintracht_Frankfurt_1998.svg/langde-330px-Logo_Eintracht_Frankfurt_1998.svg.png",
"freiburg": "https://upload.wikimedia.org/wikipedia/de/thumb/b/bf/SC_Freiburg_Logo.svg/langde-330px-SC_Freiburg_Logo.svg.png",
"fuerth": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b1/SpVgg_Greuther_F%C3%BCrth_2017.svg/langde-330px-SpVgg_Greuther_F%C3%BCrth_2017.svg.png",
"gladbach": "https://thumb.wikimedia.org/wikipedia/commons/thumb/8/81/Borussia_M%C3%B6nchengladbach_logo.svg/langde-330px-Borussia_M%C3%B6nchengladbach_logo.svg.png",
"hannover": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/cd/Hannover_96_Logo.svg/langde-330px-Hannover_96_Logo.svg.png",
"hansa": "https://thumb.wikimedia.org/wikipedia/commons/thumb/8/8f/F.C._Hansa_Rostock_Logo.svg/langde-330px-F.C._Hansa_Rostock_Logo.svg.png",
"havelse": "https://thumb.wikimedia.org/wikipedia/commons/thumb/8/89/TSV_Havelse_logo.svg/langde-330px-TSV_Havelse_logo.svg.png",
"heidenheim": "https://thumb.wikimedia.org/wikipedia/commons/thumb/9/9d/1._FC_Heidenheim_1846.svg/langde-330px-1._FC_Heidenheim_1846.svg.png",
"hertha": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/81/Hertha_BSC_Logo_2012.svg/langde-330px-Hertha_BSC_Logo_2012.svg.png",
"hoffenheim": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e7/Logo_TSG_Hoffenheim.svg/langde-330px-Logo_TSG_Hoffenheim.svg.png",
"hoffenheim-ii": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e7/Logo_TSG_Hoffenheim.svg/langde-330px-Logo_TSG_Hoffenheim.svg.png",
"holstein": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/30/Holstein_Kiel_Logo.svg/langde-330px-Holstein_Kiel_Logo.svg.png",
"hsv": "https://thumb.wikimedia.org/wikipedia/commons/thumb/f/f7/Hamburger_SV_logo.svg/langde-330px-Hamburger_SV_logo.svg.png",
"ingolstadt": "https://thumb.wikimedia.org/wikipedia/de/thumb/5/55/FC-Ingolstadt_logo.svg/langde-330px-FC-Ingolstadt_logo.svg.png",
"kaiserslautern": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d3/Logo_1_FC_Kaiserslautern.svg/langde-330px-Logo_1_FC_Kaiserslautern.svg.png",
"karlsruhe": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c8/Karlsruher_SC_Logo_2.svg/langde-330px-Karlsruher_SC_Logo_2.svg.png",
"koeln": "https://thumb.wikimedia.org/wikipedia/commons/thumb/0/01/1._FC_Koeln_Logo_2014%E2%80%93.svg/langde-330px-1._FC_Koeln_Logo_2014%E2%80%93.svg.png",
"leipzig": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/ab/2019-07-12_Fu%C3%9Fball%3B_Freundschaftsspiel_RB_Leipzig_-_FC_Z%C3%BCrich_1DX_0881_by_Stepro_2.png/330px-2019-07-12_Fu%C3%9Fball%3B_Freundschaftsspiel_RB_Leipzig_-_FC_Z%C3%BCrich_1DX_0881_by_Stepro_2.png",
"leverkusen": "https://upload.wikimedia.org/wikipedia/de/thumb/f/f7/Bayer_Leverkusen_Logo.svg/langde-330px-Bayer_Leverkusen_Logo.svg.png",
"magdeburg": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/84/1._FC_Magdeburg.svg/langde-330px-1._FC_Magdeburg.svg.png",
"mainz": "https://thumb.wikimedia.org/wikipedia/commons/thumb/9/9e/Logo_Mainz_05.svg/langde-330px-Logo_Mainz_05.svg.png",
"mannheim": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/17/Svwaldhof.svg/langde-330px-Svwaldhof.svg.png",
"muenster": "https://thumb.wikimedia.org/wikipedia/de/thumb/7/7e/SC_Preussen_Muenster_Logo_2018.svg/langde-330px-SC_Preussen_Muenster_Logo_2018.svg.png",
"nuernberg": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fa/1._FC_N%C3%BCrnberg_logo.svg/langde-330px-1._FC_N%C3%BCrnberg_logo.svg.png",
"osnabrueck": "https://thumb.wikimedia.org/wikipedia/commons/thumb/4/4e/VfL_Osnabrueck_Logo_2021%E2%80%93.svg/langde-330px-VfL_Osnabrueck_Logo_2021%E2%80%93.svg.png",
"paderborn": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/67/SC_Paderborn_07_Logo_new.svg/langde-330px-SC_Paderborn_07_Logo_new.svg.png",
"regensburg": "https://thumb.wikimedia.org/wikipedia/commons/thumb/3/3d/Jahn_Regensburg_logo2014.svg/langde-330px-Jahn_Regensburg_logo2014.svg.png",
"saarbruecken": "https://thumb.wikimedia.org/wikipedia/de/thumb/f/ff/1._FC_Saarbr%C3%BCcken.svg/langde-330px-1._FC_Saarbr%C3%BCcken.svg.png",
"schalke": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6d/FC_Schalke_04_Logo.svg/langde-330px-FC_Schalke_04_Logo.svg.png",
"schweinfurt": "https://thumb.wikimedia.org/wikipedia/de/thumb/7/7c/1._FC_Schweinfurt_05.svg/langde-330px-1._FC_Schweinfurt_05.svg.png",
"st-pauli": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b3/Fc_st_pauli_logo.svg/langde-330px-Fc_st_pauli_logo.svg.png",
"stuttgart": "https://thumb.wikimedia.org/wikipedia/commons/thumb/e/eb/VfB_Stuttgart_1893_Logo.svg/langde-330px-VfB_Stuttgart_1893_Logo.svg.png",
"stuttgart-ii": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/eb/VfB_Stuttgart_1893_Logo.svg/langde-330px-VfB_Stuttgart_1893_Logo.svg.png",
"ulm": "https://thumb.wikimedia.org/wikipedia/commons/thumb/6/6c/SSV_Ulm_1846_Fussball.svg/langde-330px-SSV_Ulm_1846_Fussball.svg.png",
"union": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/44/1._FC_Union_Berlin_Logo.svg/langde-330px-1._FC_Union_Berlin_Logo.svg.png",
"verl": "https://thumb.wikimedia.org/wikipedia/commons/thumb/c/ce/SC_Verl_Logo.svg/langde-330px-SC_Verl_Logo.svg.png",
"viktoria": "https://thumb.wikimedia.org/wikipedia/commons/thumb/d/dc/FC_Viktoria_K%C3%B6ln_1904_Logo.svg/langde-330px-FC_Viktoria_K%C3%B6ln_1904_Logo.svg.png",
"wehen": "https://upload.wikimedia.org/wikipedia/de/e/e0/SV_Wehen_Logo.png",
"wolfsburg": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c5/VfL_Wolfsburg_logo_2026.svg/langde-330px-VfL_Wolfsburg_logo_2026.svg.png",
"wuppertal": "https://thumb.wikimedia.org/wikipedia/de/thumb/9/9b/WuppertalerSVLogo.svg/langde-330px-WuppertalerSVLogo.svg.png"
}

View File

@@ -14,14 +14,43 @@ import urllib.parse
import urllib.request
OUT = sys.argv[1] if len(sys.argv) > 1 else "app/src/main/assets/crests"
URLS = "tools/crest-sources.json"
SRC = "app/src/main/java/dev/castarr/tv/data/TeamFilters.kt"
SUMMARY = "https://de.wikipedia.org/api/rest_v1/page/summary/"
UA = {"User-Agent": "Castarr build script (private use)"}
os.makedirs(OUT, exist_ok=True)
# Image URLs are plain text and safe to commit; the artwork itself is not.
sources = {}
if os.path.exists(URLS):
sources = json.load(open(URLS, encoding="utf-8"))
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
@@ -35,15 +64,19 @@ for key, full_name in clubs:
skipped += 1
continue
try:
thumb = sources.get(key)
if not thumb:
url = SUMMARY + urllib.parse.quote(full_name)
with urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=20) as r:
thumb = json.load(r).get("thumbnail", {}).get("source")
if not thumb:
raise ValueError("no thumbnail")
sources[key] = thumb.split("?")[0]
thumb = sources[key]
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)
@@ -51,4 +84,8 @@ for key, full_name in clubs:
# Wikipedia rate-limits bursts; this runs rarely and caches.
time.sleep(1.2)
with open(URLS, "w", encoding="utf-8") as f:
json.dump(dict(sorted(sources.items())), f, indent=2, ensure_ascii=False)
f.write("\n")
print(f"crests: {fetched} geladen, {skipped} vorhanden, {failed} fehlgeschlagen")