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:
@@ -241,7 +241,7 @@
|
||||
const state = {
|
||||
ws: null, connected: false, authorized: false,
|
||||
status: { state: 'idle', channel: '', group: '', live: false, volume: 0.5 },
|
||||
channels: [], playlistUrl: '',
|
||||
channels: [], playlistUrl: '', extras: { nowNext: [], favorites: [], favoritesSupported: false }, favOnly: false,
|
||||
retryDelay: 1000, volumeDragging: false, searchTerm: '',
|
||||
};
|
||||
|
||||
@@ -316,6 +316,7 @@
|
||||
$('tv-name').textContent = msg.device || 'TV';
|
||||
if (msg.status) { state.status = msg.status; }
|
||||
if (msg.channels) { state.channels = msg.channels; }
|
||||
if (msg.extras) { state.extras = msg.extras; }
|
||||
state.playlistUrl = msg.playlistUrl || '';
|
||||
$('pair-error').textContent = '';
|
||||
showView('view-remote');
|
||||
@@ -329,6 +330,7 @@
|
||||
case 'channels':
|
||||
state.channels = msg.channels || [];
|
||||
state.playlistUrl = msg.playlistUrl || '';
|
||||
if (msg.extras) { state.extras = msg.extras; }
|
||||
renderChannels();
|
||||
break;
|
||||
case 'toast':
|
||||
@@ -383,8 +385,24 @@
|
||||
$('setup').classList.add('open');
|
||||
return;
|
||||
}
|
||||
const favSet = new Set(state.extras.favorites || []);
|
||||
const favOn = state.extras.favoritesSupported && state.favOnly;
|
||||
const shown = favOn ? filtered.filter((c) => favSet.has(c.backendId)) : filtered;
|
||||
if (state.extras.favoritesSupported) {
|
||||
const bar = document.createElement('div');
|
||||
bar.style.cssText = 'display:flex;gap:8px;padding:2px 12px 10px';
|
||||
[['Alle', false], ['★ Favoriten', true]].forEach(([label, val]) => {
|
||||
const chip = document.createElement('button');
|
||||
chip.textContent = label;
|
||||
chip.style.cssText = 'padding:7px 14px;border-radius:999px;font-size:12px;background:' +
|
||||
(state.favOnly === val ? 'rgba(95,212,196,.12);color:#5fd4c4' : '#121418;color:#9aa0a8');
|
||||
chip.addEventListener('click', () => { state.favOnly = val; renderChannels(); });
|
||||
bar.appendChild(chip);
|
||||
});
|
||||
list.appendChild(bar);
|
||||
}
|
||||
const frag = document.createDocumentFragment();
|
||||
filtered.slice(0, 500).forEach((c) => {
|
||||
shown.slice(0, 500).forEach((c) => {
|
||||
const idx = state.channels.indexOf(c);
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'chan' + (c.url === state.status.url || c.name === state.status.channel ? ' current' : '');
|
||||
@@ -403,12 +421,38 @@
|
||||
const name = document.createElement('span');
|
||||
name.className = 'name'; name.textContent = c.name;
|
||||
meta.appendChild(name);
|
||||
if (c.group) {
|
||||
const info = (state.extras.nowNext || [])[idx];
|
||||
if (info && info.now) {
|
||||
const now = document.createElement('span');
|
||||
now.className = 'grp';
|
||||
const pct = info.stop > info.start
|
||||
? Math.round(100 * (Date.now() - info.start) / (info.stop - info.start)) : 0;
|
||||
now.textContent = info.now;
|
||||
meta.appendChild(now);
|
||||
const barWrap = document.createElement('span');
|
||||
barWrap.style.cssText = 'display:block;height:2px;border-radius:1px;background:#1e2126;margin-top:4px;max-width:180px';
|
||||
const bar = document.createElement('span');
|
||||
bar.style.cssText = 'display:block;height:2px;border-radius:1px;background:#5fd4c4;width:' +
|
||||
Math.max(0, Math.min(100, pct)) + '%';
|
||||
barWrap.appendChild(bar);
|
||||
meta.appendChild(barWrap);
|
||||
} else if (c.group) {
|
||||
const grp = document.createElement('span');
|
||||
grp.className = 'grp'; grp.textContent = c.group;
|
||||
meta.appendChild(grp);
|
||||
}
|
||||
btn.appendChild(num); btn.appendChild(meta);
|
||||
if (state.extras.favoritesSupported && c.backendId) {
|
||||
const star = document.createElement('span');
|
||||
const isFav = favSet.has(c.backendId);
|
||||
star.textContent = isFav ? '★' : '☆';
|
||||
star.style.cssText = 'font-size:20px;padding:8px;color:' + (isFav ? '#5fd4c4' : '#6b717a');
|
||||
star.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
send({ type: 'set_favorite', id: c.backendId });
|
||||
});
|
||||
btn.appendChild(star);
|
||||
}
|
||||
btn.addEventListener('click', () => playChannel(c));
|
||||
frag.appendChild(btn);
|
||||
});
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,167 +1,78 @@
|
||||
package dev.castarr.tv
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Color
|
||||
import android.media.AudioManager
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.media3.ui.PlayerView
|
||||
import dev.castarr.tv.pairing.Pairing
|
||||
import dev.castarr.tv.pairing.Qr
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import dev.castarr.tv.auth.DeviceAuth
|
||||
import dev.castarr.tv.data.DispatcharrRepository
|
||||
import dev.castarr.tv.data.SourceRepository
|
||||
import dev.castarr.tv.player.PlayerController
|
||||
import dev.castarr.tv.playlist.Channel
|
||||
import dev.castarr.tv.playlist.PlaylistRepository
|
||||
import dev.castarr.tv.server.ControlServer
|
||||
import dev.castarr.tv.ui.CastarrApp
|
||||
import org.json.JSONObject
|
||||
|
||||
class MainActivity : Activity(), ControlServer.Listener {
|
||||
class MainActivity : ComponentActivity(), ControlServer.Listener {
|
||||
|
||||
private lateinit var playerController: PlayerController
|
||||
private lateinit var state: AppState
|
||||
private lateinit var server: ControlServer
|
||||
private lateinit var playlist: PlaylistRepository
|
||||
private lateinit var audioManager: AudioManager
|
||||
|
||||
private lateinit var pairingScreen: View
|
||||
private lateinit var playerScreen: View
|
||||
private lateinit var playerView: PlayerView
|
||||
private lateinit var overlay: View
|
||||
private lateinit var overlayChannel: TextView
|
||||
private lateinit var overlayState: TextView
|
||||
private lateinit var overlayLiveDot: View
|
||||
private lateinit var overlayLiveLabel: TextView
|
||||
private lateinit var overlayProgress: View
|
||||
private lateinit var deviceChip: View
|
||||
private lateinit var deviceName: TextView
|
||||
private lateinit var pairingStatus: TextView
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val hideOverlay = Runnable { overlay.animate().alpha(0f).setDuration(400).start() }
|
||||
private val ticker = object : Runnable {
|
||||
override fun run() {
|
||||
updateProgress()
|
||||
state.syncFromPlayer()
|
||||
server.broadcastStatus()
|
||||
mainHandler.postDelayed(this, TICK_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
private var connectedName: String? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
|
||||
playlist = PlaylistRepository(this)
|
||||
playerController = PlayerController(this) { onPlaybackChanged() }
|
||||
|
||||
bindViews()
|
||||
setupPairingScreen()
|
||||
|
||||
val auth = DeviceAuth(this)
|
||||
state = AppState(
|
||||
context = this,
|
||||
player = PlayerController(this) { onPlaybackChanged() },
|
||||
source = SourceRepository(this),
|
||||
auth = auth,
|
||||
dispatcharr = DispatcharrRepository(auth),
|
||||
)
|
||||
server = ControlServer(this, this)
|
||||
server.startServer()
|
||||
setContent { CastarrApp(state) }
|
||||
}
|
||||
|
||||
private fun bindViews() {
|
||||
pairingScreen = findViewById(R.id.pairing_screen)
|
||||
playerScreen = findViewById(R.id.player_screen)
|
||||
playerView = findViewById(R.id.player_view)
|
||||
overlay = findViewById(R.id.overlay)
|
||||
overlayChannel = findViewById(R.id.overlay_channel)
|
||||
overlayState = findViewById(R.id.overlay_state)
|
||||
overlayLiveDot = findViewById(R.id.overlay_live_dot)
|
||||
overlayLiveLabel = findViewById(R.id.overlay_live_label)
|
||||
overlayProgress = findViewById(R.id.overlay_progress)
|
||||
deviceChip = findViewById(R.id.device_chip)
|
||||
deviceName = findViewById(R.id.device_name)
|
||||
pairingStatus = findViewById(R.id.pairing_status)
|
||||
|
||||
playerView.useController = false
|
||||
playerView.player = playerController.player
|
||||
overlayProgress.pivotX = 0f
|
||||
}
|
||||
|
||||
private fun setupPairingScreen() {
|
||||
val address = Pairing.lanAddress()
|
||||
val qrImage = findViewById<ImageView>(R.id.qr_image)
|
||||
val urlText = findViewById<TextView>(R.id.pairing_url)
|
||||
val codeText = findViewById<TextView>(R.id.pairing_code)
|
||||
codeText.text = getString(R.string.pairing_code, Pairing.code(this))
|
||||
if (address != null) {
|
||||
urlText.text = Pairing.remoteUrl(address)
|
||||
qrImage.setImageBitmap(
|
||||
Qr.encode(Pairing.pairingUrl(this, address), QR_SIZE_PX, Color.parseColor("#101216"))
|
||||
)
|
||||
} else {
|
||||
urlText.text = getString(R.string.no_network)
|
||||
}
|
||||
}
|
||||
|
||||
// --- playback state → UI + remote ---
|
||||
|
||||
private fun onPlaybackChanged() {
|
||||
val showPlayer = playerController.hasMedia
|
||||
pairingScreen.visibility = if (showPlayer) View.GONE else View.VISIBLE
|
||||
playerScreen.visibility = if (showPlayer) View.VISIBLE else View.GONE
|
||||
val active = playerController.state == "playing" || playerController.state == "buffering"
|
||||
playerScreen.keepScreenOn = active
|
||||
|
||||
state.syncFromPlayer()
|
||||
val active = state.playerState == "playing" || state.playerState == "buffering"
|
||||
window.decorView.keepScreenOn = active
|
||||
mainHandler.removeCallbacks(ticker)
|
||||
if (showPlayer) {
|
||||
overlayChannel.text = playerController.channelName
|
||||
val live = playerController.player.isCurrentMediaItemLive
|
||||
overlayLiveDot.visibility = if (live) View.VISIBLE else View.GONE
|
||||
overlayLiveLabel.text = if (live) getString(R.string.live) else ""
|
||||
overlayState.text = when (playerController.state) {
|
||||
"paused" -> getString(R.string.paused)
|
||||
"buffering" -> getString(R.string.buffering)
|
||||
"error" -> getString(R.string.playback_error, playerController.errorMessage.orEmpty())
|
||||
else -> getString(R.string.playing)
|
||||
}
|
||||
updateProgress()
|
||||
showOverlay(autoHide = playerController.state == "playing")
|
||||
if (active) mainHandler.postDelayed(ticker, TICK_INTERVAL_MS)
|
||||
}
|
||||
onClientsChanged(-1, null)
|
||||
if (active) mainHandler.postDelayed(ticker, TICK_INTERVAL_MS)
|
||||
server.broadcastStatus()
|
||||
}
|
||||
|
||||
private fun updateProgress() {
|
||||
val player = playerController.player
|
||||
val fraction = if (player.isCurrentMediaItemLive || player.duration <= 0) {
|
||||
1f
|
||||
} else {
|
||||
(player.currentPosition.toFloat() / player.duration).coerceIn(0f, 1f)
|
||||
}
|
||||
overlayProgress.scaleX = fraction
|
||||
}
|
||||
|
||||
private fun showOverlay(autoHide: Boolean) {
|
||||
mainHandler.removeCallbacks(hideOverlay)
|
||||
overlay.animate().alpha(1f).setDuration(200).start()
|
||||
if (autoHide) mainHandler.postDelayed(hideOverlay, OVERLAY_HIDE_DELAY_MS)
|
||||
}
|
||||
|
||||
// --- ControlServer.Listener (called on main thread) ---
|
||||
|
||||
override fun onPlay(url: String, name: String, group: String) {
|
||||
playerController.play(url, name, group)
|
||||
state.play(Channel(name = name, url = url, group = group, logo = ""))
|
||||
}
|
||||
|
||||
override fun onTogglePlay() = playerController.toggle()
|
||||
override fun onTogglePlay() = state.player.toggle()
|
||||
|
||||
override fun onPauseCast() = playerController.pause()
|
||||
override fun onPauseCast() = state.player.pause()
|
||||
|
||||
override fun onResumeCast() = playerController.resume()
|
||||
override fun onResumeCast() = state.player.resume()
|
||||
|
||||
override fun onStopCast() = playerController.stop()
|
||||
override fun onStopCast() = state.stopPlayback()
|
||||
|
||||
override fun onSeek(deltaSeconds: Long) {
|
||||
playerController.seekBy(deltaSeconds)
|
||||
showOverlay(autoHide = true)
|
||||
state.player.seekBy(deltaSeconds)
|
||||
state.overlayVisible = true
|
||||
}
|
||||
|
||||
override fun onVolume(value: Float) {
|
||||
@@ -171,7 +82,7 @@ class MainActivity : Activity(), ControlServer.Listener {
|
||||
}
|
||||
|
||||
override fun onSetPlaylist(url: String) {
|
||||
playlist.refresh(url) { result ->
|
||||
state.source.configure(url, state.source.epgUrl) { result ->
|
||||
mainHandler.post {
|
||||
result
|
||||
.onSuccess { server.broadcastChannels() }
|
||||
@@ -181,60 +92,88 @@ class MainActivity : Activity(), ControlServer.Listener {
|
||||
}
|
||||
|
||||
override fun onClientsChanged(count: Int, newestName: String?) {
|
||||
if (newestName != null) connectedName = newestName
|
||||
if (count >= 0) {
|
||||
pairingStatus.text = if (count > 0) {
|
||||
getString(R.string.paired_with, connectedName.orEmpty())
|
||||
} else {
|
||||
getString(R.string.ready_to_pair)
|
||||
}
|
||||
}
|
||||
val chipVisible = connectedName != null && playerController.hasMedia
|
||||
deviceChip.visibility = if (chipVisible) View.VISIBLE else View.GONE
|
||||
deviceName.text = connectedName.orEmpty()
|
||||
if (newestName != null) state.connectedRemote = newestName
|
||||
if (count == 0) state.connectedRemote = null
|
||||
}
|
||||
|
||||
override fun currentStatus(): JSONObject {
|
||||
val max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
|
||||
val vol = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
|
||||
val player = playerController.player
|
||||
val player = state.player
|
||||
return JSONObject()
|
||||
.put("state", playerController.state)
|
||||
.put("channel", playerController.channelName)
|
||||
.put("group", playerController.channelGroup)
|
||||
.put("live", player.isCurrentMediaItemLive)
|
||||
.put("seekable", player.isCurrentMediaItemSeekable)
|
||||
.put("position", player.currentPosition.coerceAtLeast(0))
|
||||
.put("duration", player.duration.coerceAtLeast(0))
|
||||
.put("state", player.state)
|
||||
.put("channel", player.channelName)
|
||||
.put("group", player.channelGroup)
|
||||
.put("live", player.player.isCurrentMediaItemLive)
|
||||
.put("seekable", player.player.isCurrentMediaItemSeekable)
|
||||
.put("position", player.player.currentPosition.coerceAtLeast(0))
|
||||
.put("duration", player.player.duration.coerceAtLeast(0))
|
||||
.put("volume", if (max > 0) vol.toDouble() / max else 0.0)
|
||||
.put("error", playerController.errorMessage ?: "")
|
||||
.put("error", player.errorMessage ?: "")
|
||||
}
|
||||
|
||||
override fun currentChannels(): List<Channel> = playlist.channels
|
||||
override fun currentChannels(): List<Channel> = state.activeChannels()
|
||||
|
||||
override fun currentPlaylistUrl(): String = playlist.playlistUrl
|
||||
override fun channelsExtras(): JSONObject {
|
||||
val nowNext = org.json.JSONArray()
|
||||
val channels = state.activeChannels()
|
||||
channels.forEach { channel ->
|
||||
val info = state.nowNext(channel)
|
||||
nowNext.put(
|
||||
JSONObject()
|
||||
.put("now", info.now?.title ?: "")
|
||||
.put("start", info.now?.start ?: 0)
|
||||
.put("stop", info.now?.stop ?: 0)
|
||||
.put("next", info.next?.title ?: "")
|
||||
)
|
||||
}
|
||||
val favorites = org.json.JSONArray()
|
||||
state.dispatcharr.favorites.value.forEach { favorites.put(it) }
|
||||
return JSONObject()
|
||||
.put("nowNext", nowNext)
|
||||
.put("favorites", favorites)
|
||||
.put("favoritesSupported", state.sourceMode == AppState.SourceMode.DISPATCHARR)
|
||||
}
|
||||
|
||||
// --- TV remote keys (TV-PC / TV-PP) ---
|
||||
override fun onToggleFavorite(channelId: Int) {
|
||||
val channel = state.activeChannels().firstOrNull { it.backendId == channelId } ?: return
|
||||
state.dispatcharr.toggleFavorite(channel)
|
||||
mainHandler.postDelayed({ server.broadcastChannels() }, 800)
|
||||
}
|
||||
|
||||
override fun currentPlaylistUrl(): String = state.source.m3uUrl
|
||||
|
||||
// --- TV remote keys during playback (TV-PC / TV-PP) ---
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
if (playerController.hasMedia) {
|
||||
if (state.playerVisible) {
|
||||
when (keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_CENTER,
|
||||
KeyEvent.KEYCODE_ENTER,
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
|
||||
playerController.toggle()
|
||||
state.player.toggle()
|
||||
return true
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY -> {
|
||||
playerController.resume()
|
||||
state.player.resume()
|
||||
return true
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_PAUSE -> {
|
||||
playerController.pause()
|
||||
state.player.pause()
|
||||
return true
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_STOP -> {
|
||||
playerController.stop()
|
||||
state.stopPlayback()
|
||||
return true
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_UP,
|
||||
KeyEvent.KEYCODE_CHANNEL_UP -> {
|
||||
state.zap(-1)
|
||||
return true
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_DOWN,
|
||||
KeyEvent.KEYCODE_CHANNEL_DOWN -> {
|
||||
state.zap(1)
|
||||
return true
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_LEFT,
|
||||
@@ -248,7 +187,7 @@ class MainActivity : Activity(), ControlServer.Listener {
|
||||
return true
|
||||
}
|
||||
KeyEvent.KEYCODE_BACK -> {
|
||||
playerController.stop()
|
||||
state.stopPlayback()
|
||||
return true
|
||||
}
|
||||
else -> Unit
|
||||
@@ -262,19 +201,17 @@ class MainActivity : Activity(), ControlServer.Listener {
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
// TV-NP: video must not keep playing when the user leaves the app.
|
||||
playerController.pause()
|
||||
state.player.pause()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
mainHandler.removeCallbacksAndMessages(null)
|
||||
server.stopServer()
|
||||
playerController.release()
|
||||
state.player.release()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val QR_SIZE_PX = 512
|
||||
const val OVERLAY_HIDE_DELAY_MS = 4_000L
|
||||
const val TICK_INTERVAL_MS = 2_000L
|
||||
const val SEEK_STEP_SECONDS = 10L
|
||||
}
|
||||
|
||||
208
app/src/main/java/dev/castarr/tv/auth/DeviceAuth.kt
Normal file
208
app/src/main/java/dev/castarr/tv/auth/DeviceAuth.kt
Normal file
@@ -0,0 +1,208 @@
|
||||
package dev.castarr.tv.auth
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.net.URLEncoder
|
||||
|
||||
/**
|
||||
* OIDC device-authorization-grant client. The only configuration a user
|
||||
* enters is the Dispatcharr URL — issuer and public client id come from its
|
||||
* /api/accounts/oidc/status/ endpoint (fork feature).
|
||||
*/
|
||||
class DeviceAuth(context: Context) {
|
||||
|
||||
private val prefs = context.getSharedPreferences("auth", Context.MODE_PRIVATE)
|
||||
|
||||
data class ServerConfig(val issuer: String, val clientId: String, val label: String)
|
||||
data class DeviceSession(
|
||||
val deviceCode: String,
|
||||
val userCode: String,
|
||||
val verificationUri: String,
|
||||
val verificationUriComplete: String,
|
||||
val intervalSeconds: Int,
|
||||
val expiresInSeconds: Int,
|
||||
)
|
||||
|
||||
sealed class PollResult {
|
||||
data object Pending : PollResult()
|
||||
data object Denied : PollResult()
|
||||
data class Success(val username: String) : PollResult()
|
||||
}
|
||||
|
||||
var serverUrl: String
|
||||
get() = prefs.getString("server_url", "").orEmpty().trimEnd('/')
|
||||
set(value) { prefs.edit().putString("server_url", value.trimEnd('/')).apply() }
|
||||
|
||||
val username: String get() = prefs.getString("username", "").orEmpty()
|
||||
val isLoggedIn: Boolean get() = prefs.getString("refresh_token", null) != null
|
||||
|
||||
private var issuer: String
|
||||
get() = prefs.getString("issuer", "").orEmpty()
|
||||
set(value) { prefs.edit().putString("issuer", value).apply() }
|
||||
private var clientId: String
|
||||
get() = prefs.getString("client_id", "").orEmpty()
|
||||
set(value) { prefs.edit().putString("client_id", value).apply() }
|
||||
|
||||
suspend fun fetchServerConfig(url: String): ServerConfig = withContext(Dispatchers.IO) {
|
||||
val status = JSONObject(get("${url.trimEnd('/')}/api/accounts/oidc/status/"))
|
||||
val config = ServerConfig(
|
||||
issuer = status.optString("issuer"),
|
||||
clientId = status.optString("device_client_id"),
|
||||
label = status.optString("label"),
|
||||
)
|
||||
require(status.optBoolean("enabled") && config.issuer.isNotEmpty() && config.clientId.isNotEmpty()) {
|
||||
"Server has no device-flow SSO configured"
|
||||
}
|
||||
serverUrl = url
|
||||
issuer = config.issuer
|
||||
clientId = config.clientId
|
||||
config
|
||||
}
|
||||
|
||||
private suspend fun discovery(): JSONObject = withContext(Dispatchers.IO) {
|
||||
JSONObject(get(issuer.trimEnd('/') + "/.well-known/openid-configuration"))
|
||||
}
|
||||
|
||||
suspend fun startDeviceFlow(): DeviceSession = withContext(Dispatchers.IO) {
|
||||
val doc = discovery()
|
||||
val response = JSONObject(
|
||||
post(
|
||||
doc.getString("device_authorization_endpoint"),
|
||||
mapOf("client_id" to clientId, "scope" to "openid profile email offline_access"),
|
||||
)
|
||||
)
|
||||
DeviceSession(
|
||||
deviceCode = response.getString("device_code"),
|
||||
userCode = response.getString("user_code"),
|
||||
verificationUri = response.optString("verification_uri"),
|
||||
verificationUriComplete = response.optString(
|
||||
"verification_uri_complete",
|
||||
response.optString("verification_uri"),
|
||||
),
|
||||
intervalSeconds = response.optInt("interval", 5).coerceAtLeast(2),
|
||||
expiresInSeconds = response.optInt("expires_in", 600),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun poll(session: DeviceSession): PollResult = withContext(Dispatchers.IO) {
|
||||
val doc = discovery()
|
||||
val body = post(
|
||||
doc.getString("token_endpoint"),
|
||||
mapOf(
|
||||
"grant_type" to "urn:ietf:params:oauth:grant-type:device_code",
|
||||
"device_code" to session.deviceCode,
|
||||
"client_id" to clientId,
|
||||
),
|
||||
allowError = true,
|
||||
)
|
||||
val json = JSONObject(body)
|
||||
when {
|
||||
json.has("access_token") -> {
|
||||
storeTokens(json)
|
||||
PollResult.Success(username)
|
||||
}
|
||||
json.optString("error") in listOf("authorization_pending", "slow_down") -> PollResult.Pending
|
||||
else -> {
|
||||
Log.w(TAG, "device flow error: ${json.optString("error")}")
|
||||
PollResult.Denied
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Valid access token, refreshing when necessary; null when logged out. */
|
||||
suspend fun accessToken(): String? = withContext(Dispatchers.IO) {
|
||||
val token = prefs.getString("access_token", null)
|
||||
val expiry = prefs.getLong("expires_at", 0)
|
||||
if (token != null && System.currentTimeMillis() < expiry - 30_000) return@withContext token
|
||||
val refresh = prefs.getString("refresh_token", null) ?: return@withContext null
|
||||
runCatching {
|
||||
val doc = discovery()
|
||||
val json = JSONObject(
|
||||
post(
|
||||
doc.getString("token_endpoint"),
|
||||
mapOf(
|
||||
"grant_type" to "refresh_token",
|
||||
"refresh_token" to refresh,
|
||||
"client_id" to clientId,
|
||||
),
|
||||
)
|
||||
)
|
||||
storeTokens(json)
|
||||
json.getString("access_token")
|
||||
}.onFailure { Log.w(TAG, "token refresh failed", it) }.getOrNull()
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
prefs.edit()
|
||||
.remove("access_token").remove("refresh_token")
|
||||
.remove("expires_at").remove("username")
|
||||
.apply()
|
||||
}
|
||||
|
||||
private fun storeTokens(json: JSONObject) {
|
||||
val editor = prefs.edit()
|
||||
.putString("access_token", json.getString("access_token"))
|
||||
.putLong(
|
||||
"expires_at",
|
||||
System.currentTimeMillis() + json.optLong("expires_in", 3600) * 1000,
|
||||
)
|
||||
if (json.has("refresh_token")) editor.putString("refresh_token", json.getString("refresh_token"))
|
||||
// Username from the id_token payload (unverified decode is fine here —
|
||||
// it arrived over TLS directly from the token endpoint).
|
||||
json.optString("id_token").split(".").getOrNull(1)?.let { payload ->
|
||||
runCatching {
|
||||
val decoded = String(android.util.Base64.decode(payload, android.util.Base64.URL_SAFE))
|
||||
val claims = JSONObject(decoded)
|
||||
editor.putString(
|
||||
"username",
|
||||
claims.optString("preferred_username", claims.optString("sub")),
|
||||
)
|
||||
}
|
||||
}
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
private fun get(url: String): String {
|
||||
val connection = URL(url).openConnection() as HttpURLConnection
|
||||
return try {
|
||||
connection.connectTimeout = 10_000
|
||||
connection.readTimeout = 15_000
|
||||
connection.inputStream.bufferedReader().use { it.readText() }
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun post(url: String, form: Map<String, String>, allowError: Boolean = false): String {
|
||||
val connection = URL(url).openConnection() as HttpURLConnection
|
||||
return try {
|
||||
connection.requestMethod = "POST"
|
||||
connection.doOutput = true
|
||||
connection.connectTimeout = 10_000
|
||||
connection.readTimeout = 15_000
|
||||
connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded")
|
||||
val body = form.entries.joinToString("&") {
|
||||
"${it.key}=${URLEncoder.encode(it.value, "UTF-8")}"
|
||||
}
|
||||
connection.outputStream.use { it.write(body.toByteArray()) }
|
||||
val stream = if (connection.responseCode >= 400) {
|
||||
if (!allowError) error("HTTP ${connection.responseCode} from $url")
|
||||
connection.errorStream
|
||||
} else {
|
||||
connection.inputStream
|
||||
}
|
||||
stream.bufferedReader().use { it.readText() }
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "DeviceAuth"
|
||||
}
|
||||
}
|
||||
193
app/src/main/java/dev/castarr/tv/data/DispatcharrRepository.kt
Normal file
193
app/src/main/java/dev/castarr/tv/data/DispatcharrRepository.kt
Normal file
@@ -0,0 +1,193 @@
|
||||
package dev.castarr.tv.data
|
||||
|
||||
import android.util.Log
|
||||
import dev.castarr.tv.auth.DeviceAuth
|
||||
import dev.castarr.tv.playlist.Channel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
/**
|
||||
* Channel source backed by the Dispatcharr fork's Bearer API: channels,
|
||||
* 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) {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
val channels = MutableStateFlow<List<Channel>>(emptyList())
|
||||
val favorites = MutableStateFlow<Set<Int>>(emptySet())
|
||||
val profiles = MutableStateFlow<List<String>>(emptyList())
|
||||
val epgUpdatedAt = MutableStateFlow(0L)
|
||||
val status = MutableStateFlow("")
|
||||
|
||||
private var programmesByTvgId: Map<String, List<Programme>> = emptyMap()
|
||||
var outputProfile: String = ""
|
||||
|
||||
fun refresh(onDone: (Result<Int>) -> Unit = {}) {
|
||||
scope.launch {
|
||||
val result = runCatching {
|
||||
status.value = "loading_channels"
|
||||
val token = auth.accessToken() ?: error("not logged in")
|
||||
val groups = fetchGroups(token)
|
||||
val list = fetchChannels(token, groups)
|
||||
channels.value = list
|
||||
launch { runCatching { refreshFavorites(token) } }
|
||||
launch { runCatching { refreshProfiles(token) } }
|
||||
launch {
|
||||
runCatching { refreshEpg(token) }
|
||||
.onFailure { Log.w(TAG, "epg failed", it) }
|
||||
}
|
||||
list.size
|
||||
}
|
||||
result.onFailure { Log.w(TAG, "refresh failed", it) }
|
||||
status.value = if (result.isSuccess) "" else "channels_error"
|
||||
onDone(result)
|
||||
}
|
||||
}
|
||||
|
||||
fun nowNext(channel: Channel): NowNext =
|
||||
XmltvParser.nowNext(programmesByTvgId[channel.tvgId])
|
||||
|
||||
fun toggleFavorite(channel: Channel) {
|
||||
if (channel.backendId == 0) return
|
||||
scope.launch {
|
||||
runCatching {
|
||||
val token = auth.accessToken() ?: return@launch
|
||||
val isFavorite = channel.backendId in favorites.value
|
||||
request(
|
||||
"${auth.serverUrl}/api/channels/favorites/${channel.backendId}/",
|
||||
if (isFavorite) "DELETE" else "POST",
|
||||
token,
|
||||
)
|
||||
favorites.value =
|
||||
if (isFavorite) favorites.value - channel.backendId
|
||||
else favorites.value + channel.backendId
|
||||
}.onFailure { Log.w(TAG, "favorite toggle failed", it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun streamUrl(id: Int): String {
|
||||
val base = "${auth.serverUrl}/proxy/ts/stream/$id"
|
||||
return if (outputProfile.isNotEmpty()) "$base?output_profile=$outputProfile" else base
|
||||
}
|
||||
|
||||
/** Re-derive stream URLs (e.g. after the output profile changed). */
|
||||
fun rebuildStreamUrls() {
|
||||
channels.value = channels.value.map { it.copy(url = streamUrl(it.backendId)) }
|
||||
}
|
||||
|
||||
private suspend fun fetchGroups(token: String): Map<Int, String> {
|
||||
val body = request("${auth.serverUrl}/api/channels/groups/?page_size=1000", "GET", token)
|
||||
val results = paginatedResults(body)
|
||||
return (0 until results.length()).associate {
|
||||
val obj = results.getJSONObject(it)
|
||||
obj.getInt("id") to obj.optString("name")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchChannels(token: String, groups: Map<Int, String>): List<Channel> {
|
||||
val list = mutableListOf<Channel>()
|
||||
var url: String? = "${auth.serverUrl}/api/channels/channels/?page_size=500"
|
||||
while (url != null && list.size < 10_000) {
|
||||
val body = request(url, "GET", token)
|
||||
val json = runCatching { JSONObject(body) }.getOrNull()
|
||||
val results = json?.optJSONArray("results") ?: JSONArray(body)
|
||||
for (i in 0 until results.length()) {
|
||||
val obj = results.getJSONObject(i)
|
||||
val id = obj.getInt("id")
|
||||
list.add(
|
||||
Channel(
|
||||
name = obj.optString("name"),
|
||||
url = streamUrl(id),
|
||||
group = groups[obj.optInt("channel_group_id")].orEmpty(),
|
||||
logo = "",
|
||||
tvgId = obj.optString("tvg_id"),
|
||||
backendId = id,
|
||||
)
|
||||
)
|
||||
}
|
||||
url = json?.optString("next")?.takeIf { it.isNotEmpty() && it != "null" }
|
||||
}
|
||||
list.sortBy { it.name.lowercase() }
|
||||
return list
|
||||
}
|
||||
|
||||
private suspend fun refreshFavorites(token: String) {
|
||||
val body = request("${auth.serverUrl}/api/channels/favorites/", "GET", token)
|
||||
val ids = JSONObject(body).optJSONArray("channels") ?: JSONArray()
|
||||
favorites.value = (0 until ids.length()).map { ids.getInt(it) }.toSet()
|
||||
}
|
||||
|
||||
private suspend fun refreshProfiles(token: String) {
|
||||
val body = request("${auth.serverUrl}/api/core/outputprofiles/", "GET", token)
|
||||
val results = paginatedResults(body)
|
||||
profiles.value = (0 until results.length()).mapNotNull {
|
||||
val obj = results.getJSONObject(it)
|
||||
if (obj.optBoolean("is_active", true)) obj.optString("name") else null
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
val map = HashMap<String, MutableList<Programme>>()
|
||||
for (i in 0 until results.length()) {
|
||||
val obj = results.getJSONObject(i)
|
||||
val tvgId = obj.optString("tvg_id")
|
||||
if (tvgId.isEmpty()) continue
|
||||
val start = parseIso(obj.optString("start_time"), format)
|
||||
val stop = parseIso(obj.optString("end_time"), format)
|
||||
if (start == 0L || stop <= start) continue
|
||||
map.getOrPut(tvgId) { mutableListOf() }
|
||||
.add(Programme(start, stop, obj.optString("title")))
|
||||
}
|
||||
map.values.forEach { it.sortBy(Programme::start) }
|
||||
programmesByTvgId = map
|
||||
epgUpdatedAt.value = System.currentTimeMillis()
|
||||
status.value = ""
|
||||
}
|
||||
|
||||
private fun parseIso(raw: String, format: SimpleDateFormat): Long = runCatching {
|
||||
format.parse(raw.substringBefore(".").substringBefore("+").removeSuffix("Z"))?.time ?: 0L
|
||||
}.getOrDefault(0L)
|
||||
|
||||
private fun paginatedResults(body: String): JSONArray =
|
||||
runCatching { JSONObject(body).optJSONArray("results") }.getOrNull() ?: JSONArray(body)
|
||||
|
||||
private suspend fun request(url: String, method: String, token: String): String =
|
||||
withContext(Dispatchers.IO) {
|
||||
val connection = URL(url).openConnection() as HttpURLConnection
|
||||
try {
|
||||
connection.requestMethod = method
|
||||
connection.connectTimeout = 10_000
|
||||
connection.readTimeout = 30_000
|
||||
connection.setRequestProperty("Authorization", "Bearer $token")
|
||||
val code = connection.responseCode
|
||||
if (code >= 400) error("HTTP $code from $url")
|
||||
connection.inputStream.bufferedReader().use { it.readText() }
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "DispatcharrRepo"
|
||||
}
|
||||
}
|
||||
118
app/src/main/java/dev/castarr/tv/data/SourceRepository.kt
Normal file
118
app/src/main/java/dev/castarr/tv/data/SourceRepository.kt
Normal file
@@ -0,0 +1,118 @@
|
||||
package dev.castarr.tv.data
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import dev.castarr.tv.playlist.Channel
|
||||
import dev.castarr.tv.playlist.M3uParser
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONArray
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
/**
|
||||
* Generic M3U + XMLTV source: fetches and caches the channel list, loads EPG
|
||||
* and answers Now/Next per channel. (The Dispatcharr Bearer-API source of
|
||||
* issue #5 will sit next to this as a second implementation.)
|
||||
*/
|
||||
class SourceRepository(context: Context) {
|
||||
|
||||
private val prefs = context.getSharedPreferences("source", Context.MODE_PRIVATE)
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
val channels = MutableStateFlow<List<Channel>>(emptyList())
|
||||
val epgUpdatedAt = MutableStateFlow(0L)
|
||||
val status = MutableStateFlow("")
|
||||
|
||||
private var programmes: Map<String, List<Programme>> = emptyMap()
|
||||
private var nameToEpgId: Map<String, String> = emptyMap()
|
||||
|
||||
var m3uUrl: String
|
||||
get() = prefs.getString("m3u_url", "").orEmpty()
|
||||
private set(value) { prefs.edit().putString("m3u_url", value).apply() }
|
||||
|
||||
var epgUrl: String
|
||||
get() = prefs.getString("epg_url", "").orEmpty()
|
||||
private set(value) { prefs.edit().putString("epg_url", value).apply() }
|
||||
|
||||
init {
|
||||
loadCachedChannels()
|
||||
if (m3uUrl.isNotEmpty()) refresh(m3uUrl, epgUrl)
|
||||
}
|
||||
|
||||
fun configure(m3u: String, epg: String, onDone: (Result<Int>) -> Unit = {}) {
|
||||
m3uUrl = m3u
|
||||
epgUrl = epg
|
||||
refresh(m3u, epg, onDone)
|
||||
}
|
||||
|
||||
fun refresh(m3u: String = m3uUrl, epg: String = epgUrl, onDone: (Result<Int>) -> Unit = {}) {
|
||||
if (m3u.isEmpty()) return
|
||||
scope.launch {
|
||||
val result = runCatching {
|
||||
status.value = "loading_channels"
|
||||
val parsed = M3uParser.parse(download(m3u))
|
||||
require(parsed.isNotEmpty()) { "empty playlist" }
|
||||
channels.value = parsed
|
||||
prefs.edit().putString("channels_cache", Channel.listToJson(parsed).toString()).apply()
|
||||
parsed.size
|
||||
}
|
||||
result.onFailure { Log.w(TAG, "channel refresh failed", it) }
|
||||
status.value = if (result.isSuccess) "" else "channels_error"
|
||||
onDone(result)
|
||||
if (result.isSuccess && epg.isNotEmpty()) refreshEpg(epg)
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshEpg(url: String) {
|
||||
runCatching {
|
||||
status.value = "loading_epg"
|
||||
val connection = open(url)
|
||||
connection.inputStream.use { stream ->
|
||||
val parsed = XmltvParser.parse(stream)
|
||||
programmes = parsed.programmes
|
||||
nameToEpgId = parsed.displayNameToId
|
||||
}
|
||||
epgUpdatedAt.value = System.currentTimeMillis()
|
||||
}.onFailure { Log.w(TAG, "epg refresh failed", it) }
|
||||
status.value = ""
|
||||
}
|
||||
|
||||
/** Now/Next for an M3U channel: match tvg-id first, then name. */
|
||||
fun nowNext(channel: Channel): NowNext {
|
||||
val direct = channel.tvgId.takeIf { it.isNotEmpty() }?.let { programmes[it] }
|
||||
val byName = direct ?: nameToEpgId[channel.name.lowercase()]?.let { programmes[it] }
|
||||
return XmltvParser.nowNext(byName)
|
||||
}
|
||||
|
||||
private fun loadCachedChannels() {
|
||||
runCatching {
|
||||
val cached = prefs.getString("channels_cache", null) ?: return
|
||||
channels.value = Channel.listFromJson(JSONArray(cached))
|
||||
}
|
||||
}
|
||||
|
||||
private fun open(url: String): HttpURLConnection =
|
||||
(URL(url).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = 15_000
|
||||
readTimeout = 60_000
|
||||
instanceFollowRedirects = true
|
||||
}
|
||||
|
||||
private fun download(url: String): String {
|
||||
val connection = open(url)
|
||||
return try {
|
||||
connection.inputStream.bufferedReader().use { it.readText() }
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "SourceRepository"
|
||||
}
|
||||
}
|
||||
113
app/src/main/java/dev/castarr/tv/data/Xmltv.kt
Normal file
113
app/src/main/java/dev/castarr/tv/data/Xmltv.kt
Normal file
@@ -0,0 +1,113 @@
|
||||
package dev.castarr.tv.data
|
||||
|
||||
import android.util.Log
|
||||
import android.util.Xml
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.io.InputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
data class Programme(val start: Long, val stop: Long, val title: String)
|
||||
|
||||
data class NowNext(val now: Programme?, val next: Programme?)
|
||||
|
||||
/**
|
||||
* Minimal XMLTV parser: programme start/stop/title per channel id. Display
|
||||
* names are also collected so channels can be matched by name when the M3U
|
||||
* carries no tvg-id.
|
||||
*/
|
||||
object XmltvParser {
|
||||
|
||||
private const val TAG = "XmltvParser"
|
||||
private const val MAX_PROGRAMMES = 200_000
|
||||
|
||||
data class Result(
|
||||
val programmes: Map<String, List<Programme>>,
|
||||
val displayNameToId: Map<String, String>,
|
||||
)
|
||||
|
||||
fun parse(input: InputStream): Result {
|
||||
val programmes = HashMap<String, MutableList<Programme>>()
|
||||
val displayNames = HashMap<String, String>()
|
||||
var count = 0
|
||||
|
||||
val parser = Xml.newPullParser()
|
||||
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
|
||||
parser.setInput(input, null)
|
||||
|
||||
var currentChannelId: String? = null
|
||||
var programmeChannel: String? = null
|
||||
var programmeStart = 0L
|
||||
var programmeStop = 0L
|
||||
var programmeTitle = ""
|
||||
var inTitle = false
|
||||
var inDisplayName = false
|
||||
|
||||
var event = parser.eventType
|
||||
while (event != XmlPullParser.END_DOCUMENT && count < MAX_PROGRAMMES) {
|
||||
when (event) {
|
||||
XmlPullParser.START_TAG -> when (parser.name) {
|
||||
"channel" -> currentChannelId = parser.getAttributeValue(null, "id")
|
||||
"display-name" -> inDisplayName = true
|
||||
"programme" -> {
|
||||
programmeChannel = parser.getAttributeValue(null, "channel")
|
||||
programmeStart = parseTime(parser.getAttributeValue(null, "start"))
|
||||
programmeStop = parseTime(parser.getAttributeValue(null, "stop"))
|
||||
programmeTitle = ""
|
||||
}
|
||||
"title" -> inTitle = programmeChannel != null
|
||||
}
|
||||
XmlPullParser.TEXT -> {
|
||||
if (inTitle && programmeTitle.isEmpty()) programmeTitle = parser.text.trim()
|
||||
if (inDisplayName) {
|
||||
val id = currentChannelId
|
||||
val name = parser.text.trim()
|
||||
if (id != null && name.isNotEmpty()) displayNames.putIfAbsent(name.lowercase(), id)
|
||||
}
|
||||
}
|
||||
XmlPullParser.END_TAG -> when (parser.name) {
|
||||
"display-name" -> inDisplayName = false
|
||||
"title" -> inTitle = false
|
||||
"channel" -> currentChannelId = null
|
||||
"programme" -> {
|
||||
val channel = programmeChannel
|
||||
if (channel != null && programmeStart > 0 && programmeStop > programmeStart) {
|
||||
programmes.getOrPut(channel) { mutableListOf() }
|
||||
.add(Programme(programmeStart, programmeStop, programmeTitle))
|
||||
count++
|
||||
}
|
||||
programmeChannel = null
|
||||
}
|
||||
}
|
||||
}
|
||||
event = parser.next()
|
||||
}
|
||||
programmes.values.forEach { it.sortBy(Programme::start) }
|
||||
Log.i(TAG, "parsed $count programmes for ${programmes.size} channels")
|
||||
return Result(programmes, displayNames)
|
||||
}
|
||||
|
||||
/** XMLTV time: `yyyyMMddHHmmss Z` (zone part optional). */
|
||||
private fun parseTime(raw: String?): Long {
|
||||
if (raw.isNullOrBlank()) return 0
|
||||
val normalized = raw.trim()
|
||||
val format = if (normalized.contains(' ')) "yyyyMMddHHmmss Z" else "yyyyMMddHHmmss"
|
||||
return try {
|
||||
SimpleDateFormat(format, Locale.US).parse(normalized)?.time ?: 0
|
||||
} catch (e: java.text.ParseException) {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fun nowNext(programmes: List<Programme>?, at: Long = System.currentTimeMillis()): NowNext {
|
||||
if (programmes.isNullOrEmpty()) return NowNext(null, null)
|
||||
val index = programmes.indexOfFirst { at < it.stop }
|
||||
if (index < 0) return NowNext(null, null)
|
||||
val candidate = programmes[index]
|
||||
return if (at >= candidate.start) {
|
||||
NowNext(candidate, programmes.getOrNull(index + 1))
|
||||
} else {
|
||||
NowNext(null, candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,16 @@ data class Channel(
|
||||
val url: String,
|
||||
val group: String,
|
||||
val logo: String,
|
||||
val tvgId: String = "",
|
||||
val backendId: Int = 0,
|
||||
) {
|
||||
fun toJson(): JSONObject = JSONObject()
|
||||
.put("name", name)
|
||||
.put("url", url)
|
||||
.put("group", group)
|
||||
.put("logo", logo)
|
||||
.put("tvgId", tvgId)
|
||||
.put("backendId", backendId)
|
||||
|
||||
companion object {
|
||||
fun fromJson(obj: JSONObject): Channel = Channel(
|
||||
@@ -21,6 +25,8 @@ data class Channel(
|
||||
url = obj.optString("url"),
|
||||
group = obj.optString("group"),
|
||||
logo = obj.optString("logo"),
|
||||
tvgId = obj.optString("tvgId"),
|
||||
backendId = obj.optInt("backendId"),
|
||||
)
|
||||
|
||||
fun listToJson(channels: List<Channel>): JSONArray {
|
||||
|
||||
@@ -11,6 +11,7 @@ object M3uParser {
|
||||
var name = ""
|
||||
var group = ""
|
||||
var logo = ""
|
||||
var tvgId = ""
|
||||
var pendingInfo = false
|
||||
|
||||
for (rawLine in content.lineSequence()) {
|
||||
@@ -20,6 +21,7 @@ object M3uParser {
|
||||
val attrs = attrRegex.findAll(line).associate { it.groupValues[1].lowercase() to it.groupValues[2] }
|
||||
group = attrs["group-title"].orEmpty()
|
||||
logo = attrs["tvg-logo"].orEmpty()
|
||||
tvgId = attrs["tvg-id"].orEmpty()
|
||||
name = line.substringAfterLast(',', "").trim()
|
||||
if (name.isEmpty()) name = attrs["tvg-name"].orEmpty()
|
||||
pendingInfo = true
|
||||
@@ -29,11 +31,12 @@ object M3uParser {
|
||||
}
|
||||
line.isEmpty() || line.startsWith("#") -> Unit
|
||||
pendingInfo -> {
|
||||
channels.add(Channel(name.ifEmpty { line }, line, group, logo))
|
||||
channels.add(Channel(name.ifEmpty { line }, line, group, logo, tvgId))
|
||||
if (channels.size >= MAX_CHANNELS) return channels
|
||||
name = ""
|
||||
group = ""
|
||||
logo = ""
|
||||
tvgId = ""
|
||||
pendingInfo = false
|
||||
}
|
||||
else -> {
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
package dev.castarr.tv.playlist
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONArray
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Fetches and caches the M3U playlist. The channel list is persisted so the
|
||||
* remote sees its channels again right after an app restart.
|
||||
*/
|
||||
class PlaylistRepository(context: Context) {
|
||||
|
||||
private val prefs = context.getSharedPreferences("playlist", Context.MODE_PRIVATE)
|
||||
private val executor = Executors.newSingleThreadExecutor()
|
||||
|
||||
var channels: List<Channel> = loadCached()
|
||||
private set
|
||||
|
||||
val playlistUrl: String
|
||||
get() = prefs.getString(KEY_URL, "").orEmpty()
|
||||
|
||||
fun refresh(url: String, onResult: (Result<List<Channel>>) -> Unit) {
|
||||
executor.execute {
|
||||
val result = runCatching {
|
||||
val content = download(url)
|
||||
val parsed = M3uParser.parse(content)
|
||||
require(parsed.isNotEmpty()) { "playlist is empty" }
|
||||
parsed
|
||||
}
|
||||
result.onSuccess { parsed ->
|
||||
channels = parsed
|
||||
prefs.edit()
|
||||
.putString(KEY_URL, url)
|
||||
.putString(KEY_CACHE, Channel.listToJson(parsed).toString())
|
||||
.apply()
|
||||
}
|
||||
onResult(result)
|
||||
}
|
||||
}
|
||||
|
||||
private fun download(url: String): String {
|
||||
val connection = URL(url).openConnection() as HttpURLConnection
|
||||
return try {
|
||||
connection.connectTimeout = 15_000
|
||||
connection.readTimeout = 30_000
|
||||
connection.instanceFollowRedirects = true
|
||||
connection.inputStream.bufferedReader().use { it.readText() }
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadCached(): List<Channel> = runCatching {
|
||||
Channel.listFromJson(JSONArray(prefs.getString(KEY_CACHE, "[]").orEmpty()))
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
private companion object {
|
||||
const val KEY_URL = "url"
|
||||
const val KEY_CACHE = "channels"
|
||||
}
|
||||
}
|
||||
@@ -33,9 +33,11 @@ class ControlServer(
|
||||
fun onSeek(deltaSeconds: Long)
|
||||
fun onVolume(value: Float)
|
||||
fun onSetPlaylist(url: String)
|
||||
fun onToggleFavorite(channelId: Int)
|
||||
fun onClientsChanged(count: Int, newestName: String?)
|
||||
fun currentStatus(): JSONObject
|
||||
fun currentChannels(): List<Channel>
|
||||
fun channelsExtras(): JSONObject
|
||||
fun currentPlaylistUrl(): String
|
||||
}
|
||||
|
||||
@@ -116,6 +118,7 @@ class ControlServer(
|
||||
.put("type", "channels")
|
||||
.put("playlistUrl", listener.currentPlaylistUrl())
|
||||
.put("channels", Channel.listToJson(listener.currentChannels()))
|
||||
.put("extras", listener.channelsExtras())
|
||||
|
||||
private fun pingClients() {
|
||||
clients.forEach { client ->
|
||||
@@ -182,6 +185,7 @@ class ControlServer(
|
||||
"seek" -> post { listener.onSeek(msg.optLong("delta")) }
|
||||
"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())
|
||||
@@ -224,6 +228,7 @@ class ControlServer(
|
||||
.put("status", listener.currentStatus())
|
||||
.put("playlistUrl", listener.currentPlaylistUrl())
|
||||
.put("channels", Channel.listToJson(listener.currentChannels()))
|
||||
.put("extras", listener.channelsExtras())
|
||||
.toString()
|
||||
)
|
||||
notifyClientsChanged(deviceName)
|
||||
|
||||
106
app/src/main/java/dev/castarr/tv/ui/CastarrApp.kt
Normal file
106
app/src/main/java/dev/castarr/tv/ui/CastarrApp.kt
Normal file
@@ -0,0 +1,106 @@
|
||||
package dev.castarr.tv.ui
|
||||
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.Text
|
||||
import dev.castarr.tv.AppState
|
||||
|
||||
@Composable
|
||||
fun CastarrApp(state: AppState) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(CastarrColors.bg)
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
TopBar(state)
|
||||
when (state.screen) {
|
||||
AppState.Screen.LIVE -> LiveScreen(state)
|
||||
AppState.Screen.SETTINGS -> SettingsScreen(state)
|
||||
}
|
||||
}
|
||||
if (state.playerVisible) {
|
||||
PlayerScreen(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopBar(state: AppState) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 40.dp, vertical = 18.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"CASTARR",
|
||||
color = CastarrColors.fg,
|
||||
fontFamily = SpaceGrotesk,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
letterSpacing = 4.sp,
|
||||
)
|
||||
Spacer(Modifier.width(36.dp))
|
||||
TabItem("Live", state.screen == AppState.Screen.LIVE) {
|
||||
state.screen = AppState.Screen.LIVE
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
TabItem("Einstellungen", state.screen == AppState.Screen.SETTINGS) {
|
||||
state.screen = AppState.Screen.SETTINGS
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
state.connectedRemote?.let { name ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(7.dp)
|
||||
.clip(CircleShape)
|
||||
.background(CastarrColors.accent)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(name, color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TabItem(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = if (selected) CastarrColors.accentDim else Color.Transparent,
|
||||
contentColor = if (selected) CastarrColors.accent else CastarrColors.muted,
|
||||
focusedContainerColor = CastarrColors.accent,
|
||||
focusedContentColor = CastarrColors.onAccent,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
fontFamily = SpaceGrotesk,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.padding(horizontal = 18.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
240
app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt
Normal file
240
app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt
Normal file
@@ -0,0 +1,240 @@
|
||||
package dev.castarr.tv.ui
|
||||
|
||||
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.fillMaxWidth
|
||||
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.lazy.LazyColumn
|
||||
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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import dev.castarr.tv.AppState
|
||||
import dev.castarr.tv.data.NowNext
|
||||
import dev.castarr.tv.playlist.Channel
|
||||
|
||||
@Composable
|
||||
fun LiveScreen(state: AppState) {
|
||||
val genericChannels by state.source.channels.collectAsStateWithLifecycle()
|
||||
val dispatcharrChannels by state.dispatcharr.channels.collectAsStateWithLifecycle()
|
||||
val favorites by state.dispatcharr.favorites.collectAsStateWithLifecycle()
|
||||
// Re-render Now/Next when a new EPG arrives.
|
||||
val genericEpg by state.source.epgUpdatedAt.collectAsStateWithLifecycle()
|
||||
val dispatcharrEpg by state.dispatcharr.epgUpdatedAt.collectAsStateWithLifecycle()
|
||||
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
|
||||
}
|
||||
|
||||
if (allChannels.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
"Noch keine Sender — richte unter Einstellungen eine Quelle ein.",
|
||||
color = CastarrColors.faint,
|
||||
fontFamily = SpaceGrotesk,
|
||||
fontSize = 15.sp,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(
|
||||
start = 40.dp, end = 40.dp, top = 8.dp, bottom = 32.dp
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(channels, key = { it.url + it.name }) { channel ->
|
||||
ChannelRow(
|
||||
channel = channel,
|
||||
index = channels.indexOf(channel),
|
||||
nowNext = state.nowNext(channel),
|
||||
playing = state.currentChannel?.url == channel.url,
|
||||
favorite = isDispatcharr && channel.backendId in favorites,
|
||||
epgStamp = epgStamp,
|
||||
onLongClick = if (isDispatcharr) {
|
||||
{ state.dispatcharr.toggleFavorite(channel) }
|
||||
} else null,
|
||||
) { state.play(channel) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilterChip(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = if (selected) CastarrColors.accentDim else CastarrColors.surface,
|
||||
contentColor = if (selected) CastarrColors.accent else CastarrColors.muted,
|
||||
focusedContainerColor = CastarrColors.accent,
|
||||
focusedContentColor = CastarrColors.onAccent,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
fontFamily = SpaceGrotesk,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelRow(
|
||||
channel: Channel,
|
||||
index: Int,
|
||||
nowNext: NowNext,
|
||||
playing: Boolean,
|
||||
favorite: Boolean,
|
||||
epgStamp: Long,
|
||||
onLongClick: (() -> Unit)?,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = if (playing) CastarrColors.accentDim else Color.Transparent,
|
||||
contentColor = CastarrColors.fg,
|
||||
focusedContainerColor = CastarrColors.surfaceFocused,
|
||||
focusedContentColor = CastarrColors.fg,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"${index + 1}",
|
||||
color = if (playing) CastarrColors.accent else CastarrColors.faint,
|
||||
fontFamily = SpaceGrotesk,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.width(44.dp),
|
||||
)
|
||||
if (favorite) {
|
||||
Text(
|
||||
"★",
|
||||
color = CastarrColors.accent,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.width(20.dp),
|
||||
)
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
channel.name,
|
||||
color = if (playing) CastarrColors.accent else CastarrColors.fg,
|
||||
fontFamily = SpaceGrotesk,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = if (playing) FontWeight.Medium else FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (channel.group.isNotEmpty()) {
|
||||
Text(
|
||||
channel.group,
|
||||
color = CastarrColors.faint,
|
||||
fontFamily = SpaceGrotesk,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
NowNextCell(nowNext)
|
||||
if (playing) {
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Box(
|
||||
Modifier
|
||||
.size(7.dp)
|
||||
.clip(CircleShape)
|
||||
.background(CastarrColors.accent)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NowNextCell(nowNext: NowNext) {
|
||||
val now = nowNext.now
|
||||
Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(320.dp)) {
|
||||
if (now != null) {
|
||||
Text(
|
||||
now.title,
|
||||
color = CastarrColors.muted,
|
||||
fontFamily = SpaceGrotesk,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val fraction = ((System.currentTimeMillis() - now.start).toFloat() /
|
||||
(now.stop - now.start).coerceAtLeast(1)).coerceIn(0f, 1f)
|
||||
Spacer(Modifier.height(5.dp))
|
||||
Box(
|
||||
Modifier
|
||||
.width(180.dp)
|
||||
.height(2.dp)
|
||||
.clip(RoundedCornerShape(1.dp))
|
||||
.background(CastarrColors.line)
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(fraction)
|
||||
.height(2.dp)
|
||||
.background(CastarrColors.accent)
|
||||
)
|
||||
}
|
||||
nowNext.next?.let { next ->
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"danach: ${next.title}",
|
||||
color = CastarrColors.faint,
|
||||
fontFamily = SpaceGrotesk,
|
||||
fontSize = 11.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
174
app/src/main/java/dev/castarr/tv/ui/PlayerScreen.kt
Normal file
174
app/src/main/java/dev/castarr/tv/ui/PlayerScreen.kt
Normal file
@@ -0,0 +1,174 @@
|
||||
package dev.castarr.tv.ui
|
||||
|
||||
import android.view.KeyEvent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusable
|
||||
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.fillMaxWidth
|
||||
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.LaunchedEffect
|
||||
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.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.media3.ui.PlayerView
|
||||
import androidx.tv.material3.Text
|
||||
import dev.castarr.tv.AppState
|
||||
|
||||
/**
|
||||
* Fullscreen playback with the minimal auto-hiding overlay. Key handling for
|
||||
* play/pause/zapping lives in MainActivity.onKeyDown; this composable only
|
||||
* grabs focus so list items underneath stop reacting.
|
||||
*/
|
||||
@Composable
|
||||
fun PlayerScreen(state: AppState) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
LaunchedEffect(state.playerVisible, state.playerState) {
|
||||
state.overlayVisible = true
|
||||
if (state.playerState == "playing") {
|
||||
kotlinx.coroutines.delay(4000)
|
||||
state.overlayVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(CastarrColors.bgDeep)
|
||||
.focusRequester(focusRequester)
|
||||
.focusable()
|
||||
.onKeyEvent { false },
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
PlayerView(context).apply {
|
||||
useController = false
|
||||
player = state.player.player
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
|
||||
if (state.overlayVisible) {
|
||||
Overlay(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Overlay(state: AppState) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp)
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
listOf(Color.Transparent, CastarrColors.bgDeep.copy(alpha = 0.92f))
|
||||
)
|
||||
)
|
||||
)
|
||||
state.connectedRemote?.let { name ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(top = 22.dp, end = 30.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(name, color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 12.sp)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Box(
|
||||
Modifier
|
||||
.size(6.dp)
|
||||
.clip(CircleShape)
|
||||
.background(CastarrColors.accent)
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 36.dp)
|
||||
.padding(bottom = 30.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (state.isLive) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(6.dp)
|
||||
.clip(CircleShape)
|
||||
.background(CastarrColors.live)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"LIVE",
|
||||
color = CastarrColors.muted,
|
||||
fontFamily = SpaceGrotesk,
|
||||
fontSize = 11.sp,
|
||||
letterSpacing = 2.sp,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(
|
||||
when (state.playerState) {
|
||||
"paused" -> "Pausiert"
|
||||
"buffering" -> "Lädt…"
|
||||
"error" -> "Wiedergabefehler"
|
||||
else -> "Wird abgespielt"
|
||||
},
|
||||
color = CastarrColors.muted,
|
||||
fontFamily = SpaceGrotesk,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
state.currentChannel?.name.orEmpty(),
|
||||
color = CastarrColors.fg,
|
||||
fontFamily = SpaceGrotesk,
|
||||
fontSize = 30.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
val fraction = if (state.isLive || state.durationMs <= 0) 1f
|
||||
else (state.positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.dp)
|
||||
.clip(RoundedCornerShape(1.dp))
|
||||
.background(CastarrColors.line)
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(fraction)
|
||||
.height(2.dp)
|
||||
.background(CastarrColors.accent)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
362
app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt
Normal file
362
app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt
Normal file
@@ -0,0 +1,362 @@
|
||||
package dev.castarr.tv.ui
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.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.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.draw.clip
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
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.pairing.Pairing
|
||||
import dev.castarr.tv.pairing.Qr
|
||||
|
||||
@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,
|
||||
)
|
||||
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 40.dp, vertical = 16.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,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
"Quelle laden",
|
||||
fontFamily = SpaceGrotesk,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.padding(horizontal = 22.dp, vertical = 11.dp),
|
||||
)
|
||||
}
|
||||
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(28.dp))
|
||||
DispatcharrAccount(state, fieldColors)
|
||||
}
|
||||
|
||||
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))
|
||||
val address = remember { Pairing.lanAddress() }
|
||||
if (address != null) {
|
||||
val qr = remember(address) {
|
||||
Qr.encode(Pairing.pairingUrl(context, address), 400, android.graphics.Color.parseColor("#101216"))
|
||||
}
|
||||
QrCard(qr)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
"${Pairing.remoteUrl(address)} · Code ${Pairing.code(context)}",
|
||||
color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 12.sp,
|
||||
)
|
||||
} else {
|
||||
Text("Keine Netzwerkverbindung", color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModeChip(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = if (selected) CastarrColors.accentDim else CastarrColors.surface,
|
||||
contentColor = if (selected) CastarrColors.accent else CastarrColors.muted,
|
||||
focusedContainerColor = CastarrColors.accent,
|
||||
focusedContentColor = CastarrColors.onAccent,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
fontFamily = SpaceGrotesk, fontSize = 12.sp,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
26
app/src/main/java/dev/castarr/tv/ui/Theme.kt
Normal file
26
app/src/main/java/dev/castarr/tv/ui/Theme.kt
Normal file
@@ -0,0 +1,26 @@
|
||||
package dev.castarr.tv.ui
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import dev.castarr.tv.R
|
||||
|
||||
object CastarrColors {
|
||||
val bg = Color(0xFF0A0B0D)
|
||||
val bgDeep = Color(0xFF05060A)
|
||||
val surface = Color(0xFF121418)
|
||||
val surfaceFocused = Color(0xFF1E2126)
|
||||
val fg = Color(0xFFF2F3F5)
|
||||
val muted = Color(0xFF9AA0A8)
|
||||
val faint = Color(0xFF6B717A)
|
||||
val accent = Color(0xFF5FD4C4)
|
||||
val accentDim = Color(0x125FD4C4)
|
||||
val live = Color(0xFFE5484D)
|
||||
val line = Color(0xFF23262B)
|
||||
val onAccent = Color(0xFF07110F)
|
||||
}
|
||||
|
||||
val SpaceGrotesk = FontFamily(
|
||||
Font(R.font.space_grotesk, FontWeight.Normal),
|
||||
)
|
||||
@@ -1,236 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/bg">
|
||||
|
||||
<!-- Pairing screen -->
|
||||
<FrameLayout
|
||||
android:id="@+id/pairing_screen"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center_horizontal">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_cast" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:text="@string/app_name"
|
||||
android:textAllCaps="true"
|
||||
android:textColor="@color/fg"
|
||||
android:textSize="16sp"
|
||||
android:letterSpacing="0.22" />
|
||||
</LinearLayout>
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="30dp"
|
||||
android:padding="16dp"
|
||||
android:background="@drawable/bg_qr_card">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/qr_image"
|
||||
android:layout_width="170dp"
|
||||
android:layout_height="170dp"
|
||||
android:importantForAccessibility="no" />
|
||||
</FrameLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="30dp"
|
||||
android:text="@string/pair_title"
|
||||
android:textColor="@color/fg"
|
||||
android:textSize="26sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:maxWidth="440dp"
|
||||
android:gravity="center"
|
||||
android:text="@string/pair_subtitle"
|
||||
android:textColor="@color/muted"
|
||||
android:textSize="14sp"
|
||||
android:lineSpacingMultiplier="1.3" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingStart="36dp"
|
||||
android:paddingEnd="36dp"
|
||||
android:paddingBottom="26dp">
|
||||
|
||||
<View
|
||||
android:layout_width="7dp"
|
||||
android:layout_height="7dp"
|
||||
android:background="@drawable/dot_accent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/pairing_status"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="@string/ready_to_pair"
|
||||
android:textColor="@color/faint"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/pairing_url"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="monospace"
|
||||
android:textColor="@color/faint"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<View
|
||||
android:layout_width="1dp"
|
||||
android:layout_height="12dp"
|
||||
android:layout_marginStart="14dp"
|
||||
android:layout_marginEnd="14dp"
|
||||
android:background="@color/line" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/pairing_code"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/muted"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
|
||||
<!-- Player screen -->
|
||||
<FrameLayout
|
||||
android:id="@+id/player_screen"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:visibility="gone"
|
||||
android:background="@color/bg_deep">
|
||||
|
||||
<androidx.media3.ui.PlayerView
|
||||
android:id="@+id/player_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/overlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="200dp"
|
||||
android:layout_gravity="bottom"
|
||||
android:background="@drawable/scrim_bottom" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/device_chip"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top|end"
|
||||
android:layout_marginTop="22dp"
|
||||
android:layout_marginEnd="30dp"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingStart="14dp"
|
||||
android:paddingEnd="14dp"
|
||||
android:paddingTop="7dp"
|
||||
android:paddingBottom="7dp"
|
||||
android:background="@drawable/bg_chip"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/device_name"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/muted"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<View
|
||||
android:layout_width="5dp"
|
||||
android:layout_height="5dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:background="@drawable/dot_accent" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="36dp"
|
||||
android:paddingEnd="36dp"
|
||||
android:paddingBottom="30dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<View
|
||||
android:id="@+id/overlay_live_dot"
|
||||
android:layout_width="6dp"
|
||||
android:layout_height="6dp"
|
||||
android:background="@drawable/dot_live" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/overlay_live_label"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="8dp"
|
||||
android:textColor="@color/muted"
|
||||
android:textSize="11sp"
|
||||
android:letterSpacing="0.18" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/overlay_state"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/muted"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/overlay_channel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textColor="@color/fg"
|
||||
android:textSize="29sp" />
|
||||
|
||||
<View
|
||||
android:id="@+id/overlay_progress"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="2dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:background="@drawable/progress_line" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
</FrameLayout>
|
||||
</FrameLayout>
|
||||
Reference in New Issue
Block a user