Add worker orbs animation to dashboard header

Worker buttons shrink to floating colored orbs with physics-based
movement, spark emissions from active workers, connection lines, and
center gravity. Hovering the header expands orbs back to full buttons
with staggered spring animation. Desktop only, toggleable in Settings
under UI Appearance.
This commit is contained in:
Broque Thomas 2026-03-16 16:47:37 -07:00
parent 42a4285e09
commit 775307c2b5
4 changed files with 661 additions and 2 deletions

View file

@ -4476,6 +4476,13 @@
</label>
<small class="settings-hint">Animated particle effects behind each page. Disable to reduce GPU usage.</small>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="worker-orbs-enabled" checked>
Worker Orbs
</label>
<small class="settings-hint">Dashboard header buttons animate as floating orbs. Hover the header to expand. Desktop only.</small>
</div>
</div>
<!-- Database Settings -->
@ -5867,6 +5874,7 @@
<script src="{{ url_for('static', filename='script.js') }}"></script>
<script src="{{ url_for('static', filename='docs.js') }}"></script>
<script src="{{ url_for('static', filename='particles.js') }}"></script>
<script src="{{ url_for('static', filename='worker-orbs.js') }}"></script>
</body>
</html>

View file

@ -730,6 +730,21 @@ function applyParticlesSetting(enabled) {
localStorage.setItem('soulsync-particles', String(enabled));
}
function applyWorkerOrbsSetting(enabled) {
window._workerOrbsEnabled = enabled;
localStorage.setItem('soulsync-worker-orbs', String(enabled));
if (window.workerOrbs) {
if (enabled) {
const activePage = document.querySelector('.page.active');
if (activePage && activePage.id === 'dashboard-page') {
window.workerOrbs.setPage('dashboard');
}
} else {
window.workerOrbs.setPage('_disabled');
}
}
}
function initAccentColorListeners() {
const presetSelect = document.getElementById('accent-preset');
const customGroup = document.getElementById('custom-color-group');
@ -758,7 +773,14 @@ function initAccentColorListeners() {
if (particlesCheckbox) {
particlesCheckbox.addEventListener('change', () => {
applyParticlesSetting(particlesCheckbox.checked);
// Server save handled by the global debouncedAutoSaveSettings listener on all checkboxes
});
}
// Worker orbs toggle — apply immediately on change
const workerOrbsCheckbox = document.getElementById('worker-orbs-enabled');
if (workerOrbsCheckbox) {
workerOrbsCheckbox.addEventListener('change', () => {
applyWorkerOrbsSetting(workerOrbsCheckbox.checked);
});
}
}
@ -774,6 +796,11 @@ function initAccentColorListeners() {
const canvas = document.getElementById('page-particles-canvas');
if (canvas) canvas.style.display = 'none';
}
// Bootstrap worker orbs setting from localStorage
const workerOrbsSaved = localStorage.getItem('soulsync-worker-orbs');
if (workerOrbsSaved === 'false') {
window._workerOrbsEnabled = false;
}
})();
// ── Profile System ─────────────────────────────────────────────
@ -2061,6 +2088,9 @@ function navigateToPage(pageId) {
// Update page background particles
if (window.pageParticles && window._particlesEnabled !== false) window.pageParticles.setPage(pageId);
// Update worker orbs
if (window.workerOrbs) window.workerOrbs.setPage(pageId);
}
// REPLACE your old loadPageData function with this one:
@ -4905,6 +4935,12 @@ async function loadSettingsData() {
if (particlesCheckbox) particlesCheckbox.checked = particlesEnabled;
applyParticlesSetting(particlesEnabled);
// Worker orbs toggle
const workerOrbsEnabled = settings.ui_appearance?.worker_orbs_enabled !== false; // default true
const workerOrbsCheckbox = document.getElementById('worker-orbs-enabled');
if (workerOrbsCheckbox) workerOrbsCheckbox.checked = workerOrbsEnabled;
applyWorkerOrbsSetting(workerOrbsEnabled);
// Populate Logging information (read-only)
document.getElementById('log-level-display').textContent = settings.logging?.level || 'INFO';
document.getElementById('log-path-display').textContent = settings.logging?.path || 'logs/app.log';
@ -5713,7 +5749,8 @@ async function saveSettings(quiet = false) {
accent_preset: document.getElementById('accent-preset')?.value || '#1db954',
accent_color: document.getElementById('accent-custom-color')?.value || '#1db954',
sidebar_visualizer: document.getElementById('sidebar-visualizer-type')?.value || 'bars',
particles_enabled: document.getElementById('particles-enabled')?.checked !== false
particles_enabled: document.getElementById('particles-enabled')?.checked !== false,
worker_orbs_enabled: document.getElementById('worker-orbs-enabled')?.checked !== false
},
youtube: {
cookies_browser: document.getElementById('youtube-cookies-browser').value,

View file

@ -5672,6 +5672,28 @@ body {
flex-wrap: wrap;
}
/* ── Worker Orbs ── */
.worker-orb-canvas {
transition: opacity 0.4s ease;
}
.worker-orb-hidden {
opacity: 0 !important;
pointer-events: none !important;
transition: opacity 0.25s ease;
}
.worker-orb-reveal {
opacity: 1 !important;
pointer-events: auto !important;
animation: workerOrbReveal 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
@keyframes workerOrbReveal {
0% { opacity: 0; transform: scale(0.3); }
100% { opacity: 1; transform: scale(1); }
}
.header-button {
height: 45px;
border: none;

592
webui/static/worker-orbs.js Normal file
View file

@ -0,0 +1,592 @@
// ============================================================
// 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: '.repair-button-container', color: [180, 130, 255] },
];
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 = [];
// ── 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,
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;
}
// ── Spark system ──
function emitSpark(orb) {
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: 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;
if (state === 'orbs' || state === 'collapsing') {
state = 'expanding';
expandProgress = 0;
}
}
function onMouseLeave() {
if (!onDashboard) return;
if (state === 'expanded' || state === 'expanding') {
enterCollapsingState();
}
}
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);
}
}
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.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.color;
const [r2, g2, b2] = 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) {
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);
}
})();