20 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
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
24 changed files with 1323 additions and 341 deletions

107
README.md
View File

@@ -1,49 +1,80 @@
# Castarr
Native Google TV client for a [Dispatcharr](https://github.com/be-nj/Dispatcharr)
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.
**Live-TV auf dem Fernseher, eingerichtet in einer Minute — alles läuft übers Handy.**
## 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)
![Senderliste](docs/screenshots/senderliste.png)
## Building
## Was die App kann
Requires JDK 17+ and the Android SDK (platform 35). A JDK toolchain is
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.
| 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 assembleRelease # signed when CASTARR_STORE_* env vars are set
./gradlew assembleDebug # Debug-APK
./gradlew assembleRelease # signiert, wenn CASTARR_STORE_*-Env gesetzt ist
```
APK output: `app/build/outputs/apk/<variant>/`. Releases on the
[releases page](https://github.com/be-nj/castarr/releases) are signed; install
over an existing debug build requires uninstalling first (signature change).
APK-Ausgabe: `app/build/outputs/apk/<variant>/`. Die Releases hier im Repo
sind signiert; über einem Debug-Build muss einmal deinstalliert werden
(Signaturwechsel). Updates bezieht die App über die Release-API dieses
Repos, die APK selbst liegt auf dem Branch `apk`.
## Backend
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/).
Architektur-Notizen: [CONTEXT.md](CONTEXT.md) und [docs/adr/](docs/adr/).

View File

@@ -12,8 +12,8 @@ android {
applicationId = "dev.castarr.tv"
minSdk = 26
targetSdk = 35
versionCode = 6
versionName = "0.3.0"
versionCode = 22
versionName = "0.8.6"
}
// Release signing from environment (see ~/.keys/castarr-release.env on the
@@ -81,6 +81,7 @@ dependencies {
implementation("org.nanohttpd:nanohttpd:2.3.1")
implementation("org.nanohttpd:nanohttpd-websocket:2.3.1")
implementation("com.google.zxing:core:3.5.3")
implementation("io.coil-kt:coil-compose:2.7.0")
}
// Full JDK for javac via toolchain (host may only have a JRE); resolved by

View File

@@ -71,6 +71,14 @@
.btn-main:disabled { opacity: .35; box-shadow: none; }
.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;
border-radius: 2px; background: #1e2126; outline: 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">
<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 class="audiorow" id="audiorow"><span class="lbl">TON</span></div>
</section>
<!-- Sender -->
@@ -411,6 +420,19 @@
if (!state.volumeDragging && typeof s.volume === 'number') {
$('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() {

View File

@@ -38,6 +38,22 @@ class AppState(
var isLive by mutableStateOf(false)
var favoritesOnly by mutableStateOf(false)
var groupFilter by mutableStateOf<String?>(null)
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("")
/** 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). */
var welcomePhase by mutableStateOf(WelcomePhase.WAIT_PHONE)
@@ -158,13 +174,31 @@ class AppState(
}
fun syncFromPlayer() {
val previous = playerState
playerState = player.state
if (playerState != previous) overlayVisible = true
isLive = player.player.isCurrentMediaItemLive
positionMs = player.player.currentPosition.coerceAtLeast(0)
durationMs = player.player.duration.coerceAtLeast(0)
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
if (!player.hasMedia && playerVisible) {
playerVisible = false
currentChannel = null
}
}
/** 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

@@ -115,6 +115,10 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
.put("duration", player.player.duration.coerceAtLeast(0))
.put("volume", if (max > 0) vol.toDouble() / max else 0.0)
.put("error", player.errorMessage ?: "")
.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()
@@ -194,10 +198,26 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
// --- TV remote keys during playback (TV-PC / TV-PP) ---
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) {
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_ENTER,
KeyEvent.KEYCODE_ENTER -> {
state.pingOverlay()
return true
}
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
state.player.toggle()
return true
@@ -224,18 +244,24 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
state.zap(1)
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_MEDIA_REWIND -> {
onSeek(-SEEK_STEP_SECONDS)
if (state.player.player.isCurrentMediaItemSeekable) onSeek(-SEEK_STEP_SECONDS)
else state.pingOverlay()
return true
}
KeyEvent.KEYCODE_DPAD_RIGHT,
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
onSeek(SEEK_STEP_SECONDS)
if (state.player.player.isCurrentMediaItemSeekable) onSeek(SEEK_STEP_SECONDS)
else state.pingOverlay()
return true
}
KeyEvent.KEYCODE_BACK -> {
state.stopPlayback()
if (state.overlayVisible) state.overlayVisible = false
else state.stopPlayback()
return true
}
else -> Unit
@@ -244,6 +270,40 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
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 ---
override fun onStop() {
@@ -262,5 +322,6 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
private companion object {
const val TICK_INTERVAL_MS = 2_000L
const val SEEK_STEP_SECONDS = 10L
const val DIGIT_COMMIT_MS = 1_800L
}
}

View File

@@ -126,7 +126,12 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) {
name = obj.optString("effective_name").ifEmpty { obj.optString("name") },
url = streamUrl(uuid),
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") },
backendId = id,
streamKey = uuid,

View File

@@ -9,6 +9,12 @@ import java.util.Locale
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?)
/**

View File

@@ -1,12 +1,16 @@
package dev.castarr.tv.player
import android.content.Context
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
import java.util.Locale
/**
* Wraps ExoPlayer plus a MediaSession so the physical TV remote's play/pause
@@ -30,12 +34,17 @@ class PlayerController(
private var retryCount = 0
private var lastUrl: String = ""
/** One selectable audio track of the current stream. */
data class AudioTrack(val label: String, val selected: Boolean)
init {
player.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) = onChanged()
override fun onIsPlayingChanged(isPlaying: Boolean) = onChanged()
override fun onTracksChanged(tracks: Tracks) = onChanged()
override fun onPlayerError(error: PlaybackException) {
// Self-heal: silently reconnect twice before surfacing (#13).
if (retryCount < 2 && lastUrl.isNotEmpty()) {
@@ -105,6 +114,48 @@ class PlayerController(
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) {
if (!hasMedia || !player.isCurrentMediaItemSeekable) return
val target = (player.currentPosition + deltaSeconds * 1000)

View File

@@ -32,6 +32,7 @@ class ControlServer(
fun onResumeCast()
fun onStopCast()
fun onSeek(deltaSeconds: Long)
fun onSetAudio(index: Int)
fun onVolume(value: Float)
fun onSetPlaylist(url: String)
fun onToggleFavorite(channelId: Int)
@@ -201,6 +202,7 @@ class ControlServer(
"resume" -> post { listener.onResumeCast() }
"stop" -> post { listener.onStopCast() }
"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)) }
"set_playlist" -> post { listener.onSetPlaylist(msg.optString("url")) }
"set_favorite" -> post { listener.onToggleFavorite(msg.optInt("id")) }

View File

@@ -46,18 +46,18 @@ fun AdvancedScreen(state: AppState) {
.fillMaxSize()
.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))
Text(
"Eigene M3U-Quelle (ErsatzTV, Threadfin, Tunarr …) — optionales XMLTV-EPG. " +
"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))
TvTextField(
value = m3u,
onValueChange = { m3u = it },
label = { Text("M3U-URL", fontFamily = SpaceGrotesk) },
label = { Text("M3U-URL", fontFamily = AppFont) },
colors = fieldColors,
textStyle = TextStyle(fontSize = 14.sp),
modifier = Modifier.width(600.dp),
@@ -66,7 +66,7 @@ fun AdvancedScreen(state: AppState) {
TvTextField(
value = epg,
onValueChange = { epg = it },
label = { Text("XMLTV-EPG-URL (optional)", fontFamily = SpaceGrotesk) },
label = { Text("XMLTV-EPG-URL (optional)", fontFamily = AppFont) },
colors = fieldColors,
textStyle = TextStyle(fontSize = 14.sp),
modifier = Modifier.width(600.dp),
@@ -92,7 +92,7 @@ fun AdvancedScreen(state: AppState) {
else -> message
}
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.size
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.ui.Alignment
import androidx.compose.ui.Modifier
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.unit.dp
import androidx.compose.ui.unit.sp
@@ -22,11 +28,23 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.tv.material3.Surface
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.Text
import dev.castarr.tv.AppState
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
@Composable
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(
modifier = Modifier
.fillMaxSize()
@@ -48,6 +66,41 @@ fun CastarrApp(state: AppState) {
if (state.playerVisible) {
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,
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))
state.connectedRemote?.let { name ->
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -83,30 +128,59 @@ private fun TopBar(state: AppState) {
.background(CastarrColors.accent)
)
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
private fun TabItem(label: String, selected: Boolean, onClick: () -> Unit) {
private fun GearButton(onClick: () -> Unit) {
Surface(
onClick = onClick,
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)),
modifier = Modifier.semantics { contentDescription = "Einstellungen" },
shape = ClickableSurfaceDefaults.shape(CircleShape),
colors = ClickableSurfaceDefaults.colors(
containerColor = if (selected) CastarrColors.accentDim else Color.Transparent,
contentColor = if (selected) CastarrColors.accent else CastarrColors.muted,
containerColor = CastarrColors.surface,
contentColor = CastarrColors.muted,
focusedContainerColor = CastarrColors.accent,
focusedContentColor = CastarrColors.onAccent,
),
) {
Text(
label,
fontFamily = SpaceGrotesk,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(horizontal = 18.dp, vertical = 8.dp),
val color = LocalContentColor.current
Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) {
Canvas(Modifier.size(18.dp)) {
val center = Offset(size.width / 2f, size.height / 2f)
// Thick ring with a hole plus stubby teeth — thin lines
// 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",
)
}
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))
Text(
subtitle,
color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 15.sp,
color = CastarrColors.muted, fontFamily = AppFont, fontSize = 15.sp,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(26.dp))
@@ -78,7 +78,7 @@ fun ErrorScreen(state: AppState) {
) {
Text(
buttonLabel,
fontFamily = SpaceGrotesk, fontSize = 16.sp,
fontFamily = AppFont, fontSize = 16.sp,
modifier = Modifier.padding(horizontal = 26.dp, vertical = 13.dp),
)
}

View File

@@ -1,11 +1,13 @@
package dev.castarr.tv.ui
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -13,29 +15,46 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.SubcomposeAsyncImage
import androidx.compose.ui.layout.ContentScale
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import dev.castarr.tv.AppState
import dev.castarr.tv.data.NowNext
import dev.castarr.tv.data.isEpgPlaceholder
import dev.castarr.tv.playlist.Channel
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Live tab: group rail on the left (focusing a group opens it — exclusive,
* like a menu), channels of the open group with Now/Next on the right.
*/
@Composable
fun LiveScreen(state: AppState) {
val genericChannels by state.source.channels.collectAsStateWithLifecycle()
@@ -50,61 +69,137 @@ fun LiveScreen(state: AppState) {
val groups = remember(allChannels) {
allChannels.mapNotNull { it.group.ifEmpty { null } }.distinct().sorted()
}
val channels = allChannels
.let { list -> if (isDispatcharr && state.favoritesOnly) list.filter { it.backendId in favorites } else list }
.let { list -> state.groupFilter?.let { g -> list.filter { it.group == g } } ?: list }
val channels = when {
state.favoritesOnly -> allChannels.filter { it.backendId in favorites }
state.groupFilter != null -> allChannels.filter { it.group == state.groupFilter }
else -> allChannels
}
if (allChannels.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
"Noch keine Sender — richte unter Einstellungen eine Quelle ein.",
color = CastarrColors.faint,
fontFamily = SpaceGrotesk,
fontFamily = AppFont,
fontSize = 15.sp,
)
}
return
}
Column(Modifier.fillMaxSize()) {
LazyRow(
modifier = Modifier.padding(bottom = 6.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 40.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
// D-pad choreography: left from any channel row lands on the SELECTED
// group (not the spatially nearest one), right from the rail enters the
// list at the top — which also gets scrolled up on every group change.
val railFocus = remember { FocusRequester() }
val listFocus = remember { FocusRequester() }
val listState = rememberLazyListState()
LaunchedEffect(state.groupFilter, state.favoritesOnly) {
listState.scrollToItem(0)
}
val intoList = if (channels.isEmpty()) Modifier
else Modifier.focusProperties { right = listFocus }
Row(Modifier.fillMaxSize()) {
LazyColumn(
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 {
FilterChip("Alle", !state.favoritesOnly && state.groupFilter == null) {
val selected = !state.favoritesOnly && state.groupFilter == null
GroupItem(
label = "Alle Sender",
count = allChannels.size,
selected = selected,
modifier = intoList.then(
if (selected) Modifier.focusRequester(railFocus) else Modifier
),
) {
state.favoritesOnly = false
state.groupFilter = null
}
}
if (isDispatcharr) {
item {
FilterChip("★ Favoriten", state.favoritesOnly) {
state.favoritesOnly = !state.favoritesOnly
GroupItem(
label = "★ Favoriten",
count = favorites.size,
selected = state.favoritesOnly,
modifier = intoList.then(
if (state.favoritesOnly) Modifier.focusRequester(railFocus) else Modifier
),
) {
state.favoritesOnly = true
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 ->
FilterChip(group, state.groupFilter == group) {
state.groupFilter = if (state.groupFilter == group) null else group
}
}
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(
start = 40.dp, end = 40.dp, top = 8.dp, bottom = 32.dp
GroupItem(
label = group,
count = remember(allChannels, group) { allChannels.count { it.group == group } },
selected = state.groupFilter == group,
modifier = intoList.then(
if (state.groupFilter == group) Modifier.focusRequester(railFocus) else Modifier
),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(channels, key = { it.url + it.name }) { channel ->
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(
if (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(
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
.focusProperties { left = railFocus }
.then(if (listIndex == 0) Modifier.focusRequester(listFocus) else Modifier),
nowNext = state.nowNext(channel),
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,
onLongClick = if (isDispatcharr) {
{ state.dispatcharr.toggleFavorite(channel) }
@@ -116,30 +211,57 @@ fun LiveScreen(state: AppState) {
}
@Composable
private fun FilterChip(label: String, selected: Boolean, onClick: () -> Unit) {
private fun GroupItem(
label: String,
count: Int,
selected: Boolean,
modifier: Modifier = Modifier,
onSelect: () -> Unit,
) {
Surface(
onClick = onClick,
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)),
// Focusing a group opens it right away (browse like a menu);
// clicking is not required, but harmless.
onClick = onSelect,
modifier = modifier
.fillMaxWidth()
.onFocusChanged { if (it.isFocused) onSelect() },
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
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,
focusedContainerColor = CastarrColors.accent,
focusedContentColor = CastarrColors.onAccent,
),
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
label,
fontFamily = SpaceGrotesk,
fontSize = 12.sp,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
fontFamily = AppFont,
fontSize = 14.sp,
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
private fun ChannelRow(
channel: Channel,
index: Int,
number: Int,
modifier: Modifier = Modifier,
nowNext: NowNext,
playing: Boolean,
favorite: Boolean,
@@ -150,57 +272,58 @@ private fun ChannelRow(
Surface(
onClick = onClick,
onLongClick = onLongClick,
modifier = Modifier.fillMaxWidth(),
modifier = modifier.fillMaxWidth(),
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)),
// No focus scale — grown rows get clipped at the pane edges.
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
colors = ClickableSurfaceDefaults.colors(
containerColor = if (playing) CastarrColors.accentDim else Color.Transparent,
containerColor = if (playing) CastarrColors.accentDim else CastarrColors.surface,
contentColor = CastarrColors.fg,
focusedContainerColor = CastarrColors.surfaceFocused,
focusedContentColor = CastarrColors.fg,
),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
border = BorderStroke(2.dp, CastarrColors.accent),
shape = RoundedCornerShape(12.dp),
),
),
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"${index + 1}",
"$number",
color = if (playing) CastarrColors.accent else CastarrColors.faint,
fontFamily = SpaceGrotesk,
fontFamily = AppFont,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.width(44.dp),
modifier = Modifier.width(40.dp),
)
if (favorite) {
Text(
"",
color = CastarrColors.accent,
fontSize = 13.sp,
modifier = Modifier.width(20.dp),
)
}
Column(Modifier.weight(1f)) {
LogoTile(channel)
Spacer(Modifier.width(14.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.width(230.dp),
) {
Text(
channel.name,
color = if (playing) CastarrColors.accent else CastarrColors.fg,
fontFamily = SpaceGrotesk,
fontFamily = AppFont,
fontSize = 16.sp,
fontWeight = if (playing) FontWeight.Medium else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
if (channel.group.isNotEmpty()) {
Text(
channel.group,
color = CastarrColors.faint,
fontFamily = SpaceGrotesk,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (favorite) {
Spacer(Modifier.width(8.dp))
Text("", color = CastarrColors.accent, fontSize = 13.sp)
}
}
NowNextCell(nowNext)
Spacer(Modifier.width(24.dp))
EpgCell(nowNext, Modifier.weight(1f))
if (playing) {
Spacer(Modifier.width(12.dp))
Box(
@@ -214,25 +337,76 @@ private fun ChannelRow(
}
}
/** Bare channel logo (they ship transparent); initials as fallback. */
@Composable
private fun NowNextCell(nowNext: NowNext) {
val now = nowNext.now
Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(320.dp)) {
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(
initials,
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
private fun formatClock(millis: Long): String =
SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis))
/**
* Left-aligned EPG column with a fixed leading edge: title + times, progress
* under the title, next programme only when it differs (design review P1/P2).
*/
@Composable
private fun EpgCell(nowNext: NowNext, modifier: Modifier = Modifier) {
val now = nowNext.now?.takeUnless { isEpgPlaceholder(it.title) }
Column(modifier) {
if (now != null) {
Row(verticalAlignment = Alignment.Bottom) {
Text(
now.title,
color = CastarrColors.muted,
fontFamily = SpaceGrotesk,
fontSize = 13.sp,
color = CastarrColors.fg,
fontFamily = AppFont,
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
Spacer(Modifier.width(12.dp))
Text(
"${formatClock(now.start)}${formatClock(now.stop)}",
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 12.sp,
)
}
val fraction = ((System.currentTimeMillis() - now.start).toFloat() /
(now.stop - now.start).coerceAtLeast(1)).coerceIn(0f, 1f)
Spacer(Modifier.height(5.dp))
Spacer(Modifier.height(6.dp))
Box(
Modifier
.width(180.dp)
.fillMaxWidth()
.height(2.dp)
.clip(RoundedCornerShape(1.dp))
.background(CastarrColors.line)
@@ -244,13 +418,16 @@ private fun NowNextCell(nowNext: NowNext) {
.background(CastarrColors.accent)
)
}
nowNext.next?.let { next ->
Spacer(Modifier.height(4.dp))
val next = nowNext.next?.takeUnless {
isEpgPlaceholder(it.title) || it.title == now.title
}
if (next != null) {
Spacer(Modifier.height(5.dp))
Text(
"danach: ${next.title}",
color = CastarrColors.faint,
fontFamily = SpaceGrotesk,
fontSize = 11.sp,
fontFamily = AppFont,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)

View File

@@ -1,6 +1,6 @@
package dev.castarr.tv.ui
import android.view.KeyEvent
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
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.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -25,29 +28,51 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
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.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.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
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 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
* play/pause/zapping lives in MainActivity.onKeyDown; this composable only
* grabs focus so list items underneath stop reacting.
* Fullscreen playback. OK opens the auto-hiding overlay whose transport
* buttons are ordinary focusable controls; media/channel keys are handled
* in MainActivity.onKeyDown.
*/
@Composable
fun PlayerScreen(state: AppState) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.requestFocus() }
LaunchedEffect(state.playerVisible, state.playerState) {
state.overlayVisible = true
if (state.playerState == "playing") {
kotlinx.coroutines.delay(4000)
LaunchedEffect(state.overlayVisible) {
// While the overlay is hidden the root grabs focus so list items
// underneath stop reacting; the overlay's play button takes over
// when it appears.
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
}
}
@@ -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
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(
Modifier
.fillMaxWidth()
.height(200.dp)
.height(280.dp)
.align(Alignment.BottomCenter)
.background(
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),
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))
Box(
Modifier
@@ -112,63 +170,219 @@ private fun Overlay(state: AppState) {
.align(Alignment.BottomStart)
.fillMaxWidth()
.padding(horizontal = 36.dp)
.padding(bottom = 30.dp),
.padding(bottom = 24.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (state.isLive) {
Box(
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) {
// No status word while simply playing, and no LIVE badge — a
// red dot reads as "recording" (user feedback).
val statusWord = when (state.playerState) {
"paused" -> "Pausiert"
"buffering" -> "Lädt…"
"error" -> "Wiedergabefehler"
else -> "Wird abgespielt"
},
else -> null
}
if (statusWord != null) {
Row {
Spacer(Modifier.weight(1f))
Text(
statusWord,
color = CastarrColors.muted,
fontFamily = SpaceGrotesk,
fontFamily = AppFont,
style = OverlayText,
fontSize = 12.sp,
)
}
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(
state.currentChannel?.name.orEmpty(),
color = CastarrColors.fg,
fontFamily = SpaceGrotesk,
fontFamily = AppFont,
style = OverlayText,
fontSize = 30.sp,
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(
Modifier
.fillMaxWidth()
.height(2.dp)
.clip(RoundedCornerShape(1.dp))
.background(CastarrColors.line)
.height(3.dp)
.clip(RoundedCornerShape(1.5.dp))
.background(Color(0x59FFFFFF))
) {
Box(
Modifier
.fillMaxWidth(fraction)
.height(2.dp)
.height(3.dp)
.background(CastarrColors.accent)
)
}
Spacer(Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
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,26 @@
package dev.castarr.tv.ui
import android.graphics.Bitmap
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -23,10 +30,18 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
@@ -37,9 +52,18 @@ import dev.castarr.tv.pairing.Qr
import dev.castarr.tv.update.UpdateChecker
import kotlinx.coroutines.launch
/** One open picker dialog: which setting, its options, what happens on pick. */
private data class Picker(
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
* fields, no scrolling. Everything text-heavy lives in the Erweitert screen.
* Google-TV-style settings: cards with full-width focusable rows; choices
* open a picker dialog (Konto / Wiedergabe / App / Handy-Fernbedienung).
* Ten-Foot-Regel (#14): no text fields, no scrolling.
*/
@Composable
fun SettingsScreen(state: AppState) {
@@ -47,133 +71,385 @@ fun SettingsScreen(state: AppState) {
val scope = rememberCoroutineScope()
val profiles by state.dispatcharr.profiles.collectAsState()
var updateStatus by remember { mutableStateOf("") }
var picker by remember { mutableStateOf<Picker?>(null) }
Row(
Modifier
.fillMaxSize()
.padding(horizontal = 40.dp, vertical = 20.dp)
.padding(horizontal = 40.dp, vertical = 12.dp)
) {
Column(Modifier.weight(1.2f)) {
SectionTitle("Konto")
Spacer(Modifier.height(8.dp))
Column(Modifier.weight(1.25f), verticalArrangement = Arrangement.spacedBy(16.dp)) {
SettingsCard("Konto") {
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(
"Angemeldet als ${state.auth.username}",
color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 14.sp,
state.auth.username.take(1).uppercase().ifEmpty { "?" },
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))
Chip("Eigene M3U", state.sourceMode == AppState.SourceMode.GENERIC) {
state.setMode(AppState.SourceMode.GENERIC)
Spacer(Modifier.width(14.dp))
Column {
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))
}
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() }
SettingRow("Abmelden", danger = true) { state.startOnboarding() }
} else {
Text(
"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))
ActionButton("Jetzt anmelden") { state.startOnboarding() }
SettingRow("Jetzt anmelden") { state.startOnboarding() }
}
}
Spacer(Modifier.height(28.dp))
SectionTitle("App")
Spacer(Modifier.height(8.dp))
Text(
"Version ${BuildConfig.VERSION_NAME}" + (state.updateAvailable?.let { " · $it verfügbar" } ?: ""),
color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 12.sp,
SettingsCard("Wiedergabe") {
if (state.auth.isLoggedIn && state.source.m3uUrl.isNotEmpty()) {
val modes = listOf("Dispatcharr", "Eigene M3U")
val modeIndex = if (state.sourceMode == AppState.SourceMode.DISPATCHARR) 0 else 1
SettingRow(
"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("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 {
updateStatus = if (state.updateAvailable != null) {
UpdateChecker.downloadAndInstall(context, state) ?: "Update wird geöffnet…"
} else {
when (val v = UpdateChecker.check(state)) {
updateStatus = when (val v = UpdateChecker.check(state)) {
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))
Text(updateStatus, color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 12.sp)
}
SettingRow(
"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) {
SectionTitle("Handy-Fernbedienung")
Spacer(Modifier.height(10.dp))
Column(Modifier.weight(0.75f)) {
SettingsCard("Handy-Fernbedienung") {
val address = remember { Pairing.lanAddress() }
if (address != null) {
val qr = remember(address) {
Qr.encode(Pairing.pairingUrl(context, address), 400, android.graphics.Color.parseColor("#101216"))
Qr.encode(
Pairing.pairingUrl(context, address), 400,
android.graphics.Color.parseColor("#101216"),
)
}
Column(
Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
QrCard(qr)
Spacer(Modifier.height(10.dp))
Text(
"${Pairing.remoteUrl(address)} · Code ${Pairing.code(context)}",
color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 12.sp,
"Mit der Handy-Kamera scannen",
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(
label,
fontFamily = SpaceGrotesk, fontSize = 12.sp,
"${Pairing.remoteUrl(address)} · Code ${Pairing.code(context)}",
color = CastarrColors.muted, fontFamily = AppFont, fontSize = 12.sp,
)
}
} else {
Text(
"Keine Netzwerkverbindung",
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp,
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() }
LaunchedEffect(Unit) { selectedFocus.requestFocus() }
Dialog(
onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Box(
Modifier
.fillMaxSize()
// Own heavy scrim — the platform default is too light and
// lets the white QR card fight the panel.
.background(CastarrColors.bgDeep.copy(alpha = 0.88f)),
contentAlignment = Alignment.Center,
) {
Column(
Modifier
.width(360.dp)
.clip(RoundedCornerShape(16.dp))
.background(CastarrColors.surface)
.padding(horizontal = 10.dp, vertical = 14.dp)
) {
Text(
picker.title.uppercase(),
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 2.sp,
modifier = Modifier.padding(start = 14.dp, bottom = 10.dp),
)
picker.options.forEachIndexed { index, option ->
val selected = index == picker.selected
Surface(
onClick = {
picker.onPick(index)
onClose()
},
modifier = Modifier
.fillMaxWidth()
.then(if (selected) Modifier.focusRequester(selectedFocus) else Modifier),
shape = ClickableSurfaceDefaults.shape(rowShape),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
colors = ClickableSurfaceDefaults.colors(
containerColor = androidx.compose.ui.graphics.Color.Transparent,
contentColor = CastarrColors.fg,
focusedContainerColor = CastarrColors.surfaceFocused,
focusedContentColor = CastarrColors.fg,
),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
border = BorderStroke(2.dp, CastarrColors.accent),
shape = rowShape,
),
),
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 11.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
option,
color = if (selected) CastarrColors.accent else CastarrColors.fg,
fontFamily = AppFont,
fontSize = 14.sp,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (selected) {
Box(
Modifier
.size(7.dp)
.clip(CircleShape)
.background(CastarrColors.accent)
)
}
}
}
}
}
}
}
}
/** Current value plus a small drawn caret, shown at a row's trailing edge. */
@Composable
private fun ValueWithCaret(value: String) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
value,
color = CastarrColors.muted,
fontFamily = AppFont,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.width(8.dp))
val color = CastarrColors.faint
Canvas(Modifier.size(9.dp)) {
drawPath(
Path().apply {
moveTo(0f, size.height * 0.3f)
lineTo(size.width, size.height * 0.3f)
lineTo(size.width / 2f, size.height * 0.75f)
close()
},
color,
)
}
}
}
@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
@@ -190,7 +466,7 @@ fun ActionButton(label: String, danger: Boolean = false, onClick: () -> Unit) {
) {
Text(
label,
fontFamily = SpaceGrotesk, fontSize = 13.sp,
fontFamily = AppFont, fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
)
}
@@ -198,17 +474,27 @@ fun ActionButton(label: String, danger: Boolean = false, onClick: () -> Unit) {
@Composable
fun QrCard(bitmap: Bitmap) {
// Quiet zone stays white for scannability, but the tile sits in a
// bordered frame on the card instead of floating as a bare white block.
Column(
Modifier
.clip(RoundedCornerShape(16.dp))
.background(androidx.compose.ui.graphics.Color(0xFFFBFCFD))
.padding(14.dp),
.clip(RoundedCornerShape(18.dp))
.background(CastarrColors.bg)
.border(1.dp, CastarrColors.line, RoundedCornerShape(18.dp))
.padding(8.dp),
verticalArrangement = Arrangement.Center,
) {
Column(
Modifier
.clip(RoundedCornerShape(12.dp))
.background(androidx.compose.ui.graphics.Color(0xFFF4F6F7))
.padding(10.dp),
) {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = "QR-Code zum Koppeln",
modifier = Modifier.size(170.dp),
modifier = Modifier.size(164.dp),
)
}
}
}

View File

@@ -21,6 +21,15 @@ object CastarrColors {
val onAccent = Color(0xFF07110F)
}
/** Display face — only for the CASTARR wordmark. */
val SpaceGrotesk = FontFamily(
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)) {
Text(
"Willkommen bei Castarr",
color = CastarrColors.fg, fontFamily = SpaceGrotesk,
color = CastarrColors.fg, fontFamily = AppFont,
fontSize = 32.sp, fontWeight = FontWeight.Medium,
)
Spacer(Modifier.height(28.dp))
@@ -90,11 +90,11 @@ fun WelcomeScreen(state: AppState) {
Spacer(Modifier.height(12.dp))
Text(
Pairing.remoteUrl(address),
color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 13.sp,
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp,
)
}
} 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(
if (done) "" else "$number",
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))
Text(
text,
color = if (active) CastarrColors.fg else CastarrColors.muted,
fontFamily = SpaceGrotesk, fontSize = 17.sp,
fontFamily = AppFont, fontSize = 17.sp,
)
}
}
@Composable
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.URL
/** In-app updater over GitHub releases (#12). Explicit, never automatic. */
/** In-app updater over Gitea releases (#12). Explicit, never automatic. */
object 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/benjamin/castarr/releases/latest"
// Release assets need an API token to upload, so the APK is served from
// an orphan branch instead — anonymously fetchable on a public repo.
private const val APK_FALLBACK =
"https://git.beckm4nn.net/benjamin/castarr/raw/branch/apk/castarr.apk"
private var apkUrl: String = ""
@@ -35,6 +41,7 @@ object UpdateChecker {
break
}
}
if (url.isEmpty() && tag.isNotEmpty()) url = APK_FALLBACK
if (url.isNotEmpty() && isNewer(tag, BuildConfig.VERSION_NAME)) {
apkUrl = url
val version = "v$tag"
@@ -69,7 +76,9 @@ object UpdateChecker {
setDataAndType(uri, "application/vnd.android.package-archive")
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
}.getOrElse {
Log.w(TAG, "install failed: ${it.javaClass.simpleName}")

Binary file not shown.

Binary file not shown.

Binary file not shown.

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