continuous-integration/drone/push Build is passing
TypeScript:
- menu_ts.ts split into menu.ts, content.ts, popup.ts and xmltv.ts; the
other files drop their _ts suffix. Bundle order fixed in ts/tsconfig.json.
- All 94 string event handlers (setAttribute("onclick", "javascript: ..."))
replaced with addEventListener closures. changeButtonAction, which
rewrote an onclick attribute from select values, is gone.
- Settings rows are generated from one SETTINGS_FIELDS table instead of
~20 copy-pasted blocks; rendered HTML is byte-identical to before.
saveSettings now serialises password inputs, so a changed Plex token is
actually sent (the server treats the mask as unchanged).
- createLayout rebuilds the menu list only when the set of visible items
changes, so focus in the menu survives a refresh.
- announce()/alertUser(): alerts are mirrored into an aria-live region.
- Filter popup offers a third type, Regular Expression (regex-filter), and
the filter table labels it. Playlist popups get a Buffer select
(default / none / xTeVe / FFmpeg / VLC) saved as the playlist's
"buffer" parameter; the tuner field stays editable when the
playlist's own buffer is active.
Tests: tests/ holds 27 jsdom tests (Node's built-in runner, jsdom pinned)
that load the built bundle with a fixture server payload: menu visibility
rules, mapping table renders names as text, settings panel fields, popup
flows, sorting, bulk select, layout refresh keeps nodes, live region,
regex option, buffer select. npm test runs in the Drone webui-check step.
README-DEV documents the layout and the test workflow.
306 lines
7.6 KiB
TypeScript
306 lines
7.6 KiB
TypeScript
// Websocket transport between the web UI and xTeVe.
|
|
//
|
|
// One socket stays open for the life of the page. Requests are queued and
|
|
// sent one at a time; each carries an id that the server echoes back, and
|
|
// the response is matched on it (or, for older servers that close after a
|
|
// single reply, to the oldest in-flight request). Connection loss triggers
|
|
// a reconnect with exponential backoff, and queued requests survive it.
|
|
|
|
interface PendingRequest {
|
|
id: string
|
|
payload: Object
|
|
resolve: (response: any) => void
|
|
reject: (err: Error) => void
|
|
timer: number
|
|
sent: boolean
|
|
}
|
|
|
|
class WSClient {
|
|
private socket: WebSocket = null
|
|
private queue: PendingRequest[] = []
|
|
private inFlight: PendingRequest = null
|
|
private nextID: number = 1
|
|
private reconnectDelay: number = 500
|
|
private reconnectTimer: number = 0
|
|
private closedByUs: boolean = false
|
|
|
|
readonly requestTimeoutMs: number = 12000
|
|
|
|
private url(): string {
|
|
var protocol = window.location.protocol == "https:" ? "wss://" : "ws://"
|
|
var host = window.location.host
|
|
if (host == undefined || host.length < 1) {
|
|
host = window.location.hostname
|
|
}
|
|
return protocol + host + "/data/"
|
|
}
|
|
|
|
// send queues a command and resolves with the parsed response.
|
|
send(payload: Object): Promise<any> {
|
|
var client = this
|
|
return new Promise(function(resolve, reject) {
|
|
var req: PendingRequest = {
|
|
id: String(client.nextID++),
|
|
payload: payload,
|
|
resolve: resolve,
|
|
reject: reject,
|
|
timer: 0,
|
|
sent: false
|
|
}
|
|
client.queue.push(req)
|
|
client.pump()
|
|
})
|
|
}
|
|
|
|
// hasQueued reports whether a command of this kind is already waiting.
|
|
hasQueued(cmd: string): boolean {
|
|
if (this.inFlight != null && this.inFlight.payload["cmd"] == cmd) {
|
|
return true
|
|
}
|
|
for (var i = 0; i < this.queue.length; i++) {
|
|
if (this.queue[i].payload["cmd"] == cmd) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
private connect(): void {
|
|
if (this.socket != null && (this.socket.readyState == WebSocket.OPEN || this.socket.readyState == WebSocket.CONNECTING)) {
|
|
return
|
|
}
|
|
|
|
var client = this
|
|
var socket = new WebSocket(this.url())
|
|
this.socket = socket
|
|
|
|
socket.onopen = function() {
|
|
WS_AVAILABLE = true
|
|
client.reconnectDelay = 500
|
|
client.pump()
|
|
}
|
|
|
|
socket.onmessage = function(e: MessageEvent) {
|
|
client.handleMessage(e.data)
|
|
}
|
|
|
|
socket.onerror = function() {
|
|
// onclose follows; nothing to do here.
|
|
}
|
|
|
|
socket.onclose = function() {
|
|
if (client.socket == socket) {
|
|
client.socket = null
|
|
}
|
|
// An in-flight request that never got its answer goes back to the
|
|
// front of the queue and is retried on the next connection.
|
|
if (client.inFlight != null) {
|
|
var req = client.inFlight
|
|
client.inFlight = null
|
|
req.sent = false
|
|
client.queue.unshift(req)
|
|
}
|
|
client.scheduleReconnect()
|
|
}
|
|
}
|
|
|
|
private scheduleReconnect(): void {
|
|
if (this.closedByUs || this.reconnectTimer != 0) {
|
|
return
|
|
}
|
|
if (this.queue.length == 0) {
|
|
// Idle: reconnect lazily on the next request.
|
|
return
|
|
}
|
|
var client = this
|
|
this.reconnectTimer = window.setTimeout(function() {
|
|
client.reconnectTimer = 0
|
|
client.connect()
|
|
}, this.reconnectDelay)
|
|
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 10000)
|
|
}
|
|
|
|
// pump sends the next queued request if the socket is open and idle.
|
|
private pump(): void {
|
|
if (this.inFlight != null || this.queue.length == 0) {
|
|
return
|
|
}
|
|
if (this.socket == null || this.socket.readyState != WebSocket.OPEN) {
|
|
this.connect()
|
|
return
|
|
}
|
|
|
|
var req = this.queue.shift()
|
|
this.inFlight = req
|
|
req.sent = true
|
|
req.payload["id"] = req.id
|
|
|
|
var client = this
|
|
req.timer = window.setTimeout(function() {
|
|
if (client.inFlight == req) {
|
|
client.inFlight = null
|
|
req.reject(new Error("timeout"))
|
|
// A socket that stopped answering is not trusted any more.
|
|
client.resetSocket()
|
|
client.pump()
|
|
}
|
|
}, this.requestTimeoutMs)
|
|
|
|
try {
|
|
this.socket.send(JSON.stringify(req.payload))
|
|
} catch (err) {
|
|
window.clearTimeout(req.timer)
|
|
this.inFlight = null
|
|
req.sent = false
|
|
this.queue.unshift(req)
|
|
this.resetSocket()
|
|
}
|
|
}
|
|
|
|
private resetSocket(): void {
|
|
var socket = this.socket
|
|
this.socket = null
|
|
if (socket != null) {
|
|
socket.onclose = null
|
|
try { socket.close() } catch (err) { /* ignore */ }
|
|
}
|
|
this.scheduleReconnect()
|
|
}
|
|
|
|
private handleMessage(raw: string): void {
|
|
var response: any
|
|
try {
|
|
response = JSON.parse(raw)
|
|
} catch (err) {
|
|
return
|
|
}
|
|
|
|
var req = this.inFlight
|
|
if (req == null) {
|
|
return
|
|
}
|
|
// Servers that echo the id let us drop stray late answers.
|
|
if (response.hasOwnProperty("id") && response["id"] != req.id) {
|
|
return
|
|
}
|
|
|
|
window.clearTimeout(req.timer)
|
|
this.inFlight = null
|
|
req.resolve(response)
|
|
this.pump()
|
|
}
|
|
}
|
|
|
|
var WS = new WSClient()
|
|
|
|
class Server {
|
|
cmd: string
|
|
|
|
constructor(cmd: string) {
|
|
this.cmd = cmd
|
|
}
|
|
|
|
request(data: Object): any {
|
|
|
|
var isLogUpdate: boolean = this.cmd == "updateLog"
|
|
|
|
// Log polling must never pile up behind a stalled connection.
|
|
if (isLogUpdate && WS.hasQueued("updateLog")) {
|
|
return
|
|
}
|
|
|
|
if (!isLogUpdate) {
|
|
showElement("loading", true)
|
|
UNDO = new Object()
|
|
setConnectionState("busy")
|
|
}
|
|
|
|
data["cmd"] = this.cmd
|
|
var requestCmd: string = this.cmd
|
|
|
|
WS.send(data).then(function(response) {
|
|
|
|
WS_FAILURE_COUNT = 0
|
|
if (!isLogUpdate) {
|
|
showElement("loading", false)
|
|
}
|
|
setConnectionState("online")
|
|
|
|
if (response["status"] == false) {
|
|
setConnectionState("offline")
|
|
alertUser(response["err"])
|
|
if (response.hasOwnProperty("reload")) {
|
|
location.reload()
|
|
}
|
|
return
|
|
}
|
|
|
|
if (response.hasOwnProperty("logoURL")) {
|
|
var div = (document.getElementById("channel-icon") as HTMLInputElement)
|
|
div.value = response["logoURL"]
|
|
div.className = "changed"
|
|
return
|
|
}
|
|
|
|
switch (requestCmd) {
|
|
case "updateLog":
|
|
SERVER["log"] = response["log"]
|
|
if (document.getElementById("content_log")) {
|
|
showLogs(false)
|
|
}
|
|
return
|
|
|
|
default:
|
|
SERVER = response
|
|
break
|
|
}
|
|
|
|
if (response.hasOwnProperty("openMenu")) {
|
|
var menu = document.getElementById(response["openMenu"])
|
|
menu.click()
|
|
showElement("popup", false)
|
|
}
|
|
|
|
if (response.hasOwnProperty("openLink")) {
|
|
window.location = response["openLink"]
|
|
}
|
|
|
|
if (response.hasOwnProperty("alert")) {
|
|
alertUser(response["alert"])
|
|
}
|
|
|
|
if (response.hasOwnProperty("reload")) {
|
|
location.reload()
|
|
}
|
|
|
|
if (response.hasOwnProperty("wizard")) {
|
|
createLayout()
|
|
configurationWizard[response["wizard"]].createWizard()
|
|
return
|
|
}
|
|
|
|
createLayout()
|
|
|
|
}, function(err) {
|
|
|
|
WS_FAILURE_COUNT++
|
|
if (!isLogUpdate) {
|
|
showElement("loading", false)
|
|
}
|
|
|
|
var state: string = "offline"
|
|
if (isLogUpdate && WS_FAILURE_COUNT < 2) {
|
|
state = "idle"
|
|
}
|
|
setConnectionState(state)
|
|
|
|
if (WS_AVAILABLE == false && !isLogUpdate && requestCmd != "getServerConfig") {
|
|
alertUser("No websocket connection to xTeVe could be established. Check your network configuration.")
|
|
}
|
|
|
|
})
|
|
}
|
|
}
|
|
|
|
var WS_FAILURE_COUNT: number = 0
|