feat: chuva mode — moon, rain, lightning; clean transitions with balada

Rain mode with canvas rain, stormy sky, sun-to-moon transition and
lightning strobe. Mode switching fixed so only one of moon/globe shows:
file pollers are edge-triggered (stale /tmp files no longer fight),
toggle scripts keep the files mutually exclusive, exits fade fast while
entrances stay slow, drawGlobe solely owns globe opacity, and leaving
chuva restores the album-color theme on fog/horizon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015HtPHsTnDcLVSCiF5FGHZj
This commit is contained in:
vini 2026-07-19 10:18:34 -03:00
parent 36220dc34f
commit 39bc5d0794
6 changed files with 320 additions and 23 deletions

View file

@ -8,5 +8,11 @@
<entry name="ShowSkeleton" type="Bool"> <entry name="ShowSkeleton" type="Bool">
<default>true</default> <default>true</default>
</entry> </entry>
<entry name="BaladaMode" type="Bool">
<default>false</default>
</entry>
<entry name="ChuvaMode" type="Bool">
<default>false</default>
</entry>
</group> </group>
</kcfg> </kcfg>

View file

@ -7,6 +7,8 @@ ColumnLayout {
id: root id: root
property alias cfg_ShowSkeleton: skeletonCheck.checked property alias cfg_ShowSkeleton: skeletonCheck.checked
property alias cfg_BaladaMode: baladaCheck.checked
property alias cfg_ChuvaMode: chuvaCheck.checked
spacing: Kirigami.Units.largeSpacing spacing: Kirigami.Units.largeSpacing
@ -18,5 +20,19 @@ ColumnLayout {
Kirigami.FormData.label: i18n("Esqueleto:") Kirigami.FormData.label: i18n("Esqueleto:")
text: i18n("Mostrar o robô dançando") text: i18n("Mostrar o robô dançando")
} }
CheckBox {
id: baladaCheck
Kirigami.FormData.label: i18n("Modo balada:")
text: i18n("Mirror ball + disco floor + refletores")
onCheckedChanged: if (checked) chuvaCheck.checked = false
}
CheckBox {
id: chuvaCheck
Kirigami.FormData.label: i18n("Modo chuva:")
text: i18n("Lua + chuva + relâmpagos")
onCheckedChanged: if (checked) baladaCheck.checked = false
}
} }
} }

View file

@ -789,7 +789,9 @@ body.balada .music-ticker {
bottom 2.8s cubic-bezier(0.16, 1, 0.3, 1), bottom 2.8s cubic-bezier(0.16, 1, 0.3, 1),
transform 2.8s cubic-bezier(0.16, 1, 0.3, 1), transform 2.8s cubic-bezier(0.16, 1, 0.3, 1),
background 7s ease, background 7s ease,
box-shadow 7s ease; box-shadow 7s ease,
width 3s ease,
height 3s ease;
/* listras geradas por JS — ver buildSunStripes() */ /* listras geradas por JS — ver buildSunStripes() */
background: background:
linear-gradient(rgba(0,0,0,0.35), rgba(0,0,0,0.28)), linear-gradient(rgba(0,0,0,0.35), rgba(0,0,0,0.28)),
@ -1003,6 +1005,112 @@ body.balada .spotlight-wrap { opacity: 1; }
.nebula-b::after { background: radial-gradient(ellipse, rgba(180,0,255,0.18), transparent 70%); } .nebula-b::after { background: radial-gradient(ellipse, rgba(180,0,255,0.18), transparent 70%); }
.nebula-c::after { background: radial-gradient(ellipse, rgba(255,140,0,0.14), transparent 70%); } .nebula-c::after { background: radial-gradient(ellipse, rgba(255,140,0,0.14), transparent 70%); }
/* ============================================================
CHUVA — CANVAS DE CHUVA
============================================================ */
#rainCanvas {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 45;
mix-blend-mode: screen;
}
/* ============================================================
CHUVA — MODO CHUVA
============================================================ */
/* Fundo: céu tempestuoso escuro */
body.chuva { background: #04080f; }
/* Estrelas somem atrás das nuvens */
#starfield { transition: opacity 3s ease; }
body.chuva #starfield { opacity: 0.1; }
/* Meteoros somem — a chuva substitui */
#meteorLayer { transition: opacity 2s ease; }
body.chuva #meteorLayer { opacity: 0; }
/* Sol vira lua: sobe, encolhe, fica prateado */
body.chuva .synthwave-sun {
bottom: 70%;
transform: translateX(-50%) translateY(0);
width: 160px;
height: 160px;
box-shadow:
0 0 22px rgba(180,205,255,0.35),
0 0 70px rgba(150,180,255,0.12),
0 0 160px rgba(120,155,255,0.05);
}
/* Halo lunar — suave e frio */
body.chuva .synthwave-sun::after {
inset: -45%;
background: radial-gradient(ellipse at 50% 50%,
rgba(190,210,255,0.12) 0%,
rgba(160,185,255,0.07) 40%,
transparent 65%
);
animation: none;
filter: blur(18px);
}
/* Listras do sol somem, elementos de balada somem */
body.chuva #sunStripes { opacity: 0; }
body.chuva #sunBaladaBg { opacity: 0; transition: opacity 1.5s ease; }
body.chuva .sun-rays-wrap { opacity: 0; }
body.chuva .spotlight-wrap { opacity: 0; }
body.chuva #discoDots { opacity: 0; }
/* Nuvens de tempestade via nebulae */
body.chuva .nebula::after { opacity: 1; }
body.chuva .nebula-a::after { background: radial-gradient(ellipse, rgba(35,45,75,0.55), transparent 70%); }
body.chuva .nebula-b::after { background: radial-gradient(ellipse, rgba(25,35,65,0.48), transparent 70%); }
body.chuva .nebula-c::after { background: radial-gradient(ellipse, rgba(45,55,85,0.42), transparent 70%); }
/* Horizonte frio e tempestuoso */
body.chuva .horizon {
background: linear-gradient(
to right,
transparent 0%,
rgba(70,120,200,0.5) 20%,
rgba(120,165,245,0.75) 50%,
rgba(70,120,200,0.5) 80%,
transparent 100%
) !important;
box-shadow:
0 0 18px rgba(100,150,235,0.55),
0 0 65px rgba(80,125,210,0.18) !important;
}
/* Face lunar */
#sunChuvaFace {
position: absolute;
inset: 0;
border-radius: 50%;
pointer-events: none;
opacity: 0;
transition: opacity 1.2s ease; /* saída rápida — entrada lenta definida em body.chuva */
background: radial-gradient(circle at 38% 32%,
#eef2fa 0%,
#c0cad8 42%,
#7888a8 78%,
#506080 100%
);
}
#sunChuvaFace::after {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
background: radial-gradient(circle at 66% 27%,
rgba(18,28,58,0.72) 22%,
rgba(22,38,72,0.38) 52%,
transparent 70%
);
}
body.chuva #sunChuvaFace { opacity: 1; transition: opacity 5s ease; }
</style> </style>
</head> </head>
<body> <body>
@ -1021,6 +1129,7 @@ body.balada .spotlight-wrap { opacity: 1; }
<div class="synthwave-sun"> <div class="synthwave-sun">
<div id="sunBaladaBg"></div> <div id="sunBaladaBg"></div>
<div id="sunStripes"></div> <div id="sunStripes"></div>
<div id="sunChuvaFace"></div>
<div class="sun-rays-wrap" id="sunRaysWrap"></div> <div class="sun-rays-wrap" id="sunRaysWrap"></div>
</div> </div>
@ -1078,6 +1187,9 @@ body.balada .spotlight-wrap { opacity: 1; }
<!-- BALADA: partículas de luz --> <!-- BALADA: partículas de luz -->
<div id="discoDots" style="position:absolute;inset:0;pointer-events:none;z-index:48;"></div> <div id="discoDots" style="position:absolute;inset:0;pointer-events:none;z-index:48;"></div>
<!-- CHUVA: canvas de chuva -->
<canvas id="rainCanvas"></canvas>
<!-- Scanlines overlay --> <!-- Scanlines overlay -->
<div class="scanlines"></div> <div class="scanlines"></div>
@ -1398,24 +1510,28 @@ requestAnimationFrame(flushBars);
/* ============================================================ /* ============================================================
MUSIC-LIGHT THEME (album color) MUSIC-LIGHT THEME (album color)
============================================================ */ ============================================================ */
let lastThemeRgb = null;
function applyTheme(rgb) { function applyTheme(rgb) {
lastThemeRgb = rgb;
const [r, g, b] = rgb; const [r, g, b] = rgb;
document.documentElement.style.setProperty('--theme-color', `rgba(${r},${g},${b},1)`); document.documentElement.style.setProperty('--theme-color', `rgba(${r},${g},${b},1)`);
horizonEl.style.background = `linear-gradient(to right, if (!chuva) {
transparent 0%, horizonEl.style.background = `linear-gradient(to right,
rgba(${r},${g},${b},0.7) 20%, transparent 0%,
rgba(${r},${g},${b},1) 50%, rgba(${r},${g},${b},0.7) 20%,
rgba(${r},${g},${b},0.7) 80%, rgba(${r},${g},${b},1) 50%,
transparent 100%)`; rgba(${r},${g},${b},0.7) 80%,
horizonEl.style.boxShadow = transparent 100%)`;
`0 0 30px rgba(${r},${g},${b},0.9), 0 0 90px rgba(${r},${g},${b},0.3)`; horizonEl.style.boxShadow =
`0 0 30px rgba(${r},${g},${b},0.9), 0 0 90px rgba(${r},${g},${b},0.3)`;
fogWisps.forEach(w => {
w.style.background = `radial-gradient(ellipse, rgba(${r},${g},${b},0.75) 0%, transparent 70%)`;
});
}
vuBars.forEach(bar => { vuBars.forEach(bar => {
bar.style.background = `rgb(${r},${g},${b})`; bar.style.background = `rgb(${r},${g},${b})`;
bar.style.boxShadow = `0 0 4px rgba(${r},${g},${b},0.8)`; bar.style.boxShadow = `0 0 4px rgba(${r},${g},${b},0.8)`;
}); });
fogWisps.forEach(w => {
w.style.background = `radial-gradient(ellipse, rgba(${r},${g},${b},0.75) 0%, transparent 70%)`;
});
} }
const albumArtEl = document.getElementById('albumArt'); const albumArtEl = document.getElementById('albumArt');
@ -1704,7 +1820,7 @@ const MIRROR_LIGHTS = [
function drawGlobe(ts) { function drawGlobe(ts) {
requestAnimationFrame(drawGlobe); requestAnimationFrame(drawGlobe);
if (baladaAmt < 0.01) { lastGlobeTs = 0; return; } if (baladaAmt < 0.01) { lastGlobeTs = 0; return; }
globeCanvas.style.opacity = baladaAmt; globeCanvas.style.opacity = chuva ? 0 : baladaAmt;
const dt = lastGlobeTs ? Math.min((ts - lastGlobeTs) / 1000, 0.05) : 0; const dt = lastGlobeTs ? Math.min((ts - lastGlobeTs) / 1000, 0.05) : 0;
lastGlobeTs = ts; lastGlobeTs = ts;
globeRot = (globeRot + dt * 0.5) % (Math.PI * 2); globeRot = (globeRot + dt * 0.5) % (Math.PI * 2);
@ -1873,21 +1989,24 @@ setInterval(() => { if (balada) globeBeat(); }, BEAT_MS);
function setBaladaMode(on) { function setBaladaMode(on) {
if (on === balada) return; if (on === balada) return;
balada = on; balada = on;
if (on) setChuvaMode(false);
document.body.classList.toggle('balada', balada); document.body.classList.toggle('balada', balada);
globeCanvas.style.opacity = on ? '1' : '0'; /* opacidade do globo é controlada por drawGlobe via baladaAmt */
} }
function toggleBalada() { setBaladaMode(!balada); } function toggleBalada() { setBaladaMode(!balada); }
document.addEventListener('keydown', e => { /* Polling de arquivos — só reage a MUDANÇAS no arquivo; um arquivo parado
if ((e.key === 'b' || e.key === 'B') && !e.target.matches('input,textarea')) em "1" não fica religando o modo e brigando com o outro toggle */
toggleBalada(); let baladaFileVal = null;
});
/* Polling de arquivos — toggle externo */
(function pollBaladaFile() { (function pollBaladaFile() {
fetch('file:///tmp/skeledance-balada?t=' + Date.now()) fetch('file:///tmp/skeledance-balada?t=' + Date.now())
.then(r => r.text()) .then(r => r.text())
.then(t => setBaladaMode(t.trim() === '1')) .then(t => {
const v = t.trim();
if (v === baladaFileVal) return;
baladaFileVal = v;
setBaladaMode(v === '1');
})
.catch(() => {}) .catch(() => {})
.finally(() => setTimeout(pollBaladaFile, 800)); .finally(() => setTimeout(pollBaladaFile, 800));
})(); })();
@ -1896,10 +2015,150 @@ document.addEventListener('keydown', e => {
fetch('file:///tmp/skeledance-skeleton?t=' + Date.now()) fetch('file:///tmp/skeledance-skeleton?t=' + Date.now())
.then(r => r.text()) .then(r => r.text())
.then(t => setShowSkeleton(t.trim() !== '0')) .then(t => setShowSkeleton(t.trim() !== '0'))
.catch(() => {}) /* arquivo ausente = skeleton visível */ .catch(() => {})
.finally(() => setTimeout(pollSkeletonFile, 800)); .finally(() => setTimeout(pollSkeletonFile, 800));
})(); })();
document.addEventListener('keydown', e => {
if (!e.target.matches('input,textarea')) {
if (e.key === 'b' || e.key === 'B') toggleBalada();
if (e.key === 'r' || e.key === 'R') toggleChuva();
}
});
/* ============================================================
CHUVA MODE
============================================================ */
let chuva = false;
let chuvaAmt = 0;
const rainCanvas = document.getElementById('rainCanvas');
const rctx = rainCanvas.getContext('2d');
const RAIN_COUNT = 280;
const rainDrops = [];
const RAIN_ANGLE = Math.PI / 13; // ~14° da vertical
const RAIN_DX = Math.sin(RAIN_ANGLE);
const RAIN_DY = Math.cos(RAIN_ANGLE);
function resizeRain() {
rainCanvas.width = window.innerWidth;
rainCanvas.height = window.innerHeight;
}
window.addEventListener('resize', resizeRain);
function initRain() {
const W = rainCanvas.width || window.innerWidth;
const H = rainCanvas.height || window.innerHeight;
rainDrops.length = 0;
for (let i = 0; i < RAIN_COUNT; i++) {
rainDrops.push({
x: -60 + Math.random() * (W + 120),
y: Math.random() * H,
len: 11 + Math.random() * 18,
speed: 440 + Math.random() * 360,
alpha: 0.3 + Math.random() * 0.45,
w: 0.8 + Math.random() * 0.8,
});
}
}
let lastRainTs = 0;
function drawRain(ts) {
requestAnimationFrame(drawRain);
const dt = lastRainTs ? Math.min((ts - lastRainTs) / 1000, 0.05) : 0;
lastRainTs = ts;
chuvaAmt += ((chuva ? 1 : 0) - chuvaAmt) * Math.min(1, dt * 0.5);
const W = rainCanvas.width, H = rainCanvas.height;
rctx.clearRect(0, 0, W, H);
if (chuvaAmt < 0.005) return;
rainDrops.forEach(d => {
d.x += d.speed * dt * RAIN_DX;
d.y += d.speed * dt * RAIN_DY;
if (d.y > H + d.len || d.x > W + d.len) {
d.y = -d.len - Math.random() * 80;
d.x = -60 + Math.random() * (W + 120);
}
rctx.beginPath();
rctx.moveTo(d.x, d.y);
rctx.lineTo(d.x + d.len * RAIN_DX, d.y + d.len * RAIN_DY);
rctx.strokeStyle = `rgba(160,195,235,${(d.alpha * chuvaAmt).toFixed(3)})`;
rctx.lineWidth = d.w;
rctx.stroke();
});
}
/* ── Relâmpago ── */
function triggerLightning() {
if (!chuva) return;
const flashes = 1 + Math.floor(Math.random() * 3);
let delay = 0;
for (let i = 0; i < flashes; i++) {
const intensity = (0.5 + Math.random() * 0.32).toFixed(2);
setTimeout(() => {
strobeEl.style.transition = 'none';
strobeEl.style.background = 'rgba(210,228,255,1)';
strobeEl.style.opacity = intensity;
setTimeout(() => {
strobeEl.style.transition = 'opacity 0.2s ease';
strobeEl.style.opacity = '0';
}, 50 + Math.random() * 30 | 0);
}, delay);
delay += 100 + (Math.random() * 110 | 0);
}
}
(function scheduleLightning() {
if (chuva) triggerLightning();
setTimeout(scheduleLightning, 10000 + Math.random() * 22000 | 0);
})();
/* ── Toggle ── */
function setChuvaMode(on) {
if (on === chuva) return;
chuva = on;
if (on) setBaladaMode(false);
document.body.classList.toggle('chuva', chuva);
if (on) {
fogWisps.forEach(w => {
w.style.background =
'radial-gradient(ellipse, rgba(70,100,160,0.5) 0%, transparent 70%)';
});
} else if (lastThemeRgb) {
applyTheme(lastThemeRgb); // devolve a cor do álbum ao fog/horizonte
}
}
function toggleChuva() { setChuvaMode(!chuva); }
let chuvaFileVal = null;
(function pollChuvaFile() {
fetch('file:///tmp/skeledance-chuva?t=' + Date.now())
.then(r => r.text())
.then(t => {
const v = t.trim();
if (v === chuvaFileVal) return;
chuvaFileVal = v;
setChuvaMode(v === '1');
})
.catch(() => {})
.finally(() => setTimeout(pollChuvaFile, 800));
})();
/* ── Inicializa rain canvas (separado de initCanvases pois roda após as declarações) ── */
(function initRainOnLoad() {
if (window.innerWidth > 0 && window.innerHeight > 0) {
resizeRain();
initRain();
requestAnimationFrame(drawRain);
} else {
setTimeout(initRainOnLoad, 80);
}
})();
/* ============================================================ /* ============================================================
DEV SHIM — only active when served via HTTP (not from QML) DEV SHIM — only active when served via HTTP (not from QML)
Simulates playerctl updates so the music ticker works in-browser. Simulates playerctl updates so the music ticker works in-browser.

View file

@ -9,11 +9,15 @@ WallpaperItem {
function applyConfig() { function applyConfig() {
webView.runJavaScript("setShowSkeleton(" + Plasmoid.configuration.ShowSkeleton + ")") webView.runJavaScript("setShowSkeleton(" + Plasmoid.configuration.ShowSkeleton + ")")
webView.runJavaScript("setBaladaMode(" + Plasmoid.configuration.BaladaMode + ")")
webView.runJavaScript("setChuvaMode(" + Plasmoid.configuration.ChuvaMode + ")")
} }
Connections { Connections {
target: Plasmoid.configuration target: Plasmoid.configuration
function onShowSkeletonChanged() { applyConfig() } function onShowSkeletonChanged() { applyConfig() }
function onBaladaModeChanged() { applyConfig() }
function onChuvaModeChanged() { applyConfig() }
} }
WebEngineView { WebEngineView {

View file

@ -1,2 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
[ "$(cat /tmp/skeledance-balada 2>/dev/null)" = "1" ] && echo 0 > /tmp/skeledance-balada || echo 1 > /tmp/skeledance-balada if [ "$(cat /tmp/skeledance-balada 2>/dev/null)" = "1" ]; then
echo 0 > /tmp/skeledance-balada
else
echo 1 > /tmp/skeledance-balada
echo 0 > /tmp/skeledance-chuva # modos são mutuamente exclusivos
fi

7
toggle-chuva.sh Executable file
View file

@ -0,0 +1,7 @@
#!/usr/bin/env bash
if [ "$(cat /tmp/skeledance-chuva 2>/dev/null)" = "1" ]; then
echo 0 > /tmp/skeledance-chuva
else
echo 1 > /tmp/skeledance-chuva
echo 0 > /tmp/skeledance-balada # modos são mutuamente exclusivos
fi