From 487ad1254da24a7eb0086167618704032ffad5bf Mon Sep 17 00:00:00 2001 From: be-nj Date: Tue, 25 Aug 2026 23:37:15 +0200 Subject: [PATCH] 0.2.2 + 0.3.0: remote threading fix, phone-first onboarding, ten-foot settings, self-healing errors, in-app updater, group filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CONTEXT.md | 21 +- app/build.gradle.kts | 6 +- app/src/main/AndroidManifest.xml | 11 + app/src/main/assets/remote/index.html | 49 ++- app/src/main/java/dev/castarr/tv/AppState.kt | 68 ++- .../main/java/dev/castarr/tv/MainActivity.kt | 50 ++- .../castarr/tv/data/DispatcharrRepository.kt | 24 +- .../dev/castarr/tv/player/PlayerController.kt | 16 +- .../dev/castarr/tv/server/ControlServer.kt | 44 +- .../java/dev/castarr/tv/ui/AdvancedScreen.kt | 98 +++++ .../main/java/dev/castarr/tv/ui/CastarrApp.kt | 16 +- .../java/dev/castarr/tv/ui/ErrorScreen.kt | 86 ++++ .../main/java/dev/castarr/tv/ui/LiveScreen.kt | 38 +- .../java/dev/castarr/tv/ui/SettingsScreen.kt | 400 ++++++------------ .../java/dev/castarr/tv/ui/TvTextField.kt | 52 +++ .../java/dev/castarr/tv/ui/WelcomeScreen.kt | 134 ++++++ .../dev/castarr/tv/update/UpdateChecker.kt | 103 +++++ app/src/main/res/xml/file_paths.xml | 4 + docs/adr/0006-viewer-admin-domain-split.md | 20 + docs/adr/0007-onboarding-via-remote.md | 13 + 20 files changed, 940 insertions(+), 313 deletions(-) create mode 100644 app/src/main/java/dev/castarr/tv/ui/AdvancedScreen.kt create mode 100644 app/src/main/java/dev/castarr/tv/ui/ErrorScreen.kt create mode 100644 app/src/main/java/dev/castarr/tv/ui/TvTextField.kt create mode 100644 app/src/main/java/dev/castarr/tv/ui/WelcomeScreen.kt create mode 100644 app/src/main/java/dev/castarr/tv/update/UpdateChecker.kt create mode 100644 app/src/main/res/xml/file_paths.xml create mode 100644 docs/adr/0006-viewer-admin-domain-split.md create mode 100644 docs/adr/0007-onboarding-via-remote.md diff --git a/CONTEXT.md b/CONTEXT.md index ce7a427..fe78548 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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 +(), API external for TV clients (api.). +_Avoid_: server (ambiguous) + +**Viewer (nodecast)**: +The browser frontend for the family on — 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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d0c54fd..0f98d8e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 69c9686..cb04416 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + @@ -32,5 +33,15 @@ + + + + diff --git a/app/src/main/assets/remote/index.html b/app/src/main/assets/remote/index.html index 2cb10e2..8debde7 100644 --- a/app/src/main/assets/remote/index.html +++ b/app/src/main/assets/remote/index.html @@ -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 @@
+ +
+

TV einrichten

+

+ Gib die Adresse deines TV-Servers an — den Rest erledigt die Anmeldung. +

+ + + +
+
+
@@ -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; } diff --git a/app/src/main/java/dev/castarr/tv/AppState.kt b/app/src/main/java/dev/castarr/tv/AppState.kt index b0f9d72..cc06ae2 100644 --- a/app/src/main/java/dev/castarr/tv/AppState.kt +++ b/app/src/main/java/dev/castarr/tv/AppState.kt @@ -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(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(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) { + 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 diff --git a/app/src/main/java/dev/castarr/tv/MainActivity.kt b/app/src/main/java/dev/castarr/tv/MainActivity.kt index c2bd08e..4c167d4 100644 --- a/app/src/main/java/dev/castarr/tv/MainActivity.kt +++ b/app/src/main/java/dev/castarr/tv/MainActivity.kt @@ -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) diff --git a/app/src/main/java/dev/castarr/tv/data/DispatcharrRepository.kt b/app/src/main/java/dev/castarr/tv/data/DispatcharrRepository.kt index 63ab6e5..8ff154c 100644 --- a/app/src/main/java/dev/castarr/tv/data/DispatcharrRepository.kt +++ b/app/src/main/java/dev/castarr/tv/data/DispatcharrRepository.kt @@ -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>(emptyList()) @@ -35,6 +37,14 @@ class DispatcharrRepository(private val auth: DeviceAuth) { private var programmesByTvgId: Map> = emptyMap() var outputProfile: String = "" + init { + runCatching { + prefs.getString("channels_cache", null)?.let { + channels.value = Channel.listFromJson(JSONArray(it)) + } + } + } + fun refresh(onDone: (Result) -> 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") } diff --git a/app/src/main/java/dev/castarr/tv/player/PlayerController.kt b/app/src/main/java/dev/castarr/tv/player/PlayerController.kt index e79b286..e878e6e 100644 --- a/app/src/main/java/dev/castarr/tv/player/PlayerController.kt +++ b/app/src/main/java/dev/castarr/tv/player/PlayerController.kt @@ -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() } diff --git a/app/src/main/java/dev/castarr/tv/server/ControlServer.kt b/app/src/main/java/dev/castarr/tv/server/ControlServer.kt index a1a4ea0..1dddf55 100644 --- a/app/src/main/java/dev/castarr/tv/server/ControlServer.kt +++ b/app/src/main/java/dev/castarr/tv/server/ControlServer.kt @@ -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 @@ -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() 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) } diff --git a/app/src/main/java/dev/castarr/tv/ui/AdvancedScreen.kt b/app/src/main/java/dev/castarr/tv/ui/AdvancedScreen.kt new file mode 100644 index 0000000..7b18705 --- /dev/null +++ b/app/src/main/java/dev/castarr/tv/ui/AdvancedScreen.kt @@ -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) + } + } +} diff --git a/app/src/main/java/dev/castarr/tv/ui/CastarrApp.kt b/app/src/main/java/dev/castarr/tv/ui/CastarrApp.kt index e8caaec..81a3d39 100644 --- a/app/src/main/java/dev/castarr/tv/ui/CastarrApp.kt +++ b/app/src/main/java/dev/castarr/tv/ui/CastarrApp.kt @@ -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) { diff --git a/app/src/main/java/dev/castarr/tv/ui/ErrorScreen.kt b/app/src/main/java/dev/castarr/tv/ui/ErrorScreen.kt new file mode 100644 index 0000000..7445174 --- /dev/null +++ b/app/src/main/java/dev/castarr/tv/ui/ErrorScreen.kt @@ -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), + ) + } + } +} diff --git a/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt b/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt index 453e545..65bc422 100644 --- a/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt @@ -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( diff --git a/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt b/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt index 4776f22..62aa58f 100644 --- a/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt @@ -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(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), + ) + } +} diff --git a/app/src/main/java/dev/castarr/tv/ui/TvTextField.kt b/app/src/main/java/dev/castarr/tv/ui/TvTextField.kt new file mode 100644 index 0000000..d3a3fda --- /dev/null +++ b/app/src/main/java/dev/castarr/tv/ui/TvTextField.kt @@ -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 + } + }, + ) +} diff --git a/app/src/main/java/dev/castarr/tv/ui/WelcomeScreen.kt b/app/src/main/java/dev/castarr/tv/ui/WelcomeScreen.kt new file mode 100644 index 0000000..b39dade --- /dev/null +++ b/app/src/main/java/dev/castarr/tv/ui/WelcomeScreen.kt @@ -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) +} diff --git a/app/src/main/java/dev/castarr/tv/update/UpdateChecker.kt b/app/src/main/java/dev/castarr/tv/update/UpdateChecker.kt new file mode 100644 index 0000000..47751c4 --- /dev/null +++ b/app/src/main/java/dev/castarr/tv/update/UpdateChecker.kt @@ -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() + } + } +} diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..c9cc257 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/docs/adr/0006-viewer-admin-domain-split.md b/docs/adr/0006-viewer-admin-domain-split.md new file mode 100644 index 0000000..489eaed --- /dev/null +++ b/docs/adr/0006-viewer-admin-domain-split.md @@ -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 (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 + (internal admin) plus api. (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. diff --git a/docs/adr/0007-onboarding-via-remote.md b/docs/adr/0007-onboarding-via-remote.md new file mode 100644 index 0000000..c435296 --- /dev/null +++ b/docs/adr/0007-onboarding-via-remote.md @@ -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.