@@ -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 {