31 Commits

Author SHA1 Message Date
be-nj
e35500fa2f CI: cache the JDK, the SDK and Gradle on the runner host
All checks were successful
Build TV app / build (push) Successful in 2m42s
Every push re-installed a JDK, re-downloaded the Android SDK and let Gradle
fetch its distribution and every dependency again — roughly two of the three
minutes a run took, spent on bytes that never change.

The job now mounts a directory from the runner host at /cache and keeps the
JDK, the SDK and GRADLE_USER_HOME there; both setup steps are skipped when
what they install is already present. Tests and the APK build share one
Gradle invocation instead of paying twice for JVM startup and configuration.

Needs the mount whitelisted in the runner's valid_volumes
(/etc/komodo/stacks/gitea/runner-cache/** on diana).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 14:15:18 +02:00
be-nj
b52ef79c01 Test the three bugs that reached the living room, and script the release
The rate limit, the version compare and the download check were all wrapped
in Android — a Context, a socket, a file — so none of them had a test, and
all three shipped broken: the remote thrown out after five reconnects
(0.11.1), a truncated APK handed to the installer (0.11.5), and a version
compare that would read 0.11.10 as older than 0.11.9 the moment we get
there.

They are now plain Kotlin in AttemptBudget and UpdateRules, with the clock
injected, and 20 tests covering the failures themselves. AttemptBudget also
prunes expired entries before evicting: dropping only empty queues let the
map grow without bound while every tracked address held one fresh failure.

tests/smoke.sh installs the debug APK on the headless emulator, walks the
first screen with the D-pad and fails on a crash or ANR — the class of bug
unit tests cannot see, and the reason 0.11.0 crashed on startup. Forty
seconds end to end.

tools/release.sh does the bump, tests, signed build, tag, apk branch and
Gitea release in one command. It refuses an unsigned build and never writes
the release notes itself; commit subjects are offered as a draft.

tests/helpers/jdk.sh picks a JDK Gradle can run on: Ubuntu moved
default-java to 25, which fails the build with a bare
"IllegalArgumentException: 25.0.4".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 14:12:51 +02:00
be-nj
f1a6600e42 Emulator helper so tests start in seconds, not minutes
All checks were successful
Build TV app / build (push) Successful in 2m54s
The Google TV emulator was launched by hand with -no-snapshot, which forces
a cold boot every time. tests/helpers/emulator.sh boots from the AVD's
default_boot snapshot (~7s) and refreshes that snapshot on stop, so the next
run stays fast. It also wraps the two things a test run needs afterwards:
installing an APK and pulling a screenshot into tests/runs/.

CONTEXT.md's example dialogue still described Xtream credentials entered
through the Remote, which ADR-0005 and ADR-0007 replaced with device flow and
phone-side onboarding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 13:29:33 +02:00
be-nj
637679fd90 Release 0.11.5
All checks were successful
Build TV app / build (push) Successful in 3m13s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 12:07:24 +02:00
be-nj
cbdac7ddb2 Make the club highlight visible and the updater honest
Three fixes.

The pulse ring never showed. It was drawn on a 30dp canvas inside a box
sized to the 20dp crest, so the parent's constraints shrank the canvas
to 20dp and the crest image covered what was left. The box is now wider
than the crest and the ring has room.

"läuft" was drawn straight onto the row, and a focused row is filled
with the accent colour — red on turquoise. The signal now sits on a
dark chip, which reads on an unfocused row, a selected one and a
focused one alike.

The updater handed the installer whatever had been written so far. It
now checks the response code, downloads to a .part file, compares the
byte count against Content-Length before renaming, and refuses a second
concurrent attempt instead of letting two writers share one file. A
half-written APK leaves the installer spinning with nothing to report,
which is what a hang looks like from the sofa.

Crests are also resampled to 128px with a proper filter when fetched;
Android's 8x downscale from the 330px originals left ragged edges.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 12:04:58 +02:00
be-nj
8bf1ed3128 Give Heidenheim its own short label
Two clubs mapped to FCH; Hansa Rostock keeps it, Heidenheim becomes
HDH, which is what its own supporters use anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 11:49:37 +02:00
be-nj
3b6a8d7d0a Show crests and league sections when picking a club
All checks were successful
Build TV app / build (push) Successful in 2m53s
The club chooser was a flat list of 56 names in a generic picker
dialog, which is unreadable from a couch. It now has its own dialog
with a crest per row, the short label on the right, and section
headers per division. Enabled clubs in the settings card show their
crest too.

A search field would have been the obvious alternative, but text
entry on a remote is exactly what the Ten-Foot rule rules out, so
grouping plus crests carries the recognition instead.

Also adds tests/demo, a generator for neutral playlist and EPG data
so screenshots never carry real channel names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 11:42:18 +02:00
be-nj
e491f8e50f Highlight a club menu while its match is on or about to start
All checks were successful
Build TV app / build (push) Successful in 3m6s
The rail entry showed a plain hit count, which says nothing about whether
that hit is happening now or six hours from now. It now turns into a red
dot with "läuft" while a match is running, and an accent dot with "gleich"
within half an hour of kick-off — the only two moments where the menu is
worth interrupting for. A one-minute ticker re-evaluates it so the state
appears on its own.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 03:42:49 +02:00
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
be-nj
844ea928ef Channel number in the player overlay (inline before the name)
Some checks failed
Build TV app / build (push) Failing after 3s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:27:50 +02:00
43 changed files with 2888 additions and 232 deletions

View File

@@ -4,24 +4,71 @@ 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
container:
image: docker.gitea.com/runner-images:ubuntu-latest
# A directory on the runner host, whitelisted in the runner's
# valid_volumes. Everything the job would otherwise download on every
# push — JDK, Android SDK, Gradle distribution and dependencies —
# lives here and survives the container.
options: -v /etc/komodo/stacks/gitea/runner-cache/castarr:/cache
env:
CACHE: /cache
JAVA_HOME: /cache/jdk17
ANDROID_SDK_ROOT: /cache/android-sdk
GRADLE_USER_HOME: /cache/gradle
JDK_URL: https://api.adoptium.net/v3/binary/latest/17/ga/linux/x64/jdk/hotspot/normal/eclipse
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: JDK 17
with: run: |
distribution: temurin if [ ! -x "$JAVA_HOME/bin/java" ]; then
java-version: "17" echo "JDK nicht im Cache — einmalig herunterladen"
mkdir -p "$JAVA_HOME"
curl -sSL "$JDK_URL" | tar -xz -C "$JAVA_HOME" --strip-components=1
fi
"$JAVA_HOME/bin/java" -version
- uses: gradle/actions/setup-gradle@v4 - name: Android SDK
run: |
if [ ! -d "$ANDROID_SDK_ROOT/platforms/android-35" ]; then
echo "SDK nicht im Cache — einmalig einrichten"
mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools"
curl -sSL -o /tmp/tools.zip "$CMDLINE_TOOLS"
unzip -q -o /tmp/tools.zip -d "$ANDROID_SDK_ROOT/cmdline-tools"
rm -rf "$ANDROID_SDK_ROOT/cmdline-tools/latest"
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"
JAVA_HOME="$JAVA_HOME" "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" \
"platforms;android-35" "build-tools;35.0.0" "platform-tools" > /dev/null
fi
echo "sdk.dir=$ANDROID_SDK_ROOT" > local.properties
- name: Build debug APK # One invocation, not two: a second --no-daemon run pays for another JVM
run: ./gradlew assembleDebug --stacktrace # start and another configuration phase to redo work it just did.
- name: Tests und Debug-APK
run: ./gradlew testDebugUnitTest 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)"
echo "Cache:"; du -sh "$CACHE"/* 2>/dev/null || echo " (leer)"

4
.gitignore vendored
View File

@@ -4,3 +4,7 @@ local.properties
.idea/ .idea/
*.iml *.iml
.kotlin/ .kotlin/
tests/runs/
app/src/main/assets/crests/
tests/demo/playlist.m3u
tests/demo/epg.xml

View File

@@ -73,9 +73,10 @@ Channel, no full guide timeline.
## Example dialogue ## Example dialogue
> **Dev:** "Does the **Remote** need Backend credentials?" > **Dev:** "Does the **Remote** need Backend credentials?"
> **Domain expert:** "No — the user enters the Xtream credentials of a > **Domain expert:** "No — the Remote is where **Onboarding** happens, not
> **Quelle** once via the Remote, the **TV-App** stores them and is the only > where credentials live: the user types the server URL there and finishes the
> one talking to the **Backend**." > IdP login on the phone. The **TV-App** holds the tokens and is the only one
> talking to the **Backend**. A generic M3U **Quelle** needs no login at all."
## Flagged ambiguities ## Flagged ambiguities

125
README.md
View File

@@ -1,49 +1,100 @@
# 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 ### Tests
The full feature set needs the Dispatcharr fork with OIDC support ```
(be-nj/Dispatcharr): its `/api/accounts/oidc/status/` endpoint hands the app ./gradlew test # Unit-Tests, Reports unter tests/runs/
the issuer and public device-flow client id. Configuration lives in tests/helpers/emulator.sh start # headloser Google-TV-Emulator
[CONTEXT.md](CONTEXT.md) and [docs/adr/](docs/adr/). tests/helpers/emulator.sh install app/build/outputs/apk/debug/app-debug.apk
tests/helpers/emulator.sh shot name # Screenshot nach tests/runs/screenshots/
tests/helpers/emulator.sh stop
```
Der Emulator (AVD `castarr-googletv`, API 34) startet aus einem Snapshot und
ist in wenigen Sekunden oben; `stop` schreibt den Snapshot vorher neu. Ein
Kaltstart entsteht nur, wenn der Snapshot fehlt.
Demo-Daten für einen Lauf ohne echtes Backend: `tests/demo/make-demo-data.py`
erzeugt Playlist und EPG, die ein lokaler HTTP-Server als generische Quelle
ausliefert.
Architektur-Notizen: [CONTEXT.md](CONTEXT.md) und [docs/adr/](docs/adr/).

View File

@@ -12,8 +12,8 @@ android {
applicationId = "dev.castarr.tv" applicationId = "dev.castarr.tv"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 20 versionCode = 38
versionName = "0.8.4" versionName = "0.11.5"
} }
// Release signing from environment (see ~/.keys/castarr-release.env on the // Release signing from environment (see ~/.keys/castarr-release.env on the
@@ -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

@@ -267,7 +267,7 @@
ws: null, connected: false, authorized: false, ws: null, connected: false, authorized: false,
status: { state: 'idle', channel: '', group: '', live: false, volume: 0.5 }, status: { state: 'idle', channel: '', group: '', live: false, volume: 0.5 },
channels: [], playlistUrl: '', extras: { nowNext: [], favorites: [], favoritesSupported: false }, favOnly: false, channels: [], playlistUrl: '', extras: { nowNext: [], favorites: [], favoritesSupported: false }, favOnly: false,
retryDelay: 1000, volumeDragging: false, searchTerm: '', retryDelay: 1000, volumeDragging: false, searchTerm: '', teamKey: null,
}; };
// --- pairing credentials --- // --- pairing credentials ---
@@ -320,6 +320,7 @@
ws.onclose = () => { ws.onclose = () => {
state.connected = false; state.authorized = false; state.connected = false; state.authorized = false;
renderConnection(); renderConnection();
state.ws = null;
setTimeout(connect, state.retryDelay); setTimeout(connect, state.retryDelay);
state.retryDelay = Math.min(state.retryDelay * 1.6, 10000); state.retryDelay = Math.min(state.retryDelay * 1.6, 10000);
}; };
@@ -340,7 +341,7 @@
if (msg.token) localStorage.setItem('castarr_token', msg.token); if (msg.token) localStorage.setItem('castarr_token', msg.token);
$('tv-name').textContent = msg.device || 'TV'; $('tv-name').textContent = msg.device || 'TV';
if (msg.status) { state.status = msg.status; } if (msg.status) { state.status = msg.status; }
if (msg.channels) { state.channels = msg.channels; } if (msg.channels) { state.channels = msg.channels.map((c, i) => { c._idx = i; return c; }); }
if (msg.extras) { state.extras = msg.extras; } if (msg.extras) { state.extras = msg.extras; }
state.playlistUrl = msg.playlistUrl || ''; state.playlistUrl = msg.playlistUrl || '';
$('pair-error').textContent = ''; $('pair-error').textContent = '';
@@ -358,10 +359,10 @@
case 'status': case 'status':
state.status = msg; state.status = msg;
renderStatus(); renderStatus();
renderChannels(); renderChannelsIfChanged();
break; break;
case 'channels': case 'channels':
state.channels = msg.channels || []; state.channels = (msg.channels || []).map((c, i) => { c._idx = i; return c; });
state.playlistUrl = msg.playlistUrl || ''; state.playlistUrl = msg.playlistUrl || '';
if (msg.extras) { state.extras = msg.extras; } if (msg.extras) { state.extras = msg.extras; }
renderChannels(); renderChannels();
@@ -446,25 +447,51 @@
$('setup').classList.add('open'); $('setup').classList.add('open');
return; return;
} }
list.innerHTML = '';
const favSet = new Set(state.extras.favorites || []); const favSet = new Set(state.extras.favorites || []);
const favOn = state.extras.favoritesSupported && state.favOnly; const favOn = state.extras.favoritesSupported && state.favOnly;
const shown = favOn ? filtered.filter((c) => favSet.has(c.backendId)) : filtered; const teams = state.extras.teams || [];
if (state.extras.favoritesSupported) { const club = teams.find((t) => t.key === state.teamKey);
// Club view: the TV's club menu, mirrored onto the phone.
const clubHits = club ? new Map(club.entries.map((e) => [e.url, e])) : null;
const shown = club
? filtered.filter((c) => clubHits.has(c.url))
: favOn ? filtered.filter((c) => favSet.has(c.backendId)) : filtered;
if (state.extras.favoritesSupported || teams.length) {
const bar = document.createElement('div'); const bar = document.createElement('div');
bar.style.cssText = 'display:flex;gap:8px;padding:2px 12px 10px'; bar.style.cssText = 'display:flex;gap:8px;padding:2px 12px 10px;overflow-x:auto';
[['Alle', false], ['★ Favoriten', true]].forEach(([label, val]) => { const views = [['Alle', null, false]];
if (state.extras.favoritesSupported) views.push(['★ Favoriten', null, true]);
teams.forEach((t) => views.push([t.label + ' ' + t.entries.length, t.key, false]));
views.forEach(([label, key, fav]) => {
const active = key ? state.teamKey === key : (!state.teamKey && state.favOnly === fav);
const chip = document.createElement('button'); const chip = document.createElement('button');
chip.textContent = label; chip.textContent = label;
chip.style.cssText = 'padding:7px 14px;border-radius:999px;font-size:12px;background:' + chip.style.cssText = 'flex:none;padding:7px 14px;border-radius:999px;font-size:12px;background:' +
(state.favOnly === val ? 'rgba(95,212,196,.12);color:#5fd4c4' : '#121418;color:#9aa0a8'); (active ? 'rgba(95,212,196,.12);color:#5fd4c4' : '#121418;color:#9aa0a8');
chip.addEventListener('click', () => { state.favOnly = val; renderChannels(); }); chip.addEventListener('click', () => {
state.teamKey = key;
state.favOnly = fav;
listSignature = '';
renderChannels();
});
bar.appendChild(chip); bar.appendChild(chip);
}); });
list.appendChild(bar); list.appendChild(bar);
} }
if (!shown.length) {
const empty = document.createElement('div');
empty.className = 'empty';
empty.textContent = club
? ('Heute läuft nichts mehr mit ' + club.name + '.')
: favOn ? 'Keine Favoriten — Stern auf einem Sender antippen.'
: 'Keine Sender gefunden.';
list.appendChild(empty);
return;
}
const frag = document.createDocumentFragment(); const frag = document.createDocumentFragment();
shown.slice(0, 500).forEach((c) => { shown.slice(0, 500).forEach((c) => {
const idx = state.channels.indexOf(c); const idx = c._idx;
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.className = 'chan' + (c.url === state.status.url || c.name === state.status.channel ? ' current' : ''); btn.className = 'chan' + (c.url === state.status.url || c.name === state.status.channel ? ' current' : '');
const num = document.createElement('span'); const num = document.createElement('span');
@@ -482,6 +509,18 @@
const name = document.createElement('span'); const name = document.createElement('span');
name.className = 'name'; name.textContent = c.name; name.className = 'name'; name.textContent = c.name;
meta.appendChild(name); meta.appendChild(name);
const hit = clubHits ? clubHits.get(c.url) : null;
if (hit) {
const line = document.createElement('span');
line.className = 'grp';
const t = new Date(hit.start);
const hh = String(t.getHours()).padStart(2, '0') + ':' + String(t.getMinutes()).padStart(2, '0');
const running = Date.now() >= hit.start && Date.now() < hit.stop;
line.textContent = (running ? 'läuft · ' : 'ab ' + hh + ' · ') + hit.title +
(hit.further > 0 ? ' · +' + hit.further + ' weitere' : '');
line.style.color = running ? '#5fd4c4' : '#9aa0a8';
meta.appendChild(line);
} else {
const info = (state.extras.nowNext || [])[idx]; const info = (state.extras.nowNext || [])[idx];
if (info && info.now) { if (info && info.now) {
const now = document.createElement('span'); const now = document.createElement('span');
@@ -502,6 +541,7 @@
grp.className = 'grp'; grp.textContent = c.group; grp.className = 'grp'; grp.textContent = c.group;
meta.appendChild(grp); meta.appendChild(grp);
} }
}
btn.appendChild(num); btn.appendChild(meta); btn.appendChild(num); btn.appendChild(meta);
if (state.extras.favoritesSupported && c.backendId) { if (state.extras.favoritesSupported && c.backendId) {
const star = document.createElement('span'); const star = document.createElement('span');
@@ -517,12 +557,28 @@
btn.addEventListener('click', () => playChannel(c)); btn.addEventListener('click', () => playChannel(c));
frag.appendChild(btn); frag.appendChild(btn);
}); });
list.innerHTML = '';
list.appendChild(frag); list.appendChild(frag);
if (state.playlistUrl) $('playlist-input').value = state.playlistUrl; // Never overwrite an input the user is typing in.
const pl = $('playlist-input');
if (state.playlistUrl && document.activeElement !== pl) pl.value = state.playlistUrl;
} }
function renderAll() { renderStatus(); renderChannels(); } // Rebuilding the list on every 2s status push swallowed taps that landed
// between touchstart and touchend. Only rebuild when something changed.
let listSignature = '';
function renderChannelsIfChanged() {
const sig = [
state.channels.length, state.searchTerm, state.favOnly, state.teamKey,
JSON.stringify((state.extras.teams || []).map((t) => [t.key, t.entries.length])),
(state.extras.favorites || []).join(','),
state.status.channel || '',
].join('|');
if (sig === listSignature) return;
listSignature = sig;
renderChannels();
}
function renderAll() { renderStatus(); listSignature = ''; renderChannels(); }
// --- actions --- // --- actions ---
function playChannel(c) { function playChannel(c) {
@@ -583,10 +639,17 @@
$('pair-btn').addEventListener('click', () => { $('pair-btn').addEventListener('click', () => {
const code = $('code-input').value.trim(); const code = $('code-input').value.trim();
if (code.length !== 4) { $('pair-error').textContent = 'Bitte 4 Ziffern eingeben.'; return; } if (!/^[0-9]{4}$/.test(code)) {
$('pair-error').textContent = 'Bitte 4 Ziffern eingeben.';
return;
}
// A fresh attempt must not inherit the backoff from earlier failures.
state.retryDelay = 1000;
$('pair-error').textContent = 'Verbinde…';
manualCode = code; manualCode = code;
$('pair-error').textContent = ''; $('pair-error').textContent = '';
if (state.ws) state.ws.close(); else connect(); // A closed socket ignores close(), so the retry has to reconnect.
if (state.ws && state.ws.readyState <= 1) state.ws.close(); else connect();
}); });
let toastTimer = null; let toastTimer = null;

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,62 @@ class AppState(
SourceMode.DISPATCHARR -> dispatcharr.nowNext(channel) SourceMode.DISPATCHARR -> dispatcharr.nowNext(channel)
} }
private fun upcoming(channel: Channel, windowMs: Long) = when (sourceMode) {
SourceMode.GENERIC -> source.upcoming(channel, windowMs)
SourceMode.DISPATCHARR -> dispatcharr.upcoming(channel, windowMs)
}
/** One channel's club broadcasts: the next one plus how many follow. */
data class TeamHit(
val programme: dev.castarr.tv.data.Programme,
val further: Int,
)
/** How urgent a club menu is right now — drives the rail highlight. */
enum class TeamUrgency { NONE, SOON, LIVE }
/** The urgent state plus the kick-off it refers to, for the rail label. */
data class TeamSignal(val urgency: TeamUrgency, val kickOff: Long)
/**
* LIVE while a match is running, SOON within half an hour of kick-off —
* the window in which someone actually wants to be nudged.
*/
fun teamSignal(matches: List<Pair<Channel, TeamHit>>): TeamSignal {
val now = System.currentTimeMillis()
var soonAt = 0L
matches.forEach { (_, hit) ->
val p = hit.programme
if (now in p.start until p.stop) return TeamSignal(TeamUrgency.LIVE, p.start)
if (p.start in now..(now + SOON_WINDOW_MS) && (soonAt == 0L || p.start < soonAt)) {
soonAt = p.start
}
}
return if (soonAt > 0) TeamSignal(TeamUrgency.SOON, soonAt)
else TeamSignal(TeamUrgency.NONE, 0L)
}
/**
* Channels showing the viewer's club within the scanned window, paired
* with the programme that matched — earliest kick-off first, so whatever
* is running right now sits on top.
*/
fun teamMatches(key: String): List<Pair<Channel, TeamHit>> {
val filter = dev.castarr.tv.data.TeamFilters.byKey(key) ?: return emptyList()
val now = System.currentTimeMillis()
val window = dev.castarr.tv.data.TeamFilters.windowEnd(now) - now
return activeChannels()
.filter { dev.castarr.tv.data.TeamFilters.scansGroup(it.group) }
.mapNotNull { channel ->
// A channel can carry several of the club's broadcasts in one
// evening; the row names the next and counts the rest.
val hits = upcoming(channel, window)
.filter { !isEpgPlaceholder(it.title) && filter.matches(it.title) }
hits.firstOrNull()?.let { channel to TeamHit(it, hits.size - 1) }
}
.sortedBy { it.second.programme.start }
}
fun refreshActive() { fun refreshActive() {
when (sourceMode) { when (sourceMode) {
SourceMode.GENERIC -> source.refresh() SourceMode.GENERIC -> source.refresh()
@@ -127,8 +233,14 @@ class AppState(
result.fold( result.fold(
onSuccess = { appError = AppError.NONE }, onSuccess = { appError = AppError.NONE },
onFailure = { throwable -> onFailure = { throwable ->
// Cached channels keep the app usable; only surface a // An expired login must always surface: cached channels
// fullscreen state when there is nothing to show. // would otherwise sit there without EPG or favourites and
// nothing would say why.
if (throwable.message == "not logged in") {
appError = AppError.RELOGIN
return
}
// Otherwise cached channels keep the app usable.
if (activeChannels().isNotEmpty()) return if (activeChannels().isNotEmpty()) return
appError = when { appError = when {
throwable.message == "not logged in" -> AppError.RELOGIN throwable.message == "not logged in" -> AppError.RELOGIN
@@ -139,6 +251,14 @@ class AppState(
) )
} }
private companion object {
/** Grace period before re-opening the channel just closed. */
const val REENTRY_GRACE_MS = 2_500L
/** How far ahead a kick-off counts as "about to start". */
const val SOON_WINDOW_MS = 30 * 60 * 1000L
}
fun isOnline(): Boolean { fun isOnline(): Boolean {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
@@ -146,6 +266,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 +276,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 +333,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) }
}) })
@@ -138,9 +147,36 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
} }
val favorites = org.json.JSONArray() val favorites = org.json.JSONArray()
state.dispatcharr.favorites.value.forEach { favorites.put(it) } state.dispatcharr.favorites.value.forEach { favorites.put(it) }
// The club menus exist on the TV; the phone should offer them too.
val teams = org.json.JSONArray()
dev.castarr.tv.data.TeamFilters.all
.filter { it.key in state.enabledTeams }
.forEach { club ->
val entries = org.json.JSONArray()
state.teamMatches(club.key).forEach { (channel, hit) ->
entries.put(
JSONObject()
.put("channel", channel.name)
.put("url", channel.url)
.put("group", channel.group)
.put("title", hit.programme.title)
.put("start", hit.programme.start)
.put("stop", hit.programme.stop)
.put("further", hit.further)
)
}
teams.put(
JSONObject()
.put("key", club.key)
.put("label", club.label)
.put("name", club.fullName)
.put("entries", entries)
)
}
return JSONObject() return JSONObject()
.put("nowNext", nowNext) .put("nowNext", nowNext)
.put("favorites", favorites) .put("favorites", favorites)
.put("teams", teams)
.put("favoritesSupported", state.sourceMode == AppState.SourceMode.DISPATCHARR) .put("favoritesSupported", state.sourceMode == AppState.SourceMode.DISPATCHARR)
} }
@@ -323,5 +359,6 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
const val TICK_INTERVAL_MS = 2_000L const val TICK_INTERVAL_MS = 2_000L
const val SEEK_STEP_SECONDS = 10L const val SEEK_STEP_SECONDS = 10L
const val DIGIT_COMMIT_MS = 1_800L const val DIGIT_COMMIT_MS = 1_800L
const val UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000L
} }
} }

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

@@ -49,13 +49,20 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) {
scope.launch { scope.launch {
val result = runCatching { val result = runCatching {
status.value = "loading_channels" status.value = "loading_channels"
Log.i(TAG, "refresh start (loggedIn=${auth.isLoggedIn})")
val token = auth.accessToken() ?: error("not logged in") val token = auth.accessToken() ?: error("not logged in")
val groups = fetchGroups(token) val groups = fetchGroups(token)
val list = fetchChannels(token, groups) val list = fetchChannels(token, groups)
channels.value = list channels.value = list
prefs.edit().putString("channels_cache", Channel.listToJson(list).toString()).apply() prefs.edit().putString("channels_cache", Channel.listToJson(list).toString()).apply()
launch { runCatching { refreshFavorites(token) } } launch {
launch { runCatching { refreshProfiles(token) } } runCatching { refreshFavorites(token) }
.onFailure { Log.w(TAG, "favorites failed: ${it.javaClass.simpleName}: ${it.message?.take(120)}") }
}
launch {
runCatching { refreshProfiles(token) }
.onFailure { Log.w(TAG, "profiles failed: ${it.javaClass.simpleName}: ${it.message?.take(120)}") }
}
launch { launch {
runCatching { refreshEpg(token) } runCatching { refreshEpg(token) }
.onFailure { Log.w(TAG, "epg failed: ${it.javaClass.simpleName}: ${it.message?.take(160)}") } .onFailure { Log.w(TAG, "epg failed: ${it.javaClass.simpleName}: ${it.message?.take(160)}") }
@@ -71,6 +78,12 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) {
fun nowNext(channel: Channel): NowNext = fun nowNext(channel: Channel): NowNext =
XmltvParser.nowNext(programmesByTvgId[channel.tvgId]) XmltvParser.nowNext(programmesByTvgId[channel.tvgId])
/** Programmes of this channel between now and now + [windowMs]. */
fun upcoming(channel: Channel, windowMs: Long): List<Programme> {
val now = System.currentTimeMillis()
return XmltvParser.programmesIn(programmesByTvgId[channel.tvgId], now, now + windowMs)
}
fun toggleFavorite(channel: Channel) { fun toggleFavorite(channel: Channel) {
if (channel.backendId == 0) return if (channel.backendId == 0) return
scope.launch { scope.launch {

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,171 @@
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,
/** 1, 2 or 3 — groups the picker by division. */
val league: Int,
) {
fun matches(title: String): Boolean {
val haystack = title.lowercase()
return needles.any { haystack.contains(it) }
}
}
object TeamFilters {
/** Minimum window scanned ahead of now. */
const val MIN_WINDOW_MS = 3 * 60 * 60 * 1000L
/**
* Scans until midnight, but never less than [MIN_WINDOW_MS] — asking in
* the morning should already answer "does my club play tonight", and
* asking late at night must not cut the evening short.
*/
fun windowEnd(now: Long): Long {
val midnight = java.util.Calendar.getInstance().apply {
timeInMillis = now
add(java.util.Calendar.DAY_OF_YEAR, 1)
set(java.util.Calendar.HOUR_OF_DAY, 0)
set(java.util.Calendar.MINUTE, 0)
set(java.util.Calendar.SECOND, 0)
set(java.util.Calendar.MILLISECOND, 0)
}.timeInMillis
return maxOf(midnight, now + MIN_WINDOW_MS)
}
/** At most this many club groups sit in the rail at once. */
const val MAX_ACTIVE = 3
private const val WHITE = 0xFFF2F3F5
private const val BLACK = 0xFF15171B
private fun club(
key: String,
label: String,
fullName: String,
needles: List<String>,
primary: Long,
secondary: Long,
article: String = fullName,
league: Int = 1,
) = TeamFilter(key, label, fullName, needles, Color(primary), Color(secondary), article, league)
/** Clubs of the top three German divisions. */
val all: List<TeamFilter> = listOf(
// --- Bundesliga ---
club("bayern", "FCB", "FC Bayern München", listOf("bayern münchen", "fc bayern"), 0xFFDC052D, WHITE, league = 1),
club("bvb", "BVB", "Borussia Dortmund", listOf("borussia dortmund", "bvb"), 0xFFFDE100, BLACK, league = 1),
club("leipzig", "RBL", "RB Leipzig", listOf("rb leipzig"), 0xFFDD0741, WHITE, league = 1),
club("leverkusen", "B04", "Bayer 04 Leverkusen", listOf("leverkusen"), 0xFFE32219, BLACK, league = 1),
club("frankfurt", "SGE", "Eintracht Frankfurt", listOf("eintracht frankfurt"), 0xFF1A1A1A, 0xFFE1000F, league = 1),
club("stuttgart", "1893", "VfB Stuttgart", listOf("vfb stuttgart"), 0xFFE32219, WHITE, league = 1),
club("gladbach", "BMG", "Borussia Mönchengladbach", listOf("mönchengladbach", "gladbach"), 0xFF1A1A1A, 0xFF00A94F, league = 1),
club("wolfsburg", "WOB", "VfL Wolfsburg", listOf("wolfsburg"), 0xFF65B32E, WHITE, league = 1),
club("bremen", "SVW", "SV Werder Bremen", listOf("werder bremen", "werder"), 0xFF1D9053, WHITE, league = 1),
club("freiburg", "SCF", "SC Freiburg", listOf("sc freiburg", "freiburg"), 0xFFE2001A, WHITE, league = 1),
club("hoffenheim", "TSG", "TSG Hoffenheim", listOf("hoffenheim"), 0xFF1C63B7, WHITE, league = 1),
club("mainz", "M05", "1. FSV Mainz 05", listOf("mainz 05", "mainz"), 0xFFE1000F, WHITE, league = 1),
club("augsburg", "FCA", "FC Augsburg", listOf("augsburg"), 0xFF00693F, 0xFFE1000F, league = 1),
club("union", "FCU", "1. FC Union Berlin", listOf("union berlin"), 0xFFE1000F, 0xFFFDE100, league = 1),
club("koeln", "EFC", "1. FC Köln", listOf("1. fc köln", "fc köln"), 0xFFE1000F, WHITE, league = 1),
club("hsv", "HSV", "Hamburger SV", listOf("hamburger sv", "hsv"), 0xFF0E5EA6, BLACK, league = 1),
club("heidenheim", "HDH", "1. FC Heidenheim", listOf("heidenheim"), 0xFFE1000F, 0xFF1656A4, league = 1),
club("st-pauli", "FCSP", "FC St. Pauli", listOf("st. pauli", "st pauli"), 0xFF6B4423, WHITE, league = 1),
// --- 2. Bundesliga ---
club("schalke", "S04", "FC Schalke 04", listOf("schalke"), 0xFF004D9D, WHITE, league = 2),
club("hertha", "BSC", "Hertha BSC", listOf("hertha"), 0xFF004D9D, WHITE, league = 2),
club("duesseldorf", "F95", "Fortuna Düsseldorf", listOf("fortuna düsseldorf"), 0xFFE1000F, WHITE, league = 2),
club("nuernberg", "FCN", "1. FC Nürnberg", listOf("nürnberg"), 0xFF8B1A1A, WHITE, league = 2),
club("kaiserslautern", "FCK", "1. FC Kaiserslautern", listOf("kaiserslautern"), 0xFFE1000F, WHITE, league = 2),
club("karlsruhe", "KSC", "Karlsruher SC", listOf("karlsruher sc", "ksc"), 0xFF0055A5, WHITE, league = 2),
club("hannover", "H96", "Hannover 96", listOf("hannover 96"), 0xFF00A94F, WHITE, league = 2),
club("paderborn", "SCP", "SC Paderborn 07", listOf("paderborn"), 0xFF004D9D, WHITE, league = 2),
club("magdeburg", "FCM", "1. FC Magdeburg", listOf("magdeburg"), 0xFF0B7A3E, WHITE, league = 2),
club("elversberg", "SVE", "SV Elversberg", listOf("elversberg"), 0xFF1A1A1A, 0xFFE1000F, league = 2),
club("darmstadt", "SV98", "SV Darmstadt 98", listOf("darmstadt"), 0xFF004E9E, WHITE, league = 2),
club("braunschweig", "BTSV", "Eintracht Braunschweig", listOf("braunschweig"), 0xFFFDE100, 0xFF004E9E, league = 2),
club("bochum", "BOC", "VfL Bochum", listOf("bochum"), 0xFF005CA9, WHITE, league = 2),
club("muenster", "SCPM", "Preußen Münster", listOf("preußen münster", "münster"), 0xFF00703C, WHITE, league = 2),
club("fuerth", "SGF", "SpVgg Greuther Fürth", listOf("greuther fürth", "fürth"), 0xFF00A94F, WHITE, league = 2),
club("holstein", "KSV", "Holstein Kiel", listOf("holstein kiel"), 0xFF004E9E, 0xFFE1000F, league = 2),
club("dresden", "SGD", "Dynamo Dresden", listOf("dynamo dresden"), 0xFFFDE100, BLACK, league = 2),
club("bielefeld", "DSC", "Arminia Bielefeld", listOf("arminia bielefeld", "bielefeld"), 0xFF00539F, WHITE, league = 2),
// --- 3. Liga ---
club("hansa", "FCH", "FC Hansa Rostock", listOf("hansa"), 0xFF0B4EA2, WHITE, league = 3),
club("saarbruecken", "FCS", "1. FC Saarbrücken", listOf("saarbrücken"), 0xFF0B4EA2, BLACK, league = 3),
club("aue", "FCE", "Erzgebirge Aue", listOf("erzgebirge aue"), 0xFF7C0A02, WHITE, league = 3),
club("cottbus", "FCEC", "Energie Cottbus", listOf("energie cottbus", "cottbus"), 0xFFE1000F, WHITE, league = 3),
club("essen", "RWE", "Rot-Weiss Essen", listOf("rot-weiss essen", "rot weiss essen"), 0xFFE1000F, WHITE, league = 3),
club("duisburg", "MSV", "MSV Duisburg", listOf("msv duisburg", "duisburg"), 0xFF004E9E, WHITE, league = 3),
club("mannheim", "SVWM", "SV Waldhof Mannheim", listOf("waldhof mannheim", "waldhof"), 0xFF0B4EA2, WHITE, league = 3),
club("wehen", "SVWW", "SV Wehen Wiesbaden", listOf("wehen wiesbaden", "wehen"), 0xFFE1000F, BLACK, league = 3),
club("ulm", "SSV", "SSV Ulm 1846", listOf("ssv ulm"), 0xFFE1000F, WHITE, article = "SSV Ulm 1846 Fußball", league = 3),
club("regensburg", "SSVJ", "SSV Jahn Regensburg", listOf("jahn regensburg", "regensburg"), 0xFFE1000F, WHITE, league = 3),
club("verl", "SCV", "SC Verl", listOf("sc verl"), 0xFF00703C, WHITE, league = 3),
club("viktoria", "VKÖ", "Viktoria Köln", listOf("viktoria köln"), 0xFFE1000F, WHITE, league = 3),
club("havelse", "TSVH", "TSV Havelse", listOf("havelse"), 0xFF004E9E, WHITE, league = 3),
club("schweinfurt", "FC05", "1. FC Schweinfurt 05", listOf("schweinfurt"), 0xFF00A94F, WHITE, league = 3),
club("osnabrueck", "VfLO", "VfL Osnabrück", listOf("osnabrück"), 0xFF6A2C8F, WHITE, league = 3),
club("aachen", "ALE", "Alemannia Aachen", listOf("alemannia aachen", "aachen"), 0xFFFDE100, BLACK, league = 3),
club("ingolstadt", "FCI", "FC Ingolstadt 04", listOf("ingolstadt"), 0xFFE1000F, BLACK, league = 3),
club("wuppertal", "WSV", "Wuppertaler SV", listOf("wuppertaler sv"), 0xFFE1000F, 0xFF0B4EA2, league = 3),
club("stuttgart-ii", "VfB2", "VfB Stuttgart II", listOf("vfb stuttgart ii"), 0xFFE32219, BLACK, league = 3),
club("hoffenheim-ii", "TSG2", "TSG Hoffenheim II", listOf("hoffenheim ii"), 0xFF1C63B7, BLACK, article = "TSG 1899 Hoffenheim", league = 3),
)
/** Clubs switched on for a viewer before they touch the settings. */
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 }
/** Clubs grouped by division, in the order the picker shows them. */
fun byLeague(clubs: List<TeamFilter> = all): List<Pair<String, List<TeamFilter>>> =
listOf(
"1. Bundesliga" to clubs.filter { it.league == 1 },
"2. Bundesliga" to clubs.filter { it.league == 2 },
"3. Liga" to clubs.filter { it.league == 3 },
).filter { it.second.isNotEmpty() }
fun defaultKeysFor(username: String): List<String> =
defaultForUser[username.trim().lowercase()].orEmpty()
}

View File

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

@@ -0,0 +1,78 @@
package dev.castarr.tv.server
/**
* Per-address budget for failed authentication attempts.
*
* Kept free of Android and of the clock so the rule itself can be tested:
* this is where the phone remote was thrown out in 0.11.0, because every
* connection cost budget instead of only the failed ones. A remote
* reconnects on every network hiccup, and five reconnects a minute are
* normal traffic, not an attack.
*/
class AttemptBudget(
private val windowMs: Long = WINDOW_MS,
private val maxFailures: Int = MAX_FAILURES,
private val maxTrackedAddresses: Int = MAX_TRACKED_ADDRESSES,
private val now: () -> Long = System::currentTimeMillis,
) {
private val failures = HashMap<String, ArrayDeque<Long>>()
/** True while this address may still try. Never consumes budget. */
@Synchronized
fun allows(address: String): Boolean {
val queue = failures[address] ?: return true
prune(queue, now())
if (queue.isEmpty()) failures.remove(address)
return queue.size < maxFailures
}
/** Only a *failed* authentication costs budget. */
@Synchronized
fun recordFailure(address: String) {
val timestamp = now()
val queue = failures.getOrPut(address) { ArrayDeque() }
queue.addLast(timestamp)
if (failures.size > maxTrackedAddresses) evict(timestamp)
}
/** A successful authentication wipes the address clean. */
@Synchronized
fun clear(address: String) {
failures.remove(address)
}
@Synchronized
fun trackedAddresses(): Int = failures.size
private fun prune(queue: ArrayDeque<Long>, timestamp: Long) {
while (queue.isNotEmpty() && timestamp - queue.first() > windowMs) {
queue.removeFirst()
}
}
/**
* Expired entries first, and only then the oldest addresses. Dropping
* just the empty queues left the map growing without bound as long as
* every tracked address still held one fresh failure.
*/
private fun evict(timestamp: Long) {
val iterator = failures.entries.iterator()
while (iterator.hasNext()) {
val entry = iterator.next()
prune(entry.value, timestamp)
if (entry.value.isEmpty()) iterator.remove()
}
if (failures.size <= maxTrackedAddresses) return
failures.entries
.sortedBy { it.value.firstOrNull() ?: 0L }
.take(failures.size - maxTrackedAddresses)
.forEach { failures.remove(it.key) }
}
companion object {
const val WINDOW_MS = 60_000L
const val MAX_FAILURES = 5
const val MAX_TRACKED_ADDRESSES = 64
}
}

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,32 @@ 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
@Synchronized // Failed authentication attempts per remote address. The rule itself
private fun codeAttemptAllowed(): Boolean { // lives in AttemptBudget, where it is unit-tested.
val now = System.currentTimeMillis() private val attempts = AttemptBudget()
while (codeAttempts.isNotEmpty() && now - codeAttempts.first() > CODE_ATTEMPT_WINDOW_MS) {
codeAttempts.removeFirst()
}
if (codeAttempts.size >= CODE_ATTEMPT_MAX) return false
codeAttempts.addLast(now)
return true
}
fun startServer() { fun startServer() {
start(0, true) // A busy port must not take the whole app down — the remote is
// optional, everything else keeps working.
// NanoHTTPD's 5 s default also applies to the long-lived WebSocket:
// with pings only every 8 s the socket timed out mid-session and the
// remote was thrown out after a few seconds. The timeout still has to
// exist (idle connections must not pin threads), it just has to be
// comfortably longer than the ping interval.
running = runCatching { start(SOCKET_TIMEOUT_MS, true) }
.onFailure { Log.w(TAG, "control server unavailable: ${it.javaClass.simpleName}") }
.isSuccess
if (!running) return
pingTimer = Timer("ws-ping", true).also { pingTimer = Timer("ws-ping", true).also {
it.schedule(object : TimerTask() { it.schedule(object : TimerTask() {
override fun run() = pingClients() override fun run() = maintainClients()
}, PING_INTERVAL_MS, PING_INTERVAL_MS) }, PING_INTERVAL_MS, PING_INTERVAL_MS)
} }
} }
@@ -83,17 +86,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 +129,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 +167,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 +194,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 +215,19 @@ class ControlServer(
} }
} }
fun dropSilently() {
clients.remove(this)
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "timeout", false) }
}
override fun onOpen() { override fun onOpen() {
// Cap concurrent sockets — a TV remote needs a handful, and an
// unbounded count is a free resource-exhaustion vector.
if (clients.size >= MAX_CLIENTS) {
Log.w(TAG, "client limit reached, refusing connection")
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "too many clients", false) }
return
}
clients.add(this) clients.add(this)
} }
@@ -227,25 +281,27 @@ class ControlServer(
} }
private fun handleHello(msg: JSONObject) { private fun handleHello(msg: JSONObject) {
val token = msg.optString("token") if (!attempts.allows(remoteAddress)) {
val tokenOk = token.isNotEmpty() && token == pairingToken
val codeOk = !tokenOk && msg.optString("code").let { code ->
code.isNotEmpty() && when {
!codeAttemptAllowed() -> {
trySend(JSONObject().put("type", "error").put("error", "rate_limited").toString()) trySend(JSONObject().put("type", "error").put("error", "rate_limited").toString())
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "rate limited", false) } runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "rate limited", false) }
return return
} }
else -> code == pairingCode val tokenOk = Pairing.isValidToken(context, msg.optString("token"))
} val codeOk = !tokenOk && Pairing.isValidCode(context, msg.optString("code"))
}
if (!tokenOk && !codeOk) { if (!tokenOk && !codeOk) {
attempts.recordFailure(remoteAddress)
trySend(JSONObject().put("type", "error").put("error", "bad_code").toString()) trySend(JSONObject().put("type", "error").put("error", "bad_code").toString())
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "bad code", false) } runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "bad code", false) }
return return
} }
attempts.clear(remoteAddress)
authorized = true authorized = true
deviceName = msg.optString("name").ifEmpty { "Handy" } deviceName = msg.optString("name").ifEmpty { "Handy" }
// A code-authenticated client gets its own revocable token, never
// the QR token — otherwise the guessable path would yield the
// master credential.
val grantedToken =
if (tokenOk) msg.optString("token") else Pairing.newSessionToken(context)
// Build the welcome payload on the main thread — it reads the // Build the welcome payload on the main thread — it reads the
// player and UI state, which must not happen on the WS thread. // player and UI state, which must not happen on the WS thread.
post { post {
@@ -253,7 +309,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 +328,9 @@ class ControlServer(
private companion object { private companion object {
const val TAG = "ControlServer" const val TAG = "ControlServer"
const val PING_INTERVAL_MS = 8_000L const val PING_INTERVAL_MS = 8_000L
const val CODE_ATTEMPT_WINDOW_MS = 60_000L const val SOCKET_TIMEOUT_MS = 40_000
const val CODE_ATTEMPT_MAX = 5 const val 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
@@ -20,16 +21,27 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.animation.core.animateFloat
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
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.Stroke
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 +81,28 @@ fun LiveScreen(state: AppState) {
val groups = remember(allChannels) { val groups = remember(allChannels) {
allChannels.mapNotNull { it.group.ifEmpty { null } }.distinct().sorted() allChannels.mapNotNull { it.group.ifEmpty { null } }.distinct().sorted()
} }
val teams = remember(state.enabledTeams) {
dev.castarr.tv.data.TeamFilters.all.filter { it.key in state.enabledTeams }
}
// Recomputed when the EPG or the channel list changes — scanning the
// sport groups' programmes is cheap, but not per frame.
val matchesByTeam = remember(teams, allChannels, epgStamp) {
teams.associate { it.key to state.teamMatches(it.key) }
}
// Re-evaluates the highlight every minute; without it "gleich" would
// only appear when something else happened to recompose.
var urgencyTick by remember { mutableStateOf(0) }
LaunchedEffect(teams) {
while (true) {
kotlinx.coroutines.delay(60_000)
urgencyTick++
}
}
val activeTeam = teams.firstOrNull { it.key == state.activeTeam }
val activeMatches = activeTeam?.let { matchesByTeam[it.key] }.orEmpty()
val teamHits = remember(activeMatches) { activeMatches.associate { it.first.url to it.second } }
val channels = when { val channels = when {
activeTeam != null -> activeMatches.map { it.first }
state.favoritesOnly -> allChannels.filter { it.backendId in favorites } state.favoritesOnly -> allChannels.filter { it.backendId in favorites }
state.groupFilter != null -> allChannels.filter { it.group == state.groupFilter } state.groupFilter != null -> allChannels.filter { it.group == state.groupFilter }
else -> allChannels else -> allChannels
@@ -92,15 +125,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 +166,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 +175,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 +187,40 @@ fun LiveScreen(state: AppState) {
GroupItem( GroupItem(
label = "★ Favoriten", label = "★ Favoriten",
count = favorites.size, count = favorites.size,
selected = state.favoritesOnly, selected = state.favoritesOnly && state.activeTeam == null,
modifier = intoList.then( modifier = intoList.then(
if (state.favoritesOnly) Modifier.focusRequester(railFocus) else Modifier if (state.favoritesOnly) Modifier.focusRequester(railFocus) else Modifier
), ),
suppressAutoSelect = { state.restorePending },
) { ) {
state.activeTeam = null
state.favoritesOnly = true state.favoritesOnly = true
state.groupFilter = null state.groupFilter = null
} }
} }
} }
items(teams, key = { it.key }) { club ->
val clubMatches = matchesByTeam[club.key].orEmpty()
val clubSignal = remember(clubMatches, urgencyTick) {
state.teamSignal(clubMatches)
}
GroupItem(
label = club.label,
count = clubMatches.size,
selected = state.activeTeam == club.key,
leading = { Crest(club, state, clubSignal.urgency) },
signal = clubSignal,
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 +234,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 +260,13 @@ fun LiveScreen(state: AppState) {
if (channels.isEmpty()) { if (channels.isEmpty()) {
item { item {
Text( Text(
if (state.favoritesOnly) when {
activeTeam != null ->
"Heute läuft nichts mehr mit ${activeTeam.fullName}."
state.favoritesOnly ->
"Noch keine Favoriten — halte OK auf einem Sender gedrückt." "Noch keine Favoriten — halte OK auf einem Sender gedrückt."
else "Diese Gruppe ist leer.", else -> "Diese Gruppe ist leer."
},
color = CastarrColors.faint, color = CastarrColors.faint,
fontFamily = AppFont, fontFamily = AppFont,
fontSize = 14.sp, fontSize = 14.sp,
@@ -192,9 +281,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 +314,10 @@ private fun GroupItem(
count: Int, count: Int,
selected: Boolean, selected: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
leading: (@Composable () -> Unit)? = null,
/** Replaces the count when a match is on or about to start. */
signal: AppState.TeamSignal? = null,
suppressAutoSelect: () -> Boolean = { false },
onSelect: () -> Unit, onSelect: () -> Unit,
) { ) {
Surface( Surface(
@@ -224,7 +326,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 +340,10 @@ private fun GroupItem(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp), modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (leading != null) {
leading()
Spacer(Modifier.width(10.dp))
}
Text( Text(
label, label,
fontFamily = AppFont, fontFamily = AppFont,
@@ -248,11 +354,36 @@ private fun GroupItem(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
// The signal lives in the value column instead of adding a
// badge — the count is worth less than "now" or "16:00" is.
val urgent = signal?.urgency ?: AppState.TeamUrgency.NONE
if (urgent == AppState.TeamUrgency.NONE) {
Text( Text(
"$count", "$count",
fontFamily = AppFont, fontFamily = AppFont,
fontSize = 12.sp, fontSize = 12.sp,
) )
} else {
// A focused row is filled with the accent colour, so signal
// colour straight on the row was red on turquoise. The chip
// gives it a dark ground that holds on every row state.
Box(
Modifier
.clip(RoundedCornerShape(999.dp))
.background(CastarrColors.bgDeep.copy(alpha = 0.92f))
.padding(horizontal = 8.dp, vertical = 3.dp)
) {
Text(
if (urgent == AppState.TeamUrgency.LIVE) "läuft"
else formatClock(signal!!.kickOff),
color = if (urgent == AppState.TeamUrgency.LIVE) CastarrColors.live
else CastarrColors.accent,
fontFamily = AppFont,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
} }
} }
} }
@@ -263,6 +394,7 @@ private fun ChannelRow(
number: Int, number: Int,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
nowNext: NowNext, nowNext: NowNext,
highlight: AppState.TeamHit? = null,
playing: Boolean, playing: Boolean,
favorite: Boolean, favorite: Boolean,
epgStamp: Long, epgStamp: Long,
@@ -323,7 +455,13 @@ private fun ChannelRow(
} }
} }
Spacer(Modifier.width(24.dp)) Spacer(Modifier.width(24.dp))
if (highlight != null) {
// In the club view the matching broadcast is the point, not
// whatever happens to be running.
HighlightCell(highlight, Modifier.weight(1f))
} else {
EpgCell(nowNext, Modifier.weight(1f)) EpgCell(nowNext, Modifier.weight(1f))
}
if (playing) { if (playing) {
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Box( Box(
@@ -337,6 +475,84 @@ 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,
urgency: AppState.TeamUrgency = AppState.TeamUrgency.NONE,
) {
// The box is wider than the crest on purpose: the pulse ring is drawn
// around it, and a box sized to the crest would clip the ring away.
Box(Modifier.size(30.dp), contentAlignment = Alignment.Center) {
if (urgency != AppState.TeamUrgency.NONE) {
// A slow pulse is what actually catches the eye from the sofa;
// colour alone does not at that distance.
val transition = androidx.compose.animation.core.rememberInfiniteTransition(
label = "crest-pulse",
)
val phase by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = androidx.compose.animation.core.infiniteRepeatable(
androidx.compose.animation.core.tween(2000, easing = androidx.compose.animation.core.LinearEasing),
androidx.compose.animation.core.RepeatMode.Restart,
),
label = "phase",
)
val ringColor =
if (urgency == AppState.TeamUrgency.LIVE) CastarrColors.live
else CastarrColors.accent
Canvas(Modifier.fillMaxSize()) {
val grow = phase.coerceAtMost(0.75f) / 0.75f
drawCircle(
color = ringColor.copy(alpha = (1f - grow) * 0.85f),
radius = size.minDimension * (0.34f + grow * 0.15f),
style = Stroke(width = 2.dp.toPx()),
)
}
}
SubcomposeAsyncImage(
model = "file:///android_asset/crests/${team.key}.png",
contentDescription = null,
contentScale = ContentScale.Fit,
filterQuality = androidx.compose.ui.graphics.FilterQuality.High,
modifier = Modifier.size(20.dp),
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 +587,42 @@ private fun LogoInitials(initials: String) {
} }
} }
/** The next club broadcast on this channel, and how many follow it. */
@Composable
private fun HighlightCell(hit: AppState.TeamHit, modifier: Modifier = Modifier) {
val programme = hit.programme
val running = System.currentTimeMillis() in programme.start until programme.stop
Column(modifier) {
Row(verticalAlignment = Alignment.Bottom) {
Text(
programme.title,
color = CastarrColors.fg,
fontFamily = AppFont,
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
Spacer(Modifier.width(12.dp))
Text(
if (running) "läuft" else "ab ${formatClock(programme.start)}",
color = if (running) CastarrColors.accent else CastarrColors.muted,
fontFamily = AppFont,
fontSize = 12.sp,
fontWeight = if (running) FontWeight.SemiBold else FontWeight.Normal,
)
}
Spacer(Modifier.height(6.dp))
Text(
"${formatClock(programme.start)}${formatClock(programme.stop)}" +
if (hit.further > 0) " · +${hit.further} weitere" else "",
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 12.sp,
)
}
}
private fun formatClock(millis: Long): String = private fun formatClock(millis: Long): String =
SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis)) SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis))

View File

@@ -28,7 +28,9 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.key
@@ -37,6 +39,7 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -98,6 +101,11 @@ fun PlayerScreen(state: AppState) {
} }
} }
/** Soft drop shadow so overlay text stays readable on bright video. */
private val OverlayText = TextStyle(
shadow = Shadow(Color(0xB3000000), Offset(0f, 2f), blurRadius = 8f),
)
private fun formatClock(millis: Long): String = private fun formatClock(millis: Long): String =
SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis)) SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis))
@@ -146,7 +154,8 @@ private fun Overlay(state: AppState) {
.padding(top = 22.dp, end = 30.dp), .padding(top = 22.dp, end = 30.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text(name, color = CastarrColors.muted, fontFamily = AppFont, fontSize = 12.sp) Text(name, color = CastarrColors.muted, fontFamily = AppFont,
style = OverlayText, fontSize = 12.sp)
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Box( Box(
Modifier Modifier
@@ -168,7 +177,8 @@ private fun Overlay(state: AppState) {
val statusWord = when (state.playerState) { val statusWord = when (state.playerState) {
"paused" -> "Pausiert" "paused" -> "Pausiert"
"buffering" -> "Lädt…" "buffering" -> "Lädt…"
"error" -> "Wiedergabefehler" "reconnecting" -> "Verbindung wird wiederhergestellt…"
"error" -> "Sender gerade nicht erreichbar"
else -> null else -> null
} }
if (statusWord != null) { if (statusWord != null) {
@@ -178,18 +188,38 @@ private fun Overlay(state: AppState) {
statusWord, statusWord,
color = CastarrColors.muted, color = CastarrColors.muted,
fontFamily = AppFont, fontFamily = AppFont,
style = OverlayText,
fontSize = 12.sp, fontSize = 12.sp,
) )
} }
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
} }
Row(verticalAlignment = Alignment.Bottom) {
// Channel number inline before the name (matches the list
// and the number-pad zapping).
val number = state.currentChannel?.let { current ->
state.activeChannels().indexOfFirst { it.url == current.url } + 1
} ?: 0
if (number > 0) {
Text(
"$number",
color = CastarrColors.accent,
fontFamily = AppFont,
style = OverlayText,
fontSize = 30.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.width(14.dp))
}
Text( Text(
state.currentChannel?.name.orEmpty(), state.currentChannel?.name.orEmpty(),
color = CastarrColors.fg, color = CastarrColors.fg,
fontFamily = AppFont, fontFamily = AppFont,
style = OverlayText,
fontSize = 30.sp, fontSize = 30.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
) )
}
val info = state.currentChannel?.let { state.nowNext(it) } val info = state.currentChannel?.let { state.nowNext(it) }
val now = info?.now?.takeUnless { isEpgPlaceholder(it.title) } val now = info?.now?.takeUnless { isEpgPlaceholder(it.title) }
@@ -200,13 +230,15 @@ private fun Overlay(state: AppState) {
now.title, now.title,
color = CastarrColors.fg, color = CastarrColors.fg,
fontFamily = AppFont, fontFamily = AppFont,
style = OverlayText,
fontSize = 18.sp, fontSize = 18.sp,
) )
Spacer(Modifier.width(14.dp)) Spacer(Modifier.width(14.dp))
Text( Text(
"${formatClock(now.start)} ${formatClock(now.stop)}", "${formatClock(now.start)} ${formatClock(now.stop)}",
color = CastarrColors.muted, color = Color(0xFFD7DBE0),
fontFamily = AppFont, fontFamily = AppFont,
style = OverlayText,
fontSize = 14.sp, fontSize = 14.sp,
) )
} }
@@ -217,8 +249,9 @@ private fun Overlay(state: AppState) {
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
Text( Text(
"Danach: ${next.title} (${formatClock(next.start)})", "Danach: ${next.title} (${formatClock(next.start)})",
color = CastarrColors.faint, color = CastarrColors.muted,
fontFamily = AppFont, fontFamily = AppFont,
style = OverlayText,
fontSize = 14.sp, fontSize = 14.sp,
) )
} }
@@ -238,7 +271,7 @@ private fun Overlay(state: AppState) {
.fillMaxWidth() .fillMaxWidth()
.height(3.dp) .height(3.dp)
.clip(RoundedCornerShape(1.5.dp)) .clip(RoundedCornerShape(1.5.dp))
.background(CastarrColors.line) .background(Color(0x59FFFFFF))
) { ) {
Box( Box(
Modifier Modifier
@@ -250,13 +283,27 @@ private fun Overlay(state: AppState) {
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
if (state.playerState == "error") {
// One clear action instead of silently dropping back to
// the channel list.
PillButton("Erneut versuchen", focusRequester = playFocus) {
state.retryPlayback()
}
Spacer(Modifier.width(10.dp))
IconButton(icon = PillIcon.STOP, contentDescription = "Beenden") {
state.stopPlayback()
}
} else {
IconButton( IconButton(
icon = if (state.playerState == "paused") PillIcon.PLAY else PillIcon.PAUSE, icon = if (state.playerState == "paused") PillIcon.PLAY else PillIcon.PAUSE,
contentDescription = if (state.playerState == "paused") "Weiter" else "Pause", contentDescription = if (state.playerState == "paused") "Weiter" else "Pause",
focusRequester = playFocus, focusRequester = playFocus,
) { state.player.toggle() } ) { state.player.toggle() }
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(10.dp))
IconButton(icon = PillIcon.STOP, contentDescription = "Beenden") { state.stopPlayback() } IconButton(icon = PillIcon.STOP, contentDescription = "Beenden") {
state.stopPlayback()
}
}
if (state.audioTracks.size > 1) { if (state.audioTracks.size > 1) {
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(10.dp))
val current = state.audioTracks.firstOrNull { it.selected } val current = state.audioTracks.firstOrNull { it.selected }
@@ -267,6 +314,7 @@ private fun Overlay(state: AppState) {
"▲ ▼ Sender wechseln", "▲ ▼ Sender wechseln",
color = CastarrColors.faint, color = CastarrColors.faint,
fontFamily = AppFont, fontFamily = AppFont,
style = OverlayText,
fontSize = 12.sp, fontSize = 12.sp,
) )
} }
@@ -346,6 +394,7 @@ private fun PillButton(
Text( Text(
label, label,
fontFamily = AppFont, fontFamily = AppFont,
style = OverlayText,
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
modifier = Modifier.padding(horizontal = 18.dp, vertical = 9.dp), modifier = Modifier.padding(horizontal = 18.dp, vertical = 9.dp),

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,14 @@ 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.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -32,6 +39,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -72,13 +80,23 @@ fun SettingsScreen(state: AppState) {
val profiles by state.dispatcharr.profiles.collectAsState() val profiles by state.dispatcharr.profiles.collectAsState()
var updateStatus by remember { mutableStateOf("") } var updateStatus by remember { mutableStateOf("") }
var picker by remember { mutableStateOf<Picker?>(null) } var picker by remember { mutableStateOf<Picker?>(null) }
var clubPicker by remember { mutableStateOf(false) }
// Bumped on reset so the QR code and the four-digit code redraw.
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 +179,32 @@ fun SettingsScreen(state: AppState) {
} }
} }
SettingsCard("Vereinsmenüs") {
Text(
"Eigene Gruppe mit allen Sendern, auf denen der Verein " +
"heute noch läuft. Bis zu " +
"${dev.castarr.tv.data.TeamFilters.MAX_ACTIVE} Vereine.",
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 11.sp,
modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 6.dp),
)
val active = dev.castarr.tv.data.TeamFilters.all
.filter { it.key in state.enabledTeams }
active.forEach { club ->
SettingRow(
club.fullName,
subtitle = "Erscheint als ${club.label} in der Senderliste · Entfernen",
leading = { ClubCrest(club, state) },
trailing = { RemoveIcon() },
) { state.toggleTeam(club.key) }
}
if (active.size < dev.castarr.tv.data.TeamFilters.MAX_ACTIVE) {
SettingRow(
"Verein hinzufügen",
subtitle = "1. bis 3. Liga",
) { clubPicker = true }
}
}
SettingsCard("App") { SettingsCard("App") {
SettingRow( SettingRow(
"Version ${BuildConfig.VERSION_NAME}", "Version ${BuildConfig.VERSION_NAME}",
@@ -201,6 +245,10 @@ fun SettingsScreen(state: AppState) {
} }
} }
} }
SettingRow(
"Senderliste neu laden",
subtitle = "Holt Sender, Programm und Favoriten erneut",
) { state.refreshActive() }
SettingRow( SettingRow(
"Erweitert", "Erweitert",
subtitle = "M3U/EPG-Adressen von Hand eintragen", subtitle = "M3U/EPG-Adressen von Hand eintragen",
@@ -212,9 +260,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 +290,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(
@@ -251,13 +313,168 @@ fun SettingsScreen(state: AppState) {
picker?.let { current -> picker?.let { current ->
PickerDialog(current) { picker = null } PickerDialog(current) { picker = null }
} }
if (clubPicker) {
ClubPickerDialog(
state = state,
onPick = { club -> state.toggleTeam(club.key); clubPicker = false },
onClose = { clubPicker = false },
)
}
}
/** Club crest from the bundled assets; initials while it loads. */
@Composable
private fun ClubCrest(team: dev.castarr.tv.data.TeamFilter, state: AppState, size: Int = 26) {
Box(Modifier.size(size.dp), contentAlignment = Alignment.Center) {
coil.compose.SubcomposeAsyncImage(
model = "file:///android_asset/crests/${team.key}.png",
contentDescription = null,
contentScale = androidx.compose.ui.layout.ContentScale.Fit,
modifier = Modifier.fillMaxSize(),
loading = { CrestInitials(team) },
error = { CrestInitials(team) },
)
}
}
@Composable
private fun CrestInitials(team: dev.castarr.tv.data.TeamFilter) {
Box(
Modifier
.fillMaxSize()
.clip(CircleShape)
.background(team.primary),
contentAlignment = Alignment.Center,
) {
Text(
team.label.take(3),
color = team.secondary,
fontFamily = AppFont,
fontSize = 8.sp,
fontWeight = FontWeight.Bold,
)
}
}
/**
* Club chooser: crests plus division headers, because a flat list of 56
* names is unreadable on a remote.
*/
@Composable
private fun ClubPickerDialog(
state: AppState,
onPick: (dev.castarr.tv.data.TeamFilter) -> Unit,
onClose: () -> Unit,
) {
val firstFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { runCatching { firstFocus.requestFocus() } }
val sections = remember(state.enabledTeams) {
dev.castarr.tv.data.TeamFilters.byLeague(
dev.castarr.tv.data.TeamFilters.all.filter { it.key !in state.enabledTeams }
)
}
val firstKey = sections.firstOrNull()?.second?.firstOrNull()?.key
Dialog(onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false)) {
Box(
Modifier
.fillMaxSize()
.background(CastarrColors.bgDeep.copy(alpha = 0.88f)),
contentAlignment = Alignment.Center,
) {
Column(
Modifier
.width(420.dp)
.clip(RoundedCornerShape(16.dp))
.background(CastarrColors.surface)
.padding(horizontal = 10.dp, vertical = 14.dp)
) {
Text(
"VEREIN HINZUFÜGEN",
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 2.sp,
modifier = Modifier.padding(start = 14.dp, bottom = 10.dp),
)
LazyColumn(Modifier.heightIn(max = 460.dp)) {
sections.forEach { (league, clubs) ->
item(key = "h-$league") {
Text(
league,
color = CastarrColors.accent,
fontFamily = AppFont,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 1.5.sp,
modifier = Modifier.padding(start = 14.dp, top = 12.dp, bottom = 6.dp),
)
}
items(clubs, key = { it.key }) { club ->
val focusMod =
if (club.key == firstKey) Modifier.focusRequester(firstFocus)
else Modifier
Surface(
onClick = { onPick(club) },
modifier = Modifier
.fillMaxWidth()
.then(focusMod),
shape = ClickableSurfaceDefaults.shape(rowShape),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
colors = ClickableSurfaceDefaults.colors(
containerColor = androidx.compose.ui.graphics.Color.Transparent,
contentColor = CastarrColors.fg,
focusedContainerColor = CastarrColors.surfaceFocused,
focusedContentColor = CastarrColors.fg,
),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
border = BorderStroke(2.dp, CastarrColors.accent),
shape = rowShape,
),
),
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ClubCrest(club, state, size = 24)
Spacer(Modifier.width(12.dp))
Text(
club.fullName,
fontFamily = AppFont,
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(
club.label,
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 12.sp,
)
}
}
}
}
}
}
}
}
} }
/** TV-friendly dropdown replacement: fullscreen scrim, options centered. */ /** TV-friendly dropdown replacement: fullscreen scrim, options centered. */
@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 +503,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 +516,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(
@@ -340,6 +565,7 @@ private fun PickerDialog(picker: Picker, onClose: () -> Unit) {
} }
} }
} }
}
/** Current value plus a small drawn caret, shown at a row's trailing edge. */ /** Current value plus a small drawn caret, shown at a row's trailing edge. */
@Composable @Composable
@@ -369,6 +595,35 @@ private fun ValueWithCaret(value: String) {
} }
} }
/** Drawn "x": the row only ever removes, so a toggle would mislead. */
@Composable
private fun RemoveIcon() {
val color = CastarrColors.muted
Canvas(Modifier.size(14.dp)) {
val stroke = 2.dp.toPx()
drawLine(color, Offset(0f, 0f), Offset(size.width, size.height), stroke)
drawLine(color, Offset(size.width, 0f), Offset(0f, size.height), stroke)
}
}
@Composable
private fun TogglePill(on: Boolean) {
Box(
Modifier
.clip(RoundedCornerShape(999.dp))
.background(if (on) CastarrColors.accent else CastarrColors.bg)
.padding(horizontal = 12.dp, vertical = 5.dp)
) {
Text(
if (on) "An" else "Aus",
color = if (on) CastarrColors.onAccent else CastarrColors.muted,
fontFamily = AppFont,
fontSize = 12.sp,
fontWeight = if (on) FontWeight.SemiBold else FontWeight.Normal,
)
}
}
@Composable @Composable
private fun SettingsCard(title: String, content: @Composable () -> Unit) { private fun SettingsCard(title: String, content: @Composable () -> Unit) {
Column( Column(
@@ -399,6 +654,7 @@ private fun SettingRow(
label: String, label: String,
subtitle: String? = null, subtitle: String? = null,
danger: Boolean = false, danger: Boolean = false,
leading: (@Composable () -> Unit)? = null,
trailing: (@Composable () -> Unit)? = null, trailing: (@Composable () -> Unit)? = null,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
@@ -424,6 +680,10 @@ private fun SettingRow(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (leading != null) {
leading()
Spacer(Modifier.width(12.dp))
}
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text( Text(
label, label,

View File

@@ -18,15 +18,18 @@ 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 = ""
/** Guards against a second click landing on the same download. */
private val busy = java.util.concurrent.atomic.AtomicBoolean(false)
/** Returns the newer version tag, or null when current. Never throws. */ /** Returns the newer version tag, or null when current. Never throws. */
suspend fun check(state: AppState): String? = withContext(Dispatchers.IO) { suspend fun check(state: AppState): String? = withContext(Dispatchers.IO) {
runCatching { runCatching {
@@ -42,7 +45,7 @@ object UpdateChecker {
} }
} }
if (url.isEmpty() && tag.isNotEmpty()) url = APK_FALLBACK if (url.isEmpty() && tag.isNotEmpty()) url = APK_FALLBACK
if (url.isNotEmpty() && isNewer(tag, BuildConfig.VERSION_NAME)) { if (url.isNotEmpty() && UpdateRules.isNewer(tag, BuildConfig.VERSION_NAME)) {
apkUrl = url apkUrl = url
val version = "v$tag" val version = "v$tag"
withContext(Dispatchers.Main) { state.updateAvailable = version } withContext(Dispatchers.Main) { state.updateAvailable = version }
@@ -54,48 +57,69 @@ object UpdateChecker {
}.onFailure { Log.w(TAG, "check failed: ${it.javaClass.simpleName}") }.getOrNull() }.onFailure { Log.w(TAG, "check failed: ${it.javaClass.simpleName}") }.getOrNull()
} }
/** Downloads the APK and hands it to the package installer. */ /**
* Downloads the APK and hands it to the package installer.
*
* The download lands in a .part file that is only renamed once the byte
* count matches what the server announced. Handing a half-written APK to
* the installer leaves it sitting on a spinner with nothing to report,
* which is indistinguishable from a hang.
*/
suspend fun downloadAndInstall(context: Context, state: AppState): String? = suspend fun downloadAndInstall(context: Context, state: AppState): String? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
// A second click while the first download runs would have two
// writers on one file, and the installer reads whichever half won.
if (!busy.compareAndSet(false, true)) return@withContext "Update läuft bereits"
try {
runCatching { runCatching {
if (apkUrl.isEmpty()) check(state) if (apkUrl.isEmpty()) check(state)
require(apkUrl.isNotEmpty()) { "no update available" } require(apkUrl.isNotEmpty()) { "no update available" }
val dir = File(context.cacheDir, "updates").apply { mkdirs() } val dir = File(context.cacheDir, "updates").apply { mkdirs() }
val file = File(dir, "castarr-update.apk") val file = File(dir, "castarr-update.apk")
(URL(apkUrl).openConnection() as HttpURLConnection).run { val part = File(dir, "castarr-update.apk.part")
connectTimeout = 15_000 part.delete()
readTimeout = 120_000 val connection = URL(apkUrl).openConnection() as HttpURLConnection
instanceFollowRedirects = true val expected = try {
inputStream.use { input -> file.outputStream().use { input.copyTo(it) } } connection.connectTimeout = 15_000
disconnect() connection.readTimeout = 120_000
connection.instanceFollowRedirects = true
val code = connection.responseCode
require(code == HttpURLConnection.HTTP_OK) { "http $code" }
val announced = connection.contentLengthLong
connection.inputStream.use { input ->
part.outputStream().use { output -> input.copyTo(output) }
} }
announced
} finally {
connection.disconnect()
}
require(UpdateRules.isComplete(part.length(), expected)) {
"truncated: ${part.length()} of $expected"
}
file.delete()
require(part.renameTo(file)) { "rename failed" }
val uri = FileProvider.getUriForFile( val uri = FileProvider.getUriForFile(
context, "${BuildConfig.APPLICATION_ID}.fileprovider", file, context, "${BuildConfig.APPLICATION_ID}.fileprovider", file,
) )
val intent = Intent(Intent.ACTION_VIEW).apply { val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive") setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK
)
} }
// Launching from the IO dispatcher stalled the installer on // Launching from the IO dispatcher stalled the installer on
// the first attempt — activities start from the main thread. // the first attempt — activities start from the main thread.
withContext(Dispatchers.Main) { context.startActivity(intent) } withContext(Dispatchers.Main) { context.startActivity(intent) }
null null
}.getOrElse { }.getOrElse {
Log.w(TAG, "install failed: ${it.javaClass.simpleName}") Log.w(TAG, "install failed: ${it.javaClass.simpleName}: ${it.message}")
File(context.cacheDir, "updates/castarr-update.apk.part").delete()
"Update fehlgeschlagen — später erneut versuchen" "Update fehlgeschlagen — später erneut versuchen"
} }
} finally {
busy.set(false)
} }
private fun isNewer(remote: String, local: String): Boolean {
fun parts(v: String) = v.split(".").mapNotNull { it.toIntOrNull() }
val r = parts(remote)
val l = parts(local)
for (i in 0 until maxOf(r.size, l.size)) {
val a = r.getOrElse(i) { 0 }
val b = l.getOrElse(i) { 0 }
if (a != b) return a > b
}
return false
} }
private fun get(url: String): String { private fun get(url: String): String {
@@ -103,7 +127,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,45 @@
package dev.castarr.tv.update
/**
* The two decisions the updater makes, without Android or a network in the
* way: is the offered release newer, and did the download arrive whole.
*
* Both shipped as bugs once — a version compare that reads "0.11.10" as
* older than "0.11.9", and a half-written APK handed to the package
* installer, which then sits on a spinner with nothing to report.
*/
object UpdateRules {
/**
* Compares dotted numeric versions segment by segment, missing segments
* counting as zero ("0.12" == "0.12.0"). A leading "v" is tolerated on
* either side; anything non-numeric is ignored rather than throwing,
* because a release tag is user input.
*/
fun isNewer(remote: String, local: String): Boolean {
val r = segments(remote)
val l = segments(local)
for (i in 0 until maxOf(r.size, l.size)) {
val a = r.getOrElse(i) { 0 }
val b = l.getOrElse(i) { 0 }
if (a != b) return a > b
}
return false
}
/**
* True when the bytes on disk match what the server announced.
*
* A server that announces nothing (chunked transfer, `announced <= 0`)
* cannot be checked against — then any non-empty file has to pass, which
* is the honest answer rather than a guess.
*/
fun isComplete(actualBytes: Long, announcedBytes: Long): Boolean {
if (actualBytes <= 0) return false
if (announcedBytes <= 0) return true
return actualBytes == announcedBytes
}
private fun segments(version: String) =
version.removePrefix("v").split(".").mapNotNull { it.toIntOrNull() }
}

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

107
tests/demo/make-demo-data.py Executable file
View File

@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Neutral demo line-up for screenshots and manual testing.
Invented channels and programmes only — no real provider data ever ends up
in a screenshot. One club match is placed a few minutes ahead so the club
menu shows its "kick-off imminent" state.
"""
import argparse
import datetime
import os
p = argparse.ArgumentParser()
p.add_argument("--out", default=os.path.dirname(os.path.abspath(__file__)))
p.add_argument("--kickoff-in", type=int, default=18,
help="minutes until the club match starts")
args = p.parse_args()
now = datetime.datetime.now().astimezone()
def fmt(dt):
# Local offset, so the emulator's clock agrees with ours (CEST vs CET).
return dt.strftime("%Y%m%d%H%M%S %z")
CHANNELS = [
("Unterhaltung", "Blau TV HD", "blau"),
("Unterhaltung", "Nordlicht HD", "nordlicht"),
("Unterhaltung", "Kanal Sieben", "sieben"),
("Filme", "Kinohaus HD", "kinohaus"),
("Filme", "Filmwerk Classic", "filmwerk"),
("Filme", "Nachtkino HD", "nachtkino"),
("Serien", "Serienbox HD", "serienbox"),
("Serien", "Staffel Eins", "staffel"),
("Sport", "Arena Sport 1 HD", "arena1"),
("Sport", "Arena Sport 2 HD", "arena2"),
("Sport", "Liga Live UHD", "ligalive"),
("Sport", "Stadionkanal HD", "stadion"),
("Nachrichten", "Tagesblick HD", "tagesblick"),
("Nachrichten", "Weltbericht", "weltbericht"),
("Doku & Wissen", "Terra Doku HD", "terra"),
("Doku & Wissen", "Wissenswelt", "wissen"),
("Kinder", "Krümelkiste", "kruemel"),
("Musik", "Tonspur HD", "tonspur"),
("Regional", "Hafenkanal HD", "hafen"),
("International", "Globus TV", "globus"),
]
PROGRAMMES = {
"blau": [("Morgenmagazin", -40, 80), ("Kochduell", 40, 60)],
"nordlicht": [("Küstenrevier", -20, 70), ("Nordlicht Talk", 50, 45)],
"sieben": [("Quiz um Sieben", -10, 60), ("Sieben Reportage", 50, 45)],
"kinohaus": [("Der weite Weg", -55, 125), ("Sturmhöhe", 70, 110)],
"filmwerk": [("Schwarzweiß", -30, 95), ("Die Erbschaft", 65, 100)],
"nachtkino": [("Mitternachtszug", -15, 105), ("Letzte Runde", 90, 95)],
"serienbox": [("Praxis Ostwind (3/12)", -25, 45), ("Praxis Ostwind (4/12)", 20, 45)],
"staffel": [("Hafenwache (7/10)", -35, 50), ("Hafenwache (8/10)", 15, 50)],
"arena1": [("Handball: Pokalrunde", -45, 105), ("Sport am Abend", 60, 60)],
"arena2": [("Radsport: Etappe 4", -50, 130), ("Motorsport kompakt", 80, 55)],
"ligalive": [("Sport aktuell", -25, 43), ("__CLUB__", None, 120)],
"stadion": [("Stadionmagazin", -20, 60), ("FC St. Pauli: Der Rückblick", 40, 45)],
"tagesblick": [("Tagesblick am Mittag", -12, 30), ("Wetter und Verkehr", 18, 15)],
"weltbericht": [("Weltbericht Spezial", -35, 75), ("Auslandsjournal", 40, 45)],
"terra": [("Wale der Arktis", -40, 90), ("Vulkane Islands", 50, 45)],
"wissen": [("Wie Brücken halten", -20, 45), ("Chemie des Alltags", 25, 45)],
"kruemel": [("Bärenbande", -15, 25), ("Malstunde", 10, 25)],
"tonspur": [("Charts der Woche", -60, 120), ("Akustik-Session", 60, 60)],
"hafen": [("Hafenrundfahrt", -30, 60), ("Regional um sechs", 30, 30)],
"globus": [("Globus Reportage", -45, 90), ("Fernweh", 45, 60)],
}
CLUB_TITLE = "Pokal: Nordstadt - FC St. Pauli, 1. Runde"
os.makedirs(args.out, exist_ok=True)
m3u = os.path.join(args.out, "playlist.m3u")
xml = os.path.join(args.out, "epg.xml")
with open(m3u, "w", encoding="utf-8") as f:
f.write("#EXTM3U\n")
for group, name, cid in CHANNELS:
f.write(f'#EXTINF:-1 tvg-id="{cid}" group-title="{group}",{name}\n')
f.write(f"http://10.0.2.2:8099/stream/{cid}.ts\n")
def escape(text):
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
with open(xml, "w", encoding="utf-8") as f:
f.write('<?xml version="1.0" encoding="UTF-8"?>\n<tv>\n')
for _, name, cid in CHANNELS:
f.write(f' <channel id="{cid}"><display-name>{escape(name)}</display-name></channel>\n')
for cid, items in PROGRAMMES.items():
for title, offset, length in items:
if title == "__CLUB__":
title, offset = CLUB_TITLE, args.kickoff_in
start = now + datetime.timedelta(minutes=offset)
stop = start + datetime.timedelta(minutes=length)
f.write(
f' <programme start="{fmt(start)}" stop="{fmt(stop)}" channel="{cid}">'
f"<title>{escape(title)}</title></programme>\n"
)
f.write("</tv>\n")
print(f"{len(CHANNELS)} Sender, {sum(len(v) for v in PROGRAMMES.values())} Sendungen")
print(f"Anstoß in {args.kickoff_in} Min: {CLUB_TITLE}")
print(f"-> {m3u}\n-> {xml}")

70
tests/helpers/emulator.sh Executable file
View File

@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Headless Google-TV emulator for test runs.
#
# The AVD keeps a "default_boot" snapshot, so a start restores a booted
# system in seconds instead of cold-booting for minutes. "stop" refreshes
# that snapshot before killing, so the next start stays fast.
#
# tests/helpers/emulator.sh start # boot (or restore) and wait
# tests/helpers/emulator.sh stop # snapshot, then kill
# tests/helpers/emulator.sh status
# tests/helpers/emulator.sh install <apk>
# tests/helpers/emulator.sh shot <name> # screenshot into tests/runs/screenshots
set -euo pipefail
AVD="${CASTARR_AVD:-castarr-googletv}"
SERIAL="${CASTARR_SERIAL:-emulator-5554}"
SDK="${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}"
ADB="$SDK/platform-tools/adb"
EMULATOR="$SDK/emulator/emulator"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
RUNS="$ROOT/tests/runs"
LOG="$RUNS/emulator.log"
booted() { [ "$("$ADB" -s "$SERIAL" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = "1" ]; }
start() {
if booted; then echo "already up: $SERIAL"; return 0; fi
mkdir -p "$RUNS"
# No -no-snapshot here: that is what makes a start slow.
nohup "$EMULATOR" -avd "$AVD" \
-no-window -no-audio -no-boot-anim \
-gpu swiftshader_indirect \
>"$LOG" 2>&1 &
local start_ts=$SECONDS
"$ADB" -s "$SERIAL" wait-for-device
for _ in $(seq 1 180); do
booted && { echo "up after $((SECONDS - start_ts))s"; return 0; }
sleep 1
done
echo "timeout waiting for boot; see $LOG" >&2
return 1
}
stop() {
if ! pgrep -f "qemu-system-.*-avd $AVD" >/dev/null 2>&1 && ! booted; then
echo "not running"; return 0
fi
# Refresh the snapshot so the next start is a restore, not a cold boot.
"$ADB" -s "$SERIAL" emu avd snapshot save default_boot
"$ADB" -s "$SERIAL" emu kill
echo "stopped, snapshot saved"
}
case "${1:-status}" in
start) start ;;
stop) stop ;;
status) booted && echo "up: $SERIAL" || echo "down" ;;
install)
[ $# -ge 2 ] || { echo "usage: $0 install <apk>" >&2; exit 2; }
"$ADB" -s "$SERIAL" install -r "$2"
;;
shot)
name="${2:-shot}"
mkdir -p "$RUNS/screenshots"
"$ADB" -s "$SERIAL" exec-out screencap -p > "$RUNS/screenshots/$name.png"
echo "$RUNS/screenshots/$name.png"
;;
*) echo "usage: $0 {start|stop|status|install <apk>|shot <name>}" >&2; exit 2 ;;
esac

38
tests/helpers/jdk.sh Executable file
View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Picks a JDK that Gradle can actually run on and exports JAVA_HOME.
#
# Ubuntu moved default-java to 25, which the Gradle version in this repo
# refuses with a bare "IllegalArgumentException: 25.0.4" — an unhelpful
# message for a build that worked yesterday. Sourced by the scripts under
# tests/ and tools/; harmless when JAVA_HOME is already a supported JDK.
#
# . tests/helpers/jdk.sh
set -u
_jdk_major() {
"$1/bin/java" -version 2>&1 | head -1 |
sed -E 's/.*version "([0-9]+).*/\1/'
}
_jdk_pick() {
local candidate major
if [ -n "${JAVA_HOME:-}" ] && [ -x "${JAVA_HOME}/bin/java" ]; then
major="$(_jdk_major "$JAVA_HOME")"
if [ "$major" -ge 17 ] 2>/dev/null && [ "$major" -le 21 ] 2>/dev/null; then
return 0
fi
fi
for candidate in \
/usr/lib/jvm/java-21-openjdk-amd64 \
/usr/lib/jvm/java-17-openjdk-amd64 \
"$HOME"/jdk-21* \
"$HOME"/jdk-17*; do
[ -x "$candidate/bin/java" ] || continue
export JAVA_HOME="$candidate"
return 0
done
echo "no JDK 17-21 found; Gradle cannot run on $(java -version 2>&1 | head -1)" >&2
return 1
}
_jdk_pick

115
tests/smoke.sh Executable file
View File

@@ -0,0 +1,115 @@
#!/usr/bin/env bash
# Smoke test on the headless Google TV emulator.
#
# Builds the debug APK, installs it, walks the first screen with the D-pad
# and fails on anything the unit tests cannot see: a crash on startup, a
# crash while navigating, an ANR. The 0.11.0 startup crash and the rail
# crash on "Verein hinzufügen" would both have been caught here.
#
# tests/smoke.sh # build, run, leave the emulator up
# tests/smoke.sh --apk <path> # skip the build, test this APK
# tests/smoke.sh --stop # stop the emulator when done
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
HELPERS="$ROOT/tests/helpers"
RUNS="$ROOT/tests/runs"
SERIAL="${CASTARR_SERIAL:-emulator-5554}"
ADB="${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}/platform-tools/adb"
PKG="dev.castarr.tv"
ACTIVITY="$PKG/.MainActivity"
APK=""
STOP_AFTER=0
while [ $# -gt 0 ]; do
case "$1" in
--apk) APK="$2"; shift 2 ;;
--stop) STOP_AFTER=1; shift ;;
*) echo "usage: $0 [--apk <path>] [--stop]" >&2; exit 2 ;;
esac
done
mkdir -p "$RUNS/screenshots"
LOG="$RUNS/smoke.log"
: > "$LOG"
step() { printf '\n== %s\n' "$1" | tee -a "$LOG"; }
fail() { printf '\nFAIL: %s\n' "$1" | tee -a "$LOG" >&2; exit 1; }
if [ -z "$APK" ]; then
step "Debug-APK bauen"
# shellcheck source=tests/helpers/jdk.sh
. "$HELPERS/jdk.sh"
(cd "$ROOT" && ./gradlew assembleDebug --no-daemon -q) >>"$LOG" 2>&1 ||
fail "Build fehlgeschlagen, siehe $LOG"
APK="$ROOT/app/build/outputs/apk/debug/app-debug.apk"
fi
[ -f "$APK" ] || fail "APK nicht gefunden: $APK"
step "Emulator starten"
"$HELPERS/emulator.sh" start | tee -a "$LOG"
step "Installieren"
# A debug build over a signed release needs the old one gone first.
"$ADB" -s "$SERIAL" uninstall "$PKG" >/dev/null 2>&1 || true
"$ADB" -s "$SERIAL" install -r "$APK" >>"$LOG" 2>&1 || fail "Installation fehlgeschlagen"
step "Starten"
"$ADB" -s "$SERIAL" logcat -c
"$ADB" -s "$SERIAL" shell am start -W -n "$ACTIVITY" >>"$LOG" 2>&1 ||
fail "am start fehlgeschlagen"
# The first frame is not the point — the crash usually lands a moment later,
# when state loads.
sleep 6
running() { [ -n "$("$ADB" -s "$SERIAL" shell pidof "$PKG" | tr -d '\r')" ]; }
crashed() {
"$ADB" -s "$SERIAL" logcat -d -b crash,main 2>/dev/null |
grep -E "FATAL EXCEPTION|ANR in $PKG|Process $PKG .* has died" | head -20
}
check() {
local where="$1" trace
trace="$(crashed || true)"
if [ -n "$trace" ]; then
printf '%s\n' "$trace" >>"$LOG"
printf '%s\n' "$trace" | head -5
fail "Absturz $where — vollständig in $LOG"
fi
running || fail "Prozess weg $where (kein Stacktrace im Log)"
}
check "beim Start"
"$HELPERS/emulator.sh" shot smoke-start >/dev/null
step "D-Pad-Navigation"
# Down/right walks the rail and opens whatever has focus; back returns.
for key in DPAD_DOWN DPAD_RIGHT DPAD_RIGHT DPAD_DOWN DPAD_CENTER BACK DPAD_UP; do
"$ADB" -s "$SERIAL" shell input keyevent "$key"
sleep 1
done
sleep 2
check "bei der Navigation"
"$HELPERS/emulator.sh" shot smoke-nav >/dev/null
step "Einstellungen öffnen"
"$ADB" -s "$SERIAL" shell am start -n "$ACTIVITY" >/dev/null 2>&1
sleep 2
check "nach dem Wiedereintritt"
if [ "$STOP_AFTER" = 1 ]; then
step "Emulator stoppen"
"$HELPERS/emulator.sh" stop | tee -a "$LOG"
fi
cat <<EOF
OK — kein Absturz, App läuft.
Screenshots: $RUNS/screenshots/smoke-start.png, smoke-nav.png
Log: $LOG
Der Test deckt Start, D-Pad und Wiedereintritt ab. Alles hinter dem
Onboarding (echte Quelle, Wiedergabe, Kopplung) braucht ein Backend und
bleibt Handarbeit.
EOF

View File

@@ -0,0 +1,101 @@
package dev.castarr.tv.server
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AttemptBudgetTest {
private var clock = 0L
private fun budget(
windowMs: Long = 60_000L,
maxFailures: Int = 5,
maxTracked: Int = 64,
) = AttemptBudget(windowMs, maxFailures, maxTracked) { clock }
/**
* The 0.11.0 bug: the remote reconnects on every network hiccup, and
* counting those successful handshakes threw the phone out after five.
*/
@Test
fun `successful connections never cost budget`() {
val budget = budget()
repeat(50) {
assertTrue(budget.allows("10.0.0.5"))
budget.clear("10.0.0.5")
}
assertTrue(budget.allows("10.0.0.5"))
}
@Test
fun `five failures in the window lock the address out`() {
val budget = budget()
repeat(5) {
assertTrue(budget.allows("10.0.0.5"))
budget.recordFailure("10.0.0.5")
}
assertFalse(budget.allows("10.0.0.5"))
}
@Test
fun `a success after failures wipes the slate`() {
val budget = budget()
repeat(4) { budget.recordFailure("10.0.0.5") }
budget.clear("10.0.0.5")
repeat(4) {
assertTrue(budget.allows("10.0.0.5"))
budget.recordFailure("10.0.0.5")
}
assertTrue(budget.allows("10.0.0.5"))
}
@Test
fun `failures expire once the window has passed`() {
val budget = budget()
repeat(5) { budget.recordFailure("10.0.0.5") }
assertFalse(budget.allows("10.0.0.5"))
clock += 60_001
assertTrue(budget.allows("10.0.0.5"))
}
@Test
fun `the window slides, it does not reset in blocks`() {
val budget = budget()
repeat(4) {
budget.recordFailure("10.0.0.5")
clock += 20_000
}
// Two of the four are older than a minute by now, so there is room.
assertTrue(budget.allows("10.0.0.5"))
}
@Test
fun `one address cannot spend another's budget`() {
val budget = budget()
repeat(5) { budget.recordFailure("10.0.0.9") }
assertFalse(budget.allows("10.0.0.9"))
assertTrue(budget.allows("10.0.0.5"))
}
/** An attacker cycling source addresses must not grow the map forever. */
@Test
fun `tracked addresses stay bounded under a flood of fresh ones`() {
val budget = budget(maxTracked = 8)
repeat(500) { i ->
budget.recordFailure("10.0.0.$i")
clock += 10
}
assertTrue(budget.trackedAddresses() <= 8)
}
@Test
fun `eviction drops the stale addresses, not the active one`() {
val budget = budget(maxTracked = 4)
repeat(4) { i -> budget.recordFailure("10.0.1.$i") }
clock += 60_001
repeat(5) { budget.recordFailure("10.0.0.5") }
assertFalse(budget.allows("10.0.0.5"))
assertEquals(1, budget.trackedAddresses())
}
}

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,80 @@
package dev.castarr.tv.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class TeamFiltersTest {
private val hansa = TeamFilters.byKey("hansa")!!
private val vfb = TeamFilters.byKey("stuttgart")!!
@Test
fun `matches the club regardless of case and surrounding text`() {
assertTrue(hansa.matches("3. Liga: FC Hansa Rostock - Dynamo Dresden"))
assertTrue(hansa.matches("HANSA ROSTOCK KOMPAKT"))
assertTrue(vfb.matches("Bundesliga: VfB Stuttgart - Bayern München"))
}
@Test
fun `does not match unrelated programmes`() {
assertFalse(hansa.matches("Tagesschau"))
assertFalse(vfb.matches("Hansa Rostock - Saarbrücken"))
}
@Test
fun `scans sport and free-to-air groups`() {
listOf("DAZN Event", "Sky Sport", "Magenta Sport", "Amazon Prime", "Free TV / HD+", "DYN Sport")
.forEach { assertTrue(it, TeamFilters.scansGroup(it)) }
}
@Test
fun `skips NFL and unrelated groups`() {
assertFalse(TeamFilters.scansGroup("DAZN Event NFL"))
assertFalse(TeamFilters.scansGroup("Kids"))
assertFalse(TeamFilters.scansGroup("Musik"))
}
@Test
fun `assigns each viewer their own club by default`() {
assertEquals(listOf("hansa"), TeamFilters.defaultKeysFor("benjamin"))
assertEquals(listOf("stuttgart"), TeamFilters.defaultKeysFor("TobiasB"))
assertTrue(TeamFilters.defaultKeysFor("someone-else").isEmpty())
}
@Test
fun `club keys are unique and every club has needles`() {
val keys = TeamFilters.all.map { it.key }
assertEquals(keys.size, keys.toSet().size)
assertTrue(TeamFilters.all.all { it.needles.isNotEmpty() })
assertTrue(TeamFilters.all.all { c -> c.needles.all { it == it.lowercase() } })
}
@Test
fun `window reaches midnight but never falls below three hours`() {
val cal = java.util.Calendar.getInstance()
// Morning: the window must reach into the evening.
cal.set(java.util.Calendar.HOUR_OF_DAY, 9)
cal.set(java.util.Calendar.MINUTE, 0)
val morning = cal.timeInMillis
val eveningKickoff = morning + 12 * 60 * 60 * 1000L
assertTrue(TeamFilters.windowEnd(morning) > eveningKickoff)
// Late at night: still at least three hours ahead.
cal.set(java.util.Calendar.HOUR_OF_DAY, 23)
cal.set(java.util.Calendar.MINUTE, 30)
val lateNight = cal.timeInMillis
assertTrue(
TeamFilters.windowEnd(lateNight) >= lateNight + TeamFilters.MIN_WINDOW_MS
)
}
@Test
fun `covers all three divisions`() {
assertTrue(TeamFilters.all.size > 50)
listOf("bayern", "schalke", "hansa").forEach {
assertTrue(it, TeamFilters.byKey(it) != null)
}
}
}

View File

@@ -0,0 +1,76 @@
package dev.castarr.tv.update
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class UpdateRulesTest {
@Test
fun `a higher patch level is newer`() {
assertTrue(UpdateRules.isNewer("0.11.5", "0.11.4"))
}
@Test
fun `the same version is not newer`() {
assertFalse(UpdateRules.isNewer("0.11.5", "0.11.5"))
}
@Test
fun `an older release never offers itself as an update`() {
assertFalse(UpdateRules.isNewer("0.11.4", "0.11.5"))
}
/** Segments are numbers, not text — "10" beats "9". */
@Test
fun `double digit segments compare numerically`() {
assertTrue(UpdateRules.isNewer("0.11.10", "0.11.9"))
assertFalse(UpdateRules.isNewer("0.11.9", "0.11.10"))
assertTrue(UpdateRules.isNewer("0.12.0", "0.9.99"))
}
@Test
fun `a missing segment counts as zero`() {
assertFalse(UpdateRules.isNewer("0.12", "0.12.0"))
assertTrue(UpdateRules.isNewer("0.12.1", "0.12"))
}
@Test
fun `a leading v on the tag makes no difference`() {
assertTrue(UpdateRules.isNewer("v0.11.5", "0.11.4"))
assertFalse(UpdateRules.isNewer("v0.11.4", "v0.11.4"))
}
@Test
fun `garbage in a tag does not offer an update`() {
assertFalse(UpdateRules.isNewer("", "0.11.5"))
assertFalse(UpdateRules.isNewer("nightly", "0.11.5"))
}
@Test
fun `a complete download passes`() {
assertTrue(UpdateRules.isComplete(actualBytes = 6_515_429, announcedBytes = 6_515_429))
}
/** The 0.11.5 bug: a truncated APK left the installer hanging. */
@Test
fun `a truncated download is rejected`() {
assertFalse(UpdateRules.isComplete(actualBytes = 3_000_000, announcedBytes = 6_515_429))
}
@Test
fun `an empty download is rejected even when nothing was announced`() {
assertFalse(UpdateRules.isComplete(actualBytes = 0, announcedBytes = -1))
assertFalse(UpdateRules.isComplete(actualBytes = 0, announcedBytes = 0))
}
@Test
fun `an unannounced length cannot be checked, so any content passes`() {
assertTrue(UpdateRules.isComplete(actualBytes = 6_515_429, announcedBytes = -1))
}
@Test
fun `more bytes than announced is rejected too`() {
assertFalse(UpdateRules.isComplete(actualBytes = 7_000_000, announcedBytes = 6_515_429))
}
}

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

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

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

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

@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Downloads club crests into the APK's assets at build time.
The badges are trademarks: they are fetched into a generated, git-ignored
folder so the repository stays free of them while the app ships with them
and needs no network at runtime.
"""
import json
import os
import re
import sys
import time
import urllib.parse
import urllib.request
OUT = sys.argv[1] if len(sys.argv) > 1 else "app/src/main/assets/crests"
URLS = "tools/crest-sources.json"
SRC = "app/src/main/java/dev/castarr/tv/data/TeamFilters.kt"
SUMMARY = "https://de.wikipedia.org/api/rest_v1/page/summary/"
UA = {"User-Agent": "Castarr build script (private use)"}
os.makedirs(OUT, exist_ok=True)
# Image URLs are plain text and safe to commit; the artwork itself is not.
sources = {}
if os.path.exists(URLS):
sources = json.load(open(URLS, encoding="utf-8"))
kotlin = open(SRC, encoding="utf-8").read()
entries = re.findall(r'club\((.*?)\)\s*,\s*(?://.*)?$', kotlin, re.M | re.S)
clubs = []
# Wikipedia serves ~330px thumbnails. The rail draws them at 20dp, which on a
# 1080p TV is 40px -- an 8x downscale that Android does with a cheap filter,
# and the edges come out ragged. Resampling once here with a proper filter
# fixes that and shrinks the APK.
CREST_PX = 128
def downscale(data: bytes) -> bytes:
try:
from PIL import Image
except ImportError:
return data
import io
image = Image.open(io.BytesIO(data)).convert("RGBA")
if max(image.size) <= CREST_PX:
return data
scale = CREST_PX / max(image.size)
size = (max(1, round(image.width * scale)), max(1, round(image.height * scale)))
out = io.BytesIO()
image.resize(size, Image.LANCZOS).save(out, format="PNG", optimize=True)
return out.getvalue()
for raw in re.findall(r'club\(\s*"([^"]+)",\s*"[^"]*",\s*"([^"]+)"[^\n]*', kotlin):
clubs.append(raw)
# an explicit article = "..." wins over the club name
overrides = dict(re.findall(r'club\(\s*"([^"]+)"[^\n]*article = "([^"]+)"', kotlin))
clubs = [(k, overrides.get(k, n)) for k, n in clubs]
fetched = skipped = failed = 0
for key, full_name in clubs:
target = os.path.join(OUT, f"{key}.png")
if os.path.exists(target) and os.path.getsize(target) > 0:
skipped += 1
continue
try:
thumb = sources.get(key)
if not thumb:
url = SUMMARY + urllib.parse.quote(full_name)
with urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=20) as r:
thumb = json.load(r).get("thumbnail", {}).get("source")
if not thumb:
raise ValueError("no thumbnail")
sources[key] = thumb.split("?")[0]
thumb = sources[key]
with urllib.request.urlopen(urllib.request.Request(thumb, headers=UA), timeout=20) as r:
data = r.read()
with open(target, "wb") as f:
f.write(downscale(data))
fetched += 1
except Exception as exc: # noqa: BLE001 - best effort, shield is the fallback
print(f" {key}: {exc}", file=sys.stderr)
failed += 1
# Wikipedia rate-limits bursts; this runs rarely and caches.
time.sleep(1.2)
with open(URLS, "w", encoding="utf-8") as f:
json.dump(dict(sorted(sources.items())), f, indent=2, ensure_ascii=False)
f.write("\n")
print(f"crests: {fetched} geladen, {skipped} vorhanden, {failed} fehlgeschlagen")

171
tools/release.sh Executable file
View File

@@ -0,0 +1,171 @@
#!/usr/bin/env bash
# Cuts a release: version bump, tests, signed build, tag, apk branch, Gitea
# release. Every step was done by hand before, fifteen times in one day.
#
# tools/release.sh 0.11.6 --notes notes.md
# tools/release.sh 0.11.6 --dry-run # show what would happen
# tools/release.sh 0.11.6 --no-smoke # skip the emulator run
#
# Release notes are never generated: the commit subjects since the last tag
# are only a starting point, written to a file for you to edit. Pass --notes
# to supply them directly.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
REMOTE="${CASTARR_REMOTE:-gitea}"
REPO="${CASTARR_REPO:-be-nj/castarr}"
API="${CASTARR_API:-https://git.beckm4nn.net/api/v1}"
SIGNING_ENV="${CASTARR_SIGNING_ENV:-$HOME/.keys/castarr-release.env}"
GRADLE="./gradlew --no-daemon -q"
VERSION=""
NOTES_FILE=""
DRY=0
SMOKE=1
while [ $# -gt 0 ]; do
case "$1" in
--notes) NOTES_FILE="$2"; shift 2 ;;
--dry-run) DRY=1; shift ;;
--no-smoke) SMOKE=0; shift ;;
-*) echo "unbekannte Option: $1" >&2; exit 2 ;;
*) VERSION="$1"; shift ;;
esac
done
[ -n "$VERSION" ] || { echo "usage: $0 <version> [--notes <file>] [--dry-run] [--no-smoke]" >&2; exit 2; }
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "Version muss X.Y.Z sein, nicht '$VERSION'" >&2; exit 2; }
say() { printf '\n== %s\n' "$1"; }
run() { if [ "$DRY" = 1 ]; then printf ' would run: %s\n' "$*"; else "$@"; fi; }
die() { printf 'ABBRUCH: %s\n' "$1" >&2; exit 1; }
# --- Vorbedingungen ---------------------------------------------------------
say "Prüfen"
[ -z "$(git status --porcelain)" ] || die "Arbeitsbaum nicht sauber"
[ "$(git rev-parse --abbrev-ref HEAD)" = "main" ] || die "nicht auf main"
git fetch -q "$REMOTE" main
[ "$(git rev-parse HEAD)" = "$(git rev-parse "$REMOTE/main")" ] ||
die "main weicht von $REMOTE/main ab — erst pushen oder pullen"
git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null &&
die "Tag v$VERSION existiert schon"
CURRENT="$(sed -nE 's/.*versionName = "(.*)".*/\1/p' app/build.gradle.kts)"
CODE="$(sed -nE 's/.*versionCode = ([0-9]+).*/\1/p' app/build.gradle.kts)"
[ -n "$CURRENT" ] && [ -n "$CODE" ] || die "Version aus app/build.gradle.kts nicht lesbar"
NEXT_CODE=$((CODE + 1))
echo " $CURRENT (code $CODE) -> $VERSION (code $NEXT_CODE)"
# shellcheck source=tests/helpers/jdk.sh
. tests/helpers/jdk.sh
echo " JDK: $(basename "${JAVA_HOME:-system}")"
# --- Notizen ----------------------------------------------------------------
LAST_TAG="$(git describe --tags --abbrev=0 2>/dev/null || true)"
if [ -z "$NOTES_FILE" ]; then
NOTES_FILE="$(mktemp -t castarr-notes-XXXX.md)"
{
echo "# Notizen für $VERSION — diese Zeilen sind ein Entwurf, keine Release-Notes."
echo "#"
[ -n "$LAST_TAG" ] && echo "# Commits seit $LAST_TAG:" || echo "# Commits:"
if [ -n "$LAST_TAG" ]; then
git log --format='# %s' "$LAST_TAG..HEAD"
else
git log --format='# %s' -10
fi
} > "$NOTES_FILE"
if [ -n "${EDITOR:-}" ] && [ -t 0 ]; then
"$EDITOR" "$NOTES_FILE"
else
say "Notizen"
echo " Entwurf liegt in $NOTES_FILE."
echo " Schreib die Notes dort hinein und ruf erneut auf:"
echo " $0 $VERSION --notes $NOTES_FILE"
exit 0
fi
fi
[ -f "$NOTES_FILE" ] || die "Notes-Datei fehlt: $NOTES_FILE"
NOTES="$(grep -v '^#' "$NOTES_FILE" | sed -e '/./,$!d')"
[ -n "${NOTES//[[:space:]]/}" ] || die "Notes sind leer"
# --- Tests ------------------------------------------------------------------
say "Unit-Tests"
run $GRADLE testDebugUnitTest
if [ "$SMOKE" = 1 ]; then
say "Smoke-Test auf dem Emulator"
run tests/smoke.sh
fi
# --- Version bumpen ---------------------------------------------------------
say "Version setzen"
if [ "$DRY" = 0 ]; then
sed -i -E "s/versionCode = $CODE/versionCode = $NEXT_CODE/; s/versionName = \"$CURRENT\"/versionName = \"$VERSION\"/" \
app/build.gradle.kts
grep -q "versionName = \"$VERSION\"" app/build.gradle.kts || die "Bump hat nicht gegriffen"
else
echo " would set versionCode=$NEXT_CODE versionName=$VERSION"
fi
# --- Signierter Build -------------------------------------------------------
say "Release-APK bauen"
if [ -f "$SIGNING_ENV" ]; then
# Signing secrets stay in the file; only this shell sees them.
set -a; . "$SIGNING_ENV"; set +a
echo " signiert (Konfiguration aus $SIGNING_ENV)"
else
echo " WARNUNG: $SIGNING_ENV fehlt — der Build wäre unsigniert"
[ "$DRY" = 1 ] || die "ohne Signatur kein Release (Updater lehnt Signaturwechsel ab)"
fi
run $GRADLE assembleRelease
APK="app/build/outputs/apk/release/app-release.apk"
[ "$DRY" = 1 ] || [ -f "$APK" ] || die "APK nicht gebaut: $APK"
# --- Commit, Tag, Push ------------------------------------------------------
say "Commit und Tag"
run git add app/build.gradle.kts
run git commit -q -m "Release $VERSION"
run git tag "v$VERSION"
run git push -q "$REMOTE" main "v$VERSION"
# --- APK-Branch -------------------------------------------------------------
say "APK auf den Branch apk"
if [ "$DRY" = 0 ]; then
git fetch -q "$REMOTE" apk
WORKTREE="$(mktemp -d -t castarr-apk-XXXX)"
trap 'git worktree remove --force "$WORKTREE" 2>/dev/null || true' EXIT
git worktree add -q -B apk "$WORKTREE" "$REMOTE/apk"
cp "$APK" "$WORKTREE/castarr.apk"
echo "$VERSION" > "$WORKTREE/VERSION"
git -C "$WORKTREE" add castarr.apk VERSION
git -C "$WORKTREE" commit -q -m "castarr $VERSION"
git -C "$WORKTREE" push -q "$REMOTE" apk
git worktree remove --force "$WORKTREE"
trap - EXIT
else
echo " would push $APK as castarr.apk"
fi
# --- Gitea-Release ----------------------------------------------------------
say "Gitea-Release"
TOKEN="${GITEA_TOKEN:-${CASTARR_GITEA_TOKEN:-}}"
if [ -z "$TOKEN" ]; then
echo " Kein GITEA_TOKEN gesetzt — Release bitte in der Weboberfläche anlegen:"
echo " ${API%/api/v1}/$REPO/releases/new?tag=v$VERSION"
echo " Notes liegen in $NOTES_FILE"
elif [ "$DRY" = 1 ]; then
echo " would create release v$VERSION"
else
BODY="$(NOTES="$NOTES" python3 -c 'import json,os; print(json.dumps({
"tag_name": "v" + os.environ["V"], "name": os.environ["V"],
"body": os.environ["NOTES"], "target_commitish": "main"}))' V="$VERSION")"
curl -sS -X POST "$API/repos/$REPO/releases" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "$BODY" > /dev/null || die "Release-API fehlgeschlagen"
echo " angelegt: ${API%/api/v1}/$REPO/releases/tag/v$VERSION"
fi
say "Fertig: $VERSION"
echo " Die App findet das Update über die Release-API, die APK über den Branch apk."