1307 lines
37 KiB
HTML
1307 lines
37 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<style>
|
||
|
||
/* ============================================================
|
||
RESET + BASE
|
||
============================================================ */
|
||
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||
|
||
html, body {
|
||
width: 100%; height: 100%;
|
||
overflow: hidden;
|
||
background: #000814;
|
||
user-select: none;
|
||
}
|
||
|
||
.scene {
|
||
position: relative;
|
||
width: 100%; height: 100%;
|
||
}
|
||
|
||
/* ============================================================
|
||
BACKGROUND: NEBULA
|
||
============================================================ */
|
||
.nebula {
|
||
position: absolute;
|
||
border-radius: 50%;
|
||
filter: blur(80px);
|
||
pointer-events: none;
|
||
animation: nebulaFloat ease-in-out infinite;
|
||
}
|
||
.nebula-a {
|
||
width: 500px; height: 350px;
|
||
background: radial-gradient(ellipse, rgba(120,0,220,0.18), transparent 70%);
|
||
top: 5%; left: 5%;
|
||
animation-duration: 9s;
|
||
}
|
||
.nebula-b {
|
||
width: 420px; height: 300px;
|
||
background: radial-gradient(ellipse, rgba(0,80,200,0.15), transparent 70%);
|
||
top: 15%; right: 8%;
|
||
animation-duration: 12s;
|
||
animation-direction: reverse;
|
||
}
|
||
.nebula-c {
|
||
width: 300px; height: 200px;
|
||
background: radial-gradient(ellipse, rgba(200,0,100,0.1), transparent 70%);
|
||
bottom: 40%; left: 20%;
|
||
animation-duration: 7s;
|
||
animation-delay: -3s;
|
||
}
|
||
@keyframes nebulaFloat {
|
||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||
50% { transform: translate(25px, -20px) scale(1.05); }
|
||
}
|
||
|
||
/* ============================================================
|
||
BACKGROUND: STARFIELD CANVAS
|
||
============================================================ */
|
||
#starfield {
|
||
position: absolute;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* ============================================================
|
||
RETROWAVE GRID FLOOR
|
||
============================================================ */
|
||
#floorCanvas {
|
||
position: absolute;
|
||
bottom: 0; left: 0;
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* Horizon glow line */
|
||
.horizon {
|
||
position: absolute;
|
||
bottom: 41.5%;
|
||
left: 0; right: 0;
|
||
height: 3px;
|
||
background: linear-gradient(
|
||
to right,
|
||
transparent 0%,
|
||
rgba(255, 0, 150, 0.9) 20%,
|
||
rgba(0, 220, 255, 1) 50%,
|
||
rgba(255, 0, 150, 0.9) 80%,
|
||
transparent 100%
|
||
);
|
||
box-shadow: 0 0 25px rgba(0,220,255,0.8), 0 0 80px rgba(0,200,255,0.25);
|
||
animation: horizonPulse 1s ease-in-out infinite;
|
||
}
|
||
@keyframes horizonPulse {
|
||
0%, 100% { opacity: 0.75; }
|
||
50% { opacity: 1; }
|
||
}
|
||
|
||
/* ============================================================
|
||
SPECTRUM VISUALIZER (behind character)
|
||
============================================================ */
|
||
.spec-bar-unused {
|
||
}
|
||
|
||
/* ============================================================
|
||
METEORS
|
||
============================================================ */
|
||
.meteor {
|
||
position: absolute;
|
||
width: 2px;
|
||
border-radius: 1px;
|
||
pointer-events: none;
|
||
transform-origin: top center;
|
||
animation: meteorFall linear forwards;
|
||
}
|
||
#meteorLayer {
|
||
position: absolute;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
-webkit-mask-image: linear-gradient(to bottom, black 50%, transparent 60%);
|
||
mask-image: linear-gradient(to bottom, black 50%, transparent 60%);
|
||
}
|
||
|
||
@keyframes meteorFall {
|
||
0% { opacity: 0; transform: rotate(var(--rot)) translateY(0); }
|
||
5% { opacity: 1; }
|
||
85% { opacity: 1; }
|
||
100% { opacity: 0; transform: rotate(var(--rot)) translateY(var(--dist)); }
|
||
}
|
||
|
||
/* ============================================================
|
||
VU METER (moved up)
|
||
============================================================ */
|
||
.vu-meter {
|
||
position: absolute;
|
||
bottom: 5%;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
display: flex;
|
||
align-items: flex-end;
|
||
gap: 4px;
|
||
pointer-events: none;
|
||
transition: opacity 0.8s;
|
||
}
|
||
.vu-bar {
|
||
width: 9px;
|
||
min-height: 3px;
|
||
border-radius: 3px 3px 0 0;
|
||
}
|
||
|
||
/* ============================================================
|
||
MUSIC TICKER
|
||
============================================================ */
|
||
.album-art {
|
||
position: absolute;
|
||
top: 4%;
|
||
right: 2%;
|
||
width: 330px;
|
||
height: 330px;
|
||
border-radius: 6px;
|
||
box-shadow: 0 0 18px rgba(0,0,0,0.8), 0 0 8px var(--theme-color, rgba(0,220,255,0.5));
|
||
transition: opacity 0.6s ease, box-shadow 0.6s ease;
|
||
pointer-events: none;
|
||
overflow: hidden;
|
||
}
|
||
.album-art img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
}
|
||
|
||
.music-ticker {
|
||
position: absolute;
|
||
top: 4%;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
white-space: nowrap;
|
||
pointer-events: none;
|
||
max-width: 80vw;
|
||
overflow: hidden;
|
||
opacity: 0;
|
||
transition: opacity 0.8s;
|
||
}
|
||
.music-icon {
|
||
color: #ff44aa;
|
||
font-size: 18px;
|
||
text-shadow: 0 0 10px #ff44aa;
|
||
animation: iconPulse 0.5s ease-in-out infinite alternate;
|
||
flex-shrink: 0;
|
||
}
|
||
@keyframes iconPulse {
|
||
from { opacity: 0.5; transform: scale(0.9); }
|
||
to { opacity: 1; transform: scale(1.1); }
|
||
}
|
||
.music-scroll-wrap {
|
||
overflow: hidden;
|
||
max-width: 60vw;
|
||
}
|
||
.music-text {
|
||
display: inline-block;
|
||
color: #ffffff;
|
||
font-family: 'Courier New', monospace;
|
||
font-size: clamp(11px, 1.2vw, 17px);
|
||
text-shadow: 0 0 8px rgba(0,200,255,0.7);
|
||
white-space: nowrap;
|
||
animation: musicScroll 18s linear infinite;
|
||
}
|
||
.music-text.short { animation: none; }
|
||
#musicArtist { color: rgba(0, 200, 255, 0.85); }
|
||
#musicTitle { color: #ffffff; }
|
||
.music-sep { color: rgba(255,255,255,0.4); }
|
||
|
||
@keyframes musicScroll {
|
||
0% { transform: translateX(100%); }
|
||
100% { transform: translateX(-100%); }
|
||
}
|
||
|
||
/* ============================================================
|
||
MUSIC NOTES (JS-spawned)
|
||
============================================================ */
|
||
.music-note {
|
||
position: absolute;
|
||
pointer-events: none;
|
||
animation: noteFly linear forwards;
|
||
opacity: 0;
|
||
}
|
||
@keyframes noteFly {
|
||
0% { transform: translateY(0) rotate(-10deg) scale(0.6); opacity: 0; }
|
||
12% { opacity: 1; }
|
||
88% { opacity: 0.9; }
|
||
100% { transform: translateY(-110px) rotate(18deg) scale(1.3); opacity: 0; }
|
||
}
|
||
|
||
/* ============================================================
|
||
LYRICS DISPLAY
|
||
============================================================ */
|
||
.lyric-display {
|
||
position: absolute;
|
||
left: 3%;
|
||
top: 5%;
|
||
max-width: 36%;
|
||
pointer-events: none;
|
||
opacity: 0;
|
||
transition: opacity 0.5s ease;
|
||
}
|
||
.lyric-line {
|
||
color: #ffffff;
|
||
font-family: 'Courier New', monospace;
|
||
font-size: clamp(12px, 1.3vw, 18px);
|
||
line-height: 1.5;
|
||
letter-spacing: 0.03em;
|
||
text-shadow:
|
||
0 0 18px rgba(0,200,255,0.85),
|
||
0 0 6px rgba(0,200,255,0.5),
|
||
0 2px 10px rgba(0,0,0,0.95),
|
||
-1px -1px 0 rgba(0,0,0,0.7),
|
||
1px 1px 0 rgba(0,0,0,0.7);
|
||
}
|
||
|
||
/* ============================================================
|
||
SCANLINES OVERLAY
|
||
============================================================ */
|
||
.scanlines {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: repeating-linear-gradient(
|
||
0deg,
|
||
transparent 0px, transparent 3px,
|
||
rgba(0,0,0,0.07) 3px, rgba(0,0,0,0.07) 4px
|
||
);
|
||
pointer-events: none;
|
||
z-index: 9999;
|
||
}
|
||
|
||
/* ============================================================
|
||
ROBOT WRAPPER
|
||
120 BPM = 0.5s per beat. All animations key off this tempo.
|
||
============================================================ */
|
||
.robot-wrap {
|
||
position: absolute;
|
||
left: 50%;
|
||
bottom: 28%;
|
||
transform: translateX(-50%);
|
||
opacity: 0;
|
||
transition: opacity 0.8s;
|
||
}
|
||
|
||
.beat-wrap {
|
||
display: block;
|
||
transform-origin: center bottom;
|
||
}
|
||
@keyframes beatBounce {
|
||
0% { transform: scale(1.08) translateY(-10px); filter: brightness(1.4); }
|
||
55% { transform: scale(0.96) translateY(4px); filter: brightness(1); }
|
||
100% { transform: scale(1) translateY(0); filter: brightness(1); }
|
||
}
|
||
.beat-wrap.beat {
|
||
animation: beatBounce 0.35s cubic-bezier(0.22, 0.61, 0.36, 1) forwards;
|
||
}
|
||
|
||
.skeleton-gif {
|
||
display: block;
|
||
height: 44vh;
|
||
width: auto;
|
||
filter: drop-shadow(0 0 20px rgba(0,200,255,0.4));
|
||
}
|
||
|
||
/* Entire robot bounces on the beat (0.5s = 1 beat at 120 BPM) */
|
||
.robot {
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
animation: robotBounce 0.5s ease-in-out infinite alternate;
|
||
}
|
||
@keyframes robotBounce {
|
||
from { transform: translateY(0) scaleY(1) scaleX(1); }
|
||
to { transform: translateY(-16px) scaleY(1.02) scaleX(0.99); }
|
||
}
|
||
|
||
/* ---- ANTENNA ---- */
|
||
.antenna-wrap {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: flex-end;
|
||
height: 44px;
|
||
}
|
||
.antenna-stick {
|
||
width: 5px; height: 36px;
|
||
background: linear-gradient(to top, #6678a0, #aabbd0);
|
||
border-radius: 3px;
|
||
position: relative;
|
||
transform-origin: bottom center;
|
||
animation: antennaSway 0.5s ease-in-out infinite alternate;
|
||
}
|
||
/* The sway is opposite to the bounce so it looks reactive */
|
||
@keyframes antennaSway {
|
||
from { transform: rotate(-14deg); }
|
||
to { transform: rotate(14deg); }
|
||
}
|
||
.antenna-ball {
|
||
position: absolute;
|
||
top: -11px; left: 50%;
|
||
transform: translateX(-50%);
|
||
width: 16px; height: 16px;
|
||
border-radius: 50%;
|
||
background: radial-gradient(circle at 35% 25%, #ff99cc, #ff0077);
|
||
box-shadow: 0 0 12px #ff0077, 0 0 30px rgba(255,0,100,0.5);
|
||
animation: ballBlink 0.25s ease-in-out infinite alternate;
|
||
}
|
||
@keyframes ballBlink {
|
||
from { opacity: 0.45; box-shadow: 0 0 5px #ff0077; }
|
||
to { opacity: 1; box-shadow: 0 0 18px #ff0077, 0 0 40px rgba(255,0,100,0.6); }
|
||
}
|
||
|
||
/* ---- HEAD ---- */
|
||
.head {
|
||
width: 94px; height: 78px;
|
||
background: linear-gradient(145deg, #c8d4e8 0%, #8898b8 55%, #6070a0 100%);
|
||
border-radius: 22px;
|
||
border: 2px solid #99aacc;
|
||
box-shadow:
|
||
inset 0 3px 7px rgba(255,255,255,0.38),
|
||
inset 0 -2px 5px rgba(0,0,60,0.3),
|
||
0 6px 18px rgba(0,0,100,0.5);
|
||
position: relative;
|
||
animation: headTilt 1s ease-in-out infinite;
|
||
margin-top: 3px;
|
||
}
|
||
@keyframes headTilt {
|
||
0% { transform: rotate(-4deg) translateY(0); }
|
||
25% { transform: rotate(0deg) translateY(-3px); }
|
||
50% { transform: rotate(4deg) translateY(0); }
|
||
75% { transform: rotate(0deg) translateY(-3px); }
|
||
100% { transform: rotate(-4deg) translateY(0); }
|
||
}
|
||
|
||
/* EYES */
|
||
.eyes {
|
||
display: flex;
|
||
justify-content: space-around;
|
||
padding: 20px 14px 0;
|
||
}
|
||
.eye {
|
||
width: 23px; height: 23px;
|
||
background: radial-gradient(circle at 38% 30%, #aaffdd, #00cc66);
|
||
border-radius: 50%;
|
||
box-shadow: 0 0 10px #00ff88, 0 0 22px rgba(0,255,136,0.45);
|
||
animation: eyeBlink 3.7s ease-in-out infinite;
|
||
position: relative;
|
||
}
|
||
/* Specular dot */
|
||
.eye::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: 5px; left: 5px;
|
||
width: 7px; height: 7px;
|
||
background: rgba(255,255,255,0.75);
|
||
border-radius: 50%;
|
||
}
|
||
/* Second eye blinks slightly offset */
|
||
.eye:last-child {
|
||
animation-delay: 0.08s;
|
||
}
|
||
@keyframes eyeBlink {
|
||
0%, 87%, 100% { transform: scaleY(1); }
|
||
93% { transform: scaleY(0.07); }
|
||
}
|
||
|
||
/* LED MOUTH */
|
||
.mouth-panel {
|
||
margin: 7px auto 0;
|
||
width: 54px; height: 11px;
|
||
background: #000c20;
|
||
border-radius: 4px;
|
||
border: 1px solid rgba(0,200,255,0.45);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-around;
|
||
padding: 0 5px;
|
||
overflow: hidden;
|
||
}
|
||
.mouth-seg {
|
||
width: 6px; height: 7px;
|
||
border-radius: 2px;
|
||
animation: mouthSegAnim 0.5s ease-in-out infinite alternate;
|
||
}
|
||
.mouth-seg:nth-child(1) { background:#00ddff; animation-delay:0.00s; }
|
||
.mouth-seg:nth-child(2) { background:#00ccee; animation-delay:0.10s; }
|
||
.mouth-seg:nth-child(3) { background:#00bbff; animation-delay:0.05s; }
|
||
.mouth-seg:nth-child(4) { background:#00ccee; animation-delay:0.10s; }
|
||
.mouth-seg:nth-child(5) { background:#00ddff; animation-delay:0.00s; }
|
||
@keyframes mouthSegAnim {
|
||
from { opacity:1; box-shadow: 0 0 4px currentColor; }
|
||
to { opacity:0.25; box-shadow: none; }
|
||
}
|
||
|
||
/* ---- NECK ---- */
|
||
.neck {
|
||
width: 26px; height: 13px;
|
||
background: linear-gradient(to bottom, #7080a8, #50607a);
|
||
border-radius: 0 0 5px 5px;
|
||
box-shadow: inset 0 -2px 4px rgba(0,0,60,0.4);
|
||
}
|
||
|
||
/* ---- MIDDLE: left arm + torso + right arm ---- */
|
||
.middle {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
/* SHOULDER BALL */
|
||
.shoulder {
|
||
width: 18px; height: 18px;
|
||
margin-top: 10px;
|
||
background: radial-gradient(circle at 32% 28%, #aabbd0, #5a6890);
|
||
border-radius: 50%;
|
||
box-shadow: 0 0 8px rgba(0,100,255,0.25);
|
||
z-index: 1;
|
||
}
|
||
|
||
/* UPPER ARM */
|
||
.arm {
|
||
width: 21px; height: 66px;
|
||
background: linear-gradient(to bottom, #8090b8, #5868a0);
|
||
border-radius: 11px;
|
||
position: relative;
|
||
transform-origin: top center;
|
||
box-shadow:
|
||
inset 0 2px 4px rgba(255,255,255,0.2),
|
||
0 3px 10px rgba(0,0,80,0.4);
|
||
margin-top: 4px;
|
||
}
|
||
.arm.left { animation: armSwingL 1s ease-in-out infinite; }
|
||
.arm.right { animation: armSwingR 1s ease-in-out infinite; }
|
||
|
||
@keyframes armSwingL {
|
||
0%, 100% { transform: rotate(-52deg); }
|
||
50% { transform: rotate(42deg); }
|
||
}
|
||
@keyframes armSwingR {
|
||
0%, 100% { transform: rotate(52deg); }
|
||
50% { transform: rotate(-42deg); }
|
||
}
|
||
|
||
/* HAND */
|
||
.hand {
|
||
position: absolute;
|
||
bottom: -13px; left: -7px;
|
||
width: 35px; height: 35px;
|
||
border-radius: 50%;
|
||
background: radial-gradient(circle at 35% 30%, #aabbd0, #5868a0);
|
||
box-shadow: 0 0 10px rgba(0,100,255,0.22);
|
||
}
|
||
|
||
/* ---- TORSO ---- */
|
||
.torso {
|
||
width: 104px; height: 95px;
|
||
background: linear-gradient(145deg, #9aaac0 0%, #6070a0 55%, #485888 100%);
|
||
border-radius: 14px;
|
||
border: 2px solid #7888b8;
|
||
box-shadow:
|
||
inset 0 3px 7px rgba(255,255,255,0.2),
|
||
inset 0 -3px 7px rgba(0,0,60,0.3),
|
||
0 6px 22px rgba(0,0,100,0.6);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
position: relative;
|
||
}
|
||
|
||
/* CHEST SCREEN */
|
||
.chest-screen {
|
||
width: 72px; height: 60px;
|
||
background: #000c20;
|
||
border-radius: 9px;
|
||
border: 1px solid rgba(0,200,255,0.65);
|
||
box-shadow: 0 0 14px rgba(0,200,255,0.5), inset 0 0 14px rgba(0,80,200,0.3);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: space-evenly;
|
||
padding: 6px;
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
/* Glass sheen */
|
||
.chest-screen::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0; left: 0; right: 50%; height: 50%;
|
||
background: linear-gradient(135deg, rgba(255,255,255,0.06), transparent);
|
||
border-radius: 9px 0 0 0;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.eq-row {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
gap: 3px;
|
||
width: 100%; height: 13px;
|
||
}
|
||
.eq-col {
|
||
flex: 1;
|
||
border-radius: 2px 2px 0 0;
|
||
/* height and animation driven by JS */
|
||
}
|
||
|
||
.chest-logo {
|
||
color: #00ccff;
|
||
font-size: 11px;
|
||
font-family: monospace;
|
||
text-shadow: 0 0 6px #00ccff;
|
||
letter-spacing: 2px;
|
||
animation: logoFlicker 1.5s steps(2) infinite;
|
||
}
|
||
@keyframes logoFlicker {
|
||
0%, 66% { opacity: 1; }
|
||
33%, 100% { opacity: 0.55; }
|
||
}
|
||
|
||
/* ---- HIP ---- */
|
||
.hip {
|
||
width: 92px; height: 24px;
|
||
background: linear-gradient(to bottom, #5868a0, #404a78);
|
||
border-radius: 0 0 10px 10px;
|
||
border: 1px solid rgba(80,100,180,0.6);
|
||
box-shadow: 0 5px 12px rgba(0,0,100,0.5);
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.rivet {
|
||
width: 9px; height: 9px;
|
||
border-radius: 50%;
|
||
background: radial-gradient(circle at 32% 28%, #aabbd0, #4a5880);
|
||
box-shadow: 0 1px 3px rgba(0,0,80,0.5);
|
||
}
|
||
|
||
/* ---- LEGS ---- */
|
||
.legs {
|
||
display: flex;
|
||
gap: 14px;
|
||
margin-top: 2px;
|
||
}
|
||
.leg-grp {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
}
|
||
|
||
/* UPPER LEG */
|
||
.upper-leg {
|
||
width: 28px; height: 68px;
|
||
background: linear-gradient(to bottom, #6070a0, #485878);
|
||
border-radius: 14px;
|
||
transform-origin: top center;
|
||
box-shadow:
|
||
inset 0 2px 5px rgba(255,255,255,0.15),
|
||
0 3px 10px rgba(0,0,80,0.5);
|
||
}
|
||
.upper-leg.left { animation: upperLegL 1s ease-in-out infinite; }
|
||
.upper-leg.right { animation: upperLegR 1s ease-in-out infinite; }
|
||
|
||
@keyframes upperLegL {
|
||
0% { transform: rotate(-18deg); }
|
||
25% { transform: rotate(4deg) translateY(-4px); }
|
||
50% { transform: rotate(18deg); }
|
||
75% { transform: rotate(4deg) translateY(-4px); }
|
||
100% { transform: rotate(-18deg); }
|
||
}
|
||
@keyframes upperLegR {
|
||
0% { transform: rotate(18deg); }
|
||
25% { transform: rotate(4deg) translateY(-4px); }
|
||
50% { transform: rotate(-18deg); }
|
||
75% { transform: rotate(4deg) translateY(-4px); }
|
||
100% { transform: rotate(18deg); }
|
||
}
|
||
|
||
/* KNEE JOINT */
|
||
.knee {
|
||
width: 22px; height: 22px;
|
||
border-radius: 50%;
|
||
background: radial-gradient(circle at 35% 28%, #8090b8, #384868);
|
||
box-shadow: 0 2px 7px rgba(0,0,100,0.5);
|
||
margin: -3px 0;
|
||
}
|
||
|
||
/* LOWER LEG */
|
||
.lower-leg {
|
||
width: 23px; height: 52px;
|
||
background: linear-gradient(to bottom, #505878, #303858);
|
||
border-radius: 12px;
|
||
position: relative;
|
||
transform-origin: top center;
|
||
box-shadow:
|
||
inset 0 2px 4px rgba(255,255,255,0.12),
|
||
0 3px 10px rgba(0,0,80,0.5);
|
||
}
|
||
.lower-leg.left { animation: lowerLegL 1s ease-in-out infinite; }
|
||
.lower-leg.right { animation: lowerLegR 1s ease-in-out infinite; }
|
||
|
||
/* Lower legs bend opposite to upper, giving a knee-flex effect */
|
||
@keyframes lowerLegL {
|
||
0%, 100% { transform: rotate(12deg); }
|
||
50% { transform: rotate(-18deg); }
|
||
}
|
||
@keyframes lowerLegR {
|
||
0%, 100% { transform: rotate(-12deg); }
|
||
50% { transform: rotate(18deg); }
|
||
}
|
||
|
||
/* FOOT */
|
||
.foot {
|
||
position: absolute;
|
||
bottom: -15px; left: -9px;
|
||
width: 41px; height: 21px;
|
||
background: linear-gradient(135deg, #6878a8, #384068);
|
||
border-radius: 8px 14px 16px 6px;
|
||
box-shadow: 0 7px 10px rgba(0,0,100,0.6), 0 0 12px rgba(0,80,255,0.12);
|
||
}
|
||
/* Toe ridge */
|
||
.foot::after {
|
||
content: '';
|
||
position: absolute;
|
||
bottom: 3px; right: 5px;
|
||
width: 55%; height: 4px;
|
||
background: rgba(100,130,210,0.35);
|
||
border-radius: 2px;
|
||
}
|
||
|
||
/* ============================================================
|
||
DANCE STYLE VARIANTS (JS switches body data-dance attr)
|
||
============================================================ */
|
||
|
||
/* SPIN mode — whole robot rotates */
|
||
[data-dance="spin"] .robot {
|
||
animation: robotSpin 2s linear infinite !important;
|
||
}
|
||
@keyframes robotSpin {
|
||
from { transform: rotateY(0deg) translateY(-8px); }
|
||
to { transform: rotateY(360deg) translateY(-8px); }
|
||
}
|
||
|
||
/* WAVE mode — arms raise high repeatedly */
|
||
[data-dance="wave"] .arm.left {
|
||
animation: armWaveL 0.5s ease-in-out infinite !important;
|
||
}
|
||
[data-dance="wave"] .arm.right {
|
||
animation: armWaveR 0.5s ease-in-out infinite !important;
|
||
}
|
||
@keyframes armWaveL {
|
||
0%, 100% { transform: rotate(-80deg); }
|
||
50% { transform: rotate(-10deg); }
|
||
}
|
||
@keyframes armWaveR {
|
||
0%, 100% { transform: rotate(80deg); }
|
||
50% { transform: rotate(10deg); }
|
||
}
|
||
[data-dance="wave"] .robot {
|
||
animation: robotBounce 0.25s ease-in-out infinite alternate !important;
|
||
}
|
||
|
||
/* SHUFFLE mode — fast leg steps */
|
||
[data-dance="shuffle"] .upper-leg.left {
|
||
animation: upperLegL 0.25s ease-in-out infinite !important;
|
||
}
|
||
[data-dance="shuffle"] .upper-leg.right {
|
||
animation: upperLegR 0.25s ease-in-out infinite !important;
|
||
}
|
||
[data-dance="shuffle"] .lower-leg.left {
|
||
animation: lowerLegL 0.25s ease-in-out infinite !important;
|
||
}
|
||
[data-dance="shuffle"] .lower-leg.right {
|
||
animation: lowerLegR 0.25s ease-in-out infinite !important;
|
||
}
|
||
[data-dance="shuffle"] .arm.left {
|
||
animation: armSwingL 0.5s ease-in-out infinite !important;
|
||
}
|
||
[data-dance="shuffle"] .arm.right {
|
||
animation: armSwingR 0.5s ease-in-out infinite !important;
|
||
}
|
||
|
||
/* MOONWALK mode — body slides and leans */
|
||
[data-dance="moonwalk"] .robot {
|
||
animation: moonwalkLean 1s ease-in-out infinite alternate !important;
|
||
}
|
||
@keyframes moonwalkLean {
|
||
from { transform: translateX(-20px) rotate(-5deg); }
|
||
to { transform: translateX(20px) rotate(5deg); }
|
||
}
|
||
[data-dance="moonwalk"] .upper-leg.left {
|
||
animation: moonLegL 1s ease-in-out infinite !important;
|
||
}
|
||
[data-dance="moonwalk"] .upper-leg.right {
|
||
animation: moonLegR 1s ease-in-out infinite !important;
|
||
}
|
||
@keyframes moonLegL {
|
||
0%, 100% { transform: rotate(25deg); }
|
||
50% { transform: rotate(-5deg); }
|
||
}
|
||
@keyframes moonLegR {
|
||
0%, 100% { transform: rotate(-25deg); }
|
||
50% { transform: rotate(5deg); }
|
||
}
|
||
|
||
/* ============================================================
|
||
SYNTHWAVE SUN
|
||
============================================================ */
|
||
.synthwave-sun {
|
||
position: absolute;
|
||
width: 280px;
|
||
height: 280px;
|
||
border-radius: 50%;
|
||
left: 50%;
|
||
bottom: 41.5%;
|
||
transform: translateX(-50%) translateY(50%);
|
||
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%,
|
||
#ffcc00 20%,
|
||
#ff7700 45%,
|
||
#ff1166 70%,
|
||
#aa00ee 100%
|
||
);
|
||
box-shadow:
|
||
0 0 50px rgba(255,120, 0, 0.7),
|
||
0 0 120px rgba(255, 20,100, 0.45),
|
||
0 0 220px rgba(150, 0,255, 0.25);
|
||
}
|
||
/* atmospheric halo above the sun */
|
||
.synthwave-sun::after {
|
||
content: '';
|
||
position: absolute;
|
||
inset: -35%;
|
||
border-radius: 50%;
|
||
background: radial-gradient(ellipse at 50% 60%,
|
||
rgba(255,100, 0, 0.18) 0%,
|
||
rgba(255, 0,120, 0.12) 40%,
|
||
transparent 70%);
|
||
filter: blur(18px);
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* ============================================================
|
||
FOG — synthwave atmospheric haze rising above horizon
|
||
============================================================ */
|
||
.fog-layer {
|
||
position: absolute;
|
||
bottom: 41.5%;
|
||
left: 0; right: 0;
|
||
height: 38%;
|
||
pointer-events: none;
|
||
overflow: visible;
|
||
}
|
||
.fog-wisp {
|
||
position: absolute;
|
||
bottom: -10px;
|
||
border-radius: 50%;
|
||
filter: blur(45px);
|
||
will-change: transform, opacity;
|
||
}
|
||
.fog-w1 { width: 75%; height: 140px; left: -8%; animation: fogDrift1 9s ease-in-out infinite alternate; }
|
||
.fog-w2 { width: 60%; height: 120px; left: 25%; animation: fogDrift2 13s ease-in-out infinite alternate; animation-delay: -4s; }
|
||
.fog-w3 { width: 55%; height: 110px; left: 48%; animation: fogDrift3 8s ease-in-out infinite alternate; animation-delay: -2s; }
|
||
|
||
@keyframes fogDrift1 {
|
||
from { transform: translateY(0) scaleX(1); opacity: 0.32; }
|
||
to { transform: translateY(-50px) scaleX(1.18); opacity: 0.55; }
|
||
}
|
||
@keyframes fogDrift2 {
|
||
from { transform: translateY(-12px) scaleX(0.93); opacity: 0.38; }
|
||
to { transform: translateY(-60px) scaleX(1.22); opacity: 0.50; }
|
||
}
|
||
@keyframes fogDrift3 {
|
||
from { transform: translateY(-6px) scaleX(1.06); opacity: 0.28; }
|
||
to { transform: translateY(-38px) scaleX(0.88); opacity: 0.45; }
|
||
}
|
||
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="scene">
|
||
|
||
<!-- Nebula blobs -->
|
||
<div class="nebula nebula-a"></div>
|
||
<div class="nebula nebula-b"></div>
|
||
<div class="nebula nebula-c"></div>
|
||
|
||
<!-- Twinkling starfield -->
|
||
<canvas id="starfield"></canvas>
|
||
|
||
<!-- Synthwave sun — sits behind floor grid and horizon -->
|
||
<div class="synthwave-sun"></div>
|
||
|
||
<!-- Retrowave grid floor -->
|
||
<canvas id="floorCanvas"></canvas>
|
||
|
||
<!-- Horizon glow -->
|
||
<div class="horizon"></div>
|
||
|
||
<!-- Dynamic fog above horizon -->
|
||
<div class="fog-layer" id="fogLayer">
|
||
<div class="fog-wisp fog-w1"></div>
|
||
<div class="fog-wisp fog-w2"></div>
|
||
<div class="fog-wisp fog-w3"></div>
|
||
</div>
|
||
|
||
<!-- Meteor layer (masked at horizon) -->
|
||
<div id="meteorLayer"></div>
|
||
|
||
<!-- ===================== ROBOT ===================== -->
|
||
<div class="robot-wrap">
|
||
<div class="beat-wrap">
|
||
<img src="skeleton3.apng" class="skeleton-gif" alt="skeleton">
|
||
</div>
|
||
</div><!-- .robot-wrap -->
|
||
|
||
<!-- VU Meter -->
|
||
<div class="vu-meter" id="vuMeter"></div>
|
||
|
||
<!-- Album art -->
|
||
<div id="albumArt" class="album-art" style="opacity:0"><img id="albumArtImg" src="" alt=""></div>
|
||
|
||
<!-- Music ticker -->
|
||
<div class="music-ticker">
|
||
<span class="music-icon">♫</span>
|
||
<div class="music-scroll-wrap">
|
||
<span class="music-text short" id="musicText">
|
||
<span id="musicArtist"></span><span class="music-sep" id="musicSep"></span><span id="musicTitle"></span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Lyrics display -->
|
||
<div class="lyric-display" id="lyricDisplay">
|
||
<div class="lyric-line" id="lyricLine"></div>
|
||
</div>
|
||
|
||
<!-- Scanlines overlay -->
|
||
<div class="scanlines"></div>
|
||
|
||
</div><!-- .scene -->
|
||
|
||
<script>
|
||
/* ============================================================
|
||
CONSTANTS
|
||
120 BPM → beat = 500ms. All timings derived from this.
|
||
============================================================ */
|
||
const BPM = 120;
|
||
const BEAT_MS = 60000 / BPM; // 500ms
|
||
|
||
/* ============================================================
|
||
STARFIELD
|
||
============================================================ */
|
||
const canvas = document.getElementById('starfield');
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
const STAR_COUNT = 220;
|
||
const stars = [];
|
||
|
||
const STAR_COLORS = [[255,221,170],[204,216,255],[238,238,255]];
|
||
|
||
function initStars() {
|
||
stars.length = 0;
|
||
for (let i = 0; i < STAR_COUNT; i++) {
|
||
const c = STAR_COLORS[Math.random() < 0.15 ? 0 : Math.random() < 0.5 ? 1 : 2];
|
||
stars.push({
|
||
x: Math.random() * canvas.width,
|
||
y: Math.random() * canvas.height * 0.62,
|
||
r: Math.random() * 1.6 + 0.2,
|
||
phase: Math.random() * Math.PI * 2,
|
||
freq: 0.4 + Math.random() * 1.2,
|
||
cr: c[0], cg: c[1], cb: c[2],
|
||
});
|
||
}
|
||
}
|
||
|
||
function resizeCanvas() {
|
||
canvas.width = window.innerWidth;
|
||
canvas.height = window.innerHeight;
|
||
initStars();
|
||
}
|
||
|
||
function drawStars(ts) {
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
const t = ts * 0.001;
|
||
stars.forEach(s => {
|
||
const alpha = (0.25 + 0.75 * (0.5 + 0.5 * Math.sin(t * s.freq + s.phase))).toFixed(2);
|
||
ctx.beginPath();
|
||
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
|
||
ctx.fillStyle = `rgba(${s.cr},${s.cg},${s.cb},${alpha})`;
|
||
ctx.fill();
|
||
});
|
||
requestAnimationFrame(drawStars);
|
||
}
|
||
|
||
window.addEventListener('resize', resizeCanvas);
|
||
|
||
/* ============================================================
|
||
FLOOR CANVAS — perspective grid with true vanishing point
|
||
============================================================ */
|
||
const floorCanvas = document.getElementById('floorCanvas');
|
||
const floorCtx = floorCanvas.getContext('2d');
|
||
|
||
const FLOOR_RATIO = 0.42;
|
||
const V_LINES = 24;
|
||
const H_LINES = 24;
|
||
let floorScroll = 0;
|
||
let lastFloorTs = 0;
|
||
let floorFadeGrad = null;
|
||
let floorFadeGradH = 0;
|
||
|
||
function resizeFloor() {
|
||
floorCanvas.width = window.innerWidth;
|
||
floorCanvas.height = Math.round(window.innerHeight * FLOOR_RATIO);
|
||
}
|
||
|
||
function drawFloor(ts) {
|
||
const dt = lastFloorTs ? Math.min((ts - lastFloorTs) / 1000, 0.1) : 0;
|
||
lastFloorTs = ts;
|
||
floorScroll = (floorScroll + 0.06 * dt) % 1;
|
||
|
||
const W = floorCanvas.width;
|
||
const H = floorCanvas.height;
|
||
const vx = W / 2;
|
||
|
||
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)';
|
||
floorCtx.lineWidth = 2;
|
||
for (let i = 0; i <= V_LINES; i++) {
|
||
const bx = spreadStart + (i / V_LINES) * spread;
|
||
floorCtx.beginPath();
|
||
floorCtx.moveTo(vx, 0);
|
||
floorCtx.lineTo(bx, H);
|
||
floorCtx.stroke();
|
||
}
|
||
|
||
// 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 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)})`;
|
||
floorCtx.lineWidth = Math.max(1, depth * 11);
|
||
floorCtx.beginPath();
|
||
floorCtx.moveTo(x1, y);
|
||
floorCtx.lineTo(x2, y);
|
||
floorCtx.stroke();
|
||
}
|
||
|
||
// Fade near horizon to hide sub-pixel lines (gradient cached, only rebuilt on resize)
|
||
if (!floorFadeGrad || floorFadeGradH !== H) {
|
||
floorFadeGradH = H;
|
||
floorFadeGrad = floorCtx.createLinearGradient(0, 0, 0, H * 0.3);
|
||
floorFadeGrad.addColorStop(0, '#000814');
|
||
floorFadeGrad.addColorStop(1, 'rgba(0,8,20,0)');
|
||
}
|
||
floorCtx.fillStyle = floorFadeGrad;
|
||
floorCtx.fillRect(0, 0, W, H * 0.3);
|
||
|
||
requestAnimationFrame(drawFloor);
|
||
}
|
||
|
||
window.addEventListener('resize', resizeFloor);
|
||
|
||
// WebEngine sometimes reports innerWidth=0 on first load — retry until valid.
|
||
function initCanvases() {
|
||
if (window.innerWidth > 0 && window.innerHeight > 0) {
|
||
resizeCanvas();
|
||
resizeFloor();
|
||
requestAnimationFrame(drawStars);
|
||
requestAnimationFrame(drawFloor);
|
||
} else {
|
||
setTimeout(initCanvases, 80);
|
||
}
|
||
}
|
||
initCanvases();
|
||
|
||
|
||
/* ============================================================
|
||
METEORS
|
||
============================================================ */
|
||
function spawnMeteor() {
|
||
const el = document.createElement('div');
|
||
el.className = 'meteor';
|
||
const x = Math.random() * 110 - 5; // % from left (can go off-edge)
|
||
const angle = 25 + Math.random() * 20; // degrees
|
||
const len = 80 + Math.random() * 140; // px
|
||
const dur = 1.2 + Math.random() * 1.8; // seconds
|
||
const color = `rgba(${180+Math.random()*75|0},${180+Math.random()*75|0},255,0.9)`;
|
||
el.style.cssText = `
|
||
--rot:${angle}deg;
|
||
--dist:${(window.innerHeight + len * 2)}px;
|
||
left:${x}%; top:${-len}px;
|
||
height:${len}px;
|
||
background:linear-gradient(to bottom, transparent, ${color});
|
||
animation-duration:${dur}s;
|
||
animation-name:meteorFall;
|
||
`;
|
||
document.getElementById('meteorLayer').appendChild(el);
|
||
setTimeout(() => el.remove(), dur * 1000);
|
||
}
|
||
|
||
// Spawn 1-2 meteors every 4-10 seconds
|
||
(function meteorLoop() {
|
||
spawnMeteor();
|
||
if (Math.random() < 0.3) setTimeout(spawnMeteor, 300);
|
||
setTimeout(meteorLoop, 4000 + Math.random() * 6000);
|
||
})();
|
||
|
||
|
||
|
||
/* ============================================================
|
||
VU METER (bottom of screen)
|
||
============================================================ */
|
||
const VU_COUNT = 38;
|
||
const vuMeter = document.getElementById('vuMeter');
|
||
const vuBars = [];
|
||
|
||
const VU_PALETTE = [
|
||
'#00ffcc','#00eebb','#00ddaa','#00cc99','#00bb88',
|
||
'#00aaff','#0099ee','#0088dd','#0077cc','#0066bb',
|
||
'#0099ff','#0088ee','#0077dd',
|
||
'#ff44aa','#ff55bb','#ff66cc','#ff77dd',
|
||
'#ff66cc','#ff55bb','#ff44aa',
|
||
'#0077dd','#0088ee','#0099ff',
|
||
'#0066bb','#0077cc','#0088dd','#0099ee','#00aaff',
|
||
'#00bb88','#00cc99','#00ddaa','#00eebb','#00ffcc',
|
||
'#00eebb','#00ddaa','#00cc99','#00bb88',
|
||
];
|
||
|
||
const MAX_BAR_H = 70;
|
||
|
||
for (let i = 0; i < VU_COUNT; i++) {
|
||
const bar = document.createElement('div');
|
||
bar.className = 'vu-bar';
|
||
bar.style.background = VU_PALETTE[i % VU_PALETTE.length];
|
||
bar.style.boxShadow = `0 0 4px ${VU_PALETTE[i % VU_PALETTE.length]}`;
|
||
bar.style.height = '3px';
|
||
bar.style.opacity = '0.15';
|
||
vuMeter.appendChild(bar);
|
||
vuBars.push(bar);
|
||
}
|
||
|
||
// Beat detection
|
||
const BASS_BARS = 4;
|
||
let bassSmooth = 0, bassEma = 0;
|
||
const beatWrap = document.querySelector('.beat-wrap');
|
||
const horizonEl = document.querySelector('.horizon');
|
||
|
||
function detectBeat(vals) {
|
||
const bass = vals.slice(0, BASS_BARS).reduce((s, v) => s + v, 0) / BASS_BARS;
|
||
bassSmooth = bassSmooth * 0.6 + bass * 0.4;
|
||
bassEma = bassEma * 0.96 + bass * 0.04;
|
||
}
|
||
|
||
// Called with semicolon-separated CAVA values (0–100 per bar) — SSE may fire >60fps,
|
||
// so we buffer and flush once per animation frame to avoid mid-frame DOM thrashing.
|
||
let pendingCava = null;
|
||
|
||
function updateBars(raw) {
|
||
pendingCava = raw;
|
||
}
|
||
|
||
function flushBars() {
|
||
if (pendingCava && isPlaying) {
|
||
const vals = pendingCava.split(';').map(v => Math.min(100, parseInt(v, 10) || 0));
|
||
detectBeat(vals);
|
||
vuBars.forEach((bar, i) => {
|
||
const v = vals[i] || 0;
|
||
bar.style.height = Math.max(3, (v / 100) * MAX_BAR_H) + 'px';
|
||
bar.style.opacity = 0.3 + 0.7 * (v / 100);
|
||
});
|
||
pendingCava = null;
|
||
}
|
||
requestAnimationFrame(flushBars);
|
||
}
|
||
requestAnimationFrame(flushBars);
|
||
|
||
/* ============================================================
|
||
MUSIC-LIGHT THEME (album color)
|
||
============================================================ */
|
||
function applyTheme(rgb) {
|
||
const [r, g, b] = rgb;
|
||
document.documentElement.style.setProperty('--theme-color', `rgba(${r},${g},${b},1)`);
|
||
horizonEl.style.background = `linear-gradient(to right,
|
||
transparent 0%,
|
||
rgba(${r},${g},${b},0.7) 20%,
|
||
rgba(${r},${g},${b},1) 50%,
|
||
rgba(${r},${g},${b},0.7) 80%,
|
||
transparent 100%)`;
|
||
horizonEl.style.boxShadow =
|
||
`0 0 30px rgba(${r},${g},${b},0.9), 0 0 90px rgba(${r},${g},${b},0.3)`;
|
||
vuBars.forEach(bar => {
|
||
bar.style.background = `rgb(${r},${g},${b})`;
|
||
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 fogWisps = document.querySelectorAll('.fog-wisp');
|
||
const lyricDisplay = document.getElementById('lyricDisplay');
|
||
const lyricLineEl = document.getElementById('lyricLine');
|
||
|
||
let lastArtUrl = '';
|
||
let lastLyricLine = '';
|
||
|
||
function fetchCurrent() {
|
||
fetch('http://127.0.0.1:8765/current')
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (d.rgb) applyTheme(d.rgb);
|
||
if (d.art_url && d.art_url !== lastArtUrl) {
|
||
lastArtUrl = d.art_url;
|
||
document.getElementById('albumArtImg').src =
|
||
'http://127.0.0.1:8765/art?' + Date.now();
|
||
}
|
||
const line = d.lyric_line || '';
|
||
if (line !== lastLyricLine) {
|
||
lastLyricLine = line;
|
||
lyricLineEl.textContent = line;
|
||
}
|
||
lyricDisplay.style.opacity = (isPlaying && line) ? '1' : '0';
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
|
||
setInterval(() => { if (isPlaying) fetchCurrent(); }, 700);
|
||
|
||
// Connect to CAVA SSE server (started by cava-start.sh via QML)
|
||
(function connectCava() {
|
||
const sse = new EventSource('http://127.0.0.1:5555');
|
||
sse.onmessage = e => { if (e.data) updateBars(e.data); };
|
||
sse.onerror = () => { sse.close(); setTimeout(connectCava, 3000); };
|
||
})();
|
||
|
||
/* ============================================================
|
||
FLOATING MUSIC NOTES
|
||
============================================================ */
|
||
const NOTES = ['♪','♫','♩','♬','♭','♮'];
|
||
const N_CLR = ['#ff44aa','#ff88cc','#cc44ff','#ff66bb','#aa44ff','#ff55dd'];
|
||
const scene = document.querySelector('.scene');
|
||
|
||
function spawnNote() {
|
||
const el = document.createElement('div');
|
||
el.className = 'music-note';
|
||
el.textContent = NOTES[Math.floor(Math.random() * NOTES.length)];
|
||
const clr = N_CLR[Math.floor(Math.random() * N_CLR.length)];
|
||
el.style.color = clr;
|
||
el.style.textShadow = `0 0 12px ${clr}, 0 0 24px ${clr}`;
|
||
el.style.fontSize = (18 + Math.random() * 18) + 'px';
|
||
/* Scatter notes around the robot */
|
||
el.style.left = (42 + Math.random() * 16) + '%';
|
||
el.style.bottom = (42 + Math.random() * 6) + '%';
|
||
const dur = 1.6 + Math.random() * 1.2;
|
||
el.style.animationDuration = dur + 's';
|
||
scene.appendChild(el);
|
||
setTimeout(() => el.remove(), dur * 1000 + 100);
|
||
}
|
||
|
||
let isPlaying = false;
|
||
|
||
/* Spawn on every beat (500ms) with a 50 % chance of two notes */
|
||
setInterval(() => {
|
||
if (!isPlaying) return;
|
||
spawnNote();
|
||
if (Math.random() < 0.5) spawnNote();
|
||
}, BEAT_MS);
|
||
|
||
/* ============================================================
|
||
MUSIC INFO (called by QML via runJavaScript)
|
||
============================================================ */
|
||
function updateMusic(artist, title) {
|
||
const el = document.getElementById('musicText');
|
||
const artEl = document.getElementById('musicArtist');
|
||
const titleEl = document.getElementById('musicTitle');
|
||
const sepEl = document.getElementById('musicSep');
|
||
const playing = !!(artist || title);
|
||
isPlaying = playing;
|
||
document.querySelector('.robot-wrap').style.opacity = playing ? '1' : '0';
|
||
document.querySelector('.music-ticker').style.opacity = playing ? '1' : '0';
|
||
albumArtEl.style.opacity = playing ? '1' : '0';
|
||
if (!playing) vuBars.forEach(b => { b.style.height = '3px'; b.style.opacity = '0.15'; });
|
||
if (playing) fetchCurrent();
|
||
const key = artist + '|' + title;
|
||
if (el.dataset.key === key) return;
|
||
el.dataset.key = key;
|
||
artEl.textContent = artist || '';
|
||
sepEl.textContent = (artist && title) ? ' — ' : '';
|
||
titleEl.textContent = title || (!artist ? '— —' : '');
|
||
if (playing) pickSkeleton();
|
||
const wrap = el.parentElement;
|
||
el.classList.toggle('short', el.scrollWidth <= wrap.clientWidth + 10);
|
||
}
|
||
|
||
/* ============================================================
|
||
DANCE STYLE MACHINE
|
||
Cycles: bounce → wave → shuffle → moonwalk → spin → repeat
|
||
Each style lasts 8s (= 16 beats at 120 BPM)
|
||
============================================================ */
|
||
const DANCE_STYLES = ['bounce', 'wave', 'shuffle', 'moonwalk', 'spin'];
|
||
const STYLE_LABELS = ['BOUNCE', 'WAVE', 'SHUFFLE', 'MOONWALK', 'SPIN'];
|
||
let danceIdx = 0;
|
||
|
||
function nextDanceStyle() {
|
||
danceIdx = (danceIdx + 1) % DANCE_STYLES.length;
|
||
document.body.setAttribute('data-dance', DANCE_STYLES[danceIdx]);
|
||
}
|
||
|
||
// Start on default bounce
|
||
document.body.setAttribute('data-dance', 'bounce');
|
||
setInterval(nextDanceStyle, 8000);
|
||
|
||
function pickSkeleton() {}
|
||
|
||
|
||
/* ============================================================
|
||
DEV SHIM — only active when served via HTTP (not from QML)
|
||
Simulates playerctl updates so the music ticker works in-browser.
|
||
============================================================ */
|
||
if (window.location.protocol !== 'file:' && !window.qt) {
|
||
const devTracks = [
|
||
['Daft Punk', 'Get Lucky'],
|
||
['Kavinsky', 'Nightcall'],
|
||
['Justice', 'D.A.N.C.E.'],
|
||
['M83', 'Midnight City'],
|
||
['Chromatics', 'Shadow'],
|
||
];
|
||
let devIdx = 0;
|
||
function devTick() {
|
||
const [artist, title] = devTracks[devIdx % devTracks.length];
|
||
updateMusic(artist, title);
|
||
devIdx++;
|
||
}
|
||
devTick();
|
||
setInterval(devTick, 5000);
|
||
}
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|