Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f6e5bd151 | ||
|
|
2307770ce9 | ||
|
|
fe3811b03e | ||
|
|
41811ead1c | ||
|
|
2817a444c3 |
@@ -8,9 +8,6 @@ dist/
|
||||
config/modules.json
|
||||
config/layouts/
|
||||
|
||||
# compiled per-machine, source sits next to it
|
||||
modules/propresenter-timers/ltc-capture
|
||||
|
||||
# the launcher's build output, and its cache of the Node runtime it embeds
|
||||
launcher/macos/build/
|
||||
launcher/macos/.cache/
|
||||
|
||||
@@ -461,7 +461,8 @@ const menu = moduleApi.header.addMenu({
|
||||
build(menuEl) {
|
||||
// called on every open with an emptied menu element — fill it with
|
||||
// buttons (give them your scoped classes; the container is styled and
|
||||
// fixed-positioned by the shell, so the tile's overflow can't clip it)
|
||||
// positioned by the shell, hosted on the body under an element carrying
|
||||
// your data-module, so your scoped CSS applies and nothing clips it)
|
||||
},
|
||||
});
|
||||
menu.setLabel('Channels (2/4) ▾'); // update the button text any time
|
||||
|
||||
@@ -81,8 +81,8 @@ export default function create({ root, moduleApi }) {
|
||||
moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, current + delta)) });
|
||||
applyInstanceSettings();
|
||||
}
|
||||
moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-2) });
|
||||
moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(2) });
|
||||
const minusBtn = moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-2) });
|
||||
const plusBtn = moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(2) });
|
||||
|
||||
// icon and tooltip track the current direction — applyInstanceSettings sets both
|
||||
const flowBtn = moduleApi.header.addButton({
|
||||
@@ -95,7 +95,7 @@ export default function create({ root, moduleApi }) {
|
||||
},
|
||||
});
|
||||
|
||||
moduleApi.header.addButton({
|
||||
const latestBtn = moduleApi.header.addButton({
|
||||
icon: JUMP_SVG,
|
||||
title: 'Latest',
|
||||
onClick() {
|
||||
@@ -112,6 +112,82 @@ export default function create({ root, moduleApi }) {
|
||||
});
|
||||
clearBtn.classList.add('pt-clear');
|
||||
|
||||
/* ── a narrow tile: the bar keeps Channels, the rest fold into ⋯ ──
|
||||
Seven controls need ~175 px, and the shell clips whatever the bar
|
||||
cannot fit — on a 3-column tile among many, or one squeezed by the
|
||||
fullscreen fit, that used to take the channel filter with it. Below
|
||||
COMPACT_PX the six secondary controls hide and a single ⋯ menu offers
|
||||
the same actions, so the filter button is the last thing to go. */
|
||||
const MORE_SVG = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>';
|
||||
const COMPACT_PX = 380; // tile width below which the full bar no longer fits
|
||||
|
||||
let moreMenuEl = null;
|
||||
const moreMenu = moduleApi.header.addMenu({
|
||||
icon: MORE_SVG,
|
||||
title: 'More',
|
||||
build(menu) {
|
||||
menu.classList.add('pt-menu');
|
||||
moreMenuEl = menu;
|
||||
rebuildMoreMenu();
|
||||
},
|
||||
});
|
||||
const moreWrap = moreMenu.button.parentElement; // the shell's .tile-dd
|
||||
const secondaryBtns = [timeBtn, minusBtn, plusBtn, flowBtn, latestBtn, clearBtn];
|
||||
|
||||
function addMoreItem(label, { on = null, onClick } = {}) {
|
||||
const item = document.createElement('button');
|
||||
item.className = 'pt-channel-item' + (on ? ' on' : '');
|
||||
if (on !== null) {
|
||||
item.setAttribute('role', 'menuitemcheckbox');
|
||||
item.setAttribute('aria-checked', String(on));
|
||||
} else {
|
||||
item.setAttribute('role', 'menuitem');
|
||||
}
|
||||
const check = document.createElement('span');
|
||||
check.className = 'check';
|
||||
check.textContent = '✓';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'name';
|
||||
name.textContent = label;
|
||||
item.append(check, name);
|
||||
item.addEventListener('click', (e) => {
|
||||
e.stopPropagation(); // the rebuild detaches this item — keep the menu open
|
||||
onClick();
|
||||
});
|
||||
moreMenuEl.appendChild(item);
|
||||
}
|
||||
|
||||
function rebuildMoreMenu() {
|
||||
if (!moreMenuEl) return;
|
||||
moreMenuEl.innerHTML = '';
|
||||
const s = moduleApi.instanceSettings;
|
||||
addMoreItem('Timestamps', { on: Boolean(s.showTimes), onClick: () => timeBtn.click() });
|
||||
addMoreItem('Newest first', { on: newestFirst(), onClick: () => flowBtn.click() });
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'pt-menu-divider';
|
||||
moreMenuEl.appendChild(divider);
|
||||
addMoreItem('Smaller text', { onClick: () => bumpTextSize(-2) });
|
||||
addMoreItem('Larger text', { onClick: () => bumpTextSize(2) });
|
||||
addMoreItem('Latest', { onClick: () => { moreMenu.close(); latestBtn.click(); } });
|
||||
addMoreItem('Clear', { onClick: () => { moreMenu.close(); clearView(); } });
|
||||
}
|
||||
|
||||
let compact = null; // unknown until the first measurement
|
||||
function applyCompact(width) {
|
||||
const next = width > 0 && width < COMPACT_PX;
|
||||
if (next === compact) return;
|
||||
compact = next;
|
||||
for (const btn of secondaryBtns) btn.hidden = compact;
|
||||
moreWrap.hidden = !compact;
|
||||
if (!compact) moreMenu.close();
|
||||
}
|
||||
// the tile's own body tells the width; onResize only knows grid cells
|
||||
const sizeWatch = new ResizeObserver((entries) => {
|
||||
applyCompact(entries[0]?.contentRect.width || root.clientWidth);
|
||||
});
|
||||
sizeWatch.observe(root);
|
||||
applyCompact(root.clientWidth);
|
||||
|
||||
function applyInstanceSettings() {
|
||||
const s = moduleApi.instanceSettings;
|
||||
const size = Math.min(48, Math.max(10, Number(s.textSize) || 15));
|
||||
@@ -126,6 +202,7 @@ export default function create({ root, moduleApi }) {
|
||||
flowBtn.classList.toggle('active', up);
|
||||
flowBtn.title = up ? 'Newest first' : 'Oldest first';
|
||||
jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages';
|
||||
rebuildMoreMenu(); // its checkmarks mirror the buttons
|
||||
}
|
||||
applyInstanceSettings();
|
||||
|
||||
@@ -415,6 +492,7 @@ export default function create({ root, moduleApi }) {
|
||||
},
|
||||
stop() {
|
||||
stopped = true;
|
||||
sizeWatch.disconnect();
|
||||
stream?.close();
|
||||
root.innerHTML = ''; // header controls are removed by the shell
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "prodcom-transcript",
|
||||
"name": "ProdCom",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"proddash": ">=1.1.0",
|
||||
"description": "Live ProdCom transcript",
|
||||
"permissions": [
|
||||
|
||||
@@ -20,9 +20,13 @@
|
||||
}
|
||||
[data-module="prodcom-transcript"] .pt-count[hidden] { display: none; }
|
||||
|
||||
/* narrow tile: the secondary controls fold into the ⋯ menu (client.js) */
|
||||
[data-module="prodcom-transcript"] .tile-btn[hidden],
|
||||
[data-module="prodcom-transcript"] .tile-dd[hidden] { display: none; }
|
||||
|
||||
/* ── channels menu ──
|
||||
The menu container is created and positioned by the shell (.tile-menu);
|
||||
it lives inside the tile, so [data-module] scoping still applies. */
|
||||
it hangs off the body under a [data-module] host, so scoping still applies. */
|
||||
|
||||
[data-module="prodcom-transcript"] .pt-channel-item {
|
||||
display: flex;
|
||||
|
||||
BIN
Binary file not shown.
Binary file not shown.
@@ -3,8 +3,8 @@
|
||||
/**
|
||||
* Built-in LTC listener — capture child + in-process decode.
|
||||
*
|
||||
* Spawns the module's native capture tool (ltc-capture, compiled from
|
||||
* ltc-capture.swift beside it) for the admin-configured audio device, reads
|
||||
* Spawns the module's native capture tool (bin/ltc-capture-macos-<arch>,
|
||||
* shipped prebuilt; source in native/) for the admin-configured audio device, reads
|
||||
* the interleaved s16le stream, extracts the configured channel, and decodes
|
||||
* SMPTE LTC with ltc.js — all inside the module server. Reports upward via
|
||||
* onUpdate roughly 4×/s:
|
||||
@@ -16,17 +16,13 @@
|
||||
* shows (short — "Not configured — see Admin"), `detail` is the full story
|
||||
* for the admin health line and the log.
|
||||
*
|
||||
* The capture binary is per-machine (gitignored), and a catalog install or
|
||||
* update ships source only — the update even wipes a previously built
|
||||
* binary. ensureCaptureBinary() therefore rebuilds it with swiftc in the
|
||||
* background as part of coming up: kicked when the LTC source starts and by
|
||||
* the admin device picker, never on the event loop (a blocking build once
|
||||
* froze the whole server — see spl-meter's history), built to a temp name
|
||||
* and renamed so a killed build can't leave a half-written "binary", and
|
||||
* rebuilt when a module update leaves the source newer than the binary.
|
||||
* A dead capture child is restarted with backoff — fast for a crash, slow
|
||||
* for config-shaped failures (unknown device, ambiguity) that never fix
|
||||
* themselves.
|
||||
* The capture tool ships prebuilt inside the module, one binary per
|
||||
* platform under bin/ (module guide, "Native helpers"): nothing is ever
|
||||
* built on the machine running ProdDash. captureTool() picks the binary for
|
||||
* this platform; when there is none, timecode reports itself off and the
|
||||
* rest of the module carries on. A dead capture child is restarted with
|
||||
* backoff — fast for a crash, slow for config-shaped failures (unknown
|
||||
* device, ambiguity) that never fix themselves.
|
||||
*
|
||||
* macOS gotcha this file can't fix: microphone permission. A ProdDash
|
||||
* server launched over SSH or from a bare launchd job gets SILENT ZEROS
|
||||
@@ -51,107 +47,43 @@ const RUNNING_WINDOW_MS = 400;
|
||||
/** stderr line the capture prints once the device is open. */
|
||||
const CAPTURING_RE = /capturing "(.+)": (\d+) channels @ (\d+) Hz/;
|
||||
|
||||
/* ── the capture tool: build it when the install didn't bring one ──────── */
|
||||
/* ── the capture tool: prebuilt, one binary per platform ───────────────── */
|
||||
|
||||
/** Tile-facing words (short); the detail goes to admin health and the log. */
|
||||
const TOOL_BUILDING = 'Capture tool building…';
|
||||
const TOOL_MISSING = 'Capture tool unavailable — see Admin';
|
||||
const BUILD_HELP = 'LTC capture tool could not be built here — build it on any Mac of the '
|
||||
+ 'same architecture with `swiftc -O -o ltc-capture ltc-capture.swift` (both files are in '
|
||||
+ 'the module folder) and copy the binary next to ltc-capture.swift';
|
||||
|
||||
let building = false;
|
||||
let buildFailed = false;
|
||||
/** What the module ships in bin/, by platform and architecture. */
|
||||
const HELPERS = {
|
||||
darwin: { arm64: 'ltc-capture-macos-arm64', x64: 'ltc-capture-macos-x86_64' },
|
||||
win32: { x64: 'ltc-capture-win-x64.exe' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Make sure the capture tool exists, building it in the background when it
|
||||
* does not — module scope, so the LTC source can kick it the moment it
|
||||
* starts and the admin device picker can kick it before any device is
|
||||
* configured (a listener-only build would deadlock: no tool → no device
|
||||
* list → no device → no listener). A catalog install ships source only and
|
||||
* a catalog update deletes the module folder (old binary included), so this
|
||||
* IS the install step for the tool.
|
||||
* The capture tool for this machine. It is never built here and never
|
||||
* looked for anywhere else: the module ships it, or timecode is off on this
|
||||
* platform and says so. `ready` means a usable binary at `bin`; otherwise
|
||||
* `error` is the short tile text and `detail` names the platform.
|
||||
*
|
||||
* Never blocks (a synchronous build once held the whole server's event loop
|
||||
* — every module — for the duration). Builds to a temp name and renames, so
|
||||
* a killed or crashed build can't leave a truncated file that looks like a
|
||||
* ready binary. A binary older than the source (a module update over an
|
||||
* existing folder) keeps serving while a replacement builds behind it.
|
||||
*
|
||||
* @returns {{ready:boolean, error:string, detail:string}} `ready` = a binary
|
||||
* is usable right now; otherwise `error` is the short tile text and
|
||||
* `detail` the full one.
|
||||
* @returns {{ready:boolean, bin:string, error:string, detail:string}}
|
||||
*/
|
||||
function ensureCaptureBinary({ moduleDir, log, onReady }) {
|
||||
const bin = path.join(moduleDir, 'ltc-capture');
|
||||
const src = bin + '.swift';
|
||||
const ok = { ready: true, error: '', detail: '' };
|
||||
let haveBin = false;
|
||||
let stale = false;
|
||||
try {
|
||||
haveBin = fs.existsSync(bin);
|
||||
if (haveBin && fs.existsSync(src)) stale = fs.statSync(src).mtimeMs > fs.statSync(bin).mtimeMs;
|
||||
} catch { /* treat as missing */ }
|
||||
if (haveBin && !stale) return ok;
|
||||
if (!fs.existsSync(src)) {
|
||||
return haveBin ? ok : { ready: false, error: TOOL_MISSING, detail: 'ltc-capture.swift is missing from the module folder' };
|
||||
}
|
||||
const notYet = haveBin ? ok : {
|
||||
function captureTool(moduleDir, platform = process.platform, arch = process.arch) {
|
||||
const name = HELPERS[platform]?.[arch] || '';
|
||||
const bin = name ? path.join(moduleDir, 'bin', name) : '';
|
||||
if (bin && fs.existsSync(bin)) return { ready: true, bin, error: '', detail: '' };
|
||||
const here = `${platform === 'darwin' ? 'macOS' : platform === 'win32' ? 'Windows' : platform} ${arch}`;
|
||||
return {
|
||||
ready: false,
|
||||
error: building || !buildFailed ? TOOL_BUILDING : TOOL_MISSING,
|
||||
detail: building || !buildFailed ? 'Building the LTC capture tool — ready in a moment' : BUILD_HELP,
|
||||
bin: '',
|
||||
error: TOOL_MISSING,
|
||||
detail: `The module ships no LTC capture tool for ${here}${name ? ` (bin/${name} is missing)` : ''} — timecode is off on this machine`,
|
||||
};
|
||||
if (building || buildFailed) return notYet;
|
||||
|
||||
building = true;
|
||||
const tmp = bin + '.building';
|
||||
log(stale ? 'ltc-capture is older than its source — rebuilding it in the background'
|
||||
: 'ltc-capture binary missing — building it in the background');
|
||||
const build = spawn('swiftc', ['-O', '-o', tmp, src], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
let buildErr = '';
|
||||
build.stderr.setEncoding('utf8');
|
||||
build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); });
|
||||
const giveUp = setTimeout(() => {
|
||||
try { build.kill('SIGKILL'); } catch { /* already gone */ }
|
||||
}, 300000);
|
||||
giveUp.unref?.();
|
||||
build.on('error', (err) => {
|
||||
clearTimeout(giveUp);
|
||||
building = false;
|
||||
buildFailed = true;
|
||||
try { fs.rmSync(tmp, { force: true }); } catch { /* nothing to clean */ }
|
||||
log(`swiftc could not be run: ${err.message}`);
|
||||
});
|
||||
build.on('exit', (code) => {
|
||||
clearTimeout(giveUp);
|
||||
building = false;
|
||||
if (code === 0 && fs.existsSync(tmp)) {
|
||||
try {
|
||||
fs.renameSync(tmp, bin);
|
||||
} catch (err) {
|
||||
buildFailed = true;
|
||||
log(`ltc-capture built but could not be moved into place: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
buildFailed = false;
|
||||
log('ltc-capture built');
|
||||
if (typeof onReady === 'function') onReady();
|
||||
return;
|
||||
}
|
||||
buildFailed = true;
|
||||
try { fs.rmSync(tmp, { force: true }); } catch { /* nothing to clean */ }
|
||||
log(`ltc-capture build failed (exit ${code})` + (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
|
||||
});
|
||||
return notYet;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{device:string, channel:number, moduleDir:string, log:Function,
|
||||
* onUpdate:(u:object)=>void}} opts
|
||||
* @returns {{stop:()=>void}}
|
||||
*/
|
||||
function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
||||
const bin = path.join(moduleDir, 'ltc-capture');
|
||||
let child = null;
|
||||
let stopped = false;
|
||||
let restartTimer = null;
|
||||
@@ -181,22 +113,11 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
||||
|
||||
function start() {
|
||||
if (stopped) return;
|
||||
const tool = ensureCaptureBinary({
|
||||
moduleDir,
|
||||
log,
|
||||
onReady() {
|
||||
// The build this start() was waiting on landed — go now.
|
||||
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
|
||||
},
|
||||
});
|
||||
const tool = captureTool(moduleDir);
|
||||
if (!tool.ready) {
|
||||
// No binary for this platform: say so and look again later — a module
|
||||
// update remounts us anyway, this is only belt and braces.
|
||||
report({ error: tool.error, detail: tool.detail });
|
||||
// onReady is only the FAST path (a successful build restarts us at
|
||||
// once). The slow retry must stay armed regardless: a failed build
|
||||
// never calls back, and a binary can also arrive from outside — scp'd
|
||||
// in on a machine whose toolchain can't build (AB's broken CLT).
|
||||
// Waiting on onReady alone deadlocked exactly that way in production:
|
||||
// "Capture tool building…" forever, with a working binary on disk.
|
||||
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
@@ -206,7 +127,7 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
||||
let decoder = null;
|
||||
let stderrTail = '';
|
||||
|
||||
child = spawn(bin, ['--device', device], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
child = spawn(tool.bin, ['--device', device], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stderr.on('data', (text) => {
|
||||
@@ -298,4 +219,4 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createLtcListener, ensureCaptureBinary };
|
||||
module.exports = { createLtcListener, captureTool };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "propresenter-timers",
|
||||
"name": "Timers",
|
||||
"version": "1.6.0",
|
||||
"version": "1.7.0",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "Timers, Clocks, and LTC From configured Modules",
|
||||
"provides": [
|
||||
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/bin/zsh
|
||||
# Build this module's native helper for every macOS architecture it ships —
|
||||
# ../bin/ltc-capture-macos-arm64 and ../bin/ltc-capture-macos-x86_64 —
|
||||
# from ltc-capture.swift beside this script. Run it on any Mac with the
|
||||
# Xcode command line tools; both architectures cross-compile from either.
|
||||
# The binaries are committed: a machine running ProdDash never builds
|
||||
# anything (docs/MODULE-GUIDE.md, "Native helpers").
|
||||
#
|
||||
# Signed with a Developer ID Application certificate when one is installed
|
||||
# (override with CODESIGN_IDENTITY) — hardened runtime plus the audio-input
|
||||
# entitlement, which a helper that reads a microphone needs under the
|
||||
# hardened runtime — and ad-hoc otherwise, which is fine on your own Macs.
|
||||
#
|
||||
# Windows is not built here: there is no Windows implementation of this
|
||||
# tool yet, so on Windows the module reports timecode off.
|
||||
set -e -u
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
NAME="ltc-capture"
|
||||
DEPLOYMENT_TARGET="13.0"
|
||||
|
||||
command -v swiftc >/dev/null || {
|
||||
echo "build.sh: swiftc not found — install the Xcode command line tools: xcode-select --install" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
IDENTITY="${CODESIGN_IDENTITY:-}"
|
||||
if [[ -z "$IDENTITY" ]]; then
|
||||
IDENTITY=$(security find-identity -v -p codesigning 2>/dev/null \
|
||||
| awk -F'"' '/Developer ID Application/ {print $2; exit}')
|
||||
fi
|
||||
if [[ -n "$IDENTITY" ]]; then
|
||||
echo "signing as $IDENTITY"
|
||||
SIGN=(--force --sign "$IDENTITY" --options runtime --timestamp)
|
||||
else
|
||||
echo "signing ad-hoc (no Developer ID installed — fine on your own Macs)"
|
||||
SIGN=(--force --sign - --options runtime)
|
||||
fi
|
||||
|
||||
mkdir -p ../bin
|
||||
for ARCH in arm64 x86_64; do
|
||||
OUT="../bin/$NAME-macos-$ARCH"
|
||||
echo " compiling $OUT"
|
||||
swiftc -O -whole-module-optimization -target "$ARCH-apple-macos$DEPLOYMENT_TARGET" -o "$OUT" "$NAME.swift"
|
||||
codesign "${SIGN[@]}" --entitlements helper.entitlements "$OUT"
|
||||
codesign --verify --strict "$OUT"
|
||||
echo " built $OUT ($(du -h "$OUT" | awk '{print $1}'))"
|
||||
done
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- The helper reads an audio input; under the hardened runtime that
|
||||
needs saying. The permission itself comes from the launcher. -->
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
+5
-5
@@ -4,12 +4,12 @@
|
||||
// The module decodes LTC in-process (ltc.js) and spawns this to move
|
||||
// samples from an input device to a pipe. Production machines rarely have
|
||||
// ffmpeg/sox, and shipping a third-party binary for that one job is
|
||||
// overkill — this is the whole job, natively. The module compiles it on
|
||||
// first use when swiftc is available; otherwise build it once on any
|
||||
// same-architecture Mac and copy the binary next to this source:
|
||||
// overkill — this is the whole job, natively. It ships prebuilt in the
|
||||
// module's bin/ (one binary per platform, committed — a booth never
|
||||
// builds anything). To rebuild after a change here, on any Mac with the
|
||||
// Xcode command line tools:
|
||||
//
|
||||
// swiftc -O -o modules/propresenter-timers/ltc-capture \
|
||||
// modules/propresenter-timers/ltc-capture.swift (once, ~5 s)
|
||||
// modules/propresenter-timers/native/build.sh (both architectures, ~10 s)
|
||||
//
|
||||
// ltc-capture --list
|
||||
// every input-capable device: name, input channels, sample rate
|
||||
@@ -81,7 +81,7 @@
|
||||
const http = require('http');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
const { createLtcListener, ensureCaptureBinary } = require('./ltc-listener');
|
||||
const { createLtcListener, captureTool } = require('./ltc-listener');
|
||||
const { createTimerPhase } = require('./phase');
|
||||
const manifest = require('./module.json');
|
||||
|
||||
@@ -660,11 +660,8 @@ function createLtcSource({ config, log, moduleDir }) {
|
||||
kind: 'ltc',
|
||||
start(e) {
|
||||
emit = e;
|
||||
// Kick the capture-tool build right away: a catalog install ships
|
||||
// source only and a catalog update wipes any previously built binary,
|
||||
// so coming up after an install/update must rebuild it without
|
||||
// waiting for a device to be picked or a listener to need it.
|
||||
ensureCaptureBinary({ moduleDir, log });
|
||||
// The capture tool ships prebuilt in the module; the listener picks
|
||||
// it for this platform when it starts and says so when there is none.
|
||||
if (!device) {
|
||||
word = {
|
||||
error: 'Not configured — see Admin',
|
||||
@@ -1359,23 +1356,21 @@ module.exports = {
|
||||
},
|
||||
|
||||
// The admin device picker asks this machine what it can hear. The
|
||||
// options shape matches the admin select's optionsRoute contract.
|
||||
// Opening the picker also kicks the capture-tool build when a catalog
|
||||
// install/update left only the source — without this, no tool meant
|
||||
// no device list, and no device meant nothing would ever build it.
|
||||
// options shape matches the admin select's optionsRoute contract. The
|
||||
// capture tool ships prebuilt in the module (bin/); with none for this
|
||||
// platform the list is empty and the error says so.
|
||||
'GET /ltc/devices': (req, res) => {
|
||||
const tool = ensureCaptureBinary({ moduleDir: __dirname, log });
|
||||
const tool = captureTool(__dirname);
|
||||
if (!tool.ready) {
|
||||
sendJson(res, 200, { options: [], error: tool.detail || tool.error });
|
||||
sendJson(res, 200, { options: [], error: tool.detail });
|
||||
return;
|
||||
}
|
||||
const bin = path.join(__dirname, 'ltc-capture');
|
||||
const child = spawn(bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 });
|
||||
const child = spawn(tool.bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 });
|
||||
let err = '';
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stderr.on('data', (t) => { err += t; });
|
||||
child.on('error', () => {
|
||||
sendJson(res, 200, { options: [], error: 'LTC capture tool missing — see modules/propresenter-timers/ltc-capture.swift' });
|
||||
child.on('error', (e) => {
|
||||
sendJson(res, 200, { options: [], error: `LTC capture tool could not be run: ${e.message}` });
|
||||
});
|
||||
child.on('exit', () => {
|
||||
if (res.writableEnded) return;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proddash",
|
||||
"version": "1.10.0",
|
||||
"version": "1.10.1",
|
||||
"private": true,
|
||||
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
||||
"license": "UNLICENSED",
|
||||
|
||||
+41
-10
@@ -640,8 +640,10 @@ function buildTile(tile) {
|
||||
el.addEventListener('pointermove', (ev) => {
|
||||
if (!headIsOff(tile)) return;
|
||||
// Mid-drag the tile moves under the pointer, and mid-resize the edges do
|
||||
// — neither is a moment to take the bar away.
|
||||
// — neither is a moment to take the bar away. Nor is an open header
|
||||
// menu, which hangs below the bar and is about to be pointed at.
|
||||
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
|
||||
if (el.classList.contains('menu-open')) return;
|
||||
const y = ev.clientY - el.getBoundingClientRect().top;
|
||||
const shown = el.classList.contains('head-peek');
|
||||
// It takes less to hold the bar open than to open it, so a bar that
|
||||
@@ -652,6 +654,7 @@ function buildTile(tile) {
|
||||
|
||||
el.addEventListener('pointerleave', () => {
|
||||
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
|
||||
if (el.classList.contains('menu-open')) return; // the pointer is on its way to the menu
|
||||
el.classList.remove('head-peek');
|
||||
});
|
||||
|
||||
@@ -659,7 +662,7 @@ function buildTile(tile) {
|
||||
applyHeadState(tile, el);
|
||||
grid.appendChild(el);
|
||||
|
||||
const entry = { el, body, dot, titleEl: title, controls, instance: null };
|
||||
const entry = { el, body, dot, titleEl: title, controls, instance: null, openMenus: new Set() };
|
||||
tileEls.set(tile.id, entry);
|
||||
applyRect(tile);
|
||||
|
||||
@@ -1426,34 +1429,60 @@ function buildModuleApi(tile, man, entry) {
|
||||
menu.className = 'tile-menu';
|
||||
menu.hidden = true;
|
||||
|
||||
/* The menu lives on the body, not in the bar. A folded-away bar
|
||||
(auto-hide, fullscreen) carries a backdrop-filter and a transform,
|
||||
and either one makes a fixed-positioned descendant position
|
||||
itself against the bar instead of the viewport — the menu landed
|
||||
a screen-height below its button, inside the tile's overflow, and
|
||||
never showed. Its host carries the tile's data-module so the
|
||||
module's scoped CSS still reaches the items. */
|
||||
const host = document.createElement('div');
|
||||
host.className = 'tile-menu-host';
|
||||
if (man) host.dataset.module = man.id;
|
||||
host.appendChild(menu);
|
||||
|
||||
const setOpen = (on) => {
|
||||
if (on === !menu.hidden) return;
|
||||
menu.hidden = !on;
|
||||
btn.classList.toggle('is-open', on);
|
||||
if (on) entry.openMenus.add(menu);
|
||||
else entry.openMenus.delete(menu);
|
||||
// an open menu holds a folded-away bar out; when the last one
|
||||
// closes with the pointer elsewhere, the bar goes back
|
||||
entry.el.classList.toggle('menu-open', entry.openMenus.size > 0);
|
||||
if (!on && !entry.openMenus.size && !entry.el.matches(':hover')) entry.el.classList.remove('head-peek');
|
||||
};
|
||||
const open = () => {
|
||||
if (typeof build === 'function') {
|
||||
menu.innerHTML = '';
|
||||
build(menu);
|
||||
}
|
||||
menu.hidden = false;
|
||||
// fixed positioning (escapes the tile's overflow): under the
|
||||
// button, right-aligned, clamped to the viewport
|
||||
setOpen(true);
|
||||
// under the button, right-aligned, kept whole inside the viewport
|
||||
const r = btn.getBoundingClientRect();
|
||||
menu.style.top = Math.min(r.bottom + 6, window.innerHeight - 80) + 'px';
|
||||
menu.style.left = '0px';
|
||||
menu.style.top = '0px';
|
||||
const w = menu.offsetWidth;
|
||||
const h = menu.offsetHeight;
|
||||
menu.style.top = Math.max(8, Math.min(r.bottom + 6, window.innerHeight - h - 8)) + 'px';
|
||||
menu.style.left = Math.max(8, Math.min(r.right - w, window.innerWidth - w - 8)) + 'px';
|
||||
};
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (menu.hidden) open();
|
||||
else menu.hidden = true;
|
||||
else setOpen(false);
|
||||
});
|
||||
const onDocClick = (e) => {
|
||||
if (!menu.hidden && !menu.contains(e.target) && !btn.contains(e.target)) menu.hidden = true;
|
||||
if (!menu.hidden && !menu.contains(e.target) && !btn.contains(e.target)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('click', onDocClick);
|
||||
docListeners.add(onDocClick);
|
||||
|
||||
wrap.append(btn, menu);
|
||||
wrap.append(btn);
|
||||
entry.controls.appendChild(wrap);
|
||||
document.body.appendChild(host);
|
||||
headerEls.add(wrap);
|
||||
headerEls.add(host);
|
||||
return {
|
||||
button: btn,
|
||||
menu,
|
||||
@@ -1461,7 +1490,7 @@ function buildModuleApi(tile, man, entry) {
|
||||
btn.textContent = text;
|
||||
},
|
||||
close() {
|
||||
menu.hidden = true;
|
||||
setOpen(false);
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -1473,6 +1502,8 @@ function buildModuleApi(tile, man, entry) {
|
||||
for (const handle of [...sseHandles]) handle.close();
|
||||
for (const el of headerEls) el.remove();
|
||||
headerEls.clear();
|
||||
entry.openMenus.clear();
|
||||
entry.el.classList.remove('menu-open');
|
||||
for (const fn of docListeners) document.removeEventListener('click', fn);
|
||||
docListeners.clear();
|
||||
},
|
||||
|
||||
+3
-2
@@ -445,8 +445,9 @@ body.menu-hidden #topbar {
|
||||
}
|
||||
.tile-dd { position: relative; display: inline-flex; }
|
||||
|
||||
/* a module header menu — fixed-positioned by the shell so the tile's
|
||||
overflow:hidden can't clip it */
|
||||
/* a module header menu — hosted on the body (shell.js addMenu) so neither
|
||||
the tile's overflow nor a folded-away bar's backdrop-filter can touch it */
|
||||
.tile-menu-host { display: contents; }
|
||||
.tile-menu {
|
||||
position: fixed;
|
||||
z-index: 70;
|
||||
|
||||
Reference in New Issue
Block a user