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:
@@ -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")
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
112
app/src/main/java/dev/castarr/tv/AppState.kt
Normal file
112
app/src/main/java/dev/castarr/tv/AppState.kt
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package dev.castarr.tv
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import dev.castarr.tv.auth.DeviceAuth
|
||||||
|
import dev.castarr.tv.data.DispatcharrRepository
|
||||||
|
import dev.castarr.tv.data.NowNext
|
||||||
|
import dev.castarr.tv.data.SourceRepository
|
||||||
|
import dev.castarr.tv.player.PlayerController
|
||||||
|
import dev.castarr.tv.playlist.Channel
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth the Compose UI observes. Mutations happen on the
|
||||||
|
* main thread (activity callbacks, remote-control listener).
|
||||||
|
*/
|
||||||
|
class AppState(
|
||||||
|
context: Context,
|
||||||
|
val player: PlayerController,
|
||||||
|
val source: SourceRepository,
|
||||||
|
val auth: DeviceAuth,
|
||||||
|
val dispatcharr: DispatcharrRepository,
|
||||||
|
) {
|
||||||
|
private val prefs = context.getSharedPreferences("app", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
var screen by mutableStateOf(Screen.LIVE)
|
||||||
|
var playerVisible by mutableStateOf(false)
|
||||||
|
var playerState by mutableStateOf("idle")
|
||||||
|
var currentChannel by mutableStateOf<Channel?>(null)
|
||||||
|
var overlayVisible by mutableStateOf(true)
|
||||||
|
var connectedRemote by mutableStateOf<String?>(null)
|
||||||
|
var positionMs by mutableLongStateOf(0L)
|
||||||
|
var durationMs by mutableLongStateOf(0L)
|
||||||
|
var isLive by mutableStateOf(false)
|
||||||
|
var favoritesOnly by mutableStateOf(false)
|
||||||
|
|
||||||
|
var sourceMode by mutableStateOf(
|
||||||
|
if (prefs.getString("source_mode", "generic") == "dispatcharr") SourceMode.DISPATCHARR
|
||||||
|
else SourceMode.GENERIC
|
||||||
|
)
|
||||||
|
private set
|
||||||
|
|
||||||
|
enum class Screen { LIVE, SETTINGS }
|
||||||
|
enum class SourceMode { GENERIC, DISPATCHARR }
|
||||||
|
|
||||||
|
init {
|
||||||
|
dispatcharr.outputProfile = prefs.getString("output_profile", "").orEmpty()
|
||||||
|
if (sourceMode == SourceMode.DISPATCHARR && auth.isLoggedIn) {
|
||||||
|
dispatcharr.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setMode(mode: SourceMode) {
|
||||||
|
sourceMode = mode
|
||||||
|
prefs.edit().putString(
|
||||||
|
"source_mode",
|
||||||
|
if (mode == SourceMode.DISPATCHARR) "dispatcharr" else "generic",
|
||||||
|
).apply()
|
||||||
|
if (mode == SourceMode.DISPATCHARR && auth.isLoggedIn) dispatcharr.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
var outputProfile: String
|
||||||
|
get() = dispatcharr.outputProfile
|
||||||
|
set(value) {
|
||||||
|
dispatcharr.outputProfile = value
|
||||||
|
prefs.edit().putString("output_profile", value).apply()
|
||||||
|
dispatcharr.rebuildStreamUrls()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun activeChannels(): List<Channel> = when (sourceMode) {
|
||||||
|
SourceMode.GENERIC -> source.channels.value
|
||||||
|
SourceMode.DISPATCHARR -> dispatcharr.channels.value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun nowNext(channel: Channel): NowNext = when (sourceMode) {
|
||||||
|
SourceMode.GENERIC -> source.nowNext(channel)
|
||||||
|
SourceMode.DISPATCHARR -> dispatcharr.nowNext(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun play(channel: Channel) {
|
||||||
|
currentChannel = channel
|
||||||
|
playerVisible = true
|
||||||
|
player.play(channel.url, channel.name, channel.group)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopPlayback() {
|
||||||
|
player.stop()
|
||||||
|
playerVisible = false
|
||||||
|
currentChannel = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun zap(direction: Int) {
|
||||||
|
val list = activeChannels()
|
||||||
|
if (list.isEmpty()) return
|
||||||
|
val current = list.indexOfFirst { it.url == currentChannel?.url }
|
||||||
|
val next = if (current < 0) 0 else (current + direction + list.size) % list.size
|
||||||
|
play(list[next])
|
||||||
|
}
|
||||||
|
|
||||||
|
fun syncFromPlayer() {
|
||||||
|
playerState = player.state
|
||||||
|
isLive = player.player.isCurrentMediaItemLive
|
||||||
|
positionMs = player.player.currentPosition.coerceAtLeast(0)
|
||||||
|
durationMs = player.player.duration.coerceAtLeast(0)
|
||||||
|
if (!player.hasMedia && playerVisible) {
|
||||||
|
playerVisible = false
|
||||||
|
currentChannel = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,167 +1,78 @@
|
|||||||
package dev.castarr.tv
|
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) {
|
if (active) mainHandler.postDelayed(ticker, TICK_INTERVAL_MS)
|
||||||
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)
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
208
app/src/main/java/dev/castarr/tv/auth/DeviceAuth.kt
Normal file
208
app/src/main/java/dev/castarr/tv/auth/DeviceAuth.kt
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
package dev.castarr.tv.auth
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.net.URLEncoder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OIDC device-authorization-grant client. The only configuration a user
|
||||||
|
* enters is the Dispatcharr URL — issuer and public client id come from its
|
||||||
|
* /api/accounts/oidc/status/ endpoint (fork feature).
|
||||||
|
*/
|
||||||
|
class DeviceAuth(context: Context) {
|
||||||
|
|
||||||
|
private val prefs = context.getSharedPreferences("auth", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
data class ServerConfig(val issuer: String, val clientId: String, val label: String)
|
||||||
|
data class DeviceSession(
|
||||||
|
val deviceCode: String,
|
||||||
|
val userCode: String,
|
||||||
|
val verificationUri: String,
|
||||||
|
val verificationUriComplete: String,
|
||||||
|
val intervalSeconds: Int,
|
||||||
|
val expiresInSeconds: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed class PollResult {
|
||||||
|
data object Pending : PollResult()
|
||||||
|
data object Denied : PollResult()
|
||||||
|
data class Success(val username: String) : PollResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
var serverUrl: String
|
||||||
|
get() = prefs.getString("server_url", "").orEmpty().trimEnd('/')
|
||||||
|
set(value) { prefs.edit().putString("server_url", value.trimEnd('/')).apply() }
|
||||||
|
|
||||||
|
val username: String get() = prefs.getString("username", "").orEmpty()
|
||||||
|
val isLoggedIn: Boolean get() = prefs.getString("refresh_token", null) != null
|
||||||
|
|
||||||
|
private var issuer: String
|
||||||
|
get() = prefs.getString("issuer", "").orEmpty()
|
||||||
|
set(value) { prefs.edit().putString("issuer", value).apply() }
|
||||||
|
private var clientId: String
|
||||||
|
get() = prefs.getString("client_id", "").orEmpty()
|
||||||
|
set(value) { prefs.edit().putString("client_id", value).apply() }
|
||||||
|
|
||||||
|
suspend fun fetchServerConfig(url: String): ServerConfig = withContext(Dispatchers.IO) {
|
||||||
|
val status = JSONObject(get("${url.trimEnd('/')}/api/accounts/oidc/status/"))
|
||||||
|
val config = ServerConfig(
|
||||||
|
issuer = status.optString("issuer"),
|
||||||
|
clientId = status.optString("device_client_id"),
|
||||||
|
label = status.optString("label"),
|
||||||
|
)
|
||||||
|
require(status.optBoolean("enabled") && config.issuer.isNotEmpty() && config.clientId.isNotEmpty()) {
|
||||||
|
"Server has no device-flow SSO configured"
|
||||||
|
}
|
||||||
|
serverUrl = url
|
||||||
|
issuer = config.issuer
|
||||||
|
clientId = config.clientId
|
||||||
|
config
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun discovery(): JSONObject = withContext(Dispatchers.IO) {
|
||||||
|
JSONObject(get(issuer.trimEnd('/') + "/.well-known/openid-configuration"))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun startDeviceFlow(): DeviceSession = withContext(Dispatchers.IO) {
|
||||||
|
val doc = discovery()
|
||||||
|
val response = JSONObject(
|
||||||
|
post(
|
||||||
|
doc.getString("device_authorization_endpoint"),
|
||||||
|
mapOf("client_id" to clientId, "scope" to "openid profile email offline_access"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
DeviceSession(
|
||||||
|
deviceCode = response.getString("device_code"),
|
||||||
|
userCode = response.getString("user_code"),
|
||||||
|
verificationUri = response.optString("verification_uri"),
|
||||||
|
verificationUriComplete = response.optString(
|
||||||
|
"verification_uri_complete",
|
||||||
|
response.optString("verification_uri"),
|
||||||
|
),
|
||||||
|
intervalSeconds = response.optInt("interval", 5).coerceAtLeast(2),
|
||||||
|
expiresInSeconds = response.optInt("expires_in", 600),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun poll(session: DeviceSession): PollResult = withContext(Dispatchers.IO) {
|
||||||
|
val doc = discovery()
|
||||||
|
val body = post(
|
||||||
|
doc.getString("token_endpoint"),
|
||||||
|
mapOf(
|
||||||
|
"grant_type" to "urn:ietf:params:oauth:grant-type:device_code",
|
||||||
|
"device_code" to session.deviceCode,
|
||||||
|
"client_id" to clientId,
|
||||||
|
),
|
||||||
|
allowError = true,
|
||||||
|
)
|
||||||
|
val json = JSONObject(body)
|
||||||
|
when {
|
||||||
|
json.has("access_token") -> {
|
||||||
|
storeTokens(json)
|
||||||
|
PollResult.Success(username)
|
||||||
|
}
|
||||||
|
json.optString("error") in listOf("authorization_pending", "slow_down") -> PollResult.Pending
|
||||||
|
else -> {
|
||||||
|
Log.w(TAG, "device flow error: ${json.optString("error")}")
|
||||||
|
PollResult.Denied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Valid access token, refreshing when necessary; null when logged out. */
|
||||||
|
suspend fun accessToken(): String? = withContext(Dispatchers.IO) {
|
||||||
|
val token = prefs.getString("access_token", null)
|
||||||
|
val expiry = prefs.getLong("expires_at", 0)
|
||||||
|
if (token != null && System.currentTimeMillis() < expiry - 30_000) return@withContext token
|
||||||
|
val refresh = prefs.getString("refresh_token", null) ?: return@withContext null
|
||||||
|
runCatching {
|
||||||
|
val doc = discovery()
|
||||||
|
val json = JSONObject(
|
||||||
|
post(
|
||||||
|
doc.getString("token_endpoint"),
|
||||||
|
mapOf(
|
||||||
|
"grant_type" to "refresh_token",
|
||||||
|
"refresh_token" to refresh,
|
||||||
|
"client_id" to clientId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
storeTokens(json)
|
||||||
|
json.getString("access_token")
|
||||||
|
}.onFailure { Log.w(TAG, "token refresh failed", it) }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout() {
|
||||||
|
prefs.edit()
|
||||||
|
.remove("access_token").remove("refresh_token")
|
||||||
|
.remove("expires_at").remove("username")
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun storeTokens(json: JSONObject) {
|
||||||
|
val editor = prefs.edit()
|
||||||
|
.putString("access_token", json.getString("access_token"))
|
||||||
|
.putLong(
|
||||||
|
"expires_at",
|
||||||
|
System.currentTimeMillis() + json.optLong("expires_in", 3600) * 1000,
|
||||||
|
)
|
||||||
|
if (json.has("refresh_token")) editor.putString("refresh_token", json.getString("refresh_token"))
|
||||||
|
// Username from the id_token payload (unverified decode is fine here —
|
||||||
|
// it arrived over TLS directly from the token endpoint).
|
||||||
|
json.optString("id_token").split(".").getOrNull(1)?.let { payload ->
|
||||||
|
runCatching {
|
||||||
|
val decoded = String(android.util.Base64.decode(payload, android.util.Base64.URL_SAFE))
|
||||||
|
val claims = JSONObject(decoded)
|
||||||
|
editor.putString(
|
||||||
|
"username",
|
||||||
|
claims.optString("preferred_username", claims.optString("sub")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
editor.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun get(url: String): String {
|
||||||
|
val connection = URL(url).openConnection() as HttpURLConnection
|
||||||
|
return try {
|
||||||
|
connection.connectTimeout = 10_000
|
||||||
|
connection.readTimeout = 15_000
|
||||||
|
connection.inputStream.bufferedReader().use { it.readText() }
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun post(url: String, form: Map<String, String>, allowError: Boolean = false): String {
|
||||||
|
val connection = URL(url).openConnection() as HttpURLConnection
|
||||||
|
return try {
|
||||||
|
connection.requestMethod = "POST"
|
||||||
|
connection.doOutput = true
|
||||||
|
connection.connectTimeout = 10_000
|
||||||
|
connection.readTimeout = 15_000
|
||||||
|
connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
val body = form.entries.joinToString("&") {
|
||||||
|
"${it.key}=${URLEncoder.encode(it.value, "UTF-8")}"
|
||||||
|
}
|
||||||
|
connection.outputStream.use { it.write(body.toByteArray()) }
|
||||||
|
val stream = if (connection.responseCode >= 400) {
|
||||||
|
if (!allowError) error("HTTP ${connection.responseCode} from $url")
|
||||||
|
connection.errorStream
|
||||||
|
} else {
|
||||||
|
connection.inputStream
|
||||||
|
}
|
||||||
|
stream.bufferedReader().use { it.readText() }
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "DeviceAuth"
|
||||||
|
}
|
||||||
|
}
|
||||||
193
app/src/main/java/dev/castarr/tv/data/DispatcharrRepository.kt
Normal file
193
app/src/main/java/dev/castarr/tv/data/DispatcharrRepository.kt
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
package dev.castarr.tv.data
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import dev.castarr.tv.auth.DeviceAuth
|
||||||
|
import dev.castarr.tv.playlist.Channel
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel source backed by the Dispatcharr fork's Bearer API: channels,
|
||||||
|
* groups, EPG grid and per-user favorites. Stream URLs go through the
|
||||||
|
* Dispatcharr proxy, optionally with a selected output profile.
|
||||||
|
*/
|
||||||
|
class DispatcharrRepository(private val auth: DeviceAuth) {
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
val channels = MutableStateFlow<List<Channel>>(emptyList())
|
||||||
|
val favorites = MutableStateFlow<Set<Int>>(emptySet())
|
||||||
|
val profiles = MutableStateFlow<List<String>>(emptyList())
|
||||||
|
val epgUpdatedAt = MutableStateFlow(0L)
|
||||||
|
val status = MutableStateFlow("")
|
||||||
|
|
||||||
|
private var programmesByTvgId: Map<String, List<Programme>> = emptyMap()
|
||||||
|
var outputProfile: String = ""
|
||||||
|
|
||||||
|
fun refresh(onDone: (Result<Int>) -> Unit = {}) {
|
||||||
|
scope.launch {
|
||||||
|
val result = runCatching {
|
||||||
|
status.value = "loading_channels"
|
||||||
|
val token = auth.accessToken() ?: error("not logged in")
|
||||||
|
val groups = fetchGroups(token)
|
||||||
|
val list = fetchChannels(token, groups)
|
||||||
|
channels.value = list
|
||||||
|
launch { runCatching { refreshFavorites(token) } }
|
||||||
|
launch { runCatching { refreshProfiles(token) } }
|
||||||
|
launch {
|
||||||
|
runCatching { refreshEpg(token) }
|
||||||
|
.onFailure { Log.w(TAG, "epg failed", it) }
|
||||||
|
}
|
||||||
|
list.size
|
||||||
|
}
|
||||||
|
result.onFailure { Log.w(TAG, "refresh failed", it) }
|
||||||
|
status.value = if (result.isSuccess) "" else "channels_error"
|
||||||
|
onDone(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun nowNext(channel: Channel): NowNext =
|
||||||
|
XmltvParser.nowNext(programmesByTvgId[channel.tvgId])
|
||||||
|
|
||||||
|
fun toggleFavorite(channel: Channel) {
|
||||||
|
if (channel.backendId == 0) return
|
||||||
|
scope.launch {
|
||||||
|
runCatching {
|
||||||
|
val token = auth.accessToken() ?: return@launch
|
||||||
|
val isFavorite = channel.backendId in favorites.value
|
||||||
|
request(
|
||||||
|
"${auth.serverUrl}/api/channels/favorites/${channel.backendId}/",
|
||||||
|
if (isFavorite) "DELETE" else "POST",
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
favorites.value =
|
||||||
|
if (isFavorite) favorites.value - channel.backendId
|
||||||
|
else favorites.value + channel.backendId
|
||||||
|
}.onFailure { Log.w(TAG, "favorite toggle failed", it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun streamUrl(id: Int): String {
|
||||||
|
val base = "${auth.serverUrl}/proxy/ts/stream/$id"
|
||||||
|
return if (outputProfile.isNotEmpty()) "$base?output_profile=$outputProfile" else base
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-derive stream URLs (e.g. after the output profile changed). */
|
||||||
|
fun rebuildStreamUrls() {
|
||||||
|
channels.value = channels.value.map { it.copy(url = streamUrl(it.backendId)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchGroups(token: String): Map<Int, String> {
|
||||||
|
val body = request("${auth.serverUrl}/api/channels/groups/?page_size=1000", "GET", token)
|
||||||
|
val results = paginatedResults(body)
|
||||||
|
return (0 until results.length()).associate {
|
||||||
|
val obj = results.getJSONObject(it)
|
||||||
|
obj.getInt("id") to obj.optString("name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchChannels(token: String, groups: Map<Int, String>): List<Channel> {
|
||||||
|
val list = mutableListOf<Channel>()
|
||||||
|
var url: String? = "${auth.serverUrl}/api/channels/channels/?page_size=500"
|
||||||
|
while (url != null && list.size < 10_000) {
|
||||||
|
val body = request(url, "GET", token)
|
||||||
|
val json = runCatching { JSONObject(body) }.getOrNull()
|
||||||
|
val results = json?.optJSONArray("results") ?: JSONArray(body)
|
||||||
|
for (i in 0 until results.length()) {
|
||||||
|
val obj = results.getJSONObject(i)
|
||||||
|
val id = obj.getInt("id")
|
||||||
|
list.add(
|
||||||
|
Channel(
|
||||||
|
name = obj.optString("name"),
|
||||||
|
url = streamUrl(id),
|
||||||
|
group = groups[obj.optInt("channel_group_id")].orEmpty(),
|
||||||
|
logo = "",
|
||||||
|
tvgId = obj.optString("tvg_id"),
|
||||||
|
backendId = id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
url = json?.optString("next")?.takeIf { it.isNotEmpty() && it != "null" }
|
||||||
|
}
|
||||||
|
list.sortBy { it.name.lowercase() }
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun refreshFavorites(token: String) {
|
||||||
|
val body = request("${auth.serverUrl}/api/channels/favorites/", "GET", token)
|
||||||
|
val ids = JSONObject(body).optJSONArray("channels") ?: JSONArray()
|
||||||
|
favorites.value = (0 until ids.length()).map { ids.getInt(it) }.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun refreshProfiles(token: String) {
|
||||||
|
val body = request("${auth.serverUrl}/api/core/outputprofiles/", "GET", token)
|
||||||
|
val results = paginatedResults(body)
|
||||||
|
profiles.value = (0 until results.length()).mapNotNull {
|
||||||
|
val obj = results.getJSONObject(it)
|
||||||
|
if (obj.optBoolean("is_active", true)) obj.optString("name") else null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun refreshEpg(token: String) {
|
||||||
|
status.value = "loading_epg"
|
||||||
|
val body = request("${auth.serverUrl}/api/epg/grid/", "GET", token)
|
||||||
|
val results = runCatching { JSONObject(body).optJSONArray("results") }.getOrNull()
|
||||||
|
?: JSONArray(body)
|
||||||
|
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).apply {
|
||||||
|
timeZone = TimeZone.getTimeZone("UTC")
|
||||||
|
}
|
||||||
|
val map = HashMap<String, MutableList<Programme>>()
|
||||||
|
for (i in 0 until results.length()) {
|
||||||
|
val obj = results.getJSONObject(i)
|
||||||
|
val tvgId = obj.optString("tvg_id")
|
||||||
|
if (tvgId.isEmpty()) continue
|
||||||
|
val start = parseIso(obj.optString("start_time"), format)
|
||||||
|
val stop = parseIso(obj.optString("end_time"), format)
|
||||||
|
if (start == 0L || stop <= start) continue
|
||||||
|
map.getOrPut(tvgId) { mutableListOf() }
|
||||||
|
.add(Programme(start, stop, obj.optString("title")))
|
||||||
|
}
|
||||||
|
map.values.forEach { it.sortBy(Programme::start) }
|
||||||
|
programmesByTvgId = map
|
||||||
|
epgUpdatedAt.value = System.currentTimeMillis()
|
||||||
|
status.value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseIso(raw: String, format: SimpleDateFormat): Long = runCatching {
|
||||||
|
format.parse(raw.substringBefore(".").substringBefore("+").removeSuffix("Z"))?.time ?: 0L
|
||||||
|
}.getOrDefault(0L)
|
||||||
|
|
||||||
|
private fun paginatedResults(body: String): JSONArray =
|
||||||
|
runCatching { JSONObject(body).optJSONArray("results") }.getOrNull() ?: JSONArray(body)
|
||||||
|
|
||||||
|
private suspend fun request(url: String, method: String, token: String): String =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val connection = URL(url).openConnection() as HttpURLConnection
|
||||||
|
try {
|
||||||
|
connection.requestMethod = method
|
||||||
|
connection.connectTimeout = 10_000
|
||||||
|
connection.readTimeout = 30_000
|
||||||
|
connection.setRequestProperty("Authorization", "Bearer $token")
|
||||||
|
val code = connection.responseCode
|
||||||
|
if (code >= 400) error("HTTP $code from $url")
|
||||||
|
connection.inputStream.bufferedReader().use { it.readText() }
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "DispatcharrRepo"
|
||||||
|
}
|
||||||
|
}
|
||||||
118
app/src/main/java/dev/castarr/tv/data/SourceRepository.kt
Normal file
118
app/src/main/java/dev/castarr/tv/data/SourceRepository.kt
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
package dev.castarr.tv.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import dev.castarr.tv.playlist.Channel
|
||||||
|
import dev.castarr.tv.playlist.M3uParser
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.json.JSONArray
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic M3U + XMLTV source: fetches and caches the channel list, loads EPG
|
||||||
|
* and answers Now/Next per channel. (The Dispatcharr Bearer-API source of
|
||||||
|
* issue #5 will sit next to this as a second implementation.)
|
||||||
|
*/
|
||||||
|
class SourceRepository(context: Context) {
|
||||||
|
|
||||||
|
private val prefs = context.getSharedPreferences("source", Context.MODE_PRIVATE)
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
val channels = MutableStateFlow<List<Channel>>(emptyList())
|
||||||
|
val epgUpdatedAt = MutableStateFlow(0L)
|
||||||
|
val status = MutableStateFlow("")
|
||||||
|
|
||||||
|
private var programmes: Map<String, List<Programme>> = emptyMap()
|
||||||
|
private var nameToEpgId: Map<String, String> = emptyMap()
|
||||||
|
|
||||||
|
var m3uUrl: String
|
||||||
|
get() = prefs.getString("m3u_url", "").orEmpty()
|
||||||
|
private set(value) { prefs.edit().putString("m3u_url", value).apply() }
|
||||||
|
|
||||||
|
var epgUrl: String
|
||||||
|
get() = prefs.getString("epg_url", "").orEmpty()
|
||||||
|
private set(value) { prefs.edit().putString("epg_url", value).apply() }
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadCachedChannels()
|
||||||
|
if (m3uUrl.isNotEmpty()) refresh(m3uUrl, epgUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun configure(m3u: String, epg: String, onDone: (Result<Int>) -> Unit = {}) {
|
||||||
|
m3uUrl = m3u
|
||||||
|
epgUrl = epg
|
||||||
|
refresh(m3u, epg, onDone)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh(m3u: String = m3uUrl, epg: String = epgUrl, onDone: (Result<Int>) -> Unit = {}) {
|
||||||
|
if (m3u.isEmpty()) return
|
||||||
|
scope.launch {
|
||||||
|
val result = runCatching {
|
||||||
|
status.value = "loading_channels"
|
||||||
|
val parsed = M3uParser.parse(download(m3u))
|
||||||
|
require(parsed.isNotEmpty()) { "empty playlist" }
|
||||||
|
channels.value = parsed
|
||||||
|
prefs.edit().putString("channels_cache", Channel.listToJson(parsed).toString()).apply()
|
||||||
|
parsed.size
|
||||||
|
}
|
||||||
|
result.onFailure { Log.w(TAG, "channel refresh failed", it) }
|
||||||
|
status.value = if (result.isSuccess) "" else "channels_error"
|
||||||
|
onDone(result)
|
||||||
|
if (result.isSuccess && epg.isNotEmpty()) refreshEpg(epg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshEpg(url: String) {
|
||||||
|
runCatching {
|
||||||
|
status.value = "loading_epg"
|
||||||
|
val connection = open(url)
|
||||||
|
connection.inputStream.use { stream ->
|
||||||
|
val parsed = XmltvParser.parse(stream)
|
||||||
|
programmes = parsed.programmes
|
||||||
|
nameToEpgId = parsed.displayNameToId
|
||||||
|
}
|
||||||
|
epgUpdatedAt.value = System.currentTimeMillis()
|
||||||
|
}.onFailure { Log.w(TAG, "epg refresh failed", it) }
|
||||||
|
status.value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Now/Next for an M3U channel: match tvg-id first, then name. */
|
||||||
|
fun nowNext(channel: Channel): NowNext {
|
||||||
|
val direct = channel.tvgId.takeIf { it.isNotEmpty() }?.let { programmes[it] }
|
||||||
|
val byName = direct ?: nameToEpgId[channel.name.lowercase()]?.let { programmes[it] }
|
||||||
|
return XmltvParser.nowNext(byName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadCachedChannels() {
|
||||||
|
runCatching {
|
||||||
|
val cached = prefs.getString("channels_cache", null) ?: return
|
||||||
|
channels.value = Channel.listFromJson(JSONArray(cached))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun open(url: String): HttpURLConnection =
|
||||||
|
(URL(url).openConnection() as HttpURLConnection).apply {
|
||||||
|
connectTimeout = 15_000
|
||||||
|
readTimeout = 60_000
|
||||||
|
instanceFollowRedirects = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun download(url: String): String {
|
||||||
|
val connection = open(url)
|
||||||
|
return try {
|
||||||
|
connection.inputStream.bufferedReader().use { it.readText() }
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "SourceRepository"
|
||||||
|
}
|
||||||
|
}
|
||||||
113
app/src/main/java/dev/castarr/tv/data/Xmltv.kt
Normal file
113
app/src/main/java/dev/castarr/tv/data/Xmltv.kt
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package dev.castarr.tv.data
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import android.util.Xml
|
||||||
|
import org.xmlpull.v1.XmlPullParser
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
data class Programme(val start: Long, val stop: Long, val title: String)
|
||||||
|
|
||||||
|
data class NowNext(val now: Programme?, val next: Programme?)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal XMLTV parser: programme start/stop/title per channel id. Display
|
||||||
|
* names are also collected so channels can be matched by name when the M3U
|
||||||
|
* carries no tvg-id.
|
||||||
|
*/
|
||||||
|
object XmltvParser {
|
||||||
|
|
||||||
|
private const val TAG = "XmltvParser"
|
||||||
|
private const val MAX_PROGRAMMES = 200_000
|
||||||
|
|
||||||
|
data class Result(
|
||||||
|
val programmes: Map<String, List<Programme>>,
|
||||||
|
val displayNameToId: Map<String, String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun parse(input: InputStream): Result {
|
||||||
|
val programmes = HashMap<String, MutableList<Programme>>()
|
||||||
|
val displayNames = HashMap<String, String>()
|
||||||
|
var count = 0
|
||||||
|
|
||||||
|
val parser = Xml.newPullParser()
|
||||||
|
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
|
||||||
|
parser.setInput(input, null)
|
||||||
|
|
||||||
|
var currentChannelId: String? = null
|
||||||
|
var programmeChannel: String? = null
|
||||||
|
var programmeStart = 0L
|
||||||
|
var programmeStop = 0L
|
||||||
|
var programmeTitle = ""
|
||||||
|
var inTitle = false
|
||||||
|
var inDisplayName = false
|
||||||
|
|
||||||
|
var event = parser.eventType
|
||||||
|
while (event != XmlPullParser.END_DOCUMENT && count < MAX_PROGRAMMES) {
|
||||||
|
when (event) {
|
||||||
|
XmlPullParser.START_TAG -> when (parser.name) {
|
||||||
|
"channel" -> currentChannelId = parser.getAttributeValue(null, "id")
|
||||||
|
"display-name" -> inDisplayName = true
|
||||||
|
"programme" -> {
|
||||||
|
programmeChannel = parser.getAttributeValue(null, "channel")
|
||||||
|
programmeStart = parseTime(parser.getAttributeValue(null, "start"))
|
||||||
|
programmeStop = parseTime(parser.getAttributeValue(null, "stop"))
|
||||||
|
programmeTitle = ""
|
||||||
|
}
|
||||||
|
"title" -> inTitle = programmeChannel != null
|
||||||
|
}
|
||||||
|
XmlPullParser.TEXT -> {
|
||||||
|
if (inTitle && programmeTitle.isEmpty()) programmeTitle = parser.text.trim()
|
||||||
|
if (inDisplayName) {
|
||||||
|
val id = currentChannelId
|
||||||
|
val name = parser.text.trim()
|
||||||
|
if (id != null && name.isNotEmpty()) displayNames.putIfAbsent(name.lowercase(), id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
XmlPullParser.END_TAG -> when (parser.name) {
|
||||||
|
"display-name" -> inDisplayName = false
|
||||||
|
"title" -> inTitle = false
|
||||||
|
"channel" -> currentChannelId = null
|
||||||
|
"programme" -> {
|
||||||
|
val channel = programmeChannel
|
||||||
|
if (channel != null && programmeStart > 0 && programmeStop > programmeStart) {
|
||||||
|
programmes.getOrPut(channel) { mutableListOf() }
|
||||||
|
.add(Programme(programmeStart, programmeStop, programmeTitle))
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
programmeChannel = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event = parser.next()
|
||||||
|
}
|
||||||
|
programmes.values.forEach { it.sortBy(Programme::start) }
|
||||||
|
Log.i(TAG, "parsed $count programmes for ${programmes.size} channels")
|
||||||
|
return Result(programmes, displayNames)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** XMLTV time: `yyyyMMddHHmmss Z` (zone part optional). */
|
||||||
|
private fun parseTime(raw: String?): Long {
|
||||||
|
if (raw.isNullOrBlank()) return 0
|
||||||
|
val normalized = raw.trim()
|
||||||
|
val format = if (normalized.contains(' ')) "yyyyMMddHHmmss Z" else "yyyyMMddHHmmss"
|
||||||
|
return try {
|
||||||
|
SimpleDateFormat(format, Locale.US).parse(normalized)?.time ?: 0
|
||||||
|
} catch (e: java.text.ParseException) {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun nowNext(programmes: List<Programme>?, at: Long = System.currentTimeMillis()): NowNext {
|
||||||
|
if (programmes.isNullOrEmpty()) return NowNext(null, null)
|
||||||
|
val index = programmes.indexOfFirst { at < it.stop }
|
||||||
|
if (index < 0) return NowNext(null, null)
|
||||||
|
val candidate = programmes[index]
|
||||||
|
return if (at >= candidate.start) {
|
||||||
|
NowNext(candidate, programmes.getOrNull(index + 1))
|
||||||
|
} else {
|
||||||
|
NowNext(null, candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,12 +8,16 @@ data class Channel(
|
|||||||
val url: String,
|
val 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 {
|
||||||
|
|||||||
@@ -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 -> {
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
package dev.castarr.tv.playlist
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import org.json.JSONArray
|
|
||||||
import java.net.HttpURLConnection
|
|
||||||
import java.net.URL
|
|
||||||
import java.util.concurrent.Executors
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches and caches the M3U playlist. The channel list is persisted so the
|
|
||||||
* remote sees its channels again right after an app restart.
|
|
||||||
*/
|
|
||||||
class PlaylistRepository(context: Context) {
|
|
||||||
|
|
||||||
private val prefs = context.getSharedPreferences("playlist", Context.MODE_PRIVATE)
|
|
||||||
private val executor = Executors.newSingleThreadExecutor()
|
|
||||||
|
|
||||||
var channels: List<Channel> = loadCached()
|
|
||||||
private set
|
|
||||||
|
|
||||||
val playlistUrl: String
|
|
||||||
get() = prefs.getString(KEY_URL, "").orEmpty()
|
|
||||||
|
|
||||||
fun refresh(url: String, onResult: (Result<List<Channel>>) -> Unit) {
|
|
||||||
executor.execute {
|
|
||||||
val result = runCatching {
|
|
||||||
val content = download(url)
|
|
||||||
val parsed = M3uParser.parse(content)
|
|
||||||
require(parsed.isNotEmpty()) { "playlist is empty" }
|
|
||||||
parsed
|
|
||||||
}
|
|
||||||
result.onSuccess { parsed ->
|
|
||||||
channels = parsed
|
|
||||||
prefs.edit()
|
|
||||||
.putString(KEY_URL, url)
|
|
||||||
.putString(KEY_CACHE, Channel.listToJson(parsed).toString())
|
|
||||||
.apply()
|
|
||||||
}
|
|
||||||
onResult(result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun download(url: String): String {
|
|
||||||
val connection = URL(url).openConnection() as HttpURLConnection
|
|
||||||
return try {
|
|
||||||
connection.connectTimeout = 15_000
|
|
||||||
connection.readTimeout = 30_000
|
|
||||||
connection.instanceFollowRedirects = true
|
|
||||||
connection.inputStream.bufferedReader().use { it.readText() }
|
|
||||||
} finally {
|
|
||||||
connection.disconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadCached(): List<Channel> = runCatching {
|
|
||||||
Channel.listFromJson(JSONArray(prefs.getString(KEY_CACHE, "[]").orEmpty()))
|
|
||||||
}.getOrDefault(emptyList())
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val KEY_URL = "url"
|
|
||||||
const val KEY_CACHE = "channels"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -33,9 +33,11 @@ class ControlServer(
|
|||||||
fun onSeek(deltaSeconds: Long)
|
fun 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)
|
||||||
|
|||||||
106
app/src/main/java/dev/castarr/tv/ui/CastarrApp.kt
Normal file
106
app/src/main/java/dev/castarr/tv/ui/CastarrApp.kt
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package dev.castarr.tv.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.tv.material3.Surface
|
||||||
|
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import dev.castarr.tv.AppState
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CastarrApp(state: AppState) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(CastarrColors.bg)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
TopBar(state)
|
||||||
|
when (state.screen) {
|
||||||
|
AppState.Screen.LIVE -> LiveScreen(state)
|
||||||
|
AppState.Screen.SETTINGS -> SettingsScreen(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.playerVisible) {
|
||||||
|
PlayerScreen(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TopBar(state: AppState) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 40.dp, vertical = 18.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"CASTARR",
|
||||||
|
color = CastarrColors.fg,
|
||||||
|
fontFamily = SpaceGrotesk,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
letterSpacing = 4.sp,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(36.dp))
|
||||||
|
TabItem("Live", state.screen == AppState.Screen.LIVE) {
|
||||||
|
state.screen = AppState.Screen.LIVE
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
TabItem("Einstellungen", state.screen == AppState.Screen.SETTINGS) {
|
||||||
|
state.screen = AppState.Screen.SETTINGS
|
||||||
|
}
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
state.connectedRemote?.let { name ->
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(7.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(CastarrColors.accent)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(name, color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 13.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TabItem(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)),
|
||||||
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = if (selected) CastarrColors.accentDim else Color.Transparent,
|
||||||
|
contentColor = if (selected) CastarrColors.accent else CastarrColors.muted,
|
||||||
|
focusedContainerColor = CastarrColors.accent,
|
||||||
|
focusedContentColor = CastarrColors.onAccent,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
fontFamily = SpaceGrotesk,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
modifier = Modifier.padding(horizontal = 18.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
240
app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt
Normal file
240
app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
package dev.castarr.tv.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||||
|
import androidx.tv.material3.Surface
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import dev.castarr.tv.AppState
|
||||||
|
import dev.castarr.tv.data.NowNext
|
||||||
|
import dev.castarr.tv.playlist.Channel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LiveScreen(state: AppState) {
|
||||||
|
val genericChannels by state.source.channels.collectAsStateWithLifecycle()
|
||||||
|
val dispatcharrChannels by state.dispatcharr.channels.collectAsStateWithLifecycle()
|
||||||
|
val favorites by state.dispatcharr.favorites.collectAsStateWithLifecycle()
|
||||||
|
// Re-render Now/Next when a new EPG arrives.
|
||||||
|
val genericEpg by state.source.epgUpdatedAt.collectAsStateWithLifecycle()
|
||||||
|
val dispatcharrEpg by state.dispatcharr.epgUpdatedAt.collectAsStateWithLifecycle()
|
||||||
|
val isDispatcharr = state.sourceMode == AppState.SourceMode.DISPATCHARR
|
||||||
|
val epgStamp = if (isDispatcharr) dispatcharrEpg else genericEpg
|
||||||
|
val allChannels = if (isDispatcharr) dispatcharrChannels else genericChannels
|
||||||
|
val channels = if (isDispatcharr && state.favoritesOnly) {
|
||||||
|
allChannels.filter { it.backendId in favorites }
|
||||||
|
} else {
|
||||||
|
allChannels
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allChannels.isEmpty()) {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
"Noch keine Sender — richte unter Einstellungen eine Quelle ein.",
|
||||||
|
color = CastarrColors.faint,
|
||||||
|
fontFamily = SpaceGrotesk,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
if (isDispatcharr) {
|
||||||
|
Row(Modifier.padding(start = 40.dp, bottom = 6.dp)) {
|
||||||
|
FilterChip("Alle", !state.favoritesOnly) { state.favoritesOnly = false }
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
FilterChip("★ Favoriten", state.favoritesOnly) { state.favoritesOnly = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(
|
||||||
|
start = 40.dp, end = 40.dp, top = 8.dp, bottom = 32.dp
|
||||||
|
),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
items(channels, key = { it.url + it.name }) { channel ->
|
||||||
|
ChannelRow(
|
||||||
|
channel = channel,
|
||||||
|
index = channels.indexOf(channel),
|
||||||
|
nowNext = state.nowNext(channel),
|
||||||
|
playing = state.currentChannel?.url == channel.url,
|
||||||
|
favorite = isDispatcharr && channel.backendId in favorites,
|
||||||
|
epgStamp = epgStamp,
|
||||||
|
onLongClick = if (isDispatcharr) {
|
||||||
|
{ state.dispatcharr.toggleFavorite(channel) }
|
||||||
|
} else null,
|
||||||
|
) { state.play(channel) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FilterChip(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)),
|
||||||
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = if (selected) CastarrColors.accentDim else CastarrColors.surface,
|
||||||
|
contentColor = if (selected) CastarrColors.accent else CastarrColors.muted,
|
||||||
|
focusedContainerColor = CastarrColors.accent,
|
||||||
|
focusedContentColor = CastarrColors.onAccent,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
fontFamily = SpaceGrotesk,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChannelRow(
|
||||||
|
channel: Channel,
|
||||||
|
index: Int,
|
||||||
|
nowNext: NowNext,
|
||||||
|
playing: Boolean,
|
||||||
|
favorite: Boolean,
|
||||||
|
epgStamp: Long,
|
||||||
|
onLongClick: (() -> Unit)?,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)),
|
||||||
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = if (playing) CastarrColors.accentDim else Color.Transparent,
|
||||||
|
contentColor = CastarrColors.fg,
|
||||||
|
focusedContainerColor = CastarrColors.surfaceFocused,
|
||||||
|
focusedContentColor = CastarrColors.fg,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"${index + 1}",
|
||||||
|
color = if (playing) CastarrColors.accent else CastarrColors.faint,
|
||||||
|
fontFamily = SpaceGrotesk,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
modifier = Modifier.width(44.dp),
|
||||||
|
)
|
||||||
|
if (favorite) {
|
||||||
|
Text(
|
||||||
|
"★",
|
||||||
|
color = CastarrColors.accent,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
modifier = Modifier.width(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
channel.name,
|
||||||
|
color = if (playing) CastarrColors.accent else CastarrColors.fg,
|
||||||
|
fontFamily = SpaceGrotesk,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = if (playing) FontWeight.Medium else FontWeight.Normal,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (channel.group.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
channel.group,
|
||||||
|
color = CastarrColors.faint,
|
||||||
|
fontFamily = SpaceGrotesk,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NowNextCell(nowNext)
|
||||||
|
if (playing) {
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(7.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(CastarrColors.accent)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NowNextCell(nowNext: NowNext) {
|
||||||
|
val now = nowNext.now
|
||||||
|
Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(320.dp)) {
|
||||||
|
if (now != null) {
|
||||||
|
Text(
|
||||||
|
now.title,
|
||||||
|
color = CastarrColors.muted,
|
||||||
|
fontFamily = SpaceGrotesk,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
val fraction = ((System.currentTimeMillis() - now.start).toFloat() /
|
||||||
|
(now.stop - now.start).coerceAtLeast(1)).coerceIn(0f, 1f)
|
||||||
|
Spacer(Modifier.height(5.dp))
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.width(180.dp)
|
||||||
|
.height(2.dp)
|
||||||
|
.clip(RoundedCornerShape(1.dp))
|
||||||
|
.background(CastarrColors.line)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth(fraction)
|
||||||
|
.height(2.dp)
|
||||||
|
.background(CastarrColors.accent)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
nowNext.next?.let { next ->
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
"danach: ${next.title}",
|
||||||
|
color = CastarrColors.faint,
|
||||||
|
fontFamily = SpaceGrotesk,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
174
app/src/main/java/dev/castarr/tv/ui/PlayerScreen.kt
Normal file
174
app/src/main/java/dev/castarr/tv/ui/PlayerScreen.kt
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
package dev.castarr.tv.ui
|
||||||
|
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.focusable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.media3.ui.PlayerView
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import dev.castarr.tv.AppState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fullscreen playback with the minimal auto-hiding overlay. Key handling for
|
||||||
|
* play/pause/zapping lives in MainActivity.onKeyDown; this composable only
|
||||||
|
* grabs focus so list items underneath stop reacting.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun PlayerScreen(state: AppState) {
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||||
|
LaunchedEffect(state.playerVisible, state.playerState) {
|
||||||
|
state.overlayVisible = true
|
||||||
|
if (state.playerState == "playing") {
|
||||||
|
kotlinx.coroutines.delay(4000)
|
||||||
|
state.overlayVisible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(CastarrColors.bgDeep)
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
.focusable()
|
||||||
|
.onKeyEvent { false },
|
||||||
|
) {
|
||||||
|
AndroidView(
|
||||||
|
factory = { context ->
|
||||||
|
PlayerView(context).apply {
|
||||||
|
useController = false
|
||||||
|
player = state.player.player
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (state.overlayVisible) {
|
||||||
|
Overlay(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Overlay(state: AppState) {
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(200.dp)
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
listOf(Color.Transparent, CastarrColors.bgDeep.copy(alpha = 0.92f))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
state.connectedRemote?.let { name ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(top = 22.dp, end = 30.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(name, color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 12.sp)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(6.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(CastarrColors.accent)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 36.dp)
|
||||||
|
.padding(bottom = 30.dp),
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (state.isLive) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(6.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(CastarrColors.live)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
"LIVE",
|
||||||
|
color = CastarrColors.muted,
|
||||||
|
fontFamily = SpaceGrotesk,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
letterSpacing = 2.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Text(
|
||||||
|
when (state.playerState) {
|
||||||
|
"paused" -> "Pausiert"
|
||||||
|
"buffering" -> "Lädt…"
|
||||||
|
"error" -> "Wiedergabefehler"
|
||||||
|
else -> "Wird abgespielt"
|
||||||
|
},
|
||||||
|
color = CastarrColors.muted,
|
||||||
|
fontFamily = SpaceGrotesk,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
state.currentChannel?.name.orEmpty(),
|
||||||
|
color = CastarrColors.fg,
|
||||||
|
fontFamily = SpaceGrotesk,
|
||||||
|
fontSize = 30.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
val fraction = if (state.isLive || state.durationMs <= 0) 1f
|
||||||
|
else (state.positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f)
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(2.dp)
|
||||||
|
.clip(RoundedCornerShape(1.dp))
|
||||||
|
.background(CastarrColors.line)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth(fraction)
|
||||||
|
.height(2.dp)
|
||||||
|
.background(CastarrColors.accent)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
362
app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt
Normal file
362
app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
package dev.castarr.tv.ui
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||||
|
import androidx.tv.material3.Surface
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import dev.castarr.tv.AppState
|
||||||
|
import dev.castarr.tv.auth.DeviceAuth
|
||||||
|
import dev.castarr.tv.pairing.Pairing
|
||||||
|
import dev.castarr.tv.pairing.Qr
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(state: AppState) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var m3u by remember { mutableStateOf(state.source.m3uUrl) }
|
||||||
|
var epg by remember { mutableStateOf(state.source.epgUrl) }
|
||||||
|
var message by remember { mutableStateOf("") }
|
||||||
|
val status by state.source.status.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val fieldColors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedTextColor = CastarrColors.fg,
|
||||||
|
unfocusedTextColor = CastarrColors.muted,
|
||||||
|
focusedBorderColor = CastarrColors.accent,
|
||||||
|
unfocusedBorderColor = CastarrColors.line,
|
||||||
|
focusedLabelColor = CastarrColors.accent,
|
||||||
|
unfocusedLabelColor = CastarrColors.faint,
|
||||||
|
cursorColor = CastarrColors.accent,
|
||||||
|
focusedContainerColor = CastarrColors.surface,
|
||||||
|
unfocusedContainerColor = CastarrColors.surface,
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 40.dp, vertical = 16.dp)
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text("Quelle", color = CastarrColors.fg, fontFamily = SpaceGrotesk, fontSize = 20.sp)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
"M3U-Playlist plus optionales XMLTV-EPG — funktioniert mit Dispatcharr, ErsatzTV, Threadfin, Tunarr.",
|
||||||
|
color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 13.sp,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(18.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = m3u,
|
||||||
|
onValueChange = { m3u = it },
|
||||||
|
label = { Text("M3U-URL", fontFamily = SpaceGrotesk) },
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = TextStyle(fontSize = 14.sp),
|
||||||
|
colors = fieldColors,
|
||||||
|
modifier = Modifier.width(560.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = epg,
|
||||||
|
onValueChange = { epg = it },
|
||||||
|
label = { Text("XMLTV-EPG-URL (optional)", fontFamily = SpaceGrotesk) },
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = TextStyle(fontSize = 14.sp),
|
||||||
|
colors = fieldColors,
|
||||||
|
modifier = Modifier.width(560.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(18.dp))
|
||||||
|
Surface(
|
||||||
|
onClick = {
|
||||||
|
message = ""
|
||||||
|
state.source.configure(m3u.trim(), epg.trim()) { result ->
|
||||||
|
message = result.fold(
|
||||||
|
onSuccess = { "$it Sender geladen" },
|
||||||
|
onFailure = { "Fehler: Quelle nicht ladbar" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)),
|
||||||
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = CastarrColors.accentDim,
|
||||||
|
contentColor = CastarrColors.accent,
|
||||||
|
focusedContainerColor = CastarrColors.accent,
|
||||||
|
focusedContentColor = CastarrColors.onAccent,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Quelle laden",
|
||||||
|
fontFamily = SpaceGrotesk,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
modifier = Modifier.padding(horizontal = 22.dp, vertical = 11.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
val statusText = when {
|
||||||
|
status == "loading_channels" -> "Lade Senderliste…"
|
||||||
|
status == "loading_epg" -> "Lade EPG…"
|
||||||
|
message.isNotEmpty() -> message
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
if (statusText.isNotEmpty()) {
|
||||||
|
Text(statusText, color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 13.sp)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(28.dp))
|
||||||
|
DispatcharrAccount(state, fieldColors)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.width(40.dp))
|
||||||
|
|
||||||
|
Column(horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally) {
|
||||||
|
Text("Handy-Fernbedienung", color = CastarrColors.fg, fontFamily = SpaceGrotesk, fontSize = 20.sp)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
val address = remember { Pairing.lanAddress() }
|
||||||
|
if (address != null) {
|
||||||
|
val qr = remember(address) {
|
||||||
|
Qr.encode(Pairing.pairingUrl(context, address), 400, android.graphics.Color.parseColor("#101216"))
|
||||||
|
}
|
||||||
|
QrCard(qr)
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Text(
|
||||||
|
"${Pairing.remoteUrl(address)} · Code ${Pairing.code(context)}",
|
||||||
|
color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Keine Netzwerkverbindung", color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 13.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun QrCard(bitmap: Bitmap) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(androidx.compose.ui.graphics.Color(0xFFFBFCFD))
|
||||||
|
.padding(14.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
bitmap = bitmap.asImageBitmap(),
|
||||||
|
contentDescription = "QR-Code zum Koppeln",
|
||||||
|
modifier = Modifier.size(150.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DispatcharrAccount(
|
||||||
|
state: AppState,
|
||||||
|
fieldColors: androidx.compose.material3.TextFieldColors,
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var serverUrl by remember {
|
||||||
|
mutableStateOf(state.auth.serverUrl.ifEmpty { "https://" })
|
||||||
|
}
|
||||||
|
var session by remember { mutableStateOf<DeviceAuth.DeviceSession?>(null) }
|
||||||
|
var phase by remember { mutableStateOf("") }
|
||||||
|
var loggedIn by remember { mutableStateOf(state.auth.isLoggedIn) }
|
||||||
|
val profiles by state.dispatcharr.profiles.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
Text("Dispatcharr-Konto", color = CastarrColors.fg, fontFamily = SpaceGrotesk, fontSize = 20.sp)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
|
||||||
|
if (!loggedIn) {
|
||||||
|
Text(
|
||||||
|
"Anmeldung per Handy: QR-Code scannen und im Browser bestätigen.",
|
||||||
|
color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 13.sp,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(14.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = serverUrl,
|
||||||
|
onValueChange = { serverUrl = it },
|
||||||
|
label = { Text("Server-URL", fontFamily = SpaceGrotesk) },
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = TextStyle(fontSize = 14.sp),
|
||||||
|
colors = fieldColors,
|
||||||
|
modifier = Modifier.width(560.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
val currentSession = session
|
||||||
|
if (currentSession == null) {
|
||||||
|
Surface(
|
||||||
|
onClick = {
|
||||||
|
phase = "Verbinde…"
|
||||||
|
scope.launch {
|
||||||
|
runCatching {
|
||||||
|
state.auth.fetchServerConfig(serverUrl.trim())
|
||||||
|
session = state.auth.startDeviceFlow()
|
||||||
|
phase = ""
|
||||||
|
}.onFailure { phase = "Fehler: Server nicht erreichbar oder kein SSO" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)),
|
||||||
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = CastarrColors.accentDim,
|
||||||
|
contentColor = CastarrColors.accent,
|
||||||
|
focusedContainerColor = CastarrColors.accent,
|
||||||
|
focusedContentColor = CastarrColors.onAccent,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Anmelden",
|
||||||
|
fontFamily = SpaceGrotesk, fontSize = 14.sp,
|
||||||
|
modifier = Modifier.padding(horizontal = 22.dp, vertical = 11.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LaunchedEffect(currentSession) {
|
||||||
|
val deadline = System.currentTimeMillis() + currentSession.expiresInSeconds * 1000L
|
||||||
|
while (System.currentTimeMillis() < deadline) {
|
||||||
|
delay(currentSession.intervalSeconds * 1000L)
|
||||||
|
when (state.auth.poll(currentSession)) {
|
||||||
|
is DeviceAuth.PollResult.Success -> {
|
||||||
|
loggedIn = true
|
||||||
|
session = null
|
||||||
|
state.setMode(AppState.SourceMode.DISPATCHARR)
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
DeviceAuth.PollResult.Denied -> {
|
||||||
|
phase = "Anmeldung abgelehnt oder abgelaufen"
|
||||||
|
session = null
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
DeviceAuth.PollResult.Pending -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
phase = "Code abgelaufen — erneut versuchen"
|
||||||
|
session = null
|
||||||
|
}
|
||||||
|
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||||
|
val qr = remember(currentSession.deviceCode) {
|
||||||
|
Qr.encode(
|
||||||
|
currentSession.verificationUriComplete,
|
||||||
|
400,
|
||||||
|
android.graphics.Color.parseColor("#101216"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
QrCard(qr)
|
||||||
|
Spacer(Modifier.width(20.dp))
|
||||||
|
Column {
|
||||||
|
Text("Code", color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 12.sp)
|
||||||
|
Text(
|
||||||
|
currentSession.userCode,
|
||||||
|
color = CastarrColors.fg, fontFamily = SpaceGrotesk, fontSize = 26.sp,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
currentSession.verificationUri,
|
||||||
|
color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Text("Warte auf Bestätigung…", color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (phase.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(phase, color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 13.sp)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
"Angemeldet als ${state.auth.username} · ${state.auth.serverUrl}",
|
||||||
|
color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 13.sp,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Row {
|
||||||
|
ModeChip("Dispatcharr", state.sourceMode == AppState.SourceMode.DISPATCHARR) {
|
||||||
|
state.setMode(AppState.SourceMode.DISPATCHARR)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
ModeChip("Eigene M3U", state.sourceMode == AppState.SourceMode.GENERIC) {
|
||||||
|
state.setMode(AppState.SourceMode.GENERIC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(14.dp))
|
||||||
|
Text("Stream-Profil", color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 12.sp)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Row {
|
||||||
|
ModeChip("Standard", state.outputProfile.isEmpty()) { state.outputProfile = "" }
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
ModeChip("Passthrough", state.outputProfile == "raw") { state.outputProfile = "raw" }
|
||||||
|
profiles.forEach { name ->
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
ModeChip(name, state.outputProfile == name) { state.outputProfile = name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(14.dp))
|
||||||
|
Surface(
|
||||||
|
onClick = {
|
||||||
|
state.auth.logout()
|
||||||
|
loggedIn = false
|
||||||
|
state.setMode(AppState.SourceMode.GENERIC)
|
||||||
|
},
|
||||||
|
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)),
|
||||||
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = CastarrColors.surface,
|
||||||
|
contentColor = CastarrColors.muted,
|
||||||
|
focusedContainerColor = CastarrColors.live,
|
||||||
|
focusedContentColor = CastarrColors.fg,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Abmelden",
|
||||||
|
fontFamily = SpaceGrotesk, fontSize = 13.sp,
|
||||||
|
modifier = Modifier.padding(horizontal = 18.dp, vertical = 9.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ModeChip(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)),
|
||||||
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = if (selected) CastarrColors.accentDim else CastarrColors.surface,
|
||||||
|
contentColor = if (selected) CastarrColors.accent else CastarrColors.muted,
|
||||||
|
focusedContainerColor = CastarrColors.accent,
|
||||||
|
focusedContentColor = CastarrColors.onAccent,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
fontFamily = SpaceGrotesk, fontSize = 12.sp,
|
||||||
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/src/main/java/dev/castarr/tv/ui/Theme.kt
Normal file
26
app/src/main/java/dev/castarr/tv/ui/Theme.kt
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package dev.castarr.tv.ui
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import dev.castarr.tv.R
|
||||||
|
|
||||||
|
object CastarrColors {
|
||||||
|
val bg = Color(0xFF0A0B0D)
|
||||||
|
val bgDeep = Color(0xFF05060A)
|
||||||
|
val surface = Color(0xFF121418)
|
||||||
|
val surfaceFocused = Color(0xFF1E2126)
|
||||||
|
val fg = Color(0xFFF2F3F5)
|
||||||
|
val muted = Color(0xFF9AA0A8)
|
||||||
|
val faint = Color(0xFF6B717A)
|
||||||
|
val accent = Color(0xFF5FD4C4)
|
||||||
|
val accentDim = Color(0x125FD4C4)
|
||||||
|
val live = Color(0xFFE5484D)
|
||||||
|
val line = Color(0xFF23262B)
|
||||||
|
val onAccent = Color(0xFF07110F)
|
||||||
|
}
|
||||||
|
|
||||||
|
val SpaceGrotesk = FontFamily(
|
||||||
|
Font(R.font.space_grotesk, FontWeight.Normal),
|
||||||
|
)
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:background="@color/bg">
|
|
||||||
|
|
||||||
<!-- Pairing screen -->
|
|
||||||
<FrameLayout
|
|
||||||
android:id="@+id/pairing_screen"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent">
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_gravity="center"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:gravity="center_horizontal">
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:gravity="center_vertical">
|
|
||||||
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="18dp"
|
|
||||||
android:layout_height="18dp"
|
|
||||||
android:importantForAccessibility="no"
|
|
||||||
android:src="@drawable/ic_cast" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginStart="10dp"
|
|
||||||
android:text="@string/app_name"
|
|
||||||
android:textAllCaps="true"
|
|
||||||
android:textColor="@color/fg"
|
|
||||||
android:textSize="16sp"
|
|
||||||
android:letterSpacing="0.22" />
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<FrameLayout
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="30dp"
|
|
||||||
android:padding="16dp"
|
|
||||||
android:background="@drawable/bg_qr_card">
|
|
||||||
|
|
||||||
<ImageView
|
|
||||||
android:id="@+id/qr_image"
|
|
||||||
android:layout_width="170dp"
|
|
||||||
android:layout_height="170dp"
|
|
||||||
android:importantForAccessibility="no" />
|
|
||||||
</FrameLayout>
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="30dp"
|
|
||||||
android:text="@string/pair_title"
|
|
||||||
android:textColor="@color/fg"
|
|
||||||
android:textSize="26sp" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="8dp"
|
|
||||||
android:maxWidth="440dp"
|
|
||||||
android:gravity="center"
|
|
||||||
android:text="@string/pair_subtitle"
|
|
||||||
android:textColor="@color/muted"
|
|
||||||
android:textSize="14sp"
|
|
||||||
android:lineSpacingMultiplier="1.3" />
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_gravity="bottom"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:gravity="center_vertical"
|
|
||||||
android:paddingStart="36dp"
|
|
||||||
android:paddingEnd="36dp"
|
|
||||||
android:paddingBottom="26dp">
|
|
||||||
|
|
||||||
<View
|
|
||||||
android:layout_width="7dp"
|
|
||||||
android:layout_height="7dp"
|
|
||||||
android:background="@drawable/dot_accent" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/pairing_status"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:layout_marginStart="8dp"
|
|
||||||
android:text="@string/ready_to_pair"
|
|
||||||
android:textColor="@color/faint"
|
|
||||||
android:textSize="12sp" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/pairing_url"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:fontFamily="monospace"
|
|
||||||
android:textColor="@color/faint"
|
|
||||||
android:textSize="12sp" />
|
|
||||||
|
|
||||||
<View
|
|
||||||
android:layout_width="1dp"
|
|
||||||
android:layout_height="12dp"
|
|
||||||
android:layout_marginStart="14dp"
|
|
||||||
android:layout_marginEnd="14dp"
|
|
||||||
android:background="@color/line" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/pairing_code"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textColor="@color/muted"
|
|
||||||
android:textSize="12sp" />
|
|
||||||
</LinearLayout>
|
|
||||||
</FrameLayout>
|
|
||||||
|
|
||||||
<!-- Player screen -->
|
|
||||||
<FrameLayout
|
|
||||||
android:id="@+id/player_screen"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:visibility="gone"
|
|
||||||
android:background="@color/bg_deep">
|
|
||||||
|
|
||||||
<androidx.media3.ui.PlayerView
|
|
||||||
android:id="@+id/player_view"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent" />
|
|
||||||
|
|
||||||
<FrameLayout
|
|
||||||
android:id="@+id/overlay"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent">
|
|
||||||
|
|
||||||
<View
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="200dp"
|
|
||||||
android:layout_gravity="bottom"
|
|
||||||
android:background="@drawable/scrim_bottom" />
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:id="@+id/device_chip"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_gravity="top|end"
|
|
||||||
android:layout_marginTop="22dp"
|
|
||||||
android:layout_marginEnd="30dp"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:gravity="center_vertical"
|
|
||||||
android:paddingStart="14dp"
|
|
||||||
android:paddingEnd="14dp"
|
|
||||||
android:paddingTop="7dp"
|
|
||||||
android:paddingBottom="7dp"
|
|
||||||
android:background="@drawable/bg_chip"
|
|
||||||
android:visibility="gone">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/device_name"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textColor="@color/muted"
|
|
||||||
android:textSize="11sp" />
|
|
||||||
|
|
||||||
<View
|
|
||||||
android:layout_width="5dp"
|
|
||||||
android:layout_height="5dp"
|
|
||||||
android:layout_marginStart="8dp"
|
|
||||||
android:background="@drawable/dot_accent" />
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_gravity="bottom"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:paddingStart="36dp"
|
|
||||||
android:paddingEnd="36dp"
|
|
||||||
android:paddingBottom="30dp">
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:gravity="center_vertical">
|
|
||||||
|
|
||||||
<View
|
|
||||||
android:id="@+id/overlay_live_dot"
|
|
||||||
android:layout_width="6dp"
|
|
||||||
android:layout_height="6dp"
|
|
||||||
android:background="@drawable/dot_live" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/overlay_live_label"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:layout_marginStart="8dp"
|
|
||||||
android:textColor="@color/muted"
|
|
||||||
android:textSize="11sp"
|
|
||||||
android:letterSpacing="0.18" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/overlay_state"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textColor="@color/muted"
|
|
||||||
android:textSize="12sp" />
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/overlay_channel"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="4dp"
|
|
||||||
android:textColor="@color/fg"
|
|
||||||
android:textSize="29sp" />
|
|
||||||
|
|
||||||
<View
|
|
||||||
android:id="@+id/overlay_progress"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="2dp"
|
|
||||||
android:layout_marginTop="16dp"
|
|
||||||
android:background="@drawable/progress_line" />
|
|
||||||
</LinearLayout>
|
|
||||||
</FrameLayout>
|
|
||||||
</FrameLayout>
|
|
||||||
</FrameLayout>
|
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user