Compose for TV UI, generic + Dispatcharr sources, device-flow login, favorites, stream profiles, richer remote
- Compose for TV scaffold: D-pad app shell (Live/Einstellungen), channel list with focus handling, fullscreen player with auto-hiding overlay; zapping via channel/D-pad keys (closes #2) - Generic source end-to-end: M3U + XMLTV configurable on the TV, Now/Next with progress in the list, channel cache (closes #3) - OIDC device-flow login: server URL is the only input, issuer/client id come from the fork's status endpoint; QR + user code screen, silent refresh, logout (closes #4) - Dispatcharr source via Bearer API: channels, groups, EPG grid; switchable against the generic source (closes #5) - Per-user favorites: star via long-press, favorites filter, backend-synced (closes #6) - Stream profile selection (Standard/Passthrough/audiofix/720p from the backend profile list), applied per stream URL (closes #7) - Phone remote: Now/Next lines, favorites star + filter over the WebSocket protocol (closes #8) Device verification pending (TV currently in use). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
112
app/src/main/java/dev/castarr/tv/AppState.kt
Normal file
112
app/src/main/java/dev/castarr/tv/AppState.kt
Normal file
@@ -0,0 +1,112 @@
|
||||
package dev.castarr.tv
|
||||
|
||||
import android.content.Context
|
||||
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.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(
|
||||
context: Context,
|
||||
val player: PlayerController,
|
||||
val source: SourceRepository,
|
||||
val auth: DeviceAuth,
|
||||
val dispatcharr: DispatcharrRepository,
|
||||
) {
|
||||
private val prefs = context.getSharedPreferences("app", Context.MODE_PRIVATE)
|
||||
|
||||
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 sourceMode by mutableStateOf(
|
||||
if (prefs.getString("source_mode", "generic") == "dispatcharr") SourceMode.DISPATCHARR
|
||||
else SourceMode.GENERIC
|
||||
)
|
||||
private set
|
||||
|
||||
enum class Screen { LIVE, SETTINGS }
|
||||
enum class SourceMode { GENERIC, DISPATCHARR }
|
||||
|
||||
init {
|
||||
dispatcharr.outputProfile = prefs.getString("output_profile", "").orEmpty()
|
||||
if (sourceMode == SourceMode.DISPATCHARR && auth.isLoggedIn) {
|
||||
dispatcharr.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
fun play(channel: Channel) {
|
||||
currentChannel = channel
|
||||
playerVisible = true
|
||||
player.play(channel.url, channel.name, channel.group)
|
||||
}
|
||||
|
||||
fun stopPlayback() {
|
||||
player.stop()
|
||||
playerVisible = false
|
||||
currentChannel = null
|
||||
}
|
||||
|
||||
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() {
|
||||
playerState = player.state
|
||||
isLive = player.player.isCurrentMediaItemLive
|
||||
positionMs = player.player.currentPosition.coerceAtLeast(0)
|
||||
durationMs = player.player.duration.coerceAtLeast(0)
|
||||
if (!player.hasMedia && playerVisible) {
|
||||
playerVisible = false
|
||||
currentChannel = null
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user