ps-access 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/web/xmb.js ADDED
@@ -0,0 +1,1069 @@
1
+ // XMB-style full-screen configurator for the PlayStation Access Controller.
2
+ import {
3
+ ACTIONS, STICKS, ORIENTATIONS, PROFILE_COUNT,
4
+ STICK_DEFAULT_SENSITIVITY, STICK_DEFAULT_DEADZONE,
5
+ parseProfile, buildProfile,
6
+ } from "./access-protocol.mjs";
7
+ import {
8
+ hidSupported, grantedControllers, requestControllers, ensureOpen,
9
+ readProfileRaw, writeProfileRaw, setActiveProfile,
10
+ } from "./hid-web.mjs";
11
+ import { SYMBOLS, symLabel, nameLabel, M, profileSVG, decodePhysical, PHYS_NAMES } from "./controller-render.mjs";
12
+ import {
13
+ PRESETS, toPortable, applyPortable, shareURL, parseShareHash, toFileText, fromFileText,
14
+ } from "./profile-library.mjs";
15
+ import {
16
+ PHYS_LABELS, STICK_MODES, STICK_DIRS, defaultBridgeMap, keyEventToValue, displayValue,
17
+ toConfigJSON, runCommand,
18
+ } from "./bridge-map.mjs";
19
+
20
+ const $ = (s) => document.querySelector(s);
21
+
22
+ // ============================ state ============================
23
+ let controllers = []; // { device, name, profiles:[obj x3] }
24
+ let activeCtrl = 0;
25
+ // nav: col = blade index, row = vertical item index, drill = { key, index } | null
26
+ const nav = { col: 1, row: 0, drill: null };
27
+ let soundOn = false;
28
+ let phys = new Set(); // physically-pressed button indices (0-7 perimeter, 8 center, 9 stick-click)
29
+ let liveAxes = [0, 0]; // physical stick, -1..1
30
+ let lastInputAt = 0;
31
+ let renaming = false;
32
+ let monitorMode = false; // full-screen live input monitor open
33
+ let monitorArm = false; // warning/confirm gate shown before entering the monitor
34
+ let warnSel = 0; // highlighted option on the confirm gate (0 = Start, 1 = Cancel)
35
+ let lastProfileSlot = 0; // slot of the most recently focused profile blade — what the Save blade acts on
36
+ let deviceProfile = null; // active on-device profile slot (0-based, from input-report byte 39); null until known
37
+ let pendingShare = null; // a portable profile decoded from the URL hash, awaiting "Apply shared"
38
+ let bridgeMap = loadBridgeMap(); // PC input-bridge mapping edited in the Key Bridge blade
39
+ let capturing = null; // { kind:"button", idx } | { kind:"stick", dir } while listening for a key
40
+
41
+ const BLADES = [
42
+ { key: "controllers", label: "Controllers", kind: "controllers" },
43
+ { key: "p1", label: "Profile 1", kind: "profile", slot: 0 },
44
+ { key: "p2", label: "Profile 2", kind: "profile", slot: 1 },
45
+ { key: "p3", label: "Profile 3", kind: "profile", slot: 2 },
46
+ { key: "save", label: "Save", kind: "save", glyph: "▣" },
47
+ { key: "library", label: "Library", kind: "library" },
48
+ { key: "bridge", label: "Key Bridge", kind: "bridge" },
49
+ { key: "monitor", label: "Monitor", kind: "monitor" },
50
+ ];
51
+
52
+ function loadBridgeMap() {
53
+ try { const s = localStorage.getItem("psaccess.bridgeMap"); if (s) return JSON.parse(s); } catch { /* ignore */ }
54
+ return defaultBridgeMap();
55
+ }
56
+ function saveBridgeMap() {
57
+ try { localStorage.setItem("psaccess.bridgeMap", JSON.stringify(bridgeMap)); } catch { /* ignore */ }
58
+ }
59
+
60
+ // Stylized, generic gamepad icon for the Controllers blade. Parts use the same `.seg` class
61
+ // as the profile controller render, so it inherits the identical segment styling.
62
+ const CONTROLLER_ICON = `<svg class="ctrl-icon" viewBox="0 0 120 92" xmlns="http://www.w3.org/2000/svg">
63
+ <path class="seg" d="M45 28 H75 C90 28 95 35 99 47 L107 67 C111 80 98 86 91 75 L83 61 C80 56 77 54 72 54 H48 C43 54 40 56 37 61 L29 75 C22 86 9 80 13 67 L21 47 C25 35 30 28 45 28 Z"/>
64
+ <path class="seg" d="M34.5 33.5 H41.5 V39 H47 V46 H41.5 V51.5 H34.5 V46 H29 V39 H34.5 Z"/>
65
+ <circle class="seg" cx="82" cy="34" r="4.2"/>
66
+ <circle class="seg" cx="91" cy="43" r="4.2"/>
67
+ <circle class="seg" cx="73" cy="43" r="4.2"/>
68
+ <circle class="seg" cx="82" cy="52" r="4.2"/>
69
+ </svg>`;
70
+
71
+ // Stylized, generic save (floppy-disk) icon for the Save blade — same `.seg` segment style.
72
+ const SAVE_ICON = `<svg class="save-icon" viewBox="0 0 96 96" xmlns="http://www.w3.org/2000/svg">
73
+ <path class="seg" d="M28 20 H60 L76 36 V68 Q76 76 68 76 H28 Q20 76 20 68 V28 Q20 20 28 20 Z"/>
74
+ <rect class="seg" x="38" y="20" width="18" height="16" rx="2"/>
75
+ <rect class="seg" x="32" y="48" width="32" height="22" rx="3"/>
76
+ </svg>`;
77
+
78
+ // Stylized "live signal" waveform for the Monitor blade (a stroked line, not segments —
79
+ // it reads instantly as activity/input).
80
+ const MONITOR_ICON = `<svg class="mon-icon" viewBox="0 0 120 92" xmlns="http://www.w3.org/2000/svg">
81
+ <polyline class="wave" points="12,52 30,52 40,30 52,68 64,22 76,52 86,44 108,44"/>
82
+ </svg>`;
83
+
84
+ // Stylized "library / share" icon (stacked cards) for the Library blade — same `.seg` style.
85
+ const LIBRARY_ICON = `<svg class="save-icon" viewBox="0 0 96 96" xmlns="http://www.w3.org/2000/svg">
86
+ <rect class="seg" x="22" y="30" width="40" height="44" rx="5"/>
87
+ <rect class="seg" x="34" y="22" width="40" height="44" rx="5"/>
88
+ </svg>`;
89
+
90
+ // Stylized keyboard icon for the Key Bridge blade — same `.seg` style.
91
+ const BRIDGE_ICON = `<svg class="ctrl-icon" viewBox="0 0 120 92" xmlns="http://www.w3.org/2000/svg">
92
+ <rect class="seg" x="12" y="26" width="96" height="48" rx="9"/>
93
+ <rect class="seg" x="24" y="37" width="10" height="10" rx="2"/>
94
+ <rect class="seg" x="40" y="37" width="10" height="10" rx="2"/>
95
+ <rect class="seg" x="56" y="37" width="10" height="10" rx="2"/>
96
+ <rect class="seg" x="72" y="37" width="10" height="10" rx="2"/>
97
+ <rect class="seg" x="88" y="37" width="8" height="10" rx="2"/>
98
+ <rect class="seg" x="40" y="53" width="40" height="10" rx="2"/>
99
+ </svg>`;
100
+
101
+ function activeProfile() {
102
+ const b = BLADES[nav.col];
103
+ if (b?.kind === "profile") return controllers[activeCtrl]?.profiles[b.slot] || null;
104
+ return controllers[activeCtrl]?.profiles[0] || null;
105
+ }
106
+
107
+ // Apply current physical button state to ALL on-screen controller renders (any orientation).
108
+ function updateLive() {
109
+ for (const el of document.querySelectorAll("#stage svg [data-btn]")) {
110
+ el.classList.toggle("on", phys.has(+el.getAttribute("data-btn")));
111
+ }
112
+ for (const th of document.querySelectorAll("#stage svg .thumb")) {
113
+ // raw axes — the live thumb always reflects the physical stick, regardless of the
114
+ // displayed orientation (which only relocates where the stick is drawn)
115
+ const bx = +th.dataset.bx, by = +th.dataset.by;
116
+ th.setAttribute("cx", (bx + liveAxes[0] * M.THUMB_R).toFixed(1));
117
+ th.setAttribute("cy", (by + liveAxes[1] * M.THUMB_R).toFixed(1));
118
+ }
119
+ // Key Bridge: light up the row for whichever physical button is being pressed.
120
+ for (const row of document.querySelectorAll("#items .item[data-phys]")) {
121
+ row.classList.toggle("physdown", phys.has(+row.dataset.phys));
122
+ }
123
+ }
124
+
125
+ // ============================ value spinners ============================
126
+ const BTN_VALUES = Object.keys(ACTIONS).map(Number); // 0..18
127
+ const PORT_VALUES = [0, 101, 102, ...Object.keys(ACTIONS).map(Number).filter((c) => c !== 0)];
128
+ const ORIENT_VALUES = [3, 2, 1, 0];
129
+ const STICK_ASSIGN = [0, 1, 2]; // off / left / right
130
+
131
+ function portLabel(v) {
132
+ if (v === 0) return { sym: "—", name: "Not assigned" };
133
+ if (v > 100) return { sym: "", name: STICKS[v - 100] };
134
+ return { sym: SYMBOLS[v] || "", name: ACTIONS[v] };
135
+ }
136
+
137
+ // Build the list of editable rows for a drill key on a profile.
138
+ function drillRows(profile, key) {
139
+ if (key === "buttons") {
140
+ return profile.buttons.map((b, i) => ({
141
+ label: `Button ${i + 1}`, focus: { type: "button", index: i },
142
+ get: () => b.map1, set: (v) => { b.map1 = v; }, values: BTN_VALUES,
143
+ display: (v) => ({ sym: v === 0 ? "—" : (SYMBOLS[v] || ""), name: nameLabel(v) }),
144
+ }));
145
+ }
146
+ if (key === "ports") {
147
+ return [1, 2, 3, 4].map((p) => {
148
+ const port = profile.ports[p];
149
+ return {
150
+ label: `Port ${p}`, focus: { type: "port", index: p },
151
+ get: () => (port.kind === "stick" ? 100 + port.stick : port.kind === "button" ? port.map1 : 0),
152
+ set: (v) => {
153
+ if (v === 0) profile.ports[p] = { kind: "none" };
154
+ else if (v > 100) profile.ports[p] = { kind: "stick", stick: v - 100, orientation: profile.ports.find((x) => x.kind === "stick")?.orientation ?? 3 };
155
+ else profile.ports[p] = { kind: "button", analog: false, map1: v, map2: 0, toggle: false };
156
+ }, values: PORT_VALUES, display: portLabel,
157
+ };
158
+ });
159
+ }
160
+ if (key === "stick") {
161
+ const st = profile.ports[0];
162
+ return [
163
+ {
164
+ label: "Assignment", focus: { type: "stick" },
165
+ get: () => (st.kind === "stick" ? st.stick : 0),
166
+ set: (v) => { if (v === 0) profile.ports[0] = { kind: "none" }; else profile.ports[0] = { kind: "stick", stick: v, orientation: st.kind === "stick" ? st.orientation : 3, sensitivity: st.sensitivity ?? 0, deadzone: st.deadzone ?? [0, 0, 0, 0, 0, 0] }; },
167
+ values: STICK_ASSIGN, display: (v) => ({ sym: "", name: v === 0 ? "Off" : STICKS[v] }),
168
+ },
169
+ {
170
+ label: "Orientation", focus: { type: "stick" },
171
+ get: () => (st.kind === "stick" ? st.orientation : 3),
172
+ set: (v) => { for (const pt of profile.ports) if (pt.kind === "stick") pt.orientation = v; },
173
+ values: ORIENT_VALUES, display: (v) => ({ sym: "", name: ORIENTATIONS[v] }),
174
+ },
175
+ ];
176
+ }
177
+ if (key === "tuning") {
178
+ const st = profile.ports[0];
179
+ const ensure = () => { if (st.kind === "stick") { st.sensitivity ??= 0; st.deadzone ??= [0, 0, 0, 0, 0, 0]; } };
180
+ const dz = (idx) => ({
181
+ label: ["Inner deadzone", "Curve", "Outer deadzone"][idx], focus: { type: "stick" },
182
+ get: () => (st.deadzone ? st.deadzone[idx * 2] : 0),
183
+ set: (v) => { ensure(); st.deadzone = (st.deadzone || [0, 0, 0, 0, 0, 0]).slice(); st.deadzone[idx * 2] = st.deadzone[idx * 2 + 1] = v; },
184
+ values: Array.from({ length: 18 }, (_, i) => i * 15).concat(255), display: (v) => ({ sym: "", name: String(v) }),
185
+ });
186
+ return [
187
+ { label: "Sensitivity", focus: { type: "stick" }, get: () => st.sensitivity ?? 0, set: (v) => { ensure(); st.sensitivity = v; }, values: Array.from({ length: 11 }, (_, i) => i), display: (v) => ({ sym: "", name: v === 0 ? "default" : String(v) }) },
188
+ dz(0), dz(1), dz(2),
189
+ ];
190
+ }
191
+ return [];
192
+ }
193
+
194
+ // vertical items for the focused blade
195
+ function bladeItems(blade) {
196
+ if (blade.kind === "profile") {
197
+ const isActive = blade.slot === deviceProfile;
198
+ return [
199
+ { key: "buttons", label: "Buttons", drill: true },
200
+ { key: "stick", label: "Built-in stick", drill: true },
201
+ { key: "ports", label: "Expansion ports", drill: true },
202
+ { key: "tuning", label: "Stick tuning", drill: true },
203
+ { key: "rename", label: "Rename profile", action: "rename" },
204
+ { key: "setactive", label: isActive ? "✓ Active on controller" : "Set active on controller", action: "setActive" },
205
+ { key: "save", label: "Save to controller", action: "save" },
206
+ ];
207
+ }
208
+ if (blade.kind === "controllers") {
209
+ // Always offer a manual connect — works as first-connect and as a reconnect/grant fallback.
210
+ return [
211
+ ...controllers.map((c, i) => ({ key: "ctrl" + i, label: c.name + (i === activeCtrl ? " ✓" : ""), action: "selectCtrl", ctrl: i })),
212
+ { key: "connect", label: "+ Connect a controller…", action: "connect" },
213
+ ];
214
+ }
215
+ if (blade.kind === "save") {
216
+ return [
217
+ { key: "savep", label: `Save Profile ${lastProfileSlot + 1}` + (controllers[activeCtrl]?.profiles[lastProfileSlot]?.name ? ` · ${controllers[activeCtrl].profiles[lastProfileSlot].name}` : ""), action: "save" },
218
+ { key: "saveall", label: "Save all 3 profiles", action: "saveAll" },
219
+ { key: "reload", label: "Reload from controller", action: "reload" },
220
+ ];
221
+ }
222
+ if (blade.kind === "library") {
223
+ const items = [];
224
+ if (pendingShare) {
225
+ items.push({ key: "applyshared", label: `Apply shared profile${pendingShare.name ? ` · ${pendingShare.name}` : ""} → Profile ${lastProfileSlot + 1}`, action: "applyShared" });
226
+ }
227
+ items.push(
228
+ { key: "export", label: `Export Profile ${lastProfileSlot + 1} (download file)`, action: "export" },
229
+ { key: "copylink", label: `Copy share link for Profile ${lastProfileSlot + 1}`, action: "copylink" },
230
+ { key: "import", label: `Import from file → Profile ${lastProfileSlot + 1}`, action: "import" },
231
+ );
232
+ PRESETS.forEach((p, i) => items.push({ key: "preset" + i, label: `Preset · ${p.name}`, action: "applyPreset", preset: i }));
233
+ return items;
234
+ }
235
+ if (blade.kind === "bridge") {
236
+ const cap = (c) => (capturing && capturing.kind === c.kind && capturing.idx === c.idx && capturing.dir === c.dir);
237
+ const items = [];
238
+ PHYS_LABELS.forEach((lab, i) => {
239
+ const c = { kind: "button", idx: i, dir: undefined };
240
+ items.push({ key: "b" + i, phys: i, action: "capture", cap: c,
241
+ label: `${lab} → ${cap(c) ? "press a key… (Esc)" : displayValue(bridgeMap.buttons[i])}` });
242
+ });
243
+ items.push({ key: "stickmode", action: "cycleStick", label: `Stick mode → ${bridgeMap.stick.mode}` });
244
+ if (bridgeMap.stick.mode === "keys") {
245
+ STICK_DIRS.forEach((dir) => {
246
+ const c = { kind: "stick", idx: undefined, dir };
247
+ items.push({ key: "sd-" + dir, action: "capture", cap: c,
248
+ label: `Stick ${dir} → ${cap(c) ? "press a key… (Esc)" : displayValue(bridgeMap.stick[dir])}` });
249
+ });
250
+ }
251
+ items.push(
252
+ { key: "br-reset", label: "Reset to defaults", action: "bridgeReset" },
253
+ { key: "br-export", label: "Export config file (bridge.json)", action: "bridgeExport" },
254
+ { key: "br-json", label: "Copy config JSON", action: "bridgeCopyJson" },
255
+ { key: "br-cmd", label: "Copy run command", action: "bridgeCopyCmd" },
256
+ );
257
+ return items;
258
+ }
259
+ if (blade.kind === "monitor") {
260
+ return [{ key: "openmon", label: "Open live monitor", action: "monitor" }];
261
+ }
262
+ return [];
263
+ }
264
+
265
+ // ============================ rendering ============================
266
+ // The profile slot the indicator/monitor reflect: the controller's *active* profile (set with the
267
+ // device's profile button, read live from the input report) when known, else the focused UI profile.
268
+ function shownProfileSlot() {
269
+ return deviceProfile ?? lastProfileSlot;
270
+ }
271
+ // Top-bar profile context (under the controller name) — shows the active on-device profile.
272
+ function updateProfileTag() {
273
+ const el = $("#mon-prof");
274
+ if (!el) return;
275
+ const slot = shownProfileSlot();
276
+ const prof = controllers[activeCtrl]?.profiles[slot];
277
+ if (!prof) { el.innerHTML = ""; return; }
278
+ const st = prof.ports[0];
279
+ const orient = st.kind === "stick" ? st.orientation : 3;
280
+ el.innerHTML = `<b>Profile ${slot + 1}</b> · ` +
281
+ (prof.name ? `${prof.name} · ` : "") + ORIENTATIONS[orient];
282
+ }
283
+
284
+ function render() {
285
+ if (BLADES[nav.col]?.kind === "profile") lastProfileSlot = BLADES[nav.col].slot;
286
+ updateProfileTag();
287
+ const bladesEl = $("#blades");
288
+ bladesEl.innerHTML = "";
289
+ BLADES.forEach((b, i) => {
290
+ const el = document.createElement("div");
291
+ el.className = "blade" + (i === nav.col ? " focused" : "");
292
+ const icon = document.createElement("div");
293
+ icon.className = "icon";
294
+ if (b.kind === "profile") icon.innerHTML = profileSVG(controllers[activeCtrl]?.profiles[b.slot]);
295
+ else {
296
+ const g = document.createElement("div"); g.className = "glyph";
297
+ if (b.kind === "controllers") g.innerHTML = CONTROLLER_ICON;
298
+ else if (b.kind === "save") g.innerHTML = SAVE_ICON;
299
+ else if (b.kind === "monitor") g.innerHTML = MONITOR_ICON;
300
+ else if (b.kind === "library") g.innerHTML = LIBRARY_ICON;
301
+ else if (b.kind === "bridge") g.innerHTML = BRIDGE_ICON;
302
+ else g.textContent = b.glyph;
303
+ icon.append(g);
304
+ }
305
+ const lab = document.createElement("div");
306
+ lab.className = "label";
307
+ lab.textContent = b.label;
308
+ el.append(icon, lab);
309
+ bladesEl.append(el);
310
+ });
311
+
312
+ bladesEl.classList.toggle("drilled", !!nav.drill);
313
+ renderItems();
314
+ renderHero();
315
+ renderCrumb();
316
+ layout();
317
+ updateLive(); // re-apply live state to freshly built renders
318
+ announce(describeNav());
319
+ }
320
+
321
+ function renderItems() {
322
+ const wrap = $("#items");
323
+ wrap.innerHTML = "";
324
+ wrap.setAttribute("role", "listbox");
325
+ wrap.setAttribute("aria-label", BLADES[nav.col].label + (nav.drill ? " " + (DRILL_LABELS[nav.drill.key] || "") : "") + " options");
326
+ const blade = BLADES[nav.col];
327
+ if (nav.drill) {
328
+ const profile = activeProfile();
329
+ const rows = drillRows(profile, nav.drill.key);
330
+ rows.forEach((r, i) => {
331
+ const v = r.get();
332
+ const disp = r.display(v);
333
+ const el = document.createElement("div");
334
+ el.className = "item" + (i === nav.drill.index ? " sel" : "");
335
+ el.setAttribute("role", "option");
336
+ el.setAttribute("aria-selected", String(i === nav.drill.index));
337
+ el.setAttribute("aria-label", `${r.label}: ${disp.name}`);
338
+ el.innerHTML = `<span class="lab">${r.label}</span><span class="val"><span class="arrow">◀</span><span class="sym">${disp.sym || ""}</span> ${disp.name}<span class="arrow">▶</span></span>`;
339
+ el.onclick = () => { nav.drill.index = i; render(); };
340
+ wrap.append(el);
341
+ });
342
+ } else {
343
+ const items = bladeItems(blade);
344
+ items.forEach((it, i) => {
345
+ const el = document.createElement("div");
346
+ el.className = "item" + (i === nav.row ? " sel" : "");
347
+ el.setAttribute("role", "option");
348
+ el.setAttribute("aria-selected", String(i === nav.row));
349
+ if (it.phys != null) el.dataset.phys = it.phys; // Key Bridge: highlight when that button is pressed
350
+ el.innerHTML = `<span class="chev">▸</span><span class="lab">${it.label}</span>`;
351
+ el.onclick = () => { nav.row = i; activate(); };
352
+ wrap.append(el);
353
+ });
354
+ }
355
+ }
356
+
357
+ function renderHero() {
358
+ const blade = BLADES[nav.col];
359
+ const hero = $("#hero");
360
+ // The blade itself is the render; the enlarged hero appears only when you drill in.
361
+ const profile = activeProfile();
362
+ if (!nav.drill || !profile) { hero.style.opacity = "0"; hero.innerHTML = ""; return; }
363
+ const rows = drillRows(profile, nav.drill.key);
364
+ const focus = rows[nav.drill.index]?.focus || null;
365
+ hero.innerHTML = profileSVG(profile, { focus });
366
+ hero.style.opacity = ".97";
367
+ }
368
+
369
+ const DRILL_LABELS = { buttons: "Buttons", stick: "Built-in stick", ports: "Expansion ports", tuning: "Stick tuning" };
370
+
371
+ function renderCrumb() {
372
+ const blade = BLADES[nav.col];
373
+ let txt = blade.label;
374
+ if (nav.drill) txt += " › " + (DRILL_LABELS[nav.drill.key] || "");
375
+ $("#crumb").textContent = txt;
376
+ }
377
+
378
+ // ============================ screen-reader announcer ============================
379
+ let lastAnnounce = "";
380
+ function announce(msg) {
381
+ const el = $("#sr");
382
+ if (!el || !msg) return;
383
+ // Re-set even when identical so assistive tech re-reads (toggle a trailing marker).
384
+ el.textContent = msg === lastAnnounce ? msg + "​" : msg;
385
+ lastAnnounce = el.textContent;
386
+ }
387
+
388
+ // Concise description of the current focus, spoken by screen readers on every nav change.
389
+ function describeNav() {
390
+ if (capturing) return "Listening — press a keyboard key to assign, Delete to clear, or Escape to cancel.";
391
+ if (monitorMode) return "Live input monitor open. Observe the controller, then press Escape to exit.";
392
+ if (monitorArm) return `Start the live monitor? ${warnSel === 0 ? "Start monitoring" : "Cancel"}, option ${warnSel + 1} of 2. Up or Down to choose, Enter to confirm.`;
393
+ const blade = BLADES[nav.col];
394
+ if (nav.drill) {
395
+ const prof = activeProfile();
396
+ if (!prof) return `${blade.label}, ${DRILL_LABELS[nav.drill.key] || ""}. Connect a controller to edit.`;
397
+ const rows = drillRows(prof, nav.drill.key);
398
+ const r = rows[nav.drill.index];
399
+ if (!r) return `${blade.label}, ${DRILL_LABELS[nav.drill.key] || ""}`;
400
+ const disp = r.display(r.get());
401
+ return `${blade.label}, ${DRILL_LABELS[nav.drill.key] || ""}. ${r.label}: ${disp.name}. ${nav.drill.index + 1} of ${rows.length}. Left or Right to change, Backspace to go back.`;
402
+ }
403
+ const items = bladeItems(blade);
404
+ const it = items[nav.row];
405
+ const label = it ? it.label.replace(/\s+/g, " ").trim() : "";
406
+ return `${blade.label} section. ${label}. ${nav.row + 1} of ${items.length}.`;
407
+ }
408
+
409
+ // position the ribbon + item list to form the cross
410
+ function layout() {
411
+ const bladesEl = $("#blades");
412
+ const focused = bladesEl.children[nav.col];
413
+ if (!focused) return;
414
+ const crossX = window.innerWidth * 0.3;
415
+ const bx = focused.offsetLeft + focused.offsetWidth / 2;
416
+ bladesEl.style.transform = `translateX(${crossX - bx}px)`;
417
+
418
+ const items = $("#items");
419
+ const crossY = window.innerHeight * 0.38;
420
+ items.style.left = crossX + "px";
421
+ items.style.top = crossY + 150 + "px"; // list hangs below the blade
422
+ // keep the selected row visible without hiding earlier rows: only scroll once the list
423
+ // grows past a comfortable count
424
+ const selIdx = nav.drill ? nav.drill.index : nav.row;
425
+ const rowH = 42, visible = 9;
426
+ const scroll = Math.max(0, selIdx - (visible - 2)) * rowH;
427
+ items.style.transform = `translateY(${-scroll}px)`;
428
+ }
429
+
430
+ // ============================ actions ============================
431
+ function activate() {
432
+ const blade = BLADES[nav.col];
433
+ const items = bladeItems(blade);
434
+ const it = items[nav.row];
435
+ if (!it) return;
436
+ blip(660);
437
+ if (it.drill) {
438
+ if (!activeProfile()) { toast("Connect a controller to edit a profile — the Key Bridge works without one"); return; }
439
+ nav.drill = { key: it.key, index: 0 }; render(); return;
440
+ }
441
+ switch (it.action) {
442
+ case "selectCtrl": activeCtrl = it.ctrl; nav.col = 1; nav.row = 0; render(); toast("Controller " + (it.ctrl + 1)); break;
443
+ case "rename": startRename(); break;
444
+ case "save": saveProfileFor(BLADES[nav.col].kind === "profile" ? BLADES[nav.col].slot : lastProfileSlot); break;
445
+ case "saveAll": saveAll(); break;
446
+ case "reload": reloadFromDevice(); break;
447
+ case "monitor": armMonitor(); break;
448
+ case "connect": connectOnce(); break;
449
+ case "setActive": setActiveFor(BLADES[nav.col].slot); break;
450
+ case "export": exportProfile(); break;
451
+ case "copylink": copyShareLink(); break;
452
+ case "import": importProfile(); break;
453
+ case "applyPreset": applyPresetToCurrent(it.preset); break;
454
+ case "applyShared": applySharedToCurrent(); break;
455
+ case "capture": startCapture(it.cap); break;
456
+ case "cycleStick": cycleStickMode(); break;
457
+ case "bridgeReset": bridgeMap = defaultBridgeMap(); saveBridgeMap(); render(); toast("Bridge mapping reset to defaults"); break;
458
+ case "bridgeExport": downloadText("ps-access-bridge.json", toConfigJSON(bridgeMap)); toast("Exported bridge.json — run it with the bridge CLI", 4000); break;
459
+ case "bridgeCopyJson": copyText(toConfigJSON(bridgeMap)).then((ok) => toast(ok ? "Config JSON copied" : "Copy failed", 2500)); break;
460
+ case "bridgeCopyCmd": copyText(runCommand()).then((ok) => toast(ok ? "Run command copied" : "Copy failed", 2500)); break;
461
+ }
462
+ }
463
+
464
+ // ============================ Key Bridge editor ============================
465
+ // Start listening for a keyboard key to assign to a physical button or stick direction.
466
+ function startCapture(cap) {
467
+ capturing = cap;
468
+ render();
469
+ toast("Press a keyboard key to assign · Esc cancels · Delete clears", 6000);
470
+ }
471
+ function finishCapture(value) {
472
+ if (!capturing) return;
473
+ if (capturing.kind === "button") bridgeMap.buttons[capturing.idx] = value;
474
+ else bridgeMap.stick[capturing.dir] = value;
475
+ capturing = null;
476
+ saveBridgeMap();
477
+ render();
478
+ blip(720);
479
+ }
480
+ function cycleStickMode() {
481
+ const i = STICK_MODES.indexOf(bridgeMap.stick.mode);
482
+ bridgeMap.stick.mode = STICK_MODES[(i + 1) % STICK_MODES.length];
483
+ saveBridgeMap();
484
+ render();
485
+ toast(`Stick mode: ${bridgeMap.stick.mode}`, 1800);
486
+ }
487
+
488
+ // ============================ library / sharing ============================
489
+ function libProfile() {
490
+ return controllers[activeCtrl]?.profiles[lastProfileSlot] || null;
491
+ }
492
+ function libSlotName() { return `Profile ${lastProfileSlot + 1}`; }
493
+
494
+ function exportProfile() {
495
+ const p = libProfile();
496
+ if (!p) { toast("Connect a controller first"); return; }
497
+ const base = (p.name || libSlotName()).replace(/[^\w.-]+/g, "_");
498
+ downloadText(`${base}.ps-access.json`, toFileText(toPortable(p)));
499
+ toast(`Exported ${libSlotName()}`, 2500);
500
+ }
501
+
502
+ async function copyShareLink() {
503
+ const p = libProfile();
504
+ if (!p) { toast("Connect a controller first"); return; }
505
+ const url = shareURL(toPortable(p));
506
+ const ok = await copyText(url);
507
+ toast(ok ? "Share link copied to clipboard" : "Couldn't copy — link is now in the address bar", 3500);
508
+ if (!ok) { try { location.hash = url.split("#")[1]; } catch { /* ignore */ } }
509
+ }
510
+
511
+ function importProfile() {
512
+ const p = libProfile();
513
+ if (!p) { toast("Connect a controller first"); return; }
514
+ pickFile(".json,.txt", (text) => {
515
+ try {
516
+ applyPortable(p, fromFileText(text, lastProfileSlot));
517
+ render();
518
+ toast(`Imported into ${libSlotName()} — Save to write it to the controller`, 4000);
519
+ blip(720);
520
+ } catch (e) { toast("Import failed: " + (e.message || e), 4000); }
521
+ });
522
+ }
523
+
524
+ function applyPresetToCurrent(index) {
525
+ const p = libProfile();
526
+ if (!p) { toast("Connect a controller first"); return; }
527
+ const preset = PRESETS[index];
528
+ if (!preset) return;
529
+ applyPortable(p, preset.portable);
530
+ render();
531
+ toast(`Applied "${preset.name}" to ${libSlotName()} — Save to keep it`, 4000);
532
+ blip(720);
533
+ }
534
+
535
+ function applySharedToCurrent() {
536
+ const p = libProfile();
537
+ if (!p) { toast("Connect a controller first"); return; }
538
+ if (!pendingShare) return;
539
+ applyPortable(p, pendingShare);
540
+ pendingShare = null;
541
+ try { history.replaceState(null, "", location.pathname + location.search); } catch { /* ignore */ }
542
+ render();
543
+ toast(`Applied shared profile to ${libSlotName()} — Save to keep it`, 4000);
544
+ blip(720);
545
+ }
546
+
547
+ function downloadText(filename, text) {
548
+ const blob = new Blob([text], { type: "application/json" });
549
+ const url = URL.createObjectURL(blob);
550
+ const a = document.createElement("a");
551
+ a.href = url; a.download = filename;
552
+ document.body.append(a); a.click(); a.remove();
553
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
554
+ }
555
+
556
+ async function copyText(text) {
557
+ try { await navigator.clipboard.writeText(text); return true; } catch { return false; }
558
+ }
559
+
560
+ function pickFile(accept, onText) {
561
+ const input = document.createElement("input");
562
+ input.type = "file"; input.accept = accept;
563
+ input.onchange = () => {
564
+ const f = input.files?.[0];
565
+ if (!f) return;
566
+ const reader = new FileReader();
567
+ reader.onload = () => onText(String(reader.result));
568
+ reader.readAsText(f);
569
+ };
570
+ input.click();
571
+ }
572
+
573
+ // Switch the controller's active profile to this slot (like its profile button). The input
574
+ // report reflects the change within a frame, so the indicator/wave update on their own.
575
+ async function setActiveFor(slot) {
576
+ const c = controllers[activeCtrl];
577
+ if (!c) { toast("Connect a controller first"); return; }
578
+ try {
579
+ await ensureOpen(c.device);
580
+ await setActiveProfile(c.device, slot + 1);
581
+ toast(`Activated Profile ${slot + 1}`, 2000);
582
+ } catch (e) { toast("Couldn't switch profile: " + (e.message || e), 4000); }
583
+ }
584
+
585
+ function startRename() {
586
+ const blade = BLADES[nav.col];
587
+ if (blade.kind !== "profile") return;
588
+ const profile = controllers[activeCtrl].profiles[blade.slot];
589
+ renaming = true;
590
+ const input = document.createElement("input");
591
+ input.className = "rename-input";
592
+ input.value = profile.name || "";
593
+ input.maxLength = 40;
594
+ const sel = $("#items").querySelector(".item.sel");
595
+ sel.innerHTML = "";
596
+ sel.append(input);
597
+ input.focus();
598
+ const done = (commit) => { renaming = false; if (commit) profile.name = input.value; render(); };
599
+ input.addEventListener("keydown", (e) => {
600
+ e.stopPropagation();
601
+ if (e.key === "Enter") done(true);
602
+ else if (e.key === "Escape") done(false);
603
+ });
604
+ input.addEventListener("blur", () => done(true));
605
+ }
606
+
607
+ // ============================ device ============================
608
+ async function load() {
609
+ if (!hidSupported()) { $("#unsupported").classList.add("show"); return; }
610
+ const granted = await grantedControllers();
611
+ if (!granted.length) {
612
+ // Focus the Controllers blade so its "+ Connect a controller…" action is front and center.
613
+ nav.col = 0; nav.row = 0; render();
614
+ toast("No controller — choose “+ Connect a controller…”", 6000);
615
+ return;
616
+ }
617
+ await addDevices(granted);
618
+ }
619
+ async function connectOnce() {
620
+ try {
621
+ const before = controllers.length;
622
+ const ds = await requestControllers();
623
+ if (!ds.length) { toast("No controller selected", 2500); return; }
624
+ await addDevices(ds);
625
+ toast(controllers.length > before ? "Controller connected" : "Controller already connected", 2000);
626
+ } catch (e) { toast(String(e.message || e), 4000); }
627
+ }
628
+ async function addDevices(devices) {
629
+ for (const device of devices) {
630
+ if (controllers.some((c) => c.device === device)) continue;
631
+ await ensureOpen(device);
632
+ device.addEventListener("inputreport", onInputReport); // physical buttons + stick, live
633
+ const profiles = [];
634
+ for (let s = 1; s <= PROFILE_COUNT; s++) {
635
+ const p = parseProfile(await readProfileRaw(device, s));
636
+ p._physOrient = p.ports[0].kind === "stick" ? p.ports[0].orientation : 3;
637
+ profiles.push(p);
638
+ }
639
+ controllers.push({ device, name: `Controller ${controllers.length + 1}`, profiles });
640
+ }
641
+ updateDeviceStatus();
642
+ render();
643
+ }
644
+ function updateDeviceStatus() {
645
+ const c = controllers[activeCtrl];
646
+ $("#dev-name").textContent = c ? c.name : "No controller";
647
+ $("#dev-dot").style.background = c ? "var(--ok)" : "var(--dim)";
648
+ }
649
+
650
+ async function saveProfileFor(slot) {
651
+ const c = controllers[activeCtrl];
652
+ if (!c) return;
653
+ try {
654
+ toast("Saving…");
655
+ await ensureOpen(c.device);
656
+ await writeProfileRaw(c.device, slot + 1, buildProfile(c.profiles[slot], { now: Date.now() }));
657
+ const reread = parseProfile(await readProfileRaw(c.device, slot + 1));
658
+ reread._physOrient = reread.ports[0].kind === "stick" ? reread.ports[0].orientation : 3;
659
+ c.profiles[slot] = reread;
660
+ render();
661
+ toast(`Saved Profile ${slot + 1}`, 2500);
662
+ blip(880);
663
+ } catch (e) { toast("Save failed: " + (e.message || e), 4000); }
664
+ }
665
+ async function saveAll() {
666
+ for (let s = 0; s < PROFILE_COUNT; s++) await saveProfileFor(s);
667
+ toast("Saved all profiles", 2500);
668
+ }
669
+ async function reloadFromDevice() {
670
+ const c = controllers[activeCtrl];
671
+ if (!c) return;
672
+ await ensureOpen(c.device);
673
+ for (let s = 1; s <= PROFILE_COUNT; s++) {
674
+ const p = parseProfile(await readProfileRaw(c.device, s));
675
+ p._physOrient = p.ports[0].kind === "stick" ? p.ports[0].orientation : 3;
676
+ c.profiles[s - 1] = p;
677
+ }
678
+ render();
679
+ toast("Reloaded from controller", 2000);
680
+ }
681
+
682
+ // ============================ live input monitor ============================
683
+ // Full-screen overlay. The controller is purely observed here (navigation is suspended), so
684
+ // every physical button and the stick can be tested freely; exit with Esc or the Done button.
685
+ function buildMonChips() {
686
+ $("#mon-chips").innerHTML = PHYS_NAMES.map((n, i) =>
687
+ `<div class="chip" data-i="${i}">${i < 8 ? n : n.split("-")[0]}<small>${i < 8 ? "button" : (i === 8 ? "center" : "L3")}</small></div>`).join("");
688
+ }
689
+ function buildMonRaw() {
690
+ let h = "";
691
+ for (let i = 0; i < 63; i++) h += `<div class="b${i === 15 || i === 16 ? " btn" : ""}" data-i="${i}">00</div>`;
692
+ $("#mon-raw").innerHTML = h;
693
+ }
694
+ // Step 1: a PS3-style confirm gate warning that the controller can't exit this view (Esc / Done
695
+ // only). A navigable two-option list — Start / Cancel — operable by keyboard (↑↓ + Enter / Esc),
696
+ // controller (stick = move, confirm = pick, any perimeter = cancel), or mouse.
697
+ function renderWarnSel() {
698
+ for (const o of document.querySelectorAll("#mon-warn .warn-opt")) o.classList.toggle("sel", +o.dataset.i === warnSel);
699
+ }
700
+ function armMonitor() {
701
+ if (!controllers[activeCtrl]) { toast("Connect a controller first"); return; }
702
+ monitorArm = true;
703
+ warnSel = 0; renderWarnSel();
704
+ inputEdge.confirm = true; inputEdge.back = true; inputEdge.armDir = true; // swallow the opening press
705
+ $("#mon-warn").classList.add("show");
706
+ $("#stage").style.display = "none"; $(".footer").style.display = "none"; // clear backdrop -> wave ribbon shows
707
+ }
708
+ function cancelArm() {
709
+ if (!monitorArm) return;
710
+ monitorArm = false;
711
+ $("#mon-warn").classList.remove("show");
712
+ $("#stage").style.display = ""; $(".footer").style.display = "";
713
+ blip(330);
714
+ }
715
+ function confirmArm() {
716
+ if (!monitorArm) return;
717
+ monitorArm = false;
718
+ $("#mon-warn").classList.remove("show");
719
+ enterMonitor(); // keeps the stage/footer hidden, shows the monitor
720
+ }
721
+ // Activate whichever option is highlighted (used by keyboard Enter and controller confirm).
722
+ function pickArm() { warnSel === 0 ? confirmArm() : cancelArm(); }
723
+ // Step 2: enter the live monitor, rendering the *chosen* profile (so the controller image
724
+ // matches that profile's orientation) and showing which profile is on screen.
725
+ function enterMonitor() {
726
+ if (!controllers[activeCtrl]) { toast("Connect a controller first"); return; }
727
+ const prof = controllers[activeCtrl].profiles[shownProfileSlot()]; // the active on-device profile
728
+ monitorMode = true;
729
+ $("#mon-render").innerHTML = profileSVG(prof); // profileSVG bakes in the orientation
730
+ updateProfileTag(); // top-bar already shows it, keep in sync
731
+ if (!$("#mon-chips").children.length) buildMonChips();
732
+ if (!$("#mon-raw").children.length) buildMonRaw();
733
+ $("#monitor").classList.add("show");
734
+ $("#stage").style.display = "none";
735
+ $(".footer").style.display = "none"; // its nav hints don't apply while observing
736
+ }
737
+ function exitMonitor() {
738
+ if (!monitorMode) return;
739
+ monitorMode = false;
740
+ $("#monitor").classList.remove("show");
741
+ $("#stage").style.display = "";
742
+ $(".footer").style.display = "";
743
+ blip(330);
744
+ render();
745
+ }
746
+ function updateMonitor(buttons, axes, d) {
747
+ for (const el of document.querySelectorAll("#mon-render svg [data-btn]"))
748
+ el.classList.toggle("on", buttons.has(+el.getAttribute("data-btn")));
749
+ const thumb = $("#mon-render svg .thumb");
750
+ if (thumb) {
751
+ thumb.setAttribute("cx", (+thumb.dataset.bx + axes[0] * M.THUMB_R).toFixed(1));
752
+ thumb.setAttribute("cy", (+thumb.dataset.by + axes[1] * M.THUMB_R).toFixed(1));
753
+ }
754
+ for (const c of $("#mon-chips").children) c.classList.toggle("on", buttons.has(+c.dataset.i));
755
+ $("#mon-stickdot").style.left = (50 + axes[0] * 38) + "%";
756
+ $("#mon-stickdot").style.top = (50 + axes[1] * 38) + "%";
757
+ $("#mon-ax").textContent = axes[0].toFixed(2);
758
+ $("#mon-ay").textContent = axes[1].toFixed(2);
759
+ const cells = $("#mon-raw").children;
760
+ for (let i = 0; i < d.length && i < cells.length; i++) {
761
+ cells[i].textContent = d[i].toString(16).padStart(2, "0");
762
+ cells[i].classList.toggle("nz", d[i] !== 0);
763
+ }
764
+ }
765
+
766
+ // ============================ input ============================
767
+ function move(dx, dy) {
768
+ if (renaming) return;
769
+ if (nav.drill) {
770
+ const profile = activeProfile();
771
+ const rows = drillRows(profile, nav.drill.key);
772
+ if (dy) { nav.drill.index = clamp(nav.drill.index + dy, 0, rows.length - 1); blip(440); render(); }
773
+ if (dx) { // spinner: cycle the focused row's value
774
+ const r = rows[nav.drill.index];
775
+ const cur = r.values.indexOf(r.get());
776
+ const next = r.values[(cur + dx + r.values.length) % r.values.length];
777
+ r.set(next); blip(560); render();
778
+ }
779
+ return;
780
+ }
781
+ if (dx) {
782
+ nav.col = clamp(nav.col + dx, 0, BLADES.length - 1);
783
+ nav.row = 0; blip(520); render();
784
+ }
785
+ if (dy) {
786
+ const items = bladeItems(BLADES[nav.col]);
787
+ nav.row = clamp(nav.row + dy, 0, items.length - 1); blip(440); render();
788
+ }
789
+ }
790
+ function back() {
791
+ if (renaming) return;
792
+ if (nav.drill) { nav.drill = null; blip(330); render(); }
793
+ }
794
+ const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
795
+
796
+ window.addEventListener("keydown", (e) => {
797
+ if (capturing) {
798
+ e.preventDefault();
799
+ if (e.key === "Escape") { capturing = null; render(); toast("Cancelled", 1200); return; }
800
+ if (e.key === "Delete") { finishCapture("nothing"); return; }
801
+ const v = keyEventToValue(e);
802
+ if (v) finishCapture(v); // null = a modifier on its own; keep listening
803
+ return;
804
+ }
805
+ if (helpOpen) { if (e.key === "Escape" || e.key === "?" || e.key === "Backspace") { closeHelp(); e.preventDefault(); } return; } // Enter falls through to activate the focused button
806
+ if (e.key === "?" || ((e.key === "h" || e.key === "H") && !renaming)) { openHelp(); e.preventDefault(); return; }
807
+ if (monitorArm) {
808
+ if (e.key === "ArrowUp" || e.key === "ArrowDown") { warnSel = warnSel ? 0 : 1; renderWarnSel(); blip(440); announce(describeNav()); e.preventDefault(); }
809
+ else if (e.key === "Enter") { pickArm(); e.preventDefault(); }
810
+ else if (e.key === "Escape" || e.key === "Backspace") { cancelArm(); e.preventDefault(); }
811
+ return;
812
+ }
813
+ if (monitorMode) { if (e.key === "Escape" || e.key === "Backspace") { exitMonitor(); e.preventDefault(); } return; }
814
+ if (renaming) return;
815
+ const k = e.key;
816
+ if (k === "ArrowLeft") { move(-1, 0); e.preventDefault(); }
817
+ else if (k === "ArrowRight") { move(1, 0); e.preventDefault(); }
818
+ else if (k === "ArrowUp") { move(0, -1); e.preventDefault(); }
819
+ else if (k === "ArrowDown") { move(0, 1); e.preventDefault(); }
820
+ else if (k === "Enter") { if (!nav.drill) activate(); }
821
+ else if (k === "Backspace" || k === "Escape") { back(); e.preventDefault(); }
822
+ else if (k === "m" || k === "M") { soundOn = !soundOn; toast("Sound " + (soundOn ? "on" : "off"), 1200); }
823
+ });
824
+
825
+ // ============================ help dialog ============================
826
+ let helpOpen = false;
827
+ let helpReturnFocus = null;
828
+ function openHelp() {
829
+ helpOpen = true;
830
+ helpReturnFocus = document.activeElement;
831
+ const dlg = $("#help");
832
+ dlg.classList.add("show");
833
+ dlg.setAttribute("aria-hidden", "false");
834
+ updateFocusBtn();
835
+ $("#help-close")?.focus();
836
+ blip(660);
837
+ }
838
+
839
+ // ---- high-visibility focus ring (opt-in, off by default, persisted) ----
840
+ function focusRingOn() { return document.body.classList.contains("hi-focus"); }
841
+ function updateFocusBtn() {
842
+ const b = $("#help-focus");
843
+ if (!b) return;
844
+ const on = focusRingOn();
845
+ b.textContent = `High-visibility focus ring: ${on ? "On" : "Off"}`;
846
+ b.setAttribute("aria-pressed", String(on));
847
+ }
848
+ function toggleFocusRing() {
849
+ const on = document.body.classList.toggle("hi-focus");
850
+ try { localStorage.setItem("psaccess.hiFocus", on ? "1" : "0"); } catch { /* ignore */ }
851
+ updateFocusBtn();
852
+ toast(`High-visibility focus ring ${on ? "on" : "off"}`, 1500);
853
+ announce(`Focus ring ${on ? "on" : "off"}`);
854
+ }
855
+ function closeHelp() {
856
+ helpOpen = false;
857
+ const dlg = $("#help");
858
+ dlg.classList.remove("show");
859
+ dlg.setAttribute("aria-hidden", "true");
860
+ try { helpReturnFocus?.focus?.(); } catch { /* ignore */ }
861
+ blip(330);
862
+ }
863
+
864
+ // ---- physical input via the raw HID input report ----
865
+ // Physical buttons: byte 15 bits 0-7 = perimeter 1-8; byte 16 bit 0 = center (9), bit 1 = stick-click (10).
866
+ // Nav scheme (per design): center/stick-click = confirm; any perimeter button = back; stick = directions.
867
+ const inputEdge = {};
868
+ let dirRepeatAt = 0;
869
+ function onInputReport(e) {
870
+ if (e.device !== controllers[activeCtrl]?.device) return;
871
+ const d = new Uint8Array(e.data.buffer.slice(e.data.byteOffset, e.data.byteOffset + e.data.byteLength));
872
+ lastInputAt = performance.now();
873
+ waveConnected = true; // a report is streaming -> the wave is visible
874
+ const { buttons, axes, profile } = decodePhysical(d);
875
+ liveAxes = axes;
876
+ phys = buttons;
877
+ // Track the controller's *active* profile (changed with the device's profile button). When it
878
+ // changes, refresh the top-bar indicator and, if the monitor is open, re-render it for that profile.
879
+ if (profile && profile - 1 !== deviceProfile) {
880
+ deviceProfile = profile - 1;
881
+ updateProfileTag();
882
+ setWaveProfile(deviceProfile); // fade the wave's leading curves to match the active profile
883
+ if (monitorMode) $("#mon-render").innerHTML = profileSVG(controllers[activeCtrl].profiles[deviceProfile]);
884
+ else if (!monitorArm && !nav.drill) render(); // refresh the "✓ Active on controller" marker
885
+ }
886
+ if (monitorMode) { updateMonitor(buttons, axes, d); setGpStatus(true); return; }
887
+ if (monitorArm) { handleArmInput(buttons, axes); setGpStatus(true); return; }
888
+ handlePhysInput(buttons, axes);
889
+ updateLive();
890
+ setGpStatus(true);
891
+ }
892
+
893
+ function handlePhysInput(buttons, axes) {
894
+ if (renaming || capturing) return; // suspend controller nav while binding a key
895
+ const now = lastInputAt;
896
+ const dir = { left: axes[0] < -0.5, right: axes[0] > 0.5, up: axes[1] < -0.5, down: axes[1] > 0.5 };
897
+ const heldDir = dir.left || dir.right || dir.up || dir.down;
898
+ const fire = (k) => { if (k === "left") move(-1, 0); else if (k === "right") move(1, 0); else if (k === "up") move(0, -1); else move(0, 1); };
899
+ let edged = false;
900
+ for (const k of ["left", "right", "up", "down"]) {
901
+ if (dir[k] && !inputEdge[k]) { fire(k); dirRepeatAt = now + 380; edged = true; }
902
+ inputEdge[k] = dir[k];
903
+ }
904
+ if (!edged && heldDir && now >= dirRepeatAt) {
905
+ fire(dir.left ? "left" : dir.right ? "right" : dir.up ? "up" : "down");
906
+ dirRepeatAt = now + 130;
907
+ }
908
+ const confirm = buttons.has(8) || buttons.has(9); // center or stick-click
909
+ const wantBack = [0, 1, 2, 3, 4, 5, 6, 7].some((i) => buttons.has(i)); // any perimeter
910
+ if (confirm && !inputEdge.confirm && !nav.drill) activate();
911
+ inputEdge.confirm = confirm;
912
+ if (wantBack && !inputEdge.back) back();
913
+ inputEdge.back = wantBack;
914
+ }
915
+
916
+ // On the gate: stick up/down moves the highlight, confirm (center/stick-click) picks it,
917
+ // any perimeter button cancels outright.
918
+ function handleArmInput(buttons, axes) {
919
+ const dir = axes[1] < -0.5 ? -1 : axes[1] > 0.5 ? 1 : 0; // up/down on the list
920
+ if (dir && !inputEdge.armDir) { warnSel = warnSel ? 0 : 1; renderWarnSel(); blip(440); }
921
+ inputEdge.armDir = dir !== 0;
922
+ const confirm = buttons.has(8) || buttons.has(9);
923
+ const wantBack = [0, 1, 2, 3, 4, 5, 6, 7].some((i) => buttons.has(i));
924
+ if (confirm && !inputEdge.confirm) pickArm();
925
+ inputEdge.confirm = confirm;
926
+ if (wantBack && !inputEdge.back) cancelArm();
927
+ inputEdge.back = wantBack;
928
+ }
929
+
930
+ function setGpStatus(on) {
931
+ const gs = $("#gp-status");
932
+ if (gs) { gs.textContent = on ? "controller: connected" : "controller: not detected"; gs.classList.toggle("on", on); }
933
+ }
934
+
935
+ // ============================ sound ============================
936
+ let actx = null;
937
+ function blip(freq) {
938
+ if (!soundOn) return;
939
+ try {
940
+ actx = actx || new (window.AudioContext || window.webkitAudioContext)();
941
+ const o = actx.createOscillator(), g = actx.createGain();
942
+ o.type = "sine"; o.frequency.value = freq;
943
+ g.gain.setValueAtTime(0.0001, actx.currentTime);
944
+ g.gain.exponentialRampToValueAtTime(0.05, actx.currentTime + 0.01);
945
+ g.gain.exponentialRampToValueAtTime(0.0001, actx.currentTime + 0.12);
946
+ o.connect(g).connect(actx.destination); o.start(); o.stop(actx.currentTime + 0.13);
947
+ } catch { /* ignore */ }
948
+ }
949
+
950
+ // ============================ toast + clock ============================
951
+ let toastT = 0;
952
+ function toast(msg, ms = 3000) {
953
+ const t = $("#toast"); t.textContent = msg; t.classList.add("show");
954
+ clearTimeout(toastT); toastT = setTimeout(() => t.classList.remove("show"), ms);
955
+ }
956
+ function tickClock() {
957
+ const d = new Date();
958
+ $("#clock").textContent = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
959
+ }
960
+
961
+ // ============================ wave background ============================
962
+ // Keep the original blue palette; vary only per-curve transparency by the active profile.
963
+ // A curve fades when its index is below the active profile slot, so the active profile's curve
964
+ // (and the ones after it) stay at full opacity:
965
+ // profile 1 -> all original; profile 2 -> curve 1 faded; profile 3 -> curves 1 & 2 faded.
966
+ // When no controller is streaming, every curve fades fully out (transparent).
967
+ let waveConnected = false; // false -> all curves transparent
968
+ let waveSlot = 0; // active profile slot (0-2) driving the fade pattern
969
+ const WAVE_FADED = 0.25; // alpha multiplier for the "more transparent" leading curves
970
+ const bandLevel = [0, 0, 0]; // eased per-curve alpha multiplier (0 = transparent, 1 = original)
971
+ function setWaveProfile(slot) { waveSlot = (slot >= 0 && slot <= 2) ? slot : 0; }
972
+
973
+ function startWave() {
974
+ // Honor "reduce motion": skip the animated background entirely (CSS also hides #wave).
975
+ if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
976
+ window.addEventListener("resize", layout);
977
+ return;
978
+ }
979
+ const cv = $("#wave"), ctx = cv.getContext("2d");
980
+ let w, h;
981
+ const resize = () => { w = cv.width = innerWidth * devicePixelRatio; h = cv.height = innerHeight * devicePixelRatio; };
982
+ resize(); window.addEventListener("resize", () => { resize(); layout(); });
983
+ let t = 0;
984
+ const bands = [
985
+ { amp: 0.10, len: 0.9, sp: 0.6, y: 0.42, hue: 215, a: 0.20 },
986
+ { amp: 0.07, len: 1.4, sp: -0.4, y: 0.55, hue: 200, a: 0.16 },
987
+ { amp: 0.13, len: 0.7, sp: 0.9, y: 0.66, hue: 230, a: 0.13 },
988
+ ];
989
+ const draw = () => {
990
+ t += 0.005;
991
+ ctx.clearRect(0, 0, w, h);
992
+ const hueShift = 18 * Math.sin(t * 0.05);
993
+ bands.forEach((b, i) => {
994
+ const target = !waveConnected ? 0 : (i < waveSlot ? WAVE_FADED : 1); // fade leading curves
995
+ bandLevel[i] += (target - bandLevel[i]) * 0.06; // ease the change
996
+ const lvl = bandLevel[i];
997
+ ctx.beginPath();
998
+ ctx.moveTo(0, h);
999
+ for (let x = 0; x <= w; x += 16 * devicePixelRatio) {
1000
+ const y = h * b.y + Math.sin(x / w * Math.PI * 2 * b.len + t * b.sp) * h * b.amp
1001
+ + Math.sin(x / w * Math.PI * 5 * b.len - t * b.sp * 1.7) * h * b.amp * 0.3;
1002
+ ctx.lineTo(x, y);
1003
+ }
1004
+ ctx.lineTo(w, h); ctx.closePath();
1005
+ const g = ctx.createLinearGradient(0, h * b.y - h * 0.2, 0, h);
1006
+ g.addColorStop(0, `hsla(${b.hue + hueShift},70%,55%,${(b.a * lvl).toFixed(3)})`); // original color, eased alpha
1007
+ g.addColorStop(1, `hsla(${b.hue + hueShift},70%,30%,0)`);
1008
+ ctx.fillStyle = g; ctx.fill();
1009
+ });
1010
+ requestAnimationFrame(draw);
1011
+ };
1012
+ draw();
1013
+ }
1014
+
1015
+ // ============================ init ============================
1016
+ function init() {
1017
+ // Register the (network-first) service worker for offline use + installability.
1018
+ if ("serviceWorker" in navigator) {
1019
+ navigator.serviceWorker.register("./sw.js").catch(() => { /* non-fatal */ });
1020
+ }
1021
+ // Restore the opt-in focus-ring preference (default off).
1022
+ try { if (localStorage.getItem("psaccess.hiFocus") === "1") document.body.classList.add("hi-focus"); } catch { /* ignore */ }
1023
+ startWave();
1024
+ tickClock(); setInterval(tickClock, 15000);
1025
+ // mark the controller disconnected if no input report has arrived recently (also fades the wave out)
1026
+ setInterval(() => { if (performance.now() - lastInputAt > 1500) { setGpStatus(false); waveConnected = false; } }, 800);
1027
+ if (navigator.hid) {
1028
+ // Auto-recover on unplug/replug without a page refresh.
1029
+ navigator.hid.addEventListener("disconnect", (e) => {
1030
+ waveConnected = false;
1031
+ const idx = controllers.findIndex((c) => c.device === e.device);
1032
+ if (idx !== -1) {
1033
+ e.device.removeEventListener("inputreport", onInputReport);
1034
+ controllers.splice(idx, 1);
1035
+ if (activeCtrl >= controllers.length) activeCtrl = Math.max(0, controllers.length - 1);
1036
+ deviceProfile = null;
1037
+ nav.drill = null;
1038
+ }
1039
+ updateDeviceStatus(); render();
1040
+ });
1041
+ let reconnecting = false;
1042
+ navigator.hid.addEventListener("connect", async () => {
1043
+ if (reconnecting) return;
1044
+ reconnecting = true;
1045
+ try {
1046
+ const before = controllers.length;
1047
+ for (let attempt = 0; attempt < 3; attempt++) { // device may need a moment to expose its USB collection
1048
+ try { await addDevices(await grantedControllers()); break; }
1049
+ catch (err) { if (attempt === 2) throw err; await new Promise((r) => setTimeout(r, 300)); }
1050
+ }
1051
+ if (controllers.length > before) toast("Controller reconnected", 1800);
1052
+ } catch (err) { toast("Reconnect failed: " + (err.message || err), 3500); }
1053
+ finally { reconnecting = false; }
1054
+ });
1055
+ }
1056
+ $("#mon-done").onclick = exitMonitor;
1057
+ $("#warn-start").onclick = confirmArm;
1058
+ $("#warn-cancel").onclick = cancelArm;
1059
+ $("#help-close").onclick = closeHelp;
1060
+ $("#help-focus").onclick = toggleFocusRing;
1061
+ $("#help").addEventListener("click", (e) => { if (e.target.id === "help") closeHelp(); });
1062
+ try {
1063
+ pendingShare = parseShareHash(location.hash);
1064
+ if (pendingShare) toast(`Shared profile "${pendingShare.name || "(unnamed)"}" detected — open Library ▸ to apply`, 6000);
1065
+ } catch { /* ignore bad hash */ }
1066
+ render();
1067
+ load();
1068
+ }
1069
+ init();