Adding Project Files
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
#Folders
|
||||||
|
/node_modules/
|
||||||
|
|
||||||
|
#Files
|
||||||
|
/config.json
|
||||||
170
arduino_thread.js
Normal file
170
arduino_thread.js
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
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
Normal file
543
index.html
Normal file
@@ -0,0 +1,543 @@
|
|||||||
|
<!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
Normal file
708
main.js
Normal file
@@ -0,0 +1,708 @@
|
|||||||
|
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
Normal file
4278
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
63
package.json
Normal file
63
package.json
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"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
Normal file
27
preload.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
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
Normal file
814
renderer.js
Normal file
@@ -0,0 +1,814 @@
|
|||||||
|
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 */ }
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
360
set_volume.py
Normal file
360
set_volume.py
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
# --- Added: support a simple --list mode BEFORE the usage check ---
|
||||||
|
if len(sys.argv) >= 2 and sys.argv[1] == "--list":
|
||||||
|
# print running audio session application/process names (one per line) and exit
|
||||||
|
if os.name == "nt":
|
||||||
|
try:
|
||||||
|
from comtypes import CoInitialize
|
||||||
|
from pycaw.pycaw import AudioUtilities
|
||||||
|
except Exception as e:
|
||||||
|
sys.stderr.write("pycaw not available: " + str(e) + "\n")
|
||||||
|
sys.exit(2)
|
||||||
|
try:
|
||||||
|
CoInitialize()
|
||||||
|
sessions = AudioUtilities.GetAllSessions()
|
||||||
|
for session in sessions:
|
||||||
|
try:
|
||||||
|
if session.Process and session.Process.name():
|
||||||
|
name = session.Process.name().lower().replace(".exe", "")
|
||||||
|
print(name, flush=True)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
sys.exit(0)
|
||||||
|
except Exception as e:
|
||||||
|
sys.stderr.write("Windows list failed: " + str(e) + "\n")
|
||||||
|
sys.stderr.write(traceback.format_exc())
|
||||||
|
sys.exit(3)
|
||||||
|
elif os.name == "posix":
|
||||||
|
try:
|
||||||
|
import pulsectl
|
||||||
|
except Exception as e:
|
||||||
|
sys.stderr.write("pulsectl not available: " + str(e) + "\n")
|
||||||
|
sys.exit(2)
|
||||||
|
try:
|
||||||
|
with pulsectl.Pulse('dreckshub-list') as pulse:
|
||||||
|
for sink_input in pulse.sink_input_list():
|
||||||
|
app_name = (sink_input.proplist.get('application.name') or "").lower()
|
||||||
|
if app_name:
|
||||||
|
print(app_name, flush=True)
|
||||||
|
sys.exit(0)
|
||||||
|
except Exception as e:
|
||||||
|
sys.stderr.write("Pulse list failed: " + str(e) + "\n")
|
||||||
|
sys.stderr.write(traceback.format_exc())
|
||||||
|
sys.exit(3)
|
||||||
|
else:
|
||||||
|
sys.stderr.write("Unsupported platform for --list: " + os.name + "\n")
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
def fail(msg):
|
||||||
|
sys.stderr.write(msg + "\n")
|
||||||
|
sys.stderr.flush()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def info(msg):
|
||||||
|
sys.stdout.write(msg + "\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
# --- Added: small helper functions to allow a persistent stdin server mode ---
|
||||||
|
def list_sessions():
|
||||||
|
"""Return a list of running audio session names (lowercased) for the current platform."""
|
||||||
|
names = []
|
||||||
|
try:
|
||||||
|
if os.name == "nt":
|
||||||
|
try:
|
||||||
|
from comtypes import CoInitialize
|
||||||
|
from pycaw.pycaw import AudioUtilities
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError("pycaw not available: " + str(e))
|
||||||
|
CoInitialize()
|
||||||
|
sessions = AudioUtilities.GetAllSessions()
|
||||||
|
for session in sessions:
|
||||||
|
try:
|
||||||
|
if session.Process and session.Process.name():
|
||||||
|
name = session.Process.name().lower().replace(".exe", "")
|
||||||
|
names.append(name)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
elif os.name == "posix":
|
||||||
|
try:
|
||||||
|
import pulsectl
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError("pulsectl not available: " + str(e))
|
||||||
|
with pulsectl.Pulse('dreckshub-list') as pulse:
|
||||||
|
for sink_input in pulse.sink_input_list():
|
||||||
|
app_name = (sink_input.proplist.get('application.name') or "").lower()
|
||||||
|
if app_name:
|
||||||
|
names.append(app_name)
|
||||||
|
else:
|
||||||
|
raise RuntimeError("Unsupported platform for list: " + os.name)
|
||||||
|
except Exception as e:
|
||||||
|
# propagate exception for caller to handle
|
||||||
|
raise
|
||||||
|
return names
|
||||||
|
|
||||||
|
def set_volume_for(proc_raw, value):
|
||||||
|
"""Set volume for matching sessions (proc_raw may be process name or substring).
|
||||||
|
Returns dict: { matched: n, matched_names: [...] } or raises on fatal errors.
|
||||||
|
"""
|
||||||
|
proc = (proc_raw or "").lower().replace(".exe", "")
|
||||||
|
value = max(0.0, min(1.0, float(value)))
|
||||||
|
matched_names = []
|
||||||
|
found = 0
|
||||||
|
|
||||||
|
if os.name == "nt":
|
||||||
|
try:
|
||||||
|
from comtypes import CoInitialize
|
||||||
|
from pycaw.pycaw import AudioUtilities
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError("pycaw not available: " + str(e))
|
||||||
|
|
||||||
|
CoInitialize()
|
||||||
|
sessions = AudioUtilities.GetAllSessions()
|
||||||
|
# First pass: exact/process-name contains target
|
||||||
|
for session in sessions:
|
||||||
|
pname = None
|
||||||
|
try:
|
||||||
|
if session.Process:
|
||||||
|
pname = session.Process.name()
|
||||||
|
except Exception:
|
||||||
|
pname = None
|
||||||
|
pname_l = (pname or "").lower()
|
||||||
|
name_clean = pname_l.replace(".exe", "")
|
||||||
|
try:
|
||||||
|
if name_clean == proc or proc in name_clean:
|
||||||
|
try:
|
||||||
|
session.SimpleAudioVolume.SetMasterVolume(value, None)
|
||||||
|
found += 1
|
||||||
|
matched_names.append(pname or "<unknown>")
|
||||||
|
except Exception as ex:
|
||||||
|
# continue trying others, but record nothing here
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Second pass: substring matches
|
||||||
|
if found == 0:
|
||||||
|
for session in sessions:
|
||||||
|
try:
|
||||||
|
if not session.Process:
|
||||||
|
continue
|
||||||
|
pname = session.Process.name()
|
||||||
|
pname_l = pname.lower()
|
||||||
|
if proc in pname_l and pname not in matched_names:
|
||||||
|
try:
|
||||||
|
session.SimpleAudioVolume.SetMasterVolume(value, None)
|
||||||
|
found += 1
|
||||||
|
matched_names.append(pname)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return {"matched": found, "matched_names": matched_names}
|
||||||
|
|
||||||
|
elif os.name == "posix":
|
||||||
|
try:
|
||||||
|
import pulsectl
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError("pulsectl not available: " + str(e))
|
||||||
|
with pulsectl.Pulse('dreckshub-set-volume') as pulse:
|
||||||
|
sink_inputs = pulse.sink_input_list()
|
||||||
|
for sink_input in sink_inputs:
|
||||||
|
app_name = (sink_input.proplist.get('application.name') or "").lower()
|
||||||
|
try:
|
||||||
|
if proc == app_name or proc in app_name:
|
||||||
|
pulse.volume_set_all_chans(sink_input, value)
|
||||||
|
found += 1
|
||||||
|
matched_names.append(app_name)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return {"matched": found, "matched_names": matched_names}
|
||||||
|
else:
|
||||||
|
raise RuntimeError("Unsupported platform: " + os.name)
|
||||||
|
|
||||||
|
# --- NEW: server mode via stdin / stdout (JSON lines) ---
|
||||||
|
if len(sys.argv) >= 2 and sys.argv[1] == "--server-stdin":
|
||||||
|
# Run a simple REPL: read JSON per-line, respond with JSON per-line.
|
||||||
|
# Commands: {"cmd":"list"} -> {"ok":true,"list":[...]}
|
||||||
|
# {"cmd":"set","process":"name","value":0.5} -> {"ok":true,"matched":1}
|
||||||
|
sys.stdout.write("") # ensure stdout available
|
||||||
|
sys.stdout.flush()
|
||||||
|
for raw in sys.stdin:
|
||||||
|
line = raw.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
obj = json.loads(line)
|
||||||
|
except Exception as e:
|
||||||
|
sys.stdout.write(json.dumps({"ok": False, "error": "invalid-json", "detail": str(e)}) + "\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
continue
|
||||||
|
|
||||||
|
cmd = obj.get("cmd")
|
||||||
|
try:
|
||||||
|
if cmd == "list":
|
||||||
|
try:
|
||||||
|
items = list_sessions()
|
||||||
|
sys.stdout.write(json.dumps({"ok": True, "list": items}) + "\n")
|
||||||
|
except Exception as e:
|
||||||
|
sys.stdout.write(json.dumps({"ok": False, "error": "list-failed", "detail": str(e)}) + "\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
elif cmd == "set":
|
||||||
|
proc = obj.get("process") or ""
|
||||||
|
val = obj.get("value", 0)
|
||||||
|
try:
|
||||||
|
res = set_volume_for(proc, val)
|
||||||
|
sys.stdout.write(json.dumps({"ok": True, "matched": res.get("matched", 0), "matched_names": res.get("matched_names", [])}) + "\n")
|
||||||
|
except Exception as e:
|
||||||
|
sys.stdout.write(json.dumps({"ok": False, "error": "set-failed", "detail": str(e)}) + "\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
else:
|
||||||
|
sys.stdout.write(json.dumps({"ok": False, "error": "unknown-cmd", "detail": cmd}) + "\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
except Exception as e:
|
||||||
|
sys.stdout.write(json.dumps({"ok": False, "error": "internal", "detail": str(e)}) + "\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
# stdin closed -> exit
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
fail("Usage: set_volume.py <process_name> <value_float_0_1> [optional_tag]")
|
||||||
|
|
||||||
|
proc_raw = sys.argv[1]
|
||||||
|
proc = proc_raw.lower()
|
||||||
|
try:
|
||||||
|
value = float(sys.argv[2])
|
||||||
|
except Exception as e:
|
||||||
|
fail("Invalid value: " + str(e))
|
||||||
|
|
||||||
|
value = max(0.0, min(1.0, value))
|
||||||
|
|
||||||
|
info(f"set_volume.py start -> proc='{proc_raw}' normalized='{value}' platform='{os.name}'")
|
||||||
|
|
||||||
|
if os.name == "nt":
|
||||||
|
try:
|
||||||
|
from comtypes import CoInitialize
|
||||||
|
from pycaw.pycaw import AudioUtilities
|
||||||
|
except Exception as e:
|
||||||
|
fail("pycaw not available: " + str(e))
|
||||||
|
|
||||||
|
try:
|
||||||
|
CoInitialize()
|
||||||
|
sessions = AudioUtilities.GetAllSessions()
|
||||||
|
info(f"Found {len(sessions)} audio session(s)")
|
||||||
|
found = 0
|
||||||
|
tried = 0
|
||||||
|
matched_names = []
|
||||||
|
|
||||||
|
target = proc.replace(".exe", "").lower()
|
||||||
|
|
||||||
|
# First pass: exact/process-name contains target
|
||||||
|
for session in sessions:
|
||||||
|
tried += 1
|
||||||
|
pname = None
|
||||||
|
try:
|
||||||
|
if session.Process:
|
||||||
|
pname = session.Process.name()
|
||||||
|
except Exception:
|
||||||
|
pname = None
|
||||||
|
pname_l = (pname or "").lower()
|
||||||
|
name_clean = pname_l.replace(".exe", "")
|
||||||
|
info(f"Session[{tried}] -> process='{pname}' cleaned='{name_clean}'")
|
||||||
|
try:
|
||||||
|
if name_clean == target or target in name_clean:
|
||||||
|
info(f" -> MATCH candidate: '{pname}' ; setting volume to {value}")
|
||||||
|
try:
|
||||||
|
session.SimpleAudioVolume.SetMasterVolume(value, None)
|
||||||
|
info(f" -> OK: set for '{pname}'")
|
||||||
|
found += 1
|
||||||
|
matched_names.append(pname or "<unknown>")
|
||||||
|
except Exception as ex:
|
||||||
|
sys.stderr.write(f" -> ERROR setting volume for '{pname}': {ex}\n")
|
||||||
|
sys.stderr.flush()
|
||||||
|
except Exception as e:
|
||||||
|
sys.stderr.write(f" -> ERROR inspecting session '{pname}': {e}\n")
|
||||||
|
sys.stderr.flush()
|
||||||
|
|
||||||
|
# Second pass: try partial substring matches if nothing found
|
||||||
|
if found == 0:
|
||||||
|
info("No exact/primary matches found; trying substring matches against full process names")
|
||||||
|
for session in sessions:
|
||||||
|
try:
|
||||||
|
if not session.Process:
|
||||||
|
continue
|
||||||
|
pname = session.Process.name()
|
||||||
|
pname_l = pname.lower()
|
||||||
|
if proc in pname_l and pname not in matched_names:
|
||||||
|
info(f" -> substring MATCH candidate: '{pname}' ; setting volume to {value}")
|
||||||
|
try:
|
||||||
|
session.SimpleAudioVolume.SetMasterVolume(value, None)
|
||||||
|
info(f" -> OK: set for '{pname}'")
|
||||||
|
found += 1
|
||||||
|
matched_names.append(pname)
|
||||||
|
except Exception as ex:
|
||||||
|
sys.stderr.write(f" -> ERROR setting volume for '{pname}': {ex}\n")
|
||||||
|
sys.stderr.flush()
|
||||||
|
except Exception as e:
|
||||||
|
sys.stderr.write(f" -> ERROR during substring pass: {e}\n")
|
||||||
|
sys.stderr.flush()
|
||||||
|
|
||||||
|
info(f"Completed Windows set-volume: matched {found} session(s).")
|
||||||
|
if found == 0:
|
||||||
|
info("Available session process names (for debugging):")
|
||||||
|
for session in sessions:
|
||||||
|
try:
|
||||||
|
if session.Process and session.Process.name():
|
||||||
|
info(" - " + session.Process.name())
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# fallback unknown entry
|
||||||
|
info(" - <unknown>")
|
||||||
|
sys.exit(0)
|
||||||
|
except Exception as e:
|
||||||
|
sys.stderr.write("Windows volume change failed: " + str(e) + "\n")
|
||||||
|
sys.stderr.write(traceback.format_exc())
|
||||||
|
sys.stderr.flush()
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
elif os.name == "posix":
|
||||||
|
try:
|
||||||
|
import pulsectl
|
||||||
|
except Exception as e:
|
||||||
|
fail("pulsectl not available: " + str(e))
|
||||||
|
|
||||||
|
try:
|
||||||
|
found = 0
|
||||||
|
with pulsectl.Pulse('dreckshub-set-volume') as pulse:
|
||||||
|
sink_inputs = pulse.sink_input_list()
|
||||||
|
info(f"Found {len(sink_inputs)} sink_input(s)")
|
||||||
|
for idx, sink_input in enumerate(sink_inputs, start=1):
|
||||||
|
app_name = (sink_input.proplist.get('application.name') or "").lower()
|
||||||
|
pid = sink_input.proplist.get('application.process.id') or ""
|
||||||
|
info(f"SinkInput[{idx}] -> app_name='{app_name}' pid='{pid}' index={sink_input.index}")
|
||||||
|
try:
|
||||||
|
if proc == app_name or proc in app_name:
|
||||||
|
info(f" -> MATCH candidate: '{app_name}' ; setting volume to {value}")
|
||||||
|
pulse.volume_set_all_chans(sink_input, value)
|
||||||
|
info(f" -> OK: set for sink_input index={sink_input.index}")
|
||||||
|
found += 1
|
||||||
|
except Exception as ex:
|
||||||
|
sys.stderr.write(f" -> ERROR setting volume for sink_input index={sink_input.index}: {ex}\n")
|
||||||
|
sys.stderr.flush()
|
||||||
|
|
||||||
|
if found == 0:
|
||||||
|
info("No matching sink_input found. Listing available application names for debug:")
|
||||||
|
for idx, sink_input in enumerate(sink_inputs, start=1):
|
||||||
|
app_name = (sink_input.proplist.get('application.name') or "")
|
||||||
|
info(f" - [{idx}] '{app_name}' (index={sink_input.index})")
|
||||||
|
info(f"Completed PulseAudio set-volume: matched {found} sink_input(s).")
|
||||||
|
sys.exit(0)
|
||||||
|
except Exception as e:
|
||||||
|
sys.stderr.write("PulseAudio volume change failed: " + str(e) + "\n")
|
||||||
|
sys.stderr.write(traceback.format_exc())
|
||||||
|
sys.stderr.flush()
|
||||||
|
sys.exit(3)
|
||||||
|
else:
|
||||||
|
fail("Unsupported platform: " + os.name)
|
||||||
|
|
||||||
1221
styles.css
Normal file
1221
styles.css
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user