feat: config files, settings screen, hardware check
- Add config.py with multi-source config (YAML, .env, env vars) - Add .env.example and config.yaml.example - Add settings API endpoints (GET/POST /api/v1/settings) - Add status endpoint (GET /api/v1/status) with hardware subsystem check - Add settings screen to web client (protected by API token) - Add localization strings for settings UI - Add tests for config module and new endpoints - Update README with config documentation
This commit is contained in:
@@ -4,18 +4,92 @@
|
||||
* 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
|
||||
@@ -24,13 +98,13 @@
|
||||
function connect() {
|
||||
const pin = PIN_INPUT.value.trim();
|
||||
if (!pin) {
|
||||
LOGIN_ERROR.textContent = "Enter a PIN";
|
||||
LOGIN_ERROR.textContent = strings.login_error_enter_pin || "Enter a PIN";
|
||||
return;
|
||||
}
|
||||
|
||||
LOGIN_ERROR.textContent = "";
|
||||
CONNECT_BTN.disabled = true;
|
||||
CONNECT_BTN.textContent = "Connecting...";
|
||||
CONNECT_BTN.textContent = strings.connecting || "Connecting...";
|
||||
|
||||
ws = new WebSocket(`${SERVER_WS}?pin=${encodeURIComponent(pin)}`);
|
||||
|
||||
@@ -38,7 +112,7 @@
|
||||
LOGIN_SCREEN.classList.remove("active");
|
||||
DASHBOARD_SCREEN.classList.add("active");
|
||||
CONNECT_BTN.disabled = false;
|
||||
CONNECT_BTN.textContent = "Connect";
|
||||
CONNECT_BTN.textContent = strings.login_button || "Connect";
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
@@ -53,9 +127,10 @@
|
||||
ws.onclose = () => {
|
||||
DASHBOARD_SCREEN.classList.remove("active");
|
||||
LOGIN_SCREEN.classList.add("active");
|
||||
LOGIN_ERROR.textContent = "Disconnected. Re-enter PIN.";
|
||||
LOGIN_ERROR.textContent =
|
||||
strings.login_error_disconnected || "Disconnected. Re-enter PIN.";
|
||||
CONNECT_BTN.disabled = false;
|
||||
CONNECT_BTN.textContent = "Connect";
|
||||
CONNECT_BTN.textContent = strings.login_button || "Connect";
|
||||
|
||||
// Auto-reconnect after 5 seconds
|
||||
clearTimeout(reconnectTimer);
|
||||
@@ -91,7 +166,7 @@
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function updateMedia(media) {
|
||||
setText("track-title", media.title || "No playback");
|
||||
setText("track-title", media.title || strings.no_playback || "No playback");
|
||||
setText("track-artist", media.artist || "");
|
||||
setText("track-album", media.album || "");
|
||||
|
||||
@@ -127,10 +202,10 @@
|
||||
const muteBtn = document.getElementById("btn-mute");
|
||||
if (audio.master_muted) {
|
||||
muteBtn.classList.add("muted");
|
||||
muteBtn.textContent = "MUTED";
|
||||
muteBtn.textContent = strings.muted || "MUTED";
|
||||
} else {
|
||||
muteBtn.classList.remove("muted");
|
||||
muteBtn.textContent = "MUTE";
|
||||
muteBtn.textContent = strings.mute || "MUTE";
|
||||
}
|
||||
|
||||
// Channels — use DOM API
|
||||
@@ -211,7 +286,7 @@
|
||||
if (!notifications || notifications.length === 0) {
|
||||
const p = document.createElement("p");
|
||||
p.style.color = "#888";
|
||||
p.textContent = "No notifications";
|
||||
p.textContent = strings.no_notifications || "No notifications";
|
||||
container.appendChild(p);
|
||||
return;
|
||||
}
|
||||
@@ -244,7 +319,7 @@
|
||||
if (n.eta) {
|
||||
const etaDiv = document.createElement("div");
|
||||
etaDiv.className = "notification-eta";
|
||||
etaDiv.textContent = "ETA: " + n.eta;
|
||||
etaDiv.textContent = (strings.eta_prefix || "ETA:") + " " + n.eta;
|
||||
div.appendChild(etaDiv);
|
||||
}
|
||||
|
||||
@@ -365,4 +440,96 @@
|
||||
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();
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -8,34 +8,40 @@
|
||||
/>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<title>PaperDash</title>
|
||||
<title data-i18n="app_name">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>
|
||||
<h1 data-i18n="login_title">PaperDash</h1>
|
||||
<p data-i18n="login_subtitle">Enter PIN to connect</p>
|
||||
<input
|
||||
type="password"
|
||||
id="pin-input"
|
||||
maxlength="4"
|
||||
data-i18n-placeholder="login_placeholder"
|
||||
placeholder="****"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button id="connect-btn">Connect</button>
|
||||
<button id="connect-btn" data-i18n="login_button">Connect</button>
|
||||
<p id="login-error" class="error"></p>
|
||||
</div>
|
||||
|
||||
<!-- Main Dashboard -->
|
||||
<div id="dashboard-screen" class="screen">
|
||||
<!-- Settings Button -->
|
||||
<button id="btn-settings" class="settings-btn">⚙</button>
|
||||
|
||||
<!-- Media Section -->
|
||||
<section id="media-section" class="panel">
|
||||
<h2>Now Playing</h2>
|
||||
<h2 data-i18n="now_playing">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-title" class="track-title" data-i18n="no_playback">
|
||||
No playback
|
||||
</div>
|
||||
<div id="track-artist" class="track-artist"></div>
|
||||
<div id="track-album" class="track-album"></div>
|
||||
</div>
|
||||
@@ -57,22 +63,24 @@
|
||||
|
||||
<!-- Audio Mixer Section -->
|
||||
<section id="audio-section" class="panel">
|
||||
<h2>Audio Mixer</h2>
|
||||
<h2 data-i18n="audio_mixer">Audio Mixer</h2>
|
||||
<div id="master-volume">
|
||||
<span id="master-label">Master</span>
|
||||
<span id="master-label" data-i18n="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>
|
||||
<button id="btn-mute" class="mute-btn" data-i18n="mute">
|
||||
MUTE
|
||||
</button>
|
||||
</div>
|
||||
<div id="channels-container"></div>
|
||||
</section>
|
||||
|
||||
<!-- Telemetry Section -->
|
||||
<section id="telemetry-section" class="panel">
|
||||
<h2>System</h2>
|
||||
<h2 data-i18n="system">System</h2>
|
||||
<div id="telemetry-grid">
|
||||
<div class="telemetry-item">
|
||||
<span class="telemetry-label">CPU</span>
|
||||
@@ -95,10 +103,49 @@
|
||||
|
||||
<!-- Notifications Section -->
|
||||
<section id="notifications-section" class="panel">
|
||||
<h2>Notifications</h2>
|
||||
<h2 data-i18n="notifications">Notifications</h2>
|
||||
<div id="notifications-container"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Settings Screen -->
|
||||
<div id="settings-screen" class="screen">
|
||||
<h1 data-i18n="settings_title">Settings</h1>
|
||||
<p data-i18n="settings_subtitle">Enter API token to access settings</p>
|
||||
<input
|
||||
type="password"
|
||||
id="settings-token-input"
|
||||
placeholder="API Token"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button id="settings-access-btn" data-i18n="access">Access</button>
|
||||
<p id="settings-error" class="error"></p>
|
||||
|
||||
<div id="settings-content" style="display: none;">
|
||||
<section class="panel">
|
||||
<h2 data-i18n="dashboard_pin_label">Dashboard PIN</h2>
|
||||
<input
|
||||
type="text"
|
||||
id="settings-pin"
|
||||
maxlength="4"
|
||||
placeholder="****"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2 data-i18n="api_token_label">API Token</h2>
|
||||
<input
|
||||
type="password"
|
||||
id="settings-token"
|
||||
placeholder="API Token"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<button id="settings-save-btn" data-i18n="save">Save</button>
|
||||
<button id="settings-back-btn" data-i18n="back">Back</button>
|
||||
<p id="settings-status" class="status"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
|
||||
@@ -342,6 +342,107 @@ body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Settings button */
|
||||
.settings-btn {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border: 2px solid var(--border);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 1.2em;
|
||||
cursor: pointer;
|
||||
z-index: 100;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Settings screen */
|
||||
#settings-screen {
|
||||
justify-content: flex-start;
|
||||
padding-top: 40px;
|
||||
}
|
||||
|
||||
#settings-screen h1 {
|
||||
font-size: 1.5em;
|
||||
border-bottom: 2px solid var(--border);
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
#settings-screen p {
|
||||
margin-bottom: 12px;
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
#settings-token-input {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border: 2px solid var(--border);
|
||||
padding: 10px;
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
font-family: inherit;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
#settings-access-btn,
|
||||
#settings-save-btn,
|
||||
#settings-back-btn {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border: 2px solid var(--border);
|
||||
padding: 10px 20px;
|
||||
font-size: 1em;
|
||||
cursor: pointer;
|
||||
margin-right: 10px;
|
||||
margin-bottom: 10px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
#settings-content {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
#settings-content .panel {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
#settings-content h2 {
|
||||
font-size: 0.9em;
|
||||
margin-bottom: 8px;
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
#settings-pin,
|
||||
#settings-token {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border: 2px solid var(--border);
|
||||
padding: 10px;
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
#settings-status {
|
||||
margin-top: 15px;
|
||||
padding: 10px;
|
||||
border: 2px solid var(--border);
|
||||
background: var(--panel-bg);
|
||||
}
|
||||
|
||||
.status {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--dim);
|
||||
font-size: 0.9em;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* E-Ink specific: no transitions, sharp edges */
|
||||
* {
|
||||
transition: none !important;
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package me.kareemhorstink.paperdash
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.WindowInsets
|
||||
import android.view.WindowInsetsController
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
@@ -37,6 +40,9 @@ class MainActivity : AppCompatActivity() {
|
||||
webView.isVerticalScrollBarEnabled = false
|
||||
webView.overScrollMode = WebView.OVER_SCROLL_NEVER
|
||||
|
||||
// Add JavaScript interface for localized strings
|
||||
webView.addJavascriptInterface(LocalizationInterface(this), "Android")
|
||||
|
||||
// Hide system bars for full-screen E-Ink display
|
||||
hideSystemBars()
|
||||
|
||||
@@ -67,4 +73,26 @@ class MainActivity : AppCompatActivity() {
|
||||
super.onWindowFocusChanged(hasFocus)
|
||||
if (hasFocus) hideSystemBars()
|
||||
}
|
||||
|
||||
/**
|
||||
* JavaScript interface that provides localized strings to the web client.
|
||||
* Call from JavaScript: window.Android.getString("key")
|
||||
*/
|
||||
inner class LocalizationInterface(context: Context) {
|
||||
@JavascriptInterface
|
||||
fun getString(key: String): String {
|
||||
return try {
|
||||
val resourceId = context.resources.getIdentifier(key, "string", context.packageName)
|
||||
if (resourceId != 0) {
|
||||
context.resources.getString(resourceId)
|
||||
} else {
|
||||
Log.w("PaperDash", "String resource not found: $key")
|
||||
""
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("PaperDash", "Error getting string: $key", e)
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">PaperDash</string>
|
||||
<string name="login_title">PaperDash</string>
|
||||
<string name="login_subtitle">Enter PIN to connect</string>
|
||||
<string name="login_placeholder">****</string>
|
||||
<string name="login_button">Connect</string>
|
||||
<string name="login_error_enter_pin">Enter a PIN</string>
|
||||
<string name="login_error_disconnected">Disconnected. Re-enter PIN.</string>
|
||||
<string name="connecting">Connecting…</string>
|
||||
<string name="no_playback">No playback</string>
|
||||
<string name="album_art_alt">Album art</string>
|
||||
<string name="master_label">Master</string>
|
||||
<string name="mute">MUTE</string>
|
||||
<string name="muted">MUTED</string>
|
||||
<string name="no_notifications">No notifications</string>
|
||||
<string name="eta_prefix">ETA:</string>
|
||||
<string name="settings_title">Settings</string>
|
||||
<string name="settings_subtitle">Enter API token to access settings</string>
|
||||
<string name="access">Access</string>
|
||||
<string name="dashboard_pin_label">Dashboard PIN</string>
|
||||
<string name="api_token_label">API Token</string>
|
||||
<string name="save">Save</string>
|
||||
<string name="back">Back</string>
|
||||
<string name="settings_error_invalid_token">Invalid token</string>
|
||||
<string name="settings_saved">Settings saved</string>
|
||||
<string name="settings_error_save_failed">Failed to save settings</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user