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:
be-nj
2026-08-25 23:37:15 +02:00
parent 1015a98acb
commit 487ad1254d
20 changed files with 940 additions and 313 deletions

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