diff --git a/Dockerfile b/Dockerfile
index adede0d..5ac34fa 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -4,7 +4,7 @@ ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json* ./
-RUN npm install --omit=dev --no-audit --no-fund
+RUN apk add --no-cache ffmpeg && npm install --omit=dev --no-audit --no-fund
COPY src ./src
COPY public ./public
diff --git a/package.json b/package.json
index d148256..7bed1b9 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,7 @@
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js",
- "check": "node --check src/server.js && node --check src/filestash.js && node --check src/effects.js && node --check src/homeassistant.js && node --check src/routes.js"
+ "check": "node --check src/server.js && node --check src/filestash.js && node --check src/effects.js && node --check src/homeassistant.js && node --check src/streamMixer.js && node --check src/routes.js"
},
"engines": {
"node": ">=20"
diff --git a/public/app.js b/public/app.js
index 2fea6fe..48f6c24 100644
--- a/public/app.js
+++ b/public/app.js
@@ -1,8 +1,12 @@
(() => {
'use strict';
+ const STORAGE_KEY = 'arcana-fx-sets-v2';
+
const els = {
grid: document.getElementById('grid'),
+ setGrid: document.getElementById('set-grid'),
+ setEmpty: document.getElementById('set-empty'),
empty: document.getElementById('empty'),
search: document.getElementById('search'),
categories: document.getElementById('categories'),
@@ -10,26 +14,44 @@
statusText: document.getElementById('status-text'),
refresh: document.getElementById('refresh-btn'),
stop: document.getElementById('stop-btn'),
+ stream: document.getElementById('stream-btn'),
+ streamStop: document.getElementById('stream-stop-btn'),
+ now: document.getElementById('now-btn'),
+ nowCount: document.getElementById('now-count'),
entityLabel: document.getElementById('entity-label'),
countLabel: document.getElementById('count-label'),
- lights: document.getElementById('lights'),
+ setSelect: document.getElementById('set-select'),
+ newSet: document.getElementById('new-set-btn'),
+ renameSet: document.getElementById('rename-set-btn'),
+ deleteSet: document.getElementById('delete-set-btn'),
+ setTitle: document.getElementById('set-title'),
+ setCount: document.getElementById('set-count'),
+ drawer: document.getElementById('now-drawer'),
+ backdrop: document.getElementById('drawer-backdrop'),
+ drawerClose: document.getElementById('drawer-close'),
+ drawerStopAll: document.getElementById('drawer-stop-all'),
+ activeList: document.getElementById('active-list'),
+ activeEmpty: document.getElementById('active-empty'),
};
const state = {
effects: [],
+ byPath: new Map(),
categories: [],
activeCategory: 'all',
search: '',
- playingPath: null,
busy: false,
+ streamOn: false,
+ active: [],
+ sets: loadSets(),
+ currentSetId: null,
};
- const LIGHT_ICONS = {
- flame: '\u{1F525}',
- moon: '\u{1F319}',
- sparkles: '\u{2728}',
- droplet: '\u{1FA78}',
- };
+ if (!state.sets.length) state.sets = [{ id: uid(), name: 'Session Favorites', paths: [] }];
+ state.currentSetId = state.sets[0].id;
+
+ function uid() { return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; }
+ function currentSet() { return state.sets.find((s) => s.id === state.currentSetId) || state.sets[0]; }
function setStatus(kind, text) {
els.status.classList.remove('ok', 'busy', 'err');
@@ -38,190 +60,277 @@
}
function escapeHtml(str) {
- return String(str)
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''');
+ return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''');
}
- function renderCategories() {
- const all = ['all', ...state.categories];
- els.categories.innerHTML = all
- .map((c) => {
- const label = c === 'all' ? 'All' : c;
- const active = state.activeCategory === c ? ' active' : '';
- return ``;
- })
- .join('');
+ function loadSets() {
+ try {
+ const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
+ return Array.isArray(parsed) ? parsed.filter((s) => s && s.id && s.name && Array.isArray(s.paths)) : [];
+ } catch { return []; }
}
- function renderGrid() {
- const q = state.search.trim().toLowerCase();
- const filtered = state.effects.filter((e) => {
- if (state.activeCategory !== 'all' && e.category !== state.activeCategory) return false;
- if (!q) return true;
- return (
- e.name.toLowerCase().includes(q) ||
- e.category.toLowerCase().includes(q) ||
- e.path.toLowerCase().includes(q)
- );
- });
-
- els.empty.hidden = filtered.length !== 0;
- els.countLabel.textContent = `${filtered.length} of ${state.effects.length} effects`;
-
- els.grid.innerHTML = filtered
- .map((e) => {
- const playing = state.playingPath === e.path ? ' playing' : '';
- return `
- `;
- })
- .join('');
- }
-
- function renderLights(cfg) {
- if (!cfg || !Array.isArray(cfg.placeholders)) {
- els.lights.innerHTML = '';
- return;
- }
- els.lights.innerHTML = cfg.placeholders
- .map((p) => {
- const icon = LIGHT_ICONS[p.icon] || '✨';
- return `
-
- Soon
- ${icon}
- ${escapeHtml(p.name)}
-
`;
- })
- .join('');
- }
+ function saveSets() { localStorage.setItem(STORAGE_KEY, JSON.stringify(state.sets)); }
async function fetchJson(url, opts) {
const res = await fetch(url, opts);
const text = await res.text();
let body = null;
- if (text) {
- try { body = JSON.parse(text); } catch { body = { error: text }; }
- }
+ if (text) { try { body = JSON.parse(text); } catch { body = { error: text }; } }
if (!res.ok) {
- const msg = (body && body.error) || `${res.status} ${res.statusText}`;
- const err = new Error(msg);
+ const err = new Error((body && body.error) || `${res.status} ${res.statusText}`);
err.status = res.status;
throw err;
}
return body || {};
}
+ function renderCategories() {
+ const all = ['all', ...state.categories];
+ els.categories.innerHTML = all.map((c) => {
+ const active = state.activeCategory === c ? ' active' : '';
+ const label = c === 'all' ? 'All' : c;
+ return ``;
+ }).join('');
+ }
+
+ function visibleEffects() {
+ const q = state.search.trim().toLowerCase();
+ return state.effects.filter((e) => {
+ if (state.activeCategory !== 'all' && e.category !== state.activeCategory) return false;
+ if (!q) return true;
+ const haystack = `${e.name} ${e.category} ${e.type} ${e.path.replace(/[/.\\_-]+/g, ' ')}`.toLowerCase();
+ return haystack.includes(q);
+ }).slice(0, 240);
+ }
+
+ function tile(effect, { inSetView = false } = {}) {
+ const set = currentSet();
+ const inSet = set.paths.includes(effect.path);
+ return `
+
+
+
+ ${inSetView ? `` : ``}
+
+ `;
+ }
+
+ function renderGrid() {
+ const filtered = visibleEffects();
+ els.empty.hidden = filtered.length !== 0;
+ els.countLabel.textContent = `${state.effects.length} effects · showing ${filtered.length}`;
+ els.grid.innerHTML = filtered.map((e) => tile(e)).join('');
+ renderSet();
+ }
+
+ function renderSetsSelect() {
+ els.setSelect.innerHTML = state.sets.map((s) => ``).join('');
+ els.setSelect.value = state.currentSetId;
+ }
+
+ function renderSet() {
+ renderSetsSelect();
+ const set = currentSet();
+ const effects = set.paths.map((p) => state.byPath.get(p)).filter(Boolean);
+ els.setTitle.textContent = set.name;
+ els.setCount.textContent = `${effects.length} item${effects.length === 1 ? '' : 's'}`;
+ els.setEmpty.hidden = effects.length > 0;
+ els.setGrid.innerHTML = effects.map((e) => tile(e, { inSetView: true })).join('');
+ }
+
+ function renderActive() {
+ els.nowCount.textContent = String(state.active.length);
+ els.activeEmpty.hidden = state.active.length > 0;
+ els.activeList.innerHTML = state.active.map((a) => `
+
+
+ ${escapeHtml(a.name)}
+ ${escapeHtml(a.category)} · ${Math.round((a.ageMs || 0) / 1000)}s
+ ${a.error ? `${escapeHtml(a.error)}` : ''}
+
+
+
`).join('');
+ }
+
async function loadConfig() {
try {
const cfg = await fetchJson('/api/config');
- if (cfg.mediaPlayer) {
- els.entityLabel.textContent = cfg.mediaPlayer;
- }
- renderLights(cfg.lightControl);
- } catch (err) {
- console.warn('config load failed', err);
- }
+ if (cfg.mediaPlayer) els.entityLabel.textContent = cfg.mediaPlayer;
+ } catch (err) { console.warn('config load failed', err); }
}
async function loadEffects({ force = false } = {}) {
- setStatus('busy', force ? 'Rescanning the share…' : 'Summoning effects…');
+ setStatus('busy', force ? 'Rescanning library…' : 'Loading effects…');
try {
- const url = force ? '/api/effects?refresh=1' : '/api/effects';
- const data = await fetchJson(url);
+ const data = await fetchJson(force ? '/api/effects?refresh=1' : '/api/effects');
state.effects = Array.isArray(data.effects) ? data.effects : [];
+ state.byPath = new Map(state.effects.map((e) => [e.path, e]));
state.categories = Array.isArray(data.categories) ? data.categories : [];
- if (!state.categories.includes(state.activeCategory) && state.activeCategory !== 'all') {
- state.activeCategory = 'all';
- }
+ if (!state.categories.includes(state.activeCategory) && state.activeCategory !== 'all') state.activeCategory = 'all';
renderCategories();
renderGrid();
- setStatus('ok', `Ready — ${state.effects.length} effect${state.effects.length === 1 ? '' : 's'} loaded`);
+ setStatus('ok', `Ready — ${state.effects.length} effects`);
} catch (err) {
console.error(err);
- setStatus('err', `Failed to load effects: ${err.message}`);
+ setStatus('err', `Load failed: ${err.message}`);
}
}
async function play(effectPath) {
- if (state.busy) return;
- state.busy = true;
- setStatus('busy', `Casting — ${effectPath.split('/').pop()}`);
+ setStatus('busy', `Injecting — ${effectPath.split('/').pop()}`);
try {
const res = await fetchJson('/api/play', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ path: effectPath }),
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: effectPath }),
});
- state.playingPath = effectPath;
- renderGrid();
- const name = (res.played && res.played.name) || effectPath;
- setStatus('ok', `Playing — ${name}`);
+ state.active = (res.stream && res.stream.active) || state.active;
+ renderActive();
+ setStatus('ok', `Injected — ${(res.injected && res.injected.name) || effectPath}`);
} catch (err) {
console.error(err);
setStatus('err', `Play failed: ${err.message}`);
- } finally {
- state.busy = false;
}
}
- async function stop() {
+ async function startStream() {
if (state.busy) return;
state.busy = true;
- setStatus('busy', 'Silencing…');
+ setStatus('busy', 'Starting continuous device stream…');
try {
- await fetchJson('/api/stop', { method: 'POST' });
- state.playingPath = null;
- renderGrid();
- setStatus('ok', 'Stopped');
+ await fetchJson('/api/stream/start', { method: 'POST' });
+ state.streamOn = true;
+ els.stream.textContent = 'Restart device stream';
+ setStatus('ok', 'Device stream running — clicks will mix instead of replacing playback');
} catch (err) {
console.error(err);
- setStatus('err', `Stop failed: ${err.message}`);
- } finally {
- state.busy = false;
- }
+ setStatus('err', `Stream start failed: ${err.message}`);
+ } finally { state.busy = false; }
}
- // --- Event wiring ---
+ async function stopDeviceStream() {
+ setStatus('busy', 'Stopping device stream…');
+ try {
+ await fetchJson('/api/stream/stop', { method: 'POST' });
+ state.streamOn = false;
+ state.active = [];
+ els.stream.textContent = 'Start device stream';
+ renderActive();
+ setStatus('ok', 'Device stream stopped');
+ } catch (err) { setStatus('err', `Stop failed: ${err.message}`); }
+ }
- els.grid.addEventListener('click', (e) => {
- const btn = e.target.closest('.tile');
- if (!btn) return;
- const path = btn.dataset.path;
- if (path) play(path);
- });
+ async function stopEffects() {
+ try {
+ const res = await fetchJson('/api/stop', { method: 'POST' });
+ state.active = (res.stream && res.stream.active) || [];
+ renderActive();
+ setStatus('ok', 'Stopped all injected effects');
+ } catch (err) { setStatus('err', `Stop failed: ${err.message}`); }
+ }
- els.categories.addEventListener('click', (e) => {
- const btn = e.target.closest('.chip');
- if (!btn) return;
- state.activeCategory = btn.dataset.cat || 'all';
- renderCategories();
+ async function refreshActive() {
+ try {
+ const res = await fetchJson('/api/stream/status');
+ state.active = (res.stream && res.stream.active) || [];
+ renderActive();
+ } catch { /* status polling is best-effort */ }
+ }
+
+ function toggleSet(effectPath) {
+ const set = currentSet();
+ const idx = set.paths.indexOf(effectPath);
+ if (idx >= 0) set.paths.splice(idx, 1); else set.paths.push(effectPath);
+ saveSets();
renderGrid();
- });
+ }
+ function removeFromSet(effectPath) {
+ const set = currentSet();
+ set.paths = set.paths.filter((p) => p !== effectPath);
+ saveSets();
+ renderGrid();
+ }
+
+ function newSet() {
+ const name = prompt('Set name?', 'New Encounter');
+ if (!name) return;
+ const set = { id: uid(), name: name.trim(), paths: [] };
+ state.sets.push(set);
+ state.currentSetId = set.id;
+ saveSets();
+ renderGrid();
+ }
+
+ function renameSet() {
+ const set = currentSet();
+ const name = prompt('Rename set:', set.name);
+ if (!name) return;
+ set.name = name.trim();
+ saveSets();
+ renderGrid();
+ }
+
+ function deleteSet() {
+ if (state.sets.length === 1) return alert('Keep at least one set.');
+ const set = currentSet();
+ if (!confirm(`Delete set "${set.name}"?`)) return;
+ state.sets = state.sets.filter((s) => s.id !== set.id);
+ state.currentSetId = state.sets[0].id;
+ saveSets();
+ renderGrid();
+ }
+
+ function openDrawer() { els.drawer.removeAttribute('aria-hidden'); els.backdrop.hidden = false; refreshActive(); }
+ function closeDrawer() { els.drawer.setAttribute('aria-hidden', 'true'); els.backdrop.hidden = true; }
+
+ function gridClick(e) {
+ const action = e.target.closest('[data-action]');
+ const tileEl = e.target.closest('.tile');
+ if (!action || !tileEl) return;
+ const path = tileEl.dataset.path;
+ if (action.dataset.action === 'play') play(path);
+ if (action.dataset.action === 'toggle-set') toggleSet(path);
+ if (action.dataset.action === 'remove-set') removeFromSet(path);
+ }
+
+ els.grid.addEventListener('click', gridClick);
+ els.setGrid.addEventListener('click', gridClick);
+ els.categories.addEventListener('click', (e) => {
+ const btn = e.target.closest('.chip'); if (!btn) return;
+ state.activeCategory = btn.dataset.cat || 'all'; renderCategories(); renderGrid();
+ });
let searchTimer = null;
els.search.addEventListener('input', () => {
clearTimeout(searchTimer);
- searchTimer = setTimeout(() => {
- state.search = els.search.value;
- renderGrid();
- }, 80);
+ searchTimer = setTimeout(() => { state.search = els.search.value; renderGrid(); }, 80);
+ });
+ els.refresh.addEventListener('click', () => loadEffects({ force: true }));
+ els.stop.addEventListener('click', stopEffects);
+ els.stream.addEventListener('click', startStream);
+ els.streamStop.addEventListener('click', stopDeviceStream);
+ els.drawerStopAll.addEventListener('click', stopEffects);
+ els.now.addEventListener('click', openDrawer);
+ els.drawerClose.addEventListener('click', closeDrawer);
+ els.backdrop.addEventListener('click', closeDrawer);
+ els.setSelect.addEventListener('change', () => { state.currentSetId = els.setSelect.value; renderGrid(); });
+ els.newSet.addEventListener('click', newSet);
+ els.renameSet.addEventListener('click', renameSet);
+ els.deleteSet.addEventListener('click', deleteSet);
+ els.activeList.addEventListener('click', async (e) => {
+ const btn = e.target.closest('[data-stop-id]'); if (!btn) return;
+ await fetchJson('/api/play/stop', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: btn.dataset.stopId }) });
+ refreshActive();
});
- els.refresh.addEventListener('click', () => loadEffects({ force: true }));
- els.stop.addEventListener('click', stop);
-
- // --- Boot ---
loadConfig();
loadEffects();
+ renderSetsSelect();
+ renderActive();
+ setInterval(refreshActive, 1500);
})();
diff --git a/public/index.html b/public/index.html
index e107c7f..9772c1e 100644
--- a/public/index.html
+++ b/public/index.html
@@ -15,56 +15,80 @@
🔮
Arcana FX
-
Dungeon Master’s Control Room
+
D&D soundboard mixer
- Summoning effects…
+ Loading effects…
-
-
+
+
+
-
-
-
Sound Effects
-
-
-
+
+
+
Prepared Sets
+
Main workflow: build encounter sets, then fire sounds from the set during play.
+
+
+
+
+
+
-
-
-
-
-
- No effects match your incantation.
-
+
-
Light Control
- Coming soon
+ Current Set
+ 0 items
-
- A scrying mirror for the lantern circuits. Coloured washes, flicker spells, and
- power runes will appear here once the relays are bound.
-
-
+
+ No effects in this set yet. Search below and use + Set.
+
+
+
+
+
+
+ No effects match the search.
+
+
+