Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e491f8e50f | ||
|
|
56b5d3f0b0 | ||
|
|
b0b8f8801d | ||
|
|
9a0fa37e55 | ||
|
|
73f6dbba75 | ||
|
|
f6f6a2499f | ||
|
|
8aa3a4da02 | ||
|
|
b7ee0523d1 | ||
|
|
a3105925bb | ||
|
|
7f1dff5661 | ||
|
|
869060c56f | ||
|
|
7ad7f5172e | ||
|
|
753ab3a3ab | ||
|
|
f157323626 | ||
|
|
c0adce2b09 | ||
|
|
7abd4b2a48 | ||
|
|
f69ae2f437 | ||
|
|
01a76bffcd | ||
|
|
13352994e4 | ||
|
|
6acbe77200 | ||
|
|
7da6f5c69f | ||
|
|
e66c8a863b | ||
|
|
ca401e8ca3 | ||
|
|
844ea928ef |
53
.github/workflows/build.yml
vendored
53
.github/workflows/build.yml
vendored
@@ -4,24 +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:
|
||||||
- uses: actions/checkout@v4
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
git init -q .
|
||||||
|
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||||
|
git fetch -q --depth 1 origin "${GITHUB_SHA}"
|
||||||
|
git checkout -q FETCH_HEAD
|
||||||
|
|
||||||
- uses: actions/setup-java@v4
|
- name: Install JDK
|
||||||
with:
|
run: |
|
||||||
distribution: temurin
|
apt-get update -qq
|
||||||
java-version: "17"
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq openjdk-17-jdk-headless > /dev/null
|
||||||
|
java -version
|
||||||
|
|
||||||
- uses: gradle/actions/setup-gradle@v4
|
- name: Install Android SDK
|
||||||
|
run: |
|
||||||
|
mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools"
|
||||||
|
curl -sSL -o /tmp/tools.zip "$CMDLINE_TOOLS"
|
||||||
|
unzip -q /tmp/tools.zip -d "$ANDROID_SDK_ROOT/cmdline-tools"
|
||||||
|
mv "$ANDROID_SDK_ROOT/cmdline-tools/cmdline-tools" "$ANDROID_SDK_ROOT/cmdline-tools/latest"
|
||||||
|
# Accept licences by writing the hashes: piping "yes" into
|
||||||
|
# sdkmanager dies of SIGPIPE (exit 141) under pipefail.
|
||||||
|
mkdir -p "$ANDROID_SDK_ROOT/licenses"
|
||||||
|
echo "24333f8a63b6825ea9c5514f83c2829b004d1fee" > "$ANDROID_SDK_ROOT/licenses/android-sdk-license"
|
||||||
|
echo "84831b9409646a918e30573bab4c9c91346d8abd" > "$ANDROID_SDK_ROOT/licenses/android-sdk-preview-license"
|
||||||
|
"$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" \
|
||||||
|
"platforms;android-35" "build-tools;35.0.0" "platform-tools" > /dev/null
|
||||||
|
echo "sdk.dir=$ANDROID_SDK_ROOT" > local.properties
|
||||||
|
|
||||||
|
- name: Unit tests
|
||||||
|
run: ./gradlew testDebugUnitTest --no-daemon --stacktrace
|
||||||
|
|
||||||
- name: Build debug APK
|
- name: Build debug APK
|
||||||
run: ./gradlew assembleDebug --stacktrace
|
run: ./gradlew assembleDebug --no-daemon --stacktrace
|
||||||
|
|
||||||
- name: Upload APK
|
- name: Summary
|
||||||
uses: actions/upload-artifact@v4
|
if: always()
|
||||||
with:
|
run: |
|
||||||
name: castarr-debug-apk
|
echo "APK:"; ls -la app/build/outputs/apk/debug/ 2>/dev/null || echo " (kein Build)"
|
||||||
path: app/build/outputs/apk/debug/app-debug.apk
|
echo "Tests:"; ls tests/runs/junit/ 2>/dev/null || echo " (keine Reports)"
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,3 +4,5 @@ local.properties
|
|||||||
.idea/
|
.idea/
|
||||||
*.iml
|
*.iml
|
||||||
.kotlin/
|
.kotlin/
|
||||||
|
tests/runs/
|
||||||
|
app/src/main/assets/crests/
|
||||||
|
|||||||
109
README.md
109
README.md
@@ -1,49 +1,82 @@
|
|||||||
# Castarr
|
# Castarr
|
||||||
|
|
||||||
Native Google TV client for a [Dispatcharr](https://github.com/be-nj/Dispatcharr)
|
**Live-TV auf dem Fernseher, eingerichtet in einer Minute — alles läuft übers Handy.**
|
||||||
backend: live IPTV with a full D-pad UI (Compose for TV), login via the OIDC
|
|
||||||
device flow (Authentik), per-user favorites, per-stream output profiles, and
|
|
||||||
an optional phone remote paired by QR code — served by the TV itself, no phone
|
|
||||||
app install.
|
|
||||||
|
|
||||||
## Features
|
Castarr ist eine App für Google TV / Android TV. Auf dem Fernseher muss nie
|
||||||
|
etwas getippt werden: Zum Einrichten scannt man einmal einen QR-Code mit dem
|
||||||
|
Handy, meldet sich dort an — fertig. Danach zappt man mit der normalen
|
||||||
|
TV-Fernbedienung oder steuert alles bequem vom Handy aus.
|
||||||
|
|
||||||
- **Live TV** channel list with Now/Next EPG and progress, zapping via
|
| Senderliste | Player |
|
||||||
channel/D-pad keys, fullscreen playback (ExoPlayer, HLS + MPEG-TS) with a
|
| --- | --- |
|
||||||
minimal auto-hiding overlay
|
|  |  |
|
||||||
- **Login without typing**: the TV shows a QR code and a user code; confirm on
|
|
||||||
your phone at your identity provider (OIDC device authorization grant). The
|
|
||||||
only thing entered on the TV is the Dispatcharr URL.
|
|
||||||
- **Dispatcharr source**: channels, groups and EPG via the fork's
|
|
||||||
Bearer-authenticated API; favorites are stored per user on the backend
|
|
||||||
(long-press a channel to star it, filter chip in the list)
|
|
||||||
- **Stream profiles**: pick Standard / Passthrough / audiofix / 720p (list
|
|
||||||
comes from the backend) — applied per stream request
|
|
||||||
- **Generic source fallback**: any M3U (+ optional XMLTV) URL works without a
|
|
||||||
login — ErsatzTV, Threadfin, Tunarr, plain playlists
|
|
||||||
- **Phone remote** (optional): scan the QR under Einstellungen; play/pause,
|
|
||||||
zapping, volume, channel list with Now/Next and favorite toggling in the
|
|
||||||
browser — no app install, rate-limited pairing code as fallback
|
|
||||||
- Physical remote keys and Google Assistant keep working (Media3
|
|
||||||
MediaSession, TV app quality guidelines TV-PC/TV-PP/TV-VC)
|
|
||||||
|
|
||||||
## Building
|
| Nummerntasten | Einrichtung |
|
||||||
|
| --- | --- |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
Requires JDK 17+ and the Android SDK (platform 35). A JDK toolchain is
|
## Was die App kann
|
||||||
resolved automatically (foojay) if the host only has a JRE.
|
|
||||||
|
- **Senderliste mit Logos und Programm** — was gerade läuft, wie lange noch,
|
||||||
|
was danach kommt. Gruppen und Favoriten links, Sender rechts.
|
||||||
|
- **Zappen wie früher** — Nummerntasten auf der Fernbedienung schalten direkt
|
||||||
|
um (mit großer Anzeige, welcher Sender gleich kommt), hoch/runter blättert
|
||||||
|
durch die Sender.
|
||||||
|
- **Aufgeräumter Player** — OK-Taste zeigt Sendung, Zeiten und Fortschritt,
|
||||||
|
dazu Pause/Stopp-Buttons und Tonspur-Wahl, wenn der Sender mehrere hat.
|
||||||
|
Blendet sich von selbst wieder aus.
|
||||||
|
- **Handy als Fernbedienung** — QR-Code in den Einstellungen scannen, schon
|
||||||
|
hat man Senderliste, Suche, Lautstärke und Favoriten im Handy-Browser.
|
||||||
|
Keine App-Installation nötig.
|
||||||
|
- **Favoriten** — OK auf einem Sender gedrückt halten, Stern gesetzt. Die
|
||||||
|
Favoriten gelten pro Person und sind auf jedem Gerät dieselben.
|
||||||
|
- **Updates aus der App heraus** — unter Einstellungen → App genügt ein
|
||||||
|
Klick, wenn eine neue Version bereitsteht.
|
||||||
|
|
||||||
|
## Installation auf dem Fernseher
|
||||||
|
|
||||||
|
1. Auf dem Google TV die App **Downloader** installieren (oder einen anderen
|
||||||
|
Weg nutzen, eine APK-Datei zu öffnen).
|
||||||
|
2. Diese Adresse eingeben:
|
||||||
|
`git.beckm4nn.net/be-nj/castarr/raw/branch/apk/castarr.apk`
|
||||||
|
3. Installation bestätigen („Unbekannte Quellen" für Downloader erlauben,
|
||||||
|
wenn der Fernseher fragt).
|
||||||
|
4. Castarr starten und den drei Schritten auf dem Bildschirm folgen:
|
||||||
|
QR-Code scannen, auf dem Handy Server angeben und anmelden — den Rest
|
||||||
|
macht der Fernseher.
|
||||||
|
|
||||||
|
Danach hält sich die App über den eingebauten Updater selbst aktuell.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Für Technikinteressierte
|
||||||
|
|
||||||
|
Native Kotlin-App (Jetpack Compose for TV, Media3/ExoPlayer für HLS und
|
||||||
|
MPEG-TS). Als Backend dient ein [Dispatcharr](https://github.com/be-nj/Dispatcharr)-Fork
|
||||||
|
mit OIDC-Unterstützung: Anmeldung per Device Authorization Grant (der
|
||||||
|
Fernseher zeigt QR + Code, bestätigt wird am Handy beim Identity Provider),
|
||||||
|
API-Zugriff per Bearer-Token, Favoriten und Ausgabeprofile pro Nutzer.
|
||||||
|
Ohne Dispatcharr funktioniert auch jede M3U-Playlist (+ optionales XMLTV)
|
||||||
|
als generische Quelle — ErsatzTV, Threadfin, Tunarr, einfache Playlists.
|
||||||
|
|
||||||
|
Die Handy-Fernbedienung serviert der Fernseher selbst (eingebetteter
|
||||||
|
HTTP/WebSocket-Server, Kopplung per QR-Token mit ratenlimitiertem
|
||||||
|
Zahlencode als Fallback). Physische Mediatasten und Google Assistant
|
||||||
|
funktionieren über eine Media3 MediaSession.
|
||||||
|
|
||||||
|
### Selbst bauen
|
||||||
|
|
||||||
|
JDK 17+ und Android SDK (Platform 35) erforderlich; eine passende
|
||||||
|
JDK-Toolchain wird automatisch aufgelöst (foojay).
|
||||||
|
|
||||||
```
|
```
|
||||||
./gradlew assembleDebug # debug APK
|
./gradlew assembleDebug # Debug-APK
|
||||||
./gradlew assembleRelease # signed when CASTARR_STORE_* env vars are set
|
./gradlew assembleRelease # signiert, wenn CASTARR_STORE_*-Env gesetzt ist
|
||||||
```
|
```
|
||||||
|
|
||||||
APK output: `app/build/outputs/apk/<variant>/`. Releases on the
|
APK-Ausgabe: `app/build/outputs/apk/<variant>/`. Die Releases hier im Repo
|
||||||
[releases page](https://git.beckm4nn.net/benjamin/castarr/releases) are signed; install
|
sind signiert; über einem Debug-Build muss einmal deinstalliert werden
|
||||||
over an existing debug build requires uninstalling first (signature change).
|
(Signaturwechsel). Updates bezieht die App über die Release-API dieses
|
||||||
|
Repos, die APK selbst liegt auf dem Branch `apk`.
|
||||||
|
|
||||||
## Backend
|
Architektur-Notizen: [CONTEXT.md](CONTEXT.md) und [docs/adr/](docs/adr/).
|
||||||
|
|
||||||
The full feature set needs the Dispatcharr fork with OIDC support
|
|
||||||
(be-nj/Dispatcharr): its `/api/accounts/oidc/status/` endpoint hands the app
|
|
||||||
the issuer and public device-flow client id. Configuration lives in
|
|
||||||
[CONTEXT.md](CONTEXT.md) and [docs/adr/](docs/adr/).
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ android {
|
|||||||
applicationId = "dev.castarr.tv"
|
applicationId = "dev.castarr.tv"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 20
|
versionCode = 36
|
||||||
versionName = "0.8.4"
|
versionName = "0.11.3"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release signing from environment (see ~/.keys/castarr-release.env on the
|
// Release signing from environment (see ~/.keys/castarr-release.env on the
|
||||||
@@ -51,6 +51,20 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Global layout rule: everything test-related lives under tests/.
|
||||||
|
sourceSets {
|
||||||
|
getByName("test") {
|
||||||
|
java.setSrcDirs(listOf("../tests/unit"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testOptions {
|
||||||
|
unitTests.all {
|
||||||
|
it.reports.junitXml.outputLocation.set(file("../tests/runs/junit"))
|
||||||
|
it.reports.html.outputLocation.set(file("../tests/runs/junit-html"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
buildConfig = true
|
buildConfig = true
|
||||||
@@ -82,6 +96,7 @@ dependencies {
|
|||||||
implementation("org.nanohttpd:nanohttpd-websocket:2.3.1")
|
implementation("org.nanohttpd:nanohttpd-websocket:2.3.1")
|
||||||
implementation("com.google.zxing:core:3.5.3")
|
implementation("com.google.zxing:core:3.5.3")
|
||||||
implementation("io.coil-kt:coil-compose:2.7.0")
|
implementation("io.coil-kt:coil-compose:2.7.0")
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full JDK for javac via toolchain (host may only have a JRE); resolved by
|
// Full JDK for javac via toolchain (host may only have a JRE); resolved by
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:banner="@drawable/tv_banner"
|
android:banner="@drawable/tv_banner"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
|||||||
@@ -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 ---
|
||||||
@@ -320,6 +320,7 @@
|
|||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
state.connected = false; state.authorized = false;
|
state.connected = false; state.authorized = false;
|
||||||
renderConnection();
|
renderConnection();
|
||||||
|
state.ws = null;
|
||||||
setTimeout(connect, state.retryDelay);
|
setTimeout(connect, state.retryDelay);
|
||||||
state.retryDelay = Math.min(state.retryDelay * 1.6, 10000);
|
state.retryDelay = Math.min(state.retryDelay * 1.6, 10000);
|
||||||
};
|
};
|
||||||
@@ -340,7 +341,7 @@
|
|||||||
if (msg.token) localStorage.setItem('castarr_token', msg.token);
|
if (msg.token) localStorage.setItem('castarr_token', msg.token);
|
||||||
$('tv-name').textContent = msg.device || 'TV';
|
$('tv-name').textContent = msg.device || 'TV';
|
||||||
if (msg.status) { state.status = msg.status; }
|
if (msg.status) { state.status = msg.status; }
|
||||||
if (msg.channels) { state.channels = msg.channels; }
|
if (msg.channels) { state.channels = msg.channels.map((c, i) => { c._idx = i; return c; }); }
|
||||||
if (msg.extras) { state.extras = msg.extras; }
|
if (msg.extras) { state.extras = msg.extras; }
|
||||||
state.playlistUrl = msg.playlistUrl || '';
|
state.playlistUrl = msg.playlistUrl || '';
|
||||||
$('pair-error').textContent = '';
|
$('pair-error').textContent = '';
|
||||||
@@ -358,10 +359,10 @@
|
|||||||
case 'status':
|
case 'status':
|
||||||
state.status = msg;
|
state.status = msg;
|
||||||
renderStatus();
|
renderStatus();
|
||||||
renderChannels();
|
renderChannelsIfChanged();
|
||||||
break;
|
break;
|
||||||
case 'channels':
|
case 'channels':
|
||||||
state.channels = msg.channels || [];
|
state.channels = (msg.channels || []).map((c, i) => { c._idx = i; return c; });
|
||||||
state.playlistUrl = msg.playlistUrl || '';
|
state.playlistUrl = msg.playlistUrl || '';
|
||||||
if (msg.extras) { state.extras = msg.extras; }
|
if (msg.extras) { state.extras = msg.extras; }
|
||||||
renderChannels();
|
renderChannels();
|
||||||
@@ -446,25 +447,51 @@
|
|||||||
$('setup').classList.add('open');
|
$('setup').classList.add('open');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
if (!shown.length) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'empty';
|
||||||
|
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;
|
||||||
|
}
|
||||||
const frag = document.createDocumentFragment();
|
const frag = document.createDocumentFragment();
|
||||||
shown.slice(0, 500).forEach((c) => {
|
shown.slice(0, 500).forEach((c) => {
|
||||||
const idx = state.channels.indexOf(c);
|
const idx = c._idx;
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.className = 'chan' + (c.url === state.status.url || c.name === state.status.channel ? ' current' : '');
|
btn.className = 'chan' + (c.url === state.status.url || c.name === state.status.channel ? ' current' : '');
|
||||||
const num = document.createElement('span');
|
const num = document.createElement('span');
|
||||||
@@ -482,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');
|
||||||
@@ -502,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');
|
||||||
@@ -517,12 +557,28 @@
|
|||||||
btn.addEventListener('click', () => playChannel(c));
|
btn.addEventListener('click', () => playChannel(c));
|
||||||
frag.appendChild(btn);
|
frag.appendChild(btn);
|
||||||
});
|
});
|
||||||
list.innerHTML = '';
|
|
||||||
list.appendChild(frag);
|
list.appendChild(frag);
|
||||||
if (state.playlistUrl) $('playlist-input').value = state.playlistUrl;
|
// Never overwrite an input the user is typing in.
|
||||||
|
const pl = $('playlist-input');
|
||||||
|
if (state.playlistUrl && document.activeElement !== pl) pl.value = state.playlistUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAll() { renderStatus(); renderChannels(); }
|
// Rebuilding the list on every 2s status push swallowed taps that landed
|
||||||
|
// between touchstart and touchend. Only rebuild when something changed.
|
||||||
|
let listSignature = '';
|
||||||
|
function renderChannelsIfChanged() {
|
||||||
|
const sig = [
|
||||||
|
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('|');
|
||||||
|
if (sig === listSignature) return;
|
||||||
|
listSignature = sig;
|
||||||
|
renderChannels();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAll() { renderStatus(); listSignature = ''; renderChannels(); }
|
||||||
|
|
||||||
// --- actions ---
|
// --- actions ---
|
||||||
function playChannel(c) {
|
function playChannel(c) {
|
||||||
@@ -583,10 +639,17 @@
|
|||||||
|
|
||||||
$('pair-btn').addEventListener('click', () => {
|
$('pair-btn').addEventListener('click', () => {
|
||||||
const code = $('code-input').value.trim();
|
const code = $('code-input').value.trim();
|
||||||
if (code.length !== 4) { $('pair-error').textContent = 'Bitte 4 Ziffern eingeben.'; return; }
|
if (!/^[0-9]{4}$/.test(code)) {
|
||||||
|
$('pair-error').textContent = 'Bitte 4 Ziffern eingeben.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A fresh attempt must not inherit the backoff from earlier failures.
|
||||||
|
state.retryDelay = 1000;
|
||||||
|
$('pair-error').textContent = 'Verbinde…';
|
||||||
manualCode = code;
|
manualCode = code;
|
||||||
$('pair-error').textContent = '';
|
$('pair-error').textContent = '';
|
||||||
if (state.ws) state.ws.close(); else connect();
|
// A closed socket ignores close(), so the retry has to reconnect.
|
||||||
|
if (state.ws && state.ws.readyState <= 1) state.ws.close(); else connect();
|
||||||
});
|
});
|
||||||
|
|
||||||
let toastTimer = null;
|
let toastTimer = null;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import dev.castarr.tv.auth.DeviceAuth
|
import dev.castarr.tv.auth.DeviceAuth
|
||||||
import dev.castarr.tv.data.DispatcharrRepository
|
import dev.castarr.tv.data.DispatcharrRepository
|
||||||
import dev.castarr.tv.data.NowNext
|
import dev.castarr.tv.data.NowNext
|
||||||
|
import dev.castarr.tv.data.isEpgPlaceholder
|
||||||
import dev.castarr.tv.data.SourceRepository
|
import dev.castarr.tv.data.SourceRepository
|
||||||
import dev.castarr.tv.player.PlayerController
|
import dev.castarr.tv.player.PlayerController
|
||||||
import dev.castarr.tv.playlist.Channel
|
import dev.castarr.tv.playlist.Channel
|
||||||
@@ -26,6 +27,7 @@ class AppState(
|
|||||||
val dispatcharr: DispatcharrRepository,
|
val dispatcharr: DispatcharrRepository,
|
||||||
) {
|
) {
|
||||||
private val prefs = context.getSharedPreferences("app", Context.MODE_PRIVATE)
|
private val prefs = context.getSharedPreferences("app", Context.MODE_PRIVATE)
|
||||||
|
val crests = dev.castarr.tv.data.Crests(context)
|
||||||
|
|
||||||
var screen by mutableStateOf(Screen.LIVE)
|
var screen by mutableStateOf(Screen.LIVE)
|
||||||
var playerVisible by mutableStateOf(false)
|
var playerVisible by mutableStateOf(false)
|
||||||
@@ -38,6 +40,37 @@ class AppState(
|
|||||||
var isLive by mutableStateOf(false)
|
var isLive by mutableStateOf(false)
|
||||||
var favoritesOnly by mutableStateOf(false)
|
var favoritesOnly by mutableStateOf(false)
|
||||||
var groupFilter by mutableStateOf<String?>(null)
|
var groupFilter by mutableStateOf<String?>(null)
|
||||||
|
|
||||||
|
/** Club shortcut currently open in the rail, if any. */
|
||||||
|
var activeTeam by mutableStateOf<String?>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Club menus switched on for this TV. Seeded from the signed-in viewer's
|
||||||
|
* own club; every club can be toggled on in the settings, so a household
|
||||||
|
* can watch for more than one.
|
||||||
|
*/
|
||||||
|
var enabledTeams by mutableStateOf(loadEnabledTeams())
|
||||||
|
private set
|
||||||
|
|
||||||
|
private fun loadEnabledTeams(): Set<String> {
|
||||||
|
val stored = prefs.getStringSet("teams", null)
|
||||||
|
if (stored != null) return stored.toSet()
|
||||||
|
return dev.castarr.tv.data.TeamFilters.defaultKeysFor(auth.username).toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns false when the cap of [TeamFilters.MAX_ACTIVE] is reached. */
|
||||||
|
fun toggleTeam(key: String): Boolean {
|
||||||
|
if (key !in enabledTeams &&
|
||||||
|
enabledTeams.size >= dev.castarr.tv.data.TeamFilters.MAX_ACTIVE
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
enabledTeams =
|
||||||
|
if (key in enabledTeams) enabledTeams - key else enabledTeams + key
|
||||||
|
prefs.edit().putStringSet("teams", enabledTeams).apply()
|
||||||
|
if (activeTeam == key && key !in enabledTeams) activeTeam = null
|
||||||
|
return true
|
||||||
|
}
|
||||||
var audioTracks by mutableStateOf<List<PlayerController.AudioTrack>>(emptyList())
|
var audioTracks by mutableStateOf<List<PlayerController.AudioTrack>>(emptyList())
|
||||||
private set
|
private set
|
||||||
private var lastNowTitle: String? = null
|
private var lastNowTitle: String? = null
|
||||||
@@ -45,6 +78,23 @@ class AppState(
|
|||||||
/** Digits typed on the remote's number pad (channel switching). */
|
/** Digits typed on the remote's number pad (channel switching). */
|
||||||
var digitBuffer by mutableStateOf("")
|
var digitBuffer by mutableStateOf("")
|
||||||
|
|
||||||
|
/** False when the control server could not bind its port. */
|
||||||
|
var remoteAvailable by mutableStateOf(true)
|
||||||
|
|
||||||
|
/** Channel to restore focus to when the list comes back (#13). */
|
||||||
|
var lastWatched by mutableStateOf<Channel?>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True while the list is being restored after playback. The group rail
|
||||||
|
* opens whatever gets focused, so without this the focus landing there
|
||||||
|
* would silently switch the view back to "Alle Sender".
|
||||||
|
*/
|
||||||
|
var restorePending by mutableStateOf(false)
|
||||||
|
|
||||||
|
private val reentryHandler = android.os.Handler(android.os.Looper.getMainLooper())
|
||||||
|
private var lastStoppedUrl: String = ""
|
||||||
|
private var lastStoppedAt: Long = 0L
|
||||||
|
|
||||||
/** Bumped on every interaction with the visible overlay to restart the
|
/** Bumped on every interaction with the visible overlay to restart the
|
||||||
* auto-hide timer. */
|
* auto-hide timer. */
|
||||||
var overlayPing by mutableLongStateOf(0L)
|
var overlayPing by mutableLongStateOf(0L)
|
||||||
@@ -115,6 +165,56 @@ class AppState(
|
|||||||
SourceMode.DISPATCHARR -> dispatcharr.nowNext(channel)
|
SourceMode.DISPATCHARR -> dispatcharr.nowNext(channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun upcoming(channel: Channel, windowMs: Long) = when (sourceMode) {
|
||||||
|
SourceMode.GENERIC -> source.upcoming(channel, windowMs)
|
||||||
|
SourceMode.DISPATCHARR -> dispatcharr.upcoming(channel, windowMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LIVE while a match is running, SOON within half an hour of kick-off —
|
||||||
|
* the window in which someone actually wants to be nudged.
|
||||||
|
*/
|
||||||
|
fun teamUrgency(matches: List<Pair<Channel, TeamHit>>): TeamUrgency {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
var soon = false
|
||||||
|
matches.forEach { (_, hit) ->
|
||||||
|
val p = hit.programme
|
||||||
|
if (now in p.start until p.stop) return TeamUrgency.LIVE
|
||||||
|
if (p.start in now..(now + SOON_WINDOW_MS)) soon = true
|
||||||
|
}
|
||||||
|
return if (soon) TeamUrgency.SOON else TeamUrgency.NONE
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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, TeamHit>> {
|
||||||
|
val filter = dev.castarr.tv.data.TeamFilters.byKey(key) ?: return emptyList()
|
||||||
|
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 ->
|
||||||
|
// 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.programme.start }
|
||||||
|
}
|
||||||
|
|
||||||
fun refreshActive() {
|
fun refreshActive() {
|
||||||
when (sourceMode) {
|
when (sourceMode) {
|
||||||
SourceMode.GENERIC -> source.refresh()
|
SourceMode.GENERIC -> source.refresh()
|
||||||
@@ -127,8 +227,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
|
||||||
@@ -139,6 +245,14 @@ 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 {
|
fun isOnline(): Boolean {
|
||||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
|
val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
|
||||||
@@ -146,6 +260,8 @@ class AppState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun startOnboarding() {
|
fun startOnboarding() {
|
||||||
|
prefs.edit().remove("teams").apply()
|
||||||
|
activeTeam = null
|
||||||
auth.logout()
|
auth.logout()
|
||||||
appError = AppError.NONE
|
appError = AppError.NONE
|
||||||
welcomePhase = WelcomePhase.WAIT_PHONE
|
welcomePhase = WelcomePhase.WAIT_PHONE
|
||||||
@@ -154,17 +270,41 @@ class AppState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun play(channel: Channel) {
|
fun play(channel: Channel) {
|
||||||
|
lastWatched = channel
|
||||||
currentChannel = channel
|
currentChannel = channel
|
||||||
playerVisible = true
|
playerVisible = true
|
||||||
|
// Re-opening the very channel that was just closed can hit the
|
||||||
|
// provider before it released the previous session, which comes back
|
||||||
|
// as its "Stream Offline" still image. Give it a moment.
|
||||||
|
val sinceStop = System.currentTimeMillis() - lastStoppedAt
|
||||||
|
val sameChannel = channel.url == lastStoppedUrl
|
||||||
|
if (sameChannel && sinceStop in 0 until REENTRY_GRACE_MS) {
|
||||||
|
val wait = REENTRY_GRACE_MS - sinceStop
|
||||||
|
playerState = "reconnecting"
|
||||||
|
reentryHandler.removeCallbacksAndMessages(null)
|
||||||
|
reentryHandler.postDelayed({
|
||||||
|
player.play(channel.url, channel.name, channel.group)
|
||||||
|
}, wait)
|
||||||
|
return
|
||||||
|
}
|
||||||
player.play(channel.url, channel.name, channel.group)
|
player.play(channel.url, channel.name, channel.group)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stopPlayback() {
|
fun stopPlayback() {
|
||||||
|
restorePending = lastWatched != null
|
||||||
|
reentryHandler.removeCallbacksAndMessages(null)
|
||||||
|
lastStoppedUrl = currentChannel?.url.orEmpty()
|
||||||
|
lastStoppedAt = System.currentTimeMillis()
|
||||||
player.stop()
|
player.stop()
|
||||||
playerVisible = false
|
playerVisible = false
|
||||||
currentChannel = null
|
currentChannel = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Retry the failed channel without leaving the player. */
|
||||||
|
fun retryPlayback() {
|
||||||
|
player.retryNow()
|
||||||
|
}
|
||||||
|
|
||||||
fun zap(direction: Int) {
|
fun zap(direction: Int) {
|
||||||
val list = activeChannels()
|
val list = activeChannels()
|
||||||
if (list.isEmpty()) return
|
if (list.isEmpty()) return
|
||||||
@@ -187,10 +327,15 @@ class AppState(
|
|||||||
overlayVisible = true
|
overlayVisible = true
|
||||||
}
|
}
|
||||||
lastNowTitle = nowTitle
|
lastNowTitle = nowTitle
|
||||||
if (!player.hasMedia && playerVisible) {
|
// Keep the player on screen while an error or reconnect is pending —
|
||||||
|
// otherwise the failure silently drops the viewer back to the list.
|
||||||
|
if (!player.hasMedia && playerVisible &&
|
||||||
|
player.errorMessage == null && !player.reconnecting
|
||||||
|
) {
|
||||||
playerVisible = false
|
playerVisible = false
|
||||||
currentChannel = null
|
currentChannel = null
|
||||||
}
|
}
|
||||||
|
if (player.errorMessage != null || player.reconnecting) overlayVisible = true
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Steps to the next audio track (D-pad friendly: one key, cycles). */
|
/** Steps to the next audio track (D-pad friendly: one key, cycles). */
|
||||||
|
|||||||
@@ -48,8 +48,16 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
|||||||
)
|
)
|
||||||
server = ControlServer(this, this)
|
server = ControlServer(this, this)
|
||||||
server.startServer()
|
server.startServer()
|
||||||
|
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() {
|
||||||
@@ -115,6 +123,7 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
|||||||
.put("duration", player.player.duration.coerceAtLeast(0))
|
.put("duration", player.player.duration.coerceAtLeast(0))
|
||||||
.put("volume", if (max > 0) vol.toDouble() / max else 0.0)
|
.put("volume", if (max > 0) vol.toDouble() / max else 0.0)
|
||||||
.put("error", player.errorMessage ?: "")
|
.put("error", player.errorMessage ?: "")
|
||||||
|
.put("reconnecting", player.reconnecting)
|
||||||
.put("audioTracks", org.json.JSONArray().also { arr ->
|
.put("audioTracks", org.json.JSONArray().also { arr ->
|
||||||
state.audioTracks.forEach { arr.put(it.label) }
|
state.audioTracks.forEach { arr.put(it.label) }
|
||||||
})
|
})
|
||||||
@@ -138,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
63
app/src/main/java/dev/castarr/tv/data/Crests.kt
Normal file
63
app/src/main/java/dev/castarr/tv/data/Crests.kt
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package dev.castarr.tv.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.net.URLEncoder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves club crests at runtime from Wikipedia's page summary. The badges
|
||||||
|
* are trademarks, so they are fetched and cached on the device rather than
|
||||||
|
* shipped with the app — nothing copyrighted lives in the repository.
|
||||||
|
*/
|
||||||
|
class Crests(context: Context) {
|
||||||
|
|
||||||
|
private val prefs = context.getSharedPreferences("crests", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
/** Cached crest URL for a club, fetched once and then reused. */
|
||||||
|
suspend fun urlFor(article: String): String? = withContext(Dispatchers.IO) {
|
||||||
|
prefs.getString(article, null)?.takeIf { it.isNotEmpty() }?.let {
|
||||||
|
return@withContext it
|
||||||
|
}
|
||||||
|
val resolved = runCatching { fetch(article) }
|
||||||
|
.onFailure { Log.w(TAG, "crest lookup failed for $article: $it") }
|
||||||
|
.getOrNull()
|
||||||
|
if (resolved != null) {
|
||||||
|
Log.i(TAG, "crest for $article: $resolved")
|
||||||
|
prefs.edit().putString(article, resolved).apply()
|
||||||
|
} else {
|
||||||
|
// Deliberately not cached: a single failed lookup (no network
|
||||||
|
// yet at start-up) must not hide the crest forever.
|
||||||
|
Log.w(TAG, "no crest for $article")
|
||||||
|
}
|
||||||
|
resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetch(article: String): String? {
|
||||||
|
val encoded = URLEncoder.encode(article, "UTF-8").replace("+", "%20")
|
||||||
|
val connection = URL("$SUMMARY$encoded").openConnection() as HttpURLConnection
|
||||||
|
return try {
|
||||||
|
connection.connectTimeout = 10_000
|
||||||
|
connection.readTimeout = 10_000
|
||||||
|
connection.setRequestProperty("User-Agent", USER_AGENT)
|
||||||
|
connection.setRequestProperty("Accept", "application/json")
|
||||||
|
val body = connection.inputStream.bufferedReader().use { it.readText() }
|
||||||
|
JSONObject(body).optJSONObject("thumbnail")?.optString("source")
|
||||||
|
?.takeIf { it.isNotEmpty() }
|
||||||
|
// Drop the analytics query the API appends.
|
||||||
|
?.substringBefore("?")
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "Crests"
|
||||||
|
const val SUMMARY = "https://de.wikipedia.org/api/rest_v1/page/summary/"
|
||||||
|
const val USER_AGENT = "Castarr TV (private use)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)}") }
|
||||||
@@ -71,6 +78,12 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) {
|
|||||||
fun nowNext(channel: Channel): NowNext =
|
fun nowNext(channel: Channel): NowNext =
|
||||||
XmltvParser.nowNext(programmesByTvgId[channel.tvgId])
|
XmltvParser.nowNext(programmesByTvgId[channel.tvgId])
|
||||||
|
|
||||||
|
/** Programmes of this channel between now and now + [windowMs]. */
|
||||||
|
fun upcoming(channel: Channel, windowMs: Long): List<Programme> {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
return XmltvParser.programmesIn(programmesByTvgId[channel.tvgId], now, now + windowMs)
|
||||||
|
}
|
||||||
|
|
||||||
fun toggleFavorite(channel: Channel) {
|
fun toggleFavorite(channel: Channel) {
|
||||||
if (channel.backendId == 0) return
|
if (channel.backendId == 0) return
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
|||||||
@@ -82,13 +82,20 @@ class SourceRepository(context: Context) {
|
|||||||
status.value = ""
|
status.value = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Now/Next for an M3U channel: match tvg-id first, then name. */
|
/** Programmes of this channel between now and now + [windowMs]. */
|
||||||
fun nowNext(channel: Channel): NowNext {
|
fun upcoming(channel: Channel, windowMs: Long): List<Programme> {
|
||||||
val direct = channel.tvgId.takeIf { it.isNotEmpty() }?.let { programmes[it] }
|
val now = System.currentTimeMillis()
|
||||||
val byName = direct ?: nameToEpgId[channel.name.lowercase()]?.let { programmes[it] }
|
return XmltvParser.programmesIn(programmesFor(channel), now, now + windowMs)
|
||||||
return XmltvParser.nowNext(byName)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun programmesFor(channel: Channel): List<Programme>? {
|
||||||
|
val direct = channel.tvgId.takeIf { it.isNotEmpty() }?.let { programmes[it] }
|
||||||
|
return direct ?: nameToEpgId[channel.name.lowercase()]?.let { programmes[it] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Now/Next for an M3U channel: match tvg-id first, then name. */
|
||||||
|
fun nowNext(channel: Channel): NowNext = XmltvParser.nowNext(programmesFor(channel))
|
||||||
|
|
||||||
private fun loadCachedChannels() {
|
private fun loadCachedChannels() {
|
||||||
runCatching {
|
runCatching {
|
||||||
val cached = prefs.getString("channels_cache", null) ?: return
|
val cached = prefs.getString("channels_cache", null) ?: return
|
||||||
@@ -96,17 +103,44 @@ class SourceRepository(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun open(url: String): HttpURLConnection =
|
/**
|
||||||
(URL(url).openConnection() as HttpURLConnection).apply {
|
* Only plain HTTP(S) is fetched, and redirects are followed manually so
|
||||||
|
* every hop can be checked again — a paired phone must not be able to
|
||||||
|
* point the TV at arbitrary internal services (SSRF).
|
||||||
|
*/
|
||||||
|
private fun open(url: String): HttpURLConnection {
|
||||||
|
val parsed = URL(url)
|
||||||
|
require(parsed.protocol.equals("http", true) || parsed.protocol.equals("https", true)) {
|
||||||
|
"unsupported scheme"
|
||||||
|
}
|
||||||
|
return (parsed.openConnection() as HttpURLConnection).apply {
|
||||||
connectTimeout = 15_000
|
connectTimeout = 15_000
|
||||||
readTimeout = 60_000
|
readTimeout = 60_000
|
||||||
instanceFollowRedirects = true
|
instanceFollowRedirects = true
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads at most [MAX_BYTES] and gives up after [MAX_TRANSFER_MS] overall.
|
||||||
|
* `readTimeout` alone only bounds a single read, so a server trickling
|
||||||
|
* bytes could grow the buffer until the app died.
|
||||||
|
*/
|
||||||
private fun download(url: String): String {
|
private fun download(url: String): String {
|
||||||
val connection = open(url)
|
val connection = open(url)
|
||||||
|
val deadline = System.currentTimeMillis() + MAX_TRANSFER_MS
|
||||||
return try {
|
return try {
|
||||||
connection.inputStream.bufferedReader().use { it.readText() }
|
val builder = StringBuilder()
|
||||||
|
connection.inputStream.bufferedReader().use { reader ->
|
||||||
|
val buffer = CharArray(8 * 1024)
|
||||||
|
while (true) {
|
||||||
|
val read = reader.read(buffer)
|
||||||
|
if (read < 0) break
|
||||||
|
builder.append(buffer, 0, read)
|
||||||
|
require(builder.length <= MAX_BYTES) { "playlist too large" }
|
||||||
|
require(System.currentTimeMillis() < deadline) { "download timed out" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
builder.toString()
|
||||||
} finally {
|
} finally {
|
||||||
connection.disconnect()
|
connection.disconnect()
|
||||||
}
|
}
|
||||||
@@ -114,5 +148,7 @@ class SourceRepository(context: Context) {
|
|||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val TAG = "SourceRepository"
|
const val TAG = "SourceRepository"
|
||||||
|
const val MAX_BYTES = 24 * 1024 * 1024
|
||||||
|
const val MAX_TRANSFER_MS = 120_000L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
160
app/src/main/java/dev/castarr/tv/data/TeamFilters.kt
Normal file
160
app/src/main/java/dev/castarr/tv/data/TeamFilters.kt
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
package dev.castarr.tv.data
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-club shortcut to "where does my club play tonight": a rail entry that
|
||||||
|
* lists every channel whose EPG mentions the club within the next few hours.
|
||||||
|
* Crests are fetched from Wikipedia at runtime and cached on the device;
|
||||||
|
* the club colours are the fallback while that is pending or unavailable.
|
||||||
|
*/
|
||||||
|
data class TeamFilter(
|
||||||
|
val key: String,
|
||||||
|
/** Short caption next to the crest in the rail. */
|
||||||
|
val label: String,
|
||||||
|
/** Full club name, shown in the settings and in messages. */
|
||||||
|
val fullName: String,
|
||||||
|
/** Lowercase needles matched against programme titles. */
|
||||||
|
val needles: List<String>,
|
||||||
|
val primary: Color,
|
||||||
|
val secondary: Color,
|
||||||
|
/** German Wikipedia article the crest is resolved from. */
|
||||||
|
val article: String,
|
||||||
|
) {
|
||||||
|
fun matches(title: String): Boolean {
|
||||||
|
val haystack = title.lowercase()
|
||||||
|
return needles.any { haystack.contains(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object TeamFilters {
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
|
||||||
|
private const val WHITE = 0xFFF2F3F5
|
||||||
|
private const val BLACK = 0xFF15171B
|
||||||
|
|
||||||
|
private fun club(
|
||||||
|
key: String,
|
||||||
|
label: String,
|
||||||
|
fullName: String,
|
||||||
|
needles: List<String>,
|
||||||
|
primary: Long,
|
||||||
|
secondary: Long,
|
||||||
|
article: String = fullName,
|
||||||
|
) = TeamFilter(key, label, fullName, needles, Color(primary), Color(secondary), article)
|
||||||
|
|
||||||
|
/** Clubs of the top three German divisions. */
|
||||||
|
val all: List<TeamFilter> = listOf(
|
||||||
|
// --- Bundesliga ---
|
||||||
|
club("bayern", "FCB", "FC Bayern München", listOf("bayern münchen", "fc bayern"), 0xFFDC052D, WHITE),
|
||||||
|
club("bvb", "BVB", "Borussia Dortmund", listOf("borussia dortmund", "bvb"), 0xFFFDE100, BLACK),
|
||||||
|
club("leipzig", "RBL", "RB Leipzig", listOf("rb leipzig"), 0xFFDD0741, WHITE),
|
||||||
|
club("leverkusen", "B04", "Bayer 04 Leverkusen", listOf("leverkusen"), 0xFFE32219, BLACK),
|
||||||
|
club("frankfurt", "SGE", "Eintracht Frankfurt", listOf("eintracht frankfurt"), 0xFF1A1A1A, 0xFFE1000F),
|
||||||
|
club("stuttgart", "1893", "VfB Stuttgart", listOf("vfb stuttgart"), 0xFFE32219, WHITE),
|
||||||
|
club("gladbach", "BMG", "Borussia Mönchengladbach", listOf("mönchengladbach", "gladbach"), 0xFF1A1A1A, 0xFF00A94F),
|
||||||
|
club("wolfsburg", "WOB", "VfL Wolfsburg", listOf("wolfsburg"), 0xFF65B32E, WHITE),
|
||||||
|
club("bremen", "SVW", "SV Werder Bremen", listOf("werder bremen", "werder"), 0xFF1D9053, WHITE),
|
||||||
|
club("freiburg", "SCF", "SC Freiburg", listOf("sc freiburg", "freiburg"), 0xFFE2001A, WHITE),
|
||||||
|
club("hoffenheim", "TSG", "TSG Hoffenheim", listOf("hoffenheim"), 0xFF1C63B7, WHITE),
|
||||||
|
club("mainz", "M05", "1. FSV Mainz 05", listOf("mainz 05", "mainz"), 0xFFE1000F, WHITE),
|
||||||
|
club("augsburg", "FCA", "FC Augsburg", listOf("augsburg"), 0xFF00693F, 0xFFE1000F),
|
||||||
|
club("union", "FCU", "1. FC Union Berlin", listOf("union berlin"), 0xFFE1000F, 0xFFFDE100),
|
||||||
|
club("koeln", "EFC", "1. FC Köln", listOf("1. fc köln", "fc köln"), 0xFFE1000F, WHITE),
|
||||||
|
club("hsv", "HSV", "Hamburger SV", listOf("hamburger sv", "hsv"), 0xFF0E5EA6, BLACK),
|
||||||
|
club("heidenheim", "FCH1", "1. FC Heidenheim", listOf("heidenheim"), 0xFFE1000F, 0xFF1656A4),
|
||||||
|
club("st-pauli", "FCSP", "FC St. Pauli", listOf("st. pauli", "st pauli"), 0xFF6B4423, WHITE),
|
||||||
|
// --- 2. Bundesliga ---
|
||||||
|
club("schalke", "S04", "FC Schalke 04", listOf("schalke"), 0xFF004D9D, WHITE),
|
||||||
|
club("hertha", "BSC", "Hertha BSC", listOf("hertha"), 0xFF004D9D, WHITE),
|
||||||
|
club("duesseldorf", "F95", "Fortuna Düsseldorf", listOf("fortuna düsseldorf"), 0xFFE1000F, WHITE),
|
||||||
|
club("nuernberg", "FCN", "1. FC Nürnberg", listOf("nürnberg"), 0xFF8B1A1A, WHITE),
|
||||||
|
club("kaiserslautern", "FCK", "1. FC Kaiserslautern", listOf("kaiserslautern"), 0xFFE1000F, WHITE),
|
||||||
|
club("karlsruhe", "KSC", "Karlsruher SC", listOf("karlsruher sc", "ksc"), 0xFF0055A5, WHITE),
|
||||||
|
club("hannover", "H96", "Hannover 96", listOf("hannover 96"), 0xFF00A94F, WHITE),
|
||||||
|
club("paderborn", "SCP", "SC Paderborn 07", listOf("paderborn"), 0xFF004D9D, WHITE),
|
||||||
|
club("magdeburg", "FCM", "1. FC Magdeburg", listOf("magdeburg"), 0xFF0B7A3E, WHITE),
|
||||||
|
club("elversberg", "SVE", "SV Elversberg", listOf("elversberg"), 0xFF1A1A1A, 0xFFE1000F),
|
||||||
|
club("darmstadt", "SV98", "SV Darmstadt 98", listOf("darmstadt"), 0xFF004E9E, WHITE),
|
||||||
|
club("braunschweig", "BTSV", "Eintracht Braunschweig", listOf("braunschweig"), 0xFFFDE100, 0xFF004E9E),
|
||||||
|
club("bochum", "BOC", "VfL Bochum", listOf("bochum"), 0xFF005CA9, WHITE),
|
||||||
|
club("muenster", "SCPM", "Preußen Münster", listOf("preußen münster", "münster"), 0xFF00703C, WHITE),
|
||||||
|
club("fuerth", "SGF", "SpVgg Greuther Fürth", listOf("greuther fürth", "fürth"), 0xFF00A94F, WHITE),
|
||||||
|
club("holstein", "KSV", "Holstein Kiel", listOf("holstein kiel"), 0xFF004E9E, 0xFFE1000F),
|
||||||
|
club("dresden", "SGD", "Dynamo Dresden", listOf("dynamo dresden"), 0xFFFDE100, BLACK),
|
||||||
|
club("bielefeld", "DSC", "Arminia Bielefeld", listOf("arminia bielefeld", "bielefeld"), 0xFF00539F, WHITE),
|
||||||
|
// --- 3. Liga ---
|
||||||
|
club("hansa", "FCH", "FC Hansa Rostock", listOf("hansa"), 0xFF0B4EA2, WHITE),
|
||||||
|
club("saarbruecken", "FCS", "1. FC Saarbrücken", listOf("saarbrücken"), 0xFF0B4EA2, BLACK),
|
||||||
|
club("aue", "FCE", "Erzgebirge Aue", listOf("erzgebirge aue"), 0xFF7C0A02, WHITE),
|
||||||
|
club("cottbus", "FCEC", "Energie Cottbus", listOf("energie cottbus", "cottbus"), 0xFFE1000F, WHITE),
|
||||||
|
club("essen", "RWE", "Rot-Weiss Essen", listOf("rot-weiss essen", "rot weiss essen"), 0xFFE1000F, WHITE),
|
||||||
|
club("duisburg", "MSV", "MSV Duisburg", listOf("msv duisburg", "duisburg"), 0xFF004E9E, WHITE),
|
||||||
|
club("mannheim", "SVWM", "SV Waldhof Mannheim", listOf("waldhof mannheim", "waldhof"), 0xFF0B4EA2, WHITE),
|
||||||
|
club("wehen", "SVWW", "SV Wehen Wiesbaden", listOf("wehen wiesbaden", "wehen"), 0xFFE1000F, BLACK),
|
||||||
|
club("ulm", "SSV", "SSV Ulm 1846", listOf("ssv ulm"), 0xFFE1000F, WHITE, article = "SSV Ulm 1846 Fußball"),
|
||||||
|
club("regensburg", "SSVJ", "SSV Jahn Regensburg", listOf("jahn regensburg", "regensburg"), 0xFFE1000F, WHITE),
|
||||||
|
club("verl", "SCV", "SC Verl", listOf("sc verl"), 0xFF00703C, WHITE),
|
||||||
|
club("viktoria", "VKÖ", "Viktoria Köln", listOf("viktoria köln"), 0xFFE1000F, WHITE),
|
||||||
|
club("havelse", "TSVH", "TSV Havelse", listOf("havelse"), 0xFF004E9E, WHITE),
|
||||||
|
club("schweinfurt", "FC05", "1. FC Schweinfurt 05", listOf("schweinfurt"), 0xFF00A94F, WHITE),
|
||||||
|
club("osnabrueck", "VfLO", "VfL Osnabrück", listOf("osnabrück"), 0xFF6A2C8F, WHITE),
|
||||||
|
club("aachen", "ALE", "Alemannia Aachen", listOf("alemannia aachen", "aachen"), 0xFFFDE100, BLACK),
|
||||||
|
club("ingolstadt", "FCI", "FC Ingolstadt 04", listOf("ingolstadt"), 0xFFE1000F, BLACK),
|
||||||
|
club("wuppertal", "WSV", "Wuppertaler SV", listOf("wuppertaler sv"), 0xFFE1000F, 0xFF0B4EA2),
|
||||||
|
club("stuttgart-ii", "VfB2", "VfB Stuttgart II", listOf("vfb stuttgart ii"), 0xFFE32219, BLACK),
|
||||||
|
club("hoffenheim-ii", "TSG2", "TSG Hoffenheim II", listOf("hoffenheim ii"), 0xFF1C63B7, BLACK, article = "TSG 1899 Hoffenheim"),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Clubs switched on for a viewer before they touch the settings. */
|
||||||
|
private val defaultForUser = mapOf(
|
||||||
|
"benjamin" to listOf("hansa"),
|
||||||
|
"tobiasb" to listOf("stuttgart"),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only sport and free-to-air groups are scanned. Searching all 500
|
||||||
|
* channels would mostly turn up shopping and radio, and the club is
|
||||||
|
* never on those anyway.
|
||||||
|
*/
|
||||||
|
private val groupIncludes = listOf(
|
||||||
|
"sport", "dazn", "sky", "magenta", "prime", "free tv", "hd+",
|
||||||
|
"dyn", "del", "bundesliga", "fussball", "fußball", "at / ch", "at/ch",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Explicitly out of scope even though they match an include. */
|
||||||
|
private val groupExcludes = listOf("nfl")
|
||||||
|
|
||||||
|
fun scansGroup(group: String): Boolean {
|
||||||
|
val g = group.lowercase()
|
||||||
|
if (groupExcludes.any { g.contains(it) }) return false
|
||||||
|
return groupIncludes.any { g.contains(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun byKey(key: String): TeamFilter? = all.firstOrNull { it.key == key }
|
||||||
|
|
||||||
|
fun defaultKeysFor(username: String): List<String> =
|
||||||
|
defaultForUser[username.trim().lowercase()].orEmpty()
|
||||||
|
}
|
||||||
@@ -105,6 +105,14 @@ object XmltvParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Programmes overlapping [from]..[to], in broadcast order. */
|
||||||
|
fun programmesIn(
|
||||||
|
programmes: List<Programme>?,
|
||||||
|
from: Long,
|
||||||
|
to: Long,
|
||||||
|
): List<Programme> =
|
||||||
|
programmes.orEmpty().filter { it.stop > from && it.start < to }
|
||||||
|
|
||||||
fun nowNext(programmes: List<Programme>?, at: Long = System.currentTimeMillis()): NowNext {
|
fun nowNext(programmes: List<Programme>?, at: Long = System.currentTimeMillis()): NowNext {
|
||||||
if (programmes.isNullOrEmpty()) return NowNext(null, null)
|
if (programmes.isNullOrEmpty()) return NowNext(null, null)
|
||||||
val index = programmes.indexOfFirst { at < it.stop }
|
val index = programmes.indexOfFirst { at < it.stop }
|
||||||
|
|||||||
@@ -3,35 +3,84 @@ package dev.castarr.tv.pairing
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import java.net.Inet4Address
|
import java.net.Inet4Address
|
||||||
import java.net.NetworkInterface
|
import java.net.NetworkInterface
|
||||||
|
import java.security.MessageDigest
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
|
|
||||||
object Pairing {
|
object Pairing {
|
||||||
|
|
||||||
const val PORT = 8765
|
const val PORT = 8765
|
||||||
|
|
||||||
|
private const val PREFS = "pairing"
|
||||||
|
private const val KEY_CODE = "code"
|
||||||
|
private const val KEY_TOKEN = "token"
|
||||||
|
private const val KEY_SESSIONS = "session_tokens"
|
||||||
|
private const val MAX_SESSIONS = 8
|
||||||
|
|
||||||
|
private fun prefs(context: Context) =
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Four-digit pairing code, generated once and kept stable so an already
|
* Four-digit pairing code, generated once and kept stable so an already
|
||||||
* paired phone survives app restarts. Human fallback only — the QR code
|
* paired phone survives app restarts. Human fallback only — the QR code
|
||||||
* carries the long token below.
|
* carries the long token below.
|
||||||
*/
|
*/
|
||||||
fun code(context: Context): String {
|
fun code(context: Context): String {
|
||||||
val prefs = context.getSharedPreferences("pairing", Context.MODE_PRIVATE)
|
prefs(context).getString(KEY_CODE, null)?.let { return it }
|
||||||
prefs.getString("code", null)?.let { return it }
|
|
||||||
val code = "%04d".format(SecureRandom().nextInt(10_000))
|
val code = "%04d".format(SecureRandom().nextInt(10_000))
|
||||||
prefs.edit().putString("code", code).apply()
|
prefs(context).edit().putString(KEY_CODE, code).apply()
|
||||||
return code
|
return code
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 128-bit random token embedded in the QR code; not brute-forceable. */
|
/** 128-bit random token embedded in the QR code; not brute-forceable. */
|
||||||
fun token(context: Context): String {
|
fun token(context: Context): String {
|
||||||
val prefs = context.getSharedPreferences("pairing", Context.MODE_PRIVATE)
|
prefs(context).getString(KEY_TOKEN, null)?.let { return it }
|
||||||
prefs.getString("token", null)?.let { return it }
|
val token = randomToken()
|
||||||
val bytes = ByteArray(16).also { SecureRandom().nextBytes(it) }
|
prefs(context).edit().putString(KEY_TOKEN, token).apply()
|
||||||
val token = bytes.joinToString("") { "%02x".format(it) }
|
|
||||||
prefs.edit().putString("token", token).apply()
|
|
||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issues a separate token for a client that authenticated with the
|
||||||
|
* four-digit code. Handing out the QR token there would make the
|
||||||
|
* brute-forceable path yield the un-rotating master credential.
|
||||||
|
*/
|
||||||
|
fun newSessionToken(context: Context): String {
|
||||||
|
val token = randomToken()
|
||||||
|
val sessions = sessionTokens(context).toMutableList()
|
||||||
|
sessions.add(token)
|
||||||
|
while (sessions.size > MAX_SESSIONS) sessions.removeAt(0)
|
||||||
|
prefs(context).edit().putStringSet(KEY_SESSIONS, sessions.toSet()).apply()
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Constant-time check against the QR token and all session tokens. */
|
||||||
|
fun isValidToken(context: Context, candidate: String): Boolean {
|
||||||
|
if (candidate.isEmpty()) return false
|
||||||
|
if (constantTimeEquals(candidate, token(context))) return true
|
||||||
|
return sessionTokens(context).any { constantTimeEquals(candidate, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isValidCode(context: Context, candidate: String): Boolean =
|
||||||
|
candidate.isNotEmpty() && constantTimeEquals(candidate, code(context))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops every credential: the QR token, the code and all session tokens.
|
||||||
|
* Paired phones must scan again — the way to revoke a leaked token.
|
||||||
|
*/
|
||||||
|
fun reset(context: Context) {
|
||||||
|
prefs(context).edit().clear().apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sessionTokens(context: Context): List<String> =
|
||||||
|
prefs(context).getStringSet(KEY_SESSIONS, emptySet())?.toList().orEmpty()
|
||||||
|
|
||||||
|
private fun randomToken(): String =
|
||||||
|
ByteArray(16).also { SecureRandom().nextBytes(it) }
|
||||||
|
.joinToString("") { "%02x".format(it) }
|
||||||
|
|
||||||
|
private fun constantTimeEquals(a: String, b: String): Boolean =
|
||||||
|
MessageDigest.isEqual(a.toByteArray(), b.toByteArray())
|
||||||
|
|
||||||
/** Best-guess LAN IPv4 address of this device, or null when offline. */
|
/** Best-guess LAN IPv4 address of this device, or null when offline. */
|
||||||
fun lanAddress(): String? {
|
fun lanAddress(): String? {
|
||||||
val candidates = runCatching {
|
val candidates = runCatching {
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ class PlayerController(
|
|||||||
) {
|
) {
|
||||||
|
|
||||||
val player: ExoPlayer = ExoPlayer.Builder(context).build()
|
val player: ExoPlayer = ExoPlayer.Builder(context).build()
|
||||||
private val mediaSession: MediaSession = MediaSession.Builder(context, player).build()
|
// Explicit id: Media3 keeps a process-wide registry and throws when a
|
||||||
|
// session with the same (default, empty) id is still registered.
|
||||||
|
private val mediaSession: MediaSession =
|
||||||
|
MediaSession.Builder(context, player).setId("castarr").build()
|
||||||
|
|
||||||
var channelName: String = ""
|
var channelName: String = ""
|
||||||
private set
|
private set
|
||||||
@@ -33,6 +36,16 @@ class PlayerController(
|
|||||||
private set
|
private set
|
||||||
private var retryCount = 0
|
private var retryCount = 0
|
||||||
private var lastUrl: String = ""
|
private var lastUrl: String = ""
|
||||||
|
private val retryHandler = android.os.Handler(android.os.Looper.getMainLooper())
|
||||||
|
private var pendingRetry: Runnable? = null
|
||||||
|
|
||||||
|
/** True while a reconnect attempt is scheduled or running. */
|
||||||
|
var reconnecting: Boolean = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** When the current channel was opened — used to pace re-entry. */
|
||||||
|
var startedAt: Long = 0L
|
||||||
|
private set
|
||||||
|
|
||||||
/** One selectable audio track of the current stream. */
|
/** One selectable audio track of the current stream. */
|
||||||
data class AudioTrack(val label: String, val selected: Boolean)
|
data class AudioTrack(val label: String, val selected: Boolean)
|
||||||
@@ -46,14 +59,18 @@ class PlayerController(
|
|||||||
override fun onTracksChanged(tracks: Tracks) = onChanged()
|
override fun onTracksChanged(tracks: Tracks) = onChanged()
|
||||||
|
|
||||||
override fun onPlayerError(error: PlaybackException) {
|
override fun onPlayerError(error: PlaybackException) {
|
||||||
// Self-heal: silently reconnect twice before surfacing (#13).
|
// Self-heal with spacing: providers rate-limit reconnects, so
|
||||||
if (retryCount < 2 && lastUrl.isNotEmpty()) {
|
// three back-to-back attempts all land in the same blocked
|
||||||
|
// window and burn the budget in under a second.
|
||||||
|
if (retryCount < RETRY_DELAYS_MS.size && lastUrl.isNotEmpty()) {
|
||||||
|
val delay = RETRY_DELAYS_MS[retryCount]
|
||||||
retryCount++
|
retryCount++
|
||||||
player.setMediaItem(MediaItem.fromUri(lastUrl))
|
reconnecting = true
|
||||||
player.prepare()
|
onChanged()
|
||||||
player.play()
|
scheduleRetry(delay)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
reconnecting = false
|
||||||
errorMessage = "unreachable"
|
errorMessage = "unreachable"
|
||||||
onChanged()
|
onChanged()
|
||||||
}
|
}
|
||||||
@@ -63,6 +80,7 @@ class PlayerController(
|
|||||||
val state: String
|
val state: String
|
||||||
get() = when {
|
get() = when {
|
||||||
errorMessage != null -> "error"
|
errorMessage != null -> "error"
|
||||||
|
reconnecting -> "reconnecting"
|
||||||
player.playbackState == Player.STATE_BUFFERING -> "buffering"
|
player.playbackState == Player.STATE_BUFFERING -> "buffering"
|
||||||
player.playbackState == Player.STATE_READY && player.playWhenReady -> "playing"
|
player.playbackState == Player.STATE_READY && player.playWhenReady -> "playing"
|
||||||
player.playbackState == Player.STATE_READY -> "paused"
|
player.playbackState == Player.STATE_READY -> "paused"
|
||||||
@@ -74,9 +92,12 @@ class PlayerController(
|
|||||||
|
|
||||||
fun play(url: String, name: String, group: String) {
|
fun play(url: String, name: String, group: String) {
|
||||||
if (url.isEmpty()) return
|
if (url.isEmpty()) return
|
||||||
|
cancelRetry()
|
||||||
errorMessage = null
|
errorMessage = null
|
||||||
|
reconnecting = false
|
||||||
retryCount = 0
|
retryCount = 0
|
||||||
lastUrl = url
|
lastUrl = url
|
||||||
|
startedAt = System.currentTimeMillis()
|
||||||
channelName = name.ifEmpty { url }
|
channelName = name.ifEmpty { url }
|
||||||
channelGroup = group
|
channelGroup = group
|
||||||
val item = MediaItem.Builder()
|
val item = MediaItem.Builder()
|
||||||
@@ -88,13 +109,45 @@ class PlayerController(
|
|||||||
player.play()
|
player.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun scheduleRetry(delayMs: Long) {
|
||||||
|
cancelRetry()
|
||||||
|
val runnable = Runnable {
|
||||||
|
pendingRetry = null
|
||||||
|
player.setMediaItem(MediaItem.fromUri(lastUrl))
|
||||||
|
player.prepare()
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
pendingRetry = runnable
|
||||||
|
retryHandler.postDelayed(runnable, delayMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelRetry() {
|
||||||
|
pendingRetry?.let { retryHandler.removeCallbacks(it) }
|
||||||
|
pendingRetry = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retries the current channel immediately, resetting the backoff. */
|
||||||
|
fun retryNow() {
|
||||||
|
if (lastUrl.isEmpty()) return
|
||||||
|
cancelRetry()
|
||||||
|
retryCount = 0
|
||||||
|
errorMessage = null
|
||||||
|
reconnecting = true
|
||||||
|
onChanged()
|
||||||
|
player.setMediaItem(MediaItem.fromUri(lastUrl))
|
||||||
|
player.prepare()
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
|
||||||
fun toggle() {
|
fun toggle() {
|
||||||
if (!hasMedia) return
|
if (!hasMedia) return
|
||||||
if (player.isPlaying) player.pause() else resume()
|
// playWhenReady, not isPlaying: the latter is false while buffering,
|
||||||
|
// which made pausing during a stall silently do nothing.
|
||||||
|
if (player.playWhenReady) pause() else resume()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun pause() {
|
fun pause() {
|
||||||
if (player.isPlaying) player.pause()
|
player.playWhenReady = false
|
||||||
}
|
}
|
||||||
|
|
||||||
fun resume() {
|
fun resume() {
|
||||||
@@ -104,6 +157,8 @@ class PlayerController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
|
cancelRetry()
|
||||||
|
reconnecting = false
|
||||||
player.stop()
|
player.stop()
|
||||||
player.clearMediaItems()
|
player.clearMediaItems()
|
||||||
channelName = ""
|
channelName = ""
|
||||||
@@ -158,13 +213,22 @@ class PlayerController(
|
|||||||
|
|
||||||
fun seekBy(deltaSeconds: Long) {
|
fun seekBy(deltaSeconds: Long) {
|
||||||
if (!hasMedia || !player.isCurrentMediaItemSeekable) return
|
if (!hasMedia || !player.isCurrentMediaItemSeekable) return
|
||||||
val target = (player.currentPosition + deltaSeconds * 1000)
|
// An unknown duration is C.TIME_UNSET (large negative). Clamping the
|
||||||
.coerceIn(0, player.duration.coerceAtLeast(0))
|
// upper bound to it collapsed every seek to position 0.
|
||||||
|
val duration = player.duration
|
||||||
|
var target = (player.currentPosition + deltaSeconds * 1000).coerceAtLeast(0)
|
||||||
|
if (duration > 0) target = target.coerceAtMost(duration)
|
||||||
player.seekTo(target)
|
player.seekTo(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun release() {
|
fun release() {
|
||||||
|
cancelRetry()
|
||||||
mediaSession.release()
|
mediaSession.release()
|
||||||
player.release()
|
player.release()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** Spacing between reconnect attempts after a stream drops. */
|
||||||
|
val RETRY_DELAYS_MS = longArrayOf(1_000L, 3_000L, 8_000L)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,24 @@ package dev.castarr.tv.playlist
|
|||||||
object M3uParser {
|
object M3uParser {
|
||||||
|
|
||||||
private const val MAX_CHANNELS = 5000
|
private const val MAX_CHANNELS = 5000
|
||||||
|
private const val BOM = ''
|
||||||
|
|
||||||
private val attrRegex = Regex("""([\w-]+)="([^"]*)"""")
|
private val attrRegex = Regex("""([\w-]+)="([^"]*)"""")
|
||||||
|
|
||||||
|
/** Thrown when the payload is not a playlist at all (HTML, JSON, …). */
|
||||||
|
class NotAPlaylistException : IllegalArgumentException("not an M3U playlist")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an M3U playlist. Rejects payloads that carry no playlist markers
|
||||||
|
* at all — pointing the app at a 404 page used to silently replace the
|
||||||
|
* channel list with HTML fragments.
|
||||||
|
*/
|
||||||
fun parse(content: String): List<Channel> {
|
fun parse(content: String): List<Channel> {
|
||||||
|
// A UTF-8 BOM is not whitespace, so trim() leaves it in place and the
|
||||||
|
// first line stops looking like a comment.
|
||||||
|
val text = content.trimStart(BOM)
|
||||||
|
if (!looksLikePlaylist(text)) throw NotAPlaylistException()
|
||||||
|
|
||||||
val channels = mutableListOf<Channel>()
|
val channels = mutableListOf<Channel>()
|
||||||
var name = ""
|
var name = ""
|
||||||
var group = ""
|
var group = ""
|
||||||
@@ -14,15 +28,15 @@ object M3uParser {
|
|||||||
var tvgId = ""
|
var tvgId = ""
|
||||||
var pendingInfo = false
|
var pendingInfo = false
|
||||||
|
|
||||||
for (rawLine in content.lineSequence()) {
|
for (rawLine in text.lineSequence()) {
|
||||||
val line = rawLine.trim()
|
val line = rawLine.trim().trimStart(BOM)
|
||||||
when {
|
when {
|
||||||
line.startsWith("#EXTINF", ignoreCase = true) -> {
|
line.startsWith("#EXTINF", ignoreCase = true) -> {
|
||||||
val attrs = attrRegex.findAll(line).associate { it.groupValues[1].lowercase() to it.groupValues[2] }
|
val attrs = attrRegex.findAll(line).associate { it.groupValues[1].lowercase() to it.groupValues[2] }
|
||||||
group = attrs["group-title"].orEmpty()
|
group = attrs["group-title"].orEmpty()
|
||||||
logo = attrs["tvg-logo"].orEmpty()
|
logo = attrs["tvg-logo"].orEmpty()
|
||||||
tvgId = attrs["tvg-id"].orEmpty()
|
tvgId = attrs["tvg-id"].orEmpty()
|
||||||
name = line.substringAfterLast(',', "").trim()
|
name = displayName(line)
|
||||||
if (name.isEmpty()) name = attrs["tvg-name"].orEmpty()
|
if (name.isEmpty()) name = attrs["tvg-name"].orEmpty()
|
||||||
pendingInfo = true
|
pendingInfo = true
|
||||||
}
|
}
|
||||||
@@ -41,11 +55,43 @@ object M3uParser {
|
|||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
// Bare URL without #EXTINF — still a playable entry.
|
// Bare URL without #EXTINF — still a playable entry.
|
||||||
channels.add(Channel(line, line, "", ""))
|
channels.add(Channel(line, line, group, ""))
|
||||||
if (channels.size >= MAX_CHANNELS) return channels
|
if (channels.size >= MAX_CHANNELS) return channels
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return channels
|
return channels
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun looksLikePlaylist(text: String): Boolean {
|
||||||
|
val head = text.lineSequence().take(50)
|
||||||
|
return head.any {
|
||||||
|
val line = it.trim().trimStart(BOM)
|
||||||
|
line.startsWith("#EXTM3U", ignoreCase = true) ||
|
||||||
|
line.startsWith("#EXTINF", ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The display name is everything after the FIRST comma that follows the
|
||||||
|
* duration field — `substringAfterLast` swallowed every name containing
|
||||||
|
* a comma ("Comedy Central, HD" became "HD").
|
||||||
|
*/
|
||||||
|
private fun displayName(extinf: String): String {
|
||||||
|
val payload = extinf.substringAfter(':', "")
|
||||||
|
val comma = firstUnquotedComma(payload)
|
||||||
|
return if (comma < 0) "" else payload.substring(comma + 1).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Commas inside attribute values (group-title="A, B") do not separate. */
|
||||||
|
private fun firstUnquotedComma(payload: String): Int {
|
||||||
|
var inQuotes = false
|
||||||
|
payload.forEachIndexed { index, c ->
|
||||||
|
when {
|
||||||
|
c == '"' -> inQuotes = !inQuotes
|
||||||
|
c == ',' && !inQuotes -> return index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import java.util.concurrent.Executors
|
|||||||
/**
|
/**
|
||||||
* Embedded HTTP + WebSocket server. Serves the phone remote (a single HTML
|
* Embedded HTTP + WebSocket server. Serves the phone remote (a single HTML
|
||||||
* page) over HTTP and takes playback commands over a WebSocket. A client
|
* page) over HTTP and takes playback commands over a WebSocket. A client
|
||||||
* authorizes itself with the four-digit pairing code from the QR code.
|
* authorizes itself with the token from the QR code or the four-digit
|
||||||
|
* pairing code.
|
||||||
*/
|
*/
|
||||||
class ControlServer(
|
class ControlServer(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
@@ -51,30 +52,63 @@ class ControlServer(
|
|||||||
// sends are serialized through this worker.
|
// sends are serialized through this worker.
|
||||||
private val sendExecutor = Executors.newSingleThreadExecutor()
|
private val sendExecutor = Executors.newSingleThreadExecutor()
|
||||||
private val clients = CopyOnWriteArrayList<RemoteSocket>()
|
private val clients = CopyOnWriteArrayList<RemoteSocket>()
|
||||||
private val pairingCode = Pairing.code(context)
|
|
||||||
private val pairingToken = Pairing.token(context)
|
|
||||||
private var pingTimer: Timer? = null
|
private var pingTimer: Timer? = null
|
||||||
|
|
||||||
// Rate limit for the human-typable 4-digit code (the QR token is not
|
/** False when the port could not be bound; the app stays usable. */
|
||||||
// brute-forceable and stays exempt).
|
@Volatile
|
||||||
private val codeAttempts = ArrayDeque<Long>()
|
var running = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
// Failed authentication attempts per remote address. Counting every
|
||||||
|
// failure (not just the ones carrying a code) keeps a hostile client
|
||||||
|
// 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[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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
private fun codeAttemptAllowed(): Boolean {
|
private fun clearFailures(address: String) {
|
||||||
val now = System.currentTimeMillis()
|
attempts.remove(address)
|
||||||
while (codeAttempts.isNotEmpty() && now - codeAttempts.first() > CODE_ATTEMPT_WINDOW_MS) {
|
|
||||||
codeAttempts.removeFirst()
|
|
||||||
}
|
|
||||||
if (codeAttempts.size >= CODE_ATTEMPT_MAX) return false
|
|
||||||
codeAttempts.addLast(now)
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startServer() {
|
fun startServer() {
|
||||||
start(0, true)
|
// A busy port must not take the whole app down — the remote is
|
||||||
|
// optional, everything else keeps working.
|
||||||
|
// 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
|
||||||
pingTimer = Timer("ws-ping", true).also {
|
pingTimer = Timer("ws-ping", true).also {
|
||||||
it.schedule(object : TimerTask() {
|
it.schedule(object : TimerTask() {
|
||||||
override fun run() = pingClients()
|
override fun run() = maintainClients()
|
||||||
}, PING_INTERVAL_MS, PING_INTERVAL_MS)
|
}, PING_INTERVAL_MS, PING_INTERVAL_MS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,17 +117,41 @@ class ControlServer(
|
|||||||
pingTimer?.cancel()
|
pingTimer?.cancel()
|
||||||
pingTimer = null
|
pingTimer = null
|
||||||
sendExecutor.shutdown()
|
sendExecutor.shutdown()
|
||||||
stop()
|
if (running) runCatching { stop() }
|
||||||
|
running = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- HTTP ---
|
// --- HTTP ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rejects cross-origin upgrades. NanoWSD itself never looks at `Origin`,
|
||||||
|
* and WebSockets are exempt from the same-origin policy, so without this
|
||||||
|
* any page on the LAN could talk to the control socket.
|
||||||
|
*/
|
||||||
|
override fun serve(session: IHTTPSession): Response {
|
||||||
|
val origin = session.headers["origin"]
|
||||||
|
if (origin != null && !isOwnOrigin(origin, session.headers["host"])) {
|
||||||
|
Log.w(TAG, "rejected cross-origin request")
|
||||||
|
return newFixedLengthResponse(
|
||||||
|
Response.Status.FORBIDDEN, NanoHTTPD.MIME_PLAINTEXT, "forbidden",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return super.serve(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isOwnOrigin(origin: String, host: String?): Boolean {
|
||||||
|
if (host.isNullOrEmpty()) return false
|
||||||
|
return origin.equals("http://$host", ignoreCase = true) ||
|
||||||
|
origin.equals("https://$host", ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
override fun serveHttp(session: IHTTPSession): Response {
|
override fun serveHttp(session: IHTTPSession): Response {
|
||||||
return when (session.uri) {
|
return when (session.uri) {
|
||||||
"/", "/index.html" -> {
|
"/", "/index.html" -> {
|
||||||
val html = context.assets.open("remote/index.html").bufferedReader().use { it.readText() }
|
val html = context.assets.open("remote/index.html").bufferedReader().use { it.readText() }
|
||||||
newFixedLengthResponse(Response.Status.OK, "text/html; charset=utf-8", html).apply {
|
newFixedLengthResponse(Response.Status.OK, "text/html; charset=utf-8", html).apply {
|
||||||
addHeader("Cache-Control", "no-store")
|
addHeader("Cache-Control", "no-store")
|
||||||
|
addHeader("Referrer-Policy", "no-referrer")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> newFixedLengthResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, "not found")
|
else -> newFixedLengthResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, "not found")
|
||||||
@@ -102,7 +160,8 @@ class ControlServer(
|
|||||||
|
|
||||||
// --- WebSocket ---
|
// --- WebSocket ---
|
||||||
|
|
||||||
override fun openWebSocket(handshake: IHTTPSession): WebSocket = RemoteSocket(handshake)
|
override fun openWebSocket(handshake: IHTTPSession): WebSocket =
|
||||||
|
RemoteSocket(handshake, handshake.remoteIpAddress.orEmpty())
|
||||||
|
|
||||||
fun broadcast(message: JSONObject) {
|
fun broadcast(message: JSONObject) {
|
||||||
val payload = message.toString()
|
val payload = message.toString()
|
||||||
@@ -139,8 +198,18 @@ class ControlServer(
|
|||||||
.put("channels", Channel.listToJson(listener.currentChannels()))
|
.put("channels", Channel.listToJson(listener.currentChannels()))
|
||||||
.put("extras", listener.channelsExtras())
|
.put("extras", listener.channelsExtras())
|
||||||
|
|
||||||
private fun pingClients() {
|
/**
|
||||||
|
* Pings live clients and drops sockets that never authenticated. Without
|
||||||
|
* this an unauthenticated connection would pin a thread forever.
|
||||||
|
*/
|
||||||
|
private fun maintainClients() {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
clients.forEach { client ->
|
clients.forEach { client ->
|
||||||
|
if (!client.authorized && now - client.openedAt > HANDSHAKE_TIMEOUT_MS) {
|
||||||
|
Log.d(TAG, "dropping client that never authenticated")
|
||||||
|
client.dropSilently()
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
client.ping(PING_PAYLOAD)
|
client.ping(PING_PAYLOAD)
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
@@ -156,12 +225,16 @@ class ControlServer(
|
|||||||
mainHandler.post { listener.onClientsChanged(count, newestName) }
|
mainHandler.post { listener.onClientsChanged(count, newestName) }
|
||||||
}
|
}
|
||||||
|
|
||||||
inner class RemoteSocket(handshake: IHTTPSession) : WebSocket(handshake) {
|
inner class RemoteSocket(
|
||||||
|
handshake: IHTTPSession,
|
||||||
|
private val remoteAddress: String,
|
||||||
|
) : WebSocket(handshake) {
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
var authorized = false
|
var authorized = false
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
val openedAt = System.currentTimeMillis()
|
||||||
private var deviceName: String = ""
|
private var deviceName: String = ""
|
||||||
|
|
||||||
fun trySend(payload: String) {
|
fun trySend(payload: String) {
|
||||||
@@ -173,7 +246,19 @@ class ControlServer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun dropSilently() {
|
||||||
|
clients.remove(this)
|
||||||
|
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "timeout", false) }
|
||||||
|
}
|
||||||
|
|
||||||
override fun onOpen() {
|
override fun onOpen() {
|
||||||
|
// Cap concurrent sockets — a TV remote needs a handful, and an
|
||||||
|
// unbounded count is a free resource-exhaustion vector.
|
||||||
|
if (clients.size >= MAX_CLIENTS) {
|
||||||
|
Log.w(TAG, "client limit reached, refusing connection")
|
||||||
|
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "too many clients", false) }
|
||||||
|
return
|
||||||
|
}
|
||||||
clients.add(this)
|
clients.add(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,25 +312,27 @@ class ControlServer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun handleHello(msg: JSONObject) {
|
private fun handleHello(msg: JSONObject) {
|
||||||
val token = msg.optString("token")
|
if (!attemptAllowed(remoteAddress)) {
|
||||||
val tokenOk = token.isNotEmpty() && token == pairingToken
|
|
||||||
val codeOk = !tokenOk && msg.optString("code").let { code ->
|
|
||||||
code.isNotEmpty() && when {
|
|
||||||
!codeAttemptAllowed() -> {
|
|
||||||
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) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
else -> code == pairingCode
|
val tokenOk = Pairing.isValidToken(context, msg.optString("token"))
|
||||||
}
|
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
|
||||||
|
// the QR token — otherwise the guessable path would yield the
|
||||||
|
// master credential.
|
||||||
|
val grantedToken =
|
||||||
|
if (tokenOk) msg.optString("token") else Pairing.newSessionToken(context)
|
||||||
// Build the welcome payload on the main thread — it reads the
|
// Build the welcome payload on the main thread — it reads the
|
||||||
// player and UI state, which must not happen on the WS thread.
|
// player and UI state, which must not happen on the WS thread.
|
||||||
post {
|
post {
|
||||||
@@ -253,7 +340,7 @@ class ControlServer(
|
|||||||
.put("type", "welcome")
|
.put("type", "welcome")
|
||||||
.put("setup", listener.setupNeeded())
|
.put("setup", listener.setupNeeded())
|
||||||
.put("device", android.os.Build.MODEL)
|
.put("device", android.os.Build.MODEL)
|
||||||
.put("token", pairingToken)
|
.put("token", grantedToken)
|
||||||
.put("status", listener.currentStatus())
|
.put("status", listener.currentStatus())
|
||||||
.put("playlistUrl", listener.currentPlaylistUrl())
|
.put("playlistUrl", listener.currentPlaylistUrl())
|
||||||
.put("channels", Channel.listToJson(listener.currentChannels()))
|
.put("channels", Channel.listToJson(listener.currentChannels()))
|
||||||
@@ -272,8 +359,12 @@ class ControlServer(
|
|||||||
private companion object {
|
private companion object {
|
||||||
const val TAG = "ControlServer"
|
const val TAG = "ControlServer"
|
||||||
const val PING_INTERVAL_MS = 8_000L
|
const val PING_INTERVAL_MS = 8_000L
|
||||||
const val CODE_ATTEMPT_WINDOW_MS = 60_000L
|
const val SOCKET_TIMEOUT_MS = 40_000
|
||||||
const val CODE_ATTEMPT_MAX = 5
|
const val ATTEMPT_WINDOW_MS = 60_000L
|
||||||
|
const val ATTEMPT_MAX = 5
|
||||||
|
const val MAX_TRACKED_ADDRESSES = 64
|
||||||
|
const val MAX_CLIENTS = 8
|
||||||
|
const val HANDSHAKE_TIMEOUT_MS = 10_000L
|
||||||
val PING_PAYLOAD = byteArrayOf(0x6e, 0x63)
|
val PING_PAYLOAD = byteArrayOf(0x6e, 0x63)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,6 +132,10 @@ private fun TopBar(state: AppState) {
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.width(18.dp))
|
Spacer(Modifier.width(18.dp))
|
||||||
}
|
}
|
||||||
|
state.updateAvailable?.let { version ->
|
||||||
|
UpdateChip(version) { state.screen = AppState.Screen.SETTINGS }
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
}
|
||||||
GearButton {
|
GearButton {
|
||||||
state.screen = if (state.screen == AppState.Screen.LIVE) AppState.Screen.SETTINGS
|
state.screen = if (state.screen == AppState.Screen.LIVE) AppState.Screen.SETTINGS
|
||||||
else AppState.Screen.LIVE
|
else AppState.Screen.LIVE
|
||||||
@@ -139,6 +143,33 @@ private fun TopBar(state: AppState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown in the top bar as soon as the start-up check finds a newer
|
||||||
|
* release — otherwise an update would only be noticed by someone who
|
||||||
|
* happens to open the settings.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun UpdateChip(version: String, onClick: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)),
|
||||||
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = CastarrColors.accentDim,
|
||||||
|
contentColor = CastarrColors.accent,
|
||||||
|
focusedContainerColor = CastarrColors.accent,
|
||||||
|
focusedContentColor = CastarrColors.onAccent,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Update $version",
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Round settings button with a drawn gear (glyphs render as emoji). */
|
/** Round settings button with a drawn gear (glyphs render as emoji). */
|
||||||
@Composable
|
@Composable
|
||||||
private fun GearButton(onClick: () -> Unit) {
|
private fun GearButton(onClick: () -> Unit) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package dev.castarr.tv.ui
|
package dev.castarr.tv.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -23,13 +24,22 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.drawscope.clipPath
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusProperties
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -69,7 +79,28 @@ fun LiveScreen(state: AppState) {
|
|||||||
val groups = remember(allChannels) {
|
val groups = remember(allChannels) {
|
||||||
allChannels.mapNotNull { it.group.ifEmpty { null } }.distinct().sorted()
|
allChannels.mapNotNull { it.group.ifEmpty { null } }.distinct().sorted()
|
||||||
}
|
}
|
||||||
|
val teams = remember(state.enabledTeams) {
|
||||||
|
dev.castarr.tv.data.TeamFilters.all.filter { it.key in state.enabledTeams }
|
||||||
|
}
|
||||||
|
// Recomputed when the EPG or the channel list changes — scanning the
|
||||||
|
// sport groups' programmes is cheap, but not per frame.
|
||||||
|
val matchesByTeam = remember(teams, allChannels, epgStamp) {
|
||||||
|
teams.associate { it.key to state.teamMatches(it.key) }
|
||||||
|
}
|
||||||
|
// 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 } }
|
||||||
val channels = when {
|
val channels = when {
|
||||||
|
activeTeam != null -> activeMatches.map { it.first }
|
||||||
state.favoritesOnly -> allChannels.filter { it.backendId in favorites }
|
state.favoritesOnly -> allChannels.filter { it.backendId in favorites }
|
||||||
state.groupFilter != null -> allChannels.filter { it.group == state.groupFilter }
|
state.groupFilter != null -> allChannels.filter { it.group == state.groupFilter }
|
||||||
else -> allChannels
|
else -> allChannels
|
||||||
@@ -92,15 +123,38 @@ fun LiveScreen(state: AppState) {
|
|||||||
// list at the top — which also gets scrolled up on every group change.
|
// list at the top — which also gets scrolled up on every group change.
|
||||||
val railFocus = remember { FocusRequester() }
|
val railFocus = remember { FocusRequester() }
|
||||||
val listFocus = remember { FocusRequester() }
|
val listFocus = remember { FocusRequester() }
|
||||||
|
val restoreFocus = remember { FocusRequester() }
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
LaunchedEffect(state.groupFilter, state.favoritesOnly) {
|
// Index of the channel the viewer last watched, so leaving playback
|
||||||
listState.scrollToItem(0)
|
// returns them to where they were instead of the top of the list.
|
||||||
|
val restoreIndex = state.lastWatched?.let { watched ->
|
||||||
|
channels.indexOfFirst { it.url == watched.url }.takeIf { it >= 0 }
|
||||||
|
}
|
||||||
|
var restored by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(state.groupFilter, state.favoritesOnly) {
|
||||||
|
// A fresh group starts at the top; a return from playback does not.
|
||||||
|
if (restoreIndex == null) listState.scrollToItem(0)
|
||||||
|
}
|
||||||
|
LaunchedEffect(restoreIndex, channels.size, state.restorePending) {
|
||||||
|
if (restoreIndex != null && (state.restorePending || !restored)) {
|
||||||
|
listState.scrollToItem(restoreIndex)
|
||||||
|
runCatching { restoreFocus.requestFocus() }
|
||||||
|
restored = true
|
||||||
|
state.restorePending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A FocusRequester that is not currently attached throws when used, and
|
||||||
|
// in a LazyColumn the selected row may well be scrolled out of
|
||||||
|
// composition — so jumps are attempted, not declared.
|
||||||
|
val intoList = if (channels.isEmpty()) Modifier else Modifier.onPreviewKeyEvent { event ->
|
||||||
|
event.type == KeyEventType.KeyDown && event.key == Key.DirectionRight &&
|
||||||
|
runCatching { listFocus.requestFocus() }.isSuccess
|
||||||
}
|
}
|
||||||
val intoList = if (channels.isEmpty()) Modifier
|
|
||||||
else Modifier.focusProperties { right = listFocus }
|
|
||||||
|
|
||||||
|
val railState = rememberLazyListState()
|
||||||
Row(Modifier.fillMaxSize()) {
|
Row(Modifier.fillMaxSize()) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
|
state = railState,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(264.dp)
|
.width(264.dp)
|
||||||
.fillMaxHeight(),
|
.fillMaxHeight(),
|
||||||
@@ -110,7 +164,8 @@ fun LiveScreen(state: AppState) {
|
|||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
val selected = !state.favoritesOnly && state.groupFilter == null
|
val selected = !state.favoritesOnly && state.groupFilter == null &&
|
||||||
|
state.activeTeam == null
|
||||||
GroupItem(
|
GroupItem(
|
||||||
label = "Alle Sender",
|
label = "Alle Sender",
|
||||||
count = allChannels.size,
|
count = allChannels.size,
|
||||||
@@ -118,7 +173,9 @@ fun LiveScreen(state: AppState) {
|
|||||||
modifier = intoList.then(
|
modifier = intoList.then(
|
||||||
if (selected) Modifier.focusRequester(railFocus) else Modifier
|
if (selected) Modifier.focusRequester(railFocus) else Modifier
|
||||||
),
|
),
|
||||||
|
suppressAutoSelect = { state.restorePending },
|
||||||
) {
|
) {
|
||||||
|
state.activeTeam = null
|
||||||
state.favoritesOnly = false
|
state.favoritesOnly = false
|
||||||
state.groupFilter = null
|
state.groupFilter = null
|
||||||
}
|
}
|
||||||
@@ -128,16 +185,39 @@ fun LiveScreen(state: AppState) {
|
|||||||
GroupItem(
|
GroupItem(
|
||||||
label = "★ Favoriten",
|
label = "★ Favoriten",
|
||||||
count = favorites.size,
|
count = favorites.size,
|
||||||
selected = state.favoritesOnly,
|
selected = state.favoritesOnly && state.activeTeam == null,
|
||||||
modifier = intoList.then(
|
modifier = intoList.then(
|
||||||
if (state.favoritesOnly) Modifier.focusRequester(railFocus) else Modifier
|
if (state.favoritesOnly) Modifier.focusRequester(railFocus) else Modifier
|
||||||
),
|
),
|
||||||
|
suppressAutoSelect = { state.restorePending },
|
||||||
) {
|
) {
|
||||||
|
state.activeTeam = null
|
||||||
state.favoritesOnly = true
|
state.favoritesOnly = true
|
||||||
state.groupFilter = null
|
state.groupFilter = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
items(teams, key = { it.key }) { club ->
|
||||||
|
val clubMatches = matchesByTeam[club.key].orEmpty()
|
||||||
|
GroupItem(
|
||||||
|
label = club.label,
|
||||||
|
count = clubMatches.size,
|
||||||
|
selected = state.activeTeam == club.key,
|
||||||
|
leading = { Crest(club, state) },
|
||||||
|
urgency = remember(clubMatches, urgencyTick) {
|
||||||
|
state.teamUrgency(clubMatches)
|
||||||
|
},
|
||||||
|
modifier = intoList.then(
|
||||||
|
if (state.activeTeam == club.key) Modifier.focusRequester(railFocus)
|
||||||
|
else Modifier
|
||||||
|
),
|
||||||
|
suppressAutoSelect = { state.restorePending },
|
||||||
|
) {
|
||||||
|
state.activeTeam = club.key
|
||||||
|
state.favoritesOnly = false
|
||||||
|
state.groupFilter = null
|
||||||
|
}
|
||||||
|
}
|
||||||
item {
|
item {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -151,11 +231,13 @@ fun LiveScreen(state: AppState) {
|
|||||||
GroupItem(
|
GroupItem(
|
||||||
label = group,
|
label = group,
|
||||||
count = remember(allChannels, group) { allChannels.count { it.group == group } },
|
count = remember(allChannels, group) { allChannels.count { it.group == group } },
|
||||||
selected = state.groupFilter == group,
|
selected = state.groupFilter == group && state.activeTeam == null,
|
||||||
modifier = intoList.then(
|
modifier = intoList.then(
|
||||||
if (state.groupFilter == group) Modifier.focusRequester(railFocus) else Modifier
|
if (state.groupFilter == group) Modifier.focusRequester(railFocus) else Modifier
|
||||||
),
|
),
|
||||||
|
suppressAutoSelect = { state.restorePending },
|
||||||
) {
|
) {
|
||||||
|
state.activeTeam = null
|
||||||
state.favoritesOnly = false
|
state.favoritesOnly = false
|
||||||
state.groupFilter = group
|
state.groupFilter = group
|
||||||
}
|
}
|
||||||
@@ -175,9 +257,13 @@ fun LiveScreen(state: AppState) {
|
|||||||
if (channels.isEmpty()) {
|
if (channels.isEmpty()) {
|
||||||
item {
|
item {
|
||||||
Text(
|
Text(
|
||||||
if (state.favoritesOnly)
|
when {
|
||||||
|
activeTeam != null ->
|
||||||
|
"Heute läuft nichts mehr mit ${activeTeam.fullName}."
|
||||||
|
state.favoritesOnly ->
|
||||||
"Noch keine Favoriten — halte OK auf einem Sender gedrückt."
|
"Noch keine Favoriten — halte OK auf einem Sender gedrückt."
|
||||||
else "Diese Gruppe ist leer.",
|
else -> "Diese Gruppe ist leer."
|
||||||
|
},
|
||||||
color = CastarrColors.faint,
|
color = CastarrColors.faint,
|
||||||
fontFamily = AppFont,
|
fontFamily = AppFont,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
@@ -192,9 +278,18 @@ fun LiveScreen(state: AppState) {
|
|||||||
// matching the remote's number pad in every view.
|
// matching the remote's number pad in every view.
|
||||||
number = allChannels.indexOf(channel) + 1,
|
number = allChannels.indexOf(channel) + 1,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.focusProperties { left = railFocus }
|
.onPreviewKeyEvent { event ->
|
||||||
.then(if (listIndex == 0) Modifier.focusRequester(listFocus) else Modifier),
|
event.type == KeyEventType.KeyDown &&
|
||||||
|
event.key == Key.DirectionLeft &&
|
||||||
|
runCatching { railFocus.requestFocus() }.isSuccess
|
||||||
|
}
|
||||||
|
.then(if (listIndex == 0) Modifier.focusRequester(listFocus) else Modifier)
|
||||||
|
.then(
|
||||||
|
if (listIndex == restoreIndex) Modifier.focusRequester(restoreFocus)
|
||||||
|
else Modifier
|
||||||
|
),
|
||||||
nowNext = state.nowNext(channel),
|
nowNext = state.nowNext(channel),
|
||||||
|
highlight = teamHits[channel.url],
|
||||||
playing = state.currentChannel?.url == channel.url,
|
playing = state.currentChannel?.url == channel.url,
|
||||||
// All rows are favorites in the favorites view — the
|
// All rows are favorites in the favorites view — the
|
||||||
// star only carries meaning elsewhere.
|
// star only carries meaning elsewhere.
|
||||||
@@ -216,6 +311,10 @@ private fun GroupItem(
|
|||||||
count: Int,
|
count: Int,
|
||||||
selected: Boolean,
|
selected: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
leading: (@Composable () -> Unit)? = null,
|
||||||
|
/** Replaces the count and adds a dot when a match is on or imminent. */
|
||||||
|
urgency: AppState.TeamUrgency = AppState.TeamUrgency.NONE,
|
||||||
|
suppressAutoSelect: () -> Boolean = { false },
|
||||||
onSelect: () -> Unit,
|
onSelect: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
@@ -224,7 +323,7 @@ private fun GroupItem(
|
|||||||
onClick = onSelect,
|
onClick = onSelect,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.onFocusChanged { if (it.isFocused) onSelect() },
|
.onFocusChanged { if (it.isFocused && !suppressAutoSelect()) onSelect() },
|
||||||
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)),
|
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)),
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
@@ -238,6 +337,10 @@ private fun GroupItem(
|
|||||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp),
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
|
if (leading != null) {
|
||||||
|
leading()
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
fontFamily = AppFont,
|
fontFamily = AppFont,
|
||||||
@@ -248,13 +351,34 @@ private fun GroupItem(
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
|
when (urgency) {
|
||||||
|
AppState.TeamUrgency.LIVE, AppState.TeamUrgency.SOON -> {
|
||||||
|
val accent =
|
||||||
|
if (urgency == AppState.TeamUrgency.LIVE) CastarrColors.live
|
||||||
|
else CastarrColors.accent
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(6.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(accent)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
Text(
|
Text(
|
||||||
|
if (urgency == AppState.TeamUrgency.LIVE) "läuft" else "gleich",
|
||||||
|
color = accent,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AppState.TeamUrgency.NONE -> Text(
|
||||||
"$count",
|
"$count",
|
||||||
fontFamily = AppFont,
|
fontFamily = AppFont,
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -263,6 +387,7 @@ private fun ChannelRow(
|
|||||||
number: Int,
|
number: Int,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
nowNext: NowNext,
|
nowNext: NowNext,
|
||||||
|
highlight: AppState.TeamHit? = null,
|
||||||
playing: Boolean,
|
playing: Boolean,
|
||||||
favorite: Boolean,
|
favorite: Boolean,
|
||||||
epgStamp: Long,
|
epgStamp: Long,
|
||||||
@@ -323,7 +448,13 @@ private fun ChannelRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(24.dp))
|
Spacer(Modifier.width(24.dp))
|
||||||
|
if (highlight != null) {
|
||||||
|
// In the club view the matching broadcast is the point, not
|
||||||
|
// whatever happens to be running.
|
||||||
|
HighlightCell(highlight, Modifier.weight(1f))
|
||||||
|
} else {
|
||||||
EpgCell(nowNext, Modifier.weight(1f))
|
EpgCell(nowNext, Modifier.weight(1f))
|
||||||
|
}
|
||||||
if (playing) {
|
if (playing) {
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
Box(
|
Box(
|
||||||
@@ -337,6 +468,50 @@ private fun ChannelRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The club badge. The images ship in the APK (fetched at build time by
|
||||||
|
* tools/fetch-crests.py, never committed), so nothing has to load over the
|
||||||
|
* network; the coloured shield stands in if one is ever missing.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun Crest(team: dev.castarr.tv.data.TeamFilter, state: AppState) {
|
||||||
|
Box(Modifier.size(20.dp), contentAlignment = Alignment.Center) {
|
||||||
|
SubcomposeAsyncImage(
|
||||||
|
model = "file:///android_asset/crests/${team.key}.png",
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
loading = { ShieldFallback(team) },
|
||||||
|
error = { ShieldFallback(team) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ShieldFallback(team: dev.castarr.tv.data.TeamFilter) {
|
||||||
|
Canvas(Modifier.size(18.dp)) {
|
||||||
|
val w = size.width
|
||||||
|
val h = size.height
|
||||||
|
val shield = Path().apply {
|
||||||
|
moveTo(w * 0.5f, 0f)
|
||||||
|
lineTo(w, h * 0.18f)
|
||||||
|
lineTo(w, h * 0.55f)
|
||||||
|
cubicTo(w, h * 0.82f, w * 0.75f, h * 0.95f, w * 0.5f, h)
|
||||||
|
cubicTo(w * 0.25f, h * 0.95f, 0f, h * 0.82f, 0f, h * 0.55f)
|
||||||
|
lineTo(0f, h * 0.18f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
drawPath(shield, team.primary)
|
||||||
|
clipPath(shield) {
|
||||||
|
drawRect(
|
||||||
|
team.secondary,
|
||||||
|
topLeft = Offset(0f, h * 0.42f),
|
||||||
|
size = androidx.compose.ui.geometry.Size(w, h * 0.16f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Bare channel logo (they ship transparent); initials as fallback. */
|
/** Bare channel logo (they ship transparent); initials as fallback. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun LogoTile(channel: Channel) {
|
private fun LogoTile(channel: Channel) {
|
||||||
@@ -371,6 +546,42 @@ private fun LogoInitials(initials: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The next club broadcast on this channel, and how many follow it. */
|
||||||
|
@Composable
|
||||||
|
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) {
|
||||||
|
Text(
|
||||||
|
programme.title,
|
||||||
|
color = CastarrColors.fg,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Text(
|
||||||
|
if (running) "läuft" else "ab ${formatClock(programme.start)}",
|
||||||
|
color = if (running) CastarrColors.accent else CastarrColors.muted,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = if (running) FontWeight.SemiBold else FontWeight.Normal,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
"${formatClock(programme.start)}–${formatClock(programme.stop)}" +
|
||||||
|
if (hit.further > 0) " · +${hit.further} weitere" else "",
|
||||||
|
color = CastarrColors.faint,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun formatClock(millis: Long): String =
|
private fun formatClock(millis: Long): String =
|
||||||
SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis))
|
SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis))
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ import androidx.compose.ui.focus.FocusRequester
|
|||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Path
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.Shadow
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.input.key.KeyEventType
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
import androidx.compose.ui.input.key.key
|
import androidx.compose.ui.input.key.key
|
||||||
@@ -37,6 +39,7 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
|
|||||||
import androidx.compose.ui.input.key.type
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
@@ -98,6 +101,11 @@ fun PlayerScreen(state: AppState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Soft drop shadow so overlay text stays readable on bright video. */
|
||||||
|
private val OverlayText = TextStyle(
|
||||||
|
shadow = Shadow(Color(0xB3000000), Offset(0f, 2f), blurRadius = 8f),
|
||||||
|
)
|
||||||
|
|
||||||
private fun formatClock(millis: Long): String =
|
private fun formatClock(millis: Long): String =
|
||||||
SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis))
|
SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis))
|
||||||
|
|
||||||
@@ -146,7 +154,8 @@ private fun Overlay(state: AppState) {
|
|||||||
.padding(top = 22.dp, end = 30.dp),
|
.padding(top = 22.dp, end = 30.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(name, color = CastarrColors.muted, fontFamily = AppFont, fontSize = 12.sp)
|
Text(name, color = CastarrColors.muted, fontFamily = AppFont,
|
||||||
|
style = OverlayText, fontSize = 12.sp)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -168,7 +177,8 @@ private fun Overlay(state: AppState) {
|
|||||||
val statusWord = when (state.playerState) {
|
val statusWord = when (state.playerState) {
|
||||||
"paused" -> "Pausiert"
|
"paused" -> "Pausiert"
|
||||||
"buffering" -> "Lädt…"
|
"buffering" -> "Lädt…"
|
||||||
"error" -> "Wiedergabefehler"
|
"reconnecting" -> "Verbindung wird wiederhergestellt…"
|
||||||
|
"error" -> "Sender gerade nicht erreichbar"
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
if (statusWord != null) {
|
if (statusWord != null) {
|
||||||
@@ -178,18 +188,38 @@ private fun Overlay(state: AppState) {
|
|||||||
statusWord,
|
statusWord,
|
||||||
color = CastarrColors.muted,
|
color = CastarrColors.muted,
|
||||||
fontFamily = AppFont,
|
fontFamily = AppFont,
|
||||||
|
style = OverlayText,
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
}
|
}
|
||||||
|
Row(verticalAlignment = Alignment.Bottom) {
|
||||||
|
// Channel number inline before the name (matches the list
|
||||||
|
// and the number-pad zapping).
|
||||||
|
val number = state.currentChannel?.let { current ->
|
||||||
|
state.activeChannels().indexOfFirst { it.url == current.url } + 1
|
||||||
|
} ?: 0
|
||||||
|
if (number > 0) {
|
||||||
|
Text(
|
||||||
|
"$number",
|
||||||
|
color = CastarrColors.accent,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
style = OverlayText,
|
||||||
|
fontSize = 30.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(14.dp))
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
state.currentChannel?.name.orEmpty(),
|
state.currentChannel?.name.orEmpty(),
|
||||||
color = CastarrColors.fg,
|
color = CastarrColors.fg,
|
||||||
fontFamily = AppFont,
|
fontFamily = AppFont,
|
||||||
|
style = OverlayText,
|
||||||
fontSize = 30.sp,
|
fontSize = 30.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val info = state.currentChannel?.let { state.nowNext(it) }
|
val info = state.currentChannel?.let { state.nowNext(it) }
|
||||||
val now = info?.now?.takeUnless { isEpgPlaceholder(it.title) }
|
val now = info?.now?.takeUnless { isEpgPlaceholder(it.title) }
|
||||||
@@ -200,13 +230,15 @@ private fun Overlay(state: AppState) {
|
|||||||
now.title,
|
now.title,
|
||||||
color = CastarrColors.fg,
|
color = CastarrColors.fg,
|
||||||
fontFamily = AppFont,
|
fontFamily = AppFont,
|
||||||
|
style = OverlayText,
|
||||||
fontSize = 18.sp,
|
fontSize = 18.sp,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(14.dp))
|
Spacer(Modifier.width(14.dp))
|
||||||
Text(
|
Text(
|
||||||
"${formatClock(now.start)} – ${formatClock(now.stop)}",
|
"${formatClock(now.start)} – ${formatClock(now.stop)}",
|
||||||
color = CastarrColors.muted,
|
color = Color(0xFFD7DBE0),
|
||||||
fontFamily = AppFont,
|
fontFamily = AppFont,
|
||||||
|
style = OverlayText,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -217,8 +249,9 @@ private fun Overlay(state: AppState) {
|
|||||||
Spacer(Modifier.height(2.dp))
|
Spacer(Modifier.height(2.dp))
|
||||||
Text(
|
Text(
|
||||||
"Danach: ${next.title} (${formatClock(next.start)})",
|
"Danach: ${next.title} (${formatClock(next.start)})",
|
||||||
color = CastarrColors.faint,
|
color = CastarrColors.muted,
|
||||||
fontFamily = AppFont,
|
fontFamily = AppFont,
|
||||||
|
style = OverlayText,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -238,7 +271,7 @@ private fun Overlay(state: AppState) {
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(3.dp)
|
.height(3.dp)
|
||||||
.clip(RoundedCornerShape(1.5.dp))
|
.clip(RoundedCornerShape(1.5.dp))
|
||||||
.background(CastarrColors.line)
|
.background(Color(0x59FFFFFF))
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -250,13 +283,27 @@ private fun Overlay(state: AppState) {
|
|||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (state.playerState == "error") {
|
||||||
|
// One clear action instead of silently dropping back to
|
||||||
|
// the channel list.
|
||||||
|
PillButton("Erneut versuchen", focusRequester = playFocus) {
|
||||||
|
state.retryPlayback()
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
IconButton(icon = PillIcon.STOP, contentDescription = "Beenden") {
|
||||||
|
state.stopPlayback()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
IconButton(
|
IconButton(
|
||||||
icon = if (state.playerState == "paused") PillIcon.PLAY else PillIcon.PAUSE,
|
icon = if (state.playerState == "paused") PillIcon.PLAY else PillIcon.PAUSE,
|
||||||
contentDescription = if (state.playerState == "paused") "Weiter" else "Pause",
|
contentDescription = if (state.playerState == "paused") "Weiter" else "Pause",
|
||||||
focusRequester = playFocus,
|
focusRequester = playFocus,
|
||||||
) { state.player.toggle() }
|
) { state.player.toggle() }
|
||||||
Spacer(Modifier.width(10.dp))
|
Spacer(Modifier.width(10.dp))
|
||||||
IconButton(icon = PillIcon.STOP, contentDescription = "Beenden") { state.stopPlayback() }
|
IconButton(icon = PillIcon.STOP, contentDescription = "Beenden") {
|
||||||
|
state.stopPlayback()
|
||||||
|
}
|
||||||
|
}
|
||||||
if (state.audioTracks.size > 1) {
|
if (state.audioTracks.size > 1) {
|
||||||
Spacer(Modifier.width(10.dp))
|
Spacer(Modifier.width(10.dp))
|
||||||
val current = state.audioTracks.firstOrNull { it.selected }
|
val current = state.audioTracks.firstOrNull { it.selected }
|
||||||
@@ -267,6 +314,7 @@ private fun Overlay(state: AppState) {
|
|||||||
"▲ ▼ Sender wechseln",
|
"▲ ▼ Sender wechseln",
|
||||||
color = CastarrColors.faint,
|
color = CastarrColors.faint,
|
||||||
fontFamily = AppFont,
|
fontFamily = AppFont,
|
||||||
|
style = OverlayText,
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -346,6 +394,7 @@ private fun PillButton(
|
|||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
fontFamily = AppFont,
|
fontFamily = AppFont,
|
||||||
|
style = OverlayText,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
modifier = Modifier.padding(horizontal = 18.dp, vertical = 9.dp),
|
modifier = Modifier.padding(horizontal = 18.dp, vertical = 9.dp),
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import androidx.compose.foundation.Canvas
|
|||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -14,9 +16,13 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -32,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
|
||||||
@@ -72,13 +79,22 @@ fun SettingsScreen(state: AppState) {
|
|||||||
val profiles by state.dispatcharr.profiles.collectAsState()
|
val profiles by state.dispatcharr.profiles.collectAsState()
|
||||||
var updateStatus by remember { mutableStateOf("") }
|
var updateStatus by remember { mutableStateOf("") }
|
||||||
var picker by remember { mutableStateOf<Picker?>(null) }
|
var picker by remember { mutableStateOf<Picker?>(null) }
|
||||||
|
// Bumped on reset so the QR code and the four-digit code redraw.
|
||||||
|
var pairingEpoch by remember { mutableStateOf(0) }
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.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(
|
||||||
@@ -161,6 +177,41 @@ fun SettingsScreen(state: AppState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsCard("Vereinsmenüs") {
|
||||||
|
Text(
|
||||||
|
"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),
|
||||||
|
)
|
||||||
|
val active = dev.castarr.tv.data.TeamFilters.all
|
||||||
|
.filter { it.key in state.enabledTeams }
|
||||||
|
active.forEach { club ->
|
||||||
|
SettingRow(
|
||||||
|
club.fullName,
|
||||||
|
subtitle = "Erscheint als ${club.label} in der Senderliste · Entfernen",
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard("App") {
|
SettingsCard("App") {
|
||||||
SettingRow(
|
SettingRow(
|
||||||
"Version ${BuildConfig.VERSION_NAME}",
|
"Version ${BuildConfig.VERSION_NAME}",
|
||||||
@@ -201,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",
|
||||||
@@ -212,9 +267,15 @@ fun SettingsScreen(state: AppState) {
|
|||||||
|
|
||||||
Column(Modifier.weight(0.75f)) {
|
Column(Modifier.weight(0.75f)) {
|
||||||
SettingsCard("Handy-Fernbedienung") {
|
SettingsCard("Handy-Fernbedienung") {
|
||||||
val address = remember { Pairing.lanAddress() }
|
val address = remember(pairingEpoch) { Pairing.lanAddress() }
|
||||||
if (address != null) {
|
if (!state.remoteAvailable) {
|
||||||
val qr = remember(address) {
|
Text(
|
||||||
|
"Fernbedienung nicht verfügbar — Port belegt. Neustart des Fernsehers hilft meistens.",
|
||||||
|
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp,
|
||||||
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||||
|
)
|
||||||
|
} else if (address != null) {
|
||||||
|
val qr = remember(address, pairingEpoch) {
|
||||||
Qr.encode(
|
Qr.encode(
|
||||||
Pairing.pairingUrl(context, address), 400,
|
Pairing.pairingUrl(context, address), 400,
|
||||||
android.graphics.Color.parseColor("#101216"),
|
android.graphics.Color.parseColor("#101216"),
|
||||||
@@ -236,6 +297,14 @@ fun SettingsScreen(state: AppState) {
|
|||||||
"${Pairing.remoteUrl(address)} · Code ${Pairing.code(context)}",
|
"${Pairing.remoteUrl(address)} · Code ${Pairing.code(context)}",
|
||||||
color = CastarrColors.muted, fontFamily = AppFont, fontSize = 12.sp,
|
color = CastarrColors.muted, fontFamily = AppFont, fontSize = 12.sp,
|
||||||
)
|
)
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
}
|
||||||
|
SettingRow(
|
||||||
|
"Kopplung zurücksetzen",
|
||||||
|
subtitle = "Neuer Code, alle Handys müssen neu scannen",
|
||||||
|
) {
|
||||||
|
Pairing.reset(context)
|
||||||
|
pairingEpoch++
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
@@ -257,7 +326,13 @@ fun SettingsScreen(state: AppState) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun PickerDialog(picker: Picker, onClose: () -> Unit) {
|
private fun PickerDialog(picker: Picker, onClose: () -> Unit) {
|
||||||
val selectedFocus = remember { FocusRequester() }
|
val selectedFocus = remember { FocusRequester() }
|
||||||
LaunchedEffect(Unit) { selectedFocus.requestFocus() }
|
// A requester bound to no composed row throws; with no preselection
|
||||||
|
// (adding something new) there is deliberately no such row.
|
||||||
|
LaunchedEffect(Unit) { runCatching { selectedFocus.requestFocus() } }
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
LaunchedEffect(picker.selected) {
|
||||||
|
if (picker.selected > 0) listState.scrollToItem(picker.selected)
|
||||||
|
}
|
||||||
Dialog(
|
Dialog(
|
||||||
onDismissRequest = onClose,
|
onDismissRequest = onClose,
|
||||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||||
@@ -286,7 +361,11 @@ private fun PickerDialog(picker: Picker, onClose: () -> Unit) {
|
|||||||
letterSpacing = 2.sp,
|
letterSpacing = 2.sp,
|
||||||
modifier = Modifier.padding(start = 14.dp, bottom = 10.dp),
|
modifier = Modifier.padding(start = 14.dp, bottom = 10.dp),
|
||||||
)
|
)
|
||||||
picker.options.forEachIndexed { index, option ->
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier.heightIn(max = 420.dp),
|
||||||
|
) {
|
||||||
|
itemsIndexed(picker.options) { index, option ->
|
||||||
val selected = index == picker.selected
|
val selected = index == picker.selected
|
||||||
Surface(
|
Surface(
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -295,7 +374,11 @@ private fun PickerDialog(picker: Picker, onClose: () -> Unit) {
|
|||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.then(if (selected) Modifier.focusRequester(selectedFocus) else Modifier),
|
.then(
|
||||||
|
if (selected || (picker.selected < 0 && index == 0))
|
||||||
|
Modifier.focusRequester(selectedFocus)
|
||||||
|
else Modifier
|
||||||
|
),
|
||||||
shape = ClickableSurfaceDefaults.shape(rowShape),
|
shape = ClickableSurfaceDefaults.shape(rowShape),
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
@@ -339,6 +422,7 @@ private fun PickerDialog(picker: Picker, onClose: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Current value plus a small drawn caret, shown at a row's trailing edge. */
|
/** Current value plus a small drawn caret, shown at a row's trailing edge. */
|
||||||
@@ -369,6 +453,35 @@ 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(
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(999.dp))
|
||||||
|
.background(if (on) CastarrColors.accent else CastarrColors.bg)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 5.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
if (on) "An" else "Aus",
|
||||||
|
color = if (on) CastarrColors.onAccent else CastarrColors.muted,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = if (on) FontWeight.SemiBold else FontWeight.Normal,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SettingsCard(title: String, content: @Composable () -> Unit) {
|
private fun SettingsCard(title: String, content: @Composable () -> Unit) {
|
||||||
Column(
|
Column(
|
||||||
|
|||||||
@@ -18,12 +18,12 @@ object UpdateChecker {
|
|||||||
|
|
||||||
private const val TAG = "UpdateChecker"
|
private const val TAG = "UpdateChecker"
|
||||||
private const val LATEST =
|
private const val LATEST =
|
||||||
"https://git.beckm4nn.net/api/v1/repos/benjamin/castarr/releases/latest"
|
"https://git.beckm4nn.net/api/v1/repos/be-nj/castarr/releases/latest"
|
||||||
|
|
||||||
// Release assets need an API token to upload, so the APK is served from
|
// Release assets need an API token to upload, so the APK is served from
|
||||||
// an orphan branch instead — anonymously fetchable on a public repo.
|
// an orphan branch instead — anonymously fetchable on a public repo.
|
||||||
private const val APK_FALLBACK =
|
private const val APK_FALLBACK =
|
||||||
"https://git.beckm4nn.net/benjamin/castarr/raw/branch/apk/castarr.apk"
|
"https://git.beckm4nn.net/be-nj/castarr/raw/branch/apk/castarr.apk"
|
||||||
|
|
||||||
private var apkUrl: String = ""
|
private var apkUrl: String = ""
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ object UpdateChecker {
|
|||||||
return try {
|
return try {
|
||||||
connection.connectTimeout = 10_000
|
connection.connectTimeout = 10_000
|
||||||
connection.readTimeout = 15_000
|
connection.readTimeout = 15_000
|
||||||
connection.setRequestProperty("Accept", "application/vnd.github+json")
|
connection.setRequestProperty("Accept", "application/json")
|
||||||
connection.inputStream.bufferedReader().use { it.readText() }
|
connection.inputStream.bufferedReader().use { it.readText() }
|
||||||
} finally {
|
} finally {
|
||||||
connection.disconnect()
|
connection.disconnect()
|
||||||
|
|||||||
7
app/src/main/res/xml/backup_rules.xml
Normal file
7
app/src/main/res/xml/backup_rules.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Credentials must not travel to Google Drive: the pairing token grants
|
||||||
|
full control of the TV and the OIDC tokens are account credentials. -->
|
||||||
|
<full-backup-content>
|
||||||
|
<exclude domain="sharedpref" path="pairing.xml" />
|
||||||
|
<exclude domain="sharedpref" path="auth.xml" />
|
||||||
|
</full-backup-content>
|
||||||
11
app/src/main/res/xml/data_extraction_rules.xml
Normal file
11
app/src/main/res/xml/data_extraction_rules.xml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup>
|
||||||
|
<exclude domain="sharedpref" path="pairing.xml" />
|
||||||
|
<exclude domain="sharedpref" path="auth.xml" />
|
||||||
|
</cloud-backup>
|
||||||
|
<device-transfer>
|
||||||
|
<exclude domain="sharedpref" path="pairing.xml" />
|
||||||
|
<exclude domain="sharedpref" path="auth.xml" />
|
||||||
|
</device-transfer>
|
||||||
|
</data-extraction-rules>
|
||||||
BIN
docs/screenshots/nummerntasten.png
Normal file
BIN
docs/screenshots/nummerntasten.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
BIN
docs/screenshots/player.png
Normal file
BIN
docs/screenshots/player.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
BIN
docs/screenshots/senderliste.png
Normal file
BIN
docs/screenshots/senderliste.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 143 KiB |
BIN
docs/screenshots/willkommen.png
Normal file
BIN
docs/screenshots/willkommen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
80
tests/unit/M3uParserTest.kt
Normal file
80
tests/unit/M3uParserTest.kt
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package dev.castarr.tv.playlist
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertThrows
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class M3uParserTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `keeps commas inside display names`() {
|
||||||
|
val channels = M3uParser.parse(
|
||||||
|
"""
|
||||||
|
#EXTM3U
|
||||||
|
#EXTINF:-1 group-title="Doku",Comedy Central, HD
|
||||||
|
http://example.com/a.ts
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
assertEquals(1, channels.size)
|
||||||
|
assertEquals("Comedy Central, HD", channels[0].name)
|
||||||
|
assertEquals("Doku", channels[0].group)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `commas inside attributes do not split the name`() {
|
||||||
|
val channels = M3uParser.parse(
|
||||||
|
"""
|
||||||
|
#EXTM3U
|
||||||
|
#EXTINF:-1 group-title="News, Sport" tvg-id="x",Das Erste HD
|
||||||
|
http://example.com/b.ts
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
assertEquals("Das Erste HD", channels[0].name)
|
||||||
|
assertEquals("News, Sport", channels[0].group)
|
||||||
|
assertEquals("x", channels[0].tvgId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `strips a UTF-8 BOM instead of inventing a channel`() {
|
||||||
|
val channels = M3uParser.parse(
|
||||||
|
"#EXTM3U\n#EXTINF:-1,Channel A\nhttp://example.com/a.ts\n"
|
||||||
|
)
|
||||||
|
assertEquals(1, channels.size)
|
||||||
|
assertEquals("Channel A", channels[0].name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejects HTML instead of turning it into channels`() {
|
||||||
|
assertThrows(M3uParser.NotAPlaylistException::class.java) {
|
||||||
|
M3uParser.parse("<!DOCTYPE html>\n<html>\n<body>404</body>\n</html>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejects an empty payload`() {
|
||||||
|
assertThrows(M3uParser.NotAPlaylistException::class.java) {
|
||||||
|
M3uParser.parse("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `accepts a playlist whose header is missing but has entries`() {
|
||||||
|
val channels = M3uParser.parse("#EXTINF:-1,Nur ein Sender\nhttp://example.com/c.ts")
|
||||||
|
assertEquals(1, channels.size)
|
||||||
|
assertEquals("Nur ein Sender", channels[0].name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `falls back to the url when the name is empty`() {
|
||||||
|
val channels = M3uParser.parse("#EXTM3U\n#EXTINF:-1,\nhttp://example.com/d.ts")
|
||||||
|
assertEquals("http://example.com/d.ts", channels[0].name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `carries EXTGRP over to a bare url`() {
|
||||||
|
val channels = M3uParser.parse("#EXTM3U\n#EXTGRP:Sport\nhttp://example.com/e.ts")
|
||||||
|
assertTrue(channels.isNotEmpty())
|
||||||
|
assertEquals("Sport", channels[0].group)
|
||||||
|
}
|
||||||
|
}
|
||||||
80
tests/unit/TeamFiltersTest.kt
Normal file
80
tests/unit/TeamFiltersTest.kt
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package dev.castarr.tv.data
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class TeamFiltersTest {
|
||||||
|
|
||||||
|
private val hansa = TeamFilters.byKey("hansa")!!
|
||||||
|
private val vfb = TeamFilters.byKey("stuttgart")!!
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `matches the club regardless of case and surrounding text`() {
|
||||||
|
assertTrue(hansa.matches("3. Liga: FC Hansa Rostock - Dynamo Dresden"))
|
||||||
|
assertTrue(hansa.matches("HANSA ROSTOCK KOMPAKT"))
|
||||||
|
assertTrue(vfb.matches("Bundesliga: VfB Stuttgart - Bayern München"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `does not match unrelated programmes`() {
|
||||||
|
assertFalse(hansa.matches("Tagesschau"))
|
||||||
|
assertFalse(vfb.matches("Hansa Rostock - Saarbrücken"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `scans sport and free-to-air groups`() {
|
||||||
|
listOf("DAZN Event", "Sky Sport", "Magenta Sport", "Amazon Prime", "Free TV / HD+", "DYN Sport")
|
||||||
|
.forEach { assertTrue(it, TeamFilters.scansGroup(it)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `skips NFL and unrelated groups`() {
|
||||||
|
assertFalse(TeamFilters.scansGroup("DAZN Event NFL"))
|
||||||
|
assertFalse(TeamFilters.scansGroup("Kids"))
|
||||||
|
assertFalse(TeamFilters.scansGroup("Musik"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `assigns each viewer their own club by default`() {
|
||||||
|
assertEquals(listOf("hansa"), TeamFilters.defaultKeysFor("benjamin"))
|
||||||
|
assertEquals(listOf("stuttgart"), TeamFilters.defaultKeysFor("TobiasB"))
|
||||||
|
assertTrue(TeamFilters.defaultKeysFor("someone-else").isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `club keys are unique and every club has needles`() {
|
||||||
|
val keys = TeamFilters.all.map { it.key }
|
||||||
|
assertEquals(keys.size, keys.toSet().size)
|
||||||
|
assertTrue(TeamFilters.all.all { it.needles.isNotEmpty() })
|
||||||
|
assertTrue(TeamFilters.all.all { c -> c.needles.all { it == it.lowercase() } })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `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)
|
||||||
|
listOf("bayern", "schalke", "hansa").forEach {
|
||||||
|
assertTrue(it, TeamFilters.byKey(it) != null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
tests/unit/XmltvWindowTest.kt
Normal file
34
tests/unit/XmltvWindowTest.kt
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package dev.castarr.tv.data
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class XmltvWindowTest {
|
||||||
|
|
||||||
|
private fun p(startMin: Long, endMin: Long, title: String) =
|
||||||
|
Programme(startMin * 60_000, endMin * 60_000, title)
|
||||||
|
|
||||||
|
private val schedule = listOf(
|
||||||
|
p(0, 60, "Läuft gerade"),
|
||||||
|
p(60, 120, "Gleich danach"),
|
||||||
|
p(150, 210, "In zweieinhalb Stunden"),
|
||||||
|
p(300, 360, "Deutlich später"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `returns programmes overlapping the window`() {
|
||||||
|
val titles = XmltvParser.programmesIn(schedule, 30 * 60_000, 180 * 60_000).map { it.title }
|
||||||
|
assertEquals(listOf("Läuft gerade", "Gleich danach", "In zweieinhalb Stunden"), titles)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `excludes programmes outside the window`() {
|
||||||
|
val titles = XmltvParser.programmesIn(schedule, 0, 60 * 60_000).map { it.title }
|
||||||
|
assertEquals(listOf("Läuft gerade"), titles)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `handles a missing schedule`() {
|
||||||
|
assertEquals(emptyList<Programme>(), XmltvParser.programmesIn(null, 0, 1_000))
|
||||||
|
}
|
||||||
|
}
|
||||||
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