@manybitsbyte/nesplayer-svelte 0.8.0

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.
@@ -0,0 +1,709 @@
1
+ <script lang="ts">
2
+ type GamepadInfo = { id: string; index: number; name: string };
3
+ type GamepadProfile = { id: string; buttonMap: Record<number, number>; quickSaveBtn?: number | null; quickLoadBtn?: number | null };
4
+ type PlayerInput = { type: 'keyboard' } | { type: 'gamepad'; id: string } | { type: 'none' };
5
+
6
+ const NES_BUTTONS: Array<{ label: string; bit: number }> = [
7
+ { label: 'Up', bit: 0x10 },
8
+ { label: 'Down', bit: 0x20 },
9
+ { label: 'Left', bit: 0x40 },
10
+ { label: 'Right', bit: 0x80 },
11
+ { label: 'Select', bit: 0x04 },
12
+ { label: 'Start', bit: 0x08 },
13
+ { label: 'B', bit: 0x02 },
14
+ { label: 'A', bit: 0x01 },
15
+ ];
16
+
17
+ const GP_BTN_NAMES: Record<number, string> = {
18
+ 0: 'A', 1: 'B', 2: 'X', 3: 'Y',
19
+ 4: 'LB', 5: 'RB', 6: 'LT', 7: 'RT',
20
+ 8: 'Back', 9: 'Start', 10: 'L3', 11: 'R3',
21
+ 12: '↑', 13: '↓', 14: '←', 15: '→',
22
+ };
23
+
24
+ function keyLabel(code: string): string {
25
+ const named: Record<string, string> = {
26
+ ArrowUp: '↑', ArrowDown: '↓', ArrowLeft: '←', ArrowRight: '→',
27
+ ShiftLeft: 'L.Shift', ShiftRight: 'R.Shift',
28
+ ControlLeft: 'L.Ctrl', ControlRight: 'R.Ctrl',
29
+ AltLeft: 'L.Alt', AltRight: 'R.Alt',
30
+ Enter: 'Enter', Space: 'Space', Escape: 'Esc',
31
+ Tab: 'Tab', Backspace: '⌫', Delete: 'Del',
32
+ CapsLock: 'Caps', MetaLeft: 'L.Meta', MetaRight: 'R.Meta',
33
+ };
34
+ if (named[code]) { return named[code]; }
35
+ if (code.startsWith('Key')) { return code.slice(3); }
36
+ if (code.startsWith('Digit')) { return code.slice(5); }
37
+ if (code.startsWith('Numpad')) { return 'Num' + code.slice(6); }
38
+ return code;
39
+ }
40
+
41
+ function gpBtnLabel(idx: number): string {
42
+ return GP_BTN_NAMES[idx] ?? `Btn ${idx}`;
43
+ }
44
+
45
+ function keyForBit(map: Record<string, number>, bit: number): string {
46
+ for (const [code, b] of Object.entries(map)) {
47
+ if (b === bit) { return code; }
48
+ }
49
+ return '';
50
+ }
51
+
52
+ function gpIdxForBit(map: Record<number, number>, bit: number): number | null {
53
+ for (const [idx, b] of Object.entries(map)) {
54
+ if (b === bit) { return Number(idx); }
55
+ }
56
+ return null;
57
+ }
58
+
59
+ interface Props {
60
+ defaultKeyMap: Record<string, number>;
61
+ defaultGamepadButtonMap: Record<number, number>;
62
+ detectedGamepads: GamepadInfo[];
63
+ gamepadProfiles: GamepadProfile[];
64
+ player1Input: PlayerInput;
65
+ player2Input: PlayerInput;
66
+ player1KeyMap: Record<string, number>;
67
+ player2KeyMap: Record<string, number>;
68
+ quickSaveKey: string;
69
+ quickLoadKey: string;
70
+ onquicksavekeychange: (key: string) => void;
71
+ onquickloadkeychange: (key: string) => void;
72
+ onplayer1inputchange: (v: PlayerInput) => void;
73
+ onplayer2inputchange: (v: PlayerInput) => void;
74
+ onplayer1keymapchange: (m: Record<string, number>) => void;
75
+ onplayer2keymapchange: (m: Record<string, number>) => void;
76
+ ongpprofilechange: (p: GamepadProfile) => void;
77
+ onclose: () => void;
78
+ }
79
+
80
+ let {
81
+ defaultKeyMap, defaultGamepadButtonMap,
82
+ detectedGamepads, gamepadProfiles,
83
+ player1Input, player2Input,
84
+ player1KeyMap, player2KeyMap,
85
+ quickSaveKey, quickLoadKey,
86
+ onquicksavekeychange, onquickloadkeychange,
87
+ onplayer1inputchange, onplayer2inputchange,
88
+ onplayer1keymapchange, onplayer2keymapchange,
89
+ ongpprofilechange, onclose,
90
+ }: Props = $props();
91
+
92
+ let kbRebind = $state<{ player: 1 | 2; bit: number } | null>(null);
93
+ let gpRebind = $state<{ padId: string; bit: number } | null>(null);
94
+ let stateKeyRebind = $state<'save' | 'load' | null>(null);
95
+ let stateGpRebind = $state<{ padId: string; which: 'save' | 'load' } | null>(null);
96
+
97
+ function activeProfile(padId: string): GamepadProfile {
98
+ return gamepadProfiles.find(p => p.id === padId) ?? { id: padId, buttonMap: { ...defaultGamepadButtonMap } };
99
+ }
100
+
101
+ // Assign an input to a player. If a gamepad is being assigned, remove it from the other player first.
102
+ function assignP1(input: PlayerInput) {
103
+ if (input.type === 'gamepad' && player2Input.type === 'gamepad' && player2Input.id === input.id) {
104
+ onplayer2inputchange({ type: 'none' });
105
+ }
106
+ onplayer1inputchange(input);
107
+ }
108
+
109
+ function assignP2(input: PlayerInput) {
110
+ if (input.type === 'gamepad' && player1Input.type === 'gamepad' && player1Input.id === input.id) {
111
+ onplayer1inputchange({ type: 'keyboard' });
112
+ }
113
+ onplayer2inputchange(input);
114
+ }
115
+
116
+ // Keyboard rebind effect
117
+ $effect(() => {
118
+ if (!kbRebind) { return; }
119
+ const { player, bit } = kbRebind;
120
+ const handler = (e: KeyboardEvent) => {
121
+ e.preventDefault();
122
+ e.stopImmediatePropagation();
123
+ if (e.code === 'Escape') { kbRebind = null; return; }
124
+ const src = player === 1 ? player1KeyMap : player2KeyMap;
125
+ const emit = player === 1 ? onplayer1keymapchange : onplayer2keymapchange;
126
+ const next: Record<string, number> = {};
127
+ for (const [code, b] of Object.entries(src)) {
128
+ if (b !== bit) { next[code] = b; }
129
+ }
130
+ next[e.code] = bit;
131
+ emit(next);
132
+ kbRebind = null;
133
+ };
134
+ window.addEventListener('keydown', handler, { capture: true });
135
+ return () => { window.removeEventListener('keydown', handler, { capture: true }); };
136
+ });
137
+
138
+ $effect(() => {
139
+ if (!stateKeyRebind) { return; }
140
+ const which = stateKeyRebind;
141
+ const handler = (e: KeyboardEvent) => {
142
+ e.preventDefault();
143
+ e.stopImmediatePropagation();
144
+ if (e.code === 'Escape') { stateKeyRebind = null; return; }
145
+ if (which === 'save') { onquicksavekeychange(e.code); }
146
+ else { onquickloadkeychange(e.code); }
147
+ stateKeyRebind = null;
148
+ };
149
+ window.addEventListener('keydown', handler, { capture: true });
150
+ return () => { window.removeEventListener('keydown', handler, { capture: true }); };
151
+ });
152
+
153
+ $effect(() => {
154
+ if (!gpRebind) { return; }
155
+ const { padId, bit } = gpRebind;
156
+
157
+ const initialGp = [...navigator.getGamepads()].find(p => p?.id === padId && p?.connected);
158
+ const initial = new Set<number>();
159
+ if (initialGp) {
160
+ for (let i = 0; i < initialGp.buttons.length; i++) {
161
+ if (initialGp.buttons[i]?.pressed) { initial.add(i); }
162
+ }
163
+ }
164
+ let released = initial.size === 0;
165
+
166
+ function poll() {
167
+ const gp = [...navigator.getGamepads()].find(p => p?.id === padId && p?.connected) ?? null;
168
+ if (!gp) { return; }
169
+
170
+ if (!released) {
171
+ released = ![...initial].some(i => gp.buttons[i]?.pressed);
172
+ return;
173
+ }
174
+
175
+ for (let i = 0; i < gp.buttons.length; i++) {
176
+ if (gp.buttons[i]?.pressed) {
177
+ const curProfile = activeProfile(padId);
178
+ const next: Record<number, number> = {};
179
+ for (const [k, v] of Object.entries(curProfile.buttonMap)) {
180
+ if (v !== bit) { next[Number(k)] = v; }
181
+ }
182
+ next[i] = bit;
183
+ ongpprofilechange({ ...curProfile, buttonMap: next });
184
+ gpRebind = null;
185
+ return;
186
+ }
187
+ }
188
+ }
189
+
190
+ const intervalId = setInterval(poll, 16);
191
+ return () => { clearInterval(intervalId); };
192
+ });
193
+
194
+ $effect(() => {
195
+ if (!stateGpRebind) { return; }
196
+ const { padId, which } = stateGpRebind;
197
+
198
+ const initialGp = [...navigator.getGamepads()].find(p => p?.id === padId && p?.connected);
199
+ const initial = new Set<number>();
200
+ if (initialGp) {
201
+ for (let i = 0; i < initialGp.buttons.length; i++) {
202
+ if (initialGp.buttons[i]?.pressed) { initial.add(i); }
203
+ }
204
+ }
205
+ let released = initial.size === 0;
206
+
207
+ function poll() {
208
+ const gp = [...navigator.getGamepads()].find(p => p?.id === padId && p?.connected) ?? null;
209
+ if (!gp) { return; }
210
+ if (!released) {
211
+ released = ![...initial].some(i => gp.buttons[i]?.pressed);
212
+ return;
213
+ }
214
+ for (let i = 0; i < gp.buttons.length; i++) {
215
+ if (gp.buttons[i]?.pressed) {
216
+ const cur = activeProfile(padId);
217
+ if (which === 'save') {
218
+ ongpprofilechange({ ...cur, quickSaveBtn: i });
219
+ } else {
220
+ ongpprofilechange({ ...cur, quickLoadBtn: i });
221
+ }
222
+ stateGpRebind = null;
223
+ return;
224
+ }
225
+ }
226
+ }
227
+
228
+ const intervalId = setInterval(poll, 16);
229
+ return () => { clearInterval(intervalId); };
230
+ });
231
+ </script>
232
+
233
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
234
+ <div class="cp-backdrop" role="presentation" onclick={onclose}></div>
235
+
236
+ <div
237
+ class="cp-panel"
238
+ role="dialog"
239
+ aria-label="Controller settings"
240
+ aria-modal="true"
241
+ onclick={(e) => e.stopPropagation()}
242
+ ondblclick={(e) => e.stopPropagation()}
243
+ >
244
+ <div class="cp-header">
245
+ <span class="cp-title">Controllers</span>
246
+ <button class="cp-close" onclick={onclose} title="Close" aria-label="Close">
247
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
248
+ <line x1="18" y1="6" x2="6" y2="18"/>
249
+ <line x1="6" y1="6" x2="18" y2="18"/>
250
+ </svg>
251
+ </button>
252
+ </div>
253
+
254
+ <div class="cp-art">
255
+ <svg viewBox="0 0 260 160" xmlns="http://www.w3.org/2000/svg" class="cp-svg" aria-hidden="true">
256
+ <defs>
257
+ <linearGradient id="cpWireGrad" gradientUnits="userSpaceOnUse" x1="0" y1="36" x2="0" y2="-10">
258
+ <stop offset="0%" stop-color="#888" stop-opacity="1"/>
259
+ <stop offset="100%" stop-color="#888" stop-opacity="0"/>
260
+ </linearGradient>
261
+ <linearGradient id="cpBodyGrad" x1="0" y1="0" x2="0" y2="1">
262
+ <stop offset="0%" stop-color="#2c2c2c"/>
263
+ <stop offset="100%" stop-color="#191919"/>
264
+ </linearGradient>
265
+ <linearGradient id="cpDpadGrad" x1="0" y1="0" x2="0" y2="1">
266
+ <stop offset="0%" stop-color="#4a4a4a"/>
267
+ <stop offset="100%" stop-color="#2e2e2e"/>
268
+ </linearGradient>
269
+ <radialGradient id="cpBtnRed" cx="38%" cy="32%" r="62%">
270
+ <stop offset="0%" stop-color="#f04040"/>
271
+ <stop offset="100%" stop-color="#8b0f0f"/>
272
+ </radialGradient>
273
+ <filter id="cpShadow">
274
+ <feDropShadow dx="0" dy="2" stdDeviation="2.5" flood-opacity="0.45"/>
275
+ </filter>
276
+ </defs>
277
+ <path d="M124,36 C112,24 144,12 130,2 C116,-8 144,-14 132,-22" stroke="url(#cpWireGrad)" stroke-width="5.5" fill="none" stroke-linecap="round"/>
278
+ <path d="M130,36 C142,26 112,15 126,5 C140,-5 110,-12 122,-20" stroke="url(#cpWireGrad)" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.4"/>
279
+ <rect x="10" y="38" width="240" height="114" rx="21" fill="rgba(0,0,0,0.4)" transform="translate(2,5)"/>
280
+ <rect x="10" y="38" width="240" height="114" rx="21" fill="url(#cpBodyGrad)" stroke="#484848" stroke-width="2"/>
281
+ <rect x="14" y="42" width="232" height="28" rx="17" fill="white" opacity="0.035"/>
282
+ <rect x="52" y="71" width="14" height="46" rx="3" fill="url(#cpDpadGrad)" stroke="#222" stroke-width="1.5"/>
283
+ <rect x="38" y="85" width="42" height="18" rx="3" fill="url(#cpDpadGrad)" stroke="#222" stroke-width="1.5"/>
284
+ <rect x="52" y="85" width="14" height="18" fill="#3c3c3c"/>
285
+ <polygon points="59,75 56,80 62,80" fill="#606060"/>
286
+ <polygon points="59,113 56,108 62,108" fill="#606060"/>
287
+ <polygon points="42,94 47,91 47,97" fill="#606060"/>
288
+ <polygon points="76,94 71,91 71,97" fill="#606060"/>
289
+ <rect x="102" y="93" width="24" height="10" rx="5" fill="#353535" stroke="#555" stroke-width="1.2"/>
290
+ <text x="114" y="109" font-size="5.5" fill="#5a5a5a" text-anchor="middle" font-family="sans-serif" font-weight="700" letter-spacing="0.5">SELECT</text>
291
+ <rect x="135" y="93" width="24" height="10" rx="5" fill="#353535" stroke="#555" stroke-width="1.2"/>
292
+ <text x="147" y="109" font-size="5.5" fill="#5a5a5a" text-anchor="middle" font-family="sans-serif" font-weight="700" letter-spacing="0.5">START</text>
293
+ <circle cx="192" cy="100" r="18" fill="rgba(0,0,0,0.35)" transform="translate(1,3)"/>
294
+ <circle cx="192" cy="100" r="18" fill="url(#cpBtnRed)" stroke="#6b0a0a" stroke-width="2" filter="url(#cpShadow)"/>
295
+ <ellipse cx="186" cy="93" rx="6" ry="4.5" fill="white" opacity="0.22" transform="rotate(-20,186,93)"/>
296
+ <text x="192" y="105" font-size="15" font-weight="900" fill="white" text-anchor="middle" font-family="sans-serif">B</text>
297
+ <circle cx="222" cy="85" r="18" fill="rgba(0,0,0,0.35)" transform="translate(1,3)"/>
298
+ <circle cx="222" cy="85" r="18" fill="url(#cpBtnRed)" stroke="#6b0a0a" stroke-width="2" filter="url(#cpShadow)"/>
299
+ <ellipse cx="216" cy="78" rx="6" ry="4.5" fill="white" opacity="0.22" transform="rotate(-20,216,78)"/>
300
+ <text x="222" y="90" font-size="15" font-weight="900" fill="white" text-anchor="middle" font-family="sans-serif">A</text>
301
+ <text x="127" y="70" font-size="6.5" fill="#282828" text-anchor="middle" font-family="sans-serif" font-weight="800" letter-spacing="3">NINTENDO</text>
302
+ </svg>
303
+ </div>
304
+
305
+ <!-- Player 1 -->
306
+ {@render playerSection(1, player1Input, player1KeyMap)}
307
+
308
+ <!-- Player 2 -->
309
+ {@render playerSection(2, player2Input, player2KeyMap)}
310
+ </div>
311
+
312
+ {#snippet playerSection(player: 1 | 2, input: PlayerInput, kmap: Record<string, number>)}
313
+ {@const isP1 = player === 1}
314
+ {@const assign = isP1 ? assignP1 : assignP2}
315
+
316
+ <div class="cp-section">
317
+ <div class="cp-player-row">
318
+ <span class="cp-player-label">Player {player}</span>
319
+ </div>
320
+
321
+ <!-- Input source selector -->
322
+ <div class="cp-source-row">
323
+ {#if isP1}
324
+ <!-- P1 can be keyboard or any gamepad (not none) -->
325
+ <button
326
+ class="cp-source-btn"
327
+ class:active={input.type === 'keyboard'}
328
+ onclick={() => assign({ type: 'keyboard' })}
329
+ >
330
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="2" y="6" width="20" height="14" rx="2"/><line x1="6" y1="10" x2="6" y2="10"/><line x1="10" y1="10" x2="10" y2="10"/><line x1="14" y1="10" x2="14" y2="10"/><line x1="18" y1="10" x2="18" y2="10"/><line x1="6" y1="14" x2="6" y2="14"/><line x1="18" y1="14" x2="18" y2="14"/><line x1="10" y1="14" x2="14" y2="14"/></svg>
331
+ Keyboard
332
+ </button>
333
+ {:else}
334
+ <!-- P2 can be none, keyboard, or any gamepad -->
335
+ <button
336
+ class="cp-source-btn"
337
+ class:active={input.type === 'none'}
338
+ onclick={() => assign({ type: 'none' })}
339
+ >
340
+ None
341
+ </button>
342
+ <button
343
+ class="cp-source-btn"
344
+ class:active={input.type === 'keyboard'}
345
+ onclick={() => assign({ type: 'keyboard' })}
346
+ >
347
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="2" y="6" width="20" height="14" rx="2"/><line x1="6" y1="10" x2="6" y2="10"/><line x1="10" y1="10" x2="10" y2="10"/><line x1="14" y1="10" x2="14" y2="10"/><line x1="18" y1="10" x2="18" y2="10"/><line x1="6" y1="14" x2="6" y2="14"/><line x1="18" y1="14" x2="18" y2="14"/><line x1="10" y1="14" x2="14" y2="14"/></svg>
348
+ Keys
349
+ </button>
350
+ {/if}
351
+
352
+ {#each detectedGamepads as pad}
353
+ <button
354
+ class="cp-source-btn cp-source-btn--pad"
355
+ class:active={input.type === 'gamepad' && input.id === pad.id}
356
+ onclick={() => assign({ type: 'gamepad', id: pad.id })}
357
+ title={pad.id}
358
+ >
359
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="2" y="6" width="20" height="12" rx="2"/><line x1="6" y1="12" x2="10" y2="12"/><line x1="8" y1="10" x2="8" y2="14"/><circle cx="15" cy="13" r="0.5" fill="currentColor"/><circle cx="18" cy="11" r="0.5" fill="currentColor"/></svg>
360
+ {pad.name}
361
+ </button>
362
+ {/each}
363
+ </div>
364
+
365
+ {#if input.type === 'keyboard'}
366
+ <div class="cp-bindings">
367
+ {#each NES_BUTTONS as btn}
368
+ {@const currentKey = keyForBit(kmap, btn.bit)}
369
+ {@const isRebinding = kbRebind?.player === player && kbRebind?.bit === btn.bit}
370
+ <div class="cp-row">
371
+ <span class="cp-btn-label">{btn.label}</span>
372
+ <button
373
+ class="cp-key-tag"
374
+ class:rebinding={isRebinding}
375
+ onclick={() => { kbRebind = isRebinding ? null : { player, bit: btn.bit }; }}
376
+ >
377
+ {#if isRebinding}
378
+ <span class="cp-listening">press key…</span>
379
+ {:else}
380
+ {currentKey ? keyLabel(currentKey) : '—'}
381
+ {/if}
382
+ </button>
383
+ </div>
384
+ {/each}
385
+ <div class="cp-divider"></div>
386
+ <div class="cp-row">
387
+ <span class="cp-btn-label">Quick Save</span>
388
+ <button
389
+ class="cp-key-tag"
390
+ class:rebinding={stateKeyRebind === 'save'}
391
+ onclick={() => { stateKeyRebind = stateKeyRebind === 'save' ? null : 'save'; kbRebind = null; }}
392
+ >
393
+ {#if stateKeyRebind === 'save'}
394
+ <span class="cp-listening">press key…</span>
395
+ {:else}
396
+ {keyLabel(quickSaveKey)}
397
+ {/if}
398
+ </button>
399
+ </div>
400
+ <div class="cp-row">
401
+ <span class="cp-btn-label">Quick Load</span>
402
+ <button
403
+ class="cp-key-tag"
404
+ class:rebinding={stateKeyRebind === 'load'}
405
+ onclick={() => { stateKeyRebind = stateKeyRebind === 'load' ? null : 'load'; kbRebind = null; }}
406
+ >
407
+ {#if stateKeyRebind === 'load'}
408
+ <span class="cp-listening">press key…</span>
409
+ {:else}
410
+ {keyLabel(quickLoadKey)}
411
+ {/if}
412
+ </button>
413
+ </div>
414
+ <button
415
+ class="cp-reset-btn"
416
+ onclick={() => {
417
+ const emit = isP1 ? onplayer1keymapchange : onplayer2keymapchange;
418
+ emit({ ...defaultKeyMap });
419
+ kbRebind = null;
420
+ }}
421
+ >Reset to defaults</button>
422
+ </div>
423
+
424
+ {:else if input.type === 'gamepad'}
425
+ {@const prof = gamepadProfiles.find(p => p.id === input.id) ?? { id: input.id, buttonMap: defaultGamepadButtonMap }}
426
+ <div class="cp-bindings">
427
+ {#each NES_BUTTONS as btn}
428
+ {@const boundIdx = gpIdxForBit(prof.buttonMap, btn.bit)}
429
+ {@const isRebinding = gpRebind?.padId === input.id && gpRebind?.bit === btn.bit}
430
+ <div class="cp-row">
431
+ <span class="cp-btn-label">{btn.label}</span>
432
+ <button
433
+ class="cp-key-tag"
434
+ class:rebinding={isRebinding}
435
+ onclick={() => { gpRebind = isRebinding ? null : { padId: input.id, bit: btn.bit }; }}
436
+ >
437
+ {#if isRebinding}
438
+ <span class="cp-listening">press button…</span>
439
+ {:else}
440
+ {boundIdx !== null ? gpBtnLabel(boundIdx) : '—'}
441
+ {/if}
442
+ </button>
443
+ </div>
444
+ {/each}
445
+ <div class="cp-divider"></div>
446
+ <div class="cp-row">
447
+ <span class="cp-btn-label">Quick Save</span>
448
+ <button
449
+ class="cp-key-tag"
450
+ class:rebinding={stateGpRebind?.padId === input.id && stateGpRebind?.which === 'save'}
451
+ onclick={() => { stateGpRebind = (stateGpRebind?.which === 'save' && stateGpRebind?.padId === input.id) ? null : { padId: input.id, which: 'save' }; gpRebind = null; }}
452
+ >
453
+ {#if stateGpRebind?.padId === input.id && stateGpRebind?.which === 'save'}
454
+ <span class="cp-listening">press button…</span>
455
+ {:else}
456
+ {prof.quickSaveBtn != null ? gpBtnLabel(prof.quickSaveBtn) : '—'}
457
+ {/if}
458
+ </button>
459
+ </div>
460
+ <div class="cp-row">
461
+ <span class="cp-btn-label">Quick Load</span>
462
+ <button
463
+ class="cp-key-tag"
464
+ class:rebinding={stateGpRebind?.padId === input.id && stateGpRebind?.which === 'load'}
465
+ onclick={() => { stateGpRebind = (stateGpRebind?.which === 'load' && stateGpRebind?.padId === input.id) ? null : { padId: input.id, which: 'load' }; gpRebind = null; }}
466
+ >
467
+ {#if stateGpRebind?.padId === input.id && stateGpRebind?.which === 'load'}
468
+ <span class="cp-listening">press button…</span>
469
+ {:else}
470
+ {prof.quickLoadBtn != null ? gpBtnLabel(prof.quickLoadBtn) : '—'}
471
+ {/if}
472
+ </button>
473
+ </div>
474
+ <button
475
+ class="cp-reset-btn"
476
+ onclick={() => {
477
+ ongpprofilechange({ id: input.id, buttonMap: { ...defaultGamepadButtonMap }, quickSaveBtn: null, quickLoadBtn: null });
478
+ gpRebind = null;
479
+ stateGpRebind = null;
480
+ }}
481
+ >Reset to defaults</button>
482
+ </div>
483
+ {/if}
484
+ </div>
485
+ {/snippet}
486
+
487
+ <style>
488
+ .cp-backdrop {
489
+ position: absolute;
490
+ inset: 0;
491
+ background: rgba(0, 0, 0, 0.58);
492
+ z-index: 20;
493
+ cursor: default;
494
+ }
495
+
496
+ .cp-panel {
497
+ position: absolute;
498
+ inset: 0;
499
+ margin: auto;
500
+ width: min(292px, calc(100% - 68px));
501
+ height: fit-content;
502
+ max-height: calc(100% - 20px);
503
+ background: #0d0d1c;
504
+ border: 1px solid rgba(255, 255, 255, 0.1);
505
+ border-radius: 10px;
506
+ overflow-y: auto;
507
+ overflow-x: hidden;
508
+ z-index: 21;
509
+ display: flex;
510
+ flex-direction: column;
511
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.75);
512
+ }
513
+
514
+ .cp-header {
515
+ display: flex;
516
+ align-items: center;
517
+ justify-content: space-between;
518
+ padding: 10px 12px 9px;
519
+ border-bottom: 1px solid rgba(255, 255, 255, 0.07);
520
+ flex-shrink: 0;
521
+ }
522
+
523
+ .cp-title {
524
+ font-size: 0.72rem;
525
+ font-weight: 700;
526
+ color: rgba(255, 255, 255, 0.7);
527
+ letter-spacing: 0.12em;
528
+ text-transform: uppercase;
529
+ }
530
+
531
+ .cp-close {
532
+ width: 22px;
533
+ height: 22px;
534
+ border: none;
535
+ background: rgba(255, 255, 255, 0.07);
536
+ border-radius: 4px;
537
+ color: rgba(255, 255, 255, 0.5);
538
+ cursor: pointer;
539
+ display: flex;
540
+ align-items: center;
541
+ justify-content: center;
542
+ padding: 0;
543
+ flex-shrink: 0;
544
+ }
545
+
546
+ .cp-close:hover {
547
+ background: rgba(255, 255, 255, 0.14);
548
+ color: rgba(255, 255, 255, 0.85);
549
+ }
550
+
551
+ .cp-art {
552
+ padding: 10px 20px 4px;
553
+ display: flex;
554
+ justify-content: center;
555
+ flex-shrink: 0;
556
+ }
557
+
558
+ .cp-svg {
559
+ width: 100%;
560
+ max-width: 210px;
561
+ overflow: visible;
562
+ }
563
+
564
+ .cp-divider {
565
+ border: none;
566
+ border-top: 1px solid rgba(255, 255, 255, 0.07);
567
+ margin: 4px 0;
568
+ }
569
+
570
+ .cp-section {
571
+ padding: 8px 14px 10px;
572
+ border-top: 1px solid rgba(255, 255, 255, 0.06);
573
+ }
574
+
575
+ .cp-player-row {
576
+ display: flex;
577
+ align-items: center;
578
+ justify-content: space-between;
579
+ margin-bottom: 7px;
580
+ }
581
+
582
+ .cp-player-label {
583
+ font-size: 0.62rem;
584
+ font-weight: 700;
585
+ letter-spacing: 0.14em;
586
+ text-transform: uppercase;
587
+ color: rgba(255, 255, 255, 0.35);
588
+ }
589
+
590
+ .cp-source-row {
591
+ display: flex;
592
+ flex-wrap: wrap;
593
+ gap: 5px;
594
+ margin-bottom: 2px;
595
+ }
596
+
597
+ .cp-source-btn {
598
+ display: flex;
599
+ align-items: center;
600
+ gap: 5px;
601
+ padding: 4px 10px;
602
+ border: 1px solid rgba(255, 255, 255, 0.1);
603
+ border-radius: 20px;
604
+ background: rgba(255, 255, 255, 0.04);
605
+ color: rgba(255, 255, 255, 0.5);
606
+ font-size: 0.68rem;
607
+ font-weight: 600;
608
+ cursor: pointer;
609
+ white-space: nowrap;
610
+ transition: border-color 0.1s, background 0.1s, color 0.1s;
611
+ max-width: 120px;
612
+ overflow: hidden;
613
+ text-overflow: ellipsis;
614
+ }
615
+
616
+ .cp-source-btn svg {
617
+ flex-shrink: 0;
618
+ }
619
+
620
+ .cp-source-btn:hover {
621
+ border-color: rgba(255, 255, 255, 0.22);
622
+ background: rgba(255, 255, 255, 0.08);
623
+ color: rgba(255, 255, 255, 0.8);
624
+ }
625
+
626
+ .cp-source-btn.active {
627
+ border-color: #4ade80;
628
+ background: rgba(74, 222, 128, 0.1);
629
+ color: #4ade80;
630
+ }
631
+
632
+ .cp-bindings {
633
+ display: flex;
634
+ flex-direction: column;
635
+ gap: 5px;
636
+ margin-top: 8px;
637
+ }
638
+
639
+ .cp-row {
640
+ display: flex;
641
+ align-items: center;
642
+ justify-content: space-between;
643
+ }
644
+
645
+ .cp-btn-label {
646
+ font-size: 0.75rem;
647
+ color: rgba(255, 255, 255, 0.55);
648
+ min-width: 52px;
649
+ }
650
+
651
+ .cp-key-tag {
652
+ min-width: 80px;
653
+ height: 26px;
654
+ padding: 0 10px;
655
+ border: 1px solid rgba(255, 255, 255, 0.12);
656
+ border-radius: 5px;
657
+ background: rgba(255, 255, 255, 0.05);
658
+ color: rgba(255, 255, 255, 0.8);
659
+ font-size: 0.72rem;
660
+ font-weight: 600;
661
+ font-family: inherit;
662
+ cursor: pointer;
663
+ text-align: center;
664
+ white-space: nowrap;
665
+ transition: border-color 0.1s, background 0.1s;
666
+ }
667
+
668
+ .cp-key-tag:hover {
669
+ border-color: rgba(255, 255, 255, 0.26);
670
+ background: rgba(255, 255, 255, 0.09);
671
+ }
672
+
673
+ .cp-key-tag.rebinding {
674
+ border-color: #f87171;
675
+ background: rgba(248, 113, 113, 0.12);
676
+ animation: cp-pulse 0.8s ease-in-out infinite alternate;
677
+ }
678
+
679
+ @keyframes cp-pulse {
680
+ from { box-shadow: 0 0 0 0 rgba(248, 113, 113, 0); }
681
+ to { box-shadow: 0 0 0 3px rgba(248, 113, 113, 0.25); }
682
+ }
683
+
684
+ .cp-listening {
685
+ color: #f87171;
686
+ font-style: italic;
687
+ font-size: 0.65rem;
688
+ }
689
+
690
+ .cp-reset-btn {
691
+ margin-top: 8px;
692
+ width: 100%;
693
+ height: 26px;
694
+ border: 1px solid rgba(255, 255, 255, 0.08);
695
+ border-radius: 5px;
696
+ background: rgba(255, 255, 255, 0.03);
697
+ color: rgba(255, 255, 255, 0.35);
698
+ font-size: 0.66rem;
699
+ font-weight: 600;
700
+ letter-spacing: 0.04em;
701
+ cursor: pointer;
702
+ transition: background 0.1s, color 0.1s;
703
+ }
704
+
705
+ .cp-reset-btn:hover {
706
+ background: rgba(255, 255, 255, 0.08);
707
+ color: rgba(255, 255, 255, 0.65);
708
+ }
709
+ </style>