Some checks failed
Build TV app / build (push) Failing after 5s
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>
314 lines
12 KiB
Kotlin
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()
|
|
}
|
|
}
|