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:
be-nj
2026-08-25 00:09:00 +02:00
commit a11b5a68c0
44 changed files with 2418 additions and 0 deletions

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

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

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

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

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

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

View File

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

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