Compare commits
1 Commits
main
...
CustomTkin
| Author | SHA1 | Date | |
|---|---|---|---|
| 98832cd765 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
#Folders
|
||||
/node_modules/
|
||||
/.venv/
|
||||
|
||||
#Files
|
||||
/config.json
|
||||
/config.json
|
||||
|
||||
@@ -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;
|
||||
543
index.html
543
index.html
@@ -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
708
main.js
@@ -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
4278
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
63
package.json
63
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
27
preload.js
27
preload.js
@@ -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);
|
||||
}
|
||||
});
|
||||
814
renderer.js
814
renderer.js
@@ -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
1221
styles.css
File diff suppressed because it is too large
Load Diff
189
ui.py
Normal file
189
ui.py
Normal 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()
|
||||
#
|
||||
Reference in New Issue
Block a user