331de4bc9e
- New settings page at /settings (localhost-only) accessible via tray icon - PIN manager with 6-digit codes, 5-min TTL, single-use - API token uses secrets.token_urlsafe(32) for 256-bit entropy - Config auto-creates config.yaml on first run with detected local IP - Default PIN and token generated randomly on first startup - Added pin_ttl setting (default 300s) - Updated build_exe.py for onefile exe with settings assets bundled - Added start.bat and start.sh for easy development startup
535 lines
16 KiB
JavaScript
535 lines
16 KiB
JavaScript
/**
|
|
* 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();
|
|
});
|
|
})();
|