Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0b8f8801d | ||
|
|
9a0fa37e55 | ||
|
|
73f6dbba75 | ||
|
|
f6f6a2499f | ||
|
|
8aa3a4da02 | ||
|
|
b7ee0523d1 | ||
|
|
a3105925bb |
59
.github/workflows/build.yml
vendored
59
.github/workflows/build.yml
vendored
@@ -4,36 +4,53 @@ on:
|
|||||||
push:
|
push:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# No marketplace actions: this Gitea runner resolves bare action names
|
||||||
|
# against the local instance, and even with full URLs it cannot resolve
|
||||||
|
# annotated tags ("unsupported object type"). Everything below is plain
|
||||||
|
# shell, which also keeps the job readable.
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||||
|
CMDLINE_TOOLS: https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
||||||
steps:
|
steps:
|
||||||
# Full URLs: this Gitea resolves bare action names against itself,
|
- name: Checkout
|
||||||
# where these actions do not exist.
|
run: |
|
||||||
- uses: https://github.com/actions/checkout@v4
|
git init -q .
|
||||||
|
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||||
|
git fetch -q --depth 1 origin "${GITHUB_SHA}"
|
||||||
|
git checkout -q FETCH_HEAD
|
||||||
|
|
||||||
- uses: https://github.com/actions/setup-java@v4
|
- name: Install JDK
|
||||||
with:
|
run: |
|
||||||
distribution: temurin
|
apt-get update -qq
|
||||||
java-version: "17"
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq openjdk-17-jdk-headless > /dev/null
|
||||||
|
java -version
|
||||||
|
|
||||||
- uses: https://github.com/gradle/actions/setup-gradle@v4
|
- name: Install Android SDK
|
||||||
|
run: |
|
||||||
|
mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools"
|
||||||
|
curl -sSL -o /tmp/tools.zip "$CMDLINE_TOOLS"
|
||||||
|
unzip -q /tmp/tools.zip -d "$ANDROID_SDK_ROOT/cmdline-tools"
|
||||||
|
mv "$ANDROID_SDK_ROOT/cmdline-tools/cmdline-tools" "$ANDROID_SDK_ROOT/cmdline-tools/latest"
|
||||||
|
# Accept licences by writing the hashes: piping "yes" into
|
||||||
|
# sdkmanager dies of SIGPIPE (exit 141) under pipefail.
|
||||||
|
mkdir -p "$ANDROID_SDK_ROOT/licenses"
|
||||||
|
echo "24333f8a63b6825ea9c5514f83c2829b004d1fee" > "$ANDROID_SDK_ROOT/licenses/android-sdk-license"
|
||||||
|
echo "84831b9409646a918e30573bab4c9c91346d8abd" > "$ANDROID_SDK_ROOT/licenses/android-sdk-preview-license"
|
||||||
|
"$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" \
|
||||||
|
"platforms;android-35" "build-tools;35.0.0" "platform-tools" > /dev/null
|
||||||
|
echo "sdk.dir=$ANDROID_SDK_ROOT" > local.properties
|
||||||
|
|
||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
run: ./gradlew testDebugUnitTest --stacktrace
|
run: ./gradlew testDebugUnitTest --no-daemon --stacktrace
|
||||||
|
|
||||||
- name: Build debug APK
|
- name: Build debug APK
|
||||||
run: ./gradlew assembleDebug --stacktrace
|
run: ./gradlew assembleDebug --no-daemon --stacktrace
|
||||||
|
|
||||||
- name: Upload APK
|
- name: Summary
|
||||||
uses: https://github.com/actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: castarr-debug-apk
|
|
||||||
path: app/build/outputs/apk/debug/app-debug.apk
|
|
||||||
|
|
||||||
- name: Upload test report
|
|
||||||
if: always()
|
if: always()
|
||||||
uses: https://github.com/actions/upload-artifact@v4
|
run: |
|
||||||
with:
|
echo "APK:"; ls -la app/build/outputs/apk/debug/ 2>/dev/null || echo " (kein Build)"
|
||||||
name: castarr-test-report
|
echo "Tests:"; ls tests/runs/junit/ 2>/dev/null || echo " (keine Reports)"
|
||||||
path: tests/runs/
|
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ local.properties
|
|||||||
*.iml
|
*.iml
|
||||||
.kotlin/
|
.kotlin/
|
||||||
tests/runs/
|
tests/runs/
|
||||||
|
app/src/main/assets/crests/
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ android {
|
|||||||
applicationId = "dev.castarr.tv"
|
applicationId = "dev.castarr.tv"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 28
|
versionCode = 34
|
||||||
versionName = "0.10.2"
|
versionName = "0.11.1"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release signing from environment (see ~/.keys/castarr-release.env on the
|
// Release signing from environment (see ~/.keys/castarr-release.env on the
|
||||||
|
|||||||
@@ -267,7 +267,7 @@
|
|||||||
ws: null, connected: false, authorized: false,
|
ws: null, connected: false, authorized: false,
|
||||||
status: { state: 'idle', channel: '', group: '', live: false, volume: 0.5 },
|
status: { state: 'idle', channel: '', group: '', live: false, volume: 0.5 },
|
||||||
channels: [], playlistUrl: '', extras: { nowNext: [], favorites: [], favoritesSupported: false }, favOnly: false,
|
channels: [], playlistUrl: '', extras: { nowNext: [], favorites: [], favoritesSupported: false }, favOnly: false,
|
||||||
retryDelay: 1000, volumeDragging: false, searchTerm: '',
|
retryDelay: 1000, volumeDragging: false, searchTerm: '', teamKey: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- pairing credentials ---
|
// --- pairing credentials ---
|
||||||
@@ -450,16 +450,31 @@
|
|||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
const favSet = new Set(state.extras.favorites || []);
|
const favSet = new Set(state.extras.favorites || []);
|
||||||
const favOn = state.extras.favoritesSupported && state.favOnly;
|
const favOn = state.extras.favoritesSupported && state.favOnly;
|
||||||
const shown = favOn ? filtered.filter((c) => favSet.has(c.backendId)) : filtered;
|
const teams = state.extras.teams || [];
|
||||||
if (state.extras.favoritesSupported) {
|
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');
|
const bar = document.createElement('div');
|
||||||
bar.style.cssText = 'display:flex;gap:8px;padding:2px 12px 10px';
|
bar.style.cssText = 'display:flex;gap:8px;padding:2px 12px 10px;overflow-x:auto';
|
||||||
[['Alle', false], ['★ Favoriten', true]].forEach(([label, val]) => {
|
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');
|
const chip = document.createElement('button');
|
||||||
chip.textContent = label;
|
chip.textContent = label;
|
||||||
chip.style.cssText = 'padding:7px 14px;border-radius:999px;font-size:12px;background:' +
|
chip.style.cssText = 'flex:none;padding:7px 14px;border-radius:999px;font-size:12px;background:' +
|
||||||
(state.favOnly === val ? 'rgba(95,212,196,.12);color:#5fd4c4' : '#121418;color:#9aa0a8');
|
(active ? 'rgba(95,212,196,.12);color:#5fd4c4' : '#121418;color:#9aa0a8');
|
||||||
chip.addEventListener('click', () => { state.favOnly = val; renderChannels(); });
|
chip.addEventListener('click', () => {
|
||||||
|
state.teamKey = key;
|
||||||
|
state.favOnly = fav;
|
||||||
|
listSignature = '';
|
||||||
|
renderChannels();
|
||||||
|
});
|
||||||
bar.appendChild(chip);
|
bar.appendChild(chip);
|
||||||
});
|
});
|
||||||
list.appendChild(bar);
|
list.appendChild(bar);
|
||||||
@@ -467,8 +482,9 @@
|
|||||||
if (!shown.length) {
|
if (!shown.length) {
|
||||||
const empty = document.createElement('div');
|
const empty = document.createElement('div');
|
||||||
empty.className = 'empty';
|
empty.className = 'empty';
|
||||||
empty.textContent = favOn
|
empty.textContent = club
|
||||||
? 'Keine Favoriten — Stern auf einem Sender antippen.'
|
? ('Heute läuft nichts mehr mit ' + club.name + '.')
|
||||||
|
: favOn ? 'Keine Favoriten — Stern auf einem Sender antippen.'
|
||||||
: 'Keine Sender gefunden.';
|
: 'Keine Sender gefunden.';
|
||||||
list.appendChild(empty);
|
list.appendChild(empty);
|
||||||
return;
|
return;
|
||||||
@@ -493,6 +509,18 @@
|
|||||||
const name = document.createElement('span');
|
const name = document.createElement('span');
|
||||||
name.className = 'name'; name.textContent = c.name;
|
name.className = 'name'; name.textContent = c.name;
|
||||||
meta.appendChild(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];
|
const info = (state.extras.nowNext || [])[idx];
|
||||||
if (info && info.now) {
|
if (info && info.now) {
|
||||||
const now = document.createElement('span');
|
const now = document.createElement('span');
|
||||||
@@ -513,6 +541,7 @@
|
|||||||
grp.className = 'grp'; grp.textContent = c.group;
|
grp.className = 'grp'; grp.textContent = c.group;
|
||||||
meta.appendChild(grp);
|
meta.appendChild(grp);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
btn.appendChild(num); btn.appendChild(meta);
|
btn.appendChild(num); btn.appendChild(meta);
|
||||||
if (state.extras.favoritesSupported && c.backendId) {
|
if (state.extras.favoritesSupported && c.backendId) {
|
||||||
const star = document.createElement('span');
|
const star = document.createElement('span');
|
||||||
@@ -539,7 +568,8 @@
|
|||||||
let listSignature = '';
|
let listSignature = '';
|
||||||
function renderChannelsIfChanged() {
|
function renderChannelsIfChanged() {
|
||||||
const sig = [
|
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.extras.favorites || []).join(','),
|
||||||
state.status.channel || '',
|
state.status.channel || '',
|
||||||
].join('|');
|
].join('|');
|
||||||
|
|||||||
@@ -170,22 +170,31 @@ class AppState(
|
|||||||
SourceMode.DISPATCHARR -> dispatcharr.upcoming(channel, windowMs)
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
* is running right now sits on top.
|
* 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 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()
|
return activeChannels()
|
||||||
.filter { dev.castarr.tv.data.TeamFilters.scansGroup(it.group) }
|
.filter { dev.castarr.tv.data.TeamFilters.scansGroup(it.group) }
|
||||||
.mapNotNull { channel ->
|
.mapNotNull { channel ->
|
||||||
upcoming(channel, window)
|
// A channel can carry several of the club's broadcasts in one
|
||||||
.firstOrNull { !isEpgPlaceholder(it.title) && filter.matches(it.title) }
|
// evening; the row names the next and counts the rest.
|
||||||
?.let { channel to it }
|
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() {
|
fun refreshActive() {
|
||||||
@@ -200,8 +209,14 @@ class AppState(
|
|||||||
result.fold(
|
result.fold(
|
||||||
onSuccess = { appError = AppError.NONE },
|
onSuccess = { appError = AppError.NONE },
|
||||||
onFailure = { throwable ->
|
onFailure = { throwable ->
|
||||||
// Cached channels keep the app usable; only surface a
|
// An expired login must always surface: cached channels
|
||||||
// fullscreen state when there is nothing to show.
|
// 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
|
if (activeChannels().isNotEmpty()) return
|
||||||
appError = when {
|
appError = when {
|
||||||
throwable.message == "not logged in" -> AppError.RELOGIN
|
throwable.message == "not logged in" -> AppError.RELOGIN
|
||||||
|
|||||||
@@ -50,7 +50,14 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
|||||||
server.startServer()
|
server.startServer()
|
||||||
state.remoteAvailable = server.running
|
state.remoteAvailable = server.running
|
||||||
setContent { CastarrApp(state) }
|
setContent { CastarrApp(state) }
|
||||||
lifecycleScope.launch { UpdateChecker.check(state) }
|
lifecycleScope.launch {
|
||||||
|
// At start-up and then occasionally: a TV often keeps the same
|
||||||
|
// app process alive for days.
|
||||||
|
while (true) {
|
||||||
|
UpdateChecker.check(state)
|
||||||
|
delay(UPDATE_CHECK_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onPlaybackChanged() {
|
private fun onPlaybackChanged() {
|
||||||
@@ -140,9 +147,36 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
|||||||
}
|
}
|
||||||
val favorites = org.json.JSONArray()
|
val favorites = org.json.JSONArray()
|
||||||
state.dispatcharr.favorites.value.forEach { favorites.put(it) }
|
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()
|
return JSONObject()
|
||||||
.put("nowNext", nowNext)
|
.put("nowNext", nowNext)
|
||||||
.put("favorites", favorites)
|
.put("favorites", favorites)
|
||||||
|
.put("teams", teams)
|
||||||
.put("favoritesSupported", state.sourceMode == AppState.SourceMode.DISPATCHARR)
|
.put("favoritesSupported", state.sourceMode == AppState.SourceMode.DISPATCHARR)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,5 +359,6 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
|||||||
const val TICK_INTERVAL_MS = 2_000L
|
const val TICK_INTERVAL_MS = 2_000L
|
||||||
const val SEEK_STEP_SECONDS = 10L
|
const val SEEK_STEP_SECONDS = 10L
|
||||||
const val DIGIT_COMMIT_MS = 1_800L
|
const val DIGIT_COMMIT_MS = 1_800L
|
||||||
|
const val UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,15 +18,22 @@ class Crests(context: Context) {
|
|||||||
|
|
||||||
private val prefs = context.getSharedPreferences("crests", Context.MODE_PRIVATE)
|
private val prefs = context.getSharedPreferences("crests", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
/** Cached crest URL for a club, fetching it once per article. */
|
/** Cached crest URL for a club, fetched once and then reused. */
|
||||||
suspend fun urlFor(article: String): String? = withContext(Dispatchers.IO) {
|
suspend fun urlFor(article: String): String? = withContext(Dispatchers.IO) {
|
||||||
prefs.getString(article, null)?.let { return@withContext it.ifEmpty { null } }
|
prefs.getString(article, null)?.takeIf { it.isNotEmpty() }?.let {
|
||||||
|
return@withContext it
|
||||||
|
}
|
||||||
val resolved = runCatching { fetch(article) }
|
val resolved = runCatching { fetch(article) }
|
||||||
.onFailure { Log.w(TAG, "crest lookup failed: ${it.javaClass.simpleName}") }
|
.onFailure { Log.w(TAG, "crest lookup failed for $article: $it") }
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
// Remember misses too, so a club without a crest is not looked up
|
if (resolved != null) {
|
||||||
// again on every screen.
|
Log.i(TAG, "crest for $article: $resolved")
|
||||||
prefs.edit().putString(article, resolved.orEmpty()).apply()
|
prefs.edit().putString(article, resolved).apply()
|
||||||
|
} else {
|
||||||
|
// Deliberately not cached: a single failed lookup (no network
|
||||||
|
// yet at start-up) must not hide the crest forever.
|
||||||
|
Log.w(TAG, "no crest for $article")
|
||||||
|
}
|
||||||
resolved
|
resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +48,8 @@ class Crests(context: Context) {
|
|||||||
val body = connection.inputStream.bufferedReader().use { it.readText() }
|
val body = connection.inputStream.bufferedReader().use { it.readText() }
|
||||||
JSONObject(body).optJSONObject("thumbnail")?.optString("source")
|
JSONObject(body).optJSONObject("thumbnail")?.optString("source")
|
||||||
?.takeIf { it.isNotEmpty() }
|
?.takeIf { it.isNotEmpty() }
|
||||||
|
// Drop the analytics query the API appends.
|
||||||
|
?.substringBefore("?")
|
||||||
} finally {
|
} finally {
|
||||||
connection.disconnect()
|
connection.disconnect()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,13 +49,20 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) {
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
val result = runCatching {
|
val result = runCatching {
|
||||||
status.value = "loading_channels"
|
status.value = "loading_channels"
|
||||||
|
Log.i(TAG, "refresh start (loggedIn=${auth.isLoggedIn})")
|
||||||
val token = auth.accessToken() ?: error("not logged in")
|
val token = auth.accessToken() ?: error("not logged in")
|
||||||
val groups = fetchGroups(token)
|
val groups = fetchGroups(token)
|
||||||
val list = fetchChannels(token, groups)
|
val list = fetchChannels(token, groups)
|
||||||
channels.value = list
|
channels.value = list
|
||||||
prefs.edit().putString("channels_cache", Channel.listToJson(list).toString()).apply()
|
prefs.edit().putString("channels_cache", Channel.listToJson(list).toString()).apply()
|
||||||
launch { runCatching { refreshFavorites(token) } }
|
launch {
|
||||||
launch { runCatching { refreshProfiles(token) } }
|
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 {
|
launch {
|
||||||
runCatching { refreshEpg(token) }
|
runCatching { refreshEpg(token) }
|
||||||
.onFailure { Log.w(TAG, "epg failed: ${it.javaClass.simpleName}: ${it.message?.take(160)}") }
|
.onFailure { Log.w(TAG, "epg failed: ${it.javaClass.simpleName}: ${it.message?.take(160)}") }
|
||||||
|
|||||||
@@ -29,8 +29,25 @@ data class TeamFilter(
|
|||||||
|
|
||||||
object TeamFilters {
|
object TeamFilters {
|
||||||
|
|
||||||
/** Window scanned ahead of now. */
|
/** Minimum window scanned ahead of now. */
|
||||||
const val WINDOW_MS = 3 * 60 * 60 * 1000L
|
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. */
|
/** At most this many club groups sit in the rail at once. */
|
||||||
const val MAX_ACTIVE = 3
|
const val MAX_ACTIVE = 3
|
||||||
@@ -97,7 +114,7 @@ object TeamFilters {
|
|||||||
club("duisburg", "MSV", "MSV Duisburg", listOf("msv duisburg", "duisburg"), 0xFF004E9E, 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("mannheim", "SVWM", "SV Waldhof Mannheim", listOf("waldhof mannheim", "waldhof"), 0xFF0B4EA2, WHITE),
|
||||||
club("wehen", "SVWW", "SV Wehen Wiesbaden", listOf("wehen wiesbaden", "wehen"), 0xFFE1000F, BLACK),
|
club("wehen", "SVWW", "SV Wehen Wiesbaden", listOf("wehen wiesbaden", "wehen"), 0xFFE1000F, BLACK),
|
||||||
club("ulm", "SSV", "SSV Ulm 1846", listOf("ssv ulm"), 0xFFE1000F, WHITE),
|
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("regensburg", "SSVJ", "SSV Jahn Regensburg", listOf("jahn regensburg", "regensburg"), 0xFFE1000F, WHITE),
|
||||||
club("verl", "SCV", "SC Verl", listOf("sc verl"), 0xFF00703C, WHITE),
|
club("verl", "SCV", "SC Verl", listOf("sc verl"), 0xFF00703C, WHITE),
|
||||||
club("viktoria", "VKÖ", "Viktoria Köln", listOf("viktoria köln"), 0xFFE1000F, WHITE),
|
club("viktoria", "VKÖ", "Viktoria Köln", listOf("viktoria köln"), 0xFFE1000F, WHITE),
|
||||||
@@ -108,7 +125,7 @@ object TeamFilters {
|
|||||||
club("ingolstadt", "FCI", "FC Ingolstadt 04", listOf("ingolstadt"), 0xFFE1000F, BLACK),
|
club("ingolstadt", "FCI", "FC Ingolstadt 04", listOf("ingolstadt"), 0xFFE1000F, BLACK),
|
||||||
club("wuppertal", "WSV", "Wuppertaler SV", listOf("wuppertaler sv"), 0xFFE1000F, 0xFF0B4EA2),
|
club("wuppertal", "WSV", "Wuppertaler SV", listOf("wuppertaler sv"), 0xFFE1000F, 0xFF0B4EA2),
|
||||||
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),
|
||||||
club("hoffenheim-ii", "TSG2", "TSG Hoffenheim II", listOf("hoffenheim ii"), 0xFF1C63B7, BLACK),
|
club("hoffenheim-ii", "TSG2", "TSG Hoffenheim II", listOf("hoffenheim ii"), 0xFF1C63B7, BLACK, article = "TSG 1899 Hoffenheim"),
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Clubs switched on for a viewer before they touch the settings. */
|
/** Clubs switched on for a viewer before they touch the settings. */
|
||||||
|
|||||||
@@ -64,19 +64,34 @@ class ControlServer(
|
|||||||
// from spending someone else's budget.
|
// from spending someone else's budget.
|
||||||
private val attempts = HashMap<String, ArrayDeque<Long>>()
|
private val attempts = HashMap<String, ArrayDeque<Long>>()
|
||||||
|
|
||||||
|
/** True while this address may still try; does not consume budget. */
|
||||||
@Synchronized
|
@Synchronized
|
||||||
private fun attemptAllowed(address: String): Boolean {
|
private fun attemptAllowed(address: String): Boolean {
|
||||||
val now = System.currentTimeMillis()
|
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) {
|
while (queue.isNotEmpty() && now - queue.first() > ATTEMPT_WINDOW_MS) {
|
||||||
queue.removeFirst()
|
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) {
|
if (attempts.size > MAX_TRACKED_ADDRESSES) {
|
||||||
attempts.entries.removeAll { it.value.isEmpty() }
|
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() {
|
fun startServer() {
|
||||||
@@ -292,8 +307,6 @@ class ControlServer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun handleHello(msg: JSONObject) {
|
private fun handleHello(msg: JSONObject) {
|
||||||
// Every failed attempt counts against this address, whether it
|
|
||||||
// carried a token or a code.
|
|
||||||
if (!attemptAllowed(remoteAddress)) {
|
if (!attemptAllowed(remoteAddress)) {
|
||||||
trySend(JSONObject().put("type", "error").put("error", "rate_limited").toString())
|
trySend(JSONObject().put("type", "error").put("error", "rate_limited").toString())
|
||||||
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "rate limited", false) }
|
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "rate limited", false) }
|
||||||
@@ -302,10 +315,12 @@ class ControlServer(
|
|||||||
val tokenOk = Pairing.isValidToken(context, msg.optString("token"))
|
val tokenOk = Pairing.isValidToken(context, msg.optString("token"))
|
||||||
val codeOk = !tokenOk && Pairing.isValidCode(context, msg.optString("code"))
|
val codeOk = !tokenOk && Pairing.isValidCode(context, msg.optString("code"))
|
||||||
if (!tokenOk && !codeOk) {
|
if (!tokenOk && !codeOk) {
|
||||||
|
recordFailure(remoteAddress)
|
||||||
trySend(JSONObject().put("type", "error").put("error", "bad_code").toString())
|
trySend(JSONObject().put("type", "error").put("error", "bad_code").toString())
|
||||||
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "bad code", false) }
|
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "bad code", false) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
clearFailures(remoteAddress)
|
||||||
authorized = true
|
authorized = true
|
||||||
deviceName = msg.optString("name").ifEmpty { "Handy" }
|
deviceName = msg.optString("name").ifEmpty { "Handy" }
|
||||||
// A code-authenticated client gets its own revocable token, never
|
// A code-authenticated client gets its own revocable token, never
|
||||||
|
|||||||
@@ -132,6 +132,10 @@ private fun TopBar(state: AppState) {
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.width(18.dp))
|
Spacer(Modifier.width(18.dp))
|
||||||
}
|
}
|
||||||
|
state.updateAvailable?.let { version ->
|
||||||
|
UpdateChip(version) { state.screen = AppState.Screen.SETTINGS }
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
}
|
||||||
GearButton {
|
GearButton {
|
||||||
state.screen = if (state.screen == AppState.Screen.LIVE) AppState.Screen.SETTINGS
|
state.screen = if (state.screen == AppState.Screen.LIVE) AppState.Screen.SETTINGS
|
||||||
else AppState.Screen.LIVE
|
else AppState.Screen.LIVE
|
||||||
@@ -139,6 +143,33 @@ private fun TopBar(state: AppState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown in the top bar as soon as the start-up check finds a newer
|
||||||
|
* release — otherwise an update would only be noticed by someone who
|
||||||
|
* happens to open the settings.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun UpdateChip(version: String, onClick: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)),
|
||||||
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = CastarrColors.accentDim,
|
||||||
|
contentColor = CastarrColors.accent,
|
||||||
|
focusedContainerColor = CastarrColors.accent,
|
||||||
|
focusedContentColor = CastarrColors.onAccent,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Update $version",
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Round settings button with a drawn gear (glyphs render as emoji). */
|
/** Round settings button with a drawn gear (glyphs render as emoji). */
|
||||||
@Composable
|
@Composable
|
||||||
private fun GearButton(onClick: () -> Unit) {
|
private fun GearButton(onClick: () -> Unit) {
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ fun LiveScreen(state: AppState) {
|
|||||||
Text(
|
Text(
|
||||||
when {
|
when {
|
||||||
activeTeam != null ->
|
activeTeam != null ->
|
||||||
"In den nächsten 3 Stunden läuft nichts mit ${activeTeam.fullName}."
|
"Heute läuft nichts mehr mit ${activeTeam.fullName}."
|
||||||
state.favoritesOnly ->
|
state.favoritesOnly ->
|
||||||
"Noch keine Favoriten — halte OK auf einem Sender gedrückt."
|
"Noch keine Favoriten — halte OK auf einem Sender gedrückt."
|
||||||
else -> "Diese Gruppe ist leer."
|
else -> "Diese Gruppe ist leer."
|
||||||
@@ -351,7 +351,7 @@ private fun ChannelRow(
|
|||||||
number: Int,
|
number: Int,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
nowNext: NowNext,
|
nowNext: NowNext,
|
||||||
highlight: dev.castarr.tv.data.Programme? = null,
|
highlight: AppState.TeamHit? = null,
|
||||||
playing: Boolean,
|
playing: Boolean,
|
||||||
favorite: Boolean,
|
favorite: Boolean,
|
||||||
epgStamp: Long,
|
epgStamp: Long,
|
||||||
@@ -433,28 +433,21 @@ private fun ChannelRow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The club badge, resolved from Wikipedia and cached on the device. Until
|
* The club badge. The images ship in the APK (fetched at build time by
|
||||||
* it arrives (or if it never does) a shield in the club colours stands in.
|
* tools/fetch-crests.py, never committed), so nothing has to load over the
|
||||||
|
* network; the coloured shield stands in if one is ever missing.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun Crest(team: dev.castarr.tv.data.TeamFilter, state: AppState) {
|
private fun Crest(team: dev.castarr.tv.data.TeamFilter, state: AppState) {
|
||||||
val url by androidx.compose.runtime.produceState<String?>(null, team.key) {
|
|
||||||
value = state.crests.urlFor(team.article)
|
|
||||||
}
|
|
||||||
Box(Modifier.size(20.dp), contentAlignment = Alignment.Center) {
|
Box(Modifier.size(20.dp), contentAlignment = Alignment.Center) {
|
||||||
val crestUrl = url
|
SubcomposeAsyncImage(
|
||||||
if (crestUrl != null) {
|
model = "file:///android_asset/crests/${team.key}.png",
|
||||||
SubcomposeAsyncImage(
|
contentDescription = null,
|
||||||
model = crestUrl,
|
contentScale = ContentScale.Fit,
|
||||||
contentDescription = null,
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Fit,
|
loading = { ShieldFallback(team) },
|
||||||
modifier = Modifier.fillMaxSize(),
|
error = { ShieldFallback(team) },
|
||||||
loading = { ShieldFallback(team) },
|
)
|
||||||
error = { ShieldFallback(team) },
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
ShieldFallback(team)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,9 +510,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
|
@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
|
val running = System.currentTimeMillis() in programme.start until programme.stop
|
||||||
Column(modifier) {
|
Column(modifier) {
|
||||||
Row(verticalAlignment = Alignment.Bottom) {
|
Row(verticalAlignment = Alignment.Bottom) {
|
||||||
@@ -543,7 +537,8 @@ private fun HighlightCell(programme: dev.castarr.tv.data.Programme, modifier: Mo
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
Text(
|
Text(
|
||||||
"${formatClock(programme.start)}–${formatClock(programme.stop)}",
|
"${formatClock(programme.start)}–${formatClock(programme.stop)}" +
|
||||||
|
if (hit.further > 0) " · +${hit.further} weitere" else "",
|
||||||
color = CastarrColors.faint,
|
color = CastarrColors.faint,
|
||||||
fontFamily = AppFont,
|
fontFamily = AppFont,
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import androidx.compose.foundation.Canvas
|
|||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -36,6 +38,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
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.Path
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
@@ -84,7 +87,14 @@ fun SettingsScreen(state: AppState) {
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(horizontal = 40.dp, vertical = 12.dp)
|
.padding(horizontal = 40.dp, vertical = 12.dp)
|
||||||
) {
|
) {
|
||||||
Column(Modifier.weight(1.25f), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
// The cards outgrew one screen once club menus arrived; focus
|
||||||
|
// movement scrolls this column along.
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.weight(1.25f)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
SettingsCard("Konto") {
|
SettingsCard("Konto") {
|
||||||
if (state.auth.isLoggedIn) {
|
if (state.auth.isLoggedIn) {
|
||||||
Row(
|
Row(
|
||||||
@@ -169,8 +179,8 @@ fun SettingsScreen(state: AppState) {
|
|||||||
|
|
||||||
SettingsCard("Vereinsmenüs") {
|
SettingsCard("Vereinsmenüs") {
|
||||||
Text(
|
Text(
|
||||||
"Eigene Gruppe mit allen Sendern, auf denen der Verein in den " +
|
"Eigene Gruppe mit allen Sendern, auf denen der Verein " +
|
||||||
"nächsten 3 Stunden läuft. Bis zu " +
|
"heute noch läuft. Bis zu " +
|
||||||
"${dev.castarr.tv.data.TeamFilters.MAX_ACTIVE} Vereine.",
|
"${dev.castarr.tv.data.TeamFilters.MAX_ACTIVE} Vereine.",
|
||||||
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 11.sp,
|
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 11.sp,
|
||||||
modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 6.dp),
|
modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 6.dp),
|
||||||
@@ -180,8 +190,8 @@ fun SettingsScreen(state: AppState) {
|
|||||||
active.forEach { club ->
|
active.forEach { club ->
|
||||||
SettingRow(
|
SettingRow(
|
||||||
club.fullName,
|
club.fullName,
|
||||||
subtitle = "Erscheint als ${club.label} in der Senderliste",
|
subtitle = "Erscheint als ${club.label} in der Senderliste · Entfernen",
|
||||||
trailing = { TogglePill(on = true) },
|
trailing = { RemoveIcon() },
|
||||||
) { state.toggleTeam(club.key) }
|
) { state.toggleTeam(club.key) }
|
||||||
}
|
}
|
||||||
if (active.size < dev.castarr.tv.data.TeamFilters.MAX_ACTIVE) {
|
if (active.size < dev.castarr.tv.data.TeamFilters.MAX_ACTIVE) {
|
||||||
@@ -242,6 +252,10 @@ fun SettingsScreen(state: AppState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
SettingRow(
|
||||||
|
"Senderliste neu laden",
|
||||||
|
subtitle = "Holt Sender, Programm und Favoriten erneut",
|
||||||
|
) { state.refreshActive() }
|
||||||
SettingRow(
|
SettingRow(
|
||||||
"Erweitert",
|
"Erweitert",
|
||||||
subtitle = "M3U/EPG-Adressen von Hand eintragen",
|
subtitle = "M3U/EPG-Adressen von Hand eintragen",
|
||||||
@@ -439,6 +453,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
|
@Composable
|
||||||
private fun TogglePill(on: Boolean) {
|
private fun TogglePill(on: Boolean) {
|
||||||
Box(
|
Box(
|
||||||
|
|||||||
@@ -51,6 +51,25 @@ class TeamFiltersTest {
|
|||||||
assertTrue(TeamFilters.all.all { c -> c.needles.all { it == it.lowercase() } })
|
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
|
@Test
|
||||||
fun `covers all three divisions`() {
|
fun `covers all three divisions`() {
|
||||||
assertTrue(TeamFilters.all.size > 50)
|
assertTrue(TeamFilters.all.size > 50)
|
||||||
|
|||||||
58
tools/crest-sources.json
Normal file
58
tools/crest-sources.json
Normal 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"
|
||||||
|
}
|
||||||
67
tools/fetch-crests.py
Normal file
67
tools/fetch-crests.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Downloads club crests into the APK's assets at build time.
|
||||||
|
|
||||||
|
The badges are trademarks: they are fetched into a generated, git-ignored
|
||||||
|
folder so the repository stays free of them while the app ships with them
|
||||||
|
and needs no network at runtime.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
OUT = sys.argv[1] if len(sys.argv) > 1 else "app/src/main/assets/crests"
|
||||||
|
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 = []
|
||||||
|
for raw in re.findall(r'club\(\s*"([^"]+)",\s*"[^"]*",\s*"([^"]+)"[^\n]*', kotlin):
|
||||||
|
clubs.append(raw)
|
||||||
|
# an explicit article = "..." wins over the club name
|
||||||
|
overrides = dict(re.findall(r'club\(\s*"([^"]+)"[^\n]*article = "([^"]+)"', kotlin))
|
||||||
|
clubs = [(k, overrides.get(k, n)) for k, n in clubs]
|
||||||
|
|
||||||
|
fetched = skipped = failed = 0
|
||||||
|
for key, full_name in clubs:
|
||||||
|
target = os.path.join(OUT, f"{key}.png")
|
||||||
|
if os.path.exists(target) and os.path.getsize(target) > 0:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
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)
|
||||||
|
fetched += 1
|
||||||
|
except Exception as exc: # noqa: BLE001 - best effort, shield is the fallback
|
||||||
|
print(f" {key}: {exc}", file=sys.stderr)
|
||||||
|
failed += 1
|
||||||
|
# Wikipedia rate-limits bursts; this runs rarely and caches.
|
||||||
|
time.sleep(1.2)
|
||||||
|
|
||||||
|
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")
|
||||||
Reference in New Issue
Block a user