From b27ac8783b88735e6a0668f73b328ac90423342d Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 23 Mar 2026 18:31:20 +0300 Subject: [PATCH] feat: expand appearance with shader effects and new style presets Add 5 WebGL shader background effects (Aurora, Plasma, Digital Rain, Starfield, Warp Tunnel) via a new bg-shaders.ts engine that shares a dedicated canvas. Add 5 style presets (Sakura, Ocean, Copper, Vapor, Monolith) with distinctive font pairings. Remove CSS particles effect in favor of shader-based alternatives. Fix dot grid visibility and tune all shader intensities for subtle ambient appearance. --- .../wled_controller/static/css/appearance.css | 137 +++-- .../src/wled_controller/static/css/base.css | 3 +- server/src/wled_controller/static/js/app.ts | 2 + .../static/js/core/bg-shaders.ts | 491 ++++++++++++++++++ .../static/js/features/appearance.ts | 207 ++++++-- .../wled_controller/static/locales/en.json | 11 +- .../wled_controller/static/locales/ru.json | 11 +- .../wled_controller/static/locales/zh.json | 11 +- .../src/wled_controller/templates/index.html | 1 + 9 files changed, 795 insertions(+), 79 deletions(-) create mode 100644 server/src/wled_controller/static/js/core/bg-shaders.ts diff --git a/server/src/wled_controller/static/css/appearance.css b/server/src/wled_controller/static/css/appearance.css index aba68e7..ddb16e2 100644 --- a/server/src/wled_controller/static/css/appearance.css +++ b/server/src/wled_controller/static/css/appearance.css @@ -143,6 +143,94 @@ h1 { to { opacity: 1; } } +/* ── Aurora preview: horizontal gradient bands ── */ +[data-effect="aurora"] .ap-bg-preview-inner { + background: + linear-gradient(180deg, + transparent 0%, + color-mix(in srgb, var(--primary-color) 18%, transparent) 25%, + transparent 50%, + color-mix(in srgb, var(--primary-color) 12%, transparent) 70%, + transparent 100%); + animation: ap-aurora-sway 6s ease-in-out infinite alternate; +} + +@keyframes ap-aurora-sway { + from { transform: translateX(-5%) scaleY(1); opacity: 0.8; } + to { transform: translateX(5%) scaleY(1.15); opacity: 1; } +} + +/* ── Plasma preview: color blobs ── */ +[data-effect="plasma"] .ap-bg-preview-inner { + background: + radial-gradient(circle at 30% 40%, + color-mix(in srgb, var(--primary-color) 20%, transparent) 0%, transparent 50%), + radial-gradient(circle at 70% 60%, + color-mix(in srgb, var(--primary-color) 15%, transparent) 0%, transparent 50%), + radial-gradient(circle at 50% 20%, + color-mix(in srgb, var(--primary-color) 12%, transparent) 0%, transparent 40%); + animation: ap-plasma-cycle 5s ease-in-out infinite alternate; +} + +@keyframes ap-plasma-cycle { + from { transform: scale(1) rotate(0deg); } + to { transform: scale(1.1) rotate(3deg); } +} + +/* ── Digital Rain preview: vertical lines ── */ +[data-effect="rain"] .ap-bg-preview-inner { + background: + linear-gradient(180deg, var(--primary-color) 0%, transparent 60%) 10% 0 / 1px 70% no-repeat, + linear-gradient(180deg, var(--primary-color) 0%, transparent 50%) 25% 20% / 1px 50% no-repeat, + linear-gradient(180deg, var(--primary-color) 0%, transparent 60%) 40% 10% / 1px 60% no-repeat, + linear-gradient(180deg, var(--primary-color) 0%, transparent 40%) 55% 30% / 1px 40% no-repeat, + linear-gradient(180deg, var(--primary-color) 0%, transparent 55%) 70% 5% / 1px 55% no-repeat, + linear-gradient(180deg, var(--primary-color) 0%, transparent 50%) 85% 15% / 1px 50% no-repeat; + opacity: 0.4; + animation: ap-rain-fall 3s linear infinite; +} + +@keyframes ap-rain-fall { + from { transform: translateY(-20%); } + to { transform: translateY(20%); } +} + +/* ── Stars preview: scattered dots ── */ +[data-effect="stars"] .ap-bg-preview-inner { + background: + radial-gradient(circle 1px at 15% 20%, #fff 0%, transparent 100%), + radial-gradient(circle 1.5px at 40% 60%, var(--primary-color) 0%, transparent 100%), + radial-gradient(circle 1px at 65% 30%, #fff 0%, transparent 100%), + radial-gradient(circle 0.8px at 80% 70%, #fff 0%, transparent 100%), + radial-gradient(circle 1.2px at 30% 85%, var(--primary-color) 0%, transparent 100%), + radial-gradient(circle 0.7px at 55% 15%, #fff 0%, transparent 100%), + radial-gradient(circle 1px at 90% 45%, #fff 0%, transparent 100%), + radial-gradient(circle 0.9px at 10% 55%, #fff 0%, transparent 100%); + opacity: 0.7; + animation: ap-stars-twinkle 3s ease-in-out infinite alternate; +} + +@keyframes ap-stars-twinkle { + from { opacity: 0.5; } + to { opacity: 0.8; } +} + +/* ── Warp preview: radial tunnel ── */ +[data-effect="warp"] .ap-bg-preview-inner { + background: radial-gradient(circle at 50% 50%, + color-mix(in srgb, var(--primary-color) 20%, transparent) 0%, + transparent 30%, + color-mix(in srgb, var(--primary-color) 10%, transparent) 50%, + transparent 70%, + color-mix(in srgb, var(--primary-color) 6%, transparent) 90%); + animation: ap-warp-pulse 4s ease-in-out infinite; +} + +@keyframes ap-warp-pulse { + 0%, 100% { transform: scale(1); opacity: 0.7; } + 50% { transform: scale(1.2); opacity: 1; } +} + [data-effect="grid"] .ap-bg-preview-inner { background-image: radial-gradient(circle, var(--text-muted) 0.5px, transparent 0.5px); @@ -168,21 +256,12 @@ h1 { ); } -[data-effect="particles"] .ap-bg-preview-inner { - background: - radial-gradient(circle 2px at 20% 40%, var(--primary-color) 0%, transparent 100%), - radial-gradient(circle 1.5px at 60% 25%, var(--primary-color) 0%, transparent 100%), - radial-gradient(circle 2px at 75% 65%, var(--primary-color) 0%, transparent 100%), - radial-gradient(circle 1px at 40% 80%, var(--primary-color) 0%, transparent 100%), - radial-gradient(circle 1.5px at 90% 35%, var(--primary-color) 0%, transparent 100%); - opacity: 0.6; -} - /* ═══ Full-page background effects ═══ Uses a dedicated
(same pattern as the WebGL canvas). - The active effect class (e.g. .bg-effect-grid) is set directly on the div. */ + The active effect class (e.g. .bg-effect-grid) is set directly on the div. + Shader effects use instead. */ -/* When a CSS bg effect is active, make body transparent so the layer shows through +/* When a CSS/shader bg effect is active, make body transparent so the layer shows (mirrors [data-bg-anim="on"] body { background: transparent } in base.css) */ [data-bg-effect] body { background: transparent; @@ -202,7 +281,7 @@ h1 { background: color-mix(in srgb, var(--bg-color) 60%, transparent); } -/* Card translucency for CSS bg effects (match existing bg-anim behaviour) */ +/* Card translucency for bg effects (match existing bg-anim behaviour) */ [data-bg-effect][data-theme="dark"] .card, [data-bg-effect][data-theme="dark"] .template-card, [data-bg-effect][data-theme="dark"] .add-device-card, @@ -227,17 +306,16 @@ h1 { #bg-effect-layer.bg-effect-grid, #bg-effect-layer.bg-effect-mesh, -#bg-effect-layer.bg-effect-scanlines, -#bg-effect-layer.bg-effect-particles { +#bg-effect-layer.bg-effect-scanlines { display: block; } /* ── Grid: dot matrix ── */ #bg-effect-layer.bg-effect-grid { background-image: - radial-gradient(circle 1px, var(--text-secondary) 0%, transparent 100%); - background-size: 28px 28px; - opacity: 0.35; + radial-gradient(circle 1.5px, var(--text-color) 0%, transparent 100%); + background-size: 24px 24px; + opacity: 0.18; } /* ── Gradient mesh: ambient blobs ── */ @@ -269,29 +347,6 @@ h1 { ); } -/* ── CSS Particles: glowing dots ── */ -#bg-effect-layer.bg-effect-particles { - background: - radial-gradient(circle 40px at 10% 20%, color-mix(in srgb, var(--primary-color) 30%, transparent) 0%, transparent 100%), - radial-gradient(circle 25px at 30% 70%, color-mix(in srgb, var(--primary-color) 25%, transparent) 0%, transparent 100%), - radial-gradient(circle 50px at 55% 15%, color-mix(in srgb, var(--primary-color) 20%, transparent) 0%, transparent 100%), - radial-gradient(circle 30px at 70% 55%, color-mix(in srgb, var(--primary-color) 28%, transparent) 0%, transparent 100%), - radial-gradient(circle 35px at 85% 35%, color-mix(in srgb, var(--primary-color) 22%, transparent) 0%, transparent 100%), - radial-gradient(circle 20px at 45% 45%, color-mix(in srgb, var(--primary-color) 32%, transparent) 0%, transparent 100%), - radial-gradient(circle 45px at 20% 85%, color-mix(in srgb, var(--primary-color) 18%, transparent) 0%, transparent 100%), - radial-gradient(circle 28px at 92% 78%, color-mix(in srgb, var(--primary-color) 25%, transparent) 0%, transparent 100%), - radial-gradient(circle 15px at 65% 90%, color-mix(in srgb, var(--primary-color) 35%, transparent) 0%, transparent 100%), - radial-gradient(circle 35px at 5% 50%, color-mix(in srgb, var(--primary-color) 20%, transparent) 0%, transparent 100%); - animation: bg-particles-float 30s ease-in-out infinite alternate; -} - -@keyframes bg-particles-float { - 0% { transform: translate(0, 0); } - 33% { transform: translate(15px, -20px); } - 66% { transform: translate(-10px, 10px); } - 100% { transform: translate(5px, -5px); } -} - /* ─── Mobile: 2-column grid ─── */ @media (max-width: 480px) { .ap-grid { diff --git a/server/src/wled_controller/static/css/base.css b/server/src/wled_controller/static/css/base.css index b3116ca..6bda796 100644 --- a/server/src/wled_controller/static/css/base.css +++ b/server/src/wled_controller/static/css/base.css @@ -132,7 +132,8 @@ html.modal-open { } /* ── Ambient animated background ── */ -#bg-anim-canvas { +#bg-anim-canvas, +#bg-effect-canvas { display: none; position: fixed; inset: 0; diff --git a/server/src/wled_controller/static/js/app.ts b/server/src/wled_controller/static/js/app.ts index dd5467d..4c38795 100644 --- a/server/src/wled_controller/static/js/app.ts +++ b/server/src/wled_controller/static/js/app.ts @@ -14,6 +14,7 @@ import { t, initLocale, changeLocale } from './core/i18n.ts'; // Layer 1.5: visual effects import { initCardGlare } from './core/card-glare.ts'; import { initBgAnim, updateBgAnimAccent, updateBgAnimTheme } from './core/bg-anim.ts'; +import { initBgShaders } from './core/bg-shaders.ts'; import { initTabIndicator, updateTabIndicator } from './core/tab-indicator.ts'; // Layer 2: ui @@ -634,6 +635,7 @@ document.addEventListener('DOMContentLoaded', async () => { // Initialize visual effects initCardGlare(); initBgAnim(); + initBgShaders(); initAppearance(); initTabIndicator(); updateBgAnimTheme(document.documentElement.getAttribute('data-theme') !== 'light'); diff --git a/server/src/wled_controller/static/js/core/bg-shaders.ts b/server/src/wled_controller/static/js/core/bg-shaders.ts new file mode 100644 index 0000000..df1b82f --- /dev/null +++ b/server/src/wled_controller/static/js/core/bg-shaders.ts @@ -0,0 +1,491 @@ +/** + * Shader-based background effects engine. + * + * Renders various full-screen WebGL shader effects on a shared canvas. + * Each effect is a fragment shader that receives common uniforms + * (time, resolution, accent color, theme brightness). + * + * The engine reuses the same element + * and rebuilds the GL program when switching shaders. + */ + +// ─── Shader library ────────────────────────────────────────── + +const VERT = ` +attribute vec2 a_pos; +void main() { gl_Position = vec4(a_pos, 0.0, 1.0); } +`; + +/** Aurora — flowing northern-lights bands */ +const FRAG_AURORA = ` +precision mediump float; +uniform float u_time; +uniform vec2 u_res; +uniform vec3 u_accent; +uniform vec3 u_bg; +uniform float u_light; + +vec3 mod289(vec3 x){return x-floor(x*(1./289.))*289.;} +vec2 mod289(vec2 x){return x-floor(x*(1./289.))*289.;} +vec3 permute(vec3 x){return mod289(((x*34.)+1.)*x);} + +float snoise(vec2 v){ + const vec4 C=vec4(.211324865,.366025404,-.577350269,.024390244); + vec2 i=floor(v+dot(v,C.yy)); + vec2 x0=v-i+dot(i,C.xx); + vec2 i1=(x0.x>x0.y)?vec2(1,0):vec2(0,1); + vec4 x12=x0.xyxy+C.xxzz; x12.xy-=i1; + i=mod289(i); + vec3 p=permute(permute(i.y+vec3(0,i1.y,1))+i.x+vec3(0,i1.x,1)); + vec3 m=max(.5-vec3(dot(x0,x0),dot(x12.xy,x12.xy),dot(x12.zw,x12.zw)),0.); + m=m*m; m=m*m; + vec3 x=2.*fract(p*C.www)-1.; + vec3 h=abs(x)-.5; + vec3 ox=floor(x+.5); + vec3 a0=x-ox; + m*=1.79284291-.85373472*(a0*a0+h*h); + vec3 g; + g.x=a0.x*x0.x+h.x*x0.y; + g.yz=a0.yz*x12.xz+h.yz*x12.yw; + return 130.*dot(m,g); +} + +void main(){ + vec2 uv=gl_FragCoord.xy/u_res; + float t=u_time*.06; + + // Vertical bands that sway horizontally + float wave1=sin(uv.x*3.+t*1.2+snoise(vec2(uv.x*2.,t*.3))*.8)*.5+.5; + float wave2=sin(uv.x*5.-t*.9+snoise(vec2(uv.x*1.5,t*.2+2.))*.6)*.5+.5; + float wave3=sin(uv.x*2.+t*.7+snoise(vec2(uv.x*3.,t*.25+5.))*.7)*.5+.5; + + // Fade toward the top of the screen + float yFade=smoothstep(0.,.7,uv.y)*smoothstep(1.,.4,uv.y); + + // Combine bands + float band1=smoothstep(.35,.65,wave1)*yFade; + float band2=smoothstep(.4,.7,wave2)*yFade*.7; + float band3=smoothstep(.3,.6,wave3)*yFade*.5; + + vec3 col1=u_accent; + vec3 col2=u_accent.gbr; + vec3 col3=mix(u_accent,vec3(1),0.3); + + float intensity=band1*0.12+band2*0.08+band3*0.06; + vec3 color=band1*col1+band2*col2+band3*col3; + color=color/(band1+band2+band3+.001); + + float boost=1.+u_light*.4; + vec3 result=mix(u_bg,u_bg+color*.6,intensity*boost); + + gl_FragColor=vec4(result,1.); +} +`; + +/** Plasma — classic color-cycling plasma */ +const FRAG_PLASMA = ` +precision mediump float; +uniform float u_time; +uniform vec2 u_res; +uniform vec3 u_accent; +uniform vec3 u_bg; +uniform float u_light; + +void main(){ + vec2 uv=gl_FragCoord.xy/u_res; + float aspect=u_res.x/u_res.y; + vec2 p=vec2(uv.x*aspect,uv.y); + float t=u_time*.15; + + float v1=sin(p.x*4.+t); + float v2=sin(p.y*4.+t*.7); + float v3=sin((p.x+p.y)*3.+t*1.1); + float v4=sin(length(p-vec2(aspect*.5,.5))*6.-t*.8); + + float v=(v1+v2+v3+v4)*.25; // -1..1 + + vec3 col1=u_accent; + vec3 col2=u_accent.brg; + vec3 col3=u_accent.gbr; + + float m1=sin(v*3.14159)*.5+.5; + float m2=sin(v*3.14159+2.094)*.5+.5; + vec3 color=col1*(1.-m1)+col2*m1; + color=color*(1.-m2*.4)+col3*m2*.4; + + float boost=1.+u_light*.3; + float intensity=smoothstep(-.2,.8,v)*.22*boost; + vec3 result=mix(u_bg,u_bg+color*.7,intensity); + + gl_FragColor=vec4(result,1.); +} +`; + +/** Digital Rain — matrix-style falling columns */ +const FRAG_RAIN = ` +precision mediump float; +uniform float u_time; +uniform vec2 u_res; +uniform vec3 u_accent; +uniform vec3 u_bg; +uniform float u_light; + +float hash(vec2 p){ + return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453); +} + +void main(){ + vec2 uv=gl_FragCoord.xy/u_res; + float aspect=u_res.x/u_res.y; + float t=u_time*.12; + + // Many thin columns + float cols=80.*aspect; + float col_x=floor(uv.x*cols); + + // Multiple drops per column at staggered phases + float totalGlow=0.; + for(int d=0;d<3;d++){ + float seed=float(d)*37.; + float speed=.3+hash(vec2(col_x,seed))*1.0; + float phase=hash(vec2(col_x,seed+1.))*6.28; + float drop_y=1.-fract(t*speed+phase); + + // Trail: fades downward from the drop head + float dist=drop_y-uv.y; + if(dist<0.) dist+=1.; + float trailLen=.15+hash(vec2(col_x,seed+2.))*.25; + float trail=smoothstep(trailLen,0.,dist); + trail*=trail; + + // Cell flicker within the trail + float rows=80.; + float row_y=floor(uv.y*rows); + float flicker=step(.35,hash(vec2(col_x+seed,row_y+floor(t*4.)))); + totalGlow+=trail*flicker*.12; + + // Bright head + float headDist=abs(uv.y-drop_y); + if(headDist>.5) headDist=1.-headDist; + totalGlow+=smoothstep(.006,0.,headDist)*.15; + } + + totalGlow=min(totalGlow,.35); + float boost=1.+u_light*.25; + vec3 result=mix(u_bg,u_bg+u_accent,totalGlow*boost); + + gl_FragColor=vec4(result,1.); +} +`; + +/** Starfield — parallax depth star field */ +const FRAG_STARS = ` +precision mediump float; +uniform float u_time; +uniform vec2 u_res; +uniform vec3 u_accent; +uniform vec3 u_bg; +uniform float u_light; + +float hash(vec2 p){ + return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453); +} + +void main(){ + vec2 uv=(gl_FragCoord.xy-.5*u_res)/u_res.y; + float t=u_time*.02; + + vec3 result=u_bg; + + // 4 layers of stars at different depths + for(int layer=0;layer<4;layer++){ + float fl=float(layer); + float depth=1.+fl*.6; + float speed=.05*depth; + + vec2 st=uv*20.*depth; + st.y+=t*speed*15.; + + vec2 cell=floor(st); + vec2 f=fract(st); + + // Check this cell and its 8 neighbours for stars + for(int dy=-1;dy<=1;dy++){ + for(int dx=-1;dx<=1;dx++){ + vec2 neighbor=vec2(float(dx),float(dy)); + vec2 c=cell+neighbor; + float h=hash(c+fl*137.); + + // Only ~40% of cells have a star + if(h>.6) continue; + + vec2 starPos=vec2(hash(c*1.17+fl*53.),hash(c*2.31+fl*79.)); + float d=length(f-neighbor-starPos); + + float starSize=.04+h*.06; + float brightness=smoothstep(starSize,starSize*.1,d); + + // Soft glow halo + float glow=smoothstep(starSize*4.,0.,d)*.3; + + // Twinkle + float twinkle=sin(t*8.+h*43.)*.25+.75; + float total=(brightness+glow)*twinkle; + + // Color: white or accent-tinted + vec3 starColor=mix(vec3(1.),u_accent,step(.3,h)); + float boost=1.+u_light*.2; + result+=starColor*total*.12*boost/depth; + } + } + } + + gl_FragColor=vec4(result,1.); +} +`; + +/** Warp — smooth tunnel/vortex speed effect */ +const FRAG_WARP = ` +precision mediump float; +uniform float u_time; +uniform vec2 u_res; +uniform vec3 u_accent; +uniform vec3 u_bg; +uniform float u_light; + +void main(){ + vec2 uv=(gl_FragCoord.xy-.5*u_res)/u_res.y; + float t=u_time*.1; + + float r=length(uv); + float a=atan(uv.y,uv.x); + + // Logarithmic tunnel mapping for smooth depth + float tunnel=log(r+.001)*-2.; + + // Twist increases with depth (further from center = more twist) + float twist=a/3.14159+tunnel*.15+t; + + // Overlapping band patterns at different scales + float band1=sin(tunnel*4.-t*2.)*.5+.5; + float band2=sin(tunnel*7.+t*1.5)*.5+.5; + float rays=sin(twist*6.)*.5+.5; + + float pattern=mix(band1,band2,.5)*rays; + + // Smooth radial fade — no harsh edges + float fade=smoothstep(0.,.03,r)*smoothstep(.9,.15,r); + + // Gentle center glow + float centerGlow=smoothstep(.3,0.,r)*.08; + + vec3 col1=u_accent; + vec3 col2=u_accent.gbr; + vec3 color=mix(col1,col2,rays); + + float intensity=pattern*fade*.15+centerGlow; + float boost=1.+u_light*.3; + vec3 result=mix(u_bg,u_bg+color*.6,intensity*boost); + + gl_FragColor=vec4(result,1.); +} +`; + +// ─── Shader registry ───────────────────────────────────────── + +export type ShaderEffectId = 'aurora' | 'plasma' | 'rain' | 'stars' | 'warp'; + +const SHADER_MAP: Record = { + aurora: FRAG_AURORA, + plasma: FRAG_PLASMA, + rain: FRAG_RAIN, + stars: FRAG_STARS, + warp: FRAG_WARP, +}; + +// ─── Engine state ──────────────────────────────────────────── + +let _canvas: HTMLCanvasElement | null = null; +let _gl: WebGLRenderingContext | null = null; +let _prog: WebGLProgram | null = null; +let _raf: number | null = null; +let _startTime = 0; +let _activeShader: ShaderEffectId | null = null; + +// Uniforms +let _uTime: WebGLUniformLocation | null = null; +let _uRes: WebGLUniformLocation | null = null; +let _uAccent: WebGLUniformLocation | null = null; +let _uBg: WebGLUniformLocation | null = null; +let _uLight: WebGLUniformLocation | null = null; + +let _accent = [76 / 255, 175 / 255, 80 / 255]; +let _bgColor = [26 / 255, 26 / 255, 26 / 255]; +let _isLight = 0.0; + +// ─── GL helpers ────────────────────────────────────────────── + +function _compile(gl: WebGLRenderingContext, type: number, src: string): WebGLShader | null { + const s = gl.createShader(type); + if (!s) return null; + gl.shaderSource(s, src); + gl.compileShader(s); + if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) { + console.error('bg-shaders compile:', gl.getShaderInfoLog(s)); + return null; + } + return s; +} + +function _buildProgram(gl: WebGLRenderingContext, fragSrc: string): WebGLProgram | null { + const vs = _compile(gl, gl.VERTEX_SHADER, VERT); + const fs = _compile(gl, gl.FRAGMENT_SHADER, fragSrc); + if (!vs || !fs) return null; + + const prog = gl.createProgram()!; + gl.attachShader(prog, vs); + gl.attachShader(prog, fs); + gl.linkProgram(prog); + if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { + console.error('bg-shaders link:', gl.getProgramInfoLog(prog)); + return null; + } + + // Clean up individual shaders + gl.deleteShader(vs); + gl.deleteShader(fs); + + return prog; +} + +function _ensureCanvas(): boolean { + if (_canvas) return true; + _canvas = document.getElementById('bg-effect-canvas') as HTMLCanvasElement | null; + return !!_canvas; +} + +function _ensureGL(): boolean { + if (_gl) return true; + if (!_ensureCanvas()) return false; + _gl = _canvas!.getContext('webgl', { alpha: false, antialias: false, depth: false }); + if (!_gl) return false; + + // Full-screen quad (shared across all shaders) + const buf = _gl.createBuffer(); + _gl.bindBuffer(_gl.ARRAY_BUFFER, buf); + _gl.bufferData(_gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), _gl.STATIC_DRAW); + return true; +} + +function _switchProgram(shaderId: ShaderEffectId): boolean { + if (!_ensureGL()) return false; + const gl = _gl!; + + // Delete old program + if (_prog) { + gl.deleteProgram(_prog); + _prog = null; + } + + const fragSrc = SHADER_MAP[shaderId]; + if (!fragSrc) return false; + + _prog = _buildProgram(gl, fragSrc); + if (!_prog) return false; + + gl.useProgram(_prog); + + // Re-bind vertex attrib + const aPos = gl.getAttribLocation(_prog, 'a_pos'); + gl.enableVertexAttribArray(aPos); + gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0); + + // Cache uniform locations + _uTime = gl.getUniformLocation(_prog, 'u_time'); + _uRes = gl.getUniformLocation(_prog, 'u_res'); + _uAccent = gl.getUniformLocation(_prog, 'u_accent'); + _uBg = gl.getUniformLocation(_prog, 'u_bg'); + _uLight = gl.getUniformLocation(_prog, 'u_light'); + + return true; +} + +function _resize(): void { + if (!_canvas || !_gl) return; + const w = Math.round(window.innerWidth * 0.5); + const h = Math.round(window.innerHeight * 0.5); + _canvas.width = w; + _canvas.height = h; + _gl.viewport(0, 0, w, h); +} + +function _draw(time: number): void { + _raf = requestAnimationFrame(_draw); + if (!_gl || !_prog) return; + + const t = (time - _startTime) * 0.001; + _gl.uniform1f(_uTime, t); + _gl.uniform2f(_uRes, _canvas!.width, _canvas!.height); + _gl.uniform3f(_uAccent, _accent[0], _accent[1], _accent[2]); + _gl.uniform3f(_uBg, _bgColor[0], _bgColor[1], _bgColor[2]); + _gl.uniform1f(_uLight, _isLight); + + _gl.drawArrays(_gl.TRIANGLE_STRIP, 0, 4); +} + +// ─── Public API ────────────────────────────────────────────── + +/** Start a shader effect. Stops any currently running shader. */ +export function startShaderEffect(id: ShaderEffectId): void { + stopShaderEffect(); + + if (!_switchProgram(id)) return; + + _activeShader = id; + _resize(); + _startTime = performance.now(); + _raf = requestAnimationFrame(_draw); + + // Show canvas + if (_canvas) _canvas.style.display = 'block'; +} + +/** Stop the currently running shader effect. */ +export function stopShaderEffect(): void { + if (_raf) { + cancelAnimationFrame(_raf); + _raf = null; + } + _activeShader = null; + if (_canvas) _canvas.style.display = 'none'; +} + +/** Update accent color (called when user changes accent). */ +export function updateShaderAccent(hex: string): void { + _accent = [ + parseInt(hex.slice(1, 3), 16) / 255, + parseInt(hex.slice(3, 5), 16) / 255, + parseInt(hex.slice(5, 7), 16) / 255, + ]; +} + +/** Update theme brightness (called on theme toggle). */ +export function updateShaderTheme(isDark: boolean): void { + _bgColor = isDark ? [26 / 255, 26 / 255, 26 / 255] : [245 / 255, 245 / 255, 245 / 255]; + _isLight = isDark ? 0.0 : 1.0; +} + +/** Get the currently active shader ID (or null). */ +export function getActiveShader(): ShaderEffectId | null { + return _activeShader; +} + +/** Check if a given ID is a valid shader effect. */ +export function isShaderEffect(id: string): id is ShaderEffectId { + return id in SHADER_MAP; +} + +/** Initialize resize listener. */ +export function initBgShaders(): void { + window.addEventListener('resize', () => { if (_raf) _resize(); }); +} \ No newline at end of file diff --git a/server/src/wled_controller/static/js/features/appearance.ts b/server/src/wled_controller/static/js/features/appearance.ts index 35e1b61..d77cf4c 100644 --- a/server/src/wled_controller/static/js/features/appearance.ts +++ b/server/src/wled_controller/static/js/features/appearance.ts @@ -2,11 +2,16 @@ * Appearance — style presets (font + colors) and background effect presets. * * Persists choices to localStorage. Style presets override CSS variables; - * background effects toggle CSS classes on . + * background effects toggle CSS classes on or start WebGL shaders. */ import { t } from '../core/i18n.ts'; import { showToast } from '../core/ui.ts'; +import { + startShaderEffect, stopShaderEffect, isShaderEffect, + updateShaderAccent, updateShaderTheme, + type ShaderEffectId, +} from '../core/bg-shaders.ts'; // ─── Types ────────────────────────────────────────────────── @@ -42,10 +47,12 @@ interface ThemeVars { interface BgEffectPreset { readonly id: string; readonly nameKey: string; - /** CSS class added to . '' = no effect. */ + /** CSS class added to bg-effect-layer. '' = none (shader or webgl-only). */ readonly cssClass: string; - /** For the WebGL noise field, we reuse the existing data-bg-anim attr */ + /** Reuse the existing WebGL noise-field (data-bg-anim). */ readonly useBgAnim: boolean; + /** Shader effect ID (from bg-shaders.ts). '' = not a shader. */ + readonly shaderId: string; } // ─── Style preset definitions ─────────────────────────────── @@ -90,10 +97,10 @@ const STYLE_PRESETS: readonly StylePreset[] = [ { id: 'ember', nameKey: 'appearance.preset.ember', - fontBody: "'Space Grotesk', 'DM Sans', -apple-system, sans-serif", + fontBody: "'Nunito Sans', 'DM Sans', -apple-system, sans-serif", fontHeading: "'Orbitron', sans-serif", accent: '#FF6D00', - fontUrl: 'https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600;700&display=swap', + fontUrl: 'https://fonts.googleapis.com/css2?family=Nunito+Sans:wght@400;600;700&display=swap', dark: { bgColor: '#1a1410', bgSecondary: '#241c14', cardBg: '#2c221a', textColor: '#e8ddd0', textSecondary: '#a89880', textMuted: '#7a6e5e', @@ -108,10 +115,10 @@ const STYLE_PRESETS: readonly StylePreset[] = [ { id: 'arctic', nameKey: 'appearance.preset.arctic', - fontBody: "'Inter', 'DM Sans', -apple-system, sans-serif", + fontBody: "'Outfit', 'DM Sans', -apple-system, sans-serif", fontHeading: "'Orbitron', sans-serif", accent: '#0091EA', - fontUrl: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap', + fontUrl: 'https://fonts.googleapis.com/css2?family=Outfit:wght@400;600;700&display=swap', dark: { bgColor: '#0e1820', bgSecondary: '#142028', cardBg: '#1a2830', textColor: '#d0e4f0', textSecondary: '#88a8c0', textMuted: '#607888', @@ -159,17 +166,111 @@ const STYLE_PRESETS: readonly StylePreset[] = [ borderColor: '#e0c8d8', inputBg: '#f5eaf2', }, }, + { + id: 'sakura', + nameKey: 'appearance.preset.sakura', + fontBody: "'Libre Baskerville', 'Georgia', serif", + fontHeading: "'Playfair Display', 'Georgia', serif", + accent: '#E8607C', + fontUrl: 'https://fonts.googleapis.com/css2?family=Libre+Baskerville:wght@400;700&family=Playfair+Display:wght@700&display=swap', + dark: { + bgColor: '#1a1216', bgSecondary: '#241820', cardBg: '#2c1e26', + textColor: '#f0dce4', textSecondary: '#b89aa8', textMuted: '#8a6e7c', + borderColor: '#3e2c36', inputBg: '#1e1418', + }, + light: { + bgColor: '#fdf5f7', bgSecondary: '#f8e8ee', cardBg: '#ffffff', + textColor: '#3a1e28', textSecondary: '#6e4a58', textMuted: '#a07888', + borderColor: '#ecd0da', inputBg: '#faf0f4', + }, + }, + { + id: 'ocean', + nameKey: 'appearance.preset.ocean', + fontBody: "'Plus Jakarta Sans', 'DM Sans', -apple-system, sans-serif", + fontHeading: "'Orbitron', sans-serif", + accent: '#0288D1', + fontUrl: 'https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;600;700&display=swap', + dark: { + bgColor: '#0a1628', bgSecondary: '#0e1e34', cardBg: '#142640', + textColor: '#c8ddf0', textSecondary: '#7498b8', textMuted: '#506e88', + borderColor: '#1e3858', inputBg: '#0c1a2e', + }, + light: { + bgColor: '#eef5fa', bgSecondary: '#deeaf4', cardBg: '#ffffff', + textColor: '#0e2840', textSecondary: '#386080', textMuted: '#6890a8', + borderColor: '#c0d8ea', inputBg: '#e4eff6', + }, + }, + { + id: 'copper', + nameKey: 'appearance.preset.copper', + fontBody: "'Barlow', 'DM Sans', -apple-system, sans-serif", + fontHeading: "'Barlow Condensed', 'Barlow', sans-serif", + accent: '#D4764E', + fontUrl: 'https://fonts.googleapis.com/css2?family=Barlow:wght@400;600;700&family=Barlow+Condensed:wght@700&display=swap', + dark: { + bgColor: '#18120e', bgSecondary: '#221a14', cardBg: '#2a201a', + textColor: '#e4d4c4', textSecondary: '#a88e78', textMuted: '#7a6454', + borderColor: '#3a2e24', inputBg: '#1c1610', + }, + light: { + bgColor: '#faf6f2', bgSecondary: '#f0e6dc', cardBg: '#ffffff', + textColor: '#362618', textSecondary: '#6e5440', textMuted: '#9a8268', + borderColor: '#dcd0c2', inputBg: '#f6efe6', + }, + }, + { + id: 'vapor', + nameKey: 'appearance.preset.vapor', + fontBody: "'Quicksand', 'DM Sans', -apple-system, sans-serif", + fontHeading: "'Righteous', 'Orbitron', sans-serif", + accent: '#E040FB', + fontUrl: 'https://fonts.googleapis.com/css2?family=Quicksand:wght@400;600;700&family=Righteous&display=swap', + dark: { + bgColor: '#0e0818', bgSecondary: '#160e24', cardBg: '#1c1430', + textColor: '#e8d0f8', textSecondary: '#a078c0', textMuted: '#705898', + borderColor: '#2c1e48', inputBg: '#120a1e', + }, + light: { + bgColor: '#faf2fe', bgSecondary: '#f0e4f8', cardBg: '#ffffff', + textColor: '#281438', textSecondary: '#604078', textMuted: '#9870b0', + borderColor: '#dcc8ee', inputBg: '#f4eaf8', + }, + }, + { + id: 'monolith', + nameKey: 'appearance.preset.monolith', + fontBody: "'Instrument Sans', 'DM Sans', -apple-system, sans-serif", + fontHeading: "'Instrument Sans', sans-serif", + accent: '#FFFFFF', + fontUrl: 'https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;600;700&display=swap', + dark: { + bgColor: '#000000', bgSecondary: '#0a0a0a', cardBg: '#111111', + textColor: '#e8e8e8', textSecondary: '#888888', textMuted: '#555555', + borderColor: '#222222', inputBg: '#080808', + }, + light: { + bgColor: '#ffffff', bgSecondary: '#f2f2f2', cardBg: '#fafafa', + textColor: '#111111', textSecondary: '#555555', textMuted: '#888888', + borderColor: '#dddddd', inputBg: '#f5f5f5', + }, + }, ]; // ─── Background effect definitions ────────────────────────── const BG_EFFECT_PRESETS: readonly BgEffectPreset[] = [ - { id: 'none', nameKey: 'appearance.bg.none', cssClass: '', useBgAnim: false }, - { id: 'noise', nameKey: 'appearance.bg.noise', cssClass: '', useBgAnim: true }, - { id: 'grid', nameKey: 'appearance.bg.grid', cssClass: 'bg-effect-grid', useBgAnim: false }, - { id: 'mesh', nameKey: 'appearance.bg.mesh', cssClass: 'bg-effect-mesh', useBgAnim: false }, - { id: 'scanlines', nameKey: 'appearance.bg.scanlines', cssClass: 'bg-effect-scanlines', useBgAnim: false }, - { id: 'particles', nameKey: 'appearance.bg.particles', cssClass: 'bg-effect-particles', useBgAnim: false }, + { id: 'none', nameKey: 'appearance.bg.none', cssClass: '', useBgAnim: false, shaderId: '' }, + { id: 'noise', nameKey: 'appearance.bg.noise', cssClass: '', useBgAnim: true, shaderId: '' }, + { id: 'aurora', nameKey: 'appearance.bg.aurora', cssClass: '', useBgAnim: false, shaderId: 'aurora' }, + { id: 'plasma', nameKey: 'appearance.bg.plasma', cssClass: '', useBgAnim: false, shaderId: 'plasma' }, + { id: 'rain', nameKey: 'appearance.bg.rain', cssClass: '', useBgAnim: false, shaderId: 'rain' }, + { id: 'stars', nameKey: 'appearance.bg.stars', cssClass: '', useBgAnim: false, shaderId: 'stars' }, + { id: 'warp', nameKey: 'appearance.bg.warp', cssClass: '', useBgAnim: false, shaderId: 'warp' }, + { id: 'grid', nameKey: 'appearance.bg.grid', cssClass: 'bg-effect-grid', useBgAnim: false, shaderId: '' }, + { id: 'mesh', nameKey: 'appearance.bg.mesh', cssClass: 'bg-effect-mesh', useBgAnim: false, shaderId: '' }, + { id: 'scanlines', nameKey: 'appearance.bg.scanlines', cssClass: 'bg-effect-scanlines', useBgAnim: false, shaderId: '' }, ]; // ─── Persistence keys ─────────────────────────────────────── @@ -218,6 +319,9 @@ export function applyStylePreset(id: string): void { localStorage.setItem('accentColor', preset.accent); } + // Update shader accent if a shader effect is running + updateShaderAccent(preset.accent); + // Apply theme-specific color overrides _applyThemeVars(preset); @@ -237,30 +341,47 @@ export function applyBgEffect(id: string): void { const html = document.documentElement; - // Remove all CSS effect classes from the dedicated layer element + // ── 1. Stop all previous effects ── + + // Stop shader effects + stopShaderEffect(); + + // Remove CSS effect classes from the dedicated layer const layer = document.getElementById('bg-effect-layer'); if (layer) { BG_EFFECT_PRESETS.forEach(e => { if (e.cssClass) layer.classList.remove(e.cssClass); }); - if (effect.cssClass) layer.classList.add(effect.cssClass); } - // Set data-bg-effect on so CSS can make body transparent - if (effect.cssClass) { + // Turn off WebGL noise-field + html.setAttribute('data-bg-anim', 'off'); + localStorage.setItem('bgAnim', 'off'); + + // Clear data-bg-effect (CSS transparency trigger) + html.removeAttribute('data-bg-effect'); + + // ── 2. Activate the selected effect ── + + const hasVisualEffect = effect.useBgAnim || !!effect.cssClass || !!effect.shaderId; + + if (effect.useBgAnim) { + // Original WebGL noise field + html.setAttribute('data-bg-anim', 'on'); + localStorage.setItem('bgAnim', 'on'); + } else if (effect.shaderId && isShaderEffect(effect.shaderId)) { + // New shader-based effect — needs body transparency via data-bg-effect + html.setAttribute('data-bg-effect', effect.id); + startShaderEffect(effect.shaderId as ShaderEffectId); + } else if (effect.cssClass && layer) { + // CSS-based effect + layer.classList.add(effect.cssClass); html.setAttribute('data-bg-effect', effect.id); - } else { - html.removeAttribute('data-bg-effect'); } - // Toggle WebGL bg-anim (only for the "noise" effect) - html.setAttribute('data-bg-anim', effect.useBgAnim ? 'on' : 'off'); - localStorage.setItem('bgAnim', effect.useBgAnim ? 'on' : 'off'); - - // Update header toggle button (lit when any effect is active) - const hasEffect = effect.useBgAnim || !!effect.cssClass; + // Update header toggle button opacity const bgAnimBtn = document.getElementById('bg-anim-btn'); - if (bgAnimBtn) bgAnimBtn.style.opacity = hasEffect ? '1' : '0.5'; + if (bgAnimBtn) bgAnimBtn.style.opacity = hasVisualEffect ? '1' : '0.5'; _updatePresetSelection('bg', id); @@ -279,20 +400,35 @@ export function initAppearance(): void { document.documentElement.style.setProperty('--font-body', preset.fontBody); document.documentElement.style.setProperty('--font-heading', preset.fontHeading); _applyThemeVars(preset); + + // Sync accent to shader engine + updateShaderAccent(preset.accent); } - // Apply background effect silently on the dedicated layer element + // Sync theme to shader engine + const isDark = (document.documentElement.getAttribute('data-theme') || 'dark') === 'dark'; + updateShaderTheme(isDark); + + // Apply background effect silently const effect = BG_EFFECT_PRESETS.find(e => e.id === _activeBgEffectId); - if (effect) { - const layer = document.getElementById('bg-effect-layer'); - if (layer && effect.cssClass) layer.classList.add(effect.cssClass); - if (effect.cssClass) { - document.documentElement.setAttribute('data-bg-effect', effect.id); - } + if (effect && effect.id !== 'none') { + const html = document.documentElement; + if (effect.useBgAnim) { - document.documentElement.setAttribute('data-bg-anim', 'on'); + html.setAttribute('data-bg-anim', 'on'); localStorage.setItem('bgAnim', 'on'); + } else if (effect.shaderId && isShaderEffect(effect.shaderId)) { + html.setAttribute('data-bg-effect', effect.id); + startShaderEffect(effect.shaderId as ShaderEffectId); + } else if (effect.cssClass) { + const layer = document.getElementById('bg-effect-layer'); + if (layer) layer.classList.add(effect.cssClass); + html.setAttribute('data-bg-effect', effect.id); } + + // Update header button + const bgAnimBtn = document.getElementById('bg-anim-btn'); + if (bgAnimBtn) bgAnimBtn.style.opacity = '1'; } } @@ -416,6 +552,9 @@ const _themeObserver = new MutationObserver((mutations) => { if (preset && preset.id !== 'default') { _applyThemeVars(preset); } + // Sync shader theme + const isDark = document.documentElement.getAttribute('data-theme') !== 'light'; + updateShaderTheme(isDark); break; } } diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 8dd55b2..36a7ec7 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -1749,15 +1749,24 @@ "appearance.preset.arctic": "Arctic", "appearance.preset.terminal": "Terminal", "appearance.preset.neon": "Neon", + "appearance.preset.sakura": "Sakura", + "appearance.preset.ocean": "Ocean", + "appearance.preset.copper": "Copper", + "appearance.preset.vapor": "Vapor", + "appearance.preset.monolith": "Monolith", "appearance.preset.applied": "Style preset applied", "appearance.bg.label": "Background Effects", "appearance.bg.hint": "Add an ambient background layer behind the interface.", "appearance.bg.none": "None", "appearance.bg.noise": "Noise Field", + "appearance.bg.aurora": "Aurora", + "appearance.bg.plasma": "Plasma", + "appearance.bg.rain": "Digital Rain", + "appearance.bg.stars": "Starfield", + "appearance.bg.warp": "Warp Tunnel", "appearance.bg.grid": "Dot Grid", "appearance.bg.mesh": "Gradient Mesh", "appearance.bg.scanlines": "Scanlines", - "appearance.bg.particles": "Particles", "appearance.bg.applied": "Background effect applied", "color_strip": { diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index c8dcb4f..412f9bc 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -1751,15 +1751,24 @@ "appearance.preset.arctic": "Арктика", "appearance.preset.terminal": "Терминал", "appearance.preset.neon": "Неон", + "appearance.preset.sakura": "Сакура", + "appearance.preset.ocean": "Океан", + "appearance.preset.copper": "Медь", + "appearance.preset.vapor": "Вейпорвейв", + "appearance.preset.monolith": "Монолит", "appearance.preset.applied": "Стиль применён", "appearance.bg.label": "Фоновые эффекты", "appearance.bg.hint": "Добавьте фоновый слой за интерфейсом.", "appearance.bg.none": "Нет", "appearance.bg.noise": "Шумовое поле", + "appearance.bg.aurora": "Северное сияние", + "appearance.bg.plasma": "Плазма", + "appearance.bg.rain": "Цифровой дождь", + "appearance.bg.stars": "Звёздное поле", + "appearance.bg.warp": "Тоннель", "appearance.bg.grid": "Точечная сетка", "appearance.bg.mesh": "Градиент", "appearance.bg.scanlines": "Развёртка", - "appearance.bg.particles": "Частицы", "appearance.bg.applied": "Фоновый эффект применён", "color_strip": { diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index e2ea578..9c7ae03 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -1749,15 +1749,24 @@ "appearance.preset.arctic": "极地", "appearance.preset.terminal": "终端", "appearance.preset.neon": "霓虹", + "appearance.preset.sakura": "樱花", + "appearance.preset.ocean": "海洋", + "appearance.preset.copper": "铜色", + "appearance.preset.vapor": "蒸汽波", + "appearance.preset.monolith": "黑白", "appearance.preset.applied": "样式已应用", "appearance.bg.label": "背景效果", "appearance.bg.hint": "在界面后面添加环境背景层。", "appearance.bg.none": "无", "appearance.bg.noise": "噪声场", + "appearance.bg.aurora": "极光", + "appearance.bg.plasma": "等离子", + "appearance.bg.rain": "数字雨", + "appearance.bg.stars": "星空", + "appearance.bg.warp": "隧道", "appearance.bg.grid": "点阵", "appearance.bg.mesh": "渐变网格", "appearance.bg.scanlines": "扫描线", - "appearance.bg.particles": "粒子", "appearance.bg.applied": "背景效果已应用", "color_strip": { diff --git a/server/src/wled_controller/templates/index.html b/server/src/wled_controller/templates/index.html index 56a1ac2..f0969f1 100644 --- a/server/src/wled_controller/templates/index.html +++ b/server/src/wled_controller/templates/index.html @@ -20,6 +20,7 @@
+