skeledance/plugin/contents/ui/dancer.html
ltadeu6 93c019c02e
fix: use img tag for album art instead of CSS background-image
CSS background-image url() from http:// is blocked in WebEngine local
context even when fetch() works; <img src> does not have this restriction.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 10:33:26 -03:00

1202 lines
34 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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; }
}
@keyframes horizonBeat {
0% { opacity: 1; box-shadow: 0 0 60px var(--theme-color, rgba(0,220,255,1)), 0 0 140px var(--theme-color, rgba(0,200,255,0.6)), 0 0 8px #fff; filter: brightness(2.5); }
100% { opacity: 0.75; box-shadow: 0 0 25px rgba(0,220,255,0.8), 0 0 80px rgba(0,200,255,0.25); filter: brightness(1); }
}
.horizon.beat {
animation: horizonBeat 0.45s ease-out forwards;
}
/* ============================================================
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: 110px;
height: 110px;
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-label {
color: rgba(0, 200, 255, 0.6);
font-family: 'Courier New', monospace;
font-size: clamp(9px, 1vw, 13px);
letter-spacing: 3px;
flex-shrink: 0;
}
.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; }
@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; }
}
/* ============================================================
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: 32%;
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: 40vh;
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); }
}
/* ROBOT SHADOW (ellipse on the floor) */
.robot-shadow {
position: absolute;
bottom: -28px;
left: 50%;
transform: translateX(-50%);
width: 130px; height: 22px;
background: radial-gradient(ellipse, rgba(0,100,255,0.38) 0%, transparent 68%);
border-radius: 50%;
animation: shadowAnim 0.5s ease-in-out infinite alternate;
pointer-events: none;
}
@keyframes shadowAnim {
from { transform: translateX(-50%) scaleX(0.75) scaleY(0.6); opacity: 0.35; }
to { transform: translateX(-50%) scaleX(1.3) scaleY(1.6); opacity: 0.85; }
}
</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>
<!-- Retrowave grid floor -->
<canvas id="floorCanvas"></canvas>
<!-- Horizon glow -->
<div class="horizon"></div>
<!-- Meteor layer (masked at horizon) -->
<div id="meteorLayer"></div>
<!-- ===================== ROBOT ===================== -->
<div class="robot-wrap">
<div class="beat-wrap">
<img src="skeleton4.apng" class="skeleton-gif" alt="skeleton">
<div class="robot-shadow"></div>
</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>
<span class="music-label">NOW PLAYING</span>
<div class="music-scroll-wrap">
<span class="music-text short" id="musicText">— —</span>
</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 = [];
function initStars() {
stars.length = 0;
for (let i = 0; i < STAR_COUNT; i++) {
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,
color: Math.random() < 0.15 ? '#ffddaa' : (Math.random() < 0.5 ? '#ccd8ff' : '#eeeeff'),
});
}
}
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));
ctx.beginPath();
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
ctx.fillStyle = s.color.replace(')', `, ${alpha.toFixed(2)})`)
.replace('rgb', 'rgba').replace('#ccd8ff', `rgba(204,216,255,${alpha.toFixed(2)})`)
.replace('#eeeeff', `rgba(238,238,255,${alpha.toFixed(2)})`)
.replace('#ffddaa', `rgba(255,221,170,${alpha.toFixed(2)})`);
ctx.fill();
});
requestAnimationFrame(drawStars);
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
requestAnimationFrame(drawStars);
/* ============================================================
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;
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
const fade = floorCtx.createLinearGradient(0, 0, 0, H * 0.3);
fade.addColorStop(0, '#000814');
fade.addColorStop(1, 'rgba(0,8,20,0)');
floorCtx.fillStyle = fade;
floorCtx.fillRect(0, 0, W, H * 0.3);
requestAnimationFrame(drawFloor);
}
window.addEventListener('resize', resizeFloor);
resizeFloor();
requestAnimationFrame(drawFloor);
/* ============================================================
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;
const BEAT_THRESH = 1.5;
const BEAT_COOLDOWN = 280;
let bassSmooth = 0, bassEma = 0, lastBeat = 0;
const beatWrap = document.querySelector('.beat-wrap');
const horizonEl = document.querySelector('.horizon');
function triggerBeat() {
horizonEl.classList.remove('beat');
void horizonEl.offsetWidth;
horizonEl.classList.add('beat');
}
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;
const now = performance.now();
if (bassSmooth > bassEma * BEAT_THRESH && bassSmooth > 8 && now - lastBeat > BEAT_COOLDOWN) {
lastBeat = now;
triggerBeat();
}
}
// Called with semicolon-separated CAVA values (0100 per bar)
function updateBars(raw) {
if (!isPlaying) return;
const vals = raw.split(';').map(v => Math.min(100, parseInt(v, 10) || 0));
detectBeat(vals);
// VU meter (low bars)
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);
});
}
/* ============================================================
MUSIC-LIGHT THEME (album color)
============================================================ */
function applyTheme(rgb) {
const [r, g, b] = rgb;
const color = `rgba(${r},${g},${b},1)`;
document.documentElement.style.setProperty('--theme-color', color);
// Tint horizon
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)`;
}
const albumArtEl = document.getElementById('albumArt');
let lastArtUrl = '';
function fetchTheme() {
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();
}
})
.catch(() => {});
}
// 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 text = (artist && title) ? `${artist}${title}`
: (title || artist || '— —');
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) fetchTheme();
if (el.textContent === text) return;
el.textContent = text;
if (playing) pickSkeleton();
// scroll only if text is long
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);
/* ============================================================
SKELETON SWITCHER
Add new files to SKELETONS to include them in the rotation.
Switches on music change and randomly every 16 beats (8s).
============================================================ */
const SKELETONS = ['skeleton2.apng', 'skeleton3.apng', 'skeleton4.apng', 'skeleton5.apng', 'skeleton6.apng'];
const skeletonImg = document.querySelector('.skeleton-gif');
let currentSkel = 2;
// Preload all skeletons so switches are instant
const _preloaded = SKELETONS.map(src => { const i = new Image(); i.src = src; return i; });
function pickSkeleton() {
if (SKELETONS.length < 2) return;
let next;
do { next = Math.floor(Math.random() * SKELETONS.length); }
while (next === currentSkel);
currentSkel = next;
skeletonImg.src = SKELETONS[currentSkel];
}
/* ============================================================
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>