Compare commits

...

2 commits

Author SHA1 Message Date
vini
36220dc34f fix: smooth balada nebula color transition via ::after opacity 2026-06-14 13:27:27 -03:00
vini
d422cea905 feat: balada mode — mirror ball, disco floor, spotlights, sun transition
- Mirror ball globe: canvas-based tile rendering with winner-takes-all
  specular lighting + Lambert diffuse edges; 8 orthogonal lights orbit
  slowly (~4 min/rev) ensuring no dark periods
- Disco floor tiles: per-frame alpha/color lerp (dt-based) decoupled from
  the 400ms randomization gate — eliminates choppy low-FPS appearance;
  smooth ~1.2s fade-in, ~2.4s color blending per tile
- Sun: organic JS-generated stripes (sqrt-spaced, perspective-correct),
  opacity crossfade overlay for retrowave↔balada gradient transition,
  slow position animation (rises to sky in balada mode)
- Spotlights, disco particles, ray rotation, beat pulse
- toggle-balada.sh / toggle-skeleton.sh: external IPC scripts via /tmp files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 19:36:24 -03:00
3 changed files with 530 additions and 15 deletions

View file

@ -32,6 +32,15 @@ html, body {
pointer-events: none;
animation: nebulaFloat ease-in-out infinite;
}
.nebula::after {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
opacity: 0;
transition: opacity 9s ease;
}
body.balada .nebula::after { opacity: 1; }
.nebula-a {
width: 500px; height: 350px;
background: radial-gradient(ellipse, rgba(120,0,220,0.18), transparent 70%);
@ -184,7 +193,11 @@ html, body {
max-width: 80vw;
overflow: hidden;
opacity: 0;
transition: opacity 0.8s;
transition: opacity 0.8s, top 0.8s ease;
}
body.balada .music-ticker {
/* acima do VU meter: bottom 5% + 70px barras + 10px gap */
top: calc(100% - 5% - 70px - 10px - 28px);
}
.music-icon {
color: #ff44aa;
@ -772,14 +785,13 @@ html, body {
left: 50%;
bottom: 41.5%;
transform: translateX(-50%) translateY(50%);
transition:
bottom 2.8s cubic-bezier(0.16, 1, 0.3, 1),
transform 2.8s cubic-bezier(0.16, 1, 0.3, 1),
background 7s ease,
box-shadow 7s ease;
/* listras geradas por JS — ver buildSunStripes() */
background:
repeating-linear-gradient(
to bottom,
transparent 0px,
transparent 13px,
rgba(20, 0, 50, 0.72) 13px,
rgba(20, 0, 50, 0.72) 17px
),
linear-gradient(rgba(0,0,0,0.35), rgba(0,0,0,0.28)),
linear-gradient(to bottom,
#fff9cc 0%,
@ -842,6 +854,155 @@ html, body {
to { transform: translateY(-38px) scaleX(0.88); opacity: 0.45; }
}
/* ============================================================
BALADA — SOL VIRA DISCO (raios giram ao redor do centro)
============================================================ */
/* Sol sobe para o céu */
body.balada .synthwave-sun {
bottom: 70%;
transform: translateX(-50%) translateY(0);
/* background fica no #sunBaladaBg para crossfade suave via opacidade */
box-shadow:
0 0 10px 2px rgba(255,113,206,0.18),
0 0 28px 6px rgba(185,103,255,0.10),
0 0 60px 12px rgba(1,205,254,0.05),
0 0 120px 25px rgba(185,103,255,0.03);
}
/* Overlay de balada — fade in/out suave sobre o fundo retrowave */
#sunBaladaBg {
position: absolute;
inset: 0;
border-radius: 50%;
pointer-events: none;
opacity: 0;
transition: opacity 9s ease;
background: radial-gradient(circle at 40% 35%, #2a0050 0%, #08000f 100%);
}
body.balada #sunBaladaBg { opacity: 1; }
/* Listras orgânicas do sol retrowave — geradas por buildSunStripes() */
#sunStripes {
position: absolute;
inset: 0;
border-radius: 50%;
pointer-events: none;
opacity: 1;
transition: opacity 9s ease;
}
body.balada #sunStripes { opacity: 0; }
/* Linhas do globo desenhadas via canvas JS — ver drawGlobe() */
body.balada .synthwave-sun::before { display: none; }
/* Halo vaporwave — mais discreto */
body.balada .synthwave-sun::after {
inset: -50%;
background: radial-gradient(ellipse at 50% 50%,
rgba(255,113,206,0.12) 0%,
rgba(185,103,255,0.08) 38%,
rgba(1,205,254,0.04) 60%,
transparent 74%
);
animation: haloBreath 4s ease-in-out infinite alternate;
filter: blur(22px);
}
@keyframes haloBreath {
from { transform: scale(0.97); opacity: 0.5; }
to { transform: scale(1.06); opacity: 0.85; }
}
/* Raios: container centrado no sol (140px = raio de 280px) */
.sun-rays-wrap {
position: absolute;
top: 140px; left: 140px;
width: 0; height: 0;
pointer-events: none;
opacity: 0;
transition: opacity 1.8s ease 1s;
}
body.balada .sun-rays-wrap { opacity: 1; }
.sun-ray {
position: absolute;
top: -1.5px; left: 0;
height: 3px;
transform-origin: left center;
border-radius: 0 3px 3px 0;
}
/* ============================================================
BALADA — REFLETORES (SPOTLIGHTS)
============================================================ */
.spotlight-wrap {
position: absolute; inset: 0;
pointer-events: none;
z-index: 41;
opacity: 0;
transition: opacity 2.2s ease 0.6s;
}
body.balada .spotlight-wrap { opacity: 1; }
.spl {
position: absolute;
top: 0;
}
.spl::after {
content: '';
position: absolute;
top: -60px; /* começa acima da tela — esconde o corte da geometria */
left: -35px;
width: 70px; height: calc(68vh + 60px);
clip-path: polygon(22% 0%, 78% 0%, 100% 100%, 0% 100%);
transform-origin: 50% 60px; /* pivô na borda superior da tela */
opacity: 0.12;
}
.spl-1 { left: 15%; }
.spl-1::after { background: linear-gradient(rgba(255,113,206,0.9),transparent); animation: sws1 9s ease-in-out infinite; }
.spl-2 { left: 50%; }
.spl-2::after { background: linear-gradient(rgba(1,205,254,0.9),transparent); animation: sws2 11s ease-in-out infinite; animation-delay:-3s; }
.spl-3 { left: 85%; }
.spl-3::after { background: linear-gradient(rgba(185,103,255,0.9),transparent); animation: sws1 8s ease-in-out infinite; animation-delay:-2s; }
.spl-4 { left: 30%; }
.spl-4::after { background: linear-gradient(rgba(5,255,161,0.9),transparent); animation: sws3 13s ease-in-out infinite; animation-delay:-5s; }
.spl-5 { left: 70%; }
.spl-5::after { background: linear-gradient(rgba(255,251,150,0.9),transparent); animation: sws2 10s ease-in-out infinite; animation-delay:-7s; }
@keyframes sws1 { 0%,100%{ transform:rotate(-20deg); } 50%{ transform:rotate(20deg); } }
@keyframes sws2 { 0%,100%{ transform:rotate(24deg); } 50%{ transform:rotate(-24deg); } }
@keyframes sws3 { 0%,100%{ transform:rotate(-14deg); } 50%{ transform:rotate(30deg); } }
/* ============================================================
BALADA — PARTÍCULAS DE LUZ + STROBE
============================================================ */
.disco-dot {
position: absolute;
border-radius: 50%;
pointer-events: none;
animation: discoDotFly linear forwards;
}
@keyframes discoDotFly {
0% { opacity:0; transform:translate(0,0) scale(0); }
8% { opacity:1; transform:translate(var(--tx1),var(--ty1)) scale(1); }
90% { opacity:0.85; transform:translate(var(--tx2),var(--ty2)) scale(0.6); }
100%{ opacity:0; transform:translate(var(--tx3),var(--ty3)) scale(0); }
}
.strobe {
position: fixed; inset: 0;
background: white; opacity: 0;
pointer-events: none; z-index: 9990;
mix-blend-mode: overlay;
}
/* ============================================================
BALADA — SOBRESCRITAS DO AMBIENTE
============================================================ */
.nebula-a::after { background: radial-gradient(ellipse, rgba(255,0,100,0.22), 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%); }
</style>
</head>
<body>
@ -856,8 +1017,12 @@ html, body {
<!-- Twinkling starfield -->
<canvas id="starfield"></canvas>
<!-- Synthwave sun — sits behind floor grid and horizon -->
<div class="synthwave-sun"></div>
<!-- Synthwave sun — sits behind floor grid and horizon; becomes disco ball in balada mode -->
<div class="synthwave-sun">
<div id="sunBaladaBg"></div>
<div id="sunStripes"></div>
<div class="sun-rays-wrap" id="sunRaysWrap"></div>
</div>
<!-- Retrowave grid floor -->
<canvas id="floorCanvas"></canvas>
@ -901,9 +1066,24 @@ html, body {
<!-- Lyrics display — verse karaoke -->
<div class="lyric-display" id="lyricDisplay"><div class="lyric-inner" id="lyricInner"></div></div>
<!-- BALADA: refletores do teto -->
<div class="spotlight-wrap">
<div class="spl spl-1"></div>
<div class="spl spl-2"></div>
<div class="spl spl-3"></div>
<div class="spl spl-4"></div>
<div class="spl spl-5"></div>
</div>
<!-- BALADA: partículas de luz -->
<div id="discoDots" style="position:absolute;inset:0;pointer-events:none;z-index:48;"></div>
<!-- Scanlines overlay -->
<div class="scanlines"></div>
<!-- Strobe flash -->
<div class="strobe" id="strobe"></div>
</div><!-- .scene -->
<script>
@ -968,10 +1148,36 @@ const floorCanvas = document.getElementById('floorCanvas');
const floorCtx = floorCanvas.getContext('2d');
const FLOOR_RATIO = 0.42;
/* Disco floor tiles */
const DISCO_COLORS = [
[255,113,206],[185,103,255],[1,205,254],
[5,255,161],[255,251,150],[255,68,170],
[200,0,255],[0,180,255]
];
const TCOLS = 24, TROWS = 24; // match V_LINES=24, H_LINES=24 for 1:1 tile alignment
const discoTileGrid = Array.from({length: TROWS * TCOLS}, () => {
const c = DISCO_COLORS[Math.floor(Math.random() * DISCO_COLORS.length)];
return { r: c[0], g: c[1], b: c[2], tr: c[0], tg: c[1], tb: c[2], alpha: 0, target: 0,
rgba(a) { return `rgba(${this.r|0},${this.g|0},${this.b|0},${a.toFixed(2)})`; } };
});
let lastTileTs = 0;
function tickDiscoTiles(ts) {
if (ts - lastTileTs < 400) return;
lastTileTs = ts;
discoTileGrid.forEach(t => {
if (Math.random() < 0.04) {
const c = DISCO_COLORS[Math.floor(Math.random() * DISCO_COLORS.length)];
t.tr = c[0]; t.tg = c[1]; t.tb = c[2];
t.target = Math.random() < 0.4 ? 0 : 0.12 + Math.random() * 0.28;
}
});
}
const V_LINES = 24;
const H_LINES = 24;
let floorScroll = 0;
let lastFloorTs = 0;
let baladaAmt = 0; // 0 = retrowave, 1 = balada — transição suave
let floorFadeGrad = null;
let floorFadeGradH = 0;
@ -984,6 +1190,7 @@ function drawFloor(ts) {
const dt = lastFloorTs ? Math.min((ts - lastFloorTs) / 1000, 0.1) : 0;
lastFloorTs = ts;
floorScroll = (floorScroll + 0.06 * dt) % 1;
baladaAmt += ((balada ? 1 : 0) - baladaAmt) * Math.min(1, dt * 0.6); // ~1.7s de transição
const W = floorCanvas.width;
const H = floorCanvas.height;
@ -992,10 +1199,47 @@ function drawFloor(ts) {
floorCtx.clearRect(0, 0, W, H);
// Vertical lines radiating from vanishing point (center top)
// Bottom spread exceeds screen width so laterals are filled
const spread = W * 4;
const spreadStart = vx - spread / 2;
floorCtx.strokeStyle = 'rgba(0, 200, 255, 0.32)';
const floorHue = (ts * 0.04) % 360;
/* ── Tiles de balada anos 80 — fade controlado por baladaAmt ── */
if (baladaAmt > 0.01) {
tickDiscoTiles(ts);
const lerpA = Math.min(1, dt * 1.8);
const lerpC = Math.min(1, dt * 0.9);
discoTileGrid.forEach(t => {
t.alpha += (t.target - t.alpha) * lerpA;
t.r += (t.tr - t.r) * lerpC;
t.g += (t.tg - t.g) * lerpC;
t.b += (t.tb - t.b) * lerpC;
});
const colStep = spread / TCOLS;
for (let r = 0; r < TROWS; r++) {
const t1 = ((r / TROWS) + floorScroll) % 1;
const t2 = (((r + 1) / TROWS) + floorScroll) % 1;
const d1 = Math.pow(t1, 2.2), d2 = Math.pow(t2, 2.2);
const y1 = d1 * H, y2 = d2 * H;
if (y2 <= y1 || y2 - y1 < 1) continue;
for (let c = 0; c < TCOLS; c++) {
const tile = discoTileGrid[r * TCOLS + c];
if (tile.alpha < 0.01) continue;
const bx1 = spreadStart + c * colStep;
const bx2 = bx1 + colStep;
floorCtx.fillStyle = tile.rgba(tile.alpha * baladaAmt);
floorCtx.beginPath();
floorCtx.moveTo(vx + (bx1 - vx) * d1, y1);
floorCtx.lineTo(vx + (bx2 - vx) * d1, y1);
floorCtx.lineTo(vx + (bx2 - vx) * d2, y2);
floorCtx.lineTo(vx + (bx1 - vx) * d2, y2);
floorCtx.closePath();
floorCtx.fill();
}
}
}
const vH = 200 + (floorHue - 200) * baladaAmt;
floorCtx.strokeStyle = `hsla(${vH}, 100%, 65%, 0.32)`;
floorCtx.lineWidth = 2;
for (let i = 0; i <= V_LINES; i++) {
const bx = spreadStart + (i / V_LINES) * spread;
@ -1008,11 +1252,13 @@ function drawFloor(ts) {
// Horizontal lines — perspective-correct spacing, animated
for (let i = 0; i < H_LINES; i++) {
const t = ((i / H_LINES) + floorScroll) % 1;
const depth = Math.pow(t, 2.2); // power curve: compressed near horizon
const depth = Math.pow(t, 2.2);
const y = depth * H;
const x1 = vx - (spread / 2) * depth;
const x2 = vx + (spread / 2) * depth;
floorCtx.strokeStyle = `rgba(0, 200, 255, ${(0.12 + depth * 0.25).toFixed(2)})`;
const alpha = (0.12 + depth * 0.25).toFixed(2);
const hH = (200 + (floorHue + i * 12 - 200) * baladaAmt + 360) % 360;
floorCtx.strokeStyle = `hsla(${hH}, 100%, 65%, ${alpha})`;
floorCtx.lineWidth = Math.max(1, depth * 11);
floorCtx.beginPath();
floorCtx.moveTo(x1, y);
@ -1332,7 +1578,7 @@ let isPlaying = false;
/* Spawn on every beat (500ms) with a 50 % chance of two notes */
setInterval(() => {
if (!isPlaying) return;
if (!isPlaying || !showSkeleton) return;
spawnNote();
if (Math.random() < 0.5) spawnNote();
}, BEAT_MS);
@ -1389,6 +1635,271 @@ setInterval(nextDanceStyle, 8000);
function pickSkeleton() {}
/* ============================================================
BALADA MODE
============================================================ */
let balada = false;
const sunEl = document.querySelector('.synthwave-sun');
/* Listras orgânicas do sol — perspectiva compressa: mais densas em baixo, esparsas em cima.
Distribuição sqrt(i/(N+1)): primeira listra ~22% do topo, última ~95%, gap diminuindo para baixo. */
function buildSunStripes() {
const el = document.getElementById('sunStripes');
const N = 28;
const stops = ['transparent 0%'];
for (let i = 1; i <= N; i++) {
// sqrt(i/N): primeira listra em ~19% do topo, última exatamente em 100%
const t = Math.sqrt(i / N);
const pct = 100 * t;
const frac = (i - 1) / (N - 1); // 0→1 linear para rampas
// Espessura: 0.7px (fino no topo) → 6px (sólido no fundo)
const wPx = 0.7 + 5.3 * frac;
const wPct = wPx / 280 * 100;
// Opacidade: 0.14 (suave mas visível no topo) → 0.72 no fundo, raiz para não ser flat
const a = (0.14 + 0.58 * Math.sqrt(frac)).toFixed(2);
const p1 = Math.max(0, pct - wPct / 2).toFixed(3);
const p2 = Math.min(100, pct + wPct / 2).toFixed(3);
stops.push(`transparent ${p1}%`);
stops.push(`rgba(14,0,30,${a}) ${p1}%`);
stops.push(`rgba(14,0,30,${a}) ${p2}%`);
stops.push(`transparent ${p2}%`);
}
stops.push('transparent 100%');
el.style.background = `linear-gradient(to bottom,${stops.join(',')})`;
}
buildSunStripes();
const sunRaysWrap = document.getElementById('sunRaysWrap');
/* ── Canvas do globo — latitude/longitude com curvatura real ── */
const globeCanvas = document.createElement('canvas');
globeCanvas.width = 280;
globeCanvas.height = 280;
globeCanvas.style.cssText =
'position:absolute;inset:0;border-radius:50%;pointer-events:none;' +
'opacity:0;transition:opacity 1.5s ease;';
sunEl.appendChild(globeCanvas);
const gctx = globeCanvas.getContext('2d');
let globeRot = 0, lastGlobeTs = 0;
/* 8 luzes nos eixos ortogonais — cobertura 360° garantida.
±Y não são afetados pelo lrot, ±X e ±Z orbitam entre si. */
const MIRROR_LIGHTS = [
{ x: 1, y: 0, z: 0, r: 1, g: 205, b: 254 }, // cyan +X
{ x: -1, y: 0, z: 0, r: 185, g: 103, b: 255 }, // purple -X
{ x: 0, y: 1, z: 0, r: 255, g: 113, b: 206 }, // pink +Y (topo — fixo)
{ x: 0, y: -1, z: 0, r: 5, g: 255, b: 161 }, // mint -Y (base — fixo)
{ x: 0, y: 0, z: 1, r: 255, g: 255, b: 255 }, // branco +Z
{ x: 0, y: 0, z: -1, r: 255, g: 251, b: 150 }, // amarelo -Z
{ x: 0.71, y: 0.71, z: 0, r: 255, g: 113, b: 206 }, // pink diagonal alta
{ x: -0.71, y: -0.71, z: 0, r: 5, g: 255, b: 161 }, // mint diagonal baixa
];
function drawGlobe(ts) {
requestAnimationFrame(drawGlobe);
if (baladaAmt < 0.01) { lastGlobeTs = 0; return; }
globeCanvas.style.opacity = baladaAmt;
const dt = lastGlobeTs ? Math.min((ts - lastGlobeTs) / 1000, 0.05) : 0;
lastGlobeTs = ts;
globeRot = (globeRot + dt * 0.5) % (Math.PI * 2);
const N_LAT = 14;
const BASE_LON = 28;
const FILL = 0.78;
const SHINE = 8; // expoente baixo → highlights amplos, muitos tiles acesos
const R = 137, cx = 140, cy = 140;
gctx.clearRect(0, 0, 280, 280);
gctx.save();
gctx.beginPath();
gctx.arc(cx, cy, R, 0, Math.PI * 2);
gctx.clip();
gctx.fillStyle = '#06000c';
gctx.fillRect(0, 0, 280, 280);
for (let lat = 0; lat < N_LAT; lat++) {
const phi1 = -Math.PI / 2 + (lat / N_LAT) * Math.PI;
const phi2 = -Math.PI / 2 + ((lat + 1) / N_LAT) * Math.PI;
const phiC = (phi1 + phi2) / 2;
const cosP = Math.cos(phiC), sinP = Math.sin(phiC);
const nLon = Math.max(3, Math.round(BASE_LON * cosP));
for (let lon = 0; lon < nLon; lon++) {
const th1 = globeRot + (lon / nLon) * Math.PI * 2;
const th2 = globeRot + ((lon + 1) / nLon) * Math.PI * 2;
const thC = (th1 + th2) / 2;
const nx = cosP * Math.sin(thC);
const ny = sinP;
const nz = cosP * Math.cos(thC);
if (nz <= 0.02) continue;
// Vetor refletido da câmera (0,0,1) sobre a normal do tile
const rx = 2 * nz * nx;
const ry = 2 * nz * ny;
const rz = 2 * nz * nz - 1;
const lrot = ts * 0.000026;
let bestDot = 0, bestR = 0, bestG = 0, bestB = 0;
let dr = 0, dg = 0, db = 0;
for (const L of MIRROR_LIGHTS) {
const lx = L.x * Math.cos(lrot) - L.z * Math.sin(lrot);
const lz = L.x * Math.sin(lrot) + L.z * Math.cos(lrot);
const spec = rx * lx + ry * L.y + rz * lz;
if (spec > bestDot) { bestDot = spec; bestR = L.r; bestG = L.g; bestB = L.b; }
const diff = Math.max(0, nx * lx + ny * L.y + nz * lz);
dr += diff * L.r * 0.07; dg += diff * L.g * 0.07; db += diff * L.b * 0.07;
}
const s = bestDot > 0.2 ? Math.pow(bestDot, SHINE) : 0;
const amb = 0.18 + 0.08 * nz;
let cr = amb * 100 + dr + s * bestR;
let cg = amb * 65 + dg + s * bestG;
let cb = amb * 170 + db + s * bestB;
const dP = (phi2 - phi1) * FILL, oP = (phi2 - phi1) * (1 - FILL) / 2;
const dTh = (th2 - th1) * FILL, oTh = (th2 - th1) * (1 - FILL) / 2;
const pts = [];
for (let ci = 0; ci < 4; ci++) {
const p = phi1 + oP + (ci >> 1) * dP;
const th = th1 + oTh + (ci & 1) * dTh;
pts.push([cx + R * Math.cos(p) * Math.sin(th), cy - R * Math.sin(p)]);
}
gctx.fillStyle = `rgb(${Math.min(255,cr)|0},${Math.min(255,cg)|0},${Math.min(255,cb)|0})`;
gctx.beginPath();
gctx.moveTo(pts[0][0], pts[0][1]);
gctx.lineTo(pts[1][0], pts[1][1]);
gctx.lineTo(pts[3][0], pts[3][1]);
gctx.lineTo(pts[2][0], pts[2][1]);
gctx.closePath();
gctx.fill();
}
}
gctx.restore();
}
requestAnimationFrame(drawGlobe);
const discoDots = document.getElementById('discoDots');
const strobeEl = document.getElementById('strobe');
/* ── Raios do sol/disco — paleta vaporwave ── */
const RAY_COLORS = [
'#ff71ce','#b967ff','#01cdfe',
'#05ffa1','#fffb96','#b967ff',
'#ff71ce','#01cdfe','#b967ff','#ff71ce'
];
(function buildRays() {
for (let i = 0; i < RAY_COLORS.length; i++) {
const r = document.createElement('div');
r.className = 'sun-ray';
const col = RAY_COLORS[i];
r.style.width = '95vmax';
r.style.height = '18px';
r.style.top = '-9px';
r.style.transform = `rotate(${(i / RAY_COLORS.length) * 360}deg)`;
/* gradiente suave — sem borda dura */
r.style.background =
`linear-gradient(to right, ${col}28 0%, ${col}12 45%, transparent 80%)`;
r.style.filter = 'blur(3px)';
sunRaysWrap.appendChild(r);
}
})();
/* Rotação contínua dos raios */
(function animateRays(ts) {
if (balada) sunRaysWrap.style.transform = `rotate(${(ts * 0.025) % 360}deg)`;
requestAnimationFrame(animateRays);
})(0);
/* ── Partículas de luz disparadas do sol ── */
function spawnDiscoDot() {
const d = document.createElement('div');
d.className = 'disco-dot';
const col = RAY_COLORS[Math.floor(Math.random() * RAY_COLORS.length)];
const sz = 4 + Math.random() * 7;
const dur = 1.4 + Math.random() * 2;
const ox = 48 + Math.random() * 4;
const oy = 22 + Math.random() * 4;
const s = () => (Math.random() - 0.5) * 2;
d.style.cssText =
`width:${sz}px;height:${sz}px;` +
`background:${col};box-shadow:0 0 ${sz * 1.5}px ${col};` +
`left:${ox}vw;top:${oy}vh;` +
`--tx1:${s()*12}vw;--ty1:${s()*8}vh;` +
`--tx2:${s()*35}vw;--ty2:${5 + Math.random() * 30}vh;` +
`--tx3:${s()*65}vw;--ty3:${15 + Math.random() * 55}vh;` +
`animation-duration:${dur}s;`;
discoDots.appendChild(d);
setTimeout(() => d.remove(), dur * 1000 + 100);
}
setInterval(() => {
if (!balada) return;
if (baladaAmt > 0.3 && discoDots.childElementCount < 28) spawnDiscoDot();
}, 280);
/* ── Strobe no beat ── */
let strobeCooldown = false;
function triggerStrobe() {
if (!balada || strobeCooldown) return;
strobeCooldown = true;
strobeEl.style.transition = 'none';
strobeEl.style.opacity = '0.2';
setTimeout(() => {
strobeEl.style.transition = 'opacity 0.3s ease';
strobeEl.style.opacity = '0';
setTimeout(() => { strobeCooldown = false; }, 600);
}, 50);
}
/* strobe desativado — muito agressivo visualmente */
/* setInterval(() => { if (balada && Math.random() < 0.35) triggerStrobe(); }, BEAT_MS * 2); */
/* ── Pulso do sol/disco no beat ── */
function globeBeat() {
if (!balada || !sunEl) return;
sunEl.style.boxShadow =
'0 0 14px 3px rgba(255,113,206,0.08),' +
'0 0 30px 6px rgba(185,103,255,0.05)';
setTimeout(() => { sunEl.style.boxShadow = ''; }, 1200);
}
setInterval(() => { if (balada) globeBeat(); }, BEAT_MS);
/* ── Toggle ── */
function setBaladaMode(on) {
if (on === balada) return;
balada = on;
document.body.classList.toggle('balada', balada);
globeCanvas.style.opacity = on ? '1' : '0';
}
function toggleBalada() { setBaladaMode(!balada); }
document.addEventListener('keydown', e => {
if ((e.key === 'b' || e.key === 'B') && !e.target.matches('input,textarea'))
toggleBalada();
});
/* Polling de arquivos — toggle externo */
(function pollBaladaFile() {
fetch('file:///tmp/skeledance-balada?t=' + Date.now())
.then(r => r.text())
.then(t => setBaladaMode(t.trim() === '1'))
.catch(() => {})
.finally(() => setTimeout(pollBaladaFile, 800));
})();
(function pollSkeletonFile() {
fetch('file:///tmp/skeledance-skeleton?t=' + Date.now())
.then(r => r.text())
.then(t => setShowSkeleton(t.trim() !== '0'))
.catch(() => {}) /* arquivo ausente = skeleton visível */
.finally(() => setTimeout(pollSkeletonFile, 800));
})();
/* ============================================================
DEV SHIM — only active when served via HTTP (not from QML)
Simulates playerctl updates so the music ticker works in-browser.

2
toggle-balada.sh Executable file
View file

@ -0,0 +1,2 @@
#!/usr/bin/env bash
[ "$(cat /tmp/skeledance-balada 2>/dev/null)" = "1" ] && echo 0 > /tmp/skeledance-balada || echo 1 > /tmp/skeledance-balada

2
toggle-skeleton.sh Executable file
View file

@ -0,0 +1,2 @@
#!/usr/bin/env bash
[ "$(cat /tmp/skeledance-skeleton 2>/dev/null)" = "0" ] && echo 1 > /tmp/skeledance-skeleton || echo 0 > /tmp/skeledance-skeleton