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>
245 lines
8.4 KiB
Kotlin
245 lines
8.4 KiB
Kotlin
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)
|
|
}
|
|
}
|