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
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
98
app/src/main/java/dev/castarr/tv/ui/AdvancedScreen.kt
Normal file
98
app/src/main/java/dev/castarr/tv/ui/AdvancedScreen.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
86
app/src/main/java/dev/castarr/tv/ui/ErrorScreen.kt
Normal file
86
app/src/main/java/dev/castarr/tv/ui/ErrorScreen.kt
Normal 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
52
app/src/main/java/dev/castarr/tv/ui/TvTextField.kt
Normal file
52
app/src/main/java/dev/castarr/tv/ui/TvTextField.kt
Normal 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
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
134
app/src/main/java/dev/castarr/tv/ui/WelcomeScreen.kt
Normal file
134
app/src/main/java/dev/castarr/tv/ui/WelcomeScreen.kt
Normal 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)
|
||||
}
|
||||
103
app/src/main/java/dev/castarr/tv/update/UpdateChecker.kt
Normal file
103
app/src/main/java/dev/castarr/tv/update/UpdateChecker.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user