backnforth/js/bls.js
type-two 4a46417cba Safety, privacy, session security, bug fixes, zero-install server
- First-run safety notice (photosensitivity, clinical use, privacy); glow
  rate capped at 2.5Hz below the photosensitive trigger band
- Patient data: initials/code hint, Export All / Wipe All buttons
- Relay sessions get crypto-random room ids instead of guessable room-1
- Fix: Pan Sync off now actually stops pink-noise panning
- Fix: slider drags no longer audibly restart the noise source
- Fix: switching pink->discrete preset no longer leaves noise running
- Fix: Speed Boost no longer compounds into stored speed across reloads
- Fix: patient/preset names HTML-escaped in lists
- Fix: patientViewport now relays over LAN (old server dropped it)
- server.py rewritten stdlib-only: static site + WS relay in one process,
  no pip/venv; setup.sh/bat deleted, start scripts are now 3 lines
- MIT LICENSE added; README rewritten with safety-first quick start

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:38:05 +10:00

343 lines
11 KiB
JavaScript

;(function(){
// --- Constants & Defaults ---
var defaultState = {
shape:'circle', shapeColor:'#00ff88', bgColor:'#000000', direction:'lr',
speed:250, speedMultiplier:1, size:28,
yPercent:50, yOscillateEnabled:false, yOscillateRate:0.5,
glowEnabled:false, glowIntensity:1, glowRate:0.5,
rampEnabled:false, rampSeconds:10, easingMode:'linear',
edgePauseMs:0,
wiggleEnabled:false, wiggleAmplitude:0,
audioMode:'programmatic', programPreset:'pink',
syncPan:true, panRate:0.5, cueRate:1.2, volume:0.6,
audioName:null, audioEnabled:false,
running:false, paused:false, pausedMs:0, pausedAt:null, durationSec:600, startedAt:null,
patientWidth:'full',
toneFreq:440, toneDurationMs:70, adsrAttackMs:8, adsrDecayMs:80, adsrSustainLevel:0.3, adsrReleaseMs:10
};
// --- Channel (Cross-Tab Communication) ---
function Channel(name){ this.name = name; this.handlers = []; }
Channel.prototype.on = function(handler){ this.handlers.push(handler) };
Channel.prototype.send = function(payload){
try{
localStorage.setItem('bls_session', JSON.stringify({name:this.name, payload:payload, t:Date.now()}));
} catch(e){}
};
window.addEventListener('storage', function(e){
if(e.key !== 'bls_session') return;
try {
var data = JSON.parse(e.newValue || '{}');
if(!data) return;
if(Channel._handlers){
var hs = Channel._handlers[data.name] || [];
for(var i=0; i<hs.length; i++){ hs[i](data.payload); }
}
} catch(e){}
});
Channel._handlers = {};
Channel.prototype.on = function(handler){
if(!Channel._handlers[this.name]) Channel._handlers[this.name] = [];
Channel._handlers[this.name].push(handler);
};
// --- State Persistence ---
function loadState(){
try{
var s = JSON.parse(localStorage.getItem('bls_state') || 'null');
if(!s) return Object.assign({}, defaultState);
return Object.assign({}, defaultState, s);
} catch(e){
return Object.assign({}, defaultState);
}
}
function saveState(s){
try{ localStorage.setItem('bls_state', JSON.stringify(s)); } catch(e){}
}
function mergeState(prev, next, persist){
var s = Object.assign({}, prev, next);
if(persist !== false) saveState(s);
return s;
}
// --- Presets ---
function presetKeys(){
return ['shape','shapeColor','bgColor','direction','speed','speedMultiplier','size','yPercent','glowEnabled','glowIntensity','glowRate','rampEnabled','rampSeconds','easingMode','edgePauseMs','wiggleEnabled','wiggleAmplitude','audioMode','programPreset','syncPan','panRate','cueRate','volume','audioName','audioEnabled','patientWidth','toneFreq','toneDurationMs','adsrAttackMs','adsrDecayMs','adsrSustainLevel','adsrReleaseMs'];
}
function sanitizePreset(s){
var out={}; var keys=presetKeys();
for(var i=0; i<keys.length; i++){
var k=keys[i];
if(Object.prototype.hasOwnProperty.call(s,k)) out[k]=s[k];
}
return out;
}
function loadPresets(){
try{
var m = JSON.parse(localStorage.getItem('bls_presets') || 'null');
return m && typeof m==='object' ? m : {};
} catch(e){ return {}; }
}
function savePresets(m){
try{ localStorage.setItem('bls_presets', JSON.stringify(m)); } catch(e){}
}
// --- Renderer ---
function Renderer(canvas){
var ctx = canvas.getContext('2d');
var state = loadState(); // Initial state loaded from storage
var pos = null;
var dir = {x:1, y:0};
var running = false;
var onUpdate = null;
var pauseMs = 0;
var wigglePhase = 0;
// Optimized: Reusable objects/vars to avoid GC
var _drawVars = {
x: 0, y: 0, r: 0, wx: 0, wy: 0,
doGlow: false, gi: 0, freq: 0, a: 0, hue: 0, t: 0, s: 0, h2: 0
};
function setDir(){
if(state.direction === 'lr'){ dir.x=1; dir.y=0; }
else if(state.direction === 'ud'){ dir.x=0; dir.y=1; }
else if(state.direction === 'diag_down'){ dir.x=1; dir.y=1; }
else { dir.x=1; dir.y=-1; }
var len = Math.sqrt(dir.x*dir.x + dir.y*dir.y);
dir.x /= len;
dir.y /= len;
}
function yFromPercent(h, r, p){
return Math.max(r, Math.min(h-r, r + (h-2*r) * (p/100)));
}
function ensurePos(){
var w=canvas.width, h=canvas.height, r=state.size;
var defPct = (state.direction==='lr'?50 : state.direction==='ud'?5 : state.direction==='diag_down'?5 : 95);
var pct = (state.yPercent===undefined || state.yPercent===null ? defPct : state.yPercent);
if(!pos){
if(state.direction === 'lr'){ pos = {x: r+2, y: yFromPercent(h,r,pct)}; }
else if(state.direction === 'ud'){ pos = {x: w/2, y: yFromPercent(h,r,pct)}; }
else { pos = {x: r+2, y: yFromPercent(h,r,pct)}; }
} else {
pos.y = yFromPercent(h, r, pct);
}
pos.x = Math.max(r, Math.min(w-r, pos.x));
pos.y = Math.max(r, Math.min(h-r, pos.y));
}
function clear(){
ctx.fillStyle = state.bgColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
function draw(){
var v = _drawVars;
v.r = state.size;
v.x = pos.x;
v.y = pos.y;
v.doGlow = !!state.glowEnabled;
v.gi = state.glowIntensity || 1;
v.t = Date.now()/1000;
v.wx = 0; v.wy = 0;
if(state.wiggleEnabled && state.wiggleAmplitude > 0){
wigglePhase += 0.04 + state.speed/4000;
v.s = Math.sin(wigglePhase);
if(state.direction === 'lr'){ v.wy = v.s * state.wiggleAmplitude; }
else if(state.direction === 'ud'){ v.wx = v.s * state.wiggleAmplitude; }
else { v.wx = -dir.y * v.s * state.wiggleAmplitude; v.wy = dir.x * v.s * state.wiggleAmplitude; }
}
if(v.doGlow){
v.freq = Math.max(0.05, state.glowRate || 0.5);
v.a = 0.5 + 0.5 * Math.sin(v.t * 2 * Math.PI * v.freq);
v.hue = (v.t * 60 * v.freq) % 360;
ctx.save();
ctx.shadowBlur = v.r * v.gi * (0.7 + v.a);
ctx.shadowColor = 'hsla(' + v.hue + ',100%,60%,0.9)';
}
ctx.fillStyle = state.shapeColor;
if(state.shape === 'circle'){
ctx.beginPath();
ctx.arc(v.x + v.wx, v.y + v.wy, v.r, 0, Math.PI*2);
ctx.closePath();
ctx.fill();
} else if(state.shape === 'square'){
ctx.fillRect(v.x - v.r + v.wx, v.y - v.r + v.wy, v.r*2, v.r*2);
} else {
v.h2 = v.r * 1.6;
ctx.beginPath();
ctx.moveTo(v.x + v.wx, v.y - v.h2/2 + v.wy);
ctx.lineTo(v.x - v.r + v.wx, v.y + v.h2/2 + v.wy);
ctx.lineTo(v.x + v.r + v.wx, v.y + v.h2/2 + v.wy);
ctx.closePath();
ctx.fill();
}
if(v.doGlow){ ctx.restore(); }
}
function effectiveSpeed(w, h, r){
// speed stays the slider's base value; the boost multiplier applies here,
// so presets and reloads never compound a pre-multiplied speed
var base = state.speed * (state.speedMultiplier || 1);
var ramp = 1;
if(state.rampEnabled && state.startedAt && state.rampSeconds > 0){
var dt = Math.max(0, (Date.now() - state.startedAt)/1000);
var k = Math.min(1, dt/state.rampSeconds);
ramp = 0.2 + 0.8 * k;
}
var s = 1;
if(state.easingMode === 'ease'){
if(dir.x !== 0){
var u = (pos.x - r) / (w - 2*r);
s = 0.3 + 0.7 * (0.5 - 0.5 * Math.cos(2 * Math.PI * Math.max(0, Math.min(1, u))));
} else {
var v = (pos.y - r) / (h - 2*r);
s = 0.3 + 0.7 * (0.5 - 0.5 * Math.cos(2 * Math.PI * Math.max(0, Math.min(1, v))));
}
}
return base * ramp * s;
}
function step(dt){
var w=canvas.width, h=canvas.height, r=state.size;
if(pauseMs > 0){
pauseMs = Math.max(0, pauseMs - dt*1000);
return;
}
var sp = effectiveSpeed(w, h, r);
pos.x += dir.x * sp * dt;
pos.y += dir.y * sp * dt;
var hit = false;
if(pos.x <= r){ pos.x = r; dir.x = Math.abs(dir.x); hit=true; }
if(pos.x >= w - r){ pos.x = w - r; dir.x = -Math.abs(dir.x); hit=true; }
if(pos.y <= r){ pos.y = r; dir.y = Math.abs(dir.y); hit=true; }
if(pos.y >= h - r){ pos.y = h - r; dir.y = -Math.abs(dir.y); hit=true; }
if(hit && state.edgePauseMs > 0){ pauseMs = state.edgePauseMs; }
if(onUpdate){ onUpdate({x:pos.x, y:pos.y, w:w, h:h}); }
}
var last = 0;
function loop(t){
if(!running) return;
if(!last) last = t;
var dt = (t - last)/1000;
last = t;
clear();
draw();
step(dt);
requestAnimationFrame(loop);
}
function start(){
if(!running){
running = true;
last = 0;
requestAnimationFrame(loop);
}
}
function stop(){ running = false; }
function setState(next, persist){
var n = next || {};
var prevDir = state.direction;
var prevSize = state.size;
var hadDir = Object.prototype.hasOwnProperty.call(n,'direction');
var hadSize = Object.prototype.hasOwnProperty.call(n,'size');
var hadY = Object.prototype.hasOwnProperty.call(n,'yPercent');
state = mergeState(state, n, persist); // Default persist=false for Renderer calls generally
if(hadDir && state.direction !== prevDir){ setDir(); }
if(hadSize && state.size !== prevSize){ ensurePos(); }
if(hadY){
var r = state.size;
var defPct = (state.direction==='lr'?50 : state.direction==='ud'?5 : state.direction==='diag_down'?5 : 95);
var pct = (state.yPercent===undefined || state.yPercent===null ? defPct : state.yPercent);
pos.y = yFromPercent(canvas.height, r, pct);
}
clear();
draw();
}
function resize(w, h){
if(typeof w === 'number') canvas.width = w;
if(typeof h === 'number') canvas.height = h;
ensurePos();
clear();
draw();
}
function setOnUpdate(fn){ onUpdate = fn; }
setDir();
ensurePos();
clear();
draw();
return {
start:start, stop:stop, setState:setState, resize:resize,
getState:function(){return state;}, setOnUpdate:setOnUpdate
};
}
// --- Export ---
window.bls = {
createChannel: function(name){ return new Channel(name); },
defaultState: defaultState,
loadState: loadState,
saveState: saveState,
mergeState: mergeState,
Renderer: Renderer,
listPresets: function(){ var m=loadPresets(); return Object.keys(m).sort(); },
savePreset: function(name, st){
if(!name) return;
var m = loadPresets();
m[name] = sanitizePreset(st || loadState());
savePresets(m);
},
loadPreset: function(name){ var m = loadPresets(); return m[name] || null; },
deletePreset: function(name){ var m = loadPresets(); if(m[name]){ delete m[name]; savePresets(m); } },
exportPresets: function(){ return JSON.stringify(loadPresets(), null, 2); },
importPresets: function(json){
try{
var data = typeof json==='string' ? JSON.parse(json) : json;
if(!data || typeof data!=='object') return;
var m = loadPresets();
for(var k in data){
if(Object.prototype.hasOwnProperty.call(data,k)) m[k] = sanitizePreset(data[k]);
}
savePresets(m);
} catch(e){}
}
};
window.BLS = window.bls;
})();