31 Commits
v0.9.0 ... main

Author SHA1 Message Date
be-nj
8fc7a3887e README: the login is not tied to a particular identity provider
All checks were successful
Build TV app / build (push) Successful in 43s
The server names its issuer and client id; everything after that is OIDC discovery and RFC 8628. Authentik is what this household runs, not a requirement — and the generic M3U source needs no login at all.
2026-08-26 20:16:54 +02:00
be-nj
80b4c0c6cd Show the login link as a QR so nobody races the code
All checks were successful
Build TV app / build (push) Successful in 47s
The identity provider's code lives a minute here, and the screen asked
people to type nine digits into a phone within it. That is the wrong
argument to have: the phone has a camera.

While the login is pending, the QR on the welcome screen points at the
provider's confirmation page with the code already in the URL, instead of at
this TV's own remote. Scanning ends at "confirm"; the digits stay underneath
for whoever prefers them, and the automatic renewal still covers a code that
runs out while somebody walks to the phone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 19:55:58 +02:00
be-nj
06b31f0b9d Record crashes on the device so "two weeks without one" can be checked
All checks were successful
Build TV app / build (push) Successful in 46s
The milestone asks for two weeks in the living room without a crash. Nobody
reads logcat on a TV, and by the time someone says "it was gone yesterday"
the log has rotated — the criterion had no evidence behind it.

CrashLog keeps the last ten crashes in the app's own files: timestamp,
version, and the top of the stack. Nothing is sent anywhere. The settings
show either "Kein Absturz aufgezeichnet" or the newest one, and clicking
the row clears the record — so the two weeks start when you say they do.

Verified by crashing the app on purpose: the entry appears as
"26.08.2026 18:10 · 0.11.5" with the exception below it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 19:50:01 +02:00
be-nj
660837c116 Observation sheet for the onboarding test (#21)
All checks were successful
Build TV app / build (push) Successful in 44s
A person who does not know the app sets up the TV while somebody watches and says nothing. The sheet says what to write down, so the result is a list of issues instead of an impression.
2026-08-26 19:36:43 +02:00
be-nj
d269618ebf Work through the 1.0 milestone: focus, backend tests, remote, login code
All checks were successful
Build TV app / build (push) Successful in 53s
#20 Each settings column is a focus group now. The middle column is shorter
than the left, so "down" past its last row used to search wider and land
back in the left column — "Erweitert" was only reachable by going right,
down, left.

#22 The whole backend path had no coverage, which is why an afternoon
against the real instance found three bugs. Parsing moved out of
DispatcharrRepository into DispatcharrJson and is covered by twelve tests
built from the shapes that instance actually returns: pagination, channels
without a uuid or hidden from output, effective vs plain names and logos,
favourites without the key, inactive output profiles, and all three EPG
envelopes the fork has shipped. Android's stub org.json throws in unit
tests, so the real implementation is on the test classpath.
What no test can reach is written down in tests/dispatcharr-checkliste.md.

#24 The remote's channel tab was only ever read in source. Driven through
the DevTools protocol against the real backend it lists 503 channels with
logos and EPG, the search filters, the club chip switches view, and a star
survives the round trip to the backend and back — set and unset. It also
showed the phone still spelling out "BL: Bayer Leverkusen - VfB Stuttgart,
tipico Topspiel der Woche, 16. Spieltag" where the TV shortens to the
fixture; the payload shortens now too.

#23 The TV says "Der alte Code war abgelaufen — neuer Code 566071643"
instead of swapping the digits without a word.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 19:33:29 +02:00
be-nj
de6d3a921f What 505 real channels showed that 20 demo ones could not
All checks were successful
Build TV app / build (push) Successful in 42s
The favourite count sat at zero while the backend already had two: the rail
read the favourites off the StateFlow inside the composable instead of
collecting it, so it never recomposed — a regression from making favourites
work for both source types. The list collects again.

Real EPG titles broke the fixture shortener in both directions. "BL: VfB
Stuttgart - Saison 25-26" became a fixture against a season, and
"… FC Bayern München - VfB Stuttgart - 1. Halbzeit" grew a third team; a
season, a matchday, a half or a year is now rejected, and a second dash ends
the away side. The kick-off chip also gave way in the row: the line below
already carries the time, and "Bayer Leverkusen - VfB Stuttgart" needed the
width to fit at all.

Merged provider EPGs list the same programme several times, minutes apart —
the day plan showed The Big Bang Theory three times and two entries marked
as running at once. XmltvParser.collapseDuplicates folds a repeat into the
entry whose slot it falls inside.

The login code expired before anyone could confirm it: Authentik hands out a
minute, and the TV dropped back to step one without a word. It fetches a
fresh code instead, up to eight times.

Tested against the household's own Dispatcharr through api.tv.beckm4nn.net.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 16:53:14 +02:00
be-nj
4cb7a6efdb Act on the screen-by-screen review: 17 findings
All checks were successful
Build TV app / build (push) Successful in 43s
Defects. "Kopplung zurücksetzen" now asks first, naming how many phones
would have to scan again — one stray OK used to wipe a working pairing, and
the settings put the focus right on it: entering the screen landed on the
gear, one press down jumped into the right column onto that very action, and
every further press down did nothing. Focus now starts on the first row of
the left column. 1. FC Köln was labelled "EFC". An unreachable source used
to look like an evening without programmes — cached list, empty EPG, club
menus counting down to zero — so both repositories now record when they last
came through, and the top bar says "Server nicht erreichbar · Stand 14:46".
The field labels on "Erweitert" sat on the outline with the border running
through them; they sit above their field now.

Polish. The app opens on the channel list — on the channel last watched,
which survives a restart — instead of the settings gear. Rows without EPG
fill their empty half with the channel's group. The club chip says "ab
15:06" like the row next to it, the club menu carries the club's name
instead of "FCSP", and a fixture title is shortened to the fixture, so
"Pokal: Nordstadt - FC St. P…" became "Nordstadt - FC St. Pauli".
The settings are three columns and no longer scroll (CONTEXT.md's ten-foot
rule).

Features. Favourites work for a plain M3U source too, stored on the device
and mirrored to the phone; they used to exist only with a Dispatcharr login.
The channel keys jump the list by initial letter, left and right jump a
division in the club picker, and right on a channel opens its day plan —
the EPG beyond now/next, which was parsed all along and never shown. When a
club match starts elsewhere the player offers to switch. The remote asked
for a code "unten auf dem TV-Bildschirm" where none was; the welcome screen
shows it now and the wording points at where it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 15:54:00 +02:00
be-nj
ac90898a46 Smoke test: truncate the demo server log per run
All checks were successful
Build TV app / build (push) Successful in 46s
2026-08-26 14:34:18 +02:00
be-nj
bb6af51788 README: document the test and release scripts
All checks were successful
Build TV app / build (push) Successful in 46s
2026-08-26 14:33:37 +02:00
be-nj
a7d8fcd089 Split the two screens that kept breaking, and let the smoke test reach them
Some checks failed
Build TV app / build (push) Has been cancelled
SettingsScreen and LiveScreen had grown to 760 and 689 lines and were where
most of the last few days' bugs lived. Each is now the screen itself plus
the pieces it composes — club chooser, pickers and rows next to settings,
channel row and cells next to the live list. No behaviour changes: the same
composables, moved, with the visibility widened from private to internal
where a caller now sits in another file.

formatClock existed twice, once per screen, and the move turned that into a
compile error rather than a quiet duplicate. It is one internal function now.

The smoke test seeds the demo playlist into the app's preferences before
launching, so it walks the channel list and the player instead of stopping
at the onboarding screen — the screens this commit moves are now actually
exercised. It also refuses to start when its port is already taken: a
leftover server from an earlier session answered every request with a 404
and the wait loop span forever.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 14:33:09 +02:00
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
50 changed files with 4582 additions and 724 deletions

View File

@@ -4,24 +4,71 @@ on:
push:
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:
build:
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:
- 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
with:
distribution: temurin
java-version: "17"
- name: JDK 17
run: |
if [ ! -x "$JAVA_HOME/bin/java" ]; then
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
run: ./gradlew assembleDebug --stacktrace
# One invocation, not two: a second --no-daemon run pays for another JVM
# 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
uses: actions/upload-artifact@v4
with:
name: castarr-debug-apk
path: app/build/outputs/apk/debug/app-debug.apk
- name: Summary
if: always()
run: |
echo "APK:"; ls -la app/build/outputs/apk/debug/ 2>/dev/null || echo " (kein Build)"
echo "Tests:"; ls tests/runs/junit/ 2>/dev/null || echo " (keine Reports)"
echo "Cache:"; du -sh "$CACHE"/* 2>/dev/null || echo " (leer)"

3
.gitignore vendored
View File

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

View File

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

View File

@@ -38,7 +38,7 @@ TV-Fernbedienung oder steuert alles bequem vom Handy aus.
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/benjamin/castarr/raw/branch/apk/castarr.apk`
`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:
@@ -58,6 +58,13 @@ 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.
Diese Quelle braucht überhaupt keine Anmeldung.
An einen bestimmten Identity Provider ist nichts gebunden: der Server nennt
der App seinen Issuer und die Client-ID, alles Weitere ist Standard (OIDC
Discovery, Device Authorization Grant nach RFC 8628). Authentik ist hier nur
das, was im Haushalt läuft — Keycloak, Zitadel oder Authelia tun es genauso,
solange sie den Device-Flow können.
Die Handy-Fernbedienung serviert der Fernseher selbst (eingebetteter
HTTP/WebSocket-Server, Kopplung per QR-Token mit ratenlimitiertem
@@ -79,4 +86,41 @@ sind signiert; über einem Debug-Build muss einmal deinstalliert werden
(Signaturwechsel). Updates bezieht die App über die Release-API dieses
Repos, die APK selbst liegt auf dem Branch `apk`.
### Tests
```
./gradlew test # Unit-Tests, Reports unter tests/runs/
tests/smoke.sh # Emulator: bauen, starten, durchklicken (~40 s)
tests/smoke.sh --stop # dasselbe, Emulator danach herunterfahren
```
`tests/smoke.sh` baut das Debug-APK, setzt die Demo-Playlist als Quelle,
läuft mit dem D-Pad von der Senderliste bis in den Player und schlägt fehl
bei Absturz, ANR oder leerer Senderliste. Screenshots und Log landen unter
`tests/runs/`. Nicht abgedeckt: echte Wiedergabe, Onboarding am Handy,
Kopplung.
Der Emulator dahinter lässt sich auch einzeln steuern:
```
tests/helpers/emulator.sh start | stop | status
tests/helpers/emulator.sh install <apk>
tests/helpers/emulator.sh shot <name>
```
Er startet aus einem Snapshot und ist in wenigen Sekunden oben; `stop`
schreibt den Snapshot vorher neu. Ein Kaltstart entsteht nur, wenn der
Snapshot fehlt (AVD `castarr-googletv`, API 34).
### Release
```
tools/release.sh 0.11.6 --notes notes.md
```
Bumpt die Version, testet, baut signiert, taggt, schiebt die APK auf den
Branch `apk` und legt das Gitea-Release an. Ohne `--notes` schreibt es einen
Entwurf aus den Commit-Betreffs, den du selbst ausformulierst — Release-Notes
erfindet das Skript nicht.
Architektur-Notizen: [CONTEXT.md](CONTEXT.md) und [docs/adr/](docs/adr/).

View File

@@ -12,8 +12,8 @@ android {
applicationId = "dev.castarr.tv"
minSdk = 26
targetSdk = 35
versionCode = 23
versionName = "0.9.0"
versionCode = 38
versionName = "0.11.5"
}
// Release signing from environment (see ~/.keys/castarr-release.env on the
@@ -97,6 +97,9 @@ dependencies {
implementation("com.google.zxing:core:3.5.3")
implementation("io.coil-kt:coil-compose:2.7.0")
testImplementation("junit:junit:4.13.2")
// Android ships a stub org.json for unit tests that throws on every call;
// the real one lets the backend parsing be tested without a device.
testImplementation("org.json:json:20240303")
}
// Full JDK for javac via toolchain (host may only have a JRE); resolved by

View File

@@ -168,7 +168,7 @@
<!-- Pairing -->
<section class="view" id="view-pair">
<h1>Mit dem TV koppeln</h1>
<p>Gib den 4-stelligen Code ein, der unten auf dem TV-Bildschirm steht.</p>
<p>Gib den 4-stelligen Code ein, der auf dem Fernseher unter dem QR-Code steht.</p>
<input id="code-input" inputmode="numeric" pattern="[0-9]*" maxlength="4" placeholder="····">
<button class="btn-accent" id="pair-btn">Verbinden</button>
<div id="pair-error"></div>
@@ -267,7 +267,7 @@
ws: null, connected: false, authorized: false,
status: { state: 'idle', channel: '', group: '', live: false, volume: 0.5 },
channels: [], playlistUrl: '', extras: { nowNext: [], favorites: [], favoritesSupported: false }, favOnly: false,
retryDelay: 1000, volumeDragging: false, searchTerm: '',
retryDelay: 1000, volumeDragging: false, searchTerm: '', teamKey: null,
};
// --- pairing credentials ---
@@ -449,17 +449,35 @@
}
list.innerHTML = '';
const favSet = new Set(state.extras.favorites || []);
// Same identity the TV uses: the backend id where there is one, the
// position in the full list where there is not (plain M3U playlists).
const favId = (c) => (c.backendId ? c.backendId : -(state.channels.indexOf(c) + 1));
const favOn = state.extras.favoritesSupported && state.favOnly;
const shown = favOn ? filtered.filter((c) => favSet.has(c.backendId)) : filtered;
if (state.extras.favoritesSupported) {
const teams = state.extras.teams || [];
const club = teams.find((t) => t.key === state.teamKey);
// Club view: the TV's club menu, mirrored onto the phone.
const clubHits = club ? new Map(club.entries.map((e) => [e.url, e])) : null;
const shown = club
? filtered.filter((c) => clubHits.has(c.url))
: favOn ? filtered.filter((c) => favSet.has(favId(c))) : filtered;
if (state.extras.favoritesSupported || teams.length) {
const bar = document.createElement('div');
bar.style.cssText = 'display:flex;gap:8px;padding:2px 12px 10px';
[['Alle', false], ['★ Favoriten', true]].forEach(([label, val]) => {
bar.style.cssText = 'display:flex;gap:8px;padding:2px 12px 10px;overflow-x:auto';
const views = [['Alle', null, false]];
if (state.extras.favoritesSupported) views.push(['★ Favoriten', null, true]);
teams.forEach((t) => views.push([t.label + ' ' + t.entries.length, t.key, false]));
views.forEach(([label, key, fav]) => {
const active = key ? state.teamKey === key : (!state.teamKey && state.favOnly === fav);
const chip = document.createElement('button');
chip.textContent = label;
chip.style.cssText = 'padding:7px 14px;border-radius:999px;font-size:12px;background:' +
(state.favOnly === val ? 'rgba(95,212,196,.12);color:#5fd4c4' : '#121418;color:#9aa0a8');
chip.addEventListener('click', () => { state.favOnly = val; renderChannels(); });
chip.style.cssText = 'flex:none;padding:7px 14px;border-radius:999px;font-size:12px;background:' +
(active ? 'rgba(95,212,196,.12);color:#5fd4c4' : '#121418;color:#9aa0a8');
chip.addEventListener('click', () => {
state.teamKey = key;
state.favOnly = fav;
listSignature = '';
renderChannels();
});
bar.appendChild(chip);
});
list.appendChild(bar);
@@ -467,8 +485,9 @@
if (!shown.length) {
const empty = document.createElement('div');
empty.className = 'empty';
empty.textContent = favOn
? 'Keine Favoriten — Stern auf einem Sender antippen.'
empty.textContent = club
? ('Heute läuft nichts mehr mit ' + club.name + '.')
: favOn ? 'Keine Favoriten — Stern auf einem Sender antippen.'
: 'Keine Sender gefunden.';
list.appendChild(empty);
return;
@@ -493,6 +512,18 @@
const name = document.createElement('span');
name.className = 'name'; name.textContent = c.name;
meta.appendChild(name);
const hit = clubHits ? clubHits.get(c.url) : null;
if (hit) {
const line = document.createElement('span');
line.className = 'grp';
const t = new Date(hit.start);
const hh = String(t.getHours()).padStart(2, '0') + ':' + String(t.getMinutes()).padStart(2, '0');
const running = Date.now() >= hit.start && Date.now() < hit.stop;
line.textContent = (running ? 'läuft · ' : 'ab ' + hh + ' · ') + hit.title +
(hit.further > 0 ? ' · +' + hit.further + ' weitere' : '');
line.style.color = running ? '#5fd4c4' : '#9aa0a8';
meta.appendChild(line);
} else {
const info = (state.extras.nowNext || [])[idx];
if (info && info.now) {
const now = document.createElement('span');
@@ -513,15 +544,16 @@
grp.className = 'grp'; grp.textContent = c.group;
meta.appendChild(grp);
}
}
btn.appendChild(num); btn.appendChild(meta);
if (state.extras.favoritesSupported && c.backendId) {
if (state.extras.favoritesSupported) {
const star = document.createElement('span');
const isFav = favSet.has(c.backendId);
const isFav = favSet.has(favId(c));
star.textContent = isFav ? '★' : '☆';
star.style.cssText = 'font-size:20px;padding:8px;color:' + (isFav ? '#5fd4c4' : '#6b717a');
star.addEventListener('click', (e) => {
e.stopPropagation();
send({ type: 'set_favorite', id: c.backendId });
send({ type: 'set_favorite', id: favId(c) });
});
btn.appendChild(star);
}
@@ -539,7 +571,8 @@
let listSignature = '';
function renderChannelsIfChanged() {
const sig = [
state.channels.length, state.searchTerm, state.favOnly,
state.channels.length, state.searchTerm, state.favOnly, state.teamKey,
JSON.stringify((state.extras.teams || []).map((t) => [t.key, t.entries.length])),
(state.extras.favorites || []).join(','),
state.status.channel || '',
].join('|');

View File

@@ -4,12 +4,14 @@ import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import dev.castarr.tv.auth.DeviceAuth
import dev.castarr.tv.data.DispatcharrRepository
import dev.castarr.tv.data.NowNext
import dev.castarr.tv.data.isEpgPlaceholder
import dev.castarr.tv.data.SourceRepository
import dev.castarr.tv.player.PlayerController
import dev.castarr.tv.playlist.Channel
@@ -26,6 +28,7 @@ class AppState(
val dispatcharr: DispatcharrRepository,
) {
private val prefs = context.getSharedPreferences("app", Context.MODE_PRIVATE)
val crests = dev.castarr.tv.data.Crests(context)
var screen by mutableStateOf(Screen.LIVE)
var playerVisible by mutableStateOf(false)
@@ -38,19 +41,129 @@ class AppState(
var isLive by mutableStateOf(false)
var favoritesOnly by mutableStateOf(false)
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())
private set
private var lastNowTitle: String? = null
/**
* Bumped by the remote's channel keys while the list is open: jumps to
* the next or previous initial letter. Right is taken by the day plan,
* and a text field is out of the question (ten-foot rule).
*/
var letterJump by mutableIntStateOf(0)
private set
var letterJumpDirection = 1
private set
fun requestLetterJump(direction: Int) {
letterJumpDirection = direction
letterJump++
}
/** Everything this channel still shows today, for the day plan. */
fun upcomingToday(channel: Channel): List<dev.castarr.tv.data.Programme> {
val now = System.currentTimeMillis()
val window = dev.castarr.tv.data.TeamFilters.windowEnd(now) - now
return dev.castarr.tv.data.XmltvParser.collapseDuplicates(upcoming(channel, window))
}
/** Digits typed on the remote's number pad (channel switching). */
var digitBuffer by mutableStateOf("")
/** False when the control server could not bind its port. */
var remoteAvailable by mutableStateOf(true)
/**
* Favourites for a generic M3U source. The Dispatcharr fork keeps them
* per user on the backend; a plain playlist has nowhere to put them, and
* "no way to mark a channel" is not an acceptable answer for the second
* source type the app officially supports.
*/
private var localFavorites by mutableStateOf(
prefs.getStringSet("favorites", emptySet())?.toSet().orEmpty()
)
/** Marked channels of whichever source is active. */
fun favoriteCount(): Int = when (sourceMode) {
SourceMode.GENERIC -> localFavorites.size
SourceMode.DISPATCHARR -> dispatcharr.favorites.value.size
}
fun isFavorite(channel: Channel): Boolean = when (sourceMode) {
SourceMode.GENERIC -> channel.url in localFavorites
SourceMode.DISPATCHARR -> channel.backendId in dispatcharr.favorites.value
}
fun toggleFavorite(channel: Channel) {
when (sourceMode) {
SourceMode.GENERIC -> {
localFavorites =
if (channel.url in localFavorites) localFavorites - channel.url
else localFavorites + channel.url
prefs.edit().putStringSet("favorites", localFavorites).apply()
}
SourceMode.DISPATCHARR -> dispatcharr.toggleFavorite(channel)
}
}
/** Channel to restore focus to when the list comes back (#13). */
var lastWatched by mutableStateOf<Channel?>(null)
/**
* The same channel across restarts, by URL. Without it the app opens
* with the focus on the settings gear, which is not what anyone reaches
* for when they switch the TV on.
*/
private var lastWatchedUrl: String = prefs.getString("last_watched", "").orEmpty()
/** URL the channel list should focus on, or empty for "no idea". */
fun restoreTargetUrl(): String = lastWatched?.url ?: lastWatchedUrl
/**
* The channel before the current one, for the jump back that every
* remote has. Only set once a second channel has been watched.
*/
var previousChannel by mutableStateOf<Channel?>(null)
private set
/**
* 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
@@ -69,6 +182,21 @@ class AppState(
var welcomePhase by mutableStateOf(WelcomePhase.WAIT_PHONE)
var welcomeUserCode by mutableStateOf("")
/**
* True once the login code has been replaced because the old one ran out.
* The identity provider hands out short-lived codes; swapping the digits
* on screen without a word leaves whoever is typing them wondering why
* the phone says no.
*/
var welcomeCodeRenewed by mutableStateOf(false)
/**
* The identity provider's confirmation link, complete with the code.
* Shown as a QR while the login is pending: scanning it beats typing
* nine digits against a code that lives a minute.
*/
var welcomeLoginUrl by mutableStateOf("")
/** Plain-language fullscreen error state (issue #13). */
var appError by mutableStateOf(AppError.NONE)
@@ -125,6 +253,115 @@ class AppState(
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 }
}
/**
* A club match that has just started somewhere else while the viewer is
* watching something. The app knew the kick-off all along and used to
* keep it to itself.
*/
data class ClubNudge(
val clubName: String,
val channel: Channel,
val programmeTitle: String,
)
var clubNudge by mutableStateOf<ClubNudge?>(null)
private set
/** Programme already offered, so a dismissed nudge stays dismissed. */
private var nudgedProgramme: String = ""
/**
* Offers the first club match that started within the last few minutes
* on a channel other than the current one. Called on a timer while
* something is playing.
*/
fun checkClubNudge() {
if (!playerVisible) { clubNudge = null; return }
val now = System.currentTimeMillis()
enabledTeams.forEach { key ->
val club = dev.castarr.tv.data.TeamFilters.byKey(key) ?: return@forEach
teamMatches(key).forEach { (channel, hit) ->
val p = hit.programme
val justStarted = now - p.start in 0 until NUDGE_GRACE_MS
val elsewhere = channel.url != currentChannel?.url
val stamp = "${p.start}:${channel.url}"
if (justStarted && elsewhere && stamp != nudgedProgramme) {
nudgedProgramme = stamp
clubNudge = ClubNudge(club.shortName, channel, p.title)
return
}
}
}
}
fun dismissClubNudge() {
clubNudge = null
}
/** Switches to the offered match. */
fun acceptClubNudge(): Boolean {
val nudge = clubNudge ?: return false
clubNudge = null
play(nudge.channel)
return true
}
fun refreshActive() {
when (sourceMode) {
SourceMode.GENERIC -> source.refresh()
@@ -137,8 +374,14 @@ class AppState(
result.fold(
onSuccess = { appError = AppError.NONE },
onFailure = { throwable ->
// Cached channels keep the app usable; only surface a
// fullscreen state when there is nothing to show.
// An expired login must always surface: cached channels
// would otherwise sit there without EPG or favourites and
// nothing would say why.
if (throwable.message == "not logged in") {
appError = AppError.RELOGIN
return
}
// Otherwise cached channels keep the app usable.
if (activeChannels().isNotEmpty()) return
appError = when {
throwable.message == "not logged in" -> AppError.RELOGIN
@@ -152,6 +395,12 @@ 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
/** How long after kick-off the nudge is still worth showing. */
const val NUDGE_GRACE_MS = 5 * 60 * 1000L
}
fun isOnline(): Boolean {
@@ -161,15 +410,22 @@ class AppState(
}
fun startOnboarding() {
prefs.edit().remove("teams").apply()
activeTeam = null
auth.logout()
appError = AppError.NONE
welcomePhase = WelcomePhase.WAIT_PHONE
welcomeUserCode = ""
welcomeCodeRenewed = false
welcomeLoginUrl = ""
screen = Screen.WELCOME
}
fun play(channel: Channel) {
currentChannel?.takeIf { it.url != channel.url }?.let { previousChannel = it }
lastWatched = channel
lastWatchedUrl = channel.url
prefs.edit().putString("last_watched", channel.url).apply()
currentChannel = channel
playerVisible = true
// Re-opening the very channel that was just closed can hit the
@@ -190,6 +446,8 @@ class AppState(
}
fun stopPlayback() {
currentChannel?.let { lastWatched = it }
restorePending = lastWatched != null
reentryHandler.removeCallbacksAndMessages(null)
lastStoppedUrl = currentChannel?.url.orEmpty()
lastStoppedAt = System.currentTimeMillis()
@@ -203,6 +461,19 @@ class AppState(
player.retryNow()
}
/**
* Back to the channel watched before this one — the button every remote
* has and this app did not. Falls back to the last channel of a finished
* session, so it also works right after the app starts.
*/
fun zapBack() {
val target = previousChannel
?: lastWatched?.takeIf { it.url != currentChannel?.url }
?: return
val known = activeChannels().firstOrNull { it.url == target.url } ?: target
play(known)
}
fun zap(direction: Int) {
val list = activeChannels()
if (list.isEmpty()) return

View File

@@ -0,0 +1,70 @@
package dev.castarr.tv
import android.content.Context
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Keeps a short record of crashes on the device.
*
* "Two weeks in the living room without a crash" is only a criterion if a
* crash leaves a trace. Nobody reads logcat on a TV, and by the time someone
* mentions "it was gone yesterday" the log has long rotated.
*
* Deliberately local: no reporting service, nothing leaves the device.
*/
object CrashLog {
private const val FILE = "crashes.log"
private const val SEPARATOR = "\n---\n"
/** How many crashes are kept; older ones fall off the front. */
const val KEEP = 10
/** Lines of stack trace per entry — enough to place it, not a dump. */
const val TRACE_LINES = 12
fun install(context: Context, version: String) {
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, error ->
runCatching { append(context, render(System.currentTimeMillis(), version, error)) }
previous?.uncaughtException(thread, error)
}
}
/** One entry: when, which version, and the top of the stack. */
fun render(at: Long, version: String, error: Throwable): String {
val stamp = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.GERMANY).format(Date(at))
val trace = error.stackTraceToString()
.lineSequence()
.take(TRACE_LINES)
.joinToString("\n")
return "$stamp · $version\n$trace"
}
/** Newest last, at most [KEEP] entries. */
fun trim(entries: List<String>, keep: Int = KEEP): List<String> =
entries.filter { it.isNotBlank() }.takeLast(keep)
fun entries(context: Context): List<String> = runCatching {
val file = File(context.filesDir, FILE)
if (!file.exists()) return emptyList()
file.readText().split(SEPARATOR).filter { it.isNotBlank() }
}.getOrDefault(emptyList())
/** First line of the newest entry, or null when nothing ever crashed. */
fun lastSummary(context: Context): String? =
entries(context).lastOrNull()?.lineSequence()?.firstOrNull()
fun clear(context: Context) {
runCatching { File(context.filesDir, FILE).delete() }
}
private fun append(context: Context, entry: String) {
val file = File(context.filesDir, FILE)
val kept = trim(entries(context) + entry)
file.writeText(kept.joinToString(SEPARATOR))
}
}

View File

@@ -37,6 +37,8 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Before anything else, so a crash during start-up is recorded too.
CrashLog.install(applicationContext, BuildConfig.VERSION_NAME)
audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
val auth = DeviceAuth(this)
state = AppState(
@@ -50,7 +52,14 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
server.startServer()
state.remoteAvailable = server.running
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() {
@@ -138,12 +147,52 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
.put("next", info.next?.title ?: "")
)
}
// The phone marks a channel by backend id where there is one, and by
// its position in the list where there is not (plain M3U).
val favorites = org.json.JSONArray()
state.dispatcharr.favorites.value.forEach { favorites.put(it) }
channels.forEachIndexed { index, channel ->
if (state.isFavorite(channel)) {
favorites.put(if (channel.backendId != 0) channel.backendId else -(index + 1))
}
}
// 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)
// Same shortening as on the TV: the phone showed
// "BL: Bayer Leverkusen - VfB Stuttgart, tipico
// Topspiel der Woche, 16. Spieltag" in full.
.put(
"title",
dev.castarr.tv.data.TeamFilters.fixture(hit.programme.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.shortName)
.put("name", club.fullName)
.put("entries", entries)
)
}
return JSONObject()
.put("nowNext", nowNext)
.put("favorites", favorites)
.put("favoritesSupported", state.sourceMode == AppState.SourceMode.DISPATCHARR)
.put("teams", teams)
.put("favoritesSupported", true)
}
override fun setupNeeded(): Boolean = state.screen == AppState.Screen.WELCOME
@@ -154,11 +203,22 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
lifecycleScope.launch {
try {
state.auth.fetchServerConfig(url.trim())
// Identity providers hand out short-lived codes — Authentik
// defaults to a minute, which is not enough time to walk to
// the phone. A fresh code is fetched automatically instead of
// dropping the viewer back to step one without a word.
repeat(LOGIN_CODE_ROUNDS) { round ->
val session = state.auth.startDeviceFlow()
state.welcomeUserCode = session.userCode
state.welcomeLoginUrl = session.verificationUriComplete
state.welcomeCodeRenewed = round > 0
state.welcomePhase = AppState.WelcomePhase.WAIT_LOGIN
server.broadcastLoginLink(session.verificationUriComplete, session.userCode)
val deadline = System.currentTimeMillis() + session.expiresInSeconds * 1000L
if (round > 0) {
server.broadcastToast("Neuer Anmeldecode: ${session.userCode}")
}
val deadline =
System.currentTimeMillis() + session.expiresInSeconds * 1000L
while (System.currentTimeMillis() < deadline) {
delay(session.intervalSeconds * 1000L)
when (state.auth.poll(session)) {
@@ -179,8 +239,12 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
dev.castarr.tv.auth.DeviceAuth.PollResult.Pending -> Unit
}
}
server.broadcastToast("Anmeldecode abgelaufen — bitte erneut versuchen")
}
server.broadcastToast("Anmeldung abgebrochen — bitte erneut versuchen")
state.welcomePhase = AppState.WelcomePhase.WAIT_PHONE
state.welcomeUserCode = ""
state.welcomeLoginUrl = ""
state.welcomeCodeRenewed = false
} catch (e: Exception) {
android.util.Log.w("Onboarding", "configure failed: ${e.javaClass.simpleName}")
server.broadcastToast("Server nicht erreichbar oder ohne Anmeldung")
@@ -190,8 +254,15 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
}
override fun onToggleFavorite(channelId: Int) {
val channel = state.activeChannels().firstOrNull { it.backendId == channelId } ?: return
state.dispatcharr.toggleFavorite(channel)
val channels = state.activeChannels()
val channel = if (channelId < 0) {
channels.getOrNull(-channelId - 1)
} else {
channels.firstOrNull { it.backendId == channelId }
} ?: return
state.toggleFavorite(channel)
// The backend round-trip needs a moment; a local favourite does not,
// but one delayed broadcast covers both.
mainHandler.postDelayed({ server.broadcastChannels() }, 800)
}
@@ -209,6 +280,20 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
onDigit(keyCode - KeyEvent.KEYCODE_0)
return true
}
// Channel keys jump the open list by initial letter.
if (!state.playerVisible && state.screen == AppState.Screen.LIVE) {
when (keyCode) {
KeyEvent.KEYCODE_CHANNEL_UP -> {
state.requestLetterJump(-1)
return true
}
KeyEvent.KEYCODE_CHANNEL_DOWN -> {
state.requestLetterJump(1)
return true
}
else -> Unit
}
}
if (state.playerVisible) {
when (keyCode) {
// OK opens the control overlay (buttons take over from
@@ -217,6 +302,9 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
// reaches the activity.
KeyEvent.KEYCODE_DPAD_CENTER,
KeyEvent.KEYCODE_ENTER -> {
// An offered club match takes the key before playback
// controls do — that is what the hint on it promises.
if (state.acceptClubNudge()) return true
state.pingOverlay()
return true
}
@@ -236,6 +324,13 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
state.stopPlayback()
return true
}
// The jump every remote has: back to what was on before.
KeyEvent.KEYCODE_LAST_CHANNEL,
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
state.pingOverlay()
state.zapBack()
return true
}
KeyEvent.KEYCODE_DPAD_UP,
KeyEvent.KEYCODE_CHANNEL_UP -> {
state.zap(-1)
@@ -262,6 +357,10 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
return true
}
KeyEvent.KEYCODE_BACK -> {
if (state.clubNudge != null) {
state.dismissClubNudge()
return true
}
if (state.overlayVisible) state.overlayVisible = false
else state.stopPlayback()
return true
@@ -325,5 +424,9 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
const val TICK_INTERVAL_MS = 2_000L
const val SEEK_STEP_SECONDS = 10L
const val DIGIT_COMMIT_MS = 1_800L
const val UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000L
/** How often a fresh login code is fetched before giving up. */
const val LOGIN_CODE_ROUNDS = 8
}
}

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

@@ -0,0 +1,119 @@
package dev.castarr.tv.data
import dev.castarr.tv.playlist.Channel
import org.json.JSONArray
import org.json.JSONObject
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
/**
* Everything the Dispatcharr backend sends, turned into the app's own types.
*
* Split out of [DispatcharrRepository] so the parsing can be tested without a
* server: the whole backend path had no coverage, and the first run against a
* real instance turned up three bugs in an afternoon.
*/
object DispatcharrJson {
/** `{"results": [...]}` from the paginated API, or a bare array. */
fun paginatedResults(body: String): JSONArray =
runCatching { JSONObject(body).optJSONArray("results") }.getOrNull() ?: JSONArray(body)
/** Next page URL of a paginated response, or null on the last one. */
fun nextPage(body: String): String? =
runCatching { JSONObject(body).optString("next") }.getOrNull()
?.takeIf { it.isNotEmpty() && it != "null" }
fun parseGroups(body: String): Map<Int, String> {
val results = paginatedResults(body)
return (0 until results.length()).associate {
val obj = results.getJSONObject(it)
obj.getInt("id") to obj.optString("name")
}
}
/** `{"channels": [1, 2]}` — the ids the signed-in user starred. */
fun parseFavorites(body: String): Set<Int> {
val ids = runCatching { JSONObject(body).optJSONArray("channels") }.getOrNull() ?: JSONArray()
return (0 until ids.length()).map { ids.getInt(it) }.toSet()
}
/** Output profile names; inactive ones are not offered. */
fun parseProfiles(body: String): List<String> {
val results = paginatedResults(body)
return (0 until results.length()).mapNotNull {
val obj = results.getJSONObject(it)
if (obj.optBoolean("is_active", true)) obj.optString("name") else null
}
}
/**
* One page of channels.
*
* A channel without a uuid cannot be played (the proxy resolves by uuid,
* integer ids give a 404), and one hidden from output is not meant to
* show up at all — both are dropped.
*/
fun parseChannels(
body: String,
groups: Map<Int, String>,
streamUrl: (uuid: String) -> String,
logoUrl: (id: Int) -> String,
): List<Channel> {
val results = paginatedResults(body)
val list = mutableListOf<Channel>()
for (i in 0 until results.length()) {
val obj = results.getJSONObject(i)
val uuid = obj.optString("uuid")
if (uuid.isEmpty() || obj.optBoolean("hidden_from_output")) continue
list.add(
Channel(
name = obj.optString("effective_name").ifEmpty { obj.optString("name") },
url = streamUrl(uuid),
group = groups[obj.optInt("channel_group_id")].orEmpty(),
logo = (obj.optInt("effective_logo_id").takeIf { it > 0 }
?: obj.optInt("logo_id").takeIf { it > 0 })
?.let(logoUrl)
.orEmpty(),
tvgId = obj.optString("effective_tvg_id").ifEmpty { obj.optString("tvg_id") },
backendId = obj.getInt("id"),
streamKey = uuid,
)
)
}
return list
}
/**
* The EPG grid, keyed by tvg-id. The envelope is `{"data": [...]}`, but
* `results` and a bare array are tolerated — the fork has shipped all
* three at some point.
*/
fun parseEpgGrid(body: String): Map<String, List<Programme>> {
val root = runCatching { JSONObject(body) }.getOrNull()
val results = root?.optJSONArray("data")
?: root?.optJSONArray("results")
?: runCatching { JSONArray(body) }.getOrElse { error("unexpected EPG envelope") }
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
val map = HashMap<String, MutableList<Programme>>()
for (i in 0 until results.length()) {
val obj = results.getJSONObject(i)
val tvgId = obj.optString("tvg_id")
if (tvgId.isEmpty()) continue
val start = parseIso(obj.optString("start_time"), format)
val stop = parseIso(obj.optString("end_time"), format)
if (start == 0L || stop <= start) continue
map.getOrPut(tvgId) { mutableListOf() }
.add(Programme(start, stop, obj.optString("title")))
}
map.values.forEach { it.sortBy(Programme::start) }
return map
}
private fun parseIso(raw: String, format: SimpleDateFormat): Long = runCatching {
format.parse(raw.substringBefore(".").substringBefore("+").removeSuffix("Z"))?.time ?: 0L
}.getOrDefault(0L)
}

View File

@@ -11,12 +11,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
/**
* Channel source backed by the Dispatcharr fork's Bearer API: channels,
@@ -34,6 +30,12 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) {
val epgUpdatedAt = MutableStateFlow(0L)
val status = MutableStateFlow("")
/** When the channel list last came through, persisted across restarts. */
val updatedAt = MutableStateFlow(prefs.getLong("channels_at", 0L))
/** True while the most recent attempt to reach the backend failed. */
val unreachable = MutableStateFlow(false)
private var programmesByTvgId: Map<String, List<Programme>> = emptyMap()
var outputProfile: String = ""
@@ -49,13 +51,20 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) {
scope.launch {
val result = runCatching {
status.value = "loading_channels"
Log.i(TAG, "refresh start (loggedIn=${auth.isLoggedIn})")
val token = auth.accessToken() ?: error("not logged in")
val groups = fetchGroups(token)
val list = fetchChannels(token, groups)
channels.value = list
prefs.edit().putString("channels_cache", Channel.listToJson(list).toString()).apply()
launch { runCatching { refreshFavorites(token) } }
launch { runCatching { refreshProfiles(token) } }
launch {
runCatching { refreshFavorites(token) }
.onFailure { Log.w(TAG, "favorites failed: ${it.javaClass.simpleName}: ${it.message?.take(120)}") }
}
launch {
runCatching { refreshProfiles(token) }
.onFailure { Log.w(TAG, "profiles failed: ${it.javaClass.simpleName}: ${it.message?.take(120)}") }
}
launch {
runCatching { refreshEpg(token) }
.onFailure { Log.w(TAG, "epg failed: ${it.javaClass.simpleName}: ${it.message?.take(160)}") }
@@ -63,6 +72,11 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) {
list.size
}
result.onFailure { Log.w(TAG, "refresh failed: ${it.javaClass.simpleName}: ${it.message?.take(160)}") }
result.onSuccess {
updatedAt.value = System.currentTimeMillis()
prefs.edit().putLong("channels_at", updatedAt.value).apply()
}
unreachable.value = result.isFailure
status.value = if (result.isSuccess) "" else "channels_error"
onDone(result)
}
@@ -71,6 +85,12 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) {
fun nowNext(channel: Channel): NowNext =
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) {
if (channel.backendId == 0) return
scope.launch {
@@ -100,45 +120,23 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) {
channels.value = channels.value.map { it.copy(url = streamUrl(it.streamKey)) }
}
private suspend fun fetchGroups(token: String): Map<Int, String> {
val body = request("${auth.serverUrl}/api/channels/groups/?page_size=1000", "GET", token)
val results = paginatedResults(body)
return (0 until results.length()).associate {
val obj = results.getJSONObject(it)
obj.getInt("id") to obj.optString("name")
}
}
private suspend fun fetchGroups(token: String): Map<Int, String> =
DispatcharrJson.parseGroups(
request("${auth.serverUrl}/api/channels/groups/?page_size=1000", "GET", token)
)
private suspend fun fetchChannels(token: String, groups: Map<Int, String>): List<Channel> {
val list = mutableListOf<Channel>()
var url: String? = "${auth.serverUrl}/api/channels/channels/?page_size=500"
while (url != null && list.size < 10_000) {
val body = request(url, "GET", token)
val json = runCatching { JSONObject(body) }.getOrNull()
val results = json?.optJSONArray("results") ?: JSONArray(body)
for (i in 0 until results.length()) {
val obj = results.getJSONObject(i)
val id = obj.getInt("id")
val uuid = obj.optString("uuid")
if (uuid.isEmpty() || obj.optBoolean("hidden_from_output")) continue
list.add(
Channel(
name = obj.optString("effective_name").ifEmpty { obj.optString("name") },
url = streamUrl(uuid),
group = groups[obj.optInt("channel_group_id")].orEmpty(),
// The list serializer only carries logo ids; the
// cache endpoint serves the image without auth.
logo = (obj.optInt("effective_logo_id").takeIf { it > 0 }
?: obj.optInt("logo_id").takeIf { it > 0 })
?.let { "${auth.serverUrl.trimEnd('/')}/api/channels/logos/$it/cache/" }
.orEmpty(),
tvgId = obj.optString("effective_tvg_id").ifEmpty { obj.optString("tvg_id") },
backendId = id,
streamKey = uuid,
list += DispatcharrJson.parseChannels(
body = body,
groups = groups,
streamUrl = ::streamUrl,
logoUrl = { "${auth.serverUrl.trimEnd('/')}/api/channels/logos/$it/cache/" },
)
)
}
url = json?.optString("next")?.takeIf { it.isNotEmpty() && it != "null" }
url = DispatcharrJson.nextPage(body)
}
list.sortBy { it.name.lowercase() }
return list
@@ -146,54 +144,22 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) {
private suspend fun refreshFavorites(token: String) {
val body = request("${auth.serverUrl}/api/channels/favorites/", "GET", token)
val ids = JSONObject(body).optJSONArray("channels") ?: JSONArray()
favorites.value = (0 until ids.length()).map { ids.getInt(it) }.toSet()
favorites.value = DispatcharrJson.parseFavorites(body)
}
private suspend fun refreshProfiles(token: String) {
val body = request("${auth.serverUrl}/api/core/outputprofiles/", "GET", token)
val results = paginatedResults(body)
profiles.value = (0 until results.length()).mapNotNull {
val obj = results.getJSONObject(it)
if (obj.optBoolean("is_active", true)) obj.optString("name") else null
}
profiles.value = DispatcharrJson.parseProfiles(body)
}
private suspend fun refreshEpg(token: String) {
status.value = "loading_epg"
val body = request("${auth.serverUrl}/api/epg/grid/", "GET", token)
// Envelope is {"data": [...]}; tolerate bare arrays and "results" too.
val root = runCatching { JSONObject(body) }.getOrNull()
val results = root?.optJSONArray("data")
?: root?.optJSONArray("results")
?: runCatching { JSONArray(body) }.getOrElse { error("unexpected EPG envelope") }
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
val map = HashMap<String, MutableList<Programme>>()
for (i in 0 until results.length()) {
val obj = results.getJSONObject(i)
val tvgId = obj.optString("tvg_id")
if (tvgId.isEmpty()) continue
val start = parseIso(obj.optString("start_time"), format)
val stop = parseIso(obj.optString("end_time"), format)
if (start == 0L || stop <= start) continue
map.getOrPut(tvgId) { mutableListOf() }
.add(Programme(start, stop, obj.optString("title")))
}
map.values.forEach { it.sortBy(Programme::start) }
programmesByTvgId = map
programmesByTvgId = DispatcharrJson.parseEpgGrid(body)
epgUpdatedAt.value = System.currentTimeMillis()
status.value = ""
}
private fun parseIso(raw: String, format: SimpleDateFormat): Long = runCatching {
format.parse(raw.substringBefore(".").substringBefore("+").removeSuffix("Z"))?.time ?: 0L
}.getOrDefault(0L)
private fun paginatedResults(body: String): JSONArray =
runCatching { JSONObject(body).optJSONArray("results") }.getOrNull() ?: JSONArray(body)
private suspend fun request(url: String, method: String, token: String): String =
withContext(Dispatchers.IO) {
val connection = URL(url).openConnection() as HttpURLConnection

View File

@@ -28,6 +28,12 @@ class SourceRepository(context: Context) {
val epgUpdatedAt = MutableStateFlow(0L)
val status = MutableStateFlow("")
/** When the channel list last came through, persisted across restarts. */
val updatedAt = MutableStateFlow(prefs.getLong("channels_at", 0L))
/** True while the most recent attempt to reach the source failed. */
val unreachable = MutableStateFlow(false)
private var programmes: Map<String, List<Programme>> = emptyMap()
private var nameToEpgId: Map<String, String> = emptyMap()
@@ -62,6 +68,11 @@ class SourceRepository(context: Context) {
parsed.size
}
result.onFailure { Log.w(TAG, "channel refresh failed", it) }
result.onSuccess {
updatedAt.value = System.currentTimeMillis()
prefs.edit().putLong("channels_at", updatedAt.value).apply()
}
unreachable.value = result.isFailure
status.value = if (result.isSuccess) "" else "channels_error"
onDone(result)
if (result.isSuccess && epg.isNotEmpty()) refreshEpg(epg)
@@ -82,13 +93,20 @@ class SourceRepository(context: Context) {
status.value = ""
}
/** Now/Next for an M3U channel: match tvg-id first, then name. */
fun nowNext(channel: Channel): NowNext {
val direct = channel.tvgId.takeIf { it.isNotEmpty() }?.let { programmes[it] }
val byName = direct ?: nameToEpgId[channel.name.lowercase()]?.let { programmes[it] }
return XmltvParser.nowNext(byName)
/** Programmes of this channel between now and now + [windowMs]. */
fun upcoming(channel: Channel, windowMs: Long): List<Programme> {
val now = System.currentTimeMillis()
return XmltvParser.programmesIn(programmesFor(channel), now, now + windowMs)
}
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() {
runCatching {
val cached = prefs.getString("channels_cache", null) ?: return

View File

@@ -0,0 +1,222 @@
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,
/** Two-to-four letter code, shown next to the full name in the picker. */
val label: String,
/**
* Name for the club menu in the channel list. The code alone ("FCSP")
* is unreadable for anyone who did not set the club up themselves.
*/
val shortName: 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,
shortName: String,
fullName: String,
needles: List<String>,
primary: Long,
secondary: Long,
article: String = fullName,
league: Int = 1,
) = TeamFilter(key, label, shortName, fullName, needles, Color(primary), Color(secondary), article, league)
/** Clubs of the top three German divisions. */
val all: List<TeamFilter> = listOf(
// --- Bundesliga ---
club("bayern", "FCB", "Bayern", "FC Bayern München", listOf("bayern münchen", "fc bayern"), 0xFFDC052D, WHITE, league = 1),
club("bvb", "BVB", "Dortmund", "Borussia Dortmund", listOf("borussia dortmund", "bvb"), 0xFFFDE100, BLACK, league = 1),
club("leipzig", "RBL", "Leipzig", "RB Leipzig", listOf("rb leipzig"), 0xFFDD0741, WHITE, league = 1),
club("leverkusen", "B04", "Leverkusen", "Bayer 04 Leverkusen", listOf("leverkusen"), 0xFFE32219, BLACK, league = 1),
club("frankfurt", "SGE", "Frankfurt", "Eintracht Frankfurt", listOf("eintracht frankfurt"), 0xFF1A1A1A, 0xFFE1000F, league = 1),
club("stuttgart", "1893", "Stuttgart", "VfB Stuttgart", listOf("vfb stuttgart"), 0xFFE32219, WHITE, league = 1),
club("gladbach", "BMG", "Gladbach", "Borussia Mönchengladbach", listOf("mönchengladbach", "gladbach"), 0xFF1A1A1A, 0xFF00A94F, league = 1),
club("wolfsburg", "WOB", "Wolfsburg", "VfL Wolfsburg", listOf("wolfsburg"), 0xFF65B32E, WHITE, league = 1),
club("bremen", "SVW", "Werder", "SV Werder Bremen", listOf("werder bremen", "werder"), 0xFF1D9053, WHITE, league = 1),
club("freiburg", "SCF", "Freiburg", "SC Freiburg", listOf("sc freiburg", "freiburg"), 0xFFE2001A, WHITE, league = 1),
club("hoffenheim", "TSG", "Hoffenheim", "TSG Hoffenheim", listOf("hoffenheim"), 0xFF1C63B7, WHITE, league = 1),
club("mainz", "M05", "Mainz", "1. FSV Mainz 05", listOf("mainz 05", "mainz"), 0xFFE1000F, WHITE, league = 1),
club("augsburg", "FCA", "Augsburg", "FC Augsburg", listOf("augsburg"), 0xFF00693F, 0xFFE1000F, league = 1),
club("union", "FCU", "Union Berlin", "1. FC Union Berlin", listOf("union berlin"), 0xFFE1000F, 0xFFFDE100, league = 1),
club("koeln", "KOE", "Köln", "1. FC Köln", listOf("1. fc köln", "fc köln"), 0xFFE1000F, WHITE, league = 1),
club("hsv", "HSV", "HSV", "Hamburger SV", listOf("hamburger sv", "hsv"), 0xFF0E5EA6, BLACK, league = 1),
club("heidenheim", "HDH", "Heidenheim", "1. FC Heidenheim", listOf("heidenheim"), 0xFFE1000F, 0xFF1656A4, league = 1),
club("st-pauli", "FCSP", "St. Pauli", "FC St. Pauli", listOf("st. pauli", "st pauli"), 0xFF6B4423, WHITE, league = 1),
// --- 2. Bundesliga ---
club("schalke", "S04", "Schalke", "FC Schalke 04", listOf("schalke"), 0xFF004D9D, WHITE, league = 2),
club("hertha", "BSC", "Hertha", "Hertha BSC", listOf("hertha"), 0xFF004D9D, WHITE, league = 2),
club("duesseldorf", "F95", "Düsseldorf", "Fortuna Düsseldorf", listOf("fortuna düsseldorf"), 0xFFE1000F, WHITE, league = 2),
club("nuernberg", "FCN", "Nürnberg", "1. FC Nürnberg", listOf("nürnberg"), 0xFF8B1A1A, WHITE, league = 2),
club("kaiserslautern", "FCK", "Kaiserslautern", "1. FC Kaiserslautern", listOf("kaiserslautern"), 0xFFE1000F, WHITE, league = 2),
club("karlsruhe", "KSC", "Karlsruhe", "Karlsruher SC", listOf("karlsruher sc", "ksc"), 0xFF0055A5, WHITE, league = 2),
club("hannover", "H96", "Hannover 96", "Hannover 96", listOf("hannover 96"), 0xFF00A94F, WHITE, league = 2),
club("paderborn", "SCP", "Paderborn", "SC Paderborn 07", listOf("paderborn"), 0xFF004D9D, WHITE, league = 2),
club("magdeburg", "FCM", "Magdeburg", "1. FC Magdeburg", listOf("magdeburg"), 0xFF0B7A3E, WHITE, league = 2),
club("elversberg", "SVE", "Elversberg", "SV Elversberg", listOf("elversberg"), 0xFF1A1A1A, 0xFFE1000F, league = 2),
club("darmstadt", "SV98", "Darmstadt", "SV Darmstadt 98", listOf("darmstadt"), 0xFF004E9E, WHITE, league = 2),
club("braunschweig", "BTSV", "Braunschweig", "Eintracht Braunschweig", listOf("braunschweig"), 0xFFFDE100, 0xFF004E9E, league = 2),
club("bochum", "BOC", "Bochum", "VfL Bochum", listOf("bochum"), 0xFF005CA9, WHITE, league = 2),
club("muenster", "SCPM", "Münster", "Preußen Münster", listOf("preußen münster", "münster"), 0xFF00703C, WHITE, league = 2),
club("fuerth", "SGF", "Fürth", "SpVgg Greuther Fürth", listOf("greuther fürth", "fürth"), 0xFF00A94F, WHITE, league = 2),
club("holstein", "KSV", "Kiel", "Holstein Kiel", listOf("holstein kiel"), 0xFF004E9E, 0xFFE1000F, league = 2),
club("dresden", "SGD", "Dresden", "Dynamo Dresden", listOf("dynamo dresden"), 0xFFFDE100, BLACK, league = 2),
club("bielefeld", "DSC", "Bielefeld", "Arminia Bielefeld", listOf("arminia bielefeld", "bielefeld"), 0xFF00539F, WHITE, league = 2),
// --- 3. Liga ---
club("hansa", "FCH", "Rostock", "FC Hansa Rostock", listOf("hansa"), 0xFF0B4EA2, WHITE, league = 3),
club("saarbruecken", "FCS", "Saarbrücken", "1. FC Saarbrücken", listOf("saarbrücken"), 0xFF0B4EA2, BLACK, league = 3),
club("aue", "FCE", "Aue", "Erzgebirge Aue", listOf("erzgebirge aue"), 0xFF7C0A02, WHITE, league = 3),
club("cottbus", "FCEC", "Cottbus", "Energie Cottbus", listOf("energie cottbus", "cottbus"), 0xFFE1000F, WHITE, league = 3),
club("essen", "RWE", "Essen", "Rot-Weiss Essen", listOf("rot-weiss essen", "rot weiss essen"), 0xFFE1000F, WHITE, league = 3),
club("duisburg", "MSV", "Duisburg", "MSV Duisburg", listOf("msv duisburg", "duisburg"), 0xFF004E9E, WHITE, league = 3),
club("mannheim", "SVWM", "Mannheim", "SV Waldhof Mannheim", listOf("waldhof mannheim", "waldhof"), 0xFF0B4EA2, WHITE, league = 3),
club("wehen", "SVWW", "Wehen Wiesbaden", "SV Wehen Wiesbaden", listOf("wehen wiesbaden", "wehen"), 0xFFE1000F, BLACK, league = 3),
club("ulm", "SSV", "Ulm", "SSV Ulm 1846", listOf("ssv ulm"), 0xFFE1000F, WHITE, article = "SSV Ulm 1846 Fußball", league = 3),
club("regensburg", "SSVJ", "Regensburg", "SSV Jahn Regensburg", listOf("jahn regensburg", "regensburg"), 0xFFE1000F, WHITE, league = 3),
club("verl", "SCV", "Verl", "SC Verl", listOf("sc verl"), 0xFF00703C, WHITE, league = 3),
club("viktoria", "VKÖ", "Viktoria Köln", "Viktoria Köln", listOf("viktoria köln"), 0xFFE1000F, WHITE, league = 3),
club("havelse", "TSVH", "Havelse", "TSV Havelse", listOf("havelse"), 0xFF004E9E, WHITE, league = 3),
club("schweinfurt", "FC05", "Schweinfurt", "1. FC Schweinfurt 05", listOf("schweinfurt"), 0xFF00A94F, WHITE, league = 3),
club("osnabrueck", "VfLO", "Osnabrück", "VfL Osnabrück", listOf("osnabrück"), 0xFF6A2C8F, WHITE, league = 3),
club("aachen", "ALE", "Aachen", "Alemannia Aachen", listOf("alemannia aachen", "aachen"), 0xFFFDE100, BLACK, league = 3),
club("ingolstadt", "FCI", "Ingolstadt", "FC Ingolstadt 04", listOf("ingolstadt"), 0xFFE1000F, BLACK, league = 3),
club("wuppertal", "WSV", "Wuppertal", "Wuppertaler SV", listOf("wuppertaler sv"), 0xFFE1000F, 0xFF0B4EA2, league = 3),
club("stuttgart-ii", "VfB2", "Stuttgart II", "VfB Stuttgart II", listOf("vfb stuttgart ii"), 0xFFE32219, BLACK, league = 3),
club("hoffenheim-ii", "TSG2", "Hoffenheim II", "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) }
}
/**
* The fixture inside an EPG title: "Pokal: Nordstadt - FC St. Pauli,
* 1. Runde" becomes "Nordstadt - FC St. Pauli".
*
* In the club menu the row exists *because* of that pairing, and the
* club is usually the second half — exactly the half a one-line title
* cuts off. Returns null when there is no pairing to find, and the
* caller keeps the original title.
*/
fun fixture(title: String): String? {
// Drop a leading competition ("Pokal:", "Bundesliga:").
val body = title.substringAfter(":", title).trim()
val separator = SEPARATORS.firstOrNull { body.contains(it) } ?: return null
// Trailing detail after the pairing ("…, 1. Runde", "… | live").
val pairing = body.substringBefore(",").substringBefore(" | ").trim()
if (!pairing.contains(separator)) return null
val (rawHome, rawAway) = pairing.split(separator, limit = 2)
val home = rawHome.trim()
// A second dash usually starts a detail, not a third team:
// "… - VfB Stuttgart - 1. Halbzeit".
val away = rawAway.substringBefore(" - ").trim()
if (home.length < 3 || away.length < 3) return null
// A season, a matchday or a half is not an opponent — real EPG data
// is full of "VfB Stuttgart - Saison 25-26".
if (isNotATeam(home) || isNotATeam(away)) return null
return "$home - $away"
}
private fun isNotATeam(side: String): Boolean {
val s = side.lowercase()
if (NON_TEAM_WORDS.any { s.contains(it) }) return true
return YEAR_LIKE.containsMatchIn(s)
}
/** Dash variants German EPG data uses between the two teams. */
private val SEPARATORS = listOf(" - ", " ", "", " vs. ", " vs ", " gegen ")
private val NON_TEAM_WORDS = listOf(
"saison", "halbzeit", "spieltag", "runde", "highlights", "höhepunkte",
"konferenz", "vereinsprofil", "rückblick", "zusammenfassung", "magazin",
)
/** "25-26", "2025/26", "1899" — never an opponent on its own. */
private val YEAR_LIKE = Regex("""\b\d{2}\s*[-/]\s*\d{2}\b|\b(19|20)\d{2}\b""")
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,39 @@ object XmltvParser {
}
}
/**
* Collapses the same programme listed several times.
*
* Merged provider EPGs carry a title more than once with starts a few
* minutes apart — the day plan showed "The Big Bang Theory" three times
* in half an hour and marked two entries as running at once. The first
* entry wins and swallows the repeats that start inside its slot.
*/
fun collapseDuplicates(
programmes: List<Programme>,
toleranceMs: Long = DUPLICATE_TOLERANCE_MS,
): List<Programme> {
val kept = mutableListOf<Programme>()
programmes.sortedBy { it.start }.forEach { candidate ->
val previous = kept.lastOrNull { it.title.equals(candidate.title, ignoreCase = true) }
val repeats = previous != null &&
(candidate.start - previous.start <= toleranceMs || candidate.start < previous.stop)
if (!repeats) kept.add(candidate)
}
return kept
}
/** Two starts this close with the same title are the same broadcast. */
const val DUPLICATE_TOLERANCE_MS = 30 * 60 * 1000L
/** 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 {
if (programmes.isNullOrEmpty()) return NowNext(null, null)
val index = programmes.indexOfFirst { at < it.stop }

View File

@@ -71,6 +71,12 @@ object Pairing {
prefs(context).edit().clear().apply()
}
/**
* How many phones would have to scan again after a reset — the number
* the confirmation asks about, so nobody wipes a working pairing blind.
*/
fun pairedCount(context: Context): Int = sessionTokens(context).size
private fun sessionTokens(context: Context): List<String> =
prefs(context).getStringSet(KEY_SESSIONS, emptySet())?.toList().orEmpty()

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

@@ -59,30 +59,19 @@ class ControlServer(
var running = false
private set
// Failed authentication attempts per remote address. Counting every
// failure (not just the ones carrying a code) keeps a hostile client
// from spending someone else's budget.
private val attempts = HashMap<String, ArrayDeque<Long>>()
@Synchronized
private fun attemptAllowed(address: String): Boolean {
val now = System.currentTimeMillis()
val queue = attempts.getOrPut(address) { ArrayDeque() }
while (queue.isNotEmpty() && now - queue.first() > ATTEMPT_WINDOW_MS) {
queue.removeFirst()
}
if (attempts.size > MAX_TRACKED_ADDRESSES) {
attempts.entries.removeAll { it.value.isEmpty() }
}
if (queue.size >= ATTEMPT_MAX) return false
queue.addLast(now)
return true
}
// Failed authentication attempts per remote address. The rule itself
// lives in AttemptBudget, where it is unit-tested.
private val attempts = AttemptBudget()
fun startServer() {
// A busy port must not take the whole app down — the remote is
// optional, everything else keeps working.
running = runCatching { start(NanoHTTPD.SOCKET_READ_TIMEOUT, true) }
// NanoHTTPD's 5 s default also applies to the long-lived WebSocket:
// with pings only every 8 s the socket timed out mid-session and the
// remote was thrown out after a few seconds. The timeout still has to
// exist (idle connections must not pin threads), it just has to be
// comfortably longer than the ping interval.
running = runCatching { start(SOCKET_TIMEOUT_MS, true) }
.onFailure { Log.w(TAG, "control server unavailable: ${it.javaClass.simpleName}") }
.isSuccess
if (!running) return
@@ -292,9 +281,7 @@ class ControlServer(
}
private fun handleHello(msg: JSONObject) {
// Every failed attempt counts against this address, whether it
// carried a token or a code.
if (!attemptAllowed(remoteAddress)) {
if (!attempts.allows(remoteAddress)) {
trySend(JSONObject().put("type", "error").put("error", "rate_limited").toString())
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "rate limited", false) }
return
@@ -302,10 +289,12 @@ class ControlServer(
val tokenOk = Pairing.isValidToken(context, msg.optString("token"))
val codeOk = !tokenOk && Pairing.isValidCode(context, msg.optString("code"))
if (!tokenOk && !codeOk) {
attempts.recordFailure(remoteAddress)
trySend(JSONObject().put("type", "error").put("error", "bad_code").toString())
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "bad code", false) }
return
}
attempts.clear(remoteAddress)
authorized = true
deviceName = msg.optString("name").ifEmpty { "Handy" }
// A code-authenticated client gets its own revocable token, never
@@ -339,9 +328,7 @@ class ControlServer(
private companion object {
const val TAG = "ControlServer"
const val PING_INTERVAL_MS = 8_000L
const val ATTEMPT_WINDOW_MS = 60_000L
const val ATTEMPT_MAX = 5
const val MAX_TRACKED_ADDRESSES = 64
const val SOCKET_TIMEOUT_MS = 40_000
const val MAX_CLIENTS = 8
const val HANDSHAKE_TIMEOUT_MS = 10_000L
val PING_PAYLOAD = byteArrayOf(0x6e, 0x63)

View File

@@ -54,19 +54,22 @@ fun AdvancedScreen(state: AppState) {
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp,
)
Spacer(Modifier.height(18.dp))
// Labels sit above the field, not in a notch in its outline: the
// notch never cut out cleanly here and the border ran straight
// through the text.
FieldLabel("M3U-URL")
TvTextField(
value = m3u,
onValueChange = { m3u = it },
label = { Text("M3U-URL", fontFamily = AppFont) },
colors = fieldColors,
textStyle = TextStyle(fontSize = 14.sp),
modifier = Modifier.width(600.dp),
)
Spacer(Modifier.height(12.dp))
Spacer(Modifier.height(14.dp))
FieldLabel("XMLTV-EPG-URL (optional)")
TvTextField(
value = epg,
onValueChange = { epg = it },
label = { Text("XMLTV-EPG-URL (optional)", fontFamily = AppFont) },
colors = fieldColors,
textStyle = TextStyle(fontSize = 14.sp),
modifier = Modifier.width(600.dp),
@@ -96,3 +99,15 @@ fun AdvancedScreen(state: AppState) {
}
}
}
@Composable
private fun FieldLabel(text: String) {
Text(
text.uppercase(),
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 11.sp,
letterSpacing = 1.5.sp,
modifier = Modifier.padding(bottom = 6.dp, start = 2.dp),
)
}

View File

@@ -1,21 +1,32 @@
package dev.castarr.tv.ui
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
@@ -24,11 +35,10 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.tv.material3.Surface
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import dev.castarr.tv.AppState
import kotlin.math.PI
@@ -69,6 +79,7 @@ fun CastarrApp(state: AppState) {
if (state.digitBuffer.isNotEmpty()) {
DigitBadge(state)
}
ClubNudge(state)
}
}
@@ -118,6 +129,8 @@ private fun TopBar(state: AppState) {
fontWeight = FontWeight.Medium,
letterSpacing = 4.sp,
)
Spacer(Modifier.width(24.dp))
StaleNotice(state)
Spacer(Modifier.weight(1f))
state.connectedRemote?.let { name ->
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -132,6 +145,10 @@ private fun TopBar(state: AppState) {
}
Spacer(Modifier.width(18.dp))
}
state.updateAvailable?.let { version ->
UpdateChip(version) { state.screen = AppState.Screen.SETTINGS }
Spacer(Modifier.width(10.dp))
}
GearButton {
state.screen = if (state.screen == AppState.Screen.LIVE) AppState.Screen.SETTINGS
else AppState.Screen.LIVE
@@ -139,6 +156,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). */
@Composable
private fun GearButton(onClick: () -> Unit) {
@@ -184,3 +228,120 @@ private fun GearButton(onClick: () -> Unit) {
}
}
}
/**
* Says so when the source could not be reached. Without it a cached channel
* list looks exactly like a normal evening with an empty EPG — the club
* menus even count down to zero.
*/
@Composable
private fun StaleNotice(state: AppState) {
val unreachable by when (state.sourceMode) {
AppState.SourceMode.GENERIC -> state.source.unreachable
AppState.SourceMode.DISPATCHARR -> state.dispatcharr.unreachable
}.collectAsState()
val updatedAt by when (state.sourceMode) {
AppState.SourceMode.GENERIC -> state.source.updatedAt
AppState.SourceMode.DISPATCHARR -> state.dispatcharr.updatedAt
}.collectAsState()
if (!unreachable) return
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
Modifier
.size(7.dp)
.clip(CircleShape)
.background(CastarrColors.live)
)
Spacer(Modifier.width(8.dp))
Text(
if (updatedAt > 0) {
"Server nicht erreichbar · Stand ${formatClock(updatedAt)}"
} else {
"Server nicht erreichbar"
},
color = CastarrColors.muted,
fontFamily = AppFont,
fontSize = 13.sp,
)
}
}
/**
* "St. Pauli läuft jetzt auf Liga Live UHD." Shown over the running picture
* when a club match starts elsewhere; OK switches, Zurück dismisses.
*/
@Composable
private fun ClubNudge(state: AppState) {
LaunchedEffect(state.playerVisible) {
while (state.playerVisible) {
state.checkClubNudge()
kotlinx.coroutines.delay(30_000)
}
}
val nudge = state.clubNudge ?: return
// The card takes focus while it is up: with the playback overlay open,
// one of its buttons would otherwise swallow the OK the card promises.
val focus = remember { FocusRequester() }
LaunchedEffect(nudge) { runCatching { focus.requestFocus() } }
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopEnd) {
Surface(
onClick = { state.acceptClubNudge() },
modifier = Modifier
.padding(top = 28.dp, end = 40.dp)
.focusRequester(focus),
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(14.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
colors = ClickableSurfaceDefaults.colors(
containerColor = CastarrColors.surface,
contentColor = CastarrColors.fg,
focusedContainerColor = CastarrColors.surface,
focusedContentColor = CastarrColors.fg,
),
border = ClickableSurfaceDefaults.border(
border = Border(
border = BorderStroke(1.dp, CastarrColors.accent),
shape = RoundedCornerShape(14.dp),
),
focusedBorder = Border(
border = BorderStroke(2.dp, CastarrColors.accent),
shape = RoundedCornerShape(14.dp),
),
),
) {
Column(
Modifier.padding(horizontal = 20.dp, vertical = 14.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
Modifier
.size(7.dp)
.clip(CircleShape)
.background(CastarrColors.live)
)
Spacer(Modifier.width(8.dp))
Text(
"${nudge.clubName} läuft jetzt",
color = CastarrColors.fg,
fontFamily = AppFont,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
)
}
Spacer(Modifier.height(4.dp))
Text(
nudge.channel.name,
color = CastarrColors.muted,
fontFamily = AppFont,
fontSize = 13.sp,
)
Spacer(Modifier.height(8.dp))
Text(
"OK zum Wechseln · Zurück zum Ausblenden",
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 12.sp,
)
}
}
}
}

View File

@@ -0,0 +1,13 @@
package dev.castarr.tv.ui
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Wall-clock time as the screens show it: "20:15", German locale, device
* timezone. Lived twice in two screens, which is once too often for a rule
* that has to be the same everywhere.
*/
internal fun formatClock(millis: Long): String =
SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis))

View File

@@ -0,0 +1,150 @@
package dev.castarr.tv.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.tv.material3.Text
import dev.castarr.tv.AppState
import dev.castarr.tv.data.isEpgPlaceholder
import dev.castarr.tv.playlist.Channel
/**
* What this channel still shows today. The EPG beyond "now and next" the
* app was missing: the data was parsed all along, only never displayed.
*
* Read-only on purpose — no recordings, no reminders, nothing to operate.
*/
@Composable
internal fun DayPlanDialog(state: AppState, channel: Channel, onClose: () -> Unit) {
val programmes = remember(channel.url) {
state.upcomingToday(channel).filterNot { isEpgPlaceholder(it.title) }
}
Dialog(onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false)) {
Box(
Modifier
.fillMaxSize()
.background(CastarrColors.bgDeep.copy(alpha = 0.88f)),
contentAlignment = Alignment.Center,
) {
Column(
Modifier
.width(760.dp)
.clip(RoundedCornerShape(16.dp))
.background(CastarrColors.surface)
.padding(horizontal = 26.dp, vertical = 22.dp)
) {
Text(
channel.name,
color = CastarrColors.fg,
fontFamily = AppFont,
fontSize = 20.sp,
fontWeight = FontWeight.Medium,
)
Text(
"Heute noch",
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 12.sp,
letterSpacing = 1.5.sp,
)
Spacer(Modifier.height(14.dp))
if (programmes.isEmpty()) {
Text(
"Für diesen Sender liegt kein Programm vor.",
color = CastarrColors.muted,
fontFamily = AppFont,
fontSize = 14.sp,
)
} else {
val now = System.currentTimeMillis()
LazyColumn(
Modifier.heightIn(max = 520.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
itemsIndexed(programmes) { index, programme ->
val running = now in programme.start until programme.stop
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(
if (running) CastarrColors.accentDim
else androidx.compose.ui.graphics.Color.Transparent
)
.padding(horizontal = 12.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
formatClock(programme.start),
color = if (running) CastarrColors.accent
else CastarrColors.faint,
fontFamily = AppFont,
fontSize = 13.sp,
modifier = Modifier.width(64.dp),
)
Text(
programme.title,
color = if (running) CastarrColors.accent
else CastarrColors.fg,
fontFamily = AppFont,
fontSize = 15.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (running) {
Text(
"läuft",
color = CastarrColors.accent,
fontFamily = AppFont,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
if (index < programmes.lastIndex) {
Box(
Modifier
.padding(horizontal = 12.dp)
.fillMaxWidth()
.height(1.dp)
.background(CastarrColors.line)
)
}
}
}
}
Spacer(Modifier.height(16.dp))
Text(
"Zurück zum Schließen",
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 12.sp,
)
}
}
}
}

View File

@@ -0,0 +1,271 @@
package dev.castarr.tv.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.animation.core.animateFloat
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.input.key.key
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.SubcomposeAsyncImage
import androidx.compose.ui.layout.ContentScale
import androidx.tv.material3.Text
import dev.castarr.tv.AppState
import dev.castarr.tv.data.NowNext
import dev.castarr.tv.data.isEpgPlaceholder
import dev.castarr.tv.playlist.Channel
/**
* 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
internal 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. */
@Composable
internal fun LogoTile(channel: Channel) {
val initials = channel.name.split(" ").filter { it.isNotBlank() }
.take(2).map { it.first() }.joinToString("").uppercase()
Box(Modifier.size(width = 52.dp, height = 40.dp), contentAlignment = Alignment.Center) {
if (channel.logo.isNotEmpty()) {
SubcomposeAsyncImage(
model = channel.logo,
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(),
loading = { LogoInitials(initials) },
error = { LogoInitials(initials) },
)
} else {
LogoInitials(initials)
}
}
}
@Composable
private fun LogoInitials(initials: String) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
initials,
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
/** The next club broadcast on this channel, and how many follow it. */
@Composable
internal fun HighlightCell(hit: AppState.TeamHit, modifier: Modifier = Modifier) {
val programme = hit.programme
val running = System.currentTimeMillis() in programme.start until programme.stop
// The row is here because of the fixture, so the fixture is what the
// line shows — the raw title cut off exactly at the club's name.
val title = dev.castarr.tv.data.TeamFilters.fixture(programme.title) ?: programme.title
Column(modifier) {
Row(verticalAlignment = Alignment.Bottom) {
Text(
title,
color = CastarrColors.fg,
fontFamily = AppFont,
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
// Only "läuft" earns space up here. The kick-off is already in
// the line below, and the fixture needs every pixel: real titles
// like "Bayer Leverkusen - VfB Stuttgart" were still cut off.
if (running) {
Spacer(Modifier.width(12.dp))
Text(
"läuft",
color = CastarrColors.accent,
fontFamily = AppFont,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
Spacer(Modifier.height(6.dp))
Text(
(if (running) "" else "ab ") +
"${formatClock(programme.start)}${formatClock(programme.stop)}" +
if (hit.further > 0) " · +${hit.further} weitere" else "",
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 12.sp,
)
}
}
/**
* Left-aligned EPG column with a fixed leading edge: title + times, progress
* under the title, next programme only when it differs (design review P1/P2).
*/
@Composable
internal fun EpgCell(
nowNext: NowNext,
modifier: Modifier = Modifier,
/** Shown instead of an empty half-row when the channel has no EPG. */
fallback: String = "",
) {
val now = nowNext.now?.takeUnless { isEpgPlaceholder(it.title) }
Column(modifier) {
if (now == null && fallback.isNotEmpty()) {
Text(
fallback,
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (now != null) {
Row(verticalAlignment = Alignment.Bottom) {
Text(
now.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(
"${formatClock(now.start)}${formatClock(now.stop)}",
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 12.sp,
)
}
val fraction = ((System.currentTimeMillis() - now.start).toFloat() /
(now.stop - now.start).coerceAtLeast(1)).coerceIn(0f, 1f)
Spacer(Modifier.height(6.dp))
Box(
Modifier
.fillMaxWidth()
.height(2.dp)
.clip(RoundedCornerShape(1.dp))
.background(CastarrColors.line)
) {
Box(
Modifier
.fillMaxWidth(fraction)
.height(2.dp)
.background(CastarrColors.accent)
)
}
val next = nowNext.next?.takeUnless {
isEpgPlaceholder(it.title) || it.title == now.title
}
if (next != null) {
Spacer(Modifier.height(5.dp))
Text(
"danach: ${next.title}",
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}

View File

@@ -0,0 +1,117 @@
package dev.castarr.tv.ui
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import dev.castarr.tv.AppState
import dev.castarr.tv.data.NowNext
import dev.castarr.tv.playlist.Channel
@Composable
internal fun ChannelRow(
channel: Channel,
number: Int,
modifier: Modifier = Modifier,
nowNext: NowNext,
highlight: AppState.TeamHit? = null,
playing: Boolean,
favorite: Boolean,
epgStamp: Long,
onLongClick: (() -> Unit)?,
onClick: () -> Unit,
) {
Surface(
onClick = onClick,
onLongClick = onLongClick,
modifier = modifier.fillMaxWidth(),
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)),
// No focus scale — grown rows get clipped at the pane edges.
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
colors = ClickableSurfaceDefaults.colors(
containerColor = if (playing) CastarrColors.accentDim else CastarrColors.surface,
contentColor = CastarrColors.fg,
focusedContainerColor = CastarrColors.surfaceFocused,
focusedContentColor = CastarrColors.fg,
),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
border = BorderStroke(2.dp, CastarrColors.accent),
shape = RoundedCornerShape(12.dp),
),
),
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"$number",
color = if (playing) CastarrColors.accent else CastarrColors.faint,
fontFamily = AppFont,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.width(40.dp),
)
LogoTile(channel)
Spacer(Modifier.width(14.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.width(230.dp),
) {
Text(
channel.name,
color = if (playing) CastarrColors.accent else CastarrColors.fg,
fontFamily = AppFont,
fontSize = 16.sp,
fontWeight = if (playing) FontWeight.Medium else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
if (favorite) {
Spacer(Modifier.width(8.dp))
Text("", color = CastarrColors.accent, fontSize = 13.sp)
}
}
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), fallback = channel.group)
}
if (playing) {
Spacer(Modifier.width(12.dp))
Box(
Modifier
.size(7.dp)
.clip(CircleShape)
.background(CastarrColors.accent)
)
}
}
}
}

View File

@@ -1,10 +1,8 @@
package dev.castarr.tv.ui
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
@@ -15,43 +13,37 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.SubcomposeAsyncImage
import androidx.compose.ui.layout.ContentScale
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import dev.castarr.tv.AppState
import dev.castarr.tv.data.NowNext
import dev.castarr.tv.data.isEpgPlaceholder
import dev.castarr.tv.playlist.Channel
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Live tab: group rail on the left (focusing a group opens it — exclusive,
@@ -61,18 +53,45 @@ import java.util.Locale
fun LiveScreen(state: AppState) {
val genericChannels by state.source.channels.collectAsStateWithLifecycle()
val dispatcharrChannels by state.dispatcharr.channels.collectAsStateWithLifecycle()
val favorites by state.dispatcharr.favorites.collectAsStateWithLifecycle()
// Backend favourites have to be *collected*, not read off the flow:
// reading the value inside a composable never recomposes, so the rail
// sat at zero while the backend already knew better.
val backendFavorites by state.dispatcharr.favorites.collectAsStateWithLifecycle()
// Re-render Now/Next when a new EPG arrives.
val genericEpg by state.source.epgUpdatedAt.collectAsStateWithLifecycle()
val dispatcharrEpg by state.dispatcharr.epgUpdatedAt.collectAsStateWithLifecycle()
val isDispatcharr = state.sourceMode == AppState.SourceMode.DISPATCHARR
fun isFavorite(channel: dev.castarr.tv.playlist.Channel) =
if (isDispatcharr) channel.backendId in backendFavorites else state.isFavorite(channel)
val favoriteCount = if (isDispatcharr) backendFavorites.size else state.favoriteCount()
val epgStamp = if (isDispatcharr) dispatcharrEpg else genericEpg
val allChannels = if (isDispatcharr) dispatcharrChannels else genericChannels
val groups = remember(allChannels) {
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 {
state.favoritesOnly -> allChannels.filter { it.backendId in favorites }
activeTeam != null -> activeMatches.map { it.first }
state.favoritesOnly -> allChannels.filter { isFavorite(it) }
state.groupFilter != null -> allChannels.filter { it.group == state.groupFilter }
else -> allChannels
}
@@ -97,27 +116,74 @@ fun LiveScreen(state: AppState) {
val restoreFocus = remember { FocusRequester() }
val listState = rememberLazyListState()
// Index of the channel the viewer last watched, so leaving playback
// 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 }
// returns them to where they were instead of the top of the list. The
// URL survives a restart, so switching the TV on lands there too.
val restoreTarget = state.restoreTargetUrl()
val restoreIndex = restoreTarget.takeIf { it.isNotEmpty() }?.let { url ->
channels.indexOfFirst { it.url == url }.takeIf { it >= 0 }
}
var restored by remember { mutableStateOf(false) }
// Right jumps to the next initial letter — the only way through a long
// list without a text field, which the ten-foot rule rules out.
var focusedIndex by remember { mutableStateOf(0) }
var jumpIndex by remember { mutableStateOf<Int?>(null) }
val jumpFocus = remember { FocusRequester() }
LaunchedEffect(jumpIndex) {
val target = jumpIndex ?: return@LaunchedEffect
listState.scrollToItem(target)
runCatching { jumpFocus.requestFocus() }
focusedIndex = target
jumpIndex = null
}
fun initial(name: String) = name.trimStart().firstOrNull()?.uppercaseChar()
fun jumpToInitial(direction: Int) {
if (channels.isEmpty()) return
val from = focusedIndex.coerceIn(0, channels.lastIndex)
val current = initial(channels[from].name)
jumpIndex = if (direction > 0) {
val next = channels.drop(from + 1).indexOfFirst { initial(it.name) != current }
// Past the last letter, wrap to the top rather than do nothing.
if (next >= 0) from + 1 + next else 0
} else {
val before = channels.take(from).indexOfLast { initial(it.name) != current }
if (before < 0) 0 else {
val letter = initial(channels[before].name)
channels.take(before + 1).indexOfFirst { initial(it.name) == letter }
}
}
}
LaunchedEffect(state.letterJump) {
if (state.letterJump > 0) jumpToInitial(state.letterJumpDirection)
}
var dayPlanFor by remember { mutableStateOf<dev.castarr.tv.playlist.Channel?>(null) }
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) {
if (restoreIndex != null && !restored) {
LaunchedEffect(restoreIndex, channels.size, state.restorePending) {
if (restoreIndex != null && (state.restorePending || !restored)) {
listState.scrollToItem(restoreIndex)
runCatching { restoreFocus.requestFocus() }
restored = true
state.restorePending = false
} else if (restoreIndex == null && !restored && channels.isNotEmpty()) {
// Nothing to restore, but the list still beats the settings gear
// as a starting point.
restored = runCatching { listFocus.requestFocus() }.isSuccess
}
}
val intoList = if (channels.isEmpty()) Modifier
else Modifier.focusProperties { right = listFocus }
// 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 railState = rememberLazyListState()
Row(Modifier.fillMaxSize()) {
LazyColumn(
state = railState,
modifier = Modifier
.width(264.dp)
.fillMaxHeight(),
@@ -127,7 +193,8 @@ fun LiveScreen(state: AppState) {
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
item {
val selected = !state.favoritesOnly && state.groupFilter == null
val selected = !state.favoritesOnly && state.groupFilter == null &&
state.activeTeam == null
GroupItem(
label = "Alle Sender",
count = allChannels.size,
@@ -135,25 +202,51 @@ fun LiveScreen(state: AppState) {
modifier = intoList.then(
if (selected) Modifier.focusRequester(railFocus) else Modifier
),
suppressAutoSelect = { state.restorePending },
) {
state.activeTeam = null
state.favoritesOnly = false
state.groupFilter = null
}
}
if (isDispatcharr) {
// Favourites exist for both source types now, so the entry is
// no longer tied to being signed in.
item {
GroupItem(
label = "★ Favoriten",
count = favorites.size,
selected = state.favoritesOnly,
count = favoriteCount,
selected = state.favoritesOnly && state.activeTeam == null,
modifier = intoList.then(
if (state.favoritesOnly) Modifier.focusRequester(railFocus) else Modifier
),
suppressAutoSelect = { state.restorePending },
) {
state.activeTeam = null
state.favoritesOnly = true
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.shortName,
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 {
Box(
@@ -168,11 +261,13 @@ fun LiveScreen(state: AppState) {
GroupItem(
label = group,
count = remember(allChannels, group) { allChannels.count { it.group == group } },
selected = state.groupFilter == group,
selected = state.groupFilter == group && state.activeTeam == null,
modifier = intoList.then(
if (state.groupFilter == group) Modifier.focusRequester(railFocus) else Modifier
),
suppressAutoSelect = { state.restorePending },
) {
state.activeTeam = null
state.favoritesOnly = false
state.groupFilter = group
}
@@ -192,9 +287,13 @@ fun LiveScreen(state: AppState) {
if (channels.isEmpty()) {
item {
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."
else "Diese Gruppe ist leer.",
else -> "Diese Gruppe ist leer."
},
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 14.sp,
@@ -209,26 +308,48 @@ fun LiveScreen(state: AppState) {
// matching the remote's number pad in every view.
number = allChannels.indexOf(channel) + 1,
modifier = Modifier
.focusProperties { left = railFocus }
.onFocusChanged { if (it.isFocused) focusedIndex = listIndex }
.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) {
return@onPreviewKeyEvent false
}
when (event.key) {
Key.DirectionLeft ->
runCatching { railFocus.requestFocus() }.isSuccess
// The day plan for this channel — the EPG
// beyond "now and next" (#16).
Key.DirectionRight -> {
dayPlanFor = channel
true
}
else -> false
}
}
.then(if (listIndex == 0) Modifier.focusRequester(listFocus) else Modifier)
.then(
if (listIndex == restoreIndex) Modifier.focusRequester(restoreFocus)
else Modifier
)
.then(
if (listIndex == jumpIndex) Modifier.focusRequester(jumpFocus)
else Modifier
),
nowNext = state.nowNext(channel),
highlight = teamHits[channel.url],
playing = state.currentChannel?.url == channel.url,
// All rows are favorites in the favorites view — the
// star only carries meaning elsewhere.
favorite = isDispatcharr && !state.favoritesOnly &&
channel.backendId in favorites,
favorite = !state.favoritesOnly && isFavorite(channel),
epgStamp = epgStamp,
onLongClick = if (isDispatcharr) {
{ state.dispatcharr.toggleFavorite(channel) }
} else null,
onLongClick = { state.toggleFavorite(channel) },
) { state.play(channel) }
}
}
}
dayPlanFor?.let { channel ->
DayPlanDialog(state, channel) { dayPlanFor = null }
}
}
@Composable
@@ -237,6 +358,10 @@ private fun GroupItem(
count: Int,
selected: Boolean,
modifier: Modifier = Modifier,
leading: (@Composable () -> Unit)? = null,
/** Replaces the count when a match is on or about to start. */
signal: AppState.TeamSignal? = null,
suppressAutoSelect: () -> Boolean = { false },
onSelect: () -> Unit,
) {
Surface(
@@ -245,7 +370,7 @@ private fun GroupItem(
onClick = onSelect,
modifier = modifier
.fillMaxWidth()
.onFocusChanged { if (it.isFocused) onSelect() },
.onFocusChanged { if (it.isFocused && !suppressAutoSelect()) onSelect() },
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
colors = ClickableSurfaceDefaults.colors(
@@ -259,6 +384,10 @@ private fun GroupItem(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (leading != null) {
leading()
Spacer(Modifier.width(10.dp))
}
Text(
label,
fontFamily = AppFont,
@@ -269,189 +398,35 @@ private fun GroupItem(
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
// The signal lives in the value column instead of adding a
// badge — the count is worth less than "now" or "16:00" is.
val urgent = signal?.urgency ?: AppState.TeamUrgency.NONE
if (urgent == AppState.TeamUrgency.NONE) {
Text(
"$count",
fontFamily = AppFont,
fontSize = 12.sp,
)
}
}
}
@Composable
private fun ChannelRow(
channel: Channel,
number: Int,
modifier: Modifier = Modifier,
nowNext: NowNext,
playing: Boolean,
favorite: Boolean,
epgStamp: Long,
onLongClick: (() -> Unit)?,
onClick: () -> Unit,
) {
Surface(
onClick = onClick,
onLongClick = onLongClick,
modifier = modifier.fillMaxWidth(),
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)),
// No focus scale — grown rows get clipped at the pane edges.
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
colors = ClickableSurfaceDefaults.colors(
containerColor = if (playing) CastarrColors.accentDim else CastarrColors.surface,
contentColor = CastarrColors.fg,
focusedContainerColor = CastarrColors.surfaceFocused,
focusedContentColor = CastarrColors.fg,
),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
border = BorderStroke(2.dp, CastarrColors.accent),
shape = RoundedCornerShape(12.dp),
),
),
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"$number",
color = if (playing) CastarrColors.accent else CastarrColors.faint,
fontFamily = AppFont,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.width(40.dp),
)
LogoTile(channel)
Spacer(Modifier.width(14.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.width(230.dp),
) {
Text(
channel.name,
color = if (playing) CastarrColors.accent else CastarrColors.fg,
fontFamily = AppFont,
fontSize = 16.sp,
fontWeight = if (playing) FontWeight.Medium else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
if (favorite) {
Spacer(Modifier.width(8.dp))
Text("", color = CastarrColors.accent, fontSize = 13.sp)
}
}
Spacer(Modifier.width(24.dp))
EpgCell(nowNext, Modifier.weight(1f))
if (playing) {
Spacer(Modifier.width(12.dp))
} 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
.size(7.dp)
.clip(CircleShape)
.background(CastarrColors.accent)
)
}
}
}
}
/** Bare channel logo (they ship transparent); initials as fallback. */
@Composable
private fun LogoTile(channel: Channel) {
val initials = channel.name.split(" ").filter { it.isNotBlank() }
.take(2).map { it.first() }.joinToString("").uppercase()
Box(Modifier.size(width = 52.dp, height = 40.dp), contentAlignment = Alignment.Center) {
if (channel.logo.isNotEmpty()) {
SubcomposeAsyncImage(
model = channel.logo,
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(),
loading = { LogoInitials(initials) },
error = { LogoInitials(initials) },
)
} else {
LogoInitials(initials)
}
}
}
@Composable
private fun LogoInitials(initials: String) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
.clip(RoundedCornerShape(999.dp))
.background(CastarrColors.bgDeep.copy(alpha = 0.92f))
.padding(horizontal = 8.dp, vertical = 3.dp)
) {
Text(
initials,
color = CastarrColors.faint,
if (urgent == AppState.TeamUrgency.LIVE) "läuft"
else "ab ${formatClock(signal!!.kickOff)}",
color = if (urgent == AppState.TeamUrgency.LIVE) CastarrColors.live
else CastarrColors.accent,
fontFamily = AppFont,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
private fun formatClock(millis: Long): String =
SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis))
/**
* Left-aligned EPG column with a fixed leading edge: title + times, progress
* under the title, next programme only when it differs (design review P1/P2).
*/
@Composable
private fun EpgCell(nowNext: NowNext, modifier: Modifier = Modifier) {
val now = nowNext.now?.takeUnless { isEpgPlaceholder(it.title) }
Column(modifier) {
if (now != null) {
Row(verticalAlignment = Alignment.Bottom) {
Text(
now.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(
"${formatClock(now.start)}${formatClock(now.stop)}",
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 12.sp,
)
}
val fraction = ((System.currentTimeMillis() - now.start).toFloat() /
(now.stop - now.start).coerceAtLeast(1)).coerceIn(0f, 1f)
Spacer(Modifier.height(6.dp))
Box(
Modifier
.fillMaxWidth()
.height(2.dp)
.clip(RoundedCornerShape(1.dp))
.background(CastarrColors.line)
) {
Box(
Modifier
.fillMaxWidth(fraction)
.height(2.dp)
.background(CastarrColors.accent)
)
}
val next = nowNext.next?.takeUnless {
isEpgPlaceholder(it.title) || it.title == now.title
}
if (next != null) {
Spacer(Modifier.height(5.dp))
Text(
"danach: ${next.title}",
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}

View File

@@ -51,9 +51,6 @@ import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import dev.castarr.tv.AppState
import dev.castarr.tv.data.isEpgPlaceholder
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Fullscreen playback. OK opens the auto-hiding overlay whose transport
@@ -106,9 +103,6 @@ private val OverlayText = TextStyle(
shadow = Shadow(Color(0xB3000000), Offset(0f, 2f), blurRadius = 8f),
)
private fun formatClock(millis: Long): String =
SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis))
@Composable
private fun Overlay(state: AppState) {
val playFocus = remember { FocusRequester() }

View File

@@ -0,0 +1,240 @@
package dev.castarr.tv.ui
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.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.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import dev.castarr.tv.AppState
/** Club crest from the bundled assets; initials while it loads. */
@Composable
internal 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
internal 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
val listState = rememberLazyListState()
// Left and right jump a whole division: without it the third league is
// forty presses away.
var leagueIndex by remember { mutableStateOf(0) }
val leagueFocus = remember { FocusRequester() }
var leagueJump by remember { mutableStateOf(false) }
// Item index of each division header in the flat list the LazyColumn
// builds (one header plus its clubs per section).
val leagueStarts = remember(sections) {
var index = 0
sections.map { (_, clubs) ->
val start = index
index += clubs.size + 1
start
}
}
val leagueFirstKeys = remember(sections) { sections.map { it.second.firstOrNull()?.key } }
LaunchedEffect(leagueIndex, leagueJump) {
if (!leagueJump) return@LaunchedEffect
leagueStarts.getOrNull(leagueIndex)?.let { listState.scrollToItem(it) }
runCatching { leagueFocus.requestFocus() }
leagueJump = false
}
fun jumpLeague(direction: Int) {
val next = (leagueIndex + direction).coerceIn(0, sections.lastIndex.coerceAtLeast(0))
if (next == leagueIndex) return
leagueIndex = next
leagueJump = true
}
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 = 2.dp),
)
Text(
"◀ ▶ Liga wechseln",
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 11.sp,
modifier = Modifier.padding(start = 14.dp, bottom = 8.dp),
)
LazyColumn(
state = listState,
modifier = Modifier
.heightIn(max = 460.dp)
.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when (event.key) {
Key.DirectionRight -> { jumpLeague(1); true }
Key.DirectionLeft -> { jumpLeague(-1); true }
else -> false
}
},
) {
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 = when (club.key) {
firstKey -> Modifier.focusRequester(firstFocus)
leagueFirstKeys.getOrNull(leagueIndex) ->
Modifier.focusRequester(leagueFocus)
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,
)
}
}
}
}
}
}
}
}
}

View File

@@ -1,11 +1,6 @@
package dev.castarr.tv.ui
import android.graphics.Bitmap
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -17,8 +12,10 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -31,19 +28,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.foundation.focusGroup
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import dev.castarr.tv.AppState
import dev.castarr.tv.BuildConfig
@@ -53,7 +43,7 @@ import dev.castarr.tv.update.UpdateChecker
import kotlinx.coroutines.launch
/** One open picker dialog: which setting, its options, what happens on pick. */
private data class Picker(
internal data class Picker(
val title: String,
val options: List<String>,
val selected: Int,
@@ -72,15 +62,30 @@ fun SettingsScreen(state: AppState) {
val profiles by state.dispatcharr.profiles.collectAsState()
var updateStatus by remember { mutableStateOf("") }
var picker by remember { mutableStateOf<Picker?>(null) }
var clubPicker by remember { mutableStateOf(false) }
// Bumped on reset so the QR code and the four-digit code redraw.
var pairingEpoch by remember { mutableStateOf(0) }
var confirmPairingReset by remember { mutableStateOf(false) }
// Focus belongs on the first row of the left column, not on the gear the
// viewer just left: from there "down" used to land on the destructive
// action in the right column and go nowhere after that.
val firstRowFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { runCatching { firstRowFocus.requestFocus() } }
Row(
Modifier
.fillMaxSize()
.padding(horizontal = 40.dp, vertical = 12.dp)
) {
Column(Modifier.weight(1.25f), verticalArrangement = Arrangement.spacedBy(16.dp)) {
// Three columns instead of two: the cards outgrew one screen once
// club menus arrived, and the ten-foot rule (CONTEXT.md) allows no
// scrolling outside the channel list.
Column(
Modifier
.weight(1f)
.focusGroup(),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
SettingsCard("Konto") {
if (state.auth.isLoggedIn) {
Row(
@@ -114,14 +119,21 @@ fun SettingsScreen(state: AppState) {
)
}
}
SettingRow("Abmelden", danger = true) { state.startOnboarding() }
SettingRow(
"Abmelden",
danger = true,
modifier = Modifier.focusRequester(firstRowFocus),
) { state.startOnboarding() }
} else {
Text(
"Nicht angemeldet.",
color = CastarrColors.muted, fontFamily = AppFont, fontSize = 14.sp,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
)
SettingRow("Jetzt anmelden") { state.startOnboarding() }
SettingRow(
"Jetzt anmelden",
modifier = Modifier.focusRequester(firstRowFocus),
) { state.startOnboarding() }
}
}
@@ -163,6 +175,42 @@ 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.shortName} 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 }
}
}
}
Spacer(Modifier.width(16.dp))
Column(
Modifier
.weight(1f)
.focusGroup(),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
SettingsCard("App") {
SettingRow(
"Version ${BuildConfig.VERSION_NAME}",
@@ -203,16 +251,37 @@ fun SettingsScreen(state: AppState) {
}
}
}
SettingRow(
"Senderliste neu laden",
subtitle = "Holt Sender, Programm und Favoriten erneut",
) { state.refreshActive() }
SettingRow(
"Erweitert",
subtitle = "M3U/EPG-Adressen von Hand eintragen",
) { state.screen = AppState.Screen.ADVANCED }
// "Zwei Wochen ohne Absturz" ist nur nachprüfbar, wenn ein
// Absturz überhaupt eine Spur hinterlässt.
val lastCrash = remember(pairingEpoch) {
dev.castarr.tv.CrashLog.lastSummary(context)
}
SettingRow(
"Stabilität",
subtitle = lastCrash?.let { "Letzter Absturz: $it · Klicken löscht" }
?: "Kein Absturz aufgezeichnet",
) {
dev.castarr.tv.CrashLog.clear(context)
pairingEpoch++
}
}
}
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(0.75f)) {
Column(
Modifier
.weight(1f)
.focusGroup()
) {
SettingsCard("Handy-Fernbedienung") {
val address = remember(pairingEpoch) { Pairing.lanAddress() }
if (!state.remoteAvailable) {
@@ -249,10 +318,7 @@ fun SettingsScreen(state: AppState) {
SettingRow(
"Kopplung zurücksetzen",
subtitle = "Neuer Code, alle Handys müssen neu scannen",
) {
Pairing.reset(context)
pairingEpoch++
}
) { confirmPairingReset = true }
} else {
Text(
"Keine Netzwerkverbindung",
@@ -267,250 +333,31 @@ fun SettingsScreen(state: AppState) {
picker?.let { current ->
PickerDialog(current) { picker = null }
}
}
/** TV-friendly dropdown replacement: fullscreen scrim, options centered. */
@Composable
private fun PickerDialog(picker: Picker, onClose: () -> Unit) {
val selectedFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { selectedFocus.requestFocus() }
Dialog(
onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Box(
Modifier
.fillMaxSize()
// Own heavy scrim — the platform default is too light and
// lets the white QR card fight the panel.
.background(CastarrColors.bgDeep.copy(alpha = 0.88f)),
contentAlignment = Alignment.Center,
) {
Column(
Modifier
.width(360.dp)
.clip(RoundedCornerShape(16.dp))
.background(CastarrColors.surface)
.padding(horizontal = 10.dp, vertical = 14.dp)
) {
Text(
picker.title.uppercase(),
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 2.sp,
modifier = Modifier.padding(start = 14.dp, bottom = 10.dp),
)
picker.options.forEachIndexed { index, option ->
val selected = index == picker.selected
Surface(
onClick = {
picker.onPick(index)
onClose()
if (confirmPairingReset) {
val paired = remember(pairingEpoch) { Pairing.pairedCount(context) }
ConfirmDialog(
title = "Kopplung zurücksetzen?",
message = when (paired) {
0 -> "Der Code und der QR-Code werden neu erzeugt. Bereits " +
"gekoppelte Handys müssen danach erneut scannen."
1 -> "Ein gekoppeltes Handy verliert den Zugriff und muss " +
"erneut scannen."
else -> "$paired gekoppelte Handys verlieren den Zugriff und " +
"müssen erneut scannen."
},
modifier = Modifier
.fillMaxWidth()
.then(if (selected) Modifier.focusRequester(selectedFocus) else Modifier),
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 = 11.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
option,
color = if (selected) CastarrColors.accent else CastarrColors.fg,
fontFamily = AppFont,
fontSize = 14.sp,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (selected) {
Box(
Modifier
.size(7.dp)
.clip(CircleShape)
.background(CastarrColors.accent)
)
}
}
}
}
}
}
}
}
/** Current value plus a small drawn caret, shown at a row's trailing edge. */
@Composable
private fun ValueWithCaret(value: String) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
value,
color = CastarrColors.muted,
fontFamily = AppFont,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.width(8.dp))
val color = CastarrColors.faint
Canvas(Modifier.size(9.dp)) {
drawPath(
Path().apply {
moveTo(0f, size.height * 0.3f)
lineTo(size.width, size.height * 0.3f)
lineTo(size.width / 2f, size.height * 0.75f)
close()
confirmLabel = "Zurücksetzen",
onConfirm = {
Pairing.reset(context)
pairingEpoch++
},
color,
onClose = { confirmPairingReset = false },
)
}
}
}
@Composable
private fun SettingsCard(title: String, content: @Composable () -> Unit) {
Column(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(CastarrColors.surface)
.padding(horizontal = 10.dp, vertical = 14.dp)
) {
Text(
title.uppercase(),
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 2.sp,
modifier = Modifier.padding(start = 14.dp, bottom = 8.dp),
)
content()
}
}
private val rowShape = RoundedCornerShape(10.dp)
/** One full-width focusable settings row: label + subtitle, value right. */
@Composable
private fun SettingRow(
label: String,
subtitle: String? = null,
danger: Boolean = false,
trailing: (@Composable () -> Unit)? = null,
onClick: () -> Unit,
) {
Surface(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
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, if (danger) CastarrColors.live else CastarrColors.accent),
shape = rowShape,
),
),
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
label,
color = if (danger) CastarrColors.live else CastarrColors.fg,
fontFamily = AppFont,
fontSize = 15.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (subtitle != null) {
Text(
subtitle,
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (trailing != null) {
Spacer(Modifier.width(12.dp))
trailing()
}
}
}
}
@Composable
fun ActionButton(label: String, danger: Boolean = false, onClick: () -> Unit) {
Surface(
onClick = onClick,
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)),
colors = ClickableSurfaceDefaults.colors(
containerColor = if (danger) CastarrColors.surface else CastarrColors.accentDim,
contentColor = if (danger) CastarrColors.muted else CastarrColors.accent,
focusedContainerColor = if (danger) CastarrColors.live else CastarrColors.accent,
focusedContentColor = if (danger) CastarrColors.fg else CastarrColors.onAccent,
),
) {
Text(
label,
fontFamily = AppFont, fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
if (clubPicker) {
ClubPickerDialog(
state = state,
onPick = { club -> state.toggleTeam(club.key); clubPicker = false },
onClose = { clubPicker = false },
)
}
}
@Composable
fun QrCard(bitmap: Bitmap) {
// Quiet zone stays white for scannability, but the tile sits in a
// bordered frame on the card instead of floating as a bare white block.
Column(
Modifier
.clip(RoundedCornerShape(18.dp))
.background(CastarrColors.bg)
.border(1.dp, CastarrColors.line, RoundedCornerShape(18.dp))
.padding(8.dp),
verticalArrangement = Arrangement.Center,
) {
Column(
Modifier
.clip(RoundedCornerShape(12.dp))
.background(androidx.compose.ui.graphics.Color(0xFFF4F6F7))
.padding(10.dp),
) {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = "QR-Code zum Koppeln",
modifier = Modifier.size(164.dp),
)
}
}
}

View File

@@ -0,0 +1,411 @@
package dev.castarr.tv.ui
import android.graphics.Bitmap
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
/** TV-friendly dropdown replacement: fullscreen scrim, options centered. */
@Composable
internal fun PickerDialog(picker: Picker, onClose: () -> Unit) {
val selectedFocus = remember { FocusRequester() }
// 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(
onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Box(
Modifier
.fillMaxSize()
// Own heavy scrim — the platform default is too light and
// lets the white QR card fight the panel.
.background(CastarrColors.bgDeep.copy(alpha = 0.88f)),
contentAlignment = Alignment.Center,
) {
Column(
Modifier
.width(360.dp)
.clip(RoundedCornerShape(16.dp))
.background(CastarrColors.surface)
.padding(horizontal = 10.dp, vertical = 14.dp)
) {
Text(
picker.title.uppercase(),
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 2.sp,
modifier = Modifier.padding(start = 14.dp, bottom = 10.dp),
)
LazyColumn(
state = listState,
modifier = Modifier.heightIn(max = 420.dp),
) {
itemsIndexed(picker.options) { index, option ->
val selected = index == picker.selected
Surface(
onClick = {
picker.onPick(index)
onClose()
},
modifier = Modifier
.fillMaxWidth()
.then(
if (selected || (picker.selected < 0 && index == 0))
Modifier.focusRequester(selectedFocus)
else Modifier
),
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 = 11.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
option,
color = if (selected) CastarrColors.accent else CastarrColors.fg,
fontFamily = AppFont,
fontSize = 14.sp,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (selected) {
Box(
Modifier
.size(7.dp)
.clip(CircleShape)
.background(CastarrColors.accent)
)
}
}
}
}
}
}
}
}
}
/** Current value plus a small drawn caret, shown at a row's trailing edge. */
@Composable
internal fun ValueWithCaret(value: String) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
value,
color = CastarrColors.muted,
fontFamily = AppFont,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.width(8.dp))
val color = CastarrColors.faint
Canvas(Modifier.size(9.dp)) {
drawPath(
Path().apply {
moveTo(0f, size.height * 0.3f)
lineTo(size.width, size.height * 0.3f)
lineTo(size.width / 2f, size.height * 0.75f)
close()
},
color,
)
}
}
}
/** Drawn "x": the row only ever removes, so a toggle would mislead. */
@Composable
internal 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
internal fun SettingsCard(title: String, content: @Composable () -> Unit) {
Column(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(CastarrColors.surface)
.padding(horizontal = 10.dp, vertical = 14.dp)
) {
Text(
title.uppercase(),
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 2.sp,
modifier = Modifier.padding(start = 14.dp, bottom = 8.dp),
)
content()
}
}
internal val rowShape = RoundedCornerShape(10.dp)
/** One full-width focusable settings row: label + subtitle, value right. */
@Composable
internal fun SettingRow(
label: String,
subtitle: String? = null,
danger: Boolean = false,
leading: (@Composable () -> Unit)? = null,
trailing: (@Composable () -> Unit)? = null,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
Surface(
onClick = onClick,
modifier = modifier.fillMaxWidth(),
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, if (danger) CastarrColors.live else CastarrColors.accent),
shape = rowShape,
),
),
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (leading != null) {
leading()
Spacer(Modifier.width(12.dp))
}
Column(Modifier.weight(1f)) {
Text(
label,
color = if (danger) CastarrColors.live else CastarrColors.fg,
fontFamily = AppFont,
fontSize = 15.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (subtitle != null) {
Text(
subtitle,
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 11.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
if (trailing != null) {
Spacer(Modifier.width(12.dp))
trailing()
}
}
}
}
@Composable
fun ActionButton(
label: String,
danger: Boolean = false,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
Surface(
onClick = onClick,
modifier = modifier,
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)),
colors = ClickableSurfaceDefaults.colors(
containerColor = if (danger) CastarrColors.surface else CastarrColors.accentDim,
contentColor = if (danger) CastarrColors.muted else CastarrColors.accent,
focusedContainerColor = if (danger) CastarrColors.live else CastarrColors.accent,
focusedContentColor = if (danger) CastarrColors.fg else CastarrColors.onAccent,
),
) {
Text(
label,
fontFamily = AppFont, fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
)
}
}
@Composable
fun QrCard(bitmap: Bitmap) {
// Quiet zone stays white for scannability, but the tile sits in a
// bordered frame on the card instead of floating as a bare white block.
Column(
Modifier
.clip(RoundedCornerShape(18.dp))
.background(CastarrColors.bg)
.border(1.dp, CastarrColors.line, RoundedCornerShape(18.dp))
.padding(8.dp),
verticalArrangement = Arrangement.Center,
) {
Column(
Modifier
.clip(RoundedCornerShape(12.dp))
.background(androidx.compose.ui.graphics.Color(0xFFF4F6F7))
.padding(10.dp),
) {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = "QR-Code zum Koppeln",
modifier = Modifier.size(164.dp),
)
}
}
}
/**
* Asks before something irreversible happens. Focus starts on "Abbrechen":
* a stray press of OK must not be the one that carries out the action.
*/
@Composable
internal fun ConfirmDialog(
title: String,
message: String,
confirmLabel: String,
onConfirm: () -> Unit,
onClose: () -> Unit,
) {
val cancelFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { runCatching { cancelFocus.requestFocus() } }
Dialog(
onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Box(
Modifier
.fillMaxSize()
.background(CastarrColors.bgDeep.copy(alpha = 0.88f)),
contentAlignment = Alignment.Center,
) {
Column(
Modifier
.width(520.dp)
.clip(RoundedCornerShape(16.dp))
.background(CastarrColors.surface)
.padding(horizontal = 26.dp, vertical = 24.dp)
) {
Text(
title,
color = CastarrColors.fg,
fontFamily = AppFont,
fontSize = 22.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(10.dp))
Text(
message,
color = CastarrColors.muted,
fontFamily = AppFont,
fontSize = 15.sp,
)
Spacer(Modifier.height(22.dp))
Row {
ActionButton("Abbrechen", modifier = Modifier.focusRequester(cancelFocus)) {
onClose()
}
Spacer(Modifier.width(12.dp))
ActionButton(confirmLabel, danger = true) {
onConfirm()
onClose()
}
}
}
}
}
}

View File

@@ -21,7 +21,6 @@ import androidx.compose.ui.text.TextStyle
fun TvTextField(
value: String,
onValueChange: (String) -> Unit,
label: @Composable () -> Unit,
colors: TextFieldColors,
modifier: Modifier = Modifier,
textStyle: TextStyle = TextStyle.Default,
@@ -30,7 +29,6 @@ fun TvTextField(
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = label,
singleLine = true,
textStyle = textStyle,
colors = colors,

View File

@@ -61,8 +61,18 @@ fun WelcomeScreen(state: AppState) {
AppState.WelcomePhase.WAIT_PHONE -> Unit
AppState.WelcomePhase.WAIT_URL -> Status("Handy verbunden — warte auf den Server…")
AppState.WelcomePhase.WAIT_LOGIN -> Status(
if (state.welcomeUserCode.isEmpty()) "Warte auf die Anmeldung am Handy…"
else "Anmeldung am Handy bestätigen · Code ${state.welcomeUserCode}"
when {
state.welcomeUserCode.isEmpty() ->
"Warte auf die Anmeldung am Handy…"
// Saying so beats swapping the digits in silence
// while somebody is typing them.
state.welcomeCodeRenewed ->
"Der alte Code war abgelaufen — neuer Code " +
state.welcomeUserCode
else ->
"Anmeldung am Handy bestätigen · Code " +
state.welcomeUserCode
}
)
AppState.WelcomePhase.DONE -> Status("Angemeldet! Lade Sender…")
}
@@ -78,18 +88,40 @@ fun WelcomeScreen(state: AppState) {
.background(Color(0xFFFBFCFD))
.padding(18.dp),
) {
val qr = remember(address) {
// While the login is pending the QR points at the
// identity provider's confirmation page instead of at
// this TV: scanning it beats typing nine digits
// against a code that lives a minute.
val target = state.welcomeLoginUrl.ifEmpty {
Pairing.pairingUrl(context, address)
}
val qr = remember(address, target) {
Qr.encode(
Pairing.pairingUrl(context, address),
target,
520,
android.graphics.Color.parseColor("#101216"),
)
}
Image(qr.asImageBitmap(), contentDescription = "Einrichtungs-QR", modifier = Modifier.size(230.dp))
Image(
qr.asImageBitmap(),
contentDescription = if (state.welcomeLoginUrl.isEmpty()) {
"Einrichtungs-QR"
} else {
"Anmelde-QR"
},
modifier = Modifier.size(230.dp),
)
}
Spacer(Modifier.height(12.dp))
Text(
Pairing.remoteUrl(address),
// Both fallbacks in one line: the address for a phone
// whose camera will not scan, and the code the remote
// asks for when the QR was not what opened it.
if (state.welcomeLoginUrl.isEmpty()) {
"${Pairing.remoteUrl(address)} · Code ${Pairing.code(context)}"
} else {
"Scannen und bestätigen — oder Code ${state.welcomeUserCode} eingeben"
},
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp,
)
}

View File

@@ -18,15 +18,18 @@ object UpdateChecker {
private const val TAG = "UpdateChecker"
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
// an orphan branch instead — anonymously fetchable on a public repo.
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 = ""
/** Guards against a second click landing on the same download. */
private val busy = java.util.concurrent.atomic.AtomicBoolean(false)
/** Returns the newer version tag, or null when current. Never throws. */
suspend fun check(state: AppState): String? = withContext(Dispatchers.IO) {
runCatching {
@@ -42,7 +45,7 @@ object UpdateChecker {
}
}
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
val version = "v$tag"
withContext(Dispatchers.Main) { state.updateAvailable = version }
@@ -54,48 +57,69 @@ object UpdateChecker {
}.onFailure { Log.w(TAG, "check failed: ${it.javaClass.simpleName}") }.getOrNull()
}
/** Downloads the APK and hands it to the package installer. */
/**
* Downloads the APK and hands it to the package installer.
*
* The download lands in a .part file that is only renamed once the byte
* count matches what the server announced. Handing a half-written APK to
* the installer leaves it sitting on a spinner with nothing to report,
* which is indistinguishable from a hang.
*/
suspend fun downloadAndInstall(context: Context, state: AppState): String? =
withContext(Dispatchers.IO) {
// A second click while the first download runs would have two
// writers on one file, and the installer reads whichever half won.
if (!busy.compareAndSet(false, true)) return@withContext "Update läuft bereits"
try {
runCatching {
if (apkUrl.isEmpty()) check(state)
require(apkUrl.isNotEmpty()) { "no update available" }
val dir = File(context.cacheDir, "updates").apply { mkdirs() }
val file = File(dir, "castarr-update.apk")
(URL(apkUrl).openConnection() as HttpURLConnection).run {
connectTimeout = 15_000
readTimeout = 120_000
instanceFollowRedirects = true
inputStream.use { input -> file.outputStream().use { input.copyTo(it) } }
disconnect()
val part = File(dir, "castarr-update.apk.part")
part.delete()
val connection = URL(apkUrl).openConnection() as HttpURLConnection
val expected = try {
connection.connectTimeout = 15_000
connection.readTimeout = 120_000
connection.instanceFollowRedirects = true
val code = connection.responseCode
require(code == HttpURLConnection.HTTP_OK) { "http $code" }
val announced = connection.contentLengthLong
connection.inputStream.use { input ->
part.outputStream().use { output -> input.copyTo(output) }
}
announced
} finally {
connection.disconnect()
}
require(UpdateRules.isComplete(part.length(), expected)) {
"truncated: ${part.length()} of $expected"
}
file.delete()
require(part.renameTo(file)) { "rename failed" }
val uri = FileProvider.getUriForFile(
context, "${BuildConfig.APPLICATION_ID}.fileprovider", file,
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
addFlags(
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK
)
}
// Launching from the IO dispatcher stalled the installer on
// the first attempt — activities start from the main thread.
withContext(Dispatchers.Main) { context.startActivity(intent) }
null
}.getOrElse {
Log.w(TAG, "install failed: ${it.javaClass.simpleName}")
Log.w(TAG, "install failed: ${it.javaClass.simpleName}: ${it.message}")
File(context.cacheDir, "updates/castarr-update.apk.part").delete()
"Update fehlgeschlagen — später erneut versuchen"
}
} finally {
busy.set(false)
}
private fun isNewer(remote: String, local: String): Boolean {
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 {
@@ -103,7 +127,7 @@ object UpdateChecker {
return try {
connection.connectTimeout = 10_000
connection.readTimeout = 15_000
connection.setRequestProperty("Accept", "application/vnd.github+json")
connection.setRequestProperty("Accept", "application/json")
connection.inputStream.bufferedReader().use { it.readText() }
} finally {
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() }
}

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

@@ -0,0 +1,115 @@
#!/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")
p.add_argument("--now", default=None,
help="base time as YYYY-mm-ddTHH:MM:SS; defaults to this "
"machine's clock. An emulator restored from a snapshot "
"runs on the clock it was saved with, so pass its time "
"here or every programme lands in the wrong hour")
args = p.parse_args()
if args.now:
now = datetime.datetime.strptime(args.now, "%Y-%m-%dT%H:%M:%S").astimezone()
else:
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}")

View File

@@ -0,0 +1,78 @@
# Dispatcharr-Pfad: Ablauf vor einem Release
Was hier steht, deckt kein Test ab: Anmeldung, Backend-Abruf und alles, was
ein echtes Konto braucht. `tests/smoke.sh` hilft dabei nicht — er
deinstalliert die App und damit die Anmeldung.
Dauer: etwa zehn Minuten. Am Emulator oder auf einem echten Fernseher.
## Vorbereitet
- Emulator läuft (`tests/helpers/emulator.sh start`)
- Aktuelles APK installiert
- Jemand ist erreichbar, der die Anmeldung im Identity Provider bestätigen
kann — der Gerätecode lebt derzeit 60 Sekunden (siehe Issue #23)
## 1. Anmelden
- [ ] App zurücksetzen: `adb shell pm clear dev.castarr.tv`
- [ ] App starten — der Willkommensbildschirm zeigt QR, Adresse **und** den
vierstelligen Kopplungscode
- [ ] Fernbedienung am Handy öffnen, Server eintragen
- [ ] Der Fernseher zeigt „Anmeldung am Handy bestätigen" mit Code
- [ ] Code bestätigen; der Fernseher wechselt von allein in die Senderliste
- [ ] Läuft der Code ab, holt der Fernseher selbstständig einen neuen —
er darf **nicht** kommentarlos auf Schritt 1 zurückfallen
## 2. Senderliste
- [ ] Die Zahl neben „Alle Sender" entspricht der Senderzahl im Backend
- [ ] Sender tragen Logos und Namen aus dem Backend
- [ ] Sender mit Programm zeigen es rechts, Sender ohne zeigen ihre Gruppe
- [ ] Die Kopfleiste zeigt **keinen** Hinweis „Server nicht erreichbar"
- [ ] Sendertasten springen zum nächsten Anfangsbuchstaben
- [ ] Rechts auf einer Senderzeile öffnet den Tagesplan; keine Sendung steht
doppelt drin, höchstens eine ist als „läuft" markiert
## 3. Favoriten (die Stelle, an der es zuletzt still kaputt war)
- [ ] „★ Favoriten" zeigt die Zahl, die das Backend kennt — nicht 0
- [ ] Langer Druck auf einen Sender setzt den Stern, die Zahl steigt sofort
- [ ] Erneuter langer Druck entfernt ihn, die Zahl sinkt
- [ ] App neu starten: die Favoriten sind noch da (sie kommen vom Backend)
- [ ] Am Handy: derselbe Stern, dieselbe Zahl
## 4. Vereinsmenüs
- [ ] Für einen Verein mit Spiel heute steht ein Menü mit Kurznamen in der
Leiste („Stuttgart", nicht „1893")
- [ ] Läuft ein Spiel, steht „läuft" im Chip, sonst „ab HH:MM"
- [ ] In der Vereinsansicht steht die Paarung, nicht der EPG-Rohtitel —
und der Vereinsname ist vollständig zu lesen
- [ ] Ein Titel ohne Paarung („Vereinsprofil VfB Stuttgart") wird unverändert
gezeigt, nicht zu einer Begegnung verbogen
## 5. Wiedergabe und Profile
- [ ] Ein Sender startet und zeigt Bild
- [ ] Sendertasten hoch/runter wechseln den Sender
- [ ] Die Sender-Zurück-Taste springt zum vorherigen Sender
- [ ] Einstellungen → Stream-Qualität listet die Profile des Backends
- [ ] Ein anderes Profil auswählen, Sender neu starten — er läuft weiter
## 6. Abmelden
- [ ] Einstellungen → Abmelden führt zurück zum Willkommensbildschirm
- [ ] Nach erneutem Start bleibt der Willkommensbildschirm stehen
(keine Senderliste aus dem Zwischenspeicher)
## Wenn etwas hakt
Logcat mitlesen:
```
adb -s emulator-5554 logcat -s DispatcharrRepo DeviceAuth Onboarding
```
Fehler beim Abruf tauchen dort auf; ein stiller Fehlschlag ohne Logzeile ist
selbst ein Befund und gehört als Issue aufgeschrieben.

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

View File

@@ -0,0 +1,44 @@
# Onboarding: Beobachtungsbogen
Für Issue #21. Eine Person, die Castarr nicht kennt, richtet den Fernseher
ein. Du sitzt daneben und **sagst nichts** — auch nicht, wenn es weh tut.
Notieren, nicht helfen. Erst wenn jemand endgültig feststeckt, abbrechen und
den Punkt aufschreiben.
## Vorbereitung (vor dem Termin, ohne Publikum)
- [ ] Aktuelles Release auf dem Fernseher installiert
- [ ] App zurückgesetzt, sodass der Willkommensbildschirm kommt
- [ ] Jemand ist erreichbar, der die Anmeldung im Identity Provider
bestätigen kann — oder die testende Person hat selbst ein Konto
- [ ] Handy der testenden Person ist im selben WLAN
- [ ] Uhrzeit notieren, wenn es losgeht
## Was zu notieren ist
Keine Bewertung, nur was passiert ist.
| Schritt | Beobachtung |
|---|---|
| QR entdeckt? | scannt / tippt Adresse ab / sucht Anleitung |
| Kamera-App oder Handy-Browser? | |
| Erste Reaktion auf „Server angeben" | |
| Was wurde eingetippt? | |
| Anmeldung: Code rechtzeitig bestätigt? | ja / abgelaufen (wie oft) |
| Hat der Hinweis „Der alte Code war abgelaufen" geholfen? | |
| Wo wurde gezögert (länger als ~10 s)? | |
| Wo wurde laut gefragt? | |
| Ende: Senderliste erreicht? | ja / nein, hängen geblieben bei … |
| Gesamtdauer | |
## Anschließend fragen
- Was war der unklarste Moment?
- Was hättest du erwartet, das nicht passiert ist?
- Würdest du das noch mal allein machen?
## Was daraus wird
Jede Zeile mit einer Beobachtung, die nicht „lief glatt" heißt, wird ein
Issue. Erst wenn jemand ohne Zuruf bei der Senderliste landet, ist #21
erledigt — beim ersten Versuch, nicht beim dritten.

172
tests/smoke.sh Executable file
View File

@@ -0,0 +1,172 @@
#!/usr/bin/env bash
# Smoke test on the headless Google TV emulator.
#
# Builds the debug APK, seeds the demo playlist as its source, installs it
# and walks from the channel list into the player with the D-pad. Fails on
# anything the unit tests cannot see: a crash on startup, a crash while
# navigating, an ANR, or a channel list that stayed empty. 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"
DEMO_PORT="${CASTARR_DEMO_PORT:-8099}"
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"
# The player retries the fake demo stream for as long as it is on screen, so
# without this the request log grows by a few hundred KB per run.
: > "$RUNS/http.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 "Demo-Quelle bereitstellen"
# The emulator restores its snapshot with the clock frozen at save time, so
# the demo programme has to be built around *its* idea of now, not ours.
EMU_NOW="$("$ADB" -s "$SERIAL" shell date +%Y-%m-%dT%H:%M:%S | tr -d '\r')"
echo " Emulator-Uhr: $EMU_NOW" | tee -a "$LOG"
python3 "$ROOT/tests/demo/make-demo-data.py" --now "$EMU_NOW" >>"$LOG" 2>&1 ||
fail "Demo-Daten fehlgeschlagen"
# The emulator reaches the host at 10.0.2.2, which is the address baked into
# the demo playlist.
# A leftover server from an earlier run happily answers on this port while
# serving a directory that no longer exists — every request a 404, and the
# wait below would spin forever.
if ss -ltn "sport = :$DEMO_PORT" 2>/dev/null | grep -q LISTEN; then
fail "Port $DEMO_PORT ist belegt: $(ss -ltnp "sport = :$DEMO_PORT" 2>/dev/null | tail -1)"
fi
# 10.0.2.2 inside the emulator is the host loopback, so binding there is enough.
(cd "$ROOT/tests/demo" && exec python3 -m http.server "$DEMO_PORT" --bind 127.0.0.1) \
>>"$RUNS/http.log" 2>&1 &
DEMO_PID=$!
trap 'kill "$DEMO_PID" 2>/dev/null || true' EXIT
for _ in $(seq 1 50); do
curl -sf "http://127.0.0.1:$DEMO_PORT/playlist.m3u" -o /dev/null && break
sleep 0.2
done
curl -sf "http://127.0.0.1:$DEMO_PORT/playlist.m3u" -o /dev/null ||
fail "Demo-Server antwortet nicht auf Port $DEMO_PORT"
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"
# Onboarding runs on a phone (ADR-0007), which a test has no way to be, so
# the source is seeded straight into the preferences the first run reads.
# Only a debug build allows this, which is the build under test.
"$ADB" -s "$SERIAL" shell run-as "$PKG" sh -c "'mkdir -p shared_prefs && cat > shared_prefs/source.xml'" <<XML || fail "Quelle konnte nicht gesetzt werden"
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<string name="m3u_url">http://10.0.2.2:$DEMO_PORT/playlist.m3u</string>
<string name="epg_url">http://10.0.2.2:$DEMO_PORT/epg.xml</string>
</map>
XML
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
# Reaching the channel list is the point: it is the screen the demo source
# feeds, and a blank one means the playlist never arrived.
step "Senderliste prüfen"
DUMP="$RUNS/ui-dump.xml"
"$ADB" -s "$SERIAL" shell uiautomator dump /sdcard/ui.xml >>"$LOG" 2>&1 || true
"$ADB" -s "$SERIAL" pull /sdcard/ui.xml "$DUMP" >>"$LOG" 2>&1 || true
if grep -q "Blau TV HD" "$DUMP" 2>/dev/null; then
echo " Sender aus der Demo-Playlist sichtbar"
else
fail "Senderliste zeigt die Demo-Playlist nicht — Dump in $DUMP"
fi
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 "Wiedereintritt"
"$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
Abgedeckt: Start mit gesetzter Quelle, Senderliste aus der Demo-Playlist,
D-Pad bis in den Player, Wiedereintritt. Nicht abgedeckt: echte Wiedergabe
(die Demo-Streams sind Attrappen), Onboarding am Handy, Kopplung und alles,
was ein Dispatcharr-Backend braucht.
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,47 @@
package dev.castarr.tv
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class CrashLogTest {
@Test
fun `an entry names the time, the version and the top of the stack`() {
val error = IllegalStateException("Senderliste leer")
// 26.08.2026, 19:12 (MESZ)
val entry = CrashLog.render(1787764320000L, "0.12.0", error)
val lines = entry.lines()
assertTrue(lines.first().contains("0.12.0"))
assertTrue(lines.first().contains("26.08.2026"))
assertTrue(entry.contains("IllegalStateException"))
assertTrue(entry.contains("Senderliste leer"))
}
@Test
fun `a long stack is cut down to something readable`() {
val error = RuntimeException("tief")
val entry = CrashLog.render(0L, "0.12.0", error)
// Kopfzeile plus höchstens TRACE_LINES Zeilen Stack.
assertTrue(entry.lines().size <= CrashLog.TRACE_LINES + 1)
}
@Test
fun `only the newest entries are kept`() {
val entries = (1..15).map { "Absturz $it" }
val kept = CrashLog.trim(entries)
assertEquals(CrashLog.KEEP, kept.size)
assertEquals("Absturz 15", kept.last())
assertEquals("Absturz 6", kept.first())
}
@Test
fun `blank entries are dropped instead of counting`() {
assertEquals(listOf("echt"), CrashLog.trim(listOf("", " ", "echt")))
}
@Test
fun `nothing recorded stays nothing`() {
assertEquals(emptyList<String>(), CrashLog.trim(emptyList()))
}
}

View File

@@ -0,0 +1,145 @@
package dev.castarr.tv.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Shapes taken from the household's own Dispatcharr fork. The backend path
* had no coverage at all, and the first run against a real instance found
* three bugs — these are the shapes that produced them.
*/
class DispatcharrJsonTest {
private fun streamUrl(uuid: String) = "https://tv.example/proxy/ts/stream/$uuid"
private fun logoUrl(id: Int) = "https://tv.example/api/channels/logos/$id/cache/"
private val groups = mapOf(1 to "Free TV / HD+", 2 to "Sky Sport")
@Test
fun `a paginated page yields its channels`() {
val body = """
{"count": 2, "next": null, "results": [
{"id": 12, "uuid": "aaa", "name": "Das Erste", "effective_name": "Das Erste HD",
"channel_group_id": 1, "effective_logo_id": 7, "effective_tvg_id": "ard.de"},
{"id": 13, "uuid": "bbb", "name": "ZDF HD", "channel_group_id": 1, "logo_id": 8}
]}
""".trimIndent()
val channels = DispatcharrJson.parseChannels(body, groups, ::streamUrl, ::logoUrl)
assertEquals(2, channels.size)
val first = channels.first()
assertEquals("Das Erste HD", first.name)
assertEquals("Free TV / HD+", first.group)
assertEquals("ard.de", first.tvgId)
assertEquals(12, first.backendId)
assertEquals("https://tv.example/proxy/ts/stream/aaa", first.url)
assertEquals("https://tv.example/api/channels/logos/7/cache/", first.logo)
// Without an effective name the plain one is used, same for the logo.
assertEquals("ZDF HD", channels[1].name)
assertEquals("https://tv.example/api/channels/logos/8/cache/", channels[1].logo)
}
/** The proxy resolves by uuid; an integer id gives a 404. */
@Test
fun `a channel without uuid is dropped`() {
val body = """{"results": [{"id": 1, "uuid": "", "name": "Kaputt"}]}"""
assertTrue(DispatcharrJson.parseChannels(body, groups, ::streamUrl, ::logoUrl).isEmpty())
}
@Test
fun `a channel hidden from output is dropped`() {
val body = """
{"results": [{"id": 1, "uuid": "aaa", "name": "Intern", "hidden_from_output": true}]}
""".trimIndent()
assertTrue(DispatcharrJson.parseChannels(body, groups, ::streamUrl, ::logoUrl).isEmpty())
}
@Test
fun `an unknown group leaves the group empty instead of failing`() {
val body = """{"results": [{"id": 1, "uuid": "aaa", "name": "X", "channel_group_id": 99}]}"""
assertEquals("", DispatcharrJson.parseChannels(body, groups, ::streamUrl, ::logoUrl).first().group)
}
@Test
fun `a bare array works like a paginated page`() {
val body = """[{"id": 5, "uuid": "ccc", "name": "Bar"}]"""
assertEquals(1, DispatcharrJson.parseChannels(body, groups, ::streamUrl, ::logoUrl).size)
}
@Test
fun `the next page is followed only while there is one`() {
assertEquals(
"https://tv.example/api/channels/channels/?page=2",
DispatcharrJson.nextPage("""{"next": "https://tv.example/api/channels/channels/?page=2"}"""),
)
assertNull(DispatcharrJson.nextPage("""{"next": null}"""))
assertNull(DispatcharrJson.nextPage("""{"next": ""}"""))
assertNull(DispatcharrJson.nextPage("""[{"id": 1}]"""))
}
@Test
fun `groups come back as id to name`() {
val body = """{"results": [{"id": 1, "name": "Free TV / HD+"}, {"id": 2, "name": "Sky Sport"}]}"""
assertEquals(groups, DispatcharrJson.parseGroups(body))
}
@Test
fun `favourites are the ids under channels`() {
assertEquals(setOf(46, 118), DispatcharrJson.parseFavorites("""{"channels": [46, 118]}"""))
assertEquals(emptySet<Int>(), DispatcharrJson.parseFavorites("""{"channels": []}"""))
// A user who never starred anything gets an envelope without the key.
assertEquals(emptySet<Int>(), DispatcharrJson.parseFavorites("""{}"""))
}
@Test
fun `inactive output profiles are not offered`() {
val body = """
{"results": [
{"name": "raw", "is_active": true},
{"name": "alt", "is_active": false},
{"name": "default"}
]}
""".trimIndent()
assertEquals(listOf("raw", "default"), DispatcharrJson.parseProfiles(body))
}
@Test
fun `the epg grid is keyed by tvg id and sorted`() {
val body = """
{"data": [
{"tvg_id": "ard.de", "title": "Tagesschau",
"start_time": "2026-08-26T18:00:00Z", "end_time": "2026-08-26T18:15:00Z"},
{"tvg_id": "ard.de", "title": "Sportschau",
"start_time": "2026-08-26T17:00:00Z", "end_time": "2026-08-26T18:00:00Z"}
]}
""".trimIndent()
val grid = DispatcharrJson.parseEpgGrid(body)
assertEquals(listOf("Sportschau", "Tagesschau"), grid.getValue("ard.de").map { it.title })
}
@Test
fun `grid entries without a usable time or id are skipped`() {
val body = """
{"data": [
{"tvg_id": "", "title": "Ohne Sender",
"start_time": "2026-08-26T18:00:00Z", "end_time": "2026-08-26T18:15:00Z"},
{"tvg_id": "zdf.de", "title": "Kaputte Zeit",
"start_time": "kein Datum", "end_time": "2026-08-26T18:15:00Z"},
{"tvg_id": "zdf.de", "title": "Ende vor Start",
"start_time": "2026-08-26T18:15:00Z", "end_time": "2026-08-26T18:00:00Z"}
]}
""".trimIndent()
assertTrue(DispatcharrJson.parseEpgGrid(body).isEmpty())
}
/** The fork has shipped "data", "results" and a bare array over time. */
@Test
fun `all three grid envelopes are accepted`() {
val entry = """{"tvg_id": "ard.de", "title": "X",
"start_time": "2026-08-26T18:00:00Z", "end_time": "2026-08-26T18:15:00Z"}"""
listOf("""{"data": [$entry]}""", """{"results": [$entry]}""", """[$entry]""").forEach { body ->
assertEquals(1, DispatcharrJson.parseEpgGrid(body).getValue("ard.de").size)
}
}
}

View File

@@ -0,0 +1,74 @@
package dev.castarr.tv.data
import org.junit.Assert.assertEquals
import org.junit.Test
class DuplicateProgrammesTest {
private fun p(startMin: Int, stopMin: Int, title: String) =
Programme(startMin * 60_000L, stopMin * 60_000L, title)
/** The real case: one channel, one episode, three EPG entries. */
@Test
fun `repeats a few minutes apart collapse into one`() {
val collapsed = XmltvParser.collapseDuplicates(
listOf(
p(1090, 1140, "The Big Bang Theory"),
p(1095, 1145, "The Big Bang Theory"),
p(1120, 1170, "The Big Bang Theory"),
)
)
assertEquals(1, collapsed.size)
assertEquals(1090 * 60_000L, collapsed.first().start)
}
@Test
fun `a genuine repeat later in the evening survives`() {
val collapsed = XmltvParser.collapseDuplicates(
listOf(
p(1090, 1140, "Tagesschau"),
p(1300, 1320, "Tagesschau"),
)
)
assertEquals(2, collapsed.size)
}
@Test
fun `different programmes at the same time both stay`() {
val collapsed = XmltvParser.collapseDuplicates(
listOf(
p(1090, 1140, "Sportschau"),
p(1092, 1142, "Tagesschau"),
)
)
assertEquals(2, collapsed.size)
}
@Test
fun `a repeat inside the first slot collapses even beyond the tolerance`() {
val collapsed = XmltvParser.collapseDuplicates(
listOf(
p(1000, 1200, "Fußball: Konferenz"),
p(1100, 1300, "Fußball: Konferenz"),
)
)
assertEquals(1, collapsed.size)
}
@Test
fun `an unsorted list is handled in time order`() {
val collapsed = XmltvParser.collapseDuplicates(
listOf(
p(1095, 1145, "Two and a Half Men"),
p(1090, 1140, "Two and a Half Men"),
)
)
assertEquals(1, collapsed.size)
assertEquals(1090 * 60_000L, collapsed.first().start)
}
@Test
fun `an empty list stays empty`() {
assertEquals(emptyList<Programme>(), XmltvParser.collapseDuplicates(emptyList()))
}
}

104
tests/unit/FixtureTest.kt Normal file
View File

@@ -0,0 +1,104 @@
package dev.castarr.tv.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class FixtureTest {
@Test
fun `competition prefix and round suffix fall away`() {
assertEquals(
"Nordstadt - FC St. Pauli",
TeamFilters.fixture("Pokal: Nordstadt - FC St. Pauli, 1. Runde"),
)
}
@Test
fun `a bare pairing survives unchanged`() {
assertEquals(
"FC Bayern München - Borussia Dortmund",
TeamFilters.fixture("FC Bayern München - Borussia Dortmund"),
)
}
@Test
fun `en dash and vs count as separators`() {
assertEquals("Schalke - HSV", TeamFilters.fixture("2. Liga: Schalke HSV"))
assertEquals("Kiel - Rostock", TeamFilters.fixture("Kiel vs. Rostock"))
}
@Test
fun `trailing channel detail is dropped`() {
assertEquals(
"Werder - Union Berlin",
TeamFilters.fixture("Bundesliga: Werder - Union Berlin | live"),
)
}
/** A magazine about the club is a hit, but has no pairing to shorten. */
@Test
fun `a title without a pairing keeps the original`() {
assertNull(TeamFilters.fixture("FC St. Pauli: Der Rückblick"))
assertNull(TeamFilters.fixture("Sportschau"))
}
@Test
fun `a dangling separator is not a pairing`() {
assertNull(TeamFilters.fixture("Pokal: - , 1. Runde"))
}
/** Hyphenated club names must not be mistaken for the separator. */
@Test
fun `hyphens inside a name do not split`() {
assertEquals(
"Rot-Weiss Essen - Preußen Münster",
TeamFilters.fixture("3. Liga: Rot-Weiss Essen - Preußen Münster"),
)
}
// --- Titel aus echten EPG-Daten (Sky, DAZN, ran) ----------------------
@Test
fun `a real Sky title yields both clubs`() {
assertEquals(
"Bayer Leverkusen - VfB Stuttgart",
TeamFilters.fixture(
"BL: Bayer Leverkusen - VfB Stuttgart, tipico Topspiel der Woche, 16. Spieltag"
),
)
}
/** The one that gave "VfB Stuttgart - Saison 25-26" as a fixture. */
@Test
fun `a season is not an opponent`() {
assertNull(TeamFilters.fixture("BL: VfB Stuttgart - Saison 25-26"))
assertNull(TeamFilters.fixture("BL: Vereinsprofil VfB Stuttgart"))
}
@Test
fun `a trailing half is dropped, not treated as a third team`() {
assertEquals(
"Bundesliga Eröffnungsspiel FC Bayern München - VfB Stuttgart",
TeamFilters.fixture(
"ran Fußball: Bundesliga Eröffnungsspiel FC Bayern München - VfB Stuttgart - 1. Halbzeit"
),
)
}
@Test
fun `youth teams keep their suffix`() {
assertEquals(
"VfB Stuttgart U19 - SC Freiburg U19",
TeamFilters.fixture("Live DFB-Pokal Junioren: VfB Stuttgart U19 - SC Freiburg U19, 2. Runde"),
)
}
@Test
fun `a live prefix falls away with the competition`() {
assertEquals(
"VfB Stuttgart - Hamburger SV",
TeamFilters.fixture("LIVE: VfB Stuttgart - Hamburger SV"),
)
}
}

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