feat: add atmosphere audio filters

This commit is contained in:
Hermes Agent
2026-05-21 09:23:49 +02:00
parent 17bf8d4e3a
commit 94d7a411da
5 changed files with 255 additions and 2 deletions

View File

@@ -34,6 +34,8 @@
drawerStopAll: document.getElementById('drawer-stop-all'),
activeList: document.getElementById('active-list'),
activeEmpty: document.getElementById('active-empty'),
filterGrid: document.getElementById('filter-grid'),
filterStatus: document.getElementById('filter-status'),
};
const state = {
@@ -53,6 +55,8 @@
sets: [],
currentSetId: null,
lastVariantPerGroup: new Map(), // anti-immediate-repeat memory
filters: [],
filter: { id: null, name: null, active: false },
};
state.sets = loadSets();
@@ -239,6 +243,53 @@
</div>`).join('');
}
function renderFilters() {
const activeId = state.filter && state.filter.active ? state.filter.id : null;
els.filterStatus.textContent = activeId ? `Active: ${state.filter.name || activeId}` : 'No filter';
els.filterStatus.classList.toggle('filter-active', Boolean(activeId));
els.filterGrid.innerHTML = state.filters.map((f) => {
const active = f.id === activeId ? ' active' : '';
return `
<article class="filter-card${active}" data-filter-id="${escapeHtml(f.id)}">
<div>
<strong>${escapeHtml(f.name)}</strong>
<span>${escapeHtml(f.description || '')}</span>
</div>
<div class="filter-actions">
<button class="mini${active ? ' ok' : ''}" type="button" data-filter-action="toggle">${active ? 'Clear' : 'Toggle'}</button>
<button class="mini momentary" type="button" data-filter-action="hold" title="Hold down to apply, release to clear">Hold</button>
</div>
</article>`;
}).join('');
}
async function setFilter(id, active = true) {
try {
const res = await fetchJson('/api/filter', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, active }),
});
state.filter = res.filter || (res.stream && res.stream.filter) || state.filter;
renderFilters();
setStatus('ok', state.filter && state.filter.active ? `Filter active — ${state.filter.name}` : 'Filter cleared');
} catch (err) {
console.error(err);
setStatus('err', `Filter failed: ${err.message}`);
}
}
async function loadFilters() {
try {
const res = await fetchJson('/api/filters');
state.filters = Array.isArray(res.filters) ? res.filters : [];
state.filter = res.filter || state.filter;
renderFilters();
} catch (err) {
console.warn('filters load failed', err);
}
}
async function loadConfig() {
try {
const cfg = await fetchJson('/api/config');
@@ -365,6 +416,11 @@
function applyStreamStatus(stream) {
if (!stream) return;
state.active = stream.active || [];
if (stream.filter) {
state.filter = stream.filter;
if (Array.isArray(stream.filters)) state.filters = stream.filters;
renderFilters();
}
renderActive();
}
@@ -482,6 +538,35 @@
els.grid.addEventListener('click', gridClick);
els.setGrid.addEventListener('click', gridClick);
els.filterGrid.addEventListener('click', (e) => {
const btn = e.target.closest('[data-filter-action="toggle"]');
if (!btn) return;
const card = btn.closest('[data-filter-id]');
if (!card) return;
const id = card.dataset.filterId;
const alreadyActive = state.filter && state.filter.active && state.filter.id === id;
setFilter(id, !alreadyActive);
});
els.filterGrid.addEventListener('pointerdown', (e) => {
const btn = e.target.closest('[data-filter-action="hold"]');
if (!btn) return;
e.preventDefault();
const card = btn.closest('[data-filter-id]');
if (!card) return;
btn.setPointerCapture?.(e.pointerId);
btn.classList.add('holding');
setFilter(card.dataset.filterId, true);
});
for (const eventName of ['pointerup', 'pointercancel', 'lostpointercapture']) {
els.filterGrid.addEventListener(eventName, (e) => {
const btn = e.target.closest?.('[data-filter-action="hold"]');
if (!btn || !btn.classList.contains('holding')) return;
btn.classList.remove('holding');
setFilter(null, false);
});
}
els.categories.addEventListener('click', (e) => {
const btn = e.target.closest('.chip'); if (!btn) return;
state.activeCategory = btn.dataset.cat || 'all'; renderCategories(); renderGrid();
@@ -510,6 +595,7 @@
});
loadConfig();
loadFilters();
loadEffects();
renderSetsSelect();
renderActive();

View File

@@ -45,6 +45,17 @@
</div>
</section>
<section class="panel filter-panel">
<div class="panel-head">
<div>
<h2>Atmosphere Filters</h2>
<p class="muted">Change the live device stream: click Toggle to latch, or hold Momentary and release to clear.</p>
</div>
<span class="badge" id="filter-status">No filter</span>
</div>
<div id="filter-grid" class="filter-grid" aria-live="polite"></div>
</section>
<section class="panel">
<div class="panel-head">
<h2 id="set-title">Current Set</h2>

View File

@@ -74,6 +74,15 @@ input[type="search"]:focus { border-color: var(--accent); }
.variant-row { display: flex; flex-wrap: wrap; gap: 4px; padding: 0 10px 6px; }
.variant-btn { padding: 3px 8px; font-size: 11px; border-radius: 6px; min-width: 26px; background: rgba(179,136,255,.08); border: 1px solid rgba(179,136,255,.28); color: var(--ink-dim); }
.variant-btn:hover { background: rgba(179,136,255,.20); color: var(--ink); border-color: var(--accent); }
.filter-panel .panel-head { align-items: flex-start; justify-content: space-between; }
.filter-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px; }
.filter-card { display: flex; justify-content: space-between; gap: 12px; align-items: center; padding: 12px; border: 1px solid var(--panel-border); border-radius: 12px; background: linear-gradient(160deg, rgba(255,255,255,.035), rgba(255,255,255,.01)); transition: border-color .15s ease, background .15s ease, box-shadow .15s ease; }
.filter-card.active { border-color: rgba(110,255,200,.7); background: radial-gradient(260px 110px at 20% 0%, rgba(110,255,200,.14), rgba(255,255,255,.02)); box-shadow: inset 0 0 0 1px rgba(110,255,200,.12); }
.filter-card strong { display: block; font-size: 13px; letter-spacing: .08em; text-transform: uppercase; margin-bottom: 4px; }
.filter-card span { display: block; color: var(--ink-dim); font-size: 12px; line-height: 1.35; }
.filter-actions { display: flex; gap: 6px; flex-shrink: 0; }
.momentary.holding { color: #06140f; background: linear-gradient(135deg, var(--accent-2), #a5ffe1); border-color: rgba(110,255,200,.8); }
.badge.filter-active { color: var(--accent-2); background: rgba(110,255,200,.12); border-color: rgba(110,255,200,.5); }
.active-row.is-loop { border-color: rgba(255,180,84,.45); background: rgba(255,180,84,.06); }
.active-row .loop-glyph { font-weight: 700; }
.empty { margin: 16px 4px 0; color: var(--ink-dim); font-style: italic; }