You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
SoulSync/webui/static/worker-orbs.js

635 lines
21 KiB

// ============================================================
// SoulSync — Worker Orbs
// Dashboard header buttons shrink to floating orbs, expand on hover
// ============================================================
(function () {
'use strict';
// Disable on mobile
if (window.innerWidth <= 768 || /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent)) return;
// ── Worker definitions with brand colors ──
const WORKER_DEFS = [
{ container: '.mb-button-container', color: [186, 85, 211] },
{ container: '.audiodb-button-container', color: [0, 188, 212] },
{ container: '.deezer-button-container', color: [162, 56, 255] },
{ container: '.spotify-enrich-button-container', color: [30, 215, 96] },
{ container: '.itunes-enrich-button-container', color: [251, 91, 137] },
{ container: '.lastfm-enrich-button-container', color: [213, 16, 7] },
{ container: '.genius-enrich-button-container', color: [255, 255, 100] },
{ container: '.tidal-enrich-button-container', color: [180, 180, 255] },
{ container: '.qobuz-enrich-button-container', color: [1, 112, 239] },
{ container: '.hydrabase-button-container', color: [200, 200, 200] },
{ container: '.soulid-button-container', color: [29, 185, 84], rainbow: true },
{ container: '.repair-button-container', color: [180, 130, 255], rainbow: true },
];
const ORB_RADIUS = 7;
const ORB_DIAMETER = ORB_RADIUS * 2;
const CONNECTION_DIST = 70;
const LERP_SPEED = 0.08;
const EXPAND_STAGGER = 35;
const MAX_SPARKS = 60; // global spark pool cap
const SPARK_RATE = 0.12; // chance per frame per active orb to emit
let dashboardHeader = null;
let headerActions = null;
let canvas = null;
let ctx = null;
let orbs = [];
let sparks = []; // particle emissions from active orbs
let state = 'idle';
let animFrame = null;
let onDashboard = false;
let expandProgress = 0;
let staggerTimers = [];
let collapseDelay = null;
const COLLAPSE_DELAY_MS = 7000;
// ── Init ──
function init() {
dashboardHeader = document.querySelector('#dashboard-page .dashboard-header');
headerActions = document.querySelector('#dashboard-page .header-actions');
if (!dashboardHeader || !headerActions) return;
canvas = document.createElement('canvas');
canvas.className = 'worker-orb-canvas';
canvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:5;';
dashboardHeader.appendChild(canvas);
ctx = canvas.getContext('2d');
WORKER_DEFS.forEach((def, i) => {
const el = headerActions.querySelector(def.container);
if (!el) return;
orbs.push({
el,
color: def.color,
rainbow: def.rainbow || false,
index: i,
x: 0, y: 0,
vx: (Math.random() - 0.5) * 0.6,
vy: (Math.random() - 0.5) * 0.6,
homeX: 0, homeY: 0,
visible: true,
phase: Math.random() * Math.PI * 2,
active: false,
});
});
computeHomes();
scatterOrbs();
dashboardHeader.addEventListener('mouseenter', onMouseEnter);
dashboardHeader.addEventListener('mouseleave', onMouseLeave);
window.addEventListener('resize', onResize);
document.addEventListener('visibilitychange', onVisibility);
}
function computeHomes() {
if (!dashboardHeader || !headerActions) return;
const headerRect = dashboardHeader.getBoundingClientRect();
orbs.forEach(orb => {
const elRect = orb.el.getBoundingClientRect();
orb.homeX = (elRect.left - headerRect.left) + elRect.width / 2;
orb.homeY = (elRect.top - headerRect.top) + elRect.height / 2;
orb.visible = orb.el.offsetParent !== null;
});
}
function scatterOrbs() {
if (!canvas) return;
const w = canvas.clientWidth || 600;
const h = canvas.clientHeight || 80;
orbs.forEach(orb => {
if (!orb.visible) return;
orb.x = ORB_RADIUS + Math.random() * (w - ORB_DIAMETER);
orb.y = ORB_RADIUS + Math.random() * (h - ORB_DIAMETER);
});
}
function resizeCanvas() {
if (!canvas) return;
canvas.width = canvas.clientWidth;
canvas.height = canvas.clientHeight;
}
// ── Rainbow color cycle (matches repair button's CSS rainbow) ──
const RAINBOW = [
[255, 0, 0],
[255, 136, 0],
[255, 255, 0],
[0, 255, 0],
[0, 136, 255],
[136, 0, 255],
];
function getRainbowColor(time) {
const t = ((time * 0.33) % 1 + 1) % 1; // ~3s cycle to match CSS 3s
const idx = t * RAINBOW.length;
const i = Math.floor(idx);
const f = idx - i;
const a = RAINBOW[i % RAINBOW.length];
const b = RAINBOW[(i + 1) % RAINBOW.length];
return [
Math.round(a[0] + (b[0] - a[0]) * f),
Math.round(a[1] + (b[1] - a[1]) * f),
Math.round(a[2] + (b[2] - a[2]) * f),
];
}
// ── Spark system ──
function emitSpark(orb, colorOverride) {
if (sparks.length >= MAX_SPARKS) return;
const angle = Math.random() * Math.PI * 2;
const speed = 0.4 + Math.random() * 0.8;
sparks.push({
x: orb.x,
y: orb.y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 1.0, // 1.0 → 0.0
decay: 0.012 + Math.random() * 0.012,
color: colorOverride || orb.color,
radius: 1.5 + Math.random() * 1.5,
});
}
function updateSparks() {
for (let i = sparks.length - 1; i >= 0; i--) {
const s = sparks[i];
s.x += s.vx;
s.y += s.vy;
s.vx *= 0.98;
s.vy *= 0.98;
s.life -= s.decay;
if (s.life <= 0) {
sparks.splice(i, 1);
}
}
}
function drawSparks(ctx) {
for (const s of sparks) {
const [r, g, b] = s.color;
const alpha = s.life * 0.6;
const radius = s.radius * s.life;
// Spark glow
const glow = ctx.createRadialGradient(s.x, s.y, 0, s.x, s.y, radius * 3);
glow.addColorStop(0, `rgba(${r}, ${g}, ${b}, ${alpha * 0.4})`);
glow.addColorStop(1, 'rgba(0,0,0,0)');
ctx.beginPath();
ctx.arc(s.x, s.y, radius * 3, 0, Math.PI * 2);
ctx.fillStyle = glow;
ctx.fill();
// Spark core
ctx.beginPath();
ctx.arc(s.x, s.y, radius, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`;
ctx.fill();
}
}
// ── State machine ──
function enterOrbState() {
if (state === 'orbs') return;
state = 'orbs';
expandProgress = 0;
orbs.forEach(orb => {
orb.el.classList.add('worker-orb-hidden');
});
canvas.style.opacity = '1';
canvas.style.display = '';
resizeCanvas();
startLoop();
}
function enterExpandedState() {
state = 'expanded';
expandProgress = 1;
clearStaggerTimers();
orbs.forEach((orb, i) => {
const t = setTimeout(() => {
orb.el.classList.remove('worker-orb-hidden');
orb.el.classList.add('worker-orb-reveal');
}, i * EXPAND_STAGGER);
staggerTimers.push(t);
});
canvas.style.opacity = '0';
setTimeout(() => {
if (state === 'expanded') {
canvas.style.display = 'none';
stopLoop();
}
}, 400);
}
function enterCollapsingState() {
state = 'collapsing';
clearStaggerTimers();
const total = orbs.length;
orbs.forEach((orb, i) => {
const t = setTimeout(() => {
orb.el.classList.remove('worker-orb-reveal');
orb.el.classList.add('worker-orb-hidden');
}, (total - 1 - i) * 20);
staggerTimers.push(t);
});
canvas.style.display = '';
canvas.style.opacity = '1';
resizeCanvas();
computeHomes();
orbs.forEach(orb => {
orb.x = orb.homeX;
orb.y = orb.homeY;
orb.vx = (Math.random() - 0.5) * 0.4;
orb.vy = (Math.random() - 0.5) * 0.4;
});
startLoop();
setTimeout(() => {
if (state === 'collapsing') {
state = 'orbs';
expandProgress = 0;
}
}, total * 20 + 100);
}
function clearStaggerTimers() {
staggerTimers.forEach(t => clearTimeout(t));
staggerTimers = [];
}
// ── Events ──
function onMouseEnter() {
if (!onDashboard) return;
// Cancel any pending collapse
if (collapseDelay) {
clearTimeout(collapseDelay);
collapseDelay = null;
}
if (state === 'orbs' || state === 'collapsing') {
state = 'expanding';
expandProgress = 0;
}
}
function onMouseLeave() {
if (!onDashboard) return;
if (state === 'expanded' || state === 'expanding') {
// Delay before collapsing back to orbs
if (collapseDelay) clearTimeout(collapseDelay);
collapseDelay = setTimeout(() => {
collapseDelay = null;
if (state === 'expanded' || state === 'expanding') {
enterCollapsingState();
}
}, COLLAPSE_DELAY_MS);
}
}
function onResize() {
computeHomes();
resizeCanvas();
const w = canvas ? canvas.width : 600;
const h = canvas ? canvas.height : 80;
orbs.forEach(orb => {
orb.x = Math.max(ORB_RADIUS, Math.min(w - ORB_RADIUS, orb.x));
orb.y = Math.max(ORB_RADIUS, Math.min(h - ORB_RADIUS, orb.y));
});
}
function onVisibility() {
if (document.hidden) {
stopLoop();
} else if (onDashboard && (state === 'orbs' || state === 'expanding' || state === 'collapsing')) {
startLoop();
}
}
// ── Animation loop ──
let frameCount = 0;
function startLoop() {
if (animFrame) return;
tick();
}
function stopLoop() {
if (animFrame) {
cancelAnimationFrame(animFrame);
animFrame = null;
}
}
function tick() {
animFrame = requestAnimationFrame(tick);
if (!canvas || !ctx) return;
frameCount++;
const time = frameCount / 60;
const w = canvas.width;
const h = canvas.height;
if (w === 0 || h === 0) {
resizeCanvas();
return;
}
// Check active state every 30 frames
if (frameCount % 30 === 0) {
orbs.forEach(orb => {
orb.visible = orb.el.offsetParent !== null;
const btn = orb.el.querySelector('button');
orb.active = btn ? btn.classList.contains('active') : false;
});
}
const visibleOrbs = orbs.filter(o => o.visible);
if (state === 'orbs' || state === 'collapsing') {
updatePhysics(visibleOrbs, w, h);
} else if (state === 'expanding') {
updateExpanding(visibleOrbs, w, h);
}
// Emit sparks from active orbs
for (const orb of visibleOrbs) {
if (orb.active && Math.random() < SPARK_RATE) {
emitSpark(orb, orb.rainbow ? getRainbowColor(time) : null);
}
}
updateSparks();
// Draw
ctx.clearRect(0, 0, w, h);
drawConnections(ctx, visibleOrbs, time);
drawSparks(ctx);
drawOrbs(ctx, visibleOrbs, time);
}
// ── Physics ──
function updatePhysics(visible, w, h) {
const cx = w * 0.5;
const cy = h * 0.5;
for (const orb of visible) {
// Active orbs drift faster
const driftStrength = orb.active ? 0.04 : 0.02;
orb.vx += (Math.random() - 0.5) * driftStrength;
orb.vy += (Math.random() - 0.5) * driftStrength;
// Subtle gravity toward center — keeps orbs loosely grouped
const gx = cx - orb.x;
const gy = cy - orb.y;
const gDist = Math.sqrt(gx * gx + gy * gy);
if (gDist > 1) {
const gStrength = 0.003;
orb.vx += (gx / gDist) * gStrength;
orb.vy += (gy / gDist) * gStrength;
}
// Damping
orb.vx *= 0.993;
orb.vy *= 0.993;
// Speed cap — active orbs move a bit faster
const maxSpeed = orb.active ? 0.8 : 0.5;
const speed = Math.sqrt(orb.vx * orb.vx + orb.vy * orb.vy);
if (speed > maxSpeed) {
const scale = maxSpeed / speed;
orb.vx *= scale;
orb.vy *= scale;
}
// Soft repulsion from other orbs
for (const other of visible) {
if (other === orb) continue;
const dx = orb.x - other.x;
const dy = orb.y - other.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 35 && dist > 0.1) {
const force = 0.03 * (1 - dist / 35);
orb.vx += (dx / dist) * force;
orb.vy += (dy / dist) * force;
}
}
// Move
orb.x += orb.vx;
orb.y += orb.vy;
// Boundary bounce
if (orb.x < ORB_RADIUS) { orb.x = ORB_RADIUS; orb.vx *= -0.7; }
if (orb.x > w - ORB_RADIUS) { orb.x = w - ORB_RADIUS; orb.vx *= -0.7; }
if (orb.y < ORB_RADIUS) { orb.y = ORB_RADIUS; orb.vy *= -0.7; }
if (orb.y > h - ORB_RADIUS) { orb.y = h - ORB_RADIUS; orb.vy *= -0.7; }
}
}
function updateExpanding(visible) {
let allClose = true;
for (const orb of visible) {
const dx = orb.homeX - orb.x;
const dy = orb.homeY - orb.y;
orb.x += dx * LERP_SPEED;
orb.y += dy * LERP_SPEED;
orb.vx *= 0.9;
orb.vy *= 0.9;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > 3) allClose = false;
}
expandProgress = Math.min(1, expandProgress + 0.03);
if (allClose || expandProgress >= 1) {
enterExpandedState();
}
}
// ── Drawing ──
function drawOrbs(ctx, visible, time) {
for (const orb of visible) {
const [r, g, b] = orb.rainbow ? getRainbowColor(time) : orb.color;
const pulse = 0.5 + 0.5 * Math.sin(time * 2 + orb.phase);
// Active orbs are larger and breathe — size oscillates
let baseRadius = orb.active ? ORB_RADIUS + 3 : ORB_RADIUS;
if (orb.active) {
baseRadius += 2 * Math.sin(time * 3 + orb.phase);
}
// Scale up during expand transition
const currentRadius = state === 'expanding'
? baseRadius + expandProgress * 4
: baseRadius;
// Inactive orbs are dimmer
const activeMult = orb.active ? 1.0 : 0.45;
// Outer glow — much larger and brighter for active
const glowRadius = orb.active ? currentRadius * 5 : currentRadius * 3;
const glowAlpha = orb.active
? (0.25 + pulse * 0.2) * activeMult
: (0.06 + pulse * 0.03) * activeMult;
const glow = ctx.createRadialGradient(orb.x, orb.y, 0, orb.x, orb.y, glowRadius);
glow.addColorStop(0, `rgba(${r}, ${g}, ${b}, ${glowAlpha})`);
glow.addColorStop(1, 'rgba(0,0,0,0)');
ctx.beginPath();
ctx.arc(orb.x, orb.y, glowRadius, 0, Math.PI * 2);
ctx.fillStyle = glow;
ctx.fill();
// Core
const coreAlpha = orb.active
? 0.85 + pulse * 0.15
: (0.3 + pulse * 0.08) * activeMult;
ctx.beginPath();
ctx.arc(orb.x, orb.y, Math.max(1, currentRadius), 0, Math.PI * 2);
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${coreAlpha})`;
ctx.fill();
// Inactive: subtle border ring so they're visible against dark backgrounds
if (!orb.active) {
ctx.beginPath();
ctx.arc(orb.x, orb.y, currentRadius, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${0.3 + pulse * 0.1})`;
ctx.lineWidth = 1;
ctx.stroke();
}
// Active: expanding pulse ring that fades
if (orb.active) {
// Inner ring — tight, bright
const ring1 = currentRadius + 2 + pulse * 3;
ctx.beginPath();
ctx.arc(orb.x, orb.y, ring1, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${0.25 + pulse * 0.15})`;
ctx.lineWidth = 1;
ctx.stroke();
// Outer ring — wide, faint, slower pulse
const pulse2 = 0.5 + 0.5 * Math.sin(time * 1.2 + orb.phase + 1);
const ring2 = currentRadius + 6 + pulse2 * 6;
ctx.beginPath();
ctx.arc(orb.x, orb.y, ring2, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${0.06 + pulse2 * 0.06})`;
ctx.lineWidth = 0.5;
ctx.stroke();
}
}
}
function drawConnections(ctx, visible, time) {
for (let i = 0; i < visible.length; i++) {
for (let j = i + 1; j < visible.length; j++) {
const a = visible[i], b = visible[j];
const dx = a.x - b.x;
const dy = a.y - b.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < CONNECTION_DIST) {
// Connections between active orbs are brighter
const activePair = a.active && b.active;
const anyActive = a.active || b.active;
const baseAlpha = activePair ? 0.3 : (anyActive ? 0.2 : 0.15);
const alpha = (1 - dist / CONNECTION_DIST) * baseAlpha;
const [r1, g1, b1] = a.rainbow ? getRainbowColor(time) : a.color;
const [r2, g2, b2] = b.rainbow ? getRainbowColor(time) : b.color;
const mr = (r1 + r2) >> 1;
const mg = (g1 + g2) >> 1;
const mb = (b1 + b2) >> 1;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.strokeStyle = `rgba(${mr}, ${mg}, ${mb}, ${alpha})`;
ctx.lineWidth = anyActive ? 0.8 : 0.5;
ctx.stroke();
}
}
}
}
// ── Page awareness ──
function isEnabled() {
return window._workerOrbsEnabled !== false;
}
function setPage(pageId) {
const wasDashboard = onDashboard;
onDashboard = (pageId === 'dashboard') && isEnabled();
if (onDashboard && !wasDashboard) {
computeHomes();
resizeCanvas();
sparks = [];
enterOrbState();
} else if (!onDashboard && wasDashboard) {
if (collapseDelay) { clearTimeout(collapseDelay); collapseDelay = null; }
stopLoop();
state = 'idle';
sparks = [];
orbs.forEach(orb => {
orb.el.classList.remove('worker-orb-hidden', 'worker-orb-reveal');
});
if (canvas) {
canvas.style.display = 'none';
canvas.style.opacity = '0';
}
}
}
// ── Bootstrap ──
function bootstrap() {
init();
if (!dashboardHeader) return;
window.workerOrbs = { setPage };
const activePage = document.querySelector('.page.active');
if (activePage && activePage.id === 'dashboard-page' && isEnabled()) {
setTimeout(() => {
computeHomes();
resizeCanvas();
enterOrbState();
onDashboard = true;
}, 300);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootstrap);
} else {
setTimeout(bootstrap, 100);
}
})();