function initAdmin(){ var previewCanvas = document.getElementById('previewCanvas'); var renderer = BLS.Renderer(previewCanvas); var chan = BLS.createChannel('bls-session'); var state = BLS.loadState(); renderer.setState(state); window.AdminRenderer = renderer; var shapeControls = document.getElementById('shapeControls'); var directionControls = document.getElementById('directionControls'); var easingControls = document.getElementById('easingControls'); var widthControls = document.getElementById('widthControls'); var shapeSwatches = document.getElementById('shapeSwatches'); var bgSwatches = document.getElementById('bgSwatches'); var shapeCustom = document.getElementById('shapeCustom'); var bgCustom = document.getElementById('bgCustom'); var hiddenShapePicker = document.createElement('input'); hiddenShapePicker.type='color'; hiddenShapePicker.style.display='none'; document.body.appendChild(hiddenShapePicker); var hiddenBgPicker = document.createElement('input'); hiddenBgPicker.type='color'; hiddenBgPicker.style.display='none'; document.body.appendChild(hiddenBgPicker); var speed = document.getElementById('speed'); var speedDown = document.getElementById('speedDown'); var speedUp = document.getElementById('speedUp'); var speedLabel = document.getElementById('speedLabel'); var size = document.getElementById('size'); var sizeLabel = document.getElementById('sizeLabel'); var yPercent = document.getElementById('yPercent'); var yPercentLabel = document.getElementById('yPercentLabel'); var yReset = document.getElementById('yReset'); var yOscillateEnable = document.getElementById('yOscillateEnable'); var yOscTick = null; var glowEnable = document.getElementById('glowEnable'); var glowIntensity = document.getElementById('glowIntensity'); var glowIntensityLabel = document.getElementById('glowIntensityLabel'); var glowRate = document.getElementById('glowRate'); var glowRateLabel = document.getElementById('glowRateLabel'); var rampEnable = document.getElementById('rampEnable'); var rampSeconds = document.getElementById('rampSeconds'); var rampLabel = document.getElementById('rampLabel'); var edgePause = document.getElementById('edgePause'); var edgePauseLabel = document.getElementById('edgePauseLabel'); var wiggleEnable = document.getElementById('wiggleEnable'); var wiggleAmplitude = document.getElementById('wiggleAmplitude'); var wiggleLabel = document.getElementById('wiggleLabel'); var presetName = document.getElementById('presetName'); var presetList = document.getElementById('presetList'); var savePresetBtn = document.getElementById('savePreset'); var applyPresetBtn = document.getElementById('applyPreset'); var deletePresetBtn = document.getElementById('deletePreset'); var exportPresetsBtn = document.getElementById('exportPresets'); var importPresetsBtn = document.getElementById('importPresets'); var timerMinutes = document.getElementById('timerMinutes'); var timerDisplay = document.getElementById('timerDisplay'); var statusText = document.getElementById('statusText'); var elapsedText = document.getElementById('elapsedText'); var startBtn = document.getElementById('startSession'); var endBtn = document.getElementById('endSession'); var openPatient = document.getElementById('openPatient'); var loadAudioBtn = document.getElementById('loadAudioBtn'); var audioModeButtons = document.getElementById('audioModeButtons'); var audioEnable = document.getElementById('audioEnable'); var programControls = document.getElementById('programmaticControls'); var soundToneToggle = document.getElementById('soundToneToggle'); var soundPresetControls = document.getElementById('soundPresetControls'); var soundPreset = document.getElementById('soundPreset'); var tonePresetControls = document.getElementById('tonePresetControls'); var tonePreset = document.getElementById('tonePreset'); var toneFreq = document.getElementById('toneFreq'); var toneDuration = document.getElementById('toneDuration'); var toneDurationLabel = document.getElementById('toneDurationLabel'); var adsrAttack = document.getElementById('adsrAttack'); var adsrDecay = document.getElementById('adsrDecay'); var adsrSustain = document.getElementById('adsrSustain'); var adsrRelease = document.getElementById('adsrRelease'); var toneFreqRow = document.getElementById('toneFreqRow'); var toneDurRow = document.getElementById('toneDurRow'); var adsrField = document.getElementById('adsrField'); var syncPan = document.getElementById('syncPan'); var panRate = document.getElementById('panRate'); var panRateLabel = document.getElementById('panRateLabel'); var cueRate = document.getElementById('cueRate'); var cueRateLabel = document.getElementById('cueRateLabel'); var volume = document.getElementById('volume'); var volumeLabel = document.getElementById('volumeLabel'); var testSound = document.getElementById('testSound'); var connectHID = document.getElementById('connectHID'); var deviceStatus = document.getElementById('deviceStatus'); var knobSens = document.getElementById('knobSens'); var knobSensLabel = document.getElementById('knobSensLabel'); var hidDevice = null; var audioFiles = []; var patientName = document.getElementById('patientName'); var savePatientBtn = document.getElementById('savePatient'); var sessionCountEl = document.getElementById('sessionCount'); var lastSessionInfoEl = document.getElementById('lastSessionInfo'); var clearSessionsBtn = document.getElementById('clearSessions'); var deletePatientBtn = document.getElementById('deletePatient'); var sessionSelect = document.getElementById('sessionSelect'); var loadSessionBtn = document.getElementById('loadSession'); var sessionNotesEl = document.getElementById('sessionNotes'); var saveSessionNotesBtn = document.getElementById('saveSessionNotes'); var deleteSessionSingleBtn = document.getElementById('deleteSession'); function setActive(group, dataKey, stateKey){ var btns = group.querySelectorAll('button'); for(var i=0;i'+n+'' }).join(''); var sel = document.getElementById('patientList'); if(sel){ sel.innerHTML = html } } function loadPatients(){ try{ var m = JSON.parse(localStorage.getItem('bls_patients')||'null'); return m&&typeof m==='object'? m : {} }catch(e){ return {} } } function savePatients(m){ try{ localStorage.setItem('bls_patients', JSON.stringify(m)) }catch(e){} } function getPatient(name){ var n = (name||'').trim()||'Unnamed'; var m = loadPatients(); if(!m[n]) m[n] = { name:n, sessions:[], count:0, last:null }; return m[n] } function setPatient(p){ var m = loadPatients(); m[p.name] = p; savePatients(m) } function deletePatient(name){ var n = (name||'').trim()||'Unnamed'; var m = loadPatients(); if(m[n]){ delete m[n]; savePatients(m) } } function refreshPatientUI(){ var n = (patientName.value||'').trim()||'Unnamed'; var p = getPatient(n); sessionCountEl.textContent = String(p.count||0); if(p.last){ var d = new Date(p.last.end); var dur = Math.round((p.last.durationSec||0)); lastSessionInfoEl.textContent = d.toLocaleString()+' • '+dur+'s' } else { lastSessionInfoEl.textContent = '—' } var host = document.getElementById('sessionHistory'); if(host){ if(!p.sessions || !p.sessions.length){ host.innerHTML = 'No sessions yet' } else { var rows = p.sessions.slice().sort(function(a,b){ return a.start - b.start }).map(function(s, idx){ var start = new Date(s.start).toLocaleString(); var end = new Date(s.end).toLocaleString(); var dur = Math.round(s.durationSec||0); return '
'+String(idx+1)+'. '+start+' → '+end+' • '+dur+'s
' }); host.innerHTML = rows.join('') } } if(sessionSelect){ var opts = (!p.sessions || !p.sessions.length)? [] : p.sessions.slice().sort(function(a,b){ return a.start - b.start }).map(function(s, idx){ var start = new Date(s.start).toLocaleString(); return { i: idx, label: String(idx+1)+'. '+start } }); var html = opts.map(function(o){ return '' }).join(''); sessionSelect.innerHTML = html; if(opts.length){ sessionSelect.value = String(opts[opts.length-1].i); var s = p.sessions[opts[opts.length-1].i]; if(sessionNotesEl){ sessionNotesEl.value = s.notes||'' } } else { if(sessionNotesEl){ sessionNotesEl.value = '' } } } } function recordSession(){ var n = (patientName.value||'').trim()||'Unnamed'; var st = BLS.loadState(); if(!st.startedAt) return; var end = Date.now(); var durationSec = Math.floor((end - st.startedAt)/1000); var p = getPatient(n); p.sessions.push({ start: st.startedAt, end: end, durationSec: durationSec, notes: '' }); p.count = (p.count||0)+1; p.last = { start: st.startedAt, end: end, durationSec: durationSec, notes: '' }; setPatient(p); refreshPatientUI() } function refreshPresetList(){ var names = BLS.listPresets(); var html=''; for(var i=0;i'+n+'' } presetList.innerHTML = html } function applyState(next, broadcast){ var prevRunning = state && !!state.running; state = BLS.mergeState(state, next||{}); renderer.setState(state); if(prevRunning !== !!state.running){ if(state.running){ renderer.start() } else { renderer.stop() } } if(broadcast){ chan.send(state) } try{ if(window.Recorder && Recorder.running){ Recorder.recordState(BLS.loadState()) } }catch(_){ } publishState() } function cycle(arr, cur){ var i = arr.indexOf(cur); return arr[(i+1) % arr.length] } var shapeOrder = ['circle','square','triangle']; var dirOrder = ['lr','ud','diag_down','diag_up']; var easeOrder = ['linear','ease']; var progOrder = ['pink','hybrid','tone','click','ping','woodblock','bell','bass','kick','snare','hihat','sweep','zap','bubble']; function labelForDirection(d){ return d==='lr'? 'Left ↔ Right' : d==='ud'? 'Up ↕ Down' : d==='diag_down'? 'Diagonal ↘︎' : 'Diagonal ↗︎' } function labelForEasing(e){ return e==='ease'? 'Ease In-Out' : 'Linear' } function labelForProgram(p){ var map={pink:'Pink Noise', hybrid:'Hybrid', tone:'Tone', click:'Click', ping:'Ping', woodblock:'Woodblock', bell:'Bell', bass:'Bass', kick:'Kick', snare:'Snare', hihat:'Hi-Hat', sweep:'Sweep', zap:'Zap', bubble:'Bubble'}; return map[p]||p } function applyStateLocal(next){ state = BLS.mergeState(state, next||{}); renderer.setState(state); if(speed){ speed.value = String(parseInt(state.speed||parseInt(speed.value,10)||250, 10)) } if(size){ size.value = String(state.size||parseFloat(size.value)||80) } if(glowEnable){ glowEnable.checked = !!state.glowEnabled } if(glowIntensity){ glowIntensity.value = String(Math.round((state.glowIntensity||1)*100)) } if(glowRate){ glowRate.value = String(state.glowRate||parseFloat(glowRate.value)||0.5) } if(yPercent){ yPercent.value = String(state.yPercent||parseInt(yPercent.value,10)||50) } if(audioEnable){ audioEnable.checked = !!state.audioEnabled } (function(){ var progEl = document.getElementById('programPreset'); if(progEl){ progEl.value = state.programPreset || progEl.value || 'pink' } })() if(toneFreq){ toneFreq.value = String(state.toneFreq||parseInt(toneFreq.value,10)||440) } if(toneDuration){ toneDuration.value = String(state.toneDurationMs||parseInt(toneDuration.value,10)||70) } if(adsrAttack){ adsrAttack.value = String(state.adsrAttackMs||parseInt(adsrAttack.value,10)||8) } if(adsrDecay){ adsrDecay.value = String(state.adsrDecayMs||parseInt(adsrDecay.value,10)||80) } if(adsrSustain){ adsrSustain.value = String(state.adsrSustainLevel||parseFloat(adsrSustain.value)||0.3) } if(adsrRelease){ adsrRelease.value = String(state.adsrReleaseMs||parseInt(adsrRelease.value,10)||10) } updateSpeedLabel(); updateSizeLabel(); updateGlowIntensityLabel(); updateGlowRateLabel(); updateYPercentLabel(); updateRampLabel(); updateEdgePauseLabel(); updateWiggleLabel(); updatePanRateLabel(); updateCueRateLabel(); updateVolumeLabel(); try{ if(typeof refreshToneVisibility==='function'){ refreshToneVisibility() } else { var on = (state.programPreset==='tone'); var tp = document.getElementById('tonePresetControls'); if(tp) tp.style.display = on? 'flex':'none'; var fr = document.getElementById('toneFreqRow'); if(fr) fr.style.display = on? 'flex':'none'; var dr = document.getElementById('toneDurRow'); if(dr) dr.style.display = on? 'flex':'none'; var af = document.getElementById('adsrField'); if(af) af.style.display = on? 'flex':'none' } }catch(_){ } if(state.running){ var pausedExtra = (state.paused && state.pausedAt)? (Date.now() - state.pausedAt) : 0; var elapsed = Math.max(0, Math.floor((Date.now() - (state.startedAt||Date.now()) - (state.pausedMs||0) - pausedExtra)/1000)); var remaining = Math.max(0, Math.floor((state.durationSec||0) - elapsed)); if(state.paused){ renderer.stop() } else { renderer.start() } if(statusText){ statusText.textContent = state.paused? 'Paused' : 'Replaying' } var em = Math.floor(elapsed/60), es = elapsed%60; var rm = Math.floor(remaining/60), rs = remaining%60; elapsedText.textContent = String(em).padStart(2,'0')+':'+String(es).padStart(2,'0'); timerDisplay.textContent = String(rm).padStart(2,'0')+':'+String(rs).padStart(2,'0'); } else { renderer.stop(); if(statusText){ statusText.textContent = 'Idle' } } } window.applyStateLocal = applyStateLocal function publishState(){ try{ if(!patientSyncOn) return; if(wsConnected && ws){ ws.send(JSON.stringify({type:'state', sessionId: wsSid||'default', payload: BLS.loadState()})) } }catch(e){} } chan.on(function(payload){ if(!payload || typeof payload!=='object') return; if(payload.type==='patientViewport'){ var pv = payload.payload||{}; try{ window.PatientViewport = pv }catch(_){ } var r = window.AdminRenderer; if(r){ r.resize(parseInt(pv.canvasW||640,10), parseInt(pv.canvasH||360,10)) } var pvLabel = document.getElementById('patientViewportLabel'); if(pvLabel){ pvLabel.textContent = (pv.canvasW||'?')+'×'+(pv.canvasH||'?') } var info = document.getElementById('patientViewportInfo'); if(info){ info.textContent = 'Patient viewport: ' + (pv.winW||'?')+'×'+(pv.winH||'?') + ' • canvas ' + (pv.canvasW||'?')+'×'+(pv.canvasH||'?') } } }) if(shapeControls){ shapeControls.addEventListener('click', function(e){ var d = e.target && e.target.dataset && e.target.dataset.shape; if(!d) return; applyState({shape: d}, true); setActive(shapeControls,'shape','shape') }) } if(directionControls){ directionControls.addEventListener('click', function(e){ var d = e.target && e.target.dataset && e.target.dataset.direction; if(!d) return; applyState({direction: d}, true); setActive(directionControls,'direction','direction') }) } if(easingControls){ easingControls.addEventListener('click', function(e){ var d = e.target && e.target.dataset && e.target.dataset.easing; if(!d) return; applyState({easingMode: d}, true); setActive(easingControls,'easing','easingMode') }) } if(widthControls){ widthControls.addEventListener('click', function(e){ var w = e.target && e.target.dataset && e.target.dataset.width; if(!w) return; applyState({patientWidth: w}, true); var btns = widthControls.querySelectorAll('button'); for(var i=0;i127) b0 = b0-256 }catch(_){ b0 = 0 } } var sens = parseInt(knobSens.value,10)||1; var delta = b0 * sens; if(delta!==0){ var current = parseInt(speed.value,10)||250; var next = Math.max(parseInt(speed.min,10), Math.min(parseInt(speed.max,10), current + delta)); speed.value = String(next); updateSpeedLabel(); applyState({speed: next}, true) } }) }catch(e){ deviceStatus.textContent = 'Error connecting' } } connectHID.addEventListener('click', connectHIDDevice); var tickId = null; function updateStartBtn(){ if(!startBtn) return; var st=BLS.loadState(); if(!st.running){ startBtn.textContent='Start Session' } else if(st.paused){ startBtn.textContent='Resume' } else { startBtn.textContent='Pause' } } function startTick(){ if(tickId) clearInterval(tickId); tickId = setInterval(function(){ var st = BLS.loadState(); if(!st.running){ clearInterval(tickId); tickId=null; return } var pausedExtra = (st.paused && st.pausedAt)? (Date.now() - st.pausedAt) : 0; var elapsed = Math.floor((Date.now()-st.startedAt - (st.pausedMs||0) - pausedExtra)/1000); if(elapsed<0) elapsed=0; var rem = Math.max(0, st.durationSec - elapsed); var em = Math.floor(elapsed/60), es = elapsed%60; elapsedText.textContent = String(em).padStart(2,'0')+':'+String(es).padStart(2,'0'); var rm = Math.floor(rem/60); timerDisplay.textContent = String(rm).padStart(2,'0')+':'+String(rem%60).padStart(2,'0'); if(rem<=0){ stopTimer() } }, 250) } function startTimer(){ if(tickId) clearInterval(tickId); var durationSec = Math.max(0, (parseInt(timerMinutes.value||'0',10)||0)*60); var startedAt = Date.now(); applyState({running:true, paused:false, pausedMs:0, pausedAt:null, durationSec:durationSec, startedAt:startedAt}, true); statusText.textContent = 'Running'; updateStartBtn(); tickId = setInterval(function(){ var st = BLS.loadState(); if(!st.running){ clearInterval(tickId); tickId=null; return } var pausedExtra = (st.paused && st.pausedAt)? (Date.now() - st.pausedAt) : 0; var elapsed = Math.floor((Date.now()-st.startedAt - (st.pausedMs||0) - pausedExtra)/1000); if(elapsed<0) elapsed=0; var rem = Math.max(0, st.durationSec - elapsed); var em = Math.floor(elapsed/60), es = elapsed%60; elapsedText.textContent = String(em).padStart(2,'0')+':'+String(es).padStart(2,'0'); var rm = Math.floor(rem/60); timerDisplay.textContent = String(rm).padStart(2,'0')+':'+String(rem%60).padStart(2,'0'); if(rem<=0){ stopTimer() } }, 250); } function pauseTimer(){ var st=BLS.loadState(); if(!st.running || st.paused) return; applyState({paused:true, pausedAt:Date.now()}, true); statusText.textContent='Paused'; updateStartBtn(); var r = window.AdminRenderer; if(r){ r.stop() } } function resumeTimer(){ var st=BLS.loadState(); if(!st.running || !st.paused) return; var add = (st.pausedAt? (Date.now()-st.pausedAt) : 0); applyState({paused:false, pausedMs:(st.pausedMs||0)+add, pausedAt:null}, true); statusText.textContent='Running'; updateStartBtn(); var r = window.AdminRenderer; if(r){ r.start() } } function stopTimer(){ if(tickId){ clearInterval(tickId); tickId=null } var st0 = BLS.loadState(); if(st0 && st0.running){ recordSession() } applyState({ running:false, paused:false, pausedMs:0, pausedAt:null, startedAt:null, durationSec:0 }, true); statusText.textContent = 'Idle'; updateStartBtn(); updateTimerLabel(); if(elapsedText){ elapsedText.textContent = '00:00' } refreshPatientUI() } startBtn.addEventListener('click', function(){ var st=BLS.loadState(); if(!st.running){ startTimer() } else if(st.paused){ resumeTimer() } else { pauseTimer() } }); endBtn.addEventListener('click', function(){ if(window.Recorder && Recorder.running){ try{ var rec = Recorder.stop(); if(Recorder.path && Recorder.path.length){ rec.path = Recorder.path.slice(); Recorder.path = null } var name = (prompt('Recording name')||'').trim()||null; (async function(){ try{ await Recorder.add(name, rec) }catch(_){ } refreshRecList() })() }catch(_){ } } stopTimer(); }); window.AdminStartTimer = startTimer; window.AdminStopTimer = stopTimer; wsConnectBtn.addEventListener('click', function(){ wsConnect(); updateJoinLink() }); wsDisconnectBtn.addEventListener('click', wsDisconnect); wsUrl.addEventListener('input', updateJoinLink); wsSession.addEventListener('input', updateJoinLink); function copyText(t, fallbackElem){ var ok=false; if(navigator.clipboard && navigator.clipboard.writeText){ try{ navigator.clipboard.writeText(t); ok=true }catch(e){} } if(!ok && fallbackElem){ try{ fallbackElem.focus(); fallbackElem.select(); ok=document.execCommand('copy') }catch(e){} } if(!ok){ var ta=document.createElement('textarea'); ta.value=t; ta.setAttribute('readonly',''); ta.style.position='fixed'; ta.style.bottom='0'; ta.style.left='0'; ta.style.opacity='0.01'; document.body.appendChild(ta); ta.focus(); ta.select(); try{ ok=document.execCommand('copy') }catch(e){} document.body.removeChild(ta) } if(!ok){ try{ alert(t) }catch(_){ } } return ok } copyJoinLink.addEventListener('click', function(){ var v = joinLink.value||''; if(!v) return; var b=this; var ok=copyText(v, joinLink); var old=b.textContent; b.textContent = ok ? 'Copied!' : 'Shown'; setTimeout(function(){ b.textContent=old }, 1000) }); var detectIPBtn = document.getElementById('detectIP'); if(detectIPBtn){ detectIPBtn.addEventListener('click', function(){ function setUrl(ip){ if(!ip) return; wsUrl.value = 'ws://' + ip + ':8787'; if(!(wsSession.value||'').trim()){ wsSession.value = 'room-1' } updateJoinLink(); var v = joinLink.value||''; if(v){ var ok = copyText(v, joinLink); var b = document.getElementById('copyJoinLink'); if(b){ var old=b.textContent; b.textContent = ok? 'Copied!' : 'Copy'; setTimeout(function(){ b.textContent=old }, 1000) } } } var host = location.hostname; if(host && host !== 'localhost' && host !== '127.0.0.1'){ setUrl(host); return } // Attempt server-assisted detection (function(){ var probeUrl = 'ws://' + (host || 'localhost') + ':8787' try{ var temp = new WebSocket(probeUrl) temp.onopen = function(){ try{ temp.send(JSON.stringify({type:'detect_ip'})) }catch(e){} } temp.onmessage = function(ev){ try{ var msg = JSON.parse(ev.data); if(msg && msg.type==='detect_ip' && msg.ip){ setUrl(msg.ip); wsStatus.textContent = 'IP detected'; try{ temp.close() }catch(e){} return } }catch(e){} try{ temp.close() }catch(e){} // Fallback to WebRTC if server didn't respond with IP tryWebRTC() } temp.onerror = function(){ tryWebRTC() } }catch(_){ tryWebRTC() } })() function tryWebRTC(){ var RTC = window.RTCPeerConnection || window.webkitRTCPeerConnection || window.mozRTCPeerConnection if(!RTC){ wsStatus.textContent = 'Detect unavailable'; return } try{ var pc = new RTC({iceServers: []}); pc.createDataChannel('x'); pc.onicecandidate = function(e){ if(!e || !e.candidate){ pc.close(); return } var cand = e.candidate; var ip = (cand && cand.address) || null; if(!ip){ var s = cand.candidate || ''; var m = s.match(/(?:\s|\b)(\d{1,3}(?:\.\d{1,3}){3})(?:\b)/); if(m) ip = m[1] } if(ip && /^\d+\.\d+\.\d+\.\d+$/.test(ip) && !/^169\./.test(ip)){ setUrl(ip); wsStatus.textContent = 'IP detected'; pc.close() } }; pc.createOffer().then(function(o){ return pc.setLocalDescription(o) }).catch(function(){ wsStatus.textContent = 'Detect failed' }) }catch(_){ wsStatus.textContent = 'Detect failed' } } }) } openPatient.addEventListener('click', function(){ window.open('patient.html','bls-patient'); }); renderer.resize(360,220); updateSpeedLabel(); updateSizeLabel(); updateYPercentLabel(); updateTimerLabel(); updateGlowIntensityLabel(); updateGlowRateLabel(); updateRampLabel(); updateEdgePauseLabel(); updateWiggleLabel(); updatePanRateLabel(); updateCueRateLabel(); updateVolumeLabel(); statusText.textContent = state.running? (state.paused? 'Paused':'Running'):'Idle'; if(shapeControls) setActive(shapeControls,'shape','shape'); if(directionControls) setActive(directionControls,'direction','direction'); if(easingControls) setActive(easingControls,'easing','easingMode'); updateStartBtn(); if(state.running){ startTick() } audioEnable.checked = !!state.audioEnabled; glowEnable.checked = !!state.glowEnabled; if(yOscillateEnable){ yOscillateEnable.checked = !!state.yOscillateEnabled } if(state.yOscillateEnabled){ startYOsc() } glowIntensity.value = Math.round((state.glowIntensity||1)*100); rampEnable.checked = !!state.rampEnabled; rampSeconds.value = parseInt(state.rampSeconds||10,10); edgePause.value = parseInt(state.edgePauseMs||0,10); wiggleEnable.checked = !!state.wiggleEnabled; wiggleAmplitude.value = parseInt(state.wiggleAmplitude||0,10); setProgramMode(); // initialize toggle selection if(soundPreset && state.programPreset && state.programPreset!=='tone'){ soundPreset.value = state.programPreset } if(toneFreq){ toneFreq.value = String(state.toneFreq||440) } if(toneDuration){ toneDuration.value = String(state.toneDurationMs||70); if(toneDurationLabel){ toneDurationLabel.textContent = (state.toneDurationMs||70)+' ms' } } if(adsrAttack){ adsrAttack.value = String(state.adsrAttackMs||8) } if(adsrDecay){ adsrDecay.value = String(state.adsrDecayMs||80) } if(adsrSustain){ adsrSustain.value = String(state.adsrSustainLevel||0.3) } if(adsrRelease){ adsrRelease.value = String(state.adsrReleaseMs||10) } syncPan.checked = !!state.syncPan; panRate.value = String(state.panRate||0.5); cueRate.value = String(state.cueRate||1.2); volume.value = String(Math.round((state.volume||0.6)*100)); glowRate.value = String(state.glowRate||0.5); updateKnobSensLabel(); yPercent.value = parseInt(state.yPercent||50,10); updateYPercentLabel(); applyState({ yPercent: parseInt(yPercent.value,10) }, false); refreshPresetList(); refreshPatientList(); updateJoinLink(); refreshPatientUI(); } function waitForBLS(){ if(window.BLS){ initAdmin() } else { setTimeout(waitForBLS, 50) } } if(document.readyState==='loading'){ document.addEventListener('DOMContentLoaded', waitForBLS) } else { waitForBLS() } var wsUrl = document.getElementById('wsUrl'); var wsSession = document.getElementById('wsSession'); var wsConnectBtn = document.getElementById('wsConnect'); var wsDisconnectBtn = document.getElementById('wsDisconnect'); var wsStatus = document.getElementById('wsStatus'); var netModeSel = document.getElementById('netMode'); var rtcControls = document.getElementById('webrtcControls'); var rtcStatus = document.getElementById('rtcStatus'); var rtcOffer = document.getElementById('rtcOffer'); var rtcAnswer = document.getElementById('rtcAnswer'); var rtcCreateOffer = document.getElementById('rtcCreateOffer'); var rtcAcceptAnswer = document.getElementById('rtcAcceptAnswer'); var joinLink = document.getElementById('joinLink'); var copyJoinLink = document.getElementById('copyJoinLink'); var simpleViewEnable = document.getElementById('simpleViewEnable'); var themeToggle = document.getElementById('themeToggle'); var networkPanelSection = document.getElementById('networkPanelSection'); var networkHeading = document.getElementById('networkHeading'); var patientSyncOn = true; (function(){ var el = document.getElementById('patientSyncEnable'); if(!el) return; el.checked = !!patientSyncOn; el.addEventListener('change', function(){ patientSyncOn = !!el.checked }) })() var startRelayBtn = document.getElementById('startRelayBtn'); var stopRelayBtn = document.getElementById('stopRelayBtn'); var relayStatusDot = document.getElementById('relayStatusDot'); var relayStatusText = document.getElementById('relayStatusText'); var recStartBtn = document.getElementById('recStart'); var recStopBtn = document.getElementById('recStop'); var recList = document.getElementById('recList'); var recPlayBtn = document.getElementById('recPlay'); var recExportBtn = document.getElementById('recExport'); var recImportBtn = document.getElementById('recImport'); var recDeleteBtn = document.getElementById('recDelete'); var recClearBtn = document.getElementById('recClear'); var locked = false; var unlockTimer=null; var lockBanner = document.getElementById('lockBanner'); var themeSelect = document.getElementById('themeSelect'); var controllersPanel = document.getElementById('controllersPanel'); var lockHotkeyEnable = document.getElementById('lockHotkeyEnable'); var ws = null; var wsConnected = false; var wsSid = null; function cycleGlobal(arr, cur){ var i = arr.indexOf(cur); return arr[(i+1) % arr.length] } var shapeOrderGlobal = ['circle','square','triangle']; var dirOrderGlobal = ['lr','ud','diag_down','diag_up']; var easeOrderGlobal = ['linear','ease']; function setActiveGroup(selector, dataKey, stateKey){ var host = document.querySelector(selector); if(!host) return; var st = (window.BLS? BLS.loadState():{}); var btns = host.querySelectorAll('button'); for(var i=0;i'+(r.name||('Recording '+(i+1)))+'' }).join(''); recList.innerHTML = html } if(recStartBtn){ recStartBtn.addEventListener('click', function(){ var st = BLS.loadState(); if(!st.running && window.AdminStartTimer){ window.AdminStartTimer() } if(window.Recorder){ Recorder.start(); statusText.textContent='Recording'; var r = window.AdminRenderer; var lastT = 0; if(r){ r.setOnUpdate(function(info){ if(!Recorder.running) return; var t = Date.now() - (Recorder.startTime||Date.now()); if(t - lastT >= 100){ try{ if(!Recorder.path) Recorder.path=[]; Recorder.path.push({ t:t, x:info.x, y:info.y }); lastT = t }catch(_){ } } }) } } }) } if(recStopBtn){ recStopBtn.addEventListener('click', async function(){ if(window.Recorder){ var rec = Recorder.stop(); if(Recorder.path && Recorder.path.length){ rec.path = Recorder.path.slice(); Recorder.path = null } var name = (prompt('Recording name')||'').trim()||null; if(!rec.samples || !rec.samples.length){ try{ alert('No parameter changes captured in this recording') }catch(_){ } } await Recorder.add(name, rec); if(window.AdminStopTimer){ window.AdminStopTimer() } statusText.textContent='Idle'; var r = window.AdminRenderer; if(r){ r.setOnUpdate(null) } refreshRecList() } }) } if(recPlayBtn){ recPlayBtn.addEventListener('click', async function(){ var idxRaw = (recList && recList.value)||''; var idx = idxRaw? parseInt(idxRaw,10) : -1; var all = await Recorder.list(); if(idx<0) idx = (all.length? all.length-1 : -1); if(!(all && all[idx])){ statusText.textContent='No recordings'; return } var rec = all[idx].data; var mode = (netModeSel && netModeSel.value)||'relay'; var usingRelay = (mode==='relay'); var effectiveWs = (usingRelay && patientSyncOn && ws && ws.readyState===1) ? ws : null; var opts = { mode: usingRelay? 'relay':'webrtc', sid: wsSid||'default', ws: effectiveWs, speed:1, applyLocal: function(s){ if(window.applyStateLocal){ window.applyStateLocal(s) } else { try{ var st = BLS.mergeState(BLS.loadState(), s||{}); var r = window.AdminRenderer; if(r){ r.setState(st); if(st.running){ r.start() } else { r.stop() } } }catch(_){ } } }, sendLocalChannel: function(s){ try{ if(patientSyncOn){ chan.send(s) } }catch(_){ } }, patientSyncOn: patientSyncOn }; Recorder.play(rec, opts); statusText.textContent='Replaying' }) } if(recExportBtn){ recExportBtn.addEventListener('click', async function(){ var idxRaw = (recList && recList.value)||''; var idx = idxRaw? parseInt(idxRaw,10) : -1; var all = await Recorder.list(); if(idx<0) idx = (all.length? all.length-1 : -1); if(!(all && all[idx])){ statusText.textContent='No recordings'; return } var payload = JSON.stringify(all[idx].data, null, 2); if(window.showSaveFilePicker){ try{ var handle = await window.showSaveFilePicker({ suggestedName: (all[idx].name||'recording')+'.json', types:[{ description:'JSON', accept:{ 'application/json':['.json'] } }] }); var w = await handle.createWritable(); await w.write(payload); await w.close(); statusText.textContent='Exported' }catch(_){ } } else { var a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([payload], {type:'application/json'})); a.download = (all[idx].name||'recording')+'.json'; a.click(); URL.revokeObjectURL(a.href); statusText.textContent='Exported' } }) } if(recImportBtn){ recImportBtn.addEventListener('click', async function(){ try{ if(window.showOpenFilePicker){ var files = await window.showOpenFilePicker({ multiple:false, types:[{ description:'JSON', accept:{ 'application/json':['.json'] } }] }); if(files && files.length){ var file = await files[0].getFile(); var text = await file.text(); var data = JSON.parse(text); var name = prompt('Recording name')||'Imported'; await Recorder.add(name, data); await refreshRecList(); statusText.textContent='Imported' } } else { var input = document.createElement('input'); input.type='file'; input.accept='.json,application/json'; input.addEventListener('change', async function(){ var f = input.files && input.files[0]; if(!f) return; var reader = new FileReader(); reader.onload = async function(){ try{ var data = JSON.parse(reader.result); var name = prompt('Recording name')||'Imported'; await Recorder.add(name, data); await refreshRecList(); statusText.textContent='Imported' }catch(_){ } }; reader.readAsText(f) }); input.click() } }catch(_){ } }) } if(recDeleteBtn){ recDeleteBtn.addEventListener('click', async function(){ var idxRaw = (recList && recList.value)||''; var idx = idxRaw? parseInt(idxRaw,10) : -1; var all = await Recorder.list(); if(!(all && all[idx])){ statusText.textContent='No selection'; return } var name = all[idx].name || ('Recording '+(idx+1)); if(!confirm('Delete recording "'+name+'"?')) return; var id = all[idx].id; try{ if(window.RecordingStore && id){ await RecordingStore.delete(id) } else { try{ var arr = JSON.parse(localStorage.getItem('bls_recordings')||'[]'); if(Array.isArray(arr)){ arr.splice(idx,1); localStorage.setItem('bls_recordings', JSON.stringify(arr)) } }catch(_){ } } await refreshRecList(); statusText.textContent='Deleted' }catch(_){ } }) } if(recClearBtn){ recClearBtn.addEventListener('click', async function(){ try{ await Recorder.clear(); await refreshRecList(); statusText.textContent='Cleared' }catch(_){ } }) } refreshRecList() function lockOn(){ locked=true; document.body.classList.add('locked'); if(lockBanner) lockBanner.textContent='LOCKED • Hold U to unlock' } function lockOff(){ locked=false; document.body.classList.remove('locked'); if(lockBanner) lockBanner.textContent='UNLOCKED' } window.addEventListener('keydown', function(e){ if(lockHotkeyEnable && !lockHotkeyEnable.checked) return; if(e.key==='l' || e.key==='L'){ if(!locked){ lockOn() } } if(e.key==='u' || e.key==='U'){ if(locked && !unlockTimer){ if(lockBanner) lockBanner.textContent='Unlocking...'; unlockTimer=setTimeout(function(){ unlockTimer=null; lockOff() }, 2000) } } }); window.addEventListener('keyup', function(e){ if(lockHotkeyEnable && !lockHotkeyEnable.checked) return; if(e.key==='u' || e.key==='U'){ if(unlockTimer){ clearTimeout(unlockTimer); unlockTimer=null; if(lockBanner && locked){ lockBanner.textContent='LOCKED • Hold U to unlock' } } } }); function applyTheme(val){ var pref = val||'auto'; if(pref==='dark'){ document.body.classList.add('theme-dark') } else if(pref==='light'){ document.body.classList.remove('theme-dark') } else { var mq = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; if(mq){ document.body.classList.add('theme-dark') } else { document.body.classList.remove('theme-dark') } } try{ localStorage.setItem('bls_theme', pref) }catch(_){ } } if(themeSelect){ var saved = null; try{ saved = localStorage.getItem('bls_theme') }catch(_){ } if(saved){ themeSelect.value = saved } applyTheme(themeSelect.value); themeSelect.addEventListener('change', function(){ applyTheme(themeSelect.value) }) } (function(){ var saved = null; try{ saved = localStorage.getItem('bls_theme') }catch(_){ } var prefersDark = false; try{ prefersDark = !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) }catch(_){ } var isDark = (saved==='dark') || (!saved && prefersDark); if(themeToggle){ themeToggle.checked = isDark; themeToggle.addEventListener('change', function(){ applyTheme(themeToggle.checked? 'dark' : 'light') }) } applyTheme(saved ? saved : (isDark ? 'dark' : 'light')) })(); (function(){ if(networkPanelSection && networkHeading){ } })(); function updatePreviewFromViewport(){ try{ var pv = window.PatientViewport; if(!pv) return; var pvShort = document.getElementById('patientViewportShort'); if(pvShort){ pvShort.textContent = (pv.canvasW||'?')+'×'+(pv.canvasH||'?') } var cv = document.getElementById('previewCanvas'); if(cv){ var cont = cv.parentElement; var maxW = Math.min((cont && cont.clientWidth) || 520, 520); var ratio = (parseInt(pv.canvasW||640,10)) / (parseInt(pv.canvasH||360,10)); cv.style.width = maxW + 'px'; cv.style.height = Math.round(maxW / (ratio || (640/360))) + 'px'; } }catch(e){} } setInterval(updatePreviewFromViewport, 500) function applySimpleView(on){ if(on){ document.body.classList.add('simple-view') } else { document.body.classList.remove('simple-view') } try{ localStorage.setItem('bls_simple_view', String(!!on)) }catch(_){ } } if(simpleViewEnable){ var sv = null; try{ sv = localStorage.getItem('bls_simple_view') }catch(_){ } if(sv!==null){ simpleViewEnable.checked = (sv==='true') } applySimpleView(!!simpleViewEnable.checked); simpleViewEnable.addEventListener('change', function(){ applySimpleView(!!simpleViewEnable.checked) }) } if(lockHotkeyEnable){ var savedLock = null; try{ savedLock = localStorage.getItem('bls_lock_hotkey') }catch(_){ } if(savedLock!==null){ lockHotkeyEnable.checked = savedLock==='true' } lockHotkeyEnable.addEventListener('change', function(){ try{ localStorage.setItem('bls_lock_hotkey', String(!!lockHotkeyEnable.checked)) }catch(_){ } }) } if(window.Controllers){ Controllers.registerAction('start', function(){ var b = document.getElementById('startSession'); if(b) b.click() }) Controllers.registerAction('stop', function(){ var b = document.getElementById('endSession'); if(b) b.click() }) Controllers.registerAction('speed_up', function(){ var el = document.getElementById('speed'); if(!el) return; var current = parseInt(el.value,10)||250; var step = parseInt(el.step,10)||10; var next = Math.min(parseInt(el.max,10), current + step); el.value=String(next); el.dispatchEvent(new Event('input', {bubbles:true})) }) Controllers.registerAction('speed_down', function(){ var el = document.getElementById('speed'); if(!el) return; var current = parseInt(el.value,10)||250; var step = parseInt(el.step,10)||10; var next = Math.max(parseInt(el.min,10), current - step); el.value=String(next); el.dispatchEvent(new Event('input', {bubbles:true})) }) Controllers.registerAction('volume_up', function(){ var el = document.getElementById('volume'); if(!el) return; var current = parseInt(el.value,10)||60; var next = Math.min(100, current + 5); el.value=String(next); el.dispatchEvent(new Event('input', {bubbles:true})) }) Controllers.registerAction('volume_down', function(){ var el = document.getElementById('volume'); if(!el) return; var current = parseInt(el.value,10)||60; var next = Math.max(0, current - 5); el.value=String(next); el.dispatchEvent(new Event('input', {bubbles:true})) }) Controllers.registerAction('audio_toggle', function(){ var el = document.getElementById('audioEnable'); if(!el) return; el.checked = !el.checked; el.dispatchEvent(new Event('change', {bubbles:true})) }) Controllers.registerAction('glow_toggle', function(){ var el = document.getElementById('glowEnable'); if(!el) return; el.checked = !el.checked; el.dispatchEvent(new Event('change', {bubbles:true})) }) Controllers.registerAction('wiggle_toggle', function(){ var el = document.getElementById('wiggleEnable'); if(!el) return; el.checked = !el.checked; el.dispatchEvent(new Event('change', {bubbles:true})) }) Controllers.registerAction('record_start', function(){ var b = document.getElementById('recStart'); if(b) b.click() }) Controllers.registerAction('record_stop', function(){ var b = document.getElementById('recStop'); if(b) b.click() }) Controllers.registerAction('replay', function(){ var b = document.getElementById('recPlay'); if(b) b.click() }) Controllers.registerAction('lock_on', function(){ if(typeof lockOn==='function') lockOn() }) Controllers.registerAction('lock_off', function(){ if(typeof lockOff==='function') lockOff() }) Controllers.registerAction('lock_toggle', function(){ if(typeof lockOn==='function' && typeof lockOff==='function'){ if(document.body.classList.contains('locked')) lockOff(); else lockOn() } }) Controllers.registerAction('shape_next', function(){ var cur = (window.BLS? BLS.loadState().shape : 'circle') || 'circle'; var next = cycleGlobal(shapeOrderGlobal, cur); var btn = document.querySelector('#shapeControls button[data-shape="'+next+'"]'); if(btn) btn.click() }) Controllers.registerAction('direction_next', function(){ var cur = (window.BLS? BLS.loadState().direction : 'lr') || 'lr'; var next = cycleGlobal(dirOrderGlobal, cur); var btn = document.querySelector('#directionControls button[data-direction="'+next+'"]'); if(btn) btn.click() }) Controllers.registerAction('easing_next', function(){ var cur = (window.BLS? BLS.loadState().easingMode : 'linear') || 'linear'; var next = cycleGlobal(easeOrderGlobal, cur); var btn = document.querySelector('#easingControls button[data-easing="'+next+'"]'); if(btn) btn.click() }) Controllers.registerAction('program_next', function(){ var st = (window.BLS? BLS.loadState():{programPreset:'pink'}); if(st.programPreset==='tone'){ var sel = document.getElementById('tonePreset'); if(sel){ var i = sel.selectedIndex; sel.selectedIndex = (i+1) % sel.options.length; sel.dispatchEvent(new Event('change', {bubbles:true})) } } else { var list = ['pink','hybrid','click','ping','woodblock','bell','bass','kick','snare','hihat','sweep','zap','bubble']; var i = list.indexOf(st.programPreset||'pink'); var next = list[(i+1) % list.length]; applyState({programPreset: next}, true); var toggle = document.getElementById('soundToneToggle'); if(toggle){ var btns = toggle.querySelectorAll('button'); for(var j=0;j