Initial commit: StreamDeck Electron app
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
config.json
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# StreamDeck – Custom Touchscreen Stream Deck
|
||||||
|
|
||||||
|
A native macOS Stream Deck replacement for HDMI touchscreens. Built with **Electron**, it stays on a dedicated secondary monitor and sends keystrokes/clipboard/shell commands to whatever app you're working in — **without stealing focus**.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Full grid of programmable buttons** – 6×3 configurable per page
|
||||||
|
- **Multiple pages** – swipe or tap arrows/dots to navigate
|
||||||
|
- **Realtime system monitor page** – CPU usage (overall + per-core), memory usage, uptime, load
|
||||||
|
- **Native macOS actions**:
|
||||||
|
- `keystroke` – sends keyboard shortcuts to your **currently active app** (e.g. `cmd+v`, `ctrl+up`, `space`)
|
||||||
|
- `copy` / `paste` / `cut` – clipboard operations via NSPasteboard
|
||||||
|
- `shell` – runs shell commands
|
||||||
|
- `osascript` – runs AppleScript
|
||||||
|
- `app` – opens any macOS application
|
||||||
|
- `url` – opens URLs in default browser
|
||||||
|
- `sleep` – puts Mac to sleep
|
||||||
|
- **Touchscreen-optimized** – large touch targets, frameless window, auto-fullscreen
|
||||||
|
- **No focus stealing** – app hides itself before sending keystrokes, so clipboard and shortcuts go to your previous app, not the StreamDeck
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- **macOS** (tested on Sequoia 15.x)
|
||||||
|
- **Node.js** 18+ (for development / `npm install`)
|
||||||
|
- **Touchscreen** with USB touch controller (e.g. WCH `USB2IIC_CTP_CONTROL`)
|
||||||
|
- **Touch-Base / UPDD touch driver** – **required separately!** The touchscreen **will not work** without this driver on macOS. Download and install from [touch-base.com](https://www.touch-base.com/). After installation, use the UPDD Commander to map the touch device to your secondary monitor.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Install dependencies
|
||||||
|
cd streamdeck
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 2. Copy config template and customize
|
||||||
|
cp config.template.json config.json
|
||||||
|
# Edit config.json to set up your buttons
|
||||||
|
|
||||||
|
# 3. Run!
|
||||||
|
./start.sh # Electron app (touchscreen)
|
||||||
|
# or
|
||||||
|
./start.sh server # Web-based mode for testing in browser
|
||||||
|
```
|
||||||
|
|
||||||
|
For the Electron app:
|
||||||
|
|
||||||
|
- The window opens automatically on your **second monitor** (touchscreen)
|
||||||
|
- Fullscreen is enabled automatically on start
|
||||||
|
- Press **F11** to toggle fullscreen
|
||||||
|
- Press **F2** to show the debug event overlay
|
||||||
|
|
||||||
|
## Button Configuration
|
||||||
|
|
||||||
|
Edit `config.json`. Each button can have:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"label": "Button Name",
|
||||||
|
"icon": "🔔",
|
||||||
|
"color": "blue",
|
||||||
|
"actions": [
|
||||||
|
{ "type": "keystroke", "value": "cmd+v" },
|
||||||
|
{ "type": "shell", "value": "open -a Safari" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Available action types
|
||||||
|
|
||||||
|
| Type | Value | Effect |
|
||||||
|
|---|---|---|
|
||||||
|
| `keystroke` | `cmd+v`, `ctrl+shift+z`, `space`, `f2` | Sends keyboard shortcut to active app |
|
||||||
|
| `shell` | `open -a Safari` | Runs shell command in background |
|
||||||
|
| `osascript` | `set volume 50` | Runs AppleScript |
|
||||||
|
| `app` | `Visual Studio Code` | Opens application |
|
||||||
|
| `url` | `https://github.com` | Opens URL in browser |
|
||||||
|
| `copy` | _none_ | Copies selected text (Cmd+C) |
|
||||||
|
| `paste` | `optional text` | Pastes text (Cmd+V) |
|
||||||
|
| `cut` | _none_ | Cuts selected text (Cmd+X) |
|
||||||
|
| `sleep` | _none_ | Puts Mac to sleep |
|
||||||
|
|
||||||
|
### Key syntax
|
||||||
|
|
||||||
|
- Modifiers: `cmd`, `ctrl`, `alt`, `shift`
|
||||||
|
- Special keys: `up`, `down`, `left`, `right`, `space`, `return`, `tab`, `escape`, `f1`–`f12`
|
||||||
|
- Combine with `+` (e.g. `cmd+shift+z`, `ctrl+up`)
|
||||||
|
- Regular characters are typed literally (e.g. `a`, `1`, `Hello`)
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
streamdeck/
|
||||||
|
├── config.json # Your button configuration (local, not in git)
|
||||||
|
├── config.template.json # Template for config
|
||||||
|
├── electron-main.js # Electron main process (window, IPC, actions)
|
||||||
|
├── preload.js # Context bridge (renderer ↔ main)
|
||||||
|
├── server.js # Node.js web server for browser mode
|
||||||
|
├── package.json
|
||||||
|
├── start.sh # Convenience launcher
|
||||||
|
├── public/
|
||||||
|
│ ├── index.html
|
||||||
|
│ ├── app.js # Frontend logic
|
||||||
|
│ └── style.css # Touch-optimized UI
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"gridCols": 6,
|
||||||
|
"gridRows": 3,
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"title": "General",
|
||||||
|
"buttons": [
|
||||||
|
{ "label": "Safari", "icon": "🌐", "color": "blue", "actions": [{ "type": "app", "value": "Safari" }] },
|
||||||
|
{ "label": "Terminal", "icon": "⬛", "color": "dark", "actions": [{ "type": "app", "value": "Terminal" }] },
|
||||||
|
{ "label": "VS Code", "icon": "💻", "color": "purple", "actions": [{ "type": "app", "value": "Visual Studio Code" }] },
|
||||||
|
{ "label": "Spotlight", "icon": "🔍", "color": "white", "actions": [{ "type": "keystroke", "value": "cmd+space" }] },
|
||||||
|
{ "label": "Mute", "icon": "🔇", "color": "orange", "actions": [{ "type": "keystroke", "value": "cmd+shift+m" }] },
|
||||||
|
{ "label": "Lock", "icon": "🔒", "color": "red", "actions": [{ "type": "shell", "value": "/System/Library/CoreServices/Menu\\ Extras/User.menu/Contents/Resources/CGSession -suspend" }] },
|
||||||
|
|
||||||
|
{ "label": "Copy", "icon": "📋", "color": "white", "actions": [{ "type": "keystroke", "value": "cmd+c" }] },
|
||||||
|
{ "label": "Paste", "icon": "📌", "color": "white", "actions": [{ "type": "keystroke", "value": "cmd+v" }] },
|
||||||
|
{ "label": "Cut", "icon": "✂️", "color": "orange", "actions": [{ "type": "keystroke", "value": "cmd+x" }] },
|
||||||
|
{ "label": "Save", "icon": "💾", "color": "blue", "actions": [{ "type": "keystroke", "value": "cmd+s" }] },
|
||||||
|
{ "label": "Undo", "icon": "↩️", "color": "teal", "actions": [{ "type": "keystroke", "value": "cmd+z" }] },
|
||||||
|
{ "label": "Redo", "icon": "↪️", "color": "teal", "actions": [{ "type": "keystroke", "value": "cmd+shift+z" }] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
/* ─── Electron Main Process ──────────────────────────────────────────────────
|
||||||
|
* StreamDeck – natív macOS app a touchscreen-re
|
||||||
|
*
|
||||||
|
* Képességek:
|
||||||
|
* - Ablak a 2. monitorra (ZL480X1920, 1920×480)
|
||||||
|
* - Kiosk mód (nincs Safari címsor, nincs fókusz probléma)
|
||||||
|
* - natív clipboard (copy/paste) – nem kell fókusz
|
||||||
|
* - Billentyűk küldése az aktív app-nak System Events-en keresztül
|
||||||
|
* - Shell parancsok, app indítás, URL megnyitás
|
||||||
|
* ──────────────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const { app, BrowserWindow, ipcMain, clipboard, shell, globalShortcut, screen } = require('electron');
|
||||||
|
const path = require('path');
|
||||||
|
const { exec, execSync } = require('child_process');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
/* ─── Config ───────────────────────────────────────────────────────────────── */
|
||||||
|
const CONFIG_PATH = path.join(__dirname, 'config.json');
|
||||||
|
let config = loadConfig();
|
||||||
|
|
||||||
|
function loadConfig() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load config.json:', e.message);
|
||||||
|
return { gridCols: 6, gridRows: 3, pages: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Window ───────────────────────────────────────────────────────────────── */
|
||||||
|
let mainWindow = null;
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
const display = getTouchscreenDisplay();
|
||||||
|
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
x: display ? display.bounds.x : undefined,
|
||||||
|
y: display ? display.bounds.y : undefined,
|
||||||
|
width: display ? display.bounds.width : 1920,
|
||||||
|
height: display ? display.bounds.height : 480,
|
||||||
|
frame: false, // nincs címsor
|
||||||
|
fullscreen: false,
|
||||||
|
kiosk: false,
|
||||||
|
resizable: false,
|
||||||
|
alwaysOnTop: true, // mindig felül
|
||||||
|
skipTaskbar: true, // nem jelenik meg a Dock-ban
|
||||||
|
autoHideMenuBar: true, // menüsor elrejtése fullscreen-ben
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: false,
|
||||||
|
},
|
||||||
|
title: 'StreamDeck',
|
||||||
|
backgroundColor: '#1a1a2e',
|
||||||
|
});
|
||||||
|
|
||||||
|
// A touchscreen méretére igazítva, pont a monitorra
|
||||||
|
if (display) {
|
||||||
|
mainWindow.setBounds({
|
||||||
|
x: display.bounds.x,
|
||||||
|
y: display.bounds.y,
|
||||||
|
width: display.bounds.width,
|
||||||
|
height: display.bounds.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
mainWindow.loadFile(path.join(__dirname, 'public', 'index.html'));
|
||||||
|
|
||||||
|
// DevTools kikapcsolva élesben
|
||||||
|
if (process.argv.includes('--dev')) {
|
||||||
|
mainWindow.webContents.openDevTools({ mode: 'detach' });
|
||||||
|
}
|
||||||
|
|
||||||
|
mainWindow.on('closed', () => { mainWindow = null; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTouchscreenDisplay() {
|
||||||
|
const displays = screen.getAllDisplays();
|
||||||
|
|
||||||
|
// Keressük a ZL480X1920 nevű monitort (vagy a 2. monitort)
|
||||||
|
for (const d of displays) {
|
||||||
|
if (d.size.width === 1920 && (d.size.height === 480 || d.size.height === 479)) {
|
||||||
|
console.log(`Found touchscreen display:`, d.label || d.id, d.bounds);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ha nem találjuk, a második monitor
|
||||||
|
if (displays.length >= 2) {
|
||||||
|
console.log(`Using second display:`, displays[1].label || displays[1].id);
|
||||||
|
return displays[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('No second display found, using primary');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Native Action Executor ──────────────────────────────────────────────────
|
||||||
|
* A config.json action típusok futtatása.
|
||||||
|
* A billentyűk az aktív (előző) app-nak mennek, nem a StreamDeck-nek.
|
||||||
|
* ───────────────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
async function executeAction(action) {
|
||||||
|
const { type, value } = action;
|
||||||
|
console.log(`Executing action: ${type} = "${value}"`);
|
||||||
|
|
||||||
|
// Billentyűzet action-ök: elrejtjük az ablakot, hogy a billentyűk
|
||||||
|
// az előzőleg aktív app-hoz menjenek, ne a StreamDeck-hez
|
||||||
|
const stealsFocus = ['keystroke', 'copy', 'paste', 'cut'].includes(type);
|
||||||
|
if (stealsFocus) {
|
||||||
|
app.hide();
|
||||||
|
await sleep(80);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (type) {
|
||||||
|
|
||||||
|
/* ─── Keystroke → az előzőleg aktív app-nak megy ──────────────────── */
|
||||||
|
case 'keystroke': {
|
||||||
|
const parsed = parseKeystroke(value || '');
|
||||||
|
if (!parsed) break;
|
||||||
|
|
||||||
|
let osaCmd;
|
||||||
|
if (parsed.modifiers.length > 0) {
|
||||||
|
const mods = parsed.modifiers.join(' ');
|
||||||
|
osaCmd = `osascript -e 'tell application "System Events" to keystroke "${parsed.key}" using {${mods}}'`;
|
||||||
|
} else {
|
||||||
|
osaCmd = `osascript -e 'tell application "System Events" to keystroke "${parsed.key}"'`;
|
||||||
|
}
|
||||||
|
await execPromise(osaCmd);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Clipboard: copy ──────────────────────────────────────────────── */
|
||||||
|
case 'copy': {
|
||||||
|
await execPromise(`osascript -e 'tell application "System Events" to keystroke "c" using command down'`);
|
||||||
|
await sleep(100);
|
||||||
|
const text = clipboard.readText();
|
||||||
|
console.log(`Copied: "${text.substring(0, 80)}..."`);
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
mainWindow.webContents.send('clipboard-result', { action: 'copy', text });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Clipboard: paste ─────────────────────────────────────────────── */
|
||||||
|
case 'paste': {
|
||||||
|
const stored = value || clipboard.readText();
|
||||||
|
clipboard.writeText(stored);
|
||||||
|
await execPromise(`osascript -e 'tell application "System Events" to keystroke "v" using command down'`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Clipboard: cut ───────────────────────────────────────────────── */
|
||||||
|
case 'cut': {
|
||||||
|
await execPromise(`osascript -e 'tell application "System Events" to keystroke "x" using command down'`);
|
||||||
|
await sleep(100);
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
mainWindow.webContents.send('clipboard-result', { action: 'cut', text: clipboard.readText() });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Shell ────────────────────────────────────────────────────────── */
|
||||||
|
case 'shell': {
|
||||||
|
exec(value || '', {
|
||||||
|
timeout: 15000,
|
||||||
|
env: { ...process.env, PATH: '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin' }
|
||||||
|
}, (err, stdout, stderr) => {
|
||||||
|
const output = (stdout || '') + (stderr ? '\n' + stderr : '');
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
mainWindow.webContents.send('shell-result', { type, value, output });
|
||||||
|
}
|
||||||
|
if (err) console.error('shell error:', err.message);
|
||||||
|
console.log('shell output:', output.substring(0, 200));
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── AppleScript ──────────────────────────────────────────────────── */
|
||||||
|
case 'osascript': {
|
||||||
|
await execPromise(`osascript -e '${(value || "").replace(/'/g, "'\\''")}'`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── App indítás ──────────────────────────────────────────────────── */
|
||||||
|
case 'app': {
|
||||||
|
shell.openPath(`/Applications/${value}.app`).catch(() => {
|
||||||
|
exec(`open -a '${(value || "").replace(/'/g, "'\\''")}'`);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── URL megnyitás ────────────────────────────────────────────────── */
|
||||||
|
case 'url': {
|
||||||
|
if (value) shell.openExternal(value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Sleep ────────────────────────────────────────────────────────── */
|
||||||
|
case 'sleep': {
|
||||||
|
exec('pmset sleepnow');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn(`Unknown action type: ${type}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Action failed [${type}]:`, e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visszahozzuk az ablakot
|
||||||
|
if (stealsFocus) {
|
||||||
|
await sleep(50);
|
||||||
|
app.show();
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
mainWindow.show();
|
||||||
|
mainWindow.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── IPC ──────────────────────────────────────────────────────────────────── */
|
||||||
|
ipcMain.handle('get-config', () => config);
|
||||||
|
ipcMain.handle('reload-config', () => {
|
||||||
|
config = loadConfig();
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('execute-action', async (event, action) => {
|
||||||
|
await executeAction(action);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('execute-actions', async (event, actions) => {
|
||||||
|
for (const action of actions) {
|
||||||
|
await executeAction(action);
|
||||||
|
await sleep(50); // kis késleltetés action-ök között
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('get-clipboard-text', () => clipboard.readText());
|
||||||
|
ipcMain.handle('set-clipboard-text', (event, text) => clipboard.writeText(text));
|
||||||
|
ipcMain.handle('toggle-fullscreen', () => {
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
const newState = !mainWindow.isFullScreen();
|
||||||
|
mainWindow.setFullScreen(newState);
|
||||||
|
return newState;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ─── System Stats ──────────────────────────────────────────────────────────
|
||||||
|
* Valós idejű CPU- és memóriafigyelés a Monitor oldalhoz.
|
||||||
|
* A CPU terhelést két mintavétel különbségéből számolja.
|
||||||
|
* ──────────────────────────────────────────────────────────────────────────── */
|
||||||
|
let cpuPrevSample = null;
|
||||||
|
|
||||||
|
function sampleCpuTimes() {
|
||||||
|
const cpus = os.cpus();
|
||||||
|
const total = { idle: 0, tick: 0 };
|
||||||
|
const cores = cpus.map(cpu => {
|
||||||
|
const tick = cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.idle + cpu.times.irq;
|
||||||
|
const idle = cpu.times.idle;
|
||||||
|
total.idle += idle;
|
||||||
|
total.tick += tick;
|
||||||
|
return { tick, idle };
|
||||||
|
});
|
||||||
|
return { total, cores };
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcMain.handle('get-system-stats', () => {
|
||||||
|
const memTotal = os.totalmem();
|
||||||
|
const memFree = os.freemem();
|
||||||
|
const memUsed = memTotal - memFree;
|
||||||
|
|
||||||
|
const current = sampleCpuTimes();
|
||||||
|
let cpuPercent = 0;
|
||||||
|
const perCore = [];
|
||||||
|
|
||||||
|
if (cpuPrevSample) {
|
||||||
|
// Overall CPU
|
||||||
|
const idleDelta = current.total.idle - cpuPrevSample.total.idle;
|
||||||
|
const tickDelta = current.total.tick - cpuPrevSample.total.tick;
|
||||||
|
cpuPercent = tickDelta > 0 ? Math.round((1 - idleDelta / tickDelta) * 1000) / 10 : 0;
|
||||||
|
|
||||||
|
// Per-core
|
||||||
|
current.cores.forEach((core, i) => {
|
||||||
|
const prevCore = cpuPrevSample.cores[i];
|
||||||
|
if (prevCore) {
|
||||||
|
const dTick = core.tick - prevCore.tick;
|
||||||
|
const dIdle = core.idle - prevCore.idle;
|
||||||
|
const pct = dTick > 0 ? Math.round((1 - dIdle / dTick) * 100) : 0;
|
||||||
|
perCore.push({ usage: Math.min(pct, 100) });
|
||||||
|
} else {
|
||||||
|
perCore.push({ usage: 0 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
current.cores.forEach(() => perCore.push({ usage: 0 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
cpuPrevSample = { total: current.total, cores: current.cores };
|
||||||
|
|
||||||
|
return {
|
||||||
|
cpu: {
|
||||||
|
usage: cpuPercent,
|
||||||
|
cores: current.cores.length,
|
||||||
|
perCore,
|
||||||
|
},
|
||||||
|
memory: {
|
||||||
|
total: memTotal,
|
||||||
|
used: memUsed,
|
||||||
|
free: memFree,
|
||||||
|
usagePercent: Math.round((memUsed / memTotal) * 100),
|
||||||
|
},
|
||||||
|
uptime: os.uptime(),
|
||||||
|
loadavg: os.loadavg(),
|
||||||
|
hostname: os.hostname(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ─── Global Shortcut: Escape kilépés kiosk-ból ─────────────────────────── */
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
// StreamDeck ikon elrejtése a Dock-ból (overlay app)
|
||||||
|
app.dock && app.dock.hide();
|
||||||
|
createWindow();
|
||||||
|
|
||||||
|
// Ctrl+Cmd+F: fullscreen toggle
|
||||||
|
globalShortcut.register('Control+Command+F', () => {
|
||||||
|
if (mainWindow) {
|
||||||
|
mainWindow.setFullScreen(!mainWindow.isFullScreen());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// F11: toggle kiosk
|
||||||
|
globalShortcut.register('F11', () => {
|
||||||
|
if (mainWindow) {
|
||||||
|
mainWindow.setFullScreen(!mainWindow.isFullScreen());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Esc: ha fullscreen, kilép belőle
|
||||||
|
globalShortcut.register('Escape', () => {
|
||||||
|
if (mainWindow && mainWindow.isFullScreen()) {
|
||||||
|
mainWindow.setFullScreen(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
globalShortcut.unregisterAll();
|
||||||
|
app.quit();
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ─── Helpers ──────────────────────────────────────────────────────────────── */
|
||||||
|
function execPromise(cmd) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
exec(cmd, {
|
||||||
|
timeout: 10000,
|
||||||
|
env: { ...process.env, PATH: '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin' }
|
||||||
|
}, (err, stdout, stderr) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(stdout);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||||
|
|
||||||
|
function parseKeystroke(str) {
|
||||||
|
if (!str) return null;
|
||||||
|
const parts = str.toLowerCase().split('+');
|
||||||
|
const keyMap = {
|
||||||
|
'cmd': 'command down', 'command': 'command down',
|
||||||
|
'ctrl': 'control down', 'control': 'control down',
|
||||||
|
'alt': 'option down', 'option': 'option down',
|
||||||
|
'shift': 'shift down',
|
||||||
|
'up': 'up arrow', 'down': 'down arrow', 'left': 'left arrow', 'right': 'right arrow',
|
||||||
|
'return': 'return', 'enter': 'return', 'tab': 'tab', 'escape': 'escape',
|
||||||
|
'space': 'space', 'backspace': 'delete',
|
||||||
|
'del': 'delete', 'delete': 'delete',
|
||||||
|
'f1': 'f1', 'f2': 'f2', 'f3': 'f3', 'f4': 'f4', 'f5': 'f5',
|
||||||
|
'f6': 'f6', 'f7': 'f7', 'f8': 'f8', 'f9': 'f9', 'f10': 'f10',
|
||||||
|
'f11': 'f11', 'f12': 'f12',
|
||||||
|
};
|
||||||
|
|
||||||
|
const key = keyMap[parts[parts.length - 1]] || parts[parts.length - 1];
|
||||||
|
const modifiers = parts.slice(0, -1).map(p => keyMap[p]).filter(Boolean);
|
||||||
|
|
||||||
|
return { key, modifiers };
|
||||||
|
}
|
||||||
Generated
+1722
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "streamdeck-custom",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Custom Stream Deck for HDMI touchscreen",
|
||||||
|
"main": "electron-main.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "electron .",
|
||||||
|
"electron": "electron .",
|
||||||
|
"server": "node server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"ws": "^8.16.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"electron": "^33.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
/* ─── Preload Script ─────────────────────────────────────────────────────────
|
||||||
|
* Context bridge a renderer és main process között.
|
||||||
|
* Exponálja a natív API-kat a weboldalnak.
|
||||||
|
* ──────────────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
|
// Config
|
||||||
|
getConfig: () => ipcRenderer.invoke('get-config'),
|
||||||
|
reloadConfig: () => ipcRenderer.invoke('reload-config'),
|
||||||
|
|
||||||
|
// Action futtatás
|
||||||
|
executeAction: (action) => ipcRenderer.invoke('execute-action', action),
|
||||||
|
executeActions: (actions) => ipcRenderer.invoke('execute-actions', actions),
|
||||||
|
|
||||||
|
// Clipboard
|
||||||
|
getClipboardText: () => ipcRenderer.invoke('get-clipboard-text'),
|
||||||
|
setClipboardText: (text) => ipcRenderer.invoke('set-clipboard-text', text),
|
||||||
|
|
||||||
|
// System stats
|
||||||
|
getSystemStats: () => ipcRenderer.invoke('get-system-stats'),
|
||||||
|
|
||||||
|
// Fullscreen
|
||||||
|
toggleFullscreen: () => ipcRenderer.invoke('toggle-fullscreen'),
|
||||||
|
|
||||||
|
// Eredmény események (pl. shell output)
|
||||||
|
onShellResult: (callback) => {
|
||||||
|
ipcRenderer.on('shell-result', (event, data) => callback(data));
|
||||||
|
},
|
||||||
|
onClipboardResult: (callback) => {
|
||||||
|
ipcRenderer.on('clipboard-result', (event, data) => callback(data));
|
||||||
|
},
|
||||||
|
});
|
||||||
+485
@@ -0,0 +1,485 @@
|
|||||||
|
/* ─── State ────────────────────────────────────────────────────────────────── */
|
||||||
|
const IS_ELECTRON = typeof window !== 'undefined' && window.electronAPI !== undefined;
|
||||||
|
|
||||||
|
let config = { pages: [], gridCols: 5, gridRows: 3 };
|
||||||
|
let currentPage = 0;
|
||||||
|
let ws = null;
|
||||||
|
let wsReconnectTimer = null;
|
||||||
|
let btnExecuting = {}; // { id: setTimeout }
|
||||||
|
|
||||||
|
/* ─── DOM refs ────────────────────────────────────────────────────────────── */
|
||||||
|
const gridEl = document.getElementById('grid');
|
||||||
|
const navEl = document.getElementById('nav');
|
||||||
|
const titleEl = document.getElementById('page-title');
|
||||||
|
const wsStatusEl = document.getElementById('ws-status');
|
||||||
|
const pageIndicatorEl = document.getElementById('page-indicator');
|
||||||
|
const navPrevEl = document.getElementById('nav-prev');
|
||||||
|
const navNextEl = document.getElementById('nav-next');
|
||||||
|
|
||||||
|
/* ─── Config + Monitor page ───────────────────────────────────────────────── */
|
||||||
|
// A Monitor page index: mindig az utolsó oldal (config.pages.length után hozzáfűzve)
|
||||||
|
const MONITOR_PAGE_LABEL = '📊';
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
try {
|
||||||
|
if (IS_ELECTRON) {
|
||||||
|
config = await window.electronAPI.getConfig();
|
||||||
|
} else {
|
||||||
|
const res = await fetch('/api/config');
|
||||||
|
config = await res.json();
|
||||||
|
}
|
||||||
|
if (!config.pages || config.pages.length === 0) {
|
||||||
|
config.pages = [{ title: 'Hello', buttons: [] }];
|
||||||
|
}
|
||||||
|
// Virtuális Monitor oldal hozzáfűzése (nem mentjük config.json-ba)
|
||||||
|
config.pages.push({
|
||||||
|
title: 'Monitor',
|
||||||
|
buttons: [],
|
||||||
|
_isMonitor: true,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load config:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── WebSocket (böngésző módban) ────────────────────────────────────────── */
|
||||||
|
function connectWS() {
|
||||||
|
if (IS_ELECTRON) {
|
||||||
|
// Electron: nincs szükség WS-re
|
||||||
|
document.body.classList.add('connected');
|
||||||
|
wsStatusEl.title = 'Electron app – natív';
|
||||||
|
wsStatusEl.className = 'ws-online';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) return;
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
ws = new WebSocket(`${proto}//${location.host}`);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
console.log('◉ WebSocket connected');
|
||||||
|
if (wsReconnectTimer) { clearTimeout(wsReconnectTimer); wsReconnectTimer = null; }
|
||||||
|
document.body.classList.add('connected');
|
||||||
|
wsStatusEl.className = 'ws-online';
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
console.log('◉ WebSocket disconnected');
|
||||||
|
document.body.classList.remove('connected');
|
||||||
|
wsStatusEl.className = 'ws-offline';
|
||||||
|
ws = null;
|
||||||
|
wsReconnectTimer = setTimeout(connectWS, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(ev.data);
|
||||||
|
if (msg.type === 'connected') console.log(' ✓', msg.message);
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => { /* onclose will fire next */ };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Send press ──────────────────────────────────────────────────────────── */
|
||||||
|
function sendPress(button) {
|
||||||
|
const actions = button.actions || [];
|
||||||
|
if (actions.length === 0) return;
|
||||||
|
|
||||||
|
if (IS_ELECTRON) {
|
||||||
|
// Elektron: natív IPC-n keresztül
|
||||||
|
window.electronAPI.executeActions(actions).catch(console.error);
|
||||||
|
} else if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'press',
|
||||||
|
actions: actions,
|
||||||
|
id: button.id || button.label
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visual feedback
|
||||||
|
const btnId = button.id || button.label;
|
||||||
|
if (btnExecuting[btnId]) clearTimeout(btnExecuting[btnId]);
|
||||||
|
delete btnExecuting[btnId];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Render grid (or monitor dashboard) ─────────────────────────────────── */
|
||||||
|
let monitorInterval = null;
|
||||||
|
|
||||||
|
function renderPage(pageIdx) {
|
||||||
|
const page = config.pages[pageIdx];
|
||||||
|
if (!page) return;
|
||||||
|
|
||||||
|
currentPage = pageIdx;
|
||||||
|
titleEl.textContent = page.title || '';
|
||||||
|
|
||||||
|
// Ha Monitor oldal, rendereld a dashboard-ot
|
||||||
|
if (page._isMonitor) {
|
||||||
|
renderMonitorPage();
|
||||||
|
renderNav();
|
||||||
|
updateNavArrows();
|
||||||
|
updatePageIndicator();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Biztonság: ha volt monitor poll, állítsd le
|
||||||
|
stopMonitorPoll();
|
||||||
|
|
||||||
|
// Build grid
|
||||||
|
gridEl.innerHTML = '';
|
||||||
|
gridEl.style.gridTemplateColumns = `repeat(${config.gridCols || 5}, 1fr)`;
|
||||||
|
gridEl.style.gridTemplateRows = `repeat(${config.gridRows || 3}, 1fr)`;
|
||||||
|
gridEl.classList.remove('drag-scroll');
|
||||||
|
|
||||||
|
const buttons = page.buttons || [];
|
||||||
|
|
||||||
|
buttons.forEach((btn, i) => {
|
||||||
|
const el = document.createElement('button');
|
||||||
|
el.className = 'button';
|
||||||
|
el.dataset.index = i;
|
||||||
|
|
||||||
|
// Color
|
||||||
|
if (btn.color) el.classList.add(`color-${btn.color}`);
|
||||||
|
|
||||||
|
// Icon
|
||||||
|
if (btn.icon) {
|
||||||
|
const iconSpan = document.createElement('span');
|
||||||
|
iconSpan.className = 'icon';
|
||||||
|
iconSpan.textContent = btn.icon;
|
||||||
|
el.appendChild(iconSpan);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label
|
||||||
|
if (btn.label) {
|
||||||
|
const labelSpan = document.createElement('span');
|
||||||
|
labelSpan.className = 'label';
|
||||||
|
labelSpan.textContent = btn.label;
|
||||||
|
el.appendChild(labelSpan);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background image
|
||||||
|
if (btn.image) {
|
||||||
|
el.style.backgroundImage = `url(${btn.image})`;
|
||||||
|
el.style.backgroundSize = 'cover';
|
||||||
|
el.style.backgroundPosition = 'center';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event handlers (multiple redundancies for different touchscreen drivers) ──
|
||||||
|
function fireAction(e) {
|
||||||
|
// Log to debug overlay
|
||||||
|
if (window.debugLog) window.debugLog(e.type, btn.label || '(no label)');
|
||||||
|
// Visual feedback
|
||||||
|
el.classList.add('pressed');
|
||||||
|
setTimeout(() => el.classList.remove('pressed'), 150);
|
||||||
|
// Send action to server
|
||||||
|
sendPress(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
el.addEventListener('pointerdown', fireAction);
|
||||||
|
el.addEventListener('mousedown', fireAction);
|
||||||
|
el.addEventListener('click', (e) => { e.preventDefault(); fireAction(e); });
|
||||||
|
el.addEventListener('touchstart', (e) => {
|
||||||
|
// Some touchscreens ONLY send touchstart
|
||||||
|
fireAction(e);
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
// Colspan / rowspan (via CSS grid)
|
||||||
|
if (btn.colspan) el.style.gridColumn = `span ${btn.colspan}`;
|
||||||
|
if (btn.rowspan) el.style.gridRow = `span ${btn.rowspan}`;
|
||||||
|
|
||||||
|
// Size classes for easier styling
|
||||||
|
if (btn.width) el.style.width = btn.width;
|
||||||
|
if (btn.height) el.style.height = btn.height;
|
||||||
|
|
||||||
|
// Empty button (spacer)
|
||||||
|
if (btn.empty) {
|
||||||
|
el.style.visibility = 'hidden';
|
||||||
|
el.style.pointerEvents = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
gridEl.appendChild(el);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigation dots, arrows, page indicator
|
||||||
|
renderNav();
|
||||||
|
updateNavArrows();
|
||||||
|
updatePageIndicator();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Monitor dashboard ─────────────────────────────────────────────────────
|
||||||
|
* Realtime CPU és memória grafikonok. Csak Electron módban működik.
|
||||||
|
* ──────────────────────────────────────────────────────────────────────────── */
|
||||||
|
function renderMonitorPage() {
|
||||||
|
gridEl.innerHTML = '';
|
||||||
|
gridEl.style.gridTemplateColumns = '1fr';
|
||||||
|
gridEl.style.gridTemplateRows = '1fr';
|
||||||
|
gridEl.classList.remove('drag-scroll');
|
||||||
|
|
||||||
|
// Container
|
||||||
|
const dash = document.createElement('div');
|
||||||
|
dash.id = 'monitor-dashboard';
|
||||||
|
dash.innerHTML = `
|
||||||
|
<div class="monitor-section" id="monitor-cpu">
|
||||||
|
<div class="monitor-title">CPU</div>
|
||||||
|
<div class="monitor-bar-bg"><div class="monitor-bar monitor-bar-cpu" id="cpu-bar" style="width:0%"></div></div>
|
||||||
|
<div class="monitor-value" id="cpu-value">—</div>
|
||||||
|
<div class="monitor-cores" id="cpu-cores"></div>
|
||||||
|
</div>
|
||||||
|
<div class="monitor-section" id="monitor-mem">
|
||||||
|
<div class="monitor-title">MEMORY</div>
|
||||||
|
<div class="monitor-bar-bg"><div class="monitor-bar monitor-bar-mem" id="mem-bar" style="width:0%"></div></div>
|
||||||
|
<div class="monitor-value" id="mem-value">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="monitor-section" id="monitor-sys">
|
||||||
|
<div class="monitor-title">SYSTEM</div>
|
||||||
|
<div class="monitor-line" id="monitor-uptime">Uptime: —</div>
|
||||||
|
<div class="monitor-line" id="monitor-load">Load: —</div>
|
||||||
|
<div class="monitor-line" id="monitor-host">Host: —</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
gridEl.appendChild(dash);
|
||||||
|
|
||||||
|
// Start polling
|
||||||
|
startMonitorPoll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopMonitorPoll() {
|
||||||
|
if (monitorInterval) {
|
||||||
|
clearInterval(monitorInterval);
|
||||||
|
monitorInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startMonitorPoll() {
|
||||||
|
stopMonitorPoll();
|
||||||
|
|
||||||
|
if (!IS_ELECTRON) {
|
||||||
|
document.getElementById('monitor-cpu') &&
|
||||||
|
(document.getElementById('monitor-cpu').innerHTML = '<div class="monitor-title">CPU</div><div style="color:#666;padding:10px">Only available in Electron mode</div>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update() {
|
||||||
|
try {
|
||||||
|
const stats = await window.electronAPI.getSystemStats();
|
||||||
|
if (!stats) return;
|
||||||
|
|
||||||
|
// CPU
|
||||||
|
const cpuEl = document.getElementById('cpu-bar');
|
||||||
|
const cpuVal = document.getElementById('cpu-value');
|
||||||
|
const cpuCores = document.getElementById('cpu-cores');
|
||||||
|
if (cpuEl) cpuEl.style.width = Math.min(stats.cpu.usage, 100) + '%';
|
||||||
|
if (cpuVal) cpuVal.textContent = stats.cpu.usage.toFixed(1) + '%';
|
||||||
|
|
||||||
|
if (cpuCores && stats.cpu.perCore) {
|
||||||
|
let html = '';
|
||||||
|
stats.cpu.perCore.forEach((core, i) => {
|
||||||
|
const pct = Math.min(core.usage || 0, 100);
|
||||||
|
html += `<div class="core-bar-bg"><span class="core-label">C${i}</span><div class="core-bar-fill" style="width:${pct}%"></div><span class="core-pct">${pct}%</span></div>`;
|
||||||
|
});
|
||||||
|
cpuCores.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memory
|
||||||
|
const memEl = document.getElementById('mem-bar');
|
||||||
|
const memVal = document.getElementById('mem-value');
|
||||||
|
if (memEl) memEl.style.width = Math.min(stats.memory.usagePercent, 100) + '%';
|
||||||
|
if (memVal) {
|
||||||
|
const usedGB = (stats.memory.used / 1024 / 1024 / 1024).toFixed(1);
|
||||||
|
const totalGB = (stats.memory.total / 1024 / 1024 / 1024).toFixed(1);
|
||||||
|
const color = stats.memory.usagePercent > 85 ? '#f87171' : stats.memory.usagePercent > 65 ? '#fbbf24' : '#4ade80';
|
||||||
|
memVal.innerHTML = `${usedGB} GB / ${totalGB} GB <span style="color:${color}">(${stats.memory.usagePercent}%)</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// System info
|
||||||
|
const uptimeEl = document.getElementById('monitor-uptime');
|
||||||
|
const loadEl = document.getElementById('monitor-load');
|
||||||
|
const hostEl = document.getElementById('monitor-host');
|
||||||
|
if (uptimeEl) {
|
||||||
|
const days = Math.floor(stats.uptime / 86400);
|
||||||
|
const hrs = Math.floor((stats.uptime % 86400) / 3600);
|
||||||
|
const mins = Math.floor((stats.uptime % 3600) / 60);
|
||||||
|
uptimeEl.textContent = `Uptime: ${days}d ${hrs}h ${mins}m`;
|
||||||
|
}
|
||||||
|
if (loadEl) {
|
||||||
|
loadEl.textContent = `Load: ${stats.loadavg.map(v => v.toFixed(2)).join(', ')}`;
|
||||||
|
}
|
||||||
|
if (hostEl) {
|
||||||
|
hostEl.textContent = `Host: ${stats.hostname}`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Monitor update error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update();
|
||||||
|
monitorInterval = setInterval(update, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNav() {
|
||||||
|
navEl.innerHTML = '';
|
||||||
|
config.pages.forEach((page, i) => {
|
||||||
|
const dot = document.createElement('button');
|
||||||
|
dot.className = 'nav-dot';
|
||||||
|
dot.dataset.index = i;
|
||||||
|
if (i === currentPage) dot.classList.add('active');
|
||||||
|
|
||||||
|
function gotoPage(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
renderPage(i);
|
||||||
|
}
|
||||||
|
dot.addEventListener('click', gotoPage);
|
||||||
|
dot.addEventListener('touchstart', (e) => {
|
||||||
|
// For touchscreen drivers that only send touchstart, not click
|
||||||
|
gotoPage(e);
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
navEl.appendChild(dot);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Fullscreen toggle ──────────────────────────────────────────────────── */
|
||||||
|
document.getElementById('fullscreen-btn').addEventListener('click', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (IS_ELECTRON) {
|
||||||
|
await window.electronAPI.toggleFullscreen();
|
||||||
|
} else if (!document.fullscreenElement) {
|
||||||
|
await document.documentElement.requestFullscreen().catch(() => {});
|
||||||
|
} else {
|
||||||
|
await document.exitFullscreen().catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Auto-fullscreen Electron indításkor
|
||||||
|
if (IS_ELECTRON) {
|
||||||
|
window.electronAPI.toggleFullscreen();
|
||||||
|
} else {
|
||||||
|
// Böngésző: első touch-ra fullscreen
|
||||||
|
document.addEventListener('touchstart', () => {
|
||||||
|
if (!document.fullscreenElement && document.documentElement.requestFullscreen) {
|
||||||
|
document.documentElement.requestFullscreen().catch(() => {});
|
||||||
|
}
|
||||||
|
}, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateNavArrows() {
|
||||||
|
navPrevEl.disabled = currentPage <= 0;
|
||||||
|
navNextEl.disabled = currentPage >= config.pages.length - 1;
|
||||||
|
navPrevEl.style.opacity = navPrevEl.disabled ? '0.15' : '0.3';
|
||||||
|
navNextEl.style.opacity = navNextEl.disabled ? '0.15' : '0.3';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePageIndicator() {
|
||||||
|
pageIndicatorEl.textContent = `${currentPage + 1} / ${config.pages.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Navigation arrow handlers ──────────────────────────────────────────────
|
||||||
|
function goToPrevPage(e) {
|
||||||
|
if (currentPage > 0) renderPage(currentPage - 1);
|
||||||
|
}
|
||||||
|
function goToNextPage(e) {
|
||||||
|
if (currentPage < config.pages.length - 1) renderPage(currentPage + 1);
|
||||||
|
}
|
||||||
|
navPrevEl.addEventListener('click', goToPrevPage);
|
||||||
|
navPrevEl.addEventListener('touchstart', (e) => { goToPrevPage(e); }, { passive: true });
|
||||||
|
navNextEl.addEventListener('click', goToNextPage);
|
||||||
|
navNextEl.addEventListener('touchstart', (e) => { goToNextPage(e); }, { passive: true });
|
||||||
|
|
||||||
|
/* ─── Swipe gesture for page navigation ────────────────────────────────────
|
||||||
|
* Egyszerűsített verzió: NEM vár pointermove/touchmove eseményekre.
|
||||||
|
* Közvetlenül a felengedéskor méri a távolságot a lenyomás pontjától.
|
||||||
|
* Ez azért kell, mert egyes touchscreen-ek (pl. WCH USB touch) NEM
|
||||||
|
* küldenek mozgás eseményeket (pointermove/touchmove).
|
||||||
|
* ──────────────────────────────────────────────────────────────────────────── */
|
||||||
|
let swipeStartX = 0;
|
||||||
|
let swipeStartY = 0;
|
||||||
|
let swipeDownTime = 0;
|
||||||
|
let swipeTarget = null;
|
||||||
|
|
||||||
|
function handleSwipeEnd(endX, endY, target) {
|
||||||
|
const dx = endX - swipeStartX;
|
||||||
|
const dy = endY - swipeStartY;
|
||||||
|
const elapsed = Date.now() - swipeDownTime;
|
||||||
|
|
||||||
|
// Nem swipe ha: túl kis távolság (>20px), vagy túl lassú (>500ms),
|
||||||
|
// vagy nem elég vízszintes, vagy gombról indult
|
||||||
|
if (Math.abs(dx) < 20 || elapsed > 500) return;
|
||||||
|
if (Math.abs(dx) < Math.abs(dy) * 1.5) return; // nem elég vízszintes
|
||||||
|
if (target && target.closest('.button')) return; // gombról indult
|
||||||
|
|
||||||
|
if (dx > 0 && currentPage > 0) renderPage(currentPage - 1);
|
||||||
|
else if (dx < 0 && currentPage < config.pages.length - 1) renderPage(currentPage + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pointer events – minden touchscreen ezt küldi (UPDD driver is)
|
||||||
|
document.addEventListener('pointerdown', (e) => {
|
||||||
|
swipeStartX = e.screenX;
|
||||||
|
swipeStartY = e.screenY;
|
||||||
|
swipeDownTime = Date.now();
|
||||||
|
swipeTarget = e.target;
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
document.addEventListener('pointerup', (e) => {
|
||||||
|
handleSwipeEnd(e.screenX, e.screenY, swipeTarget);
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
// Touch events – egyes eszközök ezt küldik pointer helyett
|
||||||
|
document.addEventListener('touchstart', (e) => {
|
||||||
|
swipeStartX = e.changedTouches[0].screenX;
|
||||||
|
swipeStartY = e.changedTouches[0].screenY;
|
||||||
|
swipeDownTime = Date.now();
|
||||||
|
swipeTarget = e.target;
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
document.addEventListener('touchend', (e) => {
|
||||||
|
handleSwipeEnd(
|
||||||
|
e.changedTouches[0].screenX,
|
||||||
|
e.changedTouches[0].screenY,
|
||||||
|
swipeTarget
|
||||||
|
);
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
// ← → keyboard nav
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'ArrowRight' && currentPage < config.pages.length - 1) renderPage(currentPage + 1);
|
||||||
|
if (e.key === 'ArrowLeft' && currentPage > 0) renderPage(currentPage - 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ─── Document-level event logger (for troubleshooting touch) ──────────── */
|
||||||
|
function docLog(e) {
|
||||||
|
window.debugLog(`📄 ${e.type}`, `target:${e.target?.tagName} pt:${e.pointerType || '-'} btn:${e.which || e.button || 0}`);
|
||||||
|
}
|
||||||
|
document.addEventListener('pointerdown', docLog);
|
||||||
|
document.addEventListener('pointerup', docLog);
|
||||||
|
document.addEventListener('mousedown', docLog);
|
||||||
|
document.addEventListener('mouseup', docLog);
|
||||||
|
document.addEventListener('click', docLog);
|
||||||
|
document.addEventListener('touchstart', docLog);
|
||||||
|
document.addEventListener('touchend', docLog);
|
||||||
|
|
||||||
|
/* ─── Debug overlay ──────────────────────────────────────────────────────── */
|
||||||
|
const debugEl = document.getElementById('debug-overlay');
|
||||||
|
let debugCount = 0;
|
||||||
|
window.debugLog = function(type, label) {
|
||||||
|
const now = new Date();
|
||||||
|
const t = now.toLocaleTimeString();
|
||||||
|
const entry = `[${t}] ${type.padEnd(15)} ${label || ''}`;
|
||||||
|
debugEl.innerHTML += `<div>${entry}</div>`;
|
||||||
|
debugEl.scrollTop = debugEl.scrollHeight;
|
||||||
|
if (++debugCount > 100) { debugEl.innerHTML = debugEl.innerHTML.split('</div>').slice(-50).join('</div>'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Press F2 to toggle debug overlay
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'F2') {
|
||||||
|
debugEl.style.display = debugEl.style.display === 'none' ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ─── Init ────────────────────────────────────────────────────────────────── */
|
||||||
|
(async function init() {
|
||||||
|
await loadConfig();
|
||||||
|
renderPage(0);
|
||||||
|
connectWS();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, maximum-scale=1.0, viewport-fit=cover">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<title>StreamDeck</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<!-- Page indicator / header -->
|
||||||
|
<div id="header">
|
||||||
|
<span id="ws-status" class="ws-offline">●</span>
|
||||||
|
<span id="page-title"></span>
|
||||||
|
<button id="fullscreen-btn">⛶</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Button grid with arrow overlays -->
|
||||||
|
<div id="grid-wrapper">
|
||||||
|
<div id="grid"></div>
|
||||||
|
<button id="nav-prev" class="nav-arrow">‹</button>
|
||||||
|
<button id="nav-next" class="nav-arrow">›</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Page navigation dots -->
|
||||||
|
<div id="nav"></div>
|
||||||
|
|
||||||
|
<!-- Page indicator text -->
|
||||||
|
<div id="page-indicator"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="debug-overlay" style="display:none; position:fixed; bottom:0; left:0; right:0; background:rgba(0,0,0,0.85); color:#0f0; font:11px monospace; padding:6px; max-height:80px; overflow-y:auto; z-index:999; pointer-events:none;"></div>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
/* ─── Reset ────────────────────────────────────────────────────────────────── */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html, body {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #fff;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
touch-action: manipulation;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Layout ──────────────────────────────────────────────────────────────── */
|
||||||
|
#app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
padding: 10px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#header {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
padding: 2px 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.7;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ws-status {
|
||||||
|
font-size: 10px;
|
||||||
|
transition: color 0.3s;
|
||||||
|
}
|
||||||
|
#ws-status.ws-online { color: #4ade80; }
|
||||||
|
#ws-status.ws-offline { color: #f87171; }
|
||||||
|
|
||||||
|
#fullscreen-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #555;
|
||||||
|
color: #888;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 8px;
|
||||||
|
line-height: 1.4;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
#fullscreen-btn:active { background: rgba(255,255,255,0.1); }
|
||||||
|
|
||||||
|
.button {
|
||||||
|
position: relative;
|
||||||
|
border: none;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #16213e;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px;
|
||||||
|
transition: all 0.1s ease;
|
||||||
|
box-shadow: 0 3px 12px rgba(0,0,0,0.4);
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: center;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button .icon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button .label {
|
||||||
|
font-size: 16px;
|
||||||
|
opacity: 0.95;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pressed state */
|
||||||
|
.button:active, .button.pressed {
|
||||||
|
transform: scale(0.92);
|
||||||
|
filter: brightness(1.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Color variants */
|
||||||
|
.button.color-blue { background: #0f3460; }
|
||||||
|
.button.color-green { background: #1b4332; }
|
||||||
|
.button.color-red { background: #5c1a1a; }
|
||||||
|
.button.color-purple { background: #3b1f6e; }
|
||||||
|
.button.color-orange { background: #5c3a1a; }
|
||||||
|
.button.color-teal { background: #1a4a4a; }
|
||||||
|
.button.color-pink { background: #5c1a3a; }
|
||||||
|
.button.color-yellow { background: #5c4a1a; color: #ffe066; }
|
||||||
|
.button.color-white { background: #2a2a3e; }
|
||||||
|
.button.color-dark { background: #0d0d1a; }
|
||||||
|
|
||||||
|
/* Transparent (image only) */
|
||||||
|
.button.color-transparent { background: transparent; box-shadow: none; }
|
||||||
|
.button.color-transparent:active { filter: brightness(1.5); }
|
||||||
|
|
||||||
|
/* ─── Grid wrapper with side arrows ──────────────────────────────────────── */
|
||||||
|
#grid-wrapper {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#grid {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
align-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-arrow {
|
||||||
|
position: absolute;
|
||||||
|
top: 0; bottom: 0;
|
||||||
|
width: 56px;
|
||||||
|
border: none;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
color: rgba(255,255,255,0.35);
|
||||||
|
font-size: 40px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10;
|
||||||
|
transition: all 0.2s;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
touch-action: manipulation;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.nav-arrow:active { background: rgba(255,255,255,0.15); color: #fff; }
|
||||||
|
#nav-prev { left: 0; border-radius: 0 10px 10px 0; }
|
||||||
|
#nav-next { right: 0; border-radius: 10px 0 0 10px; }
|
||||||
|
|
||||||
|
/* ─── Navigation dots ─────────────────────────────────────────────────────── */
|
||||||
|
#nav {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dot {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #444;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
padding: 0;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dot:active {
|
||||||
|
background: #666;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dot.active {
|
||||||
|
background: #4a9eff;
|
||||||
|
border-color: #6bb4ff;
|
||||||
|
box-shadow: 0 0 10px #4a9eff;
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Page indicator text ────────────────────────────────────────────────── */
|
||||||
|
#page-indicator {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #555;
|
||||||
|
padding: 2px 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Responsive ──────────────────────────────────────────────────────────── */
|
||||||
|
/* Ultrawide landscape (e.g. 1920×480) — the touchscreen */
|
||||||
|
@media (min-aspect-ratio: 3/1) {
|
||||||
|
.button { padding: 10px 14px; }
|
||||||
|
.button .icon { font-size: 48px; }
|
||||||
|
.button .label { font-size: 16px; }
|
||||||
|
#app { padding: 8px 12px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small portrait (e.g. 1024×768) */
|
||||||
|
@media (max-aspect-ratio: 4/3) {
|
||||||
|
.button { border-radius: 20px; }
|
||||||
|
.button .icon { font-size: 56px; }
|
||||||
|
.button .label { font-size: 20px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Animations ──────────────────────────────────────────────────────────── */
|
||||||
|
@keyframes flash {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.executing {
|
||||||
|
animation: flash 0.3s ease 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Drag scroll (for pages with many rows) ──────────────────────────────── */
|
||||||
|
#grid.drag-scroll {
|
||||||
|
overflow-y: auto;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#grid.drag-scroll::-webkit-scrollbar {
|
||||||
|
width: 3px;
|
||||||
|
}
|
||||||
|
#grid.drag-scroll::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
#grid.drag-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background: #555;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Monitor dashboard ───────────────────────────────────────────────────── */
|
||||||
|
#monitor-dashboard {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
grid-template-rows: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
height: 100%;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-section {
|
||||||
|
background: #16213e;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-title {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-bar-bg {
|
||||||
|
height: 20px;
|
||||||
|
background: #0d1b2a;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-bar {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: width 0.6s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-bar-cpu { background: linear-gradient(90deg, #4a9eff, #6bb4ff); }
|
||||||
|
.monitor-bar-mem { background: linear-gradient(90deg, #4ade80, #6ee7a0); }
|
||||||
|
|
||||||
|
.monitor-value {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Per-core bars */
|
||||||
|
.monitor-cores {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
flex: 1;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-bar-bg {
|
||||||
|
height: 14px;
|
||||||
|
background: #0d1b2a;
|
||||||
|
border-radius: 7px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-label {
|
||||||
|
position: absolute;
|
||||||
|
left: 6px;
|
||||||
|
font-size: 8px;
|
||||||
|
color: #aaa;
|
||||||
|
z-index: 1;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: linear-gradient(90deg, #4a9eff, #7bb8ff);
|
||||||
|
transition: width 0.6s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-pct {
|
||||||
|
position: absolute;
|
||||||
|
right: 6px;
|
||||||
|
font-size: 8px;
|
||||||
|
color: #ddd;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* System info section */
|
||||||
|
#monitor-sys {
|
||||||
|
grid-column: 1 / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-line {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #bbb;
|
||||||
|
padding: 3px 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ultrawide tuning */
|
||||||
|
@media (min-aspect-ratio: 3/1) {
|
||||||
|
#monitor-dashboard { gap: 6px; padding: 2px; }
|
||||||
|
.monitor-section { padding: 6px 10px; border-radius: 8px; }
|
||||||
|
.monitor-title { font-size: 10px; }
|
||||||
|
.monitor-value { font-size: 16px; }
|
||||||
|
.monitor-bar-bg { height: 16px; }
|
||||||
|
.core-bar-bg { height: 12px; }
|
||||||
|
.monitor-line { font-size: 11px; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const WebSocket = require('ws');
|
||||||
|
const http = require('http');
|
||||||
|
const path = require('path');
|
||||||
|
const { exec, spawn } = require('child_process');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
// ─── Config ───────────────────────────────────────────────────────────────────
|
||||||
|
const PORT = 8090;
|
||||||
|
const CONFIG_PATH = path.join(__dirname, 'config.json');
|
||||||
|
|
||||||
|
let config = { pages: [], gridCols: 5, gridRows: 3 };
|
||||||
|
function loadConfig() {
|
||||||
|
try {
|
||||||
|
config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
||||||
|
console.log(` ✓ Config loaded: ${config.pages.length} page(s), ${config.gridCols}×${config.gridRows} grid`);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(' ! No config.json found, using defaults');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadConfig();
|
||||||
|
|
||||||
|
// ─── Express server ────────────────────────────────────────────────────────────
|
||||||
|
const app = express();
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// API endpoint to get config
|
||||||
|
app.get('/api/config', (req, res) => res.json(config));
|
||||||
|
|
||||||
|
// API endpoint to reload config
|
||||||
|
app.post('/api/reload', (req, res) => {
|
||||||
|
loadConfig();
|
||||||
|
res.json({ ok: true, pages: config.pages.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
// API endpoint to run AppleScript
|
||||||
|
app.post('/api/osascript', (req, res) => {
|
||||||
|
const { script } = req.body;
|
||||||
|
if (!script) return res.status(400).json({ error: 'missing script' });
|
||||||
|
exec(`osascript -e '${script.replace(/'/g, "'\\''")}'`, (err, stdout, stderr) => {
|
||||||
|
if (err && stderr) console.error(' ✗ osascript error:', stderr.trim());
|
||||||
|
res.json({ ok: !err, stdout: stdout?.trim(), stderr: stderr?.trim() });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// API endpoint to run shell commands
|
||||||
|
app.post('/api/exec', (req, res) => {
|
||||||
|
const { command } = req.body;
|
||||||
|
if (!command) return res.status(400).json({ error: 'missing command' });
|
||||||
|
exec(command, { timeout: 5000 }, (err, stdout, stderr) => {
|
||||||
|
if (err && stderr) console.error(' ✗ exec error:', stderr.trim());
|
||||||
|
res.json({ ok: !err, stdout: stdout?.trim(), stderr: stderr?.trim() });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// API endpoint to open an app
|
||||||
|
app.post('/api/open', (req, res) => {
|
||||||
|
const { app, url } = req.body;
|
||||||
|
if (url) {
|
||||||
|
exec(`open '${url.replace(/'/g, "'\\''")}'`, (err) => {
|
||||||
|
res.json({ ok: !err });
|
||||||
|
});
|
||||||
|
} else if (app) {
|
||||||
|
exec(`open -a '${app.replace(/'/g, "'\\''")}'`, (err) => {
|
||||||
|
res.json({ ok: !err });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(400).json({ error: 'missing app or url' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── WebSocket server ──────────────────────────────────────────────────────────
|
||||||
|
const server = http.createServer(app);
|
||||||
|
const wss = new WebSocket.Server({ server });
|
||||||
|
|
||||||
|
function runAction(action, ws) {
|
||||||
|
if (!action) return;
|
||||||
|
|
||||||
|
const { type, value } = action;
|
||||||
|
console.log(` ▶ ${type}: ${value?.substring(0, 60)}`);
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'keystroke': {
|
||||||
|
// Simulate keyboard shortcut via AppleScript
|
||||||
|
// value: "cmd+c", "cmd+shift+4", "cmd+space", etc.
|
||||||
|
const parts = value.split('+').map(s => s.trim().toLowerCase());
|
||||||
|
const key = parts.pop();
|
||||||
|
const specialKeys = {
|
||||||
|
'cmd': 'command down', 'ctrl': 'control down', 'alt': 'option down',
|
||||||
|
'shift': 'shift down', 'option': 'option down', 'command': 'command down',
|
||||||
|
'control': 'control down', 'fn': 'fn down',
|
||||||
|
};
|
||||||
|
// Map key names to AppleScript key codes or names
|
||||||
|
const keyMap = {
|
||||||
|
'enter': 'return', 'return': 'return', 'tab': 'tab', 'space': 'space',
|
||||||
|
'escape': 'escape', 'esc': 'escape', 'backspace': 'backspace',
|
||||||
|
'delete': 'backspace', 'up': 'up', 'down': 'down', 'left': 'left',
|
||||||
|
'right': 'right', 'home': 'home', 'end': 'end', 'pageup': 'page up',
|
||||||
|
'pagedown': 'page down',
|
||||||
|
};
|
||||||
|
|
||||||
|
const appleKey = keyMap[key] || key;
|
||||||
|
const pressedKeys = parts.map(k => specialKeys[k] || k).join(', ');
|
||||||
|
const script = pressedKeys
|
||||||
|
? `tell application "System Events" to keystroke "${appleKey}" using {${pressedKeys}}`
|
||||||
|
: `tell application "System Events" to keystroke "${appleKey}"`;
|
||||||
|
|
||||||
|
exec(`osascript -e '${script.replace(/'/g, "'\\''")}'`, (err) => {
|
||||||
|
if (err) console.error(` ✗ keystroke error: ${err.message}`);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'app': {
|
||||||
|
exec(`open -a '${(value || '').replace(/'/g, "'\\''")}'`, (err) => {
|
||||||
|
if (err) console.error(` ✗ open app error: ${err.message}`);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'url': {
|
||||||
|
exec(`open '${(value || '').replace(/'/g, "'\\''")}'`, (err) => {
|
||||||
|
if (err) console.error(` ✗ open url error: ${err.message}`);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'shell': {
|
||||||
|
exec(value, { timeout: 5000 }, (err, stdout, stderr) => {
|
||||||
|
if (err) console.error(` ✗ shell error: ${err.message}`);
|
||||||
|
else if (stdout?.trim()) console.log(` ↳ ${stdout.trim().substring(0, 80)}`);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'osascript': {
|
||||||
|
exec(`osascript -e '${(value || '').replace(/'/g, "'\\''")}'`, (err, stdout, stderr) => {
|
||||||
|
if (err && stderr) console.error(` ✗ osascript error: ${stderr.trim()}`);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'volume': {
|
||||||
|
const vol = parseInt(value);
|
||||||
|
if (!isNaN(vol)) {
|
||||||
|
exec(`osascript -e 'set volume output volume ${vol}'`, (err) => {
|
||||||
|
if (err) console.error(` ✗ volume error: ${err.message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'brightness': {
|
||||||
|
// Requires external tool or AppleScript
|
||||||
|
exec(`osascript -e 'tell application "System Events"' -e 'repeat 20 times' -e 'key code 107' -e 'end repeat' -e 'end tell'`, () => {});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'sleep': {
|
||||||
|
exec('pmset displaysleepnow', (err) => {
|
||||||
|
if (err) console.error(` ✗ sleep error: ${err.message}`);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'screenshot': {
|
||||||
|
const script = value === 'window'
|
||||||
|
? 'tell application "System Events" to keystroke "3" using {command down, shift down}' // full screen
|
||||||
|
: value === 'area'
|
||||||
|
? 'tell application "System Events" to keystroke "4" using {command down, shift down}' // area
|
||||||
|
: 'tell application "System Events" to keystroke "5" using {command down, shift down}'; // screen record
|
||||||
|
exec(`osascript -e '${script}'`, (err) => {
|
||||||
|
if (err) console.error(` ✗ screenshot error: ${err.message}`);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
if (ws) ws.send(JSON.stringify({ type: 'error', message: `Unknown action type: ${type}` }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wss.on('connection', (ws) => {
|
||||||
|
console.log(' ◉ Browser connected via WebSocket');
|
||||||
|
ws.send(JSON.stringify({ type: 'connected', message: 'Stream Deck ready' }));
|
||||||
|
|
||||||
|
ws.on('message', (raw) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(raw.toString());
|
||||||
|
console.log(` ◀ ${msg.type}${msg.id ? ` [${msg.id}]` : ''}`);
|
||||||
|
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'press':
|
||||||
|
if (msg.actions) {
|
||||||
|
// Execute multiple actions sequentially
|
||||||
|
const executeNext = (index) => {
|
||||||
|
if (index >= msg.actions.length) return;
|
||||||
|
const action = msg.actions[index];
|
||||||
|
const delay = action.delay || 0;
|
||||||
|
if (delay > 0) {
|
||||||
|
setTimeout(() => { runAction(action, ws); executeNext(index + 1); }, delay);
|
||||||
|
} else {
|
||||||
|
runAction(action, ws);
|
||||||
|
executeNext(index + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
executeNext(0);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'ping':
|
||||||
|
ws.send(JSON.stringify({ type: 'pong' }));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
ws.send(JSON.stringify({ type: 'error', message: `Unknown message type: ${msg.type}` }));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(' ✗ Invalid message:', e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => console.log(' ◉ Browser disconnected'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Start ─────────────────────────────────────────────────────────────────────
|
||||||
|
server.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log('');
|
||||||
|
console.log('╔══════════════════════════════════════════════╗');
|
||||||
|
console.log('║ 🎛 Custom Stream Deck Server ║');
|
||||||
|
console.log('╠══════════════════════════════════════════════╣');
|
||||||
|
console.log(`║ Local: http://localhost:${PORT} ║`);
|
||||||
|
console.log(`║ Network: http://192.168.1.231:${PORT} ║`);
|
||||||
|
console.log('╠══════════════════════════════════════════════╣');
|
||||||
|
console.log(`║ Pages: ${config.pages.length} Grid: ${config.gridCols}×${config.gridRows} ║`);
|
||||||
|
console.log('╚══════════════════════════════════════════════╝');
|
||||||
|
console.log('');
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# StreamDeck — Electron app (touchscreen) / Web server (böngésző)
|
||||||
|
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
cd "$DIR"
|
||||||
|
|
||||||
|
case "${1:-electron}" in
|
||||||
|
server|node)
|
||||||
|
pkill -f "node $DIR/server.js" 2>/dev/null
|
||||||
|
nohup node server.js > /tmp/streamdeck.log 2>&1 &
|
||||||
|
echo "StreamDeck server started (PID $!)"
|
||||||
|
echo " → http://localhost:8090"
|
||||||
|
echo " → Log: /tmp/streamdeck.log"
|
||||||
|
echo " → Stop: pkill -f \"node $DIR/server.js\""
|
||||||
|
;;
|
||||||
|
electron|app|--dev)
|
||||||
|
echo "Starting StreamDeck Electron app..."
|
||||||
|
if [ "$1" = "--dev" ]; then
|
||||||
|
npx electron . --dev
|
||||||
|
else
|
||||||
|
npx electron .
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 [electron|server|--dev]"
|
||||||
|
echo " electron (default) Start native macOS app on touchscreen"
|
||||||
|
echo " server Start web server (browser mode)"
|
||||||
|
echo " --dev Electron app with DevTools"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Reference in New Issue
Block a user