ui fix, many features added
This commit is contained in:
parent
cbd4b25bc6
commit
8ef5c54c9b
85
README.md
85
README.md
@ -1,6 +1,79 @@
|
||||
# Bilateral Stimulation Simulator
|
||||
|
||||
Simple browser-based tool with an therapist panel and a full-screen Patient view. The therapist controls everything; the Patient view mirrors motion and audio.
|
||||
Simple browser-based tool with a therapist panel and a full-screen Patient view. The therapist controls everything; the Patient views bilateral stimulus as per therapist settings.
|
||||
|
||||
## User Guide
|
||||
|
||||
### Getting Started
|
||||
- Open the app and stay on the Therapist (Admin) page; the Patient view mirrors whatever you set.
|
||||
- For one computer with two screens: open the Patient view and move it to the patient-facing display, then go fullscreen.
|
||||
- For two computers: you can also use Network Mode (details below in Nerd Stuff), but the simple two‑screen setup is usually easiest.
|
||||
- Audio may need a single click in the Patient view to unlock — this is normal browser behavior.
|
||||
|
||||
### Set Up the Visuals
|
||||
- Shape: choose Circle, Square, or Triangle.
|
||||
- Size: drag the size slider to make the shape larger or smaller.
|
||||
- Speed: use the speed slider; the number shows how fast the shape moves.
|
||||
- Direction: pick Left↔Right (horizontal), Up↕Down (vertical), or Diagonal.
|
||||
- Color: pick a shape color and a background color; use the swatches or the color picker.
|
||||
- Easing and Edge Pause: choose smoother movement (Ease In‑Out) and optional short pauses at the edges.
|
||||
- Vertical Position: set a baseline (0–100%) for where the motion track sits.
|
||||
- Vertical Oscillation: toggle gentle up/down motion around the chosen baseline.
|
||||
- Extras: enable Glow Pulse, Wiggle, Ramp Speed.
|
||||
|
||||
### Sound Options
|
||||
- Programmatic Sounds: built‑in cues like Pink Noise, Hybrid (noise plus edge hits), Click, Ping, Woodblock, Bell, Bass, Kick, Snare, Hi‑Hat, Sweep, Zap, Bubble.
|
||||
- Pan Sync: optionally pan sound left/right in time with the movement.
|
||||
- Volume: set an overall volume; use Test Sound to preview.
|
||||
#### Tone Mode (ADSR)
|
||||
- Frequency: pitch of the tone in Hertz (e.g., 440 Hz).
|
||||
- Duration: how long the tone plays on each hit (milliseconds).
|
||||
- Attack: time to rise from silence to full volume.
|
||||
- Decay: time to fall from full volume down to the sustain level.
|
||||
- Sustain: steady level held during the middle of the tone (0.0–1.0).
|
||||
- Release: time to fade from the sustain level back to silence.
|
||||
- Pan per hit: tones pan left/right to match where the shape hits the edges.
|
||||
- Pan Sync (continuous): for pink noise, enables smooth left/right panning following movement.
|
||||
- Pan Rate: speed of continuous panning when Pan Sync is on.
|
||||
|
||||
### Use Your Own Audio
|
||||
- Choose Folder: select a folder of audio files (MP3/WAV). Then pick a track from the list.
|
||||
- Play/Pan: the app can pan the file left/right to match movement.
|
||||
- Tip: the first time you use audio, click once in the Patient view to unlock sound.
|
||||
|
||||
### Patients and Sessions
|
||||
- Save Patient: enter a name and save. Patients are listed for quick loading later.
|
||||
- Session History: each session records start/end time and duration automatically for the loaded patient.
|
||||
- Session Notes: pick a session from the list, edit notes, and save; notes stay with the session.
|
||||
- Manage: load an existing patient, clear a patient’s sessions, or delete a patient.
|
||||
- Storage: patient info stays on your computer in the browser (no cloud backend).
|
||||
|
||||
### Record Movement Changes (Research/Teaching Tool)
|
||||
- Start Recording: begin capturing every change you make (speed, size, direction, color, etc.).
|
||||
- Stop & Save: end recording and name it; it appears in your recording list.
|
||||
- Replay: select a recording and play it back to reproduce the exact sequence of changes.
|
||||
- Export: save a recording as a JSON file — handy to share or archive.
|
||||
- Import: load a previously exported JSON file back into your recording list.
|
||||
- Clear/Delete: remove one recording or clear the entire list.
|
||||
|
||||
### Presets
|
||||
- Save a Preset: name the current setup (visuals and audio) and save.
|
||||
- Apply: pick a preset to immediately apply all saved settings.
|
||||
- Delete: remove a preset you no longer need.
|
||||
- Import/Export: backup or share all presets as a single JSON file.
|
||||
|
||||
### Controllers and Shortcuts
|
||||
- Keyboard Mapping: map keys to actions like Start/Stop, Speed Up/Down, Volume Up/Down, Toggle Audio, Glow/Wiggle, Lock/Unlock, Record Start/Stop, Replay, and Next Shape/Direction/Easing/Sound.
|
||||
- MIDI Controllers: connect a MIDI device and map buttons/knobs to the same actions; the mapping panel shows what’s currently assigned.
|
||||
- HID Dial/Knob: connect a hardware dial via the “Connect HID” button, then turn the dial to change speed; adjust sensitivity if the dial feels too strong or too subtle.
|
||||
- Lock/Unlock: press `L` to lock the interface (prevents accidental clicks). Hold `U` to unlock; the banner shows the lock status.
|
||||
|
||||
### Tips and Safety
|
||||
- Go gently: start slow and simple. Adjust based on comfort.
|
||||
- Pause anytime: you can stop motion and sound immediately.
|
||||
- Everything is local: patients, presets, and recordings are stored on your device.
|
||||
|
||||
## Nerd Stuff
|
||||
|
||||
## How It Works
|
||||
|
||||
@ -29,7 +102,7 @@ Simple browser-based tool with an therapist panel and a full-screen Patient view
|
||||
### macOS
|
||||
- First‑Time Setup
|
||||
- Press `Cmd + Space` → type `Terminal` → press `Enter`
|
||||
- `cd` into the folder where you unzipped the app
|
||||
- `cd` into the folder where you unzipped the app eg 'cd documents/bls-sim'
|
||||
- Copy and paste: `chmod +x ./setup.sh && ./setup.sh`
|
||||
- Allow the macOS firewall prompt (Private networks)
|
||||
- Your browser opens at `http://<your‑IP>:8000/`; “Relay” turns green
|
||||
@ -144,6 +217,12 @@ Simple browser-based tool with an therapist panel and a full-screen Patient view
|
||||
- Open `index.html` and use the Admin panel
|
||||
- Click “Open Patient View” to launch `patient.html` in a separate tab/window
|
||||
|
||||
### Controllers (Tech)
|
||||
- Keyboard: uses standard keydown events and a mapping registry stored in `localStorage` (`bls_mappings`).
|
||||
- MIDI: uses Web MIDI (`navigator.requestMIDIAccess`) and maps Notes/CCs to actions.
|
||||
- HID: uses WebHID (`navigator.hid.requestDevice`) and listens to `inputreport`; example HID dial adjusts speed with sensitivity scaling.
|
||||
- Gamepad: not implemented.
|
||||
|
||||
## Network Mode Setup (Two Computers on the Same Wi‑Fi/LAN)
|
||||
|
||||
### Overview
|
||||
@ -235,6 +314,8 @@ Simple browser-based tool with an therapist panel and a full-screen Patient view
|
||||
|
||||
- Presets: `localStorage` key `bls_presets`
|
||||
- Patients: `localStorage` key `bls_patients`
|
||||
- App State (current settings): `localStorage` key `bls_state`
|
||||
- Recordings: IndexedDB database `bls_rec_db`, store `recordings` (legacy `localStorage` key `bls_recordings` may exist and is auto‑migrated)
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
1143
css/admin.css
Normal file
1143
css/admin.css
Normal file
File diff suppressed because it is too large
Load Diff
888
index.html
888
index.html
@ -1,294 +1,638 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Bilataral Stimulation Simulator Admin</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6' fill='%231f6feb'/%3E%3C/svg%3E">
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 0; background: #f7f7f7; color: #111; }
|
||||
header { padding: 16px 20px; background: #222; color: #fff; display: flex; align-items: center; justify-content: space-between; }
|
||||
header h1 { font-size: 18px; margin: 0; }
|
||||
main { display: grid; grid-template-columns: 380px 1fr; gap: 16px; padding: 16px; }
|
||||
.panel { background: #fff; border: 1px solid #ddd; border-radius: 8px; padding: 12px; }
|
||||
.panel h2 { font-size: 14px; margin: 0 0 8px; color: #333; }
|
||||
.row { display: flex; gap: 10px; align-items: center; margin: 8px 0; flex-wrap: wrap; }
|
||||
.row label { font-size: 13px; color: #333; }
|
||||
.controls button { padding: 8px 10px; border: 1px solid #ccc; border-radius: 6px; background: #fafafa; cursor: pointer; }
|
||||
.controls button.active { background: #1f6feb; color: #fff; border-color: #1f6feb; box-shadow: 0 0 0 2px rgba(31,111,235,0.4) inset; }
|
||||
#easingControls button.active { background: #1f6feb; color: #fff; border-color: #1f6feb; box-shadow: 0 0 0 2px rgba(31,111,235,0.4) inset; }
|
||||
.controls input[type="range"] { width: 180px; }
|
||||
.preview { display: flex; align-items: center; justify-content: center; height: 220px; background: #000; border-radius: 8px; overflow: hidden; }
|
||||
.preview { width: 360px; margin: 0 auto; }
|
||||
.previewContainer { justify-content: flex-start; }
|
||||
.previewCanvas { display: block; margin: 0; }
|
||||
.footer { display: flex; gap: 8px; }
|
||||
.footer button { padding: 10px 12px; border: none; border-radius: 6px; cursor: pointer; }
|
||||
.footer .primary { background: #1f6feb; color: #fff; }
|
||||
.footer .danger { background: #b31d1d; color: #fff; }
|
||||
.primary { background: #2da44e; color: #fff; border: none; padding: 12px 18px; border-radius: 8px; font-weight: 600; }
|
||||
.danger { background: #d1242f; color: #fff; border: none; padding: 12px 18px; border-radius: 8px; font-weight: 600; }
|
||||
#patientControls .danger { background: transparent; color: #d1242f; border: 1px solid #d1242f; padding: 6px 10px; border-radius: 6px; font-weight: 500; }
|
||||
.metrics { display: flex; gap: 12px; font-size: 13px; color: #444; }
|
||||
.small { font-size: 12px; color: #666; display: inline-block; min-width: 60px; }
|
||||
.statusDot { display:inline-block; width:10px; height:10px; border-radius:50%; background:#d1242f; margin-right:6px; vertical-align:middle; }
|
||||
.statusDot.on { background:#2da44e; }
|
||||
.field { display: flex; align-items: center; gap: 8px; flex-wrap: nowrap; }
|
||||
.field label { min-width: 90px; }
|
||||
#speed { width: 360px; }
|
||||
</style>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Bilateral Stimulation Simulator - Admin</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6' fill='%231f6feb'/%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="css/admin.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>BILATERAL STIMULATION SIMULATOR</h1>
|
||||
<div>
|
||||
<button id="openPatient">Open Patient View</button>
|
||||
<div class="header-content">
|
||||
<h1>Bilateral Stimulation Simulator</h1>
|
||||
<div class="simple-toggle" style="margin:0 auto; display:flex; align-items:center; gap:8px;">
|
||||
<label class="control-label" style="color:inherit;">Simple View</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="simpleViewEnable">
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button id="openPatient" class="btn-secondary">
|
||||
<span class="btn-icon">👁️</span>
|
||||
Open Patient View
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="lockBanner" class="lock-banner">
|
||||
<span class="lock-icon">🔒</span>
|
||||
LOCKED • Hold U to unlock
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<section class="panel">
|
||||
<h2>Session Controls</h2>
|
||||
<div class="row controls" id="shapeControls">
|
||||
<label>Shape:</label>
|
||||
<button data-shape="circle" class="active">Circle</button>
|
||||
<button data-shape="square">Square</button>
|
||||
<button data-shape="triangle">Triangle</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Shape Color:</label>
|
||||
<input type="color" id="shapeColor" value="#00ff88">
|
||||
<div id="shapePreset" class="controls">
|
||||
<button data-color="#00ff00" style="background:#00ff00;color:#000">Green</button>
|
||||
<button data-color="#0066ff" style="background:#0066ff;color:#fff">Blue</button>
|
||||
<button data-color="#ffd400" style="background:#ffd400;color:#000">Yellow</button>
|
||||
<button data-color="#ff2a2a" style="background:#ff2a2a;color:#fff">Red</button>
|
||||
<button data-color="#ffffff" style="background:#ffffff;color:#000">White</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Background:</label>
|
||||
<input type="color" id="bgColor" value="#000000">
|
||||
<div id="bgPreset" class="controls">
|
||||
<button data-color="#00ff00" style="background:#00ff00;color:#000">Green</button>
|
||||
<button data-color="#0066ff" style="background:#0066ff;color:#fff">Blue</button>
|
||||
<button data-color="#ffd400" style="background:#ffd400;color:#000">Yellow</button>
|
||||
<button data-color="#ff2a2a" style="background:#ff2a2a;color:#fff">Red</button>
|
||||
<button data-color="#ffffff" style="background:#ffffff;color:#000">White</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row controls" id="directionControls">
|
||||
<label>Direction:</label>
|
||||
<button data-direction="lr" class="active">Left ↔ Right</button>
|
||||
<button data-direction="ud">Up ↕ Down</button>
|
||||
<button data-direction="diag_down">Diagonal ↘︎</button>
|
||||
<button data-direction="diag_up">Diagonal ↗︎</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Size:</label>
|
||||
<input type="range" id="size" min="10" max="100" step="2" value="28">
|
||||
<span id="sizeLabel" class="small">28 px</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Vertical Position:</label>
|
||||
<input type="range" id="yPercent" min="0" max="100" step="1" value="50">
|
||||
<span id="yPercentLabel" class="small">50%</span>
|
||||
<button id="yReset">Reset</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Glow Pulse:</label>
|
||||
<label class="small"><input type="checkbox" id="glowEnable"> Enable</label>
|
||||
<div class="field">
|
||||
<label>Glow Radius:</label>
|
||||
<input type="range" id="glowIntensity" min="0" max="200" step="5" value="100">
|
||||
<span id="glowIntensityLabel" class="small">100%</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Glow Rate:</label>
|
||||
<input type="range" id="glowRate" min="0.1" max="3" step="0.1" value="0.5">
|
||||
<span id="glowRateLabel" class="small">0.5 Hz</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Ramp Speed:</label>
|
||||
<label class="small"><input type="checkbox" id="rampEnable"> Enable</label>
|
||||
<input type="range" id="rampSeconds" min="1" max="60" step="1" value="10">
|
||||
<span id="rampLabel" class="small">10 s</span>
|
||||
</div>
|
||||
<div class="row controls" id="easingControls">
|
||||
<label>Smoothness:</label>
|
||||
<button data-easing="linear" class="active">Linear</button>
|
||||
<button data-easing="ease">Ease In-Out</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Edge Pause:</label>
|
||||
<input type="range" id="edgePause" min="0" max="150" step="10" value="0">
|
||||
<span id="edgePauseLabel" class="small">0 ms</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Wiggle:</label>
|
||||
<label class="small"><input type="checkbox" id="wiggleEnable"> Enable</label>
|
||||
<input type="range" id="wiggleAmplitude" min="0" max="10" step="1" value="0">
|
||||
<span id="wiggleLabel" class="small">0 px</span>
|
||||
</div>
|
||||
|
||||
<div class="row" id="presetControls">
|
||||
<label>Presets:</label>
|
||||
<div class="field">
|
||||
<label>Name:</label>
|
||||
<input type="text" id="presetName" placeholder="My Preset" style="width:160px">
|
||||
<button id="savePreset">Save</button>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Apply:</label>
|
||||
<select id="presetList"></select>
|
||||
<button id="applyPreset">Apply</button>
|
||||
<button id="deletePreset">Delete</button>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Import/Export:</label>
|
||||
<button id="exportPresets">Export JSON</button>
|
||||
<button id="importPresets">Import JSON</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" id="audioControls">
|
||||
<label>Audio:</label>
|
||||
<select id="audioMode">
|
||||
<option value="programmatic">Programmatic</option>
|
||||
<option value="file">From Files</option>
|
||||
<option value="off">Off</option>
|
||||
</select>
|
||||
<label class="small"><input type="checkbox" id="audioEnable"> Enable</label>
|
||||
<button id="testSound">Test Sound</button>
|
||||
<div id="programmaticControls" class="row">
|
||||
<div class="field">
|
||||
<label>Preset:</label>
|
||||
<select id="programPreset">
|
||||
<option value="pink">Pink Noise</option>
|
||||
<option value="hybrid">Hybrid (noise + edge cues)</option>
|
||||
<option value="click">Click</option>
|
||||
<option value="ping">Ping</option>
|
||||
<option value="woodblock">Woodblock</option>
|
||||
<option value="bell">Bell</option>
|
||||
<option value="bass">Bass</option>
|
||||
<option value="kick">Kick</option>
|
||||
<option value="snare">Snare</option>
|
||||
<option value="hihat">Hi-Hat</option>
|
||||
<option value="sweep">Sweep</option>
|
||||
<option value="zap">Zap</option>
|
||||
<option value="bubble">Bubble</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="small"><input type="checkbox" id="syncPan" checked> Pan sync to movement</label>
|
||||
<div class="field">
|
||||
<label>Pan Rate:</label>
|
||||
<input type="range" id="panRate" min="0.1" max="2" step="0.1" value="0.5">
|
||||
<span id="panRateLabel" class="small">0.5 Hz</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Cue Rate:</label>
|
||||
<input type="range" id="cueRate" min="0.1" max="3" step="0.1" value="1.2">
|
||||
<span id="cueRateLabel" class="small">1.2 Hz</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Volume:</label>
|
||||
<input type="range" id="volume" min="0" max="100" step="5" value="60">
|
||||
<span id="volumeLabel" class="small">60%</span>
|
||||
<!-- Left Column: Controls -->
|
||||
<aside class="sidebar">
|
||||
<section class="panel collapsible" id="visualPanel">
|
||||
<h2 role="button" tabindex="0">
|
||||
<span class="panel-icon">🎨</span>
|
||||
Visual Settings
|
||||
<span class="panel-actions">
|
||||
<label class="toggle" title="Toggle Dark/Light">
|
||||
<input type="checkbox" id="themeToggle">
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
</label>
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<div class="control-group">
|
||||
<label class="control-label">Shape</label>
|
||||
<div class="button-group" id="shapeControls">
|
||||
<button data-shape="circle" class="active">⚪ Circle</button>
|
||||
<button data-shape="square">⬛ Square</button>
|
||||
<button data-shape="triangle">🔺 Triangle</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="fileControls" class="row">
|
||||
<button id="pickAudioFolder">Choose Folder</button>
|
||||
<select id="audioSelect"><option value="">No audio</option></select>
|
||||
|
||||
<div class="control-group">
|
||||
<label class="control-label">Shape Color</label>
|
||||
<div class="color-swatches" id="shapeSwatches">
|
||||
<button class="swatch swatch-green" data-color="#00ff00" title="Green"></button>
|
||||
<button class="swatch swatch-blue" data-color="#0066ff" title="Blue"></button>
|
||||
<button class="swatch swatch-yellow" data-color="#ffd400" title="Yellow"></button>
|
||||
<button class="swatch swatch-red" data-color="#ff2a2a" title="Red"></button>
|
||||
<button class="swatch swatch-white" data-color="#ffffff" title="White"></button>
|
||||
<button class="swatch swatch-custom" id="shapeCustom" title="Custom Color"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" id="deviceControls">
|
||||
<label>Controller:</label>
|
||||
<button id="connectHID">Connect HID Device</button>
|
||||
<span id="deviceStatus" class="small">Not connected</span>
|
||||
<label>Sensitivity:</label>
|
||||
<input type="range" id="knobSens" min="1" max="50" step="1" value="5">
|
||||
<span id="knobSensLabel" class="small">x5</span>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Preview</h2>
|
||||
<div class="preview previewContainer" id="previewContainer">
|
||||
<canvas class="previewCanvas" id="previewCanvas" width="640" height="360"></canvas>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Speed:</label>
|
||||
<input type="range" id="speed" min="50" max="600" step="10" value="250">
|
||||
<span id="speedLabel" class="small">250 px/s</span>
|
||||
</div>
|
||||
<div class="row controls" id="speedBoost">
|
||||
<label>Speed Boost:</label>
|
||||
<button data-mult="1" class="active">1×</button>
|
||||
<button data-mult="2">2×</button>
|
||||
<button data-mult="3">3×</button>
|
||||
<button data-mult="4">4×</button>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="row">
|
||||
<label>Timer (minutes):</label>
|
||||
<input type="number" id="timerMinutes" min="0" value="10" style="width:80px">
|
||||
<span id="timerDisplay" class="small">10:00</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="primary" id="startSession">Start Session</button>
|
||||
<button class="danger" id="endSession">End Session</button>
|
||||
</div>
|
||||
<div class="metrics">
|
||||
<div>Status: <span id="statusText">Idle</span></div>
|
||||
<div>Elapsed: <span id="elapsedText">00:00</span></div>
|
||||
</div>
|
||||
<div class="row" id="patientControls">
|
||||
<label>Patient:</label>
|
||||
<input type="text" id="patientName" placeholder="Patient Name" style="width:180px">
|
||||
<button id="savePatient">Save Patient</button>
|
||||
<select id="patientList" style="min-width:160px"></select>
|
||||
<button id="loadPatient">Load Patient</button>
|
||||
<button id="clearSessions">Clear Sessions</button>
|
||||
<button id="deletePatient" class="danger">Delete Patient</button>
|
||||
<span class="small">Sessions: <span id="sessionCount">0</span></span>
|
||||
<span class="small">Last: <span id="lastSessionInfo">—</span></span>
|
||||
</div>
|
||||
<div class="row" id="patientHistory">
|
||||
<label>Session History:</label>
|
||||
<div id="sessionHistory" class="small" style="max-height:160px; overflow:auto; flex:1"></div>
|
||||
</div>
|
||||
<div class="small">This preview mirrors the patient's view.</div>
|
||||
<div class="panel" style="margin-top:8px">
|
||||
<h2>Network</h2>
|
||||
<div class="row">
|
||||
<label>Relay:</label>
|
||||
<span id="relayStatusDot" class="statusDot"></span>
|
||||
<span id="relayStatusText" class="small">Not running</span>
|
||||
<button id="startRelayBtn">Copy Start Command</button>
|
||||
<button id="stopRelayBtn">Stop Server</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Step 3:</label>
|
||||
<input type="text" id="wsUrl" placeholder="ws://192.168.x.x:8787" style="width:220px">
|
||||
<input type="text" id="wsSession" placeholder="room-1" style="width:120px">
|
||||
<button id="detectIP">Detect Local IP</button>
|
||||
<button id="wsConnect">Connect</button>
|
||||
<button id="wsDisconnect">Disconnect</button>
|
||||
<span class="small">Status: <span id="wsStatus">Disconnected</span></span>
|
||||
|
||||
<div class="control-group">
|
||||
<label class="control-label">Background</label>
|
||||
<div class="color-swatches" id="bgSwatches">
|
||||
<button class="swatch swatch-black" data-color="#000000" title="Black"></button>
|
||||
<button class="swatch swatch-blue" data-color="#0066ff" title="Blue"></button>
|
||||
<button class="swatch swatch-yellow" data-color="#ffd400" title="Yellow"></button>
|
||||
<button class="swatch swatch-red" data-color="#ff2a2a" title="Red"></button>
|
||||
<button class="swatch swatch-white" data-color="#ffffff" title="White"></button>
|
||||
<button class="swatch swatch-custom" id="bgCustom" title="Custom Color"></button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Step 4:</label>
|
||||
<input type="text" id="joinLink" readonly style="flex:1; min-width:260px">
|
||||
<button id="copyJoinLink">Copy</button>
|
||||
</div>
|
||||
<div id="osHelp" class="small" style="margin-top:6px"></div>
|
||||
|
||||
<div class="control-group">
|
||||
<label class="control-label">Movement Direction</label>
|
||||
<div class="button-group" id="directionControls">
|
||||
<button data-direction="lr" class="active">HORIZ ↔️</button>
|
||||
<button data-direction="ud">VERT ↕️</button>
|
||||
<button data-direction="diag_down">DIAG L ↘️</button>
|
||||
<button data-direction="diag_up">DIAG R ↗️</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label class="control-label">Display Width</label>
|
||||
<div class="button-group" id="widthControls">
|
||||
<button data-width="full" class="active">Full</button>
|
||||
<button data-width="reduced">Reduced</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label class="control-label">Size: <span id="sizeLabel">28 px</span></label>
|
||||
<input type="range" id="size" min="10" max="100" step="0.5" value="28" class="slider">
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label class="control-label">Vertical Position: <span id="yPercentLabel">50%</span></label>
|
||||
<div class="slider-with-reset">
|
||||
<input type="range" id="yPercent" min="0" max="100" step="1" value="50" class="slider">
|
||||
<button id="yReset" class="btn-small">Reset</button>
|
||||
</div>
|
||||
<div class="control-row" style="margin-top:8px;">
|
||||
<div class="control-group">
|
||||
<label class="control-label" style="margin:0;">Vertical Oscillation</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="yOscillateEnable">
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label class="control-label">Gradual Speed Ramp</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="rampEnable">
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label class="control-label">Ramp Duration: <span id="rampLabel">10 s</span></label>
|
||||
<input type="range" id="rampSeconds" min="1" max="60" step="1" value="10" class="slider">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel collapsible" id="networkPanel">
|
||||
<h2 role="button" tabindex="0">
|
||||
<span class="panel-icon">✨</span>
|
||||
Effects
|
||||
</h2>
|
||||
|
||||
<div class="control-group">
|
||||
<label class="control-label">Glow Pulse Effect</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="glowEnable">
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="control-group control-indent">
|
||||
<label class="control-label">Intensity: <span id="glowIntensityLabel">100%</span></label>
|
||||
<input type="range" id="glowIntensity" min="0" max="200" step="5" value="100" class="slider">
|
||||
</div>
|
||||
|
||||
<div class="control-group control-indent">
|
||||
<label class="control-label">Rate: <span id="glowRateLabel">0.5 Hz</span></label>
|
||||
<input type="range" id="glowRate" min="0.1" max="3" step="0.1" value="0.5" class="slider">
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label class="control-label">Wiggle Effect</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="wiggleEnable">
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="control-group control-indent">
|
||||
<label class="control-label">Amount: <span id="wiggleLabel">0 px</span></label>
|
||||
<input type="range" id="wiggleAmplitude" min="0" max="10" step="1" value="0" class="slider">
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label class="control-label">Movement Style</label>
|
||||
<div class="button-group" id="easingControls">
|
||||
<button data-easing="linear" class="active">Linear</button>
|
||||
<button data-easing="ease">Smooth</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label class="control-label">Edge Pause: <span id="edgePauseLabel">0 ms</span></label>
|
||||
<input type="range" id="edgePause" min="0" max="150" step="10" value="0" class="slider">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel panel-recording collapsible" id="recordingPanel">
|
||||
<h2 role="button" tabindex="0">
|
||||
<span class="panel-icon">🔊</span>
|
||||
Audio Settings
|
||||
</h2>
|
||||
|
||||
<div class="control-group">
|
||||
<div class="button-group" id="audioModeButtons">
|
||||
<button data-mode="programmatic" class="active">Program</button>
|
||||
<button id="loadAudioBtn">Load Audio</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group" style="display:flex; align-items:center; justify-content:space-between; gap:8px;">
|
||||
<div>
|
||||
<label class="control-label">Enable Audio</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="audioEnable">
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
</label>
|
||||
</div>
|
||||
<button id="testSound" class="btn-small">Test Sound</button>
|
||||
</div>
|
||||
|
||||
<div id="programmaticControls" class="audio-controls">
|
||||
<div class="control-group">
|
||||
<div class="button-group" id="soundToneToggle">
|
||||
<button data-mode="sound" class="active">Sound</button>
|
||||
<button data-mode="tone">Tone</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="soundPresetControls" class="control-group">
|
||||
<label class="control-label">Sound Type</label>
|
||||
<select id="soundPreset" class="select-input">
|
||||
<option value="pink">Pink Noise</option>
|
||||
<option value="hybrid">Hybrid (noise + edge cues)</option>
|
||||
<option value="click">Click</option>
|
||||
<option value="ping">Ping</option>
|
||||
<option value="woodblock">Woodblock</option>
|
||||
<option value="bell">Bell</option>
|
||||
<option value="bass">Bass</option>
|
||||
<option value="kick">Kick</option>
|
||||
<option value="snare">Snare</option>
|
||||
<option value="hihat">Hi-Hat</option>
|
||||
<option value="sweep">Sweep</option>
|
||||
<option value="zap">Zap</option>
|
||||
<option value="bubble">Bubble</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="tonePresetControls" class="control-group">
|
||||
<label class="control-label">Tone Preset</label>
|
||||
<select id="tonePreset" class="select-input">
|
||||
<option value="warm_low">Warm Low — 180 Hz @ 100 ms</option>
|
||||
<option value="soft_pulse">Soft Pulse — 220 Hz @ 90 ms</option>
|
||||
<option value="neutral_pulse">Neutral Pulse — 280 Hz @ 90 ms</option>
|
||||
<option value="bright_pulse">Bright Pulse — 330 Hz @ 80 ms</option>
|
||||
<option value="soft_tick">Soft Tick — 440 Hz @ 70 ms</option>
|
||||
<option value="medium_tick">Medium Tick — 520 Hz @ 60 ms</option>
|
||||
<option value="clear_click">Clear Click — 660 Hz @ 40 ms</option>
|
||||
<option value="bright_ping">Bright Ping — 880 Hz @ 50 ms</option>
|
||||
<option value="light_ping">Light Ping — 1000 Hz @ 50 ms</option>
|
||||
<option value="sharp_cue">Sharp Cue — 1200 Hz @ 30 ms</option>
|
||||
<option value="accent">Accent — 1500 Hz @ 30 ms</option>
|
||||
<option value="spark">Spark — 1800 Hz @ 30 ms</option>
|
||||
<option value="crisp">Crisp — 2000 Hz @ 30 ms</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="toneFreqRow" class="control-group">
|
||||
<label class="control-label">Frequency (Hz)</label>
|
||||
<input type="number" id="toneFreq" min="100" max="2000" step="10" value="440" class="number-input">
|
||||
</div>
|
||||
|
||||
<div id="toneDurRow" class="control-group">
|
||||
<label class="control-label">Duration: <span id="toneDurationLabel">70 ms</span></label>
|
||||
<input type="range" id="toneDuration" min="30" max="150" step="5" value="70" class="slider">
|
||||
</div>
|
||||
|
||||
<div id="adsrField" class="adsr-controls">
|
||||
<details class="adsr-details">
|
||||
<summary>Advanced: ADSR Envelope</summary>
|
||||
<div class="adsr-grid">
|
||||
<div class="adsr-control">
|
||||
<label>Attack: <span id="adsrAttackLabel">8 ms</span></label>
|
||||
<input type="range" id="adsrAttack" min="5" max="20" step="1" value="8" class="slider">
|
||||
</div>
|
||||
<div class="adsr-control">
|
||||
<label>Decay: <span id="adsrDecayLabel">80 ms</span></label>
|
||||
<input type="range" id="adsrDecay" min="40" max="120" step="5" value="80" class="slider">
|
||||
</div>
|
||||
<div class="adsr-control">
|
||||
<label>Sustain: <span id="adsrSustainLabel">0.30</span></label>
|
||||
<input type="range" id="adsrSustain" min="0" max="1" step="0.05" value="0.3" class="slider">
|
||||
</div>
|
||||
<div class="adsr-control">
|
||||
<label>Release: <span id="adsrReleaseLabel">10 ms</span></label>
|
||||
<input type="range" id="adsrRelease" min="5" max="50" step="1" value="10" class="slider">
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label class="control-label">Pan synced to movement</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="syncPan" checked>
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label class="control-label">Pan Rate: <span id="panRateLabel">0.5 Hz</span></label>
|
||||
<input type="range" id="panRate" min="0.1" max="2" step="0.1" value="0.5" class="slider">
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label class="control-label">Cue Rate: <span id="cueRateLabel">1.2 Hz</span></label>
|
||||
<input type="range" id="cueRate" min="0.1" max="3" step="0.1" value="1.2" class="slider">
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label class="control-label">Volume: <span id="volumeLabel">60%</span></label>
|
||||
<input type="range" id="volume" min="0" max="100" step="5" value="60" class="slider">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</section>
|
||||
<section class="panel collapsible" id="presetsPanel">
|
||||
<h2 role="button" tabindex="0">PRESETS</h2>
|
||||
|
||||
<div class="preset-controls">
|
||||
<div class="control-row">
|
||||
<input type="text" id="presetName" placeholder="Preset name..." class="text-input flex-1">
|
||||
<button id="savePreset" class="btn-secondary">Save</button>
|
||||
</div>
|
||||
|
||||
<div class="control-row">
|
||||
<select id="presetList" class="select-input flex-1"></select>
|
||||
<button id="applyPreset" class="btn-secondary">Apply</button>
|
||||
<button id="deletePreset" class="btn-danger-outline">Delete</button>
|
||||
</div>
|
||||
|
||||
<div class="control-row">
|
||||
<button id="exportPresets" class="btn-secondary">Export JSON</button>
|
||||
<button id="importPresets" class="btn-secondary">Import JSON</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="controllersPanel" class="panel collapsible">
|
||||
<h2 role="button" tabindex="0">CONTROL</h2>
|
||||
<div class="controller-content">
|
||||
<div class="status-display">
|
||||
<span id="ctlStatusDot" class="status-dot"></span>
|
||||
<span id="ctlStatusText" class="status-value">No device</span>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label class="control-label">MIDI Input</label>
|
||||
<select id="midiSelect" class="select-input"></select>
|
||||
</div>
|
||||
|
||||
<div class="mapping-section">
|
||||
<h3>Map Actions</h3>
|
||||
<div class="mapping-buttons">
|
||||
<button data-map-action="start" class="btn-map">Start Session</button>
|
||||
<button data-map-action="stop" class="btn-map">End Session</button>
|
||||
<button data-map-action="speed_up" class="btn-map">Speed +</button>
|
||||
<button data-map-action="speed_down" class="btn-map">Speed −</button>
|
||||
<button data-map-action="volume_up" class="btn-map">Volume +</button>
|
||||
<button data-map-action="volume_down" class="btn-map">Volume −</button>
|
||||
<button data-map-action="audio_toggle" class="btn-map">Audio Toggle</button>
|
||||
<button data-map-action="glow_toggle" class="btn-map">Glow Toggle</button>
|
||||
<button data-map-action="wiggle_toggle" class="btn-map">Wiggle Toggle</button>
|
||||
<button data-map-action="lock_toggle" class="btn-map">Lock Toggle</button>
|
||||
<button data-map-action="record_start" class="btn-map">Record Start</button>
|
||||
<button data-map-action="record_stop" class="btn-map">Record Stop</button>
|
||||
<button data-map-action="replay" class="btn-map">Replay</button>
|
||||
<button data-map-action="shape_next" class="btn-map">Cycle Shape</button>
|
||||
<button data-map-action="direction_next" class="btn-map">Cycle Direction</button>
|
||||
<button data-map-action="easing_next" class="btn-map">Cycle Smoothness</button>
|
||||
<button data-map-action="program_next" class="btn-map">Cycle Audio Preset</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mapping-section">
|
||||
<h3>Current Mappings</h3>
|
||||
<div id="ctlMappings" class="mappings-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="mapping-section">
|
||||
<h3>Hardware Controller</h3>
|
||||
<div class="control-row">
|
||||
<button id="connectHID" class="btn-secondary">Connect HID Device</button>
|
||||
<span id="deviceStatus" class="status-value">Not connected</span>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label class="control-label">Sensitivity: <span id="knobSensLabel">x5</span></label>
|
||||
<input type="range" id="knobSens" min="1" max="50" step="1" value="5" class="slider">
|
||||
</div>
|
||||
<div class="control-group" style="display:flex; align-items:center; justify-content:space-between; gap:8px;">
|
||||
<div>
|
||||
<label class="control-label">Enable Lock Hotkey (L to lock, U to unlock)</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="lockHotkeyEnable" checked>
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<!-- Right Column: Session & Preview -->
|
||||
<div class="main-content">
|
||||
<section class="panel panel-session collapsible">
|
||||
<h2 role="button" tabindex="0">
|
||||
<span class="panel-icon">⚡</span>
|
||||
Session Control
|
||||
</h2>
|
||||
|
||||
<div class="preview-container">
|
||||
<canvas id="previewCanvas" class="preview-canvas" width="640" height="360"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="session-actions" style="margin-top:10px;">
|
||||
<button id="startSession" class="btn-primary btn-large">
|
||||
<span class="btn-icon">▶️</span>
|
||||
Start Session
|
||||
</button>
|
||||
<button id="endSession" class="btn-danger btn-large">
|
||||
<span class="btn-icon">⏹️</span>
|
||||
End Session
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="session-controls">
|
||||
<div class="control-row">
|
||||
<div class="control-group">
|
||||
<label class="control-label">Speed: <span id="speedLabel">250 px/s</span></label>
|
||||
<div class="speed-control">
|
||||
<button id="speedDown" class="btn-adjust">−</button>
|
||||
<input type="range" id="speed" min="50" max="1800" step="1" value="250" class="slider slider-wide">
|
||||
<button id="speedUp" class="btn-adjust">+</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="timer-section">
|
||||
<div id="patientViewportShort" class="status-value" style="margin-bottom:8px; font-size:12px; opacity:0.8;">—</div>
|
||||
<div class="timer-display">
|
||||
<div class="timer-box">
|
||||
<label>Duration (minutes)</label>
|
||||
<input type="number" id="timerMinutes" min="0" value="10" class="timer-input">
|
||||
</div>
|
||||
<div class="timer-box">
|
||||
<label>Remaining</label>
|
||||
<div id="timerDisplay" class="timer-value">10:00</div>
|
||||
</div>
|
||||
<div class="timer-box">
|
||||
<label>Elapsed</label>
|
||||
<div id="elapsedText" class="timer-value">00:00</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="status-bar">
|
||||
<span class="status-label">Status:</span>
|
||||
<span id="statusText" class="status-value">Idle</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel panel-recording collapsible">
|
||||
<h2 role="button" tabindex="0">
|
||||
<span class="panel-icon"><EFBFBD></span>
|
||||
Recording & Playback
|
||||
</h2>
|
||||
|
||||
<div class="recording-controls">
|
||||
<div class="control-row">
|
||||
<button id="recStart" class="btn-record">
|
||||
<span class="record-dot">●</span>
|
||||
Record
|
||||
</button>
|
||||
<button id="recStop" class="btn-secondary">Stop & Save</button>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="control-row">
|
||||
<select id="recList" class="select-input flex-1"></select>
|
||||
<button id="recPlay" class="btn-secondary">Replay</button>
|
||||
<button id="recExport" class="btn-secondary">Export</button>
|
||||
<button id="recImport" class="btn-secondary">Import</button>
|
||||
</div>
|
||||
|
||||
<div class="control-row">
|
||||
<button id="recDelete" class="btn-danger-outline">Delete</button>
|
||||
<button id="recClear" class="btn-danger-outline">Clear All</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<section class="panel collapsible">
|
||||
<h2 role="button" tabindex="0">
|
||||
<span class="panel-icon">👤</span>
|
||||
Patient Management
|
||||
</h2>
|
||||
|
||||
<div class="patient-controls">
|
||||
<div class="control-row">
|
||||
<input type="text" id="patientName" placeholder="Patient name..." class="text-input flex-1">
|
||||
<button id="savePatient" class="btn-secondary">Save</button>
|
||||
</div>
|
||||
|
||||
<div class="control-row">
|
||||
<select id="patientList" class="select-input flex-1"></select>
|
||||
<button id="loadPatient" class="btn-secondary">Load</button>
|
||||
<button id="deletePatient" class="btn-danger-outline">Delete</button>
|
||||
</div>
|
||||
|
||||
<div class="patient-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Total Sessions:</span>
|
||||
<span id="sessionCount" class="stat-value">0</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Last Session:</span>
|
||||
<span id="lastSessionInfo" class="stat-value">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label class="control-label">Session History</label>
|
||||
<div id="sessionHistory" class="session-history"></div>
|
||||
<button id="clearSessions" class="btn-danger-outline btn-small">Clear All Sessions</button>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label class="control-label">Session Notes</label>
|
||||
<div class="control-row">
|
||||
<select id="sessionSelect" class="select-input flex-1"></select>
|
||||
<button id="loadSession" class="btn-secondary btn-small">Load</button>
|
||||
<button id="saveSessionNotes" class="btn-secondary btn-small">Save Notes</button>
|
||||
<button id="deleteSession" class="btn-danger-outline btn-small">Delete Session</button>
|
||||
</div>
|
||||
<textarea id="sessionNotes" class="textarea-input" placeholder="Enter session notes..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel collapsible" id="networkPanelSection">
|
||||
<h2 id="networkHeading" role="button" tabindex="0">
|
||||
<span class="panel-icon">🌐</span>
|
||||
Network Connection
|
||||
</h2>
|
||||
|
||||
<div class="network-controls">
|
||||
<div class="control-group">
|
||||
<label class="control-label">Connection Mode</label>
|
||||
<select id="netMode" class="select-input">
|
||||
<option value="relay">Relay (WebSocket)</option>
|
||||
<option value="webrtc">WebRTC (P2P)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="webrtcControls" class="webrtc-section">
|
||||
<div class="control-group">
|
||||
<button id="rtcCreateOffer" class="btn-secondary">Create Offer</button>
|
||||
<input type="text" id="rtcOffer" placeholder="Offer (base64)" class="text-input flex-1">
|
||||
</div>
|
||||
<div class="qr-container">
|
||||
<img id="rtcOfferQR" alt="Offer QR Code" class="qr-code">
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<button id="rtcAcceptAnswer" class="btn-secondary">Accept Answer</button>
|
||||
<input type="text" id="rtcAnswer" placeholder="Answer (base64)" class="text-input flex-1">
|
||||
</div>
|
||||
<div class="status-display">
|
||||
<span class="status-label">WebRTC Status:</span>
|
||||
<span id="rtcStatus" class="status-value">Idle</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relay-section">
|
||||
<div class="status-display">
|
||||
<span id="relayStatusDot" class="status-dot"></span>
|
||||
<span id="relayStatusText" class="status-value">Not running</span>
|
||||
</div>
|
||||
|
||||
<div class="control-row">
|
||||
<button id="startRelayBtn" class="btn-secondary">Copy Start Command</button>
|
||||
<button id="stopRelayBtn" class="btn-danger-outline">Stop Server</button>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label class="control-label">Connection Details</label>
|
||||
<div class="connection-inputs">
|
||||
<input type="text" id="wsUrl" placeholder="ws://192.168.x.x:8787" class="text-input">
|
||||
<input type="text" id="wsSession" placeholder="room-1" class="text-input">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-row">
|
||||
<button id="detectIP" class="btn-secondary">Detect Local IP</button>
|
||||
<button id="wsConnect" class="btn-secondary">Connect</button>
|
||||
<button id="wsDisconnect" class="btn-danger-outline">Disconnect</button>
|
||||
</div>
|
||||
|
||||
<div class="status-display">
|
||||
<span class="status-label">Status:</span>
|
||||
<span id="wsStatus" class="status-value">Disconnected</span>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label class="control-label">Patient Join Link</label>
|
||||
<div class="control-row">
|
||||
<input type="text" id="joinLink" readonly class="text-input flex-1">
|
||||
<button id="copyJoinLink" class="btn-secondary">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="osHelp" class="help-text"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
<script src="js/bls.js"></script>
|
||||
<script src="js/audio.js"></script>
|
||||
<script src="js/webrtc.js"></script>
|
||||
<script src="js/recorder.js"></script>
|
||||
<script src="js/recording-store.js"></script>
|
||||
<script src="js/controllers.js"></script>
|
||||
<script src="js/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
368
js/admin.js
368
js/admin.js
@ -4,20 +4,28 @@ function initAdmin(){
|
||||
var chan = BLS.createChannel('bls-session');
|
||||
var state = BLS.loadState();
|
||||
renderer.setState(state);
|
||||
var shapeControls = document.getElementById('shapeControls');
|
||||
window.AdminRenderer = renderer;
|
||||
var shapeControls = document.getElementById('shapeControls');
|
||||
var directionControls = document.getElementById('directionControls');
|
||||
var easingControls = document.getElementById('easingControls');
|
||||
var shapeColor = document.getElementById('shapeColor');
|
||||
var bgColor = document.getElementById('bgColor');
|
||||
var shapePreset = document.getElementById('shapePreset');
|
||||
var bgPreset = document.getElementById('bgPreset');
|
||||
var speed = document.getElementById('speed');
|
||||
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');
|
||||
@ -45,13 +53,25 @@ var elapsedText = document.getElementById('elapsedText');
|
||||
var startBtn = document.getElementById('startSession');
|
||||
var endBtn = document.getElementById('endSession');
|
||||
var openPatient = document.getElementById('openPatient');
|
||||
var audioSelect = document.getElementById('audioSelect');
|
||||
var pickAudioFolder = document.getElementById('pickAudioFolder');
|
||||
var loadAudioBtn = document.getElementById('loadAudioBtn');
|
||||
var audioModeButtons = document.getElementById('audioModeButtons');
|
||||
var audioEnable = document.getElementById('audioEnable');
|
||||
var audioMode = document.getElementById('audioMode');
|
||||
var programControls = document.getElementById('programmaticControls');
|
||||
var fileControls = document.getElementById('fileControls');
|
||||
var programPreset = document.getElementById('programPreset');
|
||||
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');
|
||||
@ -72,6 +92,11 @@ var endBtn = document.getElementById('endSession');
|
||||
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<btns.length;i++){
|
||||
@ -82,7 +107,7 @@ function setActive(group, dataKey, stateKey){
|
||||
}
|
||||
function updateTimerLabel(){ var m = Math.max(0, parseInt(timerMinutes.value||'0',10)); timerDisplay.textContent = String(m).padStart(2,'0')+':00'; }
|
||||
function updateSpeedLabel(){ speedLabel.textContent = speed.value+' px/s'; }
|
||||
function updateSizeLabel(){ sizeLabel.textContent = size.value+' px'; }
|
||||
function updateSizeLabel(){ var v = parseFloat(size.value||'0'); sizeLabel.textContent = v.toFixed(1)+' px'; }
|
||||
function updateGlowIntensityLabel(){ glowIntensityLabel.textContent = glowIntensity.value+'%'; }
|
||||
function updateGlowRateLabel(){ glowRateLabel.textContent = parseFloat(glowRate.value).toFixed(1)+' Hz'; }
|
||||
function updateYPercentLabel(){ yPercentLabel.textContent = yPercent.value+'%'; }
|
||||
@ -92,7 +117,7 @@ function updateTimerLabel(){ var m = Math.max(0, parseInt(timerMinutes.value||'0
|
||||
function updatePanRateLabel(){ panRateLabel.textContent = parseFloat(panRate.value).toFixed(1)+' Hz' }
|
||||
function updateCueRateLabel(){ cueRateLabel.textContent = parseFloat(cueRate.value).toFixed(1)+' Hz' }
|
||||
function updateVolumeLabel(){ volumeLabel.textContent = volume.value+'%' }
|
||||
function refreshAudioMode(){ var m = audioMode.value; programControls.style.display = (m==='programmatic' ? 'flex' : 'none'); fileControls.style.display = (m==='file' ? 'flex' : 'none') }
|
||||
function setProgramMode(){ if(programControls) programControls.style.display = 'flex'; applyState({audioMode: 'programmatic'}, true) }
|
||||
function updateKnobSensLabel(){ knobSensLabel.textContent = 'x'+knobSens.value }
|
||||
function refreshPatientList(){ var m = loadPatients(); var names = Object.keys(m).sort(); var html = names.map(function(n){ return '<option value="'+n+'">'+n+'</option>' }).join(''); var sel = document.getElementById('patientList'); if(sel){ sel.innerHTML = html }
|
||||
}
|
||||
@ -101,69 +126,163 @@ function updateTimerLabel(){ var m = Math.max(0, parseInt(timerMinutes.value||'0
|
||||
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 = '<em>No sessions yet</em>' } 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 '<div>'+String(idx+1)+'. '+start+' → '+end+' • '+dur+'s</div>' }); host.innerHTML = rows.join('') } } }
|
||||
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 }); p.count = (p.count||0)+1; p.last = { start: st.startedAt, end: end, durationSec: durationSec }; setPatient(p); refreshPatientUI() }
|
||||
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 = '<em>No sessions yet</em>' } 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 '<div>'+String(idx+1)+'. '+start+' → '+end+' • '+dur+'s</div>' }); 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 '<option value="'+o.i+'">'+o.label+'</option>' }).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<names.length;i++){ var n=names[i]; html += '<option value="'+n+'">'+n+'</option>' } presetList.innerHTML = html }
|
||||
function applyState(next, broadcast){
|
||||
var prevRunning = state && !!state.running;
|
||||
state = BLS.mergeState(state, next||{});
|
||||
renderer.setState(state);
|
||||
if(state.running){ renderer.start() } else { renderer.stop() }
|
||||
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 publishState(){ try{ if(wsConnected && ws){ ws.send(JSON.stringify({type:'state', sessionId: wsSid||'default', payload: BLS.loadState()})) } }catch(e){} }
|
||||
shapeControls.addEventListener('click', function(e){ var d = e.target.dataset.shape; if(!d) return; applyState({shape:d}, true); setActive(shapeControls,'shape','shape'); });
|
||||
directionControls.addEventListener('click', function(e){ var d = e.target.dataset.direction; if(!d) return; applyState({direction:d}, true); setActive(directionControls,'direction','direction'); });
|
||||
easingControls.addEventListener('click', function(e){ var d = e.target.dataset.easing; if(!d) return; applyState({easingMode:d}, true); setActive(easingControls,'easing','easingMode'); });
|
||||
shapeColor.addEventListener('input', function(){ applyState({shapeColor:shapeColor.value}, true); });
|
||||
bgColor.addEventListener('input', function(){ applyState({bgColor:bgColor.value}, true); });
|
||||
shapePreset.addEventListener('click', function(e){ var c = e.target.dataset.color; if(!c) return; shapeColor.value = c; applyState({shapeColor:c}, true); });
|
||||
bgPreset.addEventListener('click', function(e){ var c = e.target.dataset.color; if(!c) return; bgColor.value = c; applyState({bgColor:c}, true); });
|
||||
speed.addEventListener('input', function(){ updateSpeedLabel(); var m = (BLS.loadState().speedMultiplier||1); applyState({speed:parseInt(speed.value,10)*m}, true); publishState() });
|
||||
size.addEventListener('input', function(){ updateSizeLabel(); applyState({size:parseInt(size.value,10)}, true); });
|
||||
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;i<btns.length;i++){ var b=btns[i]; b.classList.toggle('active', b.dataset.width===w) } }) }
|
||||
|
||||
function setCustomColor(key, val){ try{ localStorage.setItem(key, val) }catch(_){ } }
|
||||
function getCustomColor(key, def){ try{ var v = localStorage.getItem(key); return v||def }catch(_){ return def } }
|
||||
if(shapeSwatches){ shapeSwatches.addEventListener('click', function(e){ var b = e.target; var c = b.dataset && b.dataset.color; if(!c && b.id==='shapeCustom'){ hiddenShapePicker.value = getCustomColor('bls_shape_custom', '#ffffff'); hiddenShapePicker.click(); hiddenShapePicker.oninput = function(){ var v = hiddenShapePicker.value; setCustomColor('bls_shape_custom', v); applyState({shapeColor: v}, true) }; return } if(!c) return; applyState({shapeColor: c}, true) }) }
|
||||
if(bgSwatches){ bgSwatches.addEventListener('click', function(e){ var b = e.target; var c = b.dataset && b.dataset.color; if(!c && b.id==='bgCustom'){ hiddenBgPicker.value = getCustomColor('bls_bg_custom', '#000000'); hiddenBgPicker.click(); hiddenBgPicker.oninput = function(){ var v = hiddenBgPicker.value; setCustomColor('bls_bg_custom', v); applyState({bgColor: v}, true) }; return } if(!c) return; applyState({bgColor: c}, true) }) }
|
||||
speed.addEventListener('input', function(){ updateSpeedLabel(); applyState({speed:parseFloat(speed.value)}, true); });
|
||||
size.addEventListener('input', function(){ updateSizeLabel(); applyState({size:parseFloat(size.value)}, true); });
|
||||
yPercent.addEventListener('input', function(){ updateYPercentLabel(); applyState({yPercent:parseInt(yPercent.value,10)}, true); });
|
||||
yReset.addEventListener('click', function(){ yPercent.value=50; updateYPercentLabel(); applyState({yPercent:50}, true); });
|
||||
glowEnable.addEventListener('change', function(){ applyState({glowEnabled: glowEnable.checked}, true); publishState() });
|
||||
glowIntensity.addEventListener('input', function(){ updateGlowIntensityLabel(); var f = parseInt(glowIntensity.value,10)/100; applyState({glowIntensity:f}, true); publishState() });
|
||||
glowRate.addEventListener('input', function(){ updateGlowRateLabel(); applyState({glowRate: parseFloat(glowRate.value)}, true); publishState() });
|
||||
rampEnable.addEventListener('change', function(){ var st = BLS.loadState(); var next = {rampEnabled: rampEnable.checked}; if(!rampEnable.checked){ next.easingMode = 'linear' } if(st.running && rampEnable.checked){ next.startedAt = Date.now() } applyState(next, true); publishState() });
|
||||
rampSeconds.addEventListener('input', function(){ updateRampLabel(); var st = BLS.loadState(); var next = {rampSeconds: parseInt(rampSeconds.value,10)}; if(st.running && st.rampEnabled){ next.startedAt = Date.now() } applyState(next, true); publishState() });
|
||||
edgePause.addEventListener('input', function(){ updateEdgePauseLabel(); applyState({edgePauseMs: parseInt(edgePause.value,10)}, true); publishState() });
|
||||
wiggleEnable.addEventListener('change', function(){ applyState({wiggleEnabled: wiggleEnable.checked}, true); publishState() });
|
||||
wiggleAmplitude.addEventListener('input', function(){ updateWiggleLabel(); applyState({wiggleAmplitude: parseInt(wiggleAmplitude.value,10)}, true); publishState() });
|
||||
function startYOsc(){ if(yOscTick) clearInterval(yOscTick); var startT = Date.now(); yOscTick = setInterval(function(){ var st = BLS.loadState(); if(!st.running || !st.yOscillateEnabled){ return } var t = (Date.now() - startT)/1000; var fy = st.yOscillateRate || 0.5; var p = 50 + 50*Math.sin(2*Math.PI*fy*t); applyState({ yPercent: Math.round(Math.max(0, Math.min(100, p))) }, true) }, 50) }
|
||||
if(yOscillateEnable){ yOscillateEnable.addEventListener('change', function(){ var on = !!yOscillateEnable.checked; applyState({ yOscillateEnabled: on }, true); if(on){ startYOsc() } else { if(yOscTick){ clearInterval(yOscTick); yOscTick=null } } }) }
|
||||
glowEnable.addEventListener('change', function(){ applyState({glowEnabled: glowEnable.checked}, true); });
|
||||
glowIntensity.addEventListener('input', function(){ updateGlowIntensityLabel(); var f = parseInt(glowIntensity.value,10)/100; applyState({glowIntensity:f}, true); });
|
||||
glowRate.addEventListener('input', function(){ updateGlowRateLabel(); applyState({glowRate: parseFloat(glowRate.value)}, true); });
|
||||
rampEnable.addEventListener('change', function(){ var st = BLS.loadState(); var next = {rampEnabled: rampEnable.checked}; if(!rampEnable.checked){ next.easingMode = 'linear' } if(st.running && rampEnable.checked){ next.startedAt = Date.now() } applyState(next, true); });
|
||||
rampSeconds.addEventListener('input', function(){ updateRampLabel(); var st = BLS.loadState(); var next = {rampSeconds: parseInt(rampSeconds.value,10)}; if(st.running && st.rampEnabled){ next.startedAt = Date.now() } applyState(next, true); });
|
||||
edgePause.addEventListener('input', function(){ updateEdgePauseLabel(); applyState({edgePauseMs: parseInt(edgePause.value,10)}, true); });
|
||||
wiggleEnable.addEventListener('change', function(){ applyState({wiggleEnabled: wiggleEnable.checked}, true); });
|
||||
wiggleAmplitude.addEventListener('input', function(){ updateWiggleLabel(); applyState({wiggleAmplitude: parseInt(wiggleAmplitude.value,10)}, true); });
|
||||
timerMinutes.addEventListener('input', function(){ updateTimerLabel(); });
|
||||
savePresetBtn.addEventListener('click', function(){ var n = (presetName.value||'').trim(); if(!n) return; BLS.savePreset(n, BLS.loadState()); refreshPresetList() })
|
||||
applyPresetBtn.addEventListener('click', function(){ var n = presetList.value; if(!n) return; var preset = BLS.loadPreset(n); if(!preset) return; applyState(preset, true); setActive(shapeControls,'shape','shape'); setActive(directionControls,'direction','direction'); setActive(easingControls,'easing','easingMode') })
|
||||
deletePresetBtn.addEventListener('click', function(){ var n = presetList.value; if(!n) return; BLS.deletePreset(n); refreshPresetList() })
|
||||
applyPresetBtn.addEventListener('click', function(){ var n = presetList.value; if(!n) return; var preset = BLS.loadPreset(n); if(!preset) return; var base = (BLS && BLS.defaultState) ? BLS.defaultState : {}; var full = Object.assign({}, base, preset); applyState(full, true) })
|
||||
deletePresetBtn.addEventListener('click', function(){ var n = presetList.value; if(!n) return; if(!confirm('Delete preset "'+n+'"?')) return; BLS.deletePreset(n); refreshPresetList() })
|
||||
exportPresetsBtn.addEventListener('click', async function(){ var data = BLS.exportPresets(); if(window.showSaveFilePicker){ try{ var handle = await window.showSaveFilePicker({ suggestedName: 'bls-presets.json', types:[{ description:'JSON', accept:{ 'application/json':['.json'] } }] }); var w = await handle.createWritable(); await w.write(data); await w.close() }catch(e){} } else { var a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([data], {type:'application/json'})); a.download = 'bls-presets.json'; a.click(); URL.revokeObjectURL(a.href) } })
|
||||
importPresetsBtn.addEventListener('click', async function(){ if(window.showOpenFilePicker){ try{ 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(); BLS.importPresets(text); refreshPresetList() } }catch(e){} } else { var input = document.createElement('input'); input.type='file'; input.accept='.json,application/json'; input.addEventListener('change', function(){ var f = input.files && input.files[0]; if(!f) return; var reader = new FileReader(); reader.onload = function(){ BLS.importPresets(reader.result); refreshPresetList() }; reader.readAsText(f) }); input.click() } })
|
||||
async function listAudioFromHandle(handle){ audioFiles=[]; if(!handle) return; try{ for await (const [name, h] of handle.entries()){ if(h.kind==='file' && /\.(mp3|wav)$/i.test(name)){ audioFiles.push({name:name, handle:h}) } } }catch(e){} audioSelect.innerHTML = '<option value="">No audio</option>' + audioFiles.map(function(f){ return '<option value="'+f.name+'">'+f.name+'</option>' }).join('') }
|
||||
async function pickFolder(){ if(window.showDirectoryPicker){ try{ var dir = await window.showDirectoryPicker(); await listAudioFromHandle(dir); }catch(e){} } else { var input = document.createElement('input'); input.type='file'; input.webkitdirectory=true; input.multiple=true; input.addEventListener('change', function(){ audioFiles = Array.from(input.files).filter(function(f){ return /\.(mp3|wav)$/i.test(f.name) }).map(function(f){ return {name:f.name, file:f} }); audioSelect.innerHTML = '<option value="">No audio</option>' + audioFiles.map(function(f){ return '<option value="'+f.name+'">'+f.name+'</option>' }).join(''); }); input.click(); } }
|
||||
pickAudioFolder.addEventListener('click', pickFolder);
|
||||
audioSelect.addEventListener('change', function(){ var name = audioSelect.value || null; applyState({audioName:name}, true) });
|
||||
async function pickAudioFile(){ if(window.showOpenFilePicker){ try{ var files = await window.showOpenFilePicker({ multiple:false, types:[{ description:'Audio', accept:{ 'audio/*':['.mp3','.wav'] } }] }); if(files && files.length){ var file = await files[0].getFile(); audioFiles = [{name:file.name, file:file}]; applyState({ audioName: file.name, audioMode: 'file' }, true); if(audioModeButtons){ var btns = audioModeButtons.querySelectorAll('button'); for(var i=0;i<btns.length;i++){ var b=btns[i]; b.classList.toggle('active', b.id==='loadAudioBtn') } } } }catch(_){ } } else { var input = document.createElement('input'); input.type='file'; input.accept='.mp3,.wav,audio/*'; input.addEventListener('change', function(){ var f = input.files && input.files[0]; if(!f) return; audioFiles = [{name:f.name, file:f}]; applyState({ audioName: f.name, audioMode: 'file' }, true); if(audioModeButtons){ var btns = audioModeButtons.querySelectorAll('button'); for(var i=0;i<btns.length;i++){ var b=btns[i]; b.classList.toggle('active', b.id==='loadAudioBtn') } } }); input.click(); }
|
||||
}
|
||||
if(loadAudioBtn){ loadAudioBtn.addEventListener('click', function(){ pickAudioFile(); }) }
|
||||
if(audioModeButtons){ audioModeButtons.addEventListener('click', function(e){ var m = e.target && e.target.dataset && e.target.dataset.mode; if(!m) return; if(m==='programmatic'){ setProgramMode(); var btns = audioModeButtons.querySelectorAll('button'); for(var i=0;i<btns.length;i++){ var b=btns[i]; var mode=b.dataset.mode; b.classList.toggle('active', mode==='programmatic') } } }) }
|
||||
audioEnable.addEventListener('change', function(){ applyState({audioEnabled: audioEnable.checked}, true) });
|
||||
audioMode.addEventListener('change', function(){ refreshAudioMode(); applyState({audioMode: audioMode.value}, true) });
|
||||
programPreset.addEventListener('change', function(){ applyState({programPreset: programPreset.value}, true); publishState() });
|
||||
syncPan.addEventListener('change', function(){ applyState({syncPan: syncPan.checked}, true); publishState() });
|
||||
panRate.addEventListener('input', function(){ updatePanRateLabel(); applyState({panRate: parseFloat(panRate.value)}, true); publishState() });
|
||||
cueRate.addEventListener('input', function(){ updateCueRateLabel(); applyState({cueRate: parseFloat(cueRate.value)}, true); publishState() });
|
||||
volume.addEventListener('input', function(){ updateVolumeLabel(); applyState({volume: parseInt(volume.value,10)/100}, true); publishState() });
|
||||
testSound.addEventListener('click', function(){ try{ var st = BLS.loadState(); if(st.audioMode==='programmatic'){ AudioEngine.setVolume(st.volume||0.6); var pan = 0; if(st.programPreset==='click'){ AudioEngine.playClick(pan) } else if(st.programPreset==='ping'){ AudioEngine.playPing(pan) } else if(st.programPreset==='woodblock'){ AudioEngine.playWoodblock(pan) } else if(st.programPreset==='bell'){ AudioEngine.playBell(pan) } else if(st.programPreset==='bass'){ AudioEngine.playBass(pan) } else if(st.programPreset==='kick'){ AudioEngine.playKick(pan) } else if(st.programPreset==='snare'){ AudioEngine.playSnare(pan) } else if(st.programPreset==='hihat'){ AudioEngine.playHiHat(pan) } else if(st.programPreset==='sweep'){ AudioEngine.playSweep(pan) } else if(st.programPreset==='zap'){ AudioEngine.playZap(pan) } else if(st.programPreset==='bubble'){ AudioEngine.playBubble(pan) } else if(st.programPreset==='pink'){ AudioEngine.startPinkNoisePan(st.panRate||0.5); setTimeout(function(){ AudioEngine.stopNoise() }, 800) } else if(st.programPreset==='hybrid'){ AudioEngine.startHybrid(st.cueRate||1.2); setTimeout(function(){ AudioEngine.stopNoise() }, 1000) } } }catch(e){} });
|
||||
function refreshProgramMode(){ var st = BLS.loadState(); var toneOn = (st.programPreset==='tone'); if(tonePresetControls) tonePresetControls.style.display = toneOn? 'flex':'none'; if(toneFreqRow) toneFreqRow.style.display = toneOn? 'flex':'none'; if(toneDurRow) toneDurRow.style.display = toneOn? 'flex':'none'; if(adsrField) adsrField.style.display = toneOn? 'block':'none'; if(soundPresetControls) soundPresetControls.style.display = toneOn? 'none':'flex'; if(soundToneToggle){ var btns = soundToneToggle.querySelectorAll('button'); for(var i=0;i<btns.length;i++){ var b=btns[i]; var m=b.dataset.mode; b.classList.toggle('active', (toneOn? m==='tone' : m==='sound')) } } }
|
||||
if(soundToneToggle){ soundToneToggle.addEventListener('click', function(e){ var m = e.target && e.target.dataset && e.target.dataset.mode; if(!m) return; if(m==='tone'){ applyState({programPreset:'tone'}, true) } else { var val = (soundPreset && soundPreset.value) || 'pink'; applyState({programPreset: val}, true) } refreshProgramMode(); }) }
|
||||
if(soundPreset){ soundPreset.addEventListener('change', function(){ var val = soundPreset.value||'pink'; applyState({programPreset: val}, true); refreshProgramMode(); }) }
|
||||
var tonePresetMap = {
|
||||
warm_low: { freq:180, dur:100 },
|
||||
soft_pulse: { freq:220, dur:90 },
|
||||
neutral_pulse: { freq:280, dur:90 },
|
||||
bright_pulse: { freq:330, dur:80 },
|
||||
soft_tick: { freq:440, dur:70 },
|
||||
medium_tick: { freq:520, dur:60 },
|
||||
clear_click: { freq:660, dur:40 },
|
||||
bright_ping: { freq:880, dur:50 },
|
||||
light_ping: { freq:1000, dur:50 },
|
||||
sharp_cue: { freq:1200, dur:30 },
|
||||
accent: { freq:1500, dur:30 },
|
||||
spark: { freq:1800, dur:30 },
|
||||
crisp: { freq:2000, dur:30 }
|
||||
};
|
||||
if(tonePreset){ tonePreset.addEventListener('change', function(){ var key = tonePreset.value; var p = tonePresetMap[key] || tonePresetMap.soft_pulse; toneFreq.value = String(p.freq); toneDuration.value = String(p.dur); toneDurationLabel.textContent = p.dur+' ms'; applyState({ toneFreq: p.freq, toneDurationMs: p.dur, programPreset: 'tone' }, true); refreshProgramMode(); }) }
|
||||
function updateToneDuration(){ toneDurationLabel.textContent = toneDuration.value+' ms' }
|
||||
function toneSelected(){ return (BLS.loadState().programPreset==='tone') }
|
||||
if(toneFreq){ toneFreq.addEventListener('input', function(){ if(!toneSelected()) return; applyState({ toneFreq: parseInt(toneFreq.value,10)||440 }, true); }) }
|
||||
if(toneDuration){ toneDuration.addEventListener('input', function(){ if(!toneSelected()) return; updateToneDuration(); applyState({ toneDurationMs: parseInt(toneDuration.value,10)||70 }, true); }) }
|
||||
if(adsrAttack){ adsrAttack.addEventListener('input', function(){ if(!toneSelected()) return; applyState({ adsrAttackMs: parseInt(adsrAttack.value,10)||8 }, true); }) }
|
||||
if(adsrDecay){ adsrDecay.addEventListener('input', function(){ if(!toneSelected()) return; applyState({ adsrDecayMs: parseInt(adsrDecay.value,10)||80 }, true); }) }
|
||||
if(adsrSustain){ adsrSustain.addEventListener('input', function(){ if(!toneSelected()) return; applyState({ adsrSustainLevel: parseFloat(adsrSustain.value)||0.3 }, true); }) }
|
||||
if(adsrRelease){ adsrRelease.addEventListener('input', function(){ if(!toneSelected()) return; applyState({ adsrReleaseMs: parseInt(adsrRelease.value,10)||10 }, true); }) }
|
||||
syncPan.addEventListener('change', function(){ applyState({syncPan: syncPan.checked}, true); });
|
||||
panRate.addEventListener('input', function(){ updatePanRateLabel(); applyState({panRate: parseFloat(panRate.value)}, true); });
|
||||
cueRate.addEventListener('input', function(){ updateCueRateLabel(); applyState({cueRate: parseFloat(cueRate.value)}, true); });
|
||||
volume.addEventListener('input', function(){ updateVolumeLabel(); applyState({volume: parseInt(volume.value,10)/100}, true); });
|
||||
testSound.addEventListener('click', function(){ try{ var st = BLS.loadState(); if(st.audioMode==='programmatic'){ AudioEngine.setVolume(st.volume||0.6); var pan = 0; if(st.programPreset==='tone'){ AudioEngine.playTone({ freq: st.toneFreq||440, durationMs: st.toneDurationMs||70, pan: pan, adsr:{ attackMs: st.adsrAttackMs||8, decayMs: st.adsrDecayMs||80, sustain: st.adsrSustainLevel||0.3, releaseMs: st.adsrReleaseMs||10 } }) } else if(st.programPreset==='click'){ AudioEngine.playClick(pan) } else if(st.programPreset==='ping'){ AudioEngine.playPing(pan) } else if(st.programPreset==='woodblock'){ AudioEngine.playWoodblock(pan) } else if(st.programPreset==='bell'){ AudioEngine.playBell(pan) } else if(st.programPreset==='bass'){ AudioEngine.playBass(pan) } else if(st.programPreset==='kick'){ AudioEngine.playKick(pan) } else if(st.programPreset==='snare'){ AudioEngine.playSnare(pan) } else if(st.programPreset==='hihat'){ AudioEngine.playHiHat(pan) } else if(st.programPreset==='sweep'){ AudioEngine.playSweep(pan) } else if(st.programPreset==='zap'){ AudioEngine.playZap(pan) } else if(st.programPreset==='bubble'){ AudioEngine.playBubble(pan) } else if(st.programPreset==='pink'){ AudioEngine.startPinkNoisePan(st.panRate||0.5); setTimeout(function(){ AudioEngine.stopNoise() }, 800) } else if(st.programPreset==='hybrid'){ AudioEngine.startHybrid(st.cueRate||1.2); setTimeout(function(){ AudioEngine.stopNoise() }, 1000) } } }catch(e){} });
|
||||
knobSens.addEventListener('input', function(){ updateKnobSensLabel() });
|
||||
if(clearSessionsBtn){ clearSessionsBtn.addEventListener('click', function(){ var n = (patientName.value||'').trim()||'Unnamed'; var p = getPatient(n); p.sessions = []; p.count = 0; p.last = null; setPatient(p); refreshPatientUI() }) }
|
||||
if(deletePatientBtn){ deletePatientBtn.addEventListener('click', function(){ var n = (patientName.value||'').trim()||'Unnamed'; deletePatient(n); patientName.value=''; refreshPatientList(); refreshPatientUI() }) }
|
||||
if(deletePatientBtn){ deletePatientBtn.addEventListener('click', function(){ var sel = document.getElementById('patientList'); var n = (sel && sel.value) ? sel.value : (patientName.value||'').trim(); if(!n){ try{ alert('Please select or enter a patient name') }catch(_){ } return } if(!confirm('Delete patient "'+n+'" and all their data?')) return; deletePatient(n); patientName.value=''; refreshPatientList(); refreshPatientUI() }) }
|
||||
var loadPatientBtn = document.getElementById('loadPatient');
|
||||
if(loadPatientBtn){ loadPatientBtn.addEventListener('click', function(){ var sel = document.getElementById('patientList'); if(!sel) return; var n = sel.value; if(!n) return; patientName.value = n; refreshPatientUI() }) }
|
||||
var speedBoost = document.getElementById('speedBoost');
|
||||
if(speedBoost){ speedBoost.addEventListener('click', function(e){ var m = e.target.dataset.mult; if(!m) return; var mult = parseInt(m,10)||1; var btns = speedBoost.querySelectorAll('button'); for(var i=0;i<btns.length;i++){ btns[i].classList.toggle('active', btns[i].dataset.mult==String(mult)) } var base = parseInt(speed.value,10)||250; updateSpeedLabel(); applyState({ speed: base*mult, speedMultiplier: mult }, true) }) }
|
||||
savePatientBtn.addEventListener('click', function(){ var n = (patientName.value||'').trim()||'Unnamed'; var p = getPatient(n); setPatient(p); refreshPatientUI() })
|
||||
if(saveSessionNotesBtn){ saveSessionNotesBtn.addEventListener('click', function(){ var n = (patientName.value||'').trim()||'Unnamed'; var p = getPatient(n); var idx = parseInt((sessionSelect && sessionSelect.value)||'-1',10); if(!p.sessions || !(p.sessions[idx])) return; p.sessions[idx].notes = (sessionNotesEl && sessionNotesEl.value)||''; setPatient(p); refreshPatientUI() }) }
|
||||
if(loadSessionBtn){ loadSessionBtn.addEventListener('click', function(){ var n = (patientName.value||'').trim()||'Unnamed'; var p = getPatient(n); var idx = parseInt((sessionSelect && sessionSelect.value)||'-1',10); if(!p.sessions || !(p.sessions[idx])) return; if(sessionNotesEl){ sessionNotesEl.value = p.sessions[idx].notes||'' } }) }
|
||||
if(deleteSessionSingleBtn){ deleteSessionSingleBtn.addEventListener('click', function(){ var n = (patientName.value||'').trim()||'Unnamed'; var p = getPatient(n); var idx = parseInt((sessionSelect && sessionSelect.value)||'-1',10); if(!p.sessions || !(p.sessions[idx])) return; p.sessions.splice(idx,1); p.count = (p.sessions||[]).length; if(p.count){ var sorted = p.sessions.slice().sort(function(a,b){ return a.start - b.start }); p.last = sorted[p.count-1] } else { p.last = null } setPatient(p); refreshPatientUI() }) }
|
||||
async function connectHIDDevice(){ if(!navigator.hid){ deviceStatus.textContent = 'HID not supported'; return } try{ var devices = await navigator.hid.requestDevice({filters: []}); if(!devices || !devices.length){ deviceStatus.textContent = 'No device selected'; return } hidDevice = devices[0]; await hidDevice.open(); deviceStatus.textContent = 'Connected: '+(hidDevice.productName||'HID'); hidDevice.addEventListener('inputreport', function(e){ var data = e.data; if(!data) return; var b0 = 0; try{ b0 = data.getInt8(0) }catch(_){ try{ b0 = data.getUint8(0); if(b0>127) 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 startTimer(){ if(tickId) clearInterval(tickId); var durationSec = Math.max(0, (parseInt(timerMinutes.value||'0',10)||0)*60); var startedAt = Date.now(); applyState({running:true, durationSec:durationSec, startedAt:startedAt}, true); statusText.textContent = 'Running'; tickId = setInterval(function(){ var st = BLS.loadState(); if(!st.running){ clearInterval(tickId); tickId=null; return } var elapsed = Math.floor((Date.now()-st.startedAt)/1000); 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 stopTimer(){ if(tickId){ clearInterval(tickId); tickId=null } recordSession(); applyState({running:false}, true); statusText.textContent = 'Idle'; refreshPatientUI() }
|
||||
startBtn.addEventListener('click', function(){ startTimer(); publishState() });
|
||||
endBtn.addEventListener('click', function(){ stopTimer(); publishState() });
|
||||
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);
|
||||
@ -227,26 +346,36 @@ function stopTimer(){ if(tickId){ clearInterval(tickId); tickId=null } recordSes
|
||||
}) }
|
||||
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? 'Running':'Idle'; setActive(shapeControls,'shape','shape'); setActive(directionControls,'direction','direction'); setActive(easingControls,'easing','easingMode');
|
||||
audioEnable.checked = !!state.audioEnabled; if(state.audioName){ var opt = document.createElement('option'); opt.value = state.audioName; opt.textContent = state.audioName; audioSelect.appendChild(opt); audioSelect.value = state.audioName }
|
||||
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);
|
||||
audioMode.value = state.audioMode || 'programmatic';
|
||||
programPreset.value = state.programPreset || 'pink';
|
||||
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);
|
||||
refreshAudioMode();
|
||||
|
||||
updateKnobSensLabel();
|
||||
yPercent.value = parseInt(state.yPercent||50,10);
|
||||
updateYPercentLabel();
|
||||
applyState({ yPercent: parseInt(yPercent.value,10) }, false);
|
||||
refreshPresetList(); refreshPatientList(); updateJoinLink();
|
||||
refreshPatientUI();
|
||||
}
|
||||
@ -257,14 +386,52 @@ if(document.readyState==='loading'){ document.addEventListener('DOMContentLoaded
|
||||
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 publishState(){ try{ if(wsConnected && ws){ ws.send(JSON.stringify({type:'state', sessionId: wsSid||'default', payload: BLS.loadState()})) } }catch(e){} }
|
||||
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<btns.length;i++){ var b = btns[i]; var isActive = (b.dataset && b.dataset[dataKey] === st[stateKey]); b.classList.toggle('active', !!isActive) } }
|
||||
function publishState(){
|
||||
try{
|
||||
var payload = {type:'state', sessionId: wsSid||'default', payload: BLS.loadState()}
|
||||
if(window.DEBUG_REC){ try{ console.log('Publishing state:', payload) }catch(_){} }
|
||||
if(!patientSyncOn){ return }
|
||||
if(netModeSel && netModeSel.value==='webrtc'){ if(window.NetRTC){ NetRTC.send(payload) } }
|
||||
else if(wsConnected && ws){ ws.send(JSON.stringify(payload)) }
|
||||
}catch(e){}
|
||||
}
|
||||
function updateJoinLink(){ var sid = (wsSession.value||'').trim(); var base = location.origin + '/patient.html'; if(sid){ joinLink.value = base + '#' + encodeURIComponent(sid) } else { joinLink.value = '' } }
|
||||
function renderOsHelp(){ var ua = navigator.userAgent||''; var isWin = /Windows/i.test(ua); var isMac = /Macintosh|Mac OS/i.test(ua); var isLinux = /Linux/i.test(ua) && !isWin && !isMac; var setup = (isWin? 'setup.bat' : 'chmod +x ./setup.sh && ./setup.sh'); var start = (isWin? 'start-relay.bat' : 'chmod +x ./start-relay.sh && ./start-relay.sh'); var open = (isWin? 'Open PowerShell' : isMac? 'Cmd+Space → Terminal' : 'Open Terminal'); return { setup: setup, start: start, open: open } }
|
||||
(function(){ var el = document.getElementById('osHelp'); if(!el) return; var h = renderOsHelp(); var text = 'First time: ' + h.open + ', cd to the folder, run: ' + h.setup + ' • Every session: ' + h.start; el.innerText = text })();
|
||||
@ -285,5 +452,88 @@ if(document.readyState==='loading'){ document.addEventListener('DOMContentLoaded
|
||||
})();
|
||||
if(startRelayBtn){ startRelayBtn.addEventListener('click', function(){ var h = renderOsHelp(); var ok = copyText(h.start); var old = startRelayBtn.textContent; startRelayBtn.textContent = ok? 'Copied!' : 'Copy Start Command'; setTimeout(function(){ startRelayBtn.textContent = old }, 1200) }) }
|
||||
if(stopRelayBtn){ stopRelayBtn.addEventListener('click', function(){ var host = location.hostname || 'localhost'; var url = 'ws://' + host + ':8787'; try{ var temp = new WebSocket(url); temp.onopen = function(){ try{ temp.send(JSON.stringify({type:'shutdown'})) }catch(e){} }; setTimeout(function(){ setRelayStatus(false) }, 1500) }catch(e){} }) }
|
||||
function wsConnect(){ var url = (wsUrl.value||'').trim(); var sid = (wsSession.value||'').trim()||'default'; var btn = document.getElementById('wsConnect'); if(btn && btn.disabled){ wsStatus.textContent = 'Relay not running'; return } if(ws){ try{ ws.close() }catch(e){} ws=null } if(!url){ wsStatus.textContent = 'Missing URL'; return } try{ ws = new WebSocket(url) }catch(e){ wsStatus.textContent = 'Error'; return } wsSid = sid; wsStatus.textContent = 'Connecting...'; ws.onopen = function(){ wsConnected = true; wsStatus.textContent = 'Connected'; try{ ws.send(JSON.stringify({type:'join', sessionId:sid, role:'admin'})) }catch(e){} publishState() }; ws.onclose = function(){ wsConnected = false; wsStatus.textContent = 'Disconnected' }; ws.onerror = function(){ wsStatus.textContent = 'Error' }; ws.onmessage = function(ev){ try{ var msg = JSON.parse(ev.data); if(msg && msg.type==='joined'){ wsStatus.textContent = 'Connected' } }catch(e){} } }
|
||||
function wsConnect(){ var url = (wsUrl.value||'').trim(); var sid = (wsSession.value||'').trim()||'default'; var btn = document.getElementById('wsConnect'); if(btn && btn.disabled){ wsStatus.textContent = 'Relay not running'; return } if(ws){ try{ ws.close() }catch(e){} ws=null } if(!url){ wsStatus.textContent = 'Missing URL'; return } try{ ws = new WebSocket(url) }catch(e){ wsStatus.textContent = 'Error'; return } wsSid = sid; wsStatus.textContent = 'Connecting...'; ws.onopen = function(){ wsConnected = true; wsStatus.textContent = 'Connected'; try{ ws.send(JSON.stringify({type:'join', sessionId:sid, role:'admin'})) }catch(e){} publishState() }; ws.onclose = function(){ wsConnected = false; wsStatus.textContent = 'Disconnected' }; ws.onerror = function(){ wsStatus.textContent = 'Error' }; ws.onmessage = function(ev){ try{ var msg = JSON.parse(ev.data); if(msg && msg.type==='joined'){ wsStatus.textContent = 'Connected' } else if(msg && msg.type==='patientViewport'){ var pv = msg.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 pvShort = document.getElementById('patientViewportShort'); if(pvShort){ pvShort.textContent = (pv.canvasW||'?')+'×'+(pv.canvasH||'?') } } }catch(e){} } }
|
||||
function wsDisconnect(){ if(ws){ try{ ws.close() }catch(e){} } ws=null; wsConnected=false; wsStatus.textContent = 'Disconnected' }
|
||||
function refreshNetMode(){ var m = (netModeSel && netModeSel.value) || 'relay'; if(rtcControls) rtcControls.style.display = (m==='webrtc'? 'flex':'none'); var relayRows = document.querySelectorAll('#osHelp, #wsUrl, #wsSession, #detectIP, #wsConnect, #wsDisconnect'); for(var i=0;i<relayRows.length;i++){ var el=relayRows[i]; if(!el) continue }
|
||||
var btn = document.getElementById('wsConnect'); if(btn) btn.disabled = (m==='webrtc'); var s = document.getElementById('wsStatus'); if(s && m==='webrtc'){ s.textContent = 'P2P mode' }
|
||||
}
|
||||
if(netModeSel){ netModeSel.addEventListener('change', refreshNetMode); refreshNetMode() }
|
||||
if(rtcCreateOffer){ rtcCreateOffer.addEventListener('click', async function(){ try{ rtcStatus.textContent='Creating...'; var str = await NetRTC.startOfferer(); rtcOffer.value = str; rtcStatus.textContent='Offer ready'; var img = document.getElementById('rtcOfferQR'); if(img){ var enc = encodeURIComponent(str); img.src = 'https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=' + enc } }catch(e){ rtcStatus.textContent='Error' } }) }
|
||||
if(rtcAcceptAnswer){ rtcAcceptAnswer.addEventListener('click', async function(){ var ans = (rtcAnswer.value||'').trim(); if(!ans){ rtcStatus.textContent='Missing answer'; return } try{ rtcStatus.textContent='Connecting...'; await NetRTC.acceptAnswer(ans); rtcStatus.textContent='Connected'; }catch(e){ rtcStatus.textContent='Error' } }) }
|
||||
if(window.NetRTC && NetRTC.onMessage){ NetRTC.onMessage(function(msg){ if(!msg || typeof msg!=='object') return; if(msg.type==='patientViewport'){ var pv = msg.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 pvShort = document.getElementById('patientViewportShort'); if(pvShort){ pvShort.textContent = (pv.canvasW||'?')+'×'+(pv.canvasH||'?') } } }) }
|
||||
window.addEventListener('message', function(ev){ var d = ev && ev.data; if(!d || d.type!=='patientViewport') return; var pv = d.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 pvShort = document.getElementById('patientViewportShort'); if(pvShort){ pvShort.textContent = (pv.canvasW||'?')+'×'+(pv.canvasH||'?') } });
|
||||
|
||||
async function refreshRecList(){ if(!recList) return; var all = (window.Recorder? await Recorder.list(): []); var html = all.map(function(r, i){ return '<option value="'+i+'">'+(r.name||('Recording '+(i+1)))+'</option>' }).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<btns.length;j++){ var b=btns[j]; var m=b.dataset.mode; b.classList.toggle('active', m==='sound') } } } })
|
||||
}
|
||||
if(window.Controllers && Controllers.initPanel){ Controllers.initPanel() }
|
||||
if(speedDown){ speedDown.addEventListener('click', function(){ var el = document.getElementById('speed'); if(!el) return; var current = parseInt(el.value,10)||250; var next = Math.max(parseInt(el.min,10), current - parseInt(el.step,10)); el.value=String(next); var lab = document.getElementById('speedLabel'); if(lab){ lab.textContent = el.value+' px/s' } el.dispatchEvent(new Event('input', {bubbles:true})) }) }
|
||||
if(speedUp){ speedUp.addEventListener('click', function(){ var el = document.getElementById('speed'); if(!el) return; var current = parseInt(el.value,10)||250; var next = Math.min(parseInt(el.max,10), current + parseInt(el.step,10)); el.value=String(next); var lab = document.getElementById('speedLabel'); if(lab){ lab.textContent = el.value+' px/s' } el.dispatchEvent(new Event('input', {bubbles:true})) }) }
|
||||
if(recStartBtn){ try{ recStartBtn.innerHTML = '<span style="color:#e22; font-weight:700">●</span> Record' }catch(_){ } }
|
||||
(function(){
|
||||
var sections = document.querySelectorAll('.panel.collapsible');
|
||||
sections.forEach(function(section){
|
||||
var heading = section.querySelector('h2');
|
||||
if(!heading) return;
|
||||
var key = 'bls_collapse_' + (section.id || Math.random().toString(36).slice(2));
|
||||
try{ var saved = localStorage.getItem(key); if(saved==='true'){ section.classList.add('collapsed') } }catch(_){ }
|
||||
heading.setAttribute('aria-expanded', String(!section.classList.contains('collapsed')));
|
||||
function toggle(){ var isCollapsed = section.classList.toggle('collapsed'); heading.setAttribute('aria-expanded', String(!isCollapsed)); try{ localStorage.setItem(key, String(!!isCollapsed)) }catch(_){ } }
|
||||
heading.addEventListener('click', toggle);
|
||||
heading.addEventListener('keydown', function(e){ if(e.key==='Enter' || e.key===' '){ e.preventDefault(); toggle() } })
|
||||
})
|
||||
})();
|
||||
|
||||
32
js/audio.js
32
js/audio.js
@ -443,8 +443,38 @@
|
||||
this.panInterval=null;
|
||||
this.noiseNodes.forEach(function(n){ try{ n.disconnect() }catch(e){} });
|
||||
this.noiseNodes=[]
|
||||
},
|
||||
playTone: function(opts){
|
||||
var ctx = this.ensureCtx();
|
||||
if(!this.master){ try{ this.master = ctx.createGain(); this.master.connect(ctx.destination) }catch(e){} }
|
||||
var freq = (opts && opts.freq) || 440;
|
||||
var durationMs = (opts && opts.durationMs) || 70;
|
||||
var panVal = (opts && typeof opts.pan==='number') ? opts.pan : 0;
|
||||
var adsr = (opts && opts.adsr) || { attackMs:8, decayMs:80, sustain:0.3, releaseMs:10 };
|
||||
var osc = ctx.createOscillator();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(freq, ctx.currentTime);
|
||||
var gain = ctx.createGain();
|
||||
gain.gain.setValueAtTime(0.0001, ctx.currentTime);
|
||||
var p = makePanner(ctx);
|
||||
if(p.setPan) p.setPan(panVal); else if(p.pan) p.pan.value = panVal;
|
||||
osc.connect(gain);
|
||||
if(p._l){ gain.connect(p._l); gain.connect(p._r) } else { gain.connect(p) }
|
||||
p.connect(this.master);
|
||||
var now = ctx.currentTime;
|
||||
var a = Math.max(0.001, (adsr.attackMs||8)/1000);
|
||||
var d = Math.max(0.001, (adsr.decayMs||80)/1000);
|
||||
var s = Math.max(0, Math.min(1, (adsr.sustain!=null? adsr.sustain:0.3)));
|
||||
var r = Math.max(0.001, (adsr.releaseMs||10)/1000);
|
||||
var dur = Math.max(0.005, (durationMs||70)/1000);
|
||||
gain.gain.linearRampToValueAtTime(1.0, now + a);
|
||||
gain.gain.linearRampToValueAtTime(s, now + a + d);
|
||||
gain.gain.setValueAtTime(s, now + dur);
|
||||
gain.gain.linearRampToValueAtTime(0.0001, now + dur + r);
|
||||
osc.start(now);
|
||||
osc.stop(now + dur + r + 0.01);
|
||||
}
|
||||
}
|
||||
|
||||
window.AudioEngine = AudioEngine
|
||||
})();
|
||||
})();
|
||||
|
||||
@ -5,15 +5,15 @@
|
||||
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) };
|
||||
var defaultState = { shape:'circle', shapeColor:'#00ff88', bgColor:'#000000', direction:'lr', speed:250, speedMultiplier:1, size:28, yPercent:50, 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, durationSec:600, startedAt:null };
|
||||
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 };
|
||||
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){ var s = Object.assign({}, prev, next); saveState(s); return s }
|
||||
function presetKeys(){ return ['shape','shapeColor','bgColor','direction','speed','size','yPercent','glowEnabled','glowIntensity','glowRate','rampEnabled','rampSeconds','easingMode','edgePauseMs','wiggleEnabled','wiggleAmplitude','audioMode','programPreset','syncPan','panRate','cueRate','volume','audioName','audioEnabled'] }
|
||||
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){} }
|
||||
function Renderer(canvas){ var ctx = canvas.getContext('2d'); var state = loadState(); var pos=null; var dir={x:1,y:0}; var running=false; var onUpdate=null; var pauseMs=0; var wigglePhase=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; if(!pos){ if(state.direction==='lr'){pos={x:r+2,y:yFromPercent(h,r,state.yPercent||50)}} else if(state.direction==='ud'){pos={x:w/2,y:yFromPercent(h,r,state.yPercent||5)}} else if(state.direction==='diag_down'){pos={x:r+2,y:yFromPercent(h,r,state.yPercent||5)}} else {pos={x:r+2,y:yFromPercent(h,r,state.yPercent||95)}} } 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 r=state.size; var x=pos.x,y=pos.y; var doGlow = !!state.glowEnabled; var gi = state.glowIntensity || 1; var t = Date.now()/1000; var freq = Math.max(0.05, state.glowRate||0.5); var a = 0.5 + 0.5*Math.sin(t*2*Math.PI*freq); var hue = (t*60*freq)%360; var wx=0, wy=0; if(state.wiggleEnabled && state.wiggleAmplitude>0){ wigglePhase += 0.04 + state.speed/4000; var s = Math.sin(wigglePhase); if(state.direction==='lr'){ wy = s * state.wiggleAmplitude } else if(state.direction==='ud'){ wx = s * state.wiggleAmplitude } else { wx = -dir.y * s * state.wiggleAmplitude; wy = dir.x * s * state.wiggleAmplitude } } if(doGlow){ ctx.save(); ctx.shadowBlur = r * gi * (0.7 + a); ctx.shadowColor = 'hsla('+hue+',100%,60%,0.9)' } ctx.fillStyle=state.shapeColor; if(state.shape==='circle'){ ctx.beginPath(); ctx.arc(x+wx,y+wy,r,0,Math.PI*2); ctx.closePath(); ctx.fill() } else if(state.shape==='square'){ ctx.fillRect(x-r+wx,y-r+wy,r*2,r*2) } else { var h2=r*1.6; ctx.beginPath(); ctx.moveTo(x+wx,y-h2/2+wy); ctx.lineTo(x-r+wx,y+h2/2+wy); ctx.lineTo(x+r+wx,y+h2/2+wy); ctx.closePath(); ctx.fill() } if(doGlow){ ctx.restore() } } function effectiveSpeed(w,h,r){ var base = state.speed; 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){ var hadY = next && Object.prototype.hasOwnProperty.call(next,'yPercent'); state=mergeState(state,next||{}); setDir(); ensurePos(); if(hadY){ var r=state.size; pos.y = yFromPercent(canvas.height, r, state.yPercent||50) } 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 } }
|
||||
function Renderer(canvas){ var ctx = canvas.getContext('2d'); var state = loadState(); var pos=null; var dir={x:1,y:0}; var running=false; var onUpdate=null; var pauseMs=0; var wigglePhase=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 r=state.size; var x=pos.x,y=pos.y; var doGlow = !!state.glowEnabled; var gi = state.glowIntensity || 1; var t = Date.now()/1000; var freq = Math.max(0.05, state.glowRate||0.5); var a = 0.5 + 0.5*Math.sin(t*2*Math.PI*freq); var hue = (t*60*freq)%360; var wx=0, wy=0; if(state.wiggleEnabled && state.wiggleAmplitude>0){ wigglePhase += 0.04 + state.speed/4000; var s = Math.sin(wigglePhase); if(state.direction==='lr'){ wy = s * state.wiggleAmplitude } else if(state.direction==='ud'){ wx = s * state.wiggleAmplitude } else { wx = -dir.y * s * state.wiggleAmplitude; wy = dir.x * s * state.wiggleAmplitude } } if(doGlow){ ctx.save(); ctx.shadowBlur = r * gi * (0.7 + a); ctx.shadowColor = 'hsla('+hue+',100%,60%,0.9)' } ctx.fillStyle=state.shapeColor; if(state.shape==='circle'){ ctx.beginPath(); ctx.arc(x+wx,y+wy,r,0,Math.PI*2); ctx.closePath(); ctx.fill() } else if(state.shape==='square'){ ctx.fillRect(x-r+wx,y-r+wy,r*2,r*2) } else { var h2=r*1.6; ctx.beginPath(); ctx.moveTo(x+wx,y-h2/2+wy); ctx.lineTo(x-r+wx,y+h2/2+wy); ctx.lineTo(x+r+wx,y+h2/2+wy); ctx.closePath(); ctx.fill() } if(doGlow){ ctx.restore() } } function effectiveSpeed(w,h,r){ var base = state.speed; 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){ var n = next||{}; var prevDir = state.direction; var prevSize = state.size; var prevY = state.yPercent; 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); 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 } }
|
||||
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) },
|
||||
|
||||
25
js/controllers.js
Normal file
25
js/controllers.js
Normal file
@ -0,0 +1,25 @@
|
||||
;(function(){
|
||||
var STORE = 'bls_mappings'
|
||||
function load(){ try{ var m = JSON.parse(localStorage.getItem(STORE)||'null'); return Array.isArray(m)? m: [] }catch(_){ return [] } }
|
||||
function save(m){ try{ localStorage.setItem(STORE, JSON.stringify(m||[])) }catch(_){ } }
|
||||
var actions = {}
|
||||
var waiting = null
|
||||
var midiAccess = null
|
||||
var midiInput = null
|
||||
function setStatus(on){ var dot = document.getElementById('ctlStatusDot'); var text = document.getElementById('ctlStatusText'); if(dot) dot.classList.toggle('on', !!on); if(text) text.textContent = on? 'Device connected' : 'No device' }
|
||||
function handleMIDIMessage(ev){ var data = ev.data||[]; var status = data[0]||0; var code = data[1]||0; var val = data[2]||0; var type = (status & 0xF0); var isNoteOn = (type===0x90 && val>0); var isCC = (type===0xB0);
|
||||
if(waiting){ var map = { source:'midi', deviceId:(midiInput && midiInput.id)||'default', type:isNoteOn? 'note':'cc', code:code, actionId:waiting }; var all=load(); all.push(map); save(all); waiting=null; updateMappingsUI(); return }
|
||||
var all = load(); for(var i=0;i<all.length;i++){ var m=all[i]; if(m.source==='midi' && (!midiInput || m.deviceId===(midiInput.id||'default'))){ if((m.type==='note' && isNoteOn && m.code===code) || (m.type==='cc' && isCC && m.code===code)){ var fn = actions[m.actionId]; if(typeof fn==='function') fn(val) } } }
|
||||
}
|
||||
function setupMIDI(){ if(!navigator.requestMIDIAccess) return; navigator.requestMIDIAccess().then(function(acc){ midiAccess=acc; var inputs = Array.from(acc.inputs.values()); midiInput = inputs[0]||null; if(midiInput){ midiInput.onmidimessage = handleMIDIMessage; setStatus(true) } else { setStatus(false) } var sel = document.getElementById('midiSelect'); if(sel){ var html = inputs.map(function(i){ return '<option value="'+i.id+'">'+(i.name||i.id)+'</option>' }).join(''); sel.innerHTML = html; sel.addEventListener('change', function(){ var id = sel.value; var input = acc.inputs.get(id); midiInput = input||null; if(midiInput){ midiInput.onmidimessage = handleMIDIMessage; setStatus(true) } else { setStatus(false) } }) }
|
||||
}).catch(function(){ setStatus(false) }) }
|
||||
function handleKeyDown(ev){ var all = load(); if(waiting){ var key = ev.code || ev.key || ''; if(!key) return; var map = { source:'keyboard', key:key, actionId:waiting }; all.push(map); save(all); waiting=null; updateMappingsUI(); return } for(var i=0;i<all.length;i++){ var m=all[i]; if(m.source==='keyboard'){ var hit = (m.key===ev.code || m.key===ev.key); if(hit){ var fn = actions[m.actionId]; if(typeof fn==='function') fn(ev) } } } }
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
function updateMappingsUI(){ var host = document.getElementById('ctlMappings'); if(!host) return; var all = load(); var html = all.map(function(m, idx){ var label = m.source==='keyboard'? (m.key||'') : (m.type+' '+m.code); return '<div>'+m.source+' • '+label+' → '+m.actionId+' <button data-idx="'+idx+'">Delete</button></div>' }).join(''); host.innerHTML = html; host.querySelectorAll('button').forEach(function(b){ b.addEventListener('click', function(){ var idx = parseInt(b.dataset.idx,10); var arr = load(); arr.splice(idx,1); save(arr); updateMappingsUI() }) }) }
|
||||
var Controllers = {
|
||||
registerAction: function(id, fn){ actions[id]=fn },
|
||||
mapAction: function(id){ waiting = id },
|
||||
initPanel: function(){ setupMIDI(); updateMappingsUI(); var mapBtns = document.querySelectorAll('[data-map-action]'); mapBtns.forEach(function(btn){ btn.addEventListener('click', function(){ var id = btn.dataset.mapAction; waiting = id }) }) }
|
||||
}
|
||||
window.Controllers = Controllers
|
||||
})();
|
||||
109
js/recorder.js
Normal file
109
js/recorder.js
Normal file
@ -0,0 +1,109 @@
|
||||
;(function(){
|
||||
var DBKEY = 'bls_recordings'
|
||||
var Recorder = {
|
||||
running: false,
|
||||
startTime: 0,
|
||||
samples: [],
|
||||
start: function(){ this.running=true; this.startTime=Date.now(); this.samples=[]; this.base = (window.BLS? JSON.parse(JSON.stringify(BLS.loadState())): {} ); this._last = JSON.parse(JSON.stringify(this.base)); if(window.DEBUG_REC){ try{ console.log('Recording started. Base:', this.base) }catch(_){} } },
|
||||
recordState: function(s){ if(!this.running) return; var t = Date.now() - this.startTime; var delta = {}; var hasChanges=false; try{ for(var k in s){ if(!Object.prototype.hasOwnProperty.call(s,k)) continue; var v = s[k]; var b = this._last && this._last[k]; if(JSON.stringify(v)!==JSON.stringify(b)){ delta[k]=v; hasChanges=true } } }catch(_){ delta = s; hasChanges=true } this._last = JSON.parse(JSON.stringify(s)); if(hasChanges){ this.samples.push({ t: t, delta: delta }); if(window.DEBUG_REC){ try{ console.log('Recorded delta at', t, 'ms:', delta) }catch(_){} } } },
|
||||
stop: function(){ var dur = Date.now()-this.startTime; var out = { meta:{ createdAt: Date.now(), durationMs: dur, version:1 }, base: this.base||{}, samples: this.samples.slice() }; if(window.DEBUG_REC){ try{ console.log('Recording stopped. Duration:', dur, 'ms. Samples:', this.samples.length) }catch(_){} } this.running=false; this.startTime=0; this.samples=[]; this.base=null; this._last=null; return out },
|
||||
list: async function(){ if(window.RecordingStore){ return await RecordingStore.list() } try{ var m = JSON.parse(localStorage.getItem(DBKEY)||'null'); return Array.isArray(m)? m: [] }catch(_){ return [] } },
|
||||
add: async function(name, rec){ if(window.RecordingStore){ await RecordingStore.add(name, rec); return } var all = []; try{ all = JSON.parse(localStorage.getItem(DBKEY)||'[]') }catch(_){ all=[] } all.push({ name: name||('Recording '+(all.length+1)), data: rec }); try{ localStorage.setItem(DBKEY, JSON.stringify(all)) }catch(_){} },
|
||||
clear: async function(){ if(window.RecordingStore){ var list = await RecordingStore.list(); for(var i=0;i<list.length;i++){ try{ await RecordingStore.delete(list[i].id) }catch(_){ } } } else { try{ localStorage.removeItem(DBKEY) }catch(_){ } } },
|
||||
play: function(rec, opts){
|
||||
var speed = (opts&&opts.speed)||1;
|
||||
var samples = (rec&&rec.samples)||[];
|
||||
var start = Date.now();
|
||||
var idx=0;
|
||||
var runStartRel = null;
|
||||
if(rec && rec.base && rec.base.running){ runStartRel = 0 }
|
||||
for(var i=0;i<samples.length;i++){ var d=samples[i].delta||samples[i].state||{}; if((d && d.running===true) || (d && typeof d.startedAt==='number')){ runStartRel = (runStartRel==null? samples[i].t : runStartRel); break } }
|
||||
function adjustForReplay(s, elapsed){
|
||||
if(!s) return s;
|
||||
try{
|
||||
var out = JSON.parse(JSON.stringify(s));
|
||||
if(out && out.running){
|
||||
var base = (runStartRel!=null)? runStartRel : 0;
|
||||
var rel = Math.max(0, (elapsed - base));
|
||||
out.startedAt = Date.now() - rel;
|
||||
}
|
||||
return out;
|
||||
}catch(_){ return s }
|
||||
}
|
||||
var cur = JSON.parse(JSON.stringify((rec && rec.base) || {}));
|
||||
// Apply base state immediately so Patient starts the session without waiting for first delta
|
||||
var stopTimer = null;
|
||||
var hasStopDelta = false;
|
||||
try{ for(var j=0;j<samples.length;j++){ var dj = samples[j] && (samples[j].delta||samples[j].state)||{}; if(dj && dj.running===false){ hasStopDelta = true; break } } }catch(_){ }
|
||||
(function applyBase(){
|
||||
var s0 = adjustForReplay(cur, 0);
|
||||
try{
|
||||
if(window.DEBUG_REC){ try{ console.log('Playing recording. Base:', (rec&&rec.base)||{}) }catch(_){} }
|
||||
if(opts && typeof opts.applyLocal==='function'){ opts.applyLocal(s0) }
|
||||
if(opts && opts.patientSyncOn){
|
||||
if(opts && typeof opts.sendLocalChannel==='function'){ opts.sendLocalChannel(s0) }
|
||||
if(window.NetRTC && opts && opts.mode==='webrtc'){
|
||||
NetRTC.send({type:'state', sessionId: opts.sid||'default', payload: s0})
|
||||
} else if(opts && opts.ws){ if(opts.ws.readyState===1){ opts.ws.send(JSON.stringify({type:'state', sessionId: opts.sid||'default', payload: s0}) ) } }
|
||||
}
|
||||
}catch(_){ }
|
||||
try{
|
||||
var endMs = null;
|
||||
if(rec && rec.meta && typeof rec.meta.durationMs==='number'){ endMs = rec.meta.durationMs }
|
||||
if(endMs==null || !(endMs>0)){ endMs = (samples && samples.length? samples[samples.length-1].t : 0) }
|
||||
if(endMs && endMs>0 && !hasStopDelta){
|
||||
stopTimer = setTimeout(function(){
|
||||
var sStop = JSON.parse(JSON.stringify(cur));
|
||||
sStop.running = false;
|
||||
if(opts && typeof opts.applyLocal==='function'){ opts.applyLocal(sStop) }
|
||||
if(opts && opts.patientSyncOn){
|
||||
if(window.NetRTC && opts && opts.mode==='webrtc'){
|
||||
NetRTC.send({type:'state', sessionId: opts.sid||'default', payload: sStop})
|
||||
} else if(opts && opts.ws){ if(opts.ws.readyState===1){ opts.ws.send(JSON.stringify({type:'state', sessionId: opts.sid||'default', payload: sStop}) ) } }
|
||||
if(opts && typeof opts.sendLocalChannel==='function'){ opts.sendLocalChannel(sStop) }
|
||||
}
|
||||
}, endMs);
|
||||
}
|
||||
}catch(_){ }
|
||||
})();
|
||||
var lastEmit = -1;
|
||||
function tick(){
|
||||
var now = Date.now();
|
||||
var elapsed = (now-start)*speed;
|
||||
while(idx < samples.length && samples[idx].t <= elapsed){
|
||||
var delta = samples[idx].delta || samples[idx].state || {};
|
||||
for(var key in delta){ if(Object.prototype.hasOwnProperty.call(delta,key)){ cur[key] = delta[key] } }
|
||||
var s = adjustForReplay(cur, elapsed);
|
||||
try{
|
||||
if(opts && typeof opts.applyLocal==='function'){ opts.applyLocal(s) }
|
||||
if(opts && opts.patientSyncOn){
|
||||
if(opts && typeof opts.sendLocalChannel==='function'){ opts.sendLocalChannel(s) }
|
||||
if(window.NetRTC && opts && opts.mode==='webrtc'){
|
||||
NetRTC.send({type:'state', sessionId: opts.sid||'default', payload: s})
|
||||
} else if(opts && opts.ws){ if(opts.ws.readyState===1){ opts.ws.send(JSON.stringify({type:'state', sessionId: opts.sid||'default', payload: s}) ) } }
|
||||
}
|
||||
if(window.DEBUG_REC){ try{ console.log('Applying sample', idx, 'at', Math.round(elapsed), 'ms:', delta) }catch(_){} }
|
||||
}catch(_){ }
|
||||
idx++
|
||||
}
|
||||
// Heartbeat: emit current state periodically to update timers/UI throughout replay
|
||||
if(elapsed - lastEmit >= 250){
|
||||
var sHB = adjustForReplay(cur, elapsed);
|
||||
try{
|
||||
if(opts && typeof opts.applyLocal==='function'){ opts.applyLocal(sHB) }
|
||||
if(opts && opts.patientSyncOn){
|
||||
if(opts && typeof opts.sendLocalChannel==='function'){ opts.sendLocalChannel(sHB) }
|
||||
if(window.NetRTC && opts && opts.mode==='webrtc'){
|
||||
NetRTC.send({type:'state', sessionId: opts.sid||'default', payload: sHB})
|
||||
} else if(opts && opts.ws){ if(opts.ws.readyState===1){ opts.ws.send(JSON.stringify({type:'state', sessionId: opts.sid||'default', payload: sHB}) ) } }
|
||||
}
|
||||
}catch(_){ }
|
||||
lastEmit = elapsed;
|
||||
}
|
||||
setTimeout(tick, 20)
|
||||
}
|
||||
tick()
|
||||
}
|
||||
}
|
||||
window.Recorder = Recorder
|
||||
})();
|
||||
23
js/recording-store.js
Normal file
23
js/recording-store.js
Normal file
@ -0,0 +1,23 @@
|
||||
;(function(){
|
||||
function openDB(){
|
||||
return new Promise(function(resolve, reject){
|
||||
var req = indexedDB.open('bls_rec_db', 1);
|
||||
req.onupgradeneeded = function(e){ var db = req.result; if(!db.objectStoreNames.contains('recordings')){ db.createObjectStore('recordings', { keyPath: 'id' }) } };
|
||||
req.onsuccess = function(){ resolve(req.result) };
|
||||
req.onerror = function(){ reject(req.error) };
|
||||
})
|
||||
}
|
||||
async function putRec(rec){ var db = await openDB(); return new Promise(function(resolve, reject){ var tx = db.transaction('recordings','readwrite'); var store = tx.objectStore('recordings'); var req = store.put(rec); req.onsuccess=function(){ resolve(rec) }; req.onerror=function(){ reject(req.error) } }) }
|
||||
async function getAll(){ var db = await openDB(); return new Promise(function(resolve, reject){ var tx = db.transaction('recordings','readonly'); var store = tx.objectStore('recordings'); var req = store.getAll(); req.onsuccess=function(){ resolve(req.result||[]) }; req.onerror=function(){ reject(req.error) } }) }
|
||||
async function delById(id){ var db = await openDB(); return new Promise(function(resolve, reject){ var tx = db.transaction('recordings','readwrite'); var store = tx.objectStore('recordings'); var req = store.delete(id); req.onsuccess=function(){ resolve(true) }; req.onerror=function(){ reject(req.error) } }) }
|
||||
function genId(){ return 'rec_'+Date.now()+'_'+Math.random().toString(36).slice(2,8) }
|
||||
async function migrateLocal(){ try{ var raw = localStorage.getItem('bls_recordings'); if(!raw) return; var arr = JSON.parse(raw||'[]'); if(!Array.isArray(arr) || !arr.length) return; var existing = await getAll(); var names = new Set((existing||[]).map(function(r){ return r.name })); for(var i=0;i<arr.length;i++){ var r = arr[i]; if(r && r.name && !names.has(r.name)){ var id = genId(); var meta = (r.data && r.data.meta) || {createdAt: Date.now(), durationMs:0, version:1}; await putRec({ id:id, name: r.name||('Recording '+(i+1)), meta: meta, base: (r.data && r.data.base)||null, samples: (r.data && r.data.samples)||[], path: (r.data && r.data.path)||null }) } } localStorage.removeItem('bls_recordings') }catch(_){ }
|
||||
}
|
||||
var RecordingStore = {
|
||||
list: async function(){ await migrateLocal(); var all = await getAll(); return all.map(function(r){ return { id:r.id, name:r.name, data:{ meta:r.meta, base:r.base, samples:r.samples, path:r.path } } }) },
|
||||
add: async function(name, data){ var rec = { id: genId(), name: name||('Recording '+new Date().toLocaleString()), meta: data.meta||{ createdAt: Date.now(), durationMs: data.meta && data.meta.durationMs || 0, version: 1 }, base: data.base||null, samples: data.samples||[], path: data.path||null }; await putRec(rec); return rec },
|
||||
delete: async function(id){ return delById(id) }
|
||||
}
|
||||
window.RecordingStore = RecordingStore
|
||||
})();
|
||||
|
||||
54
js/webrtc.js
Normal file
54
js/webrtc.js
Normal file
@ -0,0 +1,54 @@
|
||||
;(function(){
|
||||
function createPC(){
|
||||
var RTC = window.RTCPeerConnection || window.webkitRTCPeerConnection || window.mozRTCPeerConnection;
|
||||
var pc = new RTC({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
|
||||
return pc
|
||||
}
|
||||
var NetRTC = {
|
||||
pc: null,
|
||||
dc: null,
|
||||
role: null,
|
||||
onmsg: null,
|
||||
startOfferer: async function(){
|
||||
this.role = 'offerer';
|
||||
this.pc = createPC();
|
||||
this.dc = this.pc.createDataChannel('bls');
|
||||
var self=this;
|
||||
this.dc.onmessage = function(ev){ try{ var m=JSON.parse(ev.data); if(self.onmsg) self.onmsg(m) }catch(_){ } };
|
||||
await this.pc.setLocalDescription(await this.pc.createOffer());
|
||||
var done = new Promise(function(resolve){
|
||||
if(self.pc.iceGatheringState==='complete'){ resolve() } else {
|
||||
var check = function(){ if(self.pc.iceGatheringState==='complete'){ self.pc.removeEventListener('icegatheringstatechange', check); resolve() } };
|
||||
self.pc.addEventListener('icegatheringstatechange', check)
|
||||
}
|
||||
});
|
||||
await done;
|
||||
return btoa(JSON.stringify(self.pc.localDescription))
|
||||
},
|
||||
acceptAnswer: async function(answerStr){
|
||||
var desc = JSON.parse(atob(answerStr));
|
||||
await this.pc.setRemoteDescription(desc)
|
||||
},
|
||||
startAnswerer: async function(offerStr){
|
||||
this.role = 'answerer';
|
||||
this.pc = createPC();
|
||||
var self=this;
|
||||
this.pc.ondatachannel = function(e){ self.dc = e.channel; self.dc.onmessage = function(ev){ try{ var m=JSON.parse(ev.data); if(self.onmsg) self.onmsg(m) }catch(_){ } } };
|
||||
await this.pc.setRemoteDescription(JSON.parse(atob(offerStr)));
|
||||
await this.pc.setLocalDescription(await this.pc.createAnswer());
|
||||
var done = new Promise(function(resolve){
|
||||
if(self.pc.iceGatheringState==='complete'){ resolve() } else {
|
||||
var check = function(){ if(self.pc.iceGatheringState==='complete'){ self.pc.removeEventListener('icegatheringstatechange', check); resolve() } };
|
||||
self.pc.addEventListener('icegatheringstatechange', check)
|
||||
}
|
||||
});
|
||||
await done;
|
||||
return btoa(JSON.stringify(self.pc.localDescription))
|
||||
},
|
||||
send: function(obj){ if(this.dc && this.dc.readyState==='open'){ try{ this.dc.send(JSON.stringify(obj)) }catch(_){ } } },
|
||||
onMessage: function(cb){ this.onmsg = cb },
|
||||
disconnect: function(){ try{ if(this.dc) this.dc.close() }catch(_){} try{ if(this.pc) this.pc.close() }catch(_){} this.dc=null; this.pc=null; this.onmsg=null; this.role=null }
|
||||
};
|
||||
window.NetRTC = NetRTC
|
||||
})();
|
||||
|
||||
35
patient.html
35
patient.html
@ -6,7 +6,9 @@ BLS<!doctype html>
|
||||
<title>BLS Patient View</title>
|
||||
<style>
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body { background: #000; display: flex; align-items: center; justify-content: center; }
|
||||
:root { --bg:#000; }
|
||||
.theme-dark { --bg:#000 }
|
||||
body { background: var(--bg); display: flex; align-items: center; justify-content: center; }
|
||||
#canvas { display: block; }
|
||||
#overlay { position: fixed; top: 10px; left: 10px; right: 10px; display: flex; justify-content: space-between; align-items: center; color: #fff; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; opacity: 0.8; }
|
||||
#overlay button { padding: 8px 10px; border: none; border-radius: 6px; background: #1f6feb; color: #fff; cursor: pointer; }
|
||||
@ -14,14 +16,20 @@ BLS<!doctype html>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="canvas"></canvas>
|
||||
<div id="overlay" style="display:none">
|
||||
<span>Click or tap to enable audio</span>
|
||||
</div>
|
||||
|
||||
<script src="js/bls.js"></script>
|
||||
<script src="js/audio.js"></script>
|
||||
<script src="js/webrtc.js"></script>
|
||||
<script>
|
||||
function initPatient(){
|
||||
var canvas = document.getElementById('canvas')
|
||||
function size(){ canvas.width = window.innerWidth; canvas.height = window.innerHeight }
|
||||
size(); window.addEventListener('resize', size)
|
||||
var widthMode = (BLS.loadState().patientWidth||'full');
|
||||
function size(){ var w = window.innerWidth, h = window.innerHeight; var fac = (widthMode==='reduced'? 0.7 : 1); canvas.width = Math.floor(w*fac); canvas.height = h }
|
||||
function sendViewport(){ try{ var pv = { winW: window.innerWidth, winH: window.innerHeight, canvasW: canvas.width, canvasH: canvas.height }; if(ws && ws.readyState===1){ ws.send(JSON.stringify({type:'patientViewport', payload: pv})) } if(window.NetRTC && NetRTC.send){ NetRTC.send({type:'patientViewport', payload: pv}) } try{ if(window.opener){ window.opener.postMessage({type:'patientViewport', payload: pv}, '*') } }catch(_){ } try{ if(window.BLS){ BLS.createChannel('bls-session').send({type:'patientViewport', payload: pv}) } }catch(_){ } }catch(e){} }
|
||||
size(); sendViewport(); window.addEventListener('resize', function(){ size(); sendViewport() })
|
||||
var renderer = BLS.Renderer(canvas)
|
||||
var chan = BLS.createChannel('bls-session')
|
||||
var qs = new URLSearchParams(location.search)
|
||||
@ -29,7 +37,9 @@ BLS<!doctype html>
|
||||
var wsSid = qs.get('sid') || hashSid || 'default'
|
||||
var wsUrl = qs.get('url') || ('ws://' + (location.hostname||'localhost') + ':8787')
|
||||
var ws = null
|
||||
var rtcMode = !!qs.get('p2p')
|
||||
chan.on(function(msg){ if(!msg || typeof msg !== 'object') return; var apply = Object.assign({}, msg); if(apply && apply.rampEnabled === false){ apply.easingMode = 'linear' } if(apply && apply.glowEnabled === false){ apply.glowIntensity = 0 } renderer.setState(apply); var st = renderer.getState(); if(st.running){ renderer.start() } else { renderer.stop() } })
|
||||
var running = false;
|
||||
function wsConnect(){ if(!wsUrl) return; try{ ws = new WebSocket(wsUrl) }catch(e){ return } ws.onopen = function(){ try{ ws.send(JSON.stringify({type:'join', sessionId: wsSid, role:'patient'})) }catch(e){} };
|
||||
ws.onmessage = function(ev){
|
||||
try{
|
||||
@ -39,8 +49,10 @@ BLS<!doctype html>
|
||||
if(apply && apply.rampEnabled === false){ apply.easingMode = 'linear' }
|
||||
if(apply && apply.glowEnabled === false){ apply.glowIntensity = 0 }
|
||||
renderer.setState(apply);
|
||||
var nextMode = apply.patientWidth || widthMode;
|
||||
if(nextMode !== widthMode){ widthMode = nextMode; size() }
|
||||
var st = renderer.getState();
|
||||
if(!st.running){ stopAudio(); stopProgrammatic(); renderer.stop(); return }
|
||||
if(!st.running || st.paused){ stopAudio(); stopProgrammatic(); renderer.stop(); running=false; return }
|
||||
if(st.audioEnabled){
|
||||
if(st.audioMode==='file'){
|
||||
(async function(){ if(!audioFolder){ await ensureFolder() } if(st.audioName){ await loadAudioByName(st.audioName); playBuffer() } })()
|
||||
@ -48,23 +60,28 @@ BLS<!doctype html>
|
||||
stopAudio(); if(!audioUnlocked) bindUnlock(); startProgrammatic(st)
|
||||
}
|
||||
} else { stopAudio(); stopProgrammatic() }
|
||||
renderer.start()
|
||||
if(!running){ renderer.start(); running=true }
|
||||
}
|
||||
}catch(e){}
|
||||
};
|
||||
ws.onclose = function(){ ws = null }
|
||||
ws.onopen = function(){ try{ sendViewport() }catch(e){} }
|
||||
}
|
||||
if(!rtcMode && wsUrl){ wsConnect() }
|
||||
if(rtcMode){
|
||||
var offerStr = prompt('Paste Offer from Admin');
|
||||
if(offerStr){ NetRTC.startAnswerer(offerStr).then(function(answer){ alert('Show this Answer to Admin to complete connect'); console.log(answer); try{ navigator.clipboard && navigator.clipboard.writeText(answer) }catch(_){ } }); NetRTC.onMessage(function(msg){ if(msg && msg.type==='state'){ var apply = Object.assign({}, msg.payload||{}); if(apply && apply.rampEnabled === false){ apply.easingMode = 'linear' } if(apply && apply.glowEnabled === false){ apply.glowIntensity = 0 } renderer.setState(apply); var st = renderer.getState(); if(!st.running || st.paused){ stopAudio(); stopProgrammatic(); renderer.stop(); return } if(st.audioEnabled){ if(st.audioMode==='file'){ (async function(){ if(!audioFolder){ await ensureFolder() } if(st.audioName){ await loadAudioByName(st.audioName); playBuffer() } })() } else { stopAudio(); if(!audioUnlocked) bindUnlock(); startProgrammatic(st) } } else { stopAudio(); stopProgrammatic() } renderer.start() } }); try{ sendViewport() }catch(e){} }
|
||||
}
|
||||
if(wsUrl){ wsConnect() }
|
||||
renderer.setState(BLS.loadState())
|
||||
var st = renderer.getState(); if(st.running){ renderer.start() }
|
||||
var audioCtx = null, panner = null, gain = null, source = null, audioFolder = null, audioBuffer = null
|
||||
var audioUnlocked = false
|
||||
var pendingProgramState = null
|
||||
function bindUnlock(){ ['pointerdown','keydown','touchstart','mousemove'].forEach(function(ev){ window.addEventListener(ev, function once(){ try{ AudioEngine.ensureCtx(); audioUnlocked = true; AudioEngine.playPing(0) }catch(e){} if(pendingProgramState){ var st = pendingProgramState; pendingProgramState = null; startProgrammatic(st) } try{ window.removeEventListener(ev, once) }catch(_){ } }, { once:true }) }) }
|
||||
function bindUnlock(){ var ov = document.getElementById('overlay'); if(ov){ ov.style.display='flex' } ['pointerdown','keydown','touchstart','mousemove'].forEach(function(ev){ window.addEventListener(ev, function once(){ try{ AudioEngine.ensureCtx(); audioUnlocked = true; AudioEngine.playPing(0) }catch(e){} if(ov){ ov.style.display='none' } if(pendingProgramState){ var st = pendingProgramState; pendingProgramState = null; startProgrammatic(st) } try{ window.removeEventListener(ev, once) }catch(_){ } }, { once:true }) }) }
|
||||
bindUnlock()
|
||||
var progRunning = false
|
||||
function stopProgrammatic(){ AudioEngine.stopNoise(); progRunning=false }
|
||||
function startProgrammatic(st){ if(!audioUnlocked){ pendingProgramState = st; bindUnlock(); return } AudioEngine.setVolume(st.volume||0.6); if(st.programPreset==='pink'){ if(st.syncPan){ renderer.setOnUpdate(function(info){ var pan=(info.x/info.w)*2-1; }); AudioEngine.startPinkNoisePan(Math.max(0.1, st.panRate||0.5)) } else { AudioEngine.startPinkNoisePan(Math.max(0.1, st.panRate||0.5)) } progRunning=true } else if(st.programPreset==='hybrid'){ AudioEngine.startHybrid(Math.max(0.1, st.cueRate||1.2)); progRunning=true } else { renderer.setOnUpdate(function(info){ var r = renderer.getState().size; var hitL = info.x<=r+0.1; var hitR = info.x>=info.w-r-0.1; var hitT = info.y<=r+0.1; var hitB = info.y>=info.h-r-0.1; if(hitL||hitR||hitT||hitB){ var pan = hitL? -1 : hitR? 1 : 0; var pre = st.programPreset; if(pre==='click'){ AudioEngine.playClick(pan) } else if(pre==='ping'){ AudioEngine.playPing(pan) } else if(pre==='woodblock'){ AudioEngine.playWoodblock(pan) } else if(pre==='bell'){ AudioEngine.playBell(pan) } else if(pre==='bass'){ AudioEngine.playBass(pan) } else if(pre==='kick'){ AudioEngine.playKick(pan) } else if(pre==='snare'){ AudioEngine.playSnare(pan) } else if(pre==='hihat'){ AudioEngine.playHiHat(pan) } else if(pre==='sweep'){ AudioEngine.playSweep(pan) } else if(pre==='zap'){ AudioEngine.playZap(pan) } else if(pre==='bubble'){ AudioEngine.playBubble(pan) } else { AudioEngine.playWoodblock(pan) } } }); progRunning=true } }
|
||||
function startProgrammatic(st){ if(!audioUnlocked){ pendingProgramState = st; bindUnlock(); return } AudioEngine.setVolume(st.volume||0.6); if(st.programPreset==='pink'){ if(st.syncPan){ renderer.setOnUpdate(function(info){ var pan=(info.x/info.w)*2-1; }); AudioEngine.startPinkNoisePan(Math.max(0.1, st.panRate||0.5)) } else { AudioEngine.startPinkNoisePan(Math.max(0.1, st.panRate||0.5)) } progRunning=true } else if(st.programPreset==='hybrid'){ AudioEngine.startHybrid(Math.max(0.1, st.cueRate||1.2)); progRunning=true } else { renderer.setOnUpdate(function(info){ var r = renderer.getState().size; var hitL = info.x<=r+0.1; var hitR = info.x>=info.w-r-0.1; var hitT = info.y<=r+0.1; var hitB = info.y>=info.h-r-0.1; if(hitL||hitR||hitT||hitB){ var pan = hitL? -1 : hitR? 1 : 0; var pre = st.programPreset; if(pre==='tone'){ AudioEngine.playTone({ freq: st.toneFreq||440, durationMs: st.toneDurationMs||70, pan: pan, adsr:{ attackMs: st.adsrAttackMs||8, decayMs: st.adsrDecayMs||80, sustain: st.adsrSustainLevel||0.3, releaseMs: st.adsrReleaseMs||10 } }) } else if(pre==='click'){ AudioEngine.playClick(pan) } else if(pre==='ping'){ AudioEngine.playPing(pan) } else if(pre==='woodblock'){ AudioEngine.playWoodblock(pan) } else if(pre==='bell'){ AudioEngine.playBell(pan) } else if(pre==='bass'){ AudioEngine.playBass(pan) } else if(pre==='kick'){ AudioEngine.playKick(pan) } else if(pre==='snare'){ AudioEngine.playSnare(pan) } else if(pre==='hihat'){ AudioEngine.playHiHat(pan) } else if(pre==='sweep'){ AudioEngine.playSweep(pan) } else if(pre==='zap'){ AudioEngine.playZap(pan) } else if(pre==='bubble'){ AudioEngine.playBubble(pan) } else { AudioEngine.playWoodblock(pan) } } }); progRunning=true } }
|
||||
function ensureAudio(){ if(!audioCtx){ audioCtx = new (window.AudioContext||window.webkitAudioContext)(); panner = audioCtx.createStereoPanner ? audioCtx.createStereoPanner() : null; gain = audioCtx.createGain(); if(panner){ panner.connect(gain) } gain.connect(audioCtx.destination) } if(audioCtx.state==='suspended'){ audioCtx.resume() } }
|
||||
async function chooseAudioFolder(){ ensureAudio(); if(window.showDirectoryPicker){ try{ audioFolder = await window.showDirectoryPicker(); var st0 = BLS.loadState(); if(st0.audioEnabled && st0.audioName){ await loadAudioByName(st0.audioName); if(st0.running) playBuffer() } }catch(e){ return } } else { var input = document.createElement('input'); input.type='file'; input.webkitdirectory=true; input.multiple=true; input.addEventListener('change', function(){ var st0 = BLS.loadState(); var files = Array.from(input.files); var match = files.find(function(f){ return f.name === st0.audioName }); if(match){ var fr = new FileReader(); fr.onload = function(){ audioCtx.decodeAudioData(fr.result).then(function(buf){ audioBuffer = buf; if(st0.running && st0.audioEnabled) playBuffer() }) }; fr.readAsArrayBuffer(match) } }); input.click(); }
|
||||
}
|
||||
@ -73,7 +90,7 @@ BLS<!doctype html>
|
||||
function stopAudio(){ if(source){ try{ source.stop() }catch(e){} source.disconnect(); source=null } }
|
||||
async function ensureFolder(){ if(!audioFolder){ try{ await chooseAudioFolder() }catch(e){} } }
|
||||
renderer.setOnUpdate(function(info){ if(panner){ var pan = (info.x / info.w) * 2 - 1; if(pan < -1) pan=-1; if(pan>1) pan=1; try{ panner.pan.value = pan }catch(e){} } })
|
||||
chan.on(async function(msg){ if(!msg || typeof msg!=='object') return; if(!msg.running){ stopAudio(); stopProgrammatic(); return } if(msg.audioEnabled){ if(msg.audioMode==='file'){ if(!audioFolder){ await ensureFolder() } if(msg.audioName){ await loadAudioByName(msg.audioName); playBuffer() } } else { stopAudio(); if(!audioUnlocked) bindUnlock(); startProgrammatic(msg) } } else { stopAudio(); stopProgrammatic() } })
|
||||
chan.on(async function(msg){ if(!msg || typeof msg!=='object') return; if(!msg.running || msg.paused){ stopAudio(); stopProgrammatic(); renderer.stop(); return } if(msg.audioEnabled){ if(msg.audioMode==='file'){ if(!audioFolder){ await ensureFolder() } if(msg.audioName){ await loadAudioByName(msg.audioName); playBuffer() } } else { stopAudio(); if(!audioUnlocked) bindUnlock(); startProgrammatic(msg) } } else { stopAudio(); stopProgrammatic() } })
|
||||
}
|
||||
function wait(){ if(window.BLS){ initPatient() } else { setTimeout(wait, 50) } }
|
||||
if(document.readyState==='loading'){ document.addEventListener('DOMContentLoaded', wait) } else { wait() }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user