Adjusting Project files for branch

This commit is contained in:
2025-11-19 23:13:06 +01:00
parent 7c94b4322c
commit 98832cd765
10 changed files with 191 additions and 7825 deletions

3
.gitignore vendored
View File

@@ -1,5 +1,6 @@
#Folders
/node_modules/
/.venv/
#Files
/config.json
/config.json

View File

@@ -1,170 +0,0 @@
const { SerialPort } = require('serialport');
const fs = require('fs');
const EventEmitter = require('events');
// add parser import
const { ReadlineParser } = require('@serialport/parser-readline');
class ArduinoThread extends EventEmitter {
constructor(comPort, baudRate = 9600, sliderCount = 3, yamlPath = 'config.json', verbose = false) {
super();
if (typeof comPort !== 'string') throw new Error('comPort must be a string');
if (typeof baudRate !== 'number') throw new Error('baudRate must be a number');
if (typeof sliderCount !== 'number') throw new Error('sliderCount must be a number');
this.port = new SerialPort({ path: comPort, baudRate, autoOpen: true });
this.sliderCount = sliderCount;
this.yamlPath = yamlPath; // now points to config.json by default
this.verbose = !!verbose;
this.sendBuffer = [];
this.updateList = Array(sliderCount).fill(0);
this.isRunning = true;
this.pendingMessage = null;
// pipe through a newline-based parser so we always get full lines
try {
this.parser = this.port.pipe(new ReadlineParser({ delimiter: '\n' }));
this.parser.on('data', (line) => this._handleData(line));
} catch (err) {
// fallback to raw data event if parser unavailable
this.port.on('data', (data) => this._handleData(data));
}
// keep error/close handlers on the port
this.port.on('error', (err) => {
this.emit('error', err);
if (this.verbose) console.error('Serial error:', err);
});
this.port.on('close', () => {
this.isRunning = false;
if (this.verbose) console.log('Serial port closed');
});
}
_handleData(data) {
// accept string or Buffer; trim newline/whitespace
const input = (typeof data === 'string') ? data.trim() : data.toString().trim();
// acknowledgement handling
if (input === 'OK' && this.pendingMessage) {
if (this.verbose) console.log(`Message confirmed: ${this.pendingMessage}`);
this.pendingMessage = null;
// drain buffer if present
if (this.sendBuffer.length > 0) {
const next = this.sendBuffer.shift();
this._writeMessage(next);
}
return;
}
const numbers = input.split('|');
if (numbers.length === this.sliderCount) {
try {
const values = numbers.map((n) => parseInt(n, 10));
values.forEach((val, i) => {
if (!Number.isFinite(val)) throw new Error('parse error');
if (val !== this.updateList[i]) {
this._setVolume(val, i);
}
});
this.updateList = values;
this.emit('update', values); // notify listeners (GUI)
if (this.verbose) console.log('Slider update ->', values);
} catch (err) {
this.emit('warn', { msg: 'Invalid slider data', raw: input });
if (this.verbose) console.warn('Invalid slider data:', input);
}
} else {
// Not an update; emit for consumers
this.emit('raw', input);
if (this.verbose) console.warn('Unexpected input:', input);
}
}
send(message) {
if (!message || typeof message !== 'string') return;
if (this.pendingMessage) {
// queue if another message is awaiting ACK
this.sendBuffer.push(message);
return;
}
this._writeMessage(message);
}
_writeMessage(message) {
if (!this.port || !this.port.writable) {
const err = new Error('Serial port not writable');
this.emit('error', err);
if (this.verbose) console.error(err);
return;
}
this.pendingMessage = message;
this.port.write(message + '\n', (err) => {
if (err) {
this.emit('error', err);
if (this.verbose) console.error('Error writing:', err.message);
this.pendingMessage = null;
} else if (this.verbose) {
console.log('Wrote message:', message);
}
});
// fallback timeout if device never replies OK
setTimeout(() => {
if (this.pendingMessage) {
this.emit('warn', { msg: "Did not receive OK from device", message: this.pendingMessage });
if (this.verbose) console.warn('Did not receive OK from device for', this.pendingMessage);
this.pendingMessage = null;
// try to drain next queued message if any
if (this.sendBuffer.length > 0) {
const next = this.sendBuffer.shift();
this._writeMessage(next);
}
}
}, 100); // adjustable timeout
}
_setVolume(value, sliderNumber) {
const normalized = value / 1023;
if (!fs.existsSync(this.yamlPath)) {
this.emit('warn', { msg: 'Config file missing', path: this.yamlPath });
if (this.verbose) console.warn('Config file missing:', this.yamlPath);
return;
}
try {
const file = fs.readFileSync(this.yamlPath, 'utf8');
const config = JSON.parse(file || '{}'); // parse JSON config.json
const processes = config[sliderNumber] || [];
processes.forEach((proc) => {
// Emit an event so the application can perform the platform-specific change.
this.emit('set-volume', { process: proc, value: normalized, slider: sliderNumber });
if (this.verbose) {
console.log(`Would set volume for "${proc}" to ${normalized.toFixed(2)} (slider ${sliderNumber})`);
}
});
} catch (err) {
this.emit('error', err);
if (this.verbose) console.error('Config JSON error:', err);
}
}
stop() {
this.isRunning = false;
try {
if (this.port && this.port.isOpen) this.port.close();
} catch (e) {
if (this.verbose) console.warn('Error closing port', e);
}
}
getValues() {
return this.updateList.slice();
}
}
module.exports = ArduinoThread;

View File

@@ -1,543 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dreckshub</title>
<link rel="stylesheet" href="styles.css">
<!-- Tighter vertical spacing for customisation options -->
<style>
/* predictable sizing */
*, *::before, *::after { box-sizing: border-box; }
/* very tight vertical spacing */
.customisation-grid {
display: flex;
flex-direction: column;
gap: 2px; /* minimal vertical gap */
width: 100%;
max-width: 720px;
}
.customisation-column {
display: flex;
flex-direction: column;
gap: 2px; /* minimal gap between rows */
width: 100%;
}
/* compact horizontal row: label | color wheel | reset button */
.customisation-row {
display: flex;
align-items: center;
gap: 3px; /* tight horizontal spacing */
padding: 1px 0; /* minimal vertical padding */
min-height: 26px; /* compact row height */
}
.customisation-row label {
flex: 0 0 auto;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: 0;
font-size: 13px;
line-height: 1;
}
/* tighten spacing inside the control group so wheel is very close to name */
.color-control {
display: inline-flex;
align-items: center;
gap: 2px; /* very tight */
flex: 0 0 auto;
}
/* rectangular swatch with rounded corners (replaces circular variant) */
input[type="color"]{
-webkit-appearance: none;
appearance: none;
width: 40px; /* slightly wider rectangle */
height: 26px;
padding: 0;
border: none; /* remove default box */
background: transparent;
cursor: pointer;
border-radius: 6px; /* rounded rectangle (not pill) */
}
/* WebKit browsers: remove wrapper padding & match rounded corners */
input[type="color"]::-webkit-color-swatch-wrapper {
padding: 0;
border-radius: 6px;
}
input[type="color"]::-webkit-color-swatch {
border: none;
border-radius: 6px;
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.12); /* subtle inset ring */
}
/* Firefox swatch styling with rounded corners */
input[type="color"]::-moz-color-swatch {
border: none;
border-radius: 6px;
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.12);
}
/* nicer focus state (matches rectangle radius) */
input[type="color"]:focus {
outline: 2px solid rgba(15,102,214,0.9); /* example accent */
outline-offset: 2px;
border-radius: 6px;
}
/* slider accent: use CSS variable so we can update from JS */
input[type="range"],
.vertical-slider {
accent-color: var(--accent-color, #0f66d6);
}
/* media query variant (keep sizes in sync with tightened layout) */
@media (max-width: 520px) {
.customisation-grid { gap: 2px; }
.customisation-column { gap: 2px; }
.customisation-row { gap: 3px; min-height: 24px; padding: 1px 0; }
input[type="color"] { width: 32px; height: 24px; border-radius:5px; }
input[type="color"]::-webkit-color-swatch-wrapper { border-radius:5px; }
input[type="color"]::-webkit-color-swatch { border-radius:5px; }
input[type="color"]::-moz-color-swatch { border-radius:5px; }
.reset-default { width: 26px; height: 26px; font-size:11px; }
.reset-default .reset-arrow { font-size:14px; } /* increased from 13px */
}
/* General settings: Device, COM Port and Auto-connect — normal weight */
.settings-left-column .settings-row label[for="device-select"],
.settings-left-column .settings-row label[for="com-port-select"],
.settings-left-column .settings-row label[for="autoconnect"] {
font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 13px;
font-weight: 400; /* normal */
letter-spacing: 0;
}
/* ensure selects and option text match (not bold) */
.settings-left-column .settings-row .settings-select,
#device-select,
#com-port-select,
.settings-left-column .settings-row .settings-select option {
font-family: inherit;
font-size: 13px;
font-weight: 400;
letter-spacing: 0;
}
/* keep checkbox sizing unchanged but use the same font metrics for adjacent label */
.settings-left-column .settings-row .settings-checkbox {
vertical-align: middle;
}
/* ensure potentiometer ring color can be overridden by JS and isn't forced to blue
include pseudo-elements so gradient/overlay layers are covered */
.pot-ring,
.pot-ring::before,
.pot-ring::after {
background: var(--pot-ring-color, #007bff) !important;
background-image: none !important;
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.12) !important;
border-radius: 50% !important;
}
/* Ghostwire button-grid: ensure first-row spacing below the first 4 items */
.device-content[data-device="makropad ghostwire"] .button-grid {
display: grid;
grid-template-columns: repeat(4, auto); /* 3 buttons + pot in first row */
column-gap: 8px;
align-items: center;
}
/* clear default margins on children, then add vertical gap under first row only */
.device-content[data-device="makropad ghostwire"] .button-grid > * {
margin: 0;
}
.device-content[data-device="makropad ghostwire"] .button-grid > *:nth-child(-n+4) {
margin-bottom: 12px; /* increased space under first row */
}
/* responsive fallback: collapse to 2 columns on narrow widths */
@media (max-width: 480px) {
.device-content[data-device="makropad ghostwire"] .button-grid {
grid-template-columns: repeat(2, auto);
}
.device-content[data-device="makropad ghostwire"] .button-grid > *:nth-child(-n+2) {
margin-bottom: 10px;
}
}
</style>
</head>
<body>
<div class="app-wrapper">
<aside class="sidebar">
<div class="sidebar-top">
<div class="logo" role="banner" aria-label="Dreckshub">
<span class="logo-text">
<span class="logo-base">Drecks</span><span class="logo-highlight">hub</span>
</span>
</div>
<nav class="tabs">
<button class="tab-button active" data-target="tab-1">Main</button>
<button class="tab-button" data-target="tab-2">Placeholder 1</button>
<button class="tab-button" data-target="tab-3">Sliders</button>
</nav>
</div>
<div class="sidebar-bottom">
<button id="settings-btn" class="settings-btn">⚙ Settings</button>
</div>
</aside>
<main class="content-area">
<section id="tab-1" class="tab-content active">
<!-- Makropad-specific content -->
<div class="device-content" data-device="makropad">
<h2>Makropad — Main</h2>
<p>Content for Makropad on the first tab.</p>
</div>
<!-- Makropad Ghostwire-specific content -->
<div class="device-content" data-device="makropad ghostwire" style="display:none;">
<h2>Makropad Ghostwire — Main</h2>
<p>Alternate content for Makropad Ghostwire on the first tab.</p>
</div>
</section>
<section id="tab-2" class="tab-content">
<!-- Makropad-specific two-column layout -->
<div class="device-content" data-device="makropad">
<div class="makropad-two-col" aria-hidden="false">
<div class="left-group">
<div class="grid-3cols">
<button class="grid-button">b</button>
<button class="grid-button">b</button>
<button class="grid-button">b</button>
</div>
<!-- spacer row (empty) to match the requested layout) -->
<div class="grid-3cols spacer-row">
<div></div><div></div><div></div>
</div>
<div class="grid-3cols">
<button class="grid-button">b</button>
<button class="grid-button">b</button>
<button class="grid-button">b</button>
</div>
<div class="grid-3cols">
<button class="grid-button">b</button>
<button class="grid-button">b</button>
<button class="grid-button">b</button>
</div>
</div>
<div class="vertical-sep" aria-hidden="true"></div>
<div class="right-group">
<div class="grid-3cols">
<button class="grid-button">b</button>
<button class="grid-button">b</button>
<button class="grid-button">b</button>
</div>
<div class="grid-3cols">
<button class="grid-button">b</button>
<button class="grid-button">b</button>
<button class="grid-button">b</button>
</div>
<div class="grid-3cols">
<button class="grid-button">b</button>
<button class="grid-button">b</button>
<button class="grid-button">b</button>
</div>
<div class="grid-3cols">
<button class="grid-button">b</button>
<button class="grid-button">b</button>
<button class="grid-button">b</button>
</div>
</div>
</div>
</div>
<!-- Makropad Ghostwire-specific grid -->
<div class="device-content" data-device="makropad ghostwire" style="display:none;">
<div class="button-grid" aria-hidden="false">
<!-- different arrangement / labels for Ghostwire -->
<button class="grid-button">1</button>
<button class="grid-button">2</button>
<button class="grid-button">3</button>
<div class="potentiometer" role="img" aria-label="ghost pot" tabindex="-1">
<div class="pot-ring" style="--deg: 90deg;"></div>
<div class="pot-inner"><span class="pot-value">50</span></div>
</div>
<!-- remaining placeholders -->
<button class="grid-button">4</button>
<button class="grid-button">5</button>
<button class="grid-button">6</button>
<button class="grid-button">7</button>
<button class="grid-button">8</button>
<button class="grid-button">9</button>
<button class="grid-button">10</button>
<button class="grid-button">11</button>
<button class="grid-button">12</button>
<button class="grid-button">13</button>
<button class="grid-button">14</button>
<button class="grid-button">15</button>
</div>
</div>
</section>
<section id="tab-3" class="tab-content">
<div class="main-layout">
<!-- Sliders Panel for vertical sliders -->
<div class="sliders-panel">
<div class="slider-group">
<div class="slider-row">
<div class="slider-tag-generator">
<input type="text" id="tag-input-slider0" class="tag-input" placeholder="Enter Program Name">
<div id="tag-container-slider0" class="tag-container"></div>
</div>
<div class="slider-controls">
<input type="range" min="0" max="100" value="0" id="slider0" class="vertical-slider">
<span id="slider0-value-bubble" class="value-bubble" aria-hidden="true">50</span>
<button class="slider-btn" id="slider0-btn"></button>
</div>
<div class="slider-separator" aria-hidden="true"></div>
</div>
</div>
<div class="slider-group">
<div class="slider-row">
<div class="slider-tag-generator">
<input type="text" id="tag-input-slider1" class="tag-input" placeholder="Enter Program Name">
<div id="tag-container-slider1" class="tag-container"></div>
</div>
<div class="slider-controls">
<input type="range" min="0" max="100" value="0" id="slider1" class="vertical-slider">
<span id="slider1-value-bubble" class="value-bubble" aria-hidden="true">0</span>
<button class="slider-btn" id="slider1-btn"></button>
</div>
<div class="slider-separator" aria-hidden="true"></div>
</div>
</div>
<div class="slider-group">
<div class="slider-row">
<div class="slider-tag-generator">
<input type="text" id="tag-input-slider2" class="tag-input" placeholder="Enter Program Name">
<div id="tag-container-slider2" class="tag-container"></div>
</div>
<div class="slider-controls">
<input type="range" min="0" max="100" value="0" id="slider2" class="vertical-slider">
<span id="slider2-value-bubble" class="value-bubble" aria-hidden="true">0</span>
<button class="slider-btn" id="slider2-btn"></button>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Simple settings panel toggled by settings button -->
<div id="settings-overlay" class="settings-overlay" aria-hidden="true">
<div class="settings-window" role="dialog" aria-modal="true" aria-labelledby="settings-title">
<div class="settings-topbar">
<div id="settings-title" class="settings-title">Settings</div>
<nav class="settings-tabs">
<button class="settings-tab-button active" data-target="settings-tab-1">General</button>
<button class="settings-tab-button" data-target="settings-tab-2">Customisation</button>
<button class="settings-tab-button" data-target="settings-tab-3">Placeholder B</button>
</nav>
<button class="settings-close" id="close-settings"></button>
</div>
<div class="settings-content">
<section id="settings-tab-1" class="settings-tab-content active">
<!-- left column: keep both dropdowns on the left stacked -->
<div class="settings-left-column">
<div class="settings-row">
<label for="device-select">Device</label>
<select id="device-select" class="settings-select" aria-label="Device selection">
<option value="Makropad">Makropad</option>
<option value="Makropad Ghostwire">Makropad Ghostwire</option>
</select>
</div>
<div class="settings-row" style="margin-top:8px;">
<label for="com-port-select">COM Port</label>
<select id="com-port-select" class="settings-select" aria-label="COM port selection">
<option value="">(none)</option>
</select>
<button id="com-refresh-btn" class="settings-close" title="Refresh COM ports"></button>
<!-- Connect / Disconnect controls -->
<button id="connect-com-btn" class="assign-btn connect-state" style="margin-left:8px;">Connect</button>
</div>
<!-- new: autoconnect checkbox under the two dropdowns -->
<div class="settings-row" style="margin-top:8px;">
<label for="autoconnect">Auto-connect</label>
<input type="checkbox" id="autoconnect" class="settings-checkbox" />
</div>
</div>
<!-- right column (can hold extra controls later) -->
<div class="settings-right-column">
<div class="settings-checkbox-row">
<label class="checkbox-label">
<input type="checkbox" id="startup-with-system" class="settings-checkbox" />
Startup with system
</label>
</div>
<div class="settings-checkbox-row" style="margin-top:8px;">
<label class="checkbox-label">
<input type="checkbox" id="close-to-tray" class="settings-checkbox" />
Close to tray
</label>
</div>
</div>
</section>
<section id="settings-tab-2" class="settings-tab-content">
<div class="customisation-grid">
<!-- new layout: 3 columns for controls -->
<div class="customisation-column">
<div class="customisation-row">
<label for="cust-bg">Background</label>
<div class="color-control">
<input type="color" id="cust-bg" name="cust-bg" value="#0d0d0f" data-default="#0d0d0f" />
<button type="button" class="reset-default" data-target="cust-bg" title="Reset to default"><span class="reset-arrow">↩︎</span></button>
</div>
</div>
<div class="customisation-row">
<label for="cust-sidebar">Sidebar</label>
<div class="color-control">
<input type="color" id="cust-sidebar" name="cust-sidebar" value="#111111" data-default="#111111" />
<button type="button" class="reset-default" data-target="cust-sidebar" title="Reset to default"><span class="reset-arrow">↩︎</span></button>
</div>
</div>
</div>
<div class="customisation-column">
<div class="customisation-row">
<label for="cust-button">Button (grid)</label>
<div class="color-control">
<input type="color" id="cust-button" name="cust-button" value="#1b1b1b" data-default="#1b1b1b" />
<button type="button" class="reset-default" data-target="cust-button" title="Reset to default"><span class="reset-arrow">↩︎</span></button>
</div>
</div>
<div class="customisation-row">
<label for="cust-pot">Potentiometer ring</label>
<div class="color-control">
<input type="color" id="cust-pot" name="cust-pot" value="#007bff" data-default="#007bff" />
<button type="button" class="reset-default" data-target="cust-pot" title="Reset to default"><span class="reset-arrow">↩︎</span></button>
</div>
</div>
</div>
<div class="customisation-column">
<div class="customisation-row">
<label for="cust-accent">Accent (links / highlights)</label>
<div class="color-control">
<input type="color" id="cust-accent" name="cust-accent" value="#0f66d6" data-default="#0f66d6" />
<button type="button" class="reset-default" data-target="cust-accent" title="Reset to default"><span class="reset-arrow">↩︎</span></button>
</div>
</div>
</div>
</div>
</section>
<section id="settings-tab-3" class="settings-tab-content">
<h4>Placeholder B</h4>
<p>Content for placeholder B.</p>
</section>
</div>
</div>
</div>
</main>
</div>
<!-- wiring for colour inputs + reset buttons (applies cust-pot to .pot-ring elements) -->
<script>
(function () {
function applyColorToPotRing(color) {
// set on :root so pseudoelements that read the CSS var update
document.documentElement.style.setProperty('--pot-ring-color', color);
document.querySelectorAll('.pot-ring').forEach(el => {
// also set the custom property on the element itself (higher specificity)
el.style.setProperty('--pot-ring-color', color, '');
// clear any background-image and force a plain background color on the element
el.style.setProperty('background-image', 'none', 'important');
el.style.setProperty('background-color', color, 'important');
el.style.setProperty('background', color, 'important');
// ensure inline box-shadow as fallback
el.style.boxShadow = 'inset 0 0 0 1px rgba(0,0,0,0.12)';
// small toggle to force paint/reflow for stubborn cases
el.dataset.potRepaint = '1';
window.requestAnimationFrame(() => { delete el.dataset.potRepaint; });
});
}
function applyAccentColor(color) {
// set on :root so all inputs using var(--accent-color) update
document.documentElement.style.setProperty('--accent-color', color);
// also ensure existing range inputs get the accent color (fallback)
document.querySelectorAll('input[type="range"]').forEach(r => {
r.style.setProperty('accent-color', color);
});
}
function applyInputValue(input) {
if (!input) return;
const id = input.id;
const val = input.value;
if (id === 'cust-pot') {
applyColorToPotRing(val);
}
// add more mappings here if you want other inputs to affect other UI parts
}
// wire color inputs that include data-default
document.addEventListener('DOMContentLoaded', () => {
// initialize all color inputs
document.querySelectorAll('input[type="color"][id]').forEach(input => {
applyInputValue(input);
// also initialize accent if this is the cust-accent input
if (input.id === 'cust-accent') applyAccentColor(input.value);
input.addEventListener('input', () => applyInputValue(input));
input.addEventListener('change', () => applyInputValue(input));
});
// ensure range inputs reflect the accent variable initially
const custAccent = document.getElementById('cust-accent');
if (custAccent) applyAccentColor(custAccent.value);
// wire reset buttons
document.querySelectorAll('.reset-default[data-target]').forEach(btn => {
btn.addEventListener('click', (e) => {
const targetId = btn.getAttribute('data-target');
const input = document.getElementById(targetId);
if (!input) return;
const def = input.getAttribute('data-default') || '#000000';
input.value = def;
// trigger input/change handlers
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
});
});
// reapply cust-pot when new .pot-ring elements appear
const obs = new MutationObserver(() => {
const custPot = document.getElementById('cust-pot');
if (custPot) applyInputValue(custPot);
});
obs.observe(document.body, { childList: true, subtree: true });
});
})();
</script>
<script src="renderer.js"></script>
</body>
</html>

708
main.js
View File

@@ -1,708 +0,0 @@
const { app, BrowserWindow, ipcMain, Tray, Menu, nativeImage } = require("electron");
const fs = require("fs");
const path = require("path");
const { exec } = require("child_process");
const { execFile } = require("child_process");
const { spawn } = require("child_process");
// add ArduinoThread module and instance holder
const ArduinoThread = require("./arduino_thread");
let arduinoInstance = null;
// NEW: persistent python server helpers (globals)
let pythonServer = null;
let pythonServerStdoutBuffer = "";
let pythonServerReady = false;
let mainWindow;
let tray = null;
let isQuiting = false;
const tagsFilePath = path.join(__dirname, "config.json");
// load a single app/tray icon to reuse (tray.png expected next to index.html/main.js)
const iconPath = path.join(__dirname, "tray.png");
let appIcon;
try {
if (fs.existsSync(iconPath)) {
const img = nativeImage.createFromPath(iconPath);
// prefer a multi-size icon where possible; keep as-is if empty
appIcon = img && !img.isEmpty() ? img : undefined;
}
} catch (e) {
appIcon = undefined;
}
function readConfig() {
try {
if (fs.existsSync(tagsFilePath)) {
return JSON.parse(fs.readFileSync(tagsFilePath, "utf8"));
}
} catch (err) {
console.error("readConfig error:", err);
}
return {};
}
function writeConfig(obj) {
try {
fs.writeFileSync(tagsFilePath, JSON.stringify(obj, null, 2));
} catch (err) {
console.error("writeConfig error:", err);
}
}
// helper to run a command and return stdout
function execPromise(cmd) {
return new Promise((resolve, reject) => {
exec(cmd, { encoding: "utf8", windowsHide: true }, (err, stdout, stderr) => {
if (err) return reject(err);
resolve(stdout || "");
});
});
}
// --- NEW: tray helpers ---
function createTray() {
try {
if (tray) return;
// use the shared appIcon if available
tray = new Tray(appIcon || undefined);
const contextMenu = Menu.buildFromTemplate([
{
label: "Show",
click: () => {
if (mainWindow) {
mainWindow.show();
mainWindow.focus();
}
}
},
{
label: "Quit",
click: () => {
// user explicitly wants to quit
isQuiting = true;
app.quit();
}
}
]);
tray.setToolTip("Dreckshub");
tray.setContextMenu(contextMenu);
tray.on("double-click", () => {
if (mainWindow) {
mainWindow.show();
mainWindow.focus();
}
});
} catch (err) {
console.error("createTray failed:", err);
}
}
function destroyTray() {
try {
if (tray) {
tray.destroy();
tray = null;
}
} catch (err) {
console.error("destroyTray failed:", err);
}
}
/* Ensure tray state matches config.closeToTray */
function updateTrayState(enable) {
if (enable) {
createTray();
} else {
destroyTray();
}
}
app.whenReady().then(() => {
// set macOS dock icon early so the app uses tray.png instead of default electron icon
if (process.platform === "darwin" && appIcon) {
try { app.dock && app.dock.setIcon && app.dock.setIcon(appIcon); } catch (err) { /* ignore */ }
}
// restore saved window bounds if present
const cfg = readConfig();
const wb = cfg.windowBounds || null;
const winOptions = {
// start with the typical fixed size used previously
width: 1466,
height: 642,
// prevent user resizing
resizable: false,
// set window/taskbar icon (Windows/Linux)
icon: appIcon || undefined,
webPreferences: {
preload: path.join(__dirname, "preload.js"),
},
autoHideMenuBar: true,
};
if (wb && typeof wb.width === "number" && typeof wb.height === "number") {
winOptions.width = wb.width;
winOptions.height = wb.height;
if (typeof wb.x === "number" && typeof wb.y === "number") {
winOptions.x = wb.x;
winOptions.y = wb.y;
}
}
mainWindow = new BrowserWindow(winOptions);
// window is non-resizable; no minimum size call needed
mainWindow.loadFile("index.html");
// after content loads, apply maximized state if saved
mainWindow.webContents.once("did-finish-load", () => {
try {
if (wb && wb.isMaximized) {
mainWindow.maximize();
}
} catch (err) {
console.error("apply maximize failed:", err);
}
});
// create tray if setting enabled in config
updateTrayState(Boolean(cfg && cfg.closeToTray));
// save bounds when window is closing
mainWindow.on("close", (e) => {
try {
// If close-to-tray is enabled and user didn't explicitly choose to quit, hide instead of closing
const cfgNow = readConfig();
const useTray = Boolean(cfgNow && cfgNow.closeToTray);
if (useTray && !isQuiting) {
e.preventDefault();
if (mainWindow) mainWindow.hide();
return;
}
// allow closing — save bounds as before
// getBounds returns the un-maximized bounds; good for restoring windowed state
const bounds = mainWindow.getBounds();
const isMax = mainWindow.isMaximized();
const curCfg = readConfig();
curCfg.windowBounds = {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
isMaximized: isMax,
};
writeConfig(curCfg);
} catch (err) {
console.error("save window bounds failed:", err);
}
});
mainWindow.on("closed", () => {
mainWindow = null;
});
});
ipcMain.on("save-tags", (event, data) => {
// merge incoming tag data with existing config so windowBounds (and future fields) are preserved
try {
const cfg = readConfig() || {};
// If data is an array => legacy behavior for slider0
if (Array.isArray(data)) {
cfg.slider0 = data;
} else if (data && typeof data === "object") {
// copy all keys provided (slider0, slider1, slider2, sliderAssignments, etc.)
Object.keys(data).forEach((k) => {
cfg[k] = data[k];
});
}
writeConfig(cfg);
// react to closeToTray changes immediately
try {
updateTrayState(Boolean(cfg.closeToTray));
} catch (err) {
console.error("updateTrayState failed after save-tags:", err);
}
} catch (err) {
console.error("save-tags merge failed:", err);
}
});
ipcMain.handle("load-tags", () => {
const cfg = readConfig() || {};
// ensure defaults for all three sliders for backward compatibility
if (!Array.isArray(cfg.slider0)) cfg.slider0 = [];
if (!Array.isArray(cfg.slider1)) cfg.slider1 = [];
if (!Array.isArray(cfg.slider2)) cfg.slider2 = [];
return cfg;
});
ipcMain.handle("list-serial-ports", async () => {
try {
// Windows: prefer .NET SerialPort.GetPortNames() via PowerShell (reliable, returns COM names)
if (process.platform === "win32") {
try {
const psCmd = `powershell -NoProfile -Command "[System.IO.Ports.SerialPort]::GetPortNames() | ForEach-Object { $_ }"`;
const out = await execPromise(psCmd);
const lines = out.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
if (lines.length > 0) {
// Ensure unique, return objects with label=value
const uniq = Array.from(new Set(lines));
return uniq.map(p => ({ value: p, label: p }));
}
// Fallback 1: Get-PnpDevice friendly names containing (COMx)
const psFallback = `powershell -NoProfile -Command "Get-PnpDevice -Status OK | Where-Object { $_.FriendlyName -match '\\(COM\\d+\\)' -or $_.Name -match '\\(COM\\d+\\)' } | ForEach-Object { ($_.FriendlyName -or $_.Name) }"`;
const out2 = await execPromise(psFallback);
const lines2 = out2.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
const ports = [];
const re = /(COM\d+)/i;
for (const l of lines2) {
const m = l.match(re);
if (m) ports.push({ value: m[1], label: l });
}
if (ports.length > 0) {
// dedupe by value
const seen = new Set();
return ports.filter(p => {
if (seen.has(p.value)) return false;
seen.add(p.value);
return true;
});
}
// Fallback 2: 'mode' command parsing
const out3 = await execPromise("mode");
const lines3 = out3.split(/\r?\n/);
const reMode = /(COM\d+)/gi;
const portsMode = [];
for (const l of lines3) {
const m = l.match(reMode);
if (m) {
for (const token of m) portsMode.push({ value: token, label: token });
}
}
return portsMode;
} catch (err) {
console.error("list-serial-ports (win) primary/fallback failed:", err);
return [];
}
} else {
// macOS / Linux: scan common device files
try {
const out = await execPromise("ls /dev/tty.* /dev/ttyUSB* /dev/ttyACM* 2>/dev/null || true");
const lines = out.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
return lines.map(p => ({ value: p, label: p }));
} catch (err) {
console.error("list-serial-ports (unix) failed:", err);
return [];
}
}
} catch (err) {
console.error("list-serial-ports failed:", err);
return [];
}
});
// --- new IPC handlers to start/stop and forward serial updates ---
// Ensure we don't double-register handlers if the file is reloaded in dev reloads:
if (ipcMain.removeHandler) {
try { ipcMain.removeHandler("connect-com"); } catch (e) { /* ignore */ }
}
ipcMain.handle("connect-com", async (event, { port, baudRate = 9600 }) => {
try {
// if already connected, return info
if (arduinoInstance) {
return { ok: false, message: "Already connected" };
}
if (!port) return { ok: false, message: "No port specified" };
// create and wire instance (verbose disabled to avoid noisy slider logs)
arduinoInstance = new ArduinoThread(port, Number(baudRate) || 9600, 3, "config.json", false);
// Keep last normalized value per slider so we only act when the slider actually changed.
// Index = slider number (0..2). null means "unknown / not initialized".
let _lastSliderNormalized = [null, null, null];
const _minSliderDeltaForChange = 0.01; // 1% change required to be considered different
arduinoInstance.on("update", (values) => {
// forward to renderer
try {
if (mainWindow && mainWindow.webContents) {
mainWindow.webContents.send("serial-update", values);
}
} catch (e) { /* ignore */ }
// Also ensure the Python helper is invoked for each slider value.
// This is a fallback in case the ArduinoThread did not emit 'set-volume'.
try {
const cfg = readConfig() || {};
// values expected as integers (0..1023)
if (Array.isArray(values)) {
// ensure we have a session cache and refresher running
startSessionRefresher();
values.forEach((raw, idx) => {
const num = Number(raw);
if (!Number.isFinite(num)) return;
let normalized = Math.max(0, Math.min(1, num / 1023));
// snap near-zero to exact 0 to avoid tiny audible residuals
if (normalized < 0.02) normalized = 0;
// only continue if the slider value changed enough since last time
const prev = _lastSliderNormalized[idx];
const changed = prev === null || Math.abs(prev - normalized) > _minSliderDeltaForChange;
if (!changed) return; // skip unchanged slider
_lastSliderNormalized[idx] = normalized;
const key = `slider${idx}`;
const procs = Array.isArray(cfg[key]) ? cfg[key] : [];
if (!procs || procs.length === 0) return;
// match configured process names against running sessions (case-insensitive)
procs.forEach((proc) => {
if (!proc) return;
const procLow = String(proc).toLowerCase();
const matched = _runningSessions.some(s => {
return s === procLow || s.includes(procLow) || procLow.includes(s);
});
if (matched) {
// enqueue only when matched; DO NOT produce per-update verbose logs
runPythonSetVolume(proc, normalized, idx);
}
});
});
}
} catch (err) {
console.error("update handler fallback failed:", err);
sendVolumeLog(`update handler fallback failed: ${String(err)}`, true);
}
});
arduinoInstance.on("raw", (raw) => {
try { if (mainWindow && mainWindow.webContents) mainWindow.webContents.send("serial-raw", raw); } catch (e) {}
});
arduinoInstance.on("error", (err) => {
try { if (mainWindow && mainWindow.webContents) mainWindow.webContents.send("serial-error", String(err)); } catch (e) {}
});
arduinoInstance.on("warn", (w) => {
try { if (mainWindow && mainWindow.webContents) mainWindow.webContents.send("serial-warn", w); } catch (e) {}
});
// --- new helper to call the python volume setter and emit logs to renderer ---
// send only concise important logs to renderer to avoid flooding DevTools
const _verboseVolumeLogs = false;
function sendVolumeLog(msg, force = false) {
try {
// keep main-process console for troubleshooting but avoid massive logs
if (_verboseVolumeLogs || force || /\b(error|failed|succeed|succeeded)\b/i.test(String(msg))) {
console.log(msg);
try { if (mainWindow && mainWindow.webContents) mainWindow.webContents.send("volume-log", String(msg)); } catch (e) {}
}
} catch (e) { /* ignore */ }
}
// cached running audio sessions (lowercase strings)
let _runningSessions = [];
let _sessionRefreshTimer = null;
const _sessionRefreshIntervalMs = 5000; // less frequent to avoid many spawns
// ask set_volume.py to list sessions (tries several invocation patterns)
async function refreshRunningSessions() {
const script = path.join(__dirname, "set_volume.py");
// Try invocation patterns; prefer "python <script>" as you run manually
const attempts = process.platform === "win32"
? [ ["python", script, "--list"], ["python3", script, "--list"], ["py", "-3", script, "--list"], ["py", script, "--list"] ]
: [ ["python3", script, "--list"], ["python", script, "--list"] ];
for (const att of attempts) {
const exe = att[0];
const args = att.slice(1);
try {
// removed verbose per-attempt logging to avoid noise
const out = await new Promise((resolve, reject) => {
const child = execFile(exe, args, { windowsHide: true }, (err, stdout, stderr) => {
if (err) return reject({ err, stderr });
resolve((stdout || "").toString());
});
child.on("error", (e) => reject({ err: e, stderr: "" }));
});
const lines = out.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
_runningSessions = lines.map(l => l.toLowerCase());
// previously sent a refreshRunningSessions: found ... log here — removed to reduce noise
return;
} catch (e) {
// intentionally quiet on per-attempt failures; continue trying others
}
}
// all attempts failed — keep previous cache. Do not spam logs; only keep a very concise console hint.
// (If you want visible errors for troubleshooting, reintroduce a targeted sendVolumeLog or console.warn here.)
// sendVolumeLog("refreshRunningSessions: all interpreter attempts failed. Install Python or ensure 'python'/'py' is on PATH.");
}
function startSessionRefresher() {
if (_sessionRefreshTimer) return;
// initial immediate refresh
refreshRunningSessions();
_sessionRefreshTimer = setInterval(refreshRunningSessions, _sessionRefreshIntervalMs);
}
function stopSessionRefresher() {
if (_sessionRefreshTimer) {
clearInterval(_sessionRefreshTimer);
_sessionRefreshTimer = null;
}
}
// Queue + rate-limit for invoking set_volume.py to avoid process storming
const _volumeCallQueue = [];
let _volumeActive = 0;
const _volumeMaxConcurrent = 2; // small concurrency to avoid overload
const _perProcLast = {}; // procName -> { lastValue, lastTimeMillis }
const _minValueDelta = 0.01; // only call if value changed by > 1%
const _minIntervalMs = 0; // at least 200ms between calls per proc
function enqueueVolumeCall(procName, value, slider) {
if (!procName) return;
const now = Date.now();
const key = String(procName).toLowerCase();
const prev = _perProcLast[key] || { lastValue: null, lastTime: 0 };
const deltaOk = prev.lastValue === null || Math.abs(prev.lastValue - value) > _minValueDelta;
const timeOk = (now - prev.lastTime) >= _minIntervalMs;
// Only enqueue if value significantly changed or interval passed
if (!deltaOk && !timeOk) {
// skip noisy updates
return;
}
// optimistic update to prevent duplicate rapid enqueues
_perProcLast[key] = { lastValue: value, lastTime: now };
// dedupe: if a queued entry for same proc exists, replace its value with the newest
const existingIndex = _volumeCallQueue.findIndex(it => String(it.procName).toLowerCase() === key);
if (existingIndex >= 0) {
_volumeCallQueue[existingIndex].value = Number(value);
_volumeCallQueue[existingIndex].slider = slider || null;
} else {
_volumeCallQueue.push({ procName: String(procName), value: Number(value), slider: slider || null });
}
// cap queue size to avoid runaway growth
const maxQueue = 800;
if (_volumeCallQueue.length > maxQueue) {
_volumeCallQueue.splice(0, _volumeCallQueue.length - maxQueue);
}
processVolumeQueue();
}
// Replace processVolumeQueue() internals to prefer persistent python server
function processVolumeQueue() {
if (_volumeActive >= _volumeMaxConcurrent) return;
if (_volumeCallQueue.length === 0) return;
// Attempt to send to persistent python server first
(async () => {
const item = _volumeCallQueue.shift();
_volumeActive++;
// Ensure server is running (best-effort)
const haveServer = await ensurePythonServer();
if (haveServer && pythonServer && pythonServer.stdin && !pythonServer.killed) {
try {
const payload = JSON.stringify({ cmd: "set", process: item.procName, value: Number(item.value) }) + "\n";
pythonServer.stdin.write(payload, "utf8", (err) => {
if (err) {
console.error("pythonServer write error:", err);
}
});
// we assume the Python server will apply the change quickly; log concisely
sendVolumeLog(`sent to python-server: ${item.procName}=${item.value}`, false);
} catch (err) {
// fall back to spawning a process if write fails
sendVolumeLog(`python-server write failed, falling back: ${err}`, true);
// fallback to per-call attempts (existing execFile logic)
trySpawnFallback(item).catch(e => {
sendVolumeLog(`fallback spawn failed: ${e}`, true);
});
} finally {
_volumeActive = Math.max(0, _volumeActive - 1);
setImmediate(processVolumeQueue);
}
return;
}
// If no server, use original spawn attempts (keeps previous behavior)
try {
await trySpawnFallback(item);
} catch (e) {
sendVolumeLog(`All interpreter attempts failed for '${item.procName}'.`, true);
} finally {
_volumeActive = Math.max(0, _volumeActive - 1);
setImmediate(processVolumeQueue);
}
})();
}
// Try to start a persistent python server (tries several interpreters).
function ensurePythonServer() {
if (pythonServer && !pythonServer.killed) return Promise.resolve(true);
const script = path.join(__dirname, "set_volume.py");
const interpreters = process.platform === "win32"
? ["python", "python3", "py"]
: ["python3", "python"];
let lastErr = null;
return new Promise((resolve) => {
(function tryInterp(i) {
if (i >= interpreters.length) {
console.error("Could not start python server:", lastErr);
pythonServer = null;
pythonServerReady = false;
return resolve(false);
}
const exe = interpreters[i];
try {
const child = spawn(exe, [script, "--server-stdin"], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
let started = false;
child.on("error", (err) => {
lastErr = err;
// try next interpreter
tryInterp(i + 1);
});
child.stdout.on("data", (chunk) => {
// accumulate or just log; we don't need to parse every response here
try {
const s = chunk.toString();
pythonServerStdoutBuffer += s;
// optionally parse complete lines if needed
} catch (e) { /* ignore */ }
});
child.stderr.on("data", (chunk) => {
console.error("python server stderr:", chunk.toString());
});
child.on("close", (code) => {
pythonServer = null;
pythonServerReady = false;
console.warn("python server closed, code=", code);
});
// treat as started
pythonServer = child;
pythonServerReady = true;
return resolve(true);
} catch (e) {
lastErr = e;
tryInterp(i + 1);
}
})(0);
});
}
// helper: existing per-call spawn attempts factored out so we can reuse it as fallback
async function trySpawnFallback(item) {
const script = path.join(__dirname, "set_volume.py");
const attempts = process.platform === "win32"
? [ ["python", script, String(item.procName), String(item.value)], ["python3", script, String(item.procName), String(item.value)], ["py", "-3", script, String(item.procName), String(item.value)], ["py", script, String(item.procName), String(item.value)] ]
: [ ["python3", script, String(item.procName), String(item.value)], ["python", script, String(item.procName), String(item.value)] ];
for (const att of attempts) {
const exe = att[0];
const args = att.slice(1);
try {
sendVolumeLog(`exec ${exe} ${args.join(" ")} -> ${item.procName} value=${item.value}`, false);
await new Promise((resolve, reject) => {
const child = execFile(exe, args, { windowsHide: true }, (err, stdout, stderr) => {
if (err) {
sendVolumeLog(`${exe} error: ${err.message}. stderr: ${stderr ? stderr.trim() : ""}`, true);
return reject(err);
}
return resolve();
});
child.on("error", (spawnErr) => {
sendVolumeLog(`${exe} spawn error: ${spawnErr.message}`, true);
reject(spawnErr);
});
});
sendVolumeLog(`set_volume succeeded for '${item.procName}'`, true);
return;
} catch (e) {
sendVolumeLog(`${exe} failed for '${item.procName}': ${String(e)}`, true);
// try next
}
}
throw new Error("All attempts failed");
}
// expose a simpler API used by other handlers
function runPythonSetVolume(procName, value, slider) {
enqueueVolumeCall(procName, value, slider);
}
// start session refresher once (do not start it repeatedly per update)
startSessionRefresher();
// throttle serial updates processed (avoid queueing on every single sample)
let _lastUpdateAt = 0;
const _updateThrottleMs = 120; // process ~16 updates/sec
// add listener to actually apply per-process volume changes via Python helper
arduinoInstance.on("set-volume", (info) => {
try {
// info: { process: procName, value: normalizedFloat, slider: sliderNumber }
const procName = info && info.process ? info.process : "";
const value = (typeof info.value === "number") ? info.value : 0;
const slider = typeof info.slider !== "undefined" ? info.slider : null;
if (!procName) {
sendVolumeLog(`set-volume received but no process name in info: ${JSON.stringify(info)}`);
return;
}
sendVolumeLog(`set-volume received -> process="${procName}" value=${value} slider=${slider}`);
// call Python helper (now queued/rate-limited)
runPythonSetVolume(procName, value, slider);
} catch (err) {
console.error("set-volume handler failed:", err);
sendVolumeLog(`set-volume handler failed: ${String(err)}`);
}
});
// no explicit start() needed — ArduinoThread constructor wires port and events
return { ok: true };
} catch (err) {
console.error("connect-com failed:", err);
arduinoInstance = null;
return { ok: false, message: String(err) };
}
});
// before registering disconnect, remove any previous handler
if (ipcMain.removeHandler) {
try { ipcMain.removeHandler("disconnect-com"); } catch (e) { /* ignore */ }
}
ipcMain.handle("disconnect-com", async () => {
try {
if (!arduinoInstance) return { ok: false, message: "Not connected" };
// stop session refresher if running
try { stopSessionRefresher(); } catch (e) { /* ignore */ }
try { arduinoInstance.stop(); } catch (e) { /* ignore */ }
arduinoInstance = null;
return { ok: true };
} catch (err) {
console.error("disconnect-com failed:", err);
return { ok: false, message: String(err) };
}
});
app.on("window-all-closed", () => {
// destroy tray if present
try { destroyTray(); } catch (e) { /* ignore */ }
if (process.platform !== "darwin") {
app.quit();
}
// destroy tray if present
try { destroyTray(); } catch (e) { /* ignore */ }
if (process.platform !== "darwin") {
app.quit();
}
});

4278
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,63 +0,0 @@
{
"name": "dreckshub",
"version": "1.0.0",
"description": "Dreckshub",
"main": "main.js",
"scripts": {
"start": "electron .",
"dist": "electron-builder",
"dist:win": "electron-builder --win --x64",
"dist:win-elevate": "powershell -Command \"Start-Process -Verb runAs npm -ArgumentList 'run','dist:win'\"",
"dist:linux": "electron-builder --linux --x64",
"dist:mac": "electron-builder --mac",
"postinstall": "electron-builder install-app-deps"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"electron": "^35.1.2",
"electron-builder": "^24.13.3"
},
"build": {
"asar": false,
"appId": "com.example.dreckshub",
"productName": "Dreckshub",
"directories": {
"output": "releases",
"buildResources": "build"
},
"files": [
"**/*"
],
"win": {
"target": [
"nsis",
"portable"
],
"publisherName": "Drecksladen",
"icon": "build/app.ico"
},
"nsis": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": "always",
"createStartMenuShortcut": true,
"shortcutName": "Dreckshub"
},
"linux": {
"target": [
"AppImage",
"deb"
],
"icon": "build/app.png"
},
"mac": {
"icon": "build/app.icns"
}
},
"dependencies": {
"serialport": "^13.0.0"
}
}

View File

@@ -1,27 +0,0 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("api", {
saveTags: (tags) => ipcRenderer.send("save-tags", tags),
loadTags: () => ipcRenderer.invoke("load-tags"),
listSerialPorts: () => ipcRenderer.invoke("list-serial-ports"),
// connect/disconnect + subscription API
connectCom: (port, baudRate) => ipcRenderer.invoke("connect-com", { port, baudRate }),
disconnectCom: () => ipcRenderer.invoke("disconnect-com"),
onSerialUpdate: (cb) => {
const listener = (event, data) => { cb(data); };
ipcRenderer.on("serial-update", listener);
// return unregister function
return () => ipcRenderer.removeListener("serial-update", listener);
},
onSerialRaw: (cb) => {
const listener = (event, data) => { cb(data); };
ipcRenderer.on("serial-raw", listener);
return () => ipcRenderer.removeListener("serial-raw", listener);
},
onVolumeLog: (cb) => {
const listener = (event, data) => { cb(data); };
ipcRenderer.on("volume-log", listener);
return () => ipcRenderer.removeListener("volume-log", listener);
}
});

View File

@@ -1,814 +0,0 @@
document.addEventListener("DOMContentLoaded", () => {
// per-slider tag storage
const sliderIds = ["slider0", "slider1", "slider2"];
let tags = { slider0: [], slider1: [], slider2: [] };
// slider values (existing logic) - start at 0
const sliderValues = [0, 0, 0];
// receive volume helper logs from main early so they're always visible in DevTools
if (window.api && window.api.onVolumeLog) {
let _lastVolumeLogAt = 0;
const _volumeLogThrottleMs = 200; // show at most 5 logs/sec to keep DevTools responsive
window.api.onVolumeLog((msg) => {
try {
const now = Date.now();
if (now - _lastVolumeLogAt < _volumeLogThrottleMs) return;
_lastVolumeLogAt = now;
console.log("Volume log:", msg);
} catch (e) { /* ignore */ }
});
}
// --- helper: apply device-specific view mapping (show/hide device-content blocks) ---
function applyDeviceView(device) {
try {
const key = device ? String(device).trim().toLowerCase() : "";
// show matching device-content blocks, hide the rest
document.querySelectorAll(".device-content").forEach(el => {
const elDev = (el.getAttribute("data-device") || "").toLowerCase();
if (elDev === key) {
el.style.display = ""; // let CSS decide (block/inline)
} else {
el.style.display = "none";
}
});
// also store current device as a body data attribute for optional CSS usage
if (key) document.body.setAttribute("data-device", key);
else document.body.removeAttribute("data-device");
} catch (e) { /* ignore */ }
}
// load config (tags per slider)
window.api.loadTags().then((data) => {
tags.slider0 = (data && Array.isArray(data.slider0)) ? data.slider0.slice() : [];
tags.slider1 = (data && Array.isArray(data.slider1)) ? data.slider1.slice() : [];
tags.slider2 = (data && Array.isArray(data.slider2)) ? data.slider2.slice() : [];
// initial render for each slider's tag container
sliderIds.forEach(id => renderTags(id));
// set initial device selection if present
try {
const deviceSelect = document.getElementById("device-select");
if (deviceSelect && data && typeof data.device === "string") {
deviceSelect.value = data.device;
// APPLY saved device-specific content visibility
applyDeviceView(deviceSelect.value);
}
// populate COM port select and restore previously saved selection
const comSelect = document.getElementById("com-port-select");
const comRefreshBtn = document.getElementById("com-refresh-btn");
async function populateComPorts(selected) {
if (!window.api || !window.api.listSerialPorts) return;
if (!comSelect) return;
// show searching placeholder
comSelect.innerHTML = '<option value="">Searching...</option>';
try {
const ports = await window.api.listSerialPorts(); // can be array of strings or {value,label}
console.log("listSerialPorts ->", ports);
// reset select, always keep explicit "(none)"
comSelect.innerHTML = '<option value="">(none)</option>';
if (!ports || ports.length === 0) {
// leave only (none)
if (selected) {
// if user had a saved selection that didn't appear, re-add it so it can be restored
const opt = document.createElement("option");
opt.value = String(selected);
opt.textContent = String(selected);
comSelect.appendChild(opt);
comSelect.value = selected;
}
return;
}
ports.forEach(p => {
let value, label;
if (typeof p === "string") {
value = label = p;
} else if (p && typeof p === "object") {
value = String(p.value);
label = p.label || p.value;
} else {
return;
}
const opt = document.createElement("option");
opt.value = value;
opt.textContent = label;
comSelect.appendChild(opt);
});
if (selected) {
// restore if present; otherwise keep default
try { comSelect.value = selected; } catch (e) { /* ignore */ }
}
} catch (err) {
console.warn("populateComPorts failed", err);
// show fallback
comSelect.innerHTML = '<option value="">(none)</option>';
}
}
if (comRefreshBtn) {
comRefreshBtn.addEventListener("click", () => {
populateComPorts(comSelect ? comSelect.value : undefined);
}, { passive: true });
}
// restore saved COM selection if present
if (data && typeof data.comPort === "string") {
populateComPorts(data.comPort);
} else {
populateComPorts();
}
// wire change to save configuration
if (comSelect) {
comSelect.addEventListener("change", () => saveConfig());
}
// --- NEW: initialize the three checkboxes from loaded config and wire them to save ---
const autoEl = document.getElementById("autoconnect");
const startEl = document.getElementById("startup-with-system");
const trayEl = document.getElementById("close-to-tray");
if (autoEl) autoEl.checked = !!(data && data.autoconnect);
if (startEl) startEl.checked = !!(data && data.startupWithSystem);
if (trayEl) trayEl.checked = !!(data && data.closeToTray);
if (autoEl) autoEl.addEventListener("change", () => saveConfig());
if (startEl) startEl.addEventListener("change", () => saveConfig());
if (trayEl) trayEl.addEventListener("change", () => saveConfig());
// --- RESTORE customisation values into color inputs (if present) ---
try {
const cust = data && data.customisation ? data.customisation : {};
const colorIds = ['cust-bg','cust-sidebar','cust-button','cust-pot','cust-accent'];
colorIds.forEach(id => {
const el = document.getElementById(id);
if (!el) return;
if (cust && typeof cust[id] === 'string') {
try { el.value = cust[id]; } catch (e) { /* ignore */ }
}
});
// apply any restored customisation immediately
if (window.applyCustomisation && typeof window.applyCustomisation === 'function') {
const payload = {};
if (cust['cust-bg']) payload.background = cust['cust-bg'];
if (cust['cust-sidebar']) payload.sidebar = cust['cust-sidebar'];
if (cust['cust-button']) payload.button = cust['cust-button'];
if (cust['cust-pot']) payload.potentiometer = cust['cust-pot'];
if (cust['cust-accent']) payload.accent = cust['cust-accent'];
applyCustomisation(payload);
}
} catch (e) { /* ignore */ }
// existing device select is already wired above
} catch (err) { /* ignore */ }
});
function saveConfig() {
// persist tags under slider0/slider1/slider2 and current device selection
const deviceEl = document.getElementById("device-select");
const deviceVal = deviceEl ? String(deviceEl.value) : undefined;
const comEl = document.getElementById("com-port-select");
const comVal = comEl ? String(comEl.value) : undefined;
// read new checkbox states
const autoEl = document.getElementById("autoconnect");
const startEl = document.getElementById("startup-with-system");
const trayEl = document.getElementById("close-to-tray");
const autoVal = autoEl ? Boolean(autoEl.checked) : false;
const startVal = startEl ? Boolean(startEl.checked) : false;
const trayVal = trayEl ? Boolean(trayEl.checked) : false;
const payload = {
slider0: tags.slider0,
slider1: tags.slider1,
slider2: tags.slider2
};
if (deviceVal !== undefined) payload.device = deviceVal;
if (comVal !== undefined) payload.comPort = comVal;
// include new checkbox values
payload.autoconnect = autoVal;
payload.startupWithSystem = startVal;
payload.closeToTray = trayVal;
// --- NEW: read customisation inputs and include in payload ---
try {
const colorIds = ['cust-bg','cust-sidebar','cust-button','cust-pot','cust-accent'];
const cust = {};
colorIds.forEach(id => {
const el = document.getElementById(id);
if (!el) return;
// store by element id for straightforward restore (keeps mapping simple)
cust[id] = String(el.value || el.getAttribute('data-default') || '');
});
// only include if any value present
if (Object.keys(cust).length > 0) payload.customisation = cust;
} catch (e) { /* ignore */ }
window.api.saveTags(payload);
}
// wire device dropdown to save on change
(function wireDeviceSelect() {
const deviceEl = document.getElementById("device-select");
if (!deviceEl) return;
deviceEl.addEventListener("change", () => {
// update visible device-specific fragments (do not switch tabs)
applyDeviceView(deviceEl.value);
saveConfig();
});
})();
function addTag(sliderId, value) {
// support passing an array or a comma-separated string
if (!value) return;
const arr = tags[sliderId] || [];
// normalize incoming values into an array of trimmed non-empty strings
let items = [];
if (Array.isArray(value)) {
items = value.map(v => String(v).trim()).filter(v => v !== "");
} else {
items = String(value)
.split(",")
.map(s => s.trim())
.filter(s => s !== "");
}
if (items.length === 0) return;
// insert each item at the beginning, preserving the order from left-to-right
// (so we reverse before unshifting)
items.slice().reverse().forEach(item => {
if (!arr.includes(item)) {
arr.unshift(item);
}
});
tags[sliderId] = arr;
renderTags(sliderId);
saveConfig();
}
function removeTag(sliderId, index) {
const arr = tags[sliderId] || [];
arr.splice(index, 1);
tags[sliderId] = arr;
renderTags(sliderId);
saveConfig();
}
function renderTags(sliderId) {
const container = document.getElementById(`tag-container-${sliderId}`);
if (!container) return;
container.innerHTML = "";
const arr = tags[sliderId] || [];
arr.forEach((tag, idx) => {
const tagElement = document.createElement("div");
tagElement.classList.add("tag");
// create remove button on the LEFT (now includes a separator after the X)
const removeBtn = document.createElement("span");
removeBtn.classList.add("remove-tag");
removeBtn.setAttribute("data-slider", sliderId);
removeBtn.setAttribute("data-index", String(idx));
// keep the button content minimal; separator will come from CSS ::after
removeBtn.textContent = "\u00D7"; // "×"
// create text node (safe) for the tag label
const textSpan = document.createElement("span");
textSpan.classList.add("tag-text");
textSpan.textContent = tag;
// append remove button first, then text
tagElement.appendChild(removeBtn);
tagElement.appendChild(textSpan);
container.appendChild(tagElement);
});
// wire remove buttons
container.querySelectorAll(".remove-tag").forEach(btn => {
btn.addEventListener("click", (e) => {
const s = e.target.getAttribute("data-slider");
const i = Number(e.target.getAttribute("data-index"));
removeTag(s, i);
});
});
}
// wire per-slider inputs/buttons (improved: supports Enter/comma split, blur)
sliderIds.forEach((id) => {
const input = document.getElementById(`tag-input-${id}`);
const addBtn = document.getElementById(`tag-add-${id}`);
if (input) {
// add on Enter or comma key (prevents the comma char from being inserted)
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
addTag(id, input.value);
input.value = "";
}
});
// also add remaining text when the field loses focus
input.addEventListener("blur", () => {
if (input.value && input.value.trim() !== "") {
addTag(id, input.value);
input.value = "";
}
});
}
if (addBtn) {
addBtn.addEventListener("click", () => {
if (input && input.value && input.value.trim() !== "") {
addTag(id, input.value);
input.value = "";
}
});
}
});
// --- existing slider logic (wire vertical sliders) ---
// keep original slider wiring so UI updates values
sliderIds.forEach((id, idx) => {
const slider = document.getElementById(id);
const valueBubble = document.getElementById(`${id}-value-bubble`);
if (slider && valueBubble) {
// helper: update bubble text and position
function updateBubble(val) {
const v = Number(val);
sliderValues[idx] = v;
if (valueBubble) valueBubble.textContent = String(v);
// position bubble next to the slider thumb using bounding rects and bubble height
try {
const min = Number(slider.min) || 0;
const max = Number(slider.max) || 100;
const pct = (v - min) / (max - min || 100);
// estimate thumb height (matches CSS .vertical-slider thumb)
const thumbH = 28;
// extension in pixels to lengthen the bubble rail beyond actual thumb travel
const ext = 4; // reduced by 4px as requested
const sliderRect = slider.getBoundingClientRect();
const parentRect = slider.parentElement.getBoundingClientRect();
// compute new min/max for thumb center including extension
const centerMin = (thumbH / 2) - ext;
const centerMax = (sliderRect.height - thumbH / 2) + ext;
const available = Math.max(0, centerMax - centerMin);
// thumb center within the slider rect including extension
const yWithinSliderRect = centerMin + available * (1 - pct);
// convert to parent (.slider-controls) coordinate and center the bubble by subtracting half its height
const bubbleHalf = valueBubble.offsetHeight ? (valueBubble.offsetHeight / 2) : 0;
const top = (sliderRect.top - parentRect.top) + yWithinSliderRect - bubbleHalf;
// Use transform for positioning (GPU-accelerated). Keep top at 0 in CSS.
valueBubble.style.transform = `translateY(${Math.round(top)}px)`;
} catch (err) {
// ignore position errors
}
// removed per-input console.log to keep UI responsive
}
slider.addEventListener("input", (e) => {
updateBubble(e.target.value);
});
// set initial position/value once DOM is ready
requestAnimationFrame(() => {
updateBubble(slider.value);
requestAnimationFrame(() => updateBubble(slider.value));
setTimeout(() => updateBubble(slider.value), 60);
});
}
});
// recompute bubble positions on resize (keeps them aligned if layout changes)
window.addEventListener("resize", () => {
sliderIds.forEach((id, idx) => {
const slider = document.getElementById(id);
const valueBubble = document.getElementById(`${id}-value-bubble`);
if (!slider || !valueBubble) return;
try {
const min = Number(slider.min) || 0;
const max = Number(slider.max) || 100;
const v = Number(slider.value);
const pct = (v - min) / (max - min || 100);
const thumbH = 28;
const ext = 4; // keep same extension as updateBubble (reduced)
const sliderRect = slider.getBoundingClientRect();
const parentRect = slider.parentElement.getBoundingClientRect();
const centerMin = (thumbH / 2) - ext;
const centerMax = (sliderRect.height - thumbH / 2) + ext;
const available = Math.max(0, centerMax - centerMin);
const yWithinSliderRect = centerMin + available * (1 - pct);
const bubbleHalf = valueBubble.offsetHeight ? (valueBubble.offsetHeight / 2) : 0;
const top = (sliderRect.top - parentRect.top) + yWithinSliderRect - bubbleHalf;
// Use transform to reposition instantly and smoothly
valueBubble.style.transform = `translateY(${Math.round(top)}px)`;
} catch (err) {
// ignore
}
});
}, { passive: true });
// --- add thumb dragging class while a slider is being grabbed ---
(function wireThumbDragging() {
// remove class on global pointerup/leave to ensure cleanup
function clearAll() {
sliderIds.forEach(id => {
const s = document.getElementById(id);
if (s && s.classList.contains("thumb-dragging")) s.classList.remove("thumb-dragging");
});
}
sliderIds.forEach((id) => {
const slider = document.getElementById(id);
if (!slider) return;
slider.addEventListener("pointerdown", (e) => {
// add class immediately when pointer interacts with the slider
slider.classList.add("thumb-dragging");
// capture pointer so we still receive the up event even if pointer leaves the thumb
try { slider.setPointerCapture && slider.setPointerCapture(e.pointerId); } catch (err) { /* ignore */ }
}, { passive: true });
// remove when pointer is released on the slider itself
slider.addEventListener("pointerup", () => {
slider.classList.remove("thumb-dragging");
});
// ensure we clear if pointer is canceled
slider.addEventListener("pointercancel", () => {
slider.classList.remove("thumb-dragging");
});
// also clear on global pointerup (in case pointerup happens outside element)
window.addEventListener("pointerup", () => {
slider.classList.remove("thumb-dragging");
}, { passive: true });
});
// clear on leave/unload to avoid stuck state
window.addEventListener("blur", clearAll);
window.addEventListener("pointerleave", clearAll);
})();
// --- Tab switching logic (unchanged) ---
const tabButtons = document.querySelectorAll(".tab-button");
const tabPanels = document.querySelectorAll(".tab-content");
function showTab(targetId) {
tabPanels.forEach(p => p.classList.toggle("active", p.id === targetId));
tabButtons.forEach(b => b.classList.toggle("active", b.getAttribute("data-target") === targetId));
// If the Sliders tab becomes visible, force a layout-stable recompute of bubble positions.
// We reuse the existing resize handler logic by dispatching a synthetic resize event
// after layout has had a chance to settle (double rAF + small timeout fallback).
if (targetId === "tab-3") {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
window.dispatchEvent(new Event("resize"));
});
setTimeout(() => window.dispatchEvent(new Event("resize")), 80);
});
}
}
tabButtons.forEach(btn => btn.addEventListener("click", () => showTab(btn.getAttribute("data-target"))));
showTab("tab-1");
// --- Settings overlay logic (unchanged) ---
const settingsBtn = document.getElementById("settings-btn");
const settingsOverlay = document.getElementById("settings-overlay");
const closeSettings = document.getElementById("close-settings");
function toggleSettingsOverlay(show) {
if (!settingsOverlay) return;
settingsOverlay.setAttribute("aria-hidden", String(!show));
if (show) {
// show first settings tab by default
showSettingsTab("settings-tab-1");
}
}
if (settingsBtn) settingsBtn.addEventListener("click", () => toggleSettingsOverlay(true));
if (closeSettings) closeSettings.addEventListener("click", () => toggleSettingsOverlay(false));
const settingsTabButtons = document.querySelectorAll(".settings-tab-button");
const settingsTabContents = document.querySelectorAll(".settings-tab-content");
function showSettingsTab(targetId) {
settingsTabContents.forEach(p => p.classList.toggle("active", p.id === targetId));
settingsTabButtons.forEach(b => b.classList.toggle("active", b.getAttribute("data-target") === targetId));
}
settingsTabButtons.forEach(btn => btn.addEventListener("click", () => showSettingsTab(btn.getAttribute("data-target"))));
toggleSettingsOverlay(false);
// --- Potentiometer visual (unchanged) ---
let potValue = 50;
const potEl = document.getElementById("pot-0");
const potValueEl = potEl ? potEl.querySelector(".pot-value") : null;
const potRingEl = potEl ? potEl.querySelector(".pot-ring") : null;
function updatePot(value) {
if (!potEl) return;
potValue = Math.max(0, Math.min(100, Number(value) || 0));
if (potValueEl) potValueEl.textContent = String(potValue);
if (potRingEl) potRingEl.style.setProperty("--deg", `${potValue * 3.6}deg`);
console.log(`pot updated -> ${potValue}`);
}
window.setPotValue = function(v) { updatePot(v); };
window.getPotValue = function() { return potValue; };
updatePot(potValue);
// --- Connect / Disconnect wiring ---
const connectBtn = document.getElementById("connect-com-btn");
let unregisterSerial = null;
let isConnected = false;
function setConnectedUI(connected) {
isConnected = !!connected;
if (!connectBtn) return;
if (connected) {
connectBtn.textContent = "Disconnect";
connectBtn.classList.remove("connect-state");
connectBtn.classList.add("disconnect-state");
} else {
connectBtn.textContent = "Connect";
connectBtn.classList.remove("disconnect-state");
connectBtn.classList.add("connect-state");
// ensure UI sliders reset to 0 on disconnect / initial state
sliderIds.forEach((sid, idx) => {
const sliderEl = document.getElementById(sid);
if (!sliderEl) return;
sliderEl.value = "0";
// update bubble/position via input event
sliderEl.dispatchEvent(new Event('input', { bubbles: true }));
// update internal state array
sliderValues[idx] = 0;
});
}
}
if (connectBtn) {
connectBtn.addEventListener("click", async () => {
const comSelect = document.getElementById("com-port-select");
const port = comSelect ? comSelect.value : "";
if (!port) {
console.warn("No COM port selected");
return;
}
if (!isConnected) {
await doConnect(port);
} else {
await doDisconnect();
}
}, { passive: true });
// initialize UI state
setConnectedUI(false);
}
// --- AUTOCONNECT: after COM list population, try to connect when configured ---
(async function maybeAutoConnect() {
try {
const cfg = await window.api.loadTags();
if (cfg && cfg.autoconnect && cfg.comPort) {
setTimeout(() => {
const comSelect = document.getElementById("com-port-select");
if (comSelect) {
try { comSelect.value = cfg.comPort; } catch (e) { /* ignore */ }
}
doConnect(cfg.comPort);
}, 120);
}
} catch (e) { /* ignore */ }
})();
// --- NEW: shared connect/disconnect helpers for autoconnect and button click ---
async function doConnect(port) {
if (!port) {
console.warn("doConnect: no port specified");
return { ok: false, message: "No port" };
}
if (isConnected) {
return { ok: false, message: "Already connected" };
}
try {
const result = await window.api.connectCom(port, 9600);
if (result && result.ok) {
// subscribe to serial updates exactly as the original click handler did
unregisterSerial = window.api.onSerialUpdate((values) => {
if (!Array.isArray(values)) return;
sliderIds.forEach((sid, idx) => {
const sliderEl = document.getElementById(sid);
if (!sliderEl) return;
const raw = values[idx];
if (typeof raw !== "number") return;
const pct = Math.round((raw * 100) / 1023);
const clamped = Math.max(0, Math.min(100, pct));
sliderEl.value = String(clamped);
sliderEl.dispatchEvent(new Event('input', { bubbles: true }));
sliderValues[idx] = clamped;
});
});
setConnectedUI(true);
return { ok: true };
} else {
console.warn("Connect failed:", result && result.message);
return { ok: false, message: result && result.message };
}
} catch (err) {
console.warn("doConnect error:", err);
return { ok: false, message: String(err) };
}
}
async function doDisconnect() {
if (!isConnected) {
return { ok: false, message: "Not connected" };
}
try {
const res = await window.api.disconnectCom();
if (res && res.ok) {
if (typeof unregisterSerial === "function") unregisterSerial();
unregisterSerial = null;
setConnectedUI(false);
return { ok: true };
} else {
console.warn("Disconnect returned:", res && res.message);
return { ok: false, message: res && res.message };
}
} catch (err) {
console.warn("doDisconnect error:", err);
return { ok: false, message: String(err) };
}
}
// --- NEW: utility to rebuild the layered background using current CSS vars and set inline style ---
function setBodyBackgroundFromVars() {
const root = document.documentElement;
const body = document.body;
if (!root || !body) return;
// read variables (fall back to existing defaults)
const bg1 = (getComputedStyle(root).getPropertyValue('--bg-base-1') || '#1a1a1a').trim();
const bg2 = (getComputedStyle(root).getPropertyValue('--bg-base-2') || '#0d0d0f').trim();
const stripe = (getComputedStyle(root).getPropertyValue('--stripe') || 'rgba(255,255,255,0.012)').trim();
// Recreate the layered background used in CSS, but inline so changes are immediate.
const layers = [
`radial-gradient(600px 360px at 10% 10%, rgba(255,255,255,0.03), transparent 18%)`,
`radial-gradient(700px 420px at 92% 86%, rgba(0,0,0,0.12), transparent 45%)`,
`linear-gradient(180deg, ${bg1}, ${bg2})`,
`repeating-linear-gradient(135deg, ${stripe} 0 2px, transparent 2px 8px)`
];
body.style.backgroundImage = layers.join(',\n ');
// keep background-color in sync as a fallback
body.style.backgroundColor = bg2;
body.style.backgroundRepeat = 'no-repeat';
body.style.backgroundSize = 'cover';
}
// --- NEW: apply a customisation payload (keys: background, sidebar, button, potentiometer, accent) ---
function applyCustomisation(payload) {
try {
const root = document.documentElement;
if (!root) return;
// helper: simple hex lighten for bg1 derivation
function lightenHex(hex, pct) {
try {
const h = hex.replace('#','');
const num = parseInt(h,16);
let r = (num >> 16) & 0xFF;
let g = (num >> 8) & 0xFF;
let b = num & 0xFF;
r = Math.min(255, Math.round(r + (255 - r) * pct));
g = Math.min(255, Math.round(g + (255 - g) * pct));
b = Math.min(255, Math.round(b + (255 - b) * pct));
const res = '#' + ((1<<24) + (r<<16) + (g<<8) + b).toString(16).slice(1);
return res;
} catch (e) { return hex; }
}
if (payload.background) {
root.style.setProperty('--bg-base-2', payload.background);
// derive a slightly lighter variant for the top of the gradient
root.style.setProperty('--bg-base-1', lightenHex(payload.background, 0.08));
}
if (payload.sidebar) root.style.setProperty('--sidebar-bg', payload.sidebar);
if (payload.button) root.style.setProperty('--button-bg', payload.button);
if (payload.potentiometer) root.style.setProperty('--pot-color', payload.potentiometer);
if (payload.accent) {
root.style.setProperty('--accent-color', payload.accent);
// create a subtle stripe using accent color with low alpha
try {
const rgb = payload.accent.replace('#','');
const r = parseInt(rgb.slice(0,2),16);
const g = parseInt(rgb.slice(2,4),16);
const b = parseInt(rgb.slice(4,6),16);
root.style.setProperty('--stripe', `rgba(${r},${g},${b},0.012)`);
} catch (e) {
// fallback stripe
root.style.setProperty('--stripe', 'rgba(255,255,255,0.012)');
}
}
// rebuild inline layered body background for immediate result
setBodyBackgroundFromVars();
} catch (e) { /* ignore */ }
}
// --- NEW: re-sync all customisation inputs (applies their values to CSS variables) ---
function syncCustomInputs() {
const idToKey = {
'cust-bg': 'background',
'cust-sidebar': 'sidebar',
'cust-button': 'button',
'cust-pot': 'potentiometer',
'cust-accent': 'accent'
};
const inputs = Array.from(document.querySelectorAll('#settings-tab-2 input[type="color"][data-default]'));
const payload = {};
inputs.forEach(inp => {
const key = idToKey[inp.id];
if (key && inp.value) payload[key] = inp.value;
});
if (Object.keys(payload).length) applyCustomisation(payload);
}
// --- NEW: wire reset-default buttons and live update on color inputs ---
(function wireCustomisationInputs() {
try {
// helper: update visibility of reset button for a given input
function updateResetVisibilityForInput(inp) {
if (!inp) return;
const btn = document.querySelector(`#settings-tab-2 .reset-default[data-target="${inp.id}"]`);
if (!btn) return;
const def = (inp.getAttribute('data-default') || '').toLowerCase();
const cur = (inp.value || '').toLowerCase();
if (cur !== def && cur !== '') {
btn.classList.add('dirty');
btn.setAttribute('aria-hidden', 'false');
} else {
btn.classList.remove('dirty');
btn.setAttribute('aria-hidden', 'true');
}
}
// color inputs change -> apply + save + toggle reset visibility
document.querySelectorAll('#settings-tab-2 input[type="color"][data-default]').forEach(inp => {
// set initial visibility on setup
try { updateResetVisibilityForInput(inp); } catch (e) { /* ignore */ }
inp.addEventListener('input', () => {
syncCustomInputs();
// update associated reset button visibility immediately
try { updateResetVisibilityForInput(inp); } catch (e) { /* ignore */ }
// debounce save to avoid excessive disk writes (coarse)
if (window._custSaveTimer) clearTimeout(window._custSaveTimer);
window._custSaveTimer = setTimeout(() => saveConfig(), 220);
}, { passive: true });
});
// reset buttons: restore default, hide arrow, apply + save
document.querySelectorAll('#settings-tab-2 .reset-default').forEach(btn => {
btn.classList.remove('dirty'); // ensure hidden by default
btn.setAttribute('aria-hidden', 'true');
btn.addEventListener('click', (e) => {
const target = btn.getAttribute('data-target');
if (!target) return;
const inp = document.getElementById(target);
if (!inp) return;
const def = inp.getAttribute('data-default') || inp.value || '';
try { inp.value = def; } catch (er) { /* ignore */ }
// hide the button immediately
try {
btn.classList.remove('dirty');
btn.setAttribute('aria-hidden', 'true');
} catch (er) { /* ignore */ }
// apply changes and persist
syncCustomInputs();
saveConfig();
}, { passive: true });
});
// ensure initial sync and reset-visibility for all inputs now
setTimeout(() => {
try {
document.querySelectorAll('#settings-tab-2 input[type="color"][data-default]').forEach(inp => updateResetVisibilityForInput(inp));
} catch (e) { /* ignore */ }
syncCustomInputs();
}, 40);
} catch (e) { /* ignore */ }
})();
});

1221
styles.css

File diff suppressed because it is too large Load Diff

189
ui.py Normal file
View File

@@ -0,0 +1,189 @@
import customtkinter
class MyTabView(customtkinter.CTkTabview):
def __init__(self, master, **kwargs):
super().__init__(master, **kwargs)
# create tabs
self.add("tab 1")
self.add("tab 2")
# add widgets on tabs
self.label = customtkinter.CTkLabel(master=self.tab("tab 1"))
self.label.grid(row=0, column=0, padx=20, pady=10)
class App(customtkinter.CTk):
def __init__(self):
super().__init__()
self.geometry("1466x642")
self.title("Dreckshub")
# add widgets to app
self.button = customtkinter.CTkButton(self, command=self.button_click)
self.button.grid(row=0, column=0, padx=20, pady=10)
# Slider Menu, will be dynamic per device
self.slider0 = customtkinter.CTkSlider(self, from_=0, to=100, height=470, number_of_steps=100, width=20, orientation="vertical", command=self.slider_event)
self.slider0.grid(row=0, column=3, padx=20, pady=10)
self.slider0.set(0)
self.entry0 = customtkinter.CTkEntry(self, placeholder_text="Slider0")
self.slider1 = customtkinter.CTkSlider(self, from_=0, to=100, height=470, number_of_steps=100, width=20, orientation="vertical", command=self.slider_event)
self.slider1.grid(row=0, column=4, padx=20, pady=10)
self.slider1.set(0)
self.entry1 = customtkinter.CTkEntry(self, placeholder_text="Slider1")
self.slider2 = customtkinter.CTkSlider(self, from_=0, to=100, height=470, number_of_steps=100, width=20, orientation="vertical", command=self.slider_event)
self.slider2.grid(row=0, column=5, padx=20, pady=10)
self.slider2.set(0)
self.entry2 = customtkinter.CTkEntry(self, placeholder_text="Slider2")
# add methods to app
def button_click(self):
print("button click")
def slider_event(self, value):
slider0 = self.slider0.get() / 100
slider1 = self.slider1.get() / 100
slider2 = self.slider2.get() / 100
#format = self.slider0.get() + " | " + self.slider1.get() + " | " + self.slider2.get()
print(str(slider0) + " | " + str(slider1) + " | " + str(slider2))
app = App()
app.mainloop()
#
#import customtkinter as ctk
#
#class MyTabView(ctk.CTkTabview):
# def __init__(self, master, **kwargs):
# super().__init__(master, **kwargs)
#
# # create tabs
# self.add("tab 1")
# self.add("tab 2")
#
# # add widgets on tabs
# self.label = ctk.CTkLabel(master=self.tab("tab 1"), text="Hello from Tab 1")
# self.label.grid(row=0, column=0, padx=20, pady=10)
#
#class App(ctk.CTk):
# def __init__(self):
# super().__init__()
# self.geometry("1466x642")
# self.title("Dreckshub")
#
# # ---------------------------
# # Device List / Sidebar Setup
# # ---------------------------
# self.devices = ["Device 1"] # Initial connected device
# self.MAX_DEVICES = 5
# self.pages = {} # Pages for each device
#
# self.sidebar = ctk.CTkFrame(self, width=150)
# self.sidebar.grid(row=0, column=0, rowspan=6, sticky="ns", padx=10, pady=10)
#
# # Content frame for device pages
# self.device_content = ctk.CTkFrame(self)
# self.device_content.grid(row=0, column=1, rowspan=6, sticky="nsew", padx=10, pady=10)
#
# # Configure grid weights for proper expansion
# self.grid_columnconfigure(1, weight=1)
# self.grid_rowconfigure(0, weight=1)
#
# self.refresh_sidebar()
#
# # ---------------------------
# # Other Widgets
# # ---------------------------
# self.button = ctk.CTkButton(self, text="Button", command=self.button_click)
# self.button.grid(row=0, column=2, padx=20, pady=10)
#
# # Sliders
# self.slider0 = ctk.CTkSlider(self, from_=0, to=100, height=470, number_of_steps=100, width=20, orientation="vertical", command=self.slider_event)
# self.slider0.grid(row=0, column=3, padx=20, pady=10)
# self.slider0.set(0)
#
# self.slider1 = ctk.CTkSlider(self, from_=0, to=100, height=470, number_of_steps=100, width=20, orientation="vertical", command=self.slider_event)
# self.slider1.grid(row=0, column=4, padx=20, pady=10)
# self.slider1.set(0)
#
# self.slider2 = ctk.CTkSlider(self, from_=0, to=100, height=470, number_of_steps=100, width=20, orientation="vertical", command=self.slider_event)
# self.slider2.grid(row=0, column=5, padx=20, pady=10)
# self.slider2.set(0)
#
# # TabView
# self.tab_view = MyTabView(master=self)
# self.tab_view.grid(row=1, column=2, padx=20, pady=20, columnspan=1)
#
# # ---------------------------
# # Device Sidebar Methods
# # ---------------------------
# def refresh_sidebar(self):
# # Clear existing sidebar buttons
# for widget in self.sidebar.winfo_children():
# widget.destroy()
#
# # Add a button for each device
# for device in self.devices:
# if device not in self.pages:
# page = ctk.CTkFrame(self.device_content)
# page.place(relwidth=1, relheight=1)
# label = ctk.CTkLabel(page, text=f"Settings for {device}")
# label.pack(padx=20, pady=20)
# self.pages[device] = page
#
# ctk.CTkButton(
# self.sidebar,
# text=device,
# command=lambda d=device: self.show_device_page(d)
# ).pack(pady=5, fill="x")
#
# # Add "+ Add Device" button if max devices not reached
# if len(self.devices) < self.MAX_DEVICES:
# ctk.CTkButton(
# self.sidebar,
# text="+ Add Device",
# fg_color="green",
# hover_color="lightgreen",
# command=self.add_device
# ).pack(pady=20, fill="x")
#
# # Show first device page by default
# if self.devices:
# self.show_device_page(self.devices[0])
#
# def show_device_page(self, device_name):
# for page in self.pages.values():
# page.lower()
# self.pages[device_name].lift()
#
# def add_device(self):
# new_device_name = f"Device {len(self.devices)+1}"
# self.devices.append(new_device_name)
# self.refresh_sidebar()
# self.show_device_page(new_device_name)
#
# # ---------------------------
# # Other Methods
# # ---------------------------
# def button_click(self):
# print("button click")
#
# def slider_event(self, value):
# slider0 = self.slider0.get() / 100
# slider1 = self.slider1.get() / 100
# slider2 = self.slider2.get() / 100
# print(str(slider0) + " | " + str(slider1) + " | " + str(slider2))
#
## Run the app
#app = App()
#app.mainloop()
#