32 Commits

Author SHA1 Message Date
be-nj
7f1dff5661 Real club crests, and no more crash on "Verein hinzufügen"
Some checks failed
Build TV app / build (push) Failing after 5s
Crests are resolved from the German Wikipedia page summary at runtime and
cached per device, so nothing copyrighted is stored in the repository. The
coloured shield remains as the fallback while the badge loads or when a
club has none.

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

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

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

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

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

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

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

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

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

Completes #13.

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 02:14:48 +02:00
be-nj
7abd4b2a48 README: generic group names in screenshots, consistent captions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:55:15 +02:00
be-nj
f69ae2f437 README: screenshots as 2x2 grid
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:43:14 +02:00
be-nj
01a76bffcd README: welcome screen screenshot (QR and LAN address pixelated)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:42:49 +02:00
be-nj
13352994e4 README: all screenshots full width
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:42:05 +02:00
be-nj
6acbe77200 README: overlay screenshot (scene pick)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:41:16 +02:00
be-nj
7da6f5c69f README: brighter player screenshot
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:36:11 +02:00
be-nj
e66c8a863b Overlay readability on bright video: brighter times, translucent progress track
Some checks failed
Build TV app / build (push) Failing after 2s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:32:34 +02:00
be-nj
ca401e8ca3 README for humans: German, screenshots, install guide
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:30:29 +02:00
be-nj
844ea928ef Channel number in the player overlay (inline before the name)
Some checks failed
Build TV app / build (push) Failing after 3s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:27:50 +02:00
be-nj
95f8793938 Bare, larger channel logos (no tile box)
Some checks failed
Build TV app / build (push) Failing after 2s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:20:22 +02:00
be-nj
1757d8015d Move update source to Gitea (git.beckm4nn.net)
Some checks failed
Build TV app / build (push) Failing after 3s
Releases API for the version check; the APK itself is served from the
orphan 'apk' branch of the public repo (asset uploads need a token,
raw files do not). Transitional GitHub release carries this change so
existing installs hop over.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:16:38 +02:00
be-nj
7c1f8d2c8b Channel rows with real logos (Entwurf A), no focus scale clipping
- Rows carry the Dispatcharr channel logo in a small tile (initials as
  fallback), sit on a surface instead of transparent black, and get the
  accent focus border; the group subline is gone — the logo carries
  channel identity.
- Focus scale disabled on settings rows, picker options, rail items and
  channel rows: the grown surface got clipped by its parent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:12:07 +02:00
be-nj
3068eaea19 Picker with fullscreen scrim, framed QR tile
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:07:11 +02:00
be-nj
5b6cebd041 Settings: Wiedergabe card + picker dialogs instead of chip walls
- Quelle and Stream-Qualität move out of Konto into their own
  Wiedergabe card; Konto is account + Abmelden only.
- Choices open a TV-style picker dialog (focus starts on the current
  value, Back closes) with the current value + drawn caret shown at
  the row's trailing edge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:01:38 +02:00
be-nj
5de71193ad fix: settings option chips (blob layout), real gear icon, overlay EPG filter, installer on main thread
- SegmentedRow lays label above wrapping option chips instead of
  squeezing them into the trailing slot (long profile names collapsed
  the label and blew the pill up into a blob).
- Gear icon: thick ring + stubby teeth instead of sun rays.
- Player overlay filters EPG placeholders and next==now duplicates
  like the channel list.
- Updater: startActivity from Dispatchers.Main — firing it from the IO
  dispatcher stalled the first install attempt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 00:55:47 +02:00
be-nj
deca5bd0cb fix(live): left lands on the selected group, right enters a fresh group at the top
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 00:50:36 +02:00
be-nj
a4391740ec Settings redesign: cards with focusable rows, segmented controls, gear button
- Settings page follows the Google TV pattern: Konto / App / Handy-
  Fernbedienung cards, each row a full-width focus target with label,
  subtitle and trailing value.
- Quelle and Stream-Qualität are segmented controls switched with
  left/right inside the focused row (click cycles).
- Account row with initial avatar, Abmelden as red row, update row
  with version badge; Update installieren only when one is available.
- Top bar: drawn gear icon button replaces the text pill.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 00:48:14 +02:00
be-nj
11a8056ce2 fix(player): opening key-up no longer clicks the freshly focused pause button
The key-down that opens the overlay is consumed by the activity, but its
key-up arrived after the play button grabbed focus and clicked it —
instant pause right after the overlay appeared. The overlay now swallows
select-releases whose press it never saw.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 00:32:22 +02:00
be-nj
d98f277ec4 Channel numbers on the remote pad, EPG column redesign, calmer overlay
- Number keys type the stable channel number (position in Alle Sender,
  shown identically in every view) with a live feedback badge; commits
  after a short pause or as soon as no longer number can match.
- EPG cell is a left-aligned column with a fixed leading edge: title +
  time range, progress under the title, next line only when it differs;
  placeholder programmes (kein Programm / no information) count as no
  EPG data. Star hidden in the favorites view.
- Player overlay: no LIVE dot (reads as recording) and no status word
  while playing; state text only for paused/buffering/error.
- Settings pill gets a resting surface so it reads as a target.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 00:27:55 +02:00
be-nj
8d6958536d Icon-only transport buttons (play/pause/stop need no labels)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 00:17:39 +02:00
be-nj
ee721708dc Drawn transport icons instead of glyphs (Android renders those as emoji)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 00:10:05 +02:00
be-nj
b04240ffde Live screen: group rail layout, exclusive filtering, cleaner top bar
- Groups live in a scrollable rail on the left (Alle Sender, Favoriten,
  then Dispatcharr groups with counts); focusing one opens it like a
  menu — filters no longer stack additively.
- Channel list sits right of the rail with Now/Next and progress;
  group subline only shown in the Alle view.
- Top bar: wordmark plus one context pill (Einstellungen / Zurück zu
  Live) instead of two text tabs; Back returns to Live instead of
  quitting the app.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 00:07:23 +02:00
be-nj
00762b6cbb Player overlay: real transport buttons (pause/stop/audio), OK opens controls
OK now opens the overlay like every TV player; its buttons are ordinary
focusable pills (play/pause, stop, audio-track cycle). Back closes the
overlay first, then stops. Auto-hide timer restarts on any interaction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 00:05:25 +02:00
be-nj
b12bfdf6e8 Key-hint row in player overlay instead of on-screen buttons
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 23:59:11 +02:00
be-nj
0410eda9c9 Player overlay v2: now/next with programme progress, audio track switching
- Overlay shows current programme (title, time range, EPG progress) and
  what's next; resurfaces briefly on programme change, hides after 5s.
- Audio tracks: listed from the stream, switchable via D-pad left/right
  on live streams (seek keeps left/right on seekable media), via chips
  in the overlay and from the phone remote (set_audio message).
- Main UI font switches to Inter; Space Grotesk stays for the wordmark.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 23:54:02 +02:00
be-nj
e69c79061f Version 0.3.1 2026-08-25 23:42:16 +02:00
38 changed files with 2520 additions and 410 deletions

View File

@@ -8,20 +8,32 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 # Full URLs: this Gitea resolves bare action names against itself,
# where these actions do not exist.
- uses: https://github.com/actions/checkout@v4
- uses: actions/setup-java@v4 - uses: https://github.com/actions/setup-java@v4
with: with:
distribution: temurin distribution: temurin
java-version: "17" java-version: "17"
- uses: gradle/actions/setup-gradle@v4 - uses: https://github.com/gradle/actions/setup-gradle@v4
- name: Unit tests
run: ./gradlew testDebugUnitTest --stacktrace
- name: Build debug APK - name: Build debug APK
run: ./gradlew assembleDebug --stacktrace run: ./gradlew assembleDebug --stacktrace
- name: Upload APK - name: Upload APK
uses: actions/upload-artifact@v4 uses: https://github.com/actions/upload-artifact@v4
with: with:
name: castarr-debug-apk name: castarr-debug-apk
path: app/build/outputs/apk/debug/app-debug.apk path: app/build/outputs/apk/debug/app-debug.apk
- name: Upload test report
if: always()
uses: https://github.com/actions/upload-artifact@v4
with:
name: castarr-test-report
path: tests/runs/

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ local.properties
.idea/ .idea/
*.iml *.iml
.kotlin/ .kotlin/
tests/runs/

109
README.md
View File

@@ -1,49 +1,82 @@
# Castarr # Castarr
Native Google TV client for a [Dispatcharr](https://github.com/be-nj/Dispatcharr) **Live-TV auf dem Fernseher, eingerichtet in einer Minute — alles läuft übers Handy.**
backend: live IPTV with a full D-pad UI (Compose for TV), login via the OIDC
device flow (Authentik), per-user favorites, per-stream output profiles, and
an optional phone remote paired by QR code — served by the TV itself, no phone
app install.
## Features Castarr ist eine App für Google TV / Android TV. Auf dem Fernseher muss nie
etwas getippt werden: Zum Einrichten scannt man einmal einen QR-Code mit dem
Handy, meldet sich dort an — fertig. Danach zappt man mit der normalen
TV-Fernbedienung oder steuert alles bequem vom Handy aus.
- **Live TV** channel list with Now/Next EPG and progress, zapping via | Senderliste | Player |
channel/D-pad keys, fullscreen playback (ExoPlayer, HLS + MPEG-TS) with a | --- | --- |
minimal auto-hiding overlay | ![Senderliste](docs/screenshots/senderliste.png) | ![Player](docs/screenshots/player.png) |
- **Login without typing**: the TV shows a QR code and a user code; confirm on
your phone at your identity provider (OIDC device authorization grant). The
only thing entered on the TV is the Dispatcharr URL.
- **Dispatcharr source**: channels, groups and EPG via the fork's
Bearer-authenticated API; favorites are stored per user on the backend
(long-press a channel to star it, filter chip in the list)
- **Stream profiles**: pick Standard / Passthrough / audiofix / 720p (list
comes from the backend) — applied per stream request
- **Generic source fallback**: any M3U (+ optional XMLTV) URL works without a
login — ErsatzTV, Threadfin, Tunarr, plain playlists
- **Phone remote** (optional): scan the QR under Einstellungen; play/pause,
zapping, volume, channel list with Now/Next and favorite toggling in the
browser — no app install, rate-limited pairing code as fallback
- Physical remote keys and Google Assistant keep working (Media3
MediaSession, TV app quality guidelines TV-PC/TV-PP/TV-VC)
## Building | Nummerntasten | Einrichtung |
| --- | --- |
| ![Nummerntasten](docs/screenshots/nummerntasten.png) | ![Willkommens-Bildschirm](docs/screenshots/willkommen.png) |
Requires JDK 17+ and the Android SDK (platform 35). A JDK toolchain is ## Was die App kann
resolved automatically (foojay) if the host only has a JRE.
- **Senderliste mit Logos und Programm** — was gerade läuft, wie lange noch,
was danach kommt. Gruppen und Favoriten links, Sender rechts.
- **Zappen wie früher** — Nummerntasten auf der Fernbedienung schalten direkt
um (mit großer Anzeige, welcher Sender gleich kommt), hoch/runter blättert
durch die Sender.
- **Aufgeräumter Player** — OK-Taste zeigt Sendung, Zeiten und Fortschritt,
dazu Pause/Stopp-Buttons und Tonspur-Wahl, wenn der Sender mehrere hat.
Blendet sich von selbst wieder aus.
- **Handy als Fernbedienung** — QR-Code in den Einstellungen scannen, schon
hat man Senderliste, Suche, Lautstärke und Favoriten im Handy-Browser.
Keine App-Installation nötig.
- **Favoriten** — OK auf einem Sender gedrückt halten, Stern gesetzt. Die
Favoriten gelten pro Person und sind auf jedem Gerät dieselben.
- **Updates aus der App heraus** — unter Einstellungen → App genügt ein
Klick, wenn eine neue Version bereitsteht.
## Installation auf dem Fernseher
1. Auf dem Google TV die App **Downloader** installieren (oder einen anderen
Weg nutzen, eine APK-Datei zu öffnen).
2. Diese Adresse eingeben:
`git.beckm4nn.net/be-nj/castarr/raw/branch/apk/castarr.apk`
3. Installation bestätigen („Unbekannte Quellen" für Downloader erlauben,
wenn der Fernseher fragt).
4. Castarr starten und den drei Schritten auf dem Bildschirm folgen:
QR-Code scannen, auf dem Handy Server angeben und anmelden — den Rest
macht der Fernseher.
Danach hält sich die App über den eingebauten Updater selbst aktuell.
---
## Für Technikinteressierte
Native Kotlin-App (Jetpack Compose for TV, Media3/ExoPlayer für HLS und
MPEG-TS). Als Backend dient ein [Dispatcharr](https://github.com/be-nj/Dispatcharr)-Fork
mit OIDC-Unterstützung: Anmeldung per Device Authorization Grant (der
Fernseher zeigt QR + Code, bestätigt wird am Handy beim Identity Provider),
API-Zugriff per Bearer-Token, Favoriten und Ausgabeprofile pro Nutzer.
Ohne Dispatcharr funktioniert auch jede M3U-Playlist (+ optionales XMLTV)
als generische Quelle — ErsatzTV, Threadfin, Tunarr, einfache Playlists.
Die Handy-Fernbedienung serviert der Fernseher selbst (eingebetteter
HTTP/WebSocket-Server, Kopplung per QR-Token mit ratenlimitiertem
Zahlencode als Fallback). Physische Mediatasten und Google Assistant
funktionieren über eine Media3 MediaSession.
### Selbst bauen
JDK 17+ und Android SDK (Platform 35) erforderlich; eine passende
JDK-Toolchain wird automatisch aufgelöst (foojay).
``` ```
./gradlew assembleDebug # debug APK ./gradlew assembleDebug # Debug-APK
./gradlew assembleRelease # signed when CASTARR_STORE_* env vars are set ./gradlew assembleRelease # signiert, wenn CASTARR_STORE_*-Env gesetzt ist
``` ```
APK output: `app/build/outputs/apk/<variant>/`. Releases on the APK-Ausgabe: `app/build/outputs/apk/<variant>/`. Die Releases hier im Repo
[releases page](https://github.com/be-nj/castarr/releases) are signed; install sind signiert; über einem Debug-Build muss einmal deinstalliert werden
over an existing debug build requires uninstalling first (signature change). (Signaturwechsel). Updates bezieht die App über die Release-API dieses
Repos, die APK selbst liegt auf dem Branch `apk`.
## Backend Architektur-Notizen: [CONTEXT.md](CONTEXT.md) und [docs/adr/](docs/adr/).
The full feature set needs the Dispatcharr fork with OIDC support
(be-nj/Dispatcharr): its `/api/accounts/oidc/status/` endpoint hands the app
the issuer and public device-flow client id. Configuration lives in
[CONTEXT.md](CONTEXT.md) and [docs/adr/](docs/adr/).

View File

@@ -12,8 +12,8 @@ android {
applicationId = "dev.castarr.tv" applicationId = "dev.castarr.tv"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 6 versionCode = 28
versionName = "0.3.0" versionName = "0.10.2"
} }
// Release signing from environment (see ~/.keys/castarr-release.env on the // Release signing from environment (see ~/.keys/castarr-release.env on the
@@ -51,6 +51,20 @@ android {
} }
// Global layout rule: everything test-related lives under tests/.
sourceSets {
getByName("test") {
java.setSrcDirs(listOf("../tests/unit"))
}
}
testOptions {
unitTests.all {
it.reports.junitXml.outputLocation.set(file("../tests/runs/junit"))
it.reports.html.outputLocation.set(file("../tests/runs/junit-html"))
}
}
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true buildConfig = true
@@ -81,6 +95,8 @@ dependencies {
implementation("org.nanohttpd:nanohttpd:2.3.1") implementation("org.nanohttpd:nanohttpd:2.3.1")
implementation("org.nanohttpd:nanohttpd-websocket:2.3.1") implementation("org.nanohttpd:nanohttpd-websocket:2.3.1")
implementation("com.google.zxing:core:3.5.3") implementation("com.google.zxing:core:3.5.3")
implementation("io.coil-kt:coil-compose:2.7.0")
testImplementation("junit:junit:4.13.2")
} }
// Full JDK for javac via toolchain (host may only have a JRE); resolved by // Full JDK for javac via toolchain (host may only have a JRE); resolved by

View File

@@ -12,6 +12,8 @@
<application <application
android:allowBackup="true" android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules"
android:dataExtractionRules="@xml/data_extraction_rules"
android:banner="@drawable/tv_banner" android:banner="@drawable/tv_banner"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"

View File

@@ -71,6 +71,14 @@
.btn-main:disabled { opacity: .35; box-shadow: none; } .btn-main:disabled { opacity: .35; box-shadow: none; }
.volrow { display: flex; align-items: center; gap: 16px; padding: 0 8px; } .volrow { display: flex; align-items: center; gap: 16px; padding: 0 8px; }
.audiorow { display: none; flex-wrap: wrap; align-items: center; gap: 8px; padding: 14px 8px 0; }
.audiorow.on { display: flex; }
.audiorow .lbl { color: #6b717a; font-size: 11px; letter-spacing: 2px; margin-right: 4px; }
.achip {
border: 1px solid #23262b; background: transparent; color: #9aa0a8;
border-radius: 999px; padding: 7px 14px; font-size: 13px; font-family: inherit;
}
.achip.sel { background: #5fd4c4; border-color: #5fd4c4; color: #07110f; font-weight: 600; }
input[type=range] { flex: 1; appearance: none; -webkit-appearance: none; height: 3px; input[type=range] { flex: 1; appearance: none; -webkit-appearance: none; height: 3px;
border-radius: 2px; background: #1e2126; outline: none; } border-radius: 2px; background: #1e2126; outline: none; }
input[type=range]::-webkit-slider-thumb { appearance: none; -webkit-appearance: none; input[type=range]::-webkit-slider-thumb { appearance: none; -webkit-appearance: none;
@@ -210,6 +218,7 @@
<input type="range" id="volume" min="0" max="100" value="50" aria-label="Lautstärke"> <input type="range" id="volume" min="0" max="100" value="50" aria-label="Lautstärke">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#6b717a" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M15.5 8.5a5 5 0 010 7"/><path d="M18.4 5.6a9 9 0 010 12.8"/></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#6b717a" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M15.5 8.5a5 5 0 010 7"/><path d="M18.4 5.6a9 9 0 010 12.8"/></svg>
</div> </div>
<div class="audiorow" id="audiorow"><span class="lbl">TON</span></div>
</section> </section>
<!-- Sender --> <!-- Sender -->
@@ -311,6 +320,7 @@
ws.onclose = () => { ws.onclose = () => {
state.connected = false; state.authorized = false; state.connected = false; state.authorized = false;
renderConnection(); renderConnection();
state.ws = null;
setTimeout(connect, state.retryDelay); setTimeout(connect, state.retryDelay);
state.retryDelay = Math.min(state.retryDelay * 1.6, 10000); state.retryDelay = Math.min(state.retryDelay * 1.6, 10000);
}; };
@@ -331,7 +341,7 @@
if (msg.token) localStorage.setItem('castarr_token', msg.token); if (msg.token) localStorage.setItem('castarr_token', msg.token);
$('tv-name').textContent = msg.device || 'TV'; $('tv-name').textContent = msg.device || 'TV';
if (msg.status) { state.status = msg.status; } if (msg.status) { state.status = msg.status; }
if (msg.channels) { state.channels = msg.channels; } if (msg.channels) { state.channels = msg.channels.map((c, i) => { c._idx = i; return c; }); }
if (msg.extras) { state.extras = msg.extras; } if (msg.extras) { state.extras = msg.extras; }
state.playlistUrl = msg.playlistUrl || ''; state.playlistUrl = msg.playlistUrl || '';
$('pair-error').textContent = ''; $('pair-error').textContent = '';
@@ -349,10 +359,10 @@
case 'status': case 'status':
state.status = msg; state.status = msg;
renderStatus(); renderStatus();
renderChannels(); renderChannelsIfChanged();
break; break;
case 'channels': case 'channels':
state.channels = msg.channels || []; state.channels = (msg.channels || []).map((c, i) => { c._idx = i; return c; });
state.playlistUrl = msg.playlistUrl || ''; state.playlistUrl = msg.playlistUrl || '';
if (msg.extras) { state.extras = msg.extras; } if (msg.extras) { state.extras = msg.extras; }
renderChannels(); renderChannels();
@@ -411,6 +421,19 @@
if (!state.volumeDragging && typeof s.volume === 'number') { if (!state.volumeDragging && typeof s.volume === 'number') {
$('volume').value = Math.round(s.volume * 100); $('volume').value = Math.round(s.volume * 100);
} }
const tracks = s.audioTracks || [];
const row = $('audiorow');
row.classList.toggle('on', hasMedia && tracks.length > 1);
while (row.children.length > 1) row.removeChild(row.lastChild);
if (tracks.length > 1) {
tracks.forEach((label, i) => {
const chip = document.createElement('button');
chip.className = 'achip' + (i === s.audioSelected ? ' sel' : '');
chip.textContent = label;
chip.addEventListener('click', () => send({ type: 'set_audio', index: i }));
row.appendChild(chip);
});
}
} }
function renderChannels() { function renderChannels() {
@@ -424,6 +447,7 @@
$('setup').classList.add('open'); $('setup').classList.add('open');
return; return;
} }
list.innerHTML = '';
const favSet = new Set(state.extras.favorites || []); const favSet = new Set(state.extras.favorites || []);
const favOn = state.extras.favoritesSupported && state.favOnly; const favOn = state.extras.favoritesSupported && state.favOnly;
const shown = favOn ? filtered.filter((c) => favSet.has(c.backendId)) : filtered; const shown = favOn ? filtered.filter((c) => favSet.has(c.backendId)) : filtered;
@@ -440,9 +464,18 @@
}); });
list.appendChild(bar); list.appendChild(bar);
} }
if (!shown.length) {
const empty = document.createElement('div');
empty.className = 'empty';
empty.textContent = favOn
? 'Keine Favoriten — Stern auf einem Sender antippen.'
: 'Keine Sender gefunden.';
list.appendChild(empty);
return;
}
const frag = document.createDocumentFragment(); const frag = document.createDocumentFragment();
shown.slice(0, 500).forEach((c) => { shown.slice(0, 500).forEach((c) => {
const idx = state.channels.indexOf(c); const idx = c._idx;
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.className = 'chan' + (c.url === state.status.url || c.name === state.status.channel ? ' current' : ''); btn.className = 'chan' + (c.url === state.status.url || c.name === state.status.channel ? ' current' : '');
const num = document.createElement('span'); const num = document.createElement('span');
@@ -495,12 +528,27 @@
btn.addEventListener('click', () => playChannel(c)); btn.addEventListener('click', () => playChannel(c));
frag.appendChild(btn); frag.appendChild(btn);
}); });
list.innerHTML = '';
list.appendChild(frag); list.appendChild(frag);
if (state.playlistUrl) $('playlist-input').value = state.playlistUrl; // Never overwrite an input the user is typing in.
const pl = $('playlist-input');
if (state.playlistUrl && document.activeElement !== pl) pl.value = state.playlistUrl;
} }
function renderAll() { renderStatus(); renderChannels(); } // Rebuilding the list on every 2s status push swallowed taps that landed
// between touchstart and touchend. Only rebuild when something changed.
let listSignature = '';
function renderChannelsIfChanged() {
const sig = [
state.channels.length, state.searchTerm, state.favOnly,
(state.extras.favorites || []).join(','),
state.status.channel || '',
].join('|');
if (sig === listSignature) return;
listSignature = sig;
renderChannels();
}
function renderAll() { renderStatus(); listSignature = ''; renderChannels(); }
// --- actions --- // --- actions ---
function playChannel(c) { function playChannel(c) {
@@ -561,10 +609,17 @@
$('pair-btn').addEventListener('click', () => { $('pair-btn').addEventListener('click', () => {
const code = $('code-input').value.trim(); const code = $('code-input').value.trim();
if (code.length !== 4) { $('pair-error').textContent = 'Bitte 4 Ziffern eingeben.'; return; } if (!/^[0-9]{4}$/.test(code)) {
$('pair-error').textContent = 'Bitte 4 Ziffern eingeben.';
return;
}
// A fresh attempt must not inherit the backoff from earlier failures.
state.retryDelay = 1000;
$('pair-error').textContent = 'Verbinde…';
manualCode = code; manualCode = code;
$('pair-error').textContent = ''; $('pair-error').textContent = '';
if (state.ws) state.ws.close(); else connect(); // A closed socket ignores close(), so the retry has to reconnect.
if (state.ws && state.ws.readyState <= 1) state.ws.close(); else connect();
}); });
let toastTimer = null; let toastTimer = null;

View File

@@ -10,6 +10,7 @@ import androidx.compose.runtime.setValue
import dev.castarr.tv.auth.DeviceAuth import dev.castarr.tv.auth.DeviceAuth
import dev.castarr.tv.data.DispatcharrRepository import dev.castarr.tv.data.DispatcharrRepository
import dev.castarr.tv.data.NowNext import dev.castarr.tv.data.NowNext
import dev.castarr.tv.data.isEpgPlaceholder
import dev.castarr.tv.data.SourceRepository import dev.castarr.tv.data.SourceRepository
import dev.castarr.tv.player.PlayerController import dev.castarr.tv.player.PlayerController
import dev.castarr.tv.playlist.Channel import dev.castarr.tv.playlist.Channel
@@ -26,6 +27,7 @@ class AppState(
val dispatcharr: DispatcharrRepository, val dispatcharr: DispatcharrRepository,
) { ) {
private val prefs = context.getSharedPreferences("app", Context.MODE_PRIVATE) private val prefs = context.getSharedPreferences("app", Context.MODE_PRIVATE)
val crests = dev.castarr.tv.data.Crests(context)
var screen by mutableStateOf(Screen.LIVE) var screen by mutableStateOf(Screen.LIVE)
var playerVisible by mutableStateOf(false) var playerVisible by mutableStateOf(false)
@@ -39,6 +41,70 @@ class AppState(
var favoritesOnly by mutableStateOf(false) var favoritesOnly by mutableStateOf(false)
var groupFilter by mutableStateOf<String?>(null) var groupFilter by mutableStateOf<String?>(null)
/** Club shortcut currently open in the rail, if any. */
var activeTeam by mutableStateOf<String?>(null)
/**
* Club menus switched on for this TV. Seeded from the signed-in viewer's
* own club; every club can be toggled on in the settings, so a household
* can watch for more than one.
*/
var enabledTeams by mutableStateOf(loadEnabledTeams())
private set
private fun loadEnabledTeams(): Set<String> {
val stored = prefs.getStringSet("teams", null)
if (stored != null) return stored.toSet()
return dev.castarr.tv.data.TeamFilters.defaultKeysFor(auth.username).toSet()
}
/** Returns false when the cap of [TeamFilters.MAX_ACTIVE] is reached. */
fun toggleTeam(key: String): Boolean {
if (key !in enabledTeams &&
enabledTeams.size >= dev.castarr.tv.data.TeamFilters.MAX_ACTIVE
) {
return false
}
enabledTeams =
if (key in enabledTeams) enabledTeams - key else enabledTeams + key
prefs.edit().putStringSet("teams", enabledTeams).apply()
if (activeTeam == key && key !in enabledTeams) activeTeam = null
return true
}
var audioTracks by mutableStateOf<List<PlayerController.AudioTrack>>(emptyList())
private set
private var lastNowTitle: String? = null
/** 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)
/** Channel to restore focus to when the list comes back (#13). */
var lastWatched by mutableStateOf<Channel?>(null)
/**
* True while the list is being restored after playback. The group rail
* opens whatever gets focused, so without this the focus landing there
* would silently switch the view back to "Alle Sender".
*/
var restorePending by mutableStateOf(false)
private val reentryHandler = android.os.Handler(android.os.Looper.getMainLooper())
private var lastStoppedUrl: String = ""
private var lastStoppedAt: Long = 0L
/** Bumped on every interaction with the visible overlay to restart the
* auto-hide timer. */
var overlayPing by mutableLongStateOf(0L)
private set
fun pingOverlay() {
overlayVisible = true
overlayPing++
}
/** Phone-first onboarding progress (ADR-0007). */ /** Phone-first onboarding progress (ADR-0007). */
var welcomePhase by mutableStateOf(WelcomePhase.WAIT_PHONE) var welcomePhase by mutableStateOf(WelcomePhase.WAIT_PHONE)
var welcomeUserCode by mutableStateOf("") var welcomeUserCode by mutableStateOf("")
@@ -99,6 +165,29 @@ class AppState(
SourceMode.DISPATCHARR -> dispatcharr.nowNext(channel) SourceMode.DISPATCHARR -> dispatcharr.nowNext(channel)
} }
private fun upcoming(channel: Channel, windowMs: Long) = when (sourceMode) {
SourceMode.GENERIC -> source.upcoming(channel, windowMs)
SourceMode.DISPATCHARR -> dispatcharr.upcoming(channel, windowMs)
}
/**
* Channels showing the viewer's club within the scanned window, paired
* with the programme that matched — earliest kick-off first, so whatever
* is running right now sits on top.
*/
fun teamMatches(key: String): List<Pair<Channel, dev.castarr.tv.data.Programme>> {
val filter = dev.castarr.tv.data.TeamFilters.byKey(key) ?: return emptyList()
val window = dev.castarr.tv.data.TeamFilters.WINDOW_MS
return activeChannels()
.filter { dev.castarr.tv.data.TeamFilters.scansGroup(it.group) }
.mapNotNull { channel ->
upcoming(channel, window)
.firstOrNull { !isEpgPlaceholder(it.title) && filter.matches(it.title) }
?.let { channel to it }
}
.sortedBy { it.second.start }
}
fun refreshActive() { fun refreshActive() {
when (sourceMode) { when (sourceMode) {
SourceMode.GENERIC -> source.refresh() SourceMode.GENERIC -> source.refresh()
@@ -123,6 +212,11 @@ class AppState(
) )
} }
private companion object {
/** Grace period before re-opening the channel just closed. */
const val REENTRY_GRACE_MS = 2_500L
}
fun isOnline(): Boolean { fun isOnline(): Boolean {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
@@ -130,6 +224,8 @@ class AppState(
} }
fun startOnboarding() { fun startOnboarding() {
prefs.edit().remove("teams").apply()
activeTeam = null
auth.logout() auth.logout()
appError = AppError.NONE appError = AppError.NONE
welcomePhase = WelcomePhase.WAIT_PHONE welcomePhase = WelcomePhase.WAIT_PHONE
@@ -138,17 +234,41 @@ class AppState(
} }
fun play(channel: Channel) { fun play(channel: Channel) {
lastWatched = channel
currentChannel = channel currentChannel = channel
playerVisible = true playerVisible = true
// Re-opening the very channel that was just closed can hit the
// provider before it released the previous session, which comes back
// as its "Stream Offline" still image. Give it a moment.
val sinceStop = System.currentTimeMillis() - lastStoppedAt
val sameChannel = channel.url == lastStoppedUrl
if (sameChannel && sinceStop in 0 until REENTRY_GRACE_MS) {
val wait = REENTRY_GRACE_MS - sinceStop
playerState = "reconnecting"
reentryHandler.removeCallbacksAndMessages(null)
reentryHandler.postDelayed({
player.play(channel.url, channel.name, channel.group)
}, wait)
return
}
player.play(channel.url, channel.name, channel.group) player.play(channel.url, channel.name, channel.group)
} }
fun stopPlayback() { fun stopPlayback() {
restorePending = lastWatched != null
reentryHandler.removeCallbacksAndMessages(null)
lastStoppedUrl = currentChannel?.url.orEmpty()
lastStoppedAt = System.currentTimeMillis()
player.stop() player.stop()
playerVisible = false playerVisible = false
currentChannel = null currentChannel = null
} }
/** Retry the failed channel without leaving the player. */
fun retryPlayback() {
player.retryNow()
}
fun zap(direction: Int) { fun zap(direction: Int) {
val list = activeChannels() val list = activeChannels()
if (list.isEmpty()) return if (list.isEmpty()) return
@@ -158,13 +278,36 @@ class AppState(
} }
fun syncFromPlayer() { fun syncFromPlayer() {
val previous = playerState
playerState = player.state playerState = player.state
if (playerState != previous) overlayVisible = true
isLive = player.player.isCurrentMediaItemLive isLive = player.player.isCurrentMediaItemLive
positionMs = player.player.currentPosition.coerceAtLeast(0) positionMs = player.player.currentPosition.coerceAtLeast(0)
durationMs = player.player.duration.coerceAtLeast(0) durationMs = player.player.duration.coerceAtLeast(0)
if (!player.hasMedia && playerVisible) { audioTracks = player.audioTracks()
// Briefly resurface the overlay when the running programme changes.
val nowTitle = currentChannel?.let { nowNext(it).now?.title }
if (playerVisible && nowTitle != null && lastNowTitle != null && nowTitle != lastNowTitle) {
overlayVisible = true
}
lastNowTitle = nowTitle
// Keep the player on screen while an error or reconnect is pending —
// otherwise the failure silently drops the viewer back to the list.
if (!player.hasMedia && playerVisible &&
player.errorMessage == null && !player.reconnecting
) {
playerVisible = false playerVisible = false
currentChannel = null currentChannel = null
} }
if (player.errorMessage != null || player.reconnecting) overlayVisible = true
}
/** Steps to the next audio track (D-pad friendly: one key, cycles). */
fun cycleAudio(direction: Int) {
val tracks = audioTracks
if (tracks.size < 2) return
val current = tracks.indexOfFirst { it.selected }.coerceAtLeast(0)
player.selectAudio((current + direction + tracks.size) % tracks.size)
audioTracks = player.audioTracks()
} }
} }

View File

@@ -48,6 +48,7 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
) )
server = ControlServer(this, this) server = ControlServer(this, this)
server.startServer() server.startServer()
state.remoteAvailable = server.running
setContent { CastarrApp(state) } setContent { CastarrApp(state) }
lifecycleScope.launch { UpdateChecker.check(state) } lifecycleScope.launch { UpdateChecker.check(state) }
} }
@@ -115,6 +116,11 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
.put("duration", player.player.duration.coerceAtLeast(0)) .put("duration", player.player.duration.coerceAtLeast(0))
.put("volume", if (max > 0) vol.toDouble() / max else 0.0) .put("volume", if (max > 0) vol.toDouble() / max else 0.0)
.put("error", player.errorMessage ?: "") .put("error", player.errorMessage ?: "")
.put("reconnecting", player.reconnecting)
.put("audioTracks", org.json.JSONArray().also { arr ->
state.audioTracks.forEach { arr.put(it.label) }
})
.put("audioSelected", state.audioTracks.indexOfFirst { it.selected })
} }
override fun currentChannels(): List<Channel> = state.activeChannels() override fun currentChannels(): List<Channel> = state.activeChannels()
@@ -194,10 +200,26 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
// --- TV remote keys during playback (TV-PC / TV-PP) --- // --- TV remote keys during playback (TV-PC / TV-PP) ---
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
// Number pad: type the channel number shown in the list (position in
// "Alle Sender"), switch after a short pause or as soon as no longer
// number can match. Ignored on screens with text fields.
if (keyCode in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9 &&
state.screen != AppState.Screen.WELCOME && state.screen != AppState.Screen.ADVANCED
) {
onDigit(keyCode - KeyEvent.KEYCODE_0)
return true
}
if (state.playerVisible) { if (state.playerVisible) {
when (keyCode) { when (keyCode) {
// OK opens the control overlay (buttons take over from
// there via normal Compose focus); with the overlay open
// this key is consumed by the focused button and never
// reaches the activity.
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_DPAD_CENTER,
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_ENTER -> {
state.pingOverlay()
return true
}
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
state.player.toggle() state.player.toggle()
return true return true
@@ -224,18 +246,24 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
state.zap(1) state.zap(1)
return true return true
} }
// With the overlay open, left/right moves button focus and
// never reaches the activity. Hidden overlay: seek on
// seekable media, otherwise just bring the controls up.
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_LEFT,
KeyEvent.KEYCODE_MEDIA_REWIND -> { KeyEvent.KEYCODE_MEDIA_REWIND -> {
onSeek(-SEEK_STEP_SECONDS) if (state.player.player.isCurrentMediaItemSeekable) onSeek(-SEEK_STEP_SECONDS)
else state.pingOverlay()
return true return true
} }
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_RIGHT,
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
onSeek(SEEK_STEP_SECONDS) if (state.player.player.isCurrentMediaItemSeekable) onSeek(SEEK_STEP_SECONDS)
else state.pingOverlay()
return true return true
} }
KeyEvent.KEYCODE_BACK -> { KeyEvent.KEYCODE_BACK -> {
state.stopPlayback() if (state.overlayVisible) state.overlayVisible = false
else state.stopPlayback()
return true return true
} }
else -> Unit else -> Unit
@@ -244,6 +272,40 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
return super.onKeyDown(keyCode, event) return super.onKeyDown(keyCode, event)
} }
private val digitCommit = Runnable { commitDigits() }
private fun onDigit(digit: Int) {
if (state.digitBuffer.length >= 4) return
state.digitBuffer += digit.toString()
mainHandler.removeCallbacks(digitCommit)
val number = state.digitBuffer.toIntOrNull() ?: return commitDigits()
// Another digit could still form a valid number? Then wait for it.
if (number * 10 > state.activeChannels().size) commitDigits()
else mainHandler.postDelayed(digitCommit, DIGIT_COMMIT_MS)
}
private fun commitDigits() {
mainHandler.removeCallbacks(digitCommit)
val number = state.digitBuffer.toIntOrNull()
state.digitBuffer = ""
val channel = number?.let { state.activeChannels().getOrNull(it - 1) } ?: return
state.play(channel)
}
private fun cycleAudioWithOverlay(direction: Int) {
state.overlayVisible = true
state.cycleAudio(direction)
server.broadcastStatus()
}
override fun onSetAudio(index: Int) {
if (index < 0) return
state.player.selectAudio(index)
state.syncFromPlayer()
state.overlayVisible = true
server.broadcastStatus()
}
// --- lifecycle --- // --- lifecycle ---
override fun onStop() { override fun onStop() {
@@ -262,5 +324,6 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
private companion object { private companion object {
const val TICK_INTERVAL_MS = 2_000L const val TICK_INTERVAL_MS = 2_000L
const val SEEK_STEP_SECONDS = 10L const val SEEK_STEP_SECONDS = 10L
const val DIGIT_COMMIT_MS = 1_800L
} }
} }

View File

@@ -0,0 +1,54 @@
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, fetching it once per article. */
suspend fun urlFor(article: String): String? = withContext(Dispatchers.IO) {
prefs.getString(article, null)?.let { return@withContext it.ifEmpty { null } }
val resolved = runCatching { fetch(article) }
.onFailure { Log.w(TAG, "crest lookup failed: ${it.javaClass.simpleName}") }
.getOrNull()
// Remember misses too, so a club without a crest is not looked up
// again on every screen.
prefs.edit().putString(article, resolved.orEmpty()).apply()
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() }
} finally {
connection.disconnect()
}
}
private companion object {
const val TAG = "Crests"
const val SUMMARY = "https://de.wikipedia.org/api/rest_v1/page/summary/"
const val USER_AGENT = "Castarr TV (private use)"
}
}

View File

@@ -71,6 +71,12 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) {
fun nowNext(channel: Channel): NowNext = fun nowNext(channel: Channel): NowNext =
XmltvParser.nowNext(programmesByTvgId[channel.tvgId]) XmltvParser.nowNext(programmesByTvgId[channel.tvgId])
/** Programmes of this channel between now and now + [windowMs]. */
fun upcoming(channel: Channel, windowMs: Long): List<Programme> {
val now = System.currentTimeMillis()
return XmltvParser.programmesIn(programmesByTvgId[channel.tvgId], now, now + windowMs)
}
fun toggleFavorite(channel: Channel) { fun toggleFavorite(channel: Channel) {
if (channel.backendId == 0) return if (channel.backendId == 0) return
scope.launch { scope.launch {
@@ -126,7 +132,12 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) {
name = obj.optString("effective_name").ifEmpty { obj.optString("name") }, name = obj.optString("effective_name").ifEmpty { obj.optString("name") },
url = streamUrl(uuid), url = streamUrl(uuid),
group = groups[obj.optInt("channel_group_id")].orEmpty(), group = groups[obj.optInt("channel_group_id")].orEmpty(),
logo = "", // 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") }, tvgId = obj.optString("effective_tvg_id").ifEmpty { obj.optString("tvg_id") },
backendId = id, backendId = id,
streamKey = uuid, streamKey = uuid,

View File

@@ -82,13 +82,20 @@ class SourceRepository(context: Context) {
status.value = "" status.value = ""
} }
/** Now/Next for an M3U channel: match tvg-id first, then name. */ /** Programmes of this channel between now and now + [windowMs]. */
fun nowNext(channel: Channel): NowNext { fun upcoming(channel: Channel, windowMs: Long): List<Programme> {
val direct = channel.tvgId.takeIf { it.isNotEmpty() }?.let { programmes[it] } val now = System.currentTimeMillis()
val byName = direct ?: nameToEpgId[channel.name.lowercase()]?.let { programmes[it] } return XmltvParser.programmesIn(programmesFor(channel), now, now + windowMs)
return XmltvParser.nowNext(byName)
} }
private fun programmesFor(channel: Channel): List<Programme>? {
val direct = channel.tvgId.takeIf { it.isNotEmpty() }?.let { programmes[it] }
return direct ?: nameToEpgId[channel.name.lowercase()]?.let { programmes[it] }
}
/** Now/Next for an M3U channel: match tvg-id first, then name. */
fun nowNext(channel: Channel): NowNext = XmltvParser.nowNext(programmesFor(channel))
private fun loadCachedChannels() { private fun loadCachedChannels() {
runCatching { runCatching {
val cached = prefs.getString("channels_cache", null) ?: return val cached = prefs.getString("channels_cache", null) ?: return
@@ -96,17 +103,44 @@ class SourceRepository(context: Context) {
} }
} }
private fun open(url: String): HttpURLConnection = /**
(URL(url).openConnection() as HttpURLConnection).apply { * Only plain HTTP(S) is fetched, and redirects are followed manually so
* every hop can be checked again — a paired phone must not be able to
* point the TV at arbitrary internal services (SSRF).
*/
private fun open(url: String): HttpURLConnection {
val parsed = URL(url)
require(parsed.protocol.equals("http", true) || parsed.protocol.equals("https", true)) {
"unsupported scheme"
}
return (parsed.openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000 connectTimeout = 15_000
readTimeout = 60_000 readTimeout = 60_000
instanceFollowRedirects = true instanceFollowRedirects = true
} }
}
/**
* Reads at most [MAX_BYTES] and gives up after [MAX_TRANSFER_MS] overall.
* `readTimeout` alone only bounds a single read, so a server trickling
* bytes could grow the buffer until the app died.
*/
private fun download(url: String): String { private fun download(url: String): String {
val connection = open(url) val connection = open(url)
val deadline = System.currentTimeMillis() + MAX_TRANSFER_MS
return try { return try {
connection.inputStream.bufferedReader().use { it.readText() } val builder = StringBuilder()
connection.inputStream.bufferedReader().use { reader ->
val buffer = CharArray(8 * 1024)
while (true) {
val read = reader.read(buffer)
if (read < 0) break
builder.append(buffer, 0, read)
require(builder.length <= MAX_BYTES) { "playlist too large" }
require(System.currentTimeMillis() < deadline) { "download timed out" }
}
}
builder.toString()
} finally { } finally {
connection.disconnect() connection.disconnect()
} }
@@ -114,5 +148,7 @@ class SourceRepository(context: Context) {
private companion object { private companion object {
const val TAG = "SourceRepository" const val TAG = "SourceRepository"
const val MAX_BYTES = 24 * 1024 * 1024
const val MAX_TRANSFER_MS = 120_000L
} }
} }

View File

@@ -0,0 +1,143 @@
package dev.castarr.tv.data
import androidx.compose.ui.graphics.Color
/**
* Per-club shortcut to "where does my club play tonight": a rail entry that
* lists every channel whose EPG mentions the club within the next few hours.
* Crests are fetched from Wikipedia at runtime and cached on the device;
* the club colours are the fallback while that is pending or unavailable.
*/
data class TeamFilter(
val key: String,
/** Short caption next to the crest in the rail. */
val label: String,
/** Full club name, shown in the settings and in messages. */
val fullName: String,
/** Lowercase needles matched against programme titles. */
val needles: List<String>,
val primary: Color,
val secondary: Color,
/** German Wikipedia article the crest is resolved from. */
val article: String,
) {
fun matches(title: String): Boolean {
val haystack = title.lowercase()
return needles.any { haystack.contains(it) }
}
}
object TeamFilters {
/** Window scanned ahead of now. */
const val WINDOW_MS = 3 * 60 * 60 * 1000L
/** At most this many club groups sit in the rail at once. */
const val MAX_ACTIVE = 3
private const val WHITE = 0xFFF2F3F5
private const val BLACK = 0xFF15171B
private fun club(
key: String,
label: String,
fullName: String,
needles: List<String>,
primary: Long,
secondary: Long,
article: String = fullName,
) = TeamFilter(key, label, fullName, needles, Color(primary), Color(secondary), article)
/** Clubs of the top three German divisions. */
val all: List<TeamFilter> = listOf(
// --- Bundesliga ---
club("bayern", "FCB", "FC Bayern München", listOf("bayern münchen", "fc bayern"), 0xFFDC052D, WHITE),
club("bvb", "BVB", "Borussia Dortmund", listOf("borussia dortmund", "bvb"), 0xFFFDE100, BLACK),
club("leipzig", "RBL", "RB Leipzig", listOf("rb leipzig"), 0xFFDD0741, WHITE),
club("leverkusen", "B04", "Bayer 04 Leverkusen", listOf("leverkusen"), 0xFFE32219, BLACK),
club("frankfurt", "SGE", "Eintracht Frankfurt", listOf("eintracht frankfurt"), 0xFF1A1A1A, 0xFFE1000F),
club("stuttgart", "1893", "VfB Stuttgart", listOf("vfb stuttgart"), 0xFFE32219, WHITE),
club("gladbach", "BMG", "Borussia Mönchengladbach", listOf("mönchengladbach", "gladbach"), 0xFF1A1A1A, 0xFF00A94F),
club("wolfsburg", "WOB", "VfL Wolfsburg", listOf("wolfsburg"), 0xFF65B32E, WHITE),
club("bremen", "SVW", "SV Werder Bremen", listOf("werder bremen", "werder"), 0xFF1D9053, WHITE),
club("freiburg", "SCF", "SC Freiburg", listOf("sc freiburg", "freiburg"), 0xFFE2001A, WHITE),
club("hoffenheim", "TSG", "TSG Hoffenheim", listOf("hoffenheim"), 0xFF1C63B7, WHITE),
club("mainz", "M05", "1. FSV Mainz 05", listOf("mainz 05", "mainz"), 0xFFE1000F, WHITE),
club("augsburg", "FCA", "FC Augsburg", listOf("augsburg"), 0xFF00693F, 0xFFE1000F),
club("union", "FCU", "1. FC Union Berlin", listOf("union berlin"), 0xFFE1000F, 0xFFFDE100),
club("koeln", "EFC", "1. FC Köln", listOf("1. fc köln", "fc köln"), 0xFFE1000F, WHITE),
club("hsv", "HSV", "Hamburger SV", listOf("hamburger sv", "hsv"), 0xFF0E5EA6, BLACK),
club("heidenheim", "FCH1", "1. FC Heidenheim", listOf("heidenheim"), 0xFFE1000F, 0xFF1656A4),
club("st-pauli", "FCSP", "FC St. Pauli", listOf("st. pauli", "st pauli"), 0xFF6B4423, WHITE),
// --- 2. Bundesliga ---
club("schalke", "S04", "FC Schalke 04", listOf("schalke"), 0xFF004D9D, WHITE),
club("hertha", "BSC", "Hertha BSC", listOf("hertha"), 0xFF004D9D, WHITE),
club("duesseldorf", "F95", "Fortuna Düsseldorf", listOf("fortuna düsseldorf"), 0xFFE1000F, WHITE),
club("nuernberg", "FCN", "1. FC Nürnberg", listOf("nürnberg"), 0xFF8B1A1A, WHITE),
club("kaiserslautern", "FCK", "1. FC Kaiserslautern", listOf("kaiserslautern"), 0xFFE1000F, WHITE),
club("karlsruhe", "KSC", "Karlsruher SC", listOf("karlsruher sc", "ksc"), 0xFF0055A5, WHITE),
club("hannover", "H96", "Hannover 96", listOf("hannover 96"), 0xFF00A94F, WHITE),
club("paderborn", "SCP", "SC Paderborn 07", listOf("paderborn"), 0xFF004D9D, WHITE),
club("magdeburg", "FCM", "1. FC Magdeburg", listOf("magdeburg"), 0xFF0B7A3E, WHITE),
club("elversberg", "SVE", "SV Elversberg", listOf("elversberg"), 0xFF1A1A1A, 0xFFE1000F),
club("darmstadt", "SV98", "SV Darmstadt 98", listOf("darmstadt"), 0xFF004E9E, WHITE),
club("braunschweig", "BTSV", "Eintracht Braunschweig", listOf("braunschweig"), 0xFFFDE100, 0xFF004E9E),
club("bochum", "BOC", "VfL Bochum", listOf("bochum"), 0xFF005CA9, WHITE),
club("muenster", "SCPM", "Preußen Münster", listOf("preußen münster", "münster"), 0xFF00703C, WHITE),
club("fuerth", "SGF", "SpVgg Greuther Fürth", listOf("greuther fürth", "fürth"), 0xFF00A94F, WHITE),
club("holstein", "KSV", "Holstein Kiel", listOf("holstein kiel"), 0xFF004E9E, 0xFFE1000F),
club("dresden", "SGD", "Dynamo Dresden", listOf("dynamo dresden"), 0xFFFDE100, BLACK),
club("bielefeld", "DSC", "Arminia Bielefeld", listOf("arminia bielefeld", "bielefeld"), 0xFF00539F, WHITE),
// --- 3. Liga ---
club("hansa", "FCH", "FC Hansa Rostock", listOf("hansa"), 0xFF0B4EA2, WHITE),
club("saarbruecken", "FCS", "1. FC Saarbrücken", listOf("saarbrücken"), 0xFF0B4EA2, BLACK),
club("aue", "FCE", "Erzgebirge Aue", listOf("erzgebirge aue"), 0xFF7C0A02, WHITE),
club("cottbus", "FCEC", "Energie Cottbus", listOf("energie cottbus", "cottbus"), 0xFFE1000F, WHITE),
club("essen", "RWE", "Rot-Weiss Essen", listOf("rot-weiss essen", "rot weiss essen"), 0xFFE1000F, WHITE),
club("duisburg", "MSV", "MSV Duisburg", listOf("msv duisburg", "duisburg"), 0xFF004E9E, WHITE),
club("mannheim", "SVWM", "SV Waldhof Mannheim", listOf("waldhof mannheim", "waldhof"), 0xFF0B4EA2, WHITE),
club("wehen", "SVWW", "SV Wehen Wiesbaden", listOf("wehen wiesbaden", "wehen"), 0xFFE1000F, BLACK),
club("ulm", "SSV", "SSV Ulm 1846", listOf("ssv ulm"), 0xFFE1000F, WHITE),
club("regensburg", "SSVJ", "SSV Jahn Regensburg", listOf("jahn regensburg", "regensburg"), 0xFFE1000F, WHITE),
club("verl", "SCV", "SC Verl", listOf("sc verl"), 0xFF00703C, WHITE),
club("viktoria", "VKÖ", "Viktoria Köln", listOf("viktoria köln"), 0xFFE1000F, WHITE),
club("havelse", "TSVH", "TSV Havelse", listOf("havelse"), 0xFF004E9E, WHITE),
club("schweinfurt", "FC05", "1. FC Schweinfurt 05", listOf("schweinfurt"), 0xFF00A94F, WHITE),
club("osnabrueck", "VfLO", "VfL Osnabrück", listOf("osnabrück"), 0xFF6A2C8F, WHITE),
club("aachen", "ALE", "Alemannia Aachen", listOf("alemannia aachen", "aachen"), 0xFFFDE100, BLACK),
club("ingolstadt", "FCI", "FC Ingolstadt 04", listOf("ingolstadt"), 0xFFE1000F, BLACK),
club("wuppertal", "WSV", "Wuppertaler SV", listOf("wuppertaler sv"), 0xFFE1000F, 0xFF0B4EA2),
club("stuttgart-ii", "VfB2", "VfB Stuttgart II", listOf("vfb stuttgart ii"), 0xFFE32219, BLACK),
club("hoffenheim-ii", "TSG2", "TSG Hoffenheim II", listOf("hoffenheim ii"), 0xFF1C63B7, BLACK),
)
/** Clubs switched on for a viewer before they touch the settings. */
private val defaultForUser = mapOf(
"benjamin" to listOf("hansa"),
"tobiasb" to listOf("stuttgart"),
)
/**
* Only sport and free-to-air groups are scanned. Searching all 500
* channels would mostly turn up shopping and radio, and the club is
* never on those anyway.
*/
private val groupIncludes = listOf(
"sport", "dazn", "sky", "magenta", "prime", "free tv", "hd+",
"dyn", "del", "bundesliga", "fussball", "fußball", "at / ch", "at/ch",
)
/** Explicitly out of scope even though they match an include. */
private val groupExcludes = listOf("nfl")
fun scansGroup(group: String): Boolean {
val g = group.lowercase()
if (groupExcludes.any { g.contains(it) }) return false
return groupIncludes.any { g.contains(it) }
}
fun byKey(key: String): TeamFilter? = all.firstOrNull { it.key == key }
fun defaultKeysFor(username: String): List<String> =
defaultForUser[username.trim().lowercase()].orEmpty()
}

View File

@@ -9,6 +9,12 @@ import java.util.Locale
data class Programme(val start: Long, val stop: Long, val title: String) data class Programme(val start: Long, val stop: Long, val title: String)
/** Provider EPGs pad gaps with placeholder programmes — treat as no data. */
fun isEpgPlaceholder(title: String): Boolean {
val t = title.lowercase()
return "kein programm" in t || "keine information" in t || "no information" in t
}
data class NowNext(val now: Programme?, val next: Programme?) data class NowNext(val now: Programme?, val next: Programme?)
/** /**
@@ -99,6 +105,14 @@ object XmltvParser {
} }
} }
/** Programmes overlapping [from]..[to], in broadcast order. */
fun programmesIn(
programmes: List<Programme>?,
from: Long,
to: Long,
): List<Programme> =
programmes.orEmpty().filter { it.stop > from && it.start < to }
fun nowNext(programmes: List<Programme>?, at: Long = System.currentTimeMillis()): NowNext { fun nowNext(programmes: List<Programme>?, at: Long = System.currentTimeMillis()): NowNext {
if (programmes.isNullOrEmpty()) return NowNext(null, null) if (programmes.isNullOrEmpty()) return NowNext(null, null)
val index = programmes.indexOfFirst { at < it.stop } val index = programmes.indexOfFirst { at < it.stop }

View File

@@ -3,35 +3,84 @@ package dev.castarr.tv.pairing
import android.content.Context import android.content.Context
import java.net.Inet4Address import java.net.Inet4Address
import java.net.NetworkInterface import java.net.NetworkInterface
import java.security.MessageDigest
import java.security.SecureRandom import java.security.SecureRandom
object Pairing { object Pairing {
const val PORT = 8765 const val PORT = 8765
private const val PREFS = "pairing"
private const val KEY_CODE = "code"
private const val KEY_TOKEN = "token"
private const val KEY_SESSIONS = "session_tokens"
private const val MAX_SESSIONS = 8
private fun prefs(context: Context) =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
/** /**
* Four-digit pairing code, generated once and kept stable so an already * Four-digit pairing code, generated once and kept stable so an already
* paired phone survives app restarts. Human fallback only — the QR code * paired phone survives app restarts. Human fallback only — the QR code
* carries the long token below. * carries the long token below.
*/ */
fun code(context: Context): String { fun code(context: Context): String {
val prefs = context.getSharedPreferences("pairing", Context.MODE_PRIVATE) prefs(context).getString(KEY_CODE, null)?.let { return it }
prefs.getString("code", null)?.let { return it }
val code = "%04d".format(SecureRandom().nextInt(10_000)) val code = "%04d".format(SecureRandom().nextInt(10_000))
prefs.edit().putString("code", code).apply() prefs(context).edit().putString(KEY_CODE, code).apply()
return code return code
} }
/** 128-bit random token embedded in the QR code; not brute-forceable. */ /** 128-bit random token embedded in the QR code; not brute-forceable. */
fun token(context: Context): String { fun token(context: Context): String {
val prefs = context.getSharedPreferences("pairing", Context.MODE_PRIVATE) prefs(context).getString(KEY_TOKEN, null)?.let { return it }
prefs.getString("token", null)?.let { return it } val token = randomToken()
val bytes = ByteArray(16).also { SecureRandom().nextBytes(it) } prefs(context).edit().putString(KEY_TOKEN, token).apply()
val token = bytes.joinToString("") { "%02x".format(it) }
prefs.edit().putString("token", token).apply()
return token return token
} }
/**
* Issues a separate token for a client that authenticated with the
* four-digit code. Handing out the QR token there would make the
* brute-forceable path yield the un-rotating master credential.
*/
fun newSessionToken(context: Context): String {
val token = randomToken()
val sessions = sessionTokens(context).toMutableList()
sessions.add(token)
while (sessions.size > MAX_SESSIONS) sessions.removeAt(0)
prefs(context).edit().putStringSet(KEY_SESSIONS, sessions.toSet()).apply()
return token
}
/** Constant-time check against the QR token and all session tokens. */
fun isValidToken(context: Context, candidate: String): Boolean {
if (candidate.isEmpty()) return false
if (constantTimeEquals(candidate, token(context))) return true
return sessionTokens(context).any { constantTimeEquals(candidate, it) }
}
fun isValidCode(context: Context, candidate: String): Boolean =
candidate.isNotEmpty() && constantTimeEquals(candidate, code(context))
/**
* Drops every credential: the QR token, the code and all session tokens.
* Paired phones must scan again — the way to revoke a leaked token.
*/
fun reset(context: Context) {
prefs(context).edit().clear().apply()
}
private fun sessionTokens(context: Context): List<String> =
prefs(context).getStringSet(KEY_SESSIONS, emptySet())?.toList().orEmpty()
private fun randomToken(): String =
ByteArray(16).also { SecureRandom().nextBytes(it) }
.joinToString("") { "%02x".format(it) }
private fun constantTimeEquals(a: String, b: String): Boolean =
MessageDigest.isEqual(a.toByteArray(), b.toByteArray())
/** Best-guess LAN IPv4 address of this device, or null when offline. */ /** Best-guess LAN IPv4 address of this device, or null when offline. */
fun lanAddress(): String? { fun lanAddress(): String? {
val candidates = runCatching { val candidates = runCatching {

View File

@@ -1,12 +1,16 @@
package dev.castarr.tv.player package dev.castarr.tv.player
import android.content.Context import android.content.Context
import androidx.media3.common.C
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import java.util.Locale
/** /**
* Wraps ExoPlayer plus a MediaSession so the physical TV remote's play/pause * Wraps ExoPlayer plus a MediaSession so the physical TV remote's play/pause
@@ -19,7 +23,10 @@ class PlayerController(
) { ) {
val player: ExoPlayer = ExoPlayer.Builder(context).build() val player: ExoPlayer = ExoPlayer.Builder(context).build()
private val mediaSession: MediaSession = MediaSession.Builder(context, player).build() // Explicit id: Media3 keeps a process-wide registry and throws when a
// session with the same (default, empty) id is still registered.
private val mediaSession: MediaSession =
MediaSession.Builder(context, player).setId("castarr").build()
var channelName: String = "" var channelName: String = ""
private set private set
@@ -29,6 +36,19 @@ class PlayerController(
private set private set
private var retryCount = 0 private var retryCount = 0
private var lastUrl: String = "" private var lastUrl: String = ""
private val retryHandler = android.os.Handler(android.os.Looper.getMainLooper())
private var pendingRetry: Runnable? = null
/** True while a reconnect attempt is scheduled or running. */
var reconnecting: Boolean = false
private set
/** When the current channel was opened — used to pace re-entry. */
var startedAt: Long = 0L
private set
/** One selectable audio track of the current stream. */
data class AudioTrack(val label: String, val selected: Boolean)
init { init {
player.addListener(object : Player.Listener { player.addListener(object : Player.Listener {
@@ -36,15 +56,21 @@ class PlayerController(
override fun onIsPlayingChanged(isPlaying: Boolean) = onChanged() override fun onIsPlayingChanged(isPlaying: Boolean) = onChanged()
override fun onTracksChanged(tracks: Tracks) = onChanged()
override fun onPlayerError(error: PlaybackException) { override fun onPlayerError(error: PlaybackException) {
// Self-heal: silently reconnect twice before surfacing (#13). // Self-heal with spacing: providers rate-limit reconnects, so
if (retryCount < 2 && lastUrl.isNotEmpty()) { // three back-to-back attempts all land in the same blocked
// window and burn the budget in under a second.
if (retryCount < RETRY_DELAYS_MS.size && lastUrl.isNotEmpty()) {
val delay = RETRY_DELAYS_MS[retryCount]
retryCount++ retryCount++
player.setMediaItem(MediaItem.fromUri(lastUrl)) reconnecting = true
player.prepare() onChanged()
player.play() scheduleRetry(delay)
return return
} }
reconnecting = false
errorMessage = "unreachable" errorMessage = "unreachable"
onChanged() onChanged()
} }
@@ -54,6 +80,7 @@ class PlayerController(
val state: String val state: String
get() = when { get() = when {
errorMessage != null -> "error" errorMessage != null -> "error"
reconnecting -> "reconnecting"
player.playbackState == Player.STATE_BUFFERING -> "buffering" player.playbackState == Player.STATE_BUFFERING -> "buffering"
player.playbackState == Player.STATE_READY && player.playWhenReady -> "playing" player.playbackState == Player.STATE_READY && player.playWhenReady -> "playing"
player.playbackState == Player.STATE_READY -> "paused" player.playbackState == Player.STATE_READY -> "paused"
@@ -65,9 +92,12 @@ class PlayerController(
fun play(url: String, name: String, group: String) { fun play(url: String, name: String, group: String) {
if (url.isEmpty()) return if (url.isEmpty()) return
cancelRetry()
errorMessage = null errorMessage = null
reconnecting = false
retryCount = 0 retryCount = 0
lastUrl = url lastUrl = url
startedAt = System.currentTimeMillis()
channelName = name.ifEmpty { url } channelName = name.ifEmpty { url }
channelGroup = group channelGroup = group
val item = MediaItem.Builder() val item = MediaItem.Builder()
@@ -79,13 +109,45 @@ class PlayerController(
player.play() player.play()
} }
private fun scheduleRetry(delayMs: Long) {
cancelRetry()
val runnable = Runnable {
pendingRetry = null
player.setMediaItem(MediaItem.fromUri(lastUrl))
player.prepare()
player.play()
}
pendingRetry = runnable
retryHandler.postDelayed(runnable, delayMs)
}
private fun cancelRetry() {
pendingRetry?.let { retryHandler.removeCallbacks(it) }
pendingRetry = null
}
/** Retries the current channel immediately, resetting the backoff. */
fun retryNow() {
if (lastUrl.isEmpty()) return
cancelRetry()
retryCount = 0
errorMessage = null
reconnecting = true
onChanged()
player.setMediaItem(MediaItem.fromUri(lastUrl))
player.prepare()
player.play()
}
fun toggle() { fun toggle() {
if (!hasMedia) return if (!hasMedia) return
if (player.isPlaying) player.pause() else resume() // playWhenReady, not isPlaying: the latter is false while buffering,
// which made pausing during a stall silently do nothing.
if (player.playWhenReady) pause() else resume()
} }
fun pause() { fun pause() {
if (player.isPlaying) player.pause() player.playWhenReady = false
} }
fun resume() { fun resume() {
@@ -95,6 +157,8 @@ class PlayerController(
} }
fun stop() { fun stop() {
cancelRetry()
reconnecting = false
player.stop() player.stop()
player.clearMediaItems() player.clearMediaItems()
channelName = "" channelName = ""
@@ -105,15 +169,66 @@ class PlayerController(
onChanged() onChanged()
} }
/** Audio tracks of the current stream, in group order. */
fun audioTracks(): List<AudioTrack> {
val result = mutableListOf<AudioTrack>()
val seen = mutableSetOf<String>()
player.currentTracks.groups
.filter { it.type == C.TRACK_TYPE_AUDIO }
.forEach { group ->
for (i in 0 until group.length) {
if (!group.isTrackSupported(i)) continue
val format = group.getTrackFormat(i)
var label = format.label
?: format.language?.let { Locale(it).getDisplayLanguage(Locale.GERMAN) }
?.replaceFirstChar { c -> c.uppercase() }
?: "Ton ${result.size + 1}"
if (!seen.add(label)) label = "$label ${result.size + 1}"
result.add(AudioTrack(label, group.isTrackSelected(i)))
}
}
return result
}
/** Selects the audio track at [index] of [audioTracks]'s order. */
fun selectAudio(index: Int) {
var flat = 0
player.currentTracks.groups
.filter { it.type == C.TRACK_TYPE_AUDIO }
.forEach { group ->
for (i in 0 until group.length) {
if (!group.isTrackSupported(i)) continue
if (flat == index) {
player.trackSelectionParameters = player.trackSelectionParameters
.buildUpon()
.setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, i))
.build()
onChanged()
return
}
flat++
}
}
}
fun seekBy(deltaSeconds: Long) { fun seekBy(deltaSeconds: Long) {
if (!hasMedia || !player.isCurrentMediaItemSeekable) return if (!hasMedia || !player.isCurrentMediaItemSeekable) return
val target = (player.currentPosition + deltaSeconds * 1000) // An unknown duration is C.TIME_UNSET (large negative). Clamping the
.coerceIn(0, player.duration.coerceAtLeast(0)) // upper bound to it collapsed every seek to position 0.
val duration = player.duration
var target = (player.currentPosition + deltaSeconds * 1000).coerceAtLeast(0)
if (duration > 0) target = target.coerceAtMost(duration)
player.seekTo(target) player.seekTo(target)
} }
fun release() { fun release() {
cancelRetry()
mediaSession.release() mediaSession.release()
player.release() player.release()
} }
private companion object {
/** Spacing between reconnect attempts after a stream drops. */
val RETRY_DELAYS_MS = longArrayOf(1_000L, 3_000L, 8_000L)
}
} }

View File

@@ -3,10 +3,24 @@ package dev.castarr.tv.playlist
object M3uParser { object M3uParser {
private const val MAX_CHANNELS = 5000 private const val MAX_CHANNELS = 5000
private const val BOM = ''
private val attrRegex = Regex("""([\w-]+)="([^"]*)"""") private val attrRegex = Regex("""([\w-]+)="([^"]*)"""")
/** Thrown when the payload is not a playlist at all (HTML, JSON, …). */
class NotAPlaylistException : IllegalArgumentException("not an M3U playlist")
/**
* Parses an M3U playlist. Rejects payloads that carry no playlist markers
* at all — pointing the app at a 404 page used to silently replace the
* channel list with HTML fragments.
*/
fun parse(content: String): List<Channel> { fun parse(content: String): List<Channel> {
// A UTF-8 BOM is not whitespace, so trim() leaves it in place and the
// first line stops looking like a comment.
val text = content.trimStart(BOM)
if (!looksLikePlaylist(text)) throw NotAPlaylistException()
val channels = mutableListOf<Channel>() val channels = mutableListOf<Channel>()
var name = "" var name = ""
var group = "" var group = ""
@@ -14,15 +28,15 @@ object M3uParser {
var tvgId = "" var tvgId = ""
var pendingInfo = false var pendingInfo = false
for (rawLine in content.lineSequence()) { for (rawLine in text.lineSequence()) {
val line = rawLine.trim() val line = rawLine.trim().trimStart(BOM)
when { when {
line.startsWith("#EXTINF", ignoreCase = true) -> { line.startsWith("#EXTINF", ignoreCase = true) -> {
val attrs = attrRegex.findAll(line).associate { it.groupValues[1].lowercase() to it.groupValues[2] } val attrs = attrRegex.findAll(line).associate { it.groupValues[1].lowercase() to it.groupValues[2] }
group = attrs["group-title"].orEmpty() group = attrs["group-title"].orEmpty()
logo = attrs["tvg-logo"].orEmpty() logo = attrs["tvg-logo"].orEmpty()
tvgId = attrs["tvg-id"].orEmpty() tvgId = attrs["tvg-id"].orEmpty()
name = line.substringAfterLast(',', "").trim() name = displayName(line)
if (name.isEmpty()) name = attrs["tvg-name"].orEmpty() if (name.isEmpty()) name = attrs["tvg-name"].orEmpty()
pendingInfo = true pendingInfo = true
} }
@@ -41,11 +55,43 @@ object M3uParser {
} }
else -> { else -> {
// Bare URL without #EXTINF — still a playable entry. // Bare URL without #EXTINF — still a playable entry.
channels.add(Channel(line, line, "", "")) channels.add(Channel(line, line, group, ""))
if (channels.size >= MAX_CHANNELS) return channels if (channels.size >= MAX_CHANNELS) return channels
} }
} }
} }
return channels return channels
} }
private fun looksLikePlaylist(text: String): Boolean {
val head = text.lineSequence().take(50)
return head.any {
val line = it.trim().trimStart(BOM)
line.startsWith("#EXTM3U", ignoreCase = true) ||
line.startsWith("#EXTINF", ignoreCase = true)
}
}
/**
* The display name is everything after the FIRST comma that follows the
* duration field — `substringAfterLast` swallowed every name containing
* a comma ("Comedy Central, HD" became "HD").
*/
private fun displayName(extinf: String): String {
val payload = extinf.substringAfter(':', "")
val comma = firstUnquotedComma(payload)
return if (comma < 0) "" else payload.substring(comma + 1).trim()
}
/** Commas inside attribute values (group-title="A, B") do not separate. */
private fun firstUnquotedComma(payload: String): Int {
var inQuotes = false
payload.forEachIndexed { index, c ->
when {
c == '"' -> inQuotes = !inQuotes
c == ',' && !inQuotes -> return index
}
}
return -1
}
} }

View File

@@ -18,7 +18,8 @@ import java.util.concurrent.Executors
/** /**
* Embedded HTTP + WebSocket server. Serves the phone remote (a single HTML * Embedded HTTP + WebSocket server. Serves the phone remote (a single HTML
* page) over HTTP and takes playback commands over a WebSocket. A client * page) over HTTP and takes playback commands over a WebSocket. A client
* authorizes itself with the four-digit pairing code from the QR code. * authorizes itself with the token from the QR code or the four-digit
* pairing code.
*/ */
class ControlServer( class ControlServer(
private val context: Context, private val context: Context,
@@ -32,6 +33,7 @@ class ControlServer(
fun onResumeCast() fun onResumeCast()
fun onStopCast() fun onStopCast()
fun onSeek(deltaSeconds: Long) fun onSeek(deltaSeconds: Long)
fun onSetAudio(index: Int)
fun onVolume(value: Float) fun onVolume(value: Float)
fun onSetPlaylist(url: String) fun onSetPlaylist(url: String)
fun onToggleFavorite(channelId: Int) fun onToggleFavorite(channelId: Int)
@@ -50,30 +52,43 @@ class ControlServer(
// sends are serialized through this worker. // sends are serialized through this worker.
private val sendExecutor = Executors.newSingleThreadExecutor() private val sendExecutor = Executors.newSingleThreadExecutor()
private val clients = CopyOnWriteArrayList<RemoteSocket>() private val clients = CopyOnWriteArrayList<RemoteSocket>()
private val pairingCode = Pairing.code(context)
private val pairingToken = Pairing.token(context)
private var pingTimer: Timer? = null private var pingTimer: Timer? = null
// Rate limit for the human-typable 4-digit code (the QR token is not /** False when the port could not be bound; the app stays usable. */
// brute-forceable and stays exempt). @Volatile
private val codeAttempts = ArrayDeque<Long>() var running = false
private set
// Failed authentication attempts per remote address. Counting every
// failure (not just the ones carrying a code) keeps a hostile client
// from spending someone else's budget.
private val attempts = HashMap<String, ArrayDeque<Long>>()
@Synchronized @Synchronized
private fun codeAttemptAllowed(): Boolean { private fun attemptAllowed(address: String): Boolean {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
while (codeAttempts.isNotEmpty() && now - codeAttempts.first() > CODE_ATTEMPT_WINDOW_MS) { val queue = attempts.getOrPut(address) { ArrayDeque() }
codeAttempts.removeFirst() while (queue.isNotEmpty() && now - queue.first() > ATTEMPT_WINDOW_MS) {
queue.removeFirst()
} }
if (codeAttempts.size >= CODE_ATTEMPT_MAX) return false if (attempts.size > MAX_TRACKED_ADDRESSES) {
codeAttempts.addLast(now) attempts.entries.removeAll { it.value.isEmpty() }
}
if (queue.size >= ATTEMPT_MAX) return false
queue.addLast(now)
return true return true
} }
fun startServer() { fun startServer() {
start(0, true) // A busy port must not take the whole app down — the remote is
// optional, everything else keeps working.
running = runCatching { start(NanoHTTPD.SOCKET_READ_TIMEOUT, true) }
.onFailure { Log.w(TAG, "control server unavailable: ${it.javaClass.simpleName}") }
.isSuccess
if (!running) return
pingTimer = Timer("ws-ping", true).also { pingTimer = Timer("ws-ping", true).also {
it.schedule(object : TimerTask() { it.schedule(object : TimerTask() {
override fun run() = pingClients() override fun run() = maintainClients()
}, PING_INTERVAL_MS, PING_INTERVAL_MS) }, PING_INTERVAL_MS, PING_INTERVAL_MS)
} }
} }
@@ -82,17 +97,41 @@ class ControlServer(
pingTimer?.cancel() pingTimer?.cancel()
pingTimer = null pingTimer = null
sendExecutor.shutdown() sendExecutor.shutdown()
stop() if (running) runCatching { stop() }
running = false
} }
// --- HTTP --- // --- HTTP ---
/**
* Rejects cross-origin upgrades. NanoWSD itself never looks at `Origin`,
* and WebSockets are exempt from the same-origin policy, so without this
* any page on the LAN could talk to the control socket.
*/
override fun serve(session: IHTTPSession): Response {
val origin = session.headers["origin"]
if (origin != null && !isOwnOrigin(origin, session.headers["host"])) {
Log.w(TAG, "rejected cross-origin request")
return newFixedLengthResponse(
Response.Status.FORBIDDEN, NanoHTTPD.MIME_PLAINTEXT, "forbidden",
)
}
return super.serve(session)
}
private fun isOwnOrigin(origin: String, host: String?): Boolean {
if (host.isNullOrEmpty()) return false
return origin.equals("http://$host", ignoreCase = true) ||
origin.equals("https://$host", ignoreCase = true)
}
override fun serveHttp(session: IHTTPSession): Response { override fun serveHttp(session: IHTTPSession): Response {
return when (session.uri) { return when (session.uri) {
"/", "/index.html" -> { "/", "/index.html" -> {
val html = context.assets.open("remote/index.html").bufferedReader().use { it.readText() } val html = context.assets.open("remote/index.html").bufferedReader().use { it.readText() }
newFixedLengthResponse(Response.Status.OK, "text/html; charset=utf-8", html).apply { newFixedLengthResponse(Response.Status.OK, "text/html; charset=utf-8", html).apply {
addHeader("Cache-Control", "no-store") addHeader("Cache-Control", "no-store")
addHeader("Referrer-Policy", "no-referrer")
} }
} }
else -> newFixedLengthResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, "not found") else -> newFixedLengthResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, "not found")
@@ -101,7 +140,8 @@ class ControlServer(
// --- WebSocket --- // --- WebSocket ---
override fun openWebSocket(handshake: IHTTPSession): WebSocket = RemoteSocket(handshake) override fun openWebSocket(handshake: IHTTPSession): WebSocket =
RemoteSocket(handshake, handshake.remoteIpAddress.orEmpty())
fun broadcast(message: JSONObject) { fun broadcast(message: JSONObject) {
val payload = message.toString() val payload = message.toString()
@@ -138,8 +178,18 @@ class ControlServer(
.put("channels", Channel.listToJson(listener.currentChannels())) .put("channels", Channel.listToJson(listener.currentChannels()))
.put("extras", listener.channelsExtras()) .put("extras", listener.channelsExtras())
private fun pingClients() { /**
* Pings live clients and drops sockets that never authenticated. Without
* this an unauthenticated connection would pin a thread forever.
*/
private fun maintainClients() {
val now = System.currentTimeMillis()
clients.forEach { client -> clients.forEach { client ->
if (!client.authorized && now - client.openedAt > HANDSHAKE_TIMEOUT_MS) {
Log.d(TAG, "dropping client that never authenticated")
client.dropSilently()
return@forEach
}
try { try {
client.ping(PING_PAYLOAD) client.ping(PING_PAYLOAD)
} catch (e: IOException) { } catch (e: IOException) {
@@ -155,12 +205,16 @@ class ControlServer(
mainHandler.post { listener.onClientsChanged(count, newestName) } mainHandler.post { listener.onClientsChanged(count, newestName) }
} }
inner class RemoteSocket(handshake: IHTTPSession) : WebSocket(handshake) { inner class RemoteSocket(
handshake: IHTTPSession,
private val remoteAddress: String,
) : WebSocket(handshake) {
@Volatile @Volatile
var authorized = false var authorized = false
private set private set
val openedAt = System.currentTimeMillis()
private var deviceName: String = "" private var deviceName: String = ""
fun trySend(payload: String) { fun trySend(payload: String) {
@@ -172,7 +226,19 @@ class ControlServer(
} }
} }
fun dropSilently() {
clients.remove(this)
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "timeout", false) }
}
override fun onOpen() { override fun onOpen() {
// Cap concurrent sockets — a TV remote needs a handful, and an
// unbounded count is a free resource-exhaustion vector.
if (clients.size >= MAX_CLIENTS) {
Log.w(TAG, "client limit reached, refusing connection")
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "too many clients", false) }
return
}
clients.add(this) clients.add(this)
} }
@@ -201,6 +267,7 @@ class ControlServer(
"resume" -> post { listener.onResumeCast() } "resume" -> post { listener.onResumeCast() }
"stop" -> post { listener.onStopCast() } "stop" -> post { listener.onStopCast() }
"seek" -> post { listener.onSeek(msg.optLong("delta")) } "seek" -> post { listener.onSeek(msg.optLong("delta")) }
"set_audio" -> post { listener.onSetAudio(msg.optInt("index", -1)) }
"volume" -> post { listener.onVolume(msg.optDouble("value", 1.0).toFloat().coerceIn(0f, 1f)) } "volume" -> post { listener.onVolume(msg.optDouble("value", 1.0).toFloat().coerceIn(0f, 1f)) }
"set_playlist" -> post { listener.onSetPlaylist(msg.optString("url")) } "set_playlist" -> post { listener.onSetPlaylist(msg.optString("url")) }
"set_favorite" -> post { listener.onToggleFavorite(msg.optInt("id")) } "set_favorite" -> post { listener.onToggleFavorite(msg.optInt("id")) }
@@ -225,18 +292,15 @@ class ControlServer(
} }
private fun handleHello(msg: JSONObject) { private fun handleHello(msg: JSONObject) {
val token = msg.optString("token") // Every failed attempt counts against this address, whether it
val tokenOk = token.isNotEmpty() && token == pairingToken // carried a token or a code.
val codeOk = !tokenOk && msg.optString("code").let { code -> if (!attemptAllowed(remoteAddress)) {
code.isNotEmpty() && when {
!codeAttemptAllowed() -> {
trySend(JSONObject().put("type", "error").put("error", "rate_limited").toString()) trySend(JSONObject().put("type", "error").put("error", "rate_limited").toString())
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "rate limited", false) } runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "rate limited", false) }
return return
} }
else -> code == pairingCode val tokenOk = Pairing.isValidToken(context, msg.optString("token"))
} val codeOk = !tokenOk && Pairing.isValidCode(context, msg.optString("code"))
}
if (!tokenOk && !codeOk) { if (!tokenOk && !codeOk) {
trySend(JSONObject().put("type", "error").put("error", "bad_code").toString()) trySend(JSONObject().put("type", "error").put("error", "bad_code").toString())
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "bad code", false) } runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "bad code", false) }
@@ -244,6 +308,11 @@ class ControlServer(
} }
authorized = true authorized = true
deviceName = msg.optString("name").ifEmpty { "Handy" } deviceName = msg.optString("name").ifEmpty { "Handy" }
// A code-authenticated client gets its own revocable token, never
// the QR token — otherwise the guessable path would yield the
// master credential.
val grantedToken =
if (tokenOk) msg.optString("token") else Pairing.newSessionToken(context)
// Build the welcome payload on the main thread — it reads the // Build the welcome payload on the main thread — it reads the
// player and UI state, which must not happen on the WS thread. // player and UI state, which must not happen on the WS thread.
post { post {
@@ -251,7 +320,7 @@ class ControlServer(
.put("type", "welcome") .put("type", "welcome")
.put("setup", listener.setupNeeded()) .put("setup", listener.setupNeeded())
.put("device", android.os.Build.MODEL) .put("device", android.os.Build.MODEL)
.put("token", pairingToken) .put("token", grantedToken)
.put("status", listener.currentStatus()) .put("status", listener.currentStatus())
.put("playlistUrl", listener.currentPlaylistUrl()) .put("playlistUrl", listener.currentPlaylistUrl())
.put("channels", Channel.listToJson(listener.currentChannels())) .put("channels", Channel.listToJson(listener.currentChannels()))
@@ -270,8 +339,11 @@ class ControlServer(
private companion object { private companion object {
const val TAG = "ControlServer" const val TAG = "ControlServer"
const val PING_INTERVAL_MS = 8_000L const val PING_INTERVAL_MS = 8_000L
const val CODE_ATTEMPT_WINDOW_MS = 60_000L const val ATTEMPT_WINDOW_MS = 60_000L
const val CODE_ATTEMPT_MAX = 5 const val ATTEMPT_MAX = 5
const val MAX_TRACKED_ADDRESSES = 64
const val MAX_CLIENTS = 8
const val HANDSHAKE_TIMEOUT_MS = 10_000L
val PING_PAYLOAD = byteArrayOf(0x6e, 0x63) val PING_PAYLOAD = byteArrayOf(0x6e, 0x63)
} }
} }

View File

@@ -46,18 +46,18 @@ fun AdvancedScreen(state: AppState) {
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 40.dp, vertical = 20.dp) .padding(horizontal = 40.dp, vertical = 20.dp)
) { ) {
Text("Erweitert", color = CastarrColors.fg, fontFamily = SpaceGrotesk, fontSize = 22.sp) Text("Erweitert", color = CastarrColors.fg, fontFamily = AppFont, fontSize = 22.sp)
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Text( Text(
"Eigene M3U-Quelle (ErsatzTV, Threadfin, Tunarr …) — optionales XMLTV-EPG. " + "Eigene M3U-Quelle (ErsatzTV, Threadfin, Tunarr …) — optionales XMLTV-EPG. " +
"Server: ${state.auth.serverUrl.ifEmpty { "—" }}", "Server: ${state.auth.serverUrl.ifEmpty { "—" }}",
color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 13.sp, color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp,
) )
Spacer(Modifier.height(18.dp)) Spacer(Modifier.height(18.dp))
TvTextField( TvTextField(
value = m3u, value = m3u,
onValueChange = { m3u = it }, onValueChange = { m3u = it },
label = { Text("M3U-URL", fontFamily = SpaceGrotesk) }, label = { Text("M3U-URL", fontFamily = AppFont) },
colors = fieldColors, colors = fieldColors,
textStyle = TextStyle(fontSize = 14.sp), textStyle = TextStyle(fontSize = 14.sp),
modifier = Modifier.width(600.dp), modifier = Modifier.width(600.dp),
@@ -66,7 +66,7 @@ fun AdvancedScreen(state: AppState) {
TvTextField( TvTextField(
value = epg, value = epg,
onValueChange = { epg = it }, onValueChange = { epg = it },
label = { Text("XMLTV-EPG-URL (optional)", fontFamily = SpaceGrotesk) }, label = { Text("XMLTV-EPG-URL (optional)", fontFamily = AppFont) },
colors = fieldColors, colors = fieldColors,
textStyle = TextStyle(fontSize = 14.sp), textStyle = TextStyle(fontSize = 14.sp),
modifier = Modifier.width(600.dp), modifier = Modifier.width(600.dp),
@@ -92,7 +92,7 @@ fun AdvancedScreen(state: AppState) {
else -> message else -> message
} }
if (statusText.isNotEmpty()) { if (statusText.isNotEmpty()) {
Text(statusText, color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 13.sp) Text(statusText, color = CastarrColors.muted, fontFamily = AppFont, fontSize = 13.sp)
} }
} }
} }

View File

@@ -10,11 +10,17 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -22,11 +28,23 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.tv.material3.Surface import androidx.tv.material3.Surface
import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.Text import androidx.tv.material3.Text
import dev.castarr.tv.AppState import dev.castarr.tv.AppState
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
@Composable @Composable
fun CastarrApp(state: AppState) { fun CastarrApp(state: AppState) {
// Back always leads home instead of quitting the app by accident.
androidx.activity.compose.BackHandler(
enabled = !state.playerVisible && state.screen != AppState.Screen.LIVE
&& state.screen != AppState.Screen.WELCOME
) {
state.screen = if (state.screen == AppState.Screen.ADVANCED) AppState.Screen.SETTINGS
else AppState.Screen.LIVE
}
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -48,6 +66,41 @@ fun CastarrApp(state: AppState) {
if (state.playerVisible) { if (state.playerVisible) {
PlayerScreen(state) PlayerScreen(state)
} }
if (state.digitBuffer.isNotEmpty()) {
DigitBadge(state)
}
}
}
/** Big feedback badge while typing a channel number on the remote. */
@Composable
private fun DigitBadge(state: AppState) {
val target = state.digitBuffer.toIntOrNull()
?.let { state.activeChannels().getOrNull(it - 1) }
Column(
modifier = Modifier
.padding(top = 60.dp, end = 48.dp)
.fillMaxSize()
.wrapContentSize(Alignment.TopEnd)
.clip(RoundedCornerShape(14.dp))
.background(CastarrColors.surface)
.padding(horizontal = 22.dp, vertical = 14.dp),
horizontalAlignment = Alignment.End,
) {
Text(
state.digitBuffer,
color = CastarrColors.fg,
fontFamily = AppFont,
fontSize = 34.sp,
fontWeight = FontWeight.Medium,
letterSpacing = 2.sp,
)
Text(
target?.name ?: "kein Sender",
color = if (target != null) CastarrColors.muted else CastarrColors.faint,
fontFamily = AppFont,
fontSize = 13.sp,
)
} }
} }
@@ -65,14 +118,6 @@ private fun TopBar(state: AppState) {
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
letterSpacing = 4.sp, letterSpacing = 4.sp,
) )
Spacer(Modifier.width(36.dp))
TabItem("Live", state.screen == AppState.Screen.LIVE) {
state.screen = AppState.Screen.LIVE
}
Spacer(Modifier.width(10.dp))
TabItem("Einstellungen", state.screen == AppState.Screen.SETTINGS) {
state.screen = AppState.Screen.SETTINGS
}
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
state.connectedRemote?.let { name -> state.connectedRemote?.let { name ->
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@@ -83,30 +128,59 @@ private fun TopBar(state: AppState) {
.background(CastarrColors.accent) .background(CastarrColors.accent)
) )
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text(name, color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 13.sp) Text(name, color = CastarrColors.muted, fontFamily = AppFont, fontSize = 13.sp)
} }
Spacer(Modifier.width(18.dp))
}
GearButton {
state.screen = if (state.screen == AppState.Screen.LIVE) AppState.Screen.SETTINGS
else AppState.Screen.LIVE
} }
} }
} }
/** Round settings button with a drawn gear (glyphs render as emoji). */
@Composable @Composable
private fun TabItem(label: String, selected: Boolean, onClick: () -> Unit) { private fun GearButton(onClick: () -> Unit) {
Surface( Surface(
onClick = onClick, onClick = onClick,
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)), modifier = Modifier.semantics { contentDescription = "Einstellungen" },
shape = ClickableSurfaceDefaults.shape(CircleShape),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = if (selected) CastarrColors.accentDim else Color.Transparent, containerColor = CastarrColors.surface,
contentColor = if (selected) CastarrColors.accent else CastarrColors.muted, contentColor = CastarrColors.muted,
focusedContainerColor = CastarrColors.accent, focusedContainerColor = CastarrColors.accent,
focusedContentColor = CastarrColors.onAccent, focusedContentColor = CastarrColors.onAccent,
), ),
) { ) {
Text( val color = LocalContentColor.current
label, Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) {
fontFamily = SpaceGrotesk, Canvas(Modifier.size(18.dp)) {
fontSize = 14.sp, val center = Offset(size.width / 2f, size.height / 2f)
fontWeight = FontWeight.Medium, // Thick ring with a hole plus stubby teeth — thin lines
modifier = Modifier.padding(horizontal = 18.dp, vertical = 8.dp), // read as a sun, not a gear.
val ringWidth = size.minDimension * 0.20f
drawCircle(
color,
radius = size.minDimension * 0.27f,
center = center,
style = Stroke(ringWidth),
)
val toothStart = size.minDimension * 0.34f
val toothEnd = size.minDimension * 0.50f
for (i in 0 until 8) {
val angle = (i + 0.5) * (PI / 4)
val dx = cos(angle).toFloat()
val dy = sin(angle).toFloat()
drawLine(
color,
start = center + Offset(dx * toothStart, dy * toothStart),
end = center + Offset(dx * toothEnd, dy * toothEnd),
strokeWidth = size.minDimension * 0.17f,
cap = StrokeCap.Butt,
) )
} }
}
}
}
} }

View File

@@ -55,11 +55,11 @@ fun ErrorScreen(state: AppState) {
"Neu anmelden", "Neu anmelden",
) )
} }
Text(title, color = CastarrColors.fg, fontFamily = SpaceGrotesk, fontSize = 30.sp) Text(title, color = CastarrColors.fg, fontFamily = AppFont, fontSize = 30.sp)
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
Text( Text(
subtitle, subtitle,
color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 15.sp, color = CastarrColors.muted, fontFamily = AppFont, fontSize = 15.sp,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
Spacer(Modifier.height(26.dp)) Spacer(Modifier.height(26.dp))
@@ -78,7 +78,7 @@ fun ErrorScreen(state: AppState) {
) { ) {
Text( Text(
buttonLabel, buttonLabel,
fontFamily = SpaceGrotesk, fontSize = 16.sp, fontFamily = AppFont, fontSize = 16.sp,
modifier = Modifier.padding(horizontal = 26.dp, vertical = 13.dp), modifier = Modifier.padding(horizontal = 26.dp, vertical = 13.dp),
) )
} }

View File

@@ -1,11 +1,14 @@
package dev.castarr.tv.ui package dev.castarr.tv.ui
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -13,29 +16,55 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.ClickableSurfaceDefaults
import androidx.tv.material3.Surface import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
import dev.castarr.tv.AppState import dev.castarr.tv.AppState
import dev.castarr.tv.data.NowNext import dev.castarr.tv.data.NowNext
import dev.castarr.tv.data.isEpgPlaceholder
import dev.castarr.tv.playlist.Channel 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,
* like a menu), channels of the open group with Now/Next on the right.
*/
@Composable @Composable
fun LiveScreen(state: AppState) { fun LiveScreen(state: AppState) {
val genericChannels by state.source.channels.collectAsStateWithLifecycle() val genericChannels by state.source.channels.collectAsStateWithLifecycle()
@@ -50,61 +79,209 @@ fun LiveScreen(state: AppState) {
val groups = remember(allChannels) { val groups = remember(allChannels) {
allChannels.mapNotNull { it.group.ifEmpty { null } }.distinct().sorted() allChannels.mapNotNull { it.group.ifEmpty { null } }.distinct().sorted()
} }
val channels = allChannels val teams = remember(state.enabledTeams) {
.let { list -> if (isDispatcharr && state.favoritesOnly) list.filter { it.backendId in favorites } else list } dev.castarr.tv.data.TeamFilters.all.filter { it.key in state.enabledTeams }
.let { list -> state.groupFilter?.let { g -> list.filter { it.group == g } } ?: list } }
// Recomputed when the EPG or the channel list changes — scanning the
// sport groups' programmes is cheap, but not per frame.
val matchesByTeam = remember(teams, allChannels, epgStamp) {
teams.associate { it.key to state.teamMatches(it.key) }
}
val activeTeam = teams.firstOrNull { it.key == state.activeTeam }
val activeMatches = activeTeam?.let { matchesByTeam[it.key] }.orEmpty()
val teamHits = remember(activeMatches) { activeMatches.associate { it.first.url to it.second } }
val channels = when {
activeTeam != null -> activeMatches.map { it.first }
state.favoritesOnly -> allChannels.filter { it.backendId in favorites }
state.groupFilter != null -> allChannels.filter { it.group == state.groupFilter }
else -> allChannels
}
if (allChannels.isEmpty()) { if (allChannels.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text( Text(
"Noch keine Sender — richte unter Einstellungen eine Quelle ein.", "Noch keine Sender — richte unter Einstellungen eine Quelle ein.",
color = CastarrColors.faint, color = CastarrColors.faint,
fontFamily = SpaceGrotesk, fontFamily = AppFont,
fontSize = 15.sp, fontSize = 15.sp,
) )
} }
return return
} }
Column(Modifier.fillMaxSize()) { // D-pad choreography: left from any channel row lands on the SELECTED
LazyRow( // group (not the spatially nearest one), right from the rail enters the
modifier = Modifier.padding(bottom = 6.dp), // list at the top — which also gets scrolled up on every group change.
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 40.dp), val railFocus = remember { FocusRequester() }
horizontalArrangement = Arrangement.spacedBy(8.dp), val listFocus = remember { FocusRequester() }
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 }
}
var restored by remember { mutableStateOf(false) }
LaunchedEffect(state.groupFilter, state.favoritesOnly) {
// A fresh group starts at the top; a return from playback does not.
if (restoreIndex == null) listState.scrollToItem(0)
}
LaunchedEffect(restoreIndex, channels.size, state.restorePending) {
if (restoreIndex != null && (state.restorePending || !restored)) {
listState.scrollToItem(restoreIndex)
runCatching { restoreFocus.requestFocus() }
restored = true
state.restorePending = false
}
}
// A FocusRequester that is not currently attached throws when used, and
// in a LazyColumn the selected row may well be scrolled out of
// composition — so jumps are attempted, not declared.
val intoList = if (channels.isEmpty()) Modifier else Modifier.onPreviewKeyEvent { event ->
event.type == KeyEventType.KeyDown && event.key == Key.DirectionRight &&
runCatching { listFocus.requestFocus() }.isSuccess
}
val railState = rememberLazyListState()
Row(Modifier.fillMaxSize()) {
LazyColumn(
state = railState,
modifier = Modifier
.width(264.dp)
.fillMaxHeight(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(
start = 28.dp, end = 12.dp, top = 4.dp, bottom = 32.dp
),
verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
item { item {
FilterChip("Alle", !state.favoritesOnly && state.groupFilter == null) { val selected = !state.favoritesOnly && state.groupFilter == null &&
state.activeTeam == null
GroupItem(
label = "Alle Sender",
count = allChannels.size,
selected = selected,
modifier = intoList.then(
if (selected) Modifier.focusRequester(railFocus) else Modifier
),
suppressAutoSelect = { state.restorePending },
) {
state.activeTeam = null
state.favoritesOnly = false state.favoritesOnly = false
state.groupFilter = null state.groupFilter = null
} }
} }
if (isDispatcharr) { if (isDispatcharr) {
item { item {
FilterChip("★ Favoriten", state.favoritesOnly) { GroupItem(
state.favoritesOnly = !state.favoritesOnly label = "★ Favoriten",
count = favorites.size,
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 ->
GroupItem(
label = club.label,
count = matchesByTeam[club.key]?.size ?: 0,
selected = state.activeTeam == club.key,
leading = { Crest(club, state) },
modifier = intoList.then(
if (state.activeTeam == club.key) Modifier.focusRequester(railFocus)
else Modifier
),
suppressAutoSelect = { state.restorePending },
) {
state.activeTeam = club.key
state.favoritesOnly = false
state.groupFilter = null
}
}
item {
Box(
Modifier
.padding(horizontal = 14.dp, vertical = 8.dp)
.fillMaxWidth()
.height(1.dp)
.background(CastarrColors.line)
)
}
items(groups, key = { it }) { group -> items(groups, key = { it }) { group ->
FilterChip(group, state.groupFilter == group) { GroupItem(
state.groupFilter = if (state.groupFilter == group) null else group label = group,
} count = remember(allChannels, group) { allChannels.count { it.group == group } },
} selected = state.groupFilter == group && state.activeTeam == null,
} modifier = intoList.then(
LazyColumn( if (state.groupFilter == group) Modifier.focusRequester(railFocus) else Modifier
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(
start = 40.dp, end = 40.dp, top = 8.dp, bottom = 32.dp
), ),
verticalArrangement = Arrangement.spacedBy(4.dp), suppressAutoSelect = { state.restorePending },
) { ) {
items(channels, key = { it.url + it.name }) { channel -> state.activeTeam = null
state.favoritesOnly = false
state.groupFilter = group
}
}
}
LazyColumn(
state = listState,
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(
start = 8.dp, end = 40.dp, top = 4.dp, bottom = 32.dp
),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
if (channels.isEmpty()) {
item {
Text(
when {
activeTeam != null ->
"In den nächsten 3 Stunden läuft nichts mit ${activeTeam.fullName}."
state.favoritesOnly ->
"Noch keine Favoriten — halte OK auf einem Sender gedrückt."
else -> "Diese Gruppe ist leer."
},
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 14.sp,
modifier = Modifier.padding(24.dp),
)
}
}
itemsIndexed(channels, key = { _, c -> c.url + c.name }) { listIndex, channel ->
ChannelRow( ChannelRow(
channel = channel, channel = channel,
index = channels.indexOf(channel), // Stable channel number = position in "Alle Sender",
// matching the remote's number pad in every view.
number = allChannels.indexOf(channel) + 1,
modifier = Modifier
.onPreviewKeyEvent { event ->
event.type == KeyEventType.KeyDown &&
event.key == Key.DirectionLeft &&
runCatching { railFocus.requestFocus() }.isSuccess
}
.then(if (listIndex == 0) Modifier.focusRequester(listFocus) else Modifier)
.then(
if (listIndex == restoreIndex) Modifier.focusRequester(restoreFocus)
else Modifier
),
nowNext = state.nowNext(channel), nowNext = state.nowNext(channel),
highlight = teamHits[channel.url],
playing = state.currentChannel?.url == channel.url, playing = state.currentChannel?.url == channel.url,
favorite = isDispatcharr && channel.backendId in favorites, // All rows are favorites in the favorites view — the
// star only carries meaning elsewhere.
favorite = isDispatcharr && !state.favoritesOnly &&
channel.backendId in favorites,
epgStamp = epgStamp, epgStamp = epgStamp,
onLongClick = if (isDispatcharr) { onLongClick = if (isDispatcharr) {
{ state.dispatcharr.toggleFavorite(channel) } { state.dispatcharr.toggleFavorite(channel) }
@@ -116,31 +293,65 @@ fun LiveScreen(state: AppState) {
} }
@Composable @Composable
private fun FilterChip(label: String, selected: Boolean, onClick: () -> Unit) { private fun GroupItem(
label: String,
count: Int,
selected: Boolean,
modifier: Modifier = Modifier,
leading: (@Composable () -> Unit)? = null,
suppressAutoSelect: () -> Boolean = { false },
onSelect: () -> Unit,
) {
Surface( Surface(
onClick = onClick, // Focusing a group opens it right away (browse like a menu);
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)), // clicking is not required, but harmless.
onClick = onSelect,
modifier = modifier
.fillMaxWidth()
.onFocusChanged { if (it.isFocused && !suppressAutoSelect()) onSelect() },
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = if (selected) CastarrColors.accentDim else CastarrColors.surface, containerColor = if (selected) CastarrColors.accentDim else Color.Transparent,
contentColor = if (selected) CastarrColors.accent else CastarrColors.muted, contentColor = if (selected) CastarrColors.accent else CastarrColors.muted,
focusedContainerColor = CastarrColors.accent, focusedContainerColor = CastarrColors.accent,
focusedContentColor = CastarrColors.onAccent, focusedContentColor = CastarrColors.onAccent,
), ),
) { ) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (leading != null) {
leading()
Spacer(Modifier.width(10.dp))
}
Text( Text(
label, label,
fontFamily = SpaceGrotesk, fontFamily = AppFont,
fontSize = 12.sp, fontSize = 14.sp,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp), fontWeight = if (selected) FontWeight.Medium else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
) )
Spacer(Modifier.width(8.dp))
Text(
"$count",
fontFamily = AppFont,
fontSize = 12.sp,
)
}
} }
} }
@Composable @Composable
private fun ChannelRow( private fun ChannelRow(
channel: Channel, channel: Channel,
index: Int, number: Int,
modifier: Modifier = Modifier,
nowNext: NowNext, nowNext: NowNext,
highlight: dev.castarr.tv.data.Programme? = null,
playing: Boolean, playing: Boolean,
favorite: Boolean, favorite: Boolean,
epgStamp: Long, epgStamp: Long,
@@ -150,57 +361,64 @@ private fun ChannelRow(
Surface( Surface(
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
modifier = Modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)), 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( colors = ClickableSurfaceDefaults.colors(
containerColor = if (playing) CastarrColors.accentDim else Color.Transparent, containerColor = if (playing) CastarrColors.accentDim else CastarrColors.surface,
contentColor = CastarrColors.fg, contentColor = CastarrColors.fg,
focusedContainerColor = CastarrColors.surfaceFocused, focusedContainerColor = CastarrColors.surfaceFocused,
focusedContentColor = CastarrColors.fg, focusedContentColor = CastarrColors.fg,
), ),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
border = BorderStroke(2.dp, CastarrColors.accent),
shape = RoundedCornerShape(12.dp),
),
),
) { ) {
Row( Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
"${index + 1}", "$number",
color = if (playing) CastarrColors.accent else CastarrColors.faint, color = if (playing) CastarrColors.accent else CastarrColors.faint,
fontFamily = SpaceGrotesk, fontFamily = AppFont,
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
modifier = Modifier.width(44.dp), modifier = Modifier.width(40.dp),
) )
if (favorite) { LogoTile(channel)
Text( Spacer(Modifier.width(14.dp))
"", Row(
color = CastarrColors.accent, verticalAlignment = Alignment.CenterVertically,
fontSize = 13.sp, modifier = Modifier.width(230.dp),
modifier = Modifier.width(20.dp), ) {
)
}
Column(Modifier.weight(1f)) {
Text( Text(
channel.name, channel.name,
color = if (playing) CastarrColors.accent else CastarrColors.fg, color = if (playing) CastarrColors.accent else CastarrColors.fg,
fontFamily = SpaceGrotesk, fontFamily = AppFont,
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = if (playing) FontWeight.Medium else FontWeight.Normal, fontWeight = if (playing) FontWeight.Medium else FontWeight.Normal,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
) )
if (channel.group.isNotEmpty()) { if (favorite) {
Text( Spacer(Modifier.width(8.dp))
channel.group, Text("", color = CastarrColors.accent, fontSize = 13.sp)
color = CastarrColors.faint,
fontFamily = SpaceGrotesk,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} }
} }
NowNextCell(nowNext) 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))
}
if (playing) { if (playing) {
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Box( Box(
@@ -214,25 +432,161 @@ private fun ChannelRow(
} }
} }
/**
* The club badge, resolved from Wikipedia and cached on the device. Until
* it arrives (or if it never does) a shield in the club colours stands in.
*/
@Composable @Composable
private fun NowNextCell(nowNext: NowNext) { private fun Crest(team: dev.castarr.tv.data.TeamFilter, state: AppState) {
val now = nowNext.now val url by androidx.compose.runtime.produceState<String?>(null, team.key) {
Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(320.dp)) { value = state.crests.urlFor(team.article)
if (now != null) { }
Box(Modifier.size(20.dp), contentAlignment = Alignment.Center) {
val crestUrl = url
if (crestUrl != null) {
SubcomposeAsyncImage(
model = crestUrl,
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(),
loading = { ShieldFallback(team) },
error = { ShieldFallback(team) },
)
} else {
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
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) {
Text( Text(
now.title, initials,
color = CastarrColors.muted, color = CastarrColors.faint,
fontFamily = SpaceGrotesk, fontFamily = AppFont,
fontSize = 13.sp, fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
/** The programme that matched the club filter, with its start time. */
@Composable
private fun HighlightCell(programme: dev.castarr.tv.data.Programme, modifier: Modifier = Modifier) {
val running = System.currentTimeMillis() in programme.start until programme.stop
Column(modifier) {
Row(verticalAlignment = Alignment.Bottom) {
Text(
programme.title,
color = CastarrColors.fg,
fontFamily = AppFont,
fontSize = 14.sp,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
) )
Spacer(Modifier.width(12.dp))
Text(
if (running) "läuft" else "ab ${formatClock(programme.start)}",
color = if (running) CastarrColors.accent else CastarrColors.muted,
fontFamily = AppFont,
fontSize = 12.sp,
fontWeight = if (running) FontWeight.SemiBold else FontWeight.Normal,
)
}
Spacer(Modifier.height(6.dp))
Text(
"${formatClock(programme.start)}${formatClock(programme.stop)}",
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 12.sp,
)
}
}
private fun formatClock(millis: Long): String =
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() / val fraction = ((System.currentTimeMillis() - now.start).toFloat() /
(now.stop - now.start).coerceAtLeast(1)).coerceIn(0f, 1f) (now.stop - now.start).coerceAtLeast(1)).coerceIn(0f, 1f)
Spacer(Modifier.height(5.dp)) Spacer(Modifier.height(6.dp))
Box( Box(
Modifier Modifier
.width(180.dp) .fillMaxWidth()
.height(2.dp) .height(2.dp)
.clip(RoundedCornerShape(1.dp)) .clip(RoundedCornerShape(1.dp))
.background(CastarrColors.line) .background(CastarrColors.line)
@@ -244,13 +598,16 @@ private fun NowNextCell(nowNext: NowNext) {
.background(CastarrColors.accent) .background(CastarrColors.accent)
) )
} }
nowNext.next?.let { next -> val next = nowNext.next?.takeUnless {
Spacer(Modifier.height(4.dp)) isEpgPlaceholder(it.title) || it.title == now.title
}
if (next != null) {
Spacer(Modifier.height(5.dp))
Text( Text(
"danach: ${next.title}", "danach: ${next.title}",
color = CastarrColors.faint, color = CastarrColors.faint,
fontFamily = SpaceGrotesk, fontFamily = AppFont,
fontSize = 11.sp, fontSize = 12.sp,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )

View File

@@ -1,6 +1,6 @@
package dev.castarr.tv.ui package dev.castarr.tv.ui
import android.view.KeyEvent import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -17,7 +17,10 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -25,29 +28,51 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shadow
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.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
import dev.castarr.tv.AppState 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 with the minimal auto-hiding overlay. Key handling for * Fullscreen playback. OK opens the auto-hiding overlay whose transport
* play/pause/zapping lives in MainActivity.onKeyDown; this composable only * buttons are ordinary focusable controls; media/channel keys are handled
* grabs focus so list items underneath stop reacting. * in MainActivity.onKeyDown.
*/ */
@Composable @Composable
fun PlayerScreen(state: AppState) { fun PlayerScreen(state: AppState) {
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.requestFocus() } LaunchedEffect(state.overlayVisible) {
LaunchedEffect(state.playerVisible, state.playerState) { // While the overlay is hidden the root grabs focus so list items
state.overlayVisible = true // underneath stop reacting; the overlay's play button takes over
if (state.playerState == "playing") { // when it appears.
kotlinx.coroutines.delay(4000) if (!state.overlayVisible) focusRequester.requestFocus()
}
LaunchedEffect(state.playerVisible, state.playerState, state.overlayVisible, state.overlayPing) {
if (state.overlayVisible && state.playerState == "playing") {
kotlinx.coroutines.delay(5000)
state.overlayVisible = false state.overlayVisible = false
} }
} }
@@ -76,17 +101,49 @@ fun PlayerScreen(state: AppState) {
} }
} }
/** Soft drop shadow so overlay text stays readable on bright video. */
private val OverlayText = TextStyle(
shadow = Shadow(Color(0xB3000000), Offset(0f, 2f), blurRadius = 8f),
)
private fun formatClock(millis: Long): String =
SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis))
@Composable @Composable
private fun Overlay(state: AppState) { private fun Overlay(state: AppState) {
Box(Modifier.fillMaxSize()) { val playFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { playFocus.requestFocus() }
// The key-UP of the very press that opened this overlay arrives after
// the play button grabbed focus and would click it (instant pause).
// Swallow select-releases whose press the overlay never saw.
var sawSelectDown by remember { mutableStateOf(false) }
Box(
Modifier
.fillMaxSize()
// Any key while the overlay is open keeps it open a while longer.
.onPreviewKeyEvent { event ->
state.pingOverlay()
val select = event.key == Key.DirectionCenter ||
event.key == Key.Enter || event.key == Key.NumPadEnter
when {
select && event.type == KeyEventType.KeyDown -> {
sawSelectDown = true
false
}
select && event.type == KeyEventType.KeyUp && !sawSelectDown -> true
else -> false
}
},
) {
Box( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(200.dp) .height(280.dp)
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.background( .background(
Brush.verticalGradient( Brush.verticalGradient(
listOf(Color.Transparent, CastarrColors.bgDeep.copy(alpha = 0.92f)) listOf(Color.Transparent, CastarrColors.bgDeep.copy(alpha = 0.94f))
) )
) )
) )
@@ -97,7 +154,8 @@ private fun Overlay(state: AppState) {
.padding(top = 22.dp, end = 30.dp), .padding(top = 22.dp, end = 30.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text(name, color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 12.sp) Text(name, color = CastarrColors.muted, fontFamily = AppFont,
style = OverlayText, fontSize = 12.sp)
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Box( Box(
Modifier Modifier
@@ -112,63 +170,234 @@ private fun Overlay(state: AppState) {
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 36.dp) .padding(horizontal = 36.dp)
.padding(bottom = 30.dp), .padding(bottom = 24.dp),
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { // No status word while simply playing, and no LIVE badge — a
if (state.isLive) { // red dot reads as "recording" (user feedback).
Box( val statusWord = when (state.playerState) {
Modifier
.size(6.dp)
.clip(CircleShape)
.background(CastarrColors.live)
)
Spacer(Modifier.width(8.dp))
Text(
"LIVE",
color = CastarrColors.muted,
fontFamily = SpaceGrotesk,
fontSize = 11.sp,
letterSpacing = 2.sp,
)
}
Spacer(Modifier.weight(1f))
Text(
when (state.playerState) {
"paused" -> "Pausiert" "paused" -> "Pausiert"
"buffering" -> "Lädt…" "buffering" -> "Lädt…"
"error" -> "Wiedergabefehler" "reconnecting" -> "Verbindung wird wiederhergestellt…"
else -> "Wird abgespielt" "error" -> "Sender gerade nicht erreichbar"
}, else -> null
}
if (statusWord != null) {
Row {
Spacer(Modifier.weight(1f))
Text(
statusWord,
color = CastarrColors.muted, color = CastarrColors.muted,
fontFamily = SpaceGrotesk, fontFamily = AppFont,
style = OverlayText,
fontSize = 12.sp, fontSize = 12.sp,
) )
} }
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
}
Row(verticalAlignment = Alignment.Bottom) {
// Channel number inline before the name (matches the list
// and the number-pad zapping).
val number = state.currentChannel?.let { current ->
state.activeChannels().indexOfFirst { it.url == current.url } + 1
} ?: 0
if (number > 0) {
Text(
"$number",
color = CastarrColors.accent,
fontFamily = AppFont,
style = OverlayText,
fontSize = 30.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.width(14.dp))
}
Text( Text(
state.currentChannel?.name.orEmpty(), state.currentChannel?.name.orEmpty(),
color = CastarrColors.fg, color = CastarrColors.fg,
fontFamily = SpaceGrotesk, fontFamily = AppFont,
style = OverlayText,
fontSize = 30.sp, fontSize = 30.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
) )
Spacer(Modifier.height(16.dp)) }
val fraction = if (state.isLive || state.durationMs <= 0) 1f
else (state.positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f) val info = state.currentChannel?.let { state.nowNext(it) }
val now = info?.now?.takeUnless { isEpgPlaceholder(it.title) }
if (now != null) {
Spacer(Modifier.height(2.dp))
Row(verticalAlignment = Alignment.Bottom) {
Text(
now.title,
color = CastarrColors.fg,
fontFamily = AppFont,
style = OverlayText,
fontSize = 18.sp,
)
Spacer(Modifier.width(14.dp))
Text(
"${formatClock(now.start)} ${formatClock(now.stop)}",
color = Color(0xFFD7DBE0),
fontFamily = AppFont,
style = OverlayText,
fontSize = 14.sp,
)
}
}
info?.next?.takeUnless {
isEpgPlaceholder(it.title) || it.title == now?.title
}?.let { next ->
Spacer(Modifier.height(2.dp))
Text(
"Danach: ${next.title} (${formatClock(next.start)})",
color = CastarrColors.muted,
fontFamily = AppFont,
style = OverlayText,
fontSize = 14.sp,
)
}
Spacer(Modifier.height(12.dp))
// Live with EPG: progress of the running programme. Otherwise
// playback position; unknown duration fills the bar.
val fraction = when {
now != null && now.stop > now.start ->
((System.currentTimeMillis() - now.start).toFloat() / (now.stop - now.start))
.coerceIn(0f, 1f)
state.isLive || state.durationMs <= 0 -> 1f
else -> (state.positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f)
}
Box( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(2.dp) .height(3.dp)
.clip(RoundedCornerShape(1.dp)) .clip(RoundedCornerShape(1.5.dp))
.background(CastarrColors.line) .background(Color(0x59FFFFFF))
) { ) {
Box( Box(
Modifier Modifier
.fillMaxWidth(fraction) .fillMaxWidth(fraction)
.height(2.dp) .height(3.dp)
.background(CastarrColors.accent) .background(CastarrColors.accent)
) )
} }
Spacer(Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
if (state.playerState == "error") {
// One clear action instead of silently dropping back to
// the channel list.
PillButton("Erneut versuchen", focusRequester = playFocus) {
state.retryPlayback()
}
Spacer(Modifier.width(10.dp))
IconButton(icon = PillIcon.STOP, contentDescription = "Beenden") {
state.stopPlayback()
}
} else {
IconButton(
icon = if (state.playerState == "paused") PillIcon.PLAY else PillIcon.PAUSE,
contentDescription = if (state.playerState == "paused") "Weiter" else "Pause",
focusRequester = playFocus,
) { state.player.toggle() }
Spacer(Modifier.width(10.dp))
IconButton(icon = PillIcon.STOP, contentDescription = "Beenden") {
state.stopPlayback()
}
}
if (state.audioTracks.size > 1) {
Spacer(Modifier.width(10.dp))
val current = state.audioTracks.firstOrNull { it.selected }
PillButton(label = "Ton: ${current?.label ?: ""}") { state.cycleAudio(1) }
}
Spacer(Modifier.weight(1f))
Text(
"▲ ▼ Sender wechseln",
color = CastarrColors.faint,
fontFamily = AppFont,
style = OverlayText,
fontSize = 12.sp,
)
}
} }
} }
} }
private enum class PillIcon { PLAY, PAUSE, STOP }
/** Round icon-only transport button; the icons are drawn, not typed —
* glyphs like U+23F8 render as emoji on Android. */
@Composable
private fun IconButton(
icon: PillIcon,
contentDescription: String,
focusRequester: FocusRequester? = null,
onClick: () -> Unit,
) {
Surface(
onClick = onClick,
modifier = (if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.semantics { this.contentDescription = contentDescription },
shape = ClickableSurfaceDefaults.shape(CircleShape),
colors = ClickableSurfaceDefaults.colors(
containerColor = CastarrColors.surface.copy(alpha = 0.75f),
contentColor = CastarrColors.fg,
focusedContainerColor = CastarrColors.accent,
focusedContentColor = CastarrColors.onAccent,
),
) {
val color = LocalContentColor.current
Box(Modifier.size(46.dp), contentAlignment = Alignment.Center) {
Canvas(Modifier.size(15.dp)) {
when (icon) {
PillIcon.PLAY -> drawPath(
Path().apply {
moveTo(0f, 0f)
lineTo(size.width, size.height / 2f)
lineTo(0f, size.height)
close()
},
color,
)
PillIcon.PAUSE -> {
val bar = size.width * 0.3f
drawRect(color, size = androidx.compose.ui.geometry.Size(bar, size.height))
drawRect(
color,
topLeft = androidx.compose.ui.geometry.Offset(size.width - bar, 0f),
size = androidx.compose.ui.geometry.Size(bar, size.height),
)
}
PillIcon.STOP -> drawRect(color)
}
}
}
}
}
@Composable
private fun PillButton(
label: String,
focusRequester: FocusRequester? = null,
onClick: () -> Unit,
) {
Surface(
onClick = onClick,
modifier = if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier,
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)),
colors = ClickableSurfaceDefaults.colors(
containerColor = CastarrColors.surface.copy(alpha = 0.75f),
contentColor = CastarrColors.fg,
focusedContainerColor = CastarrColors.accent,
focusedContentColor = CastarrColors.onAccent,
),
) {
Text(
label,
fontFamily = AppFont,
style = OverlayText,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(horizontal = 18.dp, vertical = 9.dp),
)
}
}

View File

@@ -1,19 +1,30 @@
package dev.castarr.tv.ui package dev.castarr.tv.ui
import android.graphics.Bitmap import android.graphics.Bitmap
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -23,10 +34,18 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
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.dp
import androidx.compose.ui.unit.sp 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.ClickableSurfaceDefaults
import androidx.tv.material3.Surface import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
@@ -37,9 +56,18 @@ import dev.castarr.tv.pairing.Qr
import dev.castarr.tv.update.UpdateChecker import dev.castarr.tv.update.UpdateChecker
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** One open picker dialog: which setting, its options, what happens on pick. */
private data class Picker(
val title: String,
val options: List<String>,
val selected: Int,
val onPick: (Int) -> Unit,
)
/** /**
* Ten-Foot-Regel (#14): one screen, focusable buttons/chips only — no text * Google-TV-style settings: cards with full-width focusable rows; choices
* fields, no scrolling. Everything text-heavy lives in the Erweitert screen. * open a picker dialog (Konto / Wiedergabe / App / Handy-Fernbedienung).
* Ten-Foot-Regel (#14): no text fields, no scrolling.
*/ */
@Composable @Composable
fun SettingsScreen(state: AppState) { fun SettingsScreen(state: AppState) {
@@ -47,133 +75,469 @@ fun SettingsScreen(state: AppState) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val profiles by state.dispatcharr.profiles.collectAsState() val profiles by state.dispatcharr.profiles.collectAsState()
var updateStatus by remember { mutableStateOf("") } var updateStatus by remember { mutableStateOf("") }
var picker by remember { mutableStateOf<Picker?>(null) }
// Bumped on reset so the QR code and the four-digit code redraw.
var pairingEpoch by remember { mutableStateOf(0) }
Row( Row(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 40.dp, vertical = 20.dp) .padding(horizontal = 40.dp, vertical = 12.dp)
) { ) {
Column(Modifier.weight(1.2f)) { Column(Modifier.weight(1.25f), verticalArrangement = Arrangement.spacedBy(16.dp)) {
SectionTitle("Konto") SettingsCard("Konto") {
Spacer(Modifier.height(8.dp))
if (state.auth.isLoggedIn) { if (state.auth.isLoggedIn) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
Modifier
.size(34.dp)
.clip(CircleShape)
.background(CastarrColors.accentDim),
contentAlignment = Alignment.Center,
) {
Text( Text(
"Angemeldet als ${state.auth.username}", state.auth.username.take(1).uppercase().ifEmpty { "?" },
color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 14.sp, color = CastarrColors.accent,
fontFamily = AppFont,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
) )
Spacer(Modifier.height(14.dp))
if (state.source.m3uUrl.isNotEmpty()) {
Row {
Chip("Dispatcharr", state.sourceMode == AppState.SourceMode.DISPATCHARR) {
state.setMode(AppState.SourceMode.DISPATCHARR)
} }
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(14.dp))
Chip("Eigene M3U", state.sourceMode == AppState.SourceMode.GENERIC) { Column {
state.setMode(AppState.SourceMode.GENERIC) Text(
state.auth.username,
color = CastarrColors.fg, fontFamily = AppFont, fontSize = 15.sp,
)
Text(
"Angemeldet über SSO",
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 11.sp,
)
} }
} }
Spacer(Modifier.height(14.dp)) SettingRow("Abmelden", danger = true) { state.startOnboarding() }
}
Text("Stream-Qualität", color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 12.sp)
Spacer(Modifier.height(6.dp))
Row {
Chip("Standard", state.outputProfile.isEmpty()) { state.outputProfile = "" }
Spacer(Modifier.width(8.dp))
Chip("Original", state.outputProfile == "raw") { state.outputProfile = "raw" }
profiles.forEach { name ->
Spacer(Modifier.width(8.dp))
Chip(name, state.outputProfile == name) { state.outputProfile = name }
}
}
Spacer(Modifier.height(18.dp))
ActionButton("Abmelden", danger = true) { state.startOnboarding() }
} else { } else {
Text( Text(
"Nicht angemeldet.", "Nicht angemeldet.",
color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 14.sp, color = CastarrColors.muted, fontFamily = AppFont, fontSize = 14.sp,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
) )
Spacer(Modifier.height(12.dp)) SettingRow("Jetzt anmelden") { state.startOnboarding() }
ActionButton("Jetzt anmelden") { state.startOnboarding() } }
} }
Spacer(Modifier.height(28.dp)) SettingsCard("Wiedergabe") {
SectionTitle("App") if (state.auth.isLoggedIn && state.source.m3uUrl.isNotEmpty()) {
Spacer(Modifier.height(8.dp)) val modes = listOf("Dispatcharr", "Eigene M3U")
Text( val modeIndex = if (state.sourceMode == AppState.SourceMode.DISPATCHARR) 0 else 1
"Version ${BuildConfig.VERSION_NAME}" + (state.updateAvailable?.let { " · $it verfügbar" } ?: ""), SettingRow(
color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 12.sp, "Quelle",
subtitle = "Woher die Senderliste kommt",
trailing = { ValueWithCaret(modes[modeIndex]) },
) {
picker = Picker("Quelle", modes, modeIndex) { index ->
state.setMode(
if (index == 0) AppState.SourceMode.DISPATCHARR
else AppState.SourceMode.GENERIC
) )
Spacer(Modifier.height(8.dp)) }
Row { }
ActionButton( }
if (state.updateAvailable != null) "Update installieren" else "Nach Update suchen" val qualities = listOf("Standard", "Original") + profiles
val qualityIndex = when (state.outputProfile) {
"" -> 0
"raw" -> 1
else -> (profiles.indexOf(state.outputProfile) + 2).coerceAtLeast(0)
}
SettingRow(
"Stream-Qualität",
subtitle = "Standard remuxt nur den Ton",
trailing = { ValueWithCaret(qualities[qualityIndex]) },
) {
picker = Picker("Stream-Qualität", qualities, qualityIndex) { index ->
state.outputProfile = when (index) {
0 -> ""
1 -> "raw"
else -> profiles.getOrElse(index - 2) { "" }
}
}
}
}
SettingsCard("Vereinsmenüs") {
Text(
"Eigene Gruppe mit allen Sendern, auf denen der Verein in den " +
"nächsten 3 Stunden läuft. Bis zu " +
"${dev.castarr.tv.data.TeamFilters.MAX_ACTIVE} Vereine.",
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 11.sp,
modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 6.dp),
)
val active = dev.castarr.tv.data.TeamFilters.all
.filter { it.key in state.enabledTeams }
active.forEach { club ->
SettingRow(
club.fullName,
subtitle = "Erscheint als ${club.label} in der Senderliste",
trailing = { TogglePill(on = true) },
) { state.toggleTeam(club.key) }
}
if (active.size < dev.castarr.tv.data.TeamFilters.MAX_ACTIVE) {
SettingRow(
"Verein hinzufügen",
subtitle = "1. bis 3. Liga",
) {
val choices = dev.castarr.tv.data.TeamFilters.all
.filter { it.key !in state.enabledTeams }
picker = Picker(
"Verein hinzufügen",
choices.map { it.fullName },
-1,
) { index ->
choices.getOrNull(index)?.let { state.toggleTeam(it.key) }
}
}
}
}
SettingsCard("App") {
SettingRow(
"Version ${BuildConfig.VERSION_NAME}",
subtitle = updateStatus.ifEmpty { "Klicken prüft auf Updates" },
trailing = state.updateAvailable?.let { version ->
{
Box(
Modifier
.clip(RoundedCornerShape(999.dp))
.background(CastarrColors.accentDim)
.padding(horizontal = 10.dp, vertical = 4.dp)
) {
Text(
"$version verfügbar",
color = CastarrColors.accent,
fontFamily = AppFont,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
},
) { ) {
scope.launch { scope.launch {
updateStatus = if (state.updateAvailable != null) { updateStatus = when (val v = UpdateChecker.check(state)) {
UpdateChecker.downloadAndInstall(context, state) ?: "Update wird geöffnet…"
} else {
when (val v = UpdateChecker.check(state)) {
null -> "App ist aktuell" null -> "App ist aktuell"
else -> "$v verfügbar — nochmal drücken zum Installieren" else -> "$v verfügbar"
} }
} }
} }
if (state.updateAvailable != null) {
SettingRow("Update installieren") {
scope.launch {
updateStatus = "Update wird heruntergeladen…"
updateStatus =
UpdateChecker.downloadAndInstall(context, state)
?: "Installation wird geöffnet…"
} }
Spacer(Modifier.width(8.dp))
ActionButton("Erweitert") { state.screen = AppState.Screen.ADVANCED }
} }
if (updateStatus.isNotEmpty()) { }
Spacer(Modifier.height(8.dp)) SettingRow(
Text(updateStatus, color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 12.sp) "Erweitert",
subtitle = "M3U/EPG-Adressen von Hand eintragen",
) { state.screen = AppState.Screen.ADVANCED }
} }
} }
Spacer(Modifier.width(40.dp)) Spacer(Modifier.width(16.dp))
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(Modifier.weight(0.75f)) {
SectionTitle("Handy-Fernbedienung") SettingsCard("Handy-Fernbedienung") {
Spacer(Modifier.height(10.dp)) val address = remember(pairingEpoch) { Pairing.lanAddress() }
val address = remember { Pairing.lanAddress() } if (!state.remoteAvailable) {
if (address != null) { Text(
val qr = remember(address) { "Fernbedienung nicht verfügbar — Port belegt. Neustart des Fernsehers hilft meistens.",
Qr.encode(Pairing.pairingUrl(context, address), 400, android.graphics.Color.parseColor("#101216")) color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
)
} else if (address != null) {
val qr = remember(address, pairingEpoch) {
Qr.encode(
Pairing.pairingUrl(context, address), 400,
android.graphics.Color.parseColor("#101216"),
)
} }
Column(
Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
QrCard(qr) QrCard(qr)
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
Text( Text(
"${Pairing.remoteUrl(address)} · Code ${Pairing.code(context)}", "Mit der Handy-Kamera scannen",
color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 12.sp, color = CastarrColors.faint, fontFamily = AppFont, fontSize = 12.sp,
) )
} else {
Text("Keine Netzwerkverbindung", color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 13.sp)
}
}
}
}
@Composable
private fun SectionTitle(text: String) {
Text(text, color = CastarrColors.fg, fontFamily = SpaceGrotesk, fontSize = 20.sp)
}
@Composable
fun Chip(label: String, selected: Boolean, onClick: () -> Unit) {
Surface(
onClick = onClick,
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)),
colors = ClickableSurfaceDefaults.colors(
containerColor = if (selected) CastarrColors.accentDim else CastarrColors.surface,
contentColor = if (selected) CastarrColors.accent else CastarrColors.muted,
focusedContainerColor = CastarrColors.accent,
focusedContentColor = CastarrColors.onAccent,
),
) {
Text( Text(
label, "${Pairing.remoteUrl(address)} · Code ${Pairing.code(context)}",
fontFamily = SpaceGrotesk, fontSize = 12.sp, color = CastarrColors.muted, fontFamily = AppFont, fontSize = 12.sp,
)
Spacer(Modifier.height(10.dp))
}
SettingRow(
"Kopplung zurücksetzen",
subtitle = "Neuer Code, alle Handys müssen neu scannen",
) {
Pairing.reset(context)
pairingEpoch++
}
} else {
Text(
"Keine Netzwerkverbindung",
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp), modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
) )
} }
}
}
}
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() }
// 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
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()
},
color,
)
}
}
}
@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
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 @Composable
@@ -190,7 +554,7 @@ fun ActionButton(label: String, danger: Boolean = false, onClick: () -> Unit) {
) { ) {
Text( Text(
label, label,
fontFamily = SpaceGrotesk, fontSize = 13.sp, fontFamily = AppFont, fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp), modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
) )
} }
@@ -198,17 +562,27 @@ fun ActionButton(label: String, danger: Boolean = false, onClick: () -> Unit) {
@Composable @Composable
fun QrCard(bitmap: Bitmap) { 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( Column(
Modifier Modifier
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(18.dp))
.background(androidx.compose.ui.graphics.Color(0xFFFBFCFD)) .background(CastarrColors.bg)
.padding(14.dp), .border(1.dp, CastarrColors.line, RoundedCornerShape(18.dp))
.padding(8.dp),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) {
Column(
Modifier
.clip(RoundedCornerShape(12.dp))
.background(androidx.compose.ui.graphics.Color(0xFFF4F6F7))
.padding(10.dp),
) { ) {
Image( Image(
bitmap = bitmap.asImageBitmap(), bitmap = bitmap.asImageBitmap(),
contentDescription = "QR-Code zum Koppeln", contentDescription = "QR-Code zum Koppeln",
modifier = Modifier.size(170.dp), modifier = Modifier.size(164.dp),
) )
} }
}
} }

View File

@@ -21,6 +21,15 @@ object CastarrColors {
val onAccent = Color(0xFF07110F) val onAccent = Color(0xFF07110F)
} }
/** Display face — only for the CASTARR wordmark. */
val SpaceGrotesk = FontFamily( val SpaceGrotesk = FontFamily(
Font(R.font.space_grotesk, FontWeight.Normal), Font(R.font.space_grotesk, FontWeight.Normal),
) )
/** UI face for everything else (Inter). */
val AppFont = FontFamily(
Font(R.font.inter_regular, FontWeight.Normal),
Font(R.font.inter_medium, FontWeight.Medium),
Font(R.font.inter_semibold, FontWeight.SemiBold),
Font(R.font.inter_semibold, FontWeight.Bold),
)

View File

@@ -49,7 +49,7 @@ fun WelcomeScreen(state: AppState) {
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text( Text(
"Willkommen bei Castarr", "Willkommen bei Castarr",
color = CastarrColors.fg, fontFamily = SpaceGrotesk, color = CastarrColors.fg, fontFamily = AppFont,
fontSize = 32.sp, fontWeight = FontWeight.Medium, fontSize = 32.sp, fontWeight = FontWeight.Medium,
) )
Spacer(Modifier.height(28.dp)) Spacer(Modifier.height(28.dp))
@@ -90,11 +90,11 @@ fun WelcomeScreen(state: AppState) {
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Text( Text(
Pairing.remoteUrl(address), Pairing.remoteUrl(address),
color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 13.sp, color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp,
) )
} }
} else { } else {
Text("Keine Netzwerkverbindung", color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 15.sp) Text("Keine Netzwerkverbindung", color = CastarrColors.faint, fontFamily = AppFont, fontSize = 15.sp)
} }
} }
} }
@@ -116,19 +116,19 @@ private fun Step(number: Int, text: String, active: Boolean, done: Boolean) {
Text( Text(
if (done) "" else "$number", if (done) "" else "$number",
color = if (active || done) CastarrColors.onAccent else CastarrColors.faint, color = if (active || done) CastarrColors.onAccent else CastarrColors.faint,
fontFamily = SpaceGrotesk, fontSize = 14.sp, fontWeight = FontWeight.Medium, fontFamily = AppFont, fontSize = 14.sp, fontWeight = FontWeight.Medium,
) )
} }
Spacer(Modifier.width(14.dp)) Spacer(Modifier.width(14.dp))
Text( Text(
text, text,
color = if (active) CastarrColors.fg else CastarrColors.muted, color = if (active) CastarrColors.fg else CastarrColors.muted,
fontFamily = SpaceGrotesk, fontSize = 17.sp, fontFamily = AppFont, fontSize = 17.sp,
) )
} }
} }
@Composable @Composable
private fun Status(text: String) { private fun Status(text: String) {
Text(text, color = CastarrColors.accent, fontFamily = SpaceGrotesk, fontSize = 14.sp) Text(text, color = CastarrColors.accent, fontFamily = AppFont, fontSize = 14.sp)
} }

View File

@@ -13,11 +13,17 @@ import java.io.File
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
/** In-app updater over GitHub releases (#12). Explicit, never automatic. */ /** In-app updater over Gitea releases (#12). Explicit, never automatic. */
object UpdateChecker { object UpdateChecker {
private const val TAG = "UpdateChecker" private const val TAG = "UpdateChecker"
private const val LATEST = "https://api.github.com/repos/be-nj/castarr/releases/latest" private const val 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/be-nj/castarr/raw/branch/apk/castarr.apk"
private var apkUrl: String = "" private var apkUrl: String = ""
@@ -35,6 +41,7 @@ object UpdateChecker {
break break
} }
} }
if (url.isEmpty() && tag.isNotEmpty()) url = APK_FALLBACK
if (url.isNotEmpty() && isNewer(tag, BuildConfig.VERSION_NAME)) { if (url.isNotEmpty() && isNewer(tag, BuildConfig.VERSION_NAME)) {
apkUrl = url apkUrl = url
val version = "v$tag" val version = "v$tag"
@@ -69,7 +76,9 @@ object UpdateChecker {
setDataAndType(uri, "application/vnd.android.package-archive") setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
} }
context.startActivity(intent) // 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 null
}.getOrElse { }.getOrElse {
Log.w(TAG, "install failed: ${it.javaClass.simpleName}") Log.w(TAG, "install failed: ${it.javaClass.simpleName}")
@@ -94,7 +103,7 @@ object UpdateChecker {
return try { return try {
connection.connectTimeout = 10_000 connection.connectTimeout = 10_000
connection.readTimeout = 15_000 connection.readTimeout = 15_000
connection.setRequestProperty("Accept", "application/vnd.github+json") connection.setRequestProperty("Accept", "application/json")
connection.inputStream.bufferedReader().use { it.readText() } connection.inputStream.bufferedReader().use { it.readText() }
} finally { } finally {
connection.disconnect() connection.disconnect()

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Credentials must not travel to Google Drive: the pairing token grants
full control of the TV and the OIDC tokens are account credentials. -->
<full-backup-content>
<exclude domain="sharedpref" path="pairing.xml" />
<exclude domain="sharedpref" path="auth.xml" />
</full-backup-content>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<exclude domain="sharedpref" path="pairing.xml" />
<exclude domain="sharedpref" path="auth.xml" />
</cloud-backup>
<device-transfer>
<exclude domain="sharedpref" path="pairing.xml" />
<exclude domain="sharedpref" path="auth.xml" />
</device-transfer>
</data-extraction-rules>

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

BIN
docs/screenshots/player.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View File

@@ -0,0 +1,80 @@
package dev.castarr.tv.playlist
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
class M3uParserTest {
@Test
fun `keeps commas inside display names`() {
val channels = M3uParser.parse(
"""
#EXTM3U
#EXTINF:-1 group-title="Doku",Comedy Central, HD
http://example.com/a.ts
""".trimIndent()
)
assertEquals(1, channels.size)
assertEquals("Comedy Central, HD", channels[0].name)
assertEquals("Doku", channels[0].group)
}
@Test
fun `commas inside attributes do not split the name`() {
val channels = M3uParser.parse(
"""
#EXTM3U
#EXTINF:-1 group-title="News, Sport" tvg-id="x",Das Erste HD
http://example.com/b.ts
""".trimIndent()
)
assertEquals("Das Erste HD", channels[0].name)
assertEquals("News, Sport", channels[0].group)
assertEquals("x", channels[0].tvgId)
}
@Test
fun `strips a UTF-8 BOM instead of inventing a channel`() {
val channels = M3uParser.parse(
"#EXTM3U\n#EXTINF:-1,Channel A\nhttp://example.com/a.ts\n"
)
assertEquals(1, channels.size)
assertEquals("Channel A", channels[0].name)
}
@Test
fun `rejects HTML instead of turning it into channels`() {
assertThrows(M3uParser.NotAPlaylistException::class.java) {
M3uParser.parse("<!DOCTYPE html>\n<html>\n<body>404</body>\n</html>")
}
}
@Test
fun `rejects an empty payload`() {
assertThrows(M3uParser.NotAPlaylistException::class.java) {
M3uParser.parse("")
}
}
@Test
fun `accepts a playlist whose header is missing but has entries`() {
val channels = M3uParser.parse("#EXTINF:-1,Nur ein Sender\nhttp://example.com/c.ts")
assertEquals(1, channels.size)
assertEquals("Nur ein Sender", channels[0].name)
}
@Test
fun `falls back to the url when the name is empty`() {
val channels = M3uParser.parse("#EXTM3U\n#EXTINF:-1,\nhttp://example.com/d.ts")
assertEquals("http://example.com/d.ts", channels[0].name)
}
@Test
fun `carries EXTGRP over to a bare url`() {
val channels = M3uParser.parse("#EXTM3U\n#EXTGRP:Sport\nhttp://example.com/e.ts")
assertTrue(channels.isNotEmpty())
assertEquals("Sport", channels[0].group)
}
}

View File

@@ -0,0 +1,61 @@
package dev.castarr.tv.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class TeamFiltersTest {
private val hansa = TeamFilters.byKey("hansa")!!
private val vfb = TeamFilters.byKey("stuttgart")!!
@Test
fun `matches the club regardless of case and surrounding text`() {
assertTrue(hansa.matches("3. Liga: FC Hansa Rostock - Dynamo Dresden"))
assertTrue(hansa.matches("HANSA ROSTOCK KOMPAKT"))
assertTrue(vfb.matches("Bundesliga: VfB Stuttgart - Bayern München"))
}
@Test
fun `does not match unrelated programmes`() {
assertFalse(hansa.matches("Tagesschau"))
assertFalse(vfb.matches("Hansa Rostock - Saarbrücken"))
}
@Test
fun `scans sport and free-to-air groups`() {
listOf("DAZN Event", "Sky Sport", "Magenta Sport", "Amazon Prime", "Free TV / HD+", "DYN Sport")
.forEach { assertTrue(it, TeamFilters.scansGroup(it)) }
}
@Test
fun `skips NFL and unrelated groups`() {
assertFalse(TeamFilters.scansGroup("DAZN Event NFL"))
assertFalse(TeamFilters.scansGroup("Kids"))
assertFalse(TeamFilters.scansGroup("Musik"))
}
@Test
fun `assigns each viewer their own club by default`() {
assertEquals(listOf("hansa"), TeamFilters.defaultKeysFor("benjamin"))
assertEquals(listOf("stuttgart"), TeamFilters.defaultKeysFor("TobiasB"))
assertTrue(TeamFilters.defaultKeysFor("someone-else").isEmpty())
}
@Test
fun `club keys are unique and every club has needles`() {
val keys = TeamFilters.all.map { it.key }
assertEquals(keys.size, keys.toSet().size)
assertTrue(TeamFilters.all.all { it.needles.isNotEmpty() })
assertTrue(TeamFilters.all.all { c -> c.needles.all { it == it.lowercase() } })
}
@Test
fun `covers all three divisions`() {
assertTrue(TeamFilters.all.size > 50)
listOf("bayern", "schalke", "hansa").forEach {
assertTrue(it, TeamFilters.byKey(it) != null)
}
}
}

View File

@@ -0,0 +1,34 @@
package dev.castarr.tv.data
import org.junit.Assert.assertEquals
import org.junit.Test
class XmltvWindowTest {
private fun p(startMin: Long, endMin: Long, title: String) =
Programme(startMin * 60_000, endMin * 60_000, title)
private val schedule = listOf(
p(0, 60, "Läuft gerade"),
p(60, 120, "Gleich danach"),
p(150, 210, "In zweieinhalb Stunden"),
p(300, 360, "Deutlich später"),
)
@Test
fun `returns programmes overlapping the window`() {
val titles = XmltvParser.programmesIn(schedule, 30 * 60_000, 180 * 60_000).map { it.title }
assertEquals(listOf("Läuft gerade", "Gleich danach", "In zweieinhalb Stunden"), titles)
}
@Test
fun `excludes programmes outside the window`() {
val titles = XmltvParser.programmesIn(schedule, 0, 60 * 60_000).map { it.title }
assertEquals(listOf("Läuft gerade"), titles)
}
@Test
fun `handles a missing schedule`() {
assertEquals(emptyList<Programme>(), XmltvParser.programmesIn(null, 0, 1_000))
}
}