Repository navigation
Expand file tree
/
Copy pathdial-viewer.html
More file actions
273 lines (253 loc) · 17.7 KB
/
Copy pathdial-viewer.html
File metadata and controls
273 lines (253 loc) · 17.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
<!doctype html>
<!-- Interactive on-screen Ulanzi D100H.
Drag the knob to spin (rotary, works on touch), tap keys (or 1-7) to light them.
Connect the real dial over WebHID (or a plugin WebSocket) to drive it live.
Calibrate to learn your unit's actual codes. Self-contained; only needs images/dial-skin/*. -->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>Ulanzi D100H, interactive dial</title>
<style>
:root { --ink:#e8e9ee; --mut:#9aa0ad; --line:#2a2e38; --chip:#1b1e26; --accent:#5ac8e6; }
* { box-sizing:border-box; }
body { margin:0; background:#0f1115; color:var(--ink); font:14px/1.5 system-ui,Segoe UI,Roboto,sans-serif; overflow-x:hidden; }
header { text-align:center; padding:16px 14px 4px; }
header h1 { margin:0; font-size:18px; font-weight:700; }
header p { margin:5px auto 0; max-width:520px; color:var(--mut); }
.bar { display:flex; gap:10px; justify-content:center; flex-wrap:wrap; padding:14px; }
button.btn { background:var(--chip); color:var(--ink); border:1px solid var(--line); border-radius:8px; padding:8px 14px; font:inherit; cursor:pointer; }
button.btn:hover { border-color:var(--accent); }
input.url { background:var(--chip); color:var(--ink); border:1px solid var(--line); border-radius:8px; padding:8px 10px; font:inherit; width:210px; }
#status { text-align:center; padding:2px 12px; }
#status .pill { display:inline-flex; align-items:center; gap:8px; background:var(--chip); border:1px solid var(--line);
border-radius:999px; padding:5px 14px; color:var(--mut); font-size:13px; }
#status .dot { width:8px; height:8px; border-radius:50%; background:#6b7280; flex:none; }
#status.ok .dot { background:#46d39a; }
#cal { display:none; max-width:560px; margin:10px auto 0; padding:10px 14px; border:1px solid var(--accent);
border-radius:10px; text-align:center; background:rgba(90,200,230,.08); }
#cal.on { display:block; }
.stage { display:flex; justify-content:center; padding:58px 16px 34px; }
.dial-device { position:relative; width:340px; max-width:min(340px,72vw); line-height:0; user-select:none; }
.dial-img { width:100%; height:auto; display:block; -webkit-user-drag:none; filter:drop-shadow(0 18px 30px rgba(0,0,0,.5)); }
.dwhite { position:absolute; inset:0; width:100%; height:auto; opacity:0; pointer-events:none; transition:opacity .04s ease; filter:drop-shadow(0 0 8px rgba(255,255,255,.7)); }
.dwhite.on { opacity:1; }
.dial-knob { position:absolute; transform:translate(-50%,-50%); transform-origin:50% 50%; cursor:grab; touch-action:none;
-webkit-user-drag:none; border-radius:50%; transition:filter .2s; }
.dial-knob:active { cursor:grabbing; }
.dial-knob.spin { filter:brightness(1.3) drop-shadow(0 0 12px rgba(255,255,255,.85)); }
.dkey { position:absolute; transform:translate(-50%,-50%); background:transparent; border:0; padding:0; cursor:pointer; border-radius:10px; touch-action:none; }
.dkey:hover { background:rgba(255,255,255,.07); }
.dlabel { position:absolute; display:flex; flex-direction:column; align-items:center; gap:4px;
background:#272c37; border:1px solid #3c4456; border-radius:10px; padding:8px 13px; white-space:nowrap;
pointer-events:none; text-align:center; box-shadow:0 3px 10px rgba(0,0,0,.5); line-height:1; }
.dlabel.L { right:calc(100% + 12px); transform:translateY(-50%); } /* fully left of the dial */
.dlabel.R { left:calc(100% + 12px); transform:translateY(-50%); } /* fully right of the dial */
.dlabel.T { bottom:calc(100% + 12px); transform:translateX(-50%); } /* fully above the dial */
.dlabel .fn { font-size:11px; font-weight:600; color:#f1f3f7; }
.dlabel .kb { font-size:9px; font-weight:500; color:#aab1bf; }
#legend { display:none; flex-wrap:wrap; gap:7px; justify-content:center; max-width:94vw; margin:2px auto 18px; }
#legend .lg { background:#272c37; border:1px solid #3c4456; border-radius:8px; padding:5px 10px; font-size:11px; font-weight:600; color:#f1f3f7; box-shadow:0 2px 6px rgba(0,0,0,.4); }
#legend .lg i { color:#aab1bf; font-style:normal; font-weight:500; font-size:9px; margin-left:4px; }
#hud { text-align:center; color:var(--ink); font:13px/1.6 ui-monospace,Menlo,Consolas,monospace; margin:2px 12px 24px; }
#hud b { color:var(--accent); font-weight:600; }
.panel { max-width:560px; margin:6px auto 12px; padding:0 18px; }
.panel .head { display:flex; justify-content:space-between; align-items:center; gap:8px; color:var(--mut); font-size:12px; margin-bottom:4px; }
#log { background:var(--chip); border:1px solid var(--line); border-radius:8px; padding:8px 10px; height:148px; overflow:auto;
font:12px/1.55 ui-monospace,Menlo,Consolas,monospace; white-space:pre-wrap; }
#log .m { color:var(--mut); } #log .u { color:var(--accent); }
footer { max-width:680px; margin:0 auto; padding:0 18px 40px; color:var(--mut); font-size:13px; }
footer code { background:var(--chip); padding:1px 5px; border-radius:4px; }
a { color:var(--accent); }
@media (max-width:560px){
.stage { padding:22px 12px 26px; }
.dial-device { max-width:78vw; }
.dlabel { display:none; } /* floating labels are desktop-only */
#legend { display:flex; } /* mobile shows a compact legend below the dial */
input.url { width:150px; }
}
</style>
</head>
<body>
<div style="position:fixed; bottom:5px; right:8px; font:11px/1 ui-monospace,Menlo,Consolas,monospace; color:#4a5161; z-index:9;">build 13</div>
<header>
<h1>Ulanzi D100H, interactive dial</h1>
<p>Drag the knob to spin. Tap a key or press <b>1–7</b>. <b>Connect</b> the real dial, or <b>Calibrate</b> to learn its codes.</p>
</header>
<div class="bar">
<button class="btn" id="hid">🎛️ Connect dial (WebHID)</button>
<input class="url" id="wsurl" value="ws://127.0.0.1:48907" title="plugin WebSocket bridge">
<button class="btn" id="ws">🔌 Connect plugin (WebSocket)</button>
<button class="btn" id="calbtn">🎯 Calibrate</button>
</div>
<div id="status"><span class="pill"><span class="dot"></span><span id="stxt">Not connected. Mouse, touch, and keyboard work now.</span></span></div>
<div id="cal">
<div id="calmsg"></div>
<div style="margin-top:8px">
<button class="btn" id="calskip" style="padding:4px 12px">Skip</button>
<button class="btn" id="calcancel" style="padding:4px 12px">Cancel</button>
</div>
</div>
<div class="stage"><div class="dial-device" id="device"></div></div>
<div id="legend"></div>
<div id="hud"></div>
<div class="panel">
<div class="head"><span>Event log: connect, then press or turn each control, and paste this to map keys</span>
<button class="btn" id="clear" style="padding:4px 10px">Clear</button></div>
<div id="log"><div class="m">waiting for input…</div></div>
</div>
<footer>
<p><b>Connect dial (WebHID)</b> reads the real D100H, so turning the physical knob spins this one. Chrome or Edge only;
pick <code>Dial_Lite</code> when prompted. <b>Calibrate</b> walks each control so the page learns your unit's
codes (saved in your browser). Caveats (the device's):</p>
<ul>
<li><b>Close Ulanzi Studio first.</b> While it's running the dial is in online mode (Studio handles the
input), so the page won't see the default volume/media codes it listens for.</li>
<li>The dial's rotate <i>is</i> a real volume key, so connecting will also move your system volume.</li>
<li>Over WebHID only the <b>dial and 3 media keys</b> are readable (Windows hides Ctrl-C/V/Z/Y). For all 7,
forward events from a UlanziDeck plugin to the <b>WebSocket</b> box (see <a href="plugin-sdk.md">plugin-sdk.md</a>).</li>
<li>VID <code>0xfff1</code> / PID <code>0x0082</code> (see <a href="hardware.md">hardware.md</a>). Assets and method: <a href="dial-skin.md">dial-skin.md</a>.</li>
</ul>
</footer>
<script>
const A = 'images/dial-skin/';
const KNOB = { cx:50.95, cy:61.58, dia:57.95 };
// index: 0 Copy,1 Paste,2 Prev,3 Play/Pause,4 Next,5 Redo,6 Undo
const KEYS = [
{ cx:3.0, cy:62.6, w:7.5, h:21.8, label:'Copy', sub:'Ctrl+C', side:'L' },
{ cx:3.0, cy:39.0, w:7.5, h:22.0, label:'Paste', sub:'Ctrl+V', side:'L' },
{ cx:16.4,cy:11.0, w:29.2, h:22.1, label:'Prev', sub:'media', side:'T' },
{ cx:47.8,cy:11.0, w:32.6, h:22.1, label:'Play/Pause', sub:'media', side:'T' },
{ cx:81.3,cy:10.9, w:33.3, h:22.0, label:'Next', sub:'media', side:'T' },
{ cx:97.0,cy:39.0, w:7.5, h:22.0, label:'Redo', sub:'Ctrl+Y', side:'R' },
{ cx:97.0,cy:63.3, w:7.5, h:23.2, label:'Undo', sub:'Ctrl+Z', side:'R' },
];
const KEYMAP = { '0_0':0,'0_1':1,'0_3':2,'0_4':3,'0_5':4,'0_7':5,'0_8':6 }; // default WS row_col to key (tune via Calibrate)
const device = document.getElementById('device');
const base = new Image(); base.src=A+'ulanzi-dial.png'; base.className='dial-img'; base.alt='Ulanzi D100H'; base.draggable=false; device.append(base);
const whites = KEYS.map((_,i)=>{ const w=new Image(); w.src=A+`white-${i+1}.png`; w.className='dwhite'; w.draggable=false; device.append(w); return w; });
const knob = new Image(); knob.src=A+'ulanzi-knob.png'; knob.className='dial-knob'; knob.draggable=false;
knob.style.left=KNOB.cx+'%'; knob.style.top=KNOB.cy+'%'; knob.style.width=KNOB.dia+'%'; device.append(knob);
const stxt = document.getElementById('stxt');
const statusEl = document.getElementById('status');
const hudEl = document.getElementById('hud');
const logEl = document.getElementById('log');
const status = t => { stxt.textContent = t; };
const connected = () => statusEl.classList.add('ok');
let angle=0, revTotal=0, spins=0, flips=0, lastDir=0, lastFired='—', spinT;
function hud(){ const a=(((Math.round(angle)%360)+360)%360);
hudEl.innerHTML = `angle <b>${a}°</b> full spins <b>${spins}</b> direction changes <b>${flips}</b> last: <b>${lastFired}</b>`; }
function applyAngle(){ knob.style.transform=`translate(-50%,-50%) rotate(${angle}deg)`; knob.classList.add('spin'); clearTimeout(spinT); spinT=setTimeout(()=>knob.classList.remove('spin'),220); hud(); }
function spin(delta){ angle+=delta; revTotal+=Math.abs(delta); spins=Math.floor(revTotal/360);
if(Math.abs(delta)>=4){ const d=delta>0?1:-1; if(lastDir && d!==lastDir) flips++; lastDir=d; } applyAngle(); }
function press(){ lastFired='press'; knob.classList.add('spin'); clearTimeout(spinT); spinT=setTimeout(()=>knob.classList.remove('spin'),150); hud(); }
function flash(i){ const w=whites[i]; if(w){ w.classList.add('on'); setTimeout(()=>w.classList.remove('on'),180); } }
function logEvent(src,hex,act){ if(logEl.firstElementChild&&logEl.firstElementChild.className==='m')logEl.textContent='';
const d=document.createElement('div'); d.innerHTML=`<span class="m">${src}</span> ${hex} <span class="u">${act}</span>`;
logEl.prepend(d); while(logEl.childElementCount>40) logEl.lastElementChild.remove(); }
document.getElementById('clear').onclick=()=>{ logEl.innerHTML='<div class="m">cleared</div>'; };
// keys: hit-areas + labels
KEYS.forEach((k,i)=>{
const b=document.createElement('button'); b.className='dkey';
b.style.left=k.cx+'%'; b.style.top=k.cy+'%'; b.style.width=k.w+'%'; b.style.height=k.h+'%'; b.title=`${k.label} (${k.sub})`;
b.addEventListener('pointerdown',e=>{ e.preventDefault(); lastFired=k.label; hud(); flash(i); });
device.append(b);
const lab=document.createElement('div'); lab.className='dlabel '+k.side; lab.innerHTML=`<span class="fn">${k.label}</span><span class="kb">${k.sub}</span>`;
if(k.side==='T') lab.style.left=k.cx+'%'; else lab.style.top=k.cy+'%';
device.append(lab);
});
document.getElementById('legend').innerHTML = KEYS.map(k=>`<span class="lg">${k.label}<i>${k.sub}</i></span>`).join('');
// knob: rotary drag (mouse + touch); tap = press
let drag=false, lastA=0, cx0=0, cy0=0, moved=false;
const ptAng = e => Math.atan2(e.clientY-cy0, e.clientX-cx0)*180/Math.PI;
knob.addEventListener('pointerdown',e=>{ const r=knob.getBoundingClientRect(); cx0=r.left+r.width/2; cy0=r.top+r.height/2;
lastA=ptAng(e); drag=true; moved=false; knob.setPointerCapture(e.pointerId); e.preventDefault(); });
knob.addEventListener('pointermove',e=>{ if(!drag) return; const a=ptAng(e); let d=a-lastA; d=((d+540)%360)-180;
if(Math.abs(d)>0.6) moved=true; lastA=a; spin(d); });
function endDrag(e){ if(drag&&!moved) press(); drag=false; try{knob.releasePointerCapture(e.pointerId);}catch{} }
knob.addEventListener('pointerup',endDrag); knob.addEventListener('pointercancel',endDrag);
addEventListener('keydown',e=>{ const n=parseInt(e.key,10); if(n>=1&&n<=7){ lastFired=KEYS[n-1].label; hud(); flash(n-1); } });
// ---- action helpers ----
function applyAct(L){ if(L.t==='key'){ lastFired=KEYS[L.i].label; flash(L.i); hud(); }
else if(L.t==='rot'){ lastFired='rotate '+(L.dir>0?'CW':'CCW'); spin(L.dir*16); }
else { press(); } }
function labelAct(L){ return L.t==='key'?KEYS[L.i].label : L.t==='rot'?('rotate '+(L.dir>0?'CW':'CCW')) : 'press'; }
// ---- calibration ----
const CAL=[
{label:'COPY key', act:{t:'key',i:0}}, {label:'PASTE key', act:{t:'key',i:1}},
{label:'PREV key', act:{t:'key',i:2}}, {label:'PLAY/PAUSE key', act:{t:'key',i:3}},
{label:'NEXT key', act:{t:'key',i:4}}, {label:'REDO key', act:{t:'key',i:5}},
{label:'UNDO key', act:{t:'key',i:6}}, {label:'rotate RIGHT (CW)', act:{t:'rot',dir:1}},
{label:'rotate LEFT (CCW)', act:{t:'rot',dir:-1}}, {label:'PRESS the knob', act:{t:'press'}},
];
let learned = (()=>{ try{ return JSON.parse(localStorage.getItem('d100h-map')||'{}'); }catch{ return {}; } })();
let conn=false, calOn=false, calStep=0;
const calBox=document.getElementById('cal');
function calPrompt(){ document.getElementById('calmsg').innerHTML=`Calibrating. On the real dial do: <b>${CAL[calStep].label}</b> (${calStep+1}/${CAL.length})`;
const a=CAL[calStep].act; if(a.t==='key') flash(a.i); }
function startCal(){ if(!conn){ status('Connect the dial first (WebHID or WebSocket), then Calibrate.'); return; }
calOn=true; calStep=0; learned={}; calBox.classList.add('on'); calPrompt(); status('Calibrating…'); }
function calCapture(token){ learned[token]=CAL[calStep].act; logEvent('CAL', token, 'to '+CAL[calStep].label); calStep++;
if(calStep>=CAL.length){ calOn=false; calBox.classList.remove('on'); localStorage.setItem('d100h-map',JSON.stringify(learned));
status('Calibrated and saved. Paste this map to share: '+JSON.stringify(learned)); logEvent('CAL','DONE',JSON.stringify(learned)); }
else calPrompt(); }
document.getElementById('calbtn').onclick=startCal;
document.getElementById('calskip').onclick=()=>{ if(calOn){ logEvent('CAL','(skip)',CAL[calStep].label); calStep++; if(calStep>=CAL.length){ calOn=false; calBox.classList.remove('on'); localStorage.setItem('d100h-map',JSON.stringify(learned)); status('Calibrated (some skipped). Map: '+JSON.stringify(learned)); } else calPrompt(); } };
document.getElementById('calcancel').onclick=()=>{ calOn=false; calBox.classList.remove('on'); status('Calibration cancelled.'); };
// route any device event: calibrate, then learned map, then built-in default
let calCool=0;
function route(token, hexOrRaw, src, dflt){
if(calOn){ if(performance.now()<calCool) return; calCool=performance.now()+700; calCapture(token); return; }
const L=learned[token];
if(L){ applyAct(L); logEvent(src, hexOrRaw, '(mapped) '+labelAct(L)); return; }
logEvent(src, hexOrRaw, dflt());
}
// ---- WebHID ----
document.getElementById('hid').onclick = async () => {
if(!('hid' in navigator)) return status('WebHID not supported. Use Chrome or Edge.');
try{
const [dev]=await navigator.hid.requestDevice({filters:[{vendorId:0xfff1,productId:0x0082}]});
if(!dev) return status('No device picked.');
if(!dev.opened) await dev.open();
conn=true; connected(); status('Connected to '+dev.productName+'. Turn the dial, or hit Calibrate.');
dev.addEventListener('inputreport', e=>{
const b=[e.reportId]; for(let i=0;i<e.data.byteLength;i++) b.push(e.data.getUint8(i));
const code=(b[1]||0)|((b[2]||0)<<8); if(code===0) return; // release
const hex=b.map(x=>x.toString(16).padStart(2,'0')).join(' ');
route('hid:0x'+code.toString(16), hex, 'HID r'+e.reportId, ()=>{
if(code===0xE9){ lastFired='rotate CW'; spin(16); return 'rotate CW (vol+)'; }
if(code===0xEA){ lastFired='rotate CCW'; spin(-16); return 'rotate CCW (vol-)'; }
if(code===0xE2){ press(); return 'press (mute)'; }
if(code===0xB6){ lastFired='Prev'; flash(2); hud(); return 'Prev'; }
if(code===0xCD){ lastFired='Play/Pause'; flash(3); hud(); return 'Play/Pause'; }
if(code===0xB5){ lastFired='Next'; flash(4); hud(); return 'Next'; }
return '?? unmapped 0x'+code.toString(16);
});
});
}catch(err){ status('Connect failed: '+err.message); }
};
// ---- WebSocket bridge ----
document.getElementById('ws').onclick=()=>{
const url=document.getElementById('wsurl').value.trim(); let ws;
try{ ws=new WebSocket(url); }catch{ return status('Bad URL'); }
status('Connecting to '+url+' …');
ws.onopen =()=>{ conn=true; connected(); status('Plugin bridge connected: '+url+'. Press or turn, or Calibrate.'); };
ws.onclose=()=>{ statusEl.classList.remove('ok'); status('Plugin bridge closed.'); };
ws.onerror=()=>status('WebSocket error. Is the plugin running?');
ws.onmessage=e=>{ let m; try{ m=JSON.parse(e.data); }catch{ return; }
const raw=String(e.data).slice(0,52);
route('ws:'+(m.key||m.type), raw, 'WS', ()=>{
if(m.type==='rotate'){ const d=m.dir<0?-1:1; lastFired='rotate '+(d>0?'CW':'CCW'); spin(d*16); return 'rotate'; }
if(m.type==='press'){ press(); return 'press'; }
if(m.type==='button'){ const i=KEYMAP[m.key]; if(i!=null){ lastFired=KEYS[i].label; flash(i); hud(); } return 'button '+(m.key||''); }
return m.type||'?';
});
};
};
hud();
</script>
</body>
</html>