Files

2880 lines
130 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 device as a PS4 controller">
<title>RemotePad NG</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%230a3c96'/%3E%3Cpath d='M9 11h14a5 5 0 0 1 4.8 6.4l-1 3.4a3 3 0 0 1-5.2 1L19 19h-6l-2.6 2.8a3 3 0 0 1-5.2-1l-1-3.4A5 5 0 0 1 9 11z' fill='%23fff'/%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: 26px; height: 26px; }
.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;
}
.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: auto 1fr auto;
align-items: center;
gap: 14px;
padding: 12px 14px;
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-row svg { width: 30px; height: 30px; color: var(--muted); }
.gp-name { font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.gp-sub { font-size: 12.5px; color: var(--muted); font-weight: 300; }
.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: 560px) {
.gp-row { grid-template-columns: auto 1fr; }
.gp-row .seg { grid-column: 1 / -1; justify-content: flex-start; }
/* 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: opens the menu */
.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);
}
.ps-btn:active { transform: scale(.92); }
.ps-btn svg { width: 46%; height: 46%; }
/* 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)); }
.corner-btn.right { right: max(8px, env(safe-area-inset-right)); }
.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); }
.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-row { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 12px; min-height: 52px; padding: 8px 18px; border-top: 1px solid var(--line-soft); }
.key-row:first-child { border-top: 0; }
.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; }
@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; }
/* ---------- 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: 26px; height: 26px; 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 pad -->
<div class="home" id="home">
<header class="topbar">
<div class="tb-group">
<div class="app-mark">
<svg viewBox="0 0 32 32" aria-hidden="true"><path fill="#fff" d="M9 10h14a6 6 0 0 1 5.8 7.6l-1.2 4.6a3.4 3.4 0 0 1-5.9 1.3L18.5 20h-5l-3.2 3.5a3.4 3.4 0 0 1-5.9-1.3l-1.2-4.6A6 6 0 0 1 9 10zm1 4v2h-2v2h2v2h2v-2h2v-2h-2v-2h-2zm11.5 0a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm-2.5 3.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3z"/></svg>
<span>RemotePad NG</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>
</div>
<div class="tb-group">
<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>Console address</h2>
<p class="hint" id="addressHint">The address is in the "RemotePad NG" notification that appears when a game starts.</p>
<div class="address-row">
<input class="field" id="address" inputmode="url" autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="192.168.1.20:4263" aria-label="Console address">
<button class="btn primary" type="submit">Connect</button>
</div>
</form>
<section aria-labelledby="padsTitle">
<h1 class="screen-title" id="padsTitle">Pick your pad</h1>
<p class="screen-sub">Pick a free pad to join the game. Every player needs a different pad.</p>
<div class="tiles" id="padGrid"></div>
</section>
<section class="panel" aria-labelledby="gpTitle" id="gamepadSection">
<h2 id="gpTitle">Controllers on this device</h2>
<p class="hint" id="gamepadHint"></p>
<ul class="gp-list" id="gamepadList"></ul>
</section>
</main>
<footer class="home-foot">
<span><a id="version" href="https://github.com/MoHadiShibli/remotePad-ng" target="_blank" rel="noopener noreferrer">RemotePad NG</a> · based on <a href="https://github.com/xfangfang/remotePad" target="_blank" rel="noopener noreferrer">remotePad</a> by xfangfang</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 pads">
<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>
<button class="corner-btn right" 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 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" 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" type="button" aria-label="Menu">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11.5L12 5l8 6.5"/><path d="M6.5 10v8.5h11V10"/></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="pads" aria-selected="true"><svg viewBox="0 0 24 24" fill="currentColor"><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"/></svg>Pads</button>
<button class="nav-item" role="tab" data-page="players" aria-selected="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="9" cy="8" r="3.5"/><path d="M2.5 20c.8-3.6 3.4-5.5 6.5-5.5s5.7 1.9 6.5 5.5"/><circle cx="17" cy="9" r="2.6"/><path d="M15.5 14.6c2.7.1 4.8 1.8 5.5 4.9"/></svg>Players</button>
<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>
</nav>
<div class="settings-pages">
<section data-page="pads">
<h2 class="page-title">Pads</h2>
<p class="hint">Name each pad, and pick a PS4 user if a game needs real users. A pad with a PS4 user plays as that user while they're signed in on the PS4, and as a guest when they aren't.</p>
<p class="hint error" id="settingsUnavailable" hidden>These settings need a newer RemotePad NG plugin on the console.</p>
<div id="padSettings"></div>
</section>
<section data-page="players" hidden>
<h2 class="page-title">Players</h2>
<div class="rows">
<label class="row"><span class="row-label">Guests join when the game starts</span><input class="check" type="checkbox" id="setJoinOnStart"><span class="row-desc">Off: a guest player joins when someone picks the pad. Turn on for games that only look for players when they start.</span></label>
<label class="row"><span class="row-label">Pads always look connected</span><input class="check" type="checkbox" id="setAlwaysConnected"><span class="row-desc">Off: the game sees a pad's controller as switched off while nobody uses the pad.</span></label>
</div>
</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="keyboard" hidden>
<h2 class="page-title">Keyboard</h2>
<p class="hint">Click a key to change it, then press the new key. Keys drive the pad opened on this page.</p>
<div class="rows" id="keyRows"></div>
<div class="actions" style="margin-top:14px"><button class="btn" type="button" id="resetKeys">Reset keys</button></div>
</section>
<div class="save-bar" id="saveBar">
<span class="save-status" id="saveStatus">Changes to pads and players are saved on the console.</span>
<select class="field" id="saveScope" aria-label="Save for"></select>
<button class="btn primary" type="button" id="saveSettings">Save</button>
</div>
</div>
</div>
</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="menuSettings">All settings</button>
<button class="btn danger" type="button" id="leaveBtn">Leave the game</button>
</div>
</aside>
<div class="toast" id="toast" role="status" aria-live="polite">
<svg viewBox="0 0 32 32" aria-hidden="true"><path fill="#fff" d="M9 10h14a6 6 0 0 1 5.8 7.6l-1.2 4.6a3.4 3.4 0 0 1-5.9 1.3L18.5 20h-5l-3.2 3.5a3.4 3.4 0 0 1-5.9-1.3l-1.2-4.6A6 6 0 0 1 9 10z"/></svg>
<span id="toastText"></span>
</div>
<script>
(() => {
'use strict';
const PADS = 4;
const B = {
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 = 0x0010FFFE;
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,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';
const KEEP_AWAKE_WEBM = 'data:video/webm;base64,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';
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('rp.' + key); return v === null ? fallback : JSON.parse(v); }
catch (e) { return fallback; }
},
set(key, value) { try { localStorage.setItem('rp.' + key, JSON.stringify(value)); } catch (e) { /* private mode */ } },
};
// Settings of this device (the console's settings live on the console)
const DEVICE_SETTINGS = [
['keepAwake', 'Keep the screen on', 'While this page controls a pad, 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: '', protocol: 1, version: '', nextId: 1,
pending: new Map(), retry: 0, retryTimer: 0, everOpened: false,
};
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: 'Pad ' + (i + 1),
color: FALLBACK_COLORS[i], clients: 0, known: false,
}));
const servedByConsole = location.protocol === 'http:' || location.protocol === 'https:';
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', '');
}
function setConnState(state, text) {
conn.state = state;
$('#conn').dataset.conn = state;
$('#padView').dataset.conn = state;
$('#connText').textContent = text;
renderPadLabel();
}
function connect(address) {
clearTimeout(conn.retryTimer);
if (conn.ws) {
conn.ws.onclose = null;
conn.ws.close();
}
conn.address = address.trim().replace(/^wss?:\/\//, '').replace(/\/.*$/, '');
if (!conn.address) {
setConnState('closed', 'Not connected');
$('#addressForm').hidden = false;
return;
}
if (!servedByConsole) store.set('address', conn.address);
setConnState('connecting', conn.everOpened ? 'Waiting for the console (is a game running?)' : 'Connecting to ' + conn.address);
let ws;
try {
ws = new WebSocket('ws://' + conn.address);
} catch (e) {
setConnState('closed', 'Invalid address');
$('#addressForm').hidden = false;
return;
}
conn.ws = ws;
ws.onopen = () => {
conn.retry = 0;
conn.everOpened = true;
setConnState('open', 'Connected to ' + conn.address);
$('#addressForm').hidden = servedByConsole;
$('#addressHint').classList.remove('error');
call('info', []).then(info => {
conn.protocol = info.protocol || 1;
conn.version = info.version || '';
$('#version').textContent = 'RemotePad NG' + (conn.version ? ' ' + conn.version : '');
if (conn.protocol >= 2) sendClaim(true); else renderPads();
if (!$('#settingsView').hidden) loadSettings();
}).catch(() => {});
resendAll();
};
ws.onmessage = ev => onMessage(ev.data);
ws.onclose = () => {
conn.ws = null;
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 && !servedByConsole) {
setConnState('closed', 'Can\'t reach ' + conn.address);
$('#addressForm').hidden = false;
$('#addressHint').textContent = 'Can\'t reach the console. Start a game with the plugin loaded, then check the address.';
$('#addressHint').classList.add('error');
} else {
setConnState('connecting', 'Waiting for the console (is a game running?)');
}
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'));
}, 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 '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;
if (s.version) {
conn.version = s.version;
$('#version').textContent = 'RemotePad NG ' + s.version;
}
for (const p of s.pads) {
const st = status[p.pad];
if (!st) continue;
const wasOpen = st.open;
Object.assign(st, p, { known: true });
// The game just opened this pad: make sure it gets our current state right away
if (!wasOpen && st.open) lastSent[p.pad] = '';
}
renderPads();
renderPadLabel();
if (gamepadSources.size && !(document.activeElement && document.activeElement.closest('.seg'))) 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 farther(a, b) {
return Math.abs(b - 128) > Math.abs(a - 128) ? b : a;
}
function mergedState(pad) {
const s = blankSource(pad);
for (const src of allSources()) {
if (src.pad !== pad) continue;
s.buttons |= src.buttons;
s.lx = farther(s.lx, src.lx);
s.ly = farther(s.ly, src.ly);
s.rx = farther(s.rx, src.rx);
s.ry = farther(s.ry, src.ry);
s.l2 = Math.max(s.l2, src.l2);
s.r2 = Math.max(s.r2, src.r2);
if (src.touches.length) s.touches = s.touches.concat(src.touches);
}
if (s.buttons & B.L2) s.l2 = Math.max(s.l2, 255);
if (s.buttons & B.R2) s.r2 = Math.max(s.r2, 255);
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 = '';
function sendClaim(force) {
keepAwake.update();
if (conn.protocol < 2) return;
const pads = claimedPads();
const key = pads.join(',');
if (!force && key === lastClaim) return;
lastClaim = key;
call('claim', pads).then(applyStatus).catch(() => {});
}
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('');
}
// Leaving a pad: protocol 2 consoles release it when the claim changes (input would claim it again),
// protocol 1 consoles need a neutral state
function releasePad(pad) {
if (pad < 0) return;
if (conn.protocol < 2) send({ method: 'u', params: [pad, 0, 128, 128, 128, 128, 0, 0, 0] });
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);
}
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); }, left));
} else {
src.buttons &= ~bit;
}
}
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 = '';
shownStick[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);
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);
};
return {
move: update,
up() {
clearTimeout(holdTimer);
if (held) {
release(screenSource, B.TOUCH);
remove();
} else if (!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'],
['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]));
let keymap = Object.assign(defaultKeys(), 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 (listeningKey) return;
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', () => {
heldKeys.clear();
Object.assign(keySource, blankSource(keySource.pad));
});
// ---------------------------------------------------------------- gamepads
// Standard mapping: https://w3c.github.io/gamepad/#remapping
const GP_BUTTONS = [
B.CROSS, B.CIRCLE, B.SQUARE, B.TRIANGLE, B.L1, B.R1, 0, 0,
B.TOUCH, B.OPTIONS, B.L3, B.R3, B.UP, B.DOWN, B.LEFT, B.RIGHT, B.TOUCH, B.TOUCH,
];
const gpAssign = store.get('gamepads', {});
const gamepadsAvailable = typeof navigator.getGamepads === 'function';
function gpKey(gp) { return gp.id + '#' + gp.index; }
function axisByte(v) {
if (Math.abs(v) < 0.06) return 128;
return Math.round(clamp((v + 1) / 2 * 255, 0, 255));
}
function pollGamepads(t) {
if (!gamepadsAvailable) return;
let list;
try { list = navigator.getGamepads(); } catch (e) { 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);
if (!src) {
const saved = gpAssign[gpKey(gp)];
// A new controller drives the pad opened on this page; elsewhere it waits until you pick a pad
const pad = saved !== undefined ? saved : (primaryPad >= 0 && ![...gamepadSources.values()].some(s => s.pad === primaryPad) ? primaryPad : -1);
src = blankSource(pad);
src.gp = gp;
src.activeAt = 0;
gamepadSources.set(gp.index, src);
renderGamepads();
sendClaim();
toast(pad >= 0 ? 'Controller ' + (gp.index + 1) + ' plays as Pad ' + (pad + 1) : 'Controller ' + (gp.index + 1) + ' connected. Pick its pad on the home screen.');
}
src.gp = gp;
let buttons = 0;
gp.buttons.forEach((b, i) => {
const bit = GP_BUTTONS[i];
if (bit && (b.pressed || b.value > 0.5)) buttons |= bit;
});
const before = src.buttons + ',' + src.lx + ',' + src.ly + ',' + src.rx + ',' + src.ry + ',' + src.l2 + ',' + src.r2;
src.buttons = buttons;
src.l2 = gp.buttons[6] ? Math.round(gp.buttons[6].value * 255) : 0;
src.r2 = gp.buttons[7] ? Math.round(gp.buttons[7].value * 255) : 0;
src.lx = axisByte(gp.axes[0] || 0);
src.ly = axisByte(gp.axes[1] || 0);
src.rx = axisByte(gp.axes[2] || 0);
src.ry = axisByte(gp.axes[3] || 0);
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)) { 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;
// One controller per pad on this device: the controller that had this pad lets go of it
if (pad >= 0) {
for (const [other, s] of gamepadSources) {
if (other !== index && s.pad === pad) {
s.pad = -1;
if (s.gp) gpAssign[gpKey(s.gp)] = -1;
}
}
}
const old = src.pad;
src.pad = pad;
if (old >= 0 && old !== pad && !claimedPads().includes(old)) releasePad(old);
if (src.gp) gpAssign[gpKey(src.gp)] = pad;
store.set('gamepads', gpAssign);
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 src of gamepadSources.values()) {
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 src of gamepadSources.values()) {
const act = src.gp && src.gp.vibrationActuator;
if (src.pad === pad && act && act.playEffect) {
act.playEffect('dual-rumble', { startDelay: 0, duration: 150, strongMagnitude: r.large / 255, weakMagnitude: r.small / 255 }).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
// playing: a device drives the pad and the game reads it. joining: a device picked it, the game hasn't
// picked the player up yet. free: nobody uses it.
function padState(s) {
if (!s.known) return 'unknown';
if (!s.enabled) return 'disabled';
if (s.full) return 'noslot';
if (s.clients > 0) return s.open ? 'playing' : 'joining';
return 'free';
}
function padMeta(s) {
const meta = [];
if (s.player > 0) meta.push('Player ' + s.player);
if (s.real) meta.push(s.shared ? 'with the DualShock' : 'PS4 user ' + s.uid);
else if (s.known) meta.push(s.cfg ? 'Guest (' + s.cfg + ' isn\'t signed in)' : 'Guest');
return meta.join(' · ');
}
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 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');
body.appendChild(el('div', 'tile-name', s.known ? s.name : 'Pad ' + (i + 1)));
body.appendChild(el('div', 'tile-meta', padMeta(s) || (s.known ? '' : conn.protocol < 2 && conn.version ? 'Update the plugin for live status' : '')));
tile.appendChild(body);
const states = el('span', 'tile-state');
if (state === 'playing') states.appendChild(stateItem('live', 'Playing'));
else if (state === 'joining') states.appendChild(stateItem('wait', 'Joining the game'));
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);
});
}
function renderGamepads() {
const list = $('#gamepadList');
list.textContent = '';
if (!gamepadsAvailable) {
$('#gamepadHint').textContent = window.isSecureContext
? 'This browser does not support controllers.'
: 'This browser only allows controllers on secure pages. Save this page to your device, open the saved file, and enter the console address.';
return;
}
$('#gamepadHint').textContent = gamepadSources.size
? 'Pick the pad each controller plays as. A row lights up while you use its controller. Keep this window in front: browsers only read controllers on the active window.'
: 'Connect controllers to this device and press a button on each one. Each controller can play as a different pad.';
for (const [index, src] of gamepadSources) {
const row = el('li', 'gp-row');
row.dataset.index = index;
row.insertAdjacentHTML('afterbegin', '<svg viewBox="0 0 24 24" fill="currentColor" 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 7z"/></svg>');
const text = el('div');
const name = (src.gp && src.gp.id || 'Controller').replace(/\s*\(.*?\)\s*/g, ' ').trim();
text.appendChild(el('div', 'gp-name', name || 'Controller'));
const mapped = src.gp && src.gp.mapping === 'standard';
text.appendChild(el('div', 'gp-sub', 'Controller ' + (index + 1) + (mapped ? '' : ' · buttons may be mixed up')));
row.appendChild(text);
const seg = el('div', 'seg');
seg.setAttribute('role', 'radiogroup');
seg.setAttribute('aria-label', 'Pad for controller ' + (index + 1));
for (let pad = -1; pad < PADS; pad++) {
const b = el('button', '', pad < 0 ? 'Off' : String(pad + 1));
b.type = 'button';
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 = 'Pad ' + (pad + 1) + ' · ' + status[pad].name;
}
b.addEventListener('click', () => setGamepadPad(index, pad));
seg.appendChild(b);
}
row.appendChild(seg);
list.appendChild(row);
}
}
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');
label.textContent = 'Pad ' + (primaryPad + 1) + (s.known && s.name ? ' · ' + s.name : '');
const state = padState(s);
const extra = conn.state !== 'open' ? $('#connText').textContent
: state === 'playing' ? (s.player ? 'Player ' + s.player : 'Playing')
: state === 'joining' ? 'Joining the game'
: state === 'noslot' ? 'No free player slot'
: state === 'disabled' ? 'Turned off' : '';
if (extra) label.appendChild(el('span', 'muted', extra));
$('#leaveBtn').hidden = !(conn.protocol >= 2 && s.known && !s.real);
}
// Light up on-screen controls for every source driving this pad (keyboard and controllers too)
let shownButtons = -1, shownL2 = -1, shownR2 = -1;
const shownStick = { L: '', R: '' };
function renderPressed() {
if (primaryPad < 0) return;
const s = mergedState(primaryPad);
if (s.buttons !== shownButtons) {
shownButtons = s.buttons;
for (const k of $$('[data-btn]', padView)) k.classList.toggle('on', !!(s.buttons & B[k.dataset.btn]));
for (const a of $$('[data-arm]', padView)) a.classList.toggle('on', !!(s.buttons & B[a.dataset.arm]));
for (const st of $$('[data-stick]', padView)) st.classList.toggle('clicked', !!(s.buttons & B[st.dataset.stick + '3']));
touchpad.classList.toggle('clicked', !!(s.buttons & B.TOUCH));
}
if (s.l2 !== shownL2) { shownL2 = s.l2; $('[data-fill="L2"]').style.height = (s.l2 / 2.55) + '%'; }
if (s.r2 !== shownR2) { shownR2 = s.r2; $('[data-fill="R2"]').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 (draggingStick[side]) continue;
const x = side === 'L' ? s.lx : s.rx;
const y = side === 'L' ? s.ly : s.ry;
const key = x + ',' + y;
if (key === shownStick[side]) continue;
shownStick[side] = key;
const stick = $(`[data-stick="${side}"]`, padView);
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');
}
}
function openPad(pad) {
const s = status[pad];
if (s.known && !s.enabled) {
toast('Pad ' + (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 also using Pad ' + (pad + 1));
} else if (!store.get('padTips', false)) {
store.set('padTips', true);
toast('The PS button opens the menu. Double-tap a stick for L3 or R3.');
}
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 = 'Pad ' + (pad + 1) + ' · RemotePad NG';
history.replaceState(null, '', updateQuery('pad', String(pad + 1)));
shownButtons = -1;
shownStick.L = shownStick.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 = 'RemotePad NG';
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'));
const fsSupported = !!(document.documentElement.requestFullscreen || document.documentElement.webkitRequestFullscreen);
$('#fsBtn').hidden = !fsSupported;
$('#fsBtn').addEventListener('click', async () => {
try {
if (document.fullscreenElement) {
await document.exitFullscreen();
} 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() {
document.body.classList.remove('menu-open');
$('#optionsMenu').classList.remove('open');
}
$('#psBtn').addEventListener('click', () => { if (!editor.active) openMenu(); });
$('#shareBtn').addEventListener('click', () => { if (!editor.active) toast('The PS4 keeps SHARE for itself, so games never see it'); });
$('#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();
try {
await call('leave', [pad]);
toast('Pad ' + (pad + 1) + ' left the game');
} catch (e) {
toast(e && e.message ? 'Could not leave: ' + e.message : 'Could not leave the game');
}
});
// ---------------------------------------------------------------- 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
let listeningKey = null;
function renderKeyRows() {
const rows = $('#keyRows');
rows.textContent = '';
for (const [action, label] of KEY_ACTIONS) {
const row = el('div', 'key-row');
row.appendChild(el('span', 'row-label', label));
const b = el('button', 'key-btn', keyLabel(keymap[action]));
b.type = 'button';
b.addEventListener('click', () => {
if (listeningKey) listeningKey.button.classList.remove('listening');
listeningKey = { action, button: b };
b.classList.add('listening');
b.textContent = 'Press a key';
});
row.appendChild(b);
rows.appendChild(row);
}
}
window.addEventListener('keydown', e => {
if (!listeningKey) return;
e.preventDefault();
e.stopPropagation();
const { action } = listeningKey;
// A key drives one action: whatever had it before loses it
for (const [other, code] of Object.entries(keymap)) if (code === e.code && other !== action) keymap[other] = '';
keymap[action] = e.code;
store.set('keymap', keymap);
listeningKey = null;
rebuildKeymap();
renderKeyRows();
}, true);
$('#resetKeys').addEventListener('click', () => {
keymap = defaultKeys();
store.set('keymap', keymap);
rebuildKeymap();
renderKeyRows();
toast('Keyboard keys reset');
});
rebuildKeymap();
renderKeyRows();
// ---------------------------------------------------------------- console settings
let consoleSettings = null;
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;
$('#saveBar').hidden = !(page === 'pads' || page === 'players');
if (listeningKey) { listeningKey.button.classList.remove('listening'); listeningKey = null; renderKeyRows(); }
}
for (const tab of $$('.nav-item')) tab.addEventListener('click', () => showPage(tab.dataset.page));
function openSettings(page) {
$('#home').hidden = true;
padView.hidden = true;
document.body.classList.remove('in-pad');
$('#settingsView').hidden = false;
showPage(page || 'pads');
loadSettings();
}
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('pads'));
$('#settingsBack').addEventListener('click', closeSettings);
$('#keysLink').addEventListener('click', () => openSettings('keyboard'));
async function loadSettings() {
$('#saveStatus').textContent = 'Loading the console\'s settings…';
try {
if (conn.protocol < 2) throw new Error('old');
consoleSettings = await call('settings', []);
$('#settingsUnavailable').hidden = true;
renderConsoleSettings();
$('#saveStatus').textContent = consoleSettings.locked
? 'Locked on the console (lock_settings=1 in remote_pad.ini): you can see these settings but not change them.'
: 'Changes apply right away and are saved on the console.';
$('#saveSettings').disabled = !!consoleSettings.locked;
} catch (e) {
consoleSettings = null;
$('#padSettings').textContent = '';
$('#settingsUnavailable').hidden = conn.state !== 'open';
$('#saveStatus').textContent = conn.state === 'open' ? 'This plugin version has no settings page.' : 'Not connected to the console.';
$('#saveSettings').disabled = true;
}
}
function userOptionLabel(u) {
const parts = [u.name || 'PS4 user', u.id];
if (u.signedIn) parts.push('signed in');
return parts.join(' · ');
}
function renderConsoleSettings() {
const cs = consoleSettings;
const container = $('#padSettings');
container.textContent = '';
cs.pads.forEach((pad, i) => {
const card = el('div', 'pad-card');
card.dataset.pad = i;
const head = el('div', 'pad-card-head');
head.style.setProperty('--pc', css(status[i].color));
head.appendChild(el('span', 'ring', String(i + 1)));
const title = el('h3', '', 'Pad ' + (i + 1));
head.appendChild(title);
const onLabel = el('label', 'switch-inline');
const on = el('input', 'check');
on.type = 'checkbox';
on.checked = pad.enabled;
on.dataset.field = 'enabled';
onLabel.appendChild(on);
onLabel.appendChild(document.createTextNode('On'));
head.appendChild(onLabel);
card.appendChild(head);
const nameRow = el('label', 'row');
nameRow.appendChild(el('span', 'row-label', 'Name'));
const name = el('input', 'field');
name.type = 'text';
name.maxLength = 16;
name.placeholder = 'Default name';
name.value = pad.name;
name.dataset.field = 'name';
nameRow.appendChild(name);
nameRow.appendChild(el('span', 'row-desc', '3 to 16 characters. Empty: the PS4 user\'s own name, or "Remote' + (i + 1) + '".'));
card.appendChild(nameRow);
const userRow = el('div', 'row');
userRow.appendChild(el('span', 'row-label', 'PS4 user'));
const user = el('select', 'field');
user.dataset.field = 'user';
user.appendChild(new Option('None, always a guest', ''));
const known = new Set();
for (const u of cs.users) {
known.add(u.id);
user.appendChild(new Option(userOptionLabel(u), u.id));
}
if (pad.id && !known.has(pad.id)) user.appendChild(new Option('PS4 user ' + pad.id, pad.id));
user.appendChild(new Option('Type an ID…', '__custom'));
user.value = pad.id || '';
userRow.appendChild(user);
userRow.appendChild(el('span', 'row-desc', 'Plays as this user while they\'re signed in on the PS4, as a guest otherwise.'));
card.appendChild(userRow);
const customRow = el('label', 'row');
customRow.hidden = true;
customRow.appendChild(el('span', 'row-label', 'User ID'));
const custom = el('input', 'field');
custom.type = 'text';
custom.placeholder = '1A2B3C4D';
custom.autocapitalize = 'characters';
custom.spellcheck = false;
custom.dataset.field = 'customId';
customRow.appendChild(custom);
const customHint = 'The ID Apollo shows on its About page (hex), or the decimal number.';
const customDesc = el('span', 'row-desc', customHint);
customRow.appendChild(customDesc);
// Show how the ID will be read, since an ID made of digits only could be hex or decimal
custom.addEventListener('input', () => {
const id = parseUserId(custom.value);
customDesc.textContent = id ? 'Saved as PS4 user ' + id + '.' : custom.value.trim() ? 'That isn\'t a PS4 user ID.' : customHint;
});
card.appendChild(customRow);
const shareRow = el('label', 'row');
shareRow.appendChild(el('span', 'row-label', 'Use with the DualShock'));
const share = el('input', 'check');
share.type = 'checkbox';
share.checked = pad.share;
share.dataset.field = 'share';
shareRow.appendChild(share);
shareRow.appendChild(el('span', 'row-desc', 'The user\'s own controller keeps working and this pad adds to it, like a second controller for the same player. Off: this pad replaces it.'));
card.appendChild(shareRow);
const sync = () => {
customRow.hidden = user.value !== '__custom';
shareRow.hidden = user.value === '';
};
user.addEventListener('change', () => { sync(); if (user.value === '__custom') custom.focus(); });
sync();
container.appendChild(card);
});
$('#setJoinOnStart').checked = cs.joinOnStart;
$('#setAlwaysConnected').checked = cs.alwaysConnected;
const scope = $('#saveScope');
scope.textContent = '';
scope.appendChild(new Option('For all games', 'all'));
if (cs.title) scope.appendChild(new Option('Only for this game (' + cs.title + ')', 'game'));
scope.value = cs.gameOnly && cs.title ? 'game' : 'all';
// lock_settings=1 on the console: show the settings, don't let them change
for (const input of $$('#padSettings input, #padSettings select, #setJoinOnStart, #setAlwaysConnected, #saveScope')) {
input.disabled = !!cs.locked;
}
}
// A typed PS4 user ID as 8 hex digits, or '' when it isn't one. 9-10 digits are decimal (as the old wiki
// explained), anything else is hex as Apollo shows it.
function parseUserId(text) {
const typed = text.trim();
let value = -1;
if (/^\d{9,10}$/.test(typed)) value = Number(typed);
else if (/^(0x)?[0-9a-f]{1,8}$/i.test(typed)) value = parseInt(typed.replace(/^0x/i, ''), 16);
if (!(value > 0 && value < 0xFFFFFFFF)) return '';
return value.toString(16).toUpperCase().padStart(8, '0');
}
function readConsoleSettings() {
const pads = [];
for (const card of $$('#padSettings .pad-card')) {
const field = name => $(`[data-field="${name}"]`, card);
const user = field('user').value;
const name = field('name').value.trim();
const padNo = Number(card.dataset.pad) + 1;
if (name && (name.length < 3 || name.includes(';'))) throw new Error('Pad ' + padNo + ': a name needs 3 to 16 characters and no ";"');
// Ids go out with 0x: an all-digit hex id like 10000001 would otherwise be read as decimal
let id = '';
if (user === '__custom') {
const hex = parseUserId(field('customId').value);
if (!hex) throw new Error('Pad ' + padNo + ': enter the user ID shown by Apollo');
id = '0x' + hex;
} else if (user) {
id = '0x' + user;
}
pads.push({ enabled: field('enabled').checked, id, share: field('share').checked && id !== '', name });
}
return {
gameOnly: $('#saveScope').value === 'game',
joinOnStart: $('#setJoinOnStart').checked,
alwaysConnected: $('#setAlwaysConnected').checked,
pads,
};
}
$('#saveSettings').addEventListener('click', async () => {
let params;
try {
params = readConsoleSettings();
} catch (e) {
$('#saveStatus').textContent = e.message;
toast(e.message);
return;
}
$('#saveSettings').disabled = true;
$('#saveStatus').textContent = 'Saving…';
try {
const result = await call('settings.set', params);
consoleSettings = result.settings;
renderConsoleSettings();
const text = result.saved
? 'Saved. New PS4 users apply fully the next time the game starts.'
: 'Applied, but the console could not save them. They last until the game closes.';
$('#saveStatus').textContent = text;
toast(result.saved ? 'Settings saved' : 'Settings applied, not saved');
} catch (e) {
const text = (e && e.message) || 'Could not save the settings';
$('#saveStatus').textContent = text;
toast(text);
} finally {
$('#saveSettings').disabled = false;
}
});
// ---------------------------------------------------------------- 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) {
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();
keepAwake.off();
} else {
resendAll();
sendClaim(true);
}
});
function frame(t) {
pollGamepads(t);
flush(t);
driveRumble(t);
renderPressed();
requestAnimationFrame(frame);
}
window.addEventListener('gamepadconnected', () => { pollGamepads(now()); renderGamepads(); });
window.addEventListener('gamepaddisconnected', () => pollGamepads(now()));
renderPads();
renderGamepads();
const address = initialAddress();
$('#address').value = address;
if (!servedByConsole) $('#addressForm').hidden = false;
connect(address);
const startPad = Number(new URLSearchParams(location.search).get('pad'));
if (startPad >= 1 && startPad <= PADS) openPad(startPad - 1);
requestAnimationFrame(frame);
})();
</script>
</body>
</html>