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:
@@ -0,0 +1,46 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "me.kareemhorstink.paperdash"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "me.kareemhorstink.paperdash"
|
||||
minSdk = 33
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||
implementation("com.google.android.material:material:1.11.0")
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
# PaperDash ProGuard Rules
|
||||
# Add project specific ProGuard rules here.
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@android:drawable/sym_def_app_icon"
|
||||
android:label="PaperDash"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.PaperDash">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="landscape"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -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;
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,106 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<title>PaperDash</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- PIN Login Screen -->
|
||||
<div id="login-screen" class="screen active">
|
||||
<h1>PaperDash</h1>
|
||||
<p>Enter PIN to connect</p>
|
||||
<input
|
||||
type="password"
|
||||
id="pin-input"
|
||||
maxlength="4"
|
||||
placeholder="****"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button id="connect-btn">Connect</button>
|
||||
<p id="login-error" class="error"></p>
|
||||
</div>
|
||||
|
||||
<!-- Main Dashboard -->
|
||||
<div id="dashboard-screen" class="screen">
|
||||
<!-- Media Section -->
|
||||
<section id="media-section" class="panel">
|
||||
<h2>Now Playing</h2>
|
||||
<div id="artwork-container"></div>
|
||||
<div id="track-info">
|
||||
<div id="track-title" class="track-title">No playback</div>
|
||||
<div id="track-artist" class="track-artist"></div>
|
||||
<div id="track-album" class="track-album"></div>
|
||||
</div>
|
||||
<div id="progress-container">
|
||||
<div id="progress-bar">
|
||||
<div id="progress-fill"></div>
|
||||
</div>
|
||||
<div id="progress-text">
|
||||
<span id="progress-current">0:00</span>
|
||||
<span id="progress-duration">0:00</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="media-controls">
|
||||
<button id="btn-prev" class="control-btn">⏮</button>
|
||||
<button id="btn-playpause" class="control-btn">⏸</button>
|
||||
<button id="btn-next" class="control-btn">⏭</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Audio Mixer Section -->
|
||||
<section id="audio-section" class="panel">
|
||||
<h2>Audio Mixer</h2>
|
||||
<div id="master-volume">
|
||||
<span id="master-label">Master</span>
|
||||
<div class="volume-controls">
|
||||
<button id="btn-vol-down" class="volume-btn">-</button>
|
||||
<span id="master-volume-value">50%</span>
|
||||
<button id="btn-vol-up" class="volume-btn">+</button>
|
||||
</div>
|
||||
<button id="btn-mute" class="mute-btn">MUTE</button>
|
||||
</div>
|
||||
<div id="channels-container"></div>
|
||||
</section>
|
||||
|
||||
<!-- Telemetry Section -->
|
||||
<section id="telemetry-section" class="panel">
|
||||
<h2>System</h2>
|
||||
<div id="telemetry-grid">
|
||||
<div class="telemetry-item">
|
||||
<span class="telemetry-label">CPU</span>
|
||||
<span id="cpu-temp" class="telemetry-value">--</span>
|
||||
</div>
|
||||
<div class="telemetry-item">
|
||||
<span class="telemetry-label">GPU</span>
|
||||
<span id="gpu-temp" class="telemetry-value">--</span>
|
||||
</div>
|
||||
<div class="telemetry-item">
|
||||
<span class="telemetry-label">RAM</span>
|
||||
<span id="ram-usage" class="telemetry-value">--</span>
|
||||
</div>
|
||||
<div class="telemetry-item">
|
||||
<span class="telemetry-label">VRAM</span>
|
||||
<span id="vram-usage" class="telemetry-value">--</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Notifications Section -->
|
||||
<section id="notifications-section" class="panel">
|
||||
<h2>Notifications</h2>
|
||||
<div id="notifications-container"></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,349 @@
|
||||
/* PaperDash — E-Ink optimized styles */
|
||||
/* High contrast, no smooth transitions, block-based UI */
|
||||
|
||||
:root {
|
||||
--bg: #000000;
|
||||
--fg: #ffffff;
|
||||
--border: #ffffff;
|
||||
--dim: #888888;
|
||||
--panel-bg: #111111;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 16px;
|
||||
overflow-x: hidden;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Screens */
|
||||
.screen {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.screen.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Login */
|
||||
#login-screen {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
#login-screen h1 {
|
||||
font-size: 2em;
|
||||
border-bottom: 2px solid var(--border);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
#pin-input {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border: 2px solid var(--border);
|
||||
padding: 12px 24px;
|
||||
font-size: 1.5em;
|
||||
text-align: center;
|
||||
letter-spacing: 8px;
|
||||
width: 200px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
#connect-btn {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border: 2px solid var(--border);
|
||||
padding: 10px 32px;
|
||||
font-size: 1.1em;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--dim);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Panels */
|
||||
.panel {
|
||||
border: 2px solid var(--border);
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
font-size: 1em;
|
||||
border-bottom: 1px solid var(--dim);
|
||||
padding-bottom: 4px;
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
/* Media */
|
||||
#artwork-container {
|
||||
width: 100%;
|
||||
max-height: 120px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid var(--dim);
|
||||
}
|
||||
|
||||
#artwork-container img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.track-title {
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.track-artist,
|
||||
.track-album {
|
||||
color: var(--dim);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Progress bar — block stepped for E-Ink */
|
||||
#progress-container {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
#progress-bar {
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
border: 2px solid var(--border);
|
||||
background: var(--bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#progress-fill {
|
||||
height: 100%;
|
||||
background: var(--fg);
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
#progress-text {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8em;
|
||||
color: var(--dim);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Media controls */
|
||||
#media-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border: 2px solid var(--border);
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
font-size: 1.3em;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Audio mixer */
|
||||
#master-volume {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#master-label {
|
||||
font-weight: bold;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.volume-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.volume-btn {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border: 2px solid var(--border);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 1.2em;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
#master-volume-value {
|
||||
min-width: 45px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mute-btn {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border: 2px solid var(--border);
|
||||
padding: 6px 12px;
|
||||
font-size: 0.8em;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.mute-btn.muted {
|
||||
background: var(--fg);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
/* Channels */
|
||||
.channel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 0;
|
||||
border-top: 1px solid var(--dim);
|
||||
}
|
||||
|
||||
.channel-name {
|
||||
min-width: 30px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.channel-meter {
|
||||
flex: 1;
|
||||
height: 16px;
|
||||
border: 1px solid var(--dim);
|
||||
overflow: hidden;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.channel-meter-fill {
|
||||
height: 100%;
|
||||
background: var(--fg);
|
||||
}
|
||||
|
||||
.channel-volume {
|
||||
min-width: 35px;
|
||||
text-align: right;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* Telemetry */
|
||||
#telemetry-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.telemetry-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--dim);
|
||||
}
|
||||
|
||||
.telemetry-label {
|
||||
color: var(--dim);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.telemetry-value {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Notifications */
|
||||
.notification {
|
||||
border: 2px solid var(--border);
|
||||
padding: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.notification.alert {
|
||||
border-color: var(--dim);
|
||||
}
|
||||
|
||||
.notification.alert.warning {
|
||||
border-style: double;
|
||||
}
|
||||
|
||||
.notification.alert.error {
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.notification-message {
|
||||
font-size: 0.9em;
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
.notification-progress {
|
||||
margin-top: 6px;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.notification-eta {
|
||||
font-size: 0.8em;
|
||||
color: var(--dim);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Scrollable dashboard */
|
||||
#dashboard-screen {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* E-Ink specific: no transitions, sharp edges */
|
||||
* {
|
||||
transition: none !important;
|
||||
-webkit-transition: none !important;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package me.kareemhorstink.paperdash
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.WindowInsets
|
||||
import android.view.WindowInsetsController
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import me.kareemhorstink.paperdash.databinding.ActivityMainBinding
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
val webView = binding.webview
|
||||
val settings = webView.settings
|
||||
|
||||
// E-Ink optimized: no smooth scrolling, no scaling
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
settings.useWideViewPort = true
|
||||
settings.loadWithOverviewMode = true
|
||||
settings.cacheMode = WebSettings.LOAD_DEFAULT
|
||||
|
||||
// Disable gestures that cause unwanted E-Ink refreshes
|
||||
webView.isHorizontalScrollBarEnabled = false
|
||||
webView.isVerticalScrollBarEnabled = false
|
||||
webView.overScrollMode = WebView.OVER_SCROLL_NEVER
|
||||
|
||||
// Hide system bars for full-screen E-Ink display
|
||||
hideSystemBars()
|
||||
|
||||
// Load from assets (bundled HTML/CSS/JS)
|
||||
webView.webViewClient = WebViewClient()
|
||||
webView.loadUrl("file:///android_asset/index.html")
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
private fun hideSystemBars() {
|
||||
window.decorView.systemUiVisibility = (
|
||||
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
|
||||
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
|
||||
View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||
)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
window.insetsController?.hide(WindowInsets.Type.statusBars() or WindowInsets.Type.navigationBars())
|
||||
window.insetsController?.systemBarsBehavior =
|
||||
WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
}
|
||||
}
|
||||
|
||||
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
||||
super.onWindowFocusChanged(hasFocus)
|
||||
if (hasFocus) hideSystemBars()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#000000">
|
||||
|
||||
<WebView
|
||||
android:id="@+id/webview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.PaperDash" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||
<item name="android:windowFullscreen">true</item>
|
||||
<item name="android:windowContentOverlay">@null</item>
|
||||
<item name="android:statusBarColor">#000000</item>
|
||||
<item name="android:navigationBarColor">#000000</item>
|
||||
</style>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user