Files
castarr/app/src/main/java/dev/castarr/tv/AppState.kt
be-nj 7f1dff5661
Some checks failed
Build TV app / build (push) Failing after 5s
Real club crests, and no more crash on "Verein hinzufügen"
Crests are resolved from the German Wikipedia page summary at runtime and
cached per device, so nothing copyrighted is stored in the repository. The
coloured shield remains as the fallback while the badge loads or when a
club has none.

The add dialog crashed for the same reason the rail did: it focused a row
that only exists when something is preselected, and adding a club has no
preselection. It now also scrolls, which 56 clubs rather need.

CI: this Gitea resolves bare action names against itself, so the workflow
now uses full GitHub URLs, and it runs the unit tests too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 02:59:23 +02:00

314 lines
12 KiB
Kotlin

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
import androidx.compose.runtime.setValue
import dev.castarr.tv.auth.DeviceAuth
import dev.castarr.tv.data.DispatcharrRepository
import dev.castarr.tv.data.NowNext
import dev.castarr.tv.data.isEpgPlaceholder
import dev.castarr.tv.data.SourceRepository
import dev.castarr.tv.player.PlayerController
import dev.castarr.tv.playlist.Channel
/**
* Single source of truth the Compose UI observes. Mutations happen on the
* main thread (activity callbacks, remote-control listener).
*/
class AppState(
private val context: Context,
val player: PlayerController,
val source: SourceRepository,
val auth: DeviceAuth,
val dispatcharr: DispatcharrRepository,
) {
private val prefs = context.getSharedPreferences("app", Context.MODE_PRIVATE)
val crests = dev.castarr.tv.data.Crests(context)
var screen by mutableStateOf(Screen.LIVE)
var playerVisible by mutableStateOf(false)
var playerState by mutableStateOf("idle")
var currentChannel by mutableStateOf<Channel?>(null)
var overlayVisible by mutableStateOf(true)
var connectedRemote by mutableStateOf<String?>(null)
var positionMs by mutableLongStateOf(0L)
var durationMs by mutableLongStateOf(0L)
var isLive by mutableStateOf(false)
var favoritesOnly by mutableStateOf(false)
var groupFilter by mutableStateOf<String?>(null)
/** Club shortcut currently open in the rail, if any. */
var activeTeam by mutableStateOf<String?>(null)
/**
* Club menus switched on for this TV. Seeded from the signed-in viewer's
* own club; every club can be toggled on in the settings, so a household
* can watch for more than one.
*/
var enabledTeams by mutableStateOf(loadEnabledTeams())
private set
private fun loadEnabledTeams(): Set<String> {
val stored = prefs.getStringSet("teams", null)
if (stored != null) return stored.toSet()
return dev.castarr.tv.data.TeamFilters.defaultKeysFor(auth.username).toSet()
}
/** Returns false when the cap of [TeamFilters.MAX_ACTIVE] is reached. */
fun toggleTeam(key: String): Boolean {
if (key !in enabledTeams &&
enabledTeams.size >= dev.castarr.tv.data.TeamFilters.MAX_ACTIVE
) {
return false
}
enabledTeams =
if (key in enabledTeams) enabledTeams - key else enabledTeams + key
prefs.edit().putStringSet("teams", enabledTeams).apply()
if (activeTeam == key && key !in enabledTeams) activeTeam = null
return true
}
var audioTracks by mutableStateOf<List<PlayerController.AudioTrack>>(emptyList())
private set
private var lastNowTitle: String? = null
/** Digits typed on the remote's number pad (channel switching). */
var digitBuffer by mutableStateOf("")
/** False when the control server could not bind its port. */
var remoteAvailable by mutableStateOf(true)
/** Channel to restore focus to when the list comes back (#13). */
var lastWatched by mutableStateOf<Channel?>(null)
/**
* True while the list is being restored after playback. The group rail
* opens whatever gets focused, so without this the focus landing there
* would silently switch the view back to "Alle Sender".
*/
var restorePending by mutableStateOf(false)
private val reentryHandler = android.os.Handler(android.os.Looper.getMainLooper())
private var lastStoppedUrl: String = ""
private var lastStoppedAt: Long = 0L
/** Bumped on every interaction with the visible overlay to restart the
* auto-hide timer. */
var overlayPing by mutableLongStateOf(0L)
private set
fun pingOverlay() {
overlayVisible = true
overlayPing++
}
/** Phone-first onboarding progress (ADR-0007). */
var welcomePhase by mutableStateOf(WelcomePhase.WAIT_PHONE)
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
else SourceMode.GENERIC
)
private set
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()
val configured = auth.isLoggedIn || source.m3uUrl.isNotEmpty()
if (!configured) {
screen = Screen.WELCOME
} else if (sourceMode == SourceMode.DISPATCHARR && auth.isLoggedIn) {
dispatcharr.refresh { classifyRefresh(it) }
}
}
fun setMode(mode: SourceMode) {
sourceMode = mode
prefs.edit().putString(
"source_mode",
if (mode == SourceMode.DISPATCHARR) "dispatcharr" else "generic",
).apply()
if (mode == SourceMode.DISPATCHARR && auth.isLoggedIn) {
dispatcharr.refresh { classifyRefresh(it) }
}
}
var outputProfile: String
get() = dispatcharr.outputProfile
set(value) {
dispatcharr.outputProfile = value
prefs.edit().putString("output_profile", value).apply()
dispatcharr.rebuildStreamUrls()
}
fun activeChannels(): List<Channel> = when (sourceMode) {
SourceMode.GENERIC -> source.channels.value
SourceMode.DISPATCHARR -> dispatcharr.channels.value
}
fun nowNext(channel: Channel): NowNext = when (sourceMode) {
SourceMode.GENERIC -> source.nowNext(channel)
SourceMode.DISPATCHARR -> dispatcharr.nowNext(channel)
}
private fun upcoming(channel: Channel, windowMs: Long) = when (sourceMode) {
SourceMode.GENERIC -> source.upcoming(channel, windowMs)
SourceMode.DISPATCHARR -> dispatcharr.upcoming(channel, windowMs)
}
/**
* Channels showing the viewer's club within the scanned window, paired
* with the programme that matched — earliest kick-off first, so whatever
* is running right now sits on top.
*/
fun teamMatches(key: String): List<Pair<Channel, dev.castarr.tv.data.Programme>> {
val filter = dev.castarr.tv.data.TeamFilters.byKey(key) ?: return emptyList()
val window = dev.castarr.tv.data.TeamFilters.WINDOW_MS
return activeChannels()
.filter { dev.castarr.tv.data.TeamFilters.scansGroup(it.group) }
.mapNotNull { channel ->
upcoming(channel, window)
.firstOrNull { !isEpgPlaceholder(it.title) && filter.matches(it.title) }
?.let { channel to it }
}
.sortedBy { it.second.start }
}
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
}
},
)
}
private companion object {
/** Grace period before re-opening the channel just closed. */
const val REENTRY_GRACE_MS = 2_500L
}
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() {
prefs.edit().remove("teams").apply()
activeTeam = null
auth.logout()
appError = AppError.NONE
welcomePhase = WelcomePhase.WAIT_PHONE
welcomeUserCode = ""
screen = Screen.WELCOME
}
fun play(channel: Channel) {
lastWatched = channel
currentChannel = channel
playerVisible = true
// Re-opening the very channel that was just closed can hit the
// provider before it released the previous session, which comes back
// as its "Stream Offline" still image. Give it a moment.
val sinceStop = System.currentTimeMillis() - lastStoppedAt
val sameChannel = channel.url == lastStoppedUrl
if (sameChannel && sinceStop in 0 until REENTRY_GRACE_MS) {
val wait = REENTRY_GRACE_MS - sinceStop
playerState = "reconnecting"
reentryHandler.removeCallbacksAndMessages(null)
reentryHandler.postDelayed({
player.play(channel.url, channel.name, channel.group)
}, wait)
return
}
player.play(channel.url, channel.name, channel.group)
}
fun stopPlayback() {
restorePending = lastWatched != null
reentryHandler.removeCallbacksAndMessages(null)
lastStoppedUrl = currentChannel?.url.orEmpty()
lastStoppedAt = System.currentTimeMillis()
player.stop()
playerVisible = false
currentChannel = null
}
/** Retry the failed channel without leaving the player. */
fun retryPlayback() {
player.retryNow()
}
fun zap(direction: Int) {
val list = activeChannels()
if (list.isEmpty()) return
val current = list.indexOfFirst { it.url == currentChannel?.url }
val next = if (current < 0) 0 else (current + direction + list.size) % list.size
play(list[next])
}
fun syncFromPlayer() {
val previous = playerState
playerState = player.state
if (playerState != previous) overlayVisible = true
isLive = player.player.isCurrentMediaItemLive
positionMs = player.player.currentPosition.coerceAtLeast(0)
durationMs = player.player.duration.coerceAtLeast(0)
audioTracks = player.audioTracks()
// Briefly resurface the overlay when the running programme changes.
val nowTitle = currentChannel?.let { nowNext(it).now?.title }
if (playerVisible && nowTitle != null && lastNowTitle != null && nowTitle != lastNowTitle) {
overlayVisible = true
}
lastNowTitle = nowTitle
// Keep the player on screen while an error or reconnect is pending —
// otherwise the failure silently drops the viewer back to the list.
if (!player.hasMedia && playerVisible &&
player.errorMessage == null && !player.reconnecting
) {
playerVisible = false
currentChannel = null
}
if (player.errorMessage != null || player.reconnecting) overlayVisible = true
}
/** Steps to the next audio track (D-pad friendly: one key, cycles). */
fun cycleAudio(direction: Int) {
val tracks = audioTracks
if (tracks.size < 2) return
val current = tracks.indexOfFirst { it.selected }.coerceAtLeast(0)
player.selectAudio((current + direction + tracks.size) % tracks.size)
audioTracks = player.audioTracks()
}
}