Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e66c8a863b | ||
|
|
ca401e8ca3 | ||
|
|
844ea928ef | ||
|
|
95f8793938 |
107
README.md
107
README.md
@@ -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
|

|
||||||
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 |
|
||||||
|
| --- | --- |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
## 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/).
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ android {
|
|||||||
applicationId = "dev.castarr.tv"
|
applicationId = "dev.castarr.tv"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 19
|
versionCode = 22
|
||||||
versionName = "0.8.3"
|
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
|
||||||
|
|||||||
@@ -337,24 +337,18 @@ private fun ChannelRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Channel logo in a small tile; initials while loading or without a logo. */
|
/** Bare channel logo (they ship transparent); initials as fallback. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun LogoTile(channel: Channel) {
|
private fun LogoTile(channel: Channel) {
|
||||||
val initials = channel.name.split(" ").filter { it.isNotBlank() }
|
val initials = channel.name.split(" ").filter { it.isNotBlank() }
|
||||||
.take(2).map { it.first() }.joinToString("").uppercase()
|
.take(2).map { it.first() }.joinToString("").uppercase()
|
||||||
Box(
|
Box(Modifier.size(width = 52.dp, height = 40.dp), contentAlignment = Alignment.Center) {
|
||||||
Modifier
|
|
||||||
.size(38.dp)
|
|
||||||
.clip(RoundedCornerShape(8.dp))
|
|
||||||
.background(CastarrColors.surfaceFocused),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
if (channel.logo.isNotEmpty()) {
|
if (channel.logo.isNotEmpty()) {
|
||||||
SubcomposeAsyncImage(
|
SubcomposeAsyncImage(
|
||||||
model = channel.logo,
|
model = channel.logo,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
modifier = Modifier.fillMaxSize().padding(5.dp),
|
modifier = Modifier.fillMaxSize(),
|
||||||
loading = { LogoInitials(initials) },
|
loading = { LogoInitials(initials) },
|
||||||
error = { LogoInitials(initials) },
|
error = { LogoInitials(initials) },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
Text(
|
Row(verticalAlignment = Alignment.Bottom) {
|
||||||
state.currentChannel?.name.orEmpty(),
|
// Channel number inline before the name (matches the list
|
||||||
color = CastarrColors.fg,
|
// and the number-pad zapping).
|
||||||
fontFamily = AppFont,
|
val number = state.currentChannel?.let { current ->
|
||||||
fontSize = 30.sp,
|
state.activeChannels().indexOfFirst { it.url == current.url } + 1
|
||||||
fontWeight = FontWeight.Medium,
|
} ?: 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(
|
||||||
|
state.currentChannel?.name.orEmpty(),
|
||||||
|
color = CastarrColors.fg,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
style = OverlayText,
|
||||||
|
fontSize = 30.sp,
|
||||||
|
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),
|
||||||
|
|||||||
BIN
docs/screenshots/nummerntasten.png
Normal file
BIN
docs/screenshots/nummerntasten.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 172 KiB |
BIN
docs/screenshots/player.png
Normal file
BIN
docs/screenshots/player.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
BIN
docs/screenshots/senderliste.png
Normal file
BIN
docs/screenshots/senderliste.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 167 KiB |
Reference in New Issue
Block a user