Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,28 @@ <h2 class="panel-title">Primary / Backup</h2>
</div>
</div>

<!-- PerfectCue Network Extender panel -->
<div class="panel">
<h2 class="panel-title">PerfectCue Network Extender</h2>
<p class="card-description">Accept slide advance/back commands from DSAN PerfectCue network extenders over TCP. Add one port per extender (or group).</p>

<div class="form-group">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="perfectcue-enabled" style="width: auto;" />
<span>Enable PerfectCue listener</span>
</label>
</div>

<div class="form-group" id="perfectcue-ports-group">
<label>Listener Ports</label>
<div id="perfectcue-port-list" style="display: flex; flex-direction: column; gap: 10px; width: 100%; min-width: 0;"></div>
<button type="button" id="add-perfectcue-port" class="btn btn-secondary" style="margin-top: 10px;">+ Add port</button>
<small class="field-hint">Default port is 8899. Add one entry per network extender or subnet if they connect on different ports.</small>
</div>

<button type="button" id="save-perfectcue-btn" class="btn btn-primary" style="margin-top: 8px;">Save PerfectCue settings</button>
</div>

<!-- Presentation rendering panel -->
<div class="panel">
<h2 class="panel-title">Presentation Rendering</h2>
Expand Down
61 changes: 61 additions & 0 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const net = require('net');
const dns = require('dns');
const util = require('util');
const QRCode = require('qrcode');
const { createPerfectCueServer } = require('./src/perfectcue-server');

// ----------------------------
// Logging helpers (secure by default)
Expand Down Expand Up @@ -1969,6 +1970,62 @@ async function checkBackupStatus() {
return { backups };
}

// ─── PerfectCue Network Extender ───────────────────────────────────────────

let perfectCueServers = [];

function dispatchPerfectCueSlide(endpoint) {
const options = {
hostname: '127.0.0.1',
port: 9595,
path: `/api/${endpoint}`,
method: 'POST',
headers: { 'Content-Length': 0 }
};
const req = http.request(options, res => {
logDebug(`[PerfectCue] dispatched ${endpoint} → HTTP ${res.statusCode}`);
res.resume();
});
req.on('error', err => logDebug(`[PerfectCue] dispatch error: ${err.message}`));
req.end();
}

function startPerfectCueListeners(ports) {
stopPerfectCueListeners();
for (const port of ports) {
const server = createPerfectCueServer({
dispatch: dispatchPerfectCueSlide,
log: (msg) => logDebug('[PerfectCue]', msg),
onStatus: () => {}
});
server.listen(port, '0.0.0.0', () => {
logDebug('[PerfectCue] Listening on port', port);
});
server.on('error', err => logDebug(`[PerfectCue] port ${port} error: ${err.message}`));
perfectCueServers.push(server);
}
}

function stopPerfectCueListeners() {
for (const server of perfectCueServers) {
server.close();
}
perfectCueServers = [];
}

function applyPerfectCuePrefs(prefs) {
if (prefs.perfectCueEnabled !== true) {
stopPerfectCueListeners();
return;
}
// Migrate legacy single-port pref
const legacyPort = prefs.perfectCuePort ? [Number(prefs.perfectCuePort)] : [];
const ports = Array.isArray(prefs.perfectCuePorts) && prefs.perfectCuePorts.length > 0
? prefs.perfectCuePorts.map(Number).filter(p => p > 0)
: (legacyPort.length > 0 ? legacyPort : [8899]);
startPerfectCueListeners(ports);
}

// Start backup status polling (called when app starts in primary mode)
let backupStatusInterval = null;

Expand Down Expand Up @@ -2554,6 +2611,7 @@ ipcMain.handle('save-preferences', async (event, incoming) => {
mergedPrefs.presentationNativeFullscreen = incoming.presentationNativeFullscreen === true;
}
const saveMeta = savePreferences(mergedPrefs) || {};
applyPerfectCuePrefs(mergedPrefs);
return { success: true, ...saveMeta };
});

Expand Down Expand Up @@ -10974,6 +11032,8 @@ app.whenReady().then(() => {
// Start backup status polling if in primary mode
startBackupStatusPolling();

applyPerfectCuePrefs(loadPreferences());

scheduleReadmeScreenshotCapture();

app.on('activate', () => {
Expand All @@ -10990,6 +11050,7 @@ app.on('window-all-closed', () => {
});

app.on('before-quit', () => {
stopPerfectCueListeners();
stopCloudflaredTunnel();
if (httpServer) {
console.log('[API] Shutting down HTTP server');
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"build:icon:win": "node scripts/build-windows-icon.js",
"download:cloudflared": "bash scripts/download-cloudflared.sh",
"smoke:slides-gpu": "electron scripts/slides-presenter-gpu-smoke.cjs",
"capture:readme-screenshots": "cross-env GSO_README_CAPTURE=1 electron ."
"capture:readme-screenshots": "cross-env GSO_README_CAPTURE=1 electron .",
"test": "node --test 'tests/**/*.test.js'"
},
"keywords": [
"electron",
Expand Down Expand Up @@ -43,6 +44,7 @@
"renderer.js",
"index.html",
"styles.css",
"src/**/*",
"BUILD-INFO.txt",
"build/icon.icns",
"build/icon.png",
Expand Down
97 changes: 97 additions & 0 deletions renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ const stagetimerEnabledCheckbox = document.getElementById('stagetimer-enabled');
const stagetimerVisibleCheckbox = document.getElementById('stagetimer-visible');
const saveStagetimerBtn = document.getElementById('save-stagetimer-btn');
const loadStagetimerBtn = document.getElementById('load-stagetimer-btn');
const perfectCueEnabledCheckbox = document.getElementById('perfectcue-enabled');
const perfectCuePortList = document.getElementById('perfectcue-port-list');
const addPerfectCuePortBtn = document.getElementById('add-perfectcue-port');
const savePerfectCueBtn = document.getElementById('save-perfectcue-btn');
const verboseLoggingCheckbox = document.getElementById('verbose-logging');
const webUiDebugConsoleEnabledCheckbox = document.getElementById('web-ui-debug-console-enabled');
const webUiThemeSelect = document.getElementById('web-ui-theme');
Expand Down Expand Up @@ -612,6 +616,14 @@ async function initDisplays() {
// Restore controller allowlist (desktop-only)
const controllerIps = Array.isArray(preferences.controllerIps) ? preferences.controllerIps : [];
renderControllerIpList(controllerIps);

// Restore PerfectCue settings (migrate legacy single-port pref)
if (perfectCueEnabledCheckbox) {
perfectCueEnabledCheckbox.checked = preferences.perfectCueEnabled === true;
}
const legacyPort = preferences.perfectCuePort ? [preferences.perfectCuePort] : [];
const perfectCuePorts = Array.isArray(preferences.perfectCuePorts) ? preferences.perfectCuePorts : legacyPort;
renderPerfectCuePortList(perfectCuePorts);

// Save preferences when selections change
presentationDisplay.addEventListener('change', saveMonitorPreferences);
Expand Down Expand Up @@ -809,6 +821,17 @@ async function initDisplays() {
await saveControllerAllowlistPreferences();
});
}

if (addPerfectCuePortBtn) {
addPerfectCuePortBtn.addEventListener('click', () => {
addPerfectCuePortRow('');
const inputs = perfectCuePortList ? perfectCuePortList.querySelectorAll('input[data-perfectcue-port="true"]') : [];
if (inputs.length) inputs[inputs.length - 1].focus();
});
}
if (savePerfectCueBtn) {
savePerfectCueBtn.addEventListener('click', savePerfectCuePrefs);
}

// Load and display network info
await updateNetworkInfo();
Expand Down Expand Up @@ -1078,6 +1101,80 @@ function renderBackupIpList(ips = []) {
normalized.forEach((ip) => addBackupIpRow(ip));
}

function addPerfectCuePortRow(initialValue = '') {
if (!perfectCuePortList) return;

const row = document.createElement('div');
row.setAttribute('data-perfectcue-row', 'true');
row.style.display = 'flex';
row.style.gap = '10px';
row.style.alignItems = 'center';
row.style.width = '100%';
row.style.minWidth = '0';

const input = document.createElement('input');
input.type = 'number';
input.className = 'input-field';
input.placeholder = '8899';
input.min = '1024';
input.max = '65535';
input.value = initialValue || '';
input.setAttribute('data-perfectcue-port', 'true');
input.style.flex = '1 1 0%';
input.style.width = '100%';
input.style.minWidth = '100px';

const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'btn btn-secondary';
removeBtn.textContent = 'Remove';
removeBtn.style.padding = '8px 10px';
removeBtn.style.minWidth = '88px';

removeBtn.addEventListener('click', () => {
const rows = perfectCuePortList.querySelectorAll('[data-perfectcue-row="true"]');
if (rows.length <= 1) {
input.value = '';
return;
}
row.remove();
});

row.appendChild(input);
row.appendChild(removeBtn);
perfectCuePortList.appendChild(row);
}

function renderPerfectCuePortList(ports = []) {
if (!perfectCuePortList) return;
perfectCuePortList.innerHTML = '';
const normalized = Array.isArray(ports) ? ports.map(Number).filter(p => p > 0) : [];
if (normalized.length === 0) {
addPerfectCuePortRow('8899');
return;
}
normalized.forEach(p => addPerfectCuePortRow(String(p)));
}

function getPerfectCuePortsFromUi() {
if (!perfectCuePortList) return [];
return Array.from(perfectCuePortList.querySelectorAll('input[data-perfectcue-port="true"]'))
.map(el => Number(el.value))
.filter(p => p >= 1024 && p <= 65535);
}

async function savePerfectCuePrefs() {
try {
const enabled = perfectCueEnabledCheckbox ? perfectCueEnabledCheckbox.checked : false;
const ports = getPerfectCuePortsFromUi();
await window.electronAPI.savePreferences({ perfectCueEnabled: enabled, perfectCuePorts: ports });
showStatus('PerfectCue settings saved', 'info');
} catch (err) {
console.error('Failed to save PerfectCue preferences:', err);
showStatus('Failed to save PerfectCue settings', 'error');
}
}

async function loadTunnelStatus() {
if (!wanEnabledCheckbox || !window.electronAPI.getTunnelStatus) return;
try {
Expand Down
10 changes: 10 additions & 0 deletions src/perfectcue-parser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
const COMMAND_BYTES = {
0x0F: 'next',
0x1F: 'previous',
};

function parsePerfectCueByte(byte) {
return COMMAND_BYTES[byte] ?? null;
}

module.exports = { parsePerfectCueByte };
39 changes: 39 additions & 0 deletions src/perfectcue-server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
const net = require('net');
const { parsePerfectCueByte } = require('./perfectcue-parser');

function createPerfectCueServer({ dispatch, log, onStatus }) {
const server = net.createServer(socket => {
onStatus('connected', socket.remoteAddress);
log(`DSAN connected from ${socket.remoteAddress}`);

socket.on('data', chunk => {
const hex = [...chunk].map(b => b.toString(16).padStart(2, '0')).join(' ');
const ascii = chunk.toString('ascii').replace(/[^\x20-\x7e]/g, '.');
log(`raw: ${hex} | ${ascii}`);

for (const byte of chunk) {
const cmd = parsePerfectCueByte(byte);
if (cmd === 'next') dispatch('next-slide');
else if (cmd === 'previous') dispatch('previous-slide');
}
});

socket.on('close', () => {
onStatus('listening', null);
log('DSAN disconnected, waiting for reconnect');
});

socket.on('error', err => {
log(`socket error: ${err.message}`);
});
});

server.on('error', err => {
onStatus('error', null);
log(`port error: ${err.message}`);
});

return server;
}

module.exports = { createPerfectCueServer };
23 changes: 23 additions & 0 deletions tests/perfectcue-parser.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { parsePerfectCueByte } = require('../src/perfectcue-parser');

test('0x0F returns next', () => {
assert.equal(parsePerfectCueByte(0x0F), 'next');
});

test('0x1F returns previous', () => {
assert.equal(parsePerfectCueByte(0x1F), 'previous');
});

test('0xFF keepalive returns null', () => {
assert.equal(parsePerfectCueByte(0xFF), null);
});

test('0x00 unknown byte returns null', () => {
assert.equal(parsePerfectCueByte(0x00), null);
});

test('arbitrary unknown byte returns null', () => {
assert.equal(parsePerfectCueByte(0xAB), null);
});
Loading
Loading