17 Commits

Author SHA1 Message Date
be-nj
8aa3a4da02 Ship the real club crests in the APK, and fix the CI licence step
All checks were successful
Build TV app / build (push) Successful in 4m15s
Resolving crests from Wikipedia at runtime never produced a visible badge
on the TV and left nothing in the log to debug, so the shield fallback was
all anyone ever saw. They are now downloaded once at build time by
tools/fetch-crests.py into assets/crests (git-ignored, so no trademarked
artwork is committed) and loaded from there — instant, and independent of
the network.

CI: accepting SDK licences by piping "yes" into sdkmanager died of SIGPIPE
(exit 141) under pipefail; the licence hashes are written directly now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 03:31:06 +02:00
be-nj
b7ee0523d1 CI: build without marketplace actions
Some checks failed
Build TV app / build (push) Failing after 21s
The runner resolves bare action names against this Gitea, and even with
full GitHub URLs it fails on annotated tags (unsupported object type).
The image also ships neither a JDK nor an Android SDK, so the job never
had a chance. Everything is plain shell now: shallow checkout, JDK 17,
command-line SDK tools, tests, debug APK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 03:10:43 +02:00
be-nj
a3105925bb Make a waiting update visible instead of hiding it in the settings
Some checks failed
Build TV app / build (push) Failing after 5s
The start-up check already ran, but its result only showed as a badge for
whoever happened to open the settings. A chip in the top bar now says
"Update <version>" and leads straight there, and the check repeats every
six hours because a TV keeps the same process alive for days.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 03:07:12 +02:00
be-nj
7f1dff5661 Real club crests, and no more crash on "Verein hinzufügen"
Some checks failed
Build TV app / build (push) Failing after 5s
Crests are resolved from the German Wikipedia page summary at runtime and
cached per device, so nothing copyrighted is stored in the repository. The
coloured shield remains as the fallback while the badge loads or when a
club has none.

The add dialog crashed for the same reason the rail did: it focused a row
that only exists when something is preselected, and adding a club has no
preselection. It now also scrolls, which 56 clubs rather need.

CI: this Gitea resolves bare action names against itself, so the workflow
now uses full GitHub URLs, and it runs the unit tests too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 02:59:23 +02:00
be-nj
869060c56f Pick clubs from the top three divisions, and stop the rail from crashing
Some checks failed
Build TV app / build (push) Failing after 2s
The club shortcut now offers every club of the first three German
divisions instead of two hard-coded ones. Up to three can be active at
once; benjamin and tobiasb still start with Hansa and the VfB switched on.

Also fixes a crash on the left jump out of the channel list: the jump
targeted a FocusRequester bound to the selected rail row, and a row that
is scrolled out of a LazyColumn is not composed, so requesting focus on it
threw. Jumps are now attempted and fall back to normal focus movement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 02:54:04 +02:00
be-nj
7ad7f5172e Club shortcut: a rail group for "where is my club on tonight"
Some checks failed
Build TV app / build (push) Failing after 2s
Adds an optional rail entry per club that lists every channel whose EPG
mentions the club within the next three hours, with the matching kick-off
instead of the usual Now/Next. Only sport and free-to-air groups are
scanned (NFL excluded), so the scan stays cheap and quiet.

Which clubs appear is a per-TV setting: the signed-in viewer's own club is
on by default, and every club can be switched on or off under Einstellungen
-> Vereinsmenüs, so a household can follow more than one.

Crests are trademarks, so the icon is a plain shield in the club colours.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 02:47:06 +02:00
be-nj
753ab3a3ab Follow the repo move to the be-nj organisation
Some checks failed
Build TV app / build (push) Failing after 2s
Updater and README point at the new path. The old one redirects, but the
updater should not depend on that. Also drops a stale GitHub-specific
Accept header.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 02:33:47 +02:00
be-nj
f157323626 fix(live): returning from playback keeps the group you were in
Some checks failed
Build TV app / build (push) Failing after 2s
The rail opens whatever gets focused, so when the player closed and focus
landed on "Alle Sender", the view silently switched away from the group
the viewer had been browsing. A restore flag now suppresses that
auto-select until focus has been placed on the channel that was watched.

Completes #13.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 02:20:17 +02:00
be-nj
c0adce2b09 Resolve the full review backlog (issues #1-#13)
Some checks failed
Build TV app / build (push) Failing after 2s
Control server security and robustness:
- Socket read timeout, handshake deadline and a client cap so an idle or
  hostile connection can no longer pin threads forever (#1)
- Per-address rate limiting that counts every failed hello, Origin
  checking on the upgrade, and a separate revocable session token for
  code-authenticated clients so the guessable path no longer yields the
  QR credential (#2)
- Credentials excluded from cloud backup and device transfer, constant
  time comparisons, and a pairing reset in the settings (#3)
- Playlist fetches restricted to http(s), capped at 24 MB and bounded by
  an overall transfer deadline (#4)
- Port conflicts and MediaSession id collisions no longer crash the app;
  the remote degrades to unavailable with a plain-language note (#11)

Player:
- Seeking no longer collapses to position 0 when the duration is unknown
  (#5)
- Pause acts on playWhenReady, so pausing during a stall works and
  playback cannot resume in the background after leaving the app (#6)
- Playback failures stay on screen with a retry action instead of
  silently dropping back to the list (#7)
- Reconnects are spaced 1s/3s/8s and re-entering the channel just closed
  waits out a short grace period, which is what the provider needs to
  release the previous session (#12)

Channel list and remote:
- Leaving playback returns to the channel the viewer came from (#13)
- The remote only rebuilds its list when the data changed, never
  overwrites a focused input and carries indices instead of scanning (#8)
- Pairing retry reconnects properly, resets its backoff and validates the
  code before spending an attempt (#9)
- M3U parsing keeps commas in names, strips a BOM and rejects payloads
  that are not playlists, covered by unit tests under tests/ (#10)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 02:14:48 +02:00
be-nj
7abd4b2a48 README: generic group names in screenshots, consistent captions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:55:15 +02:00
be-nj
f69ae2f437 README: screenshots as 2x2 grid
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:43:14 +02:00
be-nj
01a76bffcd README: welcome screen screenshot (QR and LAN address pixelated)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:42:49 +02:00
be-nj
13352994e4 README: all screenshots full width
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:42:05 +02:00
be-nj
6acbe77200 README: overlay screenshot (scene pick)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:41:16 +02:00
be-nj
7da6f5c69f README: brighter player screenshot
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:36:11 +02:00
be-nj
e66c8a863b Overlay readability on bright video: brighter times, translucent progress track
Some checks failed
Build TV app / build (push) Failing after 2s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:32:34 +02:00
be-nj
ca401e8ca3 README for humans: German, screenshots, install guide
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:30:29 +02:00
32 changed files with 1444 additions and 160 deletions

View File

@@ -4,24 +4,56 @@ 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: Fetch club crests
run: python3 tools/fetch-crests.py || true
- 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
View File

@@ -4,3 +4,5 @@ local.properties
.idea/ .idea/
*.iml *.iml
.kotlin/ .kotlin/
tests/runs/
app/src/main/assets/crests/

109
README.md
View File

@@ -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 | ![Senderliste](docs/screenshots/senderliste.png) | ![Player](docs/screenshots/player.png) |
- **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 |
| --- | --- |
| ![Nummerntasten](docs/screenshots/nummerntasten.png) | ![Willkommens-Bildschirm](docs/screenshots/willkommen.png) |
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/).

View File

@@ -12,8 +12,8 @@ android {
applicationId = "dev.castarr.tv" applicationId = "dev.castarr.tv"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 21 versionCode = 31
versionName = "0.8.5" versionName = "0.10.5"
} }
// 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

View File

@@ -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"

View File

@@ -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,6 +447,7 @@
$('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 shown = favOn ? filtered.filter((c) => favSet.has(c.backendId)) : filtered;
@@ -462,9 +464,18 @@
}); });
list.appendChild(bar); list.appendChild(bar);
} }
if (!shown.length) {
const empty = document.createElement('div');
empty.className = 'empty';
empty.textContent = 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');
@@ -517,12 +528,27 @@
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.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 +609,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;

View File

@@ -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,29 @@ 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)
}
/**
* Channels showing the viewer's club within the scanned window, paired
* with the programme that matched — earliest kick-off first, so whatever
* is running right now sits on top.
*/
fun teamMatches(key: String): List<Pair<Channel, dev.castarr.tv.data.Programme>> {
val filter = dev.castarr.tv.data.TeamFilters.byKey(key) ?: return emptyList()
val window = dev.castarr.tv.data.TeamFilters.WINDOW_MS
return activeChannels()
.filter { dev.castarr.tv.data.TeamFilters.scansGroup(it.group) }
.mapNotNull { channel ->
upcoming(channel, window)
.firstOrNull { !isEpgPlaceholder(it.title) && filter.matches(it.title) }
?.let { channel to it }
}
.sortedBy { it.second.start }
}
fun refreshActive() { fun refreshActive() {
when (sourceMode) { when (sourceMode) {
SourceMode.GENERIC -> source.refresh() SourceMode.GENERIC -> source.refresh()
@@ -139,6 +212,11 @@ class AppState(
) )
} }
private companion object {
/** Grace period before re-opening the channel just closed. */
const val REENTRY_GRACE_MS = 2_500L
}
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 +224,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 +234,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 +291,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). */

View File

@@ -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) }
}) })
@@ -323,5 +332,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
} }
} }

View 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)"
}
}

View File

@@ -71,6 +71,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 {

View File

@@ -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
} }
} }

View File

@@ -0,0 +1,143 @@
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 {
/** Window scanned ahead of now. */
const val WINDOW_MS = 3 * 60 * 60 * 1000L
/** 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()
}

View File

@@ -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 }

View File

@@ -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 {

View File

@@ -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)
}
} }

View File

@@ -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
}
} }

View File

@@ -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,43 @@ 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>>()
@Synchronized @Synchronized
private fun codeAttemptAllowed(): Boolean { private fun attemptAllowed(address: String): Boolean {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
while (codeAttempts.isNotEmpty() && now - codeAttempts.first() > CODE_ATTEMPT_WINDOW_MS) { val queue = attempts.getOrPut(address) { ArrayDeque() }
codeAttempts.removeFirst() while (queue.isNotEmpty() && now - queue.first() > ATTEMPT_WINDOW_MS) {
queue.removeFirst()
} }
if (codeAttempts.size >= CODE_ATTEMPT_MAX) return false if (attempts.size > MAX_TRACKED_ADDRESSES) {
codeAttempts.addLast(now) attempts.entries.removeAll { it.value.isEmpty() }
}
if (queue.size >= ATTEMPT_MAX) return false
queue.addLast(now)
return true 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.
running = runCatching { start(NanoHTTPD.SOCKET_READ_TIMEOUT, 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 +97,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 +140,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 +178,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 +205,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 +226,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,18 +292,15 @@ class ControlServer(
} }
private fun handleHello(msg: JSONObject) { private fun handleHello(msg: JSONObject) {
val token = msg.optString("token") // Every failed attempt counts against this address, whether it
val tokenOk = token.isNotEmpty() && token == pairingToken // carried a token or a code.
val codeOk = !tokenOk && msg.optString("code").let { code -> if (!attemptAllowed(remoteAddress)) {
code.isNotEmpty() && when { trySend(JSONObject().put("type", "error").put("error", "rate_limited").toString())
!codeAttemptAllowed() -> { runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "rate limited", false) }
trySend(JSONObject().put("type", "error").put("error", "rate_limited").toString()) return
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "rate limited", false) }
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) {
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) }
@@ -246,6 +308,11 @@ class ControlServer(
} }
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 +320,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 +339,11 @@ 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 ATTEMPT_WINDOW_MS = 60_000L
const val CODE_ATTEMPT_MAX = 5 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)
} }
} }

View File

@@ -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) {

View File

@@ -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,19 @@ 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) }
}
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 +114,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 +155,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 +164,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 +176,35 @@ 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 ->
GroupItem(
label = club.label,
count = matchesByTeam[club.key]?.size ?: 0,
selected = state.activeTeam == club.key,
leading = { Crest(club, state) },
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 +218,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 +244,13 @@ fun LiveScreen(state: AppState) {
if (channels.isEmpty()) { if (channels.isEmpty()) {
item { item {
Text( Text(
if (state.favoritesOnly) when {
"Noch keine Favoriten — halte OK auf einem Sender gedrückt." activeTeam != null ->
else "Diese Gruppe ist leer.", "In den nächsten 3 Stunden läuft nichts mit ${activeTeam.fullName}."
state.favoritesOnly ->
"Noch keine Favoriten — halte OK auf einem Sender gedrückt."
else -> "Diese Gruppe ist leer."
},
color = CastarrColors.faint, color = CastarrColors.faint,
fontFamily = AppFont, fontFamily = AppFont,
fontSize = 14.sp, fontSize = 14.sp,
@@ -192,9 +265,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 +298,8 @@ private fun GroupItem(
count: Int, count: Int,
selected: Boolean, selected: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
leading: (@Composable () -> Unit)? = null,
suppressAutoSelect: () -> Boolean = { false },
onSelect: () -> Unit, onSelect: () -> Unit,
) { ) {
Surface( Surface(
@@ -224,7 +308,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 +322,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,
@@ -263,6 +351,7 @@ private fun ChannelRow(
number: Int, number: Int,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
nowNext: NowNext, nowNext: NowNext,
highlight: dev.castarr.tv.data.Programme? = null,
playing: Boolean, playing: Boolean,
favorite: Boolean, favorite: Boolean,
epgStamp: Long, epgStamp: Long,
@@ -323,7 +412,13 @@ private fun ChannelRow(
} }
} }
Spacer(Modifier.width(24.dp)) Spacer(Modifier.width(24.dp))
EpgCell(nowNext, Modifier.weight(1f)) 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))
}
if (playing) { if (playing) {
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Box( Box(
@@ -337,6 +432,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 +510,40 @@ private fun LogoInitials(initials: String) {
} }
} }
/** The programme that matched the club filter, with its start time. */
@Composable
private fun HighlightCell(programme: dev.castarr.tv.data.Programme, modifier: Modifier = Modifier) {
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)}",
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))

View File

@@ -177,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) {
@@ -235,7 +236,7 @@ private fun Overlay(state: AppState) {
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, style = OverlayText,
fontSize = 14.sp, fontSize = 14.sp,
@@ -248,7 +249,7 @@ 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, style = OverlayText,
fontSize = 14.sp, fontSize = 14.sp,
@@ -270,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
@@ -282,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) {
IconButton( if (state.playerState == "error") {
icon = if (state.playerState == "paused") PillIcon.PLAY else PillIcon.PAUSE, // One clear action instead of silently dropping back to
contentDescription = if (state.playerState == "paused") "Weiter" else "Pause", // the channel list.
focusRequester = playFocus, PillButton("Erneut versuchen", focusRequester = playFocus) {
) { state.player.toggle() } state.retryPlayback()
Spacer(Modifier.width(10.dp)) }
IconButton(icon = PillIcon.STOP, contentDescription = "Beenden") { state.stopPlayback() } Spacer(Modifier.width(10.dp))
IconButton(icon = PillIcon.STOP, contentDescription = "Beenden") {
state.stopPlayback()
}
} else {
IconButton(
icon = if (state.playerState == "paused") PillIcon.PLAY else PillIcon.PAUSE,
contentDescription = if (state.playerState == "paused") "Weiter" else "Pause",
focusRequester = playFocus,
) { state.player.toggle() }
Spacer(Modifier.width(10.dp))
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 }

View File

@@ -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
@@ -72,13 +78,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 +176,41 @@ fun SettingsScreen(state: AppState) {
} }
} }
SettingsCard("Vereinsmenüs") {
Text(
"Eigene Gruppe mit allen Sendern, auf denen der Verein in den " +
"nächsten 3 Stunden läuft. Bis zu " +
"${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",
trailing = { TogglePill(on = true) },
) { 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}",
@@ -212,9 +262,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 +292,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 +321,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 +356,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 +369,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(
@@ -336,6 +414,7 @@ private fun PickerDialog(picker: Picker, onClose: () -> Unit) {
} }
} }
} }
}
} }
} }
} }
@@ -369,6 +448,24 @@ private fun ValueWithCaret(value: String) {
} }
} }
@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(

View File

@@ -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()

View 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>

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

BIN
docs/screenshots/player.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View 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)
}
}

View File

@@ -0,0 +1,61 @@
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 `covers all three divisions`() {
assertTrue(TeamFilters.all.size > 50)
listOf("bayern", "schalke", "hansa").forEach {
assertTrue(it, TeamFilters.byKey(it) != null)
}
}
}

View 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))
}
}

54
tools/fetch-crests.py Normal file
View File

@@ -0,0 +1,54 @@
#!/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"
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)
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:
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")
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)
print(f"crests: {fetched} geladen, {skipped} vorhanden, {failed} fehlgeschlagen")