feat: initial project scaffold — server, client, Android APK

- FastAPI server with audio mixer (Windows + Voicemeeter), media tracking,
  notifications (progress + alerts), telemetry, and system tray .exe build
- Test suite covering notifications, audio mixer, media, telemetry, and API
- E-Ink web client (vanilla JS, DOM API, block meters, swipe gestures)
- Android WebView APK for BOOX Go 7 Color Gen II (Android 13, Kotlin)
- Design decision records in .pi/docs/design/
- PyInstaller build script for server .exe
This commit is contained in:
Imrayya
2026-07-01 11:19:32 +00:00
commit 0b4f739d12
36 changed files with 3916 additions and 0 deletions
+368
View File
@@ -0,0 +1,368 @@
/**
* 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.
*/
(() => {
const SERVER_WS = `ws://${window.location.host}/ws`;
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");
let ws = null;
let reconnectTimer = null;
// -----------------------------------------------------------------------
// PIN Login
// -----------------------------------------------------------------------
function connect() {
const pin = PIN_INPUT.value.trim();
if (!pin) {
LOGIN_ERROR.textContent = "Enter a PIN";
return;
}
LOGIN_ERROR.textContent = "";
CONNECT_BTN.disabled = true;
CONNECT_BTN.textContent = "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 = "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 = "Disconnected. Re-enter PIN.";
CONNECT_BTN.disabled = false;
CONNECT_BTN.textContent = "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 || "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 = "MUTED";
} else {
muteBtn.classList.remove("muted");
muteBtn.textContent = "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 = "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 = "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;
}
})();