Files
newbury-exhibit-v2/public/admin/playlist.html
T

290 lines
17 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Playlist — Newbury Exhibit</title></head>
<body>
<div id="app"></div>
<script type="module">
import { api, requireAuth, styles, nav } from './admin.js';
document.head.insertAdjacentHTML('beforeend', `<style>${styles}
main { max-width:1080px; margin:18px auto; padding:0 16px; }
.card { background:#111730; border-radius:10px; padding:14px 16px; margin-bottom:14px; }
.card h2 { margin:0 0 10px; font-size:.95rem; }
.row { display:flex; gap:10px; align-items:center; margin:8px 0; }
.row label { width:170px; }
.trk { background:#0d1226; border-radius:10px; padding:12px 14px; margin-bottom:10px; border-left:3px solid #2a3a6e; }
.trk.sched { border-left-color:#51eaf1; }
.trk.off { opacity:.45; }
.thead { display:flex; gap:10px; align-items:center; margin-bottom:8px; }
.thead input.nm { flex:1; font-weight:600; }
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:8px 12px; }
.f label { display:block; font-size:.72rem; opacity:.7; margin-bottom:2px; }
.f input, .f select { width:100%; box-sizing:border-box; }
.rt { margin-top:8px; font-size:.8rem; color:#51eaf1; }
.rt .warn { color:#ffd166; }
.setline { font-size:.75rem; opacity:.7; margin-top:6px; }
.chips { display:flex; flex-wrap:wrap; gap:5px; margin-top:6px; max-height:150px; overflow:auto; }
.chip { padding:3px 9px; border-radius:999px; background:#1a2244; cursor:pointer; font-size:.75rem; }
.chip.on { background:#2a6ea0; }
table { width:100%; font-size:.85rem; border-collapse:collapse; }
td { padding:3px 8px 3px 0; }
</style>`);
await requireAuth();
const app = document.getElementById('app');
let cfg = await api('/api/playlist');
const { ghosts } = await api('/api/ghosts');
const scene = await api('/api/scene');
const rarities = ['Common', 'Rare', 'Epic', 'Legendary'];
const colors = ['Red', 'Yellow', 'Blue'];
let expanded = null; // trackId whose ghost-picker is open
const fmt = (s) => s >= 3600 ? `${(s / 3600).toFixed(1)} hr` : s >= 60 ? `~${Math.round(s / 60)} min` : `${Math.round(s)} s`;
const locOptions = (sel) => ['random']
.concat((scene.spawns || []).map(s => s.id))
.concat((scene.paths || []).map(p => 'path:' + p.id))
.map(v => `<option value="${v}" ${v === sel ? 'selected' : ''}>${v}</option>`).join('');
function setSize(t) {
const resIds = new Set((cfg.residents || []).map(r => r.id));
const all = ghosts.filter(g => !resIds.has(g.id));
if (!t.set || t.set === 'all') return all.length;
if (t.set.ids?.length) return t.set.ids.length;
return all.filter(g => (!t.set.colors?.length || t.set.colors.includes(g.color)) &&
(!t.set.rarities?.length || t.set.rarities.includes(g.rarity))).length;
}
function approx(t) {
const n = setSize(t);
return (t.spawnTime === 'random' || t.spawnTime == null)
? n * (t.runEach || 30)
: (Number(t.spawnTime) || 0) + (t.runEach || 30);
}
function setLabel(t) {
if (!t.set || t.set === 'all') return 'All ghosts';
if (t.set.ids?.length) {
const names = t.set.ids.map(id => ghosts.find(g => g.id === id)?.name || id);
return names.length <= 3 ? names.join(' and ') : `${names.length} ghosts`;
}
const bits = [...(t.set.colors || []), ...(t.set.rarities || [])];
return bits.length ? bits.join(' / ') : 'All ghosts';
}
function trackCard(t, i) {
const timed = !(t.spawnTime === 'random' || t.spawnTime == null);
const n = setSize(t);
const rt = approx(t);
const tooMany = t.concurrent > n;
return `<div class="trk ${timed ? 'sched' : ''} ${t.enabled === false ? 'off' : ''}" data-i="${i}">
<div class="thead">
<input class="nm" data-k="name" value="${t.name || t.id}">
<label><input type="checkbox" data-k="enabled" ${t.enabled !== false ? 'checked' : ''}> enabled</label>
<button data-up="${i}" ${i === 0 ? 'disabled' : ''}>↑</button>
<button data-down="${i}">↓</button>
<button data-del="${i}" class="danger">✕</button>
</div>
<div class="grid">
<div class="f"><label>Ghosts</label>
<button data-pick="${t.id}" style="width:100%;text-align:left">${setLabel(t)} (${n})</button></div>
<div class="f"><label>SpawnTime</label>
<select data-k="spawnMode">
<option value="random" ${!timed ? 'selected' : ''}>random (continuous)</option>
<option value="timed" ${timed ? 'selected' : ''}>every…</option>
</select></div>
<div class="f"><label>${timed ? 'interval (minutes)' : '—'}</label>
<input type="number" step="0.5" min="0" data-k="spawnTime" value="${timed ? (Number(t.spawnTime) / 60) : ''}" ${timed ? '' : 'disabled'}></div>
<div class="f"><label>SpawnPoint</label>
<select data-k="spawnPoint">${locOptions(t.spawnPoint || 'random')}</select></div>
<div class="f"><label>RunEach (seconds)</label>
<input type="number" step="1" min="1" data-k="runEach" value="${t.runEach || 30}"></div>
<div class="f"><label>ConcurrentSpawns</label>
<input type="number" step="1" min="1" data-k="concurrent" value="${t.concurrent || 1}"></div>
<div class="f"><label>Priority</label>
<input type="number" step="1" data-k="priority" value="${t.priority ?? (timed ? 10 : 0)}"></div>
</div>
<div class="rt"><b>ApproxRuntime: ${fmt(rt)}</b>
<span style="opacity:.7">— ${timed
? `one cycle: waits ${fmt(Number(t.spawnTime) || 0)}, then runs ${fmt(t.runEach)}`
: `one full pass through ${n} ghost${n === 1 ? '' : 's'} at ${t.runEach}s each`}</span>
${tooMany ? `<div class="warn">⚠ ConcurrentSpawns (${t.concurrent}) exceeds the ${n}-ghost set — only ${n} can show at once.</div>` : ''}
${timed && t.runEach > Number(t.spawnTime) ? `<div class="warn">⚠ RunEach is longer than the interval — this track is effectively always on screen.</div>` : ''}
</div>
${expanded === t.id ? ghostPicker(t) : ''}
</div>`;
}
function ghostPicker(t) {
const ids = (t.set && t.set.ids) ? t.set.ids : [];
const cols = (t.set && t.set.colors) ? t.set.colors : [];
const rars = (t.set && t.set.rarities) ? t.set.rarities : [];
return `<div class="setline" style="margin-top:10px">
<b>Set:</b> pick individual ghosts, or filter by colour/rarity. Nothing selected = all ghosts.
<button data-clear="${t.id}" style="margin-left:8px">clear</button>
<div style="margin-top:6px">Colours: ${colors.map(c =>
`<span class="chip ${cols.includes(c) ? 'on' : ''}" data-col="${t.id}|${c}">${c}</span>`).join(' ')}
&nbsp; Rarities: ${rarities.map(r =>
`<span class="chip ${rars.includes(r) ? 'on' : ''}" data-rar="${t.id}|${r}">${r}</span>`).join(' ')}</div>
<div class="chips">${ghosts.map(g =>
`<span class="chip ${ids.includes(g.id) ? 'on' : ''}" data-gid="${t.id}|${g.id}">${g.name}</span>`).join('')}</div>
</div>`;
}
function residentRow(r, i) {
const gOpts = ghosts.map(g => `<option value="${g.id}" ${g.id === r.id ? 'selected' : ''}>${g.name} (${g.rarity} ${g.color})</option>`).join('');
const locSel = r.pathId ? 'path:' + r.pathId : (r.spawnId || '');
const locOpts = ['<option value="">auto</option>']
.concat((scene.spawns || []).map(sp => `<option value="${sp.id}" ${locSel === sp.id ? 'selected' : ''}>spawn ${sp.id}</option>`))
.concat((scene.paths || []).map(pt => `<option value="path:${pt.id}" ${locSel === 'path:' + pt.id ? 'selected' : ''}>path ${pt.id}</option>`)).join('');
const behOpts = ['static', 'wander', 'path'].map(b => `<option ${(r.behavior || 'static') === b ? 'selected' : ''}>${b}</option>`).join('');
return `<div class="row" data-res="${i}">
<select data-rk="id" style="flex:2;min-width:0">${gOpts}</select>
<select data-rk="loc" style="flex:1;min-width:0">${locOpts}</select>
<select data-rk="behavior" style="width:90px">${behOpts}</select>
<button data-resdel="${i}" class="danger">✕</button></div>`;
}
function render() {
const tracks = cfg.tracks || [];
const longest = tracks.filter(t => t.enabled !== false).map(approx);
app.innerHTML = `${nav('playlist')}<main>
<div class="card">
<h2>Playlist tracks <span style="opacity:.6;font-weight:400;font-size:.8rem">— run simultaneously; scheduled tracks (cyan) outrank ambient ones and will take a spawn point from them</span></h2>
<div id="tracks">${tracks.map(trackCard).join('') || '<em style="opacity:.6">No tracks yet</em>'}</div>
<button id="addTrack" style="margin-top:6px">+ Add track</button>
${longest.length ? `<div class="rt" style="margin-top:10px">Full show loops roughly every <b>${fmt(Math.max(...longest))}</b>
(longest enabled track) · ${tracks.filter(t => t.enabled !== false).length} track(s) active</div>` : ''}
</div>
<div class="card">
<h2>Residents <span style="opacity:.6;font-weight:400;font-size:.8rem">— permanent, never rotate out, outrank every track</span></h2>
<div id="residents">${(cfg.residents || []).map(residentRow).join('') || '<em style="opacity:.6">No residents</em>'}</div>
<button id="addRes" style="margin-top:8px">+ Add resident</button>
</div>
<div class="card">
<h2>Movement &amp; rarity</h2>
<div class="grid">
<div class="f"><label>Static chance (01)</label><input id="staticChance" type="number" step="0.05" min="0" max="1" value="${cfg.behaviors.staticChance}"></div>
<div class="f"><label>Wander radius (cm)</label><input id="wanderRadius" type="number" step="1" value="${cfg.behaviors.wanderRadius}"></div>
<div class="f"><label>Wander speed</label><input id="wanderSpeed" type="number" step="0.05" value="${cfg.behaviors.wanderSpeed}"></div>
<div class="f"><label>Vertical wander (cm)</label><input id="wanderVertical" type="number" step="1" value="${cfg.behaviors.wanderVertical ?? 10}"></div>
<div class="f"><label>Path speed (cm/s)</label><input id="pathSpeed" type="number" step="0.5" value="${cfg.behaviors.pathSpeed ?? 8}"></div>
<div class="f"><label>Path chance (01)</label><input id="pathChance" type="number" step="0.05" min="0" max="1" value="${cfg.behaviors.pathChance ?? 0.4}"></div>
<div class="f"><label>Crossfade (seconds)</label><input id="fade" type="number" step="0.5" min="0" value="${cfg.crossfadeSeconds}"></div>
</div>
<table style="margin-top:10px">
<tr style="opacity:.6"><td></td><td>appearance weight</td><td>movement scale</td></tr>
${rarities.map(r => `<tr><td>${r}</td>
<td><input data-rw="${r}" type="number" step="0.05" min="0" style="width:90px" value="${cfg.rarity?.weights?.[r] ?? 1}"></td>
<td><input data-rm="${r}" type="number" step="0.05" min="0" style="width:90px" value="${cfg.rarity?.movementScale?.[r] ?? 1}"></td></tr>`).join('')}
</table>
<p style="opacity:.6;font-size:.78rem">Weights bias which ghost an <b>ambient</b> track picks next; movement scale shrinks wander/path speed for rarer ghosts.</p>
</div>
<div class="card">
<h2>Time windows <span style="opacity:.6;font-weight:400;font-size:.8rem">— whole exhibit sleeps outside these; empty = always on</span></h2>
<div id="windows">${(cfg.timeWindows || []).map((w, i) =>
`<div class="row"><input type="time" value="${w.start}" data-i="${i}" data-k="start"> →
<input type="time" value="${w.end}" data-i="${i}" data-k="end">
<button data-wdel="${i}" class="danger">✕</button></div>`).join('') || '<em style="opacity:.6">Always on</em>'}</div>
<button id="addWin" style="margin-top:8px">+ Add window</button>
</div>
<button id="save" class="primary" style="width:100%;padding:12px;font-size:1rem">Apply playlist (restarts all tracks)</button>
<div id="status" style="margin:10px 0;opacity:.75;font-size:.85rem"></div>
</main>`;
wire();
}
function wire() {
// per-track fields
document.querySelectorAll('#tracks .trk').forEach(el => {
const i = +el.dataset.i, t = cfg.tracks[i];
el.querySelectorAll('[data-k]').forEach(inp => inp.onchange = () => {
const k = inp.dataset.k;
if (k === 'enabled') t.enabled = inp.checked;
else if (k === 'name') t.name = inp.value;
else if (k === 'spawnMode') t.spawnTime = inp.value === 'random' ? 'random' : 60;
else if (k === 'spawnTime') t.spawnTime = Math.max(0, (parseFloat(inp.value) || 0) * 60);
else if (k === 'spawnPoint') t.spawnPoint = inp.value;
else t[k] = parseFloat(inp.value) || 0;
render();
});
});
document.querySelectorAll('[data-pick]').forEach(b => b.onclick = () => {
expanded = expanded === b.dataset.pick ? null : b.dataset.pick; render();
});
const trackById = id => cfg.tracks.find(t => t.id === id);
const ensureSet = t => { if (!t.set || t.set === 'all') t.set = { ids: [], colors: [], rarities: [] }; return t.set; };
document.querySelectorAll('[data-gid]').forEach(c => c.onclick = () => {
const [tid, gid] = c.dataset.gid.split('|'); const s = ensureSet(trackById(tid));
s.ids ||= []; const k = s.ids.indexOf(gid); k >= 0 ? s.ids.splice(k, 1) : s.ids.push(gid); render();
});
document.querySelectorAll('[data-col]').forEach(c => c.onclick = () => {
const [tid, v] = c.dataset.col.split('|'); const s = ensureSet(trackById(tid));
s.colors ||= []; const k = s.colors.indexOf(v); k >= 0 ? s.colors.splice(k, 1) : s.colors.push(v); render();
});
document.querySelectorAll('[data-rar]').forEach(c => c.onclick = () => {
const [tid, v] = c.dataset.rar.split('|'); const s = ensureSet(trackById(tid));
s.rarities ||= []; const k = s.rarities.indexOf(v); k >= 0 ? s.rarities.splice(k, 1) : s.rarities.push(v); render();
});
document.querySelectorAll('[data-clear]').forEach(b => b.onclick = () => { trackById(b.dataset.clear).set = 'all'; render(); });
document.querySelectorAll('[data-del]').forEach(b => b.onclick = () => { cfg.tracks.splice(+b.dataset.del, 1); render(); });
document.querySelectorAll('[data-up]').forEach(b => b.onclick = () => {
const i = +b.dataset.up; [cfg.tracks[i - 1], cfg.tracks[i]] = [cfg.tracks[i], cfg.tracks[i - 1]]; render();
});
document.querySelectorAll('[data-down]').forEach(b => b.onclick = () => {
const i = +b.dataset.down; if (i + 1 >= cfg.tracks.length) return;
[cfg.tracks[i + 1], cfg.tracks[i]] = [cfg.tracks[i], cfg.tracks[i + 1]]; render();
});
document.getElementById('addTrack').onclick = () => {
(cfg.tracks ||= []).push({ id: 'track-' + Date.now().toString(36), name: 'New track', set: 'all',
spawnTime: 600, spawnPoint: 'random', runEach: 120, concurrent: 1, priority: 10, enabled: true });
render();
};
// residents
document.getElementById('addRes').onclick = () => { (cfg.residents ||= []).push({ id: ghosts[0].id, behavior: 'static' }); render(); };
document.querySelectorAll('[data-resdel]').forEach(b => b.onclick = () => { cfg.residents.splice(+b.dataset.resdel, 1); render(); });
document.querySelectorAll('#residents [data-rk]').forEach(el => el.onchange = () => {
const i = +el.closest('[data-res]').dataset.res, r = cfg.residents[i];
if (el.dataset.rk === 'id') r.id = el.value;
else if (el.dataset.rk === 'behavior') r.behavior = el.value;
else {
delete r.spawnId; delete r.pathId;
if (el.value.startsWith('path:')) { r.pathId = el.value.slice(5); r.behavior = 'path'; }
else if (el.value) r.spawnId = el.value;
}
render();
});
// windows
document.getElementById('addWin').onclick = () => { (cfg.timeWindows ||= []).push({ start: '09:00', end: '17:00' }); render(); };
document.querySelectorAll('[data-wdel]').forEach(b => b.onclick = () => { cfg.timeWindows.splice(+b.dataset.wdel, 1); render(); });
document.querySelectorAll('#windows input').forEach(inp => inp.onchange = () => cfg.timeWindows[+inp.dataset.i][inp.dataset.k] = inp.value);
// rarity table
document.querySelectorAll('[data-rw],[data-rm]').forEach(el => el.onchange = () => {
cfg.rarity ||= { weights: {}, movementScale: {} };
if (el.dataset.rw) (cfg.rarity.weights ||= {})[el.dataset.rw] = +el.value;
if (el.dataset.rm) (cfg.rarity.movementScale ||= {})[el.dataset.rm] = +el.value;
});
document.getElementById('save').onclick = async () => {
cfg.crossfadeSeconds = +document.getElementById('fade').value;
cfg.behaviors = {
staticChance: +document.getElementById('staticChance').value,
wanderRadius: +document.getElementById('wanderRadius').value,
wanderSpeed: +document.getElementById('wanderSpeed').value,
wanderVertical: +document.getElementById('wanderVertical').value,
pathSpeed: +document.getElementById('pathSpeed').value,
pathChance: +document.getElementById('pathChance').value,
};
try { cfg = await api('/api/playlist', 'PUT', cfg); render();
document.getElementById('status').textContent = 'Applied ' + new Date().toLocaleTimeString();
} catch (e) { document.getElementById('status').textContent = 'Failed: ' + e.message; }
};
}
render();
</script>
</body></html>