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:
be-nj
2026-08-25 01:35:48 +02:00
parent 358b4c6a47
commit 0cf22204d3
19 changed files with 1817 additions and 455 deletions

View 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
}
}
}