Files
PackControl/main.js
Erik Hellak 3ff9d0dce4 Various Changes
- Added support for Streamdeck Pedal
- Changed UI to Packed Theme
- Added preview for knobs (Loupedeck Live)
- Added Start to Tray
- Added Udev Rules for Streamdeck Pedal
2026-02-28 00:00:07 +01:00

496 lines
16 KiB
JavaScript

import { app, BrowserWindow, ipcMain, dialog, Tray, Menu, nativeImage } from 'electron';
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
import { LoupedeckDevice } from './src/loupedeck/device.js';
import { ConfigManager } from './src/loupedeck/config.js';
import { PageManager } from './src/loupedeck/pages.js';
import { StreamDeckPedalDevice } from './src/streamdeck/device.js';
import { PedalPageManager } from './src/streamdeck/pages.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const START_IN_TRAY_ARG = '--start-in-tray';
const startInTray = process.argv.includes(START_IN_TRAY_ARG);
function getTrayIcon() {
const iconPath = path.join(__dirname, 'assets', 'icons', 'Icon.png');
return nativeImage.createFromPath(iconPath);
}
let mainWindow;
let tray;
let loupedeckDevice;
let configManager;
let pageManager;
let deviceStatus = { connected: false };
let pedalDevice;
let pedalPageManager;
let pedalStatus = { connected: false };
const startupWarnings = new Map();
function pushRuntimeWarning(data) {
if (!data || !data.code) return;
startupWarnings.set(data.code, data);
if (mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents) {
mainWindow.webContents.send('runtime-warning', data);
}
}
function createWindow(startHidden = false) {
mainWindow = new BrowserWindow({
width: 1580,
height: 800,
minWidth: 1380,
minHeight: 600,
show: !startHidden,
frame: false,
icon: path.join(__dirname, 'assets', 'icons', 'Icon.png'),
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
nodeIntegration: false
},
backgroundColor: '#1a1a2e',
title: 'Packed PackControl - Loupedeck Live'
});
mainWindow.loadFile('src/renderer/index.html');
mainWindow.webContents.on('did-finish-load', () => {
mainWindow.webContents.send('device-status', deviceStatus);
mainWindow.webContents.send('pedal-status', pedalStatus);
startupWarnings.forEach((warning) => {
mainWindow.webContents.send('runtime-warning', warning);
});
});
mainWindow.on('close', (event) => {
if (!app.isQuitting) {
event.preventDefault();
mainWindow.hide();
}
});
}
function applyAutostartToTraySetting(enabled) {
const openAtLogin = !!enabled;
try {
app.setLoginItemSettings({
openAtLogin,
openAsHidden: openAtLogin,
args: openAtLogin ? [START_IN_TRAY_ARG] : []
});
} catch (error) {
console.warn('Could not set login item settings:', error.message);
}
}
function createTray() {
tray = new Tray(getTrayIcon());
const contextMenu = Menu.buildFromTemplate([
{ label: 'Open', click: () => mainWindow.show() },
{ label: 'Quit', click: () => { app.isQuitting = true; app.quit(); } }
]);
tray.setToolTip('Packed');
tray.setContextMenu(contextMenu);
tray.on('click', () => mainWindow.show());
}
async function initializeLoupedeck() {
if (!configManager) {
configManager = new ConfigManager();
await configManager.load();
}
applyAutostartToTraySetting(configManager.getSetting('autostartToTray'));
pageManager = new PageManager(configManager);
loupedeckDevice = new LoupedeckDevice(pageManager, configManager);
loupedeckDevice.on('connected', () => {
deviceStatus = { connected: true };
mainWindow.webContents.send('device-status', { connected: true });
});
loupedeckDevice.on('disconnected', () => {
deviceStatus = { connected: false };
mainWindow.webContents.send('device-status', { connected: false });
});
loupedeckDevice.on('button-press', (data) => {
mainWindow.webContents.send('button-press', data);
});
loupedeckDevice.on('knob-rotate', (data) => {
mainWindow.webContents.send('knob-rotate', data);
});
loupedeckDevice.on('page-changed', (pageIndex) => {
mainWindow.webContents.send('page-changed', pageIndex);
});
loupedeckDevice.on('metric-update', (updates) => {
mainWindow.webContents.send('metric-update', updates);
});
loupedeckDevice.on('button-toggle', (data) => {
mainWindow.webContents.send('button-toggle', data);
});
loupedeckDevice.on('runtime-warning', (data) => {
pushRuntimeWarning(data);
});
await loupedeckDevice.connect();
}
async function initializePedal() {
pedalPageManager = new PedalPageManager(configManager);
pedalDevice = new StreamDeckPedalDevice(pedalPageManager, configManager);
pedalDevice.on('connected', () => {
pedalStatus = { connected: true };
mainWindow.webContents.send('pedal-status', { connected: true });
});
pedalDevice.on('disconnected', () => {
pedalStatus = { connected: false };
mainWindow.webContents.send('pedal-status', { connected: false });
});
pedalDevice.on('button-press', (data) => {
mainWindow.webContents.send('pedal-button-press', data);
});
pedalDevice.on('runtime-warning', (data) => {
pushRuntimeWarning(data);
});
await pedalDevice.connect();
}
// Pedal-IPC-Handler
ipcMain.handle('get-pedal-pages', () => pedalPageManager?.getPages() ?? []);
ipcMain.handle('get-pedal-current-page', () => pedalPageManager?.getCurrentPageIndex() ?? 0);
ipcMain.handle('get-pedal-status', () => pedalStatus);
ipcMain.handle('set-pedal-button-config', async (event, { pageIndex, buttonIndex, config }) => {
if (!pedalPageManager) return false;
pedalPageManager.setButtonConfig(pageIndex, buttonIndex, config);
await configManager.save();
return true;
});
ipcMain.handle('reset-pedal-button-config', async (event, { pageIndex, buttonIndex }) => {
if (!pedalPageManager) return null;
const defaultConfig = pedalPageManager.resetButtonConfig(pageIndex, buttonIndex);
if (!defaultConfig) return null;
await configManager.save();
return defaultConfig;
});
ipcMain.handle('add-pedal-page', async () => {
if (!pedalPageManager) return null;
const newPage = pedalPageManager.addPage();
await configManager.save();
return newPage;
});
ipcMain.handle('rename-pedal-page', async (event, { pageIndex, name }) => {
if (!pedalPageManager) return false;
pedalPageManager.renamePage(pageIndex, name);
await configManager.save();
return true;
});
ipcMain.handle('delete-pedal-page', async (event, pageIndex) => {
if (!pedalPageManager) return false;
pedalPageManager.deletePage(pageIndex);
await configManager.save();
return true;
});
ipcMain.handle('switch-pedal-page', async (event, pageIndex) => {
if (!pedalPageManager) return false;
pedalPageManager.switchPage(pageIndex);
return true;
});
ipcMain.handle('reconnect-pedal', async () => {
if (!pedalDevice) return;
await pedalDevice.disconnect();
await pedalDevice.connect();
});
// IPC-Handler
ipcMain.handle('get-config', () => configManager.getConfig());
ipcMain.handle('get-pages', () => pageManager.getPages());
ipcMain.handle('get-current-page', () => pageManager.getCurrentPageIndex());
ipcMain.handle('get-device-status', () => deviceStatus);
ipcMain.handle('set-button-config', async (event, { pageIndex, buttonIndex, config }) => {
pageManager.setButtonConfig(pageIndex, buttonIndex, config);
await configManager.save();
if (pageIndex === pageManager.getCurrentPageIndex()) {
await loupedeckDevice.renderButtonIndex(buttonIndex, config);
}
return true;
});
ipcMain.handle('reset-button-config', async (event, { pageIndex, buttonIndex }) => {
const defaultConfig = pageManager.resetButtonConfig(pageIndex, buttonIndex);
if (!defaultConfig) return null;
await configManager.save();
if (pageIndex === pageManager.getCurrentPageIndex()) {
await loupedeckDevice.renderButtonIndex(buttonIndex, defaultConfig);
}
return defaultConfig;
});
ipcMain.handle('swap-button-configs', async (event, { pageIndex, sourceIndex, targetIndex }) => {
const result = pageManager.swapButtonConfigs(pageIndex, sourceIndex, targetIndex);
if (!result) return null;
await configManager.save();
if (pageIndex === pageManager.getCurrentPageIndex()) {
await loupedeckDevice.renderButtonIndex(sourceIndex, result.source);
await loupedeckDevice.renderButtonIndex(targetIndex, result.target);
}
return result;
});
ipcMain.handle('set-knob-config', async (event, { knobIndex, config }) => {
pageManager.setKnobConfig(knobIndex, config);
await configManager.save();
return true;
});
ipcMain.handle('reset-knob-config', async (event, { knobIndex }) => {
const defaultConfig = pageManager.resetKnobConfig(knobIndex);
if (!defaultConfig) return null;
await configManager.save();
if (loupedeckDevice && loupedeckDevice.connected) {
await loupedeckDevice.renderSideDisplays();
}
return defaultConfig;
});
ipcMain.handle('add-page', async () => {
const newPage = pageManager.addPage();
await configManager.save();
return newPage;
});
ipcMain.handle('rename-page', async (event, { pageIndex, name }) => {
pageManager.renamePage(pageIndex, name);
await configManager.save();
return true;
});
ipcMain.handle('set-page-focus-config', async (event, { pageIndex, config }) => {
const updated = pageManager.setPageFocusConfig(pageIndex, config);
if (!updated) return false;
await configManager.save();
return true;
});
ipcMain.handle('delete-page', async (event, pageIndex) => {
pageManager.deletePage(pageIndex);
await configManager.save();
pageManager.renderCurrentPage(loupedeckDevice);
return true;
});
ipcMain.handle('switch-page', async (event, pageIndex) => {
pageManager.switchPage(pageIndex);
await pageManager.renderCurrentPage(loupedeckDevice);
return true;
});
ipcMain.handle('select-image', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openFile'],
filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif'] }]
});
if (!result.canceled && result.filePaths.length > 0) {
return result.filePaths[0];
}
return null;
});
ipcMain.handle('read-image-data', async (event, filePath) => {
if (!filePath) return null;
try {
const ext = path.extname(filePath).toLowerCase();
const mime = ext === '.png'
? 'image/png'
: (ext === '.jpg' || ext === '.jpeg'
? 'image/jpeg'
: (ext === '.gif' ? 'image/gif' : 'application/octet-stream'));
const data = await fs.readFile(filePath);
return `data:${mime};base64,${data.toString('base64')}`;
} catch (error) {
console.error('Could not read image:', error.message);
return null;
}
});
ipcMain.handle('export-config', async () => {
const result = await dialog.showSaveDialog(mainWindow, {
defaultPath: 'packcontrol-config.json',
filters: [{ name: 'JSON', extensions: ['json'] }]
});
if (!result.canceled) {
await configManager.exportTo(result.filePath);
return true;
}
return false;
});
ipcMain.handle('import-config', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openFile'],
filters: [{ name: 'JSON', extensions: ['json'] }]
});
if (!result.canceled && result.filePaths.length > 0) {
await configManager.importFrom(result.filePaths[0]);
pageManager.reload(configManager);
pageManager.renderCurrentPage(loupedeckDevice);
return configManager.getConfig();
}
return null;
});
ipcMain.handle('reconnect-device', async () => {
await loupedeckDevice.disconnect();
await loupedeckDevice.connect();
});
ipcMain.handle('set-setting', async (event, { key, value }) => {
const previousAccent = key === 'accentColor' ? configManager.getSetting('accentColor') : null;
configManager.setSetting(key, value);
if (key === 'autostartToTray') {
applyAutostartToTraySetting(value);
}
if (key === 'accentColor') {
const config = configManager.getConfig();
if (Array.isArray(config.pages)) {
config.pages.forEach((page) => {
if (!page || !Array.isArray(page.buttons)) return;
page.buttons.forEach((button) => {
if (!button) return;
if (button.borderColorAuto === undefined) {
if (button.borderColor && previousAccent && button.borderColor.toLowerCase() !== previousAccent.toLowerCase()) {
button.borderColorAuto = false;
return;
}
button.borderColorAuto = true;
}
if (button.borderColorAuto !== false) {
button.borderColor = value;
}
});
});
config.pages.forEach((page) => {
if (!page || !page.knobs) return;
Object.values(page.knobs).forEach((knob) => {
if (!knob) return;
if (knob.knobColorAuto === undefined) {
if (knob.knobColor && previousAccent && knob.knobColor.toLowerCase() !== previousAccent.toLowerCase()) {
knob.knobColorAuto = false;
return;
}
knob.knobColorAuto = true;
}
if (knob.knobColorAuto !== false) {
knob.knobColor = value;
}
});
});
}
}
await configManager.save();
if (key === 'accentColor' && loupedeckDevice) {
await loupedeckDevice.renderCurrentPage();
}
return true;
});
ipcMain.handle('set-circle-button-config', async (event, { index, config }) => {
pageManager.setCircleButtonConfig(index, config);
await configManager.save();
// Farbe am Geraet direkt aktualisieren
if (loupedeckDevice && loupedeckDevice.connected) {
const isActive = config.pageIndex === pageManager.getCurrentPageIndex();
const color = loupedeckDevice.getCircleButtonColor(config.pageIndex, isActive);
try {
await loupedeckDevice.device.setButtonColor({ id: index, color });
} catch (e) {
// Ignorieren
}
}
return true;
});
ipcMain.handle('render-page', async () => {
if (loupedeckDevice) {
await loupedeckDevice.renderCurrentPage();
}
});
// Fenstersteuerung
ipcMain.handle('window-minimize', () => {
mainWindow.minimize();
});
ipcMain.handle('window-maximize', () => {
if (mainWindow.isMaximized()) {
mainWindow.unmaximize();
} else {
mainWindow.maximize();
}
});
ipcMain.handle('window-close', () => {
mainWindow.close();
});
app.whenReady().then(async () => {
configManager = new ConfigManager();
await configManager.load();
const startHidden = startInTray || !!configManager.getSetting('autostartToTray');
createWindow(startHidden);
// Tray nur bauen, wenn das Icon da ist
try {
createTray();
} catch (e) {
console.log('Tray icon not found, skipping tray');
}
await initializeLoupedeck();
await initializePedal();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow(false);
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('before-quit', async () => {
app.isQuitting = true;
if (loupedeckDevice) {
await loupedeckDevice.disconnect();
}
if (pedalDevice) {
await pedalDevice.disconnect();
}
});