diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3d3af51..b7fd05a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("com.android.application") id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") } android { @@ -50,6 +51,10 @@ android { } + buildFeatures { + compose = true + } + lint { // Media3's UI classes are @UnstableApi; the opt-in lint check would // otherwise fail release builds. @@ -59,6 +64,14 @@ android { dependencies { 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-hls:1.4.1") implementation("androidx.media3:media3-ui:1.4.1") diff --git a/app/src/main/assets/remote/index.html b/app/src/main/assets/remote/index.html index 5871815..2cb10e2 100644 --- a/app/src/main/assets/remote/index.html +++ b/app/src/main/assets/remote/index.html @@ -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); }); diff --git a/app/src/main/java/dev/castarr/tv/AppState.kt b/app/src/main/java/dev/castarr/tv/AppState.kt new file mode 100644 index 0000000..b0f9d72 --- /dev/null +++ b/app/src/main/java/dev/castarr/tv/AppState.kt @@ -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(null) + var overlayVisible by mutableStateOf(true) + var connectedRemote by mutableStateOf(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 = 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 + } + } +} diff --git a/app/src/main/java/dev/castarr/tv/MainActivity.kt b/app/src/main/java/dev/castarr/tv/MainActivity.kt index 2d6a623..c2bd08e 100644 --- a/app/src/main/java/dev/castarr/tv/MainActivity.kt +++ b/app/src/main/java/dev/castarr/tv/MainActivity.kt @@ -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(R.id.qr_image) - val urlText = findViewById(R.id.pairing_url) - val codeText = findViewById(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 = playlist.channels + override fun currentChannels(): List = 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 } diff --git a/app/src/main/java/dev/castarr/tv/auth/DeviceAuth.kt b/app/src/main/java/dev/castarr/tv/auth/DeviceAuth.kt new file mode 100644 index 0000000..7a5211c --- /dev/null +++ b/app/src/main/java/dev/castarr/tv/auth/DeviceAuth.kt @@ -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, 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" + } +} diff --git a/app/src/main/java/dev/castarr/tv/data/DispatcharrRepository.kt b/app/src/main/java/dev/castarr/tv/data/DispatcharrRepository.kt new file mode 100644 index 0000000..3702691 --- /dev/null +++ b/app/src/main/java/dev/castarr/tv/data/DispatcharrRepository.kt @@ -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>(emptyList()) + val favorites = MutableStateFlow>(emptySet()) + val profiles = MutableStateFlow>(emptyList()) + val epgUpdatedAt = MutableStateFlow(0L) + val status = MutableStateFlow("") + + private var programmesByTvgId: Map> = emptyMap() + var outputProfile: String = "" + + fun refresh(onDone: (Result) -> 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 { + 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): List { + val list = mutableListOf() + 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>() + 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" + } +} diff --git a/app/src/main/java/dev/castarr/tv/data/SourceRepository.kt b/app/src/main/java/dev/castarr/tv/data/SourceRepository.kt new file mode 100644 index 0000000..220e5bf --- /dev/null +++ b/app/src/main/java/dev/castarr/tv/data/SourceRepository.kt @@ -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>(emptyList()) + val epgUpdatedAt = MutableStateFlow(0L) + val status = MutableStateFlow("") + + private var programmes: Map> = emptyMap() + private var nameToEpgId: Map = 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) -> Unit = {}) { + m3uUrl = m3u + epgUrl = epg + refresh(m3u, epg, onDone) + } + + fun refresh(m3u: String = m3uUrl, epg: String = epgUrl, onDone: (Result) -> 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" + } +} diff --git a/app/src/main/java/dev/castarr/tv/data/Xmltv.kt b/app/src/main/java/dev/castarr/tv/data/Xmltv.kt new file mode 100644 index 0000000..2025f01 --- /dev/null +++ b/app/src/main/java/dev/castarr/tv/data/Xmltv.kt @@ -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>, + val displayNameToId: Map, + ) + + fun parse(input: InputStream): Result { + val programmes = HashMap>() + val displayNames = HashMap() + 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?, 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) + } + } +} diff --git a/app/src/main/java/dev/castarr/tv/playlist/Channel.kt b/app/src/main/java/dev/castarr/tv/playlist/Channel.kt index 37c8b01..3392a23 100644 --- a/app/src/main/java/dev/castarr/tv/playlist/Channel.kt +++ b/app/src/main/java/dev/castarr/tv/playlist/Channel.kt @@ -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): JSONArray { diff --git a/app/src/main/java/dev/castarr/tv/playlist/M3uParser.kt b/app/src/main/java/dev/castarr/tv/playlist/M3uParser.kt index 6b67f67..00296e0 100644 --- a/app/src/main/java/dev/castarr/tv/playlist/M3uParser.kt +++ b/app/src/main/java/dev/castarr/tv/playlist/M3uParser.kt @@ -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 -> { diff --git a/app/src/main/java/dev/castarr/tv/playlist/PlaylistRepository.kt b/app/src/main/java/dev/castarr/tv/playlist/PlaylistRepository.kt deleted file mode 100644 index 987a9cf..0000000 --- a/app/src/main/java/dev/castarr/tv/playlist/PlaylistRepository.kt +++ /dev/null @@ -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 = loadCached() - private set - - val playlistUrl: String - get() = prefs.getString(KEY_URL, "").orEmpty() - - fun refresh(url: String, onResult: (Result>) -> 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 = runCatching { - Channel.listFromJson(JSONArray(prefs.getString(KEY_CACHE, "[]").orEmpty())) - }.getOrDefault(emptyList()) - - private companion object { - const val KEY_URL = "url" - const val KEY_CACHE = "channels" - } -} diff --git a/app/src/main/java/dev/castarr/tv/server/ControlServer.kt b/app/src/main/java/dev/castarr/tv/server/ControlServer.kt index 11177f5..a1a4ea0 100644 --- a/app/src/main/java/dev/castarr/tv/server/ControlServer.kt +++ b/app/src/main/java/dev/castarr/tv/server/ControlServer.kt @@ -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 + 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) diff --git a/app/src/main/java/dev/castarr/tv/ui/CastarrApp.kt b/app/src/main/java/dev/castarr/tv/ui/CastarrApp.kt new file mode 100644 index 0000000..e8caaec --- /dev/null +++ b/app/src/main/java/dev/castarr/tv/ui/CastarrApp.kt @@ -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), + ) + } +} diff --git a/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt b/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt new file mode 100644 index 0000000..453e545 --- /dev/null +++ b/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt @@ -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, + ) + } + } + } +} diff --git a/app/src/main/java/dev/castarr/tv/ui/PlayerScreen.kt b/app/src/main/java/dev/castarr/tv/ui/PlayerScreen.kt new file mode 100644 index 0000000..0ea7518 --- /dev/null +++ b/app/src/main/java/dev/castarr/tv/ui/PlayerScreen.kt @@ -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) + ) + } + } + } +} diff --git a/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt b/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt new file mode 100644 index 0000000..4776f22 --- /dev/null +++ b/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt @@ -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(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), + ) + } +} diff --git a/app/src/main/java/dev/castarr/tv/ui/Theme.kt b/app/src/main/java/dev/castarr/tv/ui/Theme.kt new file mode 100644 index 0000000..79a3348 --- /dev/null +++ b/app/src/main/java/dev/castarr/tv/ui/Theme.kt @@ -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), +) diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index d96f99c..0000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,236 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/build.gradle.kts b/build.gradle.kts index c7ad754..1b4481a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,5 @@ plugins { 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.plugin.compose") version "2.0.21" apply false }