Add color_cycle as standalone source type; UI polish
- color_cycle is now a top-level source type (alongside picture/static/gradient) with a configurable color list and cycle_speed; defaults to full rainbow spectrum - ColorCycleColorStripSource + ColorCycleColorStripStream: smooth 30 fps interpolation between user-defined colors, one full cycle every 20s at speed=1.0 - Removed color_cycle animation sub-type from StaticColorStripStream - Color cycle editor: compact horizontal swatch layout, proper module-scope fix (colorCycleAdd/Remove now exposed on window, DOM-synced before mutations) - Animation enabled + Frame interpolation checkboxes use toggle-switch style - Removed Potential FPS metric from targets and KC targets metric grids Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -599,3 +599,39 @@
|
||||
.gradient-stop-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ── Color Cycle editor ──────────────────────────────────────── */
|
||||
|
||||
#color-cycle-colors-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.color-cycle-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.color-cycle-item input[type="color"] {
|
||||
width: 36px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 1px;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.color-cycle-remove-btn {
|
||||
font-size: 0.6rem;
|
||||
padding: 0;
|
||||
width: 36px;
|
||||
height: 14px;
|
||||
min-width: unset;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ import {
|
||||
// Layer 5: color-strip sources
|
||||
import {
|
||||
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
|
||||
onCSSTypeChange,
|
||||
onCSSTypeChange, colorCycleAddColor, colorCycleRemoveColor,
|
||||
} from './features/color-strips.js';
|
||||
|
||||
// Layer 5: calibration
|
||||
@@ -276,6 +276,8 @@ Object.assign(window, {
|
||||
saveCSSEditor,
|
||||
deleteColorStrip,
|
||||
onCSSTypeChange,
|
||||
colorCycleAddColor,
|
||||
colorCycleRemoveColor,
|
||||
|
||||
// calibration
|
||||
showCalibration,
|
||||
|
||||
@@ -26,8 +26,13 @@ class CSSEditorModal extends Modal {
|
||||
gamma: document.getElementById('css-editor-gamma').value,
|
||||
color: document.getElementById('css-editor-color').value,
|
||||
frame_interpolation: document.getElementById('css-editor-frame-interpolation').checked,
|
||||
led_count: (type === 'static' || type === 'gradient') ? '0' : document.getElementById('css-editor-led-count').value,
|
||||
led_count: (type === 'static' || type === 'gradient' || type === 'color_cycle') ? '0' : document.getElementById('css-editor-led-count').value,
|
||||
gradient_stops: type === 'gradient' ? JSON.stringify(_gradientStops) : '[]',
|
||||
animation_enabled: document.getElementById('css-editor-animation-enabled').checked,
|
||||
animation_type: document.getElementById('css-editor-animation-type').value,
|
||||
animation_speed: document.getElementById('css-editor-animation-speed').value,
|
||||
cycle_speed: document.getElementById('css-editor-cycle-speed').value,
|
||||
cycle_colors: JSON.stringify(_colorCycleColors),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -40,15 +45,114 @@ export function onCSSTypeChange() {
|
||||
const type = document.getElementById('css-editor-type').value;
|
||||
document.getElementById('css-editor-picture-section').style.display = type === 'picture' ? '' : 'none';
|
||||
document.getElementById('css-editor-static-section').style.display = type === 'static' ? '' : 'none';
|
||||
document.getElementById('css-editor-color-cycle-section').style.display = type === 'color_cycle' ? '' : 'none';
|
||||
document.getElementById('css-editor-gradient-section').style.display = type === 'gradient' ? '' : 'none';
|
||||
// LED count is only meaningful for picture sources; static/gradient auto-size from device
|
||||
document.getElementById('css-editor-led-count-group').style.display = (type === 'static' || type === 'gradient') ? 'none' : '';
|
||||
// LED count is only meaningful for picture sources; static/gradient/color_cycle auto-size from device
|
||||
document.getElementById('css-editor-led-count-group').style.display =
|
||||
(type === 'static' || type === 'gradient' || type === 'color_cycle') ? 'none' : '';
|
||||
|
||||
// Animation section — shown for static/gradient only (color_cycle is always animating)
|
||||
const animSection = document.getElementById('css-editor-animation-section');
|
||||
const animTypeSelect = document.getElementById('css-editor-animation-type');
|
||||
if (type === 'static') {
|
||||
animSection.style.display = '';
|
||||
animTypeSelect.innerHTML =
|
||||
`<option value="breathing">${t('color_strip.animation.type.breathing')}</option>`;
|
||||
} else if (type === 'gradient') {
|
||||
animSection.style.display = '';
|
||||
animTypeSelect.innerHTML =
|
||||
`<option value="breathing">${t('color_strip.animation.type.breathing')}</option>` +
|
||||
`<option value="gradient_shift">${t('color_strip.animation.type.gradient_shift')}</option>` +
|
||||
`<option value="wave">${t('color_strip.animation.type.wave')}</option>`;
|
||||
} else {
|
||||
animSection.style.display = 'none';
|
||||
}
|
||||
|
||||
if (type === 'gradient') {
|
||||
requestAnimationFrame(() => gradientRenderAll());
|
||||
}
|
||||
}
|
||||
|
||||
function _getAnimationPayload() {
|
||||
return {
|
||||
enabled: document.getElementById('css-editor-animation-enabled').checked,
|
||||
type: document.getElementById('css-editor-animation-type').value,
|
||||
speed: parseFloat(document.getElementById('css-editor-animation-speed').value),
|
||||
};
|
||||
}
|
||||
|
||||
function _loadAnimationState(anim) {
|
||||
document.getElementById('css-editor-animation-enabled').checked = !!(anim && anim.enabled);
|
||||
const speedEl = document.getElementById('css-editor-animation-speed');
|
||||
speedEl.value = (anim && anim.speed != null) ? anim.speed : 1.0;
|
||||
document.getElementById('css-editor-animation-speed-val').textContent =
|
||||
parseFloat(speedEl.value).toFixed(1);
|
||||
// Set type after onCSSTypeChange() has populated the dropdown
|
||||
if (anim && anim.type) {
|
||||
document.getElementById('css-editor-animation-type').value = anim.type;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Color Cycle helpers ──────────────────────────────────────── */
|
||||
|
||||
const _DEFAULT_CYCLE_COLORS = ['#ff0000', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#ff00ff'];
|
||||
let _colorCycleColors = [..._DEFAULT_CYCLE_COLORS];
|
||||
|
||||
function _syncColorCycleFromDom() {
|
||||
const inputs = document.querySelectorAll('#color-cycle-colors-list input[type=color]');
|
||||
if (inputs.length > 0) {
|
||||
_colorCycleColors = Array.from(inputs).map(el => el.value);
|
||||
}
|
||||
}
|
||||
|
||||
function _colorCycleRenderList() {
|
||||
const list = document.getElementById('color-cycle-colors-list');
|
||||
if (!list) return;
|
||||
const canRemove = _colorCycleColors.length > 2;
|
||||
list.innerHTML = _colorCycleColors.map((hex, i) => `
|
||||
<div class="color-cycle-item">
|
||||
<input type="color" value="${hex}">
|
||||
${canRemove
|
||||
? `<button type="button" class="btn btn-secondary color-cycle-remove-btn"
|
||||
onclick="colorCycleRemoveColor(${i})">✕</button>`
|
||||
: `<div style="height:14px"></div>`}
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
export function colorCycleAddColor() {
|
||||
_syncColorCycleFromDom();
|
||||
_colorCycleColors.push('#ffffff');
|
||||
_colorCycleRenderList();
|
||||
}
|
||||
|
||||
export function colorCycleRemoveColor(i) {
|
||||
_syncColorCycleFromDom();
|
||||
if (_colorCycleColors.length <= 2) return;
|
||||
_colorCycleColors.splice(i, 1);
|
||||
_colorCycleRenderList();
|
||||
}
|
||||
|
||||
function _colorCycleGetColors() {
|
||||
const inputs = document.querySelectorAll('#color-cycle-colors-list input[type=color]');
|
||||
return Array.from(inputs).map(el => hexToRgbArray(el.value));
|
||||
}
|
||||
|
||||
function _loadColorCycleState(css) {
|
||||
const raw = css && css.colors;
|
||||
_colorCycleColors = (raw && raw.length >= 2)
|
||||
? raw.map(c => rgbArrayToHex(c))
|
||||
: [..._DEFAULT_CYCLE_COLORS];
|
||||
_colorCycleRenderList();
|
||||
const speed = (css && css.cycle_speed != null) ? css.cycle_speed : 1.0;
|
||||
const speedEl = document.getElementById('css-editor-cycle-speed');
|
||||
if (speedEl) {
|
||||
speedEl.value = speed;
|
||||
document.getElementById('css-editor-cycle-speed-val').textContent =
|
||||
parseFloat(speed).toFixed(1);
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert an [R, G, B] array to a CSS hex color string like "#rrggbb". */
|
||||
function rgbArrayToHex(rgb) {
|
||||
if (!Array.isArray(rgb) || rgb.length !== 3) return '#ffffff';
|
||||
@@ -66,6 +170,11 @@ function hexToRgbArray(hex) {
|
||||
export function createColorStripCard(source, pictureSourceMap) {
|
||||
const isStatic = source.source_type === 'static';
|
||||
const isGradient = source.source_type === 'gradient';
|
||||
const isColorCycle = source.source_type === 'color_cycle';
|
||||
|
||||
const animBadge = ((isStatic || isGradient) && source.animation && source.animation.enabled)
|
||||
? `<span class="stream-card-prop" title="${t('color_strip.animation')}">✨ ${t('color_strip.animation.type.' + source.animation.type) || source.animation.type}</span>`
|
||||
: '';
|
||||
|
||||
let propsHtml;
|
||||
if (isStatic) {
|
||||
@@ -75,6 +184,17 @@ export function createColorStripCard(source, pictureSourceMap) {
|
||||
<span style="display:inline-block;width:14px;height:14px;background:${hexColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${hexColor.toUpperCase()}
|
||||
</span>
|
||||
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${source.led_count}</span>` : ''}
|
||||
${animBadge}
|
||||
`;
|
||||
} else if (isColorCycle) {
|
||||
const colors = source.colors || [];
|
||||
const swatches = colors.slice(0, 8).map(c =>
|
||||
`<span style="display:inline-block;width:12px;height:12px;background:${rgbArrayToHex(c)};border:1px solid #888;border-radius:2px;margin-right:2px"></span>`
|
||||
).join('');
|
||||
propsHtml = `
|
||||
<span class="stream-card-prop">${swatches}</span>
|
||||
<span class="stream-card-prop" title="${t('color_strip.color_cycle.speed')}">🔄 ${(source.cycle_speed || 1.0).toFixed(1)}×</span>
|
||||
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${source.led_count}</span>` : ''}
|
||||
`;
|
||||
} else if (isGradient) {
|
||||
const stops = source.stops || [];
|
||||
@@ -95,6 +215,7 @@ export function createColorStripCard(source, pictureSourceMap) {
|
||||
propsHtml = `
|
||||
${cssGradient ? `<span style="flex:1 1 100%;height:12px;background:${cssGradient};border-radius:3px;border:1px solid rgba(128,128,128,0.3)"></span>` : ''}
|
||||
<span class="stream-card-prop">🎨 ${stops.length} ${t('color_strip.gradient.stops_count')}</span>
|
||||
${animBadge}
|
||||
`;
|
||||
} else {
|
||||
const srcName = (pictureSourceMap && pictureSourceMap[source.picture_source_id])
|
||||
@@ -110,8 +231,8 @@ export function createColorStripCard(source, pictureSourceMap) {
|
||||
`;
|
||||
}
|
||||
|
||||
const icon = isStatic ? '🎨' : isGradient ? '🌈' : '🎞️';
|
||||
const calibrationBtn = (!isStatic && !isGradient)
|
||||
const icon = isStatic ? '🎨' : isColorCycle ? '🔄' : isGradient ? '🌈' : '🎞️';
|
||||
const calibrationBtn = (!isStatic && !isGradient && !isColorCycle)
|
||||
? `<button class="btn btn-icon btn-secondary" onclick="showCSSCalibration('${source.id}')" title="${t('calibration.title')}">📐</button>`
|
||||
: '';
|
||||
|
||||
@@ -166,11 +287,15 @@ export async function showCSSEditor(cssId = null) {
|
||||
|
||||
if (sourceType === 'static') {
|
||||
document.getElementById('css-editor-color').value = rgbArrayToHex(css.color);
|
||||
_loadAnimationState(css.animation);
|
||||
} else if (sourceType === 'color_cycle') {
|
||||
_loadColorCycleState(css);
|
||||
} else if (sourceType === 'gradient') {
|
||||
gradientInit(css.stops || [
|
||||
{ position: 0.0, color: [255, 0, 0] },
|
||||
{ position: 1.0, color: [0, 0, 255] },
|
||||
]);
|
||||
_loadAnimationState(css.animation);
|
||||
} else {
|
||||
sourceSelect.value = css.picture_source_id || '';
|
||||
|
||||
@@ -220,6 +345,8 @@ export async function showCSSEditor(cssId = null) {
|
||||
document.getElementById('css-editor-frame-interpolation').checked = false;
|
||||
document.getElementById('css-editor-color').value = '#ffffff';
|
||||
document.getElementById('css-editor-led-count').value = 0;
|
||||
_loadAnimationState(null);
|
||||
_loadColorCycleState(null);
|
||||
document.getElementById('css-editor-title').textContent = t('color_strip.add');
|
||||
gradientInit([
|
||||
{ position: 0.0, color: [255, 0, 0] },
|
||||
@@ -259,8 +386,21 @@ export async function saveCSSEditor() {
|
||||
payload = {
|
||||
name,
|
||||
color: hexToRgbArray(document.getElementById('css-editor-color').value),
|
||||
animation: _getAnimationPayload(),
|
||||
};
|
||||
if (!cssId) payload.source_type = 'static';
|
||||
} else if (sourceType === 'color_cycle') {
|
||||
const cycleColors = _colorCycleGetColors();
|
||||
if (cycleColors.length < 2) {
|
||||
cssEditorModal.showError(t('color_strip.color_cycle.min_colors'));
|
||||
return;
|
||||
}
|
||||
payload = {
|
||||
name,
|
||||
colors: cycleColors,
|
||||
cycle_speed: parseFloat(document.getElementById('css-editor-cycle-speed').value),
|
||||
};
|
||||
if (!cssId) payload.source_type = 'color_cycle';
|
||||
} else if (sourceType === 'gradient') {
|
||||
if (_gradientStops.length < 2) {
|
||||
cssEditorModal.showError(t('color_strip.gradient.min_stops'));
|
||||
@@ -273,6 +413,7 @@ export async function saveCSSEditor() {
|
||||
color: s.color,
|
||||
...(s.colorRight ? { color_right: s.colorRight } : {}),
|
||||
})),
|
||||
animation: _getAnimationPayload(),
|
||||
};
|
||||
if (!cssId) payload.source_type = 'gradient';
|
||||
} else {
|
||||
|
||||
@@ -102,10 +102,6 @@ export function createKCTargetCard(target, sourceMap, patternTemplateMap) {
|
||||
<div class="metric-label">${t('device.metrics.target_fps')}</div>
|
||||
<div class="metric-value">${state.fps_target || 0}</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">${t('device.metrics.potential_fps')}</div>
|
||||
<div class="metric-value">${state.fps_potential?.toFixed(0) || '-'}</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">${t('device.metrics.frames')}</div>
|
||||
<div class="metric-value">${metrics.frames_processed || 0}</div>
|
||||
|
||||
@@ -466,10 +466,6 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) {
|
||||
<div class="metric-label">${t('device.metrics.target_fps')}</div>
|
||||
<div class="metric-value">${state.fps_target || 0}</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">${t('device.metrics.potential_fps')}</div>
|
||||
<div class="metric-value">${state.fps_potential?.toFixed(0) || '-'}</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">${t('device.metrics.frames')}</div>
|
||||
<div class="metric-value">${metrics.frames_processed || 0}</div>
|
||||
|
||||
@@ -573,10 +573,11 @@
|
||||
"color_strip.delete.referenced": "Cannot delete: this source is in use by a target",
|
||||
"color_strip.error.name_required": "Please enter a name",
|
||||
"color_strip.type": "Type:",
|
||||
"color_strip.type.hint": "Picture Source derives LED colors from a screen capture. Static Color fills all LEDs with a single constant color. Gradient distributes a color gradient across all LEDs.",
|
||||
"color_strip.type.hint": "Picture Source derives LED colors from a screen capture. Static Color fills all LEDs with a single constant color. Gradient distributes a color gradient across all LEDs. Color Cycle smoothly cycles through a user-defined list of colors.",
|
||||
"color_strip.type.picture": "Picture Source",
|
||||
"color_strip.type.static": "Static Color",
|
||||
"color_strip.type.gradient": "Gradient",
|
||||
"color_strip.type.color_cycle": "Color Cycle",
|
||||
"color_strip.static_color": "Color:",
|
||||
"color_strip.static_color.hint": "The solid color that will be sent to all LEDs on the strip.",
|
||||
"color_strip.gradient.preview": "Gradient:",
|
||||
@@ -587,5 +588,22 @@
|
||||
"color_strip.gradient.add_stop": "+ Add Stop",
|
||||
"color_strip.gradient.position": "Position (0.0–1.0)",
|
||||
"color_strip.gradient.bidir.hint": "Add a second color on the right side of this stop to create a hard edge in the gradient.",
|
||||
"color_strip.gradient.min_stops": "Gradient must have at least 2 stops"
|
||||
"color_strip.gradient.min_stops": "Gradient must have at least 2 stops",
|
||||
"color_strip.animation": "Animation",
|
||||
"color_strip.animation.enabled": "Enable Animation:",
|
||||
"color_strip.animation.enabled.hint": "Enables procedural animation. The LEDs will update at 30 fps driven by the selected effect.",
|
||||
"color_strip.animation.type": "Effect:",
|
||||
"color_strip.animation.type.hint": "The animation effect to apply. Breathing works for both static and gradient sources; Gradient Shift and Wave work for gradient sources only.",
|
||||
"color_strip.animation.type.breathing": "Breathing",
|
||||
"color_strip.animation.type.color_cycle": "Color Cycle",
|
||||
"color_strip.animation.type.gradient_shift": "Gradient Shift",
|
||||
"color_strip.animation.type.wave": "Wave",
|
||||
"color_strip.animation.speed": "Speed:",
|
||||
"color_strip.animation.speed.hint": "Animation speed multiplier. 1.0 ≈ one cycle per second for Breathing; higher values cycle faster.",
|
||||
"color_strip.color_cycle.colors": "Colors:",
|
||||
"color_strip.color_cycle.colors.hint": "List of colors to cycle through smoothly. At least 2 required. Default is a full rainbow spectrum.",
|
||||
"color_strip.color_cycle.add_color": "+ Add Color",
|
||||
"color_strip.color_cycle.speed": "Speed:",
|
||||
"color_strip.color_cycle.speed.hint": "Cycle speed multiplier. 1.0 ≈ one full cycle every 20 seconds; higher values cycle faster.",
|
||||
"color_strip.color_cycle.min_colors": "Color cycle must have at least 2 colors"
|
||||
}
|
||||
|
||||
@@ -573,10 +573,11 @@
|
||||
"color_strip.delete.referenced": "Невозможно удалить: источник используется в цели",
|
||||
"color_strip.error.name_required": "Введите название",
|
||||
"color_strip.type": "Тип:",
|
||||
"color_strip.type.hint": "Источник изображения получает цвета светодиодов из захвата экрана. Статический цвет заполняет все светодиоды одним постоянным цветом. Градиент распределяет цветовой градиент по всем светодиодам.",
|
||||
"color_strip.type.hint": "Источник изображения получает цвета светодиодов из захвата экрана. Статический цвет заполняет все светодиоды одним постоянным цветом. Градиент распределяет цветовой градиент по всем светодиодам. Смена цвета плавно циклически переключается между заданными цветами.",
|
||||
"color_strip.type.picture": "Источник изображения",
|
||||
"color_strip.type.static": "Статический цвет",
|
||||
"color_strip.type.gradient": "Градиент",
|
||||
"color_strip.type.color_cycle": "Смена цвета",
|
||||
"color_strip.static_color": "Цвет:",
|
||||
"color_strip.static_color.hint": "Статический цвет, который будет отправлен на все светодиоды полосы.",
|
||||
"color_strip.gradient.preview": "Градиент:",
|
||||
@@ -587,5 +588,22 @@
|
||||
"color_strip.gradient.add_stop": "+ Добавить",
|
||||
"color_strip.gradient.position": "Позиция (0.0–1.0)",
|
||||
"color_strip.gradient.bidir.hint": "Добавить второй цвет справа от этой остановки для создания резкого перехода в градиенте.",
|
||||
"color_strip.gradient.min_stops": "Градиент должен содержать не менее 2 остановок"
|
||||
"color_strip.gradient.min_stops": "Градиент должен содержать не менее 2 остановок",
|
||||
"color_strip.animation": "Анимация",
|
||||
"color_strip.animation.enabled": "Включить анимацию:",
|
||||
"color_strip.animation.enabled.hint": "Включает процедурную анимацию. Светодиоды обновляются со скоростью 30 кадров в секунду по выбранному эффекту.",
|
||||
"color_strip.animation.type": "Эффект:",
|
||||
"color_strip.animation.type.hint": "Эффект анимации. Дыхание работает для статичного цвета и градиента; сдвиг градиента и волна — только для градиентных источников.",
|
||||
"color_strip.animation.type.breathing": "Дыхание",
|
||||
"color_strip.animation.type.color_cycle": "Смена цвета",
|
||||
"color_strip.animation.type.gradient_shift": "Сдвиг градиента",
|
||||
"color_strip.animation.type.wave": "Волна",
|
||||
"color_strip.animation.speed": "Скорость:",
|
||||
"color_strip.animation.speed.hint": "Множитель скорости анимации. 1.0 ≈ один цикл в секунду для дыхания; большие значения ускоряют анимацию.",
|
||||
"color_strip.color_cycle.colors": "Цвета:",
|
||||
"color_strip.color_cycle.colors.hint": "Список цветов для плавной циклической смены. Минимум 2 цвета. По умолчанию — полный радужный спектр.",
|
||||
"color_strip.color_cycle.add_color": "+ Добавить цвет",
|
||||
"color_strip.color_cycle.speed": "Скорость:",
|
||||
"color_strip.color_cycle.speed.hint": "Множитель скорости смены. 1.0 ≈ один полный цикл за 20 секунд; большие значения ускоряют смену.",
|
||||
"color_strip.color_cycle.min_colors": "Смена цвета должна содержать не менее 2 цветов"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user