0.2.2 + 0.3.0: remote threading fix, phone-first onboarding, ten-foot settings, self-healing errors, in-app updater, group filter
Some checks failed
Build TV app / build (push) Failing after 2s

Squashed history rewrite: earlier revisions of the docs carried private
hostnames; placeholders throughout history now.

- Fix phone remote threading (welcome/get_state/broadcast) — remote connects
  and mirrors channels, Now/Next, favorites
- Phone-first onboarding (ADR-0007): one QR, server URL + IdP login on the
  phone (closes #11)
- Ten-foot settings + Erweitert sub-screen (closes #14), TvTextField D-pad
  focus fix (closes #9)
- EPG grid envelope parsing, Now/Next live (closes #10)
- Self-healing error states (closes #13), in-app updater (closes #12)
- Channel group filter chips fed by Dispatcharr groups

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 23:37:15 +02:00
parent 3e5bbbb24f
commit 20e8eb1874
20 changed files with 940 additions and 313 deletions

View File

@@ -18,8 +18,15 @@ _Avoid_: pairing (that word is reserved for the Remote)
**Backend (Dispatcharr)**:
The self-hosted stream manager owning sources, aggregation, failover, EPG and
stream profiles; forked at be-nj with native OIDC.
_Avoid_: server (ambiguous), nodecast (dropped, see ADR-0003)
stream profiles; forked at be-nj with native OIDC. Admin UI internal
(<admin-host>), API external for TV clients (api.<viewer-host>).
_Avoid_: server (ambiguous)
**Viewer (nodecast)**:
The browser frontend for the family on <viewer-host> — OIDC login, plays
via its own transcode; consumes exactly one source, the Backend's M3U/EPG
output (ADR-0006).
_Avoid_: treating it as a second source of truth
**Quelle (Source)**:
A configured backend the TV-App reads channels from — primary type Dispatcharr
@@ -38,6 +45,16 @@ Trusting a Remote via the QR token (128-bit) or the rate-limited 4-digit code.
A playable live entry the Backend exposes via Xtream API or M3U.
_Avoid_: station, sender (in code/docs)
**Onboarding**:
First-run flow: the TV shows one QR (its own pairing server); server URL and
the IdP login then happen entirely on the phone via the Remote. Nothing is
ever typed on the TV.
**Ten-Foot-Regel**:
In the family path the TV UI has no text fields and no scrolling except the
channel list itself; every screen fits the screen. Text-heavy configuration
lives in an "Erweitert" sub-screen.
**Now/Next**:
The EPG scope of the Remote in phase 1: current + upcoming programme per
Channel, no full guide timeline.

View File

@@ -12,8 +12,8 @@ android {
applicationId = "dev.castarr.tv"
minSdk = 26
targetSdk = 35
versionCode = 4
versionName = "0.2.1"
versionCode = 6
versionName = "0.3.0"
}
// Release signing from environment (see ~/.keys/castarr-release.env on the
@@ -53,6 +53,7 @@ android {
buildFeatures {
compose = true
buildConfig = true
}
lint {
@@ -71,6 +72,7 @@ dependencies {
implementation("androidx.tv:tv-material:1.0.0")
implementation("androidx.compose.material3:material3")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("androidx.media3:media3-exoplayer:1.4.1")
implementation("androidx.media3:media3-exoplayer-hls:1.4.1")

View File

@@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-feature android:name="android.software.leanback" android:required="false" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
@@ -32,5 +33,15 @@
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@@ -129,6 +129,8 @@
/* --- Pairing --- */
#view-pair { justify-content: center; align-items: center; gap: 26px; padding: 24px; text-align: center; }
#view-setup { justify-content: center; align-items: center; gap: 18px; padding: 24px; }
#setup-url:focus { border-color: #5fd4c4; }
#view-pair h1 { font-size: 24px; font-weight: 500; }
#view-pair p { font-size: 14px; color: #9aa0a8; font-weight: 300; max-width: 280px; line-height: 1.5; }
#code-input { background: #0a0b0d; border: 1px solid #1e2126; border-radius: 12px; height: 56px;
@@ -164,6 +166,20 @@
<div id="pair-error"></div>
</section>
<!-- TV-Einrichtung (Onboarding) -->
<section class="view" id="view-setup">
<h1 style="font-size:24px;font-weight:500;text-align:center">TV einrichten</h1>
<p style="font-size:14px;color:#9aa0a8;font-weight:300;max-width:300px;line-height:1.5;text-align:center">
Gib die Adresse deines TV-Servers an — den Rest erledigt die Anmeldung.
</p>
<input id="setup-url" type="url" placeholder="https://tv.example.com" autocapitalize="off" autocorrect="off"
style="background:#0a0b0d;border:1px solid #1e2126;border-radius:12px;height:52px;width:280px;padding:0 14px;font-size:15px;outline:none;text-align:center">
<button class="btn-accent" id="setup-send" style="width:280px">Verbinden</button>
<a id="setup-login" target="_blank" rel="noopener" class="btn-accent"
style="display:none;width:280px;text-align:center;line-height:46px;text-decoration:none">Jetzt anmelden</a>
<div id="setup-status" style="color:#9aa0a8;font-size:13px;min-height:18px;text-align:center"></div>
</section>
<!-- Steuerung -->
<section class="view" id="view-remote">
<div class="nowcard" id="nowcard">
@@ -319,7 +335,15 @@
if (msg.extras) { state.extras = msg.extras; }
state.playlistUrl = msg.playlistUrl || '';
$('pair-error').textContent = '';
showView('view-remote');
state.setupMode = !!msg.setup;
if (state.setupMode) {
$('setup-url').value = localStorage.getItem('castarr_server_url') || '';
$('setup-login').style.display = 'none';
$('setup-status').textContent = '';
showView('view-setup');
} else {
showView('view-remote');
}
renderAll();
break;
case 'status':
@@ -335,6 +359,21 @@
break;
case 'toast':
toast(msg.message);
if (state.setupMode) { $('setup-status').textContent = msg.message; $('setup-send').disabled = false; }
break;
case 'login_link': {
const link = $('setup-login');
link.href = msg.url;
link.style.display = 'block';
$('setup-status').textContent = 'Code: ' + (msg.code || '') + ' — nach der Anmeldung geht es hier automatisch weiter.';
showView('view-setup');
break;
}
case 'setup_done':
state.setupMode = false;
localStorage.setItem('castarr_server_url', $('setup-url').value.trim());
toast('Fertig! Der Fernseher ist eingerichtet.');
showView('view-remote');
break;
case 'error':
if (msg.error === 'bad_code' || msg.error === 'rate_limited') {
@@ -512,6 +551,14 @@
renderChannels();
});
$('setup-send').addEventListener('click', () => {
const url = $('setup-url').value.trim();
if (!url) { $('setup-status').textContent = 'Bitte Server-Adresse angeben.'; return; }
$('setup-send').disabled = true;
$('setup-status').textContent = 'Verbinde mit Server…';
send({ type: 'configure_server', url });
});
$('pair-btn').addEventListener('click', () => {
const code = $('code-input').value.trim();
if (code.length !== 4) { $('pair-error').textContent = 'Bitte 4 Ziffern eingeben.'; return; }

View File

@@ -1,6 +1,8 @@
package dev.castarr.tv
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
@@ -17,7 +19,7 @@ import dev.castarr.tv.playlist.Channel
* main thread (activity callbacks, remote-control listener).
*/
class AppState(
context: Context,
private val context: Context,
val player: PlayerController,
val source: SourceRepository,
val auth: DeviceAuth,
@@ -35,6 +37,17 @@ class AppState(
var durationMs by mutableLongStateOf(0L)
var isLive by mutableStateOf(false)
var favoritesOnly by mutableStateOf(false)
var groupFilter by mutableStateOf<String?>(null)
/** Phone-first onboarding progress (ADR-0007). */
var welcomePhase by mutableStateOf(WelcomePhase.WAIT_PHONE)
var welcomeUserCode by mutableStateOf("")
/** Plain-language fullscreen error state (issue #13). */
var appError by mutableStateOf(AppError.NONE)
/** In-app updater state (issue #12). */
var updateAvailable by mutableStateOf<String?>(null)
var sourceMode by mutableStateOf(
if (prefs.getString("source_mode", "generic") == "dispatcharr") SourceMode.DISPATCHARR
@@ -42,13 +55,18 @@ class AppState(
)
private set
enum class Screen { LIVE, SETTINGS }
enum class Screen { WELCOME, LIVE, SETTINGS, ADVANCED }
enum class SourceMode { GENERIC, DISPATCHARR }
enum class WelcomePhase { WAIT_PHONE, WAIT_URL, WAIT_LOGIN, DONE }
enum class AppError { NONE, OFFLINE, SERVER, RELOGIN }
init {
dispatcharr.outputProfile = prefs.getString("output_profile", "").orEmpty()
if (sourceMode == SourceMode.DISPATCHARR && auth.isLoggedIn) {
dispatcharr.refresh()
val configured = auth.isLoggedIn || source.m3uUrl.isNotEmpty()
if (!configured) {
screen = Screen.WELCOME
} else if (sourceMode == SourceMode.DISPATCHARR && auth.isLoggedIn) {
dispatcharr.refresh { classifyRefresh(it) }
}
}
@@ -58,7 +76,9 @@ class AppState(
"source_mode",
if (mode == SourceMode.DISPATCHARR) "dispatcharr" else "generic",
).apply()
if (mode == SourceMode.DISPATCHARR && auth.isLoggedIn) dispatcharr.refresh()
if (mode == SourceMode.DISPATCHARR && auth.isLoggedIn) {
dispatcharr.refresh { classifyRefresh(it) }
}
}
var outputProfile: String
@@ -79,6 +99,44 @@ class AppState(
SourceMode.DISPATCHARR -> dispatcharr.nowNext(channel)
}
fun refreshActive() {
when (sourceMode) {
SourceMode.GENERIC -> source.refresh()
SourceMode.DISPATCHARR -> dispatcharr.refresh { classifyRefresh(it) }
}
}
/** Map a refresh failure to a family-friendly fullscreen state. */
fun classifyRefresh(result: Result<Int>) {
result.fold(
onSuccess = { appError = AppError.NONE },
onFailure = { throwable ->
// Cached channels keep the app usable; only surface a
// fullscreen state when there is nothing to show.
if (activeChannels().isNotEmpty()) return
appError = when {
throwable.message == "not logged in" -> AppError.RELOGIN
!isOnline() -> AppError.OFFLINE
else -> AppError.SERVER
}
},
)
}
fun isOnline(): Boolean {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
fun startOnboarding() {
auth.logout()
appError = AppError.NONE
welcomePhase = WelcomePhase.WAIT_PHONE
welcomeUserCode = ""
screen = Screen.WELCOME
}
fun play(channel: Channel) {
currentChannel = channel
playerVisible = true

View File

@@ -7,6 +7,10 @@ import android.os.Looper
import android.view.KeyEvent
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.lifecycle.lifecycleScope
import dev.castarr.tv.update.UpdateChecker
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import dev.castarr.tv.auth.DeviceAuth
import dev.castarr.tv.data.DispatcharrRepository
import dev.castarr.tv.data.SourceRepository
@@ -40,11 +44,12 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
player = PlayerController(this) { onPlaybackChanged() },
source = SourceRepository(this),
auth = auth,
dispatcharr = DispatcharrRepository(auth),
dispatcharr = DispatcharrRepository(this, auth),
)
server = ControlServer(this, this)
server.startServer()
setContent { CastarrApp(state) }
lifecycleScope.launch { UpdateChecker.check(state) }
}
private fun onPlaybackChanged() {
@@ -135,6 +140,49 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
.put("favoritesSupported", state.sourceMode == AppState.SourceMode.DISPATCHARR)
}
override fun setupNeeded(): Boolean = state.screen == AppState.Screen.WELCOME
override fun onConfigureServer(url: String) {
if (state.screen != AppState.Screen.WELCOME || url.isBlank()) return
state.welcomePhase = AppState.WelcomePhase.WAIT_URL
lifecycleScope.launch {
try {
state.auth.fetchServerConfig(url.trim())
val session = state.auth.startDeviceFlow()
state.welcomeUserCode = session.userCode
state.welcomePhase = AppState.WelcomePhase.WAIT_LOGIN
server.broadcastLoginLink(session.verificationUriComplete, session.userCode)
val deadline = System.currentTimeMillis() + session.expiresInSeconds * 1000L
while (System.currentTimeMillis() < deadline) {
delay(session.intervalSeconds * 1000L)
when (state.auth.poll(session)) {
is dev.castarr.tv.auth.DeviceAuth.PollResult.Success -> {
state.welcomePhase = AppState.WelcomePhase.DONE
server.broadcastSetupDone()
state.setMode(AppState.SourceMode.DISPATCHARR)
delay(1500)
state.screen = AppState.Screen.LIVE
return@launch
}
dev.castarr.tv.auth.DeviceAuth.PollResult.Denied -> {
server.broadcastToast("Anmeldung abgelehnt — bitte erneut versuchen")
state.welcomePhase = AppState.WelcomePhase.WAIT_PHONE
state.welcomeUserCode = ""
return@launch
}
dev.castarr.tv.auth.DeviceAuth.PollResult.Pending -> Unit
}
}
server.broadcastToast("Anmeldecode abgelaufen — bitte erneut versuchen")
state.welcomePhase = AppState.WelcomePhase.WAIT_PHONE
} catch (e: Exception) {
android.util.Log.w("Onboarding", "configure failed: ${e.javaClass.simpleName}")
server.broadcastToast("Server nicht erreichbar oder ohne Anmeldung")
state.welcomePhase = AppState.WelcomePhase.WAIT_PHONE
}
}
}
override fun onToggleFavorite(channelId: Int) {
val channel = state.activeChannels().firstOrNull { it.backendId == channelId } ?: return
state.dispatcharr.toggleFavorite(channel)

View File

@@ -1,5 +1,6 @@
package dev.castarr.tv.data
import android.content.Context
import android.util.Log
import dev.castarr.tv.auth.DeviceAuth
import dev.castarr.tv.playlist.Channel
@@ -22,8 +23,9 @@ import java.util.TimeZone
* groups, EPG grid and per-user favorites. Stream URLs go through the
* Dispatcharr proxy, optionally with a selected output profile.
*/
class DispatcharrRepository(private val auth: DeviceAuth) {
class DispatcharrRepository(context: Context, private val auth: DeviceAuth) {
private val prefs = context.getSharedPreferences("dispatcharr", Context.MODE_PRIVATE)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val channels = MutableStateFlow<List<Channel>>(emptyList())
@@ -35,6 +37,14 @@ class DispatcharrRepository(private val auth: DeviceAuth) {
private var programmesByTvgId: Map<String, List<Programme>> = emptyMap()
var outputProfile: String = ""
init {
runCatching {
prefs.getString("channels_cache", null)?.let {
channels.value = Channel.listFromJson(JSONArray(it))
}
}
}
fun refresh(onDone: (Result<Int>) -> Unit = {}) {
scope.launch {
val result = runCatching {
@@ -43,15 +53,16 @@ class DispatcharrRepository(private val auth: DeviceAuth) {
val groups = fetchGroups(token)
val list = fetchChannels(token, groups)
channels.value = list
prefs.edit().putString("channels_cache", Channel.listToJson(list).toString()).apply()
launch { runCatching { refreshFavorites(token) } }
launch { runCatching { refreshProfiles(token) } }
launch {
runCatching { refreshEpg(token) }
.onFailure { Log.w(TAG, "epg failed", it) }
.onFailure { Log.w(TAG, "epg failed: ${it.javaClass.simpleName}: ${it.message?.take(160)}") }
}
list.size
}
result.onFailure { Log.w(TAG, "refresh failed", it) }
result.onFailure { Log.w(TAG, "refresh failed: ${it.javaClass.simpleName}: ${it.message?.take(160)}") }
status.value = if (result.isSuccess) "" else "channels_error"
onDone(result)
}
@@ -146,8 +157,11 @@ class DispatcharrRepository(private val auth: DeviceAuth) {
private suspend fun refreshEpg(token: String) {
status.value = "loading_epg"
val body = request("${auth.serverUrl}/api/epg/grid/", "GET", token)
val results = runCatching { JSONObject(body).optJSONArray("results") }.getOrNull()
?: JSONArray(body)
// Envelope is {"data": [...]}; tolerate bare arrays and "results" too.
val root = runCatching { JSONObject(body) }.getOrNull()
val results = root?.optJSONArray("data")
?: root?.optJSONArray("results")
?: runCatching { JSONArray(body) }.getOrElse { error("unexpected EPG envelope") }
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}

View File

@@ -27,6 +27,8 @@ class PlayerController(
private set
var errorMessage: String? = null
private set
private var retryCount = 0
private var lastUrl: String = ""
init {
player.addListener(object : Player.Listener {
@@ -35,7 +37,15 @@ class PlayerController(
override fun onIsPlayingChanged(isPlaying: Boolean) = onChanged()
override fun onPlayerError(error: PlaybackException) {
errorMessage = error.errorCodeName
// Self-heal: silently reconnect twice before surfacing (#13).
if (retryCount < 2 && lastUrl.isNotEmpty()) {
retryCount++
player.setMediaItem(MediaItem.fromUri(lastUrl))
player.prepare()
player.play()
return
}
errorMessage = "unreachable"
onChanged()
}
})
@@ -56,6 +66,8 @@ class PlayerController(
fun play(url: String, name: String, group: String) {
if (url.isEmpty()) return
errorMessage = null
retryCount = 0
lastUrl = url
channelName = name.ifEmpty { url }
channelGroup = group
val item = MediaItem.Builder()
@@ -88,6 +100,8 @@ class PlayerController(
channelName = ""
channelGroup = ""
errorMessage = null
retryCount = 0
lastUrl = ""
onChanged()
}

View File

@@ -13,6 +13,7 @@ import java.io.IOException
import java.util.Timer
import java.util.TimerTask
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.Executors
/**
* Embedded HTTP + WebSocket server. Serves the phone remote (a single HTML
@@ -34,6 +35,8 @@ class ControlServer(
fun onVolume(value: Float)
fun onSetPlaylist(url: String)
fun onToggleFavorite(channelId: Int)
fun onConfigureServer(url: String)
fun setupNeeded(): Boolean
fun onClientsChanged(count: Int, newestName: String?)
fun currentStatus(): JSONObject
fun currentChannels(): List<Channel>
@@ -42,6 +45,10 @@ class ControlServer(
}
private val mainHandler = Handler(Looper.getMainLooper())
// Socket writes must stay off the main thread (NetworkOnMainThread),
// while payloads reading player/UI state must be BUILT on it — so all
// sends are serialized through this worker.
private val sendExecutor = Executors.newSingleThreadExecutor()
private val clients = CopyOnWriteArrayList<RemoteSocket>()
private val pairingCode = Pairing.code(context)
private val pairingToken = Pairing.token(context)
@@ -74,6 +81,7 @@ class ControlServer(
fun stopServer() {
pingTimer?.cancel()
pingTimer = null
sendExecutor.shutdown()
stop()
}
@@ -97,8 +105,10 @@ class ControlServer(
fun broadcast(message: JSONObject) {
val payload = message.toString()
clients.forEach { client ->
if (client.authorized) client.trySend(payload)
sendExecutor.execute {
clients.forEach { client ->
if (client.authorized) client.trySend(payload)
}
}
}
@@ -114,6 +124,14 @@ class ControlServer(
broadcast(JSONObject().put("type", "toast").put("message", message))
}
fun broadcastLoginLink(url: String, code: String) {
broadcast(JSONObject().put("type", "login_link").put("url", url).put("code", code))
}
fun broadcastSetupDone() {
broadcast(JSONObject().put("type", "setup_done"))
}
private fun channelsMessage(): JSONObject = JSONObject()
.put("type", "channels")
.put("playlistUrl", listener.currentPlaylistUrl())
@@ -186,9 +204,15 @@ class ControlServer(
"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")) }
"get_state" -> {
trySend(listener.currentStatus().put("type", "status").toString())
trySend(channelsMessage().toString())
"configure_server" -> post { listener.onConfigureServer(msg.optString("url")) }
"get_state" -> post {
// Read state on main, write on the send worker.
val status = listener.currentStatus().put("type", "status").toString()
val channels = channelsMessage().toString()
sendExecutor.execute {
trySend(status)
trySend(channels)
}
}
else -> Unit
}
@@ -220,9 +244,12 @@ class ControlServer(
}
authorized = true
deviceName = msg.optString("name").ifEmpty { "Handy" }
trySend(
JSONObject()
// Build the welcome payload on the main thread — it reads the
// player and UI state, which must not happen on the WS thread.
post {
val welcome = JSONObject()
.put("type", "welcome")
.put("setup", listener.setupNeeded())
.put("device", android.os.Build.MODEL)
.put("token", pairingToken)
.put("status", listener.currentStatus())
@@ -230,7 +257,8 @@ class ControlServer(
.put("channels", Channel.listToJson(listener.currentChannels()))
.put("extras", listener.channelsExtras())
.toString()
)
sendExecutor.execute { trySend(welcome) }
}
notifyClientsChanged(deviceName)
}

View File

@@ -0,0 +1,98 @@
package dev.castarr.tv.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import dev.castarr.tv.AppState
/** Text-heavy configuration, deliberately outside the family path (#14). */
@Composable
fun AdvancedScreen(state: AppState) {
var m3u by remember { mutableStateOf(state.source.m3uUrl) }
var epg by remember { mutableStateOf(state.source.epgUrl) }
var message by remember { mutableStateOf("") }
val status by state.source.status.collectAsState()
val fieldColors = OutlinedTextFieldDefaults.colors(
focusedTextColor = CastarrColors.fg,
unfocusedTextColor = CastarrColors.muted,
focusedBorderColor = CastarrColors.accent,
unfocusedBorderColor = CastarrColors.line,
focusedLabelColor = CastarrColors.accent,
unfocusedLabelColor = CastarrColors.faint,
cursorColor = CastarrColors.accent,
focusedContainerColor = CastarrColors.surface,
unfocusedContainerColor = CastarrColors.surface,
)
Column(
Modifier
.fillMaxSize()
.padding(horizontal = 40.dp, vertical = 20.dp)
) {
Text("Erweitert", color = CastarrColors.fg, fontFamily = SpaceGrotesk, 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,
)
Spacer(Modifier.height(18.dp))
TvTextField(
value = m3u,
onValueChange = { m3u = it },
label = { Text("M3U-URL", fontFamily = SpaceGrotesk) },
colors = fieldColors,
textStyle = TextStyle(fontSize = 14.sp),
modifier = Modifier.width(600.dp),
)
Spacer(Modifier.height(12.dp))
TvTextField(
value = epg,
onValueChange = { epg = it },
label = { Text("XMLTV-EPG-URL (optional)", fontFamily = SpaceGrotesk) },
colors = fieldColors,
textStyle = TextStyle(fontSize = 14.sp),
modifier = Modifier.width(600.dp),
)
Spacer(Modifier.height(18.dp))
Row {
ActionButton("Quelle laden") {
message = ""
state.source.configure(m3u.trim(), epg.trim()) { result ->
message = result.fold(
onSuccess = { "$it Sender geladen" },
onFailure = { "Fehler: Quelle nicht ladbar" },
)
}
}
Spacer(Modifier.width(10.dp))
ActionButton("Zurück") { state.screen = AppState.Screen.SETTINGS }
}
Spacer(Modifier.height(10.dp))
val statusText = when {
status == "loading_channels" -> "Lade Senderliste…"
status == "loading_epg" -> "Lade EPG…"
else -> message
}
if (statusText.isNotEmpty()) {
Text(statusText, color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 13.sp)
}
}
}

View File

@@ -32,11 +32,17 @@ fun CastarrApp(state: AppState) {
.fillMaxSize()
.background(CastarrColors.bg)
) {
Column(modifier = Modifier.fillMaxSize()) {
TopBar(state)
when (state.screen) {
AppState.Screen.LIVE -> LiveScreen(state)
AppState.Screen.SETTINGS -> SettingsScreen(state)
when {
state.screen == AppState.Screen.WELCOME -> WelcomeScreen(state)
state.appError != AppState.AppError.NONE && !state.playerVisible -> ErrorScreen(state)
else -> Column(modifier = Modifier.fillMaxSize()) {
TopBar(state)
when (state.screen) {
AppState.Screen.LIVE -> LiveScreen(state)
AppState.Screen.SETTINGS -> SettingsScreen(state)
AppState.Screen.ADVANCED -> AdvancedScreen(state)
AppState.Screen.WELCOME -> Unit
}
}
}
if (state.playerVisible) {

View File

@@ -0,0 +1,86 @@
package dev.castarr.tv.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import dev.castarr.tv.AppState
import kotlinx.coroutines.delay
/** One fullscreen plain-language message, one focused button (#13). */
@Composable
fun ErrorScreen(state: AppState) {
// Self-heal: keep retrying quietly while the screen is up.
LaunchedEffect(state.appError) {
while (state.appError == AppState.AppError.SERVER || state.appError == AppState.AppError.OFFLINE) {
delay(20_000)
state.refreshActive()
}
}
Column(
modifier = Modifier
.fillMaxSize()
.background(CastarrColors.bg),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center,
) {
val (title, subtitle, buttonLabel) = when (state.appError) {
AppState.AppError.OFFLINE -> Triple(
"Kein Internet",
"Der Fernseher ist gerade nicht mit dem Internet verbunden.\nEs wird automatisch weiter versucht.",
"Jetzt erneut versuchen",
)
AppState.AppError.SERVER -> Triple(
"Server nicht erreichbar",
"Der TV-Server antwortet gerade nicht.\nEs wird automatisch weiter versucht.",
"Jetzt erneut versuchen",
)
else -> Triple(
"Bitte neu anmelden",
"Deine Anmeldung ist abgelaufen.",
"Neu anmelden",
)
}
Text(title, color = CastarrColors.fg, fontFamily = SpaceGrotesk, fontSize = 30.sp)
Spacer(Modifier.height(10.dp))
Text(
subtitle,
color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 15.sp,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(26.dp))
Surface(
onClick = {
if (state.appError == AppState.AppError.RELOGIN) state.startOnboarding()
else state.refreshActive()
},
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)),
colors = ClickableSurfaceDefaults.colors(
containerColor = CastarrColors.accentDim,
contentColor = CastarrColors.accent,
focusedContainerColor = CastarrColors.accent,
focusedContentColor = CastarrColors.onAccent,
),
) {
Text(
buttonLabel,
fontFamily = SpaceGrotesk, fontSize = 16.sp,
modifier = Modifier.padding(horizontal = 26.dp, vertical = 13.dp),
)
}
}
}

View File

@@ -13,11 +13,13 @@ 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.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -45,11 +47,12 @@ fun LiveScreen(state: AppState) {
val isDispatcharr = state.sourceMode == AppState.SourceMode.DISPATCHARR
val epgStamp = if (isDispatcharr) dispatcharrEpg else genericEpg
val allChannels = if (isDispatcharr) dispatcharrChannels else genericChannels
val channels = if (isDispatcharr && state.favoritesOnly) {
allChannels.filter { it.backendId in favorites }
} else {
allChannels
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 }
if (allChannels.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -64,11 +67,28 @@ fun LiveScreen(state: AppState) {
}
Column(Modifier.fillMaxSize()) {
if (isDispatcharr) {
Row(Modifier.padding(start = 40.dp, bottom = 6.dp)) {
FilterChip("Alle", !state.favoritesOnly) { state.favoritesOnly = false }
Spacer(Modifier.width(8.dp))
FilterChip("★ Favoriten", state.favoritesOnly) { state.favoritesOnly = true }
LazyRow(
modifier = Modifier.padding(bottom = 6.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 40.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
FilterChip("Alle", !state.favoritesOnly && state.groupFilter == null) {
state.favoritesOnly = false
state.groupFilter = null
}
}
if (isDispatcharr) {
item {
FilterChip("★ Favoriten", state.favoritesOnly) {
state.favoritesOnly = !state.favoritesOnly
}
}
}
items(groups, key = { it }) { group ->
FilterChip(group, state.groupFilter == group) {
state.groupFilter = if (state.groupFilter == group) null else group
}
}
}
LazyColumn(

View File

@@ -12,135 +12,127 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.graphics.asImageBitmap
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import dev.castarr.tv.AppState
import dev.castarr.tv.auth.DeviceAuth
import dev.castarr.tv.BuildConfig
import dev.castarr.tv.pairing.Pairing
import dev.castarr.tv.pairing.Qr
import dev.castarr.tv.update.UpdateChecker
import kotlinx.coroutines.launch
/**
* Ten-Foot-Regel (#14): one screen, focusable buttons/chips only — no text
* fields, no scrolling. Everything text-heavy lives in the Erweitert screen.
*/
@Composable
fun SettingsScreen(state: AppState) {
val context = LocalContext.current
var m3u by remember { mutableStateOf(state.source.m3uUrl) }
var epg by remember { mutableStateOf(state.source.epgUrl) }
var message by remember { mutableStateOf("") }
val status by state.source.status.collectAsStateWithLifecycle()
val fieldColors = OutlinedTextFieldDefaults.colors(
focusedTextColor = CastarrColors.fg,
unfocusedTextColor = CastarrColors.muted,
focusedBorderColor = CastarrColors.accent,
unfocusedBorderColor = CastarrColors.line,
focusedLabelColor = CastarrColors.accent,
unfocusedLabelColor = CastarrColors.faint,
cursorColor = CastarrColors.accent,
focusedContainerColor = CastarrColors.surface,
unfocusedContainerColor = CastarrColors.surface,
)
val scope = rememberCoroutineScope()
val profiles by state.dispatcharr.profiles.collectAsState()
var updateStatus by remember { mutableStateOf("") }
Row(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 40.dp, vertical = 16.dp)
.padding(horizontal = 40.dp, vertical = 20.dp)
) {
Column(Modifier.weight(1f)) {
Text("Quelle", color = CastarrColors.fg, fontFamily = SpaceGrotesk, fontSize = 20.sp)
Spacer(Modifier.height(6.dp))
Text(
"M3U-Playlist plus optionales XMLTV-EPG — funktioniert mit Dispatcharr, ErsatzTV, Threadfin, Tunarr.",
color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 13.sp,
)
Spacer(Modifier.height(18.dp))
OutlinedTextField(
value = m3u,
onValueChange = { m3u = it },
label = { Text("M3U-URL", fontFamily = SpaceGrotesk) },
singleLine = true,
textStyle = TextStyle(fontSize = 14.sp),
colors = fieldColors,
modifier = Modifier.width(560.dp),
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = epg,
onValueChange = { epg = it },
label = { Text("XMLTV-EPG-URL (optional)", fontFamily = SpaceGrotesk) },
singleLine = true,
textStyle = TextStyle(fontSize = 14.sp),
colors = fieldColors,
modifier = Modifier.width(560.dp),
)
Spacer(Modifier.height(18.dp))
Surface(
onClick = {
message = ""
state.source.configure(m3u.trim(), epg.trim()) { result ->
message = result.fold(
onSuccess = { "$it Sender geladen" },
onFailure = { "Fehler: Quelle nicht ladbar" },
)
}
},
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)),
colors = ClickableSurfaceDefaults.colors(
containerColor = CastarrColors.accentDim,
contentColor = CastarrColors.accent,
focusedContainerColor = CastarrColors.accent,
focusedContentColor = CastarrColors.onAccent,
),
) {
Column(Modifier.weight(1.2f)) {
SectionTitle("Konto")
Spacer(Modifier.height(8.dp))
if (state.auth.isLoggedIn) {
Text(
"Quelle laden",
fontFamily = SpaceGrotesk,
fontSize = 14.sp,
modifier = Modifier.padding(horizontal = 22.dp, vertical = 11.dp),
"Angemeldet als ${state.auth.username}",
color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 14.sp,
)
}
Spacer(Modifier.height(10.dp))
val statusText = when {
status == "loading_channels" -> "Lade Senderliste…"
status == "loading_epg" -> "Lade EPG…"
message.isNotEmpty() -> message
else -> ""
}
if (statusText.isNotEmpty()) {
Text(statusText, color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 13.sp)
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.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() }
} else {
Text(
"Nicht angemeldet.",
color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 14.sp,
)
Spacer(Modifier.height(12.dp))
ActionButton("Jetzt anmelden") { state.startOnboarding() }
}
Spacer(Modifier.height(28.dp))
DispatcharrAccount(state, fieldColors)
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,
)
Spacer(Modifier.height(8.dp))
Row {
ActionButton(
if (state.updateAvailable != null) "Update installieren" else "Nach Update suchen"
) {
scope.launch {
updateStatus = if (state.updateAvailable != null) {
UpdateChecker.downloadAndInstall(context, state) ?: "Update wird geöffnet…"
} else {
when (val v = UpdateChecker.check(state)) {
null -> "App ist aktuell"
else -> "$v verfügbar — nochmal drücken zum Installieren"
}
}
}
}
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)
}
}
Spacer(Modifier.width(40.dp))
Column(horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally) {
Text("Handy-Fernbedienung", color = CastarrColors.fg, fontFamily = SpaceGrotesk, fontSize = 20.sp)
Spacer(Modifier.height(6.dp))
Column(horizontalAlignment = Alignment.CenterHorizontally) {
SectionTitle("Handy-Fernbedienung")
Spacer(Modifier.height(10.dp))
val address = remember { Pairing.lanAddress() }
if (address != null) {
val qr = remember(address) {
@@ -160,189 +152,12 @@ fun SettingsScreen(state: AppState) {
}
@Composable
private fun QrCard(bitmap: Bitmap) {
Column(
Modifier
.clip(RoundedCornerShape(16.dp))
.background(androidx.compose.ui.graphics.Color(0xFFFBFCFD))
.padding(14.dp),
verticalArrangement = Arrangement.Center,
) {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = "QR-Code zum Koppeln",
modifier = Modifier.size(150.dp),
)
}
}
@Composable
private fun DispatcharrAccount(
state: AppState,
fieldColors: androidx.compose.material3.TextFieldColors,
) {
val scope = rememberCoroutineScope()
var serverUrl by remember {
mutableStateOf(state.auth.serverUrl.ifEmpty { "https://" })
}
var session by remember { mutableStateOf<DeviceAuth.DeviceSession?>(null) }
var phase by remember { mutableStateOf("") }
var loggedIn by remember { mutableStateOf(state.auth.isLoggedIn) }
val profiles by state.dispatcharr.profiles.collectAsStateWithLifecycle()
Text("Dispatcharr-Konto", color = CastarrColors.fg, fontFamily = SpaceGrotesk, fontSize = 20.sp)
Spacer(Modifier.height(6.dp))
if (!loggedIn) {
Text(
"Anmeldung per Handy: QR-Code scannen und im Browser bestätigen.",
color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 13.sp,
)
Spacer(Modifier.height(14.dp))
OutlinedTextField(
value = serverUrl,
onValueChange = { serverUrl = it },
label = { Text("Server-URL", fontFamily = SpaceGrotesk) },
singleLine = true,
textStyle = TextStyle(fontSize = 14.sp),
colors = fieldColors,
modifier = Modifier.width(560.dp),
)
Spacer(Modifier.height(12.dp))
val currentSession = session
if (currentSession == null) {
Surface(
onClick = {
phase = "Verbinde…"
scope.launch {
runCatching {
state.auth.fetchServerConfig(serverUrl.trim())
session = state.auth.startDeviceFlow()
phase = ""
}.onFailure { phase = "Fehler: Server nicht erreichbar oder kein SSO" }
}
},
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)),
colors = ClickableSurfaceDefaults.colors(
containerColor = CastarrColors.accentDim,
contentColor = CastarrColors.accent,
focusedContainerColor = CastarrColors.accent,
focusedContentColor = CastarrColors.onAccent,
),
) {
Text(
"Anmelden",
fontFamily = SpaceGrotesk, fontSize = 14.sp,
modifier = Modifier.padding(horizontal = 22.dp, vertical = 11.dp),
)
}
} else {
LaunchedEffect(currentSession) {
val deadline = System.currentTimeMillis() + currentSession.expiresInSeconds * 1000L
while (System.currentTimeMillis() < deadline) {
delay(currentSession.intervalSeconds * 1000L)
when (state.auth.poll(currentSession)) {
is DeviceAuth.PollResult.Success -> {
loggedIn = true
session = null
state.setMode(AppState.SourceMode.DISPATCHARR)
return@LaunchedEffect
}
DeviceAuth.PollResult.Denied -> {
phase = "Anmeldung abgelehnt oder abgelaufen"
session = null
return@LaunchedEffect
}
DeviceAuth.PollResult.Pending -> Unit
}
}
phase = "Code abgelaufen — erneut versuchen"
session = null
}
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
val qr = remember(currentSession.deviceCode) {
Qr.encode(
currentSession.verificationUriComplete,
400,
android.graphics.Color.parseColor("#101216"),
)
}
QrCard(qr)
Spacer(Modifier.width(20.dp))
Column {
Text("Code", color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 12.sp)
Text(
currentSession.userCode,
color = CastarrColors.fg, fontFamily = SpaceGrotesk, fontSize = 26.sp,
)
Spacer(Modifier.height(6.dp))
Text(
currentSession.verificationUri,
color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 12.sp,
)
Spacer(Modifier.height(6.dp))
Text("Warte auf Bestätigung…", color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 12.sp)
}
}
}
if (phase.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text(phase, color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 13.sp)
}
} else {
Text(
"Angemeldet als ${state.auth.username} · ${state.auth.serverUrl}",
color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 13.sp,
)
Spacer(Modifier.height(12.dp))
Row {
ModeChip("Dispatcharr", state.sourceMode == AppState.SourceMode.DISPATCHARR) {
state.setMode(AppState.SourceMode.DISPATCHARR)
}
Spacer(Modifier.width(8.dp))
ModeChip("Eigene M3U", state.sourceMode == AppState.SourceMode.GENERIC) {
state.setMode(AppState.SourceMode.GENERIC)
}
}
Spacer(Modifier.height(14.dp))
Text("Stream-Profil", color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 12.sp)
Spacer(Modifier.height(6.dp))
Row {
ModeChip("Standard", state.outputProfile.isEmpty()) { state.outputProfile = "" }
Spacer(Modifier.width(8.dp))
ModeChip("Passthrough", state.outputProfile == "raw") { state.outputProfile = "raw" }
profiles.forEach { name ->
Spacer(Modifier.width(8.dp))
ModeChip(name, state.outputProfile == name) { state.outputProfile = name }
}
}
Spacer(Modifier.height(14.dp))
Surface(
onClick = {
state.auth.logout()
loggedIn = false
state.setMode(AppState.SourceMode.GENERIC)
},
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)),
colors = ClickableSurfaceDefaults.colors(
containerColor = CastarrColors.surface,
contentColor = CastarrColors.muted,
focusedContainerColor = CastarrColors.live,
focusedContentColor = CastarrColors.fg,
),
) {
Text(
"Abmelden",
fontFamily = SpaceGrotesk, fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 18.dp, vertical = 9.dp),
)
}
}
private fun SectionTitle(text: String) {
Text(text, color = CastarrColors.fg, fontFamily = SpaceGrotesk, fontSize = 20.sp)
}
@Composable
private fun ModeChip(label: String, selected: Boolean, onClick: () -> Unit) {
fun Chip(label: String, selected: Boolean, onClick: () -> Unit) {
Surface(
onClick = onClick,
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)),
@@ -360,3 +175,40 @@ private fun ModeChip(label: String, selected: Boolean, onClick: () -> Unit) {
)
}
}
@Composable
fun ActionButton(label: String, danger: Boolean = false, onClick: () -> Unit) {
Surface(
onClick = onClick,
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)),
colors = ClickableSurfaceDefaults.colors(
containerColor = if (danger) CastarrColors.surface else CastarrColors.accentDim,
contentColor = if (danger) CastarrColors.muted else CastarrColors.accent,
focusedContainerColor = if (danger) CastarrColors.live else CastarrColors.accent,
focusedContentColor = if (danger) CastarrColors.fg else CastarrColors.onAccent,
),
) {
Text(
label,
fontFamily = SpaceGrotesk, fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
)
}
}
@Composable
fun QrCard(bitmap: Bitmap) {
Column(
Modifier
.clip(RoundedCornerShape(16.dp))
.background(androidx.compose.ui.graphics.Color(0xFFFBFCFD))
.padding(14.dp),
verticalArrangement = Arrangement.Center,
) {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = "QR-Code zum Koppeln",
modifier = Modifier.size(170.dp),
)
}
}

View File

@@ -0,0 +1,52 @@
package dev.castarr.tv.ui
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.TextFieldColors
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.TextStyle
/**
* OutlinedTextField that doesn't trap the D-pad: vertical direction keys move
* focus instead of the text cursor (single-line fields have no use for them).
*/
@Composable
fun TvTextField(
value: String,
onValueChange: (String) -> Unit,
label: @Composable () -> Unit,
colors: TextFieldColors,
modifier: Modifier = Modifier,
textStyle: TextStyle = TextStyle.Default,
) {
val focusManager = LocalFocusManager.current
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = label,
singleLine = true,
textStyle = textStyle,
colors = colors,
modifier = modifier.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when (event.key) {
Key.DirectionDown -> {
focusManager.moveFocus(FocusDirection.Down)
true
}
Key.DirectionUp -> {
focusManager.moveFocus(FocusDirection.Up)
true
}
else -> false
}
},
)
}

View File

@@ -0,0 +1,134 @@
package dev.castarr.tv.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.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.remember
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.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import dev.castarr.tv.AppState
import dev.castarr.tv.pairing.Pairing
import dev.castarr.tv.pairing.Qr
/**
* Phone-first onboarding (ADR-0007): one QR, everything else on the phone.
* No text entry and no scrolling on the TV (Ten-Foot-Regel).
*/
@Composable
fun WelcomeScreen(state: AppState) {
val context = LocalContext.current
val address = remember { Pairing.lanAddress() }
Box(Modifier.fillMaxSize().background(CastarrColors.bg)) {
Row(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 60.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
"Willkommen bei Castarr",
color = CastarrColors.fg, fontFamily = SpaceGrotesk,
fontSize = 32.sp, fontWeight = FontWeight.Medium,
)
Spacer(Modifier.height(28.dp))
Step(1, "Code mit der Handy-Kamera scannen", state.welcomePhase == AppState.WelcomePhase.WAIT_PHONE, done = state.welcomePhase > AppState.WelcomePhase.WAIT_PHONE)
Step(2, "Auf dem Handy: Server angeben und anmelden", state.welcomePhase == AppState.WelcomePhase.WAIT_URL || state.welcomePhase == AppState.WelcomePhase.WAIT_LOGIN, done = state.welcomePhase == AppState.WelcomePhase.DONE)
Step(3, "Fertig — der Fernseher macht den Rest", state.welcomePhase == AppState.WelcomePhase.DONE, done = false)
Spacer(Modifier.height(24.dp))
when (state.welcomePhase) {
AppState.WelcomePhase.WAIT_PHONE -> Unit
AppState.WelcomePhase.WAIT_URL -> Status("Handy verbunden — warte auf den Server…")
AppState.WelcomePhase.WAIT_LOGIN -> Status(
if (state.welcomeUserCode.isEmpty()) "Warte auf die Anmeldung am Handy…"
else "Anmeldung am Handy bestätigen · Code ${state.welcomeUserCode}"
)
AppState.WelcomePhase.DONE -> Status("Angemeldet! Lade Sender…")
}
}
Spacer(Modifier.width(48.dp))
if (address != null) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Column(
Modifier
.clip(RoundedCornerShape(20.dp))
.background(Color(0xFFFBFCFD))
.padding(18.dp),
) {
val qr = remember(address) {
Qr.encode(
Pairing.pairingUrl(context, address),
520,
android.graphics.Color.parseColor("#101216"),
)
}
Image(qr.asImageBitmap(), contentDescription = "Einrichtungs-QR", modifier = Modifier.size(230.dp))
}
Spacer(Modifier.height(12.dp))
Text(
Pairing.remoteUrl(address),
color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 13.sp,
)
}
} else {
Text("Keine Netzwerkverbindung", color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 15.sp)
}
}
}
}
@Composable
private fun Step(number: Int, text: String, active: Boolean, done: Boolean) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 8.dp),
) {
Box(
Modifier
.size(30.dp)
.clip(CircleShape)
.background(if (active || done) CastarrColors.accent else CastarrColors.surface),
contentAlignment = Alignment.Center,
) {
Text(
if (done) "" else "$number",
color = if (active || done) CastarrColors.onAccent else CastarrColors.faint,
fontFamily = SpaceGrotesk, 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,
)
}
}
@Composable
private fun Status(text: String) {
Text(text, color = CastarrColors.accent, fontFamily = SpaceGrotesk, fontSize = 14.sp)
}

View File

@@ -0,0 +1,103 @@
package dev.castarr.tv.update
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.core.content.FileProvider
import dev.castarr.tv.AppState
import dev.castarr.tv.BuildConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
/** In-app updater over GitHub 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 var apkUrl: String = ""
/** Returns the newer version tag, or null when current. Never throws. */
suspend fun check(state: AppState): String? = withContext(Dispatchers.IO) {
runCatching {
val json = JSONObject(get(LATEST))
val tag = json.optString("tag_name").removePrefix("v")
val assets = json.optJSONArray("assets")
var url = ""
for (i in 0 until (assets?.length() ?: 0)) {
val asset = assets!!.getJSONObject(i)
if (asset.optString("name").endsWith(".apk")) {
url = asset.optString("browser_download_url")
break
}
}
if (url.isNotEmpty() && isNewer(tag, BuildConfig.VERSION_NAME)) {
apkUrl = url
val version = "v$tag"
withContext(Dispatchers.Main) { state.updateAvailable = version }
version
} else {
withContext(Dispatchers.Main) { state.updateAvailable = null }
null
}
}.onFailure { Log.w(TAG, "check failed: ${it.javaClass.simpleName}") }.getOrNull()
}
/** Downloads the APK and hands it to the package installer. */
suspend fun downloadAndInstall(context: Context, state: AppState): String? =
withContext(Dispatchers.IO) {
runCatching {
if (apkUrl.isEmpty()) check(state)
require(apkUrl.isNotEmpty()) { "no update available" }
val dir = File(context.cacheDir, "updates").apply { mkdirs() }
val file = File(dir, "castarr-update.apk")
(URL(apkUrl).openConnection() as HttpURLConnection).run {
connectTimeout = 15_000
readTimeout = 120_000
instanceFollowRedirects = true
inputStream.use { input -> file.outputStream().use { input.copyTo(it) } }
disconnect()
}
val uri = FileProvider.getUriForFile(
context, "${BuildConfig.APPLICATION_ID}.fileprovider", file,
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
null
}.getOrElse {
Log.w(TAG, "install failed: ${it.javaClass.simpleName}")
"Update fehlgeschlagen — später erneut versuchen"
}
}
private fun isNewer(remote: String, local: String): Boolean {
fun parts(v: String) = v.split(".").mapNotNull { it.toIntOrNull() }
val r = parts(remote)
val l = parts(local)
for (i in 0 until maxOf(r.size, l.size)) {
val a = r.getOrElse(i) { 0 }
val b = l.getOrElse(i) { 0 }
if (a != b) return a > b
}
return false
}
private fun get(url: String): String {
val connection = URL(url).openConnection() as HttpURLConnection
return try {
connection.connectTimeout = 10_000
connection.readTimeout = 15_000
connection.setRequestProperty("Accept", "application/vnd.github+json")
connection.inputStream.bufferedReader().use { it.readText() }
} finally {
connection.disconnect()
}
}
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="updates" path="updates/" />
</paths>

View File

@@ -0,0 +1,20 @@
---
status: accepted
---
# Domain split: nodecast as family viewer, Dispatcharr as engine
The family needs comfortable SSO viewing in the browser; Dispatcharr's web UI
is an admin surface, not a viewer. We reinstated nodecast-tv as the browser
frontend on <viewer-host> (it already has an OIDC login and in-browser
transcoding) — but demoted to a pure viewer with exactly one source:
Dispatcharr's M3U/EPG output. Dispatcharr stays the engine (sources,
aggregation, failover, per-user favorites for the TV app) and moved to
<admin-host> (internal admin) plus api.<viewer-host> (external API
for Castarr sticks outside the LAN; the OIDC redirect URI is derived from the
request host, all three hosts are registered at the IdP).
Access control lives on the IdP, not in app code: an Authentik group policy
binding (`adult`) on the nodecast, Dispatcharr and Castarr applications gates
both browser login and the device flow. The fork's OIDC_REQUIRED_GROUP knob
stays unused.

View File

@@ -0,0 +1,13 @@
---
status: accepted
---
# Onboarding runs on the phone through the TV's own pairing server
Nobody should ever type on a TV. Instead of baking a server URL into the APK
(rejected: publishes private infrastructure in a public artifact) or on-TV
text entry (rejected: D-pad typing), first-run setup reuses the embedded
remote-control server: the TV shows one QR, the phone opens the TV-served web
app, and server URL plus IdP login (device-flow link opened directly on the
phone) happen there. Requires phone and TV on the same LAN — acceptable, since
the remote control has the same requirement by design.