Bootstrap Castarr from NodeCast prototype
Imported the native TV app (Kotlin, ExoPlayer, embedded remote server, QR pairing) plus CONTEXT.md and ADRs 0001-0005. Rename, Compose for TV UI and the Dispatcharr integration follow as tracked issues. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
281
app/src/main/java/com/nodecast/tv/MainActivity.kt
Normal file
281
app/src/main/java/com/nodecast/tv/MainActivity.kt
Normal file
@@ -0,0 +1,281 @@
|
||||
package com.nodecast.tv
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Color
|
||||
import android.media.AudioManager
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.media3.ui.PlayerView
|
||||
import com.nodecast.tv.pairing.Pairing
|
||||
import com.nodecast.tv.pairing.Qr
|
||||
import com.nodecast.tv.player.PlayerController
|
||||
import com.nodecast.tv.playlist.Channel
|
||||
import com.nodecast.tv.playlist.PlaylistRepository
|
||||
import com.nodecast.tv.server.ControlServer
|
||||
import org.json.JSONObject
|
||||
|
||||
class MainActivity : Activity(), ControlServer.Listener {
|
||||
|
||||
private lateinit var playerController: PlayerController
|
||||
private lateinit var server: ControlServer
|
||||
private lateinit var playlist: PlaylistRepository
|
||||
private lateinit var audioManager: AudioManager
|
||||
|
||||
private lateinit var pairingScreen: View
|
||||
private lateinit var playerScreen: View
|
||||
private lateinit var playerView: PlayerView
|
||||
private lateinit var overlay: View
|
||||
private lateinit var overlayChannel: TextView
|
||||
private lateinit var overlayState: TextView
|
||||
private lateinit var overlayLiveDot: View
|
||||
private lateinit var overlayLiveLabel: TextView
|
||||
private lateinit var overlayProgress: View
|
||||
private lateinit var deviceChip: View
|
||||
private lateinit var deviceName: TextView
|
||||
private lateinit var pairingStatus: TextView
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val hideOverlay = Runnable { overlay.animate().alpha(0f).setDuration(400).start() }
|
||||
private val ticker = object : Runnable {
|
||||
override fun run() {
|
||||
updateProgress()
|
||||
server.broadcastStatus()
|
||||
mainHandler.postDelayed(this, TICK_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
private var connectedName: String? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
|
||||
playlist = PlaylistRepository(this)
|
||||
playerController = PlayerController(this) { onPlaybackChanged() }
|
||||
|
||||
bindViews()
|
||||
setupPairingScreen()
|
||||
|
||||
server = ControlServer(this, this)
|
||||
server.startServer()
|
||||
}
|
||||
|
||||
private fun bindViews() {
|
||||
pairingScreen = findViewById(R.id.pairing_screen)
|
||||
playerScreen = findViewById(R.id.player_screen)
|
||||
playerView = findViewById(R.id.player_view)
|
||||
overlay = findViewById(R.id.overlay)
|
||||
overlayChannel = findViewById(R.id.overlay_channel)
|
||||
overlayState = findViewById(R.id.overlay_state)
|
||||
overlayLiveDot = findViewById(R.id.overlay_live_dot)
|
||||
overlayLiveLabel = findViewById(R.id.overlay_live_label)
|
||||
overlayProgress = findViewById(R.id.overlay_progress)
|
||||
deviceChip = findViewById(R.id.device_chip)
|
||||
deviceName = findViewById(R.id.device_name)
|
||||
pairingStatus = findViewById(R.id.pairing_status)
|
||||
|
||||
playerView.useController = false
|
||||
playerView.player = playerController.player
|
||||
overlayProgress.pivotX = 0f
|
||||
}
|
||||
|
||||
private fun setupPairingScreen() {
|
||||
val address = Pairing.lanAddress()
|
||||
val qrImage = findViewById<ImageView>(R.id.qr_image)
|
||||
val urlText = findViewById<TextView>(R.id.pairing_url)
|
||||
val codeText = findViewById<TextView>(R.id.pairing_code)
|
||||
codeText.text = getString(R.string.pairing_code, Pairing.code(this))
|
||||
if (address != null) {
|
||||
urlText.text = Pairing.remoteUrl(address)
|
||||
qrImage.setImageBitmap(
|
||||
Qr.encode(Pairing.pairingUrl(this, address), QR_SIZE_PX, Color.parseColor("#101216"))
|
||||
)
|
||||
} else {
|
||||
urlText.text = getString(R.string.no_network)
|
||||
}
|
||||
}
|
||||
|
||||
// --- playback state → UI + remote ---
|
||||
|
||||
private fun onPlaybackChanged() {
|
||||
val showPlayer = playerController.hasMedia
|
||||
pairingScreen.visibility = if (showPlayer) View.GONE else View.VISIBLE
|
||||
playerScreen.visibility = if (showPlayer) View.VISIBLE else View.GONE
|
||||
val active = playerController.state == "playing" || playerController.state == "buffering"
|
||||
playerScreen.keepScreenOn = active
|
||||
|
||||
mainHandler.removeCallbacks(ticker)
|
||||
if (showPlayer) {
|
||||
overlayChannel.text = playerController.channelName
|
||||
val live = playerController.player.isCurrentMediaItemLive
|
||||
overlayLiveDot.visibility = if (live) View.VISIBLE else View.GONE
|
||||
overlayLiveLabel.text = if (live) getString(R.string.live) else ""
|
||||
overlayState.text = when (playerController.state) {
|
||||
"paused" -> getString(R.string.paused)
|
||||
"buffering" -> getString(R.string.buffering)
|
||||
"error" -> getString(R.string.playback_error, playerController.errorMessage.orEmpty())
|
||||
else -> getString(R.string.playing)
|
||||
}
|
||||
updateProgress()
|
||||
showOverlay(autoHide = playerController.state == "playing")
|
||||
if (active) mainHandler.postDelayed(ticker, TICK_INTERVAL_MS)
|
||||
}
|
||||
onClientsChanged(-1, null)
|
||||
server.broadcastStatus()
|
||||
}
|
||||
|
||||
private fun updateProgress() {
|
||||
val player = playerController.player
|
||||
val fraction = if (player.isCurrentMediaItemLive || player.duration <= 0) {
|
||||
1f
|
||||
} else {
|
||||
(player.currentPosition.toFloat() / player.duration).coerceIn(0f, 1f)
|
||||
}
|
||||
overlayProgress.scaleX = fraction
|
||||
}
|
||||
|
||||
private fun showOverlay(autoHide: Boolean) {
|
||||
mainHandler.removeCallbacks(hideOverlay)
|
||||
overlay.animate().alpha(1f).setDuration(200).start()
|
||||
if (autoHide) mainHandler.postDelayed(hideOverlay, OVERLAY_HIDE_DELAY_MS)
|
||||
}
|
||||
|
||||
// --- ControlServer.Listener (called on main thread) ---
|
||||
|
||||
override fun onPlay(url: String, name: String, group: String) {
|
||||
playerController.play(url, name, group)
|
||||
}
|
||||
|
||||
override fun onTogglePlay() = playerController.toggle()
|
||||
|
||||
override fun onPause() = playerController.pause()
|
||||
|
||||
override fun onResume() = playerController.resume()
|
||||
|
||||
override fun onStopCast() = playerController.stop()
|
||||
|
||||
override fun onSeek(deltaSeconds: Long) {
|
||||
playerController.seekBy(deltaSeconds)
|
||||
showOverlay(autoHide = true)
|
||||
}
|
||||
|
||||
override fun onVolume(value: Float) {
|
||||
val max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
|
||||
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, (value * max).toInt().coerceIn(0, max), 0)
|
||||
server.broadcastStatus()
|
||||
}
|
||||
|
||||
override fun onSetPlaylist(url: String) {
|
||||
playlist.refresh(url) { result ->
|
||||
mainHandler.post {
|
||||
result
|
||||
.onSuccess { server.broadcastChannels() }
|
||||
.onFailure { server.broadcastToast(getString(R.string.playlist_error)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClientsChanged(count: Int, newestName: String?) {
|
||||
if (newestName != null) connectedName = newestName
|
||||
if (count >= 0) {
|
||||
pairingStatus.text = if (count > 0) {
|
||||
getString(R.string.paired_with, connectedName.orEmpty())
|
||||
} else {
|
||||
getString(R.string.ready_to_pair)
|
||||
}
|
||||
}
|
||||
val chipVisible = connectedName != null && playerController.hasMedia
|
||||
deviceChip.visibility = if (chipVisible) View.VISIBLE else View.GONE
|
||||
deviceName.text = connectedName.orEmpty()
|
||||
}
|
||||
|
||||
override fun currentStatus(): JSONObject {
|
||||
val max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
|
||||
val vol = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
|
||||
val player = playerController.player
|
||||
return JSONObject()
|
||||
.put("state", playerController.state)
|
||||
.put("channel", playerController.channelName)
|
||||
.put("group", playerController.channelGroup)
|
||||
.put("live", player.isCurrentMediaItemLive)
|
||||
.put("seekable", player.isCurrentMediaItemSeekable)
|
||||
.put("position", player.currentPosition.coerceAtLeast(0))
|
||||
.put("duration", player.duration.coerceAtLeast(0))
|
||||
.put("volume", if (max > 0) vol.toDouble() / max else 0.0)
|
||||
.put("error", playerController.errorMessage ?: "")
|
||||
}
|
||||
|
||||
override fun currentChannels(): List<Channel> = playlist.channels
|
||||
|
||||
override fun currentPlaylistUrl(): String = playlist.playlistUrl
|
||||
|
||||
// --- TV remote keys (TV-PC / TV-PP) ---
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
if (playerController.hasMedia) {
|
||||
when (keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_CENTER,
|
||||
KeyEvent.KEYCODE_ENTER,
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
|
||||
playerController.toggle()
|
||||
return true
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY -> {
|
||||
playerController.resume()
|
||||
return true
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_PAUSE -> {
|
||||
playerController.pause()
|
||||
return true
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_STOP -> {
|
||||
playerController.stop()
|
||||
return true
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_LEFT,
|
||||
KeyEvent.KEYCODE_MEDIA_REWIND -> {
|
||||
onSeek(-SEEK_STEP_SECONDS)
|
||||
return true
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT,
|
||||
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
|
||||
onSeek(SEEK_STEP_SECONDS)
|
||||
return true
|
||||
}
|
||||
KeyEvent.KEYCODE_BACK -> {
|
||||
playerController.stop()
|
||||
return true
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
return super.onKeyDown(keyCode, event)
|
||||
}
|
||||
|
||||
// --- lifecycle ---
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
// TV-NP: video must not keep playing when the user leaves the app.
|
||||
playerController.pause()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
mainHandler.removeCallbacksAndMessages(null)
|
||||
server.stopServer()
|
||||
playerController.release()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val QR_SIZE_PX = 512
|
||||
const val OVERLAY_HIDE_DELAY_MS = 4_000L
|
||||
const val TICK_INTERVAL_MS = 2_000L
|
||||
const val SEEK_STEP_SECONDS = 10L
|
||||
}
|
||||
}
|
||||
54
app/src/main/java/com/nodecast/tv/pairing/Pairing.kt
Normal file
54
app/src/main/java/com/nodecast/tv/pairing/Pairing.kt
Normal file
@@ -0,0 +1,54 @@
|
||||
package com.nodecast.tv.pairing
|
||||
|
||||
import android.content.Context
|
||||
import java.net.Inet4Address
|
||||
import java.net.NetworkInterface
|
||||
import java.security.SecureRandom
|
||||
|
||||
object Pairing {
|
||||
|
||||
const val PORT = 8765
|
||||
|
||||
/**
|
||||
* Four-digit pairing code, generated once and kept stable so an already
|
||||
* paired phone survives app restarts. Human fallback only — the QR code
|
||||
* carries the long token below.
|
||||
*/
|
||||
fun code(context: Context): String {
|
||||
val prefs = context.getSharedPreferences("pairing", Context.MODE_PRIVATE)
|
||||
prefs.getString("code", null)?.let { return it }
|
||||
val code = "%04d".format(SecureRandom().nextInt(10_000))
|
||||
prefs.edit().putString("code", code).apply()
|
||||
return code
|
||||
}
|
||||
|
||||
/** 128-bit random token embedded in the QR code; not brute-forceable. */
|
||||
fun token(context: Context): String {
|
||||
val prefs = context.getSharedPreferences("pairing", Context.MODE_PRIVATE)
|
||||
prefs.getString("token", null)?.let { return it }
|
||||
val bytes = ByteArray(16).also { SecureRandom().nextBytes(it) }
|
||||
val token = bytes.joinToString("") { "%02x".format(it) }
|
||||
prefs.edit().putString("token", token).apply()
|
||||
return token
|
||||
}
|
||||
|
||||
/** Best-guess LAN IPv4 address of this device, or null when offline. */
|
||||
fun lanAddress(): String? {
|
||||
val candidates = runCatching {
|
||||
NetworkInterface.getNetworkInterfaces().asSequence()
|
||||
.filter { it.isUp && !it.isLoopback }
|
||||
.flatMap { it.inetAddresses.asSequence() }
|
||||
.filterIsInstance<Inet4Address>()
|
||||
.filter { it.isSiteLocalAddress }
|
||||
.map { it.hostAddress }
|
||||
.filterNotNull()
|
||||
.toList()
|
||||
}.getOrDefault(emptyList())
|
||||
return candidates.firstOrNull()
|
||||
}
|
||||
|
||||
fun remoteUrl(address: String): String = "http://$address:$PORT"
|
||||
|
||||
fun pairingUrl(context: Context, address: String): String =
|
||||
"${remoteUrl(address)}/?t=${token(context)}"
|
||||
}
|
||||
26
app/src/main/java/com/nodecast/tv/pairing/Qr.kt
Normal file
26
app/src/main/java/com/nodecast/tv/pairing/Qr.kt
Normal file
@@ -0,0 +1,26 @@
|
||||
package com.nodecast.tv.pairing
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.EncodeHintType
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||
|
||||
object Qr {
|
||||
|
||||
fun encode(content: String, size: Int, foreground: Int, background: Int = Color.WHITE): Bitmap {
|
||||
val hints = mapOf(
|
||||
EncodeHintType.MARGIN to 0,
|
||||
EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M,
|
||||
)
|
||||
val matrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints)
|
||||
val pixels = IntArray(size * size)
|
||||
for (y in 0 until size) {
|
||||
for (x in 0 until size) {
|
||||
pixels[y * size + x] = if (matrix.get(x, y)) foreground else background
|
||||
}
|
||||
}
|
||||
return Bitmap.createBitmap(pixels, size, size, Bitmap.Config.ARGB_8888)
|
||||
}
|
||||
}
|
||||
105
app/src/main/java/com/nodecast/tv/player/PlayerController.kt
Normal file
105
app/src/main/java/com/nodecast/tv/player/PlayerController.kt
Normal file
@@ -0,0 +1,105 @@
|
||||
package com.nodecast.tv.player
|
||||
|
||||
import android.content.Context
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.session.MediaSession
|
||||
|
||||
/**
|
||||
* Wraps ExoPlayer plus a MediaSession so the physical TV remote's play/pause
|
||||
* keys and Google Assistant work alongside the phone remote (TV-PP/TV-VC of
|
||||
* the TV app quality guidelines).
|
||||
*/
|
||||
class PlayerController(
|
||||
context: Context,
|
||||
private val onChanged: () -> Unit,
|
||||
) {
|
||||
|
||||
val player: ExoPlayer = ExoPlayer.Builder(context).build()
|
||||
private val mediaSession: MediaSession = MediaSession.Builder(context, player).build()
|
||||
|
||||
var channelName: String = ""
|
||||
private set
|
||||
var channelGroup: String = ""
|
||||
private set
|
||||
var errorMessage: String? = null
|
||||
private set
|
||||
|
||||
init {
|
||||
player.addListener(object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) = onChanged()
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) = onChanged()
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
errorMessage = error.errorCodeName
|
||||
onChanged()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
val state: String
|
||||
get() = when {
|
||||
errorMessage != null -> "error"
|
||||
player.playbackState == Player.STATE_BUFFERING -> "buffering"
|
||||
player.playbackState == Player.STATE_READY && player.playWhenReady -> "playing"
|
||||
player.playbackState == Player.STATE_READY -> "paused"
|
||||
else -> "idle"
|
||||
}
|
||||
|
||||
val hasMedia: Boolean
|
||||
get() = player.mediaItemCount > 0 && player.playbackState != Player.STATE_IDLE
|
||||
|
||||
fun play(url: String, name: String, group: String) {
|
||||
if (url.isEmpty()) return
|
||||
errorMessage = null
|
||||
channelName = name.ifEmpty { url }
|
||||
channelGroup = group
|
||||
val item = MediaItem.Builder()
|
||||
.setUri(url)
|
||||
.setMediaMetadata(MediaMetadata.Builder().setTitle(channelName).build())
|
||||
.build()
|
||||
player.setMediaItem(item)
|
||||
player.prepare()
|
||||
player.play()
|
||||
}
|
||||
|
||||
fun toggle() {
|
||||
if (!hasMedia) return
|
||||
if (player.isPlaying) player.pause() else resume()
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
if (player.isPlaying) player.pause()
|
||||
}
|
||||
|
||||
fun resume() {
|
||||
if (!hasMedia) return
|
||||
if (player.playbackState == Player.STATE_ENDED) player.seekToDefaultPosition()
|
||||
player.play()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
player.stop()
|
||||
player.clearMediaItems()
|
||||
channelName = ""
|
||||
channelGroup = ""
|
||||
errorMessage = null
|
||||
onChanged()
|
||||
}
|
||||
|
||||
fun seekBy(deltaSeconds: Long) {
|
||||
if (!hasMedia || !player.isCurrentMediaItemSeekable) return
|
||||
val target = (player.currentPosition + deltaSeconds * 1000)
|
||||
.coerceIn(0, player.duration.coerceAtLeast(0))
|
||||
player.seekTo(target)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
mediaSession.release()
|
||||
player.release()
|
||||
}
|
||||
}
|
||||
35
app/src/main/java/com/nodecast/tv/playlist/Channel.kt
Normal file
35
app/src/main/java/com/nodecast/tv/playlist/Channel.kt
Normal file
@@ -0,0 +1,35 @@
|
||||
package com.nodecast.tv.playlist
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
data class Channel(
|
||||
val name: String,
|
||||
val url: String,
|
||||
val group: String,
|
||||
val logo: String,
|
||||
) {
|
||||
fun toJson(): JSONObject = JSONObject()
|
||||
.put("name", name)
|
||||
.put("url", url)
|
||||
.put("group", group)
|
||||
.put("logo", logo)
|
||||
|
||||
companion object {
|
||||
fun fromJson(obj: JSONObject): Channel = Channel(
|
||||
name = obj.optString("name"),
|
||||
url = obj.optString("url"),
|
||||
group = obj.optString("group"),
|
||||
logo = obj.optString("logo"),
|
||||
)
|
||||
|
||||
fun listToJson(channels: List<Channel>): JSONArray {
|
||||
val arr = JSONArray()
|
||||
channels.forEach { arr.put(it.toJson()) }
|
||||
return arr
|
||||
}
|
||||
|
||||
fun listFromJson(arr: JSONArray): List<Channel> =
|
||||
(0 until arr.length()).map { fromJson(arr.getJSONObject(it)) }
|
||||
}
|
||||
}
|
||||
48
app/src/main/java/com/nodecast/tv/playlist/M3uParser.kt
Normal file
48
app/src/main/java/com/nodecast/tv/playlist/M3uParser.kt
Normal file
@@ -0,0 +1,48 @@
|
||||
package com.nodecast.tv.playlist
|
||||
|
||||
object M3uParser {
|
||||
|
||||
private const val MAX_CHANNELS = 5000
|
||||
|
||||
private val attrRegex = Regex("""([\w-]+)="([^"]*)"""")
|
||||
|
||||
fun parse(content: String): List<Channel> {
|
||||
val channels = mutableListOf<Channel>()
|
||||
var name = ""
|
||||
var group = ""
|
||||
var logo = ""
|
||||
var pendingInfo = false
|
||||
|
||||
for (rawLine in content.lineSequence()) {
|
||||
val line = rawLine.trim()
|
||||
when {
|
||||
line.startsWith("#EXTINF", ignoreCase = true) -> {
|
||||
val attrs = attrRegex.findAll(line).associate { it.groupValues[1].lowercase() to it.groupValues[2] }
|
||||
group = attrs["group-title"].orEmpty()
|
||||
logo = attrs["tvg-logo"].orEmpty()
|
||||
name = line.substringAfterLast(',', "").trim()
|
||||
if (name.isEmpty()) name = attrs["tvg-name"].orEmpty()
|
||||
pendingInfo = true
|
||||
}
|
||||
line.startsWith("#EXTGRP", ignoreCase = true) -> {
|
||||
group = line.substringAfter(':', "").trim()
|
||||
}
|
||||
line.isEmpty() || line.startsWith("#") -> Unit
|
||||
pendingInfo -> {
|
||||
channels.add(Channel(name.ifEmpty { line }, line, group, logo))
|
||||
if (channels.size >= MAX_CHANNELS) return channels
|
||||
name = ""
|
||||
group = ""
|
||||
logo = ""
|
||||
pendingInfo = false
|
||||
}
|
||||
else -> {
|
||||
// Bare URL without #EXTINF — still a playable entry.
|
||||
channels.add(Channel(line, line, "", ""))
|
||||
if (channels.size >= MAX_CHANNELS) return channels
|
||||
}
|
||||
}
|
||||
}
|
||||
return channels
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.nodecast.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"
|
||||
}
|
||||
}
|
||||
244
app/src/main/java/com/nodecast/tv/server/ControlServer.kt
Normal file
244
app/src/main/java/com/nodecast/tv/server/ControlServer.kt
Normal file
@@ -0,0 +1,244 @@
|
||||
package com.nodecast.tv.server
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import com.nodecast.tv.pairing.Pairing
|
||||
import com.nodecast.tv.playlist.Channel
|
||||
import fi.iki.elonen.NanoHTTPD
|
||||
import fi.iki.elonen.NanoWSD
|
||||
import org.json.JSONObject
|
||||
import java.io.IOException
|
||||
import java.util.Timer
|
||||
import java.util.TimerTask
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
/**
|
||||
* Embedded HTTP + WebSocket server. Serves the phone remote (a single HTML
|
||||
* page) over HTTP and takes playback commands over a WebSocket. A client
|
||||
* authorizes itself with the four-digit pairing code from the QR code.
|
||||
*/
|
||||
class ControlServer(
|
||||
private val context: Context,
|
||||
private val listener: Listener,
|
||||
) : NanoWSD(Pairing.PORT) {
|
||||
|
||||
interface Listener {
|
||||
fun onPlay(url: String, name: String, group: String)
|
||||
fun onTogglePlay()
|
||||
fun onPause()
|
||||
fun onResume()
|
||||
fun onStopCast()
|
||||
fun onSeek(deltaSeconds: Long)
|
||||
fun onVolume(value: Float)
|
||||
fun onSetPlaylist(url: String)
|
||||
fun onClientsChanged(count: Int, newestName: String?)
|
||||
fun currentStatus(): JSONObject
|
||||
fun currentChannels(): List<Channel>
|
||||
fun currentPlaylistUrl(): String
|
||||
}
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val clients = CopyOnWriteArrayList<RemoteSocket>()
|
||||
private val pairingCode = Pairing.code(context)
|
||||
private val pairingToken = Pairing.token(context)
|
||||
private var pingTimer: Timer? = null
|
||||
|
||||
// Rate limit for the human-typable 4-digit code (the QR token is not
|
||||
// brute-forceable and stays exempt).
|
||||
private val codeAttempts = ArrayDeque<Long>()
|
||||
|
||||
@Synchronized
|
||||
private fun codeAttemptAllowed(): Boolean {
|
||||
val now = System.currentTimeMillis()
|
||||
while (codeAttempts.isNotEmpty() && now - codeAttempts.first() > CODE_ATTEMPT_WINDOW_MS) {
|
||||
codeAttempts.removeFirst()
|
||||
}
|
||||
if (codeAttempts.size >= CODE_ATTEMPT_MAX) return false
|
||||
codeAttempts.addLast(now)
|
||||
return true
|
||||
}
|
||||
|
||||
fun startServer() {
|
||||
start(0, true)
|
||||
pingTimer = Timer("ws-ping", true).also {
|
||||
it.schedule(object : TimerTask() {
|
||||
override fun run() = pingClients()
|
||||
}, PING_INTERVAL_MS, PING_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
fun stopServer() {
|
||||
pingTimer?.cancel()
|
||||
pingTimer = null
|
||||
stop()
|
||||
}
|
||||
|
||||
// --- HTTP ---
|
||||
|
||||
override fun serveHttp(session: IHTTPSession): Response {
|
||||
return when (session.uri) {
|
||||
"/", "/index.html" -> {
|
||||
val html = context.assets.open("remote/index.html").bufferedReader().use { it.readText() }
|
||||
newFixedLengthResponse(Response.Status.OK, "text/html; charset=utf-8", html).apply {
|
||||
addHeader("Cache-Control", "no-store")
|
||||
}
|
||||
}
|
||||
else -> newFixedLengthResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, "not found")
|
||||
}
|
||||
}
|
||||
|
||||
// --- WebSocket ---
|
||||
|
||||
override fun openWebSocket(handshake: IHTTPSession): WebSocket = RemoteSocket(handshake)
|
||||
|
||||
fun broadcast(message: JSONObject) {
|
||||
val payload = message.toString()
|
||||
clients.forEach { client ->
|
||||
if (client.authorized) client.trySend(payload)
|
||||
}
|
||||
}
|
||||
|
||||
fun broadcastStatus() {
|
||||
broadcast(listener.currentStatus().put("type", "status"))
|
||||
}
|
||||
|
||||
fun broadcastChannels() {
|
||||
broadcast(channelsMessage())
|
||||
}
|
||||
|
||||
fun broadcastToast(message: String) {
|
||||
broadcast(JSONObject().put("type", "toast").put("message", message))
|
||||
}
|
||||
|
||||
private fun channelsMessage(): JSONObject = JSONObject()
|
||||
.put("type", "channels")
|
||||
.put("playlistUrl", listener.currentPlaylistUrl())
|
||||
.put("channels", Channel.listToJson(listener.currentChannels()))
|
||||
|
||||
private fun pingClients() {
|
||||
clients.forEach { client ->
|
||||
try {
|
||||
client.ping(PING_PAYLOAD)
|
||||
} catch (e: IOException) {
|
||||
Log.d(TAG, "ping failed, dropping client", e)
|
||||
clients.remove(client)
|
||||
}
|
||||
}
|
||||
notifyClientsChanged(null)
|
||||
}
|
||||
|
||||
private fun notifyClientsChanged(newestName: String?) {
|
||||
val count = clients.count { it.authorized }
|
||||
mainHandler.post { listener.onClientsChanged(count, newestName) }
|
||||
}
|
||||
|
||||
inner class RemoteSocket(handshake: IHTTPSession) : WebSocket(handshake) {
|
||||
|
||||
@Volatile
|
||||
var authorized = false
|
||||
private set
|
||||
|
||||
private var deviceName: String = ""
|
||||
|
||||
fun trySend(payload: String) {
|
||||
try {
|
||||
send(payload)
|
||||
} catch (e: IOException) {
|
||||
Log.d(TAG, "send failed, dropping client", e)
|
||||
clients.remove(this)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOpen() {
|
||||
clients.add(this)
|
||||
}
|
||||
|
||||
override fun onClose(code: WebSocketFrame.CloseCode?, reason: String?, initiatedByRemote: Boolean) {
|
||||
clients.remove(this)
|
||||
notifyClientsChanged(null)
|
||||
}
|
||||
|
||||
override fun onMessage(message: WebSocketFrame) {
|
||||
val msg = runCatching { JSONObject(message.textPayload) }.getOrNull() ?: return
|
||||
val type = msg.optString("type")
|
||||
if (!authorized) {
|
||||
if (type == "hello") handleHello(msg)
|
||||
return
|
||||
}
|
||||
when (type) {
|
||||
"play" -> post {
|
||||
listener.onPlay(
|
||||
msg.optString("url"),
|
||||
msg.optString("name"),
|
||||
msg.optString("group"),
|
||||
)
|
||||
}
|
||||
"toggle" -> post { listener.onTogglePlay() }
|
||||
"pause" -> post { listener.onPause() }
|
||||
"resume" -> post { listener.onResume() }
|
||||
"stop" -> post { listener.onStopCast() }
|
||||
"seek" -> post { listener.onSeek(msg.optLong("delta")) }
|
||||
"volume" -> post { listener.onVolume(msg.optDouble("value", 1.0).toFloat().coerceIn(0f, 1f)) }
|
||||
"set_playlist" -> post { listener.onSetPlaylist(msg.optString("url")) }
|
||||
"get_state" -> {
|
||||
trySend(listener.currentStatus().put("type", "status").toString())
|
||||
trySend(channelsMessage().toString())
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPong(pong: WebSocketFrame?) = Unit
|
||||
|
||||
override fun onException(exception: IOException?) {
|
||||
clients.remove(this)
|
||||
}
|
||||
|
||||
private fun handleHello(msg: JSONObject) {
|
||||
val token = msg.optString("token")
|
||||
val tokenOk = token.isNotEmpty() && token == pairingToken
|
||||
val codeOk = !tokenOk && msg.optString("code").let { code ->
|
||||
code.isNotEmpty() && when {
|
||||
!codeAttemptAllowed() -> {
|
||||
trySend(JSONObject().put("type", "error").put("error", "rate_limited").toString())
|
||||
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "rate limited", false) }
|
||||
return
|
||||
}
|
||||
else -> code == pairingCode
|
||||
}
|
||||
}
|
||||
if (!tokenOk && !codeOk) {
|
||||
trySend(JSONObject().put("type", "error").put("error", "bad_code").toString())
|
||||
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "bad code", false) }
|
||||
return
|
||||
}
|
||||
authorized = true
|
||||
deviceName = msg.optString("name").ifEmpty { "Handy" }
|
||||
trySend(
|
||||
JSONObject()
|
||||
.put("type", "welcome")
|
||||
.put("device", android.os.Build.MODEL)
|
||||
.put("token", pairingToken)
|
||||
.put("status", listener.currentStatus())
|
||||
.put("playlistUrl", listener.currentPlaylistUrl())
|
||||
.put("channels", Channel.listToJson(listener.currentChannels()))
|
||||
.toString()
|
||||
)
|
||||
notifyClientsChanged(deviceName)
|
||||
}
|
||||
|
||||
private fun post(action: () -> Unit) {
|
||||
mainHandler.post(action)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ControlServer"
|
||||
const val PING_INTERVAL_MS = 8_000L
|
||||
const val CODE_ATTEMPT_WINDOW_MS = 60_000L
|
||||
const val CODE_ATTEMPT_MAX = 5
|
||||
val PING_PAYLOAD = byteArrayOf(0x6e, 0x63)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user