/** * PaperDash — E-Ink Dashboard Client * * Connects to the PaperDash server via WebSocket. * Uses vanilla JS with direct DOM manipulation (element.textContent) * to minimize E-Ink refresh flashing. * * Strings are loaded from Android resources via the JavaScript interface. */ (() => { const SERVER_WS = `ws://${window.location.host}/ws`; const SERVER_API = `http://${window.location.host}`; const PIN_INPUT = document.getElementById("pin-input"); const CONNECT_BTN = document.getElementById("connect-btn"); const LOGIN_ERROR = document.getElementById("login-error"); const LOGIN_SCREEN = document.getElementById("login-screen"); const DASHBOARD_SCREEN = document.getElementById("dashboard-screen"); const SETTINGS_SCREEN = document.getElementById("settings-screen"); const SETTINGS_TOKEN_INPUT = document.getElementById("settings-token-input"); const SETTINGS_ACCESS_BTN = document.getElementById("settings-access-btn"); const SETTINGS_ERROR = document.getElementById("settings-error"); const SETTINGS_CONTENT = document.getElementById("settings-content"); const SETTINGS_PIN = document.getElementById("settings-pin"); const SETTINGS_TOKEN = document.getElementById("settings-token"); const SETTINGS_SAVE_BTN = document.getElementById("settings-save-btn"); const SETTINGS_BACK_BTN = document.getElementById("settings-back-btn"); const SETTINGS_STATUS = document.getElementById("settings-status"); const BTN_SETTINGS = document.getElementById("btn-settings"); let ws = null; let reconnectTimer = null; let currentApiToken = ""; const strings = {}; // ----------------------------------------------------------------------- // Localization // ----------------------------------------------------------------------- function loadStrings() { if (window.Android && window.Android.getString) { const keys = [ "login_error_enter_pin", "connecting", "login_error_disconnected", "no_playback", "mute", "muted", "no_notifications", "eta_prefix", "settings_title", "settings_subtitle", "access", "dashboard_pin_label", "api_token_label", "save", "back", "settings_error_invalid_token", "settings_saved", "settings_error_save_failed", ]; keys.forEach((key) => { strings[key] = window.Android.getString(key); }); } applyStrings(); } function applyStrings() { // Apply data-i18n attributes document.querySelectorAll("[data-i18n]").forEach((el) => { const key = el.getAttribute("data-i18n"); if (strings[key]) { el.textContent = strings[key]; } }); // Apply data-i18n-placeholder attributes document.querySelectorAll("[data-i18n-placeholder]").forEach((el) => { const key = el.getAttribute("data-i18n-placeholder"); if (strings[key]) { el.placeholder = strings[key]; } }); } // Load strings when DOM is ready if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", loadStrings); } else { loadStrings(); } // ----------------------------------------------------------------------- // PIN Login // ----------------------------------------------------------------------- function connect() { const pin = PIN_INPUT.value.trim(); if (!pin) { LOGIN_ERROR.textContent = strings.login_error_enter_pin || "Enter a PIN"; return; } LOGIN_ERROR.textContent = ""; CONNECT_BTN.disabled = true; CONNECT_BTN.textContent = strings.connecting || "Connecting..."; ws = new WebSocket(`${SERVER_WS}?pin=${encodeURIComponent(pin)}`); ws.onopen = () => { LOGIN_SCREEN.classList.remove("active"); DASHBOARD_SCREEN.classList.add("active"); CONNECT_BTN.disabled = false; CONNECT_BTN.textContent = strings.login_button || "Connect"; }; ws.onmessage = (event) => { try { const data = JSON.parse(event.data); handleStateUpdate(data); } catch (err) { console.error("Failed to parse WebSocket message:", err); } }; ws.onclose = () => { DASHBOARD_SCREEN.classList.remove("active"); LOGIN_SCREEN.classList.add("active"); LOGIN_ERROR.textContent = strings.login_error_disconnected || "Disconnected. Re-enter PIN."; CONNECT_BTN.disabled = false; CONNECT_BTN.textContent = strings.login_button || "Connect"; // Auto-reconnect after 5 seconds clearTimeout(reconnectTimer); reconnectTimer = setTimeout(() => { PIN_INPUT.value = pin; connect(); }, 5000); }; ws.onerror = () => { ws.close(); }; } CONNECT_BTN.addEventListener("click", connect); PIN_INPUT.addEventListener("keydown", (e) => { if (e.key === "Enter") connect(); }); // ----------------------------------------------------------------------- // State Updates // ----------------------------------------------------------------------- function handleStateUpdate(data) { if (data.media) updateMedia(data.media); if (data.audio) updateAudio(data.audio); if (data.telemetry) updateTelemetry(data.telemetry); if (data.notifications) updateNotifications(data.notifications); } // ----------------------------------------------------------------------- // Media // ----------------------------------------------------------------------- function updateMedia(media) { setText("track-title", media.title || strings.no_playback || "No playback"); setText("track-artist", media.artist || ""); setText("track-album", media.album || ""); // Artwork — use DOM API to avoid innerHTML const artworkContainer = document.getElementById("artwork-container"); artworkContainer.replaceChildren(); if (media.artwork_base64) { const img = document.createElement("img"); img.src = "data:image/jpeg;base64," + media.artwork_base64; img.alt = "Album art"; artworkContainer.appendChild(img); } // Progress const pct = media.playback_percentage || 0; setStyle("progress-fill", "width", pct + "%"); setText("progress-current", formatTime(media.playback_position || 0)); setText("progress-duration", formatTime(media.playback_duration || 0)); // Play/pause button const btn = document.getElementById("btn-playpause"); btn.textContent = media.is_playing ? "\u23F8" : "\u25B6"; } // ----------------------------------------------------------------------- // Audio Mixer // ----------------------------------------------------------------------- function updateAudio(audio) { const vol = Math.round((audio.master_volume || 0) * 100); setText("master-volume-value", vol + "%"); const muteBtn = document.getElementById("btn-mute"); if (audio.master_muted) { muteBtn.classList.add("muted"); muteBtn.textContent = strings.muted || "MUTED"; } else { muteBtn.classList.remove("muted"); muteBtn.textContent = strings.mute || "MUTE"; } // Channels — use DOM API const container = document.getElementById("channels-container"); container.replaceChildren(); if (!audio.channels || audio.channels.length === 0) { return; } const fragment = document.createDocumentFragment(); for (const ch of audio.channels) { const volPct = Math.round((ch.volume || 0) * 100); const div = document.createElement("div"); div.className = "channel"; if (ch.muted) { div.style.opacity = "0.4"; } const nameSpan = document.createElement("span"); nameSpan.className = "channel-name"; nameSpan.textContent = ch.name; const meterDiv = document.createElement("div"); meterDiv.className = "channel-meter"; meterDiv.textContent = renderBlockMeter(ch.volume || 0); const volSpan = document.createElement("span"); volSpan.className = "channel-volume"; volSpan.textContent = volPct + "%"; div.appendChild(nameSpan); div.appendChild(meterDiv); div.appendChild(volSpan); fragment.appendChild(div); } container.appendChild(fragment); } function renderBlockMeter(volume) { const blocks = 10; const filled = Math.round(volume * blocks); let result = ""; for (let i = 0; i < blocks; i++) { result += i < filled ? "\u2588" : "\u2591"; } return result; } // ----------------------------------------------------------------------- // Telemetry // ----------------------------------------------------------------------- function updateTelemetry(tel) { setText("cpu-temp", tel.cpu_temp ? tel.cpu_temp + "C" : "--"); setText("gpu-temp", tel.gpu_temp ? tel.gpu_temp + "C" : "--"); setText( "ram-usage", tel.ram_usage && tel.ram_total ? Math.round((tel.ram_usage / tel.ram_total) * 100) + "%" : "--", ); setText( "vram-usage", tel.vram_usage && tel.vram_total ? Math.round((tel.vram_usage / tel.vram_total) * 100) + "%" : "--", ); } // ----------------------------------------------------------------------- // Notifications // ----------------------------------------------------------------------- function updateNotifications(notifications) { const container = document.getElementById("notifications-container"); container.replaceChildren(); if (!notifications || notifications.length === 0) { const p = document.createElement("p"); p.style.color = "#888"; p.textContent = strings.no_notifications || "No notifications"; container.appendChild(p); return; } const fragment = document.createDocumentFragment(); for (const n of notifications) { if (n.type === "progress") { const pct = n.progress ? n.progress.percentage : 0; const blocks = renderBlockMeter(pct / 100); const div = document.createElement("div"); div.className = "notification"; const titleDiv = document.createElement("div"); titleDiv.className = "notification-title"; titleDiv.textContent = n.title; const msgDiv = document.createElement("div"); msgDiv.className = "notification-message"; msgDiv.textContent = n.message; const progDiv = document.createElement("div"); progDiv.className = "notification-progress"; progDiv.textContent = blocks + " " + Math.round(pct) + "%"; div.appendChild(titleDiv); div.appendChild(msgDiv); div.appendChild(progDiv); if (n.eta) { const etaDiv = document.createElement("div"); etaDiv.className = "notification-eta"; etaDiv.textContent = (strings.eta_prefix || "ETA:") + " " + n.eta; div.appendChild(etaDiv); } fragment.appendChild(div); } else { const cls = n.priority || "info"; const div = document.createElement("div"); div.className = "notification alert " + cls; const titleDiv = document.createElement("div"); titleDiv.className = "notification-title"; titleDiv.textContent = n.title; const msgDiv = document.createElement("div"); msgDiv.className = "notification-message"; msgDiv.textContent = n.message; div.appendChild(titleDiv); div.appendChild(msgDiv); fragment.appendChild(div); } } container.appendChild(fragment); } // ----------------------------------------------------------------------- // Controls // ----------------------------------------------------------------------- function sendCommand(action, extra) { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(Object.assign({ action: action }, extra || {}))); } } document.getElementById("btn-vol-up").addEventListener("click", () => { sendCommand("volume", { bus: "master", value: 0.05 }); }); document.getElementById("btn-vol-down").addEventListener("click", () => { sendCommand("volume", { bus: "master", value: -0.05 }); }); document.getElementById("btn-mute").addEventListener("click", function () { const isMuted = this.classList.contains("muted"); sendCommand("mute", { bus: "master", muted: !isMuted }); }); document.getElementById("btn-playpause").addEventListener("click", () => { sendCommand( ws && ws.readyState === WebSocket.OPEN ? document.getElementById("btn-playpause").textContent === "\u23F8" ? "pause" : "play" : "play", ); }); document.getElementById("btn-prev").addEventListener("click", () => { sendCommand("prev"); }); document.getElementById("btn-next").addEventListener("click", () => { sendCommand("skip"); }); // ----------------------------------------------------------------------- // Swipe gesture zones (E-Ink optimized) // ----------------------------------------------------------------------- let touchStartY = 0; const SWIPE_THRESHOLD = 70; document.addEventListener( "touchstart", (e) => { touchStartY = e.touches[0].clientY; }, { passive: true }, ); document.addEventListener( "touchend", (e) => { const deltaY = e.changedTouches[0].clientY - touchStartY; if (Math.abs(deltaY) > SWIPE_THRESHOLD) { if (deltaY < 0) { sendCommand("volume", { bus: "master", value: 0.1 }); } else { sendCommand("volume", { bus: "master", value: -0.1 }); } } }, { passive: true }, ); // ----------------------------------------------------------------------- // Helpers // ----------------------------------------------------------------------- function setText(id, text) { const el = document.getElementById(id); if (el && el.textContent !== text) { el.textContent = text; } } function setStyle(id, prop, value) { const el = document.getElementById(id); if (el) { el.style[prop] = value; } } function formatTime(seconds) { if (!seconds || isNaN(seconds)) return "0:00"; const m = Math.floor(seconds / 60); const s = Math.floor(seconds % 60); return m + ":" + (s < 10 ? "0" : "") + s; } // ----------------------------------------------------------------------- // Settings // ----------------------------------------------------------------------- function showSettings() { DASHBOARD_SCREEN.classList.remove("active"); SETTINGS_SCREEN.classList.add("active"); SETTINGS_ERROR.textContent = ""; SETTINGS_STATUS.textContent = ""; SETTINGS_TOKEN_INPUT.value = ""; SETTINGS_CONTENT.style.display = "none"; } function hideSettings() { SETTINGS_SCREEN.classList.remove("active"); DASHBOARD_SCREEN.classList.add("active"); } async function accessSettings() { const token = SETTINGS_TOKEN_INPUT.value.trim(); if (!token) { SETTINGS_ERROR.textContent = strings.settings_error_invalid_token || "Invalid token"; return; } try { const response = await fetch(`${SERVER_API}/api/v1/settings`, { headers: { Authorization: `Bearer ${token}`, }, }); if (!response.ok) { SETTINGS_ERROR.textContent = strings.settings_error_invalid_token || "Invalid token"; return; } const data = await response.json(); currentApiToken = token; SETTINGS_ERROR.textContent = ""; SETTINGS_PIN.value = data.dashboard_pin || ""; SETTINGS_TOKEN.value = data.api_token || ""; SETTINGS_CONTENT.style.display = "block"; } catch (err) { SETTINGS_ERROR.textContent = strings.settings_error_invalid_token || "Invalid token"; } } async function saveSettings() { const pin = SETTINGS_PIN.value.trim(); const token = SETTINGS_TOKEN.value.trim(); try { const response = await fetch(`${SERVER_API}/api/v1/settings`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${currentApiToken}`, }, body: JSON.stringify({ dashboard_pin: pin, api_token: token, }), }); if (!response.ok) { SETTINGS_STATUS.textContent = strings.settings_error_save_failed || "Failed to save settings"; return; } SETTINGS_STATUS.textContent = strings.settings_saved || "Settings saved"; currentApiToken = token; } catch (err) { SETTINGS_STATUS.textContent = strings.settings_error_save_failed || "Failed to save settings"; } } // Settings event listeners BTN_SETTINGS.addEventListener("click", showSettings); SETTINGS_ACCESS_BTN.addEventListener("click", accessSettings); SETTINGS_SAVE_BTN.addEventListener("click", saveSettings); SETTINGS_BACK_BTN.addEventListener("click", hideSettings); SETTINGS_TOKEN_INPUT.addEventListener("keydown", (e) => { if (e.key === "Enter") accessSettings(); }); })();