mirror of
https://github.com/MoHadiShibli/Control4Free.git
synced 2026-10-18 11:01:05 +02:00
Over Bluetooth a linked DualShock 4 or DualSense switches to its full reports, which the browser's gamepad doesn't read, so the PS4 got no input from it. While linked, its buttons, sticks and triggers now come from those reports, over USB and Bluetooth alike. A fault in the experimental code no longer stops the page's input loop, and "How to turn it on" only shows when the page is insecure.
5104 lines
258 KiB
HTML
5104 lines
258 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||
<meta name="theme-color" content="#04143a">
|
||
<meta name="mobile-web-app-capable" content="yes">
|
||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||
<meta name="description" content="Use this phone or PC as a PS4 controller">
|
||
<meta name="apple-mobile-web-app-title" content="Control4Free">
|
||
<title>Control4Free</title>
|
||
<link rel="manifest" href="/manifest.webmanifest">
|
||
<link rel="apple-touch-icon" href="/icon-192.png">
|
||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1000 1000'%3E%3Crect width='1000' height='1000' rx='220' fill='%230a3c96'/%3E%3Cpath transform='translate(80 228) scale(0.84)' fill='%23fff' d='M500 78C410 78 330 74 262 62C232 46 188 42 146 62C108 80 86 120 78 170C70 230 58 320 60 400C62 470 80 535 118 568C152 598 196 592 222 560C250 525 272 470 300 430C360 418 430 440 500 440C570 440 640 418 700 430C728 470 750 525 778 560C804 592 848 598 882 568C920 535 938 470 940 400C942 320 930 230 922 170C914 120 892 80 854 62C812 42 768 46 738 62C670 74 590 78 500 78ZM366 98L634 98C648.359 98 660 109.641 660 124L660 226C660 240.359 648.359 252 634 252L366 252C351.641 252 340 240.359 340 226L340 124C340 109.641 351.641 98 366 98ZM173 148L219 148L219 189L260 189L260 235L219 235L219 276L173 276L173 235L132 235L132 189L173 189L173 148ZM804 121C818.912 121 831 133.088 831 148C831 162.912 818.912 175 804 175C789.088 175 777 162.912 777 148C777 133.088 789.088 121 804 121ZM804 249C818.912 249 831 261.088 831 276C831 290.912 818.912 303 804 303C789.088 303 777 290.912 777 276C777 261.088 789.088 249 804 249ZM740 185C754.912 185 767 197.088 767 212C767 226.912 754.912 239 740 239C725.088 239 713 226.912 713 212C713 197.088 725.088 185 740 185ZM868 185C882.912 185 895 197.088 895 212C895 226.912 882.912 239 868 239C853.088 239 841 226.912 841 212C841 197.088 853.088 185 868 185ZM338 276C374.451 276 404 305.549 404 342C404 378.451 374.451 408 338 408C301.549 408 272 378.451 272 342C272 305.549 301.549 276 338 276ZM338 298C313.699 298 294 317.699 294 342C294 366.301 313.699 386 338 386C362.301 386 382 366.301 382 342C382 317.699 362.301 298 338 298ZM662 276C698.451 276 728 305.549 728 342C728 378.451 698.451 408 662 408C625.549 408 596 378.451 596 342C596 305.549 625.549 276 662 276ZM662 298C637.699 298 618 317.699 618 342C618 366.301 637.699 386 662 386C686.301 386 706 366.301 706 342C706 317.699 686.301 298 662 298ZM500 319C509.389 319 517 326.611 517 336C517 345.389 509.389 353 500 353C490.611 353 483 345.389 483 336C483 326.611 490.611 319 500 319Z'/%3E%3C/svg%3E">
|
||
<style>
|
||
:root {
|
||
color-scheme: dark;
|
||
--deep: #020b1f;
|
||
--text: #f4f7ff;
|
||
--muted: rgba(226, 236, 255, .72);
|
||
--faint: rgba(226, 236, 255, .46);
|
||
--line: rgba(255, 255, 255, .16);
|
||
--line-soft: rgba(255, 255, 255, .09);
|
||
--panel: rgba(3, 16, 44, .55);
|
||
--panel-strong: rgba(2, 11, 31, .86);
|
||
--tile: rgba(12, 40, 98, .5);
|
||
--sky: #3a8cff;
|
||
--accent: #3a8cff;
|
||
--ok: #45e08a;
|
||
--warn: #ffd04d;
|
||
--bad: #ff6b7d;
|
||
--tri: #3fe0b8;
|
||
--cir: #ff6680;
|
||
--crs: #86b6ff;
|
||
--sqr: #ff92dc;
|
||
--glow: 0 0 0 2px #fff, 0 0 22px rgba(255, 255, 255, .5);
|
||
--font: "SST", "SST W55 Regular", "SST Light", "Helvetica Neue", "Segoe UI", Roboto, Arial, sans-serif;
|
||
--ease: cubic-bezier(.2, .8, .2, 1);
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
|
||
html, body {
|
||
margin: 0;
|
||
height: 100%;
|
||
background: var(--deep);
|
||
color: var(--text);
|
||
font-family: var(--font);
|
||
-webkit-font-smoothing: antialiased;
|
||
-webkit-tap-highlight-color: transparent;
|
||
}
|
||
|
||
button, input, select { font: inherit; color: inherit; }
|
||
button { cursor: pointer; }
|
||
[hidden] { display: none !important; }
|
||
:focus-visible { outline: none; box-shadow: var(--glow); }
|
||
|
||
/* ---------- Backdrop: deep blue with drifting light waves ---------- */
|
||
|
||
.backdrop {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 0;
|
||
overflow: hidden;
|
||
pointer-events: none;
|
||
background:
|
||
radial-gradient(120% 70% at 75% 115%, rgba(70, 150, 255, .5), transparent 62%),
|
||
radial-gradient(80% 60% at 0% -10%, rgba(25, 90, 200, .55), transparent 60%),
|
||
linear-gradient(165deg, #020f2b 0%, #062766 45%, #0b3f9c 72%, #04194a 100%);
|
||
}
|
||
|
||
.waves {
|
||
position: absolute;
|
||
left: 0;
|
||
bottom: 4%;
|
||
width: 200%;
|
||
height: 62%;
|
||
animation: drift 46s linear infinite;
|
||
}
|
||
|
||
.waves.slow { bottom: 14%; height: 50%; opacity: .7; animation-duration: 70s; animation-direction: reverse; }
|
||
|
||
.waves path { fill: none; stroke-linecap: round; }
|
||
|
||
@keyframes drift { from { transform: translateX(0); } to { transform: translateX(-50%); } }
|
||
|
||
.sparkles {
|
||
position: absolute;
|
||
inset: -10% 0;
|
||
background-image:
|
||
radial-gradient(1.5px 1.5px at 12% 22%, rgba(255, 255, 255, .7), transparent),
|
||
radial-gradient(1px 1px at 28% 64%, rgba(255, 255, 255, .55), transparent),
|
||
radial-gradient(1.5px 1.5px at 47% 38%, rgba(255, 255, 255, .6), transparent),
|
||
radial-gradient(1px 1px at 63% 76%, rgba(255, 255, 255, .5), transparent),
|
||
radial-gradient(2px 2px at 78% 30%, rgba(255, 255, 255, .45), transparent),
|
||
radial-gradient(1px 1px at 88% 58%, rgba(255, 255, 255, .6), transparent),
|
||
radial-gradient(1.5px 1.5px at 36% 88%, rgba(255, 255, 255, .5), transparent),
|
||
radial-gradient(1px 1px at 92% 12%, rgba(255, 255, 255, .55), transparent);
|
||
animation: float 24s ease-in-out infinite alternate;
|
||
}
|
||
|
||
@keyframes float { from { transform: translateY(0); } to { transform: translateY(-4%); } }
|
||
|
||
/* ---------- Top bar ---------- */
|
||
|
||
.topbar {
|
||
position: relative;
|
||
z-index: 2;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
min-height: 52px;
|
||
padding: max(10px, env(safe-area-inset-top)) max(22px, env(safe-area-inset-right)) 6px max(22px, env(safe-area-inset-left));
|
||
}
|
||
|
||
.tb-group { display: flex; align-items: center; gap: 14px; min-width: 0; }
|
||
|
||
.app-mark {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
font-size: 17px;
|
||
font-weight: 300;
|
||
letter-spacing: .02em;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.app-mark svg { width: 36px; height: 23px; }
|
||
|
||
.clock {
|
||
font-size: 17px;
|
||
font-weight: 300;
|
||
letter-spacing: .02em;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.conn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
font-size: 13px;
|
||
color: var(--muted);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
#connText, .conn-detail { overflow: hidden; text-overflow: ellipsis; }
|
||
#connText { flex: none; }
|
||
.conn-detail { min-width: 0; margin-left: -4px; }
|
||
@media (max-width: 520px) { .conn-detail { display: none; } }
|
||
|
||
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--faint); flex: none; }
|
||
[data-conn="open"] .dot { background: var(--ok); box-shadow: 0 0 10px var(--ok); }
|
||
[data-conn="connecting"] .dot { background: var(--warn); animation: pulse 1.2s ease-in-out infinite; }
|
||
[data-conn="closed"] .dot { background: var(--bad); }
|
||
@keyframes pulse { 50% { opacity: .3; } }
|
||
|
||
.icon-btn {
|
||
width: 40px;
|
||
height: 40px;
|
||
display: grid;
|
||
place-items: center;
|
||
border: 0;
|
||
border-radius: 50%;
|
||
background: rgba(255, 255, 255, .08);
|
||
box-shadow: inset 0 0 0 1px var(--line-soft);
|
||
color: var(--text);
|
||
transition: transform .2s var(--ease), background .2s, box-shadow .2s;
|
||
}
|
||
|
||
.icon-btn:hover { background: rgba(255, 255, 255, .16); box-shadow: var(--glow); }
|
||
.icon-btn:active { transform: scale(.93); }
|
||
.icon-btn svg { width: 19px; height: 19px; }
|
||
|
||
/* ---------- Home ---------- */
|
||
|
||
.home {
|
||
position: relative;
|
||
z-index: 1;
|
||
min-height: 100dvh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.home-main {
|
||
flex: 1;
|
||
width: 100%;
|
||
max-width: 1120px;
|
||
margin: 0 auto;
|
||
padding: 18px max(22px, env(safe-area-inset-right)) 26px max(22px, env(safe-area-inset-left));
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 30px;
|
||
}
|
||
|
||
.screen-title {
|
||
margin: 0;
|
||
font-size: clamp(26px, 4vw, 40px);
|
||
font-weight: 300;
|
||
letter-spacing: .01em;
|
||
}
|
||
|
||
.screen-sub { margin: 8px 0 0; color: var(--muted); font-size: 15px; font-weight: 300; line-height: 1.5; max-width: 60ch; }
|
||
|
||
.tiles {
|
||
margin-top: 22px;
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 18px;
|
||
}
|
||
|
||
@media (max-width: 760px) { .tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; } }
|
||
|
||
.tile {
|
||
--pc: #3a8cff;
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
min-height: 186px;
|
||
padding: 16px;
|
||
border: 0;
|
||
border-radius: 6px;
|
||
text-align: left;
|
||
background:
|
||
linear-gradient(160deg, rgba(255, 255, 255, .13), rgba(255, 255, 255, .02) 55%),
|
||
var(--tile);
|
||
box-shadow: inset 0 0 0 1px var(--line), 0 16px 34px -18px rgba(0, 0, 0, .9);
|
||
backdrop-filter: blur(6px);
|
||
-webkit-backdrop-filter: blur(6px);
|
||
user-select: none;
|
||
-webkit-user-select: none;
|
||
transition: transform .28s var(--ease), box-shadow .28s var(--ease);
|
||
}
|
||
|
||
.tile::after {
|
||
content: "";
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
top: 0;
|
||
height: 3px;
|
||
border-radius: 6px 6px 0 0;
|
||
background: var(--pc);
|
||
opacity: .9;
|
||
}
|
||
|
||
.tile:hover, .tile:focus-visible { transform: scale(1.045); box-shadow: var(--glow), 0 22px 44px -18px rgba(0, 0, 0, .95); z-index: 1; }
|
||
.tile:active { transform: scale(1.02); }
|
||
.tile[data-state="disabled"] { opacity: .45; }
|
||
|
||
.tile-head { display: flex; align-items: center; justify-content: space-between; }
|
||
|
||
.ring {
|
||
width: 52px;
|
||
height: 52px;
|
||
border-radius: 50%;
|
||
display: grid;
|
||
place-items: center;
|
||
font-size: 24px;
|
||
font-weight: 300;
|
||
background: radial-gradient(circle at 50% 35%, rgba(255, 255, 255, .18), rgba(255, 255, 255, .03));
|
||
box-shadow: inset 0 0 0 3px var(--pc), 0 0 18px -4px var(--pc);
|
||
}
|
||
|
||
.activity { width: 9px; height: 9px; border-radius: 50%; background: var(--pc); opacity: 0; transition: opacity .35s; }
|
||
.activity.on { opacity: 1; transition: none; }
|
||
|
||
.tile-name { font-size: 18px; font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.tile-meta { font-size: 12.5px; color: var(--muted); font-weight: 300; min-height: 1.3em; }
|
||
|
||
.tile-state { display: flex; flex-wrap: wrap; gap: 6px 10px; font-size: 13px; color: var(--muted); }
|
||
.state { display: inline-flex; align-items: center; gap: 7px; }
|
||
.state.live { color: #fff; }
|
||
.state.live .dot { background: var(--ok); box-shadow: 0 0 10px var(--ok); }
|
||
.state.wait .dot { background: var(--warn); }
|
||
.state.mine { color: #fff; font-weight: 400; }
|
||
|
||
.panel {
|
||
border-radius: 6px;
|
||
background: var(--panel);
|
||
box-shadow: inset 0 0 0 1px var(--line-soft);
|
||
backdrop-filter: blur(10px);
|
||
-webkit-backdrop-filter: blur(10px);
|
||
padding: 18px 20px;
|
||
}
|
||
|
||
.panel h2 { margin: 0; font-size: 19px; font-weight: 300; letter-spacing: .01em; }
|
||
.hint { margin: 6px 0 0; color: var(--muted); font-size: 13.5px; font-weight: 300; line-height: 1.5; }
|
||
.hint.error { color: var(--bad); }
|
||
|
||
.address-row { display: flex; gap: 10px; margin-top: 12px; }
|
||
|
||
.field {
|
||
width: 100%;
|
||
min-width: 0;
|
||
height: 44px;
|
||
padding: 0 14px;
|
||
border: 0;
|
||
border-radius: 4px;
|
||
background: rgba(0, 0, 0, .3);
|
||
box-shadow: inset 0 0 0 1px var(--line);
|
||
color: var(--text);
|
||
font-size: 15px;
|
||
transition: box-shadow .2s;
|
||
}
|
||
|
||
.field:focus { outline: none; box-shadow: inset 0 0 0 2px #fff, 0 0 16px rgba(255, 255, 255, .35); }
|
||
select.field { appearance: none; background-image: linear-gradient(45deg, transparent 50%, #fff 50%), linear-gradient(135deg, #fff 50%, transparent 50%); background-position: calc(100% - 18px) 19px, calc(100% - 13px) 19px; background-size: 5px 5px; background-repeat: no-repeat; padding-right: 34px; }
|
||
select.field option { background: #071b46; color: #fff; }
|
||
|
||
.btn {
|
||
height: 44px;
|
||
padding: 0 22px;
|
||
border: 0;
|
||
border-radius: 4px;
|
||
background: rgba(255, 255, 255, .14);
|
||
box-shadow: inset 0 0 0 1px var(--line);
|
||
color: #fff;
|
||
font-size: 15px;
|
||
white-space: nowrap;
|
||
transition: transform .2s var(--ease), background .2s, box-shadow .2s;
|
||
}
|
||
|
||
.btn:hover { background: rgba(255, 255, 255, .22); box-shadow: var(--glow); }
|
||
.btn:active { transform: scale(.97); }
|
||
.btn.primary { background: linear-gradient(180deg, #3d8dff, #1e63d6); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .25); }
|
||
.btn.primary:hover { box-shadow: var(--glow); }
|
||
.btn.danger { background: rgba(255, 107, 125, .18); box-shadow: inset 0 0 0 1px rgba(255, 107, 125, .55); }
|
||
.btn:disabled { opacity: .5; pointer-events: none; }
|
||
|
||
.gp-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 10px; }
|
||
|
||
.gp-row {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) auto;
|
||
align-items: center;
|
||
gap: 16px;
|
||
padding: 16px;
|
||
border-radius: 6px;
|
||
background: rgba(255, 255, 255, .05);
|
||
box-shadow: inset 0 0 0 1px var(--line-soft);
|
||
transition: box-shadow .25s;
|
||
}
|
||
|
||
.gp-row.active { box-shadow: var(--glow); }
|
||
.gp-device { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||
.gp-device-icon { display: grid; place-items: center; flex: 0 0 40px; height: 40px; border-radius: 6px; background: rgba(255, 255, 255, .06); color: var(--muted); }
|
||
.gp-device-icon svg { width: 26px; height: 26px; }
|
||
.gp-details { display: grid; gap: 3px; min-width: 0; }
|
||
.gp-name { font-size: 15px; font-weight: 500; line-height: 1.4; overflow-wrap: anywhere; }
|
||
.gp-sub { font-size: 12.5px; line-height: 1.45; color: var(--muted); font-weight: 300; }
|
||
.gp-meta { display: flex; flex-wrap: wrap; gap: 2px 10px; }
|
||
.gp-meta > span { max-width: 100%; overflow-wrap: anywhere; }
|
||
.gp-profile::before { content: '·'; margin-right: 10px; }
|
||
.gp-note { font-size: 12.5px; line-height: 1.45; color: var(--muted); }
|
||
.gp-note.warning { color: var(--warn); }
|
||
.gp-assignment { display: grid; gap: 6px; }
|
||
.gp-assignment-label { font-size: 12px; color: var(--muted); }
|
||
.gp-actions { display: flex; align-items: flex-end; gap: 16px; }
|
||
.gp-row .seg { flex-wrap: nowrap; gap: 4px; }
|
||
.gp-row .seg button { height: 40px; font-variant-numeric: tabular-nums; }
|
||
.gp-row .gp-mapping { height: 40px; min-width: 96px; padding: 0 14px; font-size: 14px; }
|
||
.gp-row--settings { grid-template-columns: minmax(0, 1fr) auto; }
|
||
.gp-row--settings .gp-mapping { align-self: center; }
|
||
.gp-help { margin: 12px 0 0; font-size: 12.5px; line-height: 1.5; color: var(--muted); text-wrap: pretty; }
|
||
|
||
.seg { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
|
||
|
||
.seg button {
|
||
--pc: rgba(255, 255, 255, .5);
|
||
min-width: 42px;
|
||
height: 36px;
|
||
padding: 0 10px;
|
||
border: 0;
|
||
border-radius: 4px;
|
||
background: rgba(255, 255, 255, .08);
|
||
box-shadow: inset 0 0 0 1px var(--line-soft);
|
||
font-size: 14px;
|
||
transition: background .2s, box-shadow .2s, transform .15s;
|
||
}
|
||
|
||
.seg button:hover { box-shadow: var(--glow); }
|
||
.seg button:active { transform: scale(.95); }
|
||
.seg button[aria-checked="true"] { background: #fff; color: #06215a; box-shadow: 0 0 0 2px var(--pc), 0 0 14px rgba(255, 255, 255, .45); }
|
||
|
||
@media (max-width: 760px) {
|
||
.gp-row { grid-template-columns: minmax(0, 1fr) auto; gap: 14px 10px; padding: 14px; }
|
||
.gp-device { align-items: flex-start; gap: 10px; }
|
||
.gp-profile::before { content: none; }
|
||
.gp-actions { display: contents; }
|
||
.gp-assignment { grid-column: 1 / -1; grid-row: 2; }
|
||
.gp-row .seg { justify-content: flex-start; }
|
||
.gp-row .seg button { flex: 1; }
|
||
.gp-row .gp-mapping { grid-column: 2; grid-row: 1; align-self: start; min-width: 0; padding: 0 12px; }
|
||
}
|
||
|
||
@media (max-width: 560px) {
|
||
/* A phone shows its own clock: give the connection status the room */
|
||
.home .clock { display: none; }
|
||
}
|
||
|
||
.home-foot {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
padding: 0 max(22px, env(safe-area-inset-right)) max(18px, env(safe-area-inset-bottom)) max(22px, env(safe-area-inset-left));
|
||
color: var(--faint);
|
||
font-size: 12.5px;
|
||
font-weight: 300;
|
||
}
|
||
|
||
.link-btn { border: 0; background: none; padding: 6px 0; color: var(--muted); font-size: 13px; text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--line); }
|
||
.link-btn:hover { color: #fff; }
|
||
.home-foot a { color: var(--muted); text-underline-offset: 3px; }
|
||
.home-foot .link-btn, .clock { white-space: nowrap; }
|
||
|
||
/* ---------- Controller ---------- */
|
||
|
||
/* DualShock 4 buttons over a dark background lit in the pad's color. Every control is placed on its own
|
||
from the layout in the script (DEFAULT_LAYOUT), so moving one never disturbs the others. */
|
||
body.in-pad .backdrop { display: none; }
|
||
|
||
.pad-view {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 1;
|
||
background:
|
||
radial-gradient(75% 95% at 10% -15%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 62%),
|
||
radial-gradient(65% 80% at 105% 112%, rgba(120, 140, 200, .08), transparent 62%),
|
||
#0b0e14;
|
||
touch-action: none;
|
||
user-select: none;
|
||
-webkit-user-select: none;
|
||
-webkit-touch-callout: none;
|
||
overscroll-behavior: none;
|
||
}
|
||
|
||
/* Fine grain so the dark background doesn't look flat */
|
||
.pad-view::before {
|
||
content: "";
|
||
position: absolute;
|
||
inset: 0;
|
||
opacity: .04;
|
||
pointer-events: none;
|
||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||
}
|
||
|
||
.pad-area {
|
||
position: absolute;
|
||
inset: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
||
container-type: size;
|
||
/* Size unit: 1% of the screen height, smaller on narrow screens so both hands' controls still fit */
|
||
--u: min(1cqh, .46cqw, 7px);
|
||
}
|
||
|
||
@supports not (width: 1cqh) {
|
||
.pad-area { --u: min(1vh, .46vw, 7px); }
|
||
}
|
||
|
||
/* --x, --y: centre in % of the screen. --w, --h: size in --u */
|
||
.ctl {
|
||
position: absolute;
|
||
left: calc(var(--x) * 1% - var(--w) * var(--u) / 2);
|
||
top: calc(var(--y) * 1% - var(--h) * var(--u) / 2);
|
||
width: calc(var(--w) * var(--u));
|
||
height: calc(var(--h) * var(--u));
|
||
border: 0;
|
||
padding: 0;
|
||
background: none;
|
||
color: rgba(236, 240, 248, .8);
|
||
}
|
||
|
||
/* Glossy dark plastic, lit from above */
|
||
.trigger, .bumper, .capsule, .stick-btn {
|
||
background: linear-gradient(180deg, #343c4f 0%, #1f2532 46%, #151923 100%);
|
||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .16), inset 0 0 0 1px rgba(255, 255, 255, .06), 0 6px 16px -4px rgba(0, 0, 0, .8);
|
||
transition: transform .08s var(--ease), filter .08s, box-shadow .12s, background .12s;
|
||
}
|
||
|
||
.engraved {
|
||
position: relative;
|
||
font-size: max(11px, calc(var(--u) * 2.8));
|
||
font-weight: 500;
|
||
letter-spacing: .06em;
|
||
color: rgba(236, 240, 248, .55);
|
||
text-shadow: 0 -1px 0 rgba(0, 0, 0, .7);
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* L2 / R2: curved triggers. The fill shows how far an analog trigger is pulled. */
|
||
.trigger {
|
||
display: grid;
|
||
place-items: center;
|
||
overflow: hidden;
|
||
border-radius: 44% 44% 18% 18% / 64% 64% 24% 24%;
|
||
}
|
||
|
||
.trigger::after {
|
||
content: "";
|
||
position: absolute;
|
||
left: 12%;
|
||
right: 12%;
|
||
top: 9%;
|
||
height: 26%;
|
||
border-radius: 50% 50% 0 0 / 100% 100% 0 0;
|
||
border-top: 1px solid rgba(255, 255, 255, .14);
|
||
pointer-events: none;
|
||
}
|
||
|
||
.trigger-fill { position: absolute; inset: auto 0 0; height: 0%; background: color-mix(in srgb, var(--accent) 42%, transparent); pointer-events: none; }
|
||
|
||
/* L1 / R1: slim bumpers */
|
||
.bumper {
|
||
display: grid;
|
||
place-items: center;
|
||
border-radius: 34% 34% 26% 26% / 70% 70% 50% 50%;
|
||
}
|
||
|
||
/* L3 / R3: their own buttons, shown only when double-tapping a stick doesn't press it */
|
||
.stick-btn {
|
||
display: grid;
|
||
place-items: center;
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.stick-btn .engraved { font-size: max(10px, calc(var(--u) * 2.6)); }
|
||
|
||
/* Pressed: lit in the pad's color */
|
||
.trigger.on, .bumper.on, .stick-btn.on {
|
||
transform: translateY(1px) scale(.98);
|
||
background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 40%, #2a3142), color-mix(in srgb, var(--accent) 22%, #151923));
|
||
box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--accent) 75%, transparent), 0 0 calc(var(--u) * 3.4) -1px color-mix(in srgb, var(--accent) 75%, transparent);
|
||
}
|
||
|
||
/* SHARE / OPTIONS: small upright capsules with their names printed above */
|
||
.capsule-btn {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: calc(var(--u) * 1.1);
|
||
}
|
||
|
||
.cap-label { font-size: max(8px, calc(var(--u) * 1.9)); letter-spacing: .1em; color: rgba(236, 240, 248, .55); pointer-events: none; }
|
||
.capsule { width: calc(var(--u) * 4.2); height: calc(var(--u) * 8); border-radius: 999px; pointer-events: none; }
|
||
.capsule-btn.on .capsule { transform: scale(.94); background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 45%, #2a3142), color-mix(in srgb, var(--accent) 25%, #151923)); box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--accent) 75%, transparent), 0 0 calc(var(--u) * 2.5) -1px var(--accent); }
|
||
|
||
/* D-pad: four separate arms pointing in, with the printed direction marks around them */
|
||
.dpad { touch-action: none; }
|
||
.dpad svg { display: block; width: 100%; height: 100%; overflow: visible; pointer-events: none; filter: drop-shadow(0 5px 6px rgba(0, 0, 0, .55)); }
|
||
.dpad .pivot { fill: none; stroke: rgba(255, 255, 255, .07); stroke-width: 1.5; }
|
||
.dpad .mark { fill: none; stroke: rgba(236, 240, 248, .4); stroke-width: 2.2; stroke-linejoin: round; }
|
||
.dpad .arm { fill: url(#dpadArm); stroke: rgba(255, 255, 255, .13); stroke-width: 1.2; }
|
||
.dpad .arm.on { fill: color-mix(in srgb, var(--accent) 42%, #232a3a); stroke: var(--accent); stroke-width: 2; }
|
||
|
||
/* Face buttons: round and black, with the outlined symbols in their colors */
|
||
.face-btn {
|
||
position: absolute;
|
||
width: 34%;
|
||
height: 34%;
|
||
border: 0;
|
||
padding: 0;
|
||
border-radius: 50%;
|
||
display: grid;
|
||
place-items: center;
|
||
background: radial-gradient(circle at 50% 28%, #3c4458 0%, #222837 48%, #141822 100%);
|
||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .16), inset 0 -3px 7px rgba(0, 0, 0, .5), 0 0 0 1px rgba(255, 255, 255, .05), 0 6px 16px -4px rgba(0, 0, 0, .8);
|
||
transition: transform .08s var(--ease), filter .08s, box-shadow .12s;
|
||
}
|
||
|
||
.face-btn svg { width: 56%; height: 56%; pointer-events: none; }
|
||
.face-btn.on { transform: scale(.92); filter: brightness(1.3); box-shadow: inset 0 0 0 1.5px currentColor, 0 0 calc(var(--u) * 3) -1px currentColor; }
|
||
.face .tri { left: 33%; top: 0; color: var(--tri); }
|
||
.face .sqr { left: 0; top: 33%; color: var(--sqr); }
|
||
.face .cir { left: 66%; top: 33%; color: var(--cir); }
|
||
.face .crs { left: 33%; top: 66%; color: var(--crs); }
|
||
|
||
/* Touchpad: matte plate with a fine dot grain, glowing in the pad's color. The light bar along its top
|
||
edge pulses while the console isn't connected. */
|
||
.touchpad {
|
||
border-radius: calc(var(--u) * 3) calc(var(--u) * 3) calc(var(--u) * 7) calc(var(--u) * 7);
|
||
background:
|
||
radial-gradient(circle, rgba(255, 255, 255, .06) .7px, transparent 1.2px) 0 0 / calc(var(--u) * 1.5) calc(var(--u) * 1.5),
|
||
linear-gradient(180deg, #1d2331 0%, #151a25 70%, #121620 100%);
|
||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .07), inset 0 -10px 24px rgba(0, 0, 0, .35), 0 -6px 30px -14px var(--accent), 0 10px 24px -8px rgba(0, 0, 0, .7);
|
||
overflow: hidden;
|
||
touch-action: none;
|
||
cursor: crosshair;
|
||
transition: box-shadow .3s;
|
||
}
|
||
|
||
.touchpad::before {
|
||
content: "";
|
||
position: absolute;
|
||
top: 0;
|
||
left: 7%;
|
||
right: 7%;
|
||
height: max(3px, calc(var(--u) * .8));
|
||
border-radius: 0 0 4px 4px;
|
||
background: var(--accent);
|
||
box-shadow: 0 0 calc(var(--u) * 2.5) var(--accent);
|
||
}
|
||
|
||
.pad-view:not([data-conn="open"]) .touchpad::before { animation: pulse 1.1s ease-in-out infinite; }
|
||
.touchpad.clicked { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent) 70%, transparent), inset 0 -10px 24px rgba(0, 0, 0, .35), 0 -6px 34px -10px var(--accent), 0 10px 24px -8px rgba(0, 0, 0, .7); }
|
||
|
||
.pad-label {
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 10%;
|
||
padding: 0 8%;
|
||
text-align: center;
|
||
font-size: max(11px, calc(var(--u) * 2.5));
|
||
font-weight: 300;
|
||
line-height: 1.4;
|
||
color: rgba(236, 240, 248, .55);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.pad-label .muted { display: block; overflow: hidden; text-overflow: ellipsis; color: rgba(236, 240, 248, .36); }
|
||
|
||
.finger { position: absolute; width: calc(var(--u) * 7); height: calc(var(--u) * 7); margin: calc(var(--u) * -3.5) 0 0 calc(var(--u) * -3.5); border-radius: 50%; background: color-mix(in srgb, var(--accent) 45%, transparent); box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 85%, transparent), 0 0 20px var(--accent); pointer-events: none; }
|
||
|
||
/* PS button: round inset cap with the PlayStation mark (Tokyoship, CC BY 3.0). */
|
||
.ps-btn {
|
||
border-radius: 50%;
|
||
display: grid;
|
||
place-items: center;
|
||
color: rgba(236, 240, 248, .75);
|
||
background: radial-gradient(circle at 50% 30%, #414a5f 0%, #1a1f2b 72%);
|
||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .2), 0 0 0 calc(var(--u) * .6) #121620, 0 0 0 calc(var(--u) * .6 + 1px) rgba(255, 255, 255, .08), 0 6px 16px -4px rgba(0, 0, 0, .8);
|
||
transition: transform .08s var(--ease), filter .08s, box-shadow .12s, background .12s;
|
||
}
|
||
|
||
.ps-btn.on, .ps-btn:active {
|
||
transform: scale(.92);
|
||
color: #fff;
|
||
background: radial-gradient(circle at 50% 30%, color-mix(in srgb, var(--accent) 40%, #414a5f), color-mix(in srgb, var(--accent) 22%, #1a1f2b) 72%);
|
||
box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--accent) 75%, transparent), 0 0 0 calc(var(--u) * .6) #121620, 0 0 calc(var(--u) * 3) -1px var(--accent);
|
||
}
|
||
.ps-btn svg { width: 58%; height: 58%; pointer-events: none; }
|
||
|
||
/* Sticks: DualShock tops (hollow, with a ridged rim) in a sunken well that shows how far they move */
|
||
.stick {
|
||
border-radius: 50%;
|
||
background: radial-gradient(circle at 50% 40%, #1d2333, #121621 70%);
|
||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .07), inset 0 8px 20px -10px rgba(0, 0, 0, .9);
|
||
touch-action: none;
|
||
}
|
||
|
||
.stick .knob {
|
||
position: absolute;
|
||
left: 25%;
|
||
top: 25%;
|
||
width: 50%;
|
||
height: 50%;
|
||
border-radius: 50%;
|
||
background:
|
||
repeating-conic-gradient(rgba(255, 255, 255, .045) 0 4deg, transparent 4deg 8deg),
|
||
linear-gradient(180deg, #3f475b 0%, #252b39 55%, #161a24 100%);
|
||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .2), 0 0 0 1px rgba(0, 0, 0, .55), 0 8px 16px rgba(0, 0, 0, .7);
|
||
pointer-events: none;
|
||
will-change: transform;
|
||
transition: filter .08s, scale .08s;
|
||
}
|
||
|
||
/* The thumb dish: dark at the top and lit at the bottom, so it reads as hollow */
|
||
.stick .knob::before {
|
||
content: "";
|
||
position: absolute;
|
||
inset: 17%;
|
||
border-radius: 50%;
|
||
background: linear-gradient(180deg, #0e1118 0%, #1a1f2b 55%, #2b3242 100%);
|
||
box-shadow: 0 0 0 1px rgba(0, 0, 0, .45), 0 1px 0 1px rgba(255, 255, 255, .07);
|
||
}
|
||
|
||
.stick.on .knob { box-shadow: inset 0 1px 0 rgba(255, 255, 255, .2), 0 0 0 1.5px color-mix(in srgb, var(--accent) 80%, transparent), 0 0 calc(var(--u) * 3) -1px var(--accent), 0 8px 16px rgba(0, 0, 0, .7); }
|
||
.stick.clicked .knob { scale: .9; filter: brightness(1.35); }
|
||
|
||
.keyhint { position: absolute; right: 8%; bottom: 6%; font-size: max(9px, calc(var(--u) * 1.8)); line-height: 1; color: rgba(236, 240, 248, .45); pointer-events: none; display: none; }
|
||
.show-keys .keyhint { display: block; }
|
||
|
||
/* Back and full screen, out of the way in the top corners */
|
||
.corner-btn {
|
||
position: absolute;
|
||
z-index: 2;
|
||
top: max(8px, env(safe-area-inset-top));
|
||
width: 38px;
|
||
height: 38px;
|
||
display: grid;
|
||
place-items: center;
|
||
border: 0;
|
||
border-radius: 11px;
|
||
background: rgba(22, 27, 37, .85);
|
||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .08);
|
||
color: rgba(236, 240, 248, .72);
|
||
transition: background .15s, color .15s, transform .15s var(--ease);
|
||
}
|
||
|
||
.corner-btn:hover { background: rgba(32, 39, 53, .9); color: #fff; }
|
||
.corner-btn:active { transform: scale(.92); }
|
||
.corner-btn.left { left: max(8px, env(safe-area-inset-left)); }
|
||
/* A column, not a row: in landscape R2 starts about 68px from the right
|
||
edge, room for one button beside it, not two. */
|
||
.corner-group {
|
||
position: absolute;
|
||
z-index: 2;
|
||
top: max(8px, env(safe-area-inset-top));
|
||
right: max(8px, env(safe-area-inset-right));
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
.corner-group .corner-btn { position: static; }
|
||
.corner-btn svg { width: 18px; height: 18px; }
|
||
|
||
/* Layout editor: drag controls on a grid, change the selected one from the bar */
|
||
.pad-view.editing .pad-area {
|
||
background-image:
|
||
linear-gradient(rgba(255, 255, 255, .045) 1px, transparent 1px),
|
||
linear-gradient(90deg, rgba(255, 255, 255, .045) 1px, transparent 1px);
|
||
background-size: 5% 5%;
|
||
}
|
||
|
||
.editing .corner-btn { display: none; }
|
||
.editing .ctl { cursor: grab; outline: 1px dashed rgba(236, 240, 248, .32); outline-offset: 4px; }
|
||
.editing .ctl * { pointer-events: none; }
|
||
.editing.dragging .ctl { cursor: grabbing; }
|
||
.editing .ctl.selected { outline: 2px solid var(--accent); outline-offset: 4px; }
|
||
.editing .ctl.ctl-off { opacity: .35; }
|
||
|
||
.layout-bar {
|
||
position: absolute;
|
||
z-index: 3;
|
||
left: 50%;
|
||
top: max(8px, env(safe-area-inset-top));
|
||
transform: translateX(-50%);
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px 10px;
|
||
width: max-content;
|
||
max-width: calc(100% - 16px);
|
||
padding: 8px 10px;
|
||
border-radius: 14px;
|
||
background: rgba(16, 20, 28, .94);
|
||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .1), 0 12px 30px -10px rgba(0, 0, 0, .9);
|
||
font-size: 13px;
|
||
transition: opacity .15s;
|
||
}
|
||
|
||
/* Once dragged, the bar sits where it was dropped (left/top set by the script) */
|
||
.layout-bar.moved { transform: none; }
|
||
.layout-panel { top: 0; left: 0; transform: none; flex-wrap: nowrap; }
|
||
.dragging .layout-bar { opacity: .12; pointer-events: none; }
|
||
.dragging .layout-panel { visibility: hidden; }
|
||
.lb-grip { width: 24px; height: 32px; display: grid; place-items: center; color: rgba(236, 240, 248, .5); cursor: grab; touch-action: none; }
|
||
.lb-grip svg { width: 18px; height: 18px; pointer-events: none; }
|
||
.lb-title { font-weight: 500; white-space: nowrap; }
|
||
.lb-size { display: inline-flex; align-items: center; gap: 8px; color: rgba(236, 240, 248, .75); white-space: nowrap; }
|
||
.lb-size input { width: 100px; accent-color: var(--accent); }
|
||
|
||
.lb-btn {
|
||
height: 32px;
|
||
padding: 0 12px;
|
||
border: 0;
|
||
border-radius: 9px;
|
||
background: rgba(255, 255, 255, .08);
|
||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .1);
|
||
color: #e7eaf0;
|
||
font-size: 13px;
|
||
white-space: nowrap;
|
||
transition: background .15s, transform .1s var(--ease);
|
||
}
|
||
|
||
.lb-btn:hover { background: rgba(255, 255, 255, .14); }
|
||
.lb-btn:active { transform: scale(.96); }
|
||
.lb-btn.primary { background: color-mix(in srgb, var(--accent) 70%, #1d2331); box-shadow: none; color: #fff; }
|
||
.lb-btn.warn { background: rgba(242, 114, 127, .22); box-shadow: inset 0 0 0 1px rgba(242, 114, 127, .6); }
|
||
.lb-btn.icon { width: 32px; padding: 0; display: grid; place-items: center; }
|
||
.lb-btn svg { width: 16px; height: 16px; }
|
||
|
||
.rotate { position: fixed; inset: 0; z-index: 5; display: none; place-content: center; justify-items: center; gap: 14px; padding: 24px; text-align: center; background: rgba(11, 14, 20, .96); }
|
||
.rotate svg { width: 56px; height: 56px; color: #fff; animation: tilt 2.4s var(--ease) infinite; }
|
||
.rotate strong { font-size: 20px; font-weight: 300; }
|
||
.rotate p { margin: 0; color: var(--muted); font-size: 14px; font-weight: 300; max-width: 30ch; line-height: 1.5; }
|
||
@keyframes tilt { 0%, 30% { transform: rotate(0); } 60%, 100% { transform: rotate(-90deg); } }
|
||
@media (orientation: portrait) and (max-width: 820px) { .pad-view:not(.portrait-ok) .rotate { display: grid; } }
|
||
|
||
/* ---------- Settings ---------- */
|
||
|
||
.settings-view { position: fixed; inset: 0; z-index: 3; display: grid; grid-template-rows: auto 1fr; }
|
||
.settings-view .backdrop { z-index: -1; }
|
||
|
||
.settings-body {
|
||
display: grid;
|
||
grid-template-columns: minmax(200px, 260px) 1fr;
|
||
gap: 26px;
|
||
min-height: 0;
|
||
width: 100%;
|
||
max-width: 1120px;
|
||
margin: 0 auto;
|
||
padding: 10px max(22px, env(safe-area-inset-right)) 0 max(22px, env(safe-area-inset-left));
|
||
}
|
||
|
||
.settings-nav { display: flex; flex-direction: column; gap: 6px; padding-top: 8px; }
|
||
|
||
.nav-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
height: 52px;
|
||
padding: 0 16px;
|
||
border: 0;
|
||
border-radius: 4px;
|
||
background: transparent;
|
||
color: var(--muted);
|
||
font-size: 16px;
|
||
font-weight: 300;
|
||
text-align: left;
|
||
transition: background .2s, box-shadow .2s, color .2s;
|
||
}
|
||
|
||
.nav-item svg { width: 22px; height: 22px; flex: none; }
|
||
.nav-item:hover { color: #fff; background: rgba(255, 255, 255, .08); }
|
||
.nav-item[aria-selected="true"] { color: #fff; background: rgba(255, 255, 255, .14); box-shadow: var(--glow); }
|
||
|
||
/* No bottom padding: a sticky save bar keeps that much distance from the bottom edge */
|
||
.settings-pages { min-height: 0; overflow-y: auto; padding: 8px 4px 0; touch-action: pan-y; -webkit-overflow-scrolling: touch; }
|
||
.page-title { margin: 0 0 4px; font-size: 26px; font-weight: 300; }
|
||
|
||
.rows { margin-top: 16px; border-radius: 6px; overflow: hidden; background: var(--panel); box-shadow: inset 0 0 0 1px var(--line-soft); }
|
||
|
||
.row {
|
||
display: grid;
|
||
grid-template-columns: 1fr auto;
|
||
align-items: center;
|
||
gap: 6px 18px;
|
||
min-height: 60px;
|
||
padding: 12px 18px;
|
||
border-top: 1px solid var(--line-soft);
|
||
transition: background .2s, box-shadow .2s;
|
||
}
|
||
|
||
.row:first-child { border-top: 0; }
|
||
.row:hover { background: rgba(255, 255, 255, .05); }
|
||
.row-label { font-size: 16px; font-weight: 400; }
|
||
.row-desc { grid-column: 1; font-size: 13px; color: var(--muted); font-weight: 300; line-height: 1.45; }
|
||
label.row { cursor: pointer; }
|
||
|
||
.check {
|
||
grid-column: 2;
|
||
grid-row: 1 / span 2;
|
||
appearance: none;
|
||
width: 26px;
|
||
height: 26px;
|
||
margin: 0;
|
||
border-radius: 3px;
|
||
background: rgba(0, 0, 0, .25);
|
||
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, .75);
|
||
display: grid;
|
||
place-items: center;
|
||
cursor: pointer;
|
||
transition: background .15s, box-shadow .15s;
|
||
}
|
||
|
||
.check::after { content: ""; width: 7px; height: 13px; margin-top: -3px; border: solid #fff; border-width: 0 3px 3px 0; transform: rotate(45deg) scale(0); transition: transform .15s var(--ease); }
|
||
.check:checked { background: #1f6fe6; box-shadow: inset 0 0 0 2px #fff; }
|
||
.check:checked::after { transform: rotate(45deg) scale(1); }
|
||
.check:disabled, .field:disabled { opacity: .5; cursor: not-allowed; }
|
||
.check:focus-visible { box-shadow: inset 0 0 0 2px #fff, var(--glow); }
|
||
|
||
/* Settings → Experimental */
|
||
.exp-block { margin-top: 26px; }
|
||
.exp-title { margin: 0; font-size: 18px; font-weight: 400; }
|
||
.row-desc.blocked { color: var(--warn); }
|
||
.row-desc a { color: inherit; text-underline-offset: 3px; }
|
||
|
||
.pad-card { margin-top: 16px; border-radius: 6px; background: var(--panel); box-shadow: inset 0 0 0 1px var(--line-soft); overflow: hidden; }
|
||
.pad-card-head { --pc: #3a8cff; display: flex; align-items: center; gap: 14px; padding: 14px 18px; border-bottom: 1px solid var(--line-soft); }
|
||
.pad-card-head .ring { width: 40px; height: 40px; font-size: 18px; }
|
||
.pad-card-head h3 { margin: 0; flex: 1; font-size: 18px; font-weight: 400; }
|
||
.pad-card-head .row-desc { font-size: 12.5px; }
|
||
.pad-card .row .field { width: min(320px, 46vw); }
|
||
.pad-card .row.stack { grid-template-columns: 1fr; }
|
||
.pad-card .row.stack .field { width: 100%; }
|
||
|
||
.switch-inline { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 14px; }
|
||
.switch-inline .check { grid-column: auto; grid-row: auto; }
|
||
|
||
.save-bar {
|
||
position: sticky;
|
||
bottom: 0;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
gap: 12px;
|
||
margin-top: 18px;
|
||
padding: 14px 18px max(14px, env(safe-area-inset-bottom));
|
||
border-radius: 6px;
|
||
background: var(--panel-strong);
|
||
box-shadow: inset 0 0 0 1px var(--line), 0 -10px 30px -10px rgba(0, 0, 0, .7);
|
||
}
|
||
|
||
.save-bar .save-status { flex: 1; min-width: 180px; font-size: 13.5px; color: var(--muted); font-weight: 300; }
|
||
.save-bar .field { width: auto; }
|
||
|
||
.key-btn { min-width: 110px; height: 36px; padding: 0 14px; border: 0; border-radius: 4px; background: rgba(255, 255, 255, .1); box-shadow: inset 0 0 0 1px var(--line); font-size: 14px; }
|
||
.key-btn:hover { box-shadow: var(--glow); }
|
||
.key-btn.listening { background: #fff; color: #06215a; animation: pulse 1s ease-in-out infinite; }
|
||
/* ---------- Mapping: a DualShock 4 to map a gamepad or the keyboard onto ---------- */
|
||
|
||
.map-view { position: fixed; inset: 0; z-index: 4; display: flex; flex-direction: column; overflow-y: auto; overscroll-behavior: contain; }
|
||
.map-view .backdrop { z-index: -1; position: fixed; }
|
||
|
||
.map-top {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: flex-start;
|
||
gap: 12px 16px;
|
||
padding: max(10px, env(safe-area-inset-top)) max(22px, env(safe-area-inset-right)) 12px max(22px, env(safe-area-inset-left));
|
||
}
|
||
|
||
.map-what { display: grid; gap: 4px; min-width: 0; flex: 1 1 280px; }
|
||
.map-what .field { width: 100%; }
|
||
.map-source { height: 42px; max-width: 440px; font-size: 16px; }
|
||
.map-sub { font-size: 12.5px; color: var(--muted); font-weight: 300; }
|
||
.map-profile { display: flex; align-items: flex-end; flex-wrap: wrap; gap: 10px; }
|
||
.map-label { display: grid; gap: 4px; font-size: 12px; line-height: 16px; color: var(--muted); }
|
||
.map-profile .map-label .field { width: 200px; height: 42px; font-size: 14px; }
|
||
.map-top > .icon-btn { margin-top: 20px; }
|
||
.map-top .icon-btn { width: 42px; height: 42px; }
|
||
.map-menu-wrap { position: relative; }
|
||
|
||
.map-menu {
|
||
position: absolute;
|
||
right: 0;
|
||
top: calc(100% + 6px);
|
||
z-index: 6;
|
||
display: grid;
|
||
min-width: 240px;
|
||
padding: 6px;
|
||
border-radius: 8px;
|
||
background: var(--panel-strong);
|
||
box-shadow: inset 0 0 0 1px var(--line), 0 18px 40px -12px rgba(0, 0, 0, .9);
|
||
}
|
||
|
||
.map-menu button, .map-file { display: block; padding: 10px 12px; border: 0; border-radius: 5px; background: none; color: var(--text); font-size: 14px; text-align: left; cursor: pointer; transition: background .15s; }
|
||
.map-menu button:hover, .map-file:hover { background: rgba(255, 255, 255, .1); }
|
||
.map-menu button:disabled { opacity: .4; pointer-events: none; }
|
||
.map-menu .danger { color: var(--bad); }
|
||
.map-file input { display: none; }
|
||
.map-save { display: flex; align-items: center; gap: 10px; margin-left: auto; padding-top: 20px; }
|
||
.map-save .btn { height: 42px; }
|
||
.map-dirty { font-size: 13px; color: var(--warn); }
|
||
|
||
.map-banner {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 10px 14px;
|
||
margin: 0 max(22px, env(safe-area-inset-right)) 10px max(22px, env(safe-area-inset-left));
|
||
padding: 10px 14px;
|
||
border-radius: 6px;
|
||
background: rgba(255, 208, 77, .1);
|
||
box-shadow: inset 0 0 0 1px rgba(255, 208, 77, .4);
|
||
font-size: 14px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.map-tabs { display: flex; gap: 6px; padding: 0 max(22px, env(safe-area-inset-right)) 12px max(22px, env(safe-area-inset-left)); }
|
||
.map-tabs button { height: 36px; padding: 0 16px; border: 0; border-radius: 4px; background: transparent; color: var(--muted); font-size: 14px; transition: background .2s, color .2s, box-shadow .2s; }
|
||
.map-tabs button:hover { color: #fff; background: rgba(255, 255, 255, .08); }
|
||
.map-tabs button[aria-selected="true"] { color: #06215a; background: #fff; }
|
||
|
||
.map-body {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) 360px;
|
||
align-items: start;
|
||
gap: 18px;
|
||
width: 100%;
|
||
max-width: 1400px;
|
||
margin: 0 auto;
|
||
padding: 0 max(22px, env(safe-area-inset-right)) 8px max(22px, env(safe-area-inset-left));
|
||
}
|
||
|
||
@media (max-width: 900px) { .map-body { grid-template-columns: 1fr; } }
|
||
|
||
.map-stage {
|
||
padding: 10px;
|
||
border-radius: 10px;
|
||
background:
|
||
radial-gradient(75% 95% at 10% -15%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 62%),
|
||
#0b0e14;
|
||
box-shadow: inset 0 0 0 1px var(--line-soft), 0 16px 34px -18px rgba(0, 0, 0, .9);
|
||
}
|
||
|
||
.map-pad { position: relative; width: 100%; max-width: calc(64dvh * 2); aspect-ratio: 2 / 1; margin: 0 auto; }
|
||
.map-pad .pad-area { inset: 0; }
|
||
.map-area .ctl { cursor: pointer; }
|
||
.map-area .map-unset { opacity: .4; }
|
||
.map-area .map-sel:not(.arm) { outline: 2px solid var(--accent); outline-offset: 5px; }
|
||
.map-area .arm.map-sel { stroke: #fff; stroke-width: 3; }
|
||
.map-chips { position: absolute; inset: 0; z-index: 2; pointer-events: none; }
|
||
|
||
.map-chip {
|
||
position: absolute;
|
||
max-width: 128px;
|
||
padding: 2px 7px;
|
||
border: 0;
|
||
border-radius: 5px;
|
||
background: rgba(2, 11, 31, .9);
|
||
box-shadow: inset 0 0 0 1px var(--line);
|
||
color: #fff;
|
||
font-size: 11.5px;
|
||
font-variant-numeric: tabular-nums;
|
||
line-height: 1.4;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
transform: translate(-50%, -50%);
|
||
pointer-events: auto;
|
||
transition: box-shadow .15s, background .15s;
|
||
}
|
||
|
||
.map-chip:hover { box-shadow: var(--glow); }
|
||
.map-chip.unset { color: var(--faint); box-shadow: inset 0 0 0 1px var(--line-soft); }
|
||
.map-chip.sel { background: #fff; color: #06215a; }
|
||
.map-chip.listening { background: var(--accent); animation: pulse 1s ease-in-out infinite; }
|
||
|
||
.map-panel {
|
||
display: grid;
|
||
align-content: start;
|
||
gap: 12px;
|
||
padding: 18px;
|
||
border-radius: 8px;
|
||
background: var(--panel);
|
||
box-shadow: inset 0 0 0 1px var(--line-soft);
|
||
backdrop-filter: blur(10px);
|
||
-webkit-backdrop-filter: blur(10px);
|
||
}
|
||
|
||
.map-panel-title { margin: 0; font-size: 22px; font-weight: 300; letter-spacing: .01em; }
|
||
.map-panel .hint { margin: 0; }
|
||
.map-rows { display: grid; gap: 8px; }
|
||
.map-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 44px; padding: 6px 10px; border-radius: 5px; background: rgba(255, 255, 255, .05); font-size: 14px; }
|
||
.map-row-label { flex: 1; min-width: 0; }
|
||
.map-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||
.map-actions .btn { height: 40px; padding: 0 16px; font-size: 14px; }
|
||
.link-btn:disabled { opacity: .4; pointer-events: none; }
|
||
|
||
@keyframes listen { 50% { box-shadow: inset 0 0 0 1px var(--accent), 0 0 22px -4px var(--accent); } }
|
||
.map-listen { margin: 0; padding: 14px 16px; border-radius: 6px; background: color-mix(in srgb, var(--accent) 20%, transparent); box-shadow: inset 0 0 0 1px var(--accent); font-size: 15px; line-height: 1.5; animation: listen 1.4s ease-in-out infinite; }
|
||
.map-step { margin: 0; font-size: 12.5px; color: var(--muted); }
|
||
|
||
.map-section { display: grid; gap: 10px; padding-top: 12px; border-top: 1px solid var(--line-soft); }
|
||
.map-section h4 { margin: 0; font-size: 13px; font-weight: 400; color: var(--muted); letter-spacing: .03em; }
|
||
.map-section .hint { font-size: 12.5px; }
|
||
.map-slider { display: grid; grid-template-columns: 90px minmax(0, 1fr) 62px; align-items: center; gap: 10px; font-size: 14px; }
|
||
.map-slider input { width: 100%; accent-color: var(--accent); }
|
||
.map-slider output { text-align: right; font-size: 13px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||
.map-toggle { display: flex; align-items: center; justify-content: space-between; gap: 10px; font-size: 14px; cursor: pointer; }
|
||
.map-toggle .check { grid-column: auto; grid-row: auto; }
|
||
|
||
.map-stick { justify-self: center; width: 150px; height: 150px; overflow: visible; }
|
||
.map-stick .well { fill: rgba(0, 0, 0, .35); stroke: var(--line); }
|
||
.map-stick .dead { fill: rgba(255, 208, 77, .1); stroke: rgba(255, 208, 77, .6); stroke-dasharray: 3 3; }
|
||
.map-stick .cross { stroke: var(--line-soft); }
|
||
.map-stick .raw { fill: var(--faint); }
|
||
.map-stick .out { fill: var(--accent); stroke: #fff; stroke-width: 1.5; }
|
||
.map-curve { justify-self: start; width: 110px; height: 110px; padding: 6px; border-radius: 6px; background: rgba(0, 0, 0, .3); overflow: visible; }
|
||
.map-curve .grid { fill: none; stroke: var(--line); stroke-dasharray: 3 3; }
|
||
.map-curve .line { fill: none; stroke: var(--accent); stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
|
||
|
||
.map-bar { position: relative; height: 14px; border-radius: 7px; background: rgba(0, 0, 0, .35); box-shadow: inset 0 0 0 1px var(--line-soft); overflow: hidden; }
|
||
.map-bar.wide { height: 18px; border-radius: 9px; overflow: visible; }
|
||
.map-bar.wide::after { content: ""; position: absolute; left: 50%; top: -3px; bottom: -3px; width: 1px; background: var(--line); }
|
||
.map-bar-fill { position: absolute; inset: 0 auto 0 0; width: 0; background: var(--accent); }
|
||
.map-bar-dot { position: absolute; top: 50%; left: 50%; width: 16px; height: 16px; margin: -8px 0 0 -8px; border-radius: 50%; background: #fff; box-shadow: 0 0 12px var(--accent); }
|
||
|
||
.map-inputs { display: grid; gap: 10px; }
|
||
.in-head { margin: 6px 0 0; font-size: 13px; font-weight: 400; color: var(--muted); letter-spacing: .03em; }
|
||
.in-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); gap: 8px; }
|
||
|
||
.in-tile, .in-axis {
|
||
position: relative;
|
||
border: 0;
|
||
border-radius: 6px;
|
||
background: rgba(255, 255, 255, .06);
|
||
box-shadow: inset 0 0 0 1px var(--line-soft);
|
||
text-align: left;
|
||
overflow: hidden;
|
||
transition: box-shadow .15s, transform .1s;
|
||
}
|
||
|
||
.in-tile:hover, .in-axis:hover { box-shadow: var(--glow); }
|
||
.in-tile:active, .in-axis:active { transform: scale(.98); }
|
||
.in-tile { display: grid; align-content: end; gap: 2px; min-height: 66px; padding: 8px; }
|
||
.in-tile.on { box-shadow: inset 0 0 0 2px var(--accent), 0 0 16px -2px var(--accent); }
|
||
.in-fill { position: absolute; left: 0; right: 0; bottom: 0; height: 0; background: color-mix(in srgb, var(--accent) 35%, transparent); }
|
||
.in-name { position: relative; font-size: 15px; }
|
||
.in-use { position: relative; font-size: 11.5px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.in-axes { display: grid; gap: 8px; }
|
||
.in-axis { display: grid; grid-template-columns: minmax(120px, 190px) minmax(0, 1fr) 52px; grid-template-areas: "name bar value" "use use use"; align-items: center; gap: 4px 12px; padding: 10px 12px; }
|
||
.in-axis .in-name { grid-area: name; font-size: 14px; }
|
||
.in-axis .in-use { grid-area: use; }
|
||
.in-bar { grid-area: bar; position: relative; height: 10px; border-radius: 5px; background: rgba(0, 0, 0, .35); }
|
||
.in-bar::after { content: ""; position: absolute; left: 50%; top: -3px; bottom: -3px; width: 1px; background: var(--line); }
|
||
.in-dot { position: absolute; top: 50%; left: 50%; width: 12px; height: 12px; margin: -6px 0 0 -6px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 8px var(--accent); }
|
||
.in-value { grid-area: value; text-align: right; font-size: 13px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||
.in-axis.outside .in-dot { background: var(--faint); box-shadow: none; }
|
||
.in-tile.missing, .in-axis.missing { opacity: .4; }
|
||
|
||
.map-list { display: none; gap: 6px; margin-top: 10px; }
|
||
.map-list-item { display: flex; justify-content: space-between; gap: 10px; padding: 10px 12px; border: 0; border-radius: 5px; background: rgba(255, 255, 255, .06); color: var(--text); font-size: 14px; text-align: left; }
|
||
.map-list-item.sel { box-shadow: var(--glow); background: rgba(255, 255, 255, .14); }
|
||
.map-list-tag { color: var(--muted); font-variant-numeric: tabular-nums; }
|
||
@media (max-width: 700px) { .map-chips { display: none; } .map-list { display: grid; } }
|
||
|
||
.map-status { margin: 0; padding: 6px max(22px, env(safe-area-inset-right)) max(16px, env(safe-area-inset-bottom)) max(22px, env(safe-area-inset-left)); min-height: 2.8em; font-size: 14px; color: var(--muted); }
|
||
|
||
@media (max-width: 640px) {
|
||
.map-profile { width: 100%; }
|
||
.map-label { flex: 1 1 140px; }
|
||
.map-profile .map-label .field { width: 100%; }
|
||
.map-save { width: 100%; justify-content: flex-end; padding-top: 0; }
|
||
.in-axis { grid-template-columns: minmax(0, 1fr) 52px; grid-template-areas: "name value" "bar bar" "use use"; }
|
||
}
|
||
|
||
@media (max-width: 760px) {
|
||
.settings-body { grid-template-columns: 1fr; gap: 10px; }
|
||
.settings-nav { flex-direction: row; overflow-x: auto; gap: 6px; padding: 2px 2px 8px; }
|
||
.nav-item { height: 40px; padding: 0 14px; font-size: 14px; white-space: nowrap; }
|
||
.nav-item svg { display: none; }
|
||
.pad-card .row { grid-template-columns: 1fr; }
|
||
.pad-card .row .field { width: 100%; }
|
||
.row .check { grid-row: 1; }
|
||
}
|
||
|
||
/* ---------- Options menu (controller) ---------- */
|
||
|
||
.scrim { position: fixed; inset: 0; z-index: 10; background: rgba(1, 6, 18, .5); opacity: 0; pointer-events: none; transition: opacity .25s; }
|
||
.menu-open .scrim { opacity: 1; pointer-events: auto; }
|
||
|
||
.options-menu {
|
||
position: fixed;
|
||
z-index: 11;
|
||
top: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
width: min(380px, 92vw);
|
||
padding: max(18px, env(safe-area-inset-top)) max(18px, env(safe-area-inset-right)) 18px 18px;
|
||
background: linear-gradient(180deg, rgba(4, 22, 62, .97), rgba(2, 10, 30, .98));
|
||
box-shadow: -1px 0 0 var(--line), -30px 0 60px -30px rgba(0, 0, 0, .9);
|
||
transform: translateX(105%);
|
||
transition: transform .3s var(--ease);
|
||
overflow-y: auto;
|
||
touch-action: pan-y;
|
||
}
|
||
|
||
.options-menu.open { transform: none; }
|
||
.options-menu h2 { margin: 6px 0 14px; font-size: 22px; font-weight: 300; }
|
||
.options-menu .rows { margin-top: 0; }
|
||
.options-menu .actions { display: grid; gap: 10px; margin-top: 16px; }
|
||
.menu-close { position: absolute; top: max(14px, env(safe-area-inset-top)); right: 14px; }
|
||
|
||
/* Invite: the page's address as a QR code, in a panel opened on demand */
|
||
.invite-qr {
|
||
width: min(280px, 100%);
|
||
aspect-ratio: 1;
|
||
margin: 20px auto 14px;
|
||
border-radius: 14px;
|
||
background: #fff;
|
||
overflow: hidden;
|
||
}
|
||
.invite-qr svg { display: block; width: 100%; height: 100%; }
|
||
.invite-address { text-align: center; font-size: 18px; letter-spacing: .02em; word-break: break-all; user-select: all; }
|
||
|
||
/* ---------- Notification ---------- */
|
||
|
||
.toast {
|
||
position: fixed;
|
||
z-index: 20;
|
||
top: max(14px, env(safe-area-inset-top));
|
||
left: max(14px, env(safe-area-inset-left));
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
max-width: min(420px, 90vw);
|
||
padding: 12px 16px 12px 12px;
|
||
border-radius: 4px;
|
||
background: linear-gradient(90deg, rgba(10, 52, 130, .96), rgba(4, 20, 56, .96));
|
||
box-shadow: inset 3px 0 0 #fff, 0 16px 40px -16px rgba(0, 0, 0, .9);
|
||
font-size: 14px;
|
||
opacity: 0;
|
||
transform: translateX(-20px);
|
||
pointer-events: none;
|
||
transition: opacity .25s, transform .3s var(--ease);
|
||
}
|
||
|
||
.toast svg { width: 34px; height: 22px; flex: none; }
|
||
.toast.show { opacity: 1; transform: none; }
|
||
|
||
.keep-awake { position: fixed; left: 0; bottom: 0; width: 1px; height: 1px; opacity: .01; pointer-events: none; }
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
*, *::before, *::after { animation: none !important; transition: none !important; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="backdrop" aria-hidden="true">
|
||
<svg class="waves slow" viewBox="0 0 3200 400" preserveAspectRatio="none">
|
||
<defs>
|
||
<linearGradient id="wg1" x1="0" x2="1"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset=".5" stop-color="#bcd8ff" stop-opacity=".55"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>
|
||
</defs>
|
||
<path stroke="url(#wg1)" stroke-width="1.2" d="M0 230 C 200 150 400 310 800 230 S 1400 150 1600 230 C 1800 150 2000 310 2400 230 S 3000 150 3200 230"/>
|
||
<path stroke="url(#wg1)" stroke-width="2" opacity=".6" d="M0 260 C 250 200 550 330 800 260 S 1350 190 1600 260 C 1850 200 2150 330 2400 260 S 2950 190 3200 260"/>
|
||
</svg>
|
||
<svg class="waves" viewBox="0 0 3200 400" preserveAspectRatio="none">
|
||
<defs>
|
||
<linearGradient id="wg2" x1="0" x2="1"><stop offset="0" stop-color="#7fb2ff" stop-opacity=".1"/><stop offset=".5" stop-color="#fff" stop-opacity=".5"/><stop offset="1" stop-color="#7fb2ff" stop-opacity=".1"/></linearGradient>
|
||
</defs>
|
||
<path stroke="url(#wg2)" stroke-width="1.5" d="M0 200 C 300 110 500 300 800 200 S 1300 100 1600 200 C 1900 110 2100 300 2400 200 S 2900 100 3200 200"/>
|
||
<path stroke="url(#wg2)" stroke-width="1" opacity=".7" d="M0 215 C 300 140 520 290 800 215 S 1320 130 1600 215 C 1900 140 2120 290 2400 215 S 2920 130 3200 215"/>
|
||
<path stroke="url(#wg2)" stroke-width="3" opacity=".35" d="M0 240 C 280 170 540 330 800 240 S 1330 160 1600 240 C 1880 170 2140 330 2400 240 S 2930 160 3200 240"/>
|
||
</svg>
|
||
<div class="sparkles"></div>
|
||
</div>
|
||
|
||
<!-- Home: pick a controller -->
|
||
<div class="home" id="home">
|
||
<header class="topbar">
|
||
<div class="tb-group">
|
||
<div class="app-mark">
|
||
<svg viewBox="56 38 888 566" aria-hidden="true"><path fill="#fff" d="M500 78C410 78 330 74 262 62C232 46 188 42 146 62C108 80 86 120 78 170C70 230 58 320 60 400C62 470 80 535 118 568C152 598 196 592 222 560C250 525 272 470 300 430C360 418 430 440 500 440C570 440 640 418 700 430C728 470 750 525 778 560C804 592 848 598 882 568C920 535 938 470 940 400C942 320 930 230 922 170C914 120 892 80 854 62C812 42 768 46 738 62C670 74 590 78 500 78ZM366 98L634 98C648.359 98 660 109.641 660 124L660 226C660 240.359 648.359 252 634 252L366 252C351.641 252 340 240.359 340 226L340 124C340 109.641 351.641 98 366 98ZM173 148L219 148L219 189L260 189L260 235L219 235L219 276L173 276L173 235L132 235L132 189L173 189L173 148ZM804 121C818.912 121 831 133.088 831 148C831 162.912 818.912 175 804 175C789.088 175 777 162.912 777 148C777 133.088 789.088 121 804 121ZM804 249C818.912 249 831 261.088 831 276C831 290.912 818.912 303 804 303C789.088 303 777 290.912 777 276C777 261.088 789.088 249 804 249ZM740 185C754.912 185 767 197.088 767 212C767 226.912 754.912 239 740 239C725.088 239 713 226.912 713 212C713 197.088 725.088 185 740 185ZM868 185C882.912 185 895 197.088 895 212C895 226.912 882.912 239 868 239C853.088 239 841 226.912 841 212C841 197.088 853.088 185 868 185ZM338 276C374.451 276 404 305.549 404 342C404 378.451 374.451 408 338 408C301.549 408 272 378.451 272 342C272 305.549 301.549 276 338 276ZM338 298C313.699 298 294 317.699 294 342C294 366.301 313.699 386 338 386C362.301 386 382 366.301 382 342C382 317.699 362.301 298 338 298ZM662 276C698.451 276 728 305.549 728 342C728 378.451 698.451 408 662 408C625.549 408 596 378.451 596 342C596 305.549 625.549 276 662 276ZM662 298C637.699 298 618 317.699 618 342C618 366.301 637.699 386 662 386C686.301 386 706 366.301 706 342C706 317.699 686.301 298 662 298ZM500 319C509.389 319 517 326.611 517 336C517 345.389 509.389 353 500 353C490.611 353 483 345.389 483 336C483 326.611 490.611 319 500 319Z"/></svg>
|
||
<span>Control4Free</span>
|
||
</div>
|
||
<span class="conn" id="conn" data-conn="connecting" role="status" aria-live="polite"><span class="dot"></span><span id="connText">Connecting</span><span class="conn-detail" id="connDetail"></span></span>
|
||
</div>
|
||
<div class="tb-group">
|
||
<button class="icon-btn" type="button" id="openInvite" aria-label="Invite someone" title="Invite someone">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="3.5" width="6.5" height="6.5" rx="1.2"/><rect x="14" y="3.5" width="6.5" height="6.5" rx="1.2"/><rect x="3.5" y="14" width="6.5" height="6.5" rx="1.2"/><path d="M14 14h3v3M20.5 14v.01M14 20.5h3M20.5 17.5v3"/></svg>
|
||
</button>
|
||
<button class="icon-btn" type="button" id="openSettings" aria-label="Settings">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3.2"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg>
|
||
</button>
|
||
<span class="clock" data-clock></span>
|
||
</div>
|
||
</header>
|
||
|
||
<main class="home-main">
|
||
<form class="panel" id="addressForm" hidden>
|
||
<h2>Your PS4's address</h2>
|
||
<p class="hint" id="addressHint">This copy of the page was opened from a file, so it needs the address the Control4Free app shows on the PS4, like 192.168.1.20.</p>
|
||
<div class="address-row">
|
||
<input class="field" id="address" inputmode="url" autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="192.168.1.20" aria-label="Your PS4's address">
|
||
<button class="btn primary" type="submit">Connect</button>
|
||
</div>
|
||
</form>
|
||
|
||
<section aria-labelledby="padsTitle">
|
||
<h1 class="screen-title" id="padsTitle">Connect a controller</h1>
|
||
<p class="screen-sub">Select a controller, then choose its user on the PS4 with the D-pad and Cross. Connect one at a time. PS cancels the user-selection screen.</p>
|
||
<div class="tiles" id="padGrid"></div>
|
||
</section>
|
||
|
||
<section class="panel" aria-labelledby="gpTitle" id="gamepadSection">
|
||
<h2 id="gpTitle">Gamepads on this device</h2>
|
||
<p class="hint" id="gamepadHint"></p>
|
||
<ul class="gp-list" id="gamepadList"></ul>
|
||
<p class="gp-help" id="gamepadHelp" hidden>Several gamepads can share one controller. Keep this window active while playing.</p>
|
||
</section>
|
||
</main>
|
||
|
||
<footer class="home-foot">
|
||
<span id="version">Control4Free</span>
|
||
<button class="link-btn" type="button" id="keysLink">Keyboard keys</button>
|
||
</footer>
|
||
</div>
|
||
|
||
<!-- Controller: DualShock 4 buttons, each placed from DEFAULT_LAYOUT in the script -->
|
||
<div class="pad-view" id="padView" data-conn="connecting" hidden>
|
||
<button class="corner-btn left" id="backBtn" type="button" aria-label="Back to the controllers">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg>
|
||
</button>
|
||
<div class="corner-group">
|
||
<button class="corner-btn" id="padMenuBtn" type="button" aria-label="Controller menu">☰</button>
|
||
<button class="corner-btn" id="fsBtn" type="button" aria-label="Full screen">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg>
|
||
</button>
|
||
</div>
|
||
<div class="pad-area">
|
||
<button class="ctl trigger" data-ctl="l2" data-btn="L2" type="button"><span class="trigger-fill" data-fill="L2"></span><span class="engraved">L2</span><span class="keyhint" data-hint="L2"></span></button>
|
||
<button class="ctl bumper" data-ctl="l1" data-btn="L1" type="button"><span class="engraved">L1</span><span class="keyhint" data-hint="L1"></span></button>
|
||
<button class="ctl trigger" data-ctl="r2" data-btn="R2" type="button"><span class="trigger-fill" data-fill="R2"></span><span class="engraved">R2</span><span class="keyhint" data-hint="R2"></span></button>
|
||
<button class="ctl bumper" data-ctl="r1" data-btn="R1" type="button"><span class="engraved">R1</span><span class="keyhint" data-hint="R1"></span></button>
|
||
|
||
<button class="ctl capsule-btn" data-ctl="share" id="shareBtn" data-btn="SHARE" type="button" aria-label="Share"><span class="cap-label">SHARE</span><span class="capsule"></span></button>
|
||
<button class="ctl capsule-btn" data-ctl="options" data-btn="OPTIONS" type="button" aria-label="Options"><span class="cap-label">OPTIONS</span><span class="capsule"></span><span class="keyhint" data-hint="OPTIONS"></span></button>
|
||
|
||
<div class="ctl touchpad" data-ctl="touchpad" id="touchpad" aria-label="Touchpad"><span class="pad-label" id="padLabel"></span></div>
|
||
|
||
<button class="ctl ps-btn" data-ctl="ps" id="psBtn" data-btn="PS" type="button" aria-label="PS button">
|
||
<svg viewBox="1240 1136 378 296" fill="currentColor" fill-rule="evenodd" aria-hidden="true">
|
||
<path d="m1442.9581 1196.8425-.242 234.3159-59.4601-20.4178-.4185-274.2048 75.8973 21.1864c48.5826 13.7861 78.3137 40.5845 77.8302 87.5507-.4829 54.6248-25.8649 75.8164-68.8533 55.8404v-101.2762c0-14.941-24.7536-25.8506-24.7536-2.9946z"/>
|
||
<path d="m1370.7136 1303.1522-8.9415 2.8809-86.5464 31.0437c-18.2193 8.3547-33.4371 13.7582-33.5335 27.3708.4569 17.2306 27.4593 25.6141 66.9974 32.2183 21.0698 4.2232 42.5958 1.3924 61.026-2.5123l.015-32.4651s-34.1651 13.278-42.2947 14.3829l-16.4389-.09c-12.2037-.067-17.8867-7.8843-5.7302-12.0759l65.4355-24.7626.011-35.9902z"/>
|
||
<path d="m1524.5024 1317.7768c-22.722-.075-54.1883 6.1167-69.7875 13.3735v37.0026l41.1316-15.041 32.581-10.3583c33.9325-5.5872 47.4232 6.2615 14.3731 16.1649l-88.0833 32.2321-.1072 35.8824 128.695-46.2998c14.2026-4.9527 34.177-14.9519 34.225-27.2419-3.4472-19.028-32.0697-30.1323-76.5199-34.8392-3.9869-.4111-7.9933-.6688-11.9728-.7879-1.5161-.044-3.0225-.083-4.5372-.088z"/>
|
||
</svg>
|
||
</button>
|
||
|
||
<div class="ctl dpad" data-ctl="dpad" id="dpad" aria-label="Directional buttons">
|
||
<svg viewBox="-128 -128 256 256" aria-hidden="true">
|
||
<defs>
|
||
<linearGradient id="dpadArm" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#3d4558"/><stop offset=".5" stop-color="#232a38"/><stop offset="1" stop-color="#161a24"/></linearGradient>
|
||
</defs>
|
||
<circle class="pivot" r="34"/>
|
||
<path class="mark" d="M0-122l12 14h-24zM0 122l12-14h-24zM-122 0l14 12v-24zM122 0l-14 12v-24z"/>
|
||
<path class="arm" data-arm="UP" d="M0-9L30-39V-88Q30-100 18-100H-18Q-30-100-30-88V-39Z"/>
|
||
<path class="arm" data-arm="RIGHT" d="M9 0L39 30H88Q100 30 100 18V-18Q100-30 88-30H39Z"/>
|
||
<path class="arm" data-arm="DOWN" d="M0 9L-30 39V88Q-30 100-18 100H18Q30 100 30 88V39Z"/>
|
||
<path class="arm" data-arm="LEFT" d="M-9 0L-39-30H-88Q-100-30-100-18V18Q-100 30-88 30H-39Z"/>
|
||
</svg>
|
||
</div>
|
||
|
||
<div class="ctl face" data-ctl="face">
|
||
<button class="face-btn tri" data-btn="TRIANGLE" data-slide type="button" aria-label="Triangle"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"><path d="M12 4.6l7.2 12.4H4.8z"/></svg><span class="keyhint" data-hint="TRIANGLE"></span></button>
|
||
<button class="face-btn cir" data-btn="CIRCLE" data-slide type="button" aria-label="Circle"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><circle cx="12" cy="12" r="6.6"/></svg><span class="keyhint" data-hint="CIRCLE"></span></button>
|
||
<button class="face-btn crs" data-btn="CROSS" data-slide type="button" aria-label="Cross"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M6.6 6.6l10.8 10.8M17.4 6.6L6.6 17.4"/></svg><span class="keyhint" data-hint="CROSS"></span></button>
|
||
<button class="face-btn sqr" data-btn="SQUARE" data-slide type="button" aria-label="Square"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"><rect x="6.4" y="6.4" width="11.2" height="11.2"/></svg><span class="keyhint" data-hint="SQUARE"></span></button>
|
||
</div>
|
||
|
||
<div class="ctl stick" data-ctl="lstick" data-stick="L" aria-label="Left stick"><div class="knob"></div></div>
|
||
<div class="ctl stick" data-ctl="rstick" data-stick="R" aria-label="Right stick"><div class="knob"></div></div>
|
||
<button class="ctl stick-btn" data-ctl="l3" data-btn="L3" type="button" aria-label="L3"><span class="engraved">L3</span><span class="keyhint" data-hint="L3"></span></button>
|
||
<button class="ctl stick-btn" data-ctl="r3" data-btn="R3" type="button" aria-label="R3"><span class="engraved">R3</span><span class="keyhint" data-hint="R3"></span></button>
|
||
</div>
|
||
|
||
<!-- Layout editor: the bar can be dragged by its grip; the panel follows the selected control -->
|
||
<div class="layout-bar" id="layoutBar" hidden>
|
||
<span class="lb-grip" id="lbGrip" role="button" aria-label="Move this bar">
|
||
<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.6"/><circle cx="15" cy="6" r="1.6"/><circle cx="9" cy="12" r="1.6"/><circle cx="15" cy="12" r="1.6"/><circle cx="9" cy="18" r="1.6"/><circle cx="15" cy="18" r="1.6"/></svg>
|
||
</span>
|
||
<button class="lb-btn" type="button" id="lbReset">Reset to default</button>
|
||
<button class="lb-btn" type="button" id="lbCancel">Cancel</button>
|
||
<button class="lb-btn primary" type="button" id="lbDone">Done</button>
|
||
</div>
|
||
|
||
<div class="layout-bar layout-panel" id="layoutPanel" hidden>
|
||
<span class="lb-title" id="lbTitle"></span>
|
||
<label class="lb-size">Size <input type="range" id="lbSize" min="50" max="200" step="5"></label>
|
||
<button class="lb-btn" type="button" id="lbHide">Hide</button>
|
||
</div>
|
||
|
||
<div class="rotate">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="2.5" width="12" height="19" rx="2.5"/><path d="M11 18.5h2"/></svg>
|
||
<strong>Turn your phone sideways</strong>
|
||
<p>The controller needs landscape mode.</p>
|
||
<button class="link-btn" type="button" id="portraitOk">Use it upright anyway</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Settings -->
|
||
<div class="settings-view" id="settingsView" hidden>
|
||
<div class="backdrop" aria-hidden="true"></div>
|
||
<header class="topbar">
|
||
<div class="tb-group">
|
||
<button class="icon-btn" id="settingsBack" type="button" aria-label="Back">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg>
|
||
</button>
|
||
<span class="app-mark">Settings</span>
|
||
</div>
|
||
<span class="clock" data-clock></span>
|
||
</header>
|
||
<div class="settings-body">
|
||
<nav class="settings-nav" role="tablist" aria-label="Settings">
|
||
<button class="nav-item" role="tab" data-page="device" aria-selected="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="7" y="2.5" width="10" height="19" rx="2"/><path d="M11 18.5h2"/></svg>This device</button>
|
||
<button class="nav-item" role="tab" data-page="keyboard" aria-selected="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="2.5" y="6" width="19" height="12" rx="2"/><path d="M6 10h1M9.5 10h1M13 10h1M16.5 10h1M7 14h10"/></svg>Keyboard</button>
|
||
<button class="nav-item" role="tab" data-page="gamepads" aria-selected="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"><path d="M7 7h10a5 5 0 0 1 4.8 6.4l-1 3.4a2.6 2.6 0 0 1-4.4 1.2L14.4 16H9.6l-2 2a2.6 2.6 0 0 1-4.4-1.2l-1-3.4A5 5 0 0 1 7 7z"/><path d="M7.5 10v3M6 11.5h3"/></svg>Gamepads</button>
|
||
<button class="nav-item" role="tab" data-page="experimental" aria-selected="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3h6M10 3v6.5l-5.4 8.7A1.9 1.9 0 0 0 6.2 21h11.6a1.9 1.9 0 0 0 1.6-2.8L14 9.5V3"/><path d="M7.4 15h9.2"/></svg>Experimental</button>
|
||
</nav>
|
||
|
||
<div class="settings-pages">
|
||
<section data-page="gamepads" hidden>
|
||
<h2 class="page-title">Gamepads</h2>
|
||
<p class="hint">Choose what each button and stick on a gamepad does. Mappings are saved in this browser, for every gamepad of the same kind.</p>
|
||
<ul class="gp-list" id="gpSettings"></ul>
|
||
<p class="hint" id="gpSettingsEmpty">Connect a gamepad to this device and press a button on it. If it never shows up, this browser only allows gamepads on secure pages: save this page, open the saved file, and enter the console's address.</p>
|
||
</section>
|
||
<section data-page="device" hidden>
|
||
<h2 class="page-title">This device</h2>
|
||
<p class="hint">Saved on this phone or computer only.</p>
|
||
<div class="rows" id="deviceRows"></div>
|
||
</section>
|
||
|
||
<section data-page="experimental" hidden>
|
||
<h2 class="page-title">Experimental</h2>
|
||
<p class="hint">Extras that use browser features most pages don't get, or that PS4 games never ask for. Each one stays off until you turn it on here, and it's saved on this phone or computer only.</p>
|
||
<div class="rows" id="expRows"></div>
|
||
<div class="exp-block" id="expSony" hidden>
|
||
<h3 class="exp-title">Sony controllers</h3>
|
||
<p class="hint">Link a DualShock 4 or DualSense connected to this computer. The browser asks which controller to allow. A linked controller lights up in its PS4 controller's colour, rumbles directly, and its touchpad works with fingers, not only clicks.</p>
|
||
<ul class="gp-list" id="expPads"></ul>
|
||
<p class="hint" id="expPadsEmpty">Connect a DualShock 4 or DualSense to this computer and press a button on it.</p>
|
||
</div>
|
||
<div class="exp-block" id="expMotion" hidden>
|
||
<h3 class="exp-title">Motion</h3>
|
||
<p class="hint" id="expMotionHint"></p>
|
||
<div class="map-actions" style="margin-top:12px"><button class="btn" type="button" id="expRecenter">Recenter</button></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section data-page="keyboard" hidden>
|
||
<h2 class="page-title">Keyboard</h2>
|
||
<p class="hint">Keys drive the controller open on this page. Choose them on a picture of the controller: click a control, then press its key.</p>
|
||
<div class="map-actions" style="margin-top:16px"><button class="btn primary" type="button" id="mapKeys">Choose keys</button><button class="btn" type="button" id="resetKeys">Reset keys</button></div>
|
||
</section>
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Mapping: a DualShock 4 to map a gamepad or the keyboard onto -->
|
||
<div class="map-view" id="mapView" hidden>
|
||
<div class="backdrop" aria-hidden="true"></div>
|
||
<header class="map-top">
|
||
<button class="icon-btn" id="mapBack" type="button" aria-label="Back"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg></button>
|
||
<div class="map-what">
|
||
<label class="map-label">Input device<select class="field map-source" id="mapSource"></select></label>
|
||
<span class="map-sub" id="mapSub"></span>
|
||
</div>
|
||
<div class="map-profile" id="mapProfileBox">
|
||
<label class="map-label">Profile<select class="field" id="mapProfile"></select></label>
|
||
<label class="map-label">Name<input class="field" id="mapName" maxlength="100" autocomplete="off" spellcheck="false"></label>
|
||
<div class="map-menu-wrap">
|
||
<button class="icon-btn" id="mapMenuBtn" type="button" aria-label="More profile options"><svg viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg></button>
|
||
<div class="map-menu" id="mapMenu" hidden>
|
||
<button type="button" id="mapSaveNew">Save as a new profile</button>
|
||
<button type="button" id="mapNew">New empty profile</button>
|
||
<button type="button" id="mapStandard">Back to the standard layout</button>
|
||
<button type="button" id="mapExport">Export to a file</button>
|
||
<label class="map-file">Import from a file<input type="file" id="mapImport" accept="application/json,.json"></label>
|
||
<button type="button" id="mapDelete" class="danger">Delete this profile</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="map-save">
|
||
<span class="map-dirty" id="mapDirty"></span>
|
||
<button class="btn" type="button" id="mapReset">Standard keys</button>
|
||
<button class="btn primary" type="button" id="mapSave">Save</button>
|
||
</div>
|
||
</header>
|
||
<p class="map-banner" id="mapBanner" hidden></p>
|
||
<nav class="map-tabs" id="mapTabs" role="tablist" aria-label="View"><button type="button" role="tab" data-tab="pad">DS4 Mapping</button><button type="button" role="tab" data-tab="inputs">Gamepad Controls</button></nav>
|
||
<div class="map-body">
|
||
<div>
|
||
<div class="map-stage" id="mapStage"><div class="map-pad" id="mapPad"><div class="map-chips" id="mapChips"></div></div><div class="map-list" id="mapList"></div></div>
|
||
<div class="map-inputs" id="mapInputs" hidden></div>
|
||
</div>
|
||
<aside class="map-panel" id="mapPanel"></aside>
|
||
</div>
|
||
<p class="map-status" id="mapStatus" role="status" aria-live="polite"></p>
|
||
</div>
|
||
|
||
<!-- Options menu on the controller -->
|
||
<div class="scrim" id="scrim"></div>
|
||
<aside class="options-menu" id="optionsMenu" aria-label="Options">
|
||
<button class="icon-btn menu-close" type="button" id="closeOptions" aria-label="Close">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
|
||
</button>
|
||
<h2>Options</h2>
|
||
<div class="rows" id="menuRows"></div>
|
||
<div class="actions">
|
||
<button class="btn" type="button" id="menuLayout">Edit the button layout</button>
|
||
<button class="btn" type="button" id="menuInvite">Invite someone</button>
|
||
<button class="btn" type="button" id="menuSettings">All settings</button>
|
||
<button class="btn danger" type="button" id="leaveBtn">Disconnect controller</button>
|
||
<button class="btn danger" type="button" id="stopBtn">Stop Control4Free</button>
|
||
</div>
|
||
</aside>
|
||
|
||
<aside class="options-menu" id="inviteSheet" aria-label="Invite someone">
|
||
<button class="icon-btn menu-close" type="button" id="closeInvite" aria-label="Close">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
|
||
</button>
|
||
<h2>Invite someone</h2>
|
||
<p class="hint">Scan this with a phone's camera to open the controller page. The phone has to be on the same network as the PS4.</p>
|
||
<div class="invite-qr" id="inviteQr"></div>
|
||
<p class="invite-address" id="inviteAddress"></p>
|
||
</aside>
|
||
|
||
<div class="toast" id="toast" role="status" aria-live="polite">
|
||
<svg viewBox="56 38 888 566" aria-hidden="true"><path fill="#fff" d="M500 78C410 78 330 74 262 62C232 46 188 42 146 62C108 80 86 120 78 170C70 230 58 320 60 400C62 470 80 535 118 568C152 598 196 592 222 560C250 525 272 470 300 430C360 418 430 440 500 440C570 440 640 418 700 430C728 470 750 525 778 560C804 592 848 598 882 568C920 535 938 470 940 400C942 320 930 230 922 170C914 120 892 80 854 62C812 42 768 46 738 62C670 74 590 78 500 78ZM366 98L634 98C648.359 98 660 109.641 660 124L660 226C660 240.359 648.359 252 634 252L366 252C351.641 252 340 240.359 340 226L340 124C340 109.641 351.641 98 366 98ZM173 148L219 148L219 189L260 189L260 235L219 235L219 276L173 276L173 235L132 235L132 189L173 189L173 148ZM804 121C818.912 121 831 133.088 831 148C831 162.912 818.912 175 804 175C789.088 175 777 162.912 777 148C777 133.088 789.088 121 804 121ZM804 249C818.912 249 831 261.088 831 276C831 290.912 818.912 303 804 303C789.088 303 777 290.912 777 276C777 261.088 789.088 249 804 249ZM740 185C754.912 185 767 197.088 767 212C767 226.912 754.912 239 740 239C725.088 239 713 226.912 713 212C713 197.088 725.088 185 740 185ZM868 185C882.912 185 895 197.088 895 212C895 226.912 882.912 239 868 239C853.088 239 841 226.912 841 212C841 197.088 853.088 185 868 185ZM338 276C374.451 276 404 305.549 404 342C404 378.451 374.451 408 338 408C301.549 408 272 378.451 272 342C272 305.549 301.549 276 338 276ZM338 298C313.699 298 294 317.699 294 342C294 366.301 313.699 386 338 386C362.301 386 382 366.301 382 342C382 317.699 362.301 298 338 298ZM662 276C698.451 276 728 305.549 728 342C728 378.451 698.451 408 662 408C625.549 408 596 378.451 596 342C596 305.549 625.549 276 662 276ZM662 298C637.699 298 618 317.699 618 342C618 366.301 637.699 386 662 386C686.301 386 706 366.301 706 342C706 317.699 686.301 298 662 298ZM500 319C509.389 319 517 326.611 517 336C517 345.389 509.389 353 500 353C490.611 353 483 345.389 483 336C483 326.611 490.611 319 500 319Z"/></svg>
|
||
<span id="toastText"></span>
|
||
</div>
|
||
|
||
<script>
|
||
(() => {
|
||
'use strict';
|
||
|
||
const PADS = 4;
|
||
const B = {
|
||
SHARE: 0x0001, PS: 0x10000, L3: 0x0002, R3: 0x0004, OPTIONS: 0x0008, UP: 0x0010, RIGHT: 0x0020, DOWN: 0x0040, LEFT: 0x0080,
|
||
L2: 0x0100, R2: 0x0200, L1: 0x0400, R1: 0x0800,
|
||
TRIANGLE: 0x1000, CIRCLE: 0x2000, CROSS: 0x4000, SQUARE: 0x8000, TOUCH: 0x100000,
|
||
};
|
||
const VALID_BUTTONS = 0x0011FFFF;
|
||
const TOUCH_W = 1919, TOUCH_H = 941;
|
||
// How far the drawn stick top moves at full tilt, as a share of the stick's area
|
||
const KNOB_TRAVEL = 0.27;
|
||
// A press shorter than this is held a bit longer, so games that poll once per frame still see it
|
||
const MIN_HOLD_MS = 50;
|
||
const KEEPALIVE_MS = 1000;
|
||
const FALLBACK_COLORS = [[32, 96, 255], [255, 48, 64], [48, 200, 96], [255, 80, 180]];
|
||
const KEEP_AWAKE_MP4 = 'data:video/mp4;base64,AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAZ2bW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAB9AAAQAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwAAApt0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAABAAAAAAAAB9AAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAABAAAAAQAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAAAAEAAAfQAAAAAAABAAAAAAITbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAAAoAAAAUABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABvm1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAX5zdGJsAAAAunN0c2QAAAAAAAAAAQAAAKphdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAABAAEABIAAAASAAAAAAAAAABFUxhdmM2Mi4yOC4xMDIgbGlieDI2NAAAAAAAAAAAAAAAGP//AAAAMGF2Y0MBQsAe/+EAF2dCwB6mEXsBEAAAAwAQAAADAKDxYuEYAQAGaMhCAZSyAAAAEHBhc3AAAAABAAAAAQAAABRidHJ0AAAAAAAAC1wAAAAAAAAAGHN0dHMAAAAAAAAAAQAAAAoAAAgAAAAAFHN0c3MAAAAAAAAAAQAAAAEAAAAcc3RzYwAAAAAAAAABAAAAAQAAAAEAAAABAAAAPHN0c3oAAAAAAAAAAAAAAAoAAAJ9AAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAAOHN0Y28AAAAAAAAACgAABrsAAAlAAAAJUgAACWAAAAlyAAAJgAAACZIAAAmgAAAJsgAACcQAAAMFdHJhawAAAFx0a2hkAAAAAwAAAAAAAAAAAAAAAgAAAAAAAAfQAAAAAAAAAAAAAAABAQAAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAJGVkdHMAAAAcZWxzdAAAAAAAAAABAAAH0AAABAAAAQAAAAACfW1kaWEAAAAgbWRoZAAAAAAAAAAAAAAAAAAAH0AAAEKAVcQAAAAAAC1oZGxyAAAAAAAAAABzb3VuAAAAAAAAAAAAAAAAU291bmRIYW5kbGVyAAAAAihtaW5mAAAAEHNtaGQAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAexzdGJsAAAAfnN0c2QAAAAAAAAAAQAAAG5tcDRhAAAAAAAAAAEAAAAAAAAAAAABABAAAAAAH0AAAAAAADZlc2RzAAAAAAOAgIAlAAIABICAgBdAFQAAAAAAH0AAAAE/BYCAgAUViFblAAaAgIABAgAAABRidHJ0AAAAAAAAH0AAAAE/AAAAIHN0dHMAAAAAAAAAAgAAABAAAAQAAAAAAQAAAoAAAAB8c3RzYwAAAAAAAAAJAAAAAQAAAAEAAAABAAAAAgAAAAIAAAABAAAABAAAAAEAAAABAAAABQAAAAIAAAABAAAABgAAAAEAAAABAAAABwAAAAIAAAABAAAACAAAAAEAAAABAAAACQAAAAIAAAABAAAACwAAAAEAAAABAAAAWHN0c3oAAAAAAAAAAAAAABEAAAAVAAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAAABAAAADxzdGNvAAAAAAAAAAsAAAamAAAJOAAACUoAAAlcAAAJagAACXwAAAmKAAAJnAAACaoAAAm8AAAJzgAAABpzZ3BkAQAAAHJvbGwAAAACAAAAAf//AAAAHHNiZ3AAAAAAcm9sbAAAAAEAAAARAAAAAQAAAGJ1ZHRhAAAAWm1ldGEAAAAAAAAAIWhkbHIAAAAAAAAAAG1kaXJhcHBsAAAAAAAAAAAAAAAALWlsc3QAAAAlqXRvbwAAAB1kYXRhAAAAAQAAAABMYXZmNjIuMTIuMTAyAAAACGZyZWUAAAM0bWRhdN4CAExhdmM2Mi4yOC4xMDIAAjBADgAAAmoGBf//ZtxF6b3m2Ui3lizYINkj7u94MjY0IC0gY29yZSAxNjQgcjMxMDggLSBILjI2NC9NUEVHLTQgQVZDIGNvZGVjIC0gQ29weWxlZnQgMjAwMy0yMDIzIC0gaHR0cDovL3d3dy52aWRlb2xhbi5vcmcveDI2NC5odG1sIC0gb3B0aW9uczogY2FiYWM9MCByZWY9MTYgZGVibG9jaz0xOjA6MCBhbmFseXNlPTB4MToweDEzMSBtZT11bWggc3VibWU9MTAgcHN5PTEgcHN5X3JkPTEuMDA6MC4wMCBtaXhlZF9yZWY9MSBtZV9yYW5nZT0yNCBjaHJvbWFfbWU9MSB0cmVsbGlzPTIgOHg4ZGN0PTAgY3FtPTAgZGVhZHpvbmU9MjEsMTEgZmFzdF9wc2tpcD0xIGNocm9tYV9xcF9vZmZzZXQ9LTIgdGhyZWFkcz0xIGxvb2thaGVhZF90aHJlYWRzPTEgc2xpY2VkX3RocmVhZHM9MCBucj0wIGRlY2ltYXRlPTEgaW50ZXJsYWNlZD0wIGJsdXJheV9jb21wYXQ9MCBjb25zdHJhaW5lZF9pbnRyYT0wIGJmcmFtZXM9MCB3ZWlnaHRwPTAga2V5aW50PTI1MCBrZXlpbnRfbWluPTUgc2NlbmVjdXQ9NDAgaW50cmFfcmVmcmVzaD0wIHJjX2xvb2thaGVhZD02MCByYz1jcmYgbWJ0cmVlPTEgY3JmPTUxLjAgcWNvbXA9MC42MCBxcG1pbj0wIHFwbWF4PTY5IHFwc3RlcD00IGlwX3JhdGlvPTEuNDAgYXE9MToxLjAwAIAAAAALZYiCAj5MUAAQT8ABGCAHARggBwAAAAZBmhwEfUABGCAHARggBwAAAAZBmioBH1ABGCAHAAAABkGaOwEfUAEYIAcBGCAHAAAABkGaSQBH1AEYIAcAAAAGQZpZQEfUARggBwEYIAcAAAAGQZppgEfUARggBwAAAAZBmnnAR9QBGCAHARggBwAAAAZBmoiAEPUBGCAHARggBwAAAAZBmpiQP9QBGCAH';
|
||
const KEEP_AWAKE_WEBM = 'data:video/webm;base64,GkXfo59ChoEBQveBAULygQRC84EIQoKEd2VibUKHgQJChYECGFOAZwEAAAAAAA9ZEU2bdLpNu4tTq4QVSalmU6yBoU27i1OrhBZUrmtTrIHYTbuMU6uEElTDZ1OsggufTbuMU6uEHFO7a1Osgg9D7AEAAAAAAABZAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVSalmsirXsYMPQkBNgI1MYXZmNjIuMTIuMTAyV0GNTGF2ZjYyLjEyLjEwMkSJiECfwAAAAAAAFlSua0rBrgEAAAAAAAA/14EBc8WI6/6qTnqgdpKcgQAitZyDdW5kiIEAhoVWX1ZQOIOBASPjg4QL68IA4JCwgRC6gRCagQJVsIRVuYEBrgEAAAAAAApw14ECc8WIAKqNztVx4+KcgQAitZyDdW5kiIEAhohBX1ZPUkJJU1aqhAHoSACDgQLhkZ+BAbWIQL9AAAAAAABiZIEgY6JKKgIeXQF2b3JiaXMAAAAAAUAfAAAAAAAAgFcAAAAAAACZAQN2b3JiaXM0AAAAWGlwaC5PcmcgbGliVm9yYmlzIEkgMjAyMDA3MDQgKFJlZHVjaW5nIEVudmlyb25tZW50KQEAAAAVAAAAZW5jb2Rlcj1MYXZjNjIuMjguMTAyAQV2b3JiaXMSQkNWAQAAAQAMUhQhJRlTSmMIlVJSKQUdY1BbRx1j1DlGIWQQU4hJGaV7TyqVWErIEVJYKUUdU0xTSZVSlilFHWMUU0ghU9YxZaFzFEuGSQklbE2udBZL6JljljFGHWPOWkqdY9YxRR1jUlJJoXMYOmYlZBQ6RsXoYnwwOpWiQii+x95S6S2FiluKvdcaU+sthBhLacEIYXPttdXcSmrFGGOMMcbF4lMogtCQVQAAAQAAQAQBQkNWAQAKAADCUAxFUYDQkFUAQAYAgAAURXEUx3EcR5IkywJCQ1YBAEAAAAIAACiO4SiSI0mSZFmWZVmWpnmWqLmqL/uuLuuu7eq6DoSGrAQAyAAAGIYhh95JzJBTkEkmKVXMOQih9Q455RRk0lLGmGKMUc6QUwwxBTGG0CmFENROOaUMIghDSJ1kziBLPejgYuc4EBqyIgCIAgAAjEGMIcaQcwxKBiFyjknIIETOOSmdlExKKK20lkkJLZXWIueclE5KJqW0FlLLpJTWQisFAAAEOAAABFgIhYasCACiAAAQg5BSSCnElGJOMYeUUo4px5BSzDnFmHKMMeggVMwxyByESCnFGHNOOeYgZAwq5hyEDDIBAAABDgAAARZCoSErAoA4AQCDJGmapWmiaGmaKHqmqKqiKKqq5Xmm6ZmmqnqiqaqmqrquqaqubHmeaXqmqKqeKaqqqaqua6qq64qqasumq9q26aq27MqybruyrNueqsq2qbqybqqubbuybOuuLNu65Hmq6pmm63qm6bqq69qy6rqy7Zmm64qqK9um68qy68q2rcqyrmum6bqiq9quqbqy7cqubbuyrPum6+q26sq6rsqy7tu2rvuyrQu76Lq2rsqurquyrOuyLeu2bNtCyfNU1TNN1/VM03VV17Vt1XVtWzNN1zVdV5ZF1XVl1ZV1XXVlW/dM03VNV5Vl01VlWZVl3XZlV5dF17VtVZZ9XXVlX5dt3fdlWdd903V1W5Vl21dlWfdlXfeFWbd93VNVWzddV9dN19V9W9d9YbZt3xddV9dV2daFVZZ139Z9ZZh1nTC6rq6rtuzrqizrvq7rxjDrujCsum38rq0Lw6vrxrHrvq7cvo9q277w6rYxvLpuHLuwG7/t+8axqaptm66r66Yr67ps675v67pxjK6r66os+7rqyr5v67rw674vDKPr6roqy7qw2rKvy7ouDLuuG8Nq28Lu2rpwzLIuDLfvK8evC0PVtoXh1XWjq9vGbwvD0jd2vgAAgAEHAIAAE8pAoSErAoA4AQAGIQgVYxAqxiCEEFIKIaRUMQYhYw5KxhyUEEpJIZTSKsYgZI5JyByTEEpoqZTQSiilpVBKS6GU1lJqLabUWgyhtBRKaa2U0lpqKbbUUmwVYxAy56RkjkkopbRWSmkpc0xKxqCkDkIqpaTSSkmtZc5JyaCj0jlIqaTSUkmptVBKa6GU1kpKsaXSSm2txRpKaS2k0lpJqbXUUm2ttVojxiBkjEHJnJNSSkmplNJa5pyUDjoqmYOSSimplZJSrJiT0kEoJYOMSkmltZJKK6GU1kpKsYVSWmut1ZhSSzWUklpJqcVQSmuttRpTKzWFUFILpbQWSmmttVZrai22UEJroaQWSyoxtRZjba3FGEppraQSWympxRZbja21WFNLNZaSYmyt1dhKLTnWWmtKLdbSUoyttZhbTLnFWGsNJbQWSmmtlNJaSq3F1lqtoZTWSiqxlZJabK3V2FqMNZTSYikptZBKbK21WFtsNaaWYmyx1VhSizHGWHNLtdWUWouttVhLKzXGGGtuNeVSAADAgAMAQIAJZaDQkJUAQBQAAGAMY4xBaBRyzDkpjVLOOSclcw5CCCllzkEIIaXOOQiltNQ5B6GUlEIpKaUUWyglpdZaLAAAoMABACDABk2JxQEKDVkJAEQBACDGKMUYhMYgpRiD0BijFGMQKqUYcw5CpRRjzkHIGHPOQSkZY85BJyWEEEIppYQQQiillAIAAAocAAACbNCUWByg0JAVAUAUAABgDGIMMYYgdFI6KRGETEonpZESWgspZZZKiiXGzFqJrcTYSAmthdYyayXG0mJGrcRYYioAAOzAAQDswEIoNGQlAJAHAEAYoxRjzjlnEGLMOQghNAgx5hyEECrGnHMOQggVY845ByGEzjnnIIQQQueccxBCCKGDEEIIpZTSQQghhFJK6SCEEEIppXQQQgihlFIKAAAqcAAACLBRZHOCkaBCQ1YCAHkAAIAxSjknJaVGKcYgpBRboxRjEFJqrWIMQkqtxVgxBiGl1mLsIKTUWoy1dhBSai3GWkNKrcVYa84hpdZirDXX1FqMtebce2otxlpzzrkAANwFBwCwAxtFNicYCSo0ZCUAkAcAQCCkFGOMOYeUYowx55xDSjHGmHPOKcYYc8455xRjjDnnnHOMMeecc845xphzzjnnnHPOOeegg5A555xz0EHonHPOOQghdM455xyEEAoAACpwAAAIsFFkc4KRoEJDVgIA4QAAgDGUUkoppZRSSqijlFJKKaWUUgIhpZRSSimllFJKKaWUUkoppZRSSimllFJKKaWUUkoppZRSSimllFJKKaWUUkoppZRSSimllFJKKaWUUkoppZRSSimllFJKKaWUUkoppZRSSimllFJKKaWUUkoppZRSSimllFJKKaWUUkoplVJKKaWUUkoppZRSSimlACDfCgcA/wcbZ1hJOiscDS40ZCUAEA4AABjDGISMOSclpYYxCKV0TkpJJTWMQSilcxJSSimD0FpqpaTSUkoZhJRiCyGVlFoKpbRWaymptZRSKCnFGktKqaXWMuckpJJaS622mDkHpaTWWmqtxRBCSrG11lJrsXVSUkmttdZabS2klFprLcbWYmwlpZZaa6nF1lpMqbUWW0stxtZiS63F2GKLMcYaCwDgbnAAgEiwcYaVpLPC0eBCQ1YCACEBAAQySjnnnIMQQgghUoox56CDEEIIIURKMeacgxBCCCGEjDHnIIQQQgihlJAx5hyEEEIIIYRSOucghFBKCaWUUkrnHIQQQgillFJKCSGEEEIopZRSSikhhBBKKaWUUkopJYQQQiillFJKKaWEEEIopZRSSimllBBCKKWUUkoppZQSQgihlFJKKaWUUkIIpZRSSimllFJKKCGEUkoppZRSSgkllFJKKaWUUkopIZRSSimllFJKKaUAAIADBwCAACPoJKPKImw04cIDEAAAAAIAAkwAgQGCglEIAoQRCAAAAAAACAD4AABICoCIiGjmDA4QEhQWGBocHiAiJAAAAAAAAAAAAAAAAAQSVMNnQNhzc6BjwIBnyJpFo4dFTkNPREVSRIeNTGF2ZjYyLjEyLjEwMnNz1mPAi2PFiOv+qk56oHaSZ8ihRaOHRU5DT0RFUkSHlExhdmM2Mi4yOC4xMDIgbGlidnB4Z8ihRaOIRFVSQVRJT05Eh5MwMDowMDowMi4wMDAwMDAwMDAAc3PZY8CLY8WIAKqNztVx4+JnyKRFo4dFTkNPREVSRIeXTGF2YzYyLjI4LjEwMiBsaWJ2b3JiaXNnyKFFo4hEVVJBVElPTkSHkzAwOjAwOjAyLjAzMjAwMDAwMAAfQ7Z1QsDngQCjhYIAAIAAo6OBAACAEAIAnQEqEAAQAABHCIWFiIWEiAICAAwNYAD+/6tQgKOFggAggACjhYIAQIAAo4WCAGCAAKOFggCAgACjhYIAoIAAo4WCAMCAAKOFggDggACjlYEAyACxAQABEBAAGAAYWC/0AAgAAKOFggEAgACjhYIBIIAAo4WCAUCAAKOFggFggACjhYIBgIAAo4WCAaCAAKOVgQGQALEBAAEQEAAYABhYL/QACAAAo4WCAcCAAKOFggHggACjhYICAIAAo4WCAiCAAKOFggJAgACjhYICYIAAo5WBAlgAsQEAARAQABgAGFgv9AAIAACjhYICgIAAo4WCAqCAAKOFggLAgACjhYIC4IAAo4WCAwCAAKOFggMggACjlYEDIACxAQABEBAAGAAYWC/0AAgAAKOFggNAgACjhYIDYIAAo4WCA4CAAKOFggOggACjhYIDwIAAo4WCA+CAAKOFggQAgACjlYED6ACxAQABEBAAGAAYWC/0AAgAAKOFggQggACjhYIEQIAAo4WCBGCAAKOFggSAgACjhYIEoIAAo4WCBMCAAKOVgQSwALEBAAEQEAAYABhYL/QACAAAo4WCBOCAAKOFggUAgACjhYIFIIAAo4WCBUCAAKOFggVggACjhYIFgIAAo5WBBXgAsQEAARAQFGAAYWC/0AAgAACjhYIFoIAAo4WCBcCAAKOFggXggACjhYIGAIAAo4WCBiCAAKOFggZAgACjlYEGQACxAQABEBAAGAAYWC/0AAgAAKOFggZggACjhYIGgIAAo4WCBqCAAKOFggbAgACjhYIG4IAAo4WCBwCAAKOFggcggACjlYEHCACxAQABEBAAGAAYWC/0AAgAAKOFggdAgACjhYIHYIAAo4WCB4CAAKOFggeggACjhYIHwIAAoI6hhYIH4AAAdaKEAPQkABxTu2uRu4+zgQC3iveBAfGCDH3wgQo=';
|
||
|
||
const $ = (sel, root = document) => root.querySelector(sel);
|
||
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
|
||
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
||
const now = () => performance.now();
|
||
const css = rgb => `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
|
||
|
||
const store = {
|
||
get(key, fallback) {
|
||
try { const v = localStorage.getItem('c4f.' + key); return v === null ? fallback : JSON.parse(v); }
|
||
catch (e) { return fallback; }
|
||
},
|
||
set(key, value) { try { localStorage.setItem('c4f.' + key, JSON.stringify(value)); } catch (e) { /* private mode */ } },
|
||
};
|
||
|
||
// Settings of this device. They stay in this browser.
|
||
const DEVICE_SETTINGS = [
|
||
['keepAwake', 'Keep the screen on', 'While this page drives a controller, the screen doesn\'t turn off.', true],
|
||
['haptics', 'Vibration', 'Buzz this device when you press a button or the game rumbles (Android).', 'vibrate' in navigator],
|
||
['floating', 'Floating sticks', 'A stick starts wherever your thumb lands inside its area.', false],
|
||
['tapClick', 'Tap the touchpad to click', 'A quick tap presses the touchpad button. Hold still to keep it pressed.', true],
|
||
['stickClick', 'Double-tap a stick for L3 / R3', 'Tap a stick, then touch it again right away to press it in. Hold the second touch to keep it pressed. Off: L3 and R3 get their own buttons.', true],
|
||
// Key labels only by default where a keyboard is likely: not on phones and tablets
|
||
['keys', 'Show keyboard keys', 'Label the on-screen buttons with their keyboard keys.',
|
||
navigator.maxTouchPoints === 0 && matchMedia('(hover: hover) and (pointer: fine)').matches],
|
||
];
|
||
const settings = {};
|
||
for (const [key, , , fallback] of DEVICE_SETTINGS) settings[key] = store.get(key, fallback);
|
||
|
||
let toastTimer = 0;
|
||
function toast(text) {
|
||
$('#toastText').textContent = text;
|
||
$('#toast').classList.add('show');
|
||
clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => $('#toast').classList.remove('show'), 3000);
|
||
}
|
||
|
||
function el(tag, cls, text) {
|
||
const e = document.createElement(tag);
|
||
if (cls) e.className = cls;
|
||
if (text !== undefined) e.textContent = text;
|
||
return e;
|
||
}
|
||
|
||
// ---------------------------------------------------------------- clock
|
||
|
||
function tickClock() {
|
||
const text = new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||
for (const c of $$('[data-clock]')) c.textContent = text;
|
||
}
|
||
tickClock();
|
||
setInterval(tickClock, 10000);
|
||
|
||
// ---------------------------------------------------------------- connection
|
||
|
||
const conn = {
|
||
ws: null, state: 'connecting', address: '', version: '', nextId: 1,
|
||
pending: new Map(), retry: 0, retryTimer: 0, everOpened: false,
|
||
motion: false, // the service takes 'm' (its info says so)
|
||
};
|
||
|
||
const status = Array.from({ length: PADS }, (_, i) => ({
|
||
pad: i, open: false, enabled: true, real: false, logged: false, joined: false, connected: false,
|
||
full: false, shared: false, player: 0, uid: '', cfg: '', name: 'Controller ' + (i + 1), user: '',
|
||
color: FALLBACK_COLORS[i], clients: 0, known: false,
|
||
}));
|
||
|
||
const servedByConsole = location.protocol === 'http:' || location.protocol === 'https:';
|
||
// Only a copy of the page saved to the device and opened from there needs
|
||
// to be told where the PS4 is.
|
||
const openedFromFile = location.protocol === 'file:';
|
||
|
||
function initialAddress() {
|
||
const param = new URLSearchParams(location.search).get('host');
|
||
if (param) return param;
|
||
if (servedByConsole && location.host) return location.host;
|
||
return store.get('address', '');
|
||
}
|
||
|
||
// `detail` (the address) is dropped on narrow screens, the rest never is.
|
||
function setConnState(state, text, detail) {
|
||
conn.state = state;
|
||
$('#conn').dataset.conn = state;
|
||
$('#padView').dataset.conn = state;
|
||
$('#connText').textContent = text;
|
||
$('#connDetail').textContent = detail || '';
|
||
renderPadLabel();
|
||
}
|
||
|
||
function connect(address) {
|
||
clearTimeout(conn.retryTimer);
|
||
if (conn.ws) {
|
||
conn.ws.onclose = null;
|
||
conn.ws.close();
|
||
resetClaims();
|
||
forgetSelections();
|
||
for (const [, p] of conn.pending) p.reject(new Error('Connection changed'));
|
||
conn.pending.clear();
|
||
}
|
||
conn.address = address.trim().replace(/^(?:https?|wss?):\/\//i, '').replace(/[/?#].*$/, '');
|
||
if (!conn.address.includes(':') && conn.address) conn.address += ':4264';
|
||
if (!conn.address) {
|
||
setConnState('closed', 'Not connected');
|
||
$('#addressForm').hidden = !openedFromFile;
|
||
return;
|
||
}
|
||
if (!servedByConsole) store.set('address', conn.address);
|
||
if (conn.everOpened) setConnState('connecting', 'Waiting for Control4Free on the PS4');
|
||
else setConnState('connecting', 'Connecting', ' to ' + conn.address);
|
||
let ws;
|
||
try {
|
||
ws = new WebSocket('ws://' + conn.address);
|
||
} catch (e) {
|
||
setConnState('closed', 'Invalid address');
|
||
$('#addressForm').hidden = !openedFromFile;
|
||
return;
|
||
}
|
||
conn.ws = ws;
|
||
ws.onopen = () => {
|
||
if (conn.ws !== ws) return;
|
||
conn.retry = 0;
|
||
conn.everOpened = true;
|
||
setConnState('open', 'Connected', ' to ' + conn.address);
|
||
$('#addressForm').hidden = true;
|
||
$('#addressHint').classList.remove('error');
|
||
call('info', []).then(info => {
|
||
conn.version = info.version || '';
|
||
conn.motion = info.motion === true;
|
||
showVersion();
|
||
sendClaim(true);
|
||
}).catch(() => {});
|
||
resendAll();
|
||
};
|
||
ws.onmessage = ev => { if (conn.ws === ws) onMessage(ev.data); };
|
||
ws.onclose = () => {
|
||
if (conn.ws !== ws) return;
|
||
conn.ws = null;
|
||
conn.motion = false;
|
||
motionLive.fill(false);
|
||
// Reconnecting the page must not silently recreate a controller or
|
||
// replay held buttons. The user selects a controller again.
|
||
resetClaims();
|
||
forgetSelections();
|
||
for (const [, p] of conn.pending) p.reject(new Error('closed'));
|
||
conn.pending.clear();
|
||
stopAllRumble();
|
||
const wait = Math.min(5000, 800 * Math.pow(1.6, conn.retry++));
|
||
if (!conn.everOpened && openedFromFile) {
|
||
setConnState('closed', 'Can\'t reach', ' ' + conn.address);
|
||
$('#addressForm').hidden = false;
|
||
$('#addressHint').textContent = 'Can\'t reach the console. Load the Control4Free payload, then check the address.';
|
||
$('#addressHint').classList.add('error');
|
||
} else {
|
||
setConnState('connecting', 'Waiting for Control4Free on the PS4');
|
||
}
|
||
conn.retryTimer = setTimeout(() => connect(conn.address), wait);
|
||
};
|
||
}
|
||
|
||
function send(obj) {
|
||
if (conn.ws && conn.ws.readyState === WebSocket.OPEN) {
|
||
conn.ws.send(JSON.stringify(obj));
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function call(method, params) {
|
||
const id = conn.nextId++;
|
||
return new Promise((resolve, reject) => {
|
||
if (!send({ id, method, params })) return reject(new Error('Not connected to the console'));
|
||
const timer = setTimeout(() => {
|
||
conn.pending.delete(id);
|
||
reject(new Error('The console did not answer'));
|
||
}, method === 'claim' ? 18000 : 4000);
|
||
conn.pending.set(id, {
|
||
resolve: v => { clearTimeout(timer); resolve(v); },
|
||
reject: e => { clearTimeout(timer); reject(e); },
|
||
});
|
||
});
|
||
}
|
||
|
||
function onMessage(data) {
|
||
let msg;
|
||
try { msg = JSON.parse(data); } catch (e) { return; }
|
||
if (msg.id !== undefined) {
|
||
const p = conn.pending.get(msg.id);
|
||
if (p) {
|
||
conn.pending.delete(msg.id);
|
||
if (msg.error) p.reject(msg.error); else p.resolve(msg.result);
|
||
}
|
||
return;
|
||
}
|
||
const params = msg.params;
|
||
switch (msg.method) {
|
||
case 's': applyStatus(params); break;
|
||
case 'error': toast(params.message || 'Controller error'); break;
|
||
case 'v': onRumble(params[0], params[1], params[2]); break;
|
||
case 'l': setPadColor(params[0], [params[1], params[2], params[3]]); break;
|
||
case 'rl': setPadColor(params[0], params.length >= 4 ? [params[1], params[2], params[3]] : FALLBACK_COLORS[params[0]]); break;
|
||
}
|
||
}
|
||
|
||
function applyStatus(s) {
|
||
if (!s || !Array.isArray(s.pads)) return;
|
||
let closePrimary = false;
|
||
if (s.version && s.version !== conn.version) {
|
||
conn.version = s.version;
|
||
showVersion();
|
||
}
|
||
for (const p of s.pads) {
|
||
const st = status[p.pad];
|
||
if (!st) continue;
|
||
const wasOpen = st.open;
|
||
const wasMine = st.mine;
|
||
Object.assign(st, p, { known: true });
|
||
if (wasMine && !st.mine) {
|
||
onRumble(p.pad, 0, 0);
|
||
for (const src of gamepadSources.values()) if (src.pad === p.pad) { src.pad = -1; clearDevice(src); }
|
||
if (primaryPad === p.pad) closePrimary = true;
|
||
lastClaim = '';
|
||
}
|
||
// The game just opened this pad: make sure it gets our current state right away
|
||
if (!wasOpen && st.open) lastSent[p.pad] = '';
|
||
}
|
||
// Closing sends the remaining claim. Clear every lost source first so a
|
||
// logout affecting several controllers cannot recreate a later slot.
|
||
if (closePrimary) closePad();
|
||
renderPads();
|
||
renderPadLabel();
|
||
if (gamepadSources.size) renderGamepads();
|
||
}
|
||
|
||
function setPadColor(pad, rgb) {
|
||
if (!status[pad] || !Array.isArray(rgb)) return;
|
||
status[pad].color = rgb;
|
||
renderPads();
|
||
renderPadLabel();
|
||
}
|
||
|
||
// ---------------------------------------------------------------- input state
|
||
|
||
// Every input source reports into its own slot; a pad's state merges all sources that drive it
|
||
function blankSource(pad) {
|
||
return { pad, buttons: 0, lx: 128, ly: 128, rx: 128, ry: 128, l2: 0, r2: 0, touches: [] };
|
||
}
|
||
|
||
const screenSource = blankSource(-1);
|
||
const keySource = blankSource(-1);
|
||
const gamepadSources = new Map(); // gamepad index -> source
|
||
let primaryPad = -1; // the pad opened in the controller view
|
||
|
||
function allSources() {
|
||
return [screenSource, keySource, ...gamepadSources.values()];
|
||
}
|
||
function forgetSelections() {
|
||
stopAllRumble();
|
||
mapper.cancelCapture(); releaseScreen(); heldKeys.clear();
|
||
for (const src of gamepadSources.values()) { src.pad = -1; clearDevice(src); }
|
||
primaryPad = -1;
|
||
Object.assign(screenSource, blankSource(-1)); Object.assign(keySource, blankSource(-1));
|
||
padView.hidden = true;
|
||
if (mapView.hidden && $('#settingsView').hidden) $('#home').hidden = false;
|
||
document.body.classList.remove('in-pad');
|
||
document.title = 'Control4Free';
|
||
history.replaceState(null, '', updateQuery('pad', null));
|
||
renderGamepads();
|
||
}
|
||
|
||
function mergedState(pad) {
|
||
const s = blankSource(pad);
|
||
const outputs = [];
|
||
for (const src of allSources()) {
|
||
if (src.pad !== pad) continue;
|
||
outputs.push(src.mapped || ControllerMapping.fromSource(src));
|
||
if (src.touches.length) s.touches = s.touches.concat(src.touches);
|
||
}
|
||
Object.assign(s, ControllerMapping.toSource(ControllerMapping.merge(outputs)));
|
||
if (s.l2 > 20) s.buttons |= B.L2;
|
||
if (s.r2 > 20) s.buttons |= B.R2;
|
||
s.touches = s.touches.slice(0, 2);
|
||
return s;
|
||
}
|
||
|
||
function claimedPads() {
|
||
const pads = new Set();
|
||
if (primaryPad >= 0) pads.add(primaryPad);
|
||
for (const src of gamepadSources.values()) if (src.pad >= 0) pads.add(src.pad);
|
||
return [...pads].sort();
|
||
}
|
||
|
||
let lastClaim = '';
|
||
let claimFlight = false, claimQueued = false;
|
||
let claimEpoch = 0;
|
||
function resetClaims() { claimEpoch++; lastClaim = ''; claimFlight = claimQueued = false; }
|
||
function sendClaim(force) {
|
||
keepAwake.update();
|
||
if (conn.state !== 'open') return;
|
||
if (claimFlight) { claimQueued = true; return; }
|
||
const pads = claimedPads();
|
||
const key = pads.join(',');
|
||
if (!force && key === lastClaim) return;
|
||
lastClaim = key;
|
||
claimFlight = true;
|
||
const epoch = claimEpoch;
|
||
call('claim', pads).then(s => { if (epoch === claimEpoch) applyStatus(s); }).catch(e => {
|
||
if (epoch !== claimEpoch) return;
|
||
lastClaim = '';
|
||
toast(e.message || 'Could not connect the controller');
|
||
return call('status', []).then(s => {
|
||
if (epoch !== claimEpoch) return;
|
||
applyStatus(s);
|
||
let dropped = false;
|
||
const taken = pad => pad >= 0 && status[pad].known && !status[pad].mine && status[pad].clients > 0;
|
||
if (taken(primaryPad)) { closePad(); dropped = true; }
|
||
for (const src of gamepadSources.values()) if (taken(src.pad)) { src.pad = -1; dropped = true; }
|
||
renderGamepads();
|
||
// Only ask again when something was actually given up, so a refusal
|
||
// for any other reason cannot turn into a loop.
|
||
if (dropped && claimedPads().length) sendClaim(true);
|
||
}).catch(() => {
|
||
if (epoch !== claimEpoch) return;
|
||
// No answer at all: assume nothing is ours.
|
||
closePad();
|
||
for (const src of gamepadSources.values()) src.pad = -1;
|
||
renderGamepads();
|
||
});
|
||
}).finally(() => {
|
||
if (epoch !== claimEpoch) return;
|
||
claimFlight = false;
|
||
if (claimQueued) { claimQueued = false; sendClaim(); }
|
||
});
|
||
}
|
||
|
||
// Round trip to the console, shown with the version so a laggy network is
|
||
// visible rather than guessed at.
|
||
let pingMs = 0, pingAt = 0;
|
||
function measurePing(t) {
|
||
if (conn.state !== 'open' || t - pingAt < 2000) return;
|
||
pingAt = t;
|
||
const started = now();
|
||
call('ping', []).then(() => {
|
||
pingMs = Math.round(now() - started);
|
||
showVersion();
|
||
}).catch(() => {});
|
||
}
|
||
|
||
function showVersion() {
|
||
$('#version').textContent = 'Control4Free' + (conn.version ? ' ' + conn.version : '') +
|
||
(pingMs ? ' · ' + pingMs + ' ms' : '');
|
||
}
|
||
|
||
const lastSent = Array(PADS).fill('');
|
||
const lastSentAt = Array(PADS).fill(0);
|
||
|
||
function packState(pad, s) {
|
||
const params = [pad, s.buttons & VALID_BUTTONS, s.lx, s.ly, s.rx, s.ry, s.l2, s.r2, s.touches.length];
|
||
for (const t of s.touches) params.push(t.id, t.x, t.y);
|
||
return params;
|
||
}
|
||
|
||
function flush(t) {
|
||
for (const pad of claimedPads()) {
|
||
const params = packState(pad, mergedState(pad));
|
||
const key = params.join(',');
|
||
// Send on change, and once a second anyway so the console knows this device is still here
|
||
if (key !== lastSent[pad] || t - lastSentAt[pad] > KEEPALIVE_MS) {
|
||
if (send({ method: 'u', params })) {
|
||
if (key !== lastSent[pad]) pulseActivity(pad);
|
||
lastSent[pad] = key;
|
||
lastSentAt[pad] = t;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function resendAll() {
|
||
lastSent.fill('');
|
||
}
|
||
|
||
// The console releases a controller when the claim changes; sending input
|
||
// here would only claim it again. Just forget what was last sent.
|
||
function releasePad(pad) {
|
||
if (pad < 0) return;
|
||
lastSent[pad] = '';
|
||
}
|
||
|
||
// ---------------------------------------------------------------- buttons with a minimum hold
|
||
|
||
const holdTimers = new Map();
|
||
|
||
function press(src, bit) {
|
||
src.buttons |= bit;
|
||
src.pressedAt = src.pressedAt || {};
|
||
src.pressedAt[bit] = now();
|
||
const key = (src === screenSource ? 's' : 'k') + bit;
|
||
clearTimeout(holdTimers.get(key));
|
||
holdTimers.delete(key);
|
||
if (settings.haptics && src === screenSource) haptic(8);
|
||
flush(now());
|
||
}
|
||
|
||
function release(src, bit) {
|
||
const at = (src.pressedAt && src.pressedAt[bit]) || 0;
|
||
const left = MIN_HOLD_MS - (now() - at);
|
||
const key = (src === screenSource ? 's' : 'k') + bit;
|
||
if (left > 0) {
|
||
holdTimers.set(key, setTimeout(() => {
|
||
src.buttons &= ~bit; holdTimers.delete(key); flush(now());
|
||
}, left));
|
||
} else {
|
||
src.buttons &= ~bit;
|
||
flush(now());
|
||
}
|
||
}
|
||
|
||
function haptic(ms) {
|
||
if ('vibrate' in navigator) {
|
||
try { navigator.vibrate(ms); } catch (e) { /* not allowed yet */ }
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- keep the screen on
|
||
|
||
// The Wake Lock API only works on secure (https) pages; the console's page is plain http. A tiny
|
||
// silent looping video does the same job there: browsers keep the screen on while media plays.
|
||
const keepAwake = {
|
||
video: null,
|
||
lock: null,
|
||
wanted() {
|
||
return settings.keepAwake && !document.hidden && claimedPads().length > 0;
|
||
},
|
||
async update() {
|
||
if (this.wanted()) await this.on(); else this.off();
|
||
},
|
||
async on() {
|
||
if ('wakeLock' in navigator && window.isSecureContext) {
|
||
if (!this.lock) {
|
||
try {
|
||
this.lock = await navigator.wakeLock.request('screen');
|
||
this.lock.addEventListener('release', () => { this.lock = null; });
|
||
} catch (e) { /* fall back to the video */ }
|
||
}
|
||
if (this.lock) return;
|
||
}
|
||
if (!this.video) {
|
||
const v = document.createElement('video');
|
||
v.className = 'keep-awake';
|
||
v.muted = true;
|
||
v.loop = true;
|
||
v.setAttribute('muted', '');
|
||
v.setAttribute('playsinline', '');
|
||
v.setAttribute('webkit-playsinline', '');
|
||
v.setAttribute('aria-hidden', 'true');
|
||
for (const [src, type] of [[KEEP_AWAKE_WEBM, 'video/webm'], [KEEP_AWAKE_MP4, 'video/mp4']]) {
|
||
const s = document.createElement('source');
|
||
s.src = src;
|
||
s.type = type;
|
||
v.appendChild(s);
|
||
}
|
||
document.body.appendChild(v);
|
||
this.video = v;
|
||
}
|
||
if (this.video.paused) {
|
||
try { await this.video.play(); } catch (e) { /* needs a tap first; tried again on the next one */ }
|
||
}
|
||
},
|
||
off() {
|
||
if (this.lock) { this.lock.release().catch(() => {}); this.lock = null; }
|
||
if (this.video && !this.video.paused) this.video.pause();
|
||
},
|
||
};
|
||
|
||
// Starting media needs a user gesture
|
||
document.addEventListener('pointerdown', () => { if (keepAwake.wanted()) keepAwake.update(); }, true);
|
||
document.addEventListener('keydown', () => { if (keepAwake.wanted()) keepAwake.update(); }, true);
|
||
|
||
// ---------------------------------------------------------------- on-screen controls
|
||
|
||
const padView = $('#padView');
|
||
const pointers = new Map();
|
||
const draggingStick = { L: false, R: false };
|
||
|
||
// ---------------------------------------------------------------- controller layout
|
||
|
||
// Where each control sits: x and y are its centre in % of the screen, w and h its size in --u (about 1%
|
||
// of the screen height). Each control is placed on its own.
|
||
const DEFAULT_LAYOUT = {
|
||
l2: { x: 14, y: 10, w: 22, h: 11 },
|
||
l1: { x: 14, y: 23, w: 22, h: 8 },
|
||
r2: { x: 86, y: 10, w: 22, h: 11 },
|
||
r1: { x: 86, y: 23, w: 22, h: 8 },
|
||
share: { x: 28.5, y: 13, w: 10, h: 14 },
|
||
options: { x: 71.5, y: 13, w: 10, h: 14 },
|
||
touchpad: { x: 50, y: 35, w: 76, h: 48 },
|
||
dpad: { x: 15, y: 55, w: 40, h: 40 },
|
||
face: { x: 85, y: 55, w: 40, h: 40 },
|
||
ps: { x: 50, y: 75, w: 10, h: 10 },
|
||
lstick: { x: 33, y: 79, w: 34, h: 34 },
|
||
rstick: { x: 67, y: 79, w: 34, h: 34 },
|
||
l3: { x: 17, y: 88, w: 11, h: 11 },
|
||
r3: { x: 83, y: 88, w: 11, h: 11 },
|
||
};
|
||
|
||
const CONTROL_NAMES = {
|
||
l2: 'L2', l1: 'L1', r2: 'R2', r1: 'R1', share: 'SHARE', options: 'OPTIONS', touchpad: 'Touchpad',
|
||
dpad: 'D-pad', face: 'Face buttons', ps: 'PS button', lstick: 'Left stick', rstick: 'Right stick',
|
||
l3: 'L3', r3: 'R3',
|
||
};
|
||
|
||
// This device keeps one layout per orientation
|
||
const portraitQuery = matchMedia('(orientation: portrait)');
|
||
const layoutKey = () => portraitQuery.matches ? 'layout.portrait' : 'layout.landscape';
|
||
|
||
function defaultLayout() {
|
||
const out = {};
|
||
for (const [id, place] of Object.entries(DEFAULT_LAYOUT)) out[id] = Object.assign({ hide: false }, place);
|
||
return out;
|
||
}
|
||
|
||
function loadLayout() {
|
||
const saved = store.get(layoutKey(), {}) || {};
|
||
const out = defaultLayout();
|
||
const num = (v, lo, hi, fallback) => Number.isFinite(v) ? clamp(v, lo, hi) : fallback;
|
||
for (const [id, def] of Object.entries(DEFAULT_LAYOUT)) {
|
||
const s = saved[id];
|
||
if (!s || typeof s !== 'object') continue;
|
||
out[id] = {
|
||
x: num(s.x, 0, 100, def.x), y: num(s.y, 0, 100, def.y),
|
||
w: num(s.w, def.w / 2, def.w * 2, def.w), h: num(s.h, def.h / 2, def.h * 2, def.h),
|
||
hide: !!s.hide,
|
||
};
|
||
}
|
||
return out;
|
||
}
|
||
|
||
let layout = loadLayout();
|
||
|
||
function ctlNode(id) {
|
||
return $(`[data-ctl="${id}"]`, padView);
|
||
}
|
||
|
||
function setPlace(node, place) {
|
||
for (const key of ['x', 'y', 'w', 'h']) node.style.setProperty('--' + key, place[key]);
|
||
}
|
||
|
||
function applyLayout() {
|
||
for (const node of $$('[data-ctl]', padView)) {
|
||
const id = node.dataset.ctl;
|
||
const place = layout[id];
|
||
setPlace(node, place);
|
||
// L3 / R3 buttons are only needed when double-tapping a stick doesn't press it
|
||
const unused = (id === 'l3' || id === 'r3') && settings.stickClick;
|
||
node.hidden = unused || (place.hide && !editor.active);
|
||
node.classList.toggle('ctl-off', place.hide);
|
||
}
|
||
}
|
||
|
||
// Keep the whole control on screen
|
||
function keepOnScreen(node, place) {
|
||
const area = padArea.getBoundingClientRect();
|
||
const halfW = node.offsetWidth / area.width * 50;
|
||
const halfH = node.offsetHeight / area.height * 50;
|
||
place.x = Math.round(clamp(place.x, halfW, 100 - halfW) * 2) / 2;
|
||
place.y = Math.round(clamp(place.y, halfH, 100 - halfH) * 2) / 2;
|
||
}
|
||
|
||
// ---------------------------------------------------------------- layout editor
|
||
|
||
const padArea = $('.pad-area', padView);
|
||
|
||
const editor = {
|
||
active: false,
|
||
key: '',
|
||
before: null,
|
||
selected: '',
|
||
resetTimer: 0,
|
||
|
||
open() {
|
||
if (this.active) return;
|
||
releaseScreen();
|
||
this.active = true;
|
||
this.key = layoutKey();
|
||
this.before = JSON.parse(JSON.stringify(layout));
|
||
padView.classList.add('editing');
|
||
const bar = $('#layoutBar');
|
||
bar.classList.remove('moved');
|
||
bar.style.left = bar.style.top = '';
|
||
bar.hidden = false;
|
||
this.select('');
|
||
applyLayout();
|
||
toast('Drag a control to move it. Tap one to resize or hide it.');
|
||
},
|
||
|
||
// Done keeps the changes on this device, Cancel puts back the layout from before
|
||
finish(save) {
|
||
if (!this.active) return;
|
||
if (save) store.set(this.key, layout);
|
||
else layout = this.before;
|
||
this.active = false;
|
||
this.select('');
|
||
this.disarmReset();
|
||
padView.classList.remove('editing', 'dragging');
|
||
$('#layoutBar').hidden = true;
|
||
applyLayout();
|
||
},
|
||
|
||
select(id) {
|
||
this.selected = id;
|
||
for (const node of $$('[data-ctl]', padView)) node.classList.toggle('selected', node.dataset.ctl === id);
|
||
if (id) {
|
||
$('#lbTitle').textContent = CONTROL_NAMES[id];
|
||
$('#lbSize').value = Math.round(layout[id].w / DEFAULT_LAYOUT[id].w * 100);
|
||
$('#lbHide').textContent = layout[id].hide ? 'Show' : 'Hide';
|
||
}
|
||
this.placePanel();
|
||
},
|
||
|
||
// The size and hide panel sits just below the selected control, or above it when there's no room
|
||
placePanel() {
|
||
const panel = $('#layoutPanel');
|
||
panel.hidden = !this.selected;
|
||
if (!this.selected) return;
|
||
const view = padView.getBoundingClientRect();
|
||
const r = ctlNode(this.selected).getBoundingClientRect();
|
||
const pw = panel.offsetWidth, ph = panel.offsetHeight, gap = 10;
|
||
let top = r.bottom + gap;
|
||
if (top + ph > view.height - 4) top = r.top - gap - ph;
|
||
top = clamp(top, 4, view.height - ph - 4);
|
||
panel.style.left = clamp(r.left + r.width / 2 - pw / 2, 4, view.width - pw - 4) + 'px';
|
||
panel.style.top = top + 'px';
|
||
},
|
||
|
||
// Drag the bar by its grip, out of the way of the controls under it
|
||
dragBar(e) {
|
||
e.preventDefault();
|
||
const bar = $('#layoutBar');
|
||
const view = padView.getBoundingClientRect();
|
||
const r = bar.getBoundingClientRect();
|
||
const dx = e.clientX - r.left, dy = e.clientY - r.top;
|
||
pointers.set(e.pointerId, {
|
||
move(ev) {
|
||
bar.classList.add('moved');
|
||
bar.style.left = clamp(ev.clientX - dx, 4, view.width - r.width - 4) + 'px';
|
||
bar.style.top = clamp(ev.clientY - dy, 4, view.height - r.height - 4) + 'px';
|
||
},
|
||
up() {},
|
||
});
|
||
try { padView.setPointerCapture(e.pointerId); } catch (err) { /* already gone */ }
|
||
},
|
||
|
||
pointerDown(e) {
|
||
if (e.target.closest('#lbGrip')) {
|
||
this.dragBar(e);
|
||
return;
|
||
}
|
||
if (e.target.closest('#layoutBar, #layoutPanel')) return;
|
||
const node = e.target.closest('[data-ctl]');
|
||
if (!node || !padArea.contains(node)) {
|
||
this.select('');
|
||
return;
|
||
}
|
||
e.preventDefault();
|
||
const id = node.dataset.ctl;
|
||
const place = layout[id];
|
||
this.select(id);
|
||
const area = padArea.getBoundingClientRect();
|
||
const startX = e.clientX, startY = e.clientY, x0 = place.x, y0 = place.y;
|
||
let moved = false;
|
||
pointers.set(e.pointerId, {
|
||
move(ev) {
|
||
if (!moved && Math.hypot(ev.clientX - startX, ev.clientY - startY) < 4) return;
|
||
moved = true;
|
||
padView.classList.add('dragging');
|
||
place.x = x0 + (ev.clientX - startX) / area.width * 100;
|
||
place.y = y0 + (ev.clientY - startY) / area.height * 100;
|
||
keepOnScreen(node, place);
|
||
setPlace(node, place);
|
||
},
|
||
up() {
|
||
padView.classList.remove('dragging');
|
||
editor.placePanel();
|
||
},
|
||
});
|
||
try { padView.setPointerCapture(e.pointerId); } catch (err) { /* already gone */ }
|
||
},
|
||
|
||
resize(percent) {
|
||
const id = this.selected;
|
||
if (!id) return;
|
||
const node = ctlNode(id);
|
||
const place = layout[id];
|
||
place.w = Math.round(DEFAULT_LAYOUT[id].w * percent) / 100;
|
||
place.h = Math.round(DEFAULT_LAYOUT[id].h * percent) / 100;
|
||
setPlace(node, place);
|
||
keepOnScreen(node, place);
|
||
setPlace(node, place);
|
||
this.placePanel();
|
||
},
|
||
|
||
toggleHidden() {
|
||
const id = this.selected;
|
||
if (!id) return;
|
||
layout[id].hide = !layout[id].hide;
|
||
applyLayout();
|
||
this.select(id);
|
||
},
|
||
|
||
// Reset asks for a second tap, so a stray tap doesn't throw a layout away
|
||
reset() {
|
||
if (!this.resetTimer) {
|
||
this.resetTimer = setTimeout(() => this.disarmReset(), 3000);
|
||
$('#lbReset').textContent = 'Tap again to reset';
|
||
$('#lbReset').classList.add('warn');
|
||
return;
|
||
}
|
||
this.disarmReset();
|
||
layout = defaultLayout();
|
||
this.select('');
|
||
applyLayout();
|
||
toast('Back to the default layout. Tap Done to keep it.');
|
||
},
|
||
|
||
disarmReset() {
|
||
clearTimeout(this.resetTimer);
|
||
this.resetTimer = 0;
|
||
$('#lbReset').textContent = 'Reset to default';
|
||
$('#lbReset').classList.remove('warn');
|
||
},
|
||
};
|
||
|
||
$('#lbSize').addEventListener('input', () => editor.resize(Number($('#lbSize').value)));
|
||
$('#lbHide').addEventListener('click', () => editor.toggleHidden());
|
||
$('#lbReset').addEventListener('click', () => editor.reset());
|
||
$('#lbCancel').addEventListener('click', () => editor.finish(false));
|
||
$('#lbDone').addEventListener('click', () => editor.finish(true));
|
||
|
||
const onOrientation = () => {
|
||
editor.finish(true);
|
||
layout = loadLayout();
|
||
applyLayout();
|
||
};
|
||
if (portraitQuery.addEventListener) portraitQuery.addEventListener('change', onOrientation);
|
||
else portraitQuery.addListener(onOrientation);
|
||
|
||
applyLayout();
|
||
|
||
function buttonHandler(el) {
|
||
let current = el;
|
||
press(screenSource, B[el.dataset.btn]);
|
||
return {
|
||
move(ev) {
|
||
if (!current.hasAttribute('data-slide')) return;
|
||
const hit = document.elementFromPoint(ev.clientX, ev.clientY);
|
||
const next = hit && hit.closest('[data-slide]');
|
||
if (next && next !== current) {
|
||
release(screenSource, B[current.dataset.btn]);
|
||
current = next;
|
||
press(screenSource, B[current.dataset.btn]);
|
||
}
|
||
},
|
||
up() { release(screenSource, B[current.dataset.btn]); },
|
||
};
|
||
}
|
||
|
||
function dpadHandler(el, e) {
|
||
let bits = 0;
|
||
const update = ev => {
|
||
const r = el.getBoundingClientRect();
|
||
const dx = (ev.clientX - (r.left + r.width / 2)) / (r.width / 2);
|
||
const dy = (ev.clientY - (r.top + r.height / 2)) / (r.height / 2);
|
||
let next = 0;
|
||
if (Math.hypot(dx, dy) > 0.2) {
|
||
const angle = Math.atan2(dy, dx) * 180 / Math.PI;
|
||
const sector = Math.round(((angle + 360) % 360) / 45) % 8;
|
||
next = [B.RIGHT, B.RIGHT | B.DOWN, B.DOWN, B.DOWN | B.LEFT, B.LEFT, B.LEFT | B.UP, B.UP, B.UP | B.RIGHT][sector];
|
||
}
|
||
for (const bit of [B.UP, B.DOWN, B.LEFT, B.RIGHT]) {
|
||
if ((next & bit) && !(bits & bit)) press(screenSource, bit);
|
||
if (!(next & bit) && (bits & bit)) release(screenSource, bit);
|
||
}
|
||
bits = next;
|
||
};
|
||
update(e);
|
||
return {
|
||
move: update,
|
||
up() { for (const bit of [B.UP, B.DOWN, B.LEFT, B.RIGHT]) if (bits & bit) release(screenSource, bit); },
|
||
};
|
||
}
|
||
|
||
// L3 / R3 on a touch screen: a short, still tap on a stick, then a second touch right after presses the
|
||
// stick in until that touch lifts
|
||
const lastStickTap = { L: null, R: null };
|
||
|
||
function stickHandler(el, e) {
|
||
const side = el.dataset.stick;
|
||
const knob = $('.knob', el);
|
||
const r = el.getBoundingClientRect();
|
||
const radius = r.width * 0.36;
|
||
const travel = r.width * KNOB_TRAVEL;
|
||
let cx = r.left + r.width / 2;
|
||
let cy = r.top + r.height / 2;
|
||
if (settings.floating) { cx = e.clientX; cy = e.clientY; }
|
||
const startX = e.clientX, startY = e.clientY, startAt = now();
|
||
const tap = lastStickTap[side];
|
||
const clickBit = side === 'L' ? B.L3 : B.R3;
|
||
const clicking = !!(settings.stickClick && tap && startAt - tap.at < 300 && Math.hypot(startX - tap.x, startY - tap.y) < r.width * 0.3);
|
||
lastStickTap[side] = null;
|
||
if (clicking) press(screenSource, clickBit);
|
||
let moved = false;
|
||
draggingStick[side] = true;
|
||
el.classList.add('on');
|
||
const update = ev => {
|
||
let dx = ev.clientX - cx;
|
||
let dy = ev.clientY - cy;
|
||
const len = Math.hypot(dx, dy);
|
||
if (len > radius) { dx *= radius / len; dy *= radius / len; }
|
||
if (Math.hypot(ev.clientX - startX, ev.clientY - startY) > r.width * 0.1) moved = true;
|
||
knob.style.transform = `translate(${dx / radius * travel}px, ${dy / radius * travel}px)`;
|
||
const x = Math.round(clamp(128 + (dx / radius) * 127.5, 0, 255));
|
||
const y = Math.round(clamp(128 + (dy / radius) * 127.5, 0, 255));
|
||
if (side === 'L') { screenSource.lx = x; screenSource.ly = y; } else { screenSource.rx = x; screenSource.ry = y; }
|
||
};
|
||
update(e);
|
||
return {
|
||
move: update,
|
||
up(ev) {
|
||
draggingStick[side] = false;
|
||
el.classList.remove('on');
|
||
knob.style.transform = '';
|
||
padShown.stick[side] = '';
|
||
if (side === 'L') { screenSource.lx = 128; screenSource.ly = 128; } else { screenSource.rx = 128; screenSource.ry = 128; }
|
||
if (clicking) release(screenSource, clickBit);
|
||
else if (ev && !moved && now() - startAt < 250) lastStickTap[side] = { at: now(), x: startX, y: startY };
|
||
},
|
||
};
|
||
}
|
||
|
||
let touchIdCounter = 0;
|
||
const touchpad = $('#touchpad');
|
||
|
||
function touchHandler(el, e) {
|
||
if (screenSource.touches.length >= 2) return null;
|
||
const id = touchIdCounter = (touchIdCounter + 1) % 128;
|
||
const dot = el.appendChild(el.ownerDocument.createElement('span'));
|
||
dot.className = 'finger';
|
||
const touch = { id, x: 0, y: 0 };
|
||
screenSource.touches.push(touch);
|
||
flush(now());
|
||
const startX = e.clientX, startY = e.clientY, startAt = now();
|
||
let moved = false, held = false;
|
||
const update = ev => {
|
||
const r = el.getBoundingClientRect();
|
||
const fx = clamp((ev.clientX - r.left) / r.width, 0, 1);
|
||
const fy = clamp((ev.clientY - r.top) / r.height, 0, 1);
|
||
touch.x = Math.round(fx * TOUCH_W);
|
||
touch.y = Math.round(fy * TOUCH_H);
|
||
dot.style.left = (fx * 100) + '%';
|
||
dot.style.top = (fy * 100) + '%';
|
||
if (Math.hypot(ev.clientX - startX, ev.clientY - startY) > 12) moved = true;
|
||
};
|
||
update(e);
|
||
const holdTimer = setTimeout(() => {
|
||
if (!moved && settings.tapClick) { held = true; press(screenSource, B.TOUCH); }
|
||
}, 420);
|
||
const remove = () => {
|
||
dot.remove();
|
||
const i = screenSource.touches.indexOf(touch);
|
||
if (i >= 0) screenSource.touches.splice(i, 1);
|
||
flush(now());
|
||
};
|
||
return {
|
||
move: update,
|
||
up(ev) {
|
||
clearTimeout(holdTimer);
|
||
if (held) {
|
||
release(screenSource, B.TOUCH);
|
||
remove();
|
||
} else if (ev && ev.type === 'pointerup' && !moved && settings.tapClick && now() - startAt < 300) {
|
||
// Tap: click while the finger is still reported on the pad
|
||
press(screenSource, B.TOUCH);
|
||
setTimeout(() => { release(screenSource, B.TOUCH); remove(); }, 90);
|
||
} else {
|
||
remove();
|
||
}
|
||
},
|
||
};
|
||
}
|
||
|
||
padView.addEventListener('pointerdown', e => {
|
||
if (editor.active) {
|
||
editor.pointerDown(e);
|
||
return;
|
||
}
|
||
const target = e.target.closest('[data-btn], .dpad, [data-stick], #touchpad');
|
||
if (!target || !padView.contains(target)) return;
|
||
e.preventDefault();
|
||
let handler;
|
||
if (target.dataset.btn) handler = buttonHandler(target);
|
||
else if (target.classList.contains('dpad')) handler = dpadHandler(target, e);
|
||
else if (target.dataset.stick) handler = stickHandler(target, e);
|
||
else handler = touchHandler(target, e);
|
||
if (!handler) return;
|
||
pointers.set(e.pointerId, handler);
|
||
try { padView.setPointerCapture(e.pointerId); } catch (err) { /* already gone */ }
|
||
});
|
||
|
||
padView.addEventListener('pointermove', e => {
|
||
const h = pointers.get(e.pointerId);
|
||
if (h) h.move(e);
|
||
});
|
||
|
||
const endPointer = e => {
|
||
const h = pointers.get(e.pointerId);
|
||
if (!h) return;
|
||
pointers.delete(e.pointerId);
|
||
h.up(e);
|
||
};
|
||
padView.addEventListener('pointerup', endPointer);
|
||
padView.addEventListener('pointercancel', endPointer);
|
||
padView.addEventListener('lostpointercapture', endPointer);
|
||
padView.addEventListener('contextmenu', e => e.preventDefault());
|
||
|
||
function releaseScreen() {
|
||
for (const [id, h] of pointers) { pointers.delete(id); h.up(); }
|
||
Object.assign(screenSource, blankSource(screenSource.pad));
|
||
}
|
||
|
||
// ---------------------------------------------------------------- keyboard (remappable)
|
||
|
||
const KEY_ACTIONS = [
|
||
['CROSS', 'Cross', 'Space'], ['CIRCLE', 'Circle', 'Escape'], ['SQUARE', 'Square', 'Backspace'],
|
||
['TRIANGLE', 'Triangle', 'Enter'], ['L1', 'L1', 'KeyQ'], ['R1', 'R1', 'KeyE'], ['L2', 'L2', 'Digit1'],
|
||
['R2', 'R2', 'Digit3'], ['L3', 'L3', 'KeyZ'], ['R3', 'R3', 'KeyC'], ['OPTIONS', 'Options', 'KeyO'],
|
||
['PS', 'PS button', 'KeyP'], ['SHARE', 'Share', 'KeyV'], ['TOUCH', 'Touchpad click', 'KeyU'], ['UP', 'D-pad up', 'KeyI'], ['DOWN', 'D-pad down', 'KeyK'],
|
||
['LEFT', 'D-pad left', 'KeyJ'], ['RIGHT', 'D-pad right', 'KeyL'],
|
||
['LS_UP', 'Left stick up', 'KeyW'], ['LS_DOWN', 'Left stick down', 'KeyS'], ['LS_LEFT', 'Left stick left', 'KeyA'], ['LS_RIGHT', 'Left stick right', 'KeyD'],
|
||
['RS_UP', 'Right stick up', 'ArrowUp'], ['RS_DOWN', 'Right stick down', 'ArrowDown'], ['RS_LEFT', 'Right stick left', 'ArrowLeft'], ['RS_RIGHT', 'Right stick right', 'ArrowRight'],
|
||
['T_TL', 'Touch top left', 'KeyT'], ['T_TR', 'Touch top right', 'KeyY'], ['T_BL', 'Touch bottom left', 'KeyG'], ['T_BR', 'Touch bottom right', 'KeyH'],
|
||
];
|
||
const STICK_ACTIONS = { LS_UP: ['ly', -1], LS_DOWN: ['ly', 1], LS_LEFT: ['lx', -1], LS_RIGHT: ['lx', 1], RS_UP: ['ry', -1], RS_DOWN: ['ry', 1], RS_LEFT: ['rx', -1], RS_RIGHT: ['rx', 1] };
|
||
const TOUCH_ACTIONS = { T_TL: [0.25, 0.25], T_TR: [0.75, 0.25], T_BL: [0.25, 0.75], T_BR: [0.75, 0.75] };
|
||
const defaultKeys = () => Object.fromEntries(KEY_ACTIONS.map(([action, , code]) => [action, code]));
|
||
function validKey(code) {
|
||
return typeof code === 'string' && (code === '' || /^(Key[A-Z]|Digit[0-9]|Numpad[0-9]|F([1-9]|1[0-2])|Arrow(Up|Down|Left|Right)|Space|Escape|Backspace|Enter|Tab|Shift(Left|Right)|Bracket(Left|Right)|Semicolon|Quote|Comma|Period|Slash|Backslash|Minus|Equal|Backquote|Delete|Insert|Home|End|PageUp|PageDown|CapsLock|NumLock|Numpad(Add|Subtract|Multiply|Divide|Decimal|Enter))$/.test(code));
|
||
}
|
||
function loadKeys(saved) {
|
||
const keys = defaultKeys();
|
||
if (saved && typeof saved === 'object' && !Array.isArray(saved))
|
||
for (const action of Object.keys(keys)) if (validKey(saved[action])) keys[action] = saved[action];
|
||
return keys;
|
||
}
|
||
let keymap = loadKeys(store.get('keymap', {}));
|
||
let actionForCode = {};
|
||
const heldKeys = new Map(); // code -> action
|
||
|
||
function rebuildKeymap() {
|
||
actionForCode = {};
|
||
for (const [action, code] of Object.entries(keymap)) if (code) actionForCode[code] = action;
|
||
for (const hint of $$('[data-hint]')) hint.textContent = keyLabel(keymap[hint.dataset.hint === 'OPTIONS' ? 'OPTIONS' : hint.dataset.hint]);
|
||
}
|
||
|
||
function keyLabel(code) {
|
||
if (!code) return '—';
|
||
const names = { Space: 'Space', Escape: 'Esc', Backspace: 'Bksp', Enter: 'Enter', ArrowUp: '↑', ArrowDown: '↓', ArrowLeft: '←', ArrowRight: '→', ShiftLeft: 'Shift', ShiftRight: 'R Shift', ControlLeft: 'Ctrl', ControlRight: 'R Ctrl', AltLeft: 'Alt', AltRight: 'R Alt', Tab: 'Tab' };
|
||
return names[code] || code.replace(/^Key/, '').replace(/^Digit/, '').replace(/^Numpad/, 'Num ');
|
||
}
|
||
|
||
function updateKeySticks() {
|
||
for (const axis of ['lx', 'ly', 'rx', 'ry']) {
|
||
let v = 0;
|
||
for (const action of heldKeys.values()) {
|
||
const s = STICK_ACTIONS[action];
|
||
if (s && s[0] === axis) v += s[1];
|
||
}
|
||
keySource[axis] = v === 0 ? 128 : v < 0 ? 0 : 255;
|
||
}
|
||
}
|
||
|
||
function typing() {
|
||
const a = document.activeElement;
|
||
return a && (a.tagName === 'INPUT' && a.type !== 'checkbox' || a.tagName === 'SELECT' || a.tagName === 'TEXTAREA');
|
||
}
|
||
|
||
window.addEventListener('keydown', e => {
|
||
if (primaryPad < 0 || padView.hidden || typing() || e.metaKey || e.ctrlKey || e.altKey) return;
|
||
const action = actionForCode[e.code];
|
||
if (!action) return;
|
||
e.preventDefault();
|
||
if (e.repeat || heldKeys.has(e.code)) return;
|
||
heldKeys.set(e.code, action);
|
||
if (STICK_ACTIONS[action]) updateKeySticks();
|
||
else if (TOUCH_ACTIONS[action]) {
|
||
const [fx, fy] = TOUCH_ACTIONS[action];
|
||
const id = touchIdCounter = (touchIdCounter + 1) % 128;
|
||
keySource.touches = [{ id, x: Math.round(fx * TOUCH_W), y: Math.round(fy * TOUCH_H), code: e.code }];
|
||
press(keySource, B.TOUCH);
|
||
} else press(keySource, B[action]);
|
||
});
|
||
|
||
window.addEventListener('keyup', e => {
|
||
const action = heldKeys.get(e.code);
|
||
if (!action) return;
|
||
heldKeys.delete(e.code);
|
||
e.preventDefault();
|
||
if (STICK_ACTIONS[action]) updateKeySticks();
|
||
else if (TOUCH_ACTIONS[action]) {
|
||
release(keySource, B.TOUCH);
|
||
setTimeout(() => { keySource.touches = keySource.touches.filter(t => t.code !== e.code); }, MIN_HOLD_MS);
|
||
} else release(keySource, B[action]);
|
||
});
|
||
|
||
window.addEventListener('blur', () => {
|
||
mapper.cancelCapture();
|
||
mapper.keysDown.clear();
|
||
heldKeys.clear();
|
||
Object.assign(keySource, blankSource(keySource.pad));
|
||
});
|
||
|
||
// ---------------------------------------------------------------- gamepads
|
||
|
||
// Pure mapping stages. No DOM, browser index, storage or network dependencies.
|
||
const ControllerMapping = (() => {
|
||
const axes = ['lx', 'ly', 'rx', 'ry'];
|
||
const directions = { LS_LEFT: ['lx', -1], LS_RIGHT: ['lx', 1], LS_UP: ['ly', -1], LS_DOWN: ['ly', 1],
|
||
RS_LEFT: ['rx', -1], RS_RIGHT: ['rx', 1], RS_UP: ['ry', -1], RS_DOWN: ['ry', 1] };
|
||
const hats = [[], ['UP'], ['UP', 'RIGHT'], ['RIGHT'], ['RIGHT', 'DOWN'], ['DOWN'],
|
||
['DOWN', 'LEFT'], ['LEFT'], ['LEFT', 'UP']];
|
||
const outputs = [...Object.keys(B), ...axes, ...Object.keys(directions)].filter(k => k !== 'NONE');
|
||
function outputLabel(key) {
|
||
const labels = { lx: 'Left stick, left and right', ly: 'Left stick, up and down', rx: 'Right stick, left and right', ry: 'Right stick, up and down',
|
||
TOUCH: 'Touchpad click', PS: 'PS button', LS_LEFT: 'Left stick left', LS_RIGHT: 'Left stick right',
|
||
LS_UP: 'Left stick up', LS_DOWN: 'Left stick down', RS_LEFT: 'Right stick left', RS_RIGHT: 'Right stick right',
|
||
RS_UP: 'Right stick up', RS_DOWN: 'Right stick down', UP: 'D-pad up', DOWN: 'D-pad down', LEFT: 'D-pad left', RIGHT: 'D-pad right' };
|
||
return labels[key] || (/^[LR][123]$/.test(key) ? key : key[0] + key.slice(1).toLowerCase());
|
||
}
|
||
const copy = value => JSON.parse(JSON.stringify(value));
|
||
const bounded = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||
const finite = v => typeof v === 'number' && Number.isFinite(v);
|
||
function signature(gp) {
|
||
return { id: String(gp.id || ''), mapping: String(gp.mapping || ''), buttons: gp.buttons.length, axes: gp.axes.length };
|
||
}
|
||
const signatureKey = s => JSON.stringify([s.id, s.mapping, s.buttons, s.axes]);
|
||
function read(gp) {
|
||
return { signature: signature(gp),
|
||
buttons: Array.from(gp.buttons, b => b && finite(b.value) && b.value >= 0 && b.value <= 1
|
||
? { value: b.value, pressed: b.pressed === true } : null),
|
||
axes: Array.from(gp.axes, v => finite(v) && Math.abs(v) <= 8 ? v : null) };
|
||
}
|
||
function empty() { return { buttons: 0, l2: 0, r2: 0, analog: { lx: [], ly: [], rx: [], ry: [] }, digital: { lx: [], ly: [], rx: [], ry: [] } }; }
|
||
function strongest(values) {
|
||
let strength = 0, positive = false, negative = false;
|
||
for (const v of values) {
|
||
if (!finite(v)) continue;
|
||
const a = Math.abs(v);
|
||
if (a > strength + 1e-9) { strength = a; positive = negative = false; }
|
||
if (Math.abs(a - strength) <= 1e-9 && a) { if (v > 0) positive = true; else negative = true; }
|
||
}
|
||
return positive && negative ? 0 : negative ? -strength : strength;
|
||
}
|
||
function merge(states) {
|
||
const out = empty();
|
||
for (const s of states) {
|
||
out.buttons |= s.buttons;
|
||
out.l2 = Math.max(out.l2, s.l2); out.r2 = Math.max(out.r2, s.r2);
|
||
for (const axis of axes) { out.analog[axis].push(...s.analog[axis]); out.digital[axis].push(...s.digital[axis]); }
|
||
}
|
||
return out;
|
||
}
|
||
function axisValue(out, axis) {
|
||
const d = out.digital[axis];
|
||
const digital = d.some(v => v < 0) === d.some(v => v > 0) ? 0 : d.some(v => v < 0) ? -1 : 1;
|
||
return strongest([...out.analog[axis], digital]);
|
||
}
|
||
function toSource(out) {
|
||
const s = { buttons: out.buttons, l2: Math.round(out.l2 * 255), r2: Math.round(out.r2 * 255) };
|
||
for (const axis of axes) {
|
||
const v = axisValue(out, axis);
|
||
s[axis] = Math.round((v + 1) / 2 * 255);
|
||
}
|
||
return s;
|
||
}
|
||
function fromSource(src) {
|
||
const out = empty(); out.buttons = src.buttons;
|
||
out.l2 = src.buttons & B.L2 ? 1 : src.l2 / 255; out.r2 = src.buttons & B.R2 ? 1 : src.r2 / 255;
|
||
for (const axis of axes) out.analog[axis].push(src[axis] === 128 ? 0 : src[axis] / 255 * 2 - 1);
|
||
return out;
|
||
}
|
||
function normalize(raw, c, mode) {
|
||
if (!finite(raw)) return null;
|
||
if (c && c.type === 'pedal') return bounded((raw - c.released) / (c.full - c.released), 0, 1);
|
||
const center = c ? c.center : 0, min = c ? c.min : -1, max = c ? c.max : 1;
|
||
let v = bounded((raw - center) / (raw < center ? center - min : max - center), -1, 1);
|
||
if (mode === 'positive') v = Math.max(0, v);
|
||
if (mode === 'negative') v = Math.max(0, -v);
|
||
return v;
|
||
}
|
||
function inputValue(snapshot, profile, input) {
|
||
if (input.kind === 'button') {
|
||
const b = snapshot.buttons[input.index];
|
||
return b ? input.mode === 'digital' ? (b.pressed || b.value > 0.5 ? 1 : 0) : b.value : null;
|
||
}
|
||
const raw = snapshot.axes[input.index], c = profile.calibration[input.index];
|
||
if (input.kind === 'hat') {
|
||
if (!finite(raw) || !c || c.type !== 'hat') return null;
|
||
let nearest = -1, distance = Infinity;
|
||
c.values.forEach((v, i) => { const d = Math.abs(raw - v); if (d < distance) { distance = d; nearest = i; } });
|
||
return distance <= c.tolerance && hats[nearest].includes(input.direction) ? 1 : 0;
|
||
}
|
||
if (c && c.type === 'hat') return null;
|
||
return normalize(raw, c, input.mode);
|
||
}
|
||
function tune(v, binding) {
|
||
const signed = binding.input.kind === 'axis' && binding.input.mode === 'center';
|
||
if (binding.invert) v = signed ? -v : 1 - v;
|
||
if (binding.legacy) {
|
||
return Math.abs(v) < 0.06 ? 0 : v;
|
||
}
|
||
const a = Math.abs(v), dz = binding.deadzone, sat = binding.saturation;
|
||
const magnitude = a <= dz ? 0 : Math.pow(bounded((a - dz) / (sat - dz), 0, 1), binding.exponent);
|
||
return Math.sign(v) * magnitude;
|
||
}
|
||
function evaluate(snapshot, profile, transient = {}, hold = {}) {
|
||
const out = empty();
|
||
profile.bindings.forEach((binding, i) => {
|
||
let v = inputValue(snapshot, profile, binding.input);
|
||
if (v === null || hold[i]) { delete transient[i]; return; }
|
||
v = tune(v, binding);
|
||
const target = binding.output;
|
||
if (target === 'L2' || target === 'R2') out[target.toLowerCase()] = Math.max(out[target.toLowerCase()], Math.max(0, v));
|
||
else if (axes.includes(target)) {
|
||
if (binding.input.kind === 'button' && binding.input.mode === 'digital' || binding.input.kind === 'hat') out.digital[target].push(Math.sign(v));
|
||
else out.analog[target].push(v);
|
||
} else {
|
||
const active = v >= (transient[i] ? binding.release : binding.activate);
|
||
transient[i] = active;
|
||
if (active && directions[target]) {
|
||
const [axis, sign] = directions[target]; out.digital[axis].push(sign);
|
||
} else if (active && B[target]) out.buttons |= B[target];
|
||
}
|
||
});
|
||
return out;
|
||
}
|
||
function binding(input, output, extra = {}) {
|
||
return Object.assign({ input, output, invert: false, deadzone: 0, saturation: 1, exponent: 1, activate: .5, release: .4 }, extra);
|
||
}
|
||
function browserDefault(sig) {
|
||
const names = ['CROSS', 'CIRCLE', 'SQUARE', 'TRIANGLE', 'L1', 'R1', 'L2', 'R2', 'SHARE', 'OPTIONS', 'L3', 'R3', 'UP', 'DOWN', 'LEFT', 'RIGHT', 'PS', 'TOUCH'];
|
||
return { id: 'default', name: 'Standard layout', signature: copy(sig), calibration: {},
|
||
bindings: [...names.slice(0, sig.buttons).map((output, index) => binding({ kind: 'button', index, mode: index === 6 || index === 7 ? 'pressure' : 'digital' }, output)),
|
||
...axes.slice(0, sig.axes).map((output, index) => binding({ kind: 'axis', index, mode: 'center' }, output, { legacy: true }))] };
|
||
}
|
||
function validate(profile) {
|
||
const s = profile && profile.signature;
|
||
if (!profile || typeof profile.id !== 'string' || !/^[\w-]{1,96}$/.test(profile.id) ||
|
||
typeof profile.name !== 'string' || !profile.name.trim() || profile.name.length > 100 ||
|
||
!s || typeof s.id !== 'string' || s.id.length > 2048 || typeof s.mapping !== 'string' || s.mapping.length > 100 ||
|
||
!Number.isInteger(s.buttons) || s.buttons < 0 || s.buttons > 1024 || !Number.isInteger(s.axes) || s.axes < 0 || s.axes > 1024 ||
|
||
!profile.calibration || typeof profile.calibration !== 'object' || Array.isArray(profile.calibration) ||
|
||
!Array.isArray(profile.bindings) || profile.bindings.length > 4096) return 'Invalid profile structure';
|
||
for (const [index, c] of Object.entries(profile.calibration)) {
|
||
if (!/^\d+$/.test(index) || +index >= s.axes || !c) return 'Invalid calibration index';
|
||
if (c.type === 'center') {
|
||
if (![c.min, c.center, c.max].every(finite) || !(c.min < c.center && c.center < c.max)) return 'Minimum must be below center and maximum';
|
||
} else if (c.type === 'pedal') {
|
||
if (![c.released, c.full].every(finite) || Math.abs(c.released - c.full) < .0001) return 'Released and full positions must differ';
|
||
} else if (c.type === 'hat') {
|
||
if (!Array.isArray(c.values) || c.values.length !== 9 || !c.values.every(finite) || !finite(c.tolerance) || c.tolerance <= 0 ||
|
||
c.values.some((v, i) => c.values.some((w, j) => i !== j && Math.abs(v - w) <= c.tolerance * 2))) return 'Hat positions must be distinct with a smaller tolerance';
|
||
} else return 'Unknown calibration type';
|
||
}
|
||
for (const b of profile.bindings) {
|
||
const i = b && b.input;
|
||
if (!i || !['button', 'axis', 'hat'].includes(i.kind) || !Number.isInteger(i.index) || i.index < 0 ||
|
||
i.index >= (i.kind === 'button' ? s.buttons : s.axes) || !outputs.includes(b.output) ||
|
||
![b.deadzone, b.saturation, b.exponent, b.activate, b.release].every(finite) ||
|
||
b.deadzone < 0 || b.saturation > 1 || b.saturation <= b.deadzone || b.exponent < .1 || b.exponent > 10 ||
|
||
b.release < 0 || b.release >= b.activate || b.activate > 1 || typeof b.invert !== 'boolean' ||
|
||
(b.legacy !== undefined && typeof b.legacy !== 'boolean')) return 'Invalid binding or tuning';
|
||
if (i.kind === 'button' && !['digital', 'pressure'].includes(i.mode) ||
|
||
i.kind === 'axis' && !['center', 'positive', 'negative', 'pedal'].includes(i.mode)) return 'Invalid input mode';
|
||
if (i.kind === 'hat' && (!['UP', 'RIGHT', 'DOWN', 'LEFT'].includes(i.direction) || !profile.calibration[i.index] || profile.calibration[i.index].type !== 'hat')) return 'Calibrate this hat before applying';
|
||
if (i.kind === 'axis' && (profile.calibration[i.index]?.type === 'hat' ||
|
||
i.mode === 'pedal' && profile.calibration[i.index]?.type !== 'pedal' ||
|
||
i.mode !== 'pedal' && profile.calibration[i.index]?.type === 'pedal')) return 'Input mode must match calibration';
|
||
}
|
||
return '';
|
||
}
|
||
function unresolved(profile, sig) {
|
||
return profile.bindings.some(b => b.input.index >= (b.input.kind === 'button' ? sig.buttons : sig.axes)) ||
|
||
Object.keys(profile.calibration).some(i => +i >= sig.axes);
|
||
}
|
||
// After a gamepad changes profile or comes back, a button still held down
|
||
// must not press anything: each binding that presses something waits until
|
||
// its own input has been let go. Stick axes never wait, and nothing waits
|
||
// for anything else, so a worn stick resting a little off centre, or an axis
|
||
// that never rests at zero, can't keep the rest of the gamepad silent.
|
||
function holdBack(profile) {
|
||
const hold = {};
|
||
profile.bindings.forEach((b, i) => { if (!axes.includes(b.output)) hold[i] = true; });
|
||
return hold;
|
||
}
|
||
function letGo(snapshot, profile, hold) {
|
||
for (const i of Object.keys(hold)) {
|
||
const b = profile.bindings[i];
|
||
const v = b && inputValue(snapshot, profile, b.input);
|
||
// Judged before inversion: an inverted binding is pressed at rest on purpose.
|
||
if (!b || (v !== null && Math.abs(tune(v, Object.assign({}, b, { invert: false }))) < Math.min(b.release, .2))) delete hold[i];
|
||
}
|
||
return hold;
|
||
}
|
||
// A stick or wheel's range, from where it rests and both ends in either order.
|
||
function centeredRange(center, a, b) {
|
||
const min = Math.min(a, b), max = Math.max(a, b);
|
||
if (![center, a, b].every(finite) || !(min < center - .1 && center + .1 < max)) return null;
|
||
return { type: 'center', min, center, max };
|
||
}
|
||
// A hat from its rest and its four directions. Each diagonal sits halfway
|
||
// between its neighbours; up-left, past left, one more step on.
|
||
function hatFromCardinals(rest, up, right, down, left) {
|
||
if (![rest, up, right, down, left].every(finite)) return null;
|
||
const mid = (a, b) => (a + b) / 2;
|
||
const values = [rest, up, mid(up, right), right, mid(right, down), down, mid(down, left), left, left + (left - down) / 2];
|
||
let gap = Infinity;
|
||
values.forEach((v, i) => values.forEach((w, j) => { if (i < j) gap = Math.min(gap, Math.abs(v - w)); }));
|
||
if (gap < .02) return null;
|
||
return { type: 'hat', values, tolerance: gap / 3 };
|
||
}
|
||
function exportProfile(profile) { return { format: 'control4free-controller-profile', schema: 1, profile: copy(profile) }; }
|
||
function importProfile(value) {
|
||
if (!value || value.format !== 'control4free-controller-profile' || value.schema !== 1) throw Error('Unsupported profile format or schema');
|
||
const error = validate(value.profile); if (error) throw Error(error);
|
||
return copy(value.profile);
|
||
}
|
||
return { axes, directions, hats, outputs, outputLabel, copy, signature, signatureKey, read, empty, merge, toSource, fromSource, normalize, inputValue,
|
||
tune, evaluate, binding, browserDefault, validate, unresolved, holdBack, letGo, centeredRange, hatFromCardinals, exportProfile, importProfile };
|
||
})();
|
||
|
||
const profileStore = { schema: 1, profiles: [], preferred: {} };
|
||
let profilePersistence = '', profileStorageBlocked = false;
|
||
function loadProfiles() {
|
||
try {
|
||
const raw = localStorage.getItem('c4f.gamepadProfiles');
|
||
if (!raw) return;
|
||
const saved = JSON.parse(raw);
|
||
if (!saved || saved.schema !== 1) {
|
||
profileStorageBlocked = true; profilePersistence = 'Unknown storage schema preserved. Changes are session-only.'; return;
|
||
}
|
||
if (!Array.isArray(saved.profiles) || !saved.preferred || typeof saved.preferred !== 'object' || Array.isArray(saved.preferred)) throw Error();
|
||
const ids = new Set();
|
||
for (const p of saved.profiles) {
|
||
if (p.id === 'default' || ids.has(p.id) || ControllerMapping.validate(p)) { profilePersistence = 'Invalid saved profiles were ignored.'; continue; }
|
||
ids.add(p.id); profileStore.profiles.push(ControllerMapping.copy(p));
|
||
}
|
||
for (const [key, id] of Object.entries(saved.preferred))
|
||
if (key.length < 4096 && typeof id === 'string' && ids.has(id)) Object.defineProperty(profileStore.preferred, key, { value: id, writable: true, enumerable: true, configurable: true });
|
||
} catch (e) { profilePersistence = 'Saved profiles could not be read. Browser defaults are active.'; }
|
||
}
|
||
function persistProfiles() {
|
||
if (profileStorageBlocked) return false;
|
||
try { localStorage.setItem('c4f.gamepadProfiles', JSON.stringify(profileStore)); profilePersistence = ''; return true; }
|
||
catch (e) { profilePersistence = 'Storage unavailable. Profile changes are session-only.'; toast(profilePersistence); return false; }
|
||
}
|
||
function profileFor(src) { return profileStore.profiles.find(p => p.id === src.profileId) || ControllerMapping.browserDefault(src.signature); }
|
||
// Forgets what the gamepad was doing. Its bindings that press something then
|
||
// wait until their input is let go (ControllerMapping.holdBack).
|
||
function clearDevice(src) {
|
||
Object.assign(src, blankSource(src.pad)); src.mapped = ControllerMapping.empty(); src.transient = {};
|
||
src.hold = null; src.waitNeutral = true;
|
||
}
|
||
function preferProfile(src, id) {
|
||
src.profileId = id;
|
||
const key = ControllerMapping.signatureKey(src.signature);
|
||
Object.defineProperty(profileStore.preferred, key, { value: id, writable: true, enumerable: true, configurable: true });
|
||
clearDevice(src); persistProfiles();
|
||
}
|
||
loadProfiles();
|
||
|
||
// ---------------------------------------------------------------- mapping view
|
||
|
||
// A DualShock 4 to map a gamepad or the keyboard onto: the controller
|
||
// screen's own controls, copied, with what drives each one written beside it.
|
||
// Pressing anything lights up what it does. Click a control to change it.
|
||
const MAP_TARGETS = {
|
||
CROSS: 'Cross', CIRCLE: 'Circle', SQUARE: 'Square', TRIANGLE: 'Triangle',
|
||
L1: 'L1', R1: 'R1', L2: 'L2', R2: 'R2', L3: 'L3', R3: 'R3',
|
||
SHARE: 'Share', OPTIONS: 'Options', PS: 'PS button', TOUCH: 'Touchpad',
|
||
UP: 'D-pad up', DOWN: 'D-pad down', LEFT: 'D-pad left', RIGHT: 'D-pad right',
|
||
LS: 'Left stick', RS: 'Right stick',
|
||
};
|
||
const STICKS = {
|
||
LS: { x: 'lx', y: 'ly', up: 'LS_UP', down: 'LS_DOWN', left: 'LS_LEFT', right: 'LS_RIGHT' },
|
||
RS: { x: 'rx', y: 'ry', up: 'RS_UP', down: 'RS_DOWN', left: 'RS_LEFT', right: 'RS_RIGHT' },
|
||
};
|
||
const TRIGGERS = ['L2', 'R2'];
|
||
const ARROWS = { UP: '↑', RIGHT: '→', DOWN: '↓', LEFT: '←' };
|
||
|
||
// Which DualShock 4 outputs a picture control stands for.
|
||
function targetOutputs(target) {
|
||
const s = STICKS[target];
|
||
return s ? [s.x, s.y, s.up, s.down, s.left, s.right] : [target];
|
||
}
|
||
function targetOf(output) {
|
||
for (const [id, s] of Object.entries(STICKS)) if (Object.values(s).includes(output)) return id;
|
||
return output;
|
||
}
|
||
|
||
// A gamepad input, in full and as the short tag on the picture.
|
||
function describeInput(input) {
|
||
if (input.kind === 'button') return 'Button ' + input.index + (input.mode === 'pressure' ? ', pressure' : '');
|
||
if (input.kind === 'hat') return 'Hat ' + input.index + ', ' + input.direction.toLowerCase();
|
||
return 'Axis ' + input.index + ({ positive: ', + side', negative: ', − side', pedal: ', pedal' }[input.mode] || '');
|
||
}
|
||
function tagInput(input) {
|
||
if (input.kind === 'button') return 'B' + input.index;
|
||
if (input.kind === 'hat') return 'H' + input.index + ARROWS[input.direction];
|
||
return 'A' + input.index + ({ positive: '+', negative: '−' }[input.mode] || '');
|
||
}
|
||
|
||
// The keyboard's actions for each picture control.
|
||
function keyActions(target) {
|
||
const s = STICKS[target];
|
||
if (s) return [s.up, s.left, s.down, s.right];
|
||
return target === 'TOUCH' ? ['TOUCH', 'T_TL', 'T_TR', 'T_BL', 'T_BR'] : [target];
|
||
}
|
||
const KEY_ACTION_LABEL = Object.fromEntries(KEY_ACTIONS.map(([action, label]) => [action, label]));
|
||
|
||
// What the keyboard would do with these keys held, in the shape the picture draws.
|
||
function keyboardState(map, down) {
|
||
const s = blankSource(-1), axis = { lx: 0, ly: 0, rx: 0, ry: 0 };
|
||
for (const [action, code] of Object.entries(map)) {
|
||
if (!code || !down.has(code)) continue;
|
||
if (STICK_ACTIONS[action]) axis[STICK_ACTIONS[action][0]] += STICK_ACTIONS[action][1];
|
||
else if (TOUCH_ACTIONS[action]) s.buttons |= B.TOUCH;
|
||
else s.buttons |= B[action];
|
||
}
|
||
for (const k of Object.keys(axis)) s[k] = axis[k] < 0 ? 0 : axis[k] > 0 ? 255 : 128;
|
||
if (s.buttons & B.L2) s.l2 = 255;
|
||
if (s.buttons & B.R2) s.r2 = 255;
|
||
return s;
|
||
}
|
||
|
||
const mapView = $('#mapView');
|
||
const mapArea = $('.pad-area', padView).cloneNode(true);
|
||
// The copy is a picture: no ids twice in the page, no key hints, nothing to
|
||
// play with. L3 and R3 always show, whatever the touch setting.
|
||
for (const node of $$('[id]', mapArea)) node.removeAttribute('id');
|
||
for (const node of $$('.keyhint, .pad-label', mapArea)) node.remove();
|
||
mapArea.classList.add('map-area');
|
||
for (const node of $$('button', mapArea)) node.tabIndex = -1;
|
||
$('#mapPad').appendChild(mapArea);
|
||
for (const node of $$('[data-ctl]', mapArea)) {
|
||
setPlace(node, DEFAULT_LAYOUT[node.dataset.ctl]);
|
||
node.hidden = false;
|
||
node.classList.remove('ctl-off', 'selected', 'on', 'clicked');
|
||
}
|
||
const mapNodes = {};
|
||
for (const node of $$('[data-btn]', mapArea)) mapNodes[node.dataset.btn] = node;
|
||
for (const node of $$('[data-arm]', mapArea)) mapNodes[node.dataset.arm] = node;
|
||
mapNodes.LS = $('[data-stick="L"]', mapArea);
|
||
mapNodes.RS = $('[data-stick="R"]', mapArea);
|
||
mapNodes.TOUCH = $('.touchpad', mapArea);
|
||
const mapChips = {};
|
||
for (const target of Object.keys(MAP_TARGETS)) {
|
||
const chip = el('button', 'map-chip');
|
||
chip.type = 'button';
|
||
chip.dataset.target = target;
|
||
chip.setAttribute('aria-label', MAP_TARGETS[target]);
|
||
$('#mapChips').appendChild(chip);
|
||
mapChips[target] = chip;
|
||
}
|
||
const mapCache = { buttons: -1, l2: -1, r2: -1, stick: { L: '', R: '' } };
|
||
|
||
const mapper = {
|
||
source: null, // 'keyboard', or a gamepad's index
|
||
index: null, // the gamepad being mapped; it stops playing meanwhile
|
||
draft: null, // its profile, or the keyboard's keys, being changed
|
||
saved: '', // the draft as it was last saved, to tell changes apart
|
||
target: '', // the control open in the panel
|
||
input: null, // the gamepad input open in the panel ({kind, index})
|
||
tab: 'pad',
|
||
capture: null, // listening for the gamepad: { target, output, add, baseline, ready }
|
||
listenKey: null, // listening for a key: { action }
|
||
pick: null, // choosing on the picture what an input drives
|
||
wizard: null, // the range being recorded
|
||
keysDown: new Set(),
|
||
testerState: {}, pollFailed: false, renderedAt: 0, backArmed: 0, deleteArmed: 0,
|
||
|
||
src() { return this.index === null ? null : gamepadSources.get(this.index); },
|
||
keyboard() { return this.source === 'keyboard'; },
|
||
status(text) { $('#mapStatus').textContent = text || ''; },
|
||
guideStatus() {
|
||
if (this.keyboard()) return 'Click a DS4 control to change its key.';
|
||
return (this.tab === 'inputs'
|
||
? 'Select a button or axis to inspect or change it.'
|
||
: 'Click a DS4 control, then choose the gamepad input for it.') + ' This gamepad stops playing until you leave.';
|
||
},
|
||
// Changed since saved, or a different profile chosen for this gamepad.
|
||
dirty() {
|
||
if (!this.draft) return false;
|
||
const src = this.src();
|
||
return JSON.stringify(this.draft) !== this.saved || (!!src && this.draft.id !== src.profileId);
|
||
},
|
||
|
||
// ---- opening and closing
|
||
|
||
open(source) {
|
||
this.close(true);
|
||
if (source !== 'keyboard') {
|
||
const src = gamepadSources.get(source);
|
||
if (!src) return;
|
||
this.index = source;
|
||
this.draft = ControllerMapping.copy(profileFor(src));
|
||
clearDevice(src); flush(now());
|
||
} else {
|
||
this.draft = Object.assign({}, keymap);
|
||
}
|
||
this.source = source;
|
||
this.saved = JSON.stringify(this.draft);
|
||
this.tab = 'pad'; this.target = ''; this.input = null; this.pollFailed = false;
|
||
releaseScreen(); heldKeys.clear(); Object.assign(keySource, blankSource(keySource.pad));
|
||
$('#home').hidden = true; padView.hidden = true; $('#settingsView').hidden = true;
|
||
document.body.classList.remove('in-pad');
|
||
mapView.hidden = false;
|
||
this.render();
|
||
this.status(this.guideStatus());
|
||
},
|
||
|
||
// Leaves without saving. `quiet` when another view is about to take over.
|
||
close(quiet) {
|
||
this.stopListening();
|
||
const src = this.src();
|
||
this.index = null; this.source = null; this.draft = null; this.saved = '';
|
||
this.target = ''; this.input = null; this.wizard = null; this.pick = null; this.keysDown.clear();
|
||
this.testerState = {};
|
||
if (src) clearDevice(src);
|
||
flush(now());
|
||
if (quiet || mapView.hidden) return;
|
||
mapView.hidden = true;
|
||
if (primaryPad >= 0) { padView.hidden = false; document.body.classList.add('in-pad'); }
|
||
else $('#home').hidden = false;
|
||
renderGamepads();
|
||
},
|
||
|
||
back() {
|
||
if (this.dirty() && (!this.backArmed || now() - this.backArmed > 3000)) {
|
||
this.backArmed = now();
|
||
return this.status('You have changes that aren\'t saved. Press Back again to throw them away, or Save.');
|
||
}
|
||
this.close();
|
||
},
|
||
|
||
stopListening() {
|
||
const was = this.capture || this.listenKey;
|
||
this.capture = null; this.listenKey = null;
|
||
if (was && !mapView.hidden) this.renderPanel();
|
||
},
|
||
// Kept for the page's lifecycle handlers: focus loss and polling failures stop listening.
|
||
cancelCapture() { this.stopListening(); },
|
||
|
||
// ---- the picture
|
||
|
||
render() {
|
||
this.renderTop();
|
||
$('#mapTabs').hidden = this.keyboard();
|
||
for (const b of $$('#mapTabs button')) b.setAttribute('aria-selected', String(b.dataset.tab === this.tab));
|
||
$('#mapStage').hidden = this.tab !== 'pad';
|
||
$('#mapInputs').hidden = this.tab !== 'inputs';
|
||
this.renderChips();
|
||
this.renderPanel();
|
||
if (this.tab === 'inputs') this.renderInputs();
|
||
},
|
||
|
||
renderTop() {
|
||
const select = $('#mapSource');
|
||
select.textContent = '';
|
||
select.appendChild(new Option('Keyboard', 'keyboard'));
|
||
for (const [i, src] of gamepadSources) select.appendChild(new Option('Gamepad ' + (i + 1) + ' · ' + gamepadName(src), String(i)));
|
||
select.value = this.keyboard() ? 'keyboard' : String(this.index);
|
||
const gamepad = !this.keyboard();
|
||
$('#mapProfileBox').hidden = !gamepad;
|
||
$('#mapReset').hidden = gamepad;
|
||
$('#mapSave').disabled = !this.dirty();
|
||
$('#mapDirty').textContent = this.dirty() ? 'Not saved' : '';
|
||
const banner = $('#mapBanner');
|
||
banner.textContent = '';
|
||
banner.hidden = true;
|
||
if (!gamepad) { $('#mapSub').textContent = 'Keys drive the controller open on this page.'; return; }
|
||
const src = this.src(), sig = src.signature;
|
||
$('#mapSub').textContent = sig.buttons + ' buttons, ' + sig.axes + ' axes · ' +
|
||
(sig.mapping === 'standard' ? 'the browser knows its layout' : 'the browser doesn\'t know its layout: check each control');
|
||
const profiles = $('#mapProfile');
|
||
profiles.textContent = '';
|
||
for (const p of [ControllerMapping.browserDefault(sig), ...profileStore.profiles]) profiles.appendChild(new Option(p.name, p.id));
|
||
if (![...profiles.options].some(o => o.value === this.draft.id)) profiles.appendChild(new Option('New profile', this.draft.id));
|
||
profiles.value = this.draft.id;
|
||
// The standard layout's name is the gamepad's, until one is typed.
|
||
if (document.activeElement !== $('#mapName'))
|
||
$('#mapName').value = this.draft.id === 'default' && this.draft.name === 'Standard layout' ? gamepadName(src) : this.draft.name;
|
||
$('#mapDelete').disabled = !profileStore.profiles.some(p => p.id === this.draft.id);
|
||
$('#mapDelete').textContent = 'Delete this profile';
|
||
const others = [...gamepadSources].filter(([i, s]) => i !== this.index && this.draft.id !== 'default' && s.profileId === this.draft.id).map(([i]) => 'Gamepad ' + (i + 1));
|
||
const notes = [];
|
||
if (others.length) notes.push('Saving also changes ' + others.join(' and ') + ', which use this profile.');
|
||
if (profilePersistence) notes.push(profilePersistence);
|
||
if (ControllerMapping.signatureKey(this.draft.signature) !== ControllerMapping.signatureKey(sig)) {
|
||
notes.push('This profile was made for a different gamepad.');
|
||
const fit = el('button', 'btn', 'Fit it to this gamepad');
|
||
fit.type = 'button';
|
||
fit.addEventListener('click', () => this.fit());
|
||
banner.appendChild(el('span', '', notes.join(' ') + ' '));
|
||
banner.appendChild(fit);
|
||
banner.hidden = false;
|
||
} else if (notes.length) {
|
||
banner.textContent = notes.join(' ');
|
||
banner.hidden = false;
|
||
}
|
||
},
|
||
|
||
// What drives each control, written beside it on the picture.
|
||
tags(target) {
|
||
if (this.keyboard()) {
|
||
const keys = keyActions(target).map(a => this.draft[a]).filter(Boolean);
|
||
return STICKS[target] ? keys.map(keyLabel).join(' ') : keys.map(keyLabel).slice(0, 1).join('');
|
||
}
|
||
const outs = targetOutputs(target);
|
||
return this.draft.bindings.filter(b => outs.includes(b.output)).map(b => tagInput(b.input)).filter((t, i, a) => a.indexOf(t) === i).join(' ');
|
||
},
|
||
|
||
renderChips() {
|
||
if (mapView.hidden) return;
|
||
const box = $('#mapPad').getBoundingClientRect();
|
||
for (const [target, chip] of Object.entries(mapChips)) {
|
||
const tag = this.tags(target);
|
||
chip.textContent = tag || '—';
|
||
chip.classList.toggle('unset', !tag);
|
||
chip.classList.toggle('sel', this.target === target);
|
||
chip.classList.toggle('listening', !!(this.capture && this.capture.target === target) ||
|
||
!!(this.listenKey && keyActions(target).includes(this.listenKey.action)));
|
||
const node = mapNodes[target];
|
||
node.classList.toggle('map-sel', this.target === target);
|
||
node.classList.toggle('map-unset', !tag);
|
||
const r = node.getBoundingClientRect();
|
||
chip.style.left = (r.left + r.width / 2 - box.left) + 'px';
|
||
chip.style.top = (node.classList.contains('arm') ? r.top + r.height / 2 - box.top
|
||
: target === 'TOUCH' ? r.top - box.top + 28 : r.bottom - box.top + (node.classList.contains('face-btn') ? 8 : 12)) + 'px';
|
||
}
|
||
// Narrow screens: the picture is too small for tags, so the same list sits under it.
|
||
const list = $('#mapList');
|
||
list.textContent = '';
|
||
for (const [target, label] of Object.entries(MAP_TARGETS)) {
|
||
const item = el('button', 'map-list-item' + (this.target === target ? ' sel' : ''));
|
||
item.type = 'button';
|
||
item.dataset.target = target;
|
||
item.append(el('span', '', label), el('span', 'map-list-tag', this.tags(target) || 'Nothing'));
|
||
list.appendChild(item);
|
||
}
|
||
},
|
||
|
||
// The picture lights up with whatever the draft would do right now.
|
||
live() {
|
||
let state;
|
||
if (this.keyboard()) state = keyboardState(this.draft, this.keysDown);
|
||
else {
|
||
const src = this.src();
|
||
if (!src || !src.snapshot || ControllerMapping.validate(this.draft)) state = blankSource(-1);
|
||
else {
|
||
state = Object.assign(blankSource(-1), ControllerMapping.toSource(ControllerMapping.evaluate(src.snapshot, this.draft, this.testerState)));
|
||
if (state.l2 > 20) state.buttons |= B.L2;
|
||
if (state.r2 > 20) state.buttons |= B.R2;
|
||
}
|
||
}
|
||
renderControls(mapArea, state, mapCache);
|
||
return state;
|
||
},
|
||
|
||
// ---- clicking the picture
|
||
|
||
clickPicture(e) {
|
||
const chip = e.target.closest('.map-chip, .map-list-item');
|
||
let target = chip && chip.dataset.target;
|
||
if (!target) {
|
||
const node = e.target.closest('[data-btn], [data-stick], .touchpad, .dpad');
|
||
if (!node || !mapArea.contains(node)) return;
|
||
if (node.dataset.btn) target = node.dataset.btn;
|
||
else if (node.dataset.stick) target = node.dataset.stick + 'S';
|
||
else if (node.classList.contains('touchpad')) target = 'TOUCH';
|
||
else {
|
||
const r = node.getBoundingClientRect();
|
||
const dx = e.clientX - (r.left + r.width / 2), dy = e.clientY - (r.top + r.height / 2);
|
||
target = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'RIGHT' : 'LEFT') : (dy > 0 ? 'DOWN' : 'UP');
|
||
}
|
||
}
|
||
if (this.pick) return this.pickTarget(target);
|
||
this.stopListening();
|
||
this.target = target; this.input = null; this.wizard = null;
|
||
this.render();
|
||
if (matchMedia('(max-width: 900px)').matches) $('#mapPanel').scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||
},
|
||
|
||
// ---- the panel
|
||
|
||
renderPanel() {
|
||
const panel = $('#mapPanel');
|
||
panel.textContent = '';
|
||
if (this.wizard) return this.renderWizard(panel);
|
||
if (this.pick) return this.renderPick(panel);
|
||
if (this.input) return this.renderInputPanel(panel);
|
||
if (!this.target) {
|
||
if (!this.keyboard() && this.tab === 'inputs') {
|
||
panel.appendChild(el('h3', 'map-panel-title', 'Gamepad Controls'));
|
||
panel.appendChild(el('p', 'hint', 'See the buttons and axes your browser detects, their live readings, and what they control on the DS4.'));
|
||
panel.appendChild(el('p', 'hint', 'Press a button or move a stick to identify it. Select its tile or bar to change its mapping. Axes also have response and range settings.'));
|
||
return;
|
||
}
|
||
panel.appendChild(el('h3', 'map-panel-title', this.keyboard() ? 'Keyboard' : 'DS4 Mapping'));
|
||
panel.appendChild(el('p', 'hint', this.keyboard()
|
||
? 'Click a control on the controller to choose its key. Press keys to see what they do.'
|
||
: 'Click a control on the controller to choose what drives it. Press anything on the gamepad to see what it does.' +
|
||
(this.src() && this.src().signature.mapping !== 'standard' ? ' The browser doesn\'t know this gamepad, so the standard layout is only a guess.' : '')));
|
||
return;
|
||
}
|
||
panel.appendChild(el('h3', 'map-panel-title', MAP_TARGETS[this.target]));
|
||
if (this.keyboard()) this.renderKeyPanel(panel);
|
||
else this.renderPadPanel(panel);
|
||
},
|
||
|
||
// Keys for one control. Sticks and the touchpad have several.
|
||
renderKeyPanel(panel) {
|
||
const rows = el('div', 'map-rows');
|
||
for (const action of keyActions(this.target)) {
|
||
const row = el('div', 'map-row');
|
||
row.appendChild(el('span', 'map-row-label', keyActions(this.target).length > 1 ? KEY_ACTION_LABEL[action] : 'Key'));
|
||
const listening = this.listenKey && this.listenKey.action === action;
|
||
const key = el('button', 'key-btn' + (listening ? ' listening' : ''), listening ? 'Press a key…' : keyLabel(this.draft[action]));
|
||
key.type = 'button';
|
||
key.addEventListener('click', () => {
|
||
if (listening) return this.stopListening();
|
||
this.listenKey = { action };
|
||
this.renderPanel(); this.renderChips();
|
||
});
|
||
row.appendChild(key);
|
||
const clear = el('button', 'link-btn', 'Clear');
|
||
clear.type = 'button'; clear.disabled = !this.draft[action];
|
||
clear.addEventListener('click', () => { this.draft[action] = ''; this.changed(); });
|
||
row.appendChild(clear);
|
||
rows.appendChild(row);
|
||
}
|
||
panel.appendChild(rows);
|
||
panel.appendChild(el('p', 'hint', 'A key drives one control: choosing it here takes it from wherever it was.'));
|
||
},
|
||
|
||
onKey(e) {
|
||
if (!this.listenKey) {
|
||
if (this.keyboard() && !typing()) { this.keysDown.add(e.code); e.preventDefault(); }
|
||
return;
|
||
}
|
||
e.preventDefault(); e.stopPropagation();
|
||
if (e.ctrlKey || e.altKey || e.metaKey || !e.code || !validKey(e.code)) return this.status('That key can\'t be used. Choose one without Ctrl, Alt or the Windows key.');
|
||
const { action } = this.listenKey;
|
||
const taken = Object.keys(this.draft).filter(other => other !== action && this.draft[other] === e.code);
|
||
for (const other of taken) this.draft[other] = '';
|
||
this.draft[action] = e.code;
|
||
this.listenKey = null;
|
||
this.changed();
|
||
this.status(KEY_ACTION_LABEL[action] + ' is ' + keyLabel(e.code) + (taken.length ? ', taken from ' + taken.map(a => KEY_ACTION_LABEL[a]).join(', ') : '') + '.');
|
||
},
|
||
|
||
// A gamepad control: what drives it, Change, and how it responds.
|
||
renderPadPanel(panel) {
|
||
const M = ControllerMapping, target = this.target, stick = STICKS[target];
|
||
const listening = this.capture && this.capture.target === target;
|
||
const bindings = this.draft.bindings.filter(b => targetOutputs(target).includes(b.output));
|
||
if (listening) {
|
||
const ask = el('p', 'map-listen', this.capture.ask);
|
||
panel.appendChild(ask);
|
||
const stop = el('button', 'btn', 'Cancel');
|
||
stop.type = 'button';
|
||
stop.addEventListener('click', () => { this.stopListening(); this.status(''); });
|
||
panel.appendChild(stop);
|
||
return;
|
||
}
|
||
const list = el('div', 'map-rows');
|
||
if (!bindings.length) list.appendChild(el('p', 'hint', 'Nothing drives it.'));
|
||
for (const b of bindings) {
|
||
const row = el('div', 'map-row');
|
||
const what = stick ? { [stick.x]: 'Left and right', [stick.y]: 'Up and down' }[b.output] || M.outputLabel(b.output) : '';
|
||
row.appendChild(el('span', 'map-row-label', (what ? what + ': ' : '') + describeInput(b.input) + (b.invert ? ', reversed' : '')));
|
||
const remove = el('button', 'link-btn', 'Remove');
|
||
remove.type = 'button';
|
||
remove.addEventListener('click', () => { this.draft.bindings.splice(this.draft.bindings.indexOf(b), 1); this.changed(); });
|
||
row.appendChild(remove);
|
||
list.appendChild(row);
|
||
}
|
||
panel.appendChild(list);
|
||
const actions = el('div', 'map-actions');
|
||
actions.appendChild(this.action('Change', 'btn primary', () => this.listen(target, false)));
|
||
if (!stick && bindings.length) actions.appendChild(this.action('Add another', 'btn', () => this.listen(target, true)));
|
||
panel.appendChild(actions);
|
||
|
||
if (stick) {
|
||
const x = bindings.find(b => b.output === stick.x), y = bindings.find(b => b.output === stick.y);
|
||
panel.appendChild(this.stickPreview(stick, x, y));
|
||
const axes = [x, y].filter(Boolean);
|
||
if (axes.length) {
|
||
const tune = this.section(panel, 'Response');
|
||
const dead = axes[0].legacy ? .06 : axes[0].deadzone;
|
||
this.slider(tune, 'Dead zone', 0, .5, .01, dead, v => Math.round(v * 100) + '%', v => {
|
||
for (const b of axes) { delete b.legacy; b.deadzone = v; if (b.saturation <= v) b.saturation = 1; }
|
||
});
|
||
this.slider(tune, 'Curve', .5, 3, .1, axes[0].exponent, v => v === 1 ? 'straight' : v < 1 ? 'quicker' : 'gentler', v => {
|
||
for (const b of axes) { delete b.legacy; b.exponent = v; }
|
||
});
|
||
for (const [b, label] of [[x, 'Reverse left and right'], [y, 'Reverse up and down']]) {
|
||
if (b) this.toggle(tune, label, b.invert, v => { delete b.legacy; b.invert = v; });
|
||
}
|
||
for (const b of axes) if (b.input.kind === 'axis') {
|
||
const range = el('button', 'btn', 'Set the range of axis ' + b.input.index);
|
||
range.type = 'button';
|
||
range.addEventListener('click', () => this.startWizard(b.input.index, 'center'));
|
||
tune.appendChild(range);
|
||
}
|
||
}
|
||
} else {
|
||
const analog = bindings.filter(b => b.input.kind === 'axis' || (b.input.kind === 'button' && b.input.mode === 'pressure'));
|
||
if (TRIGGERS.includes(target)) panel.appendChild(this.triggerPreview(target));
|
||
if (analog.length) {
|
||
const tune = this.section(panel, 'Response');
|
||
if (TRIGGERS.includes(target)) {
|
||
this.slider(tune, 'Dead zone', 0, .5, .01, analog[0].deadzone, v => Math.round(v * 100) + '%', v => {
|
||
for (const b of analog) { b.deadzone = v; if (b.saturation <= v) b.saturation = 1; }
|
||
});
|
||
} else {
|
||
this.slider(tune, 'Presses at', .1, .95, .05, analog[0].activate, v => Math.round(v * 100) + '%', v => {
|
||
for (const b of analog) { b.activate = v; b.release = Math.max(0, v - .1); }
|
||
});
|
||
}
|
||
for (const b of analog) if (b.input.kind === 'axis') {
|
||
const range = el('button', 'btn', 'Set the range of axis ' + b.input.index);
|
||
range.type = 'button';
|
||
range.addEventListener('click', () => this.startWizard(b.input.index, 'pedal'));
|
||
tune.appendChild(range);
|
||
}
|
||
}
|
||
}
|
||
},
|
||
|
||
action(text, cls, fn) {
|
||
const b = el('button', cls, text);
|
||
b.type = 'button';
|
||
b.addEventListener('click', fn);
|
||
return b;
|
||
},
|
||
section(panel, title) {
|
||
const box = el('div', 'map-section');
|
||
box.appendChild(el('h4', '', title));
|
||
panel.appendChild(box);
|
||
return box;
|
||
},
|
||
slider(box, label, min, max, step, value, format, apply) {
|
||
const row = el('label', 'map-slider');
|
||
const name = el('span', '', label), out = el('output', '', format(value));
|
||
const input = el('input');
|
||
Object.assign(input, { type: 'range', min, max, step, value });
|
||
input.addEventListener('input', () => { apply(Number(input.value)); out.textContent = format(Number(input.value)); this.changed(false); });
|
||
row.append(name, input, out);
|
||
box.appendChild(row);
|
||
return input;
|
||
},
|
||
toggle(box, label, value, apply) {
|
||
const row = el('label', 'map-toggle');
|
||
const input = el('input', 'check');
|
||
input.type = 'checkbox'; input.checked = value;
|
||
input.addEventListener('change', () => { apply(input.checked); this.changed(false); });
|
||
row.append(el('span', '', label), input);
|
||
box.appendChild(row);
|
||
},
|
||
|
||
// The stick's raw position (grey) and what the controller gets (lit),
|
||
// inside the dead zone ring.
|
||
stickPreview(stick, x, y) {
|
||
const ns = 'http://www.w3.org/2000/svg';
|
||
const svg = document.createElementNS(ns, 'svg');
|
||
svg.setAttribute('viewBox', '-60 -60 120 120');
|
||
svg.setAttribute('class', 'map-stick');
|
||
svg.setAttribute('aria-hidden', 'true');
|
||
svg.innerHTML = '<circle r="54" class="well"/><circle r="0" class="dead"/><line x1="-54" x2="54" class="cross"/><line y1="-54" y2="54" class="cross"/><circle r="5" class="raw"/><circle r="7" class="out"/>';
|
||
svg.dataset.x = stick.x; svg.dataset.y = stick.y;
|
||
const dead = x && !x.legacy ? x.deadzone : .06;
|
||
$('.dead', svg).setAttribute('r', String(54 * dead));
|
||
return svg;
|
||
},
|
||
triggerPreview(target) {
|
||
const bar = el('div', 'map-bar');
|
||
bar.dataset.trigger = target;
|
||
bar.appendChild(el('span', 'map-bar-fill'));
|
||
return bar;
|
||
},
|
||
|
||
// ---- listening to the gamepad
|
||
|
||
listen(target, add) {
|
||
if (!this.src()) return;
|
||
const stick = STICKS[target];
|
||
this.capture = { target, add, baseline: null, ready: false, output: stick ? stick.x : target,
|
||
ask: 'Let go of everything for a moment…' };
|
||
this.renderPanel(); this.renderChips();
|
||
},
|
||
askFor() {
|
||
const c = this.capture, stick = STICKS[c.target];
|
||
if (!stick) return 'Now press the button for ' + MAP_TARGETS[c.target] + ' on the gamepad.';
|
||
return c.output === stick.x ? 'Now move the stick you want all the way to the right.' : 'Now move it all the way down.';
|
||
},
|
||
// Called when a press or movement is seen while listening.
|
||
heard(snap, base, buttonIndex, axisIndex) {
|
||
const c = this.capture, stick = STICKS[c.target];
|
||
const message = this.assign(c.output, snap, base, buttonIndex, axisIndex, c.add);
|
||
if (message.retry) {
|
||
c.baseline = null; c.ready = false; c.ask = message.text;
|
||
return this.renderPanel();
|
||
}
|
||
if (stick && c.output === stick.x && !message.error) {
|
||
// Then the other axis, the same way.
|
||
c.output = stick.y; c.baseline = null; c.ready = false; c.ask = 'Let go of the stick…';
|
||
this.changed();
|
||
return;
|
||
}
|
||
this.capture = null;
|
||
this.changed();
|
||
this.status(message.text);
|
||
},
|
||
|
||
// Binds what was just pressed or moved to one output. Without `add` it
|
||
// replaces what the output had. Like a key, an input then drives only this
|
||
// control: the control that had it lets go.
|
||
assign(output, snap, base, buttonIndex, axisIndex, add) {
|
||
const M = ControllerMapping, axisOut = M.axes.includes(output), name = M.outputLabel(output);
|
||
const draft = M.copy(this.draft);
|
||
let input, extra = {};
|
||
if (buttonIndex >= 0) {
|
||
if (axisOut) return { retry: true, text: 'That was a button. Move a stick instead.' };
|
||
input = { kind: 'button', index: buttonIndex, mode: TRIGGERS.includes(output) ? 'pressure' : 'digital' };
|
||
} else {
|
||
const from = base.axes[axisIndex], to = snap.axes[axisIndex], c = draft.calibration[axisIndex];
|
||
if (c?.type === 'hat' || Math.abs(from) > 1.05) {
|
||
// A D-pad the browser reports as one axis: it rests outside -1..1,
|
||
// and each direction has a value of its own. Unrecorded directions
|
||
// start at the usual values; the one just pressed is recorded.
|
||
const slot = { UP: 1, RIGHT: 3, DOWN: 5, LEFT: 7 }[output];
|
||
if (!slot) return { retry: true, text: 'That\'s a D-pad. Press a button or move a stick instead.' };
|
||
const hat = c?.type === 'hat' ? c : { type: 'hat', values: [from, -1, -5 / 7, -3 / 7, -1 / 7, 1 / 7, 3 / 7, 5 / 7, 1], tolerance: .05 };
|
||
hat.values[slot] = to;
|
||
draft.calibration[axisIndex] = hat;
|
||
// A hat isn't a stick: whatever read it as one stops.
|
||
draft.bindings = draft.bindings.filter(b => !(b.input.kind === 'axis' && b.input.index === axisIndex));
|
||
input = { kind: 'hat', index: axisIndex, direction: output };
|
||
} else if (axisOut) {
|
||
if (c?.type === 'pedal' || Math.abs(from) > .8) return { retry: true, text: 'That one rests at one end, like a trigger or a pedal. Move a stick instead.' };
|
||
input = { kind: 'axis', index: axisIndex, mode: 'center' };
|
||
extra = { invert: to < from, deadzone: .06 };
|
||
} else if (c?.type === 'pedal' || (!c && Math.abs(from) > .8)) {
|
||
// A trigger or pedal resting at one end of its axis: its whole travel.
|
||
if (!c) {
|
||
draft.calibration[axisIndex] = { type: 'pedal', released: from, full: from > 0 ? -1 : 1 };
|
||
for (const b of draft.bindings) if (b.input.kind === 'axis' && b.input.index === axisIndex) { b.input.mode = 'pedal'; delete b.legacy; }
|
||
}
|
||
input = { kind: 'axis', index: axisIndex, mode: 'pedal' };
|
||
} else {
|
||
input = { kind: 'axis', index: axisIndex, mode: to > from ? 'positive' : 'negative' };
|
||
}
|
||
}
|
||
const target = targetOf(output);
|
||
const same = b => b.input.kind === input.kind && b.input.index === input.index &&
|
||
(axisOut || input.kind === 'button' || (b.input.mode === input.mode && b.input.direction === input.direction));
|
||
const taken = new Set();
|
||
draft.bindings = draft.bindings.filter(b => {
|
||
if (!add && (b.output === output || (M.directions[b.output] && M.directions[b.output][0] === output))) return false;
|
||
const sameKind = axisOut ? (M.axes.includes(b.output) || !!M.directions[b.output]) : !M.axes.includes(b.output);
|
||
if (same(b) && sameKind && targetOf(b.output) !== target) { taken.add(MAP_TARGETS[targetOf(b.output)]); return false; }
|
||
return !(add && same(b) && b.output === output);
|
||
});
|
||
draft.bindings.push(M.binding(input, output, extra));
|
||
const error = M.validate(draft);
|
||
if (error) return { error: true, text: 'That input can\'t be used for ' + name + ' (' + error + ').' };
|
||
this.draft = draft;
|
||
return { text: MAP_TARGETS[target] + ' now uses ' + describeInput(input) + (taken.size ? ', taken from ' + [...taken].join(', ') : '') + '.' };
|
||
},
|
||
|
||
// ---- the inputs tab: everything the gamepad sends
|
||
|
||
renderInputs() {
|
||
const src = this.src(), root = $('#mapInputs');
|
||
root.textContent = '';
|
||
if (!src) return;
|
||
const sig = src.signature, used = this.usage();
|
||
const buttons = el('div', 'in-grid');
|
||
for (let i = 0; i < sig.buttons; i++) {
|
||
const tile = el('button', 'in-tile');
|
||
tile.type = 'button';
|
||
tile.dataset.input = 'button:' + i;
|
||
tile.appendChild(el('span', 'in-fill'));
|
||
tile.appendChild(el('span', 'in-name', 'B' + i));
|
||
tile.appendChild(el('span', 'in-use', used['button:' + i] || '—'));
|
||
tile.addEventListener('click', () => this.openInput({ kind: 'button', index: i }));
|
||
buttons.appendChild(tile);
|
||
}
|
||
root.appendChild(el('h4', 'in-head', 'Buttons'));
|
||
root.appendChild(buttons);
|
||
root.appendChild(el('h4', 'in-head', 'Axes'));
|
||
const axes = el('div', 'in-axes');
|
||
for (let i = 0; i < sig.axes; i++) {
|
||
const row = el('button', 'in-axis');
|
||
row.type = 'button';
|
||
row.dataset.input = 'axis:' + i;
|
||
const c = this.draft.calibration[i];
|
||
row.appendChild(el('span', 'in-name', 'Axis ' + i + (c ? ' · ' + { center: 'stick or wheel', pedal: 'pedal', hat: 'D-pad hat' }[c.type] : '')));
|
||
const bar = el('span', 'in-bar');
|
||
bar.appendChild(el('span', 'in-dot'));
|
||
row.appendChild(bar);
|
||
row.appendChild(el('span', 'in-value'));
|
||
row.appendChild(el('span', 'in-use', used['axis:' + i] || '—'));
|
||
row.addEventListener('click', () => this.openInput({ kind: 'axis', index: i }));
|
||
axes.appendChild(row);
|
||
}
|
||
root.appendChild(axes);
|
||
if (!sig.buttons && !sig.axes) root.appendChild(el('p', 'hint', 'The browser shows no inputs for this gamepad.'));
|
||
},
|
||
// Which controls each input drives, by input.
|
||
usage() {
|
||
const out = {};
|
||
for (const b of this.draft.bindings) {
|
||
const key = (b.input.kind === 'button' ? 'button:' : 'axis:') + b.input.index;
|
||
const label = MAP_TARGETS[targetOf(b.output)];
|
||
out[key] = out[key] ? (out[key].includes(label) ? out[key] : out[key] + ', ' + label) : '→ ' + label;
|
||
}
|
||
return out;
|
||
},
|
||
// Live values on the inputs tab.
|
||
paintInputs(snap) {
|
||
for (const tile of $$('.in-tile', $('#mapInputs'))) {
|
||
const b = snap.buttons[+tile.dataset.input.split(':')[1]];
|
||
tile.classList.toggle('on', !!b && (b.pressed || b.value > .5));
|
||
$('.in-fill', tile).style.height = (b ? b.value * 100 : 0) + '%';
|
||
tile.classList.toggle('missing', !b);
|
||
}
|
||
for (const row of $$('.in-axis', $('#mapInputs'))) {
|
||
const v = snap.axes[+row.dataset.input.split(':')[1]];
|
||
$('.in-value', row).textContent = v === null ? '—' : v.toFixed(2);
|
||
$('.in-dot', row).style.left = (v === null ? 50 : clamp((v + 1) / 2, 0, 1) * 100) + '%';
|
||
row.classList.toggle('missing', v === null);
|
||
row.classList.toggle('outside', v !== null && Math.abs(v) > 1.001);
|
||
}
|
||
},
|
||
|
||
openInput(input) {
|
||
this.stopListening();
|
||
this.input = input; this.target = ''; this.wizard = null; this.pick = null;
|
||
this.renderPanel(); this.renderChips();
|
||
},
|
||
|
||
// One gamepad input: what it drives and how.
|
||
renderInputPanel(panel) {
|
||
const M = ControllerMapping, input = this.input;
|
||
const axis = input.kind === 'axis', c = axis && this.draft.calibration[input.index];
|
||
panel.appendChild(el('h3', 'map-panel-title', axis ? 'Axis ' + input.index : 'Button ' + input.index));
|
||
const mine = this.draft.bindings.filter(b => (b.input.kind === 'button') === !axis && b.input.index === input.index);
|
||
if (!mine.length) panel.appendChild(el('p', 'hint', 'It doesn\'t drive anything.'));
|
||
for (const b of mine) {
|
||
const box = this.section(panel, M.outputLabel(b.output));
|
||
box.appendChild(el('p', 'hint', describeInput(b.input)));
|
||
const analog = b.input.kind === 'axis' || b.input.mode === 'pressure';
|
||
if (b.input.kind !== 'hat') this.toggle(box, 'Reverse', b.invert, v => { delete b.legacy; b.invert = v; });
|
||
if (analog) {
|
||
this.slider(box, 'Dead zone', 0, .5, .01, b.legacy ? .06 : b.deadzone, v => Math.round(v * 100) + '%', v => {
|
||
delete b.legacy; b.deadzone = v; if (b.saturation <= v) b.saturation = 1;
|
||
});
|
||
const curve = this.curvePreview(b.exponent);
|
||
this.slider(box, 'Curve', .5, 3, .1, b.exponent, v => v === 1 ? 'straight' : v < 1 ? 'quicker' : 'gentler', v => {
|
||
delete b.legacy; b.exponent = v; this.drawCurve(curve, v);
|
||
});
|
||
box.appendChild(curve);
|
||
if (!M.axes.includes(b.output) && !TRIGGERS.includes(b.output) && !M.directions[b.output])
|
||
this.slider(box, 'Presses at', .1, .95, .05, b.activate, v => Math.round(v * 100) + '%', v => { b.activate = v; b.release = Math.max(0, v - .1); });
|
||
}
|
||
box.appendChild(this.action('Remove', 'link-btn', () => { this.draft.bindings.splice(this.draft.bindings.indexOf(b), 1); this.changed(); }));
|
||
}
|
||
const actions = el('div', 'map-actions');
|
||
actions.appendChild(this.action('Make it drive…', 'btn primary', () => {
|
||
if (axis && c?.type === 'hat') return this.useAsDpad(input.index);
|
||
this.pick = { input }; this.tab = 'pad'; this.render();
|
||
}));
|
||
if (axis) {
|
||
actions.appendChild(this.action(c ? 'Set its range again' : 'Set its range', 'btn', () => this.startWizard(input.index, c ? c.type : '')));
|
||
if (c) actions.appendChild(this.action('Forget its range', 'link-btn', () => {
|
||
delete this.draft.calibration[input.index];
|
||
for (const b of this.draft.bindings) if (b.input.index === input.index && b.input.kind !== 'button') {
|
||
if (b.input.kind === 'hat') b.output = '';
|
||
else if (b.input.mode === 'pedal') b.input.mode = 'positive';
|
||
}
|
||
this.draft.bindings = this.draft.bindings.filter(b => b.output);
|
||
this.changed();
|
||
}));
|
||
}
|
||
panel.appendChild(actions);
|
||
},
|
||
|
||
curvePreview(exponent) {
|
||
const ns = 'http://www.w3.org/2000/svg';
|
||
const svg = document.createElementNS(ns, 'svg');
|
||
svg.setAttribute('viewBox', '0 0 100 100');
|
||
svg.setAttribute('class', 'map-curve');
|
||
svg.setAttribute('aria-hidden', 'true');
|
||
svg.innerHTML = '<path class="grid" d="M0 100L100 0"/><path class="line"/>';
|
||
this.drawCurve(svg, exponent);
|
||
return svg;
|
||
},
|
||
drawCurve(svg, exponent) {
|
||
let d = 'M0 100';
|
||
for (let i = 1; i <= 20; i++) d += 'L' + i * 5 + ' ' + (100 - Math.pow(i / 20, exponent) * 100).toFixed(1);
|
||
$('.line', svg).setAttribute('d', d);
|
||
},
|
||
|
||
// Choosing on the picture what an input drives.
|
||
renderPick(panel) {
|
||
const input = this.pick.input;
|
||
panel.appendChild(el('h3', 'map-panel-title', (input.kind === 'axis' ? 'Axis ' : 'Button ') + input.index));
|
||
if (this.pick.stick) {
|
||
const stick = STICKS[this.pick.stick];
|
||
panel.appendChild(el('p', 'hint', 'Which way on the ' + MAP_TARGETS[this.pick.stick].toLowerCase() + '?'));
|
||
const ways = input.kind === 'axis'
|
||
? [['Left and right', stick.x], ['Up and down', stick.y]]
|
||
: [['Up', stick.up], ['Down', stick.down], ['Left', stick.left], ['Right', stick.right]];
|
||
const actions = el('div', 'map-actions');
|
||
for (const [label, output] of ways) actions.appendChild(this.action(label, 'btn', () => this.bindPicked(output)));
|
||
panel.appendChild(actions);
|
||
} else {
|
||
panel.appendChild(el('p', 'map-listen', 'Click the control on the controller it should drive.'));
|
||
}
|
||
panel.appendChild(this.action('Cancel', 'link-btn', () => { this.pick = null; this.input = input; this.render(); }));
|
||
},
|
||
pickTarget(target) {
|
||
if (STICKS[target]) { this.pick.stick = target; return this.renderPanel(); }
|
||
this.bindPicked(target);
|
||
},
|
||
bindPicked(output) {
|
||
const M = ControllerMapping, input = this.pick.input, c = this.draft.calibration[input.index];
|
||
let bound;
|
||
if (input.kind === 'button') bound = { kind: 'button', index: input.index, mode: TRIGGERS.includes(output) ? 'pressure' : 'digital' };
|
||
else if (M.axes.includes(output)) bound = { kind: 'axis', index: input.index, mode: c?.type === 'pedal' ? 'pedal' : 'center' };
|
||
else bound = { kind: 'axis', index: input.index, mode: c?.type === 'pedal' ? 'pedal' : 'positive' };
|
||
const draft = M.copy(this.draft);
|
||
draft.bindings.push(M.binding(bound, output, M.axes.includes(output) ? { deadzone: .06 } : {}));
|
||
const error = M.validate(draft);
|
||
this.pick = null; this.input = input;
|
||
if (error) { this.status('That doesn\'t work here: ' + error + '.'); return this.render(); }
|
||
this.draft = draft;
|
||
this.changed();
|
||
this.status(describeInput(bound) + ' now drives ' + M.outputLabel(output) + '.');
|
||
},
|
||
useAsDpad(index) {
|
||
this.draft.bindings = this.draft.bindings.filter(b => !(b.input.kind === 'hat' && b.input.index === index));
|
||
for (const direction of ['UP', 'RIGHT', 'DOWN', 'LEFT'])
|
||
this.draft.bindings.push(ControllerMapping.binding({ kind: 'hat', index, direction }, direction));
|
||
this.changed();
|
||
this.status('Axis ' + index + ' is the D-pad now.');
|
||
},
|
||
|
||
// ---- setting an axis's range, a step at a time
|
||
|
||
startWizard(index, type) {
|
||
this.stopListening();
|
||
this.input = { kind: 'axis', index };
|
||
this.wizard = { index, type, step: 0, seen: [], values: {} };
|
||
this.renderPanel();
|
||
},
|
||
renderWizard(panel) {
|
||
const w = this.wizard;
|
||
panel.appendChild(el('h3', 'map-panel-title', 'Range of axis ' + w.index));
|
||
if (!w.type) {
|
||
panel.appendChild(el('p', 'hint', 'What is it?'));
|
||
const actions = el('div', 'map-actions');
|
||
for (const [type, label] of [['center', 'A stick or wheel'], ['pedal', 'A pedal or trigger'], ['hat', 'A D-pad (hat)']])
|
||
actions.appendChild(this.action(label, 'btn', () => { w.type = type; this.renderPanel(); }));
|
||
panel.appendChild(actions);
|
||
} else {
|
||
const steps = this.wizardSteps();
|
||
panel.appendChild(el('p', 'map-step', 'Step ' + (w.step + 1) + ' of ' + steps.length));
|
||
panel.appendChild(el('p', 'map-listen', steps[w.step].ask));
|
||
const bar = el('div', 'map-bar wide');
|
||
bar.dataset.wizard = '1';
|
||
bar.appendChild(el('span', 'map-bar-fill'));
|
||
bar.appendChild(el('span', 'map-bar-dot'));
|
||
panel.appendChild(bar);
|
||
panel.appendChild(this.action(steps[w.step].button, 'btn primary', () => this.wizardNext()));
|
||
}
|
||
panel.appendChild(this.action('Cancel', 'link-btn', () => { this.wizard = null; this.renderPanel(); }));
|
||
},
|
||
wizardSteps() {
|
||
const t = this.wizard.type;
|
||
if (t === 'center') return [
|
||
{ key: 'center', ask: 'Let go of it, so it rests in the middle.', button: 'It\'s in the middle' },
|
||
{ key: 'a', ask: 'Push or turn it all the way one way, and hold it.', button: 'Holding it there' },
|
||
{ key: 'b', ask: 'Now all the way the other way, and hold it.', button: 'Done' }];
|
||
if (t === 'pedal') return [
|
||
{ key: 'released', ask: 'Let go of it completely.', button: 'It\'s let go' },
|
||
{ key: 'full', ask: 'Press it all the way, and hold it.', button: 'Done' }];
|
||
return [
|
||
{ key: 0, ask: 'Let go of the D-pad.', button: 'It\'s let go' },
|
||
{ key: 1, ask: 'Hold up.', button: 'Holding up' }, { key: 3, ask: 'Hold right.', button: 'Holding right' },
|
||
{ key: 5, ask: 'Hold down.', button: 'Holding down' }, { key: 7, ask: 'Hold left.', button: 'Done' }];
|
||
},
|
||
wizardNext() {
|
||
const w = this.wizard, steps = this.wizardSteps(), src = this.src();
|
||
const v = src && src.snapshot && src.snapshot.axes[w.index];
|
||
if (!Number.isFinite(v)) return this.status('The gamepad isn\'t sending this axis right now.');
|
||
w.values[steps[w.step].key] = v;
|
||
if (w.step + 1 < steps.length) { w.step++; return this.renderPanel(); }
|
||
const M = ControllerMapping;
|
||
let range;
|
||
if (w.type === 'center') range = M.centeredRange(w.values.center, w.values.a, w.values.b);
|
||
else if (w.type === 'pedal') range = Math.abs(w.values.full - w.values.released) > .2 ? { type: 'pedal', released: w.values.released, full: w.values.full } : null;
|
||
else range = M.hatFromCardinals(w.values[0], w.values[1], w.values[3], w.values[5], w.values[7]);
|
||
if (!range) { w.step = 0; this.status('That didn\'t look right: the readings were too close together. Try again.'); return this.renderPanel(); }
|
||
this.applyRange(w.index, range);
|
||
},
|
||
applyRange(index, range) {
|
||
this.draft.calibration[index] = range;
|
||
for (const b of this.draft.bindings) {
|
||
if (b.input.kind === 'button' || b.input.index !== index) continue;
|
||
delete b.legacy;
|
||
if (range.type === 'pedal' && b.input.kind === 'axis') b.input.mode = 'pedal';
|
||
if (range.type === 'center' && b.input.mode === 'pedal') b.input.mode = 'positive';
|
||
}
|
||
if (range.type === 'hat') this.draft.bindings = this.draft.bindings.filter(b => !(b.input.kind === 'axis' && b.input.index === index));
|
||
else this.draft.bindings = this.draft.bindings.filter(b => !(b.input.kind === 'hat' && b.input.index === index));
|
||
this.wizard = null;
|
||
if (range.type === 'hat') return this.useAsDpad(index);
|
||
this.changed();
|
||
this.status('Range saved in the profile. Save to keep it.');
|
||
},
|
||
|
||
// ---- live, every frame
|
||
|
||
tick(t) {
|
||
if (mapView.hidden || !this.draft) return;
|
||
const src = this.src();
|
||
if (!this.keyboard() && !src) return;
|
||
const capture = this.capture, snap = src && src.snapshot;
|
||
if (capture && snap) {
|
||
this.pollFailed = false;
|
||
if (!capture.baseline) { capture.baseline = ControllerMapping.copy(snap); capture.stableAt = t; }
|
||
else {
|
||
const base = capture.baseline;
|
||
const movedButton = snap.buttons.findIndex((b, i) => b && base.buttons[i] && b.value - base.buttons[i].value > .5);
|
||
const movedAxis = snap.axes.findIndex((v, i) => v !== null && base.axes[i] !== null && Math.abs(v - base.axes[i]) > .3);
|
||
if (!capture.ready) {
|
||
const stable = snap.axes.every((v, i) => v === null || base.axes[i] === null || Math.abs(v - base.axes[i]) < .03) &&
|
||
snap.buttons.every((b, i) => !b || !base.buttons[i] || Math.abs(b.value - base.buttons[i].value) < .03);
|
||
if (!stable) { capture.baseline = ControllerMapping.copy(snap); capture.stableAt = t; }
|
||
const released = snap.buttons.every(b => !b || (!b.pressed && b.value < .1));
|
||
if (released && t - capture.stableAt >= 300) { capture.ready = true; capture.ask = this.askFor(); this.renderPanel(); }
|
||
} else if (movedButton >= 0 || movedAxis >= 0) {
|
||
this.heard(snap, base, movedButton, movedButton >= 0 ? -1 : movedAxis);
|
||
}
|
||
}
|
||
}
|
||
if (t - this.renderedAt < 33) return;
|
||
this.renderedAt = t;
|
||
const state = this.live();
|
||
for (const svg of $$('.map-stick', $('#mapPanel'))) {
|
||
const r = svg.dataset.x === 'lx' ? [state.lx, state.ly] : [state.rx, state.ry];
|
||
const axis = this.draft.bindings && this.draft.bindings.find(b => b.output === svg.dataset.x);
|
||
$('.dead', svg).setAttribute('r', String(54 * (axis && !axis.legacy ? axis.deadzone : .06)));
|
||
$('.out', svg).setAttribute('cx', ((r[0] - 128) / 127.5 * 54).toFixed(1));
|
||
$('.out', svg).setAttribute('cy', ((r[1] - 128) / 127.5 * 54).toFixed(1));
|
||
const raw = this.rawStick(svg.dataset.x, svg.dataset.y, snap);
|
||
$('.raw', svg).setAttribute('cx', (raw[0] * 54).toFixed(1));
|
||
$('.raw', svg).setAttribute('cy', (raw[1] * 54).toFixed(1));
|
||
}
|
||
for (const bar of $$('.map-bar[data-trigger]', $('#mapPanel')))
|
||
$('.map-bar-fill', bar).style.width = (state[bar.dataset.trigger.toLowerCase()] / 2.55) + '%';
|
||
const wizardBar = $('.map-bar[data-wizard]', $('#mapPanel'));
|
||
if (wizardBar && snap) {
|
||
const v = snap.axes[this.wizard.index];
|
||
$('.map-bar-dot', wizardBar).style.left = (v === null ? 50 : clamp((v + 1) / 2, 0, 1) * 100) + '%';
|
||
}
|
||
if (this.tab === 'inputs' && snap) this.paintInputs(snap);
|
||
},
|
||
// The stick's own position, before dead zone and curve, from its axis bindings.
|
||
rawStick(xOut, yOut, snap) {
|
||
if (!snap || this.keyboard()) return [0, 0];
|
||
const value = output => {
|
||
const b = this.draft.bindings.find(b => b.output === output && b.input.kind === 'axis');
|
||
if (!b) return 0;
|
||
const v = ControllerMapping.inputValue(snap, this.draft, b.input);
|
||
return v === null ? 0 : b.invert ? -v : v;
|
||
};
|
||
return [value(xOut), value(yOut)];
|
||
},
|
||
|
||
// ---- after any change
|
||
|
||
changed(redraw = true) {
|
||
this.testerState = {};
|
||
this.backArmed = 0;
|
||
$('#mapSave').disabled = !this.dirty();
|
||
$('#mapDirty').textContent = this.dirty() ? 'Not saved' : '';
|
||
if (redraw) this.render();
|
||
else this.renderChips();
|
||
},
|
||
|
||
// ---- profiles
|
||
|
||
save(asNew) {
|
||
if (this.keyboard()) {
|
||
keymap = Object.assign({}, this.draft);
|
||
store.set('keymap', keymap);
|
||
rebuildKeymap();
|
||
this.saved = JSON.stringify(this.draft);
|
||
this.changed();
|
||
return this.status('Keys saved.');
|
||
}
|
||
const src = this.src();
|
||
if (!src) return;
|
||
if (this.draft.id === 'default' && JSON.stringify(this.draft) === this.saved) {
|
||
// The standard layout, chosen again: nothing to store but the choice.
|
||
preferProfile(src, 'default');
|
||
this.changed();
|
||
return this.status('This gamepad uses the standard layout.');
|
||
}
|
||
const draft = ControllerMapping.copy(this.draft);
|
||
draft.name = $('#mapName').value.trim() || gamepadName(src);
|
||
const error = ControllerMapping.validate(draft);
|
||
if (error) return this.status('It can\'t be saved yet: ' + error + '.');
|
||
if (ControllerMapping.unresolved(draft, src.signature) ||
|
||
ControllerMapping.signatureKey(draft.signature) !== ControllerMapping.signatureKey(src.signature))
|
||
return this.status('This profile was made for a different gamepad. Press Fit it to this gamepad first.');
|
||
if (asNew || draft.id === 'default' || !profileStore.profiles.some(p => p.id === draft.id)) draft.id = this.newId();
|
||
const at = profileStore.profiles.findIndex(p => p.id === draft.id);
|
||
if (at >= 0) profileStore.profiles[at] = draft; else profileStore.profiles.push(draft);
|
||
for (const s of gamepadSources.values()) if (s.profileId === draft.id && s !== src) clearDevice(s);
|
||
preferProfile(src, draft.id);
|
||
this.draft = ControllerMapping.copy(draft);
|
||
this.saved = JSON.stringify(this.draft);
|
||
this.changed();
|
||
this.status('Saved as ' + draft.name + '.' + (profilePersistence ? ' ' + profilePersistence : ''));
|
||
},
|
||
newId() { return 'profile-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10); },
|
||
// Drops what this gamepad doesn't have and takes on its signature.
|
||
fit() {
|
||
const sig = this.src().signature;
|
||
this.draft.bindings = this.draft.bindings.filter(b => b.input.index < (b.input.kind === 'button' ? sig.buttons : sig.axes));
|
||
for (const i of Object.keys(this.draft.calibration)) if (+i >= sig.axes) delete this.draft.calibration[i];
|
||
this.draft.signature = ControllerMapping.copy(sig);
|
||
this.changed();
|
||
},
|
||
choose(id) {
|
||
const src = this.src();
|
||
this.draft = ControllerMapping.copy(profileStore.profiles.find(p => p.id === id) || ControllerMapping.browserDefault(src.signature));
|
||
this.saved = JSON.stringify(this.draft);
|
||
this.target = ''; this.input = null;
|
||
this.changed();
|
||
},
|
||
startEmpty() {
|
||
const src = this.src();
|
||
this.draft = Object.assign(ControllerMapping.browserDefault(src.signature), { id: this.newId(), name: 'New profile', bindings: [] });
|
||
this.changed();
|
||
this.status('An empty profile: nothing does anything until you set it.');
|
||
},
|
||
resetToStandard() {
|
||
if (this.keyboard()) { this.draft = defaultKeys(); this.changed(); return this.status('Back to the standard keys. Save to keep them.'); }
|
||
const { id, name } = this.draft;
|
||
this.draft = Object.assign(ControllerMapping.browserDefault(this.src().signature), { id, name });
|
||
this.changed();
|
||
this.status('Back to the standard layout. Save to keep it.');
|
||
},
|
||
remove() {
|
||
const id = this.draft.id;
|
||
if (!profileStore.profiles.some(p => p.id === id)) return;
|
||
if (!this.deleteArmed || now() - this.deleteArmed > 3000) {
|
||
this.deleteArmed = now();
|
||
$('#mapDelete').textContent = 'Press again to delete it';
|
||
return this.status('Press Delete again to delete ' + this.draft.name + '.');
|
||
}
|
||
$('#mapMenu').hidden = true;
|
||
this.deleteArmed = 0;
|
||
profileStore.profiles = profileStore.profiles.filter(p => p.id !== id);
|
||
for (const key of Object.keys(profileStore.preferred)) if (profileStore.preferred[key] === id) delete profileStore.preferred[key];
|
||
for (const s of gamepadSources.values()) if (s.profileId === id) { s.profileId = 'default'; clearDevice(s); }
|
||
persistProfiles();
|
||
this.choose('default');
|
||
this.status('Profile deleted. ' + profilePersistence);
|
||
},
|
||
exportProfile() {
|
||
const profile = ControllerMapping.copy(this.draft);
|
||
profile.name = $('#mapName').value.trim() || profile.name;
|
||
const error = ControllerMapping.validate(profile);
|
||
if (error) return this.status('It can\'t be exported yet: ' + error + '.');
|
||
const url = URL.createObjectURL(new Blob([JSON.stringify(ControllerMapping.exportProfile(profile), null, 2)], { type: 'application/json' }));
|
||
const a = el('a'); a.href = url; a.download = 'control4free-profile.json'; a.click();
|
||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||
},
|
||
async importProfile(file) {
|
||
const index = this.index, before = this.draft;
|
||
if (!file || index === null) return;
|
||
if (file.size > 1024 * 1024) return this.status('That file is too big to be a profile.');
|
||
try {
|
||
const profile = ControllerMapping.importProfile(JSON.parse(await file.text()));
|
||
if (this.index !== index || this.draft !== before) return;
|
||
profile.id = this.newId();
|
||
this.draft = profile;
|
||
this.changed();
|
||
this.status('Imported. Check it, then Save to keep it.');
|
||
} catch (error) {
|
||
if (this.index === index && this.draft === before) this.status('That isn\'t a Control4Free profile: ' + (error.message || 'it can\'t be read'));
|
||
}
|
||
},
|
||
};
|
||
|
||
function gamepadName(src) {
|
||
return (src.signature.id || '').replace(/\s*\(.*?\)\s*/g, ' ').trim() || 'Gamepad';
|
||
}
|
||
|
||
$('#mapBack').addEventListener('click', () => mapper.back());
|
||
$('#mapSave').addEventListener('click', () => mapper.save(false));
|
||
$('#mapSaveNew').addEventListener('click', () => { $('#mapMenu').hidden = true; mapper.save(true); });
|
||
$('#mapSource').addEventListener('change', () => {
|
||
const value = $('#mapSource').value;
|
||
if (mapper.dirty()) { mapper.status('Save or press Back first: this one has changes.'); $('#mapSource').value = mapper.keyboard() ? 'keyboard' : String(mapper.index); return; }
|
||
mapper.open(value === 'keyboard' ? 'keyboard' : +value);
|
||
});
|
||
$('#mapProfile').addEventListener('change', () => mapper.choose($('#mapProfile').value));
|
||
$('#mapName').addEventListener('input', () => { mapper.draft.name = $('#mapName').value; mapper.changed(false); });
|
||
$('#mapMenuBtn').addEventListener('click', () => { $('#mapMenu').hidden = !$('#mapMenu').hidden; mapper.deleteArmed = 0; });
|
||
// The menu closes on a click anywhere else.
|
||
document.addEventListener('click', e => { if (!e.target.closest('.map-menu-wrap')) $('#mapMenu').hidden = true; });
|
||
$('#mapNew').addEventListener('click', () => { $('#mapMenu').hidden = true; mapper.startEmpty(); });
|
||
$('#mapStandard').addEventListener('click', () => { $('#mapMenu').hidden = true; mapper.resetToStandard(); });
|
||
$('#mapReset').addEventListener('click', () => mapper.resetToStandard());
|
||
$('#mapDelete').addEventListener('click', () => mapper.remove());
|
||
$('#mapExport').addEventListener('click', () => { $('#mapMenu').hidden = true; mapper.exportProfile(); });
|
||
$('#mapImport').addEventListener('change', event => {
|
||
const file = event.target.files[0];
|
||
event.target.value = ''; $('#mapMenu').hidden = true;
|
||
mapper.importProfile(file);
|
||
});
|
||
for (const tab of $$('#mapTabs button')) tab.addEventListener('click', () => {
|
||
mapper.stopListening(); mapper.tab = tab.dataset.tab;
|
||
mapper.target = ''; mapper.input = null; mapper.wizard = null; mapper.pick = null;
|
||
mapper.render(); mapper.status(mapper.guideStatus());
|
||
});
|
||
$('#mapStage').addEventListener('click', e => mapper.clickPicture(e));
|
||
window.addEventListener('keydown', e => { if (!mapView.hidden && mapper.keyboard()) mapper.onKey(e); }, true);
|
||
window.addEventListener('keyup', e => { mapper.keysDown.delete(e.code); });
|
||
window.addEventListener('resize', () => mapper.renderChips());
|
||
|
||
|
||
// Standard mapping: https://w3c.github.io/gamepad/#remapping
|
||
const gamepadsAvailable = typeof navigator.getGamepads === 'function';
|
||
|
||
function pollGamepads(t) {
|
||
if (!gamepadsAvailable) return;
|
||
let list;
|
||
try {
|
||
list = navigator.getGamepads();
|
||
} catch (e) {
|
||
for (const src of gamepadSources.values()) clearDevice(src);
|
||
mapper.cancelCapture();
|
||
mapper.pollFailed = true;
|
||
mapper.status('The browser stopped giving this page the gamepads.');
|
||
return;
|
||
}
|
||
const seen = new Set();
|
||
for (const gp of list) {
|
||
if (!gp || !gp.connected) continue;
|
||
seen.add(gp.index);
|
||
let src = gamepadSources.get(gp.index);
|
||
const signature = ControllerMapping.signature(gp);
|
||
if (src && ControllerMapping.signatureKey(src.signature) !== ControllerMapping.signatureKey(signature)) {
|
||
if (mapper.index === gp.index) mapper.close();
|
||
clearDevice(src); gamepadSources.delete(gp.index); src = null;
|
||
}
|
||
if (!src) {
|
||
// A new gamepad drives the controller opened on this page; elsewhere
|
||
// it waits until one is picked for it.
|
||
const pad = primaryPad >= 0 && ![...gamepadSources.values()].some(s => s.pad === primaryPad) ? primaryPad : -1;
|
||
src = blankSource(pad);
|
||
src.gp = gp;
|
||
src.signature = signature;
|
||
src.profileId = profileStore.preferred[ControllerMapping.signatureKey(signature)] || 'default';
|
||
clearDevice(src);
|
||
src.activeAt = 0;
|
||
gamepadSources.set(gp.index, src);
|
||
renderGamepads();
|
||
sendClaim();
|
||
toast(pad >= 0 ? 'Gamepad ' + (gp.index + 1) + ' plays as Controller ' + (pad + 1) : 'Gamepad ' + (gp.index + 1) + ' connected. Pick its controller on the home screen.');
|
||
}
|
||
src.gp = gp;
|
||
src.snapshot = ControllerMapping.read(gp);
|
||
const profile = profileFor(src);
|
||
const before = src.buttons + ',' + src.lx + ',' + src.ly + ',' + src.rx + ',' + src.ry + ',' + src.l2 + ',' + src.r2;
|
||
// Experimental: a linked Sony controller's own reports stand in for the
|
||
// browser's view of it, which goes quiet over Bluetooth once it's linked.
|
||
const viaHid = hidLinks.has(gp.index) && src.hidState && t - src.hidAt < 250;
|
||
if (viaHid) src.hold = {};
|
||
if (!src.hold) src.hold = ControllerMapping.holdBack(profile);
|
||
ControllerMapping.letGo(src.snapshot, profile, src.hold);
|
||
const waiting = Object.keys(src.hold).length > 0;
|
||
if (waiting !== src.waitNeutral) { src.waitNeutral = waiting; renderGamepads(); }
|
||
if (mapper.index === gp.index || ControllerMapping.unresolved(profile, signature)) src.mapped = ControllerMapping.empty();
|
||
else if (viaHid) src.mapped = ControllerMapping.fromSource(Object.assign(src, src.hidState));
|
||
else src.mapped = ControllerMapping.evaluate(src.snapshot, profile, src.transient, src.hold);
|
||
Object.assign(src, ControllerMapping.toSource(src.mapped));
|
||
const after = src.buttons + ',' + src.lx + ',' + src.ly + ',' + src.rx + ',' + src.ry + ',' + src.l2 + ',' + src.r2;
|
||
if (before !== after) src.activeAt = t;
|
||
}
|
||
let removed = false;
|
||
for (const index of gamepadSources.keys()) {
|
||
if (!seen.has(index)) {
|
||
if (mapper.index === index) mapper.close();
|
||
clearDevice(gamepadSources.get(index)); gamepadSources.delete(index); removed = true;
|
||
}
|
||
}
|
||
if (removed) { renderGamepads(); sendClaim(); }
|
||
// Light up the row of a controller in use, so you can tell which one is which
|
||
for (const [index, src] of gamepadSources) {
|
||
const row = $(`.gp-row[data-index="${index}"]`);
|
||
if (row) row.classList.toggle('active', t - src.activeAt < 250);
|
||
}
|
||
}
|
||
|
||
function setGamepadPad(index, pad) {
|
||
const src = gamepadSources.get(index);
|
||
if (!src) return;
|
||
if (mapper.index === index) mapper.close();
|
||
const old = src.pad;
|
||
src.pad = pad;
|
||
if (old >= 0 && old !== pad && !claimedPads().includes(old)) releasePad(old);
|
||
clearDevice(src);
|
||
renderGamepads();
|
||
renderPads();
|
||
sendClaim();
|
||
}
|
||
|
||
// ---------------------------------------------------------------- rumble and lightbar
|
||
|
||
const rumble = Array.from({ length: PADS }, () => ({ large: 0, small: 0, lastKick: 0 }));
|
||
|
||
function onRumble(pad, large, small) {
|
||
if (!rumble[pad]) return;
|
||
rumble[pad].large = large;
|
||
rumble[pad].small = small;
|
||
rumble[pad].lastKick = 0;
|
||
if (!large && !small) stopRumble(pad);
|
||
}
|
||
|
||
function stopRumble(pad) {
|
||
for (const [index, src] of gamepadSources) {
|
||
if (hidLinks.has(index)) continue; /* a linked controller is told directly */
|
||
if (src.pad === pad && src.gp && src.gp.vibrationActuator && src.gp.vibrationActuator.reset) {
|
||
try { src.gp.vibrationActuator.reset(); } catch (e) { /* ignore */ }
|
||
}
|
||
}
|
||
if (pad === primaryPad && settings.haptics) haptic(0);
|
||
}
|
||
|
||
function stopAllRumble() {
|
||
for (let i = 0; i < PADS; i++) {
|
||
rumble[i].large = rumble[i].small = 0;
|
||
stopRumble(i);
|
||
}
|
||
}
|
||
|
||
function driveRumble(t) {
|
||
for (let pad = 0; pad < PADS; pad++) {
|
||
const r = rumble[pad];
|
||
if (!r.large && !r.small) continue;
|
||
if (t - r.lastKick < 110) continue;
|
||
r.lastKick = t;
|
||
for (const [index, src] of gamepadSources) {
|
||
const act = src.gp && src.gp.vibrationActuator;
|
||
if (src.pad !== pad || !act || !act.playEffect || hidLinks.has(index)) continue;
|
||
// Experimental: Xbox controllers that have them rumble in their triggers too.
|
||
const triggers = experiments.triggers && Array.isArray(act.effects) && act.effects.includes('trigger-rumble');
|
||
const effect = { startDelay: 0, duration: 150, strongMagnitude: r.large / 255, weakMagnitude: r.small / 255 };
|
||
if (triggers) Object.assign(effect, { leftTrigger: r.large / 255 * .6, rightTrigger: r.small / 255 * .6 });
|
||
act.playEffect(triggers ? 'trigger-rumble' : 'dual-rumble', effect).catch(() => {});
|
||
}
|
||
if (pad === primaryPad && settings.haptics) {
|
||
const strength = Math.max(r.large, r.small) / 255;
|
||
if (strength > 0.15) haptic(Math.round(30 + strength * 80));
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- home screen
|
||
|
||
function padState(s) {
|
||
return !s.known ? 'unknown' : s.state || 'free';
|
||
}
|
||
|
||
function padMeta(s) {
|
||
if (s.state === 'connecting') return 'Connecting to the PS4';
|
||
if (s.state === 'select') return 'Choose a user on the PS4';
|
||
if (s.state === 'ready') return 'Signed in on the PS4';
|
||
if (s.state === 'paused') return 'Input paused';
|
||
if (s.state === 'error') return 'Input error — disconnect and reconnect';
|
||
return 'Touch, keyboard, or a gamepad on this device';
|
||
}
|
||
|
||
function stateItem(cls, text) {
|
||
const e = el('span', 'state ' + cls);
|
||
if (cls === 'live' || cls === 'wait') e.appendChild(el('span', 'dot'));
|
||
e.appendChild(document.createTextNode(text));
|
||
return e;
|
||
}
|
||
|
||
function renderPads() {
|
||
const grid = $('#padGrid');
|
||
const focused = document.activeElement?.closest('#padGrid [data-pad]')?.dataset.pad;
|
||
const mine = new Set(claimedPads());
|
||
grid.textContent = '';
|
||
status.forEach((s, i) => {
|
||
const state = padState(s);
|
||
const tile = el('button', 'tile');
|
||
tile.type = 'button';
|
||
tile.dataset.pad = i;
|
||
tile.dataset.state = state;
|
||
tile.style.setProperty('--pc', css(s.color));
|
||
const head = el('span', 'tile-head');
|
||
head.appendChild(el('span', 'ring', String(i + 1)));
|
||
head.appendChild(el('span', 'activity'));
|
||
tile.appendChild(head);
|
||
const body = el('span');
|
||
// Who's signed in on it, once the PS4 says; the ring keeps the number.
|
||
body.appendChild(el('div', 'tile-name', s.known && s.user ? s.user : 'Controller ' + (i + 1)));
|
||
body.appendChild(el('div', 'tile-meta', padMeta(s) || ''));
|
||
tile.appendChild(body);
|
||
const states = el('span', 'tile-state');
|
||
if (state === 'ready') states.appendChild(stateItem('live', 'Connected'));
|
||
else if (state === 'connecting') states.appendChild(stateItem('wait', 'Connecting'));
|
||
else if (state === 'select') states.appendChild(stateItem('wait', 'Choose user on PS4'));
|
||
else if (state === 'paused') states.appendChild(stateItem('wait', 'Paused'));
|
||
else if (state === 'error') states.appendChild(stateItem('', 'Error'));
|
||
else if (state === 'free') states.appendChild(stateItem('', 'Free'));
|
||
else if (state === 'noslot') states.appendChild(stateItem('', 'No free player slot'));
|
||
else if (state === 'disabled') states.appendChild(stateItem('', 'Turned off'));
|
||
if (mine.has(i)) states.appendChild(stateItem('mine', 'This device'));
|
||
const others = s.clients - (mine.has(i) ? 1 : 0);
|
||
if (others > 0) states.appendChild(stateItem('', others === 1 ? '+1 device' : '+' + others + ' devices'));
|
||
tile.appendChild(states);
|
||
tile.addEventListener('click', () => openPad(i));
|
||
grid.appendChild(tile);
|
||
});
|
||
if (focused !== undefined) $(`[data-pad="${focused}"]`, grid)?.focus({ preventScroll: true });
|
||
}
|
||
|
||
// Whether this browser can rumble this gamepad. Chrome and Edge can rumble
|
||
// Xbox pads, but often not a DualSense; Firefox none. Saying so on the row
|
||
// beats a gamepad that silently never rumbles.
|
||
function canRumble(gp) {
|
||
const a = gp && gp.vibrationActuator;
|
||
if (!a || typeof a.playEffect !== 'function') return false;
|
||
if (Array.isArray(a.effects)) return a.effects.includes('dual-rumble');
|
||
return !a.type || a.type === 'dual-rumble';
|
||
}
|
||
|
||
function renderGamepads() {
|
||
const list = $('#gamepadList');
|
||
const focused = document.activeElement;
|
||
const focusIndex = focused?.closest('.gp-row')?.dataset.index;
|
||
const focusKey = focused?.dataset.focus;
|
||
list.textContent = '';
|
||
$('#gamepadHelp').hidden = !gamepadsAvailable || !gamepadSources.size;
|
||
if (!gamepadsAvailable) {
|
||
$('#gamepadHint').textContent = window.isSecureContext
|
||
? 'This browser does not support gamepads.'
|
||
: 'This browser only allows gamepads on secure pages. Save this page to your device, open the saved file, and enter the console address.';
|
||
return;
|
||
}
|
||
$('#gamepadHint').textContent = gamepadSources.size
|
||
? 'Choose which PS4 controller each gamepad controls.'
|
||
: 'Connect gamepads to this device and press a button on each one. Each one can play as a different controller.';
|
||
for (const [index, src] of gamepadSources) {
|
||
const row = el('li', 'gp-row');
|
||
row.dataset.index = index;
|
||
const device = gamepadDevice(src, index);
|
||
const text = $('.gp-details', device);
|
||
const mapped = src.gp && src.gp.mapping === 'standard';
|
||
if (!mapped) text.appendChild(el('div', 'gp-note warning', 'Buttons may be mixed up. Check Mapping.'));
|
||
if (hidLinks.has(index)) text.appendChild(el('div', 'gp-note', 'Linked: light bar and rumble'));
|
||
else if (!canRumble(src.gp)) text.appendChild(el('div', 'gp-note', 'No rumble in this browser'));
|
||
if (src.waitNeutral) text.appendChild(el('div', 'gp-note warning', 'Let go of the held buttons'));
|
||
row.appendChild(device);
|
||
const actions = el('div', 'gp-actions');
|
||
const assignment = el('div', 'gp-assignment');
|
||
assignment.appendChild(el('span', 'gp-assignment-label', 'PS4 controller'));
|
||
const seg = el('div', 'seg');
|
||
seg.setAttribute('role', 'radiogroup');
|
||
seg.setAttribute('aria-label', 'Controller for gamepad ' + (index + 1));
|
||
for (let pad = -1; pad < PADS; pad++) {
|
||
const b = el('button', '', pad < 0 ? 'Off' : String(pad + 1));
|
||
b.type = 'button';
|
||
b.dataset.focus = 'pad-' + pad;
|
||
b.setAttribute('role', 'radio');
|
||
b.setAttribute('aria-checked', String(src.pad === pad));
|
||
if (pad >= 0) {
|
||
b.style.setProperty('--pc', css(status[pad].color));
|
||
b.title = 'Controller ' + (pad + 1) + (status[pad].user ? ' · ' + status[pad].user : '');
|
||
}
|
||
b.addEventListener('click', () => setGamepadPad(index, pad));
|
||
seg.appendChild(b);
|
||
}
|
||
assignment.appendChild(seg);
|
||
actions.appendChild(assignment);
|
||
const configure = el('button', 'btn gp-mapping', 'Mapping');
|
||
configure.type = 'button'; configure.dataset.focus = 'configure';
|
||
configure.addEventListener('click', () => mapper.open(index));
|
||
actions.appendChild(configure);
|
||
row.appendChild(actions);
|
||
list.appendChild(row);
|
||
}
|
||
if (focusIndex !== undefined && focusKey) $(`.gp-row[data-index="${focusIndex}"] [data-focus="${focusKey}"]`, list)?.focus({ preventScroll: true });
|
||
renderGamepadSettings();
|
||
}
|
||
|
||
function gamepadDevice(src, index) {
|
||
const device = el('div', 'gp-device');
|
||
device.insertAdjacentHTML('afterbegin', '<span class="gp-device-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 7h10a5 5 0 0 1 4.8 6.4l-1 3.4a2.6 2.6 0 0 1-4.4 1.2L14.4 16H9.6l-2 2a2.6 2.6 0 0 1-4.4-1.2l-1-3.4A5 5 0 0 1 7 7zM7 10v4m-2-2h4"/><circle cx="16" cy="10.5" r=".6" fill="currentColor"/><circle cx="18" cy="13" r=".6" fill="currentColor"/></svg></span>');
|
||
const text = el('div', 'gp-details');
|
||
text.appendChild(el('div', 'gp-name', gamepadName(src)));
|
||
const meta = el('div', 'gp-sub gp-meta');
|
||
meta.appendChild(el('span', '', 'Gamepad ' + (index + 1)));
|
||
meta.appendChild(el('span', 'gp-profile', profileFor(src).name));
|
||
text.appendChild(meta);
|
||
device.appendChild(text);
|
||
return device;
|
||
}
|
||
|
||
// Settings → Gamepads: each connected gamepad, its profile, and the way to its mapping.
|
||
function renderGamepadSettings() {
|
||
const list = $('#gpSettings');
|
||
list.textContent = '';
|
||
$('#gpSettingsEmpty').hidden = gamepadSources.size > 0;
|
||
for (const [index, src] of gamepadSources) {
|
||
const row = el('li', 'gp-row gp-row--settings');
|
||
row.appendChild(gamepadDevice(src, index));
|
||
const map = el('button', 'btn gp-mapping', 'Mapping');
|
||
map.type = 'button';
|
||
map.addEventListener('click', () => mapper.open(index));
|
||
row.appendChild(map);
|
||
list.appendChild(row);
|
||
}
|
||
if (experimentOn('hid')) renderSonyPads();
|
||
}
|
||
|
||
const activityTimers = [];
|
||
function pulseActivity(pad) {
|
||
const dot = $(`.tile[data-pad="${pad}"] .activity`);
|
||
if (!dot) return;
|
||
dot.classList.add('on');
|
||
clearTimeout(activityTimers[pad]);
|
||
activityTimers[pad] = setTimeout(() => dot.classList.remove('on'), 120);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- controller screen
|
||
|
||
// The pad's name and state, printed on the touchpad
|
||
function renderPadLabel() {
|
||
if (primaryPad < 0) return;
|
||
const s = status[primaryPad];
|
||
document.documentElement.style.setProperty('--accent', css(s.color));
|
||
const label = $('#padLabel');
|
||
// And who's signed in on it, once the PS4 says.
|
||
label.textContent = 'Controller ' + (primaryPad + 1) + (s.known && s.user ? ' · ' + s.user : '');
|
||
const state = padState(s);
|
||
const extra = conn.state !== 'open' ? $('#connText').textContent + $('#connDetail').textContent
|
||
: state === 'ready' ? 'Signed in on the PS4'
|
||
: state === 'connecting' ? 'Connecting to the PS4'
|
||
: state === 'select' ? 'Choose your user on the PS4 with D-pad and Cross'
|
||
: state === 'noslot' ? 'No free player slot'
|
||
: state === 'disabled' ? 'Turned off' : '';
|
||
if (extra) label.appendChild(el('span', 'muted', extra));
|
||
$('#leaveBtn').hidden = !s.mine;
|
||
}
|
||
|
||
// Lights up a set of DualShock 4 controls with a state: the controller
|
||
// screen's (every source driving its pad), or the mapping view's copy.
|
||
function renderControls(root, s, shown, skipStick = {}) {
|
||
if (s.buttons !== shown.buttons) {
|
||
shown.buttons = s.buttons;
|
||
for (const k of $$('[data-btn]', root)) k.classList.toggle('on', !!(s.buttons & B[k.dataset.btn]));
|
||
for (const a of $$('[data-arm]', root)) a.classList.toggle('on', !!(s.buttons & B[a.dataset.arm]));
|
||
for (const st of $$('[data-stick]', root)) st.classList.toggle('clicked', !!(s.buttons & B[st.dataset.stick + '3']));
|
||
$('.touchpad', root).classList.toggle('clicked', !!(s.buttons & B.TOUCH));
|
||
}
|
||
if (s.l2 !== shown.l2) { shown.l2 = s.l2; $('[data-fill="L2"]', root).style.height = (s.l2 / 2.55) + '%'; }
|
||
if (s.r2 !== shown.r2) { shown.r2 = s.r2; $('[data-fill="R2"]', root).style.height = (s.r2 / 2.55) + '%'; }
|
||
// Sticks follow controllers and keys too, unless a finger is moving them
|
||
for (const side of ['L', 'R']) {
|
||
if (skipStick[side]) continue;
|
||
const x = side === 'L' ? s.lx : s.rx;
|
||
const y = side === 'L' ? s.ly : s.ry;
|
||
const key = x + ',' + y;
|
||
if (key === shown.stick[side]) continue;
|
||
shown.stick[side] = key;
|
||
const stick = $(`[data-stick="${side}"]`, root);
|
||
const travel = stick.offsetWidth * KNOB_TRAVEL;
|
||
const knob = $('.knob', stick);
|
||
knob.style.transform = key === '128,128' ? '' : `translate(${(x - 128) / 127.5 * travel}px, ${(y - 128) / 127.5 * travel}px)`;
|
||
stick.classList.toggle('on', key !== '128,128');
|
||
}
|
||
}
|
||
|
||
const padShown = { buttons: -1, l2: -1, r2: -1, stick: { L: '', R: '' } };
|
||
function renderPressed() {
|
||
if (primaryPad >= 0) renderControls(padView, mergedState(primaryPad), padShown, draggingStick);
|
||
}
|
||
|
||
function openPad(pad) {
|
||
const s = status[pad];
|
||
if (s.known && !s.enabled) {
|
||
toast('Controller ' + (pad + 1) + ' is turned off in Settings');
|
||
return;
|
||
}
|
||
const others = s.clients - (claimedPads().includes(pad) ? 1 : 0);
|
||
if (s.known && others > 0) {
|
||
toast('Another device is using this controller');
|
||
return;
|
||
} else if (!store.get('padTips', false)) {
|
||
store.set('padTips', true);
|
||
toast('Choose your user on the PS4 with D-pad and Cross. Use the ☰ button for layout and settings.');
|
||
}
|
||
if (primaryPad >= 0 && primaryPad !== pad) {
|
||
const old = primaryPad;
|
||
primaryPad = -1;
|
||
if (!claimedPads().includes(old)) releasePad(old);
|
||
}
|
||
primaryPad = pad;
|
||
screenSource.pad = pad;
|
||
keySource.pad = pad;
|
||
$('#home').hidden = true;
|
||
$('#settingsView').hidden = true;
|
||
padView.hidden = false;
|
||
document.body.classList.add('in-pad');
|
||
document.title = 'Controller ' + (pad + 1) + ' · Control4Free';
|
||
history.replaceState(null, '', updateQuery('pad', String(pad + 1)));
|
||
padShown.buttons = -1;
|
||
padShown.stick.L = padShown.stick.R = '';
|
||
renderPadLabel();
|
||
sendClaim();
|
||
renderPads();
|
||
}
|
||
|
||
function closePad() {
|
||
editor.finish(true);
|
||
releaseScreen();
|
||
heldKeys.clear();
|
||
Object.assign(keySource, blankSource(-1));
|
||
if (primaryPad >= 0) {
|
||
const stillUsed = [...gamepadSources.values()].some(src => src.pad === primaryPad);
|
||
if (!stillUsed) releasePad(primaryPad);
|
||
}
|
||
primaryPad = -1;
|
||
screenSource.pad = -1;
|
||
padView.hidden = true;
|
||
document.body.classList.remove('in-pad');
|
||
$('#home').hidden = false;
|
||
document.title = 'Control4Free';
|
||
history.replaceState(null, '', updateQuery('pad', null));
|
||
document.documentElement.style.removeProperty('--accent');
|
||
if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
|
||
sendClaim();
|
||
renderPads();
|
||
}
|
||
|
||
function updateQuery(key, value) {
|
||
const url = new URL(location.href);
|
||
if (value === null) url.searchParams.delete(key); else url.searchParams.set(key, value);
|
||
return url.pathname + url.search;
|
||
}
|
||
|
||
$('#backBtn').addEventListener('click', closePad);
|
||
$('#portraitOk').addEventListener('click', () => padView.classList.add('portrait-ok'));
|
||
|
||
// Full screen. Safari on iPhone lets only video go full screen, whatever the
|
||
// page asks for; the way to lose its bars there is to open the page from the
|
||
// Home Screen, where it runs on its own. So on iPhone the button says how, and
|
||
// once the page runs that way (or is installed elsewhere) there is nothing left
|
||
// for it to do and it goes away.
|
||
const runsStandalone = navigator.standalone === true ||
|
||
!!(window.matchMedia && matchMedia('(display-mode: standalone), (display-mode: fullscreen)').matches);
|
||
const fsEnabled = !!(document.fullscreenEnabled || document.webkitFullscreenEnabled);
|
||
const homeScreenOnly = !fsEnabled && 'standalone' in navigator; // Safari on iPhone
|
||
$('#fsBtn').hidden = runsStandalone || !(fsEnabled || homeScreenOnly);
|
||
$('#fsBtn').addEventListener('click', async () => {
|
||
if (homeScreenOnly) {
|
||
toast('iPhone does not let pages go full screen. Tap Share, then Add to Home Screen, and open Control4Free from its icon to play without Safari\'s bars.');
|
||
return;
|
||
}
|
||
try {
|
||
if (document.fullscreenElement || document.webkitFullscreenElement) {
|
||
await (document.exitFullscreen ? document.exitFullscreen() : document.webkitExitFullscreen());
|
||
} else {
|
||
const root = document.documentElement;
|
||
await (root.requestFullscreen ? root.requestFullscreen() : root.webkitRequestFullscreen());
|
||
if (screen.orientation && screen.orientation.lock) screen.orientation.lock('landscape').catch(() => {});
|
||
}
|
||
} catch (e) {
|
||
toast('Full screen is not available here');
|
||
}
|
||
});
|
||
|
||
// Options menu
|
||
function openMenu() {
|
||
document.body.classList.add('menu-open');
|
||
$('#optionsMenu').classList.add('open');
|
||
}
|
||
|
||
function closeMenu() {
|
||
$('#inviteSheet').classList.remove('open');
|
||
document.body.classList.remove('menu-open');
|
||
$('#optionsMenu').classList.remove('open');
|
||
}
|
||
|
||
// ---------------------------------------------------------------- invite
|
||
|
||
// The console sends the QR code as a grid, one hex string per row, so the page
|
||
// needs no QR library: each run of dark modules becomes one rectangle.
|
||
function qrPath(size, rows, quiet) {
|
||
const dark = (row, x) => x < size && ((parseInt(row[x >> 2], 16) >> (3 - (x & 3))) & 1) === 1;
|
||
let d = '';
|
||
for (let y = 0; y < size; y++) {
|
||
const row = rows[y] || '';
|
||
for (let x = 0; x < size;) {
|
||
if (!dark(row, x)) { x++; continue; }
|
||
let end = x;
|
||
while (dark(row, end)) end++;
|
||
d += 'M' + (x + quiet) + ' ' + (y + quiet) + 'h' + (end - x) + 'v1h-' + (end - x) + 'z';
|
||
x = end;
|
||
}
|
||
}
|
||
return d;
|
||
}
|
||
|
||
// The address this page reached the console on, which is the one that works
|
||
// from this network; the console falls back to its own when there's none.
|
||
function inviteParams() {
|
||
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?::(\d{1,5}))?$/.exec(conn.address || '');
|
||
return m ? [+m[1], +m[2], +m[3], +m[4], +(m[5] || 4264)] : [];
|
||
}
|
||
|
||
function openInvite() {
|
||
closeMenu();
|
||
document.body.classList.add('menu-open');
|
||
$('#inviteSheet').classList.add('open');
|
||
const box = $('#inviteQr'), address = $('#inviteAddress');
|
||
box.textContent = '';
|
||
address.textContent = '';
|
||
call('invite', inviteParams()).then(r => {
|
||
const quiet = 4, n = r.size + quiet * 2, ns = 'http://www.w3.org/2000/svg';
|
||
const svg = document.createElementNS(ns, 'svg');
|
||
svg.setAttribute('viewBox', '0 0 ' + n + ' ' + n);
|
||
svg.setAttribute('shape-rendering', 'crispEdges');
|
||
svg.setAttribute('role', 'img');
|
||
svg.setAttribute('aria-label', 'QR code for ' + r.text);
|
||
const path = document.createElementNS(ns, 'path');
|
||
path.setAttribute('d', qrPath(r.size, r.rows, quiet));
|
||
path.setAttribute('fill', '#000');
|
||
svg.appendChild(path);
|
||
box.appendChild(svg);
|
||
address.textContent = r.text.replace(/\/$/, '');
|
||
}).catch(e => { address.textContent = e.message || 'Couldn\'t make the QR code'; });
|
||
}
|
||
|
||
$('#openInvite').addEventListener('click', openInvite);
|
||
$('#menuInvite').addEventListener('click', openInvite);
|
||
$('#closeInvite').addEventListener('click', closeMenu);
|
||
$('#padMenuBtn').addEventListener('click', () => { if (!editor.active) openMenu(); });
|
||
$('#menuLayout').addEventListener('click', () => { closeMenu(); editor.open(); });
|
||
$('#closeOptions').addEventListener('click', closeMenu);
|
||
$('#scrim').addEventListener('click', closeMenu);
|
||
$('#menuSettings').addEventListener('click', () => { closeMenu(); openSettings('device'); });
|
||
|
||
$('#leaveBtn').addEventListener('click', async () => {
|
||
const pad = primaryPad;
|
||
if (pad < 0) return;
|
||
// Controllers on this page that play as this pad stop, or their input would bring the guest back
|
||
for (const [index, src] of gamepadSources) if (src.pad === pad) setGamepadPad(index, -1);
|
||
closeMenu();
|
||
closePad();
|
||
// Updating the claim removes the device; a second leave would target an
|
||
// already removed pad. The claim response reports any failure.
|
||
toast('Controller ' + (pad + 1) + ' disconnected');
|
||
});
|
||
|
||
// ---------------------------------------------------------------- device settings (local)
|
||
|
||
function renderDeviceRows(container) {
|
||
container.textContent = '';
|
||
for (const [key, label, desc] of DEVICE_SETTINGS) {
|
||
if (key === 'haptics' && !('vibrate' in navigator)) continue;
|
||
const row = el('label', 'row');
|
||
row.appendChild(el('span', 'row-label', label));
|
||
const input = el('input', 'check');
|
||
input.type = 'checkbox';
|
||
input.checked = !!settings[key];
|
||
input.dataset.setting = key;
|
||
input.addEventListener('change', () => setDeviceSetting(key, input.checked));
|
||
row.appendChild(input);
|
||
row.appendChild(el('span', 'row-desc', desc));
|
||
container.appendChild(row);
|
||
}
|
||
}
|
||
|
||
function setDeviceSetting(key, value) {
|
||
settings[key] = value;
|
||
store.set(key, value);
|
||
for (const input of $$(`[data-setting="${key}"]`)) input.checked = value;
|
||
if (key === 'keys') padView.classList.toggle('show-keys', !!value);
|
||
if (key === 'stickClick') applyLayout();
|
||
if (key === 'keepAwake') keepAwake.update();
|
||
}
|
||
|
||
renderDeviceRows($('#menuRows'));
|
||
renderDeviceRows($('#deviceRows'));
|
||
padView.classList.toggle('show-keys', !!settings.keys);
|
||
|
||
// ---------------------------------------------------------------- keyboard settings
|
||
|
||
$('#mapKeys').addEventListener('click', () => mapper.open('keyboard'));
|
||
$('#resetKeys').addEventListener('click', () => {
|
||
keymap = defaultKeys();
|
||
store.set('keymap', keymap);
|
||
rebuildKeymap();
|
||
toast('Keyboard keys reset');
|
||
});
|
||
|
||
rebuildKeymap();
|
||
|
||
// ---------------------------------------------------------------- experimental
|
||
|
||
// Extras that need what browsers give secure pages only (WebHID, motion
|
||
// sensors), or that PS4 games never ask for (trigger rumble). Each is off
|
||
// until turned on in Settings → Experimental; while one is off, none of its
|
||
// code runs: no device prompts, no sensor listeners, no motion messages.
|
||
const EXPERIMENTS = [
|
||
['hid', 'Lights and rumble on Sony controllers', 'A DualShock 4 or DualSense connected to this computer lights up in its PS4 controller\'s colour, rumbles directly (a DualSense included), and passes on its touchpad\'s fingers. Chrome or Edge on a computer.'],
|
||
['triggers', 'Trigger rumble', 'Xbox controllers rumble in their triggers too, and a linked DualSense vibrates its triggers, following the game\'s rumble. PS4 games don\'t ask for this: Control4Free makes it up.'],
|
||
['motion', 'Motion controls', 'Tilting this phone, or a linked DualShock 4 or DualSense, moves the PS4 controller\'s motion sensors.'],
|
||
];
|
||
const EXPERIMENTS_HELP = 'https://github.com/MoHadiShibli/Control4Free/blob/main/docs/troubleshooting.md#experimental-features';
|
||
const experiments = {};
|
||
for (const [key] of EXPERIMENTS) experiments[key] = store.get('exp.' + key, false) === true;
|
||
|
||
// Why an experiment can't run in this browser, or '' when it can.
|
||
function experimentBlocked(key) {
|
||
if (key === 'triggers') return '';
|
||
if (!window.isSecureContext) return 'This browser treats the page as insecure, so it holds this back.';
|
||
if (key === 'hid' && !('hid' in navigator)) return 'This browser can\'t talk to controllers directly. Use Chrome or Edge on a computer.';
|
||
if (key === 'motion' && !('hid' in navigator) && !('DeviceMotionEvent' in window)) return 'This browser has no motion sensors to offer.';
|
||
return '';
|
||
}
|
||
const experimentOn = key => experiments[key] && !experimentBlocked(key);
|
||
|
||
function setExperiment(key, on) {
|
||
experiments[key] = on;
|
||
store.set('exp.' + key, on);
|
||
if (key === 'hid') { if (on) hidStart(); else hidStop(); }
|
||
if (key === 'motion') { if (on) motionStart(); else motionStop(); }
|
||
if (key === 'triggers') for (const link of hidLinks.values()) hidUpdate(link);
|
||
renderExperiments();
|
||
renderGamepads();
|
||
}
|
||
|
||
function renderExperiments() {
|
||
const rows = $('#expRows');
|
||
rows.textContent = '';
|
||
for (const [key, label, desc] of EXPERIMENTS) {
|
||
const blocked = experimentBlocked(key);
|
||
const row = el('label', 'row');
|
||
row.appendChild(el('span', 'row-label', label));
|
||
const input = el('input', 'check');
|
||
input.type = 'checkbox';
|
||
input.checked = experimentOn(key);
|
||
input.disabled = !!blocked;
|
||
input.dataset.experiment = key;
|
||
input.addEventListener('change', () => setExperiment(key, input.checked));
|
||
row.appendChild(input);
|
||
row.appendChild(el('span', 'row-desc', desc));
|
||
if (blocked) {
|
||
const why = el('span', 'row-desc blocked', blocked + ' ');
|
||
// Only an insecure page has a fix in this browser's settings.
|
||
if (!window.isSecureContext) {
|
||
const help = el('a', '', 'How to turn it on');
|
||
help.href = EXPERIMENTS_HELP;
|
||
help.target = '_blank';
|
||
help.rel = 'noopener';
|
||
why.appendChild(help);
|
||
}
|
||
row.appendChild(why);
|
||
}
|
||
rows.appendChild(row);
|
||
}
|
||
$('#expSony').hidden = !experimentOn('hid');
|
||
if (experimentOn('hid')) renderSonyPads();
|
||
$('#expMotion').hidden = !experimentOn('motion');
|
||
$('#expMotionHint').textContent = ('DeviceMotionEvent' in window ? 'Hold the phone sideways, screen towards you, like a controller. ' : '') +
|
||
('hid' in navigator ? 'A linked DualShock 4 or DualSense sends its own motion. ' : '') +
|
||
'Recenter makes the way you hold it now the controller\'s resting position.';
|
||
}
|
||
|
||
// ---- Sony controllers over WebHID
|
||
|
||
const SONY = 0x054c;
|
||
const SONY_MODELS = { 0x05c4: 'ds4', 0x09cc: 'ds4', 0x0ba0: 'ds4', 0x0ce6: 'dualsense', 0x0df2: 'dualsense' };
|
||
const hidLinks = new Map(); // gamepad index -> its direct connection
|
||
|
||
// Vendor and product from a gamepad's id: Chrome "… Vendor: 054c Product: 09cc)", Firefox "054c-09cc-…".
|
||
function gamepadIds(gp) {
|
||
const id = String((gp && gp.id) || '');
|
||
const m = /Vendor:\s*([0-9a-f]{4})\b[^]*?Product:\s*([0-9a-f]{4})\b/i.exec(id) || /^([0-9a-f]{1,4})-([0-9a-f]{1,4})-/i.exec(id);
|
||
return m ? { vendor: parseInt(m[1], 16), product: parseInt(m[2], 16) } : null;
|
||
}
|
||
|
||
function sonyModel(gp) {
|
||
const ids = gamepadIds(gp);
|
||
return ids && ids.vendor === SONY ? SONY_MODELS[ids.product] || null : null;
|
||
}
|
||
|
||
function renderSonyPads() {
|
||
const list = $('#expPads');
|
||
list.textContent = '';
|
||
let any = false;
|
||
for (const [index, src] of gamepadSources) {
|
||
if (!sonyModel(src.gp)) continue;
|
||
any = true;
|
||
const row = el('li', 'gp-row gp-row--settings');
|
||
const device = gamepadDevice(src, index);
|
||
const link = hidLinks.get(index);
|
||
$('.gp-details', device).appendChild(el('div', 'gp-note', link
|
||
? 'Linked' + (link.bt ? ' over Bluetooth' : ' by USB') : 'Not linked'));
|
||
row.appendChild(device);
|
||
const button = el('button', 'btn gp-mapping', link ? 'Unlink' : 'Link');
|
||
button.type = 'button';
|
||
button.dataset.link = String(index);
|
||
button.addEventListener('click', () => link ? hidDetach(index) : hidLink(index));
|
||
row.appendChild(button);
|
||
list.appendChild(row);
|
||
}
|
||
$('#expPadsEmpty').hidden = any;
|
||
}
|
||
|
||
const crcTable = (() => {
|
||
const table = new Uint32Array(256);
|
||
for (let n = 0; n < 256; n++) {
|
||
let c = n;
|
||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||
table[n] = c >>> 0;
|
||
}
|
||
return table;
|
||
})();
|
||
|
||
// Bluetooth output reports end in a CRC-32 of 0xa2, the report ID and the
|
||
// rest of the report, stored little-endian in the last four bytes.
|
||
function psCrc(reportId, data) {
|
||
let c = 0xffffffff;
|
||
const add = byte => { c = crcTable[(c ^ byte) & 0xff] ^ (c >>> 8); };
|
||
add(0xa2);
|
||
add(reportId);
|
||
for (let i = 0; i < data.length - 4; i++) add(data[i]);
|
||
c = (c ^ 0xffffffff) >>> 0;
|
||
for (let i = 0; i < 4; i++) data[data.length - 4 + i] = (c >>> (8 * i)) & 0xff;
|
||
}
|
||
|
||
// DualSense trigger effect, 11 bytes: Vibration (mode 0x26) over the whole
|
||
// travel at a strength from 1 to 8, or Off (0x05). After Nielk1's
|
||
// DualSense trigger effect generator (MIT).
|
||
function triggerEffect(out, at, strength, frequency) {
|
||
out.fill(0, at, at + 11);
|
||
if (strength <= 0) { out[at] = 0x05; return; }
|
||
let zones = 0, amplitude = 0;
|
||
for (let i = 0; i < 10; i++) { zones |= 1 << i; amplitude += (strength - 1) * 2 ** (3 * i); }
|
||
out[at] = 0x26;
|
||
out[at + 1] = zones & 0xff;
|
||
out[at + 2] = zones >> 8;
|
||
for (let i = 0; i < 4; i++) out[at + 3 + i] = Math.floor(amplitude / 2 ** (8 * i)) & 0xff;
|
||
out[at + 9] = frequency;
|
||
}
|
||
|
||
// One output report for a linked controller's current state, as Linux's
|
||
// hid-playstation driver lays them out. Returns { id, data } (data without the ID).
|
||
function hidReport(link, setupOnly) {
|
||
const [large, small] = link.motors, [r, g, b] = link.color;
|
||
if (link.model === 'ds4') {
|
||
const data = new Uint8Array(link.bt ? 77 : 31), at = link.bt ? 2 : 0;
|
||
if (link.bt) data[0] = 0xc4; // HID + CRC, 4 ms input reports
|
||
data[at] = 0x03; // motors and light bar
|
||
data[at + 3] = small;
|
||
data[at + 4] = large;
|
||
data[at + 5] = r; data[at + 6] = g; data[at + 7] = b;
|
||
if (link.bt) psCrc(0x11, data);
|
||
return { id: link.bt ? 0x11 : 0x05, data };
|
||
}
|
||
const common = new Uint8Array(47);
|
||
if (setupOnly) {
|
||
common[38] = 0x02; // light bar setup: fade out the start-up light
|
||
common[41] = 0x02;
|
||
} else {
|
||
common[0] = 0x02 | (link.v2 ? 0 : 0x01); // rumble, the DualShock 4 way
|
||
if (link.v2) common[38] = 0x04;
|
||
common[1] = 0x04; // light bar
|
||
common[2] = small;
|
||
common[3] = large;
|
||
common[44] = r; common[45] = g; common[46] = b;
|
||
if (link.triggerSend) {
|
||
common[0] |= 0x0c; // both trigger effects
|
||
triggerEffect(common, 10, link.trigger[1], 60);
|
||
triggerEffect(common, 21, link.trigger[0], 30);
|
||
}
|
||
}
|
||
if (!link.bt) {
|
||
const data = new Uint8Array(62);
|
||
data.set(common);
|
||
return { id: 0x02, data };
|
||
}
|
||
const data = new Uint8Array(77);
|
||
data[0] = (link.seq & 15) << 4;
|
||
data[1] = 0x10;
|
||
data.set(common, 2);
|
||
link.seq = (link.seq + 1) & 15;
|
||
psCrc(0x31, data);
|
||
return { id: 0x31, data };
|
||
}
|
||
|
||
function hidHasOutput(device, reportId) {
|
||
return (device.collections || []).some(c => (c.outputReports || []).some(r => r.reportId === reportId));
|
||
}
|
||
|
||
// What the linked controller should be doing: its PS4 controller's rumble and colour.
|
||
function hidUpdate(link) {
|
||
const src = gamepadSources.get(link.index);
|
||
const pad = src ? src.pad : -1;
|
||
const r = pad >= 0 && rumble[pad] ? rumble[pad] : { large: 0, small: 0 };
|
||
link.motors = [r.large, r.small];
|
||
link.color = pad >= 0 && status[pad] && Array.isArray(status[pad].color) ? status[pad].color.slice(0, 3) : [0, 0, 64];
|
||
const level = v => experiments.triggers ? Math.round(v / 255 * 8) : 0;
|
||
const trigger = link.model === 'dualsense' ? [level(r.large), level(r.small)] : [0, 0];
|
||
// Trigger effects go out only when they change; re-sending them makes the motors whine.
|
||
link.triggerSend = link.triggerSend || trigger[0] !== link.trigger[0] || trigger[1] !== link.trigger[1];
|
||
link.trigger = trigger;
|
||
hidSend(link);
|
||
}
|
||
|
||
async function hidSend(link, force) {
|
||
if (link.busy) { link.again = true; return; }
|
||
const key = link.motors.join() + '|' + link.color.join() + '|' + link.trigger.join();
|
||
const at = now();
|
||
// Unchanged: only a slow refresh while it rumbles, in case a report went missing.
|
||
if (!force && !link.triggerSend && key === link.sent && !(link.motors.some(v => v) && at - link.sentAt > 500)) return;
|
||
link.busy = true;
|
||
try {
|
||
if (link.setup) { const s = hidReport(link, true); await link.device.sendReport(s.id, s.data); link.setup = false; }
|
||
const report = hidReport(link, false);
|
||
await link.device.sendReport(report.id, report.data);
|
||
link.sent = key;
|
||
link.sentAt = at;
|
||
link.triggerSend = false;
|
||
} catch (e) {
|
||
link.sent = '';
|
||
}
|
||
link.busy = false;
|
||
if (link.again) { link.again = false; hidUpdate(link); }
|
||
}
|
||
|
||
async function hidLink(index) {
|
||
const src = gamepadSources.get(index), ids = src && gamepadIds(src.gp);
|
||
if (!ids || !experimentOn('hid')) return;
|
||
let devices = [];
|
||
try {
|
||
devices = await navigator.hid.requestDevice({ filters: [{ vendorId: SONY, productId: ids.product }] });
|
||
} catch (e) {
|
||
toast('The browser didn\'t allow the controller');
|
||
return;
|
||
}
|
||
if (devices.length) await hidAttach(index, devices[0]);
|
||
}
|
||
|
||
async function hidAttach(index, device) {
|
||
for (const [i, other] of [...hidLinks]) if (other.device === device || i === index) await hidDetach(i, true);
|
||
const model = SONY_MODELS[device.productId];
|
||
if (!model) return;
|
||
try {
|
||
if (!device.opened) await device.open();
|
||
} catch (e) {
|
||
toast('Couldn\'t open the controller. Close other apps that use it, then try again.');
|
||
return;
|
||
}
|
||
const link = {
|
||
device, model, index, bt: !hidHasOutput(device, model === 'ds4' ? 0x05 : 0x02) && hidHasOutput(device, model === 'ds4' ? 0x11 : 0x31),
|
||
seq: 0, sent: '', sentAt: 0, busy: false, again: false, setup: model === 'dualsense', v2: device.productId === 0x0df2,
|
||
motors: [0, 0], color: [0, 0, 0], trigger: [0, 0], triggerSend: false, calib: null, motion: null,
|
||
};
|
||
link.onInput = ev => hidInput(link, ev);
|
||
device.addEventListener('inputreport', link.onInput);
|
||
hidLinks.set(index, link);
|
||
await hidCalibrate(link);
|
||
if (hidLinks.get(index) === link) hidUpdate(link);
|
||
renderExperiments();
|
||
renderGamepads();
|
||
}
|
||
|
||
async function hidDetach(index, quiet) {
|
||
const link = hidLinks.get(index);
|
||
if (!link) return;
|
||
hidLinks.delete(index);
|
||
link.device.removeEventListener('inputreport', link.onInput);
|
||
const src = gamepadSources.get(index);
|
||
if (src) { src.touches = []; src.hidState = null; }
|
||
motionLeave(link.motion);
|
||
// Left still, in a dim blue, once any report on its way has gone.
|
||
for (let i = 0; link.busy && i < 20; i++) await new Promise(done => setTimeout(done, 10));
|
||
link.motors = [0, 0];
|
||
link.color = [0, 0, 64];
|
||
link.triggerSend = link.trigger.some(v => v);
|
||
link.trigger = [0, 0];
|
||
link.busy = link.again = false;
|
||
await hidSend(link, true);
|
||
try { await link.device.close(); } catch (e) { /* already gone */ }
|
||
if (!quiet) { renderExperiments(); renderGamepads(); }
|
||
}
|
||
|
||
// A controller the browser was allowed before links again by itself, when
|
||
// exactly one gamepad of that kind is waiting for it.
|
||
let hidListening = false;
|
||
async function hidStart() {
|
||
if (!experimentOn('hid')) return;
|
||
if (!hidListening) {
|
||
hidListening = true;
|
||
navigator.hid.addEventListener('disconnect', ev => {
|
||
for (const [index, link] of [...hidLinks]) if (link.device === ev.device) hidDetach(index);
|
||
});
|
||
navigator.hid.addEventListener('connect', () => { if (experimentOn('hid')) hidStart(); });
|
||
}
|
||
let granted = [];
|
||
try { granted = await navigator.hid.getDevices(); } catch (e) { return; }
|
||
for (const device of granted) {
|
||
if (device.vendorId !== SONY || !SONY_MODELS[device.productId]) continue;
|
||
if ([...hidLinks.values()].some(l => l.device === device)) continue;
|
||
const waiting = [...gamepadSources].filter(([i, src]) => !hidLinks.has(i) && (gamepadIds(src.gp) || {}).product === device.productId);
|
||
if (waiting.length === 1) await hidAttach(waiting[0][0], device);
|
||
}
|
||
}
|
||
|
||
async function hidStop() {
|
||
for (const index of [...hidLinks.keys()]) await hidDetach(index, true);
|
||
}
|
||
|
||
// Reading the calibration (and, on a DualSense, the firmware version) also
|
||
// switches a Bluetooth controller to its full reports, motion included.
|
||
async function hidCalibrate(link) {
|
||
// Offsets count the report ID as byte 0, as the report's full length does.
|
||
const lengths = { 0x02: 37, 0x05: 41, 0x20: 64 };
|
||
const feature = async id => {
|
||
const view = await link.device.receiveFeatureReport(id);
|
||
const shift = view.byteLength >= lengths[id] ? 0 : view.byteLength === lengths[id] - 1 ? -1
|
||
: view.getUint8(0) === id ? 0 : -1;
|
||
return { u16: at => view.getUint16(at + shift, true), i16: at => view.getInt16(at + shift, true) };
|
||
};
|
||
try {
|
||
const f = await feature(link.model === 'ds4' && !link.bt ? 0x02 : 0x05);
|
||
const bias = [f.i16(1), f.i16(3), f.i16(5)];
|
||
const plusMinus = link.model === 'ds4' && link.bt
|
||
? [[f.i16(7), f.i16(13)], [f.i16(9), f.i16(15)], [f.i16(11), f.i16(17)]]
|
||
: [[f.i16(7), f.i16(9)], [f.i16(11), f.i16(13)], [f.i16(15), f.i16(17)]];
|
||
const speed = f.i16(19) + f.i16(21);
|
||
const gyro = plusMinus.map(([p, m], i) => {
|
||
const span = Math.abs(p - bias[i]) + Math.abs(m - bias[i]);
|
||
return span ? speed / span : 0;
|
||
});
|
||
const accel = [[f.i16(23), f.i16(25)], [f.i16(27), f.i16(29)], [f.i16(31), f.i16(33)]].map(([p, m]) => {
|
||
const range = p - m;
|
||
return range ? { bias: p - range / 2, scale: 2 / range } : null;
|
||
});
|
||
if (gyro.every(v => v > 0) && accel.every(Boolean)) link.calib = { gyro, accel };
|
||
} catch (e) { /* fall back to the sensors' usual ranges */ }
|
||
if (link.model === 'dualsense' && !link.v2) {
|
||
try { link.v2 = (await feature(0x20)).u16(44) >= 0x0215; } catch (e) { /* older firmware's rumble */ }
|
||
}
|
||
}
|
||
|
||
// Up to two fingers from a report: contact (bit 7 set once lifted, the rest
|
||
// the finger's id), then 12-bit x and y. A DualSense's touchpad is 1080
|
||
// high and the PS4's 942, so its y is scaled.
|
||
function hidTouches(data, at, height) {
|
||
const touches = [];
|
||
for (let i = 0; i < 2 && data.byteLength >= at + 4 * i + 4; i++) {
|
||
const p = at + 4 * i, contact = data.getUint8(p);
|
||
if (contact & 0x80) continue;
|
||
const x = data.getUint8(p + 1) | (data.getUint8(p + 2) & 0x0f) << 8;
|
||
const y = data.getUint8(p + 2) >> 4 | data.getUint8(p + 3) << 4;
|
||
touches.push({ id: contact & 0x7f, x: clamp(x, 0, TOUCH_W), y: clamp(Math.round(y * (TOUCH_H + 1) / height), 0, TOUCH_H) });
|
||
}
|
||
return touches;
|
||
}
|
||
|
||
// A report's buttons, sticks and triggers. Over Bluetooth a linked controller
|
||
// switches to its full reports, which the browser's own gamepad doesn't read,
|
||
// so a linked controller's input comes from here. DS4: sticks, then buttons,
|
||
// then triggers; DualSense: sticks, triggers, a counter, buttons. Its short
|
||
// Bluetooth report, before the switch, is laid out like a DS4's.
|
||
const HAT = [B.UP, B.UP | B.RIGHT, B.RIGHT, B.RIGHT | B.DOWN, B.DOWN, B.DOWN | B.LEFT, B.LEFT, B.LEFT | B.UP];
|
||
const HID_BUTTONS = [[0x10, B.SQUARE], [0x20, B.CROSS], [0x40, B.CIRCLE], [0x80, B.TRIANGLE]];
|
||
const HID_BUTTONS1 = [[0x01, B.L1], [0x02, B.R1], [0x04, B.L2], [0x08, B.R2], [0x10, B.SHARE], [0x20, B.OPTIONS], [0x40, B.L3], [0x80, B.R3]];
|
||
function hidState(link, data, id) {
|
||
const n = data.byteLength;
|
||
let at, triggersAt, buttonsAt;
|
||
if (link.model === 'ds4' || (id === 0x01 && n < 63)) {
|
||
if (id === 0x01 && n >= 9) at = 0; else if (id === 0x11 && n >= 11) at = 2; else return null;
|
||
buttonsAt = at + 4; triggersAt = at + 7;
|
||
} else if (id === 0x01) {
|
||
at = 0; triggersAt = 4; buttonsAt = 7;
|
||
} else if (id === 0x31 && n >= 11) {
|
||
at = 1; triggersAt = 5; buttonsAt = 8;
|
||
} else return null;
|
||
const b0 = data.getUint8(buttonsAt), b1 = data.getUint8(buttonsAt + 1), b2 = data.getUint8(buttonsAt + 2);
|
||
let buttons = (b0 & 0x0f) < 8 ? HAT[b0 & 0x0f] : 0;
|
||
for (const [bit, out] of HID_BUTTONS) if (b0 & bit) buttons |= out;
|
||
for (const [bit, out] of HID_BUTTONS1) if (b1 & bit) buttons |= out;
|
||
if (b2 & 0x01) buttons |= B.PS;
|
||
if (b2 & 0x02) buttons |= B.TOUCH;
|
||
return { buttons, lx: data.getUint8(at), ly: data.getUint8(at + 1), rx: data.getUint8(at + 2), ry: data.getUint8(at + 3),
|
||
l2: data.getUint8(triggersAt), r2: data.getUint8(triggersAt + 1) };
|
||
}
|
||
|
||
// Input from a linked controller: its buttons and sticks (see hidState), the
|
||
// touchpad's fingers, which browsers' gamepads leave out, and the motion
|
||
// sensors while motion is on. Offsets are hid-playstation's, without the
|
||
// report ID: [gyro, touch, touch sets].
|
||
function hidInput(link, ev) {
|
||
const data = ev.data, id = ev.reportId, ds4 = link.model === 'ds4';
|
||
const src = gamepadSources.get(link.index);
|
||
const state = hidState(link, data, id);
|
||
if (src && state) { src.hidState = state; src.hidAt = ev.timeStamp; }
|
||
const layout = ds4 ? (id === 0x01 ? [12, 32, 3] : id === 0x11 ? [14, 34, 4] : null)
|
||
: (id === 0x01 ? [15, 32, 0] : id === 0x31 ? [16, 33, 0] : null);
|
||
if (!layout || data.byteLength < layout[1] + 8) return;
|
||
const [gyroAt, touchAt, sets] = layout, accelAt = gyroAt + 6;
|
||
if (src && !ds4) src.touches = hidTouches(data, touchAt, 1080);
|
||
if (src && ds4) {
|
||
// A DS4 report holds the touch reports since its last one, newest last.
|
||
const count = Math.min(data.getUint8(touchAt), sets);
|
||
if (count) src.touches = hidTouches(data, touchAt + 1 + 9 * (count - 1) + 1, 942);
|
||
}
|
||
if (!experimentOn('motion')) return;
|
||
const c = link.calib, D = Math.PI / 180;
|
||
const gyro = [0, 1, 2].map(i => data.getInt16(gyroAt + 2 * i, true) * (c ? c.gyro[i] : 1 / 16.4) * D);
|
||
const accel = [0, 1, 2].map(i => {
|
||
const raw = data.getInt16(accelAt + 2 * i, true);
|
||
return c ? (raw - c.accel[i].bias) * c.accel[i].scale : raw / 8192;
|
||
});
|
||
if (!link.motion) link.motion = newMotion();
|
||
motionSample(link.motion, gyro, accel, ev.timeStamp);
|
||
}
|
||
|
||
// ---- motion: the phone's sensors, or a linked controller's
|
||
|
||
// The PS4 reads a DualShock 4 with x to the right, y up out of its face and z
|
||
// towards the player, so lying flat it reads +1 G on y. A phone held sideways
|
||
// with the screen towards the player lines up with that directly.
|
||
function newMotion() { return { q: [1, 0, 0, 0], gyro: [0, 0, 0], accel: [0, 1, 0], at: 0, fresh: false, pad: -1 }; }
|
||
const phoneMotion = newMotion();
|
||
const motionSentAt = Array(PADS).fill(0);
|
||
const motionLive = Array(PADS).fill(false);
|
||
|
||
// Mahony's filter: the gyro turns the orientation, gravity keeps up up.
|
||
function motionSample(m, gyro, accel, t) {
|
||
const dt = m.at ? clamp((t - m.at) / 1000, 0, .1) : 0;
|
||
m.at = t;
|
||
m.gyro = gyro;
|
||
m.accel = accel;
|
||
m.fresh = true;
|
||
if (!dt) return;
|
||
let [w, x, y, z] = m.q, [gx, gy, gz] = gyro;
|
||
const n = Math.hypot(accel[0], accel[1], accel[2]);
|
||
if (n > .5 && n < 1.5) {
|
||
const ax = accel[0] / n, ay = accel[1] / n, az = accel[2] / n;
|
||
const vx = 2 * (x * y + w * z), vy = 1 - 2 * (x * x + z * z), vz = 2 * (y * z - w * x);
|
||
gx += 2 * (ay * vz - az * vy);
|
||
gy += 2 * (az * vx - ax * vz);
|
||
gz += 2 * (ax * vy - ay * vx);
|
||
}
|
||
const h = dt / 2;
|
||
const q = [w + h * (-x * gx - y * gy - z * gz), x + h * (w * gx + y * gz - z * gy),
|
||
y + h * (w * gy - x * gz + z * gx), z + h * (w * gz + x * gy - y * gx)];
|
||
const len = Math.hypot(...q) || 1;
|
||
m.q = q.map(v => v / len);
|
||
}
|
||
|
||
function onDeviceMotion(ev) {
|
||
const a = ev.accelerationIncludingGravity, r = ev.rotationRate;
|
||
if (!a || a.x === null || a.x === undefined || !r) return;
|
||
// Device axes to the screen's, as the phone is turned.
|
||
const angle = ((screen.orientation && screen.orientation.angle) || 0) * Math.PI / 180;
|
||
const cos = Math.cos(angle), sin = Math.sin(angle);
|
||
const turn = (x, y) => [x * cos - y * sin, x * sin + y * cos];
|
||
const [ax, ay] = turn(a.x, a.y), [gx, gy] = turn(r.beta || 0, r.gamma || 0);
|
||
const G = 9.80665, D = Math.PI / 180;
|
||
motionSample(phoneMotion, [gx * D, gy * D, (r.alpha || 0) * D], [ax / G, ay / G, (a.z || 0) / G], ev.timeStamp);
|
||
}
|
||
|
||
function motionStart() {
|
||
if (!experimentOn('motion') || !('DeviceMotionEvent' in window)) return;
|
||
window.addEventListener('devicemotion', onDeviceMotion);
|
||
// Safari sends nothing until asked from a tap; Chrome sends the events either way.
|
||
if (typeof DeviceMotionEvent.requestPermission === 'function') DeviceMotionEvent.requestPermission().catch(() => {});
|
||
}
|
||
|
||
function motionStop() {
|
||
window.removeEventListener('devicemotion', onDeviceMotion);
|
||
motionLeave(phoneMotion);
|
||
for (const link of hidLinks.values()) motionLeave(link.motion);
|
||
}
|
||
|
||
// A source stopped driving its controller: tell the PS4 it is still again.
|
||
function motionLeave(m) {
|
||
if (!m || m.pad < 0) return;
|
||
const pad = m.pad;
|
||
m.pad = -1;
|
||
m.at = 0;
|
||
if (motionLive[pad]) {
|
||
motionLive[pad] = false;
|
||
if (conn.motion) send({ method: 'm', params: [pad, 0, 0, 0, 10000, 0, 0, 0, 0, 0, 0] });
|
||
}
|
||
}
|
||
|
||
function motionSend(pad, m, t) {
|
||
if (t - motionSentAt[pad] < 16 || !m.fresh) return;
|
||
m.fresh = false;
|
||
const q = [m.q[1], m.q[2], m.q[3], m.q[0]];
|
||
const params = [pad, ...q.map(v => Math.round(clamp(v, -1, 1) * 10000)),
|
||
...m.accel.map(v => Math.round(clamp(v, -16, 16) * 1000)),
|
||
...m.gyro.map(v => Math.round(clamp(v, -35, 35) * 1000))];
|
||
if (send({ method: 'm', params })) { motionSentAt[pad] = t; motionLive[pad] = true; }
|
||
}
|
||
|
||
// Each frame: linked controllers' rumble and colour, and motion for the controllers that have it.
|
||
function experimentsTick(t) {
|
||
for (const link of hidLinks.values()) hidUpdate(link);
|
||
if (!experimentOn('motion') || !conn.motion) return;
|
||
const claimed = claimedPads();
|
||
const sources = [];
|
||
for (const link of hidLinks.values()) {
|
||
const src = gamepadSources.get(link.index);
|
||
if (link.motion) sources.push([src ? src.pad : -1, link.motion]);
|
||
}
|
||
// The phone drives the controller open on it, unless a linked controller does.
|
||
const phonePad = !padView.hidden && !document.hidden && primaryPad >= 0 ? primaryPad : -1;
|
||
if (!sources.some(([pad]) => pad === phonePad)) sources.push([phonePad, phoneMotion]);
|
||
else sources.push([-1, phoneMotion]);
|
||
for (const [pad, m] of sources) {
|
||
const target = pad >= 0 && claimed.includes(pad) && m.at ? pad : -1;
|
||
if (m.pad !== target) motionLeave(m);
|
||
if (target < 0) continue;
|
||
m.pad = target;
|
||
motionSend(target, m, t);
|
||
}
|
||
}
|
||
|
||
$('#expRecenter').addEventListener('click', () => {
|
||
phoneMotion.q = [1, 0, 0, 0];
|
||
for (const link of hidLinks.values()) if (link.motion) link.motion.q = [1, 0, 0, 0];
|
||
toast('Motion recentered');
|
||
});
|
||
|
||
renderExperiments();
|
||
if (experimentOn('hid')) hidStart();
|
||
if (experimentOn('motion')) motionStart();
|
||
|
||
// ---------------------------------------------------------------- local settings only
|
||
function showPage(page) {
|
||
for (const tab of $$('.nav-item')) tab.setAttribute('aria-selected', String(tab.dataset.page === page));
|
||
for (const section of $$('.settings-pages section')) section.hidden = section.dataset.page !== page;
|
||
}
|
||
for (const tab of $$('.nav-item')) tab.addEventListener('click', () => showPage(tab.dataset.page));
|
||
function openSettings(page) {
|
||
releaseScreen(); heldKeys.clear(); Object.assign(keySource, blankSource(keySource.pad));
|
||
$('#home').hidden = true; padView.hidden = true;
|
||
document.body.classList.remove('in-pad'); $('#settingsView').hidden = false;
|
||
showPage(page || 'device');
|
||
}
|
||
function closeSettings() {
|
||
$('#settingsView').hidden = true;
|
||
if (primaryPad >= 0) { padView.hidden = false; document.body.classList.add('in-pad'); }
|
||
else $('#home').hidden = false;
|
||
}
|
||
$('#openSettings').addEventListener('click', () => openSettings('device'));
|
||
$('#settingsBack').addEventListener('click', closeSettings);
|
||
$('#keysLink').addEventListener('click', () => mapper.open('keyboard'));
|
||
$('#stopBtn').addEventListener('click', () => {
|
||
if (!confirm('Disconnect your controllers and stop Control4Free on the PS4?')) return;
|
||
send({ method: 'stop', params: [] });
|
||
closeMenu();
|
||
});
|
||
|
||
// ---------------------------------------------------------------- page lifecycle
|
||
|
||
$('#addressForm').addEventListener('submit', e => {
|
||
e.preventDefault();
|
||
conn.retry = 0;
|
||
conn.everOpened = false;
|
||
connect($('#address').value);
|
||
});
|
||
|
||
// A hidden page stops sending (no animation frames): let go of every button right away. If it stays
|
||
// hidden, the console sees the controller disconnect after 3 seconds without keepalives.
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (document.hidden) {
|
||
mapper.cancelCapture(); mapper.keysDown.clear();
|
||
releaseScreen();
|
||
heldKeys.clear();
|
||
Object.assign(keySource, blankSource(keySource.pad));
|
||
for (const pad of claimedPads()) {
|
||
send({ method: 'u', params: [pad, 0, 128, 128, 128, 128, 0, 0, 0] });
|
||
lastSent[pad] = '';
|
||
}
|
||
stopAllRumble();
|
||
for (const src of gamepadSources.values()) clearDevice(src);
|
||
keepAwake.off();
|
||
} else {
|
||
resendAll();
|
||
sendClaim(true);
|
||
}
|
||
});
|
||
|
||
function frame(t) {
|
||
pollGamepads(t);
|
||
flush(t);
|
||
measurePing(t);
|
||
driveRumble(t);
|
||
// Experimental code must never stop the frame loop, and with it the input.
|
||
try { experimentsTick(t); } catch (e) { console.error(e); }
|
||
renderPressed();
|
||
mapper.tick(t);
|
||
requestAnimationFrame(frame);
|
||
}
|
||
|
||
window.addEventListener('gamepadconnected', () => {
|
||
pollGamepads(now()); renderGamepads();
|
||
if (experimentOn('hid')) hidStart();
|
||
});
|
||
window.addEventListener('gamepaddisconnected', event => {
|
||
// An identical replacement may already occupy the reused index when this
|
||
// event is delivered. Explicitly discard the old source's transient state.
|
||
const index = event.gamepad.index, src = gamepadSources.get(index);
|
||
if (hidLinks.has(index)) hidDetach(index);
|
||
if (mapper.index === index) mapper.close();
|
||
if (src) { clearDevice(src); gamepadSources.delete(index); }
|
||
pollGamepads(now()); renderGamepads(); sendClaim();
|
||
});
|
||
|
||
renderPads();
|
||
renderGamepads();
|
||
const address = initialAddress();
|
||
$('#address').value = address;
|
||
$('#addressForm').hidden = !openedFromFile || !!address;
|
||
connect(address);
|
||
|
||
|
||
requestAnimationFrame(frame);
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|