From bb8ef2203469e949700499499e101354dfb1fe1f Mon Sep 17 00:00:00 2001 From: Joel Klinghed Date: Thu, 23 Oct 2025 00:20:35 +0200 Subject: client: Add very basic client --- client/main.js | 286 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 client/main.js (limited to 'client/main.js') diff --git a/client/main.js b/client/main.js new file mode 100644 index 0000000..e6c46f6 --- /dev/null +++ b/client/main.js @@ -0,0 +1,286 @@ +import { api } from "./api.js"; + +let bad_monitor = false; +let last_controller = undefined; + +function show_error(msg) { + const err = document.getElementById("error"); + err.textContent = msg; + err.classList.remove("invisible"); +} + +function reset_error() { + const err = document.getElementById("error"); + err.classList.add("invisible"); +} + +function create_device(device) { + const element = document.createElement("li"); + element.id = `dev_${device.address}`; + const text = document.createElement("span"); + text.classList.add("device_text"); + element.appendChild(text); + const play = document.createElement("button"); + play.classList.add("device_play"); + play.classList.add("material-symbols-outlined"); + play.textContent = "play_arrow"; + play.addEventListener("click", () => { + api.device_action(device.address, "play") + .then(() => { + reset_error(); + if (bad_monitor) { + fetch_device(device.address); + } + }) + .catch((err) => { + show_error(`Unable to start playing: ${err}`); + }); + }); + element.appendChild(play); + const pause = document.createElement("button"); + pause.classList.add("device_pause"); + pause.classList.add("material-symbols-outlined"); + pause.textContent = "pause"; + pause.addEventListener("click", () => { + api.device_action(device.address, "pause") + .then(() => { + reset_error(); + if (bad_monitor) { + fetch_device(device.address); + } + }) + .catch((err) => { + show_error(`Unable to pause: ${err}`); + }); + }); + element.appendChild(pause); + return element; +} + +function update_device(child, device) { + const text = child.children.item(0); + const play = child.children.item(1); + const pause = child.children.item(2); + if (device.playing !== null) { + text.textContent = `${device.name} ${device.playing.status} ${device.playing.title}`; + play.classList.remove("invisible"); + play.disabled = device.playing.status === "playing"; + pause.classList.remove("invisible"); + pause.disabled = !play.disabled; + } else { + text.textContent = device.name; + play.classList.add("invisible"); + pause.classList.add("invisible"); + } +} + +function upsert_device(list, index, device) { + let child; + if (index >= list.childElementCount) { + child = create_device(device); + list.appendChild(child); + } else { + child = list.children.item(index); + } + child.id = `dev_${device.address}`; + update_device(child, device); +} + +function remove_devices(list, index) { + while (index < list.childElementCount) { + list.removeChild(list.children.item(index)); + } +} + +function update_playing(controller) { + const list = document.getElementById("playing"); + let index = 0; + controller.devices.forEach((device) => { + if (device.paired && device.connected && device.playing !== null) { + upsert_device(list, index++, device); + } + }); + remove_devices(list, index); +} + +function update_connected(controller) { + const list = document.getElementById("connected"); + let index = 0; + controller.devices.forEach((device) => { + if (device.paired && device.connected && device.playing === null) { + upsert_device(list, index++, device); + } + }); + remove_devices(list, index); +} + +function update_paired(controller) { + const list = document.getElementById("paired"); + let index = 0; + controller.devices.forEach((device) => { + if (device.paired && !device.connected) { + upsert_device(list, index++, device); + } + }); + remove_devices(list, index); +} + +function fetch_controller() { + api.controller() + .then((controller) => { + reset_error(); + last_controller = controller; + + const enable_pairing = document.getElementById("enable_pairing"); + const disable_pairing = document.getElementById("disable_pairing"); + const pairing_status = document.getElementById("pairing_status"); + if (controller.discoverable === true) { + enable_pairing.classList.add("invisible"); + enable_pairing.disabled = true; + disable_pairing.classList.remove("invisible"); + disable_pairing.disabled = false; + pairing_status.textContent = `Pair with ${controller.name}...`; + pairing_status.classList.remove("invisible"); + } else { + enable_pairing.classList.remove("invisible"); + enable_pairing.disabled = controller.pairable !== true; + disable_pairing.classList.add("invisible"); + disable_pairing.disabled = true; + pairing_status.classList.add("invisible"); + } + + update_playing(controller); + update_connected(controller); + update_paired(controller); + }) + .catch((err) => { + console.err(err); + show_error("Unable to get controller from api"); + }); +} + +function fetch_device(address) { + api.device(address) + .then((device) => { + reset_error(); + + let parent; + if (device.paired && device.connected) { + if (device.playing !== null) { + parent = document.getElementById("playing"); + } else { + parent = document.getElementById("connected"); + } + } else if (device.paired) { + parent = document.getElementById("paired"); + } else { + parent = null; + } + + let child = document.getElementById(`dev_${device.address}`); + if (child === null) { + if (parent === null) return; + + child = create_device(device); + } + update_device(child, device); + + if (parent === null) { + child.parentElement.removeChild(child); + } else if (parent !== child.parentElement) { + parent.appendChild(child); + } + }) + .catch(() => { + // Fallback to updating whole controller if device fails + fetch_controller(); + }); +} + +function monitor() { + const schema = location.protocol === "https:" ? "wss:" : "ws:"; + let path = location.pathname; + if (path.endsWith("/index.html")) { + path = path.substring(0, path.length - 10); + } + const url = `${schema}//${location.host}${path}api/v1/events`; + const socket = new WebSocket(url); + socket.addEventListener("message", (event) => { + if (event.data === "controller/update") { + fetch_controller(); + } else if (event.data.startsWith("device/update/")) { + const device_address = event.data.substring(14); + fetch_device(device_address); + } + }); + socket.addEventListener("error", (event) => { + bad_monitor = true; + console.error(event); + }); + socket.addEventListener("close", () => { + bad_monitor = true; + }); + socket.addEventListener("open", () => { + bad_monitor = false; + }); +} + +function init() { + const enable_pairing = document.getElementById("enable_pairing"); + const disable_pairing = document.getElementById("disable_pairing"); + + enable_pairing.addEventListener("click", () => { + enable_pairing.disabled = true; + api.controller_discoverable("true") + .then(() => { + if (bad_monitor) { + fetch_controller(); + if ( + last_controller !== undefined && + last_controller.discover_timeout_seconds > 0 + ) { + setTimeout(() => { + fetch_controller(); + }, last_controller.discover_timeout_seconds * 1000); + } + } + }) + .catch((err) => { + show_error(`Unable to enable pairing: ${err}`); + }); + }); + disable_pairing.addEventListener("click", () => { + disable_pairing.disabled = true; + api.controller_discoverable("false") + .then(() => { + if (bad_monitor) fetch_controller(); + }) + .catch((err) => { + show_error(`Unable to disable pairing: ${err}`); + }); + }); + + api.status() + .then(() => { + fetch_controller(); + + monitor(); + document.addEventListener("visibilitychange", () => { + if (bad_monitor && !document.hidden) { + // Try to reconnect websocket when page is activated. + monitor(); + } + }); + }) + .catch((err) => { + show_error(`Unable to connect to api: ${err}`); + }); +} + +if (document.readyState === "loading") { + addEventListener("DOMContentLoaded", () => { + init(); + }); +} else { + init(); +} -- cgit v1.2.3-70-g09d2