3 Commits

Author SHA1 Message Date
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
6 changed files with 116 additions and 51 deletions

107
README.md
View File

@@ -1,49 +1,80 @@
# 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](docs/screenshots/senderliste.png)
channel/D-pad keys, fullscreen playback (ExoPlayer, HLS + MPEG-TS) with a
minimal auto-hiding overlay
- **Login without typing**: the TV shows a QR code and a user code; confirm on
your phone at your identity provider (OIDC device authorization grant). The
only thing entered on the TV is the Dispatcharr URL.
- **Dispatcharr source**: channels, groups and EPG via the fork's
Bearer-authenticated API; favorites are stored per user on the backend
(long-press a channel to star it, filter chip in the list)
- **Stream profiles**: pick Standard / Passthrough / audiofix / 720p (list
comes from the backend) — applied per stream request
- **Generic source fallback**: any M3U (+ optional XMLTV) URL works without a
login — ErsatzTV, Threadfin, Tunarr, plain playlists
- **Phone remote** (optional): scan the QR under Einstellungen; play/pause,
zapping, volume, channel list with Now/Next and favorite toggling in the
browser — no app install, rate-limited pairing code as fallback
- Physical remote keys and Google Assistant keep working (Media3
MediaSession, TV app quality guidelines TV-PC/TV-PP/TV-VC)
## Building ## Was die App kann
Requires JDK 17+ and the Android SDK (platform 35). A JDK toolchain is - **Senderliste mit Logos und Programm** — was gerade läuft, wie lange noch,
resolved automatically (foojay) if the host only has a JRE. 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.
| Player | Nummerntasten |
| --- | --- |
| ![Player](docs/screenshots/player.png) | ![Nummerntasten](docs/screenshots/nummerntasten.png) |
## 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/benjamin/castarr/raw/branch/apk/castarr.apk`
3. Installation bestätigen („Unbekannte Quellen" für Downloader erlauben,
wenn der Fernseher fragt).
4. Castarr starten und den drei Schritten auf dem Bildschirm folgen:
QR-Code scannen, auf dem Handy Server angeben und anmelden — den Rest
macht der Fernseher.
Danach hält sich die App über den eingebauten Updater selbst aktuell.
---
## Für Technikinteressierte
Native Kotlin-App (Jetpack Compose for TV, Media3/ExoPlayer für HLS und
MPEG-TS). Als Backend dient ein [Dispatcharr](https://github.com/be-nj/Dispatcharr)-Fork
mit OIDC-Unterstützung: Anmeldung per Device Authorization Grant (der
Fernseher zeigt QR + Code, bestätigt wird am Handy beim Identity Provider),
API-Zugriff per Bearer-Token, Favoriten und Ausgabeprofile pro Nutzer.
Ohne Dispatcharr funktioniert auch jede M3U-Playlist (+ optionales XMLTV)
als generische Quelle — ErsatzTV, Threadfin, Tunarr, einfache Playlists.
Die Handy-Fernbedienung serviert der Fernseher selbst (eingebetteter
HTTP/WebSocket-Server, Kopplung per QR-Token mit ratenlimitiertem
Zahlencode als Fallback). Physische Mediatasten und Google Assistant
funktionieren über eine Media3 MediaSession.
### Selbst bauen
JDK 17+ und Android SDK (Platform 35) erforderlich; eine passende
JDK-Toolchain wird automatisch aufgelöst (foojay).
``` ```
./gradlew assembleDebug # debug APK ./gradlew assembleDebug # Debug-APK
./gradlew assembleRelease # signed when CASTARR_STORE_* env vars are set ./gradlew assembleRelease # signiert, wenn CASTARR_STORE_*-Env gesetzt ist
``` ```
APK output: `app/build/outputs/apk/<variant>/`. Releases on the APK-Ausgabe: `app/build/outputs/apk/<variant>/`. Die Releases hier im Repo
[releases page](https://git.beckm4nn.net/benjamin/castarr/releases) are signed; install sind signiert; über einem Debug-Build muss einmal deinstalliert werden
over an existing debug build requires uninstalling first (signature change). (Signaturwechsel). Updates bezieht die App über die Release-API dieses
Repos, die APK selbst liegt auf dem Branch `apk`.
## Backend Architektur-Notizen: [CONTEXT.md](CONTEXT.md) und [docs/adr/](docs/adr/).
The full feature set needs the Dispatcharr fork with OIDC support
(be-nj/Dispatcharr): its `/api/accounts/oidc/status/` endpoint hands the app
the issuer and public device-flow client id. Configuration lives in
[CONTEXT.md](CONTEXT.md) and [docs/adr/](docs/adr/).

View File

@@ -12,8 +12,8 @@ android {
applicationId = "dev.castarr.tv" applicationId = "dev.castarr.tv"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 20 versionCode = 22
versionName = "0.8.4" versionName = "0.8.6"
} }
// Release signing from environment (see ~/.keys/castarr-release.env on the // Release signing from environment (see ~/.keys/castarr-release.env on the

View File

@@ -28,7 +28,9 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.key
@@ -37,6 +39,7 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -98,6 +101,11 @@ fun PlayerScreen(state: AppState) {
} }
} }
/** Soft drop shadow so overlay text stays readable on bright video. */
private val OverlayText = TextStyle(
shadow = Shadow(Color(0xB3000000), Offset(0f, 2f), blurRadius = 8f),
)
private fun formatClock(millis: Long): String = private fun formatClock(millis: Long): String =
SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis)) SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis))
@@ -146,7 +154,8 @@ private fun Overlay(state: AppState) {
.padding(top = 22.dp, end = 30.dp), .padding(top = 22.dp, end = 30.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text(name, color = CastarrColors.muted, fontFamily = AppFont, fontSize = 12.sp) Text(name, color = CastarrColors.muted, fontFamily = AppFont,
style = OverlayText, fontSize = 12.sp)
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Box( Box(
Modifier Modifier
@@ -178,18 +187,38 @@ private fun Overlay(state: AppState) {
statusWord, statusWord,
color = CastarrColors.muted, color = CastarrColors.muted,
fontFamily = AppFont, fontFamily = AppFont,
style = OverlayText,
fontSize = 12.sp, fontSize = 12.sp,
) )
} }
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
} }
Row(verticalAlignment = Alignment.Bottom) {
// Channel number inline before the name (matches the list
// and the number-pad zapping).
val number = state.currentChannel?.let { current ->
state.activeChannels().indexOfFirst { it.url == current.url } + 1
} ?: 0
if (number > 0) {
Text(
"$number",
color = CastarrColors.accent,
fontFamily = AppFont,
style = OverlayText,
fontSize = 30.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.width(14.dp))
}
Text( Text(
state.currentChannel?.name.orEmpty(), state.currentChannel?.name.orEmpty(),
color = CastarrColors.fg, color = CastarrColors.fg,
fontFamily = AppFont, fontFamily = AppFont,
style = OverlayText,
fontSize = 30.sp, fontSize = 30.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
) )
}
val info = state.currentChannel?.let { state.nowNext(it) } val info = state.currentChannel?.let { state.nowNext(it) }
val now = info?.now?.takeUnless { isEpgPlaceholder(it.title) } val now = info?.now?.takeUnless { isEpgPlaceholder(it.title) }
@@ -200,13 +229,15 @@ private fun Overlay(state: AppState) {
now.title, now.title,
color = CastarrColors.fg, color = CastarrColors.fg,
fontFamily = AppFont, fontFamily = AppFont,
style = OverlayText,
fontSize = 18.sp, fontSize = 18.sp,
) )
Spacer(Modifier.width(14.dp)) Spacer(Modifier.width(14.dp))
Text( Text(
"${formatClock(now.start)} ${formatClock(now.stop)}", "${formatClock(now.start)} ${formatClock(now.stop)}",
color = CastarrColors.muted, color = Color(0xFFD7DBE0),
fontFamily = AppFont, fontFamily = AppFont,
style = OverlayText,
fontSize = 14.sp, fontSize = 14.sp,
) )
} }
@@ -217,8 +248,9 @@ private fun Overlay(state: AppState) {
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
Text( Text(
"Danach: ${next.title} (${formatClock(next.start)})", "Danach: ${next.title} (${formatClock(next.start)})",
color = CastarrColors.faint, color = CastarrColors.muted,
fontFamily = AppFont, fontFamily = AppFont,
style = OverlayText,
fontSize = 14.sp, fontSize = 14.sp,
) )
} }
@@ -238,7 +270,7 @@ private fun Overlay(state: AppState) {
.fillMaxWidth() .fillMaxWidth()
.height(3.dp) .height(3.dp)
.clip(RoundedCornerShape(1.5.dp)) .clip(RoundedCornerShape(1.5.dp))
.background(CastarrColors.line) .background(Color(0x59FFFFFF))
) { ) {
Box( Box(
Modifier Modifier
@@ -267,6 +299,7 @@ private fun Overlay(state: AppState) {
"▲ ▼ Sender wechseln", "▲ ▼ Sender wechseln",
color = CastarrColors.faint, color = CastarrColors.faint,
fontFamily = AppFont, fontFamily = AppFont,
style = OverlayText,
fontSize = 12.sp, fontSize = 12.sp,
) )
} }
@@ -346,6 +379,7 @@ private fun PillButton(
Text( Text(
label, label,
fontFamily = AppFont, fontFamily = AppFont,
style = OverlayText,
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
modifier = Modifier.padding(horizontal = 18.dp, vertical = 9.dp), modifier = Modifier.padding(horizontal = 18.dp, vertical = 9.dp),

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

BIN
docs/screenshots/player.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB