Compose for TV UI, generic + Dispatcharr sources, device-flow login, favorites, stream profiles, richer remote

- Compose for TV scaffold: D-pad app shell (Live/Einstellungen), channel list
  with focus handling, fullscreen player with auto-hiding overlay; zapping via
  channel/D-pad keys (closes #2)
- Generic source end-to-end: M3U + XMLTV configurable on the TV, Now/Next with
  progress in the list, channel cache (closes #3)
- OIDC device-flow login: server URL is the only input, issuer/client id come
  from the fork's status endpoint; QR + user code screen, silent refresh,
  logout (closes #4)
- Dispatcharr source via Bearer API: channels, groups, EPG grid; switchable
  against the generic source (closes #5)
- Per-user favorites: star via long-press, favorites filter, backend-synced
  (closes #6)
- Stream profile selection (Standard/Passthrough/audiofix/720p from the
  backend profile list), applied per stream URL (closes #7)
- Phone remote: Now/Next lines, favorites star + filter over the WebSocket
  protocol (closes #8)

Device verification pending (TV currently in use).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
be-nj
2026-08-25 01:35:48 +02:00
parent 358b4c6a47
commit 0cf22204d3
19 changed files with 1817 additions and 455 deletions

View File

@@ -1,6 +1,7 @@
plugins { plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
} }
android { android {
@@ -50,6 +51,10 @@ android {
} }
buildFeatures {
compose = true
}
lint { lint {
// Media3's UI classes are @UnstableApi; the opt-in lint check would // Media3's UI classes are @UnstableApi; the opt-in lint check would
// otherwise fail release builds. // otherwise fail release builds.
@@ -59,6 +64,14 @@ android {
dependencies { dependencies {
implementation("androidx.core:core-ktx:1.15.0") implementation("androidx.core:core-ktx:1.15.0")
implementation(platform("androidx.compose:compose-bom:2024.10.01"))
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.foundation:foundation")
implementation("androidx.tv:tv-material:1.0.0")
implementation("androidx.compose.material3:material3")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("androidx.media3:media3-exoplayer:1.4.1") implementation("androidx.media3:media3-exoplayer:1.4.1")
implementation("androidx.media3:media3-exoplayer-hls:1.4.1") implementation("androidx.media3:media3-exoplayer-hls:1.4.1")
implementation("androidx.media3:media3-ui:1.4.1") implementation("androidx.media3:media3-ui:1.4.1")

View File

@@ -241,7 +241,7 @@
const state = { const state = {
ws: null, connected: false, authorized: false, ws: null, connected: false, authorized: false,
status: { state: 'idle', channel: '', group: '', live: false, volume: 0.5 }, 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: '', retryDelay: 1000, volumeDragging: false, searchTerm: '',
}; };
@@ -316,6 +316,7 @@
$('tv-name').textContent = msg.device || 'TV'; $('tv-name').textContent = msg.device || 'TV';
if (msg.status) { state.status = msg.status; } if (msg.status) { state.status = msg.status; }
if (msg.channels) { state.channels = msg.channels; } if (msg.channels) { state.channels = msg.channels; }
if (msg.extras) { state.extras = msg.extras; }
state.playlistUrl = msg.playlistUrl || ''; state.playlistUrl = msg.playlistUrl || '';
$('pair-error').textContent = ''; $('pair-error').textContent = '';
showView('view-remote'); showView('view-remote');
@@ -329,6 +330,7 @@
case 'channels': case 'channels':
state.channels = msg.channels || []; state.channels = msg.channels || [];
state.playlistUrl = msg.playlistUrl || ''; state.playlistUrl = msg.playlistUrl || '';
if (msg.extras) { state.extras = msg.extras; }
renderChannels(); renderChannels();
break; break;
case 'toast': case 'toast':
@@ -383,8 +385,24 @@
$('setup').classList.add('open'); $('setup').classList.add('open');
return; 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(); const frag = document.createDocumentFragment();
filtered.slice(0, 500).forEach((c) => { shown.slice(0, 500).forEach((c) => {
const idx = state.channels.indexOf(c); const idx = state.channels.indexOf(c);
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.className = 'chan' + (c.url === state.status.url || c.name === state.status.channel ? ' current' : ''); btn.className = 'chan' + (c.url === state.status.url || c.name === state.status.channel ? ' current' : '');
@@ -403,12 +421,38 @@
const name = document.createElement('span'); const name = document.createElement('span');
name.className = 'name'; name.textContent = c.name; name.className = 'name'; name.textContent = c.name;
meta.appendChild(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'); const grp = document.createElement('span');
grp.className = 'grp'; grp.textContent = c.group; grp.className = 'grp'; grp.textContent = c.group;
meta.appendChild(grp); meta.appendChild(grp);
} }
btn.appendChild(num); btn.appendChild(meta); 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)); btn.addEventListener('click', () => playChannel(c));
frag.appendChild(btn); frag.appendChild(btn);
}); });

View File

@@ -0,0 +1,112 @@
package dev.castarr.tv
import android.content.Context
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import dev.castarr.tv.auth.DeviceAuth
import dev.castarr.tv.data.DispatcharrRepository
import dev.castarr.tv.data.NowNext
import dev.castarr.tv.data.SourceRepository
import dev.castarr.tv.player.PlayerController
import dev.castarr.tv.playlist.Channel
/**
* Single source of truth the Compose UI observes. Mutations happen on the
* main thread (activity callbacks, remote-control listener).
*/
class AppState(
context: Context,
val player: PlayerController,
val source: SourceRepository,
val auth: DeviceAuth,
val dispatcharr: DispatcharrRepository,
) {
private val prefs = context.getSharedPreferences("app", Context.MODE_PRIVATE)
var screen by mutableStateOf(Screen.LIVE)
var playerVisible by mutableStateOf(false)
var playerState by mutableStateOf("idle")
var currentChannel by mutableStateOf<Channel?>(null)
var overlayVisible by mutableStateOf(true)
var connectedRemote by mutableStateOf<String?>(null)
var positionMs by mutableLongStateOf(0L)
var durationMs by mutableLongStateOf(0L)
var isLive by mutableStateOf(false)
var favoritesOnly by mutableStateOf(false)
var sourceMode by mutableStateOf(
if (prefs.getString("source_mode", "generic") == "dispatcharr") SourceMode.DISPATCHARR
else SourceMode.GENERIC
)
private set
enum class Screen { LIVE, SETTINGS }
enum class SourceMode { GENERIC, DISPATCHARR }
init {
dispatcharr.outputProfile = prefs.getString("output_profile", "").orEmpty()
if (sourceMode == SourceMode.DISPATCHARR && auth.isLoggedIn) {
dispatcharr.refresh()
}
}
fun setMode(mode: SourceMode) {
sourceMode = mode
prefs.edit().putString(
"source_mode",
if (mode == SourceMode.DISPATCHARR) "dispatcharr" else "generic",
).apply()
if (mode == SourceMode.DISPATCHARR && auth.isLoggedIn) dispatcharr.refresh()
}
var outputProfile: String
get() = dispatcharr.outputProfile
set(value) {
dispatcharr.outputProfile = value
prefs.edit().putString("output_profile", value).apply()
dispatcharr.rebuildStreamUrls()
}
fun activeChannels(): List<Channel> = when (sourceMode) {
SourceMode.GENERIC -> source.channels.value
SourceMode.DISPATCHARR -> dispatcharr.channels.value
}
fun nowNext(channel: Channel): NowNext = when (sourceMode) {
SourceMode.GENERIC -> source.nowNext(channel)
SourceMode.DISPATCHARR -> dispatcharr.nowNext(channel)
}
fun play(channel: Channel) {
currentChannel = channel
playerVisible = true
player.play(channel.url, channel.name, channel.group)
}
fun stopPlayback() {
player.stop()
playerVisible = false
currentChannel = null
}
fun zap(direction: Int) {
val list = activeChannels()
if (list.isEmpty()) return
val current = list.indexOfFirst { it.url == currentChannel?.url }
val next = if (current < 0) 0 else (current + direction + list.size) % list.size
play(list[next])
}
fun syncFromPlayer() {
playerState = player.state
isLive = player.player.isCurrentMediaItemLive
positionMs = player.player.currentPosition.coerceAtLeast(0)
durationMs = player.player.duration.coerceAtLeast(0)
if (!player.hasMedia && playerVisible) {
playerVisible = false
currentChannel = null
}
}
}

View File

@@ -1,167 +1,78 @@
package dev.castarr.tv package dev.castarr.tv
import android.app.Activity
import android.graphics.Color
import android.media.AudioManager import android.media.AudioManager
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.view.KeyEvent import android.view.KeyEvent
import android.view.View import androidx.activity.ComponentActivity
import android.widget.ImageView import androidx.activity.compose.setContent
import android.widget.TextView import dev.castarr.tv.auth.DeviceAuth
import androidx.media3.ui.PlayerView import dev.castarr.tv.data.DispatcharrRepository
import dev.castarr.tv.pairing.Pairing import dev.castarr.tv.data.SourceRepository
import dev.castarr.tv.pairing.Qr
import dev.castarr.tv.player.PlayerController import dev.castarr.tv.player.PlayerController
import dev.castarr.tv.playlist.Channel import dev.castarr.tv.playlist.Channel
import dev.castarr.tv.playlist.PlaylistRepository
import dev.castarr.tv.server.ControlServer import dev.castarr.tv.server.ControlServer
import dev.castarr.tv.ui.CastarrApp
import org.json.JSONObject 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 server: ControlServer
private lateinit var playlist: PlaylistRepository
private lateinit var audioManager: AudioManager 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 mainHandler = Handler(Looper.getMainLooper())
private val hideOverlay = Runnable { overlay.animate().alpha(0f).setDuration(400).start() }
private val ticker = object : Runnable { private val ticker = object : Runnable {
override fun run() { override fun run() {
updateProgress() state.syncFromPlayer()
server.broadcastStatus() server.broadcastStatus()
mainHandler.postDelayed(this, TICK_INTERVAL_MS) mainHandler.postDelayed(this, TICK_INTERVAL_MS)
} }
} }
private var connectedName: String? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
audioManager = getSystemService(AUDIO_SERVICE) as AudioManager audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
playlist = PlaylistRepository(this) val auth = DeviceAuth(this)
playerController = PlayerController(this) { onPlaybackChanged() } state = AppState(
context = this,
bindViews() player = PlayerController(this) { onPlaybackChanged() },
setupPairingScreen() source = SourceRepository(this),
auth = auth,
dispatcharr = DispatcharrRepository(auth),
)
server = ControlServer(this, this) server = ControlServer(this, this)
server.startServer() 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() { private fun onPlaybackChanged() {
val showPlayer = playerController.hasMedia state.syncFromPlayer()
pairingScreen.visibility = if (showPlayer) View.GONE else View.VISIBLE val active = state.playerState == "playing" || state.playerState == "buffering"
playerScreen.visibility = if (showPlayer) View.VISIBLE else View.GONE window.decorView.keepScreenOn = active
val active = playerController.state == "playing" || playerController.state == "buffering"
playerScreen.keepScreenOn = active
mainHandler.removeCallbacks(ticker) 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) if (active) mainHandler.postDelayed(ticker, TICK_INTERVAL_MS)
}
onClientsChanged(-1, null)
server.broadcastStatus() 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) --- // --- ControlServer.Listener (called on main thread) ---
override fun onPlay(url: String, name: String, group: String) { 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) { override fun onSeek(deltaSeconds: Long) {
playerController.seekBy(deltaSeconds) state.player.seekBy(deltaSeconds)
showOverlay(autoHide = true) state.overlayVisible = true
} }
override fun onVolume(value: Float) { override fun onVolume(value: Float) {
@@ -171,7 +82,7 @@ class MainActivity : Activity(), ControlServer.Listener {
} }
override fun onSetPlaylist(url: String) { override fun onSetPlaylist(url: String) {
playlist.refresh(url) { result -> state.source.configure(url, state.source.epgUrl) { result ->
mainHandler.post { mainHandler.post {
result result
.onSuccess { server.broadcastChannels() } .onSuccess { server.broadcastChannels() }
@@ -181,60 +92,88 @@ class MainActivity : Activity(), ControlServer.Listener {
} }
override fun onClientsChanged(count: Int, newestName: String?) { override fun onClientsChanged(count: Int, newestName: String?) {
if (newestName != null) connectedName = newestName if (newestName != null) state.connectedRemote = newestName
if (count >= 0) { if (count == 0) state.connectedRemote = null
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()
} }
override fun currentStatus(): JSONObject { override fun currentStatus(): JSONObject {
val max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) val max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
val vol = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) val vol = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
val player = playerController.player val player = state.player
return JSONObject() return JSONObject()
.put("state", playerController.state) .put("state", player.state)
.put("channel", playerController.channelName) .put("channel", player.channelName)
.put("group", playerController.channelGroup) .put("group", player.channelGroup)
.put("live", player.isCurrentMediaItemLive) .put("live", player.player.isCurrentMediaItemLive)
.put("seekable", player.isCurrentMediaItemSeekable) .put("seekable", player.player.isCurrentMediaItemSeekable)
.put("position", player.currentPosition.coerceAtLeast(0)) .put("position", player.player.currentPosition.coerceAtLeast(0))
.put("duration", player.duration.coerceAtLeast(0)) .put("duration", player.player.duration.coerceAtLeast(0))
.put("volume", if (max > 0) vol.toDouble() / max else 0.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 { override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (playerController.hasMedia) { if (state.playerVisible) {
when (keyCode) { when (keyCode) {
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_DPAD_CENTER,
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_ENTER,
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
playerController.toggle() state.player.toggle()
return true return true
} }
KeyEvent.KEYCODE_MEDIA_PLAY -> { KeyEvent.KEYCODE_MEDIA_PLAY -> {
playerController.resume() state.player.resume()
return true return true
} }
KeyEvent.KEYCODE_MEDIA_PAUSE -> { KeyEvent.KEYCODE_MEDIA_PAUSE -> {
playerController.pause() state.player.pause()
return true return true
} }
KeyEvent.KEYCODE_MEDIA_STOP -> { 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 return true
} }
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_LEFT,
@@ -248,7 +187,7 @@ class MainActivity : Activity(), ControlServer.Listener {
return true return true
} }
KeyEvent.KEYCODE_BACK -> { KeyEvent.KEYCODE_BACK -> {
playerController.stop() state.stopPlayback()
return true return true
} }
else -> Unit else -> Unit
@@ -262,19 +201,17 @@ class MainActivity : Activity(), ControlServer.Listener {
override fun onStop() { override fun onStop() {
super.onStop() super.onStop()
// TV-NP: video must not keep playing when the user leaves the app. // TV-NP: video must not keep playing when the user leaves the app.
playerController.pause() state.player.pause()
} }
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
mainHandler.removeCallbacksAndMessages(null) mainHandler.removeCallbacksAndMessages(null)
server.stopServer() server.stopServer()
playerController.release() state.player.release()
} }
private companion object { 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 TICK_INTERVAL_MS = 2_000L
const val SEEK_STEP_SECONDS = 10L const val SEEK_STEP_SECONDS = 10L
} }

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

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

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

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

View File

@@ -8,12 +8,16 @@ data class Channel(
val url: String, val url: String,
val group: String, val group: String,
val logo: String, val logo: String,
val tvgId: String = "",
val backendId: Int = 0,
) { ) {
fun toJson(): JSONObject = JSONObject() fun toJson(): JSONObject = JSONObject()
.put("name", name) .put("name", name)
.put("url", url) .put("url", url)
.put("group", group) .put("group", group)
.put("logo", logo) .put("logo", logo)
.put("tvgId", tvgId)
.put("backendId", backendId)
companion object { companion object {
fun fromJson(obj: JSONObject): Channel = Channel( fun fromJson(obj: JSONObject): Channel = Channel(
@@ -21,6 +25,8 @@ data class Channel(
url = obj.optString("url"), url = obj.optString("url"),
group = obj.optString("group"), group = obj.optString("group"),
logo = obj.optString("logo"), logo = obj.optString("logo"),
tvgId = obj.optString("tvgId"),
backendId = obj.optInt("backendId"),
) )
fun listToJson(channels: List<Channel>): JSONArray { fun listToJson(channels: List<Channel>): JSONArray {

View File

@@ -11,6 +11,7 @@ object M3uParser {
var name = "" var name = ""
var group = "" var group = ""
var logo = "" var logo = ""
var tvgId = ""
var pendingInfo = false var pendingInfo = false
for (rawLine in content.lineSequence()) { 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] } val attrs = attrRegex.findAll(line).associate { it.groupValues[1].lowercase() to it.groupValues[2] }
group = attrs["group-title"].orEmpty() group = attrs["group-title"].orEmpty()
logo = attrs["tvg-logo"].orEmpty() logo = attrs["tvg-logo"].orEmpty()
tvgId = attrs["tvg-id"].orEmpty()
name = line.substringAfterLast(',', "").trim() name = line.substringAfterLast(',', "").trim()
if (name.isEmpty()) name = attrs["tvg-name"].orEmpty() if (name.isEmpty()) name = attrs["tvg-name"].orEmpty()
pendingInfo = true pendingInfo = true
@@ -29,11 +31,12 @@ object M3uParser {
} }
line.isEmpty() || line.startsWith("#") -> Unit line.isEmpty() || line.startsWith("#") -> Unit
pendingInfo -> { 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 if (channels.size >= MAX_CHANNELS) return channels
name = "" name = ""
group = "" group = ""
logo = "" logo = ""
tvgId = ""
pendingInfo = false pendingInfo = false
} }
else -> { else -> {

View File

@@ -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"
}
}

View File

@@ -33,9 +33,11 @@ class ControlServer(
fun onSeek(deltaSeconds: Long) fun onSeek(deltaSeconds: Long)
fun onVolume(value: Float) fun onVolume(value: Float)
fun onSetPlaylist(url: String) fun onSetPlaylist(url: String)
fun onToggleFavorite(channelId: Int)
fun onClientsChanged(count: Int, newestName: String?) fun onClientsChanged(count: Int, newestName: String?)
fun currentStatus(): JSONObject fun currentStatus(): JSONObject
fun currentChannels(): List<Channel> fun currentChannels(): List<Channel>
fun channelsExtras(): JSONObject
fun currentPlaylistUrl(): String fun currentPlaylistUrl(): String
} }
@@ -116,6 +118,7 @@ class ControlServer(
.put("type", "channels") .put("type", "channels")
.put("playlistUrl", listener.currentPlaylistUrl()) .put("playlistUrl", listener.currentPlaylistUrl())
.put("channels", Channel.listToJson(listener.currentChannels())) .put("channels", Channel.listToJson(listener.currentChannels()))
.put("extras", listener.channelsExtras())
private fun pingClients() { private fun pingClients() {
clients.forEach { client -> clients.forEach { client ->
@@ -182,6 +185,7 @@ class ControlServer(
"seek" -> post { listener.onSeek(msg.optLong("delta")) } "seek" -> post { listener.onSeek(msg.optLong("delta")) }
"volume" -> post { listener.onVolume(msg.optDouble("value", 1.0).toFloat().coerceIn(0f, 1f)) } "volume" -> post { listener.onVolume(msg.optDouble("value", 1.0).toFloat().coerceIn(0f, 1f)) }
"set_playlist" -> post { listener.onSetPlaylist(msg.optString("url")) } "set_playlist" -> post { listener.onSetPlaylist(msg.optString("url")) }
"set_favorite" -> post { listener.onToggleFavorite(msg.optInt("id")) }
"get_state" -> { "get_state" -> {
trySend(listener.currentStatus().put("type", "status").toString()) trySend(listener.currentStatus().put("type", "status").toString())
trySend(channelsMessage().toString()) trySend(channelsMessage().toString())
@@ -224,6 +228,7 @@ class ControlServer(
.put("status", listener.currentStatus()) .put("status", listener.currentStatus())
.put("playlistUrl", listener.currentPlaylistUrl()) .put("playlistUrl", listener.currentPlaylistUrl())
.put("channels", Channel.listToJson(listener.currentChannels())) .put("channels", Channel.listToJson(listener.currentChannels()))
.put("extras", listener.channelsExtras())
.toString() .toString()
) )
notifyClientsChanged(deviceName) notifyClientsChanged(deviceName)

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

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

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

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

View 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),
)

View File

@@ -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>

View File

@@ -1,4 +1,5 @@
plugins { plugins {
id("com.android.application") version "8.7.3" apply false id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false id("org.jetbrains.kotlin.android") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
} }