Add IconSelect for filter types, audio modes, engine descriptions; fix scroll flash
- Filter type picker: IconSelect with 3-column grid, auto-add on select, removed redundant + button - Audio mode picker: IconSelect with SVG visualizations for RMS/Peak/Beat - Capture engine grid: added per-engine icons and localized descriptions - Fixed scroll flash during icon grid open animation (settled class after transitionend) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -522,6 +522,9 @@ textarea:focus-visible {
|
||||
opacity: 1;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.icon-select-popup.open.settled {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.icon-select-grid {
|
||||
display: grid;
|
||||
|
||||
@@ -459,9 +459,6 @@ body.pp-filter-dragging .pp-filter-drag-handle {
|
||||
}
|
||||
|
||||
.pp-add-filter-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ const POPUP_CLASS = 'icon-select-popup';
|
||||
/** Close every open icon-select popup. */
|
||||
export function closeAllIconSelects() {
|
||||
document.querySelectorAll(`.${POPUP_CLASS}`).forEach(p => {
|
||||
p.classList.remove('open');
|
||||
p.classList.remove('open', 'settled');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -52,13 +52,14 @@ export class IconSelect {
|
||||
* @param {Function} [opts.onChange] - called with (value) after user picks
|
||||
* @param {number} [opts.columns=2] - grid column count
|
||||
*/
|
||||
constructor({ target, items, onChange, columns = 2 }) {
|
||||
constructor({ target, items, onChange, columns = 2, placeholder = '' }) {
|
||||
_ensureGlobalListener();
|
||||
|
||||
this._select = target;
|
||||
this._items = items;
|
||||
this._onChange = onChange;
|
||||
this._columns = columns;
|
||||
this._placeholder = placeholder;
|
||||
|
||||
// Hide the native select
|
||||
this._select.style.display = 'none';
|
||||
@@ -77,6 +78,7 @@ export class IconSelect {
|
||||
this._popup = document.createElement('div');
|
||||
this._popup.className = POPUP_CLASS;
|
||||
this._popup.addEventListener('click', (e) => e.stopPropagation());
|
||||
this._popup.addEventListener('transitionend', this._onTransitionEnd);
|
||||
this._popup.innerHTML = this._buildGrid();
|
||||
this._select.parentNode.insertBefore(this._popup, this._trigger.nextSibling);
|
||||
|
||||
@@ -84,7 +86,7 @@ export class IconSelect {
|
||||
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
|
||||
cell.addEventListener('click', () => {
|
||||
this.setValue(cell.dataset.value, true);
|
||||
this._popup.classList.remove('open');
|
||||
this._popup.classList.remove('open', 'settled');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,6 +114,10 @@ export class IconSelect {
|
||||
`<span class="icon-select-trigger-icon">${item.icon}</span>` +
|
||||
`<span class="icon-select-trigger-label">${item.label}</span>` +
|
||||
`<span class="icon-select-trigger-arrow">▾</span>`;
|
||||
} else if (this._placeholder) {
|
||||
this._trigger.innerHTML =
|
||||
`<span class="icon-select-trigger-label">${this._placeholder}</span>` +
|
||||
`<span class="icon-select-trigger-arrow">▾</span>`;
|
||||
}
|
||||
// Update active state in grid
|
||||
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
|
||||
@@ -127,6 +133,12 @@ export class IconSelect {
|
||||
}
|
||||
}
|
||||
|
||||
_onTransitionEnd = (e) => {
|
||||
if (e.propertyName === 'max-height' && this._popup.classList.contains('open')) {
|
||||
this._popup.classList.add('settled');
|
||||
}
|
||||
};
|
||||
|
||||
/** Change the value programmatically. */
|
||||
setValue(value, fireChange = false) {
|
||||
this._select.value = value;
|
||||
@@ -145,7 +157,7 @@ export class IconSelect {
|
||||
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
|
||||
cell.addEventListener('click', () => {
|
||||
this.setValue(cell.dataset.value, true);
|
||||
this._popup.classList.remove('open');
|
||||
this._popup.classList.remove('open', 'settled');
|
||||
});
|
||||
});
|
||||
this._syncTrigger();
|
||||
|
||||
@@ -34,7 +34,10 @@ const _deviceTypeIcons = {
|
||||
mqtt: _svg(P.send), ws: _svg(P.globe), openrgb: _svg(P.palette),
|
||||
mock: _svg(P.wrench),
|
||||
};
|
||||
const _engineTypeIcons = { scrcpy: _svg(P.smartphone) };
|
||||
const _engineTypeIcons = {
|
||||
mss: _svg(P.monitor), dxcam: _svg(P.zap), bettercam: _svg(P.rocket),
|
||||
camera: _svg(P.camera), scrcpy: _svg(P.smartphone), wgc: _svg(P.film),
|
||||
};
|
||||
const _audioEngineTypeIcons = { wasapi: _svg(P.volume2), sounddevice: _svg(P.mic) };
|
||||
|
||||
// ── Type-resolution getters ─────────────────────────────────
|
||||
|
||||
@@ -303,7 +303,7 @@ async function loadAvailableEngines() {
|
||||
// Update icon-grid selector with dynamic engine list
|
||||
const items = availableEngines
|
||||
.filter(e => e.available)
|
||||
.map(e => ({ value: e.type, icon: getEngineIcon(e.type), label: e.name, desc: '' }));
|
||||
.map(e => ({ value: e.type, icon: getEngineIcon(e.type), label: e.name, desc: t(`templates.engine.${e.type}.desc`) }));
|
||||
if (_engineIconSelect) { _engineIconSelect.updateItems(items); }
|
||||
else { _engineIconSelect = new IconSelect({ target: select, items, columns: 2 }); }
|
||||
_engineIconSelect.setValue(select.value);
|
||||
@@ -2026,9 +2026,7 @@ const _FILTER_ICONS = {
|
||||
function _populateFilterSelect() {
|
||||
const select = document.getElementById('pp-add-filter-select');
|
||||
select.innerHTML = `<option value="">${t('filters.select_type')}</option>`;
|
||||
const items = [
|
||||
{ value: '', icon: `<svg class="icon" viewBox="0 0 24 24">${P.wrench}</svg>`, label: t('filters.select_type') },
|
||||
];
|
||||
const items = [];
|
||||
for (const f of _availableFilters) {
|
||||
const name = _getFilterName(f.filter_id);
|
||||
select.innerHTML += `<option value="${f.filter_id}">${name}</option>`;
|
||||
@@ -2046,7 +2044,8 @@ function _populateFilterSelect() {
|
||||
_filterIconSelect = new IconSelect({
|
||||
target: select,
|
||||
items,
|
||||
columns: 2,
|
||||
columns: 3,
|
||||
placeholder: t('filters.select_type'),
|
||||
onChange: () => addFilterFromSelect(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -100,6 +100,28 @@ function _ensureWaveformIconSelect() {
|
||||
_waveformIconSelect = new IconSelect({ target: sel, items, columns: 4 });
|
||||
}
|
||||
|
||||
/* ── Audio mode icon-grid selector ────────────────────────────── */
|
||||
|
||||
const _AUDIO_MODE_SVG = {
|
||||
rms: '<svg viewBox="0 0 60 24" width="60" height="24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="8" width="12" height="12" rx="2" fill="currentColor" opacity="0.3"/><rect x="24" y="4" width="12" height="16" rx="2" fill="currentColor" opacity="0.5"/><rect x="44" y="6" width="12" height="14" rx="2" fill="currentColor" opacity="0.4"/></svg>',
|
||||
peak: '<svg viewBox="0 0 60 24" width="60" height="24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 20 L10 14 L18 16 L26 4 L34 12 L42 18 L50 10 L58 20"/><circle cx="26" cy="4" r="2" fill="currentColor"/></svg>',
|
||||
beat: '<svg viewBox="0 0 60 24" width="60" height="24" fill="none" stroke="currentColor" stroke-width="2"><path d="M0 12 L12 12 L16 2 L22 22 L28 6 L32 12 L60 12"/></svg>',
|
||||
};
|
||||
|
||||
let _audioModeIconSelect = null;
|
||||
|
||||
function _ensureAudioModeIconSelect() {
|
||||
const sel = document.getElementById('value-source-mode');
|
||||
if (!sel) return;
|
||||
const items = [
|
||||
{ value: 'rms', icon: _AUDIO_MODE_SVG.rms, label: t('value_source.mode.rms'), desc: t('value_source.mode.rms.desc') },
|
||||
{ value: 'peak', icon: _AUDIO_MODE_SVG.peak, label: t('value_source.mode.peak'), desc: t('value_source.mode.peak.desc') },
|
||||
{ value: 'beat', icon: _AUDIO_MODE_SVG.beat, label: t('value_source.mode.beat'), desc: t('value_source.mode.beat.desc') },
|
||||
];
|
||||
if (_audioModeIconSelect) { _audioModeIconSelect.updateItems(items); return; }
|
||||
_audioModeIconSelect = new IconSelect({ target: sel, items, columns: 3 });
|
||||
}
|
||||
|
||||
function _ensureVSTypeIconSelect() {
|
||||
const sel = document.getElementById('value-source-type');
|
||||
if (!sel) return;
|
||||
@@ -139,6 +161,7 @@ export async function showValueSourceModal(editData) {
|
||||
} else if (editData.source_type === 'audio') {
|
||||
_populateAudioSourceDropdown(editData.audio_source_id || '');
|
||||
document.getElementById('value-source-mode').value = editData.mode || 'rms';
|
||||
if (_audioModeIconSelect) _audioModeIconSelect.setValue(editData.mode || 'rms');
|
||||
document.getElementById('value-source-auto-gain').checked = !!editData.auto_gain;
|
||||
_setSlider('value-source-sensitivity', editData.sensitivity ?? 1.0);
|
||||
_setSlider('value-source-smoothing', editData.smoothing ?? 0.3);
|
||||
@@ -168,6 +191,7 @@ export async function showValueSourceModal(editData) {
|
||||
document.getElementById('value-source-waveform').value = 'sine';
|
||||
_populateAudioSourceDropdown('');
|
||||
document.getElementById('value-source-mode').value = 'rms';
|
||||
if (_audioModeIconSelect) _audioModeIconSelect.setValue('rms');
|
||||
document.getElementById('value-source-auto-gain').checked = false;
|
||||
_setSlider('value-source-sensitivity', 1.0);
|
||||
_setSlider('value-source-smoothing', 0.3);
|
||||
@@ -198,6 +222,7 @@ export function onValueSourceTypeChange() {
|
||||
document.getElementById('value-source-animated-section').style.display = type === 'animated' ? '' : 'none';
|
||||
if (type === 'animated') _ensureWaveformIconSelect();
|
||||
document.getElementById('value-source-audio-section').style.display = type === 'audio' ? '' : 'none';
|
||||
if (type === 'audio') _ensureAudioModeIconSelect();
|
||||
document.getElementById('value-source-adaptive-time-section').style.display = type === 'adaptive_time' ? '' : 'none';
|
||||
document.getElementById('value-source-adaptive-scene-section').style.display = type === 'adaptive_scene' ? '' : 'none';
|
||||
document.getElementById('value-source-adaptive-range-section').style.display =
|
||||
|
||||
@@ -63,6 +63,12 @@
|
||||
"templates.engine.select": "Select an engine...",
|
||||
"templates.engine.unavailable": "Unavailable",
|
||||
"templates.engine.unavailable.hint": "This engine is not available on your system",
|
||||
"templates.engine.mss.desc": "Cross-platform, pure Python",
|
||||
"templates.engine.dxcam.desc": "DirectX, low latency",
|
||||
"templates.engine.bettercam.desc": "DirectX, high performance",
|
||||
"templates.engine.camera.desc": "USB/IP camera capture",
|
||||
"templates.engine.scrcpy.desc": "Android screen mirror",
|
||||
"templates.engine.wgc.desc": "Windows Graphics Capture",
|
||||
"templates.config": "Configuration",
|
||||
"templates.config.show": "Show configuration",
|
||||
"templates.config.none": "No additional configuration",
|
||||
@@ -1063,6 +1069,9 @@
|
||||
"value_source.mode.rms": "RMS (Volume)",
|
||||
"value_source.mode.peak": "Peak",
|
||||
"value_source.mode.beat": "Beat",
|
||||
"value_source.mode.rms.desc": "Average volume level",
|
||||
"value_source.mode.peak.desc": "Loudest moment tracking",
|
||||
"value_source.mode.beat.desc": "Rhythm pulse detection",
|
||||
"value_source.auto_gain": "Auto Gain:",
|
||||
"value_source.auto_gain.hint": "Automatically normalize audio levels so output uses the full range, regardless of input volume",
|
||||
"value_source.auto_gain.enable": "Enable auto-gain",
|
||||
|
||||
@@ -63,6 +63,12 @@
|
||||
"templates.engine.select": "Выберите движок...",
|
||||
"templates.engine.unavailable": "Недоступен",
|
||||
"templates.engine.unavailable.hint": "Этот движок недоступен в вашей системе",
|
||||
"templates.engine.mss.desc": "Кроссплатформенный, чистый Python",
|
||||
"templates.engine.dxcam.desc": "DirectX, низкая задержка",
|
||||
"templates.engine.bettercam.desc": "DirectX, высокая производительность",
|
||||
"templates.engine.camera.desc": "Захват USB/IP камеры",
|
||||
"templates.engine.scrcpy.desc": "Зеркалирование экрана Android",
|
||||
"templates.engine.wgc.desc": "Windows Graphics Capture",
|
||||
"templates.config": "Конфигурация",
|
||||
"templates.config.show": "Показать конфигурацию",
|
||||
"templates.config.none": "Нет дополнительных настроек",
|
||||
@@ -1063,6 +1069,9 @@
|
||||
"value_source.mode.rms": "RMS (Громкость)",
|
||||
"value_source.mode.peak": "Пик",
|
||||
"value_source.mode.beat": "Бит",
|
||||
"value_source.mode.rms.desc": "Средний уровень громкости",
|
||||
"value_source.mode.peak.desc": "Отслеживание пиковых моментов",
|
||||
"value_source.mode.beat.desc": "Детекция ритмических ударов",
|
||||
"value_source.auto_gain": "Авто-усиление:",
|
||||
"value_source.auto_gain.hint": "Автоматически нормализует уровни звука, чтобы выходное значение использовало полный диапазон независимо от громкости входного сигнала",
|
||||
"value_source.auto_gain.enable": "Включить авто-усиление",
|
||||
|
||||
@@ -63,6 +63,12 @@
|
||||
"templates.engine.select": "选择引擎...",
|
||||
"templates.engine.unavailable": "不可用",
|
||||
"templates.engine.unavailable.hint": "此引擎在您的系统上不可用",
|
||||
"templates.engine.mss.desc": "跨平台,纯Python",
|
||||
"templates.engine.dxcam.desc": "DirectX,低延迟",
|
||||
"templates.engine.bettercam.desc": "DirectX,高性能",
|
||||
"templates.engine.camera.desc": "USB/IP摄像头捕获",
|
||||
"templates.engine.scrcpy.desc": "Android屏幕镜像",
|
||||
"templates.engine.wgc.desc": "Windows图形捕获",
|
||||
"templates.config": "配置",
|
||||
"templates.config.show": "显示配置",
|
||||
"templates.config.none": "无额外配置",
|
||||
@@ -1063,6 +1069,9 @@
|
||||
"value_source.mode.rms": "RMS(音量)",
|
||||
"value_source.mode.peak": "峰值",
|
||||
"value_source.mode.beat": "节拍",
|
||||
"value_source.mode.rms.desc": "平均音量水平",
|
||||
"value_source.mode.peak.desc": "最响时刻追踪",
|
||||
"value_source.mode.beat.desc": "节奏脉冲检测",
|
||||
"value_source.auto_gain": "自动增益:",
|
||||
"value_source.auto_gain.hint": "自动归一化音频电平,使输出使用完整范围,无论输入音量大小",
|
||||
"value_source.auto_gain.enable": "启用自动增益",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* - Navigation: network-first with offline fallback
|
||||
*/
|
||||
|
||||
const CACHE_NAME = 'ledgrab-v20';
|
||||
const CACHE_NAME = 'ledgrab-v21';
|
||||
|
||||
// Only pre-cache static assets (no auth required).
|
||||
// Do NOT pre-cache '/' — it requires API key auth and would cache an error page.
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
<select id="pp-add-filter-select" class="pp-add-filter-select">
|
||||
<option value="" data-i18n="filters.select_type">Select filter type...</option>
|
||||
</select>
|
||||
<button type="button" class="pp-add-filter-btn" onclick="addFilterFromSelect()" title="Add Filter">+</button>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
|
||||
Reference in New Issue
Block a user