--tokens FILE gates session hosting: hosts present an access code, patients join live sessions freely (no code), un-joined connections can't relay, and joined members can't spoof state into other rooms. Easy Mode grows a gentle access-code card (one-computer mode stays free/ungated); advanced panel gets a token field; patient view shows a waiting note and auto-retries. manual.html: picture-book how-to, images slot in under img/manual/. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
305 lines
15 KiB
HTML
305 lines
15 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>backnforth — Easy Mode</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>
|
|
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
|
html, body { margin: 0; background: #10141a; color: #e6edf3;
|
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
|
main { max-width: 520px; margin: 0 auto; padding: 16px 16px 48px; }
|
|
h1 { font-size: 22px; text-align: center; margin: 10px 0 2px; letter-spacing: 1px; }
|
|
.sub { text-align: center; opacity: .6; font-size: 14px; margin-bottom: 14px; }
|
|
#preview { display: block; width: 100%; height: 90px; border-radius: 12px; background: #000; }
|
|
.card { background: #1a2028; border-radius: 14px; padding: 14px 16px; margin-top: 14px; }
|
|
.card label.head { display: block; font-size: 17px; font-weight: 600; margin-bottom: 10px; }
|
|
.rowicons { display: flex; align-items: center; gap: 12px; font-size: 26px; }
|
|
.rowicons input { flex: 1; }
|
|
input[type=range] { -webkit-appearance: none; appearance: none; height: 14px; border-radius: 7px;
|
|
background: #2d3743; outline: none; margin: 14px 0; }
|
|
input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none;
|
|
width: 44px; height: 44px; border-radius: 50%; background: #1f6feb; border: 4px solid #fff;
|
|
cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,.5); }
|
|
input[type=range]::-moz-range-thumb { width: 44px; height: 44px; border-radius: 50%;
|
|
background: #1f6feb; border: 4px solid #fff; cursor: pointer; }
|
|
.word { text-align: center; font-size: 15px; opacity: .75; min-height: 20px; }
|
|
.chips { display: flex; flex-wrap: wrap; gap: 10px; }
|
|
.chip { flex: 1 1 28%; min-height: 56px; font-size: 17px; border: 2px solid #2d3743;
|
|
border-radius: 12px; background: #222a34; color: #e6edf3; cursor: pointer; }
|
|
.chip.on { border-color: #1f6feb; background: #1f6feb33; }
|
|
.dots { display: flex; gap: 14px; justify-content: center; }
|
|
.dot { width: 52px; height: 52px; border-radius: 50%; border: 3px solid #2d3743; cursor: pointer; }
|
|
.dot.on { border-color: #fff; box-shadow: 0 0 0 3px #1f6feb; }
|
|
#bigStart { width: 100%; min-height: 84px; font-size: 30px; font-weight: 700; border: none;
|
|
border-radius: 16px; background: #2ea043; color: #fff; cursor: pointer; margin-top: 16px; }
|
|
#bigStart.stop { background: #da3633; }
|
|
#bigTimer { text-align: center; font-size: 40px; font-variant-numeric: tabular-nums;
|
|
margin: 8px 0 0; opacity: .9; }
|
|
.bigbtn { width: 100%; min-height: 60px; font-size: 19px; border: 2px solid #2d3743;
|
|
border-radius: 14px; background: #222a34; color: #e6edf3; cursor: pointer; margin-top: 14px; }
|
|
.adv { display: block; text-align: center; margin-top: 22px; color: #7d8590; font-size: 15px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="safetyNotice" style="display:none; position:fixed; inset:0; z-index:9999; background:rgba(0,0,0,0.8); overflow:auto;">
|
|
<div style="max-width:560px; margin:8vh auto; background:#1c2128; color:#e6edf3; border-radius:12px; padding:28px; line-height:1.55;">
|
|
<h2 style="margin-top:0">⚠️ Read before first use</h2>
|
|
<ul style="padding-left:20px">
|
|
<li style="margin-bottom:10px"><strong>Photosensitivity:</strong> moving and pulsing visuals can trigger seizures in people with photosensitive epilepsy. Ask about seizure history before use and stop immediately if the viewer feels unwell.</li>
|
|
<li style="margin-bottom:10px"><strong>Clinical use:</strong> bilateral stimulation can surface intense memories and emotions. This tool assists a trained clinician — it is not therapy by itself.</li>
|
|
</ul>
|
|
<button id="safetyAck" style="width:100%; padding:14px; border:none; border-radius:8px; background:#1f6feb; color:#fff; font-size:16px; cursor:pointer">I understand</button>
|
|
</div>
|
|
</div>
|
|
|
|
<main>
|
|
<h1>backnforth</h1>
|
|
<div class="sub">Set it up, press start, watch the patient — not the screen.</div>
|
|
|
|
<canvas id="preview" width="640" height="120"></canvas>
|
|
|
|
<button id="bigStart">▶ START</button>
|
|
<div id="bigTimer">00:00</div>
|
|
|
|
<div class="card">
|
|
<label class="head">How fast</label>
|
|
<div class="rowicons">🐢 <input type="range" id="ezSpeed" min="60" max="600" step="10"> 🐇</div>
|
|
<div class="word" id="speedWord"></div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<label class="head">How big</label>
|
|
<div class="rowicons"><span style="font-size:14px">●</span> <input type="range" id="ezSize" min="12" max="90" step="2"> <span style="font-size:36px">●</span></div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<label class="head">Color</label>
|
|
<div class="dots" id="ezColors">
|
|
<button class="dot" data-color="#00ff88" style="background:#00ff88"></button>
|
|
<button class="dot" data-color="#58a6ff" style="background:#58a6ff"></button>
|
|
<button class="dot" data-color="#ffd33d" style="background:#ffd33d"></button>
|
|
<button class="dot" data-color="#ff6b6b" style="background:#ff6b6b"></button>
|
|
<button class="dot" data-color="#ffffff" style="background:#ffffff"></button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<label class="head">Sound</label>
|
|
<div class="chips" id="ezSounds">
|
|
<button class="chip" data-sound="off">🔇 Off</button>
|
|
<button class="chip" data-sound="ocean">🌊 Ocean</button>
|
|
<button class="chip" data-sound="beep">🔔 Beep</button>
|
|
<button class="chip" data-sound="click">🥢 Click</button>
|
|
<button class="chip" data-sound="knock">🪵 Knock</button>
|
|
</div>
|
|
<div class="rowicons" style="margin-top:8px">🔈 <input type="range" id="ezVolume" min="0" max="100" step="5"> 🔊</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<label class="head">How long</label>
|
|
<div class="chips" id="ezDuration">
|
|
<button class="chip" data-min="1">1 min</button>
|
|
<button class="chip" data-min="2">2 min</button>
|
|
<button class="chip" data-min="5">5 min</button>
|
|
<button class="chip on" data-min="0">No limit</button>
|
|
</div>
|
|
</div>
|
|
|
|
<button id="openPatientBig" class="bigbtn">🖥️ Open patient screen</button>
|
|
<button id="copyLinkBig" class="bigbtn" hidden>🔗 Copy link for patient's phone / tablet</button>
|
|
|
|
<div id="accessCard" class="card" hidden>
|
|
<label class="head">🔑 Access code</label>
|
|
<div style="font-size:14px; opacity:.8; margin-bottom:10px">
|
|
Linking a second device through this site needs a free access code
|
|
(that's how we keep the relay clean). No code? Everything still works
|
|
on one computer — just tap "Open patient screen" above.
|
|
</div>
|
|
<div style="display:flex; gap:10px">
|
|
<input id="accessInput" type="text" placeholder="paste your code"
|
|
style="flex:1; min-height:48px; font-size:17px; border-radius:10px; border:2px solid #2d3743; background:#222a34; color:#e6edf3; padding:0 12px">
|
|
<button id="accessSave" class="chip" style="flex:0 0 auto; padding:0 18px">Save</button>
|
|
</div>
|
|
</div>
|
|
|
|
<a class="adv" href="manual.html">📖 How it works (picture guide)</a>
|
|
<a class="adv" href="index.html">Advanced controls →</a>
|
|
</main>
|
|
|
|
<script src="js/bls.js"></script>
|
|
<script>
|
|
(function(){
|
|
// one-time safety notice (same ack flag as the advanced page)
|
|
var seen = false; try{ seen = localStorage.getItem('bls_safety_ack')==='1' }catch(_){ }
|
|
if(!seen){
|
|
var sn = document.getElementById('safetyNotice');
|
|
sn.style.display = 'block';
|
|
document.getElementById('safetyAck').addEventListener('click', function(){
|
|
try{ localStorage.setItem('bls_safety_ack','1') }catch(_){ }
|
|
sn.style.display = 'none';
|
|
});
|
|
}
|
|
|
|
var chan = BLS.createChannel('bls-session');
|
|
var state = BLS.loadState();
|
|
var preview = BLS.Renderer(document.getElementById('preview'));
|
|
var durationMin = 0;
|
|
var tick = null;
|
|
|
|
// optional relay: only when served over http(s); silently absent on file://
|
|
var net = { ws: null, sid: null };
|
|
(function(){
|
|
if(!/^https?:$/.test(location.protocol) || !window.WebSocket) return;
|
|
try{ net.sid = localStorage.getItem('bls_easy_sid') }catch(_){ }
|
|
if(!net.sid){
|
|
var a = new Uint32Array(2);
|
|
(window.crypto||{getRandomValues:function(x){ for(var i=0;i<x.length;i++){ x[i]=Math.floor(Math.random()*0xffffffff) } }}).getRandomValues(a);
|
|
net.sid = 'room-' + (a[0].toString(36)+a[1].toString(36)).slice(0,8);
|
|
try{ localStorage.setItem('bls_easy_sid', net.sid) }catch(_){ }
|
|
}
|
|
function connect(){
|
|
var ws;
|
|
try{ ws = new WebSocket(BLS.relayUrl()) }catch(_){ return }
|
|
ws.onopen = function(){
|
|
var token = null; try{ token = localStorage.getItem('bls_relay_token') }catch(_){ }
|
|
try{ ws.send(JSON.stringify({type:'join', sessionId: net.sid, role:'admin', token: token || undefined})) }catch(_){ }
|
|
};
|
|
ws.onmessage = function(ev){
|
|
try{
|
|
var msg = JSON.parse(ev.data);
|
|
if(msg.type === 'joined'){
|
|
net.ws = ws;
|
|
document.getElementById('copyLinkBig').hidden = false;
|
|
document.getElementById('accessCard').hidden = true;
|
|
} else if(msg.type === 'join_denied'){
|
|
document.getElementById('accessCard').hidden = false;
|
|
try{ ws.close() }catch(_){ }
|
|
}
|
|
}catch(_){ }
|
|
};
|
|
ws.onclose = function(){ net.ws = null; setTimeout(connect, 5000) };
|
|
ws.onerror = function(){ try{ ws.close() }catch(_){ } };
|
|
}
|
|
connect();
|
|
document.getElementById('accessSave').addEventListener('click', function(){
|
|
var v = document.getElementById('accessInput').value.trim();
|
|
if(!v) return;
|
|
try{ localStorage.setItem('bls_relay_token', v) }catch(_){ }
|
|
this.textContent = 'Connecting…';
|
|
var b = this;
|
|
setTimeout(function(){ b.textContent = 'Save' }, 6000);
|
|
});
|
|
})();
|
|
|
|
function apply(next){
|
|
state = BLS.mergeState(state, next, true);
|
|
chan.send(state);
|
|
if(net.ws && net.ws.readyState === 1){
|
|
try{ net.ws.send(JSON.stringify({type:'state', sessionId: net.sid, payload: state})) }catch(_){ }
|
|
}
|
|
preview.setState(state, false);
|
|
if(state.running && !state.paused){ preview.start() } else { preview.stop() }
|
|
render();
|
|
}
|
|
|
|
function speedWord(v){ return v < 160 ? 'Gentle' : v < 300 ? 'Steady' : v < 450 ? 'Quick' : 'Fast' }
|
|
var soundMap = {
|
|
off: { audioEnabled: false },
|
|
ocean: { audioEnabled: true, audioMode: 'programmatic', programPreset: 'pink', syncPan: true },
|
|
beep: { audioEnabled: true, audioMode: 'programmatic', programPreset: 'tone', toneFreq: 220, toneDurationMs: 90 },
|
|
click: { audioEnabled: true, audioMode: 'programmatic', programPreset: 'click' },
|
|
knock: { audioEnabled: true, audioMode: 'programmatic', programPreset: 'woodblock' }
|
|
};
|
|
function currentSound(){
|
|
if(!state.audioEnabled) return 'off';
|
|
if(state.programPreset === 'pink') return 'ocean';
|
|
if(state.programPreset === 'tone') return 'beep';
|
|
if(state.programPreset === 'click') return 'click';
|
|
return 'knock';
|
|
}
|
|
|
|
function render(){
|
|
var b = document.getElementById('bigStart');
|
|
b.textContent = state.running ? '⏹ STOP' : '▶ START';
|
|
b.className = state.running ? 'stop' : '';
|
|
document.getElementById('speedWord').textContent = speedWord(state.speed);
|
|
var snd = currentSound();
|
|
document.querySelectorAll('#ezSounds .chip').forEach(function(c){ c.classList.toggle('on', c.dataset.sound === snd) });
|
|
document.querySelectorAll('#ezColors .dot').forEach(function(d){ d.classList.toggle('on', d.dataset.color.toLowerCase() === String(state.shapeColor).toLowerCase()) });
|
|
}
|
|
|
|
function fmt(sec){ var m = Math.floor(sec/60), s = sec%60; return String(m).padStart(2,'0')+':'+String(s).padStart(2,'0') }
|
|
function startTick(){
|
|
if(tick) clearInterval(tick);
|
|
tick = setInterval(function(){
|
|
var st = BLS.loadState();
|
|
if(!st.running){ clearInterval(tick); tick = null; document.getElementById('bigTimer').textContent = '00:00'; render(); return }
|
|
var elapsed = Math.max(0, Math.floor((Date.now() - st.startedAt) / 1000));
|
|
var el = document.getElementById('bigTimer');
|
|
if(st.durationSec > 0){
|
|
var rem = Math.max(0, st.durationSec - elapsed);
|
|
el.textContent = fmt(rem);
|
|
if(rem <= 0){ apply({ running:false, startedAt:null }) }
|
|
} else {
|
|
el.textContent = fmt(elapsed);
|
|
}
|
|
}, 250);
|
|
}
|
|
|
|
document.getElementById('bigStart').addEventListener('click', function(){
|
|
if(state.running){
|
|
apply({ running:false, paused:false, startedAt:null });
|
|
} else {
|
|
apply({ running:true, paused:false, pausedMs:0, pausedAt:null,
|
|
startedAt: Date.now(), durationSec: durationMin * 60 });
|
|
startTick();
|
|
}
|
|
});
|
|
|
|
var ezSpeed = document.getElementById('ezSpeed');
|
|
var ezSize = document.getElementById('ezSize');
|
|
var ezVolume = document.getElementById('ezVolume');
|
|
ezSpeed.value = state.speed || 250;
|
|
ezSize.value = state.size || 28;
|
|
ezVolume.value = Math.round((state.volume || 0.6) * 100);
|
|
ezSpeed.addEventListener('input', function(){ apply({ speed: parseInt(ezSpeed.value, 10) }) });
|
|
ezSize.addEventListener('input', function(){ apply({ size: parseInt(ezSize.value, 10) }) });
|
|
ezVolume.addEventListener('input', function(){ apply({ volume: parseInt(ezVolume.value, 10) / 100 }) });
|
|
|
|
document.getElementById('ezColors').addEventListener('click', function(e){
|
|
var c = e.target && e.target.dataset && e.target.dataset.color;
|
|
if(c) apply({ shapeColor: c });
|
|
});
|
|
document.getElementById('ezSounds').addEventListener('click', function(e){
|
|
var s = e.target && e.target.dataset && e.target.dataset.sound;
|
|
if(s) apply(soundMap[s]);
|
|
});
|
|
document.getElementById('ezDuration').addEventListener('click', function(e){
|
|
var m = e.target && e.target.dataset && e.target.dataset.min;
|
|
if(m === undefined) return;
|
|
durationMin = parseInt(m, 10) || 0;
|
|
document.querySelectorAll('#ezDuration .chip').forEach(function(c){ c.classList.toggle('on', c === e.target) });
|
|
if(state.running){ apply({ durationSec: durationMin * 60, startedAt: Date.now() }) }
|
|
});
|
|
|
|
document.getElementById('openPatientBig').addEventListener('click', function(){
|
|
window.open('patient.html' + (net.sid ? '#' + net.sid : ''), 'bls-patient');
|
|
});
|
|
document.getElementById('copyLinkBig').addEventListener('click', function(){
|
|
var link = new URL('patient.html#' + net.sid, location.href).href;
|
|
var b = this;
|
|
try{ navigator.clipboard.writeText(link) }catch(_){ try{ prompt('Patient link:', link) }catch(__){ } }
|
|
b.textContent = '✅ Copied — open it on the patient device';
|
|
setTimeout(function(){ b.textContent = '🔗 Copy link for patient\'s phone / tablet' }, 2500);
|
|
});
|
|
|
|
preview.setState(state, false);
|
|
if(state.running){ preview.start(); startTick() }
|
|
render();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|