diff --git a/tests/test_video_side_shell.py b/tests/test_video_side_shell.py index cace4d05..483a3bef 100644 --- a/tests/test_video_side_shell.py +++ b/tests/test_video_side_shell.py @@ -255,7 +255,12 @@ def test_video_enrichment_module_referenced_and_isolated(): src = (_ROOT / "webui" / "static" / "video" / "video-enrichment.js").read_text(encoding="utf-8") assert src.strip().startswith("/*") or src.strip().startswith("(function") assert "(function" in src and "})();" in src - assert "window." not in src + # The only window.* touch is the (optional) handoff to the VIDEO orbs global + # — never a music global, and it declares none of its own. + assert all( + ref == "window.videoWorkerOrbs" + for ref in re.findall(r"window\.\w+", src) + ) assert "/api/video/enrichment/" in src # No music API/function calls (a comment may mention the music *side*). assert "/api/enrichment/" not in src @@ -289,6 +294,34 @@ def test_video_settings_module_referenced_and_isolated(): assert "'change'" in _VSETTINGS_JS # saves on change, like the music selector +def test_video_worker_orbs_referenced_and_isolated(): + assert "video/video-worker-orbs.js" in _INDEX + src = (_ROOT / "webui" / "static" / "video" / "video-worker-orbs.js").read_text(encoding="utf-8") + assert "(function" in src and "})();" in src + # Its own global namespace — never music's window.workerOrbs. + assert "window.videoWorkerOrbs" in src + assert "window.workerOrbs" not in src + # Activates only on the video dashboard (own page-awareness, not setPage). + assert "soulsync:video-page-shown" in src + assert 'data-video-subpage="video-dashboard"' in src + assert 'data-video-enrich="tmdb"' in src and 'data-video-enrich="tvdb"' in src + assert "data-video-manage-workers" in src + # Same 7-second idle that triggers the floating-orb collapse as music. + assert "COLLAPSE_DELAY_MS = 7000" in src + # Reuses the shared, generic orb CSS classes (design parity, no fork). + assert "worker-orb-hidden" in src and "worker-orb-canvas" in src + # Does NOT reach into the music dashboard or the music API. + assert "#dashboard-page" not in src + assert "/api/enrichment/" not in src + + +def test_music_worker_orbs_untouched_by_video(): + # The video orbs are a separate file; the music orbs must not learn about + # the video side (one-way isolation — music never depends on video). + music = (_ROOT / "webui" / "static" / "worker-orbs.js").read_text(encoding="utf-8") + assert "video" not in music.lower() + + def test_controller_is_isolated_iife_with_no_globals(): stripped = _JS.strip() # Wrapped in an IIFE → declares no module-level globals that could collide diff --git a/webui/index.html b/webui/index.html index 1620ea78..1698bc03 100644 --- a/webui/index.html +++ b/webui/index.html @@ -9064,6 +9064,9 @@ + + diff --git a/webui/static/video/video-enrichment.js b/webui/static/video/video-enrichment.js index 0143585e..ee8b68b3 100644 --- a/webui/static/video/video-enrichment.js +++ b/webui/static/video/video-enrichment.js @@ -53,6 +53,12 @@ } } setText(tip, '[data-video-enrich-progress]', 'Progress: ' + matched + ' / ' + total); + + // Feed the idle worker-orbs animation real telemetry (isolated; no-op if + // the orbs script isn't present). Drives the inbound pulses to the hub. + if (window.videoWorkerOrbs && typeof window.videoWorkerOrbs.onStatus === 'function') { + window.videoWorkerOrbs.onStatus(svc, d); + } } function pollOne(svc) { diff --git a/webui/static/video/video-worker-orbs.js b/webui/static/video/video-worker-orbs.js new file mode 100644 index 00000000..8dda720e --- /dev/null +++ b/webui/static/video/video-worker-orbs.js @@ -0,0 +1,1132 @@ +// ============================================================ +// SoulSync — VIDEO Worker Orbs (isolated copy of the music orbs) +// +// ISOLATION: this is a byte-for-byte port of webui/static/worker-orbs.js, with +// ONLY the wiring changed — it targets the VIDEO dashboard header + the video +// enrichment buttons, uses its own window.videoWorkerOrbs global, and is +// activated by the video side's page events. The music orbs file is never +// touched and the two instances never share state. Same exact animation. +// +// Video 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 ── + // VIDEO side: the two enrichment buttons + the Manage Workers hub. `sel` + // matches the BUTTON; the orb's positioned element is its container. + const WORKER_DEFS = [ + { sel: '[data-video-enrich="tmdb"]', color: [56, 189, 248], id: 'tmdb' }, + { sel: '[data-video-enrich="tvdb"]', color: [168, 85, 247], id: 'tvdb' }, + { sel: '[data-video-manage-workers]', color: [168, 85, 247], hub: true }, + ]; + + const ERROR_COLOR = [255, 80, 80]; // pulses fired on real worker errors + const PULSE_CAP = 12; // max pulses queued per status update + // Status pushes arrive ~every 2.5s. Spread each window's pulses across that + // interval so they drip steadily instead of bursting on arrival. + const STATUS_FRAMES = 120; + const MIN_RELEASE_RATE = 1 / 45; // a lone event still appears within ~0.75s + + 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 + const MAX_INFLOWS = 48; // hub inbound-pulse pool cap + const INFLOW_RATE = 0.05; // chance per frame per active orb to send a pulse inward + + let dashboardHeader = null; + let headerActions = null; + let canvas = null; + let ctx = null; + let orbs = []; + let sparks = []; // particle emissions from active orbs + let inflows = []; // pulses traveling from active orbs into the hub + let ripples = []; // impact rings where a pulse lands on the nucleus + const MAX_RIPPLES = 24; + + // ── Sleep/wake ── + // With zero workers active (and no pulses in flight) for SLEEP_AFTER_MS, + // the system drowses: nucleus dims to embers, spokes fade, drift slows to + // a crawl. First sign of work snaps it awake (~0.3s) with a ripple bloom + // from the nucleus. The idle/busy contrast is what makes activity read. + const SLEEP_AFTER_MS = 75000; + let sleepLevel = 0; // 0 awake .. 1 asleep (eased, never snaps) + let lastActivityAt = performance.now(); + let wakeBurstDone = true; + let errorHeat = 0; // 0..1 aggregate "stress" — bumps on real worker errors, decays over time + let state = 'idle'; + let animFrame = null; + let onDashboard = false; + let expandProgress = 0; + let staggerTimers = []; + let collapseDelay = null; + const COLLAPSE_DELAY_MS = 7000; + + // SoulSync logo, drawn as the hub/nucleus once loaded + let hubImage = null; + let hubImageReady = false; + + // ── Init ── + + function init() { + const sub = document.querySelector('.video-subpage[data-video-subpage="video-dashboard"]'); + dashboardHeader = sub ? sub.querySelector('.dashboard-header') : null; + headerActions = sub ? sub.querySelector('.header-actions') : null; + if (!dashboardHeader || !headerActions) return; + + if (!hubImage) { + hubImage = new Image(); + hubImage.onload = () => { hubImageReady = true; }; + hubImage.src = '/static/trans2.png'; + } + + 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 found = headerActions.querySelector(def.sel); + if (!found) return; + // Orb's positioned element = the enrich container (so hiding it tucks + // the button + tooltip away); the hub button has no container. + const el = found.closest('.video-enrich-container') || found; + const btn = found.matches('button') ? found : found.querySelector('button'); + + orbs.push({ + el, + btn, + id: def.id || null, + color: def.color, + rainbow: def.rainbow || false, + hub: def.hub || 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, + // Pseudo-3D orbital depth: z oscillates -1 (behind the nucleus, + // smaller + dimmer) to +1 (in front, larger + brighter). Pure + // draw-time effect — the force physics stay 2D. + z: 0, + zPhase: Math.random() * Math.PI * 2, + zSpeed: 0.22 + Math.random() * 0.18, + kick: 0, // excitement-kick energy (decays) + active: false, + statusSeen: false, // has a real status arrived for this worker? + lastProcessed: 0, // cumulative matched+not_found seen last update + lastErrors: 0, // cumulative error count seen last update + pendingWork: 0, // brand-colour pulses still to release + pendingErr: 0, // red pulses still to release (real errors) + workRate: 0, // pulses/frame, set so pending drains over the interval + errRate: 0, + workCarry: 0, // fractional-pulse accumulators + errCarry: 0, + }); + }); + + computeHomes(); + centerOrbs(); + + 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 centerOrbs() { + // Spawn the whole cluster dead-center and let the physics bloom it + // outward. Positions EVERY orb (visible or not): the old random + // scatter skipped not-yet-visible orbs, so on page load they all sat + // at the canvas origin and drifted in from the top-left corner. + if (!canvas) return; + const w = canvas.clientWidth || 600; + const h = canvas.clientHeight || 80; + + orbs.forEach(orb => { + // A few px of jitter so the separation force can split the stack + // (it ignores pairs closer than 0.1px). + orb.x = w / 2 + (Math.random() - 0.5) * 6; + orb.y = h / 2 + (Math.random() - 0.5) * 6; + orb.vx = (Math.random() - 0.5) * 0.6; + orb.vy = (Math.random() - 0.5) * 0.6; + }); + } + + 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), + ]; + } + + // ── Glow sprite cache ── + // Radial gradients are the expensive part of canvas glows. Bake one soft + // glow sprite per colour into an offscreen canvas and blit it with + // drawImage — a single cheap GPU copy instead of allocating a gradient + // every frame. Colours are quantised to 8-step buckets to bound the cache + // (the tint shift is imperceptible in a glow, and keeps the rainbow path + // from minting a new sprite every frame). + const GLOW_SIZE = 64; + const _glowCache = new Map(); + + function getGlowSprite(r, g, b) { + const qr = r & ~7, qg = g & ~7, qb = b & ~7; + const key = (qr << 16) | (qg << 8) | qb; + let spr = _glowCache.get(key); + if (spr) return spr; + + spr = document.createElement('canvas'); + spr.width = spr.height = GLOW_SIZE; + const gctx = spr.getContext('2d'); + const c = GLOW_SIZE / 2; + const grad = gctx.createRadialGradient(c, c, 0, c, c, c); + grad.addColorStop(0, `rgba(${qr}, ${qg}, ${qb}, 1)`); + grad.addColorStop(1, `rgba(${qr}, ${qg}, ${qb}, 0)`); + gctx.fillStyle = grad; + gctx.fillRect(0, 0, GLOW_SIZE, GLOW_SIZE); + + _glowCache.set(key, spr); + return spr; + } + + // Blit a cached glow of the given radius/alpha centred at (x, y) + function drawGlow(ctx, x, y, radius, r, g, b, alpha) { + if (alpha <= 0 || radius <= 0) return; + ctx.globalAlpha = alpha; + ctx.drawImage(getGlowSprite(r, g, b), x - radius, y - radius, radius * 2, radius * 2); + ctx.globalAlpha = 1; + } + + // ── 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 (cached sprite) + drawGlow(ctx, s.x, s.y, radius * 3, r, g, b, alpha * 0.4); + + // Spark core + ctx.beginPath(); + ctx.arc(s.x, s.y, radius, 0, Math.PI * 2); + ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`; + ctx.fill(); + } + } + + // ── Inbound pulses (active worker → hub) ── + // Each carries an active worker's color into the nucleus, so the hub + // visibly "collects" the output of whatever is running. + + function emitInflow(orb, color) { + if (inflows.length >= MAX_INFLOWS) return; + inflows.push({ + orb, // source orb (positions resolved live) + color: color || orb.color, + t: 0, // 0 at source → 1 at hub + speed: 0.012 + Math.random() * 0.01, + }); + } + + function updateInflows(hub) { + for (let i = inflows.length - 1; i >= 0; i--) { + const p = inflows[i]; + p.t += p.speed; + if (p.t >= 1) { + // The pulse lands: ripple + a couple of debris sparks at the + // contact point on the nucleus rim, so the hub visibly absorbs + // it instead of the dot just vanishing. + if (hub) { + const dx = hub.x - p.orb.x, dy = hub.y - p.orb.y; + const d = Math.sqrt(dx * dx + dy * dy) || 1; + const ux = dx / d, uy = dy / d; + const rim = ORB_RADIUS + 4; + emitImpact(hub.x - ux * rim, hub.y - uy * rim, p.color, ux, uy); + } + inflows.splice(i, 1); + } + } + } + + // ── Impact ripples (pulse → nucleus contact) ── + + function emitImpact(x, y, color, dirX, dirY) { + if (ripples.length < MAX_RIPPLES) { + ripples.push({ x, y, color, age: 0, life: 26 + Math.random() * 8 }); + } + // Two debris sparks splash back roughly the way the pulse came, fanned. + for (let i = 0; i < 2 && sparks.length < MAX_SPARKS; i++) { + const spread = (Math.random() - 0.5) * 1.6; + const cos = Math.cos(spread), sin = Math.sin(spread); + const rx = -(dirX * cos - dirY * sin); + const ry = -(dirX * sin + dirY * cos); + const speed = 0.5 + Math.random() * 0.7; + sparks.push({ + x, y, vx: rx * speed, vy: ry * speed, + life: 0.8, decay: 0.03 + Math.random() * 0.02, + color, radius: 1 + Math.random() * 1.2, + }); + } + } + + function updateRipples() { + for (let i = ripples.length - 1; i >= 0; i--) { + if (++ripples[i].age >= ripples[i].life) ripples.splice(i, 1); + } + } + + function drawRipples(ctx) { + for (const rp of ripples) { + if (rp.age < 0) continue; // staggered (wake bloom) + const t = rp.age / rp.life; // 0 → 1 + const [r, g, b] = rp.color; + ctx.beginPath(); + ctx.arc(rp.x, rp.y, 3 + t * 11, 0, Math.PI * 2); + ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${(1 - t) * 0.45})`; + ctx.lineWidth = 1.5 * (1 - t) + 0.5; + ctx.stroke(); + } + } + + function drawInflows(ctx, hub) { + if (!hub) return; + for (const p of inflows) { + const [r, g, b] = p.color; + // Ease toward hub so pulses accelerate as they arrive + const e = p.t * p.t; + const x = p.orb.x + (hub.x - p.orb.x) * e; + const y = p.orb.y + (hub.y - p.orb.y) * e; + const alpha = 0.55 * (1 - Math.abs(p.t - 0.5) * 0.6); // fade in/out at the ends + const radius = 2.2; + + // Comet tail — ghost positions at trailing t values. The path is + // parametric, so no position history is needed; the same easing + // evaluated slightly in the past gives a tail that stretches as + // the pulse accelerates into the hub. + for (let i = 3; i >= 1; i--) { + const tt = p.t - i * p.speed * 2.4; + if (tt <= 0) continue; + const te = tt * tt; + const tx = p.orb.x + (hub.x - p.orb.x) * te; + const ty = p.orb.y + (hub.y - p.orb.y) * te; + ctx.beginPath(); + ctx.arc(tx, ty, Math.max(0.4, radius * (1 - i * 0.24)), 0, Math.PI * 2); + ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${alpha * (1 - i / 4) * 0.5})`; + ctx.fill(); + } + + drawGlow(ctx, x, y, radius * 3, r, g, b, alpha * 0.5); + + ctx.beginPath(); + ctx.arc(x, 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(); + // Dashboard (re)activation: the canvas just got its real size — init may + // have run while the header was hidden (0x0), leaving positions stale. + // Bloom the cluster from dead center instead of wherever that left them. + centerOrbs(); + 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(); + inflows = []; // drop in-flight pulses; positions are about to jump + ripples = []; + 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; + + let _scrollPauseUntil = 0; + (function attachScrollPause() { + const scroller = document.querySelector('.main-content') || window; + scroller.addEventListener('scroll', () => { + _scrollPauseUntil = performance.now() + 180; + }, { passive: true }); + })(); + + function startLoop() { + if (animFrame) return; + tick(); + } + + function stopLoop() { + if (animFrame) { + cancelAnimationFrame(animFrame); + animFrame = null; + } + } + + function tick() { + animFrame = requestAnimationFrame(tick); + if (!canvas || !ctx) return; + + // Yield the frame to active scrolling (orbs freeze, resume on idle). + if (performance.now() < _scrollPauseUntil) return; + + frameCount++; + // Fully asleep: render at ~20fps. The drift is at crawl speed so the + // difference is invisible, and the canvas GPU cost drops by two thirds + // for the hours the dashboard sits idle. Wakes re-run every frame. + if (sleepLevel > 0.95 && frameCount % 3 !== 0) return; + const time = frameCount / 60; + const w = canvas.width; + const h = canvas.height; + + if (w === 0 || h === 0) { + resizeCanvas(); + return; + } + + // Health stress cools off when errors stop (~6s to settle from a spike) + if (errorHeat > 0.0001) errorHeat *= 0.992; else errorHeat = 0; + + // Check active state every 30 frames (button ref is cached at init) + if (frameCount % 30 === 0) { + orbs.forEach(orb => { + orb.visible = orb.el.offsetParent !== null; + const nowActive = orb.btn ? orb.btn.classList.contains('active') : false; + if (nowActive && !orb.active && !orb.hub) { + // Excitement kick: a freshly-woken worker jolts into motion + // (brief overspeed + size wobble + a few sparks) instead of + // just getting brighter. Decays in physics/draw. + const ang = Math.random() * Math.PI * 2; + orb.vx += Math.cos(ang) * 1.6; + orb.vy += Math.sin(ang) * 1.6; + orb.kick = 1.0; + for (let i = 0; i < 3; i++) { + emitSpark(orb, orb.rainbow ? getRainbowColor(time) : null); + } + } + orb.active = nowActive; + }); + } + + const visibleOrbs = orbs.filter(o => o.visible); + const hub = visibleOrbs.find(o => o.hub); + + // Sleep/wake bookkeeping (see declarations for the design). + if (visibleOrbs.some(o => !o.hub && o.active) || inflows.length) { + lastActivityAt = performance.now(); + } + if (performance.now() - lastActivityAt > SLEEP_AFTER_MS) { + sleepLevel = Math.min(1, sleepLevel + 0.004); // ~4s drowse-in + wakeBurstDone = false; + } else { + if (!wakeBurstDone && sleepLevel > 0.4 && hub) { + // Wake bloom: three staggered rings out of the nucleus. + for (let i = 0; i < 3 && ripples.length < MAX_RIPPLES; i++) { + ripples.push({ + x: hub.x, y: hub.y, + color: hub.rainbow ? getRainbowColor(time) : hub.color, + age: -i * 6, life: 30, + }); + } + } + wakeBurstDone = true; + sleepLevel = Math.max(0, sleepLevel - 0.05); // fast wake (~0.3s) + } + + if (state === 'orbs' || state === 'collapsing') { + updatePhysics(visibleOrbs, w, h); + } else if (state === 'expanding') { + updateExpanding(visibleOrbs, w, h); + } + + // Sparks (ambient aura while active) + inbound pulses to the hub. + // Pulses are event-driven: one per real item matched / error reported, + // drained a couple per frame so bursts stagger nicely up the spoke. + for (const orb of visibleOrbs) { + if (orb.hub) continue; + + if (orb.active && Math.random() < SPARK_RATE) { + emitSpark(orb, orb.rainbow ? getRainbowColor(time) : null); + } + + if (!hub) continue; + + if (orb.statusSeen) { + // Release queued pulses at a steady drip so a window of events + // streams up the spoke instead of arriving all at once. + if (orb.pendingWork > 0) { + orb.workCarry += orb.workRate; + while (orb.workCarry >= 1 && orb.pendingWork > 0) { + emitInflow(orb, orb.rainbow ? getRainbowColor(time) : null); + orb.workCarry -= 1; orb.pendingWork -= 1; + } + } else { + orb.workCarry = 0; + } + if (orb.pendingErr > 0) { + orb.errCarry += orb.errRate; + while (orb.errCarry >= 1 && orb.pendingErr > 0) { + emitInflow(orb, ERROR_COLOR); + orb.errCarry -= 1; orb.pendingErr -= 1; + } + } else { + orb.errCarry = 0; + } + } else if (orb.active && Math.random() < INFLOW_RATE) { + // No real status yet — keep the old ambient trickle as fallback + emitInflow(orb, orb.rainbow ? getRainbowColor(time) : null); + } + } + updateSparks(); + updateInflows(hub); + updateRipples(); + + // Draw + ctx.clearRect(0, 0, w, h); + + drawConnections(ctx, visibleOrbs, time); + drawSparks(ctx); + drawInflows(ctx, hub); + drawRipples(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) { + // The hub is a nucleus — it settles at canvas center and stays put + // while every worker orb drifts around it. No jitter, strong pull home. + if (orb.hub) { + orb.vx += (cx - orb.x) * 0.02; + orb.vy += (cy - orb.y) * 0.02; + orb.vx *= 0.85; + orb.vy *= 0.85; + orb.x += orb.vx; + orb.y += orb.vy; + continue; + } + + // 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.004; + orb.vx += (gx / gDist) * gStrength; + orb.vy += (gy / gDist) * gStrength; + + // Orbital rotation — a tangential nudge (perpendicular to the + // pull home) so the cluster slowly revolves around the nucleus + // like electrons round an atom. Stronger when the orb is active. + const tStrength = orb.active ? 0.008 : 0.005; + orb.vx += (-gy / gDist) * tStrength; + orb.vy += (gx / gDist) * tStrength; + } + + // Damping + orb.vx *= 0.993; + orb.vy *= 0.993; + + // Speed cap — active orbs move a bit faster; an excitement kick + // briefly lifts the cap so the jolt actually darts, then decays. + orb.kick = (orb.kick || 0) * 0.94; + const maxSpeed = (orb.active ? 0.8 : 0.5) * (1 + orb.kick * 2); + 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 — asleep, the drift slows to a crawl (velocities keep + // integrating so motion stays continuous through wake) + const drowse = 1 - sleepLevel * 0.75; + orb.x += orb.vx * drowse; + orb.y += orb.vy * drowse; + + // 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) { + // ── Depth pass ── + // Each worker orb drifts on a slow z-oscillation; the hub sits at z=0. + // Painter's order (back → front) makes orbs visibly pass BEHIND the + // nucleus and swing back in front — the flat drift reads as an atom. + // The effect eases out during the hover-expand morph so orbs land on + // their buttons at natural size. + const depthFade = state === 'expanding' ? (1 - expandProgress) : 1; + for (const orb of visible) { + orb.z = orb.hub ? 0 : Math.sin(time * orb.zSpeed + orb.zPhase) * depthFade; + } + const ordered = [...visible].sort((a, b) => a.z - b.z); + + for (const orb of ordered) { + const [r, g, b] = orb.rainbow ? getRainbowColor(time) : orb.color; + // -1 (back): ~18% smaller, dimmer. +1 (front): ~18% larger, full. + // Sleep folds in as a further global dim (embers, not blackout). + const dScale = 1 + orb.z * 0.18; + const dAlpha = (0.78 + 0.22 * ((orb.z + 1) / 2)) * (1 - sleepLevel * 0.45); + + // ── The hub: an energy-reactive nucleus ── + // Calm + dim when nothing's running; bigger, brighter and faster + // the more workers are active. The animation reads as a gauge. + if (orb.hub) { + const workers = visible.filter(o => !o.hub); + const activeCount = workers.filter(o => o.active).length; + const energy = workers.length ? activeCount / workers.length : 0; // 0..1 + const stress = errorHeat; // 0..1 health gauge + + // Health shows as a gentle, gradual warm-red shift in the + // nucleus — never a fast flicker. Stress does NOT speed up the + // heartbeat (that read as jitter); only the colour eases over. + const beatSpeed = 1.0 + energy * 1.4; + const slow = 0.5 + 0.5 * Math.sin(time * beatSpeed); + // Barely-there breathing — the nucleus is mostly steady + const hubR = (ORB_RADIUS + 3 + energy * 4) + slow * (0.6 + energy * 0.8); + const tint = stress * 0.55; // softened, never full alarm-red + const hr = Math.round(r + (235 - r) * tint); + const hg = Math.round(g + (60 - g) * tint); + const hb = Math.round(b + (60 - b) * tint); + + // Wide ambient glow — steady, only gently lifting with energy. + // Asleep, the nucleus dims to embers. + const ember = 1 - sleepLevel * 0.55; + const glowR = hubR * (4 + energy * 1.5); + drawGlow(ctx, orb.x, orb.y, glowR, hr, hg, hb, + (0.16 + energy * 0.16 + slow * 0.04 + stress * 0.08) * ember); + + if (hubImageReady) { + // SoulSync logo as the nucleus — fit to the pulsing radius while + // preserving the image's natural aspect ratio (no stretch) + const natW = hubImage.naturalWidth || 1; + const natH = hubImage.naturalHeight || 1; + const fit = (hubR * 3.2) / Math.max(natW, natH); + const dw = natW * fit; + const dh = natH * fit; + ctx.save(); + ctx.globalAlpha = Math.min(1, 0.9 + energy * 0.1 + slow * 0.03) * (1 - sleepLevel * 0.35); + ctx.drawImage(hubImage, orb.x - dw / 2, orb.y - dh / 2, dw, dh); + ctx.restore(); + } else { + // Fallback while the logo loads: solid bright core + highlight + ctx.beginPath(); + ctx.arc(orb.x, orb.y, hubR, 0, Math.PI * 2); + ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${0.8 + energy * 0.15})`; + ctx.fill(); + ctx.beginPath(); + ctx.arc(orb.x, orb.y, hubR * 0.5, 0, Math.PI * 2); + ctx.fillStyle = `rgba(255, 255, 255, ${0.3 + energy * 0.25 + slow * 0.2})`; + ctx.fill(); + } + + // A single, very faint expanding ring — only when workers are + // actually busy, and barely visible so it reads as a soft halo, + // not a throbbing pulse. + if (energy > 0.02) { + const ringPhase = (time * 0.35) % 1; + const ringR = hubR + ringPhase * hubR * 1.4; + ctx.beginPath(); + ctx.arc(orb.x, orb.y, ringR, 0, Math.PI * 2); + ctx.strokeStyle = `rgba(${hr}, ${hg}, ${hb}, ${(1 - ringPhase) * 0.08 * energy})`; + ctx.lineWidth = 1; + ctx.stroke(); + } + + // Health warning: a single soft red ring that breathes slowly + // (no flicker) and fades in/out gradually as stress rises/cools. + if (stress > 0.04) { + const warn = 0.5 + 0.5 * Math.sin(time * 1.4); + const wr = hubR + 3 + warn * 3; + ctx.beginPath(); + ctx.arc(orb.x, orb.y, wr, 0, Math.PI * 2); + ctx.strokeStyle = `rgba(255, 90, 90, ${stress * (0.12 + warn * 0.10)})`; + ctx.lineWidth = 1.5; + ctx.stroke(); + } + continue; + } + + 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); + } + // Excitement-kick wobble: fast, shallow, gone in under a second + if (orb.kick > 0.02) { + baseRadius += orb.kick * 2.5 * Math.sin(time * 14 + orb.phase); + } + + // Scale up during expand transition; depth scales the whole orb + const currentRadius = (state === 'expanding' + ? baseRadius + expandProgress * 4 + : baseRadius) * dScale; + + // 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) * dAlpha; + drawGlow(ctx, orb.x, orb.y, glowRadius, r, g, b, glowAlpha); + + // Core + const coreAlpha = (orb.active + ? 0.85 + pulse * 0.15 + : (0.3 + pulse * 0.08) * activeMult) * dAlpha; + 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) { + // Hub spokes — the nucleus is wired to every worker orb, full length, + // so it always reads as the center that "manages" the cluster. + const hub = visible.find(o => o.hub); + if (hub) { + const [hr, hg, hb] = hub.color; + for (const orb of visible) { + if (orb === hub) continue; + const dx = hub.x - orb.x; + const dy = hub.y - orb.y; + const dist = Math.sqrt(dx * dx + dy * dy) || 1; + // Gentle traveling pulse along each spoke (fades while asleep) + const flow = 0.5 + 0.5 * Math.sin(time * 2 - dist * 0.05); + const alpha = (0.10 + flow * 0.10 + (orb.active ? 0.06 : 0)) * (1 - sleepLevel * 0.7); + ctx.beginPath(); + ctx.moveTo(hub.x, hub.y); + ctx.lineTo(orb.x, orb.y); + ctx.strokeStyle = `rgba(${hr}, ${hg}, ${hb}, ${alpha})`; + ctx.lineWidth = orb.active ? 1.0 : 0.6; + ctx.stroke(); + } + } + + for (let i = 0; i < visible.length; i++) { + for (let j = i + 1; j < visible.length; j++) { + const a = visible[i], b = visible[j]; + if (a.hub || b.hub) continue; // hub spokes handled above + 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 * (1 - sleepLevel * 0.7); + + 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 (video side) ── + + function isEnabled() { + return window._videoWorkerOrbsEnabled !== false && !window._reduceEffectsActive; + } + + function onVideoDashboard() { + return document.body.getAttribute('data-side') === 'video' + && document.body.getAttribute('data-video-page') === 'video-dashboard' + && isEnabled(); + } + + function activate() { + if (onDashboard) return; + onDashboard = true; + computeHomes(); + resizeCanvas(); + sparks = []; + ripples = []; + enterOrbState(); + } + + function deactivate() { + if (!onDashboard) return; + onDashboard = false; + if (collapseDelay) { clearTimeout(collapseDelay); collapseDelay = null; } + stopLoop(); + state = 'idle'; + sparks = []; + ripples = []; + orbs.forEach(orb => { + orb.el.classList.remove('worker-orb-hidden', 'worker-orb-reveal'); + }); + if (canvas) { + canvas.style.display = 'none'; + canvas.style.opacity = '0'; + } + } + + function refresh() { + if (onVideoDashboard()) activate(); + else deactivate(); + } + + // ── Real telemetry → pulses ── + // Fed by video-enrichment.js's status poll. We diff the cumulative counters + // between updates and queue one inbound pulse per real item processed + // (brand colour) or error (red). No status yet → the loop falls back to an + // ambient trickle so active orbs still animate. + function onStatus(id, data) { + if (!id || !data) return; + const orb = orbs.find(o => o.id === id); + if (!orb) return; + + const s = data.stats || {}; + const num = (v) => (typeof v === 'number' && isFinite(v) ? v : 0); + const processed = num(s.matched) + num(s.not_found); + const errors = num(s.errors); + + if (!orb.statusSeen) { + // First sample is just a baseline — don't dump the whole backlog + orb.statusSeen = true; + orb.lastProcessed = processed; + orb.lastErrors = errors; + return; + } + + const dWork = processed - orb.lastProcessed; + const dErr = errors - orb.lastErrors; + orb.lastProcessed = processed; + orb.lastErrors = errors; + + // Queue the new events and (re)set a drip rate that empties the current + // backlog over the interval until the next push — steady stream, not a burst. + if (dWork > 0) { + orb.pendingWork = Math.min(PULSE_CAP, orb.pendingWork + dWork); + orb.workRate = Math.max(MIN_RELEASE_RATE, orb.pendingWork / STATUS_FRAMES); + } + if (dErr > 0) { + orb.pendingErr = Math.min(PULSE_CAP, orb.pendingErr + dErr); + orb.errRate = Math.max(MIN_RELEASE_RATE, orb.pendingErr / STATUS_FRAMES); + // Feed the nucleus health gauge — each real error eases the hub's + // stress up gradually. Small bump so it ramps in softly, never spikes. + errorHeat = Math.min(0.85, errorHeat + 0.1 * dErr); + } + } + + // ── Bootstrap ── + + function bootstrap() { + init(); + if (!dashboardHeader) return; + + window.videoWorkerOrbs = { refresh, onStatus }; + + // The video side announces page changes; switching back to the music + // side flips body[data-side]. Watch both so the orbs activate only on + // the video dashboard and tear down everywhere else. + document.addEventListener('soulsync:video-page-shown', refresh); + const mo = new MutationObserver(refresh); + mo.observe(document.body, { attributes: true, attributeFilter: ['data-side', 'data-video-page'] }); + + // Catch the case where we boot already on the video dashboard. + setTimeout(refresh, 300); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', bootstrap); + } else { + setTimeout(bootstrap, 100); + } + +})();